@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.
- package/CHANGELOG.md +60 -0
- package/docs/sdk.md +1 -1
- package/package.json +5 -5
- package/scripts/generate-template.ts +6 -6
- package/src/cli/args.ts +3 -0
- package/src/core/agent-session.ts +39 -0
- package/src/core/bash-executor.ts +3 -3
- package/src/core/cursor/exec-bridge.ts +95 -88
- package/src/core/custom-commands/bundled/review/index.ts +142 -145
- package/src/core/custom-commands/bundled/wt/index.ts +68 -66
- package/src/core/custom-commands/loader.ts +4 -6
- package/src/core/custom-tools/index.ts +2 -2
- package/src/core/custom-tools/loader.ts +66 -61
- package/src/core/custom-tools/types.ts +4 -4
- package/src/core/custom-tools/wrapper.ts +61 -25
- package/src/core/event-bus.ts +19 -47
- package/src/core/extensions/index.ts +8 -4
- package/src/core/extensions/loader.ts +160 -120
- package/src/core/extensions/types.ts +4 -4
- package/src/core/extensions/wrapper.ts +149 -100
- package/src/core/hooks/index.ts +1 -1
- package/src/core/hooks/tool-wrapper.ts +96 -70
- package/src/core/hooks/types.ts +1 -2
- package/src/core/index.ts +1 -0
- package/src/core/mcp/index.ts +6 -2
- package/src/core/mcp/json-rpc.ts +88 -0
- package/src/core/mcp/loader.ts +22 -4
- package/src/core/mcp/manager.ts +202 -48
- package/src/core/mcp/tool-bridge.ts +143 -55
- package/src/core/mcp/tool-cache.ts +122 -0
- package/src/core/python-executor.ts +3 -9
- package/src/core/sdk.ts +33 -32
- package/src/core/session-manager.ts +30 -0
- package/src/core/settings-manager.ts +54 -1
- package/src/core/ssh/ssh-executor.ts +6 -84
- package/src/core/streaming-output.ts +107 -53
- package/src/core/tools/ask.ts +92 -93
- package/src/core/tools/bash.ts +103 -94
- package/src/core/tools/calculator.ts +41 -26
- package/src/core/tools/complete.ts +76 -66
- package/src/core/tools/context.ts +22 -24
- package/src/core/tools/exa/index.ts +1 -1
- package/src/core/tools/exa/mcp-client.ts +56 -101
- package/src/core/tools/find.ts +250 -253
- package/src/core/tools/git.ts +39 -33
- package/src/core/tools/grep.ts +440 -427
- package/src/core/tools/index.ts +63 -61
- package/src/core/tools/ls.ts +119 -114
- package/src/core/tools/lsp/clients/biome-client.ts +5 -7
- package/src/core/tools/lsp/clients/index.ts +4 -4
- package/src/core/tools/lsp/clients/lsp-linter-client.ts +5 -7
- package/src/core/tools/lsp/config.ts +2 -2
- package/src/core/tools/lsp/index.ts +604 -578
- package/src/core/tools/notebook.ts +121 -119
- package/src/core/tools/output.ts +163 -147
- package/src/core/tools/patch/applicator.ts +1100 -0
- package/src/core/tools/patch/diff.ts +362 -0
- package/src/core/tools/patch/fuzzy.ts +647 -0
- package/src/core/tools/patch/index.ts +430 -0
- package/src/core/tools/patch/normalize.ts +220 -0
- package/src/core/tools/patch/normative.ts +73 -0
- package/src/core/tools/patch/parser.ts +528 -0
- package/src/core/tools/patch/shared.ts +257 -0
- package/src/core/tools/patch/types.ts +244 -0
- package/src/core/tools/python.ts +139 -136
- package/src/core/tools/read.ts +239 -216
- package/src/core/tools/render-utils.ts +196 -77
- package/src/core/tools/renderers.ts +6 -2
- package/src/core/tools/ssh.ts +99 -80
- package/src/core/tools/task/executor.ts +11 -7
- package/src/core/tools/task/index.ts +352 -343
- package/src/core/tools/task/worker.ts +13 -23
- package/src/core/tools/todo-write.ts +74 -59
- package/src/core/tools/web-fetch.ts +54 -47
- package/src/core/tools/web-search/index.ts +27 -16
- package/src/core/tools/write.ts +108 -47
- package/src/core/ttsr.ts +106 -152
- package/src/core/voice.ts +49 -39
- package/src/index.ts +16 -12
- package/src/lib/worktree/index.ts +1 -9
- package/src/modes/interactive/components/diff.ts +15 -8
- package/src/modes/interactive/components/settings-defs.ts +42 -0
- package/src/modes/interactive/components/tool-execution.ts +46 -8
- package/src/modes/interactive/controllers/event-controller.ts +6 -19
- package/src/modes/interactive/controllers/input-controller.ts +1 -1
- package/src/modes/interactive/utils/ui-helpers.ts +5 -1
- package/src/modes/rpc/rpc-mode.ts +99 -81
- package/src/prompts/tools/patch.md +76 -0
- package/src/prompts/tools/read.md +1 -1
- package/src/prompts/tools/{edit.md → replace.md} +1 -0
- package/src/utils/shell.ts +0 -40
- package/src/core/tools/edit-diff.ts +0 -574
- package/src/core/tools/edit.ts +0 -345
|
@@ -0,0 +1,1100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Patch application logic for the edit tool.
|
|
3
|
+
*
|
|
4
|
+
* Applies parsed diff hunks to file content using fuzzy matching
|
|
5
|
+
* for robust handling of whitespace and formatting differences.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { mkdirSync, unlinkSync } from "node:fs";
|
|
9
|
+
import { dirname } from "node:path";
|
|
10
|
+
import { resolveToCwd } from "../path-utils";
|
|
11
|
+
import { DEFAULT_FUZZY_THRESHOLD, findContextLine, findMatch, seekSequence } from "./fuzzy";
|
|
12
|
+
import {
|
|
13
|
+
adjustIndentation,
|
|
14
|
+
countLeadingWhitespace,
|
|
15
|
+
detectLineEnding,
|
|
16
|
+
getLeadingWhitespace,
|
|
17
|
+
normalizeToLF,
|
|
18
|
+
restoreLineEndings,
|
|
19
|
+
stripBom,
|
|
20
|
+
} from "./normalize";
|
|
21
|
+
import { normalizeCreateContent, parseHunks } from "./parser";
|
|
22
|
+
import type {
|
|
23
|
+
ApplyPatchOptions,
|
|
24
|
+
ApplyPatchResult,
|
|
25
|
+
ContextLineResult,
|
|
26
|
+
DiffHunk,
|
|
27
|
+
FileSystem,
|
|
28
|
+
NormalizedPatchInput,
|
|
29
|
+
PatchInput,
|
|
30
|
+
} from "./types";
|
|
31
|
+
import { ApplyPatchError, normalizePatchInput } from "./types";
|
|
32
|
+
|
|
33
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
34
|
+
// Default File System
|
|
35
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
36
|
+
|
|
37
|
+
/** Default filesystem implementation using Bun APIs */
|
|
38
|
+
export const defaultFileSystem: FileSystem = {
|
|
39
|
+
async exists(path: string): Promise<boolean> {
|
|
40
|
+
return Bun.file(path).exists();
|
|
41
|
+
},
|
|
42
|
+
async read(path: string): Promise<string> {
|
|
43
|
+
return Bun.file(path).text();
|
|
44
|
+
},
|
|
45
|
+
async readBinary(path: string): Promise<Uint8Array> {
|
|
46
|
+
const buffer = await Bun.file(path).arrayBuffer();
|
|
47
|
+
return new Uint8Array(buffer);
|
|
48
|
+
},
|
|
49
|
+
async write(path: string, content: string): Promise<void> {
|
|
50
|
+
await Bun.write(path, content);
|
|
51
|
+
},
|
|
52
|
+
async delete(path: string): Promise<void> {
|
|
53
|
+
unlinkSync(path);
|
|
54
|
+
},
|
|
55
|
+
async mkdir(path: string): Promise<void> {
|
|
56
|
+
mkdirSync(path, { recursive: true });
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
61
|
+
// Internal Types
|
|
62
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
63
|
+
|
|
64
|
+
interface Replacement {
|
|
65
|
+
startIndex: number;
|
|
66
|
+
oldLen: number;
|
|
67
|
+
newLines: string[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface HunkVariant {
|
|
71
|
+
oldLines: string[];
|
|
72
|
+
newLines: string[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
76
|
+
// Replacement Computation
|
|
77
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
78
|
+
|
|
79
|
+
/** Adjust indentation of newLines to match the delta between patternLines and actualLines */
|
|
80
|
+
function adjustLinesIndentation(patternLines: string[], actualLines: string[], newLines: string[]): string[] {
|
|
81
|
+
if (patternLines.length === 0 || actualLines.length === 0 || newLines.length === 0) {
|
|
82
|
+
return newLines;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Detect indent character from actual content
|
|
86
|
+
let indentChar = " ";
|
|
87
|
+
for (const line of actualLines) {
|
|
88
|
+
const ws = getLeadingWhitespace(line);
|
|
89
|
+
if (ws.length > 0) {
|
|
90
|
+
indentChar = ws[0];
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Build a map from trimmed content to available (pattern index, actual index) pairs
|
|
96
|
+
// This lets us find context lines and their corresponding actual content
|
|
97
|
+
const contentToIndices = new Map<string, Array<{ patternIdx: number; actualIdx: number }>>();
|
|
98
|
+
for (let i = 0; i < Math.min(patternLines.length, actualLines.length); i++) {
|
|
99
|
+
const trimmed = patternLines[i].trim();
|
|
100
|
+
if (trimmed.length === 0) continue;
|
|
101
|
+
const arr = contentToIndices.get(trimmed);
|
|
102
|
+
if (arr) {
|
|
103
|
+
arr.push({ patternIdx: i, actualIdx: i });
|
|
104
|
+
} else {
|
|
105
|
+
contentToIndices.set(trimmed, [{ patternIdx: i, actualIdx: i }]);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Compute fallback delta from all non-empty lines (for truly new lines)
|
|
110
|
+
let totalDelta = 0;
|
|
111
|
+
let deltaCount = 0;
|
|
112
|
+
for (let i = 0; i < Math.min(patternLines.length, actualLines.length); i++) {
|
|
113
|
+
if (patternLines[i].trim().length > 0 && actualLines[i].trim().length > 0) {
|
|
114
|
+
const pIndent = countLeadingWhitespace(patternLines[i]);
|
|
115
|
+
const aIndent = countLeadingWhitespace(actualLines[i]);
|
|
116
|
+
totalDelta += aIndent - pIndent;
|
|
117
|
+
deltaCount++;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const avgDelta = deltaCount > 0 ? Math.round(totalDelta / deltaCount) : 0;
|
|
121
|
+
|
|
122
|
+
// Track which indices we've used to handle duplicate content correctly
|
|
123
|
+
const usedIndices = new Set<number>();
|
|
124
|
+
|
|
125
|
+
return newLines.map((newLine) => {
|
|
126
|
+
if (newLine.trim().length === 0) {
|
|
127
|
+
return newLine;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const trimmed = newLine.trim();
|
|
131
|
+
const indices = contentToIndices.get(trimmed);
|
|
132
|
+
|
|
133
|
+
// Check if this is a context line (same trimmed content exists in pattern)
|
|
134
|
+
if (indices) {
|
|
135
|
+
for (const { patternIdx, actualIdx } of indices) {
|
|
136
|
+
if (!usedIndices.has(patternIdx)) {
|
|
137
|
+
usedIndices.add(patternIdx);
|
|
138
|
+
// Use actual file content directly for context lines
|
|
139
|
+
return actualLines[actualIdx];
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// This is a new/added line - apply average delta
|
|
145
|
+
if (avgDelta > 0) {
|
|
146
|
+
return indentChar.repeat(avgDelta) + newLine;
|
|
147
|
+
}
|
|
148
|
+
if (avgDelta < 0) {
|
|
149
|
+
const toRemove = Math.min(-avgDelta, countLeadingWhitespace(newLine));
|
|
150
|
+
return newLine.slice(toRemove);
|
|
151
|
+
}
|
|
152
|
+
return newLine;
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function trimCommonContext(oldLines: string[], newLines: string[]): HunkVariant | undefined {
|
|
157
|
+
let start = 0;
|
|
158
|
+
let endOld = oldLines.length;
|
|
159
|
+
let endNew = newLines.length;
|
|
160
|
+
|
|
161
|
+
while (start < endOld && start < endNew && oldLines[start] === newLines[start]) {
|
|
162
|
+
start++;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
while (endOld > start && endNew > start && oldLines[endOld - 1] === newLines[endNew - 1]) {
|
|
166
|
+
endOld--;
|
|
167
|
+
endNew--;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (start === 0 && endOld === oldLines.length && endNew === newLines.length) {
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const trimmedOld = oldLines.slice(start, endOld);
|
|
175
|
+
const trimmedNew = newLines.slice(start, endNew);
|
|
176
|
+
if (trimmedOld.length === 0 && trimmedNew.length === 0) {
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
return { oldLines: trimmedOld, newLines: trimmedNew };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function collapseConsecutiveSharedLines(oldLines: string[], newLines: string[]): HunkVariant | undefined {
|
|
183
|
+
const shared = new Set(oldLines.filter((line) => newLines.includes(line)));
|
|
184
|
+
const collapse = (lines: string[]): string[] => {
|
|
185
|
+
const out: string[] = [];
|
|
186
|
+
let i = 0;
|
|
187
|
+
while (i < lines.length) {
|
|
188
|
+
const line = lines[i];
|
|
189
|
+
out.push(line);
|
|
190
|
+
let j = i + 1;
|
|
191
|
+
while (j < lines.length && lines[j] === line && shared.has(line)) {
|
|
192
|
+
j++;
|
|
193
|
+
}
|
|
194
|
+
i = j;
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const collapsedOld = collapse(oldLines);
|
|
200
|
+
const collapsedNew = collapse(newLines);
|
|
201
|
+
if (collapsedOld.length === oldLines.length && collapsedNew.length === newLines.length) {
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
|
204
|
+
return { oldLines: collapsedOld, newLines: collapsedNew };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function collapseRepeatedBlocks(oldLines: string[], newLines: string[]): HunkVariant | undefined {
|
|
208
|
+
const shared = new Set(oldLines.filter((line) => newLines.includes(line)));
|
|
209
|
+
const collapse = (lines: string[]): string[] => {
|
|
210
|
+
const output = [...lines];
|
|
211
|
+
let changed = false;
|
|
212
|
+
let i = 0;
|
|
213
|
+
while (i < output.length) {
|
|
214
|
+
let collapsed = false;
|
|
215
|
+
for (let size = Math.floor((output.length - i) / 2); size >= 2; size--) {
|
|
216
|
+
const first = output.slice(i, i + size);
|
|
217
|
+
const second = output.slice(i + size, i + size * 2);
|
|
218
|
+
if (first.length !== second.length || first.length === 0) continue;
|
|
219
|
+
if (!first.every((line) => shared.has(line))) continue;
|
|
220
|
+
let same = true;
|
|
221
|
+
for (let idx = 0; idx < size; idx++) {
|
|
222
|
+
if (first[idx] !== second[idx]) {
|
|
223
|
+
same = false;
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (same) {
|
|
228
|
+
output.splice(i + size, size);
|
|
229
|
+
changed = true;
|
|
230
|
+
collapsed = true;
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (!collapsed) {
|
|
235
|
+
i++;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return changed ? output : lines;
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const collapsedOld = collapse(oldLines);
|
|
242
|
+
const collapsedNew = collapse(newLines);
|
|
243
|
+
if (collapsedOld.length === oldLines.length && collapsedNew.length === newLines.length) {
|
|
244
|
+
return undefined;
|
|
245
|
+
}
|
|
246
|
+
return { oldLines: collapsedOld, newLines: collapsedNew };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function reduceToSingleLineChange(oldLines: string[], newLines: string[]): HunkVariant | undefined {
|
|
250
|
+
if (oldLines.length !== newLines.length || oldLines.length === 0) return undefined;
|
|
251
|
+
let changedIndex: number | undefined;
|
|
252
|
+
for (let i = 0; i < oldLines.length; i++) {
|
|
253
|
+
if (oldLines[i] !== newLines[i]) {
|
|
254
|
+
if (changedIndex !== undefined) return undefined;
|
|
255
|
+
changedIndex = i;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (changedIndex === undefined) return undefined;
|
|
259
|
+
return { oldLines: [oldLines[changedIndex]], newLines: [newLines[changedIndex]] };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function buildFallbackVariants(hunk: DiffHunk): HunkVariant[] {
|
|
263
|
+
const variants: HunkVariant[] = [];
|
|
264
|
+
const base: HunkVariant = { oldLines: hunk.oldLines, newLines: hunk.newLines };
|
|
265
|
+
|
|
266
|
+
const trimmed = trimCommonContext(base.oldLines, base.newLines);
|
|
267
|
+
if (trimmed) variants.push(trimmed);
|
|
268
|
+
|
|
269
|
+
const deduped = collapseConsecutiveSharedLines(
|
|
270
|
+
trimmed?.oldLines ?? base.oldLines,
|
|
271
|
+
trimmed?.newLines ?? base.newLines,
|
|
272
|
+
);
|
|
273
|
+
if (deduped) variants.push(deduped);
|
|
274
|
+
|
|
275
|
+
const collapsed = collapseRepeatedBlocks(
|
|
276
|
+
deduped?.oldLines ?? trimmed?.oldLines ?? base.oldLines,
|
|
277
|
+
deduped?.newLines ?? trimmed?.newLines ?? base.newLines,
|
|
278
|
+
);
|
|
279
|
+
if (collapsed) variants.push(collapsed);
|
|
280
|
+
|
|
281
|
+
const singleLine = reduceToSingleLineChange(trimmed?.oldLines ?? base.oldLines, trimmed?.newLines ?? base.newLines);
|
|
282
|
+
if (singleLine) variants.push(singleLine);
|
|
283
|
+
|
|
284
|
+
const seen = new Set<string>();
|
|
285
|
+
return variants.filter((variant) => {
|
|
286
|
+
if (variant.oldLines.length === 0 && variant.newLines.length === 0) return false;
|
|
287
|
+
const key = `${variant.oldLines.join("\n")}||${variant.newLines.join("\n")}`;
|
|
288
|
+
if (seen.has(key)) return false;
|
|
289
|
+
seen.add(key);
|
|
290
|
+
return true;
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function findContextRelativeMatch(
|
|
295
|
+
lines: string[],
|
|
296
|
+
patternLine: string,
|
|
297
|
+
contextIndex: number,
|
|
298
|
+
preferSecondForwardMatch: boolean,
|
|
299
|
+
): number | undefined {
|
|
300
|
+
const trimmed = patternLine.trim();
|
|
301
|
+
const forwardMatches: number[] = [];
|
|
302
|
+
for (let i = contextIndex + 1; i < lines.length; i++) {
|
|
303
|
+
if (lines[i].trim() === trimmed) {
|
|
304
|
+
forwardMatches.push(i);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (forwardMatches.length > 0) {
|
|
308
|
+
if (preferSecondForwardMatch && forwardMatches.length > 1) {
|
|
309
|
+
return forwardMatches[1];
|
|
310
|
+
}
|
|
311
|
+
return forwardMatches[0];
|
|
312
|
+
}
|
|
313
|
+
for (let i = contextIndex - 1; i >= 0; i--) {
|
|
314
|
+
if (lines[i].trim() === trimmed) {
|
|
315
|
+
return i;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return undefined;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Get hint index from hunk's line number */
|
|
322
|
+
function getHunkHintIndex(hunk: DiffHunk, currentIndex: number): number | undefined {
|
|
323
|
+
if (hunk.oldStartLine === undefined) return undefined;
|
|
324
|
+
const hintIndex = Math.max(0, hunk.oldStartLine - 1);
|
|
325
|
+
return hintIndex >= currentIndex ? hintIndex : undefined;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Find hierarchical context in file lines.
|
|
330
|
+
*
|
|
331
|
+
* Handles three formats:
|
|
332
|
+
* 1. Simple context: "function foo" - find this line
|
|
333
|
+
* 2. Hierarchical (newline): "class Foo\nmethod" - find class, then method after it
|
|
334
|
+
* 3. Hierarchical (space): "class Foo method" - try as literal first, then split and search
|
|
335
|
+
*
|
|
336
|
+
* @returns The result from finding the final (innermost) context, or undefined if not found
|
|
337
|
+
*/
|
|
338
|
+
function findHierarchicalContext(
|
|
339
|
+
lines: string[],
|
|
340
|
+
context: string,
|
|
341
|
+
startFrom: number,
|
|
342
|
+
lineHint: number | undefined,
|
|
343
|
+
allowFuzzy: boolean,
|
|
344
|
+
): ContextLineResult {
|
|
345
|
+
// Check for newline-separated hierarchical contexts (from nested @@ anchors)
|
|
346
|
+
if (context.includes("\n")) {
|
|
347
|
+
const parts = context
|
|
348
|
+
.split("\n")
|
|
349
|
+
.map((p) => p.trim())
|
|
350
|
+
.filter((p) => p.length > 0);
|
|
351
|
+
let currentStart = startFrom;
|
|
352
|
+
|
|
353
|
+
for (let i = 0; i < parts.length; i++) {
|
|
354
|
+
const part = parts[i];
|
|
355
|
+
const isLast = i === parts.length - 1;
|
|
356
|
+
|
|
357
|
+
const result = findContextLine(lines, part, currentStart, { allowFuzzy });
|
|
358
|
+
|
|
359
|
+
if (result.matchCount !== undefined && result.matchCount > 1) {
|
|
360
|
+
if (isLast && lineHint !== undefined) {
|
|
361
|
+
const hintStart = Math.max(0, lineHint - 1);
|
|
362
|
+
if (hintStart >= currentStart) {
|
|
363
|
+
const hintedResult = findContextLine(lines, part, hintStart, { allowFuzzy });
|
|
364
|
+
if (hintedResult.index !== undefined) {
|
|
365
|
+
return { ...hintedResult, matchCount: 1 };
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return { index: undefined, confidence: result.confidence, matchCount: result.matchCount };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (result.index === undefined) {
|
|
373
|
+
if (isLast && lineHint !== undefined) {
|
|
374
|
+
const hintStart = Math.max(0, lineHint - 1);
|
|
375
|
+
if (hintStart >= currentStart) {
|
|
376
|
+
const hintedResult = findContextLine(lines, part, hintStart, { allowFuzzy });
|
|
377
|
+
if (hintedResult.index !== undefined) {
|
|
378
|
+
return { ...hintedResult, matchCount: 1 };
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return { index: undefined, confidence: result.confidence };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (isLast) {
|
|
386
|
+
return result;
|
|
387
|
+
}
|
|
388
|
+
currentStart = result.index + 1;
|
|
389
|
+
}
|
|
390
|
+
return { index: undefined, confidence: 0 };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Try literal context first
|
|
394
|
+
const spaceParts = context.split(/\s+/).filter((p) => p.length > 0);
|
|
395
|
+
const hasSignatureChars = /[(){}[\]]/.test(context);
|
|
396
|
+
if (!hasSignatureChars && spaceParts.length > 2) {
|
|
397
|
+
const outer = spaceParts.slice(0, -1).join(" ");
|
|
398
|
+
const inner = spaceParts[spaceParts.length - 1];
|
|
399
|
+
const outerResult = findContextLine(lines, outer, startFrom, { allowFuzzy });
|
|
400
|
+
if (outerResult.matchCount !== undefined && outerResult.matchCount > 1) {
|
|
401
|
+
return { index: undefined, confidence: outerResult.confidence, matchCount: outerResult.matchCount };
|
|
402
|
+
}
|
|
403
|
+
if (outerResult.index !== undefined) {
|
|
404
|
+
const innerResult = findContextLine(lines, inner, outerResult.index + 1, { allowFuzzy });
|
|
405
|
+
if (innerResult.index !== undefined) {
|
|
406
|
+
return innerResult.matchCount && innerResult.matchCount > 1
|
|
407
|
+
? { ...innerResult, matchCount: 1 }
|
|
408
|
+
: innerResult;
|
|
409
|
+
}
|
|
410
|
+
if (innerResult.matchCount !== undefined && innerResult.matchCount > 1) {
|
|
411
|
+
return { ...innerResult, matchCount: 1 };
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const result = findContextLine(lines, context, startFrom, { allowFuzzy });
|
|
417
|
+
|
|
418
|
+
// If line hint exists and result is ambiguous or missing, try from hint
|
|
419
|
+
if ((result.index === undefined || (result.matchCount ?? 0) > 1) && lineHint !== undefined) {
|
|
420
|
+
const hintStart = Math.max(0, lineHint - 1);
|
|
421
|
+
const hintedResult = findContextLine(lines, context, hintStart, { allowFuzzy });
|
|
422
|
+
if (hintedResult.index !== undefined) {
|
|
423
|
+
return { ...hintedResult, matchCount: 1 };
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// If found uniquely, return it
|
|
428
|
+
if (result.index !== undefined && (result.matchCount ?? 0) <= 1) {
|
|
429
|
+
return result;
|
|
430
|
+
}
|
|
431
|
+
if (result.matchCount !== undefined && result.matchCount > 1) {
|
|
432
|
+
return result;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Try from beginning if not found from current position
|
|
436
|
+
if (result.index === undefined && startFrom !== 0) {
|
|
437
|
+
const fromStartResult = findContextLine(lines, context, 0, { allowFuzzy });
|
|
438
|
+
if (fromStartResult.index !== undefined && (fromStartResult.matchCount ?? 0) <= 1) {
|
|
439
|
+
return fromStartResult;
|
|
440
|
+
}
|
|
441
|
+
if (fromStartResult.matchCount !== undefined && fromStartResult.matchCount > 1) {
|
|
442
|
+
return fromStartResult;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Fallback: try space-separated hierarchical matching
|
|
447
|
+
// e.g., "class PatchTool constructor" -> find "class PatchTool", then "constructor" after it
|
|
448
|
+
if (!hasSignatureChars && spaceParts.length > 1) {
|
|
449
|
+
const outer = spaceParts.slice(0, -1).join(" ");
|
|
450
|
+
const inner = spaceParts[spaceParts.length - 1];
|
|
451
|
+
const outerResult = findContextLine(lines, outer, startFrom, { allowFuzzy });
|
|
452
|
+
|
|
453
|
+
if (outerResult.matchCount !== undefined && outerResult.matchCount > 1) {
|
|
454
|
+
return { index: undefined, confidence: outerResult.confidence, matchCount: outerResult.matchCount };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (outerResult.index === undefined) {
|
|
458
|
+
return { index: undefined, confidence: outerResult.confidence };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const innerResult = findContextLine(lines, inner, outerResult.index + 1, { allowFuzzy });
|
|
462
|
+
if (innerResult.index !== undefined) {
|
|
463
|
+
return innerResult.matchCount && innerResult.matchCount > 1 ? { ...innerResult, matchCount: 1 } : innerResult;
|
|
464
|
+
}
|
|
465
|
+
if (innerResult.matchCount !== undefined && innerResult.matchCount > 1) {
|
|
466
|
+
return { ...innerResult, matchCount: 1 };
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return result;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Find sequence with optional hint position, returning full search result */
|
|
474
|
+
function findSequenceWithHint(
|
|
475
|
+
lines: string[],
|
|
476
|
+
pattern: string[],
|
|
477
|
+
currentIndex: number,
|
|
478
|
+
hintIndex: number | undefined,
|
|
479
|
+
eof: boolean,
|
|
480
|
+
allowFuzzy: boolean,
|
|
481
|
+
): import("./types").SequenceSearchResult {
|
|
482
|
+
// Prefer content-based search starting from currentIndex
|
|
483
|
+
const primaryResult = seekSequence(lines, pattern, currentIndex, eof, { allowFuzzy });
|
|
484
|
+
if (
|
|
485
|
+
primaryResult.matchCount &&
|
|
486
|
+
primaryResult.matchCount > 1 &&
|
|
487
|
+
hintIndex !== undefined &&
|
|
488
|
+
hintIndex !== currentIndex
|
|
489
|
+
) {
|
|
490
|
+
const hintedResult = seekSequence(lines, pattern, hintIndex, eof, { allowFuzzy });
|
|
491
|
+
if (hintedResult.index !== undefined && (hintedResult.matchCount ?? 1) <= 1) {
|
|
492
|
+
return hintedResult;
|
|
493
|
+
}
|
|
494
|
+
if (hintedResult.matchCount && hintedResult.matchCount > 1) {
|
|
495
|
+
return hintedResult;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
if (primaryResult.index !== undefined || (primaryResult.matchCount && primaryResult.matchCount > 1)) {
|
|
499
|
+
return primaryResult;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Use line hint as a secondary bias only if needed
|
|
503
|
+
if (hintIndex !== undefined && hintIndex !== currentIndex) {
|
|
504
|
+
const hintedResult = seekSequence(lines, pattern, hintIndex, eof, { allowFuzzy });
|
|
505
|
+
if (hintedResult.index !== undefined || (hintedResult.matchCount && hintedResult.matchCount > 1)) {
|
|
506
|
+
return hintedResult;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Last resort: search from beginning (handles out-of-order hunks)
|
|
511
|
+
if (currentIndex !== 0) {
|
|
512
|
+
const fromStartResult = seekSequence(lines, pattern, 0, eof, { allowFuzzy });
|
|
513
|
+
if (fromStartResult.index !== undefined || (fromStartResult.matchCount && fromStartResult.matchCount > 1)) {
|
|
514
|
+
return fromStartResult;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
return primaryResult;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function attemptSequenceFallback(
|
|
522
|
+
lines: string[],
|
|
523
|
+
hunk: DiffHunk,
|
|
524
|
+
currentIndex: number,
|
|
525
|
+
lineHint: number | undefined,
|
|
526
|
+
allowFuzzy: boolean,
|
|
527
|
+
): number | undefined {
|
|
528
|
+
if (hunk.oldLines.length === 0) return undefined;
|
|
529
|
+
const matchHint = getHunkHintIndex(hunk, currentIndex);
|
|
530
|
+
const fallbackResult = findSequenceWithHint(
|
|
531
|
+
lines,
|
|
532
|
+
hunk.oldLines,
|
|
533
|
+
currentIndex,
|
|
534
|
+
matchHint ?? lineHint,
|
|
535
|
+
false,
|
|
536
|
+
allowFuzzy,
|
|
537
|
+
);
|
|
538
|
+
if (fallbackResult.index !== undefined && (fallbackResult.matchCount ?? 1) <= 1) {
|
|
539
|
+
const nextIndex = fallbackResult.index + 1;
|
|
540
|
+
if (nextIndex <= lines.length - hunk.oldLines.length) {
|
|
541
|
+
const secondMatch = seekSequence(lines, hunk.oldLines, nextIndex, false, { allowFuzzy });
|
|
542
|
+
if (secondMatch.index !== undefined) {
|
|
543
|
+
return undefined;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return fallbackResult.index;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
for (const variant of buildFallbackVariants(hunk)) {
|
|
550
|
+
if (variant.oldLines.length === 0) continue;
|
|
551
|
+
const variantResult = findSequenceWithHint(
|
|
552
|
+
lines,
|
|
553
|
+
variant.oldLines,
|
|
554
|
+
currentIndex,
|
|
555
|
+
matchHint ?? lineHint,
|
|
556
|
+
false,
|
|
557
|
+
allowFuzzy,
|
|
558
|
+
);
|
|
559
|
+
if (variantResult.index !== undefined && (variantResult.matchCount ?? 1) <= 1) {
|
|
560
|
+
return variantResult.index;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
return undefined;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Apply a hunk using character-based fuzzy matching.
|
|
568
|
+
* Used when the hunk contains only -/+ lines without context.
|
|
569
|
+
*/
|
|
570
|
+
function applyCharacterMatch(
|
|
571
|
+
originalContent: string,
|
|
572
|
+
path: string,
|
|
573
|
+
hunk: DiffHunk,
|
|
574
|
+
fuzzyThreshold: number,
|
|
575
|
+
allowFuzzy: boolean,
|
|
576
|
+
): string {
|
|
577
|
+
const oldText = hunk.oldLines.join("\n");
|
|
578
|
+
const newText = hunk.newLines.join("\n");
|
|
579
|
+
|
|
580
|
+
const normalizedContent = normalizeToLF(originalContent);
|
|
581
|
+
const normalizedOldText = normalizeToLF(oldText);
|
|
582
|
+
|
|
583
|
+
let matchOutcome = findMatch(normalizedContent, normalizedOldText, {
|
|
584
|
+
allowFuzzy,
|
|
585
|
+
threshold: fuzzyThreshold,
|
|
586
|
+
});
|
|
587
|
+
if (!matchOutcome.match && allowFuzzy) {
|
|
588
|
+
const relaxedThreshold = Math.min(fuzzyThreshold, 0.92);
|
|
589
|
+
if (relaxedThreshold < fuzzyThreshold) {
|
|
590
|
+
const relaxedOutcome = findMatch(normalizedContent, normalizedOldText, {
|
|
591
|
+
allowFuzzy,
|
|
592
|
+
threshold: relaxedThreshold,
|
|
593
|
+
});
|
|
594
|
+
if (relaxedOutcome.match) {
|
|
595
|
+
matchOutcome = relaxedOutcome;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Check for multiple exact occurrences
|
|
601
|
+
if (matchOutcome.occurrences && matchOutcome.occurrences > 1) {
|
|
602
|
+
throw new ApplyPatchError(
|
|
603
|
+
`Found ${matchOutcome.occurrences} occurrences of the text in ${path}. ` +
|
|
604
|
+
`The text must be unique. Please provide more context to make it unique.`,
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (matchOutcome.fuzzyMatches && matchOutcome.fuzzyMatches > 1) {
|
|
609
|
+
throw new ApplyPatchError(
|
|
610
|
+
`Found ${matchOutcome.fuzzyMatches} high-confidence matches in ${path}. ` +
|
|
611
|
+
`The text must be unique. Please provide more context to make it unique.`,
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if (!matchOutcome.match) {
|
|
616
|
+
const closest = matchOutcome.closest;
|
|
617
|
+
if (closest) {
|
|
618
|
+
const similarity = Math.round(closest.confidence * 100);
|
|
619
|
+
throw new ApplyPatchError(
|
|
620
|
+
`Could not find a close enough match in ${path}. ` +
|
|
621
|
+
`Closest match (${similarity}% similar) at line ${closest.startLine}.`,
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
throw new ApplyPatchError(`Failed to find expected lines in ${path}:\n${oldText}`);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Adjust indentation to match what was actually found
|
|
628
|
+
const adjustedNewText = adjustIndentation(normalizedOldText, matchOutcome.match.actualText, newText);
|
|
629
|
+
|
|
630
|
+
// Apply the replacement
|
|
631
|
+
const before = normalizedContent.substring(0, matchOutcome.match.startIndex);
|
|
632
|
+
const after = normalizedContent.substring(matchOutcome.match.startIndex + matchOutcome.match.actualText.length);
|
|
633
|
+
return before + adjustedNewText + after;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function applyTrailingNewlinePolicy(content: string, hadFinalNewline: boolean): string {
|
|
637
|
+
if (hadFinalNewline) {
|
|
638
|
+
return content.endsWith("\n") ? content : `${content}\n`;
|
|
639
|
+
}
|
|
640
|
+
return content.replace(/\n+$/u, "");
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Compute replacements needed to transform originalLines using the diff hunks.
|
|
645
|
+
*/
|
|
646
|
+
function computeReplacements(
|
|
647
|
+
originalLines: string[],
|
|
648
|
+
path: string,
|
|
649
|
+
hunks: DiffHunk[],
|
|
650
|
+
allowFuzzy: boolean,
|
|
651
|
+
): Replacement[] {
|
|
652
|
+
const replacements: Replacement[] = [];
|
|
653
|
+
let lineIndex = 0;
|
|
654
|
+
|
|
655
|
+
for (const hunk of hunks) {
|
|
656
|
+
let contextIndex: number | undefined;
|
|
657
|
+
if (hunk.oldStartLine !== undefined && hunk.oldStartLine < 1) {
|
|
658
|
+
throw new ApplyPatchError(
|
|
659
|
+
`Line hint ${hunk.oldStartLine} is out of range for ${path} (line numbers start at 1)`,
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
if (hunk.newStartLine !== undefined && hunk.newStartLine < 1) {
|
|
663
|
+
throw new ApplyPatchError(
|
|
664
|
+
`Line hint ${hunk.newStartLine} is out of range for ${path} (line numbers start at 1)`,
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
const lineHint = hunk.oldStartLine;
|
|
668
|
+
if (lineHint !== undefined && hunk.changeContext === undefined && !hunk.hasContextLines) {
|
|
669
|
+
lineIndex = Math.max(0, Math.min(lineHint - 1, originalLines.length - 1));
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// If hunk has a changeContext, find it and adjust lineIndex
|
|
673
|
+
if (hunk.changeContext !== undefined) {
|
|
674
|
+
// Use hierarchical context matching for nested @@ anchors and space-separated contexts
|
|
675
|
+
const result = findHierarchicalContext(originalLines, hunk.changeContext, lineIndex, lineHint, allowFuzzy);
|
|
676
|
+
const idx = result.index;
|
|
677
|
+
contextIndex = idx;
|
|
678
|
+
|
|
679
|
+
if (idx === undefined || (result.matchCount !== undefined && result.matchCount > 1)) {
|
|
680
|
+
const fallback = attemptSequenceFallback(originalLines, hunk, lineIndex, lineHint, allowFuzzy);
|
|
681
|
+
if (fallback !== undefined) {
|
|
682
|
+
lineIndex = fallback;
|
|
683
|
+
} else if (result.matchCount !== undefined && result.matchCount > 1) {
|
|
684
|
+
const displayContext = hunk.changeContext.includes("\n")
|
|
685
|
+
? hunk.changeContext.split("\n").pop()
|
|
686
|
+
: hunk.changeContext;
|
|
687
|
+
throw new ApplyPatchError(
|
|
688
|
+
`Found ${result.matchCount} matches for context '${displayContext}' in ${path}. ` +
|
|
689
|
+
`Add more surrounding context or additional @@ anchors to make it unique.`,
|
|
690
|
+
);
|
|
691
|
+
} else {
|
|
692
|
+
const displayContext = hunk.changeContext.includes("\n")
|
|
693
|
+
? hunk.changeContext.split("\n").join(" > ")
|
|
694
|
+
: hunk.changeContext;
|
|
695
|
+
throw new ApplyPatchError(`Failed to find context '${displayContext}' in ${path}`);
|
|
696
|
+
}
|
|
697
|
+
} else {
|
|
698
|
+
// If oldLines[0] matches the final context, start search at idx (not idx+1)
|
|
699
|
+
// This handles the common case where @@ scope and first context line are identical
|
|
700
|
+
const firstOldLine = hunk.oldLines[0];
|
|
701
|
+
const finalContext = hunk.changeContext.includes("\n")
|
|
702
|
+
? hunk.changeContext.split("\n").pop()?.trim()
|
|
703
|
+
: hunk.changeContext.trim();
|
|
704
|
+
const isHierarchicalContext =
|
|
705
|
+
hunk.changeContext.includes("\n") || hunk.changeContext.trim().split(/\s+/).length > 2;
|
|
706
|
+
if (firstOldLine !== undefined && (firstOldLine.trim() === finalContext || isHierarchicalContext)) {
|
|
707
|
+
lineIndex = idx;
|
|
708
|
+
} else {
|
|
709
|
+
lineIndex = idx + 1;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
if (hunk.oldLines.length === 0) {
|
|
715
|
+
// Pure addition - prefer changeContext position, then line hint, then end of file
|
|
716
|
+
let insertionIdx: number;
|
|
717
|
+
if (hunk.changeContext !== undefined) {
|
|
718
|
+
// changeContext was processed above; lineIndex is set to the context line or after it
|
|
719
|
+
insertionIdx = lineIndex;
|
|
720
|
+
} else {
|
|
721
|
+
const lineHintForInsertion = hunk.oldStartLine ?? hunk.newStartLine;
|
|
722
|
+
if (lineHintForInsertion !== undefined) {
|
|
723
|
+
// Reject if line hint is out of range for insertion
|
|
724
|
+
// Valid insertion points are 1 to (file length + 1) for 1-indexed hints
|
|
725
|
+
if (lineHintForInsertion < 1) {
|
|
726
|
+
throw new ApplyPatchError(
|
|
727
|
+
`Line hint ${lineHintForInsertion} is out of range for insertion in ${path} ` +
|
|
728
|
+
`(line numbers start at 1)`,
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
if (lineHintForInsertion > originalLines.length + 1) {
|
|
732
|
+
throw new ApplyPatchError(
|
|
733
|
+
`Line hint ${lineHintForInsertion} is out of range for insertion in ${path} ` +
|
|
734
|
+
`(file has ${originalLines.length} lines)`,
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
insertionIdx = Math.max(0, lineHintForInsertion - 1);
|
|
738
|
+
} else {
|
|
739
|
+
insertionIdx =
|
|
740
|
+
originalLines.length > 0 && originalLines[originalLines.length - 1] === ""
|
|
741
|
+
? originalLines.length - 1
|
|
742
|
+
: originalLines.length;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
replacements.push({ startIndex: insertionIdx, oldLen: 0, newLines: [...hunk.newLines] });
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// Try to find the old lines in the file
|
|
751
|
+
let pattern = [...hunk.oldLines];
|
|
752
|
+
const matchHint = getHunkHintIndex(hunk, lineIndex);
|
|
753
|
+
let searchResult = findSequenceWithHint(
|
|
754
|
+
originalLines,
|
|
755
|
+
pattern,
|
|
756
|
+
lineIndex,
|
|
757
|
+
matchHint,
|
|
758
|
+
hunk.isEndOfFile,
|
|
759
|
+
allowFuzzy,
|
|
760
|
+
);
|
|
761
|
+
let newSlice = [...hunk.newLines];
|
|
762
|
+
|
|
763
|
+
// Retry without trailing empty line if present
|
|
764
|
+
if (searchResult.index === undefined && pattern.length > 0 && pattern[pattern.length - 1] === "") {
|
|
765
|
+
pattern = pattern.slice(0, -1);
|
|
766
|
+
if (newSlice.length > 0 && newSlice[newSlice.length - 1] === "") {
|
|
767
|
+
newSlice = newSlice.slice(0, -1);
|
|
768
|
+
}
|
|
769
|
+
searchResult = findSequenceWithHint(
|
|
770
|
+
originalLines,
|
|
771
|
+
pattern,
|
|
772
|
+
lineIndex,
|
|
773
|
+
matchHint,
|
|
774
|
+
hunk.isEndOfFile,
|
|
775
|
+
allowFuzzy,
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
if (searchResult.index === undefined || (searchResult.matchCount ?? 0) > 1) {
|
|
780
|
+
for (const variant of buildFallbackVariants(hunk)) {
|
|
781
|
+
if (variant.oldLines.length === 0) continue;
|
|
782
|
+
const variantResult = findSequenceWithHint(
|
|
783
|
+
originalLines,
|
|
784
|
+
variant.oldLines,
|
|
785
|
+
lineIndex,
|
|
786
|
+
matchHint,
|
|
787
|
+
hunk.isEndOfFile,
|
|
788
|
+
allowFuzzy,
|
|
789
|
+
);
|
|
790
|
+
if (variantResult.index !== undefined && (variantResult.matchCount ?? 1) <= 1) {
|
|
791
|
+
pattern = variant.oldLines;
|
|
792
|
+
newSlice = variant.newLines;
|
|
793
|
+
searchResult = variantResult;
|
|
794
|
+
break;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
if (searchResult.index === undefined && contextIndex !== undefined) {
|
|
800
|
+
for (const variant of buildFallbackVariants(hunk)) {
|
|
801
|
+
if (variant.oldLines.length !== 1 || variant.newLines.length !== 1) continue;
|
|
802
|
+
const removedLine = variant.oldLines[0];
|
|
803
|
+
const hasSharedDuplicate = hunk.newLines.some((line) => line.trim() === removedLine.trim());
|
|
804
|
+
const adjacentIndex = findContextRelativeMatch(
|
|
805
|
+
originalLines,
|
|
806
|
+
removedLine,
|
|
807
|
+
contextIndex,
|
|
808
|
+
hasSharedDuplicate,
|
|
809
|
+
);
|
|
810
|
+
if (adjacentIndex !== undefined) {
|
|
811
|
+
pattern = variant.oldLines;
|
|
812
|
+
newSlice = variant.newLines;
|
|
813
|
+
searchResult = { index: adjacentIndex, confidence: 0.95 };
|
|
814
|
+
break;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
if (searchResult.index !== undefined && contextIndex !== undefined && pattern.length === 1) {
|
|
820
|
+
const trimmed = pattern[0].trim();
|
|
821
|
+
let occurrenceCount = 0;
|
|
822
|
+
for (const line of originalLines) {
|
|
823
|
+
if (line.trim() === trimmed) occurrenceCount++;
|
|
824
|
+
}
|
|
825
|
+
if (occurrenceCount > 1) {
|
|
826
|
+
const hasSharedDuplicate = hunk.newLines.some((line) => line.trim() === trimmed);
|
|
827
|
+
const contextMatch = findContextRelativeMatch(originalLines, pattern[0], contextIndex, hasSharedDuplicate);
|
|
828
|
+
if (contextMatch !== undefined) {
|
|
829
|
+
searchResult = { index: contextMatch, confidence: searchResult.confidence ?? 0.95 };
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
if (searchResult.index === undefined) {
|
|
835
|
+
if (searchResult.matchCount !== undefined && searchResult.matchCount > 1) {
|
|
836
|
+
throw new ApplyPatchError(
|
|
837
|
+
`Found ${searchResult.matchCount} matches for the text in ${path}. ` +
|
|
838
|
+
`Add more surrounding context or additional @@ anchors to make it unique.`,
|
|
839
|
+
);
|
|
840
|
+
}
|
|
841
|
+
throw new ApplyPatchError(`Failed to find expected lines in ${path}:\n${hunk.oldLines.join("\n")}`);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const found = searchResult.index;
|
|
845
|
+
|
|
846
|
+
// Reject if match is ambiguous (prefix/substring matching found multiple matches)
|
|
847
|
+
if (searchResult.matchCount !== undefined && searchResult.matchCount > 1) {
|
|
848
|
+
throw new ApplyPatchError(
|
|
849
|
+
`Found ${searchResult.matchCount} matches for the text in ${path}. ` +
|
|
850
|
+
`Add more surrounding context or additional @@ anchors to make it unique.`,
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// For simple diffs (no context marker, no context lines), check for multiple occurrences
|
|
855
|
+
// This ensures ambiguous replacements are rejected
|
|
856
|
+
// Skip this check if isEndOfFile is set (EOF marker provides disambiguation)
|
|
857
|
+
if (hunk.changeContext === undefined && !hunk.hasContextLines && !hunk.isEndOfFile && lineHint === undefined) {
|
|
858
|
+
const secondMatch = seekSequence(originalLines, pattern, found + 1, false, { allowFuzzy });
|
|
859
|
+
if (secondMatch.index !== undefined) {
|
|
860
|
+
throw new ApplyPatchError(
|
|
861
|
+
`Found 2 occurrences of the text in ${path}. ` +
|
|
862
|
+
`The text must be unique. Please provide more context to make it unique.`,
|
|
863
|
+
);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// Adjust indentation if needed (handles fuzzy matches where indentation differs)
|
|
868
|
+
const actualMatchedLines = originalLines.slice(found, found + pattern.length);
|
|
869
|
+
const adjustedNewLines = adjustLinesIndentation(pattern, actualMatchedLines, newSlice);
|
|
870
|
+
|
|
871
|
+
replacements.push({ startIndex: found, oldLen: pattern.length, newLines: adjustedNewLines });
|
|
872
|
+
lineIndex = found + pattern.length;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// Sort by start index
|
|
876
|
+
replacements.sort((a, b) => a.startIndex - b.startIndex);
|
|
877
|
+
|
|
878
|
+
return replacements;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Apply replacements to lines, returning the modified content.
|
|
883
|
+
*/
|
|
884
|
+
function applyReplacements(lines: string[], replacements: Replacement[]): string[] {
|
|
885
|
+
const result = [...lines];
|
|
886
|
+
|
|
887
|
+
// Apply in reverse order to maintain indices
|
|
888
|
+
for (let i = replacements.length - 1; i >= 0; i--) {
|
|
889
|
+
const { startIndex, oldLen, newLines } = replacements[i];
|
|
890
|
+
result.splice(startIndex, oldLen);
|
|
891
|
+
result.splice(startIndex, 0, ...newLines);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
return result;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* Apply diff hunks to file content.
|
|
899
|
+
*/
|
|
900
|
+
function applyHunksToContent(
|
|
901
|
+
originalContent: string,
|
|
902
|
+
path: string,
|
|
903
|
+
hunks: DiffHunk[],
|
|
904
|
+
fuzzyThreshold: number,
|
|
905
|
+
allowFuzzy: boolean,
|
|
906
|
+
): string {
|
|
907
|
+
const hadFinalNewline = originalContent.endsWith("\n");
|
|
908
|
+
|
|
909
|
+
// Detect simple replace pattern: single hunk, no @@ context, no context lines, has old lines to match
|
|
910
|
+
// Only use character-based matching when there are no hints to disambiguate
|
|
911
|
+
if (hunks.length === 1) {
|
|
912
|
+
const hunk = hunks[0];
|
|
913
|
+
if (
|
|
914
|
+
hunk.changeContext === undefined &&
|
|
915
|
+
!hunk.hasContextLines &&
|
|
916
|
+
hunk.oldLines.length > 0 &&
|
|
917
|
+
hunk.oldStartLine === undefined && // No line hint to use for positioning
|
|
918
|
+
!hunk.isEndOfFile // No EOF targeting (prefer end of file)
|
|
919
|
+
) {
|
|
920
|
+
const content = applyCharacterMatch(originalContent, path, hunk, fuzzyThreshold, allowFuzzy);
|
|
921
|
+
return applyTrailingNewlinePolicy(content, hadFinalNewline);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
let originalLines = originalContent.split("\n");
|
|
926
|
+
|
|
927
|
+
// Track if we have a trailing empty element from the final newline
|
|
928
|
+
// Only strip ONE trailing empty (the newline marker), preserve actual blank lines
|
|
929
|
+
let strippedTrailingEmpty = false;
|
|
930
|
+
if (hadFinalNewline && originalLines.length > 0 && originalLines[originalLines.length - 1] === "") {
|
|
931
|
+
// Check if the second-to-last is also empty (actual blank line) - if so, only strip one
|
|
932
|
+
originalLines = originalLines.slice(0, -1);
|
|
933
|
+
strippedTrailingEmpty = true;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
const replacements = computeReplacements(originalLines, path, hunks, allowFuzzy);
|
|
937
|
+
const newLines = applyReplacements(originalLines, replacements);
|
|
938
|
+
|
|
939
|
+
// Restore the trailing empty element if we stripped it
|
|
940
|
+
if (strippedTrailingEmpty) {
|
|
941
|
+
newLines.push("");
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
const content = newLines.join("\n");
|
|
945
|
+
|
|
946
|
+
// Preserve original trailing newline behavior
|
|
947
|
+
if (hadFinalNewline && !content.endsWith("\n")) {
|
|
948
|
+
return `${content}\n`;
|
|
949
|
+
}
|
|
950
|
+
if (!hadFinalNewline && content.endsWith("\n")) {
|
|
951
|
+
return content.slice(0, -1);
|
|
952
|
+
}
|
|
953
|
+
return content;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
957
|
+
// Public API
|
|
958
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
959
|
+
|
|
960
|
+
/**
|
|
961
|
+
* Apply a patch operation to the filesystem.
|
|
962
|
+
*/
|
|
963
|
+
export async function applyPatch(input: PatchInput, options: ApplyPatchOptions): Promise<ApplyPatchResult> {
|
|
964
|
+
const normalized = normalizePatchInput(input);
|
|
965
|
+
return applyNormalizedPatch(normalized, options);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/**
|
|
969
|
+
* Apply a normalized patch operation to the filesystem.
|
|
970
|
+
* @internal
|
|
971
|
+
*/
|
|
972
|
+
async function applyNormalizedPatch(
|
|
973
|
+
input: NormalizedPatchInput,
|
|
974
|
+
options: ApplyPatchOptions,
|
|
975
|
+
): Promise<ApplyPatchResult> {
|
|
976
|
+
const {
|
|
977
|
+
cwd,
|
|
978
|
+
dryRun = false,
|
|
979
|
+
fs = defaultFileSystem,
|
|
980
|
+
fuzzyThreshold = DEFAULT_FUZZY_THRESHOLD,
|
|
981
|
+
allowFuzzy = true,
|
|
982
|
+
} = options;
|
|
983
|
+
|
|
984
|
+
const resolvePath = (p: string): string => resolveToCwd(p, cwd);
|
|
985
|
+
const absolutePath = resolvePath(input.path);
|
|
986
|
+
|
|
987
|
+
if (input.rename) {
|
|
988
|
+
const destPath = resolvePath(input.rename);
|
|
989
|
+
if (destPath === absolutePath) {
|
|
990
|
+
throw new ApplyPatchError("rename path is the same as source path");
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// Handle CREATE operation
|
|
995
|
+
if (input.op === "create") {
|
|
996
|
+
if (!input.diff) {
|
|
997
|
+
throw new ApplyPatchError("Create operation requires diff (file content)");
|
|
998
|
+
}
|
|
999
|
+
// Strip + prefixes if present (handles diffs formatted as additions)
|
|
1000
|
+
const normalizedContent = normalizeCreateContent(input.diff);
|
|
1001
|
+
const content = normalizedContent.endsWith("\n") ? normalizedContent : `${normalizedContent}\n`;
|
|
1002
|
+
|
|
1003
|
+
if (!dryRun) {
|
|
1004
|
+
const parentDir = dirname(absolutePath);
|
|
1005
|
+
if (parentDir && parentDir !== ".") {
|
|
1006
|
+
await fs.mkdir(parentDir);
|
|
1007
|
+
}
|
|
1008
|
+
await fs.write(absolutePath, content);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
return {
|
|
1012
|
+
change: {
|
|
1013
|
+
type: "create",
|
|
1014
|
+
path: absolutePath,
|
|
1015
|
+
newContent: content,
|
|
1016
|
+
},
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// Handle DELETE operation
|
|
1021
|
+
if (input.op === "delete") {
|
|
1022
|
+
if (!(await fs.exists(absolutePath))) {
|
|
1023
|
+
throw new ApplyPatchError(`File not found: ${input.path}`);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
const oldContent = await fs.read(absolutePath);
|
|
1027
|
+
if (!dryRun) {
|
|
1028
|
+
await fs.delete(absolutePath);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
return {
|
|
1032
|
+
change: {
|
|
1033
|
+
type: "delete",
|
|
1034
|
+
path: absolutePath,
|
|
1035
|
+
oldContent,
|
|
1036
|
+
},
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// Handle UPDATE operation
|
|
1041
|
+
if (!input.diff) {
|
|
1042
|
+
throw new ApplyPatchError("Update operation requires diff (hunks)");
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
if (!(await fs.exists(absolutePath))) {
|
|
1046
|
+
throw new ApplyPatchError(`File not found: ${input.path}`);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
const originalContent = await fs.read(absolutePath);
|
|
1050
|
+
const { bom: bomFromText, text: strippedContent } = stripBom(originalContent);
|
|
1051
|
+
let bom = bomFromText;
|
|
1052
|
+
if (!bom && fs.readBinary) {
|
|
1053
|
+
const bytes = await fs.readBinary(absolutePath);
|
|
1054
|
+
if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) {
|
|
1055
|
+
bom = "\uFEFF";
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
const lineEnding = detectLineEnding(strippedContent);
|
|
1059
|
+
const normalizedContent = normalizeToLF(strippedContent);
|
|
1060
|
+
const hunks = parseHunks(input.diff);
|
|
1061
|
+
|
|
1062
|
+
if (hunks.length === 0) {
|
|
1063
|
+
throw new ApplyPatchError("Diff contains no hunks");
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
const newContent = applyHunksToContent(normalizedContent, input.path, hunks, fuzzyThreshold, allowFuzzy);
|
|
1067
|
+
const finalContent = bom + restoreLineEndings(newContent, lineEnding);
|
|
1068
|
+
const destPath = input.rename ? resolvePath(input.rename) : absolutePath;
|
|
1069
|
+
const isMove = Boolean(input.rename) && destPath !== absolutePath;
|
|
1070
|
+
|
|
1071
|
+
if (!dryRun) {
|
|
1072
|
+
if (isMove) {
|
|
1073
|
+
const parentDir = dirname(destPath);
|
|
1074
|
+
if (parentDir && parentDir !== ".") {
|
|
1075
|
+
await fs.mkdir(parentDir);
|
|
1076
|
+
}
|
|
1077
|
+
await fs.write(destPath, finalContent);
|
|
1078
|
+
await fs.delete(absolutePath);
|
|
1079
|
+
} else {
|
|
1080
|
+
await fs.write(absolutePath, finalContent);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
return {
|
|
1085
|
+
change: {
|
|
1086
|
+
type: "update",
|
|
1087
|
+
path: absolutePath,
|
|
1088
|
+
newPath: isMove ? destPath : undefined,
|
|
1089
|
+
oldContent: originalContent,
|
|
1090
|
+
newContent: finalContent,
|
|
1091
|
+
},
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
/**
|
|
1096
|
+
* Preview what changes a patch would make without applying it.
|
|
1097
|
+
*/
|
|
1098
|
+
export async function previewPatch(input: PatchInput, options: ApplyPatchOptions): Promise<ApplyPatchResult> {
|
|
1099
|
+
return applyPatch(input, { ...options, dryRun: true });
|
|
1100
|
+
}
|