patch.go

  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), &params); 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