1use std::ffi::OsStr;
21use std::path::{Path, PathBuf};
22use std::process::Command;
23use std::{fmt, fs, io};
24
25const MIN_PY_REV: (u32, u32) = (3, 9);
26const MIN_PY_REV_STR: &str = "≥3.9";
27
28#[cfg(target_os = "windows")]
30const REL_PY_PATH: &[&str] = &["Scripts", "python3.exe"];
31#[cfg(not(target_os = "windows"))]
32const REL_PY_PATH: &[&str] = &["bin", "python3"];
33
34const RUFF_CONFIG_PATH: &[&str] = &["src", "tools", "tidy", "config", "ruff.toml"];
35const RUFF_CACHE_PATH: &[&str] = &["cache", "ruff_cache"];
37const PIP_REQ_PATH: &[&str] = &["src", "tools", "tidy", "config", "requirements.txt"];
38
39pub fn check(
40 root_path: &Path,
41 outdir: &Path,
42 bless: bool,
43 extra_checks: Option<&str>,
44 pos_args: &[String],
45 bad: &mut bool,
46) {
47 if let Err(e) = check_impl(root_path, outdir, bless, extra_checks, pos_args) {
48 tidy_error!(bad, "{e}");
49 }
50}
51
52fn check_impl(
53 root_path: &Path,
54 outdir: &Path,
55 bless: bool,
56 extra_checks: Option<&str>,
57 pos_args: &[String],
58) -> Result<(), Error> {
59 let show_diff = std::env::var("TIDY_PRINT_DIFF")
60 .map_or(false, |v| v.eq_ignore_ascii_case("true") || v == "1");
61
62 let lint_args = match extra_checks {
64 Some(s) => s.strip_prefix("--extra-checks=").unwrap().split(',').collect(),
65 None => vec![],
66 };
67
68 let python_all = lint_args.contains(&"py");
69 let python_lint = lint_args.contains(&"py:lint") || python_all;
70 let python_fmt = lint_args.contains(&"py:fmt") || python_all;
71 let shell_all = lint_args.contains(&"shell");
72 let shell_lint = lint_args.contains(&"shell:lint") || shell_all;
73 let cpp_all = lint_args.contains(&"cpp");
74 let cpp_fmt = lint_args.contains(&"cpp:fmt") || cpp_all;
75
76 let mut py_path = None;
77
78 let (cfg_args, file_args): (Vec<_>, Vec<_>) = pos_args
79 .iter()
80 .map(OsStr::new)
81 .partition(|arg| arg.to_str().is_some_and(|s| s.starts_with('-')));
82
83 if python_lint || python_fmt || cpp_fmt {
84 let venv_path = outdir.join("venv");
85 let mut reqs_path = root_path.to_owned();
86 reqs_path.extend(PIP_REQ_PATH);
87 py_path = Some(get_or_create_venv(&venv_path, &reqs_path)?);
88 }
89
90 if python_lint {
91 eprintln!("linting python files");
92 let py_path = py_path.as_ref().unwrap();
93 let res = run_ruff(root_path, outdir, py_path, &cfg_args, &file_args, &["check".as_ref()]);
94
95 if res.is_err() && show_diff {
96 eprintln!("\npython linting failed! Printing diff suggestions:");
97
98 let _ = run_ruff(
99 root_path,
100 outdir,
101 py_path,
102 &cfg_args,
103 &file_args,
104 &["check".as_ref(), "--diff".as_ref()],
105 );
106 }
107 let _ = res?;
109 }
110
111 if python_fmt {
112 let mut args: Vec<&OsStr> = vec!["format".as_ref()];
113 if bless {
114 eprintln!("formatting python files");
115 } else {
116 eprintln!("checking python file formatting");
117 args.push("--check".as_ref());
118 }
119
120 let py_path = py_path.as_ref().unwrap();
121 let res = run_ruff(root_path, outdir, py_path, &cfg_args, &file_args, &args);
122
123 if res.is_err() && !bless {
124 if show_diff {
125 eprintln!("\npython formatting does not match! Printing diff:");
126
127 let _ = run_ruff(
128 root_path,
129 outdir,
130 py_path,
131 &cfg_args,
132 &file_args,
133 &["format".as_ref(), "--diff".as_ref()],
134 );
135 }
136 eprintln!("rerun tidy with `--extra-checks=py:fmt --bless` to reformat Python code");
137 }
138
139 let _ = res?;
141 }
142
143 if cpp_fmt {
144 let mut cfg_args_clang_format = cfg_args.clone();
145 let mut file_args_clang_format = file_args.clone();
146 let config_path = root_path.join(".clang-format");
147 let config_file_arg = format!("file:{}", config_path.display());
148 cfg_args_clang_format.extend(&["--style".as_ref(), config_file_arg.as_ref()]);
149 if bless {
150 eprintln!("formatting C++ files");
151 cfg_args_clang_format.push("-i".as_ref());
152 } else {
153 eprintln!("checking C++ file formatting");
154 cfg_args_clang_format.extend(&["--dry-run".as_ref(), "--Werror".as_ref()]);
155 }
156 let files;
157 if file_args_clang_format.is_empty() {
158 let llvm_wrapper = root_path.join("compiler/rustc_llvm/llvm-wrapper");
159 files = find_with_extension(
160 root_path,
161 Some(llvm_wrapper.as_path()),
162 &[OsStr::new("h"), OsStr::new("cpp")],
163 )?;
164 file_args_clang_format.extend(files.iter().map(|p| p.as_os_str()));
165 }
166 let args = merge_args(&cfg_args_clang_format, &file_args_clang_format);
167 let res = py_runner(py_path.as_ref().unwrap(), false, None, "clang-format", &args);
168
169 if res.is_err() && show_diff {
170 eprintln!("\nclang-format linting failed! Printing diff suggestions:");
171
172 let mut cfg_args_clang_format_diff = cfg_args.clone();
173 cfg_args_clang_format_diff.extend(&["--style".as_ref(), config_file_arg.as_ref()]);
174 for file in file_args_clang_format {
175 let mut formatted = String::new();
176 let mut diff_args = cfg_args_clang_format_diff.clone();
177 diff_args.push(file);
178 let _ = py_runner(
179 py_path.as_ref().unwrap(),
180 false,
181 Some(&mut formatted),
182 "clang-format",
183 &diff_args,
184 );
185 if formatted.is_empty() {
186 eprintln!(
187 "failed to obtain the formatted content for '{}'",
188 file.to_string_lossy()
189 );
190 continue;
191 }
192 let actual = std::fs::read_to_string(file).unwrap_or_else(|e| {
193 panic!(
194 "failed to read the C++ file at '{}' due to '{e}'",
195 file.to_string_lossy()
196 )
197 });
198 if formatted != actual {
199 let diff = similar::TextDiff::from_lines(&actual, &formatted);
200 eprintln!(
201 "{}",
202 diff.unified_diff().context_radius(4).header(
203 &format!("{} (actual)", file.to_string_lossy()),
204 &format!("{} (formatted)", file.to_string_lossy())
205 )
206 );
207 }
208 }
209 }
210 let _ = res?;
212 }
213
214 if shell_lint {
215 eprintln!("linting shell files");
216
217 let mut file_args_shc = file_args.clone();
218 let files;
219 if file_args_shc.is_empty() {
220 files = find_with_extension(root_path, None, &[OsStr::new("sh")])?;
221 file_args_shc.extend(files.iter().map(|p| p.as_os_str()));
222 }
223
224 shellcheck_runner(&merge_args(&cfg_args, &file_args_shc))?;
225 }
226
227 Ok(())
228}
229
230fn run_ruff(
231 root_path: &Path,
232 outdir: &Path,
233 py_path: &Path,
234 cfg_args: &[&OsStr],
235 file_args: &[&OsStr],
236 ruff_args: &[&OsStr],
237) -> Result<(), Error> {
238 let mut cfg_args_ruff = cfg_args.into_iter().copied().collect::<Vec<_>>();
239 let mut file_args_ruff = file_args.into_iter().copied().collect::<Vec<_>>();
240
241 let mut cfg_path = root_path.to_owned();
242 cfg_path.extend(RUFF_CONFIG_PATH);
243 let mut cache_dir = outdir.to_owned();
244 cache_dir.extend(RUFF_CACHE_PATH);
245
246 cfg_args_ruff.extend([
247 "--config".as_ref(),
248 cfg_path.as_os_str(),
249 "--cache-dir".as_ref(),
250 cache_dir.as_os_str(),
251 ]);
252
253 if file_args_ruff.is_empty() {
254 file_args_ruff.push(root_path.as_os_str());
255 }
256
257 let mut args: Vec<&OsStr> = ruff_args.into_iter().copied().collect();
258 args.extend(merge_args(&cfg_args_ruff, &file_args_ruff));
259 py_runner(py_path, true, None, "ruff", &args)
260}
261
262fn merge_args<'a>(cfg_args: &[&'a OsStr], file_args: &[&'a OsStr]) -> Vec<&'a OsStr> {
264 let mut args = cfg_args.to_owned();
265 args.push("--".as_ref());
266 args.extend(file_args);
267 args
268}
269
270fn py_runner(
274 py_path: &Path,
275 as_module: bool,
276 stdout: Option<&mut String>,
277 bin: &'static str,
278 args: &[&OsStr],
279) -> Result<(), Error> {
280 let mut cmd = Command::new(py_path);
281 if as_module {
282 cmd.arg("-m").arg(bin).args(args);
283 } else {
284 let bin_path = py_path.with_file_name(bin);
285 cmd.arg(bin_path).args(args);
286 }
287 let status = if let Some(stdout) = stdout {
288 let output = cmd.output()?;
289 if let Ok(s) = std::str::from_utf8(&output.stdout) {
290 stdout.push_str(s);
291 }
292 output.status
293 } else {
294 cmd.status()?
295 };
296 if status.success() { Ok(()) } else { Err(Error::FailedCheck(bin)) }
297}
298
299fn get_or_create_venv(venv_path: &Path, src_reqs_path: &Path) -> Result<PathBuf, Error> {
302 let mut should_create = true;
303 let dst_reqs_path = venv_path.join("requirements.txt");
304 let mut py_path = venv_path.to_owned();
305 py_path.extend(REL_PY_PATH);
306
307 if let Ok(req) = fs::read_to_string(&dst_reqs_path) {
308 if req == fs::read_to_string(src_reqs_path)? {
309 should_create = false;
311 } else {
312 eprintln!("requirements.txt file mismatch, recreating environment");
313 }
314 }
315
316 if should_create {
317 eprintln!("removing old virtual environment");
318 if venv_path.is_dir() {
319 fs::remove_dir_all(venv_path).unwrap_or_else(|_| {
320 panic!("failed to remove directory at {}", venv_path.display())
321 });
322 }
323 create_venv_at_path(venv_path)?;
324 install_requirements(&py_path, src_reqs_path, &dst_reqs_path)?;
325 }
326
327 verify_py_version(&py_path)?;
328 Ok(py_path)
329}
330
331fn create_venv_at_path(path: &Path) -> Result<(), Error> {
334 const TRY_PY: &[&str] = &[
337 "python3.13",
338 "python3.12",
339 "python3.11",
340 "python3.10",
341 "python3.9",
342 "python3",
343 "python",
344 "python3.14",
345 ];
346
347 let mut sys_py = None;
348 let mut found = Vec::new();
349
350 for py in TRY_PY {
351 match verify_py_version(Path::new(py)) {
352 Ok(_) => {
353 sys_py = Some(*py);
354 break;
355 }
356 Err(Error::Io(e)) if e.kind() == io::ErrorKind::NotFound => (),
358 Err(Error::Version { installed, .. }) => found.push(installed),
360 Err(e) => eprintln!("note: error running '{py}': {e}"),
362 }
363 }
364
365 let Some(sys_py) = sys_py else {
366 let ret = if found.is_empty() {
367 Error::MissingReq("python3", "python file checks", None)
368 } else {
369 found.sort();
370 found.dedup();
371 Error::Version {
372 program: "python3",
373 required: MIN_PY_REV_STR,
374 installed: found.join(", "),
375 }
376 };
377 return Err(ret);
378 };
379
380 if try_create_venv(sys_py, path, "venv").is_ok() {
384 return Ok(());
385 }
386 try_create_venv(sys_py, path, "virtualenv")
387}
388
389fn try_create_venv(python: &str, path: &Path, module: &str) -> Result<(), Error> {
390 eprintln!(
391 "creating virtual environment at '{}' using '{python}' and '{module}'",
392 path.display()
393 );
394 let out = Command::new(python).args(["-m", module]).arg(path).output().unwrap();
395
396 if out.status.success() {
397 return Ok(());
398 }
399
400 let stderr = String::from_utf8_lossy(&out.stderr);
401 let err = if stderr.contains(&format!("No module named {module}")) {
402 Error::Generic(format!(
403 r#"{module} not found: you may need to install it:
404`{python} -m pip install {module}`
405If you see an error about "externally managed environment" when running the above command,
406either install `{module}` using your system package manager
407(e.g. `sudo apt-get install {python}-{module}`) or create a virtual environment manually, install
408`{module}` in it and then activate it before running tidy.
409"#
410 ))
411 } else {
412 Error::Generic(format!(
413 "failed to create venv at '{}' using {python} -m {module}: {stderr}",
414 path.display()
415 ))
416 };
417 Err(err)
418}
419
420fn verify_py_version(py_path: &Path) -> Result<(), Error> {
423 let out = Command::new(py_path).arg("--version").output()?;
424 let outstr = String::from_utf8_lossy(&out.stdout);
425 let vers = outstr.trim().split_ascii_whitespace().nth(1).unwrap().trim();
426 let mut vers_comps = vers.split('.');
427 let major: u32 = vers_comps.next().unwrap().parse().unwrap();
428 let minor: u32 = vers_comps.next().unwrap().parse().unwrap();
429
430 if (major, minor) < MIN_PY_REV {
431 Err(Error::Version {
432 program: "python",
433 required: MIN_PY_REV_STR,
434 installed: vers.to_owned(),
435 })
436 } else {
437 Ok(())
438 }
439}
440
441fn install_requirements(
442 py_path: &Path,
443 src_reqs_path: &Path,
444 dst_reqs_path: &Path,
445) -> Result<(), Error> {
446 let stat = Command::new(py_path)
447 .args(["-m", "pip", "install", "--upgrade", "pip"])
448 .status()
449 .expect("failed to launch pip");
450 if !stat.success() {
451 return Err(Error::Generic(format!("pip install failed with status {stat}")));
452 }
453
454 let stat = Command::new(py_path)
455 .args(["-m", "pip", "install", "--quiet", "--require-hashes", "-r"])
456 .arg(src_reqs_path)
457 .status()?;
458 if !stat.success() {
459 return Err(Error::Generic(format!(
460 "failed to install requirements at {}",
461 src_reqs_path.display()
462 )));
463 }
464 fs::copy(src_reqs_path, dst_reqs_path)?;
465 assert_eq!(
466 fs::read_to_string(src_reqs_path).unwrap(),
467 fs::read_to_string(dst_reqs_path).unwrap()
468 );
469 Ok(())
470}
471
472fn shellcheck_runner(args: &[&OsStr]) -> Result<(), Error> {
474 match Command::new("shellcheck").arg("--version").status() {
475 Ok(_) => (),
476 Err(e) if e.kind() == io::ErrorKind::NotFound => {
477 return Err(Error::MissingReq(
478 "shellcheck",
479 "shell file checks",
480 Some(
481 "see <https://github.com/koalaman/shellcheck#installing> \
482 for installation instructions"
483 .to_owned(),
484 ),
485 ));
486 }
487 Err(e) => return Err(e.into()),
488 }
489
490 let status = Command::new("shellcheck").args(args).status()?;
491 if status.success() { Ok(()) } else { Err(Error::FailedCheck("shellcheck")) }
492}
493
494fn find_with_extension(
496 root_path: &Path,
497 find_dir: Option<&Path>,
498 extensions: &[&OsStr],
499) -> Result<Vec<PathBuf>, Error> {
500 let stat_output =
503 Command::new("git").arg("-C").arg(root_path).args(["status", "--short"]).output()?.stdout;
504
505 if String::from_utf8_lossy(&stat_output).lines().filter(|ln| ln.starts_with('?')).count() > 0 {
506 eprintln!("found untracked files, ignoring");
507 }
508
509 let mut output = Vec::new();
510 let binding = {
511 let mut command = Command::new("git");
512 command.arg("-C").arg(root_path).args(["ls-files"]);
513 if let Some(find_dir) = find_dir {
514 command.arg(find_dir);
515 }
516 command.output()?
517 };
518 let tracked = String::from_utf8_lossy(&binding.stdout);
519
520 for line in tracked.lines() {
521 let line = line.trim();
522 let path = Path::new(line);
523
524 let Some(ref extension) = path.extension() else {
525 continue;
526 };
527 if extensions.contains(extension) {
528 output.push(root_path.join(path));
529 }
530 }
531
532 Ok(output)
533}
534
535#[derive(Debug)]
536enum Error {
537 Io(io::Error),
538 MissingReq(&'static str, &'static str, Option<String>),
540 FailedCheck(&'static str),
542 Generic(String),
544 Version {
546 program: &'static str,
547 required: &'static str,
548 installed: String,
549 },
550}
551
552impl fmt::Display for Error {
553 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
554 match self {
555 Self::MissingReq(a, b, ex) => {
556 write!(
557 f,
558 "{a} is required to run {b} but it could not be located. Is it installed?"
559 )?;
560 if let Some(s) = ex {
561 write!(f, "\n{s}")?;
562 };
563 Ok(())
564 }
565 Self::Version { program, required, installed } => write!(
566 f,
567 "insufficient version of '{program}' to run external tools: \
568 {required} required but found {installed}",
569 ),
570 Self::Generic(s) => f.write_str(s),
571 Self::Io(e) => write!(f, "IO error: {e}"),
572 Self::FailedCheck(s) => write!(f, "checks with external tool '{s}' failed"),
573 }
574 }
575}
576
577impl From<io::Error> for Error {
578 fn from(value: io::Error) -> Self {
579 Self::Io(value)
580 }
581}