@oh-my-pi/hashline 15.11.4 → 15.11.6

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 +20 -12
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/hashline",
4
- "version": "15.11.4",
4
+ "version": "15.11.6",
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
@@ -35,26 +35,31 @@ function getEditAnchors(edit: AppliedEdit): Anchor[] {
35
35
  return getCursorAnchors(edit.cursor);
36
36
  }
37
37
 
38
+ function trailingPhantomLine(fileLines: readonly string[]): number {
39
+ // `split("\n")` on a newline-terminated file yields a trailing "" sentinel.
40
+ // It is addressable for inserts (append-past-end), but it is not real
41
+ // content. Deleting it only strips the file's final newline, so ignore delete
42
+ // edits that land there; inclusive ranges ending at EOF then do the intended
43
+ // thing and delete through the last concrete line.
44
+ return fileLines.length > 1 && fileLines[fileLines.length - 1] === "" ? fileLines.length : 0;
45
+ }
46
+
47
+ function dropTrailingPhantomDeletes(edits: AppliedEdit[], fileLines: readonly string[]): AppliedEdit[] {
48
+ const phantomLine = trailingPhantomLine(fileLines);
49
+ if (phantomLine === 0) return edits;
50
+ return edits.filter(edit => edit.kind !== "delete" || edit.anchor.line !== phantomLine);
51
+ }
52
+
38
53
  /**
39
54
  * Verify every anchored edit points at an existing line. File-version binding is
40
55
  * checked once per section via the header hash before this function runs.
41
56
  */
42
- function validateLineBounds(edits: AppliedEdit[], fileLines: string[]): void {
43
- // `split("\n")` on a newline-terminated file yields a trailing "" sentinel.
44
- // It is addressable for inserts (append-past-end), but deleting it would
45
- // silently strip the file's final newline — an off-by-one that must error.
46
- const phantomLine = fileLines.length > 1 && fileLines[fileLines.length - 1] === "" ? fileLines.length : 0;
57
+ function validateLineBounds(edits: readonly AppliedEdit[], fileLines: readonly string[]): void {
47
58
  for (const edit of edits) {
48
59
  for (const anchor of getEditAnchors(edit)) {
49
60
  if (anchor.line < 1 || anchor.line > fileLines.length) {
50
61
  throw new Error(`Line ${anchor.line} does not exist (file has ${fileLines.length} lines)`);
51
62
  }
52
- if (edit.kind === "delete" && anchor.line === phantomLine) {
53
- throw new Error(
54
- `Line ${anchor.line} is the trailing blank sentinel of a newline-terminated file and has no content to delete. ` +
55
- `End the range at line ${anchor.line - 1}, or use \`insert tail:\` to append.`,
56
- );
57
- }
58
63
  }
59
64
  }
60
65
  }
@@ -742,7 +747,10 @@ export function applyEdits(text: string, edits: readonly Edit[]): ApplyResult {
742
747
  if (firstChangedLine === undefined || line < firstChangedLine) firstChangedLine = line;
743
748
  };
744
749
 
745
- const targetEdits = appliedEdits.map((edit, index) => cloneAppliedEdit(edit, index));
750
+ const targetEdits = dropTrailingPhantomDeletes(
751
+ appliedEdits.map((edit, index) => cloneAppliedEdit(edit, index)),
752
+ fileLines,
753
+ );
746
754
  validateLineBounds(targetEdits, fileLines);
747
755
  const { edits: repaired, warnings: boundaryWarnings } = repairReplacementBoundaries(targetEdits, fileLines);
748
756
  const { edits: landed, warnings: landingWarnings } = repairAfterInsertLandings(repaired, fileLines);