Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added buildgraph
Binary file not shown.
61 changes: 61 additions & 0 deletions cli/graph.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package cli

import (
"fmt"

"github.com/spf13/cobra"
)

var graphCmd = &cobra.Command{
Use: "graph",
Short: "Dump the full call graph as a DOT diagram",
Long: `Analyses the current source tree, builds the complete call graph, and
emits it as a Graphviz DOT digraph.

Pipe the output into dot(1) to render an image:

buildgraph graph | dot -Tpng -o graph.png
buildgraph graph | dot -Tsvg -o graph.svg

The diagram groups functions into clusters by owner (service / library).
Main-package entry points are highlighted in light blue.

Standard library functions (fmt.Println, os.Exit, etc.) are excluded by
default to keep the graph focused on your own code. Use --stdlib to include
them.`,
RunE: runGraph,
}

func init() {
graphCmd.Flags().StringP("format", "f", "dot", "Output format (dot)")
graphCmd.Flags().StringP("output", "o", "", "Output file (default: stdout)")
graphCmd.Flags().Bool("stdlib", false, "Include standard library functions in the graph")
}

func runGraph(cmd *cobra.Command, _ []string) error {
cfg := loadConfig()

rootPath, err := getWorkDir()
if err != nil {
return fmt.Errorf("getting working directory: %w", err)
}

rootModule, err := detectRootModule(rootPath)
if err != nil {
return fmt.Errorf("detecting root module: %w", err)
}

// Parse the project without a previous baseline — we want the full current
// graph, not a diff against anything.
_, graph, _, _, _, err := parseProject(rootPath, rootModule, cfg, nil)
if err != nil {
return fmt.Errorf("analysis failed: %w", err)
}

format, _ := cmd.Flags().GetString("format")
output, _ := cmd.Flags().GetString("output")
showStdlib, _ := cmd.Flags().GetBool("stdlib")
writeGraphOutput(graph, format, output, showStdlib)

return nil
}
189 changes: 175 additions & 14 deletions cli/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,143 @@ import (
"github.com/bubunyo/buildgraph/pkg/types"
)

// isStdlib reports whether a function key belongs to the Go standard library.
//
// Stdlib keys have no "/" in their name (e.g. "fmt.Println", "os.Exit").
// All module-internal and third-party keys contain at least one "/" as part of
// their import path (e.g. "github.com/org/pkg.Func").
func isStdlib(key string) bool {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this way of doing std check is wrong.

return !strings.Contains(key, "/")
}
Comment on lines +13 to +20

Copilot AI Mar 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The isStdlib heuristic (!strings.Contains(key, "/")) will classify any function key without a slash as stdlib. That can incorrectly hide edges for modules whose import path has no slash (e.g., local/private modules like mylib/pkg.Func). Consider a more robust check (e.g., based on dependency type/Package.Path when available, or a configurable/prefix-based filter) so the --stdlib flag doesn’t drop non-stdlib nodes in such projects.

Copilot uses AI. Check for mistakes.

// writeGraphOutput serialises a full call graph in the requested format and
// writes it to outputPath (or stdout if outputPath is empty).
// Currently only "dot" is supported; any other value is treated as "dot".
func writeGraphOutput(graph *types.CallGraph, format, outputPath string, showStdlib bool) {
var output []byte
// Only dot is supported for now; treat anything else as dot too.
_ = format
output = []byte(formatFullDot(graph, showStdlib))

if outputPath != "" {
if err := os.WriteFile(outputPath, output, 0644); err != nil {
fmt.Fprintf(os.Stderr, "failed to write output: %v\n", err)
os.Exit(1)
}
return
}
fmt.Println(string(output))
}

// formatFullDot renders the entire call graph as a Graphviz DOT digraph.
//
// Unlike formatDot (which shows only the impact of a change), this function
// dumps every function in the graph so you can visualise the full call
// structure:
//
// - One subgraph cluster per owner (service / tool / library), sorted
// alphabetically for deterministic output.
// - Main-package entry points are filled light-blue (#d0e8ff) to make them
// easy to spot.
// - All other nodes are white.
// - Every edge from node.Deps is emitted, including cross-cluster edges.
// - Node IDs are always double-quoted (same as formatDot) so the output is
// safe to pipe directly into `dot -Tpng`.
//
// When showStdlib is false (the default), stdlib dependency edges are omitted.
// Stdlib functions are identified by the absence of "/" in their key — all
// module-internal and third-party packages have at least one "/" in their
// import path, while stdlib functions like fmt.Println do not.
func formatFullDot(graph *types.CallGraph, showStdlib bool) string {
dotID := func(fn string) string {
escaped := strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(fn)
return `"` + escaped + `"`
}

// Build owner → []funcKey map from FunctionOwner, sorted for determinism.
ownerFuncs := make(map[string][]string)
for key, owner := range graph.FunctionOwner {
ownerFuncs[owner] = append(ownerFuncs[owner], key)
}
// Also ensure any node not in FunctionOwner (edge-only nodes) is captured
// under a synthetic "(unknown)" owner so it still appears in the graph.
for key := range graph.Nodes {
if _, ok := graph.FunctionOwner[key]; !ok {
ownerFuncs["(unknown)"] = append(ownerFuncs["(unknown)"], key)
}
}

owners := make([]string, 0, len(ownerFuncs))
for o := range ownerFuncs {
owners = append(owners, o)
}
sort.Strings(owners)

sb := &strings.Builder{}
fmt.Fprintln(sb, "digraph buildgraph {")
fmt.Fprintln(sb, ` rankdir=LR;`)
fmt.Fprintln(sb, ` node [fontname="Helvetica", fontsize=11, style=filled, fillcolor=white];`)
fmt.Fprintln(sb, ` edge [fontsize=9];`)
fmt.Fprintln(sb)

clusterIdx := 0
for _, owner := range owners {
keys := ownerFuncs[owner]
if len(keys) == 0 {
continue
}
sort.Strings(keys)

fmt.Fprintf(sb, " subgraph cluster_%d {\n", clusterIdx)
fmt.Fprintf(sb, " label=%q;\n", owner)
fmt.Fprintln(sb, ` style=rounded;`)
fmt.Fprintln(sb, ` color="#888888";`)
fmt.Fprintln(sb)

for _, key := range keys {
id := dotID(key)
lbl := shortLabel(key)
node, inNodes := graph.Nodes[key]
if inNodes && node.IsMain {
fmt.Fprintf(sb, " %s [label=%q, fillcolor=\"#d0e8ff\"];\n", id, lbl)
} else {
fmt.Fprintf(sb, " %s [label=%q];\n", id, lbl)
}
}
fmt.Fprintln(sb, " }")
fmt.Fprintln(sb)
clusterIdx++
}

// Emit edges from Deps. When showStdlib is false, skip edges whose target
// is a stdlib function (no "/" in the key).
fmt.Fprintln(sb, " // edges")
edgesSeen := make(map[string]bool)
// Iterate in sorted key order for determinism.
nodeKeys := make([]string, 0, len(graph.Nodes))
for k := range graph.Nodes {
nodeKeys = append(nodeKeys, k)
}
sort.Strings(nodeKeys)
for _, key := range nodeKeys {
node := graph.Nodes[key]
for _, dep := range node.Deps {
if !showStdlib && isStdlib(dep.FullName) {
continue
}
edgeKey := key + "->" + dep.FullName
if edgesSeen[edgeKey] {
continue
}
edgesSeen[edgeKey] = true
fmt.Fprintf(sb, " %s -> %s;\n", dotID(key), dotID(dep.FullName))
}
}

fmt.Fprintln(sb, "}")
return sb.String()
}

// writeOutput serialises result in the requested format and writes it to
// outputPath (or stdout if outputPath is empty).
func writeOutput(result *types.Result, graph *types.CallGraph, format, outputPath string) {
Expand Down Expand Up @@ -70,6 +207,37 @@ func formatText(result *types.Result) string {
return sb.String()
}

// shortLabel strips the module path prefix from a fully-qualified Go function
// key for readability in DOT labels.
//
// Normal form: "github.com/org/pkg/sub.Func" → "sub.Func"
// Pointer form: "(*github.com/org/pkg/sub.Type).Method" → "(*sub.Type).Method"
//
// The pointer form is handled explicitly: the import path inside the parens is
// stripped by taking the last slash-separated segment, and the result is
// reassembled as "(*<pkg.Type>).<Method>".
func shortLabel(fn string) string {
if strings.HasPrefix(fn, "(*") {
// Strip leading "(*" and find the closing ")".
inner := fn[2:] // e.g. "github.com/org/pkg/sub.Type).Method"
closing := strings.Index(inner, ")")
if closing >= 0 {
typePath := inner[:closing] // e.g. "github.com/org/pkg/sub.Type"
rest := inner[closing+1:] // e.g. ".Method"
// Take the last slash-segment of typePath.
if i := strings.LastIndex(typePath, "/"); i >= 0 {
typePath = typePath[i+1:]
}
return "(*" + typePath + ")" + rest
}
}
// Normal form: take the last slash-separated segment.
if i := strings.LastIndex(fn, "/"); i >= 0 {
return fn[i+1:]
}
return fn
}

// formatDot renders the impact as a Graphviz DOT digraph.
//
// Layout:
Expand Down Expand Up @@ -103,21 +271,14 @@ func formatDot(result *types.Result, graph *types.CallGraph) string {
rebuiltServices[s] = true
}

// dotID converts a fully-qualified function name to a safe DOT node ID.
// dotID converts a fully-qualified function name to a safe DOT node ID by
// quoting it as a DOT string literal. DOT accepts any double-quoted string
// as a valid ID, so this handles all special characters that appear in Go
// function keys: *, #, (, ), /, ., -, @, [, ], etc.
// Internal double-quotes and backslashes are escaped so the literal is valid.
dotID := func(fn string) string {
r := strings.NewReplacer(".", "_", "/", "_", "-", "_", "(", "_", ")", "_")
return r.Replace(fn)
}

// shortLabel strips the module prefix for readability.
shortLabel := func(fn string) string {
// Keep only "package.Func" — last two dot-separated segments.
parts := strings.Split(fn, "/")
if len(parts) == 0 {
return fn
}
last := parts[len(parts)-1]
return last
escaped := strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(fn)
return `"` + escaped + `"`
}

sb := &strings.Builder{}
Expand Down
Loading
Loading