1package tools
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10 "time"
11
12 "github.com/kujtimiihoxha/opencode/internal/config"
13 "github.com/kujtimiihoxha/opencode/internal/diff"
14 "github.com/kujtimiihoxha/opencode/internal/history"
15 "github.com/kujtimiihoxha/opencode/internal/lsp"
16 "github.com/kujtimiihoxha/opencode/internal/permission"
17)
18
19type PatchParams struct {
20 FilePath string `json:"file_path"`
21 Patch string `json:"patch"`
22}
23
24type PatchPermissionsParams struct {
25 FilePath string `json:"file_path"`
26 Diff string `json:"diff"`
27}
28
29type PatchResponseMetadata struct {
30 Diff string `json:"diff"`
31 Additions int `json:"additions"`
32 Removals int `json:"removals"`
33}
34
35type patchTool struct {
36 lspClients map[string]*lsp.Client
37 permissions permission.Service
38 files history.Service
39}
40
41const (
42 // TODO: test if this works as expected
43 PatchToolName = "patch"
44 patchDescription = `Applies a patch to a file. This tool is similar to the edit tool but accepts a unified diff patch instead of old/new strings.
45
46Before using this tool:
47
481. Use the FileRead tool to understand the file's contents and context
49
502. Verify the directory path is correct:
51 - Use the LS tool to verify the parent directory exists and is the correct location
52
53To apply a patch, provide the following:
541. file_path: The absolute path to the file to modify (must be absolute, not relative)
552. patch: A unified diff patch to apply to the file
56
57The tool will apply the patch to the specified file. The patch must be in unified diff format.
58
59CRITICAL REQUIREMENTS FOR USING THIS TOOL:
60
611. PATCH FORMAT: The patch must be in unified diff format, which includes:
62 - File headers (--- a/file_path, +++ b/file_path)
63 - Hunk headers (@@ -start,count +start,count @@)
64 - Added lines (prefixed with +)
65 - Removed lines (prefixed with -)
66
672. CONTEXT: The patch must include sufficient context around the changes to ensure it applies correctly.
68
693. VERIFICATION: Before using this tool:
70 - Ensure the patch applies cleanly to the current state of the file
71 - Check that the file exists and you have read it first
72
73WARNING: If you do not follow these requirements:
74 - The tool will fail if the patch doesn't apply cleanly
75 - You may change the wrong parts of the file if the context is insufficient
76
77When applying patches:
78 - Ensure the patch results in idiomatic, correct code
79 - Do not leave the code in a broken state
80 - Always use absolute file paths (starting with /)
81
82Remember: patches are a powerful way to make multiple related changes at once, but they require careful preparation.`
83)
84
85func NewPatchTool(lspClients map[string]*lsp.Client, permissions permission.Service, files history.Service) BaseTool {
86 return &patchTool{
87 lspClients: lspClients,
88 permissions: permissions,
89 files: files,
90 }
91}
92
93func (p *patchTool) Info() ToolInfo {
94 return ToolInfo{
95 Name: PatchToolName,
96 Description: patchDescription,
97 Parameters: map[string]any{
98 "file_path": map[string]any{
99 "type": "string",
100 "description": "The absolute path to the file to modify",
101 },
102 "patch": map[string]any{
103 "type": "string",
104 "description": "The unified diff patch to apply",
105 },
106 },
107 Required: []string{"file_path", "patch"},
108 }
109}
110
111func (p *patchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
112 var params PatchParams
113 if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
114 return NewTextErrorResponse("invalid parameters"), nil
115 }
116
117 if params.FilePath == "" {
118 return NewTextErrorResponse("file_path is required"), nil
119 }
120
121 if params.Patch == "" {
122 return NewTextErrorResponse("patch is required"), nil
123 }
124
125 if !filepath.IsAbs(params.FilePath) {
126 wd := config.WorkingDirectory()
127 params.FilePath = filepath.Join(wd, params.FilePath)
128 }
129
130 // Check if file exists
131 fileInfo, err := os.Stat(params.FilePath)
132 if err != nil {
133 if os.IsNotExist(err) {
134 return NewTextErrorResponse(fmt.Sprintf("file not found: %s", params.FilePath)), nil
135 }
136 return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
137 }
138
139 if fileInfo.IsDir() {
140 return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", params.FilePath)), nil
141 }
142
143 if getLastReadTime(params.FilePath).IsZero() {
144 return NewTextErrorResponse("you must read the file before patching it. Use the View tool first"), nil
145 }
146
147 modTime := fileInfo.ModTime()
148 lastRead := getLastReadTime(params.FilePath)
149 if modTime.After(lastRead) {
150 return NewTextErrorResponse(
151 fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
152 params.FilePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
153 )), nil
154 }
155
156 // Read the current file content
157 content, err := os.ReadFile(params.FilePath)
158 if err != nil {
159 return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
160 }
161
162 oldContent := string(content)
163
164 // Parse and apply the patch
165 diffResult, err := diff.ParseUnifiedDiff(params.Patch)
166 if err != nil {
167 return NewTextErrorResponse(fmt.Sprintf("failed to parse patch: %v", err)), nil
168 }
169
170 // Apply the patch to get the new content
171 newContent, err := applyPatch(oldContent, diffResult)
172 if err != nil {
173 return NewTextErrorResponse(fmt.Sprintf("failed to apply patch: %v", err)), nil
174 }
175
176 if oldContent == newContent {
177 return NewTextErrorResponse("patch did not result in any changes to the file"), nil
178 }
179
180 sessionID, messageID := GetContextValues(ctx)
181 if sessionID == "" || messageID == "" {
182 return ToolResponse{}, fmt.Errorf("session ID and message ID are required for patching a file")
183 }
184
185 // Generate a diff for permission request and metadata
186 diffText, additions, removals := diff.GenerateDiff(
187 oldContent,
188 newContent,
189 params.FilePath,
190 )
191
192 // Request permission to apply the patch
193 p.permissions.Request(
194 permission.CreatePermissionRequest{
195 Path: filepath.Dir(params.FilePath),
196 ToolName: PatchToolName,
197 Action: "patch",
198 Description: fmt.Sprintf("Apply patch to file %s", params.FilePath),
199 Params: PatchPermissionsParams{
200 FilePath: params.FilePath,
201 Diff: diffText,
202 },
203 },
204 )
205
206 // Write the new content to the file
207 err = os.WriteFile(params.FilePath, []byte(newContent), 0o644)
208 if err != nil {
209 return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
210 }
211
212 // Update file history
213 file, err := p.files.GetByPathAndSession(ctx, params.FilePath, sessionID)
214 if err != nil {
215 _, err = p.files.Create(ctx, sessionID, params.FilePath, oldContent)
216 if err != nil {
217 return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
218 }
219 }
220 if file.Content != oldContent {
221 // User manually changed the content, store an intermediate version
222 _, err = p.files.CreateVersion(ctx, sessionID, params.FilePath, oldContent)
223 if err != nil {
224 fmt.Printf("Error creating file history version: %v\n", err)
225 }
226 }
227 // Store the new version
228 _, err = p.files.CreateVersion(ctx, sessionID, params.FilePath, newContent)
229 if err != nil {
230 fmt.Printf("Error creating file history version: %v\n", err)
231 }
232
233 recordFileWrite(params.FilePath)
234 recordFileRead(params.FilePath)
235
236 // Wait for LSP diagnostics and include them in the response
237 waitForLspDiagnostics(ctx, params.FilePath, p.lspClients)
238 text := fmt.Sprintf("<r>\nPatch applied to file: %s\n</r>\n", params.FilePath)
239 text += getDiagnostics(params.FilePath, p.lspClients)
240
241 return WithResponseMetadata(
242 NewTextResponse(text),
243 PatchResponseMetadata{
244 Diff: diffText,
245 Additions: additions,
246 Removals: removals,
247 }), nil
248}
249
250// applyPatch applies a parsed diff to a string and returns the resulting content
251func applyPatch(content string, diffResult diff.DiffResult) (string, error) {
252 lines := strings.Split(content, "\n")
253
254 // Process each hunk in the diff
255 for _, hunk := range diffResult.Hunks {
256 // Parse the hunk header to get line numbers
257 var oldStart, oldCount, newStart, newCount int
258 _, err := fmt.Sscanf(hunk.Header, "@@ -%d,%d +%d,%d @@", &oldStart, &oldCount, &newStart, &newCount)
259 if err != nil {
260 // Try alternative format with single line counts
261 _, err = fmt.Sscanf(hunk.Header, "@@ -%d +%d @@", &oldStart, &newStart)
262 if err != nil {
263 return "", fmt.Errorf("invalid hunk header format: %s", hunk.Header)
264 }
265 oldCount = 1
266 newCount = 1
267 }
268
269 // Adjust for 0-based array indexing
270 oldStart--
271 newStart--
272
273 // Apply the changes
274 newLines := make([]string, 0)
275 newLines = append(newLines, lines[:oldStart]...)
276
277 // Process the hunk lines in order
278 currentOldLine := oldStart
279 for _, line := range hunk.Lines {
280 switch line.Kind {
281 case diff.LineContext:
282 newLines = append(newLines, line.Content)
283 currentOldLine++
284 case diff.LineRemoved:
285 // Skip this line in the output (it's being removed)
286 currentOldLine++
287 case diff.LineAdded:
288 // Add the new line
289 newLines = append(newLines, line.Content)
290 }
291 }
292
293 // Append the rest of the file
294 newLines = append(newLines, lines[currentOldLine:]...)
295 lines = newLines
296 }
297
298 return strings.Join(lines, "\n"), nil
299}
300