1use std::ffi::OsStr;
21use std::path::{Path, PathBuf};
22use std::process::Command;
23use std::str::FromStr;
24use std::{fmt, fs, io};
25
26use crate::CiInfo;
27
28const MIN_PY_REV: (u32, u32) = (3, 9);
29const MIN_PY_REV_STR: &str = "≥3.9";
30
31#[cfg(target_os = "windows")]
33const REL_PY_PATH: &[&str] = &["Scripts", "python3.exe"];
34#[cfg(not(target_os = "windows"))]
35const REL_PY_PATH: &[&str] = &["bin", "python3"];
36
37const RUFF_CONFIG_PATH: &[&str] = &["src", "tools", "tidy", "config", "ruff.toml"];
38const RUFF_CACHE_PATH: &[&str] = &["cache", "ruff_cache"];
40const PIP_REQ_PATH: &[&str] = &["src", "tools", "tidy", "config", "requirements.txt"];
41
42const SPELLCHECK_DIRS: &[&str] = &["compiler", "library", "src/bootstrap", "src/librustdoc"];
44
45pub fn check(
46 root_path: &Path,
47 outdir: &Path,
48 ci_info: &CiInfo,
49 bless: bool,
50 extra_checks: Option<&str>,
51 pos_args: &[String],
52 bad: &mut bool,
53) {
54 if let Err(e) = check_impl(root_path, outdir, ci_info, bless, extra_checks, pos_args) {
55 tidy_error!(bad, "{e}");
56 }
57}
58
59fn check_impl(
60 root_path: &Path,
61 outdir: &Path,
62 ci_info: &CiInfo,
63 bless: bool,
64 extra_checks: Option<&str>,
65 pos_args: &[String],
66) -> Result<(), Error> {
67 let show_diff =
68 std::env::var("TIDY_PRINT_DIFF").is_ok_and(|v| v.eq_ignore_ascii_case("true") || v == "1");
69
70 let lint_args = match extra_checks {
72 Some(s) => s
73 .strip_prefix("--extra-checks=")
74 .unwrap()
75 .split(',')
76 .map(|s| {
77 if s == "spellcheck:fix" {
78 eprintln!("warning: `spellcheck:fix` is no longer valid, use `--extra-checks=spellcheck --bless`");
79 }
80 (ExtraCheckArg::from_str(s), s)
81 })
82 .filter_map(|(res, src)| match res {
83 Ok(arg) => {
84 if arg.is_inactive_auto(ci_info) {
85 None
86 } else {
87 Some(arg)
88 }
89 }
90 Err(err) => {
91 eprintln!("warning: bad extra check argument {src:?}: {err:?}");
93 None
94 }
95 })
96 .collect(),
97 None => vec![],
98 };
99
100 macro_rules! extra_check {
101 ($lang:ident, $kind:ident) => {
102 lint_args.iter().any(|arg| arg.matches(ExtraCheckLang::$lang, ExtraCheckKind::$kind))
103 };
104 }
105
106 let python_lint = extra_check!(Py, Lint);
107 let python_fmt = extra_check!(Py, Fmt);
108 let shell_lint = extra_check!(Shell, Lint);
109 let cpp_fmt = extra_check!(Cpp, Fmt);
110 let spellcheck = extra_check!(Spellcheck, None);
111
112 let mut py_path = None;
113
114 let (cfg_args, file_args): (Vec<_>, Vec<_>) = pos_args
115 .iter()
116 .map(OsStr::new)
117 .partition(|arg| arg.to_str().is_some_and(|s| s.starts_with('-')));
118
119 if python_lint || python_fmt || cpp_fmt {
120 let venv_path = outdir.join("venv");
121 let mut reqs_path = root_path.to_owned();
122 reqs_path.extend(PIP_REQ_PATH);
123 py_path = Some(get_or_create_venv(&venv_path, &reqs_path)?);
124 }
125
126 if python_lint {
127 eprintln!("linting python files");
128 let py_path = py_path.as_ref().unwrap();
129 let res = run_ruff(root_path, outdir, py_path, &cfg_args, &file_args, &["check".as_ref()]);
130
131 if res.is_err() && show_diff {
132 eprintln!("\npython linting failed! Printing diff suggestions:");
133
134 let _ = run_ruff(
135 root_path,
136 outdir,
137 py_path,
138 &cfg_args,
139 &file_args,
140 &["check".as_ref(), "--diff".as_ref()],
141 );
142 }
143 res?;
145 }
146
147 if python_fmt {
148 let mut args: Vec<&OsStr> = vec!["format".as_ref()];
149 if bless {
150 eprintln!("formatting python files");
151 } else {
152 eprintln!("checking python file formatting");
153 args.push("--check".as_ref());
154 }
155
156 let py_path = py_path.as_ref().unwrap();
157 let res = run_ruff(root_path, outdir, py_path, &cfg_args, &file_args, &args);
158
159 if res.is_err() && !bless {
160 if show_diff {
161 eprintln!("\npython formatting does not match! Printing diff:");
162
163 let _ = run_ruff(
164 root_path,
165 outdir,
166 py_path,
167 &cfg_args,
168 &file_args,
169 &["format".as_ref(), "--diff".as_ref()],
170 );
171 }
172 eprintln!("rerun tidy with `--extra-checks=py:fmt --bless` to reformat Python code");
173 }
174
175 res?;
177 }
178
179 if cpp_fmt {
180 let mut cfg_args_clang_format = cfg_args.clone();
181 let mut file_args_clang_format = file_args.clone();
182 let config_path = root_path.join(".clang-format");
183 let config_file_arg = format!("file:{}", config_path.display());
184 cfg_args_clang_format.extend(&["--style".as_ref(), config_file_arg.as_ref()]);
185 if bless {
186 eprintln!("formatting C++ files");
187 cfg_args_clang_format.push("-i".as_ref());
188 } else {
189 eprintln!("checking C++ file formatting");
190 cfg_args_clang_format.extend(&["--dry-run".as_ref(), "--Werror".as_ref()]);
191 }
192 let files;
193 if file_args_clang_format.is_empty() {
194 let llvm_wrapper = root_path.join("compiler/rustc_llvm/llvm-wrapper");
195 files = find_with_extension(
196 root_path,
197 Some(llvm_wrapper.as_path()),
198 &[OsStr::new("h"), OsStr::new("cpp")],
199 )?;
200 file_args_clang_format.extend(files.iter().map(|p| p.as_os_str()));
201 }
202 let args = merge_args(&cfg_args_clang_format, &file_args_clang_format);
203 let res = py_runner(py_path.as_ref().unwrap(), false, None, "clang-format", &args);
204
205 if res.is_err() && show_diff {
206 eprintln!("\nclang-format linting failed! Printing diff suggestions:");
207
208 let mut cfg_args_clang_format_diff = cfg_args.clone();
209 cfg_args_clang_format_diff.extend(&["--style".as_ref(), config_file_arg.as_ref()]);
210 for file in file_args_clang_format {
211 let mut formatted = String::new();
212 let mut diff_args = cfg_args_clang_format_diff.clone();
213 diff_args.push(file);
214 let _ = py_runner(
215 py_path.as_ref().unwrap(),
216 false,
217 Some(&mut formatted),
218 "clang-format",
219 &diff_args,
220 );
221 if formatted.is_empty() {
222 eprintln!(
223 "failed to obtain the formatted content for '{}'",
224 file.to_string_lossy()
225 );
226 continue;
227 }
228 let actual = std::fs::read_to_string(file).unwrap_or_else(|e| {
229 panic!(
230 "failed to read the C++ file at '{}' due to '{e}'",
231 file.to_string_lossy()
232 )
233 });
234 if formatted != actual {
235 let diff = similar::TextDiff::from_lines(&actual, &formatted);
236 eprintln!(
237 "{}",
238 diff.unified_diff().context_radius(4).header(
239 &format!("{} (actual)", file.to_string_lossy()),
240 &format!("{} (formatted)", file.to_string_lossy())
241 )
242 );
243 }
244 }
245 }
246 res?;
248 }
249
250 if shell_lint {
251 eprintln!("linting shell files");
252
253 let mut file_args_shc = file_args.clone();
254 let files;
255 if file_args_shc.is_empty() {
256 files = find_with_extension(root_path, None, &[OsStr::new("sh")])?;
257 file_args_shc.extend(files.iter().map(|p| p.as_os_str()));
258 }
259
260 shellcheck_runner(&merge_args(&cfg_args, &file_args_shc))?;
261 }
262
263 if spellcheck {
264 let config_path = root_path.join("typos.toml");
265 let mut args = vec!["-c", config_path.as_os_str().to_str().unwrap()];
266
267 args.extend_from_slice(SPELLCHECK_DIRS);
268
269 if bless {
270 eprintln!("spellcheck files and fix");
271 args.push("--write-changes");
272 } else {
273 eprintln!("spellcheck files");
274 }
275 spellcheck_runner(&args)?;
276 }
277
278 Ok(())
279}
280
281fn run_ruff(
282 root_path: &Path,
283 outdir: &Path,
284 py_path: &Path,
285 cfg_args: &[&OsStr],
286 file_args: &[&OsStr],
287 ruff_args: &[&OsStr],
288) -> Result<(), Error> {
289 let mut cfg_args_ruff = cfg_args.to_vec();
290 let mut file_args_ruff = file_args.to_vec();
291
292 let mut cfg_path = root_path.to_owned();
293 cfg_path.extend(RUFF_CONFIG_PATH);
294 let mut cache_dir = outdir.to_owned();
295 cache_dir.extend(RUFF_CACHE_PATH);
296
297 cfg_args_ruff.extend([
298 "--config".as_ref(),
299 cfg_path.as_os_str(),
300 "--cache-dir".as_ref(),
301 cache_dir.as_os_str(),
302 ]);
303
304 if file_args_ruff.is_empty() {
305 file_args_ruff.push(root_path.as_os_str());
306 }
307
308 let mut args: Vec<&OsStr> = ruff_args.to_vec();
309 args.extend(merge_args(&cfg_args_ruff, &file_args_ruff));
310 py_runner(py_path, true, None, "ruff", &args)
311}
312
313fn merge_args<'a>(cfg_args: &[&'a OsStr], file_args: &[&'a OsStr]) -> Vec<&'a OsStr> {
315 let mut args = cfg_args.to_owned();
316 args.push("--".as_ref());
317 args.extend(file_args);
318 args
319}
320
321fn py_runner(
325 py_path: &Path,
326 as_module: bool,
327 stdout: Option<&mut String>,
328 bin: &'static str,
329 args: &[&OsStr],
330) -> Result<(), Error> {
331 let mut cmd = Command::new(py_path);
332 if as_module {
333 cmd.arg("-m").arg(bin).args(args);
334 } else {
335 let bin_path = py_path.with_file_name(bin);
336 cmd.arg(bin_path).args(args);
337 }
338 let status = if let Some(stdout) = stdout {
339 let output = cmd.output()?;
340 if let Ok(s) = std::str::from_utf8(&output.stdout) {
341 stdout.push_str(s);
342 }
343 output.status
344 } else {
345 cmd.status()?
346 };
347 if status.success() { Ok(()) } else { Err(Error::FailedCheck(bin)) }
348}
349
350fn get_or_create_venv(venv_path: &Path, src_reqs_path: &Path) -> Result<PathBuf, Error> {
353 let mut should_create = true;
354 let dst_reqs_path = venv_path.join("requirements.txt");
355 let mut py_path = venv_path.to_owned();
356 py_path.extend(REL_PY_PATH);
357
358 if let Ok(req) = fs::read_to_string(&dst_reqs_path) {
359 if req == fs::read_to_string(src_reqs_path)? {
360 should_create = false;
362 } else {
363 eprintln!("requirements.txt file mismatch, recreating environment");
364 }
365 }
366
367 if should_create {
368 eprintln!("removing old virtual environment");
369 if venv_path.is_dir() {
370 fs::remove_dir_all(venv_path).unwrap_or_else(|_| {
371 panic!("failed to remove directory at {}", venv_path.display())
372 });
373 }
374 create_venv_at_path(venv_path)?;
375 install_requirements(&py_path, src_reqs_path, &dst_reqs_path)?;
376 }
377
378 verify_py_version(&py_path)?;
379 Ok(py_path)
380}
381
382fn create_venv_at_path(path: &Path) -> Result<(), Error> {
385 const TRY_PY: &[&str] = &[
388 "python3.13",
389 "python3.12",
390 "python3.11",
391 "python3.10",
392 "python3.9",
393 "python3",
394 "python",
395 "python3.14",
396 ];
397
398 let mut sys_py = None;
399 let mut found = Vec::new();
400
401 for py in TRY_PY {
402 match verify_py_version(Path::new(py)) {
403 Ok(_) => {
404 sys_py = Some(*py);
405 break;
406 }
407 Err(Error::Io(e)) if e.kind() == io::ErrorKind::NotFound => (),
409 Err(Error::Version { installed, .. }) => found.push(installed),
411 Err(e) => eprintln!("note: error running '{py}': {e}"),
413 }
414 }
415
416 let Some(sys_py) = sys_py else {
417 let ret = if found.is_empty() {
418 Error::MissingReq("python3", "python file checks", None)
419 } else {
420 found.sort();
421 found.dedup();
422 Error::Version {
423 program: "python3",
424 required: MIN_PY_REV_STR,
425 installed: found.join(", "),
426 }
427 };
428 return Err(ret);
429 };
430
431 if try_create_venv(sys_py, path, "venv").is_ok() {
435 return Ok(());
436 }
437 try_create_venv(sys_py, path, "virtualenv")
438}
439
440fn try_create_venv(python: &str, path: &Path, module: &str) -> Result<(), Error> {
441 eprintln!(
442 "creating virtual environment at '{}' using '{python}' and '{module}'",
443 path.display()
444 );
445 let out = Command::new(python).args(["-m", module]).arg(path).output().unwrap();
446
447 if out.status.success() {
448 return Ok(());
449 }
450
451 let stderr = String::from_utf8_lossy(&out.stderr);
452 let err = if stderr.contains(&format!("No module named {module}")) {
453 Error::Generic(format!(
454 r#"{module} not found: you may need to install it:
455`{python} -m pip install {module}`
456If you see an error about "externally managed environment" when running the above command,
457either install `{module}` using your system package manager
458(e.g. `sudo apt-get install {python}-{module}`) or create a virtual environment manually, install
459`{module}` in it and then activate it before running tidy.
460"#
461 ))
462 } else {
463 Error::Generic(format!(
464 "failed to create venv at '{}' using {python} -m {module}: {stderr}",
465 path.display()
466 ))
467 };
468 Err(err)
469}
470
471fn verify_py_version(py_path: &Path) -> Result<(), Error> {
474 let out = Command::new(py_path).arg("--version").output()?;
475 let outstr = String::from_utf8_lossy(&out.stdout);
476 let vers = outstr.trim().split_ascii_whitespace().nth(1).unwrap().trim();
477 let mut vers_comps = vers.split('.');
478 let major: u32 = vers_comps.next().unwrap().parse().unwrap();
479 let minor: u32 = vers_comps.next().unwrap().parse().unwrap();
480
481 if (major, minor) < MIN_PY_REV {
482 Err(Error::Version {
483 program: "python",
484 required: MIN_PY_REV_STR,
485 installed: vers.to_owned(),
486 })
487 } else {
488 Ok(())
489 }
490}
491
492fn install_requirements(
493 py_path: &Path,
494 src_reqs_path: &Path,
495 dst_reqs_path: &Path,
496) -> Result<(), Error> {
497 let stat = Command::new(py_path)
498 .args(["-m", "pip", "install", "--upgrade", "pip"])
499 .status()
500 .expect("failed to launch pip");
501 if !stat.success() {
502 return Err(Error::Generic(format!("pip install failed with status {stat}")));
503 }
504
505 let stat = Command::new(py_path)
506 .args(["-m", "pip", "install", "--quiet", "--require-hashes", "-r"])
507 .arg(src_reqs_path)
508 .status()?;
509 if !stat.success() {
510 return Err(Error::Generic(format!(
511 "failed to install requirements at {}",
512 src_reqs_path.display()
513 )));
514 }
515 fs::copy(src_reqs_path, dst_reqs_path)?;
516 assert_eq!(
517 fs::read_to_string(src_reqs_path).unwrap(),
518 fs::read_to_string(dst_reqs_path).unwrap()
519 );
520 Ok(())
521}
522
523fn shellcheck_runner(args: &[&OsStr]) -> Result<(), Error> {
525 match Command::new("shellcheck").arg("--version").status() {
526 Ok(_) => (),
527 Err(e) if e.kind() == io::ErrorKind::NotFound => {
528 return Err(Error::MissingReq(
529 "shellcheck",
530 "shell file checks",
531 Some(
532 "see <https://github.com/koalaman/shellcheck#installing> \
533 for installation instructions"
534 .to_owned(),
535 ),
536 ));
537 }
538 Err(e) => return Err(e.into()),
539 }
540
541 let status = Command::new("shellcheck").args(args).status()?;
542 if status.success() { Ok(()) } else { Err(Error::FailedCheck("shellcheck")) }
543}
544
545fn spellcheck_runner(args: &[&str]) -> Result<(), Error> {
547 let expected_version = "typos-cli 1.34.0";
549 match Command::new("typos").arg("--version").output() {
550 Ok(o) => {
551 let stdout = String::from_utf8_lossy(&o.stdout);
552 if stdout.trim() != expected_version {
553 return Err(Error::Version {
554 program: "typos",
555 required: expected_version,
556 installed: stdout.trim().to_string(),
557 });
558 }
559 }
560 Err(e) if e.kind() == io::ErrorKind::NotFound => {
561 return Err(Error::MissingReq(
562 "typos",
563 "spellcheck file checks",
564 Some("install tool via `cargo install typos-cli@1.34.0`".to_owned()),
566 ));
567 }
568 Err(e) => return Err(e.into()),
569 }
570
571 let status = Command::new("typos").args(args).status()?;
572 if status.success() { Ok(()) } else { Err(Error::FailedCheck("typos")) }
573}
574
575fn find_with_extension(
577 root_path: &Path,
578 find_dir: Option<&Path>,
579 extensions: &[&OsStr],
580) -> Result<Vec<PathBuf>, Error> {
581 let stat_output =
584 Command::new("git").arg("-C").arg(root_path).args(["status", "--short"]).output()?.stdout;
585
586 if String::from_utf8_lossy(&stat_output).lines().filter(|ln| ln.starts_with('?')).count() > 0 {
587 eprintln!("found untracked files, ignoring");
588 }
589
590 let mut output = Vec::new();
591 let binding = {
592 let mut command = Command::new("git");
593 command.arg("-C").arg(root_path).args(["ls-files"]);
594 if let Some(find_dir) = find_dir {
595 command.arg(find_dir);
596 }
597 command.output()?
598 };
599 let tracked = String::from_utf8_lossy(&binding.stdout);
600
601 for line in tracked.lines() {
602 let line = line.trim();
603 let path = Path::new(line);
604
605 let Some(ref extension) = path.extension() else {
606 continue;
607 };
608 if extensions.contains(extension) {
609 output.push(root_path.join(path));
610 }
611 }
612
613 Ok(output)
614}
615
616#[derive(Debug)]
617enum Error {
618 Io(io::Error),
619 MissingReq(&'static str, &'static str, Option<String>),
621 FailedCheck(&'static str),
623 Generic(String),
625 Version {
627 program: &'static str,
628 required: &'static str,
629 installed: String,
630 },
631}
632
633impl fmt::Display for Error {
634 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
635 match self {
636 Self::MissingReq(a, b, ex) => {
637 write!(
638 f,
639 "{a} is required to run {b} but it could not be located. Is it installed?"
640 )?;
641 if let Some(s) = ex {
642 write!(f, "\n{s}")?;
643 };
644 Ok(())
645 }
646 Self::Version { program, required, installed } => write!(
647 f,
648 "insufficient version of '{program}' to run external tools: \
649 {required} required but found {installed}",
650 ),
651 Self::Generic(s) => f.write_str(s),
652 Self::Io(e) => write!(f, "IO error: {e}"),
653 Self::FailedCheck(s) => write!(f, "checks with external tool '{s}' failed"),
654 }
655 }
656}
657
658impl From<io::Error> for Error {
659 fn from(value: io::Error) -> Self {
660 Self::Io(value)
661 }
662}
663
664#[derive(Debug)]
665enum ExtraCheckParseError {
666 #[allow(dead_code, reason = "shown through Debug")]
667 UnknownKind(String),
668 #[allow(dead_code)]
669 UnknownLang(String),
670 UnsupportedKindForLang,
671 TooManyParts,
673 Empty,
675 AutoRequiresLang,
677}
678
679struct ExtraCheckArg {
680 auto: bool,
681 lang: ExtraCheckLang,
682 kind: Option<ExtraCheckKind>,
684}
685
686impl ExtraCheckArg {
687 fn matches(&self, lang: ExtraCheckLang, kind: ExtraCheckKind) -> bool {
688 self.lang == lang && self.kind.map(|k| k == kind).unwrap_or(true)
689 }
690
691 fn is_inactive_auto(&self, ci_info: &CiInfo) -> bool {
693 if !self.auto {
694 return false;
695 }
696 let ext = match self.lang {
697 ExtraCheckLang::Py => ".py",
698 ExtraCheckLang::Cpp => ".cpp",
699 ExtraCheckLang::Shell => ".sh",
700 ExtraCheckLang::Spellcheck => {
701 return !crate::files_modified(ci_info, |s| {
702 SPELLCHECK_DIRS.iter().any(|dir| Path::new(s).starts_with(dir))
703 });
704 }
705 };
706 !crate::files_modified(ci_info, |s| s.ends_with(ext))
707 }
708
709 fn has_supported_kind(&self) -> bool {
710 let Some(kind) = self.kind else {
711 return true;
713 };
714 use ExtraCheckKind::*;
715 let supported_kinds: &[_] = match self.lang {
716 ExtraCheckLang::Py => &[Fmt, Lint],
717 ExtraCheckLang::Cpp => &[Fmt],
718 ExtraCheckLang::Shell => &[Lint],
719 ExtraCheckLang::Spellcheck => &[],
720 };
721 supported_kinds.contains(&kind)
722 }
723}
724
725impl FromStr for ExtraCheckArg {
726 type Err = ExtraCheckParseError;
727
728 fn from_str(s: &str) -> Result<Self, Self::Err> {
729 let mut auto = false;
730 let mut parts = s.split(':');
731 let Some(mut first) = parts.next() else {
732 return Err(ExtraCheckParseError::Empty);
733 };
734 if first == "auto" {
735 let Some(part) = parts.next() else {
736 return Err(ExtraCheckParseError::AutoRequiresLang);
737 };
738 auto = true;
739 first = part;
740 }
741 let second = parts.next();
742 if parts.next().is_some() {
743 return Err(ExtraCheckParseError::TooManyParts);
744 }
745 let arg = Self { auto, lang: first.parse()?, kind: second.map(|s| s.parse()).transpose()? };
746 if !arg.has_supported_kind() {
747 return Err(ExtraCheckParseError::UnsupportedKindForLang);
748 }
749
750 Ok(arg)
751 }
752}
753
754#[derive(PartialEq, Copy, Clone)]
755enum ExtraCheckLang {
756 Py,
757 Shell,
758 Cpp,
759 Spellcheck,
760}
761
762impl FromStr for ExtraCheckLang {
763 type Err = ExtraCheckParseError;
764
765 fn from_str(s: &str) -> Result<Self, Self::Err> {
766 Ok(match s {
767 "py" => Self::Py,
768 "shell" => Self::Shell,
769 "cpp" => Self::Cpp,
770 "spellcheck" => Self::Spellcheck,
771 _ => return Err(ExtraCheckParseError::UnknownLang(s.to_string())),
772 })
773 }
774}
775
776#[derive(PartialEq, Copy, Clone)]
777enum ExtraCheckKind {
778 Lint,
779 Fmt,
780 None,
783}
784
785impl FromStr for ExtraCheckKind {
786 type Err = ExtraCheckParseError;
787
788 fn from_str(s: &str) -> Result<Self, Self::Err> {
789 Ok(match s {
790 "lint" => Self::Lint,
791 "fmt" => Self::Fmt,
792 _ => return Err(ExtraCheckParseError::UnknownKind(s.to_string())),
793 })
794 }
795}