fix(hashline): recovered dangling range separators in hunk headers

- Recover dangling range separators in hunk headers as single-line ranges instead of rejecting them.
- Ensure strict rejection is maintained when a dangling separator is followed by invalid tokens.
This commit is contained in:
can1357
2026-08-14 05:15:14 +02:00
parent 5535b5097e
commit f5911781c2
3 changed files with 53 additions and 0 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Recovered dangling range separators in hunk headers (`PUT 244.=:`, `CUT 5.=`) as single-line ranges (`N.=N`) instead of rejecting the header as an orphan payload line.
## [17.3.0] - 2026-08-13
### Fixed
+39
View File
@@ -182,6 +182,37 @@ function scanRangeSeparator(line: string, index: number, end: number): number |
return cursor;
}
/**
* Recover a dangling range separator: the run after `N` contains at least one
* non-whitespace separator char but no end number (`244.=:`, `5-`, `12.. @reg`).
* Models write this intending an open range; it collapses to `N.=N`. Returns
* the index past the run only when what follows is `:`, `@`, or end-of-header —
* anything else keeps the header on the strict rejection path.
*/
function scanDanglingSeparator(line: string, index: number, end: number): number | null {
let cursor = index;
let sawSeparatorChar = false;
while (cursor < end) {
const code = line.charCodeAt(cursor);
if (code === CHAR_HYPHEN || code === CHAR_DOT || code === CHAR_EQUALS || code === CHAR_ELLIPSIS) {
sawSeparatorChar = true;
cursor++;
continue;
}
if (isWhitespaceCode(code)) {
cursor++;
continue;
}
break;
}
if (!sawSeparatorChar) return null;
if (cursor < end) {
const code = line.charCodeAt(cursor);
if (code !== CHAR_COLON && code !== CHAR_AT) return null;
}
return cursor;
}
function scanHeaderRange(line: string, index = 0, end = trimEndIndex(line), allowSingle = false): RangeScan | null {
const numberStart = skipWhitespace(line, index, end);
const start = scanLineNumber(line, numberStart, end);
@@ -189,6 +220,14 @@ function scanHeaderRange(line: string, index = 0, end = trimEndIndex(line), allo
const afterFirst = scanRangeSeparator(line, start.nextIndex, end);
if (afterFirst === null) {
if (!allowSingle) return null;
const dangling = scanDanglingSeparator(line, start.nextIndex, end);
if (dangling !== null) {
return {
range: { start: { line: start.line }, end: { line: start.line } },
nextIndex: dangling,
hadSeparator: true,
};
}
return {
range: { start: { line: start.line }, end: { line: start.line } },
nextIndex: skipWhitespace(line, start.nextIndex, end),
+10
View File
@@ -82,6 +82,16 @@ describe("hashline core — verb header forms", () => {
expect(applyPatch(FILE, "CUT 2")).toBe("a\nc\nd\ne");
});
it("recovers a dangling range separator as a single-line range", () => {
expect(applyPatch(FILE, "PUT 2.=:\n+X")).toBe("a\nX\nc\nd\ne");
expect(applyPatch(FILE, "PUT 2-:\n+X")).toBe("a\nX\nc\nd\ne");
expect(applyPatch(FILE, "CUT 2.=")).toBe("a\nc\nd\ne");
});
it("still rejects a dangling separator followed by junk", () => {
expect(() => parsePatch("PUT 2.= junk:\n+X")).toThrow();
});
it("recovers top-level numbered snapshot rows as single-line replacements", () => {
for (const separator of [":", "|"]) {
const result = parsePatch(`2${separator}B\n4${separator}D`);