tidy/
ext_tool_checks.rs

1//! Optional checks for file types other than Rust source
2//!
3//! Handles python tool version management via a virtual environment in
4//! `build/venv`.
5//!
6//! # Functional outline
7//!
8//! 1. Run tidy with an extra option: `--extra-checks=py,shell`,
9//!    `--extra-checks=py:lint`, or similar. Optionally provide specific
10//!    configuration after a double dash (`--extra-checks=py -- foo.py`)
11//! 2. Build configuration based on args/environment:
12//!    - Formatters by default are in check only mode
13//!    - If in CI (TIDY_PRINT_DIFF=1 is set), check and print the diff
14//!    - If `--bless` is provided, formatters may run
15//!    - Pass any additional config after the `--`. If no files are specified,
16//!      use a default.
17//! 3. Print the output of the given command. If it fails and `TIDY_PRINT_DIFF`
18//!    is set, rerun the tool to print a suggestion diff (for e.g. CI)
19
20use 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/// Path to find the python executable within a virtual environment
29#[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"];
35/// Location within build directory
36const 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    // Split comma-separated args up
63    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        // Rethrow error
108        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        // Rethrow error
140        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        // Rethrow error
211        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
262/// Helper to create `cfg1 cfg2 -- file1 file2` output
263fn 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
270/// Run a python command with given arguments. `py_path` should be a virtualenv.
271///
272/// Captures `stdout` to a string if provided, otherwise prints the output.
273fn 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
299/// Create a virtuaenv at a given path if it doesn't already exist, or validate
300/// the install if it does. Returns the path to that venv's python executable.
301fn 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            // found existing environment
310            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
331/// Attempt to create a virtualenv at this path. Cycles through all expected
332/// valid python versions to find one that is installed.
333fn create_venv_at_path(path: &Path) -> Result<(), Error> {
334    /// Preferred python versions in order. Newest to oldest then current
335    /// development versions
336    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            // Skip not found errors
357            Err(Error::Io(e)) if e.kind() == io::ErrorKind::NotFound => (),
358            // Skip insufficient version errors
359            Err(Error::Version { installed, .. }) => found.push(installed),
360            // just log and skip unrecognized errors
361            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    // First try venv, which should be packaged in the Python3 standard library.
381    // If it is not available, try to create the virtual environment using the
382    // virtualenv package.
383    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
420/// Parse python's version output (`Python x.y.z`) and ensure we have a
421/// suitable version.
422fn 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
472/// Check that shellcheck is installed then run it at the given path
473fn 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
494/// Check git for tracked files matching an extension
495fn find_with_extension(
496    root_path: &Path,
497    find_dir: Option<&Path>,
498    extensions: &[&OsStr],
499) -> Result<Vec<PathBuf>, Error> {
500    // Untracked files show up for short status and are indicated with a leading `?`
501    // -C changes git to be as if run from that directory
502    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    /// a is required to run b. c is extra info
539    MissingReq(&'static str, &'static str, Option<String>),
540    /// Tool x failed the check
541    FailedCheck(&'static str),
542    /// Any message, just print it
543    Generic(String),
544    /// Installed but wrong version
545    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}