lattice_grammar/registry.rs
1//! The `CommandRegistry` holds every registered operator, motion, text
2//! object, and ex-command. The dispatcher (`super::dispatcher::execute`)
3//! looks up commands here.
4//!
5//! Built-in commands are registered at editor startup via `populate_builtins`
6//! (see `super::builtins`). Plugins register their own through the same
7//! `register_*` methods. v1 keeps these as native Rust closures; the WASM
8//! plugin host (Phase 7) wraps the same shape.
9
10use std::collections::HashMap;
11use std::sync::Arc;
12use std::sync::atomic::{AtomicU64, Ordering};
13
14use serde::{Deserialize, Serialize};
15
16use lattice_core::Buffer;
17use lattice_core::BufferId;
18use lattice_core::Document;
19use lattice_protocol::ids::CommandId;
20use lattice_protocol::position::{Position, Range as ProtoRange};
21
22use crate::args::{ArgSpec, Args};
23use crate::command::{CommandKind, CommandSpec, Count};
24use crate::error::{CommandError, GrammarResult};
25use crate::register::Register;
26use crate::source::{SourceKind, SourceLayer, SourceLocation};
27
28/// Strongly-typed handle to an operator command in the registry.
29#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
30#[serde(transparent)]
31pub struct OperatorId(pub CommandId);
32
33/// Strongly-typed handle to a motion command in the registry.
34#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
35#[serde(transparent)]
36pub struct MotionId(pub CommandId);
37
38/// Strongly-typed handle to a text-object command in the registry.
39#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
40#[serde(transparent)]
41pub struct TextObjectId(pub CommandId);
42
43/// Strongly-typed handle to an ex-command in the registry.
44#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
45#[serde(transparent)]
46pub struct ExCommandId(pub CommandId);
47
48/// Strongly-typed handle to a custom range source (plugin-registered) used by
49/// `Range::Custom(RangeId)`.
50#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
51#[serde(transparent)]
52pub struct RangeId(pub CommandId);
53
54/// Context passed to a motion's evaluator.
55///
56/// **M.2.b.0.A (2026-05-31):** `buffer_id` carries the active
57/// buffer's registry identity through to motion handlers. Built-
58/// in motions (purely content-based: `w` / `e` / `]p` / etc.)
59/// ignore it; kind-specific motions (multibuffer `]e` / `[e` /
60/// `]E` / `[E`, future file-tree / oil structural motions, future
61/// plugin-defined kinds) use it to look up the active major
62/// mode's typed state through a service registry their handler
63/// closure captures at registration time. Adding it here keeps
64/// the grammar layer free of lattice-mode / ServiceRegistry
65/// coupling — the handler decides what to look up.
66pub struct MotionContext<'a> {
67 /// The buffer text the motion reads. Motions never edit.
68 pub buffer: &'a Buffer,
69 /// Registry-level identity of the active buffer this motion
70 /// is firing against. Distinct from `lattice_protocol::ids::
71 /// DocumentId` (per-actor stable id); `BufferId` is the
72 /// registry key that mode-state lookups use.
73 pub buffer_id: BufferId,
74 /// Where the motion starts: the cursor, or the Visual head.
75 pub from: Position,
76 /// The count, `1` when none was typed; see [`Self::has_explicit_count`].
77 pub count: Count,
78 /// True when the invocation carried an explicit count (e.g.
79 /// `5G`). False for bare invocations (`G` alone). Motions
80 /// whose semantic changes with an explicit count (goto-last-
81 /// line: last vs. specific line) use this to disambiguate.
82 pub has_explicit_count: bool,
83 /// The motion's own arguments, e.g. [`Args::Char`] for `f{char}` or a
84 /// mark name for `'x`.
85 pub args: Args,
86 /// Cooperative cancellation handle (DESIGN.md §5.2.5). Hot
87 /// loops should poll `cancel.check()?` on each iteration; on a
88 /// flipped token the evaluator returns
89 /// [`crate::CommandError::Cancelled`] and the dispatcher
90 /// commits no effect.
91 pub cancel: &'a crate::CancellationToken,
92 /// N.1.4-motions: the active buffer's tree-sitter resolver for structural
93 /// motions (`]f`/`[c`/…). `None` on Plain buffers with no parse — the
94 /// motion then no-ops. Threaded by the host, identical to `TextObjectContext`.
95 pub scope_resolver: Option<&'a dyn ScopeResolver>,
96 /// OM.6b: the file the motion's buffer is backed by, so a plugin motion's
97 /// `document` handle can answer `path()`. A borrow — this context is
98 /// already borrowing, so carrying it costs nothing on the keystroke path
99 /// (motions fire on every `j`). `None` for a buffer with no file.
100 pub path: Option<&'a std::path::Path>,
101 /// OT.1: the same type-erased tree-sitter snapshot [`ActionContext::syntax`]
102 /// carries, so a plugin motion can resolve structurally instead of scanning
103 /// text. Copied straight from [`GrammarEnv::syntax`], which the host has
104 /// always threaded here — until OT.1 motions simply did not read it.
105 ///
106 /// **Borrowed, not cloned**, unlike the owned `ActionContext` peer: motions
107 /// fire on every `j`, so a native motion (which ignores this) must pay
108 /// nothing, and only a plugin motion that actually mints a `tree-snapshot`
109 /// resource pays the `Arc` bump. Same reasoning as `path` above.
110 ///
111 /// Acquired the same instant as `buffer`, so tree and text versions agree
112 /// (`plugin-treesitter-seam.md` §7). `None` when the buffer has no parse.
113 pub syntax: Option<&'a Arc<dyn std::any::Any + Send + Sync>>,
114 /// VM.3c: the last `f` / `F` / `t` / `T`, copied from
115 /// [`GrammarEnv::last_find`]. Read by `motion:find-repeat` and its
116 /// reverse; every other motion ignores it. `Copy`, so carrying it costs
117 /// nothing on the keystroke path.
118 pub last_find: Option<LastFind>,
119 /// VM.3i: the fold edges `zj` / `zk` step between, copied from
120 /// [`GrammarEnv::fold_resolver`]. Borrowed, so a motion that ignores it
121 /// pays nothing.
122 pub fold_resolver: Option<&'a dyn FoldResolver>,
123 /// VM.3d-2: the search `n` / `N` / `*` / `#` repeat, copied from
124 /// [`GrammarEnv::last_search`]. Borrowed; every other motion ignores it.
125 pub last_search: Option<&'a LastSearch>,
126 /// VM.3e: the marks `'x` / `` `x `` jump to, copied from
127 /// [`GrammarEnv::marks`]. Borrowed; every other motion ignores it.
128 pub marks: Option<&'a dyn MarkResolver>,
129 /// VM.3f: the window's lines for `H` / `M` / `L`, copied from
130 /// [`GrammarEnv::viewport`].
131 pub viewport: Option<&'a dyn ViewportResolver>,
132 /// VM.3f: vim's `nostartofline` — `H` / `M` / `L` keep the cursor's column
133 /// instead of landing on the first non-blank.
134 pub nostartofline: bool,
135 /// VM.3g-1: the goal column, copied from [`GrammarEnv::curswant`].
136 pub curswant: Option<Curswant>,
137 /// VM.3g-2: the display geometry `gj` / `gk` / `g0` / `g$` read.
138 pub display: Option<&'a dyn DisplayResolver>,
139 /// VM.3f: see [`GrammarEnv::scrolloff`].
140 pub scrolloff: u32,
141 /// VM.3f: whether this motion is resolving an OPERATOR's target.
142 ///
143 /// `H` / `L` are adjusted for `scrolloff` "unless an operator is pending"
144 /// (`:h H`), so `dH` reaches the window's real top edge while a bare `H`
145 /// stops at the margin. Checked in vim 9.2.
146 pub operator_pending: bool,
147}
148
149/// What a motion's evaluator returned.
150#[derive(Debug, Clone, Copy, Default)]
151pub struct MotionResult {
152 /// Where the cursor lands.
153 pub target: Position,
154 /// `true` if the motion is linewise (ranges expand to whole lines on
155 /// resolution).
156 pub linewise: bool,
157 /// VM.3c: override [`MotionSpec::exclusive`] for THIS invocation.
158 /// `None` — the overwhelming default — means "use the spec's flag".
159 ///
160 /// ## Why the axis had to move
161 ///
162 /// `linewise` has always travelled with the RESULT and `exclusive` with
163 /// the SPEC, and nothing needed the asymmetry resolved until a motion
164 /// existed whose exclusivity is not knowable until it runs. `;` is that
165 /// motion: it repeats whatever `f` / `F` / `t` / `T` came last, and vim
166 /// gives it that motion's exclusivity — `f` and `t` are inclusive, `F`
167 /// and `T` are not. A single flag on the `;` spec has to be wrong half
168 /// the time, and it was: `d,` after an `f` deleted one character too
169 /// many, because `,` acts as `F` while the spec said inclusive.
170 ///
171 /// Not on the WIT boundary. A plugin declares exclusivity on its
172 /// `MotionSpec`, which is the right place for every motion that knows its
173 /// own answer, so `from_wit` decodes this as `None` deliberately rather
174 /// than for want of a field to read.
175 pub exclusive: Option<bool>,
176 /// VM.3d-2: a notice for the user, which the dispatcher echoes alongside
177 /// the motion's effect — the search wrap. Not on the WIT boundary: a
178 /// plugin motion reports none.
179 pub notice: Option<MotionNotice>,
180 /// VM.3g-3: override the goal column for THIS invocation, the way
181 /// [`Self::exclusive`] overrides the spec's flag. `None` — the
182 /// overwhelming default — means "apply the spec's [`CurswantEffect`]".
183 ///
184 /// ## Why a motion has to be able to say it
185 ///
186 /// `CurswantEffect` can express "keep the goal", "pin it to the line end"
187 /// and "take it from where I landed", and for every motion in vim except
188 /// two that is the whole story. `gj` / `gk` are the exception: they aim at
189 /// a screen column that the reached display row may be too short to hold,
190 /// and vim records **the aim, not the landing**.
191 ///
192 /// Measured in vim 9.2 (`vimcheck_scroll_curswant.vim`), wrap at 80 over a
193 /// 240-char line then a 100-char line:
194 ///
195 /// ```text
196 /// gj -> line 2 row 1 col=80 curswant=80
197 /// gj -> line 2 row 2 (CLAMPED) col=100 curswant=160
198 /// ```
199 ///
200 /// 160 is the column the motion *wanted* — `next_start + goal`, before the
201 /// clamp to the row's 100 characters. Recording the landing instead loses
202 /// the aim permanently, so a following `j` onto a long line returns to 100
203 /// where vim returns to 160.
204 ///
205 /// `SetFromTarget` cannot express this because the target IS the clamped
206 /// landing, and `Keep` cannot because the goal genuinely changes on every
207 /// `gj` (it tracks the screen column down the wrapped rows). Only the
208 /// motion knows the unclamped aim, so only the motion can report it.
209 ///
210 /// Not on the WIT boundary, for the same reason `exclusive` is not: a
211 /// plugin motion declares its effect on the spec, which is the right place
212 /// for every motion that knows its own answer. `from_wit` decodes this as
213 /// `None` deliberately rather than for want of a field to read.
214 pub curswant: Option<Curswant>,
215}
216
217/// Implementation of a motion. Boxed because evaluator closures capture
218/// configuration; cheap to call.
219type MotionFn = Arc<dyn Fn(&MotionContext) -> GrammarResult<MotionResult> + Send + Sync>;
220
221/// VM.3g-1: vim's `curswant` — the column a vertical motion aims for, which
222/// survives passing through a SHORT line. `jj` from column 9 over a 2-column
223/// line lands back on 9, not on 2 (vim 9.2, `vimcheck_curswant2.vim`).
224#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225pub enum Curswant {
226 /// Aim for this byte column, clamped to whatever line is reached.
227 Col(u32),
228 /// vim's MAXCOL: `$` sticks to the END of every line it lands on, so
229 /// `$jj` sits at each line's end rather than at a fixed column.
230 EndOfLine,
231}
232
233/// VM.3g-1: what a motion does to the goal column. A property of the MOTION
234/// (vim's `j` always keeps it, `$` always pins it), so it lives on the spec
235/// rather than the result — and [`Self::SetFromTarget`] is the default, so
236/// every motion that has never heard of `curswant`, plugin motions included,
237/// behaves as vim's ordinary motions do.
238#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
239pub enum CurswantEffect {
240 /// The landing column becomes the goal: every horizontal motion, and by
241 /// the same rule every edit, Insert and yank the host routes through it.
242 #[default]
243 SetFromTarget,
244 /// Aim at the existing goal and leave it untouched: `j` / `k`, and later
245 /// `gj` / `gk`, which vim gives the SAME goal column.
246 Keep,
247 /// Pin to end-of-line: `$`, and later `g$`.
248 PinToEnd,
249}
250
251/// A registered motion: its evaluator plus the vim properties the
252/// dispatcher and host need without running it.
253///
254/// Registered through [`CommandRegistry::register_motion`] (or
255/// [`CommandRegistry::register_plugin_motion`]). The dispatcher runs
256/// `apply` for a bare motion (the cursor moves to the result) and for an
257/// operator target (the span is cursor..result, shaped by `exclusive` and
258/// the result's `linewise`).
259///
260/// # Examples
261///
262/// A motion that jumps to the end of the buffer (a simplified `G`):
263///
264/// ```
265/// use std::sync::Arc;
266/// use lattice_core::{BufferId, Document};
267/// use lattice_grammar::{
268/// CancellationToken, CommandInvocation, CommandRegistry, CurswantEffect, Effect, MotionSpec,
269/// execute,
270/// };
271/// use lattice_grammar::registry::MotionResult;
272/// use lattice_protocol::position::Position;
273///
274/// let mut registry = CommandRegistry::new();
275/// let id = registry.register_motion(
276/// "motion:buffer-end",
277/// "Move to the start of the last line.",
278/// MotionSpec {
279/// jump: true,
280/// exclusive: false,
281/// curswant: CurswantEffect::default(),
282/// args_schema: vec![],
283/// apply: Arc::new(|ctx| {
284/// let last = ctx.buffer.content_line_count().saturating_sub(1);
285/// Ok(MotionResult { target: Position::new(last, 0), ..Default::default() })
286/// }),
287/// },
288/// );
289/// assert!(registry.motion_is_jump(id.0));
290///
291/// let mut doc = Document::from_text("one\ntwo\nthree");
292/// let effect = execute(
293/// ®istry,
294/// &mut doc,
295/// BufferId(0),
296/// Position::ZERO,
297/// CommandInvocation::of(id.0),
298/// &CancellationToken::never(),
299/// )
300/// .unwrap();
301/// assert!(matches!(effect, Effect::CursorMove(p) if p == Position::new(2, 0)));
302/// ```
303#[derive(Clone)]
304pub struct MotionSpec {
305 /// A vim *jump*: the host records the origin in position history (so
306 /// `<C-o>` returns) and opens folds at the destination. Read through
307 /// [`CommandRegistry::motion_is_jump`].
308 pub jump: bool,
309 /// Whether the motion's end position is excluded from an operator's span
310 /// (`w`, `b`, `0` are exclusive; `e`, `f`, `$` are inclusive). A
311 /// [`MotionResult::exclusive`] overrides it per invocation.
312 pub exclusive: bool,
313 /// VM.3g-1: this motion's effect on the goal column. See [`CurswantEffect`].
314 pub curswant: CurswantEffect,
315 /// The evaluator. Pure: reads the [`MotionContext`], returns where to go.
316 pub apply: MotionFn,
317 /// Per-positional-argument metadata (DESIGN.md §B.1). Empty for
318 /// motions without args (the common case).
319 pub args_schema: Vec<ArgSpec>,
320}
321
322impl std::fmt::Debug for MotionSpec {
323 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
324 f.debug_struct("MotionSpec")
325 .field("jump", &self.jump)
326 .field("exclusive", &self.exclusive)
327 .finish_non_exhaustive()
328 }
329}
330
331/// Context passed to an operator's evaluator.
332pub struct OperatorContext<'a> {
333 /// The document to edit. Native operators apply their edits here
334 /// directly (as one undo unit) and report them in the returned
335 /// [`Effect`](crate::Effect); plugin operators hold a read-only view and
336 /// return an `ApplyEdit` effect instead.
337 pub document: &'a mut Document,
338 /// CM.3: the buffer the operator is running over — the `target` a plugin
339 /// operator names in an `apply-edit` effect.
340 ///
341 /// Absent until now, and the absence was an asymmetry rather than a
342 /// decision, exactly as MR.2 found for [`ExCommandContext::buffer_id`]:
343 /// [`ActionContext`] and [`MotionContext`] both carry it, a native
344 /// operator never needed it because it mutates `document` in place, and a
345 /// PLUGIN operator cannot — it holds a read-only handle and must ask the
346 /// host to apply. Without this field a plugin operator can read its range
347 /// and never change it, which makes the contribution pointless.
348 pub buffer_id: BufferId,
349 /// The span to operate on, already resolved from the target or range and
350 /// expanded to whole lines when [`Self::linewise`].
351 pub range: ProtoRange,
352 /// VM.3m: the start of the operated text BEFORE linewise expansion —
353 /// `min(cursor, motion target)` for a motion, the object's or selection's
354 /// start otherwise, the cursor for a count / current-line / ex range.
355 /// vim leaves the cursor here after a yank, which is why the expanded
356 /// `range` can't answer: `yk` keeps its column (`k`'s target has it) and
357 /// `yy` doesn't move at all, though both expand to whole lines.
358 pub origin: Position,
359 /// Whether the range was produced by a linewise source (vim's
360 /// `Range::CurrentLine` / `Range::Whole`, or a linewise visual
361 /// selection). Yank uses this to tag the unnamed register so paste
362 /// can do the right thing.
363 pub linewise: bool,
364 /// The register the operator reads or writes (unnamed when none typed).
365 pub register: Register,
366 /// The count, `1` when none was typed. Usually already folded into the
367 /// span by target resolution (`3dw`), so most operators ignore it.
368 pub count: Count,
369 /// The operator's own arguments, e.g. the captured char for `r{char}` or
370 /// surround's `ys{motion}{char}`.
371 pub args: Args,
372 /// Cooperative cancellation handle (DESIGN.md §5.2.5). Operators
373 /// that scan large ranges (`d_whole`, `gU` over a big visual
374 /// block) should poll `cancel.check()?` between rows; on a
375 /// flipped token return [`crate::CommandError::Cancelled`].
376 pub cancel: &'a crate::CancellationToken,
377 /// IN.0: one level of indentation, resolved by the host. Only the
378 /// indent operators (`>` / `<`) read it.
379 pub indent: lattice_core::IndentUnit,
380 /// IN.7: per-line indent depth for the `=` operator, injected by
381 /// the host. `None` (the default) means `=` has no structural
382 /// source and leaves lines alone -- the same graceful-degradation
383 /// contract every other env field carries.
384 pub indent_resolver: Option<&'a dyn IndentResolver>,
385 /// RF.2: the buffer's `textwidth`, resolved by the host (including
386 /// any `:setlocal`). Read by the reflow operator (`gq` / `gw`).
387 ///
388 /// Not an `Option`, for the same reason [`Self::indent`] is not:
389 /// there is always a defensible answer, and
390 /// `WrapWidth::default()` is the registered option default, so a
391 /// caller that never resolved config reflows like an unconfigured
392 /// buffer rather than not at all.
393 pub textwidth: lattice_core::WrapWidth,
394 /// RF.2: the buffer language's line-comment leader, so reflow can
395 /// keep a comment block's marker. `None` for prose languages and
396 /// for any language whose comment syntax is undeclared -- reflow
397 /// then uses indentation alone, which is the right answer there.
398 pub comment_syntax: Option<&'a CommentSyntax>,
399 /// RF.5b: for each intent, whether the buffer's chain resolves to the
400 /// **native** engine.
401 ///
402 /// The host resolves the chain — it owns the LSP client and the
403 /// `PATH` probe — and hands down the one bit the operator needs:
404 /// "do it yourself, or hand me the range". `true` (the default) is
405 /// the shipped configuration, so the common path never delegates.
406 pub native_format: NativeFormatIntents,
407}
408
409/// RF.5b: which formatting intents the buffer handles natively.
410///
411/// A struct rather than two loose bools so a third intent cannot be added
412/// to one call site and forgotten at the other.
413#[derive(Debug, Clone, Copy, PartialEq, Eq)]
414pub struct NativeFormatIntents {
415 /// `=` (`format.indent`) resolves to the native tree-sitter indenter.
416 pub indent: bool,
417 /// `gq` / `gw` (`format.reflow`) resolve to the native reflow engine.
418 pub reflow: bool,
419}
420
421impl Default for NativeFormatIntents {
422 /// Native for everything — the shipped chains, and the answer a
423 /// hand-built env should get.
424 fn default() -> Self {
425 NativeFormatIntents {
426 indent: true,
427 reflow: true,
428 }
429 }
430}
431
432/// An operator's evaluator returns the full `Effect` it produced. Most
433/// operators return `Effect::Edits(...)`; `change` adds a mode transition
434/// via `Effect::Many(...)`; `yank` returns `Effect::Yank { ... }`. The
435/// dispatcher passes the result through unchanged -- composition is
436/// expressed in the Effect, not via flags on the spec.
437type OperatorFn =
438 Arc<dyn Fn(&mut OperatorContext) -> GrammarResult<crate::effect::Effect> + Send + Sync>;
439
440/// A registered operator: its evaluator plus how the dispatcher should
441/// feed it spans.
442///
443/// Registered through [`CommandRegistry::register_operator`] (or
444/// [`CommandRegistry::register_plugin_operator`]). The dispatcher resolves
445/// the invocation's range or target to a span, builds an
446/// [`OperatorContext`], and returns whatever `apply` returns unchanged.
447#[derive(Clone)]
448pub struct OperatorSpec {
449 /// Declared dot-repeatability. Currently informational: nothing reads it
450 /// yet (`.` repeat is decided host-side).
451 pub repeatable: bool,
452 /// The evaluator. Runs once per span (once per row for a blockwise
453 /// selection when [`Self::blockwise_per_row`]).
454 pub apply: OperatorFn,
455 /// Per-positional-argument metadata (DESIGN.md §B.1). Empty for
456 /// operators without args (the common case).
457 pub args_schema: Vec<ArgSpec>,
458 /// Block-visual dispatch hint. `true` (the default for v1
459 /// rectangle ops -- `d`, `y`, `c`) means a blockwise visual
460 /// selection routes per-row: each row's column slice gets its
461 /// own ProtoRange and `apply` runs once per row, with results
462 /// merged into a single Blockwise yank + concatenated Edits.
463 /// `false` (linewise-style ops -- `>`, `<`, `gU`, `gu`, `g~`)
464 /// means a blockwise visual collapses to a single contiguous
465 /// range covering anchor..head; `apply` runs once. This keeps
466 /// the operator a single undo unit instead of N per-row units.
467 pub blockwise_per_row: bool,
468 /// When true (e.g. surround's `ys{motion}{char}`), the operator's
469 /// keymap bindings append `ChordPattern::CharLiteral` after every
470 /// motion path so a wrapping character is captured as `Args::Char`
471 /// by the wildcard resolution. Default false.
472 pub post_motion_char: bool,
473}
474
475impl std::fmt::Debug for OperatorSpec {
476 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
477 f.debug_struct("OperatorSpec")
478 .field("repeatable", &self.repeatable)
479 .field("blockwise_per_row", &self.blockwise_per_row)
480 .finish_non_exhaustive()
481 }
482}
483
484/// IN.7: how deep should this line sit?
485///
486/// The `=` operator needs a per-line indent level, which only the
487/// tree-sitter engine can compute — and this crate is deliberately
488/// tree-sitter-agnostic (see its `Cargo.toml`). Same shape and same
489/// reason as [`ScopeResolver`] above: the grammar declares the
490/// question, the host injects something that can answer it, and
491/// nothing tree-shaped crosses the boundary.
492///
493/// `None` from `levels_for_line` means "no answer for this row" — no
494/// tree, no `indents.scm` for the language, a parse error, or a
495/// position inside a string. The operator leaves such lines untouched
496/// rather than guessing, so `=` over a range containing a broken
497/// region reindents what it understands and does not mangle the rest.
498pub trait IndentResolver {
499 /// Indent depth in levels (not columns) for `line`.
500 fn levels_for_line(&self, line: u32) -> Option<i32>;
501}
502
503/// VM.3i: where `zj` / `zk` find the next fold edge.
504///
505/// Folds, and which of them are visible, are host state: the fold table and
506/// `foldenable` live on the editor, and "a closed fold is counted as one fold"
507/// needs the host's fold index. So the grammar asks this, the way `=` asks
508/// [`IndentResolver`], and the motion itself stays a pure function of the
509/// answer.
510///
511/// `None` from `fold_edge` means there's no edge that way, and the motion
512/// leaves the cursor where it is, as vim does.
513pub trait FoldResolver {
514 /// The nearest visible fold START after `line` (`forward`), or the
515 /// nearest visible fold END before it (`!forward`).
516 fn fold_edge(&self, line: u32, forward: bool) -> Option<u32>;
517}
518
519/// VM.3e: the mark table `'x` / `` `x `` read, so they can be motions. The
520/// host owns the marks (`m` writes them); the grammar only asks where one is.
521/// `None` means the mark isn't set, which fails the motion with E20.
522pub trait MarkResolver {
523 /// Where mark `name` is set, or `None` when it is not.
524 fn mark(&self, name: char) -> Option<Position>;
525}
526
527/// The host's table is a plain map, so it is its own resolver: a read-only
528/// buffer borrows it as is, and the actor path carries an `Arc` of a clone.
529impl MarkResolver for std::collections::HashMap<char, Position> {
530 fn mark(&self, name: char) -> Option<Position> {
531 self.get(&name).copied()
532 }
533}
534
535/// VM.3f: which line of the window `H` / `M` / `L` mean. The layout — folds,
536/// row heights, window size — stays on the host; the grammar only asks.
537pub trait ViewportResolver {
538 /// `Top`: the `count`-th line from the top (`H`); `Bottom`: the `count`-th
539 /// from the bottom (`L`); `Middle`: the middle line (`M`, count ignored).
540 /// `None` when the window shows nothing.
541 fn viewport_line(&self, pos: crate::app_effect::ViewportPos, count: u32) -> Option<u32>;
542}
543
544/// VM.3f: the window's lines, top to bottom, each with its height in
545/// line-heights (1.0 for an ordinary row). The host builds it from the walk it
546/// paints with; this type owns vim's rule for which of them `H` / `M` / `L`
547/// mean, so the keyboard motion and the host's `JumpViewport` action answer
548/// from one place. Checked in vim 9.2: a count past the window clamps to the
549/// far edge, and `M` is top + (lines shown − 1) / 2 — half the lines SHOWN, so
550/// a short buffer's middle, not the window's.
551#[derive(Debug, Clone, Default)]
552pub struct ShownLines(pub Vec<(u32, f32)>);
553
554impl ViewportResolver for ShownLines {
555 fn viewport_line(&self, pos: crate::app_effect::ViewportPos, count: u32) -> Option<u32> {
556 use crate::app_effect::ViewportPos;
557 let lines = &self.0;
558 let last = lines.len().checked_sub(1)?;
559 let n = (count.max(1) - 1) as usize;
560 Some(match pos {
561 ViewportPos::Top => lines[n.min(last)].0,
562 ViewportPos::Bottom => lines[last - n.min(last)].0,
563 ViewportPos::Middle => {
564 // (shown − 1) / 2, spent in line-heights so a tall row pulls
565 // `M` up to the line actually drawn mid-window.
566 let budget = (lines.iter().map(|l| l.1).sum::<f32>() - 1.0) / 2.0;
567 let mut used = 0.0;
568 let mut at = lines[0].0;
569 for &(line, cost) in &lines[1..] {
570 if used + cost > budget {
571 break;
572 }
573 used += cost;
574 at = line;
575 }
576 at
577 }
578 })
579 }
580}
581
582/// VM.3g-2: what the DISPLAY looks like, for `gj` / `gk` / `g0` / `g$`. Soft
583/// wrap is a rendering decision — the wrap width, and how many rows a line
584/// takes once tabs and wide characters are measured — so the grammar asks
585/// rather than computes, exactly as it does for folds and the viewport.
586pub trait DisplayResolver {
587 /// Soft-wrap width in columns, or `0` when `wrap` is off — which degrades
588 /// `gj` to `j` and `g0` to `0`, as vim does.
589 fn wrap_width(&self) -> u32;
590 /// How many display rows `line` occupies. `1` when wrap is off.
591 fn segments(&self, line: u32) -> u32;
592}
593
594/// Resolves a tree-sitter scope near the cursor, for the structural text
595/// objects (`af` / `ac` / `aa` / …) and structural motions (`]f` / `[c` / …).
596///
597/// Defined here so `lattice-grammar` stays free of any tree-sitter
598/// dependency: the host implements it (backed by the buffer's
599/// `SyntaxSnapshot`) and threads it in through [`GrammarEnv::scope_resolver`]
600/// to [`TextObjectContext`] and [`MotionContext`]. Introduced in N.1.4a.
601pub trait ScopeResolver {
602 /// The innermost node at `(line, col_byte)` whose capture name ends with
603 /// `suffix` (e.g. `"function.outer"`), as a byte-precise half-open
604 /// `[start, end)` [`ProtoRange`] — byte columns, not just rows, so
605 /// intra-line objects like `aa` / `ia` are charwise-accurate. End is
606 /// exclusive, matching tree-sitter node ranges and the operator slice
607 /// convention. `None` when there is no parse or no match.
608 fn scope_at(&self, line: u32, col_byte: u32, suffix: &str) -> Option<ProtoRange>;
609
610 /// The `count`-th node whose capture name ends with `suffix`, in `dir`,
611 /// targeting the node's `boundary`. Respects the enclosing-object rule
612 /// (see treesitter-motions.md §4.1): `(Forward, Start)` / `(Backward, End)`
613 /// skip the object the cursor is inside; `(Backward, Start)` / `(Forward, End)`
614 /// may land on the current object's own boundary. Returns the target
615 /// position, or `None` (no tree / no match / fewer than `count` candidates).
616 fn scope_toward(
617 &self,
618 line: u32,
619 col_byte: u32,
620 suffix: &str,
621 dir: NavDir,
622 boundary: NavBoundary,
623 count: u32,
624 ) -> Option<Position>;
625}
626
627/// Direction of travel for a structural motion. `Forward` scans toward EOF,
628/// `Backward` toward BOF.
629#[derive(Debug, Clone, Copy, PartialEq, Eq)]
630pub enum NavDir {
631 /// Toward the end of the buffer.
632 Forward,
633 /// Toward the start of the buffer.
634 Backward,
635}
636
637/// Which boundary of the target node the motion lands on.
638#[derive(Debug, Clone, Copy, PartialEq, Eq)]
639pub enum NavBoundary {
640 /// Land on the node's first byte.
641 Start,
642 /// Land on the node's last byte.
643 End,
644}
645
646/// N.1.6 (2026-06-10): per-buffer comment-leader descriptor for the
647/// comment text objects (`aC` / `iC`). Commentstring-driven (NOT
648/// tree-sitter) so it works for any language with a known leader, even
649/// without a parse tree. The host populates it from the active buffer's
650/// language (`Lang::comment_syntax`); `None` (or `line: None`) means the
651/// comment objects resolve nothing (graceful operator no-op).
652#[derive(Debug, Clone, Default, PartialEq, Eq)]
653pub struct CommentSyntax {
654 /// Line-comment leader, e.g. `"//"` (rust / js) or `"#"` (python).
655 pub line: Option<String>,
656 /// Block-comment delimiters, e.g. `("/*", "*/")`. Reserved for a
657 /// follow-up; v1 comment objects use `line` only.
658 pub block: Option<(String, String)>,
659}
660
661/// `f` / `F` / `t` / `T` — which direction, and whether the target character
662/// is included.
663///
664/// VM.3c moved this down from `lattice_host::action` so `;` and `,` can be
665/// MOTIONS. They have to be: vim composes them (`d;` repeats the last find
666/// under an operator) and VM.1's derivation only mirrors motions into Visual,
667/// so as actions they were unreachable from a selection and from an operator
668/// both. The host re-exports this name, so no call site moved.
669#[derive(Debug, Clone, Copy, PartialEq, Eq)]
670pub enum FindKind {
671 /// `f` — forward to the next occurrence, landing ON it.
672 Forward,
673 /// `F` — backward to the previous occurrence, landing ON it.
674 Backward,
675 /// `t` — forward, landing one byte BEFORE it.
676 TillForward,
677 /// `T` — backward, landing one byte AFTER it.
678 TillBackward,
679}
680
681impl FindKind {
682 /// The kind `,` repeats — this one, reversed. `;` repeats `self`.
683 ///
684 /// A method rather than a `match` at the two call sites, because the
685 /// pairing is a property of the enum: adding a fifth variant should break
686 /// here, once, rather than silently repeat in the wrong direction.
687 pub fn reversed(self) -> Self {
688 match self {
689 FindKind::Forward => FindKind::Backward,
690 FindKind::Backward => FindKind::Forward,
691 FindKind::TillForward => FindKind::TillBackward,
692 FindKind::TillBackward => FindKind::TillForward,
693 }
694 }
695}
696
697/// The last `f` / `F` / `t` / `T` the user ran, which is all `;` and `,` need
698/// to repeat it.
699#[derive(Debug, Clone, Copy, PartialEq, Eq)]
700pub struct LastFind {
701 /// Which of the four find motions it was.
702 pub kind: FindKind,
703 /// The character searched for.
704 pub target: char,
705}
706
707/// VM.3d-2: the last completed `/` / `?` / `*` / `#` search, which is all `n`
708/// and `N` need to repeat it. Moved here from the host (which re-exports the
709/// name, so no call site moved) for the reason [`LastFind`] was: `n` is a
710/// motion now, and the state it repeats has to reach the grammar.
711#[derive(Debug, Clone, PartialEq, Eq)]
712pub struct LastSearch {
713 /// The pattern as the user typed it (regex syntax, compiled with
714 /// `fancy-regex`).
715 pub pattern: String,
716 /// The direction the search ran: `n` repeats it, `N` reverses it.
717 pub direction: crate::modal::SearchDirection,
718}
719
720/// VM.3d-2: something a motion wants the user told, alongside where it moved.
721/// `Copy`, so [`MotionResult`] stays `Copy`; the dispatcher renders the text.
722#[derive(Debug, Clone, Copy, PartialEq, Eq)]
723pub enum MotionNotice {
724 /// vim's "search hit BOTTOM, continuing at TOP".
725 SearchHitBottom,
726 /// vim's "search hit TOP, continuing at BOTTOM".
727 SearchHitTop,
728}
729
730/// The per-dispatch environment: everything the host knows that a
731/// command's `apply` may need but the grammar layer cannot derive for
732/// itself. Bundled into ONE value so the dispatch seam carries a single
733/// env rather than a widening parameter list (the long-term-fit choice
734/// over parallel params). `Copy`; `default()` is the no-input case, and
735/// commands that read nothing from the env (`iw`, `ap`, `i{`) are
736/// unaffected by what it carries.
737///
738/// It has widened twice, and the name followed on the second:
739///
740/// - **N.1.6 (2026-06-10)** introduced it as a text-object-only env —
741/// the tree-sitter `scope_resolver` (`af`/`ac`, N.1.4) and the
742/// `comment_syntax` (`aC`/`iC`).
743/// - **TS.1** added the tree-sitter `syntax` snapshot for **actions**
744/// (borrowed `Arc<dyn Any>` so `execute_action` can `Arc::clone` it
745/// into the owned `ActionContext::syntax` — a `&dyn Any` alone can't
746/// recover the `Arc`), which already made `TextObjectEnv` a misnomer.
747/// - **IN.0** renamed it to `GrammarEnv` on adding a field read by
748/// **operators** (`>` / `<`), which would have made the old name
749/// actively misleading rather than merely stale.
750#[derive(Clone, Copy, Default)]
751pub struct GrammarEnv<'a> {
752 /// Tree-sitter scope lookup for structural text objects and motions.
753 /// `None` on buffers with no parse.
754 pub scope_resolver: Option<&'a dyn ScopeResolver>,
755 /// The buffer language's comment leader, for `aC` / `iC` and reflow.
756 /// `None` when the language declares none.
757 pub comment_syntax: Option<&'a CommentSyntax>,
758 /// TS.1: the per-dispatch tree-sitter snapshot, type-erased and borrowed so
759 /// it stays `Copy`. `execute_action` clones it into `ActionContext::syntax`;
760 /// motions / text-objects / operators ignore it (they read
761 /// `scope_resolver`). `None` = no parse.
762 pub syntax: Option<&'a Arc<dyn std::any::Any + Send + Sync>>,
763 /// IN.0: one level of indentation, resolved by the host from
764 /// `shiftwidth` / `expandtab` / `tabstop` (including any
765 /// `:setlocal` override). Read by the `>` / `<` operators.
766 ///
767 /// Not an `Option`: there is always a defensible answer, and
768 /// `IndentUnit::default()` is the registered option defaults, so a
769 /// caller that never resolved config indents like an unconfigured
770 /// buffer instead of like nothing. That keeps the ~40 test and
771 /// plugin call sites that build a `default()` env working without
772 /// each having to care about indentation.
773 pub indent: lattice_core::IndentUnit,
774 /// IN.7: per-line indent depth for the `=` operator, injected by
775 /// the host. `None` (the default) means `=` has no structural
776 /// source and reindents nothing -- the same graceful-degradation
777 /// contract every other env field carries.
778 pub indent_resolver: Option<&'a dyn IndentResolver>,
779 /// RF.2: the buffer's `textwidth`, resolved by the host (including
780 /// any `:setlocal`). Read by the reflow operator.
781 ///
782 /// `WrapWidth` rather than a bare `usize` precisely so this struct
783 /// can keep its DERIVED `Default`: a `usize` would default to `0`,
784 /// which is not a column anything wraps at, and all ~40 hand-built
785 /// `default()` envs would silently get a reflow that does nothing.
786 pub textwidth: lattice_core::WrapWidth,
787 /// RF.5b: which intents this buffer handles natively. See
788 /// [`NativeFormatIntents`].
789 pub native_format: NativeFormatIntents,
790 /// OS.2: the **active region** — the Visual/Select selection extent,
791 /// normalised so `start <= end`. `None` in Normal mode and on every
792 /// non-chord firing path.
793 ///
794 /// Injected by the host from the SAME resolver that fills
795 /// `lattice_mode::ActionContext::selection` (MG.18e), rather than
796 /// re-derived here from the document's selections. That is deliberate: two
797 /// answers to "what is the region" is precisely the drift OS.3 spent a fix
798 /// round removing from `Checkboxes`, and a plugin action seeing a different
799 /// region than a native mode handler reached the same way is the same bug
800 /// wearing a boundary.
801 ///
802 /// `None` (the default) means no region, so the ~40 call sites that build a
803 /// `default()` env keep behaving exactly as they did.
804 pub selection: Option<lattice_protocol::position::Range>,
805 /// VM.3c: the last `f` / `F` / `t` / `T`, so `;` and `,` can be motions
806 /// rather than host actions.
807 ///
808 /// Carried here for the reason `syntax` and `selection` had to be: `;`
809 /// reaches the grammar through the ACTOR path on every real keystroke, so
810 /// a field missing here is a field the motion never sees no matter how
811 /// well the direct path is tested. `None` — the default, and the state
812 /// before the user has pressed any `f` — makes `;` a no-op, which is what
813 /// vim does.
814 pub last_find: Option<LastFind>,
815 /// VM.3i: the buffer's fold edges, so `zj` / `zk` can be motions.
816 ///
817 /// Carried here for the reason `last_find` is: `zj` reaches the grammar
818 /// through the ACTOR path on every real keystroke, so a field missing
819 /// here is one the motion never sees. `None` — no folds, or a caller
820 /// with no fold table — leaves `zj` / `zk` where they are.
821 pub fold_resolver: Option<&'a dyn FoldResolver>,
822 /// VM.3d-2: the last search, so `n` / `N` / `*` / `#` can be motions.
823 /// Carried for the reason `last_find` is: they reach the grammar through
824 /// the ACTOR path on every real keystroke. `None` makes `n` fail with
825 /// E35, as vim does before any search.
826 pub last_search: Option<&'a LastSearch>,
827 /// VM.3e: the mark table, so `'x` / `` `x `` can be motions. Carried for
828 /// the reason `last_search` is. `None` makes every mark unset (E20).
829 pub marks: Option<&'a dyn MarkResolver>,
830 /// VM.3f: the lines the window shows, so `H` / `M` / `L` can be motions.
831 /// `None` — no window, or an invocation that isn't one of them — makes
832 /// them fail without moving.
833 pub viewport: Option<&'a dyn ViewportResolver>,
834 /// VM.3f: `!startofline`. Named for the vim option that turns the rule
835 /// OFF so that `Default` (false) is vim's default.
836 pub nostartofline: bool,
837 /// VM.3g-1: the goal column `j` / `k` aim for. `None` — nothing has set one
838 /// yet — means "use the cursor's own column", which is what the first `j`
839 /// after any edit does in vim.
840 pub curswant: Option<Curswant>,
841 /// VM.3g-2: display geometry for `gj` / `gk` / `g0` / `g$`. `None` — no
842 /// renderer has reported one — leaves them behaving as `j` / `k` / `0` /
843 /// `$`, which is also what they do with `wrap` off.
844 pub display: Option<&'a dyn DisplayResolver>,
845 /// VM.3g-3: where a motion's [`MotionResult::curswant`] override is
846 /// reported back to the caller. `None` — the default — discards it, which
847 /// is right for every caller that does not maintain a goal column.
848 ///
849 /// The symmetric partner of [`Self::curswant`] above: that field carries
850 /// the goal INTO the motion, this one carries the motion's answer back
851 /// out. It is a slot rather than a return value because a motion's effect
852 /// reaches the host as an `Effect::CursorMove`, a WIT type and the wrong
853 /// place for per-dispatch host state — `exclusive` and `notice` are kept
854 /// off that boundary for the same reason.
855 ///
856 /// A shared slot rather than `&mut` so `GrammarEnv` stays `Copy`, which
857 /// the tree-sitter snapshot field above already depends on; a `Mutex`
858 /// rather than a `Cell` because the owning `DispatchEnv` crosses the
859 /// `Document` trait into an async `Pending<Effect>` and must stay
860 /// `Send + Sync`. The lock is uncontended — one motion writes it once per
861 /// dispatch, on the same thread that reads it.
862 pub curswant_out: Option<&'a std::sync::Mutex<Option<Curswant>>>,
863 /// VM.3f: `scrolloff`, the margin `H` / `L` keep from the window's edges.
864 /// `0` — the default — leaves them on the first and last lines shown,
865 /// which is vim with `scrolloff=0`.
866 pub scrolloff: u32,
867}
868
869/// Context passed to a text-object's evaluator.
870pub struct TextObjectContext<'a> {
871 /// The buffer text the object reads.
872 pub buffer: &'a Buffer,
873 /// The cursor the object is evaluated around.
874 pub at: Position,
875 /// The count, `1` when none was typed (`2aw`, `2i(`).
876 pub count: Count,
877 /// The object's own arguments.
878 pub args: Args,
879 /// Cooperative cancellation handle (DESIGN.md §5.2.5). Most
880 /// text objects are O(line); polling rarely matters. Tag /
881 /// paragraph / sentence objects that walk further can poll
882 /// `cancel.check()?` on their inner loops.
883 pub cancel: &'a crate::CancellationToken,
884 /// N.1.4a: tree-sitter scope resolver for the structural text
885 /// objects (`af` / `ac` / …), injected by the host (backed by the
886 /// buffer's `SyntaxSnapshot`). `None` for buffers with no syntax —
887 /// the structural objects then resolve nothing; the classic
888 /// objects (`iw`, `ap`, `i{`) never read it.
889 pub scope_resolver: Option<&'a dyn ScopeResolver>,
890 /// N.1.6: comment-leader descriptor for the comment objects
891 /// (`aC` / `iC`), injected by the host from the active buffer's
892 /// language. `None` (or `line: None`) ⇒ the comment objects resolve
893 /// nothing. Only `aC` / `iC` read it.
894 pub comment_syntax: Option<&'a CommentSyntax>,
895 /// OM.6b: the file the object's buffer is backed by — the borrowed peer of
896 /// [`MotionContext::path`], for the same reason.
897 pub path: Option<&'a std::path::Path>,
898 /// OT.1: the type-erased tree-sitter snapshot — the borrowed peer of
899 /// [`MotionContext::syntax`], for the same reason, and carrying the same
900 /// version-agreement guarantee.
901 ///
902 /// Distinct from `scope_resolver` above rather than replacing it: the
903 /// resolver answers "what scope encloses this point" for the native
904 /// structural objects, while this hands a plugin the whole tree to query.
905 /// A plugin object resolving a subtree needs the second, not the first.
906 pub syntax: Option<&'a Arc<dyn std::any::Any + Send + Sync>>,
907}
908
909type TextObjectFn = Arc<dyn Fn(&TextObjectContext) -> GrammarResult<ProtoRange> + Send + Sync>;
910
911/// A registered text object: an evaluator that returns the span around a
912/// position.
913///
914/// Registered through [`CommandRegistry::register_text_object`] (or
915/// [`CommandRegistry::register_plugin_text_object`]). Used as an operator
916/// target (`diw`) and to set a Visual selection (`viw`).
917#[derive(Clone)]
918pub struct TextObjectSpec {
919 /// The evaluator: returns the selected span as a half-open range.
920 pub apply: TextObjectFn,
921 /// Per-positional-argument metadata (DESIGN.md §B.1). Empty for
922 /// text objects without args (the common case).
923 pub args_schema: Vec<ArgSpec>,
924}
925
926impl std::fmt::Debug for TextObjectSpec {
927 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
928 f.debug_struct("TextObjectSpec").finish_non_exhaustive()
929 }
930}
931
932/// Context handed to an ex-command's evaluator. Mirrors the shape passed
933/// to motion / operator / text-object specs but adds the `bang` bit and
934/// drops direct document mutation: ex-commands describe their work by
935/// returning an [`crate::effect::Effect`], which the host applies.
936///
937/// Owns a [`crate::CancellationToken`] (not a borrow) so apply
938/// closures can hold it across `Box::new(move |ctx| ...)`
939/// boundaries without lifetime gymnastics. The actor flips a clone
940/// when cancellation arrives.
941pub struct ExCommandContext {
942 /// Whether the command was typed with a trailing `!` (`:q!`).
943 pub bang: bool,
944 /// Arguments produced by the spec's [`ExCommandSpec::parse_args`].
945 pub args: Args,
946 /// The line range typed before the command (`:%s`, `:1,5…`), unresolved;
947 /// `None` when there was none. The command resolves it itself.
948 pub range: Option<crate::range::Range>,
949 /// The register named in the invocation, unnamed by default.
950 pub register: Register,
951 /// The count, `1` when none was given.
952 pub count: Count,
953 /// The buffer the `:` line was submitted from (MR.2) — the same
954 /// fact [`ActionContext::buffer_id`] carries, filled from the same
955 /// place in [`crate::dispatcher::execute`].
956 ///
957 /// It was absent, and the absence was an asymmetry rather than a
958 /// decision: vim's ex-commands are buffer-scoped by definition
959 /// (`:w`, `:%s`, `:bd`), the `:` line is a parser front-end onto the
960 /// one dispatcher (design §5.2.1), and a command reached that way
961 /// was seeing strictly less than the same command reached by a
962 /// chord. `:magit-status` is what found it: it must open the
963 /// repository of the buffer you are looking at, and via `:` it could
964 /// not name that buffer at all.
965 ///
966 /// Deliberately just the id: an ex-command that needs the buffer's
967 /// text, path or name resolves it through a handle it captured at
968 /// boot (`SubsystemBoot::buffer_store`), which keeps this context
969 /// free of services and keeps `lattice-grammar` unaware of them.
970 pub buffer_id: BufferId,
971 /// OC.10: where the caret sits when the `:` line is submitted — the four
972 /// fields below are exactly the ones [`ActionContext`] already carries, and
973 /// they are here for the reason `buffer_id` above is.
974 ///
975 /// MR.2 made that argument for the id: "a command reached that way was
976 /// seeing strictly less than the same command reached by a chord". It is the
977 /// same argument, and stopping at the id left it half-made. A *plugin*
978 /// ex-command felt it hardest: `apply-ex-command` returns `list<effect>`,
979 /// and `Effect::ApplyEdit` names a target buffer the guest had no way to
980 /// obtain — so the seam offered an effect vocabulary a guest structurally
981 /// could not use. `:org-clock-in` is what found it.
982 ///
983 /// Native ex-commands ignore all four; they cost an `Arc` bump each.
984 pub cursor: Position,
985 /// A point-in-time view of the buffer the `:` line was submitted from.
986 /// O(1) rope clone (Arc-shared nodes), as on [`ActionContext::buffer`].
987 pub buffer: Buffer,
988 /// The file behind that buffer, so a plugin's `document` handle can answer
989 /// `path()`. An `Arc` bump per dispatch, `None` for a buffer with no file.
990 pub path: Option<std::sync::Arc<std::path::PathBuf>>,
991 /// The per-dispatch tree snapshot, type-erased for the same layering reason
992 /// [`ActionContext::syntax`] is. Cloned at the same instant as `buffer`, so
993 /// tree and text agree on version (§7).
994 pub syntax: Option<Arc<dyn std::any::Any + Send + Sync>>,
995 /// Cooperative cancellation handle (DESIGN.md §5.2.5); owned so a
996 /// closure can keep it.
997 pub cancel: crate::CancellationToken,
998}
999
1000/// Parser callback for an ex-command. The host hands the rest of the
1001/// command line (everything after the command word and the optional `!`)
1002/// plus the `bang` bit; the callback returns typed [`Args`].
1003type ExParseFn = Arc<dyn Fn(&str, bool) -> GrammarResult<Args> + Send + Sync>;
1004
1005/// Evaluator callback. Returns the [`Effect`] the host should commit.
1006type ExApplyFn =
1007 Arc<dyn Fn(&ExCommandContext) -> GrammarResult<crate::effect::Effect> + Send + Sync>;
1008
1009/// How the user types this command on the `:` line. The default
1010/// (`Keyword`) covers most commands -- `:write`, `:quit`, `:set
1011/// number`. `Delimiter` is for the small family of commands whose
1012/// arguments are interleaved with delimiters: `:s/pat/repl/`,
1013/// `:g/pat/body`, `:v/pat/body`. The keyword form (`:ex:substitute`,
1014/// `:ex:global`) for these is intentionally a hard error -- the
1015/// front-end parser routes them via `try_parse_substitute` /
1016/// `try_parse_global`. UI surfaces (completion, command palette)
1017/// hide `Delimiter` commands because there's no useful keyword-form
1018/// completion for them; the user types the delimiter directly.
1019// PL8.F: no longer `Copy` — the `hint` became `Cow<'static, str>` (a plugin
1020// delimiter command's hint crosses WIT as an owned string and must free on
1021// unregister). Consumers that bound it by value now bind by reference.
1022#[derive(Debug, Clone, PartialEq, Eq, Default)]
1023pub enum SurfaceForm {
1024 /// Type the command word, optionally with `!`, then args
1025 /// separated by whitespace. The default for most commands.
1026 #[default]
1027 Keyword,
1028 /// Type a delimiter prefix followed by a body. The keyword form
1029 /// errors with a redirect message; the embedded `hint` is the
1030 /// canonical syntax shown in that error (`:s/pat/repl/`,
1031 /// `:g/pat/body`).
1032 Delimiter {
1033 /// The canonical syntax shown in the redirect error and in help,
1034 /// e.g. `:s/pat/repl/`.
1035 hint: std::borrow::Cow<'static, str>,
1036 },
1037}
1038
1039/// A registered ex-command: how the `:` line parses it and what it
1040/// returns.
1041///
1042/// Registered through [`CommandRegistry::register_ex_command`] (or
1043/// [`CommandRegistry::register_plugin_ex_command`]). The `:` front-end (in
1044/// `lattice-host`) resolves the typed word to a registry name, rejects a
1045/// `!` the spec does not accept, calls [`Self::parse_args`] on the rest of
1046/// the line, and dispatches the resulting invocation. `apply` receives an
1047/// [`ExCommandContext`] and returns an [`Effect`](crate::Effect) — it never
1048/// performs I/O itself.
1049///
1050/// # Examples
1051///
1052/// ```
1053/// use std::sync::Arc;
1054/// use lattice_core::{BufferId, Document};
1055/// use lattice_grammar::{
1056/// Args, CancellationToken, CommandInvocation, CommandRegistry, EchoLevel, Effect,
1057/// ExCommandSpec, LatencyClass, SurfaceForm, execute,
1058/// };
1059/// use lattice_protocol::position::Position;
1060///
1061/// let mut registry = CommandRegistry::new();
1062/// let id = registry.register_ex_command(
1063/// "ex:greet",
1064/// "Echo a greeting (`:greet [name]`).",
1065/// ExCommandSpec {
1066/// latency_class: LatencyClass::Reflex,
1067/// accepts_bang: false,
1068/// accepts_range: false,
1069/// parse_args: Arc::new(|rest, _bang| {
1070/// Ok(match rest.trim() {
1071/// "" => Args::None,
1072/// name => Args::String(name.to_string()),
1073/// })
1074/// }),
1075/// apply: Arc::new(|ctx| {
1076/// let who = match &ctx.args {
1077/// Args::String(s) => s.as_str(),
1078/// _ => "world",
1079/// };
1080/// Ok(Effect::Echo { level: EchoLevel::Info, text: format!("hello, {who}") })
1081/// }),
1082/// args_schema: vec![],
1083/// surface_form: SurfaceForm::Keyword,
1084/// },
1085/// );
1086///
1087/// let spec = registry.ex_command_spec(id.0).unwrap();
1088/// let args = (spec.parse_args)(" lattice", false).unwrap();
1089/// let mut doc = Document::from_text("");
1090/// let effect = execute(
1091/// ®istry,
1092/// &mut doc,
1093/// BufferId(0),
1094/// Position::ZERO,
1095/// CommandInvocation::of(id.0).with_args(args),
1096/// &CancellationToken::never(),
1097/// )
1098/// .unwrap();
1099/// assert!(matches!(effect, Effect::Echo { text, .. } if text == "hello, lattice"));
1100/// ```
1101#[derive(Clone)]
1102pub struct ExCommandSpec {
1103 /// Latency class declaration (DESIGN.md §5.2.5). Most ex-commands
1104 /// stay [`crate::command::LatencyClass::Reflex`] (the default) --
1105 /// they're cheap state mutations. File I/O (`:write`, `:edit`)
1106 /// and help-buffer builders (`:describe-*`, `:apropos`,
1107 /// `:keymap`) declare [`crate::command::LatencyClass::Display`]
1108 /// so future cancellation / deadline machinery treats them with
1109 /// the right budget.
1110 pub latency_class: crate::command::LatencyClass,
1111 /// Whether the parser should accept a trailing `!` after the command
1112 /// word. If `false`, `:cmd!` parses as an unknown command.
1113 pub accepts_bang: bool,
1114 /// Whether the parser should accept an ex-style line range (`1,5cmd`,
1115 /// `'a,'bcmd`, ...). v1 only honours `Whole` and `CurrentLine`; this
1116 /// flag is the migration knob for richer range parsing.
1117 pub accepts_range: bool,
1118 /// Parses everything after the command word (and `!`) into [`Args`].
1119 /// Receives the bang bit so `!` can change the grammar.
1120 pub parse_args: ExParseFn,
1121 /// The evaluator: packages the invocation into an [`Effect`](crate::Effect).
1122 pub apply: ExApplyFn,
1123 /// Per-positional-argument metadata (DESIGN.md §B.1). Drives palette
1124 /// forms, missing-arg prompts, completion, validation, and
1125 /// `:describe-command` output. Empty for commands taking no
1126 /// structured args (`:q`, `:noh`).
1127 pub args_schema: Vec<ArgSpec>,
1128 /// User-facing surface form. Drives whether completion / palette
1129 /// list this command. Defaults to [`SurfaceForm::Keyword`].
1130 pub surface_form: SurfaceForm,
1131}
1132
1133impl std::fmt::Debug for ExCommandSpec {
1134 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1135 f.debug_struct("ExCommandSpec")
1136 .field("accepts_bang", &self.accepts_bang)
1137 .field("accepts_range", &self.accepts_range)
1138 .finish_non_exhaustive()
1139 }
1140}
1141
1142/// Doc string for an auto-generated `:<mode-name>` mode-toggle ex-command —
1143/// shared so native and plugin modes read identically in `:describe-command`.
1144pub const MODE_TOGGLE_COMMAND_DOC: &str = "Toggle the mode on the active buffer (auto-generated; see `:help modes` for \
1145 the full mode-system overview).";
1146
1147/// Build the auto-generated `:<mode-name>` mode-toggle ex-command spec. The
1148/// `apply` returns [`crate::effect::Effect::ToggleMode`]; the host routes that
1149/// to `toggle_mode_by_name`, flipping the mode on the active buffer. Shared by
1150/// boot (native modes, `register_mode_toggle_commands`) AND the plugin
1151/// modes-seam drain, so a plugin-registered mode gets an IDENTICAL `:<mode>`
1152/// toggle surface. The caller registers it under the right provenance: `Builtin`
1153/// for native modes; `SourceLayer::Plugin(id)` for plugin modes, so unload
1154/// reverses it. Takes no arguments.
1155pub fn mode_toggle_ex_command_spec(mode_name: &str) -> ExCommandSpec {
1156 let mode = mode_name.to_string();
1157 ExCommandSpec {
1158 latency_class: crate::command::LatencyClass::Reflex,
1159 accepts_bang: false,
1160 accepts_range: false,
1161 parse_args: std::sync::Arc::new(|s: &str, _bang: bool| {
1162 if s.trim().is_empty() {
1163 Ok(crate::args::Args::None)
1164 } else {
1165 Err(crate::error::CommandError::BadArgs(
1166 "mode toggle takes no arguments".into(),
1167 ))
1168 }
1169 }),
1170 apply: std::sync::Arc::new(move |_ctx| {
1171 Ok(crate::effect::Effect::ToggleMode {
1172 mode_name: mode.clone(),
1173 })
1174 }),
1175 args_schema: Vec::new(),
1176 surface_form: SurfaceForm::Keyword,
1177 }
1178}
1179
1180/// Context passed to a free-form action's evaluator. Mirrors
1181/// [`ExCommandContext`]'s shape (no document mutation; the App
1182/// applies the returned [`crate::effect::Effect`]) but omits the
1183/// `bang` bit -- chord-bound actions never carry one. The
1184/// `register` / `count` slots flow the count and register prefixes
1185/// the user typed before the chord (vim's `3"+yy`-style); most
1186/// actions ignore them.
1187pub struct ActionContext {
1188 /// The action's arguments; usually [`Args::None`].
1189 pub args: Args,
1190 /// The `"x` prefix typed before the chord, unnamed by default.
1191 pub register: Register,
1192 /// The count typed before the chord, `1` by default.
1193 pub count: Count,
1194 /// Where the caret sits when the action fires (AP.0.1) — the
1195 /// action's equivalent of `MotionContext::from`. Native actions
1196 /// ignore it; a plugin action pairs it with `buffer` (below) to
1197 /// read the text around the cursor.
1198 pub cursor: Position,
1199 /// The active buffer's id (AP.2) — the `target` a plugin action
1200 /// names in an `Effect::ApplyEdit`. Mirrors `MotionContext::buffer_id`.
1201 pub buffer_id: BufferId,
1202 /// A point-in-time view of the buffer the action fired in
1203 /// (AP.0.1). An owned `Buffer` — a `ropey::Rope` clone is O(1)
1204 /// (Arc-shared nodes), so carrying it costs nothing on the
1205 /// dispatch path and avoids a context lifetime. Native actions
1206 /// ignore it; the plugin-host trampoline mints a `document`
1207 /// resource from it so a grammar plugin can read buffer text.
1208 /// Layering: a `lattice-core` type, so `lattice-grammar` needs no
1209 /// `lattice-runtime` dependency (the snapshot is built host-side).
1210 pub buffer: Buffer,
1211 /// OS.2: the active region — the Visual/Select selection extent, normalised
1212 /// so `start <= end`. `None` in Normal mode and on every non-chord firing
1213 /// path (prompt submit, transient item, a `Confirm` yes-action).
1214 ///
1215 /// The peer of `lattice_mode::ActionContext::selection` (MG.18e), which
1216 /// native mode handlers have had since magit needed to stage part of a
1217 /// hunk. A plugin action reached the same way saw strictly less — the
1218 /// position OC.10 fixed for `ex-command-context`. Carried in from
1219 /// [`GrammarEnv::selection`] so both contexts quote one resolver.
1220 ///
1221 /// **Carries no visual kind**, matching the field it mirrors: every
1222 /// consumer so far reads the row span, and inventing a charwise/blockwise
1223 /// contract before something holds it would be inventing a promise.
1224 pub selection: Option<lattice_protocol::position::Range>,
1225 /// TS.1: a point-in-time tree-sitter snapshot for the buffer the action
1226 /// fired in, **type-erased** as `Arc<dyn Any>` so `lattice-grammar` keeps
1227 /// its `protocol`+`core`-only dep set (the same reason `buffer` is a
1228 /// `lattice-core` type — a concrete `SyntaxSnapshot` would drag the whole
1229 /// syntax stack under the lean grammar crate). The host upcasts the
1230 /// buffer's `Arc<SyntaxSnapshot>` here **at the same instant** it clones
1231 /// `buffer` (so tree + text versions agree); native actions ignore it; the
1232 /// plugin-host trampoline downcasts it to mint a `tree-snapshot` resource
1233 /// so a grammar plugin can query structure (auto-pair's `enclosing` scope).
1234 /// `None` when the buffer has no parse (plain text / parse pending).
1235 pub syntax: Option<Arc<dyn std::any::Any + Send + Sync>>,
1236 /// Cooperative cancellation handle (DESIGN.md §5.2.5). Most
1237 /// actions are O(1) state mutations and ignore this; long-
1238 /// running ones (a hypothetical "rebuild fold tree" action)
1239 /// poll `cancel.check()?` between iterations.
1240 pub cancel: crate::CancellationToken,
1241 /// OM.6b: the file the action's buffer is backed by, so a plugin action's
1242 /// `document` handle can answer `path()` — the question `org-archive-subtree`
1243 /// asks to name `<file>_archive`.
1244 ///
1245 /// Owned, unlike the motion and text-object peers, because this context is
1246 /// owned; `Arc<PathBuf>` rather than `PathBuf` so it is an Arc bump per
1247 /// dispatch and not an allocation. `None` for a buffer with no file.
1248 pub path: Option<std::sync::Arc<std::path::PathBuf>>,
1249}
1250
1251/// Evaluator callback for a free-form action. Returns the
1252/// [`crate::effect::Effect`] the host should apply -- typically
1253/// `Effect::AppAction(AppEffect::Foo)` for a chord-bound action,
1254/// occasionally a richer `Effect::Many([...])` if the action also
1255/// emits an edit / mode transition / yank.
1256type ActionFn = Arc<dyn Fn(&ActionContext) -> GrammarResult<crate::effect::Effect> + Send + Sync>;
1257
1258/// A registered free-form action: a command with no grammar role, usually
1259/// bound to a chord.
1260///
1261/// Registered through [`CommandRegistry::register_action`] (or
1262/// [`CommandRegistry::register_plugin_action`]). Most return
1263/// `Effect::AppAction(..)` for the host to apply; mode-owned actions are
1264/// registered by their mode's crate, not the host.
1265#[derive(Clone)]
1266pub struct ActionSpec {
1267 /// The evaluator: returns the [`Effect`](crate::Effect) the host applies.
1268 pub apply: ActionFn,
1269 /// Per-positional-argument metadata (DESIGN.md §B.1). Empty
1270 /// for actions without args (the common case for chord
1271 /// bindings -- the args slot is reserved for future
1272 /// captured-char / numeric-param variants in slice 8.i.2-3).
1273 pub args_schema: Vec<ArgSpec>,
1274}
1275
1276impl std::fmt::Debug for ActionSpec {
1277 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1278 f.debug_struct("ActionSpec").finish_non_exhaustive()
1279 }
1280}
1281
1282/// What a registered command holds in the registry, beyond its metadata.
1283#[derive(Clone)]
1284pub enum CommandRegistration {
1285 /// A motion; see [`MotionSpec`].
1286 Motion(MotionSpec),
1287 /// An operator; see [`OperatorSpec`].
1288 Operator(OperatorSpec),
1289 /// A text object; see [`TextObjectSpec`].
1290 TextObject(TextObjectSpec),
1291 /// An ex-command; see [`ExCommandSpec`].
1292 ExCommand(ExCommandSpec),
1293 /// Free-form App-side action. The dispatcher's
1294 /// `CommandKind::Action` branch invokes the spec's `apply`
1295 /// closure and returns its [`crate::effect::Effect`] -- almost
1296 /// always an [`crate::effect::Effect::AppAction`] carrying a
1297 /// typed [`crate::app_effect::AppEffect`]. Wired in slice 8.i.0;
1298 /// populated from the per-mode keymap modules during 8.i.1-3 as
1299 /// the legacy `Action` bridge retires. See
1300 /// `docs/dev/notes/8i-approach.md`.
1301 Action(ActionSpec),
1302}
1303
1304impl CommandRegistration {
1305 /// The [`CommandKind`] this registration dispatches as.
1306 pub fn kind(&self) -> CommandKind {
1307 match self {
1308 CommandRegistration::Motion(_) => CommandKind::Motion,
1309 CommandRegistration::Operator(_) => CommandKind::Operator,
1310 CommandRegistration::TextObject(_) => CommandKind::TextObject,
1311 CommandRegistration::ExCommand(_) => CommandKind::ExCommand,
1312 CommandRegistration::Action(_) => CommandKind::Action,
1313 }
1314 }
1315}
1316
1317/// The one registry of every command: motions, operators, text objects,
1318/// ex-commands and actions, built-in and plugin alike (DESIGN.md §5.2.1).
1319///
1320/// **Registration.** Each kind has a `register_<kind>` method taking a
1321/// canonical name, a doc string and a spec. It mints a fresh
1322/// [`CommandId`] from a process-wide counter, records the caller's file and
1323/// line as provenance (`#[track_caller]`; callers cannot forge it), and
1324/// returns a typed id ([`MotionId`], [`OperatorId`], …). Plugins go
1325/// through `register_plugin_<kind>`, which stamps
1326/// [`SourceLayer::Plugin`] instead, and are
1327/// removed wholesale by [`Self::unregister_plugin`].
1328///
1329/// **Names.** Names are namespaced by kind: `motion:word-forward`,
1330/// `operator:delete`, `text-object:inner-word`, `ex:write`, `action:…`.
1331/// Names are not checked for uniqueness: registering a name again mints a
1332/// new id and repoints the name at it, while the old id stays
1333/// dispatchable. Aliases (`:w`) are not registry entries; the `:`
1334/// front-end maps them to canonical names.
1335///
1336/// **Lookup and dispatch.** [`Self::id_by_name`] and [`Self::lookup`]
1337/// resolve names and ids to [`CommandSpec`] metadata;
1338/// [`execute`](crate::execute) takes the registry and an invocation and
1339/// routes by kind. At runtime the registry is shared as a
1340/// [`CommandRegistryHandle`](crate::CommandRegistryHandle): readers load a
1341/// snapshot wait-free, writers clone, mutate and store it.
1342///
1343/// # Examples
1344///
1345/// ```
1346/// use lattice_grammar::{CommandKind, CommandRegistry, builtins, ex_commands};
1347///
1348/// let mut registry = CommandRegistry::new();
1349/// assert!(registry.is_empty());
1350/// let b = builtins::populate(&mut registry);
1351/// ex_commands::populate(&mut registry);
1352///
1353/// let id = registry.id_by_name("operator:delete").unwrap();
1354/// assert_eq!(id, b.delete.0);
1355/// let spec = registry.lookup(id).unwrap();
1356/// assert_eq!(spec.kind, CommandKind::Operator);
1357/// assert_eq!(registry.lookup_by_name("ex:write").unwrap().kind, CommandKind::ExCommand);
1358///
1359/// // Aliases are the front-end's business, not the registry's.
1360/// assert!(registry.id_by_name("w").is_none());
1361/// ```
1362#[derive(Debug, Default, Clone)]
1363pub struct CommandRegistry {
1364 by_id: HashMap<CommandId, CommandEntry>,
1365 by_name: HashMap<String, CommandId>,
1366 /// Motions in the vim "word-forward" class (`w` / `W`). Under an
1367 /// operator these get the word-motion special case: `dw` / `cw` /
1368 /// `yw` on the last word of a line stop at the line end rather than
1369 /// reaching over the newline into the next line's first word
1370 /// (`:help word-motions`). Populated by `builtins::populate` via
1371 /// [`Self::tag_word_forward_motion`]; read by the operator range
1372 /// resolver.
1373 word_forward_motions: std::collections::HashSet<CommandId>,
1374 /// Monotonic mutation counter, bumped on every `insert` and every
1375 /// non-empty `unregister_plugin`. A cheap version stamp for cache
1376 /// invalidation: the `:`-command-name completion generator keys its cache on
1377 /// this (via [`Self::generation`]) so a plugin's *runtime* command
1378 /// registration or unload — which RCUs a fresh registry with a bumped
1379 /// counter — shows up in `<Tab>` completion without any manual cache flush.
1380 /// Clones with the registry (RCU snapshot), so the stored copy carries the
1381 /// post-mutation value the dispatcher + completion then read.
1382 generation: u64,
1383}
1384
1385#[derive(Clone)]
1386pub(crate) struct CommandEntry {
1387 pub spec: CommandSpec,
1388 pub registration: CommandRegistration,
1389}
1390
1391impl std::fmt::Debug for CommandEntry {
1392 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1393 f.debug_struct("CommandEntry")
1394 .field("spec", &self.spec)
1395 .field("registration", &self.registration.kind().label())
1396 .finish()
1397 }
1398}
1399
1400impl CommandRegistry {
1401 /// An empty registry. Populate it with
1402 /// [`builtins::populate`](crate::builtins::populate) and
1403 /// [`ex_commands::populate`](crate::ex_commands::populate), then the
1404 /// host's and subsystems' own registrations.
1405 pub fn new() -> Self {
1406 Self::default()
1407 }
1408
1409 /// Tag a motion as "word-forward class" (vim `w` / `W`) so the
1410 /// operator range resolver applies the word-motion line-stop special
1411 /// case to it. Called by `builtins::populate` right after the two
1412 /// word-forward motions are registered. Idempotent.
1413 pub fn tag_word_forward_motion(&mut self, id: MotionId) {
1414 self.word_forward_motions.insert(id.0);
1415 }
1416
1417 /// Whether `id` is a word-forward-class motion (see
1418 /// [`Self::tag_word_forward_motion`]).
1419 pub fn is_word_forward_motion(&self, id: CommandId) -> bool {
1420 self.word_forward_motions.contains(&id)
1421 }
1422
1423 /// Register a motion. The caller's source location is captured
1424 /// via `#[track_caller]` -- the caller cannot supply or override
1425 /// it. Trusted subsystems (config loader, plugin host bridge,
1426 /// runtime dispatcher) reach the `pub(crate) insert_motion`
1427 /// companion directly with a layer-appropriate source.
1428 #[track_caller]
1429 pub fn register_motion(&mut self, name: &str, doc: &str, spec: MotionSpec) -> MotionId {
1430 let source = capture_builtin_source();
1431 self.insert_motion(name, doc, spec, source)
1432 }
1433
1434 /// Internal entry point: store the spec with a caller-supplied
1435 /// source. Visible only inside `lattice-grammar`; trusted
1436 /// subsystems in sibling crates reach this via a sealed-trait
1437 /// re-export when they exist (deferred until first cross-crate
1438 /// trusted subsystem lands -- see DESIGN.md §5.11).
1439 pub(crate) fn insert_motion(
1440 &mut self,
1441 name: &str,
1442 doc: &str,
1443 spec: MotionSpec,
1444 source: SourceLocation,
1445 ) -> MotionId {
1446 let id = next_command_id();
1447 let args_schema = spec.args_schema.clone();
1448 self.insert(CommandEntry {
1449 spec: CommandSpec {
1450 id,
1451 name: name.to_string(),
1452 kind: CommandKind::Motion,
1453 doc: doc.to_string(),
1454 args_schema,
1455 source,
1456 latency_class: crate::command::LatencyClass::Reflex,
1457 },
1458 registration: CommandRegistration::Motion(spec),
1459 });
1460 MotionId(id)
1461 }
1462
1463 /// Register an operator; provenance and id minting as for
1464 /// [`Self::register_motion`].
1465 #[track_caller]
1466 pub fn register_operator(&mut self, name: &str, doc: &str, spec: OperatorSpec) -> OperatorId {
1467 let source = capture_builtin_source();
1468 self.insert_operator(name, doc, spec, source)
1469 }
1470
1471 pub(crate) fn insert_operator(
1472 &mut self,
1473 name: &str,
1474 doc: &str,
1475 spec: OperatorSpec,
1476 source: SourceLocation,
1477 ) -> OperatorId {
1478 let id = next_command_id();
1479 let args_schema = spec.args_schema.clone();
1480 self.insert(CommandEntry {
1481 spec: CommandSpec {
1482 id,
1483 name: name.to_string(),
1484 kind: CommandKind::Operator,
1485 doc: doc.to_string(),
1486 args_schema,
1487 source,
1488 latency_class: crate::command::LatencyClass::Reflex,
1489 },
1490 registration: CommandRegistration::Operator(spec),
1491 });
1492 OperatorId(id)
1493 }
1494
1495 /// Register a text object; provenance and id minting as for
1496 /// [`Self::register_motion`].
1497 #[track_caller]
1498 pub fn register_text_object(
1499 &mut self,
1500 name: &str,
1501 doc: &str,
1502 spec: TextObjectSpec,
1503 ) -> TextObjectId {
1504 let source = capture_builtin_source();
1505 self.insert_text_object(name, doc, spec, source)
1506 }
1507
1508 pub(crate) fn insert_text_object(
1509 &mut self,
1510 name: &str,
1511 doc: &str,
1512 spec: TextObjectSpec,
1513 source: SourceLocation,
1514 ) -> TextObjectId {
1515 let id = next_command_id();
1516 let args_schema = spec.args_schema.clone();
1517 self.insert(CommandEntry {
1518 spec: CommandSpec {
1519 id,
1520 name: name.to_string(),
1521 kind: CommandKind::TextObject,
1522 doc: doc.to_string(),
1523 args_schema,
1524 source,
1525 latency_class: crate::command::LatencyClass::Reflex,
1526 },
1527 registration: CommandRegistration::TextObject(spec),
1528 });
1529 TextObjectId(id)
1530 }
1531
1532 /// Register an ex-command; provenance and id minting as for
1533 /// [`Self::register_motion`]. The command's latency class comes from
1534 /// [`ExCommandSpec::latency_class`] (other kinds are always `Reflex`).
1535 #[track_caller]
1536 pub fn register_ex_command(
1537 &mut self,
1538 name: &str,
1539 doc: &str,
1540 spec: ExCommandSpec,
1541 ) -> ExCommandId {
1542 let source = capture_builtin_source();
1543 self.insert_ex_command(name, doc, spec, source)
1544 }
1545
1546 pub(crate) fn insert_ex_command(
1547 &mut self,
1548 name: &str,
1549 doc: &str,
1550 spec: ExCommandSpec,
1551 source: SourceLocation,
1552 ) -> ExCommandId {
1553 let id = next_command_id();
1554 let args_schema = spec.args_schema.clone();
1555 self.insert(CommandEntry {
1556 spec: CommandSpec {
1557 id,
1558 name: name.to_string(),
1559 kind: CommandKind::ExCommand,
1560 doc: doc.to_string(),
1561 args_schema,
1562 source,
1563 latency_class: spec.latency_class,
1564 },
1565 registration: CommandRegistration::ExCommand(spec),
1566 });
1567 ExCommandId(id)
1568 }
1569
1570 /// Register a free-form action (DESIGN.md §5.2.1; see
1571 /// `docs/dev/notes/8i-approach.md`). Used by chord bindings whose
1572 /// historical `Action` enum payload had no grammar concept
1573 /// attached. The spec's `apply` returns an
1574 /// [`crate::effect::Effect`] -- typically
1575 /// `Effect::AppAction(AppEffect::...)`.
1576 #[track_caller]
1577 pub fn register_action(&mut self, name: &str, doc: &str, spec: ActionSpec) -> CommandId {
1578 let source = capture_builtin_source();
1579 self.insert_action(name, doc, spec, source)
1580 }
1581
1582 pub(crate) fn insert_action(
1583 &mut self,
1584 name: &str,
1585 doc: &str,
1586 spec: ActionSpec,
1587 source: SourceLocation,
1588 ) -> CommandId {
1589 let id = next_command_id();
1590 let args_schema = spec.args_schema.clone();
1591 self.insert(CommandEntry {
1592 spec: CommandSpec {
1593 id,
1594 name: name.to_string(),
1595 kind: CommandKind::Action,
1596 doc: doc.to_string(),
1597 args_schema,
1598 source,
1599 latency_class: crate::command::LatencyClass::Reflex,
1600 },
1601 registration: CommandRegistration::Action(spec),
1602 });
1603 id
1604 }
1605
1606 // ---- Plugin-contribution registration (PH7.7c, §6) ----
1607 // The forgery-safe cross-crate seam for a WASM plugin's grammar
1608 // contributions. Each takes the **host-issued** `plugin_id` (a `u32`, never a
1609 // `SourceLocation`) and always stamps `SourceLayer::Plugin(plugin_id)` via
1610 // [`SourceLocation::plugin`] — so the "no public fn takes a `SourceLocation`"
1611 // forgery invariant (`source.rs`) holds, and a plugin cannot forge builtin /
1612 // user provenance. The `spec.apply` / `parse_args` are the plugin host's sync
1613 // trampoline closures into the guest (`lattice-plugin-host::grammar_trampoline`);
1614 // the registry, dispatcher, and every `:describe-*` view treat the entry
1615 // exactly like a builtin (paramount #3 — a plugin motion is first-class
1616 // grammar). These are the public counterpart to the `pub(crate) insert_*`
1617 // path builtins use through `register_*`.
1618
1619 /// Register a plugin-contributed motion under `SourceLayer::Plugin(plugin_id)`.
1620 pub fn register_plugin_motion(
1621 &mut self,
1622 plugin_id: u32,
1623 name: &str,
1624 doc: &str,
1625 spec: MotionSpec,
1626 ) -> MotionId {
1627 self.insert_motion(name, doc, spec, SourceLocation::plugin(plugin_id))
1628 }
1629
1630 /// Register a plugin-contributed operator under `SourceLayer::Plugin(plugin_id)`.
1631 pub fn register_plugin_operator(
1632 &mut self,
1633 plugin_id: u32,
1634 name: &str,
1635 doc: &str,
1636 spec: OperatorSpec,
1637 ) -> OperatorId {
1638 self.insert_operator(name, doc, spec, SourceLocation::plugin(plugin_id))
1639 }
1640
1641 /// Register a plugin-contributed text object under `SourceLayer::Plugin(plugin_id)`.
1642 pub fn register_plugin_text_object(
1643 &mut self,
1644 plugin_id: u32,
1645 name: &str,
1646 doc: &str,
1647 spec: TextObjectSpec,
1648 ) -> TextObjectId {
1649 self.insert_text_object(name, doc, spec, SourceLocation::plugin(plugin_id))
1650 }
1651
1652 /// Register a plugin-contributed ex-command under `SourceLayer::Plugin(plugin_id)`.
1653 pub fn register_plugin_ex_command(
1654 &mut self,
1655 plugin_id: u32,
1656 name: &str,
1657 doc: &str,
1658 spec: ExCommandSpec,
1659 ) -> ExCommandId {
1660 self.insert_ex_command(name, doc, spec, SourceLocation::plugin(plugin_id))
1661 }
1662
1663 /// Register a plugin-contributed action under `SourceLayer::Plugin(plugin_id)`.
1664 pub fn register_plugin_action(
1665 &mut self,
1666 plugin_id: u32,
1667 name: &str,
1668 doc: &str,
1669 spec: ActionSpec,
1670 ) -> CommandId {
1671 self.insert_action(name, doc, spec, SourceLocation::plugin(plugin_id))
1672 }
1673
1674 /// Remove every command a plugin contributed, keyed by its host-issued
1675 /// `plugin_id` (the `u32` inside `SourceLayer::Plugin`). The teardown seam
1676 /// for a plugin reload / unload (PH7.12b): the registry is otherwise
1677 /// append-only, so without this a reload would re-register on top of the
1678 /// old entries and the `by_id`/`by_name` maps would grow unbounded across
1679 /// reloads (audit F6). Provenance-driven — only `Plugin(plugin_id)` entries
1680 /// go; built-in / config / runtime commands are never touched, mirroring
1681 /// the forgery invariant (a caller supplies only a `u32`, never a
1682 /// `SourceLayer`). Returns the number of commands removed (0 if the plugin
1683 /// contributed none — an idempotent no-op on a second unload). Every
1684 /// index (`by_id`, `by_name`, the word-forward tag set) is kept consistent.
1685 pub fn unregister_plugin(&mut self, plugin_id: u32) -> usize {
1686 let doomed: Vec<CommandId> = self
1687 .by_id
1688 .iter()
1689 .filter(|(_, entry)| entry.spec.source.layer == SourceLayer::Plugin(plugin_id))
1690 .map(|(id, _)| *id)
1691 .collect();
1692 for id in &doomed {
1693 if let Some(entry) = self.by_id.remove(id) {
1694 self.by_name.remove(&entry.spec.name);
1695 }
1696 self.word_forward_motions.remove(id);
1697 }
1698 if !doomed.is_empty() {
1699 self.generation = self.generation.wrapping_add(1);
1700 }
1701 doomed.len()
1702 }
1703
1704 /// Metadata for a registered id, or `None` if the id is unknown here
1705 /// (never registered, or removed by [`Self::unregister_plugin`]).
1706 pub fn lookup(&self, id: CommandId) -> Option<&CommandSpec> {
1707 self.by_id.get(&id).map(|e| &e.spec)
1708 }
1709
1710 /// Metadata by canonical name (`"motion:word-forward"`). Exact match;
1711 /// no alias or prefix resolution.
1712 pub fn lookup_by_name(&self, name: &str) -> Option<&CommandSpec> {
1713 self.by_name.get(name).and_then(|id| self.lookup(*id))
1714 }
1715
1716 /// The id currently bound to a canonical name. How modes and the host
1717 /// find a command they did not register themselves (e.g.
1718 /// `id_by_name("action:…")` at mode activation).
1719 pub fn id_by_name(&self, name: &str) -> Option<CommandId> {
1720 self.by_name.get(name).copied()
1721 }
1722
1723 pub(crate) fn entry(&self, id: CommandId) -> Option<&CommandEntry> {
1724 self.by_id.get(&id)
1725 }
1726
1727 /// Is `id` a motion that vim would call a **jump**?
1728 ///
1729 /// `MotionSpec::jump` has been declared since the grammar's first slice
1730 /// and, until VM.3a, read by nobody: the host decided what counted as a
1731 /// jump with a hardcoded `goto_first_line || goto_last_line` in
1732 /// `run_document_invocation`. So `}`, `{`, `(`, `)`, the sixteen
1733 /// tree-sitter structural motions and every plugin motion declared
1734 /// `jump: true` and silently got neither a position-history entry nor a
1735 /// fold-open at the destination — org's headline motions say in their own
1736 /// source comment that a headline jump "is somewhere you want `<C-o>` to
1737 /// bring you back from", and it was not.
1738 ///
1739 /// `false` for a non-motion or an unregistered id, so a caller can ask
1740 /// about any `CommandId` without pre-checking the kind.
1741 pub fn motion_is_jump(&self, id: CommandId) -> bool {
1742 match self.by_id.get(&id).map(|e| &e.registration) {
1743 Some(CommandRegistration::Motion(spec)) => spec.jump,
1744 _ => false,
1745 }
1746 }
1747
1748 /// VM.3g-1: what this command does to the goal column. Anything that is NOT
1749 /// a motion — an operator, an action, an ex-command — answers
1750 /// `SetFromTarget`, which is what vim does after an edit, an Insert exit or
1751 /// a yank: the column it leaves the cursor on becomes the new goal.
1752 pub fn motion_curswant(&self, id: CommandId) -> CurswantEffect {
1753 match self.by_id.get(&id).map(|e| &e.registration) {
1754 Some(CommandRegistration::Motion(spec)) => spec.curswant,
1755 _ => CurswantEffect::SetFromTarget,
1756 }
1757 }
1758
1759 /// Borrow the [`ExCommandSpec`] body for an ex-command id. Returns
1760 /// `None` for ids that aren't ex-commands or aren't registered.
1761 /// Used by the `:`-line parser front-end so it can call the
1762 /// command's `parse_args` callback before building the
1763 /// `CommandInvocation`.
1764 pub fn ex_command_spec(&self, id: CommandId) -> Option<&ExCommandSpec> {
1765 match self.by_id.get(&id)?.registration {
1766 CommandRegistration::ExCommand(ref spec) => Some(spec),
1767 _ => None,
1768 }
1769 }
1770
1771 /// Number of registered commands.
1772 pub fn len(&self) -> usize {
1773 self.by_id.len()
1774 }
1775
1776 /// Whether nothing is registered.
1777 pub fn is_empty(&self) -> bool {
1778 self.by_id.is_empty()
1779 }
1780
1781 /// Every registered canonical name, in no particular order.
1782 pub fn names(&self) -> impl Iterator<Item = &str> {
1783 self.by_name.keys().map(String::as_str)
1784 }
1785
1786 fn insert(&mut self, entry: CommandEntry) {
1787 let id = entry.spec.id;
1788 let name = entry.spec.name.clone();
1789 self.by_id.insert(id, entry);
1790 self.by_name.insert(name, id);
1791 self.generation = self.generation.wrapping_add(1);
1792 }
1793
1794 /// Monotonic mutation counter (see the `generation` field). Read by the
1795 /// completion layer's command-name generator to key its cache: a change
1796 /// means the command set moved (a plugin loaded or unloaded), so the cached
1797 /// candidate list must be regenerated rather than served stale.
1798 pub fn generation(&self) -> u64 {
1799 self.generation
1800 }
1801}
1802
1803fn next_command_id() -> CommandId {
1804 static NEXT: AtomicU64 = AtomicU64::new(1);
1805 CommandId::new(NEXT.fetch_add(1, Ordering::Relaxed))
1806}
1807
1808/// Read the immediate caller's source location through the
1809/// `#[track_caller]` mechanism. Must be called from inside a
1810/// `#[track_caller]`-marked function whose caller is the
1811/// registration site we want to record. Marked
1812/// `#[track_caller]` itself so the location propagates through:
1813/// `register_motion` -> `capture_builtin_source` -> caller's site.
1814#[track_caller]
1815fn capture_builtin_source() -> SourceLocation {
1816 let loc = std::panic::Location::caller();
1817 SourceLocation {
1818 layer: SourceLayer::Builtin,
1819 kind: SourceKind::File {
1820 path: std::path::PathBuf::from(loc.file()),
1821 line: Some(loc.line()),
1822 },
1823 }
1824}
1825
1826/// Helper used by the dispatcher to extract the typed body from a registry
1827/// entry.
1828pub(crate) fn require_motion(entry: &CommandEntry) -> GrammarResult<&MotionSpec> {
1829 match &entry.registration {
1830 CommandRegistration::Motion(s) => Ok(s),
1831 other => Err(CommandError::KindMismatch {
1832 expected: "motion",
1833 actual: other.kind().label(),
1834 }),
1835 }
1836}
1837
1838pub(crate) fn require_operator(entry: &CommandEntry) -> GrammarResult<&OperatorSpec> {
1839 match &entry.registration {
1840 CommandRegistration::Operator(s) => Ok(s),
1841 other => Err(CommandError::KindMismatch {
1842 expected: "operator",
1843 actual: other.kind().label(),
1844 }),
1845 }
1846}
1847
1848pub(crate) fn require_text_object(entry: &CommandEntry) -> GrammarResult<&TextObjectSpec> {
1849 match &entry.registration {
1850 CommandRegistration::TextObject(s) => Ok(s),
1851 other => Err(CommandError::KindMismatch {
1852 expected: "text-object",
1853 actual: other.kind().label(),
1854 }),
1855 }
1856}
1857
1858pub(crate) fn require_ex_command(entry: &CommandEntry) -> GrammarResult<&ExCommandSpec> {
1859 match &entry.registration {
1860 CommandRegistration::ExCommand(s) => Ok(s),
1861 other => Err(CommandError::KindMismatch {
1862 expected: "ex-command",
1863 actual: other.kind().label(),
1864 }),
1865 }
1866}
1867
1868pub(crate) fn require_action(entry: &CommandEntry) -> GrammarResult<&ActionSpec> {
1869 match &entry.registration {
1870 CommandRegistration::Action(s) => Ok(s),
1871 other => Err(CommandError::KindMismatch {
1872 expected: "action",
1873 actual: other.kind().label(),
1874 }),
1875 }
1876}
1877
1878#[cfg(test)]
1879mod tests {
1880 #![allow(clippy::unwrap_used, clippy::panic)]
1881 use super::*;
1882
1883 fn dummy_motion() -> MotionSpec {
1884 MotionSpec {
1885 curswant: crate::registry::CurswantEffect::default(),
1886 jump: false,
1887 exclusive: false,
1888 apply: Arc::new(|ctx| {
1889 Ok(MotionResult {
1890 curswant: None,
1891 target: ctx.from,
1892 linewise: false,
1893 exclusive: None,
1894 notice: None,
1895 })
1896 }),
1897 args_schema: vec![],
1898 }
1899 }
1900
1901 #[test]
1902 fn empty_registry() {
1903 let r = CommandRegistry::new();
1904 assert!(r.is_empty());
1905 assert_eq!(r.len(), 0);
1906 assert!(r.lookup_by_name("nope").is_none());
1907 }
1908
1909 #[test]
1910 fn register_motion_returns_id_and_finds_by_name() {
1911 let mut r = CommandRegistry::new();
1912 let id = r.register_motion("test:noop", "no-op motion", dummy_motion());
1913 assert_eq!(r.len(), 1);
1914 let spec = r.lookup_by_name("test:noop").unwrap();
1915 assert_eq!(spec.id, id.0);
1916 assert_eq!(spec.kind, CommandKind::Motion);
1917 }
1918
1919 #[test]
1920 fn distinct_ids_for_distinct_registrations() {
1921 let mut r = CommandRegistry::new();
1922 let a = r.register_motion("a", "", dummy_motion());
1923 let b = r.register_motion("b", "", dummy_motion());
1924 assert_ne!(a.0, b.0);
1925 }
1926
1927 #[test]
1928 fn unregister_plugin_removes_only_that_plugins_commands() {
1929 let mut r = CommandRegistry::new();
1930 // A built-in and two plugins, one of which registers a word-forward motion.
1931 r.register_motion("builtin:w", "builtin", dummy_motion());
1932 let p7 = r.register_plugin_motion(7, "p7:down", "", dummy_motion());
1933 r.tag_word_forward_motion(p7);
1934 r.register_plugin_motion(7, "p7:up", "", dummy_motion());
1935 r.register_plugin_motion(9, "p9:left", "", dummy_motion());
1936 assert_eq!(r.len(), 4);
1937
1938 // Unregister plugin 7: both its commands go, the built-in and plugin 9 stay.
1939 let removed = r.unregister_plugin(7);
1940 assert_eq!(removed, 2);
1941 assert_eq!(r.len(), 2);
1942 assert!(r.lookup_by_name("p7:down").is_none());
1943 assert!(r.lookup_by_name("p7:up").is_none());
1944 assert!(r.lookup_by_name("builtin:w").is_some());
1945 assert!(r.lookup_by_name("p9:left").is_some());
1946 // The word-forward tag for the removed motion is gone too.
1947 assert!(!r.is_word_forward_motion(p7.0));
1948
1949 // Idempotent: a second unload of the same plugin removes nothing.
1950 assert_eq!(r.unregister_plugin(7), 0);
1951 }
1952
1953 #[test]
1954 fn generation_bumps_on_register_and_nonempty_unregister() {
1955 // The completion layer keys its `:`-command cache on this counter, so it
1956 // MUST move whenever the command set changes (a plugin drain / unload) —
1957 // otherwise plugin commands never appear in `<Tab>` completion.
1958 let mut r = CommandRegistry::new();
1959 let g0 = r.generation();
1960 r.register_motion("builtin:w", "builtin", dummy_motion());
1961 let g1 = r.generation();
1962 assert!(g1 > g0, "registering a command must bump generation");
1963 r.register_plugin_motion(7, "p7:down", "", dummy_motion());
1964 let g2 = r.generation();
1965 assert!(g2 > g1, "a plugin registration must bump generation");
1966 assert_eq!(r.unregister_plugin(7), 1);
1967 let g3 = r.generation();
1968 assert!(
1969 g3 > g2,
1970 "unregistering a plugin's commands must bump generation"
1971 );
1972 // A no-op unregister (nothing removed) must NOT bump — no cache churn.
1973 assert_eq!(r.unregister_plugin(7), 0);
1974 assert_eq!(
1975 r.generation(),
1976 g3,
1977 "an empty unregister must not bump generation"
1978 );
1979 }
1980
1981 #[test]
1982 fn mode_toggle_spec_toggles_named_mode_and_rejects_args() {
1983 let spec = mode_toggle_ex_command_spec("auto-pair-mode");
1984 // No args → Ok(None); any args → BadArgs.
1985 assert!(matches!(
1986 (spec.parse_args)("", false),
1987 Ok(crate::args::Args::None)
1988 ));
1989 assert!((spec.parse_args)("nope", false).is_err());
1990 // apply → ToggleMode for the named mode (the spec ignores ctx).
1991 let ctx = ExCommandContext {
1992 bang: false,
1993 args: crate::args::Args::None,
1994 range: None,
1995 register: Register::default(),
1996 count: Count::default(),
1997 buffer_id: BufferId::default(),
1998 // OC.10: this spec ignores the buffer entirely (it toggles a mode),
1999 // so the four carry their empty forms.
2000 cursor: Position::default(),
2001 buffer: Buffer::default(),
2002 path: None,
2003 syntax: None,
2004 cancel: crate::CancellationToken::new(),
2005 };
2006 match (spec.apply)(&ctx) {
2007 Ok(crate::effect::Effect::ToggleMode { mode_name }) => {
2008 assert_eq!(mode_name, "auto-pair-mode");
2009 }
2010 other => panic!("expected ToggleMode, got {other:?}"),
2011 }
2012 }
2013
2014 #[test]
2015 fn lookup_by_id_returns_metadata() {
2016 let mut r = CommandRegistry::new();
2017 let id = r.register_motion("test:m", "doc", dummy_motion());
2018 let spec = r.lookup(id.0).unwrap();
2019 assert_eq!(spec.name, "test:m");
2020 assert_eq!(spec.doc, "doc");
2021 }
2022
2023 // ---- Source-capture determinism (DESIGN.md §5.11) ----
2024 //
2025 // `#[track_caller]` is the load-bearing piece -- it captures the
2026 // caller's `(file, line)` automatically so registration sites
2027 // never need to spell their location explicitly. These tests
2028 // pin its behaviour: any future refactor that breaks call-site
2029 // capture (e.g. wrapping `register_motion` in a `dyn Fn`
2030 // dispatcher, which resets the location) fails CI.
2031
2032 #[test]
2033 fn track_caller_captures_register_motion_call_site() {
2034 let mut r = CommandRegistry::new();
2035 // Sentinel: capture the line of the next call.
2036 let expected_line = line!() + 1;
2037 let id = r.register_motion("test:sentinel", "", dummy_motion());
2038 let spec = r.lookup(id.0).unwrap();
2039 match &spec.source.kind {
2040 SourceKind::File {
2041 path,
2042 line: Some(line),
2043 } => {
2044 assert!(
2045 path.to_string_lossy().contains("registry.rs"),
2046 "expected path to contain `registry.rs`, got `{}`",
2047 path.display()
2048 );
2049 assert_eq!(
2050 *line, expected_line,
2051 "track_caller line drift: expected {expected_line}, got {line}"
2052 );
2053 }
2054 other => panic!("expected Builtin/File source, got {other:?}"),
2055 }
2056 assert_eq!(spec.source.layer, SourceLayer::Builtin);
2057 }
2058
2059 #[test]
2060 fn track_caller_captures_register_operator_call_site() {
2061 let mut r = CommandRegistry::new();
2062 let expected_line = line!() + 1;
2063 let id = r.register_operator(
2064 "test:sentinel-op",
2065 "",
2066 OperatorSpec {
2067 repeatable: false,
2068 apply: Arc::new(|_| Ok(crate::effect::Effect::None)),
2069 args_schema: vec![],
2070 blockwise_per_row: false,
2071 post_motion_char: false,
2072 },
2073 );
2074 let spec = r.lookup(id.0).unwrap();
2075 if let SourceKind::File {
2076 line: Some(line), ..
2077 } = &spec.source.kind
2078 {
2079 // The literal ends 6 lines after the `expected_line`
2080 // assignment because of formatting -- track_caller records
2081 // the line of the call expression's *first* token.
2082 assert_eq!(*line, expected_line);
2083 } else {
2084 panic!("expected File source, got {:?}", spec.source.kind);
2085 }
2086 }
2087
2088 #[test]
2089 fn track_caller_captures_register_text_object_call_site() {
2090 let mut r = CommandRegistry::new();
2091 let expected_line = line!() + 1;
2092 let id = r.register_text_object(
2093 "test:sentinel-tobj",
2094 "",
2095 TextObjectSpec {
2096 apply: Arc::new(|_| Ok(ProtoRange::new(Position::ZERO, Position::ZERO))),
2097 args_schema: vec![],
2098 },
2099 );
2100 let spec = r.lookup(id.0).unwrap();
2101 if let SourceKind::File {
2102 line: Some(line), ..
2103 } = &spec.source.kind
2104 {
2105 assert_eq!(*line, expected_line);
2106 } else {
2107 panic!("expected File source");
2108 }
2109 }
2110
2111 #[test]
2112 fn each_registration_records_its_own_line() {
2113 // Two adjacent registrations should record different lines,
2114 // proving that `#[track_caller]` distinguishes call sites
2115 // and not just call origins.
2116 let mut r = CommandRegistry::new();
2117 let id_a = r.register_motion("test:a", "", dummy_motion());
2118 let id_b = r.register_motion("test:b", "", dummy_motion());
2119 let line_a = line_of(&r, id_a.0).expect("id_a has File source");
2120 let line_b = line_of(&r, id_b.0).expect("id_b has File source");
2121 assert_ne!(line_a, line_b);
2122 assert!(
2123 line_b > line_a,
2124 "second call's line should follow the first"
2125 );
2126 }
2127
2128 #[test]
2129 fn track_caller_propagates_through_helper_marked_track_caller() {
2130 // A helper that wraps `register_motion` and is itself marked
2131 // `#[track_caller]` should pass the caller's location through.
2132 // Without `#[track_caller]` on the helper, the location would
2133 // be that of the inner call inside the helper.
2134 #[track_caller]
2135 fn helper(r: &mut CommandRegistry, name: &str) -> MotionId {
2136 r.register_motion(name, "", dummy_motion())
2137 }
2138 let mut r = CommandRegistry::new();
2139 let expected_line = line!() + 1;
2140 let id = helper(&mut r, "test:via-helper");
2141 let line = line_of(&r, id.0).expect("File source");
2142 assert_eq!(
2143 line, expected_line,
2144 "helper marked #[track_caller] should propagate the OUTER caller's line"
2145 );
2146 }
2147
2148 #[test]
2149 fn unmarked_helper_records_inner_line_not_outer() {
2150 // Counterexample: a helper that is NOT `#[track_caller]`
2151 // captures its own internal line, not the outer caller.
2152 // This documents the propagation contract -- any helper that
2153 // wraps `register_*` must opt in.
2154 fn unmarked_helper(r: &mut CommandRegistry, name: &str) -> (MotionId, u32) {
2155 let inner_line = line!() + 1;
2156 let id = r.register_motion(name, "", dummy_motion());
2157 (id, inner_line)
2158 }
2159 let mut r = CommandRegistry::new();
2160 let (id, inner_line) = unmarked_helper(&mut r, "test:unmarked");
2161 let captured = line_of(&r, id.0).expect("File source");
2162 assert_eq!(
2163 captured, inner_line,
2164 "unmarked helper should record the INNER call line"
2165 );
2166 }
2167
2168 fn line_of(r: &CommandRegistry, id: CommandId) -> Option<u32> {
2169 match &r.lookup(id)?.source.kind {
2170 SourceKind::File { line, .. } => *line,
2171 _ => None,
2172 }
2173 }
2174}