Skip to content

Commit aee5757

Browse files
authored
Rollup merge of #149961 - add-optional-spellcheck-in-pre-hook, r=lolbinarycat
tidy: add if-installed prefix condition to extra checks system Ref: https://rust-lang.zulipchat.com/#narrow/channel/326414-t-infra.2Fbootstrap/topic/Should.20Spellcheck.20check.20all.20files.3F/with/543227610 tidy now runs spellcheck (typos-cli) without adding `--extra-checks=spellcheck` option if the tool is already installed under ./build/misc-tools and the version is expected. It will improve code quality without bothering engineers who doesn't want to use typos or who cleans up ./build directory frequently.
2 parents 4586feb + 0dfff23 commit aee5757

4 files changed

Lines changed: 301 additions & 114 deletions

File tree

src/tools/tidy/src/extra_checks/mod.rs

Lines changed: 210 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,18 @@ use std::ffi::OsStr;
2121
use std::path::{Path, PathBuf};
2222
use std::process::Command;
2323
use std::str::FromStr;
24-
use std::{fmt, fs, io};
24+
use std::{env, fmt, fs, io};
25+
26+
use build_helper::ci::CiEnv;
2527

2628
use crate::CiInfo;
2729
use crate::diagnostics::TidyCtx;
2830

2931
mod rustdoc_js;
3032

33+
#[cfg(test)]
34+
mod tests;
35+
3136
const MIN_PY_REV: (u32, u32) = (3, 9);
3237
const MIN_PY_REV_STR: &str = "≥3.9";
3338

@@ -43,6 +48,7 @@ const RUFF_CACHE_PATH: &[&str] = &["cache", "ruff_cache"];
4348
const PIP_REQ_PATH: &[&str] = &["src", "tools", "tidy", "config", "requirements.txt"];
4449

4550
const SPELLCHECK_DIRS: &[&str] = &["compiler", "library", "src/bootstrap", "src/librustdoc"];
51+
const SPELLCHECK_VER: &str = "1.38.1";
4652

4753
pub fn check(
4854
root_path: &Path,
@@ -115,6 +121,7 @@ fn check_impl(
115121
.collect(),
116122
None => vec![],
117123
};
124+
lint_args.retain(|ck| ck.is_non_if_installed_or_matches(root_path, outdir));
118125
if lint_args.iter().any(|ck| ck.auto) {
119126
crate::files_modified_batch_filter(ci_info, &mut lint_args, |ck, path| {
120127
ck.is_non_auto_or_matches(path)
@@ -421,21 +428,11 @@ fn py_runner(
421428
/// Create a virtuaenv at a given path if it doesn't already exist, or validate
422429
/// the install if it does. Returns the path to that venv's python executable.
423430
fn get_or_create_venv(venv_path: &Path, src_reqs_path: &Path) -> Result<PathBuf, Error> {
424-
let mut should_create = true;
425-
let dst_reqs_path = venv_path.join("requirements.txt");
426431
let mut py_path = venv_path.to_owned();
427432
py_path.extend(REL_PY_PATH);
428433

429-
if let Ok(req) = fs::read_to_string(&dst_reqs_path) {
430-
if req == fs::read_to_string(src_reqs_path)? {
431-
// found existing environment
432-
should_create = false;
433-
} else {
434-
eprintln!("requirements.txt file mismatch, recreating environment");
435-
}
436-
}
437-
438-
if should_create {
434+
if !has_py_tools(venv_path, src_reqs_path)? {
435+
let dst_reqs_path = venv_path.join("requirements.txt");
439436
eprintln!("removing old virtual environment");
440437
if venv_path.is_dir() {
441438
fs::remove_dir_all(venv_path).unwrap_or_else(|_| {
@@ -450,6 +447,18 @@ fn get_or_create_venv(venv_path: &Path, src_reqs_path: &Path) -> Result<PathBuf,
450447
Ok(py_path)
451448
}
452449

450+
fn 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+
453462
/// Attempt to create a virtualenv at this path. Cycles through all expected
454463
/// valid python versions to find one that is installed.
455464
fn create_venv_at_path(path: &Path) -> Result<(), Error> {
@@ -591,23 +600,26 @@ fn install_requirements(
591600
Ok(())
592601
}
593602

594-
/// Check that shellcheck is installed then run it at the given path
595-
fn shellcheck_runner(args: &[&OsStr]) -> Result<(), Error> {
603+
/// Returns `Ok` if shellcheck is installed, `Err` otherwise.
604+
fn has_shellcheck() -> Result<(), Error> {
596605
match Command::new("shellcheck").arg("--version").status() {
597-
Ok(_) => (),
598-
Err(e) if e.kind() == io::ErrorKind::NotFound => {
599-
return Err(Error::MissingReq(
600-
"shellcheck",
601-
"shell file checks",
602-
Some(
603-
"see <https://github.com/koalaman/shellcheck#installing> \
604-
for installation instructions"
605-
.to_owned(),
606-
),
607-
));
608-
}
609-
Err(e) => return Err(e.into()),
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()),
610617
}
618+
}
619+
620+
/// Check that shellcheck is installed then run it at the given path
621+
fn shellcheck_runner(args: &[&OsStr]) -> Result<(), Error> {
622+
has_shellcheck()?;
611623

612624
let status = Command::new("shellcheck").args(args).status()?;
613625
if status.success() { Ok(()) } else { Err(Error::FailedCheck("shellcheck")) }
@@ -621,7 +633,7 @@ fn spellcheck_runner(
621633
args: &[&str],
622634
) -> Result<(), Error> {
623635
let bin_path =
624-
crate::ensure_version_or_cargo_install(outdir, cargo, "typos-cli", "typos", "1.38.1")?;
636+
ensure_version_or_cargo_install(outdir, cargo, "typos-cli", "typos", SPELLCHECK_VER)?;
625637
match Command::new(bin_path).current_dir(src_root).args(args).status() {
626638
Ok(status) => {
627639
if status.success() {
@@ -675,6 +687,83 @@ fn find_with_extension(
675687
Ok(output)
676688
}
677689

690+
/// Check if the given executable is installed and the version is expected.
691+
fn 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+
710+
/// If the given executable is installed with the given version, use that,
711+
/// otherwise install via cargo.
712+
fn 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+
// use --force to ensure that if the required version is bumped, we update it.
730+
// use --target-dir to ensure we have a build cache so repeated invocations aren't slow.
731+
// modify PATH so that cargo doesn't print a warning telling the user to modify the path.
732+
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+
// On CI, we set opt-level flag for quicker installation.
749+
// Since lower opt-level decreases the tool's performance,
750+
// we don't set this option on local.
751+
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+
678767
#[derive(Debug)]
679768
enum Error {
680769
Io(io::Error),
@@ -723,7 +812,7 @@ impl From<io::Error> for Error {
723812
}
724813
}
725814

726-
#[derive(Debug)]
815+
#[derive(Debug, PartialEq)]
727816
enum ExtraCheckParseError {
728817
#[allow(dead_code, reason = "shown through Debug")]
729818
UnknownKind(String),
@@ -736,10 +825,16 @@ enum ExtraCheckParseError {
736825
Empty,
737826
/// `auto` specified without lang part.
738827
AutoRequiresLang,
828+
/// `if-installed` specified without lang part.
829+
IfInstalledRequiresLang,
739830
}
740831

832+
#[derive(PartialEq, Debug)]
741833
struct ExtraCheckArg {
834+
/// Only run the check if files to check have been modified.
742835
auto: bool,
836+
/// Only run the check if the requisite software is already installed.
837+
if_installed: bool,
743838
lang: ExtraCheckLang,
744839
/// None = run all extra checks for the given lang
745840
kind: Option<ExtraCheckKind>,
@@ -750,6 +845,58 @@ impl ExtraCheckArg {
750845
self.lang == lang && self.kind.map(|k| k == kind).unwrap_or(true)
751846
}
752847

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+
// If Lint is enabled, check both eslint and es-check.
871+
rustdoc_js::has_tool(build_dir, "eslint")
872+
&& rustdoc_js::has_tool(build_dir, "es-check")
873+
}
874+
Some(ExtraCheckKind::Typecheck) => {
875+
// If Typecheck is enabled, check tsc.
876+
rustdoc_js::has_tool(build_dir, "tsc")
877+
}
878+
None => {
879+
// No kind means it will check both Lint and Typecheck.
880+
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+
753900
/// Returns `false` if this is an auto arg and the passed filename does not trigger the auto rule
754901
fn is_non_auto_or_matches(&self, filepath: &str) -> bool {
755902
if !self.auto {
@@ -792,22 +939,44 @@ impl FromStr for ExtraCheckArg {
792939

793940
fn from_str(s: &str) -> Result<Self, Self::Err> {
794941
let mut auto = false;
942+
let mut if_installed = false;
795943
let mut parts = s.split(':');
796-
let Some(mut first) = parts.next() else {
797-
return Err(ExtraCheckParseError::Empty);
944+
let mut first = match parts.next() {
945+
Some("") | None => return Err(ExtraCheckParseError::Empty),
946+
Some(part) => part,
798947
};
799-
if first == "auto" {
800-
let Some(part) = parts.next() else {
801-
return Err(ExtraCheckParseError::AutoRequiresLang);
802-
};
803-
auto = true;
804-
first = part;
948+
949+
// The loop allows users to specify `auto` and `if-installed` in any order.
950+
// Both auto:if-installed:<check> and if-installed:auto:<check> are valid.
951+
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+
}
805969
}
806970
let second = parts.next();
807971
if parts.next().is_some() {
808972
return Err(ExtraCheckParseError::TooManyParts);
809973
}
810-
let arg = Self { auto, lang: first.parse()?, kind: second.map(|s| s.parse()).transpose()? };
974+
let arg = Self {
975+
auto,
976+
if_installed,
977+
lang: first.parse()?,
978+
kind: second.map(|s| s.parse()).transpose()?,
979+
};
811980
if !arg.has_supported_kind() {
812981
return Err(ExtraCheckParseError::UnsupportedKindForLang);
813982
}
@@ -816,7 +985,7 @@ impl FromStr for ExtraCheckArg {
816985
}
817986
}
818987

819-
#[derive(PartialEq, Copy, Clone)]
988+
#[derive(PartialEq, Copy, Clone, Debug)]
820989
enum ExtraCheckLang {
821990
Py,
822991
Shell,
@@ -840,7 +1009,7 @@ impl FromStr for ExtraCheckLang {
8401009
}
8411010
}
8421011

843-
#[derive(PartialEq, Copy, Clone)]
1012+
#[derive(PartialEq, Copy, Clone, Debug)]
8441013
enum ExtraCheckKind {
8451014
Lint,
8461015
Fmt,

src/tools/tidy/src/extra_checks/rustdoc_js.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ fn spawn_cmd(cmd: &mut Command) -> Result<Child, io::Error> {
2121
})
2222
}
2323

24+
pub(super) fn has_tool(outdir: &Path, name: &str) -> bool {
25+
let bin_path = node_module_bin(outdir, name);
26+
Command::new(bin_path).arg("--version").status().is_ok()
27+
}
28+
2429
/// install all js dependencies from package.json.
2530
pub(super) fn npm_install(root_path: &Path, outdir: &Path, npm: &Path) -> Result<(), super::Error> {
2631
npm::install(root_path, outdir, npm)?;

0 commit comments

Comments
 (0)