use {
regex::Regex,
reovim_driver_command::{
ArgKind, ArgSpec, Command, CommandContext, CommandHandler, CommandResult,
},
reovim_driver_session::{BufferApi, ChangeTracker, SessionRuntime},
reovim_kernel::api::v1::{CommandId, ModuleId, Position},
};
const COMMANDS_MODULE: ModuleId = ModuleId::new("commands");
#[derive(Debug, Clone)]
struct SubstituteSpec {
pattern: String,
replacement: String,
global: bool,
case_insensitive: bool,
count_only: bool,
}
#[cfg_attr(coverage_nightly, coverage(off))]
fn parse_substitute(input: &str) -> Option<SubstituteSpec> {
if input.is_empty() {
return None;
}
let mut chars = input.chars();
let delimiter = chars.next()?;
if delimiter.is_alphanumeric() || delimiter == '\\' {
return None;
}
let rest: String = chars.collect();
let parts = split_by_delimiter(&rest, delimiter);
if parts.len() < 2 {
return None;
}
let pattern = parts[0].clone();
let replacement = parts[1].clone();
if pattern.is_empty() {
return None;
}
let flags_str = parts.get(2).map_or("", String::as_str);
let mut global = false;
let mut case_insensitive = false;
let mut count_only = false;
for flag in flags_str.chars() {
match flag {
'g' => global = true,
'i' => case_insensitive = true,
'n' => count_only = true,
_ => {} }
}
Some(SubstituteSpec {
pattern,
replacement,
global,
case_insensitive,
count_only,
})
}
#[cfg_attr(coverage_nightly, coverage(off))]
fn split_by_delimiter(input: &str, delimiter: char) -> Vec<String> {
let mut parts = Vec::new();
let mut current = String::new();
let mut escaped = false;
for ch in input.chars() {
if escaped {
if ch != delimiter {
current.push('\\');
}
current.push(ch);
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == delimiter {
parts.push(std::mem::take(&mut current));
} else {
current.push(ch);
}
}
if escaped {
current.push('\\');
}
parts.push(current);
parts
}
#[derive(Debug, Clone, Copy, Default)]
pub struct SubstituteCommand;
impl Command for SubstituteCommand {
fn id(&self) -> CommandId {
CommandId::new(COMMANDS_MODULE, "substitute")
}
fn description(&self) -> &'static str {
"Search and replace text"
}
fn args(&self) -> Vec<ArgSpec> {
vec![ArgSpec::optional(
"args",
ArgKind::Rest,
"Pattern, replacement, and flags (/pat/rep/flags)",
)]
}
fn names(&self) -> &[&'static str] {
&["s", "substitute"]
}
}
impl CommandHandler for SubstituteCommand {
#[allow(clippy::too_many_lines)]
#[cfg_attr(coverage_nightly, coverage(off))]
fn execute(&self, runtime: &mut SessionRuntime<'_>, args: &CommandContext) -> CommandResult {
let Some(buffer_id) = args.buffer_id() else {
return CommandResult::error("No active buffer");
};
let raw = args.string("args").unwrap_or("");
let Some(spec) = parse_substitute(raw) else {
return CommandResult::error("E486: Invalid substitute syntax");
};
let regex_pattern = if spec.case_insensitive {
format!("(?i){}", spec.pattern)
} else {
spec.pattern.clone()
};
let regex = match Regex::new(®ex_pattern) {
Ok(r) => r,
Err(e) => {
return CommandResult::error(&format!("E486: Invalid pattern: {e}"));
}
};
let (start_line, end_line) = if let Some((s, e)) = args.range() {
(s, e)
} else {
let cursor_line = runtime.windows().active().map_or(0, |w| w.cursor.line);
(cursor_line, cursor_line)
};
let line_count = runtime.buffer_line_count(buffer_id).unwrap_or(0);
let end_line = end_line.min(line_count.saturating_sub(1));
if start_line > end_line || line_count == 0 {
return CommandResult::error("E486: Pattern not found");
}
if spec.count_only {
let mut total_matches = 0usize;
let mut lines_with_matches = 0usize;
for line_idx in start_line..=end_line {
if let Some(line) = runtime.buffer_line(buffer_id, line_idx) {
let count = regex.find_iter(&line).count();
if count > 0 {
total_matches += count;
lines_with_matches += 1;
}
}
}
tracing::info!(
"{total_matches} match{} on {lines_with_matches} line{}",
if total_matches == 1 { "" } else { "es" },
if lines_with_matches == 1 { "" } else { "s" },
);
return CommandResult::Success;
}
let mut total_subs = 0usize;
let mut lines_changed = 0usize;
let mut last_sub_line = start_line;
for line_idx in (start_line..=end_line).rev() {
let Some(line) = runtime.buffer_line(buffer_id, line_idx) else {
continue;
};
let new_line = if spec.global {
regex
.replace_all(&line, spec.replacement.as_str())
.into_owned()
} else {
regex.replace(&line, spec.replacement.as_str()).into_owned()
};
if new_line != line {
let line_len = line.len();
let start = Position::new(line_idx, 0);
let end = Position::new(line_idx, line_len);
runtime.delete_range(buffer_id, start, end);
runtime.insert_text(buffer_id, start, &new_line);
let subs_on_line = if spec.global {
regex.find_iter(&line).count()
} else {
1
};
total_subs += subs_on_line;
lines_changed += 1;
last_sub_line = line_idx;
}
}
if total_subs == 0 {
return CommandResult::error("E486: Pattern not found");
}
if let Some(window) = runtime.windows_mut().active_mut() {
window.cursor.line = last_sub_line;
window.cursor.column = 0;
}
runtime.record_cursor_move(buffer_id);
tracing::info!(
"{total_subs} substitution{} on {lines_changed} line{}",
if total_subs == 1 { "" } else { "s" },
if lines_changed == 1 { "" } else { "s" },
);
CommandResult::Success
}
}
#[cfg(test)]
#[path = "substitute_tests.rs"]
mod tests;