Skip to main content

lattice_grammar/
ex_commands.rs

1//! Built-in ex-commands registered as peers of motions / operators / text
2//! objects in the unified `CommandRegistry` (DESIGN.md §5.2.1).
3//!
4//! Each spec's `apply` callback is intentionally thin: it only packages
5//! the parsed args into the matching [`Effect`] variant. The host (`App`)
6//! owns the side-effect implementation (file I/O, view options, echo
7//! area, document swap, ...) -- this keeps the closures static-state-free
8//! so they can later be loaded from a WASM plugin without redesign.
9//!
10//! Coverage:
11//! - Keyword form: `:w[rite]`, `:q[uit]`, `:wq`/`:x`, `:noh[lsearch]`,
12//!   `:reg[isters]`, `:marks`, `:d[elete]`, `:set`, `:e[dit]`.
13//! - Delimiter-syntax form (Appendix B.2): `:s/.../.../[g]`,
14//!   `:%s/.../.../[g]`, `:g/.../.../`, `:v/.../.../`. These use
15//!   `Args::List` to carry pattern / replacement / flags / body /
16//!   inverted as positional `ArgValue`s; the parser front-end strips
17//!   the delimiter prefix and dispatches through the same
18//!   `grammar::execute()` as everything else.
19//!
20//! (The list above is the original core; `populate` now also registers the
21//! buffer / pane / tab commands, the `:describe-*` and `:list-*` help
22//! family, the `:lsp-*` commands, `:format`, `:tutor`, `:cd` and more —
23//! see [`ExBuiltins`] and `:list-commands`.)
24//!
25//! Every builtin is registered as `ex:<name>`. Aliases (`:w` for
26//! `ex:write`, `:q` for `ex:quit`, `:e` for `ex:edit`, ...) are NOT
27//! separate registry entries -- they would inflate the `CommandId`
28//! namespace and complicate `:describe-command`. Alias resolution is the
29//! parser front-end's job (`expand_alias` / `ALIAS_TABLE` in
30//! `lattice-host`'s `excommand` module). New subsystem-coupled commands
31//! follow the project naming rule: one dashed, namespaced alias
32//! (`lsp-format`), no collapsed or generic forms.
33//!
34//! # Examples
35//!
36//! How the `:` front-end turns `:w out.txt` into an [`Effect`]: resolve
37//! the name, let the spec parse the rest of the line, dispatch.
38//!
39//! ```
40//! use lattice_core::{BufferId, Document};
41//! use lattice_grammar::{
42//!     CancellationToken, CommandInvocation, CommandRegistry, Effect, ex_commands, execute,
43//! };
44//! use lattice_protocol::position::Position;
45//!
46//! let mut registry = CommandRegistry::new();
47//! ex_commands::populate(&mut registry);
48//!
49//! // `w` -> `ex:write` is the host alias table's job; the registry holds
50//! // the canonical name.
51//! let id = registry.id_by_name("ex:write").unwrap();
52//! let spec = registry.ex_command_spec(id).unwrap();
53//! let args = (spec.parse_args)("out.txt", false).unwrap();
54//!
55//! let mut doc = Document::from_text("hello\n");
56//! let effect = execute(
57//!     &registry,
58//!     &mut doc,
59//!     BufferId(0),
60//!     Position::ZERO,
61//!     CommandInvocation::of(id).with_args(args),
62//!     &CancellationToken::never(),
63//! )
64//! .unwrap();
65//! // The command only describes the work; the host does the I/O.
66//! match effect {
67//!     Effect::SaveBuffer { path } => assert_eq!(path, Some("out.txt".into())),
68//!     other => panic!("unexpected {other:?}"),
69//! }
70//! ```
71
72use crate::AppEffect;
73use crate::args::{ArgDefault, ArgKind, ArgSpec, ArgValue, Args};
74use crate::command::LatencyClass;
75use crate::effect::{Effect, LspRequest, QuitScope, SubstituteScope};
76use crate::error::{CommandError, GrammarResult};
77use crate::range::Range;
78use crate::registry::{CommandRegistry, ExCommandContext, ExCommandId, ExCommandSpec, SurfaceForm};
79use std::sync::Arc;
80
81/// The ids of the built-in ex-commands, as returned by [`populate`];
82/// mirrors [`Builtins`](crate::builtins::Builtins) for motions / operators /
83/// text objects.
84///
85/// Each field's doc gives the registered name (`ex:<name>`) and the usual
86/// `:` spelling. Not every builtin ex-command has a field: several
87/// (`ex:quit-all`, `ex:only`, `ex:split`, …) are reached only by name, which
88/// is how the `:` line resolves every command anyway. A field exists where
89/// some caller wants the id without a name lookup.
90#[derive(Debug, Clone, Copy)]
91pub struct ExBuiltins {
92    /// `ex:write`: `:w[rite] [path]` — write the buffer to disk, or to `path`.
93    pub write: ExCommandId,
94    /// `ex:quit`: `:q[uit][!]` — close the pane, or quit on the last one; `!` discards changes.
95    pub quit: ExCommandId,
96    /// `ex:write-quit`: `:wq[!]` / `:x[!]` — write, then quit.
97    pub write_quit: ExCommandId,
98    /// `ex:nohlsearch`: `:noh[lsearch]` — clear the search highlight until the next search.
99    pub no_hlsearch: ExCommandId,
100    /// `ex:registers`: `:reg[isters]` — show every register's contents.
101    pub list_registers: ExCommandId,
102    /// `ex:marks`: `:marks` — show every set mark and its position.
103    pub list_marks: ExCommandId,
104    /// `ex:delete`: `:d[elete]` — delete the current line, newline included (no range form yet).
105    pub delete_line: ExCommandId,
106    /// `ex:set`: `:set <option>` — set an option; the argument string goes to the host's option parser verbatim.
107    pub set_option: ExCommandId,
108    /// `ex:setlocal`: `:setl[ocal]` / `:sl` — set a buffer-local override.
109    pub set_local_option: ExCommandId,
110    /// `ex:setglobal`: `:setg[lobal]` / `:sg` — set the global value, leaving local overrides.
111    pub set_global_option: ExCommandId,
112    /// T.9.b (2026-06-18): `:colorscheme <name>` — swap the active
113    /// theme by name (`lattice-host` looks the name up in
114    /// `lattice_theme::builtin_themes()` and calls `set_theme`).
115    pub colorscheme: ExCommandId,
116    /// `ex:edit`: `:e[dit][!] [path]` — load a file into the current pane; `!` discards changes.
117    pub edit: ExCommandId,
118    /// `ex:substitute`: `:s/pat/rep/[g]` / `:%s/…` — substitute on the current line or the whole buffer; delimiter form ([`SurfaceForm::Delimiter`]).
119    pub substitute: ExCommandId,
120    /// `ex:global`: `:g/pat/cmd` and `:v/pat/cmd` — run a command on every (non-)matching line; delimiter form.
121    pub global: ExCommandId,
122    /// `ex:describe-command`: `:describe-command <name>` — the help view for a registered command.
123    pub describe_command: ExCommandId,
124    /// `ex:describe-buffer`: `:describe-buffer` — the help view for the active buffer's state and modes.
125    pub describe_buffer: ExCommandId,
126    /// `ex:apropos`: `:apropos <text>` — search command names and docs.
127    pub apropos: ExCommandId,
128    /// `ex:describe-key`: `:describe-key <chord>` — what a key chord is bound to.
129    pub describe_key: ExCommandId,
130    /// `ex:keymap`: `:keymap` — every default binding, by mode.
131    pub list_keymap: ExCommandId,
132    /// `ex:bnext`: `:bn[ext]` — next listed buffer.
133    pub buffer_next: ExCommandId,
134    /// `ex:bprev`: `:bp[rev]` — previous listed buffer.
135    pub buffer_prev: ExCommandId,
136    /// `ex:buffers`: `:ls` — list open buffers as a static view.
137    pub list_buffers: ExCommandId,
138    /// `ex:bdelete`: `:bd[elete][!]` — close the active buffer; `!` discards changes.
139    pub buffer_delete: ExCommandId,
140    /// `ex:filetree`: `:filetree [path]` — open a file-tree buffer (default: the working directory).
141    pub file_tree: ExCommandId,
142    /// `ex:filetree-close`: `:filetree-close` — dismiss the file-tree buffer.
143    pub file_tree_close: ExCommandId,
144    /// `ex:oil`: `:oil [path]` — open an oil (editable directory listing) buffer.
145    pub oil: ExCommandId,
146    /// `ex:describe-option`: `:describe-option <name>` — the help view for a typed option.
147    pub describe_option: ExCommandId,
148    /// T.9.d: `:describe-element` / `:describe-face` — theme-element
149    /// introspection (host `build_describe_element_content`).
150    pub describe_element: ExCommandId,
151    /// `ex:options`: `:options` — list every registered option.
152    pub list_options: ExCommandId,
153    /// PI.2: plugin-API introspection (`:describe-plugin-api [<seam>]`,
154    /// `:list-plugin-apis`). The catalog is derived from `wit/` at build
155    /// time by `lattice-plugin-api`; the host renders it.
156    pub describe_plugin_api: ExCommandId,
157    /// `ex:list-plugin-apis`: `:list-plugin-apis` — one row per plugin-API interface (see [`Self::describe_plugin_api`]).
158    pub list_plugin_apis: ExCommandId,
159    /// PI.2b: `:export-plugin-api [markdown|json]` -- dump the catalog to a
160    /// savable buffer.
161    pub export_plugin_api: ExCommandId,
162    /// PI.3: `:list-commands` -- enumerate every command, source-grouped.
163    pub list_commands: ExCommandId,
164    /// PI.4: `:describe-plugin <name>` / `:list-plugins` -- loaded-plugin
165    /// introspection (Facet B).
166    pub describe_plugin: ExCommandId,
167    /// `ex:list-plugins`: `:list-plugins` — every loaded plugin (see [`Self::describe_plugin`]).
168    pub list_plugins: ExCommandId,
169    /// `ex:describe-events`: `:describe-events` — list every registered event.
170    pub describe_events: ExCommandId,
171    /// `ex:describe-event`: `:describe-event <name>` — the help view for one event.
172    pub describe_event: ExCommandId,
173    // CR.6 (2026-06-24): the 11 diff/hunk ex-command ids
174    // (`describe_diff`/`diff_open`/`diff_off`/`diff_this`/`diff_split`/
175    // `diff_get_cmd`/`diff_put_cmd`/`diff_accept`/`diff_reject`/`hunk_next`/
176    // `hunk_prev`) are gone — the diff subsystem registers its own commands
177    // in `lattice_diff::install()` (the multibuffer pattern). They were
178    // unused outside this crate (name-resolved at the `:` line).
179    /// `ex:list-modes`: `:list-modes` — every registered mode, grouped major / minor, with its state on the active buffer.
180    pub list_modes: ExCommandId,
181    /// `ex:describe-mode`: `:describe-mode <name>` — the help view for one mode.
182    pub describe_mode: ExCommandId,
183    /// `ex:describe-option-resolution`: `:describe-option-resolution <name>` — which resolver layer supplies an option's value on the active buffer.
184    pub describe_option_resolution: ExCommandId,
185    /// `ex:customize`: `:customize [name]` — the customize buffer: typed option editing that writes back to user TOML.
186    pub customize: ExCommandId,
187    /// `ex:tutor`: `:tutor [N]` — open the interactive tutor at lesson `N` (default 1).
188    pub tutor: ExCommandId,
189    /// `ex:tutor-next`: `:tutor-next` — next tutor exercise (as `<CR>` in tutor-mode).
190    pub tutor_next: ExCommandId,
191    /// `ex:tutor-prev`: `:tutor-prev` — previous tutor exercise.
192    pub tutor_prev: ExCommandId,
193    /// `ex:hover`: `:hover [markdown]` — open a hover popup at the cursor.
194    pub hover: ExCommandId,
195    /// `ex:hover-close`: `:hover-close` — dismiss the hover popup.
196    pub hover_close: ExCommandId,
197    /// `ex:help`: `:help [topic]` — the help index, or a named topic.
198    pub help: ExCommandId,
199    /// `ex:diagnostics`: `:diagnostics` — every workspace diagnostic, with source links.
200    pub list_diagnostics: ExCommandId,
201    /// `ex:diag-next`: `:diag-next` — cursor to the next diagnostic in the buffer (wraps; also `]d`).
202    pub next_diagnostic: ExCommandId,
203    /// `ex:diag-prev`: `:diag-prev` — cursor to the previous diagnostic (wraps; also `[d`).
204    pub prev_diagnostic: ExCommandId,
205    /// `ex:lsp-log`: `:lsp-log [server]` — the LSP subsystem log, or one server's.
206    pub lsp_log: ExCommandId,
207    /// `ex:messages`: `:messages` — the `*messages*` buffer (every echo and notification).
208    pub messages: ExCommandId,
209    /// `ex:lsp-trace`: `:lsp-trace <server>` — toggle JSON-RPC wire tracing for a server.
210    pub lsp_trace: ExCommandId,
211    /// `ex:lsp-status`: `:lsp-status` — every running LSP server.
212    pub lsp_status: ExCommandId,
213    /// `ex:lsp-server-log`: `:lsp-server-log` — running servers, each linking to its log and trace.
214    pub lsp_server_log: ExCommandId,
215    /// `ex:lsp-restart`: `:lsp-restart <server>` — restart a server (registered; the supervisor path is still a no-op).
216    pub lsp_restart: ExCommandId,
217    /// `ex:lsp-progress-cancel`: `:lsp-progress-cancel [server]` — cancel cancellable `$/progress` work.
218    pub lsp_progress_cancel: ExCommandId,
219    /// `ex:lsp-expand-region`: `:lsp-expand-region` — one step outward in the LSP `selectionRange` chain.
220    pub lsp_expand_region: ExCommandId,
221    /// `ex:lsp-shrink-region`: `:lsp-shrink-region` — one step back inward.
222    pub lsp_shrink_region: ExCommandId,
223    /// `ex:lsp-log-level`: `:lsp-log-level [server] <level>` — set the minimum log level.
224    pub lsp_log_level: ExCommandId,
225    /// `ex:lsp-log-clear`: `:lsp-log-clear [server]` — drop the records in a log buffer.
226    pub lsp_log_clear: ExCommandId,
227    /// `ex:lsp-symbols`: `:lsp-symbols` — picker over the document's symbols.
228    pub lsp_symbols: ExCommandId,
229    /// `ex:lsp-workspace-symbol`: `:lsp-workspace-symbol [query]` — picker over workspace symbols.
230    pub lsp_workspace_symbol: ExCommandId,
231    /// `ex:lsp-incoming-calls`: `:lsp-incoming-calls` — picker over callers of the function at the cursor.
232    pub lsp_incoming_calls: ExCommandId,
233    /// `ex:lsp-outgoing-calls`: `:lsp-outgoing-calls` — picker over its callees.
234    pub lsp_outgoing_calls: ExCommandId,
235    /// `ex:lsp-supertypes`: `:lsp-supertypes` — picker over the supertypes of the type at the cursor.
236    pub lsp_supertypes: ExCommandId,
237    /// `ex:lsp-subtypes`: `:lsp-subtypes` — picker over its subtypes.
238    pub lsp_subtypes: ExCommandId,
239    /// `ex:lsp-moniker`: `:lsp-moniker` — echo the moniker of the symbol at the cursor.
240    pub lsp_moniker: ExCommandId,
241    /// `ex:lsp-code-lens`: `:lsp-code-lens` — picker over the buffer's code lenses.
242    pub lsp_code_lens: ExCommandId,
243    /// `ex:lsp-color-presentation`: `:lsp-color-presentation` — picker of alternative formats for the color literal at the cursor.
244    pub lsp_color_presentation: ExCommandId,
245    /// IN.8b: `:format` — the LSP-independent cascade.
246    pub format: ExCommandId,
247    /// `ex:lsp-format`: `:lsp-format` — `textDocument/formatting`, applied as one undo unit. LSP-only; `:format` is the cascade.
248    pub lsp_format: ExCommandId,
249    /// `ex:lsp-format-range`: `:[range]lsp-format-range` — `textDocument/rangeFormatting` over a range or the selection.
250    pub lsp_format_range: ExCommandId,
251    /// `ex:lsp-signature-help`: `:lsp-signature-help` — the signature-help popup at the cursor.
252    pub lsp_signature_help: ExCommandId,
253    /// `ex:lsp-complete`: `:lsp-complete` — picker over LSP completion items at the cursor.
254    pub lsp_complete: ExCommandId,
255    /// `ex:lsp-rename`: `:lsp-rename [new-name]` — workspace rename of the symbol at the cursor.
256    pub lsp_rename: ExCommandId,
257    /// `ex:lsp-code-action`: `:lsp-code-action` — picker over code actions at the cursor or selection.
258    pub lsp_code_action: ExCommandId,
259    // SN.3c.1 (2026-06-14): `:snippet-expand` removed (UX-useless;
260    // `<C-x><C-s>` is the live trigger, now mode-owned). The expand
261    // path no longer has an ex-command surface form.
262    /// `ex:reload-snippets`: `:reload-snippets` — re-read the snippet files from disk.
263    pub reload_snippets: ExCommandId,
264    /// `ex:cd`: `:cd [path]` — change the working directory (no arg: `$HOME`).
265    pub cd: ExCommandId,
266    /// `ex:pwd`: `:pwd` — echo the working directory.
267    pub pwd: ExCommandId,
268    /// PR.2: `:project-root` — the introspection affordance for project
269    /// resolution.
270    pub project_root: ExCommandId,
271}
272
273/// Register every built-in ex-command into `registry` and return the ids
274/// callers need by field (see [`ExBuiltins`]).
275///
276/// Call once per registry, at boot, next to
277/// [`builtins::populate`](crate::builtins::populate). Every spec's `apply`
278/// only packages its parsed args into an [`Effect`]; the host performs the
279/// side effect. Subsystem crates (multibuffer, compilation, ai, …) register
280/// their own ex-commands at install time rather than here.
281pub fn populate(registry: &mut CommandRegistry) -> ExBuiltins {
282    let write = registry.register_ex_command(
283        "ex:write",
284        "Write the current buffer to disk (`:w [path]`).",
285        ExCommandSpec {
286            latency_class: LatencyClass::Display,
287            accepts_bang: false,
288            accepts_range: false,
289            parse_args: Arc::new(parse_optional_path),
290            apply: Arc::new(apply_write),
291            args_schema: vec![ArgSpec {
292                name: "path".into(),
293                kind: ArgKind::String,
294                doc: "Destination path. Absent = overwrite current file.".into(),
295                prompt: "path:".into(),
296                default: ArgDefault::None,
297                completion: Some("gen:files".into()),
298                picker: None,
299            }],
300            surface_form: SurfaceForm::Keyword,
301        },
302    );
303    let quit = registry.register_ex_command(
304        "ex:quit",
305        "Quit the editor (`:q[!]`).",
306        ExCommandSpec {
307            latency_class: LatencyClass::Reflex,
308            accepts_bang: true,
309            accepts_range: false,
310            parse_args: Arc::new(parse_no_args),
311            apply: Arc::new(apply_quit),
312            args_schema: vec![],
313            surface_form: SurfaceForm::Keyword,
314        },
315    );
316    let write_quit = registry.register_ex_command(
317        "ex:write-quit",
318        "Write the current buffer and quit (`:wq[!]` / `:x[!]`).",
319        ExCommandSpec {
320            latency_class: LatencyClass::Display,
321            accepts_bang: true,
322            accepts_range: false,
323            parse_args: Arc::new(parse_no_args),
324            apply: Arc::new(apply_write_quit),
325            args_schema: vec![],
326            surface_form: SurfaceForm::Keyword,
327        },
328    );
329    // `:qa[!]` -- quit the whole editor regardless of pane / tab count.
330    // Distinct from `:q` (which closes a pane unless it's the last);
331    // both flow through `Effect::QuitEditor`, differing only in
332    // `QuitScope`. Reached by name (aliases `qa` / `qall` / `quitall`
333    // live in the host alias table) -- no `ExBuiltins` field needed,
334    // mirroring `:tabonly`.
335    let _quit_all = registry.register_ex_command(
336        "ex:quit-all",
337        "Quit the editor, closing every pane and tab (`:qa[!]`).",
338        ExCommandSpec {
339            latency_class: LatencyClass::Reflex,
340            accepts_bang: true,
341            accepts_range: false,
342            parse_args: Arc::new(parse_no_args),
343            apply: Arc::new(apply_quit_all),
344            args_schema: vec![],
345            surface_form: SurfaceForm::Keyword,
346        },
347    );
348    // `:only` -- close every pane except the active one (vim `<C-w>o`,
349    // emacs `C-x 1`). A pane op like `:tabonly`, so it emits the same
350    // `Effect::AppAction(AppEffect::OnlyPane)` carrier (aliases `only`
351    // / `on` live in the host alias table). Reached by name -- no
352    // `ExBuiltins` field, mirroring `:tabonly`.
353    let _only = registry.register_ex_command(
354        "ex:only",
355        "Close every pane except the active one (`:only`).",
356        ExCommandSpec {
357            latency_class: LatencyClass::Reflex,
358            accepts_bang: false,
359            accepts_range: false,
360            parse_args: Arc::new(parse_no_args),
361            apply: Arc::new(|_| Ok(Effect::AppAction(AppEffect::OnlyPane))),
362            args_schema: vec![],
363            surface_form: SurfaceForm::Keyword,
364        },
365    );
366    // ZP.2: `:zoom-pane` -- the non-destructive counterpart of
367    // `:only`. Dashed + namespaced per the ex-command naming rule:
368    // the bare `:zoom` slot stays free for a future font/UI scale
369    // command in the GPUI peer, which is the plausible claimant.
370    let _zoom_pane = registry.register_ex_command(
371        "ex:zoom-pane",
372        "Toggle tmux-style zoom on the active pane (`:zoom-pane`).",
373        ExCommandSpec {
374            latency_class: LatencyClass::Reflex,
375            accepts_bang: false,
376            accepts_range: false,
377            parse_args: Arc::new(parse_no_args),
378            apply: Arc::new(|_| Ok(Effect::AppAction(AppEffect::ToggleZoomPane))),
379            args_schema: vec![],
380            surface_form: SurfaceForm::Keyword,
381        },
382    );
383    // Pane-management ex-commands (vim `:sp` / `:vs` / `:clo`, emacs
384    // `C-x 2` / `C-x 3` / `C-x 0`). No-arg today: the split shows the
385    // current buffer (an optional `[file]` arg is a future addition).
386    // Pane ops like `:only`, emitting the AppEffect carrier; reached by
387    // name (aliases in the host alias table), no `ExBuiltins` field.
388    let _split = registry.register_ex_command(
389        "ex:split",
390        "Split the window horizontally (`:sp[lit]`).",
391        ExCommandSpec {
392            latency_class: LatencyClass::Reflex,
393            accepts_bang: false,
394            accepts_range: false,
395            parse_args: Arc::new(parse_no_args),
396            apply: Arc::new(|_| Ok(Effect::AppAction(AppEffect::SplitPaneHorizontal))),
397            args_schema: vec![],
398            surface_form: SurfaceForm::Keyword,
399        },
400    );
401    let _vsplit = registry.register_ex_command(
402        "ex:vsplit",
403        "Split the window vertically (`:vs[plit]`).",
404        ExCommandSpec {
405            latency_class: LatencyClass::Reflex,
406            accepts_bang: false,
407            accepts_range: false,
408            parse_args: Arc::new(parse_no_args),
409            apply: Arc::new(|_| Ok(Effect::AppAction(AppEffect::SplitPaneVertical))),
410            args_schema: vec![],
411            surface_form: SurfaceForm::Keyword,
412        },
413    );
414    let _close = registry.register_ex_command(
415        "ex:close",
416        "Close the active pane (`:clo[se]`).",
417        ExCommandSpec {
418            latency_class: LatencyClass::Reflex,
419            accepts_bang: false,
420            accepts_range: false,
421            parse_args: Arc::new(parse_no_args),
422            apply: Arc::new(|_| Ok(Effect::AppAction(AppEffect::ClosePane))),
423            args_schema: vec![],
424            surface_form: SurfaceForm::Keyword,
425        },
426    );
427    // org-cycle fold commands. Plain names (the `:diff` pattern — no `ex:`
428    // prefix / host alias); `:fold-cycle` resolves directly. Carry the
429    // `AppEffect` to the host fold handlers, same as the `z<Space>` /
430    // `z<Tab>` keymap chords.
431    let _fold_cycle = registry.register_ex_command(
432        "fold-cycle",
433        "org-cycle: cycle the fold under the cursor through \
434         FOLDED → CHILDREN → SUBTREE (`z<Space>`).",
435        ExCommandSpec {
436            latency_class: LatencyClass::Reflex,
437            accepts_bang: false,
438            accepts_range: false,
439            parse_args: Arc::new(parse_no_args),
440            apply: Arc::new(|_| Ok(Effect::AppAction(AppEffect::CycleFoldAtCursor))),
441            args_schema: vec![],
442            surface_form: SurfaceForm::Keyword,
443        },
444    );
445    let _fold_cycle_global = registry.register_ex_command(
446        "fold-cycle-global",
447        "org-cycle: cycle the whole buffer through \
448         OVERVIEW → CONTENTS → SHOW-ALL (`z<Tab>`).",
449        ExCommandSpec {
450            latency_class: LatencyClass::Reflex,
451            accepts_bang: false,
452            accepts_range: false,
453            parse_args: Arc::new(parse_no_args),
454            apply: Arc::new(|_| Ok(Effect::AppAction(AppEffect::CycleFoldsGlobal))),
455            args_schema: vec![],
456            surface_form: SurfaceForm::Keyword,
457        },
458    );
459    let _fold_goto_parent = registry.register_ex_command(
460        "fold-goto-parent",
461        "Move the cursor to the parent heading, one level up the fold \
462         hierarchy (`zp`).",
463        ExCommandSpec {
464            latency_class: LatencyClass::Reflex,
465            accepts_bang: false,
466            accepts_range: false,
467            parse_args: Arc::new(parse_no_args),
468            apply: Arc::new(|_| Ok(Effect::AppAction(AppEffect::GotoParentFold))),
469            args_schema: vec![],
470            surface_form: SurfaceForm::Keyword,
471        },
472    );
473    let no_hlsearch = registry.register_ex_command(
474        "ex:nohlsearch",
475        "Clear the search-highlight overlay (`:noh[lsearch]`).",
476        ExCommandSpec {
477            latency_class: LatencyClass::Reflex,
478            accepts_bang: false,
479            accepts_range: false,
480            parse_args: Arc::new(parse_no_args),
481            apply: Arc::new(|_| Ok(Effect::ClearSearchHighlight)),
482            args_schema: vec![],
483            surface_form: SurfaceForm::Keyword,
484        },
485    );
486    let list_registers = registry.register_ex_command(
487        "ex:registers",
488        "Show every register's contents (`:reg[isters]`).",
489        ExCommandSpec {
490            latency_class: LatencyClass::Display,
491            accepts_bang: false,
492            accepts_range: false,
493            parse_args: Arc::new(parse_no_args),
494            apply: Arc::new(|_| Ok(Effect::EchoRegisters)),
495            args_schema: vec![],
496            surface_form: SurfaceForm::Keyword,
497        },
498    );
499    let list_marks = registry.register_ex_command(
500        "ex:marks",
501        "Show every set mark's name + position (`:marks`).",
502        ExCommandSpec {
503            latency_class: LatencyClass::Display,
504            accepts_bang: false,
505            accepts_range: false,
506            parse_args: Arc::new(parse_no_args),
507            apply: Arc::new(|_| Ok(Effect::EchoMarks)),
508            args_schema: vec![],
509            surface_form: SurfaceForm::Keyword,
510        },
511    );
512    let delete_line = registry.register_ex_command(
513        "ex:delete",
514        "Delete the current line including its newline (`:d[elete]`).",
515        ExCommandSpec {
516            latency_class: LatencyClass::Reflex,
517            accepts_bang: false,
518            accepts_range: false,
519            parse_args: Arc::new(parse_no_args),
520            apply: Arc::new(|_| Ok(Effect::DeleteCurrentLine)),
521            args_schema: vec![],
522            surface_form: SurfaceForm::Keyword,
523        },
524    );
525    let set_option = registry.register_ex_command(
526        "ex:set",
527        "Set a view option (`:set <option>`).",
528        ExCommandSpec {
529            latency_class: LatencyClass::Reflex,
530            accepts_bang: false,
531            accepts_range: false,
532            parse_args: Arc::new(parse_required_string),
533            apply: Arc::new(apply_set),
534            // Single arg slot tied to the `gen:options` completion
535            // generator so `:set <Tab>` enumerates option names and
536            // `:set foldmethod=<Tab>` enumerates valid values.
537            args_schema: vec![ArgSpec {
538                name: "option".into(),
539                kind: ArgKind::String,
540                doc: "Option name, `name=value`, `name?`, or `noname`.".into(),
541                prompt: "option:".into(),
542                default: ArgDefault::Required,
543                completion: Some("gen:options".into()),
544                picker: None,
545            }],
546            surface_form: SurfaceForm::Keyword,
547        },
548    );
549    let set_local_option = registry.register_ex_command(
550        "ex:setlocal",
551        "Set a buffer-local option override (`:setlocal <option>`).",
552        ExCommandSpec {
553            latency_class: LatencyClass::Reflex,
554            accepts_bang: false,
555            accepts_range: false,
556            parse_args: Arc::new(parse_required_string),
557            apply: Arc::new(apply_set_local),
558            args_schema: vec![ArgSpec {
559                name: "option".into(),
560                kind: ArgKind::String,
561                doc: "Option name, `name=value`, `noname`, or `name&` to clear.".into(),
562                prompt: "option:".into(),
563                default: ArgDefault::Required,
564                completion: Some("gen:options".into()),
565                picker: None,
566            }],
567            surface_form: SurfaceForm::Keyword,
568        },
569    );
570    let set_global_option = registry.register_ex_command(
571        "ex:setglobal",
572        "Set a global option (`:setglobal <option>`). Does not update buffer-local overrides.",
573        ExCommandSpec {
574            latency_class: LatencyClass::Reflex,
575            accepts_bang: false,
576            accepts_range: false,
577            parse_args: Arc::new(parse_required_string),
578            apply: Arc::new(apply_set_global),
579            args_schema: vec![ArgSpec {
580                name: "option".into(),
581                kind: ArgKind::String,
582                doc: "Option name, `name=value`, `noname`, or `name?` to query global value."
583                    .into(),
584                prompt: "option:".into(),
585                default: ArgDefault::Required,
586                completion: Some("gen:options".into()),
587                picker: None,
588            }],
589            surface_form: SurfaceForm::Keyword,
590        },
591    );
592    let colorscheme = registry.register_ex_command(
593        "ex:colorscheme",
594        "Swap the active theme by name (`:colorscheme <name>`).",
595        ExCommandSpec {
596            latency_class: LatencyClass::Display,
597            accepts_bang: false,
598            accepts_range: false,
599            // T.12a: no-arg is now legal — it opens the live-preview
600            // theme picker host-side. `parse_optional_path` returns
601            // `Args::None` on empty input, `Args::String` otherwise.
602            parse_args: Arc::new(parse_optional_path),
603            apply: Arc::new(apply_colorscheme),
604            args_schema: vec![ArgSpec {
605                name: "name".into(),
606                kind: ArgKind::String,
607                doc: "Theme name (`catppuccin-mocha`, `catppuccin-macchiato`). Omit to open the live-preview picker.".into(),
608                prompt: "colorscheme:".into(),
609                // T.12a: optional — no-arg opens the picker.
610                default: ArgDefault::None,
611                // Inline `<Tab>` over the same `theme_names()` the picker
612                // enumerates; the no-arg form still opens the picker, which is
613                // what adds live preview.
614                completion: Some("gen:themes".into()),
615                picker: None,
616            }],
617            surface_form: SurfaceForm::Keyword,
618        },
619    );
620    let edit = registry.register_ex_command(
621        "ex:edit",
622        "Load a file into the current document (`:e[!] [path]`).",
623        ExCommandSpec {
624            latency_class: LatencyClass::Display,
625            accepts_bang: true,
626            accepts_range: false,
627            parse_args: Arc::new(parse_optional_path),
628            apply: Arc::new(apply_edit),
629            args_schema: vec![ArgSpec {
630                name: "path".into(),
631                kind: ArgKind::String,
632                doc: "File path to open. Absent = reload current file.".into(),
633                prompt: "path:".into(),
634                default: ArgDefault::None,
635                completion: Some("gen:files".into()),
636                picker: None,
637            }],
638            surface_form: SurfaceForm::Keyword,
639        },
640    );
641    let substitute = registry.register_ex_command(
642        "ex:substitute",
643        "Replace pattern with replacement on the current line or `%` whole buffer (`:s/pat/rep/[g]`).",
644        ExCommandSpec {
645            latency_class: LatencyClass::Reflex,
646            accepts_bang: false,
647            // `accepts_range: true` even though v1 only honours
648            // CurrentLine and Whole; the parser front-end provides the
649            // range from the `s/` vs `%s/` prefix.
650            accepts_range: true,
651            // The substitute call enters via the parser front-end's
652            // delimiter detection, not the keyword form -- parse_args
653            // is unreachable for normal `:`-line input. We keep a
654            // stub that errors on direct use to prevent surprise from
655            // a script invocation.
656            parse_args: Arc::new(parse_substitute_args_unreachable),
657            apply: Arc::new(apply_substitute),
658            args_schema: vec![
659                ArgSpec::required(
660                    "pattern",
661                    ArgKind::Pattern,
662                    "Search pattern (literal in v1; regex post-1.0)",
663                ),
664                ArgSpec::required(
665                    "replacement",
666                    ArgKind::String,
667                    "Replacement text (empty deletes matches)",
668                ),
669                ArgSpec {
670                    name: "flags".into(),
671                    kind: ArgKind::String,
672                    doc: "Flags string (currently `g` honoured; others ignored)".into(),
673                    prompt: "".into(),
674                    default: ArgDefault::Literal(ArgValue::String(String::new())),
675                    completion: None,
676                    picker: None,
677                },
678            ],
679            surface_form: SurfaceForm::Delimiter {
680                hint: ":s/pattern/replacement/[flags]  (or :%s/.../.../  for whole buffer)"
681                    .into(),
682            },
683        },
684    );
685    let global = registry.register_ex_command(
686        "ex:global",
687        "Run a command on every line matching (`:g`) or NOT matching (`:v`) a pattern.",
688        ExCommandSpec {
689            latency_class: LatencyClass::Reflex,
690            accepts_bang: false,
691            accepts_range: false,
692            parse_args: Arc::new(parse_global_args_unreachable),
693            apply: Arc::new(apply_global),
694            args_schema: vec![
695                ArgSpec::required("pattern", ArgKind::Pattern, "Match pattern (literal in v1)"),
696                ArgSpec {
697                    name: "inverted".into(),
698                    kind: ArgKind::Bool,
699                    doc: "True for `:v` form -- match lines NOT matching the pattern.".into(),
700                    prompt: "".into(),
701                    default: ArgDefault::Literal(ArgValue::Bool(false)),
702                    completion: None,
703                    picker: None,
704                },
705                ArgSpec::required(
706                    "body",
707                    ArgKind::Raw,
708                    "Ex-command to run on each matching line (re-parsed per match)",
709                ),
710            ],
711            surface_form: SurfaceForm::Delimiter {
712                hint: ":g/pattern/body  (or :v/pattern/body  for inverted)".into(),
713            },
714        },
715    );
716    let describe_command = registry.register_ex_command(
717        "ex:describe-command",
718        "Open the help view for a named command (DESIGN.md §5.11).",
719        ExCommandSpec {
720            latency_class: LatencyClass::Display,
721            accepts_bang: false,
722            accepts_range: false,
723            parse_args: Arc::new(parse_required_string),
724            apply: Arc::new(apply_describe_command),
725            args_schema: vec![ArgSpec {
726                name: "name".into(),
727                kind: ArgKind::String,
728                doc: "Registered command name (`ex:write`, `motion:word-forward`, ...)".into(),
729                prompt: "command:".into(),
730                default: ArgDefault::Required,
731                completion: Some("gen:commands".into()),
732                picker: None,
733            }],
734            surface_form: SurfaceForm::Keyword,
735        },
736    );
737    let describe_buffer = registry.register_ex_command(
738        "ex:describe-buffer",
739        "Open the help view for the current buffer's state (DESIGN.md §5.11).",
740        ExCommandSpec {
741            latency_class: LatencyClass::Display,
742            accepts_bang: false,
743            accepts_range: false,
744            parse_args: Arc::new(parse_no_args),
745            apply: Arc::new(|_| Ok(Effect::DescribeBuffer)),
746            args_schema: vec![],
747            surface_form: SurfaceForm::Keyword,
748        },
749    );
750    let apropos = registry.register_ex_command(
751        "ex:apropos",
752        "Search every registered command's name + doc for a substring (DESIGN.md §5.11).",
753        ExCommandSpec {
754            latency_class: LatencyClass::Display,
755            accepts_bang: false,
756            accepts_range: false,
757            parse_args: Arc::new(parse_required_string),
758            apply: Arc::new(apply_apropos),
759            args_schema: vec![ArgSpec {
760                name: "pattern".into(),
761                kind: ArgKind::String,
762                doc: "Case-insensitive substring matched against name and doc".into(),
763                prompt: "apropos:".into(),
764                default: ArgDefault::Required,
765                completion: None,
766                picker: None,
767            }],
768            surface_form: SurfaceForm::Keyword,
769        },
770    );
771    let describe_key = registry.register_ex_command(
772        "ex:describe-key",
773        "Open the help view for a key chord (DESIGN.md §5.11).",
774        ExCommandSpec {
775            latency_class: LatencyClass::Display,
776            accepts_bang: false,
777            accepts_range: false,
778            parse_args: Arc::new(parse_required_string),
779            apply: Arc::new(apply_describe_key),
780            args_schema: vec![ArgSpec {
781                name: "chord".into(),
782                kind: ArgKind::Chord,
783                doc: "Chord notation (`j`, `dw`, `<C-d>`, `gg`, `<Esc>`, ...)".into(),
784                prompt: "key:".into(),
785                default: ArgDefault::Required,
786                completion: None,
787                picker: None,
788            }],
789            surface_form: SurfaceForm::Keyword,
790        },
791    );
792    let list_keymap = registry.register_ex_command(
793        "ex:keymap",
794        "Open the help view listing every default keymap binding by mode (DESIGN.md §5.11).",
795        ExCommandSpec {
796            latency_class: LatencyClass::Display,
797            accepts_bang: false,
798            accepts_range: false,
799            parse_args: Arc::new(parse_no_args),
800            apply: Arc::new(|_| Ok(Effect::ListKeymap)),
801            args_schema: vec![],
802            surface_form: SurfaceForm::Keyword,
803        },
804    );
805    let buffer_next = registry.register_ex_command(
806        "ex:bnext",
807        "Cycle to the next open document buffer (`:bn[ext]`).",
808        ExCommandSpec {
809            latency_class: LatencyClass::Reflex,
810            accepts_bang: false,
811            accepts_range: false,
812            parse_args: Arc::new(parse_no_args),
813            apply: Arc::new(|_| Ok(Effect::BufferNext)),
814            args_schema: vec![],
815            surface_form: SurfaceForm::Keyword,
816        },
817    );
818    // Issue #29 (2026-05-22): tab management ex-commands.
819    // Each returns Effect::AppAction wrapping the matching
820    // AppEffect; the host's apply_app_effect pushes the
821    // matching Action onto out.next_actions.
822    let _tab_next = registry.register_ex_command(
823        "ex:tabnext",
824        "Switch to the next tab (`:tabn[ext]`).",
825        ExCommandSpec {
826            latency_class: LatencyClass::Reflex,
827            accepts_bang: false,
828            accepts_range: false,
829            parse_args: Arc::new(parse_no_args),
830            apply: Arc::new(|_| Ok(Effect::AppAction(AppEffect::NextTab))),
831            args_schema: vec![],
832            surface_form: SurfaceForm::Keyword,
833        },
834    );
835    let _tab_prev = registry.register_ex_command(
836        "ex:tabprev",
837        "Switch to the previous tab (`:tabp[rev]`).",
838        ExCommandSpec {
839            latency_class: LatencyClass::Reflex,
840            accepts_bang: false,
841            accepts_range: false,
842            parse_args: Arc::new(parse_no_args),
843            apply: Arc::new(|_| Ok(Effect::AppAction(AppEffect::PrevTab))),
844            args_schema: vec![],
845            surface_form: SurfaceForm::Keyword,
846        },
847    );
848    let _tab_new = registry.register_ex_command(
849        "ex:tabnew",
850        "Open a new tab (optionally with `<path>`).",
851        ExCommandSpec {
852            latency_class: LatencyClass::Reflex,
853            accepts_bang: false,
854            accepts_range: false,
855            parse_args: Arc::new(parse_optional_path),
856            apply: Arc::new(|ctx| match &ctx.args {
857                Args::String(path) if !path.is_empty() => {
858                    Ok(Effect::AppAction(AppEffect::NewTabAt(path.clone())))
859                }
860                _ => Ok(Effect::AppAction(AppEffect::NewTab)),
861            }),
862            args_schema: vec![ArgSpec {
863                name: "path".into(),
864                kind: ArgKind::String,
865                doc: "Optional file path to open in the new tab".into(),
866                prompt: "".into(),
867                default: ArgDefault::None,
868                completion: Some("gen:files".into()),
869                picker: None,
870            }],
871            surface_form: SurfaceForm::Keyword,
872        },
873    );
874    let _tab_close = registry.register_ex_command(
875        "ex:tabclose",
876        "Close the active tab.",
877        ExCommandSpec {
878            latency_class: LatencyClass::Reflex,
879            accepts_bang: false,
880            accepts_range: false,
881            parse_args: Arc::new(parse_no_args),
882            apply: Arc::new(|_| Ok(Effect::AppAction(AppEffect::CloseTab))),
883            args_schema: vec![],
884            surface_form: SurfaceForm::Keyword,
885        },
886    );
887    // Issue #40 / Terminal-mode T1: `:terminal [cmd]` opens a
888    // PTY-backed shell buffer (T2 wires keystroke input).
889    let _terminal = registry.register_ex_command(
890        "ex:terminal",
891        "Open a PTY-backed shell buffer (optionally running `<cmd>`).",
892        ExCommandSpec {
893            latency_class: LatencyClass::Reflex,
894            accepts_bang: false,
895            accepts_range: false,
896            parse_args: Arc::new(parse_optional_path),
897            apply: Arc::new(|ctx| match &ctx.args {
898                Args::String(s) if !s.is_empty() => {
899                    Ok(Effect::AppAction(AppEffect::TerminalSpawn(Some(s.clone()))))
900                }
901                _ => Ok(Effect::AppAction(AppEffect::TerminalSpawn(None))),
902            }),
903            args_schema: vec![ArgSpec {
904                name: "cmd".into(),
905                kind: ArgKind::String,
906                doc: "Optional command line (default: $SHELL or /bin/sh)".into(),
907                prompt: "".into(),
908                default: ArgDefault::None,
909                completion: None,
910                picker: None,
911            }],
912            surface_form: SurfaceForm::Keyword,
913        },
914    );
915    // T4 (2026-05-25): `:tabterminal [cmd]` — open a new tab
916    // with a PTY-backed shell. Sugar for `:tabnew | :terminal`.
917    let _tab_terminal = registry.register_ex_command(
918        "ex:tabterminal",
919        "Open a new tab containing a PTY-backed shell buffer.",
920        ExCommandSpec {
921            latency_class: LatencyClass::Reflex,
922            accepts_bang: false,
923            accepts_range: false,
924            parse_args: Arc::new(parse_optional_path),
925            apply: Arc::new(|ctx| match &ctx.args {
926                Args::String(s) if !s.is_empty() => Ok(Effect::AppAction(
927                    AppEffect::TerminalSpawnInNewTab(Some(s.clone())),
928                )),
929                _ => Ok(Effect::AppAction(AppEffect::TerminalSpawnInNewTab(None))),
930            }),
931            args_schema: vec![ArgSpec {
932                name: "cmd".into(),
933                kind: ArgKind::String,
934                doc: "Optional command line (default: $SHELL or /bin/sh)".into(),
935                prompt: "".into(),
936                default: ArgDefault::None,
937                completion: None,
938                picker: None,
939            }],
940            surface_form: SurfaceForm::Keyword,
941        },
942    );
943    let _tab_only = registry.register_ex_command(
944        "ex:tabonly",
945        "Close every tab except the active one.",
946        ExCommandSpec {
947            latency_class: LatencyClass::Reflex,
948            accepts_bang: false,
949            accepts_range: false,
950            parse_args: Arc::new(parse_no_args),
951            apply: Arc::new(|_| Ok(Effect::AppAction(AppEffect::OnlyTab))),
952            args_schema: vec![],
953            surface_form: SurfaceForm::Keyword,
954        },
955    );
956    // `:tabmove [N]` — optional positional u32 arg. Missing
957    // (or 0) means "move to last position" (mirrors vim).
958    // Stored as Args::String for the parsed-int text since
959    // Args has no Int variant; apply re-parses.
960    let _tab_move = registry.register_ex_command(
961        "ex:tabmove",
962        "Move the active tab to position N (1-indexed; default last).",
963        ExCommandSpec {
964            latency_class: LatencyClass::Reflex,
965            accepts_bang: false,
966            accepts_range: false,
967            parse_args: Arc::new(parse_tabmove_arg),
968            apply: Arc::new(|ctx| {
969                let n: u32 = match &ctx.args {
970                    Args::String(s) => s.parse::<u32>().unwrap_or(0),
971                    _ => 0,
972                };
973                Ok(Effect::AppAction(AppEffect::MoveTab(n)))
974            }),
975            args_schema: vec![ArgSpec::required(
976                "n",
977                ArgKind::Int,
978                "Target position (1-indexed; 0 = last)",
979            )],
980            surface_form: SurfaceForm::Keyword,
981        },
982    );
983    let buffer_prev = registry.register_ex_command(
984        "ex:bprev",
985        "Cycle to the previous open document buffer (`:bp[rev]`).",
986        ExCommandSpec {
987            latency_class: LatencyClass::Reflex,
988            accepts_bang: false,
989            accepts_range: false,
990            parse_args: Arc::new(parse_no_args),
991            apply: Arc::new(|_| Ok(Effect::BufferPrev)),
992            args_schema: vec![],
993            surface_form: SurfaceForm::Keyword,
994        },
995    );
996    let list_buffers = registry.register_ex_command(
997        "ex:buffers",
998        "List every open document buffer as a static text view (`:ls`). \
999         For the fuzzy switcher, see `:buffers` / `:b`.",
1000        ExCommandSpec {
1001            latency_class: LatencyClass::Display,
1002            accepts_bang: false,
1003            accepts_range: false,
1004            parse_args: Arc::new(parse_no_args),
1005            apply: Arc::new(|_| Ok(Effect::ListBuffers)),
1006            args_schema: vec![],
1007            surface_form: SurfaceForm::Keyword,
1008        },
1009    );
1010    let _buffer_picker = registry.register_ex_command(
1011        "ex:buffer-picker",
1012        "Open the vertico-style buffer switcher (`:buffers` / `:b`). \
1013         Type to filter; `<CR>` to switch. For the static text listing, see `:ls`.",
1014        ExCommandSpec {
1015            latency_class: LatencyClass::Display,
1016            accepts_bang: false,
1017            accepts_range: false,
1018            parse_args: Arc::new(parse_no_args),
1019            apply: Arc::new(|_| Ok(Effect::OpenBufferPicker)),
1020            args_schema: vec![],
1021            surface_form: SurfaceForm::Keyword,
1022        },
1023    );
1024    let buffer_delete = registry.register_ex_command(
1025        "ex:bdelete",
1026        "Close the active document buffer (`:bd[elete][!]`). `!` discards unsaved changes.",
1027        ExCommandSpec {
1028            latency_class: LatencyClass::Reflex,
1029            accepts_bang: true,
1030            accepts_range: false,
1031            parse_args: Arc::new(parse_no_args),
1032            apply: Arc::new(|ctx| Ok(Effect::BufferDelete { force: ctx.bang })),
1033            args_schema: vec![],
1034            surface_form: SurfaceForm::Keyword,
1035        },
1036    );
1037    // `:files [root]` and `:recent` are short aliases for
1038    // `:picker files [root]` / `:picker recent`. They emit
1039    // the canonical `Effect::OpenPicker` so they go through
1040    // the same trait-driven dispatch + MRU pipeline; the
1041    // separate ex-command surface exists only for vim
1042    // muscle memory.
1043    let _files_picker = registry.register_ex_command(
1044        "ex:files",
1045        "Open the project file picker (`:files [root]`). Absent root = the active buffer's project. Alias for `:picker files [root]`.",
1046        ExCommandSpec {
1047            latency_class: LatencyClass::Display,
1048            accepts_bang: false,
1049            accepts_range: false,
1050            parse_args: Arc::new(parse_optional_path),
1051            apply: Arc::new(|ctx| {
1052                let args: Vec<String> = match &ctx.args {
1053                    Args::String(p) if !p.is_empty() => vec![p.clone()],
1054                    _ => Vec::new(),
1055                };
1056                Ok(Effect::OpenPicker {
1057                    source: "files".into(),
1058                    args,
1059                    root: None,
1060                    fill_action: None,
1061                    query: None,
1062                })
1063            }),
1064            args_schema: vec![ArgSpec {
1065                name: "root".into(),
1066                kind: ArgKind::String,
1067                doc: "Directory to walk. Absent = the active buffer's project root.".into(),
1068                prompt: "root:".into(),
1069                default: ArgDefault::None,
1070                completion: Some("gen:files".into()),
1071                picker: None,
1072            }],
1073            surface_form: SurfaceForm::Keyword,
1074        },
1075    );
1076    let _recent_files_picker = registry.register_ex_command(
1077        "ex:recent",
1078        "Open the recent-files picker (`:recent`). Alias for `:picker recent`.",
1079        ExCommandSpec {
1080            latency_class: LatencyClass::Display,
1081            accepts_bang: false,
1082            accepts_range: false,
1083            parse_args: Arc::new(parse_no_args),
1084            apply: Arc::new(|_| {
1085                Ok(Effect::OpenPicker {
1086                    source: "recent".into(),
1087                    args: Vec::new(),
1088                    root: None,
1089                    fill_action: None,
1090                    query: None,
1091                })
1092            }),
1093            args_schema: vec![],
1094            surface_form: SurfaceForm::Keyword,
1095        },
1096    );
1097    // MB.5: `:history searches` opens the search-line history picker
1098    // (also reachable via `q/` / `q?`). `:history commands` (the
1099    // default when no arg is given) opens the command-line history.
1100    let _history_picker = registry.register_ex_command(
1101        "ex:history",
1102        "Open a history picker (`:history [commands|searches|pane-buffers]`). \
1103         `commands`: command-line history picker (default, also `q:`). \
1104         `searches`: search-line history picker (`q/` / `q?`). \
1105         `pane-buffers`: this pane's buffer history (`<C-6>` / `<C-7>`). \
1106         `<CR>` loads the chosen entry into the `:` / `/` line (does not execute).",
1107        ExCommandSpec {
1108            latency_class: LatencyClass::Display,
1109            accepts_bang: false,
1110            accepts_range: false,
1111            parse_args: Arc::new(parse_history_args),
1112            apply: Arc::new(|ctx| {
1113                let source = if let Args::List(ref values) = ctx.args
1114                    && !values.is_empty()
1115                {
1116                    match values[0].as_str() {
1117                        Some("searches") => "search-history",
1118                        // PBH.5: this pane's buffer trail.
1119                        Some("pane-buffers") => "pane-buffer-history",
1120                        _ => "history",
1121                    }
1122                } else {
1123                    "history"
1124                };
1125                Ok(Effect::OpenPicker {
1126                    source: source.into(),
1127                    args: Vec::new(),
1128                    root: None,
1129                    fill_action: None,
1130                    query: None,
1131                })
1132            }),
1133            args_schema: vec![ArgSpec {
1134                name: "kind".into(),
1135                kind: ArgKind::String,
1136                prompt: "history kind (`commands`, `searches`, or `pane-buffers`)".into(),
1137                default: ArgDefault::None,
1138                doc: "`commands` (default), `searches`, or `pane-buffers`.".into(),
1139                completion: Some("gen:history-kinds".into()),
1140                picker: None,
1141            }],
1142            surface_form: SurfaceForm::Keyword,
1143        },
1144    );
1145    let _picker = registry.register_ex_command(
1146        "ex:picker",
1147        "Open a picker over the named source (`:picker <source> [args...]`). \
1148         Source ids come from the host's `PickerRegistry` -- type `<Tab>` after \
1149         `:picker ` to list them. Short aliases like `:files`, `:recent`, `:b` \
1150         dispatch through the same machinery.",
1151        ExCommandSpec {
1152            latency_class: LatencyClass::Display,
1153            accepts_bang: false,
1154            accepts_range: false,
1155            parse_args: Arc::new(parse_picker_args),
1156            apply: Arc::new(|ctx| {
1157                // `parse_picker_args` always produces an `Args::List`
1158                // with `source` at index 0 + raw arg tokens after.
1159                let list = ctx
1160                    .args
1161                    .as_list()
1162                    .ok_or_else(|| CommandError::BadArgs("picker: expected list args".into()))?;
1163                let source = list
1164                    .first()
1165                    .and_then(|v| v.as_str())
1166                    .ok_or_else(|| CommandError::BadArgs("picker: source missing".into()))?
1167                    .to_string();
1168                let args: Vec<String> = list[1..]
1169                    .iter()
1170                    .filter_map(|v| v.as_str().map(String::from))
1171                    .collect();
1172                // `:picker <source> [args]` names no root: it is the user
1173                // asking for a picker where they are. A source that wants a
1174                // root from an argument (`files`) still reads one.
1175                Ok(Effect::OpenPicker {
1176                    source,
1177                    args,
1178                    root: None,
1179                    fill_action: None,
1180                    query: None,
1181                })
1182            }),
1183            args_schema: vec![ArgSpec {
1184                name: "source".into(),
1185                kind: ArgKind::String,
1186                doc: "Picker source id (`files`, `recent`, `buffers`, ...).".into(),
1187                prompt: "source:".into(),
1188                default: ArgDefault::Required,
1189                // `gen:picker-sources` walks the App's
1190                // `picker_registry` (Arc-shared, captured Weakly
1191                // by the generator) and emits one candidate per
1192                // registered source id. Adding a new picker source
1193                // surfaces in `:picker <Tab>` automatically.
1194                completion: Some("gen:picker-sources".into()),
1195                picker: None,
1196            }],
1197            surface_form: SurfaceForm::Keyword,
1198        },
1199    );
1200    let file_tree = registry.register_ex_command(
1201        "ex:filetree",
1202        "Open a file-tree buffer (`:Filetree [path]`). Absent = current dir.",
1203        ExCommandSpec {
1204            latency_class: LatencyClass::Display,
1205            accepts_bang: false,
1206            accepts_range: false,
1207            parse_args: Arc::new(parse_optional_path),
1208            apply: Arc::new(|ctx| {
1209                let root = match &ctx.args {
1210                    Args::String(p) if !p.is_empty() => Some(std::path::PathBuf::from(p.as_str())),
1211                    _ => None,
1212                };
1213                Ok(Effect::OpenFileTree { root })
1214            }),
1215            args_schema: vec![ArgSpec {
1216                name: "root".into(),
1217                kind: ArgKind::String,
1218                doc: "Directory to open as the tree root. Absent = current dir.".into(),
1219                prompt: "root:".into(),
1220                default: ArgDefault::None,
1221                completion: Some("gen:files".into()),
1222                picker: None,
1223            }],
1224            surface_form: SurfaceForm::Keyword,
1225        },
1226    );
1227    let file_tree_close = registry.register_ex_command(
1228        "ex:filetree-close",
1229        "Dismiss the file-tree buffer (`:FiletreeClose`).",
1230        ExCommandSpec {
1231            latency_class: LatencyClass::Reflex,
1232            accepts_bang: false,
1233            accepts_range: false,
1234            parse_args: Arc::new(parse_no_args),
1235            apply: Arc::new(|_| Ok(Effect::CloseFileTree)),
1236            args_schema: vec![],
1237            surface_form: SurfaceForm::Keyword,
1238        },
1239    );
1240    let oil = registry.register_ex_command(
1241        "ex:oil",
1242        "Open an oil buffer (`:Oil [path]`). Absent = current dir.",
1243        ExCommandSpec {
1244            latency_class: LatencyClass::Display,
1245            accepts_bang: false,
1246            accepts_range: false,
1247            parse_args: Arc::new(parse_optional_path),
1248            apply: Arc::new(|ctx| {
1249                let dir = match &ctx.args {
1250                    Args::String(p) if !p.is_empty() => Some(std::path::PathBuf::from(p.as_str())),
1251                    _ => None,
1252                };
1253                Ok(Effect::OpenOil { dir })
1254            }),
1255            args_schema: vec![ArgSpec {
1256                name: "dir".into(),
1257                kind: ArgKind::String,
1258                doc: "Directory to open. Absent = current document's parent.".into(),
1259                prompt: "dir:".into(),
1260                default: ArgDefault::None,
1261                completion: Some("gen:files".into()),
1262                picker: None,
1263            }],
1264            surface_form: SurfaceForm::Keyword,
1265        },
1266    );
1267    let describe_option = registry.register_ex_command(
1268        "ex:describe-option",
1269        "Open the help view for a typed option (`:describe-option NAME`).",
1270        ExCommandSpec {
1271            latency_class: LatencyClass::Display,
1272            accepts_bang: false,
1273            accepts_range: false,
1274            parse_args: Arc::new(parse_required_string),
1275            apply: Arc::new(|ctx| match &ctx.args {
1276                Args::String(name) => Ok(Effect::DescribeOption {
1277                    name: name.to_string(),
1278                }),
1279                _ => Err(CommandError::BadArgs("expected option name".into())),
1280            }),
1281            args_schema: vec![ArgSpec {
1282                name: "name".into(),
1283                kind: ArgKind::String,
1284                doc: "Registered option name (or alias).".into(),
1285                prompt: "option:".into(),
1286                default: ArgDefault::Required,
1287                completion: Some("gen:options".into()),
1288                picker: None,
1289            }],
1290            surface_form: SurfaceForm::Keyword,
1291        },
1292    );
1293    let describe_element = registry.register_ex_command(
1294        "ex:describe-element",
1295        "Open the help view for a theme element / face \
1296         (`:describe-element NAME`, alias `:describe-face NAME`). \
1297         Shows owner, doc, the authoring (reference-form) style spec \
1298         (palette keys + inherit parent), and the concrete resolved \
1299         style under the active theme.",
1300        ExCommandSpec {
1301            latency_class: LatencyClass::Display,
1302            accepts_bang: false,
1303            accepts_range: false,
1304            parse_args: Arc::new(parse_required_string),
1305            apply: Arc::new(|ctx| match &ctx.args {
1306                Args::String(name) => Ok(Effect::DescribeElement {
1307                    name: name.to_string(),
1308                }),
1309                _ => Err(CommandError::BadArgs("expected element name".into())),
1310            }),
1311            args_schema: vec![ArgSpec {
1312                name: "name".into(),
1313                kind: ArgKind::String,
1314                // `gen:elements` is a host generator (the theme registry is a
1315                // host-side ServiceRegistry service); it's registered in
1316                // `editor_boot.rs` and walks `ThemeRegistry::element_names`.
1317                doc: "Registered theme-element name (e.g. `syntax.keyword`, `diff.add.sign`)."
1318                    .into(),
1319                prompt: "element:".into(),
1320                default: ArgDefault::Required,
1321                completion: Some("gen:elements".into()),
1322                picker: None,
1323            }],
1324            surface_form: SurfaceForm::Keyword,
1325        },
1326    );
1327    let list_options = registry.register_ex_command(
1328        "ex:options",
1329        "List every registered option (`:options`).",
1330        ExCommandSpec {
1331            latency_class: LatencyClass::Display,
1332            accepts_bang: false,
1333            accepts_range: false,
1334            parse_args: Arc::new(parse_no_args),
1335            apply: Arc::new(|_| Ok(Effect::ListOptions)),
1336            args_schema: vec![],
1337            surface_form: SurfaceForm::Keyword,
1338        },
1339    );
1340    let describe_plugin_api = registry.register_ex_command(
1341        "ex:describe-plugin-api",
1342        "Open the help view for the plugin API (`:describe-plugin-api [<seam>]`). \
1343         With a seam name (`host-services`, `picker-source`, ...) render that \
1344         interface's functions, direction, and capability; without, list every \
1345         seam. The catalog is derived from `wit/` at build time.",
1346        ExCommandSpec {
1347            latency_class: LatencyClass::Display,
1348            accepts_bang: false,
1349            accepts_range: false,
1350            parse_args: Arc::new(parse_optional_string),
1351            apply: Arc::new(apply_describe_plugin_api),
1352            args_schema: vec![ArgSpec {
1353                name: "seam".into(),
1354                kind: ArgKind::String,
1355                doc: "Plugin-API interface name (`host-services`, `picker-source`, \
1356                      `grammar`, ...). Omit to list every seam."
1357                    .into(),
1358                prompt: "seam:".into(),
1359                default: ArgDefault::None,
1360                // A `gen:plugin-apis` completion generator is a follow-up (the
1361                // catalog lives host-side in `lattice-plugin-api`, which the
1362                // grammar crate's generators can't reach -- the describe-element
1363                // precedent).
1364                completion: Some("gen:plugin-api-seams".into()),
1365                picker: None,
1366            }],
1367            surface_form: SurfaceForm::Keyword,
1368        },
1369    );
1370    let list_plugin_apis = registry.register_ex_command(
1371        "ex:list-plugin-apis",
1372        "List every plugin-API interface the `wit/` package exposes \
1373         (`:list-plugin-apis`). One row per seam with direction + capability + \
1374         function count.",
1375        ExCommandSpec {
1376            latency_class: LatencyClass::Display,
1377            accepts_bang: false,
1378            accepts_range: false,
1379            parse_args: Arc::new(parse_no_args),
1380            apply: Arc::new(|_| Ok(Effect::ListPluginApis)),
1381            args_schema: vec![],
1382            surface_form: SurfaceForm::Keyword,
1383        },
1384    );
1385    let export_plugin_api = registry.register_ex_command(
1386        "ex:export-plugin-api",
1387        "Export the whole plugin-API catalog to a savable buffer \
1388         (`:export-plugin-api [markdown|json]`). Opens `*plugin-api.md*` (or \
1389         `*plugin-api.json*`) under text-mode; save it with `:w <path>`. \
1390         Defaults to markdown.",
1391        ExCommandSpec {
1392            latency_class: LatencyClass::Display,
1393            accepts_bang: false,
1394            accepts_range: false,
1395            parse_args: Arc::new(parse_optional_string),
1396            apply: Arc::new(apply_export_plugin_api),
1397            args_schema: vec![ArgSpec {
1398                name: "format".into(),
1399                kind: ArgKind::String,
1400                doc: "`markdown` (default) or `json`.".into(),
1401                prompt: "format:".into(),
1402                default: ArgDefault::None,
1403                completion: Some("gen:plugin-api-formats".into()),
1404                picker: None,
1405            }],
1406            surface_form: SurfaceForm::Keyword,
1407        },
1408    );
1409    let list_commands = registry.register_ex_command(
1410        "ex:list-commands",
1411        "List every registered command grouped by source (`:list-commands`): \
1412         built-in, user config, plugin, ... Each row links to its \
1413         `:describe-command` view.",
1414        ExCommandSpec {
1415            latency_class: LatencyClass::Display,
1416            accepts_bang: false,
1417            accepts_range: false,
1418            parse_args: Arc::new(parse_no_args),
1419            apply: Arc::new(|_| Ok(Effect::ListCommands)),
1420            args_schema: vec![],
1421            surface_form: SurfaceForm::Keyword,
1422        },
1423    );
1424    let describe_plugin = registry.register_ex_command(
1425        "ex:describe-plugin",
1426        "Open the help view for a loaded plugin (`:describe-plugin <name>`): its \
1427         own documentation + contributions. (Loaded-plugin enumeration is \
1428         Phase-8-gated; today no plugins are loaded.)",
1429        ExCommandSpec {
1430            latency_class: LatencyClass::Display,
1431            accepts_bang: false,
1432            accepts_range: false,
1433            parse_args: Arc::new(parse_required_string),
1434            apply: Arc::new(|ctx| match &ctx.args {
1435                Args::String(name) => Ok(Effect::DescribePlugin { name: name.clone() }),
1436                _ => Err(CommandError::BadArgs("expected a plugin name".into())),
1437            }),
1438            args_schema: vec![ArgSpec {
1439                name: "name".into(),
1440                kind: ArgKind::String,
1441                doc: "Loaded plugin name (e.g. `git-gutter`).".into(),
1442                prompt: "plugin:".into(),
1443                default: ArgDefault::Required,
1444                completion: Some("gen:plugins".into()),
1445                picker: None,
1446            }],
1447            surface_form: SurfaceForm::Keyword,
1448        },
1449    );
1450    let list_plugins = registry.register_ex_command(
1451        "ex:list-plugins",
1452        "List every loaded plugin (`:list-plugins`): name + doc summary. Empty \
1453         until the Phase-8 plugin loader is wired in.",
1454        ExCommandSpec {
1455            latency_class: LatencyClass::Display,
1456            accepts_bang: false,
1457            accepts_range: false,
1458            parse_args: Arc::new(parse_no_args),
1459            apply: Arc::new(|_| Ok(Effect::ListPlugins)),
1460            args_schema: vec![],
1461            surface_form: SurfaceForm::Keyword,
1462        },
1463    );
1464    let describe_events = registry.register_ex_command(
1465        "ex:describe-events",
1466        "List every registered event (`:describe-events`). Walks the \
1467         distributed-slice event registry and renders one row per event \
1468         with name + source crate + doc.",
1469        ExCommandSpec {
1470            latency_class: LatencyClass::Display,
1471            accepts_bang: false,
1472            accepts_range: false,
1473            parse_args: Arc::new(parse_no_args),
1474            apply: Arc::new(|_| Ok(Effect::DescribeEvents)),
1475            args_schema: vec![],
1476            surface_form: SurfaceForm::Keyword,
1477        },
1478    );
1479    let describe_event = registry.register_ex_command(
1480        "ex:describe-event",
1481        "Open the help view for a registered event (`:describe-event NAME`).",
1482        ExCommandSpec {
1483            latency_class: LatencyClass::Display,
1484            accepts_bang: false,
1485            accepts_range: false,
1486            parse_args: Arc::new(parse_required_string),
1487            apply: Arc::new(|ctx| match &ctx.args {
1488                Args::String(name) => Ok(Effect::DescribeEvent {
1489                    name: name.to_string(),
1490                }),
1491                _ => Err(CommandError::BadArgs("expected event name".into())),
1492            }),
1493            args_schema: vec![ArgSpec {
1494                name: "name".into(),
1495                kind: ArgKind::String,
1496                doc: "Registered event name (e.g. `lsp.buffer-attached`).".into(),
1497                prompt: "event:".into(),
1498                default: ArgDefault::Required,
1499                completion: Some("gen:events".into()),
1500                picker: None,
1501            }],
1502            surface_form: SurfaceForm::Keyword,
1503        },
1504    );
1505    let list_modes = registry.register_ex_command(
1506        "ex:list-modes",
1507        "List every registered mode (`:list-modes`). Groups by \
1508         kind (Major / Minor) and shows each mode's current \
1509         activation state on the active buffer.",
1510        ExCommandSpec {
1511            latency_class: LatencyClass::Display,
1512            accepts_bang: false,
1513            accepts_range: false,
1514            parse_args: Arc::new(parse_no_args),
1515            apply: Arc::new(|_| Ok(Effect::ListModes)),
1516            args_schema: vec![],
1517            surface_form: SurfaceForm::Keyword,
1518        },
1519    );
1520    // `:describe-active-modes`, deliberately NOT `:describe-modes`.
1521    // The shorter name is a prefix-sibling of `:describe-mode`, which
1522    // would push `:describe-mode<Tab>` off the single-candidate
1523    // completion branch and reintroduce the bug
1524    // `tab_on_complete_command_name_steps_into_the_arg_slot` guards.
1525    // Name-resolved at the `:` line and by `keymap_help.rs`; no
1526    // struct field needed (see the CR.6 note on `ExCommands`).
1527    let _describe_active_modes = registry.register_ex_command(
1528        "ex:describe-active-modes",
1529        "Show the mode stack live on the current buffer \
1530         (`:describe-active-modes`, `<C-h>m`): the major plus every \
1531         minor, each with the chords it contributes.",
1532        ExCommandSpec {
1533            latency_class: LatencyClass::Display,
1534            accepts_bang: false,
1535            accepts_range: false,
1536            parse_args: Arc::new(parse_no_args),
1537            apply: Arc::new(|_| Ok(Effect::DescribeActiveModes)),
1538            args_schema: vec![],
1539            surface_form: SurfaceForm::Keyword,
1540        },
1541    );
1542    // DAM.6: the buffer-scoped peer of `:keymap`. `:keymap` renders
1543    // the whole static catalog (the exhaustive reference); this one
1544    // answers "what can I press *here*".
1545    let _describe_bindings = registry.register_ex_command(
1546        "ex:describe-bindings",
1547        "List the chords that can fire on the current buffer \
1548         (`:describe-bindings`, `<C-h>K`): builtin bindings live in \
1549         the current binding-mode plus every active mode's \
1550         contributions. `:keymap` lists the full default catalog.",
1551        ExCommandSpec {
1552            latency_class: LatencyClass::Display,
1553            accepts_bang: false,
1554            accepts_range: false,
1555            parse_args: Arc::new(parse_no_args),
1556            apply: Arc::new(|_| Ok(Effect::DescribeActiveBindings)),
1557            args_schema: vec![],
1558            surface_form: SurfaceForm::Keyword,
1559        },
1560    );
1561    let describe_mode = registry.register_ex_command(
1562        "ex:describe-mode",
1563        "Open the help view for a registered mode \
1564         (`:describe-mode NAME`).",
1565        ExCommandSpec {
1566            latency_class: LatencyClass::Display,
1567            accepts_bang: false,
1568            accepts_range: false,
1569            parse_args: Arc::new(parse_required_string),
1570            apply: Arc::new(|ctx| match &ctx.args {
1571                Args::String(name) => Ok(Effect::DescribeMode {
1572                    name: name.to_string(),
1573                }),
1574                _ => Err(CommandError::BadArgs("expected mode name".into())),
1575            }),
1576            args_schema: vec![ArgSpec {
1577                name: "name".into(),
1578                kind: ArgKind::String,
1579                doc: "Registered mode name (e.g. `lsp-mode`, `text-mode`).".into(),
1580                prompt: "mode:".into(),
1581                default: ArgDefault::Required,
1582                completion: Some("gen:modes".into()),
1583                picker: None,
1584            }],
1585            surface_form: SurfaceForm::Keyword,
1586        },
1587    );
1588    let describe_option_resolution = registry.register_ex_command(
1589        "ex:describe-option-resolution",
1590        "Show which resolver layer provides the resolved value \
1591         for an option on the active buffer \
1592         (`:describe-option-resolution NAME`).",
1593        ExCommandSpec {
1594            latency_class: LatencyClass::Display,
1595            accepts_bang: false,
1596            accepts_range: false,
1597            parse_args: Arc::new(parse_required_string),
1598            apply: Arc::new(|ctx| match &ctx.args {
1599                Args::String(name) => Ok(Effect::DescribeOptionResolution {
1600                    name: name.to_string(),
1601                }),
1602                _ => Err(CommandError::BadArgs("expected option name".into())),
1603            }),
1604            args_schema: vec![ArgSpec {
1605                name: "name".into(),
1606                kind: ArgKind::String,
1607                doc: "Registered option name (e.g. `number`, `tabstop`).".into(),
1608                prompt: "option:".into(),
1609                default: ArgDefault::Required,
1610                completion: Some("gen:options".into()),
1611                picker: None,
1612            }],
1613            surface_form: SurfaceForm::Keyword,
1614        },
1615    );
1616    let tutor = registry.register_ex_command(
1617        "ex:tutor",
1618        "Open the interactive Lattice tutor lesson \
1619         (`:tutor [N]`). With no arg, opens lesson 1. The \
1620         lesson is embedded in the binary; each invocation \
1621         copies a fresh practice file to a temp path so you \
1622         can edit / practice without losing the canonical \
1623         lesson source. Run `:tutor` again to start over.",
1624        ExCommandSpec {
1625            latency_class: LatencyClass::Display,
1626            accepts_bang: false,
1627            accepts_range: false,
1628            parse_args: Arc::new(parse_optional_path),
1629            apply: Arc::new(|ctx| match &ctx.args {
1630                Args::None => Ok(Effect::Tutor { lesson: None }),
1631                Args::String(s) => {
1632                    let n: u32 = s.parse().map_err(|_| {
1633                        CommandError::BadArgs(format!("expected lesson number, got `{s}`"))
1634                    })?;
1635                    Ok(Effect::Tutor { lesson: Some(n) })
1636                }
1637                _ => Err(CommandError::BadArgs(
1638                    "expected at most one numeric argument".into(),
1639                )),
1640            }),
1641            args_schema: vec![ArgSpec {
1642                name: "lesson".into(),
1643                kind: ArgKind::String,
1644                doc: "Lesson number (default: 1).".into(),
1645                prompt: "lesson:".into(),
1646                default: ArgDefault::None,
1647                completion: None,
1648                picker: None,
1649            }],
1650            surface_form: SurfaceForm::Keyword,
1651        },
1652    );
1653    let tutor_next = registry.register_ex_command(
1654        "ex:tutor-next",
1655        "Advance to the next tutor exercise (or lesson). \
1656         Equivalent to pressing `<CR>` in tutor-mode.",
1657        ExCommandSpec {
1658            latency_class: LatencyClass::Display,
1659            accepts_bang: false,
1660            accepts_range: false,
1661            parse_args: Arc::new(parse_no_args),
1662            apply: Arc::new(|_| Ok(Effect::AppAction(AppEffect::TutorAdvance))),
1663            args_schema: vec![],
1664            surface_form: SurfaceForm::Keyword,
1665        },
1666    );
1667    let tutor_prev = registry.register_ex_command(
1668        "ex:tutor-prev",
1669        "Retreat to the previous tutor exercise.",
1670        ExCommandSpec {
1671            latency_class: LatencyClass::Display,
1672            accepts_bang: false,
1673            accepts_range: false,
1674            parse_args: Arc::new(parse_no_args),
1675            apply: Arc::new(|_| Ok(Effect::AppAction(AppEffect::TutorRetreat))),
1676            args_schema: vec![],
1677            surface_form: SurfaceForm::Keyword,
1678        },
1679    );
1680    let customize = registry.register_ex_command(
1681        "ex:customize",
1682        "Open the customize buffer (`:customize [name]`). With no \
1683         arg, opens the picker listing every registered group + \
1684         every mode with at least one customizable option. With \
1685         a `<name>-mode` arg, opens the focused view of that \
1686         mode's contributions. With a group name (no `-mode` \
1687         suffix), opens the cross-mode group view.",
1688        ExCommandSpec {
1689            latency_class: LatencyClass::Display,
1690            accepts_bang: false,
1691            accepts_range: false,
1692            parse_args: Arc::new(parse_optional_path),
1693            apply: Arc::new(|ctx| match &ctx.args {
1694                Args::None => Ok(Effect::Customize { name: None }),
1695                Args::String(name) => Ok(Effect::Customize {
1696                    name: Some(name.to_string()),
1697                }),
1698                _ => Err(CommandError::BadArgs(
1699                    "expected at most one argument".into(),
1700                )),
1701            }),
1702            args_schema: vec![ArgSpec {
1703                name: "name".into(),
1704                kind: ArgKind::String,
1705                doc: "Group name (e.g. `editor`, `lsp`) OR mode name \
1706                      ending in `-mode` (e.g. `lsp-completion-mode`)."
1707                    .into(),
1708                prompt: "customize:".into(),
1709                default: ArgDefault::None,
1710                completion: Some("gen:customize".into()),
1711                picker: None,
1712            }],
1713            surface_form: SurfaceForm::Keyword,
1714        },
1715    );
1716    let hover = registry.register_ex_command(
1717        "ex:hover",
1718        "Open a hover popup at the cursor (`:hover [markdown]`). v1 path: feed text manually; \
1719         Phase 4 LSP will source from `textDocument/hover`.",
1720        ExCommandSpec {
1721            latency_class: LatencyClass::Display,
1722            accepts_bang: false,
1723            accepts_range: false,
1724            parse_args: Arc::new(parse_optional_path),
1725            apply: Arc::new(|ctx| {
1726                let markdown = match &ctx.args {
1727                    Args::String(s) if !s.is_empty() => s.to_string(),
1728                    _ => "(empty hover)".to_string(),
1729                };
1730                Ok(Effect::OpenHover { markdown })
1731            }),
1732            args_schema: vec![ArgSpec {
1733                name: "markdown".into(),
1734                kind: ArgKind::String,
1735                doc: "Markdown body of the hover popup.".into(),
1736                prompt: "hover:".into(),
1737                default: ArgDefault::None,
1738                completion: None,
1739                picker: None,
1740            }],
1741            surface_form: SurfaceForm::Keyword,
1742        },
1743    );
1744    let hover_close = registry.register_ex_command(
1745        "ex:hover-close",
1746        "Dismiss the active hover popup (`:HoverClose`).",
1747        ExCommandSpec {
1748            latency_class: LatencyClass::Reflex,
1749            accepts_bang: false,
1750            accepts_range: false,
1751            parse_args: Arc::new(parse_no_args),
1752            apply: Arc::new(|_| Ok(Effect::DismissPopup)),
1753            args_schema: vec![],
1754            surface_form: SurfaceForm::Keyword,
1755        },
1756    );
1757    // ---- LSP introspection (Phase 4.1.g) -------------------
1758    let messages = registry.register_ex_command(
1759        "ex:messages",
1760        "Open the `*messages*` buffer -- the emacs `*Messages*` analogue carrying every minibuffer echo / notification with timestamps (`:messages`).",
1761        ExCommandSpec {
1762            latency_class: LatencyClass::Display,
1763            accepts_bang: false,
1764            accepts_range: false,
1765            parse_args: Arc::new(parse_no_args),
1766            apply: Arc::new(|_ctx| Ok(Effect::OpenMessages)),
1767            args_schema: Vec::new(),
1768            surface_form: SurfaceForm::Keyword,
1769        },
1770    );
1771    let lsp_log = registry.register_ex_command(
1772        "ex:lsp-log",
1773        "Open the LSP subsystem log (`*lsp*`) or a per-server log (`*lsp:<server>*`) (`:lsp-log [server]`).",
1774        ExCommandSpec {
1775            latency_class: LatencyClass::Display,
1776            accepts_bang: false,
1777            accepts_range: false,
1778            parse_args: Arc::new(parse_optional_path),
1779            apply: Arc::new(|ctx| {
1780                let server_id = match &ctx.args {
1781                    Args::String(s) if !s.is_empty() => Some(s.to_string()),
1782                    _ => None,
1783                };
1784                Ok(Effect::OpenLspLog { server_id })
1785            }),
1786            args_schema: vec![ArgSpec {
1787                name: "server".into(),
1788                kind: ArgKind::String,
1789                doc: "Server id (e.g. `rust`, `python`). Absent = subsystem-wide log.".into(),
1790                prompt: "server:".into(),
1791                default: ArgDefault::None,
1792                completion: Some("gen:lsp-servers".into()),
1793                picker: None,
1794            }],
1795            surface_form: SurfaceForm::Keyword,
1796        },
1797    );
1798    let lsp_trace = registry.register_ex_command(
1799        "ex:lsp-trace",
1800        "Toggle JSON-RPC wire trace for a server (pure toggle; view records via `:lsp-trace-log <server>`) (`:lsp-trace <server>`).",
1801        ExCommandSpec {
1802            latency_class: LatencyClass::Display,
1803            accepts_bang: false,
1804            accepts_range: false,
1805            parse_args: Arc::new(parse_required_string),
1806            apply: Arc::new(|ctx| match &ctx.args {
1807                Args::String(s) if !s.is_empty() => Ok(Effect::ToggleLspTrace {
1808                    server_id: s.to_string(),
1809                }),
1810                _ => Err(CommandError::BadArgs(
1811                    ":lsp-trace requires a server id".into(),
1812                )),
1813            }),
1814            args_schema: vec![ArgSpec {
1815                name: "server".into(),
1816                kind: ArgKind::String,
1817                doc: "Server id to toggle trace on.".into(),
1818                prompt: "server:".into(),
1819                default: ArgDefault::None,
1820                completion: Some("gen:lsp-servers".into()),
1821                picker: None,
1822            }],
1823            surface_form: SurfaceForm::Keyword,
1824        },
1825    );
1826    let _lsp_trace_log = registry.register_ex_command(
1827        "ex:lsp-trace-log",
1828        "Open the JSON-RPC trace ring for an LSP server (`:lsp-trace-log [server]`). No arg = picker over every running instance; arg = pre-filter (single match short-circuits). Independent of `:lsp-trace`.",
1829        ExCommandSpec {
1830            latency_class: LatencyClass::Display,
1831            accepts_bang: false,
1832            accepts_range: false,
1833            parse_args: Arc::new(parse_optional_path),
1834            apply: Arc::new(|ctx| {
1835                let server_id = match &ctx.args {
1836                    Args::String(s) if !s.is_empty() => Some(s.to_string()),
1837                    _ => None,
1838                };
1839                Ok(Effect::OpenLspTraceLog { server_id })
1840            }),
1841            args_schema: vec![ArgSpec {
1842                name: "server".into(),
1843                kind: ArgKind::String,
1844                doc: "Server id (e.g. `rust`). Absent = picker over every running instance.".into(),
1845                prompt: "server:".into(),
1846                default: ArgDefault::None,
1847                completion: Some("gen:lsp-servers".into()),
1848                picker: None,
1849            }],
1850            surface_form: SurfaceForm::Keyword,
1851        },
1852    );
1853    let lsp_status = registry.register_ex_command(
1854        "ex:lsp-status",
1855        "Render every running LSP server (id, root, pid, uptime) in a help-style buffer (`:lsp-status`).",
1856        ExCommandSpec {
1857            latency_class: LatencyClass::Display,
1858            accepts_bang: false,
1859            accepts_range: false,
1860            parse_args: Arc::new(parse_no_args),
1861            apply: Arc::new(|_| Ok(Effect::LspStatus)),
1862            args_schema: vec![],
1863            surface_form: SurfaceForm::Keyword,
1864        },
1865    );
1866    // LR.2: the editable references view. One alias, dashed and
1867    // `lsp-` namespaced. Deliberately no chord: `gR` is vim's Virtual
1868    // Replace, unimplemented here, so binding it would foreclose a
1869    // grammar slot rather than collide with one. `<C-q>` from the
1870    // picker (LR.5) is the discoverable path.
1871    let _lsp_references_view = registry.register_ex_command(
1872        "ex:lsp-references",
1873        "Open every reference to the symbol under the cursor as an editable multibuffer, \
1874         one excerpt per site. Edits propagate to the source files. `gr` keeps opening the \
1875         picker, which is the better surface for jumping to a single site.",
1876        ExCommandSpec {
1877            latency_class: LatencyClass::Display,
1878            accepts_bang: false,
1879            accepts_range: false,
1880            parse_args: Arc::new(parse_no_args),
1881            apply: Arc::new(|_| Ok(Effect::Lsp(LspRequest::ReferencesView))),
1882            args_schema: vec![],
1883            surface_form: SurfaceForm::Keyword,
1884        },
1885    );
1886
1887    // EP.6: references into the error list, on demand. A third
1888    // terminus on the references drain, not a cache snapshot — there is
1889    // no standing "current references" state to pull from.
1890    let _lsp_references_to_error_list = registry.register_ex_command(
1891        "ex:lsp-references-to-error-list",
1892        "Find references to the symbol under the cursor and put them in the error list \
1893         (`:next-error` / `]qq` / `:problems`). The manual peer of \
1894         `lsp.references-to-error-list`, which is off by default.",
1895        ExCommandSpec {
1896            latency_class: LatencyClass::Display,
1897            accepts_bang: false,
1898            accepts_range: false,
1899            parse_args: Arc::new(parse_no_args),
1900            apply: Arc::new(|_| Ok(Effect::Lsp(LspRequest::ReferencesToErrorList))),
1901            args_schema: vec![],
1902            surface_form: SurfaceForm::Keyword,
1903        },
1904    );
1905
1906    // EP.4: one alias, dashed + `lsp-` namespaced, per the ex-command
1907    // naming rule. No collapsed spelling, no generic `diagnostics`
1908    // alias -- a generic name would imply it works without LSP.
1909    let _lsp_diagnostics_to_error_list = registry.register_ex_command(
1910        "ex:lsp-diagnostics-to-error-list",
1911        "Pull the language server's currently published diagnostics into the error list \
1912         (`:problems` / `:next-error` / the picker). The manual peer of \
1913         `lsp.diagnostics-to-error-list`; surfaces what servers have published, which is \
1914         not a workspace scan.",
1915        ExCommandSpec {
1916            latency_class: LatencyClass::Display,
1917            accepts_bang: false,
1918            accepts_range: false,
1919            parse_args: Arc::new(parse_no_args),
1920            apply: Arc::new(|_| Ok(Effect::LspDiagnosticsToErrorList)),
1921            args_schema: vec![],
1922            surface_form: SurfaceForm::Keyword,
1923        },
1924    );
1925    let lsp_server_log = registry.register_ex_command(
1926        "ex:lsp-server-log",
1927        "Picker-style listing of every running LSP server actor with workspace root + buffer count + capability summary; each row links to its log + trace via `exec:` URLs (`:lsp-server-log`).",
1928        ExCommandSpec {
1929            latency_class: LatencyClass::Display,
1930            accepts_bang: false,
1931            accepts_range: false,
1932            parse_args: Arc::new(parse_no_args),
1933            apply: Arc::new(|_| Ok(Effect::LspServerLogListing)),
1934            args_schema: vec![],
1935            surface_form: SurfaceForm::Keyword,
1936        },
1937    );
1938    let lsp_restart = registry.register_ex_command(
1939        "ex:lsp-restart",
1940        "Force-restart a stuck LSP server. Wired but no-op until the supervisor restart path lands in 4.4 (`:lsp-restart <server>`).",
1941        ExCommandSpec {
1942            latency_class: LatencyClass::Display,
1943            accepts_bang: false,
1944            accepts_range: false,
1945            parse_args: Arc::new(parse_required_string),
1946            apply: Arc::new(|ctx| match &ctx.args {
1947                Args::String(s) if !s.is_empty() => Ok(Effect::LspRestart {
1948                    server_id: s.to_string(),
1949                }),
1950                _ => Err(CommandError::BadArgs(
1951                    ":lsp-restart requires a server id".into(),
1952                )),
1953            }),
1954            args_schema: vec![ArgSpec {
1955                name: "server".into(),
1956                kind: ArgKind::String,
1957                doc: "Server id to restart.".into(),
1958                prompt: "server:".into(),
1959                default: ArgDefault::None,
1960                completion: Some("gen:lsp-servers".into()),
1961                picker: None,
1962            }],
1963            surface_form: SurfaceForm::Keyword,
1964        },
1965    );
1966    let lsp_progress_cancel = registry.register_ex_command(
1967        "ex:lsp-progress-cancel",
1968        "Cancel cancellable LSP $/progress operations on the named server (or every server attached to the active buffer if omitted) (`:lsp-progress-cancel [server]`).",
1969        ExCommandSpec {
1970            latency_class: LatencyClass::Reflex,
1971            accepts_bang: false,
1972            accepts_range: false,
1973            parse_args: Arc::new(parse_optional_path),
1974            apply: Arc::new(|ctx| match &ctx.args {
1975                Args::String(s) if !s.trim().is_empty() => Ok(Effect::LspProgressCancel {
1976                    server_id: Some(s.trim().to_string()),
1977                }),
1978                _ => Ok(Effect::LspProgressCancel { server_id: None }),
1979            }),
1980            args_schema: vec![ArgSpec {
1981                name: "server".into(),
1982                kind: ArgKind::String,
1983                doc: "Optional server id; omit to cancel on every attached server.".into(),
1984                prompt: "server:".into(),
1985                default: ArgDefault::None,
1986                completion: Some("gen:lsp-servers".into()),
1987                picker: None,
1988            }],
1989            surface_form: SurfaceForm::Keyword,
1990        },
1991    );
1992    let lsp_expand_region = registry.register_ex_command(
1993        "ex:lsp-expand-region",
1994        "Smart-expansion: walk one step outward in the LSP selectionRange chain (`:lsp-expand-region`).",
1995        ExCommandSpec {
1996            latency_class: LatencyClass::Reflex,
1997            accepts_bang: false,
1998            accepts_range: false,
1999            parse_args: Arc::new(parse_no_args),
2000            apply: Arc::new(|_| Ok(Effect::LspExpandRegion)),
2001            args_schema: vec![],
2002            surface_form: SurfaceForm::Keyword,
2003        },
2004    );
2005    let lsp_shrink_region = registry.register_ex_command(
2006        "ex:lsp-shrink-region",
2007        "Walk one step inward in the cached LSP selectionRange chain (`:lsp-shrink-region`).",
2008        ExCommandSpec {
2009            latency_class: LatencyClass::Reflex,
2010            accepts_bang: false,
2011            accepts_range: false,
2012            parse_args: Arc::new(parse_no_args),
2013            apply: Arc::new(|_| Ok(Effect::LspShrinkRegion)),
2014            args_schema: vec![],
2015            surface_form: SurfaceForm::Keyword,
2016        },
2017    );
2018    let lsp_log_level = registry.register_ex_command(
2019        "ex:lsp-log-level",
2020        "Set the subsystem-wide default min log level (or a per-server override) (`:lsp-log-level [server] <level>`).",
2021        ExCommandSpec {
2022            latency_class: LatencyClass::Reflex,
2023            accepts_bang: false,
2024            accepts_range: false,
2025            parse_args: Arc::new(parse_required_string),
2026            apply: Arc::new(|ctx| match &ctx.args {
2027                Args::String(s) if !s.is_empty() => {
2028                    let trimmed = s.trim();
2029                    let mut parts = trimmed.split_whitespace();
2030                    let first = parts.next().unwrap_or("");
2031                    let second = parts.next();
2032                    let (server_id, level) = match second {
2033                        Some(level) => (Some(first.to_string()), level.to_string()),
2034                        None => (None, first.to_string()),
2035                    };
2036                    Ok(Effect::SetLspLogLevel { server_id, level })
2037                }
2038                _ => Err(CommandError::BadArgs(
2039                    ":lsp-log-level requires `[server] <level>`".into(),
2040                )),
2041            }),
2042            args_schema: vec![ArgSpec {
2043                name: "spec".into(),
2044                kind: ArgKind::String,
2045                // Single-token completion lands the level form
2046                // (`info`, `debug`, ...). The two-token
2047                // `<server> <level>` form parses correctly at
2048                // submit; v1 ships completion for the common
2049                // (subsystem-wide) shape.
2050                doc: "Either a level (`error`/`warn`/`info`/`debug`/`trace`) for the subsystem default, or `<server> <level>` for a per-server override.".into(),
2051                prompt: "[server] level:".into(),
2052                default: ArgDefault::None,
2053                completion: Some("gen:log-levels".into()),
2054                picker: None,
2055            }],
2056            surface_form: SurfaceForm::Keyword,
2057        },
2058    );
2059    let lsp_log_clear = registry.register_ex_command(
2060        "ex:lsp-log-clear",
2061        "Drop the records in `*lsp*` (no arg) or `*lsp:<server>*` (with arg) (`:lsp-log-clear [server]`).",
2062        ExCommandSpec {
2063            latency_class: LatencyClass::Reflex,
2064            accepts_bang: false,
2065            accepts_range: false,
2066            parse_args: Arc::new(parse_optional_path),
2067            apply: Arc::new(|ctx| {
2068                let server_id = match &ctx.args {
2069                    Args::String(s) if !s.is_empty() => Some(s.to_string()),
2070                    _ => None,
2071                };
2072                Ok(Effect::LspLogClear { server_id })
2073            }),
2074            args_schema: vec![ArgSpec {
2075                name: "server".into(),
2076                kind: ArgKind::String,
2077                doc: "Server id whose ring to clear. Absent = subsystem-wide.".into(),
2078                prompt: "server:".into(),
2079                default: ArgDefault::None,
2080                completion: Some("gen:lsp-servers".into()),
2081                picker: None,
2082            }],
2083            surface_form: SurfaceForm::Keyword,
2084        },
2085    );
2086
2087    let lsp_symbols = registry.register_ex_command(
2088        "ex:lsp-symbols",
2089        "Open a vertico picker over the active document's symbol outline (`:lsp-symbols`, Phase 4.2.e).",
2090        ExCommandSpec {
2091            latency_class: LatencyClass::Display,
2092            accepts_bang: false,
2093            accepts_range: false,
2094            parse_args: Arc::new(parse_no_args),
2095            apply: Arc::new(|_| Ok(Effect::LspDocumentSymbol)),
2096            args_schema: vec![],
2097            surface_form: SurfaceForm::Keyword,
2098        },
2099    );
2100    // IN.8b: the LSP-INDEPENDENT format command. Cascades on
2101    // AVAILABILITY, not on failure: if an attached server advertises
2102    // formatting it is used, otherwise `formatprg` or the built-in
2103    // table. Failure-fallback would need a "how long do we wait before
2104    // giving up" answer, and any number there is arbitrary.
2105    let format = registry.register_ex_command(
2106        "ex:format",
2107        "Format the active buffer: LSP if a server advertises formatting, otherwise `formatprg` \
2108         or the built-in per-language formatter table (`:format`).",
2109        ExCommandSpec {
2110            latency_class: LatencyClass::Display,
2111            accepts_bang: false,
2112            accepts_range: false,
2113            parse_args: Arc::new(parse_no_args),
2114            apply: Arc::new(|_| Ok(Effect::Format)),
2115            args_schema: vec![],
2116            surface_form: SurfaceForm::Keyword,
2117        },
2118    );
2119    let lsp_format = registry.register_ex_command(
2120        "ex:lsp-format",
2121        "Run `textDocument/formatting` on the active buffer's highest-priority LSP server; apply the returned edits as one undo unit (`:format`, Phase 4.3).",
2122        ExCommandSpec {
2123            latency_class: LatencyClass::Display,
2124            accepts_bang: false,
2125            accepts_range: false,
2126            parse_args: Arc::new(parse_no_args),
2127            apply: Arc::new(|_| Ok(Effect::LspFormat)),
2128            args_schema: vec![],
2129            surface_form: SurfaceForm::Keyword,
2130        },
2131    );
2132    let lsp_format_range = registry.register_ex_command(
2133        "ex:lsp-format-range",
2134        "Run `textDocument/rangeFormatting` over the active Visual selection or the supplied line range (`:[range]format-range`, Phase 4.3).",
2135        ExCommandSpec {
2136            latency_class: LatencyClass::Display,
2137            accepts_bang: false,
2138            accepts_range: true,
2139            parse_args: Arc::new(parse_no_args),
2140            apply: Arc::new(|_| Ok(Effect::LspFormatRange)),
2141            args_schema: vec![],
2142            surface_form: SurfaceForm::Keyword,
2143        },
2144    );
2145
2146    let lsp_code_action = registry.register_ex_command(
2147        "ex:lsp-code-action",
2148        "Open a vertico picker over LSP code actions at the cursor / selection (`:code-actions`, Phase 4.3).",
2149        ExCommandSpec {
2150            latency_class: LatencyClass::Display,
2151            accepts_bang: false,
2152            accepts_range: true,
2153            parse_args: Arc::new(parse_no_args),
2154            apply: Arc::new(|_| Ok(Effect::LspCodeAction)),
2155            args_schema: vec![],
2156            surface_form: SurfaceForm::Keyword,
2157        },
2158    );
2159
2160    let lsp_rename = registry.register_ex_command(
2161        "ex:lsp-rename",
2162        "Rename the symbol under cursor across the workspace via textDocument/rename. Empty name uses prepareRename's placeholder when advertised (`:rename [new-name]`, Phase 4.3).",
2163        ExCommandSpec {
2164            latency_class: LatencyClass::Display,
2165            accepts_bang: false,
2166            accepts_range: false,
2167            parse_args: Arc::new(parse_optional_path),
2168            apply: Arc::new(|ctx| {
2169                let new_name = match &ctx.args {
2170                    Args::String(s) => s.trim().to_string(),
2171                    _ => String::new(),
2172                };
2173                Ok(Effect::LspRename { new_name })
2174            }),
2175            args_schema: vec![ArgSpec {
2176                name: "new-name".into(),
2177                kind: ArgKind::String,
2178                doc: "Replacement identifier. Empty -> use the server's prepareRename placeholder.".into(),
2179                prompt: "new name:".into(),
2180                default: ArgDefault::None,
2181                completion: None,
2182                picker: None,
2183            }],
2184            surface_form: SurfaceForm::Keyword,
2185        },
2186    );
2187
2188    let lsp_complete = registry.register_ex_command(
2189        "ex:lsp-complete",
2190        "Open a vertico picker over LSP completion items at the cursor (`:complete`, Phase 4.2.g). Plain-text insert -- snippet expansion / lazy resolve land with buffer-level Insert-mode completion.",
2191        ExCommandSpec {
2192            latency_class: LatencyClass::Display,
2193            accepts_bang: false,
2194            accepts_range: false,
2195            parse_args: Arc::new(parse_no_args),
2196            apply: Arc::new(|_| Ok(Effect::LspComplete)),
2197            args_schema: vec![],
2198            surface_form: SurfaceForm::Keyword,
2199        },
2200    );
2201
2202    let lsp_signature_help = registry.register_ex_command(
2203        "ex:lsp-signature-help",
2204        "Open the LSP signature-help popup for the current cursor (`:signature-help`, Phase 4.3). Trigger-character driven in Insert mode -- typing `(` / `,` etc. fires the same request automatically.",
2205        ExCommandSpec {
2206            latency_class: LatencyClass::Display,
2207            accepts_bang: false,
2208            accepts_range: false,
2209            parse_args: Arc::new(parse_no_args),
2210            apply: Arc::new(|_| Ok(Effect::LspSignatureHelp)),
2211            args_schema: vec![],
2212            surface_form: SurfaceForm::Keyword,
2213        },
2214    );
2215
2216    let lsp_workspace_symbol = registry.register_ex_command(
2217        "ex:lsp-workspace-symbol",
2218        "Open a vertico picker over workspace symbols matching `query` (server-side substring filter; `:lsp-workspace-symbol [query]`, Phase 4.2.f).",
2219        ExCommandSpec {
2220            latency_class: LatencyClass::Display,
2221            accepts_bang: false,
2222            accepts_range: false,
2223            parse_args: Arc::new(parse_optional_path),
2224            apply: Arc::new(|ctx| {
2225                let query = match &ctx.args {
2226                    Args::String(s) => s.to_string(),
2227                    _ => String::new(),
2228                };
2229                Ok(Effect::LspWorkspaceSymbol { query })
2230            }),
2231            args_schema: vec![ArgSpec {
2232                name: "query".into(),
2233                kind: ArgKind::String,
2234                doc: "Server-side substring filter; empty returns the server's idea of \"every workspace symbol\".".into(),
2235                prompt: "query:".into(),
2236                default: ArgDefault::None,
2237                completion: None,
2238                picker: None,
2239            }],
2240            surface_form: SurfaceForm::Keyword,
2241        },
2242    );
2243
2244    let lsp_incoming_calls = registry.register_ex_command(
2245        "ex:lsp-incoming-calls",
2246        "Open a vertico picker over the callers of the function at the cursor (`textDocument/prepareCallHierarchy` -> `callHierarchy/incomingCalls`, Phase 4.5.a).",
2247        ExCommandSpec {
2248            latency_class: LatencyClass::Display,
2249            accepts_bang: false,
2250            accepts_range: false,
2251            parse_args: Arc::new(parse_no_args),
2252            apply: Arc::new(|_ctx| Ok(Effect::LspIncomingCalls)),
2253            args_schema: Vec::new(),
2254            surface_form: SurfaceForm::Keyword,
2255        },
2256    );
2257    let lsp_outgoing_calls = registry.register_ex_command(
2258        "ex:lsp-outgoing-calls",
2259        "Open a vertico picker over the callees of the function at the cursor (`textDocument/prepareCallHierarchy` -> `callHierarchy/outgoingCalls`, Phase 4.5.a).",
2260        ExCommandSpec {
2261            latency_class: LatencyClass::Display,
2262            accepts_bang: false,
2263            accepts_range: false,
2264            parse_args: Arc::new(parse_no_args),
2265            apply: Arc::new(|_ctx| Ok(Effect::LspOutgoingCalls)),
2266            args_schema: Vec::new(),
2267            surface_form: SurfaceForm::Keyword,
2268        },
2269    );
2270    let lsp_supertypes = registry.register_ex_command(
2271        "ex:lsp-supertypes",
2272        "Open a vertico picker over the types the type at the cursor subtypes (`textDocument/prepareTypeHierarchy` -> `typeHierarchy/supertypes`, Phase 4.5.b).",
2273        ExCommandSpec {
2274            latency_class: LatencyClass::Display,
2275            accepts_bang: false,
2276            accepts_range: false,
2277            parse_args: Arc::new(parse_no_args),
2278            apply: Arc::new(|_ctx| Ok(Effect::LspSupertypes)),
2279            args_schema: Vec::new(),
2280            surface_form: SurfaceForm::Keyword,
2281        },
2282    );
2283    let lsp_subtypes = registry.register_ex_command(
2284        "ex:lsp-subtypes",
2285        "Open a vertico picker over the subtypes of the type at the cursor (`textDocument/prepareTypeHierarchy` -> `typeHierarchy/subtypes`, Phase 4.5.b).",
2286        ExCommandSpec {
2287            latency_class: LatencyClass::Display,
2288            accepts_bang: false,
2289            accepts_range: false,
2290            parse_args: Arc::new(parse_no_args),
2291            apply: Arc::new(|_ctx| Ok(Effect::LspSubtypes)),
2292            args_schema: Vec::new(),
2293            surface_form: SurfaceForm::Keyword,
2294        },
2295    );
2296    let lsp_moniker = registry.register_ex_command(
2297        "ex:lsp-moniker",
2298        "Fire `textDocument/moniker` at the cursor and echo the result (cross-project symbol identifier, Phase 4.5.g).",
2299        ExCommandSpec {
2300            latency_class: LatencyClass::Display,
2301            accepts_bang: false,
2302            accepts_range: false,
2303            parse_args: Arc::new(parse_no_args),
2304            apply: Arc::new(|_ctx| Ok(Effect::LspMoniker)),
2305            args_schema: Vec::new(),
2306            surface_form: SurfaceForm::Keyword,
2307        },
2308    );
2309    let lsp_code_lens = registry.register_ex_command(
2310        "ex:lsp-code-lens",
2311        "Open a picker over the active buffer's cached code lenses; accept routes the chosen lens through `workspace/executeCommand` (Phase 4.5.d).",
2312        ExCommandSpec {
2313            latency_class: LatencyClass::Display,
2314            accepts_bang: false,
2315            accepts_range: false,
2316            parse_args: Arc::new(parse_no_args),
2317            apply: Arc::new(|_ctx| Ok(Effect::LspCodeLens)),
2318            args_schema: Vec::new(),
2319            surface_form: SurfaceForm::Keyword,
2320        },
2321    );
2322    let lsp_color_presentation = registry.register_ex_command(
2323        "ex:lsp-color-presentation",
2324        "At the cursor, look up the color literal in the documentColor cache and open a picker of alternative formats (`textDocument/colorPresentation`, Phase 4.5.e).",
2325        ExCommandSpec {
2326            latency_class: LatencyClass::Display,
2327            accepts_bang: false,
2328            accepts_range: false,
2329            parse_args: Arc::new(parse_no_args),
2330            apply: Arc::new(|_ctx| Ok(Effect::LspColorPresentation)),
2331            args_schema: Vec::new(),
2332            surface_form: SurfaceForm::Keyword,
2333        },
2334    );
2335
2336    let list_diagnostics = registry.register_ex_command(
2337        "ex:diagnostics",
2338        "Open a help-style buffer listing every workspace diagnostic with clickable per-entry source links (`:diagnostics`).",
2339        ExCommandSpec {
2340            latency_class: LatencyClass::Display,
2341            accepts_bang: false,
2342            accepts_range: false,
2343            parse_args: Arc::new(parse_no_args),
2344            apply: Arc::new(|_| Ok(Effect::ListDiagnostics)),
2345            args_schema: vec![],
2346            surface_form: SurfaceForm::Keyword,
2347        },
2348    );
2349    let _list_errors = registry.register_ex_command(
2350        "error-list",
2351        "Open the error list in a fuzzy picker (`:error`; vim `:clist`/`:cl`). The flat browse-and-jump surface — complements `:next-error` (step) and `:problems` (the *problems* multibuffer).",
2352        ExCommandSpec {
2353            latency_class: LatencyClass::Display,
2354            accepts_bang: false,
2355            accepts_range: false,
2356            parse_args: Arc::new(parse_no_args),
2357            apply: Arc::new(|_| Ok(Effect::ListErrors)),
2358            args_schema: vec![],
2359            surface_form: SurfaceForm::Keyword,
2360        },
2361    );
2362    let next_diagnostic = registry.register_ex_command(
2363        "ex:diag-next",
2364        "Move the cursor to the next diagnostic in the active buffer (wraps; `]d` / `:diag-next` / `:cnext`).",
2365        ExCommandSpec {
2366            latency_class: LatencyClass::Reflex,
2367            accepts_bang: false,
2368            accepts_range: false,
2369            parse_args: Arc::new(parse_no_args),
2370            apply: Arc::new(|_| Ok(Effect::NextDiagnostic)),
2371            args_schema: vec![],
2372            surface_form: SurfaceForm::Keyword,
2373        },
2374    );
2375    let prev_diagnostic = registry.register_ex_command(
2376        "ex:diag-prev",
2377        "Move the cursor to the previous diagnostic in the active buffer (wraps; `[d` / `:diag-prev` / `:cprev`).",
2378        ExCommandSpec {
2379            latency_class: LatencyClass::Reflex,
2380            accepts_bang: false,
2381            accepts_range: false,
2382            parse_args: Arc::new(parse_no_args),
2383            apply: Arc::new(|_| Ok(Effect::PrevDiagnostic)),
2384            args_schema: vec![],
2385            surface_form: SurfaceForm::Keyword,
2386        },
2387    );
2388    // CM.2 / CM.7 / naming-2026-07-22: the error navigation family.
2389    // Readable canonical names (`next-error`, emacs vocabulary) lead;
2390    // the vim `:c*` spellings are aliases in `lattice-host::excommand`.
2391    // Each returns `Effect::AppAction(AppEffect::ErrorNav { target })`;
2392    // the host's `do_error_nav` walks the core error list (echoes
2393    // `no error list` when empty — no diagnostic fallback). IDs are
2394    // not stored (resolved by name), mirroring `_tab_move`.
2395    let _error_next = registry.register_ex_command(
2396        "next-error",
2397        "Jump to the next error / location in the error list (wraps; `:next-error`; vim `:cnext`/`:cn`/`]qq`). Echoes `no error list` when empty (diagnostics live on `]d`/`[d` / `:diag-*`).",
2398        ExCommandSpec {
2399            latency_class: LatencyClass::Reflex,
2400            accepts_bang: false,
2401            accepts_range: false,
2402            parse_args: Arc::new(parse_no_args),
2403            apply: Arc::new(|_| {
2404                Ok(Effect::AppAction(AppEffect::ErrorNav {
2405                    target: crate::app_effect::ErrorTarget::Next,
2406                }))
2407            }),
2408            args_schema: vec![],
2409            surface_form: SurfaceForm::Keyword,
2410        },
2411    );
2412    let _error_prev = registry.register_ex_command(
2413        "previous-error",
2414        "Jump to the previous error / location in the error list (wraps; `:previous-error`; vim `:cprev`/`:cp`/`[qq`). Echoes `no error list` when empty (diagnostics live on `]d`/`[d` / `:diag-*`).",
2415        ExCommandSpec {
2416            latency_class: LatencyClass::Reflex,
2417            accepts_bang: false,
2418            accepts_range: false,
2419            parse_args: Arc::new(parse_no_args),
2420            apply: Arc::new(|_| {
2421                Ok(Effect::AppAction(AppEffect::ErrorNav {
2422                    target: crate::app_effect::ErrorTarget::Prev,
2423                }))
2424            }),
2425            args_schema: vec![],
2426            surface_form: SurfaceForm::Keyword,
2427        },
2428    );
2429    let _error_cc = registry.register_ex_command(
2430        "error",
2431        "Jump to error / location N in the error list (1-based; `:error [N]`; vim `:cc [N]`). Bare `:error` re-visits the current entry.",
2432        ExCommandSpec {
2433            latency_class: LatencyClass::Reflex,
2434            accepts_bang: false,
2435            accepts_range: false,
2436            parse_args: Arc::new(parse_cc_arg),
2437            apply: Arc::new(|ctx| {
2438                let n: Option<usize> = match &ctx.args {
2439                    Args::String(s) => s.parse::<usize>().ok(),
2440                    _ => None,
2441                };
2442                Ok(Effect::AppAction(AppEffect::ErrorNav {
2443                    target: crate::app_effect::ErrorTarget::Jump(n),
2444                }))
2445            }),
2446            args_schema: vec![ArgSpec::optional(
2447                "n",
2448                ArgKind::Int,
2449                "Target error entry (1-indexed; default current)",
2450            )],
2451            surface_form: SurfaceForm::Keyword,
2452        },
2453    );
2454    let _error_first = registry.register_ex_command(
2455        "first-error",
2456        "Jump to the first error / location in the error list (`:first-error`; vim `:cfirst`/`:cr`/`[Q`).",
2457        ExCommandSpec {
2458            latency_class: LatencyClass::Reflex,
2459            accepts_bang: false,
2460            accepts_range: false,
2461            parse_args: Arc::new(parse_no_args),
2462            apply: Arc::new(|_| {
2463                Ok(Effect::AppAction(AppEffect::ErrorNav {
2464                    target: crate::app_effect::ErrorTarget::First,
2465                }))
2466            }),
2467            args_schema: vec![],
2468            surface_form: SurfaceForm::Keyword,
2469        },
2470    );
2471    let _error_last = registry.register_ex_command(
2472        "last-error",
2473        "Jump to the last error / location in the error list (`:last-error`; vim `:clast`/`]Q`).",
2474        ExCommandSpec {
2475            latency_class: LatencyClass::Reflex,
2476            accepts_bang: false,
2477            accepts_range: false,
2478            parse_args: Arc::new(parse_no_args),
2479            apply: Arc::new(|_| {
2480                Ok(Effect::AppAction(AppEffect::ErrorNav {
2481                    target: crate::app_effect::ErrorTarget::Last,
2482                }))
2483            }),
2484            args_schema: vec![],
2485            surface_form: SurfaceForm::Keyword,
2486        },
2487    );
2488    // CM.7 (2026-07-22): file-level error traversal — jump to the
2489    // first entry of the next / previous file. `:next-error-file`/`]qf`
2490    // (vim `:cnextfile`/`:cnf`) and `:previous-error-file`/`[qf`.
2491    let _error_next_file = registry.register_ex_command(
2492        "next-error-file",
2493        "Jump to the first error / location in the next file (wraps; `:next-error-file`; vim `:cnextfile`/`:cnf`/`]qf`).",
2494        ExCommandSpec {
2495            latency_class: LatencyClass::Reflex,
2496            accepts_bang: false,
2497            accepts_range: false,
2498            parse_args: Arc::new(parse_no_args),
2499            apply: Arc::new(|_| {
2500                Ok(Effect::AppAction(AppEffect::ErrorNav {
2501                    target: crate::app_effect::ErrorTarget::NextFile,
2502                }))
2503            }),
2504            args_schema: vec![],
2505            surface_form: SurfaceForm::Keyword,
2506        },
2507    );
2508    let _error_prev_file = registry.register_ex_command(
2509        "previous-error-file",
2510        "Jump to the first error / location in the previous file (wraps; `:previous-error-file`; vim `:cprevfile`/`:cpf`/`[qf`).",
2511        ExCommandSpec {
2512            latency_class: LatencyClass::Reflex,
2513            accepts_bang: false,
2514            accepts_range: false,
2515            parse_args: Arc::new(parse_no_args),
2516            apply: Arc::new(|_| {
2517                Ok(Effect::AppAction(AppEffect::ErrorNav {
2518                    target: crate::app_effect::ErrorTarget::PrevFile,
2519                }))
2520            }),
2521            args_schema: vec![],
2522            surface_form: SurfaceForm::Keyword,
2523        },
2524    );
2525    // SN.3c.1 (2026-06-14): `:snippet-expand` ex-command removed.
2526    // The only live snippet-expand trigger is `<C-x><C-s>`, now
2527    // mode-owned by `snippet-mode` (`keymap()` + `action_handlers()`
2528    // → `Effect::ExpandSnippet`). An ex-command surface form was
2529    // UX-useless, so it's gone rather than re-routed.
2530    let reload_snippets = registry.register_ex_command(
2531        "ex:reload-snippets",
2532        "Re-read every snippet file from disk and rebuild the per-language snippet registry (`:reload-snippets`, Phase 4.2.g.4).",
2533        ExCommandSpec {
2534            latency_class: LatencyClass::Display,
2535            accepts_bang: false,
2536            accepts_range: false,
2537            parse_args: Arc::new(parse_no_args),
2538            apply: Arc::new(|_| Ok(Effect::ReloadSnippets)),
2539            args_schema: vec![],
2540            surface_form: SurfaceForm::Keyword,
2541        },
2542    );
2543    let cd = registry.register_ex_command(
2544        "ex:cd",
2545        "Change the working directory (`:cd [path]`). No arg goes to HOME.",
2546        ExCommandSpec {
2547            latency_class: LatencyClass::Reflex,
2548            accepts_bang: false,
2549            accepts_range: false,
2550            parse_args: Arc::new(parse_optional_path),
2551            apply: Arc::new(|ctx| {
2552                let path = match &ctx.args {
2553                    Args::String(s) => Some(s.clone()),
2554                    Args::None => None,
2555                    _ => return Err(CommandError::BadArgs("unexpected argument".into())),
2556                };
2557                Ok(Effect::ChangeDir(path))
2558            }),
2559            args_schema: vec![ArgSpec {
2560                name: "path".into(),
2561                kind: ArgKind::String,
2562                doc: "Directory path. Absent = HOME.".into(),
2563                prompt: "directory:".into(),
2564                default: ArgDefault::None,
2565                completion: Some("gen:directories".into()),
2566                picker: None,
2567            }],
2568            surface_form: SurfaceForm::Keyword,
2569        },
2570    );
2571    let pwd = registry.register_ex_command(
2572        "ex:pwd",
2573        "Print the current working directory (`:pwd`).",
2574        ExCommandSpec {
2575            latency_class: LatencyClass::Reflex,
2576            accepts_bang: false,
2577            accepts_range: false,
2578            parse_args: Arc::new(parse_no_args),
2579            apply: Arc::new(|_| Ok(Effect::PrintWorkingDir)),
2580            args_schema: vec![],
2581            surface_form: SurfaceForm::Keyword,
2582        },
2583    );
2584    let project_root = registry.register_ex_command(
2585        "ex:project-root",
2586        "Print the active buffer's project root and the marker that decided it (`:project-root`).",
2587        ExCommandSpec {
2588            latency_class: LatencyClass::Reflex,
2589            accepts_bang: false,
2590            accepts_range: false,
2591            parse_args: Arc::new(parse_no_args),
2592            apply: Arc::new(|_| Ok(Effect::PrintProjectRoot)),
2593            args_schema: vec![],
2594            surface_form: SurfaceForm::Keyword,
2595        },
2596    );
2597    let help = registry.register_ex_command(
2598        "ex:help",
2599        "Open the topic index or a named help topic (`:help [topic]`).",
2600        ExCommandSpec {
2601            latency_class: LatencyClass::Display,
2602            accepts_bang: false,
2603            accepts_range: false,
2604            parse_args: Arc::new(parse_optional_path),
2605            apply: Arc::new(|ctx| {
2606                let topic = match &ctx.args {
2607                    Args::String(s) if !s.is_empty() => Some(s.to_string()),
2608                    _ => None,
2609                };
2610                Ok(Effect::OpenHelpTopic { topic })
2611            }),
2612            args_schema: vec![ArgSpec {
2613                name: "topic".into(),
2614                kind: ArgKind::String,
2615                doc: "Topic name (`folding`, `buffers`, ...). Absent = index.".into(),
2616                prompt: "topic:".into(),
2617                default: ArgDefault::None,
2618                completion: Some("gen:help-topics".into()),
2619                picker: None,
2620            }],
2621            surface_form: SurfaceForm::Keyword,
2622        },
2623    );
2624    ExBuiltins {
2625        write,
2626        quit,
2627        write_quit,
2628        no_hlsearch,
2629        list_registers,
2630        list_marks,
2631        delete_line,
2632        set_option,
2633        set_local_option,
2634        set_global_option,
2635        colorscheme,
2636        edit,
2637        substitute,
2638        global,
2639        describe_command,
2640        describe_buffer,
2641        apropos,
2642        describe_key,
2643        list_keymap,
2644        buffer_next,
2645        buffer_prev,
2646        list_buffers,
2647        buffer_delete,
2648        file_tree,
2649        file_tree_close,
2650        oil,
2651        describe_option,
2652        describe_element,
2653        list_options,
2654        describe_plugin_api,
2655        list_plugin_apis,
2656        export_plugin_api,
2657        list_commands,
2658        describe_plugin,
2659        list_plugins,
2660        describe_events,
2661        describe_event,
2662        // CR.6: diff/hunk ex-commands now registered by lattice_diff::install().
2663        list_modes,
2664        describe_mode,
2665        describe_option_resolution,
2666        customize,
2667        tutor,
2668        tutor_next,
2669        tutor_prev,
2670        hover,
2671        hover_close,
2672        help,
2673        list_diagnostics,
2674        next_diagnostic,
2675        prev_diagnostic,
2676        lsp_log,
2677        messages,
2678        lsp_trace,
2679        lsp_status,
2680        lsp_server_log,
2681        lsp_restart,
2682        lsp_progress_cancel,
2683        lsp_expand_region,
2684        lsp_shrink_region,
2685        lsp_log_level,
2686        lsp_log_clear,
2687        lsp_symbols,
2688        lsp_workspace_symbol,
2689        lsp_incoming_calls,
2690        lsp_outgoing_calls,
2691        lsp_supertypes,
2692        lsp_subtypes,
2693        lsp_moniker,
2694        lsp_code_lens,
2695        lsp_color_presentation,
2696        format,
2697        lsp_format,
2698        lsp_format_range,
2699        lsp_signature_help,
2700        lsp_complete,
2701        lsp_rename,
2702        lsp_code_action,
2703        reload_snippets,
2704        cd,
2705        pwd,
2706        project_root,
2707    }
2708}
2709
2710// ---- parse_args helpers (raw string -> typed Args) ----
2711
2712fn parse_no_args(rest: &str, _bang: bool) -> GrammarResult<Args> {
2713    if rest.trim().is_empty() {
2714        Ok(Args::None)
2715    } else {
2716        Err(CommandError::BadArgs(
2717            "trailing characters after command".into(),
2718        ))
2719    }
2720}
2721
2722/// MB.5: parse `:history [commands|search]`. When no arg is given,
2723/// defaults to `commands` (command-line history). `search` opens the
2724/// search-line history picker (`q/` / `q?`).
2725fn parse_history_args(rest: &str, _bang: bool) -> GrammarResult<Args> {
2726    let trimmed = rest.trim();
2727    if trimmed.is_empty() {
2728        return Ok(Args::None);
2729    }
2730    let lower = trimmed.to_lowercase();
2731    match lower.as_str() {
2732        // PBH.5: the accepted set lives here AND in the `apply` mapping
2733        // above; both must list a kind for it to be reachable. Adding it
2734        // to only one is the failure this arm's test catches.
2735        "commands" | "searches" | "pane-buffers" => Ok(Args::List(vec![ArgValue::String(lower)])),
2736        other => Err(CommandError::BadArgs(format!(
2737            "unknown history kind `{other}`; expected `commands`, `searches`, or `pane-buffers`"
2738        ))),
2739    }
2740}
2741
2742fn parse_optional_path(rest: &str, _bang: bool) -> GrammarResult<Args> {
2743    let trimmed = rest.trim();
2744    if trimmed.is_empty() {
2745        Ok(Args::None)
2746    } else {
2747        Ok(Args::String(trimmed.to_string()))
2748    }
2749}
2750
2751/// `:tabmove [N]` parser (issue #29 slice 3). Optional
2752/// positional integer; missing → `0` (vim's "move to last").
2753/// Stored as `Args::String` because `Args` has no Int variant;
2754/// the apply closure re-parses.
2755fn parse_tabmove_arg(rest: &str, _bang: bool) -> GrammarResult<Args> {
2756    let trimmed = rest.trim();
2757    if trimmed.is_empty() {
2758        return Ok(Args::String("0".to_string()));
2759    }
2760    // Validate it's a non-negative integer before passing on.
2761    trimmed.parse::<u32>().map_err(|e| {
2762        CommandError::BadArgs(format!(":tabmove arg must be a non-negative integer: {e}"))
2763    })?;
2764    Ok(Args::String(trimmed.to_string()))
2765}
2766
2767/// CM.2 (2026-07-22): `:cc [N]` parser. Optional positional
2768/// 1-based integer; missing → `Args::None` (bare `:cc` re-visits
2769/// the current entry). Stored as `Args::String` because `Args` has
2770/// no Int variant; the apply closure re-parses.
2771fn parse_cc_arg(rest: &str, _bang: bool) -> GrammarResult<Args> {
2772    let trimmed = rest.trim();
2773    if trimmed.is_empty() {
2774        return Ok(Args::None);
2775    }
2776    // Validate it's a positive integer before passing on.
2777    trimmed
2778        .parse::<usize>()
2779        .map_err(|e| CommandError::BadArgs(format!(":cc arg must be a positive integer: {e}")))?;
2780    Ok(Args::String(trimmed.to_string()))
2781}
2782
2783/// PI.2: `:describe-plugin-api [<seam>]` -- an optional seam name. Missing
2784/// arg -> `Args::None` (render the full list); present -> `Args::String`.
2785fn parse_optional_string(rest: &str, _bang: bool) -> GrammarResult<Args> {
2786    let trimmed = rest.trim();
2787    if trimmed.is_empty() {
2788        Ok(Args::None)
2789    } else {
2790        Ok(Args::String(trimmed.to_string()))
2791    }
2792}
2793
2794fn apply_describe_plugin_api(ctx: &ExCommandContext) -> GrammarResult<Effect> {
2795    let seam = match &ctx.args {
2796        Args::String(s) => Some(s.clone()),
2797        Args::None => None,
2798        _ => {
2799            return Err(CommandError::BadArgs(
2800                "expected an optional seam name".into(),
2801            ));
2802        }
2803    };
2804    Ok(Effect::DescribePluginApi { seam })
2805}
2806
2807fn apply_export_plugin_api(ctx: &ExCommandContext) -> GrammarResult<Effect> {
2808    let format = match &ctx.args {
2809        Args::String(s) => {
2810            let f = s.trim().to_ascii_lowercase();
2811            // Validate at parse time so a typo echoes a grammar error rather
2812            // than silently defaulting; the host maps `markdown`/`json`.
2813            if f != "markdown" && f != "md" && f != "json" {
2814                return Err(CommandError::BadArgs(format!(
2815                    "unknown format `{s}` (expected `markdown` or `json`)"
2816                )));
2817            }
2818            Some(f)
2819        }
2820        Args::None => None,
2821        _ => return Err(CommandError::BadArgs("expected an optional format".into())),
2822    };
2823    Ok(Effect::ExportPluginApi { format })
2824}
2825
2826fn parse_required_string(rest: &str, _bang: bool) -> GrammarResult<Args> {
2827    let trimmed = rest.trim();
2828    if trimmed.is_empty() {
2829        Err(CommandError::BadArgs("argument required".into()))
2830    } else {
2831        Ok(Args::String(trimmed.to_string()))
2832    }
2833}
2834
2835/// `:picker <source> [args...]` parser. First whitespace-delimited
2836/// token is the source id; the rest pass through as `Raw` values
2837/// the App's source-specific handler re-interprets against the
2838/// resolved [`PickerSourceSpec::args_schema`]. The grammar stays
2839/// agnostic of which sources exist and what args they take --
2840/// validation happens host-side against the registry.
2841fn parse_picker_args(rest: &str, _bang: bool) -> GrammarResult<Args> {
2842    let trimmed = rest.trim();
2843    if trimmed.is_empty() {
2844        return Err(CommandError::BadArgs(
2845            "picker source required (e.g. `:picker files`)".into(),
2846        ));
2847    }
2848    let mut tokens = trimmed.split_whitespace();
2849    let source = tokens
2850        .next()
2851        .expect("non-empty after trim guarantees at least one token");
2852    let mut values: Vec<ArgValue> = Vec::with_capacity(1);
2853    values.push(ArgValue::String(source.to_string()));
2854    for tok in tokens {
2855        values.push(ArgValue::Raw(tok.to_string()));
2856    }
2857    Ok(Args::List(values))
2858}
2859
2860/// `:substitute` and `:global` enter through the `:`-line parser's
2861/// delimiter detection, not through the generic keyword path -- their
2862/// args come pre-parsed as `Args::List`. These stubs guard against a
2863/// caller that registers a keyword alias `:substitute foo`: the parse
2864/// path errors instead of producing malformed Args::List.
2865fn parse_substitute_args_unreachable(_rest: &str, _bang: bool) -> GrammarResult<Args> {
2866    Err(CommandError::BadArgs(
2867        "use the delimiter form: `:s/pattern/replacement/[flags]`".into(),
2868    ))
2869}
2870
2871fn parse_global_args_unreachable(_rest: &str, _bang: bool) -> GrammarResult<Args> {
2872    Err(CommandError::BadArgs(
2873        "use the delimiter form: `:g/pattern/body` (or `:v/...` for inverted)".into(),
2874    ))
2875}
2876
2877// ---- apply closures ----
2878
2879fn apply_write(ctx: &ExCommandContext) -> GrammarResult<Effect> {
2880    let path = match &ctx.args {
2881        Args::None => None,
2882        Args::String(s) => Some(std::path::PathBuf::from(s)),
2883        _ => {
2884            return Err(CommandError::BadArgs(
2885                "expected optional path string".into(),
2886            ));
2887        }
2888    };
2889    Ok(Effect::SaveBuffer { path })
2890}
2891
2892fn apply_quit(ctx: &ExCommandContext) -> GrammarResult<Effect> {
2893    // `:q` is pane-scoped: close the active pane when more than one is
2894    // open; quit the editor only on the last pane. See `QuitScope`.
2895    Ok(Effect::QuitEditor {
2896        force: ctx.bang,
2897        scope: QuitScope::Pane,
2898    })
2899}
2900
2901fn apply_quit_all(ctx: &ExCommandContext) -> GrammarResult<Effect> {
2902    // `:qa` is editor-scoped: ignore pane / tab count and shut the
2903    // editor outright (subject to the same dirty guard as `:q` unless
2904    // forced). Distinct command from `:q`, same `QuitEditor` effect
2905    // with `scope = All`.
2906    Ok(Effect::QuitEditor {
2907        force: ctx.bang,
2908        scope: QuitScope::All,
2909    })
2910}
2911
2912fn apply_write_quit(ctx: &ExCommandContext) -> GrammarResult<Effect> {
2913    // The bang on `:wq!` / `:x!` propagates to the quit step (vim's
2914    // semantics: force the quit even if the save fails). Save itself is
2915    // never forced -- writing a path you don't have permission for fails
2916    // visibly. `:wq` is pane-scoped, mirroring `:q`.
2917    Ok(Effect::Many(vec![
2918        Effect::SaveBuffer { path: None },
2919        Effect::QuitEditor {
2920            force: ctx.bang,
2921            scope: QuitScope::Pane,
2922        },
2923    ]))
2924}
2925
2926fn apply_set(ctx: &ExCommandContext) -> GrammarResult<Effect> {
2927    match &ctx.args {
2928        Args::String(s) => Ok(Effect::SetOption { spec: s.clone() }),
2929        _ => Err(CommandError::BadArgs("expected option string".into())),
2930    }
2931}
2932
2933fn apply_set_local(ctx: &ExCommandContext) -> GrammarResult<Effect> {
2934    match &ctx.args {
2935        Args::String(s) => Ok(Effect::SetLocalOption { spec: s.clone() }),
2936        _ => Err(CommandError::BadArgs("expected option string".into())),
2937    }
2938}
2939
2940fn apply_set_global(ctx: &ExCommandContext) -> GrammarResult<Effect> {
2941    match &ctx.args {
2942        Args::String(s) => Ok(Effect::SetGlobalOption { spec: s.clone() }),
2943        _ => Err(CommandError::BadArgs("expected option string".into())),
2944    }
2945}
2946
2947fn apply_colorscheme(ctx: &ExCommandContext) -> GrammarResult<Effect> {
2948    // T.9.b: `:colorscheme <name>` swaps the active theme directly.
2949    // T.12a: `:colorscheme` with NO name opens the live-preview theme
2950    // picker host-side. We encode the no-arg case as an EMPTY
2951    // `SetColorscheme("")` — the host's `Effect::SetColorscheme` arm
2952    // branches on the empty string and calls `open_picker("colorscheme")`.
2953    match &ctx.args {
2954        Args::String(s) if !s.trim().is_empty() => Ok(Effect::SetColorscheme(s.trim().to_string())),
2955        // Args::None (no-arg) or empty/whitespace string → picker.
2956        _ => Ok(Effect::SetColorscheme(String::new())),
2957    }
2958}
2959
2960fn apply_edit(ctx: &ExCommandContext) -> GrammarResult<Effect> {
2961    let path = match &ctx.args {
2962        Args::None => None,
2963        Args::String(s) => Some(std::path::PathBuf::from(s)),
2964        _ => {
2965            return Err(CommandError::BadArgs(
2966                "expected optional path string".into(),
2967            ));
2968        }
2969    };
2970    Ok(Effect::OpenBuffer {
2971        path,
2972        force: ctx.bang,
2973    })
2974}
2975
2976fn apply_substitute(ctx: &ExCommandContext) -> GrammarResult<Effect> {
2977    let list = ctx
2978        .args
2979        .as_list()
2980        .ok_or_else(|| CommandError::BadArgs("expected Args::List for :substitute".into()))?;
2981    if list.len() != 3 {
2982        return Err(CommandError::BadArgs(
2983            "expected 3 args: pattern, replacement, flags".into(),
2984        ));
2985    }
2986    let pattern = list[0]
2987        .as_str()
2988        .ok_or_else(|| CommandError::BadArgs("arg 0 (pattern) must be string-shaped".into()))?
2989        .to_string();
2990    let replacement = list[1]
2991        .as_str()
2992        .ok_or_else(|| CommandError::BadArgs("arg 1 (replacement) must be string-shaped".into()))?
2993        .to_string();
2994    let flags = list[2]
2995        .as_str()
2996        .ok_or_else(|| CommandError::BadArgs("arg 2 (flags) must be string-shaped".into()))?;
2997    let global = flags.contains('g');
2998    // Scope falls out of the invocation's range: `s/...` -> CurrentLine,
2999    // `%s/...` -> Whole. The parser front-end set this from the
3000    // delimiter prefix.
3001    let scope = match ctx.range {
3002        Some(Range::Whole) => SubstituteScope::Whole,
3003        _ => SubstituteScope::CurrentLine,
3004    };
3005    Ok(Effect::Substitute {
3006        scope,
3007        pattern,
3008        replacement,
3009        global,
3010    })
3011}
3012
3013fn apply_describe_command(ctx: &ExCommandContext) -> GrammarResult<Effect> {
3014    match &ctx.args {
3015        Args::String(s) => Ok(Effect::DescribeCommand {
3016            name: s.clone(),
3017            anchor: None,
3018        }),
3019        _ => Err(CommandError::BadArgs("expected command name string".into())),
3020    }
3021}
3022
3023fn apply_apropos(ctx: &ExCommandContext) -> GrammarResult<Effect> {
3024    match &ctx.args {
3025        Args::String(s) => Ok(Effect::Apropos { pattern: s.clone() }),
3026        _ => Err(CommandError::BadArgs("expected pattern string".into())),
3027    }
3028}
3029
3030fn apply_describe_key(ctx: &ExCommandContext) -> GrammarResult<Effect> {
3031    match &ctx.args {
3032        Args::String(s) => Ok(Effect::DescribeKey { chord: s.clone() }),
3033        _ => Err(CommandError::BadArgs(
3034            "expected chord notation string".into(),
3035        )),
3036    }
3037}
3038
3039fn apply_global(ctx: &ExCommandContext) -> GrammarResult<Effect> {
3040    let list = ctx
3041        .args
3042        .as_list()
3043        .ok_or_else(|| CommandError::BadArgs("expected Args::List for :global".into()))?;
3044    if list.len() != 3 {
3045        return Err(CommandError::BadArgs(
3046            "expected 3 args: pattern, inverted, body".into(),
3047        ));
3048    }
3049    let pattern = list[0]
3050        .as_str()
3051        .ok_or_else(|| CommandError::BadArgs("arg 0 (pattern) must be string-shaped".into()))?
3052        .to_string();
3053    let inverted = list[1]
3054        .as_bool()
3055        .ok_or_else(|| CommandError::BadArgs("arg 1 (inverted) must be bool".into()))?;
3056    let body = list[2]
3057        .as_invocation()
3058        .ok_or_else(|| {
3059            CommandError::BadArgs(
3060                "arg 2 (body) must be a parsed Invocation -- parser front-end is responsible"
3061                    .into(),
3062            )
3063        })?
3064        .clone();
3065    Ok(Effect::Global {
3066        pattern,
3067        inverted,
3068        body: Box::new(body),
3069    })
3070}
3071
3072#[cfg(test)]
3073mod tests {
3074    #![allow(clippy::unwrap_used, clippy::panic)]
3075    use super::*;
3076    use crate::CancellationToken;
3077    use crate::command::CommandInvocation;
3078    use crate::dispatcher::execute;
3079    use lattice_core::Document;
3080    use lattice_protocol::position::Position;
3081
3082    fn fixture() -> (CommandRegistry, ExBuiltins, Document) {
3083        let mut registry = CommandRegistry::new();
3084        let _ = crate::builtins::populate(&mut registry);
3085        let ex = populate(&mut registry);
3086        (registry, ex, Document::empty())
3087    }
3088
3089    #[test]
3090    fn write_with_no_path_emits_save_buffer_none() {
3091        let (registry, ex, mut doc) = fixture();
3092        let inv = CommandInvocation::of(ex.write.0).with_args(Args::None);
3093        let eff = execute(
3094            &registry,
3095            &mut doc,
3096            lattice_core::BufferId(0),
3097            Position::ZERO,
3098            inv,
3099            &CancellationToken::never(),
3100        )
3101        .unwrap();
3102        match eff {
3103            Effect::SaveBuffer { path } => assert!(path.is_none()),
3104            other => panic!("unexpected effect: {other:?}"),
3105        }
3106    }
3107
3108    #[test]
3109    fn write_with_path_carries_path() {
3110        let (registry, ex, mut doc) = fixture();
3111        let inv = CommandInvocation::of(ex.write.0).with_args(Args::String("foo.txt".into()));
3112        let eff = execute(
3113            &registry,
3114            &mut doc,
3115            lattice_core::BufferId(0),
3116            Position::ZERO,
3117            inv,
3118            &CancellationToken::never(),
3119        )
3120        .unwrap();
3121        match eff {
3122            Effect::SaveBuffer { path: Some(p) } => {
3123                assert_eq!(p, std::path::PathBuf::from("foo.txt"))
3124            }
3125            other => panic!("unexpected effect: {other:?}"),
3126        }
3127    }
3128
3129    #[test]
3130    fn quit_bang_propagates_to_force() {
3131        let (registry, ex, mut doc) = fixture();
3132        let inv = CommandInvocation::of(ex.quit.0).with_bang(true);
3133        let eff = execute(
3134            &registry,
3135            &mut doc,
3136            lattice_core::BufferId(0),
3137            Position::ZERO,
3138            inv,
3139            &CancellationToken::never(),
3140        )
3141        .unwrap();
3142        match eff {
3143            Effect::QuitEditor { force, scope } => {
3144                assert!(force);
3145                assert_eq!(scope, QuitScope::Pane, ":q is pane-scoped");
3146            }
3147            other => panic!("unexpected effect: {other:?}"),
3148        }
3149    }
3150
3151    #[test]
3152    fn quit_no_bang_is_not_forced() {
3153        let (registry, ex, mut doc) = fixture();
3154        let inv = CommandInvocation::of(ex.quit.0);
3155        let eff = execute(
3156            &registry,
3157            &mut doc,
3158            lattice_core::BufferId(0),
3159            Position::ZERO,
3160            inv,
3161            &CancellationToken::never(),
3162        )
3163        .unwrap();
3164        match eff {
3165            Effect::QuitEditor { force, scope } => {
3166                assert!(!force);
3167                assert_eq!(scope, QuitScope::Pane, ":q is pane-scoped");
3168            }
3169            other => panic!("unexpected effect: {other:?}"),
3170        }
3171    }
3172
3173    #[test]
3174    fn describe_active_modes_is_registered_and_takes_no_args() {
3175        // DAM.1: `:describe-active-modes` resolves by name (no
3176        // `ExCommands` field) and yields `Effect::DescribeActiveModes`
3177        // with no argument — the whole point is that it reads the
3178        // active buffer rather than prompting, which is what
3179        // `<C-h>m` did wrong for two months.
3180        let (registry, _ex, mut doc) = fixture();
3181        let id = registry
3182            .id_by_name("ex:describe-active-modes")
3183            .expect("ex:describe-active-modes must be registered");
3184        let eff = execute(
3185            &registry,
3186            &mut doc,
3187            lattice_core::BufferId(0),
3188            Position::ZERO,
3189            CommandInvocation::of(id),
3190            &CancellationToken::never(),
3191        )
3192        .unwrap();
3193        assert!(matches!(eff, Effect::DescribeActiveModes), "got {eff:?}");
3194    }
3195
3196    #[test]
3197    fn describe_active_modes_has_an_empty_args_schema() {
3198        // Guards the interactive-arg-spec path: a non-empty
3199        // `args_schema` with a Required default is exactly what makes
3200        // `:describe-mode` prompt. This command must never prompt.
3201        let (registry, _ex, _doc) = fixture();
3202        let spec = registry
3203            .lookup_by_name("ex:describe-active-modes")
3204            .expect("registered");
3205        assert!(
3206            spec.args_schema.is_empty(),
3207            "an arg schema would re-arm the prompt this command exists to remove: {:?}",
3208            spec.args_schema,
3209        );
3210    }
3211
3212    #[test]
3213    fn describe_active_modes_does_not_shadow_describe_mode() {
3214        // DAM.1 naming lock-in. `:describe-modes` would have been a
3215        // prefix-sibling of `:describe-mode`, pushing
3216        // `:describe-mode<Tab>` off the single-candidate completion
3217        // branch that
3218        // `tab_on_complete_command_name_steps_into_the_arg_slot`
3219        // (lattice-ui-tui) exists to guard. Assert no registered
3220        // ex-command name has `ex:describe-mode` as a strict prefix.
3221        let (registry, _ex, _doc) = fixture();
3222        assert!(
3223            registry.id_by_name("ex:describe-mode").is_some(),
3224            "ex:describe-mode must still be registered and untouched",
3225        );
3226        assert!(
3227            registry.id_by_name("ex:describe-modes").is_none(),
3228            "`:describe-modes` must not exist — it would shadow `:describe-mode` completion",
3229        );
3230    }
3231
3232    #[test]
3233    fn describe_mode_still_requires_its_argument() {
3234        // DAM.1 is additive: `:describe-mode` keeps the required arg
3235        // (and therefore the `gen:modes` prompt) so `<C-h>M` can
3236        // preserve the browse-any-mode path.
3237        assert!(matches!(
3238            parse_required_string("", false),
3239            Err(CommandError::BadArgs(_))
3240        ));
3241        let (registry, _ex, _doc) = fixture();
3242        let spec = registry
3243            .lookup_by_name("ex:describe-mode")
3244            .expect("registered");
3245        assert_eq!(spec.args_schema.len(), 1, "describe-mode keeps its one arg");
3246    }
3247
3248    #[test]
3249    fn quit_all_is_editor_scoped() {
3250        // `:qa` is a distinct command from `:q` but the same effect:
3251        // `QuitEditor` with `scope = All`. `:qa!` forces past the dirty
3252        // guard. Resolved by name (no `ExBuiltins` field, like `:tabonly`).
3253        let (registry, _ex, mut doc) = fixture();
3254        let id = registry
3255            .id_by_name("ex:quit-all")
3256            .expect("ex:quit-all must be registered");
3257        for (bang, want_force) in [(false, false), (true, true)] {
3258            let mut inv = CommandInvocation::of(id);
3259            if bang {
3260                inv = inv.with_bang(true);
3261            }
3262            let eff = execute(
3263                &registry,
3264                &mut doc,
3265                lattice_core::BufferId(0),
3266                Position::ZERO,
3267                inv,
3268                &CancellationToken::never(),
3269            )
3270            .unwrap();
3271            match eff {
3272                Effect::QuitEditor { force, scope } => {
3273                    assert_eq!(force, want_force);
3274                    assert_eq!(scope, QuitScope::All, ":qa is editor-scoped");
3275                }
3276                other => panic!("unexpected effect: {other:?}"),
3277            }
3278        }
3279    }
3280
3281    #[test]
3282    fn write_quit_emits_many_save_then_quit() {
3283        let (registry, ex, mut doc) = fixture();
3284        let inv = CommandInvocation::of(ex.write_quit.0).with_bang(true);
3285        let eff = execute(
3286            &registry,
3287            &mut doc,
3288            lattice_core::BufferId(0),
3289            Position::ZERO,
3290            inv,
3291            &CancellationToken::never(),
3292        )
3293        .unwrap();
3294        match eff {
3295            Effect::Many(parts) => {
3296                assert!(matches!(parts[0], Effect::SaveBuffer { .. }));
3297                assert!(matches!(
3298                    parts[1],
3299                    Effect::QuitEditor {
3300                        force: true,
3301                        scope: QuitScope::Pane
3302                    }
3303                ));
3304            }
3305            other => panic!("unexpected effect: {other:?}"),
3306        }
3307    }
3308
3309    #[test]
3310    fn pane_split_and_close_commands_emit_app_effects() {
3311        // `:split` / `:vsplit` / `:close` are pane ops (like `:only`):
3312        // each emits the matching `AppEffect` carrier. Resolved by name
3313        // (no `ExBuiltins` field, mirroring `:tabonly`).
3314        let (registry, _ex, mut doc) = fixture();
3315        for (name, want) in [
3316            ("ex:split", AppEffect::SplitPaneHorizontal),
3317            ("ex:vsplit", AppEffect::SplitPaneVertical),
3318            ("ex:close", AppEffect::ClosePane),
3319            // ZP.2: `:zoom-pane` is the same shape — a pane op that
3320            // emits its carrier, not an editing command.
3321            ("ex:zoom-pane", AppEffect::ToggleZoomPane),
3322        ] {
3323            let id = registry
3324                .id_by_name(name)
3325                .unwrap_or_else(|| panic!("{name} must be registered"));
3326            let eff = execute(
3327                &registry,
3328                &mut doc,
3329                lattice_core::BufferId(0),
3330                Position::ZERO,
3331                CommandInvocation::of(id),
3332                &CancellationToken::never(),
3333            )
3334            .unwrap();
3335            match eff {
3336                Effect::AppAction(got) => assert_eq!(got, want, "{name}"),
3337                other => panic!("{name}: unexpected effect: {other:?}"),
3338            }
3339        }
3340    }
3341
3342    #[test]
3343    fn nohlsearch_emits_clear_search_highlight() {
3344        let (registry, ex, mut doc) = fixture();
3345        let inv = CommandInvocation::of(ex.no_hlsearch.0);
3346        let eff = execute(
3347            &registry,
3348            &mut doc,
3349            lattice_core::BufferId(0),
3350            Position::ZERO,
3351            inv,
3352            &CancellationToken::never(),
3353        )
3354        .unwrap();
3355        assert!(matches!(eff, Effect::ClearSearchHighlight));
3356    }
3357
3358    #[test]
3359    fn registers_emits_echo_registers() {
3360        let (registry, ex, mut doc) = fixture();
3361        let inv = CommandInvocation::of(ex.list_registers.0);
3362        let eff = execute(
3363            &registry,
3364            &mut doc,
3365            lattice_core::BufferId(0),
3366            Position::ZERO,
3367            inv,
3368            &CancellationToken::never(),
3369        )
3370        .unwrap();
3371        assert!(matches!(eff, Effect::EchoRegisters));
3372    }
3373
3374    #[test]
3375    fn marks_emits_echo_marks() {
3376        let (registry, ex, mut doc) = fixture();
3377        let inv = CommandInvocation::of(ex.list_marks.0);
3378        let eff = execute(
3379            &registry,
3380            &mut doc,
3381            lattice_core::BufferId(0),
3382            Position::ZERO,
3383            inv,
3384            &CancellationToken::never(),
3385        )
3386        .unwrap();
3387        assert!(matches!(eff, Effect::EchoMarks));
3388    }
3389
3390    #[test]
3391    fn delete_emits_delete_current_line() {
3392        let (registry, ex, mut doc) = fixture();
3393        let inv = CommandInvocation::of(ex.delete_line.0);
3394        let eff = execute(
3395            &registry,
3396            &mut doc,
3397            lattice_core::BufferId(0),
3398            Position::ZERO,
3399            inv,
3400            &CancellationToken::never(),
3401        )
3402        .unwrap();
3403        assert!(matches!(eff, Effect::DeleteCurrentLine));
3404    }
3405
3406    #[test]
3407    fn describe_command_emits_describe_command_effect() {
3408        let (registry, ex, mut doc) = fixture();
3409        let inv =
3410            CommandInvocation::of(ex.describe_command.0).with_args(Args::String("ex:write".into()));
3411        let eff = execute(
3412            &registry,
3413            &mut doc,
3414            lattice_core::BufferId(0),
3415            Position::ZERO,
3416            inv,
3417            &CancellationToken::never(),
3418        )
3419        .unwrap();
3420        match eff {
3421            Effect::DescribeCommand { name, anchor } => {
3422                assert_eq!(name, "ex:write");
3423                assert!(anchor.is_none());
3424            }
3425            other => panic!("unexpected effect: {other:?}"),
3426        }
3427    }
3428
3429    #[test]
3430    fn describe_plugin_api_emits_effect_with_and_without_seam() {
3431        // With a seam name -> Some.
3432        let (registry, ex, mut doc) = fixture();
3433        let inv = CommandInvocation::of(ex.describe_plugin_api.0)
3434            .with_args(Args::String("host-services".into()));
3435        let eff = execute(
3436            &registry,
3437            &mut doc,
3438            lattice_core::BufferId(0),
3439            Position::ZERO,
3440            inv,
3441            &CancellationToken::never(),
3442        )
3443        .unwrap();
3444        match eff {
3445            Effect::DescribePluginApi { seam } => {
3446                assert_eq!(seam.as_deref(), Some("host-services"))
3447            }
3448            other => panic!("unexpected effect: {other:?}"),
3449        }
3450        // No arg -> None (renders the full list).
3451        let inv = CommandInvocation::of(ex.describe_plugin_api.0);
3452        let eff = execute(
3453            &registry,
3454            &mut doc,
3455            lattice_core::BufferId(0),
3456            Position::ZERO,
3457            inv,
3458            &CancellationToken::never(),
3459        )
3460        .unwrap();
3461        match eff {
3462            Effect::DescribePluginApi { seam } => assert!(seam.is_none()),
3463            other => panic!("unexpected effect: {other:?}"),
3464        }
3465    }
3466
3467    #[test]
3468    fn list_plugin_apis_emits_effect() {
3469        let (registry, ex, mut doc) = fixture();
3470        let inv = CommandInvocation::of(ex.list_plugin_apis.0);
3471        let eff = execute(
3472            &registry,
3473            &mut doc,
3474            lattice_core::BufferId(0),
3475            Position::ZERO,
3476            inv,
3477            &CancellationToken::never(),
3478        )
3479        .unwrap();
3480        assert!(matches!(eff, Effect::ListPluginApis));
3481    }
3482
3483    #[test]
3484    fn export_plugin_api_validates_format_and_emits_effect() {
3485        let (registry, ex, mut doc) = fixture();
3486        let run = |registry: &_, doc: &mut _, args: Args| {
3487            execute(
3488                registry,
3489                doc,
3490                lattice_core::BufferId(0),
3491                Position::ZERO,
3492                CommandInvocation::of(ex.export_plugin_api.0).with_args(args),
3493                &CancellationToken::never(),
3494            )
3495        };
3496        // json -> Some("json"); no arg -> None (markdown default).
3497        match run(&registry, &mut doc, Args::String("json".into())).unwrap() {
3498            Effect::ExportPluginApi { format } => assert_eq!(format.as_deref(), Some("json")),
3499            other => panic!("unexpected: {other:?}"),
3500        }
3501        match run(&registry, &mut doc, Args::None).unwrap() {
3502            Effect::ExportPluginApi { format } => assert!(format.is_none()),
3503            other => panic!("unexpected: {other:?}"),
3504        }
3505        // An unknown format is a parse-time error (not a silent default).
3506        assert!(run(&registry, &mut doc, Args::String("yaml".into())).is_err());
3507    }
3508
3509    #[test]
3510    fn list_commands_emits_effect() {
3511        let (registry, ex, mut doc) = fixture();
3512        let eff = execute(
3513            &registry,
3514            &mut doc,
3515            lattice_core::BufferId(0),
3516            Position::ZERO,
3517            CommandInvocation::of(ex.list_commands.0),
3518            &CancellationToken::never(),
3519        )
3520        .unwrap();
3521        assert!(matches!(eff, Effect::ListCommands));
3522    }
3523
3524    #[test]
3525    fn describe_plugin_and_list_plugins_emit_effects() {
3526        let (registry, ex, mut doc) = fixture();
3527        let run = |registry: &_, doc: &mut _, id, args: Args| {
3528            execute(
3529                registry,
3530                doc,
3531                lattice_core::BufferId(0),
3532                Position::ZERO,
3533                CommandInvocation::of(id).with_args(args),
3534                &CancellationToken::never(),
3535            )
3536        };
3537        match run(
3538            &registry,
3539            &mut doc,
3540            ex.describe_plugin.0,
3541            Args::String("git-gutter".into()),
3542        )
3543        .unwrap()
3544        {
3545            Effect::DescribePlugin { name } => assert_eq!(name, "git-gutter"),
3546            other => panic!("unexpected: {other:?}"),
3547        }
3548        // A name is required.
3549        assert!(run(&registry, &mut doc, ex.describe_plugin.0, Args::None).is_err());
3550        assert!(matches!(
3551            run(&registry, &mut doc, ex.list_plugins.0, Args::None).unwrap(),
3552            Effect::ListPlugins
3553        ));
3554    }
3555
3556    /// Regression guard (plugin-introspection tie-together): a plugin command
3557    /// (PH7.7 `register_plugin_ex_command`) lands in the SAME store
3558    /// `registry.names()` iterates — the one `gen:commands` completion,
3559    /// `:list-commands`, `:describe-command`, and `:apropos` all read live —
3560    /// carrying `Plugin(id)` provenance so `:list-commands` groups it and PI.3
3561    /// resolves its name. If this breaks, plugin commands silently vanish from
3562    /// every introspection/completion surface.
3563    #[test]
3564    fn plugin_command_lands_in_the_store_read_by_completion_and_introspection() {
3565        let mut registry = CommandRegistry::new();
3566        let spec = ExCommandSpec {
3567            latency_class: LatencyClass::Display,
3568            accepts_bang: false,
3569            accepts_range: false,
3570            parse_args: Arc::new(parse_no_args),
3571            apply: Arc::new(|_| Ok(Effect::None)),
3572            args_schema: vec![],
3573            surface_form: SurfaceForm::Keyword,
3574        };
3575        registry.register_plugin_ex_command(7, "plugin-cmd", "a plugin command", spec);
3576        // Visible to the live enumeration every introspection/completion consumer walks.
3577        assert!(
3578            registry.names().any(|n| n == "plugin-cmd"),
3579            "plugin command must be in the store `names()` iterates"
3580        );
3581        let spec = registry
3582            .lookup_by_name("plugin-cmd")
3583            .expect("plugin command is looked up like any other");
3584        assert!(
3585            matches!(spec.source.layer, crate::source::SourceLayer::Plugin(7)),
3586            "plugin command carries Plugin(id) provenance"
3587        );
3588        assert_eq!(spec.doc, "a plugin command");
3589    }
3590
3591    #[test]
3592    fn describe_buffer_emits_describe_buffer_effect() {
3593        let (registry, ex, mut doc) = fixture();
3594        let inv = CommandInvocation::of(ex.describe_buffer.0);
3595        let eff = execute(
3596            &registry,
3597            &mut doc,
3598            lattice_core::BufferId(0),
3599            Position::ZERO,
3600            inv,
3601            &CancellationToken::never(),
3602        )
3603        .unwrap();
3604        assert!(matches!(eff, Effect::DescribeBuffer));
3605    }
3606
3607    #[test]
3608    fn apropos_emits_apropos_effect() {
3609        let (registry, ex, mut doc) = fixture();
3610        let inv = CommandInvocation::of(ex.apropos.0).with_args(Args::String("write".into()));
3611        let eff = execute(
3612            &registry,
3613            &mut doc,
3614            lattice_core::BufferId(0),
3615            Position::ZERO,
3616            inv,
3617            &CancellationToken::never(),
3618        )
3619        .unwrap();
3620        match eff {
3621            Effect::Apropos { pattern } => assert_eq!(pattern, "write"),
3622            other => panic!("unexpected effect: {other:?}"),
3623        }
3624    }
3625
3626    #[test]
3627    fn describe_command_advertises_args_schema() {
3628        // §B.1 metadata is what makes :describe-command interesting.
3629        let (registry, ex, _doc) = fixture();
3630        let spec = registry.lookup(ex.describe_command.0).unwrap();
3631        assert_eq!(spec.args_schema.len(), 1);
3632        assert_eq!(spec.args_schema[0].name, "name");
3633    }
3634
3635    #[test]
3636    fn apropos_advertises_args_schema() {
3637        let (registry, ex, _doc) = fixture();
3638        let spec = registry.lookup(ex.apropos.0).unwrap();
3639        assert_eq!(spec.args_schema.len(), 1);
3640        assert_eq!(spec.args_schema[0].name, "pattern");
3641    }
3642
3643    #[test]
3644    fn set_with_string_arg_carries_spec() {
3645        let (registry, ex, mut doc) = fixture();
3646        let inv = CommandInvocation::of(ex.set_option.0).with_args(Args::String("number".into()));
3647        let eff = execute(
3648            &registry,
3649            &mut doc,
3650            lattice_core::BufferId(0),
3651            Position::ZERO,
3652            inv,
3653            &CancellationToken::never(),
3654        )
3655        .unwrap();
3656        match eff {
3657            Effect::SetOption { spec } => assert_eq!(spec, "number"),
3658            other => panic!("unexpected effect: {other:?}"),
3659        }
3660    }
3661
3662    #[test]
3663    fn set_with_no_args_errors() {
3664        let (registry, ex, mut doc) = fixture();
3665        // The dispatcher itself doesn't error here -- parse_args is called
3666        // by the parser front-end, not the dispatcher. apply with the
3667        // wrong Args variant errors instead.
3668        let inv = CommandInvocation::of(ex.set_option.0).with_args(Args::None);
3669        let err = execute(
3670            &registry,
3671            &mut doc,
3672            lattice_core::BufferId(0),
3673            Position::ZERO,
3674            inv,
3675            &CancellationToken::never(),
3676        )
3677        .unwrap_err();
3678        assert!(matches!(err, CommandError::BadArgs(_)));
3679    }
3680
3681    #[test]
3682    fn edit_with_path_and_bang_carries_force() {
3683        let (registry, ex, mut doc) = fixture();
3684        let inv = CommandInvocation::of(ex.edit.0)
3685            .with_args(Args::String("/tmp/x".into()))
3686            .with_bang(true);
3687        let eff = execute(
3688            &registry,
3689            &mut doc,
3690            lattice_core::BufferId(0),
3691            Position::ZERO,
3692            inv,
3693            &CancellationToken::never(),
3694        )
3695        .unwrap();
3696        match eff {
3697            Effect::OpenBuffer { path, force } => {
3698                assert_eq!(path, Some(std::path::PathBuf::from("/tmp/x")));
3699                assert!(force);
3700            }
3701            other => panic!("unexpected effect: {other:?}"),
3702        }
3703    }
3704
3705    #[test]
3706    fn parse_no_args_rejects_trailing() {
3707        assert!(matches!(
3708            parse_no_args("oops", false),
3709            Err(CommandError::BadArgs(_))
3710        ));
3711        assert!(matches!(parse_no_args("", false), Ok(Args::None)));
3712        assert!(matches!(parse_no_args("   ", false), Ok(Args::None)));
3713    }
3714
3715    #[test]
3716    fn parse_optional_path_returns_some_or_none() {
3717        assert!(matches!(parse_optional_path("", false), Ok(Args::None)));
3718        match parse_optional_path("foo.rs", false).unwrap() {
3719            Args::String(s) => assert_eq!(s, "foo.rs"),
3720            other => panic!("unexpected args: {other:?}"),
3721        }
3722    }
3723
3724    #[test]
3725    fn parse_picker_args_splits_source_then_raw_tokens() {
3726        // Empty input rejects -- source is required.
3727        assert!(matches!(
3728            parse_picker_args("", false),
3729            Err(CommandError::BadArgs(_))
3730        ));
3731        assert!(matches!(
3732            parse_picker_args("   ", false),
3733            Err(CommandError::BadArgs(_))
3734        ));
3735        // Just a source id -> single-element list with String source.
3736        match parse_picker_args("files", false).unwrap() {
3737            Args::List(v) => {
3738                assert_eq!(v.len(), 1);
3739                assert_eq!(v[0].as_str(), Some("files"));
3740                assert!(matches!(v[0], ArgValue::String(_)));
3741            }
3742            other => panic!("unexpected args: {other:?}"),
3743        }
3744        // Source + rest -> first is String source, rest are Raw tokens
3745        // the App handler will re-interpret per source-specific shape.
3746        match parse_picker_args("files  /tmp/a  /tmp/b", false).unwrap() {
3747            Args::List(v) => {
3748                assert_eq!(v.len(), 3);
3749                assert_eq!(v[0].as_str(), Some("files"));
3750                assert!(matches!(v[0], ArgValue::String(_)));
3751                assert_eq!(v[1].as_str(), Some("/tmp/a"));
3752                assert!(matches!(v[1], ArgValue::Raw(_)));
3753                assert_eq!(v[2].as_str(), Some("/tmp/b"));
3754                assert!(matches!(v[2], ArgValue::Raw(_)));
3755            }
3756            other => panic!("unexpected args: {other:?}"),
3757        }
3758    }
3759
3760    #[test]
3761    fn picker_command_applies_into_open_picker_effect() {
3762        let (registry, _ex, mut doc) = fixture();
3763        let picker_id = registry
3764            .id_by_name("ex:picker")
3765            .expect("ex:picker must be registered");
3766        let args = parse_picker_args("files /tmp/a", false).unwrap();
3767        let inv = CommandInvocation::of(picker_id).with_args(args);
3768        let eff = execute(
3769            &registry,
3770            &mut doc,
3771            lattice_core::BufferId(0),
3772            Position::ZERO,
3773            inv,
3774            &CancellationToken::never(),
3775        )
3776        .unwrap();
3777        match eff {
3778            Effect::OpenPicker { source, args, .. } => {
3779                assert_eq!(source, "files");
3780                assert_eq!(args, vec!["/tmp/a".to_string()]);
3781            }
3782            other => panic!("expected Effect::OpenPicker, got {other:?}"),
3783        }
3784    }
3785
3786    #[test]
3787    fn parse_required_string_demands_arg() {
3788        assert!(matches!(
3789            parse_required_string("", false),
3790            Err(CommandError::BadArgs(_))
3791        ));
3792        match parse_required_string("number", false).unwrap() {
3793            Args::String(s) => assert_eq!(s, "number"),
3794            other => panic!("unexpected args: {other:?}"),
3795        }
3796    }
3797
3798    #[test]
3799    fn option_describing_commands_complete_against_gen_options() {
3800        // `:describe-option NAME` and `:describe-option-resolution
3801        // NAME` both consume a typed-option name; both must offer
3802        // the same completion source so the user gets the same
3803        // candidates after `<Tab>` regardless of which command
3804        // they typed.
3805        let (registry, ex, _) = fixture();
3806        for id in [ex.describe_option, ex.describe_option_resolution] {
3807            let spec = registry.ex_command_spec(id.0).unwrap();
3808            assert_eq!(spec.args_schema.len(), 1);
3809            assert_eq!(
3810                spec.args_schema[0].completion.as_deref(),
3811                Some("gen:options")
3812            );
3813        }
3814    }
3815
3816    #[test]
3817    fn introspection_commands_advertise_completion_sources() {
3818        // Every `:describe-*` / `:customize` / `:lsp-*` command
3819        // that consumes a registry-like arg must advertise a
3820        // completion source so `<Tab>` returns candidates. Source
3821        // names are stable; the boot path registers a generator
3822        // per name in `lattice-ui-tui::host_generators`.
3823        let (registry, ex, _) = fixture();
3824        let cases: &[(crate::ExCommandId, &str)] = &[
3825            (ex.describe_command, "gen:commands"),
3826            (ex.describe_event, "gen:events"),
3827            (ex.describe_mode, "gen:modes"),
3828            (ex.describe_option, "gen:options"),
3829            (ex.describe_option_resolution, "gen:options"),
3830            (ex.customize, "gen:customize"),
3831        ];
3832        for (id, expected) in cases {
3833            let cmd = registry.lookup(id.0).unwrap();
3834            let spec = registry.ex_command_spec(id.0).unwrap();
3835            assert_eq!(
3836                spec.args_schema[0].completion.as_deref(),
3837                Some(*expected),
3838                "{} should complete against {expected}",
3839                cmd.name
3840            );
3841        }
3842    }
3843
3844    #[test]
3845    fn lsp_admin_commands_complete_against_server_ids() {
3846        // `:lsp-log`, `:lsp-trace`, `:lsp-trace-log`,
3847        // `:lsp-restart`, `:lsp-log-clear` all take a server-id
3848        // argument; each must offer `gen:lsp-servers` so `<Tab>`
3849        // surfaces the currently-running set.
3850        let (registry, ex, _) = fixture();
3851        let cases: &[crate::ExCommandId] =
3852            &[ex.lsp_log, ex.lsp_trace, ex.lsp_restart, ex.lsp_log_clear];
3853        for id in cases {
3854            let cmd = registry.lookup(id.0).unwrap();
3855            let spec = registry.ex_command_spec(id.0).unwrap();
3856            assert_eq!(
3857                spec.args_schema[0].completion.as_deref(),
3858                Some("gen:lsp-servers"),
3859                "{} should complete against gen:lsp-servers",
3860                cmd.name
3861            );
3862        }
3863        // `:lsp-log-level` completes against the level palette
3864        // (subsystem-wide common form). The two-token
3865        // `<server> <level>` form parses correctly at submit; only
3866        // the first token gets candidates today.
3867        let cmd = registry.lookup(ex.lsp_log_level.0).unwrap();
3868        let spec = registry.ex_command_spec(ex.lsp_log_level.0).unwrap();
3869        assert_eq!(
3870            spec.args_schema[0].completion.as_deref(),
3871            Some("gen:log-levels"),
3872            "{} should complete against gen:log-levels",
3873            cmd.name
3874        );
3875    }
3876}