@oh-my-pi/hashline 17.2.0 → 17.2.2
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/CHANGELOG.md +20 -0
- package/README.md +7 -7
- package/dist/types/clipboard.d.ts +6 -4
- package/dist/types/format.d.ts +21 -27
- package/dist/types/messages.d.ts +48 -27
- package/dist/types/parser.d.ts +2 -1
- package/dist/types/prefixes.d.ts +2 -0
- package/dist/types/tokenizer.d.ts +12 -8
- package/dist/types/types.d.ts +53 -25
- package/package.json +2 -2
- package/src/apply.ts +72 -1
- package/src/block.ts +27 -5
- package/src/clipboard.ts +141 -42
- package/src/format.ts +35 -34
- package/src/grammar.lark +12 -11
- package/src/input.ts +14 -10
- package/src/messages.ts +129 -65
- package/src/parser.ts +307 -103
- package/src/patcher.ts +8 -8
- package/src/prefixes.ts +12 -4
- package/src/prompt.md +37 -38
- package/src/recovery.ts +56 -15
- package/src/tokenizer.ts +160 -191
- package/src/types.ts +48 -25
package/src/parser.ts
CHANGED
|
@@ -7,19 +7,28 @@ import { HL_PAYLOAD_REPLACE, HL_RANGE_SEP } from "./format";
|
|
|
7
7
|
import {
|
|
8
8
|
type AbsoluteRangeOp,
|
|
9
9
|
BARE_BODY_AUTO_PIPED_WARNING,
|
|
10
|
+
BARE_RANGE_AUTO_PUT_WARNING,
|
|
11
|
+
COLON_ON_REGISTER_PUT,
|
|
12
|
+
COLONLESS_PUT_TAKES_NO_BODY,
|
|
13
|
+
COLONLESS_SPAN_PUT,
|
|
14
|
+
CUT_COLON_IGNORED_WARNING,
|
|
10
15
|
CUT_TAKES_NO_BODY,
|
|
11
|
-
|
|
16
|
+
DIFF_OLD_ROWS_IGNORED_WARNING,
|
|
12
17
|
EMPTY_INSERT,
|
|
18
|
+
EMPTY_PUT_AUTO_CUT_WARNING,
|
|
13
19
|
invalidAbsoluteRangeMessage,
|
|
14
20
|
MINUS_BULLET_AUTO_PIPED_WARNING,
|
|
15
21
|
MINUS_ROW_REJECTED,
|
|
16
22
|
MOVE_TAKES_NO_BODY,
|
|
17
|
-
|
|
23
|
+
READ_METADATA_IGNORED_WARNING,
|
|
24
|
+
REGISTER_PUT_TAKES_NO_BODY,
|
|
18
25
|
REM_TAKES_NO_BODY,
|
|
26
|
+
REPLACE_PAIR_COALESCED_WARNING,
|
|
27
|
+
SNAPSHOT_ROWS_AUTO_PUT_WARNING,
|
|
19
28
|
} from "./messages";
|
|
20
|
-
import { stripOneLeadingHashlinePrefix } from "./prefixes";
|
|
29
|
+
import { isReadMetadataLine, stripOneLeadingHashlinePrefix } from "./prefixes";
|
|
21
30
|
import { type BlockTarget, cloneCursor, type ParsedRange, type Token, Tokenizer } from "./tokenizer";
|
|
22
|
-
import type { Anchor, BlockSpan, Cursor, Edit, FileOp } from "./types";
|
|
31
|
+
import type { Anchor, BlockSpan, Cursor, Edit, FileOp, PasteTarget } from "./types";
|
|
23
32
|
|
|
24
33
|
/** Bounds parser amplification before the target file's line count is available. */
|
|
25
34
|
const MAX_EXPANDED_RANGE_LINES = 100_000;
|
|
@@ -33,23 +42,32 @@ export class InvalidAbsoluteRangeError extends Error {
|
|
|
33
42
|
readonly endLine: number;
|
|
34
43
|
/** Operation whose range was invalid. */
|
|
35
44
|
readonly op: AbsoluteRangeOp;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
45
|
+
readonly register?: string;
|
|
46
|
+
|
|
47
|
+
constructor(
|
|
48
|
+
patchLine: number,
|
|
49
|
+
startLine: number,
|
|
50
|
+
endLine: number,
|
|
51
|
+
op: AbsoluteRangeOp,
|
|
52
|
+
block?: BlockSpan,
|
|
53
|
+
register?: string,
|
|
54
|
+
) {
|
|
55
|
+
super(invalidAbsoluteRangeMessage(patchLine, startLine, endLine, op, block, register));
|
|
39
56
|
this.name = "InvalidAbsoluteRangeError";
|
|
40
57
|
this.patchLine = patchLine;
|
|
41
58
|
this.startLine = startLine;
|
|
42
59
|
this.endLine = endLine;
|
|
43
60
|
this.op = op;
|
|
61
|
+
this.register = register;
|
|
44
62
|
}
|
|
45
63
|
|
|
46
64
|
/** Rebuild this error with a proven syntactic-block endpoint suggestion. */
|
|
47
65
|
withBlock(block: BlockSpan): InvalidAbsoluteRangeError {
|
|
48
|
-
return new InvalidAbsoluteRangeError(this.patchLine, this.startLine, this.endLine, this.op, block);
|
|
66
|
+
return new InvalidAbsoluteRangeError(this.patchLine, this.startLine, this.endLine, this.op, block, this.register);
|
|
49
67
|
}
|
|
50
68
|
}
|
|
51
69
|
|
|
52
|
-
function validateRange(range: ParsedRange, lineNum: number, op: AbsoluteRangeOp): void {
|
|
70
|
+
function validateRange(range: ParsedRange, lineNum: number, op: AbsoluteRangeOp, register?: string): void {
|
|
53
71
|
if (
|
|
54
72
|
!Number.isSafeInteger(range.start.line) ||
|
|
55
73
|
range.start.line < 1 ||
|
|
@@ -61,7 +79,7 @@ function validateRange(range: ParsedRange, lineNum: number, op: AbsoluteRangeOp)
|
|
|
61
79
|
);
|
|
62
80
|
}
|
|
63
81
|
if (range.end.line < range.start.line) {
|
|
64
|
-
throw new InvalidAbsoluteRangeError(lineNum, range.start.line, range.end.line, op);
|
|
82
|
+
throw new InvalidAbsoluteRangeError(lineNum, range.start.line, range.end.line, op, undefined, register);
|
|
65
83
|
}
|
|
66
84
|
const span = range.end.line - range.start.line + 1;
|
|
67
85
|
if (span > MAX_EXPANDED_RANGE_LINES) {
|
|
@@ -76,20 +94,15 @@ function isSkippableCommentLine(line: string): boolean {
|
|
|
76
94
|
}
|
|
77
95
|
|
|
78
96
|
/**
|
|
79
|
-
* Body-row rejection message for
|
|
80
|
-
* for
|
|
97
|
+
* Body-row rejection message for ops that take no `+TEXT` rows, or `null`
|
|
98
|
+
* for ops whose header (`:`) promises a body.
|
|
81
99
|
*/
|
|
82
|
-
function bodylessTargetMessage(target: BlockTarget): string | null {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
case "paste_after_block":
|
|
89
|
-
return PASTE_TAKES_NO_BODY;
|
|
90
|
-
default:
|
|
91
|
-
return null;
|
|
92
|
-
}
|
|
100
|
+
function bodylessTargetMessage(target: BlockTarget, hadColon: boolean): string | null {
|
|
101
|
+
if (target.kind === "cut" || target.kind === "cut_block") return CUT_TAKES_NO_BODY;
|
|
102
|
+
if (target.kind === "rem" || target.kind === "move") return null;
|
|
103
|
+
if (target.register !== undefined) return REGISTER_PUT_TAKES_NO_BODY;
|
|
104
|
+
if (!hadColon) return COLONLESS_PUT_TAKES_NO_BODY;
|
|
105
|
+
return null;
|
|
93
106
|
}
|
|
94
107
|
|
|
95
108
|
/**
|
|
@@ -99,6 +112,26 @@ function bodylessTargetMessage(target: BlockTarget): string | null {
|
|
|
99
112
|
*/
|
|
100
113
|
const BARE_LITERAL_VALUE_RE = /^\s*(?:"[^"]*"|'[^']*'|[-+]?\d+(?:\.\d+)?)\s*,?\s*$/;
|
|
101
114
|
|
|
115
|
+
const TOP_LEVEL_SNAPSHOT_ROW_RE = /^\s*([1-9]\d*):(.*)$/;
|
|
116
|
+
|
|
117
|
+
function parseTopLevelSnapshotRow(text: string): { line: number; text: string } | null {
|
|
118
|
+
const match = TOP_LEVEL_SNAPSHOT_ROW_RE.exec(text);
|
|
119
|
+
if (match === null) return null;
|
|
120
|
+
const line = Number(match[1]);
|
|
121
|
+
if (!Number.isSafeInteger(line)) return null;
|
|
122
|
+
return { line, text: match[2] };
|
|
123
|
+
}
|
|
124
|
+
const TOP_LEVEL_BARE_RANGE_HEADER_RE = /^\s*([1-9]\d*)(?:\s|[-.=…])+([1-9]\d*)\s*:\s*$/;
|
|
125
|
+
|
|
126
|
+
function parseTopLevelBareRangeHeader(text: string): ParsedRange | null {
|
|
127
|
+
const match = TOP_LEVEL_BARE_RANGE_HEADER_RE.exec(text);
|
|
128
|
+
if (match === null) return null;
|
|
129
|
+
const start = Number(match[1]);
|
|
130
|
+
const end = Number(match[2]);
|
|
131
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end)) return null;
|
|
132
|
+
return { start: { line: start }, end: { line: end } };
|
|
133
|
+
}
|
|
134
|
+
|
|
102
135
|
/**
|
|
103
136
|
* Markdown-bullet shape: optional indent, `-`, exactly one space, then
|
|
104
137
|
* content. Unified-diff `-` rows almost never match — code lines get the `-`
|
|
@@ -119,36 +152,30 @@ function detectApplyPatchContamination(text: string, _hasPending: boolean): stri
|
|
|
119
152
|
return (
|
|
120
153
|
`apply_patch sentinel ${JSON.stringify(preview)} is not valid in hashline. ` +
|
|
121
154
|
"File sections start with `[path#HASH]` (no `Update File:` / `Add File:` keyword). " +
|
|
122
|
-
`Use \`
|
|
155
|
+
`Use \`PUT N${HL_RANGE_SEP}M:\`, \`CUT N${HL_RANGE_SEP}M\`, or \`PUT <N:\`/\`PUT >N:\` ops.`
|
|
123
156
|
);
|
|
124
157
|
}
|
|
125
158
|
if (/^@@\s+[-+]?\d+,\d+\s+[-+]?\d+,\d+\s+@@/.test(trimmed)) {
|
|
126
159
|
return (
|
|
127
160
|
"unified-diff hunk header (`@@ -N,M +N,M @@`) is not valid in hashline. " +
|
|
128
|
-
`Use \`
|
|
161
|
+
`Use \`PUT N${HL_RANGE_SEP}M:\`, \`CUT N${HL_RANGE_SEP}M\`, or \`PUT <N:\`/\`PUT >N:\` ops.`
|
|
129
162
|
);
|
|
130
163
|
}
|
|
131
164
|
if (trimmed.startsWith("@@")) {
|
|
132
165
|
const preview = trimmed.length > 48 ? `${trimmed.slice(0, 48)}…` : trimmed;
|
|
133
166
|
return (
|
|
134
167
|
`\`@@\`-bracketed hunk header ${JSON.stringify(preview)} is not valid in hashline. ` +
|
|
135
|
-
`Drop the \`@@ ... @@\` brackets and write a
|
|
168
|
+
`Drop the \`@@ ... @@\` brackets and write a header such as \`PUT N${HL_RANGE_SEP}M:\`.`
|
|
136
169
|
);
|
|
137
170
|
}
|
|
138
|
-
// Bare `PASTE` (optionally `PASTE 5` / `PASTE:`) — the op requires an
|
|
139
|
-
// explicit position suffix; a bare form would otherwise surface as a
|
|
140
|
-
// confusing body-row rejection under the preceding hunk.
|
|
141
|
-
if (/^PASTE(?:\s+[1-9]\d*)?\s*:?\s*$/.test(trimmed)) {
|
|
142
|
-
return "`PASTE` needs a position: use `PASTE.PRE N` / `PASTE.POST N` / `PASTE.HEAD` / `PASTE.TAIL` / `PASTE.BLK.POST N`.";
|
|
143
|
-
}
|
|
144
171
|
if (/^[1-9]\d*\s*$/.test(trimmed)) {
|
|
145
|
-
return `hunk headers need a verb. Use \`
|
|
172
|
+
return `hunk headers need a verb and both endpoints. Use \`PUT ${trimmed}${HL_RANGE_SEP}${trimmed}:\` to replace, or \`CUT ${trimmed}${HL_RANGE_SEP}${trimmed}\` to delete.`;
|
|
146
173
|
}
|
|
147
|
-
const bareRange = /^([1-9]\d*)\s
|
|
174
|
+
const bareRange = /^([1-9]\d*)\s+(?:[1-9]\d*)\s*:?$/.exec(trimmed);
|
|
148
175
|
if (bareRange !== null) {
|
|
149
176
|
return (
|
|
150
177
|
`bare range hunk header ${JSON.stringify(trimmed)} is not valid. ` +
|
|
151
|
-
`Hunk headers need a verb:
|
|
178
|
+
`Hunk headers need a verb: use \`PUT N${HL_RANGE_SEP}M:\` or \`CUT N${HL_RANGE_SEP}M\`.`
|
|
152
179
|
);
|
|
153
180
|
}
|
|
154
181
|
return null;
|
|
@@ -165,6 +192,8 @@ interface Pending {
|
|
|
165
192
|
target: BlockTarget;
|
|
166
193
|
lineNum: number;
|
|
167
194
|
payloads: PayloadRow[];
|
|
195
|
+
/** Whether the header carried `:` — the promise that body rows follow. */
|
|
196
|
+
hadColon: boolean;
|
|
168
197
|
/**
|
|
169
198
|
* Blank rows seen after the body started. Interior blanks are committed to
|
|
170
199
|
* the payload when the next non-blank row arrives; trailing blanks before
|
|
@@ -225,27 +254,45 @@ export class Executor {
|
|
|
225
254
|
this.#consumePendingSkippableComments();
|
|
226
255
|
this.#handleRaw(token.text, token.lineNum);
|
|
227
256
|
return;
|
|
228
|
-
case "op-block":
|
|
257
|
+
case "op-block": {
|
|
229
258
|
this.#discardPendingSkippableComments();
|
|
230
|
-
|
|
231
|
-
|
|
259
|
+
const target = token.target;
|
|
260
|
+
if (target.kind === "replace") {
|
|
261
|
+
validateRange(target.range, token.lineNum, "replace", target.register);
|
|
262
|
+
}
|
|
263
|
+
if (target.kind === "cut") {
|
|
264
|
+
validateRange(target.range, token.lineNum, "cut", target.register);
|
|
265
|
+
}
|
|
266
|
+
// `:` exclusively promises body rows; ops that never take a body
|
|
267
|
+
// reject it outright so the sigil keeps one meaning.
|
|
268
|
+
if (token.hadColon && (target.kind === "cut" || target.kind === "cut_block")) {
|
|
269
|
+
if (!this.#warnings.includes(CUT_COLON_IGNORED_WARNING)) {
|
|
270
|
+
this.#warnings.push(CUT_COLON_IGNORED_WARNING);
|
|
271
|
+
}
|
|
232
272
|
}
|
|
233
|
-
if (token.target.kind
|
|
234
|
-
|
|
273
|
+
if (token.hadColon && target.kind !== "rem" && target.kind !== "move" && target.register !== undefined) {
|
|
274
|
+
throw new Error(`line ${token.lineNum}: ${COLON_ON_REGISTER_PUT}`);
|
|
235
275
|
}
|
|
236
|
-
if (
|
|
276
|
+
if (target.kind === "rem") {
|
|
237
277
|
this.#flushPending();
|
|
238
278
|
this.#setFileOp({ kind: "rem" }, token.lineNum);
|
|
239
279
|
return;
|
|
240
280
|
}
|
|
241
|
-
if (
|
|
281
|
+
if (target.kind === "move") {
|
|
242
282
|
this.#flushPending();
|
|
243
|
-
this.#setFileOp({ kind: "move", dest:
|
|
283
|
+
this.#setFileOp({ kind: "move", dest: target.dest }, token.lineNum);
|
|
244
284
|
return;
|
|
245
285
|
}
|
|
246
286
|
this.#flushPending();
|
|
247
|
-
this.#pending = {
|
|
287
|
+
this.#pending = {
|
|
288
|
+
target,
|
|
289
|
+
lineNum: token.lineNum,
|
|
290
|
+
payloads: [],
|
|
291
|
+
hadColon: token.hadColon,
|
|
292
|
+
deferredBlanks: [],
|
|
293
|
+
};
|
|
248
294
|
return;
|
|
295
|
+
}
|
|
249
296
|
}
|
|
250
297
|
}
|
|
251
298
|
|
|
@@ -253,7 +300,7 @@ export class Executor {
|
|
|
253
300
|
this.#consumePendingSkippableComments();
|
|
254
301
|
this.#flushPending();
|
|
255
302
|
this.#validateFileOp();
|
|
256
|
-
this.#
|
|
303
|
+
this.#normalizeOverlappingRanges();
|
|
257
304
|
return {
|
|
258
305
|
edits: this.#edits,
|
|
259
306
|
...(this.#fileOp === undefined ? {} : { fileOp: this.#fileOp }),
|
|
@@ -263,11 +310,11 @@ export class Executor {
|
|
|
263
310
|
|
|
264
311
|
endStreaming(): { edits: Edit[]; fileOp?: FileOp; warnings: string[] } {
|
|
265
312
|
this.#consumePendingSkippableComments();
|
|
266
|
-
|
|
267
|
-
|
|
313
|
+
const pending = this.#pending;
|
|
314
|
+
if (pending && (pending.payloads.length > 0 || this.#isCompleteBodylessOp(pending))) this.#flushPending();
|
|
268
315
|
else this.#pending = undefined;
|
|
269
316
|
this.#validateFileOp();
|
|
270
|
-
this.#
|
|
317
|
+
this.#normalizeOverlappingRanges();
|
|
271
318
|
return {
|
|
272
319
|
edits: this.#edits,
|
|
273
320
|
...(this.#fileOp === undefined ? {} : { fileOp: this.#fileOp }),
|
|
@@ -275,6 +322,27 @@ export class Executor {
|
|
|
275
322
|
};
|
|
276
323
|
}
|
|
277
324
|
|
|
325
|
+
/**
|
|
326
|
+
* True when a payload-less pending op is already a complete, valid op —
|
|
327
|
+
* safe to flush at the end of a streaming parse. A `:`-op still awaiting
|
|
328
|
+
* body rows and the invalid colonless-span shape (possibly a truncated
|
|
329
|
+
* `PUT 5-9 @reg` line) are dropped instead.
|
|
330
|
+
*/
|
|
331
|
+
#isCompleteBodylessOp(pending: Pending): boolean {
|
|
332
|
+
const { target, hadColon } = pending;
|
|
333
|
+
if (target.kind === "cut" || target.kind === "cut_block") return true;
|
|
334
|
+
if (target.kind === "rem" || target.kind === "move") return false;
|
|
335
|
+
if (target.register !== undefined) return true;
|
|
336
|
+
if (hadColon) return false;
|
|
337
|
+
return (
|
|
338
|
+
target.kind === "insert_before" ||
|
|
339
|
+
target.kind === "insert_after" ||
|
|
340
|
+
target.kind === "insert_after_block" ||
|
|
341
|
+
target.kind === "bof" ||
|
|
342
|
+
target.kind === "eof"
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
278
346
|
reset(): void {
|
|
279
347
|
this.#edits = [];
|
|
280
348
|
this.#warnings = [];
|
|
@@ -304,25 +372,78 @@ export class Executor {
|
|
|
304
372
|
}
|
|
305
373
|
}
|
|
306
374
|
|
|
307
|
-
#
|
|
308
|
-
|
|
375
|
+
#normalizeOverlappingRanges(): void {
|
|
376
|
+
type ConcreteHunk = {
|
|
377
|
+
lineNum: number;
|
|
378
|
+
sourceLines: Set<number>;
|
|
379
|
+
clipboardDependent: boolean;
|
|
380
|
+
};
|
|
381
|
+
const hunks = new Map<number, ConcreteHunk>();
|
|
382
|
+
const hunkFor = (lineNum: number): ConcreteHunk => {
|
|
383
|
+
let hunk = hunks.get(lineNum);
|
|
384
|
+
if (hunk === undefined) {
|
|
385
|
+
hunk = { lineNum, sourceLines: new Set(), clipboardDependent: false };
|
|
386
|
+
hunks.set(lineNum, hunk);
|
|
387
|
+
}
|
|
388
|
+
return hunk;
|
|
389
|
+
};
|
|
309
390
|
for (const edit of this.#edits) {
|
|
310
|
-
if (edit.kind
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
391
|
+
if (edit.kind === "cut") {
|
|
392
|
+
hunkFor(edit.lineNum).clipboardDependent = true;
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
if (edit.kind === "paste" && edit.at.kind === "span") {
|
|
396
|
+
const hunk = hunkFor(edit.lineNum);
|
|
397
|
+
hunk.clipboardDependent = true;
|
|
398
|
+
for (let line = edit.at.range.start.line; line <= edit.at.range.end.line; line++) {
|
|
399
|
+
hunk.sourceLines.add(line);
|
|
400
|
+
}
|
|
401
|
+
continue;
|
|
315
402
|
}
|
|
316
|
-
if (
|
|
403
|
+
if (edit.kind === "delete") hunkFor(edit.lineNum).sourceLines.add(edit.anchor.line);
|
|
317
404
|
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
405
|
+
|
|
406
|
+
const ownerByLine = new Map<number, ConcreteHunk>();
|
|
407
|
+
const dropped = new Set<number>();
|
|
408
|
+
const claim = (hunk: ConcreteHunk): void => {
|
|
409
|
+
for (const line of hunk.sourceLines) ownerByLine.set(line, hunk);
|
|
410
|
+
};
|
|
411
|
+
for (const hunk of hunks.values()) {
|
|
412
|
+
if (hunk.sourceLines.size === 0) continue;
|
|
413
|
+
const overlaps = new Set<ConcreteHunk>();
|
|
414
|
+
let firstOverlap: number | undefined;
|
|
415
|
+
for (const line of hunk.sourceLines) {
|
|
416
|
+
const owner = ownerByLine.get(line);
|
|
417
|
+
if (owner === undefined) continue;
|
|
418
|
+
overlaps.add(owner);
|
|
419
|
+
firstOverlap ??= line;
|
|
420
|
+
}
|
|
421
|
+
if (overlaps.size === 0) {
|
|
422
|
+
claim(hunk);
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
const previous = overlaps.size === 1 ? overlaps.values().next().value : undefined;
|
|
426
|
+
const exact =
|
|
427
|
+
previous !== undefined &&
|
|
428
|
+
previous.sourceLines.size === hunk.sourceLines.size &&
|
|
429
|
+
[...hunk.sourceLines].every(line => previous.sourceLines.has(line));
|
|
430
|
+
if (exact && !previous.clipboardDependent) {
|
|
431
|
+
dropped.add(previous.lineNum);
|
|
432
|
+
for (const line of previous.sourceLines) {
|
|
433
|
+
if (ownerByLine.get(line) === previous) ownerByLine.delete(line);
|
|
434
|
+
}
|
|
435
|
+
claim(hunk);
|
|
436
|
+
if (!this.#warnings.includes(REPLACE_PAIR_COALESCED_WARNING)) {
|
|
437
|
+
this.#warnings.push(REPLACE_PAIR_COALESCED_WARNING);
|
|
438
|
+
}
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
321
441
|
throw new Error(
|
|
322
|
-
`line ${
|
|
442
|
+
`line ${hunk.lineNum}: anchor line ${firstOverlap} is already targeted by another hunk on line ${previous?.lineNum ?? "an earlier line"}. ` +
|
|
323
443
|
"Issue ONE hunk per range; payload is only the final desired content, never a before/after pair.",
|
|
324
444
|
);
|
|
325
445
|
}
|
|
446
|
+
if (dropped.size > 0) this.#edits = this.#edits.filter(edit => !dropped.has(edit.lineNum));
|
|
326
447
|
}
|
|
327
448
|
|
|
328
449
|
#handleLiteralPayload(text: string, lineNum: number): void {
|
|
@@ -334,13 +455,19 @@ export class Executor {
|
|
|
334
455
|
`Got ${JSON.stringify(`${HL_PAYLOAD_REPLACE}${text}`)}.`,
|
|
335
456
|
);
|
|
336
457
|
}
|
|
337
|
-
const noBodyOnLiteral = bodylessTargetMessage(pending.target);
|
|
458
|
+
const noBodyOnLiteral = bodylessTargetMessage(pending.target, pending.hadColon);
|
|
338
459
|
if (noBodyOnLiteral !== null) throw new Error(`line ${lineNum}: ${noBodyOnLiteral}`);
|
|
339
460
|
this.#commitDeferredBlanks(pending);
|
|
340
461
|
pending.payloads.push({ kind: "literal", text, lineNum });
|
|
341
462
|
}
|
|
342
463
|
|
|
343
464
|
#handleRaw(text: string, lineNum: number): void {
|
|
465
|
+
if (this.#pending === undefined && isReadMetadataLine(text)) {
|
|
466
|
+
if (!this.#warnings.includes(READ_METADATA_IGNORED_WARNING)) {
|
|
467
|
+
this.#warnings.push(READ_METADATA_IGNORED_WARNING);
|
|
468
|
+
}
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
344
471
|
const contamination = detectApplyPatchContamination(text, this.#pending !== undefined);
|
|
345
472
|
if (contamination !== null) throw new Error(`line ${lineNum}: ${contamination}`);
|
|
346
473
|
if (this.#fileOp !== undefined) throw new Error(`line ${lineNum}: ${MOVE_TAKES_NO_BODY}`);
|
|
@@ -349,7 +476,7 @@ export class Executor {
|
|
|
349
476
|
this.#handleBlank(text, lineNum);
|
|
350
477
|
return;
|
|
351
478
|
}
|
|
352
|
-
const noBodyOnRaw = bodylessTargetMessage(this.#pending.target);
|
|
479
|
+
const noBodyOnRaw = bodylessTargetMessage(this.#pending.target, this.#pending.hadColon);
|
|
353
480
|
if (noBodyOnRaw !== null) throw new Error(`line ${lineNum}: ${noBodyOnRaw}`);
|
|
354
481
|
const row: PayloadRow = { kind: "literal", text, lineNum, bare: true };
|
|
355
482
|
// `-` rows are held and judged at flush time by #resolveMinusRows,
|
|
@@ -369,9 +496,40 @@ export class Executor {
|
|
|
369
496
|
return;
|
|
370
497
|
}
|
|
371
498
|
if (text.trim().length === 0) return;
|
|
499
|
+
const bareRange = parseTopLevelBareRangeHeader(text);
|
|
500
|
+
if (bareRange !== null) {
|
|
501
|
+
validateRange(bareRange, lineNum, "replace");
|
|
502
|
+
this.#pending = {
|
|
503
|
+
target: { kind: "replace", range: bareRange },
|
|
504
|
+
lineNum,
|
|
505
|
+
payloads: [],
|
|
506
|
+
hadColon: true,
|
|
507
|
+
deferredBlanks: [],
|
|
508
|
+
};
|
|
509
|
+
if (!this.#warnings.includes(BARE_RANGE_AUTO_PUT_WARNING)) {
|
|
510
|
+
this.#warnings.push(BARE_RANGE_AUTO_PUT_WARNING);
|
|
511
|
+
}
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
const snapshotRow = parseTopLevelSnapshotRow(text);
|
|
515
|
+
if (snapshotRow !== null) {
|
|
516
|
+
const range = { start: { line: snapshotRow.line }, end: { line: snapshotRow.line } };
|
|
517
|
+
validateRange(range, lineNum, "replace");
|
|
518
|
+
this.#pushInsert(
|
|
519
|
+
{ kind: "before_anchor", anchor: { line: snapshotRow.line } },
|
|
520
|
+
snapshotRow.text,
|
|
521
|
+
lineNum,
|
|
522
|
+
"replacement",
|
|
523
|
+
);
|
|
524
|
+
this.#pushDeleteRange(range, lineNum);
|
|
525
|
+
if (!this.#warnings.includes(SNAPSHOT_ROWS_AUTO_PUT_WARNING)) {
|
|
526
|
+
this.#warnings.push(SNAPSHOT_ROWS_AUTO_PUT_WARNING);
|
|
527
|
+
}
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
372
530
|
throw new Error(
|
|
373
531
|
`line ${lineNum}: payload line has no preceding hunk header. ` +
|
|
374
|
-
`Use \`
|
|
532
|
+
`Use \`PUT N${HL_RANGE_SEP}M:\`, \`CUT N${HL_RANGE_SEP}M\`, or \`PUT <N:\`/\`PUT >N:\` above the body. Got ${JSON.stringify(text)}.`,
|
|
375
533
|
);
|
|
376
534
|
}
|
|
377
535
|
|
|
@@ -385,7 +543,7 @@ export class Executor {
|
|
|
385
543
|
#handleBlank(text: string, lineNum: number): void {
|
|
386
544
|
const pending = this.#pending;
|
|
387
545
|
if (!pending) return;
|
|
388
|
-
if (bodylessTargetMessage(pending.target) !== null) return;
|
|
546
|
+
if (bodylessTargetMessage(pending.target, pending.hadColon) !== null) return;
|
|
389
547
|
if (pending.payloads.length === 0) return;
|
|
390
548
|
pending.deferredBlanks.push({ kind: "literal", text, lineNum, bare: true });
|
|
391
549
|
}
|
|
@@ -398,15 +556,13 @@ export class Executor {
|
|
|
398
556
|
}
|
|
399
557
|
|
|
400
558
|
/**
|
|
401
|
-
* Judge bare `-` body rows once the whole hunk body is known.
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
* explicit `+- item` sibling. Those rows are kept as literal content with a
|
|
407
|
-
* warning instead of failing the patch.
|
|
559
|
+
* Judge bare `-` body rows once the whole hunk body is known. Non-bullet
|
|
560
|
+
* rows paired with explicit `+new` rows are unified-diff contamination, so
|
|
561
|
+
* discard the redundant old rows. Unambiguously literal Markdown bullets
|
|
562
|
+
* are kept. Other `-` rows remain rejected rather than silently corrupting
|
|
563
|
+
* source.
|
|
408
564
|
*/
|
|
409
|
-
#resolveMinusRows(payloads:
|
|
565
|
+
#resolveMinusRows(payloads: PayloadRow[]): void {
|
|
410
566
|
let firstMinus: PayloadRow | undefined;
|
|
411
567
|
let allBulletShaped = true;
|
|
412
568
|
let hasExplicit = false;
|
|
@@ -426,6 +582,15 @@ export class Executor {
|
|
|
426
582
|
this.#warnings.push(MINUS_BULLET_AUTO_PIPED_WARNING);
|
|
427
583
|
return;
|
|
428
584
|
}
|
|
585
|
+
if (hasExplicit && !allBulletShaped) {
|
|
586
|
+
for (let i = payloads.length - 1; i >= 0; i--) {
|
|
587
|
+
if (payloads[i].minus) payloads.splice(i, 1);
|
|
588
|
+
}
|
|
589
|
+
if (!this.#warnings.includes(DIFF_OLD_ROWS_IGNORED_WARNING)) {
|
|
590
|
+
this.#warnings.push(DIFF_OLD_ROWS_IGNORED_WARNING);
|
|
591
|
+
}
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
429
594
|
throw new Error(`line ${firstMinus.lineNum}: ${MINUS_ROW_REJECTED}`);
|
|
430
595
|
}
|
|
431
596
|
|
|
@@ -476,10 +641,11 @@ export class Executor {
|
|
|
476
641
|
for (let line = range.start.line; line <= range.end.line; line++) this.#pushDelete({ line }, lineNum);
|
|
477
642
|
}
|
|
478
643
|
|
|
479
|
-
#pushCut(range: ParsedRange, lineNum: number): void {
|
|
644
|
+
#pushCut(range: ParsedRange, lineNum: number, register: string | undefined): void {
|
|
480
645
|
this.#edits.push({
|
|
481
646
|
kind: "cut",
|
|
482
647
|
range: { start: { ...range.start }, end: { ...range.end } },
|
|
648
|
+
...(register === undefined ? {} : { register }),
|
|
483
649
|
lineNum,
|
|
484
650
|
index: this.#editIndex++,
|
|
485
651
|
});
|
|
@@ -488,17 +654,29 @@ export class Executor {
|
|
|
488
654
|
this.#pushDeleteRange(range, lineNum);
|
|
489
655
|
}
|
|
490
656
|
|
|
657
|
+
#pushPaste(at: PasteTarget, register: string | undefined, lineNum: number): void {
|
|
658
|
+
this.#edits.push({
|
|
659
|
+
kind: "paste",
|
|
660
|
+
at,
|
|
661
|
+
...(register === undefined ? {} : { register }),
|
|
662
|
+
lineNum,
|
|
663
|
+
index: this.#editIndex++,
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
|
|
491
667
|
#pushBlock(
|
|
492
668
|
anchor: Anchor,
|
|
493
669
|
payloads: readonly PayloadRow[],
|
|
494
670
|
lineNum: number,
|
|
495
671
|
mode?: "insert_after" | "cut" | "paste_after",
|
|
672
|
+
register?: string,
|
|
496
673
|
): void {
|
|
497
674
|
this.#edits.push({
|
|
498
675
|
kind: "block",
|
|
499
676
|
anchor: { ...anchor },
|
|
500
677
|
payloads: payloads.map(payload => payload.text),
|
|
501
678
|
...(mode === undefined ? {} : { mode }),
|
|
679
|
+
...(register === undefined ? {} : { register }),
|
|
502
680
|
lineNum,
|
|
503
681
|
index: this.#editIndex++,
|
|
504
682
|
});
|
|
@@ -511,58 +689,84 @@ export class Executor {
|
|
|
511
689
|
#flushPending(): void {
|
|
512
690
|
const pending = this.#pending;
|
|
513
691
|
if (!pending) return;
|
|
514
|
-
const { target, lineNum, payloads } = pending;
|
|
692
|
+
const { target, lineNum, payloads, hadColon } = pending;
|
|
515
693
|
this.#resolveMinusRows(payloads);
|
|
516
694
|
this.#stripBarePrefixesIfUniform(payloads);
|
|
517
695
|
this.#pending = undefined;
|
|
696
|
+
if (target.kind === "rem" || target.kind === "move") return;
|
|
518
697
|
if (target.kind === "cut") {
|
|
519
|
-
this.#pushCut(target.range, lineNum);
|
|
698
|
+
this.#pushCut(target.range, lineNum, target.register);
|
|
520
699
|
return;
|
|
521
700
|
}
|
|
522
701
|
if (target.kind === "cut_block") {
|
|
523
|
-
this.#pushBlock(target.anchor, [], lineNum, "cut");
|
|
702
|
+
this.#pushBlock(target.anchor, [], lineNum, "cut", target.register);
|
|
524
703
|
return;
|
|
525
704
|
}
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
705
|
+
// Span targets: body writes, register pastes over the span; the
|
|
706
|
+
// anonymous register never pastes over a span (too easy to fire by
|
|
707
|
+
// forgetting `:` + body on a replace).
|
|
708
|
+
if (target.kind === "replace") {
|
|
709
|
+
if (target.register !== undefined) {
|
|
710
|
+
this.#pushPaste(
|
|
711
|
+
{ kind: "span", range: { start: { ...target.range.start }, end: { ...target.range.end } } },
|
|
712
|
+
target.register,
|
|
713
|
+
lineNum,
|
|
714
|
+
);
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
if (payloads.length === 0) {
|
|
718
|
+
if (!hadColon) throw new Error(`line ${lineNum}: ${COLONLESS_SPAN_PUT}`);
|
|
719
|
+
this.#pushDeleteRange(target.range, lineNum);
|
|
720
|
+
if (!this.#warnings.includes(EMPTY_PUT_AUTO_CUT_WARNING)) {
|
|
721
|
+
this.#warnings.push(EMPTY_PUT_AUTO_CUT_WARNING);
|
|
722
|
+
}
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
const cursor: Cursor = { kind: "before_anchor", anchor: { ...target.range.start } };
|
|
726
|
+
this.#emitPayloadRows(cursor, payloads, lineNum, "replacement");
|
|
727
|
+
this.#pushDeleteRange(target.range, lineNum);
|
|
532
728
|
return;
|
|
533
729
|
}
|
|
534
730
|
if (target.kind === "block") {
|
|
535
|
-
if (
|
|
731
|
+
if (target.register !== undefined) {
|
|
732
|
+
this.#pushBlock(target.anchor, [], lineNum, undefined, target.register);
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
if (payloads.length === 0) {
|
|
736
|
+
if (!hadColon) throw new Error(`line ${lineNum}: ${COLONLESS_SPAN_PUT}`);
|
|
737
|
+
this.#pushBlock(target.anchor, [], lineNum);
|
|
738
|
+
if (!this.#warnings.includes(EMPTY_PUT_AUTO_CUT_WARNING)) {
|
|
739
|
+
this.#warnings.push(EMPTY_PUT_AUTO_CUT_WARNING);
|
|
740
|
+
}
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
536
743
|
this.#pushBlock(target.anchor, payloads, lineNum);
|
|
537
744
|
return;
|
|
538
745
|
}
|
|
746
|
+
// Gap targets: body inserts, register pastes, and the colonless
|
|
747
|
+
// bodyless form is an anonymous paste.
|
|
539
748
|
if (target.kind === "insert_after_block") {
|
|
540
|
-
if (payloads.length === 0)
|
|
541
|
-
|
|
542
|
-
return;
|
|
543
|
-
}
|
|
544
|
-
if (payloads.length === 0) {
|
|
545
|
-
if (target.kind === "replace") {
|
|
546
|
-
this.#pushDeleteRange(target.range, lineNum);
|
|
749
|
+
if (target.register !== undefined || (!hadColon && payloads.length === 0)) {
|
|
750
|
+
this.#pushBlock(target.anchor, [], lineNum, "paste_after", target.register);
|
|
547
751
|
return;
|
|
548
752
|
}
|
|
549
|
-
throw new Error(`line ${lineNum}: ${EMPTY_INSERT}`);
|
|
550
|
-
|
|
551
|
-
if (target.kind === "replace") {
|
|
552
|
-
const cursor: Cursor = { kind: "before_anchor", anchor: { ...target.range.start } };
|
|
553
|
-
this.#emitPayloadRows(cursor, payloads, lineNum, "replacement");
|
|
554
|
-
this.#pushDeleteRange(target.range, lineNum);
|
|
555
|
-
return;
|
|
556
|
-
}
|
|
557
|
-
if (target.kind === "insert_before") {
|
|
558
|
-
this.#emitPayloadRows({ kind: "before_anchor", anchor: { ...target.anchor } }, payloads, lineNum);
|
|
753
|
+
if (payloads.length === 0) throw new Error(`line ${lineNum}: ${EMPTY_INSERT}`);
|
|
754
|
+
this.#pushBlock(target.anchor, payloads, lineNum, "insert_after");
|
|
559
755
|
return;
|
|
560
756
|
}
|
|
561
|
-
|
|
562
|
-
|
|
757
|
+
const cursor: Cursor =
|
|
758
|
+
target.kind === "insert_before"
|
|
759
|
+
? { kind: "before_anchor", anchor: { ...target.anchor } }
|
|
760
|
+
: target.kind === "insert_after"
|
|
761
|
+
? { kind: "after_anchor", anchor: { ...target.anchor } }
|
|
762
|
+
: target.kind === "bof"
|
|
763
|
+
? { kind: "bof" }
|
|
764
|
+
: { kind: "eof" };
|
|
765
|
+
if (target.register !== undefined || (!hadColon && payloads.length === 0)) {
|
|
766
|
+
this.#pushPaste({ kind: "gap", cursor }, target.register, lineNum);
|
|
563
767
|
return;
|
|
564
768
|
}
|
|
565
|
-
|
|
769
|
+
if (payloads.length === 0) throw new Error(`line ${lineNum}: ${EMPTY_INSERT}`);
|
|
566
770
|
this.#emitPayloadRows(cursor, payloads, lineNum);
|
|
567
771
|
}
|
|
568
772
|
}
|