From 47040d50ae797baf1dbc278ec7a975777aefc706 Mon Sep 17 00:00:00 2001 From: thibaud-leclere Date: Tue, 12 May 2026 09:41:31 +0200 Subject: [PATCH] feat: add cachegrind parser with gzip support Two-pass parser resolves forward cfn= references; all cost lines (including call edges) are accumulated as inclusive costs per function. Co-Authored-By: Claude Sonnet 4.6 --- go.mod | 10 +- go.sum | 2 - internal/cachegrind/parser.go | 200 +++++++++++++++++++++++++++++ internal/cachegrind/parser_test.go | 81 ++++++++++++ 4 files changed, 288 insertions(+), 5 deletions(-) create mode 100644 internal/cachegrind/parser.go create mode 100644 internal/cachegrind/parser_test.go diff --git a/go.mod b/go.mod index 0bb8711..3aea777 100644 --- a/go.mod +++ b/go.mod @@ -2,19 +2,23 @@ module forge.lclr.dev/AI/xdebug-mcp go 1.25.5 -require forge.lclr.dev/AI/mcp-framework v1.10.0 +require ( + forge.lclr.dev/AI/mcp-framework v1.10.0 + github.com/stretchr/testify v1.11.1 +) require ( github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect github.com/99designs/keyring v1.2.2 // indirect github.com/BurntSushi/toml v1.6.0 // indirect github.com/danieljoos/wincred v1.1.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/dvsekhvalnov/jose2go v1.5.0 // indirect github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect - github.com/mark3labs/mcp-go v0.52.0 // indirect github.com/mtibben/percent v0.2.1 // indirect - github.com/stretchr/testify v1.11.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/term v0.40.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 49d4dd2..ac3f01c 100644 --- a/go.sum +++ b/go.sum @@ -20,8 +20,6 @@ github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NM github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/mark3labs/mcp-go v0.52.0 h1:uRSzupNSUyPGDpF4owY5X4zEpACPwBnlM3FAFuXN6gQ= -github.com/mark3labs/mcp-go v0.52.0/go.mod h1:Zg9cB2HdwdMMVgY0xtTzq3KvYIOJQDsaut+jWjwDaQY= github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs= github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= diff --git a/internal/cachegrind/parser.go b/internal/cachegrind/parser.go new file mode 100644 index 0000000..e54e973 --- /dev/null +++ b/internal/cachegrind/parser.go @@ -0,0 +1,200 @@ +package cachegrind + +import ( + "bufio" + "compress/gzip" + "fmt" + "io" + "os" + "strconv" + "strings" +) + +// ParseFile opens a cachegrind file (plain text or gzip) and returns a Profile. +func ParseFile(path string) (*Profile, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open %s: %w", path, err) + } + defer f.Close() + + var r io.Reader = f + if strings.HasSuffix(path, ".gz") { + gr, gzErr := gzip.NewReader(f) + if gzErr != nil { + // fall back to plain text + if _, seekErr := f.Seek(0, io.SeekStart); seekErr != nil { + return nil, fmt.Errorf("gzip open %s: %w", path, gzErr) + } + } else { + defer gr.Close() + r = gr + } + } + + return Parse(r) +} + +// Parse reads cachegrind format from r and returns a Profile. +// All cost lines for a function are summed into inclusive costs (includes callees). +// Two passes are used: the first builds the function table, the second resolves call edges. +func Parse(r io.Reader) (*Profile, error) { + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, 4*1024*1024), 4*1024*1024) + + var lines []string + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + if err := scanner.Err(); err != nil { + return nil, err + } + + p := &Profile{ByName: make(map[string][]*Function)} + + // Pass 1: collect events count and build fnTable (alias → *Function). + flTable := map[int]string{} + fnTable := map[int]*Function{} + nEvents := 0 + currentFile1 := "" + + for _, line := range lines { + switch { + case strings.HasPrefix(line, "events:"): + p.Events = strings.Fields(strings.TrimPrefix(line, "events:")) + nEvents = len(p.Events) + case strings.HasPrefix(line, "cmd:"): + p.Cmd = strings.TrimSpace(strings.TrimPrefix(line, "cmd:")) + case strings.HasPrefix(line, "fl="): + alias, name := parseAlias(line[3:]) + if name != "" { + flTable[alias] = name + } + currentFile1 = flTable[alias] + case strings.HasPrefix(line, "fn="): + alias, name := parseAlias(line[3:]) + if name != "" { + fn := &Function{ + Name: name, + File: currentFile1, + Costs: make([]int64, nEvents), + } + fnTable[alias] = fn + p.Functions = append(p.Functions, fn) + p.ByName[name] = append(p.ByName[name], fn) + } + } + } + + // Pass 2: accumulate costs and resolve call edges (all functions now known). + var currentFn *Function + var pendingCallee *Function + pendingCallCount := int64(0) + inCallCtx := false + + for _, line := range lines { + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + switch { + case strings.HasPrefix(line, "events:"), strings.HasPrefix(line, "cmd:"), + strings.HasPrefix(line, "version:"), strings.HasPrefix(line, "creator:"), + strings.HasPrefix(line, "part:"), strings.HasPrefix(line, "positions:"), + strings.HasPrefix(line, "ob="), strings.HasPrefix(line, "totals:"), + strings.HasPrefix(line, "summary:"): + // already handled or irrelevant + + case strings.HasPrefix(line, "fl="): + inCallCtx = false + + case strings.HasPrefix(line, "fn="): + alias, _ := parseAlias(line[3:]) + currentFn = fnTable[alias] + inCallCtx = false + + case strings.HasPrefix(line, "cfl="): + // called file alias — not needed + + case strings.HasPrefix(line, "cfn="): + alias, _ := parseAlias(line[4:]) + pendingCallee = fnTable[alias] + + case strings.HasPrefix(line, "calls="): + parts := strings.Fields(line[6:]) + if len(parts) > 0 { + pendingCallCount, _ = strconv.ParseInt(parts[0], 10, 64) + } + inCallCtx = true + + default: + if len(line) == 0 || line[0] < '0' || line[0] > '9' { + continue + } + costs, err := parseCostLine(line, nEvents) + if err != nil { + continue + } + if inCallCtx && currentFn != nil && pendingCallee != nil { + call := &Call{ + Caller: currentFn, + Callee: pendingCallee, + Count: pendingCallCount, + Costs: costs, + } + currentFn.Calls = append(currentFn.Calls, call) + pendingCallee.CalledBy = append(pendingCallee.CalledBy, call) + pendingCallee = nil + pendingCallCount = 0 + inCallCtx = false + } + if currentFn != nil { + addCosts(currentFn.Costs, costs) + } + } + } + + return p, nil +} + +// parseAlias parses "(N)" or "(N) name" from the value portion of fl=/fn=. +func parseAlias(s string) (int, string) { + if !strings.HasPrefix(s, "(") { + return 0, "" + } + end := strings.Index(s, ")") + if end < 0 { + return 0, "" + } + alias, _ := strconv.Atoi(s[1:end]) + name := "" + if len(s) > end+1 { + name = strings.TrimSpace(s[end+1:]) + } + return alias, name +} + +// parseCostLine parses "linenum cost1 cost2 ..." and returns the cost slice. +func parseCostLine(line string, nEvents int) ([]int64, error) { + parts := strings.Fields(line) + if len(parts) < 2 { + return nil, fmt.Errorf("short cost line: %q", line) + } + costs := make([]int64, nEvents) + for i := 0; i < nEvents && i+1 < len(parts); i++ { + v, err := strconv.ParseInt(parts[i+1], 10, 64) + if err != nil { + return nil, err + } + costs[i] = v + } + return costs, nil +} + +func addCosts(dst, src []int64) { + for i := range src { + if i < len(dst) { + dst[i] += src[i] + } + } +} diff --git a/internal/cachegrind/parser_test.go b/internal/cachegrind/parser_test.go new file mode 100644 index 0000000..4000c89 --- /dev/null +++ b/internal/cachegrind/parser_test.go @@ -0,0 +1,81 @@ +package cachegrind_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "forge.lclr.dev/AI/xdebug-mcp/internal/cachegrind" +) + +const simpleFixture = `version: 1 +creator: test +cmd: index.php +part: 1 +positions: line + +events: Time_(10ns) Memory_(bytes) + +fl=(1) index.php +fn=(1) main +1 2000 1000 +cfl=(1) +cfn=(2) +calls=1 0 0 +2 1500 700 + +fl=(1) +fn=(2) query +10 1500 700 +cfl=(1) +cfn=(3) +calls=2 0 0 +15 500 200 + +fl=(1) +fn=(3) connect +20 250 100 +` + +func TestParseSimple(t *testing.T) { + p, err := cachegrind.Parse(strings.NewReader(simpleFixture)) + require.NoError(t, err) + + assert.Equal(t, "index.php", p.Cmd) + assert.Equal(t, []string{"Time_(10ns)", "Memory_(bytes)"}, p.Events) + assert.Len(t, p.Functions, 3) + + main := p.ByName["main"] + require.Len(t, main, 1) + // main costs: self (2000,1000) + call to query (1500,700) = (3500,1700) + assert.Equal(t, []int64{3500, 1700}, main[0].Costs) + assert.Len(t, main[0].Calls, 1) + assert.Equal(t, int64(1), main[0].Calls[0].Count) + assert.Equal(t, "query", main[0].Calls[0].Callee.Name) + + query := p.ByName["query"] + require.Len(t, query, 1) + assert.Equal(t, []int64{2000, 900}, query[0].Costs) + assert.Len(t, query[0].CalledBy, 1) + assert.Len(t, query[0].Calls, 1) + assert.Equal(t, int64(2), query[0].Calls[0].Count) + + connect := p.ByName["connect"] + require.Len(t, connect, 1) + assert.Equal(t, []int64{250, 100}, connect[0].Costs) + assert.Empty(t, connect[0].Calls) + assert.Len(t, connect[0].CalledBy, 1) +} + +func TestParseFile_gzip(t *testing.T) { + path := "/home/leclere/Uppler/apps/uppler1/docker/tmp/xdebug/cachegrind.out.45.gz" + p, err := cachegrind.ParseFile(path) + if err != nil { + t.Skipf("real file unavailable: %v", err) + } + assert.NotEmpty(t, p.Events) + assert.NotEmpty(t, p.Functions) + assert.NotEmpty(t, p.Cmd) +}