use std::borrow::Cow;
use hjkl_engine::Editor;
struct CommandDef {
full_name: &'static str,
min_prefix: usize,
}
#[rustfmt::skip]
static COMMAND_NAMES: &[CommandDef] = &[
CommandDef { full_name: "bNext", min_prefix: 2 },
CommandDef { full_name: "bdelete", min_prefix: 2 },
CommandDef { full_name: "bfirst", min_prefix: 2 },
CommandDef { full_name: "blast", min_prefix: 2 },
CommandDef { full_name: "bnext", min_prefix: 2 },
CommandDef { full_name: "bprevious", min_prefix: 2 },
CommandDef { full_name: "buffers", min_prefix: 2 },
CommandDef { full_name: "delete", min_prefix: 1 },
CommandDef { full_name: "edit", min_prefix: 1 },
CommandDef { full_name: "files", min_prefix: 2 },
CommandDef { full_name: "foldindent", min_prefix: 5 },
CommandDef { full_name: "foldsyntax", min_prefix: 5 },
CommandDef { full_name: "global", min_prefix: 1 },
CommandDef { full_name: "ls", min_prefix: 2 },
CommandDef { full_name: "changes", min_prefix: 7 },
CommandDef { full_name: "jumps", min_prefix: 5 },
CommandDef { full_name: "marks", min_prefix: 5 },
CommandDef { full_name: "nohlsearch", min_prefix: 3 },
CommandDef { full_name: "qall", min_prefix: 2 },
CommandDef { full_name: "quit", min_prefix: 1 },
CommandDef { full_name: "read", min_prefix: 1 },
CommandDef { full_name: "redo", min_prefix: 3 },
CommandDef { full_name: "registers", min_prefix: 3 },
CommandDef { full_name: "set", min_prefix: 2 },
CommandDef { full_name: "sort", min_prefix: 3 },
CommandDef { full_name: "substitute", min_prefix: 1 },
CommandDef { full_name: "undo", min_prefix: 1 },
CommandDef { full_name: "vglobal", min_prefix: 1 },
CommandDef { full_name: "wall", min_prefix: 2 },
CommandDef { full_name: "write", min_prefix: 1 },
CommandDef { full_name: "wq", min_prefix: 2 },
CommandDef { full_name: "wqall", min_prefix: 3 },
CommandDef { full_name: "x", min_prefix: 1 },
];
fn resolve_name(input: &str) -> Option<&'static str> {
if input.is_empty() {
return None;
}
if let Some(exact) = COMMAND_NAMES
.iter()
.find(|d| d.full_name == input)
.map(|d| d.full_name)
{
return Some(exact);
}
let candidates: Vec<&'static str> = COMMAND_NAMES
.iter()
.filter(|d| d.full_name.starts_with(input) && input.len() >= d.min_prefix)
.map(|d| d.full_name)
.collect();
if candidates.len() == 1 {
Some(candidates[0])
} else {
None
}
}
fn split_name(cmd: &str) -> (&str, &str) {
let split_at = cmd
.char_indices()
.find(|(_, c)| !c.is_ascii_alphabetic())
.map(|(i, _)| i)
.unwrap_or(cmd.len());
cmd.split_at(split_at)
}
pub fn canonical_command_name(cmd: &str) -> Cow<'_, str> {
let (name, rest) = split_name(cmd);
if name.is_empty() {
return Cow::Borrowed(cmd);
}
match resolve_name(name) {
Some(canon) if canon != name => Cow::Owned(format!("{canon}{rest}")),
_ => Cow::Borrowed(cmd),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExEffect {
None,
Save,
SaveAs(String),
Quit { force: bool, save: bool },
Unknown(String),
Substituted { count: usize, lines_changed: usize },
Ok,
Info(String),
Error(String),
}
pub fn run<H: hjkl_engine::Host>(
editor: &mut Editor<hjkl_buffer::Buffer, H>,
input: &str,
) -> ExEffect {
let cmd = input.trim();
if cmd.is_empty() {
return ExEffect::None;
}
if cmd.starts_with('/') || cmd.starts_with('?') {
let forward = cmd.starts_with('/');
let delim = if forward { '/' } else { '?' };
let body = &cmd[1..];
let pat_str: String = match body.strip_suffix(delim).unwrap_or(body) {
"" => match editor.last_search() {
Some(p) if !p.is_empty() => p.to_string(),
_ => return ExEffect::Error("no previous search pattern".into()),
},
s => s.to_string(),
};
let s = editor.settings();
let case_insensitive =
s.ignore_case && !(s.smartcase && pat_str.chars().any(|c| c.is_uppercase()));
let compile_src: std::borrow::Cow<'_, str> = if case_insensitive {
std::borrow::Cow::Owned(format!("(?i){pat_str}"))
} else {
std::borrow::Cow::Borrowed(pat_str.as_str())
};
return match regex::Regex::new(&compile_src) {
Ok(re) => {
editor.set_search_pattern(Some(re));
if forward {
editor.search_advance_forward(false);
} else {
editor.search_advance_backward(true);
}
editor.ensure_cursor_in_scrolloff();
editor.set_last_search(Some(pat_str), forward);
ExEffect::Ok
}
Err(e) => ExEffect::Error(format!("bad search pattern: {e}")),
};
}
let (range, cmd) = match parse_range(cmd, editor) {
Ok(pair) => pair,
Err(e) => return ExEffect::Error(e),
};
if range.is_none() {
if let Ok(line) = cmd.parse::<usize>() {
editor.goto_line(line);
return ExEffect::Ok;
}
} else if cmd.is_empty() {
if let Some(r) = range {
editor.goto_line(r.start_one_based());
return ExEffect::Ok;
}
}
let canon = canonical_command_name(cmd);
let cmd: &str = canon.as_ref();
match cmd {
"quit" => {
return ExEffect::Quit {
force: false,
save: false,
};
}
"quit!" => {
return ExEffect::Quit {
force: true,
save: false,
};
}
"write" => return ExEffect::Save,
"wq" | "x" => {
return ExEffect::Quit {
force: false,
save: true,
};
}
"qall" => {
return ExEffect::Quit {
force: false,
save: false,
};
}
"qall!" => {
return ExEffect::Quit {
force: true,
save: false,
};
}
"wqall" | "wqall!" => {
return ExEffect::Quit {
force: false,
save: true,
};
}
"nohlsearch" => {
editor.set_search_pattern(None);
return ExEffect::Ok;
}
"registers" => return ExEffect::Info(format_registers(editor)),
"marks" => return ExEffect::Info(format_marks(editor)),
"jumps" => return ExEffect::Info(format_jumps(editor)),
"changes" => return ExEffect::Info(format_changes(editor)),
"undo" => {
editor.undo();
return ExEffect::Ok;
}
"redo" => {
editor.redo();
return ExEffect::Ok;
}
"foldindent" => return apply_fold_indent(editor),
"foldsyntax" => return apply_fold_syntax(editor),
_ => {}
}
if let Some(path) = cmd.strip_prefix("write ") {
let path = path.trim();
if !path.is_empty() {
return ExEffect::SaveAs(path.to_string());
}
}
if let Some(rest) = cmd.strip_prefix("sort") {
return apply_sort(editor, range, rest);
}
if let Some(rest) = cmd
.strip_prefix("set ")
.or(if cmd == "set" { Some("") } else { None })
{
return apply_set(editor, rest);
}
if let Some((negate, rest)) = parse_global_prefix(cmd) {
return apply_global(editor, range, rest, negate);
}
if let Some(rest) = cmd.strip_prefix("substitute") {
return match parse_substitute_body(rest) {
Ok(sub) => match apply_substitute(editor, range, sub) {
Ok((count, lines_changed)) => ExEffect::Substituted {
count,
lines_changed,
},
Err(e) => ExEffect::Error(e),
},
Err(e) => ExEffect::Error(e),
};
}
if cmd == "delete" {
return apply_delete_range(editor, range);
}
if let Some(path) = cmd.strip_prefix("read ") {
return apply_read_file(editor, path.trim());
}
if let Some(shell_cmd) = cmd.strip_prefix('!') {
return apply_shell_filter(editor, range, shell_cmd.trim());
}
ExEffect::Unknown(cmd.to_string())
}
fn apply_fold_syntax<H: hjkl_engine::Host>(
editor: &mut Editor<hjkl_buffer::Buffer, H>,
) -> ExEffect {
let ranges = editor.syntax_fold_ranges().to_vec();
if ranges.is_empty() {
return ExEffect::Info("no syntax block ranges available".into());
}
let count = ranges.len();
for (start, end) in ranges {
editor.apply_fold_op(hjkl_engine::FoldOp::Add {
start_row: start,
end_row: end,
closed: true,
});
}
ExEffect::Info(format!("created {count} fold(s)"))
}
fn apply_fold_indent<H: hjkl_engine::Host>(
editor: &mut Editor<hjkl_buffer::Buffer, H>,
) -> ExEffect {
let lines = editor.buffer().lines().to_vec();
let total = lines.len();
if total == 0 {
return ExEffect::Ok;
}
let indent =
|line: &str| -> usize { line.chars().take_while(|c| *c == ' ' || *c == '\t').count() };
let indents: Vec<usize> = lines.iter().map(|l| indent(l)).collect();
let blank: Vec<bool> = lines.iter().map(|l| l.trim().is_empty()).collect();
let mut new_folds: Vec<(usize, usize)> = Vec::new();
let mut i = 0;
while i + 1 < total {
if blank[i] {
i += 1;
continue;
}
let head_indent = indents[i];
let mut j = i + 1;
while j < total && blank[j] {
j += 1;
}
if j >= total || indents[j] <= head_indent {
i += 1;
continue;
}
let mut end = j;
let mut k = j + 1;
while k < total {
if !blank[k] && indents[k] <= head_indent {
break;
}
end = k;
k += 1;
}
new_folds.push((i, end));
i += 1;
}
if new_folds.is_empty() {
return ExEffect::Info("no indented blocks to fold".into());
}
let count = new_folds.len();
for (start, end) in new_folds {
editor.apply_fold_op(hjkl_engine::FoldOp::Add {
start_row: start,
end_row: end,
closed: true,
});
}
ExEffect::Info(format!("created {count} fold(s)"))
}
fn apply_shell_filter<H: hjkl_engine::Host>(
editor: &mut Editor<hjkl_buffer::Buffer, H>,
range: Option<Range>,
cmd: &str,
) -> ExEffect {
if cmd.is_empty() {
return ExEffect::Error(":! needs a shell command".into());
}
use std::io::Write;
use std::process::{Command, Stdio};
if range.is_none() {
let output = Command::new("sh").arg("-c").arg(cmd).output();
return match output {
Ok(out) if out.status.success() => {
let stdout = String::from_utf8_lossy(&out.stdout).trim_end().to_string();
if stdout.is_empty() {
ExEffect::Info(format!("`{cmd}` exited 0"))
} else {
ExEffect::Info(stdout)
}
}
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr);
let trimmed = stderr.trim();
let label = if trimmed.is_empty() {
"no stderr".to_string()
} else {
trimmed.to_string()
};
ExEffect::Error(format!(
"command exited {} ({label})",
out.status
.code()
.map(|c| c.to_string())
.unwrap_or_else(|| "?".into())
))
}
Err(e) => ExEffect::Error(format!("cannot run `{cmd}`: {e}")),
};
}
let scope = Range::or_default(range, Range::whole(editor));
let mut all_lines: Vec<String> = editor.buffer().lines().to_vec();
let total = all_lines.len();
if total == 0 {
return ExEffect::Ok;
}
let bot = scope.end.min(total - 1);
if scope.start > bot {
return ExEffect::Ok;
}
let payload = all_lines[scope.start..=bot].join("\n");
let mut child = match Command::new("sh")
.arg("-c")
.arg(cmd)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) => return ExEffect::Error(format!("cannot spawn `{cmd}`: {e}")),
};
if let Some(stdin) = child.stdin.as_mut() {
match stdin.write_all(payload.as_bytes()) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => {}
Err(e) => return ExEffect::Error(format!("cannot write to `{cmd}`: {e}")),
}
}
let output = match child.wait_with_output() {
Ok(o) => o,
Err(e) => return ExEffect::Error(format!("`{cmd}` failed: {e}")),
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let trimmed = stderr.trim();
let label = if trimmed.is_empty() {
"no stderr".to_string()
} else {
trimmed.to_string()
};
return ExEffect::Error(format!(
"command exited {} ({label})",
output
.status
.code()
.map(|c| c.to_string())
.unwrap_or_else(|| "?".into())
));
}
let stdout = match String::from_utf8(output.stdout) {
Ok(s) => s,
Err(_) => return ExEffect::Error("filter output was not UTF-8".into()),
};
let trimmed = stdout.strip_suffix('\n').unwrap_or(&stdout);
let new_rows: Vec<String> = trimmed.split('\n').map(String::from).collect();
editor.push_undo();
let after: Vec<String> = all_lines.split_off(bot + 1);
all_lines.truncate(scope.start);
all_lines.extend(new_rows);
all_lines.extend(after);
editor.restore(all_lines, (scope.start, 0));
mark_dirty_after_ex(editor);
ExEffect::Ok
}
fn apply_read_file<H: hjkl_engine::Host>(
editor: &mut Editor<hjkl_buffer::Buffer, H>,
path: &str,
) -> ExEffect {
use hjkl_buffer::{Edit, Position};
if path.is_empty() {
return ExEffect::Error(":r needs a file path or `!cmd`".into());
}
let content = if let Some(cmd) = path.strip_prefix('!') {
let cmd = cmd.trim();
if cmd.is_empty() {
return ExEffect::Error(":r ! needs a shell command".into());
}
match std::process::Command::new("sh").arg("-c").arg(cmd).output() {
Ok(out) if out.status.success() => match String::from_utf8(out.stdout) {
Ok(s) => s,
Err(_) => return ExEffect::Error("command output was not UTF-8".into()),
},
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr);
let trimmed = stderr.trim();
let label = if trimmed.is_empty() {
"no stderr".to_string()
} else {
trimmed.to_string()
};
return ExEffect::Error(format!(
"command exited {} ({label})",
out.status
.code()
.map(|c| c.to_string())
.unwrap_or_else(|| "?".into())
));
}
Err(e) => return ExEffect::Error(format!("cannot run `{cmd}`: {e}")),
}
} else {
match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) => return ExEffect::Error(format!("cannot read `{path}`: {e}")),
}
};
let trimmed = content.strip_suffix('\n').unwrap_or(&content);
editor.push_undo();
let row = editor.cursor().0;
let line_chars = editor
.buffer()
.line(row)
.map(|l| l.chars().count())
.unwrap_or(0);
let insert_text = format!("\n{trimmed}");
editor.mutate_edit(Edit::InsertStr {
at: Position::new(row, line_chars),
text: insert_text,
});
editor.jump_cursor(row + 1, 0);
mark_dirty_after_ex(editor);
ExEffect::Ok
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Range {
start: usize,
end: usize,
}
impl Range {
fn whole<H: hjkl_engine::Host>(editor: &Editor<hjkl_buffer::Buffer, H>) -> Self {
let last = editor.buffer().lines().len().saturating_sub(1);
Self {
start: 0,
end: last,
}
}
fn single(row: usize) -> Self {
Self {
start: row,
end: row,
}
}
fn start_one_based(&self) -> usize {
self.start + 1
}
fn or_default(opt: Option<Self>, default: Self) -> Self {
opt.unwrap_or(default)
}
}
#[derive(Debug, Clone, Copy)]
enum Address {
Number(usize), Current,
Last,
Mark(char),
}
fn parse_address(s: &str) -> Option<(Address, &str)> {
let mut chars = s.char_indices();
let (_, first) = chars.next()?;
match first {
'.' => Some((Address::Current, &s[1..])),
'$' => Some((Address::Last, &s[1..])),
'\'' => {
let (_, mark) = chars.next()?;
Some((Address::Mark(mark), &s[2..]))
}
'0'..='9' => {
let mut end = 1;
for (i, c) in s.char_indices().skip(1) {
if c.is_ascii_digit() {
end = i + c.len_utf8();
} else {
break;
}
}
let n: usize = s[..end].parse().ok()?;
Some((Address::Number(n), &s[end..]))
}
_ => None,
}
}
fn resolve_address<H: hjkl_engine::Host>(
addr: Address,
editor: &Editor<hjkl_buffer::Buffer, H>,
) -> Result<usize, String> {
let last = editor.buffer().lines().len().saturating_sub(1);
match addr {
Address::Number(n) => Ok(n.saturating_sub(1).min(last)),
Address::Current => Ok(editor.cursor().0),
Address::Last => Ok(last),
Address::Mark(c) => editor
.mark(c)
.map(|(r, _)| r.min(last))
.ok_or_else(|| format!("mark `{c}` not set")),
}
}
fn parse_range<'a, H: hjkl_engine::Host>(
cmd: &'a str,
editor: &Editor<hjkl_buffer::Buffer, H>,
) -> Result<(Option<Range>, &'a str), String> {
if let Some(rest) = cmd.strip_prefix('%') {
return Ok((Some(Range::whole(editor)), rest));
}
let Some((start_addr, after_start)) = parse_address(cmd) else {
return Ok((None, cmd));
};
let start = resolve_address(start_addr, editor)?;
if let Some(after_comma) = after_start.strip_prefix(',') {
let (end_addr, rest) =
parse_address(after_comma).unwrap_or((Address::Number(start + 1), after_comma));
let end = resolve_address(end_addr, editor)?;
let (lo, hi) = if start <= end {
(start, end)
} else {
(end, start)
};
return Ok((Some(Range { start: lo, end: hi }), rest));
}
Ok((Some(Range::single(start)), after_start))
}
fn apply_delete_range<H: hjkl_engine::Host>(
editor: &mut Editor<hjkl_buffer::Buffer, H>,
range: Option<Range>,
) -> ExEffect {
use hjkl_buffer::{Edit, MotionKind, Position};
let r = Range::or_default(range, Range::single(editor.cursor().0));
let total = editor.buffer().row_count();
if total == 0 {
return ExEffect::Ok;
}
let bot = r.end.min(total.saturating_sub(1));
if r.start > bot {
return ExEffect::Ok;
}
editor.push_undo();
for row in (r.start..=bot).rev() {
if editor.buffer().row_count() == 1 {
let line_chars = editor
.buffer()
.line(0)
.map(|l| l.chars().count())
.unwrap_or(0);
if line_chars > 0 {
editor.mutate_edit(Edit::DeleteRange {
start: Position::new(0, 0),
end: Position::new(0, line_chars),
kind: MotionKind::Char,
});
}
continue;
}
editor.mutate_edit(Edit::DeleteRange {
start: Position::new(row, 0),
end: Position::new(row, 0),
kind: MotionKind::Line,
});
}
mark_dirty_after_ex(editor);
ExEffect::Ok
}
fn parse_global_prefix(cmd: &str) -> Option<(bool, &str)> {
if let Some(rest) = cmd.strip_prefix("global!") {
return Some((true, rest));
}
if let Some(rest) = cmd.strip_prefix("vglobal") {
return Some((true, rest));
}
if let Some(rest) = cmd.strip_prefix("global") {
return Some((false, rest));
}
None
}
fn apply_global<H: hjkl_engine::Host>(
editor: &mut Editor<hjkl_buffer::Buffer, H>,
range: Option<Range>,
body: &str,
negate: bool,
) -> ExEffect {
use hjkl_buffer::{Edit, MotionKind, Position};
let mut chars = body.chars();
let sep = match chars.next() {
Some(c) => c,
None => return ExEffect::Error("empty :g pattern".into()),
};
if sep.is_alphanumeric() || sep == '\\' {
return ExEffect::Error("global needs a separator, e.g. :g/foo/d".into());
}
let rest: String = chars.collect();
let parts = split_unescaped(&rest, sep);
if parts.len() < 2 {
return ExEffect::Error("global needs /pattern/cmd".into());
}
let pattern = unescape(&parts[0], sep);
let cmd = parts[1].trim();
if cmd != "d" {
return ExEffect::Error(format!(":g supports only `d` today, got `{cmd}`"));
}
let regex = match regex::Regex::new(&pattern) {
Ok(r) => r,
Err(e) => return ExEffect::Error(format!("bad pattern: {e}")),
};
editor.push_undo();
let scope = Range::or_default(range, Range::whole(editor));
let row_count = editor.buffer().row_count();
let bot = scope.end.min(row_count.saturating_sub(1));
let mut targets: Vec<usize> = Vec::new();
for row in scope.start..=bot {
let line = editor.buffer().line(row).unwrap_or("");
let matches = regex.is_match(line);
if matches != negate {
targets.push(row);
}
}
if targets.is_empty() {
editor.pop_last_undo();
return ExEffect::Substituted {
count: 0,
lines_changed: 0,
};
}
let count = targets.len();
for row in targets.iter().rev() {
let row = *row;
if editor.buffer().row_count() == 1 {
let line_chars = editor
.buffer()
.line(0)
.map(|l| l.chars().count())
.unwrap_or(0);
if line_chars > 0 {
editor.mutate_edit(Edit::DeleteRange {
start: Position::new(0, 0),
end: Position::new(0, line_chars),
kind: MotionKind::Char,
});
}
continue;
}
editor.mutate_edit(Edit::DeleteRange {
start: Position::new(row, 0),
end: Position::new(row, 0),
kind: MotionKind::Line,
});
}
mark_dirty_after_ex(editor);
ExEffect::Substituted {
count,
lines_changed: count,
}
}
fn apply_set<H: hjkl_engine::Host>(
editor: &mut Editor<hjkl_buffer::Buffer, H>,
body: &str,
) -> ExEffect {
let trimmed = body.trim();
if trimmed.is_empty() {
let s = editor.settings();
let wrap = match s.wrap {
hjkl_buffer::Wrap::None => "off",
hjkl_buffer::Wrap::Char => "char",
hjkl_buffer::Wrap::Word => "word",
};
let scl = match s.signcolumn {
hjkl_engine::types::SignColumnMode::Yes => "yes",
hjkl_engine::types::SignColumnMode::No => "no",
hjkl_engine::types::SignColumnMode::Auto => "auto",
};
return ExEffect::Info(format!(
"shiftwidth={} tabstop={} softtabstop={} textwidth={} undolevels={} timeoutlen={} iskeyword=\"{}\" expandtab={} ignorecase={} smartcase={} wrapscan={} autoindent={} smartindent={} undobreak={} readonly={} wrap={} number={} relativenumber={} numberwidth={} cursorline={} cursorcolumn={} signcolumn={} foldcolumn={} colorcolumn=\"{}\"",
s.shiftwidth,
s.tabstop,
s.softtabstop,
s.textwidth,
s.undo_levels,
s.timeout_len.as_millis(),
s.iskeyword,
if s.expandtab { "on" } else { "off" },
if s.ignore_case { "on" } else { "off" },
if s.smartcase { "on" } else { "off" },
if s.wrapscan { "on" } else { "off" },
if s.autoindent { "on" } else { "off" },
if s.smartindent { "on" } else { "off" },
if s.undo_break_on_motion { "on" } else { "off" },
if s.readonly { "on" } else { "off" },
wrap,
if s.number { "on" } else { "off" },
if s.relativenumber { "on" } else { "off" },
s.numberwidth,
if s.cursorline { "on" } else { "off" },
if s.cursorcolumn { "on" } else { "off" },
scl,
s.foldcolumn,
s.colorcolumn,
));
}
for token in trimmed.split_whitespace() {
if let Err(e) = apply_set_token(editor, token) {
return ExEffect::Error(e);
}
}
ExEffect::Ok
}
fn apply_set_token<H: hjkl_engine::Host>(
editor: &mut Editor<hjkl_buffer::Buffer, H>,
token: &str,
) -> Result<(), String> {
if let Some((name, value)) = token.split_once('=') {
if matches!(name, "iskeyword" | "isk") {
editor.set_iskeyword(value);
return Ok(());
}
if matches!(name, "signcolumn" | "scl") {
editor.settings_mut().signcolumn = match value {
"yes" => hjkl_engine::types::SignColumnMode::Yes,
"no" => hjkl_engine::types::SignColumnMode::No,
"auto" => hjkl_engine::types::SignColumnMode::Auto,
other => {
return Err(format!(
"signcolumn must be `yes`, `no`, or `auto`, got `{other}`"
));
}
};
return Ok(());
}
if matches!(name, "colorcolumn" | "cc") {
editor.settings_mut().colorcolumn = value.to_string();
return Ok(());
}
let parsed: usize = value
.parse()
.map_err(|_| format!("bad value `{value}` for :set {name}"))?;
match name {
"shiftwidth" | "sw" => {
if parsed == 0 {
return Err("shiftwidth must be > 0".into());
}
editor.settings_mut().shiftwidth = parsed;
}
"tabstop" | "ts" => {
if parsed == 0 {
return Err("tabstop must be > 0".into());
}
editor.settings_mut().tabstop = parsed;
}
"textwidth" | "tw" => {
if parsed == 0 {
return Err("textwidth must be > 0".into());
}
editor.settings_mut().textwidth = parsed;
}
"undolevels" | "ul" => {
editor.settings_mut().undo_levels = parsed.min(u32::MAX as usize) as u32;
}
"timeoutlen" | "tm" => {
editor.settings_mut().timeout_len =
core::time::Duration::from_millis(parsed as u64);
}
"numberwidth" | "nuw" => {
if !(1..=20).contains(&parsed) {
return Err(format!("numberwidth must be in range 1..=20, got {parsed}"));
}
editor.settings_mut().numberwidth = parsed;
}
"foldcolumn" | "fdc" => {
if parsed > 12 {
return Err(format!("foldcolumn must be in range 0..=12, got {parsed}"));
}
editor.settings_mut().foldcolumn = parsed as u32;
}
other => return Err(format!("unknown :set option `{other}`")),
}
return Ok(());
}
if let Some(name) = token.strip_suffix('!') {
match name {
"number" | "nu" => {
editor.settings_mut().number = !editor.settings().number;
}
"relativenumber" | "rnu" => {
editor.settings_mut().relativenumber = !editor.settings().relativenumber;
}
"cursorline" | "cul" => {
editor.settings_mut().cursorline = !editor.settings().cursorline;
}
"cursorcolumn" | "cuc" => {
editor.settings_mut().cursorcolumn = !editor.settings().cursorcolumn;
}
other => return Err(format!("unknown :set option `{other}`")),
}
return Ok(());
}
let (name, value) = if let Some(rest) = token.strip_prefix("no") {
(rest, false)
} else {
(token, true)
};
match name {
"ignorecase" | "ic" => editor.settings_mut().ignore_case = value,
"smartcase" | "scs" => editor.settings_mut().smartcase = value,
"wrapscan" | "ws" => editor.settings_mut().wrapscan = value,
"expandtab" | "et" => editor.settings_mut().expandtab = value,
"autoindent" | "ai" => editor.settings_mut().autoindent = value,
"smartindent" | "si" => editor.settings_mut().smartindent = value,
"undobreak" => editor.settings_mut().undo_break_on_motion = value,
"readonly" | "ro" => editor.settings_mut().readonly = value,
"number" | "nu" => editor.settings_mut().number = value,
"relativenumber" | "rnu" => editor.settings_mut().relativenumber = value,
"cursorline" | "cul" => editor.settings_mut().cursorline = value,
"cursorcolumn" | "cuc" => editor.settings_mut().cursorcolumn = value,
"wrap" => {
editor.settings_mut().wrap = if value {
match editor.settings().wrap {
hjkl_buffer::Wrap::Word => hjkl_buffer::Wrap::Word,
_ => hjkl_buffer::Wrap::Char,
}
} else {
hjkl_buffer::Wrap::None
};
}
"linebreak" | "lbr" => {
editor.settings_mut().wrap = if value {
hjkl_buffer::Wrap::Word
} else {
match editor.settings().wrap {
hjkl_buffer::Wrap::None => hjkl_buffer::Wrap::None,
_ => hjkl_buffer::Wrap::Char,
}
};
}
"foldenable" | "fen" => {}
other => return Err(format!("unknown :set option `{other}`")),
}
Ok(())
}
fn apply_sort<H: hjkl_engine::Host>(
editor: &mut Editor<hjkl_buffer::Buffer, H>,
range: Option<Range>,
flags: &str,
) -> ExEffect {
let trimmed = flags.trim();
let mut reverse = false;
let mut unique = false;
let mut numeric = false;
let mut ignore_case = false;
for c in trimmed.chars() {
match c {
'!' => reverse = true,
'u' => unique = true,
'n' => numeric = true,
'i' => ignore_case = true,
' ' | '\t' => {}
other => return ExEffect::Error(format!("bad :sort flag `{other}`")),
}
}
let mut all_lines: Vec<String> = editor.buffer().lines().to_vec();
let total = all_lines.len();
if total == 0 {
return ExEffect::Ok;
}
let scope = Range::or_default(range, Range::whole(editor));
let bot = scope.end.min(total - 1);
if scope.start > bot {
return ExEffect::Ok;
}
let mut slice: Vec<String> = all_lines[scope.start..=bot].to_vec();
if numeric {
slice.sort_by_key(|l| extract_leading_number(l));
} else if ignore_case {
slice.sort_by_key(|s| s.to_lowercase());
} else {
slice.sort();
}
if reverse {
slice.reverse();
}
if unique {
let cmp_key = |s: &str| -> String {
if ignore_case {
s.to_lowercase()
} else {
s.to_string()
}
};
let mut seen = std::collections::HashSet::new();
slice.retain(|line| seen.insert(cmp_key(line)));
}
let after: Vec<String> = all_lines.split_off(bot + 1);
all_lines.truncate(scope.start);
all_lines.extend(slice);
all_lines.extend(after);
editor.push_undo();
editor.restore(all_lines, (scope.start, 0));
mark_dirty_after_ex(editor);
ExEffect::Ok
}
fn extract_leading_number(line: &str) -> i64 {
let bytes = line.as_bytes();
let mut i = 0;
while i < bytes.len() && !bytes[i].is_ascii_digit() && bytes[i] != b'-' {
i += 1;
}
if i >= bytes.len() {
return i64::MIN;
}
let mut j = i;
if bytes[j] == b'-' {
j += 1;
}
let start = j;
while j < bytes.len() && bytes[j].is_ascii_digit() {
j += 1;
}
if j == start {
return i64::MIN;
}
line[i..j].parse().unwrap_or(i64::MIN)
}
fn format_registers<H: hjkl_engine::Host>(editor: &Editor<hjkl_buffer::Buffer, H>) -> String {
let r = editor.registers();
let mut lines = vec!["--- Registers ---".to_string()];
let mut push = |sel: &str, text: &str, linewise: bool| {
if text.is_empty() {
return;
}
let marker = if linewise { "L" } else { " " };
lines.push(format!("{sel:<3} {marker} {}", display_register(text)));
};
push("\"\"", &r.unnamed.text, r.unnamed.linewise);
push("\"0", &r.yank_zero.text, r.yank_zero.linewise);
for (i, slot) in r.delete_ring.iter().enumerate() {
let sel = format!("\"{}", i + 1);
push(&sel, &slot.text, slot.linewise);
}
for (i, slot) in r.named.iter().enumerate() {
let sel = format!("\"{}", (b'a' + i as u8) as char);
push(&sel, &slot.text, slot.linewise);
}
if lines.len() == 1 {
lines.push("(no registers set)".to_string());
}
lines.join("\n")
}
fn display_register(text: &str) -> String {
let escaped: String = text
.chars()
.map(|c| match c {
'\n' => "\\n".to_string(),
'\t' => "\\t".to_string(),
'\r' => "\\r".to_string(),
c => c.to_string(),
})
.collect();
const MAX: usize = 60;
if escaped.chars().count() > MAX {
let head: String = escaped.chars().take(MAX - 3).collect();
format!("{head}...")
} else {
escaped
}
}
fn format_marks<H: hjkl_engine::Host>(editor: &Editor<hjkl_buffer::Buffer, H>) -> String {
let mut lines = vec!["--- Marks ---".to_string(), "mark line col".to_string()];
let entries: Vec<(char, usize, usize)> =
editor.marks().map(|(c, (r, col))| (c, r, col)).collect();
for (c, r, col) in entries {
lines.push(format!(" {c} {:>4} {col:>3}", r + 1));
}
if let Some((r, col)) = editor.last_jump_back() {
lines.push(format!(" ' {:>4} {col:>3}", r + 1));
}
if let Some((r, col)) = editor.last_edit_pos() {
lines.push(format!(" . {:>4} {col:>3}", r + 1));
}
if lines.len() == 2 {
lines.push("(no marks set)".to_string());
}
lines.join("\n")
}
fn format_jumps<H: hjkl_engine::Host>(editor: &Editor<hjkl_buffer::Buffer, H>) -> String {
let (back, fwd) = editor.jump_list();
if back.is_empty() && fwd.is_empty() {
return "(no jumps recorded)".to_string();
}
let mut lines = vec![
"--- Jump list ---".to_string(),
" jump line col".to_string(),
];
let back_len = back.len();
for (i, &(row, col)) in back.iter().rev().enumerate() {
let jump_num = i + 1;
lines.push(format!("{jump_num:>5} {:>4} {:>4}", row + 1, col));
}
lines.push(format!("{:>5} (current position)", 0));
for (i, &(row, col)) in fwd.iter().enumerate() {
let jump_num = -(i as isize + 1);
lines.push(format!("{jump_num:>5} {:>4} {:>4}", row + 1, col));
}
let _ = back_len; lines.join("\n")
}
fn format_changes<H: hjkl_engine::Host>(editor: &Editor<hjkl_buffer::Buffer, H>) -> String {
let (list, cursor) = editor.change_list();
if list.is_empty() {
return "(no changes recorded)".to_string();
}
let mut lines = vec![
"--- Change list ---".to_string(),
"change line col".to_string(),
];
let len = list.len();
for (display_idx, &(row, col)) in list.iter().rev().enumerate() {
let change_num = display_idx + 1;
let marker = match cursor {
Some(c) if c == len - 1 - display_idx => " <",
_ => "",
};
lines.push(format!(
"{change_num:>6} {:>4} {:>4}{marker}",
row + 1,
col
));
}
lines.join("\n")
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Substitute {
pattern: String,
replacement: String,
global: bool,
case_insensitive: bool,
case_sensitive: bool,
#[allow(dead_code)]
confirm: bool,
}
fn parse_substitute_body(body: &str) -> Result<Substitute, String> {
let mut chars = body.chars();
let sep = chars.next().ok_or_else(|| "empty substitute".to_string())?;
if sep.is_alphanumeric() || sep == '\\' {
return Err("substitute needs a separator, e.g. :s/foo/bar/".into());
}
let rest: String = chars.collect();
let parts = split_unescaped(&rest, sep);
if parts.len() < 2 {
return Err("substitute needs /pattern/replacement/".into());
}
let pattern = unescape(&parts[0], sep);
let replacement = unescape(&parts[1], sep);
let flags = parts.get(2).cloned().unwrap_or_default();
let mut global = false;
let mut case_insensitive = false;
let mut case_sensitive = false;
let mut confirm = false;
for f in flags.chars() {
match f {
'g' => global = true,
'i' => case_insensitive = true,
'I' => case_sensitive = true,
'c' => confirm = true, other => return Err(format!("unknown substitute flag: {other}")),
}
}
Ok(Substitute {
pattern,
replacement,
global,
case_insensitive,
case_sensitive,
confirm,
})
}
fn split_unescaped(s: &str, sep: char) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\' {
if let Some(&next) = chars.peek() {
if next == sep {
cur.push(sep);
chars.next();
} else {
cur.push('\\');
cur.push(next);
chars.next();
}
} else {
cur.push('\\');
}
} else if c == sep {
out.push(std::mem::take(&mut cur));
} else {
cur.push(c);
}
}
out.push(cur);
out
}
fn unescape(s: &str, _sep: char) -> String {
s.to_string()
}
fn apply_substitute<H: hjkl_engine::Host>(
editor: &mut Editor<hjkl_buffer::Buffer, H>,
range: Option<Range>,
sub: Substitute,
) -> Result<(usize, usize), String> {
let effective_pat: String = if sub.pattern.is_empty() {
editor
.last_search()
.map(str::to_owned)
.ok_or_else(|| "no previous regular expression".to_string())?
} else {
sub.pattern.clone()
};
let case_insensitive = if sub.case_sensitive {
false
} else if sub.case_insensitive {
true
} else {
editor.settings().ignore_case
};
let pattern = if case_insensitive {
format!("(?i){effective_pat}")
} else {
effective_pat.clone()
};
let regex = regex::Regex::new(&pattern).map_err(|e| format!("bad pattern: {e}"))?;
editor.push_undo();
let scope = Range::or_default(range, Range::single(editor.cursor().0));
let (range_start, range_end) = (scope.start, scope.end);
let mut new_lines: Vec<String> = editor.buffer().lines().to_vec();
let mut count = 0usize;
let mut lines_changed = 0usize;
let mut last_changed_row = range_start;
let clamp = range_end.min(new_lines.len().saturating_sub(1));
for (i, line) in new_lines[range_start..=clamp].iter_mut().enumerate() {
let (replaced, n) = regex_replace(®ex, line, &sub.replacement, sub.global);
if n > 0 {
*line = replaced;
count += n;
lines_changed += 1;
last_changed_row = range_start + i;
}
}
if count == 0 {
editor.pop_last_undo();
return Ok((0, 0));
}
editor.buffer_mut().replace_all(&new_lines.join("\n"));
editor
.buffer_mut()
.set_cursor(hjkl_buffer::Position::new(last_changed_row, 0));
mark_dirty_after_ex(editor);
editor.set_last_search(Some(effective_pat), true);
Ok((count, lines_changed))
}
fn regex_replace(
regex: ®ex::Regex,
text: &str,
replacement: &str,
global: bool,
) -> (String, usize) {
let matches = regex.find_iter(text).count();
if matches == 0 {
return (text.to_string(), 0);
}
let rep = expand_vim_replacement(replacement);
let replaced = if global {
regex.replace_all(text, rep.as_str()).into_owned()
} else {
regex.replace(text, rep.as_str()).into_owned()
};
let count = if global { matches } else { 1 };
(replaced, count)
}
fn expand_vim_replacement(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\' {
if let Some(&next) = chars.peek() {
out.push('\\');
out.push(next);
chars.next();
} else {
out.push('\\');
}
} else if c == '&' {
out.push_str("$0");
} else {
out.push(c);
}
}
out
}
fn mark_dirty_after_ex<H: hjkl_engine::Host>(editor: &mut Editor<hjkl_buffer::Buffer, H>) {
editor.mark_content_dirty();
}
#[cfg(test)]
mod resolver_tests {
use super::*;
#[test]
fn resolves_unique_prefix_to_canonical() {
assert_eq!(canonical_command_name("noh"), "nohlsearch");
assert_eq!(canonical_command_name("nohl"), "nohlsearch");
assert_eq!(canonical_command_name("nohls"), "nohlsearch");
assert_eq!(canonical_command_name("nohlsearch"), "nohlsearch");
assert_eq!(canonical_command_name("reg"), "registers");
assert_eq!(canonical_command_name("red"), "redo");
assert_eq!(canonical_command_name("u"), "undo");
assert_eq!(canonical_command_name("e"), "edit");
assert_eq!(canonical_command_name("se"), "set");
}
#[test]
fn keeps_args_intact() {
assert_eq!(canonical_command_name("e foo.txt"), "edit foo.txt");
assert_eq!(canonical_command_name("w /tmp/x"), "write /tmp/x");
assert_eq!(canonical_command_name("s/foo/bar/"), "substitute/foo/bar/");
assert_eq!(canonical_command_name("e!"), "edit!");
assert_eq!(canonical_command_name("q!"), "quit!");
}
#[test]
fn ambiguous_prefix_falls_through() {
assert_eq!(canonical_command_name("f"), "f");
assert_eq!(canonical_command_name("fold"), "fold");
assert_eq!(canonical_command_name("r"), "read");
assert_eq!(canonical_command_name("red"), "redo");
assert_eq!(canonical_command_name("reg"), "registers");
}
#[test]
fn unknown_command_passes_through_unchanged() {
assert_eq!(canonical_command_name("foobar"), "foobar");
assert_eq!(canonical_command_name(""), "");
}
#[test]
fn resolves_multi_buffer_commands() {
assert_eq!(canonical_command_name("bd"), "bdelete");
assert_eq!(canonical_command_name("bn"), "bnext");
assert_eq!(canonical_command_name("ls"), "ls");
assert_eq!(canonical_command_name("bd!"), "bdelete!");
assert_eq!(canonical_command_name("bp"), "bprevious");
assert_eq!(canonical_command_name("wa"), "wall");
assert_eq!(canonical_command_name("qa"), "qall");
assert_eq!(canonical_command_name("wqa"), "wqall");
}
}
#[cfg(all(test, feature = "crossterm"))]
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use hjkl_engine::Editor;
use hjkl_engine::types::{DefaultHost, Options};
fn new(content: &str) -> Editor {
let mut e = Editor::new(
hjkl_buffer::Buffer::new(),
DefaultHost::new(),
Options::default(),
);
e.set_content(content);
e
}
fn type_keys<H: hjkl_engine::Host>(e: &mut Editor<hjkl_buffer::Buffer, H>, keys: &str) {
for c in keys.chars() {
let ev = match c {
'\n' => KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
'\x1b' => KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
ch => KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE),
};
e.handle_key(ev);
}
}
#[test]
fn substitute_current_line() {
let mut e = new("foo foo\nfoo foo");
let effect = run(&mut e, "s/foo/bar/");
assert_eq!(
effect,
ExEffect::Substituted {
count: 1,
lines_changed: 1
}
);
assert_eq!(e.buffer().lines()[0], "bar foo");
assert_eq!(e.buffer().lines()[1], "foo foo");
}
#[test]
fn substitute_current_line_global() {
let mut e = new("foo foo\nfoo");
run(&mut e, "s/foo/bar/g");
assert_eq!(e.buffer().lines()[0], "bar bar");
assert_eq!(e.buffer().lines()[1], "foo");
}
#[test]
fn substitute_whole_buffer_global() {
let mut e = new("foo\nfoo foo\nbar");
let effect = run(&mut e, "%s/foo/xyz/g");
assert_eq!(
effect,
ExEffect::Substituted {
count: 3,
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 substitute_zero_matches_reports_zero() {
let mut e = new("hello");
let effect = run(&mut e, "s/xyz/abc/");
assert_eq!(
effect,
ExEffect::Substituted {
count: 0,
lines_changed: 0
}
);
assert_eq!(e.buffer().lines()[0], "hello");
}
#[test]
fn substitute_respects_case_insensitive_flag() {
let mut e = new("Foo");
let effect = run(&mut e, "s/foo/bar/i");
assert_eq!(
effect,
ExEffect::Substituted {
count: 1,
lines_changed: 1
}
);
assert_eq!(e.buffer().lines()[0], "bar");
}
#[test]
fn substitute_accepts_case_sensitive_flag() {
let mut e = new("Foo foo");
e.settings_mut().ignore_case = true;
let effect = run(&mut e, "s/foo/bar/I");
assert_eq!(
effect,
ExEffect::Substituted {
count: 1,
lines_changed: 1
}
);
assert_eq!(e.buffer().lines()[0], "Foo bar");
}
#[test]
fn substitute_confirm_flag_accepted_not_error() {
let mut e = new("foo");
let effect = run(&mut e, "s/foo/bar/c");
assert_eq!(
effect,
ExEffect::Substituted {
count: 1,
lines_changed: 1
}
);
assert_eq!(e.buffer().lines()[0], "bar");
}
#[test]
fn substitute_empty_pattern_reuses_last_search() {
let mut e = new("hello world");
e.set_last_search(Some("world".to_string()), true);
let effect = run(&mut e, "s//planet/");
assert_eq!(
effect,
ExEffect::Substituted {
count: 1,
lines_changed: 1
}
);
assert_eq!(e.buffer().lines()[0], "hello planet");
}
#[test]
fn substitute_accepts_alternate_separator() {
let mut e = new("/usr/local/bin");
run(&mut e, "s#/usr#/opt#");
assert_eq!(e.buffer().lines()[0], "/opt/local/bin");
}
#[test]
fn substitute_ampersand_in_replacement() {
let mut e = new("foo");
run(&mut e, "s/foo/[&]/");
assert_eq!(e.buffer().lines()[0], "[foo]");
}
#[test]
fn goto_line() {
let mut e = new("a\nb\nc\nd");
run(&mut e, "3");
assert_eq!(e.cursor().0, 2);
}
#[test]
fn quit_and_force_quit() {
let mut e = new("");
assert_eq!(
run(&mut e, "q"),
ExEffect::Quit {
force: false,
save: false
}
);
assert_eq!(
run(&mut e, "q!"),
ExEffect::Quit {
force: true,
save: false
}
);
assert_eq!(
run(&mut e, "wq"),
ExEffect::Quit {
force: false,
save: true
}
);
}
#[test]
fn qall_and_wqall_dispatch() {
let mut e = new("");
assert_eq!(
run(&mut e, "qa"),
ExEffect::Quit {
force: false,
save: false
}
);
assert_eq!(
run(&mut e, "qa!"),
ExEffect::Quit {
force: true,
save: false
}
);
assert_eq!(
run(&mut e, "qall"),
ExEffect::Quit {
force: false,
save: false
}
);
assert_eq!(
run(&mut e, "qall!"),
ExEffect::Quit {
force: true,
save: false
}
);
assert_eq!(
run(&mut e, "wqa"),
ExEffect::Quit {
force: false,
save: true
}
);
assert_eq!(
run(&mut e, "wqall"),
ExEffect::Quit {
force: false,
save: true
}
);
assert_eq!(
run(&mut e, "wqall!"),
ExEffect::Quit {
force: false,
save: true
}
);
}
#[test]
fn write_returns_save() {
let mut e = new("");
assert_eq!(run(&mut e, "w"), ExEffect::Save);
}
#[test]
fn write_path_returns_save_as() {
let mut e = new("");
assert_eq!(
run(&mut e, "w /tmp/foo.txt"),
ExEffect::SaveAs("/tmp/foo.txt".to_string())
);
}
#[test]
fn noh_is_ok() {
let mut e = new("");
assert_eq!(run(&mut e, "noh"), ExEffect::Ok);
}
#[test]
fn registers_lists_unnamed_and_named() {
let mut e = new("hello world");
type_keys(&mut e, "yw");
type_keys(&mut e, "\"ayw");
let info = match run(&mut e, "reg") {
ExEffect::Info(s) => s,
other => panic!("expected Info, got {other:?}"),
};
assert!(info.starts_with("--- Registers ---"));
assert!(info.contains("\"\""));
assert!(info.contains("\"0"));
assert!(info.contains("\"a"));
assert_eq!(run(&mut e, "registers"), ExEffect::Info(info));
}
#[test]
fn registers_empty_state() {
let mut e = new("hi");
let info = match run(&mut e, "reg") {
ExEffect::Info(s) => s,
other => panic!("expected Info, got {other:?}"),
};
assert!(info.contains("(no registers set)"));
}
#[test]
fn marks_lists_user_and_special() {
let mut e = new("alpha\nbeta\ngamma");
type_keys(&mut e, "ma");
type_keys(&mut e, "jjmb");
type_keys(&mut e, "iX");
let info = match run(&mut e, "marks") {
ExEffect::Info(s) => s,
other => panic!("expected Info, got {other:?}"),
};
assert!(info.starts_with("--- Marks ---"));
assert!(info.contains(" a "));
assert!(info.contains(" b "));
assert!(info.contains(" . "));
}
#[test]
fn undo_alias_reverses_last_change() {
let mut e = new("hello");
type_keys(&mut e, "Aworld\x1b");
assert_eq!(e.buffer().lines()[0], "helloworld");
assert_eq!(run(&mut e, "undo"), ExEffect::Ok);
assert_eq!(e.buffer().lines()[0], "hello");
type_keys(&mut e, "Awow\x1b");
assert_eq!(e.buffer().lines()[0], "hellowow");
assert_eq!(run(&mut e, "u"), ExEffect::Ok);
assert_eq!(e.buffer().lines()[0], "hello");
}
#[test]
fn redo_alias_reapplies_undone_change() {
let mut e = new("hi");
type_keys(&mut e, "Athere\x1b");
assert_eq!(e.buffer().lines()[0], "hithere");
run(&mut e, "undo");
assert_eq!(e.buffer().lines()[0], "hi");
assert_eq!(run(&mut e, "redo"), ExEffect::Ok);
assert_eq!(e.buffer().lines()[0], "hithere");
run(&mut e, "u");
assert_eq!(run(&mut e, "red"), ExEffect::Ok);
assert_eq!(e.buffer().lines()[0], "hithere");
}
#[test]
fn marks_empty_state() {
let mut e = new("hi");
let info = match run(&mut e, "marks") {
ExEffect::Info(s) => s,
other => panic!("expected Info, got {other:?}"),
};
assert!(info.contains("(no marks set)"));
}
#[test]
fn sort_alphabetical() {
let mut e = new("banana\napple\ncherry");
assert_eq!(run(&mut e, "sort"), ExEffect::Ok);
assert_eq!(
e.buffer().lines(),
vec!["apple".to_string(), "banana".into(), "cherry".into()]
);
}
#[test]
fn sort_reverse_with_bang() {
let mut e = new("apple\nbanana\ncherry");
run(&mut e, "sort!");
assert_eq!(
e.buffer().lines(),
vec!["cherry".to_string(), "banana".into(), "apple".into()]
);
}
#[test]
fn sort_unique() {
let mut e = new("foo\nbar\nfoo\nbaz\nbar");
run(&mut e, "sort u");
assert_eq!(
e.buffer().lines(),
vec!["bar".to_string(), "baz".into(), "foo".into()]
);
}
#[test]
fn sort_numeric() {
let mut e = new("10\n2\n100\n7");
run(&mut e, "sort n");
assert_eq!(
e.buffer().lines(),
vec!["2".to_string(), "7".into(), "10".into(), "100".into()]
);
}
#[test]
fn sort_ignore_case() {
let mut e = new("Banana\napple\nCherry");
run(&mut e, "sort i");
assert_eq!(
e.buffer().lines(),
vec!["apple".to_string(), "Banana".into(), "Cherry".into()]
);
}
#[test]
fn sort_undo_restores_original_order() {
let mut e = new("c\nb\na");
run(&mut e, "sort");
assert_eq!(e.buffer().lines()[0], "a");
e.undo();
assert_eq!(
e.buffer().lines(),
vec!["c".to_string(), "b".into(), "a".into()]
);
}
#[test]
fn sort_rejects_unknown_flag() {
let mut e = new("a\nb");
match run(&mut e, "sortz") {
ExEffect::Error(msg) => assert!(msg.contains("z")),
other => panic!("expected Error, got {other:?}"),
}
}
#[test]
fn range_sort_partial() {
let mut e = new("z\nc\nb\na\nx");
run(&mut e, "2,4sort");
assert_eq!(
e.buffer().lines(),
vec![
"z".to_string(),
"a".into(),
"b".into(),
"c".into(),
"x".into(),
]
);
}
#[test]
fn range_substitute_partial() {
let mut e = new("foo\nfoo\nfoo\nfoo");
let effect = run(&mut e, "2,3s/foo/bar/");
assert_eq!(
effect,
ExEffect::Substituted {
count: 2,
lines_changed: 2
}
);
assert_eq!(
e.buffer().lines(),
vec!["foo".to_string(), "bar".into(), "bar".into(), "foo".into(),]
);
}
#[test]
fn range_delete_drops_lines() {
let mut e = new("a\nb\nc\nd\ne");
run(&mut e, "2,4d");
assert_eq!(e.buffer().lines(), vec!["a".to_string(), "e".into()]);
}
#[test]
fn percent_substitute_still_works() {
let mut e = new("foo\nfoo");
let effect = run(&mut e, "%s/foo/bar/");
assert_eq!(
effect,
ExEffect::Substituted {
count: 2,
lines_changed: 2
}
);
assert_eq!(e.buffer().lines(), vec!["bar".to_string(), "bar".into()]);
}
#[test]
fn dot_dollar_addresses_resolve() {
let mut e = new("a\nb\nc\nd");
e.jump_cursor(1, 0);
run(&mut e, ".,$d");
assert_eq!(e.buffer().lines(), vec!["a".to_string()]);
}
#[test]
fn mark_address_resolves() {
let mut e = new("a\nb\nc\nd\ne");
e.jump_cursor(1, 0);
type_keys(&mut e, "ma");
e.jump_cursor(3, 0);
type_keys(&mut e, "mb");
run(&mut e, "'a,'bd");
assert_eq!(e.buffer().lines(), vec!["a".to_string(), "e".into()]);
}
#[test]
fn range_global_partial() {
let mut e = new("foo\nfoo\nbar\nfoo\nfoo");
run(&mut e, "2,4g/foo/d");
assert_eq!(
e.buffer().lines(),
vec!["foo".to_string(), "bar".into(), "foo".into()]
);
}
#[test]
fn bare_line_number_jumps() {
let mut e = new("a\nb\nc\nd");
run(&mut e, "3");
assert_eq!(e.cursor().0, 2);
}
#[test]
fn set_shiftwidth_changes_indent_step() {
let mut e = new("hello");
run(&mut e, "set sw=4");
assert_eq!(e.settings().shiftwidth, 4);
type_keys(&mut e, ">>");
assert_eq!(e.buffer().lines()[0], " hello");
}
#[test]
fn set_tabstop_stored() {
let mut e = new("");
run(&mut e, "set tabstop=4");
assert_eq!(e.settings().tabstop, 4);
}
#[test]
fn set_ignorecase_affects_substitute() {
let mut e = new("Hello");
let effect = run(&mut e, "s/h/X/");
assert_eq!(
effect,
ExEffect::Substituted {
count: 0,
lines_changed: 0
}
);
run(&mut e, "set ignorecase");
assert!(e.settings().ignore_case);
let effect = run(&mut e, "s/h/X/");
assert_eq!(
effect,
ExEffect::Substituted {
count: 1,
lines_changed: 1
}
);
assert_eq!(e.buffer().lines()[0], "Xello");
}
#[test]
fn set_no_prefix_disables_boolean() {
let mut e = new("x");
run(&mut e, "set ic");
assert!(e.settings().ignore_case);
run(&mut e, "set noic");
assert!(!e.settings().ignore_case);
}
#[test]
fn set_zero_shiftwidth_errors() {
let mut e = new("x");
match run(&mut e, "set sw=0") {
ExEffect::Error(msg) => assert!(msg.contains("shiftwidth")),
other => panic!("expected Error, got {other:?}"),
}
}
#[test]
fn set_unknown_option_errors() {
let mut e = new("x");
match run(&mut e, "set bogus") {
ExEffect::Error(msg) => assert!(msg.contains("bogus")),
other => panic!("expected Error, got {other:?}"),
}
}
#[test]
fn bare_set_reports_current_values() {
let mut e = new("x");
match run(&mut e, "set") {
ExEffect::Info(msg) => {
assert!(msg.contains("shiftwidth=4"), "got: {msg}");
assert!(msg.contains("ignorecase=off"), "got: {msg}");
assert!(msg.contains("wrap=off"), "got: {msg}");
}
other => panic!("expected Info, got {other:?}"),
}
}
#[test]
fn set_wrap_flips_to_char_mode() {
let mut e = new("x");
run(&mut e, "set wrap");
assert_eq!(e.settings().wrap, hjkl_buffer::Wrap::Char);
}
#[test]
fn set_nowrap_resets() {
let mut e = new("x");
run(&mut e, "set wrap");
run(&mut e, "set nowrap");
assert_eq!(e.settings().wrap, hjkl_buffer::Wrap::None);
}
#[test]
fn set_linebreak_flips_to_word_mode() {
let mut e = new("x");
run(&mut e, "set linebreak");
assert_eq!(e.settings().wrap, hjkl_buffer::Wrap::Word);
}
#[test]
fn set_wrap_after_linebreak_keeps_word_mode() {
let mut e = new("x");
run(&mut e, "set linebreak");
run(&mut e, "set wrap");
assert_eq!(e.settings().wrap, hjkl_buffer::Wrap::Word);
}
#[test]
fn set_nolinebreak_drops_to_char_when_wrap_on() {
let mut e = new("x");
run(&mut e, "set linebreak");
run(&mut e, "set nolinebreak");
assert_eq!(e.settings().wrap, hjkl_buffer::Wrap::Char);
}
#[test]
fn foldsyntax_applies_host_supplied_ranges() {
let mut e = new("a\nb\nc\nd\ne");
e.set_syntax_fold_ranges(vec![(0, 2), (3, 4)]);
match run(&mut e, "foldsyntax") {
ExEffect::Info(msg) => assert!(msg.contains("2 fold")),
other => panic!("expected Info, got {other:?}"),
}
let folds = e.buffer().folds();
assert_eq!(folds.len(), 2);
assert!(folds.iter().any(|f| f.start_row == 0 && f.end_row == 2));
assert!(folds.iter().any(|f| f.start_row == 3 && f.end_row == 4));
}
#[test]
fn foldsyntax_no_ranges_reports_info() {
let mut e = new("a\nb");
match run(&mut e, "foldsyntax") {
ExEffect::Info(msg) => assert!(msg.contains("no syntax block")),
other => panic!("expected Info, got {other:?}"),
}
}
#[test]
fn foldsyntax_short_alias() {
let mut e = new("a\nb\nc");
e.set_syntax_fold_ranges(vec![(0, 2)]);
assert!(matches!(run(&mut e, "folds"), ExEffect::Info(_)));
assert_eq!(e.buffer().folds().len(), 1);
}
#[test]
fn foldindent_creates_fold_for_indented_block() {
let mut e = new("SELECT *\n FROM t\n WHERE x = 1\nORDER BY id");
match run(&mut e, "foldindent") {
ExEffect::Info(msg) => assert!(msg.contains("1 fold")),
other => panic!("expected Info, got {other:?}"),
}
let folds = e.buffer().folds();
assert_eq!(folds.len(), 1);
assert_eq!(folds[0].start_row, 0);
assert_eq!(folds[0].end_row, 2);
assert!(folds[0].closed);
}
#[test]
fn foldindent_no_blocks_reports_info() {
let mut e = new("a\nb\nc");
match run(&mut e, "foldindent") {
ExEffect::Info(msg) => assert!(msg.contains("no indented blocks")),
other => panic!("expected Info, got {other:?}"),
}
assert!(e.buffer().folds().is_empty());
}
#[test]
fn foldindent_handles_nested_blocks() {
let mut e = new("outer\n mid\n inner1\n inner2\n back\noutmost");
run(&mut e, "foldindent");
let folds = e.buffer().folds();
assert_eq!(folds.len(), 2);
assert_eq!(folds[0].start_row, 0);
assert_eq!(folds[0].end_row, 4);
assert_eq!(folds[1].start_row, 1);
assert_eq!(folds[1].end_row, 3);
}
#[test]
fn foldindent_skips_blanks_inside_block() {
let mut e = new("head\n body1\n\n body2\nfoot");
run(&mut e, "foldindent");
let folds = e.buffer().folds();
assert_eq!(folds.len(), 1);
assert_eq!(folds[0].start_row, 0);
assert_eq!(folds[0].end_row, 3);
}
#[test]
fn foldindent_short_alias() {
let mut e = new("a\n b\nc");
assert!(matches!(run(&mut e, "foldi"), ExEffect::Info(_)));
assert_eq!(e.buffer().folds().len(), 1);
}
#[test]
fn read_file_inserts_below_current_row() {
let dir = std::env::temp_dir();
let path = dir.join(format!("hjkl_read_{}.sql", std::process::id()));
std::fs::write(&path, "SELECT 1;\nSELECT 2;\n").unwrap();
let mut e = new("alpha\nbeta");
e.jump_cursor(0, 0);
let cmd = format!("r {}", path.display());
assert_eq!(run(&mut e, &cmd), ExEffect::Ok);
assert_eq!(
e.buffer().lines(),
vec![
"alpha".to_string(),
"SELECT 1;".into(),
"SELECT 2;".into(),
"beta".into(),
]
);
assert_eq!(e.cursor(), (1, 0));
std::fs::remove_file(&path).ok();
}
#[test]
fn shell_filter_replaces_range() {
let mut e = new("c\nb\na");
assert_eq!(run(&mut e, "%!sort"), ExEffect::Ok);
assert_eq!(
e.buffer().lines(),
vec!["a".to_string(), "b".into(), "c".into()]
);
}
#[test]
fn shell_filter_partial_range() {
let mut e = new("head\ngamma\nbeta\nalpha\ntail");
run(&mut e, "2,4!sort");
assert_eq!(
e.buffer().lines(),
vec![
"head".to_string(),
"alpha".into(),
"beta".into(),
"gamma".into(),
"tail".into(),
]
);
}
#[test]
fn shell_filter_undo_restores() {
let mut e = new("c\nb\na");
let before: Vec<String> = e.buffer().lines().to_vec();
run(&mut e, "%!sort");
e.undo();
assert_eq!(e.buffer().lines(), before);
}
#[test]
fn shell_command_no_range_returns_info() {
let mut e = new("buffer stays put");
match run(&mut e, "!echo from-shell") {
ExEffect::Info(msg) => assert!(msg.contains("from-shell")),
other => panic!("expected Info, got {other:?}"),
}
assert_eq!(e.buffer().lines()[0], "buffer stays put");
}
#[test]
fn shell_filter_failing_command_errors() {
let mut e = new("a\nb");
match run(&mut e, "%!exit 5") {
ExEffect::Error(msg) => assert!(msg.contains("exited 5"), "msg was: {msg}"),
other => panic!("expected Error, got {other:?}"),
}
}
#[test]
fn shell_bang_empty_command_errors() {
let mut e = new("a");
match run(&mut e, "!") {
ExEffect::Error(msg) => assert!(msg.contains("shell command")),
other => panic!("expected Error, got {other:?}"),
}
}
#[test]
fn read_bang_inserts_command_stdout() {
let mut e = new("alpha\nbeta");
e.jump_cursor(0, 0);
assert_eq!(run(&mut e, "r !echo hello"), ExEffect::Ok);
assert_eq!(
e.buffer().lines(),
vec!["alpha".to_string(), "hello".into(), "beta".into()]
);
}
#[test]
fn read_bang_failing_command_errors() {
let mut e = new("hi");
match run(&mut e, "r !exit 7") {
ExEffect::Error(msg) => assert!(msg.contains("exited 7")),
other => panic!("expected Error, got {other:?}"),
}
}
#[test]
fn read_bang_empty_command_errors() {
let mut e = new("hi");
match run(&mut e, "r !") {
ExEffect::Error(msg) => assert!(msg.contains("shell command")),
other => panic!("expected Error, got {other:?}"),
}
}
#[test]
fn read_file_alias_read_works() {
let dir = std::env::temp_dir();
let path = dir.join(format!("hjkl_read_alias_{}.sql", std::process::id()));
std::fs::write(&path, "x").unwrap();
let mut e = new("");
let cmd = format!("read {}", path.display());
run(&mut e, &cmd);
assert_eq!(e.buffer().lines(), vec!["".to_string(), "x".into()]);
std::fs::remove_file(&path).ok();
}
#[test]
fn read_file_missing_path_errors() {
let mut e = new("a");
match run(&mut e, "r /nonexistent/path/sqeel_test_xyzzy") {
ExEffect::Error(msg) => assert!(msg.contains("cannot read")),
other => panic!("expected Error, got {other:?}"),
}
}
#[test]
fn read_file_undo_restores() {
let dir = std::env::temp_dir();
let path = dir.join(format!("hjkl_read_undo_{}.sql", std::process::id()));
std::fs::write(&path, "ins\n").unwrap();
let mut e = new("a\nb");
e.jump_cursor(0, 0);
run(&mut e, &format!("r {}", path.display()));
assert_eq!(e.buffer().lines().len(), 3);
e.undo();
assert_eq!(e.buffer().lines(), vec!["a".to_string(), "b".into()]);
std::fs::remove_file(&path).ok();
}
#[test]
fn unknown_command() {
let mut e = new("");
match run(&mut e, "blargh") {
ExEffect::Unknown(cmd) => assert_eq!(cmd, "blargh"),
other => panic!("expected Unknown, got {other:?}"),
}
}
#[test]
fn bad_substitute_pattern() {
let mut e = new("hi");
match run(&mut e, "s/[unterminated/foo/") {
ExEffect::Error(_) => {}
other => panic!("expected Error, got {other:?}"),
}
}
#[test]
fn substitute_escaped_separator() {
let mut e = new("a/b/c");
let effect = run(&mut e, "s/\\//-/g");
assert_eq!(
effect,
ExEffect::Substituted {
count: 2,
lines_changed: 1
}
);
assert_eq!(e.buffer().lines()[0], "a-b-c");
}
#[test]
fn global_delete_drops_matching_rows() {
let mut e = new("keep1\nDROP1\nkeep2\nDROP2\nkeep3");
let effect = run(&mut e, "g/DROP/d");
assert_eq!(
effect,
ExEffect::Substituted {
count: 2,
lines_changed: 2
}
);
assert_eq!(
e.buffer().lines(),
&[
"keep1".to_string(),
"keep2".to_string(),
"keep3".to_string()
]
);
}
#[test]
fn global_negated_drops_non_matching_rows() {
let mut e = new("keep1\nother\nkeep2");
let effect = run(&mut e, "v/keep/d");
assert_eq!(
effect,
ExEffect::Substituted {
count: 1,
lines_changed: 1
}
);
assert_eq!(
e.buffer().lines(),
&["keep1".to_string(), "keep2".to_string()]
);
}
#[test]
fn global_with_regex_pattern() {
let mut e = new("foo bar\nbaz qux\nfoo baz\nbaz");
let effect = run(&mut e, r"g/^foo/d");
assert_eq!(
effect,
ExEffect::Substituted {
count: 2,
lines_changed: 2
}
);
assert_eq!(
e.buffer().lines(),
&["baz qux".to_string(), "baz".to_string()]
);
}
#[test]
fn global_no_matches_reports_zero() {
let mut e = new("hello\nworld");
let effect = run(&mut e, "g/xyz/d");
assert_eq!(
effect,
ExEffect::Substituted {
count: 0,
lines_changed: 0
}
);
assert_eq!(e.buffer().lines().len(), 2);
}
#[test]
fn global_unsupported_command_errors_out() {
let mut e = new("foo\nbar");
let effect = run(&mut e, "g/foo/p");
assert!(matches!(effect, ExEffect::Error(_)));
}
#[test]
fn format_jumps_empty() {
let e = new("hello");
let info = match run(&mut { e }, "jumps") {
ExEffect::Info(s) => s,
other => panic!("expected Info, got {other:?}"),
};
assert_eq!(info, "(no jumps recorded)");
}
#[test]
fn format_jumps_with_entries() {
let mut e = new("line1\nline2\nline3\nline4\nline5");
type_keys(&mut e, "gg");
type_keys(&mut e, "G");
type_keys(&mut e, "gg");
let info = match run(&mut e, "jumps") {
ExEffect::Info(s) => s,
other => panic!("expected Info, got {other:?}"),
};
assert!(info.contains("--- Jump list ---"), "header missing: {info}");
assert!(info.contains("jump"), "column header missing: {info}");
}
#[test]
fn format_changes_empty() {
let e = new("hello");
let info = match run(&mut { e }, "changes") {
ExEffect::Info(s) => s,
other => panic!("expected Info, got {other:?}"),
};
assert_eq!(info, "(no changes recorded)");
}
#[test]
fn format_changes_with_edits() {
let mut e = new("hello\nworld");
type_keys(&mut e, "Afoo\x1b");
type_keys(&mut e, "jAbar\x1b");
let info = match run(&mut e, "changes") {
ExEffect::Info(s) => s,
other => panic!("expected Info, got {other:?}"),
};
assert!(
info.contains("--- Change list ---"),
"header missing: {info}"
);
assert!(info.contains("change"), "column header missing: {info}");
}
#[test]
fn slash_pat_search_address_jumps_forward() {
let mut e = new("alpha\nbeta\nfoo here\ndelta\nfoo also");
let effect = run(&mut e, "/foo");
assert_eq!(effect, ExEffect::Ok);
assert_eq!(e.cursor().0, 2);
e.goto_line(1);
let effect = run(&mut e, "/foo/");
assert_eq!(effect, ExEffect::Ok);
assert_eq!(e.cursor().0, 2);
}
#[test]
fn question_pat_search_address_jumps_backward() {
let mut e = new("foo first\nbeta\nfoo middle\ndelta\nlast");
e.goto_line(5); let effect = run(&mut e, "?foo");
assert_eq!(effect, ExEffect::Ok);
assert_eq!(
e.cursor().0,
2,
"?foo from row 4 must land on previous match (row 2)"
);
e.goto_line(5);
let effect = run(&mut e, "?foo?");
assert_eq!(effect, ExEffect::Ok);
assert_eq!(e.cursor().0, 2);
}
#[test]
fn search_address_persists_direction_for_n() {
let mut e = new("foo a\nbeta\nfoo b\ndelta\nfoo c");
e.goto_line(5); run(&mut e, "?foo");
assert_eq!(e.last_search(), Some("foo"));
assert!(!e.last_search_forward(), "?foo must persist backward dir");
run(&mut e, "/foo");
assert!(e.last_search_forward(), "/foo must persist forward dir");
}
#[test]
fn empty_search_address_reuses_last_pattern() {
let mut e = new("alpha\nfoo a\nbar\nfoo b");
run(&mut e, "/foo");
assert_eq!(e.cursor().0, 1);
e.search_advance_forward(true); e.goto_line(1); let effect = run(&mut e, "/");
assert_eq!(effect, ExEffect::Ok);
assert_eq!(e.cursor().0, 1, "/ alone should re-find first match");
}
#[test]
fn empty_search_address_with_no_history_errors() {
let mut e = new("foo");
let effect = run(&mut e, "?");
assert!(matches!(effect, ExEffect::Error(_)));
}
}