返回 DeepSeek-Reasonix
delete_symbol.go
根目录 / internal / tool / builtin / delete_symbol.go
1 package builtin
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "go/ast"
8 "go/parser"
9 "go/token"
10 "path/filepath"
11 "strings"
12
13 "reasonix/internal/diff"
14 "reasonix/internal/sandbox"
15 "reasonix/internal/tool"
16 )
17
18 func init() { tool.RegisterBuiltin(deleteSymbol{}) }
19
20 type deleteSymbol struct {
21 roots []string
22 rootSet *sandbox.WritableRootSet
23 guard SessionDataGuard
24 managed ManagedConfigPaths
25 workDir string
26 overlay FileOverlay
27 }
28
29 type symbolMatch struct {
30 name string
31 kind string
32 parent string
33 line int
34 start token.Pos
35 docStart token.Pos // start of the symbol's doc comment, if any (excluded from start)
36 end token.Pos
37 siblings []string
38 }
39
40 func (deleteSymbol) Name() string { return "delete_symbol" }
41
42 func (deleteSymbol) Description() string {
43 return "Delete a named symbol (function, method, type, interface, const, var) from a Go source file using AST parsing. For non-Go files, use delete_range with manual anchors."
44 }
45
46 func (deleteSymbol) Schema() json.RawMessage {
47 return json.RawMessage(`{
48 "type":"object",
49 "properties":{
50 "path":{"type":"string","description":"Path to the source file"},
51 "name":{"type":"string","description":"Name of the symbol to delete"},
52 "kind":{"type":"string","description":"Optional kind filter: func, method, type, interface, const, var"},
53 "parent":{"type":"string","description":"Optional parent struct name for method disambiguation"}
54 },
55 "required":["path","name"]
56 }`)
57 }
58
59 func (deleteSymbol) ReadOnly() bool { return false }
60
61 func (d deleteSymbol) DeclareWriteAccess(args json.RawMessage) (tool.WriteAccessDeclaration, error) {
62 return declareFilePathWriteAccess(d.workDir, args)
63 }
64
65 func (d deleteSymbol) Execute(ctx context.Context, args json.RawMessage) (string, error) {
66 var p struct {
67 Path string `json:"path"`
68 Name string `json:"name"`
69 Kind string `json:"kind"`
70 Parent string `json:"parent"`
71 }
72 if err := json.Unmarshal(args, &p); err != nil {
73 return "", fmt.Errorf("invalid args: %w", err)
74 }
75 if p.Path == "" {
76 return "", fmt.Errorf("path is required")
77 }
78 if p.Name == "" {
79 return "", fmt.Errorf("name is required")
80 }
81 p.Path = resolveIn(d.workDir, p.Path)
82 if err := confineWrite(ctx, effectiveWriteRoots(ctx, d.rootSet, d.roots), d.guard, d.managed, p.Path); err != nil {
83 return "", err
84 }
85
86 ext := strings.ToLower(filepath.Ext(p.Path))
87 if ext != ".go" {
88 return "", fmt.Errorf("delete_symbol only supports Go files — use delete_range for %s files", ext)
89 }
90 unlock := lockMutationPath(p.Path)
91 defer unlock()
92
93 src, err := readEditSource(ctx, d.overlay, p.Path)
94 if err != nil {
95 return "", fmt.Errorf("read %s: %w", p.Path, err)
96 }
97 if err := src.requireObserved(ctx, d.overlay, p.Path); err != nil {
98 return "", err
99 }
100 original := src.content
101
102 m, fset, err := d.findSymbol(p.Path, p.Name, p.Kind, p.Parent, []byte(original))
103 if err != nil {
104 return "", err
105 }
106
107 newContent := deleteLines(original, fset, m)
108 if err := src.write(ctx, d.overlay, p.Path, newContent); err != nil {
109 return "", fmt.Errorf("write %s: %w", p.Path, err)
110 }
111
112 change := diff.Build(p.Path, original, newContent, diff.Modify)
113 return change.Diff, nil
114 }
115
116 func (d deleteSymbol) Preview(ctx context.Context, args json.RawMessage) (diff.Change, error) {
117 var p struct {
118 Path string `json:"path"`
119 Name string `json:"name"`
120 Kind string `json:"kind"`
121 Parent string `json:"parent"`
122 }
123 if err := json.Unmarshal(args, &p); err != nil {
124 return diff.Change{}, fmt.Errorf("invalid args: %w", err)
125 }
126 if p.Path == "" {
127 return diff.Change{}, fmt.Errorf("path is required")
128 }
129 if p.Name == "" {
130 return diff.Change{}, fmt.Errorf("name is required")
131 }
132 p.Path = resolveIn(d.workDir, p.Path)
133 if err := confinePreview(effectiveWriteRoots(ctx, d.rootSet, d.roots), d.guard, d.managed, p.Path); err != nil {
134 return diff.Change{}, err
135 }
136
137 ext := strings.ToLower(filepath.Ext(p.Path))
138 if ext != ".go" {
139 return diff.Change{}, fmt.Errorf("delete_symbol only supports Go files")
140 }
141
142 src, err := readEditSource(ctx, d.overlay, p.Path)
143 if err != nil {
144 return diff.Change{}, fmt.Errorf("read %s: %w", p.Path, err)
145 }
146 original := src.content
147
148 m, fset, err := d.findSymbol(p.Path, p.Name, p.Kind, p.Parent, []byte(original))
149 if err != nil {
150 return diff.Change{}, err
151 }
152
153 newContent := deleteLines(original, fset, m)
154 return diff.Build(p.Path, original, newContent, diff.Modify), nil
155 }
156
157 // findSymbol parses src rather than re-reading path, so the returned fset
158 // offsets index the exact bytes the caller will edit — an unsaved editor buffer
159 // differs from disk, and offsets from the wrong one would cut the wrong lines.
160 func (d deleteSymbol) findSymbol(path, name, kind, parent string, src []byte) (symbolMatch, *token.FileSet, error) {
161 fset := token.NewFileSet()
162 f, err := parser.ParseFile(fset, path, src, parser.ParseComments)
163 if err != nil {
164 return symbolMatch{}, nil, fmt.Errorf("parse %s: %w", path, err)
165 }
166
167 matches := collectSymbols(fset, f)
168
169 var byName []symbolMatch
170 for _, m := range matches {
171 if m.name == name {
172 byName = append(byName, m)
173 }
174 }
175 if len(byName) == 0 {
176 return symbolMatch{}, nil, fmt.Errorf("symbol %q not found in %s", name, path)
177 }
178
179 filtered := byName
180 if kind != "" {
181 var byKind []symbolMatch
182 for _, m := range filtered {
183 if m.kind == kind {
184 byKind = append(byKind, m)
185 }
186 }
187 if len(byKind) == 0 {
188 return symbolMatch{}, nil, fmt.Errorf("symbol %q with kind %q not found", name, kind)
189 }
190 filtered = byKind
191 }
192 if parent != "" {
193 var byParent []symbolMatch
194 for _, m := range filtered {
195 if m.parent == parent {
196 byParent = append(byParent, m)
197 }
198 }
199 if len(byParent) == 0 {
200 return symbolMatch{}, nil, fmt.Errorf("symbol %q (kind=%q parent=%q) not found", name, kind, parent)
201 }
202 filtered = byParent
203 }
204
205 if len(filtered) > 1 {
206 var b strings.Builder
207 fmt.Fprintf(&b, "Multiple matches for %q — disambiguate with kind/parent:\n", name)
208 for _, m := range filtered {
209 fmt.Fprintf(&b, " line %d: %s %s", m.line, m.kind, m.name)
210 if m.parent != "" {
211 fmt.Fprintf(&b, " (on %s)", m.parent)
212 }
213 b.WriteString("\n")
214 }
215 return symbolMatch{}, nil, fmt.Errorf("%s", b.String())
216 }
217
218 if len(filtered[0].siblings) > 1 {
219 return symbolMatch{}, nil, fmt.Errorf("%s %q is declared in a multi-name %s spec with %s; delete_symbol refuses to remove it because that would also delete sibling symbols", filtered[0].kind, name, filtered[0].kind, strings.Join(filtered[0].siblings, ", "))
220 }
221
222 return filtered[0], fset, nil
223 }
224
225 func collectSymbols(fset *token.FileSet, f *ast.File) []symbolMatch {
226 var matches []symbolMatch
227 for _, decl := range f.Decls {
228 switch d := decl.(type) {
229 case *ast.FuncDecl:
230 m := symbolMatch{
231 name: d.Name.Name,
232 kind: "func",
233 start: d.Pos(),
234 end: d.End(),
235 line: fset.Position(d.Pos()).Line,
236 }
237 if d.Doc != nil {
238 m.docStart = d.Doc.Pos()
239 }
240 if d.Recv != nil && len(d.Recv.List) > 0 {
241 m.kind = "method"
242 recvType := d.Recv.List[0].Type
243 if se, ok := recvType.(*ast.StarExpr); ok {
244 if ident, ok := se.X.(*ast.Ident); ok {
245 m.parent = ident.Name
246 }
247 } else if ident, ok := recvType.(*ast.Ident); ok {
248 m.parent = ident.Name
249 }
250 }
251 matches = append(matches, m)
252 case *ast.GenDecl:
253 for _, spec := range d.Specs {
254 switch s := spec.(type) {
255 case *ast.TypeSpec:
256 m := symbolMatch{
257 name: s.Name.Name,
258 start: s.Pos(),
259 end: s.End(),
260 line: fset.Position(s.Pos()).Line,
261 }
262 if _, ok := s.Type.(*ast.InterfaceType); ok {
263 m.kind = "interface"
264 } else {
265 m.kind = "type"
266 }
267 if doc := specDoc(d, s.Doc); doc != nil {
268 m.docStart = doc.Pos()
269 }
270 matches = append(matches, m)
271 case *ast.ValueSpec:
272 kind := "var"
273 if d.Tok == token.CONST {
274 kind = "const"
275 }
276 names := make([]string, 0, len(s.Names))
277 for _, ident := range s.Names {
278 names = append(names, ident.Name)
279 }
280 var docStart token.Pos
281 if doc := specDoc(d, s.Doc); doc != nil {
282 docStart = doc.Pos()
283 }
284 for _, ident := range s.Names {
285 matches = append(matches, symbolMatch{
286 name: ident.Name,
287 kind: kind,
288 start: ident.Pos(),
289 docStart: docStart,
290 end: s.End(), // whole spec, incl. a multi-line value — ident.End() stops at the name
291 line: fset.Position(ident.Pos()).Line,
292 siblings: names,
293 })
294 }
295 }
296 }
297 }
298 }
299 return matches
300 }
301
302 // specDoc returns the doc comment governing one spec of a GenDecl: the spec's own
303 // doc when grouped (type/const/var (...)), else the GenDecl's doc for an
304 // unparenthesized single declaration — where the parser attaches the comment to
305 // the GenDecl, not the spec. nil for an undocumented spec, and never the group's
306 // own doc when deleting just one spec of a parenthesized block.
307 func specDoc(gen *ast.GenDecl, own *ast.CommentGroup) *ast.CommentGroup {
308 if own != nil {
309 return own
310 }
311 if gen.Lparen == token.NoPos {
312 return gen.Doc
313 }
314 return nil
315 }
316
317 func deleteLines(content string, fset *token.FileSet, m symbolMatch) string {
318 start := m.start
319 if m.docStart.IsValid() {
320 start = m.docStart // delete the doc comment along with the symbol, not orphan it
321 }
322 startOff := fset.Position(start).Offset
323 endOff := fset.Position(m.end).Offset
324
325 lineStart := startOff
326 for lineStart > 0 && content[lineStart-1] != '\n' {
327 lineStart--
328 }
329
330 lineEnd := endOff
331 for lineEnd < len(content) && content[lineEnd] != '\n' {
332 lineEnd++
333 }
334 if lineEnd < len(content) {
335 lineEnd++
336 }
337
338 return content[:lineStart] + content[lineEnd:]
339 }
340
340 lines GO