Files
toolLooper/tools/patch_files.js

675 lines
21 KiB
JavaScript

#!/usr/bin/env node
/**
* A self-contained JavaScript utility for applying human-readable
* "pseudo-diff" patch files to a collection of text files.
*/
// --------------------------------------------------------------------------- //
// Domain objects
// --------------------------------------------------------------------------- //
class ActionType {
static ADD = "add";
static DELETE = "delete";
static UPDATE = "update";
}
class FileChange {
constructor(type, old_content = null, new_content = null, move_path = null) {
this.type = type;
this.old_content = old_content;
this.new_content = new_content;
this.move_path = move_path;
}
}
class Commit {
constructor() {
this.changes = {};
}
}
// --------------------------------------------------------------------------- //
// Exceptions
// --------------------------------------------------------------------------- //
class DiffError extends Error {
/** Any problem detected while parsing or applying a patch. */
constructor(message) {
super(message);
this.name = "DiffError";
}
}
// --------------------------------------------------------------------------- //
// Helper classes used while parsing patches
// --------------------------------------------------------------------------- //
class Chunk {
constructor(orig_index = -1, del_lines = [], ins_lines = []) {
this.orig_index = orig_index;
this.del_lines = del_lines;
this.ins_lines = ins_lines;
}
}
class PatchAction {
constructor(type, new_file = null, chunks = [], move_path = null) {
this.type = type;
this.new_file = new_file;
this.chunks = chunks;
this.move_path = move_path;
}
}
class Patch {
constructor() {
this.actions = {};
}
}
// --------------------------------------------------------------------------- //
// Patch text parser
// --------------------------------------------------------------------------- //
class Parser {
constructor(current_files, lines) {
this.current_files = current_files;
this.lines = lines;
this.index = 0;
this.patch = new Patch();
this.fuzz = 0;
}
// ------------- low-level helpers -------------------------------------- //
_cur_line() {
if (this.index >= this.lines.length) {
throw new DiffError("Unexpected end of input while parsing patch");
}
return this.lines[this.index];
}
static _norm(line) {
/** Strip CR so comparisons work for both LF and CRLF input. */
return line.replace(/\r$/, "");
}
// ------------- scanning convenience ----------------------------------- //
is_done(prefixes = null) {
if (this.index >= this.lines.length) {
return true;
}
if (
prefixes &&
prefixes.length > 0
) {
const currentLine = Parser._norm(this._cur_line());
for (const prefix of prefixes) {
if (currentLine.startsWith(prefix)) {
return true;
}
}
}
return false;
}
startswith(prefix) {
return Parser._norm(this._cur_line()).startsWith(prefix);
}
read_str(prefix) {
/**
* Consume the current line if it starts with *prefix* and return the text
* **after** the prefix. Raises if prefix is empty.
*/
if (prefix === "") {
throw new Error("read_str() requires a non-empty prefix");
}
if (Parser._norm(this._cur_line()).startsWith(prefix)) {
const text = this._cur_line().substring(prefix.length);
this.index += 1;
return text;
}
return "";
}
read_line() {
/** Return the current raw line and advance. */
const line = this._cur_line();
this.index += 1;
return line;
}
// ------------- public entry point -------------------------------------- //
parse() {
while (!this.is_done(["*** End Patch"])) {
// ---------- UPDATE ---------- //
let path = this.read_str("*** Update File: ");
if (path) {
if (path in this.patch.actions) {
throw new DiffError(`Duplicate update for file: ${path}`);
}
const move_to = this.read_str("*** Move to: ");
if (!(path in this.current_files)) {
throw new DiffError(`Update File Error - missing file: ${path}`);
}
const text = this.current_files[path];
const action = this._parse_update_file(text);
action.move_path = move_to || null;
this.patch.actions[path] = action;
continue;
}
// ---------- DELETE ---------- //
path = this.read_str("*** Delete File: ");
if (path) {
if (path in this.patch.actions) {
throw new DiffError(`Duplicate delete for file: ${path}`);
}
if (!(path in this.current_files)) {
throw new DiffError(`Delete File Error - missing file: ${path}`);
}
this.patch.actions[path] = new PatchAction(ActionType.DELETE);
continue;
}
// ---------- ADD ---------- //
path = this.read_str("*** Add File: ");
if (path) {
if (path in this.patch.actions) {
throw new DiffError(`Duplicate add for file: ${path}`);
}
if (path in this.current_files) {
throw new DiffError(`Add File Error - file already exists: ${path}`);
}
this.patch.actions[path] = this._parse_add_file();
continue;
}
throw new DiffError(`Unknown line while parsing: ${this._cur_line()}`);
}
if (!this.startswith("*** End Patch")) {
throw new DiffError("Missing *** End Patch sentinel");
}
this.index += 1; // consume sentinel
}
// ------------- section parsers ---------------------------------------- //
_parse_update_file(text) {
const action = new PatchAction(ActionType.UPDATE);
const lines = text.split("\n");
let index = 0;
while (!this.is_done([
"*** End Patch",
"*** Update File:",
"*** Delete File:",
"*** Add File:",
"*** End of File",
])) {
const def_str = this.read_str("@@ ");
let section_str = "";
if (!def_str && Parser._norm(this._cur_line()) === "@@") {
section_str = this.read_line();
}
if (!(def_str || section_str || index === 0)) {
throw new DiffError(`Invalid line in update section:\n${this._cur_line()}`);
}
if (def_str && def_str.trim()) {
let found = false;
if (!lines.slice(0, index).includes(def_str)) {
for (let i = index; i < lines.length; i++) {
if (lines[i] === def_str) {
index = i + 1;
found = true;
break;
}
}
}
if (!found && !lines.slice(0, index).map(s => s.trim()).includes(def_str.trim())) {
for (let i = index; i < lines.length; i++) {
if (lines[i].trim() === def_str.trim()) {
index = i + 1;
this.fuzz += 1;
found = true;
break;
}
}
}
}
const [next_ctx, chunks, end_idx, eof] = peek_next_section(this.lines, this.index);
const [new_index, fuzz] = find_context(lines, next_ctx, index, eof);
if (new_index === -1) {
const ctx_txt = next_ctx.join("\n");
throw new DiffError(
`Invalid ${eof ? 'EOF ' : ''}context at ${index}:\n${ctx_txt}`
);
}
this.fuzz += fuzz;
for (const ch of chunks) {
ch.orig_index += new_index;
action.chunks.push(ch);
}
index = new_index + next_ctx.length;
this.index = end_idx;
}
return action;
}
_parse_add_file() {
const lines = [];
while (!this.is_done([
"*** End Patch",
"*** Update File:",
"*** Delete File:",
"*** Add File:"
])) {
const s = this.read_line();
if (!s.startsWith("+")) {
throw new DiffError(`Invalid Add File line (missing '+'): ${s}`);
}
lines.push(s.substring(1)); // strip leading '+'
}
return new PatchAction(ActionType.ADD, lines.join("\n"));
}
}
// --------------------------------------------------------------------------- //
// Helper functions
// --------------------------------------------------------------------------- //
function find_context_core(lines, context, start) {
if (!context || context.length === 0) {
return [start, 0];
}
// Exact match
for (let i = start; i <= lines.length - context.length; i++) {
let match = true;
for (let j = 0; j < context.length; j++) {
if (lines[i + j] !== context[j]) {
match = false;
break;
}
}
if (match) {
return [i, 0];
}
}
// Rstrip match
for (let i = start; i <= lines.length - context.length; i++) {
let match = true;
for (let j = 0; j < context.length; j++) {
if (lines[i + j].replace(/\s+$/, "") !== context[j].replace(/\s+$/, "")) {
match = false;
break;
}
}
if (match) {
return [i, 1];
}
}
// Trim match
for (let i = start; i <= lines.length - context.length; i++) {
let match = true;
for (let j = 0; j < context.length; j++) {
if (lines[i + j].trim() !== context[j].trim()) {
match = false;
break;
}
}
if (match) {
return [i, 100];
}
}
return [-1, 0];
}
function find_context(lines, context, start, eof) {
if (eof) {
let [new_index, fuzz] = find_context_core(lines, context, Math.max(0, lines.length - context.length));
if (new_index !== -1) {
return [new_index, fuzz];
}
[new_index, fuzz] = find_context_core(lines, context, start);
return [new_index, fuzz + 10000];
}
return find_context_core(lines, context, start);
}
function peek_next_section(lines, index) {
const old = [];
let del_lines = [];
let ins_lines = [];
const chunks = [];
let mode = "keep";
const orig_index = index;
while (index < lines.length) {
let s = lines[index];
if (s.startsWith("@@") ||
s === "*** End Patch" ||
s.startsWith("*** Update File:") ||
s.startsWith("*** Delete File:") ||
s.startsWith("*** Add File:") ||
s === "*** End of File") {
break;
}
if (s === "***") {
break;
}
if (s.startsWith("***")) {
throw new DiffError(`Invalid Line: ${s}`);
}
index += 1;
const last_mode = mode;
if (s === "") {
s = " ";
}
if (s[0] === "+") {
mode = "add";
} else if (s[0] === "-") {
mode = "delete";
} else if (s[0] === " ") {
mode = "keep";
} else {
throw new DiffError(`Invalid Line: ${s}`);
}
s = s.substring(1);
if (mode === "keep" && last_mode !== mode) {
if (ins_lines.length > 0 || del_lines.length > 0) {
chunks.push(
new Chunk(
old.length - del_lines.length,
[...del_lines],
[...ins_lines]
)
);
}
del_lines = [];
ins_lines = [];
}
if (mode === "delete") {
del_lines.push(s);
old.push(s);
} else if (mode === "add") {
ins_lines.push(s);
} else if (mode === "keep") {
old.push(s);
}
}
if (ins_lines.length > 0 || del_lines.length > 0) {
chunks.push(
new Chunk(
old.length - del_lines.length,
[...del_lines],
[...ins_lines]
)
);
}
if (index < lines.length && lines[index] === "*** End of File") {
index += 1;
return [old, chunks, index, true];
}
if (index === orig_index) {
throw new DiffError("Nothing in this section");
}
return [old, chunks, index, false];
}
// --------------------------------------------------------------------------- //
// Patch → Commit and Commit application
// --------------------------------------------------------------------------- //
function _get_updated_file(text, action, path) {
if (action.type !== ActionType.UPDATE) {
throw new DiffError("_get_updated_file called with non-update action");
}
const orig_lines = text.split("\n");
const dest_lines = [];
let orig_index = 0;
for (const chunk of action.chunks) {
if (chunk.orig_index > orig_lines.length) {
throw new DiffError(
`${path}: chunk.orig_index ${chunk.orig_index} exceeds file length`
);
}
if (orig_index > chunk.orig_index) {
throw new DiffError(
`${path}: overlapping chunks at ${orig_index} > ${chunk.orig_index}`
);
}
// Add lines before the chunk
for (let i = orig_index; i < chunk.orig_index; i++) {
dest_lines.push(orig_lines[i]);
}
orig_index = chunk.orig_index;
// Add inserted lines
dest_lines.push(...chunk.ins_lines);
// Skip deleted lines
orig_index += chunk.del_lines.length;
}
// Add remaining lines
for (let i = orig_index; i < orig_lines.length; i++) {
dest_lines.push(orig_lines[i]);
}
return dest_lines.join("\n");
}
function patch_to_commit(patch, orig) {
const commit = new Commit();
for (const [path, action] of Object.entries(patch.actions)) {
if (action.type === ActionType.DELETE) {
commit.changes[path] = new FileChange(
ActionType.DELETE,
orig[path],
null,
null
);
} else if (action.type === ActionType.ADD) {
if (action.new_file === null) {
throw new DiffError("ADD action without file content");
}
commit.changes[path] = new FileChange(
ActionType.ADD,
null,
action.new_file,
null
);
} else if (action.type === ActionType.UPDATE) {
const new_content = _get_updated_file(orig[path], action, path);
commit.changes[path] = new FileChange(
ActionType.UPDATE,
orig[path],
new_content,
action.move_path
);
}
}
return commit;
}
// --------------------------------------------------------------------------- //
// User-facing helpers
// --------------------------------------------------------------------------- //
function text_to_patch(text, orig) {
// Handle different line ending scenarios
let lines = text.split(/\r?\n/);
// Remove trailing empty line if it exists (from trailing newline)
if (lines.length > 0 && lines[lines.length - 1] === "") {
lines = lines.slice(0, -1);
}
// Debug logging
console.log("Lines count:", lines.length);
console.log("First line:", JSON.stringify(lines[0]));
if (lines.length > 0) {
console.log("Last line:", JSON.stringify(lines[lines.length - 1]));
console.log("First line normalized:", JSON.stringify(Parser._norm(lines[0])));
console.log("Last line normalized:", JSON.stringify(Parser._norm(lines[lines.length - 1])));
}
if (
lines.length < 2 ||
!Parser._norm(lines[0]).startsWith("*** Begin Patch") ||
(lines.length > 0 && Parser._norm(lines[lines.length - 1]) !== "*** End Patch")
) {
throw new DiffError("Invalid patch text - missing sentinels");
}
const parser = new Parser(orig, lines);
parser.index = 1;
parser.parse();
return [parser.patch, parser.fuzz];
}
function identify_files_needed(text) {
// Handle line splitting consistently
let lines = text.split(/\r?\n/);
if (lines.length > 0 && lines[lines.length - 1] === "") {
lines = lines.slice(0, -1);
}
const updateFiles = lines
.filter(line => line.startsWith("*** Update File: "))
.map(line => line.substring("*** Update File: ".length));
const deleteFiles = lines
.filter(line => line.startsWith("*** Delete File: "))
.map(line => line.substring("*** Delete File: ".length));
return [...updateFiles, ...deleteFiles];
}
function identify_files_added(text) {
// Handle line splitting consistently
let lines = text.split(/\r?\n/);
if (lines.length > 0 && lines[lines.length - 1] === "") {
lines = lines.slice(0, -1);
}
return lines
.filter(line => line.startsWith("*** Add File: "))
.map(line => line.substring("*** Add File: ".length));
}
// --------------------------------------------------------------------------- //
// File-system helpers
// --------------------------------------------------------------------------- //
function load_files(paths, open_fn) {
const result = {};
for (const path of paths) {
result[path] = open_fn(path);
}
return result;
}
function apply_commit(commit, write_fn, remove_fn) {
for (const [path, change] of Object.entries(commit.changes)) {
if (change.type === ActionType.DELETE) {
remove_fn(path);
} else if (change.type === ActionType.ADD) {
if (change.new_content === null) {
throw new DiffError(`ADD change for ${path} has no content`);
}
write_fn(path, change.new_content);
} else if (change.type === ActionType.UPDATE) {
if (change.new_content === null) {
throw new DiffError(`UPDATE change for ${path} has no new content`);
}
const target = change.move_path || path;
write_fn(target, change.new_content);
if (change.move_path) {
remove_fn(path);
}
}
}
}
function process_patch(text, open_fn, write_fn, remove_fn) {
if (!text.startsWith("*** Begin Patch")) {
throw new DiffError("Patch text must start with *** Begin Patch");
}
const paths = identify_files_needed(text);
const orig = load_files(paths, open_fn);
const [patch, _fuzz] = text_to_patch(text, orig);
const commit = patch_to_commit(patch, orig);
apply_commit(commit, write_fn, remove_fn);
return "Done!";
}
// --------------------------------------------------------------------------- //
// Default FS helpers
// --------------------------------------------------------------------------- //
import fs from 'fs';
import path from 'path';
function open_file(filepath) {
return fs.readFileSync(filepath, 'utf8');
}
function write_file(filepath, content) {
const dir = path.dirname(filepath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(filepath, content, 'utf8');
}
function remove_file(filepath) {
if (fs.existsSync(filepath)) {
fs.unlinkSync(filepath);
}
}
// Export for the new interface
export default {
type: "function",
name: "patch_files",
description: "Apply a unified diff patch to files within a chroot directory, with option to reverse the patch",
parameters: {
type: "object",
properties: {
patch: {
type: "string",
description: "The unidiff patch string to apply.",
}
},
required: ["patch"],
additionalProperties: false,
},
strict: true,
};
export async function run(args) {
console.log('patch_files:', args);
try {
const result = process_patch(
args.patch,
open_file,
write_file,
remove_file
);
return result;
} catch (error) {
if (error instanceof DiffError) {
throw new Error(`Patch error: ${error.message}`);
}
throw error;
}
}