src/tools/tidy/src/extra_checks/mod.rs RUST 1,140 lines View on github.com → Search inside
1//! Optional checks for file types other than Rust source2//!3//! Handles python tool version management via a virtual environment in4//! `build/venv`.5//!6//! # Functional outline7//!8//! 1. Run tidy with an extra option: `--extra-checks=py,shell`,9//!    `--extra-checks=py:lint`, or similar. Optionally provide specific10//!    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 mode13//!    - If `--bless` is provided, formatters may run14//!    - 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 suggestion17//!    diff.1819use std::ffi::{OsStr, OsString};20use std::path::{Path, PathBuf};21use std::process::Command;22use std::str::FromStr;23use std::{env, fmt, fs, io};2425use crate::diagnostics::TidyCtx;2627mod rustdoc_js;2829#[cfg(test)]30mod tests;3132const MIN_PY_REV: (u32, u32) = (3, 11);33const MIN_PY_REV_STR: &str = "≥3.11";3435/// Path to find the python executable within a virtual environment36#[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"];4041const RUFF_CONFIG_PATH: &[&str] = &["src", "tools", "tidy", "config", "ruff.toml"];42/// Location within build directory43const RUFF_CACHE_PATH: &[&str] = &["cache", "ruff_cache"];44const PIP_REQ_PATH: &[&str] = &["src", "tools", "tidy", "config", "requirements.txt"];4546const SPELLCHECK_DIRS: &[&str] = &["compiler", "library", "src/bootstrap", "src/librustdoc"];47const SPELLCHECK_VER: &str = "1.38.1";4849pub 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 up61    let mut lint_args = match extra_checks {62        Some(s) => s63            .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                    None78                }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    }9293    macro_rules! extra_check {94        ($lang:ident, $kind:ident) => {95            lint_args.iter().any(|arg| arg.matches(ExtraCheckLang::$lang, ExtraCheckKind::$kind))96        };97    }9899    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);106107    let mut py_path = None;108109    let (cfg_args, file_args): (Vec<_>, Vec<_>) = pos_args110        .iter()111        .map(OsStr::new)112        .partition(|arg| arg.to_str().is_some_and(|s| s.starts_with('-')));113114    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    }122123    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    }133134    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    }144145    if cpp_fmt {146        check_cpp_fmt(root_path, &cfg_args, &file_args, py_path.as_ref().unwrap(), &tidy_ctx);147    }148149    if shell_lint {150        check_shell_lint(root_path, &cfg_args, &file_args, &tidy_ctx);151    }152153    if spellcheck {154        check_spellcheck(root_path, outdir, cargo, &tidy_ctx);155    }156157    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    }163164    if js_lint {165        check_js_lint(outdir, librustdoc_path, tools_path, &tidy_ctx);166    }167168    if js_typecheck {169        check_js_typecheck(outdir, librustdoc_path, &tidy_ctx);170    }171}172173fn py_prepare(root_path: &Path, outdir: &Path, tidy_ctx: &TidyCtx) -> Option<PathBuf> {174    let mut check = tidy_ctx.start_check("extra_checks:py_prepare");175176    let venv_path = outdir.join("venv");177    let mut reqs_path = root_path.to_owned();178    reqs_path.extend(PIP_REQ_PATH);179180    match get_or_create_venv(&venv_path, &reqs_path) {181        Ok(p) => Some(p),182        Err(e) => {183            check.error(e);184            None185        }186    }187}188189fn 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");191192    if let Err(e) = rustdoc_js::npm_install(root_path, outdir, npm) {193        check.error(e.to_string());194        return None;195    }196197    Some(())198}199200fn 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}207208fn check_spellcheck(root_path: &Path, outdir: &Path, cargo: &Path, tidy_ctx: &TidyCtx) {209    let mut check = tidy_ctx.start_check("extra_checks:spellcheck");210211    let bless = tidy_ctx.is_bless_enabled();212213    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);216217    if bless {218        eprintln!("spellchecking files and fixing typos");219        args.push("--write-changes");220    } else {221        eprintln!("spellchecking files");222    }223224    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}231232fn 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");234235    let bless = tidy_ctx.is_bless_enabled();236237    if bless {238        eprintln!("linting javascript files and applying suggestions");239    } else {240        eprintln!("linting javascript files");241    }242243    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    }248249    if let Err(e) = rustdoc_js::es_check(outdir, librustdoc_path) {250        check.error(e);251    }252}253254fn check_js_typecheck(outdir: &Path, librustdoc_path: &Path, tidy_ctx: &TidyCtx) {255    let mut check = tidy_ctx.start_check("extra_checks:js_typecheck");256257    eprintln!("typechecking javascript files");258    if let Err(e) = rustdoc_js::typecheck(outdir, librustdoc_path) {259        check.error(e);260    }261}262263fn 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");270271    eprintln!("linting shell files");272273    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        }283284        file_args_shc.extend(files.iter().map(|p| p.as_os_str()));285    }286287    if let Err(e) = shellcheck_runner(&merge_args(&cfg_args, &file_args_shc)) {288        check.error(e);289    }290}291292fn 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");301302    let bless = tidy_ctx.is_bless_enabled();303304    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    };311312    let res = run_ruff(root_path, outdir, py_path, &cfg_args, &file_args, args);313314    if res.is_err() && !bless {315        eprintln!("\npython linting failed! Printing diff suggestions:");316317        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}334335fn 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");344345    let bless = tidy_ctx.is_bless_enabled();346347    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    }354355    let res = run_ruff(root_path, outdir, py_path, &cfg_args, &file_args, &args);356357    if res.is_err() && !bless {358        eprintln!("\npython formatting does not match! Printing diff:");359360        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    }370371    if let Err(e) = res {372        check.error(e);373    }374}375376fn 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");384385    let bless = tidy_ctx.is_bless_enabled();386387    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);418419    if res.is_err() && !bless {420        eprintln!("\nclang-format linting failed! Printing diff suggestions:");421422        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    }452453    if let Err(e) = res {454        check.error(e);455    }456}457458fn 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();468469    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);473474    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    ]);480481    if file_args_ruff.is_empty() {482        file_args_ruff.push(root_path.as_os_str());483    }484485    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}489490/// Helper to create `cfg1 cfg2 -- file1 file2` output491fn 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    args496}497498/// 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.status521    } else {522        cmd.status()?523    };524    if status.success() { Ok(()) } else { Err(Error::FailedCheck(bin)) }525}526527/// Create a virtuaenv at a given path if it doesn't already exist, or validate528/// 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);532533    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    }544545    verify_py_version(&py_path)?;546    Ok(py_path)547}548549fn 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    }557558    Ok(false)559}560561/// Attempt to create a virtualenv at this path. Cycles through all expected562/// 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 current565    /// development versions566    const TRY_PY: &[&str] = &[567        "python3.14",568        "python3.13",569        "python3.12",570        "python3.11",571        "python3",572        "python",573        "python3.15",574    ];575576    let mut sys_py = None;577    let mut found = Vec::new();578579    for py in TRY_PY {580        match verify_py_version(Path::new(py)) {581            Ok(_) => {582                sys_py = Some(*py);583                break;584            }585            // Skip not found errors586            Err(Error::Io(e)) if e.kind() == io::ErrorKind::NotFound => (),587            // Skip insufficient version errors588            Err(Error::Version { installed, .. }) => found.push(installed),589            // just log and skip unrecognized errors590            Err(e) => eprintln!("note: error running '{py}': {e}"),591        }592    }593594    let Some(sys_py) = sys_py else {595        let ret = if found.is_empty() {596            Error::MissingReq("python3", "python file checks", None)597        } else {598            found.sort();599            found.dedup();600            Error::Version {601                program: "python3",602                required: MIN_PY_REV_STR,603                installed: found.join(", "),604            }605        };606        return Err(ret);607    };608609    // First try venv, which should be packaged in the Python3 standard library.610    // If it is not available, try to create the virtual environment using the611    // virtualenv package.612    if try_create_venv(sys_py, path, "venv").is_ok() {613        return Ok(());614    }615    try_create_venv(sys_py, path, "virtualenv")616}617618fn try_create_venv(python: &str, path: &Path, module: &str) -> Result<(), Error> {619    eprintln!(620        "creating virtual environment at '{}' using '{python}' and '{module}'",621        path.display()622    );623    let out = Command::new(python).args(["-m", module]).arg(path).output().unwrap();624625    if out.status.success() {626        return Ok(());627    }628629    let stderr = String::from_utf8_lossy(&out.stderr);630    let err = if stderr.contains(&format!("No module named {module}")) {631        Error::Generic(format!(632            r#"{module} not found: you may need to install it:633`{python} -m pip install {module}`634If you see an error about "externally managed environment" when running the above command,635either install `{module}` using your system package manager636(e.g. `sudo apt-get install {python}-{module}`) or create a virtual environment manually, install637`{module}` in it and then activate it before running tidy.638"#639        ))640    } else {641        Error::Generic(format!(642            "failed to create venv at '{}' using {python} -m {module}: {stderr}",643            path.display()644        ))645    };646    Err(err)647}648649/// Parse python's version output (`Python x.y.z`) and ensure we have a650/// suitable version.651fn verify_py_version(py_path: &Path) -> Result<(), Error> {652    let out = Command::new(py_path).arg("--version").output()?;653    let outstr = String::from_utf8_lossy(&out.stdout);654    let vers = outstr.trim().split_ascii_whitespace().nth(1).unwrap().trim();655    let mut vers_comps = vers.split('.');656    let major: u32 = vers_comps.next().unwrap().parse().unwrap();657    let minor: u32 = vers_comps.next().unwrap().parse().unwrap();658659    if (major, minor) < MIN_PY_REV {660        Err(Error::Version {661            program: "python",662            required: MIN_PY_REV_STR,663            installed: vers.to_owned(),664        })665    } else {666        Ok(())667    }668}669670fn install_requirements(671    py_path: &Path,672    src_reqs_path: &Path,673    dst_reqs_path: &Path,674) -> Result<(), Error> {675    let stat = Command::new(py_path)676        .args(["-m", "pip", "install", "--upgrade", "pip"])677        .status()678        .expect("failed to launch pip");679    if !stat.success() {680        return Err(Error::Generic(format!("pip install failed with status {stat}")));681    }682683    let stat = Command::new(py_path)684        .args(["-m", "pip", "install", "--quiet", "--require-hashes", "-r"])685        .arg(src_reqs_path)686        .status()?;687    if !stat.success() {688        return Err(Error::Generic(format!(689            "failed to install requirements at {}",690            src_reqs_path.display()691        )));692    }693    fs::copy(src_reqs_path, dst_reqs_path)?;694    assert_eq!(695        fs::read_to_string(src_reqs_path).unwrap(),696        fs::read_to_string(dst_reqs_path).unwrap()697    );698    Ok(())699}700701/// Returns `Ok` if shellcheck is installed, `Err` otherwise.702fn has_shellcheck() -> Result<(), Error> {703    match Command::new("shellcheck").arg("--version").status() {704        Ok(_) => Ok(()),705        Err(e) if e.kind() == io::ErrorKind::NotFound => Err(Error::MissingReq(706            "shellcheck",707            "shell file checks",708            Some(709                "see <https://github.com/koalaman/shellcheck#installing> \710                for installation instructions"711                    .to_owned(),712            ),713        )),714        Err(e) => Err(e.into()),715    }716}717718/// Check that shellcheck is installed then run it at the given path719fn shellcheck_runner(args: &[&OsStr]) -> Result<(), Error> {720    has_shellcheck()?;721722    let status = Command::new("shellcheck").args(args).status()?;723    if status.success() { Ok(()) } else { Err(Error::FailedCheck("shellcheck")) }724}725726/// Ensure that spellchecker is installed then run it at the given path727fn spellcheck_runner(728    src_root: &Path,729    outdir: &Path,730    cargo: &Path,731    args: &[&str],732    is_ci: bool,733) -> Result<(), Error> {734    let bin_path = ensure_version_or_cargo_install(735        outdir,736        cargo,737        "typos-cli",738        "typos",739        SPELLCHECK_VER,740        is_ci,741    )?;742    match Command::new(bin_path).current_dir(src_root).args(args).status() {743        Ok(status) => {744            if status.success() {745                Ok(())746            } else {747                Err(Error::FailedCheck("typos"))748            }749        }750        Err(err) => Err(Error::Generic(format!("failed to run typos tool: {err:?}"))),751    }752}753754/// Check git for tracked files matching an extension755fn find_with_extension(756    root_path: &Path,757    find_dir: Option<&Path>,758    extensions: &[&OsStr],759) -> Result<Vec<PathBuf>, Error> {760    // Untracked files show up for short status and are indicated with a leading `?`761    // -C changes git to be as if run from that directory762    let stat_output =763        Command::new("git").arg("-C").arg(root_path).args(["status", "--short"]).output()?.stdout;764765    if String::from_utf8_lossy(&stat_output).lines().filter(|ln| ln.starts_with('?')).count() > 0 {766        eprintln!("found untracked files, ignoring");767    }768769    let mut output = Vec::new();770    let binding = {771        let mut command = Command::new("git");772        command.arg("-C").arg(root_path).args(["ls-files"]);773        if let Some(find_dir) = find_dir {774            command.arg(find_dir);775        }776        command.output()?777    };778    let tracked = String::from_utf8_lossy(&binding.stdout);779780    for line in tracked.lines() {781        let line = line.trim();782        let path = Path::new(line);783784        let Some(ref extension) = path.extension() else {785            continue;786        };787        if extensions.contains(extension) {788            output.push(root_path.join(path));789        }790    }791792    Ok(output)793}794795/// Check if the given executable is installed and the version is expected.796fn ensure_version(build_dir: &Path, bin_name: &str, version: &str) -> Result<PathBuf, Error> {797    let bin_path = build_dir.join("misc-tools").join("bin").join(bin_name);798799    match Command::new(&bin_path).arg("--version").output() {800        Ok(output) => {801            let Some(v) = str::from_utf8(&output.stdout).unwrap().trim().split_whitespace().last()802            else {803                return Err(Error::Generic("version check failed".to_string()));804            };805806            if v != version {807                return Err(Error::Version { program: "", required: "", installed: v.to_string() });808            }809            Ok(bin_path)810        }811        Err(e) => Err(Error::Io(e)),812    }813}814815/// If the given executable is installed with the given version, use that,816/// otherwise install via cargo.817fn ensure_version_or_cargo_install(818    build_dir: &Path,819    cargo: &Path,820    pkg_name: &str,821    bin_name: &str,822    version: &str,823    is_ci: bool,824) -> Result<PathBuf, Error> {825    if let Ok(bin_path) = ensure_version(build_dir, bin_name, version) {826        return Ok(bin_path);827    }828829    eprintln!("building external tool {bin_name} from package {pkg_name}@{version}");830831    let tool_root_dir = build_dir.join("misc-tools");832    let tool_bin_dir = tool_root_dir.join("bin");833    let bin_path = tool_bin_dir.join(bin_name).with_extension(env::consts::EXE_EXTENSION);834835    // use --force to ensure that if the required version is bumped, we update it.836    // use --target-dir to ensure we have a build cache so repeated invocations aren't slow.837    // modify PATH so that cargo doesn't print a warning telling the user to modify the path.838    let mut cmd = Command::new(cargo);839    cmd.args(["install", "--locked", "--force", "--quiet"])840        .arg("--root")841        .arg(&tool_root_dir)842        .arg("--target-dir")843        .arg(tool_root_dir.join("target"))844        .arg(format!("{pkg_name}@{version}"))845        .env(846            "PATH",847            env::join_paths(848                env::split_paths(&env::var("PATH").unwrap())849                    .chain(std::iter::once(tool_bin_dir.clone())),850            )851            .expect("build dir contains invalid char"),852        );853854    // On CI, we set opt-level flag for quicker installation.855    // Since lower opt-level decreases the tool's performance,856    // we don't set this option on local.857    if is_ci {858        cmd.env("RUSTFLAGS", "-Copt-level=0");859    }860861    let cargo_exit_code = cmd.spawn()?.wait()?;862    if !cargo_exit_code.success() {863        return Err(Error::Generic("cargo install failed".to_string()));864    }865    assert!(866        matches!(bin_path.try_exists(), Ok(true)),867        "cargo install did not produce the expected binary"868    );869    eprintln!("finished building tool {bin_name}");870    Ok(bin_path)871}872873#[derive(Debug)]874enum Error {875    Io(io::Error),876    /// a is required to run b. c is extra info877    MissingReq(&'static str, &'static str, Option<String>),878    /// Tool x failed the check879    FailedCheck(&'static str),880    /// Any message, just print it881    Generic(String),882    /// Installed but wrong version883    Version {884        program: &'static str,885        required: &'static str,886        installed: String,887    },888}889890impl fmt::Display for Error {891    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {892        match self {893            Self::MissingReq(a, b, ex) => {894                write!(895                    f,896                    "{a} is required to run {b} but it could not be located. Is it installed?"897                )?;898                if let Some(s) = ex {899                    write!(f, "\n{s}")?;900                };901                Ok(())902            }903            Self::Version { program, required, installed } => write!(904                f,905                "insufficient version of '{program}' to run external tools: \906                {required} required but found {installed}",907            ),908            Self::Generic(s) => f.write_str(s),909            Self::Io(e) => write!(f, "IO error: {e}"),910            Self::FailedCheck(s) => write!(f, "checks with external tool '{s}' failed"),911        }912    }913}914915impl From<io::Error> for Error {916    fn from(value: io::Error) -> Self {917        Self::Io(value)918    }919}920921#[derive(Debug, PartialEq)]922enum ExtraCheckParseError {923    #[allow(dead_code, reason = "shown through Debug")]924    UnknownKind(String),925    #[allow(dead_code)]926    UnknownLang(String),927    UnsupportedKindForLang,928    /// Too many `:`929    TooManyParts,930    /// Tried to parse the empty string931    Empty,932    /// `auto` specified without lang part.933    AutoRequiresLang,934    /// `if-installed` specified without lang part.935    IfInstalledRequiresLang,936}937938#[derive(PartialEq, Debug)]939struct ExtraCheckArg {940    /// Only run the check if files to check have been modified.941    auto: bool,942    /// Only run the check if the requisite software is already installed.943    if_installed: bool,944    lang: ExtraCheckLang,945    /// None = run all extra checks for the given lang946    kind: Option<ExtraCheckKind>,947}948949impl ExtraCheckArg {950    fn matches(&self, lang: ExtraCheckLang, kind: ExtraCheckKind) -> bool {951        self.lang == lang && self.kind.map(|k| k == kind).unwrap_or(true)952    }953954    fn is_non_if_installed_or_matches(&self, root_path: &Path, build_dir: &Path) -> bool {955        if !self.if_installed {956            return true;957        }958959        match self.lang {960            ExtraCheckLang::Spellcheck => {961                match ensure_version(build_dir, "typos", SPELLCHECK_VER) {962                    Ok(_) => true,963                    Err(Error::Version { installed, .. }) => {964                        eprintln!(965                            "warning: the tool `typos` is detected, but version {installed} doesn't match with the expected version {SPELLCHECK_VER}"966                        );967                        false968                    }969                    _ => false,970                }971            }972            ExtraCheckLang::Shell => has_shellcheck().is_ok(),973            ExtraCheckLang::Js => {974                match self.kind {975                    Some(ExtraCheckKind::Lint) => {976                        // If Lint is enabled, check both eslint and es-check.977                        rustdoc_js::has_tool(build_dir, "eslint")978                            && rustdoc_js::has_tool(build_dir, "es-check")979                    }980                    Some(ExtraCheckKind::Typecheck) => {981                        // If Typecheck is enabled, check tsc.982                        rustdoc_js::has_tool(build_dir, "tsc")983                    }984                    None => {985                        // No kind means it will check both Lint and Typecheck.986                        rustdoc_js::has_tool(build_dir, "eslint")987                            && rustdoc_js::has_tool(build_dir, "es-check")988                            && rustdoc_js::has_tool(build_dir, "tsc")989                    }990                    Some(_) => unreachable!("js shouldn't have other type of ExtraCheckKind"),991                }992            }993            ExtraCheckLang::Py | ExtraCheckLang::Cpp => {994                let venv_path = build_dir.join("venv");995                let mut reqs_path = root_path.to_owned();996                reqs_path.extend(PIP_REQ_PATH);997                let Ok(v) = has_py_tools(&venv_path, &reqs_path) else {998                    return false;999                };10001001                v1002            }1003        }1004    }10051006    /// Returns `false` if this is an auto arg and the passed filename does not trigger the auto rule1007    fn is_non_auto_or_matches(&self, filepath: &str) -> bool {1008        if !self.auto {1009            return true;1010        }1011        let exts: &[&str] = match self.lang {1012            ExtraCheckLang::Py => &[".py"],1013            ExtraCheckLang::Cpp => &[".cpp"],1014            ExtraCheckLang::Shell => &[".sh"],1015            ExtraCheckLang::Js => &[".js", ".ts"],1016            ExtraCheckLang::Spellcheck => {1017                if SPELLCHECK_DIRS.iter().any(|dir| Path::new(filepath).starts_with(dir)) {1018                    return true;1019                }1020                &[]1021            }1022        };1023        exts.iter().any(|ext| filepath.ends_with(ext))1024    }10251026    fn has_supported_kind(&self) -> bool {1027        let Some(kind) = self.kind else {1028            // "run all extra checks" mode is supported for all languages.1029            return true;1030        };1031        use ExtraCheckKind::*;1032        let supported_kinds: &[_] = match self.lang {1033            ExtraCheckLang::Py => &[Fmt, Lint],1034            ExtraCheckLang::Cpp => &[Fmt],1035            ExtraCheckLang::Shell => &[Lint],1036            ExtraCheckLang::Spellcheck => &[],1037            ExtraCheckLang::Js => &[Lint, Typecheck],1038        };1039        supported_kinds.contains(&kind)1040    }1041}10421043impl FromStr for ExtraCheckArg {1044    type Err = ExtraCheckParseError;10451046    fn from_str(s: &str) -> Result<Self, Self::Err> {1047        let mut auto = false;1048        let mut if_installed = false;1049        let mut parts = s.split(':');1050        let mut first = match parts.next() {1051            Some("") | None => return Err(ExtraCheckParseError::Empty),1052            Some(part) => part,1053        };10541055        // The loop allows users to specify `auto` and `if-installed` in any order.1056        // Both auto:if-installed:<check> and if-installed:auto:<check> are valid.1057        loop {1058            match (first, auto, if_installed) {1059                ("auto", false, _) => {1060                    let Some(part) = parts.next() else {1061                        return Err(ExtraCheckParseError::AutoRequiresLang);1062                    };1063                    auto = true;1064                    first = part;1065                }1066                ("if-installed", _, false) => {1067                    let Some(part) = parts.next() else {1068                        return Err(ExtraCheckParseError::IfInstalledRequiresLang);1069                    };1070                    if_installed = true;1071                    first = part;1072                }1073                _ => break,1074            }1075        }1076        let second = parts.next();1077        if parts.next().is_some() {1078            return Err(ExtraCheckParseError::TooManyParts);1079        }1080        let arg = Self {1081            auto,1082            if_installed,1083            lang: first.parse()?,1084            kind: second.map(|s| s.parse()).transpose()?,1085        };1086        if !arg.has_supported_kind() {1087            return Err(ExtraCheckParseError::UnsupportedKindForLang);1088        }10891090        Ok(arg)1091    }1092}10931094#[derive(PartialEq, Copy, Clone, Debug)]1095enum ExtraCheckLang {1096    Py,1097    Shell,1098    Cpp,1099    Spellcheck,1100    Js,1101}11021103impl FromStr for ExtraCheckLang {1104    type Err = ExtraCheckParseError;11051106    fn from_str(s: &str) -> Result<Self, Self::Err> {1107        Ok(match s {1108            "py" => Self::Py,1109            "shell" => Self::Shell,1110            "cpp" => Self::Cpp,1111            "spellcheck" => Self::Spellcheck,1112            "js" => Self::Js,1113            _ => return Err(ExtraCheckParseError::UnknownLang(s.to_string())),1114        })1115    }1116}11171118#[derive(PartialEq, Copy, Clone, Debug)]1119enum ExtraCheckKind {1120    Lint,1121    Fmt,1122    Typecheck,1123    /// Never parsed, but used as a placeholder for1124    /// langs that never have a specific kind.1125    None,1126}11271128impl FromStr for ExtraCheckKind {1129    type Err = ExtraCheckParseError;11301131    fn from_str(s: &str) -> Result<Self, Self::Err> {1132        Ok(match s {1133            "lint" => Self::Lint,1134            "fmt" => Self::Fmt,1135            "typecheck" => Self::Typecheck,1136            _ => return Err(ExtraCheckParseError::UnknownKind(s.to_string())),1137        })1138    }1139}

Findings

✓ No findings reported for this file.

Get this view in your editor

Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.