Skip to main content

tidy/extra_checks/
mod.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::str::FromStr;
24use std::{env, fmt, fs, io};
25
26use build_helper::ci::CiEnv;
27
28use crate::CiInfo;
29use crate::diagnostics::TidyCtx;
30
31mod rustdoc_js;
32
33#[cfg(test)]
34mod tests;
35
36const MIN_PY_REV: (u32, u32) = (3, 9);
37const MIN_PY_REV_STR: &str = "≥3.9";
38
39/// Path to find the python executable within a virtual environment
40#[cfg(target_os = "windows")]
41const REL_PY_PATH: &[&str] = &["Scripts", "python3.exe"];
42#[cfg(not(target_os = "windows"))]
43const REL_PY_PATH: &[&str] = &["bin", "python3"];
44
45const RUFF_CONFIG_PATH: &[&str] = &["src", "tools", "tidy", "config", "ruff.toml"];
46/// Location within build directory
47const RUFF_CACHE_PATH: &[&str] = &["cache", "ruff_cache"];
48const PIP_REQ_PATH: &[&str] = &["src", "tools", "tidy", "config", "requirements.txt"];
49
50const SPELLCHECK_DIRS: &[&str] = &["compiler", "library", "src/bootstrap", "src/librustdoc"];
51const SPELLCHECK_VER: &str = "1.38.1";
52
53pub fn check(
54    root_path: &Path,
55    outdir: &Path,
56    ci_info: &CiInfo,
57    librustdoc_path: &Path,
58    tools_path: &Path,
59    npm: &Path,
60    cargo: &Path,
61    extra_checks: Option<&str>,
62    pos_args: &[String],
63    tidy_ctx: TidyCtx,
64) {
65    let mut check = tidy_ctx.start_check("extra_checks");
66
67    if let Err(e) = check_impl(
68        root_path,
69        outdir,
70        ci_info,
71        librustdoc_path,
72        tools_path,
73        npm,
74        cargo,
75        extra_checks,
76        pos_args,
77        &tidy_ctx,
78    ) {
79        check.error(e);
80    }
81}
82
83fn check_impl(
84    root_path: &Path,
85    outdir: &Path,
86    ci_info: &CiInfo,
87    librustdoc_path: &Path,
88    tools_path: &Path,
89    npm: &Path,
90    cargo: &Path,
91    extra_checks: Option<&str>,
92    pos_args: &[String],
93    tidy_ctx: &TidyCtx,
94) -> Result<(), Error> {
95    let show_diff =
96        std::env::var("TIDY_PRINT_DIFF").is_ok_and(|v| v.eq_ignore_ascii_case("true") || v == "1");
97    let bless = tidy_ctx.is_bless_enabled();
98
99    // Split comma-separated args up
100    let mut lint_args = match extra_checks {
101        Some(s) => s
102            .strip_prefix("--extra-checks=")
103            .unwrap()
104            .split(',')
105            .map(|s| {
106                if s == "spellcheck:fix" {
107                    eprintln!("warning: `spellcheck:fix` is no longer valid, use `--extra-checks=spellcheck --bless`");
108                }
109                (ExtraCheckArg::from_str(s), s)
110            })
111            .filter_map(|(res, src)| match res {
112                Ok(arg) => {
113                    Some(arg)
114                }
115                Err(err) => {
116                    // only warn because before bad extra checks would be silently ignored.
117                    eprintln!("warning: bad extra check argument {src:?}: {err:?}");
118                    None
119                }
120            })
121            .collect(),
122        None => vec![],
123    };
124    lint_args.retain(|ck| ck.is_non_if_installed_or_matches(root_path, outdir));
125    if lint_args.iter().any(|ck| ck.auto) {
126        crate::files_modified_batch_filter(ci_info, &mut lint_args, |ck, path| {
127            ck.is_non_auto_or_matches(path)
128        });
129    }
130
131    macro_rules! extra_check {
132        ($lang:ident, $kind:ident) => {
133            lint_args.iter().any(|arg| arg.matches(ExtraCheckLang::$lang, ExtraCheckKind::$kind))
134        };
135    }
136
137    let rerun_with_bless = |mode: &str, action: &str| {
138        if !bless {
139            eprintln!("rerun tidy with `--extra-checks={mode} --bless` to {action}");
140        }
141    };
142
143    let python_lint = extra_check!(Py, Lint);
144    let python_fmt = extra_check!(Py, Fmt);
145    let shell_lint = extra_check!(Shell, Lint);
146    let cpp_fmt = extra_check!(Cpp, Fmt);
147    let spellcheck = extra_check!(Spellcheck, None);
148    let js_lint = extra_check!(Js, Lint);
149    let js_typecheck = extra_check!(Js, Typecheck);
150
151    let mut py_path = None;
152
153    let (cfg_args, file_args): (Vec<_>, Vec<_>) = pos_args
154        .iter()
155        .map(OsStr::new)
156        .partition(|arg| arg.to_str().is_some_and(|s| s.starts_with('-')));
157
158    if python_lint || python_fmt || cpp_fmt {
159        let venv_path = outdir.join("venv");
160        let mut reqs_path = root_path.to_owned();
161        reqs_path.extend(PIP_REQ_PATH);
162        py_path = Some(get_or_create_venv(&venv_path, &reqs_path)?);
163    }
164
165    if python_lint {
166        let py_path = py_path.as_ref().unwrap();
167        let args: &[&OsStr] = if bless {
168            eprintln!("linting python files and applying suggestions");
169            &["check".as_ref(), "--fix".as_ref()]
170        } else {
171            eprintln!("linting python files");
172            &["check".as_ref()]
173        };
174
175        let res = run_ruff(root_path, outdir, py_path, &cfg_args, &file_args, args);
176
177        if res.is_err() && show_diff && !bless {
178            eprintln!("\npython linting failed! Printing diff suggestions:");
179
180            let diff_res = run_ruff(
181                root_path,
182                outdir,
183                py_path,
184                &cfg_args,
185                &file_args,
186                &["check".as_ref(), "--diff".as_ref()],
187            );
188            // `ruff check --diff` will return status 0 if there are no suggestions.
189            if diff_res.is_err() {
190                rerun_with_bless("py:lint", "apply ruff suggestions");
191            }
192        }
193        // Rethrow error
194        res?;
195    }
196
197    if python_fmt {
198        let mut args: Vec<&OsStr> = vec!["format".as_ref()];
199        if bless {
200            eprintln!("formatting python files");
201        } else {
202            eprintln!("checking python file formatting");
203            args.push("--check".as_ref());
204        }
205
206        let py_path = py_path.as_ref().unwrap();
207        let res = run_ruff(root_path, outdir, py_path, &cfg_args, &file_args, &args);
208
209        if res.is_err() && !bless {
210            if show_diff {
211                eprintln!("\npython formatting does not match! Printing diff:");
212
213                let _ = run_ruff(
214                    root_path,
215                    outdir,
216                    py_path,
217                    &cfg_args,
218                    &file_args,
219                    &["format".as_ref(), "--diff".as_ref()],
220                );
221            }
222            rerun_with_bless("py:fmt", "reformat Python code");
223        }
224
225        // Rethrow error
226        res?;
227    }
228
229    if cpp_fmt {
230        let mut cfg_args_clang_format = cfg_args.clone();
231        let mut file_args_clang_format = file_args.clone();
232        let config_path = root_path.join(".clang-format");
233        let config_file_arg = format!("file:{}", config_path.display());
234        cfg_args_clang_format.extend(&["--style".as_ref(), config_file_arg.as_ref()]);
235        if bless {
236            eprintln!("formatting C++ files");
237            cfg_args_clang_format.push("-i".as_ref());
238        } else {
239            eprintln!("checking C++ file formatting");
240            cfg_args_clang_format.extend(&["--dry-run".as_ref(), "--Werror".as_ref()]);
241        }
242        let files;
243        if file_args_clang_format.is_empty() {
244            let llvm_wrapper = root_path.join("compiler/rustc_llvm/llvm-wrapper");
245            files = find_with_extension(
246                root_path,
247                Some(llvm_wrapper.as_path()),
248                &[OsStr::new("h"), OsStr::new("cpp")],
249            )?;
250            file_args_clang_format.extend(files.iter().map(|p| p.as_os_str()));
251        }
252        let args = merge_args(&cfg_args_clang_format, &file_args_clang_format);
253        let res = py_runner(py_path.as_ref().unwrap(), false, None, "clang-format", &args);
254
255        if res.is_err() && show_diff && !bless {
256            eprintln!("\nclang-format linting failed! Printing diff suggestions:");
257
258            let mut cfg_args_clang_format_diff = cfg_args.clone();
259            cfg_args_clang_format_diff.extend(&["--style".as_ref(), config_file_arg.as_ref()]);
260            for file in file_args_clang_format {
261                let mut formatted = String::new();
262                let mut diff_args = cfg_args_clang_format_diff.clone();
263                diff_args.push(file);
264                let _ = py_runner(
265                    py_path.as_ref().unwrap(),
266                    false,
267                    Some(&mut formatted),
268                    "clang-format",
269                    &diff_args,
270                );
271                if formatted.is_empty() {
272                    eprintln!(
273                        "failed to obtain the formatted content for '{}'",
274                        file.to_string_lossy()
275                    );
276                    continue;
277                }
278                let actual = std::fs::read_to_string(file).unwrap_or_else(|e| {
279                    panic!(
280                        "failed to read the C++ file at '{}' due to '{e}'",
281                        file.to_string_lossy()
282                    )
283                });
284                if formatted != actual {
285                    let diff = similar::TextDiff::from_lines(&actual, &formatted);
286                    eprintln!(
287                        "{}",
288                        diff.unified_diff().context_radius(4).header(
289                            &format!("{} (actual)", file.to_string_lossy()),
290                            &format!("{} (formatted)", file.to_string_lossy())
291                        )
292                    );
293                }
294            }
295            rerun_with_bless("cpp:fmt", "reformat C++ code");
296        }
297        // Rethrow error
298        res?;
299    }
300
301    if shell_lint {
302        eprintln!("linting shell files");
303
304        let mut file_args_shc = file_args.clone();
305        let files;
306        if file_args_shc.is_empty() {
307            files = find_with_extension(root_path, None, &[OsStr::new("sh")])?;
308            file_args_shc.extend(files.iter().map(|p| p.as_os_str()));
309        }
310
311        shellcheck_runner(&merge_args(&cfg_args, &file_args_shc))?;
312    }
313
314    if spellcheck {
315        let config_path = root_path.join("typos.toml");
316        let mut args = vec!["-c", config_path.as_os_str().to_str().unwrap()];
317
318        args.extend_from_slice(SPELLCHECK_DIRS);
319
320        if bless {
321            eprintln!("spellchecking files and fixing typos");
322            args.push("--write-changes");
323        } else {
324            eprintln!("spellchecking files");
325        }
326        let res = spellcheck_runner(root_path, &outdir, &cargo, &args);
327        if res.is_err() {
328            rerun_with_bless("spellcheck", "fix typos");
329        }
330        res?;
331    }
332
333    if js_lint || js_typecheck {
334        rustdoc_js::npm_install(root_path, outdir, npm)?;
335    }
336
337    if js_lint {
338        if bless {
339            eprintln!("linting javascript files");
340        } else {
341            eprintln!("linting javascript files and applying suggestions");
342        }
343        let res = rustdoc_js::lint(outdir, librustdoc_path, tools_path, bless);
344        if res.is_err() {
345            rerun_with_bless("js:lint", "apply eslint suggestions");
346        }
347        res?;
348        rustdoc_js::es_check(outdir, librustdoc_path)?;
349    }
350
351    if js_typecheck {
352        eprintln!("typechecking javascript files");
353        rustdoc_js::typecheck(outdir, librustdoc_path)?;
354    }
355
356    Ok(())
357}
358
359fn run_ruff(
360    root_path: &Path,
361    outdir: &Path,
362    py_path: &Path,
363    cfg_args: &[&OsStr],
364    file_args: &[&OsStr],
365    ruff_args: &[&OsStr],
366) -> Result<(), Error> {
367    let mut cfg_args_ruff = cfg_args.to_vec();
368    let mut file_args_ruff = file_args.to_vec();
369
370    let mut cfg_path = root_path.to_owned();
371    cfg_path.extend(RUFF_CONFIG_PATH);
372    let mut cache_dir = outdir.to_owned();
373    cache_dir.extend(RUFF_CACHE_PATH);
374
375    cfg_args_ruff.extend([
376        "--config".as_ref(),
377        cfg_path.as_os_str(),
378        "--cache-dir".as_ref(),
379        cache_dir.as_os_str(),
380    ]);
381
382    if file_args_ruff.is_empty() {
383        file_args_ruff.push(root_path.as_os_str());
384    }
385
386    let mut args: Vec<&OsStr> = ruff_args.to_vec();
387    args.extend(merge_args(&cfg_args_ruff, &file_args_ruff));
388    py_runner(py_path, true, None, "ruff", &args)
389}
390
391/// Helper to create `cfg1 cfg2 -- file1 file2` output
392fn merge_args<'a>(cfg_args: &[&'a OsStr], file_args: &[&'a OsStr]) -> Vec<&'a OsStr> {
393    let mut args = cfg_args.to_owned();
394    args.push("--".as_ref());
395    args.extend(file_args);
396    args
397}
398
399/// Run a python command with given arguments. `py_path` should be a virtualenv.
400///
401/// Captures `stdout` to a string if provided, otherwise prints the output.
402fn py_runner(
403    py_path: &Path,
404    as_module: bool,
405    stdout: Option<&mut String>,
406    bin: &'static str,
407    args: &[&OsStr],
408) -> Result<(), Error> {
409    let mut cmd = Command::new(py_path);
410    if as_module {
411        cmd.arg("-m").arg(bin).args(args);
412    } else {
413        let bin_path = py_path.with_file_name(bin);
414        cmd.arg(bin_path).args(args);
415    }
416    let status = if let Some(stdout) = stdout {
417        let output = cmd.output()?;
418        if let Ok(s) = std::str::from_utf8(&output.stdout) {
419            stdout.push_str(s);
420        }
421        output.status
422    } else {
423        cmd.status()?
424    };
425    if status.success() { Ok(()) } else { Err(Error::FailedCheck(bin)) }
426}
427
428/// Create a virtuaenv at a given path if it doesn't already exist, or validate
429/// the install if it does. Returns the path to that venv's python executable.
430fn get_or_create_venv(venv_path: &Path, src_reqs_path: &Path) -> Result<PathBuf, Error> {
431    let mut py_path = venv_path.to_owned();
432    py_path.extend(REL_PY_PATH);
433
434    if !has_py_tools(venv_path, src_reqs_path)? {
435        let dst_reqs_path = venv_path.join("requirements.txt");
436        eprintln!("removing old virtual environment");
437        if venv_path.is_dir() {
438            fs::remove_dir_all(venv_path).unwrap_or_else(|_| {
439                panic!("failed to remove directory at {}", venv_path.display())
440            });
441        }
442        create_venv_at_path(venv_path)?;
443        install_requirements(&py_path, src_reqs_path, &dst_reqs_path)?;
444    }
445
446    verify_py_version(&py_path)?;
447    Ok(py_path)
448}
449
450fn has_py_tools(venv_path: &Path, src_reqs_path: &Path) -> Result<bool, Error> {
451    let dst_reqs_path = venv_path.join("requirements.txt");
452    if let Ok(req) = fs::read_to_string(&dst_reqs_path) {
453        if req == fs::read_to_string(src_reqs_path)? {
454            return Ok(true);
455        }
456        eprintln!("requirements.txt file mismatch");
457    }
458
459    Ok(false)
460}
461
462/// Attempt to create a virtualenv at this path. Cycles through all expected
463/// valid python versions to find one that is installed.
464fn create_venv_at_path(path: &Path) -> Result<(), Error> {
465    /// Preferred python versions in order. Newest to oldest then current
466    /// development versions
467    const TRY_PY: &[&str] = &[
468        "python3.13",
469        "python3.12",
470        "python3.11",
471        "python3.10",
472        "python3.9",
473        "python3",
474        "python",
475        "python3.14",
476    ];
477
478    let mut sys_py = None;
479    let mut found = Vec::new();
480
481    for py in TRY_PY {
482        match verify_py_version(Path::new(py)) {
483            Ok(_) => {
484                sys_py = Some(*py);
485                break;
486            }
487            // Skip not found errors
488            Err(Error::Io(e)) if e.kind() == io::ErrorKind::NotFound => (),
489            // Skip insufficient version errors
490            Err(Error::Version { installed, .. }) => found.push(installed),
491            // just log and skip unrecognized errors
492            Err(e) => eprintln!("note: error running '{py}': {e}"),
493        }
494    }
495
496    let Some(sys_py) = sys_py else {
497        let ret = if found.is_empty() {
498            Error::MissingReq("python3", "python file checks", None)
499        } else {
500            found.sort();
501            found.dedup();
502            Error::Version {
503                program: "python3",
504                required: MIN_PY_REV_STR,
505                installed: found.join(", "),
506            }
507        };
508        return Err(ret);
509    };
510
511    // First try venv, which should be packaged in the Python3 standard library.
512    // If it is not available, try to create the virtual environment using the
513    // virtualenv package.
514    if try_create_venv(sys_py, path, "venv").is_ok() {
515        return Ok(());
516    }
517    try_create_venv(sys_py, path, "virtualenv")
518}
519
520fn try_create_venv(python: &str, path: &Path, module: &str) -> Result<(), Error> {
521    eprintln!(
522        "creating virtual environment at '{}' using '{python}' and '{module}'",
523        path.display()
524    );
525    let out = Command::new(python).args(["-m", module]).arg(path).output().unwrap();
526
527    if out.status.success() {
528        return Ok(());
529    }
530
531    let stderr = String::from_utf8_lossy(&out.stderr);
532    let err = if stderr.contains(&format!("No module named {module}")) {
533        Error::Generic(format!(
534            r#"{module} not found: you may need to install it:
535`{python} -m pip install {module}`
536If you see an error about "externally managed environment" when running the above command,
537either install `{module}` using your system package manager
538(e.g. `sudo apt-get install {python}-{module}`) or create a virtual environment manually, install
539`{module}` in it and then activate it before running tidy.
540"#
541        ))
542    } else {
543        Error::Generic(format!(
544            "failed to create venv at '{}' using {python} -m {module}: {stderr}",
545            path.display()
546        ))
547    };
548    Err(err)
549}
550
551/// Parse python's version output (`Python x.y.z`) and ensure we have a
552/// suitable version.
553fn verify_py_version(py_path: &Path) -> Result<(), Error> {
554    let out = Command::new(py_path).arg("--version").output()?;
555    let outstr = String::from_utf8_lossy(&out.stdout);
556    let vers = outstr.trim().split_ascii_whitespace().nth(1).unwrap().trim();
557    let mut vers_comps = vers.split('.');
558    let major: u32 = vers_comps.next().unwrap().parse().unwrap();
559    let minor: u32 = vers_comps.next().unwrap().parse().unwrap();
560
561    if (major, minor) < MIN_PY_REV {
562        Err(Error::Version {
563            program: "python",
564            required: MIN_PY_REV_STR,
565            installed: vers.to_owned(),
566        })
567    } else {
568        Ok(())
569    }
570}
571
572fn install_requirements(
573    py_path: &Path,
574    src_reqs_path: &Path,
575    dst_reqs_path: &Path,
576) -> Result<(), Error> {
577    let stat = Command::new(py_path)
578        .args(["-m", "pip", "install", "--upgrade", "pip"])
579        .status()
580        .expect("failed to launch pip");
581    if !stat.success() {
582        return Err(Error::Generic(format!("pip install failed with status {stat}")));
583    }
584
585    let stat = Command::new(py_path)
586        .args(["-m", "pip", "install", "--quiet", "--require-hashes", "-r"])
587        .arg(src_reqs_path)
588        .status()?;
589    if !stat.success() {
590        return Err(Error::Generic(format!(
591            "failed to install requirements at {}",
592            src_reqs_path.display()
593        )));
594    }
595    fs::copy(src_reqs_path, dst_reqs_path)?;
596    assert_eq!(
597        fs::read_to_string(src_reqs_path).unwrap(),
598        fs::read_to_string(dst_reqs_path).unwrap()
599    );
600    Ok(())
601}
602
603/// Returns `Ok` if shellcheck is installed, `Err` otherwise.
604fn has_shellcheck() -> Result<(), Error> {
605    match Command::new("shellcheck").arg("--version").status() {
606        Ok(_) => Ok(()),
607        Err(e) if e.kind() == io::ErrorKind::NotFound => Err(Error::MissingReq(
608            "shellcheck",
609            "shell file checks",
610            Some(
611                "see <https://github.com/koalaman/shellcheck#installing> \
612                for installation instructions"
613                    .to_owned(),
614            ),
615        )),
616        Err(e) => Err(e.into()),
617    }
618}
619
620/// Check that shellcheck is installed then run it at the given path
621fn shellcheck_runner(args: &[&OsStr]) -> Result<(), Error> {
622    has_shellcheck()?;
623
624    let status = Command::new("shellcheck").args(args).status()?;
625    if status.success() { Ok(()) } else { Err(Error::FailedCheck("shellcheck")) }
626}
627
628/// Ensure that spellchecker is installed then run it at the given path
629fn spellcheck_runner(
630    src_root: &Path,
631    outdir: &Path,
632    cargo: &Path,
633    args: &[&str],
634) -> Result<(), Error> {
635    let bin_path =
636        ensure_version_or_cargo_install(outdir, cargo, "typos-cli", "typos", SPELLCHECK_VER)?;
637    match Command::new(bin_path).current_dir(src_root).args(args).status() {
638        Ok(status) => {
639            if status.success() {
640                Ok(())
641            } else {
642                Err(Error::FailedCheck("typos"))
643            }
644        }
645        Err(err) => Err(Error::Generic(format!("failed to run typos tool: {err:?}"))),
646    }
647}
648
649/// Check git for tracked files matching an extension
650fn find_with_extension(
651    root_path: &Path,
652    find_dir: Option<&Path>,
653    extensions: &[&OsStr],
654) -> Result<Vec<PathBuf>, Error> {
655    // Untracked files show up for short status and are indicated with a leading `?`
656    // -C changes git to be as if run from that directory
657    let stat_output =
658        Command::new("git").arg("-C").arg(root_path).args(["status", "--short"]).output()?.stdout;
659
660    if String::from_utf8_lossy(&stat_output).lines().filter(|ln| ln.starts_with('?')).count() > 0 {
661        eprintln!("found untracked files, ignoring");
662    }
663
664    let mut output = Vec::new();
665    let binding = {
666        let mut command = Command::new("git");
667        command.arg("-C").arg(root_path).args(["ls-files"]);
668        if let Some(find_dir) = find_dir {
669            command.arg(find_dir);
670        }
671        command.output()?
672    };
673    let tracked = String::from_utf8_lossy(&binding.stdout);
674
675    for line in tracked.lines() {
676        let line = line.trim();
677        let path = Path::new(line);
678
679        let Some(ref extension) = path.extension() else {
680            continue;
681        };
682        if extensions.contains(extension) {
683            output.push(root_path.join(path));
684        }
685    }
686
687    Ok(output)
688}
689
690/// Check if the given executable is installed and the version is expected.
691fn ensure_version(build_dir: &Path, bin_name: &str, version: &str) -> Result<PathBuf, Error> {
692    let bin_path = build_dir.join("misc-tools").join("bin").join(bin_name);
693
694    match Command::new(&bin_path).arg("--version").output() {
695        Ok(output) => {
696            let Some(v) = str::from_utf8(&output.stdout).unwrap().trim().split_whitespace().last()
697            else {
698                return Err(Error::Generic("version check failed".to_string()));
699            };
700
701            if v != version {
702                return Err(Error::Version { program: "", required: "", installed: v.to_string() });
703            }
704            Ok(bin_path)
705        }
706        Err(e) => Err(Error::Io(e)),
707    }
708}
709
710/// If the given executable is installed with the given version, use that,
711/// otherwise install via cargo.
712fn ensure_version_or_cargo_install(
713    build_dir: &Path,
714    cargo: &Path,
715    pkg_name: &str,
716    bin_name: &str,
717    version: &str,
718) -> Result<PathBuf, Error> {
719    if let Ok(bin_path) = ensure_version(build_dir, bin_name, version) {
720        return Ok(bin_path);
721    }
722
723    eprintln!("building external tool {bin_name} from package {pkg_name}@{version}");
724
725    let tool_root_dir = build_dir.join("misc-tools");
726    let tool_bin_dir = tool_root_dir.join("bin");
727    let bin_path = tool_bin_dir.join(bin_name).with_extension(env::consts::EXE_EXTENSION);
728
729    // use --force to ensure that if the required version is bumped, we update it.
730    // use --target-dir to ensure we have a build cache so repeated invocations aren't slow.
731    // modify PATH so that cargo doesn't print a warning telling the user to modify the path.
732    let mut cmd = Command::new(cargo);
733    cmd.args(["install", "--locked", "--force", "--quiet"])
734        .arg("--root")
735        .arg(&tool_root_dir)
736        .arg("--target-dir")
737        .arg(tool_root_dir.join("target"))
738        .arg(format!("{pkg_name}@{version}"))
739        .env(
740            "PATH",
741            env::join_paths(
742                env::split_paths(&env::var("PATH").unwrap())
743                    .chain(std::iter::once(tool_bin_dir.clone())),
744            )
745            .expect("build dir contains invalid char"),
746        );
747
748    // On CI, we set opt-level flag for quicker installation.
749    // Since lower opt-level decreases the tool's performance,
750    // we don't set this option on local.
751    if CiEnv::is_ci() {
752        cmd.env("RUSTFLAGS", "-Copt-level=0");
753    }
754
755    let cargo_exit_code = cmd.spawn()?.wait()?;
756    if !cargo_exit_code.success() {
757        return Err(Error::Generic("cargo install failed".to_string()));
758    }
759    assert!(
760        matches!(bin_path.try_exists(), Ok(true)),
761        "cargo install did not produce the expected binary"
762    );
763    eprintln!("finished building tool {bin_name}");
764    Ok(bin_path)
765}
766
767#[derive(Debug)]
768enum Error {
769    Io(io::Error),
770    /// a is required to run b. c is extra info
771    MissingReq(&'static str, &'static str, Option<String>),
772    /// Tool x failed the check
773    FailedCheck(&'static str),
774    /// Any message, just print it
775    Generic(String),
776    /// Installed but wrong version
777    Version {
778        program: &'static str,
779        required: &'static str,
780        installed: String,
781    },
782}
783
784impl fmt::Display for Error {
785    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
786        match self {
787            Self::MissingReq(a, b, ex) => {
788                write!(
789                    f,
790                    "{a} is required to run {b} but it could not be located. Is it installed?"
791                )?;
792                if let Some(s) = ex {
793                    write!(f, "\n{s}")?;
794                };
795                Ok(())
796            }
797            Self::Version { program, required, installed } => write!(
798                f,
799                "insufficient version of '{program}' to run external tools: \
800                {required} required but found {installed}",
801            ),
802            Self::Generic(s) => f.write_str(s),
803            Self::Io(e) => write!(f, "IO error: {e}"),
804            Self::FailedCheck(s) => write!(f, "checks with external tool '{s}' failed"),
805        }
806    }
807}
808
809impl From<io::Error> for Error {
810    fn from(value: io::Error) -> Self {
811        Self::Io(value)
812    }
813}
814
815#[derive(Debug, PartialEq)]
816enum ExtraCheckParseError {
817    #[allow(dead_code, reason = "shown through Debug")]
818    UnknownKind(String),
819    #[allow(dead_code)]
820    UnknownLang(String),
821    UnsupportedKindForLang,
822    /// Too many `:`
823    TooManyParts,
824    /// Tried to parse the empty string
825    Empty,
826    /// `auto` specified without lang part.
827    AutoRequiresLang,
828    /// `if-installed` specified without lang part.
829    IfInstalledRequiresLang,
830}
831
832#[derive(PartialEq, Debug)]
833struct ExtraCheckArg {
834    /// Only run the check if files to check have been modified.
835    auto: bool,
836    /// Only run the check if the requisite software is already installed.
837    if_installed: bool,
838    lang: ExtraCheckLang,
839    /// None = run all extra checks for the given lang
840    kind: Option<ExtraCheckKind>,
841}
842
843impl ExtraCheckArg {
844    fn matches(&self, lang: ExtraCheckLang, kind: ExtraCheckKind) -> bool {
845        self.lang == lang && self.kind.map(|k| k == kind).unwrap_or(true)
846    }
847
848    fn is_non_if_installed_or_matches(&self, root_path: &Path, build_dir: &Path) -> bool {
849        if !self.if_installed {
850            return true;
851        }
852
853        match self.lang {
854            ExtraCheckLang::Spellcheck => {
855                match ensure_version(build_dir, "typos", SPELLCHECK_VER) {
856                    Ok(_) => true,
857                    Err(Error::Version { installed, .. }) => {
858                        eprintln!(
859                            "warning: the tool `typos` is detected, but version {installed} doesn't match with the expected version {SPELLCHECK_VER}"
860                        );
861                        false
862                    }
863                    _ => false,
864                }
865            }
866            ExtraCheckLang::Shell => has_shellcheck().is_ok(),
867            ExtraCheckLang::Js => {
868                match self.kind {
869                    Some(ExtraCheckKind::Lint) => {
870                        // If Lint is enabled, check both eslint and es-check.
871                        rustdoc_js::has_tool(build_dir, "eslint")
872                            && rustdoc_js::has_tool(build_dir, "es-check")
873                    }
874                    Some(ExtraCheckKind::Typecheck) => {
875                        // If Typecheck is enabled, check tsc.
876                        rustdoc_js::has_tool(build_dir, "tsc")
877                    }
878                    None => {
879                        // No kind means it will check both Lint and Typecheck.
880                        rustdoc_js::has_tool(build_dir, "eslint")
881                            && rustdoc_js::has_tool(build_dir, "es-check")
882                            && rustdoc_js::has_tool(build_dir, "tsc")
883                    }
884                    Some(_) => unreachable!("js shouldn't have other type of ExtraCheckKind"),
885                }
886            }
887            ExtraCheckLang::Py | ExtraCheckLang::Cpp => {
888                let venv_path = build_dir.join("venv");
889                let mut reqs_path = root_path.to_owned();
890                reqs_path.extend(PIP_REQ_PATH);
891                let Ok(v) = has_py_tools(&venv_path, &reqs_path) else {
892                    return false;
893                };
894
895                v
896            }
897        }
898    }
899
900    /// Returns `false` if this is an auto arg and the passed filename does not trigger the auto rule
901    fn is_non_auto_or_matches(&self, filepath: &str) -> bool {
902        if !self.auto {
903            return true;
904        }
905        let exts: &[&str] = match self.lang {
906            ExtraCheckLang::Py => &[".py"],
907            ExtraCheckLang::Cpp => &[".cpp"],
908            ExtraCheckLang::Shell => &[".sh"],
909            ExtraCheckLang::Js => &[".js", ".ts"],
910            ExtraCheckLang::Spellcheck => {
911                if SPELLCHECK_DIRS.iter().any(|dir| Path::new(filepath).starts_with(dir)) {
912                    return true;
913                }
914                &[]
915            }
916        };
917        exts.iter().any(|ext| filepath.ends_with(ext))
918    }
919
920    fn has_supported_kind(&self) -> bool {
921        let Some(kind) = self.kind else {
922            // "run all extra checks" mode is supported for all languages.
923            return true;
924        };
925        use ExtraCheckKind::*;
926        let supported_kinds: &[_] = match self.lang {
927            ExtraCheckLang::Py => &[Fmt, Lint],
928            ExtraCheckLang::Cpp => &[Fmt],
929            ExtraCheckLang::Shell => &[Lint],
930            ExtraCheckLang::Spellcheck => &[],
931            ExtraCheckLang::Js => &[Lint, Typecheck],
932        };
933        supported_kinds.contains(&kind)
934    }
935}
936
937impl FromStr for ExtraCheckArg {
938    type Err = ExtraCheckParseError;
939
940    fn from_str(s: &str) -> Result<Self, Self::Err> {
941        let mut auto = false;
942        let mut if_installed = false;
943        let mut parts = s.split(':');
944        let mut first = match parts.next() {
945            Some("") | None => return Err(ExtraCheckParseError::Empty),
946            Some(part) => part,
947        };
948
949        // The loop allows users to specify `auto` and `if-installed` in any order.
950        // Both auto:if-installed:<check> and if-installed:auto:<check> are valid.
951        loop {
952            match (first, auto, if_installed) {
953                ("auto", false, _) => {
954                    let Some(part) = parts.next() else {
955                        return Err(ExtraCheckParseError::AutoRequiresLang);
956                    };
957                    auto = true;
958                    first = part;
959                }
960                ("if-installed", _, false) => {
961                    let Some(part) = parts.next() else {
962                        return Err(ExtraCheckParseError::IfInstalledRequiresLang);
963                    };
964                    if_installed = true;
965                    first = part;
966                }
967                _ => break,
968            }
969        }
970        let second = parts.next();
971        if parts.next().is_some() {
972            return Err(ExtraCheckParseError::TooManyParts);
973        }
974        let arg = Self {
975            auto,
976            if_installed,
977            lang: first.parse()?,
978            kind: second.map(|s| s.parse()).transpose()?,
979        };
980        if !arg.has_supported_kind() {
981            return Err(ExtraCheckParseError::UnsupportedKindForLang);
982        }
983
984        Ok(arg)
985    }
986}
987
988#[derive(PartialEq, Copy, Clone, Debug)]
989enum ExtraCheckLang {
990    Py,
991    Shell,
992    Cpp,
993    Spellcheck,
994    Js,
995}
996
997impl FromStr for ExtraCheckLang {
998    type Err = ExtraCheckParseError;
999
1000    fn from_str(s: &str) -> Result<Self, Self::Err> {
1001        Ok(match s {
1002            "py" => Self::Py,
1003            "shell" => Self::Shell,
1004            "cpp" => Self::Cpp,
1005            "spellcheck" => Self::Spellcheck,
1006            "js" => Self::Js,
1007            _ => return Err(ExtraCheckParseError::UnknownLang(s.to_string())),
1008        })
1009    }
1010}
1011
1012#[derive(PartialEq, Copy, Clone, Debug)]
1013enum ExtraCheckKind {
1014    Lint,
1015    Fmt,
1016    Typecheck,
1017    /// Never parsed, but used as a placeholder for
1018    /// langs that never have a specific kind.
1019    None,
1020}
1021
1022impl FromStr for ExtraCheckKind {
1023    type Err = ExtraCheckParseError;
1024
1025    fn from_str(s: &str) -> Result<Self, Self::Err> {
1026        Ok(match s {
1027            "lint" => Self::Lint,
1028            "fmt" => Self::Fmt,
1029            "typecheck" => Self::Typecheck,
1030            _ => return Err(ExtraCheckParseError::UnknownKind(s.to_string())),
1031        })
1032    }
1033}