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,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct SubstFlags {
pub all: bool,
pub ignore_case: bool,
pub case_sensitive: bool,
pub confirm: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct SubstituteOutcome {
pub replacements: usize,
pub lines_changed: 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 = translate_replacement(raw_replacement);
let mut flags = SubstFlags::default();
for ch in raw_flags.chars() {
match ch {
'g' => flags.all = true,
'i' => flags.ignore_case = true,
'I' => flags.case_sensitive = true,
'c' => flags.confirm = true, other => return Err(format!("unknown flag '{other}' in substitute")),
}
}
Ok(SubstituteCmd {
pattern,
replacement,
flags,
})
}
pub fn apply_substitute<H: crate::types::Host>(
ed: &mut Editor<hjkl_buffer::Buffer, 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}"))?;
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::vim::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, 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,
});
}
ed.buffer_mut().replace_all(&new_lines.join("\n"));
ed.buffer_mut()
.set_cursor(hjkl_buffer::Position::new(last_changed_row, 0));
ed.mark_content_dirty();
ed.set_last_search(Some(pattern_str), true);
Ok(SubstituteOutcome {
replacements,
lines_changed,
})
}
#[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::Buffer, 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 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();
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) {
let replacement = regex
.captures(m.as_str())
.map(|caps| {
let mut rep = String::new();
caps.expand(&cmd.replacement, &mut rep);
rep
})
.unwrap_or_else(|| cmd.replacement.clone());
matches.push(SubstituteMatch {
row: row as u32,
byte_start: m.start() as u32,
byte_end: m.end() as u32,
replacement,
});
}
} else {
if let Some(m) = regex.find(line) {
let replacement = regex
.captures(m.as_str())
.map(|caps| {
let mut rep = String::new();
caps.expand(&cmd.replacement, &mut rep);
rep
})
.unwrap_or_else(|| cmd.replacement.clone());
matches.push(SubstituteMatch {
row: row as u32,
byte_start: m.start() as u32,
byte_end: m.end() as u32,
replacement,
});
}
}
}
}
Ok(matches)
}
pub fn apply_collected_matches<H: crate::types::Host>(
ed: &mut crate::Editor<hjkl_buffer::Buffer, 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::vim::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;
}
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
}
fn translate_replacement(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 4);
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '&' {
out.push_str("$0");
} else if c == '\\' {
match chars.next() {
Some('&') => out.push('&'), Some('\\') => out.push('\\'), Some(d @ '1'..='9') => {
out.push('$');
out.push(d);
}
Some(other) => out.push(other), None => {} }
} else {
out.push(c);
}
}
out
}
fn do_replace(regex: &Regex, text: &str, replacement: &str, all: bool) -> (String, usize) {
let matches = regex.find_iter(text).count();
if matches == 0 {
return (text.to_string(), 0);
}
let replaced = if all {
regex.replace_all(text, replacement).into_owned()
} else {
regex.replace(text, replacement).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::Buffer;
fn editor_with(content: &str) -> Editor<Buffer, DefaultHost> {
let mut e = Editor::new(Buffer::new(), DefaultHost::new(), Options::default());
e.set_content(content);
e
}
fn buf_line(e: &Editor<Buffer, 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_ampersand_becomes_dollar_zero() {
let cmd = parse_substitute("/foo/[&]/").unwrap();
assert_eq!(cmd.replacement, "[$0]");
}
#[test]
fn parse_escaped_ampersand_is_literal() {
let cmd = parse_substitute("/foo/\\&/").unwrap();
assert_eq!(cmd.replacement, "&");
}
#[test]
fn parse_group_ref_translates() {
let cmd = parse_substitute("/(foo)/\\1/").unwrap();
assert_eq!(cmd.replacement, "$1");
}
#[test]
fn parse_group_ref_nine() {
let cmd = parse_substitute("/(x)/\\9/").unwrap();
assert_eq!(cmd.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 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>>");
}
}