Files
oh-my-pi/packages/coding-agent/src/vim/engine.ts
T
can1357 550efebbcc fix(vim): prevented partial-insert corruption in vim tool execution
- Added rollback handling for pending INSERT-mode changes whenever a non-final kbd sequence leaves insert mode, and updated the resulting VimInputError with guidance for using `insert` and escaping insert transitions.
- Hardened VimTool execution by resetting stale insert state before processing commands and by only applying empty inserts when Vim remains in INSERT mode.
- Adjusted Vim search handling to mimic Vim magic escaping and taught `o`/`O` numeric prefixes to act like `Go`/`GO` line inserts, then updated the expected error message test.
2026-04-13 20:23:43 +02:00

1751 lines
54 KiB
TypeScript

import type { FileDiagnosticsResult } from "../lsp";
import { snapshotEqual, type VimBuffer } from "./buffer";
import { parseExCommand } from "./commands";
import { replayTokens } from "./parser";
import type {
Position,
VimBufferSnapshot,
VimInputMode,
VimKeyToken,
VimLoadedFile,
VimPendingInput,
VimRegister,
VimSearchState,
VimSelection,
VimUndoEntry,
} from "./types";
import { clonePosition, maxPosition, minPosition, toPublicMode, VimInputError as VimError } from "./types";
export interface VimSaveResult {
loaded: VimLoadedFile;
diagnostics?: FileDiagnosticsResult;
}
export interface VimEngineCallbacks {
beforeMutate: (buffer: VimBuffer) => Promise<void>;
loadBuffer: (path: string) => Promise<VimLoadedFile>;
saveBuffer: (buffer: VimBuffer, options?: { force?: boolean }) => Promise<VimSaveResult>;
}
interface PendingChange {
before: VimBufferSnapshot;
tokens: string[];
moveCursorLeftOnEscape: boolean;
inserted: boolean;
}
interface MotionResult {
nextIndex: number;
target: Position;
inclusive?: boolean;
linewise?: boolean;
range?: { start: number; end: number; linewise?: boolean };
}
const WORD_CHAR = /[A-Za-z0-9_]/;
const DEFAULT_VIEWPORT_HEIGHT = 40;
const BRACKET_PAIRS = new Map<string, string>([
["(", ")"],
["[", "]"],
["{", "}"],
["<", ">"],
]);
const CLOSING_BRACKETS = new Map<string, string>(
Array.from(BRACKET_PAIRS.entries()).map(([open, close]) => [close, open]),
);
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function isWhitespace(char: string): boolean {
return /\s/.test(char);
}
function isWordChar(char: string): boolean {
return WORD_CHAR.test(char);
}
function wordCategory(char: string, bigWord: boolean): "space" | "word" | "punct" {
if (char.length === 0 || isWhitespace(char)) {
return "space";
}
if (bigWord) {
return "word";
}
return isWordChar(char) ? "word" : "punct";
}
function decodeReplacement(replacement: string): string {
return replacement.replace(/\\\//g, "/").replace(/\\\\/g, "\\");
}
function literalTextToReplayTokens(text: string): string[] {
const tokens: string[] = [];
for (const char of text) {
if (char === "\n") {
tokens.push("CR");
continue;
}
if (char === "\t") {
tokens.push("Tab");
continue;
}
tokens.push(char);
}
return tokens;
}
// Convert a vim-style search pattern to a JavaScript RegExp.
// In vim's default ("magic") mode, (, ), {, }, |, + are literal unless backslash-escaped.
// In JS regex these are metacharacters. Swap the escaping so bare chars are literal
// and \( etc. become regex groups.
function vimPatternToJsRegex(pattern: string): string {
return pattern.replace(/\\([(){}|+])|([(){}|+])/g, (_match, escaped, bare) => {
if (escaped) return escaped; // \( -> ( (regex group)
return `\\${bare}`; // ( -> \( (literal paren)
});
}
function createSearchRegex(pattern: string, flags = "g"): RegExp {
try {
return new RegExp(vimPatternToJsRegex(pattern), flags);
} catch {
return new RegExp(escapeRegex(pattern), flags);
}
}
function detectIndentUnit(lines: string[]): string {
for (const line of lines) {
if (line.startsWith("\t")) {
return "\t";
}
if (line.startsWith(" ")) {
return " ";
}
}
return "\t";
}
function normalizeRange(start: number, end: number): { start: number; end: number } {
return {
start: Math.min(start, end),
end: Math.max(start, end),
};
}
function selectionFromAnchor(buffer: VimBuffer, anchor: Position, linewise: boolean): VimSelection {
if (linewise) {
const startLine = Math.min(anchor.line, buffer.cursor.line);
const endLine = Math.max(anchor.line, buffer.cursor.line);
return {
kind: "line",
start: { line: startLine + 1, col: 1 },
end: { line: endLine + 1, col: buffer.getLine(endLine).length + 1 },
};
}
const start = minPosition(anchor, buffer.cursor);
const end = maxPosition(anchor, buffer.cursor);
return {
kind: "char",
start: { line: start.line + 1, col: start.col + 1 },
end: { line: end.line + 1, col: end.col + 1 },
};
}
function expandVisualOffsets(
buffer: VimBuffer,
anchor: Position,
linewise: boolean,
): { start: number; end: number; linewise: boolean } {
if (linewise) {
const startLine = Math.min(anchor.line, buffer.cursor.line);
const endLine = Math.max(anchor.line, buffer.cursor.line);
const startOffset = buffer.positionToOffset({ line: startLine, col: 0 });
const endOffset =
endLine >= buffer.lastLineIndex()
? buffer.getText().length
: buffer.positionToOffset({ line: endLine + 1, col: 0 });
return { start: startOffset, end: endOffset, linewise: true };
}
const anchorOffset = buffer.positionToOffset(anchor);
const cursorOffset = buffer.positionToOffset(buffer.cursor);
const { start, end } = normalizeRange(anchorOffset, cursorOffset);
return { start, end: end + 1, linewise: false };
}
function nextWordStart(text: string, offset: number, bigWord: boolean): number {
let index = Math.min(Math.max(offset, 0), text.length);
if (index >= text.length) {
return text.length;
}
const currentCategory = wordCategory(text[index] ?? "", bigWord);
if (currentCategory === "space") {
while (index < text.length && wordCategory(text[index] ?? "", bigWord) === "space") {
index += 1;
}
return index;
}
while (index < text.length && wordCategory(text[index] ?? "", bigWord) === currentCategory) {
index += 1;
}
while (index < text.length && wordCategory(text[index] ?? "", bigWord) === "space") {
index += 1;
}
return index;
}
function previousWordStart(text: string, offset: number, bigWord: boolean): number {
let index = Math.min(Math.max(offset - 1, 0), text.length);
while (index > 0 && wordCategory(text[index] ?? "", bigWord) === "space") {
index -= 1;
}
const category = wordCategory(text[index] ?? "", bigWord);
while (index > 0 && wordCategory(text[index - 1] ?? "", bigWord) === category) {
index -= 1;
}
return index;
}
function endOfWord(text: string, offset: number, bigWord: boolean): number {
let index = Math.min(Math.max(offset, 0), text.length);
while (index < text.length && wordCategory(text[index] ?? "", bigWord) === "space") {
index += 1;
}
const category = wordCategory(text[index] ?? "", bigWord);
while (index < text.length && wordCategory(text[index] ?? "", bigWord) === category) {
index += 1;
}
return Math.max(0, index - 1);
}
function findParagraphStart(lines: string[], line: number): number {
let index = Math.max(0, line - 1);
while (index > 0 && lines[index]!.trim().length > 0) {
index -= 1;
}
while (index > 0 && lines[index - 1]!.trim().length === 0) {
index -= 1;
}
return index;
}
function findParagraphEnd(lines: string[], line: number): number {
let index = Math.min(lines.length - 1, line + 1);
while (index < lines.length - 1 && lines[index]!.trim().length > 0) {
index += 1;
}
while (index < lines.length - 1 && lines[index + 1]!.trim().length === 0) {
index += 1;
}
return index;
}
export class VimEngine {
buffer: VimBuffer;
inputMode: VimInputMode = "normal";
selectionAnchor: Position | null = null;
register: VimRegister = { kind: "char", text: "" };
lastSearch: VimSearchState | null = null;
lastCommand?: string;
statusMessage?: string;
diagnostics?: FileDiagnosticsResult;
viewportStart = 1;
closed = false;
#callbacks: VimEngineCallbacks;
#undoStack: VimUndoEntry[] = [];
#redoStack: VimUndoEntry[] = [];
#pendingInput = "";
#lastChangeTokens: string[] | null = null;
#pendingChange: PendingChange | null = null;
#stepCallback?: () => Promise<void>;
constructor(buffer: VimBuffer, callbacks: VimEngineCallbacks) {
this.buffer = buffer;
this.#callbacks = callbacks;
}
clone(callbacks?: Partial<VimEngineCallbacks>): VimEngine {
const next = new VimEngine(this.buffer.clone(), {
beforeMutate: callbacks?.beforeMutate ?? this.#callbacks.beforeMutate,
loadBuffer: callbacks?.loadBuffer ?? this.#callbacks.loadBuffer,
saveBuffer: callbacks?.saveBuffer ?? this.#callbacks.saveBuffer,
});
next.inputMode = this.inputMode;
next.selectionAnchor = this.selectionAnchor ? clonePosition(this.selectionAnchor) : null;
next.register = { ...this.register };
next.lastSearch = this.lastSearch ? { ...this.lastSearch } : null;
next.lastCommand = this.lastCommand;
next.statusMessage = this.statusMessage;
next.diagnostics = this.diagnostics;
next.viewportStart = this.viewportStart;
next.closed = this.closed;
next.#pendingInput = this.#pendingInput;
next.#lastChangeTokens = this.#lastChangeTokens ? [...this.#lastChangeTokens] : null;
next.#pendingChange = this.#pendingChange
? {
before: {
...this.#pendingChange.before,
lines: [...this.#pendingChange.before.lines],
cursor: clonePosition(this.#pendingChange.before.cursor),
baseFingerprint: this.#pendingChange.before.baseFingerprint
? { ...this.#pendingChange.before.baseFingerprint }
: null,
},
tokens: [...this.#pendingChange.tokens],
moveCursorLeftOnEscape: this.#pendingChange.moveCursorLeftOnEscape,
inserted: this.#pendingChange.inserted,
}
: null;
next.#undoStack = this.#undoStack.map(entry => ({
before: {
...entry.before,
lines: [...entry.before.lines],
cursor: clonePosition(entry.before.cursor),
baseFingerprint: entry.before.baseFingerprint ? { ...entry.before.baseFingerprint } : null,
},
after: {
...entry.after,
lines: [...entry.after.lines],
cursor: clonePosition(entry.after.cursor),
baseFingerprint: entry.after.baseFingerprint ? { ...entry.after.baseFingerprint } : null,
},
}));
next.#redoStack = this.#redoStack.map(entry => ({
before: {
...entry.before,
lines: [...entry.before.lines],
cursor: clonePosition(entry.before.cursor),
baseFingerprint: entry.before.baseFingerprint ? { ...entry.before.baseFingerprint } : null,
},
after: {
...entry.after,
lines: [...entry.after.lines],
cursor: clonePosition(entry.after.cursor),
baseFingerprint: entry.after.baseFingerprint ? { ...entry.after.baseFingerprint } : null,
},
}));
return next;
}
getPublicMode() {
return toPublicMode(this.inputMode);
}
getSelection(): VimSelection | undefined {
if (this.selectionAnchor === null) {
return undefined;
}
return selectionFromAnchor(this.buffer, this.selectionAnchor, this.inputMode === "visual-line");
}
getPendingInput(): VimPendingInput | undefined {
switch (this.inputMode) {
case "insert":
return { kind: "insert", text: "" };
case "command":
case "search-forward":
case "search-backward":
return { kind: this.inputMode, text: this.#pendingInput };
default:
return undefined;
}
}
rollbackPendingInsert(): void {
if (this.#pendingChange) {
this.buffer.restore(this.#pendingChange.before);
this.#pendingChange = null;
}
this.inputMode = "normal";
this.selectionAnchor = null;
this.#pendingInput = "";
}
setCursor(line: number, col: number): void {
this.buffer.setCursor({ line, col });
}
async executeTokens(
tokens: readonly VimKeyToken[],
lastCommand?: string,
onStep?: () => Promise<void>,
): Promise<void> {
const previousStepCallback = this.#stepCallback;
this.#stepCallback = onStep ?? previousStepCallback;
this.lastCommand = lastCommand;
this.statusMessage = undefined;
this.diagnostics = undefined;
try {
for (let index = 0; index < tokens.length; ) {
switch (this.inputMode) {
case "insert":
index = this.#executeInsert(tokens, index);
break;
case "command":
case "search-forward":
case "search-backward":
index = await this.#executePrompt(tokens, index);
break;
case "visual":
case "visual-line":
index = await this.#executeVisual(tokens, index);
break;
default:
index = await this.#executeNormal(tokens, index);
break;
}
if (this.closed) {
break;
}
this.#ensureCursorVisible();
await this.#stepCallback?.();
}
} finally {
this.#stepCallback = previousStepCallback;
}
}
async close(force: boolean): Promise<void> {
if (this.buffer.modified && !force) {
throw new VimError("Unsaved changes; use force to discard");
}
this.closed = true;
this.statusMessage = `Closed ${this.buffer.displayPath}`;
}
#ensureCursorVisible(): void {
const line = this.buffer.cursor.line + 1;
if (line < this.viewportStart) {
this.viewportStart = line;
return;
}
const viewportEnd = this.viewportStart + DEFAULT_VIEWPORT_HEIGHT - 1;
if (line > viewportEnd) {
this.viewportStart = Math.max(1, line - DEFAULT_VIEWPORT_HEIGHT + 1);
}
}
centerViewportOnCursor(size = DEFAULT_VIEWPORT_HEIGHT): void {
const lineCount = Math.max(this.buffer.lineCount(), 1);
const clampedSize = Math.max(1, Math.min(size, lineCount));
const maxStart = Math.max(1, lineCount - clampedSize + 1);
this.viewportStart = Math.max(1, Math.min(this.buffer.cursor.line + 1 - Math.floor(clampedSize / 2), maxStart));
}
#clearSelection(): void {
this.selectionAnchor = null;
if (this.inputMode === "visual" || this.inputMode === "visual-line") {
this.inputMode = "normal";
}
}
async #ensureEditable(): Promise<void> {
await this.#callbacks.beforeMutate(this.buffer);
this.diagnostics = undefined;
this.statusMessage = undefined;
}
#pushUndo(entry: VimUndoEntry, changeTokens?: readonly string[]): void {
if (snapshotEqual(entry.before, entry.after)) {
return;
}
this.#undoStack.push(entry);
this.#redoStack = [];
if (changeTokens && changeTokens.length > 0) {
this.#lastChangeTokens = [...changeTokens];
}
}
#beginPendingChange(prefixTokens: readonly string[], moveCursorLeftOnEscape: boolean): void {
this.#pendingChange = {
before: this.buffer.createSnapshot(),
tokens: [...prefixTokens],
moveCursorLeftOnEscape,
inserted: false,
};
}
#markPendingInserted(): void {
if (this.#pendingChange) {
this.#pendingChange.inserted = true;
}
}
#commitPendingChange(): void {
if (!this.#pendingChange) {
return;
}
const entry: VimUndoEntry = {
before: this.#pendingChange.before,
after: this.buffer.createSnapshot(),
};
this.#pushUndo(entry, this.#pendingChange.tokens);
this.#pendingChange = null;
}
async #applyAtomicChange(tokens: readonly string[], mutator: () => void): Promise<void> {
await this.#ensureEditable();
const before = this.buffer.createSnapshot();
mutator();
this.buffer.modified = true;
this.#pushUndo({ before, after: this.buffer.createSnapshot() }, tokens);
}
async #startInsertChange(
tokens: readonly string[],
mutator?: () => void,
moveCursorLeftOnEscape = true,
): Promise<void> {
await this.#ensureEditable();
this.#beginPendingChange(tokens, moveCursorLeftOnEscape);
mutator?.();
this.buffer.modified = true;
this.inputMode = "insert";
}
async #executePrompt(tokens: readonly VimKeyToken[], index: number): Promise<number> {
const token = tokens[index]!;
if (token.value === "Esc") {
this.#pendingInput = "";
this.inputMode = "normal";
return index + 1;
}
if (token.value === "BS") {
this.#pendingInput = this.#pendingInput.slice(0, -1);
return index + 1;
}
if (token.value !== "CR") {
this.#pendingInput += token.value === "Tab" ? "\t" : token.value;
return index + 1;
}
const input = this.#pendingInput;
this.#pendingInput = "";
const mode = this.inputMode;
this.inputMode = "normal";
if (mode === "command") {
await this.#executeEx(input);
} else {
await this.#runSearch(input, mode === "search-forward" ? 1 : -1, true);
}
return index + 1;
}
#exitInsertMode(): void {
if (this.#pendingChange) {
this.#pendingChange.tokens.push("Esc");
if (this.#pendingChange.moveCursorLeftOnEscape && this.#pendingChange.inserted && this.buffer.cursor.col > 0) {
this.buffer.setCursor({ line: this.buffer.cursor.line, col: this.buffer.cursor.col - 1 });
}
}
this.inputMode = "normal";
this.#commitPendingChange();
}
async applyLiteralInsert(text: string, exitInsertMode: boolean): Promise<void> {
if (this.inputMode !== "insert" || !this.#pendingChange) {
throw new VimError("Insert payload requires INSERT mode.");
}
if (text.length > 0) {
const offset = this.buffer.currentOffset();
this.buffer.replaceOffsets(offset, offset, text, offset + text.length);
this.buffer.modified = true;
if (text.includes("\n")) {
this.buffer.trailingNewline = this.buffer.trailingNewline || text.endsWith("\n");
}
this.#pendingChange.tokens.push(...literalTextToReplayTokens(text));
this.#markPendingInserted();
}
if (exitInsertMode) {
this.#exitInsertMode();
}
}
#executeInsert(tokens: readonly VimKeyToken[], index: number): number {
const token = tokens[index]!;
if (token.value === "Esc") {
this.#exitInsertMode();
return index + 1;
}
if (token.value === "CR") {
const offset = this.buffer.currentOffset();
this.buffer.replaceOffsets(offset, offset, "\n", offset + 1);
this.buffer.modified = true;
this.buffer.trailingNewline = true;
this.#pendingChange?.tokens.push(token.value);
this.#markPendingInserted();
return index + 1;
}
if (token.value === "BS") {
const offset = this.buffer.currentOffset();
if (offset > 0) {
this.buffer.deleteOffsets(offset - 1, offset);
this.buffer.modified = true;
this.#pendingChange?.tokens.push(token.value);
this.#markPendingInserted();
}
return index + 1;
}
if (token.value === "Tab") {
const offset = this.buffer.currentOffset();
this.buffer.replaceOffsets(offset, offset, "\t", offset + 1);
this.buffer.modified = true;
this.#pendingChange?.tokens.push(token.value);
this.#markPendingInserted();
return index + 1;
}
if (token.value === "C-w") {
const offset = this.buffer.currentOffset();
const text = this.buffer.getText();
let start = previousWordStart(text, offset, false);
if (start === offset && start > 0) {
start -= 1;
}
this.buffer.deleteOffsets(start, offset);
this.buffer.modified = true;
this.#pendingChange?.tokens.push(token.value);
this.#markPendingInserted();
return index + 1;
}
const insertText = token.value;
const offset = this.buffer.currentOffset();
this.buffer.replaceOffsets(offset, offset, insertText, offset + insertText.length);
this.buffer.modified = true;
this.#pendingChange?.tokens.push(token.value);
this.#markPendingInserted();
return index + 1;
}
async #executeVisual(tokens: readonly VimKeyToken[], index: number): Promise<number> {
const token = tokens[index]!;
if (token.value === "Esc") {
this.#clearSelection();
return index + 1;
}
if (token.value === "v") {
if (this.inputMode === "visual") {
this.#clearSelection();
}
return index + 1;
}
if (token.value === "V") {
this.inputMode = this.inputMode === "visual-line" ? "visual" : "visual-line";
return index + 1;
}
const { count, hasCount, nextIndex } = this.#readCount(tokens, index);
const opToken = tokens[nextIndex];
if (!opToken) {
return nextIndex;
}
switch (opToken.value) {
case "d":
case "y":
case "c":
case ">":
case "<":
case "~": {
const visual = expandVisualOffsets(
this.buffer,
this.selectionAnchor ?? this.buffer.cursor,
this.inputMode === "visual-line",
);
const consumeExtraIndent =
(opToken.value === ">" || opToken.value === "<") && tokens[nextIndex + 1]?.value === opToken.value;
const visualTokens = consumeExtraIndent ? [opToken.value, opToken.value] : [opToken.value];
await this.#applyVisualOperator(opToken.value, visual, count, visualTokens);
return nextIndex + visualTokens.length;
}
case "r": {
const replacement = tokens[nextIndex + 1];
if (!replacement || replacement.value.length !== 1) {
throw new VimError("Visual replace requires a literal character", opToken);
}
const visual = expandVisualOffsets(
this.buffer,
this.selectionAnchor ?? this.buffer.cursor,
this.inputMode === "visual-line",
);
await this.#applyAtomicChange(["r", replacement.value], () => {
const original = this.buffer.getText().slice(visual.start, visual.end);
let replaced = "";
for (const char of original) {
replaced += char === "\n" ? "\n" : replacement.value;
}
this.buffer.replaceOffsets(visual.start, visual.end, replaced, visual.start);
});
this.#clearSelection();
return nextIndex + 2;
}
default:
break;
}
const motion = this.#resolveMotion(tokens, nextIndex, count, hasCount);
this.buffer.setCursor(motion.target);
return motion.nextIndex;
}
async #applyVisualOperator(
operator: string,
visual: { start: number; end: number; linewise: boolean },
count: number,
tokens: readonly string[],
): Promise<void> {
switch (operator) {
case "y": {
this.register = {
kind: visual.linewise ? "line" : "char",
text: this.buffer.getText().slice(visual.start, visual.end),
};
this.#clearSelection();
this.statusMessage = `Yanked ${count} selection${count === 1 ? "" : "s"}`;
return;
}
case "d": {
await this.#applyAtomicChange(tokens, () => {
this.register = {
kind: visual.linewise ? "line" : "char",
text: this.buffer.getText().slice(visual.start, visual.end),
};
this.buffer.deleteOffsets(visual.start, visual.end);
});
this.#clearSelection();
return;
}
case "c": {
await this.#startInsertChange(tokens, () => {
this.register = {
kind: visual.linewise ? "line" : "char",
text: this.buffer.getText().slice(visual.start, visual.end),
};
this.buffer.deleteOffsets(visual.start, visual.end);
});
this.#clearSelection();
return;
}
case ">":
case "<": {
const startLine = this.buffer.offsetToPosition(visual.start).line;
const endLine = this.buffer.offsetToPosition(Math.max(visual.start, visual.end - 1)).line;
await this.#applyAtomicChange(tokens, () => {
this.buffer.indentLines(
startLine,
endLine,
detectIndentUnit(this.buffer.lines),
operator === ">" ? 1 : -1,
);
});
this.#clearSelection();
return;
}
case "~": {
await this.#applyAtomicChange(tokens, () => {
const original = this.buffer.getText().slice(visual.start, visual.end);
let replaced = "";
for (const char of original) {
if (char >= "a" && char <= "z") {
replaced += char.toUpperCase();
} else if (char >= "A" && char <= "Z") {
replaced += char.toLowerCase();
} else {
replaced += char;
}
}
this.buffer.replaceOffsets(visual.start, visual.end, replaced, visual.start);
});
this.#clearSelection();
return;
}
default:
throw new VimError(`Unsupported visual operator: ${operator}`);
}
}
async #executeNormal(tokens: readonly VimKeyToken[], index: number): Promise<number> {
const { count, hasCount, nextIndex } = this.#readCount(tokens, index);
const token = tokens[nextIndex];
if (!token) {
return nextIndex;
}
switch (token.value) {
case "h":
this.buffer.setCursor({ line: this.buffer.cursor.line, col: this.buffer.cursor.col - count });
return nextIndex + 1;
case "j":
this.buffer.setCursor({ line: this.buffer.cursor.line + count, col: this.buffer.cursor.col });
return nextIndex + 1;
case "k":
this.buffer.setCursor({ line: this.buffer.cursor.line - count, col: this.buffer.cursor.col });
return nextIndex + 1;
case "l":
this.buffer.setCursor({ line: this.buffer.cursor.line, col: this.buffer.cursor.col + count });
return nextIndex + 1;
case "w":
case "W":
case "b":
case "B":
case "e":
case "E":
case "0":
case "$":
case "^":
case "g":
case "G":
case "f":
case "F":
case "t":
case "T":
case "{":
case "}":
case "%":
case "H":
case "M":
case "L": {
const motion = this.#resolveMotion(tokens, nextIndex, count, hasCount);
this.buffer.setCursor(motion.target);
return motion.nextIndex;
}
case "n":
await this.#repeatSearch(this.lastSearch?.direction ?? 1, count);
return nextIndex + 1;
case "N":
await this.#repeatSearch(((this.lastSearch?.direction ?? 1) * -1) as 1 | -1, count);
return nextIndex + 1;
case "/":
this.inputMode = "search-forward";
this.#pendingInput = "";
return nextIndex + 1;
case "?":
this.inputMode = "search-backward";
this.#pendingInput = "";
return nextIndex + 1;
case ":":
this.inputMode = "command";
this.#pendingInput = "";
return nextIndex + 1;
case "v":
this.inputMode = "visual";
this.selectionAnchor = clonePosition(this.buffer.cursor);
return nextIndex + 1;
case "V":
this.inputMode = "visual-line";
this.selectionAnchor = clonePosition(this.buffer.cursor);
return nextIndex + 1;
case "i":
await this.#startInsertChange(["i"]);
return nextIndex + 1;
case "a":
this.buffer.setCursor({ line: this.buffer.cursor.line, col: this.buffer.cursor.col + 1 });
await this.#startInsertChange(["a"]);
return nextIndex + 1;
case "I":
this.buffer.setCursor({
line: this.buffer.cursor.line,
col: this.buffer.firstNonBlank(this.buffer.cursor.line),
});
await this.#startInsertChange(["I"]);
return nextIndex + 1;
case "A":
this.buffer.setCursor({
line: this.buffer.cursor.line,
col: this.buffer.getLine(this.buffer.cursor.line).length,
});
await this.#startInsertChange(["A"]);
return nextIndex + 1;
case "o":
// When count > 1 (e.g. `13o`), interpret as `13Go` — go to line N then open below.
// Models confuse `No` with `NGo`; bare `o` with a high count is almost never intended.
if (hasCount) {
this.buffer.setCursor({ line: Math.min(count, this.buffer.lineCount()) - 1, col: 0 });
}
await this.#startInsertChange(["o"], () => {
const line = this.buffer.cursor.line + 1;
this.buffer.insertLines(line, [""]);
});
return nextIndex + 1;
case "O":
if (hasCount) {
this.buffer.setCursor({ line: Math.min(count, this.buffer.lineCount()) - 1, col: 0 });
}
await this.#startInsertChange(["O"], () => {
const line = this.buffer.cursor.line;
this.buffer.insertLines(line, [""]);
});
return nextIndex + 1;
case "s":
await this.#startInsertChange(["s"], () => {
const start = this.buffer.currentOffset();
this.register = {
kind: "char",
text: this.buffer.deleteOffsets(start, Math.min(this.buffer.getText().length, start + count)),
};
});
return nextIndex + 1;
case "S":
await this.#changeWholeLines(count, ["S"]);
return nextIndex + 1;
case "x":
await this.#applyAtomicChange(["x"], () => {
const start = this.buffer.currentOffset();
this.register = {
kind: "char",
text: this.buffer.deleteOffsets(start, Math.min(this.buffer.getText().length, start + count)),
};
});
return nextIndex + 1;
case "X":
await this.#applyAtomicChange(["X"], () => {
const end = this.buffer.currentOffset();
const start = Math.max(0, end - count);
this.register = { kind: "char", text: this.buffer.deleteOffsets(start, end) };
});
return nextIndex + 1;
case "r": {
const replacement = tokens[nextIndex + 1];
if (!replacement || replacement.value.length !== 1) {
throw new VimError("r requires a replacement character", token);
}
await this.#applyAtomicChange(["r", replacement.value], () => {
const start = this.buffer.currentOffset();
this.buffer.replaceOffsets(
start,
Math.min(this.buffer.getText().length, start + count),
replacement.value.repeat(count),
start,
);
});
return nextIndex + 2;
}
case "~":
await this.#applyAtomicChange(["~"], () => {
const start = this.buffer.currentOffset();
const end = Math.min(this.buffer.getText().length, start + count);
const text = this.buffer.getText().slice(start, end);
let toggled = "";
for (const char of text) {
if (char >= "a" && char <= "z") {
toggled += char.toUpperCase();
} else if (char >= "A" && char <= "Z") {
toggled += char.toLowerCase();
} else {
toggled += char;
}
}
this.buffer.replaceOffsets(start, end, toggled, end);
});
return nextIndex + 1;
case "J":
await this.#applyAtomicChange(["J"], () => {
this.buffer.joinLines(this.buffer.cursor.line, count);
});
return nextIndex + 1;
case "p":
case "P":
await this.#applyAtomicChange([token.value], () => {
this.#paste(token.value === "p", count);
});
return nextIndex + 1;
case "u":
await this.#undo(count);
return nextIndex + 1;
case "C-r":
await this.#redo(count);
return nextIndex + 1;
case ".":
await this.#repeatLastChange(count, token);
return nextIndex + 1;
case "d":
case "c":
case "y":
case ">":
case "<":
return this.#executeOperator(tokens, nextIndex, count, hasCount, token.value);
case "D":
await this.#applyAtomicChange(["D"], () => {
const start = this.buffer.currentOffset();
const line = this.buffer.getLine(this.buffer.cursor.line);
const end = start + (line.length - this.buffer.cursor.col);
this.register = { kind: "char", text: this.buffer.deleteOffsets(start, end) };
});
return nextIndex + 1;
case "C":
await this.#startInsertChange(["C"], () => {
const start = this.buffer.currentOffset();
const line = this.buffer.getLine(this.buffer.cursor.line);
const end = start + (line.length - this.buffer.cursor.col);
this.register = { kind: "char", text: this.buffer.deleteOffsets(start, end) };
});
return nextIndex + 1;
case "z": {
const zTarget = tokens[nextIndex + 1];
if (!zTarget) {
throw new VimError("z requires a second key", token);
}
if (zTarget.value === "z") {
this.viewportStart = Math.max(1, this.buffer.cursor.line + 1 - 20);
} else if (zTarget.value === "t") {
this.viewportStart = this.buffer.cursor.line + 1;
} else if (zTarget.value === "b") {
this.viewportStart = Math.max(1, this.buffer.cursor.line + 1 - 39);
} else {
throw new VimError(`Unsupported z command: z${zTarget.value}`, zTarget);
}
return nextIndex + 2;
}
case "C-d":
this.buffer.setCursor({
line: this.buffer.cursor.line + Math.max(1, Math.floor(40 / 2) * count),
col: this.buffer.cursor.col,
});
return nextIndex + 1;
case "C-u":
this.buffer.setCursor({
line: this.buffer.cursor.line - Math.max(1, Math.floor(40 / 2) * count),
col: this.buffer.cursor.col,
});
return nextIndex + 1;
default:
throw new VimError(`Unsupported command: ${token.value}`, token);
}
}
async #repeatLastChange(count: number, token: VimKeyToken): Promise<void> {
if (!this.#lastChangeTokens || this.#lastChangeTokens.length === 0) {
throw new VimError("No previous change to repeat", token);
}
for (let index = 0; index < count; index += 1) {
await this.executeTokens(replayTokens(this.#lastChangeTokens), ".");
}
}
async #undo(count: number): Promise<void> {
await this.#ensureEditable();
let applied = 0;
for (let index = 0; index < count; index += 1) {
const entry = this.#undoStack.pop();
if (!entry) {
break;
}
this.#redoStack.push(entry);
this.buffer.restore(entry.before);
applied += 1;
}
this.inputMode = "normal";
this.selectionAnchor = null;
this.#pendingChange = null;
this.statusMessage = `Undid ${applied} change${applied === 1 ? "" : "s"}`;
}
async #redo(count: number): Promise<void> {
await this.#ensureEditable();
let applied = 0;
for (let index = 0; index < count; index += 1) {
const entry = this.#redoStack.pop();
if (!entry) {
break;
}
this.#undoStack.push(entry);
this.buffer.restore(entry.after);
applied += 1;
}
this.inputMode = "normal";
this.selectionAnchor = null;
this.#pendingChange = null;
this.statusMessage = `Redid ${applied} change${applied === 1 ? "" : "s"}`;
}
async #executeOperator(
tokens: readonly VimKeyToken[],
operatorIndex: number,
operatorCount: number,
hasOperatorCount: boolean,
operator: string,
): Promise<number> {
const { count: motionCount, hasCount: hasMotionCount, nextIndex } = this.#readCount(tokens, operatorIndex + 1);
const token = tokens[nextIndex];
if (!token) {
throw new VimError(`Operator ${operator} requires a motion`, tokens[operatorIndex]);
}
const hasAnyCount = hasOperatorCount || hasMotionCount;
const effectiveCount = hasMotionCount ? operatorCount * motionCount : operatorCount;
if (token.value === operator) {
if (operator === "d") {
await this.#applyAtomicChange([operator, operator], () => {
const start = this.buffer.cursor.line;
const removed = this.buffer.deleteLines(start, start + Math.max(1, effectiveCount) - 1);
this.register = { kind: "line", text: removed.join("\n") };
});
return nextIndex + 1;
}
if (operator === "y") {
const start = this.buffer.cursor.line;
const end = this.buffer.clampLine(start + Math.max(1, effectiveCount) - 1);
this.register = { kind: "line", text: this.buffer.lines.slice(start, end + 1).join("\n") };
this.statusMessage = `Yanked ${end - start + 1} line${end === start ? "" : "s"}`;
return nextIndex + 1;
}
if (operator === "c") {
await this.#changeWholeLines(Math.max(1, effectiveCount), [operator, operator]);
return nextIndex + 1;
}
if (operator === ">" || operator === "<") {
await this.#applyAtomicChange([operator, operator], () => {
this.buffer.indentLines(
this.buffer.cursor.line,
this.buffer.cursor.line + Math.max(1, effectiveCount) - 1,
detectIndentUnit(this.buffer.lines),
operator === ">" ? 1 : -1,
);
});
return nextIndex + 1;
}
}
if (token.value === "i" || token.value === "a") {
const object = tokens[nextIndex + 1];
if (!object) {
throw new VimError(`Missing text object after ${operator}${token.value}`, token);
}
const textObject = this.#resolveTextObject(token.value === "i", object.value, object);
await this.#applyOperatorToMotion(
operator,
{ nextIndex: nextIndex + 2, target: this.buffer.cursor, range: textObject },
[operator, token.value, object.value],
);
return nextIndex + 2;
}
// In vim, `cw` and `cW` act like `ce` and `cE` (don't include trailing whitespace)
const motionToken = tokens[nextIndex];
let motion: MotionResult;
if (operator === "c" && motionToken && (motionToken.value === "w" || motionToken.value === "W")) {
const eMotionValue = motionToken.value === "w" ? "e" : "E";
const syntheticTokens: readonly VimKeyToken[] = [
...tokens.slice(0, nextIndex),
{ ...motionToken, value: eMotionValue },
...tokens.slice(nextIndex + 1),
];
motion = this.#resolveMotion(syntheticTokens, nextIndex, effectiveCount, hasAnyCount);
} else {
motion = this.#resolveMotion(tokens, nextIndex, effectiveCount, hasAnyCount);
}
await this.#applyOperatorToMotion(
operator,
motion,
tokens.slice(operatorIndex, motion.nextIndex).map(tokenEntry => tokenEntry.value),
);
return motion.nextIndex;
}
async #applyOperatorToMotion(operator: string, motion: MotionResult, tokens: readonly string[]): Promise<void> {
if (operator === "y") {
const range = this.#resolveMotionRange(motion);
this.register = {
kind: range.linewise ? "line" : "char",
text: this.buffer.getText().slice(range.start, range.end),
};
this.statusMessage = `Yanked ${range.linewise ? "line" : "selection"}`;
return;
}
if (operator === ">" || operator === "<") {
const range = this.#resolveMotionRange(motion);
const startLine = this.buffer.offsetToPosition(range.start).line;
const endLine = this.buffer.offsetToPosition(Math.max(range.start, range.end - 1)).line;
await this.#applyAtomicChange(tokens, () => {
this.buffer.indentLines(startLine, endLine, detectIndentUnit(this.buffer.lines), operator === ">" ? 1 : -1);
});
return;
}
if (operator === "d") {
const range = this.#resolveMotionRange(motion);
await this.#applyAtomicChange(tokens, () => {
this.register = {
kind: range.linewise ? "line" : "char",
text: this.buffer.getText().slice(range.start, range.end),
};
this.buffer.deleteOffsets(range.start, range.end);
});
return;
}
if (operator === "c") {
const range = this.#resolveMotionRange(motion);
await this.#startInsertChange(tokens, () => {
this.register = {
kind: range.linewise ? "line" : "char",
text: this.buffer.getText().slice(range.start, range.end),
};
this.buffer.deleteOffsets(range.start, range.end);
});
return;
}
}
async #changeWholeLines(count: number, tokens: readonly string[]): Promise<void> {
await this.#startInsertChange(tokens, () => {
const start = this.buffer.cursor.line;
const end = this.buffer.clampLine(start + count - 1);
const removed = this.buffer.lines.slice(start, end + 1);
this.register = { kind: "line", text: removed.join("\n") };
this.buffer.lines.splice(start, end - start + 1, "");
if (this.buffer.lines.length === 0) {
this.buffer.lines = [""];
}
this.buffer.setCursor({ line: Math.min(start, this.buffer.lastLineIndex()), col: 0 });
});
}
#resolveMotionRange(motion: MotionResult): { start: number; end: number; linewise: boolean } {
if (motion.range) {
return {
start: motion.range.start,
end: motion.range.end,
linewise: motion.range.linewise ?? false,
};
}
if (motion.linewise) {
const startLine = Math.min(this.buffer.cursor.line, motion.target.line);
const endLine = Math.max(this.buffer.cursor.line, motion.target.line);
const start = this.buffer.positionToOffset({ line: startLine, col: 0 });
const end =
endLine >= this.buffer.lastLineIndex()
? this.buffer.getText().length
: this.buffer.positionToOffset({ line: endLine + 1, col: 0 });
return { start, end, linewise: true };
}
const from = this.buffer.positionToOffset(this.buffer.cursor);
const to = this.buffer.positionToOffset(motion.target);
const normalized = normalizeRange(from, to);
return {
start: normalized.start,
end: normalized.end + (motion.inclusive === false ? 0 : 1),
linewise: false,
};
}
#resolveMotion(tokens: readonly VimKeyToken[], index: number, count: number, hasCount = true): MotionResult {
const token = tokens[index];
if (!token) {
throw new VimError("Missing motion");
}
const text = this.buffer.getText();
switch (token.value) {
case "h":
return {
nextIndex: index + 1,
target: { line: this.buffer.cursor.line, col: this.buffer.cursor.col - count },
};
case "j":
return {
nextIndex: index + 1,
target: { line: this.buffer.cursor.line + count, col: this.buffer.cursor.col },
linewise: true,
};
case "k":
return {
nextIndex: index + 1,
target: { line: this.buffer.cursor.line - count, col: this.buffer.cursor.col },
linewise: true,
};
case "l":
return {
nextIndex: index + 1,
target: { line: this.buffer.cursor.line, col: this.buffer.cursor.col + count },
};
case "w":
case "W": {
let offset = this.buffer.currentOffset();
for (let step = 0; step < count; step += 1) {
offset = nextWordStart(text, step === 0 ? offset + 1 : offset, token.value === "W");
}
return { nextIndex: index + 1, target: this.buffer.offsetToPosition(offset), inclusive: false };
}
case "b":
case "B": {
let offset = this.buffer.currentOffset();
for (let step = 0; step < count; step += 1) {
offset = previousWordStart(text, offset, token.value === "B");
}
return { nextIndex: index + 1, target: this.buffer.offsetToPosition(offset) };
}
case "e":
case "E": {
let offset = this.buffer.currentOffset();
for (let step = 0; step < count; step += 1) {
offset = endOfWord(text, step === 0 ? offset : offset + 1, token.value === "E");
}
return { nextIndex: index + 1, target: this.buffer.offsetToPosition(offset) };
}
case "0":
return { nextIndex: index + 1, target: { line: this.buffer.cursor.line, col: 0 } };
case "^":
return {
nextIndex: index + 1,
target: { line: this.buffer.cursor.line, col: this.buffer.firstNonBlank(this.buffer.cursor.line) },
};
case "$":
return {
nextIndex: index + 1,
target: {
line: this.buffer.cursor.line,
col: Math.max(0, this.buffer.getLine(this.buffer.cursor.line).length - 1),
},
};
case "g": {
const next = tokens[index + 1];
if (!next || next.value !== "g") {
throw new VimError("Unsupported g motion", token);
}
return {
nextIndex: index + 2,
target: { line: hasCount ? Math.max(0, count - 1) : 0, col: 0 },
linewise: true,
};
}
case "G":
return {
nextIndex: index + 1,
target: { line: hasCount ? count - 1 : this.buffer.lastLineIndex(), col: 0 },
linewise: true,
};
case "f":
case "F":
case "t":
case "T": {
const searchToken = tokens[index + 1];
if (!searchToken || searchToken.value.length !== 1) {
throw new VimError(`${token.value} requires a literal character`, token);
}
const line = this.buffer.getLine(this.buffer.cursor.line);
const cursorCol = this.buffer.cursor.col;
let matchIndex = -1;
if (token.value === "f" || token.value === "t") {
let start = cursorCol + 1;
for (let step = 0; step < count; step += 1) {
matchIndex = line.indexOf(searchToken.value, start);
if (matchIndex === -1) break;
start = matchIndex + 1;
}
if (matchIndex === -1) {
throw new VimError(`Character not found: ${searchToken.value}`, searchToken);
}
if (token.value === "t") {
matchIndex -= 1;
}
} else {
let start = Math.max(0, cursorCol - 1);
for (let step = 0; step < count; step += 1) {
matchIndex = line.lastIndexOf(searchToken.value, start);
if (matchIndex === -1) break;
start = matchIndex - 1;
}
if (matchIndex === -1) {
throw new VimError(`Character not found: ${searchToken.value}`, searchToken);
}
if (token.value === "T") {
matchIndex += 1;
}
}
return {
nextIndex: index + 2,
target: { line: this.buffer.cursor.line, col: Math.max(0, matchIndex) },
};
}
case "{":
return {
nextIndex: index + 1,
target: { line: findParagraphStart(this.buffer.lines, this.buffer.cursor.line), col: 0 },
linewise: true,
};
case "}":
return {
nextIndex: index + 1,
target: { line: findParagraphEnd(this.buffer.lines, this.buffer.cursor.line), col: 0 },
linewise: true,
};
case "%": {
const match = this.#findMatchingBracket();
return { nextIndex: index + 1, target: match };
}
case "H":
return {
nextIndex: index + 1,
target: { line: Math.max(0, this.viewportStart - 1), col: 0 },
linewise: true,
};
case "M":
return {
nextIndex: index + 1,
target: { line: Math.max(0, this.viewportStart - 1 + 20), col: 0 },
linewise: true,
};
case "L":
return {
nextIndex: index + 1,
target: { line: Math.max(0, this.viewportStart - 1 + 39), col: 0 },
linewise: true,
};
default:
throw new VimError(`Unsupported motion: ${token.value}`, token);
}
}
#resolveTextObject(
inner: boolean,
objectToken: string,
sourceToken: VimKeyToken,
): { start: number; end: number; linewise?: boolean } {
if (objectToken === "w" || objectToken === "W") {
return this.#resolveWordTextObject(inner, objectToken === "W");
}
if (objectToken === '"' || objectToken === "'" || objectToken === "`") {
return this.#resolveQuoteTextObject(inner, objectToken, sourceToken);
}
const normalized =
objectToken === ")"
? "("
: objectToken === "}"
? "{"
: objectToken === "]"
? "["
: objectToken === ">"
? "<"
: objectToken;
if (!BRACKET_PAIRS.has(normalized)) {
throw new VimError(`Unsupported text object: ${objectToken}`, sourceToken);
}
return this.#resolveBracketTextObject(inner, normalized, sourceToken);
}
#resolveWordTextObject(inner: boolean, bigWord: boolean): { start: number; end: number } {
const text = this.buffer.getText();
const cursor = this.buffer.currentOffset();
let start = cursor;
if (wordCategory(text[start] ?? "", bigWord) === "space") {
start = nextWordStart(text, start, bigWord);
}
const category = wordCategory(text[start] ?? "", bigWord);
while (start > 0 && wordCategory(text[start - 1] ?? "", bigWord) === category) {
start -= 1;
}
let end = start;
while (end < text.length && wordCategory(text[end] ?? "", bigWord) === category) {
end += 1;
}
if (!inner) {
while (end < text.length && wordCategory(text[end] ?? "", bigWord) === "space") {
end += 1;
}
while (start > 0 && wordCategory(text[start - 1] ?? "", bigWord) === "space") {
start -= 1;
}
}
return { start, end };
}
#resolveQuoteTextObject(inner: boolean, quote: string, sourceToken: VimKeyToken): { start: number; end: number } {
const line = this.buffer.getLine(this.buffer.cursor.line);
const col = this.buffer.cursor.col;
const before = line.lastIndexOf(quote, col);
const after = line.indexOf(quote, col + (line[col] === quote ? 1 : 0));
if (before === -1 || after === -1 || before === after) {
throw new VimError(`Quote text object not found for ${quote}`, sourceToken);
}
const startCol = inner ? before + 1 : before;
const endCol = inner ? after : after + 1;
return {
start: this.buffer.positionToOffset({ line: this.buffer.cursor.line, col: startCol }),
end: this.buffer.positionToOffset({ line: this.buffer.cursor.line, col: endCol }),
};
}
#resolveBracketTextObject(inner: boolean, open: string, sourceToken: VimKeyToken): { start: number; end: number } {
const close = BRACKET_PAIRS.get(open)!;
const text = this.buffer.getText();
const cursor = this.buffer.currentOffset();
let start = -1;
let depth = 0;
for (let index = cursor; index >= 0; index -= 1) {
const char = text[index] ?? "";
if (char === close) {
depth += 1;
} else if (char === open) {
if (depth === 0) {
start = index;
break;
}
depth -= 1;
}
}
if (start === -1) {
throw new VimError(`Text object ${open}${close} not found`, sourceToken);
}
let end = -1;
depth = 0;
for (let index = start; index < text.length; index += 1) {
const char = text[index] ?? "";
if (char === open) {
depth += 1;
} else if (char === close) {
depth -= 1;
if (depth === 0) {
end = index;
break;
}
}
}
if (end === -1) {
throw new VimError(`Text object ${open}${close} not found`, sourceToken);
}
return {
start: inner ? start + 1 : start,
end: inner ? end : end + 1,
};
}
#findMatchingBracket(): Position {
const text = this.buffer.getText();
const cursor = this.buffer.currentOffset();
let offset = cursor;
let char = text[offset] ?? "";
if (!BRACKET_PAIRS.has(char) && !CLOSING_BRACKETS.has(char)) {
offset += 1;
char = text[offset] ?? "";
}
if (BRACKET_PAIRS.has(char)) {
const close = BRACKET_PAIRS.get(char)!;
let depth = 0;
for (let index = offset; index < text.length; index += 1) {
const current = text[index] ?? "";
if (current === char) depth += 1;
if (current === close) {
depth -= 1;
if (depth === 0) {
return this.buffer.offsetToPosition(index);
}
}
}
}
if (CLOSING_BRACKETS.has(char)) {
const open = CLOSING_BRACKETS.get(char)!;
let depth = 0;
for (let index = offset; index >= 0; index -= 1) {
const current = text[index] ?? "";
if (current === char) depth += 1;
if (current === open) {
depth -= 1;
if (depth === 0) {
return this.buffer.offsetToPosition(index);
}
}
}
}
throw new VimError("Matching bracket not found");
}
async #runSearch(pattern: string, direction: 1 | -1, updateState: boolean): Promise<void> {
const text = this.buffer.getText();
const regex = createSearchRegex(pattern, "g");
const cursor = this.buffer.currentOffset();
let matchOffset = -1;
if (direction > 0) {
regex.lastIndex = Math.min(text.length, cursor + 1);
const match = regex.exec(text);
if (match && match.index >= 0) {
matchOffset = match.index;
} else {
regex.lastIndex = 0;
const wrapMatch = regex.exec(text);
if (wrapMatch && wrapMatch.index >= 0) {
matchOffset = wrapMatch.index;
}
}
} else {
const matches = Array.from(text.matchAll(regex));
for (let index = matches.length - 1; index >= 0; index -= 1) {
const match = matches[index];
if ((match.index ?? -1) < cursor) {
matchOffset = match.index ?? -1;
break;
}
}
if (matchOffset === -1 && matches.length > 0) {
matchOffset = matches[matches.length - 1]?.index ?? -1;
}
}
if (matchOffset === -1) {
throw new VimError(`Pattern not found: ${pattern}`);
}
this.buffer.setCursor(this.buffer.offsetToPosition(matchOffset));
this.statusMessage = `${direction > 0 ? "/" : "?"}${pattern}`;
if (updateState) {
this.lastSearch = { pattern, direction };
}
}
async #repeatSearch(direction: 1 | -1, count: number): Promise<void> {
if (!this.lastSearch) {
throw new VimError("No previous search");
}
for (let index = 0; index < count; index += 1) {
await this.#runSearch(this.lastSearch.pattern, direction, false);
}
this.lastSearch = { pattern: this.lastSearch.pattern, direction };
}
async #executeEx(input: string): Promise<void> {
const command = parseExCommand(input);
switch (command.kind) {
case "goto-line":
this.buffer.setCursor({ line: Math.max(0, command.line - 1), col: 0 });
this.statusMessage = `Line ${command.line}`;
return;
case "write": {
const result = await this.#callbacks.saveBuffer(this.buffer, { force: command.force });
this.buffer.markSaved(result.loaded);
this.diagnostics = result.diagnostics;
this.statusMessage = result.diagnostics
? `Wrote ${this.buffer.displayPath} (${result.diagnostics.summary})`
: `Wrote ${this.buffer.displayPath}`;
this.#undoStack = [];
this.#redoStack = [];
return;
}
case "write-quit":
await this.#executeEx(command.force ? "w!" : "w");
this.closed = true;
this.statusMessage = `Wrote and closed ${this.buffer.displayPath}`;
return;
case "quit":
if (this.buffer.modified && !command.force) {
throw new VimError("Unsaved changes; use :q! to discard");
}
this.closed = true;
this.statusMessage = `Closed ${this.buffer.displayPath}`;
return;
case "edit": {
if (this.buffer.modified && !command.force) {
throw new VimError("Unsaved changes; use :e! to reload or force open");
}
const next = await this.#callbacks.loadBuffer(command.path ?? this.buffer.displayPath);
this.buffer.replaceLoadedFile(next);
this.inputMode = "normal";
this.selectionAnchor = null;
this.#pendingInput = "";
this.#pendingChange = null;
this.#undoStack = [];
this.#redoStack = [];
this.statusMessage = command.path
? `Opened ${this.buffer.displayPath}`
: `Reloaded ${this.buffer.displayPath}`;
return;
}
case "substitute": {
const totalLines = this.buffer.lineCount();
const range =
command.range === "all"
? { start: 1, end: totalLines }
: (command.range ?? { start: this.buffer.cursor.line + 1, end: this.buffer.cursor.line + 1 });
const startLine = Math.max(1, Math.min(range.start, totalLines));
const endLine = Math.max(startLine, Math.min(range.end, totalLines));
const regexFlags = command.flags.includes("i") ? "gi" : "g";
const regex = createSearchRegex(command.pattern, regexFlags);
let replacements = 0;
await this.#applyAtomicChange([":substitute"], () => {
for (let lineIndex = startLine - 1; lineIndex <= endLine - 1; lineIndex += 1) {
const line = this.buffer.getLine(lineIndex);
let lineReplacements = 0;
const nextLine = line.replace(regex, match => {
if (!command.flags.includes("g") && lineReplacements > 0) {
return match;
}
lineReplacements += 1;
replacements += 1;
return decodeReplacement(command.replacement).replace(/&/g, match);
});
this.buffer.replaceLine(lineIndex, nextLine);
regex.lastIndex = 0;
}
});
if (replacements === 0) {
throw new VimError(`Pattern not found: ${command.pattern}`);
}
this.statusMessage = `${replacements} substitution${replacements === 1 ? "" : "s"}`;
return;
}
case "delete": {
const totalLines = this.buffer.lineCount();
const range =
command.range === "all"
? { start: 1, end: totalLines }
: (command.range ?? { start: this.buffer.cursor.line + 1, end: this.buffer.cursor.line + 1 });
await this.#applyAtomicChange([":delete"], () => {
const removed = this.buffer.deleteLines(range.start - 1, range.end - 1);
this.register = { kind: "line", text: removed.join("\n") };
});
this.statusMessage = `Deleted ${range.end - range.start + 1} line${range.end === range.start ? "" : "s"}`;
return;
}
}
}
#paste(after: boolean, count: number): void {
if (!this.register.text) {
return;
}
if (this.register.kind === "line") {
const lines = this.register.text.split("\n");
const insertAt = after ? this.buffer.cursor.line + 1 : this.buffer.cursor.line;
for (let iteration = 0; iteration < count; iteration += 1) {
this.buffer.insertLines(insertAt + iteration * lines.length, lines);
}
return;
}
const text = this.register.text.repeat(count);
const offset = this.buffer.currentOffset() + (after ? 1 : 0);
this.buffer.replaceOffsets(offset, offset, text, offset + text.length);
}
#readCount(tokens: readonly VimKeyToken[], index: number): { count: number; hasCount: boolean; nextIndex: number } {
let cursor = index;
let digits = "";
while (cursor < tokens.length) {
const value = tokens[cursor]?.value ?? "";
if (!/^\d$/.test(value)) {
break;
}
if (digits.length === 0 && value === "0") {
break;
}
digits += value;
cursor += 1;
}
return {
count: digits.length > 0 ? Number.parseInt(digits, 10) : 1,
hasCount: digits.length > 0,
nextIndex: cursor,
};
}
}