use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use ast_grep_config::RuleConfig;
use ast_grep_core::tree_sitter::LanguageExt;
use ast_grep_language::SupportLang;
use crate::baseline::{finding_hash, Baseline};
use crate::config::Config;
use crate::disable::is_disabled;
use crate::finding::Finding;
use crate::rule::{Category, Rule, RuleId, Severity};
use crate::scanner::{build_glob_set, compile_ast_grep_rule, walk_files, AstGrepRule, ScanError};
use crate::test_filter::{CfgTestRanges, TestPaths};
const MAX_ITERATIONS: usize = 10;
struct CompiledFixRule {
rule_id: RuleId,
lang: SupportLang,
skip_test_code: bool,
config: RuleConfig<SupportLang>,
}
struct RawEdit {
position: usize,
deleted_length: usize,
inserted: Vec<u8>,
}
pub struct FileFix {
pub path: PathBuf,
pub original: String,
pub fixed: String,
}
#[derive(Default)]
pub struct FixReport {
pub files: Vec<FileFix>,
pub applied: usize,
}
impl FixReport {
pub fn files_changed(&self) -> usize {
self.files.len()
}
}
fn compile_fix_rules(rules: &[Rule]) -> Result<Vec<CompiledFixRule>, ScanError> {
let mut compiled = Vec::new();
for rule in rules.iter().filter(|r| r.is_autofixable()) {
let Some(rewrite) = rule.rewrite.as_deref() else {
continue;
};
let uses_dedicated_matcher = !rule.autofix_rule.is_null();
let constraints =
(!uses_dedicated_matcher && !rule.constraints.is_null()).then_some(&rule.constraints);
for &lang in rule.language.ast_grep_langs() {
let ast_grep_rule = AstGrepRule {
id: rule.id.as_str(),
language: lang,
severity: &rule.severity,
message: &rule.message,
note: rule.note.as_deref(),
rule: rule.fix_matcher(),
constraints,
fix: Some(rewrite),
files: rule.files.as_deref(),
ignores: rule.ignores.as_deref(),
};
let config =
compile_ast_grep_rule(&ast_grep_rule).map_err(|source| ScanError::RuleCompile {
id: rule.id.clone(),
source,
})?;
compiled.push(CompiledFixRule {
rule_id: rule.id.clone(),
lang,
skip_test_code: rule.skip_test_code,
config,
});
}
}
Ok(compiled)
}
fn baseline_hash_set(baseline: Option<&Baseline>) -> HashSet<String> {
baseline
.map(|b| b.findings.iter().map(|e| e.hash.clone()).collect())
.unwrap_or_default()
}
fn apply_edits(source: &str, edits: &[RawEdit]) -> String {
let mut bytes = source.as_bytes().to_vec();
for edit in edits.iter().rev() {
let end = edit.position + edit.deleted_length;
if end > bytes.len() {
continue;
}
bytes.splice(edit.position..end, edit.inserted.iter().copied());
}
String::from_utf8(bytes).unwrap_or_else(|_| source.to_string())
}
fn select_non_overlapping(mut edits: Vec<RawEdit>) -> Vec<RawEdit> {
edits.sort_by_key(|e| e.position);
let mut kept: Vec<RawEdit> = Vec::new();
let mut last_end = 0usize;
for edit in edits {
if edit.position >= last_end {
last_end = edit.position + edit.deleted_length;
kept.push(edit);
}
}
kept
}
fn collect_edits(
source: &str,
path: &Path,
lang: SupportLang,
rules: &[CompiledFixRule],
test_paths: &TestPaths,
baseline_hashes: &HashSet<String>,
baseline_root: &Path,
) -> Vec<RawEdit> {
let ast = lang.ast_grep(source);
let cfg_test = (lang == SupportLang::Rust).then(|| CfgTestRanges::from_root(&ast));
let is_test_file = test_paths.is_test(path);
let mut edits = Vec::new();
for rule in rules.iter().filter(|r| r.lang == lang) {
let Some(fixer) = rule.config.fixer.first() else {
continue;
};
for node_match in ast.root().find_all(&rule.config.matcher) {
let start_line = node_match.start_pos().line() + 1;
let in_test_code = rule.skip_test_code
&& (is_test_file
|| cfg_test
.as_ref()
.is_some_and(|ranges| ranges.contains_line(start_line)));
if in_test_code {
continue;
}
if is_disabled(source, start_line, &rule.rule_id) {
continue;
}
if !baseline_hashes.is_empty() {
let finding = position_finding(&rule.rule_id, path, start_line, &node_match.text());
let hash = finding_hash(&finding, baseline_root, Some(source));
if baseline_hashes.contains(&hash) {
continue;
}
}
let edit = node_match.make_edit(&rule.config.matcher, fixer);
edits.push(RawEdit {
position: edit.position,
deleted_length: edit.deleted_length,
inserted: edit.inserted_text,
});
}
}
edits
}
fn position_finding(rule_id: &RuleId, path: &Path, line: usize, matched: &str) -> Finding {
Finding {
rule_id: rule_id.clone(),
severity: Severity::Warning,
category: Category::Correctness,
message: String::new(),
note: None,
fix: None,
file: path.to_path_buf(),
line,
column: 1,
end_line: line,
end_column: 1,
matched_text: matched.to_string(),
confidence: None,
escalated: false,
}
}
fn fix_source(
original: &str,
path: &Path,
lang: SupportLang,
rules: &[CompiledFixRule],
test_paths: &TestPaths,
baseline_hashes: &HashSet<String>,
baseline_root: &Path,
) -> (String, usize) {
let mut source = original.to_string();
let mut applied = 0;
for _ in 0..MAX_ITERATIONS {
let edits = collect_edits(
&source,
path,
lang,
rules,
test_paths,
baseline_hashes,
baseline_root,
);
if edits.is_empty() {
break;
}
let selected = select_non_overlapping(edits);
if selected.is_empty() {
break;
}
applied += selected.len();
source = apply_edits(&source, &selected);
}
(source, applied)
}
pub fn fix_paths(
paths: &[PathBuf],
rules: &[Rule],
config: &Config,
baseline: Option<&Baseline>,
baseline_root: &Path,
) -> Result<FixReport, ScanError> {
let test_paths = TestPaths::new(&config.scan.test_paths)?;
let ignores = build_glob_set(&config.scan.ignores)?;
let compiled = compile_fix_rules(rules)?;
if compiled.is_empty() {
return Ok(FixReport::default());
}
let baseline_hashes = baseline_hash_set(baseline);
let mut report = FixReport::default();
for (path, lang) in walk_files(paths, &ignores) {
let Ok(original) = fs::read_to_string(&path) else {
continue;
};
let (fixed, applied) = fix_source(
&original,
&path,
lang,
&compiled,
&test_paths,
&baseline_hashes,
baseline_root,
);
if applied > 0 && fixed != original {
report.applied += applied;
report.files.push(FileFix {
path,
original,
fixed,
});
}
}
Ok(report)
}
pub fn fix_snippet(rule: &Rule, source: &str) -> Result<String, ScanError> {
let compiled = compile_fix_rules(std::slice::from_ref(rule))?;
if compiled.is_empty() {
return Ok(source.to_string());
}
let lang = compiled[0].lang;
let test_paths = TestPaths::new(&[])?;
let baseline_hashes = HashSet::new();
let path = Path::new("snippet");
let (fixed, _applied) = fix_source(
source,
path,
lang,
&compiled,
&test_paths,
&baseline_hashes,
path,
);
Ok(fixed)
}
#[cfg(test)]
mod tests;