1use regex::Regex;
32
33use crate::Editor;
34
35pub type SubstError = String;
37
38#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct SubstituteCmd {
43 pub pattern: Option<String>,
46 pub replacement: String,
50 pub flags: SubstFlags,
52 pub count: Option<usize>,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
59pub struct SubstFlags {
60 pub all: bool,
62 pub ignore_case: bool,
64 pub case_sensitive: bool,
66 pub confirm: bool,
70 pub report_only: bool,
74 pub no_error: bool,
77 pub print: bool,
81 pub print_num: bool,
83 pub print_list: bool,
85 pub reuse_previous: bool,
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
93pub struct SubstituteOutcome {
94 pub replacements: usize,
96 pub lines_changed: usize,
98 pub last_row: Option<usize>,
102}
103
104pub fn parse_substitute(s: &str) -> Result<SubstituteCmd, SubstError> {
131 let rest = s
133 .strip_prefix('/')
134 .ok_or_else(|| format!("substitute: expected '/' delimiter, got {s:?}"))?;
135
136 let parts = split_on_slash(rest);
139
140 if parts.len() < 2 {
141 return Err("substitute needs /pattern/replacement/".into());
142 }
143
144 let raw_pattern = &parts[0];
145 let raw_replacement = &parts[1];
146 let raw_flags = parts.get(2).map(String::as_str).unwrap_or("");
147
148 let pattern = if raw_pattern.is_empty() {
150 None
151 } else {
152 Some(raw_pattern.clone())
153 };
154
155 let replacement = raw_replacement.clone();
158
159 let mut flags = SubstFlags::default();
162 let mut count: Option<usize> = None;
163 let mut chars = raw_flags.chars().peekable();
164 while let Some(&ch) = chars.peek() {
165 match ch {
166 'g' => flags.all = true,
167 'i' => flags.ignore_case = true,
168 'I' => flags.case_sensitive = true,
169 'c' => flags.confirm = true,
170 'n' => flags.report_only = true,
171 'e' => flags.no_error = true,
172 'p' => flags.print = true,
173 '#' => {
174 flags.print = true;
175 flags.print_num = true;
176 }
177 'l' => {
178 flags.print = true;
179 flags.print_list = true;
180 }
181 '&' => flags.reuse_previous = true,
184 ' ' | '\t' => {}
185 c if c.is_ascii_digit() => break, other => return Err(format!("unknown flag '{other}' in substitute")),
187 }
188 chars.next();
189 }
190 let rest: String = chars.collect();
192 let rest = rest.trim();
193 if !rest.is_empty() {
194 match rest.parse::<usize>() {
195 Ok(n) if n > 0 => count = Some(n),
196 _ => return Err(format!("trailing characters in substitute: {rest:?}")),
197 }
198 }
199
200 Ok(SubstituteCmd {
201 pattern,
202 replacement,
203 flags,
204 count,
205 })
206}
207
208pub fn apply_substitute<H: crate::types::Host>(
237 ed: &mut Editor<hjkl_buffer::Buffer, H>,
238 cmd: &SubstituteCmd,
239 line_range: std::ops::RangeInclusive<u32>,
240) -> Result<SubstituteOutcome, SubstError> {
241 let pattern_str: String = match &cmd.pattern {
243 Some(p) => p.clone(),
244 None => ed
245 .last_search()
246 .map(str::to_owned)
247 .ok_or_else(|| "no previous regular expression".to_string())?,
248 };
249
250 let effective_pattern = if cmd.flags.case_sensitive {
255 use crate::search::{CaseMode, resolve_case_mode};
258 let (stripped, _) = resolve_case_mode(&pattern_str, CaseMode::Sensitive);
259 stripped
260 } else if cmd.flags.ignore_case {
261 use crate::search::{CaseMode, resolve_case_mode};
263 let (stripped, _) = resolve_case_mode(&pattern_str, CaseMode::Sensitive);
264 format!("(?i){stripped}")
265 } else {
266 use crate::search::{CaseMode, resolve_case_mode};
268 let base = CaseMode::from_options(ed.settings().ignore_case, ed.settings().smartcase);
269 let (stripped, mode) = resolve_case_mode(&pattern_str, base);
270 if mode == CaseMode::Insensitive {
271 format!("(?i){stripped}")
272 } else {
273 stripped
274 }
275 };
276
277 let regex = Regex::new(&effective_pattern).map_err(|e| format!("bad pattern: {e}"))?;
278
279 let prev_replacement = ed
283 .last_substitute()
284 .map(|c| c.replacement.clone())
285 .unwrap_or_default();
286
287 ed.push_undo();
288
289 let start = *line_range.start() as usize;
290 let end = *line_range.end() as usize;
291 let rope = crate::types::Query::rope(ed.buffer());
292 let total = rope.len_lines();
293
294 let clamp_end = end.min(total.saturating_sub(1));
295 let mut new_lines: Vec<String> = crate::rope_util::rope_to_lines_vec(&rope);
296 let mut replacements = 0usize;
297 let mut lines_changed = 0usize;
298 let mut last_changed_row = 0usize;
299
300 if start <= clamp_end {
301 for (row, line) in new_lines[start..=clamp_end].iter_mut().enumerate() {
302 let (replaced, n) = do_replace(
303 ®ex,
304 line,
305 &cmd.replacement,
306 &prev_replacement,
307 cmd.flags.all,
308 );
309 if n > 0 {
310 *line = replaced;
311 replacements += n;
312 lines_changed += 1;
313 last_changed_row = start + row;
314 }
315 }
316 }
317
318 if replacements == 0 {
319 ed.pop_last_undo();
320 return Ok(SubstituteOutcome {
321 replacements: 0,
322 lines_changed: 0,
323 last_row: None,
324 });
325 }
326
327 if cmd.flags.report_only {
330 ed.pop_last_undo();
331 ed.set_last_search(Some(pattern_str), true);
332 return Ok(SubstituteOutcome {
333 replacements,
334 lines_changed,
335 last_row: None,
336 });
337 }
338
339 ed.buffer_mut().replace_all(&new_lines.join("\n"));
341
342 let final_total = crate::types::Query::rope(ed.buffer()).len_lines();
345 let cursor_row = last_changed_row.min(final_total.saturating_sub(1));
346 let first_non_blank = crate::buf_helpers::buf_line(ed.buffer(), cursor_row)
347 .unwrap_or_default()
348 .chars()
349 .take_while(|c| *c == ' ' || *c == '\t')
350 .count();
351 let line_len = crate::buf_helpers::buf_line(ed.buffer(), cursor_row)
352 .unwrap_or_default()
353 .chars()
354 .count();
355 let cursor_col = first_non_blank.min(line_len.saturating_sub(1));
356 ed.buffer_mut()
357 .set_cursor(hjkl_buffer::Position::new(cursor_row, cursor_col));
358
359 ed.mark_content_dirty();
360
361 ed.set_last_search(Some(pattern_str), true);
363
364 Ok(SubstituteOutcome {
365 replacements,
366 lines_changed,
367 last_row: Some(cursor_row),
368 })
369}
370
371#[derive(Debug, Clone, PartialEq, Eq)]
378pub struct SubstituteMatch {
379 pub row: u32,
381 pub byte_start: u32,
383 pub byte_end: u32,
385 pub replacement: String,
387}
388
389pub fn collect_substitute_matches<H: crate::types::Host>(
401 ed: &crate::Editor<hjkl_buffer::Buffer, H>,
402 cmd: &SubstituteCmd,
403 line_range: std::ops::RangeInclusive<u32>,
404) -> Result<Vec<SubstituteMatch>, SubstError> {
405 let pattern_str: String = match &cmd.pattern {
407 Some(p) => p.clone(),
408 None => ed
409 .last_search()
410 .map(str::to_owned)
411 .ok_or_else(|| "no previous regular expression".to_string())?,
412 };
413
414 let effective_pattern = if cmd.flags.case_sensitive {
415 use crate::search::{CaseMode, resolve_case_mode};
416 let (stripped, _) = resolve_case_mode(&pattern_str, CaseMode::Sensitive);
417 stripped
418 } else if cmd.flags.ignore_case {
419 use crate::search::{CaseMode, resolve_case_mode};
420 let (stripped, _) = resolve_case_mode(&pattern_str, CaseMode::Sensitive);
421 format!("(?i){stripped}")
422 } else {
423 use crate::search::{CaseMode, resolve_case_mode};
424 let base = CaseMode::from_options(ed.settings().ignore_case, ed.settings().smartcase);
425 let (stripped, mode) = resolve_case_mode(&pattern_str, base);
426 if mode == CaseMode::Insensitive {
427 format!("(?i){stripped}")
428 } else {
429 stripped
430 }
431 };
432
433 let regex = Regex::new(&effective_pattern).map_err(|e| format!("bad pattern: {e}"))?;
434
435 let prev_replacement = ed
436 .last_substitute()
437 .map(|c| c.replacement.clone())
438 .unwrap_or_default();
439
440 let start = *line_range.start() as usize;
441 let end = *line_range.end() as usize;
442 let rope = crate::types::Query::rope(ed.buffer());
443 let total = rope.len_lines();
444 let clamp_end = end.min(total.saturating_sub(1));
445
446 let mut matches: Vec<SubstituteMatch> = Vec::new();
447
448 let expand = |line: &str, start: usize| {
452 regex
453 .captures_at(line, start)
454 .map(|caps| expand_replacement(&cmd.replacement, &caps, &prev_replacement))
455 .unwrap_or_default()
456 };
457
458 if start <= clamp_end {
459 for row in start..=clamp_end {
460 let line = hjkl_buffer::rope_line_str(&rope, row);
461 let line = line.trim_end_matches('\n');
463
464 if cmd.flags.all {
465 for m in regex.find_iter(line) {
466 matches.push(SubstituteMatch {
467 row: row as u32,
468 byte_start: m.start() as u32,
469 byte_end: m.end() as u32,
470 replacement: expand(line, m.start()),
471 });
472 }
473 } else if let Some(m) = regex.find(line) {
474 matches.push(SubstituteMatch {
476 row: row as u32,
477 byte_start: m.start() as u32,
478 byte_end: m.end() as u32,
479 replacement: expand(line, m.start()),
480 });
481 }
482 }
483 }
484
485 Ok(matches)
486}
487
488pub fn apply_collected_matches<H: crate::types::Host>(
501 ed: &mut crate::Editor<hjkl_buffer::Buffer, H>,
502 matches: &[SubstituteMatch],
503 accepted: &[bool],
504) -> usize {
505 assert_eq!(
506 matches.len(),
507 accepted.len(),
508 "apply_collected_matches: accepted.len() must equal matches.len()"
509 );
510
511 let mut to_apply: Vec<&SubstituteMatch> = matches
514 .iter()
515 .zip(accepted.iter())
516 .filter_map(|(m, &ok)| if ok { Some(m) } else { None })
517 .collect();
518
519 if to_apply.is_empty() {
520 return 0;
521 }
522
523 to_apply.sort_unstable_by(|a, b| b.row.cmp(&a.row).then(b.byte_start.cmp(&a.byte_start)));
524
525 let rope = crate::types::Query::rope(ed.buffer());
526 let mut lines_vec: Vec<String> = crate::rope_util::rope_to_lines_vec(&rope);
527 let mut applied = 0usize;
528 let mut last_changed_row: Option<usize> = None;
529
530 for sm in &to_apply {
531 let row = sm.row as usize;
532 if row >= lines_vec.len() {
533 continue;
534 }
535 let line = &lines_vec[row];
536 let bs = sm.byte_start as usize;
537 let be = sm.byte_end as usize;
538 if be > line.len() || bs > be {
539 continue;
540 }
541 if !line.is_char_boundary(bs) || !line.is_char_boundary(be) {
544 continue;
545 }
546 let mut new_line = String::with_capacity(line.len() + sm.replacement.len());
548 new_line.push_str(&line[..bs]);
549 new_line.push_str(&sm.replacement);
550 new_line.push_str(&line[be..]);
551 lines_vec[row] = new_line;
552 applied += 1;
553 last_changed_row = Some(row);
554 }
555
556 if applied > 0 {
557 ed.buffer_mut().replace_all(&lines_vec.join("\n"));
558 if let Some(row) = last_changed_row {
559 ed.buffer_mut()
560 .set_cursor(hjkl_buffer::Position::new(row, 0));
561 }
562 ed.mark_content_dirty();
563 }
564
565 applied
566}
567
568fn split_on_slash(s: &str) -> Vec<String> {
575 let mut out: Vec<String> = Vec::new();
576 let mut cur = String::new();
577 let mut chars = s.chars().peekable();
578 while let Some(c) = chars.next() {
579 if c == '\\' {
580 match chars.peek() {
581 Some(&'/') => {
582 cur.push('/');
584 chars.next();
585 }
586 Some(_) => {
587 let next = chars.next().unwrap();
590 cur.push('\\');
591 cur.push(next);
592 }
593 None => cur.push('\\'),
594 }
595 } else if c == '/' {
596 if out.len() < 2 {
597 out.push(std::mem::take(&mut cur));
598 } else {
599 cur.push(c);
603 }
607 } else {
608 cur.push(c);
609 }
610 }
611 out.push(cur);
612 out
613}
614
615#[derive(Clone, Copy, PartialEq)]
617enum CaseState {
618 None,
619 OneUpper,
621 OneLower,
623 AllUpper,
625 AllLower,
627}
628
629fn push_cased(out: &mut String, case: &mut CaseState, ch: char) {
632 match *case {
633 CaseState::None => out.push(ch),
634 CaseState::OneUpper => {
635 out.extend(ch.to_uppercase());
636 *case = CaseState::None;
637 }
638 CaseState::OneLower => {
639 out.extend(ch.to_lowercase());
640 *case = CaseState::None;
641 }
642 CaseState::AllUpper => out.extend(ch.to_uppercase()),
643 CaseState::AllLower => out.extend(ch.to_lowercase()),
644 }
645}
646
647fn expand_replacement(raw: &str, caps: ®ex::Captures, prev: &str) -> String {
661 let mut out = String::with_capacity(raw.len() + 8);
662 expand_into(&mut out, raw, caps, prev, true);
663 out
664}
665
666fn expand_into(out: &mut String, raw: &str, caps: ®ex::Captures, prev: &str, allow_tilde: bool) {
667 let mut case = CaseState::None;
668 let mut chars = raw.chars();
669 while let Some(c) = chars.next() {
670 match c {
671 '&' => {
672 let g = caps.get(0).map(|m| m.as_str()).unwrap_or("");
673 for ch in g.chars() {
674 push_cased(out, &mut case, ch);
675 }
676 }
677 '~' if allow_tilde => {
678 let mut tmp = String::new();
681 expand_into(&mut tmp, prev, caps, "", false);
682 for ch in tmp.chars() {
683 push_cased(out, &mut case, ch);
684 }
685 }
686 '\\' => match chars.next() {
687 Some('&') => push_cased(out, &mut case, '&'),
688 Some('~') => push_cased(out, &mut case, '~'),
689 Some('\\') => push_cased(out, &mut case, '\\'),
690 Some('r') => out.push('\n'),
692 Some('t') => out.push('\t'),
693 Some('n') => out.push('\0'),
694 Some(d @ '0'..='9') => {
695 let idx = d as usize - '0' as usize;
696 let g = caps.get(idx).map(|m| m.as_str()).unwrap_or("");
697 for ch in g.chars() {
698 push_cased(out, &mut case, ch);
699 }
700 }
701 Some('u') => case = CaseState::OneUpper,
702 Some('l') => case = CaseState::OneLower,
703 Some('U') => case = CaseState::AllUpper,
704 Some('L') => case = CaseState::AllLower,
705 Some('e') | Some('E') => case = CaseState::None,
706 Some(other) => push_cased(out, &mut case, other),
707 None => {} },
709 _ => push_cased(out, &mut case, c),
710 }
711 }
712}
713
714fn do_replace(
718 regex: &Regex,
719 text: &str,
720 replacement: &str,
721 prev: &str,
722 all: bool,
723) -> (String, usize) {
724 let matches = regex.find_iter(text).count();
725 if matches == 0 {
726 return (text.to_string(), 0);
727 }
728 let rep = |caps: ®ex::Captures| expand_replacement(replacement, caps, prev);
729 let replaced = if all {
730 regex.replace_all(text, rep).into_owned()
731 } else {
732 regex.replace(text, rep).into_owned()
733 };
734 let count = if all { matches } else { 1 };
735 (replaced, count)
736}
737
738#[cfg(test)]
739mod tests {
740 use super::*;
741 use crate::types::{DefaultHost, Options};
742 use hjkl_buffer::Buffer;
743
744 fn editor_with(content: &str) -> Editor<Buffer, DefaultHost> {
745 let mut e = Editor::new(Buffer::new(), DefaultHost::new(), Options::default());
746 e.set_content(content);
747 e
748 }
749
750 fn buf_line(e: &Editor<Buffer, DefaultHost>, row: usize) -> String {
751 hjkl_buffer::rope_line_str(&e.buffer().rope(), row)
752 }
753
754 #[test]
757 fn parse_basic() {
758 let cmd = parse_substitute("/foo/bar/").unwrap();
759 assert_eq!(cmd.pattern.as_deref(), Some("foo"));
760 assert_eq!(cmd.replacement, "bar");
761 assert!(!cmd.flags.all);
762 }
763
764 #[test]
765 fn parse_trailing_slash_optional() {
766 let cmd = parse_substitute("/foo/bar").unwrap();
767 assert_eq!(cmd.pattern.as_deref(), Some("foo"));
768 assert_eq!(cmd.replacement, "bar");
769 }
770
771 #[test]
772 fn parse_global_flag() {
773 let cmd = parse_substitute("/x/y/g").unwrap();
774 assert!(cmd.flags.all);
775 }
776
777 #[test]
778 fn parse_ignore_case_flag() {
779 let cmd = parse_substitute("/x/y/i").unwrap();
780 assert!(cmd.flags.ignore_case);
781 }
782
783 #[test]
784 fn parse_case_sensitive_flag() {
785 let cmd = parse_substitute("/x/y/I").unwrap();
786 assert!(cmd.flags.case_sensitive);
787 }
788
789 #[test]
790 fn parse_confirm_flag_accepted() {
791 let cmd = parse_substitute("/x/y/c").unwrap();
792 assert!(cmd.flags.confirm);
793 }
794
795 #[test]
796 fn parse_multi_flags() {
797 let cmd = parse_substitute("/x/y/gi").unwrap();
798 assert!(cmd.flags.all);
799 assert!(cmd.flags.ignore_case);
800 }
801
802 #[test]
803 fn parse_unknown_flag_errors() {
804 let err = parse_substitute("/x/y/z").unwrap_err();
805 assert!(err.to_string().contains("unknown flag 'z'"), "{err}");
806 }
807
808 #[test]
809 fn parse_empty_pattern_is_none() {
810 let cmd = parse_substitute("//bar/").unwrap();
811 assert!(cmd.pattern.is_none());
812 assert_eq!(cmd.replacement, "bar");
813 }
814
815 #[test]
816 fn parse_empty_replacement_ok() {
817 let cmd = parse_substitute("/foo//").unwrap();
818 assert_eq!(cmd.pattern.as_deref(), Some("foo"));
819 assert_eq!(cmd.replacement, "");
820 }
821
822 #[test]
823 fn parse_escaped_slash_in_pattern() {
824 let cmd = parse_substitute("/a\\/b/c/").unwrap();
825 assert_eq!(cmd.pattern.as_deref(), Some("a/b"));
826 }
827
828 #[test]
829 fn parse_escaped_slash_in_replacement() {
830 let cmd = parse_substitute("/a/b\\/c/").unwrap();
831 assert_eq!(cmd.replacement, "b/c");
833 }
834
835 #[test]
838 fn parse_keeps_replacement_raw() {
839 assert_eq!(parse_substitute("/foo/[&]/").unwrap().replacement, "[&]");
840 assert_eq!(parse_substitute("/foo/\\&/").unwrap().replacement, "\\&");
841 assert_eq!(parse_substitute("/(foo)/\\1/").unwrap().replacement, "\\1");
842 assert_eq!(parse_substitute("/(x)/\\9/").unwrap().replacement, "\\9");
843 }
844
845 #[test]
846 fn parse_wrong_delimiter_errors() {
847 let err = parse_substitute("|foo|bar|").unwrap_err();
848 assert!(err.to_string().contains("'/'"), "{err}");
849 }
850
851 #[test]
852 fn parse_too_few_fields_errors() {
853 let err = parse_substitute("/foo").unwrap_err();
854 assert!(
855 err.to_string().contains("needs /pattern/replacement"),
856 "{err}"
857 );
858 }
859
860 #[test]
863 fn apply_single_line_first_only() {
864 let mut e = editor_with("foo foo");
865 let cmd = parse_substitute("/foo/bar/").unwrap();
866 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
867 assert_eq!(out.replacements, 1);
868 assert_eq!(out.lines_changed, 1);
869 assert_eq!(buf_line(&e, 0), "bar foo");
870 }
871
872 #[test]
873 fn apply_single_line_global() {
874 let mut e = editor_with("foo foo foo");
875 let cmd = parse_substitute("/foo/bar/g").unwrap();
876 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
877 assert_eq!(out.replacements, 3);
878 assert_eq!(out.lines_changed, 1);
879 assert_eq!(buf_line(&e, 0), "bar bar bar");
880 }
881
882 #[test]
883 fn apply_multi_line_range() {
884 let mut e = editor_with("foo\nfoo foo\nbar");
885 let cmd = parse_substitute("/foo/xyz/g").unwrap();
886 let out = apply_substitute(&mut e, &cmd, 0..=2).unwrap();
887 assert_eq!(out.replacements, 3);
888 assert_eq!(out.lines_changed, 2);
889 assert_eq!(buf_line(&e, 0), "xyz");
890 assert_eq!(buf_line(&e, 1), "xyz xyz");
891 assert_eq!(buf_line(&e, 2), "bar");
892 }
893
894 #[test]
895 fn apply_no_match_returns_zero() {
896 let mut e = editor_with("hello");
897 let original = buf_line(&e, 0);
898 let cmd = parse_substitute("/xyz/abc/").unwrap();
899 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
900 assert_eq!(out.replacements, 0);
901 assert_eq!(out.lines_changed, 0);
902 assert_eq!(buf_line(&e, 0), original);
903 }
904
905 #[test]
906 fn apply_case_insensitive_flag() {
907 let mut e = editor_with("Foo FOO foo");
908 let cmd = parse_substitute("/foo/bar/gi").unwrap();
909 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
910 assert_eq!(out.replacements, 3);
911 assert_eq!(buf_line(&e, 0), "bar bar bar");
912 }
913
914 #[test]
915 fn apply_case_sensitive_flag_overrides_editor_setting() {
916 let mut e = editor_with("Foo foo");
917 e.settings_mut().ignore_case = true;
919 let cmd = parse_substitute("/foo/bar/I").unwrap();
921 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
922 assert_eq!(out.replacements, 1);
924 assert_eq!(buf_line(&e, 0), "Foo bar");
925 }
926
927 #[test]
928 fn apply_empty_pattern_reuses_last_search() {
929 let mut e = editor_with("hello world");
930 e.set_last_search(Some("world".to_string()), true);
931 let cmd = parse_substitute("//planet/").unwrap();
932 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
933 assert_eq!(out.replacements, 1);
934 assert_eq!(buf_line(&e, 0), "hello planet");
935 }
936
937 #[test]
938 fn apply_empty_pattern_no_last_search_errors() {
939 let mut e = editor_with("hello");
940 let cmd = parse_substitute("//bar/").unwrap();
941 let err = apply_substitute(&mut e, &cmd, 0..=0).unwrap_err();
942 assert!(
943 err.to_string().contains("no previous regular expression"),
944 "{err}"
945 );
946 }
947
948 #[test]
949 fn apply_updates_last_search() {
950 let mut e = editor_with("foo");
951 let cmd = parse_substitute("/foo/bar/").unwrap();
952 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
953 assert_eq!(e.last_search(), Some("foo"));
954 }
955
956 #[test]
957 fn apply_empty_replacement_deletes_match() {
958 let mut e = editor_with("hello world");
959 let cmd = parse_substitute("/world//").unwrap();
960 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
961 assert_eq!(out.replacements, 1);
962 assert_eq!(buf_line(&e, 0), "hello ");
963 }
964
965 #[test]
966 fn apply_undo_reverts_in_one_step() {
967 let mut e = editor_with("foo");
968 let cmd = parse_substitute("/foo/bar/").unwrap();
969 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
970 assert_eq!(buf_line(&e, 0), "bar");
971 e.undo();
972 assert_eq!(buf_line(&e, 0), "foo");
973 }
974
975 #[test]
976 fn apply_ampersand_in_replacement() {
977 let mut e = editor_with("foo");
978 let cmd = parse_substitute("/foo/[&]/").unwrap();
979 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
980 assert_eq!(buf_line(&e, 0), "[foo]");
981 }
982
983 #[test]
984 fn apply_capture_group_reference() {
985 let mut e = editor_with("hello world");
986 let cmd = parse_substitute("/(\\w+)/<<\\1>>/g").unwrap();
987 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
988 assert_eq!(buf_line(&e, 0), "<<hello>> <<world>>");
989 }
990
991 #[test]
992 fn apply_backslash_r_splits_line() {
993 let mut e = editor_with("a,b,c");
996 let cmd = parse_substitute("/,/\\r/g").unwrap();
997 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
998 assert_eq!(buf_line(&e, 0), "a");
999 assert_eq!(buf_line(&e, 1), "b");
1000 assert_eq!(buf_line(&e, 2), "c");
1001 }
1002
1003 #[test]
1004 fn apply_backslash_t_inserts_tab() {
1005 let mut e = editor_with("a,b");
1006 let cmd = parse_substitute("/,/\\t/").unwrap();
1007 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1008 assert_eq!(buf_line(&e, 0), "a\tb");
1009 }
1010
1011 #[test]
1012 fn apply_literal_dollar_in_replacement() {
1013 let mut e = editor_with("x");
1016 let cmd = parse_substitute("/x/$5/").unwrap();
1017 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1018 assert_eq!(buf_line(&e, 0), "$5");
1019 }
1020
1021 #[test]
1022 fn apply_backslash_zero_is_whole_match() {
1023 let mut e = editor_with("foo");
1025 let cmd = parse_substitute("/foo/[\\0]/").unwrap();
1026 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1027 assert_eq!(buf_line(&e, 0), "[foo]");
1028 }
1029
1030 #[test]
1031 fn apply_group_ref_then_literal_digits() {
1032 let mut e = editor_with("ab");
1034 let cmd = parse_substitute("/(.)/\\11/g").unwrap();
1035 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1036 assert_eq!(buf_line(&e, 0), "a1b1");
1037 }
1038
1039 fn expand(raw: &str, pat: &str, text: &str, prev: &str) -> String {
1042 let re = Regex::new(pat).unwrap();
1043 let caps = re.captures(text).unwrap();
1044 expand_replacement(raw, &caps, prev)
1045 }
1046
1047 #[test]
1048 fn expand_case_upper_run_and_end() {
1049 assert_eq!(expand("\\U\\0\\Ex", "foo", "foo", ""), "FOOx");
1051 assert_eq!(expand("\\L&\\E", "FOO", "FOO", ""), "foo");
1052 }
1053
1054 #[test]
1055 fn expand_case_one_shot() {
1056 assert_eq!(expand("\\u\\0", "foo", "foo", ""), "Foo");
1058 assert_eq!(expand("\\l\\0", "FOO", "FOO", ""), "fOO");
1059 }
1060
1061 #[test]
1062 fn expand_case_applies_to_group() {
1063 assert_eq!(expand("\\U\\1-y\\E", "(f)oo", "foo", ""), "F-Y");
1065 }
1066
1067 #[test]
1068 fn expand_literal_dollar_and_amp() {
1069 assert_eq!(expand("$\\0", "x", "x", ""), "$x");
1070 assert_eq!(expand("[&]", "foo", "foo", ""), "[foo]");
1071 assert_eq!(expand("\\&", "foo", "foo", ""), "&");
1072 }
1073
1074 #[test]
1075 fn expand_tilde_uses_previous_replacement() {
1076 assert_eq!(expand("~!", "x", "x", "PREV"), "PREV!");
1078 assert_eq!(expand("~", "(.)", "a", "[\\1]"), "[a]");
1079 assert_eq!(expand("\\~", "x", "x", "PREV"), "~");
1081 }
1082
1083 #[test]
1086 fn apply_report_only_counts_without_mutating() {
1087 let mut e = editor_with("foo foo foo");
1088 let cmd = parse_substitute("/foo/bar/gn").unwrap();
1089 assert!(cmd.flags.report_only);
1090 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1091 assert_eq!(out.replacements, 3);
1092 assert_eq!(buf_line(&e, 0), "foo foo foo");
1094 }
1095
1096 #[test]
1099 fn apply_upper_run() {
1100 let mut e = editor_with("hello world");
1101 let cmd = parse_substitute("/world/\\U&\\E/").unwrap();
1102 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1103 assert_eq!(buf_line(&e, 0), "hello WORLD");
1104 }
1105
1106 #[test]
1111 fn substitute_respects_smartcase() {
1112 let mut e = editor_with("Foo");
1113 let cmd = parse_substitute("/foo/bar/").unwrap();
1115 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1116 assert_eq!(out.replacements, 1);
1117 assert_eq!(buf_line(&e, 0), "bar");
1118 }
1119
1120 #[test]
1123 fn substitute_i_flag_overrides_c() {
1124 let mut e = editor_with("foo");
1125 let cmd = parse_substitute("/Foo/bar/i").unwrap();
1127 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1128 assert_eq!(out.replacements, 1, "expected match on 'foo' with /i flag");
1129 assert_eq!(buf_line(&e, 0), "bar");
1130 }
1131
1132 #[test]
1135 fn substitute_lower_c_inline_overrides_smartcase() {
1136 let mut e = editor_with("FOO");
1137 let cmd = parse_substitute("/\\cFoo/bar/").unwrap();
1139 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1140 assert_eq!(out.replacements, 1);
1141 assert_eq!(buf_line(&e, 0), "bar");
1142 }
1143
1144 #[test]
1147 fn collect_substitute_matches_finds_all_occurrences() {
1148 let e = editor_with("foo bar foo");
1149 let cmd = parse_substitute("/foo/baz/g").unwrap();
1150 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1151 assert_eq!(matches.len(), 2, "expected 2 matches for /g flag");
1152 assert_eq!(matches[0].byte_start, 0);
1153 assert_eq!(matches[0].byte_end, 3);
1154 assert_eq!(matches[1].byte_start, 8);
1155 assert_eq!(matches[1].byte_end, 11);
1156 assert_eq!(matches[0].replacement, "baz");
1157 assert_eq!(matches[1].replacement, "baz");
1158 }
1159
1160 #[test]
1161 fn collect_substitute_matches_respects_g_flag() {
1162 let e = editor_with("foo foo foo");
1164 let cmd = parse_substitute("/foo/baz/").unwrap();
1165 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1166 assert_eq!(matches.len(), 1, "expected 1 match without /g");
1167 assert_eq!(matches[0].byte_start, 0);
1168 }
1169
1170 #[test]
1171 fn collect_substitute_matches_respects_range() {
1172 let e = editor_with("foo\nfoo\nfoo\nfoo\nfoo");
1173 let cmd = parse_substitute("/foo/bar/g").unwrap();
1174 let matches = collect_substitute_matches(&e, &cmd, 1..=2).unwrap();
1176 assert_eq!(matches.len(), 2);
1177 assert_eq!(matches[0].row, 1);
1178 assert_eq!(matches[1].row, 2);
1179 }
1180
1181 #[test]
1182 fn collect_substitute_matches_expands_template() {
1183 let e = editor_with("hello world");
1184 let cmd = parse_substitute("/(\\w+)/<<\\1>>/g").unwrap();
1186 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1187 assert_eq!(matches.len(), 2);
1188 assert_eq!(matches[0].replacement, "<<hello>>");
1189 assert_eq!(matches[1].replacement, "<<world>>");
1190 }
1191
1192 #[test]
1195 fn apply_collected_matches_reverse_order_preserves_offsets() {
1196 let mut e = editor_with("foo bar baz");
1200 let cmd = parse_substitute("/(foo|bar|baz)/X/g").unwrap();
1201 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1202 assert_eq!(matches.len(), 3);
1203 let accepted = vec![true; 3];
1204 let applied = apply_collected_matches(&mut e, &matches, &accepted);
1205 assert_eq!(applied, 3);
1206 assert_eq!(buf_line(&e, 0), "X X X");
1207 }
1208
1209 #[test]
1210 fn apply_collected_matches_subset_only() {
1211 let mut e = editor_with("foo bar foo");
1213 let cmd = parse_substitute("/foo/ZZZ/g").unwrap();
1214 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1215 assert_eq!(matches.len(), 2, "expected 2 foo matches");
1216 let accepted = vec![true, false];
1218 let applied = apply_collected_matches(&mut e, &matches, &accepted);
1219 assert_eq!(applied, 1);
1220 assert_eq!(buf_line(&e, 0), "ZZZ bar foo");
1222 }
1223
1224 #[test]
1225 fn apply_collected_matches_zero_accepted() {
1226 let mut e = editor_with("foo bar foo");
1227 let cmd = parse_substitute("/foo/ZZZ/g").unwrap();
1228 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1229 let accepted = vec![false; matches.len()];
1230 let applied = apply_collected_matches(&mut e, &matches, &accepted);
1231 assert_eq!(applied, 0);
1232 assert_eq!(buf_line(&e, 0), "foo bar foo");
1233 }
1234
1235 #[test]
1236 fn apply_collected_matches_expands_template() {
1237 let mut e = editor_with("hello world");
1238 let cmd = parse_substitute("/(\\w+)/<<\\1>>/g").unwrap();
1239 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1240 let accepted = vec![true; matches.len()];
1241 let applied = apply_collected_matches(&mut e, &matches, &accepted);
1242 assert_eq!(applied, 2);
1243 assert_eq!(buf_line(&e, 0), "<<hello>> <<world>>");
1244 }
1245}