Skip to main content

scope_engine/
patch.rs

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            // New file creation: only allow if all edits are Append to line 1
317            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        // Line 1: "pub fn keep() {" -> hashes[0]
565        // Line 5: "pub fn remove_me() {" -> hashes[4]
566        // Line 7: "}" -> hashes[6]
567
568        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, // delete
587            },
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(),     // wrong hash
646            end: Some("3#ff".to_string()), // wrong hash
647            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, // missing end
681            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}