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