Skip to content

Commit dfc09c9

Browse files
committed
feat: fail on multiple matches by default and add replaceAll option for
bulk replacement
1 parent d374610 commit dfc09c9

3 files changed

Lines changed: 305 additions & 75 deletions

File tree

src/fs_service/io/edit.rs

Lines changed: 160 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ impl FileSystemService {
4444
edits: Vec<EditOperation>,
4545
dry_run: Option<bool>,
4646
save_to: Option<&Path>,
47+
replace_all: Option<bool>,
4748
) -> ServiceResult<String> {
4849
let allowed_directories = self.allowed_directories().await;
4950
let valid_path = self.validate_path(file_path, allowed_directories)?;
@@ -59,9 +60,25 @@ impl FileSystemService {
5960
for edit in edits {
6061
let normalized_old = normalize_line_endings(&edit.old_text);
6162
let normalized_new = normalize_line_endings(&edit.new_text);
63+
let do_replace_all = replace_all.unwrap_or(false);
64+
6265
// If exact match exists, use it
6366
if modified_content.contains(&normalized_old) {
64-
modified_content = modified_content.replacen(&normalized_old, &normalized_new, 1);
67+
let count = modified_content.matches(&normalized_old).count();
68+
if !do_replace_all && count > 1 {
69+
return Err(RpcError::internal_error()
70+
.with_message(format!(
71+
"Multiple occurrences of oldText found ({}). Use replace_all=true to replace all occurrences",
72+
count
73+
))
74+
.into());
75+
}
76+
if do_replace_all {
77+
modified_content = modified_content.replace(&normalized_old, &normalized_new);
78+
} else {
79+
modified_content =
80+
modified_content.replacen(&normalized_old, &normalized_new, 1);
81+
}
6582
continue;
6683
}
6784

@@ -78,8 +95,6 @@ impl FileSystemService {
7895
.map(|s| s.to_string())
7996
.collect();
8097

81-
let mut match_found = false;
82-
8398
// skip when the match is impossible:
8499
if old_lines.len() > content_lines.len() {
85100
let error_message = format!(
@@ -94,6 +109,8 @@ impl FileSystemService {
94109
}
95110

96111
let max_start = content_lines.len().saturating_sub(old_lines.len());
112+
let mut match_count = 0;
113+
let mut last_match_idx = 0;
97114
for i in 0..=max_start {
98115
let potential_match = &content_lines[i..i + old_lines.len()];
99116

@@ -104,71 +121,155 @@ impl FileSystemService {
104121
});
105122

106123
if is_match {
107-
// Preserve original indentation of first line
108-
let original_indent = content_lines[i]
109-
.chars()
110-
.take_while(|&c| c.is_whitespace())
111-
.collect::<String>();
112-
113-
let new_lines: Vec<String> = normalized_new
114-
.split('\n')
115-
.enumerate()
116-
.map(|(j, line)| {
117-
// Keep indentation of the first line
118-
if j == 0 {
119-
return format!("{}{}", original_indent, line.trim_start());
120-
}
121-
122-
// For subsequent lines, preserve relative indentation and original whitespace type
123-
let old_indent = old_lines
124-
.get(j)
125-
.map(|line| {
126-
line.chars()
127-
.take_while(|&c| c.is_whitespace())
128-
.collect::<String>()
129-
})
130-
.unwrap_or_default();
131-
132-
let new_indent = line
133-
.chars()
134-
.take_while(|&c| c.is_whitespace())
135-
.collect::<String>();
136-
137-
// Use the same whitespace character as original_indent (tabs or spaces)
138-
let indent_char = if original_indent.contains('\t') {
139-
"\t"
140-
} else {
141-
" "
142-
};
143-
let relative_indent = if new_indent.len() >= old_indent.len() {
144-
new_indent.len() - old_indent.len()
145-
} else {
146-
0 // Don't reduce indentation below original
147-
};
148-
format!(
149-
"{}{}{}",
150-
&original_indent,
151-
&indent_char.repeat(relative_indent),
152-
line.trim_start()
153-
)
154-
})
155-
.collect();
156-
157-
let mut content_lines = content_lines.clone();
158-
content_lines.splice(i..i + old_lines.len(), new_lines);
159-
modified_content = content_lines.join("\n");
160-
match_found = true;
161-
break;
124+
match_count += 1;
125+
last_match_idx = i;
126+
if !do_replace_all {
127+
break;
128+
}
162129
}
163130
}
164-
if !match_found {
131+
132+
if match_count == 0 {
165133
return Err(RpcError::internal_error()
166134
.with_message(format!(
167135
"Could not find exact match for edit:\n{}",
168136
edit.old_text
169137
))
170138
.into());
171139
}
140+
141+
if !do_replace_all && match_count > 1 {
142+
return Err(RpcError::internal_error()
143+
.with_message(format!(
144+
"Multiple occurrences of oldText found ({}). Use replaceAll:true to replace all occurrences",
145+
match_count
146+
))
147+
.into());
148+
}
149+
150+
// Apply the edit(s)
151+
let mut content_lines = content_lines.clone();
152+
if do_replace_all {
153+
let mut i = 0;
154+
while i <= content_lines.len().saturating_sub(old_lines.len()) {
155+
let potential_match = &content_lines[i..i + old_lines.len()];
156+
let is_match = old_lines.iter().enumerate().all(|(j, old_line)| {
157+
let content_line = &potential_match[j];
158+
old_line.trim() == content_line.trim()
159+
});
160+
161+
if is_match {
162+
let original_indent = content_lines[i]
163+
.chars()
164+
.take_while(|&c| c.is_whitespace())
165+
.collect::<String>();
166+
167+
let new_lines: Vec<String> = normalized_new
168+
.split('\n')
169+
.enumerate()
170+
.map(|(j, line)| {
171+
if j == 0 {
172+
return format!("{}{}", original_indent, line.trim_start());
173+
}
174+
175+
let old_indent = old_lines
176+
.get(j)
177+
.map(|line| {
178+
line.chars()
179+
.take_while(|&c| c.is_whitespace())
180+
.collect::<String>()
181+
})
182+
.unwrap_or_default();
183+
184+
let new_indent = line
185+
.chars()
186+
.take_while(|&c| c.is_whitespace())
187+
.collect::<String>();
188+
189+
let indent_char = if original_indent.contains('\t') {
190+
"\t"
191+
} else {
192+
" "
193+
};
194+
let relative_indent = if new_indent.len() >= old_indent.len() {
195+
new_indent.len() - old_indent.len()
196+
} else {
197+
0
198+
};
199+
format!(
200+
"{}{}{}",
201+
&original_indent,
202+
&indent_char.repeat(relative_indent),
203+
line.trim_start()
204+
)
205+
})
206+
.collect();
207+
208+
content_lines.splice(i..i + old_lines.len(), new_lines);
209+
// Don't increment i since we replaced the block and need to check again
210+
} else {
211+
i += 1;
212+
}
213+
}
214+
modified_content = content_lines.join("\n");
215+
} else {
216+
// Single match case - use last_match_idx
217+
let i = last_match_idx;
218+
let original_indent = content_lines[i]
219+
.chars()
220+
.take_while(|&c| c.is_whitespace())
221+
.collect::<String>();
222+
223+
let new_lines: Vec<String> = normalized_new
224+
.split('\n')
225+
.enumerate()
226+
.map(|(j, line)| {
227+
if j == 0 {
228+
return format!("{}{}", original_indent, line.trim_start());
229+
}
230+
231+
let old_indent = old_lines
232+
.get(j)
233+
.map(|line| {
234+
line.chars()
235+
.take_while(|&c| c.is_whitespace())
236+
.collect::<String>()
237+
})
238+
.unwrap_or_default();
239+
240+
let new_indent = line
241+
.chars()
242+
.take_while(|&c| c.is_whitespace())
243+
.collect::<String>();
244+
245+
let indent_char = if original_indent.contains('\t') {
246+
"\t"
247+
} else {
248+
" "
249+
};
250+
let relative_indent = if new_indent.len() >= old_indent.len() {
251+
new_indent.len() - old_indent.len()
252+
} else {
253+
0
254+
};
255+
format!(
256+
"{}{}{}",
257+
&original_indent,
258+
&indent_char.repeat(relative_indent),
259+
line.trim_start()
260+
)
261+
})
262+
.collect();
263+
264+
content_lines.splice(i..i + old_lines.len(), new_lines);
265+
modified_content = content_lines.join("\n");
266+
}
267+
if !do_replace_all && match_count == 1 {
268+
continue;
269+
}
270+
if do_replace_all {
271+
continue;
272+
}
172273
}
173274

174275
let diff = self.create_unified_diff(

src/tools/edit_file.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,17 @@ pub struct EditFile {
4848
skip_serializing_if = "std::option::Option::is_none"
4949
)]
5050
pub dry_run: Option<bool>,
51+
/// Optional flag to replace all occurrences of `oldText`.
52+
///
53+
/// Default: `false`.
54+
/// - `false` or `null` → if multiple matches are found, an error is returned.
55+
/// - `true` → replace all matches.
56+
#[serde(
57+
rename = "replaceAll",
58+
default,
59+
skip_serializing_if = "std::option::Option::is_none"
60+
)]
61+
pub replace_all: Option<bool>,
5162
}
5263

5364
impl EditFile {
@@ -56,7 +67,13 @@ impl EditFile {
5667
context: &FileSystemService,
5768
) -> std::result::Result<CallToolResult, CallToolError> {
5869
let diff = context
59-
.apply_file_edits(Path::new(&params.path), params.edits, params.dry_run, None)
70+
.apply_file_edits(
71+
Path::new(&params.path),
72+
params.edits,
73+
params.dry_run,
74+
None,
75+
params.replace_all,
76+
)
6077
.await
6178
.map_err(CallToolError::new)?;
6279

0 commit comments

Comments
 (0)