@oh-my-pi/hashline 17.1.3 → 17.1.5
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 +7 -0
- package/dist/types/messages.d.ts +14 -2
- package/dist/types/parser.d.ts +16 -1
- package/package.json +2 -2
- package/src/block.ts +64 -7
- package/src/messages.ts +79 -8
- package/src/parser.ts +31 -6
- package/src/patcher.ts +22 -2
- package/src/tokenizer.ts +12 -8
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [17.1.5] - 2026-07-27
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- Improved reversed-range and invalid block-anchor diagnostics with absolute endpoint corrections plus nearby syntactic opener suggestions, without auto-applying the suggested edit ([#6671](https://github.com/can1357/oh-my-pi/issues/6671)).
|
|
10
|
+
- Accepted a single dot between integer range endpoints, such as `DEL 235.258`, as an unambiguous range separator ([#6671](https://github.com/can1357/oh-my-pi/issues/6671)).
|
|
11
|
+
|
|
5
12
|
## [17.1.2] - 2026-07-24
|
|
6
13
|
|
|
7
14
|
### Changed
|
package/dist/types/messages.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** Centralized error/warning text for the hashline parser, applier, and patcher. */
|
|
2
|
+
import type { BlockSpan } from "./types.js";
|
|
2
3
|
/** Lines of context shown either side of a hash mismatch. */
|
|
3
4
|
export declare const MISMATCH_CONTEXT = 2;
|
|
4
5
|
/**
|
|
@@ -7,6 +8,10 @@ export declare const MISMATCH_CONTEXT = 2;
|
|
|
7
8
|
* contribute no rows.
|
|
8
9
|
*/
|
|
9
10
|
export declare function formatAnchoredContext(anchorLines: readonly number[], fileLines: readonly string[]): string[];
|
|
11
|
+
/** Concrete range operation rejected because its absolute end precedes its start. */
|
|
12
|
+
export type AbsoluteRangeOp = "replace" | "delete";
|
|
13
|
+
/** Explain absolute range endpoints and provide safe, non-applying retry forms. */
|
|
14
|
+
export declare function invalidAbsoluteRangeMessage(patchLine: number, start: number, end: number, op: AbsoluteRangeOp, block?: BlockSpan): string;
|
|
10
15
|
/** Optional patch envelope start marker; silently consumed. */
|
|
11
16
|
export declare const BEGIN_PATCH_MARKER = "*** Begin Patch";
|
|
12
17
|
/** Optional patch envelope end marker; terminates parsing. */
|
|
@@ -32,6 +37,13 @@ export declare const MINUS_ROW_REJECTED = "`-` rows are not valid; the range alr
|
|
|
32
37
|
export declare const EMPTY_REPLACE = "`SWAP N.=M:` needs at least one `+TEXT` body row. To delete lines, use `DEL N.=M`.";
|
|
33
38
|
/** `replace_block N:` hunk with no body. */
|
|
34
39
|
export declare const EMPTY_BLOCK = "`SWAP.BLK N:` needs at least one `+TEXT` body row. To delete a block, use `DEL.BLK N`.";
|
|
40
|
+
/** Optional source-aware suggestions appended to block-anchor diagnostics. */
|
|
41
|
+
export interface BlockDiagnosticSuggestions {
|
|
42
|
+
/** Closest following multi-line block that begins after the authored anchor. */
|
|
43
|
+
nextBlock?: BlockSpan;
|
|
44
|
+
/** Closest preceding multi-line block whose span contains the authored anchor. */
|
|
45
|
+
enclosingBlock?: BlockSpan;
|
|
46
|
+
}
|
|
35
47
|
/**
|
|
36
48
|
* Block-anchored replace/delete could not resolve to a syntactic block
|
|
37
49
|
* (unsupported language, blank/out-of-range line, no node beginning on N, or
|
|
@@ -40,7 +52,7 @@ export declare const EMPTY_BLOCK = "`SWAP.BLK N:` needs at least one `+TEXT` bod
|
|
|
40
52
|
* lowered to plain `insert after N:` instead (see
|
|
41
53
|
* {@link insertAfterBlockUnresolvedLoweredWarning}).
|
|
42
54
|
*/
|
|
43
|
-
export declare function blockUnresolvedMessage(line: number, op?: "replace" | "delete", fileLines?: readonly string[]): string;
|
|
55
|
+
export declare function blockUnresolvedMessage(line: number, op?: "replace" | "delete", fileLines?: readonly string[], suggestions?: BlockDiagnosticSuggestions): string;
|
|
44
56
|
/** Block-anchored edit reached a path with no {@link BlockResolver} wired in — a host-configuration bug. */
|
|
45
57
|
export declare const BLOCK_RESOLVER_UNAVAILABLE = "`SWAP.BLK`/`DEL.BLK`/`INS.BLK.POST` are not available here (no block resolver configured). Use a concrete line range.";
|
|
46
58
|
/**
|
|
@@ -160,4 +172,4 @@ export type BlockOp = "replace" | "delete" | "insert_after";
|
|
|
160
172
|
* form only earns its keep when it spares counting a closing line you cannot
|
|
161
173
|
* see. Reject and point at both fixes.
|
|
162
174
|
*/
|
|
163
|
-
export declare function blockSingleLineMessage(line: number, op: BlockOp): string;
|
|
175
|
+
export declare function blockSingleLineMessage(line: number, op: BlockOp, enclosingBlock?: BlockSpan): string;
|
package/dist/types/parser.d.ts
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
|
+
import { type AbsoluteRangeOp } from "./messages.js";
|
|
1
2
|
import { type Token } from "./tokenizer.js";
|
|
2
|
-
import type { Edit, FileOp } from "./types.js";
|
|
3
|
+
import type { BlockSpan, Edit, FileOp } from "./types.js";
|
|
4
|
+
/** Parser error carrying enough range metadata for source-aware diagnostic enrichment. */
|
|
5
|
+
export declare class InvalidAbsoluteRangeError extends Error {
|
|
6
|
+
/** Patch-language line containing the invalid range header. */
|
|
7
|
+
readonly patchLine: number;
|
|
8
|
+
/** Absolute first source line authored in the range. */
|
|
9
|
+
readonly startLine: number;
|
|
10
|
+
/** Invalid absolute last source line authored in the range. */
|
|
11
|
+
readonly endLine: number;
|
|
12
|
+
/** Operation whose range was invalid. */
|
|
13
|
+
readonly op: AbsoluteRangeOp;
|
|
14
|
+
constructor(patchLine: number, startLine: number, endLine: number, op: AbsoluteRangeOp, block?: BlockSpan);
|
|
15
|
+
/** Rebuild this error with a proven syntactic-block endpoint suggestion. */
|
|
16
|
+
withBlock(block: BlockSpan): InvalidAbsoluteRangeError;
|
|
17
|
+
}
|
|
3
18
|
export declare class Executor {
|
|
4
19
|
#private;
|
|
5
20
|
feed(token: Token): void;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/hashline",
|
|
4
|
-
"version": "17.1.
|
|
4
|
+
"version": "17.1.5",
|
|
5
5
|
"description": "Hashline: a compact, line-anchored patch language and applier. Pluggable FS/IO so it works over disk, in-memory, or any custom backend.",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"fmt": "biome format --write ."
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@oh-my-pi/pi-natives": "17.1.
|
|
36
|
+
"@oh-my-pi/pi-natives": "17.1.5",
|
|
37
37
|
"lru-cache": "11.5.2"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
package/src/block.ts
CHANGED
|
@@ -15,12 +15,57 @@
|
|
|
15
15
|
import { STRUCTURAL_CLOSER_RE } from "./apply";
|
|
16
16
|
import {
|
|
17
17
|
BLOCK_RESOLVER_UNAVAILABLE,
|
|
18
|
+
type BlockDiagnosticSuggestions,
|
|
18
19
|
blockSingleLineMessage,
|
|
19
20
|
blockUnresolvedMessage,
|
|
20
21
|
insertAfterBlockCloserLoweredWarning,
|
|
21
22
|
insertAfterBlockUnresolvedLoweredWarning,
|
|
22
23
|
} from "./messages";
|
|
23
|
-
import type { BlockResolution, BlockResolver, Cursor, Edit } from "./types";
|
|
24
|
+
import type { BlockResolution, BlockResolver, BlockSpan, Cursor, Edit } from "./types";
|
|
25
|
+
|
|
26
|
+
/** Maximum nearby lines inspected only after a block anchor has already failed. */
|
|
27
|
+
const BLOCK_SUGGESTION_SCAN_LIMIT = 64;
|
|
28
|
+
|
|
29
|
+
function resolveDiagnosticBlock(resolver: BlockResolver, path: string, text: string, line: number): BlockSpan | null {
|
|
30
|
+
try {
|
|
31
|
+
return resolver({ path, text, line });
|
|
32
|
+
} catch {
|
|
33
|
+
// Suggestions are best-effort and must never hide the authoritative anchor error.
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function findNextBlock(
|
|
39
|
+
anchorLine: number,
|
|
40
|
+
lines: readonly string[],
|
|
41
|
+
path: string,
|
|
42
|
+
text: string,
|
|
43
|
+
resolver: BlockResolver,
|
|
44
|
+
): BlockSpan | null {
|
|
45
|
+
const lastLine = Math.min(lines.length, anchorLine + BLOCK_SUGGESTION_SCAN_LIMIT);
|
|
46
|
+
for (let line = anchorLine + 1; line <= lastLine; line++) {
|
|
47
|
+
if (lines[line - 1]?.trim().length === 0) continue;
|
|
48
|
+
const span = resolveDiagnosticBlock(resolver, path, text, line);
|
|
49
|
+
if (span?.start === line && span.end > line) return span;
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function findEnclosingBlock(
|
|
55
|
+
anchorLine: number,
|
|
56
|
+
lines: readonly string[],
|
|
57
|
+
path: string,
|
|
58
|
+
text: string,
|
|
59
|
+
resolver: BlockResolver,
|
|
60
|
+
): BlockSpan | null {
|
|
61
|
+
const firstLine = Math.max(1, anchorLine - BLOCK_SUGGESTION_SCAN_LIMIT);
|
|
62
|
+
for (let line = anchorLine - 1; line >= firstLine; line--) {
|
|
63
|
+
if (lines[line - 1]?.trim().length === 0) continue;
|
|
64
|
+
const span = resolveDiagnosticBlock(resolver, path, text, line);
|
|
65
|
+
if (span?.start === line && span.end >= anchorLine && span.end > line) return span;
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
24
69
|
|
|
25
70
|
export interface ResolveBlockEditsOptions {
|
|
26
71
|
/**
|
|
@@ -105,11 +150,18 @@ export function resolveBlockEdits(
|
|
|
105
150
|
continue;
|
|
106
151
|
}
|
|
107
152
|
if (onUnresolved === "drop") continue;
|
|
108
|
-
throw new Error(
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
153
|
+
if (!resolver) throw new Error(`line ${edit.lineNum}: ${BLOCK_RESOLVER_UNAVAILABLE}`);
|
|
154
|
+
const lines = text.split("\n");
|
|
155
|
+
const nextBlock =
|
|
156
|
+
lines[edit.anchor.line - 1]?.trim().length === 0
|
|
157
|
+
? findNextBlock(edit.anchor.line, lines, path, text, resolver)
|
|
158
|
+
: null;
|
|
159
|
+
const enclosingBlock =
|
|
160
|
+
nextBlock === null ? findEnclosingBlock(edit.anchor.line, lines, path, text, resolver) : null;
|
|
161
|
+
const suggestions: BlockDiagnosticSuggestions = {};
|
|
162
|
+
if (nextBlock) suggestions.nextBlock = nextBlock;
|
|
163
|
+
if (enclosingBlock) suggestions.enclosingBlock = enclosingBlock;
|
|
164
|
+
throw new Error(`line ${edit.lineNum}: ${blockUnresolvedMessage(edit.anchor.line, op, lines, suggestions)}`);
|
|
113
165
|
}
|
|
114
166
|
if (span.start === span.end) {
|
|
115
167
|
// A single-line block resolution means line N is a bare statement, not
|
|
@@ -118,7 +170,12 @@ export function resolveBlockEdits(
|
|
|
118
170
|
// and its `break;`). The plain op is exact for one line, so reject and
|
|
119
171
|
// point at it; drop instead on the lenient preview path.
|
|
120
172
|
if (onUnresolved === "drop") continue;
|
|
121
|
-
|
|
173
|
+
const enclosingBlock = resolver
|
|
174
|
+
? findEnclosingBlock(edit.anchor.line, text.split("\n"), path, text, resolver)
|
|
175
|
+
: null;
|
|
176
|
+
throw new Error(
|
|
177
|
+
`line ${edit.lineNum}: ${blockSingleLineMessage(edit.anchor.line, op, enclosingBlock ?? undefined)}`,
|
|
178
|
+
);
|
|
122
179
|
}
|
|
123
180
|
options.onResolved?.({
|
|
124
181
|
anchorLine: edit.anchor.line,
|
package/src/messages.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** Centralized error/warning text for the hashline parser, applier, and patcher. */
|
|
2
2
|
|
|
3
3
|
import { formatNumberedLine, HL_FILE_HASH_SEP, HL_FILE_PREFIX, HL_FILE_SUFFIX, HL_RANGE_SEP } from "./format";
|
|
4
|
+
import type { BlockSpan } from "./types";
|
|
4
5
|
|
|
5
6
|
/** Lines of context shown either side of a hash mismatch. */
|
|
6
7
|
export const MISMATCH_CONTEXT = 2;
|
|
@@ -29,6 +30,39 @@ export function formatAnchoredContext(anchorLines: readonly number[], fileLines:
|
|
|
29
30
|
}
|
|
30
31
|
return rows;
|
|
31
32
|
}
|
|
33
|
+
/** Concrete range operation rejected because its absolute end precedes its start. */
|
|
34
|
+
export type AbsoluteRangeOp = "replace" | "delete";
|
|
35
|
+
|
|
36
|
+
/** Explain absolute range endpoints and provide safe, non-applying retry forms. */
|
|
37
|
+
export function invalidAbsoluteRangeMessage(
|
|
38
|
+
patchLine: number,
|
|
39
|
+
start: number,
|
|
40
|
+
end: number,
|
|
41
|
+
op: AbsoluteRangeOp,
|
|
42
|
+
block?: BlockSpan,
|
|
43
|
+
): string {
|
|
44
|
+
const single = op === "replace" ? `SWAP ${start}${HL_RANGE_SEP}${start}:` : `DEL ${start}`;
|
|
45
|
+
const countedEnd = start + end - 1;
|
|
46
|
+
const counted =
|
|
47
|
+
Number.isSafeInteger(countedEnd) && countedEnd >= start
|
|
48
|
+
? op === "replace"
|
|
49
|
+
? `SWAP ${start}${HL_RANGE_SEP}${countedEnd}:`
|
|
50
|
+
: `DEL ${start}${HL_RANGE_SEP}${countedEnd}`
|
|
51
|
+
: null;
|
|
52
|
+
const blockForm = op === "replace" ? `SWAP.BLK ${start}:` : `DEL.BLK ${start}`;
|
|
53
|
+
let message =
|
|
54
|
+
`line ${patchLine}: Invalid absolute range: start ${start}, end ${end}. ` +
|
|
55
|
+
`The value after \`${HL_RANGE_SEP}\` is an absolute source line, not a line count or replacement length. ` +
|
|
56
|
+
`For one line use \`${single}\`.`;
|
|
57
|
+
if (counted !== null) {
|
|
58
|
+
message += ` For ${end} lines starting at ${start}, use \`${counted}\`.`;
|
|
59
|
+
}
|
|
60
|
+
if (block?.start === start && block.end > start) {
|
|
61
|
+
message +=
|
|
62
|
+
` The syntactic block beginning at ${start} ends at ${block.end}, ` + `so \`${blockForm}\` is also valid.`;
|
|
63
|
+
}
|
|
64
|
+
return message;
|
|
65
|
+
}
|
|
32
66
|
|
|
33
67
|
/** Optional patch envelope start marker; silently consumed. */
|
|
34
68
|
export const BEGIN_PATCH_MARKER = "*** Begin Patch";
|
|
@@ -70,6 +104,14 @@ export const EMPTY_REPLACE = `\`SWAP N${HL_RANGE_SEP}M:\` needs at least one \`+
|
|
|
70
104
|
/** `replace_block N:` hunk with no body. */
|
|
71
105
|
export const EMPTY_BLOCK = "`SWAP.BLK N:` needs at least one `+TEXT` body row. To delete a block, use `DEL.BLK N`.";
|
|
72
106
|
|
|
107
|
+
/** Optional source-aware suggestions appended to block-anchor diagnostics. */
|
|
108
|
+
export interface BlockDiagnosticSuggestions {
|
|
109
|
+
/** Closest following multi-line block that begins after the authored anchor. */
|
|
110
|
+
nextBlock?: BlockSpan;
|
|
111
|
+
/** Closest preceding multi-line block whose span contains the authored anchor. */
|
|
112
|
+
enclosingBlock?: BlockSpan;
|
|
113
|
+
}
|
|
114
|
+
|
|
73
115
|
/**
|
|
74
116
|
* Block-anchored replace/delete could not resolve to a syntactic block
|
|
75
117
|
* (unsupported language, blank/out-of-range line, no node beginning on N, or
|
|
@@ -82,12 +124,31 @@ export function blockUnresolvedMessage(
|
|
|
82
124
|
line: number,
|
|
83
125
|
op: "replace" | "delete" = "replace",
|
|
84
126
|
fileLines?: readonly string[],
|
|
127
|
+
suggestions: BlockDiagnosticSuggestions = {},
|
|
85
128
|
): string {
|
|
86
129
|
const phrase = op === "delete" ? `DEL.BLK ${line}` : `SWAP.BLK ${line}:`;
|
|
87
130
|
const fallback = op === "delete" ? `DEL ${line}${HL_RANGE_SEP}M` : `SWAP ${line}${HL_RANGE_SEP}M:`;
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
131
|
+
const anchorText = fileLines?.[line - 1];
|
|
132
|
+
const nextBlock = suggestions.nextBlock;
|
|
133
|
+
let message: string;
|
|
134
|
+
if (anchorText !== undefined && anchorText.trim().length === 0 && nextBlock) {
|
|
135
|
+
const retry = op === "delete" ? `DEL.BLK ${nextBlock.start}` : `SWAP.BLK ${nextBlock.start}:`;
|
|
136
|
+
message =
|
|
137
|
+
`Line ${line} is blank; no syntactic block can begin there. ` +
|
|
138
|
+
`The next multi-line block begins at line ${nextBlock.start} and ends at line ${nextBlock.end}. ` +
|
|
139
|
+
`Retry \`${retry}\`.`;
|
|
140
|
+
} else {
|
|
141
|
+
message =
|
|
142
|
+
`\`${phrase}\` could not resolve a syntactic block beginning on line ${line} ` +
|
|
143
|
+
`(unsupported language, blank/closer line, or parse error). Use \`${fallback}\` with explicit lines.`;
|
|
144
|
+
}
|
|
145
|
+
const enclosingBlock = suggestions.enclosingBlock;
|
|
146
|
+
if (enclosingBlock) {
|
|
147
|
+
const retry = op === "delete" ? `DEL.BLK ${enclosingBlock.start}` : `SWAP.BLK ${enclosingBlock.start}:`;
|
|
148
|
+
message +=
|
|
149
|
+
` The nearest enclosing multi-line block begins at line ${enclosingBlock.start} ` +
|
|
150
|
+
`and ends at line ${enclosingBlock.end}; use \`${retry}\` to target it.`;
|
|
151
|
+
}
|
|
91
152
|
if (fileLines) {
|
|
92
153
|
const context = formatAnchoredContext([line], fileLines);
|
|
93
154
|
if (context.length > 0) message += `\n\n${context.join("\n")}`;
|
|
@@ -344,7 +405,7 @@ export type BlockOp = "replace" | "delete" | "insert_after";
|
|
|
344
405
|
* form only earns its keep when it spares counting a closing line you cannot
|
|
345
406
|
* see. Reject and point at both fixes.
|
|
346
407
|
*/
|
|
347
|
-
export function blockSingleLineMessage(line: number, op: BlockOp): string {
|
|
408
|
+
export function blockSingleLineMessage(line: number, op: BlockOp, enclosingBlock?: BlockSpan): string {
|
|
348
409
|
const blockForm = op === "insert_after" ? "INS.BLK.POST" : op === "delete" ? "DEL.BLK" : "SWAP.BLK";
|
|
349
410
|
const plainForm =
|
|
350
411
|
op === "insert_after"
|
|
@@ -352,9 +413,19 @@ export function blockSingleLineMessage(line: number, op: BlockOp): string {
|
|
|
352
413
|
: op === "delete"
|
|
353
414
|
? `DEL ${line}`
|
|
354
415
|
: `SWAP ${line}${HL_RANGE_SEP}${line}:`;
|
|
355
|
-
|
|
416
|
+
let message =
|
|
356
417
|
`\`${blockForm} ${line}\` resolved a single-line block — line ${line} is a bare statement, not the opening line ` +
|
|
357
|
-
`of a multi-line construct. For
|
|
358
|
-
|
|
359
|
-
|
|
418
|
+
`of a multi-line construct. For only this statement use \`${plainForm}\`.`;
|
|
419
|
+
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}:`;
|
|
426
|
+
message +=
|
|
427
|
+
` The nearest enclosing multi-line block begins at line ${enclosingBlock.start} ` +
|
|
428
|
+
`and ends at line ${enclosingBlock.end}; use \`${enclosingForm}\` to target it.`;
|
|
429
|
+
}
|
|
430
|
+
return message;
|
|
360
431
|
}
|
package/src/parser.ts
CHANGED
|
@@ -5,11 +5,13 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { HL_PAYLOAD_REPLACE, HL_RANGE_SEP } from "./format";
|
|
7
7
|
import {
|
|
8
|
+
type AbsoluteRangeOp,
|
|
8
9
|
BARE_BODY_AUTO_PIPED_WARNING,
|
|
9
10
|
DELETE_BLOCK_TAKES_NO_BODY,
|
|
10
11
|
DELETE_TAKES_NO_BODY,
|
|
11
12
|
EMPTY_BLOCK,
|
|
12
13
|
EMPTY_INSERT,
|
|
14
|
+
invalidAbsoluteRangeMessage,
|
|
13
15
|
MINUS_BULLET_AUTO_PIPED_WARNING,
|
|
14
16
|
MINUS_ROW_REJECTED,
|
|
15
17
|
MOVE_TAKES_NO_BODY,
|
|
@@ -17,13 +19,36 @@ import {
|
|
|
17
19
|
} from "./messages";
|
|
18
20
|
import { stripOneLeadingHashlinePrefix } from "./prefixes";
|
|
19
21
|
import { type BlockTarget, cloneCursor, type ParsedRange, type Token, Tokenizer } from "./tokenizer";
|
|
20
|
-
import type { Anchor, Cursor, Edit, FileOp } from "./types";
|
|
22
|
+
import type { Anchor, BlockSpan, Cursor, Edit, FileOp } from "./types";
|
|
23
|
+
/** Parser error carrying enough range metadata for source-aware diagnostic enrichment. */
|
|
24
|
+
export class InvalidAbsoluteRangeError extends Error {
|
|
25
|
+
/** Patch-language line containing the invalid range header. */
|
|
26
|
+
readonly patchLine: number;
|
|
27
|
+
/** Absolute first source line authored in the range. */
|
|
28
|
+
readonly startLine: number;
|
|
29
|
+
/** Invalid absolute last source line authored in the range. */
|
|
30
|
+
readonly endLine: number;
|
|
31
|
+
/** Operation whose range was invalid. */
|
|
32
|
+
readonly op: AbsoluteRangeOp;
|
|
33
|
+
|
|
34
|
+
constructor(patchLine: number, startLine: number, endLine: number, op: AbsoluteRangeOp, block?: BlockSpan) {
|
|
35
|
+
super(invalidAbsoluteRangeMessage(patchLine, startLine, endLine, op, block));
|
|
36
|
+
this.name = "InvalidAbsoluteRangeError";
|
|
37
|
+
this.patchLine = patchLine;
|
|
38
|
+
this.startLine = startLine;
|
|
39
|
+
this.endLine = endLine;
|
|
40
|
+
this.op = op;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Rebuild this error with a proven syntactic-block endpoint suggestion. */
|
|
44
|
+
withBlock(block: BlockSpan): InvalidAbsoluteRangeError {
|
|
45
|
+
return new InvalidAbsoluteRangeError(this.patchLine, this.startLine, this.endLine, this.op, block);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
21
48
|
|
|
22
|
-
function validateRangeOrder(range: ParsedRange, lineNum: number): void {
|
|
49
|
+
function validateRangeOrder(range: ParsedRange, lineNum: number, op: AbsoluteRangeOp): void {
|
|
23
50
|
if (range.end.line < range.start.line) {
|
|
24
|
-
throw new
|
|
25
|
-
`line ${lineNum}: range ${range.start.line}${HL_RANGE_SEP}${range.end.line} ends before it starts.`,
|
|
26
|
-
);
|
|
51
|
+
throw new InvalidAbsoluteRangeError(lineNum, range.start.line, range.end.line, op);
|
|
27
52
|
}
|
|
28
53
|
}
|
|
29
54
|
|
|
@@ -170,7 +195,7 @@ export class Executor {
|
|
|
170
195
|
case "op-block":
|
|
171
196
|
this.#discardPendingSkippableComments();
|
|
172
197
|
if (token.target.kind === "replace" || token.target.kind === "delete") {
|
|
173
|
-
validateRangeOrder(token.target.range, token.lineNum);
|
|
198
|
+
validateRangeOrder(token.target.range, token.lineNum, token.target.kind);
|
|
174
199
|
}
|
|
175
200
|
if (token.target.kind === "rem") {
|
|
176
201
|
this.#flushPending();
|
package/src/patcher.ts
CHANGED
|
@@ -38,9 +38,10 @@ import {
|
|
|
38
38
|
} from "./messages";
|
|
39
39
|
import { MismatchError } from "./mismatch";
|
|
40
40
|
import { detectLineEnding, type LineEnding, normalizeToLF, restoreLineEndings, stripBom } from "./normalize";
|
|
41
|
+
import { InvalidAbsoluteRangeError } from "./parser";
|
|
41
42
|
import { Recovery, type RecoveryResult } from "./recovery";
|
|
42
43
|
import type { Snapshot, SnapshotStore } from "./snapshots";
|
|
43
|
-
import type { ApplyResult, BlockResolution, BlockResolver, Edit, FileOp } from "./types";
|
|
44
|
+
import type { ApplyResult, BlockResolution, BlockResolver, BlockSpan, Edit, FileOp } from "./types";
|
|
44
45
|
|
|
45
46
|
/**
|
|
46
47
|
* Upper bound on the number of unseen anchor lines whose actual file content
|
|
@@ -277,6 +278,25 @@ export class Patcher {
|
|
|
277
278
|
}
|
|
278
279
|
}
|
|
279
280
|
|
|
281
|
+
async #parseWithRangeDiagnostics(section: PatchSection) {
|
|
282
|
+
try {
|
|
283
|
+
return section.parse();
|
|
284
|
+
} catch (error) {
|
|
285
|
+
if (!(error instanceof InvalidAbsoluteRangeError) || !this.blockResolver) throw error;
|
|
286
|
+
let span: BlockSpan | null = null;
|
|
287
|
+
try {
|
|
288
|
+
const read = await this.#tryRead(section.path);
|
|
289
|
+
if (read.exists) {
|
|
290
|
+
const normalized = normalizeToLF(stripBom(read.rawContent).text);
|
|
291
|
+
span = this.blockResolver({ path: section.path, text: normalized, line: error.startLine });
|
|
292
|
+
}
|
|
293
|
+
} catch {
|
|
294
|
+
// Source-aware enrichment is best-effort; preserve the actionable parser error.
|
|
295
|
+
}
|
|
296
|
+
throw span?.start === error.startLine && span.end > span.start ? error.withBlock(span) : error;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
280
300
|
/**
|
|
281
301
|
* Read a section's target file, parse the section, validate the snapshot
|
|
282
302
|
* tag (with recovery), and apply the edits in memory. Returns a
|
|
@@ -287,7 +307,7 @@ export class Patcher {
|
|
|
287
307
|
* tag mismatch ({@link MismatchError}).
|
|
288
308
|
*/
|
|
289
309
|
async prepare(section: PatchSection): Promise<PreparedSection> {
|
|
290
|
-
const parsed = section
|
|
310
|
+
const parsed = await this.#parseWithRangeDiagnostics(section);
|
|
291
311
|
const parseWarnings = [...parsed.warnings];
|
|
292
312
|
const fileOp = parsed.fileOp;
|
|
293
313
|
assertSectionHashPresent(section.path, section.fileHash);
|
package/src/tokenizer.ts
CHANGED
|
@@ -171,14 +171,18 @@ function scanRangeSeparator(line: string, index: number, end: number): number |
|
|
|
171
171
|
consumedSeparator = true;
|
|
172
172
|
continue;
|
|
173
173
|
}
|
|
174
|
-
if (
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
174
|
+
if (code === CHAR_DOT && cursor + 1 < end) {
|
|
175
|
+
const next = line.charCodeAt(cursor + 1);
|
|
176
|
+
if (next === CHAR_DOT || next === CHAR_EQUALS) {
|
|
177
|
+
cursor += 2;
|
|
178
|
+
consumedSeparator = true;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (isNonZeroDigitCode(next)) {
|
|
182
|
+
cursor++;
|
|
183
|
+
consumedSeparator = true;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
182
186
|
}
|
|
183
187
|
break;
|
|
184
188
|
}
|