550efebbcc
- 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.
1751 lines
54 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|