diff --git a/AGENTS.md b/AGENTS.md index 1695c2b..a286c8f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,13 +61,13 @@ src/ vim/ Pure vim engine (thin barrel re-exports the public surface): index.ts (7 lines) Barrel — public surface only. No export *, no internals. types.ts (57 lines) Action union, VimState, Mode, Operator, Pending, Range, KeyEvent, HandlerResult, PromptAccess - text.ts (77 lines) Pure string algorithms: isWhitespace, charKind, endOfWord, currentLineRange, wordRange + text.ts (210 lines) Pure string algorithms: charKind, endOfWord, currentLineRange, wordRange, bracketRange, quoteRange, anyBracketRange, anyQuoteRange tables.ts (35 lines) Keybinding maps: MOTIONS, SELECT_MOTIONS, DELETE_MOTION (engine-internal) - textobject.ts (16 lines) resolveTextObject — object char → inclusive Range seam (iw/aw; PR2 adds pairs) + textobject.ts (36 lines) resolveTextObject — object char → inclusive Range seam (iw/aw, quote/bracket pairs, iq/ib) util.ts (19 lines) State-agnostic primitives: translateKey, PASS, pushN state.ts (76 lines) VimState lifecycle + transitions insert.ts (32 lines) handleInsertKey - normal.ts (378 lines) handleNormalKey (+ file-local finishUndoableChange, isInputEmpty) + normal.ts (378 lines) handleNormalKey (+ file-local finishUndoableChange, applyOperatorRange, isInputEmpty) visual.ts (104 lines) handleVisualKey leader.ts (73 lines) Leader key matching: matchesKeyLike, findMatchingLeader, leaderChar clipboard.ts (19 lines) writeClipboard() — cross-platform (pbcopy/xclip/xsel/wl-copy/clip.exe) @@ -76,13 +76,13 @@ test/ support.ts (33 lines) Shared assertion helpers + ev() fixtures.ts (17 lines) Prompt fixtures: mockPrompt, emptyPrompt vim/ Per-module engine tests mirroring src/vim/: - text.test.ts (216) endOfWord, charKind/isWhitespace/currentLineRange, wordRange units + text.test.ts (385) endOfWord, charKind, currentLineRange, wordRange, bracketRange, quoteRange, any* units state.test.ts (70) createVimState, toggleVimMode util.test.ts (31) translateKey insert.test.ts (92) handleInsertKey - normal.test.ts (762) handleNormalKey branches - visual.test.ts (254) handleVisualKey branches - textobject.test.ts (23) resolveTextObject dispatch seam + normal.test.ts (823) handleNormalKey branches + visual.test.ts (287) handleVisualKey branches + textobject.test.ts (64) resolveTextObject dispatch seam integration.test.ts (418) Full pipeline: one-shot normal, plugin init, undo snapshots, version sync leader.test.ts (125 lines) Unit tests for leader key matching functions ``` @@ -139,10 +139,10 @@ To add a new motion that works with operators: ### Adding a text object -Text objects (`iw`/`aw`, and the quote/bracket pairs coming next) route through one seam, so the handlers never change: +Text objects (`iw`/`aw`, the quote/bracket pairs, and the `iq`/`ib` aliases) route through one seam, so the handlers never change: -1. Add the pure range algorithm to `src/vim/text.ts` — e.g. `wordRange`, and for pairs a `pairRange`. It takes `(text, offset, around)` and returns an inclusive `Range` or `null` when there's nothing to select. -2. Add a `case` to `resolveTextObject` in `src/vim/textobject.ts` mapping the object char to that algorithm. This is the only dispatch point — the "any quote" (`q`) and "any bracket" (`b`) aliases compose over the per-delimiter cases here. +1. Add the pure range algorithm to `src/vim/text.ts` — e.g. `wordRange`, `bracketRange`, `quoteRange`. It takes `(text, offset, around)` (plus the delimiters for pairs) and returns an inclusive `Range` or `null` when there's nothing to select. +2. Add a `case` to `resolveTextObject` in `src/vim/textobject.ts` mapping the object char to that algorithm. This is the only dispatch point — the "any quote" (`q` → `anyQuoteRange`) and "any bracket" (`b` → `anyBracketRange`) aliases compose over the per-delimiter functions, picking the tightest enclosing pair. 3. No handler change needed. `normal.ts` (operator + `i`/`a` → textobject pending → `deleteRange`/`yank`/insert) and `visual.ts` (`i`/`a` → textobject pending → `selectRange`) already send every object char through `resolveTextObject`. 4. Test the range algorithm in `test/vim/text.test.ts` and the dispatch in `test/vim/textobject.test.ts`; add handler branches in `normal.test.ts`/`visual.test.ts` only if the object needs new handling. diff --git a/BACKLOG.md b/BACKLOG.md index 8e4deca..bea04fe 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -26,7 +26,7 @@ Ordered by priority within each category. ## New features -1. **Text objects (`ciw`, `diw`, `yiw`, `ci"`, `di"`, `da(`, etc.).** Feasible now that we have cursor position access. Read `plainText` + `cursorOffset`, compute the object range in pure logic, apply the edit via `setSelection` + `deleteSelectedText` or direct text manipulation. Start with word and quote objects, then add bracket/paren. +1. ~~**Text objects (`ciw`, `diw`, `yiw`, `ci"`, `di"`, `da(`, etc.).**~~ Done. Word objects (`iw`/`aw`), quote and bracket pairs, and the `iq`/`ib` any-type aliases, all via the pure `resolveTextObject` seam. 2. **Visual-line mode (`V`).** The widget's `getLineInfo()` and `setSelection()` make line-wise selection straightforward. Extend the existing visual mode with a `visual-line` variant. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0180f20..8632acb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version ### Added - Word text objects `iw` and `aw`, for use with the `d`, `c`, and `y` operators and in visual mode: `diw`, `ciw`, `daw`, `viw`, and so on ([#57](https://github.com/oribarilan/vimcode/issues/57)). +- Quote and bracket text objects `i"` `i'` `` i` `` `i(` `i{` `i[` `i<` (with `a` variants) for `d`/`c`/`y` and visual mode: `di"`, `ci(`, `vi[`, and so on. `iq`/`ib` match the nearest quote/bracket of any type; `ib` means any bracket, not vim's parens-only `ib`. ### Fixed diff --git a/README.md b/README.md index 2c981fa..4b26bc6 100644 --- a/README.md +++ b/README.md @@ -135,15 +135,19 @@ Counts work on both operator and motion: `2dd` deletes 2 lines, `d3w` deletes 3 ### Text objects -`iw` (inner word) and `aw` (a word) combine with `d`, `c`, `y` and work in visual mode: +Text objects pair with `d`, `c`, `y` and select in visual mode (`viw`, `vi(`, `vib`, …). `i` takes the inside; `a` takes the delimiters too (for words, the trailing whitespace): -| Combo | Action | -|-------|--------| -| `diw` `ciw` `yiw` | Operate on the word under the cursor | -| `daw` `caw` `yaw` | Same, plus the word's trailing whitespace | -| `viw` `vaw` | Select the word (inner / around) | +| Object | Selects | +|--------|---------| +| `iw` `aw` | The word under the cursor | +| `i"` `i'` `` i` `` | Inside the quotes | +| `i(` `i{` `i[` `i<` | Inside the bracket pair (opener or closer) | +| `iq` | The nearest quote of any type (`"` `'` `` ` ``) | +| `ib` | The nearest bracket of any type (`()` `{}` `[]`) | + +So `diw`, `ci"`, `da(`, `yi{`, `vib`, `ciq` all work. `iw` covers the run under the cursor — word, punctuation, or whitespace; `aw` also takes the trailing whitespace (or leading, when there's none). Brackets nest and can span lines; quotes pair within the current line. -`iw` covers the run under the cursor — word, punctuation, or whitespace. `aw` also takes the trailing whitespace, or the leading whitespace when there's none. Quote and bracket objects (`di"`, `ci(`) are coming next. +**`ib` note:** it means "any bracket" — the nearest of `()` `{}` `[]` — not vim's parens-only `ib`. Use `i(` when you specifically want parentheses. ### Insert entries @@ -197,7 +201,6 @@ All normal-mode motions work for extending the selection: `h` `j` `k` `l` `w` `b ## Known gaps - `Ctrl+v` - block visual mode is not supported -- `di"`, `ci(`, etc. (quote and bracket text objects) - not yet implemented; word objects `iw`/`aw` now work - No persistent mode indicator - the toast fades after about a second. A slot-based indicator needs the host's JSX runtime, which doesn't resolve reliably from git-installed plugins ([#3](https://github.com/oribarilan/vimcode/issues/3)). Configurable key bindings are next once the core vim coverage stabilizes. diff --git a/src/vim/text.ts b/src/vim/text.ts index f6ffab9..56ee4eb 100644 --- a/src/vim/text.ts +++ b/src/vim/text.ts @@ -81,3 +81,130 @@ export function wordRange(text: string, offset: number, around: boolean): Range while (leading > 0 && !isLineBreak(text[leading - 1]) && charKind(text[leading - 1]) === "space") leading--; return { start: leading, end }; } + +// Inclusive range for a bracket pair text object (i(/a(, i{/a{, ...). Finds +// the innermost pair enclosing the cursor, depth-aware and across lines. A +// cursor sitting on either delimiter counts as inside that pair. `around` +// includes the delimiters; inner excludes them and returns null for an empty +// pair. Returns null when no pair encloses the cursor. `open` must differ +// from `close` (use quoteRange for symmetric delimiters). +export function bracketRange(text: string, offset: number, open: string, close: string, around: boolean): Range | null { + const len = text.length; + if (len === 0) return null; + const pos = Math.min(Math.max(offset, 0), len - 1); + + let openIdx: number; + let closeIdx: number; + if (text[pos] === open) { + openIdx = pos; + closeIdx = matchForward(text, pos, open, close); + } else if (text[pos] === close) { + closeIdx = pos; + openIdx = matchBackward(text, pos, open, close); + } else { + openIdx = enclosingOpen(text, pos, open, close); + closeIdx = openIdx === -1 ? -1 : matchForward(text, openIdx, open, close); + } + if (openIdx === -1 || closeIdx === -1) return null; + + if (around) return { start: openIdx, end: closeIdx }; + if (closeIdx - openIdx <= 1) return null; // empty pair — nothing inside + return { start: openIdx + 1, end: closeIdx - 1 }; +} + +// Scan left from an interior offset for the nearest open with no matching +// close between it and the cursor (the enclosing pair's opening). +function enclosingOpen(text: string, from: number, open: string, close: string): number { + let depth = 0; + for (let i = from; i >= 0; i--) { + if (text[i] === close) depth++; + else if (text[i] === open) { + if (depth === 0) return i; + depth--; + } + } + return -1; +} + +// Scan right from an opening delimiter for its matching close (depth-aware). +function matchForward(text: string, openIdx: number, open: string, close: string): number { + let depth = 0; + for (let i = openIdx; i < text.length; i++) { + if (text[i] === open) depth++; + else if (text[i] === close && --depth === 0) return i; + } + return -1; +} + +// Scan left from a closing delimiter for its matching open (depth-aware). +function matchBackward(text: string, closeIdx: number, open: string, close: string): number { + let depth = 0; + for (let i = closeIdx; i >= 0; i--) { + if (text[i] === close) depth++; + else if (text[i] === open && --depth === 0) return i; + } + return -1; +} + +// Inclusive range for a quote text object (i"/a", i'/a', ...). Quotes don't +// nest, so they pair left-to-right within the cursor's line (never across a +// newline). Returns the pair enclosing the cursor — a cursor on a quote counts +// as inside — with `around` including the quotes. Inner returns null for an +// empty pair; the function returns null when no pair encloses the cursor. +export function quoteRange(text: string, offset: number, quote: string, around: boolean): Range | null { + const len = text.length; + if (len === 0) return null; + const pos = Math.min(Math.max(offset, 0), len - 1); + const lineStart = text.lastIndexOf("\n", pos - 1) + 1; + const nextNewline = text.indexOf("\n", pos); + const lineEnd = nextNewline === -1 ? len : nextNewline; // exclusive + + const quotes: number[] = []; + for (let i = lineStart; i < lineEnd; i++) { + if (text[i] === quote) quotes.push(i); + } + + for (let p = 0; p + 1 < quotes.length; p += 2) { + const openIdx = quotes[p]; + const closeIdx = quotes[p + 1]; + if (pos < openIdx || pos > closeIdx) continue; + if (around) return { start: openIdx, end: closeIdx }; + if (closeIdx - openIdx <= 1) return null; // empty pair + return { start: openIdx + 1, end: closeIdx - 1 }; + } + return null; +} + +// `iq`/`aq`: the tightest quote pair (of " ' `) enclosing the cursor. +export function anyQuoteRange(text: string, offset: number, around: boolean): Range | null { + let winner: string | null = null; + let bestSpan = Infinity; + for (const quote of ['"', "'", "`"]) { + const span = quoteRange(text, offset, quote, true); + if (span && span.end - span.start < bestSpan) { + bestSpan = span.end - span.start; + winner = quote; + } + } + return winner === null ? null : quoteRange(text, offset, winner, around); +} + +// `ib`/`ab`: the tightest bracket pair (of () {} [], not angle) enclosing the +// cursor. Diverges from vim, where `ib` is parens only — see README. +export function anyBracketRange(text: string, offset: number, around: boolean): Range | null { + const pairs: Array<[string, string]> = [ + ["(", ")"], + ["{", "}"], + ["[", "]"], + ]; + let winner: [string, string] | null = null; + let bestSpan = Infinity; + for (const [open, close] of pairs) { + const span = bracketRange(text, offset, open, close, true); + if (span && span.end - span.start < bestSpan) { + bestSpan = span.end - span.start; + winner = [open, close]; + } + } + return winner === null ? null : bracketRange(text, offset, winner[0], winner[1], around); +} diff --git a/src/vim/textobject.ts b/src/vim/textobject.ts index 97ecfbf..7c18f3f 100644 --- a/src/vim/textobject.ts +++ b/src/vim/textobject.ts @@ -1,15 +1,35 @@ -import { wordRange } from "./text"; +import { anyBracketRange, anyQuoteRange, bracketRange, quoteRange, wordRange } from "./text"; import type { Range } from "./types"; // The single dispatch seam for text objects. Maps an object char (the key // after `i`/`a`) to its inclusive [start, end] range under the cursor, or // null when the char is not a text object or there is nothing to select. -// PR2 extends this with quote and bracket pairs (" ' ` ( ) { } [ ] < > q b) -// without touching the normal/visual handlers. +// `q` is any quote (" ' `) and `b` is any bracket (() {} [], not angle); +// both pick the tightest pair enclosing the cursor. export function resolveTextObject(text: string, offset: number, objectChar: string, around: boolean): Range | null { switch (objectChar) { case "w": return wordRange(text, offset, around); + case '"': + case "'": + case "`": + return quoteRange(text, offset, objectChar, around); + case "q": + return anyQuoteRange(text, offset, around); + case "(": + case ")": + return bracketRange(text, offset, "(", ")", around); + case "{": + case "}": + return bracketRange(text, offset, "{", "}", around); + case "[": + case "]": + return bracketRange(text, offset, "[", "]", around); + case "<": + case ">": + return bracketRange(text, offset, "<", ">", around); + case "b": + return anyBracketRange(text, offset, around); default: return null; } diff --git a/test/vim/normal.test.ts b/test/vim/normal.test.ts index 58ba519..d32c74b 100644 --- a/test/vim/normal.test.ts +++ b/test/vim/normal.test.ts @@ -240,6 +240,67 @@ describe("handleNormalKey — text objects", () => { }); }); +// ── handleNormalKey — pair text objects (end-to-end through the handler) ── + +describe("handleNormalKey — pair text objects", () => { + const prompt = (text: string, offset: number): PromptAccess => ({ + getLine: (n) => text.split("\n")[n] ?? "", + getLineCount: () => text.split("\n").length, + getCursorLine: () => 0, + getCursorOffset: () => offset, + getPlainText: () => text, + }); + + it('di" deletes inside double quotes', () => { + const p = prompt('say "hi"', 6); + handleNormalKey(state, "d", ev("d"), p); + handleNormalKey(state, "i", ev("i"), p); + const r = handleNormalKey(state, '"', ev('"'), p); + expect(deleteRanges(r.actions)).toEqual([{ start: 5, end: 6 }]); + expect(state.mode).toBe("normal"); + }); + + it("ci( deletes inside parens and enters insert", () => { + const p = prompt("(abc)", 2); + handleNormalKey(state, "c", ev("c"), p); + handleNormalKey(state, "i", ev("i"), p); + const r = handleNormalKey(state, "(", ev("("), p); + expect(deleteRanges(r.actions)).toEqual([{ start: 1, end: 3 }]); + expect(state.mode).toBe("insert"); + }); + + it("da( deletes around parens including the delimiters", () => { + const p = prompt("(abc)", 2); + handleNormalKey(state, "d", ev("d"), p); + handleNormalKey(state, "a", ev("a"), p); + const r = handleNormalKey(state, "(", ev("("), p); + expect(deleteRanges(r.actions)).toEqual([{ start: 0, end: 4 }]); + }); + + it("dib deletes inside the nearest bracket of any type", () => { + const p = prompt("[x]", 1); + handleNormalKey(state, "d", ev("d"), p); + handleNormalKey(state, "i", ev("i"), p); + const r = handleNormalKey(state, "b", ev("b"), p); + expect(deleteRanges(r.actions)).toEqual([{ start: 1, end: 1 }]); + }); + + it("diq deletes inside the nearest quote of any type", () => { + const p = prompt("'hi'", 2); + handleNormalKey(state, "d", ev("d"), p); + handleNormalKey(state, "i", ev("i"), p); + const r = handleNormalKey(state, "q", ev("q"), p); + expect(deleteRanges(r.actions)).toEqual([{ start: 1, end: 2 }]); + }); + + it("db still deletes a word backward — b stays a motion, not an object", () => { + const p = prompt("hello world", 6); + handleNormalKey(state, "d", ev("d"), p); + const r = handleNormalKey(state, "b", ev("b"), p); + expect(cmds(r.actions)).toEqual(["input.delete.word.backward"]); + }); +}); + // ── handleNormalKey — operators ───────────────────────────── describe("handleNormalKey — operators", () => { diff --git a/test/vim/text.test.ts b/test/vim/text.test.ts index 54ea6a1..72b0fa9 100644 --- a/test/vim/text.test.ts +++ b/test/vim/text.test.ts @@ -1,6 +1,15 @@ import { describe, expect, it } from "bun:test"; import { endOfWord } from "../../src/vim"; -import { charKind, currentLineRange, isWhitespace, wordRange } from "../../src/vim/text"; +import { + anyBracketRange, + anyQuoteRange, + bracketRange, + charKind, + currentLineRange, + isWhitespace, + quoteRange, + wordRange, +} from "../../src/vim/text"; // ── endOfWord ────────────────────────────────────────────── @@ -223,3 +232,154 @@ describe("wordRange (around)", () => { expect(wordRange("a \r\nb", 1, false)).toEqual({ start: 1, end: 1 }); }); }); + +// ── bracketRange ─────────────────────────────────────────── + +describe("bracketRange", () => { + it("inner spans between the delimiters", () => { + expect(bracketRange("(abc)", 2, "(", ")", false)).toEqual({ start: 1, end: 3 }); + }); + + it("around includes the delimiters", () => { + expect(bracketRange("(abc)", 2, "(", ")", true)).toEqual({ start: 0, end: 4 }); + }); + + it("works with the cursor on the opening delimiter", () => { + expect(bracketRange("(abc)", 0, "(", ")", false)).toEqual({ start: 1, end: 3 }); + }); + + it("works with the cursor on the closing delimiter", () => { + expect(bracketRange("(abc)", 4, "(", ")", false)).toEqual({ start: 1, end: 3 }); + }); + + it("selects the innermost pair when nested", () => { + // ( a ( b ) c ) — cursor on b (index 3) picks the inner pair + expect(bracketRange("(a(b)c)", 3, "(", ")", false)).toEqual({ start: 3, end: 3 }); + }); + + it("selects the outer pair when the cursor is outside the inner one", () => { + expect(bracketRange("(a(b)c)", 1, "(", ")", false)).toEqual({ start: 1, end: 5 }); + }); + + it("spans multiple lines", () => { + // "(\nx\n)" — inner is everything between the parens, newlines included + expect(bracketRange("(\nx\n)", 2, "(", ")", false)).toEqual({ start: 1, end: 3 }); + }); + + it("returns null for an empty pair (nothing inside)", () => { + expect(bracketRange("()", 0, "(", ")", false)).toBeNull(); + }); + + it("around still selects an empty pair", () => { + expect(bracketRange("()", 0, "(", ")", true)).toEqual({ start: 0, end: 1 }); + }); + + it("returns null when there is no enclosing pair", () => { + expect(bracketRange("abc", 1, "(", ")", false)).toBeNull(); + }); + + it("returns null when the cursor is outside the pair", () => { + expect(bracketRange("(a)b", 3, "(", ")", false)).toBeNull(); + }); + + it("handles curly braces too", () => { + expect(bracketRange("x{ y }z", 3, "{", "}", true)).toEqual({ start: 1, end: 5 }); + }); +}); + +// ── quoteRange ───────────────────────────────────────────── + +describe("quoteRange", () => { + it("inner spans between the quotes", () => { + // say "hi" ok — cursor on the i (index 6) + expect(quoteRange('say "hi" ok', 6, '"', false)).toEqual({ start: 5, end: 6 }); + }); + + it("around includes the quotes", () => { + expect(quoteRange('say "hi" ok', 6, '"', true)).toEqual({ start: 4, end: 7 }); + }); + + it("works with the cursor on the opening quote", () => { + expect(quoteRange('say "hi"', 4, '"', false)).toEqual({ start: 5, end: 6 }); + }); + + it("works with the cursor on the closing quote", () => { + expect(quoteRange('say "hi"', 7, '"', false)).toEqual({ start: 5, end: 6 }); + }); + + it("returns null for an empty pair", () => { + expect(quoteRange('a "" b', 2, '"', false)).toBeNull(); + }); + + it("around still selects an empty pair", () => { + expect(quoteRange('a "" b', 2, '"', true)).toEqual({ start: 2, end: 3 }); + }); + + it("pairs quotes left-to-right and picks the enclosing pair", () => { + // "a" "b" — cursor on b (index 5) selects the second pair + expect(quoteRange('"a" "b"', 5, '"', false)).toEqual({ start: 5, end: 5 }); + }); + + it("returns null when the cursor is between two pairs", () => { + expect(quoteRange('"a" "b"', 3, '"', false)).toBeNull(); + }); + + it("does not pair quotes across a newline", () => { + // "x"\n"y" — cursor on y is enclosed only by the second line's pair + expect(quoteRange('"x"\n"y"', 5, '"', false)).toEqual({ start: 5, end: 5 }); + }); + + it("returns null for an unpaired quote", () => { + expect(quoteRange('a "b', 3, '"', false)).toBeNull(); + }); + + it("returns null when there are no quotes", () => { + expect(quoteRange("abc", 1, '"', false)).toBeNull(); + }); +}); + +// ── anyQuoteRange (iq) ───────────────────────────────────── + +describe("anyQuoteRange", () => { + it("matches whichever quote type is present", () => { + expect(anyQuoteRange("say 'hi'", 6, false)).toEqual({ start: 5, end: 6 }); + }); + + it("picks the tightest enclosing quote when types nest", () => { + // "a 'b' c" — cursor on b: the single-quote pair is tighter than the double + expect(anyQuoteRange("\"a 'b' c\"", 4, false)).toEqual({ start: 4, end: 4 }); + }); + + it("supports around", () => { + expect(anyQuoteRange("say 'hi'", 6, true)).toEqual({ start: 4, end: 7 }); + }); + + it("returns null when no quote encloses the cursor", () => { + expect(anyQuoteRange("abc", 1, false)).toBeNull(); + }); +}); + +// ── anyBracketRange (ib) ─────────────────────────────────── + +describe("anyBracketRange", () => { + it("matches whichever bracket type is present", () => { + expect(anyBracketRange("a [x] b", 3, false)).toEqual({ start: 3, end: 3 }); + }); + + it("picks the tightest enclosing bracket when types nest", () => { + // ( [x] ) — cursor on x: the square-bracket pair is tighter than the paren + expect(anyBracketRange("( [x] )", 3, false)).toEqual({ start: 3, end: 3 }); + }); + + it("supports around", () => { + expect(anyBracketRange("( [x] )", 3, true)).toEqual({ start: 2, end: 4 }); + }); + + it("excludes angle brackets (ib is ()/{}/[] only)", () => { + expect(anyBracketRange("", 1, false)).toBeNull(); + }); + + it("returns null when no bracket encloses the cursor", () => { + expect(anyBracketRange("abc", 1, false)).toBeNull(); + }); +}); diff --git a/test/vim/textobject.test.ts b/test/vim/textobject.test.ts index 884880e..a55d4fd 100644 --- a/test/vim/textobject.test.ts +++ b/test/vim/textobject.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from "bun:test"; import { resolveTextObject } from "../../src/vim/textobject"; // resolveTextObject is the single dispatch seam: object char -> range. -// PR1 handles only `w`; unknown chars must return null so handlers no-op. +// Word (`w`), quote (" ' ` and `q`=any), and bracket (( ) { } [ ] < > and +// `b`=any) objects all route through here; unknown chars return null so +// handlers no-op. describe("resolveTextObject", () => { it("resolves `w` to the inner word range", () => { @@ -20,4 +22,43 @@ describe("resolveTextObject", () => { it("returns null when the underlying object has no range", () => { expect(resolveTextObject("", 0, "w", false)).toBeNull(); }); + + it("resolves double quotes", () => { + expect(resolveTextObject('say "hi"', 6, '"', false)).toEqual({ start: 5, end: 6 }); + }); + + it("resolves single quotes", () => { + expect(resolveTextObject("say 'hi'", 6, "'", false)).toEqual({ start: 5, end: 6 }); + }); + + it("resolves backticks", () => { + expect(resolveTextObject("say `hi`", 6, "`", false)).toEqual({ start: 5, end: 6 }); + }); + + it("resolves `q` to the nearest quote of any type", () => { + expect(resolveTextObject("'hi'", 2, "q", false)).toEqual({ start: 1, end: 2 }); + }); + + it("resolves parens from either delimiter", () => { + expect(resolveTextObject("(abc)", 2, "(", false)).toEqual({ start: 1, end: 3 }); + expect(resolveTextObject("(abc)", 2, ")", false)).toEqual({ start: 1, end: 3 }); + }); + + it("resolves braces, square brackets, and angle brackets", () => { + expect(resolveTextObject("{x}", 1, "{", false)).toEqual({ start: 1, end: 1 }); + expect(resolveTextObject("[x]", 1, "[", false)).toEqual({ start: 1, end: 1 }); + expect(resolveTextObject("", 1, "<", false)).toEqual({ start: 1, end: 1 }); + }); + + it("resolves `b` to the nearest bracket of any type", () => { + expect(resolveTextObject("[x]", 1, "b", false)).toEqual({ start: 1, end: 1 }); + }); + + it("`b` excludes angle brackets", () => { + expect(resolveTextObject("", 1, "b", false)).toBeNull(); + }); + + it("passes the around flag through to pairs", () => { + expect(resolveTextObject("(abc)", 2, "(", true)).toEqual({ start: 0, end: 4 }); + }); }); diff --git a/test/vim/visual.test.ts b/test/vim/visual.test.ts index e70de23..1dd820d 100644 --- a/test/vim/visual.test.ts +++ b/test/vim/visual.test.ts @@ -252,3 +252,36 @@ describe("handleVisualKey — text objects", () => { expect(state.pending).toEqual({ kind: "none" }); }); }); + +// ── handleVisualKey — pair text objects ──────────────────── + +describe("handleVisualKey — pair text objects", () => { + const prompt = (text: string, offset: number): PromptAccess => ({ + ...mockPrompt, + getCursorOffset: () => offset, + getPlainText: () => text, + }); + + beforeEach(() => { + state.mode = "visual"; + }); + + it('vi" selects inside double quotes', () => { + const p = prompt('say "hi"', 6); + state.visualAnchor = 6; + handleVisualKey(state, "i", ev("i"), p); + const r = handleVisualKey(state, '"', ev('"'), p); + expect(selectRanges(r.actions)).toEqual([{ start: 5, end: 6 }]); + expect(cursorTos(r.actions)).toEqual([6]); + expect(state.visualAnchor).toBe(5); + }); + + it("vib selects inside the nearest bracket", () => { + const p = prompt("(abc)", 2); + state.visualAnchor = 2; + handleVisualKey(state, "i", ev("i"), p); + const r = handleVisualKey(state, "b", ev("b"), p); + expect(selectRanges(r.actions)).toEqual([{ start: 1, end: 3 }]); + expect(cursorTos(r.actions)).toEqual([3]); + }); +});