Skip to main content

wowlab_tidy/runner/
clean.rs

1// #t(file: rust_alloc_in_loop, rust_default_hasher) clean is a cold developer-tool path with trusted rule-name keys and explicit edit reports
2
3use std::{
4    collections::{BTreeSet, HashSet},
5    process::ExitCode,
6};
7
8#[cfg(test)]
9use googletest::prelude::*;
10use wowlab_common::output;
11
12use super::RunCtx;
13use crate::{
14    FileCtx, Fix, Rule, Violation,
15    infra::{fix, ignore::SuppressionEntry, parse, walk},
16    languages::rust::{self, SuppressionAuditError},
17};
18
19const DEFAULT_FILE_SIZE_THRESHOLD: usize = 1500;
20const PREVIOUS_LINE_OFFSET: usize = 2;
21
22#[derive(Debug)]
23struct Removal {
24    rel: String,
25    line: usize,
26    targets: Box<[String]>,
27}
28
29#[derive(Debug, Default)]
30struct CleanPlan {
31    fixes: Vec<(String, Fix)>,
32    removals: Vec<Removal>,
33    failures: Vec<Box<str>>,
34    generated_files: usize,
35}
36
37#[derive(Debug)]
38struct AuditedFile {
39    rel: String,
40    contents: String,
41    suppressions: crate::infra::ignore::Suppressions,
42    raw_violations: Vec<Violation>,
43}
44
45#[derive(Debug)]
46enum AuditedPath {
47    Generated,
48    Source(Box<AuditedSource>),
49}
50
51#[derive(Debug)]
52struct AuditedSource {
53    selected: Option<AuditedFile>,
54    workspace_file: crate::languages::workspace::WorkspaceRustFile,
55}
56
57#[derive(Debug)]
58enum EntryPlanError {
59    Render,
60}
61
62/// Remove only suppression targets that do not cover an underlying violation.
63#[must_use]
64pub fn clean_suppressions(ctx: &RunCtx<'_>, dry_run: bool) -> ExitCode {
65    let mut plan = build_plan(ctx);
66
67    if !plan.failures.is_empty() {
68        return report_failures(plan.failures, ctx.quiet);
69    }
70
71    plan.removals
72        .sort_by(|a, b| a.rel.cmp(&b.rel).then(a.line.cmp(&b.line)));
73
74    if plan.removals.is_empty() {
75        if !ctx.quiet {
76            output::success("no unused suppression directives found");
77
78            if plan.generated_files > 0 {
79                output::detail(&format!(
80                    "skipped {} generated Rust file(s)",
81                    plan.generated_files
82                ));
83            }
84
85            output::blank();
86        }
87
88        return ExitCode::SUCCESS;
89    }
90
91    let target_count: usize = plan
92        .removals
93        .iter()
94        .map(|removal| removal.targets.len())
95        .sum();
96
97    if !ctx.quiet {
98        print_removals(&plan.removals);
99    }
100
101    if dry_run {
102        if !ctx.quiet {
103            super::report::print_dry_run(&plan.fixes);
104            output::detail(&format!(
105                "{target_count} unused suppression target(s) would be removed (dry run)"
106            ));
107            output::blank();
108        }
109
110        return ExitCode::SUCCESS;
111    }
112
113    let applied = fix::apply_fixes(&plan.fixes, ctx.root);
114
115    if applied != plan.fixes.len() {
116        if !ctx.quiet {
117            output::error(&format!(
118                "applied {applied} of {} planned directive edit(s)",
119                plan.fixes.len()
120            ));
121            output::blank();
122        }
123
124        return ExitCode::FAILURE;
125    }
126
127    if !ctx.quiet {
128        output::success(&format!(
129            "removed {target_count} unused suppression target(s) in {applied} directive(s)"
130        ));
131        output::blank();
132    }
133
134    ExitCode::SUCCESS
135}
136
137fn report_failures(mut failures: Vec<Box<str>>, quiet: bool) -> ExitCode {
138    failures.sort_unstable();
139
140    if !quiet {
141        for failure in failures {
142            output::error(&failure);
143        }
144
145        output::blank();
146        output::error("clean aborted without writing because suppression analysis was incomplete");
147        output::blank();
148    }
149
150    ExitCode::FAILURE
151}
152
153fn build_plan(ctx: &RunCtx<'_>) -> CleanPlan {
154    let rules: Vec<&Rule> = inventory::iter::<Rule>
155        .into_iter()
156        .filter(|rule| rule.check.extensions().contains(&"rs"))
157        .collect();
158    let registered_names: HashSet<&str> = inventory::iter::<Rule>
159        .into_iter()
160        .map(|rule| rule.info.name)
161        .collect();
162    let mut plan = CleanPlan::default();
163    let (selected_rels, paths) = match cleaner_paths(ctx) {
164        Ok(paths) => paths,
165        Err(error) => {
166            plan.failures.push(error.to_string().into_boxed_str());
167
168            return plan;
169        }
170    };
171    let mut audited_files = Vec::new();
172    let mut workspace_files = Vec::new();
173
174    for path in paths {
175        let rel = relative_path(ctx, &path);
176        let selected = selected_rels.contains(&rel);
177
178        match audit_path(ctx, &path, rel, selected, &rules, &registered_names) {
179            Ok(AuditedPath::Generated) => {
180                plan.generated_files += usize::from(selected);
181            }
182            Ok(AuditedPath::Source(source)) => {
183                workspace_files.push(source.workspace_file);
184
185                if let Some(file) = source.selected {
186                    audited_files.push(file);
187                }
188            }
189            Err(failures) => plan.failures.extend(failures),
190        }
191    }
192
193    if !plan.failures.is_empty() {
194        return plan;
195    }
196
197    for violation in rust::audit_workspace_suppressions(&workspace_files, &rules, ctx.config) {
198        if let Some(file) = audited_files
199            .iter_mut()
200            .find(|file| file.rel == violation.rel)
201        {
202            file.raw_violations.push(violation);
203        }
204    }
205
206    for file in &mut audited_files {
207        file.raw_violations
208            .sort_by(|a, b| a.line.cmp(&b.line).then(a.rule.cmp(&b.rule)));
209    }
210
211    for file in audited_files {
212        plan_file(ctx, file, &rules, &mut plan);
213    }
214
215    plan
216}
217
218fn cleaner_paths(
219    ctx: &RunCtx<'_>,
220) -> Result<(BTreeSet<String>, Vec<wowlab_fs::path::PathBuf>), walk::PathDiscoveryError> {
221    let selected_paths = if ctx.dirty {
222        walk::git_dirty_paths(ctx.root, ctx.crates_dir, ctx.crate_filter, &["rs"])?
223    } else {
224        walk::rs_paths(ctx.crates_dir, ctx.crate_filter)?
225    };
226    let selected_rels = selected_paths
227        .iter()
228        .map(|path| relative_path(ctx, path))
229        .collect();
230    let paths = walk::rs_paths(ctx.crates_dir, &[])?;
231
232    Ok((selected_rels, paths))
233}
234
235fn audit_path(
236    ctx: &RunCtx<'_>,
237    path: &wowlab_fs::path::Path,
238    rel: String,
239    selected: bool,
240    rules: &[&Rule],
241    registered_names: &HashSet<&str>,
242) -> Result<AuditedPath, Vec<Box<str>>> {
243    let contents = match wowlab_fs::file::read_text(path) {
244        Ok(contents) => contents,
245        Err(error) => {
246            return Err(vec![
247                format!("{rel}: failed to read source: {error}").into_boxed_str(),
248            ]);
249        }
250    };
251
252    if is_generated(&contents) {
253        return Ok(AuditedPath::Generated);
254    }
255
256    let lines: Vec<&str> = contents.lines().collect();
257    let file = FileCtx {
258        rel: &rel,
259        path,
260        lines: &lines,
261        contents: &contents,
262        config: ctx.config,
263    };
264    let audit = match rust::audit_suppressions(&file, rules, registered_names) {
265        Ok(audit) => audit,
266        Err(SuppressionAuditError::RustSyntax) => {
267            return Err(vec![
268                format!("{rel}: Rust syntax could not be parsed").into_boxed_str(),
269            ]);
270        }
271        Err(SuppressionAuditError::InvalidDirectives(errors)) => {
272            return Err(errors
273                .into_iter()
274                .map(|error| {
275                    format!("{}:{}: {}", error.rel, error.line, error.message).into_boxed_str()
276                })
277                .collect());
278        }
279    };
280
281    let selected = selected.then_some(AuditedFile {
282        rel,
283        contents,
284        suppressions: audit.suppressions,
285        raw_violations: audit.raw_violations,
286    });
287
288    Ok(AuditedPath::Source(Box::new(AuditedSource {
289        selected,
290        workspace_file: audit.workspace_file,
291    })))
292}
293
294fn relative_path(ctx: &RunCtx<'_>, path: &wowlab_fs::path::Path) -> String {
295    path.strip_prefix(ctx.root)
296        .unwrap_or(path)
297        .display()
298        .to_string()
299}
300
301fn plan_file(ctx: &RunCtx<'_>, audited: AuditedFile, rules: &[&Rule], plan: &mut CleanPlan) {
302    let AuditedFile {
303        rel,
304        contents,
305        suppressions,
306        raw_violations,
307    } = audited;
308    let lines: Vec<&str> = contents.lines().collect();
309
310    for entry in suppressions.entries {
311        let source_line = lines
312            .get(entry.line.saturating_sub(1))
313            .copied()
314            .unwrap_or("");
315        let budget_active = entry.values.contains_key("rust_too_many_lines_in_file")
316            && lines.len() > file_size_threshold(ctx.config, rules);
317        let Ok(planned) = plan_entry(&entry, &raw_violations, source_line, budget_active) else {
318            plan.failures.push(
319                format!(
320                    "{rel}:{}: failed to render suppression directive",
321                    entry.line
322                )
323                .into_boxed_str(),
324            );
325            continue;
326        };
327        let Some((mut fix, stale)) = planned else {
328            continue;
329        };
330
331        if fix.replacement.is_empty() && redundant_separator_after(&entry, &lines) {
332            fix.end_line = fix.end_line.saturating_add(1);
333        }
334
335        // #t(rust_clone_in_loop) the edit and report entry independently own the stable relative path
336        plan.fixes.push((rel.clone(), fix));
337        plan.removals.push(Removal {
338            // #t(rust_clone_in_loop) the edit and report entry independently own the stable relative path
339            rel: rel.clone(),
340            line: entry.line,
341            targets: stale.into_boxed_slice(),
342        });
343    }
344}
345
346fn redundant_separator_after(entry: &SuppressionEntry, lines: &[&str]) -> bool {
347    let next_is_blank = lines
348        .get(entry.line)
349        .is_some_and(|line| line.trim().is_empty());
350    let previous_is_blank = entry
351        .line
352        .checked_sub(PREVIOUS_LINE_OFFSET)
353        .and_then(|index| lines.get(index))
354        .is_some_and(|line| line.trim().is_empty());
355
356    next_is_blank
357        && (entry.line == 1 || previous_is_blank || matches!(entry.scope, parse::Scope::File))
358}
359
360fn print_removals(removals: &[Removal]) {
361    for removal in removals {
362        output::detail(&format!(
363            "{}:{} remove {}",
364            removal.rel,
365            removal.line,
366            removal.targets.join(", ")
367        ));
368    }
369
370    output::blank();
371}
372
373fn plan_entry(
374    entry: &SuppressionEntry,
375    raw_violations: &[Violation],
376    source_line: &str,
377    budget_active: bool,
378) -> Result<Option<(Fix, Vec<String>)>, EntryPlanError> {
379    if entry.wildcard {
380        return Ok((!raw_violations
381            .iter()
382            .any(|violation| entry.suppresses(violation)))
383        .then(|| (Fix::delete(entry.line, entry.line), vec!["*".to_string()])));
384    }
385
386    let active: Vec<&str> = entry
387        .rules
388        .iter()
389        .filter(|target| {
390            (target.as_str() == "rust_too_many_lines_in_file" && budget_active)
391                || raw_violations.iter().any(|violation| {
392                    violation.rule == Some(target.as_str()) && entry.covers_line(violation.line)
393                })
394        })
395        .map(String::as_str)
396        .collect();
397    let stale: Vec<String> = entry
398        .rules
399        .iter()
400        .filter(|target| !active.contains(&target.as_str()))
401        .cloned()
402        .collect();
403
404    if stale.is_empty() {
405        return Ok(None);
406    }
407
408    let fix = if active.is_empty() {
409        Fix::delete(entry.line, entry.line)
410    } else {
411        let rendered: Vec<String> = active
412            .iter()
413            .map(|target| {
414                entry.values.get(*target).map_or_else(
415                    || (*target).to_owned(),
416                    |value| format!("{target} = {value}"),
417                )
418            })
419            .collect();
420        let rendered_refs: Vec<&str> = rendered.iter().map(String::as_str).collect();
421        let replacement = parse::rewrite_directive_rules(source_line, &entry.scope, &rendered_refs)
422            .ok_or(EntryPlanError::Render)?;
423
424        Fix::replace_line(entry.line, replacement)
425    };
426
427    Ok(Some((fix, stale)))
428}
429
430fn file_size_threshold(config: &crate::Config, rules: &[&Rule]) -> usize {
431    let parameter = rules
432        .iter()
433        .find(|rule| rule.info.name == "rust_too_many_lines_in_file")
434        .and_then(|rule| rule.info.params.first());
435
436    parameter.map_or(DEFAULT_FILE_SIZE_THRESHOLD, |param| {
437        config.get_usize("rust_too_many_lines_in_file", param)
438    })
439}
440
441fn is_generated(contents: &str) -> bool {
442    const GENERATED_HEADER_LINES: usize = 8;
443
444    contents
445        .lines()
446        .take(GENERATED_HEADER_LINES)
447        .any(|line| line.contains("@generated"))
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453    use crate::{infra::ignore, violation};
454
455    fn entries(source: &str) -> Result<Vec<SuppressionEntry>> {
456        let lines: Vec<&str> = source.lines().collect();
457        let mut errors = Vec::new();
458        let suppressions = ignore::suppressed_lines("fixture.rs", &lines, &mut errors, None);
459
460        verify_true!(errors.is_empty())?;
461
462        Ok(suppressions.entries)
463    }
464
465    fn tagged(line: usize, rule: &'static str) -> Violation {
466        violation("fixture.rs", line, "fixture violation").with_rule(rule)
467    }
468
469    #[gtest]
470    fn generated_header_is_detected_without_scanning_body() -> Result<()> {
471        verify_true!(is_generated(
472            "//! @generated by codegen\npub fn generated() {}"
473        ))?;
474        verify_false!(is_generated(
475            "\n\n\n\n\n\n\n\n// @generated appears too late\npub fn handwritten() {}\n"
476        ))?;
477
478        Ok(())
479    }
480
481    #[gtest]
482    fn mixed_targets_rewrite_only_the_target_list() -> Result<()> {
483        let source =
484            "    // #t(fn: rust_dbg, rust_panic) keep this exact reason\nfn f() { dbg!(1); }";
485        let entry = entries(source)?.pop().or_fail()?;
486
487        let (fix, stale) = plan_entry(
488            &entry,
489            &[tagged(2, "rust_dbg")],
490            source.lines().next().or_fail()?,
491            false,
492        )
493        .or_fail()?
494        .or_fail()?;
495
496        verify_eq!(stale, ["rust_panic"])?;
497        verify_eq!(
498            fix.replacement,
499            "    // #t(fn: rust_dbg) keep this exact reason"
500        )?;
501
502        Ok(())
503    }
504
505    #[gtest]
506    fn fully_stale_directive_deletes_only_its_line() -> Result<()> {
507        let source = "// #t(rust_panic) stale target\nlet value = 1;";
508        let entry = entries(source)?.pop().or_fail()?;
509
510        let (fix, stale) = plan_entry(&entry, &[], source.lines().next().or_fail()?, false)
511            .or_fail()?
512            .or_fail()?;
513
514        verify_eq!(stale, ["rust_panic"])?;
515        verify_true!(fix.replacement.is_empty())?;
516        verify_eq!((fix.start_line, fix.end_line), (1, 1))?;
517
518        Ok(())
519    }
520
521    #[gtest]
522    fn stale_file_directive_deletes_its_required_separator() -> Result<()> {
523        let source = "// #t(file: rust_panic) stale target\n\nfn retained() {}";
524        let entry = entries(source)?.pop().or_fail()?;
525        let lines: Vec<&str> = source.lines().collect();
526
527        verify_true!(redundant_separator_after(&entry, &lines))
528    }
529
530    #[gtest]
531    fn wildcard_is_atomic() -> Result<()> {
532        let source = "// #t(*) fixture wildcard\ndbg!(1);";
533        let entry = entries(source)?.pop().or_fail()?;
534
535        verify_true!(
536            plan_entry(
537                &entry,
538                &[tagged(2, "rust_dbg")],
539                source.lines().next().or_fail()?,
540                false,
541            )
542            .or_fail()?
543            .is_none()
544        )?;
545        let (_, stale) = plan_entry(&entry, &[], source.lines().next().or_fail()?, false)
546            .or_fail()?
547            .or_fail()?;
548
549        verify_eq!(stale, ["*"])?;
550
551        Ok(())
552    }
553
554    #[gtest]
555    fn every_scope_keeps_a_target_with_a_covered_violation() -> Result<()> {
556        let cases = [
557            ("// #t(rust_dbg) next\ndbg!(1);", 2),
558            ("// #t(file: rust_dbg) file\nfn f() {}", 20),
559            ("// #t(block: rust_dbg) block\ndbg!(1);\n", 2),
560            ("// #t(fn: rust_dbg) function\nfn f() {\n    dbg!(1);\n}", 3),
561        ];
562
563        for (source, violation_line) in cases {
564            let entry = entries(source)?.pop().or_fail()?;
565
566            verify_true!(
567                plan_entry(
568                    &entry,
569                    &[tagged(violation_line, "rust_dbg")],
570                    source.lines().next().or_fail()?,
571                    false,
572                )
573                .or_fail()?
574                .is_none()
575            )?;
576        }
577
578        Ok(())
579    }
580
581    #[gtest]
582    fn overlapping_active_directives_are_both_preserved() -> Result<()> {
583        let source =
584            "// #t(file: rust_dbg) first\n// #t(file: rust_dbg) second\nfn f() { dbg!(1); }";
585        let raw = [tagged(3, "rust_dbg")];
586
587        for entry in entries(source)? {
588            verify_true!(
589                plan_entry(
590                    &entry,
591                    &raw,
592                    source.lines().nth(entry.line - 1).or_fail()?,
593                    false,
594                )
595                .or_fail()?
596                .is_none()
597            )?;
598        }
599
600        Ok(())
601    }
602
603    #[gtest]
604    fn file_size_budget_is_preserved_above_base_threshold() -> Result<()> {
605        let source =
606            "// #t(file: rust_too_many_lines_in_file = 1600) cohesive generated-style table";
607        let entry = entries(source)?.pop().or_fail()?;
608
609        verify_true!(plan_entry(&entry, &[], source, true).or_fail()?.is_none())?;
610
611        Ok(())
612    }
613
614    #[gtest]
615    fn file_size_budget_is_stale_below_base_threshold() -> Result<()> {
616        let source = "// #t(file: rust_too_many_lines_in_file = 1600) old budget";
617        let entry = entries(source)?.pop().or_fail()?;
618        let (fix, stale) = plan_entry(&entry, &[], source, false)
619            .or_fail()?
620            .or_fail()?;
621
622        verify_eq!(stale, ["rust_too_many_lines_in_file"])?;
623        verify_true!(fix.replacement.is_empty())?;
624
625        Ok(())
626    }
627
628    #[gtest]
629    fn mixed_directive_preserves_numeric_budget_when_other_target_is_stale() -> Result<()> {
630        let source = "// #t(file: rust_too_many_lines_in_file = 1600, rust_dbg) exact reason";
631        let entry = entries(source)?.pop().or_fail()?;
632        let (fix, stale) = plan_entry(&entry, &[], source, true).or_fail()?.or_fail()?;
633
634        verify_eq!(stale, ["rust_dbg"])?;
635        verify_eq!(
636            fix.replacement,
637            "// #t(file: rust_too_many_lines_in_file = 1600) exact reason"
638        )?;
639
640        Ok(())
641    }
642}