@oh-my-pi/pi-coding-agent 6.2.0 → 6.7.67

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 (93) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/docs/sdk.md +1 -1
  3. package/package.json +5 -5
  4. package/scripts/generate-template.ts +6 -6
  5. package/src/cli/args.ts +3 -0
  6. package/src/core/agent-session.ts +39 -0
  7. package/src/core/bash-executor.ts +3 -3
  8. package/src/core/cursor/exec-bridge.ts +95 -88
  9. package/src/core/custom-commands/bundled/review/index.ts +142 -145
  10. package/src/core/custom-commands/bundled/wt/index.ts +68 -66
  11. package/src/core/custom-commands/loader.ts +4 -6
  12. package/src/core/custom-tools/index.ts +2 -2
  13. package/src/core/custom-tools/loader.ts +66 -61
  14. package/src/core/custom-tools/types.ts +4 -4
  15. package/src/core/custom-tools/wrapper.ts +61 -25
  16. package/src/core/event-bus.ts +19 -47
  17. package/src/core/extensions/index.ts +8 -4
  18. package/src/core/extensions/loader.ts +160 -120
  19. package/src/core/extensions/types.ts +4 -4
  20. package/src/core/extensions/wrapper.ts +149 -100
  21. package/src/core/hooks/index.ts +1 -1
  22. package/src/core/hooks/tool-wrapper.ts +96 -70
  23. package/src/core/hooks/types.ts +1 -2
  24. package/src/core/index.ts +1 -0
  25. package/src/core/mcp/index.ts +6 -2
  26. package/src/core/mcp/json-rpc.ts +88 -0
  27. package/src/core/mcp/loader.ts +22 -4
  28. package/src/core/mcp/manager.ts +202 -48
  29. package/src/core/mcp/tool-bridge.ts +143 -55
  30. package/src/core/mcp/tool-cache.ts +122 -0
  31. package/src/core/python-executor.ts +3 -9
  32. package/src/core/sdk.ts +33 -32
  33. package/src/core/session-manager.ts +30 -0
  34. package/src/core/settings-manager.ts +54 -1
  35. package/src/core/ssh/ssh-executor.ts +6 -84
  36. package/src/core/streaming-output.ts +107 -53
  37. package/src/core/tools/ask.ts +92 -93
  38. package/src/core/tools/bash.ts +103 -94
  39. package/src/core/tools/calculator.ts +41 -26
  40. package/src/core/tools/complete.ts +76 -66
  41. package/src/core/tools/context.ts +22 -24
  42. package/src/core/tools/exa/index.ts +1 -1
  43. package/src/core/tools/exa/mcp-client.ts +56 -101
  44. package/src/core/tools/find.ts +250 -253
  45. package/src/core/tools/git.ts +39 -33
  46. package/src/core/tools/grep.ts +440 -427
  47. package/src/core/tools/index.ts +63 -61
  48. package/src/core/tools/ls.ts +119 -114
  49. package/src/core/tools/lsp/clients/biome-client.ts +5 -7
  50. package/src/core/tools/lsp/clients/index.ts +4 -4
  51. package/src/core/tools/lsp/clients/lsp-linter-client.ts +5 -7
  52. package/src/core/tools/lsp/config.ts +2 -2
  53. package/src/core/tools/lsp/index.ts +604 -578
  54. package/src/core/tools/notebook.ts +121 -119
  55. package/src/core/tools/output.ts +163 -147
  56. package/src/core/tools/patch/applicator.ts +1100 -0
  57. package/src/core/tools/patch/diff.ts +362 -0
  58. package/src/core/tools/patch/fuzzy.ts +647 -0
  59. package/src/core/tools/patch/index.ts +430 -0
  60. package/src/core/tools/patch/normalize.ts +220 -0
  61. package/src/core/tools/patch/normative.ts +73 -0
  62. package/src/core/tools/patch/parser.ts +528 -0
  63. package/src/core/tools/patch/shared.ts +257 -0
  64. package/src/core/tools/patch/types.ts +244 -0
  65. package/src/core/tools/python.ts +139 -136
  66. package/src/core/tools/read.ts +239 -216
  67. package/src/core/tools/render-utils.ts +196 -77
  68. package/src/core/tools/renderers.ts +6 -2
  69. package/src/core/tools/ssh.ts +99 -80
  70. package/src/core/tools/task/executor.ts +11 -7
  71. package/src/core/tools/task/index.ts +352 -343
  72. package/src/core/tools/task/worker.ts +13 -23
  73. package/src/core/tools/todo-write.ts +74 -59
  74. package/src/core/tools/web-fetch.ts +54 -47
  75. package/src/core/tools/web-search/index.ts +27 -16
  76. package/src/core/tools/write.ts +108 -47
  77. package/src/core/ttsr.ts +106 -152
  78. package/src/core/voice.ts +49 -39
  79. package/src/index.ts +16 -12
  80. package/src/lib/worktree/index.ts +1 -9
  81. package/src/modes/interactive/components/diff.ts +15 -8
  82. package/src/modes/interactive/components/settings-defs.ts +42 -0
  83. package/src/modes/interactive/components/tool-execution.ts +46 -8
  84. package/src/modes/interactive/controllers/event-controller.ts +6 -19
  85. package/src/modes/interactive/controllers/input-controller.ts +1 -1
  86. package/src/modes/interactive/utils/ui-helpers.ts +5 -1
  87. package/src/modes/rpc/rpc-mode.ts +99 -81
  88. package/src/prompts/tools/patch.md +76 -0
  89. package/src/prompts/tools/read.md +1 -1
  90. package/src/prompts/tools/{edit.md → replace.md} +1 -0
  91. package/src/utils/shell.ts +0 -40
  92. package/src/core/tools/edit-diff.ts +0 -574
  93. package/src/core/tools/edit.ts +0 -345
@@ -0,0 +1,430 @@
1
+ /**
2
+ * Edit tool module.
3
+ *
4
+ * Supports two modes:
5
+ * - Replace mode (default): oldText/newText replacement with fuzzy matching
6
+ * - Patch mode: structured diff format with explicit operation type
7
+ *
8
+ * The mode is determined by the `edit.patchMode` setting.
9
+ */
10
+
11
+ import { mkdir } from "node:fs/promises";
12
+ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
13
+ import { StringEnum } from "@oh-my-pi/pi-ai";
14
+ import { Type } from "@sinclair/typebox";
15
+ import patchDescription from "../../../prompts/tools/patch.md" with { type: "text" };
16
+ import replaceDescription from "../../../prompts/tools/replace.md" with { type: "text" };
17
+ import { renderPromptTemplate } from "../../prompt-templates";
18
+ import type { ToolSession } from "../index";
19
+ import {
20
+ createLspWritethrough,
21
+ type FileDiagnosticsResult,
22
+ flushLspWritethroughBatch,
23
+ type WritethroughCallback,
24
+ writethroughNoop,
25
+ } from "../lsp/index";
26
+ import { resolveToCwd } from "../path-utils";
27
+ import { applyPatch } from "./applicator";
28
+ import { generateDiffString, generateUnifiedDiffString, replaceText } from "./diff";
29
+ import { DEFAULT_FUZZY_THRESHOLD, findMatch } from "./fuzzy";
30
+ import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "./normalize";
31
+ import { buildNormativeUpdateInput } from "./normative";
32
+ import { type EditToolDetails, getLspBatchRequest } from "./shared";
33
+ // Internal imports
34
+ import type { FileSystem, Operation, PatchInput } from "./types";
35
+ import { EditMatchError } from "./types";
36
+
37
+ // ═══════════════════════════════════════════════════════════════════════════
38
+ // Re-exports
39
+ // ═══════════════════════════════════════════════════════════════════════════
40
+
41
+ // Application
42
+ export { applyPatch, defaultFileSystem, previewPatch } from "./applicator";
43
+ // Diff generation
44
+ export { computeEditDiff, computePatchDiff, generateDiffString, generateUnifiedDiffString, replaceText } from "./diff";
45
+
46
+ // Fuzzy matching
47
+ export { DEFAULT_FUZZY_THRESHOLD, findContextLine, findMatch as findEditMatch, findMatch, seekSequence } from "./fuzzy";
48
+
49
+ // Normalization
50
+ export {
51
+ adjustIndentation as adjustNewTextIndentation,
52
+ detectLineEnding,
53
+ normalizeToLF,
54
+ restoreLineEndings,
55
+ stripBom,
56
+ } from "./normalize";
57
+
58
+ // Parsing
59
+ export { normalizeCreateContent, normalizeDiff, parseHunks as parseDiffHunks } from "./parser";
60
+ export type { EditRenderContext, EditToolDetails } from "./shared";
61
+ // Rendering
62
+ export { editToolRenderer, getLspBatchRequest } from "./shared";
63
+ export type {
64
+ ApplyPatchOptions,
65
+ ApplyPatchResult,
66
+ ContextLineResult,
67
+ DiffError,
68
+ DiffError as EditDiffError,
69
+ DiffHunk,
70
+ DiffHunk as UpdateChunk,
71
+ DiffHunk as UpdateFileChunk,
72
+ DiffResult,
73
+ DiffResult as EditDiffResult,
74
+ FileChange,
75
+ FileSystem,
76
+ FuzzyMatch as EditMatch,
77
+ FuzzyMatch,
78
+ MatchOutcome as EditMatchOutcome,
79
+ MatchOutcome,
80
+ Operation,
81
+ PatchInput,
82
+ SequenceSearchResult,
83
+ } from "./types";
84
+ // Types
85
+ // Legacy aliases for backwards compatibility
86
+ export { ApplyPatchError, EditMatchError, ParseError } from "./types";
87
+
88
+ // ═══════════════════════════════════════════════════════════════════════════
89
+ // Schemas
90
+ // ═══════════════════════════════════════════════════════════════════════════
91
+
92
+ const replaceEditSchema = Type.Object({
93
+ path: Type.String({ description: "Path to the file to edit (relative or absolute)" }),
94
+ oldText: Type.String({
95
+ description: "Text to find and replace (high-confidence fuzzy matching for whitespace/indentation is always on)",
96
+ }),
97
+ newText: Type.String({ description: "New text to replace the old text with" }),
98
+ all: Type.Optional(Type.Boolean({ description: "Replace all occurrences instead of requiring unique match" })),
99
+ });
100
+
101
+ const patchEditSchema = Type.Object({
102
+ path: Type.String({ description: "Path to the file" }),
103
+ op: Type.Optional(
104
+ StringEnum(["create", "delete", "update"], {
105
+ description: "The operation to perform (Defaults to 'update')",
106
+ }),
107
+ ),
108
+ rename: Type.Optional(Type.String({ description: "New path, if moving" })),
109
+ diff: Type.Optional(
110
+ Type.String({
111
+ description: "Diff hunk(s) for update. Full content for create.",
112
+ }),
113
+ ),
114
+ });
115
+
116
+ export type ReplaceParams = { path: string; oldText: string; newText: string; all?: boolean };
117
+ export type PatchParams = { path: string; op?: string; rename?: string; diff?: string };
118
+
119
+ // ═══════════════════════════════════════════════════════════════════════════
120
+ // LSP FileSystem for patch mode
121
+ // ═══════════════════════════════════════════════════════════════════════════
122
+
123
+ class LspFileSystem implements FileSystem {
124
+ private lastDiagnostics: FileDiagnosticsResult | undefined;
125
+ private fileCache: Record<string, Bun.BunFile> = {};
126
+
127
+ constructor(
128
+ private readonly writethrough: (
129
+ dst: string,
130
+ content: string,
131
+ signal?: AbortSignal,
132
+ file?: import("bun").BunFile,
133
+ batch?: { id: string; flush: boolean },
134
+ ) => Promise<FileDiagnosticsResult | undefined>,
135
+ private readonly signal?: AbortSignal,
136
+ private readonly batchRequest?: { id: string; flush: boolean },
137
+ ) {}
138
+
139
+ #getFile(path: string): Bun.BunFile {
140
+ if (this.fileCache[path]) {
141
+ return this.fileCache[path];
142
+ }
143
+ const file = Bun.file(path);
144
+ this.fileCache[path] = file;
145
+ return file;
146
+ }
147
+
148
+ async exists(path: string): Promise<boolean> {
149
+ return this.#getFile(path).exists();
150
+ }
151
+
152
+ async read(path: string): Promise<string> {
153
+ return this.#getFile(path).text();
154
+ }
155
+
156
+ async readBinary(path: string): Promise<Uint8Array> {
157
+ const buffer = await this.#getFile(path).arrayBuffer();
158
+ return new Uint8Array(buffer);
159
+ }
160
+
161
+ async write(path: string, content: string): Promise<void> {
162
+ const file = this.#getFile(path);
163
+ const result = await this.writethrough(path, content, this.signal, file, this.batchRequest);
164
+ if (result) {
165
+ this.lastDiagnostics = result;
166
+ }
167
+ }
168
+
169
+ async delete(path: string): Promise<void> {
170
+ await this.#getFile(path).unlink();
171
+ }
172
+
173
+ async mkdir(path: string): Promise<void> {
174
+ await mkdir(path, { recursive: true });
175
+ }
176
+
177
+ getDiagnostics(): FileDiagnosticsResult | undefined {
178
+ return this.lastDiagnostics;
179
+ }
180
+ }
181
+
182
+ // ═══════════════════════════════════════════════════════════════════════════
183
+ // Tool Class
184
+ // ═══════════════════════════════════════════════════════════════════════════
185
+
186
+ type TInput = typeof replaceEditSchema | typeof patchEditSchema;
187
+
188
+ /**
189
+ * Edit tool implementation.
190
+ *
191
+ * Creates replace-mode or patch-mode behavior based on session settings.
192
+ */
193
+ export class EditTool implements AgentTool<TInput> {
194
+ public readonly name = "edit";
195
+ public readonly label = "Edit";
196
+ public readonly description: string;
197
+ public readonly parameters: TInput;
198
+
199
+ private readonly session: ToolSession;
200
+ private readonly patchMode: boolean;
201
+ private readonly allowFuzzy: boolean;
202
+ private readonly fuzzyThreshold: number;
203
+ private readonly writethrough: WritethroughCallback;
204
+
205
+ constructor(session: ToolSession) {
206
+ this.session = session;
207
+
208
+ const {
209
+ OMP_EDIT_FUZZY: editFuzzy = "auto",
210
+ OMP_EDIT_FUZZY_THRESHOLD: editFuzzyThreshold = "auto",
211
+ OMP_EDIT_VARIANT: editVariant = "auto",
212
+ } = process.env;
213
+
214
+ switch (editVariant) {
215
+ case "replace":
216
+ this.patchMode = false;
217
+ break;
218
+ case "patch":
219
+ this.patchMode = true;
220
+ break;
221
+ case "auto":
222
+ this.patchMode = session.settings?.getEditPatchMode?.() ?? true;
223
+ break;
224
+ default:
225
+ throw new Error(`Invalid OMP_EDIT_VARIANT: ${process.env.OMP_EDIT_VARIANT}`);
226
+ }
227
+ switch (editFuzzy) {
228
+ case "true":
229
+ case "1":
230
+ this.allowFuzzy = true;
231
+ break;
232
+ case "false":
233
+ case "0":
234
+ this.allowFuzzy = false;
235
+ break;
236
+ case "auto":
237
+ this.allowFuzzy = session.settings?.getEditFuzzyMatch() ?? true;
238
+ break;
239
+ default:
240
+ throw new Error(`Invalid OMP_EDIT_FUZZY: ${editFuzzy}`);
241
+ }
242
+ switch (editFuzzyThreshold) {
243
+ case "auto":
244
+ this.fuzzyThreshold = session.settings?.getEditFuzzyThreshold?.() ?? DEFAULT_FUZZY_THRESHOLD;
245
+ break;
246
+ default:
247
+ this.fuzzyThreshold = parseFloat(editFuzzyThreshold);
248
+ if (Number.isNaN(this.fuzzyThreshold) || this.fuzzyThreshold < 0 || this.fuzzyThreshold > 1) {
249
+ throw new Error(`Invalid OMP_EDIT_FUZZY_THRESHOLD: ${editFuzzyThreshold}`);
250
+ }
251
+ break;
252
+ }
253
+
254
+ const enableLsp = session.enableLsp ?? true;
255
+ const enableDiagnostics = enableLsp ? (session.settings?.getLspDiagnosticsOnEdit() ?? false) : false;
256
+ const enableFormat = enableLsp ? (session.settings?.getLspFormatOnWrite() ?? true) : false;
257
+ this.writethrough = enableLsp
258
+ ? createLspWritethrough(session.cwd, { enableFormat, enableDiagnostics })
259
+ : writethroughNoop;
260
+ this.description = this.patchMode
261
+ ? renderPromptTemplate(patchDescription)
262
+ : renderPromptTemplate(replaceDescription);
263
+ this.parameters = this.patchMode ? patchEditSchema : replaceEditSchema;
264
+ }
265
+
266
+ public async execute(
267
+ _toolCallId: string,
268
+ params: ReplaceParams | PatchParams,
269
+ signal?: AbortSignal,
270
+ _onUpdate?: AgentToolUpdateCallback<EditToolDetails, TInput>,
271
+ context?: AgentToolContext,
272
+ ): Promise<AgentToolResult<EditToolDetails, TInput>> {
273
+ const batchRequest = getLspBatchRequest(context?.toolCall);
274
+
275
+ // ─────────────────────────────────────────────────────────────────
276
+ // Patch mode execution
277
+ // ─────────────────────────────────────────────────────────────────
278
+ if (this.patchMode) {
279
+ const { path, op: rawOp, rename, diff } = params as PatchParams;
280
+
281
+ // Normalize unrecognized operations to "update"
282
+ const op: Operation = rawOp === "create" || rawOp === "delete" ? rawOp : "update";
283
+
284
+ if (path.endsWith(".ipynb")) {
285
+ throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
286
+ }
287
+ if (rename?.endsWith(".ipynb")) {
288
+ throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
289
+ }
290
+
291
+ const input: PatchInput = { path, op, rename, diff };
292
+ const fs = new LspFileSystem(this.writethrough, signal, batchRequest);
293
+ const result = await applyPatch(input, {
294
+ cwd: this.session.cwd,
295
+ fs,
296
+ fuzzyThreshold: this.fuzzyThreshold,
297
+ allowFuzzy: this.allowFuzzy,
298
+ });
299
+ const effRename = result.change.newPath ? rename : undefined;
300
+
301
+ // Generate diff for display
302
+ let diffResult = { diff: "", firstChangedLine: undefined as number | undefined };
303
+ let normative: PatchInput | undefined;
304
+ if (result.change.type === "update" && result.change.oldContent && result.change.newContent) {
305
+ const normalizedOld = normalizeToLF(stripBom(result.change.oldContent).text);
306
+ const normalizedNew = normalizeToLF(stripBom(result.change.newContent).text);
307
+ diffResult = generateUnifiedDiffString(normalizedOld, normalizedNew);
308
+ normative = buildNormativeUpdateInput({
309
+ path,
310
+ rename: effRename,
311
+ oldContent: result.change.oldContent,
312
+ newContent: result.change.newContent,
313
+ });
314
+ }
315
+
316
+ let resultText: string;
317
+ switch (result.change.type) {
318
+ case "create":
319
+ resultText = `Created ${path}`;
320
+ break;
321
+ case "delete":
322
+ resultText = `Deleted ${path}`;
323
+ break;
324
+ case "update":
325
+ resultText = effRename ? `Updated and moved ${path} to ${effRename}` : `Updated ${path}`;
326
+ break;
327
+ }
328
+
329
+ let diagnostics = fs.getDiagnostics();
330
+ if (op === "delete" && batchRequest?.flush) {
331
+ const flushedDiagnostics = await flushLspWritethroughBatch(batchRequest.id, this.session.cwd, signal);
332
+ diagnostics ??= flushedDiagnostics;
333
+ }
334
+ if (diagnostics?.messages?.length) {
335
+ resultText += `\n\nLSP Diagnostics (${diagnostics.summary}):\n`;
336
+ resultText += diagnostics.messages.map((d) => ` ${d}`).join("\n");
337
+ }
338
+
339
+ return {
340
+ content: [{ type: "text", text: resultText }],
341
+ details: {
342
+ diff: diffResult.diff,
343
+ firstChangedLine: diffResult.firstChangedLine,
344
+ diagnostics,
345
+ op,
346
+ rename: effRename,
347
+ },
348
+ $normative: normative,
349
+ };
350
+ }
351
+
352
+ // ─────────────────────────────────────────────────────────────────
353
+ // Replace mode execution
354
+ // ─────────────────────────────────────────────────────────────────
355
+ const { path, oldText, newText, all } = params as ReplaceParams;
356
+
357
+ if (path.endsWith(".ipynb")) {
358
+ throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
359
+ }
360
+
361
+ if (oldText.length === 0) {
362
+ throw new Error("oldText must not be empty.");
363
+ }
364
+
365
+ const absolutePath = resolveToCwd(path, this.session.cwd);
366
+ const file = Bun.file(absolutePath);
367
+
368
+ if (!(await file.exists())) {
369
+ throw new Error(`File not found: ${path}`);
370
+ }
371
+
372
+ const rawContent = await file.text();
373
+ const { bom, text: content } = stripBom(rawContent);
374
+ const originalEnding = detectLineEnding(content);
375
+ const normalizedContent = normalizeToLF(content);
376
+ const normalizedOldText = normalizeToLF(oldText);
377
+ const normalizedNewText = normalizeToLF(newText);
378
+
379
+ const result = replaceText(normalizedContent, normalizedOldText, normalizedNewText, {
380
+ fuzzy: this.allowFuzzy,
381
+ all: all ?? false,
382
+ threshold: this.fuzzyThreshold,
383
+ });
384
+
385
+ if (result.count === 0) {
386
+ // Get error details
387
+ const matchOutcome = findMatch(normalizedContent, normalizedOldText, {
388
+ allowFuzzy: this.allowFuzzy,
389
+ threshold: this.fuzzyThreshold,
390
+ });
391
+
392
+ if (matchOutcome.occurrences && matchOutcome.occurrences > 1) {
393
+ throw new Error(
394
+ `Found ${matchOutcome.occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique, or use all: true to replace all.`,
395
+ );
396
+ }
397
+
398
+ throw new EditMatchError(path, normalizedOldText, matchOutcome.closest, {
399
+ allowFuzzy: this.allowFuzzy,
400
+ threshold: this.fuzzyThreshold,
401
+ fuzzyMatches: matchOutcome.fuzzyMatches,
402
+ });
403
+ }
404
+
405
+ if (normalizedContent === result.content) {
406
+ throw new Error(
407
+ `No changes made to ${path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.`,
408
+ );
409
+ }
410
+
411
+ const finalContent = bom + restoreLineEndings(result.content, originalEnding);
412
+ const diagnostics = await this.writethrough(absolutePath, finalContent, signal, file, batchRequest);
413
+ const diffResult = generateDiffString(normalizedContent, result.content);
414
+
415
+ let resultText =
416
+ result.count > 1
417
+ ? `Successfully replaced ${result.count} occurrences in ${path}.`
418
+ : `Successfully replaced text in ${path}.`;
419
+
420
+ if (diagnostics?.messages?.length) {
421
+ resultText += `\n\nLSP Diagnostics (${diagnostics.summary}):\n`;
422
+ resultText += diagnostics.messages.map((d) => ` ${d}`).join("\n");
423
+ }
424
+
425
+ return {
426
+ content: [{ type: "text", text: resultText }],
427
+ details: { diff: diffResult.diff, firstChangedLine: diffResult.firstChangedLine, diagnostics },
428
+ };
429
+ }
430
+ }
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Text normalization utilities for the edit tool.
3
+ *
4
+ * Handles line endings, BOM, whitespace, and Unicode normalization.
5
+ */
6
+
7
+ // ═══════════════════════════════════════════════════════════════════════════
8
+ // Line Ending Utilities
9
+ // ═══════════════════════════════════════════════════════════════════════════
10
+
11
+ export type LineEnding = "\r\n" | "\n";
12
+
13
+ /** Detect the predominant line ending in content */
14
+ export function detectLineEnding(content: string): LineEnding {
15
+ const crlfIdx = content.indexOf("\r\n");
16
+ const lfIdx = content.indexOf("\n");
17
+ if (lfIdx === -1) return "\n";
18
+ if (crlfIdx === -1) return "\n";
19
+ return crlfIdx < lfIdx ? "\r\n" : "\n";
20
+ }
21
+
22
+ /** Normalize all line endings to LF */
23
+ export function normalizeToLF(text: string): string {
24
+ return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
25
+ }
26
+
27
+ /** Restore line endings to the specified type */
28
+ export function restoreLineEndings(text: string, ending: LineEnding): string {
29
+ return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text;
30
+ }
31
+
32
+ // ═══════════════════════════════════════════════════════════════════════════
33
+ // BOM Handling
34
+ // ═══════════════════════════════════════════════════════════════════════════
35
+
36
+ export interface BomResult {
37
+ /** The BOM character if present, empty string otherwise */
38
+ bom: string;
39
+ /** The text without the BOM */
40
+ text: string;
41
+ }
42
+
43
+ /** Strip UTF-8 BOM if present */
44
+ export function stripBom(content: string): BomResult {
45
+ return content.startsWith("\uFEFF") ? { bom: "\uFEFF", text: content.slice(1) } : { bom: "", text: content };
46
+ }
47
+
48
+ // ═══════════════════════════════════════════════════════════════════════════
49
+ // Whitespace Utilities
50
+ // ═══════════════════════════════════════════════════════════════════════════
51
+
52
+ /** Count leading whitespace characters in a line */
53
+ export function countLeadingWhitespace(line: string): number {
54
+ let count = 0;
55
+ for (let i = 0; i < line.length; i++) {
56
+ const char = line[i];
57
+ if (char === " " || char === "\t") {
58
+ count++;
59
+ } else {
60
+ break;
61
+ }
62
+ }
63
+ return count;
64
+ }
65
+
66
+ /** Get the leading whitespace string from a line */
67
+ export function getLeadingWhitespace(line: string): string {
68
+ return line.slice(0, countLeadingWhitespace(line));
69
+ }
70
+
71
+ /** Compute minimum indentation of non-empty lines */
72
+ export function minIndent(text: string): number {
73
+ const lines = text.split("\n");
74
+ let min = Infinity;
75
+ for (const line of lines) {
76
+ if (line.trim().length > 0) {
77
+ min = Math.min(min, countLeadingWhitespace(line));
78
+ }
79
+ }
80
+ return min === Infinity ? 0 : min;
81
+ }
82
+
83
+ /** Detect the indentation character used in text (space or tab) */
84
+ export function detectIndentChar(text: string): string {
85
+ const lines = text.split("\n");
86
+ for (const line of lines) {
87
+ const ws = getLeadingWhitespace(line);
88
+ if (ws.length > 0) {
89
+ return ws[0];
90
+ }
91
+ }
92
+ return " ";
93
+ }
94
+
95
+ // ═══════════════════════════════════════════════════════════════════════════
96
+ // Unicode Normalization
97
+ // ═══════════════════════════════════════════════════════════════════════════
98
+
99
+ /**
100
+ * Normalize common Unicode punctuation to ASCII equivalents.
101
+ * Allows diffs with ASCII characters to match source files with typographic punctuation.
102
+ */
103
+ export function normalizeUnicode(s: string): string {
104
+ return s
105
+ .trim()
106
+ .split("")
107
+ .map((c) => {
108
+ const code = c.charCodeAt(0);
109
+
110
+ // Various dash/hyphen code-points → ASCII '-'
111
+ if (
112
+ code === 0x2010 || // HYPHEN
113
+ code === 0x2011 || // NON-BREAKING HYPHEN
114
+ code === 0x2012 || // FIGURE DASH
115
+ code === 0x2013 || // EN DASH
116
+ code === 0x2014 || // EM DASH
117
+ code === 0x2015 || // HORIZONTAL BAR
118
+ code === 0x2212 // MINUS SIGN
119
+ ) {
120
+ return "-";
121
+ }
122
+
123
+ // Fancy single quotes → '
124
+ if (
125
+ code === 0x2018 || // LEFT SINGLE QUOTATION MARK
126
+ code === 0x2019 || // RIGHT SINGLE QUOTATION MARK
127
+ code === 0x201a || // SINGLE LOW-9 QUOTATION MARK
128
+ code === 0x201b // SINGLE HIGH-REVERSED-9 QUOTATION MARK
129
+ ) {
130
+ return "'";
131
+ }
132
+
133
+ // Fancy double quotes → "
134
+ if (
135
+ code === 0x201c || // LEFT DOUBLE QUOTATION MARK
136
+ code === 0x201d || // RIGHT DOUBLE QUOTATION MARK
137
+ code === 0x201e || // DOUBLE LOW-9 QUOTATION MARK
138
+ code === 0x201f // DOUBLE HIGH-REVERSED-9 QUOTATION MARK
139
+ ) {
140
+ return '"';
141
+ }
142
+
143
+ // Non-breaking space and other odd spaces → normal space
144
+ if (
145
+ code === 0x00a0 || // NO-BREAK SPACE
146
+ code === 0x2002 || // EN SPACE
147
+ code === 0x2003 || // EM SPACE
148
+ code === 0x2004 || // THREE-PER-EM SPACE
149
+ code === 0x2005 || // FOUR-PER-EM SPACE
150
+ code === 0x2006 || // SIX-PER-EM SPACE
151
+ code === 0x2007 || // FIGURE SPACE
152
+ code === 0x2008 || // PUNCTUATION SPACE
153
+ code === 0x2009 || // THIN SPACE
154
+ code === 0x200a || // HAIR SPACE
155
+ code === 0x202f || // NARROW NO-BREAK SPACE
156
+ code === 0x205f || // MEDIUM MATHEMATICAL SPACE
157
+ code === 0x3000 // IDEOGRAPHIC SPACE
158
+ ) {
159
+ return " ";
160
+ }
161
+
162
+ return c;
163
+ })
164
+ .join("");
165
+ }
166
+
167
+ /**
168
+ * Normalize a line for fuzzy comparison.
169
+ * Trims, collapses whitespace, and normalizes punctuation.
170
+ */
171
+ export function normalizeForFuzzy(line: string): string {
172
+ const trimmed = line.trim();
173
+ if (trimmed.length === 0) return "";
174
+
175
+ return trimmed
176
+ .replace(/[""„‟«»]/g, '"')
177
+ .replace(/[''‚‛`´]/g, "'")
178
+ .replace(/[‐‑‒–—−]/g, "-")
179
+ .replace(/[ \t]+/g, " ");
180
+ }
181
+
182
+ // ═══════════════════════════════════════════════════════════════════════════
183
+ // Indentation Adjustment
184
+ // ═══════════════════════════════════════════════════════════════════════════
185
+
186
+ /**
187
+ * Adjust newText indentation to match the indentation delta between
188
+ * what was provided (oldText) and what was actually matched (actualText).
189
+ *
190
+ * If oldText has 0 indent but actualText has 12 spaces, we add 12 spaces
191
+ * to each line in newText.
192
+ */
193
+ export function adjustIndentation(oldText: string, actualText: string, newText: string): string {
194
+ const oldMin = minIndent(oldText);
195
+ const actualMin = minIndent(actualText);
196
+ const delta = actualMin - oldMin;
197
+
198
+ if (delta === 0) {
199
+ return newText;
200
+ }
201
+
202
+ const indentChar = detectIndentChar(actualText);
203
+ const lines = newText.split("\n");
204
+
205
+ const adjusted = lines.map((line) => {
206
+ if (line.trim().length === 0) {
207
+ return line; // Preserve empty/whitespace-only lines as-is
208
+ }
209
+
210
+ if (delta > 0) {
211
+ return indentChar.repeat(delta) + line;
212
+ }
213
+
214
+ // Remove indentation (delta < 0)
215
+ const toRemove = Math.min(-delta, countLeadingWhitespace(line));
216
+ return line.slice(toRemove);
217
+ });
218
+
219
+ return adjusted.join("\n");
220
+ }