use super::execute::{TxState, read_and_probe, read_file_content};
use crate::plan::Operation;
use std::path::{Path, PathBuf};
impl TxState<'_> {
pub(crate) fn parse_ast_cached(
&mut self,
path: &Path,
source: &str,
lang: crate::ast::Language,
) -> Result<(tree_sitter_lib::Tree, tree_sitter_lib::Language), crate::ast::ParseFailure> {
let hash = crate::ast::hash_source(source);
if let Some((last_hash, cached)) = self.ast_trees.get(path) {
if *last_hash == hash {
let ts_lang =
crate::ast::ts_language_for(lang).ok_or(crate::ast::ParseFailure::NoGrammar)?;
return Ok((cached.clone(), ts_lang));
}
let mut old_tree = cached.clone();
apply_full_file_edit(&mut old_tree, source);
let (tree, ts_lang) =
crate::ast::try_parse_source_with_old(source, lang, Some(&old_tree))?;
self.ast_trees
.insert(path.to_path_buf(), (hash, tree.clone()));
return Ok((tree, ts_lang));
}
let (tree, ts_lang) = crate::ast::try_parse_source_with_old(source, lang, None)?;
self.ast_trees
.insert(path.to_path_buf(), (hash, tree.clone()));
Ok((tree, ts_lang))
}
pub(crate) fn extract_symbols_cached(
&mut self,
path: &Path,
source: &str,
lang: crate::ast::Language,
) -> anyhow::Result<Vec<crate::ast::symbols::SymbolDef>> {
match self.parse_ast_cached(path, source, lang) {
Ok((tree, _)) => Ok(crate::ast::symbols::extract_symbols_from_tree(
&tree, source, lang,
)),
Err(crate::ast::ParseFailure::DeadlineExceeded) => {
Err(crate::exit::ParseTimeoutError {
msg: format!("parse deadline exceeded for {lang}"),
}
.into())
}
Err(crate::ast::ParseFailure::NoGrammar) => Ok(Vec::new()),
}
}
}
fn apply_full_file_edit(tree: &mut tree_sitter_lib::Tree, new_source: &str) {
let root = tree.root_node();
tree.edit(&tree_sitter_lib::InputEdit {
start_byte: 0,
old_end_byte: root.end_byte(),
new_end_byte: new_source.len(),
start_position: tree_sitter_lib::Point { row: 0, column: 0 },
old_end_position: root.end_position(),
new_end_position: source_end_point(new_source),
});
}
fn source_end_point(source: &str) -> tree_sitter_lib::Point {
let mut row = 0usize;
let mut line_start = 0usize;
for (i, b) in source.as_bytes().iter().enumerate() {
if *b == b'\n' {
row = row.saturating_add(1);
line_start = i.saturating_add(1);
}
}
tree_sitter_lib::Point {
row,
column: source.len().saturating_sub(line_start),
}
}
fn collect_ast_source_files(dir: &Path) -> anyhow::Result<Vec<PathBuf>> {
#[cfg(any(feature = "cli", feature = "files"))]
{
use ignore::WalkBuilder;
let mut files = Vec::new();
let mut builder = WalkBuilder::new(dir);
crate::files::apply_platform_ignore_case(&mut builder);
let walker = builder
.follow_links(false)
.standard_filters(true)
.hidden(false)
.filter_entry(|e| !crate::files::should_skip_walk_dirname(e.file_name()))
.build();
for entry in walker {
let entry = entry.map_err(|e| {
anyhow::anyhow!("ast directory walk failed under {}: {e}", dir.display())
})?;
let path = entry.path();
if path.is_file() && crate::ast::Language::from_path(path).has_grammar() {
files.push(path.to_path_buf());
}
}
files.sort();
Ok(files)
}
#[cfg(not(any(feature = "cli", feature = "files")))]
{
let mut files = Vec::new();
collect_ast_source_files_simple(dir, &mut files)?;
files.sort();
Ok(files)
}
}
#[cfg(not(any(feature = "cli", feature = "files")))]
fn collect_ast_source_files_simple(dir: &Path, out: &mut Vec<PathBuf>) -> anyhow::Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let name = entry.file_name();
if name == ".git" || name == ".patchloom" {
continue;
}
let ft = entry.file_type()?;
if ft.is_symlink() {
continue;
}
if ft.is_dir() {
collect_ast_source_files_simple(&path, out)?;
} else if ft.is_file() && crate::ast::Language::from_path(&path).has_grammar() {
out.push(path);
}
}
Ok(())
}
fn resolve_op_lang(lang: Option<&str>, path: &Path) -> anyhow::Result<crate::ast::Language> {
match lang {
Some(s) => crate::ast::parse_lang_hint(s),
None => Ok(crate::ast::Language::from_path(path)),
}
}
fn ast_rename_single_file(
tx: &mut TxState<'_>,
abs: &Path,
old: &str,
new: &str,
lang_hint: Option<&str>,
) -> anyhow::Result<usize> {
crate::ast::rename::reject_empty_rename_names(old, new)?;
let content = read_file_content(tx.pending, tx.existed_before, abs)?.to_string();
let lang_val = resolve_op_lang(lang_hint, abs)?;
let rename = match tx.parse_ast_cached(abs, &content, lang_val) {
Ok((tree, _)) => Ok(Some(crate::ast::rename::rename_in_tree(
&content, &tree, old, new,
))),
Err(crate::ast::ParseFailure::DeadlineExceeded) => Err(crate::exit::ParseTimeoutError {
msg: format!("parse deadline exceeded for {lang_val}"),
}
.into()),
Err(crate::ast::ParseFailure::NoGrammar) => Ok(None),
};
match rename {
Ok(Some(r)) if r.replacements > 0 => {
tx.write_file(abs, r.content);
Ok(r.replacements)
}
Ok(Some(_)) => Err(crate::exit::NoMatchError {
msg: format!("no matches for '{}' in {}", old, abs.display()),
}
.into()),
Err(e) if crate::exit::is_parse_timeout(&e) => Err(e),
Ok(None) | Err(_) => {
let re = crate::ops::replace::compile_replace_regex(old, false, false, false, true)?;
if let Some(re) = re {
let new_content = re.replace_all(&content, new).to_string();
let count = re.find_iter(&content).count();
if count > 0 {
tx.write_file(abs, new_content);
return Ok(count);
}
}
Err(crate::exit::NoMatchError {
msg: format!("no matches for '{}' in {}", old, abs.display()),
}
.into())
}
}
}
pub(crate) fn execute_ast_op(op: &Operation, tx: &mut TxState<'_>) -> anyhow::Result<usize> {
match op {
Operation::AstRename {
path,
old,
new,
lang,
} => {
let abs = tx.cwd.join(path);
if abs.is_dir() {
let files = collect_ast_source_files(&abs)?;
if files.is_empty() {
return Err(crate::exit::NoMatchError {
msg: format!("no source files found in {path}"),
}
.into());
}
let mut total = 0usize;
for file_path in &files {
if let Some(g) = tx.guard {
let rel = file_path
.strip_prefix(tx.cwd)
.unwrap_or(file_path.as_path())
.to_string_lossy();
g.check_path(rel.as_ref())
.map_err(crate::fallback::EditError::guard_rejected)?;
}
match ast_rename_single_file(tx, file_path, old, new, lang.as_deref()) {
Ok(n) => total = total.saturating_add(n),
Err(e) if crate::exit::is_no_match(&e) => {}
Err(e) => return Err(e),
}
}
if total == 0 {
return Err(crate::exit::NoMatchError {
msg: format!("no matches for '{}' in {}", old, path),
}
.into());
}
return Ok(total);
}
ast_rename_single_file(tx, &abs, old, new, lang.as_deref())
}
Operation::AstReplace {
path,
symbol,
old,
new_text,
regex,
lang,
} => {
let abs = tx.cwd.join(path);
let content = read_file_content(tx.pending, tx.existed_before, &abs)?.to_string();
let lang_val = resolve_op_lang(lang.as_deref(), &abs)?;
let symbols = tx.extract_symbols_cached(&abs, &content, lang_val)?;
let result = crate::ast::replace::replace_in_symbol_from_symbols(
&content, &symbols, symbol, old, new_text, *regex, lang_val,
)?;
match result {
Some(r) if r.replacements > 0 => {
tx.write_file(&abs, r.content);
Ok(r.replacements)
}
Some(_) => Err(crate::exit::NoMatchError {
msg: format!(
"no matches for '{}' in symbol '{}' in {}",
old, symbol, path
),
}
.into()),
None => Err(crate::exit::NoMatchError {
msg: format!("symbol '{}' not found in {}", symbol, path),
}
.into()),
}
}
Operation::AstReplaceSymbol {
path,
symbol,
content,
lang,
} => {
let abs = tx.cwd.join(path);
let file_content = read_file_content(tx.pending, tx.existed_before, &abs)?.to_string();
let lang_val = resolve_op_lang(lang.as_deref(), &abs)?;
let symbols = tx.extract_symbols_cached(&abs, &file_content, lang_val)?;
let new_content = crate::ast::replace::replace_symbol_from_symbols(
&file_content,
&symbols,
symbol,
content,
lang_val,
)
.map_err(|e| {
if crate::exit::is_no_match(&e) {
anyhow::Error::new(crate::exit::NoMatchError {
msg: format!("symbol '{symbol}' not found in {path}"),
})
} else {
e
}
})?;
if new_content != file_content {
tx.write_file(&abs, new_content);
}
Ok(1)
}
Operation::AstDeleteSymbol { path, symbol, lang } => {
let abs = tx.cwd.join(path);
let file_content = read_file_content(tx.pending, tx.existed_before, &abs)?.to_string();
let lang_val = resolve_op_lang(lang.as_deref(), &abs)?;
let symbols = tx.extract_symbols_cached(&abs, &file_content, lang_val)?;
let new_content = crate::ast::replace::delete_symbol_from_symbols(
&file_content,
&symbols,
symbol,
lang_val,
)
.map_err(|e| {
if crate::exit::is_no_match(&e) {
anyhow::Error::new(crate::exit::NoMatchError {
msg: format!("symbol '{symbol}' not found in {path}"),
})
} else {
e
}
})?;
if new_content != file_content {
tx.write_file(&abs, new_content);
}
Ok(1)
}
Operation::AstRewriteSignature {
path,
old,
new_signature,
visibility,
parameters,
return_type,
lang,
} => {
let abs = tx.cwd.join(path);
let content = read_file_content(tx.pending, tx.existed_before, &abs)?.to_string();
let lang_val = resolve_op_lang(lang.as_deref(), &abs)?;
let has_structured =
visibility.is_some() || parameters.is_some() || return_type.is_some();
if new_signature.is_none() && !has_structured {
return Err(crate::exit::InvalidInputError {
msg: "ast.rewrite_signature requires new_signature and/or visibility/parameters/return_type".into(),
}
.into());
}
let tree = match tx.parse_ast_cached(&abs, &content, lang_val) {
Ok((tree, _)) => Some(tree),
Err(crate::ast::ParseFailure::DeadlineExceeded) => {
return Err(crate::exit::ParseTimeoutError {
msg: format!("parse deadline exceeded for {lang_val}"),
}
.into());
}
Err(crate::ast::ParseFailure::NoGrammar) => None,
};
let new_content = if let Some(new_sig) = new_signature {
crate::ast::rewrite::reject_empty_new_signature(new_sig)?;
let span = match tree.as_ref() {
Some(tree) => crate::ast::rewrite::function_span_in_tree(&content, tree, old),
None => None,
};
let Some(span) = span else {
return Err(crate::exit::NoMatchError {
msg: format!("function '{old}' not found in {path}"),
}
.into());
};
crate::ast::rewrite::splice_function_signature(
&content,
span.signature_range,
new_sig,
)
} else {
let edit = crate::ast::rewrite::FunctionSigEdit {
visibility: visibility.clone(),
parameters: parameters.clone(),
return_type: return_type.clone(),
};
match crate::ast::rewrite::try_rewrite_function_signature_in_tree(
&content,
tree.as_ref(),
old,
&edit,
lang_val,
) {
Ok(Some(c)) => c,
Ok(None) => {
return Err(crate::exit::NoMatchError {
msg: format!("function '{old}' not found in {path}"),
}
.into());
}
Err(e) => return Err(e),
}
};
if new_content == content {
return Ok(0);
}
tx.write_file(&abs, new_content);
Ok(1)
}
Operation::AstInsert {
path,
content: insert_content,
inside,
after,
before,
position,
lang,
} => {
let abs = tx.cwd.join(path);
let file_content = read_file_content(tx.pending, tx.existed_before, &abs)?.to_string();
let lang_val = resolve_op_lang(lang.as_deref(), &abs)?;
let pos = match position.as_deref() {
None | Some("") | Some("end") => crate::ast::insert::InsertPosition::End,
Some("start") => crate::ast::insert::InsertPosition::Start,
Some(other) => {
return Err(crate::exit::InvalidInputError {
msg: format!("unknown ast.insert position {other:?}; use start or end"),
}
.into());
}
};
let symbols = tx.extract_symbols_cached(&abs, &file_content, lang_val)?;
let result = crate::ast::insert::insert_code_from_symbols(
&file_content,
&symbols,
insert_content,
inside.as_deref(),
after.as_deref(),
before.as_deref(),
pos,
lang_val,
)?;
tx.write_file(&abs, result.content);
Ok(1)
}
Operation::AstWrap {
path,
symbols: sym_names,
lines: line_range,
wrapper,
preamble,
lang,
} => {
let abs = tx.cwd.join(path);
let file_content = read_file_content(tx.pending, tx.existed_before, &abs)?.to_string();
let lang_val = resolve_op_lang(lang.as_deref(), &abs)?;
let symbols = tx.extract_symbols_cached(&abs, &file_content, lang_val)?;
let result = crate::ast::wrap::wrap_code_from_symbols(
&file_content,
&symbols,
sym_names.as_deref(),
line_range.as_deref(),
wrapper,
preamble.as_deref(),
lang_val,
)?;
tx.write_file(&abs, result.content);
Ok(1)
}
Operation::AstImports {
path,
add,
remove,
dedupe,
lang,
} => {
let abs = tx.cwd.join(path);
let mut file_content =
read_file_content(tx.pending, tx.existed_before, &abs)?.to_string();
let lang_val = resolve_op_lang(lang.as_deref(), &abs)?;
let mut total_changes = 0usize;
if let Some(add_list) = add {
crate::ast::imports::reject_empty_import_items(add_list)?;
let result = crate::ast::imports::add_imports(&file_content, add_list, lang_val);
total_changes += result.added;
file_content = result.content;
}
if let Some(remove_list) = remove {
let result =
crate::ast::imports::remove_imports(&file_content, remove_list, lang_val);
total_changes += result.removed;
file_content = result.content;
}
if *dedupe {
let result = crate::ast::imports::dedupe_imports(&file_content, lang_val);
total_changes += result.deduped;
file_content = result.content;
}
if total_changes > 0 {
tx.write_file(&abs, file_content);
}
Ok(total_changes)
}
Operation::AstReorder {
path,
inside,
order,
lang,
} => {
let abs = tx.cwd.join(path);
let file_content = read_file_content(tx.pending, tx.existed_before, &abs)?.to_string();
let lang_val = resolve_op_lang(lang.as_deref(), &abs)?;
let strategy = crate::ast::reorder::parse_strategy(order)?;
let symbols = tx.extract_symbols_cached(&abs, &file_content, lang_val)?;
let result = crate::ast::reorder::reorder_symbols_from_symbols(
&file_content,
&symbols,
inside.as_deref(),
&strategy,
lang_val,
)?;
if result.symbols_reordered > 0 {
tx.write_file(&abs, result.content);
}
Ok(result.symbols_reordered)
}
Operation::AstGroup {
path,
module,
symbols: sym_names,
preamble,
position,
lang,
} => {
let abs = tx.cwd.join(path);
let file_content = read_file_content(tx.pending, tx.existed_before, &abs)?.to_string();
let lang_val = resolve_op_lang(lang.as_deref(), &abs)?;
let pos = crate::ast::group::parse_group_position(position.as_deref())?;
let spec = crate::ast::group::GroupSpec {
module: module.clone(),
symbols: sym_names.clone(),
preamble: preamble.clone(),
position: pos,
};
let symbols = tx.extract_symbols_cached(&abs, &file_content, lang_val)?;
let result = crate::ast::group::group_symbols_from_symbols(
&file_content,
&symbols,
&spec,
lang_val,
)?;
if result.symbols_moved > 0 {
tx.write_file(&abs, result.content);
}
Ok(result.symbols_moved)
}
Operation::AstMove {
path,
target,
symbols: sym_names,
position,
target_prepend,
lang,
update_imports,
old_module_path,
new_module_path,
} => {
if let Some(p) = target_prepend
&& p.trim().is_empty()
&& !p.contains('\n')
&& !p.contains('\r')
{
return Err(crate::exit::InvalidInputError {
msg: "ast move target_prepend must not be empty".into(),
}
.into());
}
let rewrite_mods =
import_rewrite_modules(*update_imports, old_module_path, new_module_path)?;
let abs_source = tx.cwd.join(path);
let abs_target = tx.cwd.join(target);
if crate::ops::file::is_real_directory(&abs_target) {
return Err(crate::exit::InvalidInputError {
msg: format!("target is a directory: {target}"),
}
.into());
}
let source_content =
read_file_content(tx.pending, tx.existed_before, &abs_source)?.to_string();
let target_content = if tx.path_present_for_if_exists(&abs_target) {
read_file_content(tx.pending, tx.existed_before, &abs_target)?.to_string()
} else {
target_prepend
.as_ref()
.map(|p| format!("{p}\n\n"))
.unwrap_or_default()
};
let lang_val = resolve_op_lang(lang.as_deref(), &abs_source)?;
if rewrite_mods.is_some() {
crate::ast::import_rewrite::reject_unsupported_update_imports(lang_val)?;
}
let pos = crate::ast::move_symbols::parse_position(position.as_deref())?;
let src_symbols = tx.extract_symbols_cached(&abs_source, &source_content, lang_val)?;
let result = crate::ast::move_symbols::move_symbols_from_symbols(
&source_content,
&target_content,
&src_symbols,
sym_names,
pos,
lang_val,
)?;
tx.write_file(&abs_source, result.source_content);
tx.write_file(&abs_target, result.target_content);
if let Some((old, new)) = rewrite_mods {
apply_consumer_import_rewrites(
tx,
&abs_source,
&abs_target,
lang_val,
&symbol_moves(sym_names, &old, &new),
)?;
}
Ok(result.symbols_moved)
}
Operation::AstExtractToFile {
source,
symbol,
target,
replacement,
unwrap,
prepend,
force,
lang,
update_imports,
old_module_path,
new_module_path,
} => {
let rewrite_mods =
import_rewrite_modules(*update_imports, old_module_path, new_module_path)?;
let abs_source = tx.cwd.join(source);
let abs_target = tx.cwd.join(target);
if crate::ops::file::is_real_directory(&abs_target) {
return Err(crate::exit::InvalidInputError {
msg: format!("target is a directory: {target}"),
}
.into());
}
if !force && tx.path_present_for_if_exists(&abs_target) {
return Err(crate::exit::AlreadyExistsError {
msg: format!(
"target file '{target}' already exists (use force: true to overwrite)"
),
}
.into());
}
let source_content =
read_file_content(tx.pending, tx.existed_before, &abs_source)?.to_string();
let lang_val = resolve_op_lang(lang.as_deref(), &abs_source)?;
if rewrite_mods.is_some() {
crate::ast::import_rewrite::reject_unsupported_update_imports(lang_val)?;
}
let do_unwrap = unwrap.unwrap_or(true);
let symbols = tx.extract_symbols_cached(&abs_source, &source_content, lang_val)?;
let result = crate::ast::extract_to_file::extract_to_file_from_symbols(
&source_content,
&symbols,
symbol,
replacement.as_deref(),
do_unwrap,
prepend.as_deref(),
lang_val,
)?;
tx.write_file(&abs_source, result.source_content);
tx.write_file(&abs_target, result.target_content);
if let Some((old, new)) = rewrite_mods {
apply_consumer_import_rewrites(
tx,
&abs_source,
&abs_target,
lang_val,
&symbol_moves(std::slice::from_ref(symbol), &old, &new),
)?;
}
Ok(1)
}
Operation::AstSplit {
source,
targets,
keep_in_source,
source_suffix,
source_prefix,
require_exhaustive,
lang,
} => {
let abs_source = tx.cwd.join(source);
let source_content =
read_file_content(tx.pending, tx.existed_before, &abs_source)?.to_string();
let lang_val = resolve_op_lang(lang.as_deref(), &abs_source)?;
let split_targets: Vec<crate::ast::split::SplitTarget> = targets
.iter()
.map(|t| crate::ast::split::SplitTarget {
path: t.path.clone(),
symbols: t.symbols.clone(),
prepend: t.prepend.clone(),
})
.collect();
for t in targets {
let abs_target = tx.cwd.join(&t.path);
if crate::ops::file::is_real_directory(&abs_target) {
return Err(crate::exit::InvalidInputError {
msg: format!("target is a directory: {}", t.path),
}
.into());
}
}
let exhaustive = require_exhaustive.unwrap_or(true);
let symbols = tx.extract_symbols_cached(&abs_source, &source_content, lang_val)?;
let result = crate::ast::split::split_file_from_symbols(
&source_content,
&symbols,
&split_targets,
keep_in_source,
source_suffix.as_deref(),
source_prefix.as_deref(),
exhaustive,
lang_val,
)?;
tx.write_file(&abs_source, result.source_content);
for (target_path, target_content) in &result.targets {
let abs_target = tx.cwd.join(target_path);
tx.write_file(&abs_target, target_content.clone());
}
Ok(result.symbols_distributed)
}
_ => unreachable!("execute_ast_op called with non-AST operation"),
}
}
fn import_rewrite_modules(
update_imports: bool,
old_module_path: &Option<String>,
new_module_path: &Option<String>,
) -> anyhow::Result<Option<(String, String)>> {
if !update_imports {
return Ok(None);
}
let old = old_module_path
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
let new = new_module_path
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
match (old, new) {
(Some(old), Some(new)) => Ok(Some((old.to_string(), new.to_string()))),
_ => Err(crate::exit::InvalidInputError {
msg: "update_imports requires old_module_path and new_module_path".into(),
}
.into()),
}
}
fn symbol_moves(
symbols: &[String],
old: &str,
new: &str,
) -> Vec<crate::ast::import_rewrite::SymbolMove> {
symbols
.iter()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|name| crate::ast::import_rewrite::SymbolMove {
name: name.to_string(),
old_module: old.to_string(),
new_module: new.to_string(),
})
.collect()
}
fn apply_consumer_import_rewrites(
tx: &mut TxState<'_>,
source: &Path,
target: &Path,
lang: crate::ast::Language,
moves: &[crate::ast::import_rewrite::SymbolMove],
) -> anyhow::Result<()> {
if moves.is_empty() {
return Ok(());
}
let mut files = collect_ast_source_files(tx.cwd)?;
for pending in tx.pending.keys() {
if crate::ast::Language::from_path(pending) == lang && !files.iter().any(|f| f == pending) {
files.push(pending.clone());
}
}
files.sort();
for file in files {
if paths_equal(&file, source) || paths_equal(&file, target) {
continue;
}
if tx.deletions.contains(&file) {
continue;
}
if crate::ast::Language::from_path(&file) != lang {
continue;
}
if is_skipped_consumer_path(&file, tx.cwd) {
continue;
}
if let Some(g) = tx.guard {
let rel = file.strip_prefix(tx.cwd).unwrap_or(file.as_path());
g.check_path(&rel.to_string_lossy())
.map_err(crate::fallback::EditError::guard_rejected)?;
}
if !read_and_probe(tx.pending, tx.existed_before, &file)? {
continue;
}
let content = read_file_content(tx.pending, tx.existed_before, &file)?.to_string();
if let Some(rewritten) =
crate::ast::import_rewrite::rewrite_imports_in_source(&content, lang, moves)
{
tx.write_file(&file, rewritten);
}
}
Ok(())
}
fn paths_equal(a: &Path, b: &Path) -> bool {
if a == b {
return true;
}
match (a.canonicalize(), b.canonicalize()) {
(Ok(x), Ok(y)) => x == y,
_ => false,
}
}
fn is_skipped_consumer_path(path: &Path, cwd: &Path) -> bool {
let rel = path.strip_prefix(cwd).unwrap_or(path);
rel.components().any(|c| {
matches!(
c.as_os_str().to_str(),
Some("target" | "node_modules" | ".git" | ".patchloom")
)
})
}
#[cfg(all(test, any(feature = "cli", feature = "files")))]
mod tests {
use super::collect_ast_source_files;
use std::fs;
use tempfile::TempDir;
#[test]
fn collect_respects_gitignore() {
let dir = TempDir::new().unwrap();
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(dir.path())
.status()
.expect("git init");
fs::write(dir.path().join(".gitignore"), "ignored.rs\n").unwrap();
fs::write(dir.path().join("kept.rs"), "fn kept() {}\n").unwrap();
fs::write(dir.path().join("ignored.rs"), "fn ignored() {}\n").unwrap();
let files = collect_ast_source_files(dir.path()).unwrap();
let names: Vec<_> = files
.iter()
.filter_map(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
.collect();
assert!(names.contains(&"kept.rs".to_string()), "got {names:?}");
assert!(
!names.contains(&"ignored.rs".to_string()),
"gitignore must exclude ignored.rs: {names:?}"
);
}
#[cfg(unix)]
#[test]
fn collect_does_not_follow_symlink_dirs() {
use std::os::unix::fs::symlink;
let dir = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
fs::write(outside.path().join("inside.rs"), "fn inside() {}\n").unwrap();
let link = dir.path().join("linkdir");
symlink(outside.path(), &link).unwrap();
fs::write(dir.path().join("root.rs"), "fn root() {}\n").unwrap();
let files = collect_ast_source_files(dir.path()).unwrap();
let names: Vec<_> = files
.iter()
.filter_map(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
.collect();
assert!(names.contains(&"root.rs".to_string()), "got {names:?}");
assert!(
!names.contains(&"inside.rs".to_string()),
"must not follow symlink dir: {names:?}"
);
}
#[test]
fn collect_skips_git_and_patchloom_dirs() {
let dir = TempDir::new().unwrap();
fs::create_dir_all(dir.path().join(".git/objects")).unwrap();
fs::write(dir.path().join(".git/objects/fake.rs"), "fn fake() {}\n").unwrap();
fs::create_dir_all(dir.path().join(".patchloom/backups")).unwrap();
fs::write(
dir.path().join(".patchloom/backups/also.rs"),
"fn also() {}\n",
)
.unwrap();
fs::write(dir.path().join("root.rs"), "fn root() {}\n").unwrap();
let files = collect_ast_source_files(dir.path()).unwrap();
let names: Vec<_> = files
.iter()
.filter_map(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
.collect();
assert_eq!(names, vec!["root.rs".to_string()], "got {names:?}");
}
#[test]
fn ast_rename_single_file_timeout_is_parse_timeout() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("deep.rs");
let original = crate::ast::nested_rust_source_for_timeout(80_000);
fs::write(&path, &original).unwrap();
let plan = crate::plan::Plan {
version: crate::plan::SCHEMA_VERSION,
cwd: None,
operations: vec![crate::plan::Operation::AstRename {
path: "deep.rs".into(),
old: "x".into(),
new: "y".into(),
lang: None,
}],
write_policy: None,
strict: None,
format: None,
validate: None,
verify: None,
for_each: None,
agent_preset: false,
expected_sha256: None,
};
let _guard = crate::ast::ParseTimeoutGuard::set(std::time::Duration::from_millis(1));
let report = crate::tx::execute_plan_direct(plan, dir.path(), None).expect("plan ok");
assert!(
!report.ok,
"timeout must not apply a word-boundary rename: {report:?}"
);
assert_eq!(
report.error_kind.as_deref(),
Some("parse_timeout"),
"tx rename timeout must be parse_timeout, got {report:?}"
);
assert!(!report.applied, "timeout must not set applied");
let after = fs::read_to_string(&path).unwrap();
assert_eq!(after, original, "timeout must not word-boundary-write");
}
#[test]
fn ast_move_empty_target_prepend_is_invalid_input() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("src.rs");
let original = "fn foo() { let x = 1; }\n";
fs::write(&path, original).unwrap();
for prepend in [Some(String::new()), Some(" ".into())] {
let plan = crate::plan::Plan {
version: crate::plan::SCHEMA_VERSION,
cwd: None,
operations: vec![crate::plan::Operation::AstMove {
path: "src.rs".into(),
target: "dest.rs".into(),
symbols: vec!["foo".into()],
position: None,
target_prepend: prepend.clone(),
lang: None,
update_imports: false,
old_module_path: None,
new_module_path: None,
}],
write_policy: None,
strict: None,
format: None,
validate: None,
verify: None,
for_each: None,
agent_preset: false,
expected_sha256: None,
};
let report =
crate::tx::execute_plan_direct(plan, dir.path(), None).expect("plan executes");
assert!(!report.ok, "empty target_prepend must fail: {report:?}");
assert_eq!(
report.error_kind.as_deref(),
Some("invalid_input"),
"empty target_prepend must be invalid_input, got {report:?}"
);
assert!(!report.applied, "must not apply on empty target_prepend");
let after = fs::read_to_string(&path).unwrap();
assert_eq!(after, original, "source must be unchanged");
assert!(
!dir.path().join("dest.rs").exists(),
"empty target_prepend must not create dest"
);
}
}
#[test]
fn ast_move_newline_target_prepend_is_ok() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("src.rs");
fs::write(&path, "fn foo() { let x = 1; }\n").unwrap();
let plan = crate::plan::Plan {
version: crate::plan::SCHEMA_VERSION,
cwd: None,
operations: vec![crate::plan::Operation::AstMove {
path: "src.rs".into(),
target: "dest.rs".into(),
symbols: vec!["foo".into()],
position: None,
target_prepend: Some("\n".into()),
lang: None,
update_imports: false,
old_module_path: None,
new_module_path: None,
}],
write_policy: None,
strict: None,
format: None,
validate: None,
verify: None,
for_each: None,
agent_preset: false,
expected_sha256: None,
};
let report = crate::tx::execute_plan_direct(plan, dir.path(), None).expect("plan executes");
assert!(report.ok, "newline target_prepend must apply: {report:?}");
assert!(report.applied);
let dest = fs::read_to_string(dir.path().join("dest.rs")).unwrap();
assert!(
dest.starts_with('\n'),
"newline prepend should insert blank line: {dest:?}"
);
assert!(dest.contains("fn foo()"));
}
#[test]
fn extract_symbols_cached_twice_same_pending_is_one_parse() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("lib.rs");
let src = "fn foo() {}\nfn bar() {}\n";
fs::write(&path, src).unwrap();
let mut f = crate::tx::TxStateFixture::new();
let mut tx = f.state(dir.path());
let content = crate::tx::read_file_content(tx.pending, tx.existed_before, &path)
.unwrap()
.to_string();
crate::ast::reset_parse_count();
let first = tx
.extract_symbols_cached(&path, &content, crate::ast::Language::Rust)
.unwrap();
let second = tx
.extract_symbols_cached(&path, &content, crate::ast::Language::Rust)
.unwrap();
assert_eq!(
crate::ast::take_parse_count(),
1,
"unchanged pending must reuse the cached tree"
);
assert_eq!(first.len(), second.len());
assert!(first.iter().any(|s| s.name == "foo"));
assert!(second.iter().any(|s| s.name == "bar"));
}
#[test]
fn extract_symbols_cached_after_write_uses_incremental_and_finds_new() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("lib.rs");
fs::write(&path, "fn foo() {}\nfn bar() {}\n").unwrap();
let mut f = crate::tx::TxStateFixture::new();
let mut tx = f.state(dir.path());
let content = crate::tx::read_file_content(tx.pending, tx.existed_before, &path)
.unwrap()
.to_string();
crate::ast::reset_parse_count();
let first = tx
.extract_symbols_cached(&path, &content, crate::ast::Language::Rust)
.unwrap();
assert!(first.iter().any(|s| s.name == "bar"));
tx.write_file(&path, "fn foo() {}\nfn baz() {}\n".into());
let updated = tx.pending.get(&path).unwrap().1.clone();
let second = tx
.extract_symbols_cached(&path, &updated, crate::ast::Language::Rust)
.unwrap();
assert_eq!(
crate::ast::take_parse_count(),
2,
"write then extract is one cold parse plus one incremental"
);
assert!(second.iter().any(|s| s.name == "baz"));
assert!(!second.iter().any(|s| s.name == "bar"));
}
#[test]
fn extract_symbols_cached_different_files_do_not_share() {
let dir = TempDir::new().unwrap();
let a = dir.path().join("a.rs");
let b = dir.path().join("b.rs");
fs::write(&a, "fn alpha() {}\n").unwrap();
fs::write(&b, "fn beta() {}\n").unwrap();
let mut f = crate::tx::TxStateFixture::new();
let mut tx = f.state(dir.path());
let a_src = crate::tx::read_file_content(tx.pending, tx.existed_before, &a)
.unwrap()
.to_string();
let b_src = crate::tx::read_file_content(tx.pending, tx.existed_before, &b)
.unwrap()
.to_string();
crate::ast::reset_parse_count();
let a_syms = tx
.extract_symbols_cached(&a, &a_src, crate::ast::Language::Rust)
.unwrap();
let b_syms = tx
.extract_symbols_cached(&b, &b_src, crate::ast::Language::Rust)
.unwrap();
assert_eq!(crate::ast::take_parse_count(), 2);
assert!(a_syms.iter().any(|s| s.name == "alpha"));
assert!(b_syms.iter().any(|s| s.name == "beta"));
assert!(!a_syms.iter().any(|s| s.name == "beta"));
}
#[test]
fn two_ast_replace_same_file_both_land_and_reuse_tree() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("lib.rs");
fs::write(&path, "fn foo() { let x = 1; }\nfn bar() { let y = 2; }\n").unwrap();
let plan = crate::plan::Plan {
version: crate::plan::SCHEMA_VERSION,
cwd: None,
operations: vec![
crate::plan::Operation::AstReplace {
path: "lib.rs".into(),
symbol: "foo".into(),
old: "x".into(),
new_text: "xx".into(),
regex: false,
lang: None,
},
crate::plan::Operation::AstReplace {
path: "lib.rs".into(),
symbol: "bar".into(),
old: "y".into(),
new_text: "yy".into(),
regex: false,
lang: None,
},
],
write_policy: None,
strict: None,
format: None,
validate: None,
verify: None,
for_each: None,
agent_preset: false,
expected_sha256: None,
};
crate::ast::reset_parse_count();
let report = crate::tx::execute_plan_direct(plan, dir.path(), None).expect("plan ok");
assert!(report.ok, "two ast.replace must apply: {report:?}");
assert!(report.applied);
let after = fs::read_to_string(&path).unwrap();
assert!(after.contains("let xx = 1"), "foo replace missing: {after}");
assert!(after.contains("let yy = 2"), "bar replace missing: {after}");
let n = crate::ast::take_parse_count();
assert!(
n <= 2,
"two replaces on one file must not cold-parse each op twice, got {n}"
);
assert!(n >= 1, "at least one parse must run, got {n}");
}
}