use std::collections::HashSet;
use std::path::{Path, PathBuf};
use ast_grep_core::source::Edit;
use ast_grep_core::tree_sitter::LanguageExt;
use cosh_sdk::ast::{apply_edits, compile_search_patterns, is_supported_file, resolve_language};
use cosh_sdk::find::{FileType, GlobOptions, glob};
use cosh_sdk::hashline::{
diff::structured_patch,
format,
fs::{DiskFilesystem, Filesystem},
};
use cosh_sdk::rollback;
use cosh_sdk::tree_sitter::tree_sitter;
use crate::util::path_guard::assert_editable_file;
use super::edit::EditResult;
use super::types::{AstEditOp, FsAstEdit, FsMetadata};
struct FileEditOutcome {
path: String,
file_hash: String,
header: String,
first_changed_line: Option<u32>,
warnings: Vec<String>,
diff: Option<String>,
touched: bool,
}
fn splice_anchor(anchor: &[usize], edits: &[Edit<String>]) -> Vec<usize> {
let mut sorted: Vec<&Edit<String>> = edits.iter().collect();
sorted.sort_by(|a, b| {
a.position
.cmp(&b.position)
.then(a.deleted_length.cmp(&b.deleted_length))
.then(a.inserted_text.cmp(&b.inserted_text))
});
sorted.dedup_by(|a, b| {
a.position == b.position
&& a.deleted_length == b.deleted_length
&& a.inserted_text == b.inserted_text
});
let mut out = anchor.to_vec();
for e in sorted.iter().rev() {
let start = e.position;
let end = e.position.saturating_add(e.deleted_length);
let orig = out.get(start).or_else(|| out.last()).copied().unwrap_or(0);
out.splice(start..end, std::iter::repeat_n(orig, e.inserted_text.len()));
}
out
}
fn newlines_before(text: &str, byte: usize) -> u32 {
let end = byte.min(text.len());
text[..end].bytes().filter(|&b| b == b'\n').count() as u32
}
pub const DEFAULT_MAX_FILES: usize = 1000;
fn has_glob_chars(p: &str) -> bool {
p.bytes().any(|b| matches!(b, b'*' | b'?' | b'[' | b'{'))
}
fn is_supported(path: &Path) -> bool {
is_supported_file(path, None)
}
fn collect_supported_files(metadata: &FsMetadata, dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(rd) = std::fs::read_dir(dir) else {
return;
};
for entry in rd.flatten() {
let Ok(ft) = entry.file_type() else {
continue;
};
if ft.is_symlink() {
continue;
}
let p = entry.path();
if ft.is_dir() {
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name == ".git" || name == "node_modules" {
continue;
}
collect_supported_files(metadata, &p, out);
} else if ft.is_file()
&& let Ok(abs) = metadata.fs_guard(&p.to_string_lossy())
&& is_supported(&abs)
&& !out.contains(&abs)
{
out.push(abs);
}
}
}
fn expand_glob(metadata: &FsMetadata, root: &Path, pattern: &str) -> Result<Vec<PathBuf>, String> {
let res = glob(GlobOptions {
pattern: pattern.to_string(),
path: root.to_string_lossy().to_string(),
file_type: Some(FileType::File),
recursive: None,
hidden: None,
max_results: None,
gitignore: Some(true),
cache: None,
sort_by_mtime: None,
include_node_modules: None,
timeout_ms: None,
on_match: None,
})
.map_err(|e| format!("glob `{pattern}` failed: {e}"))?;
let mut v = Vec::new();
for m in res.matches {
if let Ok(abs) = metadata.fs_guard(&m.path)
&& is_supported(&abs)
&& !v.contains(&abs)
{
v.push(abs);
}
}
Ok(v)
}
fn resolve_target_files(
metadata: &FsMetadata,
root: &Path,
paths: &[String],
max_files: usize,
) -> Result<(Vec<PathBuf>, bool), String> {
let mut out = Vec::new();
for p in paths {
if has_glob_chars(p) {
out.extend(expand_glob(metadata, root, p)?);
} else {
let abs = metadata
.fs_guard(p)
.map_err(|e| format!("permission denied for `{p}`: {e}"))?;
if !abs.exists() {
continue;
}
if abs.is_dir() {
collect_supported_files(metadata, &abs, &mut out);
} else if is_supported(&abs) && !out.contains(&abs) {
out.push(abs);
}
}
}
out.sort();
out.dedup();
let limit_reached = out.len() > max_files;
if limit_reached {
out.truncate(max_files);
}
Ok((out, limit_reached))
}
async fn rewrite_file(abs: &Path, ops: &[AstEditOp]) -> Result<FileEditOutcome, String> {
let fs = DiskFilesystem::new();
let path_str = abs.to_string_lossy().to_string();
assert_editable_file(abs).map_err(|e| format!("failed to edit `{path_str}`: {e}"))?;
let original = fs
.read_text(&path_str)
.await
.map_err(|e| format!("failed to read `{path_str}`: {e}"))?;
let lang = resolve_language(None, abs)
.map_err(|e| format!("unsupported language for `{path_str}`: {e}"))?;
if original.is_empty() {
let hash = format::compute_file_hash("");
return Ok(FileEditOutcome {
path: path_str.clone(),
file_hash: hash.clone(),
header: format::format_hashline_header(&path_str, &hash),
first_changed_line: None,
warnings: vec!["no AST matches found".to_string()],
diff: None,
touched: false,
});
}
let mut text = original.clone();
let mut anchor: Vec<usize> = (0..original.len()).collect();
let mut total = 0usize;
let mut first_change_orig: Option<usize> = None;
let mut issues: Vec<String> = Vec::new();
for op in ops {
let patterns = match compile_search_patterns(&op.pat, lang) {
Ok(ps) => ps,
Err(e) => {
issues.push(format!("unable to parse pattern `{}`: {e}", op.pat));
continue;
}
};
let mut ast = lang.ast_grep(&text);
for pattern in patterns {
let edits = ast.root().replace_all(pattern, op.out.as_str());
if edits.is_empty() {
continue;
}
match apply_edits(ast.root().text().as_ref(), &edits) {
Ok(updated) => {
for e in &edits {
let orig_start = anchor
.get(e.position.min(anchor.len().saturating_sub(1)))
.copied()
.unwrap_or(0);
if first_change_orig.is_none_or(|fc| orig_start < fc) {
first_change_orig = Some(orig_start);
}
}
anchor = splice_anchor(&anchor, &edits);
text = updated;
total += edits.len();
ast = lang.ast_grep(&text);
}
Err(e) => issues.push(format!("rewrite `{}` skipped: {e}", op.pat)),
}
}
}
if total == 0 {
issues.push("no AST matches found".to_string());
}
let touched = text != original;
if touched {
let _ = rollback::record(&path_str, &original);
fs.write_text(&path_str, &text)
.await
.map_err(|e| format!("failed to write `{path_str}`: {e}"))?;
tree_sitter().invalidate(&path_str);
let _ = rollback::record(&path_str, &text);
}
let first_changed_line = first_change_orig.map(|b| newlines_before(&original, b) + 1);
let hash = format::compute_file_hash(&text);
let header = format::format_hashline_header(&path_str, &hash);
let diff = if touched {
Some(structured_patch(&original, &text, 3).to_unified_diff(&path_str, &path_str))
} else {
None
};
Ok(FileEditOutcome {
path: path_str,
file_hash: hash,
header,
first_changed_line,
warnings: issues,
diff,
touched,
})
}
pub async fn ast_edit(metadata: FsMetadata, tg: FsAstEdit) -> Result<Vec<EditResult>, String> {
let root = metadata.root.clone();
if tg.paths.is_empty() {
return Err("AST edit requires at least one `paths` entry".to_string());
}
if tg.ops.is_empty() {
return Err("AST edit requires at least one `ops` entry".to_string());
}
let mut seen_pats = HashSet::new();
for (i, op) in tg.ops.iter().enumerate() {
if op.pat.trim().is_empty() {
return Err(format!("`ops[{i}].pat` must be a non-empty pattern"));
}
if !seen_pats.insert(op.pat.as_str()) {
return Err(format!("duplicate rewrite pattern `{}`", op.pat));
}
}
let max_files = tg.max_files.unwrap_or(DEFAULT_MAX_FILES);
let (files, limit_reached) = resolve_target_files(&metadata, &root, &tg.paths, max_files)?;
if files.is_empty() && !limit_reached {
return Ok(Vec::new());
}
let mut results = Vec::new();
for f in files {
let outcome = rewrite_file(&f, &tg.ops).await?;
if outcome.touched || !outcome.warnings.is_empty() {
results.push(EditResult {
path: outcome.path,
file_hash: outcome.file_hash,
header: outcome.header,
first_changed_line: outcome.first_changed_line,
warnings: outcome.warnings,
lsp_notes: None,
dry_run: None,
diff: outcome.diff,
});
}
}
if limit_reached {
let note = format!("AST edit limit reached ({max_files} files); narrow `paths`");
match results.last_mut() {
Some(last) => last.warnings.push(note),
None => results.push(EditResult {
path: String::new(),
file_hash: String::new(),
header: String::new(),
first_changed_line: None,
warnings: vec![note],
lsp_notes: None,
dry_run: None,
diff: None,
}),
}
}
Ok(results)
}