From af3464cdbef6e354766d01fed9287b561e771deb Mon Sep 17 00:00:00 2001 From: can1357 Date: Fri, 12 Jun 2026 05:05:35 +0200 Subject: [PATCH] fix(hashline): ignored deletes of the trailing newline sentinel in applyEdits - Added a trailing-sentinel guard to identify newline-generated phantom lines and drop delete edits targeting them. - Updated edit validation to allow those no-op deletes while preserving real line bounds checks. - Adjusted format-v2 tests to expect no-op handling of sentinel deletes and clipping for ranges ending at the sentinel. --- packages/hashline/src/apply.ts | 32 +++++++++++++++--------- packages/hashline/test/format-v2.test.ts | 13 +++++++--- 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/packages/hashline/src/apply.ts b/packages/hashline/src/apply.ts index ca7bba16d..e83c0ddf6 100644 --- a/packages/hashline/src/apply.ts +++ b/packages/hashline/src/apply.ts @@ -35,26 +35,31 @@ function getEditAnchors(edit: AppliedEdit): Anchor[] { return getCursorAnchors(edit.cursor); } +function trailingPhantomLine(fileLines: readonly string[]): number { + // `split("\n")` on a newline-terminated file yields a trailing "" sentinel. + // It is addressable for inserts (append-past-end), but it is not real + // content. Deleting it only strips the file's final newline, so ignore delete + // edits that land there; inclusive ranges ending at EOF then do the intended + // thing and delete through the last concrete line. + return fileLines.length > 1 && fileLines[fileLines.length - 1] === "" ? fileLines.length : 0; +} + +function dropTrailingPhantomDeletes(edits: AppliedEdit[], fileLines: readonly string[]): AppliedEdit[] { + const phantomLine = trailingPhantomLine(fileLines); + if (phantomLine === 0) return edits; + return edits.filter(edit => edit.kind !== "delete" || edit.anchor.line !== phantomLine); +} + /** * Verify every anchored edit points at an existing line. File-version binding is * checked once per section via the header hash before this function runs. */ -function validateLineBounds(edits: AppliedEdit[], fileLines: string[]): void { - // `split("\n")` on a newline-terminated file yields a trailing "" sentinel. - // It is addressable for inserts (append-past-end), but deleting it would - // silently strip the file's final newline — an off-by-one that must error. - const phantomLine = fileLines.length > 1 && fileLines[fileLines.length - 1] === "" ? fileLines.length : 0; +function validateLineBounds(edits: readonly AppliedEdit[], fileLines: readonly string[]): void { for (const edit of edits) { for (const anchor of getEditAnchors(edit)) { if (anchor.line < 1 || anchor.line > fileLines.length) { throw new Error(`Line ${anchor.line} does not exist (file has ${fileLines.length} lines)`); } - if (edit.kind === "delete" && anchor.line === phantomLine) { - throw new Error( - `Line ${anchor.line} is the trailing blank sentinel of a newline-terminated file and has no content to delete. ` + - `End the range at line ${anchor.line - 1}, or use \`insert tail:\` to append.`, - ); - } } } } @@ -742,7 +747,10 @@ export function applyEdits(text: string, edits: readonly Edit[]): ApplyResult { if (firstChangedLine === undefined || line < firstChangedLine) firstChangedLine = line; }; - const targetEdits = appliedEdits.map((edit, index) => cloneAppliedEdit(edit, index)); + const targetEdits = dropTrailingPhantomDeletes( + appliedEdits.map((edit, index) => cloneAppliedEdit(edit, index)), + fileLines, + ); validateLineBounds(targetEdits, fileLines); const { edits: repaired, warnings: boundaryWarnings } = repairReplacementBoundaries(targetEdits, fileLines); const { edits: landed, warnings: landingWarnings } = repairAfterInsertLandings(repaired, fileLines); diff --git a/packages/hashline/test/format-v2.test.ts b/packages/hashline/test/format-v2.test.ts index 262054e0c..b1d94f4e5 100644 --- a/packages/hashline/test/format-v2.test.ts +++ b/packages/hashline/test/format-v2.test.ts @@ -66,15 +66,20 @@ describe("hashline format v4", () => { expect(() => applyEdits("a\nb", edits)).toThrow(/Line 4 does not exist/); }); - it("rejects deleting the trailing blank sentinel of a newline-terminated file", () => { + it("ignores deleting the trailing blank sentinel of a newline-terminated file", () => { // "a\nb\n" splits into ["a", "b", ""]; line 3 is the phantom sentinel. const edits = parsePatch("delete 3").edits; - expect(() => applyEdits("a\nb\n", edits)).toThrow(/trailing blank sentinel/); + expect(applyEdits("a\nb\n", edits).text).toBe("a\nb\n"); }); - it("rejects a replace range that spans the trailing blank sentinel", () => { + it("treats a delete range ending at the trailing sentinel as ending at the last real line", () => { + const edits = parsePatch("delete 2..3").edits; + expect(applyEdits("a\nb\n", edits).text).toBe("a\n"); + }); + + it("treats a replace range ending at the trailing sentinel as ending at the last real line", () => { const edits = parsePatch("replace 2..3:\n+B").edits; - expect(() => applyEdits("a\nb\n", edits)).toThrow(/trailing blank sentinel/); + expect(applyEdits("a\nb\n", edits).text).toBe("a\nB\n"); }); it("still allows inserts anchored on the trailing blank sentinel", () => {