jeopi-hashline 16.2.13

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/src/parser.ts ADDED
@@ -0,0 +1,456 @@
1
+ /**
2
+ * Token-driven state machine that turns a stream of {@link Token}s into a
3
+ * flat list of {@link Edit}s. Sits between the {@link Tokenizer} and the
4
+ * applier.
5
+ */
6
+ import { HL_PAYLOAD_REPLACE, HL_RANGE_SEP } from "./format";
7
+ import {
8
+ BARE_BODY_AUTO_PIPED_WARNING,
9
+ DELETE_BLOCK_TAKES_NO_BODY,
10
+ DELETE_TAKES_NO_BODY,
11
+ EMPTY_BLOCK,
12
+ EMPTY_INSERT,
13
+ MINUS_ROW_REJECTED,
14
+ MOVE_TAKES_NO_BODY,
15
+ REM_TAKES_NO_BODY,
16
+ } from "./messages";
17
+ import { stripOneLeadingHashlinePrefix } from "./prefixes";
18
+ import { type BlockTarget, cloneCursor, type ParsedRange, type Token, Tokenizer } from "./tokenizer";
19
+ import type { Anchor, Cursor, Edit, FileOp } from "./types";
20
+
21
+ function validateRangeOrder(range: ParsedRange, lineNum: number): void {
22
+ if (range.end.line < range.start.line) {
23
+ throw new Error(
24
+ `line ${lineNum}: range ${range.start.line}${HL_RANGE_SEP}${range.end.line} ends before it starts.`,
25
+ );
26
+ }
27
+ }
28
+
29
+ function expandRange(range: ParsedRange): Anchor[] {
30
+ const anchors: Anchor[] = [];
31
+ for (let line = range.start.line; line <= range.end.line; line++) anchors.push({ line });
32
+ return anchors;
33
+ }
34
+
35
+ function isSkippableCommentLine(line: string): boolean {
36
+ return line.trimStart().startsWith("#");
37
+ }
38
+
39
+ /**
40
+ * Stripped remainder of a bare `N: <value>` row that is a lone quoted or
41
+ * numeric literal (optionally comma-terminated) — the shape of a numeric-keyed
42
+ * dict/YAML body rather than read-output paste.
43
+ */
44
+ const BARE_LITERAL_VALUE_RE = /^\s*(?:"[^"]*"|'[^']*'|[-+]?\d+(?:\.\d+)?)\s*,?\s*$/;
45
+
46
+ function detectApplyPatchContamination(text: string, _hasPending: boolean): string | null {
47
+ const trimmed = text.trimStart();
48
+ if (trimmed.length === 0) return null;
49
+ if (
50
+ trimmed.startsWith("*** Update File:") ||
51
+ trimmed.startsWith("*** Add File:") ||
52
+ trimmed.startsWith("*** Delete File:") ||
53
+ trimmed.startsWith("*** Move to:")
54
+ ) {
55
+ const preview = trimmed.length > 48 ? `${trimmed.slice(0, 48)}…` : trimmed;
56
+ return (
57
+ `apply_patch sentinel ${JSON.stringify(preview)} is not valid in hashline. ` +
58
+ "File sections start with `[path#HASH]` (no `Update File:` / `Add File:` keyword). " +
59
+ `Use \`SWAP N${HL_RANGE_SEP}M:\`, \`DEL N${HL_RANGE_SEP}M\`, or \`INS.PRE|POST|HEAD|TAIL:\` ops.`
60
+ );
61
+ }
62
+ if (/^@@\s+[-+]?\d+,\d+\s+[-+]?\d+,\d+\s+@@/.test(trimmed)) {
63
+ return (
64
+ "unified-diff hunk header (`@@ -N,M +N,M @@`) is not valid in hashline. " +
65
+ `Use \`SWAP N${HL_RANGE_SEP}M:\`, \`DEL N${HL_RANGE_SEP}M\`, or \`INS.PRE|POST|HEAD|TAIL:\` ops.`
66
+ );
67
+ }
68
+ if (trimmed.startsWith("@@")) {
69
+ const preview = trimmed.length > 48 ? `${trimmed.slice(0, 48)}…` : trimmed;
70
+ return (
71
+ `\`@@\`-bracketed hunk header ${JSON.stringify(preview)} is not valid in hashline. ` +
72
+ `Drop the \`@@ ... @@\` brackets and write a verb header such as \`SWAP N${HL_RANGE_SEP}M:\`.`
73
+ );
74
+ }
75
+ if (/^DEL\s+[1-9]\d*(?:\s*(?:\.\.|\.=|-|…|\s)\s*[1-9]\d*)?\s*:/.test(trimmed)) {
76
+ return `\`DEL N${HL_RANGE_SEP}M\` has no colon and no body. Remove the colon and body rows.`;
77
+ }
78
+ if (/^[1-9]\d*\s*$/.test(trimmed)) {
79
+ return `hunk headers need a verb. Use \`SWAP ${trimmed}${HL_RANGE_SEP}${trimmed}:\` to replace, or \`DEL ${trimmed}\` to delete.`;
80
+ }
81
+ const bareRange = /^([1-9]\d*)\s*[-. …=]+\s*([1-9]\d*)\s*:?$/.exec(trimmed);
82
+ if (bareRange !== null) {
83
+ return (
84
+ `bare range hunk header ${JSON.stringify(trimmed)} is not valid. ` +
85
+ `Hunk headers need a verb: write \`SWAP ${bareRange[1]}${HL_RANGE_SEP}${bareRange[2]}:\` or \`DEL ${bareRange[1]}${HL_RANGE_SEP}${bareRange[2]}\`.`
86
+ );
87
+ }
88
+ return null;
89
+ }
90
+
91
+ interface PendingComment {
92
+ lineNum: number;
93
+ text: string;
94
+ }
95
+
96
+ type PayloadRow = { kind: "literal"; text: string; lineNum: number; bare?: boolean };
97
+
98
+ interface Pending {
99
+ target: BlockTarget;
100
+ lineNum: number;
101
+ payloads: PayloadRow[];
102
+ /**
103
+ * Blank rows seen after the body started. Interior blanks are committed to
104
+ * the payload when the next non-blank row arrives; trailing blanks before
105
+ * the next header/op are layout separators and are discarded on flush.
106
+ */
107
+ deferredBlanks: PayloadRow[];
108
+ }
109
+
110
+ export class Executor {
111
+ #edits: Edit[] = [];
112
+ #warnings: string[] = [];
113
+ #editIndex = 0;
114
+ #pending: Pending | undefined;
115
+ #fileOp: FileOp | undefined;
116
+ #terminated = false;
117
+ #skippableComments: PendingComment[] = [];
118
+
119
+ #discardPendingSkippableComments(): void {
120
+ this.#skippableComments = [];
121
+ }
122
+
123
+ #consumePendingSkippableComments(): void {
124
+ if (this.#skippableComments.length === 0) return;
125
+ for (const comment of this.#skippableComments) this.#handleRaw(comment.text, comment.lineNum);
126
+ this.#skippableComments = [];
127
+ }
128
+
129
+ feed(token: Token): void {
130
+ if (this.#terminated) return;
131
+ switch (token.kind) {
132
+ case "envelope-begin":
133
+ this.#consumePendingSkippableComments();
134
+ return;
135
+ case "envelope-end":
136
+ this.#consumePendingSkippableComments();
137
+ this.#terminated = true;
138
+ return;
139
+ case "abort":
140
+ this.#terminated = true;
141
+ return;
142
+ case "header":
143
+ this.#consumePendingSkippableComments();
144
+ this.#flushPending();
145
+ return;
146
+ case "blank":
147
+ this.#consumePendingSkippableComments();
148
+ this.#handleBlank("", token.lineNum);
149
+ return;
150
+ case "payload-literal":
151
+ this.#consumePendingSkippableComments();
152
+ this.#handleLiteralPayload(token.text, token.lineNum);
153
+ return;
154
+ case "raw":
155
+ if (this.#pending === undefined && isSkippableCommentLine(token.text)) {
156
+ this.#skippableComments.push({ text: token.text, lineNum: token.lineNum });
157
+ return;
158
+ }
159
+ this.#consumePendingSkippableComments();
160
+ this.#handleRaw(token.text, token.lineNum);
161
+ return;
162
+ case "op-block":
163
+ this.#discardPendingSkippableComments();
164
+ if (token.target.kind === "replace" || token.target.kind === "delete") {
165
+ validateRangeOrder(token.target.range, token.lineNum);
166
+ }
167
+ if (token.target.kind === "rem") {
168
+ this.#flushPending();
169
+ this.#setFileOp({ kind: "rem" }, token.lineNum);
170
+ return;
171
+ }
172
+ if (token.target.kind === "move") {
173
+ this.#flushPending();
174
+ this.#setFileOp({ kind: "move", dest: token.target.dest }, token.lineNum);
175
+ return;
176
+ }
177
+ this.#flushPending();
178
+ this.#pending = { target: token.target, lineNum: token.lineNum, payloads: [], deferredBlanks: [] };
179
+ return;
180
+ }
181
+ }
182
+
183
+ end(): { edits: Edit[]; fileOp?: FileOp; warnings: string[] } {
184
+ this.#consumePendingSkippableComments();
185
+ this.#flushPending();
186
+ this.#validateFileOp();
187
+ this.#validateNoOverlappingDeletes();
188
+ return {
189
+ edits: this.#edits,
190
+ ...(this.#fileOp === undefined ? {} : { fileOp: this.#fileOp }),
191
+ warnings: this.#warnings,
192
+ };
193
+ }
194
+
195
+ endStreaming(): { edits: Edit[]; fileOp?: FileOp; warnings: string[] } {
196
+ this.#consumePendingSkippableComments();
197
+ if (this.#pending && this.#pending.payloads.length > 0) this.#flushPending();
198
+ else if (this.#pending?.target.kind === "delete" || this.#pending?.target.kind === "delete_block")
199
+ this.#flushPending();
200
+ else this.#pending = undefined;
201
+ this.#validateFileOp();
202
+ this.#validateNoOverlappingDeletes();
203
+ return {
204
+ edits: this.#edits,
205
+ ...(this.#fileOp === undefined ? {} : { fileOp: this.#fileOp }),
206
+ warnings: this.#warnings,
207
+ };
208
+ }
209
+
210
+ reset(): void {
211
+ this.#edits = [];
212
+ this.#warnings = [];
213
+ this.#editIndex = 0;
214
+ this.#pending = undefined;
215
+ this.#fileOp = undefined;
216
+ this.#skippableComments = [];
217
+ this.#terminated = false;
218
+ }
219
+
220
+ #setFileOp(fileOp: FileOp, lineNum: number): void {
221
+ if (this.#fileOp !== undefined) {
222
+ throw new Error(
223
+ `line ${lineNum}: only one file-level op (\`REM\` or \`MV\`) per section. Merge them under one header.`,
224
+ );
225
+ }
226
+ if (fileOp.kind === "rem" && this.#edits.length > 0) {
227
+ throw new Error(`line ${lineNum}: ${REM_TAKES_NO_BODY}`);
228
+ }
229
+ this.#fileOp = fileOp;
230
+ }
231
+
232
+ #validateFileOp(): void {
233
+ if (this.#fileOp?.kind !== "rem") return;
234
+ if (this.#edits.length > 0) {
235
+ throw new Error("`REM` deletes the whole file and cannot be combined with line ops.");
236
+ }
237
+ }
238
+
239
+ #validateNoOverlappingDeletes(): void {
240
+ const sourceLinesByAnchor = new Map<number, number[]>();
241
+ for (const edit of this.#edits) {
242
+ if (edit.kind !== "delete") continue;
243
+ let sourceLines = sourceLinesByAnchor.get(edit.anchor.line);
244
+ if (sourceLines === undefined) {
245
+ sourceLines = [];
246
+ sourceLinesByAnchor.set(edit.anchor.line, sourceLines);
247
+ }
248
+ if (!sourceLines.includes(edit.lineNum)) sourceLines.push(edit.lineNum);
249
+ }
250
+ for (const [anchorLine, sourceLines] of sourceLinesByAnchor) {
251
+ if (sourceLines.length < 2) continue;
252
+ const [firstBlock, secondBlock] = [...sourceLines].sort((a, b) => a - b);
253
+ throw new Error(
254
+ `line ${secondBlock}: anchor line ${anchorLine} is already targeted by another hunk on line ${firstBlock}. ` +
255
+ "Issue ONE hunk per range; payload is only the final desired content, never a before/after pair.",
256
+ );
257
+ }
258
+ }
259
+
260
+ #handleLiteralPayload(text: string, lineNum: number): void {
261
+ const pending = this.#pending;
262
+ if (!pending) {
263
+ if (this.#fileOp !== undefined) throw new Error(`line ${lineNum}: ${MOVE_TAKES_NO_BODY}`);
264
+ throw new Error(
265
+ `line ${lineNum}: payload line has no preceding hunk header. ` +
266
+ `Got ${JSON.stringify(`${HL_PAYLOAD_REPLACE}${text}`)}.`,
267
+ );
268
+ }
269
+ if (pending.target.kind === "delete") throw new Error(`line ${lineNum}: ${DELETE_TAKES_NO_BODY}`);
270
+ if (pending.target.kind === "delete_block") throw new Error(`line ${lineNum}: ${DELETE_BLOCK_TAKES_NO_BODY}`);
271
+ this.#commitDeferredBlanks(pending);
272
+ pending.payloads.push({ kind: "literal", text, lineNum });
273
+ }
274
+
275
+ #handleRaw(text: string, lineNum: number): void {
276
+ const contamination = detectApplyPatchContamination(text, this.#pending !== undefined);
277
+ if (contamination !== null) throw new Error(`line ${lineNum}: ${contamination}`);
278
+ if (this.#fileOp !== undefined) throw new Error(`line ${lineNum}: ${MOVE_TAKES_NO_BODY}`);
279
+ if (this.#pending) {
280
+ if (text.trim().length === 0) {
281
+ this.#handleBlank(text, lineNum);
282
+ return;
283
+ }
284
+ if (this.#pending.target.kind === "delete") throw new Error(`line ${lineNum}: ${DELETE_TAKES_NO_BODY}`);
285
+ if (this.#pending.target.kind === "delete_block")
286
+ throw new Error(`line ${lineNum}: ${DELETE_BLOCK_TAKES_NO_BODY}`);
287
+ if (text.trimStart().charCodeAt(0) === 45 /* - */) throw new Error(`line ${lineNum}: ${MINUS_ROW_REJECTED}`);
288
+ if (!this.#warnings.includes(BARE_BODY_AUTO_PIPED_WARNING)) this.#warnings.push(BARE_BODY_AUTO_PIPED_WARNING);
289
+ this.#commitDeferredBlanks(this.#pending);
290
+ // Defer read-output line-number stripping to #flushPending: a bare
291
+ // "N:text" row is only a copy-paste artifact from snapshot output
292
+ // when *every* bare row in the hunk carries that prefix. Stripping a
293
+ // row in isolation would corrupt a genuine body that merely starts
294
+ // with "digits:" (YAML ports "42:hello", timestamps "12:30") when it
295
+ // sits next to an unprefixed sibling. Rows with an explicit "+" go
296
+ // through #handleLiteralPayload and are never bare, never stripped.
297
+ this.#pending.payloads.push({ kind: "literal", text, lineNum, bare: true });
298
+ return;
299
+ }
300
+ if (text.trim().length === 0) return;
301
+ throw new Error(
302
+ `line ${lineNum}: payload line has no preceding hunk header. ` +
303
+ `Use \`SWAP N${HL_RANGE_SEP}M:\`, \`DEL N${HL_RANGE_SEP}M\`, or \`INS.PRE|POST|HEAD|TAIL:\` above the body. Got ${JSON.stringify(text)}.`,
304
+ );
305
+ }
306
+
307
+ /**
308
+ * A blank row inside a hunk body is ambiguous: interior blanks are body
309
+ * content (a bare-pasted body legitimately contains empty lines), while
310
+ * blanks before the body starts or trailing into the next op are layout.
311
+ * Defer them; {@link #commitDeferredBlanks} folds them in only when a later
312
+ * non-blank row proves they were interior.
313
+ */
314
+ #handleBlank(text: string, lineNum: number): void {
315
+ const pending = this.#pending;
316
+ if (!pending) return;
317
+ if (pending.target.kind === "delete" || pending.target.kind === "delete_block") return;
318
+ if (pending.payloads.length === 0) return;
319
+ pending.deferredBlanks.push({ kind: "literal", text, lineNum, bare: true });
320
+ }
321
+
322
+ #commitDeferredBlanks(pending: Pending): void {
323
+ if (pending.deferredBlanks.length === 0) return;
324
+ if (!this.#warnings.includes(BARE_BODY_AUTO_PIPED_WARNING)) this.#warnings.push(BARE_BODY_AUTO_PIPED_WARNING);
325
+ pending.payloads.push(...pending.deferredBlanks);
326
+ pending.deferredBlanks = [];
327
+ }
328
+
329
+ /**
330
+ * Strip a single read-output line-number prefix (`N:`) from every bare body
331
+ * row, but only when *all* bare rows carry one. A uniform set of prefixes is
332
+ * the signature of content pasted straight from `read`/`search` output; a
333
+ * mixed set means the `N:` is genuine payload content and must stay. Rows
334
+ * authored with an explicit `+` are not bare and are never touched.
335
+ */
336
+ #stripBarePrefixesIfUniform(payloads: PayloadRow[]): void {
337
+ let sawBare = false;
338
+ let allLiteralValues = true;
339
+ for (const row of payloads) {
340
+ if (!row.bare || row.text.trim().length === 0) continue;
341
+ sawBare = true;
342
+ const stripped = stripOneLeadingHashlinePrefix(row.text);
343
+ if (stripped === row.text) return;
344
+ allLiteralValues &&= BARE_LITERAL_VALUE_RE.test(stripped);
345
+ }
346
+ if (!sawBare) return;
347
+ // A body where every stripped remainder is a lone quoted/numeric literal
348
+ // (optionally comma-terminated) is the shape of a numeric-keyed dict or
349
+ // YAML mapping (`1: "one",`), not read-output paste; stripping the "N:"
350
+ // keys would mangle every line. Leave such bodies untouched.
351
+ if (allLiteralValues) return;
352
+ for (const row of payloads) {
353
+ if (row.bare && row.text.trim().length > 0) row.text = stripOneLeadingHashlinePrefix(row.text);
354
+ }
355
+ }
356
+
357
+ #pushInsert(cursor: Cursor, text: string, lineNum: number, mode?: "replacement"): void {
358
+ this.#edits.push({
359
+ kind: "insert",
360
+ cursor: cloneCursor(cursor),
361
+ text,
362
+ lineNum,
363
+ index: this.#editIndex++,
364
+ ...(mode === undefined ? {} : { mode }),
365
+ });
366
+ }
367
+
368
+ #pushDelete(anchor: Anchor, lineNum: number): void {
369
+ this.#edits.push({ kind: "delete", anchor: { ...anchor }, lineNum, index: this.#editIndex++ });
370
+ }
371
+
372
+ #pushBlock(anchor: Anchor, payloads: readonly PayloadRow[], lineNum: number, mode?: "insert_after"): void {
373
+ this.#edits.push({
374
+ kind: "block",
375
+ anchor: { ...anchor },
376
+ payloads: payloads.map(payload => payload.text),
377
+ ...(mode === undefined ? {} : { mode }),
378
+ lineNum,
379
+ index: this.#editIndex++,
380
+ });
381
+ }
382
+
383
+ #emitPayloadRows(cursor: Cursor, payloads: readonly PayloadRow[], lineNum: number, mode?: "replacement"): void {
384
+ for (const payload of payloads) this.#pushInsert(cursor, payload.text, lineNum, mode);
385
+ }
386
+
387
+ #flushPending(): void {
388
+ const pending = this.#pending;
389
+ if (!pending) return;
390
+ const { target, lineNum, payloads } = pending;
391
+ this.#stripBarePrefixesIfUniform(payloads);
392
+ this.#pending = undefined;
393
+ if (target.kind === "delete") {
394
+ for (const anchor of expandRange(target.range)) this.#pushDelete(anchor, lineNum);
395
+ return;
396
+ }
397
+ if (target.kind === "delete_block") {
398
+ // A block edit with no payloads resolves to a pure block deletion.
399
+ this.#pushBlock(target.anchor, [], lineNum);
400
+ return;
401
+ }
402
+ if (target.kind === "block") {
403
+ if (payloads.length === 0) throw new Error(`line ${lineNum}: ${EMPTY_BLOCK}`);
404
+ this.#pushBlock(target.anchor, payloads, lineNum);
405
+ return;
406
+ }
407
+ if (target.kind === "insert_after_block") {
408
+ if (payloads.length === 0) throw new Error(`line ${lineNum}: ${EMPTY_INSERT}`);
409
+ this.#pushBlock(target.anchor, payloads, lineNum, "insert_after");
410
+ return;
411
+ }
412
+ if (payloads.length === 0) {
413
+ if (target.kind === "replace") {
414
+ for (const anchor of expandRange(target.range)) this.#pushDelete(anchor, lineNum);
415
+ return;
416
+ }
417
+ throw new Error(`line ${lineNum}: ${EMPTY_INSERT}`);
418
+ }
419
+ if (target.kind === "replace") {
420
+ const cursor: Cursor = { kind: "before_anchor", anchor: { ...target.range.start } };
421
+ this.#emitPayloadRows(cursor, payloads, lineNum, "replacement");
422
+ for (const anchor of expandRange(target.range)) this.#pushDelete(anchor, lineNum);
423
+ return;
424
+ }
425
+ if (target.kind === "insert_before") {
426
+ this.#emitPayloadRows({ kind: "before_anchor", anchor: { ...target.anchor } }, payloads, lineNum);
427
+ return;
428
+ }
429
+ if (target.kind === "insert_after") {
430
+ this.#emitPayloadRows({ kind: "after_anchor", anchor: { ...target.anchor } }, payloads, lineNum);
431
+ return;
432
+ }
433
+ const cursor: Cursor = target.kind === "bof" ? { kind: "bof" } : { kind: "eof" };
434
+ this.#emitPayloadRows(cursor, payloads, lineNum);
435
+ }
436
+ }
437
+
438
+ function drain(executor: Executor, tokenizer: Tokenizer): { edits: Edit[]; fileOp?: FileOp; warnings: string[] } {
439
+ for (const token of tokenizer.end()) executor.feed(token);
440
+ return executor.end();
441
+ }
442
+
443
+ export function parsePatch(diff: string): { edits: Edit[]; fileOp?: FileOp; warnings: string[] } {
444
+ const tokenizer = new Tokenizer();
445
+ const executor = new Executor();
446
+ for (const token of tokenizer.feed(diff)) executor.feed(token);
447
+ return drain(executor, tokenizer);
448
+ }
449
+
450
+ export function parsePatchStreaming(diff: string): { edits: Edit[]; fileOp?: FileOp; warnings: string[] } {
451
+ const tokenizer = new Tokenizer();
452
+ const executor = new Executor();
453
+ for (const token of tokenizer.feed(diff)) executor.feed(token);
454
+ for (const token of tokenizer.end()) executor.feed(token);
455
+ return executor.endStreaming();
456
+ }