Files
oh-my-pi/packages/coding-agent/src/patch/hashline.ts
T
can1357 f5e2ff4d69 feat(hashline): enhanced hashline edits with substring matching, merge repair, and Unicode normalization
- Added substring-based source matching for hashline edits to support matching lines by content when hash references are unavailable.
- Added automatic detection and repair of single-line merges where models incorrectly combine multiple lines into one.
- Added normalization of Unicode-confusable hyphens to ASCII hyphens to handle model-generated variations.
- Added heuristics to restore indentation and preserve wrapped line formatting during edits.
- Relaxed comma validation in src to allow commas while rejecting inputs with multiple line references.
- Enhanced parseLineRef to accept shorter hash prefixes instead of requiring exact hash matches.
- Improved error messages for hash mismatches to provide more actionable guidance.
2026-02-10 18:10:40 +01:00

782 lines
28 KiB
TypeScript

/**
* 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 4-character
* hex hash derived from the line content and the line number (xxHash64 with the
* line number as seed, truncated to 4 hex chars).
* The combined `LINE:HASH` 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"`)
*/
import type { HashlineEdit, HashMismatch } from "./types";
// ═══════════════════════════════════════════════════════════════════════════
// Source Spec Parsing
// ═══════════════════════════════════════════════════════════════════════════
/** Parsed representation of a `HashlineEdit.src` field. */
type SrcSpec =
| { 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: "substring"; needle: string };
/**
* Parse a `HashlineEdit.src` string into a structured spec.
*
* Accepted forms:
* - `"5:ab"` — single line reference
* - `"5:ab..9:ef"` — inclusive range
* - `"5:ab.."` — insert-after marker
* - `"..5:ab"` — insert-before marker
*
* @throws Error on embedded newlines, commas, or invalid refs
*/
function parseSrc(src: string): SrcSpec {
if (src.includes("\n")) {
throw new Error(`src must not contain newlines: "${src}"`);
}
// Allow commas in `src` because some models accidentally paste file content
// after the line ref (e.g. `14:abexport function foo(a, b)`), which often
// contains commas. However, reject inputs that *look like* multiple refs.
if (/,\s*\d+:[0-9a-fA-F]/.test(src)) {
throw new Error(`Invalid src "${src}": appears to contain multiple line refs separated by commas`);
}
if (src.startsWith("..")) {
if (src.indexOf("..", 2) !== -1) {
throw new Error(`Invalid src "${src}": insert-before form must be exactly "..LINE:HASH"`);
}
return { kind: "insertBefore", before: parseLineRef(src.slice(2)) };
}
const dotIdx = src.indexOf("..");
if (dotIdx !== -1) {
const lhs = src.slice(0, dotIdx);
const rhs = src.slice(dotIdx + 2);
if (rhs === "") {
return { kind: "insertAfter", after: parseLineRef(lhs) };
}
const start = parseLineRef(lhs);
const end = parseLineRef(rhs);
// Some models attempt sub-line ranges like `347:aa..347:bb`. Hashline mode
// does not support sub-line addressing; treat same-line ranges as single-line.
if (start.line === end.line) {
return { kind: "single", ref: start };
}
return { kind: "range", start, end };
}
// Default: try as a line ref; if that fails, fall back to a constrained
// substring match (needle must be unique within the file).
try {
return { kind: "single", ref: parseLineRef(src) };
} catch {
const needle = src.trim();
if (needle.length === 0) {
throw new Error(`Invalid src "${src}": empty`);
}
return { kind: "substring", needle };
}
}
/** 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:HASH| CONTENT` */
const HASHLINE_PREFIX_RE = /^\d+:[0-9a-fA-F]{1,16}\| /;
/** Pattern matching a unified-diff `+` prefix (but not `++`) */
const DIFF_PLUS_RE = /^\+(?!\+)/;
/**
* Compare two strings ignoring all whitespace differences.
*
* Returns true when the non-whitespace characters are identical — meaning
* the only differences are in spaces, tabs, or other whitespace.
*/
function equalsIgnoringWhitespace(a: string, b: string): boolean {
// Fast path: identical strings
if (a === b) return true;
// Compare with all whitespace removed
return a.replace(/\s+/g, "") === b.replace(/\s+/g, "");
}
function stripAllWhitespace(s: string): string {
return s.replace(/\s+/g, "");
}
function stripTrailingContinuationTokens(s: string): string {
// Heuristic: models often merge a continuation line into the prior line
// while also changing the trailing operator (e.g. `&&` → `||`).
// Strip common trailing continuation tokens so we can still detect merges.
return s.replace(/(?:&&|\|\||\?\?|\?|:|=|,|\+|-|\*|\/|\.|\()\s*$/u, "");
}
function stripMergeOperatorChars(s: string): string {
// Used for merge detection when the model changes a logical operator like
// `||` → `??` while also merging adjacent lines.
return s.replace(/[|&?]/g, "");
}
function leadingWhitespace(s: string): string {
const match = s.match(/^\s*/);
return match ? match[0] : "";
}
function restoreLeadingIndent(templateLine: string, line: string): string {
if (line.length === 0) return line;
const templateIndent = leadingWhitespace(templateLine);
if (templateIndent.length === 0) return line;
const indent = leadingWhitespace(line);
if (indent.length > 0) return line;
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;
const out = new Array<string>(newLines.length);
for (let i = 0; i < newLines.length; i++) {
const restored = restoreLeadingIndent(oldLines[i], newLines[i]);
out[i] = restored;
if (restored !== newLines[i]) changed = true;
}
return changed ? out : newLines;
}
/**
* Undo pure formatting rewrites where the model reflows a single logical line
* into multiple lines (or similar), but the token stream is identical.
*/
function restoreOldWrappedLines(oldLines: string[], newLines: string[]): string[] {
if (oldLines.length === 0 || newLines.length < 2) return newLines;
const canonToOld = new Map<string, { line: string; count: number }>();
for (const line of oldLines) {
const canon = stripAllWhitespace(line);
const bucket = canonToOld.get(canon);
if (bucket) bucket.count++;
else canonToOld.set(canon, { line, count: 1 });
}
const candidates: { start: number; len: number; replacement: string; canon: string }[] = [];
for (let start = 0; start < newLines.length; start++) {
for (let len = 2; len <= 6 && start + len <= newLines.length; len++) {
const canonSpan = stripAllWhitespace(newLines.slice(start, start + len).join(""));
const old = canonToOld.get(canonSpan);
if (old && old.count === 1 && canonSpan.length >= 6) {
candidates.push({ start, len, replacement: old.line, canon: canonSpan });
}
}
}
if (candidates.length === 0) return newLines;
// Keep only spans whose canonical match is unique in the new output.
const canonCounts = new Map<string, number>();
for (const c of candidates) {
canonCounts.set(c.canon, (canonCounts.get(c.canon) ?? 0) + 1);
}
const uniqueCandidates = candidates.filter(c => (canonCounts.get(c.canon) ?? 0) === 1);
if (uniqueCandidates.length === 0) return newLines;
// Apply replacements back-to-front so indices remain stable.
uniqueCandidates.sort((a, b) => b.start - a.start);
const out = [...newLines];
for (const c of uniqueCandidates) {
out.splice(c.start, c.len, c.replacement);
}
return out;
}
/**
* For replace edits (N old → N new), preserve original content on lines where
* the only difference is whitespace.
*
* Models frequently reformat code (e.g., removing spaces inside import braces)
* when making targeted edits. This detects lines that changed only in
* whitespace and keeps the original, preventing spurious formatting diffs.
*/
function preserveWhitespaceOnlyLines(oldLines: string[], newLines: string[]): string[] {
if (oldLines.length !== newLines.length) return newLines;
let anyPreserved = false;
const result = new Array<string>(newLines.length);
for (let i = 0; i < newLines.length; i++) {
if (oldLines[i] !== newLines[i] && equalsIgnoringWhitespace(oldLines[i], newLines[i])) {
result[i] = oldLines[i];
anyPreserved = true;
} else {
result[i] = newLines[i];
}
}
return anyPreserved ? result : newLines;
}
/**
* A weaker variant of {@link preserveWhitespaceOnlyLines} that can preserve
* whitespace even when the replacement line counts don't match.
*/
function preserveWhitespaceOnlyLinesLoose(oldLines: string[], newLines: string[]): string[] {
const canonToOld = new Map<string, string[]>();
for (const oldLine of oldLines) {
const canon = stripAllWhitespace(oldLine);
const bucket = canonToOld.get(canon);
if (bucket) bucket.push(oldLine);
else canonToOld.set(canon, [oldLine]);
}
let anyPreserved = false;
const result = new Array<string>(newLines.length);
for (let i = 0; i < newLines.length; i++) {
const newLine = newLines[i];
const bucket = canonToOld.get(stripAllWhitespace(newLine));
if (bucket) {
const oldLine = bucket.find(l => l !== newLine && equalsIgnoringWhitespace(l, newLine));
if (oldLine) {
result[i] = oldLine;
anyPreserved = true;
continue;
}
}
result[i] = newLine;
}
return anyPreserved ? result : newLines;
}
function stripInsertAnchorEchoAfter(anchorLine: string, dstLines: string[]): string[] {
if (dstLines.length <= 1) return dstLines;
if (equalsIgnoringWhitespace(dstLines[0], anchorLine)) {
return dstLines.slice(1);
}
return dstLines;
}
function stripInsertAnchorEchoBefore(anchorLine: string, dstLines: string[]): string[] {
if (dstLines.length <= 1) return dstLines;
if (equalsIgnoringWhitespace(dstLines[dstLines.length - 1], anchorLine)) {
return dstLines.slice(0, -1);
}
return dstLines;
}
function stripRangeBoundaryEcho(fileLines: string[], startLine: number, endLine: number, dstLines: string[]): string[] {
// Only strip when the model replaced with multiple lines and grew the edit.
// This avoids turning a single-line replacement into a deletion.
const count = endLine - startLine + 1;
if (dstLines.length <= 1 || dstLines.length <= count) return dstLines;
let out = dstLines;
const beforeIdx = startLine - 2;
if (beforeIdx >= 0 && equalsIgnoringWhitespace(out[0], fileLines[beforeIdx])) {
out = out.slice(1);
}
const afterIdx = endLine;
if (
afterIdx < fileLines.length &&
out.length > 0 &&
equalsIgnoringWhitespace(out[out.length - 1], fileLines[afterIdx])
) {
out = out.slice(0, -1);
}
return out;
}
/**
* Strip hashline display prefixes and diff `+` markers from replacement lines.
*
* Models frequently copy the `LINE:HASH| ` 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 HASH_LEN = 2;
const HASH_MASK = BigInt((1 << (HASH_LEN * 4)) - 1);
const HEX_DICT = Array.from({ length: Number(HASH_MASK) + 1 }, (_, i) => i.toString(16).padStart(HASH_LEN, "0"));
/**
* Compute the 4-character hex hash of a single line.
*
* Uses xxHash64 truncated to the first 4 hex characters.
* The line number is included as a seed so the same content on different lines
* produces different hashes.
* The line input should not include a trailing newline.
*/
export function computeLineHash(idx: number, line: string): string {
if (line.endsWith("\r")) {
line = line.slice(0, -1);
}
return HEX_DICT[Number(Bun.hash.xxHash64(line, BigInt(idx)) & HASH_MASK)];
}
/**
* Format file content with hashline prefixes for display.
*
* 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)
* @returns Formatted string with one hashline-prefixed line per input line
*
* @example
* ```
* formatHashLines("function hi() {\n return;\n}")
* // "1:a3f2| function hi() {\n2:b1c0| return;\n3:de45| }"
* ```
*/
export function formatHashLines(content: string, startLine = 1): string {
const lines = content.split("\n");
return lines
.map((line, i) => {
const num = startLine + i;
const hash = computeLineHash(num, line);
return `${num}:${hash}| ${line}`;
})
.join("\n");
}
/**
* Parse a line reference string like `"5:abcd"` into structured form.
*
* @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"
// Models often copy the full display format from read output.
const cleaned = ref.replace(/\|.*$/, "").trim();
const strictMatch = cleaned.match(/^(\d+):([0-9a-fA-F]{1,16})$/);
const prefixMatch = strictMatch ? null : cleaned.match(new RegExp(`^(\\d+):([0-9a-fA-F]{${HASH_LEN}})`));
const match = strictMatch ?? prefixMatch;
if (!match) {
throw new Error(`Invalid line reference "${ref}". Expected format "LINE:HASH" (e.g. "5:a3f2").`);
}
const line = Number.parseInt(match[1], 10);
if (line < 1) {
throw new Error(`Line number must be >= 1, got ${line} in "${ref}".`);
}
return { line, hash: match[2] };
}
// ═══════════════════════════════════════════════════════════════════════════
// Hash Mismatch Error
// ═══════════════════════════════════════════════════════════════════════════
/** Number of context lines shown above/below each mismatched line */
const MISMATCH_CONTEXT = 2;
/**
* Error thrown when one or more hashline references have stale hashes.
*
* Displays grep-style output with `>>>` markers on mismatched lines,
* showing the correct `LINE:HASH` so the caller can fix all refs at once.
*/
export class HashlineMismatchError extends Error {
constructor(
public readonly mismatches: HashMismatch[],
public readonly fileLines: string[],
) {
super(HashlineMismatchError.formatMessage(mismatches, fileLines));
this.name = "HashlineMismatchError";
}
static formatMessage(mismatches: HashMismatch[], fileLines: string[]): string {
const mismatchSet = new Map<number, HashMismatch>();
for (const m of mismatches) {
mismatchSet.set(m.line, m);
}
// Collect line ranges to display (mismatch lines + context)
const displayLines = new Set<number>();
for (const m of mismatches) {
const lo = Math.max(1, m.line - MISMATCH_CONTEXT);
const hi = Math.min(fileLines.length, m.line + MISMATCH_CONTEXT);
for (let i = lo; i <= hi; i++) {
displayLines.add(i);
}
}
const sorted = [...displayLines].sort((a, b) => a - b);
const lines: string[] = [];
lines.push(
`${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since last read. Use the updated LINE:HASH references shown below (>>> marks changed lines).`,
);
lines.push("");
let prevLine = -1;
for (const lineNum of sorted) {
// Gap separator between non-contiguous regions
if (prevLine !== -1 && lineNum > prevLine + 1) {
lines.push(" ...");
}
prevLine = lineNum;
const content = fileLines[lineNum - 1];
const hash = computeLineHash(lineNum, content);
const prefix = `${lineNum}:${hash}`;
if (mismatchSet.has(lineNum)) {
lines.push(`>>> ${prefix}| ${content}`);
} else {
lines.push(` ${prefix}| ${content}`);
}
}
return lines.join("\n");
}
}
/**
* Validate that a line reference points to an existing line with a matching hash.
*
* @param ref - Parsed line reference (1-indexed line number + expected hash)
* @param fileLines - Array of file lines (0-indexed)
* @throws HashlineMismatchError if the hash doesn't match (includes correct hashes in context)
* @throws Error if the line is out of range
*/
export function validateLineRef(ref: { line: number; hash: string }, fileLines: string[]): void {
if (ref.line < 1 || ref.line > fileLines.length) {
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()) {
throw new HashlineMismatchError([{ line: ref.line, expected: ref.hash, actual: actualHash }], fileLines);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Edit Application
// ═══════════════════════════════════════════════════════════════════════════
/**
* Apply an array of hashline edits to file content.
*
* Each edit's `src` field is parsed as one of:
* - `"5:ab"` — replace exactly that line
* - `"5:ab..9:ef"` — replace/delete the inclusive range
* - `"5:ab.."` — insert after line 5 (line 5 unchanged)
* - `"..5:ab"` — insert before line 5 (line 5 unchanged)
*
* Edits are sorted bottom-up (highest effective line first) so earlier
* splices don't invalidate later line numbers.
*
* @returns The modified content and the 1-indexed first changed line number
*/
export function applyHashlineEdits(
content: string,
edits: HashlineEdit[],
): { content: string; firstChangedLine: number | undefined } {
if (edits.length === 0) {
return { content, firstChangedLine: undefined };
}
const fileLines = content.split("\n");
let firstChangedLine: number | undefined;
// Parse src specs and dst lines up front
const parsed = edits.map(e => ({
spec: parseSrc(e.src),
dstLines: stripNewLinePrefixes(splitDstLines(e.dst)),
}));
const explicitlyTouchedLines = new Set<number>();
for (const { spec } of parsed) {
switch (spec.kind) {
case "single":
explicitlyTouchedLines.add(spec.ref.line);
break;
case "range":
for (let ln = spec.start.line; ln <= spec.end.line; ln++) explicitlyTouchedLines.add(ln);
break;
case "insertAfter":
explicitlyTouchedLines.add(spec.after.line);
break;
case "insertBefore":
explicitlyTouchedLines.add(spec.before.line);
break;
case "substring":
break;
}
}
// Pre-validate: collect all hash mismatches before mutating
const mismatches: HashMismatch[] = [];
for (const { spec, dstLines } of parsed) {
const refsToValidate: { line: number; hash: string }[] = [];
switch (spec.kind) {
case "single":
refsToValidate.push(spec.ref);
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}`);
}
refsToValidate.push(spec.start, spec.end);
break;
case "insertAfter":
if (dstLines.length === 0) {
throw new Error('Insert-after edit (src "N:HH..") requires non-empty dst');
}
refsToValidate.push(spec.after);
break;
case "insertBefore":
if (dstLines.length === 0) {
throw new Error('Insert-before edit (src "..N:HH") requires non-empty dst');
}
refsToValidate.push(spec.before);
break;
case "substring":
if (dstLines.length !== 1) {
throw new Error(`Substring src requires single-line dst (got ${dstLines.length} lines)`);
}
break;
}
for (const ref of refsToValidate) {
if (ref.line < 1 || ref.line > fileLines.length) {
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()) {
mismatches.push({ line: ref.line, expected: ref.hash, actual: actualHash });
}
}
}
if (mismatches.length > 0) {
throw new HashlineMismatchError(mismatches, fileLines);
}
// Compute sort key (descending) — bottom-up application
const annotated = parsed.map((p, idx) => {
let sortLine: number;
let precedence: number;
switch (p.spec.kind) {
case "single":
sortLine = p.spec.ref.line;
precedence = 0;
break;
case "range":
sortLine = p.spec.end.line;
precedence = 0;
break;
case "insertAfter":
sortLine = p.spec.after.line;
precedence = 1;
break;
case "insertBefore":
sortLine = p.spec.before.line;
precedence = 2;
break;
case "substring":
sortLine = 0;
precedence = 3;
break;
}
return { ...p, 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 } of annotated) {
switch (spec.kind) {
case "single": {
const merged = maybeExpandSingleLineMerge(spec.ref.line, dstLines);
if (merged) {
const origLines = fileLines.slice(merged.startLine - 1, merged.startLine - 1 + merged.deleteCount);
let nextLines = merged.newLines;
nextLines = restoreIndentForPairedReplacement([origLines[0] ?? ""], nextLines);
nextLines = preserveWhitespaceOnlyLinesLoose(origLines, nextLines);
if (
origLines.join("\n") === nextLines.join("\n") &&
origLines.some(l => CONFUSABLE_HYPHENS_RE.test(l))
) {
nextLines = normalizeConfusableHyphensInLines(nextLines);
}
fileLines.splice(merged.startLine - 1, merged.deleteCount, ...nextLines);
trackFirstChanged(merged.startLine);
break;
}
const count = 1;
const origLines = fileLines.slice(spec.ref.line - 1, spec.ref.line);
let stripped = stripRangeBoundaryEcho(fileLines, spec.ref.line, spec.ref.line, dstLines);
stripped = restoreOldWrappedLines(origLines, stripped);
const preserved =
stripped.length === count
? preserveWhitespaceOnlyLines(origLines, stripped)
: preserveWhitespaceOnlyLinesLoose(origLines, stripped);
let newLines = restoreIndentForPairedReplacement(origLines, preserved);
if (origLines.join("\n") === newLines.join("\n") && origLines.some(l => CONFUSABLE_HYPHENS_RE.test(l))) {
newLines = normalizeConfusableHyphensInLines(newLines);
}
fileLines.splice(spec.ref.line - 1, count, ...newLines);
trackFirstChanged(spec.ref.line);
break;
}
case "range": {
const count = spec.end.line - spec.start.line + 1;
const origLines = fileLines.slice(spec.start.line - 1, spec.start.line - 1 + count);
let stripped = stripRangeBoundaryEcho(fileLines, spec.start.line, spec.end.line, dstLines);
stripped = restoreOldWrappedLines(origLines, stripped);
const preserved =
stripped.length === count
? preserveWhitespaceOnlyLines(origLines, stripped)
: preserveWhitespaceOnlyLinesLoose(origLines, stripped);
let newLines = restoreIndentForPairedReplacement(origLines, preserved);
if (origLines.join("\n") === newLines.join("\n") && origLines.some(l => CONFUSABLE_HYPHENS_RE.test(l))) {
newLines = normalizeConfusableHyphensInLines(newLines);
}
fileLines.splice(spec.start.line - 1, count, ...newLines);
trackFirstChanged(spec.start.line);
break;
}
case "insertAfter": {
const anchorLine = fileLines[spec.after.line - 1];
const inserted = stripInsertAnchorEchoAfter(anchorLine, dstLines);
fileLines.splice(spec.after.line, 0, ...inserted);
trackFirstChanged(spec.after.line + 1);
break;
}
case "insertBefore": {
const anchorLine = fileLines[spec.before.line - 1];
const inserted = stripInsertAnchorEchoBefore(anchorLine, dstLines);
fileLines.splice(spec.before.line - 1, 0, ...inserted);
trackFirstChanged(spec.before.line);
break;
}
case "substring": {
const indices: number[] = [];
for (let i = 0; i < fileLines.length; i++) {
if (fileLines[i].includes(spec.needle)) indices.push(i);
}
if (indices.length === 0) {
throw new Error(`Substring src not found in file: "${spec.needle}"`);
}
if (indices.length > 1) {
const previews = indices
.slice(0, 5)
.map(i => `${i + 1}: ${fileLines[i]}`)
.join("\n");
const more = indices.length > 5 ? `\n... (${indices.length - 5} more)` : "";
throw new Error(
`Substring src is ambiguous (found ${indices.length} matches): "${spec.needle}"\n${previews}${more}`,
);
}
const lineIdx = indices[0];
const original = fileLines[lineIdx];
const replaced = original.replace(spec.needle, dstLines[0]);
fileLines.splice(lineIdx, 1, replaced);
trackFirstChanged(lineIdx + 1);
break;
}
}
}
return {
content: fileLines.join("\n"),
firstChangedLine,
};
function trackFirstChanged(line: number): void {
if (firstChangedLine === undefined || line < firstChangedLine) {
firstChangedLine = line;
}
}
function maybeExpandSingleLineMerge(
line: number,
dst: string[],
): { startLine: number; deleteCount: number; newLines: string[] } | null {
if (dst.length !== 1) return null;
if (line < 1 || line > fileLines.length) return null;
const newLine = dst[0];
const newCanon = stripAllWhitespace(newLine);
const newCanonForMergeOps = stripMergeOperatorChars(newCanon);
if (newCanon.length === 0) return null;
const orig = fileLines[line - 1];
const origCanon = stripAllWhitespace(orig);
const origCanonForMatch = stripTrailingContinuationTokens(origCanon);
const origCanonForMergeOps = stripMergeOperatorChars(origCanon);
if (origCanon.length === 0) return null;
const prevIdx = line - 2;
const nextIdx = line;
// Case A: dst absorbed the next continuation line.
if (nextIdx < fileLines.length && !explicitlyTouchedLines.has(line + 1)) {
const next = fileLines[nextIdx];
const nextCanon = stripAllWhitespace(next);
const a = newCanon.indexOf(origCanonForMatch);
const b = newCanon.indexOf(nextCanon);
if (a !== -1 && b !== -1 && a < b && newCanon.length <= origCanon.length + nextCanon.length + 32) {
return { startLine: line, deleteCount: 2, newLines: [newLine] };
}
}
// Case B: dst absorbed the previous declaration/continuation line.
if (prevIdx >= 0 && !explicitlyTouchedLines.has(line - 1)) {
const prev = fileLines[prevIdx];
const prevCanon = stripAllWhitespace(prev);
const prevCanonForMatch = stripTrailingContinuationTokens(prevCanon);
const a = newCanonForMergeOps.indexOf(stripMergeOperatorChars(prevCanonForMatch));
const b = newCanonForMergeOps.indexOf(origCanonForMergeOps);
if (a !== -1 && b !== -1 && a < b && newCanon.length <= prevCanon.length + origCanon.length + 32) {
return { startLine: line - 1, deleteCount: 2, newLines: [newLine] };
}
}
return null;
}
}