Skip to main content

lattice_host/
excommand.rs

1//! Phase 2->3 ex-command parser.
2//!
3//! Every `:`-line input becomes a [`CommandInvocation`] dispatched
4//! through `lattice_grammar::execute()` (DESIGN.md §5.2.1). Two parse
5//! shapes feed the same dispatcher:
6//!
7//! - **Keyword form** (`:w foo.txt`, `:q!`, `:set number`): split off
8//!   the command word + optional bang, look up by alias in the
9//!   registry, call the spec's `parse_args(rest, bang)` to get typed
10//!   `Args`, build a `CommandInvocation`.
11//! - **Delimiter form** (`:s/.../.../`, `:%s/.../.../`, `:g/.../.../`,
12//!   `:v/.../.../`): the delimiter syntax doesn't fit the keyword
13//!   parse, so the front-end parses the body itself and produces an
14//!   `Args::List([pattern, replacement, flags])` for `:substitute` or
15//!   `Args::List([pattern, inverted, body])` for `:global` (DESIGN.md
16//!   §B.1, §B.2). The same dispatcher then resolves the registered
17//!   command id and runs the matching apply closure.
18
19use std::collections::HashMap;
20
21use thiserror::Error;
22
23use lattice_grammar::registry::{CommandRegistry, MotionId, TextObjectId};
24use lattice_grammar::{ArgValue, Args, CommandInvocation, CommandKind, Range, Target};
25
26#[derive(Debug, Error, PartialEq, Eq)]
27pub enum ExCommandError {
28    #[error("empty command")]
29    Empty,
30    #[error("unknown command: {0}")]
31    Unknown(String),
32    #[error("trailing characters after command")]
33    TrailingArgs,
34    #[error("malformed substitute: {0}")]
35    BadSubstitute(&'static str),
36    #[error("invalid args: {0}")]
37    BadArgs(String),
38    #[error("`!` is not allowed for `{0}`")]
39    BangNotAllowed(String),
40    /// User typed the keyword form (`:ex:global`) of a command whose
41    /// surface form is `Delimiter`. `name` is the canonical command
42    /// name, `hint` is the syntax to use instead.
43    #[error("`{name}` uses delimiter syntax; type `{hint}`")]
44    WrongSurfaceForm {
45        name: String,
46        // PL8.F: `SurfaceForm::Delimiter.hint` became `Cow<'static, str>` (a
47        // plugin delimiter command's hint is owned + frees on unregister), so
48        // this carried field can't stay `&'static str`.
49        hint: std::borrow::Cow<'static, str>,
50    },
51}
52
53/// Parse a `:` line into a [`CommandInvocation`] dispatchable through
54/// the unified `grammar::execute()`.
55pub fn parse(line: &str, registry: &CommandRegistry) -> Result<CommandInvocation, ExCommandError> {
56    let trimmed = line.trim();
57    if trimmed.is_empty() {
58        return Err(ExCommandError::Empty);
59    }
60
61    // Vim's Visual `:` prefills the cmdline with `'<,'>` (the visual
62    // range). Strip that prefix and mark the resulting invocation
63    // `Range::Selection`, which resolves from `last_visual` (captured when
64    // `:` left Visual — `resolve_grammar_range` + the narrow handler read
65    // it). Lets `:'<,'>narrow` and other range-honoring commands act on
66    // the selection. (General `%` / `1,5` line-range prefixes are a
67    // separate enhancement; substitute keeps its own scope model.)
68    if let Some(rest) = trimmed.strip_prefix("'<,'>") {
69        let inner = parse(rest, registry)?;
70        return Ok(inner.with_range(Range::Selection));
71    }
72
73    // Bare line number: `:42` → go to line 42 (same as `42G`).
74    // Checked before keyword parse; pure digits are not valid command
75    // names so there's no ambiguity. `:0` is treated as `:1`.
76    if let Ok(n) = trimmed.parse::<u32>() {
77        let id = registry
78            .id_by_name("motion:goto-last-line")
79            .ok_or_else(|| ExCommandError::Unknown(trimmed.to_string()))?;
80        return Ok(lattice_grammar::CommandInvocation::of(id)
81            .with_count(lattice_grammar::command::Count(n.max(1))));
82    }
83
84    // Delimiter-syntax routes through the registry too -- the front-end
85    // parses the body into Args::List.
86    if let Some(inv) = try_parse_substitute(trimmed, registry)? {
87        return Ok(inv);
88    }
89    if let Some(inv) = try_parse_global(trimmed, registry)? {
90        return Ok(inv);
91    }
92
93    parse_invocation(trimmed, registry)
94}
95
96/// Parse the keyword form (`:cmd[!] [args]`) into a registry-bound
97/// `CommandInvocation`. The caller has already filtered out the
98/// delimiter-syntax cases.
99fn parse_invocation(
100    trimmed: &str,
101    registry: &CommandRegistry,
102) -> Result<CommandInvocation, ExCommandError> {
103    // Split into command word and rest. The command word may end in `!`;
104    // we strip it here and surface it as the bang bit.
105    let (raw_cmd, rest) = match trimmed.find(char::is_whitespace) {
106        Some(i) => (&trimmed[..i], trimmed[i..].trim()),
107        None => (trimmed, ""),
108    };
109    let (cmd, bang) = if let Some(stripped) = raw_cmd.strip_suffix('!') {
110        (stripped, true)
111    } else {
112        (raw_cmd, false)
113    };
114
115    // DESIGN.md §5.2.1 kind-prefix form: `:motion <name>`,
116    // `:operator <name> [target]`, `:text-object <name>`. The
117    // bare kind word reserves the namespace; the next token is
118    // looked up as `kind:<name>` in the registry. This is the
119    // canonical surface for invoking non-ex-command primitives
120    // from `:`. Bang on the kind word itself is rejected (it'd be
121    // ambiguous which side it applied to).
122    if let Some(kind) = parse_kind_word(cmd) {
123        if bang {
124            return Err(ExCommandError::BangNotAllowed(raw_cmd.to_string()));
125        }
126        return parse_kind_prefixed(registry, kind, rest);
127    }
128
129    // Resolution order: try the typed text directly as a registry
130    // name first (so canonical names like `ex:describe-command`
131    // resolve), then fall back to alias expansion (so user-friendly
132    // shorthands like `describe-command` / `q` / `wq` resolve too).
133    // Both forms reach the same `CommandSpec`; this is what lets
134    // tab-completion's accepted candidates submit correctly
135    // regardless of whether the candidate text was the canonical
136    // form or the alias.
137    let id = if let Some(id) = registry.id_by_name(cmd) {
138        id
139    } else if let Some(canonical) = expand_alias(cmd) {
140        registry
141            .id_by_name(canonical)
142            .ok_or_else(|| ExCommandError::Unknown(raw_cmd.to_string()))?
143    } else {
144        return Err(ExCommandError::Unknown(raw_cmd.to_string()));
145    };
146
147    let entry = registry
148        .lookup(id)
149        .ok_or_else(|| ExCommandError::Unknown(raw_cmd.to_string()))?;
150
151    // The bare-word path handles ex-commands only (`:write`, `:wq`,
152    // `:set ...`). Motions / operators / text-objects are reached
153    // from `:` via the explicit kind-prefix form
154    // (`:motion goto-first-line`, `:operator delete word-forward`,
155    // `:text-object inner-word`) -- handled in `parse_invocation`'s
156    // kind-prefix branch above. If we land here with a non-ex
157    // command, the user typed the registered canonical name (e.g.
158    // `:motion:goto-first-line`) -- supported as a fallback for
159    // tooling / scripts but not the canonical user surface; we
160    // dispatch but don't accept args because the kind-prefix form
161    // is the right place for that.
162    match entry.kind {
163        CommandKind::ExCommand => parse_ex_command(registry, id, entry, raw_cmd, rest, bang),
164        CommandKind::Motion => parse_naked_motion(id, raw_cmd, rest, bang),
165        CommandKind::Operator => parse_operator_with_target(registry, id, raw_cmd, rest, bang),
166        CommandKind::TextObject => Err(ExCommandError::BadArgs(format!(
167            "`{cmd}` is a text-object; pair it with an operator \
168             (`:operator delete {cmd}`) or use chord grammar"
169        ))),
170        CommandKind::Action => parse_naked_action(id, raw_cmd, rest, bang),
171    }
172}
173
174/// Match a bare command word against the reserved kind-prefix set
175/// (`motion`, `operator`, `text-object`). Returns the matching
176/// [`CommandKind`] or `None` for any other word -- which falls
177/// through to ex-command resolution.
178fn parse_kind_word(s: &str) -> Option<CommandKind> {
179    match s {
180        "motion" => Some(CommandKind::Motion),
181        "operator" => Some(CommandKind::Operator),
182        "text-object" => Some(CommandKind::TextObject),
183        _ => None,
184    }
185}
186
187/// Resolve the kind-prefix form: the leading kind word fixes the
188/// namespace; the next whitespace-delimited token is the command
189/// tail (the part after `motion:` / `operator:` / `text-object:`
190/// in the canonical name). Subsequent tokens, if any, feed each
191/// kind's specific parser (operators take a target).
192fn parse_kind_prefixed(
193    registry: &CommandRegistry,
194    kind: CommandKind,
195    rest: &str,
196) -> Result<CommandInvocation, ExCommandError> {
197    let trimmed = rest.trim();
198    let (tail, more) = match trimmed.find(char::is_whitespace) {
199        Some(i) => (&trimmed[..i], trimmed[i..].trim()),
200        None => (trimmed, ""),
201    };
202    if tail.is_empty() {
203        return Err(ExCommandError::BadArgs(format!(
204            "`{}` requires a name (e.g. `:{} <name>`)",
205            kind.label(),
206            kind.label()
207        )));
208    }
209    let canonical = format!("{}:{}", kind.label(), tail);
210    let id = registry
211        .id_by_name(&canonical)
212        .ok_or_else(|| ExCommandError::Unknown(canonical.clone()))?;
213    // Defensive: the lookup should match the kind we asserted via
214    // the prefix. Mismatch means a registry inconsistency, not
215    // user error.
216    let entry = registry
217        .lookup(id)
218        .ok_or_else(|| ExCommandError::Unknown(canonical.clone()))?;
219    debug_assert_eq!(entry.kind, kind, "kind-prefix lookup inconsistency");
220    let _ = entry;
221    match kind {
222        CommandKind::Motion => parse_naked_motion(id, &canonical, more, false),
223        CommandKind::Operator => parse_operator_with_target(registry, id, &canonical, more, false),
224        CommandKind::TextObject => Err(ExCommandError::BadArgs(format!(
225            "`{tail}` is a text-object; pair it with an operator \
226             (`:operator delete {tail}`) or use chord grammar"
227        ))),
228        CommandKind::ExCommand | CommandKind::Action => unreachable!(),
229    }
230}
231
232/// Run the ex-command-specific parsing path: surface-form check,
233/// bang validation, then the spec's `parse_args` callback.
234fn parse_ex_command(
235    registry: &CommandRegistry,
236    id: lattice_grammar::CommandId,
237    entry: &lattice_grammar::CommandSpec,
238    raw_cmd: &str,
239    rest: &str,
240    bang: bool,
241) -> Result<CommandInvocation, ExCommandError> {
242    let spec =
243        ex_spec_for(registry, id).ok_or_else(|| ExCommandError::Unknown(raw_cmd.to_string()))?;
244    // Surface-form check before parse_args. Commands flagged
245    // `Delimiter` (`:ex:substitute`, `:ex:global`) are not
246    // keyword-invocable; the front-end's delimiter-detection path
247    // (`try_parse_substitute` / `try_parse_global`) is the only
248    // valid entry. Reaching here means the user typed the keyword
249    // form by hand. Surface a precise error with the right syntax
250    // hint instead of letting the call fall through to a generic
251    // `parse_args` failure (which would say "invalid args").
252    // PL8.F: `SurfaceForm` is no longer `Copy` — bind the hint by reference and
253    // clone it into the owned error field.
254    if let lattice_grammar::SurfaceForm::Delimiter { hint } = &spec.surface_form {
255        return Err(ExCommandError::WrongSurfaceForm {
256            name: entry.name.clone(),
257            hint: hint.clone(),
258        });
259    }
260    if bang && !spec.accepts_bang {
261        return Err(ExCommandError::BangNotAllowed(raw_cmd.to_string()));
262    }
263    let args = (spec.parse_args)(rest, bang).map_err(|e| match e {
264        lattice_grammar::CommandError::BadArgs(msg) => ExCommandError::BadArgs(msg),
265        other => ExCommandError::BadArgs(other.to_string()),
266    })?;
267
268    Ok(CommandInvocation::of(id).with_args(args).with_bang(bang))
269}
270
271/// `:motion:NAME` -- run the motion against the active cursor. v1
272/// accepts the naked form only (Args::None); motions that need a
273/// char arg (`f`, `t`, etc.) error from inside the motion's
274/// evaluator with `CommandError::InvalidArgs`. Bang is rejected --
275/// motions don't carry a force flag.
276fn parse_naked_motion(
277    id: lattice_grammar::CommandId,
278    raw_cmd: &str,
279    rest: &str,
280    bang: bool,
281) -> Result<CommandInvocation, ExCommandError> {
282    if bang {
283        return Err(ExCommandError::BangNotAllowed(raw_cmd.to_string()));
284    }
285    if !rest.is_empty() {
286        return Err(ExCommandError::TrailingArgs);
287    }
288    Ok(CommandInvocation::of(id))
289}
290
291/// `:<action-name> [args]` -- run a registered action from the `:` line.
292///
293/// Actions were the one kind this parser refused, answering `Unknown` — which
294/// reports "unknown command" for a command that is registered, listed by
295/// `:describe-command`, and reachable from a chord. Motions, operators and
296/// text-objects were all already reachable here; the design this parser
297/// implements says every kind should be ("the `:` line is a parser
298/// front-end", design.md §5.2.1), and actions were simply the kind nobody had
299/// needed from `:` yet.
300///
301/// The consequence was concrete: a plugin that contributes actions — org
302/// contributes fifty-odd — could be driven by chords and by nothing else. No
303/// `:org-todo-cycle`, so no scripting it, no `:` history, no discovering it by
304/// typing part of the name.
305///
306/// **The remainder crosses as one `Args::String`**, unlike the naked-motion
307/// path which refuses trailing args. An action's argument shape is the
308/// registrant's business and there is no `parse_ex_args` for actions to
309/// declare one with, so the honest contract is to hand over what was typed and
310/// let the handler read it — which is exactly what the picker's
311/// `InvokeCommand` path already does when it dispatches an action with args.
312fn parse_naked_action(
313    id: lattice_grammar::CommandId,
314    raw_cmd: &str,
315    rest: &str,
316    bang: bool,
317) -> Result<CommandInvocation, ExCommandError> {
318    if bang {
319        return Err(ExCommandError::BangNotAllowed(raw_cmd.to_string()));
320    }
321    let invocation = CommandInvocation::of(id);
322    Ok(if rest.trim().is_empty() {
323        invocation
324    } else {
325        invocation.with_args(lattice_grammar::Args::String(rest.trim().to_string()))
326    })
327}
328
329/// `:operator <name> [target]` -- run the operator against a motion
330/// or text-object whose tail name follows. The target lives in
331/// `CommandInvocation::target`; the dispatcher's existing
332/// resolve-target path handles the rest.
333///
334/// Target resolution uses the kind-prefix form's implicit-namespace
335/// rule: a bare tail like `word-forward` is tried as `motion:word-
336/// forward` first, then `text-object:word-forward`. The user can
337/// also type the full canonical name (`motion:word-forward`) for
338/// disambiguation; that path resolves directly. v1 accepts only
339/// the trailing-name form -- explicit ranges
340/// (`:operator delete .`, `:operator delete %`) are queued because
341/// they overlap vim's existing range-prefix syntax (`:1,5d`) and we
342/// want a single canonical surface.
343fn parse_operator_with_target(
344    registry: &CommandRegistry,
345    id: lattice_grammar::CommandId,
346    raw_cmd: &str,
347    rest: &str,
348    bang: bool,
349) -> Result<CommandInvocation, ExCommandError> {
350    if bang {
351        return Err(ExCommandError::BangNotAllowed(raw_cmd.to_string()));
352    }
353    let trimmed = rest.trim();
354    if trimmed.is_empty() {
355        return Err(ExCommandError::BadArgs(format!(
356            "`{raw_cmd}` requires a target; use chord grammar (e.g. `dw`) \
357             or pass a motion / text-object name \
358             (`:operator delete word-forward`)"
359        )));
360    }
361    // Resolution attempts (later wins out only if earlier fails):
362    //   1. Direct canonical: user typed `motion:word-forward`.
363    //   2. Implicit motion: prefix with `motion:`.
364    //   3. Implicit text-object: prefix with `text-object:`.
365    let target_id = registry
366        .id_by_name(trimmed)
367        .or_else(|| registry.id_by_name(&format!("motion:{trimmed}")))
368        .or_else(|| registry.id_by_name(&format!("text-object:{trimmed}")))
369        .ok_or_else(|| ExCommandError::Unknown(trimmed.to_string()))?;
370    let target_entry = registry
371        .lookup(target_id)
372        .ok_or_else(|| ExCommandError::Unknown(trimmed.to_string()))?;
373    let target = match target_entry.kind {
374        CommandKind::Motion => Target::Motion(MotionId(target_id), Args::None),
375        CommandKind::TextObject => Target::TextObject(TextObjectId(target_id), Args::None),
376        _ => {
377            return Err(ExCommandError::BadArgs(format!(
378                "`{trimmed}` is not a motion or text-object \
379                 (operators take motion/text-object targets only)"
380            )));
381        }
382    };
383    Ok(CommandInvocation::of(id).with_target(target))
384}
385
386/// Borrow the registered [`ExCommandSpec`] body by id. The registry's
387/// `entry()` accessor is `pub(crate)`, so we pull the spec via a public
388/// helper -- swapping for a real registry method is a one-liner once
389/// the registry crate exposes one.
390fn ex_spec_for(
391    registry: &CommandRegistry,
392    id: lattice_grammar::CommandId,
393) -> Option<&lattice_grammar::ExCommandSpec> {
394    registry.ex_command_spec(id)
395}
396
397/// Map a user-typed command word to the canonical name registered in
398/// the `CommandRegistry`. Aliases live here -- not as duplicate registry
399/// entries -- so that `:describe-command` and the command palette show
400/// one row per command, not five.
401fn expand_alias(cmd: &str) -> Option<&'static str> {
402    ALIAS_TABLE
403        .iter()
404        .find_map(|(short, canon)| (*short == cmd).then_some(*canon))
405}
406
407/// Single source of truth for the alias table. `aliases()` exposes it
408/// as a HashMap for tests and any future `:describe-aliases` view; the
409/// hot path uses the slice directly via `expand_alias`.
410static ALIAS_TABLE: &[(&str, &str)] = &[
411    ("w", "ex:write"),
412    ("write", "ex:write"),
413    ("q", "ex:quit"),
414    ("quit", "ex:quit"),
415    ("wq", "ex:write-quit"),
416    ("x", "ex:write-quit"),
417    // `:qa` quits the whole editor (every pane + tab), distinct from
418    // `:q` which closes a single pane unless it's the last one.
419    ("qa", "ex:quit-all"),
420    ("qall", "ex:quit-all"),
421    ("quitall", "ex:quit-all"),
422    // `:only` / `:on` -- close every pane except the active one
423    // (vim's `<C-w>o`). Pane axis of `:tabonly`.
424    ("only", "ex:only"),
425    ("on", "ex:only"),
426    // Pane splits + close (vim `:sp` / `:vs` / `:clo`).
427    ("split", "ex:split"),
428    ("sp", "ex:split"),
429    ("vsplit", "ex:vsplit"),
430    ("vsp", "ex:vsplit"),
431    ("vs", "ex:vsplit"),
432    ("close", "ex:close"),
433    ("clo", "ex:close"),
434    // ZP.2: `:zoom-pane` -- the non-destructive `:only`. One alias,
435    // the dashed namespaced form: no vim-tradition short, because
436    // the 1-2 letter slots are scarce and reserved for
437    // vim-canonical commands, and no bare `:zoom`, which a future
438    // font/UI scale command in the GPUI peer has the better claim on.
439    ("zoom-pane", "ex:zoom-pane"),
440    ("noh", "ex:nohlsearch"),
441    ("nohl", "ex:nohlsearch"),
442    ("nohlsearch", "ex:nohlsearch"),
443    ("reg", "ex:registers"),
444    ("registers", "ex:registers"),
445    ("marks", "ex:marks"),
446    ("d", "ex:delete"),
447    ("delete", "ex:delete"),
448    ("set", "ex:set"),
449    // T.9.b (2026-06-18): `:colorscheme` + the vim short `:colo`.
450    ("colorscheme", "ex:colorscheme"),
451    ("colo", "ex:colorscheme"),
452    ("setlocal", "ex:setlocal"),
453    ("sl", "ex:setlocal"),
454    ("setglobal", "ex:setglobal"),
455    ("sg", "ex:setglobal"),
456    ("e", "ex:edit"),
457    ("edit", "ex:edit"),
458    ("describe-command", "ex:describe-command"),
459    ("describe-buffer", "ex:describe-buffer"),
460    ("apropos", "ex:apropos"),
461    ("describe-key", "ex:describe-key"),
462    ("keymap", "ex:keymap"),
463    ("bn", "ex:bnext"),
464    ("bnext", "ex:bnext"),
465    ("bp", "ex:bprev"),
466    ("bprev", "ex:bprev"),
467    // Issue #29 (2026-05-22): tab management.
468    ("tabn", "ex:tabnext"),
469    ("tabnext", "ex:tabnext"),
470    ("tabp", "ex:tabprev"),
471    ("tabprev", "ex:tabprev"),
472    ("tabprevious", "ex:tabprev"),
473    ("tabN", "ex:tabprev"),
474    ("tabnew", "ex:tabnew"),
475    ("tabe", "ex:tabnew"),
476    ("tabedit", "ex:tabnew"),
477    ("tabc", "ex:tabclose"),
478    ("tabclose", "ex:tabclose"),
479    // Issue #40 / Terminal-mode T1 (2026-05-22).
480    ("term", "ex:terminal"),
481    ("terminal", "ex:terminal"),
482    ("tnew", "ex:terminal"),
483    // T4 (2026-05-25): `:tabterminal [cmd]` opens a fresh tab
484    // and lands a terminal in it. Sugar for `:tabnew | :terminal`.
485    ("tabterminal", "ex:tabterminal"),
486    ("tabterm", "ex:tabterminal"),
487    ("tabo", "ex:tabonly"),
488    ("tabonly", "ex:tabonly"),
489    ("tabm", "ex:tabmove"),
490    ("tabmove", "ex:tabmove"),
491    // `:ls` keeps vim/emacs's static text listing (`list-buffers`,
492    // `<C-x><C-b>`). `:buffers` and `:b` open the fuzzy buffer picker —
493    // consistent with `:files` opening the file picker; the picker is the
494    // interesting, actionable surface.
495    ("ls", "ex:buffers"),
496    ("buffers", "ex:buffer-picker"),
497    ("bd", "ex:bdelete"),
498    ("bdelete", "ex:bdelete"),
499    ("b", "ex:buffer-picker"),
500    // Picker entry points (Phase 4.x picker work). `:picker
501    // <source>` is the canonical surface; the per-source
502    // aliases (`:files`, `:recent`) ship for vim muscle
503    // memory and route through their dedicated effect.
504    ("picker", "ex:picker"),
505    ("files", "ex:files"),
506    ("recent", "ex:recent"),
507    ("history", "ex:history"),
508    ("Tree", "ex:filetree"),
509    ("tree", "ex:filetree"),
510    ("Filetree", "ex:filetree"),
511    ("filetree", "ex:filetree"),
512    ("TreeClose", "ex:filetree-close"),
513    ("FiletreeClose", "ex:filetree-close"),
514    ("filetree-close", "ex:filetree-close"),
515    // `:Oil` is oil.nvim's spelling; `:oil` matches `:tree` beside it.
516    ("Oil", "ex:oil"),
517    ("oil", "ex:oil"),
518    // IN.8b's LSP-independent formatter, so the generic name is honest.
519    ("format", "ex:format"),
520    ("reload-snippets", "ex:reload-snippets"),
521    ("describe-option", "ex:describe-option"),
522    // T.9.d (2026-06-18): theme-element / face introspection. Both the
523    // element-name and the emacs `face` framing route to the one
524    // command.
525    ("describe-element", "ex:describe-element"),
526    ("describe-face", "ex:describe-element"),
527    ("options", "ex:options"),
528    // PI.2: plugin-API introspection.
529    ("describe-plugin-api", "ex:describe-plugin-api"),
530    ("list-plugin-apis", "ex:list-plugin-apis"),
531    ("export-plugin-api", "ex:export-plugin-api"),
532    ("list-commands", "ex:list-commands"),
533    ("describe-plugin", "ex:describe-plugin"),
534    ("list-plugins", "ex:list-plugins"),
535    ("describe-events", "ex:describe-events"),
536    ("describe-event", "ex:describe-event"),
537    // CR.6 (2026-06-24): the diff/hunk commands no longer need aliases —
538    // `lattice_diff::install()` registers them under their plain canonical
539    // names (`diff`, `diffsplit`, `hunk-next`, …), so `:diff` resolves
540    // directly (the multibuffer pattern). No host `ex:diff*` shim remains.
541    ("list-modes", "ex:list-modes"),
542    ("describe-mode", "ex:describe-mode"),
543    // DAM.1: `describe-active-modes`, deliberately NOT
544    // `describe-modes` — the shorter name is a prefix-sibling of
545    // `describe-mode` and would break its `<Tab>` completion.
546    ("describe-active-modes", "ex:describe-active-modes"),
547    ("describe-bindings", "ex:describe-bindings"),
548    (
549        "describe-option-resolution",
550        "ex:describe-option-resolution",
551    ),
552    ("customize", "ex:customize"),
553    ("tutor", "ex:tutor"),
554    ("Tutor", "ex:tutor"),
555    ("tutor-next", "ex:tutor-next"),
556    ("tutor-prev", "ex:tutor-prev"),
557    ("hover", "ex:hover"),
558    ("HoverClose", "ex:hover-close"),
559    ("h", "ex:help"),
560    ("help", "ex:help"),
561    // K.3.1 (2026-06-02): emacs-style canonical name for the
562    // help-for-help entry point. Same effect as `:help` — the
563    // <C-h>-prefix bindings (`<C-h><C-h>` / `<C-h>?`) wire here
564    // per the K.3 help-prefix slice plan.
565    ("help-for-help", "ex:help"),
566    ("diagnostics", "ex:diagnostics"),
567    ("diag", "ex:diagnostics"),
568    ("diag-next", "ex:diag-next"),
569    ("dnext", "ex:diag-next"),
570    ("diag-prev", "ex:diag-prev"),
571    ("dprev", "ex:diag-prev"),
572    // CM.2 / CM.7 (2026-07-22): `:cnext`/`:cn`/`:cprev`/`:cp` repointed
573    // from `ex:diag-*` to the error-list family. Error-list commands touch
574    // ONLY the error list (no diagnostic fallback — dedicated
575    // `]d`/`[d` + `:diag-*` own diagnostics); an empty list echoes
576    // `no error list`, matching vim's `E42: No Errors`.
577    // naming-2026-07-22: readable canonical names (`next-error`, emacs
578    // vocabulary) lead; the vim `:c*` spellings are aliases onto them.
579    ("cnext", "next-error"),
580    ("cn", "next-error"),
581    ("cprev", "previous-error"),
582    ("cp", "previous-error"),
583    ("cc", "error"),
584    // The error picker (`:error`; parallel to `:diagnostics`).
585    ("clist", "error-list"),
586    ("cl", "error-list"),
587    ("cfirst", "first-error"),
588    ("crewind", "first-error"),
589    ("cr", "first-error"),
590    ("clast", "last-error"),
591    // File-level traversal.
592    ("cnextfile", "next-error-file"),
593    ("cnf", "next-error-file"),
594    ("cprevfile", "previous-error-file"),
595    ("cpf", "previous-error-file"),
596    // The grouped *problems* multibuffer (`:problems`; vim `:copen`/`:cclose`).
597    ("copen", "problems"),
598    ("cclose", "problems-close"),
599    // LSP commands. Naming convention:
600    //
601    // 1. Dashed canonical only. Every LSP-coupled command has
602    //    exactly one user-facing form -- the explicit `lsp-*`
603    //    dashed name that tracks the canonical `ex:lsp-*` id.
604    //    Generic names (`format`, `rename`, `complete`,
605    //    `signature-help`, `code-actions`, `format-range`) are
606    //    NOT registered as LSP aliases. They imply genericness
607    //    -- a non-LSP `:format` could come from rustfmt-direct,
608    //    a project formatter, treesitter, etc. -- and silently
609    //    no-op when no LSP server (or no server with the
610    //    relevant capability) is attached. The explicit
611    //    `lsp-` prefix makes the dependency visible at the
612    //    cmdline and reserves the generic names for future
613    //    non-LSP implementations.
614    //
615    // 2. No collapsed forms (`lspformat`, `lspcodeaction`,
616    //    `signaturehelp`, ...). They duplicate the dashed
617    //    canonical with no visual benefit.
618    //
619    // 3. No vim-style 1-2 letter shortcuts for LSP commands.
620    //    Vim shorts (`cn`, `cp`, `bn`, `bp`, etc.) come from
621    //    decades of vim tradition tied to specific commands
622    //    (`:cnext` etc.). LSP didn't exist when those were
623    //    canonised, so any 1-2 letter LSP shortcut would be
624    //    novel -- and `fmt` / `rn` / `ca` are too generic to
625    //    earn that scarcity. If the user-config alias
626    //    mechanism eventually lands (slice 8.h's WIT-shaped
627    //    plugin / init.rs API), users / plugins can add their
628    //    own personal shortcuts.
629    ("messages", "ex:messages"),
630    ("msg", "ex:messages"),
631    ("lsp-log", "ex:lsp-log"),
632    ("lsp-trace", "ex:lsp-trace"),
633    ("lsp-trace-log", "ex:lsp-trace-log"),
634    ("lsp-status", "ex:lsp-status"),
635    // LR.2: the editable references view (the picker stays on `gr`).
636    ("lsp-references", "ex:lsp-references"),
637    // EP.6: references into the error list, on demand.
638    (
639        "lsp-references-to-error-list",
640        "ex:lsp-references-to-error-list",
641    ),
642    // EP.4: exactly one alias — dashed + `lsp-` namespaced. No
643    // collapsed form, no generic `diagnostics` alias.
644    (
645        "lsp-diagnostics-to-error-list",
646        "ex:lsp-diagnostics-to-error-list",
647    ),
648    ("lsp-server-log", "ex:lsp-server-log"),
649    ("lsp-restart", "ex:lsp-restart"),
650    ("lsp-progress-cancel", "ex:lsp-progress-cancel"),
651    ("lsp-expand-region", "ex:lsp-expand-region"),
652    ("lsp-shrink-region", "ex:lsp-shrink-region"),
653    ("lsp-log-level", "ex:lsp-log-level"),
654    ("lsp-log-clear", "ex:lsp-log-clear"),
655    // Navigation pickers (Phase 4.2.e / 4.2.f).
656    ("lsp-symbols", "ex:lsp-symbols"),
657    ("lsp-workspace-symbol", "ex:lsp-workspace-symbol"),
658    // 4.5.a -- call hierarchy.
659    ("lsp-incoming-calls", "ex:lsp-incoming-calls"),
660    ("lsp-outgoing-calls", "ex:lsp-outgoing-calls"),
661    // 4.5.b -- type hierarchy.
662    ("lsp-supertypes", "ex:lsp-supertypes"),
663    ("lsp-subtypes", "ex:lsp-subtypes"),
664    // 4.5.g -- moniker (cross-project symbol id).
665    ("lsp-moniker", "ex:lsp-moniker"),
666    // 4.5.d -- code lens picker.
667    ("lsp-code-lens", "ex:lsp-code-lens"),
668    // 4.5.e -- color presentation picker.
669    ("lsp-color-presentation", "ex:lsp-color-presentation"),
670    // Phase 4.3 edits.
671    ("lsp-format", "ex:lsp-format"),
672    ("lsp-format-range", "ex:lsp-format-range"),
673    ("lsp-signature-help", "ex:lsp-signature-help"),
674    ("lsp-complete", "ex:lsp-complete"),
675    ("lsp-rename", "ex:lsp-rename"),
676    ("lsp-code-action", "ex:lsp-code-action"),
677    ("cd", "ex:cd"),
678    ("chdir", "ex:cd"),
679    ("pwd", "ex:pwd"),
680    // PR.2. Dashed, not collapsed, and no 1-2 letter short: those slots
681    // are scarce and reserved for vim-canonical commands.
682    ("project-root", "ex:project-root"),
683];
684
685/// Built-in aliases as a `(short, canonical)` map. Exposed for tests
686/// and any future `:describe-aliases` view.
687pub fn aliases() -> HashMap<&'static str, &'static str> {
688    ALIAS_TABLE.iter().copied().collect()
689}
690
691/// Resolve a user-typed command spelling against the registry,
692/// trying the canonical form first then falling back to the alias
693/// table. Mirrors [`parse`]'s two-stage resolution so introspection
694/// (`:describe-command`, `:apropos`) accepts both forms a user can
695/// type at `:` (canonical `ex:write` or alias `w` / `write`).
696///
697/// Relocated to `lattice-host` in 5.5.F.2 alongside the
698/// `:describe-command` builder that depends on it.
699pub fn resolve_command_name_or_alias(
700    registry: &lattice_grammar::CommandRegistry,
701    name: &str,
702) -> Option<lattice_grammar::CommandId> {
703    if let Some(id) = registry.id_by_name(name) {
704        return Some(id);
705    }
706    let canonical = aliases().get(name).copied()?;
707    registry.id_by_name(canonical)
708}
709
710/// Reverse map of the alias table: for each canonical name, the
711/// preferred user-facing alias (the longest one). Used by completion
712/// to rewrite raw `gen:commands` output (which produces canonical
713/// names like `ex:describe-command`) into the form a user actually
714/// types (`describe-command`).
715///
716/// Picking the longest alias produces the most descriptive form
717/// (`write` over `w`, `nohlsearch` over `noh`). Commands without
718/// any alias map to themselves -- the canonical IS the user-facing
719/// form.
720pub fn preferred_alias_for(canonical: &str) -> Option<&'static str> {
721    ALIAS_TABLE
722        .iter()
723        .filter(|(_, c)| *c == canonical)
724        .map(|(short, _)| *short)
725        .max_by_key(|s| s.len())
726}
727
728/// `:g/pattern/body` and `:v/pattern/body`. Produces a registered-
729/// invocation pointing at `ex:global` with `Args::List([pattern,
730/// inverted, body])`.
731fn try_parse_global(
732    input: &str,
733    registry: &CommandRegistry,
734) -> Result<Option<CommandInvocation>, ExCommandError> {
735    let (inverted, rest) = if let Some(rest) = input.strip_prefix("g/") {
736        (false, rest)
737    } else if let Some(rest) = input.strip_prefix("v/") {
738        (true, rest)
739    } else {
740        return Ok(None);
741    };
742    // Walk forward to the next unescaped `/`.
743    let mut pattern = String::new();
744    let mut chars = rest.chars().peekable();
745    let mut found_delim = false;
746    while let Some(c) = chars.next() {
747        if c == '\\' {
748            if let Some(next) = chars.next() {
749                pattern.push(next);
750            }
751            continue;
752        }
753        if c == '/' {
754            found_delim = true;
755            break;
756        }
757        pattern.push(c);
758    }
759    if !found_delim {
760        return Err(ExCommandError::BadSubstitute(
761            "missing closing `/` after pattern",
762        ));
763    }
764    if pattern.is_empty() {
765        return Err(ExCommandError::BadSubstitute("empty pattern"));
766    }
767    let body: String = chars.collect();
768    if body.is_empty() {
769        return Err(ExCommandError::BadSubstitute("empty body"));
770    }
771    // Parse the body as a CommandInvocation up front so the host
772    // dispatches it per matching line without re-parsing, and body
773    // syntax errors surface at `:g` parse time rather than mid-iteration.
774    let body_inv = parse(&body, registry)?;
775    let id = registry
776        .id_by_name("ex:global")
777        .ok_or_else(|| ExCommandError::Unknown("ex:global".into()))?;
778    Ok(Some(CommandInvocation::of(id).with_args(Args::List(vec![
779        ArgValue::Pattern(pattern),
780        ArgValue::Bool(inverted),
781        ArgValue::Invocation(Box::new(body_inv)),
782    ]))))
783}
784
785/// Vim's `[%]s/pattern/replacement/[flags]`. Produces a
786/// registered-invocation pointing at `ex:substitute` with the scope
787/// expressed via `Range::CurrentLine` / `Range::Whole` and
788/// `Args::List([pattern, replacement, flags])`.
789/// Scope (current line vs. whole buffer) detected on the partial
790/// or full `:s` / `:%s` form. Used by both the substitute parser
791/// and the live-preview parser below.
792#[derive(Debug, Clone, Copy, PartialEq, Eq)]
793pub enum SubstitutePartialScope {
794    CurrentLine,
795    Whole,
796}
797
798/// Result of a best-effort parse of an in-progress substitute
799/// command line, used by the live-preview path
800/// (`refresh_substitute_preview` in App). Unlike the full
801/// `try_parse_substitute` path, this never errors on incomplete
802/// input -- a half-typed pattern or a missing second `/` is fine.
803/// Returns `None` only when the input doesn't look like a
804/// substitute at all.
805#[derive(Debug, Clone, PartialEq, Eq)]
806pub struct SubstitutePartial {
807    pub scope: SubstitutePartialScope,
808    pub pattern: String,
809    /// None when the user hasn't typed the second `/` yet (still
810    /// inside the pattern field). `Some("")` is a typed second `/`
811    /// with an empty replacement so far.
812    pub replacement: Option<String>,
813    /// None when the user hasn't typed the third `/` yet. `Some("")`
814    /// is a typed third `/` with no flags so far. The flags string
815    /// is opaque -- the live preview only uses pattern + replacement.
816    pub flags: Option<String>,
817}
818
819/// Best-effort parse of a partial `:s` / `:%s` command line. Used by
820/// the live-preview path: as the user types, we want to highlight
821/// matches of the in-progress pattern even before the second `/` or
822/// closing `/` is typed. Backslash-escapes are honored the same way
823/// `try_parse_substitute` honors them, so a partial `\/` doesn't
824/// flip the field state mid-stream.
825pub fn try_parse_substitute_partial(input: &str) -> Option<SubstitutePartial> {
826    let (scope, body) = if let Some(rest) = input.strip_prefix("%s/") {
827        (SubstitutePartialScope::Whole, rest)
828    } else if let Some(rest) = input.strip_prefix("s/") {
829        (SubstitutePartialScope::CurrentLine, rest)
830    } else {
831        return None;
832    };
833
834    let mut pattern = String::new();
835    let mut replacement: Option<String> = None;
836    let mut flags: Option<String> = None;
837    let mut state = 0u8; // 0 = pattern, 1 = replacement, 2 = flags
838    let mut chars = body.chars().peekable();
839    while let Some(c) = chars.next() {
840        if c == '\\' {
841            if let Some(next) = chars.next() {
842                match state {
843                    0 => pattern.push(next),
844                    1 => replacement.get_or_insert_with(String::new).push(next),
845                    _ => flags.get_or_insert_with(String::new).push(next),
846                }
847            } else {
848                // Trailing `\` with nothing after -- mid-input.
849                // Treat as a literal in the current field so the
850                // user sees their typing reflected.
851                match state {
852                    0 => pattern.push('\\'),
853                    1 => replacement.get_or_insert_with(String::new).push('\\'),
854                    _ => flags.get_or_insert_with(String::new).push('\\'),
855                }
856            }
857            continue;
858        }
859        if c == '/' {
860            state += 1;
861            if state == 1 {
862                replacement = Some(String::new());
863            } else if state == 2 {
864                flags = Some(String::new());
865            }
866            // Extra `/` past the flags field is just absorbed into
867            // the flags string -- the full parser would reject it,
868            // but for a live preview we don't care.
869            if state > 2 {
870                flags.get_or_insert_with(String::new).push('/');
871                state = 2;
872            }
873            continue;
874        }
875        match state {
876            0 => pattern.push(c),
877            1 => replacement.get_or_insert_with(String::new).push(c),
878            _ => flags.get_or_insert_with(String::new).push(c),
879        }
880    }
881
882    Some(SubstitutePartial {
883        scope,
884        pattern,
885        replacement,
886        flags,
887    })
888}
889
890fn try_parse_substitute(
891    input: &str,
892    registry: &CommandRegistry,
893) -> Result<Option<CommandInvocation>, ExCommandError> {
894    let (range, body) = if let Some(rest) = input.strip_prefix("%s/") {
895        (Range::Whole, rest)
896    } else if let Some(rest) = input.strip_prefix("s/") {
897        (Range::CurrentLine, rest)
898    } else {
899        return Ok(None);
900    };
901
902    // Walk `body` character-by-character respecting backslash-escapes.
903    // Vim's substitute uses `\/` as an escape-for-delimiter; we accept
904    // `\/` as a literal `/` in either pattern or replacement.
905    let mut pattern = String::new();
906    let mut replacement = String::new();
907    let mut flags = String::new();
908    let mut state = 0u8; // 0 = pattern, 1 = replacement, 2 = flags
909    let mut chars = body.chars().peekable();
910    while let Some(c) = chars.next() {
911        if c == '\\' {
912            // Take the next char literally (escape).
913            if let Some(next) = chars.next() {
914                let target = match state {
915                    0 => &mut pattern,
916                    1 => &mut replacement,
917                    _ => &mut flags,
918                };
919                target.push(next);
920            }
921            continue;
922        }
923        if c == '/' {
924            state += 1;
925            if state > 2 {
926                return Err(ExCommandError::BadSubstitute("too many `/` separators"));
927            }
928            continue;
929        }
930        let target = match state {
931            0 => &mut pattern,
932            1 => &mut replacement,
933            _ => &mut flags,
934        };
935        target.push(c);
936    }
937
938    if pattern.is_empty() {
939        return Err(ExCommandError::BadSubstitute("empty pattern"));
940    }
941    let id = registry
942        .id_by_name("ex:substitute")
943        .ok_or_else(|| ExCommandError::Unknown("ex:substitute".into()))?;
944    Ok(Some(CommandInvocation::of(id).with_range(range).with_args(
945        Args::List(vec![
946            ArgValue::Pattern(pattern),
947            ArgValue::String(replacement),
948            ArgValue::String(flags),
949        ]),
950    )))
951}
952
953// ─────────────────────────────────────────────────────────────────
954// MB.4 (rich minibuffer): live `:` line decorations.
955//
956// A lightweight tokenizer + validator that turns the in-progress
957// command line into (1) syntax-highlight spans, (2) a live error
958// indicator, and (3) a parameter hint — all produced off the render
959// thread (computed on the actor thread, like
960// `refresh_substitute_preview`, then published into the modeline
961// render state). The ex-parser IS the grammar front-end (paramount
962// #3), so this reuses its resolution + validation rather than
963// re-implementing it.
964// ─────────────────────────────────────────────────────────────────
965
966use lattice_cells::style::Style;
967
968/// One highlighted span of the `:` command line. `range` is a byte
969/// range into the line text (the `:` prompt is NOT included — spans
970/// are relative to `command_line()`).
971#[derive(Debug, Clone, PartialEq, Eq)]
972pub struct CommandLineSpan {
973    pub range: std::ops::Range<usize>,
974    pub style: Style,
975}
976
977/// Live decorations for the `:` line (MB.4): syntax spans, an
978/// optional validation error, and an optional parameter hint.
979#[derive(Debug, Clone, Default, PartialEq, Eq)]
980pub struct CommandLineDecorations {
981    /// Per-token highlight spans over the line text.
982    pub spans: Vec<CommandLineSpan>,
983    /// A live error message (unknown command / bad args), surfaced
984    /// only once the command word is "committed" (a trailing space or
985    /// `!`) so a valid-command *prefix* mid-typing doesn't flash red.
986    pub error: Option<String>,
987    /// A parameter hint (`<pat>/<rep>/[flags]`, `<file>`, …) derived
988    /// from the resolved command's `ArgSpec`s. Shown dim after the line.
989    pub param_hint: Option<String>,
990}
991
992impl CommandLineDecorations {
993    /// True when there is nothing to draw (empty / all-default line).
994    pub fn is_empty(&self) -> bool {
995        self.spans.is_empty() && self.error.is_none() && self.param_hint.is_none()
996    }
997}
998
999/// Byte length of a leading range prefix (`%` whole-file, `'<,'>`
1000/// visual range) at the start of `line`, or `None` when the line has
1001/// no range prefix. Only the prefixes the ex-parser actually honors
1002/// are recognised; anything else falls through to the command word.
1003fn leading_range_len(line: &str) -> Option<usize> {
1004    if line.starts_with("'<,'>") {
1005        Some("'<,'>".len())
1006    } else if line.starts_with('%') {
1007        Some(1)
1008    } else {
1009        None
1010    }
1011}
1012
1013/// Format a command's `ArgSpec`s as a parameter hint — `<name>` for a
1014/// required arg, `[<name>]` for an optional one (emacs / `marginalia`
1015/// convention, matching the command-palette args column).
1016fn format_arg_hint(schema: &[lattice_grammar::args::ArgSpec]) -> String {
1017    use lattice_grammar::args::ArgDefault;
1018    schema
1019        .iter()
1020        .map(|a| match a.default {
1021            ArgDefault::Required => format!("<{}>", a.name),
1022            _ => format!("[<{}>]", a.name),
1023        })
1024        .collect::<Vec<_>>()
1025        .join(" ")
1026}
1027
1028/// Tokenize a substitute body (`s/pat/rep/flags`) starting at byte
1029/// `start` (the `s`), pushing spans for the `s`, each `/` delimiter,
1030/// the pattern, replacement, and flags. Honors `\`-escapes so an
1031/// escaped `\/` doesn't split a field. Byte offsets stay absolute.
1032fn tokenize_substitute(line: &str, start: usize, spans: &mut Vec<CommandLineSpan>) {
1033    // The command letter `s`.
1034    spans.push(CommandLineSpan {
1035        range: start..start + 1,
1036        style: Style::Keyword,
1037    });
1038    // 0 = pattern, 1 = replacement, 2 = flags.
1039    let mut state = 0u8;
1040    let mut field_start: Option<usize> = None;
1041    let mut chars = line[start + 1..].char_indices().peekable();
1042    while let Some((rel, c)) = chars.next() {
1043        let at = start + 1 + rel;
1044        if c == '\\' {
1045            // Escape: absorb the next char into the current field.
1046            field_start.get_or_insert(at);
1047            let _ = chars.next();
1048            continue;
1049        }
1050        if c == '/' {
1051            // Close the field before this delimiter.
1052            if let Some(fs) = field_start.take() {
1053                spans.push(CommandLineSpan {
1054                    range: fs..at,
1055                    style: field_style(state),
1056                });
1057            }
1058            spans.push(CommandLineSpan {
1059                range: at..at + c.len_utf8(),
1060                style: Style::Punctuation,
1061            });
1062            state = state.saturating_add(1).min(2);
1063            continue;
1064        }
1065        field_start.get_or_insert(at);
1066    }
1067    if let Some(fs) = field_start {
1068        spans.push(CommandLineSpan {
1069            range: fs..line.len(),
1070            style: field_style(state),
1071        });
1072    }
1073}
1074
1075fn field_style(state: u8) -> Style {
1076    match state {
1077        0 | 1 => Style::String, // pattern / replacement
1078        _ => Style::Attribute,  // flags
1079    }
1080}
1081
1082/// Compute the live decorations for a `:` command line (MB.4). `line`
1083/// is the text without the leading `:` (i.e. `command_line()`). Pure +
1084/// cheap (one line, no I/O) so it runs on the actor thread each edit.
1085pub fn command_line_decorations(line: &str, registry: &CommandRegistry) -> CommandLineDecorations {
1086    let mut deco = CommandLineDecorations::default();
1087    if line.trim().is_empty() {
1088        return deco;
1089    }
1090    let end = line.trim_end().len();
1091
1092    // Bare line number (`:42`) — a goto-line range.
1093    if line.trim().chars().all(|c| c.is_ascii_digit()) {
1094        deco.spans.push(CommandLineSpan {
1095            range: 0..end,
1096            style: Style::Number,
1097        });
1098        return deco;
1099    }
1100
1101    // Optional leading range prefix (`%`, `'<,'>`).
1102    let mut cursor = 0usize;
1103    if let Some(rng) = leading_range_len(line) {
1104        deco.spans.push(CommandLineSpan {
1105            range: 0..rng,
1106            style: Style::Number,
1107        });
1108        cursor = rng;
1109    }
1110    let rest = &line[cursor..];
1111
1112    // Substitute delimiter form (`s/…/…/…`).
1113    if rest.starts_with("s/") {
1114        tokenize_substitute(line, cursor, &mut deco.spans);
1115        deco.param_hint = Some("<pattern>/<replacement>/[flags]".to_string());
1116        return deco;
1117    }
1118    // Global form (`g/pattern/cmd`, `v/pattern/cmd`).
1119    if rest.starts_with("g/") || rest.starts_with("v/") {
1120        deco.spans.push(CommandLineSpan {
1121            range: cursor..cursor + 1,
1122            style: Style::Keyword,
1123        });
1124        if end > cursor + 1 {
1125            deco.spans.push(CommandLineSpan {
1126                range: cursor + 1..end,
1127                style: Style::Default,
1128            });
1129        }
1130        deco.param_hint = Some("/<pattern>/<command>".to_string());
1131        return deco;
1132    }
1133
1134    // Keyword form: command word (+ optional `!`) then args.
1135    let word_rel = rest.find(char::is_whitespace).unwrap_or(rest.len());
1136    let raw_word = &rest[..word_rel];
1137    let (word, has_bang) = raw_word
1138        .strip_suffix('!')
1139        .map(|w| (w, true))
1140        .unwrap_or((raw_word, false));
1141    let word_end = cursor + word.len();
1142
1143    let resolved = if word.is_empty() {
1144        None
1145    } else {
1146        resolve_command_name_or_alias(registry, word)
1147    };
1148    let known = resolved.is_some();
1149    // A word is "committed" once it is followed by whitespace or a
1150    // bang — before that, a valid-command prefix shouldn't flash red.
1151    let committed = word_rel < rest.len() || has_bang;
1152    let flag_error = !word.is_empty() && !known && committed;
1153
1154    deco.spans.push(CommandLineSpan {
1155        range: cursor..word_end,
1156        style: if flag_error {
1157            Style::DiagnosticError
1158        } else {
1159            Style::Keyword
1160        },
1161    });
1162    if has_bang {
1163        deco.spans.push(CommandLineSpan {
1164            range: word_end..word_end + 1,
1165            style: Style::Operator,
1166        });
1167    }
1168    let args_start = cursor + raw_word.len();
1169    if args_start < end {
1170        deco.spans.push(CommandLineSpan {
1171            range: args_start..end,
1172            style: Style::Default,
1173        });
1174    }
1175
1176    if flag_error {
1177        deco.error = Some(format!("unknown command: {raw_word}"));
1178    }
1179
1180    // Parameter hint from the resolved command's ArgSpec (only when the
1181    // command is known and takes args).
1182    if let Some(id) = resolved
1183        && let Some(spec) = registry.lookup(id)
1184        && !spec.args_schema.is_empty()
1185    {
1186        deco.param_hint = Some(format_arg_hint(&spec.args_schema));
1187    }
1188
1189    deco
1190}
1191
1192#[cfg(test)]
1193mod tests {
1194    #![allow(clippy::unwrap_used, clippy::panic)]
1195    use super::*;
1196    use lattice_grammar::CommandKind;
1197
1198    fn fixture() -> CommandRegistry {
1199        let mut registry = CommandRegistry::new();
1200        let _ = lattice_grammar::builtins::populate(&mut registry);
1201        let _ = lattice_grammar::ex_commands::populate(&mut registry);
1202        // `:problems` / `:problems-close` (aliased from vim `:copen` /
1203        // `:cclose` in the table below) are registered by the multibuffer
1204        // install path at boot, not by the grammar populate. Mirror that
1205        // here so `aliases_table_is_self_consistent` sees their targets.
1206        lattice_multibuffer::providers::problems::register_problems_ex_commands(&mut registry);
1207        registry
1208    }
1209
1210    fn invocation_name<'a>(inv: &'a CommandInvocation, registry: &'a CommandRegistry) -> &'a str {
1211        registry
1212            .lookup(inv.command)
1213            .map(|s| s.name.as_str())
1214            .unwrap_or("?")
1215    }
1216
1217    // ---- MB.4: live command-line decorations ----
1218
1219    /// The command word of a known command highlights as a keyword;
1220    /// no error, and a known command with args offers a param hint.
1221    #[test]
1222    fn mb4_known_command_highlights_keyword_with_hint() {
1223        let reg = fixture();
1224        let d = command_line_decorations("write foo.txt", &reg);
1225        // First span covers the command word `write` as a keyword.
1226        assert_eq!(d.spans[0].range, 0..5);
1227        assert_eq!(d.spans[0].style, Style::Keyword);
1228        assert!(d.error.is_none(), "known command has no error");
1229        assert!(d.param_hint.is_some(), "`:write` takes a path arg → a hint");
1230    }
1231
1232    /// An unknown command, once committed (a trailing space), flags an
1233    /// error and paints the word with the diagnostic-error style.
1234    #[test]
1235    fn mb4_unknown_committed_command_flags_error() {
1236        let reg = fixture();
1237        let d = command_line_decorations("frobnicate x", &reg);
1238        assert_eq!(d.spans[0].style, Style::DiagnosticError);
1239        assert_eq!(d.error.as_deref(), Some("unknown command: frobnicate"));
1240    }
1241
1242    /// A *prefix* of a valid command, still being typed (no trailing
1243    /// space), must NOT flash red — reduces mid-typing flicker.
1244    #[test]
1245    fn mb4_uncommitted_prefix_does_not_error() {
1246        let reg = fixture();
1247        let d = command_line_decorations("writ", &reg);
1248        assert_eq!(d.spans[0].style, Style::Keyword);
1249        assert!(d.error.is_none());
1250    }
1251
1252    /// A substitute line tokenizes into `s`, `/` delimiters, the
1253    /// pattern, and the replacement, and offers the s/// hint.
1254    #[test]
1255    fn mb4_substitute_tokenizes_fields() {
1256        let reg = fixture();
1257        let d = command_line_decorations("s/foo/bar/g", &reg);
1258        // `s` keyword.
1259        assert_eq!(d.spans[0].range, 0..1);
1260        assert_eq!(d.spans[0].style, Style::Keyword);
1261        // Somewhere a `/` delimiter (punctuation) and a String field.
1262        assert!(d.spans.iter().any(|s| s.style == Style::Punctuation));
1263        assert!(d.spans.iter().any(|s| s.style == Style::String));
1264        // Flags field styled as an attribute.
1265        assert!(d.spans.iter().any(|s| s.style == Style::Attribute));
1266        assert!(d.param_hint.is_some());
1267    }
1268
1269    /// A `%`-scoped substitute paints the leading `%` as a range.
1270    #[test]
1271    fn mb4_range_prefix_is_highlighted() {
1272        let reg = fixture();
1273        let d = command_line_decorations("%s/a/b/", &reg);
1274        assert_eq!(d.spans[0].range, 0..1);
1275        assert_eq!(d.spans[0].style, Style::Number);
1276    }
1277
1278    /// An empty line produces no decorations.
1279    #[test]
1280    fn mb4_empty_line_is_bare() {
1281        let reg = fixture();
1282        assert!(command_line_decorations("   ", &reg).is_empty());
1283    }
1284
1285    // ---- Substitute live-preview parser ----
1286
1287    #[test]
1288    fn partial_substitute_with_pattern_only() {
1289        let p = try_parse_substitute_partial("s/foo").unwrap();
1290        assert_eq!(p.scope, SubstitutePartialScope::CurrentLine);
1291        assert_eq!(p.pattern, "foo");
1292        assert_eq!(p.replacement, None);
1293        assert_eq!(p.flags, None);
1294    }
1295
1296    #[test]
1297    fn partial_substitute_with_pattern_and_typed_delimiter() {
1298        // Typed second `/` -- replacement is Some("") even before
1299        // the user types any replacement chars.
1300        let p = try_parse_substitute_partial("s/foo/").unwrap();
1301        assert_eq!(p.pattern, "foo");
1302        assert_eq!(p.replacement.as_deref(), Some(""));
1303        assert_eq!(p.flags, None);
1304    }
1305
1306    #[test]
1307    fn partial_substitute_with_replacement_in_progress() {
1308        let p = try_parse_substitute_partial("s/foo/bar").unwrap();
1309        assert_eq!(p.pattern, "foo");
1310        assert_eq!(p.replacement.as_deref(), Some("bar"));
1311    }
1312
1313    #[test]
1314    fn partial_substitute_with_flags() {
1315        let p = try_parse_substitute_partial("%s/foo/bar/g").unwrap();
1316        assert_eq!(p.scope, SubstitutePartialScope::Whole);
1317        assert_eq!(p.pattern, "foo");
1318        assert_eq!(p.replacement.as_deref(), Some("bar"));
1319        assert_eq!(p.flags.as_deref(), Some("g"));
1320    }
1321
1322    #[test]
1323    fn partial_substitute_rejects_non_substitute_input() {
1324        assert!(try_parse_substitute_partial("write").is_none());
1325        assert!(try_parse_substitute_partial("/foo").is_none());
1326        assert!(try_parse_substitute_partial("g/foo/d").is_none());
1327    }
1328
1329    #[test]
1330    fn partial_substitute_honors_backslash_escape_in_pattern() {
1331        // `\/` is an escaped delimiter -- still part of the pattern.
1332        let p = try_parse_substitute_partial(r"s/foo\/bar").unwrap();
1333        assert_eq!(p.pattern, "foo/bar");
1334        assert_eq!(p.replacement, None);
1335    }
1336
1337    #[test]
1338    fn empty_input_is_empty_error() {
1339        let r = fixture();
1340        assert_eq!(parse("", &r).unwrap_err(), ExCommandError::Empty);
1341        assert_eq!(parse("   ", &r).unwrap_err(), ExCommandError::Empty);
1342    }
1343
1344    #[test]
1345    fn write_short_form_routes_to_registry() {
1346        let r = fixture();
1347        let inv = parse("w", &r).unwrap();
1348        assert_eq!(invocation_name(&inv, &r), "ex:write");
1349        assert!(!inv.bang);
1350        assert_eq!(inv.args, Args::None);
1351    }
1352
1353    #[test]
1354    fn colorscheme_full_and_short_alias_route_to_registry() {
1355        // T.9.b: both `:colorscheme <name>` and the vim short `:colo`
1356        // resolve to `ex:colorscheme` carrying the name as a String arg.
1357        let r = fixture();
1358        let inv = parse("colorscheme catppuccin-macchiato", &r).unwrap();
1359        assert_eq!(invocation_name(&inv, &r), "ex:colorscheme");
1360        assert_eq!(inv.args, Args::String("catppuccin-macchiato".to_string()));
1361        let inv_short = parse("colo catppuccin-mocha", &r).unwrap();
1362        assert_eq!(invocation_name(&inv_short, &r), "ex:colorscheme");
1363        assert_eq!(inv_short.args, Args::String("catppuccin-mocha".to_string()));
1364    }
1365
1366    #[test]
1367    fn colorscheme_without_name_opens_picker() {
1368        // T.12a: the no-arg form no longer errors — it parses to
1369        // `ex:colorscheme` with `Args::None`. The host's
1370        // `Effect::SetColorscheme("")` arm opens the live-preview
1371        // theme picker (see dispatch.rs + the `theme_picker_*` tests).
1372        let r = fixture();
1373        let inv = parse("colorscheme", &r).unwrap();
1374        assert_eq!(invocation_name(&inv, &r), "ex:colorscheme");
1375        assert_eq!(inv.args, Args::None);
1376        let inv_short = parse("colo", &r).unwrap();
1377        assert_eq!(invocation_name(&inv_short, &r), "ex:colorscheme");
1378        assert_eq!(inv_short.args, Args::None);
1379    }
1380
1381    #[test]
1382    fn write_with_path_carries_string_arg() {
1383        let r = fixture();
1384        let inv = parse("w foo.txt", &r).unwrap();
1385        assert_eq!(invocation_name(&inv, &r), "ex:write");
1386        assert_eq!(inv.args, Args::String("foo.txt".into()));
1387    }
1388
1389    #[test]
1390    fn quit_bang_sets_bang_field() {
1391        let r = fixture();
1392        let inv = parse("q!", &r).unwrap();
1393        assert_eq!(invocation_name(&inv, &r), "ex:quit");
1394        assert!(inv.bang);
1395    }
1396
1397    #[test]
1398    fn quit_long_form_alias_resolves() {
1399        let r = fixture();
1400        let inv = parse("quit", &r).unwrap();
1401        assert_eq!(invocation_name(&inv, &r), "ex:quit");
1402    }
1403
1404    #[test]
1405    fn writequit_aliases_collapse_to_one_command() {
1406        let r = fixture();
1407        let wq = parse("wq", &r).unwrap();
1408        let x = parse("x", &r).unwrap();
1409        assert_eq!(wq.command, x.command);
1410        assert_eq!(invocation_name(&wq, &r), "ex:write-quit");
1411    }
1412
1413    #[test]
1414    fn writequit_bang_propagates() {
1415        let r = fixture();
1416        let inv = parse("wq!", &r).unwrap();
1417        assert!(inv.bang);
1418        assert_eq!(invocation_name(&inv, &r), "ex:write-quit");
1419    }
1420
1421    #[test]
1422    fn buffers_and_b_open_the_picker_ls_keeps_the_text_list() {
1423        // `:buffers` and `:b` both open the fuzzy buffer picker (consistent
1424        // with `:files`); `:ls` keeps the static text listing.
1425        let r = fixture();
1426        let buffers = parse("buffers", &r).unwrap();
1427        let b = parse("b", &r).unwrap();
1428        assert_eq!(invocation_name(&buffers, &r), "ex:buffer-picker");
1429        assert_eq!(
1430            buffers.command, b.command,
1431            ":buffers and :b are the same command"
1432        );
1433        assert_eq!(invocation_name(&parse("ls", &r).unwrap(), &r), "ex:buffers");
1434    }
1435
1436    #[test]
1437    fn unknown_command_reports_name() {
1438        let r = fixture();
1439        assert_eq!(
1440            parse("frobnicate", &r).unwrap_err(),
1441            ExCommandError::Unknown("frobnicate".into())
1442        );
1443    }
1444
1445    #[test]
1446    fn bang_on_command_that_does_not_accept_bang_errors() {
1447        let r = fixture();
1448        // `:set!` is not valid -- accepts_bang = false.
1449        assert_eq!(
1450            parse("set!", &r).unwrap_err(),
1451            ExCommandError::BangNotAllowed("set!".into())
1452        );
1453    }
1454
1455    #[test]
1456    fn parse_args_propagates_bad_args_error() {
1457        let r = fixture();
1458        // `:set` with no option string.
1459        let err = parse("set", &r).unwrap_err();
1460        assert!(matches!(err, ExCommandError::BadArgs(_)));
1461    }
1462
1463    #[test]
1464    fn trailing_args_on_no_arg_command_surfaces_bad_args() {
1465        let r = fixture();
1466        // `:q please` -- parse_no_args rejects via BadArgs.
1467        let err = parse("q please", &r).unwrap_err();
1468        assert!(matches!(err, ExCommandError::BadArgs(_)));
1469    }
1470
1471    // ---- §5.2.1 kind-prefix form: every command reachable from `:` ----
1472
1473    #[test]
1474    fn motion_kind_prefix_dispatches_naked() {
1475        let r = fixture();
1476        let inv = parse("motion goto-first-line", &r).unwrap();
1477        assert_eq!(invocation_name(&inv, &r), "motion:goto-first-line");
1478        assert_eq!(inv.target, None);
1479        assert!(matches!(inv.args, Args::None));
1480        assert!(!inv.bang);
1481    }
1482
1483    #[test]
1484    fn motion_kind_prefix_with_trailing_text_errors() {
1485        let r = fixture();
1486        let err = parse("motion goto-first-line nonsense", &r).unwrap_err();
1487        assert!(matches!(err, ExCommandError::TrailingArgs));
1488    }
1489
1490    #[test]
1491    fn motion_kind_prefix_without_name_errors_helpfully() {
1492        let r = fixture();
1493        let err = parse("motion", &r).unwrap_err();
1494        let msg = match err {
1495            ExCommandError::BadArgs(m) => m,
1496            other => panic!("expected BadArgs, got {other:?}"),
1497        };
1498        assert!(msg.contains("requires a name"), "got: {msg}");
1499    }
1500
1501    #[test]
1502    fn kind_prefix_with_unknown_tail_errors_unknown() {
1503        let r = fixture();
1504        let err = parse("motion no-such-thing", &r).unwrap_err();
1505        match err {
1506            ExCommandError::Unknown(name) => {
1507                assert_eq!(name, "motion:no-such-thing");
1508            }
1509            other => panic!("expected Unknown, got {other:?}"),
1510        }
1511    }
1512
1513    #[test]
1514    fn kind_prefix_rejects_bang_on_kind_word() {
1515        let r = fixture();
1516        let err = parse("motion! goto-first-line", &r).unwrap_err();
1517        assert!(matches!(err, ExCommandError::BangNotAllowed(_)));
1518    }
1519
1520    #[test]
1521    fn operator_with_bare_motion_target_resolves_via_implicit_namespace() {
1522        let r = fixture();
1523        // `:operator delete word-forward` -- target tail looked up
1524        // as `motion:word-forward` implicitly.
1525        let inv = parse("operator delete word-forward", &r).unwrap();
1526        assert_eq!(invocation_name(&inv, &r), "operator:delete");
1527        match inv.target {
1528            Some(Target::Motion(_, _)) => {}
1529            other => panic!("expected motion target, got {other:?}"),
1530        }
1531    }
1532
1533    #[test]
1534    fn operator_with_full_canonical_target_also_resolves() {
1535        let r = fixture();
1536        // `:operator delete motion:word-forward` -- canonical form
1537        // also accepted for disambiguation.
1538        let inv = parse("operator delete motion:word-forward", &r).unwrap();
1539        assert_eq!(invocation_name(&inv, &r), "operator:delete");
1540        match inv.target {
1541            Some(Target::Motion(_, _)) => {}
1542            other => panic!("expected motion target, got {other:?}"),
1543        }
1544    }
1545
1546    #[test]
1547    fn operator_with_text_object_target_via_implicit_namespace() {
1548        let r = fixture();
1549        let inv = parse("operator delete inner-word", &r).unwrap();
1550        assert_eq!(invocation_name(&inv, &r), "operator:delete");
1551        match inv.target {
1552            Some(Target::TextObject(_, _)) => {}
1553            other => panic!("expected text-object target, got {other:?}"),
1554        }
1555    }
1556
1557    #[test]
1558    fn operator_without_target_errors_helpfully() {
1559        let r = fixture();
1560        let err = parse("operator delete", &r).unwrap_err();
1561        let msg = match err {
1562            ExCommandError::BadArgs(m) => m,
1563            other => panic!("expected BadArgs, got {other:?}"),
1564        };
1565        assert!(msg.contains("requires a target"), "got: {msg}");
1566        assert!(msg.contains("chord grammar"), "got: {msg}");
1567    }
1568
1569    #[test]
1570    fn operator_with_non_motion_target_errors() {
1571        let r = fixture();
1572        // Pass an ex-command name as target -- not a motion or
1573        // text-object. Implicit namespace tries `motion:ex:write`
1574        // and `text-object:ex:write` (both miss); the canonical
1575        // `ex:write` resolves but fails the kind check.
1576        let err = parse("operator delete ex:write", &r).unwrap_err();
1577        let msg = match err {
1578            ExCommandError::BadArgs(m) => m,
1579            other => panic!("expected BadArgs, got {other:?}"),
1580        };
1581        assert!(msg.contains("not a motion or text-object"), "got: {msg}");
1582    }
1583
1584    #[test]
1585    fn naked_text_object_errors_helpfully() {
1586        let r = fixture();
1587        let err = parse("text-object inner-word", &r).unwrap_err();
1588        let msg = match err {
1589            ExCommandError::BadArgs(m) => m,
1590            other => panic!("expected BadArgs, got {other:?}"),
1591        };
1592        assert!(msg.contains("text-object"), "got: {msg}");
1593        assert!(msg.contains("operator"), "got: {msg}");
1594    }
1595
1596    #[test]
1597    fn ex_command_path_unchanged() {
1598        // Sanity check: the kind-prefix work doesn't disturb the
1599        // ex-command happy path. `:write foo.txt` still resolves.
1600        let r = fixture();
1601        let inv = parse("write foo.txt", &r).unwrap();
1602        assert_eq!(invocation_name(&inv, &r), "ex:write");
1603    }
1604
1605    // ---- Substitute / global: registry-routed delimiter form ----
1606
1607    #[test]
1608    fn substitute_current_line_produces_invocation_with_args_list() {
1609        let r = fixture();
1610        let inv = parse("s/foo/bar/", &r).unwrap();
1611        assert_eq!(invocation_name(&inv, &r), "ex:substitute");
1612        assert_eq!(inv.range, Some(Range::CurrentLine));
1613        let list = inv.args.as_list().expect("expected Args::List");
1614        assert_eq!(list.len(), 3);
1615        assert_eq!(list[0], ArgValue::Pattern("foo".into()));
1616        assert_eq!(list[1], ArgValue::String("bar".into()));
1617        assert_eq!(list[2], ArgValue::String(String::new()));
1618    }
1619
1620    #[test]
1621    fn substitute_whole_buffer_sets_range_whole() {
1622        let r = fixture();
1623        let inv = parse("%s/foo/bar/g", &r).unwrap();
1624        assert_eq!(invocation_name(&inv, &r), "ex:substitute");
1625        assert_eq!(inv.range, Some(Range::Whole));
1626        let list = inv.args.as_list().unwrap();
1627        assert_eq!(list[2], ArgValue::String("g".into()));
1628    }
1629
1630    #[test]
1631    fn substitute_with_escaped_slash_in_pattern() {
1632        let r = fixture();
1633        let inv = parse("s/a\\/b/c/", &r).unwrap();
1634        let list = inv.args.as_list().unwrap();
1635        assert_eq!(list[0], ArgValue::Pattern("a/b".into()));
1636    }
1637
1638    #[test]
1639    fn substitute_empty_pattern_is_error() {
1640        let r = fixture();
1641        assert!(matches!(
1642            parse("s//bar/", &r),
1643            Err(ExCommandError::BadSubstitute(_))
1644        ));
1645    }
1646
1647    #[test]
1648    fn substitute_flags_string_carries_through() {
1649        let r = fixture();
1650        let inv = parse("s/foo/bar/gi", &r).unwrap();
1651        let list = inv.args.as_list().unwrap();
1652        // Apply closure interprets the flag string; the parser just
1653        // hands it through verbatim.
1654        assert_eq!(list[2], ArgValue::String("gi".into()));
1655    }
1656
1657    #[test]
1658    fn global_basic_match_with_delete_body() {
1659        let r = fixture();
1660        let inv = parse("g/foo/d", &r).unwrap();
1661        assert_eq!(invocation_name(&inv, &r), "ex:global");
1662        let list = inv.args.as_list().unwrap();
1663        assert_eq!(list[0], ArgValue::Pattern("foo".into()));
1664        assert_eq!(list[1], ArgValue::Bool(false));
1665        // Body is parsed up front -- arg 2 is an Invocation pointing
1666        // at the resolved `:d` command, not a Raw string.
1667        let body = list[2].as_invocation().expect("body should be parsed");
1668        assert_eq!(invocation_name(body, &r), "ex:delete");
1669    }
1670
1671    #[test]
1672    fn vglobal_inverts_match() {
1673        let r = fixture();
1674        let inv = parse("v/foo/d", &r).unwrap();
1675        let list = inv.args.as_list().unwrap();
1676        assert_eq!(list[1], ArgValue::Bool(true));
1677    }
1678
1679    #[test]
1680    fn global_body_can_be_substitute() {
1681        // Nested delimiter command in the body is parsed up front;
1682        // the resulting Invocation points at `:s` with its own args.
1683        let r = fixture();
1684        let inv = parse("g/foo/s/a/b/g", &r).unwrap();
1685        let list = inv.args.as_list().unwrap();
1686        let body = list[2].as_invocation().expect("body should be parsed");
1687        assert_eq!(invocation_name(body, &r), "ex:substitute");
1688        let body_list = body.args.as_list().unwrap();
1689        assert_eq!(body_list[0], ArgValue::Pattern("a".into()));
1690        assert_eq!(body_list[1], ArgValue::String("b".into()));
1691        assert_eq!(body_list[2], ArgValue::String("g".into()));
1692    }
1693
1694    #[test]
1695    fn global_with_unparseable_body_errors_at_parse_time() {
1696        // The body must parse against the registry; an unknown
1697        // command surfaces immediately, not after `:g` matches.
1698        let r = fixture();
1699        let result = parse("g/foo/this-is-not-a-real-command", &r);
1700        assert!(
1701            matches!(result, Err(ExCommandError::Unknown(_))),
1702            "expected Unknown error, got {result:?}"
1703        );
1704    }
1705
1706    #[test]
1707    fn global_with_empty_body_errors_at_parse_time() {
1708        let r = fixture();
1709        let result = parse("g/foo/", &r);
1710        assert!(matches!(result, Err(ExCommandError::BadSubstitute(_))));
1711    }
1712
1713    #[test]
1714    fn nohlsearch_aliases_route_to_one_command() {
1715        let r = fixture();
1716        for s in ["noh", "nohl", "nohlsearch"] {
1717            let inv = parse(s, &r).unwrap();
1718            assert_eq!(invocation_name(&inv, &r), "ex:nohlsearch", "alias `{s}`");
1719        }
1720    }
1721
1722    #[test]
1723    fn registers_aliases_route_to_one_command() {
1724        let r = fixture();
1725        for s in ["reg", "registers"] {
1726            let inv = parse(s, &r).unwrap();
1727            assert_eq!(invocation_name(&inv, &r), "ex:registers");
1728        }
1729    }
1730
1731    #[test]
1732    fn edit_with_path() {
1733        let r = fixture();
1734        let inv = parse("e foo.txt", &r).unwrap();
1735        assert_eq!(invocation_name(&inv, &r), "ex:edit");
1736        assert_eq!(inv.args, Args::String("foo.txt".into()));
1737        assert!(!inv.bang);
1738    }
1739
1740    #[test]
1741    fn edit_force_with_bang() {
1742        let r = fixture();
1743        let inv = parse("e! /tmp/x", &r).unwrap();
1744        assert_eq!(invocation_name(&inv, &r), "ex:edit");
1745        assert!(inv.bang);
1746        assert_eq!(inv.args, Args::String("/tmp/x".into()));
1747    }
1748
1749    #[test]
1750    fn edit_without_path_is_reload() {
1751        let r = fixture();
1752        let inv = parse("e", &r).unwrap();
1753        assert_eq!(invocation_name(&inv, &r), "ex:edit");
1754        assert_eq!(inv.args, Args::None);
1755    }
1756
1757    #[test]
1758    fn set_with_option_parses() {
1759        let r = fixture();
1760        let inv = parse("set number", &r).unwrap();
1761        assert_eq!(invocation_name(&inv, &r), "ex:set");
1762        assert_eq!(inv.args, Args::String("number".into()));
1763    }
1764
1765    #[test]
1766    fn set_short_alias_with_value() {
1767        let r = fixture();
1768        let inv = parse("set nu", &r).unwrap();
1769        assert_eq!(inv.args, Args::String("nu".into()));
1770    }
1771
1772    #[test]
1773    fn marks_routes_to_registry() {
1774        let r = fixture();
1775        let inv = parse("marks", &r).unwrap();
1776        assert_eq!(invocation_name(&inv, &r), "ex:marks");
1777    }
1778
1779    #[test]
1780    fn delete_short_form_routes_to_registry() {
1781        let r = fixture();
1782        for s in ["d", "delete"] {
1783            let inv = parse(s, &r).unwrap();
1784            assert_eq!(invocation_name(&inv, &r), "ex:delete");
1785        }
1786    }
1787
1788    #[test]
1789    fn whitespace_around_command_is_tolerated() {
1790        let r = fixture();
1791        let inv = parse("  w  ", &r).unwrap();
1792        assert_eq!(invocation_name(&inv, &r), "ex:write");
1793        let inv = parse("\t w foo.rs \t", &r).unwrap();
1794        assert_eq!(invocation_name(&inv, &r), "ex:write");
1795        assert_eq!(inv.args, Args::String("foo.rs".into()));
1796    }
1797
1798    #[test]
1799    fn motion_name_in_registry_is_not_an_ex_command() {
1800        // `motion:word-forward` is registered but is not an ex-command;
1801        // it must surface as Unknown when typed at the `:` line. (No
1802        // alias maps to it -- this guards against a future regression
1803        // where someone adds `motion:word-forward` as an alias target.)
1804        let r = fixture();
1805        let id = r.id_by_name("motion:word-forward").unwrap();
1806        let entry = r.lookup(id).unwrap();
1807        assert_eq!(entry.kind, CommandKind::Motion);
1808    }
1809
1810    #[test]
1811    fn aliases_table_is_self_consistent() {
1812        // Every alias points at a name registered in the registry.
1813        let r = fixture();
1814        for (short, canonical) in aliases() {
1815            assert!(
1816                r.id_by_name(canonical).is_some(),
1817                "alias `{short}` -> `{canonical}` not registered"
1818            );
1819        }
1820    }
1821
1822    /// Regression: every picker entry-point ex-command is
1823    /// invocable through its user-facing name. The grammar
1824    /// registers the canonical `ex:*` form; without an alias
1825    /// row the parser rejects `:picker` / `:files` /
1826    /// `:recent` as "unknown command". This walks the
1827    /// expected user-facing surface end-to-end through `parse`
1828    /// so a missing alias row surfaces in CI rather than at
1829    /// runtime.
1830    #[test]
1831    fn picker_commands_resolve_through_user_facing_names() {
1832        let r = fixture();
1833        for line in ["picker files", "files", "recent"] {
1834            let result = parse(line, &r);
1835            assert!(
1836                result.is_ok(),
1837                "expected `:{line}` to parse, got {:?}",
1838                result.err()
1839            );
1840        }
1841    }
1842
1843    /// Every `ex:*` command the grammar registers is reachable from the `:`
1844    /// line. The parser has no `ex:<typed>` fallback — a command with no
1845    /// alias row answers "unknown command" however it is spelled, which is
1846    /// how `:Oil` (documented in `ex-commands.md` and `oil-mode.md`), `:format`
1847    /// and `:reload-snippets` all shipped dead. `:s/` and `:g/` are
1848    /// delimiter-form and reached by their own parsers, not the table.
1849    #[test]
1850    fn every_ex_command_has_a_typed_name() {
1851        let r = fixture();
1852        let table = aliases();
1853        let delimiter_form = ["ex:substitute", "ex:global"];
1854        let mut missing: Vec<&str> = r
1855            .names()
1856            .filter(|n| n.starts_with("ex:") && !delimiter_form.contains(n))
1857            .filter(|n| !table.values().any(|c| c == n))
1858            .collect();
1859        missing.sort_unstable();
1860        assert!(
1861            missing.is_empty(),
1862            "ex-commands with no `:` spelling: {missing:?}"
1863        );
1864    }
1865
1866    #[test]
1867    fn oil_resolves_in_both_spellings() {
1868        let r = fixture();
1869        for line in ["oil", "Oil", "oil /tmp", "Oil /tmp"] {
1870            assert!(parse(line, &r).is_ok(), "expected `:{line}` to parse");
1871        }
1872    }
1873
1874    #[test]
1875    fn substitute_and_global_register_with_args_schema() {
1876        // §B.1: ex:substitute and ex:global advertise their structured
1877        // shape via args_schema. A future :describe-command consumes
1878        // this; in v1 it's used by tests as a smoke check.
1879        let r = fixture();
1880        let sub_id = r.id_by_name("ex:substitute").unwrap();
1881        let sub = r.lookup(sub_id).unwrap();
1882        assert_eq!(sub.args_schema.len(), 3);
1883        assert_eq!(sub.args_schema[0].name, "pattern");
1884        assert_eq!(sub.args_schema[1].name, "replacement");
1885        assert_eq!(sub.args_schema[2].name, "flags");
1886
1887        let global_id = r.id_by_name("ex:global").unwrap();
1888        let global = r.lookup(global_id).unwrap();
1889        assert_eq!(global.args_schema.len(), 3);
1890        assert_eq!(global.args_schema[0].name, "pattern");
1891        assert_eq!(global.args_schema[1].name, "inverted");
1892        assert_eq!(global.args_schema[2].name, "body");
1893    }
1894
1895    #[test]
1896    fn keyword_form_of_substitute_returns_wrong_surface_form_error() {
1897        // The user types `:ex:substitute foo`. Surface-form check
1898        // fires before parse_args; the error names the canonical
1899        // command and the syntax to use.
1900        let r = fixture();
1901        let err = parse("ex:substitute foo bar", &r).unwrap_err();
1902        match err {
1903            ExCommandError::WrongSurfaceForm { name, hint } => {
1904                assert_eq!(name, "ex:substitute");
1905                assert!(hint.contains(":s/"));
1906            }
1907            other => panic!("expected WrongSurfaceForm, got {other:?}"),
1908        }
1909    }
1910
1911    #[test]
1912    fn keyword_form_of_global_returns_wrong_surface_form_error() {
1913        let r = fixture();
1914        let err = parse("ex:global //d", &r).unwrap_err();
1915        match err {
1916            ExCommandError::WrongSurfaceForm { name, hint } => {
1917                assert_eq!(name, "ex:global");
1918                assert!(hint.contains(":g/"));
1919                assert!(hint.contains(":v/"));
1920            }
1921            other => panic!("expected WrongSurfaceForm, got {other:?}"),
1922        }
1923    }
1924
1925    #[test]
1926    fn delimiter_form_of_substitute_still_parses() {
1927        // Surface-form gating must NOT break the front-end delimiter
1928        // path -- the gate fires only for the keyword route.
1929        let r = fixture();
1930        let inv = parse("s/foo/bar/", &r).unwrap();
1931        assert_eq!(invocation_name(&inv, &r), "ex:substitute");
1932    }
1933
1934    #[test]
1935    fn delimiter_form_of_global_still_parses() {
1936        let r = fixture();
1937        let inv = parse("g/foo/d", &r).unwrap();
1938        assert_eq!(invocation_name(&inv, &r), "ex:global");
1939    }
1940
1941    #[test]
1942    fn bare_integer_routes_to_goto_last_line_with_count() {
1943        let r = fixture();
1944        let inv = parse("42", &r).unwrap();
1945        assert_eq!(invocation_name(&inv, &r), "motion:goto-last-line");
1946        let count = inv.count.expect("bare integer must carry explicit count");
1947        assert_eq!(count.get(), 42);
1948    }
1949
1950    #[test]
1951    fn bare_integer_one_routes_to_goto_last_line_with_count_one() {
1952        let r = fixture();
1953        let inv = parse("1", &r).unwrap();
1954        assert_eq!(invocation_name(&inv, &r), "motion:goto-last-line");
1955        assert_eq!(inv.count.unwrap().get(), 1);
1956    }
1957}