@oh-my-pi/pi-coding-agent 15.3.2 → 15.4.2

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.
Files changed (193) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/dist/types/cli/file-processor.d.ts +1 -1
  3. package/dist/types/config/settings-schema.d.ts +45 -3
  4. package/dist/types/config/settings.d.ts +1 -1
  5. package/dist/types/debug/raw-sse.d.ts +2 -0
  6. package/dist/types/edit/file-read-cache.d.ts +15 -4
  7. package/dist/types/edit/index.d.ts +3 -8
  8. package/dist/types/edit/renderer.d.ts +1 -2
  9. package/dist/types/eval/__tests__/shared-executors.test.d.ts +1 -0
  10. package/dist/types/eval/js/shared/local-module-loader.d.ts +16 -0
  11. package/dist/types/eval/js/shared/rewrite-imports.d.ts +4 -0
  12. package/dist/types/eval/js/shared/runtime.d.ts +14 -8
  13. package/dist/types/eval/py/executor.d.ts +1 -2
  14. package/dist/types/eval/py/kernel.d.ts +6 -0
  15. package/dist/types/eval/py/tool-bridge.d.ts +1 -5
  16. package/dist/types/eval/session-id.d.ts +3 -0
  17. package/dist/types/extensibility/extensions/types.d.ts +1 -3
  18. package/dist/types/hashline/anchors.d.ts +15 -9
  19. package/dist/types/hashline/constants.d.ts +0 -2
  20. package/dist/types/hashline/diff.d.ts +1 -2
  21. package/dist/types/hashline/executor.d.ts +52 -0
  22. package/dist/types/hashline/hash.d.ts +44 -93
  23. package/dist/types/hashline/index.d.ts +2 -1
  24. package/dist/types/hashline/input.d.ts +2 -9
  25. package/dist/types/hashline/recovery.d.ts +3 -9
  26. package/dist/types/hashline/tokenizer.d.ts +91 -0
  27. package/dist/types/hashline/types.d.ts +5 -7
  28. package/dist/types/modes/components/extensions/types.d.ts +0 -4
  29. package/dist/types/modes/types.d.ts +1 -0
  30. package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
  31. package/dist/types/sdk.d.ts +2 -0
  32. package/dist/types/session/agent-session.d.ts +11 -15
  33. package/dist/types/session/agent-storage.d.ts +11 -10
  34. package/dist/types/slash-commands/acp-builtins.d.ts +3 -3
  35. package/dist/types/slash-commands/types.d.ts +0 -5
  36. package/dist/types/task/executor.d.ts +2 -0
  37. package/dist/types/tool-discovery/tool-index.d.ts +0 -50
  38. package/dist/types/tools/index.d.ts +2 -8
  39. package/dist/types/tools/match-line-format.d.ts +4 -4
  40. package/dist/types/tools/output-schema-validator.d.ts +64 -0
  41. package/dist/types/tools/review.d.ts +13 -0
  42. package/dist/types/tools/search-tool-bm25.d.ts +1 -1
  43. package/dist/types/tools/search.d.ts +4 -3
  44. package/dist/types/utils/edit-mode.d.ts +1 -1
  45. package/dist/types/web/kagi.d.ts +4 -2
  46. package/dist/types/web/parallel.d.ts +4 -3
  47. package/dist/types/web/scrapers/types.d.ts +2 -1
  48. package/dist/types/web/search/index.d.ts +12 -4
  49. package/dist/types/web/search/provider.d.ts +2 -1
  50. package/dist/types/web/search/providers/anthropic.d.ts +9 -4
  51. package/dist/types/web/search/providers/base.d.ts +34 -2
  52. package/dist/types/web/search/providers/brave.d.ts +8 -1
  53. package/dist/types/web/search/providers/codex.d.ts +13 -9
  54. package/dist/types/web/search/providers/exa.d.ts +10 -1
  55. package/dist/types/web/search/providers/gemini.d.ts +20 -23
  56. package/dist/types/web/search/providers/jina.d.ts +2 -1
  57. package/dist/types/web/search/providers/kagi.d.ts +4 -1
  58. package/dist/types/web/search/providers/kimi.d.ts +10 -1
  59. package/dist/types/web/search/providers/parallel.d.ts +3 -2
  60. package/dist/types/web/search/providers/perplexity.d.ts +5 -2
  61. package/dist/types/web/search/providers/searxng.d.ts +2 -1
  62. package/dist/types/web/search/providers/synthetic.d.ts +5 -8
  63. package/dist/types/web/search/providers/tavily.d.ts +11 -4
  64. package/dist/types/web/search/providers/utils.d.ts +8 -6
  65. package/dist/types/web/search/providers/zai.d.ts +12 -3
  66. package/package.json +7 -7
  67. package/src/cli/file-processor.ts +12 -2
  68. package/src/cli.ts +0 -8
  69. package/src/commands/commit.ts +8 -8
  70. package/src/config/prompt-templates.ts +6 -6
  71. package/src/config/settings-schema.ts +47 -3
  72. package/src/config/settings.ts +5 -5
  73. package/src/debug/raw-sse.ts +68 -3
  74. package/src/edit/file-read-cache.ts +68 -25
  75. package/src/edit/index.ts +6 -37
  76. package/src/edit/renderer.ts +9 -47
  77. package/src/edit/streaming.ts +43 -56
  78. package/src/eval/__tests__/shared-executors.test.ts +520 -0
  79. package/src/eval/js/context-manager.ts +64 -53
  80. package/src/eval/js/shared/local-module-loader.ts +265 -0
  81. package/src/eval/js/shared/prelude.txt +4 -0
  82. package/src/eval/js/shared/rewrite-imports.ts +85 -0
  83. package/src/eval/js/shared/runtime.ts +129 -86
  84. package/src/eval/js/worker-core.ts +23 -38
  85. package/src/eval/py/executor.ts +155 -84
  86. package/src/eval/py/kernel.ts +10 -1
  87. package/src/eval/py/prelude.py +22 -24
  88. package/src/eval/py/runner.py +203 -85
  89. package/src/eval/py/tool-bridge.ts +17 -10
  90. package/src/eval/session-id.ts +8 -0
  91. package/src/exec/bash-executor.ts +27 -16
  92. package/src/extensibility/extensions/runner.ts +0 -1
  93. package/src/extensibility/extensions/types.ts +1 -3
  94. package/src/hashline/anchors.ts +56 -65
  95. package/src/hashline/apply.ts +29 -31
  96. package/src/hashline/constants.ts +0 -3
  97. package/src/hashline/diff-preview.ts +4 -5
  98. package/src/hashline/diff.ts +30 -4
  99. package/src/hashline/execute.ts +91 -26
  100. package/src/hashline/executor.ts +239 -0
  101. package/src/hashline/grammar.lark +12 -10
  102. package/src/hashline/hash.ts +69 -114
  103. package/src/hashline/index.ts +2 -1
  104. package/src/hashline/input.ts +48 -41
  105. package/src/hashline/prefixes.ts +21 -11
  106. package/src/hashline/recovery.ts +63 -71
  107. package/src/hashline/stream.ts +2 -2
  108. package/src/hashline/tokenizer.ts +467 -0
  109. package/src/hashline/types.ts +6 -8
  110. package/src/internal-urls/docs-index.generated.ts +7 -7
  111. package/src/modes/components/extensions/types.ts +0 -5
  112. package/src/modes/components/session-observer-overlay.ts +11 -2
  113. package/src/modes/components/settings-selector.ts +10 -1
  114. package/src/modes/components/tree-selector.ts +10 -2
  115. package/src/modes/controllers/command-controller.ts +1 -3
  116. package/src/modes/controllers/extension-ui-controller.ts +10 -11
  117. package/src/modes/controllers/selector-controller.ts +5 -5
  118. package/src/modes/theme/theme.ts +4 -2
  119. package/src/modes/types.ts +4 -1
  120. package/src/modes/utils/ui-helpers.ts +4 -0
  121. package/src/prompts/agents/explore.md +1 -1
  122. package/src/prompts/tools/ast-edit.md +1 -1
  123. package/src/prompts/tools/ast-grep.md +1 -1
  124. package/src/prompts/tools/eval.md +1 -1
  125. package/src/prompts/tools/hashline.md +73 -94
  126. package/src/prompts/tools/read.md +4 -4
  127. package/src/prompts/tools/search.md +3 -3
  128. package/src/sdk.ts +33 -26
  129. package/src/session/agent-session.ts +59 -66
  130. package/src/session/agent-storage.ts +13 -14
  131. package/src/slash-commands/acp-builtins.ts +3 -3
  132. package/src/slash-commands/types.ts +0 -6
  133. package/src/task/executor.ts +26 -57
  134. package/src/task/index.ts +8 -4
  135. package/src/tool-discovery/tool-index.ts +0 -134
  136. package/src/tools/ast-edit.ts +36 -13
  137. package/src/tools/ast-grep.ts +45 -4
  138. package/src/tools/browser/tab-worker.ts +3 -2
  139. package/src/tools/eval.ts +2 -1
  140. package/src/tools/fetch.ts +23 -14
  141. package/src/tools/index.ts +2 -8
  142. package/src/tools/irc.ts +59 -5
  143. package/src/tools/match-line-format.ts +5 -7
  144. package/src/tools/output-schema-validator.ts +132 -0
  145. package/src/tools/read.ts +142 -31
  146. package/src/tools/review.ts +23 -0
  147. package/src/tools/search-tool-bm25.ts +3 -30
  148. package/src/tools/search.ts +48 -16
  149. package/src/tools/write.ts +3 -3
  150. package/src/tools/yield.ts +32 -41
  151. package/src/utils/edit-mode.ts +1 -2
  152. package/src/utils/file-mentions.ts +2 -2
  153. package/src/web/kagi.ts +15 -6
  154. package/src/web/parallel.ts +9 -6
  155. package/src/web/scrapers/types.ts +7 -1
  156. package/src/web/scrapers/youtube.ts +13 -7
  157. package/src/web/search/index.ts +37 -11
  158. package/src/web/search/provider.ts +5 -3
  159. package/src/web/search/providers/anthropic.ts +30 -21
  160. package/src/web/search/providers/base.ts +35 -2
  161. package/src/web/search/providers/brave.ts +4 -4
  162. package/src/web/search/providers/codex.ts +118 -89
  163. package/src/web/search/providers/exa.ts +3 -2
  164. package/src/web/search/providers/gemini.ts +58 -155
  165. package/src/web/search/providers/jina.ts +4 -4
  166. package/src/web/search/providers/kagi.ts +17 -11
  167. package/src/web/search/providers/kimi.ts +29 -13
  168. package/src/web/search/providers/parallel.ts +171 -23
  169. package/src/web/search/providers/perplexity.ts +38 -37
  170. package/src/web/search/providers/searxng.ts +3 -1
  171. package/src/web/search/providers/synthetic.ts +16 -19
  172. package/src/web/search/providers/tavily.ts +23 -18
  173. package/src/web/search/providers/utils.ts +11 -17
  174. package/src/web/search/providers/zai.ts +16 -8
  175. package/dist/types/hashline/parser.d.ts +0 -7
  176. package/dist/types/mcp/discoverable-tool-metadata.d.ts +0 -7
  177. package/dist/types/tools/vim.d.ts +0 -58
  178. package/dist/types/vim/buffer.d.ts +0 -41
  179. package/dist/types/vim/commands.d.ts +0 -6
  180. package/dist/types/vim/engine.d.ts +0 -47
  181. package/dist/types/vim/parser.d.ts +0 -3
  182. package/dist/types/vim/render.d.ts +0 -25
  183. package/dist/types/vim/types.d.ts +0 -182
  184. package/src/hashline/parser.ts +0 -246
  185. package/src/mcp/discoverable-tool-metadata.ts +0 -24
  186. package/src/prompts/tools/vim.md +0 -98
  187. package/src/tools/vim.ts +0 -949
  188. package/src/vim/buffer.ts +0 -309
  189. package/src/vim/commands.ts +0 -382
  190. package/src/vim/engine.ts +0 -2409
  191. package/src/vim/parser.ts +0 -134
  192. package/src/vim/render.ts +0 -252
  193. package/src/vim/types.ts +0 -197
package/src/vim/buffer.ts DELETED
@@ -1,309 +0,0 @@
1
- import { clonePosition, type Position, type VimBufferSnapshot, type VimFingerprint, type VimLoadedFile } from "./types";
2
-
3
- function splitText(text: string): string[] {
4
- if (text.length === 0) {
5
- return [""];
6
- }
7
- return text.split("\n");
8
- }
9
-
10
- export function snapshotEqual(left: VimBufferSnapshot, right: VimBufferSnapshot): boolean {
11
- if (
12
- left.displayPath !== right.displayPath ||
13
- left.filePath !== right.filePath ||
14
- left.modified !== right.modified ||
15
- left.trailingNewline !== right.trailingNewline ||
16
- left.cursor.line !== right.cursor.line ||
17
- left.cursor.col !== right.cursor.col ||
18
- left.editabilityChecked !== right.editabilityChecked
19
- ) {
20
- return false;
21
- }
22
-
23
- if (left.baseFingerprint === null || right.baseFingerprint === null) {
24
- if (left.baseFingerprint !== right.baseFingerprint) {
25
- return false;
26
- }
27
- } else if (
28
- left.baseFingerprint.exists !== right.baseFingerprint.exists ||
29
- left.baseFingerprint.size !== right.baseFingerprint.size ||
30
- left.baseFingerprint.mtimeMs !== right.baseFingerprint.mtimeMs ||
31
- left.baseFingerprint.hash !== right.baseFingerprint.hash
32
- ) {
33
- return false;
34
- }
35
-
36
- if (left.lines.length !== right.lines.length) {
37
- return false;
38
- }
39
-
40
- for (let index = 0; index < left.lines.length; index += 1) {
41
- if (left.lines[index] !== right.lines[index]) {
42
- return false;
43
- }
44
- }
45
-
46
- return true;
47
- }
48
-
49
- export class VimBuffer {
50
- displayPath: string;
51
- filePath: string;
52
- lines: string[];
53
- cursor: Position;
54
- modified: boolean;
55
- trailingNewline: boolean;
56
- baseFingerprint: VimFingerprint | null;
57
- editabilityChecked: boolean;
58
-
59
- constructor(input: VimLoadedFile) {
60
- this.displayPath = input.displayPath;
61
- this.filePath = input.absolutePath;
62
- this.lines = input.lines.length > 0 ? [...input.lines] : [""];
63
- this.cursor = { line: 0, col: 0 };
64
- this.modified = false;
65
- this.trailingNewline = input.trailingNewline;
66
- this.baseFingerprint = input.fingerprint ? { ...input.fingerprint } : null;
67
- this.editabilityChecked = false;
68
- }
69
-
70
- clone(): VimBuffer {
71
- const clone = new VimBuffer({
72
- absolutePath: this.filePath,
73
- displayPath: this.displayPath,
74
- lines: [...this.lines],
75
- trailingNewline: this.trailingNewline,
76
- fingerprint: this.baseFingerprint ? { ...this.baseFingerprint } : null,
77
- });
78
- clone.cursor = clonePosition(this.cursor);
79
- clone.modified = this.modified;
80
- clone.editabilityChecked = this.editabilityChecked;
81
- return clone;
82
- }
83
-
84
- createSnapshot(): VimBufferSnapshot {
85
- return {
86
- displayPath: this.displayPath,
87
- filePath: this.filePath,
88
- lines: [...this.lines],
89
- cursor: clonePosition(this.cursor),
90
- modified: this.modified,
91
- trailingNewline: this.trailingNewline,
92
- baseFingerprint: this.baseFingerprint ? { ...this.baseFingerprint } : null,
93
- editabilityChecked: this.editabilityChecked,
94
- };
95
- }
96
-
97
- restore(snapshot: VimBufferSnapshot): void {
98
- this.displayPath = snapshot.displayPath;
99
- this.filePath = snapshot.filePath;
100
- this.lines = snapshot.lines.length > 0 ? [...snapshot.lines] : [""];
101
- this.cursor = clonePosition(snapshot.cursor);
102
- this.modified = snapshot.modified;
103
- this.trailingNewline = snapshot.trailingNewline;
104
- this.baseFingerprint = snapshot.baseFingerprint ? { ...snapshot.baseFingerprint } : null;
105
- this.editabilityChecked = snapshot.editabilityChecked;
106
- this.clampCursor();
107
- }
108
-
109
- replaceLoadedFile(input: VimLoadedFile): void {
110
- this.displayPath = input.displayPath;
111
- this.filePath = input.absolutePath;
112
- this.lines = input.lines.length > 0 ? [...input.lines] : [""];
113
- this.cursor = { line: 0, col: 0 };
114
- this.modified = false;
115
- this.trailingNewline = input.trailingNewline;
116
- this.baseFingerprint = input.fingerprint ? { ...input.fingerprint } : null;
117
- this.editabilityChecked = false;
118
- }
119
-
120
- markSaved(input: VimLoadedFile): void {
121
- this.lines = input.lines.length > 0 ? [...input.lines] : [""];
122
- this.modified = false;
123
- this.trailingNewline = input.trailingNewline;
124
- this.baseFingerprint = input.fingerprint ? { ...input.fingerprint } : null;
125
- this.clampCursor();
126
- }
127
-
128
- lineCount(): number {
129
- return this.lines.length;
130
- }
131
-
132
- lastLineIndex(): number {
133
- return Math.max(0, this.lines.length - 1);
134
- }
135
-
136
- getLine(line: number): string {
137
- return this.lines[this.clampLine(line)] ?? "";
138
- }
139
-
140
- clampLine(line: number): number {
141
- return Math.min(Math.max(line, 0), this.lastLineIndex());
142
- }
143
-
144
- clampCol(line: number, col: number): number {
145
- return Math.min(Math.max(col, 0), this.getLine(line).length);
146
- }
147
-
148
- setCursor(position: Position): void {
149
- this.cursor = {
150
- line: this.clampLine(position.line),
151
- col: this.clampCol(position.line, position.col),
152
- };
153
- }
154
-
155
- clampCursor(): void {
156
- this.setCursor(this.cursor);
157
- }
158
-
159
- firstNonBlank(line: number): number {
160
- const content = this.getLine(line);
161
- const index = content.search(/\S/);
162
- return index === -1 ? 0 : index;
163
- }
164
-
165
- getText(): string {
166
- return this.lines.join("\n");
167
- }
168
-
169
- setText(text: string, trailingNewline = this.trailingNewline): void {
170
- const normalizedText = trailingNewline && text.endsWith("\n") ? text.slice(0, -1) : text;
171
- this.lines = splitText(normalizedText);
172
- this.trailingNewline = trailingNewline;
173
- this.clampCursor();
174
- }
175
-
176
- currentOffset(): number {
177
- return this.positionToOffset(this.cursor);
178
- }
179
-
180
- positionToOffset(position: Position): number {
181
- const line = this.clampLine(position.line);
182
- const col = this.clampCol(line, position.col);
183
- let offset = 0;
184
- for (let index = 0; index < line; index += 1) {
185
- offset += this.lines[index]!.length + 1;
186
- }
187
- return offset + col;
188
- }
189
-
190
- offsetToPosition(offset: number): Position {
191
- const text = this.getText();
192
- const clamped = Math.min(Math.max(offset, 0), text.length);
193
- let remaining = clamped;
194
- for (let line = 0; line < this.lines.length; line += 1) {
195
- const current = this.lines[line]!;
196
- if (remaining <= current.length) {
197
- return { line, col: remaining };
198
- }
199
- remaining -= current.length;
200
- if (line < this.lines.length - 1) {
201
- if (remaining === 0) {
202
- return { line: line + 1, col: 0 };
203
- }
204
- remaining -= 1;
205
- }
206
- }
207
- return { line: this.lastLineIndex(), col: this.getLine(this.lastLineIndex()).length };
208
- }
209
-
210
- setCursorFromOffset(offset: number): void {
211
- this.cursor = this.offsetToPosition(offset);
212
- }
213
-
214
- replaceOffsets(start: number, end: number, replacement: string, cursorOffset = start + replacement.length): void {
215
- const text = this.getText();
216
- const normalizedStart = Math.min(Math.max(start, 0), text.length);
217
- const normalizedEnd = Math.min(Math.max(end, normalizedStart), text.length);
218
- const nextText = `${text.slice(0, normalizedStart)}${replacement}${text.slice(normalizedEnd)}`;
219
- // getText() omits the trailing-newline marker, so any \n in the
220
- // replacement is content (a line separator), not a file-trailing newline.
221
- // Bypass setText() which would incorrectly strip it.
222
- this.lines = splitText(nextText);
223
- this.clampCursor();
224
- this.setCursorFromOffset(cursorOffset);
225
- }
226
-
227
- deleteOffsets(start: number, end: number): string {
228
- const text = this.getText();
229
- const normalizedStart = Math.min(Math.max(start, 0), text.length);
230
- const normalizedEnd = Math.min(Math.max(end, normalizedStart), text.length);
231
- const removed = text.slice(normalizedStart, normalizedEnd);
232
- this.replaceOffsets(normalizedStart, normalizedEnd, "", normalizedStart);
233
- return removed;
234
- }
235
-
236
- deleteLines(startLine: number, endLine: number): string[] {
237
- const start = this.clampLine(Math.min(startLine, endLine));
238
- const end = this.clampLine(Math.max(startLine, endLine));
239
- const removed = this.lines.slice(start, end + 1);
240
- this.lines.splice(start, end - start + 1);
241
- if (this.lines.length === 0) {
242
- this.lines = [""];
243
- }
244
- this.setCursor({ line: Math.min(start, this.lastLineIndex()), col: 0 });
245
- if (this.lines.length > 1 || removed.length > 1) {
246
- this.trailingNewline = true;
247
- }
248
- return removed;
249
- }
250
-
251
- insertLines(index: number, newLines: string[]): void {
252
- const at = Math.min(Math.max(index, 0), this.lines.length);
253
- const normalized = newLines.length > 0 ? newLines : [""];
254
- this.lines.splice(at, 0, ...normalized);
255
- this.setCursor({ line: at, col: 0 });
256
- this.trailingNewline = true;
257
- }
258
-
259
- replaceLine(line: number, content: string): void {
260
- const target = this.clampLine(line);
261
- this.lines[target] = content;
262
- this.setCursor(this.cursor);
263
- }
264
-
265
- joinLines(startLine: number, count: number): void {
266
- const start = this.clampLine(startLine);
267
- const end = this.clampLine(start + Math.max(count, 1));
268
- if (start >= end) {
269
- return;
270
- }
271
- const joined = this.lines
272
- .slice(start, end + 1)
273
- .map(line => line.trim())
274
- .join(" ");
275
- this.lines.splice(start, end - start + 1, joined);
276
- this.setCursor({ line: start, col: Math.max(0, joined.length - 1) });
277
- }
278
-
279
- indentLines(startLine: number, endLine: number, indentUnit: string, direction: 1 | -1): void {
280
- const start = this.clampLine(Math.min(startLine, endLine));
281
- const end = this.clampLine(Math.max(startLine, endLine));
282
- for (let line = start; line <= end; line += 1) {
283
- const content = this.lines[line] ?? "";
284
- if (direction > 0) {
285
- this.lines[line] = `${indentUnit}${content}`;
286
- continue;
287
- }
288
- if (content.startsWith(indentUnit)) {
289
- this.lines[line] = content.slice(indentUnit.length);
290
- continue;
291
- }
292
- const spaces = content.match(/^ +/)?.[0].length ?? 0;
293
- this.lines[line] = content.slice(Math.min(spaces, indentUnit.length));
294
- }
295
- this.setCursor(this.cursor);
296
- }
297
-
298
- getCharacterAtOffset(offset: number): string {
299
- const text = this.getText();
300
- if (offset < 0 || offset >= text.length) {
301
- return "";
302
- }
303
- return text[offset] ?? "";
304
- }
305
-
306
- getCharacter(position: Position): string {
307
- return this.getCharacterAtOffset(this.positionToOffset(position));
308
- }
309
- }
@@ -1,382 +0,0 @@
1
- import type { VimExCommand, VimLineRange } from "./types";
2
- import { VimInputError } from "./types";
3
-
4
- export interface VimExParseContext {
5
- currentLine: number;
6
- lastLine: number;
7
- }
8
-
9
- interface ParsedLineAddress {
10
- line: number;
11
- nextIndex: number;
12
- }
13
-
14
- function clampLine(line: number, context: VimExParseContext): number {
15
- return Math.min(Math.max(line, 1), Math.max(1, context.lastLine));
16
- }
17
-
18
- function readDigits(raw: string, start: number): { digits: string; nextIndex: number } {
19
- let index = start;
20
- let digits = "";
21
- while (index < raw.length) {
22
- const char = raw[index] ?? "";
23
- if (!/^\d$/.test(char)) {
24
- break;
25
- }
26
- digits += char;
27
- index += 1;
28
- }
29
- return { digits, nextIndex: index };
30
- }
31
-
32
- function parseLineAddress(
33
- raw: string,
34
- start: number,
35
- context: VimExParseContext,
36
- relativeBase = context.currentLine,
37
- ): ParsedLineAddress | undefined {
38
- let index = start;
39
- let line: number | undefined;
40
- const first = raw[index] ?? "";
41
-
42
- if (/^\d$/.test(first)) {
43
- const { digits, nextIndex } = readDigits(raw, index);
44
- line = Number.parseInt(digits, 10);
45
- index = nextIndex;
46
- } else if (first === ".") {
47
- line = context.currentLine;
48
- index += 1;
49
- } else if (first === "$") {
50
- line = context.lastLine;
51
- index += 1;
52
- } else if (first === "+" || first === "-") {
53
- line = relativeBase;
54
- } else {
55
- return undefined;
56
- }
57
-
58
- while (index < raw.length) {
59
- const sign = raw[index];
60
- if (sign !== "+" && sign !== "-") {
61
- break;
62
- }
63
- index += 1;
64
- const { digits, nextIndex } = readDigits(raw, index);
65
- index = nextIndex;
66
- const offset = digits.length > 0 ? Number.parseInt(digits, 10) : 1;
67
- line += sign === "+" ? offset : -offset;
68
- }
69
-
70
- return { line: clampLine(line, context), nextIndex: index };
71
- }
72
-
73
- function parseLineRange(raw: string, context?: VimExParseContext): { range?: VimLineRange | "all"; rest: string } {
74
- if (raw.startsWith("%")) {
75
- return { range: "all", rest: raw.slice(1).trimStart() };
76
- }
77
-
78
- if (!context) {
79
- const match = raw.match(/^(\d+)(?:\s*,\s*(\d+))?/);
80
- if (!match) {
81
- return { rest: raw };
82
- }
83
-
84
- const start = Number.parseInt(match[1] ?? "", 10);
85
- const end = Number.parseInt(match[2] ?? match[1] ?? "", 10);
86
- return {
87
- range: { start, end },
88
- rest: raw.slice(match[0].length).trimStart(),
89
- };
90
- }
91
-
92
- const first = parseLineAddress(raw, 0, context);
93
- if (!first) {
94
- return { rest: raw };
95
- }
96
-
97
- let index = first.nextIndex;
98
- while (raw[index] === " ") {
99
- index += 1;
100
- }
101
-
102
- const separator = raw[index];
103
- if (separator !== "," && separator !== ";") {
104
- return {
105
- range: { start: first.line, end: first.line },
106
- rest: raw.slice(index).trimStart(),
107
- };
108
- }
109
-
110
- index += 1;
111
- while (raw[index] === " ") {
112
- index += 1;
113
- }
114
-
115
- const second = parseLineAddress(raw, index, context, separator === ";" ? first.line : context.currentLine);
116
- if (!second) {
117
- throw new VimInputError(`Missing line address after ${separator}`);
118
- }
119
-
120
- return {
121
- range: { start: first.line, end: second.line },
122
- rest: raw.slice(second.nextIndex).trimStart(),
123
- };
124
- }
125
-
126
- function parseDelimitedSegments(raw: string): { pattern: string; replacement: string; flags: string } {
127
- if (raw.length === 0) {
128
- throw new VimInputError("Missing substitute delimiter");
129
- }
130
-
131
- const delimiter = raw[0] ?? "/";
132
- const segments: string[] = [];
133
- let current = "";
134
- let escaped = false;
135
-
136
- for (let index = 1; index < raw.length; index += 1) {
137
- const char = raw[index] ?? "";
138
- if (escaped) {
139
- current += char;
140
- escaped = false;
141
- continue;
142
- }
143
- if (char === "\\") {
144
- escaped = true;
145
- current += char;
146
- continue;
147
- }
148
- if (char === delimiter && segments.length < 2) {
149
- segments.push(current);
150
- current = "";
151
- continue;
152
- }
153
- current += char;
154
- }
155
-
156
- if (segments.length !== 2) {
157
- throw new VimInputError("Substitute command must look like :s/pattern/replacement/flags");
158
- }
159
-
160
- return {
161
- pattern: segments[0] ?? "",
162
- replacement: segments[1] ?? "",
163
- flags: current.trim(),
164
- };
165
- }
166
-
167
- function parseDestination(raw: string, context?: VimExParseContext): number {
168
- const trimmed = raw.trim();
169
- if (trimmed.length === 0) {
170
- throw new VimInputError("Missing destination");
171
- }
172
-
173
- if (/^\d+$/.test(trimmed)) {
174
- return Number.parseInt(trimmed, 10);
175
- }
176
-
177
- if (context) {
178
- const address = parseLineAddress(trimmed, 0, context);
179
- if (address && trimmed.slice(address.nextIndex).trim().length === 0) {
180
- return address.line;
181
- }
182
- }
183
-
184
- const destination = Number.parseInt(trimmed, 10);
185
- if (Number.isNaN(destination)) {
186
- throw new VimInputError("Invalid destination");
187
- }
188
- return destination;
189
- }
190
-
191
- function matchGlobalCommand(rest: string): { pattern: string; command: string; invert: boolean } | undefined {
192
- const globalMatch = rest.match(/^(g|v|g!|global|global!|vglobal)\s*([/|#])(.+?)\2(.*)$/);
193
- if (!globalMatch) {
194
- return undefined;
195
- }
196
- return {
197
- invert: globalMatch[1] === "v" || globalMatch[1] === "vglobal" || globalMatch[1]?.endsWith("!") === true,
198
- pattern: globalMatch[3] ?? "",
199
- command: (globalMatch[4] ?? "d").trim() || "d",
200
- };
201
- }
202
-
203
- function matchDestinationCommand(rest: string, prefixes: readonly string[]): string | undefined {
204
- for (const prefix of prefixes) {
205
- if (!rest.startsWith(prefix)) {
206
- continue;
207
- }
208
- const suffix = rest.slice(prefix.length);
209
- if (suffix.length === 0) {
210
- return "";
211
- }
212
- if (/^\s/.test(suffix) || /^[\d.$+-]/.test(suffix)) {
213
- return suffix.trim();
214
- }
215
- }
216
- return undefined;
217
- }
218
-
219
- export function parseExCommand(input: string, context?: VimExParseContext): VimExCommand {
220
- const trimmed = input.trim();
221
- const normalized = trimmed.startsWith(":") ? trimmed.slice(1).trimStart() : trimmed;
222
- if (normalized.length === 0) {
223
- throw new VimInputError("Empty ex command");
224
- }
225
-
226
- if (/^\d+$/.test(normalized)) {
227
- return {
228
- kind: "goto-line",
229
- line: Number.parseInt(normalized, 10),
230
- };
231
- }
232
-
233
- if (normalized === "w" || normalized === "write") {
234
- return { kind: "write", force: false };
235
- }
236
- if (normalized === "w!" || normalized === "write!") {
237
- return { kind: "write", force: true };
238
- }
239
- if (normalized === "update" || normalized === "up") {
240
- return { kind: "update", force: false };
241
- }
242
- if (normalized === "update!" || normalized === "up!") {
243
- return { kind: "update", force: true };
244
- }
245
- if (normalized === "wq" || normalized === "x" || normalized === "xit" || normalized === "exit") {
246
- return { kind: "write-quit", force: false };
247
- }
248
- if (normalized === "wq!" || normalized === "x!" || normalized === "xit!" || normalized === "exit!") {
249
- return { kind: "write-quit", force: true };
250
- }
251
- if (normalized === "q" || normalized === "quit") {
252
- return { kind: "quit", force: false };
253
- }
254
- if (normalized === "q!" || normalized === "quit!") {
255
- return { kind: "quit", force: true };
256
- }
257
- if (normalized === "e" || normalized === "edit") {
258
- return { kind: "edit", force: false };
259
- }
260
- if (normalized === "e!" || normalized === "edit!") {
261
- return { kind: "edit", force: true };
262
- }
263
- if (normalized.startsWith("e ") || normalized.startsWith("edit ")) {
264
- const path = normalized.startsWith("edit ") ? normalized.slice(5).trim() : normalized.slice(2).trim();
265
- return { kind: "edit", force: false, path };
266
- }
267
- if (normalized.startsWith("e! ") || normalized.startsWith("edit! ")) {
268
- const path = normalized.startsWith("edit! ") ? normalized.slice(6).trim() : normalized.slice(3).trim();
269
- return { kind: "edit", force: true, path };
270
- }
271
-
272
- const global = matchGlobalCommand(normalized);
273
- if (global) {
274
- return { kind: "global", ...global };
275
- }
276
-
277
- const { range, rest } = parseLineRange(normalized, context);
278
- if (range && rest.length === 0) {
279
- if (range === "all") {
280
- throw new VimInputError(":% requires a following command");
281
- }
282
- return {
283
- kind: "goto-line",
284
- line: range.start,
285
- };
286
- }
287
-
288
- const rangedGlobal = matchGlobalCommand(rest);
289
- if (rangedGlobal) {
290
- return { kind: "global", range, ...rangedGlobal };
291
- }
292
-
293
- if (rest === "sort" || rest.startsWith("sort ") || rest.startsWith("sort!")) {
294
- const flags = rest.slice(4).trim();
295
- return { kind: "sort", range: range ?? undefined, flags };
296
- }
297
- if (rest === "j" || rest === "join" || rest === "j!" || rest === "join!") {
298
- return { kind: "join", range: range ?? undefined, trimWhitespace: !rest.endsWith("!") };
299
- }
300
-
301
- if (rest.startsWith("substitute")) {
302
- const segments = parseDelimitedSegments(rest.slice("substitute".length));
303
- return {
304
- kind: "substitute",
305
- range,
306
- pattern: segments.pattern,
307
- replacement: segments.replacement,
308
- flags: segments.flags,
309
- };
310
- }
311
-
312
- if (/^s(?:\W|$)/.test(rest)) {
313
- const segments = parseDelimitedSegments(rest.slice(1));
314
- return {
315
- kind: "substitute",
316
- range,
317
- pattern: segments.pattern,
318
- replacement: segments.replacement,
319
- flags: segments.flags,
320
- };
321
- }
322
-
323
- if (
324
- rest === "d" ||
325
- rest === "del" ||
326
- rest === "delete" ||
327
- rest.startsWith("d ") ||
328
- rest.startsWith("del ") ||
329
- rest.startsWith("delete ")
330
- ) {
331
- return {
332
- kind: "delete",
333
- range,
334
- };
335
- }
336
-
337
- if (
338
- rest === "y" ||
339
- rest === "ya" ||
340
- rest === "yank" ||
341
- rest.startsWith("y ") ||
342
- rest.startsWith("ya ") ||
343
- rest.startsWith("yank ")
344
- ) {
345
- return {
346
- kind: "yank",
347
- range,
348
- };
349
- }
350
-
351
- if (rest === "pu" || rest === "put" || rest === "pu!" || rest === "put!") {
352
- return {
353
- kind: "put",
354
- range,
355
- before: rest.endsWith("!"),
356
- };
357
- }
358
-
359
- const copyDestination = matchDestinationCommand(rest, ["copy", "co", "t"]);
360
- if (copyDestination !== undefined) {
361
- const destination = parseDestination(copyDestination, context);
362
- return { kind: "copy", range, destination };
363
- }
364
-
365
- const moveDestination = matchDestinationCommand(rest, ["move", "mo", "m"]);
366
- if (moveDestination !== undefined) {
367
- const destination = parseDestination(moveDestination, context);
368
- return { kind: "move", range, destination };
369
- }
370
-
371
- if (rest === "a" || rest === "append" || rest.startsWith("a ") || rest.startsWith("append ")) {
372
- const text = rest.startsWith("append") ? rest.slice(6).trimStart() : rest.slice(1).trimStart();
373
- return { kind: "append", range: range === "all" ? undefined : range, text };
374
- }
375
-
376
- if (rest === "i" || rest === "insert" || rest.startsWith("i ") || rest.startsWith("insert ")) {
377
- const text = rest.startsWith("insert") ? rest.slice(6).trimStart() : rest.slice(1).trimStart();
378
- return { kind: "insert-before", range: range === "all" ? undefined : range, text };
379
- }
380
-
381
- throw new VimInputError(`Unsupported ex command: ${input}.`);
382
- }