1use 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
26const MAX_ITERATIONS: usize = 10;
29
30struct CompiledFixRule {
33 rule_id: RuleId,
34 lang: SupportLang,
35 skip_test_code: bool,
36 config: RuleConfig<SupportLang>,
37}
38
39struct RawEdit {
42 position: usize,
43 deleted_length: usize,
44 inserted: Vec<u8>,
45}
46
47pub struct FileFix {
49 pub path: PathBuf,
50 pub original: String,
51 pub fixed: String,
52}
53
54#[derive(Default)]
56pub struct FixReport {
57 pub files: Vec<FileFix>,
59 pub applied: usize,
61}
62
63impl FixReport {
64 pub fn files_changed(&self) -> usize {
66 self.files.len()
67 }
68}
69
70fn 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 let Some(rewrite) = rule.rewrite.as_deref() else {
77 continue;
78 };
79 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
114fn 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
121fn 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
135fn 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
151fn 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
205fn 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
226fn 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
261pub 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
307pub 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;