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