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