pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
129 lines (128 loc) • 5 kB
JavaScript
import { normalizeForGuardMatch } from "./host-edit-normalize.js";
/**
* Normalize text the same way the read-guard's match-counter does — now the
* host edit tool's full fuzzy-match space (BOM + lone-CR -> LF, NFKC, smart
* quotes/dashes/spaces, trailing-ws trim). Used as the canonical form for
* comparing oldText against file content so the autopatch bridge resolves the
* same span the host would apply (#257). Kept in lockstep with the gate's
* `normalizeContent` and index.ts's `normalizeOldTextForMatch`.
*/
export function normalizeOldTextForMatch(text) {
return normalizeForGuardMatch(text);
}
/**
* Locate the line range of a uniquely-matching oldText inside the
* trim-end-normalized file content. Returns `undefined` when the needle
* is empty, not found, or matches more than once. Lines are 1-indexed.
*
* Used by the autopatch → synthetic-read bridge: a successful unique-match
* autopatch (indent or trailing-ws) proves the agent's oldText reflects
* real content at that span, so we can safely register a synthetic read
* covering it and avoid a downstream zero_read block.
*/
export function findUniqueMatchLineRange(matchNormalizedContent, oldText) {
const needle = normalizeOldTextForMatch(oldText);
if (!needle)
return undefined;
const firstIdx = matchNormalizedContent.indexOf(needle);
if (firstIdx === -1)
return undefined;
const secondIdx = matchNormalizedContent.indexOf(needle, firstIdx + needle.length);
if (secondIdx !== -1)
return undefined;
let startLine = 1;
for (let i = 0; i < firstIdx; i++) {
if (matchNormalizedContent.charCodeAt(i) === 10)
startLine++;
}
let lineCount = 1;
for (let i = 0; i < needle.length; i++) {
if (needle.charCodeAt(i) === 10)
lineCount++;
}
return { startLine, endLine: startLine + lineCount - 1 };
}
function normalizeLf(value) {
return value.replace(/\r\n/g, "\n");
}
function countOccurrences(haystack, needle) {
if (!needle)
return 0;
let count = 0;
let pos = 0;
while (pos < haystack.length) {
const idx = haystack.indexOf(needle, pos);
if (idx === -1)
break;
count += 1;
pos = idx + needle.length;
}
return count;
}
export function stripTrailingWhitespaceDetailed(value) {
const lines = normalizeLf(value).split("\n");
let removedLineTrailingWhitespace = false;
const strippedLines = lines.map((line) => {
const stripped = line.trimEnd();
if (stripped !== line)
removedLineTrailingWhitespace = true;
return stripped;
});
let removedTrailingEmptyLineCount = 0;
while (strippedLines.length > 1 &&
strippedLines[strippedLines.length - 1] === "") {
strippedLines.pop();
removedTrailingEmptyLineCount += 1;
}
return {
text: strippedLines.join("\n"),
removedLineTrailingWhitespace,
removedTrailingEmptyLineCount,
};
}
function stripEquivalentTrailingEmptyLines(value, maxCount) {
if (maxCount <= 0)
return value;
const lines = normalizeLf(value).split("\n");
let removed = 0;
while (removed < maxCount && lines.length > 1) {
const last = lines[lines.length - 1];
if (last.trim() !== "")
break;
lines.pop();
removed += 1;
}
return lines.join("\n");
}
/**
* Build the safe Pass 1 oldText trailing-whitespace patch.
*
* Safety contract:
* - only patch when the original raw oldText does NOT already match the file
* - require the stripped raw candidate to match exactly once
* - distinguish ordinary line-end whitespace from trailing empty-line removal
* - when trailing empty lines are removed from oldText, remove the equivalent
* trailing empty-line suffix from newText so the replacement span is preserved
*/
export function computeTrailingWhitespaceOldTextPatch(args) {
const fileContentLf = normalizeLf(args.fileContent);
const original = normalizeLf(args.oldText);
const stripped = stripTrailingWhitespaceDetailed(args.oldText);
if (stripped.text === original)
return undefined;
// If the original already matches exactly, do not shrink the replacement span.
if (countOccurrences(fileContentLf, original) !== 0)
return undefined;
// Only patch when the exact stripped candidate is unambiguous in the real file.
if (countOccurrences(fileContentLf, stripped.text) !== 1)
return undefined;
const patchedNewText = args.newText !== undefined && stripped.removedTrailingEmptyLineCount > 0
? stripEquivalentTrailingEmptyLines(args.newText, stripped.removedTrailingEmptyLineCount)
: args.newText;
return {
oldText: stripped.text,
newText: patchedNewText,
removedLineTrailingWhitespace: stripped.removedLineTrailingWhitespace,
removedTrailingEmptyLineCount: stripped.removedTrailingEmptyLineCount,
};
}