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