@prometheus-ai/hashline 0.5.0
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/README.md +79 -0
- package/dist/types/apply.d.ts +8 -0
- package/dist/types/block.d.ts +24 -0
- package/dist/types/diff-preview.d.ts +12 -0
- package/dist/types/format.d.ts +76 -0
- package/dist/types/fs.d.ts +80 -0
- package/dist/types/index.d.ts +17 -0
- package/dist/types/input.d.ts +100 -0
- package/dist/types/messages.d.ts +85 -0
- package/dist/types/mismatch.d.ts +44 -0
- package/dist/types/normalize.d.ts +20 -0
- package/dist/types/parser.d.ts +23 -0
- package/dist/types/patcher.d.ts +109 -0
- package/dist/types/prefixes.d.ts +34 -0
- package/dist/types/recovery.d.ts +40 -0
- package/dist/types/snapshots.d.ts +55 -0
- package/dist/types/stream.d.ts +2 -0
- package/dist/types/tokenizer.d.ts +65 -0
- package/dist/types/types.d.ts +129 -0
- package/package.json +62 -0
- package/src/apply.ts +586 -0
- package/src/block.ts +84 -0
- package/src/diff-preview.ts +49 -0
- package/src/format.ts +134 -0
- package/src/fs.ts +167 -0
- package/src/grammar.lark +25 -0
- package/src/index.ts +17 -0
- package/src/input.ts +423 -0
- package/src/messages.ts +128 -0
- package/src/mismatch.ts +138 -0
- package/src/normalize.ts +38 -0
- package/src/parser.ts +325 -0
- package/src/patcher.ts +392 -0
- package/src/prefixes.ts +132 -0
- package/src/prompt.md +109 -0
- package/src/recovery.ts +186 -0
- package/src/snapshots.ts +128 -0
- package/src/stream.ts +132 -0
- package/src/tokenizer.ts +471 -0
- package/src/types.ts +132 -0
package/src/apply.ts
ADDED
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apply a parsed list of {@link Edit}s to a text body and return the
|
|
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 repairReplacementBoundaries},
|
|
7
|
+
* which absorbs common model mistakes where a payload restates unchanged range
|
|
8
|
+
* boundaries or duplicates/drops structural closers.
|
|
9
|
+
*/
|
|
10
|
+
import { UNRESOLVED_BLOCK_INTERNAL } from "./messages";
|
|
11
|
+
import { cloneCursor } from "./tokenizer";
|
|
12
|
+
import type { Anchor, ApplyResult, Cursor, Edit } from "./types";
|
|
13
|
+
|
|
14
|
+
type LineOrigin = "original" | "insert" | "replacement";
|
|
15
|
+
|
|
16
|
+
type InsertEdit = Extract<Edit, { kind: "insert" }>;
|
|
17
|
+
type DeleteEdit = Extract<Edit, { kind: "delete" }>;
|
|
18
|
+
type AppliedEdit = InsertEdit | DeleteEdit;
|
|
19
|
+
|
|
20
|
+
interface IndexedEdit {
|
|
21
|
+
edit: AppliedEdit;
|
|
22
|
+
idx: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isReplacementInsert(edit: Edit): edit is InsertEdit & { mode: "replacement" } {
|
|
26
|
+
return edit.kind === "insert" && edit.mode === "replacement";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getCursorAnchors(cursor: Cursor): Anchor[] {
|
|
30
|
+
return cursor.kind === "before_anchor" || cursor.kind === "after_anchor" ? [cursor.anchor] : [];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getEditAnchors(edit: AppliedEdit): Anchor[] {
|
|
34
|
+
if (edit.kind === "delete") return [edit.anchor];
|
|
35
|
+
return getCursorAnchors(edit.cursor);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Verify every anchored edit points at an existing line. File-version binding is
|
|
40
|
+
* checked once per section via the header hash before this function runs.
|
|
41
|
+
*/
|
|
42
|
+
function validateLineBounds(edits: AppliedEdit[], fileLines: string[]): void {
|
|
43
|
+
for (const edit of edits) {
|
|
44
|
+
for (const anchor of getEditAnchors(edit)) {
|
|
45
|
+
if (anchor.line < 1 || anchor.line > fileLines.length) {
|
|
46
|
+
throw new Error(`Line ${anchor.line} does not exist (file has ${fileLines.length} lines)`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function cloneAppliedEdit(edit: AppliedEdit, index: number): AppliedEdit {
|
|
53
|
+
if (edit.kind === "delete") return { ...edit, anchor: { ...edit.anchor }, index };
|
|
54
|
+
return { ...edit, cursor: cloneCursor(edit.cursor), index };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function insertAtStart(fileLines: string[], lineOrigins: LineOrigin[], lines: string[]): void {
|
|
58
|
+
if (lines.length === 0) return;
|
|
59
|
+
const origins = lines.map((): LineOrigin => "insert");
|
|
60
|
+
if (fileLines.length === 1 && fileLines[0] === "") {
|
|
61
|
+
fileLines.splice(0, 1, ...lines);
|
|
62
|
+
lineOrigins.splice(0, 1, ...origins);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
fileLines.splice(0, 0, ...lines);
|
|
66
|
+
lineOrigins.splice(0, 0, ...origins);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function insertAtEnd(fileLines: string[], lineOrigins: LineOrigin[], lines: string[]): number | undefined {
|
|
70
|
+
if (lines.length === 0) return undefined;
|
|
71
|
+
const origins = lines.map((): LineOrigin => "insert");
|
|
72
|
+
if (fileLines.length === 1 && fileLines[0] === "") {
|
|
73
|
+
fileLines.splice(0, 1, ...lines);
|
|
74
|
+
lineOrigins.splice(0, 1, ...origins);
|
|
75
|
+
return 1;
|
|
76
|
+
}
|
|
77
|
+
const hasTrailingNewline = fileLines.length > 0 && fileLines[fileLines.length - 1] === "";
|
|
78
|
+
const insertIndex = hasTrailingNewline ? fileLines.length - 1 : fileLines.length;
|
|
79
|
+
fileLines.splice(insertIndex, 0, ...lines);
|
|
80
|
+
lineOrigins.splice(insertIndex, 0, ...origins);
|
|
81
|
+
return insertIndex + 1;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function bucketAnchorEditsByLine(edits: IndexedEdit[]): Map<number, IndexedEdit[]> {
|
|
85
|
+
const byLine = new Map<number, IndexedEdit[]>();
|
|
86
|
+
for (const entry of edits) {
|
|
87
|
+
const line =
|
|
88
|
+
entry.edit.kind === "delete"
|
|
89
|
+
? entry.edit.anchor.line
|
|
90
|
+
: entry.edit.cursor.kind === "before_anchor" || entry.edit.cursor.kind === "after_anchor"
|
|
91
|
+
? entry.edit.cursor.anchor.line
|
|
92
|
+
: 0;
|
|
93
|
+
const bucket = byLine.get(line);
|
|
94
|
+
if (bucket) bucket.push(entry);
|
|
95
|
+
else byLine.set(line, [entry]);
|
|
96
|
+
}
|
|
97
|
+
return byLine;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
101
|
+
// Replacement-boundary repair
|
|
102
|
+
//
|
|
103
|
+
// Models routinely miscount a replacement range's edges. Sometimes the payload
|
|
104
|
+
// re-states unchanged lines that still live on both sides of the range
|
|
105
|
+
// (duplicating a function header and final statement); sometimes it only
|
|
106
|
+
// re-states or omits a structural closer, which leaves delimiter balance broken.
|
|
107
|
+
//
|
|
108
|
+
// A balance-neutral boundary-echo repair fires only when both the leading and
|
|
109
|
+
// trailing payload edges are exact copies of the surviving lines outside the
|
|
110
|
+
// range. One-sided content echoes are left alone unless delimiter-balance repair
|
|
111
|
+
// proves they are duplicated structural boundaries. This preserves intended
|
|
112
|
+
// duplicate statements while absorbing the common "body includes the unchanged
|
|
113
|
+
// wrapper" mistake.
|
|
114
|
+
|
|
115
|
+
/** A line that is nothing but closing delimiters: `}`, `)`, `];`, `})`, `},`. */
|
|
116
|
+
const STRUCTURAL_CLOSER_RE = /^\s*[)\]}]+[;,]?\s*$/;
|
|
117
|
+
|
|
118
|
+
interface DelimiterBalance {
|
|
119
|
+
paren: number;
|
|
120
|
+
bracket: number;
|
|
121
|
+
brace: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Net `()` / `[]` / `{}` delta across `lines`, skipping delimiters inside line
|
|
126
|
+
* comments (`//`), block comments, and string/template literals. Block-comment
|
|
127
|
+
* and backtick-template state carry across lines; `"` / `'` reset at EOL since
|
|
128
|
+
* they cannot span lines. Deliberately language-light: constructs it cannot
|
|
129
|
+
* classify (e.g. regex literals) are counted naively, which can only suppress a
|
|
130
|
+
* repair (the safe direction), never force one.
|
|
131
|
+
*/
|
|
132
|
+
function computeDelimiterBalance(lines: readonly string[]): DelimiterBalance {
|
|
133
|
+
const balance: DelimiterBalance = { paren: 0, bracket: 0, brace: 0 };
|
|
134
|
+
let inBlockComment = false;
|
|
135
|
+
let quote = "";
|
|
136
|
+
for (const line of lines) {
|
|
137
|
+
for (let i = 0; i < line.length; i++) {
|
|
138
|
+
const ch = line[i];
|
|
139
|
+
if (inBlockComment) {
|
|
140
|
+
if (ch === "*" && line[i + 1] === "/") {
|
|
141
|
+
inBlockComment = false;
|
|
142
|
+
i++;
|
|
143
|
+
}
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (quote) {
|
|
147
|
+
if (ch === "\\") i++;
|
|
148
|
+
else if (ch === quote) quote = "";
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (ch === '"' || ch === "'" || ch === "`") {
|
|
152
|
+
quote = ch;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (ch === "/" && line[i + 1] === "/") break;
|
|
156
|
+
if (ch === "/" && line[i + 1] === "*") {
|
|
157
|
+
inBlockComment = true;
|
|
158
|
+
i++;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
switch (ch) {
|
|
162
|
+
case "(":
|
|
163
|
+
balance.paren++;
|
|
164
|
+
break;
|
|
165
|
+
case ")":
|
|
166
|
+
balance.paren--;
|
|
167
|
+
break;
|
|
168
|
+
case "[":
|
|
169
|
+
balance.bracket++;
|
|
170
|
+
break;
|
|
171
|
+
case "]":
|
|
172
|
+
balance.bracket--;
|
|
173
|
+
break;
|
|
174
|
+
case "{":
|
|
175
|
+
balance.brace++;
|
|
176
|
+
break;
|
|
177
|
+
case "}":
|
|
178
|
+
balance.brace--;
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// `"` / `'` cannot span lines; only backtick templates and block comments do.
|
|
183
|
+
if (quote === '"' || quote === "'") quote = "";
|
|
184
|
+
}
|
|
185
|
+
return balance;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function balanceDelta(a: DelimiterBalance, b: DelimiterBalance): DelimiterBalance {
|
|
189
|
+
return { paren: a.paren - b.paren, bracket: a.bracket - b.bracket, brace: a.brace - b.brace };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function balanceNegate(a: DelimiterBalance): DelimiterBalance {
|
|
193
|
+
return { paren: -a.paren, bracket: -a.bracket, brace: -a.brace };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function balanceEqual(a: DelimiterBalance, b: DelimiterBalance): boolean {
|
|
197
|
+
return a.paren === b.paren && a.bracket === b.bracket && a.brace === b.brace;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function balanceIsZero(a: DelimiterBalance): boolean {
|
|
201
|
+
return a.paren === 0 && a.bracket === 0 && a.brace === 0;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
interface ReplacementGroup {
|
|
205
|
+
/** Positions in the edit array of the payload inserts, in payload order. */
|
|
206
|
+
insertIndices: number[];
|
|
207
|
+
/** Positions in the edit array of the range deletes, ascending by line. */
|
|
208
|
+
deleteIndices: number[];
|
|
209
|
+
payload: string[];
|
|
210
|
+
/** First deleted line (1-indexed). */
|
|
211
|
+
startLine: number;
|
|
212
|
+
/** Last deleted line (1-indexed). */
|
|
213
|
+
endLine: number;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Detect a replacement group starting at `start`: a run of `before_anchor`
|
|
218
|
+
* replacement inserts sharing one source op line, immediately followed by the
|
|
219
|
+
* contiguous range deletes for that same op. Mirrors how the parser lowers an
|
|
220
|
+
* `replace N..M:` hunk with a body.
|
|
221
|
+
*/
|
|
222
|
+
function findReplacementGroup(edits: readonly AppliedEdit[], start: number): ReplacementGroup | undefined {
|
|
223
|
+
const first = edits[start];
|
|
224
|
+
if (first?.kind !== "insert" || first.mode !== "replacement" || first.cursor.kind !== "before_anchor") {
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
const { lineNum } = first;
|
|
228
|
+
const anchorLine = first.cursor.anchor.line;
|
|
229
|
+
const insertIndices: number[] = [];
|
|
230
|
+
const payload: string[] = [];
|
|
231
|
+
let i = start;
|
|
232
|
+
for (; i < edits.length; i++) {
|
|
233
|
+
const edit = edits[i];
|
|
234
|
+
if (edit.kind !== "insert" || edit.mode !== "replacement" || edit.lineNum !== lineNum) break;
|
|
235
|
+
if (edit.cursor.kind !== "before_anchor" || edit.cursor.anchor.line !== anchorLine) break;
|
|
236
|
+
insertIndices.push(i);
|
|
237
|
+
payload.push(edit.text);
|
|
238
|
+
}
|
|
239
|
+
const deleteIndices: number[] = [];
|
|
240
|
+
let expectedLine = anchorLine;
|
|
241
|
+
for (; i < edits.length; i++) {
|
|
242
|
+
const edit = edits[i];
|
|
243
|
+
if (edit.kind !== "delete" || edit.lineNum !== lineNum || edit.anchor.line !== expectedLine) break;
|
|
244
|
+
deleteIndices.push(i);
|
|
245
|
+
expectedLine++;
|
|
246
|
+
}
|
|
247
|
+
if (deleteIndices.length === 0) return undefined;
|
|
248
|
+
return {
|
|
249
|
+
insertIndices,
|
|
250
|
+
deleteIndices,
|
|
251
|
+
payload,
|
|
252
|
+
startLine: anchorLine,
|
|
253
|
+
endLine: anchorLine + deleteIndices.length - 1,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Largest `k` such that the payload's last `k` lines exactly equal the `k`
|
|
259
|
+
* surviving file lines just below the range AND dropping them zeroes `delta`.
|
|
260
|
+
* Requires a non-zero `delta`: a zero-balance candidate can never account for
|
|
261
|
+
* the imbalance, so intentional duplicates of ordinary statements stay intact,
|
|
262
|
+
* while duplicated structural lines (closers like `});`, openers like `foo(`)
|
|
263
|
+
* are dropped when they exactly explain the imbalance.
|
|
264
|
+
*/
|
|
265
|
+
function findDuplicateSuffix(group: ReplacementGroup, fileLines: readonly string[], delta: DelimiterBalance): number {
|
|
266
|
+
if (balanceIsZero(delta)) return 0;
|
|
267
|
+
const { payload, endLine } = group;
|
|
268
|
+
const maxK = Math.min(payload.length, fileLines.length - endLine);
|
|
269
|
+
for (let k = maxK; k >= 1; k--) {
|
|
270
|
+
let matches = true;
|
|
271
|
+
for (let t = 0; t < k; t++) {
|
|
272
|
+
if (payload[payload.length - k + t] !== fileLines[endLine + t]) {
|
|
273
|
+
matches = false;
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (!matches) continue;
|
|
278
|
+
if (balanceEqual(computeDelimiterBalance(payload.slice(payload.length - k)), delta)) return k;
|
|
279
|
+
}
|
|
280
|
+
return 0;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Largest `j` such that the payload's first `j` lines exactly equal the `j`
|
|
285
|
+
* surviving file lines just above the range AND dropping them zeroes `delta`.
|
|
286
|
+
* Requires a non-zero `delta`; see {@link findDuplicateSuffix}.
|
|
287
|
+
*/
|
|
288
|
+
function findDuplicatePrefix(group: ReplacementGroup, fileLines: readonly string[], delta: DelimiterBalance): number {
|
|
289
|
+
if (balanceIsZero(delta)) return 0;
|
|
290
|
+
const { payload, startLine } = group;
|
|
291
|
+
const maxJ = Math.min(payload.length, startLine - 1);
|
|
292
|
+
for (let j = maxJ; j >= 1; j--) {
|
|
293
|
+
let matches = true;
|
|
294
|
+
for (let t = 0; t < j; t++) {
|
|
295
|
+
if (payload[t] !== fileLines[startLine - 1 - j + t]) {
|
|
296
|
+
matches = false;
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (!matches) continue;
|
|
301
|
+
if (balanceEqual(computeDelimiterBalance(payload.slice(0, j)), delta)) return j;
|
|
302
|
+
}
|
|
303
|
+
return 0;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Smallest `m` such that the range's last `m` deleted lines are all pure
|
|
308
|
+
* structural closers and sparing them (keeping instead of deleting) zeroes
|
|
309
|
+
* `delta`. The mirror mistake: a range that swallows a closing delimiter the
|
|
310
|
+
* payload never restates.
|
|
311
|
+
*/
|
|
312
|
+
function findDroppedSuffixClosers(
|
|
313
|
+
group: ReplacementGroup,
|
|
314
|
+
fileLines: readonly string[],
|
|
315
|
+
delta: DelimiterBalance,
|
|
316
|
+
): number {
|
|
317
|
+
const wanted = balanceNegate(delta);
|
|
318
|
+
const maxM = group.deleteIndices.length;
|
|
319
|
+
for (let m = 1; m <= maxM; m++) {
|
|
320
|
+
if (!STRUCTURAL_CLOSER_RE.test(fileLines[group.endLine - m] ?? "")) break;
|
|
321
|
+
if (balanceEqual(computeDelimiterBalance(fileLines.slice(group.endLine - m, group.endLine)), wanted)) return m;
|
|
322
|
+
}
|
|
323
|
+
return 0;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
interface BoundaryEcho {
|
|
327
|
+
leading: number;
|
|
328
|
+
trailing: number;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function hasNonWhitespace(text: string): boolean {
|
|
332
|
+
for (let i = 0; i < text.length; i++) {
|
|
333
|
+
const code = text.charCodeAt(i);
|
|
334
|
+
if (code !== 9 && code !== 10 && code !== 11 && code !== 12 && code !== 13 && code !== 32) return true;
|
|
335
|
+
}
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function countDuplicateLeadingBoundaryLines(group: ReplacementGroup, fileLines: readonly string[]): number {
|
|
340
|
+
const { payload, startLine } = group;
|
|
341
|
+
const max = Math.min(payload.length, startLine - 1);
|
|
342
|
+
for (let count = max; count >= 1; count--) {
|
|
343
|
+
let matches = true;
|
|
344
|
+
let hasContent = false;
|
|
345
|
+
for (let offset = 0; offset < count; offset++) {
|
|
346
|
+
const line = payload[offset];
|
|
347
|
+
if (line !== fileLines[startLine - 1 - count + offset]) {
|
|
348
|
+
matches = false;
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
hasContent ||= hasNonWhitespace(line);
|
|
352
|
+
}
|
|
353
|
+
if (matches && hasContent) return count;
|
|
354
|
+
}
|
|
355
|
+
return 0;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function countDuplicateTrailingBoundaryLines(group: ReplacementGroup, fileLines: readonly string[]): number {
|
|
359
|
+
const { payload, endLine } = group;
|
|
360
|
+
const max = Math.min(payload.length, fileLines.length - endLine);
|
|
361
|
+
for (let count = max; count >= 1; count--) {
|
|
362
|
+
let matches = true;
|
|
363
|
+
let hasContent = false;
|
|
364
|
+
for (let offset = 0; offset < count; offset++) {
|
|
365
|
+
const line = payload[payload.length - count + offset];
|
|
366
|
+
if (line !== fileLines[endLine + offset]) {
|
|
367
|
+
matches = false;
|
|
368
|
+
break;
|
|
369
|
+
}
|
|
370
|
+
hasContent ||= hasNonWhitespace(line);
|
|
371
|
+
}
|
|
372
|
+
if (matches && hasContent) return count;
|
|
373
|
+
}
|
|
374
|
+
return 0;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function findBoundaryEcho(group: ReplacementGroup, fileLines: readonly string[]): BoundaryEcho | undefined {
|
|
378
|
+
const leadingMax = countDuplicateLeadingBoundaryLines(group, fileLines);
|
|
379
|
+
if (leadingMax === 0) return undefined;
|
|
380
|
+
const trailingMax = countDuplicateTrailingBoundaryLines(group, fileLines);
|
|
381
|
+
if (trailingMax === 0) return undefined;
|
|
382
|
+
// Bail when every payload line could be claimed by a boundary echo: any
|
|
383
|
+
// repair would strip explicit replacement content with no signal that the
|
|
384
|
+
// payload was a mistake rather than an intentional duplication.
|
|
385
|
+
if (leadingMax + trailingMax >= group.payload.length) return undefined;
|
|
386
|
+
return { leading: leadingMax, trailing: trailingMax };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function describeBoundaryEchoRepair(group: ReplacementGroup, echo: BoundaryEcho): string {
|
|
390
|
+
return (
|
|
391
|
+
`Auto-repaired a replacement boundary echo at line ${group.startLine}: ` +
|
|
392
|
+
`dropped ${echo.leading} leading and ${echo.trailing} trailing payload line(s) already present outside the range. ` +
|
|
393
|
+
`Issue the payload as the final desired content for the selected range only — never restate unchanged lines bordering the range.`
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function describeBoundaryRepair(group: ReplacementGroup, action: string): string {
|
|
398
|
+
return (
|
|
399
|
+
`Auto-repaired a delimiter-balance mismatch in the replacement at line ${group.startLine}: ${action}. ` +
|
|
400
|
+
`Issue the payload as the final desired content only — never restate or omit a closing bracket bordering the range.`
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Normalize replacement groups so common off-by-one boundaries do not duplicate
|
|
406
|
+
* unchanged surrounding lines or structural closers. Returns the repaired edit
|
|
407
|
+
* list plus one warning per repaired group.
|
|
408
|
+
*/
|
|
409
|
+
function repairReplacementBoundaries(
|
|
410
|
+
edits: readonly AppliedEdit[],
|
|
411
|
+
fileLines: readonly string[],
|
|
412
|
+
): {
|
|
413
|
+
edits: AppliedEdit[];
|
|
414
|
+
warnings: string[];
|
|
415
|
+
} {
|
|
416
|
+
const out: AppliedEdit[] = [];
|
|
417
|
+
const warnings: string[] = [];
|
|
418
|
+
let i = 0;
|
|
419
|
+
while (i < edits.length) {
|
|
420
|
+
const group = findReplacementGroup(edits, i);
|
|
421
|
+
if (!group) {
|
|
422
|
+
out.push(edits[i]);
|
|
423
|
+
i++;
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
const inserts = group.insertIndices.map(idx => edits[idx]);
|
|
427
|
+
const deletes = group.deleteIndices.map(idx => edits[idx]);
|
|
428
|
+
i = group.deleteIndices[group.deleteIndices.length - 1] + 1;
|
|
429
|
+
|
|
430
|
+
const boundaryEcho = findBoundaryEcho(group, fileLines);
|
|
431
|
+
if (boundaryEcho) {
|
|
432
|
+
warnings.push(describeBoundaryEchoRepair(group, boundaryEcho));
|
|
433
|
+
out.push(...inserts.slice(boundaryEcho.leading, inserts.length - boundaryEcho.trailing), ...deletes);
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const delta = balanceDelta(
|
|
438
|
+
computeDelimiterBalance(group.payload),
|
|
439
|
+
computeDelimiterBalance(fileLines.slice(group.startLine - 1, group.endLine)),
|
|
440
|
+
);
|
|
441
|
+
if (balanceIsZero(delta)) {
|
|
442
|
+
out.push(...inserts, ...deletes);
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const dupSuffix = findDuplicateSuffix(group, fileLines, delta);
|
|
447
|
+
if (dupSuffix > 0) {
|
|
448
|
+
warnings.push(
|
|
449
|
+
describeBoundaryRepair(
|
|
450
|
+
group,
|
|
451
|
+
`dropped ${dupSuffix} duplicated trailing payload line(s) already present below the range`,
|
|
452
|
+
),
|
|
453
|
+
);
|
|
454
|
+
out.push(...inserts.slice(0, inserts.length - dupSuffix), ...deletes);
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
const dupPrefix = findDuplicatePrefix(group, fileLines, delta);
|
|
458
|
+
if (dupPrefix > 0) {
|
|
459
|
+
warnings.push(
|
|
460
|
+
describeBoundaryRepair(
|
|
461
|
+
group,
|
|
462
|
+
`dropped ${dupPrefix} duplicated leading payload line(s) already present above the range`,
|
|
463
|
+
),
|
|
464
|
+
);
|
|
465
|
+
out.push(...inserts.slice(dupPrefix), ...deletes);
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
const droppedClosers = findDroppedSuffixClosers(group, fileLines, delta);
|
|
469
|
+
if (droppedClosers > 0) {
|
|
470
|
+
warnings.push(
|
|
471
|
+
describeBoundaryRepair(
|
|
472
|
+
group,
|
|
473
|
+
`kept ${droppedClosers} structural closing line(s) the range deleted without restating`,
|
|
474
|
+
),
|
|
475
|
+
);
|
|
476
|
+
out.push(...inserts, ...deletes.slice(0, deletes.length - droppedClosers));
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
out.push(...inserts, ...deletes);
|
|
480
|
+
}
|
|
481
|
+
return { edits: out, warnings };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Apply a parsed list of edits to a text body. Pure function — no I/O.
|
|
486
|
+
*
|
|
487
|
+
* Returns the post-edit text and the first changed line number (1-indexed).
|
|
488
|
+
* Throws if an anchor is out of bounds.
|
|
489
|
+
*/
|
|
490
|
+
export function applyEdits(text: string, edits: readonly Edit[]): ApplyResult {
|
|
491
|
+
if (edits.length === 0) return { text, firstChangedLine: undefined };
|
|
492
|
+
|
|
493
|
+
// Block edits are deferred until `resolveBlockEdits` expands them into
|
|
494
|
+
// concrete inserts + deletes. Reaching the applier with one still present
|
|
495
|
+
// is an internal wiring bug, not authored-input error.
|
|
496
|
+
for (const edit of edits) {
|
|
497
|
+
if (edit.kind === "block") throw new Error(UNRESOLVED_BLOCK_INTERNAL);
|
|
498
|
+
}
|
|
499
|
+
const appliedEdits = edits as readonly AppliedEdit[];
|
|
500
|
+
|
|
501
|
+
const fileLines = text.split("\n");
|
|
502
|
+
const lineOrigins: LineOrigin[] = fileLines.map(() => "original");
|
|
503
|
+
|
|
504
|
+
let firstChangedLine: number | undefined;
|
|
505
|
+
const trackFirstChanged = (line: number) => {
|
|
506
|
+
if (firstChangedLine === undefined || line < firstChangedLine) firstChangedLine = line;
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
const targetEdits = appliedEdits.map((edit, index) => cloneAppliedEdit(edit, index));
|
|
510
|
+
validateLineBounds(targetEdits, fileLines);
|
|
511
|
+
const { edits: repaired, warnings } = repairReplacementBoundaries(targetEdits, fileLines);
|
|
512
|
+
|
|
513
|
+
// Partition edits into bof, eof, and anchor-targeted buckets.
|
|
514
|
+
const bofLines: string[] = [];
|
|
515
|
+
const eofLines: string[] = [];
|
|
516
|
+
const anchorEdits: IndexedEdit[] = [];
|
|
517
|
+
repaired.forEach((edit, idx) => {
|
|
518
|
+
if (edit.kind === "insert" && edit.cursor.kind === "bof") {
|
|
519
|
+
bofLines.push(edit.text);
|
|
520
|
+
} else if (edit.kind === "insert" && edit.cursor.kind === "eof") {
|
|
521
|
+
eofLines.push(edit.text);
|
|
522
|
+
} else {
|
|
523
|
+
anchorEdits.push({ edit, idx });
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
// Apply per-line buckets bottom-up so earlier indices stay valid.
|
|
528
|
+
const byLine = bucketAnchorEditsByLine(anchorEdits);
|
|
529
|
+
for (const line of [...byLine.keys()].sort((a, b) => b - a)) {
|
|
530
|
+
const bucket = byLine.get(line);
|
|
531
|
+
if (!bucket) continue;
|
|
532
|
+
bucket.sort((a, b) => a.idx - b.idx);
|
|
533
|
+
|
|
534
|
+
const idx = line - 1;
|
|
535
|
+
const currentLine = fileLines[idx] ?? "";
|
|
536
|
+
const beforeInsertLines: string[] = [];
|
|
537
|
+
const afterInsertLines: string[] = [];
|
|
538
|
+
const replacementLines: string[] = [];
|
|
539
|
+
let deleteLine = false;
|
|
540
|
+
|
|
541
|
+
for (const { edit } of bucket) {
|
|
542
|
+
if (isReplacementInsert(edit)) {
|
|
543
|
+
replacementLines.push(edit.text);
|
|
544
|
+
} else if (edit.kind === "insert" && edit.cursor.kind === "after_anchor") {
|
|
545
|
+
afterInsertLines.push(edit.text);
|
|
546
|
+
} else if (edit.kind === "insert") {
|
|
547
|
+
beforeInsertLines.push(edit.text);
|
|
548
|
+
} else if (edit.kind === "delete") {
|
|
549
|
+
deleteLine = true;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
if (
|
|
553
|
+
beforeInsertLines.length === 0 &&
|
|
554
|
+
replacementLines.length === 0 &&
|
|
555
|
+
afterInsertLines.length === 0 &&
|
|
556
|
+
!deleteLine
|
|
557
|
+
)
|
|
558
|
+
continue;
|
|
559
|
+
|
|
560
|
+
const replacement = deleteLine
|
|
561
|
+
? [...beforeInsertLines, ...replacementLines, ...afterInsertLines]
|
|
562
|
+
: [...beforeInsertLines, ...replacementLines, currentLine, ...afterInsertLines];
|
|
563
|
+
const origins: LineOrigin[] = [];
|
|
564
|
+
for (let i = 0; i < beforeInsertLines.length; i++) origins.push("insert");
|
|
565
|
+
for (let i = 0; i < replacementLines.length; i++) origins.push(deleteLine ? "replacement" : "insert");
|
|
566
|
+
if (!deleteLine) origins.push(lineOrigins[idx] ?? "original");
|
|
567
|
+
for (let i = 0; i < afterInsertLines.length; i++) origins.push("insert");
|
|
568
|
+
|
|
569
|
+
fileLines.splice(idx, 1, ...replacement);
|
|
570
|
+
lineOrigins.splice(idx, 1, ...origins);
|
|
571
|
+
trackFirstChanged(line);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
if (bofLines.length > 0) {
|
|
575
|
+
insertAtStart(fileLines, lineOrigins, bofLines);
|
|
576
|
+
trackFirstChanged(1);
|
|
577
|
+
}
|
|
578
|
+
const eofChangedLine = insertAtEnd(fileLines, lineOrigins, eofLines);
|
|
579
|
+
if (eofChangedLine !== undefined) trackFirstChanged(eofChangedLine);
|
|
580
|
+
|
|
581
|
+
return {
|
|
582
|
+
text: fileLines.join("\n"),
|
|
583
|
+
firstChangedLine,
|
|
584
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
585
|
+
};
|
|
586
|
+
}
|
package/src/block.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Expand deferred `replace block N:` edits into concrete inserts + deletes.
|
|
3
|
+
*
|
|
4
|
+
* The hashline parser cannot expand a block edit on its own — the line span is
|
|
5
|
+
* unknown until file text + path (→ language) are available. This transform
|
|
6
|
+
* runs at every apply/preview boundary that has text: it calls the injected
|
|
7
|
+
* {@link BlockResolver} to resolve each block's `[start, end]` span, then emits
|
|
8
|
+
* the exact same `before_anchor` replacement inserts + range deletes that
|
|
9
|
+
* `replace start..end:` produces in the parser. After it runs, no `block` edits
|
|
10
|
+
* remain, so {@link applyEdits} (and recovery) only ever see resolved edits.
|
|
11
|
+
*/
|
|
12
|
+
import { BLOCK_RESOLVER_UNAVAILABLE, blockUnresolvedMessage } from "./messages";
|
|
13
|
+
import type { BlockResolver, Cursor, Edit } from "./types";
|
|
14
|
+
|
|
15
|
+
export interface ResolveBlockEditsOptions {
|
|
16
|
+
/**
|
|
17
|
+
* How to handle a block edit that cannot be resolved (missing resolver or a
|
|
18
|
+
* `null` span). `"throw"` (default) raises a `blockUnresolvedMessage` error —
|
|
19
|
+
* used by the authoritative apply + final preview paths. `"drop"` silently
|
|
20
|
+
* skips the edit — used by the streaming preview, where a half-written file
|
|
21
|
+
* or transient parse error must not throw.
|
|
22
|
+
*/
|
|
23
|
+
onUnresolved?: "throw" | "drop";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** True when at least one edit is an unresolved `replace block N:` edit. */
|
|
27
|
+
export function hasBlockEdit(edits: readonly Edit[]): boolean {
|
|
28
|
+
return edits.some(edit => edit.kind === "block");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve every `replace block N:` edit in `edits` against `text` (parsed as
|
|
33
|
+
* the language inferred from `path`). Non-block edits pass through untouched.
|
|
34
|
+
* Returns a fresh edit list with no `block` variants. The fast path returns the
|
|
35
|
+
* input unchanged when there is nothing to resolve.
|
|
36
|
+
*
|
|
37
|
+
* Synthesized inserts/deletes carry sequential `index` values for readability
|
|
38
|
+
* only — {@link applyEdits} re-derives every edit's index from array order, so
|
|
39
|
+
* the passthrough edits keeping their original indices is harmless.
|
|
40
|
+
*/
|
|
41
|
+
export function resolveBlockEdits(
|
|
42
|
+
edits: readonly Edit[],
|
|
43
|
+
text: string,
|
|
44
|
+
path: string,
|
|
45
|
+
resolver: BlockResolver | undefined,
|
|
46
|
+
options: ResolveBlockEditsOptions = {},
|
|
47
|
+
): readonly Edit[] {
|
|
48
|
+
if (!hasBlockEdit(edits)) return edits;
|
|
49
|
+
const onUnresolved = options.onUnresolved ?? "throw";
|
|
50
|
+
const resolved: Edit[] = [];
|
|
51
|
+
let synthIndex = 0;
|
|
52
|
+
for (const edit of edits) {
|
|
53
|
+
if (edit.kind !== "block") {
|
|
54
|
+
resolved.push(edit);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const span = resolver ? resolver({ path, text, line: edit.anchor.line }) : null;
|
|
58
|
+
if (span === null) {
|
|
59
|
+
if (onUnresolved === "drop") continue;
|
|
60
|
+
throw new Error(
|
|
61
|
+
`line ${edit.lineNum}: ${resolver ? blockUnresolvedMessage(edit.anchor.line) : BLOCK_RESOLVER_UNAVAILABLE}`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
// Mirror the parser's `replace start..end:` expansion exactly: one
|
|
65
|
+
// `before_anchor` replacement insert per payload row at `span.start`,
|
|
66
|
+
// then one delete per line across `[span.start, span.end]`. An empty
|
|
67
|
+
// `payloads` (from `delete block N`) emits no inserts — a pure deletion.
|
|
68
|
+
for (const payload of edit.payloads) {
|
|
69
|
+
const cursor: Cursor = { kind: "before_anchor", anchor: { line: span.start } };
|
|
70
|
+
resolved.push({
|
|
71
|
+
kind: "insert",
|
|
72
|
+
cursor,
|
|
73
|
+
text: payload,
|
|
74
|
+
lineNum: edit.lineNum,
|
|
75
|
+
index: synthIndex++,
|
|
76
|
+
mode: "replacement",
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
for (let line = span.start; line <= span.end; line++) {
|
|
80
|
+
resolved.push({ kind: "delete", anchor: { line }, lineNum: edit.lineNum, index: synthIndex++ });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return resolved;
|
|
84
|
+
}
|