Skip to main content

escriba_command/
lib.rs

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