@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/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 {
|
|
6
|
+
import { HL_PAYLOAD_REPLACE } from "./format";
|
|
17
7
|
import {
|
|
18
8
|
BARE_BODY_AUTO_PIPED_WARNING,
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
62
|
-
return
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
|
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
|
-
`
|
|
107
|
-
`
|
|
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
|
|
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 === "
|
|
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.#
|
|
258
|
-
|
|
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 &&
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
|
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
|
-
#
|
|
463
|
-
|
|
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
|
-
|
|
481
|
-
|
|
482
|
-
for (const
|
|
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
|
-
|
|
491
|
-
|
|
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
|
-
|
|
497
|
-
|
|
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
|
-
|
|
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
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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
|
|
514
|
-
|
|
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
|
|
542
|
-
|
|
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 {
|
|
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"
|
|
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.
|
|
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
|
-
|
|
342
|
-
if (
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
|
337
|
+
actualFileHash,
|
|
360
338
|
fileLines: normalized.split("\n"),
|
|
361
339
|
anchorLines: section.collectAnchorLines(),
|
|
362
|
-
hashRecognized
|
|
340
|
+
hashRecognized,
|
|
363
341
|
});
|
|
364
342
|
}
|
|
365
343
|
}
|