blob: 419e966888dd85d1fd3b15ee79b46ed255ea5764 [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.
97func PatchRun(ctx context.Context, m json.RawMessage) (string, error) {
98 var input patchInput
99 if err := json.Unmarshal(m, &input); err != nil {
100 return "", fmt.Errorf("failed to unmarshal user_patch input: %w", err)
101 }
102
103 // Validate the input
104 if !filepath.IsAbs(input.Path) {
105 return "", fmt.Errorf("path %q is not absolute", input.Path)
106 }
107 if len(input.Patches) == 0 {
108 return "", fmt.Errorf("no patches provided")
109 }
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:
121 return "", fmt.Errorf("file %q does not exist", input.Path)
122 }
123 }
124 case err != nil:
125 return "", fmt.Errorf("failed to read file %q: %w", input.Path, err)
126 }
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 == "" {
154 return "", fmt.Errorf("patch %d: oldText cannot be empty for %s operation", i, patch.Operation)
155 }
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:
217 return "", fmt.Errorf("unrecognized operation %q", patch.Operation)
218 }
219 }
220
221 if patchErr != nil {
222 sendTelemetry(ctx, "patch_error", map[string]any{
223 "orig": origStr,
224 "patches": input.Patches,
225 "errors": patchErr,
226 })
227 return "", patchErr
228 }
229
230 patched, err := buf.Bytes()
231 if err != nil {
232 return "", err
233 }
234 if err := os.MkdirAll(filepath.Dir(input.Path), 0o700); err != nil {
235 return "", fmt.Errorf("failed to create directory %q: %w", filepath.Dir(input.Path), err)
236 }
237 if err := os.WriteFile(input.Path, patched, 0o600); err != nil {
238 return "", fmt.Errorf("failed to write patched contents to file %q: %w", input.Path, err)
239 }
240
241 response := new(strings.Builder)
242 fmt.Fprintf(response, "- Applied all patches\n")
243
244 if parsed {
245 parseErr := parseGo(patched)
246 if parseErr != nil {
247 return "", fmt.Errorf("after applying all patches, the file no longer parses:\n%w", parseErr)
248 }
249 }
250
251 if autogenerated {
252 fmt.Fprintf(response, "- WARNING: %q appears to be autogenerated. Patches were applied anyway.\n", input.Path)
253 }
254
255 // TODO: maybe report the patch result to the model, i.e. some/all of the new code after the patches and formatting.
256 return response.String(), nil
257}
258
259func parseGo(buf []byte) error {
260 fset := token.NewFileSet()
261 _, err := parser.ParseFile(fset, "", buf, parser.SkipObjectResolution)
262 return err
263}
264
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000265// IsAutogeneratedGoFile reports whether a Go file has markers indicating it was autogenerated.
266func IsAutogeneratedGoFile(buf []byte) bool {
Earl Lee2e463fb2025-04-17 11:22:22 -0700267 for _, sig := range autogeneratedSignals {
268 if bytes.Contains(buf, []byte(sig)) {
269 return true
270 }
271 }
272
273 // https://pkg.go.dev/cmd/go#hdr-Generate_Go_files_by_processing_source
274 // "This line must appear before the first non-comment, non-blank text in the file."
275 // Approximate that by looking for it at the top of the file, before the last of the imports.
276 // (Sometimes people put it after the package declaration, because of course they do.)
277 // At least in the imports region we know it's not part of their actual code;
278 // we don't want to ignore the generator (which also includes these strings!),
279 // just the generated code.
280 fset := token.NewFileSet()
281 f, err := parser.ParseFile(fset, "x.go", buf, parser.ImportsOnly|parser.ParseComments)
282 if err == nil {
283 for _, cg := range f.Comments {
284 t := strings.ToLower(cg.Text())
285 for _, sig := range autogeneratedHeaderSignals {
286 if strings.Contains(t, sig) {
287 return true
288 }
289 }
290 }
291 }
292
293 return false
294}
295
296// autogeneratedSignals are signals that a file is autogenerated, when present anywhere in the file.
297var autogeneratedSignals = [][]byte{
298 []byte("\nfunc bindataRead("), // pre-embed bindata packed file
299}
300
301// autogeneratedHeaderSignals are signals that a file is autogenerated, when present at the top of the file.
302var autogeneratedHeaderSignals = []string{
303 // canonical would be `(?m)^// Code generated .* DO NOT EDIT\.$`
304 // but people screw it up, a lot, so be more lenient
305 strings.ToLower("generate"),
306 strings.ToLower("DO NOT EDIT"),
307 strings.ToLower("export by"),
308}