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.
This commit is contained in:
can1357
2026-06-12 05:05:35 +02:00
parent 2630faca1f
commit af3464cdbe
2 changed files with 29 additions and 16 deletions
+20 -12
View File
@@ -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);
+9 -4
View File
@@ -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", () => {