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_core::BufferId;
8use escriba_madoguchi::cap::{Buffers, Cursor, Syntax};
9use escriba_madoguchi::{BufferView, Native, Negai, Outcome, Snapshot, View, caps, erase};
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    /// A registered command whose action symbol nothing implements yet.
19    ///
20    /// Distinct from [`NotFound`](Self::NotFound), and the distinction is the
21    /// point: `NotFound` means the operator typed a name that does not exist,
22    /// while `Unhandled` means the editor ADVERTISED a binding — it is in
23    /// `--commands`, it is in the keymap, `--list-rc` counts it — and then did
24    /// nothing. The second is the more misleading of the two and used to be
25    /// the silent one.
26    #[error("action `{0}` is declared but not implemented yet")]
27    Unhandled(String),
28    #[error("command failed: {0}")]
29    Failed(String),
30    /// An alias chain that never reaches a body.
31    ///
32    /// Aliases resolve THROUGH the registry, so `A -> B -> A` would spin
33    /// forever. Bounded fuel turns that into a typed report naming the chain
34    /// the operator wrote, instead of a hung editor.
35    #[error("alias cycle resolving `{0}`")]
36    AliasCycle(String),
37    // NOTE: there was a `Buffer(#[from] BufferError)` variant here. The M2
38    // port made it dead: a command no longer performs I/O, so it cannot
39    // produce a buffer error. Save/undo/redo failures now surface from the
40    // interpreter, which is the thing that actually touches the filesystem.
41}
42
43pub type Result<T> = std::result::Result<T, CommandError>;
44
45/// A command body.
46///
47/// Reads through the counter, returns slips. There is no `&mut` in this
48/// signature, which is the point: a command cannot reach editor state, so it
49/// cannot corrupt it. It replaces `fn(&mut EditContext, &[String])`, whose
50/// `&mut BufferSet` was simultaneously too much power and too little reach —
51/// the runtime still had to special-case `:noh` because `EditContext` could
52/// not see `SearchState`.
53pub type CommandFn = fn(&dyn Snapshot, &[String]) -> Outcome;
54
55/// How a command executes when invoked.
56///
57/// - [`Handler::Native`] wraps a compiled-in Rust `fn` — the
58///   built-in command set (`save`, `quit`, …).
59/// - [`Handler::Action`] carries a dotted action symbol
60///   (e.g. `"buffer.write-all"`, `"picker.files"`) authored via a
61///   Tatara-Lisp `(defcmd …)` form and resolved at run time by
62///   [`run_action`]. This is what lets `defcmd` register a real,
63///   invokable command without a compiled handler.
64///
65/// A future `Lisp(Thunk)` variant will carry a `tatara-lisp-eval`
66/// closure for fully-programmable commands — the imperative tier of
67/// the two-tier programmability model. Keeping the handler an enum
68/// (not a bare `fn`) is what makes that extension a one-variant add.
69#[derive(Debug, Clone)]
70pub enum Handler {
71    /// Compiled-in Rust handler.
72    Native(CommandFn),
73    /// Dotted action symbol resolved at run time (Lisp `defcmd`).
74    Action(String),
75}
76
77#[derive(Debug, Clone)]
78pub struct Command {
79    pub name: String,
80    pub description: String,
81    pub handler: Handler,
82}
83
84impl Command {
85    /// A built-in command backed by a compiled-in Rust `fn`.
86    pub fn native(
87        name: impl Into<String>,
88        description: impl Into<String>,
89        handler: CommandFn,
90    ) -> Self {
91        Self {
92            name: name.into(),
93            description: description.into(),
94            handler: Handler::Native(handler),
95        }
96    }
97
98    /// A Lisp-authored command whose behavior is a dotted action
99    /// symbol resolved at run time. Mirrors `(defcmd :name … :action
100    /// "buffer.write-all")`.
101    pub fn action(
102        name: impl Into<String>,
103        description: impl Into<String>,
104        action: impl Into<String>,
105    ) -> Self {
106        Self {
107            name: name.into(),
108            description: description.into(),
109            handler: Handler::Action(action.into()),
110        }
111    }
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
115pub struct CommandSpec {
116    pub name: String,
117    pub description: String,
118    #[serde(default)]
119    pub args: Vec<CommandArgSpec>,
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
123pub struct CommandArgSpec {
124    pub name: String,
125    pub description: String,
126    #[serde(default)]
127    pub required: bool,
128    #[serde(default, skip_serializing_if = "Vec::is_empty")]
129    pub variants: Vec<String>,
130}
131
132#[derive(Debug, Default, Clone)]
133pub struct CommandRegistry {
134    commands: HashMap<String, Command>,
135}
136
137impl CommandRegistry {
138    #[must_use]
139    pub fn new() -> Self {
140        Self::default()
141    }
142
143    #[must_use]
144    pub fn default_set() -> Self {
145        let mut r = Self::new();
146        r.register(Command::native(
147            "save",
148            "Write the active buffer to disk",
149            erase::<Save>(),
150        ));
151        r.register(Command::native("quit", "Exit the editor", erase::<Quit>()));
152        // Named for the ACTION SYMBOLS the shipped keybindings use, so
153        // `<leader>bn` resolves instead of reporting "declared but not
154        // implemented yet". These are the first three entries to leave the
155        // INERT inventory in escriba/tests/action_resolution.rs.
156        r.register(Command::native(
157            "buffer.next",
158            "Go to the next buffer",
159            erase::<BufferNext>(),
160        ));
161        r.register(Command::native(
162            "buffer.prev",
163            "Go to the previous buffer",
164            erase::<BufferPrev>(),
165        ));
166        r.register(Command::native(
167            "buffer.delete",
168            "Close the active buffer",
169            erase::<BufferDelete>(),
170        ));
171        r.register(Command::native(
172            "todo.next",
173            "Go to the next TODO/FIXME marker",
174            erase::<TodoWalk<true>>(),
175        ));
176        r.register(Command::native(
177            "todo.prev",
178            "Go to the previous TODO/FIXME marker",
179            erase::<TodoWalk<false>>(),
180        ));
181        for name in ["comment.toggle-line", "comment.toggle-block"] {
182            r.register(Command::native(
183                name,
184                "Toggle the comment on the current line",
185                erase::<CommentToggle>(),
186            ));
187        }
188        for alias in ["noh", "nohl", "nohlsearch"] {
189            r.register(Command::action(
190                alias,
191                "Stop highlighting matches, keep the pattern",
192                "search.clear-highlight",
193            ));
194        }
195        r.register(Command::native(
196            "undo",
197            "Undo the last change",
198            erase::<Undo>(),
199        ));
200        r.register(Command::native(
201            "redo",
202            "Redo the last undone change",
203            erase::<Redo>(),
204        ));
205        r.register(Command::native(
206            "buffer-info",
207            "Print the active buffer summary",
208            erase::<Info>(),
209        ));
210        r
211    }
212
213    pub fn register(&mut self, command: Command) {
214        self.commands.insert(command.name.clone(), command);
215    }
216
217    /// Is `name` registered? Lets the apply layer report
218    /// override-vs-new without exposing the inner map.
219    #[must_use]
220    pub fn contains(&self, name: &str) -> bool {
221        self.commands.contains_key(name)
222    }
223
224    /// Number of registered commands.
225    #[must_use]
226    pub fn len(&self) -> usize {
227        self.commands.len()
228    }
229
230    /// True when no commands are registered.
231    #[must_use]
232    pub fn is_empty(&self) -> bool {
233        self.commands.is_empty()
234    }
235
236    /// Dispatch `name`.
237    ///
238    /// `Err` means the registry could not dispatch at all — Phase 0's two
239    /// failures, kept distinct because they mean different things to the
240    /// operator. `Ok(outcome)` means a body ran and reported for itself.
241    pub fn run(&self, name: &str, snap: &dyn Snapshot, args: &[String]) -> Result<Outcome> {
242        self.run_bounded(name, snap, args, ALIAS_FUEL)
243    }
244
245    /// One resolution path, bounded.
246    ///
247    /// An action symbol resolves against the BUILT-IN table first, then
248    /// against the registry itself. That second step is the whole point: a
249    /// `(defcmd :name "CommentToggle" :action "comment.toggle-line")` alias
250    /// used to die as `Unhandled` even though `comment.toggle-line` was a
251    /// registered native sitting in the same map — two dispatch tables that
252    /// had to agree, and did not. Every one of the 41 catalog aliases was
253    /// dead for exactly this reason.
254    ///
255    /// Resolving through the registry admits cycles, so the chain runs on
256    /// fuel and exhaustion is a typed `AliasCycle`.
257    fn run_bounded(
258        &self,
259        name: &str,
260        snap: &dyn Snapshot,
261        args: &[String],
262        fuel: u8,
263    ) -> Result<Outcome> {
264        let Some(fuel) = fuel.checked_sub(1) else {
265            return Err(CommandError::AliasCycle(name.to_string()));
266        };
267        let cmd = self
268            .commands
269            .get(name)
270            .ok_or_else(|| CommandError::NotFound(name.to_string()))?;
271        match &cmd.handler {
272            Handler::Native(f) => Ok(f(snap, args)),
273            Handler::Action(sym) => match builtin_action(sym) {
274                Some(f) => Ok(f(snap, args)),
275                // Not a built-in — but the symbol may name a registered
276                // command. `sym != name` keeps a self-referential alias from
277                // burning the whole budget before reporting.
278                None if sym != name && self.commands.contains_key(sym.as_str()) => {
279                    self.run_bounded(sym, snap, args, fuel)
280                }
281                None => Err(CommandError::Unhandled(sym.to_string())),
282            },
283        }
284    }
285
286    #[must_use]
287    pub fn names(&self) -> Vec<&str> {
288        let mut v: Vec<&str> = self.commands.keys().map(String::as_str).collect();
289        v.sort_unstable();
290        v
291    }
292
293    #[must_use]
294    pub fn specs(&self) -> Vec<CommandSpec> {
295        let mut out: Vec<CommandSpec> = self
296            .commands
297            .values()
298            .map(|c| CommandSpec {
299                name: c.name.to_string(),
300                description: c.description.to_string(),
301                args: Vec::new(),
302            })
303            .collect();
304        out.sort_by(|a, b| a.name.cmp(&b.name));
305        out
306    }
307}
308
309/// How many alias hops a chain may take before it is called a cycle.
310///
311/// Small on purpose: a legitimate chain is an alias naming a native, which
312/// is two hops. Anything deeper is a configuration mistake worth reporting.
313const ALIAS_FUEL: u8 = 8;
314
315/// The dotted action symbols escriba implements natively.
316///
317/// Returns `None` for anything else so the caller can try the registry —
318/// this used to return `Err(Unhandled)` directly, which is what made the
319/// built-in table the ONLY table and killed every catalog alias.
320fn builtin_action(sym: &str) -> Option<CommandFn> {
321    Some(match sym {
322        "buffer.save" | "buffer.write" => erase::<Save>(),
323        "buffer.write-all" => erase::<WriteAll>(),
324        "buffer.undo" => erase::<Undo>(),
325        "buffer.redo" => erase::<Redo>(),
326        "buffer.info" => erase::<Info>(),
327        "editor.quit" => erase::<Quit>(),
328        "search.clear-highlight" => erase::<Noh>(),
329        // The not-yet-implemented namespace. The shipped keybindings that
330        // land here are enumerated by `escriba/tests/action_resolution.rs`,
331        // which asserts SET EQUALITY — so the count is READ from there rather
332        // than restated. It said 85 while the real figure had ratcheted to
333        // 78; a duplicated number is a number that rots.
334        // Inert and ANNOUNCED; see CommandError::Unhandled.
335        _ => return None,
336    })
337}
338
339/// The active buffer, or the outcome to return when there isn't one.
340///
341/// "No buffer" is a DECLINE, not a failure: it is a legitimate state (boot,
342/// every `--no-defaults` run) and the operator did nothing wrong.
343fn active_or_decline(b: &escriba_madoguchi::snapshot::Buffers<'_>) -> Result2<BufferId> {
344    b.active()
345        .map(BufferView::id)
346        .ok_or_else(|| Outcome::declined("no active buffer"))
347}
348
349type Result2<T> = std::result::Result<T, Outcome>;
350
351/// Save every modified, path-backed buffer.
352///
353/// Best-effort BY CONSTRUCTION: one slip per buffer, applied independently,
354/// so one buffer's permission error cannot abort the rest. Scratch buffers
355/// have no path and are skipped.
356struct WriteAll;
357impl Native for WriteAll {
358    type Reads = caps!(Buffers);
359    fn run(v: &View<'_, Self::Reads>, _args: &[String]) -> Outcome {
360        let b = v.buffers();
361        let slips: Vec<Negai> = b
362            .ids()
363            .into_iter()
364            .filter(|id| {
365                b.get(*id)
366                    .is_some_and(|x| x.is_modified() && x.path().is_some())
367            })
368            .map(|buffer| Negai::Save { buffer })
369            .collect();
370        if slips.is_empty() {
371            return Outcome::declined("no modified files");
372        }
373        Outcome::did(slips)
374    }
375}
376
377struct Save;
378impl Native for Save {
379    type Reads = caps!(Buffers);
380    fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
381        match active_or_decline(&v.buffers()) {
382            Ok(buffer) => Outcome::did(vec![Negai::Save { buffer }]),
383            Err(o) => o,
384        }
385    }
386}
387
388struct Undo;
389impl Native for Undo {
390    type Reads = caps!(Buffers);
391    fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
392        match active_or_decline(&v.buffers()) {
393            Ok(buffer) => Outcome::did(vec![Negai::Undo { buffer }]),
394            Err(o) => o,
395        }
396    }
397}
398
399struct Redo;
400impl Native for Redo {
401    type Reads = caps!(Buffers);
402    fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
403        match active_or_decline(&v.buffers()) {
404            Ok(buffer) => Outcome::did(vec![Negai::Redo { buffer }]),
405            Err(o) => o,
406        }
407    }
408}
409
410/// Report the active buffer's shape.
411///
412/// This used to `eprintln!`. From a TUI holding the alternate screen that
413/// writes straight through the ratatui frame and corrupts it — a latent bug
414/// the port removed for free, because a command's only way to say something
415/// is now `Negai::Message`, which lands on the status line.
416struct Info;
417impl Native for Info {
418    type Reads = caps!(Buffers);
419    fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
420        let b = v.buffers();
421        let Some(buf) = b.active() else {
422            return Outcome::declined("no active buffer");
423        };
424        let mut m = String::with_capacity(48);
425        m.push_str("buffer ");
426        m.push_str(&buf.id().0.to_string());
427        m.push_str(" — ");
428        m.push_str(&buf.line_count().to_string());
429        m.push_str(" line(s)");
430        if buf.is_modified() {
431            m.push_str(" [modified]");
432        }
433        Outcome::did(vec![Negai::Message(m)])
434    }
435}
436
437/// Quit reads NOTHING.
438///
439/// Worth pausing on: under the old `EditContext` this function was handed
440/// `&mut BufferSet` and `&mut ModalState` in order to set one bool. Its
441/// capability set is now literally empty, and the type system enforces that
442/// — `caps!()` proves no membership, so every accessor on its view is
443/// unbuildable.
444/// `buffer.next` / `buffer.prev` — walk the buffer list.
445struct BufferNext;
446impl Native for BufferNext {
447    type Reads = caps!();
448    fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
449        Outcome::did(vec![Negai::CycleBuffer { forward: true }])
450    }
451}
452
453struct BufferPrev;
454impl Native for BufferPrev {
455    type Reads = caps!();
456    fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
457        Outcome::did(vec![Negai::CycleBuffer { forward: false }])
458    }
459}
460
461/// `buffer.delete` — close the active buffer.
462///
463/// Reads `Buffers` only to name WHICH buffer; whether a modified buffer may
464/// close, and what becomes active afterwards, are the interpreter's policy.
465struct BufferDelete;
466impl Native for BufferDelete {
467    type Reads = caps!(Buffers);
468    fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
469        match active_or_decline(&v.buffers()) {
470            Ok(buffer) => Outcome::did(vec![Negai::CloseBuffer(buffer)]),
471            Err(o) => o,
472        }
473    }
474}
475
476/// `comment.toggle-line` / `comment.toggle-block` — the first commands to
477/// need TWO capabilities, and the first consumer of `:commentstring`.
478///
479/// Toggle, not comment: if the line is already commented it is uncommented.
480/// A one-way "comment" verb makes the same keystroke mean two things
481/// depending on state, which is how you end up with `//// x`.
482struct CommentToggle;
483impl Native for CommentToggle {
484    type Reads = caps!(Buffers, Cursor, Syntax);
485    fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
486        let Some(ft) = v.syntax().filetype() else {
487            return Outcome::declined("no filetype for this buffer");
488        };
489        let Some(comment) = ft.comment.as_ref() else {
490            let mut m = String::from("no comment syntax for ");
491            m.push_str(&ft.name);
492            return Outcome::declined(m);
493        };
494        let b = v.buffers();
495        let Some(buf) = b.active() else {
496            return Outcome::declined("no active buffer");
497        };
498        let line_no = v.cursor().position().line;
499        let Some(line) = buf.line(line_no) else {
500            return Outcome::declined("cursor past the end of the buffer");
501        };
502        // An empty line has nothing to comment, and commenting it would
503        // leave a bare marker the next toggle cannot recognise as content.
504        if line.trim().is_empty() {
505            return Outcome::declined("nothing on this line");
506        }
507
508        // Indentation is preserved: a comment marker inserted before the
509        // indent would destroy the alignment the code is relying on.
510        let indent_len = line.len() - line.trim_start().len();
511        let (indent, body) = line.split_at(indent_len);
512        let toggled = match comment.strip(body) {
513            Some(uncommented) => uncommented.to_string(),
514            None => comment.wrap(body),
515        };
516        let mut text = String::with_capacity(indent.len() + toggled.len());
517        text.push_str(indent);
518        text.push_str(&toggled);
519
520        Outcome::did(vec![Negai::Edit {
521            buffer: buf.id(),
522            edit: escriba_core::Edit {
523                range: escriba_core::Range::new(
524                    escriba_core::Position::new(line_no, 0),
525                    escriba_core::Position::new(
526                        line_no,
527                        u32::try_from(line.chars().count()).unwrap_or(u32::MAX),
528                    ),
529                ),
530                kind: escriba_core::EditKind::Replace { text },
531            },
532        }])
533    }
534}
535
536/// `todo.next` / `todo.prev` — walk the marker list.
537///
538/// Scans on every invocation rather than relying on a cached list. The scan
539/// is pure text and costs nothing at keyboard cadence, and re-scanning means
540/// the list is always fresh — the freshness machinery then guards the window
541/// BETWEEN a publish and a walk, which is where a stale list would otherwise
542/// slip through.
543///
544/// This is also the shape every later producer takes: the command COMPUTES
545/// (it has the text through `Buffers`) and asks the interpreter to publish.
546/// Nothing here touches the registry.
547struct TodoWalk<const FORWARD: bool>;
548impl<const FORWARD: bool> Native for TodoWalk<FORWARD> {
549    type Reads = caps!(Buffers);
550    fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
551        let b = v.buffers();
552        let Some(buf) = b.active() else {
553            return Outcome::declined("no active buffer");
554        };
555        let findings = escriba_shirube::scan_markers(buf.id(), &buf.text());
556        if findings.is_empty() {
557            return Outcome::declined("no TODO markers in this buffer");
558        }
559        Outcome::did(vec![
560            Negai::PublishFindings {
561                list: "todo".to_string(),
562                findings,
563            },
564            Negai::WalkList {
565                list: "todo".to_string(),
566                forward: FORWARD,
567            },
568        ])
569    }
570}
571
572struct Quit;
573impl Native for Quit {
574    type Reads = caps!();
575    fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
576        Outcome::did(vec![Negai::Quit])
577    }
578}
579
580/// `:noh` — the command that proves the seam, and it also reads nothing.
581///
582/// It lived as a hard-coded branch inside `EditorState::run_command`,
583/// bypassing the registry entirely, because the old `EditContext` exposed
584/// buffers and modal state and could not reach `SearchState`. It is now an
585/// ordinary command asking for an ordinary slip, and it turns out not to
586/// need a view at all — it does not READ the search, it asks to change it.
587struct Noh;
588impl Native for Noh {
589    type Reads = caps!();
590    fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
591        Outcome::did(vec![Negai::ClearSearchHighlight])
592    }
593}
594
595#[cfg(test)]
596mod tests {
597    use super::*;
598    use escriba_core::BufferId;
599    use escriba_madoguchi::{FakeBuffer, FakeSnapshot, Verdict};
600
601    /// A snapshot holding one dirty, path-backed buffer.
602    fn dirty_file() -> FakeSnapshot {
603        let mut s = FakeSnapshot::default();
604        s.buffers = vec![FakeBuffer::new(1, "dirty").at("/tmp/x.txt").dirty()];
605        s.active = Some(BufferId(1));
606        s
607    }
608
609    #[test]
610    fn default_set_is_populated() {
611        let r = CommandRegistry::default_set();
612        let names = r.names();
613        assert!(names.contains(&"save"));
614        assert!(names.contains(&"quit"));
615    }
616
617    #[test]
618    fn specs_are_sorted() {
619        let r = CommandRegistry::default_set();
620        let specs = r.specs();
621        assert!(specs.windows(2).all(|w| w[0].name <= w[1].name));
622    }
623
624    #[test]
625    fn not_found_errors() {
626        // Phase 0's first failure: a name nobody registered. Still an Err,
627        // because the runtime tells a typo apart from an unbuilt capability.
628        let r = CommandRegistry::new();
629        let err = r.run("nope", &FakeSnapshot::default(), &[]).unwrap_err();
630        assert!(matches!(err, CommandError::NotFound(_)));
631    }
632
633    #[test]
634    fn a_command_asks_rather_than_acts() {
635        // The whole point of the port. `write-all` used to reach into
636        // `&mut BufferSet` and call `.save()`. It now RETURNS a request per
637        // modified path-backed buffer and touches nothing — which is also
638        // why it is best-effort by construction: the interpreter applies
639        // each slip independently, so one permission error cannot abort the
640        // rest.
641        let mut r = CommandRegistry::new();
642        r.register(Command::action(
643            "w-all",
644            "Write every modified buffer",
645            "buffer.write-all",
646        ));
647        let out = r
648            .run("w-all", &dirty_file(), &[])
649            .expect("registered command dispatches");
650        assert_eq!(
651            out.slips,
652            vec![Negai::Save {
653                buffer: BufferId(1)
654            }]
655        );
656        assert_eq!(out.verdict, Verdict::Did);
657    }
658
659    #[test]
660    fn nothing_to_save_declines_rather_than_claiming_success() {
661        // Three verdicts, not two. A scratch buffer has no path, so there is
662        // genuinely nothing to write — and saying "Did" would be the same
663        // silent lie Phase 0 removed.
664        let mut r = CommandRegistry::new();
665        r.register(Command::action("w-all", "Write all", "buffer.write-all"));
666        let out = r
667            .run("w-all", &FakeSnapshot::with_buffer("scratch"), &[])
668            .expect("dispatches");
669        assert!(out.slips.is_empty());
670        assert_eq!(out.verdict, Verdict::Declined("no modified files".into()));
671    }
672
673    #[test]
674    fn no_active_buffer_declines_rather_than_failing() {
675        // Boot, and every `--no-defaults` run, reach commands with no
676        // buffer. The operator did nothing wrong, so it is not an error.
677        let mut r = CommandRegistry::new();
678        r.register(Command::action("w", "Save", "buffer.save"));
679        let out = r
680            .run("w", &FakeSnapshot::default(), &[])
681            .expect("dispatches");
682        assert_eq!(out.verdict, Verdict::Declined("no active buffer".into()));
683        assert!(out.slips.is_empty(), "a decline asks for nothing");
684    }
685
686    #[test]
687    fn unknown_action_symbol_is_reported_not_silent() {
688        // This test used to assert the DEFECT — it called `.expect()` on the
689        // Ok, pinning `_ => Ok(())`, under which a dead keybinding and a
690        // working one were indistinguishable at every layer.
691        //
692        // Inert is still correct: `picker.files` genuinely has not landed.
693        // SILENT was never correct. It must be `Unhandled`, not `NotFound`:
694        // the command IS registered, which is what made the silence
695        // misleading in the first place.
696        let mut r = CommandRegistry::new();
697        r.register(Command::action("pick", "Pick a file", "picker.files"));
698        let err = r
699            .run("pick", &FakeSnapshot::default(), &[])
700            .expect_err("an unimplemented action must report, not report success");
701        assert!(
702            matches!(&err, CommandError::Unhandled(s) if s == "picker.files"),
703            "expected Unhandled(picker.files), got {err:?}",
704        );
705        assert!(r.contains("pick"), "the command survives its own failure");
706    }
707
708    #[test]
709    fn action_naming_a_command_is_inert_not_recursive() {
710        // `:action` takes action SYMBOLS, not command names: `run_action`
711        // resolves dotted symbols and does NOT recurse into the registry.
712        // Recursion would let a handler reach anything by naming it, which
713        // is the ceiling madoguchi exists to remove.
714        //
715        // What changed with the port: the non-recursion is now REPORTED
716        // rather than looking like a successful save.
717        let mut r = CommandRegistry::new();
718        r.register(Command::action("alias", "aliases save by name", "save"));
719        let err = r
720            .run("alias", &dirty_file(), &[])
721            .expect_err("a command-name alias resolves nothing, and says so");
722        assert!(
723            matches!(&err, CommandError::Unhandled(s) if s == "save"),
724            "expected Unhandled(save), got {err:?}",
725        );
726    }
727
728    #[test]
729    fn quit_is_a_request_not_a_flag_poke() {
730        // Was `*ctx.quit_requested = true` — a command reaching into a
731        // borrowed flag. Quit is now a request like any other, and the
732        // interpreter decides, because the interpreter is the thing that
733        // knows about unsaved buffers.
734        let mut r = CommandRegistry::new();
735        r.register(Command::action("bye", "Quit", "editor.quit"));
736        let out = r
737            .run("bye", &FakeSnapshot::default(), &[])
738            .expect("dispatches");
739        assert_eq!(out.slips, vec![Negai::Quit]);
740    }
741
742    #[test]
743    fn buffer_info_speaks_through_a_slip_not_stderr() {
744        // It used to `eprintln!`, which from a TUI holding the alternate
745        // screen writes straight through the ratatui frame and corrupts it.
746        // A command's only way to say anything is now Negai::Message.
747        let mut r = CommandRegistry::new();
748        r.register(Command::action("info", "Buffer info", "buffer.info"));
749        let out = r.run("info", &dirty_file(), &[]).expect("dispatches");
750        let Some(Negai::Message(m)) = out.slips.first() else {
751            panic!("expected a Message slip, got {:?}", out.slips);
752        };
753        assert!(m.contains("buffer 1"), "{m}");
754        assert!(m.contains("[modified]"), "{m}");
755    }
756}