1use regex::Regex;
24
25use crate::Editor;
26
27pub type SubstError = String;
29
30#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct SubstituteCmd {
35 pub pattern: Option<String>,
38 pub replacement: String,
41 pub flags: SubstFlags,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
47pub struct SubstFlags {
48 pub all: bool,
50 pub ignore_case: bool,
52 pub case_sensitive: bool,
54 pub confirm: bool,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
62pub struct SubstituteOutcome {
63 pub replacements: usize,
65 pub lines_changed: usize,
67}
68
69pub fn parse_substitute(s: &str) -> Result<SubstituteCmd, SubstError> {
96 let rest = s
98 .strip_prefix('/')
99 .ok_or_else(|| format!("substitute: expected '/' delimiter, got {s:?}"))?;
100
101 let parts = split_on_slash(rest);
104
105 if parts.len() < 2 {
106 return Err("substitute needs /pattern/replacement/".into());
107 }
108
109 let raw_pattern = &parts[0];
110 let raw_replacement = &parts[1];
111 let raw_flags = parts.get(2).map(String::as_str).unwrap_or("");
112
113 let pattern = if raw_pattern.is_empty() {
115 None
116 } else {
117 Some(raw_pattern.clone())
118 };
119
120 let replacement = translate_replacement(raw_replacement);
122
123 let mut flags = SubstFlags::default();
124 for ch in raw_flags.chars() {
125 match ch {
126 'g' => flags.all = true,
127 'i' => flags.ignore_case = true,
128 'I' => flags.case_sensitive = true,
129 'c' => flags.confirm = true, other => return Err(format!("unknown flag '{other}' in substitute")),
131 }
132 }
133
134 Ok(SubstituteCmd {
135 pattern,
136 replacement,
137 flags,
138 })
139}
140
141pub fn apply_substitute<H: crate::types::Host>(
170 ed: &mut Editor<hjkl_buffer::Buffer, H>,
171 cmd: &SubstituteCmd,
172 line_range: std::ops::RangeInclusive<u32>,
173) -> Result<SubstituteOutcome, SubstError> {
174 let pattern_str: String = match &cmd.pattern {
176 Some(p) => p.clone(),
177 None => ed
178 .last_search()
179 .map(str::to_owned)
180 .ok_or_else(|| "no previous regular expression".to_string())?,
181 };
182
183 let effective_pattern = if cmd.flags.case_sensitive {
188 use crate::search::{CaseMode, resolve_case_mode};
191 let (stripped, _) = resolve_case_mode(&pattern_str, CaseMode::Sensitive);
192 stripped
193 } else if cmd.flags.ignore_case {
194 use crate::search::{CaseMode, resolve_case_mode};
196 let (stripped, _) = resolve_case_mode(&pattern_str, CaseMode::Sensitive);
197 format!("(?i){stripped}")
198 } else {
199 use crate::search::{CaseMode, resolve_case_mode};
201 let base = CaseMode::from_options(ed.settings().ignore_case, ed.settings().smartcase);
202 let (stripped, mode) = resolve_case_mode(&pattern_str, base);
203 if mode == CaseMode::Insensitive {
204 format!("(?i){stripped}")
205 } else {
206 stripped
207 }
208 };
209
210 let regex = Regex::new(&effective_pattern).map_err(|e| format!("bad pattern: {e}"))?;
211
212 ed.push_undo();
213
214 let start = *line_range.start() as usize;
215 let end = *line_range.end() as usize;
216 let rope = crate::types::Query::rope(ed.buffer());
217 let total = rope.len_lines();
218
219 let clamp_end = end.min(total.saturating_sub(1));
220 let mut new_lines: Vec<String> = crate::vim::rope_to_lines_vec(&rope);
221 let mut replacements = 0usize;
222 let mut lines_changed = 0usize;
223 let mut last_changed_row = 0usize;
224
225 if start <= clamp_end {
226 for (row, line) in new_lines[start..=clamp_end].iter_mut().enumerate() {
227 let (replaced, n) = do_replace(®ex, line, &cmd.replacement, cmd.flags.all);
228 if n > 0 {
229 *line = replaced;
230 replacements += n;
231 lines_changed += 1;
232 last_changed_row = start + row;
233 }
234 }
235 }
236
237 if replacements == 0 {
238 ed.pop_last_undo();
239 return Ok(SubstituteOutcome {
240 replacements: 0,
241 lines_changed: 0,
242 });
243 }
244
245 ed.buffer_mut().replace_all(&new_lines.join("\n"));
247
248 ed.buffer_mut()
250 .set_cursor(hjkl_buffer::Position::new(last_changed_row, 0));
251
252 ed.mark_content_dirty();
253
254 ed.set_last_search(Some(pattern_str), true);
256
257 Ok(SubstituteOutcome {
258 replacements,
259 lines_changed,
260 })
261}
262
263#[derive(Debug, Clone, PartialEq, Eq)]
270pub struct SubstituteMatch {
271 pub row: u32,
273 pub byte_start: u32,
275 pub byte_end: u32,
277 pub replacement: String,
279}
280
281pub fn collect_substitute_matches<H: crate::types::Host>(
293 ed: &crate::Editor<hjkl_buffer::Buffer, H>,
294 cmd: &SubstituteCmd,
295 line_range: std::ops::RangeInclusive<u32>,
296) -> Result<Vec<SubstituteMatch>, SubstError> {
297 let pattern_str: String = match &cmd.pattern {
299 Some(p) => p.clone(),
300 None => ed
301 .last_search()
302 .map(str::to_owned)
303 .ok_or_else(|| "no previous regular expression".to_string())?,
304 };
305
306 let effective_pattern = if cmd.flags.case_sensitive {
307 use crate::search::{CaseMode, resolve_case_mode};
308 let (stripped, _) = resolve_case_mode(&pattern_str, CaseMode::Sensitive);
309 stripped
310 } else if cmd.flags.ignore_case {
311 use crate::search::{CaseMode, resolve_case_mode};
312 let (stripped, _) = resolve_case_mode(&pattern_str, CaseMode::Sensitive);
313 format!("(?i){stripped}")
314 } else {
315 use crate::search::{CaseMode, resolve_case_mode};
316 let base = CaseMode::from_options(ed.settings().ignore_case, ed.settings().smartcase);
317 let (stripped, mode) = resolve_case_mode(&pattern_str, base);
318 if mode == CaseMode::Insensitive {
319 format!("(?i){stripped}")
320 } else {
321 stripped
322 }
323 };
324
325 let regex = Regex::new(&effective_pattern).map_err(|e| format!("bad pattern: {e}"))?;
326
327 let start = *line_range.start() as usize;
328 let end = *line_range.end() as usize;
329 let rope = crate::types::Query::rope(ed.buffer());
330 let total = rope.len_lines();
331 let clamp_end = end.min(total.saturating_sub(1));
332
333 let mut matches: Vec<SubstituteMatch> = Vec::new();
334
335 if start <= clamp_end {
336 for row in start..=clamp_end {
337 let line = hjkl_buffer::rope_line_str(&rope, row);
338 let line = line.trim_end_matches('\n');
340
341 if cmd.flags.all {
342 for m in regex.find_iter(line) {
343 let replacement = regex
345 .captures(m.as_str())
346 .map(|caps| {
347 let mut rep = String::new();
348 caps.expand(&cmd.replacement, &mut rep);
349 rep
350 })
351 .unwrap_or_else(|| cmd.replacement.clone());
352
353 matches.push(SubstituteMatch {
354 row: row as u32,
355 byte_start: m.start() as u32,
356 byte_end: m.end() as u32,
357 replacement,
358 });
359 }
360 } else {
361 if let Some(m) = regex.find(line) {
363 let replacement = regex
364 .captures(m.as_str())
365 .map(|caps| {
366 let mut rep = String::new();
367 caps.expand(&cmd.replacement, &mut rep);
368 rep
369 })
370 .unwrap_or_else(|| cmd.replacement.clone());
371
372 matches.push(SubstituteMatch {
373 row: row as u32,
374 byte_start: m.start() as u32,
375 byte_end: m.end() as u32,
376 replacement,
377 });
378 }
379 }
380 }
381 }
382
383 Ok(matches)
384}
385
386pub fn apply_collected_matches<H: crate::types::Host>(
399 ed: &mut crate::Editor<hjkl_buffer::Buffer, H>,
400 matches: &[SubstituteMatch],
401 accepted: &[bool],
402) -> usize {
403 assert_eq!(
404 matches.len(),
405 accepted.len(),
406 "apply_collected_matches: accepted.len() must equal matches.len()"
407 );
408
409 let mut to_apply: Vec<&SubstituteMatch> = matches
412 .iter()
413 .zip(accepted.iter())
414 .filter_map(|(m, &ok)| if ok { Some(m) } else { None })
415 .collect();
416
417 if to_apply.is_empty() {
418 return 0;
419 }
420
421 to_apply.sort_unstable_by(|a, b| b.row.cmp(&a.row).then(b.byte_start.cmp(&a.byte_start)));
422
423 let rope = crate::types::Query::rope(ed.buffer());
424 let mut lines_vec: Vec<String> = crate::vim::rope_to_lines_vec(&rope);
425 let mut applied = 0usize;
426 let mut last_changed_row: Option<usize> = None;
427
428 for sm in &to_apply {
429 let row = sm.row as usize;
430 if row >= lines_vec.len() {
431 continue;
432 }
433 let line = &lines_vec[row];
434 let bs = sm.byte_start as usize;
435 let be = sm.byte_end as usize;
436 if be > line.len() || bs > be {
437 continue;
438 }
439 let mut new_line = String::with_capacity(line.len() + sm.replacement.len());
441 new_line.push_str(&line[..bs]);
442 new_line.push_str(&sm.replacement);
443 new_line.push_str(&line[be..]);
444 lines_vec[row] = new_line;
445 applied += 1;
446 last_changed_row = Some(row);
447 }
448
449 if applied > 0 {
450 ed.buffer_mut().replace_all(&lines_vec.join("\n"));
451 if let Some(row) = last_changed_row {
452 ed.buffer_mut()
453 .set_cursor(hjkl_buffer::Position::new(row, 0));
454 }
455 ed.mark_content_dirty();
456 }
457
458 applied
459}
460
461fn split_on_slash(s: &str) -> Vec<String> {
468 let mut out: Vec<String> = Vec::new();
469 let mut cur = String::new();
470 let mut chars = s.chars().peekable();
471 while let Some(c) = chars.next() {
472 if c == '\\' {
473 match chars.peek() {
474 Some(&'/') => {
475 cur.push('/');
477 chars.next();
478 }
479 Some(_) => {
480 let next = chars.next().unwrap();
483 cur.push('\\');
484 cur.push(next);
485 }
486 None => cur.push('\\'),
487 }
488 } else if c == '/' {
489 if out.len() < 2 {
490 out.push(std::mem::take(&mut cur));
491 } else {
492 cur.push(c);
496 }
500 } else {
501 cur.push(c);
502 }
503 }
504 out.push(cur);
505 out
506}
507
508fn translate_replacement(s: &str) -> String {
516 let mut out = String::with_capacity(s.len() + 4);
517 let mut chars = s.chars().peekable();
518 while let Some(c) = chars.next() {
519 if c == '&' {
520 out.push_str("$0");
521 } else if c == '\\' {
522 match chars.next() {
523 Some('&') => out.push('&'), Some('\\') => out.push('\\'), Some(d @ '1'..='9') => {
526 out.push('$');
527 out.push(d);
528 }
529 Some(other) => out.push(other), None => {} }
532 } else {
533 out.push(c);
534 }
535 }
536 out
537}
538
539fn do_replace(regex: &Regex, text: &str, replacement: &str, all: bool) -> (String, usize) {
542 let matches = regex.find_iter(text).count();
543 if matches == 0 {
544 return (text.to_string(), 0);
545 }
546 let replaced = if all {
547 regex.replace_all(text, replacement).into_owned()
548 } else {
549 regex.replace(text, replacement).into_owned()
550 };
551 let count = if all { matches } else { 1 };
552 (replaced, count)
553}
554
555#[cfg(test)]
556mod tests {
557 use super::*;
558 use crate::types::{DefaultHost, Options};
559 use hjkl_buffer::Buffer;
560
561 fn editor_with(content: &str) -> Editor<Buffer, DefaultHost> {
562 let mut e = Editor::new(Buffer::new(), DefaultHost::new(), Options::default());
563 e.set_content(content);
564 e
565 }
566
567 fn buf_line(e: &Editor<Buffer, DefaultHost>, row: usize) -> String {
568 hjkl_buffer::rope_line_str(&e.buffer().rope(), row)
569 }
570
571 #[test]
574 fn parse_basic() {
575 let cmd = parse_substitute("/foo/bar/").unwrap();
576 assert_eq!(cmd.pattern.as_deref(), Some("foo"));
577 assert_eq!(cmd.replacement, "bar");
578 assert!(!cmd.flags.all);
579 }
580
581 #[test]
582 fn parse_trailing_slash_optional() {
583 let cmd = parse_substitute("/foo/bar").unwrap();
584 assert_eq!(cmd.pattern.as_deref(), Some("foo"));
585 assert_eq!(cmd.replacement, "bar");
586 }
587
588 #[test]
589 fn parse_global_flag() {
590 let cmd = parse_substitute("/x/y/g").unwrap();
591 assert!(cmd.flags.all);
592 }
593
594 #[test]
595 fn parse_ignore_case_flag() {
596 let cmd = parse_substitute("/x/y/i").unwrap();
597 assert!(cmd.flags.ignore_case);
598 }
599
600 #[test]
601 fn parse_case_sensitive_flag() {
602 let cmd = parse_substitute("/x/y/I").unwrap();
603 assert!(cmd.flags.case_sensitive);
604 }
605
606 #[test]
607 fn parse_confirm_flag_accepted() {
608 let cmd = parse_substitute("/x/y/c").unwrap();
609 assert!(cmd.flags.confirm);
610 }
611
612 #[test]
613 fn parse_multi_flags() {
614 let cmd = parse_substitute("/x/y/gi").unwrap();
615 assert!(cmd.flags.all);
616 assert!(cmd.flags.ignore_case);
617 }
618
619 #[test]
620 fn parse_unknown_flag_errors() {
621 let err = parse_substitute("/x/y/z").unwrap_err();
622 assert!(err.to_string().contains("unknown flag 'z'"), "{err}");
623 }
624
625 #[test]
626 fn parse_empty_pattern_is_none() {
627 let cmd = parse_substitute("//bar/").unwrap();
628 assert!(cmd.pattern.is_none());
629 assert_eq!(cmd.replacement, "bar");
630 }
631
632 #[test]
633 fn parse_empty_replacement_ok() {
634 let cmd = parse_substitute("/foo//").unwrap();
635 assert_eq!(cmd.pattern.as_deref(), Some("foo"));
636 assert_eq!(cmd.replacement, "");
637 }
638
639 #[test]
640 fn parse_escaped_slash_in_pattern() {
641 let cmd = parse_substitute("/a\\/b/c/").unwrap();
642 assert_eq!(cmd.pattern.as_deref(), Some("a/b"));
643 }
644
645 #[test]
646 fn parse_escaped_slash_in_replacement() {
647 let cmd = parse_substitute("/a/b\\/c/").unwrap();
648 assert_eq!(cmd.replacement, "b/c");
650 }
651
652 #[test]
653 fn parse_ampersand_becomes_dollar_zero() {
654 let cmd = parse_substitute("/foo/[&]/").unwrap();
655 assert_eq!(cmd.replacement, "[$0]");
656 }
657
658 #[test]
659 fn parse_escaped_ampersand_is_literal() {
660 let cmd = parse_substitute("/foo/\\&/").unwrap();
661 assert_eq!(cmd.replacement, "&");
662 }
663
664 #[test]
665 fn parse_group_ref_translates() {
666 let cmd = parse_substitute("/(foo)/\\1/").unwrap();
667 assert_eq!(cmd.replacement, "$1");
668 }
669
670 #[test]
671 fn parse_group_ref_nine() {
672 let cmd = parse_substitute("/(x)/\\9/").unwrap();
673 assert_eq!(cmd.replacement, "$9");
674 }
675
676 #[test]
677 fn parse_wrong_delimiter_errors() {
678 let err = parse_substitute("|foo|bar|").unwrap_err();
679 assert!(err.to_string().contains("'/'"), "{err}");
680 }
681
682 #[test]
683 fn parse_too_few_fields_errors() {
684 let err = parse_substitute("/foo").unwrap_err();
685 assert!(
686 err.to_string().contains("needs /pattern/replacement"),
687 "{err}"
688 );
689 }
690
691 #[test]
694 fn apply_single_line_first_only() {
695 let mut e = editor_with("foo foo");
696 let cmd = parse_substitute("/foo/bar/").unwrap();
697 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
698 assert_eq!(out.replacements, 1);
699 assert_eq!(out.lines_changed, 1);
700 assert_eq!(buf_line(&e, 0), "bar foo");
701 }
702
703 #[test]
704 fn apply_single_line_global() {
705 let mut e = editor_with("foo foo foo");
706 let cmd = parse_substitute("/foo/bar/g").unwrap();
707 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
708 assert_eq!(out.replacements, 3);
709 assert_eq!(out.lines_changed, 1);
710 assert_eq!(buf_line(&e, 0), "bar bar bar");
711 }
712
713 #[test]
714 fn apply_multi_line_range() {
715 let mut e = editor_with("foo\nfoo foo\nbar");
716 let cmd = parse_substitute("/foo/xyz/g").unwrap();
717 let out = apply_substitute(&mut e, &cmd, 0..=2).unwrap();
718 assert_eq!(out.replacements, 3);
719 assert_eq!(out.lines_changed, 2);
720 assert_eq!(buf_line(&e, 0), "xyz");
721 assert_eq!(buf_line(&e, 1), "xyz xyz");
722 assert_eq!(buf_line(&e, 2), "bar");
723 }
724
725 #[test]
726 fn apply_no_match_returns_zero() {
727 let mut e = editor_with("hello");
728 let original = buf_line(&e, 0);
729 let cmd = parse_substitute("/xyz/abc/").unwrap();
730 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
731 assert_eq!(out.replacements, 0);
732 assert_eq!(out.lines_changed, 0);
733 assert_eq!(buf_line(&e, 0), original);
734 }
735
736 #[test]
737 fn apply_case_insensitive_flag() {
738 let mut e = editor_with("Foo FOO foo");
739 let cmd = parse_substitute("/foo/bar/gi").unwrap();
740 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
741 assert_eq!(out.replacements, 3);
742 assert_eq!(buf_line(&e, 0), "bar bar bar");
743 }
744
745 #[test]
746 fn apply_case_sensitive_flag_overrides_editor_setting() {
747 let mut e = editor_with("Foo foo");
748 e.settings_mut().ignore_case = true;
750 let cmd = parse_substitute("/foo/bar/I").unwrap();
752 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
753 assert_eq!(out.replacements, 1);
755 assert_eq!(buf_line(&e, 0), "Foo bar");
756 }
757
758 #[test]
759 fn apply_empty_pattern_reuses_last_search() {
760 let mut e = editor_with("hello world");
761 e.set_last_search(Some("world".to_string()), true);
762 let cmd = parse_substitute("//planet/").unwrap();
763 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
764 assert_eq!(out.replacements, 1);
765 assert_eq!(buf_line(&e, 0), "hello planet");
766 }
767
768 #[test]
769 fn apply_empty_pattern_no_last_search_errors() {
770 let mut e = editor_with("hello");
771 let cmd = parse_substitute("//bar/").unwrap();
772 let err = apply_substitute(&mut e, &cmd, 0..=0).unwrap_err();
773 assert!(
774 err.to_string().contains("no previous regular expression"),
775 "{err}"
776 );
777 }
778
779 #[test]
780 fn apply_updates_last_search() {
781 let mut e = editor_with("foo");
782 let cmd = parse_substitute("/foo/bar/").unwrap();
783 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
784 assert_eq!(e.last_search(), Some("foo"));
785 }
786
787 #[test]
788 fn apply_empty_replacement_deletes_match() {
789 let mut e = editor_with("hello world");
790 let cmd = parse_substitute("/world//").unwrap();
791 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
792 assert_eq!(out.replacements, 1);
793 assert_eq!(buf_line(&e, 0), "hello ");
794 }
795
796 #[test]
797 fn apply_undo_reverts_in_one_step() {
798 let mut e = editor_with("foo");
799 let cmd = parse_substitute("/foo/bar/").unwrap();
800 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
801 assert_eq!(buf_line(&e, 0), "bar");
802 e.undo();
803 assert_eq!(buf_line(&e, 0), "foo");
804 }
805
806 #[test]
807 fn apply_ampersand_in_replacement() {
808 let mut e = editor_with("foo");
809 let cmd = parse_substitute("/foo/[&]/").unwrap();
810 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
811 assert_eq!(buf_line(&e, 0), "[foo]");
812 }
813
814 #[test]
815 fn apply_capture_group_reference() {
816 let mut e = editor_with("hello world");
817 let cmd = parse_substitute("/(\\w+)/<<\\1>>/g").unwrap();
818 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
819 assert_eq!(buf_line(&e, 0), "<<hello>> <<world>>");
820 }
821
822 #[test]
827 fn substitute_respects_smartcase() {
828 let mut e = editor_with("Foo");
829 let cmd = parse_substitute("/foo/bar/").unwrap();
831 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
832 assert_eq!(out.replacements, 1);
833 assert_eq!(buf_line(&e, 0), "bar");
834 }
835
836 #[test]
839 fn substitute_i_flag_overrides_c() {
840 let mut e = editor_with("foo");
841 let cmd = parse_substitute("/Foo/bar/i").unwrap();
843 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
844 assert_eq!(out.replacements, 1, "expected match on 'foo' with /i flag");
845 assert_eq!(buf_line(&e, 0), "bar");
846 }
847
848 #[test]
851 fn substitute_lower_c_inline_overrides_smartcase() {
852 let mut e = editor_with("FOO");
853 let cmd = parse_substitute("/\\cFoo/bar/").unwrap();
855 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
856 assert_eq!(out.replacements, 1);
857 assert_eq!(buf_line(&e, 0), "bar");
858 }
859
860 #[test]
863 fn collect_substitute_matches_finds_all_occurrences() {
864 let e = editor_with("foo bar foo");
865 let cmd = parse_substitute("/foo/baz/g").unwrap();
866 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
867 assert_eq!(matches.len(), 2, "expected 2 matches for /g flag");
868 assert_eq!(matches[0].byte_start, 0);
869 assert_eq!(matches[0].byte_end, 3);
870 assert_eq!(matches[1].byte_start, 8);
871 assert_eq!(matches[1].byte_end, 11);
872 assert_eq!(matches[0].replacement, "baz");
873 assert_eq!(matches[1].replacement, "baz");
874 }
875
876 #[test]
877 fn collect_substitute_matches_respects_g_flag() {
878 let e = editor_with("foo foo foo");
880 let cmd = parse_substitute("/foo/baz/").unwrap();
881 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
882 assert_eq!(matches.len(), 1, "expected 1 match without /g");
883 assert_eq!(matches[0].byte_start, 0);
884 }
885
886 #[test]
887 fn collect_substitute_matches_respects_range() {
888 let e = editor_with("foo\nfoo\nfoo\nfoo\nfoo");
889 let cmd = parse_substitute("/foo/bar/g").unwrap();
890 let matches = collect_substitute_matches(&e, &cmd, 1..=2).unwrap();
892 assert_eq!(matches.len(), 2);
893 assert_eq!(matches[0].row, 1);
894 assert_eq!(matches[1].row, 2);
895 }
896
897 #[test]
898 fn collect_substitute_matches_expands_template() {
899 let e = editor_with("hello world");
900 let cmd = parse_substitute("/(\\w+)/<<\\1>>/g").unwrap();
902 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
903 assert_eq!(matches.len(), 2);
904 assert_eq!(matches[0].replacement, "<<hello>>");
905 assert_eq!(matches[1].replacement, "<<world>>");
906 }
907
908 #[test]
911 fn apply_collected_matches_reverse_order_preserves_offsets() {
912 let mut e = editor_with("foo bar baz");
916 let cmd = parse_substitute("/(foo|bar|baz)/X/g").unwrap();
917 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
918 assert_eq!(matches.len(), 3);
919 let accepted = vec![true; 3];
920 let applied = apply_collected_matches(&mut e, &matches, &accepted);
921 assert_eq!(applied, 3);
922 assert_eq!(buf_line(&e, 0), "X X X");
923 }
924
925 #[test]
926 fn apply_collected_matches_subset_only() {
927 let mut e = editor_with("foo bar foo");
929 let cmd = parse_substitute("/foo/ZZZ/g").unwrap();
930 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
931 assert_eq!(matches.len(), 2, "expected 2 foo matches");
932 let accepted = vec![true, false];
934 let applied = apply_collected_matches(&mut e, &matches, &accepted);
935 assert_eq!(applied, 1);
936 assert_eq!(buf_line(&e, 0), "ZZZ bar foo");
938 }
939
940 #[test]
941 fn apply_collected_matches_zero_accepted() {
942 let mut e = editor_with("foo bar foo");
943 let cmd = parse_substitute("/foo/ZZZ/g").unwrap();
944 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
945 let accepted = vec![false; matches.len()];
946 let applied = apply_collected_matches(&mut e, &matches, &accepted);
947 assert_eq!(applied, 0);
948 assert_eq!(buf_line(&e, 0), "foo bar foo");
949 }
950
951 #[test]
952 fn apply_collected_matches_expands_template() {
953 let mut e = editor_with("hello world");
954 let cmd = parse_substitute("/(\\w+)/<<\\1>>/g").unwrap();
955 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
956 let accepted = vec![true; matches.len()];
957 let applied = apply_collected_matches(&mut e, &matches, &accepted);
958 assert_eq!(applied, 2);
959 assert_eq!(buf_line(&e, 0), "<<hello>> <<world>>");
960 }
961}