@oh-my-pi/hashline 15.7.6 → 15.8.2
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 +104 -25
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.2",
|
|
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*$/;
|
|
@@ -260,9 +257,13 @@ function findReplacementGroup(edits: readonly AppliedEdit[], start: number): Rep
|
|
|
260
257
|
/**
|
|
261
258
|
* Largest `k` such that the payload's last `k` lines exactly equal the `k`
|
|
262
259
|
* surviving file lines just below the range AND dropping them zeroes `delta`.
|
|
263
|
-
*
|
|
260
|
+
* Requires a non-zero `delta`: a zero-balance candidate can never account for
|
|
261
|
+
* the imbalance, so intentional duplicates of ordinary statements stay intact,
|
|
262
|
+
* while duplicated structural lines (closers like `});`, openers like `foo(`)
|
|
263
|
+
* are dropped when they exactly explain the imbalance.
|
|
264
264
|
*/
|
|
265
265
|
function findDuplicateSuffix(group: ReplacementGroup, fileLines: readonly string[], delta: DelimiterBalance): number {
|
|
266
|
+
if (balanceIsZero(delta)) return 0;
|
|
266
267
|
const { payload, endLine } = group;
|
|
267
268
|
const maxK = Math.min(payload.length, fileLines.length - endLine);
|
|
268
269
|
for (let k = maxK; k >= 1; k--) {
|
|
@@ -274,7 +275,6 @@ function findDuplicateSuffix(group: ReplacementGroup, fileLines: readonly string
|
|
|
274
275
|
}
|
|
275
276
|
}
|
|
276
277
|
if (!matches) continue;
|
|
277
|
-
if (k === 1 && !STRUCTURAL_CLOSER_RE.test(payload[payload.length - 1])) continue;
|
|
278
278
|
if (balanceEqual(computeDelimiterBalance(payload.slice(payload.length - k)), delta)) return k;
|
|
279
279
|
}
|
|
280
280
|
return 0;
|
|
@@ -283,8 +283,10 @@ function findDuplicateSuffix(group: ReplacementGroup, fileLines: readonly string
|
|
|
283
283
|
/**
|
|
284
284
|
* Largest `j` such that the payload's first `j` lines exactly equal the `j`
|
|
285
285
|
* surviving file lines just above the range AND dropping them zeroes `delta`.
|
|
286
|
+
* Requires a non-zero `delta`; see {@link findDuplicateSuffix}.
|
|
286
287
|
*/
|
|
287
288
|
function findDuplicatePrefix(group: ReplacementGroup, fileLines: readonly string[], delta: DelimiterBalance): number {
|
|
289
|
+
if (balanceIsZero(delta)) return 0;
|
|
288
290
|
const { payload, startLine } = group;
|
|
289
291
|
const maxJ = Math.min(payload.length, startLine - 1);
|
|
290
292
|
for (let j = maxJ; j >= 1; j--) {
|
|
@@ -296,7 +298,6 @@ function findDuplicatePrefix(group: ReplacementGroup, fileLines: readonly string
|
|
|
296
298
|
}
|
|
297
299
|
}
|
|
298
300
|
if (!matches) continue;
|
|
299
|
-
if (j === 1 && !STRUCTURAL_CLOSER_RE.test(payload[0])) continue;
|
|
300
301
|
if (balanceEqual(computeDelimiterBalance(payload.slice(0, j)), delta)) return j;
|
|
301
302
|
}
|
|
302
303
|
return 0;
|
|
@@ -322,6 +323,77 @@ function findDroppedSuffixClosers(
|
|
|
322
323
|
return 0;
|
|
323
324
|
}
|
|
324
325
|
|
|
326
|
+
interface BoundaryEcho {
|
|
327
|
+
leading: number;
|
|
328
|
+
trailing: number;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function hasNonWhitespace(text: string): boolean {
|
|
332
|
+
for (let i = 0; i < text.length; i++) {
|
|
333
|
+
const code = text.charCodeAt(i);
|
|
334
|
+
if (code !== 9 && code !== 10 && code !== 11 && code !== 12 && code !== 13 && code !== 32) return true;
|
|
335
|
+
}
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function countDuplicateLeadingBoundaryLines(group: ReplacementGroup, fileLines: readonly string[]): number {
|
|
340
|
+
const { payload, startLine } = group;
|
|
341
|
+
const max = Math.min(payload.length, startLine - 1);
|
|
342
|
+
for (let count = max; count >= 1; count--) {
|
|
343
|
+
let matches = true;
|
|
344
|
+
let hasContent = false;
|
|
345
|
+
for (let offset = 0; offset < count; offset++) {
|
|
346
|
+
const line = payload[offset];
|
|
347
|
+
if (line !== fileLines[startLine - 1 - count + offset]) {
|
|
348
|
+
matches = false;
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
hasContent ||= hasNonWhitespace(line);
|
|
352
|
+
}
|
|
353
|
+
if (matches && hasContent) return count;
|
|
354
|
+
}
|
|
355
|
+
return 0;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function countDuplicateTrailingBoundaryLines(group: ReplacementGroup, fileLines: readonly string[]): number {
|
|
359
|
+
const { payload, endLine } = group;
|
|
360
|
+
const max = Math.min(payload.length, fileLines.length - endLine);
|
|
361
|
+
for (let count = max; count >= 1; count--) {
|
|
362
|
+
let matches = true;
|
|
363
|
+
let hasContent = false;
|
|
364
|
+
for (let offset = 0; offset < count; offset++) {
|
|
365
|
+
const line = payload[payload.length - count + offset];
|
|
366
|
+
if (line !== fileLines[endLine + offset]) {
|
|
367
|
+
matches = false;
|
|
368
|
+
break;
|
|
369
|
+
}
|
|
370
|
+
hasContent ||= hasNonWhitespace(line);
|
|
371
|
+
}
|
|
372
|
+
if (matches && hasContent) return count;
|
|
373
|
+
}
|
|
374
|
+
return 0;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function findBoundaryEcho(group: ReplacementGroup, fileLines: readonly string[]): BoundaryEcho | undefined {
|
|
378
|
+
const leadingMax = countDuplicateLeadingBoundaryLines(group, fileLines);
|
|
379
|
+
if (leadingMax === 0) return undefined;
|
|
380
|
+
const trailingMax = countDuplicateTrailingBoundaryLines(group, fileLines);
|
|
381
|
+
if (trailingMax === 0) return undefined;
|
|
382
|
+
// Bail when every payload line could be claimed by a boundary echo: any
|
|
383
|
+
// repair would strip explicit replacement content with no signal that the
|
|
384
|
+
// payload was a mistake rather than an intentional duplication.
|
|
385
|
+
if (leadingMax + trailingMax >= group.payload.length) return undefined;
|
|
386
|
+
return { leading: leadingMax, trailing: trailingMax };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function describeBoundaryEchoRepair(group: ReplacementGroup, echo: BoundaryEcho): string {
|
|
390
|
+
return (
|
|
391
|
+
`Auto-repaired a replacement boundary echo at line ${group.startLine}: ` +
|
|
392
|
+
`dropped ${echo.leading} leading and ${echo.trailing} trailing payload line(s) already present outside the range. ` +
|
|
393
|
+
`Issue the payload as the final desired content for the selected range only — never restate unchanged lines bordering the range.`
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
325
397
|
function describeBoundaryRepair(group: ReplacementGroup, action: string): string {
|
|
326
398
|
return (
|
|
327
399
|
`Auto-repaired a delimiter-balance mismatch in the replacement at line ${group.startLine}: ${action}. ` +
|
|
@@ -330,11 +402,11 @@ function describeBoundaryRepair(group: ReplacementGroup, action: string): string
|
|
|
330
402
|
}
|
|
331
403
|
|
|
332
404
|
/**
|
|
333
|
-
* Normalize
|
|
334
|
-
*
|
|
335
|
-
*
|
|
405
|
+
* Normalize replacement groups so common off-by-one boundaries do not duplicate
|
|
406
|
+
* unchanged surrounding lines or structural closers. Returns the repaired edit
|
|
407
|
+
* list plus one warning per repaired group.
|
|
336
408
|
*/
|
|
337
|
-
function
|
|
409
|
+
function repairReplacementBoundaries(
|
|
338
410
|
edits: readonly AppliedEdit[],
|
|
339
411
|
fileLines: readonly string[],
|
|
340
412
|
): {
|
|
@@ -355,6 +427,13 @@ function repairBoundaryBalance(
|
|
|
355
427
|
const deletes = group.deleteIndices.map(idx => edits[idx]);
|
|
356
428
|
i = group.deleteIndices[group.deleteIndices.length - 1] + 1;
|
|
357
429
|
|
|
430
|
+
const boundaryEcho = findBoundaryEcho(group, fileLines);
|
|
431
|
+
if (boundaryEcho) {
|
|
432
|
+
warnings.push(describeBoundaryEchoRepair(group, boundaryEcho));
|
|
433
|
+
out.push(...inserts.slice(boundaryEcho.leading, inserts.length - boundaryEcho.trailing), ...deletes);
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
|
|
358
437
|
const delta = balanceDelta(
|
|
359
438
|
computeDelimiterBalance(group.payload),
|
|
360
439
|
computeDelimiterBalance(fileLines.slice(group.startLine - 1, group.endLine)),
|
|
@@ -429,7 +508,7 @@ export function applyEdits(text: string, edits: readonly Edit[]): ApplyResult {
|
|
|
429
508
|
|
|
430
509
|
const targetEdits = appliedEdits.map((edit, index) => cloneAppliedEdit(edit, index));
|
|
431
510
|
validateLineBounds(targetEdits, fileLines);
|
|
432
|
-
const { edits: repaired, warnings } =
|
|
511
|
+
const { edits: repaired, warnings } = repairReplacementBoundaries(targetEdits, fileLines);
|
|
433
512
|
|
|
434
513
|
// Partition edits into bof, eof, and anchor-targeted buckets.
|
|
435
514
|
const bofLines: string[] = [];
|