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