@oh-my-pi/hashline 15.7.6 → 15.8.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.
- package/package.json +1 -1
- package/src/apply.ts +97 -22
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/hashline",
|
|
4
|
-
"version": "15.
|
|
4
|
+
"version": "15.8.0",
|
|
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
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
* post-edit lines plus any diagnostic warnings. Pure function: no FS, no
|
|
4
4
|
* mutation of the input.
|
|
5
5
|
*
|
|
6
|
-
* Replacement groups are first normalized by {@link
|
|
7
|
-
* which
|
|
8
|
-
*
|
|
6
|
+
* Replacement groups are first normalized by {@link repairReplacementBoundaries},
|
|
7
|
+
* which absorbs common model mistakes where a payload restates unchanged range
|
|
8
|
+
* boundaries or duplicates/drops structural closers.
|
|
9
9
|
*/
|
|
10
10
|
import { UNRESOLVED_BLOCK_INTERNAL } from "./messages";
|
|
11
11
|
import { cloneCursor } from "./tokenizer";
|
|
@@ -98,22 +98,19 @@ function bucketAnchorEditsByLine(edits: IndexedEdit[]): Map<number, IndexedEdit[
|
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
101
|
-
//
|
|
101
|
+
// Replacement-boundary repair
|
|
102
102
|
//
|
|
103
|
-
// Models routinely miscount a replacement range's edges.
|
|
104
|
-
// re-states
|
|
105
|
-
// (
|
|
106
|
-
//
|
|
107
|
-
// replacement whose payload does not preserve the deleted region's delimiter
|
|
108
|
-
// balance — and both leave the file syntactically broken.
|
|
103
|
+
// Models routinely miscount a replacement range's edges. Sometimes the payload
|
|
104
|
+
// re-states unchanged lines that still live on both sides of the range
|
|
105
|
+
// (duplicating a function header and final statement); sometimes it only
|
|
106
|
+
// re-states or omits a structural closer, which leaves delimiter balance broken.
|
|
109
107
|
//
|
|
110
|
-
// A repair fires only when
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
// strictly alone.
|
|
108
|
+
// A balance-neutral boundary-echo repair fires only when both the leading and
|
|
109
|
+
// trailing payload edges are exact copies of the surviving lines outside the
|
|
110
|
+
// range. One-sided content echoes are left alone unless delimiter-balance repair
|
|
111
|
+
// proves they are duplicated structural boundaries. This preserves intended
|
|
112
|
+
// duplicate statements while absorbing the common "body includes the unchanged
|
|
113
|
+
// wrapper" mistake.
|
|
117
114
|
|
|
118
115
|
/** A line that is nothing but closing delimiters: `}`, `)`, `];`, `})`, `},`. */
|
|
119
116
|
const STRUCTURAL_CLOSER_RE = /^\s*[)\]}]+[;,]?\s*$/;
|
|
@@ -322,6 +319,77 @@ function findDroppedSuffixClosers(
|
|
|
322
319
|
return 0;
|
|
323
320
|
}
|
|
324
321
|
|
|
322
|
+
interface BoundaryEcho {
|
|
323
|
+
leading: number;
|
|
324
|
+
trailing: number;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function hasNonWhitespace(text: string): boolean {
|
|
328
|
+
for (let i = 0; i < text.length; i++) {
|
|
329
|
+
const code = text.charCodeAt(i);
|
|
330
|
+
if (code !== 9 && code !== 10 && code !== 11 && code !== 12 && code !== 13 && code !== 32) return true;
|
|
331
|
+
}
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function countDuplicateLeadingBoundaryLines(group: ReplacementGroup, fileLines: readonly string[]): number {
|
|
336
|
+
const { payload, startLine } = group;
|
|
337
|
+
const max = Math.min(payload.length, startLine - 1);
|
|
338
|
+
for (let count = max; count >= 1; count--) {
|
|
339
|
+
let matches = true;
|
|
340
|
+
let hasContent = false;
|
|
341
|
+
for (let offset = 0; offset < count; offset++) {
|
|
342
|
+
const line = payload[offset];
|
|
343
|
+
if (line !== fileLines[startLine - 1 - count + offset]) {
|
|
344
|
+
matches = false;
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
hasContent ||= hasNonWhitespace(line);
|
|
348
|
+
}
|
|
349
|
+
if (matches && hasContent) return count;
|
|
350
|
+
}
|
|
351
|
+
return 0;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function countDuplicateTrailingBoundaryLines(group: ReplacementGroup, fileLines: readonly string[]): number {
|
|
355
|
+
const { payload, endLine } = group;
|
|
356
|
+
const max = Math.min(payload.length, fileLines.length - endLine);
|
|
357
|
+
for (let count = max; count >= 1; count--) {
|
|
358
|
+
let matches = true;
|
|
359
|
+
let hasContent = false;
|
|
360
|
+
for (let offset = 0; offset < count; offset++) {
|
|
361
|
+
const line = payload[payload.length - count + offset];
|
|
362
|
+
if (line !== fileLines[endLine + offset]) {
|
|
363
|
+
matches = false;
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
hasContent ||= hasNonWhitespace(line);
|
|
367
|
+
}
|
|
368
|
+
if (matches && hasContent) return count;
|
|
369
|
+
}
|
|
370
|
+
return 0;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function findBoundaryEcho(group: ReplacementGroup, fileLines: readonly string[]): BoundaryEcho | undefined {
|
|
374
|
+
const leadingMax = countDuplicateLeadingBoundaryLines(group, fileLines);
|
|
375
|
+
if (leadingMax === 0) return undefined;
|
|
376
|
+
const trailingMax = countDuplicateTrailingBoundaryLines(group, fileLines);
|
|
377
|
+
if (trailingMax === 0) return undefined;
|
|
378
|
+
// Bail when every payload line could be claimed by a boundary echo: any
|
|
379
|
+
// repair would strip explicit replacement content with no signal that the
|
|
380
|
+
// payload was a mistake rather than an intentional duplication.
|
|
381
|
+
if (leadingMax + trailingMax >= group.payload.length) return undefined;
|
|
382
|
+
return { leading: leadingMax, trailing: trailingMax };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function describeBoundaryEchoRepair(group: ReplacementGroup, echo: BoundaryEcho): string {
|
|
386
|
+
return (
|
|
387
|
+
`Auto-repaired a replacement boundary echo at line ${group.startLine}: ` +
|
|
388
|
+
`dropped ${echo.leading} leading and ${echo.trailing} trailing payload line(s) already present outside the range. ` +
|
|
389
|
+
`Issue the payload as the final desired content for the selected range only — never restate unchanged lines bordering the range.`
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
325
393
|
function describeBoundaryRepair(group: ReplacementGroup, action: string): string {
|
|
326
394
|
return (
|
|
327
395
|
`Auto-repaired a delimiter-balance mismatch in the replacement at line ${group.startLine}: ${action}. ` +
|
|
@@ -330,11 +398,11 @@ function describeBoundaryRepair(group: ReplacementGroup, action: string): string
|
|
|
330
398
|
}
|
|
331
399
|
|
|
332
400
|
/**
|
|
333
|
-
* Normalize
|
|
334
|
-
*
|
|
335
|
-
*
|
|
401
|
+
* Normalize replacement groups so common off-by-one boundaries do not duplicate
|
|
402
|
+
* unchanged surrounding lines or structural closers. Returns the repaired edit
|
|
403
|
+
* list plus one warning per repaired group.
|
|
336
404
|
*/
|
|
337
|
-
function
|
|
405
|
+
function repairReplacementBoundaries(
|
|
338
406
|
edits: readonly AppliedEdit[],
|
|
339
407
|
fileLines: readonly string[],
|
|
340
408
|
): {
|
|
@@ -355,6 +423,13 @@ function repairBoundaryBalance(
|
|
|
355
423
|
const deletes = group.deleteIndices.map(idx => edits[idx]);
|
|
356
424
|
i = group.deleteIndices[group.deleteIndices.length - 1] + 1;
|
|
357
425
|
|
|
426
|
+
const boundaryEcho = findBoundaryEcho(group, fileLines);
|
|
427
|
+
if (boundaryEcho) {
|
|
428
|
+
warnings.push(describeBoundaryEchoRepair(group, boundaryEcho));
|
|
429
|
+
out.push(...inserts.slice(boundaryEcho.leading, inserts.length - boundaryEcho.trailing), ...deletes);
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
|
|
358
433
|
const delta = balanceDelta(
|
|
359
434
|
computeDelimiterBalance(group.payload),
|
|
360
435
|
computeDelimiterBalance(fileLines.slice(group.startLine - 1, group.endLine)),
|
|
@@ -429,7 +504,7 @@ export function applyEdits(text: string, edits: readonly Edit[]): ApplyResult {
|
|
|
429
504
|
|
|
430
505
|
const targetEdits = appliedEdits.map((edit, index) => cloneAppliedEdit(edit, index));
|
|
431
506
|
validateLineBounds(targetEdits, fileLines);
|
|
432
|
-
const { edits: repaired, warnings } =
|
|
507
|
+
const { edits: repaired, warnings } = repairReplacementBoundaries(targetEdits, fileLines);
|
|
433
508
|
|
|
434
509
|
// Partition edits into bof, eof, and anchor-targeted buckets.
|
|
435
510
|
const bofLines: string[] = [];
|