@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/apply.ts CHANGED
@@ -7,6 +7,8 @@
7
7
  * which absorbs common model mistakes where a payload restates unchanged range
8
8
  * boundaries or duplicates/drops structural closers.
9
9
  */
10
+
11
+ import { resolveClipboardEdits } from "./clipboard";
10
12
  import {
11
13
  afterInsertLandingShiftWarning,
12
14
  ambiguousBoundaryEchoMessage,
@@ -15,7 +17,7 @@ import {
15
17
  UNRESOLVED_BLOCK_INTERNAL,
16
18
  } from "./messages";
17
19
  import { cloneCursor } from "./tokenizer";
18
- import type { Anchor, ApplyResult, Cursor, Edit } from "./types";
20
+ import type { Anchor, ApplyResult, Clipboard, Cursor, Edit } from "./types";
19
21
 
20
22
  type LineOrigin = "original" | "insert" | "replacement";
21
23
 
@@ -1221,24 +1223,43 @@ function repairAfterInsertLandings(
1221
1223
  return { edits: out ?? edits, warnings };
1222
1224
  }
1223
1225
 
1226
+ /** Optional knobs for {@link applyEdits}. */
1227
+ export interface ApplyEditsOptions {
1228
+ /**
1229
+ * Clipboard register filled by `cut` edits and read by `paste` edits.
1230
+ * Thread one register through every section of a batch to move content
1231
+ * across files; omitted, the call gets a private register.
1232
+ */
1233
+ clipboard?: Clipboard;
1234
+ /** `PASTE` with an empty register: `throw` (default) or `drop` (streaming previews). */
1235
+ onEmptyPaste?: "throw" | "drop";
1236
+ }
1237
+
1224
1238
  /**
1225
1239
  * Apply a parsed list of edits to a text body. Pure function — no I/O.
1226
1240
  *
1227
1241
  * Returns the post-edit text and the first changed line number (1-indexed).
1228
1242
  * Throws if an anchor is out of bounds.
1229
1243
  */
1230
- export function applyEdits(text: string, edits: readonly Edit[]): ApplyResult {
1244
+ export function applyEdits(text: string, edits: readonly Edit[], options: ApplyEditsOptions = {}): ApplyResult {
1231
1245
  if (edits.length === 0) return { text, firstChangedLine: undefined };
1232
1246
 
1247
+ const fileLines = text.split("\n");
1248
+
1249
+ // Clipboard pre-pass: capture `cut` ranges from the original lines and
1250
+ // expand `paste` edits into plain inserts in authored order.
1251
+ const concrete = resolveClipboardEdits(edits, fileLines, options.clipboard ?? {}, {
1252
+ ...(options.onEmptyPaste === undefined ? {} : { onEmptyPaste: options.onEmptyPaste }),
1253
+ });
1254
+
1233
1255
  // Block edits are deferred until `resolveBlockEdits` expands them into
1234
1256
  // concrete inserts + deletes. Reaching the applier with one still present
1235
1257
  // is an internal wiring bug, not authored-input error.
1236
- for (const edit of edits) {
1258
+ for (const edit of concrete) {
1237
1259
  if (edit.kind === "block") throw new Error(UNRESOLVED_BLOCK_INTERNAL);
1238
1260
  }
1239
- const appliedEdits = edits as readonly AppliedEdit[];
1261
+ const appliedEdits = concrete as readonly AppliedEdit[];
1240
1262
 
1241
- const fileLines = text.split("\n");
1242
1263
  const lineOrigins: LineOrigin[] = fileLines.map(() => "original");
1243
1264
 
1244
1265
  let firstChangedLine: number | undefined;
package/src/block.ts CHANGED
@@ -1,25 +1,22 @@
1
1
  /**
2
- * Expand deferred block edits (`replace_block N:` / `delete_block N` /
3
- * `insert_after_block N:`) into concrete inserts + deletes.
2
+ * Expand deferred block edits into concrete inserts, cuts, pastes, and deletes.
4
3
  *
5
- * The hashline parser cannot expand a block edit on its own the line span is
6
- * unknown until file text + path (→ language) are available. This transform
7
- * runs at every apply/preview boundary that has text: it calls the injected
8
- * {@link BlockResolver} to resolve each block's `[start, end]` span, then emits
9
- * the exact same edits the concrete form produces in the parser: `replace
10
- * start.=end:` inserts + deletes for a replace, a pure range delete for a
11
- * delete, and plain `after_anchor` inserts at `end` for an insert-after. After
12
- * it runs, no `block` edits remain, so {@link applyEdits} (and recovery) only
13
- * ever see resolved edits.
4
+ * The parser cannot expand a block edit until file text and language are
5
+ * available. This transform resolves each anchored span, then emits the same
6
+ * low-level edits as the corresponding concrete operation. After it runs, no
7
+ * `block` edits remain, so {@link applyEdits} and recovery see concrete edits.
14
8
  */
15
9
  import { STRUCTURAL_CLOSER_RE } from "./apply";
16
10
  import {
17
11
  BLOCK_RESOLVER_UNAVAILABLE,
18
12
  type BlockDiagnosticSuggestions,
13
+ type BlockOp,
19
14
  blockSingleLineMessage,
20
15
  blockUnresolvedMessage,
21
16
  insertAfterBlockCloserLoweredWarning,
22
17
  insertAfterBlockUnresolvedLoweredWarning,
18
+ pasteAfterBlockCloserLoweredWarning,
19
+ pasteAfterBlockUnresolvedLoweredWarning,
23
20
  } from "./messages";
24
21
  import type { BlockResolution, BlockResolver, BlockSpan, Cursor, Edit } from "./types";
25
22
 
@@ -67,15 +64,12 @@ function findEnclosingBlock(
67
64
  return null;
68
65
  }
69
66
 
67
+ /** Optional knobs for {@link resolveBlockEdits}. */
70
68
  export interface ResolveBlockEditsOptions {
71
69
  /**
72
- * How to handle a replace/delete block edit that cannot be resolved
73
- * (missing resolver or a `null` span). `"throw"` (default) raises a
74
- * `blockUnresolvedMessage` error used by the authoritative apply + final
75
- * preview paths. `"drop"` silently skips the edit — used by the streaming
76
- * preview, where a half-written file or transient parse error must not
77
- * throw. Unresolvable `insert_after_block N:` edits never reach this: they
78
- * are lowered to plain `insert after N:` with a warning.
70
+ * How to handle a replace/cut block edit that cannot be resolved. `"throw"`
71
+ * (default) raises a block error; `"drop"` skips it for streaming previews.
72
+ * Unresolvable after-block edits lower to their plain after-line form.
79
73
  */
80
74
  onUnresolved?: "throw" | "drop";
81
75
  /**
@@ -124,7 +118,7 @@ export function resolveBlockEdits(
124
118
  resolved.push(edit);
125
119
  continue;
126
120
  }
127
- const op = edit.mode === "insert_after" ? "insert_after" : edit.payloads.length === 0 ? "delete" : "replace";
121
+ const op: BlockOp = edit.mode ?? "replace";
128
122
  const span = resolver ? resolver({ path, text, line: edit.anchor.line }) : null;
129
123
  if (span === null) {
130
124
  // `insert_after_block N:` never fails the patch — lower it to plain
@@ -135,9 +129,19 @@ export function resolveBlockEdits(
135
129
  // - otherwise (unsupported language, blank line, unparsable block,
136
130
  // or no resolver wired): "after the block at N" degrades to
137
131
  // "after line N" — warn to verify the landing line.
138
- if (op === "insert_after") {
132
+ if (op === "insert_after" || op === "paste_after") {
139
133
  const anchorText = text.split("\n")[edit.anchor.line - 1];
140
134
  const isCloser = anchorText !== undefined && STRUCTURAL_CLOSER_RE.test(anchorText);
135
+ if (op === "paste_after") {
136
+ options.onWarning?.(
137
+ isCloser
138
+ ? pasteAfterBlockCloserLoweredWarning(edit.anchor.line)
139
+ : pasteAfterBlockUnresolvedLoweredWarning(edit.anchor.line),
140
+ );
141
+ const cursor: Cursor = { kind: "after_anchor", anchor: { line: edit.anchor.line } };
142
+ resolved.push({ kind: "paste", cursor, lineNum: edit.lineNum, index: synthIndex++ });
143
+ continue;
144
+ }
141
145
  options.onWarning?.(
142
146
  isCloser
143
147
  ? insertAfterBlockCloserLoweredWarning(edit.anchor.line)
@@ -183,6 +187,32 @@ export function resolveBlockEdits(
183
187
  end: span.end,
184
188
  op,
185
189
  });
190
+ if (op === "paste_after") {
191
+ // Mirror the block-lowered insert: paste after the block's last
192
+ // line, tagging `blockStart` so landing correction can slide a body
193
+ // claiming a depth inside the block back across its trailing closers.
194
+ resolved.push({
195
+ kind: "paste",
196
+ cursor: { kind: "after_anchor", anchor: { line: span.end } },
197
+ lineNum: edit.lineNum,
198
+ index: synthIndex++,
199
+ blockStart: span.start,
200
+ });
201
+ continue;
202
+ }
203
+ if (op === "cut") {
204
+ // Capture the resolved span before deleting it line-by-line.
205
+ resolved.push({
206
+ kind: "cut",
207
+ range: { start: { line: span.start }, end: { line: span.end } },
208
+ lineNum: edit.lineNum,
209
+ index: synthIndex++,
210
+ });
211
+ for (let line = span.start; line <= span.end; line++) {
212
+ resolved.push({ kind: "delete", anchor: { line }, lineNum: edit.lineNum, index: synthIndex++ });
213
+ }
214
+ continue;
215
+ }
186
216
  if (op === "insert_after") {
187
217
  // Mirror the parser's `insert after N:` lowering: one `after_anchor`
188
218
  // insert per payload row, anchored on the block's last line. The
@@ -202,10 +232,8 @@ export function resolveBlockEdits(
202
232
  }
203
233
  continue;
204
234
  }
205
- // Mirror the parser's `replace start.=end:` expansion exactly: one
206
- // `before_anchor` replacement insert per payload row at `span.start`,
207
- // then one delete per line across `[span.start, span.end]`. An empty
208
- // `payloads` (from `delete_block N`) emits no inserts — a pure deletion.
235
+ // Mirror `SWAP start.=end:`: replacement inserts at `span.start`, then
236
+ // one delete per line across the resolved span.
209
237
  for (const payload of edit.payloads) {
210
238
  const cursor: Cursor = { kind: "before_anchor", anchor: { line: span.start } };
211
239
  resolved.push({
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Clipboard register support for `CUT` / `PASTE` ops.
3
+ *
4
+ * `CUT` captures its current source lines before ordinary delete edits apply.
5
+ * `PASTE` expands the latest capture into inserts. One register flows through
6
+ * patch sections in authored order, so content moves across files; the latest
7
+ * cut wins and paste does not consume it.
8
+ */
9
+ import { HL_CUT_KEYWORD, HL_RANGE_SEP } from "./format";
10
+ import { EMPTY_PASTE } from "./messages";
11
+ import { cloneCursor } from "./tokenizer";
12
+ import type { Clipboard, Edit } from "./types";
13
+
14
+ type CutEdit = Extract<Edit, { kind: "cut" }>;
15
+
16
+ function describeCutEdit(edit: CutEdit): string {
17
+ const { start, end } = edit.range;
18
+ const range = start.line === end.line ? `${start.line}` : `${start.line}${HL_RANGE_SEP}${end.line}`;
19
+ return `${HL_CUT_KEYWORD} ${range}`;
20
+ }
21
+
22
+ /** True when at least one edit reads or writes the clipboard register. */
23
+ export function hasClipboardEdit(edits: readonly Edit[]): boolean {
24
+ return edits.some(
25
+ edit =>
26
+ edit.kind === "cut" ||
27
+ edit.kind === "paste" ||
28
+ (edit.kind === "block" && (edit.mode === "cut" || edit.mode === "paste_after")),
29
+ );
30
+ }
31
+
32
+ /** Optional knobs for {@link resolveClipboardEdits}. */
33
+ export interface ResolveClipboardEditsOptions {
34
+ /** `PASTE` with an empty register: `throw` (default) or `drop` (streaming previews). */
35
+ onEmptyPaste?: "throw" | "drop";
36
+ }
37
+
38
+ /**
39
+ * Resolve clipboard edits against the original file lines in authored order.
40
+ * Cuts fill the register and emit nothing; pastes become plain inserts.
41
+ */
42
+ export function resolveClipboardEdits(
43
+ edits: readonly Edit[],
44
+ fileLines: readonly string[],
45
+ clipboard: Clipboard,
46
+ options: ResolveClipboardEditsOptions = {},
47
+ ): readonly Edit[] {
48
+ if (!hasClipboardEdit(edits)) return edits;
49
+ const onEmptyPaste = options.onEmptyPaste ?? "throw";
50
+ const resolved: Edit[] = [];
51
+ let synthIndex = 0;
52
+ for (const edit of edits) {
53
+ if (edit.kind === "cut") {
54
+ const { start, end } = edit.range;
55
+ if (start.line < 1 || end.line > fileLines.length) {
56
+ throw new Error(
57
+ `line ${edit.lineNum}: \`${describeCutEdit(edit)}\` is out of range (file has ${fileLines.length} lines).`,
58
+ );
59
+ }
60
+ clipboard.lines = fileLines.slice(start.line - 1, end.line);
61
+ continue;
62
+ }
63
+ if (edit.kind === "paste") {
64
+ const lines = clipboard.lines;
65
+ if (lines === undefined) {
66
+ if (onEmptyPaste === "drop") continue;
67
+ throw new Error(`line ${edit.lineNum}: ${EMPTY_PASTE}`);
68
+ }
69
+ for (const text of lines) {
70
+ resolved.push({
71
+ kind: "insert",
72
+ cursor: cloneCursor(edit.cursor),
73
+ text,
74
+ lineNum: edit.lineNum,
75
+ index: synthIndex++,
76
+ ...(edit.blockStart === undefined ? {} : { blockStart: edit.blockStart }),
77
+ });
78
+ }
79
+ continue;
80
+ }
81
+ resolved.push(edit);
82
+ }
83
+ return resolved;
84
+ }
85
+
86
+ /** Create a transactional working copy of a clipboard register. */
87
+ export function forkClipboard(source?: Clipboard): Clipboard {
88
+ return source === undefined ? {} : { ...source };
89
+ }
90
+
91
+ /** Publish a clipboard fork back to its source register. */
92
+ export function commitClipboard(fork: Clipboard, target: Clipboard): void {
93
+ if (fork.lines === undefined) delete target.lines;
94
+ else target.lines = fork.lines;
95
+ }
96
+
97
+ /**
98
+ * Validate that every paste has a preceding or persisted capture without
99
+ * mutating the register or reading file content.
100
+ */
101
+ export function validateClipboardSequence(edits: readonly Edit[], clipboard: Clipboard): void {
102
+ let hasLines = clipboard.lines !== undefined;
103
+ for (const edit of edits) {
104
+ if (edit.kind === "cut") {
105
+ hasLines = true;
106
+ } else if (edit.kind === "paste" && !hasLines) {
107
+ throw new Error(`line ${edit.lineNum}: ${EMPTY_PASTE}`);
108
+ }
109
+ }
110
+ }
package/src/format.ts CHANGED
@@ -15,8 +15,6 @@ export const HL_PAYLOAD_REPLACE = "+";
15
15
 
16
16
  /** Hunk-header keyword for concrete line replacement. */
17
17
  export const HL_REPLACE_KEYWORD = "SWAP";
18
- /** Hunk-header keyword for concrete line deletion. */
19
- export const HL_DELETE_KEYWORD = "DEL";
20
18
  /** Hunk-header keyword for insertion operations. */
21
19
  export const HL_INSERT_KEYWORD = "INS";
22
20
  /** Insert position keyword for inserting before a concrete line. */
@@ -29,10 +27,16 @@ export const HL_INSERT_HEAD = "HEAD";
29
27
  export const HL_INSERT_TAIL = "TAIL";
30
28
  /** Hunk-header keyword: `SWAP.BLK N:` resolves N to a tree-sitter block range and replaces its span. */
31
29
  export const HL_REPLACE_BLOCK_KEYWORD = "SWAP.BLK";
32
- /** Hunk-header keyword: `DEL.BLK N` resolves N to a tree-sitter block range and deletes its span. */
33
- export const HL_DELETE_BLOCK_KEYWORD = "DEL.BLK";
34
30
  /** Hunk-header keyword: `INS.BLK.POST N:` inserts after the last line of the tree-sitter block at N. */
35
31
  export const HL_INSERT_AFTER_BLOCK_KEYWORD = "INS.BLK.POST";
32
+ /** Hunk-header keyword: `CUT N.=M` captures lines into the clipboard register and deletes them. */
33
+ export const HL_CUT_KEYWORD = "CUT";
34
+ /** Hunk-header keyword: `CUT.BLK N` captures the tree-sitter block at N and deletes its span. */
35
+ export const HL_CUT_BLOCK_KEYWORD = "CUT.BLK";
36
+ /** Hunk-header keyword prefix: `PASTE.PRE|POST N` / `PASTE.HEAD|TAIL` inserts the clipboard. */
37
+ export const HL_PASTE_KEYWORD = "PASTE";
38
+ /** Hunk-header keyword: `PASTE.BLK.POST N` inserts the clipboard after the tree-sitter block at N. */
39
+ export const HL_PASTE_AFTER_BLOCK_KEYWORD = "PASTE.BLK.POST";
36
40
  /** File-level keyword: `REM` deletes the whole file named by the section header. */
37
41
  export const HL_REM_KEYWORD = "REM";
38
42
  /** File-level keyword: `MV DEST` renames/moves the section file to `DEST`. */
@@ -63,9 +67,9 @@ export function formatReplaceHeader(start: number, end: number): string {
63
67
  return `${HL_REPLACE_KEYWORD} ${start}${HL_RANGE_SEP}${end}${HL_HEADER_COLON}`;
64
68
  }
65
69
 
66
- /** Format a concrete deletion hunk header. */
67
- export function formatDeleteHeader(start: number, end = start): string {
68
- return start === end ? `${HL_DELETE_KEYWORD} ${start}` : `${HL_DELETE_KEYWORD} ${start}${HL_RANGE_SEP}${end}`;
70
+ /** Format a concrete cut hunk header. */
71
+ export function formatCutHeader(start: number, end = start): string {
72
+ return start === end ? `${HL_CUT_KEYWORD} ${start}` : `${HL_CUT_KEYWORD} ${start}${HL_RANGE_SEP}${end}`;
69
73
  }
70
74
 
71
75
  /** Format an insertion hunk header for a cursor position. */
package/src/grammar.lark CHANGED
@@ -7,21 +7,18 @@ file_header: "[" filename "#" file_hash "]" LF
7
7
  file_hash: /[0-9A-F]{4}/
8
8
  filename: /[^#\r\n]+/
9
9
 
10
- hunk: replace_hunk | replace_block_hunk | insert_hunk | insert_block_hunk | delete_hunk | delete_block_hunk | remove_hunk | move_hunk
11
- replace_hunk: replace_anchor LF emit_op*
12
- replace_block_hunk: replace_block_anchor LF emit_op+
13
- insert_hunk: insert_anchor LF emit_op+
14
- insert_block_hunk: insert_block_anchor LF emit_op+
15
- delete_hunk: "DEL " header_range LF
16
- delete_block_hunk: "DEL.BLK " LID LF
17
- remove_hunk: "REM" LF
18
- move_hunk: "MV " filename LF emit_op*
19
- replace_anchor: "SWAP " header_range ":"
20
- replace_block_anchor: "SWAP.BLK " LID ":"
21
- insert_anchor: "INS." insert_pos ":"
22
- insert_block_anchor: "INS.BLK.POST " LID ":"
23
- insert_pos: "PRE " LID | "POST " LID | "HEAD" | "TAIL"
24
- emit_op: "+" /(.*)/ LF
10
+ hunk: swap_hunk | ins_hunk | cut_hunk | paste_hunk | rem_hunk | mv_hunk
11
+ swap_hunk: "SWAP " header_range ":" LF body*
12
+ | "SWAP.BLK " LID ":" LF body+
13
+ ins_hunk: "INS." pos ":" LF body+
14
+ cut_hunk: "CUT" target LF
15
+ paste_hunk: "PASTE." pos LF
16
+ rem_hunk: "REM" LF
17
+ mv_hunk: "MV " filename LF body*
18
+
19
+ target: " " header_range | ".BLK " LID
20
+ pos: "PRE " LID | "POST " LID | "BLK.POST " LID | "HEAD" | "TAIL"
21
+ body: "+" /(.*)/ LF
25
22
 
26
23
  header_range: LID ".=" LID
27
24
  LID: /[1-9]\d*/
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from "./apply";
2
2
  export * from "./block";
3
+ export * from "./clipboard";
3
4
  export * from "./diff-preview";
4
5
  export * from "./format";
5
6
  export * from "./fs";
package/src/input.ts CHANGED
@@ -10,10 +10,12 @@
10
10
  import * as path from "node:path";
11
11
  import { applyEdits } from "./apply";
12
12
  import { resolveBlockEdits } from "./block";
13
+ import { hasClipboardEdit } from "./clipboard";
13
14
  import { HL_FILE_HASH_EXAMPLES, HL_FILE_HASH_LENGTH, HL_FILE_HASH_SEP, HL_FILE_PREFIX, HL_FILE_SUFFIX } from "./format";
15
+ import { CLIPBOARD_INTERLEAVED_SECTIONS } from "./messages";
14
16
  import { parsePatch, parsePatchStreaming } from "./parser";
15
17
  import { Tokenizer } from "./tokenizer";
16
- import type { ApplyResult, BlockResolver, Edit, FileOp, SplitOptions } from "./types";
18
+ import type { ApplyResult, BlockResolver, Clipboard, Edit, FileOp, SplitOptions } from "./types";
17
19
 
18
20
  // Pure classification — single shared tokenizer is safe.
19
21
  const TOKENIZER = new Tokenizer();
@@ -97,6 +99,14 @@ interface RawSection {
97
99
  path: string;
98
100
  fileHash?: string;
99
101
  diff: string;
102
+ /**
103
+ * True when this section coalesced same-path sections that were NOT
104
+ * adjacent in the authored input (another file's section sat between
105
+ * them). Merging moves the later ops up to the first occurrence, which
106
+ * would silently reorder the clipboard register sequence — so clipboard
107
+ * ops are rejected in such sections.
108
+ */
109
+ interleaved?: boolean;
100
110
  }
101
111
 
102
112
  /**
@@ -239,10 +249,13 @@ export class PatchSection {
239
249
  readonly diff: string;
240
250
  #parsed: { edits: Edit[]; fileOp?: FileOp; warnings: string[] } | undefined;
241
251
 
252
+ #interleavedMerge: boolean;
253
+
242
254
  constructor(raw: RawSection) {
243
255
  this.path = raw.path;
244
256
  this.fileHash = raw.fileHash;
245
257
  this.diff = raw.diff;
258
+ this.#interleavedMerge = raw.interleaved === true;
246
259
  }
247
260
 
248
261
  /**
@@ -253,6 +266,12 @@ export class PatchSection {
253
266
  parse(): { edits: Edit[]; fileOp?: FileOp; warnings: readonly string[] } {
254
267
  this.#parsed ??= parsePatch(this.diff);
255
268
  const parsed = this.#parsed;
269
+ // Same-path sections merge into their first occurrence; when that merge
270
+ // crossed another file's section, the authored top-to-bottom register
271
+ // order is gone, so clipboard ops cannot apply deterministically.
272
+ if (this.#interleavedMerge && hasClipboardEdit(parsed.edits)) {
273
+ throw new Error(CLIPBOARD_INTERLEAVED_SECTIONS);
274
+ }
256
275
  const fileOp =
257
276
  parsed.fileOp === undefined
258
277
  ? undefined
@@ -289,6 +308,8 @@ export class PatchSection {
289
308
  if (edit.kind === "delete") return true;
290
309
  // A `replace_block N:` edit is anchored to concrete content on line N.
291
310
  if (edit.kind === "block") return true;
311
+ // A `CUT` range reads concrete content.
312
+ if (edit.kind === "cut") return true;
292
313
  return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor";
293
314
  });
294
315
  }
@@ -305,6 +326,10 @@ export class PatchSection {
305
326
  lines.add(edit.anchor.line);
306
327
  continue;
307
328
  }
329
+ if (edit.kind === "cut") {
330
+ for (let line = edit.range.start.line; line <= edit.range.end.line; line++) lines.add(line);
331
+ continue;
332
+ }
308
333
  if (edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor") {
309
334
  lines.add(edit.cursor.anchor.line);
310
335
  }
@@ -321,15 +346,18 @@ export class PatchSection {
321
346
  *
322
347
  * `blockResolver` resolves any `replace_block N:` edits against `text`; an
323
348
  * unresolvable block throws (this is the final, authoritative preview path).
349
+ *
350
+ * `clipboard` is the register shared by `CUT`/`PASTE` ops. Pass one when
351
+ * applying several sections so content can move across files.
324
352
  */
325
- applyTo(text: string, blockResolver?: BlockResolver): ApplyResult {
353
+ applyTo(text: string, blockResolver?: BlockResolver, clipboard?: Clipboard): ApplyResult {
326
354
  const { edits, warnings } = this.parse();
327
355
  const resolveWarnings: string[] = [];
328
356
  const resolved = resolveBlockEdits(edits, text, this.path, blockResolver, {
329
357
  onUnresolved: "throw",
330
358
  onWarning: warning => resolveWarnings.push(warning),
331
359
  });
332
- const result = applyEdits(text, resolved);
360
+ const result = applyEdits(text, resolved, { clipboard: clipboard ?? {} });
333
361
  // Preserve parse warnings so consumers don't need to call `parse()`
334
362
  // separately.
335
363
  const merged = [...warnings, ...resolveWarnings, ...(result.warnings ?? [])];
@@ -347,16 +375,16 @@ export class PatchSection {
347
375
  *
348
376
  * `blockResolver` resolves any `replace_block N:` edits against `text`; an
349
377
  * unresolvable block is silently dropped so a half-written file does not
350
- * throw mid-stream.
378
+ * throw mid-stream. A `PASTE` with an empty register is dropped too.
351
379
  */
352
- applyPartialTo(text: string, blockResolver?: BlockResolver): ApplyResult {
380
+ applyPartialTo(text: string, blockResolver?: BlockResolver, clipboard?: Clipboard): ApplyResult {
353
381
  const { edits, warnings } = parsePatchStreaming(this.diff);
354
382
  const resolveWarnings: string[] = [];
355
383
  const resolved = resolveBlockEdits(edits, text, this.path, blockResolver, {
356
384
  onUnresolved: "drop",
357
385
  onWarning: warning => resolveWarnings.push(warning),
358
386
  });
359
- const result = applyEdits(text, resolved);
387
+ const result = applyEdits(text, resolved, { clipboard: clipboard ?? {}, onEmptyPaste: "drop" });
360
388
  const merged = [...warnings, ...resolveWarnings, ...(result.warnings ?? [])];
361
389
  return merged.length > 0
362
390
  ? { ...result, warnings: merged }
@@ -374,6 +402,7 @@ export class PatchSection {
374
402
  path,
375
403
  ...(this.fileHash !== undefined ? { fileHash: this.fileHash } : {}),
376
404
  diff: this.diff,
405
+ ...(this.#interleavedMerge ? { interleaved: true } : {}),
377
406
  });
378
407
  next.#parsed = this.#parsed;
379
408
  return next;
@@ -432,7 +461,8 @@ export class Patch {
432
461
  * fails. Path order is preserved by first occurrence.
433
462
  */
434
463
  function mergeSamePathSections(sections: RawSection[]): RawSection[] {
435
- const byPath = new Map<string, { fileHash?: string; diffs: string[] }>();
464
+ const byPath = new Map<string, { fileHash?: string; diffs: string[]; interleaved: boolean }>();
465
+ let previousPath: string | undefined;
436
466
  for (const section of sections) {
437
467
  const existing = byPath.get(section.path);
438
468
  if (existing) {
@@ -446,17 +476,24 @@ function mergeSamePathSections(sections: RawSection[]): RawSection[] {
446
476
  );
447
477
  }
448
478
  if (existing.fileHash === undefined && section.fileHash !== undefined) existing.fileHash = section.fileHash;
479
+ // Merging across another file's section moves these ops up to the
480
+ // first occurrence; flag it so clipboard ops can refuse the reorder.
481
+ if (previousPath !== section.path) existing.interleaved = true;
449
482
  existing.diffs.push(section.diff);
483
+ previousPath = section.path;
450
484
  continue;
451
485
  }
452
486
  byPath.set(section.path, {
453
487
  ...(section.fileHash !== undefined ? { fileHash: section.fileHash } : {}),
454
488
  diffs: [section.diff],
489
+ interleaved: false,
455
490
  });
491
+ previousPath = section.path;
456
492
  }
457
493
  return Array.from(byPath, ([sectionPath, entry]) => ({
458
494
  path: sectionPath,
459
495
  ...(entry.fileHash !== undefined ? { fileHash: entry.fileHash } : {}),
460
496
  diff: entry.diffs.join("\n"),
497
+ ...(entry.interleaved ? { interleaved: true } : {}),
461
498
  }));
462
499
  }