1use regex::Regex;
34
35use crate::Editor;
36
37pub type SubstError = String;
39
40#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct SubstituteCmd {
45 pub pattern: Option<String>,
48 pub replacement: String,
52 pub flags: SubstFlags,
54 pub count: Option<usize>,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
61pub struct SubstFlags {
62 pub all: bool,
64 pub ignore_case: bool,
66 pub case_sensitive: bool,
68 pub confirm: bool,
72 pub report_only: bool,
76 pub no_error: bool,
79 pub print: bool,
83 pub print_num: bool,
85 pub print_list: bool,
87 pub reuse_previous: bool,
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
95pub struct SubstituteOutcome {
96 pub replacements: usize,
98 pub lines_changed: usize,
100 pub last_row: Option<usize>,
104}
105
106pub fn parse_substitute(s: &str) -> Result<SubstituteCmd, SubstError> {
133 let rest = s
135 .strip_prefix('/')
136 .ok_or_else(|| format!("substitute: expected '/' delimiter, got {s:?}"))?;
137
138 let parts = split_on_slash(rest);
141
142 if parts.len() < 2 {
143 return Err("substitute needs /pattern/replacement/".into());
144 }
145
146 let raw_pattern = &parts[0];
147 let raw_replacement = &parts[1];
148 let raw_flags = parts.get(2).map_or("", String::as_str);
149
150 let pattern = if raw_pattern.is_empty() {
152 None
153 } else {
154 Some(raw_pattern.clone())
155 };
156
157 let replacement = raw_replacement.clone();
160
161 let (flags, count) = parse_flags(raw_flags)?;
162
163 Ok(SubstituteCmd {
164 pattern,
165 replacement,
166 flags,
167 count,
168 })
169}
170
171pub fn parse_flags(raw_flags: &str) -> Result<(SubstFlags, Option<usize>), SubstError> {
182 let mut flags = SubstFlags::default();
183 let mut count: Option<usize> = None;
184 let mut chars = raw_flags.chars().peekable();
185 while let Some(&ch) = chars.peek() {
186 match ch {
187 'g' => flags.all = true,
188 'i' => flags.ignore_case = true,
189 'I' => flags.case_sensitive = true,
190 'c' => flags.confirm = true,
191 'n' => flags.report_only = true,
192 'e' => flags.no_error = true,
193 'p' => flags.print = true,
194 '#' => {
195 flags.print = true;
196 flags.print_num = true;
197 }
198 'l' => {
199 flags.print = true;
200 flags.print_list = true;
201 }
202 '&' => flags.reuse_previous = true,
205 ' ' | '\t' => {}
206 c if c.is_ascii_digit() => break, other => return Err(format!("unknown flag '{other}' in substitute")),
208 }
209 chars.next();
210 }
211 let rest: String = chars.collect();
213 let rest = rest.trim();
214 if !rest.is_empty() {
215 match rest.parse::<usize>() {
216 Ok(n) if n > 0 => count = Some(n),
217 _ => return Err(format!("trailing characters in substitute: {rest:?}")),
218 }
219 }
220 Ok((flags, count))
221}
222
223fn rebase_marks_after_row_growth<H: crate::types::Host>(
238 ed: &mut Editor<hjkl_buffer::View, H>,
239 new_lines: &[String],
240) {
241 for (i, line) in new_lines.iter().enumerate().rev() {
242 let delta = line.matches('\n').count() as isize;
243 if delta != 0 {
244 ed.shift_marks_after_edit(i, delta);
245 }
246 }
247}
248
249fn emit_whole_buffer_change<H: crate::types::Host>(
258 ed: &mut Editor<hjkl_buffer::View, H>,
259 pre_end: crate::types::Pos,
260 new_text: &str,
261) {
262 ed.buffer_mut().extend_change_log([crate::types::Edit {
263 range: crate::types::Pos::new(0, 0)..pre_end,
264 replacement: new_text.to_string(),
265 }]);
266 ed.buffer_mut().clear_pending_content_edits();
267 ed.buffer_mut().set_pending_content_reset(true);
268}
269
270pub fn apply_substitute<H: crate::types::Host>(
299 ed: &mut Editor<hjkl_buffer::View, H>,
300 cmd: &SubstituteCmd,
301 line_range: std::ops::RangeInclusive<u32>,
302) -> Result<SubstituteOutcome, SubstError> {
303 let pattern_str: String = match &cmd.pattern {
305 Some(p) => p.clone(),
306 None => ed
307 .last_search()
308 .ok_or_else(|| "no previous regular expression".to_string())?,
309 };
310
311 let prev_replacement = ed.last_substitute_replacement();
315
316 let effective_pattern = {
318 use crate::search::{CaseMode, resolve_case_mode};
319 let base = if cmd.flags.case_sensitive {
320 CaseMode::Sensitive
321 } else if cmd.flags.ignore_case {
322 CaseMode::Insensitive
323 } else {
324 CaseMode::from_options(ed.settings().ignore_case, ed.settings().smartcase)
325 };
326 let (stripped, mode) = resolve_case_mode(&pattern_str, base, &prev_replacement);
327 if mode == CaseMode::Insensitive {
328 format!("(?i){stripped}")
329 } else {
330 stripped
331 }
332 };
333
334 let regex = Regex::new(&effective_pattern).map_err(|e| format!("bad pattern: {e}"))?;
335
336 ed.push_undo();
337
338 let start = *line_range.start() as usize;
339 let end = *line_range.end() as usize;
340 let rope = crate::types::Query::rope(ed.buffer());
341 let total = rope.len_lines();
342
343 let clamp_end = end.min(total.saturating_sub(1));
344 let mut new_lines: Vec<String> = crate::rope_util::rope_to_lines_vec(&rope);
345 let mut replacements = 0usize;
346 let mut lines_changed = 0usize;
347 let mut last_changed_row = 0usize;
348
349 if start <= clamp_end {
350 for (row, line) in new_lines[start..=clamp_end].iter_mut().enumerate() {
351 let (replaced, n) = do_replace(
352 ®ex,
353 line,
354 &cmd.replacement,
355 &prev_replacement,
356 cmd.flags.all,
357 );
358 if n > 0 {
359 *line = replaced;
360 replacements += n;
361 lines_changed += 1;
362 last_changed_row = start + row;
363 }
364 }
365 }
366
367 if replacements == 0 {
368 ed.pop_last_undo();
369 return Ok(SubstituteOutcome {
370 replacements: 0,
371 lines_changed: 0,
372 last_row: None,
373 });
374 }
375
376 if cmd.flags.report_only {
379 ed.pop_last_undo();
380 ed.set_last_search(Some(pattern_str), true);
381 return Ok(SubstituteOutcome {
382 replacements,
383 lines_changed,
384 last_row: None,
385 });
386 }
387
388 let newlines_before: usize = new_lines[..last_changed_row]
396 .iter()
397 .map(|l| l.matches('\n').count())
398 .sum();
399 let newlines_within = new_lines[last_changed_row].matches('\n').count();
400 let last_changed_row = last_changed_row + newlines_before + newlines_within;
401
402 let pre_rows = crate::types::Query::rope(ed.buffer()).len_lines();
407 let last_pre_row = pre_rows.saturating_sub(1);
408 let pre_end = crate::types::Pos::new(
409 last_pre_row as u32,
410 crate::buf_helpers::buf_line(ed.buffer(), last_pre_row)
411 .unwrap_or_default()
412 .chars()
413 .count() as u32,
414 );
415 let new_text = new_lines.join("\n");
416 ed.buffer_mut().replace_all(&new_text);
417 emit_whole_buffer_change(ed, pre_end, &new_text);
418
419 rebase_marks_after_row_growth(ed, &new_lines);
423
424 let final_total = crate::types::Query::rope(ed.buffer()).len_lines();
427 let cursor_row = last_changed_row.min(final_total.saturating_sub(1));
428 let first_non_blank = crate::buf_helpers::buf_line(ed.buffer(), cursor_row)
429 .unwrap_or_default()
430 .chars()
431 .take_while(|c| *c == ' ' || *c == '\t')
432 .count();
433 let line_len = crate::buf_helpers::buf_line(ed.buffer(), cursor_row)
434 .unwrap_or_default()
435 .chars()
436 .count();
437 let cursor_col = first_non_blank.min(line_len.saturating_sub(1));
438 ed.jump_cursor(cursor_row, cursor_col);
442
443 ed.mark_content_dirty();
444
445 ed.set_last_search(Some(pattern_str), true);
447
448 Ok(SubstituteOutcome {
449 replacements,
450 lines_changed,
451 last_row: Some(cursor_row),
452 })
453}
454
455#[derive(Debug, Clone, PartialEq, Eq)]
462pub struct SubstituteMatch {
463 pub row: u32,
465 pub byte_start: u32,
467 pub byte_end: u32,
469 pub replacement: String,
471}
472
473pub fn collect_substitute_matches<H: crate::types::Host>(
485 ed: &crate::Editor<hjkl_buffer::View, H>,
486 cmd: &SubstituteCmd,
487 line_range: std::ops::RangeInclusive<u32>,
488) -> Result<Vec<SubstituteMatch>, SubstError> {
489 let pattern_str: String = match &cmd.pattern {
491 Some(p) => p.clone(),
492 None => ed
493 .last_search()
494 .ok_or_else(|| "no previous regular expression".to_string())?,
495 };
496
497 let prev_replacement = ed.last_substitute_replacement();
500
501 let effective_pattern = {
502 use crate::search::{CaseMode, resolve_case_mode};
503 let base = if cmd.flags.case_sensitive {
504 CaseMode::Sensitive
505 } else if cmd.flags.ignore_case {
506 CaseMode::Insensitive
507 } else {
508 CaseMode::from_options(ed.settings().ignore_case, ed.settings().smartcase)
509 };
510 let (stripped, mode) = resolve_case_mode(&pattern_str, base, &prev_replacement);
511 if mode == CaseMode::Insensitive {
512 format!("(?i){stripped}")
513 } else {
514 stripped
515 }
516 };
517
518 let regex = Regex::new(&effective_pattern).map_err(|e| format!("bad pattern: {e}"))?;
519
520 let start = *line_range.start() as usize;
521 let end = *line_range.end() as usize;
522 let rope = crate::types::Query::rope(ed.buffer());
523 let total = rope.len_lines();
524 let clamp_end = end.min(total.saturating_sub(1));
525
526 let mut matches: Vec<SubstituteMatch> = Vec::new();
527
528 let expand = |line: &str, start: usize| {
532 regex
533 .captures_at(line, start)
534 .map(|caps| expand_replacement(&cmd.replacement, &caps, &prev_replacement))
535 .unwrap_or_default()
536 };
537
538 if start <= clamp_end {
539 for row in start..=clamp_end {
540 let line = crate::viewport_math::rope_line_slice(&rope, row);
542 let line = line.trim_end_matches('\n');
544
545 if cmd.flags.all {
546 for m in regex.find_iter(line) {
547 matches.push(SubstituteMatch {
548 row: row as u32,
549 byte_start: m.start() as u32,
550 byte_end: m.end() as u32,
551 replacement: expand(line, m.start()),
552 });
553 }
554 } else if let Some(m) = regex.find(line) {
555 matches.push(SubstituteMatch {
557 row: row as u32,
558 byte_start: m.start() as u32,
559 byte_end: m.end() as u32,
560 replacement: expand(line, m.start()),
561 });
562 }
563 }
564 }
565
566 Ok(matches)
567}
568
569pub fn apply_collected_matches<H: crate::types::Host>(
582 ed: &mut crate::Editor<hjkl_buffer::View, H>,
583 matches: &[SubstituteMatch],
584 accepted: &[bool],
585) -> usize {
586 assert_eq!(
587 matches.len(),
588 accepted.len(),
589 "apply_collected_matches: accepted.len() must equal matches.len()"
590 );
591
592 let mut to_apply: Vec<&SubstituteMatch> = matches
595 .iter()
596 .zip(accepted.iter())
597 .filter_map(|(m, &ok)| if ok { Some(m) } else { None })
598 .collect();
599
600 if to_apply.is_empty() {
601 return 0;
602 }
603
604 to_apply.sort_unstable_by(|a, b| b.row.cmp(&a.row).then(b.byte_start.cmp(&a.byte_start)));
605
606 let rope = crate::types::Query::rope(ed.buffer());
607 let mut lines_vec: Vec<String> = crate::rope_util::rope_to_lines_vec(&rope);
608 let mut applied = 0usize;
609 let mut last_changed_row: Option<usize> = None;
610
611 for sm in &to_apply {
612 let row = sm.row as usize;
613 if row >= lines_vec.len() {
614 continue;
615 }
616 let line = &lines_vec[row];
617 let bs = sm.byte_start as usize;
618 let be = sm.byte_end as usize;
619 if be > line.len() || bs > be {
620 continue;
621 }
622 if !line.is_char_boundary(bs) || !line.is_char_boundary(be) {
625 continue;
626 }
627 let mut new_line = String::with_capacity(line.len() + sm.replacement.len());
629 new_line.push_str(&line[..bs]);
630 new_line.push_str(&sm.replacement);
631 new_line.push_str(&line[be..]);
632 lines_vec[row] = new_line;
633 applied += 1;
634 last_changed_row = Some(last_changed_row.map_or(row, |lr: usize| lr.max(row)));
639 }
640
641 if applied > 0 {
642 let pre_rows = crate::types::Query::rope(ed.buffer()).len_lines();
646 let last_pre_row = pre_rows.saturating_sub(1);
647 let pre_end = crate::types::Pos::new(
648 last_pre_row as u32,
649 crate::buf_helpers::buf_line(ed.buffer(), last_pre_row)
650 .unwrap_or_default()
651 .chars()
652 .count() as u32,
653 );
654 let new_text = lines_vec.join("\n");
655 ed.buffer_mut().replace_all(&new_text);
656 emit_whole_buffer_change(ed, pre_end, &new_text);
657 rebase_marks_after_row_growth(ed, &lines_vec);
660 if let Some(row) = last_changed_row {
661 let newlines_before: usize = lines_vec[..row]
666 .iter()
667 .map(|l| l.matches('\n').count())
668 .sum();
669 let newlines_within = lines_vec[row].matches('\n').count();
670 let row = row + newlines_before + newlines_within;
671 ed.jump_cursor(row, 0);
674 }
675 ed.mark_content_dirty();
676 }
677
678 applied
679}
680
681fn split_on_slash(s: &str) -> Vec<String> {
688 let mut out: Vec<String> = Vec::new();
689 let mut cur = String::new();
690 let mut chars = s.chars().peekable();
691 while let Some(c) = chars.next() {
692 if c == '\\' {
693 match chars.peek() {
694 Some(&'/') => {
695 cur.push('/');
697 chars.next();
698 }
699 Some(_) => {
700 let next = chars.next().unwrap();
703 cur.push('\\');
704 cur.push(next);
705 }
706 None => cur.push('\\'),
707 }
708 } else if c == '/' {
709 if out.len() < 2 {
710 out.push(std::mem::take(&mut cur));
711 } else {
712 cur.push(c);
716 }
720 } else {
721 cur.push(c);
722 }
723 }
724 out.push(cur);
725 out
726}
727
728#[derive(Clone, Copy, PartialEq)]
731enum SpanCase {
732 None,
733 Upper,
735 Lower,
737}
738
739#[derive(Clone, Copy, PartialEq)]
745enum OneShotCase {
746 Upper,
747 Lower,
748}
749
750#[derive(Clone, Copy, PartialEq)]
752struct CaseState {
753 span: SpanCase,
754 one_shot: Option<OneShotCase>,
755}
756
757impl CaseState {
758 fn new() -> Self {
759 Self {
760 span: SpanCase::None,
761 one_shot: None,
762 }
763 }
764}
765
766fn push_cased(out: &mut String, case: &mut CaseState, ch: char) {
770 let effective = match case.one_shot.take() {
771 Some(OneShotCase::Upper) => Some(SpanCase::Upper),
772 Some(OneShotCase::Lower) => Some(SpanCase::Lower),
773 None => match case.span {
774 SpanCase::None => None,
775 other => Some(other),
776 },
777 };
778 match effective {
779 None => out.push(ch),
780 Some(SpanCase::Upper) => out.extend(ch.to_uppercase()),
781 Some(SpanCase::Lower) => out.extend(ch.to_lowercase()),
782 Some(SpanCase::None) => unreachable!(),
783 }
784}
785
786fn expand_replacement(raw: &str, caps: ®ex::Captures, prev: &str) -> String {
800 let mut out = String::with_capacity(raw.len() + 8);
801 expand_into(&mut out, raw, caps, prev, true);
802 out
803}
804
805fn expand_into(out: &mut String, raw: &str, caps: ®ex::Captures, prev: &str, allow_tilde: bool) {
806 let mut case = CaseState::new();
807 let mut chars = raw.chars();
808 while let Some(c) = chars.next() {
809 match c {
810 '&' => {
811 let g = caps.get(0).map_or("", |m| m.as_str());
812 for ch in g.chars() {
813 push_cased(out, &mut case, ch);
814 }
815 }
816 '~' if allow_tilde => {
817 let mut tmp = String::new();
820 expand_into(&mut tmp, prev, caps, "", false);
821 for ch in tmp.chars() {
822 push_cased(out, &mut case, ch);
823 }
824 }
825 '\\' => match chars.next() {
826 Some('&') => push_cased(out, &mut case, '&'),
827 Some('~') => push_cased(out, &mut case, '~'),
828 Some('\\') => push_cased(out, &mut case, '\\'),
829 Some('r') => out.push('\n'),
831 Some('t') => out.push('\t'),
832 Some('n') => out.push('\0'),
833 Some(d @ '0'..='9') => {
834 let idx = d as usize - '0' as usize;
835 let g = caps.get(idx).map_or("", |m| m.as_str());
836 for ch in g.chars() {
837 push_cased(out, &mut case, ch);
838 }
839 }
840 Some('u') => case.one_shot = Some(OneShotCase::Upper),
841 Some('l') => case.one_shot = Some(OneShotCase::Lower),
842 Some('U') => case.span = SpanCase::Upper,
843 Some('L') => case.span = SpanCase::Lower,
844 Some('e') | Some('E') => case.span = SpanCase::None,
845 Some(other) => push_cased(out, &mut case, other),
846 None => {} },
848 _ => push_cased(out, &mut case, c),
849 }
850 }
851}
852
853fn do_replace(
857 regex: &Regex,
858 text: &str,
859 replacement: &str,
860 prev: &str,
861 all: bool,
862) -> (String, usize) {
863 let matches = regex.find_iter(text).count();
864 if matches == 0 {
865 return (text.to_string(), 0);
866 }
867 let rep = |caps: ®ex::Captures| expand_replacement(replacement, caps, prev);
868 let replaced = if all {
869 regex.replace_all(text, rep).into_owned()
870 } else {
871 regex.replace(text, rep).into_owned()
872 };
873 let count = if all { matches } else { 1 };
874 (replaced, count)
875}
876
877#[cfg(test)]
878mod tests {
879 use super::*;
880 use crate::types::{DefaultHost, Options};
881 use hjkl_buffer::View;
882
883 fn editor_with(content: &str) -> Editor<View, DefaultHost> {
884 let mut e = Editor::new(View::new(), DefaultHost::new(), Options::default());
885 e.set_content(content);
886 e
887 }
888
889 fn buf_line(e: &Editor<View, DefaultHost>, row: usize) -> String {
890 hjkl_buffer::rope_line_str(&e.buffer().rope(), row)
891 }
892
893 #[test]
896 fn parse_basic() {
897 let cmd = parse_substitute("/foo/bar/").unwrap();
898 assert_eq!(cmd.pattern.as_deref(), Some("foo"));
899 assert_eq!(cmd.replacement, "bar");
900 assert!(!cmd.flags.all);
901 }
902
903 #[test]
904 fn parse_trailing_slash_optional() {
905 let cmd = parse_substitute("/foo/bar").unwrap();
906 assert_eq!(cmd.pattern.as_deref(), Some("foo"));
907 assert_eq!(cmd.replacement, "bar");
908 }
909
910 #[test]
911 fn parse_global_flag() {
912 let cmd = parse_substitute("/x/y/g").unwrap();
913 assert!(cmd.flags.all);
914 }
915
916 #[test]
917 fn parse_ignore_case_flag() {
918 let cmd = parse_substitute("/x/y/i").unwrap();
919 assert!(cmd.flags.ignore_case);
920 }
921
922 #[test]
923 fn parse_case_sensitive_flag() {
924 let cmd = parse_substitute("/x/y/I").unwrap();
925 assert!(cmd.flags.case_sensitive);
926 }
927
928 #[test]
929 fn parse_confirm_flag_accepted() {
930 let cmd = parse_substitute("/x/y/c").unwrap();
931 assert!(cmd.flags.confirm);
932 }
933
934 #[test]
935 fn parse_multi_flags() {
936 let cmd = parse_substitute("/x/y/gi").unwrap();
937 assert!(cmd.flags.all);
938 assert!(cmd.flags.ignore_case);
939 }
940
941 #[test]
942 fn parse_unknown_flag_errors() {
943 let err = parse_substitute("/x/y/z").unwrap_err();
944 assert!(err.to_string().contains("unknown flag 'z'"), "{err}");
945 }
946
947 #[test]
948 fn parse_empty_pattern_is_none() {
949 let cmd = parse_substitute("//bar/").unwrap();
950 assert!(cmd.pattern.is_none());
951 assert_eq!(cmd.replacement, "bar");
952 }
953
954 #[test]
955 fn parse_empty_replacement_ok() {
956 let cmd = parse_substitute("/foo//").unwrap();
957 assert_eq!(cmd.pattern.as_deref(), Some("foo"));
958 assert_eq!(cmd.replacement, "");
959 }
960
961 #[test]
962 fn parse_escaped_slash_in_pattern() {
963 let cmd = parse_substitute("/a\\/b/c/").unwrap();
964 assert_eq!(cmd.pattern.as_deref(), Some("a/b"));
965 }
966
967 #[test]
968 fn parse_escaped_slash_in_replacement() {
969 let cmd = parse_substitute("/a/b\\/c/").unwrap();
970 assert_eq!(cmd.replacement, "b/c");
972 }
973
974 #[test]
977 fn parse_keeps_replacement_raw() {
978 assert_eq!(parse_substitute("/foo/[&]/").unwrap().replacement, "[&]");
979 assert_eq!(parse_substitute("/foo/\\&/").unwrap().replacement, "\\&");
980 assert_eq!(parse_substitute("/(foo)/\\1/").unwrap().replacement, "\\1");
981 assert_eq!(parse_substitute("/(x)/\\9/").unwrap().replacement, "\\9");
982 }
983
984 #[test]
985 fn parse_wrong_delimiter_errors() {
986 let err = parse_substitute("|foo|bar|").unwrap_err();
987 assert!(err.to_string().contains("'/'"), "{err}");
988 }
989
990 #[test]
991 fn parse_too_few_fields_errors() {
992 let err = parse_substitute("/foo").unwrap_err();
993 assert!(
994 err.to_string().contains("needs /pattern/replacement"),
995 "{err}"
996 );
997 }
998
999 #[test]
1002 fn apply_single_line_first_only() {
1003 let mut e = editor_with("foo foo");
1004 let cmd = parse_substitute("/foo/bar/").unwrap();
1005 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1006 assert_eq!(out.replacements, 1);
1007 assert_eq!(out.lines_changed, 1);
1008 assert_eq!(buf_line(&e, 0), "bar foo");
1009 }
1010
1011 #[test]
1012 fn apply_single_line_global() {
1013 let mut e = editor_with("foo foo foo");
1014 let cmd = parse_substitute("/foo/bar/g").unwrap();
1015 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1016 assert_eq!(out.replacements, 3);
1017 assert_eq!(out.lines_changed, 1);
1018 assert_eq!(buf_line(&e, 0), "bar bar bar");
1019 }
1020
1021 #[test]
1022 fn apply_multi_line_range() {
1023 let mut e = editor_with("foo\nfoo foo\nbar");
1024 let cmd = parse_substitute("/foo/xyz/g").unwrap();
1025 let out = apply_substitute(&mut e, &cmd, 0..=2).unwrap();
1026 assert_eq!(out.replacements, 3);
1027 assert_eq!(out.lines_changed, 2);
1028 assert_eq!(buf_line(&e, 0), "xyz");
1029 assert_eq!(buf_line(&e, 1), "xyz xyz");
1030 assert_eq!(buf_line(&e, 2), "bar");
1031 }
1032
1033 #[test]
1034 fn apply_no_match_returns_zero() {
1035 let mut e = editor_with("hello");
1036 let original = buf_line(&e, 0);
1037 let cmd = parse_substitute("/xyz/abc/").unwrap();
1038 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1039 assert_eq!(out.replacements, 0);
1040 assert_eq!(out.lines_changed, 0);
1041 assert_eq!(buf_line(&e, 0), original);
1042 }
1043
1044 #[test]
1045 fn apply_case_insensitive_flag() {
1046 let mut e = editor_with("Foo FOO foo");
1047 let cmd = parse_substitute("/foo/bar/gi").unwrap();
1048 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1049 assert_eq!(out.replacements, 3);
1050 assert_eq!(buf_line(&e, 0), "bar bar bar");
1051 }
1052
1053 #[test]
1054 fn apply_case_sensitive_flag_overrides_editor_setting() {
1055 let mut e = editor_with("Foo foo");
1056 e.settings_mut().ignore_case = true;
1058 let cmd = parse_substitute("/foo/bar/I").unwrap();
1060 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1061 assert_eq!(out.replacements, 1);
1063 assert_eq!(buf_line(&e, 0), "Foo bar");
1064 }
1065
1066 #[test]
1067 fn apply_inline_case_override_wins_over_flag() {
1068 let mut insensitive = editor_with("Foo FOO foo");
1069 let cmd = parse_substitute("/\\cFOO/bar/I").unwrap();
1070 let out = apply_substitute(&mut insensitive, &cmd, 0..=0).unwrap();
1071 assert_eq!(out.replacements, 1);
1072 assert_eq!(buf_line(&insensitive, 0), "bar FOO foo");
1073
1074 let mut sensitive = editor_with("Foo FOO foo");
1075 let cmd = parse_substitute("/\\Cfoo/bar/i").unwrap();
1076 let out = apply_substitute(&mut sensitive, &cmd, 0..=0).unwrap();
1077 assert_eq!(out.replacements, 1);
1078 assert_eq!(buf_line(&sensitive, 0), "Foo FOO bar");
1079 }
1080
1081 #[test]
1082 fn apply_empty_pattern_reuses_last_search() {
1083 let mut e = editor_with("hello world");
1084 e.set_last_search(Some("world".to_string()), true);
1085 let cmd = parse_substitute("//planet/").unwrap();
1086 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1087 assert_eq!(out.replacements, 1);
1088 assert_eq!(buf_line(&e, 0), "hello planet");
1089 }
1090
1091 #[test]
1092 fn apply_empty_pattern_no_last_search_errors() {
1093 let mut e = editor_with("hello");
1094 let cmd = parse_substitute("//bar/").unwrap();
1095 let err = apply_substitute(&mut e, &cmd, 0..=0).unwrap_err();
1096 assert!(
1097 err.to_string().contains("no previous regular expression"),
1098 "{err}"
1099 );
1100 }
1101
1102 #[test]
1103 fn apply_updates_last_search() {
1104 let mut e = editor_with("foo");
1105 let cmd = parse_substitute("/foo/bar/").unwrap();
1106 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1107 assert_eq!(e.last_search(), Some("foo".to_string()));
1108 }
1109
1110 #[test]
1111 fn apply_empty_replacement_deletes_match() {
1112 let mut e = editor_with("hello world");
1113 let cmd = parse_substitute("/world//").unwrap();
1114 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1115 assert_eq!(out.replacements, 1);
1116 assert_eq!(buf_line(&e, 0), "hello ");
1117 }
1118
1119 #[test]
1120 fn apply_undo_reverts_in_one_step() {
1121 let mut e = editor_with("foo");
1122 let cmd = parse_substitute("/foo/bar/").unwrap();
1123 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1124 assert_eq!(buf_line(&e, 0), "bar");
1125 e.undo();
1126 assert_eq!(buf_line(&e, 0), "foo");
1127 }
1128
1129 #[test]
1130 fn apply_ampersand_in_replacement() {
1131 let mut e = editor_with("foo");
1132 let cmd = parse_substitute("/foo/[&]/").unwrap();
1133 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1134 assert_eq!(buf_line(&e, 0), "[foo]");
1135 }
1136
1137 #[test]
1138 fn apply_capture_group_reference() {
1139 let mut e = editor_with("hello world");
1140 let cmd = parse_substitute("/\\(\\w\\+\\)/<<\\1>>/g").unwrap();
1142 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1143 assert_eq!(buf_line(&e, 0), "<<hello>> <<world>>");
1144 }
1145
1146 #[test]
1147 fn apply_backslash_r_splits_line() {
1148 let mut e = editor_with("a,b,c");
1151 let cmd = parse_substitute("/,/\\r/g").unwrap();
1152 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1153 assert_eq!(buf_line(&e, 0), "a");
1154 assert_eq!(buf_line(&e, 1), "b");
1155 assert_eq!(buf_line(&e, 2), "c");
1156 }
1157
1158 #[test]
1164 fn apply_backslash_r_multi_row_cursor_lands_on_final_split_row() {
1165 let mut e = editor_with("a,b\nc,d\n");
1166 let cmd = parse_substitute("/,/\\r/").unwrap();
1167 let total = crate::types::Query::rope(e.buffer()).len_lines();
1168 let out = apply_substitute(&mut e, &cmd, 0..=(total.saturating_sub(1)) as u32).unwrap();
1169 assert_eq!(buf_line(&e, 0), "a");
1170 assert_eq!(buf_line(&e, 1), "b");
1171 assert_eq!(buf_line(&e, 2), "c");
1172 assert_eq!(buf_line(&e, 3), "d");
1173 assert_eq!(
1174 out.last_row,
1175 Some(3),
1176 "cursor should land on the last changed line ('d', real row 3) \
1177 in post-split coordinates, not the pre-split row index"
1178 );
1179 assert_eq!(e.buffer().cursor().row, 3);
1180 }
1181
1182 #[test]
1186 fn apply_backslash_r_single_row_cursor_lands_on_last_split_line() {
1187 let mut e = editor_with("a,b");
1188 let cmd = parse_substitute("/,/\\r/").unwrap();
1189 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1190 assert_eq!(buf_line(&e, 0), "a");
1191 assert_eq!(buf_line(&e, 1), "b");
1192 assert_eq!(out.last_row, Some(1));
1193 assert_eq!(e.buffer().cursor().row, 1);
1194 }
1195
1196 #[test]
1199 fn apply_no_newline_multi_row_cursor_unaffected() {
1200 let mut e = editor_with("a\na\na");
1201 let cmd = parse_substitute("/a/X/").unwrap();
1202 let out = apply_substitute(&mut e, &cmd, 0..=2).unwrap();
1203 assert_eq!(buf_line(&e, 0), "X");
1204 assert_eq!(buf_line(&e, 1), "X");
1205 assert_eq!(buf_line(&e, 2), "X");
1206 assert_eq!(out.last_row, Some(2));
1207 assert_eq!(e.buffer().cursor().row, 2);
1208 }
1209
1210 #[test]
1216 fn apply_backslash_r_rebases_marks_below_change() {
1217 let mut e = editor_with("a\na\na\na\na\na\na\na\nX\nlast");
1219 e.set_mark('a', (8, 0));
1220 let cmd = parse_substitute("/a/b\\r/").unwrap();
1221 let out = apply_substitute(&mut e, &cmd, 0..=7).unwrap();
1222 assert_eq!(out.replacements, 8);
1223 assert_eq!(
1225 e.mark('a'),
1226 Some((16, 0)),
1227 "mark below the substitution must shift by the rows added above it"
1228 );
1229 assert_eq!(buf_line(&e, 16), "X", "the marked text now lives on row 16");
1230 }
1231
1232 #[test]
1235 fn apply_no_newline_substitute_leaves_marks_untouched() {
1236 let mut e = editor_with("a\na\na\na\na\na\na\na\nX\nlast");
1237 e.set_mark('a', (8, 0));
1238 let cmd = parse_substitute("/a/b/").unwrap();
1239 let out = apply_substitute(&mut e, &cmd, 0..=7).unwrap();
1240 assert_eq!(out.replacements, 8);
1241 assert_eq!(e.mark('a'), Some((8, 0)), "delta 0 must not move the mark");
1242 }
1243
1244 #[test]
1247 fn apply_collected_matches_backslash_r_rebases_marks_below_change() {
1248 let mut e = editor_with("a\na\na\na\na\na\na\na\nX\nlast");
1249 e.set_mark('a', (8, 0));
1250 let cmd = parse_substitute("/a/b\\r/").unwrap();
1251 let matches = collect_substitute_matches(&e, &cmd, 0..=7).unwrap();
1252 assert_eq!(matches.len(), 8);
1253 let accepted = vec![true; matches.len()];
1254 let applied = apply_collected_matches(&mut e, &matches, &accepted);
1255 assert_eq!(applied, 8);
1256 assert_eq!(
1257 e.mark('a'),
1258 Some((16, 0)),
1259 "confirm-path mark must shift by the rows added above it"
1260 );
1261 assert_eq!(buf_line(&e, 16), "X");
1262 }
1263
1264 #[test]
1273 fn substitute_emits_change_log_and_reset() {
1274 let mut e = editor_with("foo\nbar\nbaz");
1275 let _ = e.take_changes();
1276 let _ = e.take_content_reset();
1277 let _ = e.take_content_edits();
1278 let cmd = parse_substitute("/foo/qux/").unwrap();
1279 apply_substitute(&mut e, &cmd, 0..=2).unwrap();
1280 let changes = e.take_changes();
1281 assert_eq!(changes.len(), 1, "one coarse whole-buffer Replace");
1282 assert_eq!(changes[0].range.start, crate::types::Pos::new(0, 0));
1283 assert_eq!(
1284 changes[0].replacement,
1285 e.buffer().rope().to_string(),
1286 "replacement is the full post-state content"
1287 );
1288 assert!(e.take_content_reset(), "syntax hosts must reparse");
1289 assert!(e.take_content_edits().is_empty());
1290 assert!(e.take_changes().is_empty(), "take_changes drains");
1291 }
1292
1293 #[test]
1294 fn substitute_with_newline_emits_change_log_and_reset() {
1295 let mut e = editor_with("a,b\nc,d");
1296 let _ = e.take_changes();
1297 let _ = e.take_content_reset();
1298 let _ = e.take_content_edits();
1299 let cmd = parse_substitute("/,/\\r/").unwrap();
1300 apply_substitute(&mut e, &cmd, 0..=1).unwrap();
1301 assert_eq!(e.buffer().rope().to_string(), "a\nb\nc\nd");
1303 let changes = e.take_changes();
1304 assert_eq!(changes.len(), 1, "one coarse whole-buffer Replace");
1305 assert_eq!(changes[0].range.start, crate::types::Pos::new(0, 0));
1306 assert_eq!(
1307 changes[0].replacement,
1308 e.buffer().rope().to_string(),
1309 "replacement is the full post-state content"
1310 );
1311 assert!(e.take_content_reset(), "syntax hosts must reparse");
1312 assert!(e.take_content_edits().is_empty());
1313 assert!(e.take_changes().is_empty(), "take_changes drains");
1314 }
1315
1316 #[test]
1317 fn collected_substitute_emits_change_log_and_reset() {
1318 let mut e = editor_with("foo\nfoo\nbar");
1319 let _ = e.take_changes();
1320 let _ = e.take_content_reset();
1321 let _ = e.take_content_edits();
1322 let cmd = parse_substitute("/foo/qux/g").unwrap();
1323 let matches = collect_substitute_matches(&e, &cmd, 0..=2).unwrap();
1324 let accepted = vec![true; matches.len()];
1325 let applied = apply_collected_matches(&mut e, &matches, &accepted);
1326 assert_eq!(applied, 2);
1327 let changes = e.take_changes();
1328 assert_eq!(changes.len(), 1, "one coarse whole-buffer Replace");
1329 assert_eq!(changes[0].range.start, crate::types::Pos::new(0, 0));
1330 assert_eq!(
1331 changes[0].replacement,
1332 e.buffer().rope().to_string(),
1333 "replacement is the full post-state content"
1334 );
1335 assert!(e.take_content_reset(), "syntax hosts must reparse");
1336 assert!(e.take_content_edits().is_empty());
1337 assert!(e.take_changes().is_empty(), "take_changes drains");
1338 }
1339
1340 #[test]
1341 fn apply_backslash_t_inserts_tab() {
1342 let mut e = editor_with("a,b");
1343 let cmd = parse_substitute("/,/\\t/").unwrap();
1344 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1345 assert_eq!(buf_line(&e, 0), "a\tb");
1346 }
1347
1348 #[test]
1349 fn apply_literal_dollar_in_replacement() {
1350 let mut e = editor_with("x");
1353 let cmd = parse_substitute("/x/$5/").unwrap();
1354 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1355 assert_eq!(buf_line(&e, 0), "$5");
1356 }
1357
1358 #[test]
1359 fn apply_backslash_zero_is_whole_match() {
1360 let mut e = editor_with("foo");
1362 let cmd = parse_substitute("/foo/[\\0]/").unwrap();
1363 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1364 assert_eq!(buf_line(&e, 0), "[foo]");
1365 }
1366
1367 #[test]
1368 fn apply_group_ref_then_literal_digits() {
1369 let mut e = editor_with("ab");
1371 let cmd = parse_substitute("/\\(.\\)/\\11/g").unwrap();
1372 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1373 assert_eq!(buf_line(&e, 0), "a1b1");
1374 }
1375
1376 fn expand(raw: &str, pat: &str, text: &str, prev: &str) -> String {
1379 let re = Regex::new(pat).unwrap();
1380 let caps = re.captures(text).unwrap();
1381 expand_replacement(raw, &caps, prev)
1382 }
1383
1384 #[test]
1385 fn expand_case_upper_run_and_end() {
1386 assert_eq!(expand("\\U\\0\\Ex", "foo", "foo", ""), "FOOx");
1388 assert_eq!(expand("\\L&\\E", "FOO", "FOO", ""), "foo");
1389 }
1390
1391 #[test]
1392 fn expand_case_one_shot() {
1393 assert_eq!(expand("\\u\\0", "foo", "foo", ""), "Foo");
1395 assert_eq!(expand("\\l\\0", "FOO", "FOO", ""), "fOO");
1396 }
1397
1398 #[test]
1399 fn expand_case_applies_to_group() {
1400 assert_eq!(expand("\\U\\1-y\\E", "(f)oo", "foo", ""), "F-Y");
1402 }
1403
1404 #[test]
1408 fn expand_backslash_u_uppercases_first_char_of_group() {
1409 assert_eq!(expand("\\u\\1", "(\\w+)", "hello world", ""), "Hello");
1410 }
1411
1412 #[test]
1417 fn expand_one_shot_falls_back_to_active_span() {
1418 assert_eq!(expand("\\U\\l\\0", "hello", "hello", ""), "hELLO");
1419 assert_eq!(
1422 expand("\\l\\U\\1 \\2", "(\\w+) (\\w+)", "hello world", ""),
1423 "hELLO WORLD"
1424 );
1425 }
1426
1427 #[test]
1428 fn expand_literal_dollar_and_amp() {
1429 assert_eq!(expand("$\\0", "x", "x", ""), "$x");
1430 assert_eq!(expand("[&]", "foo", "foo", ""), "[foo]");
1431 assert_eq!(expand("\\&", "foo", "foo", ""), "&");
1432 }
1433
1434 #[test]
1435 fn expand_tilde_uses_previous_replacement() {
1436 assert_eq!(expand("~!", "x", "x", "PREV"), "PREV!");
1438 assert_eq!(expand("~", "(.)", "a", "[\\1]"), "[a]");
1439 assert_eq!(expand("\\~", "x", "x", "PREV"), "~");
1441 }
1442
1443 #[test]
1446 fn apply_report_only_counts_without_mutating() {
1447 let mut e = editor_with("foo foo foo");
1448 let cmd = parse_substitute("/foo/bar/gn").unwrap();
1449 assert!(cmd.flags.report_only);
1450 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1451 assert_eq!(out.replacements, 3);
1452 assert_eq!(buf_line(&e, 0), "foo foo foo");
1454 }
1455
1456 #[test]
1459 fn apply_upper_run() {
1460 let mut e = editor_with("hello world");
1461 let cmd = parse_substitute("/world/\\U&\\E/").unwrap();
1462 apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1463 assert_eq!(buf_line(&e, 0), "hello WORLD");
1464 }
1465
1466 #[test]
1471 fn substitute_respects_smartcase() {
1472 let mut e = editor_with("Foo");
1473 let cmd = parse_substitute("/foo/bar/").unwrap();
1475 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1476 assert_eq!(out.replacements, 1);
1477 assert_eq!(buf_line(&e, 0), "bar");
1478 }
1479
1480 #[test]
1483 fn substitute_i_flag_overrides_c() {
1484 let mut e = editor_with("foo");
1485 let cmd = parse_substitute("/Foo/bar/i").unwrap();
1487 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1488 assert_eq!(out.replacements, 1, "expected match on 'foo' with /i flag");
1489 assert_eq!(buf_line(&e, 0), "bar");
1490 }
1491
1492 #[test]
1495 fn substitute_lower_c_inline_overrides_smartcase() {
1496 let mut e = editor_with("FOO");
1497 let cmd = parse_substitute("/\\cFoo/bar/").unwrap();
1499 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1500 assert_eq!(out.replacements, 1);
1501 assert_eq!(buf_line(&e, 0), "bar");
1502 }
1503
1504 #[test]
1507 fn collect_inline_case_override_wins_over_flag() {
1508 let e = editor_with("Foo FOO foo");
1509 let cmd = parse_substitute("/\\cFOO/bar/I").unwrap();
1510 assert_eq!(
1511 collect_substitute_matches(&e, &cmd, 0..=0).unwrap().len(),
1512 1
1513 );
1514
1515 let cmd = parse_substitute("/\\Cfoo/bar/i").unwrap();
1516 assert_eq!(
1517 collect_substitute_matches(&e, &cmd, 0..=0).unwrap().len(),
1518 1
1519 );
1520 }
1521
1522 #[test]
1523 fn collect_substitute_matches_finds_all_occurrences() {
1524 let e = editor_with("foo bar foo");
1525 let cmd = parse_substitute("/foo/baz/g").unwrap();
1526 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1527 assert_eq!(matches.len(), 2, "expected 2 matches for /g flag");
1528 assert_eq!(matches[0].byte_start, 0);
1529 assert_eq!(matches[0].byte_end, 3);
1530 assert_eq!(matches[1].byte_start, 8);
1531 assert_eq!(matches[1].byte_end, 11);
1532 assert_eq!(matches[0].replacement, "baz");
1533 assert_eq!(matches[1].replacement, "baz");
1534 }
1535
1536 #[test]
1537 fn collect_substitute_matches_respects_g_flag() {
1538 let e = editor_with("foo foo foo");
1540 let cmd = parse_substitute("/foo/baz/").unwrap();
1541 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1542 assert_eq!(matches.len(), 1, "expected 1 match without /g");
1543 assert_eq!(matches[0].byte_start, 0);
1544 }
1545
1546 #[test]
1547 fn collect_substitute_matches_respects_range() {
1548 let e = editor_with("foo\nfoo\nfoo\nfoo\nfoo");
1549 let cmd = parse_substitute("/foo/bar/g").unwrap();
1550 let matches = collect_substitute_matches(&e, &cmd, 1..=2).unwrap();
1552 assert_eq!(matches.len(), 2);
1553 assert_eq!(matches[0].row, 1);
1554 assert_eq!(matches[1].row, 2);
1555 }
1556
1557 #[test]
1558 fn collect_substitute_matches_expands_template() {
1559 let e = editor_with("hello world");
1560 let cmd = parse_substitute("/\\(\\w\\+\\)/<<\\1>>/g").unwrap();
1562 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1563 assert_eq!(matches.len(), 2);
1564 assert_eq!(matches[0].replacement, "<<hello>>");
1565 assert_eq!(matches[1].replacement, "<<world>>");
1566 }
1567
1568 #[test]
1571 fn apply_collected_matches_reverse_order_preserves_offsets() {
1572 let mut e = editor_with("foo bar baz");
1576 let cmd = parse_substitute("/\\(foo\\|bar\\|baz\\)/X/g").unwrap();
1577 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1578 assert_eq!(matches.len(), 3);
1579 let accepted = vec![true; 3];
1580 let applied = apply_collected_matches(&mut e, &matches, &accepted);
1581 assert_eq!(applied, 3);
1582 assert_eq!(buf_line(&e, 0), "X X X");
1583 }
1584
1585 #[test]
1586 fn apply_collected_matches_subset_only() {
1587 let mut e = editor_with("foo bar foo");
1589 let cmd = parse_substitute("/foo/ZZZ/g").unwrap();
1590 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1591 assert_eq!(matches.len(), 2, "expected 2 foo matches");
1592 let accepted = vec![true, false];
1594 let applied = apply_collected_matches(&mut e, &matches, &accepted);
1595 assert_eq!(applied, 1);
1596 assert_eq!(buf_line(&e, 0), "ZZZ bar foo");
1598 }
1599
1600 #[test]
1601 fn apply_collected_matches_zero_accepted() {
1602 let mut e = editor_with("foo bar foo");
1603 let cmd = parse_substitute("/foo/ZZZ/g").unwrap();
1604 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1605 let accepted = vec![false; matches.len()];
1606 let applied = apply_collected_matches(&mut e, &matches, &accepted);
1607 assert_eq!(applied, 0);
1608 assert_eq!(buf_line(&e, 0), "foo bar foo");
1609 }
1610
1611 #[test]
1612 fn apply_collected_matches_expands_template() {
1613 let mut e = editor_with("hello world");
1614 let cmd = parse_substitute("/\\(\\w\\+\\)/<<\\1>>/g").unwrap();
1615 let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
1616 let accepted = vec![true; matches.len()];
1617 let applied = apply_collected_matches(&mut e, &matches, &accepted);
1618 assert_eq!(applied, 2);
1619 assert_eq!(buf_line(&e, 0), "<<hello>> <<world>>");
1620 }
1621
1622 #[test]
1629 fn pattern_tilde_expands_to_last_substitute() {
1630 let mut e = editor_with("foo");
1631 let first = parse_substitute("/foo/BAR/").unwrap();
1632 apply_substitute(&mut e, &first, 0..=0).unwrap();
1633 assert_eq!(buf_line(&e, 0), "BAR");
1634 e.set_last_substitute(first); let second = parse_substitute("/~/baz/").unwrap();
1637 let out = apply_substitute(&mut e, &second, 0..=0).unwrap();
1638 assert_eq!(out.replacements, 1, "pattern `~` must match `BAR`");
1639 assert_eq!(buf_line(&e, 0), "baz");
1640 }
1641
1642 #[test]
1645 fn pattern_escaped_tilde_stays_literal() {
1646 let mut e = editor_with("a~b");
1647 e.set_last_substitute(parse_substitute("/x/BAR/").unwrap());
1649 let cmd = parse_substitute("/\\~/X/").unwrap();
1650 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1651 assert_eq!(out.replacements, 1, "`\\~` must match the literal tilde");
1652 assert_eq!(buf_line(&e, 0), "aXb");
1653 }
1654
1655 #[test]
1659 fn pattern_tilde_no_previous_substitute_expands_empty() {
1660 let mut e = editor_with("ab");
1661 assert!(e.last_substitute().is_none());
1662 let cmd = parse_substitute("/a~b/X/").unwrap();
1663 let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
1664 assert_eq!(out.replacements, 1, "`~`→empty so pattern is `ab`");
1665 assert_eq!(buf_line(&e, 0), "X");
1666 }
1667
1668 #[test]
1673 fn search_pattern_tilde_shares_expansion_path() {
1674 let mut e = editor_with("BAR");
1675 e.set_last_substitute(parse_substitute("/foo/BAR/").unwrap());
1676 e.push_search_pattern("~");
1677 let re = e
1678 .search_state()
1679 .pattern
1680 .as_ref()
1681 .expect("`/~` must compile to a pattern");
1682 assert!(re.is_match("BAR"), "search `~` must expand to `BAR`");
1683 assert!(
1684 !re.is_match("~"),
1685 "search `~` must not match a literal tilde"
1686 );
1687 }
1688
1689 #[test]
1697 fn apply_substitute_resets_sticky_col_to_the_landed_column() {
1698 let mut e = editor_with("abcdefgh\nab\nabcdefgh");
1699 e.jump_cursor(0, 7);
1700 assert_eq!(e.sticky_col(), Some(7), "seeded curswant");
1701 let cmd = parse_substitute("/ab/XX/").unwrap();
1702 assert_eq!(
1703 apply_substitute(&mut e, &cmd, 1..=1).unwrap().replacements,
1704 1
1705 );
1706 assert_eq!(e.cursor(), (1, 0), "cursor lands on the changed line");
1707 assert_eq!(e.sticky_col(), Some(0), "curswant follows the cursor");
1708 }
1709
1710 #[test]
1713 fn apply_collected_matches_resets_sticky_col_to_the_landed_column() {
1714 let mut e = editor_with("abcdefgh\nab\nabcdefgh");
1715 e.jump_cursor(0, 7);
1716 assert_eq!(e.sticky_col(), Some(7), "seeded curswant");
1717 let cmd = parse_substitute("/ab/XX/").unwrap();
1718 let matches = collect_substitute_matches(&e, &cmd, 1..=1).unwrap();
1719 assert_eq!(matches.len(), 1);
1720 let accepted: Vec<bool> = vec![true];
1721 assert_eq!(apply_collected_matches(&mut e, &matches, &accepted), 1);
1722 assert_eq!(e.cursor(), (1, 0));
1723 assert_eq!(e.sticky_col(), Some(0), "curswant follows the cursor");
1724 }
1725}