1use std::ffi::OsStr;
21use std::path::{Path, PathBuf};
22use std::process::Command;
23use std::str::FromStr;
24use std::{env, fmt, fs, io};
25
26use build_helper::ci::CiEnv;
27
28use crate::CiInfo;
29use crate::diagnostics::TidyCtx;
30
31mod rustdoc_js;
32
33#[cfg(test)]
34mod tests;
35
36const MIN_PY_REV: (u32, u32) = (3, 9);
37const MIN_PY_REV_STR: &str = "≥3.9";
38
39#[cfg(target_os = "windows")]
41const REL_PY_PATH: &[&str] = &["Scripts", "python3.exe"];
42#[cfg(not(target_os = "windows"))]
43const REL_PY_PATH: &[&str] = &["bin", "python3"];
44
45const RUFF_CONFIG_PATH: &[&str] = &["src", "tools", "tidy", "config", "ruff.toml"];
46const RUFF_CACHE_PATH: &[&str] = &["cache", "ruff_cache"];
48const PIP_REQ_PATH: &[&str] = &["src", "tools", "tidy", "config", "requirements.txt"];
49
50const SPELLCHECK_DIRS: &[&str] = &["compiler", "library", "src/bootstrap", "src/librustdoc"];
51const SPELLCHECK_VER: &str = "1.38.1";
52
53pub fn check(
54 root_path: &Path,
55 outdir: &Path,
56 ci_info: &CiInfo,
57 librustdoc_path: &Path,
58 tools_path: &Path,
59 npm: &Path,
60 cargo: &Path,
61 extra_checks: Option<&str>,
62 pos_args: &[String],
63 tidy_ctx: TidyCtx,
64) {
65 let mut check = tidy_ctx.start_check("extra_checks");
66
67 if let Err(e) = check_impl(
68 root_path,
69 outdir,
70 ci_info,
71 librustdoc_path,
72 tools_path,
73 npm,
74 cargo,
75 extra_checks,
76 pos_args,
77 &tidy_ctx,
78 ) {
79 check.error(e);
80 }
81}
82
83fn check_impl(
84 root_path: &Path,
85 outdir: &Path,
86 ci_info: &CiInfo,
87 librustdoc_path: &Path,
88 tools_path: &Path,
89 npm: &Path,
90 cargo: &Path,
91 extra_checks: Option<&str>,
92 pos_args: &[String],
93 tidy_ctx: &TidyCtx,
94) -> Result<(), Error> {
95 let show_diff =
96 std::env::var("TIDY_PRINT_DIFF").is_ok_and(|v| v.eq_ignore_ascii_case("true") || v == "1");
97 let bless = tidy_ctx.is_bless_enabled();
98
99 let mut lint_args = match extra_checks {
101 Some(s) => s
102 .strip_prefix("--extra-checks=")
103 .unwrap()
104 .split(',')
105 .map(|s| {
106 if s == "spellcheck:fix" {
107 eprintln!("warning: `spellcheck:fix` is no longer valid, use `--extra-checks=spellcheck --bless`");
108 }
109 (ExtraCheckArg::from_str(s), s)
110 })
111 .filter_map(|(res, src)| match res {
112 Ok(arg) => {
113 Some(arg)
114 }
115 Err(err) => {
116 eprintln!("warning: bad extra check argument {src:?}: {err:?}");
118 None
119 }
120 })
121 .collect(),
122 None => vec![],
123 };
124 lint_args.retain(|ck| ck.is_non_if_installed_or_matches(root_path, outdir));
125 if lint_args.iter().any(|ck| ck.auto) {
126 crate::files_modified_batch_filter(ci_info, &mut lint_args, |ck, path| {
127 ck.is_non_auto_or_matches(path)
128 });
129 }
130
131 macro_rules! extra_check {
132 ($lang:ident, $kind:ident) => {
133 lint_args.iter().any(|arg| arg.matches(ExtraCheckLang::$lang, ExtraCheckKind::$kind))
134 };
135 }
136
137 let rerun_with_bless = |mode: &str, action: &str| {
138 if !bless {
139 eprintln!("rerun tidy with `--extra-checks={mode} --bless` to {action}");
140 }
141 };
142
143 let python_lint = extra_check!(Py, Lint);
144 let python_fmt = extra_check!(Py, Fmt);
145 let shell_lint = extra_check!(Shell, Lint);
146 let cpp_fmt = extra_check!(Cpp, Fmt);
147 let spellcheck = extra_check!(Spellcheck, None);
148 let js_lint = extra_check!(Js, Lint);
149 let js_typecheck = extra_check!(Js, Typecheck);
150
151 let mut py_path = None;
152
153 let (cfg_args, file_args): (Vec<_>, Vec<_>) = pos_args
154 .iter()
155 .map(OsStr::new)
156 .partition(|arg| arg.to_str().is_some_and(|s| s.starts_with('-')));
157
158 if python_lint || python_fmt || cpp_fmt {
159 let venv_path = outdir.join("venv");
160 let mut reqs_path = root_path.to_owned();
161 reqs_path.extend(PIP_REQ_PATH);
162 py_path = Some(get_or_create_venv(&venv_path, &reqs_path)?);
163 }
164
165 if python_lint {
166 let py_path = py_path.as_ref().unwrap();
167 let args: &[&OsStr] = if bless {
168 eprintln!("linting python files and applying suggestions");
169 &["check".as_ref(), "--fix".as_ref()]
170 } else {
171 eprintln!("linting python files");
172 &["check".as_ref()]
173 };
174
175 let res = run_ruff(root_path, outdir, py_path, &cfg_args, &file_args, args);
176
177 if res.is_err() && show_diff && !bless {
178 eprintln!("\npython linting failed! Printing diff suggestions:");
179
180 let diff_res = run_ruff(
181 root_path,
182 outdir,
183 py_path,
184 &cfg_args,
185 &file_args,
186 &["check".as_ref(), "--diff".as_ref()],
187 );
188 if diff_res.is_err() {
190 rerun_with_bless("py:lint", "apply ruff suggestions");
191 }
192 }
193 res?;
195 }
196
197 if python_fmt {
198 let mut args: Vec<&OsStr> = vec!["format".as_ref()];
199 if bless {
200 eprintln!("formatting python files");
201 } else {
202 eprintln!("checking python file formatting");
203 args.push("--check".as_ref());
204 }
205
206 let py_path = py_path.as_ref().unwrap();
207 let res = run_ruff(root_path, outdir, py_path, &cfg_args, &file_args, &args);
208
209 if res.is_err() && !bless {
210 if show_diff {
211 eprintln!("\npython formatting does not match! Printing diff:");
212
213 let _ = run_ruff(
214 root_path,
215 outdir,
216 py_path,
217 &cfg_args,
218 &file_args,
219 &["format".as_ref(), "--diff".as_ref()],
220 );
221 }
222 rerun_with_bless("py:fmt", "reformat Python code");
223 }
224
225 res?;
227 }
228
229 if cpp_fmt {
230 let mut cfg_args_clang_format = cfg_args.clone();
231 let mut file_args_clang_format = file_args.clone();
232 let config_path = root_path.join(".clang-format");
233 let config_file_arg = format!("file:{}", config_path.display());
234 cfg_args_clang_format.extend(&["--style".as_ref(), config_file_arg.as_ref()]);
235 if bless {
236 eprintln!("formatting C++ files");
237 cfg_args_clang_format.push("-i".as_ref());
238 } else {
239 eprintln!("checking C++ file formatting");
240 cfg_args_clang_format.extend(&["--dry-run".as_ref(), "--Werror".as_ref()]);
241 }
242 let files;
243 if file_args_clang_format.is_empty() {
244 let llvm_wrapper = root_path.join("compiler/rustc_llvm/llvm-wrapper");
245 files = find_with_extension(
246 root_path,
247 Some(llvm_wrapper.as_path()),
248 &[OsStr::new("h"), OsStr::new("cpp")],
249 )?;
250 file_args_clang_format.extend(files.iter().map(|p| p.as_os_str()));
251 }
252 let args = merge_args(&cfg_args_clang_format, &file_args_clang_format);
253 let res = py_runner(py_path.as_ref().unwrap(), false, None, "clang-format", &args);
254
255 if res.is_err() && show_diff && !bless {
256 eprintln!("\nclang-format linting failed! Printing diff suggestions:");
257
258 let mut cfg_args_clang_format_diff = cfg_args.clone();
259 cfg_args_clang_format_diff.extend(&["--style".as_ref(), config_file_arg.as_ref()]);
260 for file in file_args_clang_format {
261 let mut formatted = String::new();
262 let mut diff_args = cfg_args_clang_format_diff.clone();
263 diff_args.push(file);
264 let _ = py_runner(
265 py_path.as_ref().unwrap(),
266 false,
267 Some(&mut formatted),
268 "clang-format",
269 &diff_args,
270 );
271 if formatted.is_empty() {
272 eprintln!(
273 "failed to obtain the formatted content for '{}'",
274 file.to_string_lossy()
275 );
276 continue;
277 }
278 let actual = std::fs::read_to_string(file).unwrap_or_else(|e| {
279 panic!(
280 "failed to read the C++ file at '{}' due to '{e}'",
281 file.to_string_lossy()
282 )
283 });
284 if formatted != actual {
285 let diff = similar::TextDiff::from_lines(&actual, &formatted);
286 eprintln!(
287 "{}",
288 diff.unified_diff().context_radius(4).header(
289 &format!("{} (actual)", file.to_string_lossy()),
290 &format!("{} (formatted)", file.to_string_lossy())
291 )
292 );
293 }
294 }
295 rerun_with_bless("cpp:fmt", "reformat C++ code");
296 }
297 res?;
299 }
300
301 if shell_lint {
302 eprintln!("linting shell files");
303
304 let mut file_args_shc = file_args.clone();
305 let files;
306 if file_args_shc.is_empty() {
307 files = find_with_extension(root_path, None, &[OsStr::new("sh")])?;
308 file_args_shc.extend(files.iter().map(|p| p.as_os_str()));
309 }
310
311 shellcheck_runner(&merge_args(&cfg_args, &file_args_shc))?;
312 }
313
314 if spellcheck {
315 let config_path = root_path.join("typos.toml");
316 let mut args = vec!["-c", config_path.as_os_str().to_str().unwrap()];
317
318 args.extend_from_slice(SPELLCHECK_DIRS);
319
320 if bless {
321 eprintln!("spellchecking files and fixing typos");
322 args.push("--write-changes");
323 } else {
324 eprintln!("spellchecking files");
325 }
326 let res = spellcheck_runner(root_path, &outdir, &cargo, &args);
327 if res.is_err() {
328 rerun_with_bless("spellcheck", "fix typos");
329 }
330 res?;
331 }
332
333 if js_lint || js_typecheck {
334 rustdoc_js::npm_install(root_path, outdir, npm)?;
335 }
336
337 if js_lint {
338 if bless {
339 eprintln!("linting javascript files");
340 } else {
341 eprintln!("linting javascript files and applying suggestions");
342 }
343 let res = rustdoc_js::lint(outdir, librustdoc_path, tools_path, bless);
344 if res.is_err() {
345 rerun_with_bless("js:lint", "apply eslint suggestions");
346 }
347 res?;
348 rustdoc_js::es_check(outdir, librustdoc_path)?;
349 }
350
351 if js_typecheck {
352 eprintln!("typechecking javascript files");
353 rustdoc_js::typecheck(outdir, librustdoc_path)?;
354 }
355
356 Ok(())
357}
358
359fn run_ruff(
360 root_path: &Path,
361 outdir: &Path,
362 py_path: &Path,
363 cfg_args: &[&OsStr],
364 file_args: &[&OsStr],
365 ruff_args: &[&OsStr],
366) -> Result<(), Error> {
367 let mut cfg_args_ruff = cfg_args.to_vec();
368 let mut file_args_ruff = file_args.to_vec();
369
370 let mut cfg_path = root_path.to_owned();
371 cfg_path.extend(RUFF_CONFIG_PATH);
372 let mut cache_dir = outdir.to_owned();
373 cache_dir.extend(RUFF_CACHE_PATH);
374
375 cfg_args_ruff.extend([
376 "--config".as_ref(),
377 cfg_path.as_os_str(),
378 "--cache-dir".as_ref(),
379 cache_dir.as_os_str(),
380 ]);
381
382 if file_args_ruff.is_empty() {
383 file_args_ruff.push(root_path.as_os_str());
384 }
385
386 let mut args: Vec<&OsStr> = ruff_args.to_vec();
387 args.extend(merge_args(&cfg_args_ruff, &file_args_ruff));
388 py_runner(py_path, true, None, "ruff", &args)
389}
390
391fn merge_args<'a>(cfg_args: &[&'a OsStr], file_args: &[&'a OsStr]) -> Vec<&'a OsStr> {
393 let mut args = cfg_args.to_owned();
394 args.push("--".as_ref());
395 args.extend(file_args);
396 args
397}
398
399fn py_runner(
403 py_path: &Path,
404 as_module: bool,
405 stdout: Option<&mut String>,
406 bin: &'static str,
407 args: &[&OsStr],
408) -> Result<(), Error> {
409 let mut cmd = Command::new(py_path);
410 if as_module {
411 cmd.arg("-m").arg(bin).args(args);
412 } else {
413 let bin_path = py_path.with_file_name(bin);
414 cmd.arg(bin_path).args(args);
415 }
416 let status = if let Some(stdout) = stdout {
417 let output = cmd.output()?;
418 if let Ok(s) = std::str::from_utf8(&output.stdout) {
419 stdout.push_str(s);
420 }
421 output.status
422 } else {
423 cmd.status()?
424 };
425 if status.success() { Ok(()) } else { Err(Error::FailedCheck(bin)) }
426}
427
428fn get_or_create_venv(venv_path: &Path, src_reqs_path: &Path) -> Result<PathBuf, Error> {
431 let mut py_path = venv_path.to_owned();
432 py_path.extend(REL_PY_PATH);
433
434 if !has_py_tools(venv_path, src_reqs_path)? {
435 let dst_reqs_path = venv_path.join("requirements.txt");
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
450fn has_py_tools(venv_path: &Path, src_reqs_path: &Path) -> Result<bool, Error> {
451 let dst_reqs_path = venv_path.join("requirements.txt");
452 if let Ok(req) = fs::read_to_string(&dst_reqs_path) {
453 if req == fs::read_to_string(src_reqs_path)? {
454 return Ok(true);
455 }
456 eprintln!("requirements.txt file mismatch");
457 }
458
459 Ok(false)
460}
461
462fn create_venv_at_path(path: &Path) -> Result<(), Error> {
465 const TRY_PY: &[&str] = &[
468 "python3.13",
469 "python3.12",
470 "python3.11",
471 "python3.10",
472 "python3.9",
473 "python3",
474 "python",
475 "python3.14",
476 ];
477
478 let mut sys_py = None;
479 let mut found = Vec::new();
480
481 for py in TRY_PY {
482 match verify_py_version(Path::new(py)) {
483 Ok(_) => {
484 sys_py = Some(*py);
485 break;
486 }
487 Err(Error::Io(e)) if e.kind() == io::ErrorKind::NotFound => (),
489 Err(Error::Version { installed, .. }) => found.push(installed),
491 Err(e) => eprintln!("note: error running '{py}': {e}"),
493 }
494 }
495
496 let Some(sys_py) = sys_py else {
497 let ret = if found.is_empty() {
498 Error::MissingReq("python3", "python file checks", None)
499 } else {
500 found.sort();
501 found.dedup();
502 Error::Version {
503 program: "python3",
504 required: MIN_PY_REV_STR,
505 installed: found.join(", "),
506 }
507 };
508 return Err(ret);
509 };
510
511 if try_create_venv(sys_py, path, "venv").is_ok() {
515 return Ok(());
516 }
517 try_create_venv(sys_py, path, "virtualenv")
518}
519
520fn try_create_venv(python: &str, path: &Path, module: &str) -> Result<(), Error> {
521 eprintln!(
522 "creating virtual environment at '{}' using '{python}' and '{module}'",
523 path.display()
524 );
525 let out = Command::new(python).args(["-m", module]).arg(path).output().unwrap();
526
527 if out.status.success() {
528 return Ok(());
529 }
530
531 let stderr = String::from_utf8_lossy(&out.stderr);
532 let err = if stderr.contains(&format!("No module named {module}")) {
533 Error::Generic(format!(
534 r#"{module} not found: you may need to install it:
535`{python} -m pip install {module}`
536If you see an error about "externally managed environment" when running the above command,
537either install `{module}` using your system package manager
538(e.g. `sudo apt-get install {python}-{module}`) or create a virtual environment manually, install
539`{module}` in it and then activate it before running tidy.
540"#
541 ))
542 } else {
543 Error::Generic(format!(
544 "failed to create venv at '{}' using {python} -m {module}: {stderr}",
545 path.display()
546 ))
547 };
548 Err(err)
549}
550
551fn verify_py_version(py_path: &Path) -> Result<(), Error> {
554 let out = Command::new(py_path).arg("--version").output()?;
555 let outstr = String::from_utf8_lossy(&out.stdout);
556 let vers = outstr.trim().split_ascii_whitespace().nth(1).unwrap().trim();
557 let mut vers_comps = vers.split('.');
558 let major: u32 = vers_comps.next().unwrap().parse().unwrap();
559 let minor: u32 = vers_comps.next().unwrap().parse().unwrap();
560
561 if (major, minor) < MIN_PY_REV {
562 Err(Error::Version {
563 program: "python",
564 required: MIN_PY_REV_STR,
565 installed: vers.to_owned(),
566 })
567 } else {
568 Ok(())
569 }
570}
571
572fn install_requirements(
573 py_path: &Path,
574 src_reqs_path: &Path,
575 dst_reqs_path: &Path,
576) -> Result<(), Error> {
577 let stat = Command::new(py_path)
578 .args(["-m", "pip", "install", "--upgrade", "pip"])
579 .status()
580 .expect("failed to launch pip");
581 if !stat.success() {
582 return Err(Error::Generic(format!("pip install failed with status {stat}")));
583 }
584
585 let stat = Command::new(py_path)
586 .args(["-m", "pip", "install", "--quiet", "--require-hashes", "-r"])
587 .arg(src_reqs_path)
588 .status()?;
589 if !stat.success() {
590 return Err(Error::Generic(format!(
591 "failed to install requirements at {}",
592 src_reqs_path.display()
593 )));
594 }
595 fs::copy(src_reqs_path, dst_reqs_path)?;
596 assert_eq!(
597 fs::read_to_string(src_reqs_path).unwrap(),
598 fs::read_to_string(dst_reqs_path).unwrap()
599 );
600 Ok(())
601}
602
603fn has_shellcheck() -> Result<(), Error> {
605 match Command::new("shellcheck").arg("--version").status() {
606 Ok(_) => Ok(()),
607 Err(e) if e.kind() == io::ErrorKind::NotFound => Err(Error::MissingReq(
608 "shellcheck",
609 "shell file checks",
610 Some(
611 "see <https://github.com/koalaman/shellcheck#installing> \
612 for installation instructions"
613 .to_owned(),
614 ),
615 )),
616 Err(e) => Err(e.into()),
617 }
618}
619
620fn shellcheck_runner(args: &[&OsStr]) -> Result<(), Error> {
622 has_shellcheck()?;
623
624 let status = Command::new("shellcheck").args(args).status()?;
625 if status.success() { Ok(()) } else { Err(Error::FailedCheck("shellcheck")) }
626}
627
628fn spellcheck_runner(
630 src_root: &Path,
631 outdir: &Path,
632 cargo: &Path,
633 args: &[&str],
634) -> Result<(), Error> {
635 let bin_path =
636 ensure_version_or_cargo_install(outdir, cargo, "typos-cli", "typos", SPELLCHECK_VER)?;
637 match Command::new(bin_path).current_dir(src_root).args(args).status() {
638 Ok(status) => {
639 if status.success() {
640 Ok(())
641 } else {
642 Err(Error::FailedCheck("typos"))
643 }
644 }
645 Err(err) => Err(Error::Generic(format!("failed to run typos tool: {err:?}"))),
646 }
647}
648
649fn find_with_extension(
651 root_path: &Path,
652 find_dir: Option<&Path>,
653 extensions: &[&OsStr],
654) -> Result<Vec<PathBuf>, Error> {
655 let stat_output =
658 Command::new("git").arg("-C").arg(root_path).args(["status", "--short"]).output()?.stdout;
659
660 if String::from_utf8_lossy(&stat_output).lines().filter(|ln| ln.starts_with('?')).count() > 0 {
661 eprintln!("found untracked files, ignoring");
662 }
663
664 let mut output = Vec::new();
665 let binding = {
666 let mut command = Command::new("git");
667 command.arg("-C").arg(root_path).args(["ls-files"]);
668 if let Some(find_dir) = find_dir {
669 command.arg(find_dir);
670 }
671 command.output()?
672 };
673 let tracked = String::from_utf8_lossy(&binding.stdout);
674
675 for line in tracked.lines() {
676 let line = line.trim();
677 let path = Path::new(line);
678
679 let Some(ref extension) = path.extension() else {
680 continue;
681 };
682 if extensions.contains(extension) {
683 output.push(root_path.join(path));
684 }
685 }
686
687 Ok(output)
688}
689
690fn ensure_version(build_dir: &Path, bin_name: &str, version: &str) -> Result<PathBuf, Error> {
692 let bin_path = build_dir.join("misc-tools").join("bin").join(bin_name);
693
694 match Command::new(&bin_path).arg("--version").output() {
695 Ok(output) => {
696 let Some(v) = str::from_utf8(&output.stdout).unwrap().trim().split_whitespace().last()
697 else {
698 return Err(Error::Generic("version check failed".to_string()));
699 };
700
701 if v != version {
702 return Err(Error::Version { program: "", required: "", installed: v.to_string() });
703 }
704 Ok(bin_path)
705 }
706 Err(e) => Err(Error::Io(e)),
707 }
708}
709
710fn ensure_version_or_cargo_install(
713 build_dir: &Path,
714 cargo: &Path,
715 pkg_name: &str,
716 bin_name: &str,
717 version: &str,
718) -> Result<PathBuf, Error> {
719 if let Ok(bin_path) = ensure_version(build_dir, bin_name, version) {
720 return Ok(bin_path);
721 }
722
723 eprintln!("building external tool {bin_name} from package {pkg_name}@{version}");
724
725 let tool_root_dir = build_dir.join("misc-tools");
726 let tool_bin_dir = tool_root_dir.join("bin");
727 let bin_path = tool_bin_dir.join(bin_name).with_extension(env::consts::EXE_EXTENSION);
728
729 let mut cmd = Command::new(cargo);
733 cmd.args(["install", "--locked", "--force", "--quiet"])
734 .arg("--root")
735 .arg(&tool_root_dir)
736 .arg("--target-dir")
737 .arg(tool_root_dir.join("target"))
738 .arg(format!("{pkg_name}@{version}"))
739 .env(
740 "PATH",
741 env::join_paths(
742 env::split_paths(&env::var("PATH").unwrap())
743 .chain(std::iter::once(tool_bin_dir.clone())),
744 )
745 .expect("build dir contains invalid char"),
746 );
747
748 if CiEnv::is_ci() {
752 cmd.env("RUSTFLAGS", "-Copt-level=0");
753 }
754
755 let cargo_exit_code = cmd.spawn()?.wait()?;
756 if !cargo_exit_code.success() {
757 return Err(Error::Generic("cargo install failed".to_string()));
758 }
759 assert!(
760 matches!(bin_path.try_exists(), Ok(true)),
761 "cargo install did not produce the expected binary"
762 );
763 eprintln!("finished building tool {bin_name}");
764 Ok(bin_path)
765}
766
767#[derive(Debug)]
768enum Error {
769 Io(io::Error),
770 MissingReq(&'static str, &'static str, Option<String>),
772 FailedCheck(&'static str),
774 Generic(String),
776 Version {
778 program: &'static str,
779 required: &'static str,
780 installed: String,
781 },
782}
783
784impl fmt::Display for Error {
785 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
786 match self {
787 Self::MissingReq(a, b, ex) => {
788 write!(
789 f,
790 "{a} is required to run {b} but it could not be located. Is it installed?"
791 )?;
792 if let Some(s) = ex {
793 write!(f, "\n{s}")?;
794 };
795 Ok(())
796 }
797 Self::Version { program, required, installed } => write!(
798 f,
799 "insufficient version of '{program}' to run external tools: \
800 {required} required but found {installed}",
801 ),
802 Self::Generic(s) => f.write_str(s),
803 Self::Io(e) => write!(f, "IO error: {e}"),
804 Self::FailedCheck(s) => write!(f, "checks with external tool '{s}' failed"),
805 }
806 }
807}
808
809impl From<io::Error> for Error {
810 fn from(value: io::Error) -> Self {
811 Self::Io(value)
812 }
813}
814
815#[derive(Debug, PartialEq)]
816enum ExtraCheckParseError {
817 #[allow(dead_code, reason = "shown through Debug")]
818 UnknownKind(String),
819 #[allow(dead_code)]
820 UnknownLang(String),
821 UnsupportedKindForLang,
822 TooManyParts,
824 Empty,
826 AutoRequiresLang,
828 IfInstalledRequiresLang,
830}
831
832#[derive(PartialEq, Debug)]
833struct ExtraCheckArg {
834 auto: bool,
836 if_installed: bool,
838 lang: ExtraCheckLang,
839 kind: Option<ExtraCheckKind>,
841}
842
843impl ExtraCheckArg {
844 fn matches(&self, lang: ExtraCheckLang, kind: ExtraCheckKind) -> bool {
845 self.lang == lang && self.kind.map(|k| k == kind).unwrap_or(true)
846 }
847
848 fn is_non_if_installed_or_matches(&self, root_path: &Path, build_dir: &Path) -> bool {
849 if !self.if_installed {
850 return true;
851 }
852
853 match self.lang {
854 ExtraCheckLang::Spellcheck => {
855 match ensure_version(build_dir, "typos", SPELLCHECK_VER) {
856 Ok(_) => true,
857 Err(Error::Version { installed, .. }) => {
858 eprintln!(
859 "warning: the tool `typos` is detected, but version {installed} doesn't match with the expected version {SPELLCHECK_VER}"
860 );
861 false
862 }
863 _ => false,
864 }
865 }
866 ExtraCheckLang::Shell => has_shellcheck().is_ok(),
867 ExtraCheckLang::Js => {
868 match self.kind {
869 Some(ExtraCheckKind::Lint) => {
870 rustdoc_js::has_tool(build_dir, "eslint")
872 && rustdoc_js::has_tool(build_dir, "es-check")
873 }
874 Some(ExtraCheckKind::Typecheck) => {
875 rustdoc_js::has_tool(build_dir, "tsc")
877 }
878 None => {
879 rustdoc_js::has_tool(build_dir, "eslint")
881 && rustdoc_js::has_tool(build_dir, "es-check")
882 && rustdoc_js::has_tool(build_dir, "tsc")
883 }
884 Some(_) => unreachable!("js shouldn't have other type of ExtraCheckKind"),
885 }
886 }
887 ExtraCheckLang::Py | ExtraCheckLang::Cpp => {
888 let venv_path = build_dir.join("venv");
889 let mut reqs_path = root_path.to_owned();
890 reqs_path.extend(PIP_REQ_PATH);
891 let Ok(v) = has_py_tools(&venv_path, &reqs_path) else {
892 return false;
893 };
894
895 v
896 }
897 }
898 }
899
900 fn is_non_auto_or_matches(&self, filepath: &str) -> bool {
902 if !self.auto {
903 return true;
904 }
905 let exts: &[&str] = match self.lang {
906 ExtraCheckLang::Py => &[".py"],
907 ExtraCheckLang::Cpp => &[".cpp"],
908 ExtraCheckLang::Shell => &[".sh"],
909 ExtraCheckLang::Js => &[".js", ".ts"],
910 ExtraCheckLang::Spellcheck => {
911 if SPELLCHECK_DIRS.iter().any(|dir| Path::new(filepath).starts_with(dir)) {
912 return true;
913 }
914 &[]
915 }
916 };
917 exts.iter().any(|ext| filepath.ends_with(ext))
918 }
919
920 fn has_supported_kind(&self) -> bool {
921 let Some(kind) = self.kind else {
922 return true;
924 };
925 use ExtraCheckKind::*;
926 let supported_kinds: &[_] = match self.lang {
927 ExtraCheckLang::Py => &[Fmt, Lint],
928 ExtraCheckLang::Cpp => &[Fmt],
929 ExtraCheckLang::Shell => &[Lint],
930 ExtraCheckLang::Spellcheck => &[],
931 ExtraCheckLang::Js => &[Lint, Typecheck],
932 };
933 supported_kinds.contains(&kind)
934 }
935}
936
937impl FromStr for ExtraCheckArg {
938 type Err = ExtraCheckParseError;
939
940 fn from_str(s: &str) -> Result<Self, Self::Err> {
941 let mut auto = false;
942 let mut if_installed = false;
943 let mut parts = s.split(':');
944 let mut first = match parts.next() {
945 Some("") | None => return Err(ExtraCheckParseError::Empty),
946 Some(part) => part,
947 };
948
949 loop {
952 match (first, auto, if_installed) {
953 ("auto", false, _) => {
954 let Some(part) = parts.next() else {
955 return Err(ExtraCheckParseError::AutoRequiresLang);
956 };
957 auto = true;
958 first = part;
959 }
960 ("if-installed", _, false) => {
961 let Some(part) = parts.next() else {
962 return Err(ExtraCheckParseError::IfInstalledRequiresLang);
963 };
964 if_installed = true;
965 first = part;
966 }
967 _ => break,
968 }
969 }
970 let second = parts.next();
971 if parts.next().is_some() {
972 return Err(ExtraCheckParseError::TooManyParts);
973 }
974 let arg = Self {
975 auto,
976 if_installed,
977 lang: first.parse()?,
978 kind: second.map(|s| s.parse()).transpose()?,
979 };
980 if !arg.has_supported_kind() {
981 return Err(ExtraCheckParseError::UnsupportedKindForLang);
982 }
983
984 Ok(arg)
985 }
986}
987
988#[derive(PartialEq, Copy, Clone, Debug)]
989enum ExtraCheckLang {
990 Py,
991 Shell,
992 Cpp,
993 Spellcheck,
994 Js,
995}
996
997impl FromStr for ExtraCheckLang {
998 type Err = ExtraCheckParseError;
999
1000 fn from_str(s: &str) -> Result<Self, Self::Err> {
1001 Ok(match s {
1002 "py" => Self::Py,
1003 "shell" => Self::Shell,
1004 "cpp" => Self::Cpp,
1005 "spellcheck" => Self::Spellcheck,
1006 "js" => Self::Js,
1007 _ => return Err(ExtraCheckParseError::UnknownLang(s.to_string())),
1008 })
1009 }
1010}
1011
1012#[derive(PartialEq, Copy, Clone, Debug)]
1013enum ExtraCheckKind {
1014 Lint,
1015 Fmt,
1016 Typecheck,
1017 None,
1020}
1021
1022impl FromStr for ExtraCheckKind {
1023 type Err = ExtraCheckParseError;
1024
1025 fn from_str(s: &str) -> Result<Self, Self::Err> {
1026 Ok(match s {
1027 "lint" => Self::Lint,
1028 "fmt" => Self::Fmt,
1029 "typecheck" => Self::Typecheck,
1030 _ => return Err(ExtraCheckParseError::UnknownKind(s.to_string())),
1031 })
1032 }
1033}