refactor(hashline): removed inline modify edit kind

- Dropped the `modify` edit type and its `< ANCHORTEXT` / `+ ANCHORTEXT` syntax.
- Removed associated parser rules, regex patterns, apply logic, and tests.
- Deleted the "Append WITHIN a line" example from the prompt docs.
This commit is contained in:
can1357
2026-05-10 07:56:23 +02:00
parent 3ae1d1a8dc
commit 2ae3a4b111
7 changed files with 11 additions and 136 deletions
+8 -24
View File
@@ -1,6 +1,6 @@
import { HashlineMismatchError } from "./anchors";
import { RANGE_INTERIOR_HASH } from "./constants";
import { computeLineHash, HL_EDIT_SEP } from "./hash";
import { computeLineHash } from "./hash";
import { cloneCursor } from "./parser";
import type { Anchor, HashlineApplyOptions, HashlineCursor, HashlineEdit, HashMismatch } from "./types";
@@ -37,7 +37,6 @@ interface HashlineReplacementGroup {
function getHashlineEditAnchors(edit: HashlineEdit): Anchor[] {
if (edit.kind === "delete") return [edit.anchor];
if (edit.kind === "modify") return [edit.anchor];
if (edit.cursor.kind === "before_anchor") return [edit.cursor.anchor];
if (edit.cursor.kind === "after_anchor") return [edit.cursor.anchor];
return [];
@@ -96,7 +95,7 @@ function insertAtEnd(fileLines: string[], lineOrigins: HashlineLineOrigin[], lin
/** Bucket edits by the line they target so we can apply each line's group in one splice. */
function getAnchorTargetLine(edit: HashlineEdit): number | undefined {
if (edit.kind === "delete" || edit.kind === "modify") return edit.anchor.line;
if (edit.kind === "delete") return edit.anchor.line;
if (edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor") return edit.cursor.anchor.line;
return undefined;
}
@@ -607,11 +606,9 @@ function bucketAnchorEditsByLine(edits: IndexedEdit[]): Map<number, IndexedEdit[
const line =
entry.edit.kind === "delete"
? entry.edit.anchor.line
: entry.edit.kind === "modify"
? entry.edit.anchor.line
: entry.edit.cursor.kind === "before_anchor"
? entry.edit.cursor.anchor.line
: 0;
: entry.edit.cursor.kind === "before_anchor"
? entry.edit.cursor.anchor.line
: 0;
const bucket = byLine.get(line);
if (bucket) bucket.push(entry);
else byLine.set(line, [entry]);
@@ -683,33 +680,20 @@ export function applyHashlineEdits(
const currentLine = fileLines[idx] ?? "";
const beforeLines: string[] = [];
let deleteLine = false;
let prefix = "";
let suffix = "";
let modified = false;
for (const { edit } of bucket) {
if (edit.kind === "insert") {
beforeLines.push(edit.text);
} else if (edit.kind === "delete") {
deleteLine = true;
} else if (edit.kind === "modify") {
prefix = edit.prefix + prefix;
suffix = suffix + edit.suffix;
modified = true;
}
}
if (beforeLines.length === 0 && !deleteLine && !modified) continue;
if (deleteLine && modified) {
throw new Error(
`line ${line}: cannot combine inline modify ("< ${line}${HL_EDIT_SEP}…" or "+ ${line}${HL_EDIT_SEP}…") with a delete or replace targeting the same line.`,
);
}
if (beforeLines.length === 0 && !deleteLine) continue;
const effectiveLine = modified ? prefix + currentLine + suffix : currentLine;
const replacement = deleteLine ? beforeLines : [...beforeLines, effectiveLine];
const replacement = deleteLine ? beforeLines : [...beforeLines, currentLine];
const origins = replacement.map((): HashlineLineOrigin => (deleteLine ? "replacement" : "insert"));
if (!deleteLine) {
origins[origins.length - 1] = modified ? "replacement" : (lineOrigins[idx] ?? "original");
origins[origins.length - 1] = lineOrigins[idx] ?? "original";
}
fileLines.splice(idx, 1, ...replacement);
@@ -42,7 +42,6 @@ async function readHashlineFile(absolutePath: string, pathText: string): Promise
function hasAnchorScopedEdit(edits: HashlineEdit[]): boolean {
return edits.some(edit => {
if (edit.kind === "delete") return true;
if (edit.kind === "modify") return true;
return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor";
});
}
@@ -7,16 +7,12 @@ section: file_header line_op*
file_header: "@" path LF
line_op: inline_before_op payload*
| inline_after_op payload*
| insert_before_op payload+
line_op: insert_before_op payload+
| insert_after_op payload+
| replace_op payload*
| delete_op
| blank
inline_before_op: "<" LID $HSEP$ line_text? LF
inline_after_op: "+" LID $HSEP$ line_text? LF
insert_before_op: "<" insert_target LF
insert_after_op: "+" insert_target LF
replace_op: "=" range LF
+1 -40
View File
@@ -1,9 +1,8 @@
import { RANGE_INTERIOR_HASH } from "./constants";
import { describeAnchorExamples, HL_EDIT_SEP, HL_EDIT_SEP_RE_RAW, HL_HASH_CAPTURE_RE_RAW } from "./hash";
import { describeAnchorExamples, HL_EDIT_SEP, HL_HASH_CAPTURE_RE_RAW } from "./hash";
import type { Anchor, HashlineCursor, HashlineEdit } from "./types";
import { stripTrailingCarriageReturn } from "./utils";
const HL_EDIT_SEPARATOR_RE = HL_EDIT_SEP_RE_RAW;
const LID_CAPTURE_RE = new RegExp(`^${HL_HASH_CAPTURE_RE_RAW}$`);
function parseLid(raw: string, lineNum: number): Anchor {
@@ -70,8 +69,6 @@ const INSERT_BEFORE_OP_RE = /^<\s*(\S+)$/;
const INSERT_AFTER_OP_RE = /^\+\s*(\S+)$/;
const DELETE_OP_RE = /^-\s*(\S+)$/;
const REPLACE_OP_RE = /^=\s*(\S+)$/;
const INLINE_BEFORE_OP_RE = new RegExp(`^<\\s*${HL_HASH_CAPTURE_RE_RAW}${HL_EDIT_SEPARATOR_RE}(.*)$`);
const INLINE_AFTER_OP_RE = new RegExp(`^\\+\\s*${HL_HASH_CAPTURE_RE_RAW}${HL_EDIT_SEPARATOR_RE}(.*)$`);
export function cloneCursor(cursor: HashlineCursor): HashlineCursor {
if (cursor.kind === "before_anchor") return { kind: "before_anchor", anchor: { ...cursor.anchor } };
@@ -125,42 +122,6 @@ export function parseHashlineWithWarnings(diff: string): { edits: HashlineEdit[]
throw new Error(`line ${lineNum}: payload line has no preceding +, <, or = operation.`);
}
const inlineBeforeMatch = INLINE_BEFORE_OP_RE.exec(line);
if (inlineBeforeMatch) {
const anchor = parseLid(`${inlineBeforeMatch[1]}${inlineBeforeMatch[2]}`, lineNum);
edits.push({
kind: "modify",
anchor,
prefix: inlineBeforeMatch[3],
suffix: "",
lineNum,
index: editIndex++,
});
const cursor: HashlineCursor = { kind: "before_anchor", anchor };
const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, false);
for (const text of payload) pushInsert(cursor, text, lineNum);
i = nextIndex;
continue;
}
const inlineAfterMatch = INLINE_AFTER_OP_RE.exec(line);
if (inlineAfterMatch) {
const anchor = parseLid(`${inlineAfterMatch[1]}${inlineAfterMatch[2]}`, lineNum);
edits.push({
kind: "modify",
anchor,
prefix: "",
suffix: inlineAfterMatch[3],
lineNum,
index: editIndex++,
});
const cursor: HashlineCursor = { kind: "after_anchor", anchor };
const { payload, nextIndex } = collectPayload(lines, i + 1, lineNum, false);
for (const text of payload) pushInsert(cursor, text, lineNum);
i = nextIndex;
continue;
}
const insertBeforeMatch = INSERT_BEFORE_OP_RE.exec(line);
if (insertBeforeMatch) {
const cursor = parseInsertTarget(insertBeforeMatch[1], lineNum, "before");
+1 -2
View File
@@ -24,8 +24,7 @@ export type HashlineCursor =
export type HashlineEdit =
| { kind: "insert"; cursor: HashlineCursor; text: string; lineNum: number; index: number }
| { kind: "delete"; anchor: Anchor; lineNum: number; index: number; oldAssertion?: string }
| { kind: "modify"; anchor: Anchor; prefix: string; suffix: string; lineNum: number; index: number };
| { kind: "delete"; anchor: Anchor; lineNum: number; index: number; oldAssertion?: string };
export const hashlineEditParamsSchema = Type.Object({ input: Type.String() });
export type HashlineParams = Static<typeof hashlineEditParamsSchema>;
@@ -96,10 +96,6 @@ When your edit involves brace boundaries (`{` / `}`), prefer these shapes:
+ {{hrefr 4}}
{{hsep}} if (clean.length === 0) return DEF;
# Append WITHIN a line
@a.ts
+ {{hrefr 4}}{{hsep}} // first run
# Append to end of file
@a.ts
+ EOF
@@ -328,66 +328,6 @@ describe("hashline parser — block op syntax", () => {
});
});
describe("hashline parser — inline modify syntax", () => {
const content = "alpha\nbeta\ngamma";
it("prepends text to the anchored line via `< ANCHOR<sep>TEXT`", () => {
const diff = `< ${tag(2, "beta")}${pl("// ")}`;
expect(applyDiff(content, diff)).toBe("alpha\n// beta\ngamma");
});
it("appends text to the anchored line via `+ ANCHOR<sep>TEXT`", () => {
const diff = `+ ${tag(2, "beta")}${pl(" // tag")}`;
expect(applyDiff(content, diff)).toBe("alpha\nbeta // tag\ngamma");
});
it("combines a prepend and an append on the same line", () => {
const diff = [`< ${tag(2, "beta")}${pl("[")}`, `+ ${tag(2, "beta")}${pl("]")}`].join("\n");
expect(applyDiff(content, diff)).toBe("alpha\n[beta]\ngamma");
});
it("stacks multiple prepends with later edits wrapping earlier ones", () => {
const diff = [`< ${tag(2, "beta")}${pl("A")}`, `< ${tag(2, "beta")}${pl("B")}`].join("\n");
expect(applyDiff(content, diff)).toBe("alpha\nBAbeta\ngamma");
});
it("stacks multiple appends with later edits wrapping earlier ones", () => {
const diff = [`+ ${tag(2, "beta")}${pl("A")}`, `+ ${tag(2, "beta")}${pl("B")}`].join("\n");
expect(applyDiff(content, diff)).toBe("alpha\nbetaAB\ngamma");
});
it("appends inline AND inserts payload lines after the modified line", () => {
const diff = [`+ ${tag(2, "beta")}${pl(" // tag")}`, pl("inserted-after-1"), pl("inserted-after-2")].join("\n");
expect(applyDiff(content, diff)).toBe("alpha\nbeta // tag\ninserted-after-1\ninserted-after-2\ngamma");
});
it("prepends inline AND inserts payload lines before the modified line", () => {
const diff = [`< ${tag(2, "beta")}${pl("// ")}`, pl("inserted-before-1"), pl("inserted-before-2")].join("\n");
expect(applyDiff(content, diff)).toBe("alpha\ninserted-before-1\ninserted-before-2\n// beta\ngamma");
});
it("allows a block insert-before to coexist with an inline modify on the same line", () => {
const diff = [`< ${tag(2, "beta")}`, pl("// note"), `+ ${tag(2, "beta")}${pl("!")}`].join("\n");
expect(applyDiff(content, diff)).toBe("alpha\n// note\nbeta!\ngamma");
});
it("rejects combining inline modify with a delete on the same line", () => {
const diff = [`- ${sameLineRange(tag(2, "beta"))}`, `+ ${tag(2, "beta")}${pl("!")}`].join("\n");
expect(() => applyDiff(content, diff)).toThrow(/cannot combine inline modify/);
});
it("validates the anchor hash for inline modify just like other ops", () => {
const diff = `+ ${mistag(2, "beta")}${pl("!")}`;
expect(() => applyDiff(content, diff)).toThrow(HashlineMismatchError);
});
it("treats an empty inline payload as a no-op when nothing else follows", () => {
const diff = `+ ${tag(2, "beta")}${pl("")}`;
const result = applyHashlineEdits(content, parseHashline(diff));
expect(result.lines).toBe(content);
});
});
describe("hashline — stale anchors", () => {
it("throws HashlineMismatchError when a Lid hash no longer matches", () => {
const diff = [`= ${sameLineRange(mistag(2, "bbb"))}`, pl("BBB")].join("\n");