1use 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
47const MAX_ITERATIONS: usize = 10;
50
51struct Candidate {
57 range: Range<usize>,
58 line: usize,
59 text: String,
60 edits: Option<Vec<RawEdit>>,
61}
62
63pub struct FileFix {
65 pub path: PathBuf,
66 pub original: String,
67 pub fixed: String,
68}
69
70#[derive(Default)]
72pub struct FixReport {
73 pub files: Vec<FileFix>,
75 pub applied: usize,
77}
78
79impl FixReport {
80 pub fn files_changed(&self) -> usize {
82 self.files.len()
83 }
84}
85
86fn 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
93struct FileContext<'a> {
95 path: &'a Path,
96 lang: SupportLang,
97 rules: &'a [CompiledFixRule],
98 test_paths: &'a TestPaths,
99 env: FixEnv<'a>,
100}
101
102fn 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
181fn 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
212fn 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
232fn 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
253fn 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
281pub 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
345pub 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;