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 semantic_source: bool,
29}
30
31struct PreparedStructuredEdits {
32 files: Vec<PreparedFileEdits>,
33}
34
35#[must_use]
36pub fn line_hash(line_content: &str) -> String {
37 let mut hasher = Sha256::new();
38 hasher.update(line_content.as_bytes());
39 let result = hasher.finalize();
40 format!("{:02x}", result[0])
41}
42
43fn parse_start_anchor(anchor: &str) -> Result<(usize, String), String> {
44 let (line_str, hash_str) = anchor
45 .split_once('#')
46 .ok_or_else(|| format!("invalid start anchor (expected line#hash): {anchor}"))?;
47 let line = line_str
48 .parse::<usize>()
49 .map_err(|_| format!("invalid line number in anchor: {anchor}"))?;
50 if line == 0 {
51 return Err(format!("line number must be >= 1 in anchor: {anchor}"));
52 }
53 Ok((line, hash_str.to_string()))
54}
55
56fn verify_line(content: &str, line_num: usize, expected_hash: &str) -> Result<(), String> {
57 let lines: Vec<&str> = content.lines().collect();
58 if line_num > lines.len() {
59 return Err(format!(
60 "line {line_num} out of bounds (file has {} lines)",
61 lines.len()
62 ));
63 }
64 let actual = lines[line_num - 1];
65 let actual_hash = line_hash(actual);
66 if actual_hash != expected_hash {
67 return Err(format!(
68 "line {line_num} hash mismatch: expected {expected_hash}, got {actual_hash} — file may have changed; re-read before editing"
69 ));
70 }
71 Ok(())
72}
73
74fn apply_planned_edits_to_content(
75 original: &str,
76 edits: &[PlannedEdit],
77 file_display: &str,
78) -> Result<String, String> {
79 let mut sorted: Vec<&PlannedEdit> = edits.iter().collect();
80 sorted.sort_by_key(|edit| edit.start_line);
81 for pair in sorted.windows(2) {
82 let prev_end = pair[0].start_line + pair[0].old_count;
83 if pair[1].start_line < prev_end {
84 return Err(format!(
85 "overlapping edits in {}: edit at line {} overlaps previous edit ending at line {}",
86 file_display, pair[1].start_line, prev_end
87 ));
88 }
89 }
90
91 let mut lines = original.lines().map(str::to_string).collect::<Vec<_>>();
92 for edit in sorted.iter().rev() {
93 let start_idx = edit.start_line.saturating_sub(1);
94 let end_idx = start_idx + edit.old_count;
95 if start_idx > lines.len() || end_idx > lines.len() {
96 return Err(format!(
97 "edit exceeds file bounds in {}: lines {}-{} but file has {} lines",
98 file_display,
99 edit.start_line,
100 edit.start_line + edit.old_count,
101 lines.len()
102 ));
103 }
104 lines.splice(start_idx..end_idx, edit.replacement.clone());
105 }
106
107 if lines.is_empty() {
108 Ok(String::new())
109 } else {
110 Ok(lines.join("\n") + "\n")
111 }
112}
113
114struct PropagationCollectionContext<'a> {
115 full_path: &'a Path,
116 original: &'a str,
117 new_content: &'a str,
118 project_root: &'a Path,
119 lsp_analyzer: &'a Mutex<Option<Box<dyn Analyzer + Send>>>,
120 analyzer: &'a TreeSitterAnalyzer,
121}
122
123fn collect_propagation_results(
124 context: &PropagationCollectionContext<'_>,
125 edits: &[PlannedEdit],
126) -> Vec<PropagationResult> {
127 let PropagationCollectionContext {
128 full_path,
129 original,
130 new_content,
131 project_root,
132 lsp_analyzer,
133 analyzer,
134 } = context;
135
136 let mut results: Vec<PropagationResult> = Vec::new();
137 let mut seen: HashSet<String> = HashSet::new();
138 let mut modified_symbol_names = HashSet::new();
139
140 for edit in edits {
141 if let Some(ref name) = edit.primary_symbol_name {
142 modified_symbol_names.insert(name.clone());
143 }
144 if let Some(sel) = analyzer.find_containing_symbol(full_path, edit.start_line, project_root)
145 && let Ok(parsed) = crate::selector::parse_selector(&sel)
146 && let Some(name) = parsed.name()
147 {
148 modified_symbol_names.insert(name.to_string());
149 }
150 }
151
152 let lsp_references_for = |symbol_name: &str| {
153 let Ok(lsp_guard) = lsp_analyzer.lock() else {
154 return Vec::new();
155 };
156 let Some(lsp) = &*lsp_guard else {
157 return Vec::new();
158 };
159 let (line, character) =
160 find_symbol_position(new_content, symbol_name).unwrap_or_else(|| {
161 let hint_line = edits.first().map_or(1, |edit| edit.start_line);
162 (hint_line, 0)
163 });
164 lsp.find_references_for_symbol(full_path, line, character, project_root)
165 };
166
167 for sym_name in &modified_symbol_names {
168 let lsp_refs = lsp_references_for(sym_name);
169 if lsp_refs.is_empty() {
170 let rel = normalize_for_comparison(full_path)
171 .strip_prefix(normalize_for_comparison(project_root))
172 .ok()
173 .map_or_else(
174 || full_path.to_string_lossy().to_string(),
175 |p| p.to_string_lossy().to_string(),
176 );
177 let selector = format!("{rel}::{sym_name}");
178 if seen.insert(selector.clone()) {
179 let first_line = edits.first().map_or(1, |edit| edit.start_line);
180 let file_snippet = original
181 .lines()
182 .skip(first_line.saturating_sub(3))
183 .take(7)
184 .collect::<Vec<_>>()
185 .join("\n");
186 let project_files = std::fs::read_dir(project_root)
187 .ok()
188 .map(|entries| {
189 entries
190 .filter_map(std::result::Result::ok)
191 .filter(|e| {
192 e.path().is_dir()
193 && e.path().file_name().is_some_and(|n| n == "src")
194 })
195 .filter_map(|e| std::fs::read_dir(e.path()).ok())
196 .flat_map(|entries| {
197 let normalized_root = normalize_for_comparison(project_root);
198 entries
199 .filter_map(std::result::Result::ok)
200 .filter_map(move |e| {
201 normalize_for_comparison(&e.path())
202 .strip_prefix(&normalized_root)
203 .ok()
204 .map(|p| p.to_string_lossy().to_string())
205 })
206 })
207 .collect()
208 })
209 .unwrap_or_default();
210
211 results.push(PropagationResult {
212 selector,
213 reason: format!(
214 "symbol \"{sym_name}\" was modified; no LSP available to find references"
215 ),
216 source: PropagationSource::OpenEnded,
217 lsp_references: None,
218 diff_summary: Some("hash-based edit".to_string()),
219 file_snippet: Some(file_snippet),
220 project_files: Some(project_files),
221 });
222 }
223 } else {
224 for r in lsp_refs {
225 if seen.insert(r.selector.clone()) {
226 results.push(r);
227 }
228 }
229 }
230 }
231
232 results
233}
234
235fn find_symbol_position(content: &str, symbol_name: &str) -> Option<(usize, usize)> {
236 content.lines().enumerate().find_map(|(line_idx, line)| {
237 line.find(symbol_name)
238 .map(|character| (line_idx + 1, character))
239 })
240}
241
242pub fn edit_code_apply(
248 edits: &[StructuredEdit],
249 project_root: &Path,
250 lsp_analyzer: &Mutex<Option<Box<dyn Analyzer + Send>>>,
251) -> Result<(Vec<PropagationResult>, AppliedStructuredEditSummary), String> {
252 let analyzer = TreeSitterAnalyzer::new();
253 let prepared = prepare_structured_edits(edits, project_root, &analyzer, true)?;
254 let applied_summary = applied_summary_from_prepared(&prepared);
255 write_prepared_structured_edits(&prepared, Some(lsp_analyzer))?;
256
257 let mut results = Vec::new();
258 for file in &prepared.files {
259 if file.semantic_source {
260 results.extend(collect_propagation_results(
261 &PropagationCollectionContext {
262 full_path: &file.full_path,
263 original: &file.original,
264 new_content: &file.new_content,
265 project_root,
266 lsp_analyzer,
267 analyzer: &analyzer,
268 },
269 &file.planned,
270 ));
271 }
272 }
273
274 Ok((results, applied_summary))
275}
276
277pub fn edit_file_apply(
283 edits: &[StructuredEdit],
284 project_root: &Path,
285) -> Result<AppliedStructuredEditSummary, String> {
286 let analyzer = TreeSitterAnalyzer::new();
287 let prepared = prepare_structured_edits(edits, project_root, &analyzer, false)?;
288 write_prepared_structured_edits(&prepared, None)?;
289 Ok(applied_summary_from_prepared(&prepared))
290}
291
292struct PreparedEditContext<'a> {
293 project_root: &'a Path,
294 analyzer: &'a TreeSitterAnalyzer,
295 validate_parse: bool,
296}
297
298struct EditGroup<'a> {
299 display_path: String,
300 edits: Vec<&'a StructuredEdit>,
301}
302
303fn group_edits_by_file<'a>(
304 edits: &'a [StructuredEdit],
305 project_root: &Path,
306) -> HashMap<PathBuf, EditGroup<'a>> {
307 let mut edits_by_file = HashMap::new();
308 for edit in edits {
309 let full_path = if Path::new(&edit.path).is_absolute() {
310 PathBuf::from(&edit.path)
311 } else {
312 project_root.join(&edit.path)
313 };
314 let display_path = display_path_for_edit(project_root, &full_path);
315 edits_by_file
316 .entry(full_path)
317 .and_modify(|group: &mut EditGroup<'a>| group.edits.push(edit))
318 .or_insert_with(|| EditGroup {
319 display_path,
320 edits: vec![edit],
321 });
322 }
323 edits_by_file
324}
325
326fn read_original_content(
327 group: &EditGroup<'_>,
328 full_path: &Path,
329) -> Result<(bool, String), String> {
330 if full_path.exists() {
331 return std::fs::read_to_string(full_path)
332 .map(|content| (true, content))
333 .map_err(|error| format!("cannot read {}: {error}", full_path.display()));
334 }
335
336 for edit in &group.edits {
337 let start_valid = edit
338 .start
339 .as_deref()
340 .is_none_or(|start| start == "1#" || start.starts_with("1#"));
341 if !start_valid {
342 return Err(format!(
343 "cannot create new file {}: start anchor must be `1#`",
344 full_path.display()
345 ));
346 }
347 }
348 Ok((false, String::new()))
349}
350
351fn edit_operation(edit: &StructuredEdit, original_is_empty: bool) -> Result<EditOp, String> {
352 match (edit.op.clone(), original_is_empty) {
353 (Some(EditOp::Replace) | None, true) => Ok(EditOp::Append),
354 (Some(operation), _) => Ok(operation),
355 (None, false) => Err(format!(
356 "op is required for editing existing file {}",
357 edit.path
358 )),
359 }
360}
361
362fn edit_start_anchor(edit: &StructuredEdit, original_is_empty: bool) -> Result<String, String> {
363 match &edit.start {
364 Some(start) => Ok(start.clone()),
365 None if original_is_empty => Ok("1#".to_string()),
366 None => Err(format!(
367 "start anchor is required for editing existing file {}",
368 edit.path
369 )),
370 }
371}
372
373fn replacement_lines(edit: &StructuredEdit) -> Vec<String> {
374 edit.content
375 .as_ref()
376 .map(|content| content.clone().into_lines())
377 .unwrap_or_default()
378}
379
380fn planned_edit_for(
381 edit: &StructuredEdit,
382 full_path: &Path,
383 original: &str,
384 context: &PreparedEditContext<'_>,
385 semantic_source: bool,
386) -> Result<PlannedEdit, String> {
387 let operation = edit_operation(edit, original.is_empty())?;
388 let start = edit_start_anchor(edit, original.is_empty())?;
389 let (start_line, start_hash) = parse_start_anchor(&start)?;
390 if !original.is_empty() {
391 verify_line(original, start_line, &start_hash)?;
392 }
393
394 let primary_symbol_name = if semantic_source && !original.is_empty() {
395 context
396 .analyzer
397 .find_containing_symbol(full_path, start_line, context.project_root)
398 .and_then(|selector| crate::selector::parse_selector(&selector).ok())
399 .and_then(|parsed| parsed.name().map(str::to_string))
400 } else {
401 None
402 };
403
404 let replacement = replacement_lines(edit);
405 match operation {
406 EditOp::Replace => {
407 let end_anchor = edit
408 .end
409 .as_deref()
410 .ok_or_else(|| format!("replace requires `end` anchor for {}", edit.path))?;
411 let (end_line, end_hash) = parse_start_anchor(end_anchor)?;
412 if end_line < start_line {
413 return Err(format!(
414 "replace end line {} is before start line {} in {}",
415 end_line, start_line, edit.path
416 ));
417 }
418 if !original.is_empty() {
419 verify_line(original, end_line, &end_hash)?;
420 }
421 Ok(PlannedEdit {
422 start_line,
423 old_count: end_line - start_line + 1,
424 replacement,
425 primary_symbol_name,
426 })
427 }
428 EditOp::Append => Ok(PlannedEdit {
429 start_line: if original.is_empty() {
430 1
431 } else {
432 start_line + 1
433 },
434 old_count: 0,
435 replacement,
436 primary_symbol_name,
437 }),
438 EditOp::Prepend => Ok(PlannedEdit {
439 start_line: if original.is_empty() { 1 } else { start_line },
440 old_count: 0,
441 replacement,
442 primary_symbol_name,
443 }),
444 }
445}
446
447fn prepare_file_edits(
448 full_path: PathBuf,
449 group: EditGroup<'_>,
450 context: &PreparedEditContext<'_>,
451) -> Result<PreparedFileEdits, String> {
452 let (existed, original) = read_original_content(&group, &full_path)?;
453 let semantic_source =
454 context.validate_parse && context.analyzer.is_responsible_source_path(&full_path);
455 let planned = group
456 .edits
457 .into_iter()
458 .map(|edit| planned_edit_for(edit, &full_path, &original, context, semantic_source))
459 .collect::<Result<Vec<_>, _>>()?;
460 let new_content =
461 apply_planned_edits_to_content(&original, &planned, &full_path.to_string_lossy())?;
462 let extension = full_path
463 .extension()
464 .and_then(|extension| extension.to_str());
465 if semantic_source
466 && !new_content.is_empty()
467 && let Some(diagnostic) = context
468 .analyzer
469 .parse_error_diagnostic(extension.expect("checked above"), &new_content)
470 {
471 return Err(format!(
472 "edit rejected: tree-sitter cannot parse the result for {}\n{}",
473 full_path.display(),
474 diagnostic.message()
475 ));
476 }
477
478 Ok(PreparedFileEdits {
479 display_path: group.display_path,
480 full_path,
481 existed,
482 original,
483 new_content,
484 planned,
485 semantic_source,
486 })
487}
488
489fn prepare_structured_edits(
490 edits: &[StructuredEdit],
491 project_root: &Path,
492 analyzer: &TreeSitterAnalyzer,
493 validate_parse: bool,
494) -> Result<PreparedStructuredEdits, String> {
495 if edits.is_empty() {
496 return Err("edits array is empty".to_string());
497 }
498
499 let context = PreparedEditContext {
500 project_root,
501 analyzer,
502 validate_parse,
503 };
504 let files = group_edits_by_file(edits, project_root)
505 .into_iter()
506 .map(|(full_path, group)| prepare_file_edits(full_path, group, &context))
507 .collect::<Result<Vec<_>, _>>()?;
508 Ok(PreparedStructuredEdits { files })
509}
510
511fn write_prepared_structured_edits(
512 prepared: &PreparedStructuredEdits,
513 lsp_analyzer: Option<&Mutex<Option<Box<dyn Analyzer + Send>>>>,
514) -> Result<(), String> {
515 for file in &prepared.files {
516 if let Some(parent) = file.full_path.parent() {
517 std::fs::create_dir_all(parent).map_err(|e| {
518 format!(
519 "cannot create parent dirs for {}: {e}",
520 file.full_path.display()
521 )
522 })?;
523 }
524 std::fs::write(&file.full_path, &file.new_content)
525 .map_err(|e| format!("cannot write {}: {e}", file.full_path.display()))?;
526 if file.semantic_source
527 && let Some(lsp_analyzer) = lsp_analyzer
528 && let Ok(lsp_guard) = lsp_analyzer.lock()
529 && let Some(ref lsp) = *lsp_guard
530 {
531 lsp.notify_did_change(&file.full_path, 1, &file.new_content);
532 }
533 }
534 Ok(())
535}
536
537fn applied_summary_from_prepared(
538 prepared: &PreparedStructuredEdits,
539) -> AppliedStructuredEditSummary {
540 let files = prepared
541 .files
542 .iter()
543 .map(|file| {
544 let added_lines = file.planned.iter().map(|edit| edit.replacement.len()).sum();
545 let removed_lines = file.planned.iter().map(|edit| edit.old_count).sum();
546 AppliedStructuredEditFile {
547 path: file.display_path.clone(),
548 operation: if file.existed {
549 AppliedStructuredEditOperation::Update
550 } else {
551 AppliedStructuredEditOperation::Add
552 },
553 added_lines,
554 removed_lines,
555 original_content: file.original.clone(),
556 new_content: file.new_content.clone(),
557 }
558 })
559 .collect();
560 AppliedStructuredEditSummary { files }
561}
562
563fn display_path_for_edit(project_root: &Path, full_path: &Path) -> String {
564 let normalized_root = normalize_for_comparison(project_root);
565 let normalized_path = normalize_for_comparison(full_path);
566 if let Ok(relative) = normalized_path.strip_prefix(&normalized_root) {
567 let relative = relative.to_string_lossy().replace('\\', "/");
568 if !relative.is_empty() {
569 return relative;
570 }
571 }
572 full_path.to_string_lossy().replace('\\', "/")
573}
574
575fn normalize_for_comparison(p: &Path) -> PathBuf {
576 let s = p.to_string_lossy();
577 s.strip_prefix(r"\\?\")
578 .map_or_else(|| p.to_path_buf(), PathBuf::from)
579}
580
581#[cfg(test)]
582mod e2e_tests {
583 use super::*;
584 use crate::api;
585 use std::io::Write;
586 use std::path::PathBuf;
587
588 fn setup_temp_rust_project() -> tempfile::TempDir {
589 tempfile::tempdir().unwrap()
590 }
591
592 fn write_rust_file(dir: &Path, filename: &str, content: &str) -> (PathBuf, Vec<String>) {
593 let src_dir = dir.join("src");
594 std::fs::create_dir_all(&src_dir).unwrap();
595 let path = src_dir.join(filename);
596 let mut f = std::fs::File::create(&path).unwrap();
597 f.write_all(content.as_bytes()).unwrap();
598
599 let hashes: Vec<String> = content
600 .lines()
601 .enumerate()
602 .map(|(i, line)| format!("{}#{}", i + 1, line_hash(line)))
603 .collect();
604
605 (path, hashes)
606 }
607
608 #[test]
609 fn replace_with_hashes() {
610 let dir = setup_temp_rust_project();
611 let rust_code = "pub fn hello() {\n println!(\"hello\");\n}\n\npub fn world() {\n println!(\"world\");\n}\n";
612 let (_, hashes) = write_rust_file(dir.path(), "lib.rs", rust_code);
613
614 let edits = vec![api::StructuredEdit {
615 path: "src/lib.rs".to_string(),
616 op: Some(api::EditOp::Replace),
617 start: Some(hashes[0].clone()),
618 end: Some(hashes[2].clone()),
619 content: Some(api::EditContent::Lines(vec![
620 "pub fn hello() {".to_string(),
621 " println!(\"hello world\");".to_string(),
622 "}".to_string(),
623 ])),
624 }];
625 let lsp: Mutex<Option<Box<dyn Analyzer + Send>>> = Mutex::new(None);
626 let (propagation, applied_summary) = edit_code_apply(&edits, dir.path(), &lsp).unwrap();
627 assert!(!propagation.is_empty(), "Should have propagation results");
628 assert_eq!(applied_summary.files.len(), 1);
629 assert_eq!(applied_summary.files[0].path, "src/lib.rs");
630 assert_eq!(applied_summary.files[0].added_lines, 3);
631 assert_eq!(applied_summary.files[0].removed_lines, 3);
632 assert!(
633 applied_summary.files[0]
634 .original_content
635 .contains("\"hello\"")
636 );
637 assert!(applied_summary.files[0].new_content.contains("hello world"));
638
639 let modified = std::fs::read_to_string(dir.path().join("src/lib.rs")).unwrap();
640 assert!(modified.contains("hello world"));
641 assert!(!modified.contains("\"hello\""));
642 }
643
644 #[test]
645 fn append_and_replace_delete() {
646 let dir = setup_temp_rust_project();
647 let rust_code = "pub fn keep() {\n println!(\"keep\");\n}\n\npub fn remove_me() {\n println!(\"remove\");\n}\n";
648 let (_, hashes) = write_rust_file(dir.path(), "lib.rs", rust_code);
649 let edits = vec![
654 api::StructuredEdit {
655 path: "src/lib.rs".to_string(),
656 op: Some(api::EditOp::Append),
657 start: Some(hashes[0].clone()),
658 end: None,
659 content: Some(api::EditContent::Lines(vec![
660 "pub fn added() {".to_string(),
661 " println!(\"added\");".to_string(),
662 "}".to_string(),
663 String::new(),
664 ])),
665 },
666 api::StructuredEdit {
667 path: "src/lib.rs".to_string(),
668 op: Some(api::EditOp::Replace),
669 start: Some(hashes[4].clone()),
670 end: Some(hashes[6].clone()),
671 content: None, },
673 ];
674 let lsp: Mutex<Option<Box<dyn Analyzer + Send>>> = Mutex::new(None);
675 let (_propagation, applied_summary) = edit_code_apply(&edits, dir.path(), &lsp).unwrap();
676 assert_eq!(applied_summary.files.len(), 1);
677 assert_eq!(applied_summary.files[0].added_lines, 4);
678 assert_eq!(applied_summary.files[0].removed_lines, 3);
679
680 let modified = std::fs::read_to_string(dir.path().join("src/lib.rs")).unwrap();
681 assert!(modified.contains("pub fn added()"));
682 assert!(modified.contains("pub fn keep()"));
683 assert!(!modified.contains("remove_me"));
684 }
685
686 #[test]
687 fn creates_new_file() {
688 let dir = setup_temp_rust_project();
689 std::fs::create_dir_all(dir.path().join("src")).unwrap();
690
691 let edits = vec![api::StructuredEdit {
692 path: "src/new_file.rs".to_string(),
693 op: Some(api::EditOp::Append),
694 start: Some("1#00".to_string()),
695 end: None,
696 content: Some(api::EditContent::Lines(vec![
697 "pub fn created() {".to_string(),
698 " println!(\"created\");".to_string(),
699 "}".to_string(),
700 ])),
701 }];
702 let lsp: Mutex<Option<Box<dyn Analyzer + Send>>> = Mutex::new(None);
703 let result = edit_code_apply(&edits, dir.path(), &lsp);
704 assert!(
705 result.is_ok(),
706 "new file creation should succeed: {result:?}"
707 );
708 let (_propagation, applied_summary) = result.unwrap();
709 assert_eq!(applied_summary.files.len(), 1);
710 assert_eq!(
711 applied_summary.files[0].operation,
712 AppliedStructuredEditOperation::Add
713 );
714 assert_eq!(applied_summary.files[0].added_lines, 3);
715 assert_eq!(applied_summary.files[0].removed_lines, 0);
716
717 let created = std::fs::read_to_string(dir.path().join("src/new_file.rs")).unwrap();
718 assert!(created.contains("pub fn created()"));
719 }
720
721 #[test]
722 fn hash_mismatch_rejects_edit() {
723 let dir = setup_temp_rust_project();
724 let rust_code = "pub fn hello() {\n println!(\"hello\");\n}\n";
725 write_rust_file(dir.path(), "lib.rs", rust_code);
726
727 let edits = vec![api::StructuredEdit {
728 path: "src/lib.rs".to_string(),
729 op: Some(api::EditOp::Replace),
730 start: Some("1#ff".to_string()), end: Some("3#ff".to_string()), content: Some(api::EditContent::Text(
733 "pub fn hello() {\n println!(\"goodbye\");\n}\n".to_string(),
734 )),
735 }];
736 let lsp: Mutex<Option<Box<dyn Analyzer + Send>>> = Mutex::new(None);
737 let err = edit_code_apply(&edits, dir.path(), &lsp).unwrap_err();
738 assert!(
739 err.contains("hash mismatch"),
740 "expected hash mismatch, got: {err}"
741 );
742
743 let unchanged = std::fs::read_to_string(dir.path().join("src/lib.rs")).unwrap();
744 assert_eq!(unchanged, rust_code);
745 }
746
747 #[test]
748 fn parse_rejection_reports_tree_sitter_error_location() {
749 let dir = setup_temp_rust_project();
750 let rust_code = "pub fn hello() {\n println!(\"hello\");\n}\n";
751 let (_, hashes) = write_rust_file(dir.path(), "lib.rs", rust_code);
752
753 let edits = vec![api::StructuredEdit {
754 path: "src/lib.rs".to_string(),
755 op: Some(api::EditOp::Replace),
756 start: Some(hashes[0].clone()),
757 end: Some(hashes[2].clone()),
758 content: Some(api::EditContent::Lines(vec![
759 "pub fn hello() {".to_string(),
760 " println!(\"hello\");".to_string(),
761 ])),
762 }];
763 let lsp: Mutex<Option<Box<dyn Analyzer + Send>>> = Mutex::new(None);
764 let err = edit_code_apply(&edits, dir.path(), &lsp).unwrap_err();
765
766 assert!(err.contains("tree-sitter cannot parse the result"));
767 assert!(err.contains("src\\lib.rs") || err.contains("src/lib.rs"));
768 assert!(err.contains("first parse error:"));
769 assert!(err.contains('L'));
770 assert!(err.contains('C'));
771 assert!(err.contains("println!"));
772 assert!(err.contains('^'));
773
774 let unchanged = std::fs::read_to_string(dir.path().join("src/lib.rs")).unwrap();
775 assert_eq!(unchanged, rust_code);
776 }
777
778 #[test]
779 fn unsupported_file_falls_back_to_plain_structured_edit() {
780 let dir = setup_temp_rust_project();
781 let markdown = "# Title\n\nOriginal text.\n";
782 let path = dir.path().join("README.md");
783 std::fs::write(&path, markdown).unwrap();
784 let start = format!("3#{}", line_hash("Original text."));
785 let edits = vec![api::StructuredEdit {
786 path: "README.md".to_string(),
787 op: Some(api::EditOp::Replace),
788 start: Some(start.clone()),
789 end: Some(start),
790 content: Some(api::EditContent::Text("Updated text.".to_string())),
791 }];
792 let lsp: Mutex<Option<Box<dyn Analyzer + Send>>> = Mutex::new(None);
793
794 let (propagation, applied_summary) =
795 edit_code_apply(&edits, dir.path(), &lsp).expect("edit unsupported file");
796
797 assert!(propagation.is_empty());
798 assert_eq!(applied_summary.files.len(), 1);
799 assert_eq!(applied_summary.files[0].path, "README.md");
800 assert_eq!(
801 std::fs::read_to_string(path).unwrap(),
802 "# Title\n\nUpdated text.\n"
803 );
804 }
805
806 #[test]
807 fn mixed_supported_and_unsupported_files_keep_semantic_analysis_for_source() {
808 let dir = setup_temp_rust_project();
809 let rust_code = "pub fn hello() {\n println!(\"hello\");\n}\n";
810 let (_, hashes) = write_rust_file(dir.path(), "lib.rs", rust_code);
811 let markdown = "Before\n";
812 std::fs::write(dir.path().join("README.md"), markdown).unwrap();
813 let markdown_anchor = format!("1#{}", line_hash("Before"));
814 let edits = vec![
815 api::StructuredEdit {
816 path: "src/lib.rs".to_string(),
817 op: Some(api::EditOp::Replace),
818 start: Some(hashes[0].clone()),
819 end: Some(hashes[2].clone()),
820 content: Some(api::EditContent::Text(
821 "pub fn hello() {\n println!(\"updated\");\n}\n".to_string(),
822 )),
823 },
824 api::StructuredEdit {
825 path: "README.md".to_string(),
826 op: Some(api::EditOp::Replace),
827 start: Some(markdown_anchor.clone()),
828 end: Some(markdown_anchor),
829 content: Some(api::EditContent::Text("After".to_string())),
830 },
831 ];
832 let lsp: Mutex<Option<Box<dyn Analyzer + Send>>> = Mutex::new(None);
833
834 let (propagation, applied_summary) =
835 edit_code_apply(&edits, dir.path(), &lsp).expect("edit mixed files");
836
837 assert!(!propagation.is_empty());
838 assert_eq!(applied_summary.files.len(), 2);
839 assert_eq!(
840 std::fs::read_to_string(dir.path().join("README.md")).unwrap(),
841 "After\n"
842 );
843 assert!(
844 std::fs::read_to_string(dir.path().join("src/lib.rs"))
845 .unwrap()
846 .contains("updated")
847 );
848 }
849
850 #[test]
851 fn rejects_empty_edits() {
852 let dir = setup_temp_rust_project();
853 let lsp: Mutex<Option<Box<dyn Analyzer + Send>>> = Mutex::new(None);
854 let err = edit_code_apply(&[], dir.path(), &lsp).unwrap_err();
855 assert!(err.contains("empty"));
856 }
857
858 #[test]
859 fn replace_requires_end() {
860 let dir = setup_temp_rust_project();
861 let rust_code = "pub fn hello() {\n println!(\"hello\");\n}\n";
862 let (_, hashes) = write_rust_file(dir.path(), "lib.rs", rust_code);
863
864 let edits = vec![api::StructuredEdit {
865 path: "src/lib.rs".to_string(),
866 op: Some(api::EditOp::Replace),
867 start: Some(hashes[0].clone()),
868 end: None, content: Some(api::EditContent::Text("replaced".to_string())),
870 }];
871 let lsp: Mutex<Option<Box<dyn Analyzer + Send>>> = Mutex::new(None);
872 let err = edit_code_apply(&edits, dir.path(), &lsp).unwrap_err();
873 assert!(err.contains("requires `end`"));
874 }
875}