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}