use regex::Regex;
use crate::Editor;
pub type SubstError = String;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubstituteCmd {
pub pattern: Option<String>,
pub replacement: String,
pub flags: SubstFlags,
pub count: Option<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct SubstFlags {
pub all: bool,
pub ignore_case: bool,
pub case_sensitive: bool,
pub confirm: bool,
pub report_only: bool,
pub no_error: bool,
pub print: bool,
pub print_num: bool,
pub print_list: bool,
pub reuse_previous: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct SubstituteOutcome {
pub replacements: usize,
pub lines_changed: usize,
pub last_row: Option<usize>,
}
pub fn parse_substitute(s: &str) -> Result<SubstituteCmd, SubstError> {
let rest = s
.strip_prefix('/')
.ok_or_else(|| format!("substitute: expected '/' delimiter, got {s:?}"))?;
let parts = split_on_slash(rest);
if parts.len() < 2 {
return Err("substitute needs /pattern/replacement/".into());
}
let raw_pattern = &parts[0];
let raw_replacement = &parts[1];
let raw_flags = parts.get(2).map(String::as_str).unwrap_or("");
let pattern = if raw_pattern.is_empty() {
None
} else {
Some(raw_pattern.clone())
};
let replacement = raw_replacement.clone();
let mut flags = SubstFlags::default();
let mut count: Option<usize> = None;
let mut chars = raw_flags.chars().peekable();
while let Some(&ch) = chars.peek() {
match ch {
'g' => flags.all = true,
'i' => flags.ignore_case = true,
'I' => flags.case_sensitive = true,
'c' => flags.confirm = true,
'n' => flags.report_only = true,
'e' => flags.no_error = true,
'p' => flags.print = true,
'#' => {
flags.print = true;
flags.print_num = true;
}
'l' => {
flags.print = true;
flags.print_list = true;
}
'&' => flags.reuse_previous = true,
' ' | '\t' => {}
c if c.is_ascii_digit() => break, other => return Err(format!("unknown flag '{other}' in substitute")),
}
chars.next();
}
let rest: String = chars.collect();
let rest = rest.trim();
if !rest.is_empty() {
match rest.parse::<usize>() {
Ok(n) if n > 0 => count = Some(n),
_ => return Err(format!("trailing characters in substitute: {rest:?}")),
}
}
Ok(SubstituteCmd {
pattern,
replacement,
flags,
count,
})
}
pub fn apply_substitute<H: crate::types::Host>(
ed: &mut Editor<hjkl_buffer::View, H>,
cmd: &SubstituteCmd,
line_range: std::ops::RangeInclusive<u32>,
) -> Result<SubstituteOutcome, SubstError> {
let pattern_str: String = match &cmd.pattern {
Some(p) => p.clone(),
None => ed
.last_search()
.map(str::to_owned)
.ok_or_else(|| "no previous regular expression".to_string())?,
};
let effective_pattern = if cmd.flags.case_sensitive {
use crate::search::{CaseMode, resolve_case_mode};
let (stripped, _) = resolve_case_mode(&pattern_str, CaseMode::Sensitive);
stripped
} else if cmd.flags.ignore_case {
use crate::search::{CaseMode, resolve_case_mode};
let (stripped, _) = resolve_case_mode(&pattern_str, CaseMode::Sensitive);
format!("(?i){stripped}")
} else {
use crate::search::{CaseMode, resolve_case_mode};
let base = CaseMode::from_options(ed.settings().ignore_case, ed.settings().smartcase);
let (stripped, mode) = resolve_case_mode(&pattern_str, base);
if mode == CaseMode::Insensitive {
format!("(?i){stripped}")
} else {
stripped
}
};
let regex = Regex::new(&effective_pattern).map_err(|e| format!("bad pattern: {e}"))?;
let prev_replacement = ed
.last_substitute()
.map(|c| c.replacement.clone())
.unwrap_or_default();
ed.push_undo();
let start = *line_range.start() as usize;
let end = *line_range.end() as usize;
let rope = crate::types::Query::rope(ed.buffer());
let total = rope.len_lines();
let clamp_end = end.min(total.saturating_sub(1));
let mut new_lines: Vec<String> = crate::rope_util::rope_to_lines_vec(&rope);
let mut replacements = 0usize;
let mut lines_changed = 0usize;
let mut last_changed_row = 0usize;
if start <= clamp_end {
for (row, line) in new_lines[start..=clamp_end].iter_mut().enumerate() {
let (replaced, n) = do_replace(
®ex,
line,
&cmd.replacement,
&prev_replacement,
cmd.flags.all,
);
if n > 0 {
*line = replaced;
replacements += n;
lines_changed += 1;
last_changed_row = start + row;
}
}
}
if replacements == 0 {
ed.pop_last_undo();
return Ok(SubstituteOutcome {
replacements: 0,
lines_changed: 0,
last_row: None,
});
}
if cmd.flags.report_only {
ed.pop_last_undo();
ed.set_last_search(Some(pattern_str), true);
return Ok(SubstituteOutcome {
replacements,
lines_changed,
last_row: None,
});
}
ed.buffer_mut().replace_all(&new_lines.join("\n"));
let final_total = crate::types::Query::rope(ed.buffer()).len_lines();
let cursor_row = last_changed_row.min(final_total.saturating_sub(1));
let first_non_blank = crate::buf_helpers::buf_line(ed.buffer(), cursor_row)
.unwrap_or_default()
.chars()
.take_while(|c| *c == ' ' || *c == '\t')
.count();
let line_len = crate::buf_helpers::buf_line(ed.buffer(), cursor_row)
.unwrap_or_default()
.chars()
.count();
let cursor_col = first_non_blank.min(line_len.saturating_sub(1));
ed.buffer_mut()
.set_cursor(hjkl_buffer::Position::new(cursor_row, cursor_col));
ed.mark_content_dirty();
ed.set_last_search(Some(pattern_str), true);
Ok(SubstituteOutcome {
replacements,
lines_changed,
last_row: Some(cursor_row),
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubstituteMatch {
pub row: u32,
pub byte_start: u32,
pub byte_end: u32,
pub replacement: String,
}
pub fn collect_substitute_matches<H: crate::types::Host>(
ed: &crate::Editor<hjkl_buffer::View, H>,
cmd: &SubstituteCmd,
line_range: std::ops::RangeInclusive<u32>,
) -> Result<Vec<SubstituteMatch>, SubstError> {
let pattern_str: String = match &cmd.pattern {
Some(p) => p.clone(),
None => ed
.last_search()
.map(str::to_owned)
.ok_or_else(|| "no previous regular expression".to_string())?,
};
let effective_pattern = if cmd.flags.case_sensitive {
use crate::search::{CaseMode, resolve_case_mode};
let (stripped, _) = resolve_case_mode(&pattern_str, CaseMode::Sensitive);
stripped
} else if cmd.flags.ignore_case {
use crate::search::{CaseMode, resolve_case_mode};
let (stripped, _) = resolve_case_mode(&pattern_str, CaseMode::Sensitive);
format!("(?i){stripped}")
} else {
use crate::search::{CaseMode, resolve_case_mode};
let base = CaseMode::from_options(ed.settings().ignore_case, ed.settings().smartcase);
let (stripped, mode) = resolve_case_mode(&pattern_str, base);
if mode == CaseMode::Insensitive {
format!("(?i){stripped}")
} else {
stripped
}
};
let regex = Regex::new(&effective_pattern).map_err(|e| format!("bad pattern: {e}"))?;
let prev_replacement = ed
.last_substitute()
.map(|c| c.replacement.clone())
.unwrap_or_default();
let start = *line_range.start() as usize;
let end = *line_range.end() as usize;
let rope = crate::types::Query::rope(ed.buffer());
let total = rope.len_lines();
let clamp_end = end.min(total.saturating_sub(1));
let mut matches: Vec<SubstituteMatch> = Vec::new();
let expand = |line: &str, start: usize| {
regex
.captures_at(line, start)
.map(|caps| expand_replacement(&cmd.replacement, &caps, &prev_replacement))
.unwrap_or_default()
};
if start <= clamp_end {
for row in start..=clamp_end {
let line = hjkl_buffer::rope_line_str(&rope, row);
let line = line.trim_end_matches('\n');
if cmd.flags.all {
for m in regex.find_iter(line) {
matches.push(SubstituteMatch {
row: row as u32,
byte_start: m.start() as u32,
byte_end: m.end() as u32,
replacement: expand(line, m.start()),
});
}
} else if let Some(m) = regex.find(line) {
matches.push(SubstituteMatch {
row: row as u32,
byte_start: m.start() as u32,
byte_end: m.end() as u32,
replacement: expand(line, m.start()),
});
}
}
}
Ok(matches)
}
pub fn apply_collected_matches<H: crate::types::Host>(
ed: &mut crate::Editor<hjkl_buffer::View, H>,
matches: &[SubstituteMatch],
accepted: &[bool],
) -> usize {
assert_eq!(
matches.len(),
accepted.len(),
"apply_collected_matches: accepted.len() must equal matches.len()"
);
let mut to_apply: Vec<&SubstituteMatch> = matches
.iter()
.zip(accepted.iter())
.filter_map(|(m, &ok)| if ok { Some(m) } else { None })
.collect();
if to_apply.is_empty() {
return 0;
}
to_apply.sort_unstable_by(|a, b| b.row.cmp(&a.row).then(b.byte_start.cmp(&a.byte_start)));
let rope = crate::types::Query::rope(ed.buffer());
let mut lines_vec: Vec<String> = crate::rope_util::rope_to_lines_vec(&rope);
let mut applied = 0usize;
let mut last_changed_row: Option<usize> = None;
for sm in &to_apply {
let row = sm.row as usize;
if row >= lines_vec.len() {
continue;
}
let line = &lines_vec[row];
let bs = sm.byte_start as usize;
let be = sm.byte_end as usize;
if be > line.len() || bs > be {
continue;
}
if !line.is_char_boundary(bs) || !line.is_char_boundary(be) {
continue;
}
let mut new_line = String::with_capacity(line.len() + sm.replacement.len());
new_line.push_str(&line[..bs]);
new_line.push_str(&sm.replacement);
new_line.push_str(&line[be..]);
lines_vec[row] = new_line;
applied += 1;
last_changed_row = Some(row);
}
if applied > 0 {
ed.buffer_mut().replace_all(&lines_vec.join("\n"));
if let Some(row) = last_changed_row {
ed.buffer_mut()
.set_cursor(hjkl_buffer::Position::new(row, 0));
}
ed.mark_content_dirty();
}
applied
}
fn split_on_slash(s: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut cur = String::new();
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.peek() {
Some(&'/') => {
cur.push('/');
chars.next();
}
Some(_) => {
let next = chars.next().unwrap();
cur.push('\\');
cur.push(next);
}
None => cur.push('\\'),
}
} else if c == '/' {
if out.len() < 2 {
out.push(std::mem::take(&mut cur));
} else {
cur.push(c);
}
} else {
cur.push(c);
}
}
out.push(cur);
out
}
#[derive(Clone, Copy, PartialEq)]
enum CaseState {
None,
OneUpper,
OneLower,
AllUpper,
AllLower,
}
fn push_cased(out: &mut String, case: &mut CaseState, ch: char) {
match *case {
CaseState::None => out.push(ch),
CaseState::OneUpper => {
out.extend(ch.to_uppercase());
*case = CaseState::None;
}
CaseState::OneLower => {
out.extend(ch.to_lowercase());
*case = CaseState::None;
}
CaseState::AllUpper => out.extend(ch.to_uppercase()),
CaseState::AllLower => out.extend(ch.to_lowercase()),
}
}
fn expand_replacement(raw: &str, caps: ®ex::Captures, prev: &str) -> String {
let mut out = String::with_capacity(raw.len() + 8);
expand_into(&mut out, raw, caps, prev, true);
out
}
fn expand_into(out: &mut String, raw: &str, caps: ®ex::Captures, prev: &str, allow_tilde: bool) {
let mut case = CaseState::None;
let mut chars = raw.chars();
while let Some(c) = chars.next() {
match c {
'&' => {
let g = caps.get(0).map(|m| m.as_str()).unwrap_or("");
for ch in g.chars() {
push_cased(out, &mut case, ch);
}
}
'~' if allow_tilde => {
let mut tmp = String::new();
expand_into(&mut tmp, prev, caps, "", false);
for ch in tmp.chars() {
push_cased(out, &mut case, ch);
}
}
'\\' => match chars.next() {
Some('&') => push_cased(out, &mut case, '&'),
Some('~') => push_cased(out, &mut case, '~'),
Some('\\') => push_cased(out, &mut case, '\\'),
Some('r') => out.push('\n'),
Some('t') => out.push('\t'),
Some('n') => out.push('\0'),
Some(d @ '0'..='9') => {
let idx = d as usize - '0' as usize;
let g = caps.get(idx).map(|m| m.as_str()).unwrap_or("");
for ch in g.chars() {
push_cased(out, &mut case, ch);
}
}
Some('u') => case = CaseState::OneUpper,
Some('l') => case = CaseState::OneLower,
Some('U') => case = CaseState::AllUpper,
Some('L') => case = CaseState::AllLower,
Some('e') | Some('E') => case = CaseState::None,
Some(other) => push_cased(out, &mut case, other),
None => {} },
_ => push_cased(out, &mut case, c),
}
}
}
fn do_replace(
regex: &Regex,
text: &str,
replacement: &str,
prev: &str,
all: bool,
) -> (String, usize) {
let matches = regex.find_iter(text).count();
if matches == 0 {
return (text.to_string(), 0);
}
let rep = |caps: ®ex::Captures| expand_replacement(replacement, caps, prev);
let replaced = if all {
regex.replace_all(text, rep).into_owned()
} else {
regex.replace(text, rep).into_owned()
};
let count = if all { matches } else { 1 };
(replaced, count)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{DefaultHost, Options};
use hjkl_buffer::View;
fn editor_with(content: &str) -> Editor<View, DefaultHost> {
let mut e = Editor::new(View::new(), DefaultHost::new(), Options::default());
e.set_content(content);
e
}
fn buf_line(e: &Editor<View, DefaultHost>, row: usize) -> String {
hjkl_buffer::rope_line_str(&e.buffer().rope(), row)
}
#[test]
fn parse_basic() {
let cmd = parse_substitute("/foo/bar/").unwrap();
assert_eq!(cmd.pattern.as_deref(), Some("foo"));
assert_eq!(cmd.replacement, "bar");
assert!(!cmd.flags.all);
}
#[test]
fn parse_trailing_slash_optional() {
let cmd = parse_substitute("/foo/bar").unwrap();
assert_eq!(cmd.pattern.as_deref(), Some("foo"));
assert_eq!(cmd.replacement, "bar");
}
#[test]
fn parse_global_flag() {
let cmd = parse_substitute("/x/y/g").unwrap();
assert!(cmd.flags.all);
}
#[test]
fn parse_ignore_case_flag() {
let cmd = parse_substitute("/x/y/i").unwrap();
assert!(cmd.flags.ignore_case);
}
#[test]
fn parse_case_sensitive_flag() {
let cmd = parse_substitute("/x/y/I").unwrap();
assert!(cmd.flags.case_sensitive);
}
#[test]
fn parse_confirm_flag_accepted() {
let cmd = parse_substitute("/x/y/c").unwrap();
assert!(cmd.flags.confirm);
}
#[test]
fn parse_multi_flags() {
let cmd = parse_substitute("/x/y/gi").unwrap();
assert!(cmd.flags.all);
assert!(cmd.flags.ignore_case);
}
#[test]
fn parse_unknown_flag_errors() {
let err = parse_substitute("/x/y/z").unwrap_err();
assert!(err.to_string().contains("unknown flag 'z'"), "{err}");
}
#[test]
fn parse_empty_pattern_is_none() {
let cmd = parse_substitute("//bar/").unwrap();
assert!(cmd.pattern.is_none());
assert_eq!(cmd.replacement, "bar");
}
#[test]
fn parse_empty_replacement_ok() {
let cmd = parse_substitute("/foo//").unwrap();
assert_eq!(cmd.pattern.as_deref(), Some("foo"));
assert_eq!(cmd.replacement, "");
}
#[test]
fn parse_escaped_slash_in_pattern() {
let cmd = parse_substitute("/a\\/b/c/").unwrap();
assert_eq!(cmd.pattern.as_deref(), Some("a/b"));
}
#[test]
fn parse_escaped_slash_in_replacement() {
let cmd = parse_substitute("/a/b\\/c/").unwrap();
assert_eq!(cmd.replacement, "b/c");
}
#[test]
fn parse_keeps_replacement_raw() {
assert_eq!(parse_substitute("/foo/[&]/").unwrap().replacement, "[&]");
assert_eq!(parse_substitute("/foo/\\&/").unwrap().replacement, "\\&");
assert_eq!(parse_substitute("/(foo)/\\1/").unwrap().replacement, "\\1");
assert_eq!(parse_substitute("/(x)/\\9/").unwrap().replacement, "\\9");
}
#[test]
fn parse_wrong_delimiter_errors() {
let err = parse_substitute("|foo|bar|").unwrap_err();
assert!(err.to_string().contains("'/'"), "{err}");
}
#[test]
fn parse_too_few_fields_errors() {
let err = parse_substitute("/foo").unwrap_err();
assert!(
err.to_string().contains("needs /pattern/replacement"),
"{err}"
);
}
#[test]
fn apply_single_line_first_only() {
let mut e = editor_with("foo foo");
let cmd = parse_substitute("/foo/bar/").unwrap();
let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
assert_eq!(out.replacements, 1);
assert_eq!(out.lines_changed, 1);
assert_eq!(buf_line(&e, 0), "bar foo");
}
#[test]
fn apply_single_line_global() {
let mut e = editor_with("foo foo foo");
let cmd = parse_substitute("/foo/bar/g").unwrap();
let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
assert_eq!(out.replacements, 3);
assert_eq!(out.lines_changed, 1);
assert_eq!(buf_line(&e, 0), "bar bar bar");
}
#[test]
fn apply_multi_line_range() {
let mut e = editor_with("foo\nfoo foo\nbar");
let cmd = parse_substitute("/foo/xyz/g").unwrap();
let out = apply_substitute(&mut e, &cmd, 0..=2).unwrap();
assert_eq!(out.replacements, 3);
assert_eq!(out.lines_changed, 2);
assert_eq!(buf_line(&e, 0), "xyz");
assert_eq!(buf_line(&e, 1), "xyz xyz");
assert_eq!(buf_line(&e, 2), "bar");
}
#[test]
fn apply_no_match_returns_zero() {
let mut e = editor_with("hello");
let original = buf_line(&e, 0);
let cmd = parse_substitute("/xyz/abc/").unwrap();
let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
assert_eq!(out.replacements, 0);
assert_eq!(out.lines_changed, 0);
assert_eq!(buf_line(&e, 0), original);
}
#[test]
fn apply_case_insensitive_flag() {
let mut e = editor_with("Foo FOO foo");
let cmd = parse_substitute("/foo/bar/gi").unwrap();
let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
assert_eq!(out.replacements, 3);
assert_eq!(buf_line(&e, 0), "bar bar bar");
}
#[test]
fn apply_case_sensitive_flag_overrides_editor_setting() {
let mut e = editor_with("Foo foo");
e.settings_mut().ignore_case = true;
let cmd = parse_substitute("/foo/bar/I").unwrap();
let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
assert_eq!(out.replacements, 1);
assert_eq!(buf_line(&e, 0), "Foo bar");
}
#[test]
fn apply_empty_pattern_reuses_last_search() {
let mut e = editor_with("hello world");
e.set_last_search(Some("world".to_string()), true);
let cmd = parse_substitute("//planet/").unwrap();
let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
assert_eq!(out.replacements, 1);
assert_eq!(buf_line(&e, 0), "hello planet");
}
#[test]
fn apply_empty_pattern_no_last_search_errors() {
let mut e = editor_with("hello");
let cmd = parse_substitute("//bar/").unwrap();
let err = apply_substitute(&mut e, &cmd, 0..=0).unwrap_err();
assert!(
err.to_string().contains("no previous regular expression"),
"{err}"
);
}
#[test]
fn apply_updates_last_search() {
let mut e = editor_with("foo");
let cmd = parse_substitute("/foo/bar/").unwrap();
apply_substitute(&mut e, &cmd, 0..=0).unwrap();
assert_eq!(e.last_search(), Some("foo"));
}
#[test]
fn apply_empty_replacement_deletes_match() {
let mut e = editor_with("hello world");
let cmd = parse_substitute("/world//").unwrap();
let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
assert_eq!(out.replacements, 1);
assert_eq!(buf_line(&e, 0), "hello ");
}
#[test]
fn apply_undo_reverts_in_one_step() {
let mut e = editor_with("foo");
let cmd = parse_substitute("/foo/bar/").unwrap();
apply_substitute(&mut e, &cmd, 0..=0).unwrap();
assert_eq!(buf_line(&e, 0), "bar");
e.undo();
assert_eq!(buf_line(&e, 0), "foo");
}
#[test]
fn apply_ampersand_in_replacement() {
let mut e = editor_with("foo");
let cmd = parse_substitute("/foo/[&]/").unwrap();
apply_substitute(&mut e, &cmd, 0..=0).unwrap();
assert_eq!(buf_line(&e, 0), "[foo]");
}
#[test]
fn apply_capture_group_reference() {
let mut e = editor_with("hello world");
let cmd = parse_substitute("/(\\w+)/<<\\1>>/g").unwrap();
apply_substitute(&mut e, &cmd, 0..=0).unwrap();
assert_eq!(buf_line(&e, 0), "<<hello>> <<world>>");
}
#[test]
fn apply_backslash_r_splits_line() {
let mut e = editor_with("a,b,c");
let cmd = parse_substitute("/,/\\r/g").unwrap();
apply_substitute(&mut e, &cmd, 0..=0).unwrap();
assert_eq!(buf_line(&e, 0), "a");
assert_eq!(buf_line(&e, 1), "b");
assert_eq!(buf_line(&e, 2), "c");
}
#[test]
fn apply_backslash_t_inserts_tab() {
let mut e = editor_with("a,b");
let cmd = parse_substitute("/,/\\t/").unwrap();
apply_substitute(&mut e, &cmd, 0..=0).unwrap();
assert_eq!(buf_line(&e, 0), "a\tb");
}
#[test]
fn apply_literal_dollar_in_replacement() {
let mut e = editor_with("x");
let cmd = parse_substitute("/x/$5/").unwrap();
apply_substitute(&mut e, &cmd, 0..=0).unwrap();
assert_eq!(buf_line(&e, 0), "$5");
}
#[test]
fn apply_backslash_zero_is_whole_match() {
let mut e = editor_with("foo");
let cmd = parse_substitute("/foo/[\\0]/").unwrap();
apply_substitute(&mut e, &cmd, 0..=0).unwrap();
assert_eq!(buf_line(&e, 0), "[foo]");
}
#[test]
fn apply_group_ref_then_literal_digits() {
let mut e = editor_with("ab");
let cmd = parse_substitute("/(.)/\\11/g").unwrap();
apply_substitute(&mut e, &cmd, 0..=0).unwrap();
assert_eq!(buf_line(&e, 0), "a1b1");
}
fn expand(raw: &str, pat: &str, text: &str, prev: &str) -> String {
let re = Regex::new(pat).unwrap();
let caps = re.captures(text).unwrap();
expand_replacement(raw, &caps, prev)
}
#[test]
fn expand_case_upper_run_and_end() {
assert_eq!(expand("\\U\\0\\Ex", "foo", "foo", ""), "FOOx");
assert_eq!(expand("\\L&\\E", "FOO", "FOO", ""), "foo");
}
#[test]
fn expand_case_one_shot() {
assert_eq!(expand("\\u\\0", "foo", "foo", ""), "Foo");
assert_eq!(expand("\\l\\0", "FOO", "FOO", ""), "fOO");
}
#[test]
fn expand_case_applies_to_group() {
assert_eq!(expand("\\U\\1-y\\E", "(f)oo", "foo", ""), "F-Y");
}
#[test]
fn expand_literal_dollar_and_amp() {
assert_eq!(expand("$\\0", "x", "x", ""), "$x");
assert_eq!(expand("[&]", "foo", "foo", ""), "[foo]");
assert_eq!(expand("\\&", "foo", "foo", ""), "&");
}
#[test]
fn expand_tilde_uses_previous_replacement() {
assert_eq!(expand("~!", "x", "x", "PREV"), "PREV!");
assert_eq!(expand("~", "(.)", "a", "[\\1]"), "[a]");
assert_eq!(expand("\\~", "x", "x", "PREV"), "~");
}
#[test]
fn apply_report_only_counts_without_mutating() {
let mut e = editor_with("foo foo foo");
let cmd = parse_substitute("/foo/bar/gn").unwrap();
assert!(cmd.flags.report_only);
let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
assert_eq!(out.replacements, 3);
assert_eq!(buf_line(&e, 0), "foo foo foo");
}
#[test]
fn apply_upper_run() {
let mut e = editor_with("hello world");
let cmd = parse_substitute("/world/\\U&\\E/").unwrap();
apply_substitute(&mut e, &cmd, 0..=0).unwrap();
assert_eq!(buf_line(&e, 0), "hello WORLD");
}
#[test]
fn substitute_respects_smartcase() {
let mut e = editor_with("Foo");
let cmd = parse_substitute("/foo/bar/").unwrap();
let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
assert_eq!(out.replacements, 1);
assert_eq!(buf_line(&e, 0), "bar");
}
#[test]
fn substitute_i_flag_overrides_c() {
let mut e = editor_with("foo");
let cmd = parse_substitute("/Foo/bar/i").unwrap();
let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
assert_eq!(out.replacements, 1, "expected match on 'foo' with /i flag");
assert_eq!(buf_line(&e, 0), "bar");
}
#[test]
fn substitute_lower_c_inline_overrides_smartcase() {
let mut e = editor_with("FOO");
let cmd = parse_substitute("/\\cFoo/bar/").unwrap();
let out = apply_substitute(&mut e, &cmd, 0..=0).unwrap();
assert_eq!(out.replacements, 1);
assert_eq!(buf_line(&e, 0), "bar");
}
#[test]
fn collect_substitute_matches_finds_all_occurrences() {
let e = editor_with("foo bar foo");
let cmd = parse_substitute("/foo/baz/g").unwrap();
let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
assert_eq!(matches.len(), 2, "expected 2 matches for /g flag");
assert_eq!(matches[0].byte_start, 0);
assert_eq!(matches[0].byte_end, 3);
assert_eq!(matches[1].byte_start, 8);
assert_eq!(matches[1].byte_end, 11);
assert_eq!(matches[0].replacement, "baz");
assert_eq!(matches[1].replacement, "baz");
}
#[test]
fn collect_substitute_matches_respects_g_flag() {
let e = editor_with("foo foo foo");
let cmd = parse_substitute("/foo/baz/").unwrap();
let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
assert_eq!(matches.len(), 1, "expected 1 match without /g");
assert_eq!(matches[0].byte_start, 0);
}
#[test]
fn collect_substitute_matches_respects_range() {
let e = editor_with("foo\nfoo\nfoo\nfoo\nfoo");
let cmd = parse_substitute("/foo/bar/g").unwrap();
let matches = collect_substitute_matches(&e, &cmd, 1..=2).unwrap();
assert_eq!(matches.len(), 2);
assert_eq!(matches[0].row, 1);
assert_eq!(matches[1].row, 2);
}
#[test]
fn collect_substitute_matches_expands_template() {
let e = editor_with("hello world");
let cmd = parse_substitute("/(\\w+)/<<\\1>>/g").unwrap();
let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
assert_eq!(matches.len(), 2);
assert_eq!(matches[0].replacement, "<<hello>>");
assert_eq!(matches[1].replacement, "<<world>>");
}
#[test]
fn apply_collected_matches_reverse_order_preserves_offsets() {
let mut e = editor_with("foo bar baz");
let cmd = parse_substitute("/(foo|bar|baz)/X/g").unwrap();
let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
assert_eq!(matches.len(), 3);
let accepted = vec![true; 3];
let applied = apply_collected_matches(&mut e, &matches, &accepted);
assert_eq!(applied, 3);
assert_eq!(buf_line(&e, 0), "X X X");
}
#[test]
fn apply_collected_matches_subset_only() {
let mut e = editor_with("foo bar foo");
let cmd = parse_substitute("/foo/ZZZ/g").unwrap();
let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
assert_eq!(matches.len(), 2, "expected 2 foo matches");
let accepted = vec![true, false];
let applied = apply_collected_matches(&mut e, &matches, &accepted);
assert_eq!(applied, 1);
assert_eq!(buf_line(&e, 0), "ZZZ bar foo");
}
#[test]
fn apply_collected_matches_zero_accepted() {
let mut e = editor_with("foo bar foo");
let cmd = parse_substitute("/foo/ZZZ/g").unwrap();
let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
let accepted = vec![false; matches.len()];
let applied = apply_collected_matches(&mut e, &matches, &accepted);
assert_eq!(applied, 0);
assert_eq!(buf_line(&e, 0), "foo bar foo");
}
#[test]
fn apply_collected_matches_expands_template() {
let mut e = editor_with("hello world");
let cmd = parse_substitute("/(\\w+)/<<\\1>>/g").unwrap();
let matches = collect_substitute_matches(&e, &cmd, 0..=0).unwrap();
let accepted = vec![true; matches.len()];
let applied = apply_collected_matches(&mut e, &matches, &accepted);
assert_eq!(applied, 2);
assert_eq!(buf_line(&e, 0), "<<hello>> <<world>>");
}
}