@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 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
@@ -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;
@@ -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.3",
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.3",
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
- `line ${edit.lineNum}: ${
110
- resolver ? blockUnresolvedMessage(edit.anchor.line, op, text.split("\n")) : BLOCK_RESOLVER_UNAVAILABLE
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
- throw new Error(`line ${edit.lineNum}: ${blockSingleLineMessage(edit.anchor.line, op)}`);
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
- let message =
89
- `\`${phrase}\` could not resolve a syntactic block beginning on line ${line} ` +
90
- `(unsupported language, blank/closer line, or parse error). Use \`${fallback}\` with explicit lines.`;
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
- return (
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 that one line use \`${plainForm}\`; to act on an enclosing construct, anchor ${blockForm} ` +
358
- `on the line that OPENS it (e.g. its \`function\`/\`if\`/\`case\` header), never a statement inside it.`
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 Error(
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.parse();
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
- code === CHAR_DOT &&
176
- cursor + 1 < end &&
177
- (line.charCodeAt(cursor + 1) === CHAR_DOT || line.charCodeAt(cursor + 1) === CHAR_EQUALS)
178
- ) {
179
- cursor += 2;
180
- consumedSeparator = true;
181
- continue;
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
  }