Skip to main content

wowlab_tidy/runner/
mod.rs

1// #t(file: rust_default_hasher) trusted rule-name sets in the cold runner setup path; fast-hasher dependency not warranted
2
3mod cache;
4mod clean;
5mod report;
6
7use std::collections::HashSet;
8
9use rayon::prelude::*;
10use report::{print_dry_run, print_grouped};
11use wowlab_common::output;
12use wowlab_fs::{
13    file,
14    path::{Path, PathBuf},
15};
16
17use crate::{
18    Config, FileCtx, Fix, Rule, Violation,
19    infra::{
20        fix::{self, TreeFix},
21        walk,
22    },
23    languages::{self, workspace::WorkspaceCtx},
24};
25
26#[rustfmt::skip]
27pub use clean::clean_suppressions;
28
29#[rustfmt::skip]
30pub use report::report_suppressions;
31
32struct FileResult {
33    path: PathBuf,
34    analysis: languages::Analysis,
35}
36type CollectedAnalysis = (Vec<Violation>, Vec<(String, Fix)>, Vec<TreeFix>);
37type AnalysisResult = Result<CollectedAnalysis, AnalysisError>;
38type FileAnalysis = Result<Option<FileResult>, String>;
39type CollectedResults = (
40    Vec<Violation>,
41    Vec<(String, Fix)>,
42    Vec<TreeFix>,
43    Vec<languages::workspace::WorkspaceRustFile>,
44    Vec<languages::workspace::WorkspaceManifest>,
45);
46
47#[derive(Debug, thiserror::Error)]
48pub(crate) enum AnalysisError {
49    #[error(transparent)]
50    Discovery(#[from] walk::PathDiscoveryError),
51    #[error("failed to read source files: {0}")]
52    Read(Box<str>),
53}
54
55const MAX_FIX_ITERATIONS: usize = 10;
56
57/// Resolve the target directory Cargo uses for workspace tooling.
58#[must_use]
59pub fn cargo_target_dir(root: &Path, crates_dir: &Path) -> PathBuf {
60    resolve_cargo_target_dir(
61        root,
62        crates_dir,
63        std::env::var_os("CARGO_TARGET_DIR").map(PathBuf::from),
64    )
65}
66
67fn resolve_cargo_target_dir(
68    root: &Path,
69    crates_dir: &Path,
70    configured: Option<PathBuf>,
71) -> PathBuf {
72    match configured {
73        Some(path) if path.is_absolute() => path,
74        Some(path) => root.join(path),
75        None => crates_dir.join("target"),
76    }
77}
78
79fn matches_rule_filter(name: &str, filter: &[String]) -> bool {
80    filter.is_empty() || filter.iter().any(|r| r == name)
81}
82
83/// How the runner should handle auto-fixable violations.
84#[derive(Clone, Copy, Debug)]
85// #t(rust_non_exhaustive_on_public) internal enum used only by the tidy binary
86pub enum FixMode {
87    Off,
88    Apply,
89    DryRun,
90}
91
92/// Inputs needed by `run` and `collect_violations`.
93#[derive(Debug)]
94pub struct RunCtx<'a> {
95    pub rule_filter: &'a [String],
96    pub crate_filter: &'a [String],
97    pub config: &'a Config,
98    pub root: &'a Path,
99    pub crates_dir: &'a Path,
100    pub quiet: bool,
101    pub dirty: bool,
102}
103
104/// Run tidy rules with human-readable terminal output. Returns `true` if clean.
105#[must_use]
106pub fn run(ctx: &RunCtx<'_>, fix: FixMode) -> bool {
107    let total = enabled_rules(ctx).len();
108
109    if total == 0 {
110        if !ctx.quiet {
111            output::error("no rules matched (check tidy.toml and --rule filter)");
112        }
113
114        return false;
115    }
116
117    if !ctx.quiet {
118        output::detail(&format!("running {total} rule(s)"));
119        output::blank();
120    }
121
122    let collect_fixes = !matches!(fix, FixMode::Off);
123    let (all_violations, fixes, tree_fixes) = match collect_violations(ctx, collect_fixes, None) {
124        Ok(result) => result,
125        Err(error) => {
126            if !ctx.quiet {
127                output::error(&format!("tidy analysis aborted: {error}"));
128            }
129
130            return false;
131        }
132    };
133
134    if collect_fixes && (!fixes.is_empty() || !tree_fixes.is_empty()) {
135        return apply_and_verify(ctx, fix, fixes, tree_fixes, total);
136    }
137
138    if all_violations.is_empty() {
139        if !ctx.quiet {
140            output::success(&format!("all {total} rule(s) passed"));
141            output::blank();
142        }
143
144        true
145    } else {
146        if !ctx.quiet {
147            print_grouped(&all_violations);
148        }
149
150        false
151    }
152}
153
154fn enabled_rules(ctx: &RunCtx<'_>) -> Vec<&'static Rule> {
155    inventory::iter::<Rule>
156        .into_iter()
157        .filter(|rule| {
158            ctx.config.is_enabled(rule.info.name)
159                && matches_rule_filter(rule.info.name, ctx.rule_filter)
160        })
161        .collect()
162}
163
164// #t(fn: rust_alloc_in_loop, rust_cyclomatic_complexity) fixpoint orchestration branches by edit kind and terminal state
165fn apply_and_verify(
166    ctx: &RunCtx<'_>,
167    fix: FixMode,
168    mut fixes: Vec<(String, Fix)>,
169    mut tree_fixes: Vec<TreeFix>,
170    total: usize,
171) -> bool {
172    if matches!(fix, FixMode::DryRun) {
173        if !ctx.quiet {
174            let mut previews = fixes;
175
176            for tree_fix in tree_fixes {
177                let line_count = file::read_text(&ctx.root.join(&tree_fix.rel))
178                    .map_or(1, |contents| contents.lines().count().max(1));
179
180                previews.push((
181                    tree_fix.rel,
182                    Fix {
183                        start_line: 1,
184                        end_line: line_count,
185                        replacement: tree_fix.replacement,
186                    },
187                ));
188            }
189
190            print_dry_run(&previews);
191            output::detail(&format!(
192                "{} fix(es) would be applied (dry run, no files changed)",
193                previews.len()
194            ));
195            output::blank();
196        }
197
198        return false;
199    }
200
201    let mut applied_total = 0;
202
203    for _ in 0..MAX_FIX_ITERATIONS {
204        let applied = if tree_fixes.is_empty() {
205            fix::apply_fixes(&fixes, ctx.root)
206        } else {
207            tree_fixes.sort_by(|a, b| a.rule.cmp(b.rule).then(a.rel.cmp(&b.rel)));
208            let rule = tree_fixes[0].rule;
209            let selected: Vec<TreeFix> = tree_fixes
210                .iter()
211                .filter(|tree_fix| tree_fix.rule == rule)
212                .cloned()
213                .collect();
214
215            fix::apply_tree_fixes(&selected, ctx.root)
216        };
217
218        applied_total += applied;
219        let (remaining, next_fixes, next_tree_fixes) = match collect_violations(ctx, true, None) {
220            Ok(result) => result,
221            Err(error) => {
222                if !ctx.quiet {
223                    output::error(&format!("tidy analysis aborted after fixes: {error}"));
224                }
225
226                return false;
227            }
228        };
229
230        if remaining.is_empty() {
231            if !ctx.quiet {
232                output::success(&format!("applied {applied_total} fix(es)"));
233                output::blank();
234                output::success(&format!("all {total} rule(s) passed after fixes"));
235                output::blank();
236            }
237
238            return true;
239        }
240
241        if next_fixes.is_empty() && next_tree_fixes.is_empty() {
242            if !ctx.quiet {
243                output::success(&format!("applied {applied_total} fix(es)"));
244                output::blank();
245                print_grouped(&remaining);
246            }
247
248            return false;
249        }
250
251        if applied == 0 {
252            if !ctx.quiet {
253                output::error("fix conflict: fixable violations remain but no edit made progress");
254                print_grouped(&remaining);
255            }
256
257            return false;
258        }
259
260        fixes = next_fixes;
261        tree_fixes = next_tree_fixes;
262    }
263
264    let remaining = match collect_violations(ctx, false, None) {
265        Ok((remaining, _, _)) => remaining,
266        Err(error) => {
267            if !ctx.quiet {
268                output::error(&format!("tidy analysis aborted after fixes: {error}"));
269            }
270
271            return false;
272        }
273    };
274
275    if !ctx.quiet {
276        output::error("fix conflict: reached the 10-iteration fixpoint cap");
277        print_grouped(&remaining);
278    }
279
280    false
281}
282
283/// Walk the target crates in parallel and collect all violations (and optional fixes) from every enabled rule.
284pub(crate) fn collect_violations(
285    ctx: &RunCtx<'_>,
286    fix_mode: bool,
287    paths_override: Option<Vec<PathBuf>>,
288) -> AnalysisResult {
289    let rules = enabled_rules(ctx);
290
291    if rules.is_empty() {
292        return Ok((Vec::new(), Vec::new(), Vec::new()));
293    }
294
295    let mut extensions: Vec<&str> = rules
296        .iter()
297        .flat_map(|rule| rule.check.extensions().iter().copied())
298        .collect();
299
300    extensions.sort_unstable();
301    extensions.dedup();
302
303    let (paths, target_rels) = analysis_paths(ctx, paths_override, &extensions)?;
304    let registered_names: HashSet<&str> = inventory::iter::<Rule>
305        .into_iter()
306        .map(|rule| rule.info.name)
307        .collect();
308    let cache = (!fix_mode)
309        .then(|| cache::Session::open(ctx, &rules))
310        .flatten();
311
312    if let Some(result) = complete_cache_result(cache.as_ref(), &rules, target_rels.as_ref()) {
313        return Ok(result);
314    }
315
316    let analyzed = analyze_paths(
317        &paths,
318        cache.as_ref(),
319        ctx,
320        &rules,
321        &registered_names,
322        fix_mode,
323    );
324    let results = require_complete_analysis(analyzed)?;
325
326    persist_file_cache(cache.as_ref(), ctx.root, &results);
327
328    let (
329        mut all_violations,
330        mut all_fixes,
331        mut all_tree_fixes,
332        workspace_files,
333        workspace_manifests,
334    ) = merge_file_results(results);
335    let workspace_ctx = WorkspaceCtx {
336        files: &workspace_files,
337        manifests: &workspace_manifests,
338        config: ctx.config,
339    };
340
341    all_violations.extend(workspace_violations(cache.as_ref(), &rules, &workspace_ctx));
342    persist_workspace_cache(cache.as_ref());
343    persist_complete_cache(cache.as_ref(), &all_violations);
344    retain_targets(
345        target_rels.as_ref(),
346        &mut all_violations,
347        &mut all_fixes,
348        &mut all_tree_fixes,
349    );
350
351    all_violations.sort_by(|a, b| a.rel.cmp(&b.rel).then(a.line.cmp(&b.line)));
352
353    Ok((all_violations, all_fixes, all_tree_fixes))
354}
355
356fn complete_cache_result(
357    cache: Option<&cache::Session>,
358    rules: &[&'static Rule],
359    target_rels: Option<&HashSet<String>>,
360) -> Option<CollectedAnalysis> {
361    let mut violations = cache?.complete(rules)?;
362
363    if let Some(target_rels) = target_rels {
364        violations.retain(|violation| target_rels.contains(&violation.rel));
365    }
366
367    violations.sort_by(|a, b| a.rel.cmp(&b.rel).then(a.line.cmp(&b.line)));
368
369    Some((violations, Vec::new(), Vec::new()))
370}
371
372fn analyze_paths(
373    paths: &[PathBuf],
374    cache: Option<&cache::Session>,
375    ctx: &RunCtx<'_>,
376    rules: &[&'static Rule],
377    registered_names: &HashSet<&str>,
378    fix_mode: bool,
379) -> Vec<FileAnalysis> {
380    paths
381        .par_iter()
382        .map(|path| {
383            if let Some(result) = cache.and_then(|cache| cache.restore(path, rules)) {
384                return Ok(Some(result));
385            }
386
387            analyze_path(path, ctx, rules, registered_names, fix_mode)
388        })
389        .collect()
390}
391
392fn persist_file_cache(cache: Option<&cache::Session>, root: &Path, results: &[FileResult]) {
393    if let Some(cache) = cache {
394        cache.persist(root, results);
395    }
396}
397
398fn persist_complete_cache(cache: Option<&cache::Session>, violations: &[Violation]) {
399    if let Some(cache) = cache {
400        cache.persist_complete(violations);
401    }
402}
403
404fn persist_workspace_cache(cache: Option<&cache::Session>) {
405    if let Some(cache) = cache {
406        cache.persist_workspace();
407    }
408}
409
410fn retain_targets(
411    target_rels: Option<&HashSet<String>>,
412    violations: &mut Vec<Violation>,
413    fixes: &mut Vec<(String, Fix)>,
414    tree_fixes: &mut Vec<TreeFix>,
415) {
416    let Some(target_rels) = target_rels else {
417        return;
418    };
419
420    violations.retain(|violation| target_rels.contains(&violation.rel));
421    fixes.retain(|(rel, _)| target_rels.contains(rel));
422    tree_fixes.retain(|tree_fix| target_rels.contains(&tree_fix.rel));
423}
424
425fn analysis_paths(
426    ctx: &RunCtx<'_>,
427    paths_override: Option<Vec<PathBuf>>,
428    extensions: &[&str],
429) -> Result<(Vec<PathBuf>, Option<HashSet<String>>), AnalysisError> {
430    let requested = match paths_override {
431        Some(paths) => Some(paths),
432        None if ctx.dirty => Some(walk::git_dirty_paths(
433            ctx.root,
434            ctx.crates_dir,
435            ctx.crate_filter,
436            extensions,
437        )?),
438        None if !ctx.crate_filter.is_empty() => Some(walk::source_paths(
439            ctx.crates_dir,
440            ctx.crate_filter,
441            extensions,
442        )?),
443        None => None,
444    };
445    let Some(requested) = requested else {
446        return Ok((walk::source_paths(ctx.crates_dir, &[], extensions)?, None));
447    };
448    let target_rels = requested
449        .iter()
450        .map(|path| {
451            path.strip_prefix(ctx.root)
452                .unwrap_or(path)
453                .display()
454                .to_string()
455        })
456        .collect();
457    let paths = walk::source_paths(ctx.crates_dir, &[], extensions)?;
458
459    Ok((paths, Some(target_rels)))
460}
461
462fn analyze_path(
463    path: &Path,
464    ctx: &RunCtx<'_>,
465    rules: &[&'static Rule],
466    registered_names: &HashSet<&str>,
467    fix_mode: bool,
468) -> FileAnalysis {
469    let rel = path
470        .strip_prefix(ctx.root)
471        .unwrap_or(path)
472        .display()
473        .to_string();
474    let contents = file::read_text(path).map_err(|error| format!("{rel}: {error}"))?;
475    let lines: Vec<&str> = contents.lines().collect();
476    let file = FileCtx {
477        rel: &rel,
478        path,
479        lines: &lines,
480        contents: &contents,
481        config: ctx.config,
482    };
483    let analysis = match path.extension().and_then(|extension| extension.to_str()) {
484        Some("rs") => languages::rust::analyze(&file, rules, registered_names, fix_mode),
485        Some("toml") => languages::toml::analyze(&file, rules, fix_mode),
486        _ => return Ok(None),
487    };
488
489    Ok(Some(FileResult {
490        path: path.to_path_buf(),
491        analysis,
492    }))
493}
494
495fn require_complete_analysis(
496    analyzed: Vec<FileAnalysis>,
497) -> Result<Vec<FileResult>, AnalysisError> {
498    let mut failures = Vec::new();
499    let mut results = Vec::new();
500
501    for result in analyzed {
502        match result {
503            Ok(Some(result)) => results.push(result),
504            Ok(None) => {}
505            Err(error) => failures.push(error),
506        }
507    }
508
509    if failures.is_empty() {
510        Ok(results)
511    } else {
512        failures.sort_unstable();
513        failures.dedup();
514
515        Err(AnalysisError::Read(failures.join("; ").into_boxed_str()))
516    }
517}
518
519fn merge_file_results(results: Vec<FileResult>) -> CollectedResults {
520    let mut all_violations = Vec::new();
521    let mut all_fixes = Vec::new();
522    let mut all_tree_fixes = Vec::new();
523    let mut workspace_files = Vec::new();
524    let mut workspace_manifests = Vec::new();
525
526    for result in results {
527        all_violations.extend(result.analysis.violations);
528        all_fixes.extend(result.analysis.fixes);
529        all_tree_fixes.extend(result.analysis.tree_fixes);
530        workspace_files.extend(result.analysis.workspace_files);
531        workspace_manifests.extend(result.analysis.workspace_manifests);
532    }
533
534    (
535        all_violations,
536        all_fixes,
537        all_tree_fixes,
538        workspace_files,
539        workspace_manifests,
540    )
541}
542
543fn workspace_violations(
544    cache: Option<&cache::Session>,
545    rules: &[&'static Rule],
546    workspace_ctx: &WorkspaceCtx<'_>,
547) -> Vec<Violation> {
548    let mut violations = Vec::new();
549
550    for rule in rules {
551        let (crate::RuleCheck::RustWorkspace(check) | crate::RuleCheck::Workspace(check)) =
552            rule.check
553        else {
554            continue;
555        };
556        let compute = || {
557            check(workspace_ctx)
558                .into_iter()
559                .map(|violation| violation.with_rule(rule.info.name))
560                .filter(|violation| {
561                    workspace_ctx
562                        .files
563                        .iter()
564                        .find(|file| file.rel == violation.rel)
565                        .is_none_or(|file| {
566                            !crate::infra::ignore::is_file_suppressed(
567                                &file.suppressions,
568                                rule.info.name,
569                            ) && !file
570                                .suppressions
571                                .entries
572                                .iter()
573                                .any(|entry| entry.suppresses(violation))
574                        })
575                })
576                .collect()
577        };
578        let rule_violations = if let Some(cache) = cache {
579            cache.workspace_violations(rule, workspace_ctx, compute)
580        } else {
581            compute()
582        };
583
584        violations.extend(rule_violations);
585    }
586
587    violations
588}
589
590#[cfg(test)]
591mod tests {
592    use googletest::prelude::*;
593
594    use super::*;
595
596    #[gtest]
597    fn cargo_target_dir_honors_absolute_relative_and_default_paths() -> Result<()> {
598        let directory = wowlab_fs::temporary::Directory::new().or_fail()?;
599        let root = directory.path();
600        let crates_dir = root.join("crates");
601        let absolute = root.join("persistent-target");
602
603        verify_eq!(
604            resolve_cargo_target_dir(root, &crates_dir, Some(absolute.clone())),
605            absolute
606        )?;
607        verify_eq!(
608            resolve_cargo_target_dir(root, &crates_dir, Some(PathBuf::from("shared-target"))),
609            root.join("shared-target")
610        )?;
611
612        verify_eq!(
613            resolve_cargo_target_dir(root, &crates_dir, None),
614            crates_dir.join("target")
615        )
616    }
617
618    #[gtest]
619    fn source_read_failures_are_fatal_and_deterministic() -> Result<()> {
620        let directory = wowlab_fs::temporary::Directory::new().or_fail()?;
621        let crates_dir = directory.path().join("crates");
622
623        wowlab_fs::directory::create(&crates_dir).or_fail()?;
624        let first = crates_dir.join("a.rs");
625        let second = crates_dir.join("b.rs");
626
627        file::write_bytes(&first, [0xff]).or_fail()?;
628        file::write_bytes(&second, [0xff]).or_fail()?;
629
630        let metadata = crate::all_rules();
631        let registered: Vec<_> = metadata
632            .iter()
633            .map(|rule| (rule.name, rule.params))
634            .collect();
635        let config = Config::generate_default(&registered);
636        let ctx = RunCtx {
637            rule_filter: &[],
638            crate_filter: &[],
639            config: &config,
640            root: directory.path(),
641            crates_dir: &crates_dir,
642            quiet: true,
643            dirty: false,
644        };
645        let error = collect_violations(&ctx, false, Some(vec![second, first]))
646            .unwrap_err()
647            .to_string();
648
649        verify_that!(error.as_str(), contains_substring("crates/a.rs"))?;
650        verify_that!(error.as_str(), contains_substring("crates/b.rs"))?;
651
652        verify_true!(error.find("crates/a.rs").or_fail()? < error.find("crates/b.rs").or_fail()?)
653    }
654
655    #[gtest]
656    fn restricted_analysis_retains_complete_workspace_context() -> Result<()> {
657        let directory = wowlab_fs::temporary::Directory::new().or_fail()?;
658        let crates_dir = directory.path().join("crates");
659
660        wowlab_fs::directory::create(&crates_dir).or_fail()?;
661        let requested = crates_dir.join("changed.rs");
662        let context_only = crates_dir.join("unchanged.rs");
663
664        file::write_text(&requested, "fn changed() {}\n").or_fail()?;
665        file::write_text(&context_only, "fn unchanged() {}\n").or_fail()?;
666
667        let metadata = crate::all_rules();
668        let registered: Vec<_> = metadata
669            .iter()
670            .map(|rule| (rule.name, rule.params))
671            .collect();
672        let config = Config::generate_default(&registered);
673        let ctx = RunCtx {
674            rule_filter: &[],
675            crate_filter: &[],
676            config: &config,
677            root: directory.path(),
678            crates_dir: &crates_dir,
679            quiet: true,
680            dirty: false,
681        };
682        let (paths, target_rels) = analysis_paths(&ctx, Some(vec![requested]), &["rs"])?;
683
684        verify_that!(paths, len(eq(2)))?;
685
686        verify_eq!(
687            target_rels,
688            Some(HashSet::from(["crates/changed.rs".to_string()]))
689        )
690    }
691
692    #[gtest]
693    fn crate_filtered_analysis_retains_complete_workspace_context() -> Result<()> {
694        let directory = wowlab_fs::temporary::Directory::new().or_fail()?;
695        let crates_dir = directory.path().join("crates");
696        let selected_dir = crates_dir.join("selected");
697        let context_dir = crates_dir.join("context");
698
699        wowlab_fs::directory::ensure(&selected_dir).or_fail()?;
700        wowlab_fs::directory::ensure(&context_dir).or_fail()?;
701        file::write_text(&selected_dir.join("selected.rs"), "fn selected() {}\n").or_fail()?;
702        file::write_text(&context_dir.join("context.rs"), "fn context() {}\n").or_fail()?;
703
704        let metadata = crate::all_rules();
705        let registered: Vec<_> = metadata
706            .iter()
707            .map(|rule| (rule.name, rule.params))
708            .collect();
709        let config = Config::generate_default(&registered);
710        let crate_filter = vec!["selected".to_string()];
711        let ctx = RunCtx {
712            rule_filter: &[],
713            crate_filter: &crate_filter,
714            config: &config,
715            root: directory.path(),
716            crates_dir: &crates_dir,
717            quiet: true,
718            dirty: false,
719        };
720        let (paths, target_rels) = analysis_paths(&ctx, None, &["rs"])?;
721
722        verify_that!(paths, len(eq(2)))?;
723
724        verify_eq!(
725            target_rels,
726            Some(HashSet::from(["crates/selected/selected.rs".to_string()]))
727        )
728    }
729
730    #[gtest]
731    fn fixpoint_rechecks_unfixed_files_in_the_selected_scope() -> Result<()> {
732        let directory = wowlab_fs::temporary::Directory::new().or_fail()?;
733        let crates_dir = directory.path().join("crates");
734        let crate_dir = crates_dir.join("demo");
735
736        wowlab_fs::directory::ensure(&crate_dir).or_fail()?;
737        let fixable = crate_dir.join("fixable.rs");
738
739        file::write_text(
740            &fixable,
741            "pub fn value() {\n    let value = 1;\n    std::hint::black_box(value);\n}\n",
742        )
743        .or_fail()?;
744        file::write_text(
745            &crate_dir.join("unfixable.rs"),
746            "pub fn explode() {\n    panic!();\n}\n",
747        )
748        .or_fail()?;
749
750        let metadata = crate::all_rules();
751        let registered: Vec<_> = metadata
752            .iter()
753            .map(|rule| (rule.name, rule.params))
754            .collect();
755        let config = Config::generate_default(&registered);
756        let rule_filter = vec!["rust_padding".to_string(), "rust_panic".to_string()];
757        let crate_filter = vec!["demo".to_string()];
758        let ctx = RunCtx {
759            rule_filter: &rule_filter,
760            crate_filter: &crate_filter,
761            config: &config,
762            root: directory.path(),
763            crates_dir: &crates_dir,
764            quiet: true,
765            dirty: false,
766        };
767
768        verify_false!(run(&ctx, FixMode::Apply))?;
769
770        let fixed = file::read_text(&fixable).or_fail()?;
771
772        verify_that!(
773            fixed,
774            contains_substring("let value = 1;\n\n    std::hint::black_box(value);")
775        )
776    }
777}