585 lines
22 KiB
TypeScript
585 lines
22 KiB
TypeScript
import path from "node:path";
|
|
import { createHash } from "node:crypto";
|
|
import type { Readable, Writable } from "node:stream";
|
|
|
|
export type PatchHunk =
|
|
| { kind: "add"; path: string; content: string }
|
|
| { kind: "delete"; path: string }
|
|
| { kind: "update"; path: string; movePath: string | null; chunks: UpdateChunk[] };
|
|
|
|
export interface UpdateChunk {
|
|
changeContext: string | null;
|
|
oldLines: string[];
|
|
newLines: string[];
|
|
isEndOfFile: boolean;
|
|
}
|
|
|
|
export interface PatchParseResult {
|
|
patch: string;
|
|
hunks: PatchHunk[];
|
|
}
|
|
|
|
export interface PatchUpdateResult {
|
|
oldContent: string;
|
|
newContent: string;
|
|
}
|
|
|
|
export interface ApplyPatchV2Options {
|
|
executor: ApplyPatchV2Executor;
|
|
stdin: Readable;
|
|
stdout: Writable;
|
|
argv?: string[];
|
|
}
|
|
|
|
export interface ApplyPatchV2Executor {
|
|
run(command: string[], input?: string): Promise<ApplyPatchV2RemoteResult>;
|
|
}
|
|
|
|
export interface ApplyPatchV2RemoteResult {
|
|
exitCode: number;
|
|
stdout: string;
|
|
stderr: string;
|
|
}
|
|
|
|
export class ApplyPatchV2Error extends Error {
|
|
constructor(message: string, public readonly details: Record<string, unknown> = {}) {
|
|
super(message);
|
|
this.name = "ApplyPatchV2Error";
|
|
}
|
|
}
|
|
|
|
type PlannedFileState = { exists: true; content: string } | { exists: false; content: "" };
|
|
type PlannedOperation = { kind: "write"; path: string; content: string } | { kind: "delete"; path: string };
|
|
type Replacement = [start: number, oldLength: number, newLines: string[]];
|
|
|
|
interface ApplyPatchV2Plan {
|
|
operations: PlannedOperation[];
|
|
changed: string[];
|
|
}
|
|
|
|
const beginMarker = "*** Begin Patch";
|
|
const endMarker = "*** End Patch";
|
|
const addFileMarker = "*** Add File: ";
|
|
const deleteFileMarker = "*** Delete File: ";
|
|
const updateFileMarker = "*** Update File: ";
|
|
const moveToMarker = "*** Move to: ";
|
|
const emptyChangeContextMarker = "@@";
|
|
const changeContextMarker = "@@ ";
|
|
const eofMarker = "*** End of File";
|
|
|
|
export function isApplyPatchV2HelpArgs(args: string[] = []): boolean {
|
|
const first = args[0] ?? "";
|
|
return first === "help" || first === "--help" || first === "-h";
|
|
}
|
|
|
|
export function applyPatchV2HelpPayload() {
|
|
return {
|
|
ok: true,
|
|
command: "ssh <route> apply-patch < patch.diff",
|
|
summary: "Apply a standard apply_patch patch to a remote route with the local v2 line-based engine.",
|
|
usage: [
|
|
"tran G14:/root/hwlab/.worktree/task apply-patch < patch.diff",
|
|
"bun scripts/cli.ts ssh D601:/tmp apply-patch < patch.diff"
|
|
],
|
|
input: {
|
|
required: true,
|
|
firstLine: beginMarker,
|
|
lastLine: endMarker
|
|
},
|
|
note: "apply-patch reads patch text from stdin and uses the v2 engine by default. Use `apply-patch-v1` only for the legacy helper."
|
|
};
|
|
}
|
|
|
|
export function parseApplyPatchV2(patchText: string): PatchParseResult {
|
|
const patch = stripLenientHeredoc(patchText).trim();
|
|
const lines = patch.length === 0 ? [] : patch.split(/\r?\n/u);
|
|
const first = lines[0]?.trim();
|
|
const last = lines[lines.length - 1]?.trim();
|
|
if (first !== beginMarker) throw new ApplyPatchV2Error(`invalid patch: first line must be '${beginMarker}'`);
|
|
if (last !== endMarker) throw new ApplyPatchV2Error(`invalid patch: last line must be '${endMarker}'`);
|
|
|
|
const hunks: PatchHunk[] = [];
|
|
let index = 1;
|
|
while (index < lines.length - 1) {
|
|
const line = lines[index]?.trim() ?? "";
|
|
if (line.length === 0) {
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (line.startsWith(addFileMarker)) {
|
|
const filePath = validatePatchPath(line.slice(addFileMarker.length), index + 1);
|
|
index += 1;
|
|
const added: string[] = [];
|
|
while (index < lines.length - 1 && !isFileHeader(lines[index] ?? "")) {
|
|
const addLine = lines[index] ?? "";
|
|
if (!addLine.startsWith("+")) {
|
|
throw new ApplyPatchV2Error("invalid add file line; every added line must start with +", { line: index + 1, path: filePath });
|
|
}
|
|
added.push(addLine.slice(1));
|
|
index += 1;
|
|
}
|
|
hunks.push({ kind: "add", path: filePath, content: joinLinesWithFinalNewline(added) });
|
|
continue;
|
|
}
|
|
if (line.startsWith(deleteFileMarker)) {
|
|
hunks.push({ kind: "delete", path: validatePatchPath(line.slice(deleteFileMarker.length), index + 1) });
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (line.startsWith(updateFileMarker)) {
|
|
const filePath = validatePatchPath(line.slice(updateFileMarker.length), index + 1);
|
|
index += 1;
|
|
let movePath: string | null = null;
|
|
if ((lines[index] ?? "").startsWith(moveToMarker)) {
|
|
movePath = validatePatchPath((lines[index] ?? "").slice(moveToMarker.length), index + 1);
|
|
index += 1;
|
|
}
|
|
const chunks: UpdateChunk[] = [];
|
|
while (index < lines.length - 1 && !isFileHeader(lines[index] ?? "")) {
|
|
if ((lines[index] ?? "").trim().length === 0) {
|
|
index += 1;
|
|
continue;
|
|
}
|
|
const parsed = parseUpdateChunk(lines, index, chunks.length === 0);
|
|
chunks.push(parsed.chunk);
|
|
index = parsed.nextIndex;
|
|
}
|
|
if (chunks.length === 0) throw new ApplyPatchV2Error("update file hunk is empty", { line: index + 1, path: filePath });
|
|
hunks.push({ kind: "update", path: filePath, movePath, chunks });
|
|
continue;
|
|
}
|
|
throw new ApplyPatchV2Error("invalid hunk header", { line: index + 1, text: line });
|
|
}
|
|
|
|
return { patch, hunks };
|
|
}
|
|
|
|
export function deriveUpdatedContent(filePath: string, originalContent: string, chunks: UpdateChunk[]): PatchUpdateResult {
|
|
const originalLines = splitContentLines(originalContent);
|
|
const replacements = computeReplacements(filePath, originalLines, chunks);
|
|
const newLines = applyReplacements(originalLines, replacements);
|
|
const newContent = joinLinesWithFinalNewline(newLines);
|
|
return { oldContent: originalContent, newContent };
|
|
}
|
|
|
|
export async function runApplyPatchV2(options: ApplyPatchV2Options): Promise<number> {
|
|
if (isApplyPatchV2HelpArgs(options.argv)) {
|
|
options.stdout.write(`${JSON.stringify(applyPatchV2HelpPayload(), null, 2)}\n`);
|
|
return 0;
|
|
}
|
|
if ((options.argv?.length ?? 0) > 0) {
|
|
options.stdout.write(`${JSON.stringify({
|
|
ok: false,
|
|
error: {
|
|
code: "apply_patch_unsupported_args",
|
|
message: "ssh apply-patch uses the v2 engine and accepts no helper flags. Use apply-patch-v1 for legacy helper options."
|
|
},
|
|
unsupportedArgs: options.argv,
|
|
help: applyPatchV2HelpPayload()
|
|
}, null, 2)}\n`);
|
|
return 2;
|
|
}
|
|
const patchText = await readStreamText(options.stdin);
|
|
if (!patchText.trim()) {
|
|
options.stdout.write(`${JSON.stringify({
|
|
ok: false,
|
|
error: {
|
|
code: "apply_patch_stdin_required",
|
|
message: "ssh apply-patch requires patch text on stdin."
|
|
},
|
|
help: applyPatchV2HelpPayload()
|
|
}, null, 2)}\n`);
|
|
return 2;
|
|
}
|
|
const parsed = parseApplyPatchV2(patchText);
|
|
const plan = await planApplyPatchV2(options.executor, parsed.hunks);
|
|
for (const operation of plan.operations) {
|
|
await executePlannedOperation(options.executor, operation);
|
|
}
|
|
options.stdout.write("Success. Updated the following files:\n");
|
|
for (const item of plan.changed) options.stdout.write(`${item}\n`);
|
|
return 0;
|
|
}
|
|
|
|
async function planApplyPatchV2(executor: ApplyPatchV2Executor, hunks: PatchHunk[]): Promise<ApplyPatchV2Plan> {
|
|
const states = new Map<string, PlannedFileState>();
|
|
const operations: PlannedOperation[] = [];
|
|
const changed: string[] = [];
|
|
|
|
async function readPlannedText(filePath: string): Promise<string> {
|
|
const state = states.get(filePath);
|
|
if (state !== undefined) {
|
|
if (!state.exists) throw new ApplyPatchV2Error("cannot update a file deleted earlier in this patch", { path: filePath });
|
|
return state.content;
|
|
}
|
|
const content = await readRemoteText(executor, filePath);
|
|
states.set(filePath, { exists: true, content });
|
|
return content;
|
|
}
|
|
|
|
function planWrite(filePath: string, content: string): void {
|
|
states.set(filePath, { exists: true, content });
|
|
operations.push({ kind: "write", path: filePath, content });
|
|
}
|
|
|
|
function planDelete(filePath: string): void {
|
|
states.set(filePath, { exists: false, content: "" });
|
|
operations.push({ kind: "delete", path: filePath });
|
|
}
|
|
|
|
for (const hunk of hunks) {
|
|
if (hunk.kind === "add") {
|
|
planWrite(hunk.path, hunk.content);
|
|
changed.push(hunk.path);
|
|
continue;
|
|
}
|
|
if (hunk.kind === "delete") {
|
|
planDelete(hunk.path);
|
|
changed.push(hunk.path);
|
|
continue;
|
|
}
|
|
const originalContent = await readPlannedText(hunk.path);
|
|
const update = deriveUpdatedContent(hunk.path, originalContent, hunk.chunks);
|
|
if (hunk.movePath !== null && hunk.movePath !== hunk.path) {
|
|
planWrite(hunk.movePath, update.newContent);
|
|
planDelete(hunk.path);
|
|
changed.push(`${hunk.path} -> ${hunk.movePath}`);
|
|
continue;
|
|
}
|
|
planWrite(hunk.path, update.newContent);
|
|
changed.push(hunk.path);
|
|
}
|
|
|
|
return { operations, changed };
|
|
}
|
|
|
|
async function executePlannedOperation(executor: ApplyPatchV2Executor, operation: PlannedOperation): Promise<void> {
|
|
if (operation.kind === "write") {
|
|
await writeRemoteText(executor, operation.path, operation.content);
|
|
return;
|
|
}
|
|
await checkedRemoteV2(executor, "delete", [operation.path]);
|
|
}
|
|
|
|
async function readRemoteText(executor: ApplyPatchV2Executor, target: string): Promise<string> {
|
|
const read = await checkedRemoteV2(executor, "read", [target]);
|
|
return read.stdout;
|
|
}
|
|
|
|
async function writeRemoteText(executor: ApplyPatchV2Executor, target: string, content: string): Promise<void> {
|
|
const contentBuffer = Buffer.from(content, "utf8");
|
|
const encoded = contentBuffer.toString("base64");
|
|
const expectedBytes = String(contentBuffer.length);
|
|
const expectedSha256 = sha256Hex(contentBuffer);
|
|
if (encoded.length <= 48_000) {
|
|
await checkedRemoteV2(executor, "write-b64-argv", [target, expectedBytes, expectedSha256, ...chunkString(encoded, 12_000)]);
|
|
return;
|
|
}
|
|
await checkedRemoteV2(executor, "write-b64-stdin", [target, expectedBytes, expectedSha256], encoded);
|
|
}
|
|
|
|
async function checkedRemoteV2(executor: ApplyPatchV2Executor, operation: "read" | "write-b64-argv" | "write-b64-stdin" | "delete" | "move", args: string[], input?: string): Promise<{ stdout: string }> {
|
|
const result = await executor.run(remoteV2Script(operation, args), input);
|
|
if (result.exitCode === 0) return result;
|
|
throw new ApplyPatchV2Error("remote apply-patch v2 operation failed", {
|
|
operation,
|
|
args,
|
|
exitCode: result.exitCode,
|
|
stdout: result.stdout.slice(-2000),
|
|
stderr: result.stderr.slice(-4000),
|
|
});
|
|
}
|
|
|
|
function remoteV2Script(operation: "read" | "write-b64-argv" | "write-b64-stdin" | "delete" | "move", args: string[]): string[] {
|
|
const script = [
|
|
"set -eu",
|
|
"sha256_file() {",
|
|
" if command -v sha256sum >/dev/null 2>&1; then sha256sum -- \"$1\" | awk '{print $1}'; return; fi",
|
|
" if command -v shasum >/dev/null 2>&1; then shasum -a 256 -- \"$1\" | awk '{print $1}'; return; fi",
|
|
" if command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 -- \"$1\" | awk '{print $NF}'; return; fi",
|
|
" printf 'missing sha256 tool\\n' >&2",
|
|
" return 127",
|
|
"}",
|
|
"verify_tmp() {",
|
|
" target=$1",
|
|
" tmp=$2",
|
|
" expected_bytes=$3",
|
|
" expected_sha256=$4",
|
|
" actual_bytes=$(wc -c < \"$tmp\" | tr -d '[:space:]')",
|
|
" if [ \"$actual_bytes\" != \"$expected_bytes\" ]; then",
|
|
" rm -f -- \"$tmp\"",
|
|
" printf 'v2 decoded byte count mismatch for %s: expected=%s actual=%s\\n' \"$target\" \"$expected_bytes\" \"$actual_bytes\" >&2",
|
|
" exit 23",
|
|
" fi",
|
|
" actual_sha256=$(sha256_file \"$tmp\")",
|
|
" if [ \"$actual_sha256\" != \"$expected_sha256\" ]; then",
|
|
" rm -f -- \"$tmp\"",
|
|
" printf 'v2 decoded sha256 mismatch for %s: expected=%s actual=%s\\n' \"$target\" \"$expected_sha256\" \"$actual_sha256\" >&2",
|
|
" exit 24",
|
|
" fi",
|
|
"}",
|
|
"op=$1",
|
|
"shift",
|
|
"case \"$op\" in",
|
|
" read)",
|
|
" cat -- \"$1\"",
|
|
" ;;",
|
|
" write-b64-argv)",
|
|
" target=$1",
|
|
" expected_bytes=$2",
|
|
" expected_sha256=$3",
|
|
" shift 3",
|
|
" case \"$target\" in */*) parent=${target%/*}; mkdir -p -- \"$parent\";; esac",
|
|
" base=${target##*/}",
|
|
" dir=.",
|
|
" case \"$target\" in */*) dir=${target%/*};; esac",
|
|
" tmp=\"$dir/.${base}.unidesk-v2-$$.tmp\"",
|
|
" : > \"$tmp.b64\"",
|
|
" for chunk in \"$@\"; do printf '%s' \"$chunk\" >> \"$tmp.b64\"; done",
|
|
" base64 -d < \"$tmp.b64\" > \"$tmp\"",
|
|
" rm -f -- \"$tmp.b64\"",
|
|
" verify_tmp \"$target\" \"$tmp\" \"$expected_bytes\" \"$expected_sha256\"",
|
|
" mv -f -- \"$tmp\" \"$target\"",
|
|
" actual_sha256=$(sha256_file \"$target\")",
|
|
" if [ \"$actual_sha256\" != \"$expected_sha256\" ]; then printf 'v2 final sha256 mismatch for %s\\n' \"$target\" >&2; exit 25; fi",
|
|
" ;;",
|
|
" write-b64-stdin)",
|
|
" target=$1",
|
|
" expected_bytes=$2",
|
|
" expected_sha256=$3",
|
|
" case \"$target\" in */*) parent=${target%/*}; mkdir -p -- \"$parent\";; esac",
|
|
" base=${target##*/}",
|
|
" dir=.",
|
|
" case \"$target\" in */*) dir=${target%/*};; esac",
|
|
" tmp=\"$dir/.${base}.unidesk-v2-$$.tmp\"",
|
|
" base64 -d > \"$tmp\"",
|
|
" verify_tmp \"$target\" \"$tmp\" \"$expected_bytes\" \"$expected_sha256\"",
|
|
" mv -f -- \"$tmp\" \"$target\"",
|
|
" actual_sha256=$(sha256_file \"$target\")",
|
|
" if [ \"$actual_sha256\" != \"$expected_sha256\" ]; then printf 'v2 final sha256 mismatch for %s\\n' \"$target\" >&2; exit 25; fi",
|
|
" ;;",
|
|
" delete)",
|
|
" rm -f -- \"$1\"",
|
|
" ;;",
|
|
" move)",
|
|
" target=$2",
|
|
" case \"$target\" in */*) parent=${target%/*}; mkdir -p -- \"$parent\";; esac",
|
|
" mv -f -- \"$1\" \"$2\"",
|
|
" ;;",
|
|
" *)",
|
|
" printf 'unsupported op: %s\\n' \"$op\" >&2",
|
|
" exit 2",
|
|
" ;;",
|
|
"esac",
|
|
].join("\n");
|
|
return ["sh", "-c", script, "unidesk-apply-patch-v2", operation, ...args];
|
|
}
|
|
|
|
function sha256Hex(value: Buffer): string {
|
|
return createHash("sha256").update(value).digest("hex");
|
|
}
|
|
|
|
function chunkString(value: string, chunkSize: number): string[] {
|
|
const chunks: string[] = [];
|
|
for (let index = 0; index < value.length; index += chunkSize) {
|
|
chunks.push(value.slice(index, index + chunkSize));
|
|
}
|
|
return chunks.length > 0 ? chunks : [""];
|
|
}
|
|
|
|
function readStreamText(stream: Readable): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
const chunks: Buffer[] = [];
|
|
stream.on("data", (chunk: Buffer | string) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
|
|
stream.on("error", reject);
|
|
stream.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
stream.resume();
|
|
});
|
|
}
|
|
|
|
function stripLenientHeredoc(text: string): string {
|
|
const trimmed = text.trim();
|
|
const lines = trimmed.length === 0 ? [] : trimmed.split(/\r?\n/u);
|
|
const first = lines[0] ?? "";
|
|
const last = lines[lines.length - 1] ?? "";
|
|
if ((first === "<<EOF" || first === "<<'EOF'" || first === '<<"EOF"') && last.endsWith("EOF") && lines.length >= 4) {
|
|
return lines.slice(1, -1).join("\n");
|
|
}
|
|
return text;
|
|
}
|
|
|
|
function validatePatchPath(value: string, line: number): string {
|
|
const filePath = value.trim();
|
|
if (filePath.length === 0) throw new ApplyPatchV2Error("patch path cannot be empty", { line });
|
|
if (path.isAbsolute(filePath)) throw new ApplyPatchV2Error("patch paths must be relative", { line, path: filePath });
|
|
if (filePath.split(/[\\/]+/u).includes("..")) throw new ApplyPatchV2Error("patch paths cannot contain ..", { line, path: filePath });
|
|
return filePath;
|
|
}
|
|
|
|
function isFileHeader(line: string): boolean {
|
|
const trimmed = line.trim();
|
|
return trimmed.startsWith(addFileMarker) || trimmed.startsWith(deleteFileMarker) || trimmed.startsWith(updateFileMarker) || trimmed === endMarker;
|
|
}
|
|
|
|
function parseUpdateChunk(lines: string[], startIndex: number, allowMissingContext: boolean): { chunk: UpdateChunk; nextIndex: number } {
|
|
let index = startIndex;
|
|
let changeContext: string | null = null;
|
|
const first = lines[index] ?? "";
|
|
if (first === emptyChangeContextMarker) {
|
|
index += 1;
|
|
} else if (first.startsWith(changeContextMarker)) {
|
|
changeContext = first.slice(changeContextMarker.length);
|
|
index += 1;
|
|
} else if (!allowMissingContext) {
|
|
throw new ApplyPatchV2Error("expected update chunk to start with @@ context marker", { line: startIndex + 1, text: first });
|
|
}
|
|
|
|
const oldLines: string[] = [];
|
|
const newLines: string[] = [];
|
|
let parsed = 0;
|
|
let isEndOfFile = false;
|
|
while (index < lines.length - 1) {
|
|
const line = lines[index] ?? "";
|
|
if (isFileHeader(line)) break;
|
|
if (line === eofMarker) {
|
|
if (parsed === 0) throw new ApplyPatchV2Error("update chunk does not contain any lines", { line: index + 1 });
|
|
isEndOfFile = true;
|
|
index += 1;
|
|
break;
|
|
}
|
|
const marker = line[0] ?? "";
|
|
if (marker === " ") {
|
|
oldLines.push(line.slice(1));
|
|
newLines.push(line.slice(1));
|
|
} else if (marker === "+") {
|
|
newLines.push(line.slice(1));
|
|
} else if (marker === "-") {
|
|
oldLines.push(line.slice(1));
|
|
} else if (line.length === 0) {
|
|
oldLines.push("");
|
|
newLines.push("");
|
|
} else if (parsed > 0) {
|
|
break;
|
|
} else {
|
|
throw new ApplyPatchV2Error("unexpected line in update chunk", { line: index + 1, text: line });
|
|
}
|
|
parsed += 1;
|
|
index += 1;
|
|
}
|
|
if (parsed === 0) throw new ApplyPatchV2Error("update chunk does not contain any lines", { line: startIndex + 1 });
|
|
return { chunk: { changeContext, oldLines, newLines, isEndOfFile }, nextIndex: index };
|
|
}
|
|
|
|
function splitContentLines(content: string): string[] {
|
|
const lines = content.split("\n");
|
|
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
return lines;
|
|
}
|
|
|
|
function joinLinesWithFinalNewline(lines: string[]): string {
|
|
if (lines.length === 0) return "";
|
|
return `${lines.join("\n")}\n`;
|
|
}
|
|
|
|
function computeReplacements(filePath: string, originalLines: string[], chunks: UpdateChunk[]): Replacement[] {
|
|
const replacements: Replacement[] = [];
|
|
let lineIndex = 0;
|
|
for (const [chunkIndex, chunk] of chunks.entries()) {
|
|
if (chunk.changeContext !== null) {
|
|
const foundContext = seekSequence(originalLines, [chunk.changeContext], lineIndex, false);
|
|
if (foundContext === null) {
|
|
throw new ApplyPatchV2Error("failed to find update context", { path: filePath, chunk: chunkIndex + 1, context: chunk.changeContext });
|
|
}
|
|
lineIndex = foundContext + 1;
|
|
}
|
|
if (chunk.oldLines.length === 0) {
|
|
const insertAt = originalLines.length > 0 && originalLines[originalLines.length - 1] === "" ? originalLines.length - 1 : originalLines.length;
|
|
replacements.push([insertAt, 0, chunk.newLines]);
|
|
continue;
|
|
}
|
|
|
|
let pattern = chunk.oldLines;
|
|
let found = seekSequence(originalLines, pattern, lineIndex, chunk.isEndOfFile);
|
|
let newLines = chunk.newLines;
|
|
if (found === null && pattern[pattern.length - 1] === "") {
|
|
pattern = pattern.slice(0, -1);
|
|
newLines = newLines[newLines.length - 1] === "" ? newLines.slice(0, -1) : newLines;
|
|
found = seekSequence(originalLines, pattern, lineIndex, chunk.isEndOfFile);
|
|
}
|
|
if (found === null) {
|
|
throw new ApplyPatchV2Error("failed to find expected lines", {
|
|
path: filePath,
|
|
chunk: chunkIndex + 1,
|
|
expected: chunk.oldLines.join("\n"),
|
|
});
|
|
}
|
|
replacements.push([found, pattern.length, newLines]);
|
|
lineIndex = found + pattern.length;
|
|
}
|
|
assertNonOverlappingReplacements(filePath, replacements, originalLines.length);
|
|
return replacements;
|
|
}
|
|
|
|
function applyReplacements(lines: string[], replacements: Replacement[]): string[] {
|
|
const result = [...lines];
|
|
for (const [start, oldLen, newSegment] of [...replacements].reverse()) {
|
|
result.splice(start, oldLen, ...newSegment);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function assertNonOverlappingReplacements(filePath: string, replacements: Replacement[], lineCount: number): void {
|
|
const sorted = [...replacements].sort((left, right) => left[0] - right[0]);
|
|
let previousEnd = 0;
|
|
for (const [start, oldLen] of sorted) {
|
|
if (start < 0 || oldLen < 0 || start + oldLen > lineCount) {
|
|
throw new ApplyPatchV2Error("computed replacement is outside file bounds", {
|
|
path: filePath,
|
|
start,
|
|
oldLen,
|
|
lineCount,
|
|
});
|
|
}
|
|
if (start < previousEnd) {
|
|
throw new ApplyPatchV2Error("computed replacements overlap", {
|
|
path: filePath,
|
|
start,
|
|
previousEnd,
|
|
});
|
|
}
|
|
previousEnd = Math.max(previousEnd, start + oldLen);
|
|
}
|
|
}
|
|
|
|
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 searchStart = eof && lines.length >= pattern.length ? lines.length - pattern.length : start;
|
|
const attempts: Array<(value: string) => string> = [
|
|
(value) => value,
|
|
(value) => value.trimEnd(),
|
|
(value) => value.trim(),
|
|
normalizeLine,
|
|
];
|
|
for (const normalize of attempts) {
|
|
for (let index = searchStart; index <= lines.length - pattern.length; index += 1) {
|
|
let ok = true;
|
|
for (let offset = 0; offset < pattern.length; offset += 1) {
|
|
if (normalize(lines[index + offset] ?? "") !== normalize(pattern[offset] ?? "")) {
|
|
ok = false;
|
|
break;
|
|
}
|
|
}
|
|
if (ok) return index;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function normalizeLine(value: string): string {
|
|
return value.trim().replace(/[\u2010-\u2015\u2212]/gu, "-")
|
|
.replace(/[\u2018\u2019\u201A\u201B]/gu, "'")
|
|
.replace(/[\u201C\u201D\u201E\u201F]/gu, "\"")
|
|
.replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/gu, " ");
|
|
}
|