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