Skip to main content

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///     &registry,
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///     &registry,
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}