Files
toolLooper/tools/patch_files.js

718 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* A self-contained JavaScript utility for applying human-readable
* "pseudo-diff" patch files to a collection of text files.
*
*** Begin Patch
[YOUR_PATH]
*** End Patch
Where [YOUR_PATCH] is the actual content of your patch, specified in the following V4A diff format.
*** [ACTION] File: [path/to/file] -> ACTION can be one of Add, Update, or Delete.
For each snippet of code that needs to be changed, repeat the following:
[context_before] -> See below for further instructions on context.
- [old_code] -> Precede the old code with a minus sign.
+ [new_code] -> Precede the new, replacement code with a plus sign.
[context_after] -> See below for further instructions on context.
For instructions on [context_before] and [context_after]:
- By default, show 3 lines of code immediately above and 3 lines immediately below each change. If a change is within 3 lines of a previous change, do NOT duplicate the first changes [context_after] lines in the second changes [context_before] lines.
- If 3 lines of context is insufficient to uniquely identify the snippet of code within the file, use the @@ operator to indicate the class or function to which the snippet belongs. For instance, we might have:
@@ class BaseClass
[3 lines of pre-context]
- [old_code]
+ [new_code]
[3 lines of post-context]
- If a code block is repeated so many times in a class or function such that even a single @@ statement and 3 lines of context cannot uniquely identify the snippet of code, you can use multiple `@@` statements to jump to the right context. For instance:
@@ class BaseClass
@@ def method():
[3 lines of pre-context]
- [old_code]
+ [new_code]
[3 lines of post-context]
Note, then, that we do not use line numbers in this diff format, as the context is enough to uniquely identify code. An example of a message that you might pass as "input" to this function, in order to apply a patch, is shown below.
*
*
*
*/
// --------------------------------------------------------------------------- //
// 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;
}
}