extern crate self as escriba_command;
use std::collections::HashMap;
use escriba_core::BufferId;
use escriba_madoguchi::cap::{Buffers, Cursor, Syntax};
use escriba_madoguchi::{BufferView, Native, Negai, Outcome, Snapshot, View, caps, erase};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum CommandError {
#[error("command not found: {0}")]
NotFound(String),
#[error("action `{0}` is declared but not implemented yet")]
Unhandled(String),
#[error("command failed: {0}")]
Failed(String),
#[error("alias cycle resolving `{0}`")]
AliasCycle(String),
}
pub type Result<T> = std::result::Result<T, CommandError>;
pub type CommandFn = fn(&dyn Snapshot, &[String]) -> Outcome;
#[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",
erase::<Save>(),
));
r.register(Command::native("quit", "Exit the editor", erase::<Quit>()));
r.register(Command::native(
"buffer.next",
"Go to the next buffer",
erase::<BufferNext>(),
));
r.register(Command::native(
"buffer.prev",
"Go to the previous buffer",
erase::<BufferPrev>(),
));
r.register(Command::native(
"buffer.delete",
"Close the active buffer",
erase::<BufferDelete>(),
));
r.register(Command::native(
"picker.buffers",
"Pick an open buffer",
erase::<OpenPicker<false>>(),
));
r.register(Command::native(
"picker.commands",
"Pick a command",
erase::<OpenPicker<true>>(),
));
r.register(Command::native(
"picker.help",
"Search every keybinding",
erase::<HelpPicker>(),
));
r.register(Command::native(
"picker.grep",
"Search the project for a pattern",
erase::<GrepPicker>(),
));
r.register(Command::native(
"picker.files",
"Pick a file under the working directory",
erase::<WalkPicker<false>>(),
));
r.register(Command::native(
"picker.project",
"Pick a project root",
erase::<WalkPicker<true>>(),
));
r.register(Command::native(
"todo.next",
"Go to the next TODO/FIXME marker",
erase::<TodoWalk<true>>(),
));
r.register(Command::native(
"todo.prev",
"Go to the previous TODO/FIXME marker",
erase::<TodoWalk<false>>(),
));
for name in ["comment.toggle-line", "comment.toggle-block"] {
r.register(Command::native(
name,
"Toggle the comment on the current line",
erase::<CommentToggle>(),
));
}
for alias in ["noh", "nohl", "nohlsearch"] {
r.register(Command::action(
alias,
"Stop highlighting matches, keep the pattern",
"search.clear-highlight",
));
}
r.register(Command::native(
"undo",
"Undo the last change",
erase::<Undo>(),
));
r.register(Command::native(
"redo",
"Redo the last undone change",
erase::<Redo>(),
));
r.register(Command::native(
"buffer-info",
"Print the active buffer summary",
erase::<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, snap: &dyn Snapshot, args: &[String]) -> Result<Outcome> {
self.run_bounded(name, snap, args, ALIAS_FUEL)
}
fn run_bounded(
&self,
name: &str,
snap: &dyn Snapshot,
args: &[String],
fuel: u8,
) -> Result<Outcome> {
let Some(fuel) = fuel.checked_sub(1) else {
return Err(CommandError::AliasCycle(name.to_string()));
};
let cmd = self
.commands
.get(name)
.ok_or_else(|| CommandError::NotFound(name.to_string()))?;
match &cmd.handler {
Handler::Native(f) => Ok(f(snap, args)),
Handler::Action(sym) => match builtin_action(sym) {
Some(f) => Ok(f(snap, args)),
None if sym != name && self.commands.contains_key(sym.as_str()) => {
self.run_bounded(sym, snap, args, fuel)
}
None => Err(CommandError::Unhandled(sym.to_string())),
},
}
}
#[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
}
}
const ALIAS_FUEL: u8 = 8;
fn builtin_action(sym: &str) -> Option<CommandFn> {
Some(match sym {
"buffer.save" | "buffer.write" => erase::<Save>(),
"buffer.write-all" => erase::<WriteAll>(),
"buffer.undo" => erase::<Undo>(),
"buffer.redo" => erase::<Redo>(),
"buffer.info" => erase::<Info>(),
"editor.quit" => erase::<Quit>(),
"search.clear-highlight" => erase::<Noh>(),
_ => return None,
})
}
fn active_or_decline(b: &escriba_madoguchi::snapshot::Buffers<'_>) -> Result2<BufferId> {
b.active()
.map(BufferView::id)
.ok_or_else(|| Outcome::declined("no active buffer"))
}
type Result2<T> = std::result::Result<T, Outcome>;
struct WriteAll;
impl Native for WriteAll {
type Reads = caps!(Buffers);
fn run(v: &View<'_, Self::Reads>, _args: &[String]) -> Outcome {
let b = v.buffers();
let slips: Vec<Negai> = b
.ids()
.into_iter()
.filter(|id| {
b.get(*id)
.is_some_and(|x| x.is_modified() && x.path().is_some())
})
.map(|buffer| Negai::Save { buffer })
.collect();
if slips.is_empty() {
return Outcome::declined("no modified files");
}
Outcome::did(slips)
}
}
struct Save;
impl Native for Save {
type Reads = caps!(Buffers);
fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
match active_or_decline(&v.buffers()) {
Ok(buffer) => Outcome::did(vec![Negai::Save { buffer }]),
Err(o) => o,
}
}
}
struct Undo;
impl Native for Undo {
type Reads = caps!(Buffers);
fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
match active_or_decline(&v.buffers()) {
Ok(buffer) => Outcome::did(vec![Negai::Undo { buffer }]),
Err(o) => o,
}
}
}
struct Redo;
impl Native for Redo {
type Reads = caps!(Buffers);
fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
match active_or_decline(&v.buffers()) {
Ok(buffer) => Outcome::did(vec![Negai::Redo { buffer }]),
Err(o) => o,
}
}
}
struct Info;
impl Native for Info {
type Reads = caps!(Buffers);
fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
let b = v.buffers();
let Some(buf) = b.active() else {
return Outcome::declined("no active buffer");
};
let mut m = String::with_capacity(48);
m.push_str("buffer ");
m.push_str(&buf.id().0.to_string());
m.push_str(" — ");
m.push_str(&buf.line_count().to_string());
m.push_str(" line(s)");
if buf.is_modified() {
m.push_str(" [modified]");
}
Outcome::did(vec![Negai::Message(m)])
}
}
struct OpenPicker<const COMMANDS: bool>;
impl<const COMMANDS: bool> Native for OpenPicker<COMMANDS> {
type Reads = caps!();
fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
Outcome::did(vec![Negai::OpenPicker(if COMMANDS {
escriba_madoguchi::PickerSource::Commands
} else {
escriba_madoguchi::PickerSource::Buffers
})])
}
}
struct HelpPicker;
impl Native for HelpPicker {
type Reads = caps!();
fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
Outcome::did(vec![Negai::OpenPicker(
escriba_madoguchi::PickerSource::Help,
)])
}
}
struct GrepPicker;
impl Native for GrepPicker {
type Reads = caps!();
fn run(_v: &View<'_, Self::Reads>, args: &[String]) -> Outcome {
let pattern = args.join(" ");
if pattern.is_empty() {
return Outcome::declined("grep: give a pattern — `:picker.grep <pattern>`");
}
Outcome::did(vec![Negai::GrepProject { pattern }])
}
}
struct WalkPicker<const PROJECT: bool>;
impl<const PROJECT: bool> Native for WalkPicker<PROJECT> {
type Reads = caps!();
fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
Outcome::did(vec![Negai::OpenPicker(if PROJECT {
escriba_madoguchi::PickerSource::Project
} else {
escriba_madoguchi::PickerSource::Files
})])
}
}
struct BufferNext;
impl Native for BufferNext {
type Reads = caps!();
fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
Outcome::did(vec![Negai::CycleBuffer { forward: true }])
}
}
struct BufferPrev;
impl Native for BufferPrev {
type Reads = caps!();
fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
Outcome::did(vec![Negai::CycleBuffer { forward: false }])
}
}
struct BufferDelete;
impl Native for BufferDelete {
type Reads = caps!(Buffers);
fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
match active_or_decline(&v.buffers()) {
Ok(buffer) => Outcome::did(vec![Negai::CloseBuffer(buffer)]),
Err(o) => o,
}
}
}
struct CommentToggle;
impl Native for CommentToggle {
type Reads = caps!(Buffers, Cursor, Syntax);
fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
let Some(ft) = v.syntax().filetype() else {
return Outcome::declined("no filetype for this buffer");
};
let Some(comment) = ft.comment.as_ref() else {
let mut m = String::from("no comment syntax for ");
m.push_str(&ft.name);
return Outcome::declined(m);
};
let b = v.buffers();
let Some(buf) = b.active() else {
return Outcome::declined("no active buffer");
};
let line_no = v.cursor().position().line;
let Some(line) = buf.line(line_no) else {
return Outcome::declined("cursor past the end of the buffer");
};
if line.trim().is_empty() {
return Outcome::declined("nothing on this line");
}
let indent_len = line.len() - line.trim_start().len();
let (indent, body) = line.split_at(indent_len);
let toggled = match comment.strip(body) {
Some(uncommented) => uncommented.to_string(),
None => comment.wrap(body),
};
let mut text = String::with_capacity(indent.len() + toggled.len());
text.push_str(indent);
text.push_str(&toggled);
Outcome::did(vec![Negai::Edit {
buffer: buf.id(),
edit: escriba_core::Edit {
range: escriba_core::Range::new(
escriba_core::Position::new(line_no, 0),
escriba_core::Position::new(
line_no,
u32::try_from(line.chars().count()).unwrap_or(u32::MAX),
),
),
kind: escriba_core::EditKind::Replace { text },
},
}])
}
}
struct TodoWalk<const FORWARD: bool>;
impl<const FORWARD: bool> Native for TodoWalk<FORWARD> {
type Reads = caps!(Buffers);
fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
let b = v.buffers();
let Some(buf) = b.active() else {
return Outcome::declined("no active buffer");
};
let findings = escriba_shirube::scan_markers(buf.id(), &buf.text());
if findings.is_empty() {
return Outcome::declined("no TODO markers in this buffer");
}
Outcome::did(vec![
Negai::PublishFindings {
list: "todo".to_string(),
findings,
},
Negai::WalkList {
list: "todo".to_string(),
forward: FORWARD,
},
])
}
}
struct Quit;
impl Native for Quit {
type Reads = caps!();
fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
Outcome::did(vec![Negai::Quit])
}
}
struct Noh;
impl Native for Noh {
type Reads = caps!();
fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
Outcome::did(vec![Negai::ClearSearchHighlight])
}
}
#[cfg(test)]
mod tests {
use super::*;
use escriba_core::BufferId;
use escriba_madoguchi::{FakeBuffer, FakeSnapshot, Verdict};
fn dirty_file() -> FakeSnapshot {
let mut s = FakeSnapshot::default();
s.buffers = vec![FakeBuffer::new(1, "dirty").at("/tmp/x.txt").dirty()];
s.active = Some(BufferId(1));
s
}
#[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 err = r.run("nope", &FakeSnapshot::default(), &[]).unwrap_err();
assert!(matches!(err, CommandError::NotFound(_)));
}
#[test]
fn a_command_asks_rather_than_acts() {
let mut r = CommandRegistry::new();
r.register(Command::action(
"w-all",
"Write every modified buffer",
"buffer.write-all",
));
let out = r
.run("w-all", &dirty_file(), &[])
.expect("registered command dispatches");
assert_eq!(
out.slips,
vec![Negai::Save {
buffer: BufferId(1)
}]
);
assert_eq!(out.verdict, Verdict::Did);
}
#[test]
fn nothing_to_save_declines_rather_than_claiming_success() {
let mut r = CommandRegistry::new();
r.register(Command::action("w-all", "Write all", "buffer.write-all"));
let out = r
.run("w-all", &FakeSnapshot::with_buffer("scratch"), &[])
.expect("dispatches");
assert!(out.slips.is_empty());
assert_eq!(out.verdict, Verdict::Declined("no modified files".into()));
}
#[test]
fn no_active_buffer_declines_rather_than_failing() {
let mut r = CommandRegistry::new();
r.register(Command::action("w", "Save", "buffer.save"));
let out = r
.run("w", &FakeSnapshot::default(), &[])
.expect("dispatches");
assert_eq!(out.verdict, Verdict::Declined("no active buffer".into()));
assert!(out.slips.is_empty(), "a decline asks for nothing");
}
#[test]
fn unknown_action_symbol_is_reported_not_silent() {
let mut r = CommandRegistry::new();
r.register(Command::action("pick", "Pick a file", "picker.files"));
let err = r
.run("pick", &FakeSnapshot::default(), &[])
.expect_err("an unimplemented action must report, not report success");
assert!(
matches!(&err, CommandError::Unhandled(s) if s == "picker.files"),
"expected Unhandled(picker.files), got {err:?}",
);
assert!(r.contains("pick"), "the command survives its own failure");
}
#[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 err = r
.run("alias", &dirty_file(), &[])
.expect_err("a command-name alias resolves nothing, and says so");
assert!(
matches!(&err, CommandError::Unhandled(s) if s == "save"),
"expected Unhandled(save), got {err:?}",
);
}
#[test]
fn quit_is_a_request_not_a_flag_poke() {
let mut r = CommandRegistry::new();
r.register(Command::action("bye", "Quit", "editor.quit"));
let out = r
.run("bye", &FakeSnapshot::default(), &[])
.expect("dispatches");
assert_eq!(out.slips, vec![Negai::Quit]);
}
#[test]
fn buffer_info_speaks_through_a_slip_not_stderr() {
let mut r = CommandRegistry::new();
r.register(Command::action("info", "Buffer info", "buffer.info"));
let out = r.run("info", &dirty_file(), &[]).expect("dispatches");
let Some(Negai::Message(m)) = out.slips.first() else {
panic!("expected a Message slip, got {:?}", out.slips);
};
assert!(m.contains("buffer 1"), "{m}");
assert!(m.contains("[modified]"), "{m}");
}
}