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.buffer_mut()
373 .set_cursor(hjkl_buffer::Position::new(cursor_row, cursor_col));
374
375 ed.mark_content_dirty();
376
377 ed.set_last_search(Some(pattern_str), true);
379
380 Ok(SubstituteOutcome {
381 replacements,
382 lines_changed,
383 last_row: Some(cursor_row),
384 })
385}
386
387#[derive(Debug, Clone, PartialEq, Eq)]
394pub struct SubstituteMatch {
395 pub row: u32,
397 pub byte_start: u32,
399 pub byte_end: u32,
401 pub replacement: String,
403}
404
405pub fn collect_substitute_matches<H: crate::types::Host>(
417 ed: &crate::Editor<hjkl_buffer::View, H>,
418 cmd: &SubstituteCmd,
419 line_range: std::ops::RangeInclusive<u32>,
420) -> Result<Vec<SubstituteMatch>, SubstError> {
421 let pattern_str: String = match &cmd.pattern {
423 Some(p) => p.clone(),
424 None => ed
425 .last_search()
426 .ok_or_else(|| "no previous regular expression".to_string())?,
427 };
428
429 let prev_replacement = ed.last_substitute_replacement();
432
433 let effective_pattern = {
434 use crate::search::{CaseMode, resolve_case_mode};
435 let base = if cmd.flags.case_sensitive {
436 CaseMode::Sensitive
437 } else if cmd.flags.ignore_case {
438 CaseMode::Insensitive
439 } else {
440 CaseMode::from_options(ed.settings().ignore_case, ed.settings().smartcase)
441 };
442 let (stripped, mode) = resolve_case_mode(&pattern_str, base, &prev_replacement);
443 if mode == CaseMode::Insensitive {
444 format!("(?i){stripped}")
445 } else {
446 stripped
447 }
448 };
449
450 let regex = Regex::new(&effective_pattern).map_err(|e| format!("bad pattern: {e}"))?;
451
452 let start = *line_range.start() as usize;
453 let end = *line_range.end() as usize;
454 let rope = crate::types::Query::rope(ed.buffer());
455 let total = rope.len_lines();
456 let clamp_end = end.min(total.saturating_sub(1));
457
458 let mut matches: Vec<SubstituteMatch> = Vec::new();
459
460 let expand = |line: &str, start: usize| {
464 regex
465 .captures_at(line, start)
466 .map(|caps| expand_replacement(&cmd.replacement, &caps, &prev_replacement))
467 .unwrap_or_default()
468 };
469
470 if start <= clamp_end {
471 for row in start..=clamp_end {
472 let line = hjkl_buffer::rope_line_str(&rope, row);
473 let line = line.trim_end_matches('\n');
475
476 if cmd.flags.all {
477 for m in regex.find_iter(line) {
478 matches.push(SubstituteMatch {
479 row: row as u32,
480 byte_start: m.start() as u32,
481 byte_end: m.end() as u32,
482 replacement: expand(line, m.start()),
483 });
484 }
485 } else if let Some(m) = regex.find(line) {
486 matches.push(SubstituteMatch {
488 row: row as u32,
489 byte_start: m.start() as u32,
490 byte_end: m.end() as u32,
491 replacement: expand(line, m.start()),
492 });
493 }
494 }
495 }
496
497 Ok(matches)
498}
499
500pub fn apply_collected_matches<H: crate::types::Host>(
513 ed: &mut crate::Editor<hjkl_buffer::View, H>,
514 matches: &[SubstituteMatch],
515 accepted: &[bool],
516) -> usize {
517 assert_eq!(
518 matches.len(),
519 accepted.len(),
520 "apply_collected_matches: accepted.len() must equal matches.len()"
521 );
522
523 let mut to_apply: Vec<&SubstituteMatch> = matches
526 .iter()
527 .zip(accepted.iter())
528 .filter_map(|(m, &ok)| if ok { Some(m) } else { None })
529 .collect();
530
531 if to_apply.is_empty() {
532 return 0;
533 }
534
535 to_apply.sort_unstable_by(|a, b| b.row.cmp(&a.row).then(b.byte_start.cmp(&a.byte_start)));
536
537 let rope = crate::types::Query::rope(ed.buffer());
538 let mut lines_vec: Vec<String> = crate::rope_util::rope_to_lines_vec(&rope);
539 let mut applied = 0usize;
540 let mut last_changed_row: Option<usize> = None;
541
542 for sm in &to_apply {
543 let row = sm.row as usize;
544 if row >= lines_vec.len() {
545 continue;
546 }
547 let line = &lines_vec[row];
548 let bs = sm.byte_start as usize;
549 let be = sm.byte_end as usize;
550 if be > line.len() || bs > be {
551 continue;
552 }
553 if !line.is_char_boundary(bs) || !line.is_char_boundary(be) {
556 continue;
557 }
558 let mut new_line = String::with_capacity(line.len() + sm.replacement.len());
560 new_line.push_str(&line[..bs]);
561 new_line.push_str(&sm.replacement);
562 new_line.push_str(&line[be..]);
563 lines_vec[row] = new_line;
564 applied += 1;
565 last_changed_row = Some(last_changed_row.map_or(row, |lr: usize| lr.max(row)));
570 }
571
572 if applied > 0 {
573 ed.buffer_mut().replace_all(&lines_vec.join("\n"));
574 if let Some(row) = last_changed_row {
575 let newlines_before: usize = lines_vec[..row]
580 .iter()
581 .map(|l| l.matches('\n').count())
582 .sum();
583 let newlines_within = lines_vec[row].matches('\n').count();
584 let row = row + newlines_before + newlines_within;
585 ed.buffer_mut()
586 .set_cursor(hjkl_buffer::Position::new(row, 0));
587 }
588 ed.mark_content_dirty();
589 }
590
591 applied
592}
593
594fn split_on_slash(s: &str) -> Vec<String> {
601 let mut out: Vec<String> = Vec::new();
602 let mut cur = String::new();
603 let mut chars = s.chars().peekable();
604 while let Some(c) = chars.next() {
605 if c == '\\' {
606 match chars.peek() {
607 Some(&'/') => {
608 cur.push('/');
610 chars.next();
611 }
612 Some(_) => {
613 let next = chars.next().unwrap();
616 cur.push('\\');
617 cur.push(next);
618 }
619 None => cur.push('\\'),
620 }
621 } else if c == '/' {
622 if out.len() < 2 {
623 out.push(std::mem::take(&mut cur));
624 } else {
625 cur.push(c);
629 }
633 } else {
634 cur.push(c);
635 }
636 }
637 out.push(cur);
638 out
639}
640
641#[derive(Clone, Copy, PartialEq)]
644enum SpanCase {
645 None,
646 Upper,
648 Lower,
650}
651
652#[derive(Clone, Copy, PartialEq)]
658enum OneShotCase {
659 Upper,
660 Lower,
661}
662
663#[derive(Clone, Copy, PartialEq)]
665struct CaseState {
666 span: SpanCase,
667 one_shot: Option<OneShotCase>,
668}
669
670impl CaseState {
671 fn new() -> Self {
672 Self {
673 span: SpanCase::None,
674 one_shot: None,
675 }
676 }
677}
678
679fn push_cased(out: &mut String, case: &mut CaseState, ch: char) {
683 let effective = match case.one_shot.take() {
684 Some(OneShotCase::Upper) => Some(SpanCase::Upper),
685 Some(OneShotCase::Lower) => Some(SpanCase::Lower),
686 None => match case.span {
687 SpanCase::None => None,
688 other => Some(other),
689 },
690 };
691 match effective {
692 None => out.push(ch),
693 Some(SpanCase::Upper) => out.extend(ch.to_uppercase()),
694 Some(SpanCase::Lower) => out.extend(ch.to_lowercase()),
695 Some(SpanCase::None) => unreachable!(),
696 }
697}
698
699fn expand_replacement(raw: &str, caps: ®ex::Captures, prev: &str) -> String {
713 let mut out = String::with_capacity(raw.len() + 8);
714 expand_into(&mut out, raw, caps, prev, true);
715 out
716}
717
718fn expand_into(out: &mut String, raw: &str, caps: ®ex::Captures, prev: &str, allow_tilde: bool) {
719 let mut case = CaseState::new();
720 let mut chars = raw.chars();
721 while let Some(c) = chars.next() {
722 match c {
723 '&' => {
724 let g = caps.get(0).map_or("", |m| m.as_str());
725 for ch in g.chars() {
726 push_cased(out, &mut case, ch);
727 }
728 }
729 '~' if allow_tilde => {
730 let mut tmp = String::new();
733 expand_into(&mut tmp, prev, caps, "", false);
734 for ch in tmp.chars() {
735 push_cased(out, &mut case, ch);
736 }
737 }
738 '\\' => match chars.next() {
739 Some('&') => push_cased(out, &mut case, '&'),
740 Some('~') => push_cased(out, &mut case, '~'),
741 Some('\\') => push_cased(out, &mut case, '\\'),
742 Some('r') => out.push('\n'),
744 Some('t') => out.push('\t'),
745 Some('n') => out.push('\0'),
746 Some(d @ '0'..='9') => {
747 let idx = d as usize - '0' as usize;
748 let g = caps.get(idx).map_or("", |m| m.as_str());
749 for ch in g.chars() {
750 push_cased(out, &mut case, ch);
751 }
752 }
753 Some('u') => case.one_shot = Some(OneShotCase::Upper),
754 Some('l') => case.one_shot = Some(OneShotCase::Lower),
755 Some('U') => case.span = SpanCase::Upper,
756 Some('L') => case.span = SpanCase::Lower,
757 Some('e') | Some('E') => case.span = SpanCase::None,
758 Some(other) => push_cased(out, &mut case, other),
759 None => {} },
761 _ => push_cased(out, &mut case, c),
762 }
763 }
764}
765
766fn do_replace(
770 regex: &Regex,
771 text: &str,
772 replacement: &str,
773 prev: &str,
774 all: bool,
775) -> (String, usize) {
776 let matches = regex.find_iter(text).count();
777 if matches == 0 {
778 return (text.to_string(), 0);
779 }
780 let rep = |caps: ®ex::Captures| expand_replacement(replacement, caps, prev);
781 let replaced = if all {
782 regex.replace_all(text, rep).into_owned()
783 } else {
784 regex.replace(text, rep).into_owned()
785 };
786 let count = if all { matches } else { 1 };
787 (replaced, count)
788}
789
790#[cfg(test)]
791mod tests {
792 use super::*;
793 use crate::types::{DefaultHost, Options};
794 use hjkl_buffer::View;
795
796 fn editor_with(content: &str) -> Editor<View, DefaultHost> {
797 let mut e = Editor::new(View::new(), DefaultHost::new(), Options::default());
798 e.set_content(content);
799 e
800 }
801
802 fn buf_line(e: &Editor<View, DefaultHost>, row: usize) -> String {
803 hjkl_buffer::rope_line_str(&e.buffer().rope(), row)
804 }
805
806 #[test]
809 fn parse_basic() {
810 let cmd = parse_substitute("/foo/bar/").unwrap();
811 assert_eq!(cmd.pattern.as_deref(), Some("foo"));
812 assert_eq!(cmd.replacement, "bar");
813 assert!(!cmd.flags.all);
814 }
815
816 #[test]
817 fn parse_trailing_slash_optional() {
818 let cmd = parse_substitute("/foo/bar").unwrap();
819 assert_eq!(cmd.pattern.as_deref(), Some("foo"));
820 assert_eq!(cmd.replacement, "bar");
821 }
822
823 #[test]
824 fn parse_global_flag() {
825 let cmd = parse_substitute("/x/y/g").unwrap();
826 assert!(cmd.flags.all);
827 }
828
829 #[test]
830 fn parse_ignore_case_flag() {
831 let cmd = parse_substitute("/x/y/i").unwrap();
832 assert!(cmd.flags.ignore_case);
833 }
834
835 #[test]
836 fn parse_case_sensitive_flag() {
837 let cmd = parse_substitute("/x/y/I").unwrap();
838 assert!(cmd.flags.case_sensitive);
839 }
840
841 #[test]
842 fn parse_confirm_flag_accepted() {
843 let cmd = parse_substitute("/x/y/c").unwrap();
844 assert!(cmd.flags.confirm);
845 }
846
847 #[test]
848 fn parse_multi_flags() {
849 let cmd = parse_substitute("/x/y/gi").unwrap();
850 assert!(cmd.flags.all);
851 assert!(cmd.flags.ignore_case);
852 }
853
854 #[test]
855 fn parse_unknown_flag_errors() {
856 let err = parse_substitute("/x/y/z").unwrap_err();
857 assert!(err.to_string().contains("unknown flag 'z'"), "{err}");
858 }
859
860 #[test]
861 fn parse_empty_pattern_is_none() {
862 let cmd = parse_substitute("//bar/").unwrap();
863 assert!(cmd.pattern.is_none());
864 assert_eq!(cmd.replacement, "bar");
865 }
866
867 #[test]
868 fn parse_empty_replacement_ok() {
869 let cmd = parse_substitute("/foo//").unwrap();
870 assert_eq!(cmd.pattern.as_deref(), Some("foo"));
871 assert_eq!(cmd.replacement, "");
872 }
873
874 #[test]
875 fn parse_escaped_slash_in_pattern() {
876 let cmd = parse_substitute("/a\\/b/c/").unwrap();
877 assert_eq!(cmd.pattern.as_deref(), Some("a/b"));
878 }
879
880 #[test]
881 fn parse_escaped_slash_in_replacement() {
882 let cmd = parse_substitute("/a/b\\/c/").unwrap();
883 assert_eq!(cmd.replacement, "b/c");
885 }
886
887 #[test]
890 fn parse_keeps_replacement_raw() {
891 assert_eq!(parse_substitute("/foo/[&]/").unwrap().replacement, "[&]");
892 assert_eq!(parse_substitute("/foo/\\&/").unwrap().replacement, "\\&");
893 assert_eq!(parse_substitute("/(foo)/\\1/").unwrap().replacement, "\\1");
894 assert_eq!(parse_substitute("/(x)/\\9/").unwrap().replacement, "\\9");
895 }
896
897 #[test]
898 fn parse_wrong_delimiter_errors() {
899 let err = parse_substitute("|foo|bar|").unwrap_err();
900 assert!(err.to_string().contains("'/'"), "{err}");
901 }
902
903 #[test]
904 fn parse_too_few_fields_errors() {
905 let err = parse_substitute("/foo").unwrap_err();
906 assert!(
907 err.to_string().contains("needs /pattern/replacement"),
908 "{err}"
909 );
910 }
911
912 #[test]
915 fn apply_single_line_first_only() {
916 let mut e = editor_with("foo foo");
917 let cmd = parse_substitute("/foo/bar/").unwrap();
918 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
919 assert_eq!(out.replacements, 1);
920 assert_eq!(out.lines_changed, 1);
921 assert_eq!(buf_line(&e, 0), "bar foo");
922 }
923
924 #[test]
925 fn apply_single_line_global() {
926 let mut e = editor_with("foo foo foo");
927 let cmd = parse_substitute("/foo/bar/g").unwrap();
928 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
929 assert_eq!(out.replacements, 3);
930 assert_eq!(out.lines_changed, 1);
931 assert_eq!(buf_line(&e, 0), "bar bar bar");
932 }
933
934 #[test]
935 fn apply_multi_line_range() {
936 let mut e = editor_with("foo\nfoo foo\nbar");
937 let cmd = parse_substitute("/foo/xyz/g").unwrap();
938 let out = apply_substitute(&mut e, &cmd, 0..=2).unwrap();
939 assert_eq!(out.replacements, 3);
940 assert_eq!(out.lines_changed, 2);
941 assert_eq!(buf_line(&e, 0), "xyz");
942 assert_eq!(buf_line(&e, 1), "xyz xyz");
943 assert_eq!(buf_line(&e, 2), "bar");
944 }
945
946 #[test]
947 fn apply_no_match_returns_zero() {
948 let mut e = editor_with("hello");
949 let original = buf_line(&e, 0);
950 let cmd = parse_substitute("/xyz/abc/").unwrap();
951 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
952 assert_eq!(out.replacements, 0);
953 assert_eq!(out.lines_changed, 0);
954 assert_eq!(buf_line(&e, 0), original);
955 }
956
957 #[test]
958 fn apply_case_insensitive_flag() {
959 let mut e = editor_with("Foo FOO foo");
960 let cmd = parse_substitute("/foo/bar/gi").unwrap();
961 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
962 assert_eq!(out.replacements, 3);
963 assert_eq!(buf_line(&e, 0), "bar bar bar");
964 }
965
966 #[test]
967 fn apply_case_sensitive_flag_overrides_editor_setting() {
968 let mut e = editor_with("Foo foo");
969 e.settings_mut().ignore_case = true;
971 let cmd = parse_substitute("/foo/bar/I").unwrap();
973 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
974 assert_eq!(out.replacements, 1);
976 assert_eq!(buf_line(&e, 0), "Foo bar");
977 }
978
979 #[test]
980 fn apply_inline_case_override_wins_over_flag() {
981 let mut insensitive = editor_with("Foo FOO foo");
982 let cmd = parse_substitute("/\\cFOO/bar/I").unwrap();
983 let out = apply_substitute(&mut insensitive, &cmd, 0..=0).unwrap();
984 assert_eq!(out.replacements, 1);
985 assert_eq!(buf_line(&insensitive, 0), "bar FOO foo");
986
987 let mut sensitive = editor_with("Foo FOO foo");
988 let cmd = parse_substitute("/\\Cfoo/bar/i").unwrap();
989 let out = apply_substitute(&mut sensitive, &cmd, 0..=0).unwrap();
990 assert_eq!(out.replacements, 1);
991 assert_eq!(buf_line(&sensitive, 0), "Foo FOO bar");
992 }
993
994 #[test]
995 fn apply_empty_pattern_reuses_last_search() {
996 let mut e = editor_with("hello world");
997 e.set_last_search(Some("world".to_string()), true);
998 let cmd = parse_substitute("//planet/").unwrap();
999 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1000 assert_eq!(out.replacements, 1);
1001 assert_eq!(buf_line(&e, 0), "hello planet");
1002 }
1003
1004 #[test]
1005 fn apply_empty_pattern_no_last_search_errors() {
1006 let mut e = editor_with("hello");
1007 let cmd = parse_substitute("//bar/").unwrap();
1008 let err = apply_substitute(&mut e, &cmd, 0..=0).unwrap_err();
1009 assert!(
1010 err.to_string().contains("no previous regular expression"),
1011 "{err}"
1012 );
1013 }
1014
1015 #[test]
1016 fn apply_updates_last_search() {
1017 let mut e = editor_with("foo");
1018 let cmd = parse_substitute("/foo/bar/").unwrap();
1019 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1020 assert_eq!(e.last_search(), Some("foo".to_string()));
1021 }
1022
1023 #[test]
1024 fn apply_empty_replacement_deletes_match() {
1025 let mut e = editor_with("hello world");
1026 let cmd = parse_substitute("/world//").unwrap();
1027 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1028 assert_eq!(out.replacements, 1);
1029 assert_eq!(buf_line(&e, 0), "hello ");
1030 }
1031
1032 #[test]
1033 fn apply_undo_reverts_in_one_step() {
1034 let mut e = editor_with("foo");
1035 let cmd = parse_substitute("/foo/bar/").unwrap();
1036 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1037 assert_eq!(buf_line(&e, 0), "bar");
1038 e.undo();
1039 assert_eq!(buf_line(&e, 0), "foo");
1040 }
1041
1042 #[test]
1043 fn apply_ampersand_in_replacement() {
1044 let mut e = editor_with("foo");
1045 let cmd = parse_substitute("/foo/[&]/").unwrap();
1046 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1047 assert_eq!(buf_line(&e, 0), "[foo]");
1048 }
1049
1050 #[test]
1051 fn apply_capture_group_reference() {
1052 let mut e = editor_with("hello world");
1053 let cmd = parse_substitute("/\\(\\w\\+\\)/<<\\1>>/g").unwrap();
1055 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1056 assert_eq!(buf_line(&e, 0), "<<hello>> <<world>>");
1057 }
1058
1059 #[test]
1060 fn apply_backslash_r_splits_line() {
1061 let mut e = editor_with("a,b,c");
1064 let cmd = parse_substitute("/,/\\r/g").unwrap();
1065 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1066 assert_eq!(buf_line(&e, 0), "a");
1067 assert_eq!(buf_line(&e, 1), "b");
1068 assert_eq!(buf_line(&e, 2), "c");
1069 }
1070
1071 #[test]
1077 fn apply_backslash_r_multi_row_cursor_lands_on_final_split_row() {
1078 let mut e = editor_with("a,b\nc,d\n");
1079 let cmd = parse_substitute("/,/\\r/").unwrap();
1080 let total = crate::types::Query::rope(e.buffer()).len_lines();
1081 let out = apply_substitute(&mut e, &cmd, 0..=(total.saturating_sub(1)) as u32).unwrap();
1082 assert_eq!(buf_line(&e, 0), "a");
1083 assert_eq!(buf_line(&e, 1), "b");
1084 assert_eq!(buf_line(&e, 2), "c");
1085 assert_eq!(buf_line(&e, 3), "d");
1086 assert_eq!(
1087 out.last_row,
1088 Some(3),
1089 "cursor should land on the last changed line ('d', real row 3) \
1090 in post-split coordinates, not the pre-split row index"
1091 );
1092 assert_eq!(e.buffer().cursor().row, 3);
1093 }
1094
1095 #[test]
1099 fn apply_backslash_r_single_row_cursor_lands_on_last_split_line() {
1100 let mut e = editor_with("a,b");
1101 let cmd = parse_substitute("/,/\\r/").unwrap();
1102 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1103 assert_eq!(buf_line(&e, 0), "a");
1104 assert_eq!(buf_line(&e, 1), "b");
1105 assert_eq!(out.last_row, Some(1));
1106 assert_eq!(e.buffer().cursor().row, 1);
1107 }
1108
1109 #[test]
1112 fn apply_no_newline_multi_row_cursor_unaffected() {
1113 let mut e = editor_with("a\na\na");
1114 let cmd = parse_substitute("/a/X/").unwrap();
1115 let out = apply_substitute(&mut e, &cmd, 0..=2).unwrap();
1116 assert_eq!(buf_line(&e, 0), "X");
1117 assert_eq!(buf_line(&e, 1), "X");
1118 assert_eq!(buf_line(&e, 2), "X");
1119 assert_eq!(out.last_row, Some(2));
1120 assert_eq!(e.buffer().cursor().row, 2);
1121 }
1122
1123 #[test]
1124 fn apply_backslash_t_inserts_tab() {
1125 let mut e = editor_with("a,b");
1126 let cmd = parse_substitute("/,/\\t/").unwrap();
1127 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1128 assert_eq!(buf_line(&e, 0), "a\tb");
1129 }
1130
1131 #[test]
1132 fn apply_literal_dollar_in_replacement() {
1133 let mut e = editor_with("x");
1136 let cmd = parse_substitute("/x/$5/").unwrap();
1137 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1138 assert_eq!(buf_line(&e, 0), "$5");
1139 }
1140
1141 #[test]
1142 fn apply_backslash_zero_is_whole_match() {
1143 let mut e = editor_with("foo");
1145 let cmd = parse_substitute("/foo/[\\0]/").unwrap();
1146 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1147 assert_eq!(buf_line(&e, 0), "[foo]");
1148 }
1149
1150 #[test]
1151 fn apply_group_ref_then_literal_digits() {
1152 let mut e = editor_with("ab");
1154 let cmd = parse_substitute("/\\(.\\)/\\11/g").unwrap();
1155 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1156 assert_eq!(buf_line(&e, 0), "a1b1");
1157 }
1158
1159 fn expand(raw: &str, pat: &str, text: &str, prev: &str) -> String {
1162 let re = Regex::new(pat).unwrap();
1163 let caps = re.captures(text).unwrap();
1164 expand_replacement(raw, &caps, prev)
1165 }
1166
1167 #[test]
1168 fn expand_case_upper_run_and_end() {
1169 assert_eq!(expand("\\U\\0\\Ex", "foo", "foo", ""), "FOOx");
1171 assert_eq!(expand("\\L&\\E", "FOO", "FOO", ""), "foo");
1172 }
1173
1174 #[test]
1175 fn expand_case_one_shot() {
1176 assert_eq!(expand("\\u\\0", "foo", "foo", ""), "Foo");
1178 assert_eq!(expand("\\l\\0", "FOO", "FOO", ""), "fOO");
1179 }
1180
1181 #[test]
1182 fn expand_case_applies_to_group() {
1183 assert_eq!(expand("\\U\\1-y\\E", "(f)oo", "foo", ""), "F-Y");
1185 }
1186
1187 #[test]
1191 fn expand_backslash_u_uppercases_first_char_of_group() {
1192 assert_eq!(expand("\\u\\1", "(\\w+)", "hello world", ""), "Hello");
1193 }
1194
1195 #[test]
1200 fn expand_one_shot_falls_back_to_active_span() {
1201 assert_eq!(expand("\\U\\l\\0", "hello", "hello", ""), "hELLO");
1202 assert_eq!(
1205 expand("\\l\\U\\1 \\2", "(\\w+) (\\w+)", "hello world", ""),
1206 "hELLO WORLD"
1207 );
1208 }
1209
1210 #[test]
1211 fn expand_literal_dollar_and_amp() {
1212 assert_eq!(expand("$\\0", "x", "x", ""), "$x");
1213 assert_eq!(expand("[&]", "foo", "foo", ""), "[foo]");
1214 assert_eq!(expand("\\&", "foo", "foo", ""), "&");
1215 }
1216
1217 #[test]
1218 fn expand_tilde_uses_previous_replacement() {
1219 assert_eq!(expand("~!", "x", "x", "PREV"), "PREV!");
1221 assert_eq!(expand("~", "(.)", "a", "[\\1]"), "[a]");
1222 assert_eq!(expand("\\~", "x", "x", "PREV"), "~");
1224 }
1225
1226 #[test]
1229 fn apply_report_only_counts_without_mutating() {
1230 let mut e = editor_with("foo foo foo");
1231 let cmd = parse_substitute("/foo/bar/gn").unwrap();
1232 assert!(cmd.flags.report_only);
1233 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1234 assert_eq!(out.replacements, 3);
1235 assert_eq!(buf_line(&e, 0), "foo foo foo");
1237 }
1238
1239 #[test]
1242 fn apply_upper_run() {
1243 let mut e = editor_with("hello world");
1244 let cmd = parse_substitute("/world/\\U&\\E/").unwrap();
1245 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1246 assert_eq!(buf_line(&e, 0), "hello WORLD");
1247 }
1248
1249 #[test]
1254 fn substitute_respects_smartcase() {
1255 let mut e = editor_with("Foo");
1256 let cmd = parse_substitute("/foo/bar/").unwrap();
1258 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1259 assert_eq!(out.replacements, 1);
1260 assert_eq!(buf_line(&e, 0), "bar");
1261 }
1262
1263 #[test]
1266 fn substitute_i_flag_overrides_c() {
1267 let mut e = editor_with("foo");
1268 let cmd = parse_substitute("/Foo/bar/i").unwrap();
1270 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1271 assert_eq!(out.replacements, 1, "expected match on 'foo' with /i flag");
1272 assert_eq!(buf_line(&e, 0), "bar");
1273 }
1274
1275 #[test]
1278 fn substitute_lower_c_inline_overrides_smartcase() {
1279 let mut e = editor_with("FOO");
1280 let cmd = parse_substitute("/\\cFoo/bar/").unwrap();
1282 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1283 assert_eq!(out.replacements, 1);
1284 assert_eq!(buf_line(&e, 0), "bar");
1285 }
1286
1287 #[test]
1290 fn collect_inline_case_override_wins_over_flag() {
1291 let e = editor_with("Foo FOO foo");
1292 let cmd = parse_substitute("/\\cFOO/bar/I").unwrap();
1293 assert_eq!(
1294 collect_substitute_matches(&e, &cmd, 0..=0).unwrap().len(),
1295 1
1296 );
1297
1298 let cmd = parse_substitute("/\\Cfoo/bar/i").unwrap();
1299 assert_eq!(
1300 collect_substitute_matches(&e, &cmd, 0..=0).unwrap().len(),
1301 1
1302 );
1303 }
1304
1305 #[test]
1306 fn collect_substitute_matches_finds_all_occurrences() {
1307 let e = editor_with("foo bar foo");
1308 let cmd = parse_substitute("/foo/baz/g").unwrap();
1309 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1310 assert_eq!(matches.len(), 2, "expected 2 matches for /g flag");
1311 assert_eq!(matches[0].byte_start, 0);
1312 assert_eq!(matches[0].byte_end, 3);
1313 assert_eq!(matches[1].byte_start, 8);
1314 assert_eq!(matches[1].byte_end, 11);
1315 assert_eq!(matches[0].replacement, "baz");
1316 assert_eq!(matches[1].replacement, "baz");
1317 }
1318
1319 #[test]
1320 fn collect_substitute_matches_respects_g_flag() {
1321 let e = editor_with("foo foo foo");
1323 let cmd = parse_substitute("/foo/baz/").unwrap();
1324 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1325 assert_eq!(matches.len(), 1, "expected 1 match without /g");
1326 assert_eq!(matches[0].byte_start, 0);
1327 }
1328
1329 #[test]
1330 fn collect_substitute_matches_respects_range() {
1331 let e = editor_with("foo\nfoo\nfoo\nfoo\nfoo");
1332 let cmd = parse_substitute("/foo/bar/g").unwrap();
1333 let matches = collect_substitute_matches(&e, &cmd, 1..=2).unwrap();
1335 assert_eq!(matches.len(), 2);
1336 assert_eq!(matches[0].row, 1);
1337 assert_eq!(matches[1].row, 2);
1338 }
1339
1340 #[test]
1341 fn collect_substitute_matches_expands_template() {
1342 let e = editor_with("hello world");
1343 let cmd = parse_substitute("/\\(\\w\\+\\)/<<\\1>>/g").unwrap();
1345 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1346 assert_eq!(matches.len(), 2);
1347 assert_eq!(matches[0].replacement, "<<hello>>");
1348 assert_eq!(matches[1].replacement, "<<world>>");
1349 }
1350
1351 #[test]
1354 fn apply_collected_matches_reverse_order_preserves_offsets() {
1355 let mut e = editor_with("foo bar baz");
1359 let cmd = parse_substitute("/\\(foo\\|bar\\|baz\\)/X/g").unwrap();
1360 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1361 assert_eq!(matches.len(), 3);
1362 let accepted = vec![true; 3];
1363 let applied = apply_collected_matches(&mut e, &matches, &accepted);
1364 assert_eq!(applied, 3);
1365 assert_eq!(buf_line(&e, 0), "X X X");
1366 }
1367
1368 #[test]
1369 fn apply_collected_matches_subset_only() {
1370 let mut e = editor_with("foo bar foo");
1372 let cmd = parse_substitute("/foo/ZZZ/g").unwrap();
1373 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1374 assert_eq!(matches.len(), 2, "expected 2 foo matches");
1375 let accepted = vec![true, false];
1377 let applied = apply_collected_matches(&mut e, &matches, &accepted);
1378 assert_eq!(applied, 1);
1379 assert_eq!(buf_line(&e, 0), "ZZZ bar foo");
1381 }
1382
1383 #[test]
1384 fn apply_collected_matches_zero_accepted() {
1385 let mut e = editor_with("foo bar foo");
1386 let cmd = parse_substitute("/foo/ZZZ/g").unwrap();
1387 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1388 let accepted = vec![false; matches.len()];
1389 let applied = apply_collected_matches(&mut e, &matches, &accepted);
1390 assert_eq!(applied, 0);
1391 assert_eq!(buf_line(&e, 0), "foo bar foo");
1392 }
1393
1394 #[test]
1395 fn apply_collected_matches_expands_template() {
1396 let mut e = editor_with("hello world");
1397 let cmd = parse_substitute("/\\(\\w\\+\\)/<<\\1>>/g").unwrap();
1398 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1399 let accepted = vec![true; matches.len()];
1400 let applied = apply_collected_matches(&mut e, &matches, &accepted);
1401 assert_eq!(applied, 2);
1402 assert_eq!(buf_line(&e, 0), "<<hello>> <<world>>");
1403 }
1404
1405 #[test]
1412 fn pattern_tilde_expands_to_last_substitute() {
1413 let mut e = editor_with("foo");
1414 let first = parse_substitute("/foo/BAR/").unwrap();
1415 apply_substitute(&mut e, &first, 0..=0).unwrap();
1416 assert_eq!(buf_line(&e, 0), "BAR");
1417 e.set_last_substitute(first); let second = parse_substitute("/~/baz/").unwrap();
1420 let out = apply_substitute(&mut e, &second, 0..=0).unwrap();
1421 assert_eq!(out.replacements, 1, "pattern `~` must match `BAR`");
1422 assert_eq!(buf_line(&e, 0), "baz");
1423 }
1424
1425 #[test]
1428 fn pattern_escaped_tilde_stays_literal() {
1429 let mut e = editor_with("a~b");
1430 e.set_last_substitute(parse_substitute("/x/BAR/").unwrap());
1432 let cmd = parse_substitute("/\\~/X/").unwrap();
1433 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1434 assert_eq!(out.replacements, 1, "`\\~` must match the literal tilde");
1435 assert_eq!(buf_line(&e, 0), "aXb");
1436 }
1437
1438 #[test]
1442 fn pattern_tilde_no_previous_substitute_expands_empty() {
1443 let mut e = editor_with("ab");
1444 assert!(e.last_substitute().is_none());
1445 let cmd = parse_substitute("/a~b/X/").unwrap();
1446 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1447 assert_eq!(out.replacements, 1, "`~`→empty so pattern is `ab`");
1448 assert_eq!(buf_line(&e, 0), "X");
1449 }
1450
1451 #[test]
1456 fn search_pattern_tilde_shares_expansion_path() {
1457 let mut e = editor_with("BAR");
1458 e.set_last_substitute(parse_substitute("/foo/BAR/").unwrap());
1459 e.push_search_pattern("~");
1460 let re = e
1461 .search_state()
1462 .pattern
1463 .as_ref()
1464 .expect("`/~` must compile to a pattern");
1465 assert!(re.is_match("BAR"), "search `~` must expand to `BAR`");
1466 assert!(
1467 !re.is_match("~"),
1468 "search `~` must not match a literal tilde"
1469 );
1470 }
1471}