1use language::{BufferSnapshot, Diff, Point, ToOffset};
2use project::search::SearchQuery;
3use util::{paths::PathMatcher, ResultExt as _};
4
5/// Performs an exact string replacement in a buffer, requiring precise character-for-character matching.
6/// Uses the search functionality to locate the first occurrence of the exact string.
7/// Returns None if no exact match is found in the buffer.
8pub async fn replace_exact(old: &str, new: &str, snapshot: &BufferSnapshot) -> Option<Diff> {
9 let query = SearchQuery::text(
10 old,
11 false,
12 true,
13 true,
14 PathMatcher::new(&[]).ok()?,
15 PathMatcher::new(&[]).ok()?,
16 None,
17 )
18 .log_err()?;
19
20 let matches = query.search(&snapshot, None).await;
21
22 if matches.is_empty() {
23 return None;
24 }
25
26 let edit_range = matches[0].clone();
27 let diff = language::text_diff(&old, &new);
28
29 let edits = diff
30 .into_iter()
31 .map(|(old_range, text)| {
32 let start = edit_range.start + old_range.start;
33 let end = edit_range.start + old_range.end;
34 (start..end, text)
35 })
36 .collect::<Vec<_>>();
37
38 let diff = language::Diff {
39 base_version: snapshot.version().clone(),
40 line_ending: snapshot.line_ending(),
41 edits,
42 };
43
44 Some(diff)
45}
46
47/// Performs a replacement that's indentation-aware - matches text content ignoring leading whitespace differences.
48/// When replacing, preserves the indentation level found in the buffer at each matching line.
49/// Returns None if no match found or if indentation is offset inconsistently across matched lines.
50pub fn replace_with_flexible_indent(old: &str, new: &str, buffer: &BufferSnapshot) -> Option<Diff> {
51 let (old_lines, old_min_indent) = lines_with_min_indent(old);
52 let (new_lines, new_min_indent) = lines_with_min_indent(new);
53 let min_indent = old_min_indent.min(new_min_indent);
54
55 let old_lines = drop_lines_prefix(&old_lines, min_indent);
56 let new_lines = drop_lines_prefix(&new_lines, min_indent);
57
58 let max_row = buffer.max_point().row;
59
60 'windows: for start_row in 0..max_row.saturating_sub(old_lines.len() as u32 - 1) {
61 let mut common_leading = None;
62
63 let end_row = start_row + old_lines.len() as u32 - 1;
64
65 if end_row > max_row {
66 // The buffer ends before fully matching the pattern
67 return None;
68 }
69
70 let start_point = Point::new(start_row, 0);
71 let end_point = Point::new(end_row, buffer.line_len(end_row));
72 let range = start_point.to_offset(buffer)..end_point.to_offset(buffer);
73
74 let window_text = buffer.text_for_range(range.clone());
75 let mut window_lines = window_text.lines();
76 let mut old_lines_iter = old_lines.iter();
77
78 while let (Some(window_line), Some(old_line)) = (window_lines.next(), old_lines_iter.next())
79 {
80 let line_trimmed = window_line.trim_start();
81
82 if line_trimmed != old_line.trim_start() {
83 continue 'windows;
84 }
85
86 if line_trimmed.is_empty() {
87 continue;
88 }
89
90 let line_leading = &window_line[..window_line.len() - old_line.len()];
91
92 match &common_leading {
93 Some(common_leading) if common_leading != line_leading => {
94 continue 'windows;
95 }
96 Some(_) => (),
97 None => common_leading = Some(line_leading.to_string()),
98 }
99 }
100
101 if let Some(common_leading) = common_leading {
102 let line_ending = buffer.line_ending();
103 let replacement = new_lines
104 .iter()
105 .map(|new_line| {
106 if new_line.trim().is_empty() {
107 new_line.to_string()
108 } else {
109 common_leading.to_string() + new_line
110 }
111 })
112 .collect::<Vec<_>>()
113 .join(line_ending.as_str());
114
115 let diff = Diff {
116 base_version: buffer.version().clone(),
117 line_ending,
118 edits: vec![(range, replacement.into())],
119 };
120
121 return Some(diff);
122 }
123 }
124
125 None
126}
127
128fn drop_lines_prefix<'a>(lines: &'a [&str], prefix_len: usize) -> Vec<&'a str> {
129 lines
130 .iter()
131 .map(|line| line.get(prefix_len..).unwrap_or(""))
132 .collect()
133}
134
135fn lines_with_min_indent(input: &str) -> (Vec<&str>, usize) {
136 let mut lines = Vec::new();
137 let mut min_indent: Option<usize> = None;
138
139 for line in input.lines() {
140 lines.push(line);
141 if !line.trim().is_empty() {
142 let indent = line.len() - line.trim_start().len();
143 min_indent = Some(min_indent.map_or(indent, |m| m.min(indent)));
144 }
145 }
146
147 (lines, min_indent.unwrap_or(0))
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153 use gpui::prelude::*;
154 use gpui::TestAppContext;
155 use unindent::Unindent;
156
157 #[gpui::test]
158 fn test_replace_consistent_indentation(cx: &mut TestAppContext) {
159 let whole = r#"
160 fn test() {
161 let x = 5;
162 println!("x = {}", x);
163 let y = 10;
164 }
165 "#
166 .unindent();
167
168 let old = r#"
169 let x = 5;
170 println!("x = {}", x);
171 "#
172 .unindent();
173
174 let new = r#"
175 let x = 42;
176 println!("New value: {}", x);
177 "#
178 .unindent();
179
180 let expected = r#"
181 fn test() {
182 let x = 42;
183 println!("New value: {}", x);
184 let y = 10;
185 }
186 "#
187 .unindent();
188
189 assert_eq!(
190 test_replace_with_flexible_indent(cx, &whole, &old, &new),
191 Some(expected.to_string())
192 );
193 }
194
195 #[gpui::test]
196 fn test_replace_inconsistent_indentation(cx: &mut TestAppContext) {
197 let whole = r#"
198 fn test() {
199 if condition {
200 println!("{}", 43);
201 }
202 }
203 "#
204 .unindent();
205
206 let old = r#"
207 if condition {
208 println!("{}", 43);
209 "#
210 .unindent();
211
212 let new = r#"
213 if condition {
214 println!("{}", 42);
215 "#
216 .unindent();
217
218 assert_eq!(
219 test_replace_with_flexible_indent(cx, &whole, &old, &new),
220 None
221 );
222 }
223
224 #[gpui::test]
225 fn test_replace_with_empty_lines(cx: &mut TestAppContext) {
226 // Test with empty lines
227 let whole = r#"
228 fn test() {
229 let x = 5;
230
231 println!("x = {}", x);
232 }
233 "#
234 .unindent();
235
236 let old = r#"
237 let x = 5;
238
239 println!("x = {}", x);
240 "#
241 .unindent();
242
243 let new = r#"
244 let x = 10;
245
246 println!("New x: {}", x);
247 "#
248 .unindent();
249
250 let expected = r#"
251 fn test() {
252 let x = 10;
253
254 println!("New x: {}", x);
255 }
256 "#
257 .unindent();
258
259 assert_eq!(
260 test_replace_with_flexible_indent(cx, &whole, &old, &new),
261 Some(expected.to_string())
262 );
263 }
264
265 #[gpui::test]
266 fn test_replace_no_match(cx: &mut TestAppContext) {
267 // Test with no match
268 let whole = r#"
269 fn test() {
270 let x = 5;
271 }
272 "#
273 .unindent();
274
275 let old = r#"
276 let y = 10;
277 "#
278 .unindent();
279
280 let new = r#"
281 let y = 20;
282 "#
283 .unindent();
284
285 assert_eq!(
286 test_replace_with_flexible_indent(cx, &whole, &old, &new),
287 None
288 );
289 }
290
291 #[gpui::test]
292 fn test_replace_whole_ends_before_matching_old(cx: &mut TestAppContext) {
293 let whole = r#"
294 fn test() {
295 let x = 5;
296 "#
297 .unindent();
298
299 let old = r#"
300 let x = 5;
301 println!("x = {}", x);
302 "#
303 .unindent();
304
305 let new = r#"
306 let x = 10;
307 println!("x = {}", x);
308 "#
309 .unindent();
310
311 // Should return None because whole doesn't fully contain the old text
312 assert_eq!(
313 test_replace_with_flexible_indent(cx, &whole, &old, &new),
314 None
315 );
316 }
317
318 #[test]
319 fn test_lines_with_min_indent() {
320 // Empty string
321 assert_eq!(lines_with_min_indent(""), (vec![], 0));
322
323 // Single line without indentation
324 assert_eq!(lines_with_min_indent("hello"), (vec!["hello"], 0));
325
326 // Multiple lines with no indentation
327 assert_eq!(
328 lines_with_min_indent("line1\nline2\nline3"),
329 (vec!["line1", "line2", "line3"], 0)
330 );
331
332 // Multiple lines with consistent indentation
333 assert_eq!(
334 lines_with_min_indent(" line1\n line2\n line3"),
335 (vec![" line1", " line2", " line3"], 2)
336 );
337
338 // Multiple lines with varying indentation
339 assert_eq!(
340 lines_with_min_indent(" line1\n line2\n line3"),
341 (vec![" line1", " line2", " line3"], 2)
342 );
343
344 // Lines with mixed indentation and empty lines
345 assert_eq!(
346 lines_with_min_indent(" line1\n\n line2"),
347 (vec![" line1", "", " line2"], 2)
348 );
349 }
350
351 #[gpui::test]
352 fn test_replace_with_missing_indent_uneven_match(cx: &mut TestAppContext) {
353 let whole = r#"
354 fn test() {
355 if true {
356 let x = 5;
357 println!("x = {}", x);
358 }
359 }
360 "#
361 .unindent();
362
363 let old = r#"
364 let x = 5;
365 println!("x = {}", x);
366 "#
367 .unindent();
368
369 let new = r#"
370 let x = 42;
371 println!("x = {}", x);
372 "#
373 .unindent();
374
375 let expected = r#"
376 fn test() {
377 if true {
378 let x = 42;
379 println!("x = {}", x);
380 }
381 }
382 "#
383 .unindent();
384
385 assert_eq!(
386 test_replace_with_flexible_indent(cx, &whole, &old, &new),
387 Some(expected.to_string())
388 );
389 }
390
391 #[gpui::test]
392 fn test_replace_big_example(cx: &mut TestAppContext) {
393 let whole = r#"
394 #[cfg(test)]
395 mod tests {
396 use super::*;
397
398 #[test]
399 fn test_is_valid_age() {
400 assert!(is_valid_age(0));
401 assert!(!is_valid_age(151));
402 }
403 }
404 "#
405 .unindent();
406
407 let old = r#"
408 #[test]
409 fn test_is_valid_age() {
410 assert!(is_valid_age(0));
411 assert!(!is_valid_age(151));
412 }
413 "#
414 .unindent();
415
416 let new = r#"
417 #[test]
418 fn test_is_valid_age() {
419 assert!(is_valid_age(0));
420 assert!(!is_valid_age(151));
421 }
422
423 #[test]
424 fn test_group_people_by_age() {
425 let people = vec![
426 Person::new("Young One", 5, "young@example.com").unwrap(),
427 Person::new("Teen One", 15, "teen@example.com").unwrap(),
428 Person::new("Teen Two", 18, "teen2@example.com").unwrap(),
429 Person::new("Adult One", 25, "adult@example.com").unwrap(),
430 ];
431
432 let groups = group_people_by_age(&people);
433
434 assert_eq!(groups.get(&0).unwrap().len(), 1); // One person in 0-9
435 assert_eq!(groups.get(&10).unwrap().len(), 2); // Two people in 10-19
436 assert_eq!(groups.get(&20).unwrap().len(), 1); // One person in 20-29
437 }
438 "#
439 .unindent();
440 let expected = r#"
441 #[cfg(test)]
442 mod tests {
443 use super::*;
444
445 #[test]
446 fn test_is_valid_age() {
447 assert!(is_valid_age(0));
448 assert!(!is_valid_age(151));
449 }
450
451 #[test]
452 fn test_group_people_by_age() {
453 let people = vec![
454 Person::new("Young One", 5, "young@example.com").unwrap(),
455 Person::new("Teen One", 15, "teen@example.com").unwrap(),
456 Person::new("Teen Two", 18, "teen2@example.com").unwrap(),
457 Person::new("Adult One", 25, "adult@example.com").unwrap(),
458 ];
459
460 let groups = group_people_by_age(&people);
461
462 assert_eq!(groups.get(&0).unwrap().len(), 1); // One person in 0-9
463 assert_eq!(groups.get(&10).unwrap().len(), 2); // Two people in 10-19
464 assert_eq!(groups.get(&20).unwrap().len(), 1); // One person in 20-29
465 }
466 }
467 "#
468 .unindent();
469 assert_eq!(
470 test_replace_with_flexible_indent(cx, &whole, &old, &new),
471 Some(expected.to_string())
472 );
473 }
474
475 #[test]
476 fn test_drop_lines_prefix() {
477 // Empty array
478 assert_eq!(drop_lines_prefix(&[], 2), Vec::<&str>::new());
479
480 // Zero prefix length
481 assert_eq!(
482 drop_lines_prefix(&["line1", "line2"], 0),
483 vec!["line1", "line2"]
484 );
485
486 // Normal prefix drop
487 assert_eq!(
488 drop_lines_prefix(&[" line1", " line2"], 2),
489 vec!["line1", "line2"]
490 );
491
492 // Prefix longer than some lines
493 assert_eq!(drop_lines_prefix(&[" line1", "a"], 2), vec!["line1", ""]);
494
495 // Prefix longer than all lines
496 assert_eq!(drop_lines_prefix(&["a", "b"], 5), vec!["", ""]);
497
498 // Mixed length lines
499 assert_eq!(
500 drop_lines_prefix(&[" line1", " line2", " line3"], 2),
501 vec![" line1", "line2", " line3"]
502 );
503 }
504
505 fn test_replace_with_flexible_indent(
506 cx: &mut TestAppContext,
507 whole: &str,
508 old: &str,
509 new: &str,
510 ) -> Option<String> {
511 // Create a local buffer with the test content
512 let buffer = cx.new(|cx| language::Buffer::local(whole, cx));
513
514 // Get the buffer snapshot
515 let buffer_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
516
517 // Call replace_flexible and transform the result
518 replace_with_flexible_indent(old, new, &buffer_snapshot).map(|diff| {
519 buffer.update(cx, |buffer, cx| {
520 let _ = buffer.apply_diff(diff, cx);
521 buffer.text()
522 })
523 })
524 }
525}