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.
7//!
8//! A rewrite stays within the scope of detection: only builtin rules, or
9//! external rules the user listed in `fix.allow_external`, are applied; a rule's
10//! `files` / `ignores` globs are honored; a dedicated `autofix_rule` only
11//! rewrites inside a match of the detection `rule` (with its `constraints`).
12//! Findings that detection would suppress -- inside test code, disabled inline,
13//! or recorded in the baseline -- are never rewritten. Inline disables and
14//! baseline membership are resolved once on the original content and tracked
15//! through every edit, so a rewrite that shifts lines cannot unprotect them.
16
17use std::collections::HashSet;
18use std::fs;
19use std::ops::Range;
20use std::path::{Path, PathBuf};
21use std::slice::from_ref;
22
23use ast_grep_config::RuleConfig;
24use ast_grep_core::tree_sitter::LanguageExt;
25use ast_grep_language::SupportLang;
26use globset::GlobSet;
27use serde_yaml::Value;
28
29use crate::baseline::{finding_hash, Baseline};
30use crate::config::Config;
31use crate::disable::is_disabled;
32use crate::finding::Finding;
33use crate::rule::{Category, Rule, RuleId, Severity};
34use crate::scanner::{build_glob_set, compile_ast_grep_rule, walk_files, AstGrepRule, ScanError};
35use crate::source::RuleOrigin;
36use crate::test_filter::{CfgTestRanges, TestPaths};
37
38/// Upper bound on the re-scan/re-apply cycles per file. Fixing overlapping or
39/// nested matches can expose new ones; the loop stops at a fix point or here.
40const MAX_ITERATIONS: usize = 10;
41
42/// One autofixable rule compiled for a specific ast-grep language, keeping a way
43/// back to the slopguard rule identity for filtering.
44struct CompiledFixRule {
45    rule_id: RuleId,
46    lang: SupportLang,
47    skip_test_code: bool,
48    config: RuleConfig<SupportLang>,
49    /// The detection `rule` (with its `constraints`) when a dedicated
50    /// `autofix_rule` drives `config`: a fix match is kept only inside one of
51    /// its matches. `None` when `config` already is the detection matcher.
52    detect: Option<RuleConfig<SupportLang>>,
53    files: Option<GlobSet>,
54    ignores: Option<GlobSet>,
55}
56
57impl CompiledFixRule {
58    /// Whether the rule's `files` / `ignores` globs select `path`, with the
59    /// same semantics as the metric engine.
60    fn applies_to(&self, path: &Path) -> bool {
61        self.files.as_ref().is_none_or(|g| g.is_match(path))
62            && !self.ignores.as_ref().is_some_and(|g| g.is_match(path))
63    }
64}
65
66/// A single text replacement, decoupled from ast-grep's borrowed `Edit` so it
67/// outlives the parsed tree.
68struct RawEdit {
69    position: usize,
70    deleted_length: usize,
71    inserted: Vec<u8>,
72}
73
74/// A match `--fix` must leave alone (inline disable or baseline), as a byte
75/// range of the current buffer for one rule.
76#[derive(Debug, Clone, PartialEq, Eq)]
77struct Protected {
78    rule_id: RuleId,
79    start: usize,
80    end: usize,
81}
82
83/// A match eligible for rewriting in one pass, with what both the protection
84/// pre-pass and the edit pass need from it.
85struct Candidate {
86    range: Range<usize>,
87    line: usize,
88    text: String,
89    edit: RawEdit,
90}
91
92/// The rewrite of one file: kept only when the content actually changed.
93pub struct FileFix {
94    pub path: PathBuf,
95    pub original: String,
96    pub fixed: String,
97}
98
99/// Outcome of a `--fix` run over one or more paths.
100#[derive(Default)]
101pub struct FixReport {
102    /// Files whose content changed, with their before/after text.
103    pub files: Vec<FileFix>,
104    /// Total number of edits applied across every file.
105    pub applied: usize,
106}
107
108impl FixReport {
109    /// Number of files whose content changed.
110    pub fn files_changed(&self) -> usize {
111        self.files.len()
112    }
113}
114
115/// Whether `--fix` may apply `rule`: it is autofixable, and either builtin or
116/// an external rule the user explicitly allowed in `fix.allow_external`.
117fn is_trusted_for_fix(rule: &Rule, allow_external: &[String]) -> bool {
118    rule.is_autofixable()
119        && (rule.origin == RuleOrigin::Builtin
120            || allow_external.iter().any(|id| id == rule.id.as_str()))
121}
122
123/// Compile one ast-grep matcher of `rule` for `lang`: the fix matcher (with the
124/// `rewrite` as `fix`) or the detection matcher bounding it (`fix: None`).
125fn compile_matcher(
126    rule: &Rule,
127    lang: SupportLang,
128    matcher: &Value,
129    constraints: Option<&Value>,
130    fix: Option<&str>,
131) -> Result<RuleConfig<SupportLang>, ScanError> {
132    let ast_grep_rule = AstGrepRule {
133        id: rule.id.as_str(),
134        language: lang,
135        severity: &rule.severity,
136        message: &rule.message,
137        note: rule.note.as_deref(),
138        rule: matcher,
139        constraints,
140        fix,
141        files: rule.files.as_deref(),
142        ignores: rule.ignores.as_deref(),
143    };
144    compile_ast_grep_rule(&ast_grep_rule).map_err(|source| ScanError::RuleCompile {
145        id: rule.id.clone(),
146        source,
147    })
148}
149
150/// Compile every autofixable rule (`autofix_safe` + `rewrite`) trusted for
151/// `--fix` with its rewrite as the ast-grep fix template, once per ast-grep
152/// language it targets.
153fn compile_fix_rules(
154    rules: &[Rule],
155    allow_external: &[String],
156) -> Result<Vec<CompiledFixRule>, ScanError> {
157    let mut compiled = Vec::new();
158    for rule in rules
159        .iter()
160        .filter(|r| is_trusted_for_fix(r, allow_external))
161    {
162        // `is_autofixable` guarantees `rewrite` is present.
163        let Some(rewrite) = rule.rewrite.as_deref() else {
164            continue;
165        };
166        // A dedicated `autofix_rule` is self-contained: its metavariables need
167        // not match those of the detection `rule`, so detection `constraints`
168        // (which reference the detection rule's metavariables) do not apply to
169        // it. They apply to the separate detection matcher instead, which
170        // bounds where the dedicated matcher may rewrite.
171        let uses_dedicated_matcher = !rule.autofix_rule.is_null();
172        let detection_constraints = (!rule.constraints.is_null()).then_some(&rule.constraints);
173        let constraints = detection_constraints.filter(|_| !uses_dedicated_matcher);
174        let files = rule.files.as_deref().map(build_glob_set).transpose()?;
175        let ignores = rule.ignores.as_deref().map(build_glob_set).transpose()?;
176        for &lang in rule.language.ast_grep_langs() {
177            let config =
178                compile_matcher(rule, lang, rule.fix_matcher(), constraints, Some(rewrite))?;
179            let detect = uses_dedicated_matcher
180                .then(|| compile_matcher(rule, lang, &rule.rule, detection_constraints, None))
181                .transpose()?;
182            compiled.push(CompiledFixRule {
183                rule_id: rule.id.clone(),
184                lang,
185                skip_test_code: rule.skip_test_code,
186                config,
187                detect,
188                files: files.clone(),
189                ignores: ignores.clone(),
190            });
191        }
192    }
193    Ok(compiled)
194}
195
196/// The baseline hashes to skip, empty when no baseline is active.
197fn baseline_hash_set(baseline: Option<&Baseline>) -> HashSet<String> {
198    baseline
199        .map(|b| b.findings.iter().map(|e| e.hash.clone()).collect())
200        .unwrap_or_default()
201}
202
203/// Apply non-overlapping edits (sorted ascending) to `source`, right to left so
204/// earlier positions stay valid. Returns the new content and the number of
205/// edits actually spliced: an out-of-bounds edit is skipped and not counted,
206/// and an invalid UTF-8 result yields the original content with 0 applied.
207fn apply_edits(source: &str, edits: &[RawEdit]) -> (String, usize) {
208    let mut bytes = source.as_bytes().to_vec();
209    let mut applied = 0;
210    for edit in edits.iter().rev() {
211        let end = edit.position + edit.deleted_length;
212        if end > bytes.len() {
213            continue;
214        }
215        bytes.splice(edit.position..end, edit.inserted.iter().copied());
216        applied += 1;
217    }
218    match String::from_utf8(bytes) {
219        Ok(fixed) => (fixed, applied),
220        Err(_) => (source.to_string(), 0),
221    }
222}
223
224/// Greedily keep the edits that do not overlap a previously kept one. Input must
225/// be sorted by position. An edit starting before the running end (a nested or
226/// duplicate match) is dropped and left for the next fix-point iteration.
227fn select_non_overlapping(mut edits: Vec<RawEdit>) -> Vec<RawEdit> {
228    edits.sort_by_key(|e| e.position);
229    let mut kept: Vec<RawEdit> = Vec::new();
230    let mut last_end = 0usize;
231    for edit in edits {
232        if edit.position >= last_end {
233            last_end = edit.position + edit.deleted_length;
234            kept.push(edit);
235        }
236    }
237    kept
238}
239
240/// Move the protected ranges through one pass of applied edits (sorted
241/// ascending, non-overlapping, positions in the pre-pass buffer). A range after
242/// an edit shifts by the length delta; a range an edit touches is widened to
243/// cover the replacement, so it keeps protecting the text it held.
244fn shift_protected(protected: &mut [Protected], edits: &[RawEdit]) {
245    // Right to left: an edit never moves the positions before it, so each
246    // edit's coordinates stay valid while the later ones are folded in.
247    for edit in edits.iter().rev() {
248        let edit_end = edit.position + edit.deleted_length;
249        let inserted = edit.inserted.len();
250        for range in protected.iter_mut() {
251            if edit_end <= range.start {
252                // `range.start >= edit_end >= deleted_length`: no underflow.
253                range.start = range.start - edit.deleted_length + inserted;
254                range.end = range.end - edit.deleted_length + inserted;
255            } else if edit.position < range.end {
256                // `max(end, edit_end) >= edit_end >= deleted_length`.
257                range.end = range.end.max(edit_end) - edit.deleted_length + inserted;
258                range.start = range.start.min(edit.position);
259            }
260        }
261    }
262}
263
264/// The matches of every rule over `source` that pass the scope filters shared
265/// with detection: the rule's `files` / `ignores` globs, `skip_test_code`
266/// (path heuristic and inline `#[cfg(test)]`), and, for a dedicated
267/// `autofix_rule`, containment in a detection match.
268fn candidates<'r>(
269    source: &str,
270    path: &Path,
271    lang: SupportLang,
272    rules: &'r [CompiledFixRule],
273    test_paths: &TestPaths,
274) -> Vec<(&'r CompiledFixRule, Candidate)> {
275    let ast = lang.ast_grep(source);
276    let cfg_test = (lang == SupportLang::Rust).then(|| CfgTestRanges::from_root(&ast));
277    let is_test_file = test_paths.is_test(path);
278
279    let mut found = Vec::new();
280    for rule in rules
281        .iter()
282        .filter(|r| r.lang == lang && r.applies_to(path))
283    {
284        let Some(fixer) = rule.config.fixer.first() else {
285            continue;
286        };
287        let detected: Option<Vec<Range<usize>>> = rule.detect.as_ref().map(|detect| {
288            ast.root()
289                .find_all(&detect.matcher)
290                .map(|m| m.range())
291                .collect()
292        });
293        for node_match in ast.root().find_all(&rule.config.matcher) {
294            let range = node_match.range();
295            let start_line = node_match.start_pos().line() + 1;
296
297            let in_test_code = rule.skip_test_code
298                && (is_test_file
299                    || cfg_test
300                        .as_ref()
301                        .is_some_and(|ranges| ranges.contains_line(start_line)));
302            if in_test_code {
303                continue;
304            }
305            let outside_detection = detected.as_ref().is_some_and(|ranges| {
306                !ranges
307                    .iter()
308                    .any(|d| d.start <= range.start && range.end <= d.end)
309            });
310            if outside_detection {
311                continue;
312            }
313
314            let edit = node_match.make_edit(&rule.config.matcher, fixer);
315            found.push((
316                rule,
317                Candidate {
318                    range,
319                    line: start_line,
320                    text: node_match.text().to_string(),
321                    edit: RawEdit {
322                        position: edit.position,
323                        deleted_length: edit.deleted_length,
324                        inserted: edit.inserted_text,
325                    },
326                },
327            ));
328        }
329    }
330    found
331}
332
333/// Resolve, once on the original content, which matches detection suppresses
334/// through an inline disable directive or the baseline. Each directive is
335/// checked per (rule, match), so a blanket directive protects every rule.
336fn protected_ranges(
337    original: &str,
338    path: &Path,
339    lang: SupportLang,
340    rules: &[CompiledFixRule],
341    test_paths: &TestPaths,
342    baseline_hashes: &HashSet<String>,
343    baseline_root: &Path,
344) -> Vec<Protected> {
345    candidates(original, path, lang, rules, test_paths)
346        .into_iter()
347        .filter(|(rule, candidate)| {
348            if is_disabled(original, candidate.line, &rule.rule_id) {
349                return true;
350            }
351            if baseline_hashes.is_empty() {
352                return false;
353            }
354            let finding = position_finding(&rule.rule_id, path, candidate.line, &candidate.text);
355            let hash = finding_hash(&finding, baseline_root, Some(original));
356            baseline_hashes.contains(&hash)
357        })
358        .map(|(rule, candidate)| Protected {
359            rule_id: rule.rule_id.clone(),
360            start: candidate.range.start,
361            end: candidate.range.end,
362        })
363        .collect()
364}
365
366/// Collect the edits due for `source` in one pass: the in-scope matches that
367/// do not overlap a protected range of the same rule.
368fn collect_edits(
369    source: &str,
370    path: &Path,
371    lang: SupportLang,
372    rules: &[CompiledFixRule],
373    test_paths: &TestPaths,
374    protected: &[Protected],
375) -> Vec<RawEdit> {
376    candidates(source, path, lang, rules, test_paths)
377        .into_iter()
378        .filter(|(rule, candidate)| {
379            !protected.iter().any(|p| {
380                p.rule_id == rule.rule_id
381                    && candidate.range.start < p.end
382                    && p.start < candidate.range.end
383            })
384        })
385        .map(|(_, candidate)| candidate.edit)
386        .collect()
387}
388
389/// A finding shaped only for baseline hashing: `finding_hash` reads the rule id,
390/// path, line (for context) and matched text; the rest is filler.
391fn position_finding(rule_id: &RuleId, path: &Path, line: usize, matched: &str) -> Finding {
392    Finding {
393        rule_id: rule_id.clone(),
394        severity: Severity::Warning,
395        category: Category::Correctness,
396        message: String::new(),
397        note: None,
398        fix: None,
399        file: path.to_path_buf(),
400        line,
401        column: 1,
402        end_line: line,
403        end_column: 1,
404        matched_text: matched.to_string(),
405        confidence: None,
406        escalated: false,
407    }
408}
409
410/// Rewrite one file's content to a fix point. Returns the new content and the
411/// number of edits actually applied.
412fn fix_source(
413    original: &str,
414    path: &Path,
415    lang: SupportLang,
416    rules: &[CompiledFixRule],
417    test_paths: &TestPaths,
418    baseline_hashes: &HashSet<String>,
419    baseline_root: &Path,
420) -> (String, usize) {
421    let mut protected = protected_ranges(
422        original,
423        path,
424        lang,
425        rules,
426        test_paths,
427        baseline_hashes,
428        baseline_root,
429    );
430    let mut source = original.to_string();
431    let mut applied = 0;
432    for _ in 0..MAX_ITERATIONS {
433        let edits = collect_edits(&source, path, lang, rules, test_paths, &protected);
434        let selected = select_non_overlapping(edits);
435        if selected.is_empty() {
436            break;
437        }
438        let (fixed, count) = apply_edits(&source, &selected);
439        if count == 0 {
440            break;
441        }
442        shift_protected(&mut protected, &selected);
443        applied += count;
444        source = fixed;
445    }
446    (source, applied)
447}
448
449/// Apply the autofix-safe rewrites over `paths`.
450///
451/// Only builtin rules and the external rules listed in `fix.allow_external`
452/// are applied. Nothing is written here: [`FileFix`] carries the before/after
453/// text so the caller can print a diff (`--dry-run`) or persist the change.
454/// `baseline` suppresses rewrites already recorded against `baseline_root`.
455pub fn fix_paths(
456    paths: &[PathBuf],
457    rules: &[Rule],
458    config: &Config,
459    baseline: Option<&Baseline>,
460    baseline_root: &Path,
461) -> Result<FixReport, ScanError> {
462    let test_paths = TestPaths::new(&config.scan.test_paths)?;
463    let ignores = build_glob_set(&config.scan.ignores)?;
464    let compiled = compile_fix_rules(rules, &config.fix.allow_external)?;
465    if compiled.is_empty() {
466        return Ok(FixReport::default());
467    }
468    let baseline_hashes = baseline_hash_set(baseline);
469
470    let mut report = FixReport::default();
471    for (path, lang) in walk_files(paths, &ignores) {
472        let Ok(original) = fs::read_to_string(&path) else {
473            continue;
474        };
475        let (fixed, applied) = fix_source(
476            &original,
477            &path,
478            lang,
479            &compiled,
480            &test_paths,
481            &baseline_hashes,
482            baseline_root,
483        );
484        if applied > 0 && fixed != original {
485            report.applied += applied;
486            report.files.push(FileFix {
487                path,
488                original,
489                fixed,
490            });
491        }
492    }
493    Ok(report)
494}
495
496/// Apply a single rule's autofix rewrite to an in-memory snippet, to a fix
497/// point. Used by `slopguard test` to validate `tests.should_fix`: no baseline,
498/// no test-path filtering, no `files` / `ignores` globs, no inline-disable
499/// directives are consulted. Nothing is written to disk, so the external-rule
500/// trust gate does not apply. Returns the snippet unchanged when the rule is
501/// not autofixable.
502pub fn fix_snippet(rule: &Rule, source: &str) -> Result<String, ScanError> {
503    let mut compiled = compile_fix_rules(from_ref(rule), &[rule.id.as_str().to_string()])?;
504    if compiled.is_empty() {
505        return Ok(source.to_string());
506    }
507    for fix_rule in &mut compiled {
508        fix_rule.files = None;
509        fix_rule.ignores = None;
510    }
511    let lang = compiled[0].lang;
512    let test_paths = TestPaths::new(&[])?;
513    let baseline_hashes = HashSet::new();
514    let path = Path::new("snippet");
515    let (fixed, _applied) = fix_source(
516        source,
517        path,
518        lang,
519        &compiled,
520        &test_paths,
521        &baseline_hashes,
522        path,
523    );
524    Ok(fixed)
525}
526
527#[cfg(test)]
528mod tests;