surround-mode — vim-surround semantics as a native minor mode
Design fragment. Contracts, data model, rationale, rejected alternatives, paramount-goal alignment. Slice sequencing is §12 and lives in
../operations/slice-plans/surround-mode.md. Companion fragments:design.md(§5.8.3 — bundled plugin candidates),plugin-auto-pair.md(the first bundled plugin, structural template).
1. Why
vim-surround (tpope/vim-surround) is the standard for surrounding-text operations — ds", cs"', ysiw(, S<h1> in visual mode. It is one of the most-used vim plugins across the ecosystem (100k+ stars, shipped in every vim distribution). The design doc §5.8.3 lists "surround" as a bundled editing helper. Its purpose here is to be a native minor mode in lattice-mode — not a WASM plugin (WASM is for extensions; vim's default grammar stays native by design, CLAUDE.md §5.8.3).
The mode takes complete ownership of its grammar operators, keymap surface, and handler bodies — no new Editor:: methods, no new variants in the host's Action enum. The mode contributes its operators to the shared CommandRegistry at boot and its keymap at KeymapLayer::MinorMode(surround-mode).
2. Grammar surface
Three operators registered in the shared CommandRegistry at boot:
| Operator | Name | Trigger | Args | Target / Range |
|---|---|---|---|---|
| surround-delete | operator:surround-delete | ds{char} | Args::Char(target) | None (cursor position) |
| surround-change | operator:surround-change | cs{char1}{char2} | Args::List([Char(t), Char(r)]) | None (cursor position) |
| surround-add | operator:surround-add | ys{motion}{char} / yss{char} / S{char} | Args::Char(wrapper) | Range::CurrentLine (yss), Range::Selection (visual S), or resolved motion range (ys{motion}) |
All three are registered as CommandKind::Operator with OperatorSpec. They participate in dot-repeat (repeatable: true). The operator apply closures receive &mut OperatorContext with the resolved range + args.
For surround-delete and surround-change, the effective range is the cursor position (empty range) — the operator reads the cursor, finds the surrounding pair via the scan algorithm (§4), and produces edits covering the pair characters.
For surround-add, the range is the text to wrap. The wrapping character is captured as Args::Char(w). When invoked with a motion target (via the ys{motion}{char} keymap path), the operator receives the resolved motion range through OperatorContext.range.
2.1 post_motion_char flag
operator:surround-add sets OperatorSpec::post_motion_char = true. This flag tells register_operator_bindings to append ChordPattern::CharLiteral after every motion/text-object binding path, so the wrapping character is captured by the wildcard resolution and routed to the operator's Args::Char slot. The same flag exists in the WIT operator-spec for WASM plugin parity. See keymap-architecture.md §12 for the flag's mechanism.
3. Keymap surface
All bindings land at KeymapLayer::MinorMode(surround-mode). Refer to keymap-architecture.md for the layered keymap model.
3.1 Normal mode
| Path | Result |
|---|---|
[d, s, CharLiteral] | Invoke(surround_delete, Args::Char(captured)) |
[c, s, CharLiteral, CharLiteral] | Invoke(surround_change, Args::List([Char(c1), Char(c2)])) |
[y, s, s, CharLiteral] | Invoke(surround_add, Range::CurrentLine, Args::Char(captured)) |
[y, s, {motion}, CharLiteral] | Invoke(surround_add, Target::Motion(motion), Args::Char(captured)) |
The ys{motion}{char} bindings are generated by register_operator_bindings with post_motion_char: true. For every builtin motion (w, b, j, k, e, gg, G, …), text object (iw, aw, i(, a(, …), and syntax motion (]f, [c, …), the host creates a trie path [y, s, {motion_chord}, CharLiteral] at KeymapLayer::Builtin. This covers the full operator-pending cross-product: ysiw", ysw), ys$}, ys]{[, etc.
The CharLiteral wildcard captures the wrapping character. Because the motion target's args are set to the non-None placeholder Args::Char('\0'), substitute_invocation_char_arg routes the captured char to the operator's Args::Char slot rather than the motion's args.
3.2 Visual mode
| Path | Result |
|---|---|
[S] | Partial (overrides builtin operator:change; arms [S, CharLiteral]) |
[S, CharLiteral] | Invoke(surround_add, Range::Selection, Args::Char(captured)) |
3.3 How the multi-chord resolution works
The trie-based keymap resolves multi-key chords via partial_chord accumulation:
ds{char} (3 keystrokes):
d→ trie returnsBound(Invoke(absorb_operator_delete))→AbsorbOperatorPrefix(delete)→partial_chord = ['d']s→lookup_normal_with_prefix(['d'], 's')→Partal(intermediate node for[d, s, CharLiteral]) →partial_chord = ['d', 's']{char}→lookup_normal_with_prefix(['d', 's'], char)→Bound(Invoke(surround_delete, Args::Char(char)))
cs{char1}{char2} (4 keystrokes):
c→partial_chord = ['c']s→Partal→partial_chord = ['c', 's']{char1}→ wildcard captured,Partal(intermediate for[c, s, CharLiteral, CharLiteral]) →partial_chord = ['c', 's', char1]{char2}→ wildcard captured →Bound(Invoke(surround_change, Args::List([Char(c1), Char(c2)])))
yss{char} (4 keystrokes):
y→partial_chord = ['y']s→Partal→partial_chord = ['y', 's']s→Partal→partial_chord = ['y', 's', 's']{char}→Bound(Invoke(surround_add, Range::CurrentLine, Args::Char(char)))
ys{motion}{char} (3-5 keystrokes, e.g. ysiw"):
y→partial_chord = ['y']s→Partal→partial_chord = ['y', 's']i→Partal→partial_chord = ['y', 's', 'i']w→Partal→partial_chord = ['y', 's', 'i', 'w']"→Bound(Invoke(surround_add, Target::TextObject(inner_word), Args::Char('"')))
The post_motion_char flag on register_operator_bindings appends ChordPattern::CharLiteral to every motion/text-object/find-char binding path. The motion target's args use Args::Char('\0') as a placeholder so substitute_invocation_char_arg routes the captured char to the invocation's args slot (the wrapping char) rather than to the motion target's args.
See crates/lattice-host/src/keymap_normal.rs:1876 (lookup_normal_with_prefix) and crates/lattice-host/src/input.rs:534 (compute_normal_action) for the partial-chord resolution flow.
4. Surround pair detection (find_surround_pair)
The core algorithm shared by ds and cs. Given a cursor position and a target character, find the nearest enclosing pair bounds.
4.1 Pair mapping
Characters map to their matching pairs:
( ↔ ) [ ↔ ] { ↔ } < ↔ >
" ↔ " ' ↔ ' ` ↔ `
The mapping is bidirectional: ( → ) and ) → (. For symmetric pairs (", ', `), the same character opens and closes.
4.2 Algorithm
Scan outward from the cursor in both directions simultaneously:
- Scan backward from the cursor, tracking a stack of encountered closers. When the target's matching opener is found with an empty stack, record the position.
- Scan forward from the cursor, tracking a stack of encountered openers. When the target's matching closer is found with an empty stack, record the position.
If both opener and closer are found, return Some((opener_pos, closer_pos)).
Special cases:
- Cursor between pair: If the cursor is between
(and), scanning backward finds(, scanning forward finds)— return the bounds. - Cursor ON a delimiter (SU.3f): counts as part of the pair that delimiter belongs to, as in vim —
ds"works from either quote. The two scans are half-open around the cursor (backwardbyte < cursor, forwardbyte >= cursor), so a closer under the cursor is already found by the forward scan; an opener under it is skipped by both, and the effective cursor is nudged one character right so it sits just inside its own pair. For a symmetric target the character is its own closer, so which end it is cannot be read off the character and is decided by the count of that character preceding the cursor on the line: even ⇒ this one opens. Without the parity rule,"a" "b"with the caret on the second pair's opening quote resolves to the gap between the pairs — quotes 2 and 4 — which is a genuine enclosing pair and the wrong answer. - Nested pairs: Stacks handle nesting correctly —
((x))with cursor onx→ the innermost()pair is found. - No match: Return
None. The operator produces no effect (vim's "no surrounding" beep-equivalent is a silent no-op). - Symmetric pairs (
",'): Treated as balanced pairs — consecutive same-char encounters alternate open/close semantics.
4.3 open_close_pair(ch) → (open, close)
Maps a user-provided character to its canonical (open, close) form:
( → ( , ) ) → ( , ) [ → [ , ] ] → [ , ]
{ → { , } } → { , } < → < , > > → < , >
" → " , " ' → ' , ' ` → ` , `
Used by surround-add (the wrapping char determines what to insert) and surround-change (the replacement char determines the new wrap).
4.4 pads_inside(ch) → bool — the one place the halves differ (SU.3g)
The mapping above is deliberately many-to-one: ( and ) name the same pair, so either may be typed as a target. For insertion they are not interchangeable, and this is the single exception in the grammar:
ysiw( → ( hello ) ysiw) → (hello)
ysiw[ → [ hello ] ysiw] → [hello]
pads_inside(ch) is true exactly when ch is the opening form of an asymmetric pair. A symmetric wrapper (", ', backtick) is its own closer, so it has no opening form to mean something different and never pads, however it is typed.
The rule is two-sided, because a one-sided one does not survive contact with use — ds( has to undo what ysiw( did, or padding accumulates on every round trip:
- Insertion (
surround-add's wrapper,surround-change's replacement): pad both sides when the opening form was typed. - Removal (
surround-delete's target,surround-change's target): absorb one run of horizontal whitespace immediately inside each delimiter when the opening form was typed.ds)on( hello )deletes the parens alone and leaveshello.
So cs(" turns ( hello ) into "hello", and cs") turns "hello" into (hello).
The two absorbed runs are clamped against each other: in ( ) the forward run would otherwise take all three spaces and the backward run would take the same three again, yielding overlapping edits in one batch.
5. Operator implementations
5.1 operator:surround-delete (ds{char})
Input: OperatorContext { document, args: Args::Char(target) }
Output: Effect::Many([Edits(...), CursorMove(...)])
1. Get cursor position from document
2. Call find_surround_pair(document, cursor, target)
3. If None → return Effect::Nothing (no-op)
4. Compute edits: delete opener byte + closer byte
5. Adjust cursor position: if cursor was after opener, move back by 1
6. Return EditBatch with both deletions + cursor snap
5.2 operator:surround-change (cs{char1}{char2})
Input: OperatorContext { document, args: Args::List([Char(target), Char(replacement)]) }
Output: Effect::Many([Edits(...), CursorMove(...)])
1. Get cursor position from document
2. Call find_surround_pair(document, cursor, target)
3. If None → return Effect::Nothing
4. Call open_close_pair(replacement) → (new_open, new_close)
5. Compute edits: replace opener byte with new_open, closer byte with new_close
6. Adjust cursor as needed
7. Return EditBatch
5.3 operator:surround-add (yss{char} + S{char})
Input: OperatorContext { document, range, args: Args::Char(wrapper) }
Output: Effect::Many([Edits(...), EnterMode(Normal)])
1. Call open_close_pair(wrapper) → (open, close)
2. Get range text from document
3. Wrap: open + range_text + close
4. If range is CurrentLine (yss), wrap the whole line including the newline:
open + line_content + close + "\n"
5. If range is Selection (visual S), wrap the selection:
open + selection_text + close
6. Move cursor after the open char (inside the pair)
7. Return EditBatch
6. Multi-char capture infrastructure
Currently action_from_bound_with_capture (keymap_normal.rs:2007) only forwards captured.first() as Args::Char(c). For surround-change's two-char binding ([c, s, CharLiteral, CharLiteral] → captures two chars), the function must be extended:
Before:
if let Some(&c) = captured.first() {
inv = substitute_invocation_char_arg(inv, c);
}
After:
match captured.len() {
0 => {}
1 => {
inv = substitute_invocation_char_arg(inv, captured[0]);
}
_ => {
inv = inv.with_args(Args::List(
captured.iter().map(|&c| ArgValue::Char(c)).collect(),
));
}
}
This is a one-function change with no API surface impact — captured is already a &[char] slice. The existing single-char callers (f{char}, m{char}, r{char}, q{char}, @{char}, "{char}) are unaffected.
7. Mode lifecycle
7.1 Registration
pub struct SurroundMode;
impl SurroundMode {
pub fn mode_id() -> ModeId { ModeId::new("surround-mode") }
}
impl Mode for SurroundMode {
type Guard = ();
fn id(&self) -> ModeId { Self::mode_id() }
fn kind(&self) -> ModeKind { ModeKind::Minor }
fn activation_policy(&self) -> ActivationPolicy { ActivationPolicy::Global }
fn keymap(&self) -> Keymap { /* §3 bindings */ }
fn on_activate(&self, _ctx: ModeContext) -> LifecycleFuture<'_, ()> {
Box::pin(async { Ok(()) })
}
}
A marker mode: Guard = (), trivial on_activate. No event subscriptions, no service handles, no buffer-local state. The mode's entire surface is its operators (registered at boot) and its keymap (layered at MinorMode(surround-mode)).
7.2 Boot sequence
editor_boot.rs:
1. register_surround_operators(command_registry) → SurroundOperators { delete, change, add }
2. register_surround_mode(mode_registry) → registers SurroundMode
3. translate_mode_keymaps(keymap_handle) → picks up SurroundMode::keymap()
Operators must be registered before the mode's keymap is translated (keymap entries reference OperatorIds by name).
7.3 Activation
ActivationPolicy::Global → auto-activates on every BufferKind::Document. On deactivation (buffer close / mode toggle), the keymap layer is removed automatically. No Guard resources to clean up.
No :surround-mode toggle is registered in v1 (the mode is always-on for document buffers) — a v2 follow-up can add a typed-option gate (surround.enabled).
8. Paramount-goal alignment
UX (higher court): The mode's operators fire on the keystroke thread (grammar path, sync). Pair detection is a bounded scan (cursor-nearest pair, forward and backward) — O(line length), not O(file length). The edit is a single undo batch. Glyph latency stays within the one-frame budget.
Paramount goals: protects #3 (extensible vim modal editing — surround IS vim grammar, first-class operators) and #2 (extensibility — the mode pattern validates that non-trivial grammar contributions land entirely mode-owned). Sacrifices nothing; the one infrastructure change (multi-char capture) is general, tiny, and idle on the hot path.
Heuristic #1 (long-term fit, on merit): Native mode, not WASM. Surround IS vim grammar, and the default grammar stays native. No WASM overhead on the keystroke hot-path; no component packaging for built-in behavior.
Heuristic #2 (paramount, not other editors): The operator model mirrors the design doc's grammar architecture (§5.2) —
CommandKind::OperatorwithOperatorContext— and is motivated by the unified dispatch goal, not by "vim does it this way."
Heuristic #5 (four artefacts): This design fragment, the slice plan, operator-level benchmarks (pair-find latency), and operator tests + edge-case tests ship together.
9. HTML/XML tag surround — deferred
vim-surround supports tag targets: cst<div> (change surrounding tag), dst (delete surrounding tag), ysiwt<div> (wrap in tag). The t target requires scanning for matching <tag> / </tag> pairs, which needs an XML/ HTML-aware parser or heuristic tag-name matching. Deferred to v2.
10. HTML/XML tag surround — deferred
vim-surround supports tag targets: cst<div> (change surrounding tag), dst (delete surrounding tag), ysiwt<div> (wrap in tag). The t target requires scanning for matching <tag> / </tag> pairs, which needs an XML/ HTML-aware parser or heuristic tag-name matching. Deferred to v2.
11. Rejected alternatives
- WASM plugin. Rejected: surround is vim grammar, and the default grammar stays native. WASM overhead on every keystroke violates paramount goal #1 (performance). The same rule that keeps built-in operators native applies here.
- Action handlers instead of operators. Rejected: operators participate in dot-repeat, register-based yank, count multiplication, and blockwise dispatching — action handlers don't. Treating surround as operators means
.ds(re-deletes the last surround,"ads"yanks the deleted surround into registera, and2ds"deletes two levels of double quotes. - Separate crate (
lattice-surround). Rejected for v1: surround is a single file (~300 lines of operator + pair logic) with no crate-level dependencies beyondlattice-grammar. It sits inlattice-mode/src/modes/surround.rsalongside the other foundation modes. If it grows (v2 tag support, motion support), a crate split is the natural graduation path.
12. Slices
See ../operations/slice-plans/surround-mode.md. Five slices implemented:
- SU.1 — Pair detection + surround operators
- SU.2 — Multi-char capture + keymap +
SurroundMode - SU.3 — Boot integration + tests + benchmarks
- SU.4 —
ys{motion}{char}viapost_motion_charflag onregister_operator_bindings - SU.5 —
post_motion_charinOperatorSpec(native + WIT parity)
Deferred to v2: HTML/XML tag targets (cst<div>, dst, ysiwt<div>).