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