Skip to main content

slopguard_core/fix/
mod.rs

1//! `scan --fix`: apply the `rewrite` template of autofix-safe rules in place.
2//!
3//! The detection pass never touches `rewrite`. This module compiles each
4//! autofixable rule with its `rewrite` as the ast-grep `fix`, matches the source
5//! directly (so it can re-match a mutated buffer and reach a fix point), and
6//! applies the resulting edits. Findings that detection would suppress -- inside
7//! test code, disabled inline, or recorded in the baseline -- are never
8//! rewritten, mirroring the scanner's own filters.
9
10use std::collections::HashSet;
11use std::fs;
12use std::path::{Path, PathBuf};
13
14use ast_grep_config::RuleConfig;
15use ast_grep_core::tree_sitter::LanguageExt;
16use ast_grep_language::SupportLang;
17
18use crate::baseline::{finding_hash, Baseline};
19use crate::config::Config;
20use crate::disable::is_disabled;
21use crate::finding::Finding;
22use crate::rule::{Category, Rule, RuleId, Severity};
23use crate::scanner::{build_glob_set, compile_ast_grep_rule, walk_files, AstGrepRule, ScanError};
24use crate::test_filter::{CfgTestRanges, TestPaths};
25
26/// Upper bound on the re-scan/re-apply cycles per file. Fixing overlapping or
27/// nested matches can expose new ones; the loop stops at a fix point or here.
28const MAX_ITERATIONS: usize = 10;
29
30/// One autofixable rule compiled for a specific ast-grep language, keeping a way
31/// back to the slopguard rule identity for filtering.
32struct CompiledFixRule {
33    rule_id: RuleId,
34    lang: SupportLang,
35    skip_test_code: bool,
36    config: RuleConfig<SupportLang>,
37}
38
39/// A single text replacement, decoupled from ast-grep's borrowed `Edit` so it
40/// outlives the parsed tree.
41struct RawEdit {
42    position: usize,
43    deleted_length: usize,
44    inserted: Vec<u8>,
45}
46
47/// The rewrite of one file: kept only when the content actually changed.
48pub struct FileFix {
49    pub path: PathBuf,
50    pub original: String,
51    pub fixed: String,
52}
53
54/// Outcome of a `--fix` run over one or more paths.
55#[derive(Default)]
56pub struct FixReport {
57    /// Files whose content changed, with their before/after text.
58    pub files: Vec<FileFix>,
59    /// Total number of edits applied across every file.
60    pub applied: usize,
61}
62
63impl FixReport {
64    /// Number of files whose content changed.
65    pub fn files_changed(&self) -> usize {
66        self.files.len()
67    }
68}
69
70/// Compile every autofixable rule (`autofix_safe` + `rewrite`) with its rewrite
71/// as the ast-grep fix template, once per ast-grep language it targets.
72fn compile_fix_rules(rules: &[Rule]) -> Result<Vec<CompiledFixRule>, ScanError> {
73    let mut compiled = Vec::new();
74    for rule in rules.iter().filter(|r| r.is_autofixable()) {
75        // `is_autofixable` guarantees `rewrite` is present.
76        let Some(rewrite) = rule.rewrite.as_deref() else {
77            continue;
78        };
79        // A dedicated `autofix_rule` is self-contained: its metavariables need
80        // not match those of the detection `rule`, so detection `constraints`
81        // (which reference the detection rule's metavariables) do not apply.
82        let uses_dedicated_matcher = !rule.autofix_rule.is_null();
83        let constraints =
84            (!uses_dedicated_matcher && !rule.constraints.is_null()).then_some(&rule.constraints);
85        for &lang in rule.language.ast_grep_langs() {
86            let ast_grep_rule = AstGrepRule {
87                id: rule.id.as_str(),
88                language: lang,
89                severity: &rule.severity,
90                message: &rule.message,
91                note: rule.note.as_deref(),
92                rule: rule.fix_matcher(),
93                constraints,
94                fix: Some(rewrite),
95                files: rule.files.as_deref(),
96                ignores: rule.ignores.as_deref(),
97            };
98            let config =
99                compile_ast_grep_rule(&ast_grep_rule).map_err(|source| ScanError::RuleCompile {
100                    id: rule.id.clone(),
101                    source,
102                })?;
103            compiled.push(CompiledFixRule {
104                rule_id: rule.id.clone(),
105                lang,
106                skip_test_code: rule.skip_test_code,
107                config,
108            });
109        }
110    }
111    Ok(compiled)
112}
113
114/// The baseline hashes to skip, empty when no baseline is active.
115fn baseline_hash_set(baseline: Option<&Baseline>) -> HashSet<String> {
116    baseline
117        .map(|b| b.findings.iter().map(|e| e.hash.clone()).collect())
118        .unwrap_or_default()
119}
120
121/// Apply non-overlapping edits (sorted ascending) to `source`, right to left so
122/// earlier positions stay valid.
123fn apply_edits(source: &str, edits: &[RawEdit]) -> String {
124    let mut bytes = source.as_bytes().to_vec();
125    for edit in edits.iter().rev() {
126        let end = edit.position + edit.deleted_length;
127        if end > bytes.len() {
128            continue;
129        }
130        bytes.splice(edit.position..end, edit.inserted.iter().copied());
131    }
132    String::from_utf8(bytes).unwrap_or_else(|_| source.to_string())
133}
134
135/// Greedily keep the edits that do not overlap a previously kept one. Input must
136/// be sorted by position. An edit starting before the running end (a nested or
137/// duplicate match) is dropped and left for the next fix-point iteration.
138fn select_non_overlapping(mut edits: Vec<RawEdit>) -> Vec<RawEdit> {
139    edits.sort_by_key(|e| e.position);
140    let mut kept: Vec<RawEdit> = Vec::new();
141    let mut last_end = 0usize;
142    for edit in edits {
143        if edit.position >= last_end {
144            last_end = edit.position + edit.deleted_length;
145            kept.push(edit);
146        }
147    }
148    kept
149}
150
151/// Collect the edits due for `source` in one pass, honoring the same filters as
152/// detection: `skip_test_code` (path heuristic and inline `#[cfg(test)]`),
153/// inline disable directives, and baseline membership.
154fn collect_edits(
155    source: &str,
156    path: &Path,
157    lang: SupportLang,
158    rules: &[CompiledFixRule],
159    test_paths: &TestPaths,
160    baseline_hashes: &HashSet<String>,
161    baseline_root: &Path,
162) -> Vec<RawEdit> {
163    let ast = lang.ast_grep(source);
164    let cfg_test = (lang == SupportLang::Rust).then(|| CfgTestRanges::from_root(&ast));
165    let is_test_file = test_paths.is_test(path);
166
167    let mut edits = Vec::new();
168    for rule in rules.iter().filter(|r| r.lang == lang) {
169        let Some(fixer) = rule.config.fixer.first() else {
170            continue;
171        };
172        for node_match in ast.root().find_all(&rule.config.matcher) {
173            let start_line = node_match.start_pos().line() + 1;
174
175            let in_test_code = rule.skip_test_code
176                && (is_test_file
177                    || cfg_test
178                        .as_ref()
179                        .is_some_and(|ranges| ranges.contains_line(start_line)));
180            if in_test_code {
181                continue;
182            }
183            if is_disabled(source, start_line, &rule.rule_id) {
184                continue;
185            }
186            if !baseline_hashes.is_empty() {
187                let finding = position_finding(&rule.rule_id, path, start_line, &node_match.text());
188                let hash = finding_hash(&finding, baseline_root, Some(source));
189                if baseline_hashes.contains(&hash) {
190                    continue;
191                }
192            }
193
194            let edit = node_match.make_edit(&rule.config.matcher, fixer);
195            edits.push(RawEdit {
196                position: edit.position,
197                deleted_length: edit.deleted_length,
198                inserted: edit.inserted_text,
199            });
200        }
201    }
202    edits
203}
204
205/// A finding shaped only for baseline hashing: `finding_hash` reads the rule id,
206/// path, line (for context) and matched text; the rest is filler.
207fn position_finding(rule_id: &RuleId, path: &Path, line: usize, matched: &str) -> Finding {
208    Finding {
209        rule_id: rule_id.clone(),
210        severity: Severity::Warning,
211        category: Category::Correctness,
212        message: String::new(),
213        note: None,
214        fix: None,
215        file: path.to_path_buf(),
216        line,
217        column: 1,
218        end_line: line,
219        end_column: 1,
220        matched_text: matched.to_string(),
221        confidence: None,
222        escalated: false,
223    }
224}
225
226/// Rewrite one file's content to a fix point.
227fn fix_source(
228    original: &str,
229    path: &Path,
230    lang: SupportLang,
231    rules: &[CompiledFixRule],
232    test_paths: &TestPaths,
233    baseline_hashes: &HashSet<String>,
234    baseline_root: &Path,
235) -> (String, usize) {
236    let mut source = original.to_string();
237    let mut applied = 0;
238    for _ in 0..MAX_ITERATIONS {
239        let edits = collect_edits(
240            &source,
241            path,
242            lang,
243            rules,
244            test_paths,
245            baseline_hashes,
246            baseline_root,
247        );
248        if edits.is_empty() {
249            break;
250        }
251        let selected = select_non_overlapping(edits);
252        if selected.is_empty() {
253            break;
254        }
255        applied += selected.len();
256        source = apply_edits(&source, &selected);
257    }
258    (source, applied)
259}
260
261/// Apply the autofix-safe rewrites over `paths`.
262///
263/// Nothing is written here: [`FileFix`] carries the before/after text so the
264/// caller can print a diff (`--dry-run`) or persist the change. `baseline`
265/// suppresses rewrites already recorded against `baseline_root`.
266pub fn fix_paths(
267    paths: &[PathBuf],
268    rules: &[Rule],
269    config: &Config,
270    baseline: Option<&Baseline>,
271    baseline_root: &Path,
272) -> Result<FixReport, ScanError> {
273    let test_paths = TestPaths::new(&config.scan.test_paths)?;
274    let ignores = build_glob_set(&config.scan.ignores)?;
275    let compiled = compile_fix_rules(rules)?;
276    if compiled.is_empty() {
277        return Ok(FixReport::default());
278    }
279    let baseline_hashes = baseline_hash_set(baseline);
280
281    let mut report = FixReport::default();
282    for (path, lang) in walk_files(paths, &ignores) {
283        let Ok(original) = fs::read_to_string(&path) else {
284            continue;
285        };
286        let (fixed, applied) = fix_source(
287            &original,
288            &path,
289            lang,
290            &compiled,
291            &test_paths,
292            &baseline_hashes,
293            baseline_root,
294        );
295        if applied > 0 && fixed != original {
296            report.applied += applied;
297            report.files.push(FileFix {
298                path,
299                original,
300                fixed,
301            });
302        }
303    }
304    Ok(report)
305}
306
307/// Apply a single rule's autofix rewrite to an in-memory snippet, to a fix
308/// point. Used by `slopguard test` to validate `tests.should_fix`: no baseline,
309/// no test-path filtering, no inline-disable directives are consulted. Returns
310/// the snippet unchanged when the rule is not autofixable.
311pub fn fix_snippet(rule: &Rule, source: &str) -> Result<String, ScanError> {
312    let compiled = compile_fix_rules(std::slice::from_ref(rule))?;
313    if compiled.is_empty() {
314        return Ok(source.to_string());
315    }
316    let lang = compiled[0].lang;
317    let test_paths = TestPaths::new(&[])?;
318    let baseline_hashes = HashSet::new();
319    let path = Path::new("snippet");
320    let (fixed, _applied) = fix_source(
321        source,
322        path,
323        lang,
324        &compiled,
325        &test_paths,
326        &baseline_hashes,
327        path,
328    );
329    Ok(fixed)
330}
331
332#[cfg(test)]
333mod tests;