Skip to main content

lattice_grammar/
dispatcher.rs

1//! The unified dispatcher (DESIGN.md §5.2.1).
2//!
3//! `execute` is the single entry point through which every command flows:
4//! built-in operators / motions / text-objects, ex-commands parsed from `:`,
5//! plugin contributions, and palette selections. Vim's grammar UX is
6//! preserved exactly via the keystroke parser (which assembles a
7//! `CommandInvocation` and calls `execute`); the simplification is that there
8//! is just one dispatcher below the parser.
9//!
10//! Phase 1 implements operator-with-motion-target (the most common path),
11//! motion-alone, text-object-alone, and explicit grammar `Range` resolution.
12//! `ExCommand` and `Action` paths are wired -- the latter via slice 8.i.0
13//! (see `docs/dev/notes/8i-approach.md`); registry entries grow during slices
14//! 8.i.1-3 as the legacy `Action` bridge in `lattice-ui-tui` retires.
15
16use lattice_protocol::position::{Position, Range as ProtoRange};
17
18use crate::cancel::{CancellationToken, CheckCancelled};
19use crate::command::{CommandInvocation, CommandKind};
20use crate::effect::{Effect, YankKind};
21use crate::error::{CommandError, GrammarResult};
22use crate::modal::ModalState;
23use crate::range::Range;
24use crate::registry::{
25    ActionContext, CommandEntry, CommandRegistry, ExCommandContext, MotionContext, OperatorContext,
26    TextObjectContext, require_action, require_ex_command, require_motion, require_operator,
27    require_text_object,
28};
29use crate::target::Target;
30use lattice_core::{Buffer, BufferId, Document};
31
32/// Execute a `CommandInvocation` against `document`, using `registry` to
33/// resolve motions / text-objects / operators.
34///
35/// `cursor` is the position the modal engine considers "current" -- typically
36/// the primary selection's head.
37///
38/// `cancel` is the cooperative cancellation handle (DESIGN.md §5.2.5).
39/// Hot loops inside evaluators poll `cancel.check()?` between iterations;
40/// on a flipped token the dispatcher returns
41/// [`CommandError::Cancelled`] and commits no `Effect`. Callers that
42/// don't drive cancellation (tests, scripts) pass
43/// [`CancellationToken::never`].
44pub fn execute(
45    registry: &CommandRegistry,
46    document: &mut Document,
47    buffer_id: BufferId,
48    cursor: Position,
49    invocation: CommandInvocation,
50    cancel: &CancellationToken,
51) -> GrammarResult<Effect> {
52    // Most callers (and any buffer with no tree-sitter parse / no
53    // comment syntax) dispatch with an empty env. The structural +
54    // comment text objects resolve nothing in that case; everything
55    // else is unaffected.
56    execute_with_env(
57        registry,
58        document,
59        buffer_id,
60        cursor,
61        invocation,
62        cancel,
63        crate::registry::GrammarEnv::default(),
64    )
65}
66
67/// N.1.4a / N.1.6 (2026-06-10): `execute` plus the per-dispatch
68/// [`GrammarEnv`](crate::registry::GrammarEnv) — the tree-sitter
69/// `scope_resolver` (`af`/`ac`) and the `comment_syntax` (`aC`/`iC`).
70/// The host builds the env (N.1.4b / N.1.6) and threads it down to the
71/// `TextObjectContext`; the classic objects (`iw`, `ap`, `i{`) ignore it.
72pub fn execute_with_env(
73    registry: &CommandRegistry,
74    document: &mut Document,
75    buffer_id: BufferId,
76    cursor: Position,
77    invocation: CommandInvocation,
78    cancel: &CancellationToken,
79    env: crate::registry::GrammarEnv<'_>,
80) -> GrammarResult<Effect> {
81    // Honor any pre-existing cancellation request before we start.
82    cancel.check()?;
83
84    let entry = registry
85        .entry(invocation.command)
86        .ok_or(CommandError::UnknownCommand)?;
87
88    match entry.spec.kind {
89        CommandKind::Motion => {
90            execute_motion(document, buffer_id, cursor, &invocation, entry, cancel, env)
91        }
92        CommandKind::TextObject => {
93            execute_text_object(document, cursor, &invocation, entry, cancel, env)
94        }
95        CommandKind::Operator => execute_operator(
96            registry,
97            document,
98            buffer_id,
99            cursor,
100            &invocation,
101            entry,
102            cancel,
103            env,
104        ),
105        CommandKind::ExCommand => {
106            execute_ex_command(document, buffer_id, cursor, &invocation, entry, cancel, env)
107        }
108        CommandKind::Action => {
109            execute_action(document, buffer_id, cursor, &invocation, entry, cancel, env)
110        }
111    }
112}
113
114fn execute_action(
115    document: &Document,
116    buffer_id: BufferId,
117    cursor: Position,
118    invocation: &CommandInvocation,
119    entry: &CommandEntry,
120    cancel: &CancellationToken,
121    env: crate::registry::GrammarEnv<'_>,
122) -> GrammarResult<Effect> {
123    let spec = require_action(entry)?;
124    let ctx = ActionContext {
125        args: invocation.args.clone(),
126        register: invocation.register_or_default(),
127        count: invocation.count_or_default(),
128        cursor,
129        buffer_id,
130        // O(1) rope clone (Arc-shared nodes) — a point-in-time buffer view for
131        // a plugin action's `document` handle (AP.0.1). Native actions ignore it.
132        buffer: document.buffer().clone(),
133        // TS.1: clone the per-dispatch tree snapshot (an `Arc` bump) into the
134        // owned context so a plugin action's `tree-snapshot` handle reads the
135        // SAME point-in-time tree the buffer above was cloned from (§7 version
136        // agreement). Native actions ignore it; `None` when the buffer has no
137        // parse.
138        syntax: env.syntax.map(std::sync::Arc::clone),
139        // OS.2: the host resolved this once, for both this context and the
140        // mode one. Carried, never re-derived.
141        selection: env.selection,
142        cancel: cancel.clone(),
143        // OM.6b: an Arc bump, so an action that never asks pays nothing but
144        // the refcount.
145        path: document.path_shared(),
146    };
147    (spec.apply)(&ctx)
148}
149
150/// Resolve a *motion-only* invocation against a bare [`Buffer`] +
151/// cursor, without a [`Document`] / undo stack / selections.
152///
153/// This is the read-only path used by buffer kinds that aren't
154/// document-shaped (today: help buffers; future: file-tree, outline,
155/// diagnostics views per DESIGN.md §5.9). The chord grammar is
156/// shared -- pressing `j` in a help buffer dispatches the same
157/// `line_down` motion as in a code buffer -- but the motion runs
158/// against a different rope.
159///
160/// Returns the resolved target [`Position`]. Operators / text-
161/// objects / ex-commands return [`CommandError::InvalidArgs`] --
162/// callers route those separately (yank possibly excepted, but yank
163/// is an operator not a motion). The motion's `linewise` flag is
164/// dropped: callers that need it (yank-by-motion, etc.) are not
165/// expected on read-only buffers.
166pub fn execute_motion_only(
167    registry: &CommandRegistry,
168    buffer: &Buffer,
169    buffer_id: BufferId,
170    cursor: Position,
171    invocation: CommandInvocation,
172    cancel: &CancellationToken,
173    env: crate::registry::GrammarEnv<'_>,
174) -> GrammarResult<Position> {
175    execute_motion_only_reporting(registry, buffer, buffer_id, cursor, invocation, cancel, env)
176        .map(|(target, _)| target)
177}
178
179/// VM.3d-2: [`execute_motion_only`], also reporting the motion's notice.
180///
181/// A wrapping `n` says "search hit BOTTOM, continuing at TOP" in a document,
182/// because `execute_with_env` turns the notice into an `Effect::Echo`. The
183/// read-only path returned a bare `Position`, so the notice was dropped and
184/// the same keystroke in `:help` or the dashboard wrapped in silence. Callers
185/// that do not care keep using [`execute_motion_only`].
186pub fn execute_motion_only_reporting(
187    registry: &CommandRegistry,
188    buffer: &Buffer,
189    buffer_id: BufferId,
190    cursor: Position,
191    invocation: CommandInvocation,
192    cancel: &CancellationToken,
193    env: crate::registry::GrammarEnv<'_>,
194) -> GrammarResult<(Position, Option<crate::registry::MotionNotice>)> {
195    cancel.check()?;
196    let entry = registry
197        .entry(invocation.command)
198        .ok_or(CommandError::UnknownCommand)?;
199    if !matches!(entry.spec.kind, CommandKind::Motion) {
200        return Err(CommandError::InvalidArgs(
201            "execute_motion_only only accepts motions",
202        ));
203    }
204    let motion = require_motion(entry)?;
205    let ctx = MotionContext {
206        buffer,
207        buffer_id,
208        from: cursor,
209        count: invocation.count_or_default(),
210        has_explicit_count: invocation.count.is_some(),
211        args: invocation.args.clone(),
212        cancel,
213        scope_resolver: env.scope_resolver,
214        // This path resolves against a bare `Buffer` with no `Document`
215        // behind it (help and the other non-document buffer kinds), so there
216        // is no file to name.
217        path: None,
218        syntax: env.syntax,
219        last_find: env.last_find,
220        fold_resolver: env.fold_resolver,
221        last_search: env.last_search,
222        marks: env.marks,
223        viewport: env.viewport,
224        nostartofline: env.nostartofline,
225        curswant: env.curswant,
226        display: env.display,
227        scrolloff: env.scrolloff,
228        operator_pending: false,
229    };
230    let result = (motion.apply)(&ctx)?;
231    Ok((result.target, result.notice))
232}
233
234fn execute_ex_command(
235    document: &Document,
236    buffer_id: BufferId,
237    cursor: Position,
238    invocation: &CommandInvocation,
239    entry: &CommandEntry,
240    cancel: &CancellationToken,
241    env: crate::registry::GrammarEnv<'_>,
242) -> GrammarResult<Effect> {
243    let spec = require_ex_command(entry)?;
244    let ctx = ExCommandContext {
245        bang: invocation.bang,
246        args: invocation.args.clone(),
247        range: invocation.range.clone(),
248        register: invocation.register_or_default(),
249        count: invocation.count_or_default(),
250        // MR.2: the buffer the `:` line was submitted from — the same
251        // `buffer_id` the Action arm above passes on, from the same
252        // parameter. An ex-command that acts on "the thing in front of
253        // you" (`:magit-status` and the magit views after it) has no
254        // other way to ask.
255        buffer_id,
256        // OC.10: the same four the `execute_action` arm below fills, from the
257        // same values — they were already in scope here, which is why this
258        // closed a seam gap without any threading.
259        cursor,
260        buffer: document.buffer().clone(),
261        path: document.path_shared(),
262        syntax: env.syntax.map(std::sync::Arc::clone),
263        cancel: cancel.clone(),
264    };
265    (spec.apply)(&ctx)
266}
267
268fn execute_motion(
269    document: &Document,
270    buffer_id: BufferId,
271    cursor: Position,
272    invocation: &CommandInvocation,
273    entry: &CommandEntry,
274    cancel: &CancellationToken,
275    env: crate::registry::GrammarEnv<'_>,
276) -> GrammarResult<Effect> {
277    let motion = require_motion(entry)?;
278    let ctx = MotionContext {
279        buffer: document.buffer(),
280        buffer_id,
281        from: cursor,
282        count: invocation.count_or_default(),
283        has_explicit_count: invocation.count.is_some(),
284        args: invocation.args.clone(),
285        cancel,
286        scope_resolver: env.scope_resolver,
287        path: document.path(),
288        syntax: env.syntax,
289        last_find: env.last_find,
290        fold_resolver: env.fold_resolver,
291        last_search: env.last_search,
292        marks: env.marks,
293        viewport: env.viewport,
294        nostartofline: env.nostartofline,
295        curswant: env.curswant,
296        display: env.display,
297        scrolloff: env.scrolloff,
298        operator_pending: false,
299    };
300    let result = (motion.apply)(&ctx)?;
301    // VM.3g-3: a motion that knows its own goal column reports it here. Only
302    // `gj` / `gk` do — they aim at a screen column the reached row may be too
303    // short to hold, and vim records the aim rather than the landing, which
304    // `CurswantEffect` on the spec cannot express. Written only on SUCCESS,
305    // matching the host's rule that a failed motion leaves the goal alone.
306    if let Some(slot) = env.curswant_out {
307        if let Ok(mut g) = slot.lock() {
308            *g = result.curswant;
309        }
310    }
311    // Motions emit a cursor-only jump — the modal engine's caller
312    // takes the new position and updates state. We surface the
313    // position via Effect::CursorMove, the semantically-clean
314    // cursor-jump primitive (replaces the former SelectionChange-
315    // with-collapsed-cursor pattern).
316    Ok(with_notice(
317        Effect::CursorMove(result.target),
318        result.notice,
319    ))
320}
321
322/// VM.3d-2: what a motion's notice says. One wording for both peers — the
323/// document path (an `Effect::Echo` beside the motion) and the read-only one
324/// (the host echoes it itself), which would otherwise drift.
325pub fn notice_text(notice: crate::registry::MotionNotice) -> &'static str {
326    match notice {
327        crate::registry::MotionNotice::SearchHitBottom => "search hit BOTTOM, continuing at TOP",
328        crate::registry::MotionNotice::SearchHitTop => "search hit TOP, continuing at BOTTOM",
329    }
330}
331
332/// VM.3d-2: attach a motion's notice to the effect it produced, as an
333/// `Effect::Echo` alongside it. `None` returns the effect untouched, so every
334/// motion without a notice produces exactly the effect it always did.
335fn with_notice(effect: Effect, notice: Option<crate::registry::MotionNotice>) -> Effect {
336    let Some(notice) = notice else {
337        return effect;
338    };
339    let text = notice_text(notice);
340    Effect::Many(vec![
341        effect,
342        Effect::Echo {
343            level: crate::effect::EchoLevel::Warn,
344            text: text.to_string(),
345        },
346    ])
347}
348
349fn execute_text_object(
350    document: &Document,
351    cursor: Position,
352    invocation: &CommandInvocation,
353    entry: &CommandEntry,
354    cancel: &CancellationToken,
355    env: crate::registry::GrammarEnv<'_>,
356) -> GrammarResult<Effect> {
357    let tobj = require_text_object(entry)?;
358    let ctx = TextObjectContext {
359        buffer: document.buffer(),
360        at: cursor,
361        count: invocation.count_or_default(),
362        args: invocation.args.clone(),
363        cancel,
364        scope_resolver: env.scope_resolver,
365        comment_syntax: env.comment_syntax,
366        path: document.path(),
367        syntax: env.syntax,
368    };
369    let range = (tobj.apply)(&ctx)?;
370    // A bare text object (no operator) sets the selection to the object's
371    // span -- this is how Visual mode's `viw` / `vaf` / `vaC` work, and it
372    // is fully generic: the dispatcher does not branch on which object was
373    // resolved. The object returns a half-open `[start, end)`; charwise
374    // visual is inclusive of the head, so we place the head one byte before
375    // `end`. A later operator (`d` / `y` / `c`) re-extends to `end` via
376    // `resolve_grammar_range(Range::Selection)`, matching vim exactly.
377    if range.is_empty() {
378        return Ok(Effect::None);
379    }
380    let buffer = document.buffer();
381    let head = buffer
382        .position_to_byte(range.end)
383        .ok()
384        .filter(|&b| b > 0)
385        .and_then(|b| buffer.byte_to_position(b - 1).ok())
386        .unwrap_or(range.start);
387    let mut selections = document.selections().clone();
388    selections.replace_primary(lattice_protocol::selection::Selection {
389        anchor: range.start,
390        head,
391        visual: None,
392    });
393    Ok(Effect::SelectionChange(selections))
394}
395
396fn execute_operator(
397    registry: &CommandRegistry,
398    document: &mut Document,
399    buffer_id: BufferId,
400    cursor: Position,
401    invocation: &CommandInvocation,
402    entry: &CommandEntry,
403    cancel: &CancellationToken,
404    env: crate::registry::GrammarEnv<'_>,
405) -> GrammarResult<Effect> {
406    let operator = require_operator(entry)?;
407
408    // Blockwise visual is dispatched per-row -- but only for
409    // operators that opted into it via `blockwise_per_row`. Rectangle
410    // ops (`d`, `y`, `c`) want each row's column slice; linewise-
411    // style ops (`>`, `<`, `gU`, `gu`, `g~`) want one contiguous
412    // range covering anchor..head so the whole change is a single
413    // undo unit, matching vim's behavior on visual selections.
414    if let Some(Range::Selection) = invocation.range
415        && matches!(
416            document.selections().primary().visual,
417            Some(lattice_protocol::selection::VisualMode::Blockwise)
418        )
419        && operator.blockwise_per_row
420    {
421        return execute_operator_blockwise(operator, document, buffer_id, invocation, cancel, env);
422    }
423
424    let motion_count = invocation.count_or_default();
425    // VM.3L: a motion target reports whether it moved linewise (or became
426    // linewise by `:h exclusive-linewise`); a grammar range says so below.
427    let (target_range, target_linewise, target_notice, origin): (
428        ProtoRange,
429        bool,
430        Option<crate::registry::MotionNotice>,
431        Position,
432    ) = match (&invocation.range, &invocation.target) {
433        (Some(grammar_range), _) => {
434            let range = resolve_grammar_range(document, grammar_range, cursor, motion_count.get())?;
435            // VM.3m: vim moves the cursor to the start of a Visual selection
436            // (`Vjjy` lands on its first line) but leaves it alone for a count
437            // or ex range (`yy`, `2yy`, `:%y` all keep it).
438            let origin = if matches!(grammar_range, Range::Selection) {
439                range.start
440            } else {
441                cursor
442            };
443            (range, false, None, origin)
444        }
445        (None, Some(target)) => resolve_target(
446            registry,
447            document,
448            buffer_id,
449            cursor,
450            target,
451            motion_count,
452            cancel,
453            env,
454        )?,
455        (None, None) => return Err(CommandError::MissingTarget),
456    };
457
458    let visual_linewise = matches!(invocation.range, Some(Range::Selection))
459        && matches!(
460            document.selections().primary().visual,
461            Some(lattice_protocol::selection::VisualMode::Linewise)
462        );
463    let mut ctx = OperatorContext {
464        document,
465        buffer_id,
466        range: target_range,
467        origin,
468        linewise: matches!(
469            invocation.range,
470            Some(Range::CurrentLine) | Some(Range::Whole)
471        ) || visual_linewise
472            || target_linewise,
473        register: invocation.register_or_default(),
474        count: invocation.count_or_default(),
475        args: invocation.args.clone(),
476        cancel,
477        indent: env.indent,
478        indent_resolver: env.indent_resolver,
479        textwidth: env.textwidth,
480        comment_syntax: env.comment_syntax,
481        native_format: env.native_format,
482    };
483    // VM.3d-2: a motion target's notice (the search wrap in `dn`) is echoed
484    // with the operator's effect, as vim shows it.
485    (operator.apply)(&mut ctx).map(|effect| with_notice(effect, target_notice))
486}
487
488/// Per-row dispatch for blockwise visual operators. Vim's `Ctrl-V`
489/// selection is a rectangle; `d` / `y` / `c` operate on each row's
490/// column slice independently, then the results are committed
491/// together. We:
492///
493/// 1. Compute each row's [`ProtoRange`] from the visual selection
494///    (clamped to that row's length -- short rows get an empty range).
495/// 2. Snapshot each row's text top-down (for the merged Yank's content).
496/// 3. Run `operator.apply` per row, **bottom-up** so deletions on a
497///    row don't shift positions on rows above. For non-mutating
498///    operators (yank), order doesn't matter; bottom-up is safe.
499/// 4. Flatten the per-row Effects, merge yanks into one Blockwise
500///    yank, concatenate Edits, deduplicate `EnterMode`.
501fn execute_operator_blockwise(
502    operator: &crate::registry::OperatorSpec,
503    document: &mut Document,
504    // CM.3: threaded through so each per-row context can name its buffer, the
505    // `target` a plugin operator's `apply-edit` effect needs.
506    buffer_id: BufferId,
507    invocation: &CommandInvocation,
508    cancel: &CancellationToken,
509    env: crate::registry::GrammarEnv<'_>,
510) -> GrammarResult<Effect> {
511    let sel = document.selections().primary();
512    let (top_line, bottom_line) = (
513        sel.anchor.line.min(sel.head.line),
514        sel.anchor.line.max(sel.head.line),
515    );
516    let (left_col, right_col) = (
517        sel.anchor.byte.min(sel.head.byte),
518        sel.anchor.byte.max(sel.head.byte),
519    );
520
521    // Per-row ranges, top-down. Each range covers `[left_col,
522    // right_col + 1)` clamped to the row's actual length. The +1 is
523    // vim's inclusive-end convention for visual selections.
524    let mut row_ranges: Vec<ProtoRange> = Vec::with_capacity((bottom_line - top_line + 1) as usize);
525    for line in top_line..=bottom_line {
526        let line_len = line_byte_len(document.buffer(), line);
527        let start = left_col.min(line_len);
528        let end = (right_col + 1).min(line_len);
529        row_ranges.push(ProtoRange::new(
530            Position::new(line, start),
531            Position::new(line, end),
532        ));
533    }
534
535    // Snapshot row contents top-down before any mutation. Each row is
536    // either the column slice (if non-empty) or an empty string; the
537    // joined-by-newlines result is the Blockwise yank's content.
538    let mut row_contents: Vec<String> = Vec::with_capacity(row_ranges.len());
539    for r in &row_ranges {
540        if r.is_empty() {
541            row_contents.push(String::new());
542        } else {
543            row_contents.push(document.buffer().slice(*r)?);
544        }
545    }
546
547    // Snapshot the pre-state of the affected line span so we can
548    // collapse the per-row edits into a single batched edit at the
549    // end. Block-visual selections always span a contiguous line
550    // range, so we capture (top_line, 0) .. (bottom_line, EOL).
551    let pre_top = Position::new(top_line, 0);
552    let pre_bottom = Position::new(bottom_line, line_byte_len(document.buffer(), bottom_line));
553    let pre_range = ProtoRange::new(pre_top, pre_bottom);
554
555    // Run apply per row, bottom-up. Collect every produced Effect.
556    let register = invocation.register_or_default();
557    let count = invocation.count_or_default();
558    let args = invocation.args.clone();
559    let mut per_row_effects: Vec<Effect> = Vec::with_capacity(row_ranges.len());
560    for r in row_ranges.iter().rev() {
561        // Per-row cancellation check: lets the user Esc out of a
562        // blockwise op spanning many rows even if a single row's
563        // apply doesn't poll.
564        cancel.check()?;
565        let mut ctx = OperatorContext {
566            document,
567            buffer_id,
568            range: *r,
569            // VM.3m: each blockwise row starts at its own left edge.
570            origin: r.start,
571            linewise: false,
572            register,
573            count,
574            args: args.clone(),
575            cancel,
576            indent: env.indent,
577            indent_resolver: env.indent_resolver,
578            textwidth: env.textwidth,
579            comment_syntax: env.comment_syntax,
580            native_format: env.native_format,
581        };
582        let eff = (operator.apply)(&mut ctx)?;
583        per_row_effects.push(eff);
584    }
585    // Restore top-down order so the merged effect ordering tracks the
586    // original row order (matters for some downstream consumers, even
587    // though Edits / Yanks themselves don't carry an order beyond
588    // their internal vec).
589    per_row_effects.reverse();
590
591    // Coalesce the per-row edits into a single batched undo unit.
592    // Each row's `apply` may have called `apply_edit` once (delete /
593    // change) or zero times (yank); count the AppliedEdits, undo
594    // them, and re-emit a single `Edit::replace` covering the
595    // affected line span with the post-state text. The user's `u`
596    // then reverts the whole rectangle in one step, matching vim.
597    let edit_count: usize = per_row_effects.iter().map(count_applied_edits).sum();
598    let collapsed_edit = if edit_count > 1 {
599        // Capture post-state of the same line span. Line numbers
600        // didn't shift -- block-visual operators only modify column
601        // slices within rows; they never delete whole rows.
602        let post_bottom = Position::new(bottom_line, line_byte_len(document.buffer(), bottom_line));
603        let post_range = ProtoRange::new(pre_top, post_bottom);
604        let post_text = document.buffer().slice(post_range)?;
605
606        // Rewind every per-row edit, then commit one batched
607        // Edit::replace covering the original span.
608        for _ in 0..edit_count {
609            // Undo errors here would mean the document's undo stack
610            // diverged from what we just pushed -- treat as a hard
611            // failure so callers see it rather than silently leaving
612            // the buffer in a half-rolled-back state.
613            document
614                .undo()
615                .map_err(|_| CommandError::InvalidArgs("blockwise undo coalesce failed"))?;
616        }
617        let edit = lattice_protocol::edit::Edit::replace(pre_range, &post_text);
618        let applied = document
619            .apply_edit(edit)
620            .map_err(|_| CommandError::InvalidArgs("blockwise batched apply failed"))?;
621        Some(applied)
622    } else {
623        None
624    };
625
626    // After a rectangle op the cursor should land at the block's
627    // top-left corner -- vim's behavior. The collapsed Edit's
628    // `original_range.start` is `(top_line, 0)` (we replace full
629    // lines), which would otherwise drag the cursor to column 0.
630    // Emit a SelectionChange in the merged effect so the App's
631    // apply_effect overrides the handle_edits column. Skip if the
632    // operator was yank-only (no edits, cursor untouched).
633    let cursor_target = if edit_count > 0 {
634        let line_len = line_byte_len(document.buffer(), top_line);
635        Some(Position::new(top_line, left_col.min(line_len)))
636    } else {
637        None
638    };
639
640    Ok(merge_blockwise_effects(
641        per_row_effects,
642        row_contents,
643        register,
644        collapsed_edit,
645        cursor_target,
646        document,
647    ))
648}
649
650/// Sum of the `AppliedEdit`s contained in an Effect (recursing into
651/// `Effect::Many`). Used to count how many `apply_edit` calls were
652/// made by a single per-row operator dispatch so the blockwise
653/// coalesce can rewind exactly that many.
654fn count_applied_edits(effect: &Effect) -> usize {
655    match effect {
656        Effect::Edits(v) => v.len(),
657        Effect::Many(inner) => inner.iter().map(count_applied_edits).sum(),
658        _ => 0,
659    }
660}
661
662/// Flatten the per-row Effects and merge:
663/// - all `Effect::Edits` -> one combined `Effect::Edits`
664/// - per-row `Effect::Yank` -> one `Effect::Yank` per distinct
665///   register, with `kind = Blockwise` and content = `row_contents`
666///   joined by `\n`. Per-row yank content is discarded; the joined
667///   row snapshot is the source of truth.
668/// - `Effect::EnterMode` deduplicated (keep one if any).
669fn merge_blockwise_effects(
670    per_row_effects: Vec<Effect>,
671    row_contents: Vec<String>,
672    primary_register: crate::register::Register,
673    collapsed_edit: Option<lattice_core::buffer::AppliedEdit>,
674    cursor_target: Option<Position>,
675    _document: &Document,
676) -> Effect {
677    let mut flat: Vec<Effect> = Vec::new();
678    for e in per_row_effects {
679        flatten_effect(e, &mut flat);
680    }
681
682    let mut combined_edits: Vec<lattice_core::buffer::AppliedEdit> = Vec::new();
683    // Each entry keeps its `explicit_yank` flag so the collapsed blockwise
684    // yank preserves clipboard eligibility (a Visual-block `y` mirrors; a
685    // block delete does not).
686    let mut yank_registers: Vec<(crate::register::Register, bool)> = Vec::new();
687    let mut enter_mode: Option<ModalState> = None;
688    for e in flat {
689        match e {
690            Effect::Edits(edits) => combined_edits.extend(edits),
691            Effect::Yank {
692                register,
693                explicit_yank,
694                ..
695            } => {
696                if !yank_registers.iter().any(|(r, _)| *r == register) {
697                    yank_registers.push((register, explicit_yank));
698                }
699            }
700            Effect::EnterMode(m) => enter_mode = Some(m),
701            // Other effects shouldn't surface from operator dispatch;
702            // drop them defensively.
703            _ => {}
704        }
705    }
706
707    let joined = row_contents.join("\n");
708    let mut out: Vec<Effect> = Vec::new();
709    // Prefer the dispatcher-supplied collapsed edit if it built one
710    // (multi-row case): the per-row AppliedEdits were rewound and
711    // re-emitted as a single batched edit; surfacing the per-row
712    // copies here would mislead the host's `handle_edits` cursor
713    // logic. Single-edit cases (one-row dispatch, yank-only) keep
714    // the per-row Edits.
715    if let Some(applied) = collapsed_edit {
716        out.push(Effect::Edits(vec![applied]));
717    } else if !combined_edits.is_empty() {
718        out.push(Effect::Edits(combined_edits));
719    }
720    // Vim's blockwise paste reads the unnamed register; the operator
721    // closures emitted yanks to `primary_register` and possibly to a
722    // numbered register (`"0` for yank). Preserve that fan-out, but
723    // collapse content to one Blockwise blob.
724    if !yank_registers.is_empty() {
725        for (reg, explicit_yank) in yank_registers {
726            out.push(Effect::Yank {
727                register: reg,
728                content: joined.clone(),
729                kind: YankKind::Blockwise,
730                explicit_yank,
731            });
732        }
733    } else if !joined.is_empty() {
734        // Defensive: if no yank surfaced (e.g. operator that doesn't
735        // yank) but we have content, do nothing.
736        let _ = primary_register;
737    }
738    // Override the cursor to the block's top-left corner (post-edit)
739    // so vim's "cursor lands at the start of the visual selection"
740    // semantic holds. The collapsed Edit's original_range.start is
741    // (top_line, 0) -- without this override the host's handle_edits
742    // would drag the cursor to column 0.
743    if let Some(pos) = cursor_target {
744        out.push(Effect::CursorMove(pos));
745    }
746    if let Some(m) = enter_mode {
747        out.push(Effect::EnterMode(m));
748    }
749
750    Effect::Many(out)
751}
752
753fn flatten_effect(e: Effect, out: &mut Vec<Effect>) {
754    match e {
755        Effect::Many(parts) => {
756            for p in parts {
757                flatten_effect(p, out);
758            }
759        }
760        Effect::None => {}
761        other => out.push(other),
762    }
763}
764
765fn resolve_target(
766    registry: &CommandRegistry,
767    document: &Document,
768    buffer_id: BufferId,
769    cursor: Position,
770    target: &Target,
771    count: crate::command::Count,
772    cancel: &CancellationToken,
773    env: crate::registry::GrammarEnv<'_>,
774) -> GrammarResult<(
775    ProtoRange,
776    bool,
777    Option<crate::registry::MotionNotice>,
778    Position,
779)> {
780    match target {
781        Target::Motion(motion_id, args) => {
782            let entry = registry
783                .entry(motion_id.0)
784                .ok_or(CommandError::UnknownCommand)?;
785            let motion = require_motion(entry)?;
786            let ctx = MotionContext {
787                buffer: document.buffer(),
788                buffer_id,
789                from: cursor,
790                count,
791                has_explicit_count: false,
792                args: args.clone(),
793                cancel,
794                scope_resolver: env.scope_resolver,
795                path: document.path(),
796                syntax: env.syntax,
797                last_find: env.last_find,
798                fold_resolver: env.fold_resolver,
799                last_search: env.last_search,
800                marks: env.marks,
801                viewport: env.viewport,
802                nostartofline: env.nostartofline,
803                curswant: env.curswant,
804                display: env.display,
805                scrolloff: env.scrolloff,
806                // VM.3f: this motion is an operator's target.
807                operator_pending: true,
808            };
809            let r = (motion.apply)(&ctx)?;
810            let mut target = r.target;
811            // Vim word-motion special case (`:help word-motions`): when a
812            // word-forward motion (`w` / `W`) is used with an operator and
813            // the last word moved over is at the end of a line, the operated
814            // text ends at that line end -- it does NOT reach over the
815            // newline into the next line's first word. Fires only for
816            // word-forward-class motions and only when the motion actually
817            // landed on a later line. (This is the operator path only;
818            // plain `w` navigation still crosses lines.)
819            if registry.is_word_forward_motion(motion_id.0) && target.line > cursor.line {
820                let buffer = document.buffer();
821                target = Position::new(cursor.line, line_byte_len(buffer, cursor.line));
822            }
823            let (range, linewise) = motion_to_range(
824                document.buffer(),
825                cursor,
826                target,
827                // VM.3c: the RESULT may override the spec. Only `;` / `,` do —
828                // they inherit the exclusivity of whatever they repeat, which
829                // the spec cannot know. Everything else answers `None` and
830                // reads its own flag, exactly as before.
831                r.exclusive.unwrap_or(motion.exclusive),
832                r.linewise,
833            );
834            // VM.3m: the target BEFORE `motion_to_range` expanded it, so a
835            // yank can land where vim leaves it.
836            Ok((range, linewise, r.notice, cursor.min(target)))
837        }
838        Target::TextObject(tobj_id, args) => {
839            let entry = registry
840                .entry(tobj_id.0)
841                .ok_or(CommandError::UnknownCommand)?;
842            let tobj = require_text_object(entry)?;
843            let ctx = TextObjectContext {
844                buffer: document.buffer(),
845                at: cursor,
846                count,
847                args: args.clone(),
848                cancel,
849                scope_resolver: env.scope_resolver,
850                comment_syntax: env.comment_syntax,
851                path: document.path(),
852                syntax: env.syntax,
853            };
854            (tobj.apply)(&ctx).map(|range| (range, false, None, range.start))
855        }
856        Target::Range(grammar_range) => resolve_grammar_range(document, grammar_range, cursor, 1)
857            .map(|range| (range, false, None, range.start)),
858    }
859}
860
861fn resolve_grammar_range(
862    document: &Document,
863    range: &Range,
864    cursor: Position,
865    count: u32,
866) -> GrammarResult<ProtoRange> {
867    let count = count.max(1);
868    match range {
869        Range::Whole => {
870            let buffer = document.buffer();
871            // CV.3: content space. `:%` covers the buffer's real last
872            // line — ropey's raw count would extend a whole-buffer
873            // range onto the phantom line after the terminating
874            // newline.
875            let last_line = buffer.content_line_count().saturating_sub(1);
876            let start = Position::ZERO;
877            let end = Position::new(last_line, line_byte_len(buffer, last_line));
878            Ok(ProtoRange::new(start, end))
879        }
880        Range::CurrentLine => {
881            // Vim's `2dd` / `2yy` / `2>>` / etc.: count expands the
882            // linewise extent. Range covers `cursor.line` ..
883            // `cursor.line + count - 1`, clamped to the buffer's
884            // last addressable line.
885            let buffer = document.buffer();
886            // CV.3: content space — `2dd` at the end of a file must
887            // clamp to the last real line, not the phantom one.
888            let last = buffer.content_line_count().saturating_sub(1);
889            let start_line = cursor.line;
890            let end_line = start_line.saturating_add(count.saturating_sub(1)).min(last);
891            Ok(ProtoRange::new(
892                Position::new(start_line, 0),
893                Position::new(end_line, line_byte_len(buffer, end_line)),
894            ))
895        }
896        Range::Selection => {
897            let sel = document.selections().primary();
898            let (a, b) = ordered(sel.anchor, sel.head);
899            match sel.visual {
900                Some(lattice_protocol::selection::VisualMode::Linewise) => {
901                    // Linewise visual covers complete lines from anchor's
902                    // line to head's line, regardless of byte offsets.
903                    let buffer = document.buffer();
904                    let start = Position::new(a.line, 0);
905                    let end = Position::new(b.line, line_byte_len(buffer, b.line));
906                    Ok(ProtoRange::new(start, end))
907                }
908                Some(lattice_protocol::selection::VisualMode::Charwise) | None => {
909                    // Charwise visual: half-open `[a, b)` -- but vim treats
910                    // visual ranges as INCLUSIVE of the head, so we extend
911                    // the end by one byte (clamped to line length).
912                    let buffer = document.buffer();
913                    let line_len = line_byte_len(buffer, b.line);
914                    let extended_end = Position::new(b.line, (b.byte + 1).min(line_len));
915                    Ok(ProtoRange::new(a, extended_end))
916                }
917                Some(lattice_protocol::selection::VisualMode::Blockwise) => {
918                    // Reached when a non-operator path resolves a
919                    // grammar Range::Selection while Visual is
920                    // Blockwise (e.g. a future motion that takes a
921                    // range arg). Operators bypass this branch via
922                    // `execute_operator_blockwise`. Fall back to a
923                    // single contiguous range here -- no per-row
924                    // semantics for non-operators in v1.
925                    Ok(ProtoRange::new(a, b))
926                }
927            }
928        }
929        Range::Span { .. } | Range::Custom(_) => Err(CommandError::InvalidArgs(
930            "Span and Custom ranges are not yet resolved in Phase 1",
931        )),
932    }
933}
934
935/// Turn an operator's `(cursor, motion-target)` pair into the byte range
936/// the operator acts on, honouring vim's exclusive/inclusive motion
937/// distinction. Ranges are half-open `[start, end)`.
938///
939/// - **Exclusive** motions (`w`, `b`, `0`, ...) delete up to but not
940///   including the target: `[min, max)`.
941/// - **Inclusive** motions (`e`, `f`, `t`, `$`, `%`, ...) also cover the
942///   character at the far end of the range. Vim's rule (`:h exclusive`) is
943///   about the buffer, not about the direction of travel: "the last character
944///   towards the end of the buffer" is included. So a FORWARD inclusive motion
945///   extends one character past the target (`de` deletes through the last
946///   letter of the word), and a BACKWARD one extends one character past the
947///   CURSOR — the range is `[target, cursor + 1)`.
948///
949/// VM.3b fixed the backward half, which used to read `[target, cursor)` — the
950/// exclusive answer wearing the inclusive branch. Nothing noticed because its
951/// only users were `F` and `T`, which vim calls exclusive and which were
952/// registered here as inclusive: two compensating errors that produced the
953/// right range for the wrong reason, and the wrong range the moment a
954/// genuinely-inclusive bidirectional motion arrived. `d%` from the CLOSING
955/// bracket deleted `(abc` and left the `)` behind. `F` / `T` are registered
956/// `exclusive: true` now, so their ranges are bit-identical and say why.
957///
958/// VM.3L: returns whether the range is LINEWISE too, which the operator reads
959/// as `OperatorContext::linewise` (whole lines, a `V` register).
960fn motion_to_range(
961    buffer: &lattice_core::Buffer,
962    from: Position,
963    to: Position,
964    exclusive: bool,
965    linewise: bool,
966) -> (ProtoRange, bool) {
967    // A linewise motion (`j` / `k` / `gg` / `G`) acts on whole lines, from the
968    // lower line to the higher whichever way it moved. The range has the shape
969    // `Range::CurrentLine` resolves to, so every operator's existing linewise
970    // handling (`extend_linewise_range`, the `V` register) applies unchanged.
971    if linewise {
972        let (a, b) = ordered(from, to);
973        return (
974            ProtoRange::new(
975                Position::new(a.line, 0),
976                Position::new(b.line, line_byte_len(buffer, b.line)),
977            ),
978            true,
979        );
980    }
981    if exclusive || to == from {
982        let (a, b) = ordered(from, to);
983        // `:h exclusive-linewise`. An exclusive motion whose end lands in
984        // column 1 of a later line covers nothing on that line, so:
985        if exclusive && b.byte == 0 && b.line > a.line {
986            let last = b.line - 1;
987            let last_end = Position::new(last, line_byte_len(buffer, last));
988            // "...and the start of the motion was at or before the first
989            // non-blank in the line, the motion becomes linewise" — `d}` from
990            // the start of a paragraph deletes its lines, not the blank after.
991            if a.byte <= first_non_blank_byte(buffer, a.line) {
992                return (ProtoRange::new(Position::new(a.line, 0), last_end), true);
993            }
994            // "...the end of the motion is moved to the end of the previous line
995            // and the motion becomes inclusive" — `d}` / `dzj` from mid-line
996            // keep that line's newline.
997            return (ProtoRange::new(a, last_end), false);
998        }
999        return (ProtoRange::new(a, b), false);
1000    }
1001    if to > from {
1002        // Forward inclusive: cover the character under the target.
1003        (ProtoRange::new(from, advance_one_char(buffer, to)), false)
1004    } else {
1005        // Backward inclusive: the target is the range start, and the far end
1006        // is the character under the ORIGINAL cursor — included, because that
1007        // is what inclusive means.
1008        (ProtoRange::new(to, advance_one_char(buffer, from)), false)
1009    }
1010}
1011
1012/// VM.3L: byte column of the first non-blank character on `line` (the line's
1013/// length when it's blank), for `:h exclusive-linewise`.
1014fn first_non_blank_byte(buffer: &lattice_core::Buffer, line: u32) -> u32 {
1015    let text = buffer.line(line).unwrap_or_default();
1016    let text = text.trim_end_matches('\n');
1017    text.bytes()
1018        .position(|b| b != b' ' && b != b'\t')
1019        .unwrap_or(text.len()) as u32
1020}
1021
1022/// Byte position one UTF-8 character past `pos`, clamped to the buffer
1023/// end. `pos` is assumed to be a char boundary (motion targets always
1024/// are); a target at end-of-buffer returns `pos` unchanged.
1025///
1026/// **Never steps over a line break.** An inclusive motion covers "the
1027/// character under the target", and a newline is not a character on that
1028/// line — it is the line's boundary. vim agrees: no inclusive *charwise*
1029/// operator swallows the line break, which is why `d$` empties a line and
1030/// leaves it there while `dd` removes the line entirely. They are different
1031/// operations and the newline is the whole difference.
1032///
1033/// Without this clamp `D` and `C` deleted the break and pulled the next line
1034/// up into the current one — `motion:line-end` targets one byte PAST the last
1035/// character, so the inclusive adjustment landed on the `\n` and took it. The
1036/// bug was reachable from any inclusive motion whose target is the end of a
1037/// line, not just `$`; fixing it here rather than in `motion_line_end` covers
1038/// `e`, `f` and `t` on a line's last character by the same rule.
1039fn advance_one_char(buffer: &lattice_core::Buffer, pos: Position) -> Position {
1040    let Ok(idx) = buffer.position_to_byte(pos) else {
1041        return pos;
1042    };
1043    let text = buffer.as_string();
1044    let next = text.get(idx..).and_then(|s| s.chars().next());
1045    // The clamp. `\r` too: on a CRLF buffer the break is two characters and
1046    // stepping onto the `\r` would split it, leaving a stray carriage return
1047    // welded to the next line.
1048    if matches!(next, Some('\n') | Some('\r') | None) {
1049        return pos;
1050    }
1051    let step = next.map(|c| c.len_utf8()).unwrap_or(0);
1052    if step == 0 {
1053        return pos;
1054    }
1055    buffer.byte_to_position(idx + step).unwrap_or(pos)
1056}
1057
1058fn ordered(a: Position, b: Position) -> (Position, Position) {
1059    if a <= b { (a, b) } else { (b, a) }
1060}
1061
1062fn line_byte_len(buffer: &lattice_core::Buffer, line: u32) -> u32 {
1063    let s = buffer.as_string();
1064    let lines: Vec<&str> = s.split_inclusive('\n').collect();
1065    lines
1066        .get(line as usize)
1067        .map(|l| l.trim_end_matches('\n').len() as u32)
1068        .unwrap_or(0)
1069}
1070
1071#[cfg(test)]
1072mod tests {
1073    #![allow(clippy::unwrap_used, clippy::panic)]
1074    use std::sync::Arc;
1075
1076    use super::*;
1077    use crate::CancellationToken;
1078    use crate::app_effect::AppEffect;
1079    use crate::registry::ActionSpec;
1080
1081    /// Slice 8.i.0 wiring: a `CommandKind::Action` registry entry
1082    /// flows through `execute()` and surfaces the spec's
1083    /// `Effect::AppAction(...)` payload. The carrier exists; later
1084    /// slices populate it from the per-mode keymap modules as the
1085    /// `bind_legacy` bridge retires.
1086    #[test]
1087    fn execute_routes_action_kind_to_action_spec() {
1088        let mut registry = CommandRegistry::new();
1089        let id = registry.register_action(
1090            "test:quit-action",
1091            "smoke variant for slice 8.i.0",
1092            ActionSpec {
1093                apply: Arc::new(|_ctx| Ok(Effect::AppAction(AppEffect::Quit))),
1094                args_schema: vec![],
1095            },
1096        );
1097
1098        let mut doc = lattice_core::Document::empty();
1099        let inv = CommandInvocation::of(id);
1100        let eff = execute(
1101            &registry,
1102            &mut doc,
1103            lattice_core::BufferId(0),
1104            Position::ZERO,
1105            inv,
1106            &CancellationToken::never(),
1107        )
1108        .unwrap();
1109        match eff {
1110            Effect::AppAction(AppEffect::Quit) => {}
1111            other => panic!("expected Effect::AppAction(Quit), got {other:?}"),
1112        }
1113    }
1114
1115    /// MR.2: an ex-command sees the buffer its `:` line was submitted
1116    /// from — the same id the Action arm beside it receives, from the
1117    /// same parameter.
1118    ///
1119    /// Asserted through `execute` rather than by building a context by
1120    /// hand: the fact was *available* here all along and simply not
1121    /// forwarded, so the only thing worth pinning is the forwarding. A
1122    /// test that constructed the context itself would pass on the broken
1123    /// version too.
1124    ///
1125    /// Non-zero on purpose: `BufferId::default()` is 0, so a wiring that
1126    /// dropped the value and defaulted it would still match.
1127    #[test]
1128    fn an_ex_command_sees_the_buffer_it_was_invoked_from() {
1129        let seen: Arc<std::sync::Mutex<Option<lattice_core::BufferId>>> =
1130            Arc::new(std::sync::Mutex::new(None));
1131
1132        let mut registry = CommandRegistry::new();
1133        let id = registry.register_ex_command(
1134            "test:which-buffer",
1135            "records the buffer it fired in",
1136            crate::registry::ExCommandSpec {
1137                latency_class: crate::command::LatencyClass::Reflex,
1138                accepts_bang: false,
1139                accepts_range: false,
1140                parse_args: Arc::new(|_line, _bang| Ok(crate::args::Args::None)),
1141                apply: {
1142                    let seen = seen.clone();
1143                    Arc::new(move |ctx| {
1144                        *seen.lock().unwrap() = Some(ctx.buffer_id);
1145                        Ok(Effect::None)
1146                    })
1147                },
1148                args_schema: vec![],
1149                surface_form: crate::registry::SurfaceForm::Keyword,
1150            },
1151        );
1152
1153        let mut doc = lattice_core::Document::empty();
1154        execute(
1155            &registry,
1156            &mut doc,
1157            lattice_core::BufferId(42),
1158            Position::ZERO,
1159            CommandInvocation::of(id.0),
1160            &CancellationToken::never(),
1161        )
1162        .unwrap();
1163
1164        assert_eq!(
1165            *seen.lock().unwrap(),
1166            Some(lattice_core::BufferId(42)),
1167            "the ex-command must be told which buffer it ran in"
1168        );
1169    }
1170
1171    /// `require_action`'s kind-mismatch path: dispatching a motion
1172    /// id through the `Action` branch (impossible in normal flow,
1173    /// but the helper is shared) errors with the labeled mismatch
1174    /// rather than panicking.
1175    #[test]
1176    fn action_branch_rejects_non_action_entries() {
1177        let mut registry = CommandRegistry::new();
1178        let _ = crate::builtins::populate(&mut registry);
1179        // Look up a known motion id; pretend-route it through the
1180        // action helper directly to confirm the require_action
1181        // gate behaves.
1182        let motion_id = registry.id_by_name("motion:word-forward").unwrap();
1183        let entry = registry.entry(motion_id).unwrap();
1184        let err = crate::registry::require_action(entry).expect_err("motion entry must reject");
1185        assert!(
1186            matches!(
1187                err,
1188                crate::error::CommandError::KindMismatch { expected, actual }
1189                    if expected == "action" && actual == "motion"
1190            ),
1191            "unexpected error: {err:?}"
1192        );
1193    }
1194
1195    /// Visual-foundation slice: a *bare* text object (no operator)
1196    /// must set the selection to the object's span rather than
1197    /// no-op. This is what drives Visual mode's `viw` / `vaw` /
1198    /// `vaf`. The object returns a half-open `[start, end)`; the
1199    /// resulting charwise selection is inclusive of the head, so
1200    /// the head lands one byte before `end` and a later operator
1201    /// re-extends via `resolve_grammar_range(Range::Selection)`.
1202    #[test]
1203    fn bare_text_object_sets_selection_to_object_span() {
1204        let mut registry = CommandRegistry::new();
1205        let builtins = crate::builtins::populate(&mut registry);
1206        let mut doc = lattice_core::Document::from_text("foo bar baz");
1207        // Cursor on the `b` of "bar" (line 0, byte 4).
1208        let cursor = Position::new(0, 4);
1209        let inv = CommandInvocation::of(builtins.inner_word.0);
1210        let eff = execute(
1211            &registry,
1212            &mut doc,
1213            lattice_core::BufferId(0),
1214            cursor,
1215            inv,
1216            &CancellationToken::never(),
1217        )
1218        .unwrap();
1219        match eff {
1220            Effect::SelectionChange(set) => {
1221                let p = set.primary();
1222                // inner-word "bar" = bytes [4, 7); charwise head = 6.
1223                assert_eq!(p.anchor, Position::new(0, 4), "anchor at word start");
1224                assert_eq!(p.head, Position::new(0, 6), "head one byte before end");
1225                assert!(
1226                    p.visual.is_none(),
1227                    "bare object leaves the kind to the host"
1228                );
1229            }
1230            other => panic!("expected Effect::SelectionChange, got {other:?}"),
1231        }
1232    }
1233
1234    /// TSM.1: a motion's `apply` can read `ctx.scope_resolver` and call
1235    /// `scope_toward` -- proving the env threads through
1236    /// `execute_with_env` -> `execute_motion` -> `MotionContext` exactly
1237    /// like the existing tree-sitter text-object seam
1238    /// (`bare_text_object_sets_selection_to_object_span` above / N.1.4b).
1239    #[test]
1240    fn motion_context_carries_scope_resolver() {
1241        use crate::registry::{NavBoundary, NavDir, ScopeResolver};
1242
1243        struct FixedResolver;
1244        impl ScopeResolver for FixedResolver {
1245            fn scope_at(
1246                &self,
1247                _l: u32,
1248                _c: u32,
1249                _s: &str,
1250            ) -> Option<lattice_protocol::position::Range> {
1251                None
1252            }
1253            fn scope_toward(
1254                &self,
1255                _l: u32,
1256                _c: u32,
1257                _s: &str,
1258                _d: NavDir,
1259                _b: NavBoundary,
1260                _n: u32,
1261            ) -> Option<lattice_protocol::Position> {
1262                Some(lattice_protocol::Position::new(7, 0))
1263            }
1264        }
1265
1266        // A motion whose apply forwards to the resolver and returns its target.
1267        let mut registry = CommandRegistry::new();
1268        let m = registry.register_motion(
1269            "motion:test-nav",
1270            "test",
1271            crate::registry::MotionSpec {
1272                curswant: crate::registry::CurswantEffect::default(),
1273                jump: false,
1274                exclusive: true,
1275                args_schema: Vec::new(),
1276                apply: Arc::new(|ctx| {
1277                    let p = ctx
1278                        .scope_resolver
1279                        .and_then(|r| {
1280                            r.scope_toward(
1281                                ctx.from.line,
1282                                ctx.from.byte,
1283                                "function.outer",
1284                                NavDir::Forward,
1285                                NavBoundary::Start,
1286                                1,
1287                            )
1288                        })
1289                        .unwrap_or(ctx.from);
1290                    Ok(crate::registry::MotionResult {
1291                        curswant: None,
1292                        target: p,
1293                        linewise: false,
1294                        exclusive: None,
1295                        notice: None,
1296                    })
1297                }),
1298            },
1299        );
1300
1301        let mut doc = lattice_core::Document::from_text("fn a() {}\n");
1302        let resolver = FixedResolver;
1303        let env = crate::registry::GrammarEnv {
1304            scope_resolver: Some(&resolver),
1305            comment_syntax: None,
1306            syntax: None,
1307            ..Default::default()
1308        };
1309        let eff = execute_with_env(
1310            &registry,
1311            &mut doc,
1312            lattice_core::BufferId(0),
1313            Position::ZERO,
1314            CommandInvocation::of(m.0),
1315            &CancellationToken::never(),
1316            env,
1317        )
1318        .unwrap();
1319        // The motion resolved to row 7 via the resolver.
1320        match eff {
1321            Effect::CursorMove(pos) => {
1322                assert_eq!(pos.line, 7);
1323            }
1324            other => panic!("expected CursorMove, got {other:?}"),
1325        }
1326    }
1327}
1328
1329/// OS.2: the region reaches an action's context, and only when there is one.
1330///
1331/// Asserted through `execute_with_env` rather than by building an
1332/// `ActionContext` by hand — a hand-built context proves the struct has a
1333/// field, not that the dispatch path fills it, and "the seam exists but nothing
1334/// populates it" is the failure this whole plan keeps rediscovering (OT.4 on
1335/// `syntax`, OC.10 on `ex-command-context`).
1336#[cfg(test)]
1337mod os2_tests {
1338    #![allow(clippy::unwrap_used, clippy::panic)]
1339    use std::sync::Arc;
1340    use std::sync::Mutex;
1341
1342    use super::*;
1343    use crate::CancellationToken;
1344    use crate::app_effect::AppEffect;
1345    use crate::registry::ActionSpec;
1346    use lattice_protocol::position::Range;
1347
1348    /// Register a probe action that records the selection its context carried.
1349    fn seen_selection(env: crate::registry::GrammarEnv<'_>) -> Option<Range> {
1350        let seen: Arc<Mutex<Option<Option<Range>>>> = Arc::new(Mutex::new(None));
1351        let sink = Arc::clone(&seen);
1352        let mut registry = CommandRegistry::new();
1353        let id = registry.register_action(
1354            "test:os2-probe",
1355            "records ActionContext::selection",
1356            ActionSpec {
1357                apply: Arc::new(move |ctx| {
1358                    *sink.lock().unwrap() = Some(ctx.selection);
1359                    Ok(Effect::AppAction(AppEffect::Quit))
1360                }),
1361                args_schema: vec![],
1362            },
1363        );
1364        let mut doc = lattice_core::Document::from_text("one\ntwo\nthree\nfour\nfive\n");
1365        execute_with_env(
1366            &registry,
1367            &mut doc,
1368            lattice_core::BufferId(0),
1369            Position::ZERO,
1370            CommandInvocation::of(id),
1371            &CancellationToken::never(),
1372            env,
1373        )
1374        .unwrap();
1375        let out = seen.lock().unwrap().expect("probe action ran");
1376        out
1377    }
1378
1379    /// Normal mode: no region. A guest must be able to tell "act on the
1380    /// selection" from "act at the cursor", so a collapsed caret reported as a
1381    /// region would make every Normal action look like it had one.
1382    #[test]
1383    fn a_normal_mode_action_sees_no_selection() {
1384        assert!(seen_selection(crate::registry::GrammarEnv::default()).is_none());
1385    }
1386
1387    /// Visual: whatever the host resolved arrives intact, both endpoints.
1388    #[test]
1389    fn a_visual_action_sees_the_region_the_host_resolved() {
1390        let region = Range::new(Position::new(1, 0), Position::new(3, 5));
1391        let env = crate::registry::GrammarEnv {
1392            selection: Some(region),
1393            ..Default::default()
1394        };
1395        let seen = seen_selection(env).expect("region carried to the action");
1396        assert_eq!((seen.start.line, seen.start.byte), (1, 0));
1397        assert_eq!((seen.end.line, seen.end.byte), (3, 5));
1398    }
1399
1400    // ── VM.3L: linewise motion ranges and `:h exclusive-linewise` ──────────
1401
1402    fn range_of(
1403        text: &str,
1404        from: Position,
1405        to: Position,
1406        exclusive: bool,
1407        linewise: bool,
1408    ) -> (ProtoRange, bool) {
1409        let buffer = lattice_core::Buffer::from_text(text);
1410        motion_to_range(&buffer, from, to, exclusive, linewise)
1411    }
1412
1413    const PARA: &str = "  one a\n  two b\n\n  four d\n";
1414
1415    /// A linewise motion covers whole lines, lower to higher, either way.
1416    #[test]
1417    fn a_linewise_motion_covers_whole_lines_either_direction() {
1418        let whole = ProtoRange::new(Position::new(0, 0), Position::new(1, 7));
1419        assert_eq!(
1420            range_of(PARA, Position::new(0, 4), Position::new(1, 4), false, true),
1421            (whole, true)
1422        );
1423        assert_eq!(
1424            range_of(PARA, Position::new(1, 4), Position::new(0, 4), false, true),
1425            (whole, true)
1426        );
1427    }
1428
1429    /// vim: `d}` from 1,1 deletes lines 1–2 linewise (the motion starts at or
1430    /// before the first non-blank, so it becomes linewise).
1431    #[test]
1432    fn exclusive_to_column_one_from_the_line_start_becomes_linewise() {
1433        assert_eq!(
1434            range_of(PARA, Position::new(0, 0), Position::new(2, 0), true, false),
1435            (
1436                ProtoRange::new(Position::new(0, 0), Position::new(1, 7)),
1437                true
1438            )
1439        );
1440    }
1441
1442    /// vim: `d}` from 1,5 deletes `e a\n  two b` charwise, keeping line 2's
1443    /// newline (the end moves to the end of the previous line).
1444    #[test]
1445    fn exclusive_to_column_one_from_mid_line_ends_at_the_previous_line_end() {
1446        assert_eq!(
1447            range_of(PARA, Position::new(0, 4), Position::new(2, 0), true, false),
1448            (
1449                ProtoRange::new(Position::new(0, 4), Position::new(1, 7)),
1450                false
1451            )
1452        );
1453    }
1454
1455    /// Neither rule touches an exclusive motion that doesn't end in column 1.
1456    #[test]
1457    fn an_exclusive_motion_ending_mid_line_is_unchanged() {
1458        assert_eq!(
1459            range_of(PARA, Position::new(0, 4), Position::new(1, 2), true, false),
1460            (
1461                ProtoRange::new(Position::new(0, 4), Position::new(1, 2)),
1462                false
1463            )
1464        );
1465    }
1466}