@prometheus-ai/hashline 0.5.4 → 0.5.8
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/apply.d.ts +2 -0
- package/dist/types/block.d.ts +24 -9
- package/dist/types/diff-preview.d.ts +6 -4
- package/dist/types/messages.d.ts +73 -60
- package/dist/types/patcher.d.ts +7 -1
- package/dist/types/prefixes.d.ts +8 -0
- package/dist/types/snapshots.d.ts +6 -0
- package/dist/types/tokenizer.d.ts +3 -0
- package/dist/types/types.d.ts +45 -9
- package/package.json +3 -1
- package/src/apply.ts +267 -9
- package/src/block.ts +89 -15
- package/src/diff-preview.ts +96 -21
- package/src/grammar.lark +3 -1
- package/src/input.ts +16 -7
- package/src/messages.ts +121 -68
- package/src/mismatch.ts +5 -25
- package/src/parser.ts +89 -5
- package/src/patcher.ts +47 -9
- package/src/prefixes.ts +10 -0
- package/src/prompt.md +53 -19
- package/src/snapshots.ts +17 -1
- package/src/tokenizer.ts +11 -0
- package/src/types.ts +46 -9
package/src/apply.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* which absorbs common model mistakes where a payload restates unchanged range
|
|
8
8
|
* boundaries or duplicates/drops structural closers.
|
|
9
9
|
*/
|
|
10
|
-
import { UNRESOLVED_BLOCK_INTERNAL } from "./messages";
|
|
10
|
+
import { afterInsertLandingShiftWarning, blockInsertLandingShiftWarning, UNRESOLVED_BLOCK_INTERNAL } from "./messages";
|
|
11
11
|
import { cloneCursor } from "./tokenizer";
|
|
12
12
|
import type { Anchor, ApplyResult, Cursor, Edit } from "./types";
|
|
13
13
|
|
|
@@ -35,11 +35,26 @@ function getEditAnchors(edit: AppliedEdit): Anchor[] {
|
|
|
35
35
|
return getCursorAnchors(edit.cursor);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
function trailingPhantomLine(fileLines: readonly string[]): number {
|
|
39
|
+
// `split("\n")` on a newline-terminated file yields a trailing "" sentinel.
|
|
40
|
+
// It is addressable for inserts (append-past-end), but it is not real
|
|
41
|
+
// content. Deleting it only strips the file's final newline, so ignore delete
|
|
42
|
+
// edits that land there; inclusive ranges ending at EOF then do the intended
|
|
43
|
+
// thing and delete through the last concrete line.
|
|
44
|
+
return fileLines.length > 1 && fileLines[fileLines.length - 1] === "" ? fileLines.length : 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function dropTrailingPhantomDeletes(edits: AppliedEdit[], fileLines: readonly string[]): AppliedEdit[] {
|
|
48
|
+
const phantomLine = trailingPhantomLine(fileLines);
|
|
49
|
+
if (phantomLine === 0) return edits;
|
|
50
|
+
return edits.filter(edit => edit.kind !== "delete" || edit.anchor.line !== phantomLine);
|
|
51
|
+
}
|
|
52
|
+
|
|
38
53
|
/**
|
|
39
54
|
* Verify every anchored edit points at an existing line. File-version binding is
|
|
40
55
|
* checked once per section via the header hash before this function runs.
|
|
41
56
|
*/
|
|
42
|
-
function validateLineBounds(edits: AppliedEdit[], fileLines: string[]): void {
|
|
57
|
+
function validateLineBounds(edits: readonly AppliedEdit[], fileLines: readonly string[]): void {
|
|
43
58
|
for (const edit of edits) {
|
|
44
59
|
for (const anchor of getEditAnchors(edit)) {
|
|
45
60
|
if (anchor.line < 1 || anchor.line > fileLines.length) {
|
|
@@ -113,7 +128,7 @@ function bucketAnchorEditsByLine(edits: IndexedEdit[]): Map<number, IndexedEdit[
|
|
|
113
128
|
// wrapper" mistake.
|
|
114
129
|
|
|
115
130
|
/** A line that is nothing but closing delimiters: `}`, `)`, `];`, `})`, `},`. */
|
|
116
|
-
const STRUCTURAL_CLOSER_RE = /^\s*[)\]}]+[;,]?\s*$/;
|
|
131
|
+
export const STRUCTURAL_CLOSER_RE = /^\s*[)\]}]+[;,]?\s*$/;
|
|
117
132
|
|
|
118
133
|
interface DelimiterBalance {
|
|
119
134
|
paren: number;
|
|
@@ -303,11 +318,22 @@ function findDuplicatePrefix(group: ReplacementGroup, fileLines: readonly string
|
|
|
303
318
|
return 0;
|
|
304
319
|
}
|
|
305
320
|
|
|
321
|
+
function payloadEndsWithDeletedSuffix(group: ReplacementGroup, fileLines: readonly string[], count: number): boolean {
|
|
322
|
+
if (group.payload.length < count) return false;
|
|
323
|
+
const deletedStart = group.endLine - count;
|
|
324
|
+
const payloadStart = group.payload.length - count;
|
|
325
|
+
for (let offset = 0; offset < count; offset++) {
|
|
326
|
+
if (group.payload[payloadStart + offset] !== fileLines[deletedStart + offset]) return false;
|
|
327
|
+
}
|
|
328
|
+
return true;
|
|
329
|
+
}
|
|
330
|
+
|
|
306
331
|
/**
|
|
307
332
|
* Smallest `m` such that the range's last `m` deleted lines are all pure
|
|
308
|
-
* structural closers
|
|
309
|
-
*
|
|
310
|
-
* payload never
|
|
333
|
+
* structural closers, the payload does not already restate those same suffix
|
|
334
|
+
* lines, and sparing them (keeping instead of deleting) zeroes `delta`. The
|
|
335
|
+
* mirror mistake: a range that swallows a closing delimiter the payload never
|
|
336
|
+
* restates.
|
|
311
337
|
*/
|
|
312
338
|
function findDroppedSuffixClosers(
|
|
313
339
|
group: ReplacementGroup,
|
|
@@ -318,6 +344,7 @@ function findDroppedSuffixClosers(
|
|
|
318
344
|
const maxM = group.deleteIndices.length;
|
|
319
345
|
for (let m = 1; m <= maxM; m++) {
|
|
320
346
|
if (!STRUCTURAL_CLOSER_RE.test(fileLines[group.endLine - m] ?? "")) break;
|
|
347
|
+
if (payloadEndsWithDeletedSuffix(group, fileLines, m)) continue;
|
|
321
348
|
if (balanceEqual(computeDelimiterBalance(fileLines.slice(group.endLine - m, group.endLine)), wanted)) return m;
|
|
322
349
|
}
|
|
323
350
|
return 0;
|
|
@@ -383,6 +410,21 @@ function findBoundaryEcho(group: ReplacementGroup, fileLines: readonly string[])
|
|
|
383
410
|
// repair would strip explicit replacement content with no signal that the
|
|
384
411
|
// payload was a mistake rather than an intentional duplication.
|
|
385
412
|
if (leadingMax + trailingMax >= group.payload.length) return undefined;
|
|
413
|
+
// Balance-neutrality guard (see header comment): the dropped echo lines must
|
|
414
|
+
// either be delimiter-neutral on their own or exactly cancel the payload/range
|
|
415
|
+
// balance delta. In brace-heavy code where bare closer lines repeat, an
|
|
416
|
+
// "echo" that shifts delimiter balance is structural content the payload
|
|
417
|
+
// placed intentionally — stripping it would corrupt the result.
|
|
418
|
+
const leadingBalance = computeDelimiterBalance(group.payload.slice(0, leadingMax));
|
|
419
|
+
const trailingBalance = computeDelimiterBalance(group.payload.slice(group.payload.length - trailingMax));
|
|
420
|
+
const droppedBalance = balanceDelta(leadingBalance, balanceNegate(trailingBalance));
|
|
421
|
+
if (!balanceIsZero(droppedBalance)) {
|
|
422
|
+
const delta = balanceDelta(
|
|
423
|
+
computeDelimiterBalance(group.payload),
|
|
424
|
+
computeDelimiterBalance(fileLines.slice(group.startLine - 1, group.endLine)),
|
|
425
|
+
);
|
|
426
|
+
if (!balanceEqual(droppedBalance, delta)) return undefined;
|
|
427
|
+
}
|
|
386
428
|
return { leading: leadingMax, trailing: trailingMax };
|
|
387
429
|
}
|
|
388
430
|
|
|
@@ -481,6 +523,217 @@ function repairReplacementBoundaries(
|
|
|
481
523
|
return { edits: out, warnings };
|
|
482
524
|
}
|
|
483
525
|
|
|
526
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
527
|
+
// After-insert landing correction
|
|
528
|
+
//
|
|
529
|
+
// The body rows of an `insert after N:` hunk carry an implicit depth claim:
|
|
530
|
+
// their leading indentation says how deep the author expects the new lines
|
|
531
|
+
// to sit. Two corrections share that claim, in opposite directions:
|
|
532
|
+
//
|
|
533
|
+
// Outward (any after-insert): when the depth is shallower than line N itself,
|
|
534
|
+
// the hunk is inserting a sibling of some enclosing construct while anchored
|
|
535
|
+
// inside it — the common shape is anchoring on the last statement of a block
|
|
536
|
+
// and writing the body at the parent's depth. Sliding the landing point
|
|
537
|
+
// forward across the structural closer lines that follow (and nothing else —
|
|
538
|
+
// content lines are never crossed) places the body at the depth its
|
|
539
|
+
// indentation names.
|
|
540
|
+
//
|
|
541
|
+
// Inward (block-lowered inserts only): `insert after block N:` anchors on the
|
|
542
|
+
// resolved block's closing line, but a body indented deeper than that closer
|
|
543
|
+
// claims a depth inside the block — the common misreading of the op as
|
|
544
|
+
// "append at the end of block N's body". Sliding the landing point backward
|
|
545
|
+
// across the block's trailing closer lines places the body inside, at its
|
|
546
|
+
// claimed depth. Scoped to block-lowered inserts because there the author
|
|
547
|
+
// named the opener and never saw the closer; a plain `insert after M:` on a
|
|
548
|
+
// closer line stays literal (the escape hatch for genuinely-after content
|
|
549
|
+
// such as method-chain continuations).
|
|
550
|
+
//
|
|
551
|
+
// Both shifts are deliberately conservative: they fire only when the body
|
|
552
|
+
// and anchor indentation are comparable (one is a prefix of the other),
|
|
553
|
+
// cross only pure closing-delimiter lines, stop as soon as depth matches the
|
|
554
|
+
// body's claim, and are abandoned when any other edit in the patch targets a
|
|
555
|
+
// crossed line. Every shift is reported as a warning so the author can
|
|
556
|
+
// re-issue when the original landing was intended.
|
|
557
|
+
|
|
558
|
+
/** Leading run of tabs and spaces. */
|
|
559
|
+
function leadingIndent(line: string): string {
|
|
560
|
+
let end = 0;
|
|
561
|
+
while (end < line.length) {
|
|
562
|
+
const code = line.charCodeAt(end);
|
|
563
|
+
if (code !== 9 && code !== 32) break;
|
|
564
|
+
end++;
|
|
565
|
+
}
|
|
566
|
+
return line.slice(0, end);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** `deeper` strictly extends `shallower` (same indent style, more depth). */
|
|
570
|
+
function isIndentDeeper(deeper: string, shallower: string): boolean {
|
|
571
|
+
return deeper.length > shallower.length && deeper.startsWith(shallower);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
interface AfterInsertGroup {
|
|
575
|
+
/** Anchor line shared by every insert row of the hunk. */
|
|
576
|
+
anchor: number;
|
|
577
|
+
/** Indices into the edit list, in patch order. */
|
|
578
|
+
members: number[];
|
|
579
|
+
/** First line of the resolved block when lowered from `insert after block N:`. */
|
|
580
|
+
blockStart?: number;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Depth of an after-insert hunk's body: the shallowest indentation across its
|
|
585
|
+
* non-blank rows. Returns `undefined` when no depth claim can be made — an
|
|
586
|
+
* all-blank or all-closer body, or rows whose indentation styles are not
|
|
587
|
+
* mutually comparable (tabs vs spaces).
|
|
588
|
+
*/
|
|
589
|
+
function bodyTargetIndent(rows: readonly string[]): string | undefined {
|
|
590
|
+
const nonBlank = rows.filter(hasNonWhitespace);
|
|
591
|
+
if (nonBlank.length === 0) return undefined;
|
|
592
|
+
// A body of pure closers re-balances delimiters; it claims no depth.
|
|
593
|
+
if (nonBlank.every(row => STRUCTURAL_CLOSER_RE.test(row))) return undefined;
|
|
594
|
+
let target = leadingIndent(nonBlank[0] ?? "");
|
|
595
|
+
for (const row of nonBlank) {
|
|
596
|
+
const indent = leadingIndent(row);
|
|
597
|
+
if (indent.startsWith(target)) continue;
|
|
598
|
+
if (target.startsWith(indent)) target = indent;
|
|
599
|
+
else return undefined;
|
|
600
|
+
}
|
|
601
|
+
return target;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Resolve where an after-insert hunk anchored on `group.anchor` should land
|
|
606
|
+
* given its body depth `target`: the last structural closer line in the run
|
|
607
|
+
* directly below the anchor whose indentation still covers `target`. Returns
|
|
608
|
+
* `undefined` when the landing stays put.
|
|
609
|
+
*/
|
|
610
|
+
function resolveShiftedLanding(
|
|
611
|
+
group: AfterInsertGroup,
|
|
612
|
+
target: string,
|
|
613
|
+
fileLines: readonly string[],
|
|
614
|
+
targetedLines: ReadonlySet<number>,
|
|
615
|
+
): { line: number; crossed: number } | undefined {
|
|
616
|
+
const anchorText = fileLines[group.anchor - 1];
|
|
617
|
+
if (anchorText === undefined || !hasNonWhitespace(anchorText)) return undefined;
|
|
618
|
+
if (!isIndentDeeper(leadingIndent(anchorText), target)) return undefined;
|
|
619
|
+
|
|
620
|
+
let landing = group.anchor;
|
|
621
|
+
let crossed = 0;
|
|
622
|
+
for (let line = group.anchor + 1; line <= fileLines.length; line++) {
|
|
623
|
+
const text = fileLines[line - 1] ?? "";
|
|
624
|
+
if (!hasNonWhitespace(text)) continue; // look past blanks, never land on them
|
|
625
|
+
if (!STRUCTURAL_CLOSER_RE.test(text)) break; // content is never crossed
|
|
626
|
+
const indent = leadingIndent(text);
|
|
627
|
+
if (!indent.startsWith(target)) break; // shallower than the body — crossing would over-escape
|
|
628
|
+
if (targetedLines.has(line)) return undefined; // another hunk owns this closer
|
|
629
|
+
landing = line;
|
|
630
|
+
crossed++;
|
|
631
|
+
if (indent.length === target.length) break; // depth returned to the body's level
|
|
632
|
+
}
|
|
633
|
+
return landing === group.anchor ? undefined : { line: landing, crossed };
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Resolve where a block-lowered after-insert anchored on the block's closing
|
|
638
|
+
* line should land given a body depth `target` deeper than that closer: just
|
|
639
|
+
* above the block's trailing run of closer lines, bounded below by
|
|
640
|
+
* `blockStart` (an empty block lands the body right after its opener).
|
|
641
|
+
* Returns `undefined` when the landing stays put.
|
|
642
|
+
*/
|
|
643
|
+
function resolveInwardLanding(
|
|
644
|
+
group: AfterInsertGroup,
|
|
645
|
+
target: string,
|
|
646
|
+
blockStart: number,
|
|
647
|
+
fileLines: readonly string[],
|
|
648
|
+
targetedLines: ReadonlySet<number>,
|
|
649
|
+
): number | undefined {
|
|
650
|
+
const anchorText = fileLines[group.anchor - 1];
|
|
651
|
+
if (anchorText === undefined || !hasNonWhitespace(anchorText)) return undefined;
|
|
652
|
+
// Fires only when the block ends in a pure closer the body out-indents.
|
|
653
|
+
// Blocks ending in content (indentation-only languages) already land the
|
|
654
|
+
// body inside the block — nothing to correct.
|
|
655
|
+
if (!STRUCTURAL_CLOSER_RE.test(anchorText)) return undefined;
|
|
656
|
+
if (!isIndentDeeper(target, leadingIndent(anchorText))) return undefined;
|
|
657
|
+
|
|
658
|
+
let landing = group.anchor;
|
|
659
|
+
for (let line = group.anchor; line > blockStart; line--) {
|
|
660
|
+
const text = fileLines[line - 1] ?? "";
|
|
661
|
+
if (!hasNonWhitespace(text)) {
|
|
662
|
+
landing = line - 1; // look past trailing blanks, never land after one
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
if (!STRUCTURAL_CLOSER_RE.test(text)) break; // content reached — land right after it
|
|
666
|
+
const indent = leadingIndent(text);
|
|
667
|
+
if (!isIndentDeeper(target, indent)) break; // closer at the body's depth — land after it
|
|
668
|
+
// Another hunk owns this closer (the group's own rows put the anchor
|
|
669
|
+
// itself in `targetedLines`; that one is ours to cross).
|
|
670
|
+
if (line !== group.anchor && targetedLines.has(line)) return undefined;
|
|
671
|
+
landing = line - 1;
|
|
672
|
+
}
|
|
673
|
+
return landing === group.anchor ? undefined : landing;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Slide mis-anchored after-insert hunks to the depth their body indentation
|
|
678
|
+
* claims: outward past the structural closer lines that follow the anchor
|
|
679
|
+
* when the body is shallower, or — for `insert after block N:` lowerings —
|
|
680
|
+
* inward across the block's trailing closers when the body is deeper than
|
|
681
|
+
* the block's closing line. Returns the corrected edit list plus one warning
|
|
682
|
+
* per shifted hunk.
|
|
683
|
+
*/
|
|
684
|
+
function repairAfterInsertLandings(
|
|
685
|
+
edits: readonly AppliedEdit[],
|
|
686
|
+
fileLines: readonly string[],
|
|
687
|
+
): { edits: readonly AppliedEdit[]; warnings: string[] } {
|
|
688
|
+
// Group plain (non-replacement) after-anchor inserts per authored hunk:
|
|
689
|
+
// rows of one hunk share the anchor line and the patch header line.
|
|
690
|
+
const groups = new Map<string, AfterInsertGroup>();
|
|
691
|
+
edits.forEach((edit, idx) => {
|
|
692
|
+
if (edit.kind !== "insert" || edit.mode === "replacement") return;
|
|
693
|
+
if (edit.cursor.kind !== "after_anchor") return;
|
|
694
|
+
const key = `${edit.cursor.anchor.line}:${edit.lineNum}`;
|
|
695
|
+
const group = groups.get(key);
|
|
696
|
+
if (group === undefined)
|
|
697
|
+
groups.set(key, { anchor: edit.cursor.anchor.line, members: [idx], blockStart: edit.blockStart });
|
|
698
|
+
else group.members.push(idx);
|
|
699
|
+
});
|
|
700
|
+
if (groups.size === 0) return { edits, warnings: [] };
|
|
701
|
+
|
|
702
|
+
// Lines explicitly targeted by any edit; a shift never crosses them.
|
|
703
|
+
const targetedLines = new Set<number>();
|
|
704
|
+
for (const edit of edits) {
|
|
705
|
+
if (edit.kind === "delete") targetedLines.add(edit.anchor.line);
|
|
706
|
+
else if (edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor")
|
|
707
|
+
targetedLines.add(edit.cursor.anchor.line);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
let out: AppliedEdit[] | undefined;
|
|
711
|
+
const warnings: string[] = [];
|
|
712
|
+
const retarget = (group: AfterInsertGroup, line: number): void => {
|
|
713
|
+
out ??= [...edits];
|
|
714
|
+
for (const idx of group.members) {
|
|
715
|
+
const edit = out[idx] as InsertEdit;
|
|
716
|
+
out[idx] = { ...edit, cursor: { kind: "after_anchor", anchor: { line } } };
|
|
717
|
+
}
|
|
718
|
+
};
|
|
719
|
+
for (const group of groups.values()) {
|
|
720
|
+
const target = bodyTargetIndent(group.members.map(idx => (edits[idx] as InsertEdit).text));
|
|
721
|
+
if (target === undefined) continue;
|
|
722
|
+
const outward = resolveShiftedLanding(group, target, fileLines, targetedLines);
|
|
723
|
+
if (outward !== undefined) {
|
|
724
|
+
retarget(group, outward.line);
|
|
725
|
+
warnings.push(afterInsertLandingShiftWarning(group.anchor, outward.line, outward.crossed));
|
|
726
|
+
continue;
|
|
727
|
+
}
|
|
728
|
+
if (group.blockStart === undefined) continue;
|
|
729
|
+
const inward = resolveInwardLanding(group, target, group.blockStart, fileLines, targetedLines);
|
|
730
|
+
if (inward === undefined) continue;
|
|
731
|
+
retarget(group, inward);
|
|
732
|
+
warnings.push(blockInsertLandingShiftWarning(group.blockStart, group.anchor, inward));
|
|
733
|
+
}
|
|
734
|
+
return { edits: out ?? edits, warnings };
|
|
735
|
+
}
|
|
736
|
+
|
|
484
737
|
/**
|
|
485
738
|
* Apply a parsed list of edits to a text body. Pure function — no I/O.
|
|
486
739
|
*
|
|
@@ -506,15 +759,20 @@ export function applyEdits(text: string, edits: readonly Edit[]): ApplyResult {
|
|
|
506
759
|
if (firstChangedLine === undefined || line < firstChangedLine) firstChangedLine = line;
|
|
507
760
|
};
|
|
508
761
|
|
|
509
|
-
const targetEdits =
|
|
762
|
+
const targetEdits = dropTrailingPhantomDeletes(
|
|
763
|
+
appliedEdits.map((edit, index) => cloneAppliedEdit(edit, index)),
|
|
764
|
+
fileLines,
|
|
765
|
+
);
|
|
510
766
|
validateLineBounds(targetEdits, fileLines);
|
|
511
|
-
const { edits: repaired, warnings } = repairReplacementBoundaries(targetEdits, fileLines);
|
|
767
|
+
const { edits: repaired, warnings: boundaryWarnings } = repairReplacementBoundaries(targetEdits, fileLines);
|
|
768
|
+
const { edits: landed, warnings: landingWarnings } = repairAfterInsertLandings(repaired, fileLines);
|
|
769
|
+
const warnings = [...boundaryWarnings, ...landingWarnings];
|
|
512
770
|
|
|
513
771
|
// Partition edits into bof, eof, and anchor-targeted buckets.
|
|
514
772
|
const bofLines: string[] = [];
|
|
515
773
|
const eofLines: string[] = [];
|
|
516
774
|
const anchorEdits: IndexedEdit[] = [];
|
|
517
|
-
|
|
775
|
+
landed.forEach((edit, idx) => {
|
|
518
776
|
if (edit.kind === "insert" && edit.cursor.kind === "bof") {
|
|
519
777
|
bofLines.push(edit.text);
|
|
520
778
|
} else if (edit.kind === "insert" && edit.cursor.kind === "eof") {
|
package/src/block.ts
CHANGED
|
@@ -1,36 +1,60 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Expand deferred `replace block N:`
|
|
2
|
+
* Expand deferred block edits (`replace block N:` / `delete block N` /
|
|
3
|
+
* `insert after block N:`) into concrete inserts + deletes.
|
|
3
4
|
*
|
|
4
5
|
* The hashline parser cannot expand a block edit on its own — the line span is
|
|
5
6
|
* unknown until file text + path (→ language) are available. This transform
|
|
6
7
|
* runs at every apply/preview boundary that has text: it calls the injected
|
|
7
8
|
* {@link BlockResolver} to resolve each block's `[start, end]` span, then emits
|
|
8
|
-
* the exact same
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* the exact same edits the concrete form produces in the parser: `replace
|
|
10
|
+
* start..end:` inserts + deletes for a replace, a pure range delete for a
|
|
11
|
+
* delete, and plain `after_anchor` inserts at `end` for an insert-after. After
|
|
12
|
+
* it runs, no `block` edits remain, so {@link applyEdits} (and recovery) only
|
|
13
|
+
* ever see resolved edits.
|
|
11
14
|
*/
|
|
12
|
-
import {
|
|
13
|
-
import
|
|
15
|
+
import { STRUCTURAL_CLOSER_RE } from "./apply";
|
|
16
|
+
import {
|
|
17
|
+
BLOCK_RESOLVER_UNAVAILABLE,
|
|
18
|
+
blockUnresolvedMessage,
|
|
19
|
+
insertAfterBlockCloserLoweredWarning,
|
|
20
|
+
insertAfterBlockUnresolvedLoweredWarning,
|
|
21
|
+
} from "./messages";
|
|
22
|
+
import type { BlockResolution, BlockResolver, Cursor, Edit } from "./types";
|
|
14
23
|
|
|
15
24
|
export interface ResolveBlockEditsOptions {
|
|
16
25
|
/**
|
|
17
|
-
* How to handle a block edit that cannot be resolved
|
|
18
|
-
* `null` span). `"throw"` (default) raises a
|
|
19
|
-
* used by the authoritative apply + final
|
|
20
|
-
* skips the edit — used by the streaming
|
|
21
|
-
* or transient parse error must not
|
|
26
|
+
* How to handle a replace/delete block edit that cannot be resolved
|
|
27
|
+
* (missing resolver or a `null` span). `"throw"` (default) raises a
|
|
28
|
+
* `blockUnresolvedMessage` error — used by the authoritative apply + final
|
|
29
|
+
* preview paths. `"drop"` silently skips the edit — used by the streaming
|
|
30
|
+
* preview, where a half-written file or transient parse error must not
|
|
31
|
+
* throw. Unresolvable `insert after block N:` edits never reach this: they
|
|
32
|
+
* are lowered to plain `insert after N:` with a warning.
|
|
22
33
|
*/
|
|
23
34
|
onUnresolved?: "throw" | "drop";
|
|
35
|
+
/**
|
|
36
|
+
* Invoked once per successfully resolved block edit, in patch order, with
|
|
37
|
+
* the anchor line and the concrete span it resolved to. Lets the host echo
|
|
38
|
+
* the resolution back to the caller. Never fired for dropped/unresolvable
|
|
39
|
+
* edits.
|
|
40
|
+
*/
|
|
41
|
+
onResolved?: (resolution: BlockResolution) => void;
|
|
42
|
+
/**
|
|
43
|
+
* Invoked once per diagnostic produced while resolving — currently the
|
|
44
|
+
* `insert after block N:` lowerings (closer anchor or unresolvable block).
|
|
45
|
+
* Hosts should surface these on the apply result's `warnings`.
|
|
46
|
+
*/
|
|
47
|
+
onWarning?: (message: string) => void;
|
|
24
48
|
}
|
|
25
49
|
|
|
26
|
-
/** True when at least one edit is an unresolved
|
|
50
|
+
/** True when at least one edit is an unresolved deferred block edit. */
|
|
27
51
|
export function hasBlockEdit(edits: readonly Edit[]): boolean {
|
|
28
52
|
return edits.some(edit => edit.kind === "block");
|
|
29
53
|
}
|
|
30
54
|
|
|
31
55
|
/**
|
|
32
|
-
* Resolve every
|
|
33
|
-
*
|
|
56
|
+
* Resolve every deferred block edit in `edits` against `text` (parsed as the
|
|
57
|
+
* language inferred from `path`). Non-block edits pass through untouched.
|
|
34
58
|
* Returns a fresh edit list with no `block` variants. The fast path returns the
|
|
35
59
|
* input unchanged when there is nothing to resolve.
|
|
36
60
|
*
|
|
@@ -54,13 +78,63 @@ export function resolveBlockEdits(
|
|
|
54
78
|
resolved.push(edit);
|
|
55
79
|
continue;
|
|
56
80
|
}
|
|
81
|
+
const op = edit.mode === "insert_after" ? "insert_after" : edit.payloads.length === 0 ? "delete" : "replace";
|
|
57
82
|
const span = resolver ? resolver({ path, text, line: edit.anchor.line }) : null;
|
|
58
83
|
if (span === null) {
|
|
84
|
+
// `insert after block N:` never fails the patch — lower it to plain
|
|
85
|
+
// `insert after N:` with a warning instead. Two flavors:
|
|
86
|
+
// - anchored on a pure closing-delimiter line: no block begins
|
|
87
|
+
// there, but line N IS the end of one, and "after the end of the
|
|
88
|
+
// block" is exactly the plain form — warn with the opener rule.
|
|
89
|
+
// - otherwise (unsupported language, blank line, unparsable block,
|
|
90
|
+
// or no resolver wired): "after the block at N" degrades to
|
|
91
|
+
// "after line N" — warn to verify the landing line.
|
|
92
|
+
if (op === "insert_after") {
|
|
93
|
+
const anchorText = text.split("\n")[edit.anchor.line - 1];
|
|
94
|
+
const isCloser = anchorText !== undefined && STRUCTURAL_CLOSER_RE.test(anchorText);
|
|
95
|
+
options.onWarning?.(
|
|
96
|
+
isCloser
|
|
97
|
+
? insertAfterBlockCloserLoweredWarning(edit.anchor.line)
|
|
98
|
+
: insertAfterBlockUnresolvedLoweredWarning(edit.anchor.line),
|
|
99
|
+
);
|
|
100
|
+
for (const payload of edit.payloads) {
|
|
101
|
+
const cursor: Cursor = { kind: "after_anchor", anchor: { line: edit.anchor.line } };
|
|
102
|
+
resolved.push({ kind: "insert", cursor, text: payload, lineNum: edit.lineNum, index: synthIndex++ });
|
|
103
|
+
}
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
59
106
|
if (onUnresolved === "drop") continue;
|
|
60
107
|
throw new Error(
|
|
61
|
-
`line ${edit.lineNum}: ${
|
|
108
|
+
`line ${edit.lineNum}: ${
|
|
109
|
+
resolver ? blockUnresolvedMessage(edit.anchor.line, op, text.split("\n")) : BLOCK_RESOLVER_UNAVAILABLE
|
|
110
|
+
}`,
|
|
62
111
|
);
|
|
63
112
|
}
|
|
113
|
+
options.onResolved?.({
|
|
114
|
+
anchorLine: edit.anchor.line,
|
|
115
|
+
start: span.start,
|
|
116
|
+
end: span.end,
|
|
117
|
+
op,
|
|
118
|
+
});
|
|
119
|
+
if (op === "insert_after") {
|
|
120
|
+
// Mirror the parser's `insert after N:` lowering: one `after_anchor`
|
|
121
|
+
// insert per payload row, anchored on the block's last line. The
|
|
122
|
+
// `blockStart` tag lets the applier's landing correction slide a
|
|
123
|
+
// body that claims a depth inside the block back across the block's
|
|
124
|
+
// trailing closer lines.
|
|
125
|
+
for (const payload of edit.payloads) {
|
|
126
|
+
const cursor: Cursor = { kind: "after_anchor", anchor: { line: span.end } };
|
|
127
|
+
resolved.push({
|
|
128
|
+
kind: "insert",
|
|
129
|
+
cursor,
|
|
130
|
+
text: payload,
|
|
131
|
+
lineNum: edit.lineNum,
|
|
132
|
+
index: synthIndex++,
|
|
133
|
+
blockStart: span.start,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
64
138
|
// Mirror the parser's `replace start..end:` expansion exactly: one
|
|
65
139
|
// `before_anchor` replacement insert per payload row at `span.start`,
|
|
66
140
|
// then one delete per line across `[span.start, span.end]`. An empty
|
package/src/diff-preview.ts
CHANGED
|
@@ -1,49 +1,124 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Re-number a unified diff that uses the `+<lineNum>|content` /
|
|
3
3
|
* `-<lineNum>|content` / ` <lineNum>|content` line format into a compact
|
|
4
|
-
* preview
|
|
5
|
-
*
|
|
6
|
-
* so a follow-up edit can reuse
|
|
4
|
+
* current-file preview. Removed lines are counted for stats and post-edit
|
|
5
|
+
* offset tracking, but omitted from the preview. Added and context lines are
|
|
6
|
+
* anchored to their post-edit positions so a follow-up edit can reuse visible
|
|
7
|
+
* concrete lines directly. Long contiguous added runs are summarized with a
|
|
8
|
+
* `…` marker instead of echoing every inserted line.
|
|
7
9
|
*
|
|
8
10
|
* This is intentionally decoupled from the diff producer: anything that
|
|
9
11
|
* emits the `<sign><lineNum>|<content>` shape works.
|
|
10
12
|
*/
|
|
11
13
|
import type { CompactDiffOptions, CompactDiffPreview } from "./types";
|
|
12
14
|
|
|
13
|
-
|
|
15
|
+
const DEFAULT_ADDED_RUN_CONTEXT_LINES = 2;
|
|
16
|
+
|
|
17
|
+
const PREVIEW_ELISION_MARKER = "…";
|
|
18
|
+
/** Blank row separating non-contiguous regions of a numbered diff. */
|
|
19
|
+
const PREVIEW_GAP_ROW = "";
|
|
20
|
+
const RAW_ELISION_MARKERS = new Set(["...", PREVIEW_ELISION_MARKER, `+${PREVIEW_ELISION_MARKER}`]);
|
|
21
|
+
|
|
22
|
+
function isPreviewSeparator(line: string | undefined): boolean {
|
|
23
|
+
return line === PREVIEW_ELISION_MARKER || line === PREVIEW_GAP_ROW;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function appendPreviewLine(output: string[], line: string): void {
|
|
27
|
+
const normalized = RAW_ELISION_MARKERS.has(line) ? PREVIEW_ELISION_MARKER : line;
|
|
28
|
+
// Separators (elision markers, blank gap rows) never stack: omitted
|
|
29
|
+
// removed lines between two separators would otherwise leave them
|
|
30
|
+
// adjacent. A leading separator is dropped outright.
|
|
31
|
+
if (isPreviewSeparator(normalized) && (output.length === 0 || isPreviewSeparator(output[output.length - 1]))) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
output.push(normalized);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface ParsedDiffLine {
|
|
38
|
+
kind: "+" | "-" | " ";
|
|
39
|
+
lineNumber: number;
|
|
40
|
+
content: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalizeAddedRunContext(value: number | undefined): number {
|
|
44
|
+
if (value === undefined || !Number.isFinite(value)) return DEFAULT_ADDED_RUN_CONTEXT_LINES;
|
|
45
|
+
return Math.max(1, Math.trunc(value));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseNumberedDiffLine(line: string): ParsedDiffLine | undefined {
|
|
49
|
+
const kind = line[0];
|
|
50
|
+
if (kind !== "+" && kind !== "-" && kind !== " ") return undefined;
|
|
51
|
+
|
|
52
|
+
const body = line.slice(1);
|
|
53
|
+
const sep = body.indexOf("|");
|
|
54
|
+
if (sep === -1) return undefined;
|
|
55
|
+
|
|
56
|
+
const lineNumber = Number.parseInt(body.slice(0, sep), 10);
|
|
57
|
+
if (!Number.isFinite(lineNumber)) return undefined;
|
|
58
|
+
|
|
59
|
+
return { kind, lineNumber, content: body.slice(sep + 1) };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function appendAddedRun(output: string[], run: string[], edgeLines: number): void {
|
|
63
|
+
if (run.length === 0) return;
|
|
64
|
+
|
|
65
|
+
const collapseThreshold = edgeLines * 2 + 1;
|
|
66
|
+
if (run.length <= collapseThreshold) {
|
|
67
|
+
for (const text of run) appendPreviewLine(output, text);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
for (let i = 0; i < edgeLines; i++) appendPreviewLine(output, run[i]);
|
|
72
|
+
appendPreviewLine(output, PREVIEW_ELISION_MARKER);
|
|
73
|
+
for (let i = run.length - edgeLines; i < run.length; i++) appendPreviewLine(output, run[i]);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function buildCompactDiffPreview(diff: string, options: CompactDiffOptions = {}): CompactDiffPreview {
|
|
14
77
|
const lines = diff.length === 0 ? [] : diff.split("\n");
|
|
78
|
+
const addedRunContext = normalizeAddedRunContext(options.maxAddedRunContext ?? options.maxUnchangedRun);
|
|
15
79
|
let addedLines = 0;
|
|
16
80
|
let removedLines = 0;
|
|
81
|
+
const formatted: string[] = [];
|
|
82
|
+
const addedRun: string[] = [];
|
|
83
|
+
|
|
84
|
+
const flushAddedRun = (): void => {
|
|
85
|
+
appendAddedRun(formatted, addedRun, addedRunContext);
|
|
86
|
+
addedRun.length = 0;
|
|
87
|
+
};
|
|
17
88
|
|
|
18
89
|
// External diff producers number `+` lines with the post-edit line number,
|
|
19
90
|
// `-` lines with the pre-edit line number, and context lines with the
|
|
20
91
|
// pre-edit line number. To emit fresh line numbers usable for follow-up
|
|
21
92
|
// edits, convert context-line numbers to post-edit positions by tracking
|
|
22
93
|
// the running offset (added so far - removed so far) as we walk the diff.
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
if (
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const lineNumber = Number.parseInt(body.slice(0, sep), 10);
|
|
32
|
-
const content = body.slice(sep + 1);
|
|
94
|
+
for (const line of lines) {
|
|
95
|
+
const parsed = parseNumberedDiffLine(line);
|
|
96
|
+
if (!parsed) {
|
|
97
|
+
flushAddedRun();
|
|
98
|
+
appendPreviewLine(formatted, line);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
33
101
|
|
|
34
|
-
switch (kind) {
|
|
35
|
-
case "+":
|
|
102
|
+
switch (parsed.kind) {
|
|
103
|
+
case "+": {
|
|
36
104
|
addedLines++;
|
|
37
|
-
|
|
105
|
+
addedRun.push(`${parsed.lineNumber}:${parsed.content}`);
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
38
108
|
case "-":
|
|
109
|
+
flushAddedRun();
|
|
39
110
|
removedLines++;
|
|
40
|
-
|
|
111
|
+
break;
|
|
41
112
|
default: {
|
|
42
|
-
|
|
43
|
-
|
|
113
|
+
flushAddedRun();
|
|
114
|
+
const newLineNumber = parsed.lineNumber + addedLines - removedLines;
|
|
115
|
+
appendPreviewLine(formatted, `${newLineNumber}:${parsed.content}`);
|
|
116
|
+
break;
|
|
44
117
|
}
|
|
45
118
|
}
|
|
46
|
-
}
|
|
119
|
+
}
|
|
120
|
+
flushAddedRun();
|
|
121
|
+
while (formatted.length > 0 && isPreviewSeparator(formatted[formatted.length - 1])) formatted.pop();
|
|
47
122
|
|
|
48
123
|
return { preview: formatted.join("\n"), addedLines, removedLines };
|
|
49
124
|
}
|
package/src/grammar.lark
CHANGED
|
@@ -7,15 +7,17 @@ file_header: "[" filename "#" file_hash "]" LF
|
|
|
7
7
|
file_hash: /[0-9A-F]{4}/
|
|
8
8
|
filename: /[^#\r\n]+/
|
|
9
9
|
|
|
10
|
-
hunk: replace_hunk | replace_block_hunk | insert_hunk | delete_hunk | delete_block_hunk
|
|
10
|
+
hunk: replace_hunk | replace_block_hunk | insert_hunk | insert_block_hunk | delete_hunk | delete_block_hunk
|
|
11
11
|
replace_hunk: replace_anchor LF emit_op*
|
|
12
12
|
replace_block_hunk: replace_block_anchor LF emit_op+
|
|
13
13
|
insert_hunk: insert_anchor LF emit_op+
|
|
14
|
+
insert_block_hunk: insert_block_anchor LF emit_op+
|
|
14
15
|
delete_hunk: "delete " header_range LF
|
|
15
16
|
delete_block_hunk: "delete block " LID LF
|
|
16
17
|
replace_anchor: "replace " header_range ":"
|
|
17
18
|
replace_block_anchor: "replace block " LID ":"
|
|
18
19
|
insert_anchor: "insert " insert_pos ":"
|
|
20
|
+
insert_block_anchor: "insert after block " LID ":"
|
|
19
21
|
insert_pos: "before " LID | "after " LID | "head" | "tail"
|
|
20
22
|
emit_op: "+" /(.*)/ LF
|
|
21
23
|
|