@prometheus-ai/hashline 0.5.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.
@@ -0,0 +1,471 @@
1
+ /**
2
+ * Stateful, line-oriented classifier for hashline diff text.
3
+ *
4
+ * Format shape:
5
+ * ```
6
+ * [path/to/file.ts#1A2B]
7
+ * replace 5..7:
8
+ * +literal new line
9
+ * ```
10
+ */
11
+ import {
12
+ describeAnchorExamples,
13
+ HL_BLOCK_KEYWORD,
14
+ HL_DELETE_KEYWORD,
15
+ HL_FILE_HASH_LENGTH,
16
+ HL_FILE_HASH_SEP,
17
+ HL_FILE_PREFIX,
18
+ HL_FILE_SUFFIX,
19
+ HL_HEADER_COLON,
20
+ HL_INSERT_AFTER,
21
+ HL_INSERT_BEFORE,
22
+ HL_INSERT_HEAD,
23
+ HL_INSERT_KEYWORD,
24
+ HL_INSERT_TAIL,
25
+ HL_PAYLOAD_REPLACE,
26
+ HL_REPLACE_KEYWORD,
27
+ } from "./format";
28
+ import { ABORT_MARKER, BEGIN_PATCH_MARKER, END_PATCH_MARKER } from "./messages";
29
+ import type { Anchor, Cursor, ParsedRange } from "./types";
30
+
31
+ const CHAR_LINE_FEED = 10;
32
+ const CHAR_CARRIAGE_RETURN = 13;
33
+ const CHAR_ZERO = 48;
34
+ const CHAR_NINE = 57;
35
+ const CHAR_HASH = 35;
36
+ const CHAR_TAB = 9;
37
+ const CHAR_SPACE = 32;
38
+ const CHAR_DOT = 46;
39
+ const CHAR_HYPHEN = 45;
40
+ const CHAR_ELLIPSIS = 0x2026;
41
+
42
+ const CHAR_UPPER_A = 65;
43
+ const CHAR_UPPER_F = 70;
44
+ const CHAR_LOWER_A = 97;
45
+ const CHAR_LOWER_F = 102;
46
+ const CHAR_PAYLOAD_REPLACE = HL_PAYLOAD_REPLACE.charCodeAt(0);
47
+ const CHAR_COLON = HL_HEADER_COLON.charCodeAt(0);
48
+ const FILE_PREFIX_LENGTH = HL_FILE_PREFIX.length;
49
+ const FILE_SUFFIX_LENGTH = HL_FILE_SUFFIX.length;
50
+
51
+ function isDigitCode(code: number): boolean {
52
+ return code >= CHAR_ZERO && code <= CHAR_NINE;
53
+ }
54
+
55
+ function isNonZeroDigitCode(code: number): boolean {
56
+ return code > CHAR_ZERO && code <= CHAR_NINE;
57
+ }
58
+
59
+ function isHexDigitCode(code: number): boolean {
60
+ return (
61
+ isDigitCode(code) ||
62
+ (code >= CHAR_UPPER_A && code <= CHAR_UPPER_F) ||
63
+ (code >= CHAR_LOWER_A && code <= CHAR_LOWER_F)
64
+ );
65
+ }
66
+
67
+ function isWhitespaceCode(code: number): boolean {
68
+ return code === CHAR_SPACE || (code >= CHAR_TAB && code <= CHAR_CARRIAGE_RETURN);
69
+ }
70
+
71
+ function skipWhitespace(line: string, index: number, end = line.length): number {
72
+ while (index < end && isWhitespaceCode(line.charCodeAt(index))) index++;
73
+ return index;
74
+ }
75
+
76
+ function trimEndIndex(line: string): number {
77
+ let end = line.length;
78
+ while (end > 0 && isWhitespaceCode(line.charCodeAt(end - 1))) end--;
79
+ return end;
80
+ }
81
+
82
+ function isEmptyLine(line: string): boolean {
83
+ return line.length === 0;
84
+ }
85
+
86
+ function markerLineEquals(line: string, marker: string): boolean {
87
+ const end = trimEndIndex(line);
88
+ return end === marker.length && line.startsWith(marker);
89
+ }
90
+
91
+ export function splitHashlineLines(text: string): string[] {
92
+ if (text.length === 0) return [""];
93
+ const lines: string[] = [];
94
+ let start = 0;
95
+ for (let index = 0; index < text.length; index++) {
96
+ if (text.charCodeAt(index) !== CHAR_LINE_FEED) continue;
97
+ let end = index;
98
+ if (end > start && text.charCodeAt(end - 1) === CHAR_CARRIAGE_RETURN) end--;
99
+ lines.push(text.slice(start, end));
100
+ start = index + 1;
101
+ }
102
+ if (start < text.length) {
103
+ let end = text.length;
104
+ if (end > start && text.charCodeAt(end - 1) === CHAR_CARRIAGE_RETURN) end--;
105
+ lines.push(text.slice(start, end));
106
+ }
107
+ return lines;
108
+ }
109
+
110
+ export function cloneCursor(cursor: Cursor): Cursor {
111
+ if (cursor.kind === "before_anchor") return { kind: "before_anchor", anchor: { ...cursor.anchor } };
112
+ if (cursor.kind === "after_anchor") return { kind: "after_anchor", anchor: { ...cursor.anchor } };
113
+ return cursor;
114
+ }
115
+
116
+ interface NumberScan {
117
+ line: number;
118
+ nextIndex: number;
119
+ }
120
+
121
+ function scanLineNumber(line: string, index: number, end: number): NumberScan | null {
122
+ if (index >= end || !isNonZeroDigitCode(line.charCodeAt(index))) return null;
123
+ let lineNumber = 0;
124
+ let nextIndex = index;
125
+ while (nextIndex < end) {
126
+ const code = line.charCodeAt(nextIndex);
127
+ if (!isDigitCode(code)) break;
128
+ lineNumber = lineNumber * 10 + (code - CHAR_ZERO);
129
+ nextIndex++;
130
+ }
131
+ return { line: lineNumber, nextIndex };
132
+ }
133
+
134
+ /** Parse a bare line-number anchor. Throws on malformed input. */
135
+ export function parseLid(raw: string, lineNum: number): Anchor {
136
+ const end = trimEndIndex(raw);
137
+ const numberStart = skipWhitespace(raw, 0, end);
138
+ const number = scanLineNumber(raw, numberStart, end);
139
+ if (number === null || skipWhitespace(raw, number.nextIndex, end) !== end) {
140
+ throw new Error(
141
+ `line ${lineNum}: expected a line number such as ${describeAnchorExamples("119")}; ` +
142
+ `got ${JSON.stringify(raw)}. Use ${HL_FILE_PREFIX}PATH${HL_FILE_HASH_SEP}hash${HL_FILE_SUFFIX} from your latest read for file-version binding.`,
143
+ );
144
+ }
145
+ return { line: number.line };
146
+ }
147
+
148
+ interface RangeScan {
149
+ range: ParsedRange;
150
+ nextIndex: number;
151
+ }
152
+
153
+ function scanRangeSeparator(line: string, index: number, end: number): number | null {
154
+ let cursor = index;
155
+ let consumedSeparator = false;
156
+ while (cursor < end) {
157
+ const code = line.charCodeAt(cursor);
158
+ if (isWhitespaceCode(code)) {
159
+ cursor++;
160
+ consumedSeparator = true;
161
+ continue;
162
+ }
163
+ if (code === CHAR_HYPHEN || code === CHAR_ELLIPSIS) {
164
+ cursor++;
165
+ consumedSeparator = true;
166
+ continue;
167
+ }
168
+ if (code === CHAR_DOT && cursor + 1 < end && line.charCodeAt(cursor + 1) === CHAR_DOT) {
169
+ cursor += 2;
170
+ consumedSeparator = true;
171
+ continue;
172
+ }
173
+ break;
174
+ }
175
+ if (!consumedSeparator) return null;
176
+ if (cursor >= end || !isNonZeroDigitCode(line.charCodeAt(cursor))) return null;
177
+ return cursor;
178
+ }
179
+
180
+ function scanHeaderRange(line: string, index = 0, end = trimEndIndex(line), allowSingle = false): RangeScan | null {
181
+ const numberStart = skipWhitespace(line, index, end);
182
+ const start = scanLineNumber(line, numberStart, end);
183
+ if (start === null) return null;
184
+ const afterFirst = scanRangeSeparator(line, start.nextIndex, end);
185
+ if (afterFirst === null) {
186
+ if (!allowSingle) return null;
187
+ return {
188
+ range: { start: { line: start.line }, end: { line: start.line } },
189
+ nextIndex: skipWhitespace(line, start.nextIndex, end),
190
+ };
191
+ }
192
+ const endNumber = scanLineNumber(line, afterFirst, end);
193
+ if (endNumber === null) return null;
194
+ return {
195
+ range: { start: { line: start.line }, end: { line: endNumber.line } },
196
+ nextIndex: skipWhitespace(line, endNumber.nextIndex, end),
197
+ };
198
+ }
199
+
200
+ export type BlockTarget =
201
+ | { kind: "replace"; range: ParsedRange }
202
+ | { kind: "block"; anchor: Anchor }
203
+ | { kind: "delete"; range: ParsedRange }
204
+ | { kind: "delete_block"; anchor: Anchor }
205
+ | { kind: "insert_before"; anchor: Anchor }
206
+ | { kind: "insert_after"; anchor: Anchor }
207
+ | { kind: "bof" }
208
+ | { kind: "eof" };
209
+
210
+ interface TargetScan {
211
+ target: BlockTarget;
212
+ nextIndex: number;
213
+ }
214
+
215
+ function scanKeyword(line: string, index: number, end: number, keyword: string): number | null {
216
+ if (!line.startsWith(keyword, index)) return null;
217
+ const next = index + keyword.length;
218
+ if (next < end) {
219
+ const code = line.charCodeAt(next);
220
+ if (!isWhitespaceCode(code) && code !== CHAR_COLON) return null;
221
+ }
222
+ return next;
223
+ }
224
+
225
+ function consumeOptionalColon(line: string, index: number, end: number): number {
226
+ const cursor = skipWhitespace(line, index, end);
227
+ return cursor < end && line.charCodeAt(cursor) === CHAR_COLON ? skipWhitespace(line, cursor + 1, end) : cursor;
228
+ }
229
+
230
+ function scanInsertTarget(line: string, index: number, end: number): TargetScan | null {
231
+ const cursor = skipWhitespace(line, index, end);
232
+ const beforeEnd = scanKeyword(line, cursor, end, HL_INSERT_BEFORE);
233
+ if (beforeEnd !== null) {
234
+ const anchor = scanLineNumber(line, skipWhitespace(line, beforeEnd, end), end);
235
+ if (anchor === null) return null;
236
+ const nextIndex = consumeOptionalColon(line, anchor.nextIndex, end);
237
+ return { target: { kind: "insert_before", anchor: { line: anchor.line } }, nextIndex };
238
+ }
239
+ const afterEnd = scanKeyword(line, cursor, end, HL_INSERT_AFTER);
240
+ if (afterEnd !== null) {
241
+ const anchor = scanLineNumber(line, skipWhitespace(line, afterEnd, end), end);
242
+ if (anchor === null) return null;
243
+ const nextIndex = consumeOptionalColon(line, anchor.nextIndex, end);
244
+ return { target: { kind: "insert_after", anchor: { line: anchor.line } }, nextIndex };
245
+ }
246
+ const headEnd = scanKeyword(line, cursor, end, HL_INSERT_HEAD);
247
+ if (headEnd !== null) return { target: { kind: "bof" }, nextIndex: consumeOptionalColon(line, headEnd, end) };
248
+ const tailEnd = scanKeyword(line, cursor, end, HL_INSERT_TAIL);
249
+ if (tailEnd !== null) return { target: { kind: "eof" }, nextIndex: consumeOptionalColon(line, tailEnd, end) };
250
+ return null;
251
+ }
252
+
253
+ function scanHunkAnchor(line: string, start: number, end: number): TargetScan | null {
254
+ const cursor = skipWhitespace(line, start, end);
255
+ const replaceEnd = scanKeyword(line, cursor, end, HL_REPLACE_KEYWORD);
256
+ if (replaceEnd !== null) {
257
+ // `replace block N:` — resolve N to a tree-sitter block range at apply
258
+ // time. Try the `block` sub-keyword before falling back to a literal
259
+ // `replace N..M:` range.
260
+ const blockEnd = scanKeyword(line, skipWhitespace(line, replaceEnd, end), end, HL_BLOCK_KEYWORD);
261
+ if (blockEnd !== null) {
262
+ const anchor = scanLineNumber(line, skipWhitespace(line, blockEnd, end), end);
263
+ if (anchor === null) return null;
264
+ return {
265
+ target: { kind: "block", anchor: { line: anchor.line } },
266
+ nextIndex: consumeOptionalColon(line, anchor.nextIndex, end),
267
+ };
268
+ }
269
+ const range = scanHeaderRange(line, replaceEnd, end, true);
270
+ if (range === null) return null;
271
+ return {
272
+ target: { kind: "replace", range: range.range },
273
+ nextIndex: consumeOptionalColon(line, range.nextIndex, end),
274
+ };
275
+ }
276
+ const deleteEnd = scanKeyword(line, cursor, end, HL_DELETE_KEYWORD);
277
+ if (deleteEnd !== null) {
278
+ // `delete block N` — resolve N to a tree-sitter block range at apply
279
+ // time and delete its whole span. Like `delete N..M`, it takes no body
280
+ // and no trailing colon.
281
+ const blockEnd = scanKeyword(line, skipWhitespace(line, deleteEnd, end), end, HL_BLOCK_KEYWORD);
282
+ if (blockEnd !== null) {
283
+ const anchor = scanLineNumber(line, skipWhitespace(line, blockEnd, end), end);
284
+ if (anchor === null) return null;
285
+ const next = skipWhitespace(line, anchor.nextIndex, end);
286
+ if (next < end && line.charCodeAt(next) === CHAR_COLON) return null;
287
+ return { target: { kind: "delete_block", anchor: { line: anchor.line } }, nextIndex: next };
288
+ }
289
+ const range = scanHeaderRange(line, deleteEnd, end, true);
290
+ if (range === null) return null;
291
+ const next = skipWhitespace(line, range.nextIndex, end);
292
+ if (next < end && line.charCodeAt(next) === CHAR_COLON) return null;
293
+ return { target: { kind: "delete", range: range.range }, nextIndex: next };
294
+ }
295
+ const insertEnd = scanKeyword(line, cursor, end, HL_INSERT_KEYWORD);
296
+ if (insertEnd !== null) return scanInsertTarget(line, insertEnd, end);
297
+ return null;
298
+ }
299
+
300
+ interface ParsedHunkHeader {
301
+ target: BlockTarget;
302
+ }
303
+
304
+ function tryParseHunkHeader(line: string): ParsedHunkHeader | null {
305
+ const end = trimEndIndex(line);
306
+ const start = skipWhitespace(line, 0, end);
307
+ if (start >= end) return null;
308
+ const scan = scanHunkAnchor(line, start, end);
309
+ if (scan === null) return null;
310
+ if (scan.nextIndex !== end) return null;
311
+ return { target: scan.target };
312
+ }
313
+
314
+ function tryParseHeader(line: string): { path: string; fileHash?: string } | null {
315
+ if (!line.startsWith(HL_FILE_PREFIX)) return null;
316
+ const end = trimEndIndex(line);
317
+ if (FILE_PREFIX_LENGTH + FILE_SUFFIX_LENGTH >= end) return null;
318
+ if (!line.endsWith(HL_FILE_SUFFIX, end)) return null;
319
+ const bodyEnd = end - FILE_SUFFIX_LENGTH;
320
+ if (FILE_PREFIX_LENGTH >= bodyEnd) return null;
321
+
322
+ // The snapshot tag, when present, is the trailing `#XXXX` block inside the
323
+ // bracketed header. We detect it from the suffix so the path may
324
+ // legitimately contain whitespace (e.g. `OneDrive - Company/file.ts`).
325
+ let pathEnd = bodyEnd;
326
+ let fileHash: string | undefined;
327
+ const trailingHashStart = bodyEnd - HL_FILE_HASH_LENGTH - 1;
328
+ if (trailingHashStart >= FILE_PREFIX_LENGTH && line.charCodeAt(trailingHashStart) === CHAR_HASH) {
329
+ let allHex = true;
330
+ for (let probe = trailingHashStart + 1; probe < bodyEnd; probe++) {
331
+ if (!isHexDigitCode(line.charCodeAt(probe))) {
332
+ allHex = false;
333
+ break;
334
+ }
335
+ }
336
+ if (allHex) {
337
+ pathEnd = trailingHashStart;
338
+ fileHash = line.slice(trailingHashStart + 1, bodyEnd).toUpperCase();
339
+ }
340
+ }
341
+
342
+ // The hashline header grammar uses `#` as the path/tag separator and
343
+ // does not allow `#` inside filenames. Anything `#` left in the path
344
+ // body — short tags (`#1A2`), non-hex tags (`#1A2G`), over-long tags
345
+ // (`#1A2B5`), stale-tag copy-paste (`#1A2B copied from read`), or
346
+ // line-suffixed tags (`#1A2B:42`) — means the header is malformed.
347
+ // Surface the focused diagnostic instead of silently mis-routing the
348
+ // edit or reporting a missing tag downstream.
349
+ for (let i = FILE_PREFIX_LENGTH; i < pathEnd; i++) {
350
+ if (line.charCodeAt(i) === CHAR_HASH) return null;
351
+ }
352
+
353
+ if (pathEnd === FILE_PREFIX_LENGTH) return null;
354
+ const path = line.slice(FILE_PREFIX_LENGTH, pathEnd);
355
+ return fileHash !== undefined ? { path, fileHash } : { path };
356
+ }
357
+
358
+ interface TokenBase {
359
+ lineNum: number;
360
+ }
361
+
362
+ export type Token =
363
+ | (TokenBase & { kind: "blank" })
364
+ | (TokenBase & { kind: "envelope-begin" })
365
+ | (TokenBase & { kind: "envelope-end" })
366
+ | (TokenBase & { kind: "abort" })
367
+ | (TokenBase & { kind: "header"; path: string; fileHash?: string })
368
+ | (TokenBase & { kind: "op-block"; target: BlockTarget })
369
+ | (TokenBase & { kind: "payload-literal"; text: string })
370
+ | (TokenBase & { kind: "raw"; text: string });
371
+
372
+ function classifyLine(line: string, lineNum: number): Token {
373
+ if (isEmptyLine(line)) return { kind: "blank", lineNum };
374
+ if (markerLineEquals(line, BEGIN_PATCH_MARKER)) return { kind: "envelope-begin", lineNum };
375
+ if (markerLineEquals(line, END_PATCH_MARKER)) return { kind: "envelope-end", lineNum };
376
+ if (markerLineEquals(line, ABORT_MARKER)) return { kind: "abort", lineNum };
377
+ const firstCode = line.charCodeAt(0);
378
+ if (line.startsWith(HL_FILE_PREFIX)) {
379
+ const header = tryParseHeader(line);
380
+ if (header !== null) {
381
+ return header.fileHash !== undefined
382
+ ? { kind: "header", lineNum, path: header.path, fileHash: header.fileHash }
383
+ : { kind: "header", lineNum, path: header.path };
384
+ }
385
+ }
386
+ const lead = skipWhitespace(line, 0);
387
+ const isHunkLead =
388
+ line.startsWith(HL_REPLACE_KEYWORD, lead) ||
389
+ line.startsWith(HL_DELETE_KEYWORD, lead) ||
390
+ line.startsWith(HL_INSERT_KEYWORD, lead);
391
+ if (isHunkLead) {
392
+ const hunk = tryParseHunkHeader(line);
393
+ if (hunk !== null) return { kind: "op-block", lineNum, target: hunk.target };
394
+ }
395
+ if (firstCode === CHAR_PAYLOAD_REPLACE) return { kind: "payload-literal", lineNum, text: line.slice(1) };
396
+ return { kind: "raw", lineNum, text: line };
397
+ }
398
+
399
+ export class Tokenizer {
400
+ #buffer = "";
401
+ #nextLineNum = 1;
402
+ #closed = false;
403
+
404
+ feed(chunk: string): Token[] {
405
+ if (this.#closed) throw new Error("Tokenizer is closed; call reset() before reusing.");
406
+ if (chunk.length === 0) return [];
407
+ this.#buffer = this.#buffer ? this.#buffer + chunk : chunk;
408
+ return this.#drainCompleteLines();
409
+ }
410
+
411
+ end(): Token[] {
412
+ if (this.#closed) return [];
413
+ this.#closed = true;
414
+ const buf = this.#buffer;
415
+ this.#buffer = "";
416
+ if (buf.length === 0) return [];
417
+ let stop = buf.length;
418
+ if (buf.charCodeAt(stop - 1) === CHAR_CARRIAGE_RETURN) stop--;
419
+ return [classifyLine(buf.slice(0, stop), this.#nextLineNum++)];
420
+ }
421
+
422
+ reset(): void {
423
+ this.#buffer = "";
424
+ this.#nextLineNum = 1;
425
+ this.#closed = false;
426
+ }
427
+
428
+ tokenizeAll(text: string): Token[] {
429
+ this.reset();
430
+ const first = this.feed(text);
431
+ const last = this.end();
432
+ return last.length === 0 ? first : first.concat(last);
433
+ }
434
+
435
+ tokenize(line: string, lineNum = 0): Token {
436
+ return classifyLine(line, lineNum);
437
+ }
438
+
439
+ isOp(line: string): boolean {
440
+ return tryParseHunkHeader(line) !== null;
441
+ }
442
+
443
+ isHeader(line: string): boolean {
444
+ return tryParseHeader(line) !== null;
445
+ }
446
+
447
+ isEnvelopeMarker(line: string): boolean {
448
+ return (
449
+ markerLineEquals(line, BEGIN_PATCH_MARKER) ||
450
+ markerLineEquals(line, END_PATCH_MARKER) ||
451
+ markerLineEquals(line, ABORT_MARKER)
452
+ );
453
+ }
454
+
455
+ #drainCompleteLines(): Token[] {
456
+ const tokens: Token[] = [];
457
+ const buf = this.#buffer;
458
+ let start = 0;
459
+ for (let index = 0; index < buf.length; index++) {
460
+ if (buf.charCodeAt(index) !== CHAR_LINE_FEED) continue;
461
+ let stop = index;
462
+ if (stop > start && buf.charCodeAt(stop - 1) === CHAR_CARRIAGE_RETURN) stop--;
463
+ tokens.push(classifyLine(buf.slice(start, stop), this.#nextLineNum++));
464
+ start = index + 1;
465
+ }
466
+ this.#buffer = start < buf.length ? buf.slice(start) : "";
467
+ return tokens;
468
+ }
469
+ }
470
+
471
+ export type { ParsedRange } from "./types";
package/src/types.ts ADDED
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Pure data types shared across the hashline parser, applier, and patcher.
3
+ * Nothing in this file references a filesystem, agent runtime, or schema
4
+ * library — keep it that way.
5
+ */
6
+
7
+ /** A line-number anchor (1-indexed). */
8
+ export interface Anchor {
9
+ line: number;
10
+ }
11
+
12
+ /** Where an `insert` edit should land relative to existing content. */
13
+ export type Cursor =
14
+ | { kind: "bof" }
15
+ | { kind: "eof" }
16
+ | { kind: "before_anchor"; anchor: Anchor }
17
+ | { kind: "after_anchor"; anchor: Anchor };
18
+
19
+ /**
20
+ * A single low-level edit produced by the parser and consumed by the applier.
21
+ * Multi-line replacements decompose to one `insert` per replacement line plus
22
+ * one `delete` per consumed line. Replacement payloads are tagged so the
23
+ * applier can distinguish literal insertion from new content for a deleted
24
+ * line.
25
+ */
26
+ export type Edit =
27
+ | {
28
+ kind: "insert";
29
+ cursor: Cursor;
30
+ text: string;
31
+ lineNum: number;
32
+ index: number;
33
+ mode?: "replacement";
34
+ }
35
+ | { kind: "delete"; anchor: Anchor; lineNum: number; index: number; oldAssertion?: string }
36
+ | {
37
+ /**
38
+ * Deferred block edit (`replace block N:` / `delete block N`). The exact
39
+ * line span is unknown at parse time — it is computed by
40
+ * {@link resolveBlockEdits} once file text + path (→ language) are
41
+ * available, then expanded into concrete edits: a non-empty `payloads`
42
+ * (from `replace block`) becomes the same `replacement` inserts + deletes
43
+ * that `replace start..end:` produces; an empty `payloads` (from `delete
44
+ * block`) becomes a pure range deletion. `applyEdits` never sees this
45
+ * variant.
46
+ */
47
+ kind: "block";
48
+ anchor: Anchor;
49
+ payloads: string[];
50
+ lineNum: number;
51
+ index: number;
52
+ };
53
+
54
+ /** Result of applying a parsed set of edits to a text body. */
55
+ export interface ApplyResult {
56
+ /** Post-edit text body. */
57
+ text: string;
58
+ /** First line number (1-indexed) that changed, or `undefined` for a no-op apply. */
59
+ firstChangedLine?: number;
60
+ /** Diagnostic warnings collected by the parser, patcher, or recovery. */
61
+ warnings?: string[];
62
+ }
63
+
64
+ /** A parsed `[A..B]` line range. */
65
+ export interface ParsedRange {
66
+ start: Anchor;
67
+ end: Anchor;
68
+ }
69
+
70
+ /** Optional hints for {@link splitPatchInput}. */
71
+ export interface SplitOptions {
72
+ /** Resolves absolute paths inside hashline headers to cwd-relative form. */
73
+ cwd?: string;
74
+ /**
75
+ * Fallback path used when the input lacks a `[PATH]` header but contains
76
+ * recognizable hashline operations. Lets streaming previews work before
77
+ * the model has written the header.
78
+ */
79
+ path?: string;
80
+ }
81
+
82
+ /** Streaming-formatter knobs for {@link streamHashLines}. */
83
+ export interface StreamOptions {
84
+ /** First line number to use when formatting (1-indexed, default 1). */
85
+ startLine?: number;
86
+ /** Maximum formatted lines per yielded chunk (default 200). */
87
+ maxChunkLines?: number;
88
+ /** Maximum UTF-8 bytes per yielded chunk (default 64 KiB). */
89
+ maxChunkBytes?: number;
90
+ }
91
+
92
+ /** Result of {@link buildCompactDiffPreview}. */
93
+ export interface CompactDiffPreview {
94
+ preview: string;
95
+ addedLines: number;
96
+ removedLines: number;
97
+ }
98
+
99
+ /** Optional knobs for {@link buildCompactDiffPreview}. Reserved for future use. */
100
+ export interface CompactDiffOptions {
101
+ /** Maximum entries kept on each side of an unchanged-context truncation (default 2). */
102
+ maxUnchangedRun?: number;
103
+ }
104
+
105
+ /**
106
+ * Resolved 1-indexed inclusive line span of a `replace block N:` target.
107
+ */
108
+ export interface BlockSpan {
109
+ /** First line of the block (1-indexed, inclusive). */
110
+ start: number;
111
+ /** Last line of the block (1-indexed, inclusive). */
112
+ end: number;
113
+ }
114
+
115
+ /** Request handed to a {@link BlockResolver} to resolve one `replace block N:` anchor. */
116
+ export interface BlockResolverRequest {
117
+ /** Target file path (used to infer language by extension). */
118
+ path: string;
119
+ /** Full text the block must be resolved against (the snapshot the tag names). */
120
+ text: string;
121
+ /** 1-indexed line the block must begin on. */
122
+ line: number;
123
+ }
124
+
125
+ /**
126
+ * Resolves a `replace block N:` anchor to the line span of the syntactic block
127
+ * that begins on line N. Returns `null` when no block can be resolved
128
+ * (unrecognized language, blank/out-of-range line, no node begins there, or the
129
+ * resolved subtree has a syntax error). Pure seam: the hashline core declares
130
+ * the contract; the host injects a tree-sitter-backed implementation.
131
+ */
132
+ export type BlockResolver = (request: BlockResolverRequest) => BlockSpan | null;