Skip to main content

xtask/tasks/fmt/
lints.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4//! A harness for running custom text-based lints over repository files.
5
6mod cfg_target_arch;
7mod copyright;
8mod crate_name_nodash;
9mod orphaned_rs;
10mod package_info;
11mod repr_packed;
12mod trailing_newline;
13mod unsafe_code_comment;
14mod unused_deps;
15mod workspaced;
16
17use crate::fs_helpers::git_diffed;
18use crate::tasks::fmt::FmtCtx;
19use crate::tasks::fmt::FmtPass;
20use std::fmt::Display;
21use std::ops::Deref;
22use std::path::Path;
23use std::path::PathBuf;
24use std::sync::atomic::AtomicBool;
25use toml_edit::DocumentMut;
26
27/// Context passed to each lint, containing configuration options.
28pub struct LintCtx {
29    /// When true we are linting a subset of repo files, so some lints may want
30    /// to skip checks that require whole-repo analysis.
31    only_diffed: bool,
32}
33
34/// A trait representing a single lint check.
35pub trait Lint {
36    /// Create a new instance of this lint for a workspace.
37    fn new(ctx: &LintCtx) -> Self
38    where
39        Self: Sized;
40
41    /// Begin processing a workspace, given the parsed Cargo.toml of the workspace root.
42    fn enter_workspace(&mut self, content: &Lintable<DocumentMut>);
43
44    /// Begin processing a crate, given the parsed Cargo.toml of the crate root.
45    fn enter_crate(&mut self, content: &Lintable<DocumentMut>);
46
47    /// Process a Rust source file in the current crate.
48    fn visit_file(&mut self, content: &mut Lintable<String>);
49
50    /// Finish processing a crate, given the parsed Cargo.toml of the crate root.
51    fn exit_crate(&mut self, content: &mut Lintable<DocumentMut>);
52
53    /// Finish processing a workspace, given the parsed Cargo.toml of the workspace root.
54    fn exit_workspace(&mut self, content: &mut Lintable<DocumentMut>);
55
56    /// Process a non-Rust file in the current crate or workspace.
57    ///
58    /// For files within the directory of a crate this is called during crate processing.
59    /// For files outside of any crate this is called during workspace processing after
60    /// all crates have been processed.
61    fn visit_nonrust_file(&mut self, extension: &str, content: &mut Lintable<String>) {
62        let _ = (extension, content);
63    }
64}
65
66/// A wrapper around file content for linting.
67///
68/// Most lints will want to use the `Deref` impl to access the content directly,
69/// but this also provides utilities for reporting errors and making fixes.
70pub struct Lintable<T> {
71    content: T,
72    raw: Option<String>,
73    fix: bool,
74    path: PathBuf,
75    workspace_dir: PathBuf,
76    modified: bool,
77    // This doesn't really need to be atomic, but it lets `unfixable` only take
78    // `&self` which is more convenient.
79    failed: AtomicBool,
80}
81
82impl<T> Deref for Lintable<T> {
83    type Target = T;
84
85    fn deref(&self) -> &Self::Target {
86        &self.content
87    }
88}
89
90impl Lintable<String> {
91    /// Read a text file into a `Lintable<String>`.
92    ///
93    /// Returns `None` for binary (non-UTF-8) files.
94    fn from_file(path: &Path, ctx: &FmtCtx, workspace_dir: &Path) -> anyhow::Result<Option<Self>> {
95        let bytes = fs_err::read(path)?;
96        let content = match String::from_utf8(bytes) {
97            Ok(s) => s,
98            Err(_) => return Ok(None),
99        };
100        Ok(Some(Self {
101            content,
102            raw: None,
103            fix: ctx.fix,
104            path: path.strip_prefix(workspace_dir).unwrap().to_owned(),
105            workspace_dir: workspace_dir.to_owned(),
106            modified: false,
107            failed: AtomicBool::new(false),
108        }))
109    }
110}
111
112impl Lintable<DocumentMut> {
113    /// Read a Cargo.toml file into a `Lintable<DocumentMut>`.
114    ///
115    /// This can be from a crate or a workspace.
116    fn from_file(path: &Path, ctx: &FmtCtx, workspace_dir: &Path) -> anyhow::Result<Self> {
117        let raw = fs_err::read_to_string(path)?;
118        Ok(Self {
119            content: raw.parse()?,
120            raw: Some(raw),
121            fix: ctx.fix,
122            path: path.strip_prefix(workspace_dir).unwrap().to_owned(),
123            workspace_dir: workspace_dir.to_owned(),
124            modified: false,
125            failed: AtomicBool::new(false),
126        })
127    }
128}
129
130impl<T> Lintable<T> {
131    /// Get the path of this file relative to the workspace root, for use in error messages.
132    pub fn path(&self) -> &Path {
133        &self.path
134    }
135
136    /// Get the original raw file content as a string, for lints that need to do their own parsing.
137    ///
138    /// If the file content is already a string this will be None.
139    /// This field is not modified when fixes are made.
140    pub fn raw(&self) -> Option<&str> {
141        self.raw.as_deref()
142    }
143
144    /// If fix is enabled, apply the given fix operation to the content and mark it modified.
145    /// If fix is not enabled, report an error with the given description.
146    pub fn fix(&mut self, description: &str, op: impl FnOnce(&mut T)) {
147        if self.fix {
148            op(&mut self.content);
149            self.modified = true;
150        } else {
151            log::error!("{}: {}", self.path.display(), description);
152            self.failed
153                .store(true, std::sync::atomic::Ordering::Relaxed);
154        }
155    }
156
157    /// Report an error with the given description that cannot be automatically fixed.
158    pub fn unfixable(&self, description: &str) {
159        log::error!("{}: {}", self.path.display(), description);
160        self.failed
161            .store(true, std::sync::atomic::Ordering::Relaxed);
162    }
163
164    /// If modified, write the content back to the file. Return whether any errors were reported.
165    fn finalize(self) -> anyhow::Result<bool>
166    where
167        T: Display,
168    {
169        if self.modified {
170            let full_path = self.workspace_dir.join(&self.path);
171            fs_err::write(full_path, self.content.to_string())?;
172        }
173        Ok(self.failed.into_inner())
174    }
175}
176
177pub struct Lints;
178
179impl FmtPass for Lints {
180    fn run(self, ctx: FmtCtx) -> anyhow::Result<()> {
181        // Walk tree once to discover all Cargo.toml files and all other files
182        // (including .rs). This avoids a second walk per-crate later.
183        let mut workspace_dirs = Vec::new();
184        let mut all_crate_dirs = Vec::new();
185        let mut all_files = Vec::new();
186        for entry in ignore::Walk::new(&ctx.ctx.root) {
187            let entry = entry?;
188            if entry.file_name() == "Cargo.toml" {
189                // Identify workspace roots (Cargo.toml files with a [workspace] key).
190                let raw = fs_err::read_to_string(entry.path())?;
191                let doc: DocumentMut = raw.parse()?;
192                if doc.contains_key("workspace") {
193                    workspace_dirs.push(entry.path().parent().unwrap().to_owned());
194                } else {
195                    // Build the set of all crate directories (every Cargo.toml parent
196                    // that is not itself a workspace root).
197                    all_crate_dirs.push(entry.path().parent().unwrap().to_owned());
198                }
199            } else if entry.file_type().is_some_and(|ft| ft.is_file()) {
200                all_files.push(entry.into_path());
201            }
202        }
203
204        let mut any_failed = false;
205
206        // Run a fresh set of lints over each workspace.
207        for workspace_dir in &workspace_dirs {
208            // Nested workspace dirs that are children of this workspace.
209            let nested_workspace_dirs: Vec<_> = workspace_dirs
210                .iter()
211                .filter(|other| *other != workspace_dir && other.starts_with(workspace_dir))
212                .collect();
213
214            // Crate dirs belonging to this workspace: under workspace_dir
215            // but not under any deeper nested workspace.
216            let mut crate_dirs: Vec<_> = all_crate_dirs
217                .iter()
218                .filter(|crate_dir| {
219                    crate_dir.starts_with(workspace_dir)
220                        && !nested_workspace_dirs
221                            .iter()
222                            .any(|nested| crate_dir.starts_with(*nested))
223                })
224                .collect();
225
226            // All files belonging to this workspace (under workspace_dir,
227            // not under any nested workspace).
228            let workspace_files: Vec<_> = all_files
229                .iter()
230                .filter(|f| {
231                    f.starts_with(workspace_dir)
232                        && !nested_workspace_dirs
233                            .iter()
234                            .any(|nested| f.starts_with(*nested))
235                })
236                .collect();
237
238            // Non-crate files: files not under any crate dir, excluding .rs files.
239            let mut non_crate_files: Vec<_> = workspace_files
240                .iter()
241                .filter(|f| {
242                    f.extension().and_then(|e| e.to_str()) != Some("rs")
243                        && !crate_dirs.iter().any(|crate_dir| f.starts_with(crate_dir))
244                })
245                .copied()
246                .collect();
247
248            // If only_diffed, filter crate dirs and non-crate files.
249            if ctx.only_diffed {
250                let diffed = git_diffed(ctx.ctx.in_git_hook)?;
251                // git diff outputs paths relative to the repo root, so strip
252                // the root from our other full paths before checking for a match
253                crate_dirs.retain(|crate_dir| {
254                    let crate_dir = crate_dir.strip_prefix(&ctx.ctx.root).unwrap();
255                    diffed.iter().any(|f| f.starts_with(crate_dir))
256                });
257                non_crate_files.retain(|f| {
258                    let f = f.strip_prefix(&ctx.ctx.root).unwrap().to_owned();
259                    diffed.contains(&f)
260                });
261            }
262
263            any_failed |= lint_workspace(
264                workspace_dir,
265                &crate_dirs,
266                &non_crate_files,
267                &workspace_files,
268                &ctx,
269            )?;
270        }
271
272        if any_failed {
273            anyhow::bail!("one or more lint checks failed");
274        }
275
276        Ok(())
277    }
278}
279
280/// Run a fresh set of lints over a single workspace and its member crates..
281fn lint_workspace(
282    workspace_dir: &Path,
283    crate_dirs: &[&PathBuf],
284    non_crate_files: &[&PathBuf],
285    all_files: &[&PathBuf],
286    ctx: &FmtCtx,
287) -> anyhow::Result<bool> {
288    let lint_ctx = LintCtx {
289        only_diffed: ctx.only_diffed,
290    };
291
292    let mut lints: Vec<Box<dyn Lint>> = vec![
293        Box::new(cfg_target_arch::CfgTargetArch::new(&lint_ctx)),
294        Box::new(copyright::Copyright::new(&lint_ctx)),
295        Box::new(crate_name_nodash::CrateNameNoDash::new(&lint_ctx)),
296        Box::new(orphaned_rs::OrphanedRustFiles::new(&lint_ctx)),
297        Box::new(package_info::PackageInfo::new(&lint_ctx)),
298        Box::new(repr_packed::ReprPacked::new(&lint_ctx)),
299        Box::new(trailing_newline::TrailingNewline::new(&lint_ctx)),
300        Box::new(unsafe_code_comment::UnsafeCodeComment::new(&lint_ctx)),
301        Box::new(unused_deps::UnusedDeps::new(&lint_ctx)),
302        Box::new(workspaced::WorkspacedManifest::new(&lint_ctx)),
303    ];
304
305    let workspace_manifest_path = workspace_dir.join("Cargo.toml");
306    let mut workspace_manifest =
307        Lintable::<DocumentMut>::from_file(&workspace_manifest_path, ctx, workspace_dir)?;
308
309    log::debug!(
310        "Linting workspace {} with {} crates and {} non-crate files",
311        workspace_dir.display(),
312        crate_dirs.len(),
313        non_crate_files.len()
314    );
315    for lint in lints.iter_mut() {
316        lint.enter_workspace(&workspace_manifest);
317    }
318
319    let mut any_failed = false;
320
321    for crate_dir in crate_dirs {
322        let manifest_path = crate_dir.join("Cargo.toml");
323        let mut crate_manifest =
324            Lintable::<DocumentMut>::from_file(&manifest_path, ctx, workspace_dir)?;
325
326        log::debug!("Linting crate {}", crate_dir.display());
327        for lint in lints.iter_mut() {
328            lint.enter_crate(&crate_manifest);
329        }
330
331        // Collect nested crate dirs within this crate to avoid
332        // processing files that belong to a child crate.
333        let nested_crate_dirs: Vec<_> = crate_dirs
334            .iter()
335            .filter(|other| *other != crate_dir && other.starts_with(crate_dir))
336            .collect();
337
338        // Use pre-collected file paths instead of walking the crate
339        // directory again, avoiding redundant filesystem traversals.
340        for path in all_files.iter().filter(|f| {
341            f.starts_with(crate_dir)
342                && !nested_crate_dirs.iter().any(|nested| f.starts_with(nested))
343        }) {
344            let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
345            let Some(mut file) = Lintable::<String>::from_file(path, ctx, workspace_dir)? else {
346                // Skip binary files
347                continue;
348            };
349
350            for lint in lints.iter_mut() {
351                if ext == "rs" {
352                    lint.visit_file(&mut file);
353                } else {
354                    lint.visit_nonrust_file(ext, &mut file);
355                }
356            }
357            any_failed |= file.finalize()?;
358        }
359
360        for lint in lints.iter_mut() {
361            lint.exit_crate(&mut crate_manifest);
362        }
363        any_failed |= crate_manifest.finalize()?;
364    }
365
366    // Process non-crate files (e.g. scripts, Guide).
367    for path in non_crate_files {
368        log::debug!("Linting non-crate file {}", path.display());
369        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
370        let Some(mut file) = Lintable::<String>::from_file(path, ctx, workspace_dir)? else {
371            // Skip binary files
372            log::debug!("Skipping binary file {}", path.display());
373            continue;
374        };
375        for lint in lints.iter_mut() {
376            lint.visit_nonrust_file(ext, &mut file);
377        }
378        any_failed |= file.finalize()?;
379    }
380
381    for lint in lints.iter_mut() {
382        lint.exit_workspace(&mut workspace_manifest);
383    }
384    any_failed |= workspace_manifest.finalize()?;
385
386    Ok(any_failed)
387}