@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/src/block.ts CHANGED
@@ -139,7 +139,13 @@ export function resolveBlockEdits(
139
139
  : pasteAfterBlockUnresolvedLoweredWarning(edit.anchor.line),
140
140
  );
141
141
  const cursor: Cursor = { kind: "after_anchor", anchor: { line: edit.anchor.line } };
142
- resolved.push({ kind: "paste", cursor, lineNum: edit.lineNum, index: synthIndex++ });
142
+ resolved.push({
143
+ kind: "paste",
144
+ at: { kind: "gap", cursor },
145
+ ...(edit.register === undefined ? {} : { register: edit.register }),
146
+ lineNum: edit.lineNum,
147
+ index: synthIndex++,
148
+ });
143
149
  continue;
144
150
  }
145
151
  options.onWarning?.(
@@ -165,7 +171,9 @@ export function resolveBlockEdits(
165
171
  const suggestions: BlockDiagnosticSuggestions = {};
166
172
  if (nextBlock) suggestions.nextBlock = nextBlock;
167
173
  if (enclosingBlock) suggestions.enclosingBlock = enclosingBlock;
168
- throw new Error(`line ${edit.lineNum}: ${blockUnresolvedMessage(edit.anchor.line, op, lines, suggestions)}`);
174
+ throw new Error(
175
+ `line ${edit.lineNum}: ${blockUnresolvedMessage(edit.anchor.line, op, lines, suggestions, edit.register)}`,
176
+ );
169
177
  }
170
178
  if (span.start === span.end) {
171
179
  // A single-line block resolution means line N is a bare statement, not
@@ -193,7 +201,8 @@ export function resolveBlockEdits(
193
201
  // claiming a depth inside the block back across its trailing closers.
194
202
  resolved.push({
195
203
  kind: "paste",
196
- cursor: { kind: "after_anchor", anchor: { line: span.end } },
204
+ at: { kind: "gap", cursor: { kind: "after_anchor", anchor: { line: span.end } } },
205
+ ...(edit.register === undefined ? {} : { register: edit.register }),
197
206
  lineNum: edit.lineNum,
198
207
  index: synthIndex++,
199
208
  blockStart: span.start,
@@ -205,6 +214,7 @@ export function resolveBlockEdits(
205
214
  resolved.push({
206
215
  kind: "cut",
207
216
  range: { start: { line: span.start }, end: { line: span.end } },
217
+ ...(edit.register === undefined ? {} : { register: edit.register }),
208
218
  lineNum: edit.lineNum,
209
219
  index: synthIndex++,
210
220
  });
@@ -232,8 +242,20 @@ export function resolveBlockEdits(
232
242
  }
233
243
  continue;
234
244
  }
235
- // Mirror `SWAP start.=end:`: replacement inserts at `span.start`, then
236
- // one delete per line across the resolved span.
245
+ if (edit.register !== undefined) {
246
+ // Register-backed block replace (`PUT N* @reg`): expand to a span paste
247
+ // over the resolved block range.
248
+ resolved.push({
249
+ kind: "paste",
250
+ at: { kind: "span", range: { start: { line: span.start }, end: { line: span.end } } },
251
+ register: edit.register,
252
+ lineNum: edit.lineNum,
253
+ index: synthIndex++,
254
+ });
255
+ continue;
256
+ }
257
+ // Body-backed block replace (`PUT N*:` + body): replacement inserts at
258
+ // `span.start`, then one delete per line across the resolved span.
237
259
  for (const payload of edit.payloads) {
238
260
  const cursor: Cursor = { kind: "before_anchor", anchor: { line: span.start } };
239
261
  resolved.push({
package/src/clipboard.ts CHANGED
@@ -1,13 +1,14 @@
1
1
  /**
2
- * Clipboard register support for `CUT` / `PASTE` ops.
2
+ * Clipboard register support for `CUT` and register `PUT` ops.
3
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.
4
+ * `CUT` captures source lines before ordinary delete edits apply. Register
5
+ * `PUT`s expand captured lines into inserts (plus per-line deletes when the
6
+ * target is a `span`). Named registers (`named: Map`) persist across edit
7
+ * batches when host-owned (`PatcherOptions.clipboard`); the anonymous register
8
+ * (`lines`) is batch-local and resets between calls.
8
9
  */
9
- import { HL_CUT_KEYWORD, HL_RANGE_SEP } from "./format";
10
- import { EMPTY_PASTE } from "./messages";
10
+ import { HL_CUT_KEYWORD, HL_PUT_KEYWORD, HL_RANGE_SEP } from "./format";
11
+ import { ambiguousAnonymousPasteMessage, EMPTY_PASTE, unknownRegisterMessage } from "./messages";
11
12
  import { cloneCursor } from "./tokenizer";
12
13
  import type { Clipboard, Edit } from "./types";
13
14
 
@@ -15,29 +16,84 @@ type CutEdit = Extract<Edit, { kind: "cut" }>;
15
16
 
16
17
  function describeCutEdit(edit: CutEdit): string {
17
18
  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}`;
19
+ const span = start.line === end.line ? `${start.line}` : `${start.line}${HL_RANGE_SEP}${end.line}`;
20
+ const reg = edit.register ? ` @${edit.register}` : "";
21
+ return `${HL_CUT_KEYWORD} ${span}${reg}`;
20
22
  }
21
23
 
22
- /** True when at least one edit reads or writes the clipboard register. */
24
+ /** True when at least one edit reads or writes a clipboard register. */
23
25
  export function hasClipboardEdit(edits: readonly Edit[]): boolean {
24
26
  return edits.some(
25
27
  edit =>
26
28
  edit.kind === "cut" ||
27
29
  edit.kind === "paste" ||
28
- (edit.kind === "block" && (edit.mode === "cut" || edit.mode === "paste_after")),
30
+ (edit.kind === "block" && (edit.mode === "cut" || edit.mode === "paste_after" || edit.register !== undefined)),
29
31
  );
30
32
  }
31
33
 
32
34
  /** Optional knobs for {@link resolveClipboardEdits}. */
33
35
  export interface ResolveClipboardEditsOptions {
34
- /** `PASTE` with an empty register: `throw` (default) or `drop` (streaming previews). */
36
+ /** `PUT` with an empty register: `throw` (default) or `drop` (streaming previews). */
35
37
  onEmptyPaste?: "throw" | "drop";
36
38
  }
37
39
 
40
+ /**
41
+ * Read lines from a register. Throws on missing/ambiguous register unless `onEmptyPaste === "drop"`.
42
+ */
43
+ function readRegister(
44
+ register: string | undefined,
45
+ clipboard: Clipboard,
46
+ lineNum: number,
47
+ onEmptyPaste: "throw" | "drop",
48
+ ): readonly string[] | null {
49
+ if (register !== undefined) {
50
+ const lines = clipboard.named?.get(register);
51
+ if (lines !== undefined) return lines;
52
+ if (onEmptyPaste === "drop") return null;
53
+ const known = clipboard.named ? [...clipboard.named.keys()] : [];
54
+ throw new Error(`line ${lineNum}: ${unknownRegisterMessage(register, known)}`);
55
+ }
56
+
57
+ const pending = clipboard.pendingAnonCuts ?? [];
58
+ if (pending.length > 1) {
59
+ if (onEmptyPaste === "drop") return null;
60
+ throw new Error(`line ${lineNum}: ${ambiguousAnonymousPasteMessage(pending)}`);
61
+ }
62
+
63
+ const lines = clipboard.lines;
64
+ if (lines === undefined) {
65
+ if (onEmptyPaste === "drop") return null;
66
+ throw new Error(`line ${lineNum}: ${EMPTY_PASTE}`);
67
+ }
68
+ // Successful anonymous read clears the pending ambiguity counter for follow-up pastes.
69
+ clipboard.pendingAnonCuts = [];
70
+ return lines;
71
+ }
72
+
73
+ /**
74
+ * Write lines into a register (named or anonymous).
75
+ */
76
+ function writeRegister(edit: CutEdit, fileLines: readonly string[], clipboard: Clipboard): void {
77
+ const { start, end } = edit.range;
78
+ if (start.line < 1 || end.line > fileLines.length) {
79
+ throw new Error(
80
+ `line ${edit.lineNum}: \`${describeCutEdit(edit)}\` is out of range (file has ${fileLines.length} lines).`,
81
+ );
82
+ }
83
+ const captured = fileLines.slice(start.line - 1, end.line);
84
+ if (edit.register !== undefined) {
85
+ clipboard.named ??= new Map();
86
+ clipboard.named.set(edit.register, captured);
87
+ } else {
88
+ clipboard.lines = captured;
89
+ clipboard.pendingAnonCuts ??= [];
90
+ clipboard.pendingAnonCuts.push(describeCutEdit(edit));
91
+ }
92
+ }
93
+
38
94
  /**
39
95
  * Resolve clipboard edits against the original file lines in authored order.
40
- * Cuts fill the register and emit nothing; pastes become plain inserts.
96
+ * Cuts fill the register and emit nothing; pastes expand to inserts (+ deletes for span targets).
41
97
  */
42
98
  export function resolveClipboardEdits(
43
99
  edits: readonly Edit[],
@@ -49,32 +105,55 @@ export function resolveClipboardEdits(
49
105
  const onEmptyPaste = options.onEmptyPaste ?? "throw";
50
106
  const resolved: Edit[] = [];
51
107
  let synthIndex = 0;
108
+
52
109
  for (const edit of edits) {
53
110
  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);
111
+ writeRegister(edit, fileLines, clipboard);
61
112
  continue;
62
113
  }
63
114
  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
- });
115
+ const lines = readRegister(edit.register, clipboard, edit.lineNum, onEmptyPaste);
116
+ if (lines === null) continue;
117
+
118
+ if (edit.at.kind === "gap") {
119
+ for (const text of lines) {
120
+ resolved.push({
121
+ kind: "insert",
122
+ cursor: cloneCursor(edit.at.cursor),
123
+ text,
124
+ lineNum: edit.lineNum,
125
+ index: synthIndex++,
126
+ ...(edit.blockStart === undefined ? {} : { blockStart: edit.blockStart }),
127
+ });
128
+ }
129
+ } else {
130
+ // Span paste: insert replacement lines before start, then delete span lines.
131
+ const range = edit.at.range;
132
+ if (range.start.line < 1 || range.end.line > fileLines.length) {
133
+ const reg = edit.register ? ` @${edit.register}` : "";
134
+ throw new Error(
135
+ `line ${edit.lineNum}: \`${HL_PUT_KEYWORD} ${range.start.line}${HL_RANGE_SEP}${range.end.line}${reg}\` is out of range (file has ${fileLines.length} lines).`,
136
+ );
137
+ }
138
+ const cursor = { kind: "before_anchor" as const, anchor: { line: range.start.line } };
139
+ for (const text of lines) {
140
+ resolved.push({
141
+ kind: "insert",
142
+ cursor: cloneCursor(cursor),
143
+ text,
144
+ lineNum: edit.lineNum,
145
+ index: synthIndex++,
146
+ mode: "replacement",
147
+ });
148
+ }
149
+ for (let line = range.start.line; line <= range.end.line; line++) {
150
+ resolved.push({
151
+ kind: "delete",
152
+ anchor: { line },
153
+ lineNum: edit.lineNum,
154
+ index: synthIndex++,
155
+ });
156
+ }
78
157
  }
79
158
  continue;
80
159
  }
@@ -83,15 +162,28 @@ export function resolveClipboardEdits(
83
162
  return resolved;
84
163
  }
85
164
 
165
+ /** Start a batch with persisted named registers but no anonymous state. */
166
+ export function startClipboardBatch(source?: Clipboard): Clipboard {
167
+ if (source?.named === undefined) return {};
168
+ return { named: new Map(source.named) };
169
+ }
170
+
86
171
  /** Create a transactional working copy of a clipboard register. */
87
172
  export function forkClipboard(source?: Clipboard): Clipboard {
88
- return source === undefined ? {} : { ...source };
173
+ if (source === undefined) return {};
174
+ return {
175
+ ...(source.lines === undefined ? {} : { lines: [...source.lines] }),
176
+ ...(source.named === undefined ? {} : { named: new Map(source.named) }),
177
+ ...(source.pendingAnonCuts === undefined ? {} : { pendingAnonCuts: [...source.pendingAnonCuts] }),
178
+ };
89
179
  }
90
180
 
91
- /** Publish a clipboard fork back to its source register. */
181
+ /** Publish a clipboard fork back to its source register (only named registers persist across batches). */
92
182
  export function commitClipboard(fork: Clipboard, target: Clipboard): void {
93
- if (fork.lines === undefined) delete target.lines;
94
- else target.lines = fork.lines;
183
+ if (fork.named !== undefined) {
184
+ target.named ??= new Map();
185
+ for (const [k, v] of fork.named) target.named.set(k, v);
186
+ }
95
187
  }
96
188
 
97
189
  /**
@@ -99,12 +191,19 @@ export function commitClipboard(fork: Clipboard, target: Clipboard): void {
99
191
  * mutating the register or reading file content.
100
192
  */
101
193
  export function validateClipboardSequence(edits: readonly Edit[], clipboard: Clipboard): void {
102
- let hasLines = clipboard.lines !== undefined;
194
+ const fork = forkClipboard(clipboard);
103
195
  for (const edit of edits) {
104
196
  if (edit.kind === "cut") {
105
- hasLines = true;
106
- } else if (edit.kind === "paste" && !hasLines) {
107
- throw new Error(`line ${edit.lineNum}: ${EMPTY_PASTE}`);
197
+ if (edit.register !== undefined) {
198
+ fork.named ??= new Map();
199
+ fork.named.set(edit.register, []);
200
+ } else {
201
+ fork.lines = [];
202
+ fork.pendingAnonCuts ??= [];
203
+ fork.pendingAnonCuts.push(describeCutEdit(edit));
204
+ }
205
+ } else if (edit.kind === "paste") {
206
+ readRegister(edit.register, fork, edit.lineNum, "throw");
108
207
  }
109
208
  }
110
209
  }
package/src/format.ts CHANGED
@@ -13,40 +13,31 @@ export const HL_FILE_SUFFIX = "]";
13
13
  /** Payload sigil for literal body rows. */
14
14
  export const HL_PAYLOAD_REPLACE = "+";
15
15
 
16
- /** Hunk-header keyword for concrete line replacement. */
17
- export const HL_REPLACE_KEYWORD = "SWAP";
18
- /** Hunk-header keyword for insertion operations. */
19
- export const HL_INSERT_KEYWORD = "INS";
20
- /** Insert position keyword for inserting before a concrete line. */
21
- export const HL_INSERT_BEFORE = "PRE";
22
- /** Insert position keyword for inserting after a concrete line. */
23
- export const HL_INSERT_AFTER = "POST";
24
- /** Insert position keyword for inserting at the start of the file. */
25
- export const HL_INSERT_HEAD = "HEAD";
26
- /** Insert position keyword for inserting at the end of the file. */
27
- export const HL_INSERT_TAIL = "TAIL";
28
- /** Hunk-header keyword: `SWAP.BLK N:` resolves N to a tree-sitter block range and replaces its span. */
29
- export const HL_REPLACE_BLOCK_KEYWORD = "SWAP.BLK";
30
- /** Hunk-header keyword: `INS.BLK.POST N:` inserts after the last line of the tree-sitter block at N. */
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. */
16
+ /** Hunk-header keyword: `PUT` writes content (body rows) or a register at a span or gap. */
17
+ export const HL_PUT_KEYWORD = "PUT";
18
+ /** Hunk-header keyword: `CUT N.=M` / `CUT N*` deletes lines and captures them (anonymous register, or `@name` when given). */
33
19
  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";
40
20
  /** File-level keyword: `REM` deletes the whole file named by the section header. */
41
21
  export const HL_REM_KEYWORD = "REM";
42
22
  /** File-level keyword: `MV DEST` renames/moves the section file to `DEST`. */
43
23
  export const HL_MOVE_KEYWORD = "MV";
44
24
  export const HL_HEADER_COLON = ":";
45
25
 
26
+ /** Gap sigil: `<N` targets the gap before line N (`<1` = head). */
27
+ export const HL_GAP_BEFORE = "<";
28
+ /** Gap sigil: `>N` targets the gap after line N (`>$` = tail). */
29
+ export const HL_GAP_AFTER = ">";
30
+ /** Locator suffix: `N*` extends the anchor to the syntactic block opening at N. */
31
+ export const HL_BLOCK_SUFFIX = "*";
32
+ /** Gap anchor: `$` names the last line, so `>$` is end-of-file. */
33
+ export const HL_EOF_ANCHOR = "$";
34
+ /** Register sigil: `@name` selects a named clipboard register on `PUT`/`CUT`. */
35
+ export const HL_REGISTER_SIGIL = "@";
36
+
46
37
  /** Separator between a hashline file path and its opaque snapshot tag. */
47
38
  export const HL_FILE_HASH_SEP = "#";
48
39
 
49
- /** Separator between two line numbers in a range, e.g. `5.=10`. */
40
+ /** Canonical separator between inclusive range endpoints, e.g. `5.=10`. */
50
41
  export const HL_RANGE_SEP = ".=";
51
42
 
52
43
  /** Separator between a line number and displayed line content in hashline mode. */
@@ -62,30 +53,40 @@ export const HL_LINE_RE_RAW = `[1-9]\\d*`;
62
53
  /** Capture-group form of {@link HL_LINE_RE_RAW}. */
63
54
  export const HL_LINE_CAPTURE_RE_RAW = `(${HL_LINE_RE_RAW})`;
64
55
 
65
- /** Format a concrete replacement hunk header. */
56
+ /** Format a concrete replacement hunk header (`PUT 5.=9:`). */
66
57
  export function formatReplaceHeader(start: number, end: number): string {
67
- return `${HL_REPLACE_KEYWORD} ${start}${HL_RANGE_SEP}${end}${HL_HEADER_COLON}`;
58
+ return `${HL_PUT_KEYWORD} ${start}${HL_RANGE_SEP}${end}${HL_HEADER_COLON}`;
68
59
  }
69
60
 
70
- /** Format a concrete cut hunk header. */
61
+ /** Format a concrete cut hunk header (`CUT 5.=9`). */
71
62
  export function formatCutHeader(start: number, end = start): string {
72
- return start === end ? `${HL_CUT_KEYWORD} ${start}` : `${HL_CUT_KEYWORD} ${start}${HL_RANGE_SEP}${end}`;
63
+ return `${HL_CUT_KEYWORD} ${start}${HL_RANGE_SEP}${end}`;
73
64
  }
74
65
 
75
- /** Format an insertion hunk header for a cursor position. */
76
- export function formatInsertHeader(cursor: Cursor): string {
66
+ /** Format a gap locator for a cursor position (`<5`, `>5`, `<1`, `>$`). */
67
+ export function formatGapLocator(cursor: Cursor): string {
77
68
  switch (cursor.kind) {
78
69
  case "before_anchor":
79
- return `${HL_INSERT_KEYWORD}.${HL_INSERT_BEFORE} ${cursor.anchor.line}${HL_HEADER_COLON}`;
70
+ return `${HL_GAP_BEFORE}${cursor.anchor.line}`;
80
71
  case "after_anchor":
81
- return `${HL_INSERT_KEYWORD}.${HL_INSERT_AFTER} ${cursor.anchor.line}${HL_HEADER_COLON}`;
72
+ return `${HL_GAP_AFTER}${cursor.anchor.line}`;
82
73
  case "bof":
83
- return `${HL_INSERT_KEYWORD}.${HL_INSERT_HEAD}${HL_HEADER_COLON}`;
74
+ return `${HL_GAP_BEFORE}1`;
84
75
  case "eof":
85
- return `${HL_INSERT_KEYWORD}.${HL_INSERT_TAIL}${HL_HEADER_COLON}`;
76
+ return `${HL_GAP_AFTER}${HL_EOF_ANCHOR}`;
86
77
  }
87
78
  }
88
79
 
80
+ /** Format an insertion hunk header for a cursor position (`PUT <5:`, `PUT >$:`). */
81
+ export function formatInsertHeader(cursor: Cursor): string {
82
+ return `${HL_PUT_KEYWORD} ${formatGapLocator(cursor)}${HL_HEADER_COLON}`;
83
+ }
84
+
85
+ /** Format a register reference (`@name`). */
86
+ export function formatRegister(name: string): string {
87
+ return `${HL_REGISTER_SIGIL}${name}`;
88
+ }
89
+
89
90
  /** Number of hex characters in a content-derived file-hash tag. */
90
91
  export const HL_FILE_HASH_LENGTH = 4;
91
92
  /** Canonical uppercase hexadecimal content-hash tag carried by a hashline section header. */
package/src/grammar.lark CHANGED
@@ -7,20 +7,21 @@ file_header: "[" filename "#" file_hash "]" LF
7
7
  file_hash: /[0-9A-F]{4}/
8
8
  filename: /[^#\r\n]+/
9
9
 
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
10
+ hunk: put_hunk | cut_hunk | rem_hunk | mv_hunk
11
+ put_hunk: "PUT " put_locator ":" LF body+
12
+ | "PUT " put_locator register LF
13
+ | "PUT " gap_locator LF
14
+ cut_hunk: "CUT " cut_locator register? LF
16
15
  rem_hunk: "REM" LF
17
- mv_hunk: "MV " filename LF body*
16
+ mv_hunk: "MV " filename LF
18
17
 
19
- target: " " header_range | ".BLK " LID
20
- pos: "PRE " LID | "POST " LID | "BLK.POST " LID | "HEAD" | "TAIL"
21
- body: "+" /(.*)/ LF
18
+ put_locator: range | LID "*" | gap_locator
19
+ cut_locator: range | LID "*"
20
+ gap_locator: "<" LID | ">" LID | ">" LID "*" | ">$"
21
+ register: " @" /[A-Za-z0-9_-]+/
22
22
 
23
- header_range: LID ".=" LID
23
+ range: LID ".=" LID
24
24
  LID: /[1-9]\d*/
25
+ body: "+" /(.*)/ LF
25
26
 
26
27
  %import common.LF
package/src/input.ts CHANGED
@@ -305,11 +305,11 @@ export class PatchSection {
305
305
  */
306
306
  get hasAnchorScopedEdit(): boolean {
307
307
  return this.edits.some(edit => {
308
- if (edit.kind === "delete") return true;
309
- // A `replace_block N:` edit is anchored to concrete content on line N.
310
- if (edit.kind === "block") return true;
311
- // A `CUT` range reads concrete content.
312
- if (edit.kind === "cut") return true;
308
+ if (edit.kind === "delete" || edit.kind === "block" || edit.kind === "cut") return true;
309
+ if (edit.kind === "paste") {
310
+ if (edit.at.kind === "span") return true;
311
+ return edit.at.cursor.kind === "before_anchor" || edit.at.cursor.kind === "after_anchor";
312
+ }
313
313
  return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor";
314
314
  });
315
315
  }
@@ -318,11 +318,7 @@ export class PatchSection {
318
318
  collectAnchorLines(): readonly number[] {
319
319
  const lines = new Set<number>();
320
320
  for (const edit of this.edits) {
321
- if (edit.kind === "delete") {
322
- lines.add(edit.anchor.line);
323
- continue;
324
- }
325
- if (edit.kind === "block") {
321
+ if (edit.kind === "delete" || edit.kind === "block") {
326
322
  lines.add(edit.anchor.line);
327
323
  continue;
328
324
  }
@@ -330,6 +326,14 @@ export class PatchSection {
330
326
  for (let line = edit.range.start.line; line <= edit.range.end.line; line++) lines.add(line);
331
327
  continue;
332
328
  }
329
+ if (edit.kind === "paste") {
330
+ if (edit.at.kind === "span") {
331
+ for (let line = edit.at.range.start.line; line <= edit.at.range.end.line; line++) lines.add(line);
332
+ } else if (edit.at.cursor.kind === "before_anchor" || edit.at.cursor.kind === "after_anchor") {
333
+ lines.add(edit.at.cursor.anchor.line);
334
+ }
335
+ continue;
336
+ }
333
337
  if (edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor") {
334
338
  lines.add(edit.cursor.anchor.line);
335
339
  }