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