Skip to main content

roder_edit_core/
replace.rs

1use serde::{Deserialize, Serialize};
2
3use crate::fuzzy::{
4    FuzzyCandidate, diagnostic_candidates, normalized_unique_match_range,
5    strip_line_number_prefixes,
6};
7use crate::hunks::{EditHunk, text_edit_hunk};
8use crate::{EditToolResult, TextEdit};
9
10#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
11#[serde(rename_all = "snake_case")]
12pub enum EditMatchMode {
13    Off,
14    Diagnose,
15    ApplySafe,
16}
17
18#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
19pub struct EditOptions {
20    pub fuzzy: EditMatchMode,
21    pub strip_line_numbers: bool,
22    /**
23     * Bounded indentation normalization for inserted/replaced code: when the
24     * replaced text is uniformly indented and the replacement omitted that
25     * indentation entirely, shift the replacement right to match. Off by
26     * default; hosts opt in per call.
27     */
28    #[serde(default)]
29    pub reindent_inserted: bool,
30}
31
32impl Default for EditOptions {
33    fn default() -> Self {
34        Self {
35            fuzzy: EditMatchMode::Diagnose,
36            strip_line_numbers: true,
37            reindent_inserted: false,
38        }
39    }
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
43pub enum EditApplyError {
44    OldStringNotFound {
45        edit: Option<usize>,
46        candidates: Vec<FuzzyCandidate>,
47    },
48    OldStringAmbiguous {
49        edit: Option<usize>,
50        occurrences: usize,
51        candidates: Vec<FuzzyCandidate>,
52    },
53}
54
55pub fn apply_edit(
56    path: impl Into<String>,
57    text: &str,
58    old_string: &str,
59    new_string: &str,
60    options: EditOptions,
61) -> Result<(String, EditToolResult), EditApplyError> {
62    let edit = TextEdit {
63        old_string: old_string.to_string(),
64        new_string: new_string.to_string(),
65    };
66    apply_multi_edit(path, text, &[edit], options)
67}
68
69pub fn apply_multi_edit(
70    path: impl Into<String>,
71    text: &str,
72    edits: &[TextEdit],
73    options: EditOptions,
74) -> Result<(String, EditToolResult), EditApplyError> {
75    let path = path.into();
76    let mut updated = text.to_string();
77    let mut hunks = Vec::new();
78    for (index, edit) in edits.iter().enumerate() {
79        let old_string = if options.strip_line_numbers {
80            strip_line_number_prefixes(&edit.old_string)
81        } else {
82            edit.old_string.clone()
83        };
84        let matches = match_positions(&updated, &old_string);
85        let range = match matches.as_slice() {
86            [position] => *position..*position + old_string.len(),
87            [] => match options.fuzzy {
88                EditMatchMode::Off | EditMatchMode::Diagnose => {
89                    return Err(EditApplyError::OldStringNotFound {
90                        edit: Some(index),
91                        candidates: diagnostic_candidates(&updated, &old_string, 3),
92                    });
93                }
94                EditMatchMode::ApplySafe => normalized_unique_match_range(&updated, &old_string)
95                    .ok_or_else(|| EditApplyError::OldStringNotFound {
96                        edit: Some(index),
97                        candidates: diagnostic_candidates(&updated, &old_string, 3),
98                    })?,
99            },
100            _ => {
101                return Err(EditApplyError::OldStringAmbiguous {
102                    edit: Some(index),
103                    occurrences: matches.len(),
104                    candidates: diagnostic_candidates(&updated, &old_string, 3),
105                });
106            }
107        };
108        // Use the actual matched text (which may differ from old_string under
109        // fuzzy recovery) for reindent context and hunk/reverse-patch data.
110        let matched_old = updated[range.clone()].to_string();
111        let new_string = if options.reindent_inserted {
112            crate::post_edit::normalize_inserted_indentation(&matched_old, &edit.new_string)
113        } else {
114            edit.new_string.clone()
115        };
116        updated.replace_range(range, &new_string);
117        hunks.push(text_edit_hunk(&path, &matched_old, &new_string, index));
118    }
119    Ok((
120        updated,
121        EditToolResult {
122            path,
123            replacements: edits.len(),
124            hunks,
125        },
126    ))
127}
128
129fn match_positions(haystack: &str, needle: &str) -> Vec<usize> {
130    if needle.is_empty() {
131        return Vec::new();
132    }
133    haystack
134        .match_indices(needle)
135        .map(|(index, _)| index)
136        .collect()
137}
138
139pub fn hunks_for_edits(path: impl Into<String>, edits: &[TextEdit]) -> Vec<EditHunk> {
140    let path = path.into();
141    edits
142        .iter()
143        .enumerate()
144        .map(|(index, edit)| text_edit_hunk(&path, &edit.old_string, &edit.new_string, index))
145        .collect()
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn applies_exact_edit_once() {
154        let (updated, outcome) = apply_edit(
155            "src/lib.rs",
156            "fn main() { true }",
157            "true",
158            "false",
159            EditOptions::default(),
160        )
161        .unwrap();
162        assert_eq!(updated, "fn main() { false }");
163        assert_eq!(outcome.replacements, 1);
164        assert_eq!(outcome.hunks.len(), 1);
165    }
166
167    #[test]
168    fn refuses_ambiguous_edit() {
169        let err = apply_edit("x", "foo foo", "foo", "bar", EditOptions::default()).unwrap_err();
170        assert!(matches!(
171            err,
172            EditApplyError::OldStringAmbiguous { occurrences: 2, .. }
173        ));
174    }
175
176    #[test]
177    fn strips_line_numbers_before_matching() {
178        let (updated, _) = apply_edit(
179            "x",
180            "foo\nbar",
181            "1: foo\n2: bar",
182            "baz",
183            EditOptions::default(),
184        )
185        .unwrap();
186        assert_eq!(updated, "baz");
187    }
188}