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