1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3
4use crate::analyzer::Analyzer;
5pub use crate::api::{
6 AppliedStructuredEditFile, AppliedStructuredEditOperation, AppliedStructuredEditSummary,
7};
8use crate::api::{EditOp, PropagationResult, PropagationSource, StructuredEdit};
9use crate::treesitter::TreeSitterAnalyzer;
10use sha2::{Digest, Sha256};
11use std::sync::Mutex;
12
13#[derive(Debug, Clone)]
14struct PlannedEdit {
15 start_line: usize,
16 old_count: usize,
17 replacement: Vec<String>,
18 primary_symbol_name: Option<String>,
19}
20
21struct PreparedFileEdits {
22 display_path: String,
23 full_path: PathBuf,
24 existed: bool,
25 original: String,
26 new_content: String,
27 planned: Vec<PlannedEdit>,
28}
29
30struct PreparedStructuredEdits {
31 files: Vec<PreparedFileEdits>,
32}
33
34pub fn line_hash(line_content: &str) -> String {
35 let mut hasher = Sha256::new();
36 hasher.update(line_content.as_bytes());
37 let result = hasher.finalize();
38 format!("{:02x}", result[0])
39}
40
41fn parse_start_anchor(anchor: &str) -> Result<(usize, String), String> {
42 let (line_str, hash_str) = anchor
43 .split_once('#')
44 .ok_or_else(|| format!("invalid start anchor (expected line#hash): {anchor}"))?;
45 let line = line_str
46 .parse::<usize>()
47 .map_err(|_| format!("invalid line number in anchor: {anchor}"))?;
48 if line == 0 {
49 return Err(format!("line number must be >= 1 in anchor: {anchor}"));
50 }
51 Ok((line, hash_str.to_string()))
52}
53
54fn verify_line(content: &str, line_num: usize, expected_hash: &str) -> Result<(), String> {
55 let lines: Vec<&str> = content.lines().collect();
56 if line_num > lines.len() {
57 return Err(format!(
58 "line {line_num} out of bounds (file has {} lines)",
59 lines.len()
60 ));
61 }
62 let actual = lines[line_num - 1];
63 let actual_hash = line_hash(actual);
64 if actual_hash != expected_hash {
65 return Err(format!(
66 "line {line_num} hash mismatch: expected {expected_hash}, got {actual_hash} — file may have changed; re-read before editing"
67 ));
68 }
69 Ok(())
70}
71
72fn apply_planned_edits_to_content(
73 original: &str,
74 edits: &[PlannedEdit],
75 file_display: &str,
76) -> Result<String, String> {
77 let mut sorted: Vec<&PlannedEdit> = edits.iter().collect();
78 sorted.sort_by_key(|edit| edit.start_line);
79 for pair in sorted.windows(2) {
80 let prev_end = pair[0].start_line + pair[0].old_count;
81 if pair[1].start_line < prev_end {
82 return Err(format!(
83 "overlapping edits in {}: edit at line {} overlaps previous edit ending at line {}",
84 file_display, pair[1].start_line, prev_end
85 ));
86 }
87 }
88
89 let mut lines = original.lines().map(str::to_string).collect::<Vec<_>>();
90 for edit in sorted.iter().rev() {
91 let start_idx = edit.start_line.saturating_sub(1);
92 let end_idx = start_idx + edit.old_count;
93 if start_idx > lines.len() || end_idx > lines.len() {
94 return Err(format!(
95 "edit exceeds file bounds in {}: lines {}-{} but file has {} lines",
96 file_display,
97 edit.start_line,
98 edit.start_line + edit.old_count,
99 lines.len()
100 ));
101 }
102 lines.splice(start_idx..end_idx, edit.replacement.clone());
103 }
104
105 if lines.is_empty() {
106 Ok(String::new())
107 } else {
108 Ok(lines.join("\n") + "\n")
109 }
110}
111
112struct PropagationCollectionContext<'a> {
113 full_path: &'a Path,
114 original: &'a str,
115 new_content: &'a str,
116 project_root: &'a Path,
117 lsp_analyzer: &'a Mutex<Option<Box<dyn Analyzer + Send>>>,
118 analyzer: &'a TreeSitterAnalyzer,
119}
120
121fn collect_propagation_results(
122 context: PropagationCollectionContext<'_>,
123 edits: &[PlannedEdit],
124) -> Vec<PropagationResult> {
125 let PropagationCollectionContext {
126 full_path,
127 original,
128 new_content,
129 project_root,
130 lsp_analyzer,
131 analyzer,
132 } = context;
133
134 let mut results: Vec<PropagationResult> = Vec::new();
135 let mut seen: HashSet<String> = HashSet::new();
136 let mut modified_symbol_names = HashSet::new();
137
138 for edit in edits {
139 if let Some(ref name) = edit.primary_symbol_name {
140 modified_symbol_names.insert(name.clone());
141 }
142 if let Some(sel) = analyzer.find_containing_symbol(full_path, edit.start_line, project_root)
143 && let Ok(parsed) = crate::selector::parse_selector(&sel)
144 && let Some(name) = parsed.name()
145 {
146 modified_symbol_names.insert(name.to_string());
147 }
148 }
149
150 for sym_name in &modified_symbol_names {
151 let mut lsp_refs: Vec<PropagationResult> = Vec::new();
152 if let Ok(lsp_guard) = lsp_analyzer.lock()
153 && let Some(ref lsp) = *lsp_guard
154 {
155 let (line, character) =
156 find_symbol_position(new_content, sym_name).unwrap_or_else(|| {
157 let hint_line = edits.first().map(|edit| edit.start_line).unwrap_or(1);
158 (hint_line, 0)
159 });
160 lsp_refs = lsp.find_references_for_symbol(full_path, line, character, project_root);
161 }
162 if lsp_refs.is_empty() {
163 let selector = format!(
164 "{}::{}",
165 full_path
166 .strip_prefix(project_root)
167 .ok()
168 .map(|p| p.to_string_lossy().to_string())
169 .unwrap_or_else(|| full_path.to_string_lossy().to_string()),
170 sym_name
171 );
172 if seen.insert(selector.clone()) {
173 let first_line = edits.first().map(|edit| edit.start_line).unwrap_or(1);
174 let file_snippet = original
175 .lines()
176 .skip(first_line.saturating_sub(3))
177 .take(7)
178 .collect::<Vec<_>>()
179 .join("\n");
180 let project_files = std::fs::read_dir(project_root)
181 .ok()
182 .map(|entries| {
183 entries
184 .filter_map(|e| e.ok())
185 .filter(|e| {
186 e.path().is_dir()
187 && e.path().file_name().is_some_and(|n| n == "src")
188 })
189 .filter_map(|e| std::fs::read_dir(e.path()).ok())
190 .flat_map(|entries| {
191 entries.filter_map(|e| e.ok()).filter_map(|e| {
192 e.path()
193 .strip_prefix(project_root)
194 .ok()
195 .map(|p| p.to_string_lossy().to_string())
196 })
197 })
198 .collect()
199 })
200 .unwrap_or_default();
201
202 results.push(PropagationResult {
203 selector,
204 reason: format!(
205 "symbol \"{}\" was modified; no LSP available to find references",
206 sym_name
207 ),
208 source: PropagationSource::OpenEnded,
209 lsp_references: None,
210 diff_summary: Some("hash-based edit".to_string()),
211 file_snippet: Some(file_snippet),
212 project_files: Some(project_files),
213 });
214 }
215 } else {
216 for r in lsp_refs {
217 if seen.insert(r.selector.clone()) {
218 results.push(r);
219 }
220 }
221 }
222 }
223
224 results
225}
226
227fn find_symbol_position(content: &str, symbol_name: &str) -> Option<(usize, usize)> {
228 content.lines().enumerate().find_map(|(line_idx, line)| {
229 line.find(symbol_name)
230 .map(|character| (line_idx + 1, character))
231 })
232}
233
234fn normalized_edit_content(content: Option<&crate::api::EditContent>) -> Option<Vec<String>> {
235 content.map(|c| c.clone().into_lines())
236}
237
238pub fn edit_code_apply(
239 edits: &[StructuredEdit],
240 project_root: &Path,
241 lsp_analyzer: &Mutex<Option<Box<dyn Analyzer + Send>>>,
242) -> Result<(Vec<PropagationResult>, AppliedStructuredEditSummary), String> {
243 let analyzer = TreeSitterAnalyzer::new();
244 let prepared = prepare_structured_edits(edits, project_root, &analyzer, true)?;
245 let applied_summary = applied_summary_from_prepared(&prepared);
246 write_prepared_structured_edits(&prepared, Some(lsp_analyzer))?;
247
248 let mut results = Vec::new();
249 for file in &prepared.files {
250 results.extend(collect_propagation_results(
251 PropagationCollectionContext {
252 full_path: &file.full_path,
253 original: &file.original,
254 new_content: &file.new_content,
255 project_root,
256 lsp_analyzer,
257 analyzer: &analyzer,
258 },
259 &file.planned,
260 ));
261 }
262
263 Ok((results, applied_summary))
264}
265
266pub fn edit_file_apply(
267 edits: &[StructuredEdit],
268 project_root: &Path,
269) -> Result<AppliedStructuredEditSummary, String> {
270 let analyzer = TreeSitterAnalyzer::new();
271 let prepared = prepare_structured_edits(edits, project_root, &analyzer, false)?;
272 write_prepared_structured_edits(&prepared, None)?;
273 Ok(applied_summary_from_prepared(&prepared))
274}
275
276fn prepare_structured_edits(
277 edits: &[StructuredEdit],
278 project_root: &Path,
279 analyzer: &TreeSitterAnalyzer,
280 validate_parse: bool,
281) -> Result<PreparedStructuredEdits, String> {
282 if edits.is_empty() {
283 return Err("edits array is empty".to_string());
284 }
285
286 struct EditGroup<'a> {
287 display_path: String,
288 edits: Vec<&'a StructuredEdit>,
289 }
290
291 let mut edits_by_file: HashMap<PathBuf, EditGroup<'_>> = HashMap::new();
292 for edit in edits {
293 let full_path = if std::path::Path::new(&edit.path).is_absolute() {
294 PathBuf::from(&edit.path)
295 } else {
296 project_root.join(&edit.path)
297 };
298 let display_path = display_path_for_edit(project_root, &full_path);
299 edits_by_file
300 .entry(full_path)
301 .and_modify(|group| group.edits.push(edit))
302 .or_insert_with(|| EditGroup {
303 display_path,
304 edits: vec![edit],
305 });
306 }
307
308 let mut prepared_files = Vec::new();
309
310 for (full_path, group) in edits_by_file {
311 let existed = full_path.exists();
312 let original = if existed {
313 std::fs::read_to_string(&full_path)
314 .map_err(|e| format!("cannot read {}: {e}", full_path.display()))?
315 } else {
316 let can_create = group.edits.iter().all(|e| {
318 matches!(e.op, EditOp::Append | EditOp::Prepend)
319 && (e.start == "1#" || (e.start.starts_with("1#") && e.start.len() > 2))
320 });
321 if !can_create {
322 return Err(format!(
323 "cannot create new file {}: only append/prepend at line 1 is allowed",
324 full_path.display()
325 ));
326 }
327 String::new()
328 };
329
330 let mut planned: Vec<PlannedEdit> = Vec::new();
331
332 for edit in group.edits {
333 let (start_line, start_hash) = parse_start_anchor(&edit.start)?;
334
335 if !original.is_empty() {
336 verify_line(&original, start_line, &start_hash)?;
337 }
338
339 let mut primary_symbol_name = None;
340 if validate_parse
341 && !original.is_empty()
342 && let Some(sel) =
343 analyzer.find_containing_symbol(&full_path, start_line, project_root)
344 && let Ok(parsed) = crate::selector::parse_selector(&sel)
345 {
346 primary_symbol_name = parsed.name().map(str::to_string);
347 }
348
349 match edit.op {
350 EditOp::Replace => {
351 let (end_line, end_hash) = match &edit.end {
352 Some(end_anchor) => parse_start_anchor(end_anchor)?,
353 None => {
354 return Err(format!("replace requires `end` anchor for {}", edit.path));
355 }
356 };
357 if end_line < start_line {
358 return Err(format!(
359 "replace end line {} is before start line {} in {}",
360 end_line, start_line, edit.path
361 ));
362 }
363 if !original.is_empty() {
364 verify_line(&original, end_line, &end_hash)?;
365 }
366 let old_count = end_line - start_line + 1;
367 let replacement =
368 normalized_edit_content(edit.content.as_ref()).unwrap_or_default();
369 planned.push(PlannedEdit {
370 start_line,
371 old_count,
372 replacement,
373 primary_symbol_name,
374 });
375 }
376 EditOp::Append => {
377 let replacement =
378 normalized_edit_content(edit.content.as_ref()).unwrap_or_default();
379 let insert_line = if original.is_empty() {
380 1
381 } else {
382 start_line + 1
383 };
384 planned.push(PlannedEdit {
385 start_line: insert_line,
386 old_count: 0,
387 replacement,
388 primary_symbol_name,
389 });
390 }
391 EditOp::Prepend => {
392 let replacement =
393 normalized_edit_content(edit.content.as_ref()).unwrap_or_default();
394 let insert_line = if original.is_empty() { 1 } else { start_line };
395 planned.push(PlannedEdit {
396 start_line: insert_line,
397 old_count: 0,
398 replacement,
399 primary_symbol_name,
400 });
401 }
402 }
403 }
404
405 let new_content =
406 apply_planned_edits_to_content(&original, &planned, &full_path.to_string_lossy())?;
407
408 let ext = full_path.extension().and_then(|e| e.to_str()).unwrap_or("");
409 if validate_parse
410 && !ext.is_empty()
411 && !new_content.is_empty()
412 && !analyzer.can_parse(ext, &new_content)
413 {
414 return Err(format!(
415 "edit rejected: tree-sitter cannot parse the result for {}",
416 full_path.display()
417 ));
418 }
419
420 prepared_files.push(PreparedFileEdits {
421 display_path: group.display_path,
422 full_path,
423 existed,
424 original,
425 new_content,
426 planned,
427 });
428 }
429
430 Ok(PreparedStructuredEdits {
431 files: prepared_files,
432 })
433}
434
435fn write_prepared_structured_edits(
436 prepared: &PreparedStructuredEdits,
437 lsp_analyzer: Option<&Mutex<Option<Box<dyn Analyzer + Send>>>>,
438) -> Result<(), String> {
439 for file in &prepared.files {
440 if let Some(parent) = file.full_path.parent() {
441 std::fs::create_dir_all(parent).map_err(|e| {
442 format!(
443 "cannot create parent dirs for {}: {e}",
444 file.full_path.display()
445 )
446 })?;
447 }
448 std::fs::write(&file.full_path, &file.new_content)
449 .map_err(|e| format!("cannot write {}: {e}", file.full_path.display()))?;
450 if let Some(lsp_analyzer) = lsp_analyzer
451 && let Ok(lsp_guard) = lsp_analyzer.lock()
452 && let Some(ref lsp) = *lsp_guard
453 {
454 lsp.notify_did_change(&file.full_path, 1, &file.new_content);
455 }
456 }
457 Ok(())
458}
459
460fn applied_summary_from_prepared(
461 prepared: &PreparedStructuredEdits,
462) -> AppliedStructuredEditSummary {
463 let files = prepared
464 .files
465 .iter()
466 .map(|file| {
467 let added_lines = file.planned.iter().map(|edit| edit.replacement.len()).sum();
468 let removed_lines = file.planned.iter().map(|edit| edit.old_count).sum();
469 AppliedStructuredEditFile {
470 path: file.display_path.clone(),
471 operation: if file.existed {
472 AppliedStructuredEditOperation::Update
473 } else {
474 AppliedStructuredEditOperation::Add
475 },
476 added_lines,
477 removed_lines,
478 original_content: file.original.clone(),
479 new_content: file.new_content.clone(),
480 }
481 })
482 .collect();
483 AppliedStructuredEditSummary { files }
484}
485
486fn display_path_for_edit(project_root: &Path, full_path: &Path) -> String {
487 if let Ok(relative) = full_path.strip_prefix(project_root) {
488 let relative = relative.to_string_lossy().replace('\\', "/");
489 if !relative.is_empty() {
490 return relative;
491 }
492 }
493 full_path.to_string_lossy().replace('\\', "/")
494}
495
496#[cfg(test)]
497mod e2e_tests {
498 use super::*;
499 use crate::api;
500 use std::io::Write;
501 use std::path::PathBuf;
502
503 fn setup_temp_rust_project() -> tempfile::TempDir {
504 tempfile::tempdir().unwrap()
505 }
506
507 fn write_rust_file(dir: &Path, filename: &str, content: &str) -> (PathBuf, Vec<String>) {
508 let src_dir = dir.join("src");
509 std::fs::create_dir_all(&src_dir).unwrap();
510 let path = src_dir.join(filename);
511 let mut f = std::fs::File::create(&path).unwrap();
512 f.write_all(content.as_bytes()).unwrap();
513
514 let hashes: Vec<String> = content
515 .lines()
516 .enumerate()
517 .map(|(i, line)| format!("{}#{}", i + 1, line_hash(line)))
518 .collect();
519
520 (path, hashes)
521 }
522
523 #[test]
524 fn replace_with_hashes() {
525 let dir = setup_temp_rust_project();
526 let rust_code = "pub fn hello() {\n println!(\"hello\");\n}\n\npub fn world() {\n println!(\"world\");\n}\n";
527 let (_, hashes) = write_rust_file(dir.path(), "lib.rs", rust_code);
528
529 let edits = vec![api::StructuredEdit {
530 path: "src/lib.rs".to_string(),
531 op: api::EditOp::Replace,
532 start: hashes[0].clone(),
533 end: Some(hashes[2].clone()),
534 content: Some(api::EditContent::Lines(vec![
535 "pub fn hello() {".to_string(),
536 " println!(\"hello world\");".to_string(),
537 "}".to_string(),
538 ])),
539 }];
540 let lsp: Mutex<Option<Box<dyn Analyzer + Send>>> = Mutex::new(None);
541 let (propagation, applied_summary) = edit_code_apply(&edits, dir.path(), &lsp).unwrap();
542 assert!(!propagation.is_empty(), "Should have propagation results");
543 assert_eq!(applied_summary.files.len(), 1);
544 assert_eq!(applied_summary.files[0].path, "src/lib.rs");
545 assert_eq!(applied_summary.files[0].added_lines, 3);
546 assert_eq!(applied_summary.files[0].removed_lines, 3);
547 assert!(
548 applied_summary.files[0]
549 .original_content
550 .contains("\"hello\"")
551 );
552 assert!(applied_summary.files[0].new_content.contains("hello world"));
553
554 let modified = std::fs::read_to_string(dir.path().join("src/lib.rs")).unwrap();
555 assert!(modified.contains("hello world"));
556 assert!(!modified.contains("\"hello\""));
557 }
558
559 #[test]
560 fn append_and_replace_delete() {
561 let dir = setup_temp_rust_project();
562 let rust_code = "pub fn keep() {\n println!(\"keep\");\n}\n\npub fn remove_me() {\n println!(\"remove\");\n}\n";
563 let (_, hashes) = write_rust_file(dir.path(), "lib.rs", rust_code);
564 let edits = vec![
569 api::StructuredEdit {
570 path: "src/lib.rs".to_string(),
571 op: api::EditOp::Append,
572 start: hashes[0].clone(),
573 end: None,
574 content: Some(api::EditContent::Lines(vec![
575 "pub fn added() {".to_string(),
576 " println!(\"added\");".to_string(),
577 "}".to_string(),
578 "".to_string(),
579 ])),
580 },
581 api::StructuredEdit {
582 path: "src/lib.rs".to_string(),
583 op: api::EditOp::Replace,
584 start: hashes[4].clone(),
585 end: Some(hashes[6].clone()),
586 content: None, },
588 ];
589 let lsp: Mutex<Option<Box<dyn Analyzer + Send>>> = Mutex::new(None);
590 let (_propagation, applied_summary) = edit_code_apply(&edits, dir.path(), &lsp).unwrap();
591 assert_eq!(applied_summary.files.len(), 1);
592 assert_eq!(applied_summary.files[0].added_lines, 4);
593 assert_eq!(applied_summary.files[0].removed_lines, 3);
594
595 let modified = std::fs::read_to_string(dir.path().join("src/lib.rs")).unwrap();
596 assert!(modified.contains("pub fn added()"));
597 assert!(modified.contains("pub fn keep()"));
598 assert!(!modified.contains("remove_me"));
599 }
600
601 #[test]
602 fn creates_new_file() {
603 let dir = setup_temp_rust_project();
604 std::fs::create_dir_all(dir.path().join("src")).unwrap();
605
606 let edits = vec![api::StructuredEdit {
607 path: "src/new_file.rs".to_string(),
608 op: api::EditOp::Append,
609 start: "1#00".to_string(),
610 end: None,
611 content: Some(api::EditContent::Lines(vec![
612 "pub fn created() {".to_string(),
613 " println!(\"created\");".to_string(),
614 "}".to_string(),
615 ])),
616 }];
617 let lsp: Mutex<Option<Box<dyn Analyzer + Send>>> = Mutex::new(None);
618 let result = edit_code_apply(&edits, dir.path(), &lsp);
619 assert!(
620 result.is_ok(),
621 "new file creation should succeed: {result:?}"
622 );
623 let (_propagation, applied_summary) = result.unwrap();
624 assert_eq!(applied_summary.files.len(), 1);
625 assert_eq!(
626 applied_summary.files[0].operation,
627 AppliedStructuredEditOperation::Add
628 );
629 assert_eq!(applied_summary.files[0].added_lines, 3);
630 assert_eq!(applied_summary.files[0].removed_lines, 0);
631
632 let created = std::fs::read_to_string(dir.path().join("src/new_file.rs")).unwrap();
633 assert!(created.contains("pub fn created()"));
634 }
635
636 #[test]
637 fn hash_mismatch_rejects_edit() {
638 let dir = setup_temp_rust_project();
639 let rust_code = "pub fn hello() {\n println!(\"hello\");\n}\n";
640 write_rust_file(dir.path(), "lib.rs", rust_code);
641
642 let edits = vec![api::StructuredEdit {
643 path: "src/lib.rs".to_string(),
644 op: api::EditOp::Replace,
645 start: "1#ff".to_string(), end: Some("3#ff".to_string()), content: Some(api::EditContent::Text(
648 "pub fn hello() {\n println!(\"goodbye\");\n}\n".to_string(),
649 )),
650 }];
651 let lsp: Mutex<Option<Box<dyn Analyzer + Send>>> = Mutex::new(None);
652 let err = edit_code_apply(&edits, dir.path(), &lsp).unwrap_err();
653 assert!(
654 err.contains("hash mismatch"),
655 "expected hash mismatch, got: {err}"
656 );
657
658 let unchanged = std::fs::read_to_string(dir.path().join("src/lib.rs")).unwrap();
659 assert_eq!(unchanged, rust_code);
660 }
661
662 #[test]
663 fn rejects_empty_edits() {
664 let dir = setup_temp_rust_project();
665 let lsp: Mutex<Option<Box<dyn Analyzer + Send>>> = Mutex::new(None);
666 let err = edit_code_apply(&[], dir.path(), &lsp).unwrap_err();
667 assert!(err.contains("empty"));
668 }
669
670 #[test]
671 fn replace_requires_end() {
672 let dir = setup_temp_rust_project();
673 let rust_code = "pub fn hello() {\n println!(\"hello\");\n}\n";
674 let (_, hashes) = write_rust_file(dir.path(), "lib.rs", rust_code);
675
676 let edits = vec![api::StructuredEdit {
677 path: "src/lib.rs".to_string(),
678 op: api::EditOp::Replace,
679 start: hashes[0].clone(),
680 end: None, content: Some(api::EditContent::Text("replaced".to_string())),
682 }];
683 let lsp: Mutex<Option<Box<dyn Analyzer + Send>>> = Mutex::new(None);
684 let err = edit_code_apply(&edits, dir.path(), &lsp).unwrap_err();
685 assert!(err.contains("requires `end`"));
686 }
687}