@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/CHANGELOG.md +21 -0
- package/README.md +2 -1
- package/dist/types/apply.d.ts +22 -2
- package/dist/types/block.d.ts +4 -7
- package/dist/types/clipboard.d.ts +22 -0
- package/dist/types/format.d.ts +10 -6
- package/dist/types/index.d.ts +1 -0
- package/dist/types/input.d.ts +15 -4
- package/dist/types/messages.d.ts +44 -36
- package/dist/types/patcher.d.ts +21 -6
- package/dist/types/recovery.d.ts +3 -1
- package/dist/types/tokenizer.d.ts +12 -6
- package/dist/types/types.d.ts +57 -30
- package/package.json +2 -2
- package/src/apply.ts +26 -5
- package/src/block.ts +52 -24
- package/src/clipboard.ts +110 -0
- package/src/format.ts +11 -7
- package/src/grammar.lark +12 -15
- package/src/index.ts +1 -0
- package/src/input.ts +44 -7
- package/src/messages.ts +113 -59
- package/src/parser.ts +96 -34
- package/src/patcher.ts +89 -15
- package/src/prompt.md +51 -89
- package/src/recovery.ts +28 -4
- package/src/tokenizer.ts +85 -38
- package/src/types.ts +60 -30
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
|
|
1258
|
+
for (const edit of concrete) {
|
|
1237
1259
|
if (edit.kind === "block") throw new Error(UNRESOLVED_BLOCK_INTERNAL);
|
|
1238
1260
|
}
|
|
1239
|
-
const appliedEdits =
|
|
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
|
|
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* {@link
|
|
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/
|
|
73
|
-
* (
|
|
74
|
-
*
|
|
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
|
|
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
|
|
206
|
-
//
|
|
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({
|
package/src/clipboard.ts
ADDED
|
@@ -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
|
|
67
|
-
export function
|
|
68
|
-
return start === 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:
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
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
|
}
|