Skip to content

Commit b1fc57c

Browse files
committed
perf(migrate): speed up rewriteAllImports ~16x with cached rules, pre-filter, and rayon (#1172)
- Cache ast-grep YAML rule parsing with LazyLock (eliminating 921 redundant parses) - Add string pre-filter to skip files without vite/vitest/tsdown imports - Parallelize file processing with rayon Benchmark on 307-file project: 20.7s → 1.3s (~16x faster). Closes #1156
1 parent 37fd30b commit b1fc57c

3 files changed

Lines changed: 106 additions & 50 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/vite_migration/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ ast-grep-core = { workspace = true }
1212
ast-grep-language = { workspace = true }
1313
brush-parser = { workspace = true }
1414
ignore = { workspace = true }
15+
rayon = { workspace = true }
1516
regex = { workspace = true }
1617
serde_json = { workspace = true, features = ["preserve_order"] }
1718
vite_error = { workspace = true }

crates/vite_migration/src/import_rewriter.rs

Lines changed: 104 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ use std::{
44
sync::LazyLock,
55
};
66

7+
use ast_grep_config::RuleConfig;
8+
use ast_grep_language::SupportLang;
9+
use rayon::prelude::*;
710
use regex::Regex;
811
use vite_error::Error;
912

@@ -276,6 +279,18 @@ transform:
276279
fix: $NEW_IMPORT
277280
"#;
278281

282+
static PARSED_VITE_RULES: LazyLock<Vec<RuleConfig<SupportLang>>> = LazyLock::new(|| {
283+
ast_grep::load_rules(REWRITE_VITE_RULES).expect("failed to parse vite rewrite rules")
284+
});
285+
286+
static PARSED_VITEST_RULES: LazyLock<Vec<RuleConfig<SupportLang>>> = LazyLock::new(|| {
287+
ast_grep::load_rules(REWRITE_VITEST_RULES).expect("failed to parse vitest rewrite rules")
288+
});
289+
290+
static PARSED_TSDOWN_RULES: LazyLock<Vec<RuleConfig<SupportLang>>> = LazyLock::new(|| {
291+
ast_grep::load_rules(REWRITE_TSDOWN_RULES).expect("failed to parse tsdown rewrite rules")
292+
});
293+
279294
// Regex patterns for rewriting `/// <reference types="..." />` directives.
280295
// These cannot be handled by ast-grep because triple-slash references are parsed as comments.
281296

@@ -502,7 +517,7 @@ fn rewrite_reference_types(content: &mut String, skip_packages: &SkipPackages) -
502517
}
503518

504519
/// Packages to skip rewriting based on peerDependencies or dependencies
505-
#[derive(Debug, Clone, Default)]
520+
#[derive(Debug, Clone, Copy, Default)]
506521
struct SkipPackages {
507522
/// Skip rewriting vite imports (vite is in peerDependencies or dependencies)
508523
skip_vite: bool,
@@ -593,6 +608,12 @@ pub struct BatchRewriteResult {
593608
pub errors: Vec<(PathBuf, String)>,
594609
}
595610

611+
enum FileResult {
612+
Modified,
613+
Unchanged,
614+
Error(String),
615+
}
616+
596617
/// Rewrite imports in all TypeScript/JavaScript files under a directory
597618
///
598619
/// This function finds all TypeScript and JavaScript files in the specified directory
@@ -625,53 +646,65 @@ pub struct BatchRewriteResult {
625646
pub fn rewrite_imports_in_directory(root: &Path) -> Result<BatchRewriteResult, Error> {
626647
let walk_result = file_walker::find_ts_files(root)?;
627648

628-
let mut result = BatchRewriteResult {
629-
modified_files: Vec::new(),
630-
unchanged_files: Vec::new(),
631-
errors: Vec::new(),
632-
};
633-
634-
// Cache package.json lookups to avoid re-reading the same file
649+
// Pre-compute skip_packages for each file (requires mutable cache, done sequentially)
635650
let mut skip_packages_cache: HashMap<PathBuf, SkipPackages> = HashMap::new();
651+
let files_with_skip: Vec<(PathBuf, SkipPackages)> = walk_result
652+
.files
653+
.into_iter()
654+
.map(|file_path| {
655+
let skip_packages =
656+
if let Some(package_json_path) = find_nearest_package_json(&file_path, root) {
657+
*skip_packages_cache
658+
.entry(package_json_path.clone())
659+
.or_insert_with(|| get_skip_packages_from_package_json(&package_json_path))
660+
} else {
661+
SkipPackages::default()
662+
};
663+
(file_path, skip_packages)
664+
})
665+
.collect();
666+
667+
// Process files in parallel using rayon
668+
let results: Vec<(PathBuf, FileResult)> = files_with_skip
669+
.into_par_iter()
670+
.map(|(file_path, skip_packages)| {
671+
if skip_packages.all_skipped() {
672+
return (file_path, FileResult::Unchanged);
673+
}
636674

637-
for file_path in walk_result.files {
638-
// Find the nearest package.json for this file
639-
let skip_packages =
640-
if let Some(package_json_path) = find_nearest_package_json(&file_path, root) {
641-
skip_packages_cache
642-
.entry(package_json_path.clone())
643-
.or_insert_with(|| get_skip_packages_from_package_json(&package_json_path))
644-
.clone()
645-
} else {
646-
SkipPackages::default()
647-
};
648-
649-
// If all packages are in peerDeps for this file's package, skip it
650-
if skip_packages.all_skipped() {
651-
result.unchanged_files.push(file_path);
652-
continue;
653-
}
654-
655-
match rewrite_import(&file_path, &skip_packages) {
656-
Ok(rewrite_result) => {
657-
if rewrite_result.updated {
658-
// Write the modified content back
659-
if let Err(e) = std::fs::write(&file_path, &rewrite_result.content) {
660-
result.errors.push((file_path, e.to_string()));
675+
match rewrite_import(&file_path, &skip_packages) {
676+
Ok(rewrite_result) => {
677+
if rewrite_result.updated {
678+
if let Err(e) = std::fs::write(&file_path, &rewrite_result.content) {
679+
(file_path, FileResult::Error(e.to_string()))
680+
} else {
681+
(file_path, FileResult::Modified)
682+
}
661683
} else {
662-
result.modified_files.push(file_path);
684+
(file_path, FileResult::Unchanged)
663685
}
664-
} else {
665-
result.unchanged_files.push(file_path);
666686
}
687+
Err(e) => (file_path, FileResult::Error(e.to_string())),
667688
}
668-
Err(e) => {
669-
result.errors.push((file_path, e.to_string()));
670-
}
689+
})
690+
.collect();
691+
692+
// Collect results
693+
let mut batch_result = BatchRewriteResult {
694+
modified_files: Vec::new(),
695+
unchanged_files: Vec::new(),
696+
errors: Vec::new(),
697+
};
698+
699+
for (file_path, file_result) in results {
700+
match file_result {
701+
FileResult::Modified => batch_result.modified_files.push(file_path),
702+
FileResult::Unchanged => batch_result.unchanged_files.push(file_path),
703+
FileResult::Error(msg) => batch_result.errors.push((file_path, msg)),
671704
}
672705
}
673706

674-
Ok(result)
707+
Ok(batch_result)
675708
}
676709

677710
/// Rewrite imports in a TypeScript/JavaScript file from vite/vitest to vite-plus
@@ -698,6 +731,24 @@ fn rewrite_import(file_path: &Path, skip_packages: &SkipPackages) -> Result<Rewr
698731
rewrite_import_content(&content, skip_packages)
699732
}
700733

734+
/// Fast pre-filter to skip expensive AST parsing for files with no relevant imports.
735+
fn content_may_need_rewriting(content: &str, skip_packages: &SkipPackages) -> bool {
736+
// "vite" also matches "vitest" as a substring, covering both packages
737+
if !skip_packages.skip_vite || !skip_packages.skip_vitest {
738+
if content.contains("vite") {
739+
return true;
740+
}
741+
}
742+
// When only skip_vite is set, we still need to catch @vitest/ scoped packages
743+
if !skip_packages.skip_vitest && content.contains("@vitest/") {
744+
return true;
745+
}
746+
if !skip_packages.skip_tsdown && content.contains("tsdown") {
747+
return true;
748+
}
749+
false
750+
}
751+
701752
/// Rewrite imports in content from vite/vitest to vite-plus
702753
///
703754
/// This is the internal function that performs the actual rewrite using ast-grep.
@@ -706,33 +757,36 @@ fn rewrite_import_content(
706757
content: &str,
707758
skip_packages: &SkipPackages,
708759
) -> Result<RewriteResult, Error> {
760+
// Fast path: skip AST parsing if the file doesn't contain any target strings
761+
if !content_may_need_rewriting(content, skip_packages) {
762+
return Ok(RewriteResult { content: content.to_string(), updated: false });
763+
}
764+
709765
let mut new_content = content.to_string();
710766
let mut updated = false;
711767

712-
// Apply vite rules if not skipped
768+
// Apply vite rules if not skipped (using pre-parsed rules)
713769
if !skip_packages.skip_vite {
714-
let (vite_content, vite_updated) = ast_grep::apply_rules(&new_content, REWRITE_VITE_RULES)?;
715-
if vite_updated {
770+
let vite_content = ast_grep::apply_loaded_rules(&new_content, &PARSED_VITE_RULES);
771+
if vite_content != new_content {
716772
new_content = vite_content;
717773
updated = true;
718774
}
719775
}
720776

721-
// Apply vitest rules if not skipped
777+
// Apply vitest rules if not skipped (using pre-parsed rules)
722778
if !skip_packages.skip_vitest {
723-
let (vitest_content, vitest_updated) =
724-
ast_grep::apply_rules(&new_content, REWRITE_VITEST_RULES)?;
725-
if vitest_updated {
779+
let vitest_content = ast_grep::apply_loaded_rules(&new_content, &PARSED_VITEST_RULES);
780+
if vitest_content != new_content {
726781
new_content = vitest_content;
727782
updated = true;
728783
}
729784
}
730785

731-
// Apply tsdown rules if not skipped
786+
// Apply tsdown rules if not skipped (using pre-parsed rules)
732787
if !skip_packages.skip_tsdown {
733-
let (tsdown_content, tsdown_updated) =
734-
ast_grep::apply_rules(&new_content, REWRITE_TSDOWN_RULES)?;
735-
if tsdown_updated {
788+
let tsdown_content = ast_grep::apply_loaded_rules(&new_content, &PARSED_TSDOWN_RULES);
789+
if tsdown_content != new_content {
736790
new_content = tsdown_content;
737791
updated = true;
738792
}

0 commit comments

Comments
 (0)