diff options
| author | Dax Raad <[email protected]> | 2025-05-30 20:47:56 -0400 |
|---|---|---|
| committer | Dax Raad <[email protected]> | 2025-05-30 20:48:36 -0400 |
| commit | f3da73553c45f17e04b1e77cb13eb0fca714d1bd (patch) | |
| tree | a24317a19e1ab2a89da50db669dc6894f15d00d1 /js/src/tool/patch.ts | |
| parent | 9a26b3058ffc1023e5c7e54b6d571c903d15888e (diff) | |
| download | opencode-f3da73553c45f17e04b1e77cb13eb0fca714d1bd.tar.gz opencode-f3da73553c45f17e04b1e77cb13eb0fca714d1bd.zip | |
sync
Diffstat (limited to 'js/src/tool/patch.ts')
| -rw-r--r-- | js/src/tool/patch.ts | 420 |
1 files changed, 0 insertions, 420 deletions
diff --git a/js/src/tool/patch.ts b/js/src/tool/patch.ts deleted file mode 100644 index 9f9192fda..000000000 --- a/js/src/tool/patch.ts +++ /dev/null @@ -1,420 +0,0 @@ -import { z } from "zod"; -import * as path from "path"; -import * as fs from "fs/promises"; -import { Tool } from "./tool"; -import { FileTimes } from "./util/file-times"; - -const DESCRIPTION = `Applies a patch to multiple files in one operation. This tool is useful for making coordinated changes across multiple files. - -The patch text must follow this format: -*** Begin Patch -*** Update File: /path/to/file -@@ Context line (unique within the file) - Line to keep --Line to remove -+Line to add - Line to keep -*** Add File: /path/to/new/file -+Content of the new file -+More content -*** Delete File: /path/to/file/to/delete -*** End Patch - -Before using this tool: -1. Use the FileRead tool to understand the files' contents and context -2. Verify all file paths are correct (use the LS tool) - -CRITICAL REQUIREMENTS FOR USING THIS TOOL: - -1. UNIQUENESS: Context lines MUST uniquely identify the specific sections you want to change -2. PRECISION: All whitespace, indentation, and surrounding code must match exactly -3. VALIDATION: Ensure edits result in idiomatic, correct code -4. PATHS: Always use absolute file paths (starting with /) - -The tool will apply all changes in a single atomic operation.`; - -const PatchParams = z.object({ - patchText: z - .string() - .describe("The full patch text that describes all changes to be made"), -}); - -interface PatchResponseMetadata { - changed: string[]; - additions: number; - removals: number; -} - -interface Change { - type: "add" | "update" | "delete"; - old_content?: string; - new_content?: string; -} - -interface Commit { - changes: Record<string, Change>; -} - -interface PatchOperation { - type: "update" | "add" | "delete"; - filePath: string; - hunks?: PatchHunk[]; - content?: string; -} - -interface PatchHunk { - contextLine: string; - changes: PatchChange[]; -} - -interface PatchChange { - type: "keep" | "remove" | "add"; - content: string; -} - -function identifyFilesNeeded(patchText: string): string[] { - const files: string[] = []; - const lines = patchText.split("\n"); - for (const line of lines) { - if ( - line.startsWith("*** Update File:") || - line.startsWith("*** Delete File:") - ) { - const filePath = line.split(":", 2)[1]?.trim(); - if (filePath) files.push(filePath); - } - } - return files; -} - -function identifyFilesAdded(patchText: string): string[] { - const files: string[] = []; - const lines = patchText.split("\n"); - for (const line of lines) { - if (line.startsWith("*** Add File:")) { - const filePath = line.split(":", 2)[1]?.trim(); - if (filePath) files.push(filePath); - } - } - return files; -} - -function textToPatch( - patchText: string, - _currentFiles: Record<string, string>, -): [PatchOperation[], number] { - const operations: PatchOperation[] = []; - const lines = patchText.split("\n"); - let i = 0; - let fuzz = 0; - - while (i < lines.length) { - const line = lines[i]; - - if (line.startsWith("*** Update File:")) { - const filePath = line.split(":", 2)[1]?.trim(); - if (!filePath) { - i++; - continue; - } - - const hunks: PatchHunk[] = []; - i++; - - while (i < lines.length && !lines[i].startsWith("***")) { - if (lines[i].startsWith("@@")) { - const contextLine = lines[i].substring(2).trim(); - const changes: PatchChange[] = []; - i++; - - while ( - i < lines.length && - !lines[i].startsWith("@@") && - !lines[i].startsWith("***") - ) { - const changeLine = lines[i]; - if (changeLine.startsWith(" ")) { - changes.push({ type: "keep", content: changeLine.substring(1) }); - } else if (changeLine.startsWith("-")) { - changes.push({ - type: "remove", - content: changeLine.substring(1), - }); - } else if (changeLine.startsWith("+")) { - changes.push({ type: "add", content: changeLine.substring(1) }); - } - i++; - } - - hunks.push({ contextLine, changes }); - } else { - i++; - } - } - - operations.push({ type: "update", filePath, hunks }); - } else if (line.startsWith("*** Add File:")) { - const filePath = line.split(":", 2)[1]?.trim(); - if (!filePath) { - i++; - continue; - } - - let content = ""; - i++; - - while (i < lines.length && !lines[i].startsWith("***")) { - if (lines[i].startsWith("+")) { - content += lines[i].substring(1) + "\n"; - } - i++; - } - - operations.push({ type: "add", filePath, content: content.slice(0, -1) }); - } else if (line.startsWith("*** Delete File:")) { - const filePath = line.split(":", 2)[1]?.trim(); - if (filePath) { - operations.push({ type: "delete", filePath }); - } - i++; - } else { - i++; - } - } - - return [operations, fuzz]; -} - -function patchToCommit( - operations: PatchOperation[], - currentFiles: Record<string, string>, -): Commit { - const changes: Record<string, Change> = {}; - - for (const op of operations) { - if (op.type === "delete") { - changes[op.filePath] = { - type: "delete", - old_content: currentFiles[op.filePath] || "", - }; - } else if (op.type === "add") { - changes[op.filePath] = { - type: "add", - new_content: op.content || "", - }; - } else if (op.type === "update" && op.hunks) { - const originalContent = currentFiles[op.filePath] || ""; - const lines = originalContent.split("\n"); - - for (const hunk of op.hunks) { - const contextIndex = lines.findIndex((line) => - line.includes(hunk.contextLine), - ); - if (contextIndex === -1) { - throw new Error(`Context line not found: ${hunk.contextLine}`); - } - - let currentIndex = contextIndex; - for (const change of hunk.changes) { - if (change.type === "keep") { - currentIndex++; - } else if (change.type === "remove") { - lines.splice(currentIndex, 1); - } else if (change.type === "add") { - lines.splice(currentIndex, 0, change.content); - currentIndex++; - } - } - } - - changes[op.filePath] = { - type: "update", - old_content: originalContent, - new_content: lines.join("\n"), - }; - } - } - - return { changes }; -} - -function generateDiff( - oldContent: string, - newContent: string, - filePath: string, -): [string, number, number] { - // Mock implementation - would need actual diff generation - const lines1 = oldContent.split("\n"); - const lines2 = newContent.split("\n"); - const additions = Math.max(0, lines2.length - lines1.length); - const removals = Math.max(0, lines1.length - lines2.length); - return [`--- ${filePath}\n+++ ${filePath}\n`, additions, removals]; -} - -async function applyCommit( - commit: Commit, - writeFile: (path: string, content: string) => Promise<void>, - deleteFile: (path: string) => Promise<void>, -): Promise<void> { - for (const [filePath, change] of Object.entries(commit.changes)) { - if (change.type === "delete") { - await deleteFile(filePath); - } else if (change.new_content !== undefined) { - await writeFile(filePath, change.new_content); - } - } -} - -export const patch = Tool.define({ - name: "opencode.patch", - description: DESCRIPTION, - parameters: PatchParams, - execute: async (params) => { - if (!params.patchText) { - throw new Error("patchText is required"); - } - - // Identify all files needed for the patch and verify they've been read - const filesToRead = identifyFilesNeeded(params.patchText); - for (const filePath of filesToRead) { - let absPath = filePath; - if (!path.isAbsolute(absPath)) { - absPath = path.resolve(process.cwd(), absPath); - } - - if (!FileTimes.get(absPath)) { - throw new Error( - `you must read the file ${filePath} before patching it. Use the FileRead tool first`, - ); - } - - try { - const stats = await fs.stat(absPath); - if (stats.isDirectory()) { - throw new Error(`path is a directory, not a file: ${absPath}`); - } - - const lastRead = FileTimes.get(absPath); - if (lastRead && stats.mtime > lastRead) { - throw new Error( - `file ${absPath} has been modified since it was last read (mod time: ${stats.mtime.toISOString()}, last read: ${lastRead.toISOString()})`, - ); - } - } catch (error: any) { - if (error.code === "ENOENT") { - throw new Error(`file not found: ${absPath}`); - } - throw new Error(`failed to access file: ${error.message}`); - } - } - - // Check for new files to ensure they don't already exist - const filesToAdd = identifyFilesAdded(params.patchText); - for (const filePath of filesToAdd) { - let absPath = filePath; - if (!path.isAbsolute(absPath)) { - absPath = path.resolve(process.cwd(), absPath); - } - - try { - await fs.stat(absPath); - throw new Error(`file already exists and cannot be added: ${absPath}`); - } catch (error: any) { - if (error.code !== "ENOENT") { - throw new Error(`failed to check file: ${error.message}`); - } - } - } - - // Load all required files - const currentFiles: Record<string, string> = {}; - for (const filePath of filesToRead) { - let absPath = filePath; - if (!path.isAbsolute(absPath)) { - absPath = path.resolve(process.cwd(), absPath); - } - - try { - const content = await fs.readFile(absPath, "utf-8"); - currentFiles[filePath] = content; - } catch (error: any) { - throw new Error(`failed to read file ${absPath}: ${error.message}`); - } - } - - // Process the patch - const [patch, fuzz] = textToPatch(params.patchText, currentFiles); - if (fuzz > 3) { - throw new Error( - `patch contains fuzzy matches (fuzz level: ${fuzz}). Please make your context lines more precise`, - ); - } - - // Convert patch to commit - const commit = patchToCommit(patch, currentFiles); - - // Apply the changes to the filesystem - await applyCommit( - commit, - async (filePath: string, content: string) => { - let absPath = filePath; - if (!path.isAbsolute(absPath)) { - absPath = path.resolve(process.cwd(), absPath); - } - - // Create parent directories if needed - const dir = path.dirname(absPath); - await fs.mkdir(dir, { recursive: true }); - await fs.writeFile(absPath, content, "utf-8"); - }, - async (filePath: string) => { - let absPath = filePath; - if (!path.isAbsolute(absPath)) { - absPath = path.resolve(process.cwd(), absPath); - } - await fs.unlink(absPath); - }, - ); - - // Calculate statistics - const changedFiles: string[] = []; - let totalAdditions = 0; - let totalRemovals = 0; - - for (const [filePath, change] of Object.entries(commit.changes)) { - let absPath = filePath; - if (!path.isAbsolute(absPath)) { - absPath = path.resolve(process.cwd(), absPath); - } - changedFiles.push(absPath); - - const oldContent = change.old_content || ""; - const newContent = change.new_content || ""; - - // Calculate diff statistics - const [, additions, removals] = generateDiff( - oldContent, - newContent, - filePath, - ); - totalAdditions += additions; - totalRemovals += removals; - - // Record file operations - FileTimes.write(absPath); - FileTimes.read(absPath); - } - - const result = `Patch applied successfully. ${changedFiles.length} files changed, ${totalAdditions} additions, ${totalRemovals} removals`; - const output = result; - - return { - metadata: { - changed: changedFiles, - additions: totalAdditions, - removals: totalRemovals, - } satisfies PatchResponseMetadata, - output, - }; - }, -}); |
