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