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