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