Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

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
}