@oh-my-pi/hashline 17.2.1 → 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/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
- EMPTY_BLOCK,
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
- PASTE_TAKES_NO_BODY,
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
- constructor(patchLine: number, startLine: number, endLine: number, op: AbsoluteRangeOp, block?: BlockSpan) {
38
- super(invalidAbsoluteRangeMessage(patchLine, startLine, endLine, op, block));
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 targets that take no `+TEXT` rows, or `null`
80
- * for targets whose header is followed by a body.
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
- switch (target.kind) {
84
- case "cut":
85
- case "cut_block":
86
- return CUT_TAKES_NO_BODY;
87
- case "paste":
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 \`SWAP N${HL_RANGE_SEP}M:\`, \`CUT N${HL_RANGE_SEP}M\`, or \`INS.PRE|POST|HEAD|TAIL:\` ops.`
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 \`SWAP N${HL_RANGE_SEP}M:\`, \`CUT N${HL_RANGE_SEP}M\`, or \`INS.PRE|POST|HEAD|TAIL:\` ops.`
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 verb header such as \`SWAP N${HL_RANGE_SEP}M:\`.`
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 \`SWAP ${trimmed}${HL_RANGE_SEP}${trimmed}:\` to replace, or \`CUT ${trimmed}\` to delete.`;
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*[-. …=]+\s*([1-9]\d*)\s*:?$/.exec(trimmed);
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: write \`SWAP ${bareRange[1]}${HL_RANGE_SEP}${bareRange[2]}:\` or \`CUT ${bareRange[1]}${HL_RANGE_SEP}${bareRange[2]}\`.`
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
- if (token.target.kind === "replace") {
231
- validateRange(token.target.range, token.lineNum, "replace");
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 === "cut") {
234
- validateRange(token.target.range, token.lineNum, "cut");
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 (token.target.kind === "rem") {
276
+ if (target.kind === "rem") {
237
277
  this.#flushPending();
238
278
  this.#setFileOp({ kind: "rem" }, token.lineNum);
239
279
  return;
240
280
  }
241
- if (token.target.kind === "move") {
281
+ if (target.kind === "move") {
242
282
  this.#flushPending();
243
- this.#setFileOp({ kind: "move", dest: token.target.dest }, token.lineNum);
283
+ this.#setFileOp({ kind: "move", dest: target.dest }, token.lineNum);
244
284
  return;
245
285
  }
246
286
  this.#flushPending();
247
- this.#pending = { target: token.target, lineNum: token.lineNum, payloads: [], deferredBlanks: [] };
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.#validateNoOverlappingDeletes();
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
- if (this.#pending && this.#pending.payloads.length > 0) this.#flushPending();
267
- else if (this.#pending && bodylessTargetMessage(this.#pending.target) !== null) this.#flushPending();
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.#validateNoOverlappingDeletes();
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
- #validateNoOverlappingDeletes(): void {
308
- const sourceLinesByAnchor = new Map<number, number[]>();
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 !== "delete") continue;
311
- let sourceLines = sourceLinesByAnchor.get(edit.anchor.line);
312
- if (sourceLines === undefined) {
313
- sourceLines = [];
314
- sourceLinesByAnchor.set(edit.anchor.line, sourceLines);
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 (!sourceLines.includes(edit.lineNum)) sourceLines.push(edit.lineNum);
403
+ if (edit.kind === "delete") hunkFor(edit.lineNum).sourceLines.add(edit.anchor.line);
317
404
  }
318
- for (const [anchorLine, sourceLines] of sourceLinesByAnchor) {
319
- if (sourceLines.length < 2) continue;
320
- const [firstBlock, secondBlock] = [...sourceLines].sort((a, b) => a - b);
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 ${secondBlock}: anchor line ${anchorLine} is already targeted by another hunk on line ${firstBlock}. ` +
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 \`SWAP N${HL_RANGE_SEP}M:\`, \`CUT N${HL_RANGE_SEP}M\`, or \`INS.PRE|POST|HEAD|TAIL:\` above the body. Got ${JSON.stringify(text)}.`,
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. They are
402
- * usually unified-diff contamination (`-old` next to `+new`) and inserting
403
- * them would corrupt the file, so they are rejected EXCEPT when the body
404
- * is unambiguously a Markdown bullet list: every `-` row is bullet-shaped
405
- * (`- item`) and the body is either fully bare or already contains an
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: readonly PayloadRow[]): void {
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
- if (target.kind === "paste") {
527
- this.#edits.push({ kind: "paste", cursor: cloneCursor(target.cursor), lineNum, index: this.#editIndex++ });
528
- return;
529
- }
530
- if (target.kind === "paste_after_block") {
531
- this.#pushBlock(target.anchor, [], lineNum, "paste_after");
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 (payloads.length === 0) throw new Error(`line ${lineNum}: ${EMPTY_BLOCK}`);
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) throw new Error(`line ${lineNum}: ${EMPTY_INSERT}`);
541
- this.#pushBlock(target.anchor, payloads, lineNum, "insert_after");
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
- if (target.kind === "insert_after") {
562
- this.#emitPayloadRows({ kind: "after_anchor", anchor: { ...target.anchor } }, payloads, lineNum);
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
- const cursor: Cursor = target.kind === "bof" ? { kind: "bof" } : { kind: "eof" };
769
+ if (payloads.length === 0) throw new Error(`line ${lineNum}: ${EMPTY_INSERT}`);
566
770
  this.#emitPayloadRows(cursor, payloads, lineNum);
567
771
  }
568
772
  }