a06f331eb8
install-script / powershell-syntax (push) Waiting to run
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
install-script / install (macos-14) (push) Waiting to run
install-script / install (ubuntu-latest) (push) Waiting to run
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
1779 lines
54 KiB
Go
1779 lines
54 KiB
Go
package mcp
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"go/ast"
|
|
"go/format"
|
|
"go/parser"
|
|
"go/token"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
"golang.org/x/tools/go/ast/astutil"
|
|
|
|
"github.com/zzet/gortex/internal/agents"
|
|
"github.com/zzet/gortex/internal/graph"
|
|
)
|
|
|
|
// registerMoveInlineTools registers the move_symbol and inline_symbol
|
|
// MCP tools. They build on the same resolution and atomic-write
|
|
// primitives as edit_file / edit_symbol / rename_symbol; the Go-AST
|
|
// work lives in this file so the rename/edit pipeline stays focused on
|
|
// language-agnostic edits.
|
|
func (s *Server) registerMoveInlineTools() {
|
|
s.addTool(
|
|
mcp.NewTool("move_symbol",
|
|
mcp.WithDescription("Relocate a Go function, type, method, variable, or const to another file. Same-package moves leave callers untouched; cross-package moves rewrite every qualified reference (`pkga.Foo` becomes `pkgb.Foo`), strip the import in the new home, and add the new import in the old home. Returns the touched files plus a per-file before/after summary. Non-Go symbols are refused with an explicit unsupported-language error."),
|
|
mcp.WithString("id", mcp.Required(), mcp.Description("Symbol ID to move (e.g. pkga/a.go::Foo)")),
|
|
mcp.WithString("target_file", mcp.Required(), mcp.Description("Destination file path (relative repo-prefixed or absolute). The tool detects same-package vs cross-package by comparing the parent directories of the source and target files.")),
|
|
mcp.WithString("target_package", mcp.Description("Optional package-name override. When the target file does not yet exist and the target directory has no other Go files, this names the new package. Defaults to the existing target-dir package, then to the directory leaf name.")),
|
|
mcp.WithBoolean("dry_run", mcp.Description("Preview the change set without writing anything (default: false)")),
|
|
),
|
|
s.handleMoveSymbol,
|
|
)
|
|
|
|
s.addTool(
|
|
mcp.NewTool("inline_symbol",
|
|
mcp.WithDescription("Replace every callsite of a trivial Go function with the callee's body. Supports single-statement / single-expression functions with at most one return value. Refuses (without partial inlines) when the callee contains a defer / go / closure, has multiple return values, or when any callsite passes a side-effecting argument (a call expression). With delete_after=true the callee definition is removed once every callsite has been rewritten. Non-Go callees are refused with an explicit unsupported-language error."),
|
|
mcp.WithString("id", mcp.Required(), mcp.Description("Function symbol ID to inline (e.g. pkg/foo.go::Get)")),
|
|
mcp.WithBoolean("delete_after", mcp.Description("Remove the callee from disk after every callsite has been rewritten (default: true). Set false to keep the original function.")),
|
|
mcp.WithBoolean("dry_run", mcp.Description("Preview the change set without writing anything (default: false)")),
|
|
),
|
|
s.handleInlineSymbol,
|
|
)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// move_symbol
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// moveTouchedFile records what changed at a single file during a move.
|
|
type moveTouchedFile struct {
|
|
Path string `json:"path"`
|
|
Role string `json:"role"` // source, target, caller, target_caller
|
|
BytesBefore int `json:"bytes_before"`
|
|
BytesAfter int `json:"bytes_after"`
|
|
LinesBefore int `json:"lines_before"`
|
|
LinesAfter int `json:"lines_after"`
|
|
References int `json:"references_rewritten,omitempty"`
|
|
ImportAdded bool `json:"import_added,omitempty"`
|
|
ImportRemove bool `json:"import_removed,omitempty"`
|
|
Created bool `json:"created,omitempty"`
|
|
}
|
|
|
|
func (s *Server) handleMoveSymbol(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
id, err := s.symbolIDArg(ctx, req)
|
|
if err != nil {
|
|
return mcp.NewToolResultError("id is required"), nil
|
|
}
|
|
targetFileArg, err := req.RequireString("target_file")
|
|
if err != nil {
|
|
return mcp.NewToolResultError("target_file is required"), nil
|
|
}
|
|
targetPackageOverride := strings.TrimSpace(req.GetString("target_package", ""))
|
|
dryRun := req.GetBool("dry_run", false)
|
|
|
|
node := s.engineFor(ctx).GetSymbol(id)
|
|
if node == nil {
|
|
return mcp.NewToolResultError("symbol not found: " + id), nil
|
|
}
|
|
if !isGoSourcePath(node.FilePath) {
|
|
return mcp.NewToolResultError(fmt.Sprintf("unsupported language: move_symbol only supports Go files (got %q)", node.FilePath)), nil
|
|
}
|
|
if node.Name == "" {
|
|
return mcp.NewToolResultError("symbol has no name: " + id), nil
|
|
}
|
|
if node.Kind != graph.KindFunction && node.Kind != graph.KindMethod &&
|
|
node.Kind != graph.KindType && node.Kind != graph.KindInterface &&
|
|
node.Kind != graph.KindVariable && node.Kind != graph.KindConstant {
|
|
return mcp.NewToolResultError(fmt.Sprintf("unsupported symbol kind for move: %s", node.Kind)), nil
|
|
}
|
|
|
|
srcAbs, err := s.resolveNodePath(node)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
tgtAbs, tgtRel, err := s.resolveFilePath(targetFileArg)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
srcAbs = filepath.Clean(srcAbs)
|
|
tgtAbs = filepath.Clean(tgtAbs)
|
|
if srcAbs == tgtAbs {
|
|
return mcp.NewToolResultError("target_file is the same as the source file"), nil
|
|
}
|
|
if !isGoSourcePath(tgtAbs) {
|
|
return mcp.NewToolResultError(fmt.Sprintf("unsupported language: target_file must be a .go file (got %q)", targetFileArg)), nil
|
|
}
|
|
|
|
srcDir := filepath.Dir(srcAbs)
|
|
tgtDir := filepath.Dir(tgtAbs)
|
|
samePackage := srcDir == tgtDir
|
|
|
|
srcContent, err := os.ReadFile(srcAbs)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(fmt.Sprintf("could not read source file: %v", err)), nil
|
|
}
|
|
|
|
// Parse source file with comments so we keep doc-comments intact.
|
|
fset := token.NewFileSet()
|
|
srcFile, err := parser.ParseFile(fset, srcAbs, srcContent, parser.ParseComments)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(fmt.Sprintf("could not parse source file: %v", err)), nil
|
|
}
|
|
|
|
srcPackage := srcFile.Name.Name
|
|
|
|
// Locate the declaration we're moving inside the source AST.
|
|
moveDecl, _, moveSnippet, err := extractGoTopLevelDecl(fset, srcFile, srcContent, node.Name)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
// Determine target package name.
|
|
tgtContent, tgtExists, err := readGoFileIfExists(tgtAbs)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
targetPackage := ""
|
|
if tgtExists {
|
|
tgtFileTmp, perr := parser.ParseFile(fset, tgtAbs, tgtContent, parser.ParseComments)
|
|
if perr != nil {
|
|
return mcp.NewToolResultError(fmt.Sprintf("could not parse target file: %v", perr)), nil
|
|
}
|
|
targetPackage = tgtFileTmp.Name.Name
|
|
} else {
|
|
targetPackage = inferGoPackageForDir(tgtDir, targetPackageOverride)
|
|
if targetPackage == "" {
|
|
return mcp.NewToolResultError("could not determine target package; pass target_package"), nil
|
|
}
|
|
}
|
|
|
|
// Resolve module-level import paths for both packages so we can rewrite
|
|
// qualified references on cross-package moves.
|
|
srcImportPath, err := resolveGoImportPathForFile(srcAbs)
|
|
if err != nil && !samePackage {
|
|
return mcp.NewToolResultError(fmt.Sprintf("could not resolve import path of source package: %v", err)), nil
|
|
}
|
|
tgtImportPath, err := resolveGoImportPathForFile(tgtAbs)
|
|
if err != nil && !samePackage {
|
|
return mcp.NewToolResultError(fmt.Sprintf("could not resolve import path of target package: %v", err)), nil
|
|
}
|
|
|
|
if samePackage {
|
|
srcImportPath = ""
|
|
tgtImportPath = ""
|
|
}
|
|
|
|
// Remove the declaration from the source file content (byte splice).
|
|
newSrcContent, err := spliceOutGoDecl(srcContent, fset, moveDecl)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
// On cross-package moves the source file may now need the target import
|
|
// (if it still calls the moved symbol's siblings — handled separately
|
|
// for callers below).
|
|
if !samePackage {
|
|
// Determine if the source package still references the moved symbol
|
|
// elsewhere; if so, the source file needs to import the target
|
|
// package and rewrite bare references.
|
|
newSrcContent, _, err = rewriteCallerFileContent(newSrcContent, srcAbs, callerRewriteOpts{
|
|
Name: node.Name,
|
|
Mode: callerSourcePkgRewrite,
|
|
TargetImportPath: tgtImportPath,
|
|
TargetPackageName: targetPackage,
|
|
SourcePackageName: srcPackage,
|
|
})
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
}
|
|
|
|
// Build new target content: append the moved snippet (with leading blank
|
|
// line) to the existing target file, or synthesise a new file with a
|
|
// package declaration.
|
|
newTgtContent, targetCreated, err := buildTargetContent(tgtContent, tgtExists, targetPackage, moveSnippet)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
// Cross-package moves: strip the source import from the target file if
|
|
// the moved symbol's body referenced it bare (the move turns the
|
|
// reference into a same-package call after relocation).
|
|
if !samePackage {
|
|
newTgtContent, _, err = rewriteCallerFileContent(newTgtContent, tgtAbs, callerRewriteOpts{
|
|
Name: node.Name,
|
|
Mode: callerTargetPkgRewrite,
|
|
SourceImportPath: srcImportPath,
|
|
SourcePackageName: srcPackage,
|
|
})
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
}
|
|
|
|
// Locate caller files. Prefer graph edges (FindUsages) for prune
|
|
// candidates; fall back to walking the module tree so we catch
|
|
// callers in test fixtures where the graph hasn't yet resolved
|
|
// cross-package edges.
|
|
var (
|
|
callerEdits []moveCallerEdit
|
|
totalRewritten int
|
|
)
|
|
callerFiles := map[string]struct{}{}
|
|
usages := s.engineFor(ctx).FindUsages(id)
|
|
for _, edge := range usages.Edges {
|
|
fromNode := nodeFromUsages(usages.Nodes, edge.From)
|
|
if fromNode == nil {
|
|
continue
|
|
}
|
|
if !isGoSourcePath(fromNode.FilePath) {
|
|
continue
|
|
}
|
|
abs, rerr := s.resolveNodePath(fromNode)
|
|
if rerr != nil {
|
|
continue
|
|
}
|
|
if abs == srcAbs || abs == tgtAbs {
|
|
continue
|
|
}
|
|
callerFiles[abs] = struct{}{}
|
|
}
|
|
// Fallback / completion: scan the module tree so callers without a
|
|
// resolved graph edge are still rewritten.
|
|
modRoot := findGoModRoot(srcAbs)
|
|
if modRoot != "" {
|
|
_ = filepath.Walk(modRoot, func(p string, info os.FileInfo, werr error) error {
|
|
if werr != nil {
|
|
return nil
|
|
}
|
|
if info.IsDir() {
|
|
base := info.Name()
|
|
if base == ".git" || base == "vendor" || base == "node_modules" {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
if !isGoSourcePath(p) {
|
|
return nil
|
|
}
|
|
abs := filepath.Clean(p)
|
|
if abs == srcAbs || abs == tgtAbs {
|
|
return nil
|
|
}
|
|
callerFiles[abs] = struct{}{}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
for absPath := range callerFiles {
|
|
content, rerr := os.ReadFile(absPath)
|
|
if rerr != nil {
|
|
return mcp.NewToolResultError(fmt.Sprintf("could not read caller file %s: %v", absPath, rerr)), nil
|
|
}
|
|
// Decide rewrite mode based on whether this caller lives in the
|
|
// source package, the target package, or a third package.
|
|
callerDir := filepath.Dir(absPath)
|
|
mode := callerThirdPartyRewrite
|
|
switch callerDir {
|
|
case srcDir:
|
|
mode = callerSourcePkgRewrite
|
|
case tgtDir:
|
|
mode = callerTargetPkgRewrite
|
|
}
|
|
newContent, info, rerr := rewriteCallerFileContent(content, absPath, callerRewriteOpts{
|
|
Name: node.Name,
|
|
Mode: mode,
|
|
SourceImportPath: srcImportPath,
|
|
TargetImportPath: tgtImportPath,
|
|
SourcePackageName: srcPackage,
|
|
TargetPackageName: targetPackage,
|
|
})
|
|
if rerr != nil {
|
|
return mcp.NewToolResultError(fmt.Sprintf("could not rewrite caller %s: %v", absPath, rerr)), nil
|
|
}
|
|
if info.references == 0 && !info.importChanged() {
|
|
continue
|
|
}
|
|
callerEdits = append(callerEdits, moveCallerEdit{
|
|
absPath: absPath,
|
|
origContent: content,
|
|
newContent: newContent,
|
|
rewriteResult: info,
|
|
role: roleForMode(mode),
|
|
})
|
|
totalRewritten += info.references
|
|
}
|
|
|
|
touched := []moveTouchedFile{}
|
|
srcRel := s.repoRelative(srcAbs)
|
|
tgtRelOut := tgtRel
|
|
if filepath.IsAbs(tgtRelOut) {
|
|
tgtRelOut = s.repoRelative(tgtAbs)
|
|
}
|
|
|
|
touched = append(touched, moveTouchedFile{
|
|
Path: srcRel,
|
|
Role: "source",
|
|
BytesBefore: len(srcContent),
|
|
BytesAfter: len(newSrcContent),
|
|
LinesBefore: countLines(srcContent),
|
|
LinesAfter: countLines(newSrcContent),
|
|
})
|
|
touched = append(touched, moveTouchedFile{
|
|
Path: tgtRelOut,
|
|
Role: "target",
|
|
BytesBefore: len(tgtContent),
|
|
BytesAfter: len(newTgtContent),
|
|
LinesBefore: countLines(tgtContent),
|
|
LinesAfter: countLines(newTgtContent),
|
|
Created: targetCreated,
|
|
})
|
|
for _, ce := range callerEdits {
|
|
rel := s.repoRelative(ce.absPath)
|
|
touched = append(touched, moveTouchedFile{
|
|
Path: rel,
|
|
Role: ce.role,
|
|
BytesBefore: len(ce.origContent),
|
|
BytesAfter: len(ce.newContent),
|
|
LinesBefore: countLines(ce.origContent),
|
|
LinesAfter: countLines(ce.newContent),
|
|
References: ce.rewriteResult.references,
|
|
ImportAdded: ce.rewriteResult.importAdded,
|
|
ImportRemove: ce.rewriteResult.importRemoved,
|
|
})
|
|
}
|
|
|
|
sort.SliceStable(touched, func(i, j int) bool {
|
|
if touched[i].Role != touched[j].Role {
|
|
return moveRoleRank(touched[i].Role) < moveRoleRank(touched[j].Role)
|
|
}
|
|
return touched[i].Path < touched[j].Path
|
|
})
|
|
|
|
resp := map[string]any{
|
|
"source_id": id,
|
|
"target_id": buildMovedSymbolID(s, tgtAbs, node.Name),
|
|
"target_package": targetPackage,
|
|
"source_package": srcPackage,
|
|
"same_package": samePackage,
|
|
"references_rewritten": totalRewritten,
|
|
"touched": touched,
|
|
"files_touched": len(touched),
|
|
"dry_run": dryRun,
|
|
}
|
|
if dryRun {
|
|
return s.respondJSONOrTOON(ctx, req, resp)
|
|
}
|
|
|
|
// Apply writes atomically. Order: target first (so file exists for any
|
|
// caller that triggers a follow-up open), then source, then callers.
|
|
if err := writeMoveFile(tgtAbs, newTgtContent); err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
if err := writeMoveFile(srcAbs, newSrcContent); err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
for _, ce := range callerEdits {
|
|
if err := writeMoveFile(ce.absPath, ce.newContent); err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
}
|
|
|
|
// Re-index every touched file so the graph picks up the new location.
|
|
sess := s.sessionFor(ctx)
|
|
for _, t := range touched {
|
|
sess.recordModified(t.Path)
|
|
}
|
|
_ = s.reindexFile(srcAbs)
|
|
_ = s.reindexFile(tgtAbs)
|
|
for _, ce := range callerEdits {
|
|
_ = s.reindexFile(ce.absPath)
|
|
}
|
|
|
|
return s.respondJSONOrTOON(ctx, req, resp)
|
|
}
|
|
|
|
type moveCallerEdit struct {
|
|
absPath string
|
|
origContent []byte
|
|
newContent []byte
|
|
rewriteResult callerRewriteResult
|
|
role string
|
|
}
|
|
|
|
func roleForMode(m callerRewriteMode) string {
|
|
switch m {
|
|
case callerSourcePkgRewrite:
|
|
return "caller_source_pkg"
|
|
case callerTargetPkgRewrite:
|
|
return "caller_target_pkg"
|
|
default:
|
|
return "caller"
|
|
}
|
|
}
|
|
|
|
func moveRoleRank(role string) int {
|
|
switch role {
|
|
case "source":
|
|
return 0
|
|
case "target":
|
|
return 1
|
|
case "caller_source_pkg":
|
|
return 2
|
|
case "caller_target_pkg":
|
|
return 3
|
|
default:
|
|
return 4
|
|
}
|
|
}
|
|
|
|
func buildMovedSymbolID(s *Server, tgtAbs, name string) string {
|
|
rel := s.repoRelative(tgtAbs)
|
|
if rel == "" {
|
|
rel = tgtAbs
|
|
}
|
|
return rel + "::" + name
|
|
}
|
|
|
|
func writeMoveFile(absPath string, content []byte) error {
|
|
perm := os.FileMode(0o644)
|
|
if info, err := os.Stat(absPath); err == nil {
|
|
perm = info.Mode().Perm()
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil {
|
|
return fmt.Errorf("could not create parent dir for %s: %w", absPath, err)
|
|
}
|
|
if err := agents.AtomicWriteFile(absPath, content, perm); err != nil {
|
|
return fmt.Errorf("could not write %s: %w", absPath, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// extractGoTopLevelDecl finds the top-level declaration named `name` inside
|
|
// the parsed file and returns the decl plus its rendered byte snippet
|
|
// (including any leading doc-comment lines and the trailing newline).
|
|
// The second result is the index of the decl in file.Decls — retained
|
|
// for callers that want to relate the decl to its slice position.
|
|
func extractGoTopLevelDecl(fset *token.FileSet, file *ast.File, src []byte, name string) (ast.Decl, int, string, error) {
|
|
for i, decl := range file.Decls {
|
|
switch d := decl.(type) {
|
|
case *ast.FuncDecl:
|
|
if d.Name == nil {
|
|
continue
|
|
}
|
|
if d.Name.Name != name {
|
|
continue
|
|
}
|
|
snippet, err := renderDeclSnippet(fset, src, d, d.Doc)
|
|
if err != nil {
|
|
return nil, -1, "", err
|
|
}
|
|
return d, i, snippet, nil
|
|
case *ast.GenDecl:
|
|
// Type / Var / Const blocks. We only support the case where the
|
|
// matched spec is the sole spec in the block — multi-spec
|
|
// declarations (var ( a int; b int )) get treated as a whole
|
|
// block and we refuse the move (callers should split first).
|
|
matchedSpecIdx := -1
|
|
matchedName := ""
|
|
for si, spec := range d.Specs {
|
|
switch sp := spec.(type) {
|
|
case *ast.TypeSpec:
|
|
if sp.Name != nil && sp.Name.Name == name {
|
|
matchedSpecIdx = si
|
|
matchedName = name
|
|
}
|
|
case *ast.ValueSpec:
|
|
for _, ident := range sp.Names {
|
|
if ident.Name == name {
|
|
matchedSpecIdx = si
|
|
matchedName = name
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if matchedSpecIdx >= 0 {
|
|
break
|
|
}
|
|
}
|
|
if matchedSpecIdx < 0 {
|
|
continue
|
|
}
|
|
if len(d.Specs) > 1 {
|
|
return nil, -1, "", fmt.Errorf("symbol %q is declared inside a multi-spec %s block; split it into its own declaration before moving", matchedName, d.Tok.String())
|
|
}
|
|
snippet, err := renderDeclSnippet(fset, src, d, d.Doc)
|
|
if err != nil {
|
|
return nil, -1, "", err
|
|
}
|
|
return d, i, snippet, nil
|
|
}
|
|
}
|
|
return nil, -1, "", fmt.Errorf("top-level declaration %q not found in source file", name)
|
|
}
|
|
|
|
// renderDeclSnippet emits the byte range covering an optional doc-comment
|
|
// block and the declaration itself, normalised so it can be appended to
|
|
// another file as a self-contained block.
|
|
func renderDeclSnippet(fset *token.FileSet, src []byte, decl ast.Decl, doc *ast.CommentGroup) (string, error) {
|
|
startPos := decl.Pos()
|
|
if doc != nil {
|
|
startPos = doc.Pos()
|
|
}
|
|
endPos := decl.End()
|
|
start := fset.Position(startPos).Offset
|
|
end := fset.Position(endPos).Offset
|
|
if start < 0 || end > len(src) || start > end {
|
|
return "", fmt.Errorf("invalid declaration byte range")
|
|
}
|
|
// Capture trailing newline if present so appended decls are visually
|
|
// separated.
|
|
if end < len(src) && src[end] == '\n' {
|
|
end++
|
|
}
|
|
snippet := string(src[start:end])
|
|
// Trim trailing whitespace, then add a single trailing newline.
|
|
snippet = strings.TrimRight(snippet, " \t\r\n") + "\n"
|
|
return snippet, nil
|
|
}
|
|
|
|
// spliceOutGoDecl removes the byte range corresponding to a top-level
|
|
// declaration (including its leading doc-comment block) from the source.
|
|
// Adjacent blank lines around the splice are collapsed so the file stays
|
|
// gofmt-friendly.
|
|
func spliceOutGoDecl(src []byte, fset *token.FileSet, decl ast.Decl) ([]byte, error) {
|
|
var doc *ast.CommentGroup
|
|
switch d := decl.(type) {
|
|
case *ast.FuncDecl:
|
|
doc = d.Doc
|
|
case *ast.GenDecl:
|
|
doc = d.Doc
|
|
}
|
|
startPos := decl.Pos()
|
|
if doc != nil {
|
|
startPos = doc.Pos()
|
|
}
|
|
endPos := decl.End()
|
|
start := fset.Position(startPos).Offset
|
|
end := fset.Position(endPos).Offset
|
|
if start < 0 || end > len(src) || start > end {
|
|
return nil, fmt.Errorf("invalid declaration byte range during splice")
|
|
}
|
|
// Expand start backwards over the blank line right before the decl
|
|
// (newline + optional whitespace). At most one extra newline so we
|
|
// don't eat unrelated content.
|
|
for start > 0 && (src[start-1] == ' ' || src[start-1] == '\t') {
|
|
start--
|
|
}
|
|
// Expand end forwards over the trailing newline + one optional blank
|
|
// line, so the splice doesn't leave a double blank.
|
|
if end < len(src) && src[end] == '\n' {
|
|
end++
|
|
}
|
|
if end < len(src) && src[end] == '\n' {
|
|
end++
|
|
}
|
|
// Pull start back one more newline if we now sit on a blank line so
|
|
// the file doesn't grow blank lines on every move.
|
|
if start > 0 && src[start-1] == '\n' && end < len(src) {
|
|
start--
|
|
}
|
|
out := make([]byte, 0, len(src)-(end-start))
|
|
out = append(out, src[:start]...)
|
|
out = append(out, src[end:]...)
|
|
// Run gofmt to tidy up any whitespace artefacts.
|
|
formatted, err := format.Source(out)
|
|
if err != nil {
|
|
// Tolerate format errors — the splice may have left content that
|
|
// the parser still accepts but format chokes on (rare). Return
|
|
// the unformatted bytes so the caller can still write them.
|
|
return out, nil //nolint:nilerr // explicit fallback
|
|
}
|
|
return formatted, nil
|
|
}
|
|
|
|
// readGoFileIfExists returns the file's content if it exists and an empty
|
|
// slice with exists=false otherwise. Errors other than os.IsNotExist are
|
|
// surfaced.
|
|
func readGoFileIfExists(absPath string) ([]byte, bool, error) {
|
|
content, err := os.ReadFile(absPath)
|
|
if err == nil {
|
|
return content, true, nil
|
|
}
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil, false, nil
|
|
}
|
|
return nil, false, fmt.Errorf("could not read target file: %w", err)
|
|
}
|
|
|
|
// inferGoPackageForDir picks a package name for a new Go file in `dir`.
|
|
// Priority: explicit override, package of an existing .go sibling, then
|
|
// the directory's leaf name.
|
|
func inferGoPackageForDir(dir, override string) string {
|
|
if override != "" {
|
|
return override
|
|
}
|
|
entries, err := os.ReadDir(dir)
|
|
if err == nil {
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
continue
|
|
}
|
|
if !strings.HasSuffix(e.Name(), ".go") || strings.HasSuffix(e.Name(), "_test.go") {
|
|
continue
|
|
}
|
|
content, rerr := os.ReadFile(filepath.Join(dir, e.Name()))
|
|
if rerr != nil {
|
|
continue
|
|
}
|
|
fset := token.NewFileSet()
|
|
f, perr := parser.ParseFile(fset, "", content, parser.PackageClauseOnly)
|
|
if perr == nil && f.Name != nil {
|
|
return f.Name.Name
|
|
}
|
|
}
|
|
}
|
|
leaf := filepath.Base(dir)
|
|
leaf = strings.ReplaceAll(leaf, "-", "_")
|
|
if leaf == "." || leaf == "/" || leaf == "" {
|
|
return ""
|
|
}
|
|
return leaf
|
|
}
|
|
|
|
// resolveGoImportPathForFile walks up from the file's directory looking for a
|
|
// go.mod file. Returns "<module>/<rel-dir-to-mod>" — the standard Go import
|
|
// path for the file's package.
|
|
func resolveGoImportPathForFile(absPath string) (string, error) {
|
|
dir := filepath.Dir(absPath)
|
|
cur := dir
|
|
for {
|
|
modPath := filepath.Join(cur, "go.mod")
|
|
if data, err := os.ReadFile(modPath); err == nil {
|
|
mod := parseGoModModule(data)
|
|
if mod == "" {
|
|
return "", fmt.Errorf("could not parse module path from %s", modPath)
|
|
}
|
|
rel, err := filepath.Rel(cur, dir)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if rel == "." {
|
|
return mod, nil
|
|
}
|
|
return mod + "/" + filepath.ToSlash(rel), nil
|
|
}
|
|
parent := filepath.Dir(cur)
|
|
if parent == cur {
|
|
return "", fmt.Errorf("no go.mod found above %s", absPath)
|
|
}
|
|
cur = parent
|
|
}
|
|
}
|
|
|
|
// findGoModRoot walks upward from a file path looking for a go.mod and
|
|
// returns the directory containing it, or "" when none is found.
|
|
func findGoModRoot(start string) string {
|
|
cur := filepath.Dir(start)
|
|
for {
|
|
if _, err := os.Stat(filepath.Join(cur, "go.mod")); err == nil {
|
|
return cur
|
|
}
|
|
parent := filepath.Dir(cur)
|
|
if parent == cur {
|
|
return ""
|
|
}
|
|
cur = parent
|
|
}
|
|
}
|
|
|
|
// parseGoModModule extracts the module path from go.mod content. The
|
|
// parser is intentionally minimal — modfile would pull in another
|
|
// dependency edge into the mcp package and we only need the first
|
|
// `module <path>` line.
|
|
func parseGoModModule(data []byte) string {
|
|
for _, line := range strings.Split(string(data), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if strings.HasPrefix(line, "module ") {
|
|
rest := strings.TrimSpace(strings.TrimPrefix(line, "module"))
|
|
rest = strings.TrimSuffix(rest, "//")
|
|
rest = strings.TrimSpace(rest)
|
|
rest = strings.Trim(rest, "\"")
|
|
return rest
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// buildTargetContent appends the moved snippet to the target file's content,
|
|
// or synthesises a new file (with `package <pkg>`) when the target doesn't
|
|
// yet exist.
|
|
func buildTargetContent(existing []byte, exists bool, packageName, snippet string) ([]byte, bool, error) {
|
|
if !exists {
|
|
var b strings.Builder
|
|
b.WriteString("package ")
|
|
b.WriteString(packageName)
|
|
b.WriteString("\n\n")
|
|
b.WriteString(snippet)
|
|
out, err := format.Source([]byte(b.String()))
|
|
if err != nil {
|
|
return []byte(b.String()), true, nil //nolint:nilerr
|
|
}
|
|
return out, true, nil
|
|
}
|
|
// Append with a separating blank line.
|
|
out := bytes.TrimRight(existing, "\n")
|
|
var b bytes.Buffer
|
|
b.Write(out)
|
|
b.WriteString("\n\n")
|
|
b.WriteString(snippet)
|
|
formatted, err := format.Source(b.Bytes())
|
|
if err != nil {
|
|
return b.Bytes(), false, nil //nolint:nilerr
|
|
}
|
|
return formatted, false, nil
|
|
}
|
|
|
|
// callerRewriteMode tells rewriteCallerFileContent which sense to apply.
|
|
type callerRewriteMode int
|
|
|
|
const (
|
|
// callerThirdPartyRewrite: caller lives outside both packages. It
|
|
// imported the source package and called <srcpkg>.<Name>; after the
|
|
// move it should import the target package and call <tgtpkg>.<Name>.
|
|
callerThirdPartyRewrite callerRewriteMode = iota
|
|
// callerSourcePkgRewrite: caller lives in the source package (a
|
|
// sibling of the moved decl). After the move the call must qualify
|
|
// with the target package, and the target import must be present.
|
|
callerSourcePkgRewrite
|
|
// callerTargetPkgRewrite: caller lives in the target package. After
|
|
// the move the call is bare and the source import is dropped if it
|
|
// has no other uses.
|
|
callerTargetPkgRewrite
|
|
)
|
|
|
|
type callerRewriteOpts struct {
|
|
Name string
|
|
Mode callerRewriteMode
|
|
SourceImportPath string
|
|
TargetImportPath string
|
|
SourcePackageName string
|
|
TargetPackageName string
|
|
}
|
|
|
|
type callerRewriteResult struct {
|
|
references int
|
|
importAdded bool
|
|
importRemoved bool
|
|
}
|
|
|
|
func (r callerRewriteResult) importChanged() bool { return r.importAdded || r.importRemoved }
|
|
|
|
// rewriteCallerFileContent rewrites a Go file in-place (content -> content)
|
|
// so qualified references to a symbol that just moved are updated. It also
|
|
// manages the file's import block.
|
|
func rewriteCallerFileContent(content []byte, absPath string, opts callerRewriteOpts) ([]byte, callerRewriteResult, error) {
|
|
if len(content) == 0 {
|
|
return content, callerRewriteResult{}, nil
|
|
}
|
|
fset := token.NewFileSet()
|
|
file, err := parser.ParseFile(fset, absPath, content, parser.ParseComments)
|
|
if err != nil {
|
|
return nil, callerRewriteResult{}, fmt.Errorf("could not parse %s: %w", absPath, err)
|
|
}
|
|
|
|
result := callerRewriteResult{}
|
|
switch opts.Mode {
|
|
case callerThirdPartyRewrite:
|
|
// Rewrite `<srcAlias>.<Name>` -> `<tgtAlias>.<Name>` where srcAlias /
|
|
// tgtAlias are the local names of those imports (PkgName.Name from
|
|
// the resolved ast.Ident.Obj.Decl — we look it up via the file's
|
|
// imports). Then drop the src import if it's now unreferenced, and
|
|
// ensure the tgt import exists.
|
|
srcAlias := findImportAlias(file, opts.SourceImportPath)
|
|
if srcAlias == "" {
|
|
return content, result, nil
|
|
}
|
|
tgtAlias := findImportAlias(file, opts.TargetImportPath)
|
|
if tgtAlias == "" {
|
|
tgtAlias = opts.TargetPackageName
|
|
}
|
|
ast.Inspect(file, func(n ast.Node) bool {
|
|
sel, ok := n.(*ast.SelectorExpr)
|
|
if !ok {
|
|
return true
|
|
}
|
|
x, ok := sel.X.(*ast.Ident)
|
|
if !ok {
|
|
return true
|
|
}
|
|
if x.Name != srcAlias || sel.Sel == nil || sel.Sel.Name != opts.Name {
|
|
return true
|
|
}
|
|
x.Name = tgtAlias
|
|
result.references++
|
|
return true
|
|
})
|
|
if result.references == 0 {
|
|
return content, result, nil
|
|
}
|
|
if !goFileStillUsesIdent(file, srcAlias) {
|
|
if astutil.DeleteImport(fset, file, opts.SourceImportPath) {
|
|
result.importRemoved = true
|
|
}
|
|
}
|
|
if !goFileImportsPath(file, opts.TargetImportPath) {
|
|
if astutil.AddImport(fset, file, opts.TargetImportPath) {
|
|
result.importAdded = true
|
|
}
|
|
}
|
|
case callerSourcePkgRewrite:
|
|
// Caller is in the source package. Bare references to `<Name>`
|
|
// must become `<tgtAlias>.<Name>`. We walk with astutil so we
|
|
// can replace the ast.Ident node with a SelectorExpr in its
|
|
// proper slot — mutating ident.Name to a dotted string would
|
|
// produce a malformed printer output.
|
|
tgtAlias := opts.TargetPackageName
|
|
// Skip the rewrite at decl-name positions: those name the
|
|
// declaration itself, not a usage.
|
|
declPositions := collectDeclNamePositions(file)
|
|
// Also skip when the ident is the right-hand side of a
|
|
// SelectorExpr (struct field access / method call).
|
|
count := 0
|
|
rewrittenRoot := astutil.Apply(file, func(c *astutil.Cursor) bool {
|
|
ident, ok := c.Node().(*ast.Ident)
|
|
if !ok {
|
|
return true
|
|
}
|
|
if ident.Name != opts.Name {
|
|
return true
|
|
}
|
|
if _, isDecl := declPositions[ident.Pos()]; isDecl {
|
|
return true
|
|
}
|
|
// Detect "x.Foo" — the parent is a SelectorExpr and we're
|
|
// in the Sel slot. Cursor.Parent() returns the enclosing
|
|
// node, Cursor.Name() tells us which field we are.
|
|
if sel, ok := c.Parent().(*ast.SelectorExpr); ok && sel.Sel == ident {
|
|
return true
|
|
}
|
|
// Skip when we're the Name field of an explicit declaration
|
|
// (defensive — declPositions should already cover this).
|
|
c.Replace(&ast.SelectorExpr{
|
|
X: &ast.Ident{Name: tgtAlias, NamePos: ident.Pos()},
|
|
Sel: &ast.Ident{Name: opts.Name, NamePos: ident.Pos()},
|
|
})
|
|
count++
|
|
return true
|
|
}, nil)
|
|
if newFile, ok := rewrittenRoot.(*ast.File); ok {
|
|
file = newFile
|
|
}
|
|
result.references = count
|
|
if count > 0 {
|
|
if !goFileImportsPath(file, opts.TargetImportPath) {
|
|
if astutil.AddImport(fset, file, opts.TargetImportPath) {
|
|
result.importAdded = true
|
|
}
|
|
}
|
|
}
|
|
case callerTargetPkgRewrite:
|
|
// Caller is in the target package. References look like
|
|
// `<srcAlias>.<Name>` and should become bare `<Name>`. Then
|
|
// drop the source import if unused.
|
|
srcAlias := findImportAlias(file, opts.SourceImportPath)
|
|
if srcAlias == "" {
|
|
return content, result, nil
|
|
}
|
|
count := 0
|
|
rewrittenRoot := astutil.Apply(file, func(c *astutil.Cursor) bool {
|
|
sel, ok := c.Node().(*ast.SelectorExpr)
|
|
if !ok {
|
|
return true
|
|
}
|
|
x, ok := sel.X.(*ast.Ident)
|
|
if !ok {
|
|
return true
|
|
}
|
|
if x.Name != srcAlias || sel.Sel == nil || sel.Sel.Name != opts.Name {
|
|
return true
|
|
}
|
|
c.Replace(&ast.Ident{Name: opts.Name, NamePos: sel.Pos()})
|
|
count++
|
|
return true
|
|
}, nil)
|
|
if newFile, ok := rewrittenRoot.(*ast.File); ok {
|
|
file = newFile
|
|
}
|
|
result.references = count
|
|
if count > 0 && !goFileStillUsesIdent(file, srcAlias) {
|
|
if astutil.DeleteImport(fset, file, opts.SourceImportPath) {
|
|
result.importRemoved = true
|
|
}
|
|
}
|
|
}
|
|
|
|
if result.references == 0 && !result.importChanged() {
|
|
return content, result, nil
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
if err := format.Node(&buf, fset, file); err != nil {
|
|
return nil, callerRewriteResult{}, fmt.Errorf("could not format rewritten %s: %w", absPath, err)
|
|
}
|
|
out, err := format.Source(buf.Bytes())
|
|
if err != nil {
|
|
out = buf.Bytes()
|
|
}
|
|
return out, result, nil
|
|
}
|
|
|
|
// findImportAlias returns the local name an import is referenced under in
|
|
// the file (the explicit alias if set, otherwise the package's declared
|
|
// name — which we can't easily resolve from path; default to the path's
|
|
// last segment).
|
|
func findImportAlias(file *ast.File, importPath string) string {
|
|
for _, imp := range file.Imports {
|
|
if unquoteImport(imp.Path.Value) != importPath {
|
|
continue
|
|
}
|
|
if imp.Name != nil && imp.Name.Name != "" {
|
|
return imp.Name.Name
|
|
}
|
|
// Default: the last path segment.
|
|
parts := strings.Split(importPath, "/")
|
|
return parts[len(parts)-1]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func goFileImportsPath(file *ast.File, importPath string) bool {
|
|
for _, imp := range file.Imports {
|
|
if unquoteImport(imp.Path.Value) == importPath {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func unquoteImport(raw string) string {
|
|
if len(raw) >= 2 && raw[0] == '"' && raw[len(raw)-1] == '"' {
|
|
return raw[1 : len(raw)-1]
|
|
}
|
|
return raw
|
|
}
|
|
|
|
// goFileStillUsesIdent returns true if any ast.Ident in the file (outside of
|
|
// import specs) bears the supplied name. Used to decide whether an import is
|
|
// safe to drop after a rename.
|
|
func goFileStillUsesIdent(file *ast.File, name string) bool {
|
|
found := false
|
|
ast.Inspect(file, func(n ast.Node) bool {
|
|
if found {
|
|
return false
|
|
}
|
|
if _, ok := n.(*ast.ImportSpec); ok {
|
|
return false // don't recurse into import specs
|
|
}
|
|
sel, ok := n.(*ast.SelectorExpr)
|
|
if !ok {
|
|
return true
|
|
}
|
|
if x, ok := sel.X.(*ast.Ident); ok && x.Name == name {
|
|
found = true
|
|
return false
|
|
}
|
|
return true
|
|
})
|
|
return found
|
|
}
|
|
|
|
// collectDeclNamePositions records the token positions of every
|
|
// top-level declaration name in the file. Used by the caller-rewrite
|
|
// to skip declarations themselves so `func Foo() {}` doesn't get its
|
|
// own name rewritten.
|
|
func collectDeclNamePositions(file *ast.File) map[token.Pos]struct{} {
|
|
out := map[token.Pos]struct{}{}
|
|
for _, decl := range file.Decls {
|
|
switch d := decl.(type) {
|
|
case *ast.FuncDecl:
|
|
if d.Name != nil {
|
|
out[d.Name.Pos()] = struct{}{}
|
|
}
|
|
case *ast.GenDecl:
|
|
for _, spec := range d.Specs {
|
|
switch sp := spec.(type) {
|
|
case *ast.TypeSpec:
|
|
if sp.Name != nil {
|
|
out[sp.Name.Pos()] = struct{}{}
|
|
}
|
|
case *ast.ValueSpec:
|
|
for _, n := range sp.Names {
|
|
out[n.Pos()] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func nodeFromUsages(nodes []*graph.Node, id string) *graph.Node {
|
|
for _, n := range nodes {
|
|
if n.ID == id {
|
|
return n
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func countLines(b []byte) int {
|
|
if len(b) == 0 {
|
|
return 0
|
|
}
|
|
n := bytes.Count(b, []byte{'\n'})
|
|
if b[len(b)-1] != '\n' {
|
|
n++
|
|
}
|
|
return n
|
|
}
|
|
|
|
func isGoSourcePath(p string) bool {
|
|
return strings.HasSuffix(p, ".go")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// inline_symbol
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type inlineRefusal struct {
|
|
Site string `json:"site,omitempty"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
func (s *Server) handleInlineSymbol(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
id, err := s.symbolIDArg(ctx, req)
|
|
if err != nil {
|
|
return mcp.NewToolResultError("id is required"), nil
|
|
}
|
|
deleteAfter := req.GetBool("delete_after", true)
|
|
dryRun := req.GetBool("dry_run", false)
|
|
|
|
node := s.engineFor(ctx).GetSymbol(id)
|
|
if node == nil {
|
|
return mcp.NewToolResultError("symbol not found: " + id), nil
|
|
}
|
|
if !isGoSourcePath(node.FilePath) {
|
|
return mcp.NewToolResultError(fmt.Sprintf("unsupported language: inline_symbol only supports Go files (got %q)", node.FilePath)), nil
|
|
}
|
|
if node.Kind != graph.KindFunction && node.Kind != graph.KindMethod {
|
|
return mcp.NewToolResultError(fmt.Sprintf("inline_symbol only supports functions and methods (got %s)", node.Kind)), nil
|
|
}
|
|
|
|
calleeAbs, err := s.resolveNodePath(node)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
calleeContent, err := os.ReadFile(calleeAbs)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(fmt.Sprintf("could not read callee file: %v", err)), nil
|
|
}
|
|
|
|
calleeFset := token.NewFileSet()
|
|
calleeFile, err := parser.ParseFile(calleeFset, calleeAbs, calleeContent, parser.ParseComments)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(fmt.Sprintf("could not parse callee file: %v", err)), nil
|
|
}
|
|
|
|
calleeDecl, calleeDocless, err := findGoFunc(calleeFile, node.Name)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
if err := checkGoInlinable(calleeDecl); err != nil {
|
|
return mcp.NewToolResultError("cannot inline: " + err.Error()), nil
|
|
}
|
|
|
|
// Compute the substitution recipe: the body expression (return X)
|
|
// or the single statement.
|
|
recipe, err := buildInlineRecipe(calleeDecl)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
// Collect callsites via FindUsages, filter to EdgeCalls, group by file.
|
|
usages := s.engineFor(ctx).FindUsages(id)
|
|
calleePkg := calleeFile.Name.Name
|
|
calleeImportPath, _ := resolveGoImportPathForFile(calleeAbs)
|
|
|
|
callerFiles := map[string]struct{}{}
|
|
for _, edge := range usages.Edges {
|
|
if edge.Kind != graph.EdgeCalls {
|
|
continue
|
|
}
|
|
fromNode := nodeFromUsages(usages.Nodes, edge.From)
|
|
if fromNode == nil {
|
|
continue
|
|
}
|
|
if !isGoSourcePath(fromNode.FilePath) {
|
|
continue
|
|
}
|
|
abs, rerr := s.resolveNodePath(fromNode)
|
|
if rerr != nil {
|
|
continue
|
|
}
|
|
callerFiles[abs] = struct{}{}
|
|
}
|
|
// Fallback: walk the module tree so callsites without resolved
|
|
// graph edges are still considered.
|
|
if modRoot := findGoModRoot(calleeAbs); modRoot != "" {
|
|
_ = filepath.Walk(modRoot, func(p string, info os.FileInfo, werr error) error {
|
|
if werr != nil {
|
|
return nil
|
|
}
|
|
if info.IsDir() {
|
|
base := info.Name()
|
|
if base == ".git" || base == "vendor" || base == "node_modules" {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
if !isGoSourcePath(p) {
|
|
return nil
|
|
}
|
|
callerFiles[filepath.Clean(p)] = struct{}{}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// Iterate callers, rewrite each, collect refusals.
|
|
type callerPatch struct {
|
|
absPath string
|
|
orig []byte
|
|
patched []byte
|
|
count int
|
|
removedImp bool
|
|
}
|
|
|
|
var (
|
|
patches []callerPatch
|
|
refusals []inlineRefusal
|
|
total int
|
|
)
|
|
for absPath := range callerFiles {
|
|
raw, rerr := os.ReadFile(absPath)
|
|
if rerr != nil {
|
|
return mcp.NewToolResultError(fmt.Sprintf("could not read caller %s: %v", absPath, rerr)), nil
|
|
}
|
|
patched, count, removedImp, callerRefusals, rerr := inlineCallsInFile(raw, absPath, inlineApplyOpts{
|
|
calleeName: node.Name,
|
|
calleePackage: calleePkg,
|
|
calleeImportPath: calleeImportPath,
|
|
calleeDir: filepath.Dir(calleeAbs),
|
|
callerIsCallee: absPath == calleeAbs,
|
|
recipe: recipe,
|
|
})
|
|
if rerr != nil {
|
|
return mcp.NewToolResultError(fmt.Sprintf("could not rewrite %s: %v", absPath, rerr)), nil
|
|
}
|
|
refusals = append(refusals, callerRefusals...)
|
|
if count == 0 {
|
|
continue
|
|
}
|
|
patches = append(patches, callerPatch{
|
|
absPath: absPath,
|
|
orig: raw,
|
|
patched: patched,
|
|
count: count,
|
|
removedImp: removedImp,
|
|
})
|
|
total += count
|
|
}
|
|
|
|
if len(refusals) > 0 {
|
|
return s.respondJSONOrTOON(ctx, req, map[string]any{
|
|
"status": "refused",
|
|
"refusals": refusals,
|
|
"note": "inline_symbol refuses partial inlines; resolve every refusal and retry",
|
|
})
|
|
}
|
|
|
|
if total == 0 {
|
|
return s.respondJSONOrTOON(ctx, req, map[string]any{
|
|
"status": "no_callsites",
|
|
"callee": id,
|
|
"note": "no inlineable callsites were found",
|
|
"dry_run": dryRun,
|
|
})
|
|
}
|
|
|
|
// Optionally delete the callee.
|
|
var calleeNewContent []byte
|
|
calleeDeleted := false
|
|
if deleteAfter {
|
|
spliced, derr := spliceOutGoDecl(calleeContent, calleeFset, calleeDocless)
|
|
if derr != nil {
|
|
return mcp.NewToolResultError(derr.Error()), nil
|
|
}
|
|
calleeNewContent = spliced
|
|
calleeDeleted = true
|
|
}
|
|
|
|
type touched struct {
|
|
Path string `json:"path"`
|
|
Role string `json:"role"`
|
|
BytesBefore int `json:"bytes_before"`
|
|
BytesAfter int `json:"bytes_after"`
|
|
Inlined int `json:"inlined,omitempty"`
|
|
ImportRem bool `json:"import_removed,omitempty"`
|
|
}
|
|
var touchedList []touched
|
|
for _, p := range patches {
|
|
touchedList = append(touchedList, touched{
|
|
Path: s.repoRelative(p.absPath),
|
|
Role: "caller",
|
|
BytesBefore: len(p.orig),
|
|
BytesAfter: len(p.patched),
|
|
Inlined: p.count,
|
|
ImportRem: p.removedImp,
|
|
})
|
|
}
|
|
if calleeDeleted {
|
|
touchedList = append(touchedList, touched{
|
|
Path: s.repoRelative(calleeAbs),
|
|
Role: "callee_deleted",
|
|
BytesBefore: len(calleeContent),
|
|
BytesAfter: len(calleeNewContent),
|
|
})
|
|
}
|
|
|
|
sort.SliceStable(touchedList, func(i, j int) bool {
|
|
return touchedList[i].Path < touchedList[j].Path
|
|
})
|
|
|
|
resp := map[string]any{
|
|
"callee": id,
|
|
"callsites_inlined": total,
|
|
"callee_deleted": calleeDeleted,
|
|
"delete_after": deleteAfter,
|
|
"touched": touchedList,
|
|
"files_touched": len(touchedList),
|
|
"dry_run": dryRun,
|
|
"status": "applied",
|
|
}
|
|
if dryRun {
|
|
resp["status"] = "previewed"
|
|
return s.respondJSONOrTOON(ctx, req, resp)
|
|
}
|
|
|
|
for _, p := range patches {
|
|
if err := writeMoveFile(p.absPath, p.patched); err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
}
|
|
if calleeDeleted {
|
|
if err := writeMoveFile(calleeAbs, calleeNewContent); err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
}
|
|
|
|
sess := s.sessionFor(ctx)
|
|
for _, p := range patches {
|
|
sess.recordModified(s.repoRelative(p.absPath))
|
|
_ = s.reindexFile(p.absPath)
|
|
}
|
|
if calleeDeleted {
|
|
sess.recordModified(s.repoRelative(calleeAbs))
|
|
_ = s.reindexFile(calleeAbs)
|
|
}
|
|
|
|
return s.respondJSONOrTOON(ctx, req, resp)
|
|
}
|
|
|
|
// findGoFunc locates a top-level FuncDecl by name (or T.Method form).
|
|
func findGoFunc(file *ast.File, name string) (*ast.FuncDecl, *ast.FuncDecl, error) {
|
|
want := name
|
|
if dot := strings.LastIndex(name, "."); dot > 0 {
|
|
want = name[dot+1:]
|
|
}
|
|
for _, decl := range file.Decls {
|
|
fd, ok := decl.(*ast.FuncDecl)
|
|
if !ok || fd.Name == nil {
|
|
continue
|
|
}
|
|
if fd.Name.Name != want {
|
|
continue
|
|
}
|
|
return fd, fd, nil
|
|
}
|
|
return nil, nil, fmt.Errorf("function %q not found in %s", name, file.Name.Name)
|
|
}
|
|
|
|
// checkGoInlinable enforces the conservative inlinability rules: single
|
|
// return or none, no closures / goroutines / defers, single body stmt.
|
|
func checkGoInlinable(fd *ast.FuncDecl) error {
|
|
if fd.Body == nil {
|
|
return errors.New("function has no body")
|
|
}
|
|
if fd.Recv != nil {
|
|
return errors.New("methods are not yet supported")
|
|
}
|
|
if fd.Type != nil && fd.Type.Results != nil && len(fd.Type.Results.List) > 0 {
|
|
// Count return values across all fields.
|
|
count := 0
|
|
for _, f := range fd.Type.Results.List {
|
|
if len(f.Names) == 0 {
|
|
count++
|
|
} else {
|
|
count += len(f.Names)
|
|
}
|
|
}
|
|
if count > 1 {
|
|
return errors.New("function has multiple return values")
|
|
}
|
|
}
|
|
if len(fd.Body.List) != 1 {
|
|
return errors.New("function body must be a single statement or expression-return")
|
|
}
|
|
// Forbid closures / defers / goroutines anywhere in the body.
|
|
var refusal error
|
|
ast.Inspect(fd.Body, func(n ast.Node) bool {
|
|
if refusal != nil {
|
|
return false
|
|
}
|
|
switch n.(type) {
|
|
case *ast.DeferStmt:
|
|
refusal = errors.New("function contains a defer statement")
|
|
return false
|
|
case *ast.GoStmt:
|
|
refusal = errors.New("function contains a goroutine spawn")
|
|
return false
|
|
case *ast.FuncLit:
|
|
refusal = errors.New("function contains a closure")
|
|
return false
|
|
}
|
|
return true
|
|
})
|
|
return refusal
|
|
}
|
|
|
|
// inlineRecipe captures everything we need to splice the callee body into a
|
|
// callsite: the formal parameter names and the body expression / statement
|
|
// to substitute.
|
|
type inlineRecipe struct {
|
|
paramNames []string
|
|
// bodyExpr is the single expression returned by a `return <expr>`
|
|
// body. Nil when the body is a non-return statement (in which case
|
|
// bodyStmtSrc is set instead).
|
|
bodyExpr ast.Expr
|
|
// bodyStmtSrc is the literal Go source of the body's only statement
|
|
// when bodyExpr is nil (e.g. `x.foo = 1`).
|
|
bodyStmtSrc string
|
|
// resultType is true when the function returns a value (we substitute
|
|
// an expression). False for `func F(...)` with no results (we replace
|
|
// the call expression statement with the substituted statement).
|
|
hasResult bool
|
|
}
|
|
|
|
func buildInlineRecipe(fd *ast.FuncDecl) (inlineRecipe, error) {
|
|
r := inlineRecipe{}
|
|
if fd.Type != nil && fd.Type.Params != nil {
|
|
for _, f := range fd.Type.Params.List {
|
|
for _, n := range f.Names {
|
|
r.paramNames = append(r.paramNames, n.Name)
|
|
}
|
|
}
|
|
}
|
|
r.hasResult = fd.Type != nil && fd.Type.Results != nil && len(fd.Type.Results.List) > 0
|
|
stmt := fd.Body.List[0]
|
|
switch s := stmt.(type) {
|
|
case *ast.ReturnStmt:
|
|
if len(s.Results) != 1 {
|
|
return r, errors.New("function must return exactly one value")
|
|
}
|
|
r.bodyExpr = s.Results[0]
|
|
default:
|
|
var buf bytes.Buffer
|
|
fset := token.NewFileSet()
|
|
if err := format.Node(&buf, fset, stmt); err != nil {
|
|
return r, fmt.Errorf("could not render body: %w", err)
|
|
}
|
|
r.bodyStmtSrc = buf.String()
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
type inlineApplyOpts struct {
|
|
calleeName string
|
|
calleePackage string
|
|
calleeImportPath string
|
|
calleeDir string
|
|
callerIsCallee bool
|
|
recipe inlineRecipe
|
|
}
|
|
|
|
// inlineCallsInFile applies the inline recipe to every callsite in a single
|
|
// caller file. Returns the patched bytes, the rewritten call count, whether
|
|
// the source-package import was removed, and any refusals (per-site).
|
|
func inlineCallsInFile(content []byte, absPath string, opts inlineApplyOpts) ([]byte, int, bool, []inlineRefusal, error) {
|
|
fset := token.NewFileSet()
|
|
file, err := parser.ParseFile(fset, absPath, content, parser.ParseComments)
|
|
if err != nil {
|
|
return nil, 0, false, nil, err
|
|
}
|
|
// We need callsites of the callee. Two forms:
|
|
// - bare: `Name(args...)` inside the callee's own package
|
|
// - qualified: `pkgAlias.Name(args...)` outside the callee package
|
|
pkgAlias := ""
|
|
for _, imp := range file.Imports {
|
|
if unquoteImport(imp.Path.Value) == opts.calleeImportPath {
|
|
if imp.Name != nil {
|
|
pkgAlias = imp.Name.Name
|
|
} else {
|
|
pkgAlias = opts.calleePackage
|
|
}
|
|
}
|
|
}
|
|
// Same-package detection: caller's directory must match the
|
|
// callee's directory. Two packages with the same name in different
|
|
// dirs do not count as the same package.
|
|
callerDir := filepath.Dir(absPath)
|
|
sameFilePkg := callerDir == opts.calleeDir
|
|
|
|
var refusals []inlineRefusal
|
|
rewritten := 0
|
|
|
|
apply := func(c *astutil.Cursor) bool {
|
|
call, ok := c.Node().(*ast.CallExpr)
|
|
if !ok {
|
|
return true
|
|
}
|
|
match := false
|
|
switch fun := call.Fun.(type) {
|
|
case *ast.Ident:
|
|
if sameFilePkg && fun.Name == opts.calleeName {
|
|
match = true
|
|
}
|
|
case *ast.SelectorExpr:
|
|
if pkgAlias == "" {
|
|
return true
|
|
}
|
|
x, ok := fun.X.(*ast.Ident)
|
|
if !ok {
|
|
return true
|
|
}
|
|
if x.Name == pkgAlias && fun.Sel != nil && fun.Sel.Name == opts.calleeName {
|
|
match = true
|
|
}
|
|
}
|
|
if !match {
|
|
return true
|
|
}
|
|
|
|
// Refusal: any argument that's itself a call expression — its
|
|
// side effects would be reordered by naive substitution.
|
|
for _, arg := range call.Args {
|
|
if hasSideEffectArg(arg) {
|
|
pos := fset.Position(call.Pos())
|
|
refusals = append(refusals, inlineRefusal{
|
|
Site: fmt.Sprintf("%s:%d", absPath, pos.Line),
|
|
Reason: "argument has side effects (call expression); inlining would change evaluation order",
|
|
})
|
|
return true
|
|
}
|
|
}
|
|
|
|
// Arity must match.
|
|
if len(call.Args) != len(opts.recipe.paramNames) {
|
|
pos := fset.Position(call.Pos())
|
|
refusals = append(refusals, inlineRefusal{
|
|
Site: fmt.Sprintf("%s:%d", absPath, pos.Line),
|
|
Reason: fmt.Sprintf("call has %d args, callee declares %d params (variadic / spread not yet supported)", len(call.Args), len(opts.recipe.paramNames)),
|
|
})
|
|
return true
|
|
}
|
|
|
|
// Substitute param -> arg in a clone of the body expression.
|
|
if opts.recipe.hasResult && opts.recipe.bodyExpr != nil {
|
|
substituted, serr := substituteExprForParams(opts.recipe.bodyExpr, opts.recipe.paramNames, call.Args)
|
|
if serr != nil {
|
|
pos := fset.Position(call.Pos())
|
|
refusals = append(refusals, inlineRefusal{
|
|
Site: fmt.Sprintf("%s:%d", absPath, pos.Line),
|
|
Reason: serr.Error(),
|
|
})
|
|
return true
|
|
}
|
|
c.Replace(substituted)
|
|
rewritten++
|
|
return true
|
|
}
|
|
// No-result body: only inlineable when the call appears as an
|
|
// ExprStmt by itself; otherwise refuse.
|
|
// We can't easily check the parent here without a parent map, so
|
|
// we render the body-statement source with param substitution and
|
|
// rely on the printer to merge it cleanly.
|
|
stmtSrc, serr := substituteStmtForParams(opts.recipe.bodyStmtSrc, opts.recipe.paramNames, call.Args, fset)
|
|
if serr != nil {
|
|
pos := fset.Position(call.Pos())
|
|
refusals = append(refusals, inlineRefusal{
|
|
Site: fmt.Sprintf("%s:%d", absPath, pos.Line),
|
|
Reason: serr.Error(),
|
|
})
|
|
return true
|
|
}
|
|
// Synthesize an Ident with the resolved source so the printer
|
|
// emits it verbatim. Wrap it as an *ast.BadExpr would, but a
|
|
// simpler trick: emit the stmt-source as a comment-anchored
|
|
// literal via ast.Ident with a special marker, then run a
|
|
// regex/string fix-up pass on the formatted output.
|
|
marker := fmt.Sprintf("__INLINE_MARK_%d__", rewritten)
|
|
c.Replace(&ast.Ident{Name: marker})
|
|
rewritten++
|
|
// Record the substitution in a side-channel — the apply func
|
|
// doesn't carry state easily, so attach to the file's comments.
|
|
file.Comments = append(file.Comments, &ast.CommentGroup{List: []*ast.Comment{{Text: "//gortex-inline-marker:" + marker + ":" + stmtSrc}}})
|
|
return true
|
|
}
|
|
|
|
rewrittenRoot := astutil.Apply(file, apply, nil)
|
|
if newFile, ok := rewrittenRoot.(*ast.File); ok {
|
|
file = newFile
|
|
}
|
|
|
|
if len(refusals) > 0 {
|
|
return nil, 0, false, refusals, nil
|
|
}
|
|
if rewritten == 0 {
|
|
return content, 0, false, nil, nil
|
|
}
|
|
|
|
// Render and apply marker substitutions.
|
|
var buf bytes.Buffer
|
|
if err := format.Node(&buf, fset, file); err != nil {
|
|
return nil, 0, false, nil, err
|
|
}
|
|
out := buf.Bytes()
|
|
// Extract markers from comments + their substitutions.
|
|
type sub struct{ marker, src string }
|
|
subs := []sub{}
|
|
keepComments := file.Comments[:0]
|
|
for _, cg := range file.Comments {
|
|
newList := cg.List[:0]
|
|
for _, c := range cg.List {
|
|
if strings.HasPrefix(c.Text, "//gortex-inline-marker:") {
|
|
rest := strings.TrimPrefix(c.Text, "//gortex-inline-marker:")
|
|
parts := strings.SplitN(rest, ":", 2)
|
|
if len(parts) == 2 {
|
|
subs = append(subs, sub{marker: parts[0], src: parts[1]})
|
|
}
|
|
continue
|
|
}
|
|
newList = append(newList, c)
|
|
}
|
|
if len(newList) > 0 {
|
|
cg.List = newList
|
|
keepComments = append(keepComments, cg)
|
|
}
|
|
}
|
|
for _, sub := range subs {
|
|
out = bytes.ReplaceAll(out, []byte(sub.marker), []byte(sub.src))
|
|
}
|
|
// Strip residual marker comments that survived the format pass.
|
|
cleaned := stripInlineMarkerComments(out)
|
|
|
|
// Drop unused imports for the callee package if we converted a
|
|
// qualified call into a bare expression that no longer references
|
|
// the alias.
|
|
formatted, err := format.Source(cleaned)
|
|
if err != nil {
|
|
// Fall back to unformatted bytes.
|
|
formatted = cleaned
|
|
}
|
|
|
|
// After inlining, the import may be orphaned.
|
|
removedImp := false
|
|
if pkgAlias != "" && !bytes.Contains(formatted, []byte(pkgAlias+".")) {
|
|
// Reparse and drop the import.
|
|
fset2 := token.NewFileSet()
|
|
file2, perr := parser.ParseFile(fset2, absPath, formatted, parser.ParseComments)
|
|
if perr == nil {
|
|
if astutil.DeleteImport(fset2, file2, opts.calleeImportPath) {
|
|
removedImp = true
|
|
var b2 bytes.Buffer
|
|
if err := format.Node(&b2, fset2, file2); err == nil {
|
|
if f3, err := format.Source(b2.Bytes()); err == nil {
|
|
formatted = f3
|
|
} else {
|
|
formatted = b2.Bytes()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return formatted, rewritten, removedImp, nil, nil
|
|
}
|
|
|
|
func stripInlineMarkerComments(src []byte) []byte {
|
|
lines := bytes.Split(src, []byte{'\n'})
|
|
out := lines[:0]
|
|
for _, ln := range lines {
|
|
t := bytes.TrimSpace(ln)
|
|
if bytes.HasPrefix(t, []byte("//gortex-inline-marker:")) {
|
|
continue
|
|
}
|
|
out = append(out, ln)
|
|
}
|
|
return bytes.Join(out, []byte{'\n'})
|
|
}
|
|
|
|
func hasSideEffectArg(e ast.Expr) bool {
|
|
found := false
|
|
ast.Inspect(e, func(n ast.Node) bool {
|
|
if found {
|
|
return false
|
|
}
|
|
switch n.(type) {
|
|
case *ast.CallExpr:
|
|
found = true
|
|
return false
|
|
}
|
|
return true
|
|
})
|
|
return found
|
|
}
|
|
|
|
func substituteExprForParams(body ast.Expr, params []string, args []ast.Expr) (ast.Expr, error) {
|
|
if len(params) != len(args) {
|
|
return nil, fmt.Errorf("arity mismatch: %d params, %d args", len(params), len(args))
|
|
}
|
|
mapping := map[string]ast.Expr{}
|
|
for i, p := range params {
|
|
mapping[p] = args[i]
|
|
}
|
|
cloned := cloneExpr(body)
|
|
cloned = substituteIdentsInExpr(cloned, mapping)
|
|
// Wrap in parens to preserve precedence when spliced into the caller
|
|
// expression.
|
|
if needsParens(body) {
|
|
cloned = &ast.ParenExpr{X: cloned}
|
|
}
|
|
return cloned, nil
|
|
}
|
|
|
|
func substituteStmtForParams(stmtSrc string, params []string, args []ast.Expr, fset *token.FileSet) (string, error) {
|
|
if len(params) != len(args) {
|
|
return "", fmt.Errorf("arity mismatch: %d params, %d args", len(params), len(args))
|
|
}
|
|
// Render each arg to source.
|
|
rendered := make([]string, len(args))
|
|
for i, a := range args {
|
|
var buf bytes.Buffer
|
|
if err := format.Node(&buf, fset, a); err != nil {
|
|
return "", err
|
|
}
|
|
rendered[i] = buf.String()
|
|
}
|
|
// Do a token-aware identifier replacement by re-parsing the stmt with
|
|
// a placeholder package, walking idents, and rewriting.
|
|
wrapped := "package _p\nfunc _f() {\n" + stmtSrc + "\n}\n"
|
|
fset2 := token.NewFileSet()
|
|
f, err := parser.ParseFile(fset2, "", wrapped, 0)
|
|
if err != nil {
|
|
// Fall back to naive replacement.
|
|
out := stmtSrc
|
|
for i, p := range params {
|
|
out = strings.ReplaceAll(out, p, rendered[i])
|
|
}
|
|
return out, nil
|
|
}
|
|
mapping := map[string]string{}
|
|
for i, p := range params {
|
|
mapping[p] = rendered[i]
|
|
}
|
|
for _, decl := range f.Decls {
|
|
fd, ok := decl.(*ast.FuncDecl)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if fd.Body == nil {
|
|
continue
|
|
}
|
|
ast.Inspect(fd.Body, func(n ast.Node) bool {
|
|
ident, ok := n.(*ast.Ident)
|
|
if !ok {
|
|
return true
|
|
}
|
|
if r, has := mapping[ident.Name]; has {
|
|
ident.Name = r
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
// Pretty-print the body back out.
|
|
if len(f.Decls) > 0 {
|
|
fd, ok := f.Decls[0].(*ast.FuncDecl)
|
|
if ok && fd.Body != nil && len(fd.Body.List) > 0 {
|
|
var buf bytes.Buffer
|
|
if err := format.Node(&buf, fset2, fd.Body.List[0]); err == nil {
|
|
return buf.String(), nil
|
|
}
|
|
}
|
|
}
|
|
return stmtSrc, nil
|
|
}
|
|
|
|
func substituteIdentsInExpr(e ast.Expr, mapping map[string]ast.Expr) ast.Expr {
|
|
if e == nil {
|
|
return nil
|
|
}
|
|
rewritten := astutil.Apply(e, func(c *astutil.Cursor) bool {
|
|
ident, ok := c.Node().(*ast.Ident)
|
|
if !ok {
|
|
return true
|
|
}
|
|
if rep, has := mapping[ident.Name]; has {
|
|
c.Replace(cloneExpr(rep))
|
|
return false
|
|
}
|
|
return true
|
|
}, nil)
|
|
if expr, ok := rewritten.(ast.Expr); ok {
|
|
return expr
|
|
}
|
|
return e
|
|
}
|
|
|
|
func cloneExpr(e ast.Expr) ast.Expr {
|
|
if e == nil {
|
|
return nil
|
|
}
|
|
// Re-render and re-parse for a structural clone. ast doesn't ship
|
|
// a deep clone, so a print+parse round-trip is the simplest robust
|
|
// option for the small expressions we handle here.
|
|
fset := token.NewFileSet()
|
|
var buf bytes.Buffer
|
|
if err := format.Node(&buf, fset, e); err != nil {
|
|
return e
|
|
}
|
|
parsed, err := parser.ParseExpr(buf.String())
|
|
if err != nil {
|
|
return e
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
func needsParens(e ast.Expr) bool {
|
|
switch e.(type) {
|
|
case *ast.BinaryExpr, *ast.UnaryExpr:
|
|
return true
|
|
}
|
|
return false
|
|
}
|