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(String::as_str).unwrap_or("");
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 = if cmd.flags.case_sensitive {
274 use crate::search::{CaseMode, resolve_case_mode};
277 let (stripped, _) = resolve_case_mode(&pattern_str, CaseMode::Sensitive, &prev_replacement);
278 stripped
279 } else if cmd.flags.ignore_case {
280 use crate::search::{CaseMode, resolve_case_mode};
282 let (stripped, _) = resolve_case_mode(&pattern_str, CaseMode::Sensitive, &prev_replacement);
283 format!("(?i){stripped}")
284 } else {
285 use crate::search::{CaseMode, resolve_case_mode};
287 let base = CaseMode::from_options(ed.settings().ignore_case, ed.settings().smartcase);
288 let (stripped, mode) = resolve_case_mode(&pattern_str, base, &prev_replacement);
289 if mode == CaseMode::Insensitive {
290 format!("(?i){stripped}")
291 } else {
292 stripped
293 }
294 };
295
296 let regex = Regex::new(&effective_pattern).map_err(|e| format!("bad pattern: {e}"))?;
297
298 ed.push_undo();
299
300 let start = *line_range.start() as usize;
301 let end = *line_range.end() as usize;
302 let rope = crate::types::Query::rope(ed.buffer());
303 let total = rope.len_lines();
304
305 let clamp_end = end.min(total.saturating_sub(1));
306 let mut new_lines: Vec<String> = crate::rope_util::rope_to_lines_vec(&rope);
307 let mut replacements = 0usize;
308 let mut lines_changed = 0usize;
309 let mut last_changed_row = 0usize;
310
311 if start <= clamp_end {
312 for (row, line) in new_lines[start..=clamp_end].iter_mut().enumerate() {
313 let (replaced, n) = do_replace(
314 ®ex,
315 line,
316 &cmd.replacement,
317 &prev_replacement,
318 cmd.flags.all,
319 );
320 if n > 0 {
321 *line = replaced;
322 replacements += n;
323 lines_changed += 1;
324 last_changed_row = start + row;
325 }
326 }
327 }
328
329 if replacements == 0 {
330 ed.pop_last_undo();
331 return Ok(SubstituteOutcome {
332 replacements: 0,
333 lines_changed: 0,
334 last_row: None,
335 });
336 }
337
338 if cmd.flags.report_only {
341 ed.pop_last_undo();
342 ed.set_last_search(Some(pattern_str), true);
343 return Ok(SubstituteOutcome {
344 replacements,
345 lines_changed,
346 last_row: None,
347 });
348 }
349
350 let newlines_before: usize = new_lines[..last_changed_row]
358 .iter()
359 .map(|l| l.matches('\n').count())
360 .sum();
361 let newlines_within = new_lines[last_changed_row].matches('\n').count();
362 let last_changed_row = last_changed_row + newlines_before + newlines_within;
363
364 ed.buffer_mut().replace_all(&new_lines.join("\n"));
366
367 let final_total = crate::types::Query::rope(ed.buffer()).len_lines();
370 let cursor_row = last_changed_row.min(final_total.saturating_sub(1));
371 let first_non_blank = crate::buf_helpers::buf_line(ed.buffer(), cursor_row)
372 .unwrap_or_default()
373 .chars()
374 .take_while(|c| *c == ' ' || *c == '\t')
375 .count();
376 let line_len = crate::buf_helpers::buf_line(ed.buffer(), cursor_row)
377 .unwrap_or_default()
378 .chars()
379 .count();
380 let cursor_col = first_non_blank.min(line_len.saturating_sub(1));
381 ed.buffer_mut()
382 .set_cursor(hjkl_buffer::Position::new(cursor_row, cursor_col));
383
384 ed.mark_content_dirty();
385
386 ed.set_last_search(Some(pattern_str), true);
388
389 Ok(SubstituteOutcome {
390 replacements,
391 lines_changed,
392 last_row: Some(cursor_row),
393 })
394}
395
396#[derive(Debug, Clone, PartialEq, Eq)]
403pub struct SubstituteMatch {
404 pub row: u32,
406 pub byte_start: u32,
408 pub byte_end: u32,
410 pub replacement: String,
412}
413
414pub fn collect_substitute_matches<H: crate::types::Host>(
426 ed: &crate::Editor<hjkl_buffer::View, H>,
427 cmd: &SubstituteCmd,
428 line_range: std::ops::RangeInclusive<u32>,
429) -> Result<Vec<SubstituteMatch>, SubstError> {
430 let pattern_str: String = match &cmd.pattern {
432 Some(p) => p.clone(),
433 None => ed
434 .last_search()
435 .ok_or_else(|| "no previous regular expression".to_string())?,
436 };
437
438 let prev_replacement = ed.last_substitute_replacement();
441
442 let effective_pattern = if cmd.flags.case_sensitive {
443 use crate::search::{CaseMode, resolve_case_mode};
444 let (stripped, _) = resolve_case_mode(&pattern_str, CaseMode::Sensitive, &prev_replacement);
445 stripped
446 } else if cmd.flags.ignore_case {
447 use crate::search::{CaseMode, resolve_case_mode};
448 let (stripped, _) = resolve_case_mode(&pattern_str, CaseMode::Sensitive, &prev_replacement);
449 format!("(?i){stripped}")
450 } else {
451 use crate::search::{CaseMode, resolve_case_mode};
452 let base = CaseMode::from_options(ed.settings().ignore_case, ed.settings().smartcase);
453 let (stripped, mode) = resolve_case_mode(&pattern_str, base, &prev_replacement);
454 if mode == CaseMode::Insensitive {
455 format!("(?i){stripped}")
456 } else {
457 stripped
458 }
459 };
460
461 let regex = Regex::new(&effective_pattern).map_err(|e| format!("bad pattern: {e}"))?;
462
463 let start = *line_range.start() as usize;
464 let end = *line_range.end() as usize;
465 let rope = crate::types::Query::rope(ed.buffer());
466 let total = rope.len_lines();
467 let clamp_end = end.min(total.saturating_sub(1));
468
469 let mut matches: Vec<SubstituteMatch> = Vec::new();
470
471 let expand = |line: &str, start: usize| {
475 regex
476 .captures_at(line, start)
477 .map(|caps| expand_replacement(&cmd.replacement, &caps, &prev_replacement))
478 .unwrap_or_default()
479 };
480
481 if start <= clamp_end {
482 for row in start..=clamp_end {
483 let line = hjkl_buffer::rope_line_str(&rope, row);
484 let line = line.trim_end_matches('\n');
486
487 if cmd.flags.all {
488 for m in regex.find_iter(line) {
489 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 } else if let Some(m) = regex.find(line) {
497 matches.push(SubstituteMatch {
499 row: row as u32,
500 byte_start: m.start() as u32,
501 byte_end: m.end() as u32,
502 replacement: expand(line, m.start()),
503 });
504 }
505 }
506 }
507
508 Ok(matches)
509}
510
511pub fn apply_collected_matches<H: crate::types::Host>(
524 ed: &mut crate::Editor<hjkl_buffer::View, H>,
525 matches: &[SubstituteMatch],
526 accepted: &[bool],
527) -> usize {
528 assert_eq!(
529 matches.len(),
530 accepted.len(),
531 "apply_collected_matches: accepted.len() must equal matches.len()"
532 );
533
534 let mut to_apply: Vec<&SubstituteMatch> = matches
537 .iter()
538 .zip(accepted.iter())
539 .filter_map(|(m, &ok)| if ok { Some(m) } else { None })
540 .collect();
541
542 if to_apply.is_empty() {
543 return 0;
544 }
545
546 to_apply.sort_unstable_by(|a, b| b.row.cmp(&a.row).then(b.byte_start.cmp(&a.byte_start)));
547
548 let rope = crate::types::Query::rope(ed.buffer());
549 let mut lines_vec: Vec<String> = crate::rope_util::rope_to_lines_vec(&rope);
550 let mut applied = 0usize;
551 let mut last_changed_row: Option<usize> = None;
552
553 for sm in &to_apply {
554 let row = sm.row as usize;
555 if row >= lines_vec.len() {
556 continue;
557 }
558 let line = &lines_vec[row];
559 let bs = sm.byte_start as usize;
560 let be = sm.byte_end as usize;
561 if be > line.len() || bs > be {
562 continue;
563 }
564 if !line.is_char_boundary(bs) || !line.is_char_boundary(be) {
567 continue;
568 }
569 let mut new_line = String::with_capacity(line.len() + sm.replacement.len());
571 new_line.push_str(&line[..bs]);
572 new_line.push_str(&sm.replacement);
573 new_line.push_str(&line[be..]);
574 lines_vec[row] = new_line;
575 applied += 1;
576 last_changed_row = Some(last_changed_row.map_or(row, |lr: usize| lr.max(row)));
581 }
582
583 if applied > 0 {
584 ed.buffer_mut().replace_all(&lines_vec.join("\n"));
585 if let Some(row) = last_changed_row {
586 let newlines_before: usize = lines_vec[..row]
591 .iter()
592 .map(|l| l.matches('\n').count())
593 .sum();
594 let newlines_within = lines_vec[row].matches('\n').count();
595 let row = row + newlines_before + newlines_within;
596 ed.buffer_mut()
597 .set_cursor(hjkl_buffer::Position::new(row, 0));
598 }
599 ed.mark_content_dirty();
600 }
601
602 applied
603}
604
605fn split_on_slash(s: &str) -> Vec<String> {
612 let mut out: Vec<String> = Vec::new();
613 let mut cur = String::new();
614 let mut chars = s.chars().peekable();
615 while let Some(c) = chars.next() {
616 if c == '\\' {
617 match chars.peek() {
618 Some(&'/') => {
619 cur.push('/');
621 chars.next();
622 }
623 Some(_) => {
624 let next = chars.next().unwrap();
627 cur.push('\\');
628 cur.push(next);
629 }
630 None => cur.push('\\'),
631 }
632 } else if c == '/' {
633 if out.len() < 2 {
634 out.push(std::mem::take(&mut cur));
635 } else {
636 cur.push(c);
640 }
644 } else {
645 cur.push(c);
646 }
647 }
648 out.push(cur);
649 out
650}
651
652#[derive(Clone, Copy, PartialEq)]
655enum SpanCase {
656 None,
657 Upper,
659 Lower,
661}
662
663#[derive(Clone, Copy, PartialEq)]
669enum OneShotCase {
670 Upper,
671 Lower,
672}
673
674#[derive(Clone, Copy, PartialEq)]
676struct CaseState {
677 span: SpanCase,
678 one_shot: Option<OneShotCase>,
679}
680
681impl CaseState {
682 fn new() -> Self {
683 Self {
684 span: SpanCase::None,
685 one_shot: None,
686 }
687 }
688}
689
690fn push_cased(out: &mut String, case: &mut CaseState, ch: char) {
694 let effective = match case.one_shot.take() {
695 Some(OneShotCase::Upper) => Some(SpanCase::Upper),
696 Some(OneShotCase::Lower) => Some(SpanCase::Lower),
697 None => match case.span {
698 SpanCase::None => None,
699 other => Some(other),
700 },
701 };
702 match effective {
703 None => out.push(ch),
704 Some(SpanCase::Upper) => out.extend(ch.to_uppercase()),
705 Some(SpanCase::Lower) => out.extend(ch.to_lowercase()),
706 Some(SpanCase::None) => unreachable!(),
707 }
708}
709
710fn expand_replacement(raw: &str, caps: ®ex::Captures, prev: &str) -> String {
724 let mut out = String::with_capacity(raw.len() + 8);
725 expand_into(&mut out, raw, caps, prev, true);
726 out
727}
728
729fn expand_into(out: &mut String, raw: &str, caps: ®ex::Captures, prev: &str, allow_tilde: bool) {
730 let mut case = CaseState::new();
731 let mut chars = raw.chars();
732 while let Some(c) = chars.next() {
733 match c {
734 '&' => {
735 let g = caps.get(0).map(|m| m.as_str()).unwrap_or("");
736 for ch in g.chars() {
737 push_cased(out, &mut case, ch);
738 }
739 }
740 '~' if allow_tilde => {
741 let mut tmp = String::new();
744 expand_into(&mut tmp, prev, caps, "", false);
745 for ch in tmp.chars() {
746 push_cased(out, &mut case, ch);
747 }
748 }
749 '\\' => match chars.next() {
750 Some('&') => push_cased(out, &mut case, '&'),
751 Some('~') => push_cased(out, &mut case, '~'),
752 Some('\\') => push_cased(out, &mut case, '\\'),
753 Some('r') => out.push('\n'),
755 Some('t') => out.push('\t'),
756 Some('n') => out.push('\0'),
757 Some(d @ '0'..='9') => {
758 let idx = d as usize - '0' as usize;
759 let g = caps.get(idx).map(|m| m.as_str()).unwrap_or("");
760 for ch in g.chars() {
761 push_cased(out, &mut case, ch);
762 }
763 }
764 Some('u') => case.one_shot = Some(OneShotCase::Upper),
765 Some('l') => case.one_shot = Some(OneShotCase::Lower),
766 Some('U') => case.span = SpanCase::Upper,
767 Some('L') => case.span = SpanCase::Lower,
768 Some('e') | Some('E') => case.span = SpanCase::None,
769 Some(other) => push_cased(out, &mut case, other),
770 None => {} },
772 _ => push_cased(out, &mut case, c),
773 }
774 }
775}
776
777fn do_replace(
781 regex: &Regex,
782 text: &str,
783 replacement: &str,
784 prev: &str,
785 all: bool,
786) -> (String, usize) {
787 let matches = regex.find_iter(text).count();
788 if matches == 0 {
789 return (text.to_string(), 0);
790 }
791 let rep = |caps: ®ex::Captures| expand_replacement(replacement, caps, prev);
792 let replaced = if all {
793 regex.replace_all(text, rep).into_owned()
794 } else {
795 regex.replace(text, rep).into_owned()
796 };
797 let count = if all { matches } else { 1 };
798 (replaced, count)
799}
800
801#[cfg(test)]
802mod tests {
803 use super::*;
804 use crate::types::{DefaultHost, Options};
805 use hjkl_buffer::View;
806
807 fn editor_with(content: &str) -> Editor<View, DefaultHost> {
808 let mut e = Editor::new(View::new(), DefaultHost::new(), Options::default());
809 e.set_content(content);
810 e
811 }
812
813 fn buf_line(e: &Editor<View, DefaultHost>, row: usize) -> String {
814 hjkl_buffer::rope_line_str(&e.buffer().rope(), row)
815 }
816
817 #[test]
820 fn parse_basic() {
821 let cmd = parse_substitute("/foo/bar/").unwrap();
822 assert_eq!(cmd.pattern.as_deref(), Some("foo"));
823 assert_eq!(cmd.replacement, "bar");
824 assert!(!cmd.flags.all);
825 }
826
827 #[test]
828 fn parse_trailing_slash_optional() {
829 let cmd = parse_substitute("/foo/bar").unwrap();
830 assert_eq!(cmd.pattern.as_deref(), Some("foo"));
831 assert_eq!(cmd.replacement, "bar");
832 }
833
834 #[test]
835 fn parse_global_flag() {
836 let cmd = parse_substitute("/x/y/g").unwrap();
837 assert!(cmd.flags.all);
838 }
839
840 #[test]
841 fn parse_ignore_case_flag() {
842 let cmd = parse_substitute("/x/y/i").unwrap();
843 assert!(cmd.flags.ignore_case);
844 }
845
846 #[test]
847 fn parse_case_sensitive_flag() {
848 let cmd = parse_substitute("/x/y/I").unwrap();
849 assert!(cmd.flags.case_sensitive);
850 }
851
852 #[test]
853 fn parse_confirm_flag_accepted() {
854 let cmd = parse_substitute("/x/y/c").unwrap();
855 assert!(cmd.flags.confirm);
856 }
857
858 #[test]
859 fn parse_multi_flags() {
860 let cmd = parse_substitute("/x/y/gi").unwrap();
861 assert!(cmd.flags.all);
862 assert!(cmd.flags.ignore_case);
863 }
864
865 #[test]
866 fn parse_unknown_flag_errors() {
867 let err = parse_substitute("/x/y/z").unwrap_err();
868 assert!(err.to_string().contains("unknown flag 'z'"), "{err}");
869 }
870
871 #[test]
872 fn parse_empty_pattern_is_none() {
873 let cmd = parse_substitute("//bar/").unwrap();
874 assert!(cmd.pattern.is_none());
875 assert_eq!(cmd.replacement, "bar");
876 }
877
878 #[test]
879 fn parse_empty_replacement_ok() {
880 let cmd = parse_substitute("/foo//").unwrap();
881 assert_eq!(cmd.pattern.as_deref(), Some("foo"));
882 assert_eq!(cmd.replacement, "");
883 }
884
885 #[test]
886 fn parse_escaped_slash_in_pattern() {
887 let cmd = parse_substitute("/a\\/b/c/").unwrap();
888 assert_eq!(cmd.pattern.as_deref(), Some("a/b"));
889 }
890
891 #[test]
892 fn parse_escaped_slash_in_replacement() {
893 let cmd = parse_substitute("/a/b\\/c/").unwrap();
894 assert_eq!(cmd.replacement, "b/c");
896 }
897
898 #[test]
901 fn parse_keeps_replacement_raw() {
902 assert_eq!(parse_substitute("/foo/[&]/").unwrap().replacement, "[&]");
903 assert_eq!(parse_substitute("/foo/\\&/").unwrap().replacement, "\\&");
904 assert_eq!(parse_substitute("/(foo)/\\1/").unwrap().replacement, "\\1");
905 assert_eq!(parse_substitute("/(x)/\\9/").unwrap().replacement, "\\9");
906 }
907
908 #[test]
909 fn parse_wrong_delimiter_errors() {
910 let err = parse_substitute("|foo|bar|").unwrap_err();
911 assert!(err.to_string().contains("'/'"), "{err}");
912 }
913
914 #[test]
915 fn parse_too_few_fields_errors() {
916 let err = parse_substitute("/foo").unwrap_err();
917 assert!(
918 err.to_string().contains("needs /pattern/replacement"),
919 "{err}"
920 );
921 }
922
923 #[test]
926 fn apply_single_line_first_only() {
927 let mut e = editor_with("foo foo");
928 let cmd = parse_substitute("/foo/bar/").unwrap();
929 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
930 assert_eq!(out.replacements, 1);
931 assert_eq!(out.lines_changed, 1);
932 assert_eq!(buf_line(&e, 0), "bar foo");
933 }
934
935 #[test]
936 fn apply_single_line_global() {
937 let mut e = editor_with("foo foo foo");
938 let cmd = parse_substitute("/foo/bar/g").unwrap();
939 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
940 assert_eq!(out.replacements, 3);
941 assert_eq!(out.lines_changed, 1);
942 assert_eq!(buf_line(&e, 0), "bar bar bar");
943 }
944
945 #[test]
946 fn apply_multi_line_range() {
947 let mut e = editor_with("foo\nfoo foo\nbar");
948 let cmd = parse_substitute("/foo/xyz/g").unwrap();
949 let out = apply_substitute(&mut e, &cmd, 0..=2).unwrap();
950 assert_eq!(out.replacements, 3);
951 assert_eq!(out.lines_changed, 2);
952 assert_eq!(buf_line(&e, 0), "xyz");
953 assert_eq!(buf_line(&e, 1), "xyz xyz");
954 assert_eq!(buf_line(&e, 2), "bar");
955 }
956
957 #[test]
958 fn apply_no_match_returns_zero() {
959 let mut e = editor_with("hello");
960 let original = buf_line(&e, 0);
961 let cmd = parse_substitute("/xyz/abc/").unwrap();
962 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
963 assert_eq!(out.replacements, 0);
964 assert_eq!(out.lines_changed, 0);
965 assert_eq!(buf_line(&e, 0), original);
966 }
967
968 #[test]
969 fn apply_case_insensitive_flag() {
970 let mut e = editor_with("Foo FOO foo");
971 let cmd = parse_substitute("/foo/bar/gi").unwrap();
972 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
973 assert_eq!(out.replacements, 3);
974 assert_eq!(buf_line(&e, 0), "bar bar bar");
975 }
976
977 #[test]
978 fn apply_case_sensitive_flag_overrides_editor_setting() {
979 let mut e = editor_with("Foo foo");
980 e.settings_mut().ignore_case = true;
982 let cmd = parse_substitute("/foo/bar/I").unwrap();
984 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
985 assert_eq!(out.replacements, 1);
987 assert_eq!(buf_line(&e, 0), "Foo bar");
988 }
989
990 #[test]
991 fn apply_empty_pattern_reuses_last_search() {
992 let mut e = editor_with("hello world");
993 e.set_last_search(Some("world".to_string()), true);
994 let cmd = parse_substitute("//planet/").unwrap();
995 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
996 assert_eq!(out.replacements, 1);
997 assert_eq!(buf_line(&e, 0), "hello planet");
998 }
999
1000 #[test]
1001 fn apply_empty_pattern_no_last_search_errors() {
1002 let mut e = editor_with("hello");
1003 let cmd = parse_substitute("//bar/").unwrap();
1004 let err = apply_substitute(&mut e, &cmd, 0..=0).unwrap_err();
1005 assert!(
1006 err.to_string().contains("no previous regular expression"),
1007 "{err}"
1008 );
1009 }
1010
1011 #[test]
1012 fn apply_updates_last_search() {
1013 let mut e = editor_with("foo");
1014 let cmd = parse_substitute("/foo/bar/").unwrap();
1015 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1016 assert_eq!(e.last_search(), Some("foo".to_string()));
1017 }
1018
1019 #[test]
1020 fn apply_empty_replacement_deletes_match() {
1021 let mut e = editor_with("hello world");
1022 let cmd = parse_substitute("/world//").unwrap();
1023 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1024 assert_eq!(out.replacements, 1);
1025 assert_eq!(buf_line(&e, 0), "hello ");
1026 }
1027
1028 #[test]
1029 fn apply_undo_reverts_in_one_step() {
1030 let mut e = editor_with("foo");
1031 let cmd = parse_substitute("/foo/bar/").unwrap();
1032 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1033 assert_eq!(buf_line(&e, 0), "bar");
1034 e.undo();
1035 assert_eq!(buf_line(&e, 0), "foo");
1036 }
1037
1038 #[test]
1039 fn apply_ampersand_in_replacement() {
1040 let mut e = editor_with("foo");
1041 let cmd = parse_substitute("/foo/[&]/").unwrap();
1042 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1043 assert_eq!(buf_line(&e, 0), "[foo]");
1044 }
1045
1046 #[test]
1047 fn apply_capture_group_reference() {
1048 let mut e = editor_with("hello world");
1049 let cmd = parse_substitute("/\\(\\w\\+\\)/<<\\1>>/g").unwrap();
1051 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1052 assert_eq!(buf_line(&e, 0), "<<hello>> <<world>>");
1053 }
1054
1055 #[test]
1056 fn apply_backslash_r_splits_line() {
1057 let mut e = editor_with("a,b,c");
1060 let cmd = parse_substitute("/,/\\r/g").unwrap();
1061 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1062 assert_eq!(buf_line(&e, 0), "a");
1063 assert_eq!(buf_line(&e, 1), "b");
1064 assert_eq!(buf_line(&e, 2), "c");
1065 }
1066
1067 #[test]
1073 fn apply_backslash_r_multi_row_cursor_lands_on_final_split_row() {
1074 let mut e = editor_with("a,b\nc,d\n");
1075 let cmd = parse_substitute("/,/\\r/").unwrap();
1076 let total = crate::types::Query::rope(e.buffer()).len_lines();
1077 let out = apply_substitute(&mut e, &cmd, 0..=(total.saturating_sub(1)) as u32).unwrap();
1078 assert_eq!(buf_line(&e, 0), "a");
1079 assert_eq!(buf_line(&e, 1), "b");
1080 assert_eq!(buf_line(&e, 2), "c");
1081 assert_eq!(buf_line(&e, 3), "d");
1082 assert_eq!(
1083 out.last_row,
1084 Some(3),
1085 "cursor should land on the last changed line ('d', real row 3) \
1086 in post-split coordinates, not the pre-split row index"
1087 );
1088 assert_eq!(e.buffer().cursor().row, 3);
1089 }
1090
1091 #[test]
1095 fn apply_backslash_r_single_row_cursor_lands_on_last_split_line() {
1096 let mut e = editor_with("a,b");
1097 let cmd = parse_substitute("/,/\\r/").unwrap();
1098 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1099 assert_eq!(buf_line(&e, 0), "a");
1100 assert_eq!(buf_line(&e, 1), "b");
1101 assert_eq!(out.last_row, Some(1));
1102 assert_eq!(e.buffer().cursor().row, 1);
1103 }
1104
1105 #[test]
1108 fn apply_no_newline_multi_row_cursor_unaffected() {
1109 let mut e = editor_with("a\na\na");
1110 let cmd = parse_substitute("/a/X/").unwrap();
1111 let out = apply_substitute(&mut e, &cmd, 0..=2).unwrap();
1112 assert_eq!(buf_line(&e, 0), "X");
1113 assert_eq!(buf_line(&e, 1), "X");
1114 assert_eq!(buf_line(&e, 2), "X");
1115 assert_eq!(out.last_row, Some(2));
1116 assert_eq!(e.buffer().cursor().row, 2);
1117 }
1118
1119 #[test]
1120 fn apply_backslash_t_inserts_tab() {
1121 let mut e = editor_with("a,b");
1122 let cmd = parse_substitute("/,/\\t/").unwrap();
1123 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1124 assert_eq!(buf_line(&e, 0), "a\tb");
1125 }
1126
1127 #[test]
1128 fn apply_literal_dollar_in_replacement() {
1129 let mut e = editor_with("x");
1132 let cmd = parse_substitute("/x/$5/").unwrap();
1133 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1134 assert_eq!(buf_line(&e, 0), "$5");
1135 }
1136
1137 #[test]
1138 fn apply_backslash_zero_is_whole_match() {
1139 let mut e = editor_with("foo");
1141 let cmd = parse_substitute("/foo/[\\0]/").unwrap();
1142 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1143 assert_eq!(buf_line(&e, 0), "[foo]");
1144 }
1145
1146 #[test]
1147 fn apply_group_ref_then_literal_digits() {
1148 let mut e = editor_with("ab");
1150 let cmd = parse_substitute("/\\(.\\)/\\11/g").unwrap();
1151 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1152 assert_eq!(buf_line(&e, 0), "a1b1");
1153 }
1154
1155 fn expand(raw: &str, pat: &str, text: &str, prev: &str) -> String {
1158 let re = Regex::new(pat).unwrap();
1159 let caps = re.captures(text).unwrap();
1160 expand_replacement(raw, &caps, prev)
1161 }
1162
1163 #[test]
1164 fn expand_case_upper_run_and_end() {
1165 assert_eq!(expand("\\U\\0\\Ex", "foo", "foo", ""), "FOOx");
1167 assert_eq!(expand("\\L&\\E", "FOO", "FOO", ""), "foo");
1168 }
1169
1170 #[test]
1171 fn expand_case_one_shot() {
1172 assert_eq!(expand("\\u\\0", "foo", "foo", ""), "Foo");
1174 assert_eq!(expand("\\l\\0", "FOO", "FOO", ""), "fOO");
1175 }
1176
1177 #[test]
1178 fn expand_case_applies_to_group() {
1179 assert_eq!(expand("\\U\\1-y\\E", "(f)oo", "foo", ""), "F-Y");
1181 }
1182
1183 #[test]
1187 fn expand_backslash_u_uppercases_first_char_of_group() {
1188 assert_eq!(expand("\\u\\1", "(\\w+)", "hello world", ""), "Hello");
1189 }
1190
1191 #[test]
1196 fn expand_one_shot_falls_back_to_active_span() {
1197 assert_eq!(expand("\\U\\l\\0", "hello", "hello", ""), "hELLO");
1198 assert_eq!(
1201 expand("\\l\\U\\1 \\2", "(\\w+) (\\w+)", "hello world", ""),
1202 "hELLO WORLD"
1203 );
1204 }
1205
1206 #[test]
1207 fn expand_literal_dollar_and_amp() {
1208 assert_eq!(expand("$\\0", "x", "x", ""), "$x");
1209 assert_eq!(expand("[&]", "foo", "foo", ""), "[foo]");
1210 assert_eq!(expand("\\&", "foo", "foo", ""), "&");
1211 }
1212
1213 #[test]
1214 fn expand_tilde_uses_previous_replacement() {
1215 assert_eq!(expand("~!", "x", "x", "PREV"), "PREV!");
1217 assert_eq!(expand("~", "(.)", "a", "[\\1]"), "[a]");
1218 assert_eq!(expand("\\~", "x", "x", "PREV"), "~");
1220 }
1221
1222 #[test]
1225 fn apply_report_only_counts_without_mutating() {
1226 let mut e = editor_with("foo foo foo");
1227 let cmd = parse_substitute("/foo/bar/gn").unwrap();
1228 assert!(cmd.flags.report_only);
1229 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1230 assert_eq!(out.replacements, 3);
1231 assert_eq!(buf_line(&e, 0), "foo foo foo");
1233 }
1234
1235 #[test]
1238 fn apply_upper_run() {
1239 let mut e = editor_with("hello world");
1240 let cmd = parse_substitute("/world/\\U&\\E/").unwrap();
1241 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1242 assert_eq!(buf_line(&e, 0), "hello WORLD");
1243 }
1244
1245 #[test]
1250 fn substitute_respects_smartcase() {
1251 let mut e = editor_with("Foo");
1252 let cmd = parse_substitute("/foo/bar/").unwrap();
1254 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1255 assert_eq!(out.replacements, 1);
1256 assert_eq!(buf_line(&e, 0), "bar");
1257 }
1258
1259 #[test]
1262 fn substitute_i_flag_overrides_c() {
1263 let mut e = editor_with("foo");
1264 let cmd = parse_substitute("/Foo/bar/i").unwrap();
1266 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1267 assert_eq!(out.replacements, 1, "expected match on 'foo' with /i flag");
1268 assert_eq!(buf_line(&e, 0), "bar");
1269 }
1270
1271 #[test]
1274 fn substitute_lower_c_inline_overrides_smartcase() {
1275 let mut e = editor_with("FOO");
1276 let cmd = parse_substitute("/\\cFoo/bar/").unwrap();
1278 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1279 assert_eq!(out.replacements, 1);
1280 assert_eq!(buf_line(&e, 0), "bar");
1281 }
1282
1283 #[test]
1286 fn collect_substitute_matches_finds_all_occurrences() {
1287 let e = editor_with("foo bar foo");
1288 let cmd = parse_substitute("/foo/baz/g").unwrap();
1289 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1290 assert_eq!(matches.len(), 2, "expected 2 matches for /g flag");
1291 assert_eq!(matches[0].byte_start, 0);
1292 assert_eq!(matches[0].byte_end, 3);
1293 assert_eq!(matches[1].byte_start, 8);
1294 assert_eq!(matches[1].byte_end, 11);
1295 assert_eq!(matches[0].replacement, "baz");
1296 assert_eq!(matches[1].replacement, "baz");
1297 }
1298
1299 #[test]
1300 fn collect_substitute_matches_respects_g_flag() {
1301 let e = editor_with("foo foo foo");
1303 let cmd = parse_substitute("/foo/baz/").unwrap();
1304 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1305 assert_eq!(matches.len(), 1, "expected 1 match without /g");
1306 assert_eq!(matches[0].byte_start, 0);
1307 }
1308
1309 #[test]
1310 fn collect_substitute_matches_respects_range() {
1311 let e = editor_with("foo\nfoo\nfoo\nfoo\nfoo");
1312 let cmd = parse_substitute("/foo/bar/g").unwrap();
1313 let matches = collect_substitute_matches(&e, &cmd, 1..=2).unwrap();
1315 assert_eq!(matches.len(), 2);
1316 assert_eq!(matches[0].row, 1);
1317 assert_eq!(matches[1].row, 2);
1318 }
1319
1320 #[test]
1321 fn collect_substitute_matches_expands_template() {
1322 let e = editor_with("hello world");
1323 let cmd = parse_substitute("/\\(\\w\\+\\)/<<\\1>>/g").unwrap();
1325 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1326 assert_eq!(matches.len(), 2);
1327 assert_eq!(matches[0].replacement, "<<hello>>");
1328 assert_eq!(matches[1].replacement, "<<world>>");
1329 }
1330
1331 #[test]
1334 fn apply_collected_matches_reverse_order_preserves_offsets() {
1335 let mut e = editor_with("foo bar baz");
1339 let cmd = parse_substitute("/\\(foo\\|bar\\|baz\\)/X/g").unwrap();
1340 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1341 assert_eq!(matches.len(), 3);
1342 let accepted = vec![true; 3];
1343 let applied = apply_collected_matches(&mut e, &matches, &accepted);
1344 assert_eq!(applied, 3);
1345 assert_eq!(buf_line(&e, 0), "X X X");
1346 }
1347
1348 #[test]
1349 fn apply_collected_matches_subset_only() {
1350 let mut e = editor_with("foo bar foo");
1352 let cmd = parse_substitute("/foo/ZZZ/g").unwrap();
1353 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1354 assert_eq!(matches.len(), 2, "expected 2 foo matches");
1355 let accepted = vec![true, false];
1357 let applied = apply_collected_matches(&mut e, &matches, &accepted);
1358 assert_eq!(applied, 1);
1359 assert_eq!(buf_line(&e, 0), "ZZZ bar foo");
1361 }
1362
1363 #[test]
1364 fn apply_collected_matches_zero_accepted() {
1365 let mut e = editor_with("foo bar foo");
1366 let cmd = parse_substitute("/foo/ZZZ/g").unwrap();
1367 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1368 let accepted = vec![false; matches.len()];
1369 let applied = apply_collected_matches(&mut e, &matches, &accepted);
1370 assert_eq!(applied, 0);
1371 assert_eq!(buf_line(&e, 0), "foo bar foo");
1372 }
1373
1374 #[test]
1375 fn apply_collected_matches_expands_template() {
1376 let mut e = editor_with("hello world");
1377 let cmd = parse_substitute("/\\(\\w\\+\\)/<<\\1>>/g").unwrap();
1378 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1379 let accepted = vec![true; matches.len()];
1380 let applied = apply_collected_matches(&mut e, &matches, &accepted);
1381 assert_eq!(applied, 2);
1382 assert_eq!(buf_line(&e, 0), "<<hello>> <<world>>");
1383 }
1384
1385 #[test]
1392 fn pattern_tilde_expands_to_last_substitute() {
1393 let mut e = editor_with("foo");
1394 let first = parse_substitute("/foo/BAR/").unwrap();
1395 apply_substitute(&mut e, &first, 0..=0).unwrap();
1396 assert_eq!(buf_line(&e, 0), "BAR");
1397 e.set_last_substitute(first); let second = parse_substitute("/~/baz/").unwrap();
1400 let out = apply_substitute(&mut e, &second, 0..=0).unwrap();
1401 assert_eq!(out.replacements, 1, "pattern `~` must match `BAR`");
1402 assert_eq!(buf_line(&e, 0), "baz");
1403 }
1404
1405 #[test]
1408 fn pattern_escaped_tilde_stays_literal() {
1409 let mut e = editor_with("a~b");
1410 e.set_last_substitute(parse_substitute("/x/BAR/").unwrap());
1412 let cmd = parse_substitute("/\\~/X/").unwrap();
1413 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1414 assert_eq!(out.replacements, 1, "`\\~` must match the literal tilde");
1415 assert_eq!(buf_line(&e, 0), "aXb");
1416 }
1417
1418 #[test]
1422 fn pattern_tilde_no_previous_substitute_expands_empty() {
1423 let mut e = editor_with("ab");
1424 assert!(e.last_substitute().is_none());
1425 let cmd = parse_substitute("/a~b/X/").unwrap();
1426 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1427 assert_eq!(out.replacements, 1, "`~`→empty so pattern is `ab`");
1428 assert_eq!(buf_line(&e, 0), "X");
1429 }
1430
1431 #[test]
1436 fn search_pattern_tilde_shares_expansion_path() {
1437 let mut e = editor_with("BAR");
1438 e.set_last_substitute(parse_substitute("/foo/BAR/").unwrap());
1439 e.push_search_pattern("~");
1440 let re = e
1441 .search_state()
1442 .pattern
1443 .as_ref()
1444 .expect("`/~` must compile to a pattern");
1445 assert!(re.is_match("BAR"), "search `~` must expand to `BAR`");
1446 assert!(
1447 !re.is_match("~"),
1448 "search `~` must not match a literal tilde"
1449 );
1450 }
1451}