tidy/
lib.rs

1//! Library used by tidy and other tools.
2//!
3//! This library contains the tidy lints and exposes it
4//! to be used by tools.
5
6use std::ffi::OsStr;
7use std::path::{Path, PathBuf};
8use std::process::Command;
9use std::{env, io};
10
11use build_helper::ci::CiEnv;
12use build_helper::git::{GitConfig, get_closest_upstream_commit};
13use build_helper::stage0_parser::{Stage0Config, parse_stage0_file};
14use termcolor::WriteColor;
15
16macro_rules! static_regex {
17    ($re:literal) => {{
18        static RE: ::std::sync::LazyLock<::regex::Regex> =
19            ::std::sync::LazyLock::new(|| ::regex::Regex::new($re).unwrap());
20        &*RE
21    }};
22}
23
24/// A helper macro to `unwrap` a result except also print out details like:
25///
26/// * The expression that failed
27/// * The error itself
28/// * (optionally) a path connected to the error (e.g. failure to open a file)
29#[macro_export]
30macro_rules! t {
31    ($e:expr, $p:expr) => {
32        match $e {
33            Ok(e) => e,
34            Err(e) => panic!("{} failed on {} with {}", stringify!($e), ($p).display(), e),
35        }
36    };
37
38    ($e:expr) => {
39        match $e {
40            Ok(e) => e,
41            Err(e) => panic!("{} failed with {}", stringify!($e), e),
42        }
43    };
44}
45
46macro_rules! tidy_error {
47    ($bad:expr, $($fmt:tt)*) => ({
48        $crate::tidy_error(&format_args!($($fmt)*).to_string()).expect("failed to output error");
49        *$bad = true;
50    });
51}
52
53macro_rules! tidy_error_ext {
54    ($tidy_error:path, $bad:expr, $($fmt:tt)*) => ({
55        $tidy_error(&format_args!($($fmt)*).to_string()).expect("failed to output error");
56        *$bad = true;
57    });
58}
59
60fn tidy_error(args: &str) -> std::io::Result<()> {
61    use std::io::Write;
62
63    use termcolor::{Color, ColorChoice, ColorSpec, StandardStream};
64
65    let mut stderr = StandardStream::stdout(ColorChoice::Auto);
66    stderr.set_color(ColorSpec::new().set_fg(Some(Color::Red)))?;
67
68    write!(&mut stderr, "tidy error")?;
69    stderr.set_color(&ColorSpec::new())?;
70
71    writeln!(&mut stderr, ": {args}")?;
72    Ok(())
73}
74
75pub struct CiInfo {
76    pub git_merge_commit_email: String,
77    pub nightly_branch: String,
78    pub base_commit: Option<String>,
79    pub ci_env: CiEnv,
80}
81
82impl CiInfo {
83    pub fn new(bad: &mut bool) -> Self {
84        let stage0 = parse_stage0_file();
85        let Stage0Config { nightly_branch, git_merge_commit_email, .. } = stage0.config;
86
87        let mut info = Self {
88            nightly_branch,
89            git_merge_commit_email,
90            ci_env: CiEnv::current(),
91            base_commit: None,
92        };
93        let base_commit = match get_closest_upstream_commit(None, &info.git_config(), info.ci_env) {
94            Ok(Some(commit)) => Some(commit),
95            Ok(None) => {
96                info.error_if_in_ci("no base commit found", bad);
97                None
98            }
99            Err(error) => {
100                info.error_if_in_ci(&format!("failed to retrieve base commit: {error}"), bad);
101                None
102            }
103        };
104        info.base_commit = base_commit;
105        info
106    }
107
108    pub fn git_config(&self) -> GitConfig<'_> {
109        GitConfig {
110            nightly_branch: &self.nightly_branch,
111            git_merge_commit_email: &self.git_merge_commit_email,
112        }
113    }
114
115    pub fn error_if_in_ci(&self, msg: &str, bad: &mut bool) {
116        if self.ci_env.is_running_in_ci() {
117            *bad = true;
118            eprintln!("tidy check error: {msg}");
119        } else {
120            eprintln!("tidy check warning: {msg}. Some checks will be skipped.");
121        }
122    }
123}
124
125pub fn git_diff<S: AsRef<OsStr>>(base_commit: &str, extra_arg: S) -> Option<String> {
126    let output = Command::new("git").arg("diff").arg(base_commit).arg(extra_arg).output().ok()?;
127    Some(String::from_utf8_lossy(&output.stdout).into())
128}
129
130/// Similar to `files_modified`, but only involves a single call to `git`.
131///
132/// removes all elements from `items` that do not cause any match when `pred` is called with the list of modifed files.
133///
134/// if in CI, no elements will be removed.
135pub fn files_modified_batch_filter<T>(
136    ci_info: &CiInfo,
137    items: &mut Vec<T>,
138    pred: impl Fn(&T, &str) -> bool,
139) {
140    if CiEnv::is_ci() {
141        // assume everything is modified on CI because we really don't want false positives there.
142        return;
143    }
144    let Some(base_commit) = &ci_info.base_commit else {
145        eprintln!("No base commit, assuming all files are modified");
146        return;
147    };
148    match crate::git_diff(base_commit, "--name-status") {
149        Some(output) => {
150            let modified_files: Vec<_> = output
151                .lines()
152                .filter_map(|ln| {
153                    let (status, name) = ln
154                        .trim_end()
155                        .split_once('\t')
156                        .expect("bad format from `git diff --name-status`");
157                    if status == "M" { Some(name) } else { None }
158                })
159                .collect();
160            items.retain(|item| {
161                for modified_file in &modified_files {
162                    if pred(item, modified_file) {
163                        // at least one predicate matches, keep this item.
164                        return true;
165                    }
166                }
167                // no predicates matched, remove this item.
168                false
169            });
170        }
171        None => {
172            eprintln!("warning: failed to run `git diff` to check for changes");
173            eprintln!("warning: assuming all files are modified");
174        }
175    }
176}
177
178/// Returns true if any modified file matches the predicate, if we are in CI, or if unable to list modified files.
179pub fn files_modified(ci_info: &CiInfo, pred: impl Fn(&str) -> bool) -> bool {
180    let mut v = vec![()];
181    files_modified_batch_filter(ci_info, &mut v, |_, p| pred(p));
182    !v.is_empty()
183}
184
185/// If the given executable is installed with the given version, use that,
186/// otherwise install via cargo.
187pub fn ensure_version_or_cargo_install(
188    build_dir: &Path,
189    cargo: &Path,
190    pkg_name: &str,
191    bin_name: &str,
192    version: &str,
193) -> io::Result<PathBuf> {
194    // ignore the process exit code here and instead just let the version number check fail.
195    // we also importantly don't return if the program wasn't installed,
196    // instead we want to continue to the fallback.
197    'ck: {
198        // FIXME: rewrite as if-let chain once this crate is 2024 edition.
199        let Ok(output) = Command::new(bin_name).arg("--version").output() else {
200            break 'ck;
201        };
202        let Ok(s) = str::from_utf8(&output.stdout) else {
203            break 'ck;
204        };
205        let Some(v) = s.trim().split_whitespace().last() else {
206            break 'ck;
207        };
208        if v == version {
209            return Ok(PathBuf::from(bin_name));
210        }
211    }
212
213    let tool_root_dir = build_dir.join("misc-tools");
214    let tool_bin_dir = tool_root_dir.join("bin");
215    eprintln!("building external tool {bin_name} from package {pkg_name}@{version}");
216    // use --force to ensure that if the required version is bumped, we update it.
217    // use --target-dir to ensure we have a build cache so repeated invocations aren't slow.
218    // modify PATH so that cargo doesn't print a warning telling the user to modify the path.
219    let cargo_exit_code = Command::new(cargo)
220        .args(["install", "--locked", "--force", "--quiet"])
221        .arg("--root")
222        .arg(&tool_root_dir)
223        .arg("--target-dir")
224        .arg(tool_root_dir.join("target"))
225        .arg(format!("{pkg_name}@{version}"))
226        .env(
227            "PATH",
228            env::join_paths(
229                env::split_paths(&env::var("PATH").unwrap())
230                    .chain(std::iter::once(tool_bin_dir.clone())),
231            )
232            .expect("build dir contains invalid char"),
233        )
234        .env("RUSTFLAGS", "-Copt-level=0")
235        .spawn()?
236        .wait()?;
237    if !cargo_exit_code.success() {
238        return Err(io::Error::other("cargo install failed"));
239    }
240    let bin_path = tool_bin_dir.join(bin_name);
241    assert!(
242        matches!(bin_path.try_exists(), Ok(true)),
243        "cargo install did not produce the expected binary"
244    );
245    eprintln!("finished building tool {bin_name}");
246    Ok(bin_path)
247}
248
249pub mod alphabetical;
250pub mod bins;
251pub mod debug_artifacts;
252pub mod deps;
253pub mod edition;
254pub mod error_codes;
255pub mod extdeps;
256pub mod extra_checks;
257pub mod features;
258pub mod filenames;
259pub mod fluent_alphabetical;
260pub mod fluent_period;
261mod fluent_used;
262pub mod gcc_submodule;
263pub(crate) mod iter_header;
264pub mod known_bug;
265pub mod mir_opt_tests;
266pub mod pal;
267pub mod rustdoc_css_themes;
268pub mod rustdoc_gui_tests;
269pub mod rustdoc_json;
270pub mod rustdoc_templates;
271pub mod style;
272pub mod target_policy;
273pub mod target_specific_tests;
274pub mod tests_placement;
275pub mod tests_revision_unpaired_stdout_stderr;
276pub mod triagebot;
277pub mod ui_tests;
278pub mod unit_tests;
279pub mod unknown_revision;
280pub mod unstable_book;
281pub mod walk;
282pub mod x_version;