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