Skip to main content

escriba_command/
lib.rs

1//! `escriba-command` — command registry + palette.
2
3extern crate self as escriba_command;
4
5pub mod ex;
6
7use std::collections::HashMap;
8
9use escriba_core::BufferId;
10use escriba_madoguchi::cap::{Buffers, Cursor, Syntax};
11use escriba_madoguchi::{BufferView, Native, Negai, Outcome, Snapshot, View, caps, erase};
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14use thiserror::Error;
15
16#[derive(Debug, Error)]
17pub enum CommandError {
18    #[error("command not found: {0}")]
19    NotFound(String),
20    /// A registered command whose action symbol nothing implements yet.
21    ///
22    /// Distinct from [`NotFound`](Self::NotFound), and the distinction is the
23    /// point: `NotFound` means the operator typed a name that does not exist,
24    /// while `Unhandled` means the editor ADVERTISED a binding — it is in
25    /// `--commands`, it is in the keymap, `--list-rc` counts it — and then did
26    /// nothing. The second is the more misleading of the two and used to be
27    /// the silent one.
28    #[error("action `{0}` is declared but not implemented yet")]
29    Unhandled(String),
30    #[error("command failed: {0}")]
31    Failed(String),
32    /// An alias chain that never reaches a body.
33    ///
34    /// Aliases resolve THROUGH the registry, so `A -> B -> A` would spin
35    /// forever. Bounded fuel turns that into a typed report naming the chain
36    /// the operator wrote, instead of a hung editor.
37    #[error("alias cycle resolving `{0}`")]
38    AliasCycle(String),
39    // NOTE: there was a `Buffer(#[from] BufferError)` variant here. The M2
40    // port made it dead: a command no longer performs I/O, so it cannot
41    // produce a buffer error. Save/undo/redo failures now surface from the
42    // interpreter, which is the thing that actually touches the filesystem.
43}
44
45pub type Result<T> = std::result::Result<T, CommandError>;
46
47/// A command body.
48///
49/// Reads through the counter, returns slips. There is no `&mut` in this
50/// signature, which is the point: a command cannot reach editor state, so it
51/// cannot corrupt it. It replaces `fn(&mut EditContext, &[String])`, whose
52/// `&mut BufferSet` was simultaneously too much power and too little reach —
53/// the runtime still had to special-case `:noh` because `EditContext` could
54/// not see `SearchState`.
55pub type CommandFn = fn(&dyn Snapshot, &[String]) -> Outcome;
56
57/// How a command executes when invoked.
58///
59/// - [`Handler::Native`] wraps a compiled-in Rust `fn` — the
60///   built-in command set (`save`, `quit`, …).
61/// - [`Handler::Action`] carries a dotted action symbol
62///   (e.g. `"buffer.write-all"`, `"picker.files"`) authored via a
63///   Tatara-Lisp `(defcmd …)` form and resolved at run time by
64///   [`run_action`]. This is what lets `defcmd` register a real,
65///   invokable command without a compiled handler.
66///
67/// A future `Lisp(Thunk)` variant will carry a `tatara-lisp-eval`
68/// closure for fully-programmable commands — the imperative tier of
69/// the two-tier programmability model. Keeping the handler an enum
70/// (not a bare `fn`) is what makes that extension a one-variant add.
71#[derive(Debug, Clone)]
72pub enum Handler {
73    /// Compiled-in Rust handler.
74    Native(CommandFn),
75    /// Dotted action symbol resolved at run time (Lisp `defcmd`).
76    Action(String),
77    Blue(String),
78}
79
80#[derive(Debug, Clone)]
81pub struct Command {
82    pub name: String,
83    pub description: String,
84    pub handler: Handler,
85}
86
87impl Command {
88    /// A built-in command backed by a compiled-in Rust `fn`.
89    pub fn native(
90        name: impl Into<String>,
91        description: impl Into<String>,
92        handler: CommandFn,
93    ) -> Self {
94        Self {
95            name: name.into(),
96            description: description.into(),
97            handler: Handler::Native(handler),
98        }
99    }
100
101    /// A Lisp-authored command whose behavior is a dotted action
102    /// symbol resolved at run time. Mirrors `(defcmd :name … :action
103    /// "buffer.write-all")`.
104    pub fn action(
105        name: impl Into<String>,
106        description: impl Into<String>,
107        action: impl Into<String>,
108    ) -> Self {
109        Self {
110            name: name.into(),
111            description: description.into(),
112            handler: Handler::Action(action.into()),
113        }
114    }
115
116    pub fn blue(
117        name: impl Into<String>,
118        description: impl Into<String>,
119        function: impl Into<String>,
120    ) -> Self {
121        Self {
122            name: name.into(),
123            description: description.into(),
124            handler: Handler::Blue(function.into()),
125        }
126    }
127}
128
129fn run_blue_source(_: &dyn Snapshot, args: &[String]) -> Outcome {
130    Outcome::did([Negai::RunBlue(args.join(" "))])
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
134pub struct CommandSpec {
135    pub name: String,
136    pub description: String,
137    #[serde(default)]
138    pub args: Vec<CommandArgSpec>,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
142pub struct CommandArgSpec {
143    pub name: String,
144    pub description: String,
145    #[serde(default)]
146    pub required: bool,
147    #[serde(default, skip_serializing_if = "Vec::is_empty")]
148    pub variants: Vec<String>,
149}
150
151#[derive(Debug, Default, Clone)]
152pub struct CommandRegistry {
153    commands: HashMap<String, Command>,
154}
155
156impl CommandRegistry {
157    #[must_use]
158    pub fn new() -> Self {
159        Self::default()
160    }
161
162    #[must_use]
163    pub fn default_set() -> Self {
164        let mut r = Self::new();
165        r.register(Command::native(
166            "blue",
167            "Run a line of blue against the editor",
168            run_blue_source,
169        ));
170        r.register(Command::native(
171            "save",
172            "Write the active buffer to disk",
173            erase::<Save>(),
174        ));
175        // ── the write/quit family ────────────────────────────────────────
176        //
177        // One registered command per DISTINCT BEHAVIOUR; the many spellings
178        // an operator can type (`:x`, `:wqa`, `:quita`, …) are the ex
179        // grammar's business, not the registry's. See [`ex::VERBS`].
180        r.register(Command::native(
181            "quit",
182            "Exit the editor, unless a buffer is modified",
183            erase::<QuitChecked<false>>(),
184        ));
185        r.register(Command::native(
186            "quit!",
187            "Exit the editor, discarding unsaved changes",
188            erase::<Quit>(),
189        ));
190        r.register(Command::native(
191            "quit-all",
192            "Exit the editor, unless any buffer is modified",
193            erase::<QuitChecked<true>>(),
194        ));
195        r.register(Command::native(
196            "quit-all!",
197            "Exit the editor, discarding every unsaved change",
198            erase::<Quit>(),
199        ));
200        r.register(Command::native(
201            "write-quit",
202            "Write the active buffer, then exit",
203            erase::<WriteQuit>(),
204        ));
205        r.register(Command::native(
206            "write-quit-all",
207            "Write every modified buffer, then exit",
208            erase::<WriteQuitAll>(),
209        ));
210        r.register(Command::native(
211            "exit-write",
212            "Write the active buffer if modified, then exit",
213            erase::<ExitWrite>(),
214        ));
215        r.register(Command::native(
216            "buffer.write-all",
217            "Write every modified buffer",
218            erase::<WriteAll>(),
219        ));
220        // Named for the ACTION SYMBOLS the shipped keybindings use, so
221        // `<leader>bn` resolves instead of reporting "declared but not
222        // implemented yet". These are the first three entries to leave the
223        // INERT inventory in escriba/tests/action_resolution.rs.
224        r.register(Command::native(
225            "buffer.next",
226            "Go to the next buffer",
227            erase::<BufferNext>(),
228        ));
229        r.register(Command::native(
230            "buffer.prev",
231            "Go to the previous buffer",
232            erase::<BufferPrev>(),
233        ));
234        r.register(Command::native(
235            "buffer.delete",
236            "Close the active buffer",
237            erase::<BufferDelete>(),
238        ));
239        r.register(Command::native(
240            "picker.buffers",
241            "Pick an open buffer",
242            erase::<OpenPicker<false>>(),
243        ));
244        r.register(Command::native(
245            "picker.commands",
246            "Pick a command",
247            erase::<OpenPicker<true>>(),
248        ));
249        r.register(Command::native(
250            "picker.help",
251            "Search every keybinding",
252            erase::<HelpPicker>(),
253        ));
254        r.register(Command::native(
255            "picker.grep",
256            "Search the project for a pattern",
257            erase::<GrepPicker>(),
258        ));
259        r.register(Command::native(
260            "lsp.format",
261            "Format the buffer through its language server",
262            erase::<LspFormat>(),
263        ));
264        r.register(Command::native(
265            "dap.toggle-breakpoint",
266            "Set or clear a breakpoint on the cursor's line",
267            erase::<DapToggleBreakpoint>(),
268        ));
269        r.register(Command::native(
270            "picker.files",
271            "Pick a file under the working directory",
272            erase::<WalkPicker<false>>(),
273        ));
274        r.register(Command::native(
275            "picker.project",
276            "Pick a project root",
277            erase::<WalkPicker<true>>(),
278        ));
279        // ── the oil.nvim verbs ───────────────────────────────────────────
280        // `files.open` IS the file picker — same capability, the name the
281        // catalog binds. Registering it as its own native rather than an
282        // alias keeps `--commands` honest about what each name does.
283        r.register(Command::native(
284            "files.open",
285            "Browse files under the working directory",
286            erase::<WalkPicker<false>>(),
287        ));
288        r.register(Command::native(
289            "files.open-parent",
290            "Browse files from the parent directory",
291            erase::<ParentPicker>(),
292        ));
293        // ── the trouble.nvim verbs ───────────────────────────────────────
294        r.register(Command::native(
295            "trouble.toggle",
296            "Show located findings",
297            erase::<FindingsPicker<true>>(),
298        ));
299        r.register(Command::native(
300            "trouble.workspace",
301            "Show findings across the workspace",
302            erase::<FindingsPicker<true>>(),
303        ));
304        r.register(Command::native(
305            "trouble.document",
306            "Show findings in this buffer",
307            erase::<FindingsPicker<false>>(),
308        ));
309        r.register(Command::native(
310            "window.split",
311            "Split the window horizontally (:sp)",
312            erase::<SplitWindow<true>>(),
313        ));
314        r.register(Command::native(
315            "window.vsplit",
316            "Split the window vertically (:vsp)",
317            erase::<SplitWindow<false>>(),
318        ));
319        r.register(Command::native(
320            "window.close",
321            "Close the active window (:close)",
322            erase::<CloseWindow>(),
323        ));
324        r.register(Command::native(
325            "pane.left",
326            "Focus the window to the left",
327            erase::<FocusDir<-1, 0>>(),
328        ));
329        r.register(Command::native(
330            "pane.right",
331            "Focus the window to the right",
332            erase::<FocusDir<1, 0>>(),
333        ));
334        r.register(Command::native(
335            "pane.up",
336            "Focus the window above",
337            erase::<FocusDir<0, -1>>(),
338        ));
339        r.register(Command::native(
340            "pane.down",
341            "Focus the window below",
342            erase::<FocusDir<0, 1>>(),
343        ));
344        r.register(Command::native(
345            "conflict.next",
346            "Go to the next merge conflict",
347            erase::<ConflictWalk<true>>(),
348        ));
349        r.register(Command::native(
350            "conflict.prev",
351            "Go to the previous merge conflict",
352            erase::<ConflictWalk<false>>(),
353        ));
354        r.register(Command::native(
355            "conflict.choose-ours",
356            "Resolve the conflict keeping ours",
357            erase::<ChooseSide<0>>(),
358        ));
359        r.register(Command::native(
360            "conflict.choose-theirs",
361            "Resolve the conflict keeping theirs",
362            erase::<ChooseSide<1>>(),
363        ));
364        r.register(Command::native(
365            "conflict.choose-both",
366            "Resolve the conflict keeping both",
367            erase::<ChooseSide<2>>(),
368        ));
369        r.register(Command::native(
370            "todo.next",
371            "Go to the next TODO/FIXME marker",
372            erase::<TodoWalk<true>>(),
373        ));
374        r.register(Command::native(
375            "todo.prev",
376            "Go to the previous TODO/FIXME marker",
377            erase::<TodoWalk<false>>(),
378        ));
379        for name in ["comment.toggle-line", "comment.toggle-block"] {
380            r.register(Command::native(
381                name,
382                "Toggle the comment on the current line",
383                erase::<CommentToggle>(),
384            ));
385        }
386        for alias in ["noh", "nohl", "nohlsearch"] {
387            r.register(Command::action(
388                alias,
389                "Stop highlighting matches, keep the pattern",
390                "search.clear-highlight",
391            ));
392        }
393        r.register(Command::native(
394            "undo",
395            "Undo the last change",
396            erase::<Undo>(),
397        ));
398        r.register(Command::native(
399            "redo",
400            "Redo the last undone change",
401            erase::<Redo>(),
402        ));
403        r.register(Command::native(
404            "buffer-info",
405            "Print the active buffer summary",
406            erase::<Info>(),
407        ));
408        r
409    }
410
411    pub fn register(&mut self, command: Command) {
412        self.commands.insert(command.name.clone(), command);
413    }
414
415    /// Is `name` registered? Lets the apply layer report
416    /// override-vs-new without exposing the inner map.
417    #[must_use]
418    pub fn contains(&self, name: &str) -> bool {
419        self.commands.contains_key(name)
420    }
421
422    /// Number of registered commands.
423    #[must_use]
424    pub fn len(&self) -> usize {
425        self.commands.len()
426    }
427
428    /// True when no commands are registered.
429    #[must_use]
430    pub fn is_empty(&self) -> bool {
431        self.commands.is_empty()
432    }
433
434    /// Dispatch `name`.
435    ///
436    /// `Err` means the registry could not dispatch at all — Phase 0's two
437    /// failures, kept distinct because they mean different things to the
438    /// operator. `Ok(outcome)` means a body ran and reported for itself.
439    pub fn run(&self, name: &str, snap: &dyn Snapshot, args: &[String]) -> Result<Outcome> {
440        self.run_bounded(name, snap, args, ALIAS_FUEL)
441    }
442
443    /// One resolution path, bounded.
444    ///
445    /// An action symbol resolves against the BUILT-IN table first, then
446    /// against the registry itself. That second step is the whole point: a
447    /// `(defcmd :name "CommentToggle" :action "comment.toggle-line")` alias
448    /// used to die as `Unhandled` even though `comment.toggle-line` was a
449    /// registered native sitting in the same map — two dispatch tables that
450    /// had to agree, and did not. Every one of the 41 catalog aliases was
451    /// dead for exactly this reason.
452    ///
453    /// Resolving through the registry admits cycles, so the chain runs on
454    /// fuel and exhaustion is a typed `AliasCycle`.
455    fn run_bounded(
456        &self,
457        name: &str,
458        snap: &dyn Snapshot,
459        args: &[String],
460        fuel: u8,
461    ) -> Result<Outcome> {
462        let Some(fuel) = fuel.checked_sub(1) else {
463            return Err(CommandError::AliasCycle(name.to_string()));
464        };
465        let cmd = self
466            .commands
467            .get(name)
468            .ok_or_else(|| CommandError::NotFound(name.to_string()))?;
469        match &cmd.handler {
470            Handler::Native(f) => Ok(f(snap, args)),
471            Handler::Blue(function) => Ok(Outcome::did([Negai::CallBlue {
472                function: function.clone(),
473                args: args.to_vec(),
474            }])),
475            Handler::Action(sym) => match builtin_action(sym) {
476                Some(f) => Ok(f(snap, args)),
477                // Not a built-in — but the symbol may name a registered
478                // command. `sym != name` keeps a self-referential alias from
479                // burning the whole budget before reporting.
480                None if sym != name && self.commands.contains_key(sym.as_str()) => {
481                    self.run_bounded(sym, snap, args, fuel)
482                }
483                None => Err(CommandError::Unhandled(sym.to_string())),
484            },
485        }
486    }
487
488    #[must_use]
489    pub fn names(&self) -> Vec<&str> {
490        let mut v: Vec<&str> = self.commands.keys().map(String::as_str).collect();
491        v.sort_unstable();
492        v
493    }
494
495    #[must_use]
496    pub fn specs(&self) -> Vec<CommandSpec> {
497        let mut out: Vec<CommandSpec> = self
498            .commands
499            .values()
500            .map(|c| CommandSpec {
501                name: c.name.to_string(),
502                description: c.description.to_string(),
503                args: Vec::new(),
504            })
505            .collect();
506        out.sort_by(|a, b| a.name.cmp(&b.name));
507        out
508    }
509}
510
511/// How many alias hops a chain may take before it is called a cycle.
512///
513/// Small on purpose: a legitimate chain is an alias naming a native, which
514/// is two hops. Anything deeper is a configuration mistake worth reporting.
515const ALIAS_FUEL: u8 = 8;
516
517/// The dotted action symbols escriba implements natively.
518///
519/// Returns `None` for anything else so the caller can try the registry —
520/// this used to return `Err(Unhandled)` directly, which is what made the
521/// built-in table the ONLY table and killed every catalog alias.
522fn builtin_action(sym: &str) -> Option<CommandFn> {
523    Some(match sym {
524        "buffer.save" | "buffer.write" => erase::<Save>(),
525        "buffer.write-all" => erase::<WriteAll>(),
526        "buffer.undo" => erase::<Undo>(),
527        "buffer.redo" => erase::<Redo>(),
528        "buffer.info" => erase::<Info>(),
529        "editor.quit" => erase::<Quit>(),
530        "search.clear-highlight" => erase::<Noh>(),
531        // The not-yet-implemented namespace. The shipped keybindings that
532        // land here are enumerated by `escriba/tests/action_resolution.rs`,
533        // which asserts SET EQUALITY — so the count is READ from there rather
534        // than restated. It said 85 while the real figure had ratcheted to
535        // 78; a duplicated number is a number that rots.
536        // Inert and ANNOUNCED; see CommandError::Unhandled.
537        _ => return None,
538    })
539}
540
541/// The active buffer, or the outcome to return when there isn't one.
542///
543/// "No buffer" is a DECLINE, not a failure: it is a legitimate state (boot,
544/// every `--no-defaults` run) and the operator did nothing wrong.
545fn active_or_decline(b: &escriba_madoguchi::snapshot::Buffers<'_>) -> Result2<BufferId> {
546    b.active()
547        .map(BufferView::id)
548        .ok_or_else(|| Outcome::declined("no active buffer"))
549}
550
551type Result2<T> = std::result::Result<T, Outcome>;
552
553/// Save every modified, path-backed buffer.
554///
555/// Best-effort BY CONSTRUCTION: one slip per buffer, applied independently,
556/// so one buffer's permission error cannot abort the rest. Scratch buffers
557/// have no path and are skipped.
558struct WriteAll;
559impl Native for WriteAll {
560    type Reads = caps!(Buffers);
561    fn run(v: &View<'_, Self::Reads>, _args: &[String]) -> Outcome {
562        let b = v.buffers();
563        let slips: Vec<Negai> = b
564            .ids()
565            .into_iter()
566            .filter(|id| {
567                b.get(*id)
568                    .is_some_and(|x| x.is_modified() && x.path().is_some())
569            })
570            .map(|buffer| Negai::Save { buffer })
571            .collect();
572        if slips.is_empty() {
573            return Outcome::declined("no modified files");
574        }
575        Outcome::did(slips)
576    }
577}
578
579struct Save;
580impl Native for Save {
581    type Reads = caps!(Buffers);
582    fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
583        match active_or_decline(&v.buffers()) {
584            Ok(buffer) => Outcome::did(vec![Negai::Save { buffer }]),
585            Err(o) => o,
586        }
587    }
588}
589
590struct Undo;
591impl Native for Undo {
592    type Reads = caps!(Buffers);
593    fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
594        match active_or_decline(&v.buffers()) {
595            Ok(buffer) => Outcome::did(vec![Negai::Undo { buffer }]),
596            Err(o) => o,
597        }
598    }
599}
600
601struct Redo;
602impl Native for Redo {
603    type Reads = caps!(Buffers);
604    fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
605        match active_or_decline(&v.buffers()) {
606            Ok(buffer) => Outcome::did(vec![Negai::Redo { buffer }]),
607            Err(o) => o,
608        }
609    }
610}
611
612/// Report the active buffer's shape.
613///
614/// This used to `eprintln!`. From a TUI holding the alternate screen that
615/// writes straight through the ratatui frame and corrupts it — a latent bug
616/// the port removed for free, because a command's only way to say something
617/// is now `Negai::Message`, which lands on the status line.
618struct Info;
619impl Native for Info {
620    type Reads = caps!(Buffers);
621    fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
622        let b = v.buffers();
623        let Some(buf) = b.active() else {
624            return Outcome::declined("no active buffer");
625        };
626        let mut m = String::with_capacity(48);
627        m.push_str("buffer ");
628        m.push_str(&buf.id().0.to_string());
629        m.push_str(" — ");
630        m.push_str(&buf.line_count().to_string());
631        m.push_str(" line(s)");
632        if buf.is_modified() {
633            m.push_str(" [modified]");
634        }
635        Outcome::did(vec![Negai::Message(m)])
636    }
637}
638
639/// Quit reads NOTHING.
640///
641/// Worth pausing on: under the old `EditContext` this function was handed
642/// `&mut BufferSet` and `&mut ModalState` in order to set one bool. Its
643/// capability set is now literally empty, and the type system enforces that
644/// — `caps!()` proves no membership, so every accessor on its view is
645/// unbuildable.
646/// `buffer.next` / `buffer.prev` — walk the buffer list.
647/// `picker.buffers` / `picker.commands` — open a picker over a source.
648///
649/// Reads `caps!()`: the handler does not build the item list. It cannot —
650/// a picker needs `&mut` across many keypresses and a handler holds a
651/// read-only `Snapshot`. So the slip NAMES the source and the interpreter
652/// populates it, which keeps the one-writer seam intact while the widget
653/// still gets the mutable state it needs.
654struct OpenPicker<const COMMANDS: bool>;
655impl<const COMMANDS: bool> Native for OpenPicker<COMMANDS> {
656    type Reads = caps!();
657    fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
658        Outcome::did(vec![Negai::OpenPicker(if COMMANDS {
659            escriba_madoguchi::PickerSource::Commands
660        } else {
661            escriba_madoguchi::PickerSource::Buffers
662        })])
663    }
664}
665
666/// `picker.help` — the searchable keymap.
667struct HelpPicker;
668impl Native for HelpPicker {
669    type Reads = caps!();
670    fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
671        Outcome::did(vec![Negai::OpenPicker(
672            escriba_madoguchi::PickerSource::Help,
673        )])
674    }
675}
676
677/// `picker.grep` — matches for a pattern across the project.
678///
679/// The pattern is the command's ARGUMENT (`:picker.grep fn main`). Declining
680/// with no argument rather than opening an empty picker: an overlay with
681/// nothing in it and no way to say why is worse than a message.
682struct GrepPicker;
683impl Native for GrepPicker {
684    type Reads = caps!();
685    fn run(_v: &View<'_, Self::Reads>, args: &[String]) -> Outcome {
686        let pattern = args.join(" ");
687        if pattern.is_empty() {
688            return Outcome::declined("grep: give a pattern — `:picker.grep <pattern>`");
689        }
690        Outcome::did(vec![Negai::GrepProject { pattern }])
691    }
692}
693
694/// `lsp.format` — format the active buffer through its language server.
695///
696/// The name the catalog already binds. `escriba-conform.escribaplugin.lisp`
697/// has declared `(defcmd :name "Format" :action "lsp.format")`, a
698/// `<leader>lf` keybind and a `BufWritePre` hook against this name since it
699/// was written; all three resolved to nothing, and `tests/action_resolution.rs`
700/// listed `lsp.format` in its INERT set to say so out loud. This is the
701/// implementation those declarations were waiting for.
702struct LspFormat;
703impl Native for LspFormat {
704    type Reads = caps!();
705    fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
706        Outcome::did(vec![Negai::FormatBuffer])
707    }
708}
709
710/// `dap.toggle-breakpoint` — mark the cursor's line, or unmark it.
711///
712/// The name `escriba-dap.escribaplugin.lisp` has bound to `<leader>db` and to
713/// the `:DapToggleBreakpoint` alias since it was written; both resolved to
714/// nothing, and BOTH ratchets said so out loud (`tests/action_resolution.rs`'s
715/// INERT set and `tests/alias_revival.rs`'s `UNHANDLED_ALIASES`).
716///
717/// **It is a mark, not a debugger.** There is no adapter, no session, no
718/// protocol — the other six `dap.*` actions stay inert and stay listed. What
719/// this buys, besides the thing an operator can see on the first keypress, is
720/// the gutter cell every later DAP feature needs: `escriba_ui::gutter` now
721/// carries a breakpoint plane beside the severity plane, so a stopped-line
722/// arrow or a hit-count is a role, not a widening.
723struct DapToggleBreakpoint;
724impl Native for DapToggleBreakpoint {
725    type Reads = caps!();
726    fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
727        Outcome::did(vec![Negai::ToggleBreakpoint])
728    }
729}
730
731/// `picker.files` / `picker.project` — the bounded-walk sources.
732struct WalkPicker<const PROJECT: bool>;
733impl<const PROJECT: bool> Native for WalkPicker<PROJECT> {
734    type Reads = caps!();
735    fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
736        Outcome::did(vec![Negai::OpenPicker(if PROJECT {
737            escriba_madoguchi::PickerSource::Project
738        } else {
739            escriba_madoguchi::PickerSource::Files
740        })])
741    }
742}
743
744/// `files.open-parent` — the same bounded walk, rooted one level up.
745///
746/// oil.nvim's `-`: browse from where the current file lives, then upward.
747/// The root is resolved from the working directory rather than the buffer
748/// because a scratch buffer has no directory, and "browse from nowhere" has
749/// no sensible answer — falling back to `.` is the honest one.
750struct ParentPicker;
751impl Native for ParentPicker {
752    type Reads = caps!();
753    fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
754        let root = std::env::current_dir()
755            .ok()
756            .and_then(|d| d.parent().map(std::path::Path::to_path_buf))
757            .unwrap_or_else(|| std::path::PathBuf::from(".."));
758        Outcome::did(vec![Negai::OpenPicker(
759            escriba_madoguchi::PickerSource::FilesUnder(root),
760        )])
761    }
762}
763
764/// `trouble.*` — a view over the result registry.
765///
766/// `WORKSPACE` is the whole difference between `trouble.document` and
767/// `trouble.workspace`; `trouble.toggle` is the workspace view because that
768/// is what an operator means by "show me the problems".
769struct FindingsPicker<const WORKSPACE: bool>;
770impl<const WORKSPACE: bool> Native for FindingsPicker<WORKSPACE> {
771    type Reads = caps!();
772    fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
773        Outcome::did(vec![Negai::OpenPicker(
774            escriba_madoguchi::PickerSource::Findings {
775                workspace: WORKSPACE,
776            },
777        )])
778    }
779}
780
781/// `:sp` / `:vsp` — split the active window.
782///
783/// Reads `caps!()`: splitting is layout, not buffer content. The handler
784/// names the axis and the interpreter does it, which is the same shape the
785/// picker uses and for the same reason — a handler holds a read-only
786/// `Snapshot`.
787struct SplitWindow<const STACKED: bool>;
788impl<const STACKED: bool> Native for SplitWindow<STACKED> {
789    type Reads = caps!();
790    fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
791        Outcome::did(vec![Negai::SplitWindow { stacked: STACKED }])
792    }
793}
794
795/// `:close` / `<C-w>c`.
796struct CloseWindow;
797impl Native for CloseWindow {
798    type Reads = caps!();
799    fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
800        Outcome::did(vec![Negai::CloseWindow])
801    }
802}
803
804/// `pane.{left,right,up,down}` — `<C-w>hjkl`.
805///
806/// The direction is two const params rather than an enum so one impl covers
807/// all four without a runtime match, and so a wrong direction is a wrong
808/// TYPE at the registration site rather than a wrong argument.
809struct FocusDir<const DX: i8, const DY: i8>;
810impl<const DX: i8, const DY: i8> Native for FocusDir<DX, DY> {
811    type Reads = caps!();
812    fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
813        Outcome::did(vec![Negai::FocusDir { dx: DX, dy: DY }])
814    }
815}
816
817/// `]x` / `[x` — walk merge conflicts.
818///
819/// Identical in shape to `TodoWalk`, and that is the claim being tested: a
820/// conflict is a located finding, so navigating one is the SAME walk. If
821/// this needed anything TodoWalk did not, the shirube model would be wrong.
822struct ConflictWalk<const FORWARD: bool>;
823impl<const FORWARD: bool> Native for ConflictWalk<FORWARD> {
824    type Reads = caps!(Buffers);
825    fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
826        let b = v.buffers();
827        let Some(buf) = b.active() else {
828            return Outcome::declined("no active buffer");
829        };
830        let findings = escriba_shirube::conflict::findings(buf.id(), &buf.text());
831        if findings.is_empty() {
832            return Outcome::declined("no merge conflicts in this buffer");
833        }
834        Outcome::did(vec![
835            Negai::PublishFindings {
836                list: "conflict".to_string(),
837                findings,
838            },
839            Negai::WalkList {
840                list: "conflict".to_string(),
841                forward: FORWARD,
842            },
843        ])
844    }
845}
846
847/// `conflict.choose-{ours,theirs,both}` — resolve the conflict at the cursor.
848///
849/// Reads `caps!(Buffers, Cursor)`: it needs the text to find the region and
850/// the cursor to know WHICH region. The edit it emits replaces whole lines,
851/// because there is no such thing as keeping half of "ours".
852struct ChooseSide<const SIDE: u8>;
853impl<const SIDE: u8> Native for ChooseSide<SIDE> {
854    type Reads = caps!(Buffers, Cursor);
855    fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
856        use escriba_shirube::conflict::{Side, at, resolution};
857        let b = v.buffers();
858        let Some(buf) = b.active() else {
859            return Outcome::declined("no active buffer");
860        };
861        let text = buf.text();
862        let line = v.cursor().position().line;
863        let Some(c) = at(&text, line) else {
864            // Declined, not failed: standing outside a conflict is an
865            // ordinary place to be, and vim says nothing there either.
866            return Outcome::declined("not inside a merge conflict");
867        };
868        let side = match SIDE {
869            0 => Side::Ours,
870            1 => Side::Theirs,
871            _ => Side::Both,
872        };
873        let (from, to) = c.lines();
874        Outcome::did(vec![
875            Negai::Edit {
876                buffer: buf.id(),
877                edit: escriba_core::Edit {
878                    range: escriba_core::Range::new(
879                        escriba_core::Position::new(from, 0),
880                        escriba_core::Position::new(to, 0),
881                    ),
882                    kind: escriba_core::EditKind::Replace {
883                        text: resolution(&text, c, side),
884                    },
885                },
886            },
887            // Land on the resolved text rather than wherever the deleted
888            // markers left the cursor.
889            Negai::SetCursor {
890                buffer: buf.id(),
891                to: escriba_core::Position::new(from, 0),
892            },
893        ])
894    }
895}
896
897struct BufferNext;
898impl Native for BufferNext {
899    type Reads = caps!();
900    fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
901        Outcome::did(vec![Negai::CycleBuffer { forward: true }])
902    }
903}
904
905struct BufferPrev;
906impl Native for BufferPrev {
907    type Reads = caps!();
908    fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
909        Outcome::did(vec![Negai::CycleBuffer { forward: false }])
910    }
911}
912
913/// `buffer.delete` — close the active buffer.
914///
915/// Reads `Buffers` only to name WHICH buffer; whether a modified buffer may
916/// close, and what becomes active afterwards, are the interpreter's policy.
917struct BufferDelete;
918impl Native for BufferDelete {
919    type Reads = caps!(Buffers);
920    fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
921        match active_or_decline(&v.buffers()) {
922            Ok(buffer) => Outcome::did(vec![Negai::CloseBuffer(buffer)]),
923            Err(o) => o,
924        }
925    }
926}
927
928/// `comment.toggle-line` / `comment.toggle-block` — the first commands to
929/// need TWO capabilities, and the first consumer of `:commentstring`.
930///
931/// Toggle, not comment: if the line is already commented it is uncommented.
932/// A one-way "comment" verb makes the same keystroke mean two things
933/// depending on state, which is how you end up with `//// x`.
934struct CommentToggle;
935impl Native for CommentToggle {
936    type Reads = caps!(Buffers, Cursor, Syntax);
937    fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
938        let Some(ft) = v.syntax().filetype() else {
939            return Outcome::declined("no filetype for this buffer");
940        };
941        let Some(comment) = ft.comment.as_ref() else {
942            let mut m = String::from("no comment syntax for ");
943            m.push_str(&ft.name);
944            return Outcome::declined(m);
945        };
946        let b = v.buffers();
947        let Some(buf) = b.active() else {
948            return Outcome::declined("no active buffer");
949        };
950        let line_no = v.cursor().position().line;
951        let Some(line) = buf.line(line_no) else {
952            return Outcome::declined("cursor past the end of the buffer");
953        };
954        // An empty line has nothing to comment, and commenting it would
955        // leave a bare marker the next toggle cannot recognise as content.
956        if line.trim().is_empty() {
957            return Outcome::declined("nothing on this line");
958        }
959
960        // Indentation is preserved: a comment marker inserted before the
961        // indent would destroy the alignment the code is relying on.
962        let indent_len = line.len() - line.trim_start().len();
963        let (indent, body) = line.split_at(indent_len);
964        let toggled = match comment.strip(body) {
965            Some(uncommented) => uncommented.to_string(),
966            None => comment.wrap(body),
967        };
968        let mut text = String::with_capacity(indent.len() + toggled.len());
969        text.push_str(indent);
970        text.push_str(&toggled);
971
972        Outcome::did(vec![Negai::Edit {
973            buffer: buf.id(),
974            edit: escriba_core::Edit {
975                range: escriba_core::Range::new(
976                    escriba_core::Position::new(line_no, 0),
977                    escriba_core::Position::new(
978                        line_no,
979                        u32::try_from(line.chars().count()).unwrap_or(u32::MAX),
980                    ),
981                ),
982                kind: escriba_core::EditKind::Replace { text },
983            },
984        }])
985    }
986}
987
988/// `todo.next` / `todo.prev` — walk the marker list.
989///
990/// Scans on every invocation rather than relying on a cached list. The scan
991/// is pure text and costs nothing at keyboard cadence, and re-scanning means
992/// the list is always fresh — the freshness machinery then guards the window
993/// BETWEEN a publish and a walk, which is where a stale list would otherwise
994/// slip through.
995///
996/// This is also the shape every later producer takes: the command COMPUTES
997/// (it has the text through `Buffers`) and asks the interpreter to publish.
998/// Nothing here touches the registry.
999struct TodoWalk<const FORWARD: bool>;
1000impl<const FORWARD: bool> Native for TodoWalk<FORWARD> {
1001    type Reads = caps!(Buffers);
1002    fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
1003        let b = v.buffers();
1004        let Some(buf) = b.active() else {
1005            return Outcome::declined("no active buffer");
1006        };
1007        let findings = escriba_shirube::scan_markers(buf.id(), &buf.text());
1008        if findings.is_empty() {
1009            return Outcome::declined("no TODO markers in this buffer");
1010        }
1011        Outcome::did(vec![
1012            Negai::PublishFindings {
1013                list: "todo".to_string(),
1014                findings,
1015            },
1016            Negai::WalkList {
1017                list: "todo".to_string(),
1018                forward: FORWARD,
1019            },
1020        ])
1021    }
1022}
1023
1024/// `:q!` / `:qa!` — leave, whatever the buffers say.
1025///
1026/// Reads nothing, on purpose. The force form's whole meaning is "do not
1027/// consult the thing that would stop you", and a handler that cannot see the
1028/// modified flag cannot be talked into honouring it.
1029struct Quit;
1030impl Native for Quit {
1031    type Reads = caps!();
1032    fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
1033        Outcome::did(vec![Negai::Quit])
1034    }
1035}
1036
1037/// vim's E37, worded as vim words it — the `(add ! to override)` tail is the
1038/// half that matters, because it names the key that gets the operator out.
1039fn no_write_since_last_change(what: &str) -> Outcome {
1040    let mut m = String::with_capacity(64);
1041    m.push_str("E37: No write since last change in ");
1042    m.push_str(what);
1043    m.push_str(" (add ! to override)");
1044    Outcome::declined(m)
1045}
1046
1047/// How a buffer is named in a message: its path, or `[No Name]` as vim calls
1048/// an unsaved scratch.
1049fn buffer_label(b: &dyn BufferView) -> String {
1050    b.path()
1051        .map_or_else(|| "[No Name]".to_string(), |p| p.display().to_string())
1052}
1053
1054/// `:q` — leave, unless that would drop an unsaved change.
1055///
1056/// The refusal is what makes the bang mean something. Without it `:q` and
1057/// `:q!` are the same key sequence with different lengths, and the editor
1058/// discards an afternoon's typing without a word — which is the one failure
1059/// mode a modal editor's users are trained to expect protection from.
1060struct QuitChecked<const ALL: bool>;
1061impl<const ALL: bool> Native for QuitChecked<ALL> {
1062    type Reads = caps!(Buffers);
1063    fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
1064        let b = v.buffers();
1065        // `:q` asks about the buffer in front of the operator; `:qa` asks
1066        // about every one, because it is the whole editor it is closing.
1067        let blocker = if ALL {
1068            b.ids()
1069                .into_iter()
1070                .filter_map(|id| b.get(id))
1071                .find(|x| x.is_modified())
1072                .map(buffer_label)
1073        } else {
1074            b.active().filter(|x| x.is_modified()).map(buffer_label)
1075        };
1076        match blocker {
1077            Some(what) => no_write_since_last_change(&what),
1078            None => Outcome::did(vec![Negai::Quit]),
1079        }
1080    }
1081}
1082
1083/// `:wq` — write the active buffer, then leave.
1084///
1085/// Writes unconditionally, as vim does: `:wq` is how you touch a file's mtime
1086/// on purpose. `:x` is the other one. A buffer with no path cannot be written
1087/// anywhere, so it declines rather than quitting and silently losing the
1088/// text — `:wq` on a scratch buffer promised a write.
1089struct WriteQuit;
1090impl Native for WriteQuit {
1091    type Reads = caps!(Buffers);
1092    fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
1093        let b = v.buffers();
1094        let Some(buf) = b.active() else {
1095            return Outcome::declined("no active buffer");
1096        };
1097        if buf.path().is_none() {
1098            return Outcome::declined("E32: No file name");
1099        }
1100        Outcome::did(vec![Negai::Save { buffer: buf.id() }, Negai::Quit])
1101    }
1102}
1103
1104/// `:x` / `:exit` — write only if modified, then leave.
1105///
1106/// The difference from `:wq` is one `is_modified` and it is not cosmetic: a
1107/// pointless write bumps the mtime, and something is always watching the
1108/// mtime.
1109struct ExitWrite;
1110impl Native for ExitWrite {
1111    type Reads = caps!(Buffers);
1112    fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
1113        let b = v.buffers();
1114        let Some(buf) = b.active() else {
1115            return Outcome::declined("no active buffer");
1116        };
1117        if !buf.is_modified() {
1118            return Outcome::did(vec![Negai::Quit]);
1119        }
1120        if buf.path().is_none() {
1121            return Outcome::declined("E32: No file name");
1122        }
1123        Outcome::did(vec![Negai::Save { buffer: buf.id() }, Negai::Quit])
1124    }
1125}
1126
1127/// `:wqa` / `:xa` — write every modified, path-backed buffer, then leave.
1128///
1129/// Shares [`WriteAll`]'s rule about scratch buffers rather than restating it:
1130/// one slip per writable buffer, and a buffer with nowhere to go is skipped.
1131/// It does NOT decline on an unwritable buffer the way `:wq` does — `:wqa` is
1132/// a request to leave, and refusing the whole thing over one scratch buffer
1133/// would leave the operator with no way out but `:qa!`, which discards the
1134/// buffers `:wqa` was about to save.
1135struct WriteQuitAll;
1136impl Native for WriteQuitAll {
1137    type Reads = caps!(Buffers);
1138    fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
1139        let b = v.buffers();
1140        let mut slips: Vec<Negai> = b
1141            .ids()
1142            .into_iter()
1143            .filter(|id| {
1144                b.get(*id)
1145                    .is_some_and(|x| x.is_modified() && x.path().is_some())
1146            })
1147            .map(|buffer| Negai::Save { buffer })
1148            .collect();
1149        slips.push(Negai::Quit);
1150        Outcome::did(slips)
1151    }
1152}
1153
1154/// `:noh` — the command that proves the seam, and it also reads nothing.
1155///
1156/// It lived as a hard-coded branch inside `EditorState::run_command`,
1157/// bypassing the registry entirely, because the old `EditContext` exposed
1158/// buffers and modal state and could not reach `SearchState`. It is now an
1159/// ordinary command asking for an ordinary slip, and it turns out not to
1160/// need a view at all — it does not READ the search, it asks to change it.
1161struct Noh;
1162impl Native for Noh {
1163    type Reads = caps!();
1164    fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
1165        Outcome::did(vec![Negai::ClearSearchHighlight])
1166    }
1167}
1168
1169#[cfg(test)]
1170mod tests {
1171    use super::*;
1172    use escriba_core::BufferId;
1173    use escriba_madoguchi::{FakeBuffer, FakeSnapshot, Verdict};
1174
1175    /// A snapshot holding one dirty, path-backed buffer.
1176    fn dirty_file() -> FakeSnapshot {
1177        let mut s = FakeSnapshot::default();
1178        s.buffers = vec![FakeBuffer::new(1, "dirty").at("/tmp/x.txt").dirty()];
1179        s.active = Some(BufferId(1));
1180        s
1181    }
1182
1183    #[test]
1184    fn default_set_is_populated() {
1185        let r = CommandRegistry::default_set();
1186        let names = r.names();
1187        assert!(names.contains(&"save"));
1188        assert!(names.contains(&"quit"));
1189    }
1190
1191    #[test]
1192    fn specs_are_sorted() {
1193        let r = CommandRegistry::default_set();
1194        let specs = r.specs();
1195        assert!(specs.windows(2).all(|w| w[0].name <= w[1].name));
1196    }
1197
1198    #[test]
1199    fn not_found_errors() {
1200        // Phase 0's first failure: a name nobody registered. Still an Err,
1201        // because the runtime tells a typo apart from an unbuilt capability.
1202        let r = CommandRegistry::new();
1203        let err = r.run("nope", &FakeSnapshot::default(), &[]).unwrap_err();
1204        assert!(matches!(err, CommandError::NotFound(_)));
1205    }
1206
1207    #[test]
1208    fn a_command_asks_rather_than_acts() {
1209        // The whole point of the port. `write-all` used to reach into
1210        // `&mut BufferSet` and call `.save()`. It now RETURNS a request per
1211        // modified path-backed buffer and touches nothing — which is also
1212        // why it is best-effort by construction: the interpreter applies
1213        // each slip independently, so one permission error cannot abort the
1214        // rest.
1215        let mut r = CommandRegistry::new();
1216        r.register(Command::action(
1217            "w-all",
1218            "Write every modified buffer",
1219            "buffer.write-all",
1220        ));
1221        let out = r
1222            .run("w-all", &dirty_file(), &[])
1223            .expect("registered command dispatches");
1224        assert_eq!(
1225            out.slips,
1226            vec![Negai::Save {
1227                buffer: BufferId(1)
1228            }]
1229        );
1230        assert_eq!(out.verdict, Verdict::Did);
1231    }
1232
1233    #[test]
1234    fn nothing_to_save_declines_rather_than_claiming_success() {
1235        // Three verdicts, not two. A scratch buffer has no path, so there is
1236        // genuinely nothing to write — and saying "Did" would be the same
1237        // silent lie Phase 0 removed.
1238        let mut r = CommandRegistry::new();
1239        r.register(Command::action("w-all", "Write all", "buffer.write-all"));
1240        let out = r
1241            .run("w-all", &FakeSnapshot::with_buffer("scratch"), &[])
1242            .expect("dispatches");
1243        assert!(out.slips.is_empty());
1244        assert_eq!(out.verdict, Verdict::Declined("no modified files".into()));
1245    }
1246
1247    #[test]
1248    fn no_active_buffer_declines_rather_than_failing() {
1249        // Boot, and every `--no-defaults` run, reach commands with no
1250        // buffer. The operator did nothing wrong, so it is not an error.
1251        let mut r = CommandRegistry::new();
1252        r.register(Command::action("w", "Save", "buffer.save"));
1253        let out = r
1254            .run("w", &FakeSnapshot::default(), &[])
1255            .expect("dispatches");
1256        assert_eq!(out.verdict, Verdict::Declined("no active buffer".into()));
1257        assert!(out.slips.is_empty(), "a decline asks for nothing");
1258    }
1259
1260    #[test]
1261    fn unknown_action_symbol_is_reported_not_silent() {
1262        // This test used to assert the DEFECT — it called `.expect()` on the
1263        // Ok, pinning `_ => Ok(())`, under which a dead keybinding and a
1264        // working one were indistinguishable at every layer.
1265        //
1266        // Inert is still correct: `picker.files` genuinely has not landed.
1267        // SILENT was never correct. It must be `Unhandled`, not `NotFound`:
1268        // the command IS registered, which is what made the silence
1269        // misleading in the first place.
1270        let mut r = CommandRegistry::new();
1271        r.register(Command::action("pick", "Pick a file", "picker.files"));
1272        let err = r
1273            .run("pick", &FakeSnapshot::default(), &[])
1274            .expect_err("an unimplemented action must report, not report success");
1275        assert!(
1276            matches!(&err, CommandError::Unhandled(s) if s == "picker.files"),
1277            "expected Unhandled(picker.files), got {err:?}",
1278        );
1279        assert!(r.contains("pick"), "the command survives its own failure");
1280    }
1281
1282    #[test]
1283    fn action_naming_a_command_is_inert_not_recursive() {
1284        // `:action` takes action SYMBOLS, not command names: `run_action`
1285        // resolves dotted symbols and does NOT recurse into the registry.
1286        // Recursion would let a handler reach anything by naming it, which
1287        // is the ceiling madoguchi exists to remove.
1288        //
1289        // What changed with the port: the non-recursion is now REPORTED
1290        // rather than looking like a successful save.
1291        let mut r = CommandRegistry::new();
1292        r.register(Command::action("alias", "aliases save by name", "save"));
1293        let err = r
1294            .run("alias", &dirty_file(), &[])
1295            .expect_err("a command-name alias resolves nothing, and says so");
1296        assert!(
1297            matches!(&err, CommandError::Unhandled(s) if s == "save"),
1298            "expected Unhandled(save), got {err:?}",
1299        );
1300    }
1301
1302    #[test]
1303    fn quit_is_a_request_not_a_flag_poke() {
1304        // Was `*ctx.quit_requested = true` — a command reaching into a
1305        // borrowed flag. Quit is now a request like any other, and the
1306        // interpreter decides, because the interpreter is the thing that
1307        // knows about unsaved buffers.
1308        let mut r = CommandRegistry::new();
1309        r.register(Command::action("bye", "Quit", "editor.quit"));
1310        let out = r
1311            .run("bye", &FakeSnapshot::default(), &[])
1312            .expect("dispatches");
1313        assert_eq!(out.slips, vec![Negai::Quit]);
1314    }
1315
1316    #[test]
1317    fn buffer_info_speaks_through_a_slip_not_stderr() {
1318        // It used to `eprintln!`, which from a TUI holding the alternate
1319        // screen writes straight through the ratatui frame and corrupts it.
1320        // A command's only way to say anything is now Negai::Message.
1321        let mut r = CommandRegistry::new();
1322        r.register(Command::action("info", "Buffer info", "buffer.info"));
1323        let out = r.run("info", &dirty_file(), &[]).expect("dispatches");
1324        let Some(Negai::Message(m)) = out.slips.first() else {
1325            panic!("expected a Message slip, got {:?}", out.slips);
1326        };
1327        assert!(m.contains("buffer 1"), "{m}");
1328        assert!(m.contains("[modified]"), "{m}");
1329    }
1330}