From cc49e91df188783e9a91a443f8d0f1aa8a3fb7dc Mon Sep 17 00:00:00 2001 From: ori Date: Wed, 2 Sep 2026 11:40:43 +0300 Subject: [PATCH] feat: add word text objects (iw/aw) for operators and visual mode diw/ciw/yiw/daw/caw/yaw and viw/vaw, routed through a new resolveTextObject seam (src/vim/textobject.ts) so quote/bracket objects can be added later without touching the handlers. - fix #57: after an operator, i/a now start a text object instead of falling through to insert mode - wordRange treats CR as a line boundary (CRLF-safe) - extract applyOperatorRange to share the operator+e / operator+G / text-object apply-to-range logic --- AGENTS.md | 25 +++++++--- CHANGELOG.md | 8 ++++ README.md | 14 +++++- src/vim/normal.ts | 63 ++++++++++++++++++------- src/vim/text.ts | 47 +++++++++++++++++++ src/vim/textobject.ts | 16 +++++++ src/vim/types.ts | 9 +++- src/vim/visual.ts | 22 +++++++++ test/vim/normal.test.ts | 93 +++++++++++++++++++++++++++++++++++++ test/vim/text.test.ts | 91 +++++++++++++++++++++++++++++++++++- test/vim/textobject.test.ts | 23 +++++++++ test/vim/visual.test.ts | 41 ++++++++++++++++ 12 files changed, 426 insertions(+), 26 deletions(-) create mode 100644 src/vim/textobject.ts create mode 100644 test/vim/textobject.test.ts diff --git a/AGENTS.md b/AGENTS.md index 1bbe44d..1695c2b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,14 +60,15 @@ src/ index.ts (408 lines) Plugin entry: intercept registration, action application 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 (50 lines) Action union, VimState, Mode, Operator, KeyEvent, HandlerResult, PromptAccess - text.ts (36 lines) Pure string algorithms: isWhitespace, charKind, endOfWord, currentLineRange + 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 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) util.ts (19 lines) State-agnostic primitives: translateKey, PASS, pushN state.ts (76 lines) VimState lifecycle + transitions insert.ts (32 lines) handleInsertKey - normal.ts (340 lines) handleNormalKey (+ file-local finishUndoableChange, isInputEmpty) - visual.ts (78 lines) handleVisualKey + normal.ts (378 lines) handleNormalKey (+ file-local finishUndoableChange, 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) version.ts (46 lines) Version constant, GitHub update check (cached daily) @@ -75,12 +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 (136) endOfWord + charKind/isWhitespace/currentLineRange units + text.test.ts (216) endOfWord, charKind/isWhitespace/currentLineRange, wordRange units state.test.ts (70) createVimState, toggleVimMode util.test.ts (31) translateKey insert.test.ts (92) handleInsertKey - normal.test.ts (655) handleNormalKey branches - visual.test.ts (195) handleVisualKey branches + normal.test.ts (762) handleNormalKey branches + visual.test.ts (254) handleVisualKey branches + textobject.test.ts (23) 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 ``` @@ -135,6 +137,15 @@ To add a new motion that works with operators: 2. Add the destructive version to `DELETE_MOTION`: `{ "yourkey": "input.delete.whatever" }` 3. If the motion needs special handling with operators (like j/k which delete multiple lines), add an explicit branch in the `state.pending.kind === "operator" && key in MOTIONS` section. +### Adding a text object + +Text objects (`iw`/`aw`, and the quote/bracket pairs coming next) 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. +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. + ### Known limitations - **`setTimeout` dispatch** — commands are deferred to avoid re-entrancy. Multi-command sequences (like `O` = home + newline + up) rely on ordered setTimeout execution, which works in practice but isn't guaranteed by spec. Many of these can now be replaced with direct widget manipulation (e.g., setting `cursorOffset`, calling `insertText`). diff --git a/CHANGELOG.md b/CHANGELOG.md index 321c159..0180f20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version ## [Unreleased] +### 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)). + +### Fixed + +- After an operator, `i`/`a` now start a text object instead of switching to insert mode, so `di` and `ci` wait for the object key ([#57](https://github.com/oribarilan/vimcode/issues/57)). + ## [0.17.1] — 2026-09-02 ### Fixed diff --git a/README.md b/README.md index c1711a6..2c981fa 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,18 @@ When the input is empty, `j`/`k` scroll through prompt history instead of moving Counts work on both operator and motion: `2dd` deletes 2 lines, `d3w` deletes 3 words. +### Text objects + +`iw` (inner word) and `aw` (a word) combine with `d`, `c`, `y` and work in visual mode: + +| 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) | + +`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. + ### Insert entries | Key | Action | @@ -185,7 +197,7 @@ 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 -- `ciw`, `di"`, etc. (text objects) - not yet implemented +- `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/normal.ts b/src/vim/normal.ts index 049f6c7..8d21c48 100644 --- a/src/vim/normal.ts +++ b/src/vim/normal.ts @@ -1,7 +1,8 @@ import { consumeCount, enterInsert, resetPending } from "./state"; import { DELETE_MOTION, MOTIONS, SELECT_MOTIONS } from "./tables"; import { currentLineRange, endOfWord } from "./text"; -import type { Action, HandlerResult, KeyEvent, PromptAccess, VimState } from "./types"; +import { resolveTextObject } from "./textobject"; +import type { Action, HandlerResult, KeyEvent, Operator, PromptAccess, VimState } from "./types"; import { PASS, pushN } from "./util"; export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prompt: PromptAccess): HandlerResult { @@ -57,6 +58,19 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom if (ev.name === "tab") return PASS; + // Pending text object: the object char after d/c/y + i/a (diw, caw, ...). + // Resolves to an inclusive range and applies the operator. Must run before + // the object char is interpreted as a motion (e.g. w). + if (state.pending.kind === "textobject") { + const { op, around } = state.pending; + const range = resolveTextObject(prompt.getPlainText(), prompt.getCursorOffset(), key, around); + if (!range) { + resetPending(state); + return { consume: true, actions: [] }; + } + return applyOperatorRange(state, op, prompt.getPlainText(), range.start, range.end); + } + // Everything below is consumed const actions: Action[] = []; @@ -153,6 +167,14 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom return { consume: true, actions }; } + // Operator + i/a begins a text object (diw, caw, ...). Must precede the + // standalone i/a insert entries below — otherwise `di` falls through and + // enters insert instead of waiting for the object char (#57). + if (state.pending.kind === "operator" && (key === "i" || key === "a")) { + state.pending = { kind: "textobject", op: state.pending.op, around: key === "a" }; + return { consume: true, actions }; + } + if (key === "D") { actions.push({ type: "cmd", cmd: "input.delete.to.line.end" }); resetPending(state); @@ -171,17 +193,7 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom const n = consumeCount(state); const offset = prompt.getCursorOffset(); const target = endOfWord(prompt.getPlainText(), offset, n); - if (op === "y") { - const text = prompt.getPlainText().slice(offset, target + 1); - state.yankRegister = text; - actions.push({ type: "yank", text }); - resetPending(state); - return { consume: true, actions }; - } - actions.push({ type: "deleteRange", start: offset, end: target }); - if (op === "c") enterInsert(state, actions); - else resetPending(state); - return finishUndoableChange(actions); + return applyOperatorRange(state, op, prompt.getPlainText(), offset, target); } // Pending operator + motion @@ -216,10 +228,7 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom consumeCount(state); const offset = prompt.getCursorOffset(); const text = prompt.getPlainText(); - actions.push({ type: "deleteRange", start: offset, end: Math.max(0, text.length - 1) }); - if (op === "c") enterInsert(state, actions); - else resetPending(state); - return finishUndoableChange(actions); + return applyOperatorRange(state, op, text, offset, Math.max(0, text.length - 1)); } const deleteCmd = DELETE_MOTION[key]; @@ -342,6 +351,28 @@ function finishUndoableChange(actions: Action[]): HandlerResult { return { consume: true, actions: [{ type: "saveUndoSnapshot" }, ...actions] }; } +// Apply a d/c/y operator to an inclusive [start, end] buffer range: yank +// copies the slice; delete/change remove it, with change entering insert. +// Shared by operator+e, operator+G, and the text-object dispatch. +function applyOperatorRange( + state: VimState, + op: Operator | undefined, + text: string, + start: number, + end: number, +): HandlerResult { + if (op === "y") { + const yanked = text.slice(start, end + 1); + state.yankRegister = yanked; + resetPending(state); + return { consume: true, actions: [{ type: "yank", text: yanked }] }; + } + const actions: Action[] = [{ type: "deleteRange", start, end }]; + if (op === "c") enterInsert(state, actions); + else resetPending(state); + return finishUndoableChange(actions); +} + function isInputEmpty(prompt: PromptAccess): boolean { return prompt.getLineCount() === 1 && prompt.getLine(0) === ""; } diff --git a/src/vim/text.ts b/src/vim/text.ts index b389d51..f6ffab9 100644 --- a/src/vim/text.ts +++ b/src/vim/text.ts @@ -1,3 +1,5 @@ +import type { Range } from "./types"; + export function endOfWord(text: string, offset: number, count = 1): number { const len = text.length; if (len === 0) return 0; @@ -27,6 +29,12 @@ export function charKind(ch: string): "word" | "punct" | "space" { return "punct"; } +// A word/whitespace run never spans a line break. \r and \n both count so +// runs stay within a line on Windows (CRLF) as well as Unix. +function isLineBreak(ch: string): boolean { + return ch === "\n" || ch === "\r"; +} + export function currentLineRange(text: string, offset: number): { start: number; end: number } { if (text.length === 0) return { start: 0, end: 0 }; const safeOffset = Math.min(Math.max(offset, 0), text.length - 1); @@ -34,3 +42,42 @@ export function currentLineRange(text: string, offset: number): { start: number; const newline = text.indexOf("\n", safeOffset); return { start, end: newline === -1 ? text.length - 1 : newline }; } + +// Inclusive [start, end] offsets for the `iw`/`aw` text object under the +// cursor. A "word" is a run of one charKind (word/punct/space); runs never +// cross a newline. `around` extends past a word/punct run to its trailing +// whitespace (or leading, when there is none), and past a whitespace run to +// the following word. Returns null when there is nothing to select (empty +// text, or the cursor sits on a newline). +export function wordRange(text: string, offset: number, around: boolean): Range | null { + const len = text.length; + if (len === 0) return null; + const pos = Math.min(Math.max(offset, 0), len - 1); + if (isLineBreak(text[pos])) return null; + + const kind = charKind(text[pos]); + let start = pos; + while (start > 0 && !isLineBreak(text[start - 1]) && charKind(text[start - 1]) === kind) start--; + let end = pos; + while (end < len - 1 && !isLineBreak(text[end + 1]) && charKind(text[end + 1]) === kind) end++; + + if (!around) return { start, end }; + + if (kind === "space") { + if (end < len - 1 && !isLineBreak(text[end + 1])) { + const nextKind = charKind(text[end + 1]); + let te = end + 1; + while (te < len - 1 && !isLineBreak(text[te + 1]) && charKind(text[te + 1]) === nextKind) te++; + return { start, end: te }; + } + return { start, end }; + } + + let trailing = end; + while (trailing < len - 1 && !isLineBreak(text[trailing + 1]) && charKind(text[trailing + 1]) === "space") trailing++; + if (trailing > end) return { start, end: trailing }; + + let leading = start; + while (leading > 0 && !isLineBreak(text[leading - 1]) && charKind(text[leading - 1]) === "space") leading--; + return { start: leading, end }; +} diff --git a/src/vim/textobject.ts b/src/vim/textobject.ts new file mode 100644 index 0000000..97ecfbf --- /dev/null +++ b/src/vim/textobject.ts @@ -0,0 +1,16 @@ +import { 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. +export function resolveTextObject(text: string, offset: number, objectChar: string, around: boolean): Range | null { + switch (objectChar) { + case "w": + return wordRange(text, offset, around); + default: + return null; + } +} diff --git a/src/vim/types.ts b/src/vim/types.ts index e5d8098..ca296c1 100644 --- a/src/vim/types.ts +++ b/src/vim/types.ts @@ -1,7 +1,14 @@ export type Mode = "normal" | "insert" | "visual" | "(insert)"; export type Operator = "d" | "c" | "y"; -export type Pending = { kind: "none" } | { kind: "operator"; op: Operator } | { kind: "goto" } | { kind: "replace" }; +export type Range = { start: number; end: number }; + +export type Pending = + | { kind: "none" } + | { kind: "operator"; op: Operator } + | { kind: "goto" } + | { kind: "replace" } + | { kind: "textobject"; op?: Operator; around: boolean }; export type Action = | { type: "cmd"; cmd: string } diff --git a/src/vim/visual.ts b/src/vim/visual.ts index 0f5f815..4ab479d 100644 --- a/src/vim/visual.ts +++ b/src/vim/visual.ts @@ -1,6 +1,7 @@ import { consumeCount, enterInsert, enterNormal, exitVisual } from "./state"; import { SELECT_MOTIONS } from "./tables"; import { endOfWord } from "./text"; +import { resolveTextObject } from "./textobject"; import type { Action, HandlerResult, KeyEvent, PromptAccess, VimState } from "./types"; import { PASS, pushN } from "./util"; @@ -25,6 +26,21 @@ export function handleVisualKey(state: VimState, key: string, ev: KeyEvent, prom // Unknown g-combo or escape — fall through to normal visual handling } + // Pending text object: object char after i/a (viw, vaw, ...). Selects the + // resolved range. Must run before the object char is treated as a selection + // motion (e.g. w, b). + if (state.pending.kind === "textobject") { + const around = state.pending.around; + state.pending = { kind: "none" }; + const range = resolveTextObject(prompt.getPlainText(), prompt.getCursorOffset(), key, around); + if (range) { + state.visualAnchor = range.start; + actions.push({ type: "selectRange", start: range.start, end: range.end }); + actions.push({ type: "cursorTo", offset: range.end }); + } + return { consume: true, actions }; + } + // Count accumulation if (/[1-9]/.test(key) || (key === "0" && state.count > 0)) { state.count = state.count * 10 + parseInt(key, 10); @@ -71,6 +87,12 @@ export function handleVisualKey(state: VimState, key: string, ev: KeyEvent, prom return { consume: true, actions }; } + // i/a begin a text object (viw, vaw, ...); wait for the object char. + if (key === "i" || key === "a") { + state.pending = { kind: "textobject", around: key === "a" }; + return { consume: true, actions }; + } + // g prefix — wait for second keypress if (key === "g") { state.pending = { kind: "goto" }; diff --git a/test/vim/normal.test.ts b/test/vim/normal.test.ts index 965322d..58ba519 100644 --- a/test/vim/normal.test.ts +++ b/test/vim/normal.test.ts @@ -147,6 +147,99 @@ describe("handleNormalKey — e motion", () => { }); }); +// ── handleNormalKey — text objects ───────────────────────── + +describe("handleNormalKey — text objects", () => { + // cursor sits inside "hello" + const wordPrompt: PromptAccess = { + getLine: (n) => ["hello world"][n] ?? "", + getLineCount: () => 1, + getCursorLine: () => 0, + getCursorOffset: () => 2, + getPlainText: () => "hello world", + }; + + it("d then i sets a pending inner text object, does not enter insert (#57)", () => { + handleNormalKey(state, "d", ev("d"), wordPrompt); + const r = handleNormalKey(state, "i", ev("i"), wordPrompt); + expect(r.consume).toBe(true); + expect(r.actions).toEqual([]); + expect(state.pending).toEqual({ kind: "textobject", op: "d", around: false }); + expect(state.mode).toBe("normal"); + }); + + it("d then a sets a pending around text object", () => { + handleNormalKey(state, "d", ev("d"), wordPrompt); + handleNormalKey(state, "a", ev("a"), wordPrompt); + expect(state.pending).toEqual({ kind: "textobject", op: "d", around: true }); + }); + + it("diw deletes the inner word", () => { + handleNormalKey(state, "d", ev("d"), wordPrompt); + handleNormalKey(state, "i", ev("i"), wordPrompt); + const r = handleNormalKey(state, "w", ev("w"), wordPrompt); + expect(deleteRanges(r.actions)).toEqual([{ start: 0, end: 4 }]); + expect(state.mode).toBe("normal"); + expect(saveUndoSnapshots(r.actions)).toHaveLength(1); + }); + + it("ciw deletes the inner word and enters insert", () => { + handleNormalKey(state, "c", ev("c"), wordPrompt); + handleNormalKey(state, "i", ev("i"), wordPrompt); + const r = handleNormalKey(state, "w", ev("w"), wordPrompt); + expect(deleteRanges(r.actions)).toEqual([{ start: 0, end: 4 }]); + expect(state.mode).toBe("insert"); + }); + + it("yiw yanks the inner word", () => { + handleNormalKey(state, "y", ev("y"), wordPrompt); + handleNormalKey(state, "i", ev("i"), wordPrompt); + const r = handleNormalKey(state, "w", ev("w"), wordPrompt); + expect(state.yankRegister).toBe("hello"); + expect(r.actions.some((a) => a.type === "yank" && a.text === "hello")).toBe(true); + expect(state.mode).toBe("normal"); + }); + + it("daw deletes the word and its trailing whitespace", () => { + handleNormalKey(state, "d", ev("d"), wordPrompt); + handleNormalKey(state, "a", ev("a"), wordPrompt); + const r = handleNormalKey(state, "w", ev("w"), wordPrompt); + expect(deleteRanges(r.actions)).toEqual([{ start: 0, end: 5 }]); + expect(state.mode).toBe("normal"); + }); + + it("caw deletes a word and enters insert", () => { + handleNormalKey(state, "c", ev("c"), wordPrompt); + handleNormalKey(state, "a", ev("a"), wordPrompt); + const r = handleNormalKey(state, "w", ev("w"), wordPrompt); + expect(deleteRanges(r.actions)).toEqual([{ start: 0, end: 5 }]); + expect(state.mode).toBe("insert"); + }); + + it("yaw yanks a word with trailing whitespace", () => { + handleNormalKey(state, "y", ev("y"), wordPrompt); + handleNormalKey(state, "a", ev("a"), wordPrompt); + handleNormalKey(state, "w", ev("w"), wordPrompt); + expect(state.yankRegister).toBe("hello "); + expect(state.mode).toBe("normal"); + }); + + it("an unresolved object char cancels the operator without editing", () => { + handleNormalKey(state, "d", ev("d"), wordPrompt); + handleNormalKey(state, "i", ev("i"), wordPrompt); + const r = handleNormalKey(state, "z", ev("z"), wordPrompt); + expect(deleteRanges(r.actions)).toEqual([]); + expect(state.pending).toEqual({ kind: "none" }); + expect(state.mode).toBe("normal"); + }); + + it("standalone i still enters insert when no operator is pending", () => { + const r = handleNormalKey(state, "i", ev("i"), wordPrompt); + expect(state.mode).toBe("insert"); + expect(r.actions.some((a) => a.type === "mode" && a.mode === "insert")).toBe(true); + }); +}); + // ── handleNormalKey — operators ───────────────────────────── describe("handleNormalKey — operators", () => { diff --git a/test/vim/text.test.ts b/test/vim/text.test.ts index 86c17b4..54ea6a1 100644 --- a/test/vim/text.test.ts +++ b/test/vim/text.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; import { endOfWord } from "../../src/vim"; -import { charKind, currentLineRange, isWhitespace } from "../../src/vim/text"; +import { charKind, currentLineRange, isWhitespace, wordRange } from "../../src/vim/text"; // ── endOfWord ────────────────────────────────────────────── @@ -134,3 +134,92 @@ describe("currentLineRange", () => { expect(currentLineRange("hello", 99)).toEqual({ start: 0, end: 4 }); }); }); + +// ── wordRange (inner) ────────────────────────────────────── + +describe("wordRange (inner)", () => { + it("from mid-word, spans the whole word", () => { + expect(wordRange("hello world", 2, false)).toEqual({ start: 0, end: 4 }); + }); + + it("from the first char, spans the whole word", () => { + expect(wordRange("hello world", 0, false)).toEqual({ start: 0, end: 4 }); + }); + + it("from the last char, spans the whole word", () => { + expect(wordRange("hello world", 4, false)).toEqual({ start: 0, end: 4 }); + }); + + it("spans the second word", () => { + expect(wordRange("hello world", 6, false)).toEqual({ start: 6, end: 10 }); + }); + + it("on a single space, selects just that space", () => { + expect(wordRange("hello world", 5, false)).toEqual({ start: 5, end: 5 }); + }); + + it("on whitespace, selects the whole whitespace run", () => { + expect(wordRange("a b", 2, false)).toEqual({ start: 1, end: 3 }); + }); + + it("on punctuation, selects the punctuation char", () => { + expect(wordRange("a.b", 1, false)).toEqual({ start: 1, end: 1 }); + }); + + it("on a punctuation run, selects the whole run", () => { + expect(wordRange("a...b", 2, false)).toEqual({ start: 1, end: 3 }); + }); + + it("selects a single-char buffer", () => { + expect(wordRange("x", 0, false)).toEqual({ start: 0, end: 0 }); + }); + + it("returns null for empty text", () => { + expect(wordRange("", 0, false)).toBeNull(); + }); + + it("does not cross a newline", () => { + expect(wordRange("ab\ncd", 1, false)).toEqual({ start: 0, end: 1 }); + }); + + it("selects the word after a newline", () => { + expect(wordRange("ab\ncd", 3, false)).toEqual({ start: 3, end: 4 }); + }); + + it("returns null when the cursor sits on a newline", () => { + expect(wordRange("ab\ncd", 2, false)).toBeNull(); + }); + + it("clamps an out-of-range offset into the text", () => { + expect(wordRange("hello", 99, false)).toEqual({ start: 0, end: 4 }); + }); +}); + +// ── wordRange (around) ───────────────────────────────────── + +describe("wordRange (around)", () => { + it("includes trailing whitespace", () => { + expect(wordRange("hello world", 2, true)).toEqual({ start: 0, end: 5 }); + }); + + it("includes a whole trailing whitespace run", () => { + expect(wordRange("a b", 0, true)).toEqual({ start: 0, end: 3 }); + }); + + it("includes leading whitespace when there is no trailing whitespace", () => { + expect(wordRange("hello world", 8, true)).toEqual({ start: 5, end: 10 }); + }); + + it("on whitespace, includes the following word", () => { + expect(wordRange("a b", 2, true)).toEqual({ start: 1, end: 4 }); + }); + + it("does not extend an around-word onto a CR (CRLF safety)", () => { + expect(wordRange("word\r\nnext", 0, true)).toEqual({ start: 0, end: 3 }); + }); + + it("stops a whitespace run at a CR (CRLF safety)", () => { + // "a \r\nb": cursor on the space at index 1; the run must not swallow \r + expect(wordRange("a \r\nb", 1, false)).toEqual({ start: 1, end: 1 }); + }); +}); diff --git a/test/vim/textobject.test.ts b/test/vim/textobject.test.ts new file mode 100644 index 0000000..884880e --- /dev/null +++ b/test/vim/textobject.test.ts @@ -0,0 +1,23 @@ +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. + +describe("resolveTextObject", () => { + it("resolves `w` to the inner word range", () => { + expect(resolveTextObject("hello world", 2, "w", false)).toEqual({ start: 0, end: 4 }); + }); + + it("resolves `w` with around to include trailing whitespace", () => { + expect(resolveTextObject("hello world", 2, "w", true)).toEqual({ start: 0, end: 5 }); + }); + + it("returns null for an unknown object char", () => { + expect(resolveTextObject("hello world", 2, "z", false)).toBeNull(); + }); + + it("returns null when the underlying object has no range", () => { + expect(resolveTextObject("", 0, "w", false)).toBeNull(); + }); +}); diff --git a/test/vim/visual.test.ts b/test/vim/visual.test.ts index ba48949..e70de23 100644 --- a/test/vim/visual.test.ts +++ b/test/vim/visual.test.ts @@ -211,3 +211,44 @@ describe("handleVisualKey — arrow keys pass through", () => { }); } }); + +// ── handleVisualKey — text objects ───────────────────────── + +describe("handleVisualKey — text objects", () => { + // cursor sits inside "hello" of "hello world\n..." + const wordPrompt: PromptAccess = { ...mockPrompt, getCursorOffset: () => 2 }; + + beforeEach(() => { + state.mode = "visual"; + state.visualAnchor = 2; + }); + + it("i sets a pending inner text object, no op", () => { + const r = handleVisualKey(state, "i", ev("i"), wordPrompt); + expect(r.consume).toBe(true); + expect(r.actions).toEqual([]); + expect(state.pending).toEqual({ kind: "textobject", around: false }); + }); + + it("viw selects the inner word and anchors to its start", () => { + handleVisualKey(state, "i", ev("i"), wordPrompt); + const r = handleVisualKey(state, "w", ev("w"), wordPrompt); + expect(selectRanges(r.actions)).toEqual([{ start: 0, end: 4 }]); + expect(cursorTos(r.actions)).toEqual([4]); + expect(state.visualAnchor).toBe(0); + }); + + it("vaw selects the word and its trailing whitespace", () => { + handleVisualKey(state, "a", ev("a"), wordPrompt); + const r = handleVisualKey(state, "w", ev("w"), wordPrompt); + expect(selectRanges(r.actions)).toEqual([{ start: 0, end: 5 }]); + expect(cursorTos(r.actions)).toEqual([5]); + }); + + it("an unresolved object char cancels without selecting", () => { + handleVisualKey(state, "i", ev("i"), wordPrompt); + const r = handleVisualKey(state, "z", ev("z"), wordPrompt); + expect(selectRanges(r.actions)).toEqual([]); + expect(state.pending).toEqual({ kind: "none" }); + }); +});