326 lines
8.7 KiB
Go
326 lines
8.7 KiB
Go
package builtin
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"go/ast"
|
|
"go/parser"
|
|
"go/token"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"reasonix/internal/diff"
|
|
"reasonix/internal/tool"
|
|
)
|
|
|
|
func init() { tool.RegisterBuiltin(deleteSymbol{}) }
|
|
|
|
type deleteSymbol struct {
|
|
roots []string
|
|
guard SessionDataGuard
|
|
managed ManagedConfigPaths
|
|
workDir string
|
|
}
|
|
|
|
type symbolMatch struct {
|
|
name string
|
|
kind string
|
|
parent string
|
|
line int
|
|
start token.Pos
|
|
docStart token.Pos // start of the symbol's doc comment, if any (excluded from start)
|
|
end token.Pos
|
|
siblings []string
|
|
}
|
|
|
|
func (deleteSymbol) Name() string { return "delete_symbol" }
|
|
|
|
func (deleteSymbol) Description() string {
|
|
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."
|
|
}
|
|
|
|
func (deleteSymbol) Schema() json.RawMessage {
|
|
return json.RawMessage(`{
|
|
"type":"object",
|
|
"properties":{
|
|
"path":{"type":"string","description":"Path to the source file"},
|
|
"name":{"type":"string","description":"Name of the symbol to delete"},
|
|
"kind":{"type":"string","description":"Optional kind filter: func, method, type, interface, const, var"},
|
|
"parent":{"type":"string","description":"Optional parent struct name for method disambiguation"}
|
|
},
|
|
"required":["path","name"]
|
|
}`)
|
|
}
|
|
|
|
func (deleteSymbol) ReadOnly() bool { return false }
|
|
|
|
func (d deleteSymbol) Execute(ctx context.Context, args json.RawMessage) (string, error) {
|
|
var p struct {
|
|
Path string `json:"path"`
|
|
Name string `json:"name"`
|
|
Kind string `json:"kind"`
|
|
Parent string `json:"parent"`
|
|
}
|
|
if err := json.Unmarshal(args, &p); err != nil {
|
|
return "", fmt.Errorf("invalid args: %w", err)
|
|
}
|
|
if p.Path == "" {
|
|
return "", fmt.Errorf("path is required")
|
|
}
|
|
if p.Name == "" {
|
|
return "", fmt.Errorf("name is required")
|
|
}
|
|
p.Path = resolveIn(d.workDir, p.Path)
|
|
if err := confineWrite(ctx, d.roots, d.guard, d.managed, p.Path); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
ext := strings.ToLower(filepath.Ext(p.Path))
|
|
if ext != ".go" {
|
|
return "", fmt.Errorf("delete_symbol only supports Go files — use delete_range for %s files", ext)
|
|
}
|
|
|
|
m, fset, err := d.findSymbol(p.Path, p.Name, p.Kind, p.Parent)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
src, err := os.ReadFile(p.Path)
|
|
if err != nil {
|
|
return "", fmt.Errorf("read %s: %w", p.Path, err)
|
|
}
|
|
original := string(src)
|
|
|
|
newContent := deleteLines(original, fset, m)
|
|
if err := os.WriteFile(p.Path, []byte(newContent), 0o644); err != nil {
|
|
return "", fmt.Errorf("write %s: %w", p.Path, err)
|
|
}
|
|
|
|
change := diff.Build(p.Path, original, newContent, diff.Modify)
|
|
return change.Diff, nil
|
|
}
|
|
|
|
func (d deleteSymbol) Preview(args json.RawMessage) (diff.Change, error) {
|
|
var p struct {
|
|
Path string `json:"path"`
|
|
Name string `json:"name"`
|
|
Kind string `json:"kind"`
|
|
Parent string `json:"parent"`
|
|
}
|
|
if err := json.Unmarshal(args, &p); err != nil {
|
|
return diff.Change{}, fmt.Errorf("invalid args: %w", err)
|
|
}
|
|
if p.Path == "" {
|
|
return diff.Change{}, fmt.Errorf("path is required")
|
|
}
|
|
if p.Name == "" {
|
|
return diff.Change{}, fmt.Errorf("name is required")
|
|
}
|
|
p.Path = resolveIn(d.workDir, p.Path)
|
|
if err := confinePreview(d.roots, d.guard, d.managed, p.Path); err != nil {
|
|
return diff.Change{}, err
|
|
}
|
|
|
|
ext := strings.ToLower(filepath.Ext(p.Path))
|
|
if ext != ".go" {
|
|
return diff.Change{}, fmt.Errorf("delete_symbol only supports Go files")
|
|
}
|
|
|
|
m, fset, err := d.findSymbol(p.Path, p.Name, p.Kind, p.Parent)
|
|
if err != nil {
|
|
return diff.Change{}, err
|
|
}
|
|
|
|
src, err := os.ReadFile(p.Path)
|
|
if err != nil {
|
|
return diff.Change{}, fmt.Errorf("read %s: %w", p.Path, err)
|
|
}
|
|
original := string(src)
|
|
|
|
newContent := deleteLines(original, fset, m)
|
|
return diff.Build(p.Path, original, newContent, diff.Modify), nil
|
|
}
|
|
|
|
func (d deleteSymbol) findSymbol(path, name, kind, parent string) (symbolMatch, *token.FileSet, error) {
|
|
fset := token.NewFileSet()
|
|
f, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
|
|
if err != nil {
|
|
return symbolMatch{}, nil, fmt.Errorf("parse %s: %w", path, err)
|
|
}
|
|
|
|
matches := collectSymbols(fset, f)
|
|
|
|
var byName []symbolMatch
|
|
for _, m := range matches {
|
|
if m.name == name {
|
|
byName = append(byName, m)
|
|
}
|
|
}
|
|
if len(byName) == 0 {
|
|
return symbolMatch{}, nil, fmt.Errorf("symbol %q not found in %s", name, path)
|
|
}
|
|
|
|
filtered := byName
|
|
if kind != "" {
|
|
var byKind []symbolMatch
|
|
for _, m := range filtered {
|
|
if m.kind == kind {
|
|
byKind = append(byKind, m)
|
|
}
|
|
}
|
|
if len(byKind) == 0 {
|
|
return symbolMatch{}, nil, fmt.Errorf("symbol %q with kind %q not found", name, kind)
|
|
}
|
|
filtered = byKind
|
|
}
|
|
if parent != "" {
|
|
var byParent []symbolMatch
|
|
for _, m := range filtered {
|
|
if m.parent == parent {
|
|
byParent = append(byParent, m)
|
|
}
|
|
}
|
|
if len(byParent) == 0 {
|
|
return symbolMatch{}, nil, fmt.Errorf("symbol %q (kind=%q parent=%q) not found", name, kind, parent)
|
|
}
|
|
filtered = byParent
|
|
}
|
|
|
|
if len(filtered) > 1 {
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "Multiple matches for %q — disambiguate with kind/parent:\n", name)
|
|
for _, m := range filtered {
|
|
fmt.Fprintf(&b, " line %d: %s %s", m.line, m.kind, m.name)
|
|
if m.parent != "" {
|
|
fmt.Fprintf(&b, " (on %s)", m.parent)
|
|
}
|
|
b.WriteString("\n")
|
|
}
|
|
return symbolMatch{}, nil, fmt.Errorf("%s", b.String())
|
|
}
|
|
|
|
if len(filtered[0].siblings) > 1 {
|
|
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, ", "))
|
|
}
|
|
|
|
return filtered[0], fset, nil
|
|
}
|
|
|
|
func collectSymbols(fset *token.FileSet, f *ast.File) []symbolMatch {
|
|
var matches []symbolMatch
|
|
for _, decl := range f.Decls {
|
|
switch d := decl.(type) {
|
|
case *ast.FuncDecl:
|
|
m := symbolMatch{
|
|
name: d.Name.Name,
|
|
kind: "func",
|
|
start: d.Pos(),
|
|
end: d.End(),
|
|
line: fset.Position(d.Pos()).Line,
|
|
}
|
|
if d.Doc != nil {
|
|
m.docStart = d.Doc.Pos()
|
|
}
|
|
if d.Recv != nil && len(d.Recv.List) > 0 {
|
|
m.kind = "method"
|
|
recvType := d.Recv.List[0].Type
|
|
if se, ok := recvType.(*ast.StarExpr); ok {
|
|
if ident, ok := se.X.(*ast.Ident); ok {
|
|
m.parent = ident.Name
|
|
}
|
|
} else if ident, ok := recvType.(*ast.Ident); ok {
|
|
m.parent = ident.Name
|
|
}
|
|
}
|
|
matches = append(matches, m)
|
|
case *ast.GenDecl:
|
|
for _, spec := range d.Specs {
|
|
switch s := spec.(type) {
|
|
case *ast.TypeSpec:
|
|
m := symbolMatch{
|
|
name: s.Name.Name,
|
|
start: s.Pos(),
|
|
end: s.End(),
|
|
line: fset.Position(s.Pos()).Line,
|
|
}
|
|
if _, ok := s.Type.(*ast.InterfaceType); ok {
|
|
m.kind = "interface"
|
|
} else {
|
|
m.kind = "type"
|
|
}
|
|
if doc := specDoc(d, s.Doc); doc != nil {
|
|
m.docStart = doc.Pos()
|
|
}
|
|
matches = append(matches, m)
|
|
case *ast.ValueSpec:
|
|
kind := "var"
|
|
if d.Tok == token.CONST {
|
|
kind = "const"
|
|
}
|
|
names := make([]string, 0, len(s.Names))
|
|
for _, ident := range s.Names {
|
|
names = append(names, ident.Name)
|
|
}
|
|
var docStart token.Pos
|
|
if doc := specDoc(d, s.Doc); doc != nil {
|
|
docStart = doc.Pos()
|
|
}
|
|
for _, ident := range s.Names {
|
|
matches = append(matches, symbolMatch{
|
|
name: ident.Name,
|
|
kind: kind,
|
|
start: ident.Pos(),
|
|
docStart: docStart,
|
|
end: s.End(), // whole spec, incl. a multi-line value — ident.End() stops at the name
|
|
line: fset.Position(ident.Pos()).Line,
|
|
siblings: names,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return matches
|
|
}
|
|
|
|
// specDoc returns the doc comment governing one spec of a GenDecl: the spec's own
|
|
// doc when grouped (type/const/var (...)), else the GenDecl's doc for an
|
|
// unparenthesized single declaration — where the parser attaches the comment to
|
|
// the GenDecl, not the spec. nil for an undocumented spec, and never the group's
|
|
// own doc when deleting just one spec of a parenthesized block.
|
|
func specDoc(gen *ast.GenDecl, own *ast.CommentGroup) *ast.CommentGroup {
|
|
if own != nil {
|
|
return own
|
|
}
|
|
if gen.Lparen == token.NoPos {
|
|
return gen.Doc
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func deleteLines(content string, fset *token.FileSet, m symbolMatch) string {
|
|
start := m.start
|
|
if m.docStart.IsValid() {
|
|
start = m.docStart // delete the doc comment along with the symbol, not orphan it
|
|
}
|
|
startOff := fset.Position(start).Offset
|
|
endOff := fset.Position(m.end).Offset
|
|
|
|
lineStart := startOff
|
|
for lineStart > 0 && content[lineStart-1] != '\n' {
|
|
lineStart--
|
|
}
|
|
|
|
lineEnd := endOff
|
|
for lineEnd < len(content) && content[lineEnd] != '\n' {
|
|
lineEnd++
|
|
}
|
|
if lineEnd < len(content) {
|
|
lineEnd++
|
|
}
|
|
|
|
return content[:lineStart] + content[lineEnd:]
|
|
}
|