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 ®istry,
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 ®istry,
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 ®istry,
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 ®istry,
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 ®istry,
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}