@oh-my-pi/hashline 15.5.11 → 15.5.13
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/dist/types/format.d.ts +37 -23
- package/dist/types/input.d.ts +3 -3
- package/dist/types/messages.d.ts +14 -34
- package/dist/types/parser.d.ts +0 -53
- package/dist/types/recovery.d.ts +11 -13
- package/dist/types/snapshots.d.ts +36 -107
- package/dist/types/tokenizer.d.ts +10 -53
- package/dist/types/types.d.ts +7 -11
- package/package.json +3 -2
- package/src/apply.ts +334 -53
- package/src/format.ts +64 -28
- package/src/grammar.lark +10 -10
- package/src/input.ts +10 -13
- package/src/messages.ts +17 -36
- package/src/mismatch.ts +3 -4
- package/src/parser.ts +71 -329
- package/src/patcher.ts +21 -43
- package/src/prompt.md +43 -44
- package/src/recovery.ts +22 -72
- package/src/snapshots.ts +84 -266
- package/src/tokenizer.ts +102 -155
- package/src/types.ts +9 -13
package/src/apply.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Apply a parsed list of {@link Edit}s to a text body and return the
|
|
3
|
-
* post-edit lines. Pure function: no FS, no
|
|
3
|
+
* post-edit lines plus any diagnostic warnings. Pure function: no FS, no
|
|
4
|
+
* mutation of the input.
|
|
5
|
+
*
|
|
6
|
+
* Replacement groups are first normalized by {@link repairBoundaryBalance},
|
|
7
|
+
* which fixes the common model mistake of a payload that duplicates or drops
|
|
8
|
+
* the closing delimiter bordering the range (balance-validated; see below).
|
|
4
9
|
*/
|
|
5
10
|
import { cloneCursor } from "./tokenizer";
|
|
6
11
|
import type { Anchor, ApplyResult, Cursor, Edit } from "./types";
|
|
@@ -20,20 +25,12 @@ function isReplacementInsert(edit: Edit): edit is InsertEdit & { mode: "replacem
|
|
|
20
25
|
return edit.kind === "insert" && edit.mode === "replacement";
|
|
21
26
|
}
|
|
22
27
|
|
|
23
|
-
function rangeAnchors(start: Anchor, end: Anchor): Anchor[] {
|
|
24
|
-
const anchors: Anchor[] = [];
|
|
25
|
-
for (let line = start.line; line <= end.line; line++) anchors.push({ line });
|
|
26
|
-
return anchors;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
28
|
function getCursorAnchors(cursor: Cursor): Anchor[] {
|
|
30
|
-
return cursor.kind === "before_anchor" ? [cursor.anchor] : [];
|
|
29
|
+
return cursor.kind === "before_anchor" || cursor.kind === "after_anchor" ? [cursor.anchor] : [];
|
|
31
30
|
}
|
|
32
31
|
|
|
33
32
|
function getEditAnchors(edit: Edit): Anchor[] {
|
|
34
33
|
if (edit.kind === "delete") return [edit.anchor];
|
|
35
|
-
if (edit.kind === "repeat")
|
|
36
|
-
return [...getCursorAnchors(edit.cursor), ...rangeAnchors(edit.range.start, edit.range.end)];
|
|
37
34
|
return getCursorAnchors(edit.cursor);
|
|
38
35
|
}
|
|
39
36
|
|
|
@@ -51,44 +48,11 @@ function validateLineBounds(edits: AppliedEdit[], fileLines: string[]): void {
|
|
|
51
48
|
}
|
|
52
49
|
}
|
|
53
50
|
|
|
54
|
-
function assertLineExists(line: number, fileLines: string[]): void {
|
|
55
|
-
if (line < 1 || line > fileLines.length) {
|
|
56
|
-
throw new Error(`Line ${line} does not exist (file has ${fileLines.length} lines)`);
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
51
|
function cloneAppliedEdit(edit: AppliedEdit, index: number): AppliedEdit {
|
|
61
52
|
if (edit.kind === "delete") return { ...edit, anchor: { ...edit.anchor }, index };
|
|
62
53
|
return { ...edit, cursor: cloneCursor(edit.cursor), index };
|
|
63
54
|
}
|
|
64
55
|
|
|
65
|
-
function expandRepeatEdits(edits: Edit[], fileLines: string[]): AppliedEdit[] {
|
|
66
|
-
const expanded: AppliedEdit[] = [];
|
|
67
|
-
for (const edit of edits) {
|
|
68
|
-
if (edit.kind !== "repeat") {
|
|
69
|
-
expanded.push(cloneAppliedEdit(edit, expanded.length));
|
|
70
|
-
continue;
|
|
71
|
-
}
|
|
72
|
-
if (edit.range.end.line < edit.range.start.line) {
|
|
73
|
-
throw new Error(
|
|
74
|
-
`line ${edit.lineNum}: range ${edit.range.start.line}-${edit.range.end.line} ends before it starts.`,
|
|
75
|
-
);
|
|
76
|
-
}
|
|
77
|
-
for (let line = edit.range.start.line; line <= edit.range.end.line; line++) {
|
|
78
|
-
assertLineExists(line, fileLines);
|
|
79
|
-
expanded.push({
|
|
80
|
-
kind: "insert",
|
|
81
|
-
cursor: cloneCursor(edit.cursor),
|
|
82
|
-
text: fileLines[line - 1] ?? "",
|
|
83
|
-
lineNum: edit.lineNum,
|
|
84
|
-
index: expanded.length,
|
|
85
|
-
...(edit.mode === undefined ? {} : { mode: edit.mode }),
|
|
86
|
-
});
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
return expanded;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
56
|
function insertAtStart(fileLines: string[], lineOrigins: LineOrigin[], lines: string[]): void {
|
|
93
57
|
if (lines.length === 0) return;
|
|
94
58
|
const origins = lines.map((): LineOrigin => "insert");
|
|
@@ -122,7 +86,7 @@ function bucketAnchorEditsByLine(edits: IndexedEdit[]): Map<number, IndexedEdit[
|
|
|
122
86
|
const line =
|
|
123
87
|
entry.edit.kind === "delete"
|
|
124
88
|
? entry.edit.anchor.line
|
|
125
|
-
: entry.edit.cursor.kind === "before_anchor"
|
|
89
|
+
: entry.edit.cursor.kind === "before_anchor" || entry.edit.cursor.kind === "after_anchor"
|
|
126
90
|
? entry.edit.cursor.anchor.line
|
|
127
91
|
: 0;
|
|
128
92
|
const bucket = byLine.get(line);
|
|
@@ -132,6 +96,311 @@ function bucketAnchorEditsByLine(edits: IndexedEdit[]): Map<number, IndexedEdit[
|
|
|
132
96
|
return byLine;
|
|
133
97
|
}
|
|
134
98
|
|
|
99
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
100
|
+
// Boundary-balance repair
|
|
101
|
+
//
|
|
102
|
+
// Models routinely miscount a replacement range's edges. The payload either
|
|
103
|
+
// re-states a closing delimiter that still lives just outside the range
|
|
104
|
+
// (producing a DUPLICATE `}` / `);` / `]`) or the range deletes a closer the
|
|
105
|
+
// payload never restates (DROPPING it). Both are the same defect — a
|
|
106
|
+
// replacement whose payload does not preserve the deleted region's delimiter
|
|
107
|
+
// balance — and both leave the file syntactically broken.
|
|
108
|
+
//
|
|
109
|
+
// A repair fires only when (a) the group's payload balance differs from the
|
|
110
|
+
// deleted region's balance and (b) one boundary operation drives that
|
|
111
|
+
// difference to exactly zero while leaving the surrounding text byte-identical.
|
|
112
|
+
// The operation only ever drops an exact multi-line boundary echo or a single
|
|
113
|
+
// pure structural-closer line, or spares a deleted pure structural-closer line,
|
|
114
|
+
// so content lines are never moved or lost. Balance-preserving edits are left
|
|
115
|
+
// strictly alone.
|
|
116
|
+
|
|
117
|
+
/** A line that is nothing but closing delimiters: `}`, `)`, `];`, `})`, `},`. */
|
|
118
|
+
const STRUCTURAL_CLOSER_RE = /^\s*[)\]}]+[;,]?\s*$/;
|
|
119
|
+
|
|
120
|
+
interface DelimiterBalance {
|
|
121
|
+
paren: number;
|
|
122
|
+
bracket: number;
|
|
123
|
+
brace: number;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Net `()` / `[]` / `{}` delta across `lines`, skipping delimiters inside line
|
|
128
|
+
* comments (`//`), block comments, and string/template literals. Block-comment
|
|
129
|
+
* and backtick-template state carry across lines; `"` / `'` reset at EOL since
|
|
130
|
+
* they cannot span lines. Deliberately language-light: constructs it cannot
|
|
131
|
+
* classify (e.g. regex literals) are counted naively, which can only suppress a
|
|
132
|
+
* repair (the safe direction), never force one.
|
|
133
|
+
*/
|
|
134
|
+
function computeDelimiterBalance(lines: readonly string[]): DelimiterBalance {
|
|
135
|
+
const balance: DelimiterBalance = { paren: 0, bracket: 0, brace: 0 };
|
|
136
|
+
let inBlockComment = false;
|
|
137
|
+
let quote = "";
|
|
138
|
+
for (const line of lines) {
|
|
139
|
+
for (let i = 0; i < line.length; i++) {
|
|
140
|
+
const ch = line[i];
|
|
141
|
+
if (inBlockComment) {
|
|
142
|
+
if (ch === "*" && line[i + 1] === "/") {
|
|
143
|
+
inBlockComment = false;
|
|
144
|
+
i++;
|
|
145
|
+
}
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (quote) {
|
|
149
|
+
if (ch === "\\") i++;
|
|
150
|
+
else if (ch === quote) quote = "";
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (ch === '"' || ch === "'" || ch === "`") {
|
|
154
|
+
quote = ch;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (ch === "/" && line[i + 1] === "/") break;
|
|
158
|
+
if (ch === "/" && line[i + 1] === "*") {
|
|
159
|
+
inBlockComment = true;
|
|
160
|
+
i++;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
switch (ch) {
|
|
164
|
+
case "(":
|
|
165
|
+
balance.paren++;
|
|
166
|
+
break;
|
|
167
|
+
case ")":
|
|
168
|
+
balance.paren--;
|
|
169
|
+
break;
|
|
170
|
+
case "[":
|
|
171
|
+
balance.bracket++;
|
|
172
|
+
break;
|
|
173
|
+
case "]":
|
|
174
|
+
balance.bracket--;
|
|
175
|
+
break;
|
|
176
|
+
case "{":
|
|
177
|
+
balance.brace++;
|
|
178
|
+
break;
|
|
179
|
+
case "}":
|
|
180
|
+
balance.brace--;
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// `"` / `'` cannot span lines; only backtick templates and block comments do.
|
|
185
|
+
if (quote === '"' || quote === "'") quote = "";
|
|
186
|
+
}
|
|
187
|
+
return balance;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function balanceDelta(a: DelimiterBalance, b: DelimiterBalance): DelimiterBalance {
|
|
191
|
+
return { paren: a.paren - b.paren, bracket: a.bracket - b.bracket, brace: a.brace - b.brace };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function balanceNegate(a: DelimiterBalance): DelimiterBalance {
|
|
195
|
+
return { paren: -a.paren, bracket: -a.bracket, brace: -a.brace };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function balanceEqual(a: DelimiterBalance, b: DelimiterBalance): boolean {
|
|
199
|
+
return a.paren === b.paren && a.bracket === b.bracket && a.brace === b.brace;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function balanceIsZero(a: DelimiterBalance): boolean {
|
|
203
|
+
return a.paren === 0 && a.bracket === 0 && a.brace === 0;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
interface ReplacementGroup {
|
|
207
|
+
/** Positions in the edit array of the payload inserts, in payload order. */
|
|
208
|
+
insertIndices: number[];
|
|
209
|
+
/** Positions in the edit array of the range deletes, ascending by line. */
|
|
210
|
+
deleteIndices: number[];
|
|
211
|
+
payload: string[];
|
|
212
|
+
/** First deleted line (1-indexed). */
|
|
213
|
+
startLine: number;
|
|
214
|
+
/** Last deleted line (1-indexed). */
|
|
215
|
+
endLine: number;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Detect a replacement group starting at `start`: a run of `before_anchor`
|
|
220
|
+
* replacement inserts sharing one source op line, immediately followed by the
|
|
221
|
+
* contiguous range deletes for that same op. Mirrors how the parser lowers an
|
|
222
|
+
* `replace N..M:` hunk with a body.
|
|
223
|
+
*/
|
|
224
|
+
function findReplacementGroup(edits: readonly AppliedEdit[], start: number): ReplacementGroup | undefined {
|
|
225
|
+
const first = edits[start];
|
|
226
|
+
if (first?.kind !== "insert" || first.mode !== "replacement" || first.cursor.kind !== "before_anchor") {
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|
|
229
|
+
const { lineNum } = first;
|
|
230
|
+
const anchorLine = first.cursor.anchor.line;
|
|
231
|
+
const insertIndices: number[] = [];
|
|
232
|
+
const payload: string[] = [];
|
|
233
|
+
let i = start;
|
|
234
|
+
for (; i < edits.length; i++) {
|
|
235
|
+
const edit = edits[i];
|
|
236
|
+
if (edit.kind !== "insert" || edit.mode !== "replacement" || edit.lineNum !== lineNum) break;
|
|
237
|
+
if (edit.cursor.kind !== "before_anchor" || edit.cursor.anchor.line !== anchorLine) break;
|
|
238
|
+
insertIndices.push(i);
|
|
239
|
+
payload.push(edit.text);
|
|
240
|
+
}
|
|
241
|
+
const deleteIndices: number[] = [];
|
|
242
|
+
let expectedLine = anchorLine;
|
|
243
|
+
for (; i < edits.length; i++) {
|
|
244
|
+
const edit = edits[i];
|
|
245
|
+
if (edit.kind !== "delete" || edit.lineNum !== lineNum || edit.anchor.line !== expectedLine) break;
|
|
246
|
+
deleteIndices.push(i);
|
|
247
|
+
expectedLine++;
|
|
248
|
+
}
|
|
249
|
+
if (deleteIndices.length === 0) return undefined;
|
|
250
|
+
return {
|
|
251
|
+
insertIndices,
|
|
252
|
+
deleteIndices,
|
|
253
|
+
payload,
|
|
254
|
+
startLine: anchorLine,
|
|
255
|
+
endLine: anchorLine + deleteIndices.length - 1,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Largest `k` such that the payload's last `k` lines exactly equal the `k`
|
|
261
|
+
* surviving file lines just below the range AND dropping them zeroes `delta`.
|
|
262
|
+
* Single-line drops are limited to pure structural closers.
|
|
263
|
+
*/
|
|
264
|
+
function findDuplicateSuffix(group: ReplacementGroup, fileLines: readonly string[], delta: DelimiterBalance): number {
|
|
265
|
+
const { payload, endLine } = group;
|
|
266
|
+
const maxK = Math.min(payload.length, fileLines.length - endLine);
|
|
267
|
+
for (let k = maxK; k >= 1; k--) {
|
|
268
|
+
let matches = true;
|
|
269
|
+
for (let t = 0; t < k; t++) {
|
|
270
|
+
if (payload[payload.length - k + t] !== fileLines[endLine + t]) {
|
|
271
|
+
matches = false;
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (!matches) continue;
|
|
276
|
+
if (k === 1 && !STRUCTURAL_CLOSER_RE.test(payload[payload.length - 1])) continue;
|
|
277
|
+
if (balanceEqual(computeDelimiterBalance(payload.slice(payload.length - k)), delta)) return k;
|
|
278
|
+
}
|
|
279
|
+
return 0;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Largest `j` such that the payload's first `j` lines exactly equal the `j`
|
|
284
|
+
* surviving file lines just above the range AND dropping them zeroes `delta`.
|
|
285
|
+
*/
|
|
286
|
+
function findDuplicatePrefix(group: ReplacementGroup, fileLines: readonly string[], delta: DelimiterBalance): number {
|
|
287
|
+
const { payload, startLine } = group;
|
|
288
|
+
const maxJ = Math.min(payload.length, startLine - 1);
|
|
289
|
+
for (let j = maxJ; j >= 1; j--) {
|
|
290
|
+
let matches = true;
|
|
291
|
+
for (let t = 0; t < j; t++) {
|
|
292
|
+
if (payload[t] !== fileLines[startLine - 1 - j + t]) {
|
|
293
|
+
matches = false;
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (!matches) continue;
|
|
298
|
+
if (j === 1 && !STRUCTURAL_CLOSER_RE.test(payload[0])) continue;
|
|
299
|
+
if (balanceEqual(computeDelimiterBalance(payload.slice(0, j)), delta)) return j;
|
|
300
|
+
}
|
|
301
|
+
return 0;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Smallest `m` such that the range's last `m` deleted lines are all pure
|
|
306
|
+
* structural closers and sparing them (keeping instead of deleting) zeroes
|
|
307
|
+
* `delta`. The mirror mistake: a range that swallows a closing delimiter the
|
|
308
|
+
* payload never restates.
|
|
309
|
+
*/
|
|
310
|
+
function findDroppedSuffixClosers(
|
|
311
|
+
group: ReplacementGroup,
|
|
312
|
+
fileLines: readonly string[],
|
|
313
|
+
delta: DelimiterBalance,
|
|
314
|
+
): number {
|
|
315
|
+
const wanted = balanceNegate(delta);
|
|
316
|
+
const maxM = group.deleteIndices.length;
|
|
317
|
+
for (let m = 1; m <= maxM; m++) {
|
|
318
|
+
if (!STRUCTURAL_CLOSER_RE.test(fileLines[group.endLine - m] ?? "")) break;
|
|
319
|
+
if (balanceEqual(computeDelimiterBalance(fileLines.slice(group.endLine - m, group.endLine)), wanted)) return m;
|
|
320
|
+
}
|
|
321
|
+
return 0;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function describeBoundaryRepair(group: ReplacementGroup, action: string): string {
|
|
325
|
+
return (
|
|
326
|
+
`Auto-repaired a delimiter-balance mismatch in the replacement at line ${group.startLine}: ${action}. ` +
|
|
327
|
+
`Issue the payload as the final desired content only — never restate or omit a closing bracket bordering the range.`
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Normalize each replacement group so its payload preserves the deleted
|
|
333
|
+
* region's delimiter balance. See the section header for the contract. Returns
|
|
334
|
+
* the (possibly trimmed) edit list plus one warning per repaired group.
|
|
335
|
+
*/
|
|
336
|
+
function repairBoundaryBalance(
|
|
337
|
+
edits: readonly AppliedEdit[],
|
|
338
|
+
fileLines: readonly string[],
|
|
339
|
+
): {
|
|
340
|
+
edits: AppliedEdit[];
|
|
341
|
+
warnings: string[];
|
|
342
|
+
} {
|
|
343
|
+
const out: AppliedEdit[] = [];
|
|
344
|
+
const warnings: string[] = [];
|
|
345
|
+
let i = 0;
|
|
346
|
+
while (i < edits.length) {
|
|
347
|
+
const group = findReplacementGroup(edits, i);
|
|
348
|
+
if (!group) {
|
|
349
|
+
out.push(edits[i]);
|
|
350
|
+
i++;
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
const inserts = group.insertIndices.map(idx => edits[idx]);
|
|
354
|
+
const deletes = group.deleteIndices.map(idx => edits[idx]);
|
|
355
|
+
i = group.deleteIndices[group.deleteIndices.length - 1] + 1;
|
|
356
|
+
|
|
357
|
+
const delta = balanceDelta(
|
|
358
|
+
computeDelimiterBalance(group.payload),
|
|
359
|
+
computeDelimiterBalance(fileLines.slice(group.startLine - 1, group.endLine)),
|
|
360
|
+
);
|
|
361
|
+
if (balanceIsZero(delta)) {
|
|
362
|
+
out.push(...inserts, ...deletes);
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const dupSuffix = findDuplicateSuffix(group, fileLines, delta);
|
|
367
|
+
if (dupSuffix > 0) {
|
|
368
|
+
warnings.push(
|
|
369
|
+
describeBoundaryRepair(
|
|
370
|
+
group,
|
|
371
|
+
`dropped ${dupSuffix} duplicated trailing payload line(s) already present below the range`,
|
|
372
|
+
),
|
|
373
|
+
);
|
|
374
|
+
out.push(...inserts.slice(0, inserts.length - dupSuffix), ...deletes);
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
const dupPrefix = findDuplicatePrefix(group, fileLines, delta);
|
|
378
|
+
if (dupPrefix > 0) {
|
|
379
|
+
warnings.push(
|
|
380
|
+
describeBoundaryRepair(
|
|
381
|
+
group,
|
|
382
|
+
`dropped ${dupPrefix} duplicated leading payload line(s) already present above the range`,
|
|
383
|
+
),
|
|
384
|
+
);
|
|
385
|
+
out.push(...inserts.slice(dupPrefix), ...deletes);
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
const droppedClosers = findDroppedSuffixClosers(group, fileLines, delta);
|
|
389
|
+
if (droppedClosers > 0) {
|
|
390
|
+
warnings.push(
|
|
391
|
+
describeBoundaryRepair(
|
|
392
|
+
group,
|
|
393
|
+
`kept ${droppedClosers} structural closing line(s) the range deleted without restating`,
|
|
394
|
+
),
|
|
395
|
+
);
|
|
396
|
+
out.push(...inserts, ...deletes.slice(0, deletes.length - droppedClosers));
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
out.push(...inserts, ...deletes);
|
|
400
|
+
}
|
|
401
|
+
return { edits: out, warnings };
|
|
402
|
+
}
|
|
403
|
+
|
|
135
404
|
/**
|
|
136
405
|
* Apply a parsed list of edits to a text body. Pure function — no I/O.
|
|
137
406
|
*
|
|
@@ -149,14 +418,15 @@ export function applyEdits(text: string, edits: Edit[]): ApplyResult {
|
|
|
149
418
|
if (firstChangedLine === undefined || line < firstChangedLine) firstChangedLine = line;
|
|
150
419
|
};
|
|
151
420
|
|
|
152
|
-
const targetEdits =
|
|
421
|
+
const targetEdits = edits.map((edit, index) => cloneAppliedEdit(edit, index));
|
|
153
422
|
validateLineBounds(targetEdits, fileLines);
|
|
423
|
+
const { edits: repaired, warnings } = repairBoundaryBalance(targetEdits, fileLines);
|
|
154
424
|
|
|
155
|
-
// Partition edits into
|
|
425
|
+
// Partition edits into bof, eof, and anchor-targeted buckets.
|
|
156
426
|
const bofLines: string[] = [];
|
|
157
427
|
const eofLines: string[] = [];
|
|
158
428
|
const anchorEdits: IndexedEdit[] = [];
|
|
159
|
-
|
|
429
|
+
repaired.forEach((edit, idx) => {
|
|
160
430
|
if (edit.kind === "insert" && edit.cursor.kind === "bof") {
|
|
161
431
|
bofLines.push(edit.text);
|
|
162
432
|
} else if (edit.kind === "insert" && edit.cursor.kind === "eof") {
|
|
@@ -175,28 +445,38 @@ export function applyEdits(text: string, edits: Edit[]): ApplyResult {
|
|
|
175
445
|
|
|
176
446
|
const idx = line - 1;
|
|
177
447
|
const currentLine = fileLines[idx] ?? "";
|
|
178
|
-
const
|
|
448
|
+
const beforeInsertLines: string[] = [];
|
|
449
|
+
const afterInsertLines: string[] = [];
|
|
179
450
|
const replacementLines: string[] = [];
|
|
180
451
|
let deleteLine = false;
|
|
181
452
|
|
|
182
453
|
for (const { edit } of bucket) {
|
|
183
454
|
if (isReplacementInsert(edit)) {
|
|
184
455
|
replacementLines.push(edit.text);
|
|
456
|
+
} else if (edit.kind === "insert" && edit.cursor.kind === "after_anchor") {
|
|
457
|
+
afterInsertLines.push(edit.text);
|
|
185
458
|
} else if (edit.kind === "insert") {
|
|
186
|
-
|
|
459
|
+
beforeInsertLines.push(edit.text);
|
|
187
460
|
} else if (edit.kind === "delete") {
|
|
188
461
|
deleteLine = true;
|
|
189
462
|
}
|
|
190
463
|
}
|
|
191
|
-
if (
|
|
464
|
+
if (
|
|
465
|
+
beforeInsertLines.length === 0 &&
|
|
466
|
+
replacementLines.length === 0 &&
|
|
467
|
+
afterInsertLines.length === 0 &&
|
|
468
|
+
!deleteLine
|
|
469
|
+
)
|
|
470
|
+
continue;
|
|
192
471
|
|
|
193
472
|
const replacement = deleteLine
|
|
194
|
-
? [...
|
|
195
|
-
: [...
|
|
473
|
+
? [...beforeInsertLines, ...replacementLines, ...afterInsertLines]
|
|
474
|
+
: [...beforeInsertLines, ...replacementLines, currentLine, ...afterInsertLines];
|
|
196
475
|
const origins: LineOrigin[] = [];
|
|
197
|
-
for (let i = 0; i <
|
|
476
|
+
for (let i = 0; i < beforeInsertLines.length; i++) origins.push("insert");
|
|
198
477
|
for (let i = 0; i < replacementLines.length; i++) origins.push(deleteLine ? "replacement" : "insert");
|
|
199
478
|
if (!deleteLine) origins.push(lineOrigins[idx] ?? "original");
|
|
479
|
+
for (let i = 0; i < afterInsertLines.length; i++) origins.push("insert");
|
|
200
480
|
|
|
201
481
|
fileLines.splice(idx, 1, ...replacement);
|
|
202
482
|
lineOrigins.splice(idx, 1, ...origins);
|
|
@@ -213,5 +493,6 @@ export function applyEdits(text: string, edits: Edit[]): ApplyResult {
|
|
|
213
493
|
return {
|
|
214
494
|
text: fileLines.join("\n"),
|
|
215
495
|
firstChangedLine,
|
|
496
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
216
497
|
};
|
|
217
498
|
}
|
package/src/format.ts
CHANGED
|
@@ -4,16 +4,30 @@
|
|
|
4
4
|
* tokenizer, the prompt, and the formal grammar.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import type { Cursor } from "./types";
|
|
8
|
+
|
|
7
9
|
/** File-section header prefix: `¶path#hash`. */
|
|
8
10
|
export const HL_FILE_PREFIX = "¶";
|
|
9
11
|
|
|
10
12
|
/** Payload sigil for literal body rows. */
|
|
11
13
|
export const HL_PAYLOAD_REPLACE = "+";
|
|
12
|
-
/** Payload sigil for body rows that repeat original file lines. */
|
|
13
|
-
export const HL_PAYLOAD_REPEAT = "&";
|
|
14
14
|
|
|
15
|
-
/**
|
|
16
|
-
export const
|
|
15
|
+
/** Hunk-header keyword for concrete line replacement. */
|
|
16
|
+
export const HL_REPLACE_KEYWORD = "replace";
|
|
17
|
+
/** Hunk-header keyword for concrete line deletion. */
|
|
18
|
+
export const HL_DELETE_KEYWORD = "delete";
|
|
19
|
+
/** Hunk-header keyword for insertion operations. */
|
|
20
|
+
export const HL_INSERT_KEYWORD = "insert";
|
|
21
|
+
/** Insert position keyword for inserting before a concrete line. */
|
|
22
|
+
export const HL_INSERT_BEFORE = "before";
|
|
23
|
+
/** Insert position keyword for inserting after a concrete line. */
|
|
24
|
+
export const HL_INSERT_AFTER = "after";
|
|
25
|
+
/** Insert position keyword for inserting at the start of the file. */
|
|
26
|
+
export const HL_INSERT_HEAD = "head";
|
|
27
|
+
/** Insert position keyword for inserting at the end of the file. */
|
|
28
|
+
export const HL_INSERT_TAIL = "tail";
|
|
29
|
+
/** Hunk-header terminator for body-bearing operations. */
|
|
30
|
+
export const HL_HEADER_COLON = ":";
|
|
17
31
|
|
|
18
32
|
/** Separator between a hashline file path and its opaque snapshot tag. */
|
|
19
33
|
export const HL_FILE_HASH_SEP = "#";
|
|
@@ -28,46 +42,68 @@ function regexEscape(str: string): string {
|
|
|
28
42
|
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
29
43
|
}
|
|
30
44
|
|
|
31
|
-
/**
|
|
32
|
-
* Decoration prefix that may precede a line number in tool output:
|
|
33
|
-
* `*` (match line), `>` (context line in grep). Any combination, in any
|
|
34
|
-
* order, surrounded by optional whitespace. Output formatters emit at most
|
|
35
|
-
* one decoration per line; the parser stays liberal because it accepts
|
|
36
|
-
* whatever the model echoes back.
|
|
37
|
-
*/
|
|
38
|
-
export const HL_ANCHOR_DECORATION_RE_RAW = `\\s*[>*]*\\s*`;
|
|
39
|
-
|
|
40
|
-
/** Capture-group regex source for a decorated bare line-number anchor. */
|
|
41
|
-
export const HL_ANCHOR_RE_RAW = `${HL_ANCHOR_DECORATION_RE_RAW}(\\d+)`;
|
|
42
|
-
|
|
43
45
|
/** Bare positive line-number Lid (no decorations, no captures, no anchors). */
|
|
44
46
|
export const HL_LINE_RE_RAW = `[1-9]\\d*`;
|
|
45
47
|
|
|
46
48
|
/** Capture-group form of {@link HL_LINE_RE_RAW}. */
|
|
47
49
|
export const HL_LINE_CAPTURE_RE_RAW = `(${HL_LINE_RE_RAW})`;
|
|
48
50
|
|
|
49
|
-
/**
|
|
50
|
-
export
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
/** Format a concrete replacement hunk header. */
|
|
52
|
+
export function formatReplaceHeader(start: number, end: number): string {
|
|
53
|
+
return `${HL_REPLACE_KEYWORD} ${start}${HL_RANGE_SEP}${end}${HL_HEADER_COLON}`;
|
|
54
|
+
}
|
|
53
55
|
|
|
54
|
-
/**
|
|
55
|
-
export
|
|
56
|
+
/** Format a concrete deletion hunk header. */
|
|
57
|
+
export function formatDeleteHeader(start: number, end = start): string {
|
|
58
|
+
return start === end ? `${HL_DELETE_KEYWORD} ${start}` : `${HL_DELETE_KEYWORD} ${start}${HL_RANGE_SEP}${end}`;
|
|
59
|
+
}
|
|
56
60
|
|
|
57
|
-
/**
|
|
58
|
-
export
|
|
61
|
+
/** Format an insertion hunk header for a cursor position. */
|
|
62
|
+
export function formatInsertHeader(cursor: Cursor): string {
|
|
63
|
+
switch (cursor.kind) {
|
|
64
|
+
case "before_anchor":
|
|
65
|
+
return `${HL_INSERT_KEYWORD} ${HL_INSERT_BEFORE} ${cursor.anchor.line}${HL_HEADER_COLON}`;
|
|
66
|
+
case "after_anchor":
|
|
67
|
+
return `${HL_INSERT_KEYWORD} ${HL_INSERT_AFTER} ${cursor.anchor.line}${HL_HEADER_COLON}`;
|
|
68
|
+
case "bof":
|
|
69
|
+
return `${HL_INSERT_KEYWORD} ${HL_INSERT_HEAD}${HL_HEADER_COLON}`;
|
|
70
|
+
case "eof":
|
|
71
|
+
return `${HL_INSERT_KEYWORD} ${HL_INSERT_TAIL}${HL_HEADER_COLON}`;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
59
74
|
|
|
75
|
+
/** Number of hex characters in a content-derived file-hash tag. */
|
|
76
|
+
export const HL_FILE_HASH_LENGTH = 4;
|
|
77
|
+
/** Canonical uppercase hexadecimal content-hash tag carried by a hashline section header. */
|
|
78
|
+
export const HL_FILE_HASH_RE_RAW = `[0-9A-F]{${HL_FILE_HASH_LENGTH}}`;
|
|
60
79
|
/** Capture-group form of {@link HL_FILE_HASH_RE_RAW}. */
|
|
61
80
|
export const HL_FILE_HASH_CAPTURE_RE_RAW = `(${HL_FILE_HASH_RE_RAW})`;
|
|
62
|
-
|
|
63
81
|
/** Regex-escaped form of {@link HL_LINE_BODY_SEP}, safe for embedding inside a regex. */
|
|
64
82
|
export const HL_LINE_BODY_SEP_RE_RAW = regexEscape(HL_LINE_BODY_SEP);
|
|
65
|
-
|
|
66
83
|
/**
|
|
67
|
-
* Representative
|
|
84
|
+
* Representative file-hash tags for use in user-facing error messages and
|
|
68
85
|
* prompt examples.
|
|
69
86
|
*/
|
|
70
|
-
export const HL_FILE_HASH_EXAMPLES = ["
|
|
87
|
+
export const HL_FILE_HASH_EXAMPLES = ["1A2B", "3C4D", "9F3E"] as const;
|
|
88
|
+
/**
|
|
89
|
+
* Normalize text before hashing: trim trailing `[ \t\r]` from every line (and
|
|
90
|
+
* the final line) in a single pass so CRLF endings and display-trimmed lines
|
|
91
|
+
* do not invalidate a tag.
|
|
92
|
+
*/
|
|
93
|
+
function normalizeFileHashText(text: string): string {
|
|
94
|
+
return text.replace(/[ \t\r]+(?=\n|$)/g, "");
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Compute the content-derived hash tag carried by a hashline section header.
|
|
98
|
+
* The tag is a 4-hex fingerprint of the whole file's normalized text: any read
|
|
99
|
+
* of byte-identical content mints the same tag, and a follow-up edit anchored
|
|
100
|
+
* at any line validates whenever the live file still hashes to it.
|
|
101
|
+
*/
|
|
102
|
+
export function computeFileHash(text: string): string {
|
|
103
|
+
const normalized = normalizeFileHashText(text);
|
|
104
|
+
const low16 = Bun.hash.xxHash32(normalized, 0) & 0xffff;
|
|
105
|
+
return low16.toString(16).padStart(HL_FILE_HASH_LENGTH, "0").toUpperCase();
|
|
106
|
+
}
|
|
71
107
|
|
|
72
108
|
/**
|
|
73
109
|
* Format a comma-separated list of example anchors with an optional line-number
|
package/src/grammar.lark
CHANGED
|
@@ -4,19 +4,19 @@ end_patch: "*** End Patch" LF?
|
|
|
4
4
|
|
|
5
5
|
file_patch: file_header hunk+
|
|
6
6
|
file_header: "¶" filename ("#" file_hash)? LF
|
|
7
|
-
file_hash: /[0-9A-F]{
|
|
7
|
+
file_hash: /[0-9A-F]{4}/
|
|
8
8
|
filename: /[^\s#]+/
|
|
9
9
|
|
|
10
|
-
hunk:
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
10
|
+
hunk: body_hunk | delete_hunk
|
|
11
|
+
body_hunk: body_header emit_op+
|
|
12
|
+
delete_hunk: "delete " header_range LF
|
|
13
|
+
body_header: (replace_anchor | insert_anchor) LF
|
|
14
|
+
replace_anchor: "replace " header_range ":"
|
|
15
|
+
insert_anchor: "insert " insert_pos ":"
|
|
16
|
+
insert_pos: "before " LID | "after " LID | "head" | "tail"
|
|
17
|
+
emit_op: "+" /(.*)/ LF
|
|
15
18
|
|
|
16
|
-
|
|
17
|
-
header_range: LID WS LID
|
|
18
|
-
body_range: LID (".." LID)?
|
|
19
|
+
header_range: LID ".." LID
|
|
19
20
|
LID: /[1-9]\d*/
|
|
20
|
-
WS: /[ \t]+/
|
|
21
21
|
|
|
22
22
|
%import common.LF
|