Skip to main content

lattice_grammar/
command.rs

1//! `CommandInvocation` -- the unified call type that flows through the
2//! dispatcher (DESIGN.md §5.2.1).
3//!
4//! Vim ex-syntax (the `:` parser front-end), keymap chord resolution, command
5//! palette selection, and plugin-to-plugin calls all produce values of this
6//! shape. The dispatcher's `execute()` consumes them.
7//!
8//! An invocation is plain data: `Clone`, serializable, and free of
9//! borrowed state, so it can be recorded (macros record invocations, not
10//! keystrokes), replayed (`.`), sent across the core protocol, or built by
11//! a plugin. It names its command by [`CommandId`], which is only
12//! meaningful against the [`CommandRegistry`](crate::CommandRegistry) that
13//! minted it.
14//!
15//! # Examples
16//!
17//! The slots of `"a3dd` (register `a`, count 3, the delete operator over
18//! the current line), filled by hand:
19//!
20//! ```
21//! use lattice_grammar::{CommandInvocation, CommandRegistry, Count, Range, Register, builtins};
22//!
23//! let mut registry = CommandRegistry::new();
24//! let b = builtins::populate(&mut registry);
25//!
26//! let inv = CommandInvocation::of(b.delete.0)
27//!     .with_count(Count(3))
28//!     .with_register(Register::Named('a'))
29//!     .with_range(Range::CurrentLine);
30//!
31//! assert_eq!(inv.count_or_default().get(), 3);
32//! assert_eq!(inv.register_or_default(), Register::Named('a'));
33//!
34//! // Unset slots fall back to vim's defaults.
35//! let bare = CommandInvocation::of(b.delete.0);
36//! assert_eq!(bare.count, None);
37//! assert_eq!(bare.count_or_default(), Count::ONE);
38//! assert_eq!(bare.register_or_default(), Register::Unnamed);
39//! ```
40
41use serde::{Deserialize, Serialize};
42
43use lattice_protocol::ids::CommandId;
44
45use crate::args::Args;
46use crate::range::Range;
47use crate::register::Register;
48use crate::target::Target;
49
50/// A vim count prefix: the `3` in `3dw` or `3j`. Each command decides what
51/// it multiplies; an absent count is [`Count::ONE`] (the `Default`), but
52/// [`CommandInvocation::count`] keeps `None` distinct so a command that
53/// cares (`G` vs `5G`) can tell "no count" from "count 1".
54#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
55#[serde(transparent)]
56pub struct Count(pub u32);
57
58impl Count {
59    /// The implicit count of a bare command.
60    pub const ONE: Count = Count(1);
61
62    /// The raw value. Evaluators typically use `get().max(1)` to treat a
63    /// stray `0` as 1.
64    pub fn get(self) -> u32 {
65        self.0
66    }
67}
68
69impl Default for Count {
70    fn default() -> Self {
71        Count::ONE
72    }
73}
74
75/// One call of one command, with every grammar slot vim can fill: the
76/// unified call type every front-end produces and
77/// [`execute`](crate::execute) consumes (DESIGN.md §5.2.1).
78///
79/// A chord (`"a3dw`), a `:` line (`:%s/a/b/g`), a palette pick, a macro
80/// replay and a plugin call all become one of these. The dispatcher looks
81/// `command` up in the registry and routes by its [`CommandKind`]; each
82/// kind reads the slots it understands and ignores the rest (a motion
83/// ignores `register`, an action ignores `range` and `target`).
84///
85/// Build with [`Self::of`] plus the `with_*` builders. The value owns
86/// everything it holds and borrows nothing, so it can outlive the keystroke
87/// that produced it.
88#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
89pub struct CommandInvocation {
90    /// The command to run. Must have been minted by the registry the
91    /// invocation is dispatched against, or dispatch fails with
92    /// [`CommandError::UnknownCommand`](crate::CommandError::UnknownCommand).
93    pub command: CommandId,
94    /// The count prefix, if one was typed. `None` and `Some(Count(1))`
95    /// differ: see [`Count`].
96    pub count: Option<Count>,
97    /// The `"x` register prefix, if one was typed; `None` means the
98    /// unnamed register.
99    pub register: Option<Register>,
100    /// An explicit grammar range (`:%`, `:1,5`, the Visual selection).
101    /// When an operator has both, the range wins over [`Self::target`].
102    pub range: Option<Range>,
103    /// What an operator acts on: a motion, text object or range. Unused by
104    /// other kinds.
105    pub target: Option<Target>,
106    /// Command-specific arguments: the char of `f{char}`, the path of
107    /// `:w path`, the parsed fields of `:s/…/…/`.
108    pub args: Args,
109    /// Trailing `!` on the ex-syntax form (`:q!`, `:w!`, `:e!`). Carried
110    /// out of the parser into the dispatcher; meaningless for non-ex
111    /// invocations and ignored by motion / operator / text-object
112    /// dispatch.
113    #[serde(default)]
114    pub bang: bool,
115}
116
117impl CommandInvocation {
118    /// A bare invocation of `command`: no count, register, range or
119    /// target; [`Args::None`]; no bang.
120    pub fn of(command: CommandId) -> Self {
121        Self {
122            command,
123            count: None,
124            register: None,
125            range: None,
126            target: None,
127            args: Args::None,
128            bang: false,
129        }
130    }
131
132    /// Set the count prefix.
133    pub fn with_count(mut self, count: Count) -> Self {
134        self.count = Some(count);
135        self
136    }
137
138    /// Set the register prefix.
139    pub fn with_register(mut self, register: Register) -> Self {
140        self.register = Some(register);
141        self
142    }
143
144    /// Set an explicit range.
145    pub fn with_range(mut self, range: Range) -> Self {
146        self.range = Some(range);
147        self
148    }
149
150    /// Set the operator target.
151    pub fn with_target(mut self, target: Target) -> Self {
152        self.target = Some(target);
153        self
154    }
155
156    /// Replace the arguments.
157    pub fn with_args(mut self, args: Args) -> Self {
158        self.args = args;
159        self
160    }
161
162    /// Set the trailing-`!` bit (ex-commands only).
163    pub fn with_bang(mut self, bang: bool) -> Self {
164        self.bang = bang;
165        self
166    }
167
168    /// The count, or [`Count::ONE`] when none was typed.
169    pub fn count_or_default(&self) -> Count {
170        self.count.unwrap_or_default()
171    }
172
173    /// The register, or the unnamed register when none was typed.
174    pub fn register_or_default(&self) -> Register {
175        self.register.unwrap_or_default()
176    }
177}
178
179/// What kind of command an entry in the registry is. Determines how the
180/// dispatcher resolves the invocation.
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
182pub enum CommandKind {
183    /// Acts on a span (`d`, `c`, `y`, `gU`): resolves the invocation's
184    /// target or range, then runs over it. See
185    /// [`OperatorSpec`](crate::OperatorSpec).
186    Operator,
187    /// Computes a new cursor position (`w`, `j`, `G`); also usable as an
188    /// operator target. See [`MotionSpec`](crate::MotionSpec).
189    Motion,
190    /// Selects a span around the cursor (`iw`, `a(`); an operator target or
191    /// a Visual selection. See [`TextObjectSpec`](crate::TextObjectSpec).
192    TextObject,
193    /// Reached from the `:` line; parses its own argument string. See
194    /// [`ExCommandSpec`](crate::ExCommandSpec).
195    ExCommand,
196    /// A free-form command with no grammar role — most chord bindings that
197    /// are not motions or operators (`K` for LSP hover, fold cycling). See
198    /// [`ActionSpec`](crate::ActionSpec).
199    Action,
200}
201
202impl CommandKind {
203    /// Kebab-case name used in help views and completion annotations:
204    /// `"operator"`, `"motion"`, `"text-object"`, `"ex-command"`, `"action"`.
205    pub fn label(self) -> &'static str {
206        match self {
207            CommandKind::Operator => "operator",
208            CommandKind::Motion => "motion",
209            CommandKind::TextObject => "text-object",
210            CommandKind::ExCommand => "ex-command",
211            CommandKind::Action => "action",
212        }
213    }
214
215    /// Single-glyph marker for completion menus and help headings; agrees
216    /// with [`kind_icon`] on [`Self::label`].
217    pub fn icon(self) -> char {
218        match self {
219            CommandKind::ExCommand => ':',
220            CommandKind::Motion => '→',
221            CommandKind::Operator => '~',
222            CommandKind::TextObject => '…',
223            CommandKind::Action => '·',
224        }
225    }
226}
227
228/// Map a kind-label string to its display icon. Covers all labels
229/// emitted by `KindLabelAnnotator` and `Introspectable::kind_label`.
230/// Returns `·` for unknown labels.
231pub fn kind_icon(label: &str) -> &'static str {
232    match label {
233        "ex-command" => ":",
234        "motion" => "→",
235        "operator" => "~",
236        "text-object" => "…",
237        "action" => "·",
238        "command" => "·",
239        "option" => "=",
240        "file" => "f",
241        "directory" => "d",
242        "pattern" => "/",
243        "buffer" => "b",
244        "register" => "\"",
245        "mark" => "'",
246        "chord" => "@",
247        "plugin" => "+",
248        "plugin-api" => "+",
249        "major" => "◆",
250        "minor" => "◇",
251        "stub" => "·",
252        "doc" => "·",
253        _ => "·",
254    }
255}
256
257/// How the runtime should schedule a command, and what budget the CI
258/// test harness will eventually enforce on it (DESIGN.md §5.2.5).
259///
260/// **v1 status: declarative only.** Every spec carries a class and
261/// `:describe-command` surfaces it. The runtime infrastructure that
262/// actually enforces these budgets (cancellation tokens; deadline
263/// timers; bench-time per-class p99 assertions) lands together with
264/// the §5.10 event-bus and the cancellation-token contract. Adding
265/// the field now means hundreds of registrations don't have to be
266/// retrofitted later.
267#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
268pub enum LatencyClass {
269    /// Single-stroke editing primitive: cursor motion, char insert,
270    /// mode entry, simple delete, scroll. Sync `Effect` must commit
271    /// within the keystroke budget (`<2ms p99`). The default for
272    /// motions, operators, text objects, and small ex-commands.
273    #[default]
274    Reflex,
275    /// UI affordance whose sync prelude must *appear* immediately
276    /// (`<10ms p99`) but whose data may arrive later via events:
277    /// completion popup, picker, hover, status segment. The
278    /// `:describe-*` family of help views fits here.
279    Display,
280    /// No user-perceived sync budget. File-watcher tick, indexer
281    /// pass, plugin housekeeping, LSP debounce. Throughput-only.
282    Background,
283}
284
285impl LatencyClass {
286    /// Lower-case name: `"reflex"`, `"display"`, `"background"`.
287    pub fn label(self) -> &'static str {
288        match self {
289            LatencyClass::Reflex => "reflex",
290            LatencyClass::Display => "display",
291            LatencyClass::Background => "background",
292        }
293    }
294
295    /// Human-readable budget string for `:describe-command`
296    /// rendering. Values come straight from DESIGN.md §5.2.5.
297    pub fn budget_label(self) -> &'static str {
298        match self {
299            LatencyClass::Reflex => "<2ms p99",
300            LatencyClass::Display => "<10ms p99 sync prelude",
301            LatencyClass::Background => "throughput-only",
302        }
303    }
304}
305
306/// Metadata + the actual implementation of a registered command. Stored in
307/// the `CommandRegistry`.
308#[derive(Debug, Clone)]
309pub struct CommandSpec {
310    /// The id the registry minted at registration; unique per process.
311    pub id: CommandId,
312    /// The canonical, namespaced name (`motion:word-forward`,
313    /// `operator:delete`, `ex:write`, `action:…`). The key for
314    /// [`CommandRegistry::id_by_name`](crate::CommandRegistry::id_by_name);
315    /// user-typed aliases are resolved to it by the front-end.
316    pub name: String,
317    /// Which dispatcher path handles it.
318    pub kind: CommandKind,
319    /// The help text shown by `:describe-command`, `:apropos` and
320    /// completion.
321    pub doc: String,
322    /// Per-positional-argument metadata (DESIGN.md §B.1). Lifted from
323    /// the per-kind spec (`MotionSpec.args_schema`,
324    /// `ExCommandSpec.args_schema`, ...) at registration time so callers
325    /// can introspect arg shape without knowing the registration kind.
326    /// Used by `:describe-command`, palette form rendering, and missing-
327    /// arg prompts.
328    pub args_schema: Vec<crate::args::ArgSpec>,
329    /// Where this command was registered (DESIGN.md §5.11). Captured
330    /// via `#[track_caller]` for built-ins, by the plugin host for
331    /// plugin-registered commands, by the config loader for
332    /// user-registered commands. The field is `pub` for read access
333    /// (introspection), but the only writers are the trusted
334    /// `pub(crate) insert_*` registry methods -- there is no public
335    /// API that takes a `SourceLocation` parameter.
336    pub source: crate::source::SourceLocation,
337    /// Latency class declaration (DESIGN.md §5.2.5). Surfaced by
338    /// `:describe-command`; future cancellation / deadline
339    /// machinery reads this to set per-call budgets. v1 is purely
340    /// declarative -- no runtime enforcement yet.
341    pub latency_class: LatencyClass,
342}
343
344impl crate::introspect::Introspectable for CommandSpec {
345    fn kind_label(&self) -> &'static str {
346        self.kind.label()
347    }
348
349    fn identifier(&self) -> String {
350        self.name.clone()
351    }
352
353    fn doc(&self) -> &str {
354        &self.doc
355    }
356
357    fn sources(&self) -> Vec<crate::introspect::SourceEntry<'_>> {
358        vec![crate::introspect::SourceEntry {
359            label: crate::introspect::SourceLabel::DefinedAt,
360            source: &self.source,
361        }]
362    }
363
364    fn extra_sections(&self) -> Vec<crate::introspect::HelpSection> {
365        let mut sections = Vec::new();
366        // Latency class declaration (DESIGN.md §5.2.5). Surfaced
367        // in describe-command so users can see the budget the
368        // runtime treats this command under.
369        sections.push(crate::introspect::HelpSection {
370            heading: "Latency:".to_string(),
371            lines: vec![format!(
372                "       {}  ({})",
373                self.latency_class.label(),
374                self.latency_class.budget_label()
375            )],
376            anchor: Some("latency".to_string()),
377        });
378        if !self.args_schema.is_empty() {
379            // Two-tiered render: a parent "Arguments:" section
380            // anchored as "args", then one subsection per arg
381            // anchored as "arg:<name>". `<C-h>` on the cmdline
382            // jumps directly to the relevant `arg:<name>` (DESIGN.md
383            // §5.11.1 + §5.11.3 arg-aware help).
384            sections.push(crate::introspect::HelpSection {
385                heading: "Arguments:".to_string(),
386                lines: Vec::new(),
387                anchor: Some("args".to_string()),
388            });
389            for (i, arg) in self.args_schema.iter().enumerate() {
390                let default = match &arg.default {
391                    crate::args::ArgDefault::Required => "required".to_string(),
392                    crate::args::ArgDefault::None => "optional".to_string(),
393                    crate::args::ArgDefault::Literal(_) => "default".to_string(),
394                    crate::args::ArgDefault::UseSelection => "default: selection".to_string(),
395                    crate::args::ArgDefault::UseCursorWord => "default: cursor word".to_string(),
396                    crate::args::ArgDefault::UseLastResponse => "default: last value".to_string(),
397                };
398                let mut lines = Vec::with_capacity(2);
399                if !arg.doc.is_empty() {
400                    lines.push(format!("       {}", arg.doc));
401                }
402                sections.push(crate::introspect::HelpSection {
403                    heading: format!("  {}. {}: {:?}  ({})", i + 1, arg.name, arg.kind, default),
404                    lines,
405                    anchor: Some(format!("arg:{}", arg.name)),
406                });
407            }
408        }
409        sections
410    }
411}
412
413#[cfg(test)]
414mod tests {
415    #![allow(clippy::unwrap_used, clippy::panic)]
416    use super::*;
417
418    #[test]
419    fn count_default_is_one() {
420        assert_eq!(Count::default(), Count::ONE);
421        assert_eq!(Count::default().get(), 1);
422    }
423
424    #[test]
425    fn invocation_builder_sets_each_field() {
426        let id = CommandId::new(1);
427        let inv = CommandInvocation::of(id)
428            .with_count(Count(3))
429            .with_register(Register::Named('a'))
430            .with_range(Range::Whole)
431            .with_args(Args::Char('q'));
432        assert_eq!(inv.command, id);
433        assert_eq!(inv.count, Some(Count(3)));
434        assert_eq!(inv.register, Some(Register::Named('a')));
435        assert_eq!(inv.range, Some(Range::Whole));
436        assert_eq!(inv.args, Args::Char('q'));
437    }
438
439    #[test]
440    fn count_or_default_returns_one_when_unset() {
441        let inv = CommandInvocation::of(CommandId::new(1));
442        assert_eq!(inv.count_or_default(), Count::ONE);
443    }
444
445    #[test]
446    fn register_or_default_returns_unnamed_when_unset() {
447        let inv = CommandInvocation::of(CommandId::new(1));
448        assert_eq!(inv.register_or_default(), Register::Unnamed);
449    }
450
451    #[test]
452    fn command_kind_labels() {
453        assert_eq!(CommandKind::Operator.label(), "operator");
454        assert_eq!(CommandKind::Motion.label(), "motion");
455        assert_eq!(CommandKind::TextObject.label(), "text-object");
456        assert_eq!(CommandKind::ExCommand.label(), "ex-command");
457        assert_eq!(CommandKind::Action.label(), "action");
458    }
459
460    #[test]
461    fn command_kind_icons() {
462        assert_eq!(CommandKind::ExCommand.icon(), ':');
463        assert_eq!(CommandKind::Motion.icon(), '→');
464        assert_eq!(CommandKind::Operator.icon(), '~');
465        assert_eq!(CommandKind::TextObject.icon(), '…');
466        assert_eq!(CommandKind::Action.icon(), '·');
467    }
468
469    #[test]
470    fn kind_icon_maps_all_labels() {
471        assert_eq!(kind_icon("ex-command"), ":");
472        assert_eq!(kind_icon("motion"), "→");
473        assert_eq!(kind_icon("operator"), "~");
474        assert_eq!(kind_icon("text-object"), "…");
475        assert_eq!(kind_icon("action"), "·");
476        assert_eq!(kind_icon("command"), "·");
477        assert_eq!(kind_icon("option"), "=");
478        assert_eq!(kind_icon("file"), "f");
479        assert_eq!(kind_icon("directory"), "d");
480        assert_eq!(kind_icon("pattern"), "/");
481        assert_eq!(kind_icon("buffer"), "b");
482        assert_eq!(kind_icon("register"), "\"");
483        assert_eq!(kind_icon("mark"), "'");
484        assert_eq!(kind_icon("chord"), "@");
485        assert_eq!(kind_icon("plugin"), "+");
486        assert_eq!(kind_icon("plugin-api"), "+");
487        assert_eq!(kind_icon("major"), "◆");
488        assert_eq!(kind_icon("minor"), "◇");
489        assert_eq!(kind_icon("stub"), "·");
490        assert_eq!(kind_icon("doc"), "·");
491        assert_eq!(kind_icon("unknown"), "·");
492    }
493}