@@ -21,13 +21,18 @@ use std::ffi::OsStr;
2121use std:: path:: { Path , PathBuf } ;
2222use std:: process:: Command ;
2323use std:: str:: FromStr ;
24- use std:: { fmt, fs, io} ;
24+ use std:: { env, fmt, fs, io} ;
25+
26+ use build_helper:: ci:: CiEnv ;
2527
2628use crate :: CiInfo ;
2729use crate :: diagnostics:: TidyCtx ;
2830
2931mod rustdoc_js;
3032
33+ #[ cfg( test) ]
34+ mod tests;
35+
3136const MIN_PY_REV : ( u32 , u32 ) = ( 3 , 9 ) ;
3237const MIN_PY_REV_STR : & str = "≥3.9" ;
3338
@@ -43,6 +48,7 @@ const RUFF_CACHE_PATH: &[&str] = &["cache", "ruff_cache"];
4348const PIP_REQ_PATH : & [ & str ] = & [ "src" , "tools" , "tidy" , "config" , "requirements.txt" ] ;
4449
4550const SPELLCHECK_DIRS : & [ & str ] = & [ "compiler" , "library" , "src/bootstrap" , "src/librustdoc" ] ;
51+ const SPELLCHECK_VER : & str = "1.38.1" ;
4652
4753pub 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.
423430fn 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.
455464fn 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 ) ]
679768enum 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 ) ]
727816enum 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 ) ]
741833struct 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 ) ]
820989enum 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 ) ]
8441013enum ExtraCheckKind {
8451014 Lint ,
8461015 Fmt ,
0 commit comments