Skip to main content

lattice_mode/modes/
surround.rs

1//! surround-mode -- vim-surround semantics as a native minor mode.
2//!
3//! Three grammar operators: `surround-delete` (`ds{char}`),
4//! `surround-change` (`cs{char1}{char2}`), `surround-add`
5//! (`yss{char}` / `S{char}` in visual). All three are registered
6//! in the shared `CommandRegistry` at boot.
7//!
8//! Design: `docs/dev/architecture/surround-mode.md`;
9//! slice plan: `docs/dev/operations/slice-plans/surround-mode.md`.
10
11use std::sync::Arc;
12
13use lattice_grammar::CommandError;
14
15use lattice_core::Document;
16use lattice_core::buffer::Buffer;
17use lattice_grammar::args::{ArgKind, ArgSpec, ArgValue, Args};
18use lattice_grammar::effect::{Effect, YankKind};
19use lattice_grammar::registry::{CommandRegistry, OperatorId, OperatorSpec};
20use lattice_grammar::source::SourceLocation;
21use lattice_keymap::contribution::Keymap;
22use lattice_protocol::ChordPattern;
23use lattice_protocol::chord::KeyChord;
24use lattice_protocol::edit::Edit;
25use lattice_protocol::position::Position;
26
27use crate::mode::ActivationPolicy;
28use crate::{CapabilitySet, LifecycleFuture, Mode, ModeContext, ModeId, ModeKind};
29
30// ── Pair mapping ──────────────────────────────────────────────
31
32/// Map a user-provided character to its canonical (open, close) pair.
33///
34/// Brackets map to themselves: `(` → `('(' , ')')`, `)` → `('(' , ')')`.
35/// Symmetric pairs (`"`, `'`, `` ` ``) map to self.
36/// Unknown characters return `None`.
37pub fn open_close_pair(ch: char) -> Option<(char, char)> {
38    Some(match ch {
39        '(' | ')' => ('(', ')'),
40        '[' | ']' => ('[', ']'),
41        '{' | '}' => ('{', '}'),
42        '<' | '>' => ('<', '>'),
43        '"' => ('"', '"'),
44        '\'' => ('\'', '\''),
45        '`' => ('`', '`'),
46        _ => return None,
47    })
48}
49
50fn is_opener(ch: char) -> bool {
51    matches!(ch, '(' | '[' | '{' | '<')
52}
53
54fn is_closer(ch: char) -> bool {
55    matches!(ch, ')' | ']' | '}' | '>')
56}
57
58fn is_symmetric(ch: char) -> bool {
59    matches!(ch, '"' | '\'' | '`')
60}
61
62fn matching_closer(opener: char) -> Option<char> {
63    open_close_pair(opener).map(|(_, close)| close)
64}
65
66fn matching_opener(closer: char) -> Option<char> {
67    open_close_pair(closer).map(|(open, _)| open)
68}
69
70// ── SU.3g: the opening form pads, the closing form does not ───
71
72/// Does typing `ch` as a surround *wrapper* mean "pad the inside"?
73///
74/// vim-surround's rule, and the one piece of the grammar where the two
75/// halves of a bracket pair are not interchangeable: `ysiw(` gives
76/// `( hello )` and `ysiw)` gives `(hello)`. The distinction only exists
77/// for asymmetric pairs — a symmetric wrapper (`"`, `'`, backtick) is its
78/// own closer, so there is no opening form to mean anything different,
79/// and it never pads however it is typed.
80fn pads_inside(ch: char) -> bool {
81    matches!(open_close_pair(ch), Some((open, close)) if open != close && ch == open)
82}
83
84/// One padding string per side. Separate function from [`pads_inside`]
85/// so the two call sites that build text read as prose.
86fn padding_for(ch: char) -> &'static str {
87    if pads_inside(ch) { " " } else { "" }
88}
89
90/// Bytes of horizontal whitespace running forward from `from` in `line`,
91/// capped at `limit`.
92fn space_run_forward(line: &str, from: usize, limit: usize) -> usize {
93    line.get(from..limit)
94        .unwrap_or("")
95        .bytes()
96        .take_while(|b| *b == b' ' || *b == b'\t')
97        .count()
98}
99
100/// Bytes of horizontal whitespace running backward from `until` in
101/// `line`, not reaching before `floor`.
102fn space_run_backward(line: &str, until: usize, floor: usize) -> usize {
103    line.get(floor..until)
104        .unwrap_or("")
105        .bytes()
106        .rev()
107        .take_while(|b| *b == b' ' || *b == b'\t')
108        .count()
109}
110
111/// The two spans `ds` / `cs` replace for a pair whose delimiters sit at
112/// `open_pos` / `close_pos`.
113///
114/// With `absorb_padding` (the user typed the opening form) each span
115/// grows inward over one run of horizontal whitespace, so `ds(` undoes
116/// what `ysiw(` did. Without it the spans are the delimiters alone and
117/// `ds)` leaves any padding in place.
118///
119/// The two runs are clamped against each other: in `(   )` the forward
120/// run would otherwise take all three spaces and the backward run would
121/// take them again, producing overlapping edits in one batch.
122fn delimiter_spans(
123    buffer: &Buffer,
124    open_pos: Position,
125    open_len: u32,
126    close_pos: Position,
127    close_len: u32,
128    absorb_padding: bool,
129) -> (
130    lattice_protocol::position::Range,
131    lattice_protocol::position::Range,
132) {
133    let open_end = open_pos.byte + open_len;
134    let close_end = close_pos.byte + close_len;
135
136    let (pad_after_open, pad_before_close) = if !absorb_padding {
137        (0, 0)
138    } else if open_pos.line == close_pos.line {
139        let line = buffer.line(open_pos.line).unwrap_or_default().to_string();
140        let inner_end = close_pos.byte as usize;
141        let forward = space_run_forward(&line, open_end as usize, inner_end);
142        let backward = space_run_backward(
143            &line,
144            inner_end,
145            (open_end as usize + forward).min(inner_end),
146        );
147        (forward as u32, backward as u32)
148    } else {
149        let open_line = buffer.line(open_pos.line).unwrap_or_default().to_string();
150        let close_line = buffer.line(close_pos.line).unwrap_or_default().to_string();
151        let forward = space_run_forward(&open_line, open_end as usize, open_line.len());
152        let backward = space_run_backward(&close_line, close_pos.byte as usize, 0);
153        (forward as u32, backward as u32)
154    };
155
156    (
157        lattice_protocol::position::Range::new(
158            open_pos,
159            Position::new(open_pos.line, open_end + pad_after_open),
160        ),
161        lattice_protocol::position::Range::new(
162            Position::new(close_pos.line, close_pos.byte - pad_before_close),
163            Position::new(close_pos.line, close_end),
164        ),
165    )
166}
167
168// ── Pair detection ────────────────────────────────────────────
169
170/// Find the nearest enclosing pair matching `target` around the
171/// cursor position.
172///
173/// Returns `Some((opener_byte, closer_byte))` (byte offsets in the
174/// buffer) or `None` if no matching pair encloses the cursor.
175///
176/// Algorithm: scan backward from cursor tracking closers, scan
177/// forward tracking openers. Stacks handle nesting correctly.
178///
179/// SU.3f: a delimiter sitting *under* the cursor counts as part of the
180/// pair it belongs to, matching vim — `ds"` works with the caret on
181/// either quote. The two scans are half-open around the cursor
182/// (backward is `byte < cursor_byte`, forward is `byte >= cursor_byte`),
183/// so a closer under the cursor was already found by the forward scan
184/// while an opener under it was skipped by both. Nudging the effective
185/// cursor one character right when it sits on an opener puts it just
186/// inside its own pair, which is the position the scans already handle.
187pub fn find_surround_pair(
188    buffer: &Buffer,
189    cursor: Position,
190    target: char,
191) -> Option<(usize, usize)> {
192    let text = buffer.as_string();
193    let cursor_byte = buffer.position_to_byte(cursor).ok()?;
194
195    // Determine target's opener/closer identity.
196    let (target_open, target_close) = open_close_pair(target)?;
197
198    let cursor_byte = match text.get(cursor_byte..).and_then(|s| s.chars().next()) {
199        // Symmetric target (`"`, `'`, backtick): the character is its own
200        // closer, so "am I on an opener?" cannot be read off the character
201        // and is decided by how many precede it on the line — an even count
202        // means this one opens. Skipping this and always nudging would
203        // resolve `"a" "b"` with the caret on the second pair's opening
204        // quote to the *gap* between the pairs (quotes 2 and 4), which is a
205        // real enclosing pair and the wrong one.
206        Some(ch) if ch == target_open && target_open == target_close => {
207            let line = buffer.line(cursor.line).unwrap_or_default().to_string();
208            let preceding = line
209                .get(..cursor.byte as usize)
210                .unwrap_or("")
211                .chars()
212                .filter(|c| *c == target_open)
213                .count();
214            if preceding % 2 == 0 {
215                cursor_byte + ch.len_utf8()
216            } else {
217                cursor_byte
218            }
219        }
220        // Asymmetric: the character says which end it is. On the closer,
221        // leave the cursor alone — the forward scan starts there and finds
222        // it.
223        Some(ch) if ch == target_open => cursor_byte + ch.len_utf8(),
224        _ => cursor_byte,
225    };
226
227    // ---- Backward scan: find the unmatched opener ----
228    let mut closer_stack: Vec<char> = Vec::new();
229    let mut opener_byte: Option<usize> = None;
230    let char_indices: Vec<(usize, char)> = text.char_indices().collect();
231
232    for (byte, ch) in char_indices.iter().rev() {
233        let byte = *byte;
234        let ch = *ch;
235        if byte >= cursor_byte {
236            continue;
237        }
238
239        if is_symmetric(ch) && ch == target_close {
240            // Symmetric: alternate open/close semantics via stack.
241            if closer_stack.last() == Some(&ch) {
242                // Found a balancing closer → pop.
243                closer_stack.pop();
244            } else if closer_stack.is_empty() {
245                // Stack empty: this is the unmatched opener.
246                opener_byte = Some(byte);
247                break;
248            } else {
249                // Different char on stack: treat as closer.
250                closer_stack.push(ch);
251            }
252        } else if is_closer(ch) {
253            let complement = matching_opener(ch);
254            if complement == Some(ch) {
255                // Self-complementary closer.
256                closer_stack.push(ch);
257            } else {
258                closer_stack.push(ch);
259            }
260        } else if is_opener(ch) {
261            let complement = matching_closer(ch);
262            if let Some(comp) = complement {
263                if closer_stack.last() == Some(&comp) {
264                    closer_stack.pop();
265                }
266                // If stack is now empty and ch matches our target opener:
267                if closer_stack.is_empty() && ch == target_open {
268                    opener_byte = Some(byte);
269                    break;
270                }
271            }
272        } else if is_symmetric(ch) {
273            // Non-target symmetric char — treat as opener (pop) or closer (push).
274            if closer_stack.last() == Some(&ch) {
275                closer_stack.pop();
276            } else {
277                closer_stack.push(ch);
278            }
279        }
280    }
281
282    let opener_byte = opener_byte?;
283
284    // ---- Forward scan: find the unmatched closer ----
285    let mut opener_stack: Vec<char> = Vec::new();
286    let mut closer_byte: Option<usize> = None;
287
288    for (byte, ch) in char_indices.iter() {
289        let byte = *byte;
290        let ch = *ch;
291        if byte < cursor_byte {
292            continue;
293        }
294
295        if is_symmetric(ch) && ch == target_open {
296            if opener_stack.last() == Some(&ch) {
297                opener_stack.pop();
298            } else if opener_stack.is_empty() {
299                // Stack empty: this is the unmatched closer.
300                closer_byte = Some(byte);
301                break;
302            } else {
303                opener_stack.push(ch);
304            }
305        } else if is_opener(ch) {
306            opener_stack.push(ch);
307        } else if is_closer(ch) {
308            if let Some(open) = opener_stack.last() {
309                let comp = matching_closer(*open);
310                if comp == Some(ch) || (is_symmetric(*open) && *open == ch) {
311                    opener_stack.pop();
312                }
313            }
314            if opener_stack.is_empty() && ch == target_close {
315                closer_byte = Some(byte);
316                break;
317            }
318        } else if is_symmetric(ch) {
319            if opener_stack.last() == Some(&ch) {
320                opener_stack.pop();
321            } else {
322                opener_stack.push(ch);
323            }
324        }
325    }
326
327    let closer_byte = closer_byte?;
328    Some((opener_byte, closer_byte))
329}
330
331// ── Operator: surround-delete ─────────────────────────────────
332
333fn operator_surround_delete(
334    ctx: &mut lattice_grammar::registry::OperatorContext,
335) -> Result<Effect, CommandError> {
336    let target = match &ctx.args {
337        Args::Char(c) => *c,
338        _ => return Err(CommandError::InvalidArgs("ds requires Args::Char")),
339    };
340
341    // Scope the immutable buffer borrow — compute everything needed
342    // before we apply mutable edits.
343    let (open_pos, close_pos, open_text, close_text) = {
344        let buffer = ctx.document.buffer();
345        let cursor = get_cursor_from_document(ctx.document);
346        let (open_byte, close_byte) = match find_surround_pair(buffer, cursor, target) {
347            Some(pair) => pair,
348            None => return Ok(Effect::None),
349        };
350        let open_pos = buffer.byte_to_position(open_byte)?;
351        let close_pos = buffer.byte_to_position(close_byte)?;
352
353        let yanked_open = buffer.slice(lattice_protocol::position::Range::new(
354            open_pos,
355            Position::new(open_pos.line, open_pos.byte + target.len_utf8() as u32),
356        ))?;
357
358        let target_close = open_close_pair(target).map(|(_, c)| c).unwrap_or(target);
359        let yanked_close = buffer.slice(lattice_protocol::position::Range::new(
360            close_pos,
361            Position::new(
362                close_pos.line,
363                close_pos.byte + target_close.len_utf8() as u32,
364            ),
365        ))?;
366
367        (open_pos, close_pos, yanked_open, yanked_close)
368    };
369
370    let target_close = open_close_pair(target).map(|(_, c)| c).unwrap_or(target);
371
372    // SU.3g: `ds(` takes the inner padding with it, `ds)` does not — so
373    // `ds(` undoes `ysiw(` exactly and a round trip does not accumulate
374    // spaces.
375    let (open_span, close_span) = {
376        let buffer = ctx.document.buffer();
377        delimiter_spans(
378            buffer,
379            open_pos,
380            target.len_utf8() as u32,
381            close_pos,
382            target_close.len_utf8() as u32,
383            pads_inside(target),
384        )
385    };
386
387    // Apply deletions as one undo batch (closer first so byte offsets stay valid).
388    let edit_close = Edit::delete(close_span);
389    let edit_open = Edit::delete(open_span);
390
391    let applied = ctx.document.apply_edit_batch(vec![edit_close, edit_open])?;
392
393    let new_cursor = if open_pos.line == close_pos.line {
394        Position::new(open_pos.line, open_pos.byte)
395    } else {
396        open_pos
397    };
398
399    Ok(Effect::Many(vec![
400        Effect::Edits(applied),
401        Effect::Yank {
402            register: ctx.register,
403            content: format!("{}{}", open_text, close_text),
404            kind: YankKind::Charwise,
405            explicit_yank: false,
406        },
407        Effect::CursorMove(new_cursor),
408    ]))
409}
410
411// ── Operator: surround-change ─────────────────────────────────
412
413fn operator_surround_change(
414    ctx: &mut lattice_grammar::registry::OperatorContext,
415) -> Result<Effect, CommandError> {
416    let (target, replacement) = match &ctx.args {
417        Args::List(values) if values.len() == 2 => {
418            let t = match &values[0] {
419                ArgValue::Char(c) => *c,
420                _ => return Err(CommandError::InvalidArgs("cs arg[0] must be Char")),
421            };
422            let r = match &values[1] {
423                ArgValue::Char(c) => *c,
424                _ => return Err(CommandError::InvalidArgs("cs arg[1] must be Char")),
425            };
426            (t, r)
427        }
428        _ => {
429            return Err(CommandError::InvalidArgs(
430                "cs requires Args::List([Char, Char])",
431            ));
432        }
433    };
434
435    let (new_open, new_close) = match open_close_pair(replacement) {
436        Some(pair) => pair,
437        None => return Ok(Effect::None),
438    };
439    let target_close = open_close_pair(target).map(|(_, c)| c).unwrap_or(target);
440
441    let (open_pos, close_pos, open_text, close_text) = {
442        let buffer = ctx.document.buffer();
443        let cursor = get_cursor_from_document(ctx.document);
444        let (open_byte, close_byte) = match find_surround_pair(buffer, cursor, target) {
445            Some(pair) => pair,
446            None => return Ok(Effect::None),
447        };
448        let open_pos = buffer.byte_to_position(open_byte)?;
449        let close_pos = buffer.byte_to_position(close_byte)?;
450
451        let yanked_open = buffer.slice(lattice_protocol::position::Range::new(
452            open_pos,
453            Position::new(open_pos.line, open_pos.byte + target.len_utf8() as u32),
454        ))?;
455        let yanked_close = buffer.slice(lattice_protocol::position::Range::new(
456            close_pos,
457            Position::new(
458                close_pos.line,
459                close_pos.byte + target_close.len_utf8() as u32,
460            ),
461        ))?;
462
463        (open_pos, close_pos, yanked_open, yanked_close)
464    };
465
466    // SU.3g: both halves of the rule meet here. The *target* decides
467    // whether the old pair's padding comes out (`cs(` yes, `cs)` no) and
468    // the *replacement* decides whether new padding goes in — so
469    // `cs("` turns `( hello )` into `"hello"` and `cs")` turns
470    // `"hello"` into `(hello)`.
471    let pad = padding_for(replacement);
472    let open_text_repl = format!("{new_open}{pad}");
473    let close_text_repl = format!("{pad}{new_close}");
474
475    let (open_span, close_span) = {
476        let buffer = ctx.document.buffer();
477        delimiter_spans(
478            buffer,
479            open_pos,
480            target.len_utf8() as u32,
481            close_pos,
482            target_close.len_utf8() as u32,
483            pads_inside(target),
484        )
485    };
486
487    let edit_close = Edit::replace(close_span, close_text_repl.clone());
488    let edit_open = Edit::replace(open_span, open_text_repl);
489
490    let applied = ctx.document.apply_edit_batch(vec![edit_close, edit_open])?;
491
492    let new_cursor = if open_pos.line == close_pos.line {
493        Position::new(open_pos.line, open_pos.byte + 1)
494    } else {
495        Position::new(open_pos.line, open_pos.byte + new_open.len_utf8() as u32)
496    };
497    Ok(Effect::Many(vec![
498        Effect::Edits(applied),
499        Effect::Yank {
500            register: ctx.register,
501            content: format!("{}{}", open_text, close_text),
502            kind: YankKind::Charwise,
503            explicit_yank: false,
504        },
505        Effect::CursorMove(new_cursor),
506    ]))
507}
508
509// ── Operator: surround-add ────────────────────────────────────
510
511fn operator_surround_add(
512    ctx: &mut lattice_grammar::registry::OperatorContext,
513) -> Result<Effect, CommandError> {
514    let wrapper = match &ctx.args {
515        Args::Char(c) => *c,
516        _ => {
517            return Err(CommandError::InvalidArgs(
518                "surround-add requires Args::Char",
519            ));
520        }
521    };
522
523    let (open, close) = match open_close_pair(wrapper) {
524        Some(pair) => pair,
525        None => return Ok(Effect::None),
526    };
527
528    if ctx.range.is_empty() {
529        return Ok(Effect::None);
530    }
531
532    // Scope immutable buffer borrow — compute wrap content and cursor
533    // position before applying the mutable edit.
534    let (wrap_start, wrap_end, wrap_text, new_cursor) = {
535        let buffer = ctx.document.buffer();
536
537        let (wrap_start, wrap_end, wrap_text) = if ctx.linewise {
538            let line = buffer.line(ctx.range.start.line).unwrap_or_default();
539            let start = Position::new(ctx.range.start.line, 0);
540            let line_byte_len = buffer.line_byte_len(ctx.range.start.line);
541            let end = Position::new(ctx.range.start.line, line_byte_len);
542            let text = line.to_string();
543            (start, end, text)
544        } else {
545            let text = buffer.slice(ctx.range)?;
546            (ctx.range.start, ctx.range.end, text)
547        };
548
549        // SU.3g: the cursor lands just inside the pair, which with the
550        // padding form means past the pad as well as past the delimiter.
551        let inside = open.len_utf8() + padding_for(wrapper).len();
552
553        let new_cursor = if ctx.linewise {
554            Position::new(wrap_start.line, inside as u32)
555        } else {
556            let new_byte = buffer.position_to_byte(wrap_start)? + inside;
557            buffer.byte_to_position(new_byte)?
558        };
559
560        (wrap_start, wrap_end, wrap_text, new_cursor)
561    };
562
563    // SU.3g: `ysiw(` → `( hello )`, `ysiw)` → `(hello)`.
564    let pad = padding_for(wrapper);
565    let wrapped = format!("{open}{pad}{wrap_text}{pad}{close}");
566
567    let wrap_range = lattice_protocol::position::Range::new(wrap_start, wrap_end);
568    let edit = Edit::replace(wrap_range, wrapped);
569    let applied = ctx.document.apply_edit(edit)?;
570
571    Ok(Effect::Many(vec![
572        Effect::Edits(vec![applied]),
573        Effect::CursorMove(new_cursor),
574    ]))
575}
576
577// ── Helpers ───────────────────────────────────────────────────
578
579fn get_cursor_from_document(doc: &Document) -> Position {
580    doc.selections().primary().head
581}
582
583// ── Operator registration ─────────────────────────────────────
584
585/// Typed handles for the surround operators.
586#[derive(Debug, Clone)]
587pub struct SurroundOperators {
588    /// `operator:surround-delete` — vim's `ds{char}`.
589    pub delete: OperatorId,
590    /// `operator:surround-change` — vim's `cs{old}{new}`.
591    pub change: OperatorId,
592    /// `operator:surround-add` — vim's `ys{motion}{char}` / `yss` / visual `S`.
593    pub add: OperatorId,
594}
595
596/// Register the three surround operators in the shared
597/// `CommandRegistry`. Called from the host's grammar bootstrap.
598pub fn register_surround_operators(registry: &mut CommandRegistry) -> SurroundOperators {
599    let delete = registry.register_operator(
600        "operator:surround-delete",
601        "Delete the nearest surrounding pair (vim's `ds{char}`).",
602        OperatorSpec {
603            repeatable: true,
604            apply: Arc::new(operator_surround_delete),
605            args_schema: vec![ArgSpec::required(
606                "target",
607                ArgKind::Char,
608                "The surrounding pair character to delete (e.g. `\"`, `(`, `[`)",
609            )],
610            blockwise_per_row: false,
611            post_motion_char: false,
612        },
613    );
614
615    let change = registry.register_operator(
616        "operator:surround-change",
617        "Change the nearest surrounding pair to a different one (vim's `cs{old}{new}`).",
618        OperatorSpec {
619            repeatable: true,
620            apply: Arc::new(operator_surround_change),
621            args_schema: vec![
622                ArgSpec::required(
623                    "target",
624                    ArgKind::Char,
625                    "The current surrounding pair character",
626                ),
627                ArgSpec::required(
628                    "replacement",
629                    ArgKind::Char,
630                    "The replacement surrounding pair character",
631                ),
632            ],
633            blockwise_per_row: false,
634            post_motion_char: false,
635        },
636    );
637
638    let add = registry.register_operator(
639        "operator:surround-add",
640        "Wrap the target range in a surrounding pair (vim's `yss{char}` / visual `S{char}` / `ys{motion}{char}`).",
641        OperatorSpec {
642            repeatable: true,
643            apply: Arc::new(operator_surround_add),
644            args_schema: vec![
645                ArgSpec::required("wrapper", ArgKind::Char, "The pair character to wrap with"),
646            ],
647            blockwise_per_row: false,
648            post_motion_char: true,
649        },
650    );
651
652    SurroundOperators {
653        delete,
654        change,
655        add,
656    }
657}
658
659// ── Mode ──────────────────────────────────────────────────────
660
661/// Minor mode providing vim-surround operations.
662///
663/// Owns its operators and keymap surface. All behavior lives in
664/// the grammar operators registered at boot; the mode contributes
665/// only the keymap (with wildcard capture paths for `ds{char}`
666/// and `cs{char1}{char2}`).
667pub struct SurroundMode {
668    operators: SurroundOperators,
669}
670
671impl SurroundMode {
672    /// The canonical id, `"surround-mode"` — what [`Mode::id`](crate::Mode::id)
673    /// returns. Use it to name this mode without an instance (activation,
674    /// `implies`, keymap layers, tests).
675    pub fn mode_id() -> ModeId {
676        ModeId::new("surround-mode")
677    }
678}
679
680impl Mode for SurroundMode {
681    type Guard = ();
682
683    fn id(&self) -> ModeId {
684        Self::mode_id()
685    }
686
687    fn kind(&self) -> ModeKind {
688        ModeKind::Minor
689    }
690
691    fn activation_policy(&self) -> ActivationPolicy {
692        ActivationPolicy::Global
693    }
694
695    fn keymap(&self) -> Keymap {
696        use lattice_grammar::command::CommandInvocation;
697        use lattice_keymap::binding_mode::BindingMode;
698        use lattice_keymap::contribution::KeymapBinding;
699
700        let lit_ch = |c: char| ChordPattern::Literal(KeyChord::char(c));
701
702        // Chain-form only. Every binding here ends in a `CharLiteral`
703        // wildcard, which the table form cannot express.
704        //
705        // SU.3e: there used to be four table-form entries appended below,
706        // on the belief that they were an inert `:describe-key` / `:keymap`
707        // catalog while these bindings did the dispatching. They were not
708        // inert. `push_mode_keymap` resolves entries into real
709        // `KeymapBinding`s and pushes them into the SAME trie, so the
710        // Visual row `chord: "S"` bound `[S]` at one chord and the trie —
711        // which returns a node's own binding before descending — could
712        // never reach `[S, CharLiteral]`. The mode shadowed itself; that is
713        // the `S`-does-nothing-but-`:describe-key`-lists-it report.
714        //
715        // The three Normal rows were worse than redundant: they wrote their
716        // chords space-separated (`"d s"`), and `parse_chord_sequence` reads
717        // a space as a literal Space chord, so they bound `d<Space>s`,
718        // `c<Space>s` and `y<Space>s<Space>s`. Nothing resolved those, and
719        // three stray bindings sat in the trie. They were the only
720        // space-separated `keymap_entry!` chords in the workspace.
721        //
722        // Nothing was lost by deleting them. No production code reads the
723        // static catalog: `:describe-key` resolves against the trie
724        // (`Editor::build_describe_key_content` → `resolve_trace`), so the
725        // `with_doc` strings below are what it already showed.
726        Keymap::new()
727            .bind(
728                KeymapBinding::new(
729                    BindingMode::Normal,
730                    vec![lit_ch('d'), lit_ch('s'), ChordPattern::CharLiteral],
731                    CommandInvocation::of(self.operators.delete.0)
732                        .with_range(lattice_grammar::range::Range::CurrentLine),
733                    SourceLocation::builtin_file(file!(), line!()),
734                )
735                .with_doc("Delete the nearest surrounding pair (vim's `ds{char}`)."),
736            )
737            .bind(
738                KeymapBinding::new(
739                    BindingMode::Normal,
740                    vec![
741                        lit_ch('c'),
742                        lit_ch('s'),
743                        ChordPattern::CharLiteral,
744                        ChordPattern::CharLiteral,
745                    ],
746                    CommandInvocation::of(self.operators.change.0)
747                        .with_range(lattice_grammar::range::Range::CurrentLine),
748                    SourceLocation::builtin_file(file!(), line!()),
749                )
750                .with_doc("Change the nearest surrounding pair (vim's `cs{old}{new}`)."),
751            )
752            .bind(
753                KeymapBinding::new(
754                    BindingMode::Normal,
755                    vec![
756                        lit_ch('y'),
757                        lit_ch('s'),
758                        lit_ch('s'),
759                        ChordPattern::CharLiteral,
760                    ],
761                    CommandInvocation::of(self.operators.add.0)
762                        .with_range(lattice_grammar::range::Range::CurrentLine),
763                    SourceLocation::builtin_file(file!(), line!()),
764                )
765                .with_doc("Wrap the current line in a surrounding pair (vim's `yss{char}`)."),
766            )
767            .bind(
768                KeymapBinding::new(
769                    BindingMode::Visual,
770                    vec![lit_ch('S'), ChordPattern::CharLiteral],
771                    CommandInvocation::of(self.operators.add.0)
772                        .with_range(lattice_grammar::range::Range::Selection),
773                    SourceLocation::builtin_file(file!(), line!()),
774                )
775                .with_doc(
776                    "Wrap the visual selection in a surrounding pair (vim's visual `S{char}`).",
777                ),
778            )
779    }
780
781    fn on_activate(&self, _ctx: ModeContext) -> LifecycleFuture<'_, ()> {
782        Box::pin(async { Ok(()) })
783    }
784
785    fn required_capabilities(&self) -> CapabilitySet {
786        CapabilitySet::empty()
787    }
788}
789
790// ── Mode registration ─────────────────────────────────────────
791
792/// Register surround-mode in the ModeRegistry. Must be called
793/// AFTER `register_surround_operators` so the operator IDs are
794/// available for keymap construction.
795pub fn register_surround_modes(
796    registry: &mut crate::registry::ModeRegistry,
797    operators: SurroundOperators,
798) {
799    registry
800        .register(SurroundMode { operators })
801        .expect("surround-mode must register without conflict");
802}
803
804// ── Tests ─────────────────────────────────────────────────────
805
806#[cfg(test)]
807mod tests {
808    use super::*;
809    use lattice_core::buffer::Buffer;
810    use lattice_protocol::position::Position;
811
812    #[test]
813    fn open_close_pair_maps_brackets() {
814        assert_eq!(open_close_pair('('), Some(('(', ')')));
815        assert_eq!(open_close_pair(')'), Some(('(', ')')));
816        assert_eq!(open_close_pair('['), Some(('[', ']')));
817        assert_eq!(open_close_pair(']'), Some(('[', ']')));
818        assert_eq!(open_close_pair('{'), Some(('{', '}')));
819        assert_eq!(open_close_pair('}'), Some(('{', '}')));
820        assert_eq!(open_close_pair('<'), Some(('<', '>')));
821        assert_eq!(open_close_pair('>'), Some(('<', '>')));
822    }
823
824    #[test]
825    fn open_close_pair_maps_symmetric() {
826        assert_eq!(open_close_pair('"'), Some(('"', '"')));
827        assert_eq!(open_close_pair('\''), Some(('\'', '\'')));
828        assert_eq!(open_close_pair('`'), Some(('`', '`')));
829    }
830
831    #[test]
832    fn open_close_pair_unknown_returns_none() {
833        assert_eq!(open_close_pair('x'), None);
834        assert_eq!(open_close_pair(' '), None);
835    }
836
837    #[test]
838    fn find_surround_pair_simple_quotes() {
839        let buf = Buffer::from_text("hello \"world\" foo");
840        // Cursor at 'o' in "world" (byte 8 within line 0).
841        let cursor = buf.position_to_byte(Position::new(0, 8)).unwrap();
842        let cursor_pos = buf.byte_to_position(cursor).unwrap();
843        let pair = find_surround_pair(&buf, cursor_pos, '"').unwrap();
844        let open_pos = buf.byte_to_position(pair.0).unwrap();
845        let close_pos = buf.byte_to_position(pair.1).unwrap();
846        assert_eq!(open_pos, Position::new(0, 6)); // first "
847        assert_eq!(close_pos, Position::new(0, 12)); // second "
848    }
849
850    #[test]
851    fn find_surround_pair_brackets() {
852        let buf = Buffer::from_text("fn foo(x: i32) {}");
853        // Cursor at 'i' in i32 (byte 10).
854        let cursor = buf.position_to_byte(Position::new(0, 10)).unwrap();
855        let cursor_pos = buf.byte_to_position(cursor).unwrap();
856        let pair = find_surround_pair(&buf, cursor_pos, '(').unwrap();
857        let open_pos = buf.byte_to_position(pair.0).unwrap();
858        let close_pos = buf.byte_to_position(pair.1).unwrap();
859        // f(0)n(1) (2)f(3)o(4)o(5)((6)x(7):(8) (9)i(10)3(11)2(12))(13) (14){(15)}(16)
860        assert_eq!(open_pos, Position::new(0, 6)); // '('
861        assert_eq!(close_pos, Position::new(0, 13)); // ')'
862    }
863
864    #[test]
865    fn find_surround_pair_nested() {
866        let buf = Buffer::from_text("a (b (c) d) e");
867        // Cursor at 'c' (byte 7).
868        let cursor = buf.position_to_byte(Position::new(0, 7)).unwrap();
869        let cursor_pos = buf.byte_to_position(cursor).unwrap();
870        let pair = find_surround_pair(&buf, cursor_pos, '(').unwrap();
871        let open_pos = buf.byte_to_position(pair.0).unwrap();
872        let close_pos = buf.byte_to_position(pair.1).unwrap();
873        // Inner pair: `(c)`
874        // a(0) (1)(2)b(3) (4)((5)c(6))(7) (8)d(9))(10) (11)e(12)
875        assert_eq!(open_pos, Position::new(0, 5)); // inner '('
876        assert_eq!(close_pos, Position::new(0, 7)); // inner ')'
877    }
878
879    #[test]
880    fn find_surround_pair_no_match() {
881        let buf = Buffer::from_text("hello world");
882        let pair = find_surround_pair(&buf, Position::ZERO, '"');
883        assert!(pair.is_none());
884    }
885
886    // ── SU.3f: a delimiter sitting under the cursor ───────────
887    //
888    // The scans are half-open around the cursor — backward is
889    // `byte < cursor_byte`, forward is `byte >= cursor_byte` — so a
890    // closer under the cursor was always found (the forward scan starts
891    // on it) while an opener under the cursor never was (the backward
892    // scan starts just past it). `ds"` with the caret on the opening
893    // quote did nothing; vim deletes the pair from either delimiter.
894
895    /// Helper: byte-offset pair → (open col, close col) on line 0.
896    fn pair_cols(buf: &Buffer, cursor_col: u32, target: char) -> Option<(u32, u32)> {
897        let pair = find_surround_pair(buf, Position::new(0, cursor_col), target)?;
898        let open = buf.byte_to_position(pair.0).ok()?;
899        let close = buf.byte_to_position(pair.1).ok()?;
900        Some((open.byte, close.byte))
901    }
902
903    #[test]
904    fn a_cursor_on_the_opening_quote_finds_its_pair() {
905        let buf = Buffer::from_text("\"hello\"");
906        assert_eq!(pair_cols(&buf, 0, '"'), Some((0, 6)));
907    }
908
909    #[test]
910    fn a_cursor_on_the_closing_quote_finds_its_pair() {
911        let buf = Buffer::from_text("\"hello\"");
912        assert_eq!(pair_cols(&buf, 6, '"'), Some((0, 6)));
913    }
914
915    #[test]
916    fn a_cursor_on_the_opening_bracket_finds_its_pair() {
917        let buf = Buffer::from_text("(hello)");
918        assert_eq!(pair_cols(&buf, 0, '('), Some((0, 6)));
919    }
920
921    #[test]
922    fn a_cursor_on_the_closing_bracket_finds_its_pair() {
923        let buf = Buffer::from_text("(hello)");
924        assert_eq!(pair_cols(&buf, 6, '('), Some((0, 6)));
925    }
926
927    /// The case that makes "is this delimiter an opener or a closer?"
928    /// a real question rather than a formality. A symmetric char is
929    /// both, so the answer comes from how many precede it on the line:
930    /// an even count means this one opens. Without that rule, the
931    /// cursor on the second pair's opening quote resolves the *gap*
932    /// between the two pairs — quotes 2 and 4 — which is a plausible
933    /// enclosing pair and entirely the wrong one.
934    #[test]
935    fn a_cursor_on_a_later_opening_quote_takes_its_own_pair() {
936        let buf = Buffer::from_text("\"a\" \"b\"");
937        // "(0)a(1)"(2) (3)"(4)b(5)"(6)
938        assert_eq!(pair_cols(&buf, 4, '"'), Some((4, 6)));
939    }
940
941    #[test]
942    fn a_cursor_on_a_later_closing_quote_takes_its_own_pair() {
943        let buf = Buffer::from_text("\"a\" \"b\"");
944        assert_eq!(pair_cols(&buf, 6, '"'), Some((4, 6)));
945    }
946
947    /// Nesting still resolves innermost-first when the cursor is on an
948    /// inner delimiter rather than on text.
949    #[test]
950    fn a_cursor_on_an_inner_bracket_takes_the_inner_pair() {
951        let buf = Buffer::from_text("a (b (c) d) e");
952        // a(0) (1)((2)b(3) (4)((5)c(6))(7) (8)d(9))(10)
953        assert_eq!(pair_cols(&buf, 5, '('), Some((5, 7)));
954    }
955
956    /// An unmatched delimiter under the cursor still finds nothing,
957    /// rather than pairing with something across the buffer.
958    #[test]
959    fn a_lone_delimiter_under_the_cursor_finds_nothing() {
960        let buf = Buffer::from_text("(hello");
961        assert_eq!(pair_cols(&buf, 0, '('), None);
962    }
963
964    #[test]
965    fn find_surround_pair_close_char_target() {
966        let buf = Buffer::from_text("(hello)");
967        let cursor = buf.position_to_byte(Position::new(0, 3)).unwrap(); // on 'l'
968        let cursor_pos = buf.byte_to_position(cursor).unwrap();
969        // Using ')' as target should also find the pair.
970        let pair = find_surround_pair(&buf, cursor_pos, ')').unwrap();
971        let open_pos = buf.byte_to_position(pair.0).unwrap();
972        let close_pos = buf.byte_to_position(pair.1).unwrap();
973        assert_eq!(open_pos, Position::new(0, 0)); // '('
974        assert_eq!(close_pos, Position::new(0, 6)); // ')'
975    }
976
977    #[test]
978    fn find_surround_pair_large_line_performance() {
979        // Worst-case: scan a long line with no matching pair.
980        let line = "x".repeat(10_000);
981        let buf = Buffer::from_text(&line);
982        let cursor = buf.position_to_byte(Position::new(0, 5000)).unwrap();
983        let cursor_pos = buf.byte_to_position(cursor).unwrap();
984        let start = std::time::Instant::now();
985        let _ = find_surround_pair(&buf, cursor_pos, '"');
986        let elapsed = start.elapsed();
987        // A linear scan of 10k chars is well under 1ms; a quadratic one is
988        // tens of ms or more. The bound sits at 5ms because this is a debug
989        // build under the parallel runner, and a shared Windows CI runner
990        // measured 1.46ms — a 1ms bound failed on noise, not on the scan.
991        assert!(
992            elapsed.as_micros() < 5000,
993            "find_surround_pair on 10k chars took {:?}",
994            elapsed
995        );
996    }
997}
998
999#[cfg(test)]
1000mod operator_tests {
1001    use super::*;
1002    use lattice_core::BufferId;
1003    use lattice_grammar::CancellationToken;
1004    use lattice_grammar::args::{ArgValue, Args};
1005    use lattice_grammar::builtins::populate as grammar_builtins_populate;
1006    use lattice_grammar::command::CommandInvocation;
1007    use lattice_grammar::dispatcher::execute as grammar_execute;
1008
1009    use lattice_grammar::builtins::Builtins;
1010
1011    fn fixture(text: &str) -> (CommandRegistry, Builtins, SurroundOperators, Document) {
1012        let mut r = CommandRegistry::new();
1013        let builtins = grammar_builtins_populate(&mut r);
1014        let ops = register_surround_operators(&mut r);
1015        let d = Document::from_text(text);
1016        (r, builtins, ops, d)
1017    }
1018
1019    fn doc_text(doc: &Document) -> String {
1020        doc.buffer().as_string()
1021    }
1022
1023    fn set_cursor(doc: &mut Document, pos: Position) {
1024        use lattice_protocol::selection::{Selection, SelectionSet};
1025        doc.set_selections(SelectionSet::single(Selection::cursor(pos)));
1026    }
1027
1028    #[test]
1029    fn surround_delete_removes_double_quotes() {
1030        let (registry, _builtins, ops, mut doc) = fixture("hello \"world\" foo");
1031        let cursor = Position::new(0, 8); // on 'o' in "world"
1032        set_cursor(&mut doc, cursor);
1033        let inv = CommandInvocation::of(ops.delete.0)
1034            .with_range(lattice_grammar::range::Range::CurrentLine)
1035            .with_args(Args::Char('"'));
1036        grammar_execute(
1037            &registry,
1038            &mut doc,
1039            BufferId(0),
1040            cursor,
1041            inv,
1042            &CancellationToken::never(),
1043        )
1044        .unwrap();
1045        assert_eq!(doc_text(&doc), "hello world foo");
1046    }
1047
1048    #[test]
1049    fn surround_delete_no_match_is_noop() {
1050        let (registry, _builtins, ops, mut doc) = fixture("hello world");
1051        let cursor = Position::ZERO;
1052        set_cursor(&mut doc, cursor);
1053        let inv = CommandInvocation::of(ops.delete.0)
1054            .with_range(lattice_grammar::range::Range::CurrentLine)
1055            .with_args(Args::Char('"'));
1056        let eff = grammar_execute(
1057            &registry,
1058            &mut doc,
1059            BufferId(0),
1060            cursor,
1061            inv,
1062            &CancellationToken::never(),
1063        )
1064        .unwrap();
1065        // Effect::None is produced for no-op.
1066        assert!(matches!(eff, lattice_grammar::effect::Effect::None));
1067        assert_eq!(doc_text(&doc), "hello world");
1068    }
1069
1070    #[test]
1071    fn surround_delete_removes_parens() {
1072        let (registry, _builtins, ops, mut doc) = fixture("fn foo(x: i32) {}");
1073        let cursor = Position::new(0, 10); // on 'i' in i32
1074        set_cursor(&mut doc, cursor);
1075        let inv = CommandInvocation::of(ops.delete.0)
1076            .with_range(lattice_grammar::range::Range::CurrentLine)
1077            .with_args(Args::Char('('));
1078        grammar_execute(
1079            &registry,
1080            &mut doc,
1081            BufferId(0),
1082            cursor,
1083            inv,
1084            &CancellationToken::never(),
1085        )
1086        .unwrap();
1087        assert_eq!(doc_text(&doc), "fn foox: i32 {}");
1088    }
1089
1090    #[test]
1091    fn surround_delete_with_close_char_target() {
1092        let (registry, _builtins, ops, mut doc) = fixture("(hello world)");
1093        let cursor = Position::new(0, 3); // on 'l'
1094        set_cursor(&mut doc, cursor);
1095        let inv = CommandInvocation::of(ops.delete.0)
1096            .with_range(lattice_grammar::range::Range::CurrentLine)
1097            .with_args(Args::Char(')'));
1098        grammar_execute(
1099            &registry,
1100            &mut doc,
1101            BufferId(0),
1102            cursor,
1103            inv,
1104            &CancellationToken::never(),
1105        )
1106        .unwrap();
1107        assert_eq!(doc_text(&doc), "hello world");
1108    }
1109
1110    #[test]
1111    fn surround_change_double_to_single_quotes() {
1112        let (registry, _builtins, ops, mut doc) = fixture("hello \"world\" foo");
1113        let cursor = Position::new(0, 8); // on 'o' in "world"
1114        set_cursor(&mut doc, cursor);
1115        let inv = CommandInvocation::of(ops.change.0)
1116            .with_range(lattice_grammar::range::Range::CurrentLine)
1117            .with_args(Args::List(vec![ArgValue::Char('"'), ArgValue::Char('\'')]));
1118        grammar_execute(
1119            &registry,
1120            &mut doc,
1121            BufferId(0),
1122            cursor,
1123            inv,
1124            &CancellationToken::never(),
1125        )
1126        .unwrap();
1127        assert_eq!(doc_text(&doc), "hello 'world' foo");
1128    }
1129
1130    /// SU.3g changed this expectation deliberately: the replacement here
1131    /// is `[`, the padding form, so the result gains inner spaces. The
1132    /// unpadded spelling is `cs(]`, covered in
1133    /// `surround_change_pads_when_the_replacement_is_an_opening_form`.
1134    #[test]
1135    fn surround_change_parens_to_brackets() {
1136        let (registry, _builtins, ops, mut doc) = fixture("(hello)");
1137        let cursor = Position::new(0, 2); // on 'e'
1138        set_cursor(&mut doc, cursor);
1139        let inv = CommandInvocation::of(ops.change.0)
1140            .with_range(lattice_grammar::range::Range::CurrentLine)
1141            .with_args(Args::List(vec![ArgValue::Char('('), ArgValue::Char('[')]));
1142        grammar_execute(
1143            &registry,
1144            &mut doc,
1145            BufferId(0),
1146            cursor,
1147            inv,
1148            &CancellationToken::never(),
1149        )
1150        .unwrap();
1151        assert_eq!(doc_text(&doc), "[ hello ]");
1152    }
1153
1154    #[test]
1155    fn surround_add_linewise_wraps_line() {
1156        let (registry, _builtins, ops, mut doc) = fixture("hello world\n");
1157        let cursor = Position::new(0, 0);
1158        let inv = CommandInvocation::of(ops.add.0)
1159            .with_range(lattice_grammar::range::Range::CurrentLine)
1160            .with_args(Args::Char('"'));
1161        grammar_execute(
1162            &registry,
1163            &mut doc,
1164            BufferId(0),
1165            cursor,
1166            inv,
1167            &CancellationToken::never(),
1168        )
1169        .unwrap();
1170        assert_eq!(doc_text(&doc), "\"hello world\"\n");
1171    }
1172
1173    /// SU.3g changed this expectation deliberately: `(` is the padding
1174    /// form, so a linewise add with `(` now yields `( hello )`. The
1175    /// unpadded spelling moved to `surround_add_with_the_closing_form_does_not_pad`.
1176    #[test]
1177    fn surround_add_linewise_wraps_line_with_brackets() {
1178        let (registry, _builtins, ops, mut doc) = fixture("hello\n");
1179        let cursor = Position::new(0, 0);
1180        let inv = CommandInvocation::of(ops.add.0)
1181            .with_range(lattice_grammar::range::Range::CurrentLine)
1182            .with_args(Args::Char('('));
1183        grammar_execute(
1184            &registry,
1185            &mut doc,
1186            BufferId(0),
1187            cursor,
1188            inv,
1189            &CancellationToken::never(),
1190        )
1191        .unwrap();
1192        assert_eq!(doc_text(&doc), "( hello )\n");
1193    }
1194
1195    // ── SU.3g: the opening form pads, the closing form does not ──
1196
1197    /// Run `yss{ch}` (linewise add) over `text` and return the result.
1198    fn add_linewise(text: &str, wrapper: char) -> String {
1199        let (registry, _builtins, ops, mut doc) = fixture(text);
1200        let cursor = Position::new(0, 0);
1201        let inv = CommandInvocation::of(ops.add.0)
1202            .with_range(lattice_grammar::range::Range::CurrentLine)
1203            .with_args(Args::Char(wrapper));
1204        grammar_execute(
1205            &registry,
1206            &mut doc,
1207            BufferId(0),
1208            cursor,
1209            inv,
1210            &CancellationToken::never(),
1211        )
1212        .unwrap();
1213        doc_text(&doc)
1214    }
1215
1216    /// Run `cs{target}{replacement}` with the cursor at `col`.
1217    fn change_at(text: &str, col: u32, target: char, replacement: char) -> String {
1218        let (registry, _builtins, ops, mut doc) = fixture(text);
1219        let cursor = Position::new(0, col);
1220        set_cursor(&mut doc, cursor);
1221        let inv = CommandInvocation::of(ops.change.0)
1222            .with_range(lattice_grammar::range::Range::CurrentLine)
1223            .with_args(Args::List(vec![
1224                ArgValue::Char(target),
1225                ArgValue::Char(replacement),
1226            ]));
1227        grammar_execute(
1228            &registry,
1229            &mut doc,
1230            BufferId(0),
1231            cursor,
1232            inv,
1233            &CancellationToken::never(),
1234        )
1235        .unwrap();
1236        doc_text(&doc)
1237    }
1238
1239    /// Run `ds{target}` with the cursor at `col`.
1240    fn delete_at(text: &str, col: u32, target: char) -> String {
1241        let (registry, _builtins, ops, mut doc) = fixture(text);
1242        let cursor = Position::new(0, col);
1243        set_cursor(&mut doc, cursor);
1244        let inv = CommandInvocation::of(ops.delete.0)
1245            .with_range(lattice_grammar::range::Range::CurrentLine)
1246            .with_args(Args::Char(target));
1247        grammar_execute(
1248            &registry,
1249            &mut doc,
1250            BufferId(0),
1251            cursor,
1252            inv,
1253            &CancellationToken::never(),
1254        )
1255        .unwrap();
1256        doc_text(&doc)
1257    }
1258
1259    #[test]
1260    fn surround_add_with_the_opening_form_pads() {
1261        assert_eq!(add_linewise("hello\n", '('), "( hello )\n");
1262        assert_eq!(add_linewise("hello\n", '['), "[ hello ]\n");
1263        assert_eq!(add_linewise("hello\n", '{'), "{ hello }\n");
1264        assert_eq!(add_linewise("hello\n", '<'), "< hello >\n");
1265    }
1266
1267    #[test]
1268    fn surround_add_with_the_closing_form_does_not_pad() {
1269        assert_eq!(add_linewise("hello\n", ')'), "(hello)\n");
1270        assert_eq!(add_linewise("hello\n", ']'), "[hello]\n");
1271        assert_eq!(add_linewise("hello\n", '}'), "{hello}\n");
1272        assert_eq!(add_linewise("hello\n", '>'), "<hello>\n");
1273    }
1274
1275    /// A symmetric wrapper has no opening form to distinguish, so it never
1276    /// pads however it is typed.
1277    #[test]
1278    fn surround_add_never_pads_a_symmetric_wrapper() {
1279        assert_eq!(add_linewise("hello\n", '"'), "\"hello\"\n");
1280        assert_eq!(add_linewise("hello\n", '\''), "'hello'\n");
1281    }
1282
1283    #[test]
1284    fn surround_change_pads_when_the_replacement_is_an_opening_form() {
1285        assert_eq!(change_at("\"hello\"", 3, '"', '('), "( hello )");
1286        assert_eq!(change_at("\"hello\"", 3, '"', ')'), "(hello)");
1287    }
1288
1289    /// The removal half, and the reason it is in this slice rather than a
1290    /// later one: without it `ds(` cannot undo what `ysiw(` just did, and
1291    /// the padding would accumulate on every round trip.
1292    #[test]
1293    fn surround_delete_with_the_opening_form_takes_the_padding_too() {
1294        assert_eq!(delete_at("( hello )", 3, '('), "hello");
1295        // Already unpadded — the opening form must not eat real text.
1296        assert_eq!(delete_at("(hello)", 3, '('), "hello");
1297    }
1298
1299    /// The closing form deletes the delimiters and nothing else, so the
1300    /// padding survives. This is the pair to the test above: the two forms
1301    /// have to differ on removal or the distinction is decorative.
1302    #[test]
1303    fn surround_delete_with_the_closing_form_leaves_the_padding() {
1304        assert_eq!(delete_at("( hello )", 3, ')'), " hello ");
1305    }
1306
1307    #[test]
1308    fn surround_change_from_a_padded_pair_drops_the_padding() {
1309        assert_eq!(change_at("( hello )", 3, '(', '"'), "\"hello\"");
1310        // ...and the closing form keeps it, symmetrically with delete.
1311        assert_eq!(change_at("( hello )", 3, ')', '"'), "\" hello \"");
1312    }
1313
1314    /// Round trip: add the padded form, remove it with the same character,
1315    /// and the buffer is back where it started. This is the property the
1316    /// two halves exist to hold.
1317    #[test]
1318    fn the_padded_forms_round_trip() {
1319        for ch in ['(', '[', '{', '<'] {
1320            let added = add_linewise("hello\n", ch);
1321            let back = delete_at(added.trim_end_matches('\n'), 3, ch);
1322            assert_eq!(back, "hello", "round trip failed for {ch:?}");
1323        }
1324    }
1325
1326    #[test]
1327    fn surround_add_on_selection_wraps_text() {
1328        let (registry, _builtins, ops, mut doc) = fixture("hello world");
1329        let cursor = Position::new(0, 0);
1330        // Set the document's selection to cover "hello" (bytes 0-5, half-open).
1331        use lattice_protocol::selection::{Selection, SelectionSet};
1332        doc.set_selections(SelectionSet::from_parts(
1333            vec![Selection {
1334                anchor: Position::ZERO,
1335                head: Position::new(0, 5),
1336                visual: None,
1337            }],
1338            0,
1339        ));
1340        let inv = CommandInvocation::of(ops.add.0)
1341            .with_range(lattice_grammar::range::Range::Selection)
1342            .with_args(Args::Char('"'));
1343        let _eff = grammar_execute(
1344            &registry,
1345            &mut doc,
1346            BufferId(0),
1347            cursor,
1348            inv,
1349            &CancellationToken::never(),
1350        )
1351        .unwrap();
1352        let text = doc_text(&doc);
1353        // The text should be wrapped. The exact cursor position after wrap
1354        // varies; assert that the wrapping occurred.
1355        assert!(text.starts_with("\"hello"));
1356        assert!(text.ends_with("\" world") || text.ends_with("\"world"));
1357    }
1358
1359    #[test]
1360    fn surround_change_no_match_is_noop() {
1361        let (registry, _builtins, ops, mut doc) = fixture("hello world");
1362        let cursor = Position::ZERO;
1363        set_cursor(&mut doc, cursor);
1364        let inv = CommandInvocation::of(ops.change.0)
1365            .with_range(lattice_grammar::range::Range::CurrentLine)
1366            .with_args(Args::List(vec![ArgValue::Char('"'), ArgValue::Char('\'')]));
1367        let eff = grammar_execute(
1368            &registry,
1369            &mut doc,
1370            BufferId(0),
1371            cursor,
1372            inv,
1373            &CancellationToken::never(),
1374        )
1375        .unwrap();
1376        assert!(matches!(eff, lattice_grammar::effect::Effect::None));
1377        assert_eq!(doc_text(&doc), "hello world");
1378    }
1379
1380    #[test]
1381    fn surround_add_with_unknown_wrapper_is_noop() {
1382        let (registry, _builtins, ops, mut doc) = fixture("hello\n");
1383        let cursor = Position::ZERO;
1384        let inv = CommandInvocation::of(ops.add.0)
1385            .with_range(lattice_grammar::range::Range::CurrentLine)
1386            .with_args(Args::Char('x')); // 'x' is not a known pair
1387        let eff = grammar_execute(
1388            &registry,
1389            &mut doc,
1390            BufferId(0),
1391            cursor,
1392            inv,
1393            &CancellationToken::never(),
1394        )
1395        .unwrap();
1396        assert!(matches!(eff, lattice_grammar::effect::Effect::None));
1397    }
1398
1399    // ── ys{motion}{char} operator dispatch tests ────────────────
1400
1401    #[test]
1402    fn surround_add_via_motion_word_forward() {
1403        // ysw" → wrap from cursor to word end in double quotes.
1404        let (registry, builtins, ops, mut doc) = fixture("hello world");
1405        let cursor = Position::new(0, 0); // on 'h'
1406        set_cursor(&mut doc, cursor);
1407        let inv = CommandInvocation::of(ops.add.0)
1408            .with_target(lattice_grammar::Target::Motion(
1409                builtins.word_forward,
1410                Args::None,
1411            ))
1412            .with_args(Args::Char('"'));
1413        grammar_execute(
1414            &registry,
1415            &mut doc,
1416            BufferId(0),
1417            cursor,
1418            inv,
1419            &CancellationToken::never(),
1420        )
1421        .unwrap();
1422        // word_forward from 0 resolves past the space after "hello",
1423        // so the wrapped span is "hello " (vim-accurate: w goes to next word start).
1424        assert!(
1425            doc_text(&doc).starts_with("\"hello"),
1426            "expected wrapped text to start with \"hello, got: {}",
1427            doc_text(&doc)
1428        );
1429    }
1430
1431    #[test]
1432    fn surround_add_via_inner_word_text_object() {
1433        // ysiw" → wrap inner word in double quotes.
1434        let (registry, builtins, ops, mut doc) = fixture("hello world");
1435        let cursor = Position::new(0, 2); // on 'l' in "hello"
1436        set_cursor(&mut doc, cursor);
1437        let inv = CommandInvocation::of(ops.add.0)
1438            .with_target(lattice_grammar::Target::TextObject(
1439                builtins.inner_word,
1440                Args::None,
1441            ))
1442            .with_args(Args::Char('"'));
1443        grammar_execute(
1444            &registry,
1445            &mut doc,
1446            BufferId(0),
1447            cursor,
1448            inv,
1449            &CancellationToken::never(),
1450        )
1451        .unwrap();
1452        assert_eq!(doc_text(&doc), "\"hello\" world");
1453    }
1454}