@oh-my-pi/hashline 15.5.12 → 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/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 mutation of the input.
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 = expandRepeatEdits(edits, fileLines);
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 BOF, EOF, and anchor-targeted buckets.
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
- targetEdits.forEach((edit, idx) => {
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 insertLines: string[] = [];
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
- insertLines.push(edit.text);
459
+ beforeInsertLines.push(edit.text);
187
460
  } else if (edit.kind === "delete") {
188
461
  deleteLine = true;
189
462
  }
190
463
  }
191
- if (insertLines.length === 0 && replacementLines.length === 0 && !deleteLine) continue;
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
- ? [...insertLines, ...replacementLines]
195
- : [...insertLines, ...replacementLines, currentLine];
473
+ ? [...beforeInsertLines, ...replacementLines, ...afterInsertLines]
474
+ : [...beforeInsertLines, ...replacementLines, currentLine, ...afterInsertLines];
196
475
  const origins: LineOrigin[] = [];
197
- for (let i = 0; i < insertLines.length; i++) origins.push("insert");
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
- /** All hashline payload sigils, concatenated for fast membership tests. */
16
- export const HL_PAYLOAD_CHARS = `${HL_PAYLOAD_REPLACE}${HL_PAYLOAD_REPEAT}`;
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
- /** Regex for repeat payload rows (`&A..B`). */
50
- export const HL_PAYLOAD_REPEAT_RE = new RegExp(
51
- `^\\${HL_PAYLOAD_REPEAT}${HL_LINE_CAPTURE_RE_RAW},${HL_LINE_CAPTURE_RE_RAW}$`,
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
- /** Number of hex characters in an opaque snapshot tag. */
55
- export const HL_FILE_HASH_LENGTH = 3;
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
- /** Canonical uppercase hexadecimal opaque snapshot tag carried by a hashline section header. */
58
- export const HL_FILE_HASH_RE_RAW = `[0-9A-F]{${HL_FILE_HASH_LENGTH}}`;
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 snapshot tags for use in user-facing error messages and
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 = ["0A3", "1F7", "3C9"] as const;
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]{3}/
7
+ file_hash: /[0-9A-F]{4}/
8
8
  filename: /[^\s#]+/
9
9
 
10
- hunk: hunk_header op*
11
- hunk_header: anchor LF
12
- op: emit_op | repeat_op
13
- emit_op: "+" /(.*)/ LF
14
- repeat_op: "&" body_range LF
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
- anchor: header_range | "BOF" | "EOF"
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