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:
parent
d3c7f27c21
commit
47040d50ae
4 changed files with 288 additions and 5 deletions
10
go.mod
10
go.mod
|
|
@ -2,19 +2,23 @@ module forge.lclr.dev/AI/xdebug-mcp
|
||||||
|
|
||||||
go 1.25.5
|
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 (
|
require (
|
||||||
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect
|
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect
|
||||||
github.com/99designs/keyring v1.2.2 // indirect
|
github.com/99designs/keyring v1.2.2 // indirect
|
||||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||||
github.com/danieljoos/wincred v1.1.2 // 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/dvsekhvalnov/jose2go v1.5.0 // indirect
|
||||||
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect
|
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect
|
||||||
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // 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/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/sys v0.41.0 // indirect
|
||||||
golang.org/x/term v0.40.0 // indirect
|
golang.org/x/term v0.40.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
|
||||||
2
go.sum
2
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/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 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
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 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs=
|
||||||
github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns=
|
github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns=
|
||||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||||
|
|
|
||||||
200
internal/cachegrind/parser.go
Normal file
200
internal/cachegrind/parser.go
Normal 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]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
81
internal/cachegrind/parser_test.go
Normal file
81
internal/cachegrind/parser_test.go
Normal 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)
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue