Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
```
Expand Down Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 11 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
127 changes: 127 additions & 0 deletions src/vim/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
26 changes: 23 additions & 3 deletions src/vim/textobject.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Expand Down
61 changes: 61 additions & 0 deletions test/vim/normal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading
Loading