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