From d3f12e52cf370e10730e336ccd8efc26f1d1bfd3 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 11 Oct 2026 10:12:56 +0800 Subject: [PATCH] perf(migrate): reuse syntax trees between unchanged rules --- crates/vp_migration/src/ast_grep.rs | 74 +++++++++++++++++++++++++---- 1 file changed, 66 insertions(+), 8 deletions(-) diff --git a/crates/vp_migration/src/ast_grep.rs b/crates/vp_migration/src/ast_grep.rs index 8dc829d90e..d92aa1e2d0 100644 --- a/crates/vp_migration/src/ast_grep.rs +++ b/crates/vp_migration/src/ast_grep.rs @@ -1,5 +1,5 @@ use ast_grep_config::{GlobalRules, RuleConfig, from_yaml_string}; -use ast_grep_core::replacer::Replacer; +use ast_grep_core::{AstGrep, replacer::Replacer, tree_sitter::StrDoc}; use ast_grep_language::{LanguageExt, SupportLang}; use vp_error::Error; @@ -47,36 +47,94 @@ pub fn load_rules(yaml: &str) -> Result>, Error> { /// The transformed content (always returns a new string, even if unchanged) pub fn apply_loaded_rules(content: &str, rules: &[RuleConfig]) -> String { let mut current = content.to_string(); + let mut parsed: Option>> = None; for rule in rules { - // Parse current content with the rule's language - let grep = rule.language.ast_grep(¤t); - let root = grep.root(); - - let matcher = &rule.matcher; - // Get the fixer if available (rules without fix are pure lint, skip them) let fixers = match rule.get_fixer() { Ok(f) if !f.is_empty() => f, _ => continue, }; + // Rules usually leave a file unchanged. Reuse its tree until an edit or + // a different rule language requires parsing the current text again. + if parsed.as_ref().is_none_or(|grep| *grep.lang() != rule.language) { + parsed = Some(rule.language.ast_grep(¤t)); + } + let root = parsed.as_ref().unwrap().root(); + // Collect all matches and their replacements let mut replacements = Vec::new(); - for node in root.find_all(matcher) { + for node in root.find_all(&rule.matcher) { let range = node.range(); let replacement_bytes = fixers[0].generate_replacement(&node); let replacement_str = String::from_utf8_lossy(&replacement_bytes).to_string(); replacements.push((range.start, range.end, replacement_str)); } + if replacements.is_empty() { + continue; + } + // Replace from back to front to maintain correct positions replacements.sort_by_key(|(start, _, _)| std::cmp::Reverse(*start)); for (start, end, replacement) in replacements { current.replace_range(start..end, &replacement); } + parsed = None; } current } + +#[cfg(test)] +mod tests { + use super::apply_rules; + + #[test] + fn later_rules_see_earlier_edits_and_updated_offsets() { + let rules = r#" +id: unchanged +language: TypeScript +rule: + pattern: missing() +fix: present() +--- +id: rename +language: TypeScript +rule: + pattern: before() +fix: intermediateName() +--- +id: rename-again +language: TypeScript +rule: + pattern: intermediateName() +fix: after() +"#; + let (result, updated) = apply_rules("before(); before();", rules).unwrap(); + assert_eq!(result, "after(); after();"); + assert!(updated); + } + + #[test] + fn unchanged_content_is_reparsed_when_the_rule_language_changes() { + let rules = r#" +id: typescript +language: TypeScript +rule: + pattern: missing() +fix: present() +--- +id: bash +language: Bash +rule: + pattern: echo $TEXT +fix: printf $TEXT +"#; + let (result, updated) = apply_rules("echo hello", rules).unwrap(); + assert_eq!(result, "printf hello"); + assert!(updated); + } +}