Files
toolLooper/tools/patch_files.js

826 lines
27 KiB
JavaScript

#!/usr/bin/env node
const desc = `
This is a custom utility that makes it more convenient to add, remove, move, or edit code files. 'apply_patch' effectively allows you to execute a diff/patch against a file,
but the format of the diff specification is unique to this task, so pay careful attention to these instructions.
To use the 'apply_patch' command, you should pass a message of the following structure as "input":
*** Begin Patch
[YOUR_PATCH]
*** 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.
Complese Example:
*** Begin Patch
*** Add File: /test.js
+ function method() {
+ console.log("Hello, world!");
+ }
*** End Patch
`;
// --------------------------------------------------------------------------- //
// 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 = {};
}
}
// --------------------------------------------------------------------------- //
// Path utilities for chroot functionality
// --------------------------------------------------------------------------- //
function normalizePath(p) {
return (p || '').replace(/\\/g, '/').replace(/\/+/g, '/');
}
function joinPaths(...parts) {
const joined = parts.filter(Boolean).join('/');
return normalizePath(joined);
}
function resolvePath(chroot, filepath) {
const file = normalizePath(filepath);
if (!chroot) return file;
const root = normalizePath(chroot);
// If file is absolute, use it as-is (after normalization).
// We assume the caller ensures it is inside the chroot.
if (file.startsWith('/')) {
return file;
}
// If file is relative, join with chroot
const resolved = joinPaths(root, file);
return resolved.startsWith('/') ? resolved : '/' + resolved;
}
function unresolvePath(chroot, filepath) {
const file = normalizePath(filepath);
if (!chroot) return file;
const root = normalizePath(chroot);
const rootWithSlash = root.endsWith('/') ? root : root + '/';
// Convert absolute path back to what user would expect
if (file.startsWith(rootWithSlash)) {
// Return path relative to chroot (without leading slash for user expectation)
return file.substring(rootWithSlash.length);
}
if (file === root) {
return '';
}
// If somehow outside chroot, return as-is
return file;
}
// --------------------------------------------------------------------------- //
// Patch text parser
// --------------------------------------------------------------------------- //
class Parser {
constructor(current_files, lines, chroot = null) {
this.current_files = current_files;
this.lines = lines;
this.index = 0;
this.patch = new Patch();
this.fuzz = 0;
this.chroot = chroot;
}
// ------------- 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) {
// Resolve path with chroot
const resolvedPath = resolvePath(this.chroot, path);
if (resolvedPath in this.patch.actions) {
throw new DiffError(`Duplicate update for file: ${resolvedPath}`);
}
const move_to = this.read_str("*** Move to: ");
if (!(resolvedPath in this.current_files)) {
throw new DiffError(`Update File Error - missing file: ${resolvedPath}`);
}
const text = this.current_files[resolvedPath];
const action = this._parse_update_file(text);
action.move_path = move_to ? resolvePath(this.chroot, move_to) : null;
this.patch.actions[resolvedPath] = action;
continue;
}
// ---------- DELETE ---------- //
path = this.read_str("*** Delete File: ");
if (path) {
// Resolve path with chroot
const resolvedPath = resolvePath(this.chroot, path);
if (resolvedPath in this.patch.actions) {
throw new DiffError(`Duplicate delete for file: ${resolvedPath}`);
}
if (!(resolvedPath in this.current_files)) {
throw new DiffError(`Delete File Error - missing file: ${resolvedPath}`);
}
this.patch.actions[resolvedPath] = new PatchAction(ActionType.DELETE);
continue;
}
// ---------- ADD ---------- //
path = this.read_str("*** Add File: ");
if (path) {
// Resolve path with chroot
const resolvedPath = resolvePath(this.chroot, path);
if (resolvedPath in this.patch.actions) {
throw new DiffError(`Duplicate add for file: ${resolvedPath}`);
}
if (resolvedPath in this.current_files) {
throw new DiffError(`Add File Error - file already exists: ${resolvedPath}`);
}
this.patch.actions[resolvedPath] = 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) {
// Special case: if context is empty, return start position
if (!context || context.length === 0) {
return [start, 0];
}
// Special handling for full-file replacement patterns
// If context is large and starts with deletion lines, try to match at beginning
if (context.length > 3) {
// Try exact match at start
let [new_index, fuzz] = find_context_core(lines, context, 0);
if (new_index !== -1) {
return [new_index, fuzz];
}
// Try fuzzy match at start (allowing for whitespace differences)
let match = true;
let local_fuzz = 0;
const compare_length = Math.min(context.length, lines.length);
for (let j = 0; j < compare_length; j++) {
if (j < lines.length && j < context.length) {
if (lines[j] !== context[j]) {
if (lines[j].trim() === context[j].trim()) {
local_fuzz += 10;
} else if (lines[j].replace(/\s+$/, "") === context[j].replace(/\s+$/, "")) {
local_fuzz += 1;
} else {
// Allow some mismatch for full-file replacements
local_fuzz += 100;
}
}
}
}
if (local_fuzz < context.length * 50) { // Allow up to 50 fuzz per line
return [0, local_fuzz];
}
}
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 && (ins_lines.length > 0 || del_lines.length > 0)) {
const chunk_orig_index = old.length - del_lines.length;
chunks.push(
new Chunk(
chunk_orig_index,
[...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);
}
}
// Handle any remaining content
if (ins_lines.length > 0 || del_lines.length > 0) {
const chunk_orig_index = old.length - del_lines.length;
chunks.push(
new Chunk(
chunk_orig_index,
[...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, chroot = null) {
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);
const move_path = action.move_path ? unresolvePath(chroot, action.move_path) : null;
commit.changes[path] = new FileChange(
ActionType.UPDATE,
orig[path],
new_content,
move_path
);
}
}
return commit;
}
// --------------------------------------------------------------------------- //
// User-facing helpers
// --------------------------------------------------------------------------- //
function text_to_patch(text, orig, chroot = null) {
// 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, chroot);
parser.index = 1;
parser.parse();
return [parser.patch, parser.fuzz];
}
function identify_files_needed(text, chroot = null) {
// 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 => resolvePath(chroot, line.substring("*** Update File: ".length)));
const deleteFiles = lines
.filter(line => line.startsWith("*** Delete File: "))
.map(line => resolvePath(chroot, line.substring("*** Delete File: ".length)));
return [...updateFiles, ...deleteFiles];
}
function identify_files_added(text, chroot = null) {
// 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 => resolvePath(chroot, line.substring("*** Add File: ".length)));
}
// --------------------------------------------------------------------------- //
// File-system helpers
// --------------------------------------------------------------------------- //
function load_files(paths, open_fn) {
const result = {};
for (const p of paths) {
try {
result[p] = open_fn(p);
} catch (err) {
// Skip truly missing files so parser can emit precise DiffErrors
if (err && (err.code === 'ENOENT' || /ENOENT/.test(String(err)))) {
continue;
}
throw err;
}
}
return result;
}
function apply_commit(commit, write_fn, remove_fn, chroot = null) {
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 ? resolvePath(chroot, 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, chroot = null) {
if (!text.startsWith("*** Begin Patch")) {
throw new DiffError("Patch text must start with *** Begin Patch");
}
// Load update/delete targets and also ADD targets if they already exist,
// so the parser can detect "Add File Error - file already exists".
const updateDeletePaths = identify_files_needed(text, chroot);
const addPaths = identify_files_added(text, chroot);
const allPaths = [...updateDeletePaths, ...addPaths];
const orig = load_files(allPaths, open_fn);
const [patch, _fuzz] = text_to_patch(text, orig, chroot);
const commit = patch_to_commit(patch, orig, chroot);
apply_commit(commit, write_fn, remove_fn, chroot);
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: "Generic Text File Editor create,edit,delete - Apply a unified diff patch " + desc,
parameters: {
type: "object",
properties: {
patch: {
type: "string",
description: "The patch string to apply.",
}
},
required: ["patch"],
additionalProperties: false,
},
strict: true,
};
export async function run(args) {
try {
const result = process_patch(
args.patch,
open_file,
write_file,
remove_file,
'/workspaces/aiTools/root'
);
return result;
} catch (error) {
return `Patch error: ${error.message}`
}
}