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
223pub fn apply_substitute<H: crate::types::Host>(
252 ed: &mut Editor<hjkl_buffer::View, H>,
253 cmd: &SubstituteCmd,
254 line_range: std::ops::RangeInclusive<u32>,
255) -> Result<SubstituteOutcome, SubstError> {
256 let pattern_str: String = match &cmd.pattern {
258 Some(p) => p.clone(),
259 None => ed
260 .last_search()
261 .ok_or_else(|| "no previous regular expression".to_string())?,
262 };
263
264 let prev_replacement = ed.last_substitute_replacement();
268
269 let effective_pattern = {
271 use crate::search::{CaseMode, resolve_case_mode};
272 let base = if cmd.flags.case_sensitive {
273 CaseMode::Sensitive
274 } else if cmd.flags.ignore_case {
275 CaseMode::Insensitive
276 } else {
277 CaseMode::from_options(ed.settings().ignore_case, ed.settings().smartcase)
278 };
279 let (stripped, mode) = resolve_case_mode(&pattern_str, base, &prev_replacement);
280 if mode == CaseMode::Insensitive {
281 format!("(?i){stripped}")
282 } else {
283 stripped
284 }
285 };
286
287 let regex = Regex::new(&effective_pattern).map_err(|e| format!("bad pattern: {e}"))?;
288
289 ed.push_undo();
290
291 let start = *line_range.start() as usize;
292 let end = *line_range.end() as usize;
293 let rope = crate::types::Query::rope(ed.buffer());
294 let total = rope.len_lines();
295
296 let clamp_end = end.min(total.saturating_sub(1));
297 let mut new_lines: Vec<String> = crate::rope_util::rope_to_lines_vec(&rope);
298 let mut replacements = 0usize;
299 let mut lines_changed = 0usize;
300 let mut last_changed_row = 0usize;
301
302 if start <= clamp_end {
303 for (row, line) in new_lines[start..=clamp_end].iter_mut().enumerate() {
304 let (replaced, n) = do_replace(
305 ®ex,
306 line,
307 &cmd.replacement,
308 &prev_replacement,
309 cmd.flags.all,
310 );
311 if n > 0 {
312 *line = replaced;
313 replacements += n;
314 lines_changed += 1;
315 last_changed_row = start + row;
316 }
317 }
318 }
319
320 if replacements == 0 {
321 ed.pop_last_undo();
322 return Ok(SubstituteOutcome {
323 replacements: 0,
324 lines_changed: 0,
325 last_row: None,
326 });
327 }
328
329 if cmd.flags.report_only {
332 ed.pop_last_undo();
333 ed.set_last_search(Some(pattern_str), true);
334 return Ok(SubstituteOutcome {
335 replacements,
336 lines_changed,
337 last_row: None,
338 });
339 }
340
341 let newlines_before: usize = new_lines[..last_changed_row]
349 .iter()
350 .map(|l| l.matches('\n').count())
351 .sum();
352 let newlines_within = new_lines[last_changed_row].matches('\n').count();
353 let last_changed_row = last_changed_row + newlines_before + newlines_within;
354
355 ed.buffer_mut().replace_all(&new_lines.join("\n"));
357
358 let final_total = crate::types::Query::rope(ed.buffer()).len_lines();
361 let cursor_row = last_changed_row.min(final_total.saturating_sub(1));
362 let first_non_blank = crate::buf_helpers::buf_line(ed.buffer(), cursor_row)
363 .unwrap_or_default()
364 .chars()
365 .take_while(|c| *c == ' ' || *c == '\t')
366 .count();
367 let line_len = crate::buf_helpers::buf_line(ed.buffer(), cursor_row)
368 .unwrap_or_default()
369 .chars()
370 .count();
371 let cursor_col = first_non_blank.min(line_len.saturating_sub(1));
372 ed.jump_cursor(cursor_row, cursor_col);
376
377 ed.mark_content_dirty();
378
379 ed.set_last_search(Some(pattern_str), true);
381
382 Ok(SubstituteOutcome {
383 replacements,
384 lines_changed,
385 last_row: Some(cursor_row),
386 })
387}
388
389#[derive(Debug, Clone, PartialEq, Eq)]
396pub struct SubstituteMatch {
397 pub row: u32,
399 pub byte_start: u32,
401 pub byte_end: u32,
403 pub replacement: String,
405}
406
407pub fn collect_substitute_matches<H: crate::types::Host>(
419 ed: &crate::Editor<hjkl_buffer::View, H>,
420 cmd: &SubstituteCmd,
421 line_range: std::ops::RangeInclusive<u32>,
422) -> Result<Vec<SubstituteMatch>, SubstError> {
423 let pattern_str: String = match &cmd.pattern {
425 Some(p) => p.clone(),
426 None => ed
427 .last_search()
428 .ok_or_else(|| "no previous regular expression".to_string())?,
429 };
430
431 let prev_replacement = ed.last_substitute_replacement();
434
435 let effective_pattern = {
436 use crate::search::{CaseMode, resolve_case_mode};
437 let base = if cmd.flags.case_sensitive {
438 CaseMode::Sensitive
439 } else if cmd.flags.ignore_case {
440 CaseMode::Insensitive
441 } else {
442 CaseMode::from_options(ed.settings().ignore_case, ed.settings().smartcase)
443 };
444 let (stripped, mode) = resolve_case_mode(&pattern_str, base, &prev_replacement);
445 if mode == CaseMode::Insensitive {
446 format!("(?i){stripped}")
447 } else {
448 stripped
449 }
450 };
451
452 let regex = Regex::new(&effective_pattern).map_err(|e| format!("bad pattern: {e}"))?;
453
454 let start = *line_range.start() as usize;
455 let end = *line_range.end() as usize;
456 let rope = crate::types::Query::rope(ed.buffer());
457 let total = rope.len_lines();
458 let clamp_end = end.min(total.saturating_sub(1));
459
460 let mut matches: Vec<SubstituteMatch> = Vec::new();
461
462 let expand = |line: &str, start: usize| {
466 regex
467 .captures_at(line, start)
468 .map(|caps| expand_replacement(&cmd.replacement, &caps, &prev_replacement))
469 .unwrap_or_default()
470 };
471
472 if start <= clamp_end {
473 for row in start..=clamp_end {
474 let line = hjkl_buffer::rope_line_str(&rope, row);
475 let line = line.trim_end_matches('\n');
477
478 if cmd.flags.all {
479 for m in regex.find_iter(line) {
480 matches.push(SubstituteMatch {
481 row: row as u32,
482 byte_start: m.start() as u32,
483 byte_end: m.end() as u32,
484 replacement: expand(line, m.start()),
485 });
486 }
487 } else if let Some(m) = regex.find(line) {
488 matches.push(SubstituteMatch {
490 row: row as u32,
491 byte_start: m.start() as u32,
492 byte_end: m.end() as u32,
493 replacement: expand(line, m.start()),
494 });
495 }
496 }
497 }
498
499 Ok(matches)
500}
501
502pub fn apply_collected_matches<H: crate::types::Host>(
515 ed: &mut crate::Editor<hjkl_buffer::View, H>,
516 matches: &[SubstituteMatch],
517 accepted: &[bool],
518) -> usize {
519 assert_eq!(
520 matches.len(),
521 accepted.len(),
522 "apply_collected_matches: accepted.len() must equal matches.len()"
523 );
524
525 let mut to_apply: Vec<&SubstituteMatch> = matches
528 .iter()
529 .zip(accepted.iter())
530 .filter_map(|(m, &ok)| if ok { Some(m) } else { None })
531 .collect();
532
533 if to_apply.is_empty() {
534 return 0;
535 }
536
537 to_apply.sort_unstable_by(|a, b| b.row.cmp(&a.row).then(b.byte_start.cmp(&a.byte_start)));
538
539 let rope = crate::types::Query::rope(ed.buffer());
540 let mut lines_vec: Vec<String> = crate::rope_util::rope_to_lines_vec(&rope);
541 let mut applied = 0usize;
542 let mut last_changed_row: Option<usize> = None;
543
544 for sm in &to_apply {
545 let row = sm.row as usize;
546 if row >= lines_vec.len() {
547 continue;
548 }
549 let line = &lines_vec[row];
550 let bs = sm.byte_start as usize;
551 let be = sm.byte_end as usize;
552 if be > line.len() || bs > be {
553 continue;
554 }
555 if !line.is_char_boundary(bs) || !line.is_char_boundary(be) {
558 continue;
559 }
560 let mut new_line = String::with_capacity(line.len() + sm.replacement.len());
562 new_line.push_str(&line[..bs]);
563 new_line.push_str(&sm.replacement);
564 new_line.push_str(&line[be..]);
565 lines_vec[row] = new_line;
566 applied += 1;
567 last_changed_row = Some(last_changed_row.map_or(row, |lr: usize| lr.max(row)));
572 }
573
574 if applied > 0 {
575 ed.buffer_mut().replace_all(&lines_vec.join("\n"));
576 if let Some(row) = last_changed_row {
577 let newlines_before: usize = lines_vec[..row]
582 .iter()
583 .map(|l| l.matches('\n').count())
584 .sum();
585 let newlines_within = lines_vec[row].matches('\n').count();
586 let row = row + newlines_before + newlines_within;
587 ed.jump_cursor(row, 0);
590 }
591 ed.mark_content_dirty();
592 }
593
594 applied
595}
596
597fn split_on_slash(s: &str) -> Vec<String> {
604 let mut out: Vec<String> = Vec::new();
605 let mut cur = String::new();
606 let mut chars = s.chars().peekable();
607 while let Some(c) = chars.next() {
608 if c == '\\' {
609 match chars.peek() {
610 Some(&'/') => {
611 cur.push('/');
613 chars.next();
614 }
615 Some(_) => {
616 let next = chars.next().unwrap();
619 cur.push('\\');
620 cur.push(next);
621 }
622 None => cur.push('\\'),
623 }
624 } else if c == '/' {
625 if out.len() < 2 {
626 out.push(std::mem::take(&mut cur));
627 } else {
628 cur.push(c);
632 }
636 } else {
637 cur.push(c);
638 }
639 }
640 out.push(cur);
641 out
642}
643
644#[derive(Clone, Copy, PartialEq)]
647enum SpanCase {
648 None,
649 Upper,
651 Lower,
653}
654
655#[derive(Clone, Copy, PartialEq)]
661enum OneShotCase {
662 Upper,
663 Lower,
664}
665
666#[derive(Clone, Copy, PartialEq)]
668struct CaseState {
669 span: SpanCase,
670 one_shot: Option<OneShotCase>,
671}
672
673impl CaseState {
674 fn new() -> Self {
675 Self {
676 span: SpanCase::None,
677 one_shot: None,
678 }
679 }
680}
681
682fn push_cased(out: &mut String, case: &mut CaseState, ch: char) {
686 let effective = match case.one_shot.take() {
687 Some(OneShotCase::Upper) => Some(SpanCase::Upper),
688 Some(OneShotCase::Lower) => Some(SpanCase::Lower),
689 None => match case.span {
690 SpanCase::None => None,
691 other => Some(other),
692 },
693 };
694 match effective {
695 None => out.push(ch),
696 Some(SpanCase::Upper) => out.extend(ch.to_uppercase()),
697 Some(SpanCase::Lower) => out.extend(ch.to_lowercase()),
698 Some(SpanCase::None) => unreachable!(),
699 }
700}
701
702fn expand_replacement(raw: &str, caps: ®ex::Captures, prev: &str) -> String {
716 let mut out = String::with_capacity(raw.len() + 8);
717 expand_into(&mut out, raw, caps, prev, true);
718 out
719}
720
721fn expand_into(out: &mut String, raw: &str, caps: ®ex::Captures, prev: &str, allow_tilde: bool) {
722 let mut case = CaseState::new();
723 let mut chars = raw.chars();
724 while let Some(c) = chars.next() {
725 match c {
726 '&' => {
727 let g = caps.get(0).map_or("", |m| m.as_str());
728 for ch in g.chars() {
729 push_cased(out, &mut case, ch);
730 }
731 }
732 '~' if allow_tilde => {
733 let mut tmp = String::new();
736 expand_into(&mut tmp, prev, caps, "", false);
737 for ch in tmp.chars() {
738 push_cased(out, &mut case, ch);
739 }
740 }
741 '\\' => match chars.next() {
742 Some('&') => push_cased(out, &mut case, '&'),
743 Some('~') => push_cased(out, &mut case, '~'),
744 Some('\\') => push_cased(out, &mut case, '\\'),
745 Some('r') => out.push('\n'),
747 Some('t') => out.push('\t'),
748 Some('n') => out.push('\0'),
749 Some(d @ '0'..='9') => {
750 let idx = d as usize - '0' as usize;
751 let g = caps.get(idx).map_or("", |m| m.as_str());
752 for ch in g.chars() {
753 push_cased(out, &mut case, ch);
754 }
755 }
756 Some('u') => case.one_shot = Some(OneShotCase::Upper),
757 Some('l') => case.one_shot = Some(OneShotCase::Lower),
758 Some('U') => case.span = SpanCase::Upper,
759 Some('L') => case.span = SpanCase::Lower,
760 Some('e') | Some('E') => case.span = SpanCase::None,
761 Some(other) => push_cased(out, &mut case, other),
762 None => {} },
764 _ => push_cased(out, &mut case, c),
765 }
766 }
767}
768
769fn do_replace(
773 regex: &Regex,
774 text: &str,
775 replacement: &str,
776 prev: &str,
777 all: bool,
778) -> (String, usize) {
779 let matches = regex.find_iter(text).count();
780 if matches == 0 {
781 return (text.to_string(), 0);
782 }
783 let rep = |caps: ®ex::Captures| expand_replacement(replacement, caps, prev);
784 let replaced = if all {
785 regex.replace_all(text, rep).into_owned()
786 } else {
787 regex.replace(text, rep).into_owned()
788 };
789 let count = if all { matches } else { 1 };
790 (replaced, count)
791}
792
793#[cfg(test)]
794mod tests {
795 use super::*;
796 use crate::types::{DefaultHost, Options};
797 use hjkl_buffer::View;
798
799 fn editor_with(content: &str) -> Editor<View, DefaultHost> {
800 let mut e = Editor::new(View::new(), DefaultHost::new(), Options::default());
801 e.set_content(content);
802 e
803 }
804
805 fn buf_line(e: &Editor<View, DefaultHost>, row: usize) -> String {
806 hjkl_buffer::rope_line_str(&e.buffer().rope(), row)
807 }
808
809 #[test]
812 fn parse_basic() {
813 let cmd = parse_substitute("/foo/bar/").unwrap();
814 assert_eq!(cmd.pattern.as_deref(), Some("foo"));
815 assert_eq!(cmd.replacement, "bar");
816 assert!(!cmd.flags.all);
817 }
818
819 #[test]
820 fn parse_trailing_slash_optional() {
821 let cmd = parse_substitute("/foo/bar").unwrap();
822 assert_eq!(cmd.pattern.as_deref(), Some("foo"));
823 assert_eq!(cmd.replacement, "bar");
824 }
825
826 #[test]
827 fn parse_global_flag() {
828 let cmd = parse_substitute("/x/y/g").unwrap();
829 assert!(cmd.flags.all);
830 }
831
832 #[test]
833 fn parse_ignore_case_flag() {
834 let cmd = parse_substitute("/x/y/i").unwrap();
835 assert!(cmd.flags.ignore_case);
836 }
837
838 #[test]
839 fn parse_case_sensitive_flag() {
840 let cmd = parse_substitute("/x/y/I").unwrap();
841 assert!(cmd.flags.case_sensitive);
842 }
843
844 #[test]
845 fn parse_confirm_flag_accepted() {
846 let cmd = parse_substitute("/x/y/c").unwrap();
847 assert!(cmd.flags.confirm);
848 }
849
850 #[test]
851 fn parse_multi_flags() {
852 let cmd = parse_substitute("/x/y/gi").unwrap();
853 assert!(cmd.flags.all);
854 assert!(cmd.flags.ignore_case);
855 }
856
857 #[test]
858 fn parse_unknown_flag_errors() {
859 let err = parse_substitute("/x/y/z").unwrap_err();
860 assert!(err.to_string().contains("unknown flag 'z'"), "{err}");
861 }
862
863 #[test]
864 fn parse_empty_pattern_is_none() {
865 let cmd = parse_substitute("//bar/").unwrap();
866 assert!(cmd.pattern.is_none());
867 assert_eq!(cmd.replacement, "bar");
868 }
869
870 #[test]
871 fn parse_empty_replacement_ok() {
872 let cmd = parse_substitute("/foo//").unwrap();
873 assert_eq!(cmd.pattern.as_deref(), Some("foo"));
874 assert_eq!(cmd.replacement, "");
875 }
876
877 #[test]
878 fn parse_escaped_slash_in_pattern() {
879 let cmd = parse_substitute("/a\\/b/c/").unwrap();
880 assert_eq!(cmd.pattern.as_deref(), Some("a/b"));
881 }
882
883 #[test]
884 fn parse_escaped_slash_in_replacement() {
885 let cmd = parse_substitute("/a/b\\/c/").unwrap();
886 assert_eq!(cmd.replacement, "b/c");
888 }
889
890 #[test]
893 fn parse_keeps_replacement_raw() {
894 assert_eq!(parse_substitute("/foo/[&]/").unwrap().replacement, "[&]");
895 assert_eq!(parse_substitute("/foo/\\&/").unwrap().replacement, "\\&");
896 assert_eq!(parse_substitute("/(foo)/\\1/").unwrap().replacement, "\\1");
897 assert_eq!(parse_substitute("/(x)/\\9/").unwrap().replacement, "\\9");
898 }
899
900 #[test]
901 fn parse_wrong_delimiter_errors() {
902 let err = parse_substitute("|foo|bar|").unwrap_err();
903 assert!(err.to_string().contains("'/'"), "{err}");
904 }
905
906 #[test]
907 fn parse_too_few_fields_errors() {
908 let err = parse_substitute("/foo").unwrap_err();
909 assert!(
910 err.to_string().contains("needs /pattern/replacement"),
911 "{err}"
912 );
913 }
914
915 #[test]
918 fn apply_single_line_first_only() {
919 let mut e = editor_with("foo foo");
920 let cmd = parse_substitute("/foo/bar/").unwrap();
921 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
922 assert_eq!(out.replacements, 1);
923 assert_eq!(out.lines_changed, 1);
924 assert_eq!(buf_line(&e, 0), "bar foo");
925 }
926
927 #[test]
928 fn apply_single_line_global() {
929 let mut e = editor_with("foo foo foo");
930 let cmd = parse_substitute("/foo/bar/g").unwrap();
931 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
932 assert_eq!(out.replacements, 3);
933 assert_eq!(out.lines_changed, 1);
934 assert_eq!(buf_line(&e, 0), "bar bar bar");
935 }
936
937 #[test]
938 fn apply_multi_line_range() {
939 let mut e = editor_with("foo\nfoo foo\nbar");
940 let cmd = parse_substitute("/foo/xyz/g").unwrap();
941 let out = apply_substitute(&mut e, &cmd, 0..=2).unwrap();
942 assert_eq!(out.replacements, 3);
943 assert_eq!(out.lines_changed, 2);
944 assert_eq!(buf_line(&e, 0), "xyz");
945 assert_eq!(buf_line(&e, 1), "xyz xyz");
946 assert_eq!(buf_line(&e, 2), "bar");
947 }
948
949 #[test]
950 fn apply_no_match_returns_zero() {
951 let mut e = editor_with("hello");
952 let original = buf_line(&e, 0);
953 let cmd = parse_substitute("/xyz/abc/").unwrap();
954 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
955 assert_eq!(out.replacements, 0);
956 assert_eq!(out.lines_changed, 0);
957 assert_eq!(buf_line(&e, 0), original);
958 }
959
960 #[test]
961 fn apply_case_insensitive_flag() {
962 let mut e = editor_with("Foo FOO foo");
963 let cmd = parse_substitute("/foo/bar/gi").unwrap();
964 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
965 assert_eq!(out.replacements, 3);
966 assert_eq!(buf_line(&e, 0), "bar bar bar");
967 }
968
969 #[test]
970 fn apply_case_sensitive_flag_overrides_editor_setting() {
971 let mut e = editor_with("Foo foo");
972 e.settings_mut().ignore_case = true;
974 let cmd = parse_substitute("/foo/bar/I").unwrap();
976 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
977 assert_eq!(out.replacements, 1);
979 assert_eq!(buf_line(&e, 0), "Foo bar");
980 }
981
982 #[test]
983 fn apply_inline_case_override_wins_over_flag() {
984 let mut insensitive = editor_with("Foo FOO foo");
985 let cmd = parse_substitute("/\\cFOO/bar/I").unwrap();
986 let out = apply_substitute(&mut insensitive, &cmd, 0..=0).unwrap();
987 assert_eq!(out.replacements, 1);
988 assert_eq!(buf_line(&insensitive, 0), "bar FOO foo");
989
990 let mut sensitive = editor_with("Foo FOO foo");
991 let cmd = parse_substitute("/\\Cfoo/bar/i").unwrap();
992 let out = apply_substitute(&mut sensitive, &cmd, 0..=0).unwrap();
993 assert_eq!(out.replacements, 1);
994 assert_eq!(buf_line(&sensitive, 0), "Foo FOO bar");
995 }
996
997 #[test]
998 fn apply_empty_pattern_reuses_last_search() {
999 let mut e = editor_with("hello world");
1000 e.set_last_search(Some("world".to_string()), true);
1001 let cmd = parse_substitute("//planet/").unwrap();
1002 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1003 assert_eq!(out.replacements, 1);
1004 assert_eq!(buf_line(&e, 0), "hello planet");
1005 }
1006
1007 #[test]
1008 fn apply_empty_pattern_no_last_search_errors() {
1009 let mut e = editor_with("hello");
1010 let cmd = parse_substitute("//bar/").unwrap();
1011 let err = apply_substitute(&mut e, &cmd, 0..=0).unwrap_err();
1012 assert!(
1013 err.to_string().contains("no previous regular expression"),
1014 "{err}"
1015 );
1016 }
1017
1018 #[test]
1019 fn apply_updates_last_search() {
1020 let mut e = editor_with("foo");
1021 let cmd = parse_substitute("/foo/bar/").unwrap();
1022 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1023 assert_eq!(e.last_search(), Some("foo".to_string()));
1024 }
1025
1026 #[test]
1027 fn apply_empty_replacement_deletes_match() {
1028 let mut e = editor_with("hello world");
1029 let cmd = parse_substitute("/world//").unwrap();
1030 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1031 assert_eq!(out.replacements, 1);
1032 assert_eq!(buf_line(&e, 0), "hello ");
1033 }
1034
1035 #[test]
1036 fn apply_undo_reverts_in_one_step() {
1037 let mut e = editor_with("foo");
1038 let cmd = parse_substitute("/foo/bar/").unwrap();
1039 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1040 assert_eq!(buf_line(&e, 0), "bar");
1041 e.undo();
1042 assert_eq!(buf_line(&e, 0), "foo");
1043 }
1044
1045 #[test]
1046 fn apply_ampersand_in_replacement() {
1047 let mut e = editor_with("foo");
1048 let cmd = parse_substitute("/foo/[&]/").unwrap();
1049 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1050 assert_eq!(buf_line(&e, 0), "[foo]");
1051 }
1052
1053 #[test]
1054 fn apply_capture_group_reference() {
1055 let mut e = editor_with("hello world");
1056 let cmd = parse_substitute("/\\(\\w\\+\\)/<<\\1>>/g").unwrap();
1058 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1059 assert_eq!(buf_line(&e, 0), "<<hello>> <<world>>");
1060 }
1061
1062 #[test]
1063 fn apply_backslash_r_splits_line() {
1064 let mut e = editor_with("a,b,c");
1067 let cmd = parse_substitute("/,/\\r/g").unwrap();
1068 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1069 assert_eq!(buf_line(&e, 0), "a");
1070 assert_eq!(buf_line(&e, 1), "b");
1071 assert_eq!(buf_line(&e, 2), "c");
1072 }
1073
1074 #[test]
1080 fn apply_backslash_r_multi_row_cursor_lands_on_final_split_row() {
1081 let mut e = editor_with("a,b\nc,d\n");
1082 let cmd = parse_substitute("/,/\\r/").unwrap();
1083 let total = crate::types::Query::rope(e.buffer()).len_lines();
1084 let out = apply_substitute(&mut e, &cmd, 0..=(total.saturating_sub(1)) as u32).unwrap();
1085 assert_eq!(buf_line(&e, 0), "a");
1086 assert_eq!(buf_line(&e, 1), "b");
1087 assert_eq!(buf_line(&e, 2), "c");
1088 assert_eq!(buf_line(&e, 3), "d");
1089 assert_eq!(
1090 out.last_row,
1091 Some(3),
1092 "cursor should land on the last changed line ('d', real row 3) \
1093 in post-split coordinates, not the pre-split row index"
1094 );
1095 assert_eq!(e.buffer().cursor().row, 3);
1096 }
1097
1098 #[test]
1102 fn apply_backslash_r_single_row_cursor_lands_on_last_split_line() {
1103 let mut e = editor_with("a,b");
1104 let cmd = parse_substitute("/,/\\r/").unwrap();
1105 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1106 assert_eq!(buf_line(&e, 0), "a");
1107 assert_eq!(buf_line(&e, 1), "b");
1108 assert_eq!(out.last_row, Some(1));
1109 assert_eq!(e.buffer().cursor().row, 1);
1110 }
1111
1112 #[test]
1115 fn apply_no_newline_multi_row_cursor_unaffected() {
1116 let mut e = editor_with("a\na\na");
1117 let cmd = parse_substitute("/a/X/").unwrap();
1118 let out = apply_substitute(&mut e, &cmd, 0..=2).unwrap();
1119 assert_eq!(buf_line(&e, 0), "X");
1120 assert_eq!(buf_line(&e, 1), "X");
1121 assert_eq!(buf_line(&e, 2), "X");
1122 assert_eq!(out.last_row, Some(2));
1123 assert_eq!(e.buffer().cursor().row, 2);
1124 }
1125
1126 #[test]
1127 fn apply_backslash_t_inserts_tab() {
1128 let mut e = editor_with("a,b");
1129 let cmd = parse_substitute("/,/\\t/").unwrap();
1130 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1131 assert_eq!(buf_line(&e, 0), "a\tb");
1132 }
1133
1134 #[test]
1135 fn apply_literal_dollar_in_replacement() {
1136 let mut e = editor_with("x");
1139 let cmd = parse_substitute("/x/$5/").unwrap();
1140 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1141 assert_eq!(buf_line(&e, 0), "$5");
1142 }
1143
1144 #[test]
1145 fn apply_backslash_zero_is_whole_match() {
1146 let mut e = editor_with("foo");
1148 let cmd = parse_substitute("/foo/[\\0]/").unwrap();
1149 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1150 assert_eq!(buf_line(&e, 0), "[foo]");
1151 }
1152
1153 #[test]
1154 fn apply_group_ref_then_literal_digits() {
1155 let mut e = editor_with("ab");
1157 let cmd = parse_substitute("/\\(.\\)/\\11/g").unwrap();
1158 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1159 assert_eq!(buf_line(&e, 0), "a1b1");
1160 }
1161
1162 fn expand(raw: &str, pat: &str, text: &str, prev: &str) -> String {
1165 let re = Regex::new(pat).unwrap();
1166 let caps = re.captures(text).unwrap();
1167 expand_replacement(raw, &caps, prev)
1168 }
1169
1170 #[test]
1171 fn expand_case_upper_run_and_end() {
1172 assert_eq!(expand("\\U\\0\\Ex", "foo", "foo", ""), "FOOx");
1174 assert_eq!(expand("\\L&\\E", "FOO", "FOO", ""), "foo");
1175 }
1176
1177 #[test]
1178 fn expand_case_one_shot() {
1179 assert_eq!(expand("\\u\\0", "foo", "foo", ""), "Foo");
1181 assert_eq!(expand("\\l\\0", "FOO", "FOO", ""), "fOO");
1182 }
1183
1184 #[test]
1185 fn expand_case_applies_to_group() {
1186 assert_eq!(expand("\\U\\1-y\\E", "(f)oo", "foo", ""), "F-Y");
1188 }
1189
1190 #[test]
1194 fn expand_backslash_u_uppercases_first_char_of_group() {
1195 assert_eq!(expand("\\u\\1", "(\\w+)", "hello world", ""), "Hello");
1196 }
1197
1198 #[test]
1203 fn expand_one_shot_falls_back_to_active_span() {
1204 assert_eq!(expand("\\U\\l\\0", "hello", "hello", ""), "hELLO");
1205 assert_eq!(
1208 expand("\\l\\U\\1 \\2", "(\\w+) (\\w+)", "hello world", ""),
1209 "hELLO WORLD"
1210 );
1211 }
1212
1213 #[test]
1214 fn expand_literal_dollar_and_amp() {
1215 assert_eq!(expand("$\\0", "x", "x", ""), "$x");
1216 assert_eq!(expand("[&]", "foo", "foo", ""), "[foo]");
1217 assert_eq!(expand("\\&", "foo", "foo", ""), "&");
1218 }
1219
1220 #[test]
1221 fn expand_tilde_uses_previous_replacement() {
1222 assert_eq!(expand("~!", "x", "x", "PREV"), "PREV!");
1224 assert_eq!(expand("~", "(.)", "a", "[\\1]"), "[a]");
1225 assert_eq!(expand("\\~", "x", "x", "PREV"), "~");
1227 }
1228
1229 #[test]
1232 fn apply_report_only_counts_without_mutating() {
1233 let mut e = editor_with("foo foo foo");
1234 let cmd = parse_substitute("/foo/bar/gn").unwrap();
1235 assert!(cmd.flags.report_only);
1236 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1237 assert_eq!(out.replacements, 3);
1238 assert_eq!(buf_line(&e, 0), "foo foo foo");
1240 }
1241
1242 #[test]
1245 fn apply_upper_run() {
1246 let mut e = editor_with("hello world");
1247 let cmd = parse_substitute("/world/\\U&\\E/").unwrap();
1248 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1249 assert_eq!(buf_line(&e, 0), "hello WORLD");
1250 }
1251
1252 #[test]
1257 fn substitute_respects_smartcase() {
1258 let mut e = editor_with("Foo");
1259 let cmd = parse_substitute("/foo/bar/").unwrap();
1261 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1262 assert_eq!(out.replacements, 1);
1263 assert_eq!(buf_line(&e, 0), "bar");
1264 }
1265
1266 #[test]
1269 fn substitute_i_flag_overrides_c() {
1270 let mut e = editor_with("foo");
1271 let cmd = parse_substitute("/Foo/bar/i").unwrap();
1273 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1274 assert_eq!(out.replacements, 1, "expected match on 'foo' with /i flag");
1275 assert_eq!(buf_line(&e, 0), "bar");
1276 }
1277
1278 #[test]
1281 fn substitute_lower_c_inline_overrides_smartcase() {
1282 let mut e = editor_with("FOO");
1283 let cmd = parse_substitute("/\\cFoo/bar/").unwrap();
1285 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1286 assert_eq!(out.replacements, 1);
1287 assert_eq!(buf_line(&e, 0), "bar");
1288 }
1289
1290 #[test]
1293 fn collect_inline_case_override_wins_over_flag() {
1294 let e = editor_with("Foo FOO foo");
1295 let cmd = parse_substitute("/\\cFOO/bar/I").unwrap();
1296 assert_eq!(
1297 collect_substitute_matches(&e, &cmd, 0..=0).unwrap().len(),
1298 1
1299 );
1300
1301 let cmd = parse_substitute("/\\Cfoo/bar/i").unwrap();
1302 assert_eq!(
1303 collect_substitute_matches(&e, &cmd, 0..=0).unwrap().len(),
1304 1
1305 );
1306 }
1307
1308 #[test]
1309 fn collect_substitute_matches_finds_all_occurrences() {
1310 let e = editor_with("foo bar foo");
1311 let cmd = parse_substitute("/foo/baz/g").unwrap();
1312 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1313 assert_eq!(matches.len(), 2, "expected 2 matches for /g flag");
1314 assert_eq!(matches[0].byte_start, 0);
1315 assert_eq!(matches[0].byte_end, 3);
1316 assert_eq!(matches[1].byte_start, 8);
1317 assert_eq!(matches[1].byte_end, 11);
1318 assert_eq!(matches[0].replacement, "baz");
1319 assert_eq!(matches[1].replacement, "baz");
1320 }
1321
1322 #[test]
1323 fn collect_substitute_matches_respects_g_flag() {
1324 let e = editor_with("foo foo foo");
1326 let cmd = parse_substitute("/foo/baz/").unwrap();
1327 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1328 assert_eq!(matches.len(), 1, "expected 1 match without /g");
1329 assert_eq!(matches[0].byte_start, 0);
1330 }
1331
1332 #[test]
1333 fn collect_substitute_matches_respects_range() {
1334 let e = editor_with("foo\nfoo\nfoo\nfoo\nfoo");
1335 let cmd = parse_substitute("/foo/bar/g").unwrap();
1336 let matches = collect_substitute_matches(&e, &cmd, 1..=2).unwrap();
1338 assert_eq!(matches.len(), 2);
1339 assert_eq!(matches[0].row, 1);
1340 assert_eq!(matches[1].row, 2);
1341 }
1342
1343 #[test]
1344 fn collect_substitute_matches_expands_template() {
1345 let e = editor_with("hello world");
1346 let cmd = parse_substitute("/\\(\\w\\+\\)/<<\\1>>/g").unwrap();
1348 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1349 assert_eq!(matches.len(), 2);
1350 assert_eq!(matches[0].replacement, "<<hello>>");
1351 assert_eq!(matches[1].replacement, "<<world>>");
1352 }
1353
1354 #[test]
1357 fn apply_collected_matches_reverse_order_preserves_offsets() {
1358 let mut e = editor_with("foo bar baz");
1362 let cmd = parse_substitute("/\\(foo\\|bar\\|baz\\)/X/g").unwrap();
1363 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1364 assert_eq!(matches.len(), 3);
1365 let accepted = vec![true; 3];
1366 let applied = apply_collected_matches(&mut e, &matches, &accepted);
1367 assert_eq!(applied, 3);
1368 assert_eq!(buf_line(&e, 0), "X X X");
1369 }
1370
1371 #[test]
1372 fn apply_collected_matches_subset_only() {
1373 let mut e = editor_with("foo bar foo");
1375 let cmd = parse_substitute("/foo/ZZZ/g").unwrap();
1376 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1377 assert_eq!(matches.len(), 2, "expected 2 foo matches");
1378 let accepted = vec![true, false];
1380 let applied = apply_collected_matches(&mut e, &matches, &accepted);
1381 assert_eq!(applied, 1);
1382 assert_eq!(buf_line(&e, 0), "ZZZ bar foo");
1384 }
1385
1386 #[test]
1387 fn apply_collected_matches_zero_accepted() {
1388 let mut e = editor_with("foo bar foo");
1389 let cmd = parse_substitute("/foo/ZZZ/g").unwrap();
1390 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1391 let accepted = vec![false; matches.len()];
1392 let applied = apply_collected_matches(&mut e, &matches, &accepted);
1393 assert_eq!(applied, 0);
1394 assert_eq!(buf_line(&e, 0), "foo bar foo");
1395 }
1396
1397 #[test]
1398 fn apply_collected_matches_expands_template() {
1399 let mut e = editor_with("hello world");
1400 let cmd = parse_substitute("/\\(\\w\\+\\)/<<\\1>>/g").unwrap();
1401 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1402 let accepted = vec![true; matches.len()];
1403 let applied = apply_collected_matches(&mut e, &matches, &accepted);
1404 assert_eq!(applied, 2);
1405 assert_eq!(buf_line(&e, 0), "<<hello>> <<world>>");
1406 }
1407
1408 #[test]
1415 fn pattern_tilde_expands_to_last_substitute() {
1416 let mut e = editor_with("foo");
1417 let first = parse_substitute("/foo/BAR/").unwrap();
1418 apply_substitute(&mut e, &first, 0..=0).unwrap();
1419 assert_eq!(buf_line(&e, 0), "BAR");
1420 e.set_last_substitute(first); let second = parse_substitute("/~/baz/").unwrap();
1423 let out = apply_substitute(&mut e, &second, 0..=0).unwrap();
1424 assert_eq!(out.replacements, 1, "pattern `~` must match `BAR`");
1425 assert_eq!(buf_line(&e, 0), "baz");
1426 }
1427
1428 #[test]
1431 fn pattern_escaped_tilde_stays_literal() {
1432 let mut e = editor_with("a~b");
1433 e.set_last_substitute(parse_substitute("/x/BAR/").unwrap());
1435 let cmd = parse_substitute("/\\~/X/").unwrap();
1436 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1437 assert_eq!(out.replacements, 1, "`\\~` must match the literal tilde");
1438 assert_eq!(buf_line(&e, 0), "aXb");
1439 }
1440
1441 #[test]
1445 fn pattern_tilde_no_previous_substitute_expands_empty() {
1446 let mut e = editor_with("ab");
1447 assert!(e.last_substitute().is_none());
1448 let cmd = parse_substitute("/a~b/X/").unwrap();
1449 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1450 assert_eq!(out.replacements, 1, "`~`→empty so pattern is `ab`");
1451 assert_eq!(buf_line(&e, 0), "X");
1452 }
1453
1454 #[test]
1459 fn search_pattern_tilde_shares_expansion_path() {
1460 let mut e = editor_with("BAR");
1461 e.set_last_substitute(parse_substitute("/foo/BAR/").unwrap());
1462 e.push_search_pattern("~");
1463 let re = e
1464 .search_state()
1465 .pattern
1466 .as_ref()
1467 .expect("`/~` must compile to a pattern");
1468 assert!(re.is_match("BAR"), "search `~` must expand to `BAR`");
1469 assert!(
1470 !re.is_match("~"),
1471 "search `~` must not match a literal tilde"
1472 );
1473 }
1474
1475 #[test]
1483 fn apply_substitute_resets_sticky_col_to_the_landed_column() {
1484 let mut e = editor_with("abcdefgh\nab\nabcdefgh");
1485 e.jump_cursor(0, 7);
1486 assert_eq!(e.sticky_col(), Some(7), "seeded curswant");
1487 let cmd = parse_substitute("/ab/XX/").unwrap();
1488 assert_eq!(
1489 apply_substitute(&mut e, &cmd, 1..=1).unwrap().replacements,
1490 1
1491 );
1492 assert_eq!(e.cursor(), (1, 0), "cursor lands on the changed line");
1493 assert_eq!(e.sticky_col(), Some(0), "curswant follows the cursor");
1494 }
1495
1496 #[test]
1499 fn apply_collected_matches_resets_sticky_col_to_the_landed_column() {
1500 let mut e = editor_with("abcdefgh\nab\nabcdefgh");
1501 e.jump_cursor(0, 7);
1502 assert_eq!(e.sticky_col(), Some(7), "seeded curswant");
1503 let cmd = parse_substitute("/ab/XX/").unwrap();
1504 let matches = collect_substitute_matches(&e, &cmd, 1..=1).unwrap();
1505 assert_eq!(matches.len(), 1);
1506 let accepted: Vec<bool> = vec![true];
1507 assert_eq!(apply_collected_matches(&mut e, &matches, &accepted), 1);
1508 assert_eq!(e.cursor(), (1, 0));
1509 assert_eq!(e.sticky_col(), Some(0), "curswant follows the cursor");
1510 }
1511}