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 <noreply@anthropic.com>
This commit is contained in:
thibaud-leclere 2026-05-12 09:41:31 +02:00
parent d3c7f27c21
commit 47040d50ae
4 changed files with 288 additions and 5 deletions

10
go.mod
View file

@ -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
)

2
go.sum
View file

@ -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=

View file

@ -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]
}
}
}

View file

@ -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)
}