@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/parser.ts CHANGED
@@ -2,24 +2,14 @@
2
2
  * Token-driven state machine that turns a stream of {@link Token}s into a
3
3
  * flat list of {@link Edit}s. Sits between the {@link Tokenizer} and the
4
4
  * applier.
5
- *
6
- * Lifecycle:
7
- *
8
- * 1. Construct one {@link Executor} per patch (or share one with `reset()`).
9
- * 2. Feed it tokens via {@link Executor.feed}. Hunk body rows accumulate
10
- * until the next hunk header or {@link end} flushes them.
11
- * 3. Call {@link Executor.end} to flush the trailing pending hunk and
12
- * validate cross-hunk invariants (no overlapping deletes, etc.).
13
- *
14
- * Convenience entry point: {@link parsePatch}.
15
5
  */
16
- import { HL_PAYLOAD_REPEAT, HL_PAYLOAD_REPLACE } from "./format";
6
+ import { HL_PAYLOAD_REPLACE } from "./format";
17
7
  import {
18
8
  BARE_BODY_AUTO_PIPED_WARNING,
19
- PLUS_PREFIXED_REPEAT_WARNING,
20
- REPLACE_PAIR_COALESCED_OVERLAP_WARNING,
21
- REPLACE_PAIR_COALESCED_WARNING,
22
- UNIFIED_DIFF_BODY_AUTO_CONVERT_WARNING,
9
+ DELETE_TAKES_NO_BODY,
10
+ EMPTY_INSERT,
11
+ EMPTY_REPLACE,
12
+ MINUS_ROW_REJECTED,
23
13
  } from "./messages";
24
14
  import { type BlockTarget, cloneCursor, type ParsedRange, type Token, Tokenizer } from "./tokenizer";
25
15
  import type { Anchor, Cursor, Edit } from "./types";
@@ -30,51 +20,19 @@ function validateRangeOrder(range: ParsedRange, lineNum: number): void {
30
20
  }
31
21
  }
32
22
 
33
- /**
34
- * If `text` (the slice after a `+` literal sigil) trims to `&A..B` (or `&A`,
35
- * accepted as `&A,A`), return the parsed range. Otherwise `null`. Used to
36
- * silently reroute `+&A..B` rows as repeats — models reflexively prefix every
37
- * body row with `+`, including ones that should be repeats.
38
- */
39
- function tryParseLiteralAsRepeat(text: string): ParsedRange | null {
40
- const stripped = text.trim();
41
- if (stripped.length === 0 || stripped.charCodeAt(0) !== 38 /* & */) return null;
42
- const match = /^&([1-9]\d*)(?:\.\.([1-9]\d*))?$/.exec(stripped);
43
- if (match === null) return null;
44
- const start = Number.parseInt(match[1], 10);
45
- const end = match[2] !== undefined ? Number.parseInt(match[2], 10) : start;
46
- return { start: { line: start }, end: { line: end } };
47
- }
48
-
49
- function rangesEqual(a: ParsedRange, b: ParsedRange): boolean {
50
- return a.start.line === b.start.line && a.end.line === b.end.line;
51
- }
52
-
53
- function targetsEqualConcreteRange(a: BlockTarget, b: BlockTarget): boolean {
54
- return a.kind === "range" && b.kind === "range" && rangesEqual(a.range, b.range);
55
- }
56
-
57
- function rangesOverlap(a: ParsedRange, b: ParsedRange): boolean {
58
- return a.start.line <= b.end.line && b.start.line <= a.end.line;
23
+ function expandRange(range: ParsedRange): Anchor[] {
24
+ const anchors: Anchor[] = [];
25
+ for (let line = range.start.line; line <= range.end.line; line++) anchors.push({ line });
26
+ return anchors;
59
27
  }
60
28
 
61
- function rangesOverlapBetweenTargets(a: BlockTarget, b: BlockTarget): boolean {
62
- return a.kind === "range" && b.kind === "range" && rangesOverlap(a.range, b.range);
29
+ function isSkippableCommentLine(line: string): boolean {
30
+ return line.trimStart().startsWith("#");
63
31
  }
64
32
 
65
- /**
66
- * Detect OpenAI-`apply_patch` / unified-diff contamination in a raw line.
67
- * Returns the error message to throw, or `null` when the line is clean.
68
- *
69
- * Hashline's own file-header prefix (`¶path#hash`) sits next to
70
- * apply_patch sentinels (`*** Update File: path`); the latter are caught
71
- * here. Any `@@`-bracketed shape is also caught — hashline hunks are bare
72
- * `A B` lines, never `@@ ... @@`.
73
- */
74
33
  function detectApplyPatchContamination(text: string, _hasPending: boolean): string | null {
75
34
  const trimmed = text.trimStart();
76
35
  if (trimmed.length === 0) return null;
77
-
78
36
  if (
79
37
  trimmed.startsWith("*** Update File:") ||
80
38
  trimmed.startsWith("*** Add File:") ||
@@ -85,86 +43,51 @@ function detectApplyPatchContamination(text: string, _hasPending: boolean): stri
85
43
  return (
86
44
  `apply_patch sentinel ${JSON.stringify(preview)} is not valid in hashline. ` +
87
45
  "File sections start with `¶path#HASH` (no `Update File:` / `Add File:` keyword). " +
88
- "Hunks are bare `A B` lines with `+TEXT` / `&A..B` body rows."
46
+ "Use `replace N..M:`, `delete N..M`, or `insert before|after|head|tail:` ops."
89
47
  );
90
48
  }
91
49
  if (/^@@\s+[-+]?\d+,\d+\s+[-+]?\d+,\d+\s+@@/.test(trimmed)) {
92
50
  return (
93
51
  "unified-diff hunk header (`@@ -N,M +N,M @@`) is not valid in hashline. " +
94
- "Hashline hunks are bare `A B` lines (or `BOF` / `EOF` keywords)."
52
+ "Use `replace N..M:`, `delete N..M`, or `insert before|after|head|tail:` ops."
95
53
  );
96
54
  }
97
55
  if (trimmed.startsWith("@@")) {
98
56
  const preview = trimmed.length > 48 ? `${trimmed.slice(0, 48)}…` : trimmed;
99
57
  return (
100
58
  `\`@@\`-bracketed hunk header ${JSON.stringify(preview)} is not valid in hashline. ` +
101
- "Drop the `@@ ... @@` brackets and write the range directly: `5 7` (`BOF` / `EOF` for virtual positions)."
59
+ "Drop the `@@ ... @@` brackets and write a verb header such as `replace N..M:`."
102
60
  );
103
61
  }
62
+ if (/^delete\s+[1-9]\d*(?:\s*(?:\.\.|-|…|\s)\s*[1-9]\d*)?\s*:/.test(trimmed)) {
63
+ return "`delete N..M` has no colon and no body. Remove the colon and body rows.";
64
+ }
104
65
  if (/^[1-9]\d*\s*$/.test(trimmed)) {
66
+ return `hunk headers need a verb. Use \`replace ${trimmed}..${trimmed}:\` to replace, or \`delete ${trimmed}\` to delete.`;
67
+ }
68
+ const bareRange = /^([1-9]\d*)\s*[-. …]+\s*([1-9]\d*)\s*:?$/.exec(trimmed);
69
+ if (bareRange !== null) {
105
70
  return (
106
- `single-number hunk header ${JSON.stringify(trimmed)} is no longer accepted. ` +
107
- `Spell single-line ranges as \`${trimmed} ${trimmed}\` (two numbers); ` +
108
- "hashline hunks are bare `A B` lines (or `BOF` / `EOF`)."
71
+ `bare range hunk header ${JSON.stringify(trimmed)} is not valid. ` +
72
+ `Hunk headers need a verb: write \`replace ${bareRange[1]}..${bareRange[2]}:\` or \`delete ${bareRange[1]}..${bareRange[2]}\`.`
109
73
  );
110
74
  }
111
75
  return null;
112
76
  }
113
77
 
114
- function pendingHasAnyContent(pending: Pending): boolean {
115
- return pending.payloads.length > 0 || pending.pendingRaws.length > 0;
116
- }
117
-
118
- function expandRange(range: ParsedRange): Anchor[] {
119
- const anchors: Anchor[] = [];
120
- for (let line = range.start.line; line <= range.end.line; line++) {
121
- anchors.push({ line });
122
- }
123
- return anchors;
124
- }
125
-
126
- function isSkippableCommentLine(line: string): boolean {
127
- return line.trimStart().startsWith("#");
128
- }
129
-
130
78
  interface PendingComment {
131
79
  lineNum: number;
132
80
  text: string;
133
81
  }
134
82
 
135
- type PayloadRow =
136
- | { kind: "literal"; text: string; lineNum: number }
137
- | { kind: "repeat"; range: ParsedRange; lineNum: number };
83
+ type PayloadRow = { kind: "literal"; text: string; lineNum: number };
138
84
 
139
85
  interface Pending {
140
86
  target: BlockTarget;
141
87
  lineNum: number;
142
88
  payloads: PayloadRow[];
143
- /**
144
- * Bare body rows (no `+`/`&` prefix) buffered while we wait to see
145
- * whether the entire hunk body is uniformly unprefixed. On flush, if
146
- * every row was bare AND no `+`/`&` row was ever observed for this hunk,
147
- * we auto-prepend `+` and emit a {@link BARE_BODY_AUTO_PIPED_WARNING}.
148
- */
149
- pendingRaws: { text: string; lineNum: number }[];
150
- /**
151
- * Set true the first time a `-` row arrives inside the hunk body. From
152
- * then on we strip one leading space from raw rows (treating them as
153
- * unified-diff context lines) and retroactively strip the same space
154
- * from prior `pendingRaws`/`payloads` literals that began with a space.
155
- */
156
- unifiedDiffMode: boolean;
157
89
  }
158
90
 
159
- /**
160
- * Token-driven state machine that turns a stream of {@link Token}s into a
161
- * flat list of {@link Edit}s.
162
- *
163
- * `feed()` accepts tokens one at a time; hunk body rows accumulate until
164
- * the next hunk header or {@link end} flushes them. After `terminated`
165
- * flips true (on `envelope-end` or `abort`) subsequent feeds are silently
166
- * ignored so callers can keep draining their tokenizer.
167
- */
168
91
  export class Executor {
169
92
  #edits: Edit[] = [];
170
93
  #warnings: string[] = [];
@@ -179,24 +102,12 @@ export class Executor {
179
102
 
180
103
  #consumePendingSkippableComments(): void {
181
104
  if (this.#skippableComments.length === 0) return;
182
- const comment = this.#skippableComments[0];
105
+ for (const comment of this.#skippableComments) this.#handleRaw(comment.text, comment.lineNum);
183
106
  this.#skippableComments = [];
184
- this.#handleRaw(comment.text, comment.lineNum);
185
- }
186
-
187
- /** True once an `envelope-end` or `abort` token has been observed. */
188
- get terminated(): boolean {
189
- return this.#terminated;
190
107
  }
191
108
 
192
- /**
193
- * Consume one token. After `terminated` flips true subsequent feeds are
194
- * silently ignored so callers can keep draining the tokenizer without
195
- * explicit early-exit guards.
196
- */
197
109
  feed(token: Token): void {
198
110
  if (this.#terminated) return;
199
-
200
111
  switch (token.kind) {
201
112
  case "envelope-begin":
202
113
  this.#consumePendingSkippableComments();
@@ -219,10 +130,6 @@ export class Executor {
219
130
  this.#consumePendingSkippableComments();
220
131
  this.#handleLiteralPayload(token.text, token.lineNum);
221
132
  return;
222
- case "payload-repeat":
223
- this.#consumePendingSkippableComments();
224
- this.#handleRepeatPayload(token.range, token.lineNum);
225
- return;
226
133
  case "raw":
227
134
  if (this.#pending === undefined && isSkippableCommentLine(token.text)) {
228
135
  this.#skippableComments.push({ text: token.text, lineNum: token.lineNum });
@@ -233,47 +140,15 @@ export class Executor {
233
140
  return;
234
141
  case "op-block":
235
142
  this.#discardPendingSkippableComments();
236
- if (token.target.kind === "range") validateRangeOrder(token.target.range, token.lineNum);
237
-
238
- if (this.#pending !== undefined && targetsEqualConcreteRange(this.#pending.target, token.target)) {
239
- // Identical-range coalesce: drop the first hunk. Last-wins.
240
- this.#pending = undefined;
241
- if (!this.#warnings.includes(REPLACE_PAIR_COALESCED_WARNING)) {
242
- this.#warnings.push(REPLACE_PAIR_COALESCED_WARNING);
243
- }
244
- } else if (
245
- this.#pending !== undefined &&
246
- !pendingHasAnyContent(this.#pending) &&
247
- rangesOverlapBetweenTargets(this.#pending.target, token.target)
248
- ) {
249
- // Overlapping bare-then-concrete: drop the bare one.
250
- this.#pending = undefined;
251
- if (!this.#warnings.includes(REPLACE_PAIR_COALESCED_OVERLAP_WARNING)) {
252
- this.#warnings.push(REPLACE_PAIR_COALESCED_OVERLAP_WARNING);
253
- }
254
- } else {
255
- this.#flushPending();
143
+ if (token.target.kind === "replace" || token.target.kind === "delete") {
144
+ validateRangeOrder(token.target.range, token.lineNum);
256
145
  }
257
- this.#pending = {
258
- target: token.target,
259
- lineNum: token.lineNum,
260
- payloads: [],
261
- pendingRaws: [],
262
- unifiedDiffMode: false,
263
- };
146
+ this.#flushPending();
147
+ this.#pending = { target: token.target, lineNum: token.lineNum, payloads: [] };
264
148
  return;
265
149
  }
266
150
  }
267
151
 
268
- /**
269
- * Flush any open pending hunk and return the accumulated edits and
270
- * warnings. The executor is single-use; {@link reset} is required for
271
- * reuse.
272
- *
273
- * Throws if two hunks target the same line with non-identical ranges.
274
- * Identical-range hunks in the same patch are coalesced last-wins by
275
- * `feed()` with a warning, so they never reach the validator.
276
- */
277
152
  end(): { edits: Edit[]; warnings: string[] } {
278
153
  this.#consumePendingSkippableComments();
279
154
  this.#flushPending();
@@ -281,24 +156,15 @@ export class Executor {
281
156
  return { edits: this.#edits, warnings: this.#warnings };
282
157
  }
283
158
 
284
- /**
285
- * Streaming-tolerant variant of {@link end}. Identical, except a pending
286
- * hunk whose body has not yet accumulated any rows is treated as still
287
- * in flight and dropped instead of flushed (which would otherwise commit
288
- * a destructive delete while the model may still be typing payload).
289
- */
290
159
  endStreaming(): { edits: Edit[]; warnings: string[] } {
291
160
  this.#consumePendingSkippableComments();
292
- if (this.#pending && pendingHasAnyContent(this.#pending)) {
293
- this.#flushPending();
294
- } else {
295
- this.#pending = undefined;
296
- }
161
+ if (this.#pending && this.#pending.payloads.length > 0) this.#flushPending();
162
+ else if (this.#pending?.target.kind === "delete") this.#flushPending();
163
+ else this.#pending = undefined;
297
164
  this.#validateNoOverlappingDeletes();
298
165
  return { edits: this.#edits, warnings: this.#warnings };
299
166
  }
300
167
 
301
- /** Reset to a fresh state so the same instance can drive another parse. */
302
168
  reset(): void {
303
169
  this.#edits = [];
304
170
  this.#warnings = [];
@@ -308,12 +174,6 @@ export class Executor {
308
174
  this.#terminated = false;
309
175
  }
310
176
 
311
- /**
312
- * Each hunk contributes a delete edit per line in its range; if any line
313
- * ends up targeted by deletes originating from two different source
314
- * hunks (distinguished by their `lineNum`), the patch is internally
315
- * inconsistent.
316
- */
317
177
  #validateNoOverlappingDeletes(): void {
318
178
  const sourceLinesByAnchor = new Map<number, number[]>();
319
179
  for (const edit of this.#edits) {
@@ -330,7 +190,7 @@ export class Executor {
330
190
  const [firstBlock, secondBlock] = [...sourceLines].sort((a, b) => a - b);
331
191
  throw new Error(
332
192
  `line ${secondBlock}: anchor line ${anchorLine} is already targeted by another hunk on line ${firstBlock}. ` +
333
- `Issue ONE hunk per range; payload is only the final desired content, never a before/after pair.`,
193
+ "Issue ONE hunk per range; payload is only the final desired content, never a before/after pair.",
334
194
  );
335
195
  }
336
196
  }
@@ -343,93 +203,25 @@ export class Executor {
343
203
  `Got ${JSON.stringify(`${HL_PAYLOAD_REPLACE}${text}`)}.`,
344
204
  );
345
205
  }
346
- // Silent recovery: a body row of `+&A..B` (or `+&A` shorthand) is a
347
- // repeat row the model mistakenly prefixed with `+`. Reroute as a
348
- // repeat and surface a warning so the model sees the mistake.
349
- const repeatRange = tryParseLiteralAsRepeat(text);
350
- if (repeatRange !== null) {
351
- if (!this.#warnings.includes(PLUS_PREFIXED_REPEAT_WARNING)) {
352
- this.#warnings.push(PLUS_PREFIXED_REPEAT_WARNING);
353
- }
354
- this.#handleRepeatPayload(repeatRange, lineNum);
355
- return;
356
- }
206
+ if (pending.target.kind === "delete") throw new Error(`line ${lineNum}: ${DELETE_TAKES_NO_BODY}`);
357
207
  pending.payloads.push({ kind: "literal", text, lineNum });
358
208
  }
359
209
 
360
- #handleRepeatPayload(range: ParsedRange, lineNum: number): void {
361
- const pending = this.#pending;
362
- if (!pending) {
363
- throw new Error(
364
- `line ${lineNum}: payload line has no preceding hunk header. ` +
365
- `Got ${JSON.stringify(`${HL_PAYLOAD_REPEAT}${range.start.line}..${range.end.line}`)}.`,
366
- );
367
- }
368
- validateRangeOrder(range, lineNum);
369
- pending.payloads.push({ kind: "repeat", range, lineNum });
370
- }
371
-
372
- /**
373
- * Switch the pending hunk into unified-diff mode and retroactively
374
- * strip the leading metadata-space from any literal payloads or
375
- * buffered raws that already arrived. Idempotent.
376
- */
377
- #enterUnifiedDiffMode(pending: Pending): void {
378
- if (pending.unifiedDiffMode) return;
379
- pending.unifiedDiffMode = true;
380
- for (const row of pending.pendingRaws) {
381
- if (row.text.length > 0 && row.text.charCodeAt(0) === 32) {
382
- row.text = row.text.slice(1);
383
- }
384
- }
385
- for (const payload of pending.payloads) {
386
- if (payload.kind === "literal" && payload.text.length > 0 && payload.text.charCodeAt(0) === 32) {
387
- payload.text = payload.text.slice(1);
388
- }
389
- }
390
- }
391
-
392
210
  #handleRaw(text: string, lineNum: number): void {
393
- // Detect OpenAI-apply_patch / unified-diff contamination first so the
394
- // error message names the offending shape instead of the generic
395
- // "payload row must start with …" diagnostic.
396
211
  const contamination = detectApplyPatchContamination(text, this.#pending !== undefined);
397
212
  if (contamination !== null) throw new Error(`line ${lineNum}: ${contamination}`);
398
-
399
213
  if (this.#pending) {
400
214
  if (text.trim().length === 0) return;
401
-
402
- // L9: `-`-prefixed body rows are unified-diff "removed" markers.
403
- // The hunk header's range already deletes those lines, so we
404
- // silently drop them and enter unified-diff mode for subsequent
405
- // rows (which causes leading-space stripping on context lines).
406
- if (text.charCodeAt(0) === 45 /* - */) {
407
- this.#enterUnifiedDiffMode(this.#pending);
408
- if (!this.#warnings.includes(UNIFIED_DIFF_BODY_AUTO_CONVERT_WARNING)) {
409
- this.#warnings.push(UNIFIED_DIFF_BODY_AUTO_CONVERT_WARNING);
410
- }
411
- return;
412
- }
413
-
414
- // Treat any non-`+`/`&` body row as a literal. When the hunk is
415
- // in unified-diff mode and the row carries the metadata leading
416
- // space, strip ONE space so the actual content lands cleanly.
417
- const literalText =
418
- this.#pending.unifiedDiffMode && text.charCodeAt(0) === 32 /* space */ ? text.slice(1) : text;
419
- if (!this.#warnings.includes(BARE_BODY_AUTO_PIPED_WARNING)) {
420
- this.#warnings.push(BARE_BODY_AUTO_PIPED_WARNING);
421
- }
422
- this.#pending.payloads.push({ kind: "literal", text: literalText, lineNum });
215
+ if (this.#pending.target.kind === "delete") throw new Error(`line ${lineNum}: ${DELETE_TAKES_NO_BODY}`);
216
+ if (text.trimStart().charCodeAt(0) === 45 /* - */) throw new Error(`line ${lineNum}: ${MINUS_ROW_REJECTED}`);
217
+ if (!this.#warnings.includes(BARE_BODY_AUTO_PIPED_WARNING)) this.#warnings.push(BARE_BODY_AUTO_PIPED_WARNING);
218
+ this.#pending.payloads.push({ kind: "literal", text, lineNum });
423
219
  return;
424
220
  }
425
-
426
- // Whitespace-only raw lines outside any pending block are silently
427
- // dropped; fully empty lines arrive as `blank` tokens.
428
221
  if (text.trim().length === 0) return;
429
-
430
222
  throw new Error(
431
223
  `line ${lineNum}: payload line has no preceding hunk header. ` +
432
- `Use an \`A B\` (or \`BOF\` / \`EOF\`) line above the body. Got ${JSON.stringify(text)}.`,
224
+ `Use \`replace N..M:\`, \`delete N..M\`, or \`insert before|after|head|tail:\` above the body. Got ${JSON.stringify(text)}.`,
433
225
  );
434
226
  }
435
227
 
@@ -444,112 +236,62 @@ export class Executor {
444
236
  });
445
237
  }
446
238
 
447
- #pushRepeat(cursor: Cursor, range: ParsedRange, lineNum: number, mode?: "replacement"): void {
448
- this.#edits.push({
449
- kind: "repeat",
450
- cursor: cloneCursor(cursor),
451
- range: { start: { ...range.start }, end: { ...range.end } },
452
- lineNum,
453
- index: this.#editIndex++,
454
- ...(mode === undefined ? {} : { mode }),
455
- });
456
- }
457
-
458
239
  #pushDelete(anchor: Anchor, lineNum: number): void {
459
240
  this.#edits.push({ kind: "delete", anchor: { ...anchor }, lineNum, index: this.#editIndex++ });
460
241
  }
461
242
 
462
- #emitPayloadRow(cursor: Cursor, payload: PayloadRow, lineNum: number, mode?: "replacement"): void {
463
- if (payload.kind === "literal") {
464
- this.#pushInsert(cursor, payload.text, lineNum, mode);
465
- return;
466
- }
467
- this.#pushRepeat(cursor, payload.range, lineNum, mode);
243
+ #emitPayloadRows(cursor: Cursor, payloads: readonly PayloadRow[], lineNum: number, mode?: "replacement"): void {
244
+ for (const payload of payloads) this.#pushInsert(cursor, payload.text, lineNum, mode);
468
245
  }
469
246
 
470
247
  #flushPending(): void {
471
248
  const pending = this.#pending;
472
249
  if (!pending) return;
473
-
474
- // Convert any buffered bare body rows to literal payloads. Mixed
475
- // blocks have already been rejected; we only get here when payloads
476
- // `pendingRaws` is kept for type compatibility but no longer used —
477
- // bare rows are now pushed directly into `payloads` as literals at
478
- // arrival time (preserving body-row order).
479
250
  const { target, lineNum, payloads } = pending;
480
- if (target.kind === "bof" || target.kind === "eof") {
481
- const cursor: Cursor = target.kind === "bof" ? { kind: "bof" } : { kind: "eof" };
482
- for (const payload of payloads) {
483
- this.#emitPayloadRow(cursor, payload, lineNum);
484
- }
485
- // Empty body at BOF/EOF is a no-op (nothing to insert).
486
- this.#pending = undefined;
251
+ this.#pending = undefined;
252
+ if (target.kind === "delete") {
253
+ for (const anchor of expandRange(target.range)) this.#pushDelete(anchor, lineNum);
487
254
  return;
488
255
  }
489
-
490
- const cursor: Cursor = { kind: "before_anchor", anchor: { ...target.range.start } };
491
- // Empty body = pure delete. Otherwise, emit the body rows as
492
- // replacement payload and delete the original range.
493
- for (const payload of payloads) {
494
- this.#emitPayloadRow(cursor, payload, lineNum, "replacement");
256
+ if (payloads.length === 0) {
257
+ if (target.kind === "replace") throw new Error(`line ${lineNum}: ${EMPTY_REPLACE}`);
258
+ throw new Error(`line ${lineNum}: ${EMPTY_INSERT}`);
495
259
  }
496
- for (const anchor of expandRange(target.range)) {
497
- this.#pushDelete(anchor, lineNum);
260
+ if (target.kind === "replace") {
261
+ const cursor: Cursor = { kind: "before_anchor", anchor: { ...target.range.start } };
262
+ this.#emitPayloadRows(cursor, payloads, lineNum, "replacement");
263
+ for (const anchor of expandRange(target.range)) this.#pushDelete(anchor, lineNum);
264
+ return;
498
265
  }
499
- this.#pending = undefined;
266
+ if (target.kind === "insert_before") {
267
+ this.#emitPayloadRows({ kind: "before_anchor", anchor: { ...target.anchor } }, payloads, lineNum);
268
+ return;
269
+ }
270
+ if (target.kind === "insert_after") {
271
+ this.#emitPayloadRows({ kind: "after_anchor", anchor: { ...target.anchor } }, payloads, lineNum);
272
+ return;
273
+ }
274
+ const cursor: Cursor = target.kind === "bof" ? { kind: "bof" } : { kind: "eof" };
275
+ this.#emitPayloadRows(cursor, payloads, lineNum);
500
276
  }
501
277
  }
502
278
 
503
- /**
504
- * Drive a full hashline diff through the tokenizer + executor pipeline and
505
- * return the resulting edits plus any parse-time warnings. This is the
506
- * convenience entry point most callers want; reach for {@link Tokenizer} /
507
- * {@link Executor} directly only when you need streaming feeds, cross-section
508
- * state, or custom token handling.
509
- */
279
+ function drain(executor: Executor, tokenizer: Tokenizer): { edits: Edit[]; warnings: string[] } {
280
+ for (const token of tokenizer.end()) executor.feed(token);
281
+ return executor.end();
282
+ }
283
+
510
284
  export function parsePatch(diff: string): { edits: Edit[]; warnings: string[] } {
511
285
  const tokenizer = new Tokenizer();
512
286
  const executor = new Executor();
513
- const drain = (tokens: Token[]): void => {
514
- for (const token of tokens) {
515
- if (executor.terminated) return;
516
- executor.feed(token);
517
- }
518
- };
519
- drain(tokenizer.feed(diff));
520
- drain(tokenizer.end());
521
- return executor.end();
287
+ for (const token of tokenizer.feed(diff)) executor.feed(token);
288
+ return drain(executor, tokenizer);
522
289
  }
523
290
 
524
- /**
525
- * Streaming-tolerant variant of {@link parsePatch}. Returns whatever edits
526
- * parsed successfully when the diff is still being typed:
527
- *
528
- * - per-token feed errors stop the drain but preserve the edits already
529
- * collected (the trailing hunk is malformed mid-stream — wait for the
530
- * next chunk),
531
- * - the trailing pending hunk is dropped if it has no payload yet (avoids
532
- * a destructive bare-delete preview while payload may still be coming).
533
- *
534
- * Throws only on the cross-hunk overlap validator, which catches conflicting
535
- * shapes (two hunks hitting the same anchor). Streaming preview callers
536
- * should treat any throw here as "no preview this tick".
537
- */
538
291
  export function parsePatchStreaming(diff: string): { edits: Edit[]; warnings: string[] } {
539
292
  const tokenizer = new Tokenizer();
540
293
  const executor = new Executor();
541
- const drain = (tokens: Token[]): boolean => {
542
- for (const token of tokens) {
543
- if (executor.terminated) return false;
544
- try {
545
- executor.feed(token);
546
- } catch {
547
- return true; // stop on first parse error; keep what's collected
548
- }
549
- }
550
- return false;
551
- };
552
- if (drain(tokenizer.feed(diff))) return executor.endStreaming();
553
- drain(tokenizer.end());
294
+ for (const token of tokenizer.feed(diff)) executor.feed(token);
295
+ for (const token of tokenizer.end()) executor.feed(token);
554
296
  return executor.endStreaming();
555
297
  }
package/src/patcher.ts CHANGED
@@ -23,14 +23,14 @@
23
23
  * filesystem configuration.
24
24
  */
25
25
  import { applyEdits } from "./apply";
26
- import { formatHashlineHeader, HL_FILE_HASH_SEP, HL_FILE_PREFIX } from "./format";
26
+ import { computeFileHash, formatHashlineHeader, HL_FILE_HASH_SEP, HL_FILE_PREFIX } from "./format";
27
27
  import type { Filesystem, WriteResult } from "./fs";
28
28
  import { isNotFound } from "./fs";
29
29
  import type { Patch, PatchSection } from "./input";
30
30
  import { MismatchError } from "./mismatch";
31
31
  import { detectLineEnding, type LineEnding, normalizeToLF, restoreLineEndings, stripBom } from "./normalize";
32
32
  import { Recovery, type RecoveryResult } from "./recovery";
33
- import type { Snapshot, SnapshotStore } from "./snapshots";
33
+ import type { SnapshotStore } from "./snapshots";
34
34
  import type { ApplyResult, Edit } from "./types";
35
35
 
36
36
  export interface PatcherOptions {
@@ -97,8 +97,8 @@ export class PreparedSection {
97
97
 
98
98
  function hasAnchorScopedEdit(edits: readonly Edit[]): boolean {
99
99
  return edits.some(edit => {
100
- if (edit.kind === "delete" || edit.kind === "repeat") return true;
101
- return edit.cursor.kind === "before_anchor";
100
+ if (edit.kind === "delete") return true;
101
+ return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor";
102
102
  });
103
103
  }
104
104
 
@@ -116,25 +116,6 @@ function recoveryToApplyResult(result: RecoveryResult): ApplyResult {
116
116
  warnings: result.warnings,
117
117
  };
118
118
  }
119
-
120
- /**
121
- * Decide whether `snapshot` proves the live file is byte-for-byte the read
122
- * the model authored against. Two shapes:
123
- * - Full-text snapshot: cheap string equality.
124
- * - Sparse snapshot (e.g. selector reads, search hits): every anchor line
125
- * must be in the snapshot AND every recorded line must match the live
126
- * file. Without this branch, sparse reads can't short-circuit and fall
127
- * through to recovery, which declines them as "patcher-owned direct
128
- * apply" — yielding a spurious MismatchError on unchanged files.
129
- */
130
- function snapshotProvesUnchanged(snapshot: Snapshot, currentText: string, section: PatchSection): boolean {
131
- if (snapshot.fullText !== undefined) return snapshot.fullText === currentText;
132
- for (const lineNumber of section.collectAnchorLines()) {
133
- if (snapshot.get(lineNumber) === undefined) return false;
134
- }
135
- return snapshot.matchesLiveFile(currentText.split("\n"));
136
- }
137
-
138
119
  function mergeWarnings(...sources: ReadonlyArray<readonly string[] | undefined>): string[] {
139
120
  const out: string[] = [];
140
121
  for (const source of sources) {
@@ -324,9 +305,8 @@ export class Patcher {
324
305
  }
325
306
 
326
307
  #recordFullSnapshot(canonicalPath: string, normalized: string): string {
327
- return this.snapshots.recordContiguous(canonicalPath, 1, normalized.split("\n"), { fullText: normalized });
308
+ return this.snapshots.record(canonicalPath, normalized);
328
309
  }
329
-
330
310
  #applyWithRecovery(args: {
331
311
  section: PatchSection;
332
312
  canonicalPath: string;
@@ -337,29 +317,27 @@ export class Patcher {
337
317
  const { section, canonicalPath, exists, normalized, edits } = args;
338
318
  const expected = exists ? section.fileHash : undefined;
339
319
  if (expected === undefined) return applyEdits(normalized, [...edits]);
340
-
341
- const snapshot = this.snapshots.byHash(canonicalPath, expected);
342
- if (snapshot && snapshotProvesUnchanged(snapshot, normalized, section)) {
343
- return applyEdits(normalized, [...edits]);
344
- }
345
- if (snapshot) {
346
- const recovered = this.recovery.tryRecover({
347
- path: canonicalPath,
348
- currentText: normalized,
349
- fileHash: expected,
350
- edits,
351
- });
352
- if (recovered) return recoveryToApplyResult(recovered);
353
- }
354
-
355
- const currentHash = this.#recordFullSnapshot(canonicalPath, normalized);
320
+ // Whole-file unchanged → the tag still names the live content, so an
321
+ // edit anchored at ANY line (displayed or not) is safe to apply.
322
+ if (computeFileHash(normalized) === expected) return applyEdits(normalized, [...edits]);
323
+ // File drifted: try to replay the edit against the version the tag
324
+ // names and 3-way-merge it onto the live content.
325
+ const recovered = this.recovery.tryRecover({
326
+ path: canonicalPath,
327
+ currentText: normalized,
328
+ fileHash: expected,
329
+ edits,
330
+ });
331
+ if (recovered) return recoveryToApplyResult(recovered);
332
+ const hashRecognized = this.snapshots.byHash(canonicalPath, expected) !== null;
333
+ const actualFileHash = this.#recordFullSnapshot(canonicalPath, normalized);
356
334
  throw new MismatchError({
357
335
  path: section.path,
358
336
  expectedFileHash: expected,
359
- actualFileHash: currentHash,
337
+ actualFileHash,
360
338
  fileLines: normalized.split("\n"),
361
339
  anchorLines: section.collectAnchorLines(),
362
- hashRecognized: snapshot !== null,
340
+ hashRecognized,
363
341
  });
364
342
  }
365
343
  }