@oh-my-pi/hashline 17.1.7 → 17.2.0

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/messages.ts CHANGED
@@ -31,7 +31,19 @@ export function formatAnchoredContext(anchorLines: readonly number[], fileLines:
31
31
  return rows;
32
32
  }
33
33
  /** Concrete range operation rejected because its absolute end precedes its start. */
34
- export type AbsoluteRangeOp = "replace" | "delete";
34
+ export type AbsoluteRangeOp = "replace" | "cut";
35
+
36
+ /** Header forms per concrete-range op, used to compose retry suggestions. */
37
+ const RANGE_OP_FORMS: Record<AbsoluteRangeOp, { keyword: string; colon: string; blockKeyword: string }> = {
38
+ replace: { keyword: "SWAP", colon: ":", blockKeyword: "SWAP.BLK" },
39
+ cut: { keyword: "CUT", colon: "", blockKeyword: "CUT.BLK" },
40
+ };
41
+
42
+ /** `OP.BLK N`-style header for a concrete-range op (`SWAP.BLK 5:` / `CUT.BLK 5`). */
43
+ function blockFormAt(op: AbsoluteRangeOp, line: number): string {
44
+ const forms = RANGE_OP_FORMS[op];
45
+ return `${forms.blockKeyword} ${line}${forms.colon}`;
46
+ }
35
47
 
36
48
  /** Explain absolute range endpoints and provide safe, non-applying retry forms. */
37
49
  export function invalidAbsoluteRangeMessage(
@@ -41,15 +53,14 @@ export function invalidAbsoluteRangeMessage(
41
53
  op: AbsoluteRangeOp,
42
54
  block?: BlockSpan,
43
55
  ): string {
44
- const single = op === "replace" ? `SWAP ${start}${HL_RANGE_SEP}${start}:` : `DEL ${start}`;
56
+ const forms = RANGE_OP_FORMS[op];
57
+ const single = op === "replace" ? `SWAP ${start}${HL_RANGE_SEP}${start}:` : `${forms.keyword} ${start}`;
45
58
  const countedEnd = start + end - 1;
46
59
  const counted =
47
60
  Number.isSafeInteger(countedEnd) && countedEnd >= start
48
- ? op === "replace"
49
- ? `SWAP ${start}${HL_RANGE_SEP}${countedEnd}:`
50
- : `DEL ${start}${HL_RANGE_SEP}${countedEnd}`
61
+ ? `${forms.keyword} ${start}${HL_RANGE_SEP}${countedEnd}${forms.colon}`
51
62
  : null;
52
- const blockForm = op === "replace" ? `SWAP.BLK ${start}:` : `DEL.BLK ${start}`;
63
+ const blockForm = blockFormAt(op, start);
53
64
  let message =
54
65
  `line ${patchLine}: Invalid absolute range: start ${start}, end ${end}. ` +
55
66
  `The value after \`${HL_RANGE_SEP}\` is an absolute source line, not a line count or replacement length. ` +
@@ -99,10 +110,10 @@ export const MINUS_ROW_REJECTED =
99
110
  "`-` rows are not valid; the range already names the lines being changed. For Markdown bullets or other literal `-` lines, prefix the literal row with `+`: `+- item`.";
100
111
 
101
112
  /** Replace hunk with no body. */
102
- export const EMPTY_REPLACE = `\`SWAP N${HL_RANGE_SEP}M:\` needs at least one \`+TEXT\` body row. To delete lines, use \`DEL N${HL_RANGE_SEP}M\`.`;
113
+ export const EMPTY_REPLACE = `\`SWAP N${HL_RANGE_SEP}M:\` needs at least one \`+TEXT\` body row. To delete lines, use \`CUT N${HL_RANGE_SEP}M\`.`;
103
114
 
104
- /** `replace_block N:` hunk with no body. */
105
- export const EMPTY_BLOCK = "`SWAP.BLK N:` needs at least one `+TEXT` body row. To delete a block, use `DEL.BLK N`.";
115
+ /** `SWAP.BLK N:` hunk with no body. */
116
+ export const EMPTY_BLOCK = "`SWAP.BLK N:` needs at least one `+TEXT` body row. To delete a block, use `CUT.BLK N`.";
106
117
 
107
118
  /** Optional source-aware suggestions appended to block-anchor diagnostics. */
108
119
  export interface BlockDiagnosticSuggestions {
@@ -113,26 +124,24 @@ export interface BlockDiagnosticSuggestions {
113
124
  }
114
125
 
115
126
  /**
116
- * Block-anchored replace/delete could not resolve to a syntactic block
117
- * (unsupported language, blank/out-of-range line, no node beginning on N, or
118
- * parse error). Appends a {@link formatAnchoredContext} preview when
119
- * `fileLines` is given. `insert_after_block N:` never reaches this — it is
120
- * lowered to plain `insert after N:` instead (see
121
- * {@link insertAfterBlockUnresolvedLoweredWarning}).
127
+ * A block-anchored replace/cut could not resolve to a syntactic block.
128
+ * Appends a {@link formatAnchoredContext} preview when `fileLines` is given.
129
+ * `INS.BLK.POST N:` never reaches this path; it lowers to `INS.POST N:`.
122
130
  */
123
131
  export function blockUnresolvedMessage(
124
132
  line: number,
125
- op: "replace" | "delete" = "replace",
133
+ op: AbsoluteRangeOp = "replace",
126
134
  fileLines?: readonly string[],
127
135
  suggestions: BlockDiagnosticSuggestions = {},
128
136
  ): string {
129
- const phrase = op === "delete" ? `DEL.BLK ${line}` : `SWAP.BLK ${line}:`;
130
- const fallback = op === "delete" ? `DEL ${line}${HL_RANGE_SEP}M` : `SWAP ${line}${HL_RANGE_SEP}M:`;
137
+ const forms = RANGE_OP_FORMS[op];
138
+ const phrase = blockFormAt(op, line);
139
+ const fallback = `${forms.keyword} ${line}${HL_RANGE_SEP}M${forms.colon}`;
131
140
  const anchorText = fileLines?.[line - 1];
132
141
  const nextBlock = suggestions.nextBlock;
133
142
  let message: string;
134
143
  if (anchorText !== undefined && anchorText.trim().length === 0 && nextBlock) {
135
- const retry = op === "delete" ? `DEL.BLK ${nextBlock.start}` : `SWAP.BLK ${nextBlock.start}:`;
144
+ const retry = blockFormAt(op, nextBlock.start);
136
145
  message =
137
146
  `Line ${line} is blank; no syntactic block can begin there. ` +
138
147
  `The next multi-line block begins at line ${nextBlock.start} and ends at line ${nextBlock.end}. ` +
@@ -144,7 +153,7 @@ export function blockUnresolvedMessage(
144
153
  }
145
154
  const enclosingBlock = suggestions.enclosingBlock;
146
155
  if (enclosingBlock) {
147
- const retry = op === "delete" ? `DEL.BLK ${enclosingBlock.start}` : `SWAP.BLK ${enclosingBlock.start}:`;
156
+ const retry = blockFormAt(op, enclosingBlock.start);
148
157
  message +=
149
158
  ` The nearest enclosing multi-line block begins at line ${enclosingBlock.start} ` +
150
159
  `and ends at line ${enclosingBlock.end}; use \`${retry}\` to target it.`;
@@ -156,26 +165,46 @@ export function blockUnresolvedMessage(
156
165
  return message;
157
166
  }
158
167
 
159
- /** Block-anchored edit reached a path with no {@link BlockResolver} wired in — a host-configuration bug. */
168
+ /** Block-anchored edit reached a path with no {@link BlockResolver} wired in. */
160
169
  export const BLOCK_RESOLVER_UNAVAILABLE =
161
- "`SWAP.BLK`/`DEL.BLK`/`INS.BLK.POST` are not available here (no block resolver configured). Use a concrete line range.";
170
+ "Block ops (`SWAP.BLK`, `INS.BLK.POST`, `CUT.BLK`, `PASTE.BLK.POST`) are not available here (no block resolver configured). Use a concrete line range.";
162
171
 
163
172
  /**
164
- * `insert_after_block N:` anchored on a closing-delimiter line, lowered to
165
- * plain `insert after N:` — the closer ends a block, and inserting after it
166
- * is exactly what the plain form does.
173
+ * An after-block op anchored on a closing-delimiter line, lowered to its
174
+ * plain after-line form — the closer ends a block, and inserting after it is
175
+ * exactly what the plain form does.
167
176
  */
168
- export function insertAfterBlockCloserLoweredWarning(line: number): string {
169
- return `\`INS.BLK.POST ${line}:\` anchors on a closing delimiter, so it was applied as plain \`INS.POST ${line}:\`. Anchor on the line that OPENS the construct.`;
177
+ function closerLoweredWarning(blockForm: string, plainForm: string): string {
178
+ return `\`${blockForm}\` anchors on a closing delimiter, so it was applied as plain \`${plainForm}\`. Anchor on the line that OPENS the construct.`;
170
179
  }
171
180
 
172
181
  /**
173
- * `insert_after_block N:` anchor unresolvable (unsupported language, blank
174
- * line, parse error, or no resolver), lowered to plain `insert after N:` —
175
- * applying with a warning beats failing the patch.
182
+ * An after-block op whose anchor was unresolvable (unsupported language,
183
+ * blank line, parse error, or no resolver), lowered to its plain after-line
184
+ * form — applying with a warning beats failing the patch.
176
185
  */
186
+ function unresolvedLoweredWarning(blockForm: string, line: number, plainForm: string): string {
187
+ return `\`${blockForm}\` could not resolve a syntactic block on line ${line}, so it was applied as plain \`${plainForm}\`. Verify the landing line; anchor on a line that OPENS a construct.`;
188
+ }
189
+
190
+ /** `INS.BLK.POST N:` anchored on a closing-delimiter line; applied as `INS.POST N:`. */
191
+ export function insertAfterBlockCloserLoweredWarning(line: number): string {
192
+ return closerLoweredWarning(`INS.BLK.POST ${line}:`, `INS.POST ${line}:`);
193
+ }
194
+
195
+ /** `INS.BLK.POST N:` anchor unresolvable; applied as `INS.POST N:`. */
177
196
  export function insertAfterBlockUnresolvedLoweredWarning(line: number): string {
178
- return `\`INS.BLK.POST ${line}:\` could not resolve a syntactic block on line ${line}, so it was applied as plain \`INS.POST ${line}:\`. Verify the landing line; anchor on a line that OPENS a construct.`;
197
+ return unresolvedLoweredWarning(`INS.BLK.POST ${line}:`, line, `INS.POST ${line}:`);
198
+ }
199
+
200
+ /** `PASTE.BLK.POST N` anchored on a closing-delimiter line; applied as `PASTE.POST N`. */
201
+ export function pasteAfterBlockCloserLoweredWarning(line: number): string {
202
+ return closerLoweredWarning(`PASTE.BLK.POST ${line}`, `PASTE.POST ${line}`);
203
+ }
204
+
205
+ /** `PASTE.BLK.POST N` anchor unresolvable; applied as `PASTE.POST N`. */
206
+ export function pasteAfterBlockUnresolvedLoweredWarning(line: number): string {
207
+ return unresolvedLoweredWarning(`PASTE.BLK.POST ${line}`, line, `PASTE.POST ${line}`);
179
208
  }
180
209
  /**
181
210
  * A one-sided boundary echo whose payload is too short to be the widened
@@ -228,14 +257,11 @@ export function ambiguousCloserSpareMessage(
228
257
  }
229
258
 
230
259
  /**
231
- * Internal invariant: `applyEdits` received an unresolved `replace_block N:`
232
- * edit; `resolveBlockEdits` must run first. Wiring bug, not authored input.
260
+ * Internal invariant: `applyEdits` received an unresolved block edit;
261
+ * `resolveBlockEdits` must run first.
233
262
  */
234
263
  export const UNRESOLVED_BLOCK_INTERNAL =
235
- "internal error: unresolved `SWAP.BLK` edit reached the applier (resolveBlockEdits was not run).";
236
-
237
- /** Delete hunk received a body row. */
238
- export const DELETE_TAKES_NO_BODY = `\`DEL N${HL_RANGE_SEP}M\` does not take body rows. Remove the body, or use \`SWAP N${HL_RANGE_SEP}M:\`.`;
264
+ "internal error: unresolved block edit reached the applier (resolveBlockEdits was not run).";
239
265
 
240
266
  /** `REM` received a body row or coexists with line edits. */
241
267
  export const REM_TAKES_NO_BODY =
@@ -245,8 +271,23 @@ export const REM_TAKES_NO_BODY =
245
271
  export const MOVE_TAKES_NO_BODY =
246
272
  "`MV DEST` does not take body rows. Put line edits above the `MV` row; the destination path follows `MV` on the same line.";
247
273
 
248
- /** `delete_block N` hunk received a body row. */
249
- export const DELETE_BLOCK_TAKES_NO_BODY = "`DEL.BLK N` does not take body rows. Remove the body, or use `SWAP.BLK N:`.";
274
+ /** `CUT N.=M` hunk received a body row. */
275
+ export const CUT_TAKES_NO_BODY = `\`CUT N${HL_RANGE_SEP}M\` captures + deletes lines and takes no body rows. To replace lines with new content, use \`SWAP N${HL_RANGE_SEP}M:\`.`;
276
+
277
+ /** `PASTE` hunk received a body row. */
278
+ export const PASTE_TAKES_NO_BODY =
279
+ "`PASTE` inserts the clipboard content and takes no `+` body rows. To insert literal text, use `INS`.";
280
+
281
+ /** `PASTE` ran with an empty clipboard register. */
282
+ export const EMPTY_PASTE = `\`PASTE\` found nothing in the clipboard. Ops run top-to-bottom across the whole patch (sections included): put \`CUT N${HL_RANGE_SEP}M\` or \`CUT.BLK N\` above the \`PASTE\`.`;
283
+
284
+ /**
285
+ * Clipboard ops inside a same-path section that was merged across another
286
+ * file's section. Same-path sections coalesce into their first occurrence, so
287
+ * an interleaved layout would silently reorder the register sequence.
288
+ */
289
+ export const CLIPBOARD_INTERLEAVED_SECTIONS =
290
+ "`CUT`/`PASTE` cannot be used in a file whose sections are interleaved with another file's: same-path sections merge into the first occurrence, which would reorder the clipboard sequence. Keep each file's ops under ONE `[path#TAG]` header.";
250
291
 
251
292
  /** Insert hunk with no body. */
252
293
  export const EMPTY_INSERT = "`INS` needs at least one `+TEXT` body row.";
@@ -289,6 +330,23 @@ export const RECOVERY_LINE_REMAP_WARNING =
289
330
  export const HEADTAIL_DRIFT_WARNING =
290
331
  "Applied the `INS.HEAD:`/`INS.TAIL:` edit despite a stale snapshot tag (file changed since your read) — head/tail position is content-independent. Re-read if the drift was unexpected.";
291
332
 
333
+ /**
334
+ * The `Filesystem` reported that what actually landed on disk differs from
335
+ * what was written (see `WriteResult.text`) — most commonly an ACP-connected
336
+ * editor reformatting the buffer on save (e.g. `format_on_save` with tab/space
337
+ * settings that don't match the file). The recorded snapshot is re-keyed on
338
+ * the real, post-write content so the next edit's tag validation matches
339
+ * reality instead of silently drifting.
340
+ */
341
+ export function writeDriftWarning(path: string): string {
342
+ return (
343
+ `${path}: the file on disk after this write differs from what was sent — the client ` +
344
+ "(editor/IDE) likely reformatted it on save (e.g. format-on-save, tab/space settings). " +
345
+ "The returned snapshot reflects the actual file; re-read before further edits if the " +
346
+ "extra changes were unexpected."
347
+ );
348
+ }
349
+
292
350
  /**
293
351
  * Section omitted the mandatory snapshot tag. Shared by the apply
294
352
  * ({@link Patcher.prepare}) and preview/diff paths so both stay in lockstep.
@@ -396,33 +454,29 @@ export function unseenLinesMessage(
396
454
  }
397
455
 
398
456
  /** Op kind of a deferred block edit, for {@link blockSingleLineMessage}. */
399
- export type BlockOp = "replace" | "delete" | "insert_after";
457
+ export type BlockOp = "replace" | "insert_after" | "cut" | "paste_after";
458
+
459
+ /** Display forms per deferred-block op: block keyword, trailing colon, and single-line plain form. */
460
+ const BLOCK_OP_FORMS: Record<BlockOp, { keyword: string; colon: string; plain: (line: number) => string }> = {
461
+ replace: { keyword: "SWAP.BLK", colon: ":", plain: line => `SWAP ${line}${HL_RANGE_SEP}${line}:` },
462
+ insert_after: { keyword: "INS.BLK.POST", colon: ":", plain: line => `INS.POST ${line}:` },
463
+ cut: { keyword: "CUT.BLK", colon: "", plain: line => `CUT ${line}` },
464
+ paste_after: { keyword: "PASTE.BLK.POST", colon: "", plain: line => `PASTE.POST ${line}` },
465
+ };
400
466
 
401
467
  /**
402
- * A `replace_block`/`delete_block`/`insert_after_block` anchor resolved to a
403
- * single line almost always a bare statement the model mis-anchored, not a
404
- * multi-line construct. The plain op is unambiguous for one line; the block
405
- * form only earns its keep when it spares counting a closing line you cannot
406
- * see. Reject and point at both fixes.
468
+ * A block-op anchor resolved to a single line: line N is a bare statement,
469
+ * not the opening line of a multi-line construct. The plain op is exact for
470
+ * one line, so reject and point at it.
407
471
  */
408
472
  export function blockSingleLineMessage(line: number, op: BlockOp, enclosingBlock?: BlockSpan): string {
409
- const blockForm = op === "insert_after" ? "INS.BLK.POST" : op === "delete" ? "DEL.BLK" : "SWAP.BLK";
410
- const plainForm =
411
- op === "insert_after"
412
- ? `INS.POST ${line}:`
413
- : op === "delete"
414
- ? `DEL ${line}`
415
- : `SWAP ${line}${HL_RANGE_SEP}${line}:`;
473
+ const forms = BLOCK_OP_FORMS[op];
474
+ const plainForm = forms.plain(line);
416
475
  let message =
417
- `\`${blockForm} ${line}\` resolved a single-line block — line ${line} is a bare statement, not the opening line ` +
476
+ `\`${forms.keyword} ${line}\` resolved a single-line block — line ${line} is a bare statement, not the opening line ` +
418
477
  `of a multi-line construct. For only this statement use \`${plainForm}\`.`;
419
478
  if (enclosingBlock) {
420
- const enclosingForm =
421
- op === "insert_after"
422
- ? `INS.BLK.POST ${enclosingBlock.start}:`
423
- : op === "delete"
424
- ? `DEL.BLK ${enclosingBlock.start}`
425
- : `SWAP.BLK ${enclosingBlock.start}:`;
479
+ const enclosingForm = `${forms.keyword} ${enclosingBlock.start}${forms.colon}`;
426
480
  message +=
427
481
  ` The nearest enclosing multi-line block begins at line ${enclosingBlock.start} ` +
428
482
  `and ends at line ${enclosingBlock.end}; use \`${enclosingForm}\` to target it.`;
package/src/parser.ts CHANGED
@@ -7,19 +7,22 @@ import { HL_PAYLOAD_REPLACE, HL_RANGE_SEP } from "./format";
7
7
  import {
8
8
  type AbsoluteRangeOp,
9
9
  BARE_BODY_AUTO_PIPED_WARNING,
10
- DELETE_BLOCK_TAKES_NO_BODY,
11
- DELETE_TAKES_NO_BODY,
10
+ CUT_TAKES_NO_BODY,
12
11
  EMPTY_BLOCK,
13
12
  EMPTY_INSERT,
14
13
  invalidAbsoluteRangeMessage,
15
14
  MINUS_BULLET_AUTO_PIPED_WARNING,
16
15
  MINUS_ROW_REJECTED,
17
16
  MOVE_TAKES_NO_BODY,
17
+ PASTE_TAKES_NO_BODY,
18
18
  REM_TAKES_NO_BODY,
19
19
  } from "./messages";
20
20
  import { stripOneLeadingHashlinePrefix } from "./prefixes";
21
21
  import { type BlockTarget, cloneCursor, type ParsedRange, type Token, Tokenizer } from "./tokenizer";
22
22
  import type { Anchor, BlockSpan, Cursor, Edit, FileOp } from "./types";
23
+
24
+ /** Bounds parser amplification before the target file's line count is available. */
25
+ const MAX_EXPANDED_RANGE_LINES = 100_000;
23
26
  /** Parser error carrying enough range metadata for source-aware diagnostic enrichment. */
24
27
  export class InvalidAbsoluteRangeError extends Error {
25
28
  /** Patch-language line containing the invalid range header. */
@@ -46,22 +49,49 @@ export class InvalidAbsoluteRangeError extends Error {
46
49
  }
47
50
  }
48
51
 
49
- function validateRangeOrder(range: ParsedRange, lineNum: number, op: AbsoluteRangeOp): void {
52
+ function validateRange(range: ParsedRange, lineNum: number, op: AbsoluteRangeOp): void {
53
+ if (
54
+ !Number.isSafeInteger(range.start.line) ||
55
+ range.start.line < 1 ||
56
+ !Number.isSafeInteger(range.end.line) ||
57
+ range.end.line < 1
58
+ ) {
59
+ throw new Error(
60
+ `line ${lineNum}: ${op} range endpoints must be positive safe integers; got ${range.start.line} and ${range.end.line}.`,
61
+ );
62
+ }
50
63
  if (range.end.line < range.start.line) {
51
64
  throw new InvalidAbsoluteRangeError(lineNum, range.start.line, range.end.line, op);
52
65
  }
53
- }
54
-
55
- function expandRange(range: ParsedRange): Anchor[] {
56
- const anchors: Anchor[] = [];
57
- for (let line = range.start.line; line <= range.end.line; line++) anchors.push({ line });
58
- return anchors;
66
+ const span = range.end.line - range.start.line + 1;
67
+ if (span > MAX_EXPANDED_RANGE_LINES) {
68
+ throw new Error(
69
+ `line ${lineNum}: ${op} range spans ${span} lines; the maximum is ${MAX_EXPANDED_RANGE_LINES}. Split it into smaller hunks.`,
70
+ );
71
+ }
59
72
  }
60
73
 
61
74
  function isSkippableCommentLine(line: string): boolean {
62
75
  return line.trimStart().startsWith("#");
63
76
  }
64
77
 
78
+ /**
79
+ * Body-row rejection message for targets that take no `+TEXT` rows, or `null`
80
+ * for targets whose header is followed by a body.
81
+ */
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
+ }
93
+ }
94
+
65
95
  /**
66
96
  * Stripped remainder of a bare `N: <value>` row that is a lone quoted or
67
97
  * numeric literal (optionally comma-terminated) — the shape of a numeric-keyed
@@ -89,13 +119,13 @@ function detectApplyPatchContamination(text: string, _hasPending: boolean): stri
89
119
  return (
90
120
  `apply_patch sentinel ${JSON.stringify(preview)} is not valid in hashline. ` +
91
121
  "File sections start with `[path#HASH]` (no `Update File:` / `Add File:` keyword). " +
92
- `Use \`SWAP N${HL_RANGE_SEP}M:\`, \`DEL N${HL_RANGE_SEP}M\`, or \`INS.PRE|POST|HEAD|TAIL:\` ops.`
122
+ `Use \`SWAP N${HL_RANGE_SEP}M:\`, \`CUT N${HL_RANGE_SEP}M\`, or \`INS.PRE|POST|HEAD|TAIL:\` ops.`
93
123
  );
94
124
  }
95
125
  if (/^@@\s+[-+]?\d+,\d+\s+[-+]?\d+,\d+\s+@@/.test(trimmed)) {
96
126
  return (
97
127
  "unified-diff hunk header (`@@ -N,M +N,M @@`) is not valid in hashline. " +
98
- `Use \`SWAP N${HL_RANGE_SEP}M:\`, \`DEL N${HL_RANGE_SEP}M\`, or \`INS.PRE|POST|HEAD|TAIL:\` ops.`
128
+ `Use \`SWAP N${HL_RANGE_SEP}M:\`, \`CUT N${HL_RANGE_SEP}M\`, or \`INS.PRE|POST|HEAD|TAIL:\` ops.`
99
129
  );
100
130
  }
101
131
  if (trimmed.startsWith("@@")) {
@@ -105,17 +135,20 @@ function detectApplyPatchContamination(text: string, _hasPending: boolean): stri
105
135
  `Drop the \`@@ ... @@\` brackets and write a verb header such as \`SWAP N${HL_RANGE_SEP}M:\`.`
106
136
  );
107
137
  }
108
- if (/^DEL\s+[1-9]\d*(?:\s*(?:\.\.|\.=|-|…|\s)\s*[1-9]\d*)?\s*:/.test(trimmed)) {
109
- return `\`DEL N${HL_RANGE_SEP}M\` has no colon and no body. Remove the colon and body rows.`;
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`.";
110
143
  }
111
144
  if (/^[1-9]\d*\s*$/.test(trimmed)) {
112
- return `hunk headers need a verb. Use \`SWAP ${trimmed}${HL_RANGE_SEP}${trimmed}:\` to replace, or \`DEL ${trimmed}\` to delete.`;
145
+ return `hunk headers need a verb. Use \`SWAP ${trimmed}${HL_RANGE_SEP}${trimmed}:\` to replace, or \`CUT ${trimmed}\` to delete.`;
113
146
  }
114
147
  const bareRange = /^([1-9]\d*)\s*[-. …=]+\s*([1-9]\d*)\s*:?$/.exec(trimmed);
115
148
  if (bareRange !== null) {
116
149
  return (
117
150
  `bare range hunk header ${JSON.stringify(trimmed)} is not valid. ` +
118
- `Hunk headers need a verb: write \`SWAP ${bareRange[1]}${HL_RANGE_SEP}${bareRange[2]}:\` or \`DEL ${bareRange[1]}${HL_RANGE_SEP}${bareRange[2]}\`.`
151
+ `Hunk headers need a verb: write \`SWAP ${bareRange[1]}${HL_RANGE_SEP}${bareRange[2]}:\` or \`CUT ${bareRange[1]}${HL_RANGE_SEP}${bareRange[2]}\`.`
119
152
  );
120
153
  }
121
154
  return null;
@@ -194,8 +227,11 @@ export class Executor {
194
227
  return;
195
228
  case "op-block":
196
229
  this.#discardPendingSkippableComments();
197
- if (token.target.kind === "replace" || token.target.kind === "delete") {
198
- validateRangeOrder(token.target.range, token.lineNum, token.target.kind);
230
+ if (token.target.kind === "replace") {
231
+ validateRange(token.target.range, token.lineNum, "replace");
232
+ }
233
+ if (token.target.kind === "cut") {
234
+ validateRange(token.target.range, token.lineNum, "cut");
199
235
  }
200
236
  if (token.target.kind === "rem") {
201
237
  this.#flushPending();
@@ -228,8 +264,7 @@ export class Executor {
228
264
  endStreaming(): { edits: Edit[]; fileOp?: FileOp; warnings: string[] } {
229
265
  this.#consumePendingSkippableComments();
230
266
  if (this.#pending && this.#pending.payloads.length > 0) this.#flushPending();
231
- else if (this.#pending?.target.kind === "delete" || this.#pending?.target.kind === "delete_block")
232
- this.#flushPending();
267
+ else if (this.#pending && bodylessTargetMessage(this.#pending.target) !== null) this.#flushPending();
233
268
  else this.#pending = undefined;
234
269
  this.#validateFileOp();
235
270
  this.#validateNoOverlappingDeletes();
@@ -299,8 +334,8 @@ export class Executor {
299
334
  `Got ${JSON.stringify(`${HL_PAYLOAD_REPLACE}${text}`)}.`,
300
335
  );
301
336
  }
302
- if (pending.target.kind === "delete") throw new Error(`line ${lineNum}: ${DELETE_TAKES_NO_BODY}`);
303
- if (pending.target.kind === "delete_block") throw new Error(`line ${lineNum}: ${DELETE_BLOCK_TAKES_NO_BODY}`);
337
+ const noBodyOnLiteral = bodylessTargetMessage(pending.target);
338
+ if (noBodyOnLiteral !== null) throw new Error(`line ${lineNum}: ${noBodyOnLiteral}`);
304
339
  this.#commitDeferredBlanks(pending);
305
340
  pending.payloads.push({ kind: "literal", text, lineNum });
306
341
  }
@@ -314,9 +349,8 @@ export class Executor {
314
349
  this.#handleBlank(text, lineNum);
315
350
  return;
316
351
  }
317
- if (this.#pending.target.kind === "delete") throw new Error(`line ${lineNum}: ${DELETE_TAKES_NO_BODY}`);
318
- if (this.#pending.target.kind === "delete_block")
319
- throw new Error(`line ${lineNum}: ${DELETE_BLOCK_TAKES_NO_BODY}`);
352
+ const noBodyOnRaw = bodylessTargetMessage(this.#pending.target);
353
+ if (noBodyOnRaw !== null) throw new Error(`line ${lineNum}: ${noBodyOnRaw}`);
320
354
  const row: PayloadRow = { kind: "literal", text, lineNum, bare: true };
321
355
  // `-` rows are held and judged at flush time by #resolveMinusRows,
322
356
  // once the whole body is visible.
@@ -337,7 +371,7 @@ export class Executor {
337
371
  if (text.trim().length === 0) return;
338
372
  throw new Error(
339
373
  `line ${lineNum}: payload line has no preceding hunk header. ` +
340
- `Use \`SWAP N${HL_RANGE_SEP}M:\`, \`DEL N${HL_RANGE_SEP}M\`, or \`INS.PRE|POST|HEAD|TAIL:\` above the body. Got ${JSON.stringify(text)}.`,
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)}.`,
341
375
  );
342
376
  }
343
377
 
@@ -351,7 +385,7 @@ export class Executor {
351
385
  #handleBlank(text: string, lineNum: number): void {
352
386
  const pending = this.#pending;
353
387
  if (!pending) return;
354
- if (pending.target.kind === "delete" || pending.target.kind === "delete_block") return;
388
+ if (bodylessTargetMessage(pending.target) !== null) return;
355
389
  if (pending.payloads.length === 0) return;
356
390
  pending.deferredBlanks.push({ kind: "literal", text, lineNum, bare: true });
357
391
  }
@@ -438,7 +472,28 @@ export class Executor {
438
472
  this.#edits.push({ kind: "delete", anchor: { ...anchor }, lineNum, index: this.#editIndex++ });
439
473
  }
440
474
 
441
- #pushBlock(anchor: Anchor, payloads: readonly PayloadRow[], lineNum: number, mode?: "insert_after"): void {
475
+ #pushDeleteRange(range: ParsedRange, lineNum: number): void {
476
+ for (let line = range.start.line; line <= range.end.line; line++) this.#pushDelete({ line }, lineNum);
477
+ }
478
+
479
+ #pushCut(range: ParsedRange, lineNum: number): void {
480
+ this.#edits.push({
481
+ kind: "cut",
482
+ range: { start: { ...range.start }, end: { ...range.end } },
483
+ lineNum,
484
+ index: this.#editIndex++,
485
+ });
486
+ // Capture before ordinary per-line deletes are applied. Keeping deletion
487
+ // as low-level edits preserves overlap validation and recovery remapping.
488
+ this.#pushDeleteRange(range, lineNum);
489
+ }
490
+
491
+ #pushBlock(
492
+ anchor: Anchor,
493
+ payloads: readonly PayloadRow[],
494
+ lineNum: number,
495
+ mode?: "insert_after" | "cut" | "paste_after",
496
+ ): void {
442
497
  this.#edits.push({
443
498
  kind: "block",
444
499
  anchor: { ...anchor },
@@ -460,13 +515,20 @@ export class Executor {
460
515
  this.#resolveMinusRows(payloads);
461
516
  this.#stripBarePrefixesIfUniform(payloads);
462
517
  this.#pending = undefined;
463
- if (target.kind === "delete") {
464
- for (const anchor of expandRange(target.range)) this.#pushDelete(anchor, lineNum);
518
+ if (target.kind === "cut") {
519
+ this.#pushCut(target.range, lineNum);
520
+ return;
521
+ }
522
+ if (target.kind === "cut_block") {
523
+ this.#pushBlock(target.anchor, [], lineNum, "cut");
524
+ return;
525
+ }
526
+ if (target.kind === "paste") {
527
+ this.#edits.push({ kind: "paste", cursor: cloneCursor(target.cursor), lineNum, index: this.#editIndex++ });
465
528
  return;
466
529
  }
467
- if (target.kind === "delete_block") {
468
- // A block edit with no payloads resolves to a pure block deletion.
469
- this.#pushBlock(target.anchor, [], lineNum);
530
+ if (target.kind === "paste_after_block") {
531
+ this.#pushBlock(target.anchor, [], lineNum, "paste_after");
470
532
  return;
471
533
  }
472
534
  if (target.kind === "block") {
@@ -481,7 +543,7 @@ export class Executor {
481
543
  }
482
544
  if (payloads.length === 0) {
483
545
  if (target.kind === "replace") {
484
- for (const anchor of expandRange(target.range)) this.#pushDelete(anchor, lineNum);
546
+ this.#pushDeleteRange(target.range, lineNum);
485
547
  return;
486
548
  }
487
549
  throw new Error(`line ${lineNum}: ${EMPTY_INSERT}`);
@@ -489,7 +551,7 @@ export class Executor {
489
551
  if (target.kind === "replace") {
490
552
  const cursor: Cursor = { kind: "before_anchor", anchor: { ...target.range.start } };
491
553
  this.#emitPayloadRows(cursor, payloads, lineNum, "replacement");
492
- for (const anchor of expandRange(target.range)) this.#pushDelete(anchor, lineNum);
554
+ this.#pushDeleteRange(target.range, lineNum);
493
555
  return;
494
556
  }
495
557
  if (target.kind === "insert_before") {