feat(coding-agent): introduced hashline format v2 with colon separators and tag-based API
- Changed hashline format separator from pipe (|) to colon (:) for improved readability across all tools and output formats. - Refactored hashline edit API with operation-based structure: renamed delete->rm, rename->mv, set->target/new_content, and added explicit op field for operation types. - Updated hashline hash encoding from 4-character base36 to 2-character hexadecimal for more compact representation. - Replaced anchor terminology with tags throughout hashline documentation and API for clearer semantics.
This commit is contained in:
@@ -123,7 +123,7 @@ function normalizeMessagesForProvider(
|
||||
return changed ? normalized : messages;
|
||||
}
|
||||
|
||||
export const INTENT_FIELD = "_intent";
|
||||
export const INTENT_FIELD = "agent__intent";
|
||||
|
||||
function injectIntentIntoSchema(schema: unknown): unknown {
|
||||
if (!schema || typeof schema !== "object" || Array.isArray(schema)) return schema;
|
||||
|
||||
@@ -116,7 +116,10 @@ describe("Tool argument coercion", () => {
|
||||
id: "call-7",
|
||||
name: "t7",
|
||||
arguments:
|
||||
'{"path":"somefile.js","edits":"[{\\"target\\":\\"13#cf\\",\\"new_content\\":\\"...\\"}]"}' as unknown as Record<string, unknown>,
|
||||
'{"path":"somefile.js","edits":"[{\\"target\\":\\"13#cf\\",\\"new_content\\":\\"...\\"}]"}' as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
};
|
||||
|
||||
const result = validateToolArguments(tool, toolCall);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Display streamed tool intent in working message during agent execution
|
||||
@@ -13,6 +12,12 @@
|
||||
|
||||
### Changed
|
||||
|
||||
- Hashline format separator changed from pipe (`|`) to colon (`:`) for improved readability (e.g., `LINE#ID:content` instead of `LINE#ID|content`)
|
||||
- Hashline hash representation changed from 4-character base36 to 2-character hexadecimal for more compact line references
|
||||
- Hashline edit API: renamed `delete` parameter to `rm` for consistency with standard file operations
|
||||
- Hashline edit API: renamed `rename` parameter to `mv` for consistency with standard file operations
|
||||
- Hashline edit API: content-replace operations now require explicit `op: "replaceText"` field to distinguish from other edit types
|
||||
- Hashline documentation terminology updated: references to 'anchors' replaced with 'tags' for clearer semantics
|
||||
- Intent tracing now uses `_intent` field name in tool schemas
|
||||
- Hashline edit API: renamed `set` operation to `target`/`new_content` for clearer semantics
|
||||
- Hashline edit API: renamed `set_range` operation to `first`/`last`/`new_content`
|
||||
@@ -4834,4 +4839,4 @@ Initial public release.
|
||||
- Git branch display in footer
|
||||
- Message queueing during streaming responses
|
||||
- OAuth integration for Gmail and Google Calendar access
|
||||
- HTML export with syntax highlighting and collapsible sections
|
||||
- HTML export with syntax highlighting and collapsible sections
|
||||
@@ -247,11 +247,11 @@ handlebars.registerHelper("hlineref", (lineNum: unknown, content: unknown): stri
|
||||
|
||||
/**
|
||||
* {{hlinefull lineNum "content"}} — format a full read-style line with prefix.
|
||||
* Returns `"lineNum#hash|content"`.
|
||||
* Returns `"lineNum#hash:content"`.
|
||||
*/
|
||||
handlebars.registerHelper("hlinefull", (lineNum: unknown, content: unknown): string => {
|
||||
const { ref, text } = formatHashlineRef(lineNum, content);
|
||||
return `${ref}|${text}`;
|
||||
return `${ref}:${text}`;
|
||||
});
|
||||
|
||||
export function renderPromptTemplate(template: string, context: TemplateContext = {}): string {
|
||||
|
||||
@@ -287,7 +287,7 @@ export const SETTINGS_SCHEMA = {
|
||||
ui: {
|
||||
tab: "config",
|
||||
label: "Read hash lines",
|
||||
description: "Include line hashes in read output for hashline edit mode (LINE#ID|content)",
|
||||
description: "Include line hashes in read output for hashline edit mode (LINE#ID:content)",
|
||||
},
|
||||
},
|
||||
showHardwareCursor: {
|
||||
|
||||
@@ -8,8 +8,8 @@ import * as Diff from "diff";
|
||||
import { resolveToCwd } from "../tools/path-utils";
|
||||
import { previewPatch } from "./applicator";
|
||||
import { DEFAULT_FUZZY_THRESHOLD, findMatch } from "./fuzzy";
|
||||
import type { HashlineEdit } from "./hashline";
|
||||
import { applyHashlineEdits } from "./hashline";
|
||||
import type { HashlineEdit } from "./index";
|
||||
import { adjustIndentation, normalizeToLF, stripBom } from "./normalize";
|
||||
import type { DiffError, DiffResult, PatchInput } from "./types";
|
||||
import { EditMatchError } from "./types";
|
||||
|
||||
@@ -2,103 +2,27 @@
|
||||
* Hashline edit mode — a line-addressable edit format using content hashes.
|
||||
*
|
||||
* Each line in a file is identified by its 1-indexed line number and a short
|
||||
* base36 hash derived from the normalized line content (xxHash32, truncated to 4
|
||||
* base36 chars).
|
||||
* hexadecimal hash derived from the normalized line content (xxHash32, truncated to 2
|
||||
* hex chars).
|
||||
* The combined `LINE#ID` reference acts as both an address and a staleness check:
|
||||
* if the file has changed since the caller last read it, hash mismatches are caught
|
||||
* before any mutation occurs.
|
||||
*
|
||||
* Displayed format: `LINENUM#HASH|CONTENT`
|
||||
* Reference format: `"LINENUM#HASH"` (e.g. `"5#a3f2"`)
|
||||
* Displayed format: `LINENUM#HASH:CONTENT`
|
||||
* Reference format: `"LINENUM#HASH"` (e.g. `"5#aa"`)
|
||||
*/
|
||||
|
||||
import type { HashlineEdit } from "./index";
|
||||
import type { HashMismatch } from "./types";
|
||||
|
||||
type ParsedRefs =
|
||||
| { kind: "single"; ref: { line: number; hash: string } }
|
||||
| { kind: "range"; start: { line: number; hash: string }; end: { line: number; hash: string } }
|
||||
| { kind: "insertAfter"; after: { line: number; hash: string } }
|
||||
| { kind: "insertBefore"; before: { line: number; hash: string } }
|
||||
| { kind: "insertBetween"; after: { line: number; hash: string }; before: { line: number; hash: string } }
|
||||
| { kind: "insertAtEof" };
|
||||
|
||||
function parseHashlineEdit(edit: HashlineEdit): { spec: ParsedRefs; dstLines: string[] } {
|
||||
if ("set" in edit) {
|
||||
return {
|
||||
spec: { kind: "single", ref: parseLineRef(edit.set.ref) },
|
||||
dstLines: edit.set.body,
|
||||
};
|
||||
}
|
||||
if ("set_range" in edit) {
|
||||
const r = edit.set_range as Record<string, unknown>;
|
||||
const start = parseLineRef(r.beg as string);
|
||||
if (!r.end) {
|
||||
return {
|
||||
spec: { kind: "single", ref: start },
|
||||
dstLines: Array.isArray(r.body) ? (r.body as string[]) : splitDstLines(String(r.body ?? "")),
|
||||
};
|
||||
}
|
||||
const end = parseLineRef(r.end as string);
|
||||
return {
|
||||
spec: start.line === end.line ? { kind: "single", ref: start } : { kind: "range", start, end },
|
||||
dstLines: Array.isArray(r.body) ? (r.body as string[]) : splitDstLines(String(r.body ?? "")),
|
||||
};
|
||||
}
|
||||
if ("insert" in edit) {
|
||||
const r = edit.insert as Record<string, unknown>;
|
||||
const dstLines = Array.isArray(r.body) ? (r.body as string[]) : splitDstLines(String(r.text ?? r.content ?? ""));
|
||||
const hasAfterField = "after" in r;
|
||||
const hasBeforeField = "before" in r;
|
||||
const afterRef = r.after;
|
||||
const beforeRef = r.before;
|
||||
if (hasAfterField && (typeof afterRef !== "string" || afterRef.trim().length === 0)) {
|
||||
throw new Error('insert.after must be a non-empty "LINE#ID" string when provided');
|
||||
}
|
||||
if (hasBeforeField && (typeof beforeRef !== "string" || beforeRef.trim().length === 0)) {
|
||||
throw new Error('insert.before must be a non-empty "LINE#ID" string when provided');
|
||||
}
|
||||
const hasAfter = hasAfterField && typeof afterRef === "string";
|
||||
const hasBefore = hasBeforeField && typeof beforeRef === "string";
|
||||
if (hasAfter && hasBefore) {
|
||||
return {
|
||||
spec: {
|
||||
kind: "insertBetween",
|
||||
after: parseLineRef(afterRef),
|
||||
before: parseLineRef(beforeRef),
|
||||
},
|
||||
dstLines,
|
||||
};
|
||||
}
|
||||
if (hasAfter) {
|
||||
return {
|
||||
spec: { kind: "insertAfter", after: parseLineRef(afterRef) },
|
||||
dstLines,
|
||||
};
|
||||
}
|
||||
if (hasBefore) {
|
||||
return {
|
||||
spec: { kind: "insertBefore", before: parseLineRef(beforeRef) },
|
||||
dstLines,
|
||||
};
|
||||
}
|
||||
return { spec: { kind: "insertAtEof" }, dstLines };
|
||||
}
|
||||
if ("replace" in edit) {
|
||||
throw new Error("replace edits are applied separately; do not pass them to applyHashlineEdits");
|
||||
}
|
||||
throw new Error("Unknown hashline edit operation");
|
||||
}
|
||||
/** Split dst into lines; empty string means delete (no lines). */
|
||||
function splitDstLines(dst: string): string[] {
|
||||
return dst === "" ? [] : dst.split("\n");
|
||||
}
|
||||
|
||||
/** Pattern matching hashline display format: `LINE#ID|CONTENT` */
|
||||
const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*\d+#[0-9a-zA-Z]{1,16}\|/;
|
||||
|
||||
/** Pattern matching a unified-diff `+` prefix (but not `++`) */
|
||||
const DIFF_PLUS_RE = /^\+(?!\+)/;
|
||||
export type LineTag = { line: number; hash: string };
|
||||
export type HashlineEdit =
|
||||
| { op: "set"; tag: LineTag; content: string[] }
|
||||
| { op: "replace"; first: LineTag; last: LineTag; content: string[] }
|
||||
| { op: "append"; after?: LineTag; content: string[] }
|
||||
| { op: "prepend"; before?: LineTag; content: string[] }
|
||||
| { op: "insert"; after: LineTag; before: LineTag; content: string[] };
|
||||
export type ReplaceTextEdit = { op: "replaceText"; old_text: string; new_text: string; all?: boolean };
|
||||
export type EditSpec = HashlineEdit | ReplaceTextEdit;
|
||||
|
||||
/**
|
||||
* Compare two strings ignoring all whitespace differences.
|
||||
@@ -144,16 +68,6 @@ function restoreLeadingIndent(templateLine: string, line: string): string {
|
||||
return templateIndent + line;
|
||||
}
|
||||
|
||||
const CONFUSABLE_HYPHENS_RE = /[\u2010\u2011\u2012\u2013\u2014\u2212\uFE63\uFF0D]/g;
|
||||
|
||||
function normalizeConfusableHyphens(s: string): string {
|
||||
return s.replace(CONFUSABLE_HYPHENS_RE, "-");
|
||||
}
|
||||
|
||||
function normalizeConfusableHyphensInLines(lines: string[]): string[] {
|
||||
return lines.map(l => normalizeConfusableHyphens(l));
|
||||
}
|
||||
|
||||
function restoreIndentForPairedReplacement(oldLines: string[], newLines: string[]): string[] {
|
||||
if (oldLines.length !== newLines.length) return newLines;
|
||||
let changed = false;
|
||||
@@ -261,50 +175,19 @@ function stripRangeBoundaryEcho(fileLines: string[], startLine: number, endLine:
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip hashline display prefixes and diff `+` markers from replacement lines.
|
||||
*
|
||||
* Models frequently copy the `LINE#ID ` prefix from read output into their
|
||||
* replacement content, or include unified-diff `+` prefixes. Both corrupt the
|
||||
* output file. This strips them heuristically before application.
|
||||
*/
|
||||
function stripNewLinePrefixes(lines: string[]): string[] {
|
||||
// Detect whether the *majority* of non-empty lines carry a prefix —
|
||||
// if only one line out of many has a match it's likely real content.
|
||||
let hashPrefixCount = 0;
|
||||
let diffPlusCount = 0;
|
||||
let nonEmpty = 0;
|
||||
for (const l of lines) {
|
||||
if (l.length === 0) continue;
|
||||
nonEmpty++;
|
||||
if (HASHLINE_PREFIX_RE.test(l)) hashPrefixCount++;
|
||||
if (DIFF_PLUS_RE.test(l)) diffPlusCount++;
|
||||
}
|
||||
if (nonEmpty === 0) return lines;
|
||||
const NIBBLE_STR = "ZPMQVRWSNKTXJBYH";
|
||||
|
||||
const stripHash = hashPrefixCount > 0 && hashPrefixCount >= nonEmpty * 0.5;
|
||||
const stripPlus = !stripHash && diffPlusCount > 0 && diffPlusCount >= nonEmpty * 0.5;
|
||||
|
||||
if (!stripHash && !stripPlus) return lines;
|
||||
|
||||
return lines.map(l => {
|
||||
if (stripHash) return l.replace(HASHLINE_PREFIX_RE, "");
|
||||
if (stripPlus) return l.replace(DIFF_PLUS_RE, "");
|
||||
return l;
|
||||
});
|
||||
}
|
||||
|
||||
const HASH_LEN = 2;
|
||||
const RADIX = 16;
|
||||
const HASH_MOD = RADIX ** HASH_LEN;
|
||||
|
||||
const DICT = Array.from({ length: HASH_MOD }, (_, i) => i.toString(RADIX).padStart(HASH_LEN, "0"));
|
||||
const DICT = Array.from({ length: 256 }, (_, i) => {
|
||||
const h = i >>> 4;
|
||||
const l = i & 0x0f;
|
||||
return `${NIBBLE_STR[h]}${NIBBLE_STR[l]}`;
|
||||
});
|
||||
|
||||
/**
|
||||
* Compute a short base36 hash of a single line.
|
||||
* Compute a short hexadecimal hash of a single line.
|
||||
*
|
||||
* Uses xxHash64 on a whitespace-normalized line, truncated to {@link HASH_LEN}
|
||||
* base36 characters. The `idx` parameter is accepted for compatibility with older
|
||||
* Uses xxHash32 on a whitespace-normalized line, truncated to {@link HASH_LEN}
|
||||
* hex characters. The `idx` parameter is accepted for compatibility with older
|
||||
* call sites, but is not currently mixed into the hash.
|
||||
* The line input should not include a trailing newline.
|
||||
*/
|
||||
@@ -314,13 +197,20 @@ export function computeLineHash(idx: number, line: string): string {
|
||||
}
|
||||
line = line.replace(/\s+/g, "");
|
||||
void idx; // Might use line, but for now, let's not.
|
||||
return DICT[Bun.hash.xxHash32(line) % HASH_MOD];
|
||||
return DICT[Bun.hash.xxHash32(line) & 0xff];
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a tag given the line number and content.
|
||||
*/
|
||||
export function formatLineTag(line: number, content: string): string {
|
||||
return `${line}#${computeLineHash(line, content)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file content with hashline prefixes for display.
|
||||
*
|
||||
* Each line becomes `LINENUM#HASH|CONTENT` where LINENUM is 1-indexed.
|
||||
* Each line becomes `LINENUM#HASH:CONTENT` where LINENUM is 1-indexed.
|
||||
*
|
||||
* @param content - Raw file content string
|
||||
* @param startLine - First line number (1-indexed, defaults to 1)
|
||||
@@ -329,7 +219,7 @@ export function computeLineHash(idx: number, line: string): string {
|
||||
* @example
|
||||
* ```
|
||||
* formatHashLines("function hi() {\n return;\n}")
|
||||
* // "1#HH|function hi() {\n2#HH| return;\n3#HH|}"
|
||||
* // "1#HH:function hi() {\n2#HH: return;\n3#HH:}"
|
||||
* ```
|
||||
*/
|
||||
export function formatHashLines(content: string, startLine = 1): string {
|
||||
@@ -337,8 +227,7 @@ export function formatHashLines(content: string, startLine = 1): string {
|
||||
return lines
|
||||
.map((line, i) => {
|
||||
const num = startLine + i;
|
||||
const hash = computeLineHash(num, line);
|
||||
return `${num}#${hash}|${line}`;
|
||||
return `${formatLineTag(num, line)}:${line}`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
@@ -410,7 +299,7 @@ export async function* streamHashLinesFromUtf8(
|
||||
};
|
||||
|
||||
const pushLine = (line: string): string[] => {
|
||||
const formatted = `${lineNum}#${computeLineHash(lineNum, line)}|${line}`;
|
||||
const formatted = `${lineNum}#${computeLineHash(lineNum, line)}:${line}`;
|
||||
lineNum++;
|
||||
|
||||
const chunksToYield: string[] = [];
|
||||
@@ -504,7 +393,7 @@ export async function* streamHashLinesFromLines(
|
||||
|
||||
const pushLine = (line: string): string[] => {
|
||||
sawAnyLine = true;
|
||||
const formatted = `${lineNum}#${computeLineHash(lineNum, line)}|${line}`;
|
||||
const formatted = `${lineNum}#${computeLineHash(lineNum, line)}:${line}`;
|
||||
lineNum++;
|
||||
|
||||
const chunksToYield: string[] = [];
|
||||
@@ -560,18 +449,14 @@ export async function* streamHashLinesFromLines(
|
||||
*
|
||||
* @throws Error if the format is invalid (not `NUMBER#HEXHASH`)
|
||||
*/
|
||||
export function parseLineRef(ref: string): { line: number; hash: string } {
|
||||
// Strip display-format suffix: "5#ab|some content" → "5#ab", or legacy "5#ab some content" → "5#ab"
|
||||
// Models often copy the full display format from read output.
|
||||
const cleaned = ref
|
||||
.replace(/\|.*$/, "")
|
||||
.replace(/ {2}.*$/, "")
|
||||
.replace(/^>+\s*/, "")
|
||||
.trim();
|
||||
const normalized = cleaned.replace(/\s*#\s*/, "#");
|
||||
const strictMatch = normalized.match(/^(\d+)#([0-9a-zA-Z]{1,16})$/);
|
||||
const prefixMatch = strictMatch ? null : normalized.match(new RegExp(`^(\\d+)#([0-9a-zA-Z]{${HASH_LEN}})`));
|
||||
const match = strictMatch ?? prefixMatch;
|
||||
export function parseTag(ref: string): { line: number; hash: string } {
|
||||
// This regex captures:
|
||||
// 1. optional leading ">+" and whitespace
|
||||
// 2. line number (1+ digits)
|
||||
// 3. "#" with optional surrounding spaces
|
||||
// 4. hash (2 hex chars)
|
||||
// 5. optional trailing display suffix (":..." or " ...")
|
||||
const match = ref.match(/^\s*[>+-]*\s*(\d+)\s*#\s*([ZPMQVRWSNKTXJBYH]{2})/);
|
||||
if (!match) {
|
||||
throw new Error(`Invalid line reference "${ref}". Expected format "LINE#ID" (e.g. "5#aa").`);
|
||||
}
|
||||
@@ -648,9 +533,9 @@ export class HashlineMismatchError extends Error {
|
||||
const prefix = `${lineNum}#${hash}`;
|
||||
|
||||
if (mismatchSet.has(lineNum)) {
|
||||
lines.push(`>>> ${prefix}|${content}`);
|
||||
lines.push(`>>> ${prefix}:${content}`);
|
||||
} else {
|
||||
lines.push(` ${prefix}|${content}`);
|
||||
lines.push(` ${prefix}:${content}`);
|
||||
}
|
||||
}
|
||||
return lines.join("\n");
|
||||
@@ -670,7 +555,7 @@ export function validateLineRef(ref: { line: number; hash: string }, fileLines:
|
||||
throw new Error(`Line ${ref.line} does not exist (file has ${fileLines.length} lines)`);
|
||||
}
|
||||
const actualHash = computeLineHash(ref.line, fileLines[ref.line - 1]);
|
||||
if (actualHash !== ref.hash.toLowerCase()) {
|
||||
if (actualHash !== ref.hash) {
|
||||
throw new HashlineMismatchError([{ line: ref.line, expected: ref.hash, actual: actualHash }], fileLines);
|
||||
}
|
||||
}
|
||||
@@ -683,7 +568,7 @@ export function validateLineRef(ref: { line: number; hash: string }, fileLines:
|
||||
* Apply an array of hashline edits to file content.
|
||||
*
|
||||
* Each edit operation identifies target lines directly (`set`, `set_range`,
|
||||
* `insert`). Line references are resolved via {@link parseLineRef}
|
||||
* `insert`). Line references are resolved via {@link parseTag}
|
||||
* and hashes validated before any mutation.
|
||||
*
|
||||
* Edits are sorted bottom-up (highest effective line first) so earlier
|
||||
@@ -711,36 +596,29 @@ export function applyHashlineEdits(
|
||||
|
||||
const autocorrect = Bun.env.PI_HL_AUTOCORRECT === "1";
|
||||
|
||||
// Parse src specs and dst lines up front
|
||||
const parsed = edits.map(edit => {
|
||||
const parsedEdit = parseHashlineEdit(edit);
|
||||
return {
|
||||
spec: parsedEdit.spec,
|
||||
dstLines: stripNewLinePrefixes(parsedEdit.dstLines),
|
||||
};
|
||||
});
|
||||
|
||||
function collectExplicitlyTouchedLines(): Set<number> {
|
||||
const touched = new Set<number>();
|
||||
for (const { spec } of parsed) {
|
||||
switch (spec.kind) {
|
||||
case "single":
|
||||
touched.add(spec.ref.line);
|
||||
for (const edit of edits) {
|
||||
switch (edit.op) {
|
||||
case "set":
|
||||
touched.add(edit.tag.line);
|
||||
break;
|
||||
case "range":
|
||||
for (let ln = spec.start.line; ln <= spec.end.line; ln++) touched.add(ln);
|
||||
case "replace":
|
||||
for (let ln = edit.first.line; ln <= edit.last.line; ln++) touched.add(ln);
|
||||
break;
|
||||
case "insertAfter":
|
||||
touched.add(spec.after.line);
|
||||
case "append":
|
||||
if (edit.after) {
|
||||
touched.add(edit.after.line);
|
||||
}
|
||||
break;
|
||||
case "insertBefore":
|
||||
touched.add(spec.before.line);
|
||||
case "prepend":
|
||||
if (edit.before) {
|
||||
touched.add(edit.before.line);
|
||||
}
|
||||
break;
|
||||
case "insertBetween":
|
||||
touched.add(spec.after.line);
|
||||
touched.add(spec.before.line);
|
||||
break;
|
||||
case "insertAtEof":
|
||||
case "insert":
|
||||
touched.add(edit.after.line);
|
||||
touched.add(edit.before.line);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -755,59 +633,53 @@ export function applyHashlineEdits(
|
||||
throw new Error(`Line ${ref.line} does not exist (file has ${fileLines.length} lines)`);
|
||||
}
|
||||
const actualHash = computeLineHash(ref.line, fileLines[ref.line - 1]);
|
||||
if (actualHash === ref.hash.toLowerCase()) {
|
||||
if (actualHash === ref.hash) {
|
||||
return true;
|
||||
}
|
||||
mismatches.push({ line: ref.line, expected: ref.hash, actual: actualHash });
|
||||
return false;
|
||||
}
|
||||
for (const { spec, dstLines } of parsed) {
|
||||
switch (spec.kind) {
|
||||
case "single": {
|
||||
if (!validateRef(spec.ref)) continue;
|
||||
for (const edit of edits) {
|
||||
switch (edit.op) {
|
||||
case "set": {
|
||||
if (!validateRef(edit.tag)) continue;
|
||||
break;
|
||||
}
|
||||
case "insertAfter": {
|
||||
if (dstLines.length === 0) {
|
||||
case "append": {
|
||||
if (edit.content.length === 0) {
|
||||
throw new Error('Insert-after edit (src "N#HH..") requires non-empty dst');
|
||||
}
|
||||
if (!validateRef(spec.after)) continue;
|
||||
if (edit.after && !validateRef(edit.after)) continue;
|
||||
break;
|
||||
}
|
||||
case "insertBefore": {
|
||||
if (dstLines.length === 0) {
|
||||
case "prepend": {
|
||||
if (edit.content.length === 0) {
|
||||
throw new Error('Insert-before edit (src "N#HH..") requires non-empty dst');
|
||||
}
|
||||
if (!validateRef(spec.before)) continue;
|
||||
if (edit.before && !validateRef(edit.before)) continue;
|
||||
break;
|
||||
}
|
||||
case "insertBetween": {
|
||||
if (dstLines.length === 0) {
|
||||
case "insert": {
|
||||
if (edit.content.length === 0) {
|
||||
throw new Error('Insert-between edit (src "A#HH.. B#HH..") requires non-empty dst');
|
||||
}
|
||||
if (spec.before.line !== spec.after.line + 1) {
|
||||
if (edit.before.line !== edit.after.line + 1) {
|
||||
throw new Error(
|
||||
`insert requires adjacent anchors (after ${spec.after.line}, before ${spec.before.line})`,
|
||||
`insert requires adjacent anchors (after ${edit.after.line}, before ${edit.before.line})`,
|
||||
);
|
||||
}
|
||||
const afterValid = validateRef(spec.after);
|
||||
const beforeValid = validateRef(spec.before);
|
||||
const afterValid = validateRef(edit.after);
|
||||
const beforeValid = validateRef(edit.before);
|
||||
if (!afterValid || !beforeValid) continue;
|
||||
break;
|
||||
}
|
||||
case "insertAtEof": {
|
||||
if (dstLines.length === 0) {
|
||||
throw new Error("Insert-at-EOF edit requires non-empty dst");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "range": {
|
||||
if (spec.start.line > spec.end.line) {
|
||||
throw new Error(`Range start line ${spec.start.line} must be <= end line ${spec.end.line}`);
|
||||
case "replace": {
|
||||
if (edit.first.line > edit.last.line) {
|
||||
throw new Error(`Range start line ${edit.first.line} must be <= end line ${edit.last.line}`);
|
||||
}
|
||||
|
||||
const startValid = validateRef(spec.start);
|
||||
const endValid = validateRef(spec.end);
|
||||
const startValid = validateRef(edit.first);
|
||||
const endValid = validateRef(edit.last);
|
||||
if (!startValid || !endValid) continue;
|
||||
break;
|
||||
}
|
||||
@@ -819,30 +691,35 @@ export function applyHashlineEdits(
|
||||
// Deduplicate identical edits targeting the same line(s)
|
||||
const seenEditKeys = new Map<string, number>();
|
||||
const dedupIndices = new Set<number>();
|
||||
for (let i = 0; i < parsed.length; i++) {
|
||||
const p = parsed[i];
|
||||
for (let i = 0; i < edits.length; i++) {
|
||||
const edit = edits[i];
|
||||
let lineKey: string;
|
||||
switch (p.spec.kind) {
|
||||
case "single":
|
||||
lineKey = `s:${p.spec.ref.line}`;
|
||||
switch (edit.op) {
|
||||
case "set":
|
||||
lineKey = `s:${edit.tag.line}`;
|
||||
break;
|
||||
case "range":
|
||||
lineKey = `r:${p.spec.start.line}:${p.spec.end.line}`;
|
||||
case "replace":
|
||||
lineKey = `r:${edit.first.line}:${edit.last.line}`;
|
||||
break;
|
||||
case "insertAfter":
|
||||
lineKey = `i:${p.spec.after.line}`;
|
||||
break;
|
||||
case "insertBefore":
|
||||
lineKey = `ib:${p.spec.before.line}`;
|
||||
break;
|
||||
case "insertBetween":
|
||||
lineKey = `ix:${p.spec.after.line}:${p.spec.before.line}`;
|
||||
break;
|
||||
case "insertAtEof":
|
||||
case "append":
|
||||
if (edit.after) {
|
||||
lineKey = `i:${edit.after.line}`;
|
||||
break;
|
||||
}
|
||||
lineKey = "ieof";
|
||||
break;
|
||||
case "prepend":
|
||||
if (edit.before) {
|
||||
lineKey = `ib:${edit.before.line}`;
|
||||
break;
|
||||
}
|
||||
lineKey = "ibef";
|
||||
break;
|
||||
case "insert":
|
||||
lineKey = `ix:${edit.after.line}:${edit.before.line}`;
|
||||
break;
|
||||
}
|
||||
const dstKey = `${lineKey}|${p.dstLines.join("\n")}`;
|
||||
const dstKey = `${lineKey}:${edit.content.join("\n")}`;
|
||||
if (seenEditKeys.has(dstKey)) {
|
||||
dedupIndices.add(i);
|
||||
} else {
|
||||
@@ -850,51 +727,47 @@ export function applyHashlineEdits(
|
||||
}
|
||||
}
|
||||
if (dedupIndices.size > 0) {
|
||||
for (let i = parsed.length - 1; i >= 0; i--) {
|
||||
if (dedupIndices.has(i)) parsed.splice(i, 1);
|
||||
for (let i = edits.length - 1; i >= 0; i--) {
|
||||
if (dedupIndices.has(i)) edits.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Compute sort key (descending) — bottom-up application
|
||||
const annotated = parsed.map((p, idx) => {
|
||||
const annotated = edits.map((edit, idx) => {
|
||||
let sortLine: number;
|
||||
let precedence: number;
|
||||
switch (p.spec.kind) {
|
||||
case "single":
|
||||
sortLine = p.spec.ref.line;
|
||||
switch (edit.op) {
|
||||
case "set":
|
||||
sortLine = edit.tag.line;
|
||||
precedence = 0;
|
||||
break;
|
||||
case "range":
|
||||
sortLine = p.spec.end.line;
|
||||
case "replace":
|
||||
sortLine = edit.last.line;
|
||||
precedence = 0;
|
||||
break;
|
||||
case "insertAfter":
|
||||
sortLine = p.spec.after.line;
|
||||
case "append":
|
||||
sortLine = edit.after ? edit.after.line : fileLines.length + 1;
|
||||
precedence = 1;
|
||||
break;
|
||||
case "insertBefore":
|
||||
sortLine = p.spec.before.line;
|
||||
case "prepend":
|
||||
sortLine = edit.before ? edit.before.line : 0;
|
||||
precedence = 2;
|
||||
break;
|
||||
case "insertBetween":
|
||||
sortLine = p.spec.before.line;
|
||||
case "insert":
|
||||
sortLine = edit.before.line;
|
||||
precedence = 3;
|
||||
break;
|
||||
case "insertAtEof":
|
||||
sortLine = fileLines.length + 1;
|
||||
precedence = 4;
|
||||
break;
|
||||
}
|
||||
return { ...p, idx, sortLine, precedence };
|
||||
return { edit, idx, sortLine, precedence };
|
||||
});
|
||||
|
||||
annotated.sort((a, b) => b.sortLine - a.sortLine || a.precedence - b.precedence || a.idx - b.idx);
|
||||
|
||||
// Apply edits bottom-up
|
||||
for (const { spec, dstLines, idx } of annotated) {
|
||||
switch (spec.kind) {
|
||||
case "single": {
|
||||
const merged = autocorrect ? maybeExpandSingleLineMerge(spec.ref.line, dstLines) : null;
|
||||
for (const { edit, idx } of annotated) {
|
||||
switch (edit.op) {
|
||||
case "set": {
|
||||
const merged = autocorrect ? maybeExpandSingleLineMerge(edit.tag.line, edit.content) : null;
|
||||
if (merged) {
|
||||
const origLines = originalFileLines.slice(
|
||||
merged.startLine - 1,
|
||||
@@ -902,16 +775,11 @@ export function applyHashlineEdits(
|
||||
);
|
||||
let nextLines = merged.newLines;
|
||||
nextLines = restoreIndentForPairedReplacement([origLines[0] ?? ""], nextLines);
|
||||
if (
|
||||
origLines.join("\n") === nextLines.join("\n") &&
|
||||
origLines.some(l => CONFUSABLE_HYPHENS_RE.test(l))
|
||||
) {
|
||||
nextLines = normalizeConfusableHyphensInLines(nextLines);
|
||||
}
|
||||
if (origLines.join("\n") === nextLines.join("\n")) {
|
||||
|
||||
if (origLines.every((line, i) => line === nextLines[i])) {
|
||||
noopEdits.push({
|
||||
editIndex: idx,
|
||||
loc: `${spec.ref.line}#${spec.ref.hash}`,
|
||||
loc: `${edit.tag.line}#${edit.tag.hash}`,
|
||||
currentContent: origLines.join("\n"),
|
||||
});
|
||||
break;
|
||||
@@ -922,112 +790,113 @@ export function applyHashlineEdits(
|
||||
}
|
||||
|
||||
const count = 1;
|
||||
const origLines = originalFileLines.slice(spec.ref.line - 1, spec.ref.line);
|
||||
const origLines = originalFileLines.slice(edit.tag.line - 1, edit.tag.line);
|
||||
let stripped = autocorrect
|
||||
? stripRangeBoundaryEcho(originalFileLines, spec.ref.line, spec.ref.line, dstLines)
|
||||
: dstLines;
|
||||
? stripRangeBoundaryEcho(originalFileLines, edit.tag.line, edit.tag.line, edit.content)
|
||||
: edit.content;
|
||||
stripped = autocorrect ? restoreOldWrappedLines(origLines, stripped) : stripped;
|
||||
let newLines = autocorrect ? restoreIndentForPairedReplacement(origLines, stripped) : stripped;
|
||||
if (
|
||||
autocorrect &&
|
||||
origLines.join("\n") === newLines.join("\n") &&
|
||||
origLines.some(l => CONFUSABLE_HYPHENS_RE.test(l))
|
||||
) {
|
||||
newLines = normalizeConfusableHyphensInLines(newLines);
|
||||
}
|
||||
if (origLines.join("\n") === newLines.join("\n")) {
|
||||
const newLines = autocorrect ? restoreIndentForPairedReplacement(origLines, stripped) : stripped;
|
||||
if (origLines.every((line, i) => line === newLines[i])) {
|
||||
noopEdits.push({
|
||||
editIndex: idx,
|
||||
loc: `${spec.ref.line}#${spec.ref.hash}`,
|
||||
loc: `${edit.tag.line}#${edit.tag.hash}`,
|
||||
currentContent: origLines.join("\n"),
|
||||
});
|
||||
break;
|
||||
}
|
||||
fileLines.splice(spec.ref.line - 1, count, ...newLines);
|
||||
trackFirstChanged(spec.ref.line);
|
||||
fileLines.splice(edit.tag.line - 1, count, ...newLines);
|
||||
trackFirstChanged(edit.tag.line);
|
||||
break;
|
||||
}
|
||||
case "range": {
|
||||
const count = spec.end.line - spec.start.line + 1;
|
||||
const origLines = originalFileLines.slice(spec.start.line - 1, spec.start.line - 1 + count);
|
||||
case "replace": {
|
||||
const count = edit.last.line - edit.first.line + 1;
|
||||
const origLines = originalFileLines.slice(edit.first.line - 1, edit.first.line - 1 + count);
|
||||
let stripped = autocorrect
|
||||
? stripRangeBoundaryEcho(originalFileLines, spec.start.line, spec.end.line, dstLines)
|
||||
: dstLines;
|
||||
? stripRangeBoundaryEcho(originalFileLines, edit.first.line, edit.last.line, edit.content)
|
||||
: edit.content;
|
||||
stripped = autocorrect ? restoreOldWrappedLines(origLines, stripped) : stripped;
|
||||
let newLines = autocorrect ? restoreIndentForPairedReplacement(origLines, stripped) : stripped;
|
||||
if (
|
||||
autocorrect &&
|
||||
origLines.join("\n") === newLines.join("\n") &&
|
||||
origLines.some(l => CONFUSABLE_HYPHENS_RE.test(l))
|
||||
) {
|
||||
newLines = normalizeConfusableHyphensInLines(newLines);
|
||||
}
|
||||
if (origLines.join("\n") === newLines.join("\n")) {
|
||||
const newLines = autocorrect ? restoreIndentForPairedReplacement(origLines, stripped) : stripped;
|
||||
if (autocorrect && origLines.every((line, i) => line === newLines[i])) {
|
||||
noopEdits.push({
|
||||
editIndex: idx,
|
||||
loc: `${spec.start.line}#${spec.start.hash}`,
|
||||
loc: `${edit.first.line}#${edit.first.hash}`,
|
||||
currentContent: origLines.join("\n"),
|
||||
});
|
||||
break;
|
||||
}
|
||||
fileLines.splice(spec.start.line - 1, count, ...newLines);
|
||||
trackFirstChanged(spec.start.line);
|
||||
fileLines.splice(edit.first.line - 1, count, ...newLines);
|
||||
trackFirstChanged(edit.first.line);
|
||||
break;
|
||||
}
|
||||
case "insertAfter": {
|
||||
const anchorLine = originalFileLines[spec.after.line - 1];
|
||||
const inserted = autocorrect ? stripInsertAnchorEchoAfter(anchorLine, dstLines) : dstLines;
|
||||
case "append": {
|
||||
const inserted = edit.after
|
||||
? autocorrect
|
||||
? stripInsertAnchorEchoAfter(originalFileLines[edit.after.line - 1], edit.content)
|
||||
: edit.content
|
||||
: edit.content;
|
||||
if (inserted.length === 0) {
|
||||
noopEdits.push({
|
||||
editIndex: idx,
|
||||
loc: `${spec.after.line}#${spec.after.hash}`,
|
||||
currentContent: originalFileLines[spec.after.line - 1],
|
||||
loc: edit.after ? `${edit.after.line}#${edit.after.hash}` : "EOF",
|
||||
currentContent: edit.after ? originalFileLines[edit.after.line - 1] : "",
|
||||
});
|
||||
break;
|
||||
}
|
||||
fileLines.splice(spec.after.line, 0, ...inserted);
|
||||
trackFirstChanged(spec.after.line + 1);
|
||||
if (edit.after) {
|
||||
fileLines.splice(edit.after.line, 0, ...inserted);
|
||||
trackFirstChanged(edit.after.line + 1);
|
||||
} else {
|
||||
if (fileLines.length === 1 && fileLines[0] === "") {
|
||||
fileLines.splice(0, 1, ...inserted);
|
||||
trackFirstChanged(1);
|
||||
} else {
|
||||
fileLines.splice(fileLines.length, 0, ...inserted);
|
||||
trackFirstChanged(fileLines.length - inserted.length + 1);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "insertBefore": {
|
||||
const anchorLine = originalFileLines[spec.before.line - 1];
|
||||
const inserted = autocorrect ? stripInsertAnchorEchoBefore(anchorLine, dstLines) : dstLines;
|
||||
case "prepend": {
|
||||
const inserted = edit.before
|
||||
? autocorrect
|
||||
? stripInsertAnchorEchoBefore(originalFileLines[edit.before.line - 1], edit.content)
|
||||
: edit.content
|
||||
: edit.content;
|
||||
if (inserted.length === 0) {
|
||||
noopEdits.push({
|
||||
editIndex: idx,
|
||||
loc: `${spec.before.line}#${spec.before.hash}`,
|
||||
currentContent: originalFileLines[spec.before.line - 1],
|
||||
loc: edit.before ? `${edit.before.line}#${edit.before.hash}` : "BOF",
|
||||
currentContent: edit.before ? originalFileLines[edit.before.line - 1] : "",
|
||||
});
|
||||
break;
|
||||
}
|
||||
fileLines.splice(spec.before.line - 1, 0, ...inserted);
|
||||
trackFirstChanged(spec.before.line);
|
||||
if (edit.before) {
|
||||
fileLines.splice(edit.before.line - 1, 0, ...inserted);
|
||||
trackFirstChanged(edit.before.line);
|
||||
} else {
|
||||
if (fileLines.length === 1 && fileLines[0] === "") {
|
||||
fileLines.splice(0, 1, ...inserted);
|
||||
} else {
|
||||
fileLines.splice(0, 0, ...inserted);
|
||||
}
|
||||
trackFirstChanged(1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "insertBetween": {
|
||||
const afterLine = originalFileLines[spec.after.line - 1];
|
||||
const beforeLine = originalFileLines[spec.before.line - 1];
|
||||
const inserted = autocorrect ? stripInsertBoundaryEcho(afterLine, beforeLine, dstLines) : dstLines;
|
||||
case "insert": {
|
||||
const afterLine = originalFileLines[edit.after.line - 1];
|
||||
const beforeLine = originalFileLines[edit.before.line - 1];
|
||||
const inserted = autocorrect ? stripInsertBoundaryEcho(afterLine, beforeLine, edit.content) : edit.content;
|
||||
if (inserted.length === 0) {
|
||||
noopEdits.push({
|
||||
editIndex: idx,
|
||||
loc: `${spec.after.line}#${spec.after.hash}..${spec.before.line}#${spec.before.hash}`,
|
||||
loc: `${edit.after.line}#${edit.after.hash}..${edit.before.line}#${edit.before.hash}`,
|
||||
currentContent: `${afterLine}\n${beforeLine}`,
|
||||
});
|
||||
break;
|
||||
}
|
||||
fileLines.splice(spec.before.line - 1, 0, ...inserted);
|
||||
trackFirstChanged(spec.before.line);
|
||||
break;
|
||||
}
|
||||
case "insertAtEof": {
|
||||
if (fileLines.length === 1 && fileLines[0] === "") {
|
||||
fileLines.splice(0, 1, ...dstLines);
|
||||
trackFirstChanged(1);
|
||||
break;
|
||||
}
|
||||
fileLines.splice(fileLines.length, 0, ...dstLines);
|
||||
trackFirstChanged(fileLines.length - dstLines.length + 1);
|
||||
fileLines.splice(edit.before.line - 1, 0, ...inserted);
|
||||
trackFirstChanged(edit.before.line);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1047,12 +916,12 @@ export function applyHashlineEdits(
|
||||
|
||||
function maybeExpandSingleLineMerge(
|
||||
line: number,
|
||||
dst: string[],
|
||||
content: string[],
|
||||
): { startLine: number; deleteCount: number; newLines: string[] } | null {
|
||||
if (dst.length !== 1) return null;
|
||||
if (content.length !== 1) return null;
|
||||
if (line < 1 || line > fileLines.length) return null;
|
||||
|
||||
const newLine = dst[0];
|
||||
const newLine = content[0];
|
||||
const newCanon = stripAllWhitespace(newLine);
|
||||
const newCanonForMergeOps = stripMergeOperatorChars(newCanon);
|
||||
if (newCanon.length === 0) return null;
|
||||
|
||||
@@ -34,7 +34,14 @@ import { enforcePlanModeWrite, resolvePlanPath } from "../tools/plan-mode-guard"
|
||||
import { applyPatch } from "./applicator";
|
||||
import { generateDiffString, generateUnifiedDiffString, replaceText } from "./diff";
|
||||
import { findMatch } from "./fuzzy";
|
||||
import { applyHashlineEdits, computeLineHash, parseLineRef } from "./hashline";
|
||||
import {
|
||||
applyHashlineEdits,
|
||||
computeLineHash,
|
||||
type HashlineEdit,
|
||||
type LineTag,
|
||||
parseTag,
|
||||
type ReplaceTextEdit,
|
||||
} from "./hashline";
|
||||
import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "./normalize";
|
||||
import { buildNormativeUpdateInput } from "./normative";
|
||||
import { type EditToolDetails, getLspBatchRequest } from "./shared";
|
||||
@@ -66,7 +73,7 @@ export {
|
||||
computeLineHash,
|
||||
formatHashLines,
|
||||
HashlineMismatchError,
|
||||
parseLineRef,
|
||||
parseTag,
|
||||
streamHashLinesFromLines,
|
||||
streamHashLinesFromUtf8,
|
||||
validateLineRef,
|
||||
@@ -128,26 +135,68 @@ const patchEditSchema = Type.Object({
|
||||
|
||||
export type ReplaceParams = Static<typeof replaceEditSchema>;
|
||||
export type PatchParams = Static<typeof patchEditSchema>;
|
||||
type HashlineEditUnion = Static<typeof hashlineEditItemSchemaWithReplace>;
|
||||
|
||||
type HashlineReplaceEdit = { replace: { old_text: string; new_text: string; all?: boolean } };
|
||||
export type HashlineEdit =
|
||||
| { set: { ref: string; body: string[] } }
|
||||
| { set_range: { beg: string; end: string; body: string[] } }
|
||||
| { insert: { before?: string; after?: string; body: string[] } }
|
||||
| HashlineReplaceEdit;
|
||||
/** Pattern matching hashline display format: `LINE#ID:CONTENT` */
|
||||
const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*\d+#[0-9a-zA-Z]{1,16}:/;
|
||||
|
||||
const hashlineContentFormat = (kind: string) =>
|
||||
/** Pattern matching a unified-diff `+` prefix (but not `++`) */
|
||||
const DIFF_PLUS_RE = /^[+-](?![+-])/;
|
||||
|
||||
/**
|
||||
* Strip hashline display prefixes and diff `+` markers from replacement lines.
|
||||
*
|
||||
* Models frequently copy the `LINE#ID ` prefix from read output into their
|
||||
* replacement content, or include unified-diff `+` prefixes. Both corrupt the
|
||||
* output file. This strips them heuristically before application.
|
||||
*/
|
||||
function stripNewLinePrefixes(lines: string[]): string[] {
|
||||
// Detect whether the *majority* of non-empty lines carry a prefix —
|
||||
// if only one line out of many has a match it's likely real content.
|
||||
let hashPrefixCount = 0;
|
||||
let diffPlusCount = 0;
|
||||
let nonEmpty = 0;
|
||||
for (const l of lines) {
|
||||
if (l.length === 0) continue;
|
||||
nonEmpty++;
|
||||
if (HASHLINE_PREFIX_RE.test(l)) hashPrefixCount++;
|
||||
if (DIFF_PLUS_RE.test(l)) diffPlusCount++;
|
||||
}
|
||||
if (nonEmpty === 0) return lines;
|
||||
|
||||
const stripHash = hashPrefixCount > 0 && hashPrefixCount >= nonEmpty * 0.5;
|
||||
const stripPlus = !stripHash && diffPlusCount > 0 && diffPlusCount >= nonEmpty * 0.5;
|
||||
|
||||
if (!stripHash && !stripPlus) return lines;
|
||||
|
||||
return lines.map(l => {
|
||||
if (stripHash) return l.replace(HASHLINE_PREFIX_RE, "");
|
||||
if (stripPlus) return l.replace(DIFF_PLUS_RE, "");
|
||||
return l;
|
||||
});
|
||||
}
|
||||
|
||||
const hashlineReplaceContentFormat = (kind: string) =>
|
||||
Type.Union([
|
||||
Type.Null(),
|
||||
Type.Array(Type.String(), { description: `${kind} lines` }),
|
||||
Type.String({ description: `${kind} line` }),
|
||||
]);
|
||||
|
||||
const hashlineInsertContentFormat = (kind: string) =>
|
||||
Type.Union([
|
||||
Type.Array(Type.String(), { description: `${kind} lines`, minItems: 1 }),
|
||||
Type.String({ description: `${kind} line`, minLength: 1 }),
|
||||
]);
|
||||
|
||||
const hashlineTagFormat = (what: string) =>
|
||||
Type.String({
|
||||
description: `Tag identifying the ${what} in "LINE#ID" format`,
|
||||
});
|
||||
|
||||
function hashlineParseContent(edit: string | string[] | null): string[] {
|
||||
if (edit === null) return [];
|
||||
if (Array.isArray(edit)) return edit;
|
||||
const lines = edit.split("\n");
|
||||
const lines = stripNewLinePrefixes(edit.split("\n"));
|
||||
if (lines.length === 0) return [];
|
||||
if (lines[lines.length - 1].trim() === "") return lines.slice(0, -1);
|
||||
return lines;
|
||||
@@ -159,113 +208,89 @@ function hashlineParseContentString(edit: string | string[] | null): string {
|
||||
return edit;
|
||||
}
|
||||
|
||||
const hashlineTargetEditSchema = Type.Object({
|
||||
target: Type.String({ description: 'Line reference "LINE#ID"' }),
|
||||
new_content: hashlineContentFormat("Replacement"),
|
||||
});
|
||||
const hashlineTargetEditSchema = Type.Object(
|
||||
{
|
||||
op: Type.Literal("set"),
|
||||
tag: hashlineTagFormat("line being replaced"),
|
||||
content: hashlineReplaceContentFormat("Replacement"),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
function hashlineParseTargetEdit(edit: HashlineEditUnion): HashlineEdit | null {
|
||||
if ("target" in edit) {
|
||||
return {
|
||||
set: {
|
||||
ref: edit.target,
|
||||
body: hashlineParseContent(edit.new_content),
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const hashlineAppendEditSchema = Type.Object(
|
||||
{
|
||||
op: Type.Literal("append"),
|
||||
after: Type.Optional(hashlineTagFormat("line after which to append")),
|
||||
content: hashlineInsertContentFormat("Appended"),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
const hashlineRangeEditSchema = Type.Object({
|
||||
first: Type.String({ description: 'Start line ref "LINE#ID"' }),
|
||||
last: Type.String({ description: 'End line ref "LINE#ID"' }),
|
||||
new_content: hashlineContentFormat("Replacement"),
|
||||
});
|
||||
const hashlinePrependEditSchema = Type.Object(
|
||||
{
|
||||
op: Type.Literal("prepend"),
|
||||
before: Type.Optional(hashlineTagFormat("line before which to prepend")),
|
||||
content: hashlineInsertContentFormat("Prepended"),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
function hashlineParseRangeEdit(edit: HashlineEditUnion): HashlineEdit | null {
|
||||
if ("first" in edit && "last" in edit) {
|
||||
return {
|
||||
set_range: {
|
||||
beg: edit.first,
|
||||
end: edit.last,
|
||||
body: hashlineParseContent(edit.new_content),
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const hashlineRangeEditSchema = Type.Object(
|
||||
{
|
||||
op: Type.Literal("replace"),
|
||||
first: hashlineTagFormat("first line"),
|
||||
last: hashlineTagFormat("last line"),
|
||||
content: hashlineReplaceContentFormat("Replacement"),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
const hashlineInsertEditSchema = Type.Object({
|
||||
before: Type.Optional(Type.String({ description: 'Insert before this line "LINE#ID"' })),
|
||||
after: Type.Optional(Type.String({ description: 'Insert after this line "LINE#ID"' })),
|
||||
inserted_lines: hashlineContentFormat("Inserted"),
|
||||
});
|
||||
const hashlineInsertEditSchema = Type.Object(
|
||||
{
|
||||
op: Type.Literal("insert"),
|
||||
before: Type.Optional(hashlineTagFormat("line before which to insert")),
|
||||
after: Type.Optional(hashlineTagFormat("line after which to insert")),
|
||||
content: hashlineInsertContentFormat("Inserted"),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
function hashlineParseInsertEdit(edit: HashlineEditUnion): HashlineEdit | null {
|
||||
if ("inserted_lines" in edit) {
|
||||
const { before = "", after = "", inserted_lines } = edit;
|
||||
return {
|
||||
insert: {
|
||||
before: before.length > 0 ? before : undefined,
|
||||
after: after.length > 0 ? after : undefined,
|
||||
body: hashlineParseContent(inserted_lines),
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const hashlineReplaceTextEditSchema = Type.Object(
|
||||
{
|
||||
op: Type.Literal("replaceText"),
|
||||
old_text: Type.String({ description: "Text to find", minLength: 1 }),
|
||||
new_text: hashlineReplaceContentFormat("Replacement"),
|
||||
all: Type.Optional(Type.Boolean({ description: "Replace all occurrences" })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
const hashlineReplaceTextEditSchema = Type.Object({
|
||||
old_text: Type.String({ description: "Text to find (fuzzy whitespace matching enabled)" }),
|
||||
new_text: hashlineContentFormat("Replacement"),
|
||||
all: Type.Optional(Type.Boolean({ description: "Replace all occurrences (default: unique match required)" })),
|
||||
});
|
||||
const HL_REPLACE_ENABLED = Bun.env.PI_HL_REPLACETXT === "1";
|
||||
|
||||
function hashlineParseReplaceTextEdit(edit: HashlineEditUnion): HashlineReplaceEdit | null {
|
||||
if ("old_text" in edit && "new_text" in edit) {
|
||||
const { old_text, new_text, all } = edit;
|
||||
return {
|
||||
replace: {
|
||||
old_text: old_text,
|
||||
new_text: hashlineParseContentString(new_text),
|
||||
all: all,
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const hashlineEditItemSchema = Type.Union([
|
||||
const hashlineEditSpecSchema = Type.Union([
|
||||
hashlineTargetEditSchema,
|
||||
hashlineRangeEditSchema,
|
||||
hashlineAppendEditSchema,
|
||||
hashlinePrependEditSchema,
|
||||
hashlineInsertEditSchema,
|
||||
]);
|
||||
const hashlineEditItemSchemaWithReplace = Type.Union([
|
||||
hashlineTargetEditSchema,
|
||||
hashlineRangeEditSchema,
|
||||
hashlineInsertEditSchema,
|
||||
hashlineReplaceTextEditSchema,
|
||||
...(HL_REPLACE_ENABLED ? [hashlineReplaceTextEditSchema] : []),
|
||||
]);
|
||||
|
||||
const hashlineEditSchema = Type.Object({
|
||||
path: Type.String({ description: "File path (relative or absolute)" }),
|
||||
edits: Type.Array(hashlineEditItemSchema, { description: "Array of edit operations" }),
|
||||
delete: Type.Optional(Type.Literal(true, { description: "Delete the file" })),
|
||||
rename: Type.Optional(Type.String({ description: "New path for move" })),
|
||||
});
|
||||
const hashlineEditSchemaWithReplace = Type.Object({
|
||||
path: Type.String({ description: "File path (relative or absolute)" }),
|
||||
edits: Type.Array(hashlineEditItemSchemaWithReplace, { description: "Array of edit operations" }),
|
||||
delete: Type.Optional(Type.Literal(true, { description: "Delete the file" })),
|
||||
rename: Type.Optional(Type.String({ description: "New path for move" })),
|
||||
});
|
||||
const hashlineEditSchema = Type.Object(
|
||||
{
|
||||
path: Type.String({ description: "File path (relative or absolute)" }),
|
||||
edits: Type.Array(hashlineEditSpecSchema, {
|
||||
description: "Changes to apply to the file at `path`",
|
||||
minItems: 0,
|
||||
}),
|
||||
delete: Type.Optional(Type.Boolean({ description: "Delete the file when true" })),
|
||||
rename: Type.Optional(Type.String({ description: "New path if moving" })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export type HashlineToolEdit = Static<typeof hashlineEditItemSchemaWithReplace>;
|
||||
export type HashlineParams = Static<typeof hashlineEditSchemaWithReplace>;
|
||||
|
||||
function isHashlineReplaceTextEnabled(): boolean {
|
||||
return Bun.env.PI_HL_REPLACETXT === "1";
|
||||
}
|
||||
export type HashlineToolEdit = Static<typeof hashlineEditSpecSchema>;
|
||||
export type HashlineParams = Static<typeof hashlineEditSchema>;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// LSP FileSystem for patch mode
|
||||
@@ -355,11 +380,7 @@ function mergeDiagnosticsWithWarnings(
|
||||
// Tool Class
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
type TInput =
|
||||
| typeof replaceEditSchema
|
||||
| typeof patchEditSchema
|
||||
| typeof hashlineEditSchema
|
||||
| typeof hashlineEditSchemaWithReplace;
|
||||
type TInput = typeof replaceEditSchema | typeof patchEditSchema | typeof hashlineEditSchema;
|
||||
|
||||
export type EditMode = "replace" | "patch" | "hashline";
|
||||
|
||||
@@ -465,7 +486,7 @@ export class EditTool implements AgentTool<TInput> {
|
||||
case "patch":
|
||||
return renderPromptTemplate(patchDescription);
|
||||
case "hashline":
|
||||
return renderPromptTemplate(hashlineDescription, { allowReplaceText: isHashlineReplaceTextEnabled() });
|
||||
return renderPromptTemplate(hashlineDescription, { allowReplaceText: HL_REPLACE_ENABLED });
|
||||
default:
|
||||
return renderPromptTemplate(replaceDescription);
|
||||
}
|
||||
@@ -479,7 +500,7 @@ export class EditTool implements AgentTool<TInput> {
|
||||
case "patch":
|
||||
return patchEditSchema;
|
||||
case "hashline":
|
||||
return isHashlineReplaceTextEnabled() ? hashlineEditSchemaWithReplace : hashlineEditSchema;
|
||||
return hashlineEditSchema;
|
||||
default:
|
||||
return replaceEditSchema;
|
||||
}
|
||||
@@ -499,9 +520,8 @@ export class EditTool implements AgentTool<TInput> {
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
if (this.mode === "hashline") {
|
||||
const { path, edits, delete: deleteFile, rename } = params as HashlineParams;
|
||||
const op: Operation = deleteFile === true ? "delete" : "update";
|
||||
|
||||
enforcePlanModeWrite(this.session, path, { op, rename });
|
||||
enforcePlanModeWrite(this.session, path, { op: deleteFile ? "delete" : "update", rename });
|
||||
|
||||
if (path.endsWith(".ipynb") && edits?.length > 0) {
|
||||
throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
|
||||
@@ -511,7 +531,7 @@ export class EditTool implements AgentTool<TInput> {
|
||||
const resolvedRename = rename ? resolvePlanPath(this.session, rename) : undefined;
|
||||
const file = Bun.file(absolutePath);
|
||||
|
||||
if (deleteFile === true) {
|
||||
if (deleteFile) {
|
||||
if (await file.exists()) {
|
||||
await file.unlink();
|
||||
}
|
||||
@@ -527,30 +547,115 @@ export class EditTool implements AgentTool<TInput> {
|
||||
}
|
||||
|
||||
if (!(await file.exists())) {
|
||||
throw new Error(`File not found: ${path}`);
|
||||
}
|
||||
|
||||
const allowReplaceText = isHashlineReplaceTextEnabled();
|
||||
const anchorEdits: HashlineEdit[] = [];
|
||||
const replaceEdits: HashlineReplaceEdit[] = [];
|
||||
for (let i = 0; i < edits.length; i++) {
|
||||
const edit = edits[i] as HashlineEditUnion;
|
||||
|
||||
const anchorEdit =
|
||||
hashlineParseTargetEdit(edit) ?? hashlineParseRangeEdit(edit) ?? hashlineParseInsertEdit(edit);
|
||||
if (!anchorEdit) {
|
||||
if (allowReplaceText) {
|
||||
const replaceEdit = hashlineParseReplaceTextEdit(edit);
|
||||
if (replaceEdit) {
|
||||
replaceEdits.push(replaceEdit);
|
||||
continue;
|
||||
const content: string[] = [];
|
||||
for (const edit of edits) {
|
||||
switch (edit.op) {
|
||||
case "append": {
|
||||
if (edit.after) {
|
||||
throw new Error(`File not found: ${path}`);
|
||||
}
|
||||
content.push(...hashlineParseContent(edit.content));
|
||||
break;
|
||||
}
|
||||
case "prepend": {
|
||||
if (edit.before) {
|
||||
throw new Error(`File not found: ${path}`);
|
||||
}
|
||||
content.unshift(...hashlineParseContent(edit.content));
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new Error(`File not found: ${path}`);
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`edits[${i}] must match exactly one variant: {target,new_content} | {first,last,new_content} | {before/after,inserted_lines}${allowReplaceText ? " | {old_text,new_text}" : ""}. Got keys: [${Object.keys(edit).join(", ")}].`,
|
||||
);
|
||||
}
|
||||
anchorEdits.push(anchorEdit);
|
||||
await file.write(content.join("\n"));
|
||||
return {
|
||||
content: [{ type: "text", text: `Created ${path}` }],
|
||||
details: {
|
||||
diff: "",
|
||||
op: "create",
|
||||
meta: outputMeta().get(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const anchorEdits: HashlineEdit[] = [];
|
||||
const replaceEdits: ReplaceTextEdit[] = [];
|
||||
for (const edit of edits) {
|
||||
switch (edit.op) {
|
||||
case "set": {
|
||||
const { tag, content } = edit;
|
||||
anchorEdits.push({ op: "set", tag: parseTag(tag), content: hashlineParseContent(content) });
|
||||
break;
|
||||
}
|
||||
case "replace": {
|
||||
const { first, last, content } = edit;
|
||||
anchorEdits.push({
|
||||
op: "replace",
|
||||
first: parseTag(first),
|
||||
last: parseTag(last),
|
||||
content: hashlineParseContent(content),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "append": {
|
||||
const { after, content } = edit;
|
||||
anchorEdits.push({
|
||||
op: "append",
|
||||
...(after ? { after: parseTag(after) } : {}),
|
||||
content: hashlineParseContent(content),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "prepend": {
|
||||
const { before, content } = edit;
|
||||
anchorEdits.push({
|
||||
op: "prepend",
|
||||
...(before ? { before: parseTag(before) } : {}),
|
||||
content: hashlineParseContent(content),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "insert": {
|
||||
const { before, after, content } = edit;
|
||||
if (before && !after) {
|
||||
anchorEdits.push({
|
||||
op: "prepend",
|
||||
before: parseTag(before),
|
||||
content: hashlineParseContent(content),
|
||||
});
|
||||
} else if (after && !before) {
|
||||
anchorEdits.push({
|
||||
op: "append",
|
||||
after: parseTag(after),
|
||||
content: hashlineParseContent(content),
|
||||
});
|
||||
} else if (before && after) {
|
||||
anchorEdits.push({
|
||||
op: "insert",
|
||||
before: parseTag(before),
|
||||
after: parseTag(after),
|
||||
content: hashlineParseContent(content),
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Insert must have both before and after tags.`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "replaceText": {
|
||||
const { old_text, new_text, all } = edit;
|
||||
replaceEdits.push({
|
||||
op: "replaceText",
|
||||
old_text: old_text,
|
||||
new_text: hashlineParseContentString(new_text),
|
||||
all: all ?? false,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error(`Invalid edit operation: ${JSON.stringify(edit)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const rawContent = await file.text();
|
||||
@@ -565,12 +670,12 @@ export class EditTool implements AgentTool<TInput> {
|
||||
|
||||
// Apply content-replace edits (substr-style fuzzy replace)
|
||||
for (const r of replaceEdits) {
|
||||
if (r.replace.old_text.length === 0) {
|
||||
throw new Error("replace.old_text must not be empty.");
|
||||
if (r.old_text.length === 0) {
|
||||
throw new Error("old_text must not be empty.");
|
||||
}
|
||||
const rep = replaceText(normalizedContent, r.replace.old_text, r.replace.new_text, {
|
||||
const rep = replaceText(normalizedContent, r.old_text, r.new_text, {
|
||||
fuzzy: this.#allowFuzzy,
|
||||
all: r.replace.all ?? false,
|
||||
all: r.all ?? false,
|
||||
threshold: this.#fuzzyThreshold,
|
||||
});
|
||||
normalizedContent = rep.content;
|
||||
@@ -598,21 +703,35 @@ export class EditTool implements AgentTool<TInput> {
|
||||
// Edits were not literally identical but heuristics normalized them back
|
||||
const lines = result.content.split("\n");
|
||||
const targetLines: string[] = [];
|
||||
const refs: LineTag[] = [];
|
||||
for (const edit of anchorEdits) {
|
||||
const refs: string[] = [];
|
||||
if ("set" in edit) refs.push(edit.set.ref);
|
||||
else if ("set_range" in edit) refs.push(edit.set_range.beg, edit.set_range.end);
|
||||
else if ("insert" in edit) {
|
||||
if (edit.insert.after) refs.push(edit.insert.after);
|
||||
if (edit.insert.before) refs.push(edit.insert.before);
|
||||
refs.length = 0;
|
||||
switch (edit.op) {
|
||||
case "set":
|
||||
refs.push(edit.tag);
|
||||
break;
|
||||
case "replace":
|
||||
refs.push(edit.first, edit.last);
|
||||
break;
|
||||
case "append":
|
||||
if (edit.after) refs.push(edit.after);
|
||||
break;
|
||||
case "prepend":
|
||||
if (edit.before) refs.push(edit.before);
|
||||
break;
|
||||
case "insert":
|
||||
refs.push(edit.after, edit.before);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
for (const ref of refs) {
|
||||
try {
|
||||
const parsed = parseLineRef(ref);
|
||||
if (parsed.line >= 1 && parsed.line <= lines.length) {
|
||||
const lineContent = lines[parsed.line - 1];
|
||||
const hash = computeLineHash(parsed.line, lineContent);
|
||||
targetLines.push(`${parsed.line}#${hash}|${lineContent}`);
|
||||
if (ref.line >= 1 && ref.line <= lines.length) {
|
||||
const lineContent = lines[ref.line - 1];
|
||||
const hash = computeLineHash(ref.line, lineContent);
|
||||
targetLines.push(`${ref.line}#${hash}:${lineContent}`);
|
||||
}
|
||||
} catch {
|
||||
/* skip malformed refs */
|
||||
@@ -667,7 +786,7 @@ export class EditTool implements AgentTool<TInput> {
|
||||
diff: diffResult.diff,
|
||||
firstChangedLine: result.firstChangedLine ?? diffResult.firstChangedLine,
|
||||
diagnostics,
|
||||
op,
|
||||
op: "update",
|
||||
rename,
|
||||
meta,
|
||||
},
|
||||
@@ -714,17 +833,10 @@ export class EditTool implements AgentTool<TInput> {
|
||||
|
||||
// Generate diff for display
|
||||
let diffResult = { diff: "", firstChangedLine: undefined as number | undefined };
|
||||
let normative: PatchInput | undefined;
|
||||
if (result.change.type === "update" && result.change.oldContent && result.change.newContent) {
|
||||
const normalizedOld = normalizeToLF(stripBom(result.change.oldContent).text);
|
||||
const normalizedNew = normalizeToLF(stripBom(result.change.newContent).text);
|
||||
diffResult = generateUnifiedDiffString(normalizedOld, normalizedNew);
|
||||
normative = buildNormativeUpdateInput({
|
||||
path,
|
||||
rename: effRename,
|
||||
oldContent: result.change.oldContent,
|
||||
newContent: result.change.newContent,
|
||||
});
|
||||
}
|
||||
|
||||
let resultText: string;
|
||||
@@ -762,7 +874,6 @@ export class EditTool implements AgentTool<TInput> {
|
||||
rename: effRename,
|
||||
meta,
|
||||
},
|
||||
$normative: normative,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Powerful search tool built on ripgrep.
|
||||
<output>
|
||||
- Results are always content mode.
|
||||
{{#if IS_HASHLINE_MODE}}
|
||||
- Text output is CID prefixed: `LINE#ID|content`
|
||||
- Text output is CID prefixed: `LINE#ID:content`
|
||||
{{else}}
|
||||
{{#if IS_LINE_NUMBER_MODE}}
|
||||
- Text output is line-number-prefixed
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
# Edit
|
||||
|
||||
Apply precise file edits using `LINE#ID` anchors from `read` output.
|
||||
**CRITICAL:** anchors are `LINE#ID` only. Copy verbatim from the prefix (example: `{{hlineref 42 "const x = 1"}}`). Never include `|content`.
|
||||
Apply precise file edits using `LINE#ID` tags, anchoring to the file content.
|
||||
|
||||
<workflow>
|
||||
1. `read` the target range to capture current `LINE#ID` anchors.
|
||||
1. `read` the target range to capture current `LINE#ID` tags.
|
||||
2. Pick the smallest operation per change site (line/range/insert/content-replace).
|
||||
3. Direction-lock every edit: exact current text -> intended text.
|
||||
3. Direction-lock every edit: exact current text → intended text.
|
||||
4. Submit one `edit` call per file containing all operations.
|
||||
5. If another edit is needed in that file, re-read first (hashes changed).
|
||||
6. Output tool calls only; no prose.
|
||||
@@ -14,23 +13,22 @@ Apply precise file edits using `LINE#ID` anchors from `read` output.
|
||||
|
||||
<operations>
|
||||
- **Single line replace/delete**
|
||||
- `{ target: "LINE#ID", new_content: ["..."] }`
|
||||
- `new_content: null` deletes the line; `new_content: [""]` keeps a blank line.
|
||||
- `{ op: "set", tag: "N#ID", content: […] }`
|
||||
- `content: null` deletes the line; `content: [""]` keeps a blank line.
|
||||
- **Range replace/delete**
|
||||
- `{ first: "LINE#ID", last: "LINE#ID", new_content: ["..."] }`
|
||||
- Use for swaps, block rewrites, or deleting a full span (`new_content: null`).
|
||||
- `{ op: "replace", first: "N#ID", last: "N#ID", content: […] }`
|
||||
- Use for swaps, block rewrites, or deleting a full span (`content: null`).
|
||||
- **Insert** (new content)
|
||||
- `{ before: "LINE#ID", inserted_lines: ["..."] }`
|
||||
- `{ after: "LINE#ID", inserted_lines: ["..."] }`
|
||||
- `{ after: "LINE#ID", before: "LINE#ID", inserted_lines: ["..."] }` (between adjacent anchors; safest for blocks)
|
||||
- `inserted_lines` must be non-empty.
|
||||
- `{ op: "prepend", before: "N#ID", content: […] }` or `{ op: "prepend", content: […] }` (no `before` = insert at beginning of file)
|
||||
- `{ op: "append", after: "N#ID", content: […] }` or `{ op: "append", content: […] }` (no `after` = insert at end of file)
|
||||
- `{ op: "insert", after: "N#ID", before: "N#ID", content: […] }` (between adjacent anchors; safest for blocks)
|
||||
{{#if allowReplaceText}}
|
||||
- **Content replace** (fallback when anchors unavailable)
|
||||
- `{ old_text: "...", new_text: "...", all?: boolean }`
|
||||
- **Content replace**
|
||||
- `{ op: "replaceText", old_text: "…", new_text: "…", all?: boolean }`
|
||||
{{/if}}
|
||||
- **File-level controls**
|
||||
- `{ delete: true, edits: [] }` deletes the file (cannot be combined with `rename`).
|
||||
- `{ rename: "new/path.ts", edits: [...] }` writes result to new path and removes old path.
|
||||
- `{ rename: "new/path.ts", edits: […] }` writes result to new path and removes old path.
|
||||
**Atomicity:** all ops validate against the same pre-edit file snapshot; refs are interpreted against last `read`; applicator applies bottom-up.
|
||||
</operations>
|
||||
|
||||
@@ -44,61 +42,61 @@ Apply precise file edits using `LINE#ID` anchors from `read` output.
|
||||
7. **For swaps/moves:** prefer one range operation over multiple single-line operations.
|
||||
</rules>
|
||||
|
||||
<selection_heuristics>
|
||||
- One wrong line -> `{ target, new_content }`
|
||||
- Adjacent block changed -> `{ first, last, new_content }`
|
||||
- Missing line/block -> insert with `before`/`after` + `inserted_lines`
|
||||
</selection_heuristics>
|
||||
<op_choice>
|
||||
- One wrong line → `set`
|
||||
- Adjacent block changed → `insert`
|
||||
- Missing line/block → insert with `append`/`prepend`
|
||||
</op_choice>
|
||||
|
||||
<anchor_hygiene>
|
||||
- Copy anchor IDs exactly from `read` or error output.
|
||||
- Never handcraft hashes.
|
||||
- For inserts, prefer `after+before` dual anchors when both boundaries are known.
|
||||
<tag_choice>
|
||||
- Copy tags exactly from the prefix of the `read` or error output.
|
||||
- Never guess tags.
|
||||
- For inserts, prefer `insert` > `append`/`prepend` when both boundaries are known.
|
||||
- Re-read after each successful edit call before issuing another on same file.
|
||||
</anchor_hygiene>
|
||||
</tag_choice>
|
||||
|
||||
<recovery>
|
||||
**Hash mismatch (`>>>`)**
|
||||
- Retry with the updated anchors shown in error output.
|
||||
- Re-read only if required anchors are missing from error snippet.
|
||||
**Tag mismatch (`>>>`)**
|
||||
- Retry with the updated tags shown in error output.
|
||||
- Re-read only if required tags are missing from error snippet.
|
||||
- If mismatch repeats, stop and re-read the exact block.
|
||||
**No-op / identical content**
|
||||
- Re-read immediately; target is stale or replacement equals current text.
|
||||
- After two no-ops on same area, re-read the full function/block before retry.
|
||||
</recovery>
|
||||
|
||||
<example name="single line replace — fix a value or type">
|
||||
<example name="fix a value or type">
|
||||
```ts
|
||||
{{hlinefull 23 " const timeout: number = 5000;"}}
|
||||
```
|
||||
```
|
||||
target: "{{hlineref 23 " const timeout: number = 5000;"}}"
|
||||
new_content: [" const timeout: number = 30_000;"]
|
||||
op: "set"
|
||||
tag: "{{hlineref 23 " const timeout: number = 5000;"}}"
|
||||
content: [" const timeout: number = 30_000;"]
|
||||
```
|
||||
</example>
|
||||
|
||||
<example name="single line delete — remove a line entirely">
|
||||
<example name="remove a line entirely">
|
||||
```ts
|
||||
{{hlinefull 7 "// @ts-ignore"}}
|
||||
{{hlinefull 8 "const data = fetchSync(url);"}}
|
||||
```
|
||||
```
|
||||
target: "{{hlineref 7 "// @ts-ignore"}}"
|
||||
new_content: null
|
||||
op: "set"
|
||||
tag: "{{hlineref 7 "// @ts-ignore"}}"
|
||||
content: null
|
||||
```
|
||||
</example>
|
||||
|
||||
<example name="single line blank — clear content but keep the line break">
|
||||
<example name="clear content but keep the line break">
|
||||
```ts
|
||||
{{hlinefull 14 " placeholder: \"DO NOT SHIP\","}}
|
||||
```
|
||||
```
|
||||
target: "{{hlineref 14 " placeholder: \"DO NOT SHIP\","}}"
|
||||
new_content: [""]
|
||||
op: "set"
|
||||
tag: "{{hlineref 14 " placeholder: \"DO NOT SHIP\","}}"
|
||||
content: [""]
|
||||
```
|
||||
</example>
|
||||
|
||||
<example name="range replace — rewrite a block of logic">
|
||||
<example name="rewrite a block of logic">
|
||||
```ts
|
||||
{{hlinefull 60 " } catch (err) {"}}
|
||||
{{hlinefull 61 " console.error(err);"}}
|
||||
@@ -106,13 +104,14 @@ new_content: [""]
|
||||
{{hlinefull 63 " }"}}
|
||||
```
|
||||
```
|
||||
op: "replace"
|
||||
first: "{{hlineref 60 " } catch (err) {"}}"
|
||||
last: "{{hlineref 63 " }"}}"
|
||||
new_content: [" } catch (err) {", " if (isEnoent(err)) return null;", " throw err;", " }"]
|
||||
content: [" } catch (err) {", " if (isEnoent(err)) return null;", " throw err;", " }"]
|
||||
```
|
||||
</example>
|
||||
|
||||
<example name="range delete — remove a full block">
|
||||
<example name="remove a full block">
|
||||
```ts
|
||||
{{hlinefull 80 " // TODO: remove after migration"}}
|
||||
{{hlinefull 81 " if (legacy) {"}}
|
||||
@@ -120,49 +119,53 @@ new_content: [" } catch (err) {", " if (isEnoent(err)) return null;", "
|
||||
{{hlinefull 83 " }"}}
|
||||
```
|
||||
```
|
||||
op: "replace"
|
||||
first: "{{hlineref 80 " // TODO: remove after migration"}}"
|
||||
last: "{{hlineref 83 " }"}}"
|
||||
new_content: null
|
||||
content: null
|
||||
```
|
||||
</example>
|
||||
|
||||
<example name="insert with before — add an import above the first import">
|
||||
<example name="add an import above the first import">
|
||||
```ts
|
||||
{{hlinefull 1 "import * as fs from \"node:fs/promises\";"}}
|
||||
{{hlinefull 2 "import * as path from \"node:path\";"}}
|
||||
```
|
||||
```
|
||||
op: "prepend"
|
||||
before: "{{hlineref 1 "import * as fs from \"node:fs/promises\";"}}"
|
||||
inserted_lines: ["import * as os from \"node:os\";"]
|
||||
content: ["import * as os from \"node:os\";"]
|
||||
```
|
||||
Use `before` when prepending at the top of a block or file — there is no meaningful anchor above.
|
||||
Use `before` for anchored insertion before a specific line. Omit `before` to prepend at BOF.
|
||||
</example>
|
||||
|
||||
<example name="insert with after — append at end of file">
|
||||
<example name="append at end of file">
|
||||
```ts
|
||||
{{hlinefull 260 "export { serialize, deserialize };"}}
|
||||
```
|
||||
```
|
||||
op: "append"
|
||||
after: "{{hlineref 260 "export { serialize, deserialize };"}}"
|
||||
inserted_lines: ["export { validate };"]
|
||||
content: ["export { validate };"]
|
||||
```
|
||||
Use `after` when appending at the bottom — there is no anchor below.
|
||||
Use `after` for anchored insertion after a specific line. Omit `after` to append at EOF.
|
||||
</example>
|
||||
|
||||
<example name="insert with after + before (dual anchor) — add an entry between known siblings">
|
||||
<example name="add an entry between known siblings">
|
||||
```ts
|
||||
{{hlinefull 44 " \"build\": \"bun run compile\","}}
|
||||
{{hlinefull 45 " \"test\": \"bun test\""}}
|
||||
```
|
||||
```
|
||||
op: "insert"
|
||||
after: "{{hlineref 44 " \"build\": \"bun run compile\","}}"
|
||||
before: "{{hlineref 45 " \"test\": \"bun test\""}}"
|
||||
inserted_lines: [" \"lint\": \"biome check\","]
|
||||
content: [" \"lint\": \"biome check\","]
|
||||
```
|
||||
Dual anchors pin the insert to exactly one gap, preventing drift from edits elsewhere in the file. **Always prefer dual anchors when both boundaries are content lines.**
|
||||
</example>
|
||||
|
||||
<example name="insert a function before another function — anchor to the target, not whitespace">
|
||||
<example name="insert a function before another function">
|
||||
```ts
|
||||
{{hlinefull 100 " return buf.toString(\"hex\");"}}
|
||||
{{hlinefull 101 "}"}}
|
||||
@@ -170,18 +173,21 @@ Dual anchors pin the insert to exactly one gap, preventing drift from edits else
|
||||
{{hlinefull 103 "export function serialize(data: unknown): string {"}}
|
||||
```
|
||||
```
|
||||
op: "insert"
|
||||
before: "{{hlineref 103 "export function serialize(data: unknown): string {"}}"
|
||||
inserted_lines: ["function validate(data: unknown): boolean {", " return data != null && typeof data === \"object\";", "}", ""]
|
||||
content: ["function validate(data: unknown): boolean {", " return data != null && typeof data === \"object\";", "}", ""]
|
||||
```
|
||||
The trailing `""` in `inserted_lines` preserves the blank-line separator. **Anchor to the structural line (`export function ...`), not the blank line above it** — blank lines are ambiguous and may be added or removed by other edits.
|
||||
The trailing `""` in `content` preserves the blank-line separator. **Anchor to the structural line (`export function ...`), not the blank line above it** — blank lines are ambiguous and may be added or removed by other edits.
|
||||
</example>
|
||||
|
||||
{{#if allowReplaceText}}
|
||||
<example name="content replace (fallback) — when anchors are unavailable">
|
||||
<example name="content replace (rare)">
|
||||
```
|
||||
op: "replaceText"
|
||||
old_text: "x = 42"
|
||||
new_text: "x = 99"
|
||||
```
|
||||
|
||||
Use only when line anchors aren't available. `old_text` must match exactly one location in the file (or set `"all": true` for all occurrences).
|
||||
</example>
|
||||
{{/if}}
|
||||
@@ -193,34 +199,34 @@ delete: true
|
||||
```
|
||||
</example>
|
||||
|
||||
<example name="file rename with edits — move and modify in one atomic call">
|
||||
<example name="file rename with edits">
|
||||
```
|
||||
path: "src/utils.ts"
|
||||
rename: "src/helpers/utils.ts"
|
||||
edits: [..]
|
||||
edits: […]
|
||||
```
|
||||
</example>
|
||||
|
||||
<example name="anti-pattern: anchoring to whitespace">
|
||||
Bad — anchors to a blank line; fragile if blank lines shift:
|
||||
Bad — tags to a blank line; fragile if blank lines shift:
|
||||
```
|
||||
after: "{{hlineref 102 ""}}"
|
||||
inserted_lines: ["function validate() { ... }"]
|
||||
content: ["function validate() {", …, "}"]
|
||||
```
|
||||
|
||||
Good — anchors to the structural target:
|
||||
|
||||
```
|
||||
before: "{{hlineref 103 "export function serialize(data: unknown): string {"}}"
|
||||
inserted_lines: ["function validate() { ... }", ""]
|
||||
content: ["function validate() {", …, "}"]
|
||||
```
|
||||
</example>
|
||||
|
||||
<validation>
|
||||
- [ ] Payload shape is `{ "path": string, "edits": [operation, ...], "delete"?: true, "rename"?: string }`
|
||||
- [ ] Every operation matches exactly one variant
|
||||
- [ ] Every anchor is copied exactly as `LINE#ID` (no spaces, no `|content`)
|
||||
- [ ] `new_content` / `inserted_lines` lines are raw content only (no diff markers, no anchor prefixes)
|
||||
- [ ] Every replacement is meaningfully different from current content
|
||||
- [ ] Scope is minimal and formatting is preserved except targeted token changes
|
||||
</validation>
|
||||
**Final reminder:** anchors are immutable references to the last read snapshot. Re-read when state changes, then edit.
|
||||
<critical>
|
||||
Ensure:
|
||||
- Payload shape is `{ "path": string, "edits": [operation, …], "delete"?: boolean, "rename"?: string }`
|
||||
- Every edit matches exactly one variant
|
||||
- Every tag has been copied EXACTLY from a tool result as `N#ID`
|
||||
- Scope is minimal and formatting is preserved except targeted token changes
|
||||
</critical>
|
||||
**Final reminder:** tags are immutable references to the last read snapshot. Re-read when state changes, then edit.
|
||||
@@ -6,7 +6,7 @@ Reads files from local filesystem or internal URLs.
|
||||
- Reads up to {{DEFAULT_MAX_LINES}} lines default
|
||||
- Use `offset` and `limit` for large files
|
||||
{{#if IS_HASHLINE_MODE}}
|
||||
- Text output is CID prefixed: `LINE#ID|content`
|
||||
- Text output is CID prefixed: `LINE#ID:content`
|
||||
{{else}}
|
||||
{{#if IS_LINE_NUMBER_MODE}}
|
||||
- Text output is line-number-prefixed
|
||||
|
||||
@@ -220,10 +220,10 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
|
||||
const formatLine = (lineNumber: number, line: string, isMatch: boolean): string => {
|
||||
if (useHashLines) {
|
||||
const ref = `${lineNumber}#${computeLineHash(lineNumber, line)}`;
|
||||
return isMatch ? `>>${ref}|${line}` : ` ${ref}|${line}`;
|
||||
return isMatch ? `>>${ref}:${line}` : ` ${ref}:${line}`;
|
||||
}
|
||||
const padded = lineNumber.toString().padStart(lineWidth, " ");
|
||||
return isMatch ? `>>${padded}|${line}` : ` ${padded}|${line}`;
|
||||
return isMatch ? `>>${padded}:${line}` : ` ${padded}:${line}`;
|
||||
};
|
||||
|
||||
// Add context before
|
||||
|
||||
@@ -772,7 +772,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
const prependHashLines = (text: string, startNum: number): string => {
|
||||
const textLines = text.split("\n");
|
||||
return textLines
|
||||
.map((line, i) => `${startNum + i}#${computeLineHash(startNum + i, line)}|${line}`)
|
||||
.map((line, i) => `${startNum + i}#${computeLineHash(startNum + i, line)}:${line}`)
|
||||
.join("\n");
|
||||
};
|
||||
const formatText = (text: string, startNum: number): string => {
|
||||
@@ -929,7 +929,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
};
|
||||
const prependHashLines = (text: string, startNum: number): string => {
|
||||
const textLines = text.split("\n");
|
||||
return textLines.map((line, i) => `${startNum + i}#${computeLineHash(startNum + i, line)}|${line}`).join("\n");
|
||||
return textLines.map((line, i) => `${startNum + i}#${computeLineHash(startNum + i, line)}:${line}`).join("\n");
|
||||
};
|
||||
const formatText = (text: string, startNum: number): string => {
|
||||
if (shouldAddHashLines) return prependHashLines(text, startNum);
|
||||
|
||||
@@ -1,41 +1,45 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||
import {
|
||||
applyHashlineEdits,
|
||||
computeLineHash,
|
||||
formatHashLines,
|
||||
type HashlineEdit,
|
||||
HashlineMismatchError,
|
||||
parseLineRef,
|
||||
parseTag,
|
||||
streamHashLinesFromLines,
|
||||
streamHashLinesFromUtf8,
|
||||
validateLineRef,
|
||||
} from "@oh-my-pi/pi-coding-agent/patch";
|
||||
import { formatLineTag, type HashlineEdit, type LineTag } from "@oh-my-pi/pi-coding-agent/patch/hashline";
|
||||
|
||||
function makeTag(line: number, content: string): LineTag {
|
||||
return parseTag(formatLineTag(line, content));
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// computeLineHash
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("computeLineHash", () => {
|
||||
test("returns 2-4 character alphanumeric hash string", () => {
|
||||
it("returns 2-4 character alphanumeric hash string", () => {
|
||||
const hash = computeLineHash(1, "hello");
|
||||
expect(hash).toMatch(/^[0-9a-z]{2,4}$/);
|
||||
expect(hash).toMatch(/^[ZPMQVRWSNKTXJBYH]{2}$/);
|
||||
});
|
||||
|
||||
test("same content at same line produces same hash", () => {
|
||||
it("same content at same line produces same hash", () => {
|
||||
const a = computeLineHash(1, "hello");
|
||||
const b = computeLineHash(1, "hello");
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
test("different content produces different hash", () => {
|
||||
it("different content produces different hash", () => {
|
||||
const a = computeLineHash(1, "hello");
|
||||
const b = computeLineHash(1, "world");
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
test("empty line produces valid hash", () => {
|
||||
it("empty line produces valid hash", () => {
|
||||
const hash = computeLineHash(1, "");
|
||||
expect(hash).toMatch(/^[0-9a-z]{2,4}$/);
|
||||
expect(hash).toMatch(/^[ZPMQVRWSNKTXJBYH]{2}$/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,13 +48,13 @@ describe("computeLineHash", () => {
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("formatHashLines", () => {
|
||||
test("formats single line", () => {
|
||||
it("formats single line", () => {
|
||||
const result = formatHashLines("hello");
|
||||
const hash = computeLineHash(1, "hello");
|
||||
expect(result).toBe(`1#${hash}|hello`);
|
||||
expect(result).toBe(`1#${hash}:hello`);
|
||||
});
|
||||
|
||||
test("formats multiple lines with 1-indexed numbers", () => {
|
||||
it("formats multiple lines with 1-indexed numbers", () => {
|
||||
const result = formatHashLines("foo\nbar\nbaz");
|
||||
const lines = result.split("\n");
|
||||
expect(lines).toHaveLength(3);
|
||||
@@ -59,27 +63,27 @@ describe("formatHashLines", () => {
|
||||
expect(lines[2]).toStartWith("3#");
|
||||
});
|
||||
|
||||
test("respects custom startLine", () => {
|
||||
it("respects custom startLine", () => {
|
||||
const result = formatHashLines("foo\nbar", 10);
|
||||
const lines = result.split("\n");
|
||||
expect(lines[0]).toStartWith("10#");
|
||||
expect(lines[1]).toStartWith("11#");
|
||||
});
|
||||
|
||||
test("handles empty lines in content", () => {
|
||||
it("handles empty lines in content", () => {
|
||||
const result = formatHashLines("foo\n\nbar");
|
||||
const lines = result.split("\n");
|
||||
expect(lines).toHaveLength(3);
|
||||
expect(lines[1]).toMatch(/^2#[0-9a-z]{2,4}\|$/);
|
||||
expect(lines[1]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}:$/);
|
||||
});
|
||||
|
||||
test("round-trips with computeLineHash", () => {
|
||||
it("round-trips with computeLineHash", () => {
|
||||
const content = "function hello() {\n return 42;\n}";
|
||||
const formatted = formatHashLines(content);
|
||||
const lines = formatted.split("\n");
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const match = lines[i].match(/^(\d+)#([0-9a-z]+)\|(.*)$/);
|
||||
const match = lines[i].match(/^(\d+)#([ZPMQVRWSNKTXJBYH]{2}):(.*)$/);
|
||||
expect(match).not.toBeNull();
|
||||
const lineNum = Number.parseInt(match![1], 10);
|
||||
const hash = match![2];
|
||||
@@ -109,26 +113,26 @@ describe("streamHashLinesFrom*", () => {
|
||||
}
|
||||
}
|
||||
|
||||
test("streamHashLinesFromUtf8 matches formatHashLines", async () => {
|
||||
it("streamHashLinesFromUtf8 matches formatHashLines", async () => {
|
||||
const content = "foo\nbar\nbaz";
|
||||
const streamed = await collectText(streamHashLinesFromUtf8(utf8Chunks(content, 2), { maxChunkLines: 1 }));
|
||||
expect(streamed).toBe(formatHashLines(content));
|
||||
});
|
||||
|
||||
test("streamHashLinesFromUtf8 handles empty content", async () => {
|
||||
it("streamHashLinesFromUtf8 handles empty content", async () => {
|
||||
const content = "";
|
||||
const streamed = await collectText(streamHashLinesFromUtf8(utf8Chunks(content, 2), { maxChunkLines: 1 }));
|
||||
expect(streamed).toBe(formatHashLines(content));
|
||||
});
|
||||
|
||||
test("streamHashLinesFromLines matches formatHashLines (including trailing newline)", async () => {
|
||||
it("streamHashLinesFromLines matches formatHashLines (including trailing newline)", async () => {
|
||||
const content = "foo\nbar\n";
|
||||
const lines = ["foo", "bar", ""]; // match `content.split("\\n")`
|
||||
const streamed = await collectText(streamHashLinesFromLines(lines, { maxChunkLines: 2 }));
|
||||
expect(streamed).toBe(formatHashLines(content));
|
||||
});
|
||||
|
||||
test("chunking respects maxChunkLines", async () => {
|
||||
it("chunking respects maxChunkLines", async () => {
|
||||
const content = "a\nb\nc";
|
||||
const parts: string[] = [];
|
||||
for await (const part of streamHashLinesFromUtf8(utf8Chunks(content, 1), {
|
||||
@@ -143,47 +147,46 @@ describe("streamHashLinesFrom*", () => {
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// parseLineRef
|
||||
// parseTag
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("parseLineRef", () => {
|
||||
test("parses valid reference", () => {
|
||||
const ref = parseLineRef("5#abcd");
|
||||
expect(ref).toEqual({ line: 5, hash: "abcd" });
|
||||
describe("parseTag", () => {
|
||||
it("parses valid reference", () => {
|
||||
const ref = parseTag("5#QQ");
|
||||
expect(ref).toEqual({ line: 5, hash: "QQ" });
|
||||
});
|
||||
|
||||
test("parses single-digit hash", () => {
|
||||
const ref = parseLineRef("1#a");
|
||||
expect(ref).toEqual({ line: 1, hash: "a" });
|
||||
it("rejects single-character hash", () => {
|
||||
expect(() => parseTag("1#Q")).toThrow(/Invalid line reference/);
|
||||
});
|
||||
|
||||
test("parses long hash", () => {
|
||||
const ref = parseLineRef("100#abcdef0123456789");
|
||||
expect(ref).toEqual({ line: 100, hash: "abcdef0123456789" });
|
||||
it("parses long hash by taking strict 2-char prefix", () => {
|
||||
const ref = parseTag("100#QQQQ");
|
||||
expect(ref).toEqual({ line: 100, hash: "QQ" });
|
||||
});
|
||||
|
||||
test("rejects missing separator", () => {
|
||||
expect(() => parseLineRef("5abcd")).toThrow(/Invalid line reference/);
|
||||
it("rejects missing separator", () => {
|
||||
expect(() => parseTag("5QQ")).toThrow(/Invalid line reference/);
|
||||
});
|
||||
|
||||
test("rejects non-numeric line", () => {
|
||||
expect(() => parseLineRef("abc#1234")).toThrow(/Invalid line reference/);
|
||||
it("rejects non-numeric line", () => {
|
||||
expect(() => parseTag("abc#Q")).toThrow(/Invalid line reference/);
|
||||
});
|
||||
|
||||
test("rejects non-alphanumeric hash", () => {
|
||||
expect(() => parseLineRef("5#$$$$")).toThrow(/Invalid line reference/);
|
||||
it("rejects non-alphanumeric hash", () => {
|
||||
expect(() => parseTag("5#$$$$")).toThrow(/Invalid line reference/);
|
||||
});
|
||||
|
||||
test("rejects line number 0", () => {
|
||||
expect(() => parseLineRef("0#abcd")).toThrow(/Line number must be >= 1/);
|
||||
it("rejects line number 0", () => {
|
||||
expect(() => parseTag("0#QQ")).toThrow(/Line number must be >= 1/);
|
||||
});
|
||||
|
||||
test("rejects empty string", () => {
|
||||
expect(() => parseLineRef("")).toThrow(/Invalid line reference/);
|
||||
it("rejects empty string", () => {
|
||||
expect(() => parseTag("")).toThrow(/Invalid line reference/);
|
||||
});
|
||||
|
||||
test("rejects empty hash", () => {
|
||||
expect(() => parseLineRef("5#")).toThrow(/Invalid line reference/);
|
||||
it("rejects empty hash", () => {
|
||||
expect(() => parseTag("5#")).toThrow(/Invalid line reference/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -192,29 +195,29 @@ describe("parseLineRef", () => {
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("validateLineRef", () => {
|
||||
test("accepts valid ref with matching hash", () => {
|
||||
it("accepts valid ref with matching hash", () => {
|
||||
const lines = ["hello", "world"];
|
||||
const hash = computeLineHash(1, "hello");
|
||||
expect(() => validateLineRef({ line: 1, hash }, lines)).not.toThrow();
|
||||
});
|
||||
|
||||
test("rejects line out of range (too high)", () => {
|
||||
it("rejects line out of range (too high)", () => {
|
||||
const lines = ["hello"];
|
||||
const hash = computeLineHash(1, "hello");
|
||||
expect(() => validateLineRef({ line: 2, hash }, lines)).toThrow(/does not exist/);
|
||||
});
|
||||
|
||||
test("rejects line out of range (zero)", () => {
|
||||
it("rejects line out of range (zero)", () => {
|
||||
const lines = ["hello"];
|
||||
expect(() => validateLineRef({ line: 0, hash: "aaaa" }, lines)).toThrow(/does not exist/);
|
||||
});
|
||||
|
||||
test("rejects mismatched hash", () => {
|
||||
it("rejects mismatched hash", () => {
|
||||
const lines = ["hello", "world"];
|
||||
expect(() => validateLineRef({ line: 1, hash: "0000" }, lines)).toThrow(/has changed since last read/);
|
||||
});
|
||||
|
||||
test("validates last line correctly", () => {
|
||||
it("validates last line correctly", () => {
|
||||
const lines = ["a", "b", "c"];
|
||||
const hash = computeLineHash(3, "c");
|
||||
expect(() => validateLineRef({ line: 3, hash }, lines)).not.toThrow();
|
||||
@@ -226,31 +229,29 @@ describe("validateLineRef", () => {
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("applyHashlineEdits — replace", () => {
|
||||
function makeRef(lineNum: number, content: string): string {
|
||||
return `${lineNum}#${computeLineHash(lineNum, content)}`;
|
||||
}
|
||||
|
||||
test("replaces single line", () => {
|
||||
it("replaces single line", () => {
|
||||
const content = "aaa\nbbb\nccc";
|
||||
const edits: HashlineEdit[] = [{ set: { ref: makeRef(2, "bbb"), body: ["BBB"] } }];
|
||||
const edits: HashlineEdit[] = [{ op: "set", tag: makeTag(2, "bbb"), content: ["BBB"] }];
|
||||
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.content).toBe("aaa\nBBB\nccc");
|
||||
expect(result.firstChangedLine).toBe(2);
|
||||
});
|
||||
|
||||
test("range replace (shrink)", () => {
|
||||
it("range replace (shrink)", () => {
|
||||
const content = "aaa\nbbb\nccc\nddd";
|
||||
const edits: HashlineEdit[] = [{ set_range: { beg: makeRef(2, "bbb"), end: makeRef(3, "ccc"), body: ["ONE"] } }];
|
||||
const edits: HashlineEdit[] = [
|
||||
{ op: "replace", first: makeTag(2, "bbb"), last: makeTag(3, "ccc"), content: ["ONE"] },
|
||||
];
|
||||
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.content).toBe("aaa\nONE\nddd");
|
||||
});
|
||||
|
||||
test("range replace (same count)", () => {
|
||||
it("range replace (same count)", () => {
|
||||
const content = "aaa\nbbb\nccc\nddd";
|
||||
const edits: HashlineEdit[] = [
|
||||
{ set_range: { beg: makeRef(2, "bbb"), end: makeRef(3, "ccc"), body: ["XXX", "YYY"] } },
|
||||
{ op: "replace", first: makeTag(2, "bbb"), last: makeTag(3, "ccc"), content: ["XXX", "YYY"] },
|
||||
];
|
||||
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
@@ -258,18 +259,18 @@ describe("applyHashlineEdits — replace", () => {
|
||||
expect(result.firstChangedLine).toBe(2);
|
||||
});
|
||||
|
||||
test("replaces first line", () => {
|
||||
it("replaces first line", () => {
|
||||
const content = "first\nsecond\nthird";
|
||||
const edits: HashlineEdit[] = [{ set: { ref: makeRef(1, "first"), body: ["FIRST"] } }];
|
||||
const edits: HashlineEdit[] = [{ op: "set", tag: makeTag(1, "first"), content: ["FIRST"] }];
|
||||
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.content).toBe("FIRST\nsecond\nthird");
|
||||
expect(result.firstChangedLine).toBe(1);
|
||||
});
|
||||
|
||||
test("replaces last line", () => {
|
||||
it("replaces last line", () => {
|
||||
const content = "first\nsecond\nthird";
|
||||
const edits: HashlineEdit[] = [{ set: { ref: makeRef(3, "third"), body: ["THIRD"] } }];
|
||||
const edits: HashlineEdit[] = [{ op: "set", tag: makeTag(3, "third"), content: ["THIRD"] }];
|
||||
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.content).toBe("first\nsecond\nTHIRD");
|
||||
@@ -282,38 +283,34 @@ describe("applyHashlineEdits — replace", () => {
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("applyHashlineEdits — delete", () => {
|
||||
function makeRef(lineNum: number, content: string): string {
|
||||
return `${lineNum}#${computeLineHash(lineNum, content)}`;
|
||||
}
|
||||
|
||||
test("deletes single line", () => {
|
||||
it("deletes single line", () => {
|
||||
const content = "aaa\nbbb\nccc";
|
||||
const edits: HashlineEdit[] = [{ set: { ref: makeRef(2, "bbb"), body: [] } }];
|
||||
const edits: HashlineEdit[] = [{ op: "set", tag: makeTag(2, "bbb"), content: [] }];
|
||||
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.content).toBe("aaa\nccc");
|
||||
expect(result.firstChangedLine).toBe(2);
|
||||
});
|
||||
|
||||
test("deletes range of lines", () => {
|
||||
it("deletes range of lines", () => {
|
||||
const content = "aaa\nbbb\nccc\nddd";
|
||||
const edits: HashlineEdit[] = [{ set_range: { beg: makeRef(2, "bbb"), end: makeRef(3, "ccc"), body: [] } }];
|
||||
const edits: HashlineEdit[] = [{ op: "replace", first: makeTag(2, "bbb"), last: makeTag(3, "ccc"), content: [] }];
|
||||
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.content).toBe("aaa\nddd");
|
||||
});
|
||||
|
||||
test("deletes first line", () => {
|
||||
it("deletes first line", () => {
|
||||
const content = "aaa\nbbb\nccc";
|
||||
const edits: HashlineEdit[] = [{ set: { ref: makeRef(1, "aaa"), body: [] } }];
|
||||
const edits: HashlineEdit[] = [{ op: "set", tag: makeTag(1, "aaa"), content: [] }];
|
||||
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.content).toBe("bbb\nccc");
|
||||
});
|
||||
|
||||
test("deletes last line", () => {
|
||||
it("deletes last line", () => {
|
||||
const content = "aaa\nbbb\nccc";
|
||||
const edits: HashlineEdit[] = [{ set: { ref: makeRef(3, "ccc"), body: [] } }];
|
||||
const edits: HashlineEdit[] = [{ op: "set", tag: makeTag(3, "ccc"), content: [] }];
|
||||
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.content).toBe("aaa\nbbb");
|
||||
@@ -325,63 +322,59 @@ describe("applyHashlineEdits — delete", () => {
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("applyHashlineEdits — insert", () => {
|
||||
function makeRef(lineNum: number, content: string): string {
|
||||
return `${lineNum}#${computeLineHash(lineNum, content)}`;
|
||||
}
|
||||
|
||||
test("inserts after a line", () => {
|
||||
it("inserts after a line", () => {
|
||||
const content = "aaa\nbbb\nccc";
|
||||
const edits: HashlineEdit[] = [{ insert: { after: makeRef(1, "aaa"), body: ["NEW"] } }];
|
||||
const edits: HashlineEdit[] = [{ op: "append", after: makeTag(1, "aaa"), content: ["NEW"] }];
|
||||
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.content).toBe("aaa\nNEW\nbbb\nccc");
|
||||
expect(result.firstChangedLine).toBe(2);
|
||||
});
|
||||
|
||||
test("inserts multiple lines", () => {
|
||||
it("inserts multiple lines", () => {
|
||||
const content = "aaa\nbbb";
|
||||
const edits: HashlineEdit[] = [{ insert: { after: makeRef(1, "aaa"), body: ["x", "y", "z"] } }];
|
||||
const edits: HashlineEdit[] = [{ op: "append", after: makeTag(1, "aaa"), content: ["x", "y", "z"] }];
|
||||
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.content).toBe("aaa\nx\ny\nz\nbbb");
|
||||
});
|
||||
|
||||
test("inserts after last line", () => {
|
||||
it("inserts after last line", () => {
|
||||
const content = "aaa\nbbb";
|
||||
const edits: HashlineEdit[] = [{ insert: { after: makeRef(2, "bbb"), body: ["NEW"] } }];
|
||||
const edits: HashlineEdit[] = [{ op: "append", after: makeTag(2, "bbb"), content: ["NEW"] }];
|
||||
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.content).toBe("aaa\nbbb\nNEW");
|
||||
});
|
||||
|
||||
test("insert with empty dst throws", () => {
|
||||
it("insert with empty dst throws", () => {
|
||||
const content = "aaa\nbbb";
|
||||
const edits: HashlineEdit[] = [{ insert: { after: makeRef(1, "aaa"), body: [] } }];
|
||||
const edits: HashlineEdit[] = [{ op: "append", after: makeTag(1, "aaa"), content: [] }];
|
||||
|
||||
expect(() => applyHashlineEdits(content, edits)).toThrow();
|
||||
});
|
||||
|
||||
test("inserts at EOF without anchors", () => {
|
||||
it("inserts at EOF without anchors", () => {
|
||||
const content = "aaa\nbbb";
|
||||
const edits: HashlineEdit[] = [{ insert: { body: ["NEW"] } }];
|
||||
const edits = [{ op: "append", content: ["NEW"] }] as unknown as HashlineEdit[];
|
||||
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.content).toBe("aaa\nbbb\nNEW");
|
||||
expect(result.firstChangedLine).toBe(3);
|
||||
});
|
||||
|
||||
test("inserts at EOF into empty file without anchors", () => {
|
||||
it("inserts at EOF into empty file without anchors", () => {
|
||||
const content = "";
|
||||
const edits: HashlineEdit[] = [{ insert: { body: ["NEW"] } }];
|
||||
const edits = [{ op: "append", content: ["NEW"] }] as unknown as HashlineEdit[];
|
||||
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.content).toBe("NEW");
|
||||
expect(result.firstChangedLine).toBe(1);
|
||||
});
|
||||
|
||||
test("insert at EOF with empty dst throws", () => {
|
||||
it("insert at EOF with empty dst throws", () => {
|
||||
const content = "aaa\nbbb";
|
||||
const edits: HashlineEdit[] = [{ insert: { body: [] } }];
|
||||
const edits = [{ op: "append", content: [] }] as unknown as HashlineEdit[];
|
||||
|
||||
expect(() => applyHashlineEdits(content, edits)).toThrow();
|
||||
});
|
||||
@@ -392,62 +385,66 @@ describe("applyHashlineEdits — insert", () => {
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("applyHashlineEdits — insert (before)", () => {
|
||||
function makeRef(lineNum: number, content: string): string {
|
||||
return `${lineNum}#${computeLineHash(lineNum, content)}`;
|
||||
}
|
||||
|
||||
test("inserts before a line", () => {
|
||||
it("inserts before a line", () => {
|
||||
const content = "aaa\nbbb\nccc";
|
||||
const edits: HashlineEdit[] = [{ insert: { before: makeRef(2, "bbb"), body: ["NEW"] } }];
|
||||
const edits: HashlineEdit[] = [{ op: "prepend", before: makeTag(2, "bbb"), content: ["NEW"] }];
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.content).toBe("aaa\nNEW\nbbb\nccc");
|
||||
expect(result.firstChangedLine).toBe(2);
|
||||
});
|
||||
|
||||
test("inserts multiple lines before", () => {
|
||||
it("inserts multiple lines before", () => {
|
||||
const content = "aaa\nbbb";
|
||||
const edits: HashlineEdit[] = [{ insert: { before: makeRef(2, "bbb"), body: ["x", "y", "z"] } }];
|
||||
const edits: HashlineEdit[] = [{ op: "prepend", before: makeTag(2, "bbb"), content: ["x", "y", "z"] }];
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.content).toBe("aaa\nx\ny\nz\nbbb");
|
||||
});
|
||||
|
||||
test("inserts before first line", () => {
|
||||
it("inserts before first line", () => {
|
||||
const content = "aaa\nbbb";
|
||||
const edits: HashlineEdit[] = [{ insert: { before: makeRef(1, "aaa"), body: ["NEW"] } }];
|
||||
const edits: HashlineEdit[] = [{ op: "prepend", before: makeTag(1, "aaa"), content: ["NEW"] }];
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.content).toBe("NEW\naaa\nbbb");
|
||||
});
|
||||
|
||||
test("insert with before and empty text throws", () => {
|
||||
it("prepends at BOF without anchor", () => {
|
||||
const content = "aaa\nbbb";
|
||||
const edits: HashlineEdit[] = [{ insert: { before: makeRef(1, "aaa"), body: [] } }];
|
||||
const edits = [{ op: "prepend", content: ["NEW"] }] as unknown as HashlineEdit[];
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.content).toBe("NEW\naaa\nbbb");
|
||||
expect(result.firstChangedLine).toBe(1);
|
||||
});
|
||||
|
||||
it("insert with before and empty text throws", () => {
|
||||
const content = "aaa\nbbb";
|
||||
const edits: HashlineEdit[] = [{ op: "prepend", before: makeTag(1, "aaa"), content: [] }];
|
||||
expect(() => applyHashlineEdits(content, edits)).toThrow();
|
||||
});
|
||||
|
||||
test("strips anchor echo from end of inserted text (autocorrect)", () => {
|
||||
it("strips anchor echo from end of inserted text (autocorrect)", () => {
|
||||
Bun.env.PI_HL_AUTOCORRECT = "1";
|
||||
const content = "aaa\nbbb\nccc";
|
||||
const edits: HashlineEdit[] = [{ insert: { before: makeRef(2, "bbb"), body: ["NEW", "bbb"] } }];
|
||||
const edits: HashlineEdit[] = [{ op: "prepend", before: makeTag(2, "bbb"), content: ["NEW", "bbb"] }];
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.content).toBe("aaa\nNEW\nbbb\nccc");
|
||||
});
|
||||
Bun.env.PI_HL_AUTOCORRECT = undefined;
|
||||
|
||||
test("insert before and insert after at same line produce correct order", () => {
|
||||
it("insert before and insert after at same line produce correct order", () => {
|
||||
const content = "aaa\nbbb\nccc";
|
||||
const edits: HashlineEdit[] = [
|
||||
{ insert: { before: makeRef(2, "bbb"), body: ["BEFORE"] } },
|
||||
{ insert: { after: makeRef(2, "bbb"), body: ["AFTER"] } },
|
||||
{ op: "prepend", before: makeTag(2, "bbb"), content: ["BEFORE"] },
|
||||
{ op: "append", after: makeTag(2, "bbb"), content: ["AFTER"] },
|
||||
];
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.content).toBe("aaa\nBEFORE\nbbb\nAFTER\nccc");
|
||||
});
|
||||
|
||||
test("insert before with set at same line", () => {
|
||||
it("insert before with set at same line", () => {
|
||||
const content = "aaa\nbbb\nccc";
|
||||
const edits: HashlineEdit[] = [
|
||||
{ insert: { before: makeRef(2, "bbb"), body: ["BEFORE"] } },
|
||||
{ set: { ref: makeRef(2, "bbb"), body: ["BBB"] } },
|
||||
{ op: "prepend", before: makeTag(2, "bbb"), content: ["BEFORE"] },
|
||||
{ op: "set", tag: makeTag(2, "bbb"), content: ["BBB"] },
|
||||
];
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.content).toBe("aaa\nBEFORE\nBBB\nccc");
|
||||
@@ -459,45 +456,39 @@ describe("applyHashlineEdits — insert (before)", () => {
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("applyHashlineEdits — insert (between)", () => {
|
||||
function makeRef(lineNum: number, content: string): string {
|
||||
return `${lineNum}#${computeLineHash(lineNum, content)}`;
|
||||
}
|
||||
|
||||
test("inserts between adjacent anchors", () => {
|
||||
it("inserts between adjacent anchors", () => {
|
||||
const content = "aaa\nbbb\nccc";
|
||||
const edits: HashlineEdit[] = [
|
||||
{ insert: { after: makeRef(1, "aaa"), before: makeRef(2, "bbb"), body: ["NEW"] } },
|
||||
{ op: "insert", after: makeTag(1, "aaa"), before: makeTag(2, "bbb"), content: ["NEW"] },
|
||||
];
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.content).toBe("aaa\nNEW\nbbb\nccc");
|
||||
expect(result.firstChangedLine).toBe(2);
|
||||
});
|
||||
|
||||
test("inserts multiple lines between anchors", () => {
|
||||
it("inserts multiple lines between anchors", () => {
|
||||
const content = "aaa\nbbb\nccc";
|
||||
const edits: HashlineEdit[] = [
|
||||
{
|
||||
insert: {
|
||||
after: makeRef(1, "aaa"),
|
||||
before: makeRef(2, "bbb"),
|
||||
body: ["x", "y", "z"],
|
||||
},
|
||||
op: "insert",
|
||||
after: makeTag(1, "aaa"),
|
||||
before: makeTag(2, "bbb"),
|
||||
content: ["x", "y", "z"],
|
||||
},
|
||||
];
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.content).toBe("aaa\nx\ny\nz\nbbb\nccc");
|
||||
});
|
||||
|
||||
test("strips boundary echo from both sides (autocorrect)", () => {
|
||||
it("strips boundary echo from both sides (autocorrect)", () => {
|
||||
Bun.env.PI_HL_AUTOCORRECT = "1";
|
||||
const content = "aaa\nbbb\nccc";
|
||||
const edits: HashlineEdit[] = [
|
||||
{
|
||||
insert: {
|
||||
after: makeRef(1, "aaa"),
|
||||
before: makeRef(2, "bbb"),
|
||||
body: ["aaa", "NEW", "bbb"],
|
||||
},
|
||||
op: "insert",
|
||||
after: makeTag(1, "aaa"),
|
||||
before: makeTag(2, "bbb"),
|
||||
content: ["aaa", "NEW", "bbb"],
|
||||
},
|
||||
];
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
@@ -520,19 +511,15 @@ describe("applyHashlineEdits — heuristics", () => {
|
||||
Bun.env.PI_HL_AUTOCORRECT = origEnv;
|
||||
});
|
||||
|
||||
function makeRef(lineNum: number, content: string): string {
|
||||
return `${lineNum}#${computeLineHash(lineNum, content)}`;
|
||||
}
|
||||
|
||||
test("strips insert-after anchor echo", () => {
|
||||
it("strips insert-after anchor echo", () => {
|
||||
const content = "aaa\nbbb\nccc";
|
||||
const edits: HashlineEdit[] = [{ insert: { after: makeRef(2, "bbb"), body: ["bbb", "NEW"] } }];
|
||||
const edits: HashlineEdit[] = [{ op: "append", after: makeTag(2, "bbb"), content: ["bbb", "NEW"] }];
|
||||
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.content).toBe("aaa\nbbb\nNEW\nccc");
|
||||
});
|
||||
|
||||
test("strips range boundary echo and preserves whitespace on unchanged lines", () => {
|
||||
it("strips range boundary echo and preserves whitespace on unchanged lines", () => {
|
||||
const content = [
|
||||
"import { foo } from 'x';",
|
||||
"if (cond) {",
|
||||
@@ -547,12 +534,11 @@ describe("applyHashlineEdits — heuristics", () => {
|
||||
const end = 6;
|
||||
const edits: HashlineEdit[] = [
|
||||
{
|
||||
set_range: {
|
||||
beg: makeRef(start, "if (cond) {"),
|
||||
end: makeRef(end, "}"),
|
||||
// Echoes line after the range ("after();") and also reformats the import line.
|
||||
body: ["if (cond) {", " doA();", "} else {", " doB();", "}", "after();"],
|
||||
},
|
||||
op: "replace",
|
||||
first: makeTag(start, "if (cond) {"),
|
||||
last: makeTag(end, "}"),
|
||||
// Echoes line after the range ("after();") and also reformats the import line.
|
||||
content: ["if (cond) {", " doA();", "} else {", " doB();", "}", "after();"],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -562,15 +548,14 @@ describe("applyHashlineEdits — heuristics", () => {
|
||||
expect(result.content).toBe(content);
|
||||
});
|
||||
|
||||
test("does not override model whitespace choices in replacement content", () => {
|
||||
it("does not override model whitespace choices in replacement content", () => {
|
||||
const content = ["import { foo } from 'x';", "import { bar } from 'y';", "const x = 1;"].join("\n");
|
||||
const edits: HashlineEdit[] = [
|
||||
{
|
||||
set_range: {
|
||||
beg: makeRef(1, "import { foo } from 'x';"),
|
||||
end: makeRef(2, "import { bar } from 'y';"),
|
||||
body: ["import {foo} from 'x';", "import { bar } from 'y';", "// added"],
|
||||
},
|
||||
op: "replace",
|
||||
first: makeTag(1, "import { foo } from 'x';"),
|
||||
last: makeTag(2, "import { bar } from 'y';"),
|
||||
content: ["import {foo} from 'x';", "import { bar } from 'y';", "// added"],
|
||||
},
|
||||
];
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
@@ -582,27 +567,26 @@ describe("applyHashlineEdits — heuristics", () => {
|
||||
expect(outLines[3]).toBe("const x = 1;");
|
||||
});
|
||||
|
||||
test("restores a long wrapped line when model reflows it across many lines", () => {
|
||||
it("restores a long wrapped line when model reflows it across many lines", () => {
|
||||
const longLine =
|
||||
"const options = veryLongIdentifier + anotherLongIdentifier + thirdLongIdentifier + fourthLongIdentifier;";
|
||||
const content = ["before();", longLine, "after();"].join("\n");
|
||||
const edits: HashlineEdit[] = [
|
||||
{
|
||||
set: {
|
||||
ref: makeRef(2, longLine),
|
||||
body: [
|
||||
"const",
|
||||
"options",
|
||||
"=",
|
||||
"veryLongIdentifier",
|
||||
"+",
|
||||
"anotherLongIdentifier",
|
||||
"+",
|
||||
"thirdLongIdentifier",
|
||||
"+",
|
||||
"fourthLongIdentifier;",
|
||||
],
|
||||
},
|
||||
op: "set",
|
||||
tag: makeTag(2, longLine),
|
||||
content: [
|
||||
"const",
|
||||
"options",
|
||||
"=",
|
||||
"veryLongIdentifier",
|
||||
"+",
|
||||
"anotherLongIdentifier",
|
||||
"+",
|
||||
"thirdLongIdentifier",
|
||||
"+",
|
||||
"fourthLongIdentifier;",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -610,18 +594,17 @@ describe("applyHashlineEdits — heuristics", () => {
|
||||
expect(result.content).toBe(content);
|
||||
});
|
||||
|
||||
test("repairs single-line replacement that absorbed the next line (prevents duplication)", () => {
|
||||
it("repairs single-line replacement that absorbed the next line (prevents duplication)", () => {
|
||||
const content = [" typeof HOOK === 'undefined' &&", " typeof HOOK.checkDCE !== 'function'", "tail();"].join(
|
||||
"\n",
|
||||
);
|
||||
|
||||
const edits: HashlineEdit[] = [
|
||||
{
|
||||
set: {
|
||||
ref: makeRef(1, " typeof HOOK === 'undefined' &&"),
|
||||
// Model merged both lines into one and dropped indentation.
|
||||
body: ["typeof HOOK === 'undefined' || typeof HOOK.checkDCE !== 'function'"],
|
||||
},
|
||||
op: "set",
|
||||
tag: makeTag(1, " typeof HOOK === 'undefined' &&"),
|
||||
// Model merged both lines into one and dropped indentation.
|
||||
content: ["typeof HOOK === 'undefined' || typeof HOOK.checkDCE !== 'function'"],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -631,7 +614,7 @@ describe("applyHashlineEdits — heuristics", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("repairs single-line replacement that absorbed the previous line (prevents duplication)", () => {
|
||||
it("repairs single-line replacement that absorbed the previous line (prevents duplication)", () => {
|
||||
const content = [
|
||||
" const nativeStyleResolver: ResolveNativeStyle | void =",
|
||||
" resolveRNStyle || hook.resolveRNStyle;",
|
||||
@@ -640,11 +623,10 @@ describe("applyHashlineEdits — heuristics", () => {
|
||||
|
||||
const edits: HashlineEdit[] = [
|
||||
{
|
||||
set: {
|
||||
ref: makeRef(2, " resolveRNStyle || hook.resolveRNStyle;"),
|
||||
// Model absorbed the declaration line and dropped indentation.
|
||||
body: ["const nativeStyleResolver: ResolveNativeStyle | void = resolveRNStyle ?? hook.resolveRNStyle;"],
|
||||
},
|
||||
op: "set",
|
||||
tag: makeTag(2, " resolveRNStyle || hook.resolveRNStyle;"),
|
||||
// Model absorbed the declaration line and dropped indentation.
|
||||
content: ["const nativeStyleResolver: ResolveNativeStyle | void = resolveRNStyle ?? hook.resolveRNStyle;"],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -657,15 +639,14 @@ describe("applyHashlineEdits — heuristics", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("accepts polluted src that starts with LINE#ID but includes trailing content", () => {
|
||||
it("accepts polluted src that starts with LINE#ID but includes trailing content", () => {
|
||||
const content = "aaa\nbbb\nccc";
|
||||
const srcHash = computeLineHash(2, "bbb");
|
||||
const edits: HashlineEdit[] = [
|
||||
{
|
||||
set: {
|
||||
ref: `2#${srcHash}export function foo(a, b) {}`, // comma in trailing content
|
||||
body: ["BBB"],
|
||||
},
|
||||
op: "set",
|
||||
tag: parseTag(`2#${srcHash}export function foo(a, b) {}`), // comma in trailing content
|
||||
content: ["BBB"],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -673,29 +654,13 @@ describe("applyHashlineEdits — heuristics", () => {
|
||||
expect(result.content).toBe("aaa\nBBB\nccc");
|
||||
});
|
||||
|
||||
test("treats same-line ranges as single-line replacements", () => {
|
||||
it("treats same-line ranges as single-line replacements", () => {
|
||||
const content = "aaa\nbbb\nccc";
|
||||
const good = makeRef(2, "bbb");
|
||||
const edits: HashlineEdit[] = [{ set_range: { beg: good, end: good, body: ["BBB"] } }];
|
||||
const good = makeTag(2, "bbb");
|
||||
const edits: HashlineEdit[] = [{ op: "replace", first: good, last: good, content: ["BBB"] }];
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.content).toBe("aaa\nBBB\nccc");
|
||||
});
|
||||
|
||||
test("normalizes unicode-confusable hyphens when an edit would otherwise be a no-op", () => {
|
||||
const content = "aaa\ndevtools–unsupported-bridge-protocol\nccc";
|
||||
// dst is byte-identical to original (en-dash), so this would normally be a no-op.
|
||||
const edits: HashlineEdit[] = [
|
||||
{
|
||||
set: {
|
||||
ref: makeRef(2, "devtools–unsupported-bridge-protocol"),
|
||||
body: ["devtools–unsupported-bridge-protocol"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.content).toBe("aaa\ndevtools-unsupported-bridge-protocol\nccc");
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -703,15 +668,11 @@ describe("applyHashlineEdits — heuristics", () => {
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("applyHashlineEdits — multiple edits", () => {
|
||||
function makeRef(lineNum: number, content: string): string {
|
||||
return `${lineNum}#${computeLineHash(lineNum, content)}`;
|
||||
}
|
||||
|
||||
test("applies two non-overlapping replaces (bottom-up safe)", () => {
|
||||
it("applies two non-overlapping replaces (bottom-up safe)", () => {
|
||||
const content = "aaa\nbbb\nccc\nddd\neee";
|
||||
const edits: HashlineEdit[] = [
|
||||
{ set: { ref: makeRef(2, "bbb"), body: ["BBB"] } },
|
||||
{ set: { ref: makeRef(4, "ddd"), body: ["DDD"] } },
|
||||
{ op: "set", tag: makeTag(2, "bbb"), content: ["BBB"] },
|
||||
{ op: "set", tag: makeTag(4, "ddd"), content: ["DDD"] },
|
||||
];
|
||||
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
@@ -719,46 +680,45 @@ describe("applyHashlineEdits — multiple edits", () => {
|
||||
expect(result.firstChangedLine).toBe(2);
|
||||
});
|
||||
|
||||
test("applies replace + delete in one call", () => {
|
||||
it("applies replace + delete in one call", () => {
|
||||
const content = "aaa\nbbb\nccc\nddd";
|
||||
const edits: HashlineEdit[] = [
|
||||
{ set: { ref: makeRef(2, "bbb"), body: ["BBB"] } },
|
||||
{ set: { ref: makeRef(4, "ddd"), body: [] } },
|
||||
{ op: "set", tag: makeTag(2, "bbb"), content: ["BBB"] },
|
||||
{ op: "set", tag: makeTag(4, "ddd"), content: [] },
|
||||
];
|
||||
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.content).toBe("aaa\nBBB\nccc");
|
||||
});
|
||||
|
||||
test("applies replace + insert in one call", () => {
|
||||
it("applies replace + insert in one call", () => {
|
||||
const content = "aaa\nbbb\nccc";
|
||||
const edits: HashlineEdit[] = [
|
||||
{ set: { ref: makeRef(3, "ccc"), body: ["CCC"] } },
|
||||
{ insert: { after: makeRef(1, "aaa"), body: ["INSERTED"] } },
|
||||
{ op: "set", tag: makeTag(3, "ccc"), content: ["CCC"] },
|
||||
{ op: "append", after: makeTag(1, "aaa"), content: ["INSERTED"] },
|
||||
];
|
||||
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.content).toBe("aaa\nINSERTED\nbbb\nCCC");
|
||||
});
|
||||
|
||||
test("applies non-overlapping edits against original anchors when line counts change", () => {
|
||||
it("applies non-overlapping edits against original anchors when line counts change", () => {
|
||||
const content = "one\ntwo\nthree\nfour\nfive\nsix";
|
||||
const edits: HashlineEdit[] = [
|
||||
{
|
||||
set_range: {
|
||||
beg: makeRef(2, "two"),
|
||||
end: makeRef(3, "three"),
|
||||
body: ["TWO_THREE"],
|
||||
},
|
||||
op: "replace",
|
||||
first: makeTag(2, "two"),
|
||||
last: makeTag(3, "three"),
|
||||
content: ["TWO_THREE"],
|
||||
},
|
||||
{ set: { ref: makeRef(6, "six"), body: ["SIX"] } },
|
||||
{ op: "set", tag: makeTag(6, "six"), content: ["SIX"] },
|
||||
];
|
||||
|
||||
const result = applyHashlineEdits(content, edits);
|
||||
expect(result.content).toBe("one\nTWO_THREE\nfour\nfive\nSIX");
|
||||
});
|
||||
|
||||
test("empty edits array is a no-op", () => {
|
||||
it("empty edits array is a no-op", () => {
|
||||
const content = "aaa\nbbb";
|
||||
const result = applyHashlineEdits(content, []);
|
||||
expect(result.content).toBe(content);
|
||||
@@ -771,20 +731,16 @@ describe("applyHashlineEdits — multiple edits", () => {
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("applyHashlineEdits — errors", () => {
|
||||
function makeRef(lineNum: number, content: string): string {
|
||||
return `${lineNum}#${computeLineHash(lineNum, content)}`;
|
||||
}
|
||||
|
||||
test("rejects stale hash", () => {
|
||||
it("rejects stale hash", () => {
|
||||
const content = "aaa\nbbb\nccc";
|
||||
// Use a hash that doesn't match any line (avoid 00 — ccc hashes to 00)
|
||||
const edits: HashlineEdit[] = [{ set: { ref: "2#zz", body: ["BBB"] } }];
|
||||
const edits: HashlineEdit[] = [{ op: "set", tag: parseTag("2#QQ"), content: ["BBB"] }];
|
||||
expect(() => applyHashlineEdits(content, edits)).toThrow(HashlineMismatchError);
|
||||
});
|
||||
|
||||
test("stale hash error shows >>> markers with correct hashes", () => {
|
||||
it("stale hash error shows >>> markers with correct hashes", () => {
|
||||
const content = "aaa\nbbb\nccc\nddd\neee";
|
||||
const edits: HashlineEdit[] = [{ set: { ref: "2#zz", body: ["BBB"] } }];
|
||||
const edits: HashlineEdit[] = [{ op: "set", tag: parseTag("2#QQ"), content: ["BBB"] }];
|
||||
|
||||
try {
|
||||
applyHashlineEdits(content, edits);
|
||||
@@ -796,7 +752,7 @@ describe("applyHashlineEdits — errors", () => {
|
||||
expect(msg).toContain(">>>");
|
||||
// Should show the correct hash for line 2
|
||||
const correctHash = computeLineHash(2, "bbb");
|
||||
expect(msg).toContain(`2#${correctHash}|bbb`);
|
||||
expect(msg).toContain(`2#${correctHash}:bbb`);
|
||||
// Context lines should NOT have >>> markers
|
||||
const lines = msg.split("\n");
|
||||
const contextLines = lines.filter(l => l.startsWith(" ") && !l.startsWith(" ...") && l.includes("#"));
|
||||
@@ -804,10 +760,13 @@ describe("applyHashlineEdits — errors", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("stale hash error collects all mismatches", () => {
|
||||
it("stale hash error collects all mismatches", () => {
|
||||
const content = "aaa\nbbb\nccc\nddd\neee";
|
||||
// Use hashes that don't match any line (avoid 00 — ccc hashes to 00)
|
||||
const edits: HashlineEdit[] = [{ set: { ref: "2#zz", body: ["BBB"] } }, { set: { ref: "4#zz", body: ["DDD"] } }];
|
||||
const edits: HashlineEdit[] = [
|
||||
{ op: "set", tag: parseTag("2#ZZ"), content: ["BBB"] },
|
||||
{ op: "set", tag: parseTag("4#ZZ"), content: ["DDD"] },
|
||||
];
|
||||
|
||||
try {
|
||||
applyHashlineEdits(content, edits);
|
||||
@@ -824,10 +783,10 @@ describe("applyHashlineEdits — errors", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("does not relocate stale line refs even when hash uniquely matches another line", () => {
|
||||
it("does not relocate stale line refs even when hash uniquely matches another line", () => {
|
||||
const content = "aaa\nbbb\nccc";
|
||||
const staleButUnique = `2#${computeLineHash(1, "ccc")}`;
|
||||
const edits: HashlineEdit[] = [{ set: { ref: staleButUnique, body: ["CCC"] } }];
|
||||
const staleButUnique = parseTag(`2#${computeLineHash(1, "ccc")}`);
|
||||
const edits: HashlineEdit[] = [{ op: "set", tag: staleButUnique, content: ["CCC"] }];
|
||||
try {
|
||||
applyHashlineEdits(content, edits);
|
||||
expect.unreachable("should have thrown");
|
||||
@@ -838,59 +797,56 @@ describe("applyHashlineEdits — errors", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("does not relocate when expected hash is non-unique", () => {
|
||||
it("does not relocate when expected hash is non-unique", () => {
|
||||
const content = "dup\nmid\ndup";
|
||||
const staleDuplicate = `2#${computeLineHash(1, "dup")}`;
|
||||
const edits: HashlineEdit[] = [{ set: { ref: staleDuplicate, body: ["DUP"] } }];
|
||||
const staleDuplicate = parseTag(`2#${computeLineHash(1, "dup")}`);
|
||||
const edits: HashlineEdit[] = [{ op: "set", tag: staleDuplicate, content: ["DUP"] }];
|
||||
|
||||
expect(() => applyHashlineEdits(content, edits)).toThrow(HashlineMismatchError);
|
||||
});
|
||||
|
||||
test("rejects out-of-range line", () => {
|
||||
it("rejects out-of-range line", () => {
|
||||
const content = "aaa\nbbb";
|
||||
const edits: HashlineEdit[] = [{ set: { ref: "10#aa", body: ["X"] } }];
|
||||
const edits: HashlineEdit[] = [{ op: "set", tag: parseTag("10#ZZ"), content: ["X"] }];
|
||||
|
||||
expect(() => applyHashlineEdits(content, edits)).toThrow(/does not exist/);
|
||||
});
|
||||
|
||||
test("rejects range with start > end", () => {
|
||||
it("rejects range with start > end", () => {
|
||||
const content = "aaa\nbbb\nccc\nddd\neee";
|
||||
const edits: HashlineEdit[] = [{ set_range: { beg: makeRef(5, "eee"), end: makeRef(2, "bbb"), body: ["X"] } }];
|
||||
const edits: HashlineEdit[] = [
|
||||
{ op: "replace", first: makeTag(5, "eee"), last: makeTag(2, "bbb"), content: ["X"] },
|
||||
];
|
||||
|
||||
expect(() => applyHashlineEdits(content, edits)).toThrow();
|
||||
});
|
||||
|
||||
test("rejects insert with after and empty text", () => {
|
||||
it("rejects insert with after and empty text", () => {
|
||||
const content = "aaa\nbbb";
|
||||
const edits: HashlineEdit[] = [{ insert: { after: makeRef(1, "aaa"), body: [] } }];
|
||||
const edits: HashlineEdit[] = [{ op: "append", after: makeTag(1, "aaa"), content: [] }];
|
||||
|
||||
expect(() => applyHashlineEdits(content, edits)).toThrow();
|
||||
});
|
||||
|
||||
test("rejects insert with before and empty text", () => {
|
||||
it("rejects insert with before and empty text", () => {
|
||||
const content = "aaa\nbbb";
|
||||
const edits: HashlineEdit[] = [{ insert: { before: makeRef(1, "aaa"), body: [] } }];
|
||||
const edits: HashlineEdit[] = [{ op: "prepend", before: makeTag(1, "aaa"), content: [] }];
|
||||
expect(() => applyHashlineEdits(content, edits)).toThrow();
|
||||
});
|
||||
|
||||
test("rejects insert with both anchors and empty text", () => {
|
||||
it("rejects insert with both anchors and empty text", () => {
|
||||
const content = "aaa\nbbb";
|
||||
const edits: HashlineEdit[] = [{ insert: { after: makeRef(1, "aaa"), before: makeRef(2, "bbb"), body: [] } }];
|
||||
const edits: HashlineEdit[] = [
|
||||
{ op: "insert", after: makeTag(1, "aaa"), before: makeTag(2, "bbb"), content: [] },
|
||||
];
|
||||
expect(() => applyHashlineEdits(content, edits)).toThrow();
|
||||
});
|
||||
|
||||
test("rejects insert with non-adjacent anchors", () => {
|
||||
it("rejects insert with non-adjacent anchors", () => {
|
||||
const content = "aaa\nbbb\nccc";
|
||||
const edits: HashlineEdit[] = [
|
||||
{ insert: { after: makeRef(1, "aaa"), before: makeRef(3, "ccc"), body: ["NEW"] } },
|
||||
{ op: "insert", after: makeTag(1, "aaa"), before: makeTag(3, "ccc"), content: ["NEW"] },
|
||||
];
|
||||
expect(() => applyHashlineEdits(content, edits)).toThrow(/adjacent anchors/);
|
||||
});
|
||||
|
||||
test("rejects replace edit (handled separately in EditTool)", () => {
|
||||
const content = "aaa\nbbb";
|
||||
const edits: HashlineEdit[] = [{ replace: { old_text: "aaa", new_text: "AAA" } }];
|
||||
|
||||
expect(() => applyHashlineEdits(content, edits)).toThrow(/replace edits are applied separately/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -490,7 +490,7 @@ function b() {
|
||||
|
||||
const output = getTextOutput(result);
|
||||
expect(output).toContain("1. example.txt:2");
|
||||
expect(output).toMatch(/>>\s*2.*\|match line/);
|
||||
expect(output).toMatch(/>>\s*2#[ZPMQVRWSNKTXJBYH]{2}:match line/);
|
||||
});
|
||||
|
||||
it("should respect global limit and include context lines", async () => {
|
||||
@@ -508,9 +508,9 @@ function b() {
|
||||
|
||||
const output = getTextOutput(result);
|
||||
expect(output).toContain("1. context.txt:2");
|
||||
expect(output).toMatch(/\b1.*\|before/);
|
||||
expect(output).toMatch(/>>\s*2.*\|match one/);
|
||||
expect(output).toMatch(/\b3.*\|after/);
|
||||
expect(output).toMatch(/\b1#[ZPMQVRWSNKTXJBYH]{2}:before/);
|
||||
expect(output).toMatch(/>>\s*2#[ZPMQVRWSNKTXJBYH]{2}:match one/);
|
||||
expect(output).toMatch(/\b3#[ZPMQVRWSNKTXJBYH]{2}:after/);
|
||||
expect(output).toContain("[1 matches limit reached. Use limit=2 for more]");
|
||||
// Ensure second match is not present
|
||||
expect(output).not.toContain("match two");
|
||||
@@ -669,7 +669,7 @@ describe("edit tool CRLF handling", () => {
|
||||
const hashlineEditTool = new EditTool(session);
|
||||
const result = await hashlineEditTool.execute("hashline-replace-1", {
|
||||
path: testFile,
|
||||
edits: [{ old_text: "x = 42", new_text: "x = 99" }],
|
||||
edits: [{ op: "replaceText", old_text: "x = 42", new_text: "x = 99" }],
|
||||
});
|
||||
|
||||
expect(getTextOutput(result)).toContain("Updated");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user