1use 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
38const MAX_ITERATIONS: usize = 10;
41
42struct CompiledFixRule {
45 rule_id: RuleId,
46 lang: SupportLang,
47 skip_test_code: bool,
48 config: RuleConfig<SupportLang>,
49 detect: Option<RuleConfig<SupportLang>>,
53 files: Option<GlobSet>,
54 ignores: Option<GlobSet>,
55}
56
57impl CompiledFixRule {
58 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
66struct RawEdit {
69 position: usize,
70 deleted_length: usize,
71 inserted: Vec<u8>,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
77struct Protected {
78 rule_id: RuleId,
79 start: usize,
80 end: usize,
81}
82
83struct Candidate {
86 range: Range<usize>,
87 line: usize,
88 text: String,
89 edit: RawEdit,
90}
91
92pub struct FileFix {
94 pub path: PathBuf,
95 pub original: String,
96 pub fixed: String,
97}
98
99#[derive(Default)]
101pub struct FixReport {
102 pub files: Vec<FileFix>,
104 pub applied: usize,
106}
107
108impl FixReport {
109 pub fn files_changed(&self) -> usize {
111 self.files.len()
112 }
113}
114
115fn 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
123fn 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
150fn 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 let Some(rewrite) = rule.rewrite.as_deref() else {
164 continue;
165 };
166 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
196fn 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
203fn 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
224fn 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
240fn shift_protected(protected: &mut [Protected], edits: &[RawEdit]) {
245 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 = range.start - edit.deleted_length + inserted;
254 range.end = range.end - edit.deleted_length + inserted;
255 } else if edit.position < range.end {
256 range.end = range.end.max(edit_end) - edit.deleted_length + inserted;
258 range.start = range.start.min(edit.position);
259 }
260 }
261 }
262}
263
264fn 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
333fn 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
366fn 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
389fn 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
410fn 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
449pub 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
496pub 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;