1use regex::Regex;
25
26use crate::Editor;
27
28pub type SubstError = String;
30
31#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct SubstituteCmd {
36 pub pattern: Option<String>,
39 pub replacement: String,
42 pub flags: SubstFlags,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
48pub struct SubstFlags {
49 pub all: bool,
51 pub ignore_case: bool,
53 pub case_sensitive: bool,
55 pub confirm: bool,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
63pub struct SubstituteOutcome {
64 pub replacements: usize,
66 pub lines_changed: usize,
68}
69
70pub fn parse_substitute(s: &str) -> Result<SubstituteCmd, SubstError> {
97 let rest = s
99 .strip_prefix('/')
100 .ok_or_else(|| format!("substitute: expected '/' delimiter, got {s:?}"))?;
101
102 let parts = split_on_slash(rest);
105
106 if parts.len() < 2 {
107 return Err("substitute needs /pattern/replacement/".into());
108 }
109
110 let raw_pattern = &parts[0];
111 let raw_replacement = &parts[1];
112 let raw_flags = parts.get(2).map(String::as_str).unwrap_or("");
113
114 let pattern = if raw_pattern.is_empty() {
116 None
117 } else {
118 Some(raw_pattern.clone())
119 };
120
121 let replacement = translate_replacement(raw_replacement);
123
124 let mut flags = SubstFlags::default();
125 for ch in raw_flags.chars() {
126 match ch {
127 'g' => flags.all = true,
128 'i' => flags.ignore_case = true,
129 'I' => flags.case_sensitive = true,
130 'c' => flags.confirm = true, other => return Err(format!("unknown flag '{other}' in substitute")),
132 }
133 }
134
135 Ok(SubstituteCmd {
136 pattern,
137 replacement,
138 flags,
139 })
140}
141
142pub fn apply_substitute<H: crate::types::Host>(
171 ed: &mut Editor<hjkl_buffer::Buffer, H>,
172 cmd: &SubstituteCmd,
173 line_range: std::ops::RangeInclusive<u32>,
174) -> Result<SubstituteOutcome, SubstError> {
175 let pattern_str: String = match &cmd.pattern {
177 Some(p) => p.clone(),
178 None => ed
179 .last_search()
180 .map(str::to_owned)
181 .ok_or_else(|| "no previous regular expression".to_string())?,
182 };
183
184 let effective_pattern = if cmd.flags.case_sensitive {
189 use crate::search::{CaseMode, resolve_case_mode};
192 let (stripped, _) = resolve_case_mode(&pattern_str, CaseMode::Sensitive);
193 stripped
194 } else if cmd.flags.ignore_case {
195 use crate::search::{CaseMode, resolve_case_mode};
197 let (stripped, _) = resolve_case_mode(&pattern_str, CaseMode::Sensitive);
198 format!("(?i){stripped}")
199 } else {
200 use crate::search::{CaseMode, resolve_case_mode};
202 let base = CaseMode::from_options(ed.settings().ignore_case, ed.settings().smartcase);
203 let (stripped, mode) = resolve_case_mode(&pattern_str, base);
204 if mode == CaseMode::Insensitive {
205 format!("(?i){stripped}")
206 } else {
207 stripped
208 }
209 };
210
211 let regex = Regex::new(&effective_pattern).map_err(|e| format!("bad pattern: {e}"))?;
212
213 ed.push_undo();
214
215 let start = *line_range.start() as usize;
216 let end = *line_range.end() as usize;
217 let rope = crate::types::Query::rope(ed.buffer());
218 let total = rope.len_lines();
219
220 let clamp_end = end.min(total.saturating_sub(1));
221 let mut new_lines: Vec<String> = crate::vim::rope_to_lines_vec(&rope);
222 let mut replacements = 0usize;
223 let mut lines_changed = 0usize;
224 let mut last_changed_row = 0usize;
225
226 if start <= clamp_end {
227 for (row, line) in new_lines[start..=clamp_end].iter_mut().enumerate() {
228 let (replaced, n) = do_replace(®ex, line, &cmd.replacement, cmd.flags.all);
229 if n > 0 {
230 *line = replaced;
231 replacements += n;
232 lines_changed += 1;
233 last_changed_row = start + row;
234 }
235 }
236 }
237
238 if replacements == 0 {
239 ed.pop_last_undo();
240 return Ok(SubstituteOutcome {
241 replacements: 0,
242 lines_changed: 0,
243 });
244 }
245
246 ed.buffer_mut().replace_all(&new_lines.join("\n"));
248
249 let final_total = crate::types::Query::rope(ed.buffer()).len_lines();
252 let cursor_row = last_changed_row.min(final_total.saturating_sub(1));
253 let first_non_blank = crate::buf_helpers::buf_line(ed.buffer(), cursor_row)
254 .unwrap_or_default()
255 .chars()
256 .take_while(|c| *c == ' ' || *c == '\t')
257 .count();
258 let line_len = crate::buf_helpers::buf_line(ed.buffer(), cursor_row)
259 .unwrap_or_default()
260 .chars()
261 .count();
262 let cursor_col = first_non_blank.min(line_len.saturating_sub(1));
263 ed.buffer_mut()
264 .set_cursor(hjkl_buffer::Position::new(cursor_row, cursor_col));
265
266 ed.mark_content_dirty();
267
268 ed.set_last_search(Some(pattern_str), true);
270
271 Ok(SubstituteOutcome {
272 replacements,
273 lines_changed,
274 })
275}
276
277#[derive(Debug, Clone, PartialEq, Eq)]
284pub struct SubstituteMatch {
285 pub row: u32,
287 pub byte_start: u32,
289 pub byte_end: u32,
291 pub replacement: String,
293}
294
295pub fn collect_substitute_matches<H: crate::types::Host>(
307 ed: &crate::Editor<hjkl_buffer::Buffer, H>,
308 cmd: &SubstituteCmd,
309 line_range: std::ops::RangeInclusive<u32>,
310) -> Result<Vec<SubstituteMatch>, SubstError> {
311 let pattern_str: String = match &cmd.pattern {
313 Some(p) => p.clone(),
314 None => ed
315 .last_search()
316 .map(str::to_owned)
317 .ok_or_else(|| "no previous regular expression".to_string())?,
318 };
319
320 let effective_pattern = if cmd.flags.case_sensitive {
321 use crate::search::{CaseMode, resolve_case_mode};
322 let (stripped, _) = resolve_case_mode(&pattern_str, CaseMode::Sensitive);
323 stripped
324 } else if cmd.flags.ignore_case {
325 use crate::search::{CaseMode, resolve_case_mode};
326 let (stripped, _) = resolve_case_mode(&pattern_str, CaseMode::Sensitive);
327 format!("(?i){stripped}")
328 } else {
329 use crate::search::{CaseMode, resolve_case_mode};
330 let base = CaseMode::from_options(ed.settings().ignore_case, ed.settings().smartcase);
331 let (stripped, mode) = resolve_case_mode(&pattern_str, base);
332 if mode == CaseMode::Insensitive {
333 format!("(?i){stripped}")
334 } else {
335 stripped
336 }
337 };
338
339 let regex = Regex::new(&effective_pattern).map_err(|e| format!("bad pattern: {e}"))?;
340
341 let start = *line_range.start() as usize;
342 let end = *line_range.end() as usize;
343 let rope = crate::types::Query::rope(ed.buffer());
344 let total = rope.len_lines();
345 let clamp_end = end.min(total.saturating_sub(1));
346
347 let mut matches: Vec<SubstituteMatch> = Vec::new();
348
349 if start <= clamp_end {
350 for row in start..=clamp_end {
351 let line = hjkl_buffer::rope_line_str(&rope, row);
352 let line = line.trim_end_matches('\n');
354
355 if cmd.flags.all {
356 for m in regex.find_iter(line) {
357 let replacement = regex
362 .captures_at(line, m.start())
363 .map(|caps| {
364 let mut rep = String::new();
365 caps.expand(&cmd.replacement, &mut rep);
366 rep
367 })
368 .unwrap_or_else(|| cmd.replacement.clone());
369
370 matches.push(SubstituteMatch {
371 row: row as u32,
372 byte_start: m.start() as u32,
373 byte_end: m.end() as u32,
374 replacement,
375 });
376 }
377 } else {
378 if let Some(m) = regex.find(line) {
380 let replacement = regex
384 .captures_at(line, m.start())
385 .map(|caps| {
386 let mut rep = String::new();
387 caps.expand(&cmd.replacement, &mut rep);
388 rep
389 })
390 .unwrap_or_else(|| cmd.replacement.clone());
391
392 matches.push(SubstituteMatch {
393 row: row as u32,
394 byte_start: m.start() as u32,
395 byte_end: m.end() as u32,
396 replacement,
397 });
398 }
399 }
400 }
401 }
402
403 Ok(matches)
404}
405
406pub fn apply_collected_matches<H: crate::types::Host>(
419 ed: &mut crate::Editor<hjkl_buffer::Buffer, H>,
420 matches: &[SubstituteMatch],
421 accepted: &[bool],
422) -> usize {
423 assert_eq!(
424 matches.len(),
425 accepted.len(),
426 "apply_collected_matches: accepted.len() must equal matches.len()"
427 );
428
429 let mut to_apply: Vec<&SubstituteMatch> = matches
432 .iter()
433 .zip(accepted.iter())
434 .filter_map(|(m, &ok)| if ok { Some(m) } else { None })
435 .collect();
436
437 if to_apply.is_empty() {
438 return 0;
439 }
440
441 to_apply.sort_unstable_by(|a, b| b.row.cmp(&a.row).then(b.byte_start.cmp(&a.byte_start)));
442
443 let rope = crate::types::Query::rope(ed.buffer());
444 let mut lines_vec: Vec<String> = crate::vim::rope_to_lines_vec(&rope);
445 let mut applied = 0usize;
446 let mut last_changed_row: Option<usize> = None;
447
448 for sm in &to_apply {
449 let row = sm.row as usize;
450 if row >= lines_vec.len() {
451 continue;
452 }
453 let line = &lines_vec[row];
454 let bs = sm.byte_start as usize;
455 let be = sm.byte_end as usize;
456 if be > line.len() || bs > be {
457 continue;
458 }
459 if !line.is_char_boundary(bs) || !line.is_char_boundary(be) {
462 continue;
463 }
464 let mut new_line = String::with_capacity(line.len() + sm.replacement.len());
466 new_line.push_str(&line[..bs]);
467 new_line.push_str(&sm.replacement);
468 new_line.push_str(&line[be..]);
469 lines_vec[row] = new_line;
470 applied += 1;
471 last_changed_row = Some(row);
472 }
473
474 if applied > 0 {
475 ed.buffer_mut().replace_all(&lines_vec.join("\n"));
476 if let Some(row) = last_changed_row {
477 ed.buffer_mut()
478 .set_cursor(hjkl_buffer::Position::new(row, 0));
479 }
480 ed.mark_content_dirty();
481 }
482
483 applied
484}
485
486fn split_on_slash(s: &str) -> Vec<String> {
493 let mut out: Vec<String> = Vec::new();
494 let mut cur = String::new();
495 let mut chars = s.chars().peekable();
496 while let Some(c) = chars.next() {
497 if c == '\\' {
498 match chars.peek() {
499 Some(&'/') => {
500 cur.push('/');
502 chars.next();
503 }
504 Some(_) => {
505 let next = chars.next().unwrap();
508 cur.push('\\');
509 cur.push(next);
510 }
511 None => cur.push('\\'),
512 }
513 } else if c == '/' {
514 if out.len() < 2 {
515 out.push(std::mem::take(&mut cur));
516 } else {
517 cur.push(c);
521 }
525 } else {
526 cur.push(c);
527 }
528 }
529 out.push(cur);
530 out
531}
532
533fn translate_replacement(s: &str) -> String {
546 let mut out = String::with_capacity(s.len() + 4);
547 let mut chars = s.chars().peekable();
548 while let Some(c) = chars.next() {
549 match c {
550 '&' => out.push_str("${0}"),
551 '$' => out.push_str("$$"),
554 '\\' => match chars.next() {
555 Some('&') => out.push('&'), Some('\\') => out.push('\\'), Some('r') => out.push('\n'), Some('t') => out.push('\t'), Some('n') => out.push('\0'), Some(d @ '0'..='9') => {
561 out.push_str("${");
562 out.push(d);
563 out.push('}');
564 }
565 Some(other) => out.push(other), None => {} },
568 _ => out.push(c),
569 }
570 }
571 out
572}
573
574fn do_replace(regex: &Regex, text: &str, replacement: &str, all: bool) -> (String, usize) {
577 let matches = regex.find_iter(text).count();
578 if matches == 0 {
579 return (text.to_string(), 0);
580 }
581 let replaced = if all {
582 regex.replace_all(text, replacement).into_owned()
583 } else {
584 regex.replace(text, replacement).into_owned()
585 };
586 let count = if all { matches } else { 1 };
587 (replaced, count)
588}
589
590#[cfg(test)]
591mod tests {
592 use super::*;
593 use crate::types::{DefaultHost, Options};
594 use hjkl_buffer::Buffer;
595
596 fn editor_with(content: &str) -> Editor<Buffer, DefaultHost> {
597 let mut e = Editor::new(Buffer::new(), DefaultHost::new(), Options::default());
598 e.set_content(content);
599 e
600 }
601
602 fn buf_line(e: &Editor<Buffer, DefaultHost>, row: usize) -> String {
603 hjkl_buffer::rope_line_str(&e.buffer().rope(), row)
604 }
605
606 #[test]
609 fn parse_basic() {
610 let cmd = parse_substitute("/foo/bar/").unwrap();
611 assert_eq!(cmd.pattern.as_deref(), Some("foo"));
612 assert_eq!(cmd.replacement, "bar");
613 assert!(!cmd.flags.all);
614 }
615
616 #[test]
617 fn parse_trailing_slash_optional() {
618 let cmd = parse_substitute("/foo/bar").unwrap();
619 assert_eq!(cmd.pattern.as_deref(), Some("foo"));
620 assert_eq!(cmd.replacement, "bar");
621 }
622
623 #[test]
624 fn parse_global_flag() {
625 let cmd = parse_substitute("/x/y/g").unwrap();
626 assert!(cmd.flags.all);
627 }
628
629 #[test]
630 fn parse_ignore_case_flag() {
631 let cmd = parse_substitute("/x/y/i").unwrap();
632 assert!(cmd.flags.ignore_case);
633 }
634
635 #[test]
636 fn parse_case_sensitive_flag() {
637 let cmd = parse_substitute("/x/y/I").unwrap();
638 assert!(cmd.flags.case_sensitive);
639 }
640
641 #[test]
642 fn parse_confirm_flag_accepted() {
643 let cmd = parse_substitute("/x/y/c").unwrap();
644 assert!(cmd.flags.confirm);
645 }
646
647 #[test]
648 fn parse_multi_flags() {
649 let cmd = parse_substitute("/x/y/gi").unwrap();
650 assert!(cmd.flags.all);
651 assert!(cmd.flags.ignore_case);
652 }
653
654 #[test]
655 fn parse_unknown_flag_errors() {
656 let err = parse_substitute("/x/y/z").unwrap_err();
657 assert!(err.to_string().contains("unknown flag 'z'"), "{err}");
658 }
659
660 #[test]
661 fn parse_empty_pattern_is_none() {
662 let cmd = parse_substitute("//bar/").unwrap();
663 assert!(cmd.pattern.is_none());
664 assert_eq!(cmd.replacement, "bar");
665 }
666
667 #[test]
668 fn parse_empty_replacement_ok() {
669 let cmd = parse_substitute("/foo//").unwrap();
670 assert_eq!(cmd.pattern.as_deref(), Some("foo"));
671 assert_eq!(cmd.replacement, "");
672 }
673
674 #[test]
675 fn parse_escaped_slash_in_pattern() {
676 let cmd = parse_substitute("/a\\/b/c/").unwrap();
677 assert_eq!(cmd.pattern.as_deref(), Some("a/b"));
678 }
679
680 #[test]
681 fn parse_escaped_slash_in_replacement() {
682 let cmd = parse_substitute("/a/b\\/c/").unwrap();
683 assert_eq!(cmd.replacement, "b/c");
685 }
686
687 #[test]
688 fn parse_ampersand_becomes_dollar_zero() {
689 let cmd = parse_substitute("/foo/[&]/").unwrap();
690 assert_eq!(cmd.replacement, "[${0}]");
691 }
692
693 #[test]
694 fn parse_escaped_ampersand_is_literal() {
695 let cmd = parse_substitute("/foo/\\&/").unwrap();
696 assert_eq!(cmd.replacement, "&");
697 }
698
699 #[test]
700 fn parse_group_ref_translates() {
701 let cmd = parse_substitute("/(foo)/\\1/").unwrap();
702 assert_eq!(cmd.replacement, "${1}");
703 }
704
705 #[test]
706 fn parse_group_ref_nine() {
707 let cmd = parse_substitute("/(x)/\\9/").unwrap();
708 assert_eq!(cmd.replacement, "${9}");
709 }
710
711 #[test]
712 fn parse_wrong_delimiter_errors() {
713 let err = parse_substitute("|foo|bar|").unwrap_err();
714 assert!(err.to_string().contains("'/'"), "{err}");
715 }
716
717 #[test]
718 fn parse_too_few_fields_errors() {
719 let err = parse_substitute("/foo").unwrap_err();
720 assert!(
721 err.to_string().contains("needs /pattern/replacement"),
722 "{err}"
723 );
724 }
725
726 #[test]
729 fn apply_single_line_first_only() {
730 let mut e = editor_with("foo foo");
731 let cmd = parse_substitute("/foo/bar/").unwrap();
732 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
733 assert_eq!(out.replacements, 1);
734 assert_eq!(out.lines_changed, 1);
735 assert_eq!(buf_line(&e, 0), "bar foo");
736 }
737
738 #[test]
739 fn apply_single_line_global() {
740 let mut e = editor_with("foo foo foo");
741 let cmd = parse_substitute("/foo/bar/g").unwrap();
742 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
743 assert_eq!(out.replacements, 3);
744 assert_eq!(out.lines_changed, 1);
745 assert_eq!(buf_line(&e, 0), "bar bar bar");
746 }
747
748 #[test]
749 fn apply_multi_line_range() {
750 let mut e = editor_with("foo\nfoo foo\nbar");
751 let cmd = parse_substitute("/foo/xyz/g").unwrap();
752 let out = apply_substitute(&mut e, &cmd, 0..=2).unwrap();
753 assert_eq!(out.replacements, 3);
754 assert_eq!(out.lines_changed, 2);
755 assert_eq!(buf_line(&e, 0), "xyz");
756 assert_eq!(buf_line(&e, 1), "xyz xyz");
757 assert_eq!(buf_line(&e, 2), "bar");
758 }
759
760 #[test]
761 fn apply_no_match_returns_zero() {
762 let mut e = editor_with("hello");
763 let original = buf_line(&e, 0);
764 let cmd = parse_substitute("/xyz/abc/").unwrap();
765 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
766 assert_eq!(out.replacements, 0);
767 assert_eq!(out.lines_changed, 0);
768 assert_eq!(buf_line(&e, 0), original);
769 }
770
771 #[test]
772 fn apply_case_insensitive_flag() {
773 let mut e = editor_with("Foo FOO foo");
774 let cmd = parse_substitute("/foo/bar/gi").unwrap();
775 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
776 assert_eq!(out.replacements, 3);
777 assert_eq!(buf_line(&e, 0), "bar bar bar");
778 }
779
780 #[test]
781 fn apply_case_sensitive_flag_overrides_editor_setting() {
782 let mut e = editor_with("Foo foo");
783 e.settings_mut().ignore_case = true;
785 let cmd = parse_substitute("/foo/bar/I").unwrap();
787 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
788 assert_eq!(out.replacements, 1);
790 assert_eq!(buf_line(&e, 0), "Foo bar");
791 }
792
793 #[test]
794 fn apply_empty_pattern_reuses_last_search() {
795 let mut e = editor_with("hello world");
796 e.set_last_search(Some("world".to_string()), true);
797 let cmd = parse_substitute("//planet/").unwrap();
798 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
799 assert_eq!(out.replacements, 1);
800 assert_eq!(buf_line(&e, 0), "hello planet");
801 }
802
803 #[test]
804 fn apply_empty_pattern_no_last_search_errors() {
805 let mut e = editor_with("hello");
806 let cmd = parse_substitute("//bar/").unwrap();
807 let err = apply_substitute(&mut e, &cmd, 0..=0).unwrap_err();
808 assert!(
809 err.to_string().contains("no previous regular expression"),
810 "{err}"
811 );
812 }
813
814 #[test]
815 fn apply_updates_last_search() {
816 let mut e = editor_with("foo");
817 let cmd = parse_substitute("/foo/bar/").unwrap();
818 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
819 assert_eq!(e.last_search(), Some("foo"));
820 }
821
822 #[test]
823 fn apply_empty_replacement_deletes_match() {
824 let mut e = editor_with("hello world");
825 let cmd = parse_substitute("/world//").unwrap();
826 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
827 assert_eq!(out.replacements, 1);
828 assert_eq!(buf_line(&e, 0), "hello ");
829 }
830
831 #[test]
832 fn apply_undo_reverts_in_one_step() {
833 let mut e = editor_with("foo");
834 let cmd = parse_substitute("/foo/bar/").unwrap();
835 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
836 assert_eq!(buf_line(&e, 0), "bar");
837 e.undo();
838 assert_eq!(buf_line(&e, 0), "foo");
839 }
840
841 #[test]
842 fn apply_ampersand_in_replacement() {
843 let mut e = editor_with("foo");
844 let cmd = parse_substitute("/foo/[&]/").unwrap();
845 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
846 assert_eq!(buf_line(&e, 0), "[foo]");
847 }
848
849 #[test]
850 fn apply_capture_group_reference() {
851 let mut e = editor_with("hello world");
852 let cmd = parse_substitute("/(\\w+)/<<\\1>>/g").unwrap();
853 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
854 assert_eq!(buf_line(&e, 0), "<<hello>> <<world>>");
855 }
856
857 #[test]
858 fn apply_backslash_r_splits_line() {
859 let mut e = editor_with("a,b,c");
862 let cmd = parse_substitute("/,/\\r/g").unwrap();
863 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
864 assert_eq!(buf_line(&e, 0), "a");
865 assert_eq!(buf_line(&e, 1), "b");
866 assert_eq!(buf_line(&e, 2), "c");
867 }
868
869 #[test]
870 fn apply_backslash_t_inserts_tab() {
871 let mut e = editor_with("a,b");
872 let cmd = parse_substitute("/,/\\t/").unwrap();
873 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
874 assert_eq!(buf_line(&e, 0), "a\tb");
875 }
876
877 #[test]
878 fn apply_literal_dollar_in_replacement() {
879 let mut e = editor_with("x");
882 let cmd = parse_substitute("/x/$5/").unwrap();
883 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
884 assert_eq!(buf_line(&e, 0), "$5");
885 }
886
887 #[test]
888 fn apply_backslash_zero_is_whole_match() {
889 let mut e = editor_with("foo");
891 let cmd = parse_substitute("/foo/[\\0]/").unwrap();
892 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
893 assert_eq!(buf_line(&e, 0), "[foo]");
894 }
895
896 #[test]
897 fn apply_group_ref_then_literal_digits() {
898 let mut e = editor_with("ab");
900 let cmd = parse_substitute("/(.)/\\11/g").unwrap();
901 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
902 assert_eq!(buf_line(&e, 0), "a1b1");
903 }
904
905 #[test]
910 fn substitute_respects_smartcase() {
911 let mut e = editor_with("Foo");
912 let cmd = parse_substitute("/foo/bar/").unwrap();
914 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
915 assert_eq!(out.replacements, 1);
916 assert_eq!(buf_line(&e, 0), "bar");
917 }
918
919 #[test]
922 fn substitute_i_flag_overrides_c() {
923 let mut e = editor_with("foo");
924 let cmd = parse_substitute("/Foo/bar/i").unwrap();
926 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
927 assert_eq!(out.replacements, 1, "expected match on 'foo' with /i flag");
928 assert_eq!(buf_line(&e, 0), "bar");
929 }
930
931 #[test]
934 fn substitute_lower_c_inline_overrides_smartcase() {
935 let mut e = editor_with("FOO");
936 let cmd = parse_substitute("/\\cFoo/bar/").unwrap();
938 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
939 assert_eq!(out.replacements, 1);
940 assert_eq!(buf_line(&e, 0), "bar");
941 }
942
943 #[test]
946 fn collect_substitute_matches_finds_all_occurrences() {
947 let e = editor_with("foo bar foo");
948 let cmd = parse_substitute("/foo/baz/g").unwrap();
949 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
950 assert_eq!(matches.len(), 2, "expected 2 matches for /g flag");
951 assert_eq!(matches[0].byte_start, 0);
952 assert_eq!(matches[0].byte_end, 3);
953 assert_eq!(matches[1].byte_start, 8);
954 assert_eq!(matches[1].byte_end, 11);
955 assert_eq!(matches[0].replacement, "baz");
956 assert_eq!(matches[1].replacement, "baz");
957 }
958
959 #[test]
960 fn collect_substitute_matches_respects_g_flag() {
961 let e = editor_with("foo foo foo");
963 let cmd = parse_substitute("/foo/baz/").unwrap();
964 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
965 assert_eq!(matches.len(), 1, "expected 1 match without /g");
966 assert_eq!(matches[0].byte_start, 0);
967 }
968
969 #[test]
970 fn collect_substitute_matches_respects_range() {
971 let e = editor_with("foo\nfoo\nfoo\nfoo\nfoo");
972 let cmd = parse_substitute("/foo/bar/g").unwrap();
973 let matches = collect_substitute_matches(&e, &cmd, 1..=2).unwrap();
975 assert_eq!(matches.len(), 2);
976 assert_eq!(matches[0].row, 1);
977 assert_eq!(matches[1].row, 2);
978 }
979
980 #[test]
981 fn collect_substitute_matches_expands_template() {
982 let e = editor_with("hello world");
983 let cmd = parse_substitute("/(\\w+)/<<\\1>>/g").unwrap();
985 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
986 assert_eq!(matches.len(), 2);
987 assert_eq!(matches[0].replacement, "<<hello>>");
988 assert_eq!(matches[1].replacement, "<<world>>");
989 }
990
991 #[test]
994 fn apply_collected_matches_reverse_order_preserves_offsets() {
995 let mut e = editor_with("foo bar baz");
999 let cmd = parse_substitute("/(foo|bar|baz)/X/g").unwrap();
1000 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1001 assert_eq!(matches.len(), 3);
1002 let accepted = vec![true; 3];
1003 let applied = apply_collected_matches(&mut e, &matches, &accepted);
1004 assert_eq!(applied, 3);
1005 assert_eq!(buf_line(&e, 0), "X X X");
1006 }
1007
1008 #[test]
1009 fn apply_collected_matches_subset_only() {
1010 let mut e = editor_with("foo bar foo");
1012 let cmd = parse_substitute("/foo/ZZZ/g").unwrap();
1013 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1014 assert_eq!(matches.len(), 2, "expected 2 foo matches");
1015 let accepted = vec![true, false];
1017 let applied = apply_collected_matches(&mut e, &matches, &accepted);
1018 assert_eq!(applied, 1);
1019 assert_eq!(buf_line(&e, 0), "ZZZ bar foo");
1021 }
1022
1023 #[test]
1024 fn apply_collected_matches_zero_accepted() {
1025 let mut e = editor_with("foo bar foo");
1026 let cmd = parse_substitute("/foo/ZZZ/g").unwrap();
1027 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1028 let accepted = vec![false; matches.len()];
1029 let applied = apply_collected_matches(&mut e, &matches, &accepted);
1030 assert_eq!(applied, 0);
1031 assert_eq!(buf_line(&e, 0), "foo bar foo");
1032 }
1033
1034 #[test]
1035 fn apply_collected_matches_expands_template() {
1036 let mut e = editor_with("hello world");
1037 let cmd = parse_substitute("/(\\w+)/<<\\1>>/g").unwrap();
1038 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1039 let accepted = vec![true; matches.len()];
1040 let applied = apply_collected_matches(&mut e, &matches, &accepted);
1041 assert_eq!(applied, 2);
1042 assert_eq!(buf_line(&e, 0), "<<hello>> <<world>>");
1043 }
1044}