@oh-my-pi/hashline 16.1.7 → 16.1.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 +6 -0
- package/package.json +1 -1
- package/src/apply.ts +152 -22
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.1.8] - 2026-06-20
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Fixed multi-hunk delimiter-balance repair so a `SWAP` that drops a structural closer no longer keeps it when another hunk already removed the matching opener (a deliberate wrapper removal); the missing-closer repair now weighs each group against the whole patch's residual delimiter balance — summed per hunk so quote/comment state never bleeds across non-contiguous hunks — and consumes that residual per repair so a genuine missing closer elsewhere still fires. ([#3142](https://github.com/can1357/oh-my-pi/issues/3142))
|
|
10
|
+
|
|
5
11
|
## [16.1.2] - 2026-06-19
|
|
6
12
|
|
|
7
13
|
### Changed
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/hashline",
|
|
4
|
-
"version": "16.1.
|
|
4
|
+
"version": "16.1.8",
|
|
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",
|
package/src/apply.ts
CHANGED
|
@@ -312,6 +312,23 @@ function balanceIsZero(a: DelimiterBalance): boolean {
|
|
|
312
312
|
return a.paren === 0 && a.bracket === 0 && a.brace === 0;
|
|
313
313
|
}
|
|
314
314
|
|
|
315
|
+
function balanceSum(a: DelimiterBalance, b: DelimiterBalance): DelimiterBalance {
|
|
316
|
+
return { paren: a.paren + b.paren, bracket: a.bracket + b.bracket, brace: a.brace + b.brace };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function balanceComponentCovers(candidate: number, target: number): boolean {
|
|
320
|
+
if (target === 0) return true;
|
|
321
|
+
return candidate > 0 === target > 0 && Math.abs(candidate) >= Math.abs(target);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function balanceCovers(candidate: DelimiterBalance, target: DelimiterBalance): boolean {
|
|
325
|
+
return (
|
|
326
|
+
balanceComponentCovers(candidate.paren, target.paren) &&
|
|
327
|
+
balanceComponentCovers(candidate.bracket, target.bracket) &&
|
|
328
|
+
balanceComponentCovers(candidate.brace, target.brace)
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
315
332
|
interface ReplacementGroup {
|
|
316
333
|
/** Positions in the edit array of the payload inserts, in payload order. */
|
|
317
334
|
insertIndices: number[];
|
|
@@ -587,10 +604,72 @@ function describeOneSidedEchoRepair(group: ReplacementGroup, side: "leading" | "
|
|
|
587
604
|
);
|
|
588
605
|
}
|
|
589
606
|
|
|
607
|
+
/**
|
|
608
|
+
* One pass-1 outcome per source position: resolved edits (with an optional
|
|
609
|
+
* warning) or a deferred missing-closer candidate, resolved against the
|
|
610
|
+
* whole-patch residual in pass 2.
|
|
611
|
+
*/
|
|
612
|
+
type RepairSlot =
|
|
613
|
+
| { kind: "edits"; edits: AppliedEdit[]; warning?: string }
|
|
614
|
+
| {
|
|
615
|
+
kind: "candidate";
|
|
616
|
+
group: ReplacementGroup;
|
|
617
|
+
inserts: AppliedEdit[];
|
|
618
|
+
deletes: AppliedEdit[];
|
|
619
|
+
delta: DelimiterBalance;
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Delimiter balance of the lines immediately above a group's range that are
|
|
624
|
+
* themselves deleted by other hunks, netted against any payload inserted at
|
|
625
|
+
* those lines. When this covers the group's own delta the matching opener was
|
|
626
|
+
* deleted (or replaced by an opener of the same shape) just above — a deliberate
|
|
627
|
+
* wrapper removal — so the range's deleted closer must stay deleted, not be
|
|
628
|
+
* "kept". Scanned over its own contiguous lines so quote/comment state never
|
|
629
|
+
* bleeds in from elsewhere in the patch.
|
|
630
|
+
*/
|
|
631
|
+
function netDeletedPrefixBalance(
|
|
632
|
+
group: ReplacementGroup,
|
|
633
|
+
deletedLines: ReadonlySet<number>,
|
|
634
|
+
insertedByLine: ReadonlyMap<number, readonly string[]>,
|
|
635
|
+
fileLines: readonly string[],
|
|
636
|
+
): DelimiterBalance {
|
|
637
|
+
const deleted: string[] = [];
|
|
638
|
+
const inserted: string[] = [];
|
|
639
|
+
for (let line = group.startLine - 1; line >= 1 && deletedLines.has(line); line--) {
|
|
640
|
+
deleted.unshift(fileLines[line - 1] ?? "");
|
|
641
|
+
const insertedAtLine = insertedByLine.get(line);
|
|
642
|
+
if (insertedAtLine) inserted.unshift(...insertedAtLine);
|
|
643
|
+
}
|
|
644
|
+
return balanceDelta(computeDelimiterBalance(deleted), computeDelimiterBalance(inserted));
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Net delimiter balance a slot contributes, computed over the slot's own
|
|
649
|
+
* contiguous insert/delete lines only. Summing these per-slot deltas — never one
|
|
650
|
+
* concatenated scan across non-adjacent hunks — keeps backtick/block-comment
|
|
651
|
+
* state local, so an unterminated quote in one hunk cannot mask a real delimiter
|
|
652
|
+
* in another.
|
|
653
|
+
*/
|
|
654
|
+
function slotPatchDelta(slot: RepairSlot, fileLines: readonly string[]): DelimiterBalance {
|
|
655
|
+
if (slot.kind === "candidate") return slot.delta;
|
|
656
|
+
const inserted: string[] = [];
|
|
657
|
+
const deleted: string[] = [];
|
|
658
|
+
for (const edit of slot.edits) {
|
|
659
|
+
if (edit.kind === "insert") inserted.push(edit.text);
|
|
660
|
+
else deleted.push(fileLines[edit.anchor.line - 1] ?? "");
|
|
661
|
+
}
|
|
662
|
+
return balanceDelta(computeDelimiterBalance(inserted), computeDelimiterBalance(deleted));
|
|
663
|
+
}
|
|
664
|
+
|
|
590
665
|
/**
|
|
591
666
|
* Normalize replacement groups so common off-by-one boundaries do not duplicate
|
|
592
|
-
* unchanged surrounding lines or structural closers.
|
|
593
|
-
*
|
|
667
|
+
* unchanged surrounding lines or wrongly drop/keep structural closers. Local
|
|
668
|
+
* repairs run in pass 1; the missing-closer repair is deferred to pass 2 and
|
|
669
|
+
* weighed against the whole-patch delimiter residual, so a closer the range
|
|
670
|
+
* deleted is only kept when the patch as a whole is missing it — never when
|
|
671
|
+
* another hunk already removed the matching opener. Returns the repaired edits
|
|
672
|
+
* plus one warning per repaired group.
|
|
594
673
|
*/
|
|
595
674
|
function repairReplacementBoundaries(
|
|
596
675
|
edits: readonly AppliedEdit[],
|
|
@@ -599,13 +678,16 @@ function repairReplacementBoundaries(
|
|
|
599
678
|
edits: AppliedEdit[];
|
|
600
679
|
warnings: string[];
|
|
601
680
|
} {
|
|
602
|
-
|
|
603
|
-
|
|
681
|
+
// Pass 1: apply every repair whose correctness is local to one group
|
|
682
|
+
// (boundary echo, duplicate prefix/suffix). Defer the missing-closer repair:
|
|
683
|
+
// it must weigh a group's imbalance against the whole patch, which is only
|
|
684
|
+
// known once the local repairs above have settled.
|
|
685
|
+
const slots: RepairSlot[] = [];
|
|
604
686
|
let i = 0;
|
|
605
687
|
while (i < edits.length) {
|
|
606
688
|
const group = findReplacementGroup(edits, i);
|
|
607
689
|
if (!group) {
|
|
608
|
-
|
|
690
|
+
slots.push({ kind: "edits", edits: [edits[i]] });
|
|
609
691
|
i++;
|
|
610
692
|
continue;
|
|
611
693
|
}
|
|
@@ -615,8 +697,11 @@ function repairReplacementBoundaries(
|
|
|
615
697
|
|
|
616
698
|
const boundaryEcho = findBoundaryEcho(group, fileLines);
|
|
617
699
|
if (boundaryEcho) {
|
|
618
|
-
|
|
619
|
-
|
|
700
|
+
slots.push({
|
|
701
|
+
kind: "edits",
|
|
702
|
+
edits: [...inserts.slice(boundaryEcho.leading, inserts.length - boundaryEcho.trailing), ...deletes],
|
|
703
|
+
warning: describeBoundaryEchoRepair(group, boundaryEcho),
|
|
704
|
+
});
|
|
620
705
|
continue;
|
|
621
706
|
}
|
|
622
707
|
|
|
@@ -627,52 +712,97 @@ function repairReplacementBoundaries(
|
|
|
627
712
|
if (balanceIsZero(delta)) {
|
|
628
713
|
const oneSided = findOneSidedBoundaryEcho(group, fileLines);
|
|
629
714
|
if (oneSided) {
|
|
630
|
-
warnings.push(describeOneSidedEchoRepair(group, oneSided.side, oneSided.count));
|
|
631
715
|
const trimmed =
|
|
632
716
|
oneSided.side === "leading"
|
|
633
717
|
? inserts.slice(oneSided.count)
|
|
634
718
|
: inserts.slice(0, inserts.length - oneSided.count);
|
|
635
|
-
|
|
719
|
+
slots.push({
|
|
720
|
+
kind: "edits",
|
|
721
|
+
edits: [...trimmed, ...deletes],
|
|
722
|
+
warning: describeOneSidedEchoRepair(group, oneSided.side, oneSided.count),
|
|
723
|
+
});
|
|
636
724
|
continue;
|
|
637
725
|
}
|
|
638
|
-
|
|
726
|
+
slots.push({ kind: "edits", edits: [...inserts, ...deletes] });
|
|
639
727
|
continue;
|
|
640
728
|
}
|
|
641
729
|
|
|
642
730
|
const dupSuffix = findDuplicateSuffix(group, fileLines, delta);
|
|
643
731
|
if (dupSuffix > 0) {
|
|
644
|
-
|
|
645
|
-
|
|
732
|
+
slots.push({
|
|
733
|
+
kind: "edits",
|
|
734
|
+
edits: [...inserts.slice(0, inserts.length - dupSuffix), ...deletes],
|
|
735
|
+
warning: describeBoundaryRepair(
|
|
646
736
|
group,
|
|
647
737
|
`dropped ${dupSuffix} duplicated trailing payload line(s) already present below the range`,
|
|
648
738
|
),
|
|
649
|
-
);
|
|
650
|
-
out.push(...inserts.slice(0, inserts.length - dupSuffix), ...deletes);
|
|
739
|
+
});
|
|
651
740
|
continue;
|
|
652
741
|
}
|
|
653
742
|
const dupPrefix = findDuplicatePrefix(group, fileLines, delta);
|
|
654
743
|
if (dupPrefix > 0) {
|
|
655
|
-
|
|
656
|
-
|
|
744
|
+
slots.push({
|
|
745
|
+
kind: "edits",
|
|
746
|
+
edits: [...inserts.slice(dupPrefix), ...deletes],
|
|
747
|
+
warning: describeBoundaryRepair(
|
|
657
748
|
group,
|
|
658
749
|
`dropped ${dupPrefix} duplicated leading payload line(s) already present above the range`,
|
|
659
750
|
),
|
|
660
|
-
);
|
|
661
|
-
out.push(...inserts.slice(dupPrefix), ...deletes);
|
|
751
|
+
});
|
|
662
752
|
continue;
|
|
663
753
|
}
|
|
664
|
-
|
|
754
|
+
slots.push({ kind: "candidate", group, inserts, deletes, delta });
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// Pass 2: project the post-pass-1 stream to learn which lines are deleted and
|
|
758
|
+
// what is inserted where, sum the whole-patch residual balance bleed-free,
|
|
759
|
+
// then resolve each deferred missing-closer candidate against that residual,
|
|
760
|
+
// consuming it per repair so one missing closer is never spent twice.
|
|
761
|
+
const projected: AppliedEdit[] = [];
|
|
762
|
+
for (const slot of slots) {
|
|
763
|
+
projected.push(...(slot.kind === "candidate" ? [...slot.inserts, ...slot.deletes] : slot.edits));
|
|
764
|
+
}
|
|
765
|
+
const deletedLines = new Set<number>();
|
|
766
|
+
for (const edit of projected) {
|
|
767
|
+
if (edit.kind === "delete") deletedLines.add(edit.anchor.line);
|
|
768
|
+
}
|
|
769
|
+
const insertedByLine = new Map<number, string[]>();
|
|
770
|
+
for (const edit of projected) {
|
|
771
|
+
if (edit.kind !== "insert") continue;
|
|
772
|
+
for (const anchor of getCursorAnchors(edit.cursor)) {
|
|
773
|
+
const lines = insertedByLine.get(anchor.line);
|
|
774
|
+
if (lines) lines.push(edit.text);
|
|
775
|
+
else insertedByLine.set(anchor.line, [edit.text]);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
let remainingDelta: DelimiterBalance = { paren: 0, bracket: 0, brace: 0 };
|
|
779
|
+
for (const slot of slots) remainingDelta = balanceSum(remainingDelta, slotPatchDelta(slot, fileLines));
|
|
780
|
+
|
|
781
|
+
const out: AppliedEdit[] = [];
|
|
782
|
+
const warnings: string[] = [];
|
|
783
|
+
for (const slot of slots) {
|
|
784
|
+
if (slot.kind !== "candidate") {
|
|
785
|
+
if (slot.warning !== undefined) warnings.push(slot.warning);
|
|
786
|
+
out.push(...slot.edits);
|
|
787
|
+
continue;
|
|
788
|
+
}
|
|
789
|
+
const prefixBalance = netDeletedPrefixBalance(slot.group, deletedLines, insertedByLine, fileLines);
|
|
790
|
+
const droppedClosers =
|
|
791
|
+
!balanceCovers(prefixBalance, slot.delta) && balanceCovers(remainingDelta, slot.delta)
|
|
792
|
+
? findDroppedSuffixClosers(slot.group, fileLines, slot.delta)
|
|
793
|
+
: 0;
|
|
665
794
|
if (droppedClosers > 0) {
|
|
666
795
|
warnings.push(
|
|
667
796
|
describeBoundaryRepair(
|
|
668
|
-
group,
|
|
797
|
+
slot.group,
|
|
669
798
|
`kept ${droppedClosers} structural closing line(s) the range deleted without restating`,
|
|
670
799
|
),
|
|
671
800
|
);
|
|
672
|
-
out.push(...inserts, ...deletes.slice(0, deletes.length - droppedClosers));
|
|
801
|
+
out.push(...slot.inserts, ...slot.deletes.slice(0, slot.deletes.length - droppedClosers));
|
|
802
|
+
remainingDelta = balanceDelta(remainingDelta, slot.delta);
|
|
673
803
|
continue;
|
|
674
804
|
}
|
|
675
|
-
out.push(...inserts, ...deletes);
|
|
805
|
+
out.push(...slot.inserts, ...slot.deletes);
|
|
676
806
|
}
|
|
677
807
|
return { edits: out, warnings };
|
|
678
808
|
}
|