blob: 0d8e72528e95268696e0b7a017eed52ef77b8f9f [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001package claudetool
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "go/parser"
10 "go/token"
11 "log/slog"
12 "os"
13 "path/filepath"
14 "strings"
15
Earl Lee2e463fb2025-04-17 11:22:22 -070016 "sketch.dev/claudetool/editbuf"
17 "sketch.dev/claudetool/patchkit"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070018 "sketch.dev/llm"
Earl Lee2e463fb2025-04-17 11:22:22 -070019)
20
21// Patch is a tool for precise text modifications in files.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070022var Patch = &llm.Tool{
Earl Lee2e463fb2025-04-17 11:22:22 -070023 Name: PatchName,
24 Description: strings.TrimSpace(PatchDescription),
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070025 InputSchema: llm.MustSchema(PatchInputSchema),
Earl Lee2e463fb2025-04-17 11:22:22 -070026 Run: PatchRun,
27}
28
29const (
30 PatchName = "patch"
31 PatchDescription = `
32File modification tool for precise text edits.
33
34Operations:
35- replace: Substitute text with new content
36- append_eof: Append new text at the end of the file
37- prepend_bof: Insert new text at the beginning of the file
38- overwrite: Replace the entire file with new content (automatically creates the file)
39
40Usage notes:
41- All inputs are interpreted literally (no automatic newline or whitespace handling)
42- For replace operations, oldText must appear EXACTLY ONCE in the file
43`
44
45 // If you modify this, update the termui template for prettier rendering.
46 PatchInputSchema = `
47{
48 "type": "object",
49 "required": ["path", "patches"],
50 "properties": {
51 "path": {
52 "type": "string",
53 "description": "Absolute path to the file to patch"
54 },
55 "patches": {
56 "type": "array",
57 "description": "List of patch requests to apply",
58 "items": {
59 "type": "object",
60 "required": ["operation", "newText"],
61 "properties": {
62 "operation": {
63 "type": "string",
64 "enum": ["replace", "append_eof", "prepend_bof", "overwrite"],
65 "description": "Type of operation to perform"
66 },
67 "oldText": {
68 "type": "string",
69 "description": "Text to locate for the operation (must be unique in file, required for replace)"
70 },
71 "newText": {
72 "type": "string",
73 "description": "The new text to use (empty for deletions)"
74 }
75 }
76 }
77 }
78 }
79}
80`
81)
82
83// TODO: maybe rename PatchRequest to PatchOperation or PatchSpec or PatchPart or just Patch?
84
85type patchInput struct {
86 Path string `json:"path"`
87 Patches []patchRequest `json:"patches"`
88}
89
90type patchRequest struct {
91 Operation string `json:"operation"`
92 OldText string `json:"oldText,omitempty"`
93 NewText string `json:"newText,omitempty"`
94}
95
96// PatchRun is the entry point for the user_patch tool.
Philip Zeyliger72252cb2025-05-10 17:00:08 -070097func PatchRun(ctx context.Context, m json.RawMessage) ([]llm.Content, error) {
Earl Lee2e463fb2025-04-17 11:22:22 -070098 var input patchInput
99 if err := json.Unmarshal(m, &input); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700100 return nil, fmt.Errorf("failed to unmarshal user_patch input: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -0700101 }
102
103 // Validate the input
104 if !filepath.IsAbs(input.Path) {
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700105 return nil, fmt.Errorf("path %q is not absolute", input.Path)
Earl Lee2e463fb2025-04-17 11:22:22 -0700106 }
107 if len(input.Patches) == 0 {
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700108 return nil, fmt.Errorf("no patches provided")
Earl Lee2e463fb2025-04-17 11:22:22 -0700109 }
110 // TODO: check whether the file is autogenerated, and if so, require a "force" flag to modify it.
111
112 orig, err := os.ReadFile(input.Path)
113 // If the file doesn't exist, we can still apply patches
114 // that don't require finding existing text.
115 switch {
116 case errors.Is(err, os.ErrNotExist):
117 for _, patch := range input.Patches {
118 switch patch.Operation {
119 case "prepend_bof", "append_eof", "overwrite":
120 default:
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700121 return nil, fmt.Errorf("file %q does not exist", input.Path)
Earl Lee2e463fb2025-04-17 11:22:22 -0700122 }
123 }
124 case err != nil:
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700125 return nil, fmt.Errorf("failed to read file %q: %w", input.Path, err)
Earl Lee2e463fb2025-04-17 11:22:22 -0700126 }
127
128 likelyGoFile := strings.HasSuffix(input.Path, ".go")
129
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000130 autogenerated := likelyGoFile && IsAutogeneratedGoFile(orig)
Earl Lee2e463fb2025-04-17 11:22:22 -0700131 parsed := likelyGoFile && parseGo(orig) != nil
132
133 origStr := string(orig)
134 // Process the patches "simultaneously", minimizing them along the way.
135 // Claude generates patches that interact with each other.
136 buf := editbuf.NewBuffer(orig)
137
138 // TODO: is it better to apply the patches that apply cleanly and report on the failures?
139 // or instead have it be all-or-nothing?
140 // For now, it is all-or-nothing.
141 // TODO: when the model gets into a "cannot apply patch" cycle of doom, how do we get it unstuck?
142 // Also: how do we detect that it's in a cycle?
143 var patchErr error
144 for i, patch := range input.Patches {
145 switch patch.Operation {
146 case "prepend_bof":
147 buf.Insert(0, patch.NewText)
148 case "append_eof":
149 buf.Insert(len(orig), patch.NewText)
150 case "overwrite":
151 buf.Replace(0, len(orig), patch.NewText)
152 case "replace":
153 if patch.OldText == "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700154 return nil, fmt.Errorf("patch %d: oldText cannot be empty for %s operation", i, patch.Operation)
Earl Lee2e463fb2025-04-17 11:22:22 -0700155 }
156
157 // Attempt to apply the patch.
158 spec, count := patchkit.Unique(origStr, patch.OldText, patch.NewText)
159 switch count {
160 case 0:
161 // no matches, maybe recoverable, continued below
162 case 1:
163 // exact match, apply
164 slog.DebugContext(ctx, "patch_applied", "method", "unique")
165 spec.ApplyToEditBuf(buf)
166 continue
167 case 2:
168 // multiple matches
169 patchErr = errors.Join(patchErr, fmt.Errorf("old text not unique:\n%s", patch.OldText))
170 default:
171 // TODO: return an error instead of using agentPatch
172 slog.ErrorContext(ctx, "unique returned unexpected count", "count", count)
173 patchErr = errors.Join(patchErr, fmt.Errorf("internal error"))
174 continue
175 }
176
177 // The following recovery mechanisms are heuristic.
178 // They aren't perfect, but they appear safe,
179 // and the cases they cover appear with some regularity.
180
181 // Try adjusting the whitespace prefix.
182 spec, ok := patchkit.UniqueDedent(origStr, patch.OldText, patch.NewText)
183 if ok {
184 slog.DebugContext(ctx, "patch_applied", "method", "unique_dedent")
185 spec.ApplyToEditBuf(buf)
186 continue
187 }
188
189 // Try ignoring leading/trailing whitespace in a semantically safe way.
190 spec, ok = patchkit.UniqueInValidGo(origStr, patch.OldText, patch.NewText)
191 if ok {
192 slog.DebugContext(ctx, "patch_applied", "method", "unique_in_valid_go")
193 spec.ApplyToEditBuf(buf)
194 continue
195 }
196
197 // Try ignoring semantically insignificant whitespace.
198 spec, ok = patchkit.UniqueGoTokens(origStr, patch.OldText, patch.NewText)
199 if ok {
200 slog.DebugContext(ctx, "patch_applied", "method", "unique_go_tokens")
201 spec.ApplyToEditBuf(buf)
202 continue
203 }
204
205 // Try trimming the first line of the patch, if we can do so safely.
206 spec, ok = patchkit.UniqueTrim(origStr, patch.OldText, patch.NewText)
207 if ok {
208 slog.DebugContext(ctx, "patch_applied", "method", "unique_trim")
209 spec.ApplyToEditBuf(buf)
210 continue
211 }
212
213 // No dice.
214 patchErr = errors.Join(patchErr, fmt.Errorf("old text not found:\n%s", patch.OldText))
215 continue
216 default:
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700217 return nil, fmt.Errorf("unrecognized operation %q", patch.Operation)
Earl Lee2e463fb2025-04-17 11:22:22 -0700218 }
219 }
220
221 if patchErr != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700222 return nil, patchErr
Earl Lee2e463fb2025-04-17 11:22:22 -0700223 }
224
225 patched, err := buf.Bytes()
226 if err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700227 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -0700228 }
229 if err := os.MkdirAll(filepath.Dir(input.Path), 0o700); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700230 return nil, fmt.Errorf("failed to create directory %q: %w", filepath.Dir(input.Path), err)
Earl Lee2e463fb2025-04-17 11:22:22 -0700231 }
232 if err := os.WriteFile(input.Path, patched, 0o600); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700233 return nil, fmt.Errorf("failed to write patched contents to file %q: %w", input.Path, err)
Earl Lee2e463fb2025-04-17 11:22:22 -0700234 }
235
236 response := new(strings.Builder)
237 fmt.Fprintf(response, "- Applied all patches\n")
238
239 if parsed {
240 parseErr := parseGo(patched)
241 if parseErr != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700242 return nil, fmt.Errorf("after applying all patches, the file no longer parses:\n%w", parseErr)
Earl Lee2e463fb2025-04-17 11:22:22 -0700243 }
244 }
245
246 if autogenerated {
247 fmt.Fprintf(response, "- WARNING: %q appears to be autogenerated. Patches were applied anyway.\n", input.Path)
248 }
249
250 // TODO: maybe report the patch result to the model, i.e. some/all of the new code after the patches and formatting.
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700251 return llm.TextContent(response.String()), nil
Earl Lee2e463fb2025-04-17 11:22:22 -0700252}
253
254func parseGo(buf []byte) error {
255 fset := token.NewFileSet()
256 _, err := parser.ParseFile(fset, "", buf, parser.SkipObjectResolution)
257 return err
258}
259
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000260// IsAutogeneratedGoFile reports whether a Go file has markers indicating it was autogenerated.
261func IsAutogeneratedGoFile(buf []byte) bool {
Earl Lee2e463fb2025-04-17 11:22:22 -0700262 for _, sig := range autogeneratedSignals {
263 if bytes.Contains(buf, []byte(sig)) {
264 return true
265 }
266 }
267
268 // https://pkg.go.dev/cmd/go#hdr-Generate_Go_files_by_processing_source
269 // "This line must appear before the first non-comment, non-blank text in the file."
270 // Approximate that by looking for it at the top of the file, before the last of the imports.
271 // (Sometimes people put it after the package declaration, because of course they do.)
272 // At least in the imports region we know it's not part of their actual code;
273 // we don't want to ignore the generator (which also includes these strings!),
274 // just the generated code.
275 fset := token.NewFileSet()
276 f, err := parser.ParseFile(fset, "x.go", buf, parser.ImportsOnly|parser.ParseComments)
277 if err == nil {
278 for _, cg := range f.Comments {
279 t := strings.ToLower(cg.Text())
280 for _, sig := range autogeneratedHeaderSignals {
281 if strings.Contains(t, sig) {
282 return true
283 }
284 }
285 }
286 }
287
288 return false
289}
290
291// autogeneratedSignals are signals that a file is autogenerated, when present anywhere in the file.
292var autogeneratedSignals = [][]byte{
293 []byte("\nfunc bindataRead("), // pre-embed bindata packed file
294}
295
296// autogeneratedHeaderSignals are signals that a file is autogenerated, when present at the top of the file.
297var autogeneratedHeaderSignals = []string{
298 // canonical would be `(?m)^// Code generated .* DO NOT EDIT\.$`
299 // but people screw it up, a lot, so be more lenient
300 strings.ToLower("generate"),
301 strings.ToLower("DO NOT EDIT"),
302 strings.ToLower("export by"),
303}