Skip to content

Commit a1fd509

Browse files
committed
test: cover external callgrind graph parser
1 parent 5ad1e61 commit a1fd509

2 files changed

Lines changed: 344 additions & 0 deletions

File tree

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
# callgrind format
2+
# Runner-style uncompressed output (--compress-strings=no): full fn=/fl=/ob=
3+
# names, blank-line separated function records, a single part: block. Fed
4+
# directly to the monorepo callgrind-parser with no local preprocessing. Every
5+
# callee also has its own fn= record, since build_call_graph only nodes
6+
# functions that appear as fn= and drops edges to otherwise-unknown callees.
7+
version: 1
8+
creator: callgrind-fixture
9+
pid: 1
10+
cmd: ./prog
11+
positions: line
12+
events: Ir
13+
part: 1
14+
15+
# ===== main: object /path/to/clreq, file file1.c =====
16+
ob=/path/to/clreq
17+
fl=file1.c
18+
fn=main
19+
0 4
20+
# regular call: main -> func1; no cfi -> callee inherits file1.c.
21+
cfn=func1
22+
calls=1 50
23+
16 400
24+
# cfl= is the historical alias of cfi=; callee file resolves to cflfile.c.
25+
cfl=cflfile.c
26+
cfn=cflop
27+
calls=1 52
28+
18 30
29+
# omitted cfi/cfl: callee inherits the current file context (file1.c).
30+
cfn=nofile
31+
calls=1 53
32+
19 10
33+
# same function name in two different objects/files -> two distinct nodes.
34+
cob=liba
35+
cfi=fileA.c
36+
cfn=helper
37+
calls=1 60
38+
20 5
39+
cob=libb
40+
cfi=fileB.c
41+
cfn=helper
42+
calls=1 61
43+
21 5
44+
# cob= overrides the callee object; file inherited from context (file1.c).
45+
cob=extlib
46+
cfn=extfn
47+
calls=1 70
48+
22 3
49+
50+
# ===== func1 =====
51+
fl=file1.c
52+
fn=func1
53+
ob=/path/to/clreq
54+
23 100
55+
cfn=func2
56+
calls=1 54
57+
24 50
58+
59+
# ===== func2 =====
60+
fl=file1.c
61+
fn=func2
62+
ob=/path/to/clreq
63+
25 20
64+
cfn=rec
65+
calls=1 55
66+
26 50
67+
# a function name reused by a later call still yields a distinct edge.
68+
cfn=func1
69+
calls=1 62
70+
27 20
71+
72+
# ===== rec: direct recursion -> self-edge (caller == callee) =====
73+
fl=file1.c
74+
fn=rec
75+
ob=/path/to/clreq
76+
28 7
77+
cfn=rec
78+
calls=1 56
79+
29 7
80+
81+
# ===== callee functions (need their own fn= to become nodes) =====
82+
fl=cflfile.c
83+
fn=cflop
84+
ob=/path/to/clreq
85+
40 30
86+
87+
fl=file1.c
88+
fn=nofile
89+
ob=/path/to/clreq
90+
41 10
91+
92+
fl=fileA.c
93+
fn=helper
94+
ob=liba
95+
42 5
96+
97+
fl=fileB.c
98+
fn=helper
99+
ob=libb
100+
43 5
101+
102+
fl=file1.c
103+
fn=extfn
104+
ob=extlib
105+
44 3
106+
107+
fl=inline.c
108+
fn=inltarget
109+
ob=/path/to/clreq
110+
45 4
111+
112+
# ===== inlhost: inline fi=/fe= file transition =====
113+
# fi= moves the current file context to inline.c; a following call with no cfi
114+
# inherits inline.c for the callee. fe= restores file1.c before the function
115+
# terminates, so the caller keeps file1.c.
116+
fl=file1.c
117+
fn=inlhost
118+
ob=/path/to/clreq
119+
30 4
120+
fi=inline.c
121+
cfn=inltarget
122+
calls=1 57
123+
31 4
124+
fe=file1.c

callgrind-utils/tests/parser.rs

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
//! Integration tests for the Callgrind `.out` -> call-graph parser.
2+
//!
3+
//! The fixture (`data/example.out`) uses runner-style uncompressed Callgrind
4+
//! syntax (`--compress-strings=no`): full `fn=`/`fl=`/`ob=` names, blank-line
5+
//! separated function records, and a single `part:` block, fed directly to the
6+
//! monorepo `callgrind-parser` with no local preprocessing. Every callee also
7+
//! has its own `fn=` record, since `build_call_graph` only nodes functions that
8+
//! appear as `fn=` and drops edges to otherwise-unknown callees.
9+
//!
10+
//! Topology is asserted through the canonical JSON projection (`to_json`),
11+
//! which exposes node identity (`name`/`object`/`file`) and indexed edges with
12+
//! per-thread call counts. Path normalization is exercised through `redact`,
13+
//! which basenames `file`/`object` before serialization.
14+
15+
use callgrind_utils::model::CallGraph;
16+
use serde_json::Value;
17+
use std::io::Cursor;
18+
19+
const FIXTURE: &str = include_str!("data/example.out");
20+
21+
fn parse_default() -> CallGraph {
22+
CallGraph::parse(Cursor::new(FIXTURE)).expect("parse fixture")
23+
}
24+
25+
fn json(g: &CallGraph) -> Value {
26+
serde_json::from_str(&g.to_json().expect("to_json")).expect("valid json")
27+
}
28+
29+
/// The first node named `name` (unique names only).
30+
fn node<'a>(v: &'a Value, name: &str) -> &'a Value {
31+
v["nodes"]
32+
.as_array()
33+
.expect("nodes array")
34+
.iter()
35+
.find(|n| n["name"].as_str() == Some(name))
36+
.unwrap_or_else(|| panic!("node {name} not found"))
37+
}
38+
39+
/// All nodes named `name` (distinct by object/file).
40+
fn nodes_named<'a>(v: &'a Value, name: &str) -> Vec<&'a Value> {
41+
v["nodes"]
42+
.as_array()
43+
.expect("nodes array")
44+
.iter()
45+
.filter(|n| n["name"].as_str() == Some(name))
46+
.collect()
47+
}
48+
49+
/// All edges whose source/target nodes (looked up by name) are `caller`/`callee`.
50+
fn edges_between<'a>(v: &'a Value, caller: &str, callee: &str) -> Vec<&'a Value> {
51+
let names: Vec<&str> = v["nodes"]
52+
.as_array()
53+
.expect("nodes array")
54+
.iter()
55+
.map(|n| n["name"].as_str().unwrap())
56+
.collect();
57+
v["edges"]
58+
.as_array()
59+
.expect("edges array")
60+
.iter()
61+
.filter(|e| {
62+
let s = e["source"].as_u64().unwrap() as usize;
63+
let t = e["target"].as_u64().unwrap() as usize;
64+
names.get(s).copied() == Some(caller) && names.get(t).copied() == Some(callee)
65+
})
66+
.collect()
67+
}
68+
69+
/// Call count of an edge: `counts` is `[[{pid,tid}, count], ...]`; take the
70+
/// single (pid,tid) entry the fixture produces.
71+
fn call_count(e: &Value) -> u64 {
72+
e["counts"]
73+
.as_array()
74+
.expect("counts array")
75+
.first()
76+
.expect("at least one count entry")
77+
.as_array()
78+
.expect("count pair")[1]
79+
.as_u64()
80+
.unwrap()
81+
}
82+
83+
#[test]
84+
fn parses_basic_callgraph() {
85+
let v = json(&parse_default());
86+
assert_eq!(v["nodes"].as_array().unwrap().len(), 11, "nodes: {v:#?}");
87+
assert_eq!(v["edges"].as_array().unwrap().len(), 11, "edges: {v:#?}");
88+
89+
let e = &edges_between(&v, "main", "func1");
90+
assert_eq!(e.len(), 1);
91+
assert_eq!(call_count(e[0]), 1);
92+
// No cfi on this call -> callee inherits the caller's file.
93+
assert_eq!(node(&v, "main")["file"].as_str(), Some("file1.c"));
94+
assert_eq!(node(&v, "func1")["file"].as_str(), Some("file1.c"));
95+
}
96+
97+
#[test]
98+
fn cfl_alias_equals_cfi() {
99+
// `cfl=cflfile.c` is the historical alias of `cfi=`; the callee node's file
100+
// must resolve to cflfile.c while the object inherits the caller's.
101+
let v = json(&parse_default());
102+
let cflop = node(&v, "cflop");
103+
assert_eq!(cflop["file"].as_str(), Some("cflfile.c"));
104+
assert_eq!(cflop["object"].as_str(), Some("/path/to/clreq"));
105+
assert_eq!(edges_between(&v, "main", "cflop").len(), 1);
106+
}
107+
108+
#[test]
109+
fn omitted_cfi_inherits_current_file_context() {
110+
// No `cfi`/`cfl`: the callee inherits the current file context (file1.c).
111+
let v = json(&parse_default());
112+
let nofile = node(&v, "nofile");
113+
assert_eq!(nofile["file"].as_str(), Some("file1.c"));
114+
assert_eq!(edges_between(&v, "main", "nofile").len(), 1);
115+
}
116+
117+
#[test]
118+
fn inline_fi_fe_changes_callee_context_not_caller() {
119+
// `fi=inline.c` moves the current file context, so the callee (inltarget)
120+
// inherits inline.c. `fe=file1.c` restores the context before the function
121+
// terminates, so the caller (inlhost) keeps file1.c.
122+
let v = json(&parse_default());
123+
assert_eq!(node(&v, "inlhost")["file"].as_str(), Some("file1.c"));
124+
assert_eq!(node(&v, "inltarget")["file"].as_str(), Some("inline.c"));
125+
assert_eq!(edges_between(&v, "inlhost", "inltarget").len(), 1);
126+
}
127+
128+
#[test]
129+
fn same_name_different_object_are_distinct() {
130+
// `helper` exists in liba/fileA.c AND libb/fileB.c -> two distinct nodes,
131+
// two distinct edges from main.
132+
let v = json(&parse_default());
133+
let helpers = nodes_named(&v, "helper");
134+
assert_eq!(helpers.len(), 2, "helpers: {helpers:#?}");
135+
136+
let mut keys: Vec<(&str, &str)> = helpers
137+
.iter()
138+
.map(|n| (n["object"].as_str().unwrap(), n["file"].as_str().unwrap()))
139+
.collect();
140+
keys.sort();
141+
assert_eq!(keys, vec![("liba", "fileA.c"), ("libb", "fileB.c")]);
142+
143+
assert_eq!(edges_between(&v, "main", "helper").len(), 2);
144+
}
145+
146+
#[test]
147+
fn recursion_becomes_self_edge() {
148+
let v = json(&parse_default());
149+
let rec = edges_between(&v, "rec", "rec");
150+
assert_eq!(rec.len(), 1);
151+
assert_eq!(rec[0]["source"], rec[0]["target"]);
152+
}
153+
154+
#[test]
155+
fn cob_overrides_caller_object() {
156+
// `cob=extlib` with no `cfi`: callee object is extlib, file inherited from
157+
// the caller context (file1.c); the caller keeps its own object.
158+
let v = json(&parse_default());
159+
let extfn = node(&v, "extfn");
160+
assert_eq!(extfn["object"].as_str(), Some("extlib"));
161+
assert_eq!(extfn["file"].as_str(), Some("file1.c"));
162+
assert_eq!(node(&v, "main")["object"].as_str(), Some("/path/to/clreq"));
163+
assert_eq!(edges_between(&v, "main", "extfn").len(), 1);
164+
}
165+
166+
#[test]
167+
fn redact_basenames_object_and_file_paths() {
168+
// Raw JSON keeps the full object path verbatim; `redact` basenames it.
169+
let raw = json(&parse_default());
170+
assert!(raw["nodes"]
171+
.as_array()
172+
.unwrap()
173+
.iter()
174+
.any(|n| n["object"].as_str() == Some("/path/to/clreq")));
175+
176+
let redacted = json(&parse_default().redact());
177+
assert!(redacted["nodes"]
178+
.as_array()
179+
.unwrap()
180+
.iter()
181+
.any(|n| n["object"].as_str() == Some("clreq")));
182+
assert!(
183+
!redacted["nodes"]
184+
.as_array()
185+
.unwrap()
186+
.iter()
187+
.any(|n| n["object"].as_str().unwrap().contains('/')),
188+
"no object should retain a path separator after redaction"
189+
);
190+
}
191+
192+
#[test]
193+
fn to_json_is_canonical() {
194+
let g = parse_default();
195+
let v = json(&g);
196+
197+
assert_eq!(v["version"].as_u64(), Some(3));
198+
let nodes = v["nodes"].as_array().unwrap();
199+
let edges = v["edges"].as_array().unwrap();
200+
assert_eq!(nodes.len(), 11);
201+
assert_eq!(edges.len(), 11);
202+
203+
// Edges reference valid node indices.
204+
for e in edges {
205+
let s = e["source"].as_u64().unwrap() as usize;
206+
let t = e["target"].as_u64().unwrap() as usize;
207+
assert!(s < nodes.len() && t < nodes.len(), "edge index out of range");
208+
}
209+
210+
// Edges are sorted by source index (the serializer's canonical order).
211+
let sources: Vec<u64> = edges.iter().map(|e| e["source"].as_u64().unwrap()).collect();
212+
let mut sorted = sources.clone();
213+
sorted.sort();
214+
assert_eq!(sources, sorted, "edges must be sorted by source");
215+
216+
// Output is deterministic: re-parsing yields byte-identical JSON.
217+
let j1 = parse_default().to_json().unwrap();
218+
let j2 = parse_default().to_json().unwrap();
219+
assert_eq!(j1, j2, "JSON must be deterministic across parses");
220+
}

0 commit comments

Comments
 (0)