@oh-my-pi/hashline 16.1.12 → 16.1.14
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 +9 -0
- package/package.json +1 -1
- package/src/apply.ts +186 -33
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.1.14] - 2026-06-22
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Improved delimiter-balance repair to correctly identify and spare deleted structural closers
|
|
10
|
+
- Prevented premature deletion of structural closers when existing code below the range covers them
|
|
11
|
+
- Accurate tracking of inserted lines to improve boundary repair logic for surrounding code blocks
|
|
12
|
+
- Fixed delimiter-balance repair so deleted closer suffixes are kept only when the replacement prefix still has unmatched openers for them, avoiding duplicated trailing braces while preserving omitted outer closers.
|
|
13
|
+
|
|
5
14
|
## [16.1.8] - 2026-06-20
|
|
6
15
|
|
|
7
16
|
### Fixed
|
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.14",
|
|
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
|
@@ -430,37 +430,167 @@ function findDuplicatePrefix(group: ReplacementGroup, fileLines: readonly string
|
|
|
430
430
|
}
|
|
431
431
|
return 0;
|
|
432
432
|
}
|
|
433
|
+
interface DroppedSuffixClosers {
|
|
434
|
+
readonly startLine: number;
|
|
435
|
+
readonly count: number;
|
|
436
|
+
readonly balance: DelimiterBalance;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function countPayloadRestatedSuffixHead(payload: readonly string[], suffixLines: readonly string[]): number {
|
|
440
|
+
const maxCount = Math.min(payload.length, suffixLines.length);
|
|
441
|
+
for (let count = maxCount; count >= 1; count--) {
|
|
442
|
+
let matches = true;
|
|
443
|
+
for (let offset = 0; offset < count; offset++) {
|
|
444
|
+
if (payload[payload.length - count + offset] !== suffixLines[offset]) {
|
|
445
|
+
matches = false;
|
|
446
|
+
break;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
if (matches) return count;
|
|
450
|
+
}
|
|
451
|
+
return 0;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function countProjectedBelowSuffixTail(
|
|
455
|
+
group: ReplacementGroup,
|
|
456
|
+
fileLines: readonly string[],
|
|
457
|
+
deletedLines: ReadonlySet<number>,
|
|
458
|
+
insertedLineMaps: InsertedLineMaps,
|
|
459
|
+
suffixLines: readonly string[],
|
|
460
|
+
): number {
|
|
461
|
+
const below: string[] = [];
|
|
462
|
+
const appendCloserLines = (lines: readonly string[] | undefined): boolean => {
|
|
463
|
+
if (!lines) return true;
|
|
464
|
+
for (const text of lines) {
|
|
465
|
+
if (!STRUCTURAL_CLOSER_RE.test(text)) return false;
|
|
466
|
+
below.push(text);
|
|
467
|
+
}
|
|
468
|
+
return true;
|
|
469
|
+
};
|
|
470
|
+
if (!appendCloserLines(insertedLineMaps.after.get(group.endLine))) return 0;
|
|
471
|
+
for (let line = group.endLine + 1; line <= fileLines.length; line++) {
|
|
472
|
+
if (!appendCloserLines(insertedLineMaps.before.get(line))) break;
|
|
473
|
+
if (!deletedLines.has(line)) {
|
|
474
|
+
const text = fileLines[line - 1] ?? "";
|
|
475
|
+
if (!STRUCTURAL_CLOSER_RE.test(text)) break;
|
|
476
|
+
below.push(text);
|
|
477
|
+
}
|
|
478
|
+
if (!appendCloserLines(insertedLineMaps.after.get(line))) break;
|
|
479
|
+
}
|
|
480
|
+
const maxCount = Math.min(below.length, suffixLines.length);
|
|
481
|
+
for (let count = maxCount; count >= 1; count--) {
|
|
482
|
+
let matches = true;
|
|
483
|
+
for (let offset = 0; offset < count; offset++) {
|
|
484
|
+
if (below[offset] !== suffixLines[suffixLines.length - count + offset]) {
|
|
485
|
+
matches = false;
|
|
486
|
+
break;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
if (matches) return count;
|
|
490
|
+
}
|
|
491
|
+
return 0;
|
|
492
|
+
}
|
|
433
493
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
494
|
+
interface InsertedLineMaps {
|
|
495
|
+
readonly before: ReadonlyMap<number, readonly string[]>;
|
|
496
|
+
readonly after: ReadonlyMap<number, readonly string[]>;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function computeProjectedPrefixBalance(
|
|
500
|
+
group: ReplacementGroup,
|
|
501
|
+
fileLines: readonly string[],
|
|
502
|
+
deletedLines: ReadonlySet<number>,
|
|
503
|
+
insertedByLine: ReadonlyMap<number, readonly string[]>,
|
|
504
|
+
insertedLineMaps: InsertedLineMaps,
|
|
505
|
+
): DelimiterBalance {
|
|
506
|
+
const prefix: string[] = [];
|
|
507
|
+
for (let line = 1; line < group.startLine; line++) {
|
|
508
|
+
const inserted = insertedByLine.get(line);
|
|
509
|
+
if (inserted) prefix.push(...inserted);
|
|
510
|
+
if (!deletedLines.has(line)) prefix.push(fileLines[line - 1] ?? "");
|
|
440
511
|
}
|
|
441
|
-
|
|
512
|
+
const insertedAtStart = insertedLineMaps.before.get(group.startLine);
|
|
513
|
+
if (insertedAtStart) prefix.push(...insertedAtStart);
|
|
514
|
+
prefix.push(...group.payload);
|
|
515
|
+
return computeDelimiterBalance(prefix);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function prefixCanCoverSuffixClosers(
|
|
519
|
+
group: ReplacementGroup,
|
|
520
|
+
fileLines: readonly string[],
|
|
521
|
+
suffixBalance: DelimiterBalance,
|
|
522
|
+
coveredBelowBalance: DelimiterBalance,
|
|
523
|
+
deletedLines: ReadonlySet<number>,
|
|
524
|
+
insertedByLine: ReadonlyMap<number, readonly string[]>,
|
|
525
|
+
insertedLineMaps: InsertedLineMaps,
|
|
526
|
+
): boolean {
|
|
527
|
+
const neededOpeners = balanceNegate(suffixBalance);
|
|
528
|
+
const prefixBalance = computeProjectedPrefixBalance(
|
|
529
|
+
group,
|
|
530
|
+
fileLines,
|
|
531
|
+
deletedLines,
|
|
532
|
+
insertedByLine,
|
|
533
|
+
insertedLineMaps,
|
|
534
|
+
);
|
|
535
|
+
const uncoveredPrefixBalance = balanceSum(prefixBalance, coveredBelowBalance);
|
|
536
|
+
return balanceCovers(uncoveredPrefixBalance, neededOpeners);
|
|
442
537
|
}
|
|
443
538
|
|
|
444
539
|
/**
|
|
445
|
-
*
|
|
446
|
-
*
|
|
447
|
-
*
|
|
448
|
-
*
|
|
449
|
-
*
|
|
540
|
+
* Missing segment of the range's deleted structural-closer suffix that should
|
|
541
|
+
* be spared. Payload lines that already restate the suffix head are not kept
|
|
542
|
+
* again, and projected closers immediately below the range satisfy the suffix
|
|
543
|
+
* tail. The remaining middle segment is kept only when backed by unmatched
|
|
544
|
+
* openers plus the whole-patch residual.
|
|
450
545
|
*/
|
|
451
546
|
function findDroppedSuffixClosers(
|
|
452
547
|
group: ReplacementGroup,
|
|
453
548
|
fileLines: readonly string[],
|
|
454
549
|
delta: DelimiterBalance,
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
550
|
+
remainingDelta: DelimiterBalance,
|
|
551
|
+
deletedPrefixBalance: DelimiterBalance,
|
|
552
|
+
deletedLines: ReadonlySet<number>,
|
|
553
|
+
insertedByLine: ReadonlyMap<number, readonly string[]>,
|
|
554
|
+
insertedLineMaps: InsertedLineMaps,
|
|
555
|
+
): DroppedSuffixClosers | undefined {
|
|
556
|
+
let suffixLength = 0;
|
|
557
|
+
while (
|
|
558
|
+
suffixLength < group.deleteIndices.length &&
|
|
559
|
+
STRUCTURAL_CLOSER_RE.test(fileLines[group.endLine - suffixLength - 1] ?? "")
|
|
560
|
+
) {
|
|
561
|
+
suffixLength++;
|
|
462
562
|
}
|
|
463
|
-
return
|
|
563
|
+
if (suffixLength === 0) return undefined;
|
|
564
|
+
|
|
565
|
+
const suffixStartLine = group.endLine - suffixLength + 1;
|
|
566
|
+
const suffixLines = fileLines.slice(group.endLine - suffixLength, group.endLine);
|
|
567
|
+
const restatedHead = countPayloadRestatedSuffixHead(group.payload, suffixLines);
|
|
568
|
+
const coveredTail = countProjectedBelowSuffixTail(group, fileLines, deletedLines, insertedLineMaps, suffixLines);
|
|
569
|
+
const keepStart = restatedHead;
|
|
570
|
+
const keepEnd = suffixLength - coveredTail;
|
|
571
|
+
if (keepStart >= keepEnd) return undefined;
|
|
572
|
+
|
|
573
|
+
const keptLines = suffixLines.slice(keepStart, keepEnd);
|
|
574
|
+
const keptBalance = computeDelimiterBalance(keptLines);
|
|
575
|
+
const neededOpeners = balanceNegate(keptBalance);
|
|
576
|
+
const coveredBelowBalance = computeDelimiterBalance(suffixLines.slice(keepEnd));
|
|
577
|
+
if (!balanceCovers(delta, neededOpeners)) return undefined;
|
|
578
|
+
if (balanceCovers(deletedPrefixBalance, neededOpeners)) return undefined;
|
|
579
|
+
if (!balanceCovers(remainingDelta, neededOpeners)) return undefined;
|
|
580
|
+
if (
|
|
581
|
+
!prefixCanCoverSuffixClosers(
|
|
582
|
+
group,
|
|
583
|
+
fileLines,
|
|
584
|
+
keptBalance,
|
|
585
|
+
coveredBelowBalance,
|
|
586
|
+
deletedLines,
|
|
587
|
+
insertedByLine,
|
|
588
|
+
insertedLineMaps,
|
|
589
|
+
)
|
|
590
|
+
) {
|
|
591
|
+
return undefined;
|
|
592
|
+
}
|
|
593
|
+
return { startLine: suffixStartLine + keepStart, count: keepEnd - keepStart, balance: keptBalance };
|
|
464
594
|
}
|
|
465
595
|
|
|
466
596
|
interface BoundaryEcho {
|
|
@@ -754,10 +884,6 @@ function repairReplacementBoundaries(
|
|
|
754
884
|
slots.push({ kind: "candidate", group, inserts, deletes, delta });
|
|
755
885
|
}
|
|
756
886
|
|
|
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
887
|
const projected: AppliedEdit[] = [];
|
|
762
888
|
for (const slot of slots) {
|
|
763
889
|
projected.push(...(slot.kind === "candidate" ? [...slot.inserts, ...slot.deletes] : slot.edits));
|
|
@@ -767,6 +893,10 @@ function repairReplacementBoundaries(
|
|
|
767
893
|
if (edit.kind === "delete") deletedLines.add(edit.anchor.line);
|
|
768
894
|
}
|
|
769
895
|
const insertedByLine = new Map<number, string[]>();
|
|
896
|
+
const insertedLineMaps: { before: Map<number, string[]>; after: Map<number, string[]> } = {
|
|
897
|
+
before: new Map(),
|
|
898
|
+
after: new Map(),
|
|
899
|
+
};
|
|
770
900
|
for (const edit of projected) {
|
|
771
901
|
if (edit.kind !== "insert") continue;
|
|
772
902
|
for (const anchor of getCursorAnchors(edit.cursor)) {
|
|
@@ -774,6 +904,12 @@ function repairReplacementBoundaries(
|
|
|
774
904
|
if (lines) lines.push(edit.text);
|
|
775
905
|
else insertedByLine.set(anchor.line, [edit.text]);
|
|
776
906
|
}
|
|
907
|
+
if (edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor") {
|
|
908
|
+
const bySide = edit.cursor.kind === "before_anchor" ? insertedLineMaps.before : insertedLineMaps.after;
|
|
909
|
+
const lines = bySide.get(edit.cursor.anchor.line);
|
|
910
|
+
if (lines) lines.push(edit.text);
|
|
911
|
+
else bySide.set(edit.cursor.anchor.line, [edit.text]);
|
|
912
|
+
}
|
|
777
913
|
}
|
|
778
914
|
let remainingDelta: DelimiterBalance = { paren: 0, bracket: 0, brace: 0 };
|
|
779
915
|
for (const slot of slots) remainingDelta = balanceSum(remainingDelta, slotPatchDelta(slot, fileLines));
|
|
@@ -786,20 +922,37 @@ function repairReplacementBoundaries(
|
|
|
786
922
|
out.push(...slot.edits);
|
|
787
923
|
continue;
|
|
788
924
|
}
|
|
789
|
-
const
|
|
790
|
-
const droppedClosers =
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
925
|
+
const deletedPrefixBalance = netDeletedPrefixBalance(slot.group, deletedLines, insertedByLine, fileLines);
|
|
926
|
+
const droppedClosers = findDroppedSuffixClosers(
|
|
927
|
+
slot.group,
|
|
928
|
+
fileLines,
|
|
929
|
+
slot.delta,
|
|
930
|
+
remainingDelta,
|
|
931
|
+
deletedPrefixBalance,
|
|
932
|
+
deletedLines,
|
|
933
|
+
insertedByLine,
|
|
934
|
+
insertedLineMaps,
|
|
935
|
+
);
|
|
936
|
+
if (droppedClosers) {
|
|
795
937
|
warnings.push(
|
|
796
938
|
describeBoundaryRepair(
|
|
797
939
|
slot.group,
|
|
798
|
-
`kept ${droppedClosers} structural closing line(s) the range deleted without restating`,
|
|
940
|
+
`kept ${droppedClosers.count} structural closing line(s) the range deleted without restating`,
|
|
799
941
|
),
|
|
800
942
|
);
|
|
801
|
-
out.push(
|
|
802
|
-
|
|
943
|
+
out.push(
|
|
944
|
+
...slot.inserts,
|
|
945
|
+
...slot.deletes.filter(
|
|
946
|
+
edit =>
|
|
947
|
+
edit.kind !== "delete" ||
|
|
948
|
+
edit.anchor.line < droppedClosers.startLine ||
|
|
949
|
+
edit.anchor.line >= droppedClosers.startLine + droppedClosers.count,
|
|
950
|
+
),
|
|
951
|
+
);
|
|
952
|
+
for (let line = droppedClosers.startLine; line < droppedClosers.startLine + droppedClosers.count; line++) {
|
|
953
|
+
deletedLines.delete(line);
|
|
954
|
+
}
|
|
955
|
+
remainingDelta = balanceSum(remainingDelta, droppedClosers.balance);
|
|
803
956
|
continue;
|
|
804
957
|
}
|
|
805
958
|
out.push(...slot.inserts, ...slot.deletes);
|