@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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. 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.7.6",
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 repairBoundaryBalance},
7
- * which fixes the common model mistake of a payload that duplicates or drops
8
- * the closing delimiter bordering the range (balance-validated; see below).
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
- // Boundary-balance repair
101
+ // Replacement-boundary repair
102
102
  //
103
- // Models routinely miscount a replacement range's edges. The payload either
104
- // re-states a closing delimiter that still lives just outside the range
105
- // (producing a DUPLICATE `}` / `);` / `]`) or the range deletes a closer the
106
- // payload never restates (DROPPING it). Both are the same defect — a
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 (a) the group's payload balance differs from the
111
- // deleted region's balance and (b) one boundary operation drives that
112
- // difference to exactly zero while leaving the surrounding text byte-identical.
113
- // The operation only ever drops an exact multi-line boundary echo or a single
114
- // pure structural-closer line, or spares a deleted pure structural-closer line,
115
- // so content lines are never moved or lost. Balance-preserving edits are left
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
- * Single-line drops are limited to pure structural closers.
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 each replacement group so its payload preserves the deleted
334
- * region's delimiter balance. See the section header for the contract. Returns
335
- * the (possibly trimmed) edit list plus one warning per repaired group.
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 repairBoundaryBalance(
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 } = repairBoundaryBalance(targetEdits, fileLines);
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[] = [];