Skip to main content

slopguard_core/fix/
mod.rs

1//! `scan --fix`: apply the rewrites of autofixable rules in place.
2//!
3//! The detection pass never touches `rewrite` or `fixer`. This module compiles
4//! each autofixable rule with its `rewrite` as the ast-grep `fix` (or with its
5//! detection matcher alone when a builtin `fixer` computes the edits), matches
6//! the source directly (so it can re-match a mutated buffer and reach a fix
7//! point), and applies the resulting edits. The edits of one match form a group
8//! applied together or not at all. Rules marked `autofix_unsafe` only run when
9//! the caller asks for unsafe fixes.
10//!
11//! A rewrite stays within the scope of detection: only builtin rules, or
12//! external rules the user listed in `fix.allow_external`, are applied; a rule's
13//! `files` / `ignores` globs are honored; a dedicated `autofix_rule` only
14//! rewrites inside a match of the detection `rule` (with its `constraints`).
15//! Findings that detection would suppress -- inside test code, disabled inline,
16//! or recorded in the baseline -- are never rewritten. Inline disables and
17//! baseline membership are resolved once on the original content and tracked
18//! through every edit, so a rewrite that shifts lines cannot unprotect them.
19
20use std::collections::{HashMap, HashSet};
21use std::fs;
22use std::ops::Range;
23use std::path::{Path, PathBuf};
24use std::slice::from_ref;
25
26use ast_grep_core::tree_sitter::LanguageExt;
27use ast_grep_language::SupportLang;
28
29use crate::baseline::{finding_hash, Baseline};
30use crate::config::Config;
31use crate::disable::is_disabled;
32use crate::finding::Finding;
33use crate::resolution::strum_derive_crate;
34use crate::rule::{Category, Rule, RuleId, Severity};
35use crate::scanner::{build_glob_set, walk_files, ScanError};
36use crate::test_filter::{CfgTestRanges, TestPaths};
37
38mod compile;
39mod edits;
40mod fixers;
41
42use compile::{compile_fix_rules, needs_strum, CompiledFixRule, FixAction};
43use edits::{apply_edits, select_non_overlapping, shift_protected, Protected, RawEdit};
44use fixers::FixEnv;
45pub use fixers::FixerKind;
46
47/// Upper bound on the re-scan/re-apply cycles per file. Fixing overlapping or
48/// nested matches can expose new ones; the loop stops at a fix point or here.
49const MAX_ITERATIONS: usize = 10;
50
51/// A match in scope for rewriting in one pass, with what both the protection
52/// pre-pass and the edit pass need from it. `edits` is the group its rewrite
53/// produces, `None` when a builtin fixer declined the match: it is still
54/// protected by an inline disable or the baseline, in case a later pass could
55/// fix it.
56struct Candidate {
57    range: Range<usize>,
58    line: usize,
59    text: String,
60    edits: Option<Vec<RawEdit>>,
61}
62
63/// The rewrite of one file: kept only when the content actually changed.
64pub struct FileFix {
65    pub path: PathBuf,
66    pub original: String,
67    pub fixed: String,
68}
69
70/// Outcome of a `--fix` run over one or more paths.
71#[derive(Default)]
72pub struct FixReport {
73    /// Files whose content changed, with their before/after text.
74    pub files: Vec<FileFix>,
75    /// Total number of edits applied across every file.
76    pub applied: usize,
77}
78
79impl FixReport {
80    /// Number of files whose content changed.
81    pub fn files_changed(&self) -> usize {
82        self.files.len()
83    }
84}
85
86/// The baseline hashes to skip, empty when no baseline is active.
87fn baseline_hash_set(baseline: Option<&Baseline>) -> HashSet<String> {
88    baseline
89        .map(|b| b.findings.iter().map(|e| e.hash.clone()).collect())
90        .unwrap_or_default()
91}
92
93/// Everything fixing one file needs besides its content.
94struct FileContext<'a> {
95    path: &'a Path,
96    lang: SupportLang,
97    rules: &'a [CompiledFixRule],
98    test_paths: &'a TestPaths,
99    env: FixEnv<'a>,
100}
101
102/// The matches of every rule over `source` that pass the scope filters shared
103/// with detection: the rule's `files` / `ignores` globs, `skip_test_code`
104/// (path heuristic and inline `#[cfg(test)]`), and, for a dedicated
105/// `autofix_rule`, containment in a detection match.
106fn candidates<'r>(source: &str, file: &FileContext<'r>) -> Vec<(&'r CompiledFixRule, Candidate)> {
107    let FileContext {
108        path,
109        lang,
110        rules,
111        test_paths,
112        env,
113    } = file;
114    let (path, lang) = (*path, *lang);
115    let ast = lang.ast_grep(source);
116    let cfg_test = (lang == SupportLang::Rust).then(|| CfgTestRanges::from_root(&ast));
117    let is_test_file = test_paths.is_test(path);
118
119    let mut found = Vec::new();
120    for rule in rules
121        .iter()
122        .filter(|r| r.lang == lang && r.applies_to(path))
123    {
124        let template = rule.config.fixer.first();
125        if matches!(rule.action, FixAction::Template) && template.is_none() {
126            continue;
127        }
128        let detected: Option<Vec<Range<usize>>> = rule.detect.as_ref().map(|detect| {
129            ast.root()
130                .find_all(&detect.matcher)
131                .map(|m| m.range())
132                .collect()
133        });
134        for node_match in ast.root().find_all(&rule.config.matcher) {
135            let range = node_match.range();
136            let start_line = node_match.start_pos().line() + 1;
137
138            let in_test_code = rule.skip_test_code
139                && (is_test_file
140                    || cfg_test
141                        .as_ref()
142                        .is_some_and(|ranges| ranges.contains_line(start_line)));
143            if in_test_code {
144                continue;
145            }
146            let outside_detection = detected.as_ref().is_some_and(|ranges| {
147                !ranges
148                    .iter()
149                    .any(|d| d.start <= range.start && range.end <= d.end)
150            });
151            if outside_detection {
152                continue;
153            }
154
155            let edits = match (rule.action, template) {
156                (FixAction::Builtin(kind), _) => fixers::fix(kind, &node_match, env),
157                (FixAction::Template, Some(fixer)) => {
158                    let edit = node_match.make_edit(&rule.config.matcher, fixer);
159                    Some(vec![RawEdit {
160                        position: edit.position,
161                        deleted_length: edit.deleted_length,
162                        inserted: edit.inserted_text,
163                    }])
164                }
165                (FixAction::Template, None) => None,
166            };
167            found.push((
168                rule,
169                Candidate {
170                    range,
171                    line: start_line,
172                    text: node_match.text().to_string(),
173                    edits,
174                },
175            ));
176        }
177    }
178    found
179}
180
181/// Resolve, once on the original content, which matches detection suppresses
182/// through an inline disable directive or the baseline. Each directive is
183/// checked per (rule, match), so a blanket directive protects every rule.
184fn protected_ranges(
185    original: &str,
186    file: &FileContext<'_>,
187    baseline_hashes: &HashSet<String>,
188    baseline_root: &Path,
189) -> Vec<Protected> {
190    let path = file.path;
191    candidates(original, file)
192        .into_iter()
193        .filter(|(rule, candidate)| {
194            if is_disabled(original, candidate.line, &rule.rule_id) {
195                return true;
196            }
197            if baseline_hashes.is_empty() {
198                return false;
199            }
200            let finding = position_finding(&rule.rule_id, path, candidate.line, &candidate.text);
201            let hash = finding_hash(&finding, baseline_root, Some(original));
202            baseline_hashes.contains(&hash)
203        })
204        .map(|(rule, candidate)| Protected {
205            rule_id: rule.rule_id.clone(),
206            start: candidate.range.start,
207            end: candidate.range.end,
208        })
209        .collect()
210}
211
212/// Collect the edit groups due for `source` in one pass: the rewrites of the
213/// in-scope matches that do not overlap a protected range of the same rule.
214fn collect_edits(
215    source: &str,
216    file: &FileContext<'_>,
217    protected: &[Protected],
218) -> Vec<Vec<RawEdit>> {
219    candidates(source, file)
220        .into_iter()
221        .filter(|(rule, candidate)| {
222            !protected.iter().any(|p| {
223                p.rule_id == rule.rule_id
224                    && candidate.range.start < p.end
225                    && p.start < candidate.range.end
226            })
227        })
228        .filter_map(|(_, candidate)| candidate.edits)
229        .collect()
230}
231
232/// A finding shaped only for baseline hashing: `finding_hash` reads the rule id,
233/// path, line (for context) and matched text; the rest is filler.
234fn position_finding(rule_id: &RuleId, path: &Path, line: usize, matched: &str) -> Finding {
235    Finding {
236        rule_id: rule_id.clone(),
237        severity: Severity::Warning,
238        category: Category::Correctness,
239        message: String::new(),
240        note: None,
241        fix: None,
242        file: path.to_path_buf(),
243        line,
244        column: 1,
245        end_line: line,
246        end_column: 1,
247        matched_text: matched.to_string(),
248        confidence: None,
249        escalated: false,
250    }
251}
252
253/// Rewrite one file's content to a fix point. Returns the new content and the
254/// number of fixes (edit groups) actually applied.
255fn fix_source(
256    original: &str,
257    file: &FileContext<'_>,
258    baseline_hashes: &HashSet<String>,
259    baseline_root: &Path,
260) -> (String, usize) {
261    let mut protected = protected_ranges(original, file, baseline_hashes, baseline_root);
262    let mut source = original.to_string();
263    let mut applied = 0;
264    for _ in 0..MAX_ITERATIONS {
265        let groups = collect_edits(&source, file, &protected);
266        let (selected, fixes) = select_non_overlapping(groups, source.len());
267        if selected.is_empty() {
268            break;
269        }
270        let (fixed, count) = apply_edits(&source, &selected);
271        if count == 0 {
272            break;
273        }
274        shift_protected(&mut protected, &selected);
275        applied += fixes;
276        source = fixed;
277    }
278    (source, applied)
279}
280
281/// Apply the autofix rewrites over `paths`: the `autofix_safe` ones, plus the
282/// `autofix_unsafe` ones when `unsafe_fixes` is on.
283///
284/// Only builtin rules and the external rules listed in `fix.allow_external`
285/// are applied. Nothing is written here: [`FileFix`] carries the before/after
286/// text so the caller can print a diff (`--dry-run`) or persist the change.
287/// `baseline` suppresses rewrites already recorded against `baseline_root`.
288pub fn fix_paths(
289    paths: &[PathBuf],
290    rules: &[Rule],
291    config: &Config,
292    baseline: Option<&Baseline>,
293    baseline_root: &Path,
294    unsafe_fixes: bool,
295) -> Result<FixReport, ScanError> {
296    let test_paths = TestPaths::new(&config.scan.test_paths)?;
297    let ignores = build_glob_set(&config.scan.ignores)?;
298    let compiled = compile_fix_rules(rules, &config.fix.allow_external, unsafe_fixes)?;
299    if compiled.is_empty() {
300        return Ok(FixReport::default());
301    }
302    let baseline_hashes = baseline_hash_set(baseline);
303    let reads_manifest = needs_strum(&compiled);
304    let mut strum_by_dir: HashMap<PathBuf, Option<&'static str>> = HashMap::new();
305
306    let mut report = FixReport::default();
307    for (path, lang) in walk_files(paths, &ignores) {
308        let Ok(original) = fs::read_to_string(&path) else {
309            continue;
310        };
311        let dir = path
312            .parent()
313            .unwrap_or_else(|| Path::new("."))
314            .to_path_buf();
315        let strum_crate = if reads_manifest {
316            *strum_by_dir
317                .entry(dir)
318                .or_insert_with_key(|dir| strum_derive_crate(dir))
319        } else {
320            None
321        };
322        let file = FileContext {
323            path: &path,
324            lang,
325            rules: &compiled,
326            test_paths: &test_paths,
327            env: FixEnv {
328                path: Some(&path),
329                strum_crate,
330            },
331        };
332        let (fixed, applied) = fix_source(&original, &file, &baseline_hashes, baseline_root);
333        if applied > 0 && fixed != original {
334            report.applied += applied;
335            report.files.push(FileFix {
336                path,
337                original,
338                fixed,
339            });
340        }
341    }
342    Ok(report)
343}
344
345/// Apply a single rule's autofix rewrite to an in-memory snippet, to a fix
346/// point. Used by `slopguard test` to validate `tests.should_fix`: no baseline,
347/// no test-path filtering, no `files` / `ignores` globs, no inline-disable
348/// directives are consulted. Nothing is written to disk, so the external-rule
349/// trust gate does not apply, and an `autofix_unsafe` rewrite is applied like
350/// a safe one. No manifest is read either: the strum derive macros are assumed
351/// reachable through `strum`, and a fixer that needs another file declines.
352/// Returns the snippet unchanged when the rule is not autofixable.
353pub fn fix_snippet(rule: &Rule, source: &str) -> Result<String, ScanError> {
354    let mut compiled = compile_fix_rules(from_ref(rule), &[rule.id.as_str().to_string()], true)?;
355    if compiled.is_empty() {
356        return Ok(source.to_string());
357    }
358    for fix_rule in &mut compiled {
359        fix_rule.files = None;
360        fix_rule.ignores = None;
361    }
362    let test_paths = TestPaths::new(&[])?;
363    let path = Path::new("snippet");
364    let file = FileContext {
365        path,
366        lang: compiled[0].lang,
367        rules: &compiled,
368        test_paths: &test_paths,
369        env: FixEnv {
370            path: None,
371            strum_crate: Some("strum"),
372        },
373    };
374    let (fixed, _applied) = fix_source(source, &file, &HashSet::new(), path);
375    Ok(fixed)
376}
377
378#[cfg(test)]
379mod tests;