Skip to main content

escriba_command/
lib.rs

1//! `escriba-command` — command registry + palette.
2
3extern crate self as escriba_command;
4
5use std::collections::HashMap;
6
7use escriba_buffer::BufferSet;
8use escriba_core::BufferId;
9use escriba_mode::ModalState;
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12use thiserror::Error;
13
14#[derive(Debug, Error)]
15pub enum CommandError {
16    #[error("command not found: {0}")]
17    NotFound(String),
18    #[error("command failed: {0}")]
19    Failed(String),
20    #[error("buffer: {0}")]
21    Buffer(#[from] escriba_buffer::BufferError),
22}
23
24pub type Result<T> = std::result::Result<T, CommandError>;
25
26pub struct EditContext<'a> {
27    pub buffers: &'a mut BufferSet,
28    pub active: Option<BufferId>,
29    pub state: &'a mut ModalState,
30    /// Typed quit signal. A command that wants to exit the editor sets
31    /// this to `true`; the runtime reads it after `run`. This replaces the
32    /// old stringly-typed `__quit__` sentinel that was smuggled through the
33    /// command minibuffer — a channel that only existed because `minibuffer`
34    /// used to be a mode-independent scratch `String`. With the typed
35    /// modal sum, the minibuffer exists only in Command mode, so quit is now
36    /// a proper typed flag, not a buffer hack.
37    pub quit_requested: &'a mut bool,
38}
39
40pub type CommandFn = fn(&mut EditContext<'_>, &[String]) -> Result<()>;
41
42/// How a command executes when invoked.
43///
44/// - [`Handler::Native`] wraps a compiled-in Rust `fn` — the
45///   built-in command set (`save`, `quit`, …).
46/// - [`Handler::Action`] carries a dotted action symbol
47///   (e.g. `"buffer.write-all"`, `"picker.files"`) authored via a
48///   Tatara-Lisp `(defcmd …)` form and resolved at run time by
49///   [`run_action`]. This is what lets `defcmd` register a real,
50///   invokable command without a compiled handler.
51///
52/// A future `Lisp(Thunk)` variant will carry a `tatara-lisp-eval`
53/// closure for fully-programmable commands — the imperative tier of
54/// the two-tier programmability model. Keeping the handler an enum
55/// (not a bare `fn`) is what makes that extension a one-variant add.
56#[derive(Debug, Clone)]
57pub enum Handler {
58    /// Compiled-in Rust handler.
59    Native(CommandFn),
60    /// Dotted action symbol resolved at run time (Lisp `defcmd`).
61    Action(String),
62}
63
64#[derive(Debug, Clone)]
65pub struct Command {
66    pub name: String,
67    pub description: String,
68    pub handler: Handler,
69}
70
71impl Command {
72    /// A built-in command backed by a compiled-in Rust `fn`.
73    pub fn native(
74        name: impl Into<String>,
75        description: impl Into<String>,
76        handler: CommandFn,
77    ) -> Self {
78        Self {
79            name: name.into(),
80            description: description.into(),
81            handler: Handler::Native(handler),
82        }
83    }
84
85    /// A Lisp-authored command whose behavior is a dotted action
86    /// symbol resolved at run time. Mirrors `(defcmd :name … :action
87    /// "buffer.write-all")`.
88    pub fn action(
89        name: impl Into<String>,
90        description: impl Into<String>,
91        action: impl Into<String>,
92    ) -> Self {
93        Self {
94            name: name.into(),
95            description: description.into(),
96            handler: Handler::Action(action.into()),
97        }
98    }
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
102pub struct CommandSpec {
103    pub name: String,
104    pub description: String,
105    #[serde(default)]
106    pub args: Vec<CommandArgSpec>,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
110pub struct CommandArgSpec {
111    pub name: String,
112    pub description: String,
113    #[serde(default)]
114    pub required: bool,
115    #[serde(default, skip_serializing_if = "Vec::is_empty")]
116    pub variants: Vec<String>,
117}
118
119#[derive(Debug, Default, Clone)]
120pub struct CommandRegistry {
121    commands: HashMap<String, Command>,
122}
123
124impl CommandRegistry {
125    #[must_use]
126    pub fn new() -> Self {
127        Self::default()
128    }
129
130    #[must_use]
131    pub fn default_set() -> Self {
132        let mut r = Self::new();
133        r.register(Command::native(
134            "save",
135            "Write the active buffer to disk",
136            cmd_save,
137        ));
138        r.register(Command::native("quit", "Exit the editor", cmd_quit));
139        r.register(Command::native("undo", "Undo the last change", cmd_undo));
140        r.register(Command::native(
141            "redo",
142            "Redo the last undone change",
143            cmd_redo,
144        ));
145        r.register(Command::native(
146            "buffer-info",
147            "Print the active buffer summary",
148            cmd_buffer_info,
149        ));
150        r
151    }
152
153    pub fn register(&mut self, command: Command) {
154        self.commands.insert(command.name.clone(), command);
155    }
156
157    /// Is `name` registered? Lets the apply layer report
158    /// override-vs-new without exposing the inner map.
159    #[must_use]
160    pub fn contains(&self, name: &str) -> bool {
161        self.commands.contains_key(name)
162    }
163
164    /// Number of registered commands.
165    #[must_use]
166    pub fn len(&self) -> usize {
167        self.commands.len()
168    }
169
170    /// True when no commands are registered.
171    #[must_use]
172    pub fn is_empty(&self) -> bool {
173        self.commands.is_empty()
174    }
175
176    pub fn run(&self, name: &str, ctx: &mut EditContext<'_>, args: &[String]) -> Result<()> {
177        let cmd = self
178            .commands
179            .get(name)
180            .ok_or_else(|| CommandError::NotFound(name.to_string()))?;
181        match &cmd.handler {
182            Handler::Native(f) => f(ctx, args),
183            Handler::Action(sym) => run_action(sym, ctx, args),
184        }
185    }
186
187    #[must_use]
188    pub fn names(&self) -> Vec<&str> {
189        let mut v: Vec<&str> = self.commands.keys().map(String::as_str).collect();
190        v.sort_unstable();
191        v
192    }
193
194    #[must_use]
195    pub fn specs(&self) -> Vec<CommandSpec> {
196        let mut out: Vec<CommandSpec> = self
197            .commands
198            .values()
199            .map(|c| CommandSpec {
200                name: c.name.to_string(),
201                description: c.description.to_string(),
202                args: Vec::new(),
203            })
204            .collect();
205        out.sort_by(|a, b| a.name.cmp(&b.name));
206        out
207    }
208}
209
210/// Resolve a dotted action symbol — the `:action` of a Lisp
211/// `(defcmd …)` form — to a built-in behavior.
212///
213/// Canonical `buffer.*` / `editor.*` symbols map onto the same
214/// primitives the native commands use, so a Lisp-authored command is
215/// genuinely invokable (not a stub). Symbols that have no built-in
216/// yet (`picker.*`, `telescope.*`, plugin-provided actions) are an
217/// inert `Ok(())` — the command stays registered and invokable, it
218/// just does nothing until its wave lands. Crucially this NEVER
219/// errors or panics, so a deferred keybind whose action resolves to
220/// an unimplemented symbol degrades to a no-op instead of a crash.
221fn run_action(sym: &str, ctx: &mut EditContext<'_>, args: &[String]) -> Result<()> {
222    match sym {
223        "buffer.save" | "buffer.write" => cmd_save(ctx, args),
224        "buffer.write-all" => cmd_write_all(ctx, args),
225        "buffer.undo" => cmd_undo(ctx, args),
226        "buffer.redo" => cmd_redo(ctx, args),
227        "buffer.info" => cmd_buffer_info(ctx, args),
228        "editor.quit" => cmd_quit(ctx, args),
229        // Not-yet-implemented action namespace. Registered + invokable
230        // but inert until the relevant wave (pickers, plugin actions,
231        // tatara-lisp thunks) wires it. Inert, never fatal.
232        _ => Ok(()),
233    }
234}
235
236/// Save every modified, path-backed buffer. Best-effort: a single
237/// buffer's save failure (e.g. a permission error) must not abort the
238/// rest, and scratch buffers (no path) are skipped rather than
239/// surfacing [`BufferError::NoPath`].
240fn cmd_write_all(ctx: &mut EditContext<'_>, _args: &[String]) -> Result<()> {
241    for id in ctx.buffers.ids() {
242        if let Some(buf) = ctx.buffers.get_mut(id) {
243            if buf.modified && buf.path.is_some() {
244                let _ = buf.save();
245            }
246        }
247    }
248    Ok(())
249}
250
251fn cmd_save(ctx: &mut EditContext<'_>, _args: &[String]) -> Result<()> {
252    let id = ctx
253        .active
254        .ok_or_else(|| CommandError::Failed("no active buffer".into()))?;
255    let buf = ctx
256        .buffers
257        .get_mut(id)
258        .ok_or_else(|| CommandError::Failed("active buffer gone".into()))?;
259    buf.save()?;
260    Ok(())
261}
262
263fn cmd_quit(ctx: &mut EditContext<'_>, _: &[String]) -> Result<()> {
264    // Quit is a typed signal on the edit context — no string sentinel,
265    // no mode-specific scratch buffer. Phase 2 graduates this to a proper
266    // Result enum carrying `QuitRequested(code)`.
267    *ctx.quit_requested = true;
268    Ok(())
269}
270
271fn cmd_undo(ctx: &mut EditContext<'_>, _: &[String]) -> Result<()> {
272    let id = ctx
273        .active
274        .ok_or_else(|| CommandError::Failed("no active buffer".into()))?;
275    ctx.buffers
276        .get_mut(id)
277        .ok_or_else(|| CommandError::Failed("gone".into()))?
278        .undo()?;
279    Ok(())
280}
281
282fn cmd_redo(ctx: &mut EditContext<'_>, _: &[String]) -> Result<()> {
283    let id = ctx
284        .active
285        .ok_or_else(|| CommandError::Failed("no active buffer".into()))?;
286    ctx.buffers
287        .get_mut(id)
288        .ok_or_else(|| CommandError::Failed("gone".into()))?
289        .redo()?;
290    Ok(())
291}
292
293fn cmd_buffer_info(ctx: &mut EditContext<'_>, _: &[String]) -> Result<()> {
294    let id = ctx
295        .active
296        .ok_or_else(|| CommandError::Failed("no active buffer".into()))?;
297    let buf = ctx
298        .buffers
299        .get(id)
300        .ok_or_else(|| CommandError::Failed("gone".into()))?;
301    eprintln!(
302        "buffer {} — {} line(s), {} char(s){}",
303        id,
304        buf.line_count(),
305        buf.char_count(),
306        if buf.modified { " [modified]" } else { "" }
307    );
308    Ok(())
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    #[test]
316    fn default_set_is_populated() {
317        let r = CommandRegistry::default_set();
318        let names = r.names();
319        assert!(names.contains(&"save"));
320        assert!(names.contains(&"quit"));
321    }
322
323    #[test]
324    fn specs_are_sorted() {
325        let r = CommandRegistry::default_set();
326        let specs = r.specs();
327        assert!(specs.windows(2).all(|w| w[0].name <= w[1].name));
328    }
329
330    #[test]
331    fn not_found_errors() {
332        let r = CommandRegistry::new();
333        let mut bufs = BufferSet::new();
334        let mut state = ModalState::new();
335        let mut quit = false;
336        let mut ctx = EditContext {
337            buffers: &mut bufs,
338            active: None,
339            state: &mut state,
340            quit_requested: &mut quit,
341        };
342        let err = r.run("nope", &mut ctx, &[]).unwrap_err();
343        assert!(matches!(err, CommandError::NotFound(_)));
344    }
345
346    #[test]
347    fn action_command_registers_and_is_invokable() {
348        // A Lisp-authored `(defcmd :name "w-all" :action
349        // "buffer.write-all")` registers an `Action` handler. It must
350        // resolve (not NotFound) and run without error over a scratch
351        // buffer (write-all skips path-less buffers).
352        let mut r = CommandRegistry::new();
353        r.register(Command::action(
354            "w-all",
355            "Write every modified buffer",
356            "buffer.write-all",
357        ));
358        assert!(r.contains("w-all"));
359
360        let mut bufs = BufferSet::new();
361        let id = bufs.scratch("dirty");
362        bufs.get_mut(id).unwrap().modified = true;
363        let mut state = ModalState::new();
364        let mut quit = false;
365        let mut ctx = EditContext {
366            buffers: &mut bufs,
367            active: Some(id),
368            state: &mut state,
369            quit_requested: &mut quit,
370        };
371        // No path → write-all is a no-op, never NoPath-errors.
372        r.run("w-all", &mut ctx, &[]).expect("action command runs");
373    }
374
375    #[test]
376    fn unknown_action_symbol_is_inert_not_fatal() {
377        // An action symbol with no built-in (a future picker/plugin
378        // action) is registered + invokable but does nothing — it must
379        // never error, so a deferred keybind can't dead-end loudly.
380        let mut r = CommandRegistry::new();
381        r.register(Command::action("pick", "Pick a file", "picker.files"));
382        let mut bufs = BufferSet::new();
383        let mut state = ModalState::new();
384        let mut quit = false;
385        let mut ctx = EditContext {
386            buffers: &mut bufs,
387            active: None,
388            state: &mut state,
389            quit_requested: &mut quit,
390        };
391        r.run("pick", &mut ctx, &[]).expect("unknown action is inert");
392    }
393
394    #[test]
395    fn action_naming_a_command_is_inert_not_recursive() {
396        // A `defcmd :action` that names another COMMAND ("save") rather
397        // than a dotted action symbol ("buffer.save") is INERT —
398        // run_action only resolves dotted symbols and does NOT recurse
399        // into the registry. This pins the boundary: `:action` takes
400        // action SYMBOLS, not command names. (If aliasing-by-name is
401        // ever wanted, run_action must take registry access and this
402        // test will flip.)
403        let mut r = CommandRegistry::new();
404        r.register(Command::action("alias", "aliases save by name", "save"));
405        let mut bufs = BufferSet::new();
406        let id = bufs.scratch("dirty");
407        bufs.get_mut(id).unwrap().modified = true;
408        let mut state = ModalState::new();
409        let mut quit = false;
410        {
411            let mut ctx = EditContext {
412                buffers: &mut bufs,
413                active: Some(id),
414                state: &mut state,
415                quit_requested: &mut quit,
416            };
417            r.run("alias", &mut ctx, &[]).expect("alias runs inertly");
418        }
419        assert!(
420            bufs.get(id).unwrap().modified,
421            "command-name alias must be inert — save did not fire (no recursion)",
422        );
423    }
424
425    #[test]
426    fn action_quit_sets_quit_flag() {
427        // `editor.quit` must route to the same typed quit signal the
428        // native quit command uses, so a Lisp-defined quit alias behaves
429        // identically to the built-in.
430        let mut r = CommandRegistry::new();
431        r.register(Command::action("bye", "Quit", "editor.quit"));
432        let mut bufs = BufferSet::new();
433        let mut state = ModalState::new();
434        let mut quit = false;
435        let mut ctx = EditContext {
436            buffers: &mut bufs,
437            active: None,
438            state: &mut state,
439            quit_requested: &mut quit,
440        };
441        r.run("bye", &mut ctx, &[]).expect("quit action runs");
442        assert!(*ctx.quit_requested, "editor.quit sets the typed quit flag");
443    }
444}