From 006ea27fbd393b70be0de2557e72707209fc0b6d Mon Sep 17 00:00:00 2001 From: can1357 Date: Thu, 13 Aug 2026 06:40:40 +0200 Subject: [PATCH] fix(hashline): replaced delimiter heuristics with tree-sitter validation - Replaced heuristic delimiter-balance boundary repairs with tree-sitter guided variants. - Added combinatorial search over replacement boundary keep/drop variants and syntax-essential row validation. - Removed obsolete `CloserSpareAmbiguityError` along with custom JSX parsing and delimiter counting helpers. - Updated warning and error messages to include unified boundary diagnostics. --- .../test/tools/lsp-regressions.test.ts | 2 +- packages/hashline/CHANGELOG.md | 4 + packages/hashline/src/apply.ts | 1597 ++++++----------- packages/hashline/src/messages.ts | 107 +- packages/hashline/src/syntax.ts | 40 +- .../hashline/test/boundary-repair.test.ts | 109 +- 6 files changed, 715 insertions(+), 1144 deletions(-) diff --git a/packages/coding-agent/test/tools/lsp-regressions.test.ts b/packages/coding-agent/test/tools/lsp-regressions.test.ts index 59e44f28e..3153cac56 100644 --- a/packages/coding-agent/test/tools/lsp-regressions.test.ts +++ b/packages/coding-agent/test/tools/lsp-regressions.test.ts @@ -2645,7 +2645,7 @@ describe("lsp regressions", () => { rootMarkers: [], isLinter: true, }; - const server = installFakeLsp((message, srv) => { + installFakeLsp((message, srv) => { if (message.method === "initialize") { srv.send({ jsonrpc: "2.0", diff --git a/packages/hashline/CHANGELOG.md b/packages/hashline/CHANGELOG.md index cdec6e163..f163ca02c 100644 --- a/packages/hashline/CHANGELOG.md +++ b/packages/hashline/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Repaired mis-set replacement ranges using exact outside-row matches, indentation, tree-sitter structure, and a narrow pure-closer shape: opening comment fences and other syntax-essential edges are retained only when a parse-valid candidate satisfies those constraints; ambiguous placements are rejected. + ## [17.2.15] - 2026-08-12 ### Added diff --git a/packages/hashline/src/apply.ts b/packages/hashline/src/apply.ts index 3b4cacbd4..490382954 100644 --- a/packages/hashline/src/apply.ts +++ b/packages/hashline/src/apply.ts @@ -3,24 +3,26 @@ * post-edit lines plus any diagnostic warnings. Pure function: no FS, no * mutation of the input. * - * Replacement groups are first normalized by {@link repairReplacementBoundaries}, - * which absorbs common model mistakes where a payload restates unchanged range - * boundaries or duplicates/drops structural closers. + * Mis-set replacement range boundaries are repaired by bounded candidate + * search. Exact line equality, indentation, tree-sitter structure, and a + * narrow pure-closer shape gate constrain candidates; tree-sitter validates + * the selected result. */ import { resolveClipboardEdits } from "./clipboard"; import { afterInsertLandingShiftWarning, ambiguousBoundaryEchoMessage, - ambiguousCloserSpareMessage, - ambiguousLeadingCloserSpareMessage, + ambiguousBoundaryPlacementMessage, blockInsertLandingShiftWarning, + boundaryVariantRepairWarning, editBrokeParseWarning, - midBlockRangeWarning, REPLACEMENT_INDENT_AUTO_SHIFT_WARNING, + textualBoundaryEchoWarning, UNRESOLVED_BLOCK_INTERNAL, + UNRESOLVED_CLIPBOARD_INTERNAL, } from "./messages"; -import { parsesCleanly } from "./syntax"; +import { enclosingBoundaries, parsesCleanly } from "./syntax"; import { cloneCursor } from "./tokenizer"; import type { Anchor, ApplyResult, Clipboard, Cursor, Edit } from "./types"; @@ -30,6 +32,14 @@ type InsertEdit = Extract; type DeleteEdit = Extract; type AppliedEdit = InsertEdit | DeleteEdit; +function insertEditAt(edits: readonly AppliedEdit[], index: number): InsertEdit { + const edit = edits[index]; + if (edit?.kind !== "insert") { + throw new Error("internal error: after-insert group contains a non-insert edit"); + } + return edit; +} + interface IndexedEdit { edit: AppliedEdit; idx: number; @@ -124,269 +134,25 @@ function bucketAnchorEditsByLine(edits: IndexedEdit[]): Map|<\/[A-Za-z][\w.:-]*>|\/>)\s*[;,]?\s*$/; -const JSX_NAMED_CLOSER_RE = /^\s*<\/([A-Za-z][\w.:-]*)>\s*[;,]?\s*$/; -const JSX_FRAGMENT_CLOSER_RE = /^\s*<\/>\s*[;,]?\s*$/; - -function isStructuralCloserLine(text: string): boolean { - return STRUCTURAL_CLOSER_RE.test(text) || JSX_CLOSER_RE.test(text); -} - -function jsxCloserName(text: string): string | undefined { - if (JSX_FRAGMENT_CLOSER_RE.test(text)) return ""; - const match = JSX_NAMED_CLOSER_RE.exec(text); - return match?.[1]; -} - -interface JsxPayloadTag { - readonly name: string; - readonly closing: boolean; - readonly selfClosing: boolean; -} - -function isJsxTagStart(text: string, index: number): boolean { - const next = text[index + 1]; - return next === ">" || next === "/" || (next >= "A" && next <= "Z") || (next >= "a" && next <= "z"); -} - -function findJsxTagEnd(text: string, start: number): number { - let quote: string | undefined; - let braces = 0; - for (let i = start + 1; i < text.length; i++) { - const ch = text[i]; - if (quote) { - if (ch === "\\" && i + 1 < text.length) { - i++; - } else if (ch === quote) { - quote = undefined; - } - continue; - } - if (ch === '"' || ch === "'" || ch === "`") { - quote = ch; - } else if (ch === "{") { - braces++; - } else if (ch === "}" && braces > 0) { - braces--; - } else if (ch === ">" && braces === 0) { - return i; - } - } - return -1; -} - -function parseJsxPayloadTag(raw: string): JsxPayloadTag | undefined { - if (raw === "<>") return { name: "", closing: false, selfClosing: false }; - if (raw === "") return { name: "", closing: true, selfClosing: false }; - const closing = raw.startsWith("\s*$/.test(raw), - }; -} - -function readJsxPayloadTags(text: string): JsxPayloadTag[] { - const tags: JsxPayloadTag[] = []; - for (let start = text.indexOf("<"); start >= 0; start = text.indexOf("<", start + 1)) { - if (!isJsxTagStart(text, start)) continue; - const end = findJsxTagEnd(text, start); - if (end < 0) break; - const tag = parseJsxPayloadTag(text.slice(start, end + 1)); - if (tag) tags.push(tag); - start = end; - } - return tags; -} - -function payloadHasJsxOpenerForEcho(payloadPrefix: readonly string[], echoLines: readonly string[]): boolean { - const openTags: string[] = []; - for (const tag of readJsxPayloadTags(payloadPrefix.join("\n"))) { - if (tag.closing) { - if (openTags[openTags.length - 1] === tag.name) openTags.pop(); - } else if (!tag.selfClosing) { - openTags.push(tag.name); - } - } - for (const line of echoLines) { - const name = jsxCloserName(line); - if (name !== undefined && openTags.includes(name)) return true; - } - return false; -} - -interface DelimiterBalance { - paren: number; - bracket: number; - brace: number; -} - -/** - * Single-quote lexing mode for {@link computeDelimiterBalance}. `"literal"`: - * `'…'` is a same-line string (JS/Python/C-like default, the original - * behavior). `"rust"`: `'` opens a literal only when it lexes as a Rust char - * literal (`'a'`, `'\n'`, `'\u{7FFF}'`); any other `'` is a lifetime or - * apostrophe and stays an ordinary character — pairing arbitrary apostrophes - * would swallow real delimiters between two lifetimes (`<'a>(x: &'a str)` - * loses the `(`), and quote-state-to-EOL hides the opener on signature lines - * (`&'static str {` — the `extension()` incident). - * - * Module-scoped rather than threaded: the scan helpers are a dozen pure free - * functions all rooted in the synchronous `applyEdits` call, which sets the - * mode from its target path on every entry. - */ -let singleQuoteMode: "literal" | "rust" = "literal"; - -/** Set {@link singleQuoteMode} from the target file's extension. */ -function setDelimiterScanLanguage(path: string | undefined): void { - singleQuoteMode = path?.endsWith(".rs") ? "rust" : "literal"; -} - -/** Rust char literal at one position: `'a'`, `'\n'`, `'\x41'`, `'\u{7FFF}'`. */ -const RUST_CHAR_LITERAL_RE = /^'(?:\\u\{[0-9a-fA-F_]{1,6}\}|\\x[0-9a-fA-F]{2}|\\.|[^\\'])'/; - -/** - * Net `()` / `[]` / `{}` delta across `lines`, skipping delimiters inside line - * comments (`//`), block comments, and string/template literals. Block-comment - * and backtick-template state carry across lines; `"` / `'` reset at EOL since - * they cannot span lines. Single-quote handling follows the target language - * (see {@link singleQuoteMode}). Deliberately language-light otherwise: - * constructs it cannot classify (e.g. regex literals) are counted naively, - * which can only suppress a repair (the safe direction), never force one. - */ -function computeDelimiterBalance(lines: readonly string[]): DelimiterBalance { - const balance: DelimiterBalance = { paren: 0, bracket: 0, brace: 0 }; - let inBlockComment = false; - let quote = ""; - for (const line of lines) { - for (let i = 0; i < line.length; i++) { - const ch = line[i]; - if (inBlockComment) { - if (ch === "*" && line[i + 1] === "/") { - inBlockComment = false; - i++; - } - continue; - } - if (quote) { - if (ch === "\\") i++; - else if (ch === quote) quote = ""; - continue; - } - if (ch === "'" && singleQuoteMode === "rust") { - // A real char literal is skipped whole; a lifetime (`'static`, - // `<'a>`) or apostrophe is an ordinary character. - const literal = RUST_CHAR_LITERAL_RE.exec(line.slice(i)); - if (literal) i += literal[0].length - 1; - continue; - } - if (ch === '"' || ch === "'" || ch === "`") { - quote = ch; - continue; - } - if (ch === "/" && line[i + 1] === "/") break; - if (ch === "/" && line[i + 1] === "*") { - inBlockComment = true; - i++; - continue; - } - switch (ch) { - case "(": - balance.paren++; - break; - case ")": - balance.paren--; - break; - case "[": - balance.bracket++; - break; - case "]": - balance.bracket--; - break; - case "{": - balance.brace++; - break; - case "}": - balance.brace--; - break; - } - } - // `"` / `'` cannot span lines; only backtick templates and block comments do. - if (quote === '"' || quote === "'") quote = ""; - } - return balance; -} - -function balanceDelta(a: DelimiterBalance, b: DelimiterBalance): DelimiterBalance { - return { paren: a.paren - b.paren, bracket: a.bracket - b.bracket, brace: a.brace - b.brace }; -} - -function balanceNegate(a: DelimiterBalance): DelimiterBalance { - return { paren: -a.paren, bracket: -a.bracket, brace: -a.brace }; -} - -function balanceEqual(a: DelimiterBalance, b: DelimiterBalance): boolean { - return a.paren === b.paren && a.bracket === b.bracket && a.brace === b.brace; -} - -function balanceIsZero(a: DelimiterBalance): boolean { - return a.paren === 0 && a.bracket === 0 && a.brace === 0; -} - -function balanceSum(a: DelimiterBalance, b: DelimiterBalance): DelimiterBalance { - return { paren: a.paren + b.paren, bracket: a.bracket + b.bracket, brace: a.brace + b.brace }; -} - -function balanceComponentCovers(candidate: number, target: number): boolean { - if (target === 0) return true; - return candidate > 0 === target > 0 && Math.abs(candidate) >= Math.abs(target); -} - -function balanceCovers(candidate: DelimiterBalance, target: DelimiterBalance): boolean { - return ( - balanceComponentCovers(candidate.paren, target.paren) && - balanceComponentCovers(candidate.bracket, target.bracket) && - balanceComponentCovers(candidate.brace, target.brace) - ); -} - interface ReplacementGroup { /** Positions in the edit array of the payload inserts, in payload order. */ insertIndices: number[]; @@ -500,301 +266,6 @@ function repairReplacementIndentation(edits: AppliedEdit[], fileLines: readonly return repaired ? [REPLACEMENT_INDENT_AUTO_SHIFT_WARNING] : []; } -/** - * Largest `k` such that the payload's last `k` lines exactly equal the `k` - * surviving file lines just below the range AND dropping them zeroes `delta`. - * Requires a non-zero `delta`: a zero-balance candidate can never account for - * the imbalance, so intentional duplicates of ordinary statements stay intact, - * while duplicated structural lines (closers like `});`, openers like `foo(`) - * are dropped when they exactly explain the imbalance. - */ -function findDuplicateSuffix(group: ReplacementGroup, fileLines: readonly string[], delta: DelimiterBalance): number { - if (balanceIsZero(delta)) return 0; - const { payload, endLine } = group; - const maxK = Math.min(payload.length, fileLines.length - endLine); - for (let k = maxK; k >= 1; k--) { - let matches = true; - for (let t = 0; t < k; t++) { - if (payload[payload.length - k + t] !== fileLines[endLine + t]) { - matches = false; - break; - } - } - if (!matches) continue; - if (balanceEqual(computeDelimiterBalance(payload.slice(payload.length - k)), delta)) return k; - } - return 0; -} - -/** - * Largest `j` such that the payload's first `j` lines exactly equal the `j` - * surviving file lines just above the range AND dropping them zeroes `delta`. - * Requires a non-zero `delta`; see {@link findDuplicateSuffix}. - */ -function findDuplicatePrefix(group: ReplacementGroup, fileLines: readonly string[], delta: DelimiterBalance): number { - if (balanceIsZero(delta)) return 0; - const { payload, startLine } = group; - const maxJ = Math.min(payload.length, startLine - 1); - for (let j = maxJ; j >= 1; j--) { - let matches = true; - for (let t = 0; t < j; t++) { - if (payload[t] !== fileLines[startLine - 1 - j + t]) { - matches = false; - break; - } - } - if (!matches) continue; - if (balanceEqual(computeDelimiterBalance(payload.slice(0, j)), delta)) return j; - } - return 0; -} -interface DroppedSuffixClosers { - readonly startLine: number; - readonly count: number; - readonly balance: DelimiterBalance; -} - -function countPayloadRestatedSuffixHead(payload: readonly string[], suffixLines: readonly string[]): number { - const maxCount = Math.min(payload.length, suffixLines.length); - for (let count = maxCount; count >= 1; count--) { - let matches = true; - for (let offset = 0; offset < count; offset++) { - if (payload[payload.length - count + offset] !== suffixLines[offset]) { - matches = false; - break; - } - } - if (matches) return count; - } - return 0; -} - -function countProjectedBelowSuffixTail( - group: ReplacementGroup, - fileLines: readonly string[], - deletedLines: ReadonlySet, - insertedLineMaps: InsertedLineMaps, - suffixLines: readonly string[], -): number { - const below: string[] = []; - const appendCloserLines = (lines: readonly string[] | undefined): boolean => { - if (!lines) return true; - for (const text of lines) { - if (!STRUCTURAL_CLOSER_RE.test(text)) return false; - below.push(text); - } - return true; - }; - if (!appendCloserLines(insertedLineMaps.after.get(group.endLine))) return 0; - for (let line = group.endLine + 1; line <= fileLines.length; line++) { - if (!appendCloserLines(insertedLineMaps.before.get(line))) break; - if (!deletedLines.has(line)) { - const text = fileLines[line - 1] ?? ""; - if (!STRUCTURAL_CLOSER_RE.test(text)) break; - below.push(text); - } - if (!appendCloserLines(insertedLineMaps.after.get(line))) break; - } - const maxCount = Math.min(below.length, suffixLines.length); - for (let count = maxCount; count >= 1; count--) { - let matches = true; - for (let offset = 0; offset < count; offset++) { - if (below[offset] !== suffixLines[suffixLines.length - count + offset]) { - matches = false; - break; - } - } - if (matches) return count; - } - return 0; -} - -interface InsertedLineMaps { - readonly before: ReadonlyMap; - readonly after: ReadonlyMap; -} - -function computeProjectedPrefixBalance( - group: ReplacementGroup, - fileLines: readonly string[], - deletedLines: ReadonlySet, - insertedByLine: ReadonlyMap, - insertedLineMaps: InsertedLineMaps, -): DelimiterBalance { - const prefix: string[] = []; - for (let line = 1; line < group.startLine; line++) { - const inserted = insertedByLine.get(line); - if (inserted) prefix.push(...inserted); - if (!deletedLines.has(line)) prefix.push(fileLines[line - 1] ?? ""); - } - const insertedAtStart = insertedLineMaps.before.get(group.startLine); - if (insertedAtStart) prefix.push(...insertedAtStart); - prefix.push(...group.payload); - return computeDelimiterBalance(prefix); -} - -function prefixCanCoverSuffixClosers( - group: ReplacementGroup, - fileLines: readonly string[], - suffixBalance: DelimiterBalance, - coveredBelowBalance: DelimiterBalance, - deletedLines: ReadonlySet, - insertedByLine: ReadonlyMap, - insertedLineMaps: InsertedLineMaps, -): boolean { - const neededOpeners = balanceNegate(suffixBalance); - const prefixBalance = computeProjectedPrefixBalance( - group, - fileLines, - deletedLines, - insertedByLine, - insertedLineMaps, - ); - const uncoveredPrefixBalance = balanceSum(prefixBalance, coveredBelowBalance); - return balanceCovers(uncoveredPrefixBalance, neededOpeners); -} - -/** - * Missing segment of the range's deleted structural-closer suffix that should - * be spared. Payload lines that already restate the suffix head are not kept - * again, and projected closers immediately below the range satisfy the suffix - * tail. The remaining middle segment is kept only when backed by unmatched - * openers plus the whole-patch residual. - */ -function findDroppedSuffixClosers( - group: ReplacementGroup, - fileLines: readonly string[], - delta: DelimiterBalance, - remainingDelta: DelimiterBalance, - deletedPrefixBalance: DelimiterBalance, - deletedLines: ReadonlySet, - insertedByLine: ReadonlyMap, - insertedLineMaps: InsertedLineMaps, -): DroppedSuffixClosers | undefined { - let suffixLength = 0; - while ( - suffixLength < group.deleteIndices.length && - STRUCTURAL_CLOSER_RE.test(fileLines[group.endLine - suffixLength - 1] ?? "") - ) { - suffixLength++; - } - if (suffixLength === 0) return undefined; - - const suffixStartLine = group.endLine - suffixLength + 1; - const suffixLines = fileLines.slice(group.endLine - suffixLength, group.endLine); - const restatedHead = countPayloadRestatedSuffixHead(group.payload, suffixLines); - const coveredTail = countProjectedBelowSuffixTail(group, fileLines, deletedLines, insertedLineMaps, suffixLines); - const keepStart = restatedHead; - const keepEnd = suffixLength - coveredTail; - if (keepStart >= keepEnd) return undefined; - - const keptLines = suffixLines.slice(keepStart, keepEnd); - const keptBalance = computeDelimiterBalance(keptLines); - const neededOpeners = balanceNegate(keptBalance); - const coveredBelowBalance = computeDelimiterBalance(suffixLines.slice(keepEnd)); - if (!balanceCovers(delta, neededOpeners)) return undefined; - if (balanceCovers(deletedPrefixBalance, neededOpeners)) return undefined; - if (!balanceCovers(remainingDelta, neededOpeners)) return undefined; - if ( - !prefixCanCoverSuffixClosers( - group, - fileLines, - keptBalance, - coveredBelowBalance, - deletedLines, - insertedByLine, - insertedLineMaps, - ) - ) { - return undefined; - } - return { startLine: suffixStartLine + keepStart, count: keepEnd - keepStart, balance: keptBalance }; -} -interface DroppedPrefixClosers { - readonly count: number; - readonly balance: DelimiterBalance; -} - -/** - * Leading run of the range's deleted structural-closer line(s) that the - * payload never restates — the mirror of {@link findDroppedSuffixClosers} for - * the "range started one line early, on the `}` that ends the construct - * above" mistake. Fires only when the group's own delta and the whole-patch - * residual are both missing exactly those closers, no deleted lines above the - * range account for their opener, and dangling opener(s) actually survive - * above the range in the projected file. - */ -function findDroppedPrefixClosers( - group: ReplacementGroup, - fileLines: readonly string[], - delta: DelimiterBalance, - remainingDelta: DelimiterBalance, - deletedPrefixBalance: DelimiterBalance, - deletedLines: ReadonlySet, - insertedByLine: ReadonlyMap, -): DroppedPrefixClosers | undefined { - let prefixLength = 0; - while ( - prefixLength < group.deleteIndices.length && - STRUCTURAL_CLOSER_RE.test(fileLines[group.startLine + prefixLength - 1] ?? "") - ) { - prefixLength++; - } - if (prefixLength === 0 || prefixLength >= group.deleteIndices.length) return undefined; - // A payload that opens with a closer restates the boundary itself; that is - // an echo/duplicate mistake with a different reading — leave it alone. - if (group.payload.length === 0 || isStructuralCloserLine(group.payload[0])) return undefined; - const prefixLines = fileLines.slice(group.startLine - 1, group.startLine - 1 + prefixLength); - const balance = computeDelimiterBalance(prefixLines); - if (balanceIsZero(balance)) return undefined; - const neededOpeners = balanceNegate(balance); - if (!balanceCovers(delta, neededOpeners)) return undefined; - if (balanceCovers(deletedPrefixBalance, neededOpeners)) return undefined; - if (!balanceCovers(remainingDelta, neededOpeners)) return undefined; - // The spared closers need dangling opener(s) above the range in the - // projected file; the payload cannot supply them — it lands below the - // closers either way. - const above: string[] = []; - for (let line = 1; line < group.startLine; line++) { - const inserted = insertedByLine.get(line); - if (inserted) above.push(...inserted); - if (!deletedLines.has(line)) above.push(fileLines[line - 1] ?? ""); - } - if (!balanceCovers(computeDelimiterBalance(above), neededOpeners)) return undefined; - return { count: prefixLength, balance }; -} - -/** - * Total opening delimiters the range deletes without the payload reopening - * them while their matching closer(s) survive below — the "payload is a - * complete construct but the range ends mid-block" mistake, which orphans the - * surviving closers. A balance-only signal, so it is advisory input rather - * than proof: {@link applyEdits} surfaces it only once the tree-sitter probe - * confirms the authored edit broke the file, which is what separates a real - * mid-block range from a `}` living in prose or a regex literal. Zero when the - * payload is itself net-closing (deliberate rebalancing of a broken file) or - * when another hunk removes the surplus (whole-patch residual clean). - */ -function countOrphanedOpeners( - group: ReplacementGroup, - delta: DelimiterBalance, - remainingDelta: DelimiterBalance, - fileLines: readonly string[], -): number { - const deletedBalance = computeDelimiterBalance(fileLines.slice(group.startLine - 1, group.endLine)); - const payloadBalance = computeDelimiterBalance(group.payload); - let orphaned = 0; - for (const key of ["paren", "bracket", "brace"] as const) { - if (payloadBalance[key] < 0) return 0; - if (delta[key] >= 0 || deletedBalance[key] <= 0 || remainingDelta[key] >= 0) continue; - orphaned += Math.min(-delta[key], deletedBalance[key], -remainingDelta[key]); - } - return orphaned; -} -interface BoundaryEcho { - leading: number; - trailing: number; -} function hasNonWhitespace(text: string): boolean { for (let i = 0; i < text.length; i++) { const code = text.charCodeAt(i); @@ -840,457 +311,530 @@ function countDuplicateTrailingBoundaryLines(group: ReplacementGroup, fileLines: } return 0; } - -function findBoundaryEcho(group: ReplacementGroup, fileLines: readonly string[]): BoundaryEcho | undefined { - const leadingMax = countDuplicateLeadingBoundaryLines(group, fileLines); - if (leadingMax === 0) return undefined; - const trailingMax = countDuplicateTrailingBoundaryLines(group, fileLines); - if (trailingMax === 0) return undefined; - // Bail when every payload line could be claimed by a boundary echo: any - // repair would strip explicit replacement content with no signal that the - // payload was a mistake rather than an intentional duplication. - if (leadingMax + trailingMax >= group.payload.length) return undefined; - // Balance-neutrality guard (see header comment): the dropped echo lines must - // either be delimiter-neutral on their own or exactly cancel the payload/range - // balance delta. In brace-heavy code where bare closer lines repeat, an - // "echo" that shifts delimiter balance is structural content the payload - // placed intentionally — stripping it would corrupt the result. - const leadingBalance = computeDelimiterBalance(group.payload.slice(0, leadingMax)); - const trailingBalance = computeDelimiterBalance(group.payload.slice(group.payload.length - trailingMax)); - const droppedBalance = balanceDelta(leadingBalance, balanceNegate(trailingBalance)); - if (!balanceIsZero(droppedBalance)) { - const delta = balanceDelta( - computeDelimiterBalance(group.payload), - computeDelimiterBalance(fileLines.slice(group.startLine - 1, group.endLine)), - ); - if (!balanceEqual(droppedBalance, delta)) return undefined; - } - return { leading: leadingMax, trailing: trailingMax }; +interface TextualBoundaryAmbiguity { + readonly startLine: number; + readonly endLine: number; + readonly side: "leading" | "trailing"; + readonly count: number; } -function describeBoundaryEchoRepair(group: ReplacementGroup, echo: BoundaryEcho): string { - return ( - `Auto-repaired a replacement boundary echo at line ${group.startLine}: ` + - `dropped ${echo.leading} leading and ${echo.trailing} trailing payload line(s) already present outside the range. ` + - `Issue the payload as the final desired content for the selected range only — never restate unchanged lines bordering the range.` - ); -} - -function describeBoundaryRepair(group: ReplacementGroup, action: string): string { - return ( - `Auto-repaired a delimiter-balance mismatch in the replacement at line ${group.startLine}: ${action}. ` + - `Issue the payload as the final desired content only — never restate or omit a closing bracket bordering the range.` - ); +interface TextualBoundaryNormalization { + readonly edits: AppliedEdit[]; + readonly warnings: string[]; + readonly ambiguities: TextualBoundaryAmbiguity[]; } /** - * A single-sided boundary echo in an otherwise delimiter-balanced *multi-line* - * replacement: the payload's leading XOR trailing edge exactly restates the - * surviving line(s) just outside the range — the off-by-one "range one line - * short of the keeper I retyped" mistake (e.g. att: payload ends with - * `const x = [];` and line B+1 is the same `const x = [];`). Two-sided echoes - * are handled by {@link findBoundaryEcho}; delimiter-imbalanced one-sided echoes - * by {@link findDuplicateSuffix}/{@link findDuplicatePrefix}. + * Normalize exact boundary echoes without interpreting language tokens. * - * Scoped broadly for multi-line ranges (a construct rewrite) because retouched - * neutral keepers are usually boundary mistakes there. Single-line expansions - * are riskier — ordinary duplicated statements may be intentional — so they are - * only repaired when the duplicated edge is a structural closer line that - * carries no delimiter-balance signal itself, such as a JSX `` close. - * The dropped lines must keep the already-balanced result balanced, and must - * not consume the whole payload. - * - * A detected echo is only *repairable* when the payload is long enough to be - * the widened range's full content (`payload ≥ range + echo`). Shorter - * payloads are ambiguous — the echo may instead mean the range itself was - * shifted by the echo, which keeps the far boundary line(s) the repair would - * delete — and the caller rejects the edit instead of guessing. + * Two-sided echoes are removed when stripping both copies leaves one payload + * row per deleted range line. One-sided echoes on multi-line ranges are + * removed when the remaining payload still covers the full range; an + * under-filled one-sided echo is recorded as ambiguous so the syntax-probe + * search gets first chance to resolve it, then rejected rather than silently + * dropping unique range content. */ -function findOneSidedBoundaryEcho( - group: ReplacementGroup, - fileLines: readonly string[], -): { side: "leading" | "trailing"; count: number } | undefined { - const leading = countDuplicateLeadingBoundaryLines(group, fileLines); - const trailing = countDuplicateTrailingBoundaryLines(group, fileLines); - if (leading > 0 === trailing > 0) return undefined; - const side = leading > 0 ? "leading" : "trailing"; - const count = leading > 0 ? leading : trailing; - if (count >= group.payload.length) return undefined; - const echoLines = - side === "leading" ? group.payload.slice(0, count) : group.payload.slice(group.payload.length - count); - if (!balanceIsZero(computeDelimiterBalance(echoLines))) return undefined; - if (group.deleteIndices.length <= 1) { - if (side !== "trailing" || !echoLines.every(isStructuralCloserLine)) return undefined; - const payloadPrefix = group.payload.slice(0, group.payload.length - count); - if (payloadHasJsxOpenerForEcho(payloadPrefix, echoLines)) return undefined; - } - return { side, count }; -} - -function describeOneSidedEchoRepair(group: ReplacementGroup, side: "leading" | "trailing", count: number): string { - const where = side === "leading" ? "above" : "below"; - return ( - `Auto-repaired a replacement boundary echo at line ${group.startLine}: ` + - `dropped ${count} ${side} payload line(s) identical to the surviving line(s) just ${where} the range. ` + - `The range was one line short of the content you retyped — issue the payload as the final content for the ` + - `selected range only, and widen the range to consume any keeper you restate.` - ); -} - -/** - * One pass-1 outcome per source position: resolved edits (with an optional - * warning) or a deferred missing-closer candidate, resolved against the - * whole-patch residual in pass 2. - */ -type RepairSlot = - | { kind: "edits"; edits: AppliedEdit[]; warning?: string } - | { - kind: "candidate"; - group: ReplacementGroup; - inserts: AppliedEdit[]; - deletes: AppliedEdit[]; - delta: DelimiterBalance; - }; - -/** - * Delimiter balance of the lines immediately above a group's range that are - * themselves deleted by other hunks, netted against any payload inserted at - * those lines. When this covers the group's own delta the matching opener was - * deleted (or replaced by an opener of the same shape) just above — a deliberate - * wrapper removal — so the range's deleted closer must stay deleted, not be - * "kept". Scanned over its own contiguous lines so quote/comment state never - * bleeds in from elsewhere in the patch. - */ -function netDeletedPrefixBalance( - group: ReplacementGroup, - deletedLines: ReadonlySet, - insertedByLine: ReadonlyMap, - fileLines: readonly string[], -): DelimiterBalance { - const deleted: string[] = []; - const inserted: string[] = []; - for (let line = group.startLine - 1; line >= 1 && deletedLines.has(line); line--) { - deleted.unshift(fileLines[line - 1] ?? ""); - const insertedAtLine = insertedByLine.get(line); - if (insertedAtLine) inserted.unshift(...insertedAtLine); - } - return balanceDelta(computeDelimiterBalance(deleted), computeDelimiterBalance(inserted)); -} - -/** - * Net delimiter balance a slot contributes, computed over the slot's own - * contiguous insert/delete lines only. Summing these per-slot deltas — never one - * concatenated scan across non-adjacent hunks — keeps backtick/block-comment - * state local, so an unterminated quote in one hunk cannot mask a real delimiter - * in another. - */ -function slotPatchDelta(slot: RepairSlot, fileLines: readonly string[]): DelimiterBalance { - if (slot.kind === "candidate") return slot.delta; - const inserted: string[] = []; - const deleted: string[] = []; - for (const edit of slot.edits) { - if (edit.kind === "insert") inserted.push(edit.text); - else deleted.push(fileLines[edit.anchor.line - 1] ?? ""); - } - return balanceDelta(computeDelimiterBalance(inserted), computeDelimiterBalance(deleted)); -} - -/** - * Normalize replacement groups so common off-by-one boundaries do not duplicate - * unchanged surrounding lines or wrongly drop/keep structural closers. Local - * repairs run in pass 1; the missing-closer repairs (a closer the range - * deleted at its trailing or leading edge) are deferred to pass 2 and weighed - * against the whole-patch delimiter residual, so a closer is only kept when - * the patch as a whole is missing it — never when another hunk already - * removed the matching opener. - * - * Textual repairs (boundary echoes, payload lines duplicated from just outside - * the range) are evidence-complete on their own and always applied. The - * closer-spare repairs are not: they claim a lone `}` is syntax. They run only - * when `applySpares` is set, which {@link applyEdits} does only after the - * tree-sitter probe shows the authored edits broke a file that previously - * parsed. With `applySpares` false the same detections are reported through - * `suspicious` / `advisories` and nothing is rewritten. - * - * When the spares do run, they fire only if exactly one reading explains the - * mistake; ambiguous evidence — a one-sided echo whose payload is too short - * for the widened range, a spared trailing closer the payload neither opens - * nor indents into, or a spared leading closer whose payload claims the block - * interior — throws instead of guessing, so the author re-issues the edit - * rather than shipping silently corrupted content. - */ -function repairReplacementBoundaries( +function normalizeTextualBoundaryEchoes( edits: readonly AppliedEdit[], fileLines: readonly string[], - applySpares: boolean, -): { - edits: AppliedEdit[]; - warnings: string[]; - /** A delimiter-semantics anomaly was detected: worth a parse to confirm. */ - suspicious: boolean; - /** A swallowed block closer was detected and a spare repair is available. */ - sparesProposed: boolean; - /** Diagnostics to surface only if the result is kept unrepaired. */ - advisories: string[]; -} { - // Pass 1: apply every repair whose correctness is local to one group - // (boundary echo, duplicate prefix/suffix). Defer the missing-closer repair: - // it must weigh a group's imbalance against the whole patch, which is only - // known once the local repairs above have settled. - const slots: RepairSlot[] = []; +): TextualBoundaryNormalization { + const out: AppliedEdit[] = []; + const warnings: string[] = []; + const ambiguities: TextualBoundaryAmbiguity[] = []; let i = 0; while (i < edits.length) { const group = findReplacementGroup(edits, i); if (!group) { - slots.push({ kind: "edits", edits: [edits[i]] }); + out.push(cloneAppliedEdit(edits[i], i)); i++; continue; } - const inserts = group.insertIndices.map(idx => edits[idx]); - const deletes = group.deleteIndices.map(idx => edits[idx]); - i = group.deleteIndices[group.deleteIndices.length - 1] + 1; - - const boundaryEcho = findBoundaryEcho(group, fileLines); - if (boundaryEcho) { - slots.push({ - kind: "edits", - edits: [...inserts.slice(boundaryEcho.leading, inserts.length - boundaryEcho.trailing), ...deletes], - warning: describeBoundaryEchoRepair(group, boundaryEcho), - }); - continue; - } - - const delta = balanceDelta( - computeDelimiterBalance(group.payload), - computeDelimiterBalance(fileLines.slice(group.startLine - 1, group.endLine)), - ); - if (balanceIsZero(delta)) { - const oneSided = findOneSidedBoundaryEcho(group, fileLines); - if (oneSided) { - // A payload shorter than range+echo cannot be the widened - // range's full content: the repair would delete range line(s) - // the payload never restates, while the "shifted range" - // reading keeps them. Reject rather than guess. - if (group.payload.length < group.deleteIndices.length + oneSided.count) { - throw new Error( - ambiguousBoundaryEchoMessage(group.startLine, group.endLine, oneSided.side, oneSided.count), - ); - } - const trimmed = - oneSided.side === "leading" - ? inserts.slice(oneSided.count) - : inserts.slice(0, inserts.length - oneSided.count); - slots.push({ - kind: "edits", - edits: [...trimmed, ...deletes], - warning: describeOneSidedEchoRepair(group, oneSided.side, oneSided.count), + const inserts = replacementInserts(group, edits); + const deletes = replacementDeletes(group, edits); + const leading = countDuplicateLeadingBoundaryLines(group, fileLines); + const trailing = countDuplicateTrailingBoundaryLines(group, fileLines); + const rangeLength = group.deleteIndices.length; + let dropLeading = 0; + let dropTrailing = 0; + if (leading > 0 && trailing > 0) { + if (group.payload.length - leading - trailing === rangeLength) { + dropLeading = leading; + dropTrailing = trailing; + } + } else if (leading > 0 && rangeLength > 1) { + if (group.payload.length - leading >= rangeLength) { + dropLeading = leading; + } else { + ambiguities.push({ + startLine: group.startLine, + endLine: group.endLine, + side: "leading", + count: leading, + }); + } + } else if (trailing > 0 && rangeLength > 1) { + if (group.payload.length - trailing >= rangeLength) { + dropTrailing = trailing; + } else { + ambiguities.push({ + startLine: group.startLine, + endLine: group.endLine, + side: "trailing", + count: trailing, }); - continue; } - slots.push({ kind: "edits", edits: [...inserts, ...deletes] }); - continue; } + if (dropLeading > 0 || dropTrailing > 0) { + out.push(...inserts.slice(dropLeading, inserts.length - dropTrailing), ...deletes); + warnings.push(textualBoundaryEchoWarning(group.startLine, dropLeading, dropTrailing)); + } else { + for (const idx of group.insertIndices) out.push(cloneAppliedEdit(edits[idx], idx)); + for (const idx of group.deleteIndices) out.push(cloneAppliedEdit(edits[idx], idx)); + } + i = group.deleteIndices[group.deleteIndices.length - 1] + 1; + } + return { edits: out, warnings, ambiguities }; +} - const dupSuffix = findDuplicateSuffix(group, fileLines, delta); - if (dupSuffix > 0) { - slots.push({ - kind: "edits", - edits: [...inserts.slice(0, inserts.length - dupSuffix), ...deletes], - warning: describeBoundaryRepair( - group, - `dropped ${dupSuffix} duplicated trailing payload line(s) already present below the range`, - ), - }); - continue; +interface KeepPlan { + readonly beforeLine?: number; + readonly afterLine?: number; + readonly kept: number; +} + +interface GroupVariant { + readonly edits: AppliedEdit[]; + /** Original boundary rows retained from the selected range. */ + readonly kept: number; + /** Exact payload echoes removed from outside the selected range. */ + readonly dropped: number; +} + +interface GroupVariants { + readonly variants: GroupVariant[]; + readonly ambiguous: boolean; +} + +const INDENT_TAB_WIDTH = 4; + +function indentColumns(line: string): number { + let column = 0; + for (let i = 0; i < line.length; i++) { + const code = line.charCodeAt(i); + if (code === 32) { + column++; + } else if (code === 9) { + column += INDENT_TAB_WIDTH - (column % INDENT_TAB_WIDTH); + } else { + break; } - const dupPrefix = findDuplicatePrefix(group, fileLines, delta); - if (dupPrefix > 0) { - slots.push({ - kind: "edits", - edits: [...inserts.slice(dupPrefix), ...deletes], - warning: describeBoundaryRepair( - group, - `dropped ${dupPrefix} duplicated leading payload line(s) already present above the range`, - ), - }); - continue; + } + return column; +} + +function nearestContentLine(fileLines: readonly string[], start: number, step: 1 | -1): string | undefined { + for (let index = start; index >= 0 && index < fileLines.length; index += step) { + const line = fileLines[index]; + if (line !== undefined && hasNonWhitespace(line)) return line; + } + return undefined; +} + +function payloadEdge(payload: readonly string[], side: "leading" | "trailing"): string | undefined { + if (side === "leading") { + for (const line of payload) { + if (hasNonWhitespace(line)) return line; } - slots.push({ kind: "candidate", group, inserts, deletes, delta }); + return undefined; + } + for (let index = payload.length - 1; index >= 0; index--) { + const line = payload[index]; + if (line !== undefined && hasNonWhitespace(line)) return line; + } + return undefined; +} + +function replacementInserts(group: ReplacementGroup, edits: readonly AppliedEdit[]): InsertEdit[] { + const inserts: InsertEdit[] = []; + for (const index of group.insertIndices) { + const edit = edits[index]; + if (edit?.kind === "insert") inserts.push(edit); + } + return inserts; +} + +function replacementDeletes(group: ReplacementGroup, edits: readonly AppliedEdit[]): DeleteEdit[] { + const deletes: DeleteEdit[] = []; + for (const index of group.deleteIndices) { + const edit = edits[index]; + if (edit?.kind === "delete") deletes.push(edit); + } + return deletes; +} + +function isSourceLineDeleted(edits: readonly AppliedEdit[], line: number): boolean { + return edits.some(edit => edit.kind === "delete" && edit.anchor.line === line); +} + +/** + * Ignore a deleted trailing row only when the identical next source row + * survives every hunk. The preceding deleted row then becomes the effective + * range edge without resurrecting arbitrary interior content. + */ +function effectiveTrailingBoundary( + group: ReplacementGroup, + edits: readonly AppliedEdit[], + fileLines: readonly string[], +): number { + let line = group.endLine; + let survivor = group.endLine + 1; + while ( + line > group.startLine && + survivor <= fileLines.length && + !isSourceLineDeleted(edits, survivor) && + fileLines[line - 1] === fileLines[survivor - 1] + ) { + line--; + survivor++; + } + return line; +} + +/** Whether deleting one source row from an otherwise valid file breaks it. */ +function isSyntaxEssentialRow( + fileLines: readonly string[], + path: string, + line: number, + baselineParses: boolean, +): boolean { + if (!baselineParses) return true; + const without = [...fileLines.slice(0, line - 1), ...fileLines.slice(line)].join("\n"); + return !parsesCleanly(path, without); +} + +interface EdgeEvidence { + readonly first: boolean; + readonly last: boolean; + readonly leadingStructure: boolean; +} + +function edgeEvidence( + fileLines: readonly string[], + path: string, + group: ReplacementGroup, + trailingLine: number, + baselineParses: boolean, +): EdgeEvidence { + if (!baselineParses) { + return { first: true, last: true, leadingStructure: false }; + } + const first = isSyntaxEssentialRow(fileLines, path, group.startLine, true); + const last = trailingLine === group.startLine ? first : isSyntaxEssentialRow(fileLines, path, trailingLine, true); + const innerStart = group.startLine + 1; + const leadingStructure = + innerStart <= trailingLine && + enclosingBoundaries(fileLines, path, innerStart, trailingLine).includes(group.startLine); + return { first, last, leadingStructure }; +} + +/** + * Retention is limited to the selected range's first and effective-last rows. + * On a valid baseline, deleting the row must break syntax; every candidate + * must also satisfy source-range structure or indentation evidence. + */ +function buildKeepPlans( + group: ReplacementGroup, + trailingLine: number, + payload: readonly string[], + fileLines: readonly string[], + evidence: EdgeEvidence, + path: string, + baselineParses: boolean, +): { plans: KeepPlan[]; ambiguous: boolean } { + const leadingPayload = payloadEdge(payload, "leading"); + const trailingPayload = payloadEdge(payload, "trailing"); + const plans: KeepPlan[] = [{ kept: 0 }]; + if (leadingPayload === undefined || trailingPayload === undefined) return { plans, ambiguous: false }; + + const first = fileLines[group.startLine - 1] ?? ""; + const last = fileLines[trailingLine - 1] ?? ""; + const leadingIndent = indentColumns(leadingPayload); + const trailingIndent = indentColumns(trailingPayload); + const firstIndent = indentColumns(first); + const lastIndent = indentColumns(last); + let ambiguous = false; + + if (group.startLine === trailingLine) { + const previous = nearestContentLine(fileLines, group.startLine - 2, -1); + const fitsBefore = previous === undefined || indentColumns(previous) === trailingIndent; + if (evidence.first && fitsBefore && trailingIndent > firstIndent) { + plans.push({ afterLine: group.startLine, kept: 1 }); + } else if (baselineParses && evidence.first && trailingIndent === firstIndent) { + ambiguous = true; + } + return { plans, ambiguous }; } - const projected: AppliedEdit[] = []; - for (const slot of slots) { - projected.push(...(slot.kind === "candidate" ? [...slot.inserts, ...slot.deletes] : slot.edits)); + const next = nearestContentLine(fileLines, group.startLine, 1); + const previous = nearestContentLine(fileLines, trailingLine - 2, -1); + const beforeFirst = nearestContentLine(fileLines, group.startLine - 2, -1); + const selectedLeadingBoundary = enclosingBoundaries(fileLines, path, group.startLine + 1, group.endLine).includes( + group.startLine, + ); + const firstText = (fileLines[group.startLine - 1] ?? "").trim(); + const selectedStructuralEdge = + STRUCTURAL_CLOSER_RE.test(firstText) && + firstIndent === leadingIndent && + firstIndent === indentColumns(fileLines[group.endLine - 1] ?? ""); + const underfilledEffectiveEdge = + trailingLine < group.endLine && payload.length < group.endLine - group.startLine + 1; + const keepsLeading = + evidence.first && + (evidence.leadingStructure || selectedLeadingBoundary || selectedStructuralEdge || underfilledEffectiveEdge) && + (next === undefined || selectedStructuralEdge + ? leadingIndent >= firstIndent + : indentColumns(next) === leadingIndent); + const keepsTrailing = + (evidence.last || underfilledEffectiveEdge) && + !keepsLeading && + trailingIndent > lastIndent && + (previous === undefined || indentColumns(previous) === trailingIndent); + if (keepsLeading) plans.push({ beforeLine: group.startLine, kept: 1 }); + if (keepsTrailing) plans.push({ afterLine: trailingLine, kept: 1 }); + // Retaining both edges can silently resurrect an intentionally removed + // wrapper or signature; parsing cannot distinguish that from omission. + if ( + baselineParses && + evidence.first && + beforeFirst !== undefined && + firstIndent < indentColumns(beforeFirst) && + leadingIndent > firstIndent + ) { + ambiguous = true; } - const deletedLines = new Set(); - for (const edit of projected) { - if (edit.kind === "delete") deletedLines.add(edit.anchor.line); - } - const insertedByLine = new Map(); - const insertedLineMaps: { before: Map; after: Map } = { - before: new Map(), - after: new Map(), - }; - for (const edit of projected) { - if (edit.kind === "insert" && edit.cursor.kind === "bof") { - const inserted = insertedByLine.get(1); - if (inserted) inserted.push(edit.text); - else insertedByLine.set(1, [edit.text]); - const before = insertedLineMaps.before.get(1); - if (before) before.push(edit.text); - else insertedLineMaps.before.set(1, [edit.text]); - continue; - } - if (edit.kind !== "insert") continue; - for (const anchor of getCursorAnchors(edit.cursor)) { - const lines = insertedByLine.get(anchor.line); - if (lines) lines.push(edit.text); - else insertedByLine.set(anchor.line, [edit.text]); - } - if (edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor") { - const bySide = edit.cursor.kind === "before_anchor" ? insertedLineMaps.before : insertedLineMaps.after; - const lines = bySide.get(edit.cursor.anchor.line); - if (lines) lines.push(edit.text); - else bySide.set(edit.cursor.anchor.line, [edit.text]); - } - } - let remainingDelta: DelimiterBalance = { paren: 0, bracket: 0, brace: 0 }; - for (const slot of slots) remainingDelta = balanceSum(remainingDelta, slotPatchDelta(slot, fileLines)); + return { plans, ambiguous }; +} - const out: AppliedEdit[] = []; - const warnings: string[] = []; - const advisories: string[] = []; - let suspicious = false; - let sparesProposed = false; - for (const slot of slots) { - if (slot.kind !== "candidate") { - if (slot.warning !== undefined) warnings.push(slot.warning); - out.push(...slot.edits); - continue; - } - const deletedPrefixBalance = netDeletedPrefixBalance(slot.group, deletedLines, insertedByLine, fileLines); - const droppedClosers = findDroppedSuffixClosers( - slot.group, - fileLines, - slot.delta, - remainingDelta, - deletedPrefixBalance, - deletedLines, - insertedByLine, - insertedLineMaps, - ); - if (droppedClosers) { - suspicious = true; - sparesProposed = true; - if (!applySpares) { - // A lone `}` is only syntax if the parser says so. Keep the - // authored edit; `sparesProposed` tells the caller a repair is - // available should the probe decline to vouch for it. - out.push(...slot.inserts, ...slot.deletes); - continue; - } - // Sparing a closer re-inserts it *after* the payload, which claims - // the payload lives inside the block the closer terminates. That - // claim needs evidence: the payload carries the closer's unmatched - // opener itself, or its indentation sits deeper than the closer. - // Without either, "before or after the closer" is a coin flip — - // reject rather than guess (e.g. a statement swapped onto a lone - // `}` at the closer's own depth belongs after the block). - const keptIndent = leadingIndent(fileLines[droppedClosers.startLine - 1] ?? ""); - const payloadIndent = bodyTargetIndent(slot.group.payload); - const payloadOpens = balanceCovers( - computeDelimiterBalance(slot.group.payload), - balanceNegate(droppedClosers.balance), - ); - if (!payloadOpens && !(payloadIndent !== undefined && isIndentDeeper(payloadIndent, keptIndent))) { - throw new CloserSpareAmbiguityError( - ambiguousCloserSpareMessage( - slot.group.startLine, - slot.group.endLine, - droppedClosers.startLine, - droppedClosers.count, - ), - ); - } - warnings.push( - describeBoundaryRepair( - slot.group, - `kept ${droppedClosers.count} structural closing line(s) the range deleted without restating`, - ), - ); - out.push( - ...slot.inserts, - ...slot.deletes.filter( - edit => - edit.kind !== "delete" || - edit.anchor.line < droppedClosers.startLine || - edit.anchor.line >= droppedClosers.startLine + droppedClosers.count, - ), - ); - for (let line = droppedClosers.startLine; line < droppedClosers.startLine + droppedClosers.count; line++) { - deletedLines.delete(line); - } - remainingDelta = balanceSum(remainingDelta, droppedClosers.balance); - continue; - } - const droppedPrefix = findDroppedPrefixClosers( - slot.group, - fileLines, - slot.delta, - remainingDelta, - deletedPrefixBalance, - deletedLines, - insertedByLine, - ); - if (droppedPrefix) { - suspicious = true; - sparesProposed = true; - if (!applySpares) { - out.push(...slot.inserts, ...slot.deletes); - continue; - } - // Sparing a leading closer re-inserts it *before* the payload, - // which claims the payload lives outside (after) the block the - // closer terminates. The payload's indentation makes that call: - // at-or-above the closer's depth is sibling position; a deeper or - // incomparable claim would put the payload inside the block the - // range just closed — reject rather than guess. - const closerIndent = leadingIndent(fileLines[slot.group.startLine - 1] ?? ""); - const payloadIndent = bodyTargetIndent(slot.group.payload); - if (payloadIndent !== undefined) { - if (!closerIndent.startsWith(payloadIndent)) { - throw new CloserSpareAmbiguityError( - ambiguousLeadingCloserSpareMessage(slot.group.startLine, slot.group.endLine, droppedPrefix.count), - ); +/** + * Enumerate repair hypotheses for one replacement group. Exact outside echoes + * may be removed; edge retention requires syntax, source structure, + * indentation, or the narrow pure-closer sibling-depth shape. Tree-sitter + * validates every candidate result. + */ +function buildGroupVariants( + group: ReplacementGroup, + edits: readonly AppliedEdit[], + fileLines: readonly string[], + path: string, + baselineParses: boolean, +): GroupVariants { + const inserts = replacementInserts(group, edits); + const deletes = replacementDeletes(group, edits); + const trailingLine = effectiveTrailingBoundary(group, edits, fileLines); + const evidence = edgeEvidence(fileLines, path, group, trailingLine, baselineParses); + const dropJ = countDuplicateLeadingBoundaryLines(group, fileLines); + const dropK = countDuplicateTrailingBoundaryLines(group, fileLines); + const leadingDrops = dropJ > 0 ? [0, dropJ] : [0]; + const trailingDrops = dropK > 0 ? [0, dropK] : [0]; + const variants: GroupVariant[] = []; + let ambiguous = false; + + for (const leadingDrop of leadingDrops) { + for (const trailingDrop of trailingDrops) { + const dropped = leadingDrop + trailingDrop; + if (dropped >= inserts.length) continue; + const payload = group.payload.slice(leadingDrop, group.payload.length - trailingDrop); + const keepResult = buildKeepPlans(group, trailingLine, payload, fileLines, evidence, path, baselineParses); + ambiguous ||= keepResult.ambiguous; + for (const keep of keepResult.plans) { + if (keep.kept === 0 && dropped === 0) continue; + if (keep.kept > 0 && group.deleteIndices.length > 1 && payload.length > group.deleteIndices.length) { + continue; } - const spareEnd = slot.group.startLine + droppedPrefix.count; - warnings.push( - describeBoundaryRepair( - slot.group, - `kept ${droppedPrefix.count} leading structural closing line(s) the range deleted without restating; the payload lands after them`, + variants.push({ + kept: keep.kept, + dropped, + edits: applyGroupVariant( + inserts, + deletes, + keep.beforeLine, + keep.afterLine, + leadingDrop, + trailingDrop, + fileLines.length, ), - ); - out.push( - ...slot.inserts.map(edit => - edit.kind === "insert" - ? { ...edit, cursor: { kind: "before_anchor" as const, anchor: { line: spareEnd } } } - : edit, - ), - ...slot.deletes.filter(edit => edit.kind !== "delete" || edit.anchor.line >= spareEnd), - ); - for (let line = slot.group.startLine; line < spareEnd; line++) deletedLines.delete(line); - remainingDelta = balanceSum(remainingDelta, droppedPrefix.balance); - continue; + }); } } - const orphanedOpeners = countOrphanedOpeners(slot.group, slot.delta, remainingDelta, fileLines); - if (orphanedOpeners > 0) { - suspicious = true; - advisories.push(midBlockRangeWarning(slot.group.startLine, slot.group.endLine, orphanedOpeners)); - } - out.push(...slot.inserts, ...slot.deletes); } - return { edits: out, warnings, suspicious, sparesProposed, advisories }; + variants.sort(compareGroupVariant); + return { variants, ambiguous }; +} + +function compareGroupVariant(a: GroupVariant, b: GroupVariant): number { + return a.kept - b.kept || a.dropped - b.dropped; +} + +function applyGroupVariant( + inserts: readonly InsertEdit[], + deletes: readonly DeleteEdit[], + beforeLine: number | undefined, + afterLine: number | undefined, + dropLeading: number, + dropTrailing: number, + fileLineCount: number, +): AppliedEdit[] { + let retainedInserts = inserts.slice(dropLeading, inserts.length - dropTrailing); + const retainedDeletes = deletes.filter(edit => edit.anchor.line !== beforeLine && edit.anchor.line !== afterLine); + if (beforeLine !== undefined) { + const cursor: Cursor = + beforeLine >= fileLineCount ? { kind: "eof" } : { kind: "before_anchor", anchor: { line: beforeLine + 1 } }; + retainedInserts = retainedInserts.map(edit => ({ ...edit, cursor })); + } + return [...retainedInserts, ...retainedDeletes]; +} + +/** One choice per broken group; `null` keeps the group as authored. */ +interface BoundaryCombo { + readonly variants: readonly (GroupVariant | null)[]; + readonly touched: number; + readonly kept: number; + readonly dropped: number; +} + +/** Combinatorial cap after each group is added to the candidate beam. */ +const MAX_BOUNDARY_COMBOS = 512; + +function compareBoundaryCombo(a: BoundaryCombo, b: BoundaryCombo): number { + return a.touched - b.touched || a.kept - b.kept || a.dropped - b.dropped; +} + +/** + * Search boundary hypotheses across the whole patch. Parsing is the semantic + * filter; the deterministic cost prefers fewer touched groups, retained rows, + * then exact echo drops. Different texts tied at that full cost are not + * guessed. + */ +function repairBoundaryVariants( + edits: readonly AppliedEdit[], + fileLines: readonly string[], + path: string | undefined, + baselineParses: boolean, +): { edits: AppliedEdit[]; warnings: string[] } | undefined { + if (path === undefined) return undefined; + + const groups: { group: ReplacementGroup; variants: GroupVariant[] }[] = []; + let ambiguousGroup: ReplacementGroup | undefined; + let i = 0; + while (i < edits.length) { + const group = findReplacementGroup(edits, i); + if (group) { + const built = buildGroupVariants(group, edits, fileLines, path, baselineParses); + if (built.ambiguous && ambiguousGroup === undefined) ambiguousGroup = group; + if (built.variants.length > 0) groups.push({ group, variants: built.variants }); + i = group.deleteIndices[group.deleteIndices.length - 1] + 1; + } else { + i++; + } + } + if (groups.length === 0) { + if (ambiguousGroup) { + throw new Error(ambiguousBoundaryPlacementMessage(ambiguousGroup.startLine, ambiguousGroup.endLine)); + } + return undefined; + } + + let combos: BoundaryCombo[] = [{ variants: [], touched: 0, kept: 0, dropped: 0 }]; + for (const { variants } of groups) { + const next: BoundaryCombo[] = []; + for (const combo of combos) { + next.push({ ...combo, variants: [...combo.variants, null] }); + for (const variant of variants) { + next.push({ + variants: [...combo.variants, variant], + touched: combo.touched + 1, + kept: combo.kept + variant.kept, + dropped: combo.dropped + variant.dropped, + }); + } + } + next.sort(compareBoundaryCombo); + combos = next.slice(0, MAX_BOUNDARY_COMBOS); + } + + const authored = materializeEdits( + fileLines, + edits.map((edit, index) => cloneAppliedEdit(edit, index)), + ).text; + const candidates = combos.filter(combo => combo.touched > 0).sort(compareBoundaryCombo); + + let bestText: string | undefined; + let bestCombo: BoundaryCombo | undefined; + for (const combo of candidates) { + if (bestCombo !== undefined && compareBoundaryCombo(combo, bestCombo) > 0) break; + const candidate = spliceBoundaryCombo(edits, groups, combo); + const text = materializeEdits(fileLines, candidate).text; + if (text === authored || !parsesCleanly(path, text)) continue; + if (bestCombo === undefined) { + bestCombo = combo; + bestText = text; + continue; + } + if (text !== bestText) { + if (ambiguousGroup) { + throw new Error(ambiguousBoundaryPlacementMessage(ambiguousGroup.startLine, ambiguousGroup.endLine)); + } + return undefined; + } + } + if (bestCombo === undefined) { + if (ambiguousGroup) { + throw new Error(ambiguousBoundaryPlacementMessage(ambiguousGroup.startLine, ambiguousGroup.endLine)); + } + return undefined; + } + + const warnings: string[] = []; + groups.forEach((entry, index) => { + const variant = bestCombo.variants[index]; + if (variant) warnings.push(boundaryVariantRepairWarning(entry.group.startLine, variant.kept, variant.dropped)); + }); + return { edits: spliceBoundaryCombo(edits, groups, bestCombo), warnings }; +} + +/** Replace each group's authored edits with its combo variant (or the authored + * edits where the combo leaves a group untouched). Groups are keyed by their + * first insert index — `findReplacementGroup` builds fresh objects per scan, + * so object identity cannot be the key. */ +function spliceBoundaryCombo( + edits: readonly AppliedEdit[], + groups: readonly { group: ReplacementGroup; variants: GroupVariant[] }[], + combo: BoundaryCombo, +): AppliedEdit[] { + const chosen = new Map(); + groups.forEach((entry, idx) => { + const variant = combo.variants[idx]; + if (variant) chosen.set(entry.group.insertIndices[0], variant); + }); + const out: AppliedEdit[] = []; + let i = 0; + while (i < edits.length) { + const group = findReplacementGroup(edits, i); + if (!group) { + out.push(cloneAppliedEdit(edits[i], i)); + i++; + continue; + } + const variant = chosen.get(group.insertIndices[0]); + if (variant) { + out.push(...variant.edits); + } else { + for (const idx of group.insertIndices) out.push(cloneAppliedEdit(edits[idx], idx)); + for (const idx of group.deleteIndices) out.push(cloneAppliedEdit(edits[idx], idx)); + } + i = group.deleteIndices[group.deleteIndices.length - 1] + 1; + } + return out; } // ═══════════════════════════════════════════════════════════════════════════ @@ -1482,12 +1026,12 @@ function repairAfterInsertLandings( const retarget = (group: AfterInsertGroup, line: number): void => { out ??= [...edits]; for (const idx of group.members) { - const edit = out[idx] as InsertEdit; + const edit = insertEditAt(out, idx); out[idx] = { ...edit, cursor: { kind: "after_anchor", anchor: { line } } }; } }; for (const group of groups.values()) { - const target = bodyTargetIndent(group.members.map(idx => (edits[idx] as InsertEdit).text)); + const target = bodyTargetIndent(group.members.map(idx => insertEditAt(edits, idx).text)); if (target === undefined) continue; const outward = resolveShiftedLanding(group, target, fileLines, targetedLines); if (outward !== undefined) { @@ -1515,11 +1059,10 @@ export interface ApplyEditsOptions { /** Anonymous `PASTE` with an empty register: `throw` (default) or `drop` (streaming previews). An empty named-register paste never throws — it warns and pastes nothing. */ onEmptyPaste?: "throw" | "drop"; /** - * Target file path, used only to infer a language for the tree-sitter - * syntax probe (see {@link parsesCleanly}). Supplying it lets the applier - * confirm that the edit as authored still parses, in which case no - * delimiter-shape repair or advisory may touch it. Omitted, the probe casts - * no veto and the delimiter heuristics decide alone. + * Target path used to infer a language for the tree-sitter syntax probe. + * Required for syntax-essential boundary retention and post-apply syntax + * advisories. Without it, only exact-text boundary normalization and its + * evidence-complete rejections run. */ path?: string; } @@ -1623,17 +1166,14 @@ function materializeEdits(originalLines: readonly string[], edits: readonly Appl * Returns the post-edit text and the first changed line number (1-indexed). * Throws if an anchor is out of bounds. * - * Repairs that hinge on delimiter *semantics* (a range that swallowed the `}` - * closing the construct above or below it) are subject to a parser veto when - * `options.path` is supplied: the authored edits are materialized first, and if - * that result parses it is returned untouched. A `}` in prose, a string, or a - * regex literal is therefore never mistaken for a block closer. Only when the - * authored result does not parse — or the language is unknown to the parser, so - * balance arithmetic is the sole evidence — do the closer-spare repairs run. + * Mis-set replacement boundaries are repaired by {@link repairBoundaryVariants} + * when `options.path` lets tree-sitter judge the result. A parsing authored + * result is never second-guessed. For a broken result, only syntax-essential + * edge retention with matching indentation and exact outside-row echo removal + * are considered; every selected candidate must parse. */ export function applyEdits(text: string, edits: readonly Edit[], options: ApplyEditsOptions = {}): ApplyResult { if (edits.length === 0) return { text, firstChangedLine: undefined }; - setDelimiterScanLanguage(options.path); const fileLines = text.split("\n"); @@ -1648,10 +1188,12 @@ export function applyEdits(text: string, edits: readonly Edit[], options: ApplyE // Block edits are deferred until `resolveBlockEdits` expands them into // concrete inserts + deletes. Reaching the applier with one still present // is an internal wiring bug, not authored-input error. + const appliedEdits: AppliedEdit[] = []; for (const edit of concrete) { if (edit.kind === "block") throw new Error(UNRESOLVED_BLOCK_INTERNAL); + if (edit.kind === "cut" || edit.kind === "paste") throw new Error(UNRESOLVED_CLIPBOARD_INTERNAL); + appliedEdits.push(edit); } - const appliedEdits = concrete as readonly AppliedEdit[]; const targetEdits = dropTrailingPhantomDeletes( appliedEdits.map((edit, index) => cloneAppliedEdit(edit, index)), @@ -1659,20 +1201,17 @@ export function applyEdits(text: string, edits: readonly Edit[], options: ApplyE ); validateLineBounds(targetEdits, fileLines); const indentationWarnings = repairReplacementIndentation(targetEdits, fileLines); - const leading = [...clipboardWarnings, ...indentationWarnings]; - - // Pass 1: the authored edits, with every delimiter-semantics repair held - // back. Textual repairs (boundary echoes, duplicated payload lines) are - // evidence-complete on their own and already applied here. - const authored = repairReplacementBoundaries(targetEdits, fileLines, false); + const normalized = normalizeTextualBoundaryEchoes(targetEdits, fileLines); + const leading = [...clipboardWarnings, ...indentationWarnings, ...normalized.warnings]; + const authoredResult = materializeEdits(fileLines, normalized.edits); + const baselineParses = parsesCleanly(options.path, text); + const authoredParses = parsesCleanly(options.path, authoredResult.text); const finish = (result: Materialized, warnings: string[]): ApplyResult => { const merged = [...warnings, ...result.warnings]; // Post-apply syntax advisory: the result stopped parsing while the // pre-edit text parsed, so this patch demonstrably introduced the - // error. Catches balance-neutral misplacements (a statement swapped - // onto the wrong line) that no delimiter heuristic can see. Both - // probes are content-cached; a pathless call short-circuits to false. - if (!parsesCleanly(options.path, result.text) && parsesCleanly(options.path, text)) { + // error. Catches misplacements no boundary variant can explain. + if (!parsesCleanly(options.path, result.text) && baselineParses) { merged.push(editBrokeParseWarning(result.firstChangedLine)); } return { @@ -1681,36 +1220,30 @@ export function applyEdits(text: string, edits: readonly Edit[], options: ApplyE ...(merged.length > 0 ? { warnings: merged } : {}), }; }; - const authoredWarnings = [...leading, ...authored.warnings]; - if (!authored.suspicious) return finish(materializeEdits(fileLines, authored.edits), authoredWarnings); - const authoredResult = materializeEdits(fileLines, authored.edits); - // The authored edit keeps the file parsing, so no delimiter heuristic may - // second-guess its boundaries. This is what keeps a `}` in prose, in a - // string, or in a regex literal from ever being mistaken for a block closer. - if (parsesCleanly(options.path, authoredResult.text)) return finish(authoredResult, authoredWarnings); - - // The authored result does not parse — or the parser does not know this - // language, in which case nothing below can be proven and nothing is - // rewritten. A repair lands only when it is *shown* to restore a parsing - // file, never on delimiter arithmetic alone. - const baselineParses = parsesCleanly(options.path, text); - if (authored.sparesProposed) { - try { - const spared = repairReplacementBoundaries(targetEdits, fileLines, true); - const sparedResult = materializeEdits(fileLines, spared.edits); - if (parsesCleanly(options.path, sparedResult.text)) { - return finish(sparedResult, [...leading, ...spared.warnings, ...spared.advisories]); - } - } catch (error) { - // Only the closer-spare verdict is the parser's business, and only on - // a file it can vouch for. Every other rejection — notably the - // evidence-complete one-sided boundary echo, which is proven by exact - // line equality and would otherwise delete range lines the body never - // restates — propagates regardless of what the parser knows. - if (baselineParses || !(error instanceof CloserSpareAmbiguityError)) throw error; + const ambiguity = normalized.ambiguities[0]; + // Exact-text normalization is evidence-complete. If it leaves a parsing + // result, no speculative keep/drop variant may second-guess it. + if (authoredParses) { + if (ambiguity) { + throw new Error( + ambiguousBoundaryEchoMessage(ambiguity.startLine, ambiguity.endLine, ambiguity.side, ambiguity.count), + ); + } + return finish(authoredResult, leading); + } + const repaired = repairBoundaryVariants(normalized.edits, fileLines, options.path, baselineParses); + if (repaired) { + const repairedResult = materializeEdits(fileLines, repaired.edits); + if (parsesCleanly(options.path, repairedResult.text)) { + return finish(repairedResult, [...leading, ...repaired.warnings]); } } + if (ambiguity) { + throw new Error( + ambiguousBoundaryEchoMessage(ambiguity.startLine, ambiguity.endLine, ambiguity.side, ambiguity.count), + ); + } // Nothing proven: leave the authored edit exactly as written. Report the - // damage only when the baseline parsed, so this edit demonstrably caused it. - return finish(authoredResult, baselineParses ? [...authoredWarnings, ...authored.advisories] : authoredWarnings); + // damage — the baseline parsed, so this edit demonstrably caused it. + return finish(authoredResult, leading); } diff --git a/packages/hashline/src/messages.ts b/packages/hashline/src/messages.ts index 01002cae3..46e438faf 100644 --- a/packages/hashline/src/messages.ts +++ b/packages/hashline/src/messages.ts @@ -281,11 +281,10 @@ export function pasteAfterBlockUnresolvedLoweredWarning(line: number): string { return unresolvedLoweredWarning(`PUT >${line}*`, line, `PUT >${line}`); } /** - * A one-sided boundary echo whose payload is too short to be the widened - * range's full content: dropping the echo deletes range line(s) the payload - * never restates (the "widened range" reading), while the "range shifted by - * the echo" reading keeps them. The readings produce different files, so the - * edit is rejected instead of repaired. + * A one-sided exact boundary echo cannot cover the selected range after the + * duplicated body rows are removed. Applying or dropping it would lose + * distinct range content, so the edit is rejected unless a parse-restoring + * boundary combination proves another reading. */ export function ambiguousBoundaryEchoMessage( startLine: number, @@ -299,82 +298,60 @@ export function ambiguousBoundaryEchoMessage( : `ends by restating the ${count} line(s) just below the range`; return ( `\`PUT ${startLine}${HL_RANGE_SEP}${endLine}:\` rejected: the body ${where}, ` + - `but is too short to be the full final content of the widened range — applying it as-is or ` + - `auto-repairing would delete range line(s) the body never restates. ` + - `Re-issue with the range covering exactly the lines that change and the body as their complete ` + - `final content: drop the restated keeper from the body, or widen the range to consume it.` + `but is too short to be the full final content of the selected range. ` + + `Re-issue with the range covering exactly the lines that change and the body as their complete final content.` ); } /** - * A replacement range deletes trailing structural closer(s) the payload never - * restates, and nothing anchors the payload inside the block those closers - * terminate: the payload has no unmatched opener for them and its indentation - * is not deeper than the closer. Sparing the closer would have to guess - * whether the payload belongs before it (inside the block) or after it (a - * sibling), so the edit is rejected instead of repaired. + * A syntax-essential selected edge can be retained on either side of the + * payload, but indentation does not establish which placement was intended. */ -export function ambiguousCloserSpareMessage( - startLine: number, - endLine: number, - closerLine: number, - count: number, -): string { - const closers = count === 1 ? `line ${closerLine}` : `lines ${closerLine}-${closerLine + count - 1}`; +export function ambiguousBoundaryPlacementMessage(startLine: number, endLine: number): string { return ( - `\`PUT ${startLine}${HL_RANGE_SEP}${endLine}:\` rejected: the range deletes the closing-delimiter ` + - `${closers} but the body never restates it, and the body claims no position inside that block ` + - `(no unmatched opener, indentation not deeper than the closer) — whether the new content belongs ` + - `before or after the closer is ambiguous. Restate the closer in the body at the intended position, ` + - `or use \`PUT <${closerLine}:\` / \`PUT >${closerLine}:\` instead.` - ); -} -/** - * A replacement range starts by deleting structural closer(s) the payload - * never restates — the "range started one line early, on the `}` that ends - * the construct above" mistake — but the payload's indentation claims a depth - * inside the block those closers terminate, so whether the new content - * belongs before or after the spared closer is ambiguous. Rejected instead of - * repaired; the at-or-above-depth reading is auto-repaired by sparing the - * closer ahead of the payload. - */ -export function ambiguousLeadingCloserSpareMessage(startLine: number, endLine: number, count: number): string { - const closers = count === 1 ? `line ${startLine}` : `lines ${startLine}-${startLine + count - 1}`; - return ( - `\`PUT ${startLine}${HL_RANGE_SEP}${endLine}:\` rejected: the range starts by deleting the closing-delimiter ` + - `${closers} but the body never restates it, and the body's indentation claims a depth inside the block that ` + - `closer terminates — whether the new content belongs before or after the closer is ambiguous. ` + - `Start the range on the first line that actually changes, or restate the closer in the body at the intended position.` + `\`PUT ${startLine}${HL_RANGE_SEP}${endLine}:\` rejected: a selected boundary row is required for the file to parse, ` + + `but the body indentation does not establish whether it belongs before or after that row. ` + + `Re-read the region and re-issue with a range that excludes every unchanged boundary row.` ); } /** - * A replacement range deletes more opening delimiter(s) than the payload - * reopens while the matching closer(s) survive below the range — the - * "payload is a complete construct but the range ends mid-block" mistake. - * Surfaced as a warning, never a rejection: the applier is language-agnostic - * and opener/closer text shape cannot prove a syntactic block (the braces may - * be literal prose), so the edit applies as authored and the author decides. + * Exact-text boundary rows were removed because the remaining payload covers + * the selected range and the same rows already survive immediately outside it. */ -export function midBlockRangeWarning(startLine: number, endLine: number, orphaned: number): string { +export function textualBoundaryEchoWarning(startLine: number, leading: number, trailing: number): string { + const parts: string[] = []; + if (leading > 0) parts.push(`${leading} leading`); + if (trailing > 0) parts.push(`${trailing} trailing`); return ( - `\`PUT ${startLine}${HL_RANGE_SEP}${endLine}:\` deleted ${orphaned} opening delimiter(s) the body never ` + - `reopens. If this file is brace-structured code, the matching closing line(s) below the range are now ` + - `orphaned — the range likely ended mid-block. If the body was the construct's complete new content, ` + - `re-issue with a block op on the construct's opening line (\`PUT N*:\`) so the closing line resolves ` + - `automatically; if the delimiters are literal text, ignore this warning.` + `Auto-repaired a replacement boundary echo at line ${startLine}: dropped ${parts.join(" and ")} body line(s) ` + + `already present outside the range. Issue the body as final content for the selected range only.` + ); +} + +/** + * A replacement range's boundary disposition was corrected by the + * syntax-probe-judged search: syntax-essential source boundary rows were + * retained, or exact body echoes of surviving outside rows were removed. The + * authored result did not parse and the selected result does. + */ +export function boundaryVariantRepairWarning(startLine: number, kept: number, dropped: number): string { + const keptPart = kept === 0 ? "" : `retained ${kept} syntax-essential source boundary row(s) selected by the range`; + const droppedPart = dropped === 0 ? "" : `dropped ${dropped} body row(s) duplicated just outside the range`; + const action = [keptPart, droppedPart].filter(Boolean).join(" and "); + return ( + `Auto-repaired replacement boundaries at line ${startLine}: ${action}. ` + + `The result was verified by the syntax probe — re-issue with the range covering exactly the changed ` + + `lines and the body as their complete final content.` ); } /** * The applied result no longer parses while the pre-edit content did: the * patch introduced a syntax error. Advisory, never a rejection — the applier - * honors the authored edit — but the breakage is machine-confirmed (the - * tree-sitter probe parsed the original and rejects the result), so it is - * surfaced in the same response instead of waiting for a compiler pass. The - * classic trigger is a balance-neutral misplacement: a statement landed on - * the wrong line number with no delimiter anomaly for the repair heuristics - * to notice. + * honors the authored edit — but the breakage is machine-confirmed by + * tree-sitter and surfaced in the same response instead of waiting for a + * compiler pass. */ export function editBrokeParseWarning(firstChangedLine: number | undefined): string { const at = firstChangedLine === undefined ? "" : ` near line ${firstChangedLine}`; @@ -392,6 +369,10 @@ export function editBrokeParseWarning(firstChangedLine: number | undefined): str export const UNRESOLVED_BLOCK_INTERNAL = "internal error: unresolved block edit reached the applier (resolveBlockEdits was not run)."; +/** Internal invariant: clipboard edits must be concrete before application. */ +export const UNRESOLVED_CLIPBOARD_INTERNAL = + "internal error: unresolved clipboard edit reached the applier (resolveClipboardEdits was not run)."; + /** `REM` received a body row or coexists with line edits. */ export const REM_TAKES_NO_BODY = "`REM` deletes the whole file and takes no body rows or line ops. Issue it alone under the header."; diff --git a/packages/hashline/src/syntax.ts b/packages/hashline/src/syntax.ts index 9305aec64..e61788618 100644 --- a/packages/hashline/src/syntax.ts +++ b/packages/hashline/src/syntax.ts @@ -1,13 +1,12 @@ /** * Syntax probe for candidate edit results, via the native tree-sitter parser. * - * Delimiter-balance arithmetic cannot tell a block closer from a `}` inside a - * regex literal, a string, or Markdown prose. A parser can, so it holds veto - * power over every repair whose justification is "this line closes a syntactic - * block": when the edit the author actually wrote still parses, no such repair - * may rewrite it. The probe never *forces* a repair — an unrecognized language - * or an already-broken file simply yields no veto, leaving the delimiter - * heuristics as the only available evidence. + * Replacement-boundary repair uses parsing as its semantic filter: exact + * outside-row equality may justify dropping a duplicated payload edge, while + * retaining a selected source boundary additionally requires source-range + * structure, indentation, or a narrow pure-closer shape. An unrecognized + * language yields no structural proof, so only evidence-complete textual + * normalization remains available. */ import { enclosingBlockBoundaries } from "@oh-my-pi/pi-natives"; @@ -16,6 +15,33 @@ import { enclosingBlockBoundaries } from "@oh-my-pi/pi-natives"; const parseCache = new Map(); const PARSE_CACHE_MAX = 256; +const boundaryCache = new Map(); + +/** Syntactic node boundaries outside a visible source range. */ +export function enclosingBoundaries( + lines: readonly string[], + path: string, + startLine: number, + endLine: number, +): readonly number[] { + const text = lines.join("\n"); + const key = `${Bun.hash(text).toString(36)}:${text.length}:${path}:${startLine}:${endLine}`; + const cached = boundaryCache.get(key); + if (cached !== undefined) return cached; + let boundaries: readonly number[]; + try { + boundaries = enclosingBlockBoundaries({ code: text, path, ranges: [{ startLine, endLine }] }) ?? []; + } catch { + boundaries = []; + } + if (boundaryCache.size >= PARSE_CACHE_MAX) { + const oldest = boundaryCache.keys().next().value; + if (oldest !== undefined) boundaryCache.delete(oldest); + } + boundaryCache.set(key, boundaries); + return boundaries; +} + /** * `true` when `text` parses without a syntax error under the language inferred * from `path`. `false` covers "does not parse" and "cannot tell" alike — no diff --git a/packages/hashline/test/boundary-repair.test.ts b/packages/hashline/test/boundary-repair.test.ts index 03ca3a381..acdcec027 100644 --- a/packages/hashline/test/boundary-repair.test.ts +++ b/packages/hashline/test/boundary-repair.test.ts @@ -11,6 +11,12 @@ function apply(text: string, diff: string): { text: string; warnings: string[] } return { text: result.text, warnings: result.warnings ?? [] }; } +/** Applies JSX/TSX fixtures with the parser production `.tsx` files use. */ +function applyTsx(text: string, diff: string): { text: string; warnings: string[] } { + const result = applyEdits(text, parsePatch(diff).edits, { path: "fixture.tsx" }); + return { text: result.text, warnings: result.warnings ?? [] }; +} + /** Applies with a Rust path, for Rust-shaped fixtures. */ function applyRust(text: string, diff: string): { text: string; warnings: string[] } { const result = applyEdits(text, parsePatch(diff).edits, { path: "fixture.rs" }); @@ -23,6 +29,10 @@ function applyProse(text: string, diff: string): { text: string; warnings: strin return { text: result.text, warnings: result.warnings ?? [] }; } +function boundaryRepairWarnings(warnings: readonly string[]): string[] { + return warnings.filter(warning => /Auto-repaired (?:a )?replacement boundar/.test(warning)); +} + describe("boundary-balance repair", () => { it("restores a uniformly omitted base indent from unchanged structural rows", () => { const file = [ @@ -63,6 +73,16 @@ describe("boundary-balance repair", () => { expect(warnings.some(w => /Auto-indented a replacement body/.test(w))).toBe(false); }); + it("retains a swallowed opening comment fence when syntax and indentation prove the boundary", () => { + const file = ["class C {", "\t/**", "\t * Old summary.", "\t */", "\tmethod() {}", "}"].join("\n"); + const diff = ["PUT 2-4:", "+\t * New summary.", "+\t */"].join("\n"); + + const { text, warnings } = apply(file, diff); + + expect(text).toBe(["class C {", "\t/**", "\t * New summary.", "\t */", "\tmethod() {}", "}"].join("\n")); + expect(warnings).toEqual([expect.stringContaining("Auto-repaired replacement boundaries")]); + }); + // The canonical incident: a range-replace whose payload restates the // fragment + paren close that still live just below the range, doubling // `` and `);`. `replace 11.=31:` covers `const …` through the second `/>`. @@ -104,12 +124,12 @@ describe("boundary-balance repair", () => { "+\t\t", "+\t);", ].join("\n"); - const { text, warnings } = apply(file, diff); + const { text, warnings } = applyTsx(file, diff); // Exactly one `` and one `);` survive — no doubling. expect(text.split("\n").filter(l => l.trim() === "")).toHaveLength(1); expect(text.split("\n").filter(l => l.trim() === ");")).toHaveLength(1); expect(text.endsWith("\t\t\n\t);\n};")).toBe(true); - expect(warnings.some(w => /delimiter-balance/.test(w))).toBe(true); + expect(boundaryRepairWarnings(warnings)).toHaveLength(1); }); // Single structural-closer duplication: the range ends one line short and @@ -121,7 +141,7 @@ describe("boundary-balance repair", () => { const diff = ["PUT 2-3:", "+\tsetup2();", "+\trun2();", "+});"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(["it('a', () => {", "\tsetup2();", "\trun2();", "});", "after();"].join("\n")); - expect(warnings.some(w => /delimiter-balance/.test(w))).toBe(true); + expect(boundaryRepairWarnings(warnings)).toHaveLength(1); }); // Single structural-opener duplication: the range starts one line late and @@ -165,7 +185,7 @@ describe("boundary-balance repair", () => { ].join("\n"), ); expect(text.split("\n").filter(line => line === "\tplanRender(")).toHaveLength(1); - expect(warnings.some(w => /delimiter-balance/.test(w))).toBe(true); + expect(boundaryRepairWarnings(warnings)).toHaveLength(1); }); // A duplicated opener whose imbalance does NOT explain the delta is left alone. @@ -177,7 +197,7 @@ describe("boundary-balance repair", () => { const diff = ["PUT 2-2:", "+if (a) {", "+\tif (b) {", "+\t\tfoo();"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(["if (a) {", "if (a) {", "\tif (b) {", "\t\tfoo();", "}", "bar();"].join("\n")); - expect(warnings.some(w => /delimiter-balance/.test(w))).toBe(false); + expect(boundaryRepairWarnings(warnings)).toHaveLength(0); expect(warnings).toEqual([expect.stringContaining("introduced a syntax error")]); }); @@ -193,7 +213,7 @@ describe("boundary-balance repair", () => { "\n", ), ); - expect(warnings.some(w => /delimiter-balance/.test(w))).toBe(true); + expect(boundaryRepairWarnings(warnings)).toHaveLength(1); }); // If the selected range is already imbalanced internally, a payload that @@ -236,7 +256,7 @@ describe("boundary-balance repair", () => { ); expect(text.split("\n").filter(line => line === "func _cmd_travel_homeworld():")).toHaveLength(1); expect(text.split("\n").filter(line => line === "\tprint_status()")).toHaveLength(1); - expect(warnings.some(warning => /boundary echo/.test(warning))).toBe(true); + expect(boundaryRepairWarnings(warnings)).toHaveLength(1); }); it("preserves payloads where multi-line boundary echoes cover every line", () => { @@ -284,7 +304,7 @@ describe("boundary-balance repair", () => { const { text, warnings } = apply(file, diff); expect(text).toBe(["function f() {", "fresh();", "}"].join("\n")); - expect(warnings.some(warning => /boundary echo/.test(warning))).toBe(true); + expect(boundaryRepairWarnings(warnings)).toHaveLength(1); }); // Balance-preserving edits are never touched, even when the payload's last @@ -326,33 +346,33 @@ describe("boundary-balance repair", () => { const diff = ["PUT 2-3:", "+ a2();", "+ b2();", "+ const out = [];"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(["function f() {", " a2();", " b2();", " const out = [];", " return out;", "}"].join("\n")); - expect(warnings.some(warning => /boundary echo/.test(warning))).toBe(true); + expect(boundaryRepairWarnings(warnings)).toHaveLength(1); }); it("drops a one-sided JSX closer echo in a single-line expansion", () => { const file = ["const view = (", "
", " ", "
", ");"].join("\n"); const diff = ["PUT 3-3:", "+ ", "+ "].join("\n"); - const { text, warnings } = apply(file, diff); + const { text, warnings } = applyTsx(file, diff); expect(text).toBe(["const view = (", "
", " ", "
", ");"].join("\n")); expect(text.split("\n").filter(line => line === " ")).toHaveLength(1); - expect(warnings.some(warning => /boundary echo/.test(warning))).toBe(true); + expect(boundaryRepairWarnings(warnings)).toHaveLength(1); }); it("drops a JSX closer echo after a self-closing tag with a greater-than prop expression", () => { const file = ["const view = (", "", "old text", "", ");"].join("\n"); const diff = ["PUT 3-3:", "+ b} />", "+"].join("\n"); - const { text, warnings } = apply(file, diff); + const { text, warnings } = applyTsx(file, diff); expect(text).toBe(["const view = (", "", " b} />", "", ");"].join("\n")); expect(text.split("\n").filter(line => line === "")).toHaveLength(1); - expect(warnings.some(warning => /boundary echo/.test(warning))).toBe(true); + expect(boundaryRepairWarnings(warnings)).toHaveLength(1); }); it("preserves a nested JSX closer that matches the surviving parent closer", () => { const file = ["const view = (", '
', "old text", "
", ");"].join("\n"); const diff = ["PUT 3-3:", "+
", "+new text", "+
"].join("\n"); - const { text, warnings } = apply(file, diff); + const { text, warnings } = applyTsx(file, diff); expect(text).toBe( [ @@ -372,7 +392,7 @@ describe("boundary-balance repair", () => { it("preserves a nested JSX closer when the opener spans payload lines", () => { const file = ["const view = (", '
', "old text", "
", ");"].join("\n"); const diff = ["PUT 3-3:", "+", "+new text", "+"].join("\n"); - const { text, warnings } = apply(file, diff); + const { text, warnings } = applyTsx(file, diff); expect(text).toBe( [ @@ -398,7 +418,7 @@ describe("boundary-balance repair", () => { const diff = ["PUT 3-4:", "+a();", "+B();", "+C();"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(["setup();", "a();", "B();", "C();"].join("\n")); - expect(warnings.some(warning => /boundary echo/.test(warning))).toBe(true); + expect(boundaryRepairWarnings(warnings)).toHaveLength(1); }); // A one-sided echo whose payload cannot fill the widened range is rejected, // not repaired: dropping the echo would silently delete the range's far @@ -443,7 +463,7 @@ describe("boundary-balance repair", () => { " handle.setIdent(currentIdent());", ].join("\n"); const diff = ["PUT 4-4:", "+ after();"].join("\n"); - expect(() => apply(file, diff)).toThrow(/before or after the closer is ambiguous/); + expect(() => apply(file, diff)).toThrow(/selected boundary row is required/); }); // Contrast with the rejection above: a payload indented deeper than the @@ -455,7 +475,7 @@ describe("boundary-balance repair", () => { expect(text).toBe( ["if (!global) {", " setDone();", " return;", " setIdent();", "}", "after();"].join("\n"), ); - expect(warnings.filter(warning => /structural closing line/.test(warning))).toHaveLength(1); + expect(boundaryRepairWarnings(warnings)).toHaveLength(1); }); // #3142: the range's deleted `}` is matched by an opener another hunk deletes // (`CUT 1`). The patch nets to balanced, so the closer must stay deleted — @@ -465,7 +485,7 @@ describe("boundary-balance repair", () => { const diff = ["CUT 1", "PUT 2-3:", '+Text("New")'].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(['Text("New")', '\tText("Tail")'].join("\n")); - expect(warnings.filter(warning => /structural closing line/.test(warning))).toHaveLength(0); + expect(boundaryRepairWarnings(warnings)).toHaveLength(0); }); // A wrapper removal and a genuine missing closer in the same patch: the @@ -475,7 +495,7 @@ describe("boundary-balance repair", () => { const diff = ["CUT 1", "PUT 2-3:", '+Text("New")', "PUT 6-6:", "+\tb: 2,"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(['Text("New")', "const config = {", "\ta: 1,", "\tb: 2,", "};"].join("\n")); - expect(warnings.filter(warning => /structural closing line/.test(warning))).toHaveLength(1); + expect(boundaryRepairWarnings(warnings)).toHaveLength(1); }); // A replaced opener (not removed) leaves a genuine missing closer downstream: @@ -485,7 +505,7 @@ describe("boundary-balance repair", () => { const diff = ["PUT 1-1:", "+if (b) {", "PUT 2-3:", "+\tfresh();"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(["if (b) {", "\tfresh();", "}"].join("\n")); - expect(warnings.filter(warning => /structural closing line/.test(warning))).toHaveLength(1); + expect(boundaryRepairWarnings(warnings)).toHaveLength(1); }); it("does not keep deleted closer suffixes whose tail the payload already restates", () => { @@ -515,7 +535,7 @@ describe("boundary-balance repair", () => { "}", ].join("\n"), ); - expect(warnings.filter(warning => /structural closing line/.test(warning))).toHaveLength(0); + expect(boundaryRepairWarnings(warnings)).toHaveLength(0); }); it("keeps only the non-restated outer closer for a nested deleted suffix", () => { @@ -523,7 +543,7 @@ describe("boundary-balance repair", () => { const diff = ["PUT 2-4:", "+\tnewMethod() {", "+\t\treturn 1;", "+\t}"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(["class C {", "\tnewMethod() {", "\t\treturn 1;", "\t}", "}"].join("\n")); - expect(warnings.filter(warning => /structural closing line/.test(warning))).toHaveLength(1); + expect(boundaryRepairWarnings(warnings)).toHaveLength(1); }); it("ignores non-contiguously deleted openers when choosing which closer to keep", () => { @@ -531,7 +551,7 @@ describe("boundary-balance repair", () => { const diff = ["CUT 1", "PUT 3-4:", "+\tfresh();", "PUT 7-7:", "+\tb: 2,"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(["\told();", "\tfresh();", "const obj = {", "\ta: 1,", "\tb: 2,", "};"].join("\n")); - expect(warnings.filter(warning => /structural closing line/.test(warning))).toHaveLength(1); + expect(boundaryRepairWarnings(warnings)).toHaveLength(1); }); it("counts earlier kept closers in later projected prefixes", () => { @@ -562,7 +582,7 @@ describe("boundary-balance repair", () => { "}", ].join("\n"), ); - expect(warnings.filter(warning => /structural closing line/.test(warning))).toHaveLength(1); + expect(boundaryRepairWarnings(warnings)).toHaveLength(1); }); it("does not let an earlier kept closer cover a later orphan closer", () => { @@ -570,7 +590,7 @@ describe("boundary-balance repair", () => { const diff = ["PUT 2-3:", "+\tfresh();", "PUT 4-4:", "+after();"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(["if (a) {", "\tfresh();", "}", "after();"].join("\n")); - expect(warnings.filter(warning => /structural closing line/.test(warning))).toHaveLength(1); + expect(boundaryRepairWarnings(warnings)).toHaveLength(1); }); it("does not keep a deleted outer closer when one survives below the range", () => { @@ -578,7 +598,7 @@ describe("boundary-balance repair", () => { const diff = ["PUT 2-5:", "+\tmethod() {", "+\t\tfresh();", "+\t}"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(["class C {", "\tmethod() {", "\t\tfresh();", "\t}", "}"].join("\n")); - expect(warnings.filter(warning => /structural closing line/.test(warning))).toHaveLength(0); + expect(boundaryRepairWarnings(warnings)).toHaveLength(0); }); it("keeps an omitted inner closer when the outer closer survives below", () => { @@ -586,7 +606,7 @@ describe("boundary-balance repair", () => { const diff = ["PUT 2-5:", "+\tmethod() {", "+\t\tfresh();"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(["class C {", "\tmethod() {", "\t\tfresh();", "\t}", "}"].join("\n")); - expect(warnings.filter(warning => /structural closing line/.test(warning))).toHaveLength(1); + expect(boundaryRepairWarnings(warnings)).toHaveLength(1); }); it("counts head insertions before replacement payloads in original coordinates", () => { @@ -594,7 +614,7 @@ describe("boundary-balance repair", () => { const diff = ["PUT <1:", "+if (a) {", "PUT 1-2:", "+\tfresh();"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(["if (a) {", "\tfresh();", "}"].join("\n")); - expect(warnings.some(warning => /kept 1 structural closing line/.test(warning))).toBe(true); + expect(boundaryRepairWarnings(warnings)).toHaveLength(1); }); it("counts a separately inserted closer immediately below the range", () => { @@ -604,7 +624,7 @@ describe("boundary-balance repair", () => { expect(text).toBe( ["class C {", "\tfresh();", "}", "after();", "const obj = {", "\ta: 1,", "\tb: 2,", "};"].join("\n"), ); - expect(warnings.filter(warning => /structural closing line/.test(warning))).toHaveLength(1); + expect(boundaryRepairWarnings(warnings)).toHaveLength(1); }); it("keeps an omitted outer closer even when the payload restates an inner closer", () => { @@ -612,7 +632,7 @@ describe("boundary-balance repair", () => { const diff = ["PUT 1-5:", "+if (a) {", "+\tif (c) {", "+\t\tfresh();", "+\t}"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(["if (a) {", "\tif (c) {", "\t\tfresh();", "\t}", "}", "after();"].join("\n")); - expect(warnings.filter(warning => /structural closing line/.test(warning))).toHaveLength(1); + expect(boundaryRepairWarnings(warnings)).toHaveLength(1); }); // A dupSuffix repair in hunk A zeroes its contribution; the residual must be @@ -644,8 +664,7 @@ describe("boundary-balance repair", () => { "};", ].join("\n"), ); - expect(warnings.some(warning => /trailing payload line/.test(warning))).toBe(true); - expect(warnings.some(warning => /structural closing line/.test(warning))).toBe(true); + expect(boundaryRepairWarnings(warnings)).toHaveLength(2); }); // Per-slot residual: an unterminated backtick template in one hunk must not @@ -655,7 +674,7 @@ describe("boundary-balance repair", () => { const diff = ["PUT 1-1:", "+const log = createLog(`", "PUT 5-6:", "+\ta: 2"].join("\n"); const { text, warnings } = apply(file, diff); expect(text).toBe(["const log = createLog(`", "prefix", "`);", "const obj = {", "\ta: 2", "};"].join("\n")); - expect(warnings.filter(warning => /structural closing line/.test(warning))).toHaveLength(1); + expect(boundaryRepairWarnings(warnings)).toHaveLength(1); }); // The neon.rs incident: the range starts one line early, on the lone `}` // closing the `if` above, and the payload (sibling-depth statements) never @@ -686,7 +705,7 @@ describe("boundary-balance repair", () => { "}", ].join("\n"), ); - expect(warnings.filter(warning => /leading structural closing line/.test(warning))).toHaveLength(1); + expect(boundaryRepairWarnings(warnings)).toHaveLength(1); }); // A payload indented deeper than the swallowed closer claims the inside of @@ -695,7 +714,7 @@ describe("boundary-balance repair", () => { it("rejects a swallowed leading closer when the payload claims the block interior", () => { const file = ["fn f() {", "\tif a {", "\t\treturn;", "\t}", "\tlet lead = old1();", "}"].join("\n"); const diff = ["PUT 4-5:", "+\t\tcompute();", "+\t\tstore();"].join("\n"); - expect(() => applyRust(file, diff)).toThrow(/starts by deleting the closing-delimiter/); + expect(() => applyRust(file, diff)).toThrow(/selected boundary row is required/); }); // Deliberate two-hunk unwrap: another hunk deletes the matching `if` opener, @@ -705,7 +724,7 @@ describe("boundary-balance repair", () => { const diff = ["PUT 2-2:", "+\tguard();", "PUT 4-5:", "+\tlet lead = new1();"].join("\n"); const { text, warnings } = applyRust(file, diff); expect(text).toBe(["fn f() {", "\tguard();", "\t\treturn;", "\tlet lead = new1();", "}"].join("\n")); - expect(warnings.filter(warning => /leading structural closing line/.test(warning))).toHaveLength(0); + expect(boundaryRepairWarnings(warnings)).toHaveLength(0); }); // The "complete new function over a head-only range" incident: the payload @@ -728,7 +747,7 @@ describe("boundary-balance repair", () => { "\n", ), ); - expect(warnings.filter(warning => /ended mid-block/.test(warning))).toHaveLength(1); + expect(warnings).toEqual([expect.stringContaining("introduced a syntax error")]); }); // The applier is language-agnostic: in Markdown these braces are literal @@ -813,7 +832,7 @@ describe("boundary-balance repair", () => { const diff = ["PUT 4-5:", "+\tlet lead = new1();"].join("\n"); const { text, warnings } = applyRust(file, diff); expect(text).toBe(["fn f() {", "\tif a {", "\t\treturn;", "\t}", "\tlet lead = new1();", "}"].join("\n")); - expect(warnings.filter(warning => /leading structural closing line/.test(warning))).toHaveLength(1); + expect(boundaryRepairWarnings(warnings)).toHaveLength(1); }); // No proof, no mutation. Without a path the probe cannot judge anything, so // the closer-spare must not fire: the edit lands exactly as authored, even @@ -896,7 +915,7 @@ describe("boundary-balance repair through stale-snapshot recovery", () => { // The unrelated drift on the live file survives the merge. expect(recovered?.text).toContain("const tail = 99;"); // The repair warning propagates out through the recovery result. - expect(recovered?.warnings.some(w => /delimiter-balance/.test(w))).toBe(true); + expect(boundaryRepairWarnings(recovered?.warnings ?? [])).toHaveLength(1); }); }); @@ -936,10 +955,18 @@ describe("rust lifetime delimiter counting (the extension() incident)", () => { // Applied as authored — advisory, not repair. expect(text).toContain("\t\tself.into()"); expect(text).not.toContain("pub const fn extension"); - expect(warnings.some(w => /deleted 1 opening delimiter/.test(w))).toBe(true); expect(warnings.some(w => /introduced a syntax error/.test(w))).toBe(true); }); + it("does not resurrect a swallowed signature when body indentation matches", () => { + const { text, warnings } = applyRust(file, "PUT 11.=16:\n+ self.into()"); + + expect(text).toContain(" self.into()"); + expect(text).not.toContain("pub const fn extension"); + expect(boundaryRepairWarnings(warnings)).toHaveLength(0); + expect(warnings).toEqual([expect.stringContaining("introduced a syntax error")]); + }); + it("stays silent for the correct whole-construct replacement", () => { const diff = [ "PUT 11.=17:",