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 case_insensitive = if cmd.flags.case_sensitive {
false
} else if cmd.flags.ignore_case {
true
} else {
ed.settings().ignore_case
};
let effective_pattern = if case_insensitive {
format!("(?i){pattern_str}")
} else {
pattern_str.clone()
};
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 total = ed.buffer().lines().len();
let clamp_end = end.min(total.saturating_sub(1));
let mut new_lines: Vec<String> = ed.buffer().lines().to_vec();
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,
})
}
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
}
#[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!(e.buffer().lines()[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!(e.buffer().lines()[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!(e.buffer().lines()[0], "xyz");
assert_eq!(e.buffer().lines()[1], "xyz xyz");
assert_eq!(e.buffer().lines()[2], "bar");
}
#[test]
fn apply_no_match_returns_zero() {
let mut e = editor_with("hello");
let original = e.buffer().lines()[0].to_string();
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!(e.buffer().lines()[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!(e.buffer().lines()[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!(e.buffer().lines()[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!(e.buffer().lines()[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!(e.buffer().lines()[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!(e.buffer().lines()[0], "bar");
e.undo();
assert_eq!(e.buffer().lines()[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!(e.buffer().lines()[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!(e.buffer().lines()[0], "<<hello>> <<world>>");
}
}