xdebug-mcp/internal/cachegrind/parser.go
thibaud-leclere 47040d50ae 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>
2026-05-12 09:41:31 +02:00

200 lines
4.9 KiB
Go

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