1package tools
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "os"
8 "path/filepath"
9 "time"
10
11 "github.com/kujtimiihoxha/opencode/internal/config"
12 "github.com/kujtimiihoxha/opencode/internal/diff"
13 "github.com/kujtimiihoxha/opencode/internal/history"
14 "github.com/kujtimiihoxha/opencode/internal/logging"
15 "github.com/kujtimiihoxha/opencode/internal/lsp"
16 "github.com/kujtimiihoxha/opencode/internal/permission"
17)
18
19type PatchParams struct {
20 PatchText string `json:"patch_text"`
21}
22
23type PatchResponseMetadata struct {
24 FilesChanged []string `json:"files_changed"`
25 Additions int `json:"additions"`
26 Removals int `json:"removals"`
27}
28
29type patchTool struct {
30 lspClients map[string]*lsp.Client
31 permissions permission.Service
32 files history.Service
33}
34
35const (
36 PatchToolName = "patch"
37 patchDescription = `Applies a patch to multiple files in one operation. This tool is useful for making coordinated changes across multiple files.
38
39The patch text must follow this format:
40*** Begin Patch
41*** Update File: /path/to/file
42@@ Context line (unique within the file)
43 Line to keep
44-Line to remove
45+Line to add
46 Line to keep
47*** Add File: /path/to/new/file
48+Content of the new file
49+More content
50*** Delete File: /path/to/file/to/delete
51*** End Patch
52
53Before using this tool:
541. Use the FileRead tool to understand the files' contents and context
552. Verify all file paths are correct (use the LS tool)
56
57CRITICAL REQUIREMENTS FOR USING THIS TOOL:
58
591. UNIQUENESS: Context lines MUST uniquely identify the specific sections you want to change
602. PRECISION: All whitespace, indentation, and surrounding code must match exactly
613. VALIDATION: Ensure edits result in idiomatic, correct code
624. PATHS: Always use absolute file paths (starting with /)
63
64The tool will apply all changes in a single atomic operation.`
65)
66
67func NewPatchTool(lspClients map[string]*lsp.Client, permissions permission.Service, files history.Service) BaseTool {
68 return &patchTool{
69 lspClients: lspClients,
70 permissions: permissions,
71 files: files,
72 }
73}
74
75func (p *patchTool) Info() ToolInfo {
76 return ToolInfo{
77 Name: PatchToolName,
78 Description: patchDescription,
79 Parameters: map[string]any{
80 "patch_text": map[string]any{
81 "type": "string",
82 "description": "The full patch text that describes all changes to be made",
83 },
84 },
85 Required: []string{"patch_text"},
86 }
87}
88
89func (p *patchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
90 var params PatchParams
91 if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
92 return NewTextErrorResponse("invalid parameters"), nil
93 }
94
95 if params.PatchText == "" {
96 return NewTextErrorResponse("patch_text is required"), nil
97 }
98
99 // Identify all files needed for the patch and verify they've been read
100 filesToRead := diff.IdentifyFilesNeeded(params.PatchText)
101 for _, filePath := range filesToRead {
102 absPath := filePath
103 if !filepath.IsAbs(absPath) {
104 wd := config.WorkingDirectory()
105 absPath = filepath.Join(wd, absPath)
106 }
107
108 if getLastReadTime(absPath).IsZero() {
109 return NewTextErrorResponse(fmt.Sprintf("you must read the file %s before patching it. Use the FileRead tool first", filePath)), nil
110 }
111
112 fileInfo, err := os.Stat(absPath)
113 if err != nil {
114 if os.IsNotExist(err) {
115 return NewTextErrorResponse(fmt.Sprintf("file not found: %s", absPath)), nil
116 }
117 return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
118 }
119
120 if fileInfo.IsDir() {
121 return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", absPath)), nil
122 }
123
124 modTime := fileInfo.ModTime()
125 lastRead := getLastReadTime(absPath)
126 if modTime.After(lastRead) {
127 return NewTextErrorResponse(
128 fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
129 absPath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
130 )), nil
131 }
132 }
133
134 // Check for new files to ensure they don't already exist
135 filesToAdd := diff.IdentifyFilesAdded(params.PatchText)
136 for _, filePath := range filesToAdd {
137 absPath := filePath
138 if !filepath.IsAbs(absPath) {
139 wd := config.WorkingDirectory()
140 absPath = filepath.Join(wd, absPath)
141 }
142
143 _, err := os.Stat(absPath)
144 if err == nil {
145 return NewTextErrorResponse(fmt.Sprintf("file already exists and cannot be added: %s", absPath)), nil
146 } else if !os.IsNotExist(err) {
147 return ToolResponse{}, fmt.Errorf("failed to check file: %w", err)
148 }
149 }
150
151 // Load all required files
152 currentFiles := make(map[string]string)
153 for _, filePath := range filesToRead {
154 absPath := filePath
155 if !filepath.IsAbs(absPath) {
156 wd := config.WorkingDirectory()
157 absPath = filepath.Join(wd, absPath)
158 }
159
160 content, err := os.ReadFile(absPath)
161 if err != nil {
162 return ToolResponse{}, fmt.Errorf("failed to read file %s: %w", absPath, err)
163 }
164 currentFiles[filePath] = string(content)
165 }
166
167 // Process the patch
168 patch, fuzz, err := diff.TextToPatch(params.PatchText, currentFiles)
169 if err != nil {
170 return NewTextErrorResponse(fmt.Sprintf("failed to parse patch: %s", err)), nil
171 }
172
173 if fuzz > 3 {
174 return NewTextErrorResponse(fmt.Sprintf("patch contains fuzzy matches (fuzz level: %d). Please make your context lines more precise", fuzz)), nil
175 }
176
177 // Convert patch to commit
178 commit, err := diff.PatchToCommit(patch, currentFiles)
179 if err != nil {
180 return NewTextErrorResponse(fmt.Sprintf("failed to create commit from patch: %s", err)), nil
181 }
182
183 // Get session ID and message ID
184 sessionID, messageID := GetContextValues(ctx)
185 if sessionID == "" || messageID == "" {
186 return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a patch")
187 }
188
189 // Request permission for all changes
190 for path, change := range commit.Changes {
191 switch change.Type {
192 case diff.ActionAdd:
193 dir := filepath.Dir(path)
194 patchDiff, _, _ := diff.GenerateDiff("", *change.NewContent, path)
195 p := p.permissions.Request(
196 permission.CreatePermissionRequest{
197 Path: dir,
198 ToolName: PatchToolName,
199 Action: "create",
200 Description: fmt.Sprintf("Create file %s", path),
201 Params: EditPermissionsParams{
202 FilePath: path,
203 Diff: patchDiff,
204 },
205 },
206 )
207 if !p {
208 return ToolResponse{}, permission.ErrorPermissionDenied
209 }
210 case diff.ActionUpdate:
211 currentContent := ""
212 if change.OldContent != nil {
213 currentContent = *change.OldContent
214 }
215 newContent := ""
216 if change.NewContent != nil {
217 newContent = *change.NewContent
218 }
219 patchDiff, _, _ := diff.GenerateDiff(currentContent, newContent, path)
220 dir := filepath.Dir(path)
221 p := p.permissions.Request(
222 permission.CreatePermissionRequest{
223 Path: dir,
224 ToolName: PatchToolName,
225 Action: "update",
226 Description: fmt.Sprintf("Update file %s", path),
227 Params: EditPermissionsParams{
228 FilePath: path,
229 Diff: patchDiff,
230 },
231 },
232 )
233 if !p {
234 return ToolResponse{}, permission.ErrorPermissionDenied
235 }
236 case diff.ActionDelete:
237 dir := filepath.Dir(path)
238 patchDiff, _, _ := diff.GenerateDiff(*change.OldContent, "", path)
239 p := p.permissions.Request(
240 permission.CreatePermissionRequest{
241 Path: dir,
242 ToolName: PatchToolName,
243 Action: "delete",
244 Description: fmt.Sprintf("Delete file %s", path),
245 Params: EditPermissionsParams{
246 FilePath: path,
247 Diff: patchDiff,
248 },
249 },
250 )
251 if !p {
252 return ToolResponse{}, permission.ErrorPermissionDenied
253 }
254 }
255 }
256
257 // Apply the changes to the filesystem
258 err = diff.ApplyCommit(commit, func(path string, content string) error {
259 absPath := path
260 if !filepath.IsAbs(absPath) {
261 wd := config.WorkingDirectory()
262 absPath = filepath.Join(wd, absPath)
263 }
264
265 // Create parent directories if needed
266 dir := filepath.Dir(absPath)
267 if err := os.MkdirAll(dir, 0o755); err != nil {
268 return fmt.Errorf("failed to create parent directories for %s: %w", absPath, err)
269 }
270
271 return os.WriteFile(absPath, []byte(content), 0o644)
272 }, func(path string) error {
273 absPath := path
274 if !filepath.IsAbs(absPath) {
275 wd := config.WorkingDirectory()
276 absPath = filepath.Join(wd, absPath)
277 }
278 return os.Remove(absPath)
279 })
280 if err != nil {
281 return NewTextErrorResponse(fmt.Sprintf("failed to apply patch: %s", err)), nil
282 }
283
284 // Update file history for all modified files
285 changedFiles := []string{}
286 totalAdditions := 0
287 totalRemovals := 0
288
289 for path, change := range commit.Changes {
290 absPath := path
291 if !filepath.IsAbs(absPath) {
292 wd := config.WorkingDirectory()
293 absPath = filepath.Join(wd, absPath)
294 }
295 changedFiles = append(changedFiles, absPath)
296
297 oldContent := ""
298 if change.OldContent != nil {
299 oldContent = *change.OldContent
300 }
301
302 newContent := ""
303 if change.NewContent != nil {
304 newContent = *change.NewContent
305 }
306
307 // Calculate diff statistics
308 _, additions, removals := diff.GenerateDiff(oldContent, newContent, path)
309 totalAdditions += additions
310 totalRemovals += removals
311
312 // Update history
313 file, err := p.files.GetByPathAndSession(ctx, absPath, sessionID)
314 if err != nil && change.Type != diff.ActionAdd {
315 // If not adding a file, create history entry for existing file
316 _, err = p.files.Create(ctx, sessionID, absPath, oldContent)
317 if err != nil {
318 logging.Debug("Error creating file history", "error", err)
319 }
320 }
321
322 if err == nil && change.Type != diff.ActionAdd && file.Content != oldContent {
323 // User manually changed content, store intermediate version
324 _, err = p.files.CreateVersion(ctx, sessionID, absPath, oldContent)
325 if err != nil {
326 logging.Debug("Error creating file history version", "error", err)
327 }
328 }
329
330 // Store new version
331 if change.Type == diff.ActionDelete {
332 _, err = p.files.CreateVersion(ctx, sessionID, absPath, "")
333 } else {
334 _, err = p.files.CreateVersion(ctx, sessionID, absPath, newContent)
335 }
336 if err != nil {
337 logging.Debug("Error creating file history version", "error", err)
338 }
339
340 // Record file operations
341 recordFileWrite(absPath)
342 recordFileRead(absPath)
343 }
344
345 // Run LSP diagnostics on all changed files
346 for _, filePath := range changedFiles {
347 waitForLspDiagnostics(ctx, filePath, p.lspClients)
348 }
349
350 result := fmt.Sprintf("Patch applied successfully. %d files changed, %d additions, %d removals",
351 len(changedFiles), totalAdditions, totalRemovals)
352
353 diagnosticsText := ""
354 for _, filePath := range changedFiles {
355 diagnosticsText += getDiagnostics(filePath, p.lspClients)
356 }
357
358 if diagnosticsText != "" {
359 result += "\n\nDiagnostics:\n" + diagnosticsText
360 }
361
362 return WithResponseMetadata(
363 NewTextResponse(result),
364 PatchResponseMetadata{
365 FilesChanged: changedFiles,
366 Additions: totalAdditions,
367 Removals: totalRemovals,
368 }), nil
369}