use fancy_regex::{Regex, RegexBuilder};
use crate::document::EditDelta;
use crate::editor::vim_ops::vim_regex::{expand_replacement, translate_pattern};
use crate::editor::EditorState;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExCommand {
Write,
WriteCopy { path: String, force: bool },
WriteAs { path: String, force: bool },
SaveAsPrompt,
Quit,
WriteQuit,
WriteQuitCopy { path: String, force: bool },
WriteQuitIfModified,
Substitute(Substitution),
GoToLine(u32),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SubstituteRange {
CurrentLine,
AllLines,
VisualRange,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Substitution {
pub range: SubstituteRange,
pub pattern: String,
pub replacement: String,
pub replacement_present: bool,
pub global: bool,
pub ignore_case: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ExError {
#[error("Not an editor command: {0}")]
UnknownCommand(String),
#[error("Unknown flag: {0}")]
UnknownFlag(char),
#[error("Empty search pattern")]
EmptyPattern,
#[error("Unsupported vim pattern: {0}")]
UnsupportedPattern(String),
#[error("Invalid pattern: {0}")]
InvalidRegex(String),
}
pub fn parse_ex(input: &str) -> Result<ExCommand, ExError> {
let s = input.trim();
let (visual_range, s) = match s.strip_prefix("'<,'>") {
Some(rest) => (true, rest.trim_start()),
None => (false, s),
};
if let Some(rest) = s.strip_prefix("%s") {
return parse_substitute(SubstituteRange::AllLines, rest);
}
if let Some(rest) = s.strip_prefix('s') {
if rest.starts_with('/') {
let range = if visual_range {
SubstituteRange::VisualRange
} else {
SubstituteRange::CurrentLine
};
return parse_substitute(range, rest);
}
}
if let Some(cmd) = parse_write_forms(s) {
return Ok(cmd);
}
if !visual_range {
if s == "$" {
return Ok(ExCommand::GoToLine(u32::MAX));
}
if !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()) {
return Ok(ExCommand::GoToLine(s.parse().unwrap_or(u32::MAX)));
}
}
match s {
"q" | "quit" => Ok(ExCommand::Quit),
"x" | "xit" => Ok(ExCommand::WriteQuitIfModified),
_ if visual_range => Err(ExError::UnknownCommand(input.trim().to_owned())),
other => Err(ExError::UnknownCommand(other.to_owned())),
}
}
fn parse_write_forms(s: &str) -> Option<ExCommand> {
let (head, rest) = match s.find(char::is_whitespace) {
Some(i) => (&s[..i], s[i..].trim_start()),
None => (s, ""),
};
let force = head.ends_with('!');
let word = head.strip_suffix('!').unwrap_or(head);
let path = (!rest.is_empty()).then(|| rest.to_owned());
match (word, path) {
("saveas", Some(path)) => Some(ExCommand::WriteAs { path, force }),
("saveas", None) => Some(ExCommand::SaveAsPrompt),
("w" | "write", Some(path)) => Some(ExCommand::WriteCopy { path, force }),
("w" | "write", None) => Some(ExCommand::Write),
("wq", Some(path)) => Some(ExCommand::WriteQuitCopy { path, force }),
("wq", None) => Some(ExCommand::WriteQuit),
_ => None,
}
}
fn parse_substitute(range: SubstituteRange, rest: &str) -> Result<ExCommand, ExError> {
const DELIM: char = '/';
let Some(body) = rest.strip_prefix(DELIM) else {
let prefix = match range {
SubstituteRange::AllLines => "%s",
SubstituteRange::VisualRange => "'<,'>s",
SubstituteRange::CurrentLine => "s",
};
return Err(ExError::UnknownCommand(format!("{prefix}{rest}")));
};
let (pattern, after_pattern) = take_field(body, DELIM);
let replacement_present = after_pattern.is_some();
let (replacement, flags) = match after_pattern {
None => (String::new(), ""),
Some(after) => {
let (rep, after_rep) = take_field(after, DELIM);
(rep, after_rep.unwrap_or(""))
}
};
let mut global = false;
let mut ignore_case = false;
for c in flags.chars() {
match c {
'g' => global = true,
'i' => ignore_case = true,
c if c.is_whitespace() => {}
c => return Err(ExError::UnknownFlag(c)),
}
}
Ok(ExCommand::Substitute(Substitution {
range,
pattern,
replacement,
replacement_present,
global,
ignore_case,
}))
}
fn take_field(s: &str, delim: char) -> (String, Option<&str>) {
let mut out = String::new();
let mut escaped = false;
for (i, c) in s.char_indices() {
if escaped {
if c == delim {
out.push(delim);
} else {
out.push('\\');
out.push(c);
}
escaped = false;
} else if c == '\\' {
escaped = true;
} else if c == delim {
return (out, Some(&s[i + c.len_utf8()..]));
} else {
out.push(c);
}
}
if escaped {
out.push('\\');
}
(out, None)
}
pub fn execute_substitute(
editor: &mut EditorState,
sub: &Substitution,
visual_range: Option<(usize, usize)>,
) -> Result<usize, ExError> {
if sub.pattern.is_empty() {
return Err(ExError::EmptyPattern);
}
let translated = translate_pattern(&sub.pattern)?;
let re = RegexBuilder::new(&translated)
.case_insensitive(sub.ignore_case)
.multi_line(true)
.build()
.map_err(|e| ExError::InvalidRegex(e.to_string()))?;
let cursor_line = editor.buffer.char_to_line(editor.cursor.offset);
let Some(edit) = build_substitution(&editor.buffer, cursor_line, &re, sub, visual_range, None)?
else {
return Ok(0);
};
let count = edit.count;
let range_first = edit.range_first;
editor.apply_delta(edit.delta);
let target = editor
.buffer
.line_to_char(range_first.min(editor.buffer.line_count().saturating_sub(1)));
editor.place_cursor(target);
Ok(count)
}
pub(crate) fn resolve_substitute_lines(
buffer: &crate::document::Buffer,
cursor_line: usize,
range: SubstituteRange,
visual_range: Option<(usize, usize)>,
) -> Option<(usize, usize)> {
let line_count = buffer.line_count();
if line_count == 0 {
return None;
}
Some(match range {
SubstituteRange::AllLines => (0, line_count - 1),
SubstituteRange::CurrentLine => (cursor_line, cursor_line),
SubstituteRange::VisualRange => match visual_range {
Some((f, l)) => (f.min(line_count - 1), l.min(line_count - 1)),
None => (cursor_line, cursor_line),
},
})
}
pub(crate) struct SubstitutionEdit {
pub delta: EditDelta,
pub count: usize,
pub replaced_ranges: Vec<std::ops::Range<usize>>,
pub range_first: usize,
pub first_match_line: usize,
}
pub(crate) fn region_haystack(
buffer: &crate::document::Buffer,
first: usize,
last: usize,
) -> (String, usize, usize) {
let start_char = buffer.line_to_char(first);
let last_line = buffer.rope().line(last);
let end_char = buffer.line_to_char(last) + last_line.len_chars() - line_break_len(last_line);
let hay = buffer.rope().slice(start_char..end_char).to_string();
let start_byte = buffer.rope().char_to_byte(start_char);
(hay, start_char, start_byte)
}
fn line_break_len(line: ropey::RopeSlice) -> usize {
let n = line.len_chars();
if n == 0 {
return 0;
}
let last = line.char(n - 1);
usize::from(matches!(
last,
'\n' | '\r' | '\u{0B}' | '\u{0C}' | '\u{85}' | '\u{2028}' | '\u{2029}'
))
}
pub(crate) fn for_each_region_match<F>(
buffer: &crate::document::Buffer,
base_byte: usize,
hay: &str,
re: &Regex,
global: bool,
mut on_match: F,
) -> Result<bool, ExError>
where
F: FnMut(&fancy_regex::Captures<'_, str>, usize) -> std::ops::ControlFlow<()>,
{
if global {
for cap in re.captures_iter(hay) {
let caps = cap.map_err(|e| ExError::InvalidRegex(e.to_string()))?;
let start = caps.get(0).expect("group 0 is always present").start();
if on_match(&caps, buffer.byte_to_line(base_byte + start)).is_break() {
return Ok(false);
}
}
return Ok(true);
}
let mut pos = 0usize;
while pos <= hay.len() {
let Some(caps) = re
.captures_from_pos(hay, pos)
.map_err(|e| ExError::InvalidRegex(e.to_string()))?
else {
return Ok(true);
};
let whole = caps.get(0).expect("group 0 is always present");
let start_line = buffer.byte_to_line(base_byte + whole.start());
if on_match(&caps, start_line).is_break() {
return Ok(false);
}
let end_line = buffer.byte_to_line(base_byte + whole.end());
let covered = if whole.end() > whole.start()
&& base_byte + whole.end() == buffer.rope().line_to_byte(end_line)
{
end_line.saturating_sub(1).max(start_line)
} else {
end_line.max(start_line)
};
let next = covered + 1;
pos = if next >= buffer.line_count() {
hay.len() + 1
} else {
match buffer.rope().line_to_byte(next).checked_sub(base_byte) {
Some(rel) if rel <= hay.len() => rel,
_ => hay.len() + 1,
}
};
}
Ok(true)
}
pub(crate) fn build_substitution(
buffer: &crate::document::Buffer,
cursor_line: usize,
re: &Regex,
sub: &Substitution,
visual_range: Option<(usize, usize)>,
match_cap: Option<usize>,
) -> Result<Option<SubstitutionEdit>, ExError> {
let Some((first, last)) =
resolve_substitute_lines(buffer, cursor_line, sub.range, visual_range)
else {
return Ok(None);
};
let (hay, start_char, base_byte) = region_haystack(buffer, first, last);
let mut out = String::new();
let mut copied = 0usize;
let mut total = 0usize;
let mut replaced_ranges = Vec::new();
let mut first_match_line = None;
let completed =
for_each_region_match(buffer, base_byte, &hay, re, sub.global, |caps, line| {
let whole = caps.get(0).expect("group 0 is always present");
out.push_str(&hay[copied..whole.start()]);
let span_start = out.len();
out.push_str(&expand_replacement(&sub.replacement, caps));
replaced_ranges.push(base_byte + span_start..base_byte + out.len());
copied = whole.end();
total += 1;
first_match_line.get_or_insert(line);
if match_cap.is_some_and(|cap| total >= cap) {
std::ops::ControlFlow::Break(())
} else {
std::ops::ControlFlow::Continue(())
}
})?;
if total == 0 {
return Ok(None);
}
let removed = if completed {
out.push_str(&hay[copied..]);
hay
} else {
hay[..copied].to_owned()
};
Ok(Some(SubstitutionEdit {
delta: EditDelta {
offset: start_char,
removed,
inserted: out,
},
count: total,
replaced_ranges,
range_first: first,
first_match_line: first_match_line.unwrap_or(first),
}))
}
#[cfg(test)]
mod tests {
use super::*;
fn sub(
range: SubstituteRange,
pat: &str,
rep: Option<&str>,
global: bool,
ignore_case: bool,
) -> ExCommand {
ExCommand::Substitute(Substitution {
range,
pattern: pat.to_owned(),
replacement: rep.unwrap_or("").to_owned(),
replacement_present: rep.is_some(),
global,
ignore_case,
})
}
use SubstituteRange::{AllLines, CurrentLine, VisualRange};
#[test]
fn parses_write_quit_variants() {
assert_eq!(parse_ex("w"), Ok(ExCommand::Write));
assert_eq!(parse_ex("write"), Ok(ExCommand::Write));
assert_eq!(parse_ex("q"), Ok(ExCommand::Quit));
assert_eq!(parse_ex("wq"), Ok(ExCommand::WriteQuit));
assert_eq!(parse_ex("x"), Ok(ExCommand::WriteQuitIfModified));
assert_eq!(parse_ex("xit"), Ok(ExCommand::WriteQuitIfModified));
assert_eq!(parse_ex(" w "), Ok(ExCommand::Write));
}
#[test]
fn parses_write_as_forms() {
assert_eq!(
parse_ex("w notes.md"),
Ok(ExCommand::WriteCopy {
path: "notes.md".to_owned(),
force: false,
})
);
assert_eq!(
parse_ex("write notes.md"),
Ok(ExCommand::WriteCopy {
path: "notes.md".to_owned(),
force: false,
})
);
assert_eq!(
parse_ex("saveas notes.md"),
Ok(ExCommand::WriteAs {
path: "notes.md".to_owned(),
force: false,
})
);
assert_eq!(parse_ex("saveas"), Ok(ExCommand::SaveAsPrompt));
assert_eq!(
parse_ex("wq out.md"),
Ok(ExCommand::WriteQuitCopy {
path: "out.md".to_owned(),
force: false,
})
);
assert_eq!(parse_ex("w!"), Ok(ExCommand::Write));
assert_eq!(
parse_ex("w! notes.md"),
Ok(ExCommand::WriteCopy {
path: "notes.md".to_owned(),
force: true,
})
);
assert_eq!(
parse_ex("saveas! out.md"),
Ok(ExCommand::WriteAs {
path: "out.md".to_owned(),
force: true,
})
);
assert_eq!(
parse_ex("wq! out.md"),
Ok(ExCommand::WriteQuitCopy {
path: "out.md".to_owned(),
force: true,
})
);
assert_eq!(
parse_ex("w my file.md"),
Ok(ExCommand::WriteCopy {
path: "my file.md".to_owned(),
force: false,
})
);
}
#[test]
fn parses_a_bare_line_address() {
assert_eq!(parse_ex("42"), Ok(ExCommand::GoToLine(42)));
assert_eq!(parse_ex(" 1 "), Ok(ExCommand::GoToLine(1)));
assert_eq!(parse_ex("$"), Ok(ExCommand::GoToLine(u32::MAX)));
assert_eq!(
parse_ex("99999999999999"),
Ok(ExCommand::GoToLine(u32::MAX))
);
assert_eq!(
parse_ex("'<,'>42"),
Err(ExError::UnknownCommand("'<,'>42".to_owned()))
);
}
#[test]
fn unknown_command_errors() {
assert_eq!(
parse_ex("nope"),
Err(ExError::UnknownCommand("nope".to_owned()))
);
assert_eq!(parse_ex("s"), Err(ExError::UnknownCommand("s".to_owned())));
}
#[test]
fn parses_line_substitution() {
assert_eq!(
parse_ex("s/foo/bar/"),
Ok(sub(CurrentLine, "foo", Some("bar"), false, false))
);
assert_eq!(
parse_ex("s/foo/bar"),
Ok(sub(CurrentLine, "foo", Some("bar"), false, false))
);
assert_eq!(
parse_ex("s/foo"),
Ok(sub(CurrentLine, "foo", None, false, false))
);
}
#[test]
fn replacement_present_tracks_the_second_delimiter() {
assert_eq!(
parse_ex("s/foo/"),
Ok(sub(CurrentLine, "foo", Some(""), false, false))
);
assert_eq!(
parse_ex(r"s/foo\/bar"),
Ok(sub(CurrentLine, "foo/bar", None, false, false))
);
}
#[test]
fn parses_global_substitution_and_flags() {
assert_eq!(
parse_ex("%s/a/b/g"),
Ok(sub(AllLines, "a", Some("b"), true, false))
);
assert_eq!(
parse_ex("%s/a/b/gi"),
Ok(sub(AllLines, "a", Some("b"), true, true))
);
assert_eq!(
parse_ex("s/a/b/i"),
Ok(sub(CurrentLine, "a", Some("b"), false, true))
);
}
#[test]
fn parses_visual_range_substitution() {
assert_eq!(
parse_ex("'<,'>s/foo/bar/g"),
Ok(sub(VisualRange, "foo", Some("bar"), true, false))
);
assert_eq!(
parse_ex("'<,'>%s/a/b/"),
Ok(sub(AllLines, "a", Some("b"), false, false))
);
assert_eq!(parse_ex("'<,'>w"), Ok(ExCommand::Write));
assert_eq!(parse_ex("'<,'>wq"), Ok(ExCommand::WriteQuit));
assert_eq!(parse_ex("'<,'>q"), Ok(ExCommand::Quit));
assert_eq!(parse_ex("'<,'>x"), Ok(ExCommand::WriteQuitIfModified));
assert_eq!(
parse_ex("'<,'>nope"),
Err(ExError::UnknownCommand("'<,'>nope".to_owned()))
);
}
#[test]
fn unknown_flag_errors() {
assert_eq!(parse_ex("s/a/b/z"), Err(ExError::UnknownFlag('z')));
}
#[test]
fn escaped_delimiter_is_literal() {
assert_eq!(
parse_ex(r"s/a\/b/c\.d/"),
Ok(sub(CurrentLine, "a/b", Some(r"c\.d"), false, false))
);
}
use crate::document::Buffer;
fn re(pattern: &str) -> Regex {
RegexBuilder::new(&translate_pattern(pattern).expect("translatable"))
.multi_line(true)
.build()
.expect("compiles")
}
fn substitution(cmd: ExCommand) -> Substitution {
match cmd {
ExCommand::Substitute(s) => s,
other => panic!("not a substitution: {other:?}"),
}
}
#[test]
fn region_haystack_excludes_the_last_lines_own_newline() {
let b = Buffer::from_str("a\nb\nc\n");
assert_eq!(region_haystack(&b, 0, 1).0, "a\nb");
assert_eq!(region_haystack(&b, 0, b.line_count() - 1).0, "a\nb\nc\n");
let b2 = Buffer::from_str("a\nb");
assert_eq!(region_haystack(&b2, 0, b2.line_count() - 1).0, "a\nb");
let (hay, start_char, start_byte) = region_haystack(&b, 1, 1);
assert_eq!((hay.as_str(), start_char, start_byte), ("b", 2, 2));
}
#[test]
fn non_global_skips_lines_consumed_by_a_multiline_match() {
let b = Buffer::from_str("a\nb\nc\nd\ne");
let s = substitution(sub(AllLines, r".\n.", Some("X"), false, false));
let edit = build_substitution(&b, 0, &re(&s.pattern), &s, None, None)
.unwrap()
.expect("matched");
assert_eq!(edit.count, 2);
assert_eq!(edit.delta.inserted, "X\nX\ne");
}
#[test]
fn a_match_ending_at_a_line_start_leaves_that_line_eligible() {
let b = Buffer::from_str("a\nb\nc\nd");
let s = substitution(sub(AllLines, r"\n", Some("-"), false, false));
let edit = build_substitution(&b, 0, &re(&s.pattern), &s, None, None)
.unwrap()
.expect("matched");
assert_eq!(edit.count, 3, "one per line, none skipped");
assert_eq!(edit.delta.inserted, "a-b-c-d");
}
#[test]
fn match_cap_truncates_at_a_match_boundary() {
let b = Buffer::from_str("a\na\na\na\na\n");
let s = substitution(sub(AllLines, r"a\n", Some("b"), true, false));
let edit = build_substitution(&b, 0, &re(&s.pattern), &s, None, Some(2))
.unwrap()
.expect("matched");
assert_eq!(edit.count, 2);
assert_eq!(edit.delta.removed, "a\na\n");
assert_eq!(edit.delta.inserted, "bb");
assert!(b.contents().starts_with(&edit.delta.removed));
}
#[test]
fn a_match_cannot_escape_the_resolved_range() {
let b = Buffer::from_str("a\nb\nc\nd");
let s = substitution(sub(VisualRange, r"\n", Some("-"), true, false));
let edit = build_substitution(&b, 0, &re(&s.pattern), &s, Some((0, 1)), None)
.unwrap()
.expect("matched");
assert_eq!(edit.count, 1);
assert_eq!(edit.delta.removed, "a\nb");
assert_eq!(edit.delta.inserted, "a-b");
}
#[test]
fn replaced_ranges_are_absolute_post_apply_byte_ranges() {
let b = Buffer::from_str("foo\nfoo\n");
let s = substitution(sub(AllLines, "foo", Some("XY"), true, false));
let edit = build_substitution(&b, 0, &re(&s.pattern), &s, None, None)
.unwrap()
.expect("matched");
assert_eq!(edit.delta.inserted, "XY\nXY\n");
assert_eq!(edit.replaced_ranges, vec![0..2, 3..5]);
}
}