188 lines
5.1 KiB
TypeScript
188 lines
5.1 KiB
TypeScript
import fs from "node:fs/promises";
|
|
|
|
type UpdateFileChunk = {
|
|
changeContext?: string;
|
|
oldLines: string[];
|
|
newLines: string[];
|
|
isEndOfFile: boolean;
|
|
};
|
|
|
|
export async function applyUpdateHunk(
|
|
filePath: string,
|
|
chunks: UpdateFileChunk[],
|
|
): Promise<string> {
|
|
const originalContents = await fs.readFile(filePath, "utf8").catch((err) => {
|
|
throw new Error(`Failed to read file to update ${filePath}: ${err}`);
|
|
});
|
|
|
|
const originalLines = originalContents.split("\n");
|
|
if (originalLines.length > 0 && originalLines[originalLines.length - 1] === "") {
|
|
originalLines.pop();
|
|
}
|
|
|
|
const replacements = computeReplacements(originalLines, filePath, chunks);
|
|
let newLines = applyReplacements(originalLines, replacements);
|
|
if (newLines.length === 0 || newLines[newLines.length - 1] !== "") {
|
|
newLines = [...newLines, ""];
|
|
}
|
|
return newLines.join("\n");
|
|
}
|
|
|
|
function computeReplacements(
|
|
originalLines: string[],
|
|
filePath: string,
|
|
chunks: UpdateFileChunk[],
|
|
): Array<[number, number, string[]]> {
|
|
const replacements: Array<[number, number, string[]]> = [];
|
|
let lineIndex = 0;
|
|
|
|
for (const chunk of chunks) {
|
|
if (chunk.changeContext) {
|
|
const ctxIndex = seekSequence(originalLines, [chunk.changeContext], lineIndex, false);
|
|
if (ctxIndex === null) {
|
|
throw new Error(`Failed to find context '${chunk.changeContext}' in ${filePath}`);
|
|
}
|
|
lineIndex = ctxIndex + 1;
|
|
}
|
|
|
|
if (chunk.oldLines.length === 0) {
|
|
const insertionIndex =
|
|
originalLines.length > 0 && originalLines[originalLines.length - 1] === ""
|
|
? originalLines.length - 1
|
|
: originalLines.length;
|
|
replacements.push([insertionIndex, 0, chunk.newLines]);
|
|
continue;
|
|
}
|
|
|
|
let pattern = chunk.oldLines;
|
|
let newSlice = chunk.newLines;
|
|
let found = seekSequence(originalLines, pattern, lineIndex, chunk.isEndOfFile);
|
|
|
|
if (found === null && pattern[pattern.length - 1] === "") {
|
|
pattern = pattern.slice(0, -1);
|
|
if (newSlice.length > 0 && newSlice[newSlice.length - 1] === "") {
|
|
newSlice = newSlice.slice(0, -1);
|
|
}
|
|
found = seekSequence(originalLines, pattern, lineIndex, chunk.isEndOfFile);
|
|
}
|
|
|
|
if (found === null) {
|
|
throw new Error(
|
|
`Failed to find expected lines in ${filePath}:\n${chunk.oldLines.join("\n")}`,
|
|
);
|
|
}
|
|
|
|
replacements.push([found, pattern.length, newSlice]);
|
|
lineIndex = found + pattern.length;
|
|
}
|
|
|
|
replacements.sort((a, b) => a[0] - b[0]);
|
|
return replacements;
|
|
}
|
|
|
|
function applyReplacements(
|
|
lines: string[],
|
|
replacements: Array<[number, number, string[]]>,
|
|
): string[] {
|
|
const result = [...lines];
|
|
for (const [startIndex, oldLen, newLines] of [...replacements].reverse()) {
|
|
for (let i = 0; i < oldLen; i += 1) {
|
|
if (startIndex < result.length) {
|
|
result.splice(startIndex, 1);
|
|
}
|
|
}
|
|
for (let i = 0; i < newLines.length; i += 1) {
|
|
result.splice(startIndex + i, 0, newLines[i]);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function seekSequence(
|
|
lines: string[],
|
|
pattern: string[],
|
|
start: number,
|
|
eof: boolean,
|
|
): number | null {
|
|
if (pattern.length === 0) return start;
|
|
if (pattern.length > lines.length) return null;
|
|
|
|
const maxStart = lines.length - pattern.length;
|
|
const searchStart = eof && lines.length >= pattern.length ? maxStart : start;
|
|
if (searchStart > maxStart) return null;
|
|
|
|
for (let i = searchStart; i <= maxStart; i += 1) {
|
|
if (linesMatch(lines, pattern, i, (value) => value)) return i;
|
|
}
|
|
for (let i = searchStart; i <= maxStart; i += 1) {
|
|
if (linesMatch(lines, pattern, i, (value) => value.trimEnd())) return i;
|
|
}
|
|
for (let i = searchStart; i <= maxStart; i += 1) {
|
|
if (linesMatch(lines, pattern, i, (value) => value.trim())) return i;
|
|
}
|
|
for (let i = searchStart; i <= maxStart; i += 1) {
|
|
if (linesMatch(lines, pattern, i, (value) => normalizePunctuation(value.trim()))) {
|
|
return i;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function linesMatch(
|
|
lines: string[],
|
|
pattern: string[],
|
|
start: number,
|
|
normalize: (value: string) => string,
|
|
): boolean {
|
|
for (let idx = 0; idx < pattern.length; idx += 1) {
|
|
if (normalize(lines[start + idx]) !== normalize(pattern[idx])) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function normalizePunctuation(value: string): string {
|
|
return Array.from(value)
|
|
.map((char) => {
|
|
switch (char) {
|
|
case "\u2010":
|
|
case "\u2011":
|
|
case "\u2012":
|
|
case "\u2013":
|
|
case "\u2014":
|
|
case "\u2015":
|
|
case "\u2212":
|
|
return "-";
|
|
case "\u2018":
|
|
case "\u2019":
|
|
case "\u201A":
|
|
case "\u201B":
|
|
return "'";
|
|
case "\u201C":
|
|
case "\u201D":
|
|
case "\u201E":
|
|
case "\u201F":
|
|
return '"';
|
|
case "\u00A0":
|
|
case "\u2002":
|
|
case "\u2003":
|
|
case "\u2004":
|
|
case "\u2005":
|
|
case "\u2006":
|
|
case "\u2007":
|
|
case "\u2008":
|
|
case "\u2009":
|
|
case "\u200A":
|
|
case "\u202F":
|
|
case "\u205F":
|
|
case "\u3000":
|
|
return " ";
|
|
default:
|
|
return char;
|
|
}
|
|
})
|
|
.join("");
|
|
}
|