@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/CHANGELOG.md +20 -0
- package/README.md +7 -7
- package/dist/types/clipboard.d.ts +6 -4
- package/dist/types/format.d.ts +21 -27
- package/dist/types/messages.d.ts +48 -27
- package/dist/types/parser.d.ts +2 -1
- package/dist/types/prefixes.d.ts +2 -0
- package/dist/types/tokenizer.d.ts +12 -8
- package/dist/types/types.d.ts +53 -25
- package/package.json +2 -2
- package/src/apply.ts +72 -1
- package/src/block.ts +27 -5
- package/src/clipboard.ts +141 -42
- package/src/format.ts +35 -34
- package/src/grammar.lark +12 -11
- package/src/input.ts +14 -10
- package/src/messages.ts +129 -65
- package/src/parser.ts +307 -103
- package/src/patcher.ts +8 -8
- package/src/prefixes.ts +12 -4
- package/src/prompt.md +37 -38
- package/src/recovery.ts +56 -15
- package/src/tokenizer.ts +160 -191
- package/src/types.ts +48 -25
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({
|
|
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(
|
|
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
|
-
|
|
236
|
-
|
|
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`
|
|
2
|
+
* Clipboard register support for `CUT` and register `PUT` ops.
|
|
3
3
|
*
|
|
4
|
-
* `CUT` captures
|
|
5
|
-
* `
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
19
|
-
|
|
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
|
|
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
|
-
/** `
|
|
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
|
|
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
|
-
|
|
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.
|
|
65
|
-
if (lines ===
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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.
|
|
94
|
-
|
|
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
|
-
|
|
194
|
+
const fork = forkClipboard(clipboard);
|
|
103
195
|
for (const edit of edits) {
|
|
104
196
|
if (edit.kind === "cut") {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
17
|
-
export const
|
|
18
|
-
/** Hunk-header keyword
|
|
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
|
-
/**
|
|
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 `${
|
|
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
|
|
63
|
+
return `${HL_CUT_KEYWORD} ${start}${HL_RANGE_SEP}${end}`;
|
|
73
64
|
}
|
|
74
65
|
|
|
75
|
-
/** Format
|
|
76
|
-
export function
|
|
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 `${
|
|
70
|
+
return `${HL_GAP_BEFORE}${cursor.anchor.line}`;
|
|
80
71
|
case "after_anchor":
|
|
81
|
-
return `${
|
|
72
|
+
return `${HL_GAP_AFTER}${cursor.anchor.line}`;
|
|
82
73
|
case "bof":
|
|
83
|
-
return `${
|
|
74
|
+
return `${HL_GAP_BEFORE}1`;
|
|
84
75
|
case "eof":
|
|
85
|
-
return `${
|
|
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:
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
cut_hunk: "CUT"
|
|
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
|
|
16
|
+
mv_hunk: "MV " filename LF
|
|
18
17
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
}
|