@nghyane/arcane 0.1.23 → 0.1.24
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 +7 -0
- package/package.json +4 -4
- package/src/patch/applicator.ts +0 -29
- package/src/patch/diff.ts +0 -12
- package/src/patch/edit-tool.ts +14 -119
- package/src/patch/fuzzy.ts +0 -4
- package/src/patch/hashline.ts +66 -215
- package/src/patch/parser.ts +0 -5
- package/src/patch/schemas.ts +16 -53
- package/src/patch/shared.ts +21 -42
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@nghyane/arcane",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.24",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://github.com/nghyane/arcane",
|
|
7
7
|
"author": "Can Bölük",
|
|
@@ -44,9 +44,9 @@
|
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"@mozilla/readability": "0.6.0",
|
|
47
|
-
"@nghyane/arcane-stats": "^0.1.
|
|
48
|
-
"@nghyane/arcane-agent": "^0.1.
|
|
49
|
-
"@nghyane/arcane-ai": "^0.1.
|
|
47
|
+
"@nghyane/arcane-stats": "^0.1.13",
|
|
48
|
+
"@nghyane/arcane-agent": "^0.1.17",
|
|
49
|
+
"@nghyane/arcane-ai": "^0.1.13",
|
|
50
50
|
"@nghyane/arcane-natives": "^0.1.11",
|
|
51
51
|
"@nghyane/arcane-tui": "^0.1.15",
|
|
52
52
|
"@nghyane/arcane-utils": "^0.1.8",
|
package/src/patch/applicator.ts
CHANGED
|
@@ -114,7 +114,6 @@ function adjustLinesIndentation(patternLines: string[], actualLines: string[], n
|
|
|
114
114
|
}
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
// Detect indent character from actual content
|
|
118
117
|
let indentChar = " ";
|
|
119
118
|
for (const line of actualLines) {
|
|
120
119
|
const ws = getLeadingWhitespace(line);
|
|
@@ -220,7 +219,6 @@ function adjustLinesIndentation(patternLines: string[], actualLines: string[], n
|
|
|
220
219
|
const w = (s2 - s1) / (t2 - t1);
|
|
221
220
|
if (w > 0 && Number.isInteger(w)) {
|
|
222
221
|
const b = s1 - t1 * w;
|
|
223
|
-
// Validate all samples against this model
|
|
224
222
|
let valid = true;
|
|
225
223
|
for (const [t, s] of samples) {
|
|
226
224
|
if (t * w + b !== s) {
|
|
@@ -308,7 +306,6 @@ function adjustLinesIndentation(patternLines: string[], actualLines: string[], n
|
|
|
308
306
|
const trimmed = newLine.trim();
|
|
309
307
|
const matchingActualLines = contentToActualLines.get(trimmed);
|
|
310
308
|
|
|
311
|
-
// Check if this is a context line (same trimmed content exists in actual)
|
|
312
309
|
if (matchingActualLines && matchingActualLines.length > 0) {
|
|
313
310
|
if (matchingActualLines.length === 1) {
|
|
314
311
|
return matchingActualLines[0];
|
|
@@ -319,12 +316,10 @@ function adjustLinesIndentation(patternLines: string[], actualLines: string[], n
|
|
|
319
316
|
const usedCount = usedActualLines.get(trimmed) ?? 0;
|
|
320
317
|
if (usedCount < matchingActualLines.length) {
|
|
321
318
|
usedActualLines.set(trimmed, usedCount + 1);
|
|
322
|
-
// Use actual file content directly for context lines
|
|
323
319
|
return matchingActualLines[usedCount];
|
|
324
320
|
}
|
|
325
321
|
}
|
|
326
322
|
|
|
327
|
-
// This is a new/added line - apply consistent delta if safe
|
|
328
323
|
if (delta && delta !== 0) {
|
|
329
324
|
const newIndent = countLeadingWhitespace(newLine);
|
|
330
325
|
if (newIndent === patternMin) {
|
|
@@ -573,7 +568,6 @@ function findHierarchicalContext(
|
|
|
573
568
|
lineHint: number | undefined,
|
|
574
569
|
allowFuzzy: boolean,
|
|
575
570
|
): ContextLineResult {
|
|
576
|
-
// Check for newline-separated hierarchical contexts (from nested @@ anchors)
|
|
577
571
|
if (context.includes("\n")) {
|
|
578
572
|
const parts = context
|
|
579
573
|
.split("\n")
|
|
@@ -627,7 +621,6 @@ function findHierarchicalContext(
|
|
|
627
621
|
return { index: undefined, confidence: 0 };
|
|
628
622
|
}
|
|
629
623
|
|
|
630
|
-
// Try literal context first
|
|
631
624
|
const spaceParts = context.split(/\s+/).filter(p => p.length > 0);
|
|
632
625
|
const hasSignatureChars = /[(){}[\]]/.test(context);
|
|
633
626
|
if (!hasSignatureChars && spaceParts.length > 2) {
|
|
@@ -662,7 +655,6 @@ function findHierarchicalContext(
|
|
|
662
655
|
|
|
663
656
|
const result = findContextLine(lines, context, startFrom, { allowFuzzy });
|
|
664
657
|
|
|
665
|
-
// If line hint exists and result is ambiguous or missing, try from hint
|
|
666
658
|
if ((result.index === undefined || (result.matchCount ?? 0) > 1) && lineHint !== undefined) {
|
|
667
659
|
const hintStart = Math.max(0, lineHint - 1);
|
|
668
660
|
const hintedResult = findContextLine(lines, context, hintStart, { allowFuzzy });
|
|
@@ -671,7 +663,6 @@ function findHierarchicalContext(
|
|
|
671
663
|
}
|
|
672
664
|
}
|
|
673
665
|
|
|
674
|
-
// If found uniquely, return it
|
|
675
666
|
if (result.index !== undefined && (result.matchCount ?? 0) <= 1) {
|
|
676
667
|
return result;
|
|
677
668
|
}
|
|
@@ -679,7 +670,6 @@ function findHierarchicalContext(
|
|
|
679
670
|
return result;
|
|
680
671
|
}
|
|
681
672
|
|
|
682
|
-
// Try from beginning if not found from current position
|
|
683
673
|
if (result.index === undefined && startFrom !== 0) {
|
|
684
674
|
const fromStartResult = findContextLine(lines, context, 0, { allowFuzzy });
|
|
685
675
|
if (fromStartResult.index !== undefined && (fromStartResult.matchCount ?? 0) <= 1) {
|
|
@@ -738,7 +728,6 @@ function findSequenceWithHint(
|
|
|
738
728
|
eof: boolean,
|
|
739
729
|
allowFuzzy: boolean,
|
|
740
730
|
): import("./types").SequenceSearchResult {
|
|
741
|
-
// Prefer content-based search starting from currentIndex
|
|
742
731
|
const primaryResult = seekSequence(lines, pattern, currentIndex, eof, { allowFuzzy });
|
|
743
732
|
if (
|
|
744
733
|
primaryResult.matchCount &&
|
|
@@ -758,7 +747,6 @@ function findSequenceWithHint(
|
|
|
758
747
|
return primaryResult;
|
|
759
748
|
}
|
|
760
749
|
|
|
761
|
-
// Use line hint as a secondary bias only if needed
|
|
762
750
|
if (hintIndex !== undefined && hintIndex !== currentIndex) {
|
|
763
751
|
const hintedResult = seekSequence(lines, pattern, hintIndex, eof, { allowFuzzy });
|
|
764
752
|
if (hintedResult.index !== undefined || (hintedResult.matchCount && hintedResult.matchCount > 1)) {
|
|
@@ -857,7 +845,6 @@ function applyCharacterMatch(
|
|
|
857
845
|
}
|
|
858
846
|
}
|
|
859
847
|
|
|
860
|
-
// Check for multiple exact occurrences
|
|
861
848
|
if (matchOutcome.occurrences && matchOutcome.occurrences > 1) {
|
|
862
849
|
const previews = matchOutcome.occurrencePreviews?.join("\n\n") ?? "";
|
|
863
850
|
const moreMsg = matchOutcome.occurrences > 5 ? ` (showing first 5 of ${matchOutcome.occurrences})` : "";
|
|
@@ -886,7 +873,6 @@ function applyCharacterMatch(
|
|
|
886
873
|
throw new ApplyPatchError(`Failed to find expected lines in ${path}:\n${oldText}`);
|
|
887
874
|
}
|
|
888
875
|
|
|
889
|
-
// Adjust indentation to match what was actually found
|
|
890
876
|
const adjustedNewText = adjustIndentation(normalizedOldText, matchOutcome.match.actualText, newText);
|
|
891
877
|
|
|
892
878
|
const warnings: string[] = [];
|
|
@@ -898,7 +884,6 @@ function applyCharacterMatch(
|
|
|
898
884
|
);
|
|
899
885
|
}
|
|
900
886
|
|
|
901
|
-
// Apply the replacement
|
|
902
887
|
const before = normalizedContent.substring(0, matchOutcome.match.startIndex);
|
|
903
888
|
const after = normalizedContent.substring(matchOutcome.match.startIndex + matchOutcome.match.actualText.length);
|
|
904
889
|
return { content: before + adjustedNewText + after, warnings };
|
|
@@ -942,9 +927,7 @@ function computeReplacements(
|
|
|
942
927
|
lineIndex = Math.max(0, Math.min(lineHint - 1, originalLines.length - 1));
|
|
943
928
|
}
|
|
944
929
|
|
|
945
|
-
// If hunk has a changeContext, find it and adjust lineIndex
|
|
946
930
|
if (hunk.changeContext !== undefined) {
|
|
947
|
-
// Use hierarchical context matching for nested @@ anchors and space-separated contexts
|
|
948
931
|
const result = findHierarchicalContext(originalLines, hunk.changeContext, lineIndex, lineHint, allowFuzzy);
|
|
949
932
|
const idx = result.index;
|
|
950
933
|
contextIndex = idx;
|
|
@@ -995,7 +978,6 @@ function computeReplacements(
|
|
|
995
978
|
}
|
|
996
979
|
|
|
997
980
|
if (hunk.oldLines.length === 0) {
|
|
998
|
-
// Pure addition - prefer changeContext position, then line hint, then end of file
|
|
999
981
|
let insertionIdx: number;
|
|
1000
982
|
if (hunk.changeContext !== undefined) {
|
|
1001
983
|
// changeContext was processed above; lineIndex is set to the context line or after it
|
|
@@ -1030,7 +1012,6 @@ function computeReplacements(
|
|
|
1030
1012
|
continue;
|
|
1031
1013
|
}
|
|
1032
1014
|
|
|
1033
|
-
// Try to find the old lines in the file
|
|
1034
1015
|
let pattern = [...hunk.oldLines];
|
|
1035
1016
|
const matchHint = getHunkHintIndex(hunk, lineIndex);
|
|
1036
1017
|
let searchResult = findSequenceWithHint(
|
|
@@ -1043,7 +1024,6 @@ function computeReplacements(
|
|
|
1043
1024
|
);
|
|
1044
1025
|
let newSlice = [...hunk.newLines];
|
|
1045
1026
|
|
|
1046
|
-
// Retry without trailing empty line if present
|
|
1047
1027
|
if (searchResult.index === undefined && pattern.length > 0 && pattern[pattern.length - 1] === "") {
|
|
1048
1028
|
pattern = pattern.slice(0, -1);
|
|
1049
1029
|
if (newSlice.length > 0 && newSlice[newSlice.length - 1] === "") {
|
|
@@ -1165,7 +1145,6 @@ function computeReplacements(
|
|
|
1165
1145
|
);
|
|
1166
1146
|
}
|
|
1167
1147
|
|
|
1168
|
-
// Reject if match is ambiguous (prefix/substring matching found multiple matches)
|
|
1169
1148
|
if (searchResult.matchCount !== undefined && searchResult.matchCount > 1) {
|
|
1170
1149
|
const previews = formatSequenceMatchPreviews(
|
|
1171
1150
|
originalLines,
|
|
@@ -1186,7 +1165,6 @@ function computeReplacements(
|
|
|
1186
1165
|
if (hunk.changeContext === undefined && !hunk.hasContextLines && !hunk.isEndOfFile && lineHint === undefined) {
|
|
1187
1166
|
const secondMatch = seekSequence(originalLines, pattern, found + 1, false, { allowFuzzy });
|
|
1188
1167
|
if (secondMatch.index !== undefined) {
|
|
1189
|
-
// Extract 3-line previews for each match
|
|
1190
1168
|
const formatPreview = (startIdx: number) => {
|
|
1191
1169
|
const contextLines = 2;
|
|
1192
1170
|
const maxLineLength = 80;
|
|
@@ -1210,7 +1188,6 @@ function computeReplacements(
|
|
|
1210
1188
|
}
|
|
1211
1189
|
}
|
|
1212
1190
|
|
|
1213
|
-
// Adjust indentation if needed (handles fuzzy matches where indentation differs)
|
|
1214
1191
|
const actualMatchedLines = originalLines.slice(found, found + pattern.length);
|
|
1215
1192
|
|
|
1216
1193
|
// Skip pure-context hunks (no +/- lines — oldLines === newLines).
|
|
@@ -1235,7 +1212,6 @@ function computeReplacements(
|
|
|
1235
1212
|
lineIndex = found + pattern.length;
|
|
1236
1213
|
}
|
|
1237
1214
|
|
|
1238
|
-
// Sort by start index
|
|
1239
1215
|
replacements.sort((a, b) => a.startIndex - b.startIndex);
|
|
1240
1216
|
|
|
1241
1217
|
for (let i = 1; i < replacements.length; i++) {
|
|
@@ -1319,14 +1295,12 @@ function applyHunksToContent(
|
|
|
1319
1295
|
const { replacements, warnings } = computeReplacements(originalLines, path, hunks, allowFuzzy);
|
|
1320
1296
|
const newLines = applyReplacements(originalLines, replacements);
|
|
1321
1297
|
|
|
1322
|
-
// Restore the trailing empty element if we stripped it
|
|
1323
1298
|
if (strippedTrailingEmpty) {
|
|
1324
1299
|
newLines.push("");
|
|
1325
1300
|
}
|
|
1326
1301
|
|
|
1327
1302
|
const content = newLines.join("\n");
|
|
1328
1303
|
|
|
1329
|
-
// Preserve original trailing newline behavior
|
|
1330
1304
|
if (hadFinalNewline && !content.endsWith("\n")) {
|
|
1331
1305
|
return { content: `${content}\n`, warnings };
|
|
1332
1306
|
}
|
|
@@ -1374,7 +1348,6 @@ async function applyNormalizedPatch(
|
|
|
1374
1348
|
}
|
|
1375
1349
|
}
|
|
1376
1350
|
|
|
1377
|
-
// Handle CREATE operation
|
|
1378
1351
|
if (input.op === "create") {
|
|
1379
1352
|
if (!input.diff) {
|
|
1380
1353
|
throw new ApplyPatchError("Create operation requires diff (file content)");
|
|
@@ -1400,7 +1373,6 @@ async function applyNormalizedPatch(
|
|
|
1400
1373
|
};
|
|
1401
1374
|
}
|
|
1402
1375
|
|
|
1403
|
-
// Handle DELETE operation
|
|
1404
1376
|
if (input.op === "delete") {
|
|
1405
1377
|
if (!(await fs.exists(absolutePath))) {
|
|
1406
1378
|
throw new ApplyPatchError(`File not found: ${input.path}`);
|
|
@@ -1420,7 +1392,6 @@ async function applyNormalizedPatch(
|
|
|
1420
1392
|
};
|
|
1421
1393
|
}
|
|
1422
1394
|
|
|
1423
|
-
// Handle UPDATE operation
|
|
1424
1395
|
if (!input.diff) {
|
|
1425
1396
|
throw new ApplyPatchError("Update operation requires diff (hunks)");
|
|
1426
1397
|
}
|
package/src/patch/diff.ts
CHANGED
|
@@ -55,12 +55,10 @@ export function generateDiffString(oldContent: string, newContent: string, conte
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
if (part.added || part.removed) {
|
|
58
|
-
// Capture the first changed line (in the new file)
|
|
59
58
|
if (firstChangedLine === undefined) {
|
|
60
59
|
firstChangedLine = newLineNum;
|
|
61
60
|
}
|
|
62
61
|
|
|
63
|
-
// Show the change
|
|
64
62
|
for (const line of raw) {
|
|
65
63
|
if (part.added) {
|
|
66
64
|
output.push(formatNumberedDiffLine("+", newLineNum, lineNumWidth, line));
|
|
@@ -72,7 +70,6 @@ export function generateDiffString(oldContent: string, newContent: string, conte
|
|
|
72
70
|
}
|
|
73
71
|
lastWasChange = true;
|
|
74
72
|
} else {
|
|
75
|
-
// Context lines - only show a few before/after changes
|
|
76
73
|
const nextPartIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed);
|
|
77
74
|
|
|
78
75
|
if (lastWasChange || nextPartIsChange) {
|
|
@@ -81,18 +78,15 @@ export function generateDiffString(oldContent: string, newContent: string, conte
|
|
|
81
78
|
let skipEnd = 0;
|
|
82
79
|
|
|
83
80
|
if (!lastWasChange) {
|
|
84
|
-
// Show only last N lines as leading context
|
|
85
81
|
skipStart = Math.max(0, raw.length - contextLines);
|
|
86
82
|
linesToShow = raw.slice(skipStart);
|
|
87
83
|
}
|
|
88
84
|
|
|
89
85
|
if (!nextPartIsChange && linesToShow.length > contextLines) {
|
|
90
|
-
// Show only first N lines as trailing context
|
|
91
86
|
skipEnd = linesToShow.length - contextLines;
|
|
92
87
|
linesToShow = linesToShow.slice(0, contextLines);
|
|
93
88
|
}
|
|
94
89
|
|
|
95
|
-
// Add ellipsis if we skipped lines at start
|
|
96
90
|
if (skipStart > 0) {
|
|
97
91
|
output.push(formatNumberedDiffLine(" ", oldLineNum, lineNumWidth, "..."));
|
|
98
92
|
oldLineNum += skipStart;
|
|
@@ -105,14 +99,12 @@ export function generateDiffString(oldContent: string, newContent: string, conte
|
|
|
105
99
|
newLineNum++;
|
|
106
100
|
}
|
|
107
101
|
|
|
108
|
-
// Add ellipsis if we skipped lines at end
|
|
109
102
|
if (skipEnd > 0) {
|
|
110
103
|
output.push(formatNumberedDiffLine(" ", oldLineNum, lineNumWidth, "..."));
|
|
111
104
|
oldLineNum += skipEnd;
|
|
112
105
|
newLineNum += skipEnd;
|
|
113
106
|
}
|
|
114
107
|
} else {
|
|
115
|
-
// Skip these context lines entirely
|
|
116
108
|
oldLineNum += raw.length;
|
|
117
109
|
newLineNum += raw.length;
|
|
118
110
|
}
|
|
@@ -198,7 +190,6 @@ export function replaceText(content: string, oldText: string, newText: string, o
|
|
|
198
190
|
let count = 0;
|
|
199
191
|
|
|
200
192
|
if (options.all) {
|
|
201
|
-
// Check for exact matches first
|
|
202
193
|
const exactCount = normalizedContent.split(normalizedOldText).length - 1;
|
|
203
194
|
if (exactCount > 0) {
|
|
204
195
|
return {
|
|
@@ -207,7 +198,6 @@ export function replaceText(content: string, oldText: string, newText: string, o
|
|
|
207
198
|
};
|
|
208
199
|
}
|
|
209
200
|
|
|
210
|
-
// No exact matches - try fuzzy matching iteratively
|
|
211
201
|
while (true) {
|
|
212
202
|
const matchOutcome = findMatch(normalizedContent, normalizedOldText, {
|
|
213
203
|
allowFuzzy: options.fuzzy,
|
|
@@ -238,7 +228,6 @@ export function replaceText(content: string, oldText: string, newText: string, o
|
|
|
238
228
|
return { content: normalizedContent, count };
|
|
239
229
|
}
|
|
240
230
|
|
|
241
|
-
// Single replacement mode
|
|
242
231
|
const matchOutcome = findMatch(normalizedContent, normalizedOldText, {
|
|
243
232
|
allowFuzzy: options.fuzzy,
|
|
244
233
|
threshold,
|
|
@@ -319,7 +308,6 @@ export async function computeEditDiff(
|
|
|
319
308
|
});
|
|
320
309
|
|
|
321
310
|
if (result.count === 0) {
|
|
322
|
-
// Get closest match for error message
|
|
323
311
|
const matchOutcome = findMatch(normalizedContent, normalizedOldText, {
|
|
324
312
|
allowFuzzy: fuzzy,
|
|
325
313
|
threshold: threshold ?? DEFAULT_FUZZY_THRESHOLD,
|
package/src/patch/edit-tool.ts
CHANGED
|
@@ -35,7 +35,6 @@ import {
|
|
|
35
35
|
type HashlineEdit,
|
|
36
36
|
type LineTag,
|
|
37
37
|
parseTag,
|
|
38
|
-
type ReplaceTextEdit,
|
|
39
38
|
} from "./hashline";
|
|
40
39
|
import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "./normalize";
|
|
41
40
|
import {
|
|
@@ -44,7 +43,6 @@ import {
|
|
|
44
43
|
type HashlineParams,
|
|
45
44
|
hashlineEditSchema,
|
|
46
45
|
hashlineParseContent,
|
|
47
|
-
hashlineParseContentString,
|
|
48
46
|
normalizeEditMode,
|
|
49
47
|
type PatchParams,
|
|
50
48
|
patchEditSchema,
|
|
@@ -268,100 +266,32 @@ export class EditTool implements AgentTool<TInput, any, Theme> {
|
|
|
268
266
|
}
|
|
269
267
|
|
|
270
268
|
if (!(await file.exists())) {
|
|
271
|
-
|
|
272
|
-
for (const edit of edits) {
|
|
273
|
-
switch (edit.op) {
|
|
274
|
-
case "append": {
|
|
275
|
-
if (edit.after) {
|
|
276
|
-
throw new Error(`File not found: ${path}`);
|
|
277
|
-
}
|
|
278
|
-
content.push(...hashlineParseContent(edit.content));
|
|
279
|
-
break;
|
|
280
|
-
}
|
|
281
|
-
case "prepend": {
|
|
282
|
-
if (edit.before) {
|
|
283
|
-
throw new Error(`File not found: ${path}`);
|
|
284
|
-
}
|
|
285
|
-
content.unshift(...hashlineParseContent(edit.content));
|
|
286
|
-
break;
|
|
287
|
-
}
|
|
288
|
-
default: {
|
|
289
|
-
throw new Error(`File not found: ${path}`);
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
await file.write(content.join("\n"));
|
|
294
|
-
return {
|
|
295
|
-
content: [{ type: "text", text: `Created ${path}` }],
|
|
296
|
-
details: {
|
|
297
|
-
diff: "",
|
|
298
|
-
op: "create",
|
|
299
|
-
meta: outputMeta().get(),
|
|
300
|
-
},
|
|
301
|
-
};
|
|
269
|
+
throw new Error(`File not found: ${path}`);
|
|
302
270
|
}
|
|
303
271
|
|
|
304
272
|
const anchorEdits: HashlineEdit[] = [];
|
|
305
|
-
const replaceEdits: ReplaceTextEdit[] = [];
|
|
306
273
|
for (const edit of edits) {
|
|
307
274
|
switch (edit.op) {
|
|
308
|
-
case "
|
|
309
|
-
const {
|
|
310
|
-
anchorEdits.push({
|
|
311
|
-
op: "set",
|
|
312
|
-
tag: parseTag(tag),
|
|
313
|
-
content: hashlineParseContent(content),
|
|
314
|
-
});
|
|
315
|
-
break;
|
|
316
|
-
}
|
|
317
|
-
case "replace_range": {
|
|
318
|
-
const { first, last, content } = edit;
|
|
275
|
+
case "replace": {
|
|
276
|
+
const { target, end, content } = edit;
|
|
319
277
|
anchorEdits.push({
|
|
320
|
-
op: "
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
content: hashlineParseContent(content),
|
|
324
|
-
});
|
|
325
|
-
break;
|
|
326
|
-
}
|
|
327
|
-
case "append": {
|
|
328
|
-
const { after, content } = edit;
|
|
329
|
-
anchorEdits.push({
|
|
330
|
-
op: "append",
|
|
331
|
-
...(after ? { after: parseTag(after) } : {}),
|
|
332
|
-
content: hashlineParseContent(content),
|
|
333
|
-
});
|
|
334
|
-
break;
|
|
335
|
-
}
|
|
336
|
-
case "prepend": {
|
|
337
|
-
const { before, content } = edit;
|
|
338
|
-
anchorEdits.push({
|
|
339
|
-
op: "prepend",
|
|
340
|
-
...(before ? { before: parseTag(before) } : {}),
|
|
278
|
+
op: "replace",
|
|
279
|
+
target: parseTag(target),
|
|
280
|
+
...(end ? { end: parseTag(end) } : {}),
|
|
341
281
|
content: hashlineParseContent(content),
|
|
342
282
|
});
|
|
343
283
|
break;
|
|
344
284
|
}
|
|
345
285
|
case "insert": {
|
|
346
|
-
const {
|
|
286
|
+
const { target, position, content } = edit;
|
|
347
287
|
anchorEdits.push({
|
|
348
288
|
op: "insert",
|
|
349
|
-
|
|
350
|
-
|
|
289
|
+
target: parseTag(target),
|
|
290
|
+
position,
|
|
351
291
|
content: hashlineParseContent(content),
|
|
352
292
|
});
|
|
353
293
|
break;
|
|
354
294
|
}
|
|
355
|
-
case "replaceText": {
|
|
356
|
-
const { old_text, new_text, all } = edit;
|
|
357
|
-
replaceEdits.push({
|
|
358
|
-
op: "replaceText",
|
|
359
|
-
old_text: old_text,
|
|
360
|
-
new_text: hashlineParseContentString(new_text),
|
|
361
|
-
all: all ?? false,
|
|
362
|
-
});
|
|
363
|
-
break;
|
|
364
|
-
}
|
|
365
295
|
default:
|
|
366
296
|
throw new Error(`Invalid edit operation: ${JSON.stringify(edit)}`);
|
|
367
297
|
}
|
|
@@ -371,31 +301,8 @@ export class EditTool implements AgentTool<TInput, any, Theme> {
|
|
|
371
301
|
const { bom, text: content } = stripBom(rawContent);
|
|
372
302
|
const originalEnding = detectLineEnding(content);
|
|
373
303
|
const originalNormalized = normalizeToLF(content);
|
|
374
|
-
let normalizedContent = originalNormalized;
|
|
375
304
|
|
|
376
|
-
|
|
377
|
-
const anchorResult = applyHashlineEdits(normalizedContent, anchorEdits);
|
|
378
|
-
normalizedContent = anchorResult.content;
|
|
379
|
-
|
|
380
|
-
// Apply content-replace edits (substr-style fuzzy replace)
|
|
381
|
-
for (const r of replaceEdits) {
|
|
382
|
-
if (r.old_text.length === 0) {
|
|
383
|
-
throw new Error("old_text must not be empty.");
|
|
384
|
-
}
|
|
385
|
-
const rep = replaceText(normalizedContent, r.old_text, r.new_text, {
|
|
386
|
-
fuzzy: this.#allowFuzzy,
|
|
387
|
-
all: r.all ?? false,
|
|
388
|
-
threshold: this.#fuzzyThreshold,
|
|
389
|
-
});
|
|
390
|
-
normalizedContent = rep.content;
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
const result = {
|
|
394
|
-
content: normalizedContent,
|
|
395
|
-
firstChangedLine: anchorResult.firstChangedLine,
|
|
396
|
-
warnings: anchorResult.warnings,
|
|
397
|
-
noopEdits: anchorResult.noopEdits,
|
|
398
|
-
};
|
|
305
|
+
const result = applyHashlineEdits(originalNormalized, anchorEdits);
|
|
399
306
|
if (originalNormalized === result.content && !rename) {
|
|
400
307
|
let diagnostic = `No changes made to ${path}. The edits produced identical content.`;
|
|
401
308
|
if (result.noopEdits && result.noopEdits.length > 0) {
|
|
@@ -416,22 +323,12 @@ export class EditTool implements AgentTool<TInput, any, Theme> {
|
|
|
416
323
|
for (const edit of anchorEdits) {
|
|
417
324
|
refs.length = 0;
|
|
418
325
|
switch (edit.op) {
|
|
419
|
-
case "
|
|
420
|
-
refs.push(edit.
|
|
421
|
-
|
|
422
|
-
case "replace_range":
|
|
423
|
-
refs.push(edit.first, edit.last);
|
|
424
|
-
break;
|
|
425
|
-
case "append":
|
|
426
|
-
if (edit.after) refs.push(edit.after);
|
|
427
|
-
break;
|
|
428
|
-
case "prepend":
|
|
429
|
-
if (edit.before) refs.push(edit.before);
|
|
326
|
+
case "replace":
|
|
327
|
+
refs.push(edit.target);
|
|
328
|
+
if (edit.end) refs.push(edit.end);
|
|
430
329
|
break;
|
|
431
330
|
case "insert":
|
|
432
|
-
refs.push(edit.
|
|
433
|
-
break;
|
|
434
|
-
default:
|
|
331
|
+
refs.push(edit.target);
|
|
435
332
|
break;
|
|
436
333
|
}
|
|
437
334
|
|
|
@@ -544,7 +441,6 @@ export class EditTool implements AgentTool<TInput, any, Theme> {
|
|
|
544
441
|
}
|
|
545
442
|
const effRename = result.change.newPath ? rename : undefined;
|
|
546
443
|
|
|
547
|
-
// Generate diff for display
|
|
548
444
|
let diffResult = {
|
|
549
445
|
diff: "",
|
|
550
446
|
firstChangedLine: undefined as number | undefined,
|
|
@@ -627,7 +523,6 @@ export class EditTool implements AgentTool<TInput, any, Theme> {
|
|
|
627
523
|
});
|
|
628
524
|
|
|
629
525
|
if (result.count === 0) {
|
|
630
|
-
// Get error details
|
|
631
526
|
const matchOutcome = findMatch(normalizedContent, normalizedOldText, {
|
|
632
527
|
allowFuzzy: this.#allowFuzzy,
|
|
633
528
|
threshold: this.#fuzzyThreshold,
|
package/src/patch/fuzzy.ts
CHANGED
|
@@ -220,7 +220,6 @@ export function findMatch(
|
|
|
220
220
|
return {};
|
|
221
221
|
}
|
|
222
222
|
|
|
223
|
-
// Try exact match first
|
|
224
223
|
const exactIndex = content.indexOf(target);
|
|
225
224
|
if (exactIndex !== -1) {
|
|
226
225
|
const occurrences = content.split(target).length - 1;
|
|
@@ -260,7 +259,6 @@ export function findMatch(
|
|
|
260
259
|
};
|
|
261
260
|
}
|
|
262
261
|
|
|
263
|
-
// Try fuzzy match
|
|
264
262
|
const threshold = options.threshold ?? DEFAULT_FUZZY_THRESHOLD;
|
|
265
263
|
const { best, aboveThresholdCount, secondBestScore } = findBestFuzzyMatch(content, target, threshold);
|
|
266
264
|
|
|
@@ -384,7 +382,6 @@ export function seekSequence(
|
|
|
384
382
|
return { index: undefined, confidence: 0 };
|
|
385
383
|
}
|
|
386
384
|
|
|
387
|
-
// Determine search start position
|
|
388
385
|
const searchStart = eof && lines.length >= pattern.length ? lines.length - pattern.length : start;
|
|
389
386
|
const maxStart = lines.length - pattern.length;
|
|
390
387
|
|
|
@@ -547,7 +544,6 @@ export function seekSequence(
|
|
|
547
544
|
});
|
|
548
545
|
|
|
549
546
|
if (matchOutcome.match) {
|
|
550
|
-
// Convert character index back to line index
|
|
551
547
|
const matchedContent = contentText.substring(0, matchOutcome.match.startIndex);
|
|
552
548
|
const lineIndex = start + matchedContent.split("\n").length - 1;
|
|
553
549
|
const fallbackMatchCount = matchOutcome.occurrences ?? matchOutcome.fuzzyMatches ?? 1;
|
package/src/patch/hashline.ts
CHANGED
|
@@ -16,13 +16,8 @@ import type { HashMismatch } from "./types";
|
|
|
16
16
|
|
|
17
17
|
export type LineTag = { line: number; hash: string };
|
|
18
18
|
export type HashlineEdit =
|
|
19
|
-
| { op: "
|
|
20
|
-
| { op: "
|
|
21
|
-
| { op: "append"; after?: LineTag; content: string[] }
|
|
22
|
-
| { op: "prepend"; before?: LineTag; content: string[] }
|
|
23
|
-
| { op: "insert"; after: LineTag; before: LineTag; content: string[] };
|
|
24
|
-
export type ReplaceTextEdit = { op: "replaceText"; old_text: string; new_text: string; all?: boolean };
|
|
25
|
-
export type EditSpec = HashlineEdit | ReplaceTextEdit;
|
|
19
|
+
| { op: "replace"; target: LineTag; end?: LineTag; content: string[] }
|
|
20
|
+
| { op: "insert"; target: LineTag; position: "before" | "after"; content: string[] };
|
|
26
21
|
|
|
27
22
|
/**
|
|
28
23
|
* Compare two strings ignoring all whitespace differences.
|
|
@@ -31,9 +26,7 @@ export type EditSpec = HashlineEdit | ReplaceTextEdit;
|
|
|
31
26
|
* the only differences are in spaces, tabs, or other whitespace.
|
|
32
27
|
*/
|
|
33
28
|
function equalsIgnoringWhitespace(a: string, b: string): boolean {
|
|
34
|
-
// Fast path: identical strings
|
|
35
29
|
if (a === b) return true;
|
|
36
|
-
// Compare with all whitespace removed
|
|
37
30
|
return a.replace(/\s+/g, "") === b.replace(/\s+/g, "");
|
|
38
31
|
}
|
|
39
32
|
|
|
@@ -140,17 +133,6 @@ function stripInsertAnchorEchoBefore(anchorLine: string, dstLines: string[]): st
|
|
|
140
133
|
return dstLines;
|
|
141
134
|
}
|
|
142
135
|
|
|
143
|
-
function stripInsertBoundaryEcho(afterLine: string, beforeLine: string, dstLines: string[]): string[] {
|
|
144
|
-
let out = dstLines;
|
|
145
|
-
if (out.length > 1 && equalsIgnoringWhitespace(out[0], afterLine)) {
|
|
146
|
-
out = out.slice(1);
|
|
147
|
-
}
|
|
148
|
-
if (out.length > 1 && equalsIgnoringWhitespace(out[out.length - 1], beforeLine)) {
|
|
149
|
-
out = out.slice(0, -1);
|
|
150
|
-
}
|
|
151
|
-
return out;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
136
|
function stripRangeBoundaryEcho(fileLines: string[], startLine: number, endLine: number, dstLines: string[]): string[] {
|
|
155
137
|
// Only strip when the model replaced with multiple lines and grew the edit.
|
|
156
138
|
// This avoids turning a single-line replacement into a deletion.
|
|
@@ -504,7 +486,6 @@ export class HashlineMismatchError extends Error {
|
|
|
504
486
|
mismatchSet.set(m.line, m);
|
|
505
487
|
}
|
|
506
488
|
|
|
507
|
-
// Collect line ranges to display (mismatch lines + context)
|
|
508
489
|
const displayLines = new Set<number>();
|
|
509
490
|
for (const m of mismatches) {
|
|
510
491
|
const lo = Math.max(1, m.line - MISMATCH_CONTEXT);
|
|
@@ -630,7 +611,6 @@ export function applyHashlineEdits(
|
|
|
630
611
|
|
|
631
612
|
const autocorrect = Bun.env.ARCANE_HL_AUTOCORRECT === "1";
|
|
632
613
|
|
|
633
|
-
// Collect warnings and auto-correct edit content
|
|
634
614
|
const warnings: string[] = [];
|
|
635
615
|
for (const edit of edits) {
|
|
636
616
|
const unicodeWarning = detectUnicodeEscapePlaceholders(edit.content);
|
|
@@ -644,25 +624,14 @@ export function applyHashlineEdits(
|
|
|
644
624
|
const touched = new Set<number>();
|
|
645
625
|
for (const edit of edits) {
|
|
646
626
|
switch (edit.op) {
|
|
647
|
-
case "
|
|
648
|
-
touched.add(edit.
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
for (let ln = edit.first.line; ln <= edit.last.line; ln++) touched.add(ln);
|
|
652
|
-
break;
|
|
653
|
-
case "append":
|
|
654
|
-
if (edit.after) {
|
|
655
|
-
touched.add(edit.after.line);
|
|
656
|
-
}
|
|
657
|
-
break;
|
|
658
|
-
case "prepend":
|
|
659
|
-
if (edit.before) {
|
|
660
|
-
touched.add(edit.before.line);
|
|
627
|
+
case "replace":
|
|
628
|
+
touched.add(edit.target.line);
|
|
629
|
+
if (edit.end) {
|
|
630
|
+
for (let ln = edit.target.line; ln <= edit.end.line; ln++) touched.add(ln);
|
|
661
631
|
}
|
|
662
632
|
break;
|
|
663
633
|
case "insert":
|
|
664
|
-
touched.add(edit.
|
|
665
|
-
touched.add(edit.before.line);
|
|
634
|
+
touched.add(edit.target.line);
|
|
666
635
|
break;
|
|
667
636
|
}
|
|
668
637
|
}
|
|
@@ -685,44 +654,21 @@ export function applyHashlineEdits(
|
|
|
685
654
|
}
|
|
686
655
|
for (const edit of edits) {
|
|
687
656
|
switch (edit.op) {
|
|
688
|
-
case "
|
|
689
|
-
if (!validateRef(edit.
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
}
|
|
696
|
-
if (edit.after && !validateRef(edit.after)) continue;
|
|
697
|
-
break;
|
|
698
|
-
}
|
|
699
|
-
case "prepend": {
|
|
700
|
-
if (edit.content.length === 0) {
|
|
701
|
-
throw new Error('Insert-before edit (src "N#HH..") requires non-empty dst');
|
|
657
|
+
case "replace": {
|
|
658
|
+
if (!validateRef(edit.target)) continue;
|
|
659
|
+
if (edit.end) {
|
|
660
|
+
if (edit.target.line > edit.end.line) {
|
|
661
|
+
throw new Error(`Range start line ${edit.target.line} must be <= end line ${edit.end.line}`);
|
|
662
|
+
}
|
|
663
|
+
if (!validateRef(edit.end)) continue;
|
|
702
664
|
}
|
|
703
|
-
if (edit.before && !validateRef(edit.before)) continue;
|
|
704
665
|
break;
|
|
705
666
|
}
|
|
706
667
|
case "insert": {
|
|
707
668
|
if (edit.content.length === 0) {
|
|
708
|
-
throw new Error(
|
|
709
|
-
}
|
|
710
|
-
if (edit.before.line <= edit.after.line) {
|
|
711
|
-
throw new Error(`insert requires after (${edit.after.line}) < before (${edit.before.line})`);
|
|
669
|
+
throw new Error("Insert edit requires non-empty content");
|
|
712
670
|
}
|
|
713
|
-
|
|
714
|
-
const beforeValid = validateRef(edit.before);
|
|
715
|
-
if (!afterValid || !beforeValid) continue;
|
|
716
|
-
break;
|
|
717
|
-
}
|
|
718
|
-
case "replace_range": {
|
|
719
|
-
if (edit.first.line > edit.last.line) {
|
|
720
|
-
throw new Error(`Range start line ${edit.first.line} must be <= end line ${edit.last.line}`);
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
const startValid = validateRef(edit.first);
|
|
724
|
-
const endValid = validateRef(edit.last);
|
|
725
|
-
if (!startValid || !endValid) continue;
|
|
671
|
+
if (!validateRef(edit.target)) continue;
|
|
726
672
|
break;
|
|
727
673
|
}
|
|
728
674
|
}
|
|
@@ -730,35 +676,17 @@ export function applyHashlineEdits(
|
|
|
730
676
|
if (mismatches.length > 0) {
|
|
731
677
|
throw new HashlineMismatchError(mismatches, fileLines);
|
|
732
678
|
}
|
|
733
|
-
// Deduplicate identical edits targeting the same line(s)
|
|
734
679
|
const seenEditKeys = new Map<string, number>();
|
|
735
680
|
const dedupIndices = new Set<number>();
|
|
736
681
|
for (let i = 0; i < edits.length; i++) {
|
|
737
682
|
const edit = edits[i];
|
|
738
683
|
let lineKey: string;
|
|
739
684
|
switch (edit.op) {
|
|
740
|
-
case "
|
|
741
|
-
lineKey = `s:${edit.
|
|
742
|
-
break;
|
|
743
|
-
case "replace_range":
|
|
744
|
-
lineKey = `r:${edit.first.line}:${edit.last.line}`;
|
|
745
|
-
break;
|
|
746
|
-
case "append":
|
|
747
|
-
if (edit.after) {
|
|
748
|
-
lineKey = `i:${edit.after.line}`;
|
|
749
|
-
break;
|
|
750
|
-
}
|
|
751
|
-
lineKey = "ieof";
|
|
752
|
-
break;
|
|
753
|
-
case "prepend":
|
|
754
|
-
if (edit.before) {
|
|
755
|
-
lineKey = `ib:${edit.before.line}`;
|
|
756
|
-
break;
|
|
757
|
-
}
|
|
758
|
-
lineKey = "ibef";
|
|
685
|
+
case "replace":
|
|
686
|
+
lineKey = edit.end ? `r:${edit.target.line}:${edit.end.line}` : `s:${edit.target.line}`;
|
|
759
687
|
break;
|
|
760
688
|
case "insert":
|
|
761
|
-
lineKey = `
|
|
689
|
+
lineKey = `i:${edit.target.line}:${edit.position}`;
|
|
762
690
|
break;
|
|
763
691
|
}
|
|
764
692
|
const dstKey = `${lineKey}:${edit.content.join("\n")}`;
|
|
@@ -779,25 +707,13 @@ export function applyHashlineEdits(
|
|
|
779
707
|
let sortLine: number;
|
|
780
708
|
let precedence: number;
|
|
781
709
|
switch (edit.op) {
|
|
782
|
-
case "
|
|
783
|
-
sortLine = edit.
|
|
710
|
+
case "replace":
|
|
711
|
+
sortLine = edit.end ? edit.end.line : edit.target.line;
|
|
784
712
|
precedence = 0;
|
|
785
713
|
break;
|
|
786
|
-
case "replace_range":
|
|
787
|
-
sortLine = edit.last.line;
|
|
788
|
-
precedence = 0;
|
|
789
|
-
break;
|
|
790
|
-
case "append":
|
|
791
|
-
sortLine = edit.after ? edit.after.line : fileLines.length + 1;
|
|
792
|
-
precedence = 1;
|
|
793
|
-
break;
|
|
794
|
-
case "prepend":
|
|
795
|
-
sortLine = edit.before ? edit.before.line : 0;
|
|
796
|
-
precedence = 2;
|
|
797
|
-
break;
|
|
798
714
|
case "insert":
|
|
799
|
-
sortLine = edit.
|
|
800
|
-
precedence =
|
|
715
|
+
sortLine = edit.target.line;
|
|
716
|
+
precedence = edit.position === "before" ? 2 : 1;
|
|
801
717
|
break;
|
|
802
718
|
}
|
|
803
719
|
return { edit, idx, sortLine, precedence };
|
|
@@ -805,147 +721,83 @@ export function applyHashlineEdits(
|
|
|
805
721
|
|
|
806
722
|
annotated.sort((a, b) => b.sortLine - a.sortLine || a.precedence - b.precedence || a.idx - b.idx);
|
|
807
723
|
|
|
808
|
-
// Apply edits bottom-up
|
|
809
724
|
for (const { edit, idx } of annotated) {
|
|
810
725
|
switch (edit.op) {
|
|
811
|
-
case "
|
|
812
|
-
const
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
);
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
726
|
+
case "replace": {
|
|
727
|
+
const startLine = edit.target.line;
|
|
728
|
+
const endLine = edit.end ? edit.end.line : edit.target.line;
|
|
729
|
+
const count = endLine - startLine + 1;
|
|
730
|
+
|
|
731
|
+
if (!edit.end) {
|
|
732
|
+
const merged = autocorrect ? maybeExpandSingleLineMerge(startLine, edit.content) : null;
|
|
733
|
+
if (merged) {
|
|
734
|
+
const origLines = originalFileLines.slice(
|
|
735
|
+
merged.startLine - 1,
|
|
736
|
+
merged.startLine - 1 + merged.deleteCount,
|
|
737
|
+
);
|
|
738
|
+
let nextLines = merged.newLines;
|
|
739
|
+
nextLines = restoreIndentForPairedReplacement([origLines[0] ?? ""], nextLines);
|
|
740
|
+
|
|
741
|
+
if (origLines.length === nextLines.length && origLines.every((line, i) => line === nextLines[i])) {
|
|
742
|
+
noopEdits.push({
|
|
743
|
+
editIndex: idx,
|
|
744
|
+
loc: `${edit.target.line}#${edit.target.hash}`,
|
|
745
|
+
currentContent: origLines.join("\n"),
|
|
746
|
+
});
|
|
747
|
+
break;
|
|
748
|
+
}
|
|
749
|
+
fileLines.splice(merged.startLine - 1, merged.deleteCount, ...nextLines);
|
|
750
|
+
trackFirstChanged(merged.startLine);
|
|
827
751
|
break;
|
|
828
752
|
}
|
|
829
|
-
fileLines.splice(merged.startLine - 1, merged.deleteCount, ...nextLines);
|
|
830
|
-
trackFirstChanged(merged.startLine);
|
|
831
|
-
break;
|
|
832
753
|
}
|
|
833
754
|
|
|
834
|
-
const
|
|
835
|
-
const origLines = originalFileLines.slice(edit.tag.line - 1, edit.tag.line);
|
|
755
|
+
const origLines = originalFileLines.slice(startLine - 1, startLine - 1 + count);
|
|
836
756
|
let stripped = autocorrect
|
|
837
|
-
? stripRangeBoundaryEcho(originalFileLines,
|
|
757
|
+
? stripRangeBoundaryEcho(originalFileLines, startLine, endLine, edit.content)
|
|
838
758
|
: edit.content;
|
|
839
759
|
stripped = autocorrect ? restoreOldWrappedLines(origLines, stripped) : stripped;
|
|
840
760
|
const newLines = autocorrect ? restoreIndentForPairedReplacement(origLines, stripped) : stripped;
|
|
841
761
|
if (origLines.length === newLines.length && origLines.every((line, i) => line === newLines[i])) {
|
|
842
762
|
noopEdits.push({
|
|
843
763
|
editIndex: idx,
|
|
844
|
-
loc: `${edit.
|
|
764
|
+
loc: `${edit.target.line}#${edit.target.hash}`,
|
|
845
765
|
currentContent: origLines.join("\n"),
|
|
846
766
|
});
|
|
847
767
|
break;
|
|
848
768
|
}
|
|
849
|
-
fileLines.splice(
|
|
850
|
-
trackFirstChanged(
|
|
769
|
+
fileLines.splice(startLine - 1, count, ...newLines);
|
|
770
|
+
trackFirstChanged(startLine);
|
|
851
771
|
break;
|
|
852
772
|
}
|
|
853
|
-
case "
|
|
854
|
-
const
|
|
855
|
-
const
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
stripped = autocorrect ? restoreOldWrappedLines(origLines, stripped) : stripped;
|
|
860
|
-
const newLines = autocorrect ? restoreIndentForPairedReplacement(origLines, stripped) : stripped;
|
|
861
|
-
if (origLines.length === newLines.length && origLines.every((line, i) => line === newLines[i])) {
|
|
862
|
-
noopEdits.push({
|
|
863
|
-
editIndex: idx,
|
|
864
|
-
loc: `${edit.first.line}#${edit.first.hash}`,
|
|
865
|
-
currentContent: origLines.join("\n"),
|
|
866
|
-
});
|
|
867
|
-
break;
|
|
868
|
-
}
|
|
869
|
-
fileLines.splice(edit.first.line - 1, count, ...newLines);
|
|
870
|
-
trackFirstChanged(edit.first.line);
|
|
871
|
-
break;
|
|
872
|
-
}
|
|
873
|
-
case "append": {
|
|
874
|
-
const inserted = edit.after
|
|
875
|
-
? autocorrect
|
|
876
|
-
? stripInsertAnchorEchoAfter(originalFileLines[edit.after.line - 1], edit.content)
|
|
877
|
-
: edit.content
|
|
878
|
-
: edit.content;
|
|
879
|
-
if (inserted.length === 0) {
|
|
880
|
-
noopEdits.push({
|
|
881
|
-
editIndex: idx,
|
|
882
|
-
loc: edit.after ? `${edit.after.line}#${edit.after.hash}` : "EOF",
|
|
883
|
-
currentContent: edit.after ? originalFileLines[edit.after.line - 1] : "",
|
|
884
|
-
});
|
|
885
|
-
break;
|
|
886
|
-
}
|
|
887
|
-
if (edit.after) {
|
|
888
|
-
fileLines.splice(edit.after.line, 0, ...inserted);
|
|
889
|
-
trackFirstChanged(edit.after.line + 1);
|
|
890
|
-
} else {
|
|
891
|
-
if (fileLines.length === 1 && fileLines[0] === "") {
|
|
892
|
-
fileLines.splice(0, 1, ...inserted);
|
|
893
|
-
trackFirstChanged(1);
|
|
894
|
-
} else {
|
|
895
|
-
fileLines.splice(fileLines.length, 0, ...inserted);
|
|
896
|
-
trackFirstChanged(fileLines.length - inserted.length + 1);
|
|
897
|
-
}
|
|
898
|
-
}
|
|
899
|
-
break;
|
|
900
|
-
}
|
|
901
|
-
case "prepend": {
|
|
902
|
-
const inserted = edit.before
|
|
903
|
-
? autocorrect
|
|
904
|
-
? stripInsertAnchorEchoBefore(originalFileLines[edit.before.line - 1], edit.content)
|
|
905
|
-
: edit.content
|
|
773
|
+
case "insert": {
|
|
774
|
+
const anchorLine = originalFileLines[edit.target.line - 1];
|
|
775
|
+
const inserted = autocorrect
|
|
776
|
+
? edit.position === "after"
|
|
777
|
+
? stripInsertAnchorEchoAfter(anchorLine, edit.content)
|
|
778
|
+
: stripInsertAnchorEchoBefore(anchorLine, edit.content)
|
|
906
779
|
: edit.content;
|
|
907
780
|
if (inserted.length === 0) {
|
|
908
781
|
noopEdits.push({
|
|
909
782
|
editIndex: idx,
|
|
910
|
-
loc:
|
|
911
|
-
currentContent:
|
|
783
|
+
loc: `${edit.target.line}#${edit.target.hash}`,
|
|
784
|
+
currentContent: anchorLine,
|
|
912
785
|
});
|
|
913
786
|
break;
|
|
914
787
|
}
|
|
915
|
-
if (edit.
|
|
916
|
-
fileLines.splice(edit.
|
|
917
|
-
trackFirstChanged(edit.
|
|
788
|
+
if (edit.position === "after") {
|
|
789
|
+
fileLines.splice(edit.target.line, 0, ...inserted);
|
|
790
|
+
trackFirstChanged(edit.target.line + 1);
|
|
918
791
|
} else {
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
} else {
|
|
922
|
-
fileLines.splice(0, 0, ...inserted);
|
|
923
|
-
}
|
|
924
|
-
trackFirstChanged(1);
|
|
925
|
-
}
|
|
926
|
-
break;
|
|
927
|
-
}
|
|
928
|
-
case "insert": {
|
|
929
|
-
const afterLine = originalFileLines[edit.after.line - 1];
|
|
930
|
-
const beforeLine = originalFileLines[edit.before.line - 1];
|
|
931
|
-
const inserted = autocorrect ? stripInsertBoundaryEcho(afterLine, beforeLine, edit.content) : edit.content;
|
|
932
|
-
if (inserted.length === 0) {
|
|
933
|
-
noopEdits.push({
|
|
934
|
-
editIndex: idx,
|
|
935
|
-
loc: `${edit.after.line}#${edit.after.hash}..${edit.before.line}#${edit.before.hash}`,
|
|
936
|
-
currentContent: `${afterLine}\n${beforeLine}`,
|
|
937
|
-
});
|
|
938
|
-
break;
|
|
792
|
+
fileLines.splice(edit.target.line - 1, 0, ...inserted);
|
|
793
|
+
trackFirstChanged(edit.target.line);
|
|
939
794
|
}
|
|
940
|
-
fileLines.splice(edit.before.line - 1, 0, ...inserted);
|
|
941
|
-
trackFirstChanged(edit.before.line);
|
|
942
795
|
break;
|
|
943
796
|
}
|
|
944
797
|
}
|
|
945
798
|
}
|
|
946
799
|
|
|
947
800
|
let finalContent = fileLines.join("\n");
|
|
948
|
-
// Preserve trailing newline behavior of original content
|
|
949
801
|
if (hadFinalNewline && !finalContent.endsWith("\n")) {
|
|
950
802
|
finalContent += "\n";
|
|
951
803
|
} else if (!hadFinalNewline && finalContent.endsWith("\n")) {
|
|
@@ -1063,7 +915,6 @@ export function buildCompactDiffPreview(diff: string, options: CompactDiffOption
|
|
|
1063
915
|
|
|
1064
916
|
const inputLines = diff.split("\n");
|
|
1065
917
|
|
|
1066
|
-
// Single-pass: group consecutive lines by kind into run spans
|
|
1067
918
|
type Kind = " " | "+" | "-" | "meta";
|
|
1068
919
|
const runs: { kind: Kind; start: number; end: number }[] = [];
|
|
1069
920
|
for (let i = 0; i < inputLines.length; i++) {
|
package/src/patch/parser.ts
CHANGED
|
@@ -228,7 +228,6 @@ function parseOneHunk(lines: string[], lineNumber: number, allowMissingContext:
|
|
|
228
228
|
const unifiedHeader = isHeaderLine ? parseUnifiedHunkHeader(headerTrimmed) : undefined;
|
|
229
229
|
const isEmptyContextMarker = /^@@\s*@@$/.test(headerTrimmed);
|
|
230
230
|
|
|
231
|
-
// Check for context marker
|
|
232
231
|
if (isHeaderLine && (headerTrimmed === EMPTY_CHANGE_CONTEXT_MARKER || isEmptyContextMarker)) {
|
|
233
232
|
startIndex = 1;
|
|
234
233
|
} else if (unifiedHeader) {
|
|
@@ -280,7 +279,6 @@ function parseOneHunk(lines: string[], lineNumber: number, allowMissingContext:
|
|
|
280
279
|
throw new ParseError(`Line numbers must be >= 1 (got ${newStartLine})`, lineNumber);
|
|
281
280
|
}
|
|
282
281
|
|
|
283
|
-
// Check for nested @@ anchors on subsequent lines
|
|
284
282
|
// Format: @@ class Foo
|
|
285
283
|
// @@ method
|
|
286
284
|
while (startIndex < lines.length) {
|
|
@@ -290,7 +288,6 @@ function parseOneHunk(lines: string[], lineNumber: number, allowMissingContext:
|
|
|
290
288
|
}
|
|
291
289
|
const trimmed = nextLine.trimEnd();
|
|
292
290
|
|
|
293
|
-
// Check if it's another @@ line (nested anchor)
|
|
294
291
|
if (trimmed.startsWith(CHANGE_CONTEXT_MARKER)) {
|
|
295
292
|
const nestedContext = trimmed.slice(CHANGE_CONTEXT_MARKER.length);
|
|
296
293
|
if (nestedContext.trim().length > 0) {
|
|
@@ -301,7 +298,6 @@ function parseOneHunk(lines: string[], lineNumber: number, allowMissingContext:
|
|
|
301
298
|
// Empty @@ as separator - skip it
|
|
302
299
|
startIndex++;
|
|
303
300
|
} else {
|
|
304
|
-
// Not an @@ line, stop accumulating
|
|
305
301
|
break;
|
|
306
302
|
}
|
|
307
303
|
}
|
|
@@ -505,7 +501,6 @@ export function parseHunks(diff: string): DiffHunk[] {
|
|
|
505
501
|
const line = lines[i];
|
|
506
502
|
const trimmed = line.trim();
|
|
507
503
|
|
|
508
|
-
// Skip blank lines between hunks
|
|
509
504
|
if (trimmed === "") {
|
|
510
505
|
i++;
|
|
511
506
|
continue;
|
package/src/patch/schemas.ts
CHANGED
|
@@ -104,73 +104,36 @@ export function hashlineParseContentString(edit: string | string[] | null): stri
|
|
|
104
104
|
return edit;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
const
|
|
107
|
+
const hashlineReplaceOpSchema = Type.Object(
|
|
108
108
|
{
|
|
109
|
-
op: Type.Literal("
|
|
110
|
-
|
|
109
|
+
op: Type.Literal("replace"),
|
|
110
|
+
target: hashlineTagFormat("line to replace (or start of range)"),
|
|
111
|
+
end: Type.Optional(hashlineTagFormat("last line of range")),
|
|
111
112
|
content: hashlineReplaceContentFormat("Replacement"),
|
|
112
113
|
},
|
|
113
114
|
{ additionalProperties: false },
|
|
114
115
|
);
|
|
115
116
|
|
|
116
|
-
const
|
|
117
|
-
{
|
|
118
|
-
op: Type.Literal("append"),
|
|
119
|
-
after: Type.Optional(hashlineTagFormat("line after which to append")),
|
|
120
|
-
content: hashlineInsertContentFormat("Appended"),
|
|
121
|
-
},
|
|
122
|
-
{ additionalProperties: false },
|
|
123
|
-
);
|
|
124
|
-
|
|
125
|
-
const hashlinePrependEditSchema = Type.Object(
|
|
126
|
-
{
|
|
127
|
-
op: Type.Literal("prepend"),
|
|
128
|
-
before: Type.Optional(hashlineTagFormat("line before which to prepend")),
|
|
129
|
-
content: hashlineInsertContentFormat("Prepended"),
|
|
130
|
-
},
|
|
131
|
-
{ additionalProperties: false },
|
|
132
|
-
);
|
|
133
|
-
|
|
134
|
-
const hashlineRangeEditSchema = Type.Object(
|
|
135
|
-
{
|
|
136
|
-
op: Type.Literal("replace_range"),
|
|
137
|
-
first: hashlineTagFormat("first line"),
|
|
138
|
-
last: hashlineTagFormat("last line"),
|
|
139
|
-
content: hashlineReplaceContentFormat("Replacement"),
|
|
140
|
-
},
|
|
141
|
-
{ additionalProperties: false },
|
|
142
|
-
);
|
|
143
|
-
|
|
144
|
-
const hashlineInsertEditSchema = Type.Object(
|
|
117
|
+
const hashlineInsertOpSchema = Type.Object(
|
|
145
118
|
{
|
|
146
119
|
op: Type.Literal("insert"),
|
|
147
|
-
|
|
148
|
-
|
|
120
|
+
target: hashlineTagFormat("anchor line"),
|
|
121
|
+
position: StringEnum(["before", "after"], { description: "Insert before or after the anchor" }),
|
|
149
122
|
content: hashlineInsertContentFormat("Inserted"),
|
|
150
123
|
},
|
|
151
124
|
{ additionalProperties: false },
|
|
152
125
|
);
|
|
153
126
|
|
|
154
|
-
const
|
|
155
|
-
{
|
|
156
|
-
|
|
157
|
-
old_text: Type.String({ description: "Text to find", minLength: 1 }),
|
|
158
|
-
new_text: hashlineReplaceContentFormat("Replacement"),
|
|
159
|
-
all: Type.Optional(Type.Boolean({ description: "Replace all occurrences" })),
|
|
160
|
-
},
|
|
161
|
-
{ additionalProperties: false },
|
|
162
|
-
);
|
|
163
|
-
|
|
164
|
-
const HL_REPLACE_ENABLED = Bun.env.ARCANE_HL_REPLACETXT === "1";
|
|
127
|
+
const hashlineEditSpecUnion = Type.Union([hashlineReplaceOpSchema, hashlineInsertOpSchema], {
|
|
128
|
+
discriminator: { propertyName: "op" },
|
|
129
|
+
});
|
|
165
130
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
...(HL_REPLACE_ENABLED ? [hashlineReplaceTextEditSchema] : []),
|
|
173
|
-
]);
|
|
131
|
+
// AJV discriminator requires `oneOf`, but TypeBox emits `anyOf`.
|
|
132
|
+
// Swap to `oneOf` so AJV validates only the matching sub-schema.
|
|
133
|
+
export const hashlineEditSpecSchema = (() => {
|
|
134
|
+
const { anyOf, ...rest } = hashlineEditSpecUnion;
|
|
135
|
+
return { ...rest, oneOf: anyOf } as unknown as typeof hashlineEditSpecUnion;
|
|
136
|
+
})();
|
|
174
137
|
|
|
175
138
|
export const hashlineEditSchema = Type.Object(
|
|
176
139
|
{
|
package/src/patch/shared.ts
CHANGED
|
@@ -73,7 +73,6 @@ interface EditRenderArgs {
|
|
|
73
73
|
newText?: string;
|
|
74
74
|
patch?: string;
|
|
75
75
|
all?: boolean;
|
|
76
|
-
// Patch mode fields
|
|
77
76
|
op?: Operation;
|
|
78
77
|
rename?: string;
|
|
79
78
|
diff?: string;
|
|
@@ -81,15 +80,12 @@ interface EditRenderArgs {
|
|
|
81
80
|
* Computed preview diff (used when tool args don't include a diff, e.g. hashline mode).
|
|
82
81
|
*/
|
|
83
82
|
previewDiff?: string;
|
|
84
|
-
// Hashline mode fields
|
|
85
83
|
edits?: HashlineEditPreview[];
|
|
86
84
|
}
|
|
87
85
|
|
|
88
86
|
type HashlineEditPreview =
|
|
89
|
-
| { target: string;
|
|
90
|
-
| {
|
|
91
|
-
| { before?: string; after?: string; inserted_lines: string[] }
|
|
92
|
-
| { old_text: string; new_text: string; all?: boolean };
|
|
87
|
+
| { op: "replace"; target: string; end?: string; content: string[] | string | null }
|
|
88
|
+
| { op: "insert"; target: string; position: "before" | "after"; content: string[] | string };
|
|
93
89
|
|
|
94
90
|
/** Extended context for edit tool rendering */
|
|
95
91
|
export interface EditRenderContext {
|
|
@@ -163,39 +159,29 @@ function formatStreamingHashlineEdits(edits: unknown[], uiTheme: Theme): string
|
|
|
163
159
|
dst: "",
|
|
164
160
|
};
|
|
165
161
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
162
|
+
const op = editRecord.op;
|
|
163
|
+
const target = typeof editRecord.target === "string" ? editRecord.target : "…";
|
|
164
|
+
const content = editRecord.content;
|
|
165
|
+
const contentStr =
|
|
166
|
+
content === null
|
|
167
|
+
? ""
|
|
168
|
+
: Array.isArray(content)
|
|
169
|
+
? (content as string[]).join("\n")
|
|
170
|
+
: typeof content === "string"
|
|
171
|
+
? content
|
|
172
|
+
: "";
|
|
173
|
+
if (op === "replace") {
|
|
174
|
+
const end = typeof editRecord.end === "string" ? editRecord.end : undefined;
|
|
169
175
|
return {
|
|
170
|
-
srcLabel: `• line ${target}`,
|
|
171
|
-
dst:
|
|
176
|
+
srcLabel: end ? `• range ${target}..${end}` : `• line ${target}`,
|
|
177
|
+
dst: contentStr,
|
|
172
178
|
};
|
|
173
179
|
}
|
|
174
|
-
if (
|
|
175
|
-
const
|
|
176
|
-
const last = typeof editRecord.last === "string" ? editRecord.last : "…";
|
|
177
|
-
const newContent = editRecord.new_content;
|
|
180
|
+
if (op === "insert") {
|
|
181
|
+
const position = typeof editRecord.position === "string" ? editRecord.position : "after";
|
|
178
182
|
return {
|
|
179
|
-
srcLabel: `•
|
|
180
|
-
dst:
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
if ("old_text" in editRecord || "new_text" in editRecord) {
|
|
184
|
-
const all = typeof editRecord.all === "boolean" ? editRecord.all : false;
|
|
185
|
-
return {
|
|
186
|
-
srcLabel: `• replace old_text→new_text${all ? " (all)" : ""}`,
|
|
187
|
-
dst: typeof editRecord.new_text === "string" ? editRecord.new_text : "",
|
|
188
|
-
};
|
|
189
|
-
}
|
|
190
|
-
if ("inserted_lines" in editRecord || "before" in editRecord || "after" in editRecord) {
|
|
191
|
-
const after = typeof editRecord.after === "string" ? editRecord.after : undefined;
|
|
192
|
-
const before = typeof editRecord.before === "string" ? editRecord.before : undefined;
|
|
193
|
-
const insertedLines = editRecord.inserted_lines;
|
|
194
|
-
const text = Array.isArray(insertedLines) ? (insertedLines as string[]).join("\n") : "";
|
|
195
|
-
const refs = [after, before].filter(Boolean).join("..") || "…";
|
|
196
|
-
return {
|
|
197
|
-
srcLabel: `• insert ${refs}`,
|
|
198
|
-
dst: text,
|
|
183
|
+
srcLabel: `• insert ${position} ${target}`,
|
|
184
|
+
dst: contentStr,
|
|
199
185
|
};
|
|
200
186
|
}
|
|
201
187
|
return {
|
|
@@ -217,19 +203,16 @@ export const editToolRenderer = {
|
|
|
217
203
|
const editIcon = uiTheme.fg("muted", uiTheme.getLangIcon(editLanguage));
|
|
218
204
|
let pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", "…");
|
|
219
205
|
|
|
220
|
-
// Add arrow for move/rename operations
|
|
221
206
|
if (args.rename) {
|
|
222
207
|
pathDisplay += ` ${uiTheme.fg("dim", "→")} ${uiTheme.fg("accent", shortenPath(args.rename))}`;
|
|
223
208
|
}
|
|
224
209
|
|
|
225
|
-
// Show operation type for patch mode
|
|
226
210
|
const opTitle = args.op === "create" ? "Create" : args.op === "delete" ? "Delete" : "Edit";
|
|
227
211
|
const spinner =
|
|
228
212
|
options?.spinnerFrame !== undefined ? formatStatusIcon("running", uiTheme, options.spinnerFrame) : "";
|
|
229
213
|
const title = uiTheme.fg("toolTitle", uiTheme.bold(opTitle));
|
|
230
214
|
let text = `${title} ${spinner ? `${spinner} ` : ""}${editIcon} ${pathDisplay}`;
|
|
231
215
|
|
|
232
|
-
// Show streaming preview of diff/content
|
|
233
216
|
const previewDiffText =
|
|
234
217
|
args.previewDiff ??
|
|
235
218
|
(options.renderContext?.editDiffPreview && "diff" in options.renderContext.editDiffPreview
|
|
@@ -282,7 +265,6 @@ export const editToolRenderer = {
|
|
|
282
265
|
return new Text(`${header}\n${uiTheme.fg("error", replaceTabs(errorText))}`, 0, 0);
|
|
283
266
|
}
|
|
284
267
|
|
|
285
|
-
// Get diff text from result or preview
|
|
286
268
|
const { renderContext } = options;
|
|
287
269
|
const editDiffPreview = renderContext?.editDiffPreview;
|
|
288
270
|
const diffText =
|
|
@@ -292,7 +274,6 @@ export const editToolRenderer = {
|
|
|
292
274
|
|
|
293
275
|
const diffStats = diffText ? getDiffStats(diffText) : { added: 0, removed: 0, hunks: 0, lines: 0 };
|
|
294
276
|
|
|
295
|
-
// Build header with diff stats
|
|
296
277
|
let description = filePath || "file";
|
|
297
278
|
if (rename) description += ` ${uiTheme.fg("dim", "→")} ${shortenPath(rename)}`;
|
|
298
279
|
const meta: string[] = [];
|
|
@@ -302,7 +283,6 @@ export const editToolRenderer = {
|
|
|
302
283
|
|
|
303
284
|
const header = renderStatusLine({ icon: "success", title: opTitle, description, meta }, uiTheme);
|
|
304
285
|
|
|
305
|
-
// Tree-style diff body
|
|
306
286
|
const expanded = options.expanded;
|
|
307
287
|
const diffLines = diffText ? diffText.split("\n") : [];
|
|
308
288
|
const maxLines = expanded ? diffLines.length : Math.min(diffLines.length, PREVIEW_LIMITS.DIFF_COLLAPSED_LINES);
|
|
@@ -318,7 +298,6 @@ export const editToolRenderer = {
|
|
|
318
298
|
treeBody.push(`${uiTheme.fg("dim", `… ${remaining} more lines`)} ${formatClickHint(uiTheme)}`);
|
|
319
299
|
}
|
|
320
300
|
|
|
321
|
-
// Diagnostics
|
|
322
301
|
if (result.details?.diagnostics) {
|
|
323
302
|
const diagText = formatDiagnostics(result.details.diagnostics, expanded, uiTheme, (fp: string) =>
|
|
324
303
|
uiTheme.getLangIcon(getLanguageFromPath(fp)),
|