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