@oh-my-pi/pi-coding-agent 14.1.0 → 14.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +79 -0
- package/package.json +8 -8
- package/src/async/job-manager.ts +43 -10
- package/src/commit/agentic/tools/analyze-file.ts +1 -2
- package/src/config/mcp-schema.json +1 -1
- package/src/config/model-equivalence.ts +1 -0
- package/src/config/model-registry.ts +63 -34
- package/src/config/model-resolver.ts +111 -15
- package/src/config/settings-schema.ts +4 -3
- package/src/config/settings.ts +1 -1
- package/src/cursor.ts +64 -23
- package/src/edit/index.ts +254 -89
- package/src/edit/modes/chunk.ts +336 -57
- package/src/edit/modes/hashline.ts +51 -26
- package/src/edit/modes/patch.ts +16 -10
- package/src/edit/modes/replace.ts +15 -7
- package/src/edit/renderer.ts +248 -94
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.js +6 -4
- package/src/extensibility/custom-tools/types.ts +0 -3
- package/src/extensibility/extensions/loader.ts +16 -0
- package/src/extensibility/extensions/runner.ts +2 -7
- package/src/extensibility/extensions/types.ts +8 -4
- package/src/internal-urls/docs-index.generated.ts +3 -3
- package/src/ipy/executor.ts +447 -52
- package/src/ipy/kernel.ts +39 -13
- package/src/lsp/client.ts +54 -0
- package/src/lsp/index.ts +8 -0
- package/src/lsp/types.ts +6 -0
- package/src/main.ts +0 -1
- package/src/modes/acp/acp-agent.ts +4 -1
- package/src/modes/components/bash-execution.ts +16 -4
- package/src/modes/components/status-line/presets.ts +17 -6
- package/src/modes/components/status-line/segments.ts +15 -0
- package/src/modes/components/status-line-segment-editor.ts +1 -0
- package/src/modes/components/status-line.ts +7 -1
- package/src/modes/components/tool-execution.ts +145 -75
- package/src/modes/controllers/command-controller.ts +24 -1
- package/src/modes/controllers/event-controller.ts +4 -1
- package/src/modes/controllers/extension-ui-controller.ts +28 -5
- package/src/modes/controllers/input-controller.ts +9 -3
- package/src/modes/controllers/selector-controller.ts +4 -1
- package/src/modes/interactive-mode.ts +19 -3
- package/src/modes/print-mode.ts +13 -4
- package/src/modes/prompt-action-autocomplete.ts +3 -5
- package/src/modes/rpc/rpc-mode.ts +8 -2
- package/src/modes/shared.ts +2 -2
- package/src/modes/types.ts +1 -0
- package/src/modes/utils/ui-helpers.ts +1 -0
- package/src/prompts/tools/bash.md +2 -2
- package/src/prompts/tools/chunk-edit.md +191 -163
- package/src/prompts/tools/hashline.md +11 -11
- package/src/prompts/tools/patch.md +10 -5
- package/src/prompts/tools/{await.md → poll.md} +1 -1
- package/src/prompts/tools/read-chunk.md +3 -3
- package/src/prompts/tools/task.md +2 -2
- package/src/prompts/tools/vim.md +98 -0
- package/src/sdk.ts +754 -724
- package/src/session/agent-session.ts +164 -34
- package/src/session/session-manager.ts +50 -4
- package/src/slash-commands/builtin-registry.ts +17 -0
- package/src/task/executor.ts +4 -4
- package/src/task/index.ts +3 -5
- package/src/task/types.ts +2 -2
- package/src/tools/bash.ts +26 -8
- package/src/tools/find.ts +5 -2
- package/src/tools/grep.ts +77 -8
- package/src/tools/index.ts +48 -19
- package/src/tools/{await-tool.ts → poll-tool.ts} +36 -30
- package/src/tools/python.ts +293 -278
- package/src/tools/submit-result.ts +5 -2
- package/src/tools/todo-write.ts +8 -2
- package/src/tools/vim.ts +966 -0
- package/src/utils/edit-mode.ts +2 -1
- package/src/utils/session-color.ts +55 -0
- package/src/utils/title-generator.ts +15 -6
- package/src/vim/buffer.ts +309 -0
- package/src/vim/commands.ts +382 -0
- package/src/vim/engine.ts +2426 -0
- package/src/vim/parser.ts +151 -0
- package/src/vim/render.ts +252 -0
- package/src/vim/types.ts +197 -0
|
@@ -0,0 +1,2426 @@
|
|
|
1
|
+
import type { FileDiagnosticsResult } from "../lsp";
|
|
2
|
+
import { snapshotEqual, type VimBuffer } from "./buffer";
|
|
3
|
+
import { parseExCommand } from "./commands";
|
|
4
|
+
import { replayTokens } from "./parser";
|
|
5
|
+
import type {
|
|
6
|
+
Position,
|
|
7
|
+
VimBufferSnapshot,
|
|
8
|
+
VimInputMode,
|
|
9
|
+
VimKeyToken,
|
|
10
|
+
VimLineRange,
|
|
11
|
+
VimLoadedFile,
|
|
12
|
+
VimPendingInput,
|
|
13
|
+
VimRegister,
|
|
14
|
+
VimSearchState,
|
|
15
|
+
VimSelection,
|
|
16
|
+
VimUndoEntry,
|
|
17
|
+
} from "./types";
|
|
18
|
+
import { clonePosition, maxPosition, minPosition, toPublicMode, VimInputError as VimError } from "./types";
|
|
19
|
+
|
|
20
|
+
export interface VimSaveResult {
|
|
21
|
+
loaded: VimLoadedFile;
|
|
22
|
+
diagnostics?: FileDiagnosticsResult;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface VimEngineCallbacks {
|
|
26
|
+
beforeMutate: (buffer: VimBuffer) => Promise<void>;
|
|
27
|
+
loadBuffer: (path: string) => Promise<VimLoadedFile>;
|
|
28
|
+
saveBuffer: (buffer: VimBuffer, options?: { force?: boolean }) => Promise<VimSaveResult>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface PendingChange {
|
|
32
|
+
before: VimBufferSnapshot;
|
|
33
|
+
tokens: string[];
|
|
34
|
+
moveCursorLeftOnEscape: boolean;
|
|
35
|
+
inserted: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface MotionResult {
|
|
39
|
+
nextIndex: number;
|
|
40
|
+
target: Position;
|
|
41
|
+
inclusive?: boolean;
|
|
42
|
+
linewise?: boolean;
|
|
43
|
+
range?: { start: number; end: number; linewise?: boolean };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const WORD_CHAR = /[A-Za-z0-9_]/;
|
|
47
|
+
const DEFAULT_VIEWPORT_HEIGHT = 10;
|
|
48
|
+
const BRACKET_PAIRS = new Map<string, string>([
|
|
49
|
+
["(", ")"],
|
|
50
|
+
["[", "]"],
|
|
51
|
+
["{", "}"],
|
|
52
|
+
["<", ">"],
|
|
53
|
+
]);
|
|
54
|
+
const CLOSING_BRACKETS = new Map<string, string>(
|
|
55
|
+
Array.from(BRACKET_PAIRS.entries()).map(([open, close]) => [close, open]),
|
|
56
|
+
);
|
|
57
|
+
const NOOP_Z_COMMANDS = new Set(["a", "A", "c", "C", "m", "M", "o", "O", "r", "R", "v", "x", "X"]);
|
|
58
|
+
|
|
59
|
+
function escapeRegex(value: string): string {
|
|
60
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isWhitespace(char: string): boolean {
|
|
64
|
+
return /\s/.test(char);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isWordChar(char: string): boolean {
|
|
68
|
+
return WORD_CHAR.test(char);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function wordCategory(char: string, bigWord: boolean): "space" | "word" | "punct" {
|
|
72
|
+
if (char.length === 0 || isWhitespace(char)) {
|
|
73
|
+
return "space";
|
|
74
|
+
}
|
|
75
|
+
if (bigWord) {
|
|
76
|
+
return "word";
|
|
77
|
+
}
|
|
78
|
+
return isWordChar(char) ? "word" : "punct";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function decodeReplacement(replacement: string): string {
|
|
82
|
+
return replacement.replace(/\\\//g, "/").replace(/\\\\/g, "\\");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function literalTextToReplayTokens(text: string): string[] {
|
|
86
|
+
const tokens: string[] = [];
|
|
87
|
+
for (const char of text) {
|
|
88
|
+
if (char === "\n") {
|
|
89
|
+
tokens.push("CR");
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (char === "\t") {
|
|
93
|
+
tokens.push("Tab");
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
tokens.push(char);
|
|
97
|
+
}
|
|
98
|
+
return tokens;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Convert a vim-style search pattern to a JavaScript RegExp.
|
|
102
|
+
// In vim's default ("magic") mode, (, ), {, }, |, + are literal unless backslash-escaped.
|
|
103
|
+
// In JS regex these are metacharacters. Swap the escaping so bare chars are literal
|
|
104
|
+
// and \( etc. become regex groups.
|
|
105
|
+
function vimPatternToJsRegex(pattern: string): string {
|
|
106
|
+
return pattern.replace(/\\([(){}|+])|([(){}|+])/g, (_match, escaped, bare) => {
|
|
107
|
+
if (escaped) return escaped; // \( -> ( (regex group)
|
|
108
|
+
return `\\${bare}`; // ( -> \( (literal paren)
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function createSearchRegex(pattern: string, flags = "g"): RegExp {
|
|
113
|
+
try {
|
|
114
|
+
return new RegExp(vimPatternToJsRegex(pattern), flags);
|
|
115
|
+
} catch {
|
|
116
|
+
return new RegExp(escapeRegex(pattern), flags);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function detectIndentUnit(lines: string[]): string {
|
|
121
|
+
for (const line of lines) {
|
|
122
|
+
if (line.startsWith("\t")) {
|
|
123
|
+
return "\t";
|
|
124
|
+
}
|
|
125
|
+
if (line.startsWith(" ")) {
|
|
126
|
+
return " ";
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return "\t";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function normalizeRange(start: number, end: number): { start: number; end: number } {
|
|
133
|
+
return {
|
|
134
|
+
start: Math.min(start, end),
|
|
135
|
+
end: Math.max(start, end),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function selectionFromAnchor(buffer: VimBuffer, anchor: Position, linewise: boolean): VimSelection {
|
|
140
|
+
if (linewise) {
|
|
141
|
+
const startLine = Math.min(anchor.line, buffer.cursor.line);
|
|
142
|
+
const endLine = Math.max(anchor.line, buffer.cursor.line);
|
|
143
|
+
return {
|
|
144
|
+
kind: "line",
|
|
145
|
+
start: { line: startLine + 1, col: 1 },
|
|
146
|
+
end: { line: endLine + 1, col: buffer.getLine(endLine).length + 1 },
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
const start = minPosition(anchor, buffer.cursor);
|
|
150
|
+
const end = maxPosition(anchor, buffer.cursor);
|
|
151
|
+
return {
|
|
152
|
+
kind: "char",
|
|
153
|
+
start: { line: start.line + 1, col: start.col + 1 },
|
|
154
|
+
end: { line: end.line + 1, col: end.col + 1 },
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function expandVisualOffsets(
|
|
159
|
+
buffer: VimBuffer,
|
|
160
|
+
anchor: Position,
|
|
161
|
+
linewise: boolean,
|
|
162
|
+
): { start: number; end: number; linewise: boolean } {
|
|
163
|
+
if (linewise) {
|
|
164
|
+
const startLine = Math.min(anchor.line, buffer.cursor.line);
|
|
165
|
+
const endLine = Math.max(anchor.line, buffer.cursor.line);
|
|
166
|
+
const startOffset = buffer.positionToOffset({ line: startLine, col: 0 });
|
|
167
|
+
const endOffset =
|
|
168
|
+
endLine >= buffer.lastLineIndex()
|
|
169
|
+
? buffer.getText().length
|
|
170
|
+
: buffer.positionToOffset({ line: endLine + 1, col: 0 });
|
|
171
|
+
return { start: startOffset, end: endOffset, linewise: true };
|
|
172
|
+
}
|
|
173
|
+
const anchorOffset = buffer.positionToOffset(anchor);
|
|
174
|
+
const cursorOffset = buffer.positionToOffset(buffer.cursor);
|
|
175
|
+
const { start, end } = normalizeRange(anchorOffset, cursorOffset);
|
|
176
|
+
return { start, end: end + 1, linewise: false };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function nextWordStart(text: string, offset: number, bigWord: boolean): number {
|
|
180
|
+
let index = Math.min(Math.max(offset, 0), text.length);
|
|
181
|
+
if (index >= text.length) {
|
|
182
|
+
return text.length;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const currentCategory = wordCategory(text[index] ?? "", bigWord);
|
|
186
|
+
if (currentCategory === "space") {
|
|
187
|
+
while (index < text.length && wordCategory(text[index] ?? "", bigWord) === "space") {
|
|
188
|
+
index += 1;
|
|
189
|
+
}
|
|
190
|
+
return index;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
while (index < text.length && wordCategory(text[index] ?? "", bigWord) === currentCategory) {
|
|
194
|
+
index += 1;
|
|
195
|
+
}
|
|
196
|
+
while (index < text.length && wordCategory(text[index] ?? "", bigWord) === "space") {
|
|
197
|
+
index += 1;
|
|
198
|
+
}
|
|
199
|
+
return index;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function previousWordStart(text: string, offset: number, bigWord: boolean): number {
|
|
203
|
+
let index = Math.min(Math.max(offset - 1, 0), text.length);
|
|
204
|
+
while (index > 0 && wordCategory(text[index] ?? "", bigWord) === "space") {
|
|
205
|
+
index -= 1;
|
|
206
|
+
}
|
|
207
|
+
const category = wordCategory(text[index] ?? "", bigWord);
|
|
208
|
+
while (index > 0 && wordCategory(text[index - 1] ?? "", bigWord) === category) {
|
|
209
|
+
index -= 1;
|
|
210
|
+
}
|
|
211
|
+
return index;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function endOfWord(text: string, offset: number, bigWord: boolean): number {
|
|
215
|
+
let index = Math.min(Math.max(offset, 0), text.length);
|
|
216
|
+
while (index < text.length && wordCategory(text[index] ?? "", bigWord) === "space") {
|
|
217
|
+
index += 1;
|
|
218
|
+
}
|
|
219
|
+
const category = wordCategory(text[index] ?? "", bigWord);
|
|
220
|
+
while (index < text.length && wordCategory(text[index] ?? "", bigWord) === category) {
|
|
221
|
+
index += 1;
|
|
222
|
+
}
|
|
223
|
+
return Math.max(0, index - 1);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function endOfPreviousWord(text: string, offset: number, bigWord: boolean): number {
|
|
227
|
+
if (text.length === 0) {
|
|
228
|
+
return 0;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
let index = Math.min(Math.max(offset - 1, 0), text.length - 1);
|
|
232
|
+
while (index >= 0 && wordCategory(text[index] ?? "", bigWord) === "space") {
|
|
233
|
+
index -= 1;
|
|
234
|
+
}
|
|
235
|
+
if (index < 0) {
|
|
236
|
+
return 0;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const currentCategory = wordCategory(text[index] ?? "", bigWord);
|
|
240
|
+
while (index >= 0 && wordCategory(text[index] ?? "", bigWord) === currentCategory) {
|
|
241
|
+
index -= 1;
|
|
242
|
+
}
|
|
243
|
+
while (index >= 0 && wordCategory(text[index] ?? "", bigWord) === "space") {
|
|
244
|
+
index -= 1;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return Math.max(0, index);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function toggleCase(text: string): string {
|
|
251
|
+
let toggled = "";
|
|
252
|
+
for (const char of text) {
|
|
253
|
+
if (char >= "a" && char <= "z") {
|
|
254
|
+
toggled += char.toUpperCase();
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (char >= "A" && char <= "Z") {
|
|
258
|
+
toggled += char.toLowerCase();
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
toggled += char;
|
|
262
|
+
}
|
|
263
|
+
return toggled;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function lastNonBlankColumn(line: string): number {
|
|
267
|
+
for (let index = line.length - 1; index >= 0; index -= 1) {
|
|
268
|
+
if (!isWhitespace(line[index] ?? "")) {
|
|
269
|
+
return index;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return 0;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function findParagraphStart(lines: string[], line: number): number {
|
|
276
|
+
let index = Math.max(0, line - 1);
|
|
277
|
+
while (index > 0 && lines[index]!.trim().length > 0) {
|
|
278
|
+
index -= 1;
|
|
279
|
+
}
|
|
280
|
+
while (index > 0 && lines[index - 1]!.trim().length === 0) {
|
|
281
|
+
index -= 1;
|
|
282
|
+
}
|
|
283
|
+
return index;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function findParagraphEnd(lines: string[], line: number): number {
|
|
287
|
+
let index = Math.min(lines.length - 1, line + 1);
|
|
288
|
+
while (index < lines.length - 1 && lines[index]!.trim().length > 0) {
|
|
289
|
+
index += 1;
|
|
290
|
+
}
|
|
291
|
+
while (index < lines.length - 1 && lines[index + 1]!.trim().length === 0) {
|
|
292
|
+
index += 1;
|
|
293
|
+
}
|
|
294
|
+
return index;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export class VimEngine {
|
|
298
|
+
buffer: VimBuffer;
|
|
299
|
+
inputMode: VimInputMode = "normal";
|
|
300
|
+
selectionAnchor: Position | null = null;
|
|
301
|
+
register: VimRegister = { kind: "char", text: "" };
|
|
302
|
+
lastSearch: VimSearchState | null = null;
|
|
303
|
+
lastCharFind: { char: string; mode: "f" | "F" | "t" | "T" } | null = null;
|
|
304
|
+
lastVisual: { anchor: Position; cursor: Position; mode: VimInputMode } | null = null;
|
|
305
|
+
lastCommand?: string;
|
|
306
|
+
statusMessage?: string;
|
|
307
|
+
diagnostics?: FileDiagnosticsResult;
|
|
308
|
+
viewportStart = 1;
|
|
309
|
+
closed = false;
|
|
310
|
+
|
|
311
|
+
#callbacks: VimEngineCallbacks;
|
|
312
|
+
#undoStack: VimUndoEntry[] = [];
|
|
313
|
+
#redoStack: VimUndoEntry[] = [];
|
|
314
|
+
#pendingInput = "";
|
|
315
|
+
#lastChangeTokens: string[] | null = null;
|
|
316
|
+
#pendingChange: PendingChange | null = null;
|
|
317
|
+
#stepCallback?: () => Promise<void>;
|
|
318
|
+
|
|
319
|
+
constructor(buffer: VimBuffer, callbacks: VimEngineCallbacks) {
|
|
320
|
+
this.buffer = buffer;
|
|
321
|
+
this.#callbacks = callbacks;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
clone(callbacks?: Partial<VimEngineCallbacks>): VimEngine {
|
|
325
|
+
const next = new VimEngine(this.buffer.clone(), {
|
|
326
|
+
beforeMutate: callbacks?.beforeMutate ?? this.#callbacks.beforeMutate,
|
|
327
|
+
loadBuffer: callbacks?.loadBuffer ?? this.#callbacks.loadBuffer,
|
|
328
|
+
saveBuffer: callbacks?.saveBuffer ?? this.#callbacks.saveBuffer,
|
|
329
|
+
});
|
|
330
|
+
next.inputMode = this.inputMode;
|
|
331
|
+
next.selectionAnchor = this.selectionAnchor ? clonePosition(this.selectionAnchor) : null;
|
|
332
|
+
next.register = { ...this.register };
|
|
333
|
+
next.lastSearch = this.lastSearch ? { ...this.lastSearch } : null;
|
|
334
|
+
next.lastCharFind = this.lastCharFind ? { ...this.lastCharFind } : null;
|
|
335
|
+
next.lastVisual = this.lastVisual
|
|
336
|
+
? {
|
|
337
|
+
anchor: clonePosition(this.lastVisual.anchor),
|
|
338
|
+
cursor: clonePosition(this.lastVisual.cursor),
|
|
339
|
+
mode: this.lastVisual.mode,
|
|
340
|
+
}
|
|
341
|
+
: null;
|
|
342
|
+
next.lastCommand = this.lastCommand;
|
|
343
|
+
next.statusMessage = this.statusMessage;
|
|
344
|
+
next.diagnostics = this.diagnostics;
|
|
345
|
+
next.viewportStart = this.viewportStart;
|
|
346
|
+
next.closed = this.closed;
|
|
347
|
+
next.#pendingInput = this.#pendingInput;
|
|
348
|
+
next.#lastChangeTokens = this.#lastChangeTokens ? [...this.#lastChangeTokens] : null;
|
|
349
|
+
next.#pendingChange = this.#pendingChange
|
|
350
|
+
? {
|
|
351
|
+
before: {
|
|
352
|
+
...this.#pendingChange.before,
|
|
353
|
+
lines: [...this.#pendingChange.before.lines],
|
|
354
|
+
cursor: clonePosition(this.#pendingChange.before.cursor),
|
|
355
|
+
baseFingerprint: this.#pendingChange.before.baseFingerprint
|
|
356
|
+
? { ...this.#pendingChange.before.baseFingerprint }
|
|
357
|
+
: null,
|
|
358
|
+
},
|
|
359
|
+
tokens: [...this.#pendingChange.tokens],
|
|
360
|
+
moveCursorLeftOnEscape: this.#pendingChange.moveCursorLeftOnEscape,
|
|
361
|
+
inserted: this.#pendingChange.inserted,
|
|
362
|
+
}
|
|
363
|
+
: null;
|
|
364
|
+
next.#undoStack = this.#undoStack.map(entry => ({
|
|
365
|
+
before: {
|
|
366
|
+
...entry.before,
|
|
367
|
+
lines: [...entry.before.lines],
|
|
368
|
+
cursor: clonePosition(entry.before.cursor),
|
|
369
|
+
baseFingerprint: entry.before.baseFingerprint ? { ...entry.before.baseFingerprint } : null,
|
|
370
|
+
},
|
|
371
|
+
after: {
|
|
372
|
+
...entry.after,
|
|
373
|
+
lines: [...entry.after.lines],
|
|
374
|
+
cursor: clonePosition(entry.after.cursor),
|
|
375
|
+
baseFingerprint: entry.after.baseFingerprint ? { ...entry.after.baseFingerprint } : null,
|
|
376
|
+
},
|
|
377
|
+
}));
|
|
378
|
+
next.#redoStack = this.#redoStack.map(entry => ({
|
|
379
|
+
before: {
|
|
380
|
+
...entry.before,
|
|
381
|
+
lines: [...entry.before.lines],
|
|
382
|
+
cursor: clonePosition(entry.before.cursor),
|
|
383
|
+
baseFingerprint: entry.before.baseFingerprint ? { ...entry.before.baseFingerprint } : null,
|
|
384
|
+
},
|
|
385
|
+
after: {
|
|
386
|
+
...entry.after,
|
|
387
|
+
lines: [...entry.after.lines],
|
|
388
|
+
cursor: clonePosition(entry.after.cursor),
|
|
389
|
+
baseFingerprint: entry.after.baseFingerprint ? { ...entry.after.baseFingerprint } : null,
|
|
390
|
+
},
|
|
391
|
+
}));
|
|
392
|
+
return next;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
getPublicMode() {
|
|
396
|
+
return toPublicMode(this.inputMode);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
getSelection(): VimSelection | undefined {
|
|
400
|
+
if (this.selectionAnchor === null) {
|
|
401
|
+
return undefined;
|
|
402
|
+
}
|
|
403
|
+
return selectionFromAnchor(this.buffer, this.selectionAnchor, this.inputMode === "visual-line");
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
getPendingInput(): VimPendingInput | undefined {
|
|
407
|
+
switch (this.inputMode) {
|
|
408
|
+
case "insert":
|
|
409
|
+
return { kind: "insert", text: "" };
|
|
410
|
+
case "command":
|
|
411
|
+
case "search-forward":
|
|
412
|
+
case "search-backward":
|
|
413
|
+
return { kind: this.inputMode, text: this.#pendingInput };
|
|
414
|
+
default:
|
|
415
|
+
return undefined;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
rollbackPendingInsert(): void {
|
|
420
|
+
if (this.#pendingChange) {
|
|
421
|
+
this.buffer.restore(this.#pendingChange.before);
|
|
422
|
+
this.#pendingChange = null;
|
|
423
|
+
}
|
|
424
|
+
this.inputMode = "normal";
|
|
425
|
+
this.selectionAnchor = null;
|
|
426
|
+
this.#pendingInput = "";
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
setCursor(line: number, col: number): void {
|
|
430
|
+
this.buffer.setCursor({ line, col });
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async executeTokens(
|
|
434
|
+
tokens: readonly VimKeyToken[],
|
|
435
|
+
lastCommand?: string,
|
|
436
|
+
onStep?: () => Promise<void>,
|
|
437
|
+
): Promise<void> {
|
|
438
|
+
const previousStepCallback = this.#stepCallback;
|
|
439
|
+
this.#stepCallback = onStep ?? previousStepCallback;
|
|
440
|
+
this.lastCommand = lastCommand;
|
|
441
|
+
this.statusMessage = undefined;
|
|
442
|
+
this.diagnostics = undefined;
|
|
443
|
+
|
|
444
|
+
try {
|
|
445
|
+
for (let index = 0; index < tokens.length; ) {
|
|
446
|
+
switch (this.inputMode) {
|
|
447
|
+
case "insert":
|
|
448
|
+
index = await this.#executeInsert(tokens, index);
|
|
449
|
+
break;
|
|
450
|
+
case "command":
|
|
451
|
+
case "search-forward":
|
|
452
|
+
case "search-backward":
|
|
453
|
+
index = await this.#executePrompt(tokens, index);
|
|
454
|
+
break;
|
|
455
|
+
case "visual":
|
|
456
|
+
case "visual-line":
|
|
457
|
+
index = await this.#executeVisual(tokens, index);
|
|
458
|
+
break;
|
|
459
|
+
default:
|
|
460
|
+
index = await this.#executeNormal(tokens, index);
|
|
461
|
+
break;
|
|
462
|
+
}
|
|
463
|
+
if (this.closed) {
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
466
|
+
this.#ensureCursorVisible();
|
|
467
|
+
await this.#stepCallback?.();
|
|
468
|
+
}
|
|
469
|
+
} finally {
|
|
470
|
+
this.#stepCallback = previousStepCallback;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async close(force: boolean): Promise<void> {
|
|
475
|
+
if (this.buffer.modified && !force) {
|
|
476
|
+
throw new VimError("Unsaved changes; use force to discard");
|
|
477
|
+
}
|
|
478
|
+
this.closed = true;
|
|
479
|
+
this.statusMessage = `Closed ${this.buffer.displayPath}`;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
#ensureCursorVisible(): void {
|
|
483
|
+
const line = this.buffer.cursor.line + 1;
|
|
484
|
+
if (line < this.viewportStart) {
|
|
485
|
+
this.viewportStart = line;
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
const viewportEnd = this.viewportStart + DEFAULT_VIEWPORT_HEIGHT - 1;
|
|
489
|
+
if (line > viewportEnd) {
|
|
490
|
+
this.viewportStart = Math.max(1, line - DEFAULT_VIEWPORT_HEIGHT + 1);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
centerViewportOnCursor(size = DEFAULT_VIEWPORT_HEIGHT): void {
|
|
495
|
+
const lineCount = Math.max(this.buffer.lineCount(), 1);
|
|
496
|
+
const clampedSize = Math.max(1, Math.min(size, lineCount));
|
|
497
|
+
const maxStart = Math.max(1, lineCount - clampedSize + 1);
|
|
498
|
+
this.viewportStart = Math.max(1, Math.min(this.buffer.cursor.line + 1 - Math.floor(clampedSize / 2), maxStart));
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
#clearSelection(): void {
|
|
502
|
+
if (this.selectionAnchor && (this.inputMode === "visual" || this.inputMode === "visual-line")) {
|
|
503
|
+
this.lastVisual = {
|
|
504
|
+
anchor: clonePosition(this.selectionAnchor),
|
|
505
|
+
cursor: clonePosition(this.buffer.cursor),
|
|
506
|
+
mode: this.inputMode,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
this.selectionAnchor = null;
|
|
510
|
+
if (this.inputMode === "visual" || this.inputMode === "visual-line") {
|
|
511
|
+
this.inputMode = "normal";
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async #ensureEditable(): Promise<void> {
|
|
516
|
+
await this.#callbacks.beforeMutate(this.buffer);
|
|
517
|
+
this.diagnostics = undefined;
|
|
518
|
+
this.statusMessage = undefined;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
#pushUndo(entry: VimUndoEntry, changeTokens?: readonly string[]): void {
|
|
522
|
+
if (snapshotEqual(entry.before, entry.after)) {
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
this.#undoStack.push(entry);
|
|
526
|
+
this.#redoStack = [];
|
|
527
|
+
if (changeTokens && changeTokens.length > 0) {
|
|
528
|
+
this.#lastChangeTokens = [...changeTokens];
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
#beginPendingChange(prefixTokens: readonly string[], moveCursorLeftOnEscape: boolean): void {
|
|
533
|
+
this.#pendingChange = {
|
|
534
|
+
before: this.buffer.createSnapshot(),
|
|
535
|
+
tokens: [...prefixTokens],
|
|
536
|
+
moveCursorLeftOnEscape,
|
|
537
|
+
inserted: false,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
#markPendingInserted(): void {
|
|
542
|
+
if (this.#pendingChange) {
|
|
543
|
+
this.#pendingChange.inserted = true;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
#commitPendingChange(): void {
|
|
548
|
+
if (!this.#pendingChange) {
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
const entry: VimUndoEntry = {
|
|
552
|
+
before: this.#pendingChange.before,
|
|
553
|
+
after: this.buffer.createSnapshot(),
|
|
554
|
+
};
|
|
555
|
+
this.#pushUndo(entry, this.#pendingChange.tokens);
|
|
556
|
+
this.#pendingChange = null;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
async #applyAtomicChange(tokens: readonly string[], mutator: () => void): Promise<void> {
|
|
560
|
+
await this.#ensureEditable();
|
|
561
|
+
const before = this.buffer.createSnapshot();
|
|
562
|
+
mutator();
|
|
563
|
+
this.buffer.modified = true;
|
|
564
|
+
this.#pushUndo({ before, after: this.buffer.createSnapshot() }, tokens);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
async #startInsertChange(
|
|
568
|
+
tokens: readonly string[],
|
|
569
|
+
mutator?: () => void,
|
|
570
|
+
moveCursorLeftOnEscape = true,
|
|
571
|
+
): Promise<void> {
|
|
572
|
+
await this.#ensureEditable();
|
|
573
|
+
this.#beginPendingChange(tokens, moveCursorLeftOnEscape);
|
|
574
|
+
mutator?.();
|
|
575
|
+
this.buffer.modified = true;
|
|
576
|
+
this.inputMode = "insert";
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
async #executePrompt(tokens: readonly VimKeyToken[], index: number): Promise<number> {
|
|
580
|
+
const token = tokens[index]!;
|
|
581
|
+
if (token.value === "Esc") {
|
|
582
|
+
this.#pendingInput = "";
|
|
583
|
+
this.inputMode = "normal";
|
|
584
|
+
return index + 1;
|
|
585
|
+
}
|
|
586
|
+
if (token.value === "BS") {
|
|
587
|
+
this.#pendingInput = this.#pendingInput.slice(0, -1);
|
|
588
|
+
return index + 1;
|
|
589
|
+
}
|
|
590
|
+
if (token.value !== "CR") {
|
|
591
|
+
this.#pendingInput += token.value === "Tab" ? "\t" : token.value;
|
|
592
|
+
return index + 1;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const input = this.#pendingInput;
|
|
596
|
+
this.#pendingInput = "";
|
|
597
|
+
const mode = this.inputMode;
|
|
598
|
+
this.inputMode = "normal";
|
|
599
|
+
if (mode === "command") {
|
|
600
|
+
await this.#executeEx(input);
|
|
601
|
+
} else {
|
|
602
|
+
await this.#runSearch(input, mode === "search-forward" ? 1 : -1, true);
|
|
603
|
+
}
|
|
604
|
+
return index + 1;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
#exitInsertMode(): void {
|
|
608
|
+
if (this.#pendingChange) {
|
|
609
|
+
this.#pendingChange.tokens.push("Esc");
|
|
610
|
+
if (this.#pendingChange.moveCursorLeftOnEscape && this.#pendingChange.inserted && this.buffer.cursor.col > 0) {
|
|
611
|
+
this.buffer.setCursor({ line: this.buffer.cursor.line, col: this.buffer.cursor.col - 1 });
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
this.inputMode = "normal";
|
|
615
|
+
this.#commitPendingChange();
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
async applyLiteralInsert(text: string, exitInsertMode: boolean): Promise<void> {
|
|
619
|
+
if (this.inputMode !== "insert" || !this.#pendingChange) {
|
|
620
|
+
throw new VimError("Insert payload requires INSERT mode.");
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
if (text.length > 0) {
|
|
624
|
+
const offset = this.buffer.currentOffset();
|
|
625
|
+
this.buffer.replaceOffsets(offset, offset, text, offset + text.length);
|
|
626
|
+
this.buffer.modified = true;
|
|
627
|
+
if (text.includes("\n")) {
|
|
628
|
+
this.buffer.trailingNewline = this.buffer.trailingNewline || text.endsWith("\n");
|
|
629
|
+
}
|
|
630
|
+
this.#pendingChange.tokens.push(...literalTextToReplayTokens(text));
|
|
631
|
+
this.#markPendingInserted();
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
if (exitInsertMode) {
|
|
635
|
+
this.#exitInsertMode();
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
async #executeInsert(tokens: readonly VimKeyToken[], index: number): Promise<number> {
|
|
640
|
+
const token = tokens[index]!;
|
|
641
|
+
if (token.value === "Esc") {
|
|
642
|
+
this.#exitInsertMode();
|
|
643
|
+
return index + 1;
|
|
644
|
+
}
|
|
645
|
+
if (token.value === "CR") {
|
|
646
|
+
const offset = this.buffer.currentOffset();
|
|
647
|
+
this.buffer.replaceOffsets(offset, offset, "\n", offset + 1);
|
|
648
|
+
this.buffer.modified = true;
|
|
649
|
+
this.buffer.trailingNewline = true;
|
|
650
|
+
this.#pendingChange?.tokens.push(token.value);
|
|
651
|
+
this.#markPendingInserted();
|
|
652
|
+
return index + 1;
|
|
653
|
+
}
|
|
654
|
+
if (token.value === "BS") {
|
|
655
|
+
const offset = this.buffer.currentOffset();
|
|
656
|
+
if (offset > 0) {
|
|
657
|
+
this.buffer.deleteOffsets(offset - 1, offset);
|
|
658
|
+
this.buffer.modified = true;
|
|
659
|
+
this.#pendingChange?.tokens.push(token.value);
|
|
660
|
+
this.#markPendingInserted();
|
|
661
|
+
}
|
|
662
|
+
return index + 1;
|
|
663
|
+
}
|
|
664
|
+
if (token.value === "Tab") {
|
|
665
|
+
const offset = this.buffer.currentOffset();
|
|
666
|
+
this.buffer.replaceOffsets(offset, offset, "\t", offset + 1);
|
|
667
|
+
this.buffer.modified = true;
|
|
668
|
+
this.#pendingChange?.tokens.push(token.value);
|
|
669
|
+
this.#markPendingInserted();
|
|
670
|
+
return index + 1;
|
|
671
|
+
}
|
|
672
|
+
if (token.value === "C-w") {
|
|
673
|
+
const offset = this.buffer.currentOffset();
|
|
674
|
+
const text = this.buffer.getText();
|
|
675
|
+
let start = previousWordStart(text, offset, false);
|
|
676
|
+
if (start === offset && start > 0) {
|
|
677
|
+
start -= 1;
|
|
678
|
+
}
|
|
679
|
+
this.buffer.deleteOffsets(start, offset);
|
|
680
|
+
this.buffer.modified = true;
|
|
681
|
+
this.#pendingChange?.tokens.push(token.value);
|
|
682
|
+
this.#markPendingInserted();
|
|
683
|
+
return index + 1;
|
|
684
|
+
}
|
|
685
|
+
if (token.value === "C-u") {
|
|
686
|
+
const offset = this.buffer.currentOffset();
|
|
687
|
+
const lineStart = this.buffer.positionToOffset({ line: this.buffer.cursor.line, col: 0 });
|
|
688
|
+
if (offset > lineStart) {
|
|
689
|
+
this.buffer.deleteOffsets(lineStart, offset);
|
|
690
|
+
this.buffer.modified = true;
|
|
691
|
+
this.#pendingChange?.tokens.push(token.value);
|
|
692
|
+
this.#markPendingInserted();
|
|
693
|
+
}
|
|
694
|
+
return index + 1;
|
|
695
|
+
}
|
|
696
|
+
if (token.value === "C-o") {
|
|
697
|
+
// Execute one normal-mode command, then return to insert
|
|
698
|
+
const nextToken = tokens[index + 1];
|
|
699
|
+
if (!nextToken) {
|
|
700
|
+
return index + 1;
|
|
701
|
+
}
|
|
702
|
+
const savedMode = this.inputMode;
|
|
703
|
+
this.inputMode = "normal";
|
|
704
|
+
const nextIdx = await this.#executeNormal(tokens, index + 1);
|
|
705
|
+
this.inputMode = savedMode;
|
|
706
|
+
return nextIdx;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const insertText = token.value;
|
|
710
|
+
const offset = this.buffer.currentOffset();
|
|
711
|
+
this.buffer.replaceOffsets(offset, offset, insertText, offset + insertText.length);
|
|
712
|
+
this.buffer.modified = true;
|
|
713
|
+
this.#pendingChange?.tokens.push(token.value);
|
|
714
|
+
this.#markPendingInserted();
|
|
715
|
+
return index + 1;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
async #executeVisual(tokens: readonly VimKeyToken[], index: number): Promise<number> {
|
|
719
|
+
const token = tokens[index]!;
|
|
720
|
+
if (token.value === "Esc") {
|
|
721
|
+
this.#clearSelection();
|
|
722
|
+
return index + 1;
|
|
723
|
+
}
|
|
724
|
+
if (token.value === "v") {
|
|
725
|
+
if (this.inputMode === "visual") {
|
|
726
|
+
this.#clearSelection();
|
|
727
|
+
}
|
|
728
|
+
return index + 1;
|
|
729
|
+
}
|
|
730
|
+
if (token.value === "V") {
|
|
731
|
+
this.inputMode = this.inputMode === "visual-line" ? "visual" : "visual-line";
|
|
732
|
+
return index + 1;
|
|
733
|
+
}
|
|
734
|
+
if (token.value === "o") {
|
|
735
|
+
if (this.selectionAnchor) {
|
|
736
|
+
const tmp = clonePosition(this.buffer.cursor);
|
|
737
|
+
this.buffer.setCursor(this.selectionAnchor);
|
|
738
|
+
this.selectionAnchor = tmp;
|
|
739
|
+
}
|
|
740
|
+
return index + 1;
|
|
741
|
+
}
|
|
742
|
+
if (token.value === "J") {
|
|
743
|
+
const visual = expandVisualOffsets(
|
|
744
|
+
this.buffer,
|
|
745
|
+
this.selectionAnchor ?? this.buffer.cursor,
|
|
746
|
+
this.inputMode === "visual-line",
|
|
747
|
+
);
|
|
748
|
+
const startLine = this.buffer.offsetToPosition(visual.start).line;
|
|
749
|
+
const endLine = this.buffer.offsetToPosition(Math.max(visual.start, visual.end - 1)).line;
|
|
750
|
+
await this.#applyAtomicChange(["J"], () => {
|
|
751
|
+
this.buffer.joinLines(startLine, endLine - startLine);
|
|
752
|
+
});
|
|
753
|
+
this.#clearSelection();
|
|
754
|
+
return index + 1;
|
|
755
|
+
}
|
|
756
|
+
if (token.value === "u" || token.value === "U") {
|
|
757
|
+
const visual = expandVisualOffsets(
|
|
758
|
+
this.buffer,
|
|
759
|
+
this.selectionAnchor ?? this.buffer.cursor,
|
|
760
|
+
this.inputMode === "visual-line",
|
|
761
|
+
);
|
|
762
|
+
await this.#applyAtomicChange([token.value], () => {
|
|
763
|
+
const original = this.buffer.getText().slice(visual.start, visual.end);
|
|
764
|
+
const transformed = token.value === "U" ? original.toUpperCase() : original.toLowerCase();
|
|
765
|
+
this.buffer.replaceOffsets(visual.start, visual.end, transformed, visual.start);
|
|
766
|
+
});
|
|
767
|
+
this.#clearSelection();
|
|
768
|
+
return index + 1;
|
|
769
|
+
}
|
|
770
|
+
if (token.value === "p" || token.value === "P") {
|
|
771
|
+
const visual = expandVisualOffsets(
|
|
772
|
+
this.buffer,
|
|
773
|
+
this.selectionAnchor ?? this.buffer.cursor,
|
|
774
|
+
this.inputMode === "visual-line",
|
|
775
|
+
);
|
|
776
|
+
await this.#applyAtomicChange([token.value], () => {
|
|
777
|
+
const removed = this.buffer.getText().slice(visual.start, visual.end);
|
|
778
|
+
const pasteText = this.register.text;
|
|
779
|
+
this.buffer.replaceOffsets(visual.start, visual.end, pasteText, visual.start + pasteText.length);
|
|
780
|
+
this.register = { kind: visual.linewise ? "line" : "char", text: removed };
|
|
781
|
+
});
|
|
782
|
+
this.#clearSelection();
|
|
783
|
+
return index + 1;
|
|
784
|
+
}
|
|
785
|
+
if (token.value === "g") {
|
|
786
|
+
const next = tokens[index + 1];
|
|
787
|
+
if (!next) {
|
|
788
|
+
throw new VimError("g requires a second key", token);
|
|
789
|
+
}
|
|
790
|
+
if (next.value === "J") {
|
|
791
|
+
const visual = expandVisualOffsets(
|
|
792
|
+
this.buffer,
|
|
793
|
+
this.selectionAnchor ?? this.buffer.cursor,
|
|
794
|
+
this.inputMode === "visual-line",
|
|
795
|
+
);
|
|
796
|
+
const startLine = this.buffer.offsetToPosition(visual.start).line;
|
|
797
|
+
const endLine = this.buffer.offsetToPosition(Math.max(visual.start, visual.end - 1)).line;
|
|
798
|
+
await this.#applyAtomicChange(["g", "J"], () => {
|
|
799
|
+
const start = this.buffer.clampLine(startLine);
|
|
800
|
+
const end = this.buffer.clampLine(endLine);
|
|
801
|
+
if (start < end) {
|
|
802
|
+
const joined = this.buffer.lines.slice(start, end + 1).join("");
|
|
803
|
+
this.buffer.lines.splice(start, end - start + 1, joined);
|
|
804
|
+
this.buffer.setCursor({ line: start, col: Math.max(0, joined.length - 1) });
|
|
805
|
+
}
|
|
806
|
+
});
|
|
807
|
+
this.#clearSelection();
|
|
808
|
+
return index + 2;
|
|
809
|
+
}
|
|
810
|
+
if (next.value === "u" || next.value === "U" || next.value === "~") {
|
|
811
|
+
const visual = expandVisualOffsets(
|
|
812
|
+
this.buffer,
|
|
813
|
+
this.selectionAnchor ?? this.buffer.cursor,
|
|
814
|
+
this.inputMode === "visual-line",
|
|
815
|
+
);
|
|
816
|
+
await this.#applyAtomicChange(["g", next.value], () => {
|
|
817
|
+
const original = this.buffer.getText().slice(visual.start, visual.end);
|
|
818
|
+
const transformed =
|
|
819
|
+
next.value === "u"
|
|
820
|
+
? original.toLowerCase()
|
|
821
|
+
: next.value === "U"
|
|
822
|
+
? original.toUpperCase()
|
|
823
|
+
: toggleCase(original);
|
|
824
|
+
this.buffer.replaceOffsets(visual.start, visual.end, transformed, visual.start);
|
|
825
|
+
});
|
|
826
|
+
this.#clearSelection();
|
|
827
|
+
return index + 2;
|
|
828
|
+
}
|
|
829
|
+
throw new VimError(`Unsupported g command: g${next.display}`, next);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
const { count, hasCount, nextIndex } = this.#readCount(tokens, index);
|
|
833
|
+
const opToken = tokens[nextIndex];
|
|
834
|
+
if (!opToken) {
|
|
835
|
+
return nextIndex;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
switch (opToken.value) {
|
|
839
|
+
case "d":
|
|
840
|
+
case "x":
|
|
841
|
+
case "X":
|
|
842
|
+
case "D":
|
|
843
|
+
case "y":
|
|
844
|
+
case "c":
|
|
845
|
+
case "s":
|
|
846
|
+
case "S":
|
|
847
|
+
case "C":
|
|
848
|
+
case ">":
|
|
849
|
+
case "<":
|
|
850
|
+
case "~": {
|
|
851
|
+
const visual = expandVisualOffsets(
|
|
852
|
+
this.buffer,
|
|
853
|
+
this.selectionAnchor ?? this.buffer.cursor,
|
|
854
|
+
this.inputMode === "visual-line",
|
|
855
|
+
);
|
|
856
|
+
const consumeExtraIndent =
|
|
857
|
+
(opToken.value === ">" || opToken.value === "<") && tokens[nextIndex + 1]?.value === opToken.value;
|
|
858
|
+
const operatorValue =
|
|
859
|
+
opToken.value === "x" || opToken.value === "X" || opToken.value === "D"
|
|
860
|
+
? "d"
|
|
861
|
+
: opToken.value === "s" || opToken.value === "S" || opToken.value === "C"
|
|
862
|
+
? "c"
|
|
863
|
+
: opToken.value;
|
|
864
|
+
const visualTokens = consumeExtraIndent ? [opToken.value, opToken.value] : [opToken.value];
|
|
865
|
+
await this.#applyVisualOperator(operatorValue, visual, count, visualTokens);
|
|
866
|
+
return nextIndex + visualTokens.length;
|
|
867
|
+
}
|
|
868
|
+
case "r": {
|
|
869
|
+
const replacement = tokens[nextIndex + 1];
|
|
870
|
+
if (!replacement || replacement.value.length !== 1) {
|
|
871
|
+
throw new VimError("Visual replace requires a literal character", opToken);
|
|
872
|
+
}
|
|
873
|
+
const visual = expandVisualOffsets(
|
|
874
|
+
this.buffer,
|
|
875
|
+
this.selectionAnchor ?? this.buffer.cursor,
|
|
876
|
+
this.inputMode === "visual-line",
|
|
877
|
+
);
|
|
878
|
+
await this.#applyAtomicChange(["r", replacement.value], () => {
|
|
879
|
+
const original = this.buffer.getText().slice(visual.start, visual.end);
|
|
880
|
+
let replaced = "";
|
|
881
|
+
for (const char of original) {
|
|
882
|
+
replaced += char === "\n" ? "\n" : replacement.value;
|
|
883
|
+
}
|
|
884
|
+
this.buffer.replaceOffsets(visual.start, visual.end, replaced, visual.start);
|
|
885
|
+
});
|
|
886
|
+
this.#clearSelection();
|
|
887
|
+
return nextIndex + 2;
|
|
888
|
+
}
|
|
889
|
+
default:
|
|
890
|
+
break;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
const motion = this.#resolveMotion(tokens, nextIndex, count, hasCount);
|
|
894
|
+
this.buffer.setCursor(motion.target);
|
|
895
|
+
return motion.nextIndex;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
async #applyVisualOperator(
|
|
899
|
+
operator: string,
|
|
900
|
+
visual: { start: number; end: number; linewise: boolean },
|
|
901
|
+
count: number,
|
|
902
|
+
tokens: readonly string[],
|
|
903
|
+
): Promise<void> {
|
|
904
|
+
switch (operator) {
|
|
905
|
+
case "y": {
|
|
906
|
+
this.register = {
|
|
907
|
+
kind: visual.linewise ? "line" : "char",
|
|
908
|
+
text: this.buffer.getText().slice(visual.start, visual.end),
|
|
909
|
+
};
|
|
910
|
+
this.#clearSelection();
|
|
911
|
+
this.statusMessage = `Yanked ${count} selection${count === 1 ? "" : "s"}`;
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
case "d": {
|
|
915
|
+
await this.#applyAtomicChange(tokens, () => {
|
|
916
|
+
this.register = {
|
|
917
|
+
kind: visual.linewise ? "line" : "char",
|
|
918
|
+
text: this.buffer.getText().slice(visual.start, visual.end),
|
|
919
|
+
};
|
|
920
|
+
this.buffer.deleteOffsets(visual.start, visual.end);
|
|
921
|
+
});
|
|
922
|
+
this.#clearSelection();
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
case "c": {
|
|
926
|
+
await this.#startInsertChange(tokens, () => {
|
|
927
|
+
this.register = {
|
|
928
|
+
kind: visual.linewise ? "line" : "char",
|
|
929
|
+
text: this.buffer.getText().slice(visual.start, visual.end),
|
|
930
|
+
};
|
|
931
|
+
this.buffer.deleteOffsets(visual.start, visual.end);
|
|
932
|
+
});
|
|
933
|
+
this.#clearSelection();
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
case ">":
|
|
937
|
+
case "<": {
|
|
938
|
+
const startLine = this.buffer.offsetToPosition(visual.start).line;
|
|
939
|
+
const endLine = this.buffer.offsetToPosition(Math.max(visual.start, visual.end - 1)).line;
|
|
940
|
+
await this.#applyAtomicChange(tokens, () => {
|
|
941
|
+
this.buffer.indentLines(
|
|
942
|
+
startLine,
|
|
943
|
+
endLine,
|
|
944
|
+
detectIndentUnit(this.buffer.lines),
|
|
945
|
+
operator === ">" ? 1 : -1,
|
|
946
|
+
);
|
|
947
|
+
});
|
|
948
|
+
this.#clearSelection();
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
case "~": {
|
|
952
|
+
await this.#applyAtomicChange(tokens, () => {
|
|
953
|
+
const original = this.buffer.getText().slice(visual.start, visual.end);
|
|
954
|
+
this.buffer.replaceOffsets(visual.start, visual.end, toggleCase(original), visual.start);
|
|
955
|
+
});
|
|
956
|
+
this.#clearSelection();
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
default:
|
|
960
|
+
throw new VimError(`Unsupported visual operator: ${operator}`);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
async #executeNormal(tokens: readonly VimKeyToken[], index: number): Promise<number> {
|
|
965
|
+
const { count, hasCount, nextIndex } = this.#readCount(tokens, index);
|
|
966
|
+
const token = tokens[nextIndex];
|
|
967
|
+
if (!token) {
|
|
968
|
+
return nextIndex;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
switch (token.value) {
|
|
972
|
+
case "h":
|
|
973
|
+
this.buffer.setCursor({ line: this.buffer.cursor.line, col: this.buffer.cursor.col - count });
|
|
974
|
+
return nextIndex + 1;
|
|
975
|
+
case "j":
|
|
976
|
+
this.buffer.setCursor({ line: this.buffer.cursor.line + count, col: this.buffer.cursor.col });
|
|
977
|
+
return nextIndex + 1;
|
|
978
|
+
case "k":
|
|
979
|
+
this.buffer.setCursor({ line: this.buffer.cursor.line - count, col: this.buffer.cursor.col });
|
|
980
|
+
return nextIndex + 1;
|
|
981
|
+
case "l":
|
|
982
|
+
case " ":
|
|
983
|
+
this.buffer.setCursor({ line: this.buffer.cursor.line, col: this.buffer.cursor.col + count });
|
|
984
|
+
return nextIndex + 1;
|
|
985
|
+
case "w":
|
|
986
|
+
case "W":
|
|
987
|
+
case "b":
|
|
988
|
+
case "B":
|
|
989
|
+
case "e":
|
|
990
|
+
case "E":
|
|
991
|
+
case "0":
|
|
992
|
+
case "$":
|
|
993
|
+
case "^":
|
|
994
|
+
case "|":
|
|
995
|
+
case ";":
|
|
996
|
+
case ",":
|
|
997
|
+
case "G":
|
|
998
|
+
case "f":
|
|
999
|
+
case "F":
|
|
1000
|
+
case "t":
|
|
1001
|
+
case "T":
|
|
1002
|
+
case "{":
|
|
1003
|
+
case "}":
|
|
1004
|
+
case "%":
|
|
1005
|
+
case "H":
|
|
1006
|
+
case "M":
|
|
1007
|
+
case "+":
|
|
1008
|
+
case "-":
|
|
1009
|
+
case "_":
|
|
1010
|
+
case "L": {
|
|
1011
|
+
const motion = this.#resolveMotion(tokens, nextIndex, count, hasCount);
|
|
1012
|
+
this.buffer.setCursor(motion.target);
|
|
1013
|
+
return motion.nextIndex;
|
|
1014
|
+
}
|
|
1015
|
+
case "*":
|
|
1016
|
+
case "#": {
|
|
1017
|
+
const text = this.buffer.getText();
|
|
1018
|
+
const offset = this.buffer.currentOffset();
|
|
1019
|
+
const cat = wordCategory(text[offset] ?? "", false);
|
|
1020
|
+
if (cat === "space") {
|
|
1021
|
+
throw new VimError("No word under cursor", token);
|
|
1022
|
+
}
|
|
1023
|
+
let start = offset;
|
|
1024
|
+
while (start > 0 && wordCategory(text[start - 1] ?? "", false) === cat) start -= 1;
|
|
1025
|
+
let end = offset;
|
|
1026
|
+
while (end < text.length && wordCategory(text[end] ?? "", false) === cat) end += 1;
|
|
1027
|
+
const word = text.slice(start, end);
|
|
1028
|
+
const pattern = `\\b${escapeRegex(word)}\\b`;
|
|
1029
|
+
const direction = token.value === "*" ? 1 : -1;
|
|
1030
|
+
for (let step = 0; step < count; step += 1) {
|
|
1031
|
+
await this.#runSearch(pattern, direction, true);
|
|
1032
|
+
}
|
|
1033
|
+
return nextIndex + 1;
|
|
1034
|
+
}
|
|
1035
|
+
case "n":
|
|
1036
|
+
await this.#repeatSearch(this.lastSearch?.direction ?? 1, count);
|
|
1037
|
+
return nextIndex + 1;
|
|
1038
|
+
case "N":
|
|
1039
|
+
await this.#repeatSearch(((this.lastSearch?.direction ?? 1) * -1) as 1 | -1, count);
|
|
1040
|
+
return nextIndex + 1;
|
|
1041
|
+
case "/":
|
|
1042
|
+
this.inputMode = "search-forward";
|
|
1043
|
+
this.#pendingInput = "";
|
|
1044
|
+
return nextIndex + 1;
|
|
1045
|
+
case "?":
|
|
1046
|
+
this.inputMode = "search-backward";
|
|
1047
|
+
this.#pendingInput = "";
|
|
1048
|
+
return nextIndex + 1;
|
|
1049
|
+
case ":":
|
|
1050
|
+
this.inputMode = "command";
|
|
1051
|
+
this.#pendingInput = "";
|
|
1052
|
+
return nextIndex + 1;
|
|
1053
|
+
case "v":
|
|
1054
|
+
this.inputMode = "visual";
|
|
1055
|
+
this.selectionAnchor = clonePosition(this.buffer.cursor);
|
|
1056
|
+
return nextIndex + 1;
|
|
1057
|
+
case "V":
|
|
1058
|
+
this.inputMode = "visual-line";
|
|
1059
|
+
this.selectionAnchor = clonePosition(this.buffer.cursor);
|
|
1060
|
+
return nextIndex + 1;
|
|
1061
|
+
case "i":
|
|
1062
|
+
// When count > 1 (e.g. `2i`), interpret as `2Gi` — go to line N then insert.
|
|
1063
|
+
// Models confuse `Ni` with `NGi`; bare `i` with a high count is almost never intended.
|
|
1064
|
+
if (hasCount) {
|
|
1065
|
+
this.buffer.setCursor({ line: Math.min(count, this.buffer.lineCount()) - 1, col: 0 });
|
|
1066
|
+
}
|
|
1067
|
+
await this.#startInsertChange(["i"]);
|
|
1068
|
+
return nextIndex + 1;
|
|
1069
|
+
case "a":
|
|
1070
|
+
this.buffer.setCursor({ line: this.buffer.cursor.line, col: this.buffer.cursor.col + 1 });
|
|
1071
|
+
await this.#startInsertChange(["a"]);
|
|
1072
|
+
return nextIndex + 1;
|
|
1073
|
+
case "I":
|
|
1074
|
+
this.buffer.setCursor({
|
|
1075
|
+
line: this.buffer.cursor.line,
|
|
1076
|
+
col: this.buffer.firstNonBlank(this.buffer.cursor.line),
|
|
1077
|
+
});
|
|
1078
|
+
await this.#startInsertChange(["I"]);
|
|
1079
|
+
return nextIndex + 1;
|
|
1080
|
+
case "A":
|
|
1081
|
+
this.buffer.setCursor({
|
|
1082
|
+
line: this.buffer.cursor.line,
|
|
1083
|
+
col: this.buffer.getLine(this.buffer.cursor.line).length,
|
|
1084
|
+
});
|
|
1085
|
+
await this.#startInsertChange(["A"]);
|
|
1086
|
+
return nextIndex + 1;
|
|
1087
|
+
case "o":
|
|
1088
|
+
// When count > 1 (e.g. `13o`), interpret as `13Go` — go to line N then open below.
|
|
1089
|
+
// Models confuse `No` with `NGo`; bare `o` with a high count is almost never intended.
|
|
1090
|
+
if (hasCount) {
|
|
1091
|
+
this.buffer.setCursor({ line: Math.min(count, this.buffer.lineCount()) - 1, col: 0 });
|
|
1092
|
+
}
|
|
1093
|
+
await this.#startInsertChange(["o"], () => {
|
|
1094
|
+
const line = this.buffer.cursor.line + 1;
|
|
1095
|
+
this.buffer.insertLines(line, [""]);
|
|
1096
|
+
});
|
|
1097
|
+
return nextIndex + 1;
|
|
1098
|
+
case "O":
|
|
1099
|
+
if (hasCount) {
|
|
1100
|
+
this.buffer.setCursor({ line: Math.min(count, this.buffer.lineCount()) - 1, col: 0 });
|
|
1101
|
+
}
|
|
1102
|
+
await this.#startInsertChange(["O"], () => {
|
|
1103
|
+
const line = this.buffer.cursor.line;
|
|
1104
|
+
this.buffer.insertLines(line, [""]);
|
|
1105
|
+
});
|
|
1106
|
+
return nextIndex + 1;
|
|
1107
|
+
case "s":
|
|
1108
|
+
await this.#startInsertChange(["s"], () => {
|
|
1109
|
+
const start = this.buffer.currentOffset();
|
|
1110
|
+
this.register = {
|
|
1111
|
+
kind: "char",
|
|
1112
|
+
text: this.buffer.deleteOffsets(start, Math.min(this.buffer.getText().length, start + count)),
|
|
1113
|
+
};
|
|
1114
|
+
});
|
|
1115
|
+
return nextIndex + 1;
|
|
1116
|
+
case "S":
|
|
1117
|
+
await this.#changeWholeLines(count, ["S"]);
|
|
1118
|
+
return nextIndex + 1;
|
|
1119
|
+
case "x":
|
|
1120
|
+
await this.#applyAtomicChange(["x"], () => {
|
|
1121
|
+
const start = this.buffer.currentOffset();
|
|
1122
|
+
this.register = {
|
|
1123
|
+
kind: "char",
|
|
1124
|
+
text: this.buffer.deleteOffsets(start, Math.min(this.buffer.getText().length, start + count)),
|
|
1125
|
+
};
|
|
1126
|
+
});
|
|
1127
|
+
return nextIndex + 1;
|
|
1128
|
+
case "X":
|
|
1129
|
+
await this.#applyAtomicChange(["X"], () => {
|
|
1130
|
+
const end = this.buffer.currentOffset();
|
|
1131
|
+
const start = Math.max(0, end - count);
|
|
1132
|
+
this.register = { kind: "char", text: this.buffer.deleteOffsets(start, end) };
|
|
1133
|
+
});
|
|
1134
|
+
return nextIndex + 1;
|
|
1135
|
+
case "r": {
|
|
1136
|
+
const replacement = tokens[nextIndex + 1];
|
|
1137
|
+
if (!replacement || replacement.value.length !== 1) {
|
|
1138
|
+
throw new VimError("r requires a replacement character", token);
|
|
1139
|
+
}
|
|
1140
|
+
await this.#applyAtomicChange(["r", replacement.value], () => {
|
|
1141
|
+
const start = this.buffer.currentOffset();
|
|
1142
|
+
this.buffer.replaceOffsets(
|
|
1143
|
+
start,
|
|
1144
|
+
Math.min(this.buffer.getText().length, start + count),
|
|
1145
|
+
replacement.value.repeat(count),
|
|
1146
|
+
start,
|
|
1147
|
+
);
|
|
1148
|
+
});
|
|
1149
|
+
return nextIndex + 2;
|
|
1150
|
+
}
|
|
1151
|
+
case "~":
|
|
1152
|
+
await this.#applyAtomicChange(["~"], () => {
|
|
1153
|
+
const start = this.buffer.currentOffset();
|
|
1154
|
+
const end = Math.min(this.buffer.getText().length, start + count);
|
|
1155
|
+
const text = this.buffer.getText().slice(start, end);
|
|
1156
|
+
this.buffer.replaceOffsets(start, end, toggleCase(text), end);
|
|
1157
|
+
});
|
|
1158
|
+
return nextIndex + 1;
|
|
1159
|
+
case "J":
|
|
1160
|
+
await this.#applyAtomicChange(["J"], () => {
|
|
1161
|
+
this.buffer.joinLines(this.buffer.cursor.line, count);
|
|
1162
|
+
});
|
|
1163
|
+
return nextIndex + 1;
|
|
1164
|
+
case "p":
|
|
1165
|
+
case "P":
|
|
1166
|
+
await this.#applyAtomicChange([token.value], () => {
|
|
1167
|
+
this.#paste(token.value === "p", count);
|
|
1168
|
+
});
|
|
1169
|
+
return nextIndex + 1;
|
|
1170
|
+
case "u":
|
|
1171
|
+
await this.#undo(count);
|
|
1172
|
+
return nextIndex + 1;
|
|
1173
|
+
case "C-r":
|
|
1174
|
+
await this.#redo(count);
|
|
1175
|
+
return nextIndex + 1;
|
|
1176
|
+
case ".":
|
|
1177
|
+
await this.#repeatLastChange(count, token);
|
|
1178
|
+
return nextIndex + 1;
|
|
1179
|
+
case "d":
|
|
1180
|
+
case "c":
|
|
1181
|
+
case "y":
|
|
1182
|
+
case ">":
|
|
1183
|
+
case "<":
|
|
1184
|
+
return this.#executeOperator(tokens, nextIndex, count, hasCount, token.value);
|
|
1185
|
+
case "D":
|
|
1186
|
+
await this.#applyAtomicChange(["D"], () => {
|
|
1187
|
+
const start = this.buffer.currentOffset();
|
|
1188
|
+
const line = this.buffer.getLine(this.buffer.cursor.line);
|
|
1189
|
+
const end = start + (line.length - this.buffer.cursor.col);
|
|
1190
|
+
this.register = { kind: "char", text: this.buffer.deleteOffsets(start, end) };
|
|
1191
|
+
});
|
|
1192
|
+
return nextIndex + 1;
|
|
1193
|
+
case "C":
|
|
1194
|
+
await this.#startInsertChange(["C"], () => {
|
|
1195
|
+
const start = this.buffer.currentOffset();
|
|
1196
|
+
const line = this.buffer.getLine(this.buffer.cursor.line);
|
|
1197
|
+
const end = start + (line.length - this.buffer.cursor.col);
|
|
1198
|
+
this.register = { kind: "char", text: this.buffer.deleteOffsets(start, end) };
|
|
1199
|
+
});
|
|
1200
|
+
return nextIndex + 1;
|
|
1201
|
+
case "z": {
|
|
1202
|
+
const zTarget = tokens[nextIndex + 1];
|
|
1203
|
+
if (!zTarget) {
|
|
1204
|
+
throw new VimError("z requires a second key", token);
|
|
1205
|
+
}
|
|
1206
|
+
if (zTarget.value === "z") {
|
|
1207
|
+
this.centerViewportOnCursor();
|
|
1208
|
+
} else if (zTarget.value === "t" || zTarget.value === "CR") {
|
|
1209
|
+
this.viewportStart = this.buffer.cursor.line + 1;
|
|
1210
|
+
this.buffer.setCursor({
|
|
1211
|
+
line: this.buffer.cursor.line,
|
|
1212
|
+
col: this.buffer.firstNonBlank(this.buffer.cursor.line),
|
|
1213
|
+
});
|
|
1214
|
+
} else if (zTarget.value === "b" || zTarget.value === "-") {
|
|
1215
|
+
this.viewportStart = Math.max(1, this.buffer.cursor.line + 1 - (DEFAULT_VIEWPORT_HEIGHT - 1));
|
|
1216
|
+
this.buffer.setCursor({
|
|
1217
|
+
line: this.buffer.cursor.line,
|
|
1218
|
+
col: this.buffer.firstNonBlank(this.buffer.cursor.line),
|
|
1219
|
+
});
|
|
1220
|
+
} else if (zTarget.value === ".") {
|
|
1221
|
+
this.centerViewportOnCursor();
|
|
1222
|
+
this.buffer.setCursor({
|
|
1223
|
+
line: this.buffer.cursor.line,
|
|
1224
|
+
col: this.buffer.firstNonBlank(this.buffer.cursor.line),
|
|
1225
|
+
});
|
|
1226
|
+
} else if (NOOP_Z_COMMANDS.has(zTarget.value)) {
|
|
1227
|
+
this.statusMessage = `Ignored z${zTarget.display} (folds unsupported)`;
|
|
1228
|
+
} else {
|
|
1229
|
+
throw new VimError(`Unsupported z command: z${zTarget.display}`, zTarget);
|
|
1230
|
+
}
|
|
1231
|
+
return nextIndex + 2;
|
|
1232
|
+
}
|
|
1233
|
+
case "C-f":
|
|
1234
|
+
this.buffer.setCursor({
|
|
1235
|
+
line: this.buffer.cursor.line + Math.max(1, (DEFAULT_VIEWPORT_HEIGHT - 2) * count),
|
|
1236
|
+
col: this.buffer.cursor.col,
|
|
1237
|
+
});
|
|
1238
|
+
return nextIndex + 1;
|
|
1239
|
+
case "C-b":
|
|
1240
|
+
this.buffer.setCursor({
|
|
1241
|
+
line: this.buffer.cursor.line - Math.max(1, (DEFAULT_VIEWPORT_HEIGHT - 2) * count),
|
|
1242
|
+
col: this.buffer.cursor.col,
|
|
1243
|
+
});
|
|
1244
|
+
return nextIndex + 1;
|
|
1245
|
+
case "C-d":
|
|
1246
|
+
this.buffer.setCursor({
|
|
1247
|
+
line: this.buffer.cursor.line + Math.max(1, Math.floor(DEFAULT_VIEWPORT_HEIGHT / 2) * count),
|
|
1248
|
+
col: this.buffer.cursor.col,
|
|
1249
|
+
});
|
|
1250
|
+
return nextIndex + 1;
|
|
1251
|
+
case "C-u":
|
|
1252
|
+
this.buffer.setCursor({
|
|
1253
|
+
line: this.buffer.cursor.line - Math.max(1, Math.floor(DEFAULT_VIEWPORT_HEIGHT / 2) * count),
|
|
1254
|
+
col: this.buffer.cursor.col,
|
|
1255
|
+
});
|
|
1256
|
+
return nextIndex + 1;
|
|
1257
|
+
case "Esc":
|
|
1258
|
+
return nextIndex + 1;
|
|
1259
|
+
case "Y": {
|
|
1260
|
+
const start = this.buffer.cursor.line;
|
|
1261
|
+
const end = this.buffer.clampLine(start + count - 1);
|
|
1262
|
+
this.register = { kind: "line", text: this.buffer.lines.slice(start, end + 1).join("\n") };
|
|
1263
|
+
this.statusMessage = `Yanked ${end - start + 1} line${end === start ? "" : "s"}`;
|
|
1264
|
+
return nextIndex + 1;
|
|
1265
|
+
}
|
|
1266
|
+
case "R":
|
|
1267
|
+
await this.#startInsertChange(["R"], undefined, false);
|
|
1268
|
+
return nextIndex + 1;
|
|
1269
|
+
case "g": {
|
|
1270
|
+
const gNext = tokens[nextIndex + 1];
|
|
1271
|
+
if (!gNext) {
|
|
1272
|
+
throw new VimError("g requires a second key", token);
|
|
1273
|
+
}
|
|
1274
|
+
if (gNext.value === "g") {
|
|
1275
|
+
this.buffer.setCursor({ line: hasCount ? Math.max(0, count - 1) : 0, col: 0 });
|
|
1276
|
+
return nextIndex + 2;
|
|
1277
|
+
}
|
|
1278
|
+
if (gNext.value === "v") {
|
|
1279
|
+
if (this.lastVisual) {
|
|
1280
|
+
this.selectionAnchor = clonePosition(this.lastVisual.anchor);
|
|
1281
|
+
this.buffer.setCursor(this.lastVisual.cursor);
|
|
1282
|
+
this.inputMode = this.lastVisual.mode;
|
|
1283
|
+
}
|
|
1284
|
+
return nextIndex + 2;
|
|
1285
|
+
}
|
|
1286
|
+
if (gNext.value === "*" || gNext.value === "#") {
|
|
1287
|
+
const text = this.buffer.getText();
|
|
1288
|
+
const offset = this.buffer.currentOffset();
|
|
1289
|
+
const cat = wordCategory(text[offset] ?? "", false);
|
|
1290
|
+
if (cat === "space") {
|
|
1291
|
+
throw new VimError("No word under cursor", gNext);
|
|
1292
|
+
}
|
|
1293
|
+
let start = offset;
|
|
1294
|
+
while (start > 0 && wordCategory(text[start - 1] ?? "", false) === cat) start -= 1;
|
|
1295
|
+
let end = offset;
|
|
1296
|
+
while (end < text.length && wordCategory(text[end] ?? "", false) === cat) end += 1;
|
|
1297
|
+
const word = text.slice(start, end);
|
|
1298
|
+
const direction = gNext.value === "*" ? 1 : -1;
|
|
1299
|
+
for (let step = 0; step < count; step += 1) {
|
|
1300
|
+
await this.#runSearch(escapeRegex(word), direction, true);
|
|
1301
|
+
}
|
|
1302
|
+
return nextIndex + 2;
|
|
1303
|
+
}
|
|
1304
|
+
if (gNext.value === "U" || gNext.value === "u") {
|
|
1305
|
+
const caseOp = gNext.value;
|
|
1306
|
+
const {
|
|
1307
|
+
count: motionCount,
|
|
1308
|
+
hasCount: hasMotionCount,
|
|
1309
|
+
nextIndex: motionStart,
|
|
1310
|
+
} = this.#readCount(tokens, nextIndex + 2);
|
|
1311
|
+
const motionToken = tokens[motionStart];
|
|
1312
|
+
if (!motionToken) {
|
|
1313
|
+
throw new VimError(`g${caseOp} requires a motion`, gNext);
|
|
1314
|
+
}
|
|
1315
|
+
if ((motionToken.value === "U" && caseOp === "U") || (motionToken.value === "u" && caseOp === "u")) {
|
|
1316
|
+
const effectiveCount = hasMotionCount ? count * motionCount : count;
|
|
1317
|
+
await this.#applyAtomicChange(["g", caseOp, motionToken.value], () => {
|
|
1318
|
+
const start = this.buffer.cursor.line;
|
|
1319
|
+
const end = this.buffer.clampLine(start + effectiveCount - 1);
|
|
1320
|
+
for (let line = start; line <= end; line++) {
|
|
1321
|
+
const content = this.buffer.getLine(line);
|
|
1322
|
+
this.buffer.replaceLine(line, caseOp === "U" ? content.toUpperCase() : content.toLowerCase());
|
|
1323
|
+
}
|
|
1324
|
+
});
|
|
1325
|
+
return motionStart + 1;
|
|
1326
|
+
}
|
|
1327
|
+
const effectiveCount = hasMotionCount ? count * motionCount : count;
|
|
1328
|
+
const motion = this.#resolveMotion(tokens, motionStart, effectiveCount, hasCount || hasMotionCount);
|
|
1329
|
+
const range = this.#resolveMotionRange(motion);
|
|
1330
|
+
await this.#applyAtomicChange(
|
|
1331
|
+
tokens.slice(nextIndex, motion.nextIndex).map(tokenEntry => tokenEntry.value),
|
|
1332
|
+
() => {
|
|
1333
|
+
const text = this.buffer.getText();
|
|
1334
|
+
const slice = text.slice(range.start, range.end);
|
|
1335
|
+
const transformed = caseOp === "U" ? slice.toUpperCase() : slice.toLowerCase();
|
|
1336
|
+
this.buffer.replaceOffsets(range.start, range.end, transformed, range.start);
|
|
1337
|
+
},
|
|
1338
|
+
);
|
|
1339
|
+
return motion.nextIndex;
|
|
1340
|
+
}
|
|
1341
|
+
if (gNext.value === "~") {
|
|
1342
|
+
const {
|
|
1343
|
+
count: motionCount,
|
|
1344
|
+
hasCount: hasMotionCount,
|
|
1345
|
+
nextIndex: motionStart,
|
|
1346
|
+
} = this.#readCount(tokens, nextIndex + 2);
|
|
1347
|
+
const motionToken = tokens[motionStart];
|
|
1348
|
+
if (!motionToken) {
|
|
1349
|
+
throw new VimError("g~ requires a motion", gNext);
|
|
1350
|
+
}
|
|
1351
|
+
if (motionToken.value === "~") {
|
|
1352
|
+
const effectiveCount = hasMotionCount ? count * motionCount : count;
|
|
1353
|
+
await this.#applyAtomicChange(["g", "~", motionToken.value], () => {
|
|
1354
|
+
const start = this.buffer.cursor.line;
|
|
1355
|
+
const end = this.buffer.clampLine(start + effectiveCount - 1);
|
|
1356
|
+
for (let line = start; line <= end; line += 1) {
|
|
1357
|
+
this.buffer.replaceLine(line, toggleCase(this.buffer.getLine(line)));
|
|
1358
|
+
}
|
|
1359
|
+
});
|
|
1360
|
+
return motionStart + 1;
|
|
1361
|
+
}
|
|
1362
|
+
const effectiveCount = hasMotionCount ? count * motionCount : count;
|
|
1363
|
+
const motion = this.#resolveMotion(tokens, motionStart, effectiveCount, hasCount || hasMotionCount);
|
|
1364
|
+
const range = this.#resolveMotionRange(motion);
|
|
1365
|
+
await this.#applyAtomicChange(
|
|
1366
|
+
tokens.slice(nextIndex, motion.nextIndex).map(tokenEntry => tokenEntry.value),
|
|
1367
|
+
() => {
|
|
1368
|
+
const text = this.buffer.getText();
|
|
1369
|
+
const slice = text.slice(range.start, range.end);
|
|
1370
|
+
this.buffer.replaceOffsets(range.start, range.end, toggleCase(slice), range.start);
|
|
1371
|
+
},
|
|
1372
|
+
);
|
|
1373
|
+
return motion.nextIndex;
|
|
1374
|
+
}
|
|
1375
|
+
if (gNext.value === "J") {
|
|
1376
|
+
await this.#applyAtomicChange(["g", "J"], () => {
|
|
1377
|
+
const start = this.buffer.clampLine(this.buffer.cursor.line);
|
|
1378
|
+
const end = this.buffer.clampLine(start + Math.max(count, 1));
|
|
1379
|
+
if (start < end) {
|
|
1380
|
+
const joined = this.buffer.lines.slice(start, end + 1).join("");
|
|
1381
|
+
this.buffer.lines.splice(start, end - start + 1, joined);
|
|
1382
|
+
this.buffer.setCursor({ line: start, col: Math.max(0, joined.length - 1) });
|
|
1383
|
+
}
|
|
1384
|
+
});
|
|
1385
|
+
return nextIndex + 2;
|
|
1386
|
+
}
|
|
1387
|
+
throw new VimError(`Unsupported g command: g${gNext.display}`, gNext);
|
|
1388
|
+
}
|
|
1389
|
+
case "Z": {
|
|
1390
|
+
const zNext = tokens[nextIndex + 1];
|
|
1391
|
+
if (!zNext) {
|
|
1392
|
+
throw new VimError("Z requires a second key", token);
|
|
1393
|
+
}
|
|
1394
|
+
if (zNext.value === "Z") {
|
|
1395
|
+
await this.#executeEx("wq");
|
|
1396
|
+
return nextIndex + 2;
|
|
1397
|
+
}
|
|
1398
|
+
if (zNext.value === "Q") {
|
|
1399
|
+
await this.#executeEx("q!");
|
|
1400
|
+
return nextIndex + 2;
|
|
1401
|
+
}
|
|
1402
|
+
throw new VimError(`Unsupported Z command: Z${zNext.display}`, zNext);
|
|
1403
|
+
}
|
|
1404
|
+
default:
|
|
1405
|
+
throw new VimError(`Unsupported command: ${token.display}`, token);
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
async #repeatLastChange(count: number, token: VimKeyToken): Promise<void> {
|
|
1410
|
+
if (!this.#lastChangeTokens || this.#lastChangeTokens.length === 0) {
|
|
1411
|
+
throw new VimError("No previous change to repeat", token);
|
|
1412
|
+
}
|
|
1413
|
+
for (let index = 0; index < count; index += 1) {
|
|
1414
|
+
await this.executeTokens(replayTokens(this.#lastChangeTokens), ".");
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
async #undo(count: number): Promise<void> {
|
|
1419
|
+
await this.#ensureEditable();
|
|
1420
|
+
let applied = 0;
|
|
1421
|
+
for (let index = 0; index < count; index += 1) {
|
|
1422
|
+
const entry = this.#undoStack.pop();
|
|
1423
|
+
if (!entry) {
|
|
1424
|
+
break;
|
|
1425
|
+
}
|
|
1426
|
+
this.#redoStack.push(entry);
|
|
1427
|
+
this.buffer.restore(entry.before);
|
|
1428
|
+
applied += 1;
|
|
1429
|
+
}
|
|
1430
|
+
this.inputMode = "normal";
|
|
1431
|
+
this.selectionAnchor = null;
|
|
1432
|
+
this.#pendingChange = null;
|
|
1433
|
+
this.statusMessage = `Undid ${applied} change${applied === 1 ? "" : "s"}`;
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
async #redo(count: number): Promise<void> {
|
|
1437
|
+
await this.#ensureEditable();
|
|
1438
|
+
let applied = 0;
|
|
1439
|
+
for (let index = 0; index < count; index += 1) {
|
|
1440
|
+
const entry = this.#redoStack.pop();
|
|
1441
|
+
if (!entry) {
|
|
1442
|
+
break;
|
|
1443
|
+
}
|
|
1444
|
+
this.#undoStack.push(entry);
|
|
1445
|
+
this.buffer.restore(entry.after);
|
|
1446
|
+
applied += 1;
|
|
1447
|
+
}
|
|
1448
|
+
this.inputMode = "normal";
|
|
1449
|
+
this.selectionAnchor = null;
|
|
1450
|
+
this.#pendingChange = null;
|
|
1451
|
+
this.statusMessage = `Redid ${applied} change${applied === 1 ? "" : "s"}`;
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
async #executeOperator(
|
|
1455
|
+
tokens: readonly VimKeyToken[],
|
|
1456
|
+
operatorIndex: number,
|
|
1457
|
+
operatorCount: number,
|
|
1458
|
+
hasOperatorCount: boolean,
|
|
1459
|
+
operator: string,
|
|
1460
|
+
): Promise<number> {
|
|
1461
|
+
const { count: motionCount, hasCount: hasMotionCount, nextIndex } = this.#readCount(tokens, operatorIndex + 1);
|
|
1462
|
+
const token = tokens[nextIndex];
|
|
1463
|
+
if (!token) {
|
|
1464
|
+
throw new VimError(`Operator ${operator} requires a motion`, tokens[operatorIndex]);
|
|
1465
|
+
}
|
|
1466
|
+
const hasAnyCount = hasOperatorCount || hasMotionCount;
|
|
1467
|
+
const effectiveCount = hasMotionCount ? operatorCount * motionCount : operatorCount;
|
|
1468
|
+
|
|
1469
|
+
if (token.value === operator) {
|
|
1470
|
+
if (operator === "d") {
|
|
1471
|
+
await this.#applyAtomicChange([operator, operator], () => {
|
|
1472
|
+
const start = this.buffer.cursor.line;
|
|
1473
|
+
const removed = this.buffer.deleteLines(start, start + Math.max(1, effectiveCount) - 1);
|
|
1474
|
+
this.register = { kind: "line", text: removed.join("\n") };
|
|
1475
|
+
});
|
|
1476
|
+
return nextIndex + 1;
|
|
1477
|
+
}
|
|
1478
|
+
if (operator === "y") {
|
|
1479
|
+
const start = this.buffer.cursor.line;
|
|
1480
|
+
const end = this.buffer.clampLine(start + Math.max(1, effectiveCount) - 1);
|
|
1481
|
+
this.register = { kind: "line", text: this.buffer.lines.slice(start, end + 1).join("\n") };
|
|
1482
|
+
this.statusMessage = `Yanked ${end - start + 1} line${end === start ? "" : "s"}`;
|
|
1483
|
+
return nextIndex + 1;
|
|
1484
|
+
}
|
|
1485
|
+
if (operator === "c") {
|
|
1486
|
+
await this.#changeWholeLines(Math.max(1, effectiveCount), [operator, operator]);
|
|
1487
|
+
return nextIndex + 1;
|
|
1488
|
+
}
|
|
1489
|
+
if (operator === ">" || operator === "<") {
|
|
1490
|
+
await this.#applyAtomicChange([operator, operator], () => {
|
|
1491
|
+
this.buffer.indentLines(
|
|
1492
|
+
this.buffer.cursor.line,
|
|
1493
|
+
this.buffer.cursor.line + Math.max(1, effectiveCount) - 1,
|
|
1494
|
+
detectIndentUnit(this.buffer.lines),
|
|
1495
|
+
operator === ">" ? 1 : -1,
|
|
1496
|
+
);
|
|
1497
|
+
});
|
|
1498
|
+
return nextIndex + 1;
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
if (token.value === "i" || token.value === "a") {
|
|
1503
|
+
const object = tokens[nextIndex + 1];
|
|
1504
|
+
if (!object) {
|
|
1505
|
+
throw new VimError(`Missing text object after ${operator}${token.value}`, token);
|
|
1506
|
+
}
|
|
1507
|
+
const textObject = this.#resolveTextObject(token.value === "i", object.value, object);
|
|
1508
|
+
await this.#applyOperatorToMotion(
|
|
1509
|
+
operator,
|
|
1510
|
+
{ nextIndex: nextIndex + 2, target: this.buffer.cursor, range: textObject },
|
|
1511
|
+
[operator, token.value, object.value],
|
|
1512
|
+
);
|
|
1513
|
+
return nextIndex + 2;
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
// In vim, `cw` and `cW` act like `ce` and `cE` (don't include trailing whitespace)
|
|
1517
|
+
const motionToken = tokens[nextIndex];
|
|
1518
|
+
let motion: MotionResult;
|
|
1519
|
+
if (operator === "c" && motionToken && (motionToken.value === "w" || motionToken.value === "W")) {
|
|
1520
|
+
const eMotionValue = motionToken.value === "w" ? "e" : "E";
|
|
1521
|
+
const syntheticTokens: readonly VimKeyToken[] = [
|
|
1522
|
+
...tokens.slice(0, nextIndex),
|
|
1523
|
+
{ ...motionToken, value: eMotionValue },
|
|
1524
|
+
...tokens.slice(nextIndex + 1),
|
|
1525
|
+
];
|
|
1526
|
+
motion = this.#resolveMotion(syntheticTokens, nextIndex, effectiveCount, hasAnyCount);
|
|
1527
|
+
} else {
|
|
1528
|
+
motion = this.#resolveMotion(tokens, nextIndex, effectiveCount, hasAnyCount);
|
|
1529
|
+
}
|
|
1530
|
+
await this.#applyOperatorToMotion(
|
|
1531
|
+
operator,
|
|
1532
|
+
motion,
|
|
1533
|
+
tokens.slice(operatorIndex, motion.nextIndex).map(tokenEntry => tokenEntry.value),
|
|
1534
|
+
);
|
|
1535
|
+
return motion.nextIndex;
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
async #applyOperatorToMotion(operator: string, motion: MotionResult, tokens: readonly string[]): Promise<void> {
|
|
1539
|
+
if (operator === "y") {
|
|
1540
|
+
const range = this.#resolveMotionRange(motion);
|
|
1541
|
+
this.register = {
|
|
1542
|
+
kind: range.linewise ? "line" : "char",
|
|
1543
|
+
text: this.buffer.getText().slice(range.start, range.end),
|
|
1544
|
+
};
|
|
1545
|
+
this.statusMessage = `Yanked ${range.linewise ? "line" : "selection"}`;
|
|
1546
|
+
return;
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
if (operator === ">" || operator === "<") {
|
|
1550
|
+
const range = this.#resolveMotionRange(motion);
|
|
1551
|
+
const startLine = this.buffer.offsetToPosition(range.start).line;
|
|
1552
|
+
const endLine = this.buffer.offsetToPosition(Math.max(range.start, range.end - 1)).line;
|
|
1553
|
+
await this.#applyAtomicChange(tokens, () => {
|
|
1554
|
+
this.buffer.indentLines(startLine, endLine, detectIndentUnit(this.buffer.lines), operator === ">" ? 1 : -1);
|
|
1555
|
+
});
|
|
1556
|
+
return;
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
if (operator === "d") {
|
|
1560
|
+
const range = this.#resolveMotionRange(motion);
|
|
1561
|
+
await this.#applyAtomicChange(tokens, () => {
|
|
1562
|
+
this.register = {
|
|
1563
|
+
kind: range.linewise ? "line" : "char",
|
|
1564
|
+
text: this.buffer.getText().slice(range.start, range.end),
|
|
1565
|
+
};
|
|
1566
|
+
this.buffer.deleteOffsets(range.start, range.end);
|
|
1567
|
+
});
|
|
1568
|
+
return;
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
if (operator === "c") {
|
|
1572
|
+
const range = this.#resolveMotionRange(motion);
|
|
1573
|
+
await this.#startInsertChange(tokens, () => {
|
|
1574
|
+
this.register = {
|
|
1575
|
+
kind: range.linewise ? "line" : "char",
|
|
1576
|
+
text: this.buffer.getText().slice(range.start, range.end),
|
|
1577
|
+
};
|
|
1578
|
+
this.buffer.deleteOffsets(range.start, range.end);
|
|
1579
|
+
});
|
|
1580
|
+
return;
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
async #changeWholeLines(count: number, tokens: readonly string[]): Promise<void> {
|
|
1585
|
+
await this.#startInsertChange(tokens, () => {
|
|
1586
|
+
const start = this.buffer.cursor.line;
|
|
1587
|
+
const end = this.buffer.clampLine(start + count - 1);
|
|
1588
|
+
const removed = this.buffer.lines.slice(start, end + 1);
|
|
1589
|
+
this.register = { kind: "line", text: removed.join("\n") };
|
|
1590
|
+
this.buffer.lines.splice(start, end - start + 1, "");
|
|
1591
|
+
if (this.buffer.lines.length === 0) {
|
|
1592
|
+
this.buffer.lines = [""];
|
|
1593
|
+
}
|
|
1594
|
+
this.buffer.setCursor({ line: Math.min(start, this.buffer.lastLineIndex()), col: 0 });
|
|
1595
|
+
});
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
#resolveMotionRange(motion: MotionResult): { start: number; end: number; linewise: boolean } {
|
|
1599
|
+
if (motion.range) {
|
|
1600
|
+
return {
|
|
1601
|
+
start: motion.range.start,
|
|
1602
|
+
end: motion.range.end,
|
|
1603
|
+
linewise: motion.range.linewise ?? false,
|
|
1604
|
+
};
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
if (motion.linewise) {
|
|
1608
|
+
const startLine = Math.min(this.buffer.cursor.line, motion.target.line);
|
|
1609
|
+
const endLine = Math.max(this.buffer.cursor.line, motion.target.line);
|
|
1610
|
+
const start = this.buffer.positionToOffset({ line: startLine, col: 0 });
|
|
1611
|
+
const end =
|
|
1612
|
+
endLine >= this.buffer.lastLineIndex()
|
|
1613
|
+
? this.buffer.getText().length
|
|
1614
|
+
: this.buffer.positionToOffset({ line: endLine + 1, col: 0 });
|
|
1615
|
+
return { start, end, linewise: true };
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
const from = this.buffer.positionToOffset(this.buffer.cursor);
|
|
1619
|
+
const to = this.buffer.positionToOffset(motion.target);
|
|
1620
|
+
const normalized = normalizeRange(from, to);
|
|
1621
|
+
return {
|
|
1622
|
+
start: normalized.start,
|
|
1623
|
+
end: normalized.end + (motion.inclusive === false ? 0 : 1),
|
|
1624
|
+
linewise: false,
|
|
1625
|
+
};
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
#resolveMotion(tokens: readonly VimKeyToken[], index: number, count: number, hasCount = true): MotionResult {
|
|
1629
|
+
const token = tokens[index];
|
|
1630
|
+
if (!token) {
|
|
1631
|
+
throw new VimError("Missing motion");
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
const text = this.buffer.getText();
|
|
1635
|
+
switch (token.value) {
|
|
1636
|
+
case "h":
|
|
1637
|
+
return {
|
|
1638
|
+
nextIndex: index + 1,
|
|
1639
|
+
target: { line: this.buffer.cursor.line, col: this.buffer.cursor.col - count },
|
|
1640
|
+
};
|
|
1641
|
+
case "j":
|
|
1642
|
+
return {
|
|
1643
|
+
nextIndex: index + 1,
|
|
1644
|
+
target: { line: this.buffer.cursor.line + count, col: this.buffer.cursor.col },
|
|
1645
|
+
linewise: true,
|
|
1646
|
+
};
|
|
1647
|
+
case "k":
|
|
1648
|
+
return {
|
|
1649
|
+
nextIndex: index + 1,
|
|
1650
|
+
target: { line: this.buffer.cursor.line - count, col: this.buffer.cursor.col },
|
|
1651
|
+
linewise: true,
|
|
1652
|
+
};
|
|
1653
|
+
case "l":
|
|
1654
|
+
case " ":
|
|
1655
|
+
return {
|
|
1656
|
+
nextIndex: index + 1,
|
|
1657
|
+
target: { line: this.buffer.cursor.line, col: this.buffer.cursor.col + count },
|
|
1658
|
+
};
|
|
1659
|
+
case "w":
|
|
1660
|
+
case "W": {
|
|
1661
|
+
let offset = this.buffer.currentOffset();
|
|
1662
|
+
for (let step = 0; step < count; step += 1) {
|
|
1663
|
+
offset = nextWordStart(text, step === 0 ? offset + 1 : offset, token.value === "W");
|
|
1664
|
+
}
|
|
1665
|
+
return { nextIndex: index + 1, target: this.buffer.offsetToPosition(offset), inclusive: false };
|
|
1666
|
+
}
|
|
1667
|
+
case "b":
|
|
1668
|
+
case "B": {
|
|
1669
|
+
let offset = this.buffer.currentOffset();
|
|
1670
|
+
for (let step = 0; step < count; step += 1) {
|
|
1671
|
+
offset = previousWordStart(text, offset, token.value === "B");
|
|
1672
|
+
}
|
|
1673
|
+
return { nextIndex: index + 1, target: this.buffer.offsetToPosition(offset) };
|
|
1674
|
+
}
|
|
1675
|
+
case "e":
|
|
1676
|
+
case "E": {
|
|
1677
|
+
let offset = this.buffer.currentOffset();
|
|
1678
|
+
for (let step = 0; step < count; step += 1) {
|
|
1679
|
+
offset = endOfWord(text, step === 0 ? offset : offset + 1, token.value === "E");
|
|
1680
|
+
}
|
|
1681
|
+
return { nextIndex: index + 1, target: this.buffer.offsetToPosition(offset) };
|
|
1682
|
+
}
|
|
1683
|
+
case "0":
|
|
1684
|
+
return { nextIndex: index + 1, target: { line: this.buffer.cursor.line, col: 0 } };
|
|
1685
|
+
case "^":
|
|
1686
|
+
return {
|
|
1687
|
+
nextIndex: index + 1,
|
|
1688
|
+
target: { line: this.buffer.cursor.line, col: this.buffer.firstNonBlank(this.buffer.cursor.line) },
|
|
1689
|
+
};
|
|
1690
|
+
case "|":
|
|
1691
|
+
return {
|
|
1692
|
+
nextIndex: index + 1,
|
|
1693
|
+
target: { line: this.buffer.cursor.line, col: Math.max(0, count - 1) },
|
|
1694
|
+
};
|
|
1695
|
+
case "$":
|
|
1696
|
+
return {
|
|
1697
|
+
nextIndex: index + 1,
|
|
1698
|
+
target: {
|
|
1699
|
+
line: this.buffer.cursor.line,
|
|
1700
|
+
col: Math.max(0, this.buffer.getLine(this.buffer.cursor.line).length - 1),
|
|
1701
|
+
},
|
|
1702
|
+
};
|
|
1703
|
+
case "+": {
|
|
1704
|
+
const targetLine = this.buffer.clampLine(this.buffer.cursor.line + count);
|
|
1705
|
+
return {
|
|
1706
|
+
nextIndex: index + 1,
|
|
1707
|
+
target: { line: targetLine, col: this.buffer.firstNonBlank(targetLine) },
|
|
1708
|
+
linewise: true,
|
|
1709
|
+
};
|
|
1710
|
+
}
|
|
1711
|
+
case "-": {
|
|
1712
|
+
const targetLine = this.buffer.clampLine(this.buffer.cursor.line - count);
|
|
1713
|
+
return {
|
|
1714
|
+
nextIndex: index + 1,
|
|
1715
|
+
target: { line: targetLine, col: this.buffer.firstNonBlank(targetLine) },
|
|
1716
|
+
linewise: true,
|
|
1717
|
+
};
|
|
1718
|
+
}
|
|
1719
|
+
case "_": {
|
|
1720
|
+
const targetLine = this.buffer.clampLine(this.buffer.cursor.line + (count - 1));
|
|
1721
|
+
return {
|
|
1722
|
+
nextIndex: index + 1,
|
|
1723
|
+
target: { line: targetLine, col: this.buffer.firstNonBlank(targetLine) },
|
|
1724
|
+
linewise: true,
|
|
1725
|
+
};
|
|
1726
|
+
}
|
|
1727
|
+
case "g": {
|
|
1728
|
+
const next = tokens[index + 1];
|
|
1729
|
+
if (!next) {
|
|
1730
|
+
throw new VimError("Unsupported g motion", token);
|
|
1731
|
+
}
|
|
1732
|
+
if (next.value === "g") {
|
|
1733
|
+
return {
|
|
1734
|
+
nextIndex: index + 2,
|
|
1735
|
+
target: { line: hasCount ? Math.max(0, count - 1) : 0, col: 0 },
|
|
1736
|
+
linewise: true,
|
|
1737
|
+
};
|
|
1738
|
+
}
|
|
1739
|
+
if (next.value === "e" || next.value === "E") {
|
|
1740
|
+
let offset = this.buffer.currentOffset();
|
|
1741
|
+
for (let step = 0; step < count; step += 1) {
|
|
1742
|
+
offset = endOfPreviousWord(text, offset, next.value === "E");
|
|
1743
|
+
}
|
|
1744
|
+
return { nextIndex: index + 2, target: this.buffer.offsetToPosition(offset) };
|
|
1745
|
+
}
|
|
1746
|
+
if (next.value === "_") {
|
|
1747
|
+
const targetLine = this.buffer.clampLine(this.buffer.cursor.line + (count - 1));
|
|
1748
|
+
return {
|
|
1749
|
+
nextIndex: index + 2,
|
|
1750
|
+
target: { line: targetLine, col: lastNonBlankColumn(this.buffer.getLine(targetLine)) },
|
|
1751
|
+
};
|
|
1752
|
+
}
|
|
1753
|
+
throw new VimError("Unsupported g motion", token);
|
|
1754
|
+
}
|
|
1755
|
+
case "G":
|
|
1756
|
+
return {
|
|
1757
|
+
nextIndex: index + 1,
|
|
1758
|
+
target: { line: hasCount ? count - 1 : this.buffer.lastLineIndex(), col: 0 },
|
|
1759
|
+
linewise: true,
|
|
1760
|
+
};
|
|
1761
|
+
case "f":
|
|
1762
|
+
case "F":
|
|
1763
|
+
case "t":
|
|
1764
|
+
case "T": {
|
|
1765
|
+
const searchToken = tokens[index + 1];
|
|
1766
|
+
if (!searchToken || searchToken.value.length !== 1) {
|
|
1767
|
+
throw new VimError(`${token.value} requires a literal character`, token);
|
|
1768
|
+
}
|
|
1769
|
+
this.lastCharFind = { char: searchToken.value, mode: token.value as "f" | "F" | "t" | "T" };
|
|
1770
|
+
const line = this.buffer.getLine(this.buffer.cursor.line);
|
|
1771
|
+
const cursorCol = this.buffer.cursor.col;
|
|
1772
|
+
let matchIndex = -1;
|
|
1773
|
+
if (token.value === "f" || token.value === "t") {
|
|
1774
|
+
let start = cursorCol + 1;
|
|
1775
|
+
for (let step = 0; step < count; step += 1) {
|
|
1776
|
+
matchIndex = line.indexOf(searchToken.value, start);
|
|
1777
|
+
if (matchIndex === -1) break;
|
|
1778
|
+
start = matchIndex + 1;
|
|
1779
|
+
}
|
|
1780
|
+
if (matchIndex === -1) {
|
|
1781
|
+
throw new VimError(`Character not found: ${searchToken.value}`, searchToken);
|
|
1782
|
+
}
|
|
1783
|
+
if (token.value === "t") {
|
|
1784
|
+
matchIndex -= 1;
|
|
1785
|
+
}
|
|
1786
|
+
} else {
|
|
1787
|
+
let start = Math.max(0, cursorCol - 1);
|
|
1788
|
+
for (let step = 0; step < count; step += 1) {
|
|
1789
|
+
matchIndex = line.lastIndexOf(searchToken.value, start);
|
|
1790
|
+
if (matchIndex === -1) break;
|
|
1791
|
+
start = matchIndex - 1;
|
|
1792
|
+
}
|
|
1793
|
+
if (matchIndex === -1) {
|
|
1794
|
+
throw new VimError(`Character not found: ${searchToken.value}`, searchToken);
|
|
1795
|
+
}
|
|
1796
|
+
if (token.value === "T") {
|
|
1797
|
+
matchIndex += 1;
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
return {
|
|
1801
|
+
nextIndex: index + 2,
|
|
1802
|
+
target: { line: this.buffer.cursor.line, col: Math.max(0, matchIndex) },
|
|
1803
|
+
};
|
|
1804
|
+
}
|
|
1805
|
+
case "{":
|
|
1806
|
+
return {
|
|
1807
|
+
nextIndex: index + 1,
|
|
1808
|
+
target: { line: findParagraphStart(this.buffer.lines, this.buffer.cursor.line), col: 0 },
|
|
1809
|
+
linewise: true,
|
|
1810
|
+
};
|
|
1811
|
+
case "}":
|
|
1812
|
+
return {
|
|
1813
|
+
nextIndex: index + 1,
|
|
1814
|
+
target: { line: findParagraphEnd(this.buffer.lines, this.buffer.cursor.line), col: 0 },
|
|
1815
|
+
linewise: true,
|
|
1816
|
+
};
|
|
1817
|
+
case "%": {
|
|
1818
|
+
const match = this.#findMatchingBracket();
|
|
1819
|
+
return { nextIndex: index + 1, target: match };
|
|
1820
|
+
}
|
|
1821
|
+
case "H":
|
|
1822
|
+
return {
|
|
1823
|
+
nextIndex: index + 1,
|
|
1824
|
+
target: { line: Math.max(0, this.viewportStart - 1), col: 0 },
|
|
1825
|
+
linewise: true,
|
|
1826
|
+
};
|
|
1827
|
+
case "M":
|
|
1828
|
+
return {
|
|
1829
|
+
nextIndex: index + 1,
|
|
1830
|
+
target: { line: Math.max(0, this.viewportStart - 1 + 20), col: 0 },
|
|
1831
|
+
linewise: true,
|
|
1832
|
+
};
|
|
1833
|
+
case "L":
|
|
1834
|
+
return {
|
|
1835
|
+
nextIndex: index + 1,
|
|
1836
|
+
target: { line: Math.max(0, this.viewportStart - 1 + 39), col: 0 },
|
|
1837
|
+
linewise: true,
|
|
1838
|
+
};
|
|
1839
|
+
case ";":
|
|
1840
|
+
case ",": {
|
|
1841
|
+
if (!this.lastCharFind) {
|
|
1842
|
+
throw new VimError(
|
|
1843
|
+
"No previous character search. If you meant an ex-command range like `:4,5d`, add the `:` prefix and `<CR>` suffix.",
|
|
1844
|
+
token,
|
|
1845
|
+
);
|
|
1846
|
+
}
|
|
1847
|
+
let mode = this.lastCharFind.mode;
|
|
1848
|
+
if (token.value === ",") {
|
|
1849
|
+
const reverseMap: Record<string, "f" | "F" | "t" | "T"> = { f: "F", F: "f", t: "T", T: "t" };
|
|
1850
|
+
mode = reverseMap[mode]!;
|
|
1851
|
+
}
|
|
1852
|
+
const line = this.buffer.getLine(this.buffer.cursor.line);
|
|
1853
|
+
const cursorCol = this.buffer.cursor.col;
|
|
1854
|
+
let matchIndex = -1;
|
|
1855
|
+
if (mode === "f" || mode === "t") {
|
|
1856
|
+
let start = cursorCol + 1;
|
|
1857
|
+
for (let step = 0; step < count; step += 1) {
|
|
1858
|
+
matchIndex = line.indexOf(this.lastCharFind.char, start);
|
|
1859
|
+
if (matchIndex === -1) break;
|
|
1860
|
+
start = matchIndex + 1;
|
|
1861
|
+
}
|
|
1862
|
+
if (matchIndex !== -1 && mode === "t") matchIndex -= 1;
|
|
1863
|
+
} else {
|
|
1864
|
+
let start = Math.max(0, cursorCol - 1);
|
|
1865
|
+
for (let step = 0; step < count; step += 1) {
|
|
1866
|
+
matchIndex = line.lastIndexOf(this.lastCharFind.char, start);
|
|
1867
|
+
if (matchIndex === -1) break;
|
|
1868
|
+
start = matchIndex - 1;
|
|
1869
|
+
}
|
|
1870
|
+
if (matchIndex !== -1 && mode === "T") matchIndex += 1;
|
|
1871
|
+
}
|
|
1872
|
+
if (matchIndex === -1) {
|
|
1873
|
+
throw new VimError(`Character not found: ${this.lastCharFind.char}`, token);
|
|
1874
|
+
}
|
|
1875
|
+
return {
|
|
1876
|
+
nextIndex: index + 1,
|
|
1877
|
+
target: { line: this.buffer.cursor.line, col: Math.max(0, matchIndex) },
|
|
1878
|
+
};
|
|
1879
|
+
}
|
|
1880
|
+
default:
|
|
1881
|
+
throw new VimError(`Unsupported motion: ${token.display}`, token);
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
#resolveTextObject(
|
|
1886
|
+
inner: boolean,
|
|
1887
|
+
objectToken: string,
|
|
1888
|
+
sourceToken: VimKeyToken,
|
|
1889
|
+
): { start: number; end: number; linewise?: boolean } {
|
|
1890
|
+
if (objectToken === "w" || objectToken === "W") {
|
|
1891
|
+
return this.#resolveWordTextObject(inner, objectToken === "W");
|
|
1892
|
+
}
|
|
1893
|
+
if (objectToken === '"' || objectToken === "'" || objectToken === "`") {
|
|
1894
|
+
return this.#resolveQuoteTextObject(inner, objectToken, sourceToken);
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
if (objectToken === "p") {
|
|
1898
|
+
return this.#resolveParagraphTextObject(inner);
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
const normalized =
|
|
1902
|
+
objectToken === ")"
|
|
1903
|
+
? "("
|
|
1904
|
+
: objectToken === "}"
|
|
1905
|
+
? "{"
|
|
1906
|
+
: objectToken === "]"
|
|
1907
|
+
? "["
|
|
1908
|
+
: objectToken === ">"
|
|
1909
|
+
? "<"
|
|
1910
|
+
: objectToken;
|
|
1911
|
+
if (!BRACKET_PAIRS.has(normalized)) {
|
|
1912
|
+
throw new VimError(`Unsupported text object: ${objectToken}`, sourceToken);
|
|
1913
|
+
}
|
|
1914
|
+
return this.#resolveBracketTextObject(inner, normalized, sourceToken);
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
#resolveWordTextObject(inner: boolean, bigWord: boolean): { start: number; end: number } {
|
|
1918
|
+
const text = this.buffer.getText();
|
|
1919
|
+
const cursor = this.buffer.currentOffset();
|
|
1920
|
+
let start = cursor;
|
|
1921
|
+
if (wordCategory(text[start] ?? "", bigWord) === "space") {
|
|
1922
|
+
start = nextWordStart(text, start, bigWord);
|
|
1923
|
+
}
|
|
1924
|
+
const category = wordCategory(text[start] ?? "", bigWord);
|
|
1925
|
+
while (start > 0 && wordCategory(text[start - 1] ?? "", bigWord) === category) {
|
|
1926
|
+
start -= 1;
|
|
1927
|
+
}
|
|
1928
|
+
let end = start;
|
|
1929
|
+
while (end < text.length && wordCategory(text[end] ?? "", bigWord) === category) {
|
|
1930
|
+
end += 1;
|
|
1931
|
+
}
|
|
1932
|
+
if (!inner) {
|
|
1933
|
+
while (end < text.length && wordCategory(text[end] ?? "", bigWord) === "space") {
|
|
1934
|
+
end += 1;
|
|
1935
|
+
}
|
|
1936
|
+
while (start > 0 && wordCategory(text[start - 1] ?? "", bigWord) === "space") {
|
|
1937
|
+
start -= 1;
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
return { start, end };
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
#resolveParagraphTextObject(inner: boolean): { start: number; end: number; linewise: boolean } {
|
|
1944
|
+
const lines = this.buffer.lines;
|
|
1945
|
+
const cursorLine = this.buffer.cursor.line;
|
|
1946
|
+
let start = cursorLine;
|
|
1947
|
+
let end = cursorLine;
|
|
1948
|
+
// Find paragraph boundaries (delimited by blank lines)
|
|
1949
|
+
if (lines[cursorLine]?.trim().length === 0) {
|
|
1950
|
+
// On a blank line: select contiguous blank lines
|
|
1951
|
+
while (start > 0 && lines[start - 1]!.trim().length === 0) start -= 1;
|
|
1952
|
+
while (end < lines.length - 1 && lines[end + 1]!.trim().length === 0) end += 1;
|
|
1953
|
+
if (!inner) {
|
|
1954
|
+
// Include following non-blank paragraph
|
|
1955
|
+
while (end < lines.length - 1 && lines[end + 1]!.trim().length > 0) end += 1;
|
|
1956
|
+
}
|
|
1957
|
+
} else {
|
|
1958
|
+
// On a non-blank line: select contiguous non-blank lines
|
|
1959
|
+
while (start > 0 && lines[start - 1]!.trim().length > 0) start -= 1;
|
|
1960
|
+
while (end < lines.length - 1 && lines[end + 1]!.trim().length > 0) end += 1;
|
|
1961
|
+
if (!inner) {
|
|
1962
|
+
// Include trailing blank lines
|
|
1963
|
+
while (end < lines.length - 1 && lines[end + 1]!.trim().length === 0) end += 1;
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
const startOffset = this.buffer.positionToOffset({ line: start, col: 0 });
|
|
1967
|
+
const endOffset =
|
|
1968
|
+
end >= this.buffer.lastLineIndex()
|
|
1969
|
+
? this.buffer.getText().length
|
|
1970
|
+
: this.buffer.positionToOffset({ line: end + 1, col: 0 });
|
|
1971
|
+
return { start: startOffset, end: endOffset, linewise: true };
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
#resolveQuoteTextObject(inner: boolean, quote: string, sourceToken: VimKeyToken): { start: number; end: number } {
|
|
1975
|
+
const line = this.buffer.getLine(this.buffer.cursor.line);
|
|
1976
|
+
const col = this.buffer.cursor.col;
|
|
1977
|
+
const before = line.lastIndexOf(quote, col);
|
|
1978
|
+
const after = line.indexOf(quote, col + (line[col] === quote ? 1 : 0));
|
|
1979
|
+
if (before === -1 || after === -1 || before === after) {
|
|
1980
|
+
throw new VimError(`Quote text object not found for ${quote}`, sourceToken);
|
|
1981
|
+
}
|
|
1982
|
+
const startCol = inner ? before + 1 : before;
|
|
1983
|
+
const endCol = inner ? after : after + 1;
|
|
1984
|
+
return {
|
|
1985
|
+
start: this.buffer.positionToOffset({ line: this.buffer.cursor.line, col: startCol }),
|
|
1986
|
+
end: this.buffer.positionToOffset({ line: this.buffer.cursor.line, col: endCol }),
|
|
1987
|
+
};
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
#resolveBracketTextObject(inner: boolean, open: string, sourceToken: VimKeyToken): { start: number; end: number } {
|
|
1991
|
+
const close = BRACKET_PAIRS.get(open)!;
|
|
1992
|
+
const text = this.buffer.getText();
|
|
1993
|
+
const cursor = this.buffer.currentOffset();
|
|
1994
|
+
let start = -1;
|
|
1995
|
+
let depth = 0;
|
|
1996
|
+
for (let index = cursor; index >= 0; index -= 1) {
|
|
1997
|
+
const char = text[index] ?? "";
|
|
1998
|
+
if (char === close) {
|
|
1999
|
+
depth += 1;
|
|
2000
|
+
} else if (char === open) {
|
|
2001
|
+
if (depth === 0) {
|
|
2002
|
+
start = index;
|
|
2003
|
+
break;
|
|
2004
|
+
}
|
|
2005
|
+
depth -= 1;
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
if (start === -1) {
|
|
2009
|
+
throw new VimError(`Text object ${open}${close} not found`, sourceToken);
|
|
2010
|
+
}
|
|
2011
|
+
let end = -1;
|
|
2012
|
+
depth = 0;
|
|
2013
|
+
for (let index = start; index < text.length; index += 1) {
|
|
2014
|
+
const char = text[index] ?? "";
|
|
2015
|
+
if (char === open) {
|
|
2016
|
+
depth += 1;
|
|
2017
|
+
} else if (char === close) {
|
|
2018
|
+
depth -= 1;
|
|
2019
|
+
if (depth === 0) {
|
|
2020
|
+
end = index;
|
|
2021
|
+
break;
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
if (end === -1) {
|
|
2026
|
+
throw new VimError(`Text object ${open}${close} not found`, sourceToken);
|
|
2027
|
+
}
|
|
2028
|
+
return {
|
|
2029
|
+
start: inner ? start + 1 : start,
|
|
2030
|
+
end: inner ? end : end + 1,
|
|
2031
|
+
};
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
#findMatchingBracket(): Position {
|
|
2035
|
+
const text = this.buffer.getText();
|
|
2036
|
+
const cursor = this.buffer.currentOffset();
|
|
2037
|
+
let offset = cursor;
|
|
2038
|
+
let char = text[offset] ?? "";
|
|
2039
|
+
if (!BRACKET_PAIRS.has(char) && !CLOSING_BRACKETS.has(char)) {
|
|
2040
|
+
offset += 1;
|
|
2041
|
+
char = text[offset] ?? "";
|
|
2042
|
+
}
|
|
2043
|
+
if (BRACKET_PAIRS.has(char)) {
|
|
2044
|
+
const close = BRACKET_PAIRS.get(char)!;
|
|
2045
|
+
let depth = 0;
|
|
2046
|
+
for (let index = offset; index < text.length; index += 1) {
|
|
2047
|
+
const current = text[index] ?? "";
|
|
2048
|
+
if (current === char) depth += 1;
|
|
2049
|
+
if (current === close) {
|
|
2050
|
+
depth -= 1;
|
|
2051
|
+
if (depth === 0) {
|
|
2052
|
+
return this.buffer.offsetToPosition(index);
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
if (CLOSING_BRACKETS.has(char)) {
|
|
2058
|
+
const open = CLOSING_BRACKETS.get(char)!;
|
|
2059
|
+
let depth = 0;
|
|
2060
|
+
for (let index = offset; index >= 0; index -= 1) {
|
|
2061
|
+
const current = text[index] ?? "";
|
|
2062
|
+
if (current === char) depth += 1;
|
|
2063
|
+
if (current === open) {
|
|
2064
|
+
depth -= 1;
|
|
2065
|
+
if (depth === 0) {
|
|
2066
|
+
return this.buffer.offsetToPosition(index);
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
throw new VimError("Matching bracket not found");
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
async #runSearch(pattern: string, direction: 1 | -1, updateState: boolean): Promise<void> {
|
|
2075
|
+
const text = this.buffer.getText();
|
|
2076
|
+
const regex = createSearchRegex(pattern, "g");
|
|
2077
|
+
const cursor = this.buffer.currentOffset();
|
|
2078
|
+
let matchOffset = -1;
|
|
2079
|
+
|
|
2080
|
+
if (direction > 0) {
|
|
2081
|
+
regex.lastIndex = Math.min(text.length, cursor + 1);
|
|
2082
|
+
const match = regex.exec(text);
|
|
2083
|
+
if (match && match.index >= 0) {
|
|
2084
|
+
matchOffset = match.index;
|
|
2085
|
+
} else {
|
|
2086
|
+
regex.lastIndex = 0;
|
|
2087
|
+
const wrapMatch = regex.exec(text);
|
|
2088
|
+
if (wrapMatch && wrapMatch.index >= 0) {
|
|
2089
|
+
matchOffset = wrapMatch.index;
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
} else {
|
|
2093
|
+
const matches = Array.from(text.matchAll(regex));
|
|
2094
|
+
for (let index = matches.length - 1; index >= 0; index -= 1) {
|
|
2095
|
+
const match = matches[index];
|
|
2096
|
+
if ((match.index ?? -1) < cursor) {
|
|
2097
|
+
matchOffset = match.index ?? -1;
|
|
2098
|
+
break;
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
if (matchOffset === -1 && matches.length > 0) {
|
|
2102
|
+
matchOffset = matches[matches.length - 1]?.index ?? -1;
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
if (matchOffset === -1) {
|
|
2107
|
+
throw new VimError(`Pattern not found: ${pattern}`);
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
this.buffer.setCursor(this.buffer.offsetToPosition(matchOffset));
|
|
2111
|
+
this.statusMessage = `${direction > 0 ? "/" : "?"}${pattern}`;
|
|
2112
|
+
if (updateState) {
|
|
2113
|
+
this.lastSearch = { pattern, direction };
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
async #repeatSearch(direction: 1 | -1, count: number): Promise<void> {
|
|
2118
|
+
if (!this.lastSearch) {
|
|
2119
|
+
throw new VimError("No previous search");
|
|
2120
|
+
}
|
|
2121
|
+
for (let index = 0; index < count; index += 1) {
|
|
2122
|
+
await this.#runSearch(this.lastSearch.pattern, direction, false);
|
|
2123
|
+
}
|
|
2124
|
+
this.lastSearch = { pattern: this.lastSearch.pattern, direction };
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
#resolveExRange(
|
|
2128
|
+
range: VimLineRange | "all" | undefined,
|
|
2129
|
+
defaultStart: number,
|
|
2130
|
+
defaultEnd = defaultStart,
|
|
2131
|
+
): VimLineRange {
|
|
2132
|
+
const totalLines = Math.max(1, this.buffer.lineCount());
|
|
2133
|
+
if (range === "all") {
|
|
2134
|
+
return { start: 1, end: totalLines };
|
|
2135
|
+
}
|
|
2136
|
+
const next = range ?? { start: defaultStart, end: defaultEnd };
|
|
2137
|
+
const start = Math.max(1, Math.min(next.start, totalLines));
|
|
2138
|
+
const end = Math.max(start, Math.min(next.end, totalLines));
|
|
2139
|
+
return { start, end };
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
async #executeEx(input: string): Promise<void> {
|
|
2143
|
+
const command = parseExCommand(input, {
|
|
2144
|
+
currentLine: this.buffer.cursor.line + 1,
|
|
2145
|
+
lastLine: this.buffer.lineCount(),
|
|
2146
|
+
});
|
|
2147
|
+
switch (command.kind) {
|
|
2148
|
+
case "goto-line":
|
|
2149
|
+
this.buffer.setCursor({ line: Math.max(0, command.line - 1), col: 0 });
|
|
2150
|
+
this.statusMessage = `Line ${command.line}`;
|
|
2151
|
+
return;
|
|
2152
|
+
case "write": {
|
|
2153
|
+
const result = await this.#callbacks.saveBuffer(this.buffer, { force: command.force });
|
|
2154
|
+
this.buffer.markSaved(result.loaded);
|
|
2155
|
+
this.diagnostics = result.diagnostics;
|
|
2156
|
+
this.statusMessage = result.diagnostics
|
|
2157
|
+
? `Wrote ${this.buffer.displayPath} (${result.diagnostics.summary})`
|
|
2158
|
+
: `Wrote ${this.buffer.displayPath}`;
|
|
2159
|
+
this.#undoStack = [];
|
|
2160
|
+
this.#redoStack = [];
|
|
2161
|
+
return;
|
|
2162
|
+
}
|
|
2163
|
+
case "update":
|
|
2164
|
+
if (!this.buffer.modified) {
|
|
2165
|
+
this.statusMessage = `${this.buffer.displayPath} unchanged`;
|
|
2166
|
+
return;
|
|
2167
|
+
}
|
|
2168
|
+
await this.#executeEx(command.force ? "w!" : "w");
|
|
2169
|
+
return;
|
|
2170
|
+
case "write-quit":
|
|
2171
|
+
await this.#executeEx(command.force ? "w!" : "w");
|
|
2172
|
+
this.closed = true;
|
|
2173
|
+
this.statusMessage = `Wrote and closed ${this.buffer.displayPath}`;
|
|
2174
|
+
return;
|
|
2175
|
+
case "quit":
|
|
2176
|
+
if (this.buffer.modified && !command.force) {
|
|
2177
|
+
throw new VimError("Unsaved changes; use :q! to discard");
|
|
2178
|
+
}
|
|
2179
|
+
this.closed = true;
|
|
2180
|
+
this.statusMessage = `Closed ${this.buffer.displayPath}`;
|
|
2181
|
+
return;
|
|
2182
|
+
case "edit": {
|
|
2183
|
+
if (this.buffer.modified && !command.force) {
|
|
2184
|
+
throw new VimError("Unsaved changes; use :e! to reload or force open");
|
|
2185
|
+
}
|
|
2186
|
+
const next = await this.#callbacks.loadBuffer(command.path ?? this.buffer.displayPath);
|
|
2187
|
+
this.buffer.replaceLoadedFile(next);
|
|
2188
|
+
this.inputMode = "normal";
|
|
2189
|
+
this.selectionAnchor = null;
|
|
2190
|
+
this.#pendingInput = "";
|
|
2191
|
+
this.#pendingChange = null;
|
|
2192
|
+
this.#undoStack = [];
|
|
2193
|
+
this.#redoStack = [];
|
|
2194
|
+
this.statusMessage = command.path
|
|
2195
|
+
? `Opened ${this.buffer.displayPath}`
|
|
2196
|
+
: `Reloaded ${this.buffer.displayPath}`;
|
|
2197
|
+
return;
|
|
2198
|
+
}
|
|
2199
|
+
case "substitute": {
|
|
2200
|
+
const range = this.#resolveExRange(command.range, this.buffer.cursor.line + 1);
|
|
2201
|
+
const startLine = range.start;
|
|
2202
|
+
const endLine = range.end;
|
|
2203
|
+
const regexFlags = command.flags.includes("i") ? "gi" : "g";
|
|
2204
|
+
const regex = createSearchRegex(command.pattern, regexFlags);
|
|
2205
|
+
let replacements = 0;
|
|
2206
|
+
await this.#applyAtomicChange([":substitute"], () => {
|
|
2207
|
+
for (let lineIndex = startLine - 1; lineIndex <= endLine - 1; lineIndex += 1) {
|
|
2208
|
+
const line = this.buffer.getLine(lineIndex);
|
|
2209
|
+
let lineReplacements = 0;
|
|
2210
|
+
const nextLine = line.replace(regex, match => {
|
|
2211
|
+
if (!command.flags.includes("g") && lineReplacements > 0) {
|
|
2212
|
+
return match;
|
|
2213
|
+
}
|
|
2214
|
+
lineReplacements += 1;
|
|
2215
|
+
replacements += 1;
|
|
2216
|
+
return decodeReplacement(command.replacement).replace(/&/g, match);
|
|
2217
|
+
});
|
|
2218
|
+
this.buffer.replaceLine(lineIndex, nextLine);
|
|
2219
|
+
regex.lastIndex = 0;
|
|
2220
|
+
}
|
|
2221
|
+
});
|
|
2222
|
+
if (replacements === 0) {
|
|
2223
|
+
throw new VimError(`Pattern not found: ${command.pattern}`);
|
|
2224
|
+
}
|
|
2225
|
+
this.statusMessage = `${replacements} substitution${replacements === 1 ? "" : "s"}`;
|
|
2226
|
+
return;
|
|
2227
|
+
}
|
|
2228
|
+
case "delete": {
|
|
2229
|
+
const range = this.#resolveExRange(command.range, this.buffer.cursor.line + 1);
|
|
2230
|
+
await this.#applyAtomicChange([":delete"], () => {
|
|
2231
|
+
const removed = this.buffer.deleteLines(range.start - 1, range.end - 1);
|
|
2232
|
+
this.register = { kind: "line", text: removed.join("\n") };
|
|
2233
|
+
});
|
|
2234
|
+
this.statusMessage = `Deleted ${range.end - range.start + 1} line${range.end === range.start ? "" : "s"}`;
|
|
2235
|
+
return;
|
|
2236
|
+
}
|
|
2237
|
+
case "yank": {
|
|
2238
|
+
const range = this.#resolveExRange(command.range, this.buffer.cursor.line + 1);
|
|
2239
|
+
this.register = {
|
|
2240
|
+
kind: "line",
|
|
2241
|
+
text: this.buffer.lines.slice(range.start - 1, range.end).join("\n"),
|
|
2242
|
+
};
|
|
2243
|
+
this.statusMessage = `Yanked ${range.end - range.start + 1} line${range.end === range.start ? "" : "s"}`;
|
|
2244
|
+
return;
|
|
2245
|
+
}
|
|
2246
|
+
case "put": {
|
|
2247
|
+
if (!this.register.text) {
|
|
2248
|
+
this.statusMessage = "Register empty";
|
|
2249
|
+
return;
|
|
2250
|
+
}
|
|
2251
|
+
const anchorRange = this.#resolveExRange(command.range, this.buffer.cursor.line + 1);
|
|
2252
|
+
const anchorLine = command.before ? anchorRange.start : anchorRange.end;
|
|
2253
|
+
const lines = this.register.text.split("\n");
|
|
2254
|
+
await this.#applyAtomicChange([":put"], () => {
|
|
2255
|
+
const insertAt = command.before
|
|
2256
|
+
? Math.max(0, anchorLine - 1)
|
|
2257
|
+
: Math.min(anchorLine, this.buffer.lineCount());
|
|
2258
|
+
this.buffer.insertLines(insertAt, lines);
|
|
2259
|
+
});
|
|
2260
|
+
this.statusMessage = `Put ${lines.length} line${lines.length === 1 ? "" : "s"}`;
|
|
2261
|
+
return;
|
|
2262
|
+
}
|
|
2263
|
+
case "copy": {
|
|
2264
|
+
const totalLines = this.buffer.lineCount();
|
|
2265
|
+
const range = this.#resolveExRange(command.range, this.buffer.cursor.line + 1);
|
|
2266
|
+
const dest = Math.max(0, Math.min(command.destination, totalLines));
|
|
2267
|
+
await this.#applyAtomicChange([":copy"], () => {
|
|
2268
|
+
const lines = this.buffer.lines.slice(range.start - 1, range.end);
|
|
2269
|
+
this.buffer.insertLines(dest, lines);
|
|
2270
|
+
});
|
|
2271
|
+
this.statusMessage = `Copied ${range.end - range.start + 1} line${range.end === range.start ? "" : "s"}`;
|
|
2272
|
+
return;
|
|
2273
|
+
}
|
|
2274
|
+
case "move": {
|
|
2275
|
+
const totalLines = this.buffer.lineCount();
|
|
2276
|
+
const range = this.#resolveExRange(command.range, this.buffer.cursor.line + 1);
|
|
2277
|
+
const dest = Math.max(0, Math.min(command.destination, totalLines));
|
|
2278
|
+
await this.#applyAtomicChange([":move"], () => {
|
|
2279
|
+
const lines = this.buffer.lines.splice(range.start - 1, range.end - range.start + 1);
|
|
2280
|
+
const adjustedDest = dest > range.end - 1 ? dest - lines.length : dest;
|
|
2281
|
+
this.buffer.lines.splice(adjustedDest, 0, ...lines);
|
|
2282
|
+
if (this.buffer.lines.length === 0) this.buffer.lines = [""];
|
|
2283
|
+
this.buffer.setCursor({ line: adjustedDest, col: 0 });
|
|
2284
|
+
});
|
|
2285
|
+
this.statusMessage = `Moved ${range.end - range.start + 1} line${range.end === range.start ? "" : "s"}`;
|
|
2286
|
+
return;
|
|
2287
|
+
}
|
|
2288
|
+
case "sort": {
|
|
2289
|
+
const range = this.#resolveExRange(command.range ?? "all", 1, this.buffer.lineCount());
|
|
2290
|
+
const startLine = range.start;
|
|
2291
|
+
const endLine = range.end;
|
|
2292
|
+
const reverse = command.flags.includes("!");
|
|
2293
|
+
const ignoreCase = command.flags.includes("i");
|
|
2294
|
+
await this.#applyAtomicChange([":sort"], () => {
|
|
2295
|
+
const slice = this.buffer.lines.slice(startLine - 1, endLine);
|
|
2296
|
+
slice.sort((a, b) => {
|
|
2297
|
+
const left = ignoreCase ? a.toLowerCase() : a;
|
|
2298
|
+
const right = ignoreCase ? b.toLowerCase() : b;
|
|
2299
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
2300
|
+
});
|
|
2301
|
+
if (reverse) slice.reverse();
|
|
2302
|
+
for (let i = 0; i < slice.length; i++) {
|
|
2303
|
+
this.buffer.lines[startLine - 1 + i] = slice[i]!;
|
|
2304
|
+
}
|
|
2305
|
+
});
|
|
2306
|
+
this.statusMessage = `Sorted ${endLine - startLine + 1} line${endLine === startLine ? "" : "s"}`;
|
|
2307
|
+
return;
|
|
2308
|
+
}
|
|
2309
|
+
case "join": {
|
|
2310
|
+
const currentLine = this.buffer.cursor.line + 1;
|
|
2311
|
+
const baseRange = this.#resolveExRange(
|
|
2312
|
+
command.range,
|
|
2313
|
+
currentLine,
|
|
2314
|
+
command.range ? undefined : Math.min(this.buffer.lineCount(), currentLine + 1),
|
|
2315
|
+
);
|
|
2316
|
+
const startLine = baseRange.start;
|
|
2317
|
+
const endLine =
|
|
2318
|
+
baseRange.start === baseRange.end ? Math.min(this.buffer.lineCount(), baseRange.end + 1) : baseRange.end;
|
|
2319
|
+
const lineCount = endLine - startLine + 1;
|
|
2320
|
+
if (lineCount < 2) {
|
|
2321
|
+
this.statusMessage = "Nothing to join";
|
|
2322
|
+
return;
|
|
2323
|
+
}
|
|
2324
|
+
await this.#applyAtomicChange([":join"], () => {
|
|
2325
|
+
const startIndex = startLine - 1;
|
|
2326
|
+
if (command.trimWhitespace) {
|
|
2327
|
+
this.buffer.joinLines(startIndex, lineCount - 1);
|
|
2328
|
+
return;
|
|
2329
|
+
}
|
|
2330
|
+
const joined = this.buffer.lines.slice(startIndex, endLine).join("");
|
|
2331
|
+
this.buffer.lines.splice(startIndex, lineCount, joined);
|
|
2332
|
+
this.buffer.setCursor({ line: startIndex, col: Math.max(0, joined.length - 1) });
|
|
2333
|
+
});
|
|
2334
|
+
this.statusMessage = `Joined ${lineCount} lines`;
|
|
2335
|
+
return;
|
|
2336
|
+
}
|
|
2337
|
+
case "append": {
|
|
2338
|
+
const anchorRange = this.#resolveExRange(command.range, this.buffer.cursor.line + 1);
|
|
2339
|
+
const anchorLine = anchorRange.end;
|
|
2340
|
+
const lines = command.text.length > 0 ? command.text.split("\n") : [""];
|
|
2341
|
+
await this.#applyAtomicChange([":append"], () => {
|
|
2342
|
+
const insertAt = Math.min(anchorLine, this.buffer.lineCount());
|
|
2343
|
+
this.buffer.insertLines(insertAt, lines);
|
|
2344
|
+
});
|
|
2345
|
+
this.statusMessage = `Appended ${lines.length} line${lines.length === 1 ? "" : "s"}`;
|
|
2346
|
+
return;
|
|
2347
|
+
}
|
|
2348
|
+
case "insert-before": {
|
|
2349
|
+
const anchorRange = this.#resolveExRange(command.range, this.buffer.cursor.line + 1);
|
|
2350
|
+
const anchorLine = anchorRange.start;
|
|
2351
|
+
const lines = command.text.length > 0 ? command.text.split("\n") : [""];
|
|
2352
|
+
await this.#applyAtomicChange([":insert"], () => {
|
|
2353
|
+
const insertAt = Math.max(0, anchorLine - 1);
|
|
2354
|
+
this.buffer.insertLines(insertAt, lines);
|
|
2355
|
+
});
|
|
2356
|
+
this.statusMessage = `Inserted ${lines.length} line${lines.length === 1 ? "" : "s"}`;
|
|
2357
|
+
return;
|
|
2358
|
+
}
|
|
2359
|
+
case "global": {
|
|
2360
|
+
const regex = createSearchRegex(command.pattern);
|
|
2361
|
+
const range = this.#resolveExRange(command.range ?? "all", 1, this.buffer.lineCount());
|
|
2362
|
+
await this.#applyAtomicChange([":global"], () => {
|
|
2363
|
+
const linesToProcess: number[] = [];
|
|
2364
|
+
for (let i = range.start - 1; i <= range.end - 1; i += 1) {
|
|
2365
|
+
const matches = regex.test(this.buffer.getLine(i));
|
|
2366
|
+
regex.lastIndex = 0;
|
|
2367
|
+
if (command.invert ? !matches : matches) {
|
|
2368
|
+
linesToProcess.push(i);
|
|
2369
|
+
}
|
|
2370
|
+
}
|
|
2371
|
+
if (command.command === "d" || command.command === "delete") {
|
|
2372
|
+
// Delete matching lines in reverse to preserve indices
|
|
2373
|
+
for (let i = linesToProcess.length - 1; i >= 0; i--) {
|
|
2374
|
+
this.buffer.lines.splice(linesToProcess[i]!, 1);
|
|
2375
|
+
}
|
|
2376
|
+
if (this.buffer.lines.length === 0) this.buffer.lines = [""];
|
|
2377
|
+
this.buffer.clampCursor();
|
|
2378
|
+
this.buffer.trailingNewline = true;
|
|
2379
|
+
} else {
|
|
2380
|
+
throw new VimError(`Unsupported :global sub-command: ${command.command}`);
|
|
2381
|
+
}
|
|
2382
|
+
});
|
|
2383
|
+
this.statusMessage = `Global: processed ${command.pattern}`;
|
|
2384
|
+
return;
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
#paste(after: boolean, count: number): void {
|
|
2390
|
+
if (!this.register.text) {
|
|
2391
|
+
return;
|
|
2392
|
+
}
|
|
2393
|
+
if (this.register.kind === "line") {
|
|
2394
|
+
const lines = this.register.text.split("\n");
|
|
2395
|
+
const insertAt = after ? this.buffer.cursor.line + 1 : this.buffer.cursor.line;
|
|
2396
|
+
for (let iteration = 0; iteration < count; iteration += 1) {
|
|
2397
|
+
this.buffer.insertLines(insertAt + iteration * lines.length, lines);
|
|
2398
|
+
}
|
|
2399
|
+
return;
|
|
2400
|
+
}
|
|
2401
|
+
const text = this.register.text.repeat(count);
|
|
2402
|
+
const offset = this.buffer.currentOffset() + (after ? 1 : 0);
|
|
2403
|
+
this.buffer.replaceOffsets(offset, offset, text, offset + text.length);
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
#readCount(tokens: readonly VimKeyToken[], index: number): { count: number; hasCount: boolean; nextIndex: number } {
|
|
2407
|
+
let cursor = index;
|
|
2408
|
+
let digits = "";
|
|
2409
|
+
while (cursor < tokens.length) {
|
|
2410
|
+
const value = tokens[cursor]?.value ?? "";
|
|
2411
|
+
if (!/^\d$/.test(value)) {
|
|
2412
|
+
break;
|
|
2413
|
+
}
|
|
2414
|
+
if (digits.length === 0 && value === "0") {
|
|
2415
|
+
break;
|
|
2416
|
+
}
|
|
2417
|
+
digits += value;
|
|
2418
|
+
cursor += 1;
|
|
2419
|
+
}
|
|
2420
|
+
return {
|
|
2421
|
+
count: digits.length > 0 ? Number.parseInt(digits, 10) : 1,
|
|
2422
|
+
hasCount: digits.length > 0,
|
|
2423
|
+
nextIndex: cursor,
|
|
2424
|
+
};
|
|
2425
|
+
}
|
|
2426
|
+
}
|