1use regex::Regex;
34
35use crate::Editor;
36
37pub type SubstError = String;
39
40#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct SubstituteCmd {
45 pub pattern: Option<String>,
48 pub replacement: String,
52 pub flags: SubstFlags,
54 pub count: Option<usize>,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
61pub struct SubstFlags {
62 pub all: bool,
64 pub ignore_case: bool,
66 pub case_sensitive: bool,
68 pub confirm: bool,
72 pub report_only: bool,
76 pub no_error: bool,
79 pub print: bool,
83 pub print_num: bool,
85 pub print_list: bool,
87 pub reuse_previous: bool,
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
95pub struct SubstituteOutcome {
96 pub replacements: usize,
98 pub lines_changed: usize,
100 pub last_row: Option<usize>,
104}
105
106pub fn parse_substitute(s: &str) -> Result<SubstituteCmd, SubstError> {
133 let rest = s
135 .strip_prefix('/')
136 .ok_or_else(|| format!("substitute: expected '/' delimiter, got {s:?}"))?;
137
138 let parts = split_on_slash(rest);
141
142 if parts.len() < 2 {
143 return Err("substitute needs /pattern/replacement/".into());
144 }
145
146 let raw_pattern = &parts[0];
147 let raw_replacement = &parts[1];
148 let raw_flags = parts.get(2).map_or("", String::as_str);
149
150 let pattern = if raw_pattern.is_empty() {
152 None
153 } else {
154 Some(raw_pattern.clone())
155 };
156
157 let replacement = raw_replacement.clone();
160
161 let (flags, count) = parse_flags(raw_flags)?;
162
163 Ok(SubstituteCmd {
164 pattern,
165 replacement,
166 flags,
167 count,
168 })
169}
170
171pub fn parse_flags(raw_flags: &str) -> Result<(SubstFlags, Option<usize>), SubstError> {
182 let mut flags = SubstFlags::default();
183 let mut count: Option<usize> = None;
184 let mut chars = raw_flags.chars().peekable();
185 while let Some(&ch) = chars.peek() {
186 match ch {
187 'g' => flags.all = true,
188 'i' => flags.ignore_case = true,
189 'I' => flags.case_sensitive = true,
190 'c' => flags.confirm = true,
191 'n' => flags.report_only = true,
192 'e' => flags.no_error = true,
193 'p' => flags.print = true,
194 '#' => {
195 flags.print = true;
196 flags.print_num = true;
197 }
198 'l' => {
199 flags.print = true;
200 flags.print_list = true;
201 }
202 '&' => flags.reuse_previous = true,
205 ' ' | '\t' => {}
206 c if c.is_ascii_digit() => break, other => return Err(format!("unknown flag '{other}' in substitute")),
208 }
209 chars.next();
210 }
211 let rest: String = chars.collect();
213 let rest = rest.trim();
214 if !rest.is_empty() {
215 match rest.parse::<usize>() {
216 Ok(n) if n > 0 => count = Some(n),
217 _ => return Err(format!("trailing characters in substitute: {rest:?}")),
218 }
219 }
220 Ok((flags, count))
221}
222
223fn rebase_marks_after_row_growth<H: crate::types::Host>(
244 ed: &mut Editor<hjkl_buffer::View, H>,
245 new_lines: &[String],
246 row_offset: usize,
247) {
248 for (i, line) in new_lines.iter().enumerate().rev() {
249 let delta = line.matches('\n').count() as isize;
250 if delta != 0 {
251 ed.shift_marks_after_edit(row_offset + i, delta);
252 }
253 }
254}
255
256fn emit_whole_buffer_change<H: crate::types::Host>(
268 ed: &mut Editor<hjkl_buffer::View, H>,
269 pre_end: crate::types::Pos,
270 new_text: &str,
271) {
272 ed.buffer_mut().extend_change_log([crate::types::Edit {
273 range: crate::types::Pos::new(0, 0)..pre_end,
274 replacement: new_text.to_string(),
275 }]);
276 ed.buffer_mut().clear_pending_content_edits();
277 ed.buffer_mut().set_pending_content_reset(true);
278}
279
280pub fn apply_substitute<H: crate::types::Host>(
309 ed: &mut Editor<hjkl_buffer::View, H>,
310 cmd: &SubstituteCmd,
311 line_range: std::ops::RangeInclusive<u32>,
312) -> Result<SubstituteOutcome, SubstError> {
313 let pattern_str: String = match &cmd.pattern {
315 Some(p) => p.clone(),
316 None => ed
317 .last_search()
318 .ok_or_else(|| "no previous regular expression".to_string())?,
319 };
320
321 let prev_replacement = ed.last_substitute_replacement();
325
326 let effective_pattern = {
328 use crate::search::{CaseMode, resolve_case_mode};
329 let base = if cmd.flags.case_sensitive {
330 CaseMode::Sensitive
331 } else if cmd.flags.ignore_case {
332 CaseMode::Insensitive
333 } else {
334 CaseMode::from_options(ed.settings().ignore_case, ed.settings().smartcase)
335 };
336 let (stripped, mode) = resolve_case_mode(&pattern_str, base, &prev_replacement);
337 if mode == CaseMode::Insensitive {
338 format!("(?i){stripped}")
339 } else {
340 stripped
341 }
342 };
343
344 let regex = Regex::new(&effective_pattern).map_err(|e| format!("bad pattern: {e}"))?;
345
346 ed.push_undo();
347
348 let start = *line_range.start() as usize;
349 let end = *line_range.end() as usize;
350 let rope = crate::types::Query::rope(ed.buffer());
351 let total = rope.len_lines();
352
353 let clamp_end = end.min(total.saturating_sub(1));
354 let mut new_lines: Vec<String> = (start..=clamp_end)
360 .map(|row| crate::rope_util::rope_line_to_str(&rope, row))
361 .collect();
362 let clamp_row_chars = new_lines.last().map_or(0, |l| l.chars().count());
366 let mut replacements = 0usize;
367 let mut lines_changed = 0usize;
368 let mut last_changed_row = 0usize;
369
370 if start <= clamp_end {
371 for (row, line) in new_lines.iter_mut().enumerate() {
372 let (replaced, n) = do_replace(
373 ®ex,
374 line,
375 &cmd.replacement,
376 &prev_replacement,
377 cmd.flags.all,
378 );
379 if n > 0 {
380 *line = replaced;
381 replacements += n;
382 lines_changed += 1;
383 last_changed_row = start + row;
384 }
385 }
386 }
387
388 if replacements == 0 {
389 ed.pop_last_undo();
390 return Ok(SubstituteOutcome {
391 replacements: 0,
392 lines_changed: 0,
393 last_row: None,
394 });
395 }
396
397 if cmd.flags.report_only {
400 ed.pop_last_undo();
401 ed.set_last_search(Some(pattern_str), true);
402 return Ok(SubstituteOutcome {
403 replacements,
404 lines_changed,
405 last_row: None,
406 });
407 }
408
409 let changed_local = last_changed_row - start;
420 let newlines_before: usize = new_lines[..changed_local]
421 .iter()
422 .map(|l| l.matches('\n').count())
423 .sum();
424 let newlines_within = new_lines[changed_local].matches('\n').count();
425 let last_changed_row = last_changed_row + newlines_before + newlines_within;
426
427 let pre_rows = crate::types::Query::rope(ed.buffer()).len_lines();
435 let last_pre_row = pre_rows.saturating_sub(1);
436 let pre_end = crate::types::Pos::new(
437 last_pre_row as u32,
438 crate::buf_helpers::buf_line(ed.buffer(), last_pre_row)
439 .unwrap_or_default()
440 .chars()
441 .count() as u32,
442 );
443 let range_text = new_lines.join("\n");
444 let splice = hjkl_buffer::Edit::Replace {
445 start: hjkl_buffer::Position::new(start, 0),
451 end: hjkl_buffer::Position::new(clamp_end, clamp_row_chars),
452 with: range_text,
453 };
454 let _ = ed.buffer_mut().apply_edit(splice);
455
456 let new_text = if ed.buffer().change_log_enabled() {
461 crate::types::Query::rope(ed.buffer()).to_string()
462 } else {
463 String::new()
464 };
465 emit_whole_buffer_change(ed, pre_end, &new_text);
466
467 rebase_marks_after_row_growth(ed, &new_lines, start);
473
474 let final_total = crate::types::Query::rope(ed.buffer()).len_lines();
477 let cursor_row = last_changed_row.min(final_total.saturating_sub(1));
478 let first_non_blank = crate::buf_helpers::buf_line(ed.buffer(), cursor_row)
479 .unwrap_or_default()
480 .chars()
481 .take_while(|c| *c == ' ' || *c == '\t')
482 .count();
483 let line_len = crate::buf_helpers::buf_line(ed.buffer(), cursor_row)
484 .unwrap_or_default()
485 .chars()
486 .count();
487 let cursor_col = first_non_blank.min(line_len.saturating_sub(1));
488 ed.jump_cursor(cursor_row, cursor_col);
492
493 ed.mark_content_dirty();
494
495 ed.set_last_search(Some(pattern_str), true);
497
498 Ok(SubstituteOutcome {
499 replacements,
500 lines_changed,
501 last_row: Some(cursor_row),
502 })
503}
504
505#[derive(Debug, Clone, PartialEq, Eq)]
512pub struct SubstituteMatch {
513 pub row: u32,
515 pub byte_start: u32,
517 pub byte_end: u32,
519 pub replacement: String,
521}
522
523pub fn collect_substitute_matches<H: crate::types::Host>(
535 ed: &crate::Editor<hjkl_buffer::View, H>,
536 cmd: &SubstituteCmd,
537 line_range: std::ops::RangeInclusive<u32>,
538) -> Result<Vec<SubstituteMatch>, SubstError> {
539 let pattern_str: String = match &cmd.pattern {
541 Some(p) => p.clone(),
542 None => ed
543 .last_search()
544 .ok_or_else(|| "no previous regular expression".to_string())?,
545 };
546
547 let prev_replacement = ed.last_substitute_replacement();
550
551 let effective_pattern = {
552 use crate::search::{CaseMode, resolve_case_mode};
553 let base = if cmd.flags.case_sensitive {
554 CaseMode::Sensitive
555 } else if cmd.flags.ignore_case {
556 CaseMode::Insensitive
557 } else {
558 CaseMode::from_options(ed.settings().ignore_case, ed.settings().smartcase)
559 };
560 let (stripped, mode) = resolve_case_mode(&pattern_str, base, &prev_replacement);
561 if mode == CaseMode::Insensitive {
562 format!("(?i){stripped}")
563 } else {
564 stripped
565 }
566 };
567
568 let regex = Regex::new(&effective_pattern).map_err(|e| format!("bad pattern: {e}"))?;
569
570 let start = *line_range.start() as usize;
571 let end = *line_range.end() as usize;
572 let rope = crate::types::Query::rope(ed.buffer());
573 let total = rope.len_lines();
574 let clamp_end = end.min(total.saturating_sub(1));
575
576 let mut matches: Vec<SubstituteMatch> = Vec::new();
577
578 let expand = |line: &str, start: usize| {
582 regex
583 .captures_at(line, start)
584 .map(|caps| expand_replacement(&cmd.replacement, &caps, &prev_replacement))
585 .unwrap_or_default()
586 };
587
588 if start <= clamp_end {
589 for row in start..=clamp_end {
590 let line = crate::viewport_math::rope_line_slice(&rope, row);
592 let line = line.trim_end_matches('\n');
594
595 if cmd.flags.all {
596 for m in regex.find_iter(line) {
597 matches.push(SubstituteMatch {
598 row: row as u32,
599 byte_start: m.start() as u32,
600 byte_end: m.end() as u32,
601 replacement: expand(line, m.start()),
602 });
603 }
604 } else if let Some(m) = regex.find(line) {
605 matches.push(SubstituteMatch {
607 row: row as u32,
608 byte_start: m.start() as u32,
609 byte_end: m.end() as u32,
610 replacement: expand(line, m.start()),
611 });
612 }
613 }
614 }
615
616 Ok(matches)
617}
618
619pub fn apply_collected_matches<H: crate::types::Host>(
632 ed: &mut crate::Editor<hjkl_buffer::View, H>,
633 matches: &[SubstituteMatch],
634 accepted: &[bool],
635) -> usize {
636 assert_eq!(
637 matches.len(),
638 accepted.len(),
639 "apply_collected_matches: accepted.len() must equal matches.len()"
640 );
641
642 let mut to_apply: Vec<&SubstituteMatch> = matches
645 .iter()
646 .zip(accepted.iter())
647 .filter_map(|(m, &ok)| if ok { Some(m) } else { None })
648 .collect();
649
650 if to_apply.is_empty() {
651 return 0;
652 }
653
654 to_apply.sort_unstable_by(|a, b| b.row.cmp(&a.row).then(b.byte_start.cmp(&a.byte_start)));
655
656 let rope = crate::types::Query::rope(ed.buffer());
657 let mut lines_vec: Vec<String> = crate::rope_util::rope_to_lines_vec(&rope);
658 let mut applied = 0usize;
659 let mut last_changed_row: Option<usize> = None;
660
661 for sm in &to_apply {
662 let row = sm.row as usize;
663 if row >= lines_vec.len() {
664 continue;
665 }
666 let line = &lines_vec[row];
667 let bs = sm.byte_start as usize;
668 let be = sm.byte_end as usize;
669 if be > line.len() || bs > be {
670 continue;
671 }
672 if !line.is_char_boundary(bs) || !line.is_char_boundary(be) {
675 continue;
676 }
677 let mut new_line = String::with_capacity(line.len() + sm.replacement.len());
679 new_line.push_str(&line[..bs]);
680 new_line.push_str(&sm.replacement);
681 new_line.push_str(&line[be..]);
682 lines_vec[row] = new_line;
683 applied += 1;
684 last_changed_row = Some(last_changed_row.map_or(row, |lr: usize| lr.max(row)));
689 }
690
691 if applied > 0 {
692 let pre_rows = crate::types::Query::rope(ed.buffer()).len_lines();
696 let last_pre_row = pre_rows.saturating_sub(1);
697 let pre_end = crate::types::Pos::new(
698 last_pre_row as u32,
699 crate::buf_helpers::buf_line(ed.buffer(), last_pre_row)
700 .unwrap_or_default()
701 .chars()
702 .count() as u32,
703 );
704 let new_text = lines_vec.join("\n");
705 ed.buffer_mut().replace_all(&new_text);
706 emit_whole_buffer_change(ed, pre_end, &new_text);
707 rebase_marks_after_row_growth(ed, &lines_vec, 0);
712 if let Some(row) = last_changed_row {
713 let newlines_before: usize = lines_vec[..row]
718 .iter()
719 .map(|l| l.matches('\n').count())
720 .sum();
721 let newlines_within = lines_vec[row].matches('\n').count();
722 let row = row + newlines_before + newlines_within;
723 ed.jump_cursor(row, 0);
726 }
727 ed.mark_content_dirty();
728 }
729
730 applied
731}
732
733fn split_on_slash(s: &str) -> Vec<String> {
740 let mut out: Vec<String> = Vec::new();
741 let mut cur = String::new();
742 let mut chars = s.chars().peekable();
743 while let Some(c) = chars.next() {
744 if c == '\\' {
745 match chars.peek() {
746 Some(&'/') => {
747 cur.push('/');
749 chars.next();
750 }
751 Some(_) => {
752 let next = chars.next().unwrap();
755 cur.push('\\');
756 cur.push(next);
757 }
758 None => cur.push('\\'),
759 }
760 } else if c == '/' {
761 if out.len() < 2 {
762 out.push(std::mem::take(&mut cur));
763 } else {
764 cur.push(c);
768 }
772 } else {
773 cur.push(c);
774 }
775 }
776 out.push(cur);
777 out
778}
779
780#[derive(Clone, Copy, PartialEq)]
783enum SpanCase {
784 None,
785 Upper,
787 Lower,
789}
790
791#[derive(Clone, Copy, PartialEq)]
797enum OneShotCase {
798 Upper,
799 Lower,
800}
801
802#[derive(Clone, Copy, PartialEq)]
804struct CaseState {
805 span: SpanCase,
806 one_shot: Option<OneShotCase>,
807}
808
809impl CaseState {
810 fn new() -> Self {
811 Self {
812 span: SpanCase::None,
813 one_shot: None,
814 }
815 }
816}
817
818fn push_cased(out: &mut String, case: &mut CaseState, ch: char) {
822 let effective = match case.one_shot.take() {
823 Some(OneShotCase::Upper) => Some(SpanCase::Upper),
824 Some(OneShotCase::Lower) => Some(SpanCase::Lower),
825 None => match case.span {
826 SpanCase::None => None,
827 other => Some(other),
828 },
829 };
830 match effective {
831 None => out.push(ch),
832 Some(SpanCase::Upper) => out.extend(ch.to_uppercase()),
833 Some(SpanCase::Lower) => out.extend(ch.to_lowercase()),
834 Some(SpanCase::None) => unreachable!(),
835 }
836}
837
838fn expand_replacement(raw: &str, caps: ®ex::Captures, prev: &str) -> String {
852 let mut out = String::with_capacity(raw.len() + 8);
853 expand_into(&mut out, raw, caps, prev, true);
854 out
855}
856
857fn expand_into(out: &mut String, raw: &str, caps: ®ex::Captures, prev: &str, allow_tilde: bool) {
858 let mut case = CaseState::new();
859 let mut chars = raw.chars();
860 while let Some(c) = chars.next() {
861 match c {
862 '&' => {
863 let g = caps.get(0).map_or("", |m| m.as_str());
864 for ch in g.chars() {
865 push_cased(out, &mut case, ch);
866 }
867 }
868 '~' if allow_tilde => {
869 let mut tmp = String::new();
872 expand_into(&mut tmp, prev, caps, "", false);
873 for ch in tmp.chars() {
874 push_cased(out, &mut case, ch);
875 }
876 }
877 '\\' => match chars.next() {
878 Some('&') => push_cased(out, &mut case, '&'),
879 Some('~') => push_cased(out, &mut case, '~'),
880 Some('\\') => push_cased(out, &mut case, '\\'),
881 Some('r') => out.push('\n'),
883 Some('t') => out.push('\t'),
884 Some('n') => out.push('\0'),
885 Some(d @ '0'..='9') => {
886 let idx = d as usize - '0' as usize;
887 let g = caps.get(idx).map_or("", |m| m.as_str());
888 for ch in g.chars() {
889 push_cased(out, &mut case, ch);
890 }
891 }
892 Some('u') => case.one_shot = Some(OneShotCase::Upper),
893 Some('l') => case.one_shot = Some(OneShotCase::Lower),
894 Some('U') => case.span = SpanCase::Upper,
895 Some('L') => case.span = SpanCase::Lower,
896 Some('e') | Some('E') => case.span = SpanCase::None,
897 Some(other) => push_cased(out, &mut case, other),
898 None => {} },
900 _ => push_cased(out, &mut case, c),
901 }
902 }
903}
904
905fn do_replace(
909 regex: &Regex,
910 text: &str,
911 replacement: &str,
912 prev: &str,
913 all: bool,
914) -> (String, usize) {
915 let matches = regex.find_iter(text).count();
916 if matches == 0 {
917 return (text.to_string(), 0);
918 }
919 let rep = |caps: ®ex::Captures| expand_replacement(replacement, caps, prev);
920 let replaced = if all {
921 regex.replace_all(text, rep).into_owned()
922 } else {
923 regex.replace(text, rep).into_owned()
924 };
925 let count = if all { matches } else { 1 };
926 (replaced, count)
927}
928
929#[cfg(test)]
930mod tests {
931 use super::*;
932 use crate::types::{DefaultHost, Options};
933 use hjkl_buffer::View;
934
935 fn editor_with(content: &str) -> Editor<View, DefaultHost> {
936 let mut e = Editor::new(View::new(), DefaultHost::new(), Options::default());
937 e.buffer_mut().set_change_log_enabled(true);
938 e.set_content(content);
939 e
940 }
941
942 fn buf_line(e: &Editor<View, DefaultHost>, row: usize) -> String {
943 hjkl_buffer::rope_line_str(&e.buffer().rope(), row)
944 }
945
946 #[test]
949 fn parse_basic() {
950 let cmd = parse_substitute("/foo/bar/").unwrap();
951 assert_eq!(cmd.pattern.as_deref(), Some("foo"));
952 assert_eq!(cmd.replacement, "bar");
953 assert!(!cmd.flags.all);
954 }
955
956 #[test]
957 fn parse_trailing_slash_optional() {
958 let cmd = parse_substitute("/foo/bar").unwrap();
959 assert_eq!(cmd.pattern.as_deref(), Some("foo"));
960 assert_eq!(cmd.replacement, "bar");
961 }
962
963 #[test]
964 fn parse_global_flag() {
965 let cmd = parse_substitute("/x/y/g").unwrap();
966 assert!(cmd.flags.all);
967 }
968
969 #[test]
970 fn parse_ignore_case_flag() {
971 let cmd = parse_substitute("/x/y/i").unwrap();
972 assert!(cmd.flags.ignore_case);
973 }
974
975 #[test]
976 fn parse_case_sensitive_flag() {
977 let cmd = parse_substitute("/x/y/I").unwrap();
978 assert!(cmd.flags.case_sensitive);
979 }
980
981 #[test]
982 fn parse_confirm_flag_accepted() {
983 let cmd = parse_substitute("/x/y/c").unwrap();
984 assert!(cmd.flags.confirm);
985 }
986
987 #[test]
988 fn parse_multi_flags() {
989 let cmd = parse_substitute("/x/y/gi").unwrap();
990 assert!(cmd.flags.all);
991 assert!(cmd.flags.ignore_case);
992 }
993
994 #[test]
995 fn parse_unknown_flag_errors() {
996 let err = parse_substitute("/x/y/z").unwrap_err();
997 assert!(err.to_string().contains("unknown flag 'z'"), "{err}");
998 }
999
1000 #[test]
1001 fn parse_empty_pattern_is_none() {
1002 let cmd = parse_substitute("//bar/").unwrap();
1003 assert!(cmd.pattern.is_none());
1004 assert_eq!(cmd.replacement, "bar");
1005 }
1006
1007 #[test]
1008 fn parse_empty_replacement_ok() {
1009 let cmd = parse_substitute("/foo//").unwrap();
1010 assert_eq!(cmd.pattern.as_deref(), Some("foo"));
1011 assert_eq!(cmd.replacement, "");
1012 }
1013
1014 #[test]
1015 fn parse_escaped_slash_in_pattern() {
1016 let cmd = parse_substitute("/a\\/b/c/").unwrap();
1017 assert_eq!(cmd.pattern.as_deref(), Some("a/b"));
1018 }
1019
1020 #[test]
1021 fn parse_escaped_slash_in_replacement() {
1022 let cmd = parse_substitute("/a/b\\/c/").unwrap();
1023 assert_eq!(cmd.replacement, "b/c");
1025 }
1026
1027 #[test]
1030 fn parse_keeps_replacement_raw() {
1031 assert_eq!(parse_substitute("/foo/[&]/").unwrap().replacement, "[&]");
1032 assert_eq!(parse_substitute("/foo/\\&/").unwrap().replacement, "\\&");
1033 assert_eq!(parse_substitute("/(foo)/\\1/").unwrap().replacement, "\\1");
1034 assert_eq!(parse_substitute("/(x)/\\9/").unwrap().replacement, "\\9");
1035 }
1036
1037 #[test]
1038 fn parse_wrong_delimiter_errors() {
1039 let err = parse_substitute("|foo|bar|").unwrap_err();
1040 assert!(err.to_string().contains("'/'"), "{err}");
1041 }
1042
1043 #[test]
1044 fn parse_too_few_fields_errors() {
1045 let err = parse_substitute("/foo").unwrap_err();
1046 assert!(
1047 err.to_string().contains("needs /pattern/replacement"),
1048 "{err}"
1049 );
1050 }
1051
1052 #[test]
1055 fn apply_single_line_first_only() {
1056 let mut e = editor_with("foo foo");
1057 let cmd = parse_substitute("/foo/bar/").unwrap();
1058 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1059 assert_eq!(out.replacements, 1);
1060 assert_eq!(out.lines_changed, 1);
1061 assert_eq!(buf_line(&e, 0), "bar foo");
1062 }
1063
1064 #[test]
1065 fn apply_single_line_global() {
1066 let mut e = editor_with("foo foo foo");
1067 let cmd = parse_substitute("/foo/bar/g").unwrap();
1068 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1069 assert_eq!(out.replacements, 3);
1070 assert_eq!(out.lines_changed, 1);
1071 assert_eq!(buf_line(&e, 0), "bar bar bar");
1072 }
1073
1074 #[test]
1075 fn apply_multi_line_range() {
1076 let mut e = editor_with("foo\nfoo foo\nbar");
1077 let cmd = parse_substitute("/foo/xyz/g").unwrap();
1078 let out = apply_substitute(&mut e, &cmd, 0..=2).unwrap();
1079 assert_eq!(out.replacements, 3);
1080 assert_eq!(out.lines_changed, 2);
1081 assert_eq!(buf_line(&e, 0), "xyz");
1082 assert_eq!(buf_line(&e, 1), "xyz xyz");
1083 assert_eq!(buf_line(&e, 2), "bar");
1084 }
1085
1086 #[test]
1087 fn apply_no_match_returns_zero() {
1088 let mut e = editor_with("hello");
1089 let original = buf_line(&e, 0);
1090 let cmd = parse_substitute("/xyz/abc/").unwrap();
1091 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1092 assert_eq!(out.replacements, 0);
1093 assert_eq!(out.lines_changed, 0);
1094 assert_eq!(buf_line(&e, 0), original);
1095 }
1096
1097 #[test]
1098 fn apply_case_insensitive_flag() {
1099 let mut e = editor_with("Foo FOO foo");
1100 let cmd = parse_substitute("/foo/bar/gi").unwrap();
1101 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1102 assert_eq!(out.replacements, 3);
1103 assert_eq!(buf_line(&e, 0), "bar bar bar");
1104 }
1105
1106 #[test]
1107 fn apply_case_sensitive_flag_overrides_editor_setting() {
1108 let mut e = editor_with("Foo foo");
1109 e.settings_mut().ignore_case = true;
1111 let cmd = parse_substitute("/foo/bar/I").unwrap();
1113 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1114 assert_eq!(out.replacements, 1);
1116 assert_eq!(buf_line(&e, 0), "Foo bar");
1117 }
1118
1119 #[test]
1120 fn apply_inline_case_override_wins_over_flag() {
1121 let mut insensitive = editor_with("Foo FOO foo");
1122 let cmd = parse_substitute("/\\cFOO/bar/I").unwrap();
1123 let out = apply_substitute(&mut insensitive, &cmd, 0..=0).unwrap();
1124 assert_eq!(out.replacements, 1);
1125 assert_eq!(buf_line(&insensitive, 0), "bar FOO foo");
1126
1127 let mut sensitive = editor_with("Foo FOO foo");
1128 let cmd = parse_substitute("/\\Cfoo/bar/i").unwrap();
1129 let out = apply_substitute(&mut sensitive, &cmd, 0..=0).unwrap();
1130 assert_eq!(out.replacements, 1);
1131 assert_eq!(buf_line(&sensitive, 0), "Foo FOO bar");
1132 }
1133
1134 #[test]
1135 fn apply_empty_pattern_reuses_last_search() {
1136 let mut e = editor_with("hello world");
1137 e.set_last_search(Some("world".to_string()), true);
1138 let cmd = parse_substitute("//planet/").unwrap();
1139 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1140 assert_eq!(out.replacements, 1);
1141 assert_eq!(buf_line(&e, 0), "hello planet");
1142 }
1143
1144 #[test]
1145 fn apply_empty_pattern_no_last_search_errors() {
1146 let mut e = editor_with("hello");
1147 let cmd = parse_substitute("//bar/").unwrap();
1148 let err = apply_substitute(&mut e, &cmd, 0..=0).unwrap_err();
1149 assert!(
1150 err.to_string().contains("no previous regular expression"),
1151 "{err}"
1152 );
1153 }
1154
1155 #[test]
1156 fn apply_updates_last_search() {
1157 let mut e = editor_with("foo");
1158 let cmd = parse_substitute("/foo/bar/").unwrap();
1159 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1160 assert_eq!(e.last_search(), Some("foo".to_string()));
1161 }
1162
1163 #[test]
1164 fn apply_empty_replacement_deletes_match() {
1165 let mut e = editor_with("hello world");
1166 let cmd = parse_substitute("/world//").unwrap();
1167 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1168 assert_eq!(out.replacements, 1);
1169 assert_eq!(buf_line(&e, 0), "hello ");
1170 }
1171
1172 #[test]
1173 fn apply_undo_reverts_in_one_step() {
1174 let mut e = editor_with("foo");
1175 let cmd = parse_substitute("/foo/bar/").unwrap();
1176 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1177 assert_eq!(buf_line(&e, 0), "bar");
1178 e.undo();
1179 assert_eq!(buf_line(&e, 0), "foo");
1180 }
1181
1182 #[test]
1183 fn apply_ampersand_in_replacement() {
1184 let mut e = editor_with("foo");
1185 let cmd = parse_substitute("/foo/[&]/").unwrap();
1186 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1187 assert_eq!(buf_line(&e, 0), "[foo]");
1188 }
1189
1190 #[test]
1191 fn apply_capture_group_reference() {
1192 let mut e = editor_with("hello world");
1193 let cmd = parse_substitute("/\\(\\w\\+\\)/<<\\1>>/g").unwrap();
1195 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1196 assert_eq!(buf_line(&e, 0), "<<hello>> <<world>>");
1197 }
1198
1199 #[test]
1200 fn apply_backslash_r_splits_line() {
1201 let mut e = editor_with("a,b,c");
1204 let cmd = parse_substitute("/,/\\r/g").unwrap();
1205 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1206 assert_eq!(buf_line(&e, 0), "a");
1207 assert_eq!(buf_line(&e, 1), "b");
1208 assert_eq!(buf_line(&e, 2), "c");
1209 }
1210
1211 #[test]
1217 fn apply_backslash_r_multi_row_cursor_lands_on_final_split_row() {
1218 let mut e = editor_with("a,b\nc,d\n");
1219 let cmd = parse_substitute("/,/\\r/").unwrap();
1220 let total = crate::types::Query::rope(e.buffer()).len_lines();
1221 let out = apply_substitute(&mut e, &cmd, 0..=(total.saturating_sub(1)) as u32).unwrap();
1222 assert_eq!(buf_line(&e, 0), "a");
1223 assert_eq!(buf_line(&e, 1), "b");
1224 assert_eq!(buf_line(&e, 2), "c");
1225 assert_eq!(buf_line(&e, 3), "d");
1226 assert_eq!(
1227 out.last_row,
1228 Some(3),
1229 "cursor should land on the last changed line ('d', real row 3) \
1230 in post-split coordinates, not the pre-split row index"
1231 );
1232 assert_eq!(e.buffer().cursor().row, 3);
1233 }
1234
1235 #[test]
1239 fn apply_backslash_r_single_row_cursor_lands_on_last_split_line() {
1240 let mut e = editor_with("a,b");
1241 let cmd = parse_substitute("/,/\\r/").unwrap();
1242 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1243 assert_eq!(buf_line(&e, 0), "a");
1244 assert_eq!(buf_line(&e, 1), "b");
1245 assert_eq!(out.last_row, Some(1));
1246 assert_eq!(e.buffer().cursor().row, 1);
1247 }
1248
1249 #[test]
1252 fn apply_no_newline_multi_row_cursor_unaffected() {
1253 let mut e = editor_with("a\na\na");
1254 let cmd = parse_substitute("/a/X/").unwrap();
1255 let out = apply_substitute(&mut e, &cmd, 0..=2).unwrap();
1256 assert_eq!(buf_line(&e, 0), "X");
1257 assert_eq!(buf_line(&e, 1), "X");
1258 assert_eq!(buf_line(&e, 2), "X");
1259 assert_eq!(out.last_row, Some(2));
1260 assert_eq!(e.buffer().cursor().row, 2);
1261 }
1262
1263 #[test]
1269 fn apply_backslash_r_rebases_marks_below_change() {
1270 let mut e = editor_with("a\na\na\na\na\na\na\na\nX\nlast");
1272 e.set_mark('a', (8, 0));
1273 let cmd = parse_substitute("/a/b\\r/").unwrap();
1274 let out = apply_substitute(&mut e, &cmd, 0..=7).unwrap();
1275 assert_eq!(out.replacements, 8);
1276 assert_eq!(
1278 e.mark('a'),
1279 Some((16, 0)),
1280 "mark below the substitution must shift by the rows added above it"
1281 );
1282 assert_eq!(buf_line(&e, 16), "X", "the marked text now lives on row 16");
1283 }
1284
1285 #[test]
1288 fn apply_no_newline_substitute_leaves_marks_untouched() {
1289 let mut e = editor_with("a\na\na\na\na\na\na\na\nX\nlast");
1290 e.set_mark('a', (8, 0));
1291 let cmd = parse_substitute("/a/b/").unwrap();
1292 let out = apply_substitute(&mut e, &cmd, 0..=7).unwrap();
1293 assert_eq!(out.replacements, 8);
1294 assert_eq!(e.mark('a'), Some((8, 0)), "delta 0 must not move the mark");
1295 }
1296
1297 #[test]
1300 fn apply_collected_matches_backslash_r_rebases_marks_below_change() {
1301 let mut e = editor_with("a\na\na\na\na\na\na\na\nX\nlast");
1302 e.set_mark('a', (8, 0));
1303 let cmd = parse_substitute("/a/b\\r/").unwrap();
1304 let matches = collect_substitute_matches(&e, &cmd, 0..=7).unwrap();
1305 assert_eq!(matches.len(), 8);
1306 let accepted = vec![true; matches.len()];
1307 let applied = apply_collected_matches(&mut e, &matches, &accepted);
1308 assert_eq!(applied, 8);
1309 assert_eq!(
1310 e.mark('a'),
1311 Some((16, 0)),
1312 "confirm-path mark must shift by the rows added above it"
1313 );
1314 assert_eq!(buf_line(&e, 16), "X");
1315 }
1316
1317 #[test]
1323 fn splice_range_not_covering_last_row_keeps_suffix() {
1324 let mut e = editor_with("x\ny\nz");
1325 let cmd = parse_substitute("/x/X/").unwrap();
1326 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1327 assert_eq!(out.replacements, 1);
1328 assert_eq!(e.buffer().rope().to_string(), "X\ny\nz");
1329 assert_eq!(buf_line(&e, 0), "X");
1330 assert_eq!(buf_line(&e, 2), "z");
1331 }
1332
1333 #[test]
1336 fn splice_preserves_trailing_newline_and_phantom_row() {
1337 let mut e = editor_with("a\nb\nc\n");
1338 let cmd = parse_substitute("/a/A/").unwrap();
1339 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1340 assert_eq!(e.buffer().rope().to_string(), "A\nb\nc\n");
1341 }
1342
1343 #[test]
1347 fn splice_char_columns_handle_multibyte() {
1348 let mut e = editor_with("héllo\nworld");
1351 let cmd = parse_substitute("/world/X/").unwrap();
1352 apply_substitute(&mut e, &cmd, 1..=1).unwrap();
1353 assert_eq!(e.buffer().rope().to_string(), "héllo\nX");
1354
1355 let mut e = editor_with("wörld");
1358 let cmd = parse_substitute("/wörld/X/").unwrap();
1359 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1360 assert_eq!(e.buffer().rope().to_string(), "X");
1361 }
1362
1363 #[test]
1366 fn splice_preserves_crlf_rows() {
1367 let mut e = editor_with("a\r\nb\r\nc");
1368 let cmd = parse_substitute("/b/X/").unwrap();
1369 apply_substitute(&mut e, &cmd, 1..=1).unwrap();
1370 assert_eq!(e.buffer().rope().to_string(), "a\r\nX\r\nc");
1371 }
1372
1373 #[test]
1379 fn substitute_ascii_digit_class_leaves_non_ascii_digits() {
1380 let mut e = editor_with("٣ a 123");
1381 let cmd = parse_substitute(r"/\d/X/g").unwrap();
1382 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1383 assert_eq!(e.buffer().rope().to_string(), "٣ a XXX");
1384 }
1385
1386 #[test]
1387 fn substitute_ascii_digit_class_does_not_match_unicode_digit() {
1388 let mut e = editor_with("٣");
1389 let cmd = parse_substitute(r"/\d/X/g").unwrap();
1390 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1391 assert_eq!(e.buffer().rope().to_string(), "٣");
1392 }
1393
1394 #[test]
1403 fn substitute_emits_change_log_and_reset() {
1404 let mut e = editor_with("foo\nbar\nbaz");
1405 let _ = e.take_changes();
1406 let _ = e.take_content_reset();
1407 let _ = e.take_content_edits();
1408 let cmd = parse_substitute("/foo/qux/").unwrap();
1409 apply_substitute(&mut e, &cmd, 0..=2).unwrap();
1410 let changes = e.take_changes();
1411 assert_eq!(changes.len(), 1, "one coarse whole-buffer Replace");
1412 assert_eq!(changes[0].range.start, crate::types::Pos::new(0, 0));
1413 assert_eq!(
1414 changes[0].replacement,
1415 e.buffer().rope().to_string(),
1416 "replacement is the full post-state content"
1417 );
1418 assert!(e.take_content_reset(), "syntax hosts must reparse");
1419 assert!(e.take_content_edits().is_empty());
1420 assert!(e.take_changes().is_empty(), "take_changes drains");
1421 }
1422
1423 #[test]
1424 fn substitute_with_newline_emits_change_log_and_reset() {
1425 let mut e = editor_with("a,b\nc,d");
1426 let _ = e.take_changes();
1427 let _ = e.take_content_reset();
1428 let _ = e.take_content_edits();
1429 let cmd = parse_substitute("/,/\\r/").unwrap();
1430 apply_substitute(&mut e, &cmd, 0..=1).unwrap();
1431 assert_eq!(e.buffer().rope().to_string(), "a\nb\nc\nd");
1433 let changes = e.take_changes();
1434 assert_eq!(changes.len(), 1, "one coarse whole-buffer Replace");
1435 assert_eq!(changes[0].range.start, crate::types::Pos::new(0, 0));
1436 assert_eq!(
1437 changes[0].replacement,
1438 e.buffer().rope().to_string(),
1439 "replacement is the full post-state content"
1440 );
1441 assert!(e.take_content_reset(), "syntax hosts must reparse");
1442 assert!(e.take_content_edits().is_empty());
1443 assert!(e.take_changes().is_empty(), "take_changes drains");
1444 }
1445
1446 #[test]
1447 fn collected_substitute_emits_change_log_and_reset() {
1448 let mut e = editor_with("foo\nfoo\nbar");
1449 let _ = e.take_changes();
1450 let _ = e.take_content_reset();
1451 let _ = e.take_content_edits();
1452 let cmd = parse_substitute("/foo/qux/g").unwrap();
1453 let matches = collect_substitute_matches(&e, &cmd, 0..=2).unwrap();
1454 let accepted = vec![true; matches.len()];
1455 let applied = apply_collected_matches(&mut e, &matches, &accepted);
1456 assert_eq!(applied, 2);
1457 let changes = e.take_changes();
1458 assert_eq!(changes.len(), 1, "one coarse whole-buffer Replace");
1459 assert_eq!(changes[0].range.start, crate::types::Pos::new(0, 0));
1460 assert_eq!(
1461 changes[0].replacement,
1462 e.buffer().rope().to_string(),
1463 "replacement is the full post-state content"
1464 );
1465 assert!(e.take_content_reset(), "syntax hosts must reparse");
1466 assert!(e.take_content_edits().is_empty());
1467 assert!(e.take_changes().is_empty(), "take_changes drains");
1468 }
1469
1470 #[test]
1471 fn apply_backslash_t_inserts_tab() {
1472 let mut e = editor_with("a,b");
1473 let cmd = parse_substitute("/,/\\t/").unwrap();
1474 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1475 assert_eq!(buf_line(&e, 0), "a\tb");
1476 }
1477
1478 #[test]
1479 fn apply_literal_dollar_in_replacement() {
1480 let mut e = editor_with("x");
1483 let cmd = parse_substitute("/x/$5/").unwrap();
1484 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1485 assert_eq!(buf_line(&e, 0), "$5");
1486 }
1487
1488 #[test]
1489 fn apply_backslash_zero_is_whole_match() {
1490 let mut e = editor_with("foo");
1492 let cmd = parse_substitute("/foo/[\\0]/").unwrap();
1493 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1494 assert_eq!(buf_line(&e, 0), "[foo]");
1495 }
1496
1497 #[test]
1498 fn apply_group_ref_then_literal_digits() {
1499 let mut e = editor_with("ab");
1501 let cmd = parse_substitute("/\\(.\\)/\\11/g").unwrap();
1502 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1503 assert_eq!(buf_line(&e, 0), "a1b1");
1504 }
1505
1506 fn expand(raw: &str, pat: &str, text: &str, prev: &str) -> String {
1509 let re = Regex::new(pat).unwrap();
1510 let caps = re.captures(text).unwrap();
1511 expand_replacement(raw, &caps, prev)
1512 }
1513
1514 #[test]
1515 fn expand_case_upper_run_and_end() {
1516 assert_eq!(expand("\\U\\0\\Ex", "foo", "foo", ""), "FOOx");
1518 assert_eq!(expand("\\L&\\E", "FOO", "FOO", ""), "foo");
1519 }
1520
1521 #[test]
1522 fn expand_case_one_shot() {
1523 assert_eq!(expand("\\u\\0", "foo", "foo", ""), "Foo");
1525 assert_eq!(expand("\\l\\0", "FOO", "FOO", ""), "fOO");
1526 }
1527
1528 #[test]
1529 fn expand_case_applies_to_group() {
1530 assert_eq!(expand("\\U\\1-y\\E", "(f)oo", "foo", ""), "F-Y");
1532 }
1533
1534 #[test]
1538 fn expand_backslash_u_uppercases_first_char_of_group() {
1539 assert_eq!(expand("\\u\\1", "(\\w+)", "hello world", ""), "Hello");
1540 }
1541
1542 #[test]
1547 fn expand_one_shot_falls_back_to_active_span() {
1548 assert_eq!(expand("\\U\\l\\0", "hello", "hello", ""), "hELLO");
1549 assert_eq!(
1552 expand("\\l\\U\\1 \\2", "(\\w+) (\\w+)", "hello world", ""),
1553 "hELLO WORLD"
1554 );
1555 }
1556
1557 #[test]
1558 fn expand_literal_dollar_and_amp() {
1559 assert_eq!(expand("$\\0", "x", "x", ""), "$x");
1560 assert_eq!(expand("[&]", "foo", "foo", ""), "[foo]");
1561 assert_eq!(expand("\\&", "foo", "foo", ""), "&");
1562 }
1563
1564 #[test]
1565 fn expand_tilde_uses_previous_replacement() {
1566 assert_eq!(expand("~!", "x", "x", "PREV"), "PREV!");
1568 assert_eq!(expand("~", "(.)", "a", "[\\1]"), "[a]");
1569 assert_eq!(expand("\\~", "x", "x", "PREV"), "~");
1571 }
1572
1573 #[test]
1576 fn apply_report_only_counts_without_mutating() {
1577 let mut e = editor_with("foo foo foo");
1578 let cmd = parse_substitute("/foo/bar/gn").unwrap();
1579 assert!(cmd.flags.report_only);
1580 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1581 assert_eq!(out.replacements, 3);
1582 assert_eq!(buf_line(&e, 0), "foo foo foo");
1584 }
1585
1586 #[test]
1589 fn apply_upper_run() {
1590 let mut e = editor_with("hello world");
1591 let cmd = parse_substitute("/world/\\U&\\E/").unwrap();
1592 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1593 assert_eq!(buf_line(&e, 0), "hello WORLD");
1594 }
1595
1596 #[test]
1601 fn substitute_respects_smartcase() {
1602 let mut e = editor_with("Foo");
1603 let cmd = parse_substitute("/foo/bar/").unwrap();
1605 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1606 assert_eq!(out.replacements, 1);
1607 assert_eq!(buf_line(&e, 0), "bar");
1608 }
1609
1610 #[test]
1613 fn substitute_i_flag_overrides_c() {
1614 let mut e = editor_with("foo");
1615 let cmd = parse_substitute("/Foo/bar/i").unwrap();
1617 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1618 assert_eq!(out.replacements, 1, "expected match on 'foo' with /i flag");
1619 assert_eq!(buf_line(&e, 0), "bar");
1620 }
1621
1622 #[test]
1625 fn substitute_lower_c_inline_overrides_smartcase() {
1626 let mut e = editor_with("FOO");
1627 let cmd = parse_substitute("/\\cFoo/bar/").unwrap();
1629 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1630 assert_eq!(out.replacements, 1);
1631 assert_eq!(buf_line(&e, 0), "bar");
1632 }
1633
1634 #[test]
1637 fn collect_inline_case_override_wins_over_flag() {
1638 let e = editor_with("Foo FOO foo");
1639 let cmd = parse_substitute("/\\cFOO/bar/I").unwrap();
1640 assert_eq!(
1641 collect_substitute_matches(&e, &cmd, 0..=0).unwrap().len(),
1642 1
1643 );
1644
1645 let cmd = parse_substitute("/\\Cfoo/bar/i").unwrap();
1646 assert_eq!(
1647 collect_substitute_matches(&e, &cmd, 0..=0).unwrap().len(),
1648 1
1649 );
1650 }
1651
1652 #[test]
1653 fn collect_substitute_matches_finds_all_occurrences() {
1654 let e = editor_with("foo bar foo");
1655 let cmd = parse_substitute("/foo/baz/g").unwrap();
1656 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1657 assert_eq!(matches.len(), 2, "expected 2 matches for /g flag");
1658 assert_eq!(matches[0].byte_start, 0);
1659 assert_eq!(matches[0].byte_end, 3);
1660 assert_eq!(matches[1].byte_start, 8);
1661 assert_eq!(matches[1].byte_end, 11);
1662 assert_eq!(matches[0].replacement, "baz");
1663 assert_eq!(matches[1].replacement, "baz");
1664 }
1665
1666 #[test]
1667 fn collect_substitute_matches_respects_g_flag() {
1668 let e = editor_with("foo foo foo");
1670 let cmd = parse_substitute("/foo/baz/").unwrap();
1671 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1672 assert_eq!(matches.len(), 1, "expected 1 match without /g");
1673 assert_eq!(matches[0].byte_start, 0);
1674 }
1675
1676 #[test]
1677 fn collect_substitute_matches_respects_range() {
1678 let e = editor_with("foo\nfoo\nfoo\nfoo\nfoo");
1679 let cmd = parse_substitute("/foo/bar/g").unwrap();
1680 let matches = collect_substitute_matches(&e, &cmd, 1..=2).unwrap();
1682 assert_eq!(matches.len(), 2);
1683 assert_eq!(matches[0].row, 1);
1684 assert_eq!(matches[1].row, 2);
1685 }
1686
1687 #[test]
1688 fn collect_substitute_matches_expands_template() {
1689 let e = editor_with("hello world");
1690 let cmd = parse_substitute("/\\(\\w\\+\\)/<<\\1>>/g").unwrap();
1692 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1693 assert_eq!(matches.len(), 2);
1694 assert_eq!(matches[0].replacement, "<<hello>>");
1695 assert_eq!(matches[1].replacement, "<<world>>");
1696 }
1697
1698 #[test]
1701 fn apply_collected_matches_reverse_order_preserves_offsets() {
1702 let mut e = editor_with("foo bar baz");
1706 let cmd = parse_substitute("/\\(foo\\|bar\\|baz\\)/X/g").unwrap();
1707 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1708 assert_eq!(matches.len(), 3);
1709 let accepted = vec![true; 3];
1710 let applied = apply_collected_matches(&mut e, &matches, &accepted);
1711 assert_eq!(applied, 3);
1712 assert_eq!(buf_line(&e, 0), "X X X");
1713 }
1714
1715 #[test]
1716 fn apply_collected_matches_subset_only() {
1717 let mut e = editor_with("foo bar foo");
1719 let cmd = parse_substitute("/foo/ZZZ/g").unwrap();
1720 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1721 assert_eq!(matches.len(), 2, "expected 2 foo matches");
1722 let accepted = vec![true, false];
1724 let applied = apply_collected_matches(&mut e, &matches, &accepted);
1725 assert_eq!(applied, 1);
1726 assert_eq!(buf_line(&e, 0), "ZZZ bar foo");
1728 }
1729
1730 #[test]
1731 fn apply_collected_matches_zero_accepted() {
1732 let mut e = editor_with("foo bar foo");
1733 let cmd = parse_substitute("/foo/ZZZ/g").unwrap();
1734 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1735 let accepted = vec![false; matches.len()];
1736 let applied = apply_collected_matches(&mut e, &matches, &accepted);
1737 assert_eq!(applied, 0);
1738 assert_eq!(buf_line(&e, 0), "foo bar foo");
1739 }
1740
1741 #[test]
1742 fn apply_collected_matches_expands_template() {
1743 let mut e = editor_with("hello world");
1744 let cmd = parse_substitute("/\\(\\w\\+\\)/<<\\1>>/g").unwrap();
1745 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1746 let accepted = vec![true; matches.len()];
1747 let applied = apply_collected_matches(&mut e, &matches, &accepted);
1748 assert_eq!(applied, 2);
1749 assert_eq!(buf_line(&e, 0), "<<hello>> <<world>>");
1750 }
1751
1752 #[test]
1759 fn pattern_tilde_expands_to_last_substitute() {
1760 let mut e = editor_with("foo");
1761 let first = parse_substitute("/foo/BAR/").unwrap();
1762 apply_substitute(&mut e, &first, 0..=0).unwrap();
1763 assert_eq!(buf_line(&e, 0), "BAR");
1764 e.set_last_substitute(first); let second = parse_substitute("/~/baz/").unwrap();
1767 let out = apply_substitute(&mut e, &second, 0..=0).unwrap();
1768 assert_eq!(out.replacements, 1, "pattern `~` must match `BAR`");
1769 assert_eq!(buf_line(&e, 0), "baz");
1770 }
1771
1772 #[test]
1775 fn pattern_escaped_tilde_stays_literal() {
1776 let mut e = editor_with("a~b");
1777 e.set_last_substitute(parse_substitute("/x/BAR/").unwrap());
1779 let cmd = parse_substitute("/\\~/X/").unwrap();
1780 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1781 assert_eq!(out.replacements, 1, "`\\~` must match the literal tilde");
1782 assert_eq!(buf_line(&e, 0), "aXb");
1783 }
1784
1785 #[test]
1789 fn pattern_tilde_no_previous_substitute_expands_empty() {
1790 let mut e = editor_with("ab");
1791 assert!(e.last_substitute().is_none());
1792 let cmd = parse_substitute("/a~b/X/").unwrap();
1793 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1794 assert_eq!(out.replacements, 1, "`~`→empty so pattern is `ab`");
1795 assert_eq!(buf_line(&e, 0), "X");
1796 }
1797
1798 #[test]
1803 fn search_pattern_tilde_shares_expansion_path() {
1804 let mut e = editor_with("BAR");
1805 e.set_last_substitute(parse_substitute("/foo/BAR/").unwrap());
1806 e.push_search_pattern("~");
1807 let re = e
1808 .search_state()
1809 .pattern
1810 .as_ref()
1811 .expect("`/~` must compile to a pattern");
1812 assert!(re.is_match("BAR"), "search `~` must expand to `BAR`");
1813 assert!(
1814 !re.is_match("~"),
1815 "search `~` must not match a literal tilde"
1816 );
1817 }
1818
1819 #[test]
1827 fn apply_substitute_resets_sticky_col_to_the_landed_column() {
1828 let mut e = editor_with("abcdefgh\nab\nabcdefgh");
1829 e.jump_cursor(0, 7);
1830 assert_eq!(e.sticky_col(), Some(7), "seeded curswant");
1831 let cmd = parse_substitute("/ab/XX/").unwrap();
1832 assert_eq!(
1833 apply_substitute(&mut e, &cmd, 1..=1).unwrap().replacements,
1834 1
1835 );
1836 assert_eq!(e.cursor(), (1, 0), "cursor lands on the changed line");
1837 assert_eq!(e.sticky_col(), Some(0), "curswant follows the cursor");
1838 }
1839
1840 #[test]
1843 fn apply_collected_matches_resets_sticky_col_to_the_landed_column() {
1844 let mut e = editor_with("abcdefgh\nab\nabcdefgh");
1845 e.jump_cursor(0, 7);
1846 assert_eq!(e.sticky_col(), Some(7), "seeded curswant");
1847 let cmd = parse_substitute("/ab/XX/").unwrap();
1848 let matches = collect_substitute_matches(&e, &cmd, 1..=1).unwrap();
1849 assert_eq!(matches.len(), 1);
1850 let accepted: Vec<bool> = vec![true];
1851 assert_eq!(apply_collected_matches(&mut e, &matches, &accepted), 1);
1852 assert_eq!(e.cursor(), (1, 0));
1853 assert_eq!(e.sticky_col(), Some(0), "curswant follows the cursor");
1854 }
1855}