extern crate self as escriba_command;
use std::collections::HashMap;
use escriba_buffer::BufferSet;
use escriba_core::BufferId;
use escriba_mode::ModalState;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum CommandError {
#[error("command not found: {0}")]
NotFound(String),
#[error("command failed: {0}")]
Failed(String),
#[error("buffer: {0}")]
Buffer(#[from] escriba_buffer::BufferError),
}
pub type Result<T> = std::result::Result<T, CommandError>;
pub struct EditContext<'a> {
pub buffers: &'a mut BufferSet,
pub active: Option<BufferId>,
pub state: &'a mut ModalState,
pub quit_requested: &'a mut bool,
}
pub type CommandFn = fn(&mut EditContext<'_>, &[String]) -> Result<()>;
#[derive(Debug, Clone)]
pub enum Handler {
Native(CommandFn),
Action(String),
}
#[derive(Debug, Clone)]
pub struct Command {
pub name: String,
pub description: String,
pub handler: Handler,
}
impl Command {
pub fn native(
name: impl Into<String>,
description: impl Into<String>,
handler: CommandFn,
) -> Self {
Self {
name: name.into(),
description: description.into(),
handler: Handler::Native(handler),
}
}
pub fn action(
name: impl Into<String>,
description: impl Into<String>,
action: impl Into<String>,
) -> Self {
Self {
name: name.into(),
description: description.into(),
handler: Handler::Action(action.into()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct CommandSpec {
pub name: String,
pub description: String,
#[serde(default)]
pub args: Vec<CommandArgSpec>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct CommandArgSpec {
pub name: String,
pub description: String,
#[serde(default)]
pub required: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub variants: Vec<String>,
}
#[derive(Debug, Default, Clone)]
pub struct CommandRegistry {
commands: HashMap<String, Command>,
}
impl CommandRegistry {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn default_set() -> Self {
let mut r = Self::new();
r.register(Command::native(
"save",
"Write the active buffer to disk",
cmd_save,
));
r.register(Command::native("quit", "Exit the editor", cmd_quit));
r.register(Command::native("undo", "Undo the last change", cmd_undo));
r.register(Command::native(
"redo",
"Redo the last undone change",
cmd_redo,
));
r.register(Command::native(
"buffer-info",
"Print the active buffer summary",
cmd_buffer_info,
));
r
}
pub fn register(&mut self, command: Command) {
self.commands.insert(command.name.clone(), command);
}
#[must_use]
pub fn contains(&self, name: &str) -> bool {
self.commands.contains_key(name)
}
#[must_use]
pub fn len(&self) -> usize {
self.commands.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.commands.is_empty()
}
pub fn run(&self, name: &str, ctx: &mut EditContext<'_>, args: &[String]) -> Result<()> {
let cmd = self
.commands
.get(name)
.ok_or_else(|| CommandError::NotFound(name.to_string()))?;
match &cmd.handler {
Handler::Native(f) => f(ctx, args),
Handler::Action(sym) => run_action(sym, ctx, args),
}
}
#[must_use]
pub fn names(&self) -> Vec<&str> {
let mut v: Vec<&str> = self.commands.keys().map(String::as_str).collect();
v.sort_unstable();
v
}
#[must_use]
pub fn specs(&self) -> Vec<CommandSpec> {
let mut out: Vec<CommandSpec> = self
.commands
.values()
.map(|c| CommandSpec {
name: c.name.to_string(),
description: c.description.to_string(),
args: Vec::new(),
})
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
}
fn run_action(sym: &str, ctx: &mut EditContext<'_>, args: &[String]) -> Result<()> {
match sym {
"buffer.save" | "buffer.write" => cmd_save(ctx, args),
"buffer.write-all" => cmd_write_all(ctx, args),
"buffer.undo" => cmd_undo(ctx, args),
"buffer.redo" => cmd_redo(ctx, args),
"buffer.info" => cmd_buffer_info(ctx, args),
"editor.quit" => cmd_quit(ctx, args),
_ => Ok(()),
}
}
fn cmd_write_all(ctx: &mut EditContext<'_>, _args: &[String]) -> Result<()> {
for id in ctx.buffers.ids() {
if let Some(buf) = ctx.buffers.get_mut(id) {
if buf.modified && buf.path.is_some() {
let _ = buf.save();
}
}
}
Ok(())
}
fn cmd_save(ctx: &mut EditContext<'_>, _args: &[String]) -> Result<()> {
let id = ctx
.active
.ok_or_else(|| CommandError::Failed("no active buffer".into()))?;
let buf = ctx
.buffers
.get_mut(id)
.ok_or_else(|| CommandError::Failed("active buffer gone".into()))?;
buf.save()?;
Ok(())
}
fn cmd_quit(ctx: &mut EditContext<'_>, _: &[String]) -> Result<()> {
*ctx.quit_requested = true;
Ok(())
}
fn cmd_undo(ctx: &mut EditContext<'_>, _: &[String]) -> Result<()> {
let id = ctx
.active
.ok_or_else(|| CommandError::Failed("no active buffer".into()))?;
ctx.buffers
.get_mut(id)
.ok_or_else(|| CommandError::Failed("gone".into()))?
.undo()?;
Ok(())
}
fn cmd_redo(ctx: &mut EditContext<'_>, _: &[String]) -> Result<()> {
let id = ctx
.active
.ok_or_else(|| CommandError::Failed("no active buffer".into()))?;
ctx.buffers
.get_mut(id)
.ok_or_else(|| CommandError::Failed("gone".into()))?
.redo()?;
Ok(())
}
fn cmd_buffer_info(ctx: &mut EditContext<'_>, _: &[String]) -> Result<()> {
let id = ctx
.active
.ok_or_else(|| CommandError::Failed("no active buffer".into()))?;
let buf = ctx
.buffers
.get(id)
.ok_or_else(|| CommandError::Failed("gone".into()))?;
eprintln!(
"buffer {} — {} line(s), {} char(s){}",
id,
buf.line_count(),
buf.char_count(),
if buf.modified { " [modified]" } else { "" }
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_set_is_populated() {
let r = CommandRegistry::default_set();
let names = r.names();
assert!(names.contains(&"save"));
assert!(names.contains(&"quit"));
}
#[test]
fn specs_are_sorted() {
let r = CommandRegistry::default_set();
let specs = r.specs();
assert!(specs.windows(2).all(|w| w[0].name <= w[1].name));
}
#[test]
fn not_found_errors() {
let r = CommandRegistry::new();
let mut bufs = BufferSet::new();
let mut state = ModalState::new();
let mut quit = false;
let mut ctx = EditContext {
buffers: &mut bufs,
active: None,
state: &mut state,
quit_requested: &mut quit,
};
let err = r.run("nope", &mut ctx, &[]).unwrap_err();
assert!(matches!(err, CommandError::NotFound(_)));
}
#[test]
fn action_command_registers_and_is_invokable() {
let mut r = CommandRegistry::new();
r.register(Command::action(
"w-all",
"Write every modified buffer",
"buffer.write-all",
));
assert!(r.contains("w-all"));
let mut bufs = BufferSet::new();
let id = bufs.scratch("dirty");
bufs.get_mut(id).unwrap().modified = true;
let mut state = ModalState::new();
let mut quit = false;
let mut ctx = EditContext {
buffers: &mut bufs,
active: Some(id),
state: &mut state,
quit_requested: &mut quit,
};
r.run("w-all", &mut ctx, &[]).expect("action command runs");
}
#[test]
fn unknown_action_symbol_is_inert_not_fatal() {
let mut r = CommandRegistry::new();
r.register(Command::action("pick", "Pick a file", "picker.files"));
let mut bufs = BufferSet::new();
let mut state = ModalState::new();
let mut quit = false;
let mut ctx = EditContext {
buffers: &mut bufs,
active: None,
state: &mut state,
quit_requested: &mut quit,
};
r.run("pick", &mut ctx, &[]).expect("unknown action is inert");
}
#[test]
fn action_naming_a_command_is_inert_not_recursive() {
let mut r = CommandRegistry::new();
r.register(Command::action("alias", "aliases save by name", "save"));
let mut bufs = BufferSet::new();
let id = bufs.scratch("dirty");
bufs.get_mut(id).unwrap().modified = true;
let mut state = ModalState::new();
let mut quit = false;
{
let mut ctx = EditContext {
buffers: &mut bufs,
active: Some(id),
state: &mut state,
quit_requested: &mut quit,
};
r.run("alias", &mut ctx, &[]).expect("alias runs inertly");
}
assert!(
bufs.get(id).unwrap().modified,
"command-name alias must be inert — save did not fire (no recursion)",
);
}
#[test]
fn action_quit_sets_quit_flag() {
let mut r = CommandRegistry::new();
r.register(Command::action("bye", "Quit", "editor.quit"));
let mut bufs = BufferSet::new();
let mut state = ModalState::new();
let mut quit = false;
let mut ctx = EditContext {
buffers: &mut bufs,
active: None,
state: &mut state,
quit_requested: &mut quit,
};
r.run("bye", &mut ctx, &[]).expect("quit action runs");
assert!(*ctx.quit_requested, "editor.quit sets the typed quit flag");
}
}