1mod 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#[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#[derive(Clone, Copy, Debug)]
85pub enum FixMode {
87 Off,
88 Apply,
89 DryRun,
90}
91
92#[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#[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
164fn 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
283pub(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 ®istered_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(®istered);
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(®istered);
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(®istered);
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(®istered);
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}