use std::collections::HashMap;
use std::io::{self, BufRead, Write};
#[derive(Debug, Clone)]
pub struct CallRecord {
pub callee: String,
pub call_count: u64,
pub time: u64,
pub memory: i64,
}
#[derive(Debug, Clone)]
pub struct PhpFunction {
pub name: String,
pub file: String,
pub self_time: u64,
pub self_memory: i64,
pub inclusive_time: u64,
pub inclusive_memory: i64,
pub call_count: u64,
pub calls: Vec<CallRecord>,
}
pub struct ProfileIndex {
pub functions: Vec<PhpFunction>,
pub total_time: u64,
pub total_memory: i64,
pub command: String,
focus: Option<String>,
ignore: Option<String>,
}
impl ProfileIndex {
pub fn parse(text: &str) -> Self {
let mut file_names: HashMap<u32, String> = HashMap::new();
let mut fn_names: HashMap<u32, String> = HashMap::new();
let mut fn_self_time: HashMap<u32, u64> = HashMap::new();
let mut fn_self_memory: HashMap<u32, i64> = HashMap::new();
let mut fn_call_count: HashMap<u32, u64> = HashMap::new();
let mut fn_file: HashMap<u32, u32> = HashMap::new();
let mut fn_calls: HashMap<u32, Vec<CallRecord>> = HashMap::new();
let mut current_fl: u32 = 0;
let mut current_fn: u32 = 0;
let mut _current_cfl: u32 = 0;
let mut current_cfn: u32 = 0;
let mut pending_call_count: u64 = 0;
let mut total_time: u64 = 0;
let mut total_memory: i64 = 0;
let mut command = String::new();
let mut bare_name_ids: HashMap<String, u32> = HashMap::new();
let mut next_bare_id: u32 = 500_000;
for line in text.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Some(rest) = line.strip_prefix("cmd: ") {
command = rest.to_string();
continue;
}
if line.starts_with("version:")
|| line.starts_with("creator:")
|| line.starts_with("part:")
|| line.starts_with("positions:")
|| line.starts_with("events:")
{
continue;
}
if let Some(rest) = line.strip_prefix("summary: ") {
let parts: Vec<&str> = rest.split_whitespace().collect();
if let Some(t) = parts.first() {
total_time = t.parse().unwrap_or(0);
}
if let Some(m) = parts.get(1) {
total_memory = m.parse().unwrap_or(0);
}
continue;
}
if let Some(rest) = line.strip_prefix("fl=") {
if let Some((id_str, name)) = parse_id_assignment(rest, &mut bare_name_ids, &mut next_bare_id) {
current_fl = id_str;
if !name.is_empty() {
file_names.insert(id_str, name.to_string());
}
}
continue;
}
if let Some(rest) = line.strip_prefix("fn=") {
if let Some((id_str, name)) = parse_id_assignment(rest, &mut bare_name_ids, &mut next_bare_id) {
current_fn = id_str;
if !name.is_empty() {
fn_names.insert(id_str, name.to_string());
}
fn_file.entry(current_fn).or_insert(current_fl);
*fn_call_count.entry(current_fn).or_insert(0) += 1;
}
continue;
}
if let Some(rest) = line.strip_prefix("cfl=") {
if let Some((id_str, name)) = parse_id_assignment(rest, &mut bare_name_ids, &mut next_bare_id) {
_current_cfl = id_str;
if !name.is_empty() {
file_names.insert(id_str, name.to_string());
}
}
continue;
}
if let Some(rest) = line.strip_prefix("cfn=") {
if let Some((id_str, name)) = parse_id_assignment(rest, &mut bare_name_ids, &mut next_bare_id) {
current_cfn = id_str;
if !name.is_empty() {
fn_names.insert(id_str, name.to_string());
}
}
continue;
}
if let Some(rest) = line.strip_prefix("calls=") {
let parts: Vec<&str> = rest.split_whitespace().collect();
if let Some(c) = parts.first() {
pending_call_count = c.parse().unwrap_or(0);
}
continue;
}
if line.chars().next().map_or(false, |c| c.is_ascii_digit()) {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 {
let time: u64 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
let memory: i64 = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
if pending_call_count > 0 {
let callee_name = format!("__id_{}__", current_cfn);
let calls = fn_calls.entry(current_fn).or_default();
if let Some(existing) = calls.iter_mut().find(|c| c.callee == callee_name) {
existing.call_count += pending_call_count;
existing.time += time;
existing.memory += memory;
} else {
calls.push(CallRecord {
callee: callee_name,
call_count: pending_call_count,
time,
memory,
});
}
pending_call_count = 0;
} else {
*fn_self_time.entry(current_fn).or_insert(0) += time;
*fn_self_memory.entry(current_fn).or_insert(0) += memory;
}
}
}
}
for calls in fn_calls.values_mut() {
for call in calls.iter_mut() {
if let Some(rest) = call.callee.strip_prefix("__id_") {
if let Some(id_str) = rest.strip_suffix("__") {
if let Ok(id) = id_str.parse::<u32>() {
if let Some(name) = fn_names.get(&id) {
call.callee = name.clone();
}
}
}
}
}
}
for calls in fn_calls.values_mut() {
let mut merged: Vec<CallRecord> = Vec::new();
for call in calls.drain(..) {
if let Some(existing) = merged.iter_mut().find(|c| c.callee == call.callee) {
existing.call_count += call.call_count;
existing.time += call.time;
existing.memory += call.memory;
} else {
merged.push(call);
}
}
*calls = merged;
}
let mut functions = Vec::new();
let mut all_fn_ids: Vec<u32> = fn_names.keys().copied().collect();
all_fn_ids.sort();
for id in all_fn_ids {
let name = fn_names.get(&id).cloned().unwrap_or_else(|| format!("fn#{}", id));
let file = fn_file
.get(&id)
.and_then(|fid| file_names.get(fid))
.cloned()
.unwrap_or_default();
let self_time = fn_self_time.get(&id).copied().unwrap_or(0);
let self_memory = fn_self_memory.get(&id).copied().unwrap_or(0);
let calls = fn_calls.get(&id).cloned().unwrap_or_default();
let callee_time: u64 = calls.iter().map(|c| c.time).sum();
let callee_memory: i64 = calls.iter().map(|c| c.memory).sum();
functions.push(PhpFunction {
name,
file,
self_time,
self_memory,
inclusive_time: self_time + callee_time,
inclusive_memory: self_memory + callee_memory,
call_count: fn_call_count.get(&id).copied().unwrap_or(1),
calls,
});
}
ProfileIndex {
functions,
total_time,
total_memory,
command,
focus: None,
ignore: None,
}
}
fn filter(&self, pattern: &str) -> Vec<&PhpFunction> {
self.functions
.iter()
.filter(|f| {
if !pattern.is_empty() && pattern != "." {
if !f.name.to_lowercase().contains(&pattern.to_lowercase()) {
return false;
}
}
if let Some(ref focus) = self.focus {
if !f.name.to_lowercase().contains(&focus.to_lowercase()) {
return false;
}
}
if let Some(ref ignore) = self.ignore {
if f.name.to_lowercase().contains(&ignore.to_lowercase()) {
return false;
}
}
true
})
.collect()
}
fn format_time(t: u64) -> String {
if t >= 1_000_000 {
format!("{:.1}ms", t as f64 / 100_000.0)
} else if t >= 1_000 {
format!("{:.1}µs", t as f64 / 100.0)
} else {
format!("{}0ns", t)
}
}
fn format_memory(m: i64) -> String {
let abs = m.unsigned_abs();
let sign = if m < 0 { "-" } else { "" };
if abs >= 1_048_576 {
format!("{}{:.1}MB", sign, abs as f64 / 1_048_576.0)
} else if abs >= 1_024 {
format!("{}{:.1}KB", sign, abs as f64 / 1_024.0)
} else {
format!("{}{}B", sign, abs)
}
}
fn format_pct(&self, time: u64) -> String {
if self.total_time > 0 {
format!("{:.1}%", time as f64 / self.total_time as f64 * 100.0)
} else {
"-%".to_string()
}
}
pub fn cmd_hotspots(&self, n: usize, pattern: &str) -> String {
let mut matched = self.filter(pattern);
matched.sort_by(|a, b| b.inclusive_time.cmp(&a.inclusive_time));
let mut out = String::new();
for f in matched.iter().take(n) {
out.push_str(&format!(
"{:>7} {:>8} {:<40} {}x {}\n",
self.format_pct(f.inclusive_time),
Self::format_time(f.inclusive_time),
f.name,
f.call_count,
Self::format_memory(f.inclusive_memory),
));
}
if out.is_empty() {
out.push_str("no functions found\n");
}
out
}
pub fn cmd_flat(&self, n: usize, pattern: &str) -> String {
let mut matched = self.filter(pattern);
matched.sort_by(|a, b| b.self_time.cmp(&a.self_time));
let mut out = String::new();
out.push_str(&format!(
"{:>7} {:>8} {:<40} {:>8} {}\n",
"self%", "self", "function", "calls", "self mem"
));
out.push_str(&format!("{}\n", "-".repeat(80)));
for f in matched.iter().take(n) {
out.push_str(&format!(
"{:>7} {:>8} {:<40} {:>7}x {}\n",
self.format_pct(f.self_time),
Self::format_time(f.self_time),
f.name,
f.call_count,
Self::format_memory(f.self_memory),
));
}
if matched.is_empty() {
out.push_str("no functions found\n");
}
out
}
pub fn cmd_calls(&self, pattern: &str) -> String {
let matched = self.filter(pattern);
let mut out = String::new();
for f in &matched {
if f.calls.is_empty() {
out.push_str(&format!("{}: no calls\n", f.name));
} else {
out.push_str(&format!("{} ({} callees):\n", f.name, f.calls.len()));
let mut sorted = f.calls.clone();
sorted.sort_by(|a, b| b.time.cmp(&a.time));
for c in &sorted {
out.push_str(&format!(
" → {:<40} {}x {} {}\n",
c.callee,
c.call_count,
Self::format_time(c.time),
Self::format_memory(c.memory),
));
}
}
}
if out.is_empty() {
out.push_str("no functions found\n");
}
out
}
pub fn cmd_callers(&self, pattern: &str) -> String {
let pat_lower = pattern.to_lowercase();
let mut out = String::new();
for f in &self.functions {
let hits: Vec<&CallRecord> = f
.calls
.iter()
.filter(|c| c.callee.to_lowercase().contains(&pat_lower))
.collect();
if !hits.is_empty() {
for c in &hits {
out.push_str(&format!(
"{:<40} → {:<30} {}x {}\n",
f.name,
c.callee,
c.call_count,
Self::format_time(c.time),
));
}
}
}
if out.is_empty() {
out.push_str(&format!("no callers found for '{}'\n", pattern));
}
out
}
pub fn cmd_stats(&self, pattern: &str) -> String {
let matched = self.filter(pattern);
if matched.is_empty() {
return "no functions found\n".into();
}
let total_self_time: u64 = matched.iter().map(|f| f.self_time).sum();
let total_self_mem: i64 = matched.iter().map(|f| f.self_memory).sum();
let total_incl_time: u64 = matched.iter().map(|f| f.inclusive_time).sum();
let total_calls: u64 = matched.iter().map(|f| f.call_count).sum();
let label = if pattern.is_empty() || pattern == "." {
format!("--- {} ---", self.command)
} else {
format!("--- filter: {} ---", pattern)
};
let mut out = format!("{}\n", label);
out.push_str(&format!("Functions: {}\n", matched.len()));
out.push_str(&format!("Total calls: {}\n", total_calls));
out.push_str(&format!(
"Self time: {} ({})\n",
Self::format_time(total_self_time),
self.format_pct(total_self_time)
));
out.push_str(&format!(
"Inclusive time: {}\n",
Self::format_time(total_incl_time)
));
out.push_str(&format!("Self memory: {}\n", Self::format_memory(total_self_mem)));
if self.total_time > 0 {
out.push_str(&format!(
"Program total: {} {}\n",
Self::format_time(self.total_time),
Self::format_memory(self.total_memory)
));
}
out
}
pub fn cmd_inspect(&self, pattern: &str) -> String {
let matched = self.filter(pattern);
let mut out = String::new();
for f in &matched {
out.push_str(&format!("{} ({})\n", f.name, f.file));
out.push_str(&format!(
" Self: {:>8} ({})\n",
Self::format_time(f.self_time),
self.format_pct(f.self_time)
));
out.push_str(&format!(
" Inclusive: {:>8} ({})\n",
Self::format_time(f.inclusive_time),
self.format_pct(f.inclusive_time)
));
out.push_str(&format!(
" Memory: {:>8} self, {} inclusive\n",
Self::format_memory(f.self_memory),
Self::format_memory(f.inclusive_memory)
));
out.push_str(&format!(" Calls: {}x\n", f.call_count));
if !f.calls.is_empty() {
out.push_str(" Callees:\n");
let mut sorted = f.calls.clone();
sorted.sort_by(|a, b| b.time.cmp(&a.time));
for c in &sorted {
out.push_str(&format!(
" → {:<36} {}x {} {}\n",
c.callee,
c.call_count,
Self::format_time(c.time),
Self::format_memory(c.memory),
));
}
}
out.push('\n');
}
if out.is_empty() {
out.push_str("no functions found\n");
}
out
}
pub fn cmd_memory(&self, n: usize, pattern: &str) -> String {
let mut matched = self.filter(pattern);
matched.sort_by(|a, b| b.self_memory.cmp(&a.self_memory));
let mut out = String::new();
for f in matched.iter().take(n) {
if f.self_memory == 0 && f.inclusive_memory == 0 {
continue;
}
out.push_str(&format!(
"{:>8} self {:>8} incl {:<40} {}x\n",
Self::format_memory(f.self_memory),
Self::format_memory(f.inclusive_memory),
f.name,
f.call_count,
));
}
if out.is_empty() {
out.push_str("no memory-allocating functions found\n");
}
out
}
pub fn cmd_search(&self, pattern: &str) -> String {
let pat_lower = pattern.to_lowercase();
let mut matched: Vec<&PhpFunction> = self
.functions
.iter()
.filter(|f| f.name.to_lowercase().contains(&pat_lower) && f.self_time > 0)
.collect();
if matched.is_empty() {
return format!("no functions matching '{}'\n", pattern);
}
matched.sort_by(|a, b| b.inclusive_time.cmp(&a.inclusive_time));
let mut out = format!("{} matches:\n", matched.len());
for f in matched.iter().take(30) {
out.push_str(&format!(
" {:>7} {:<40} {}x\n",
self.format_pct(f.inclusive_time),
f.name,
f.call_count,
));
}
out
}
pub fn cmd_tree(&self, n: usize) -> String {
let filtered: Vec<&PhpFunction> = self.filter("");
let called_names: std::collections::HashSet<&str> = filtered
.iter()
.flat_map(|f| f.calls.iter().map(|c| c.callee.as_str()))
.collect();
let mut roots: Vec<&PhpFunction> = filtered
.iter()
.filter(|f| !called_names.contains(f.name.as_str()) && f.inclusive_time > 0)
.copied()
.collect();
if roots.is_empty() {
roots = filtered
.iter()
.filter(|f| f.inclusive_time > 0)
.copied()
.collect();
}
roots.sort_by(|a, b| b.inclusive_time.cmp(&a.inclusive_time));
roots.truncate(n);
let fn_map: HashMap<&str, &PhpFunction> = filtered
.iter()
.map(|f| (f.name.as_str(), *f))
.collect();
let mut out = String::new();
let mut visited = std::collections::HashSet::new();
for root in &roots {
self.tree_recurse(root, 0, &fn_map, &mut out, 5, &mut visited);
visited.clear();
}
if out.is_empty() {
out.push_str("no call tree found\n");
}
out
}
fn tree_recurse<'a>(
&self,
func: &'a PhpFunction,
depth: usize,
fn_map: &HashMap<&str, &'a PhpFunction>,
out: &mut String,
max_depth: usize,
visited: &mut std::collections::HashSet<&'a str>,
) {
if depth > max_depth {
return;
}
let pct = if self.total_time > 0 {
func.inclusive_time as f64 / self.total_time as f64 * 100.0
} else {
0.0
};
if pct < 0.5 {
return;
}
let indent = " ".repeat(depth);
if !visited.insert(func.name.as_str()) {
out.push_str(&format!(
"{}{:>6.1}% {} (recursive)\n",
indent, pct, func.name,
));
return;
}
out.push_str(&format!(
"{}{:>6.1}% {} ({}x)\n",
indent, pct, func.name, func.call_count,
));
let mut sorted = func.calls.clone();
sorted.sort_by(|a, b| b.time.cmp(&a.time));
for call in &sorted {
if let Some(callee) = fn_map.get(call.callee.as_str()) {
self.tree_recurse(callee, depth + 1, fn_map, out, max_depth, visited);
}
}
visited.remove(func.name.as_str());
}
pub fn cmd_hotpath(&self) -> String {
let filtered: Vec<&PhpFunction> = self.filter("");
let fn_map: HashMap<&str, &PhpFunction> = filtered
.iter()
.map(|f| (f.name.as_str(), *f))
.collect();
let called_names: std::collections::HashSet<&str> = filtered
.iter()
.flat_map(|f| f.calls.iter().map(|c| c.callee.as_str()))
.collect();
let root = filtered
.iter()
.filter(|f| !called_names.contains(f.name.as_str()) && f.inclusive_time > 0)
.max_by_key(|f| f.inclusive_time)
.or_else(|| {
filtered.iter().filter(|f| f.inclusive_time > 0).max_by_key(|f| f.inclusive_time)
});
let Some(root) = root else {
return "no call data\n".to_string();
};
let mut out = format!(
"hottest path ({}):\n",
Self::format_time(root.inclusive_time)
);
let mut current = *root;
let mut depth = 0;
let mut visited = std::collections::HashSet::new();
loop {
let indent = " ".repeat(depth);
out.push_str(&format!(
"{}→ {} ({}, {}x)\n",
indent,
current.name,
Self::format_time(current.self_time),
current.call_count,
));
if !visited.insert(current.name.as_str()) {
out.push_str(&format!("{} (recursive)\n", indent));
break;
}
let hottest_call = current
.calls
.iter()
.filter(|c| fn_map.contains_key(c.callee.as_str()))
.max_by_key(|c| c.time);
if let Some(hottest_call) = hottest_call {
current = fn_map[hottest_call.callee.as_str()];
depth += 1;
} else {
break;
}
}
out
}
}
fn parse_id_assignment<'a>(
s: &'a str,
bare_name_ids: &mut HashMap<String, u32>,
next_bare_id: &mut u32,
) -> Option<(u32, &'a str)> {
let s = s.trim();
if s.is_empty() {
return None;
}
if s.starts_with('(') {
let end_paren = s.find(')')?;
let id: u32 = s[1..end_paren].parse().ok()?;
let rest = s[end_paren + 1..].trim();
return Some((id, rest));
}
let name = s;
let id = *bare_name_ids.entry(name.to_string()).or_insert_with(|| {
let id = *next_bare_id;
*next_bare_id += 1;
id
});
Some((id, name))
}
pub fn run_repl(cachegrind_path: &str, prompt: &str) -> io::Result<()> {
let text = std::fs::read_to_string(cachegrind_path)?;
let mut index = ProfileIndex::parse(&text);
eprintln!(
"--- ready: {} functions profiled ---",
index.functions.len()
);
eprintln!("Type: help");
let stdin = io::stdin();
let mut stdout = io::stdout();
loop {
print!("{prompt}");
stdout.flush()?;
let mut line = String::new();
if stdin.lock().read_line(&mut line)? == 0 {
break;
}
let line = line.trim();
if line.is_empty() {
continue;
}
let parts: Vec<&str> = line.splitn(3, ' ').collect();
let cmd = parts[0];
let arg1 = parts.get(1).copied().unwrap_or("");
let arg2 = parts.get(2).copied().unwrap_or("");
let pat = if arg2.is_empty() { arg1.to_string() } else { format!("{arg1} {arg2}") };
let result = match cmd {
"hotspots" => {
let n: usize = arg1.parse().unwrap_or(10);
index.cmd_hotspots(n, arg2)
}
"flat" => {
let n: usize = arg1.parse().unwrap_or(20);
index.cmd_flat(n, arg2)
}
"calls" if arg1.is_empty() => "usage: calls <pattern>\n".into(),
"calls" => index.cmd_calls(&pat),
"callers" if arg1.is_empty() => "usage: callers <pattern>\n".into(),
"callers" => index.cmd_callers(&pat),
"inspect" if arg1.is_empty() => "usage: inspect <pattern>\n".into(),
"inspect" => index.cmd_inspect(&pat),
"stats" => index.cmd_stats(arg1),
"memory" => {
let n: usize = arg1.parse().unwrap_or(10);
index.cmd_memory(n, arg2)
}
"search" if arg1.is_empty() => "usage: search <pattern>\n".into(),
"search" => index.cmd_search(&pat),
"tree" => {
let n: usize = arg1.parse().unwrap_or(10);
index.cmd_tree(n)
}
"hotpath" => index.cmd_hotpath(),
"focus" if arg1.is_empty() => "usage: focus <pattern>\n".into(),
"focus" => {
index.focus = Some(pat.clone());
format!("focus set: {}\n", pat)
}
"ignore" if arg1.is_empty() => "usage: ignore <pattern>\n".into(),
"ignore" => {
index.ignore = Some(pat.clone());
format!("ignore set: {}\n", pat)
}
"reset" => {
index.focus = None;
index.ignore = None;
"filters cleared\n".into()
}
"help" => {
"php-profile commands:\n \
hotspots [N] [pat] top N functions by inclusive time (default 10)\n \
flat [N] [pat] top N functions by self time (default 20)\n \
calls <pattern> what does this function call?\n \
callers <pattern> who calls this function?\n \
inspect <pattern> detailed breakdown of matching functions\n \
stats [pattern] summary statistics\n \
memory [N] [pat] top N functions by memory allocation\n \
search <pattern> find functions matching a pattern\n \
tree [N] call tree from roots (top N branches)\n \
hotpath single most expensive call chain\n \
focus <pattern> filter all commands to matching functions\n \
ignore <pattern> exclude matching functions from all commands\n \
reset clear focus/ignore filters\n \
help show this help\n \
exit quit\n"
.into()
}
"exit" | "quit" => break,
_ => format!(
"unknown command: {}. Type 'help' for available commands.\n",
cmd
),
};
print!("{}", result);
stdout.flush()?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = include_str!("../tests/fixtures/xdebug_cachegrind_sample.out");
#[test]
fn parse_finds_all_functions() {
let idx = ProfileIndex::parse(SAMPLE);
assert_eq!(idx.functions.len(), 11);
}
#[test]
fn parse_command() {
let idx = ProfileIndex::parse(SAMPLE);
assert_eq!(idx.command, "/tmp/demo.php");
}
#[test]
fn parse_totals() {
let idx = ProfileIndex::parse(SAMPLE);
assert_eq!(idx.total_time, 429023);
assert_eq!(idx.total_memory, 480384);
}
#[test]
fn parse_self_time() {
let idx = ProfileIndex::parse(SAMPLE);
let multiply = idx.functions.iter().find(|f| f.name == "Matrix->multiply").unwrap();
assert_eq!(multiply.self_time, 175000);
}
#[test]
fn parse_inclusive_time() {
let idx = ProfileIndex::parse(SAMPLE);
let multiply = idx.functions.iter().find(|f| f.name == "Matrix->multiply").unwrap();
assert_eq!(multiply.inclusive_time, 175000 + 141 + 9500);
}
#[test]
fn parse_calls() {
let idx = ProfileIndex::parse(SAMPLE);
let multiply = idx.functions.iter().find(|f| f.name == "Matrix->multiply").unwrap();
assert_eq!(multiply.calls.len(), 2);
let set_call = multiply.calls.iter().find(|c| c.callee == "Matrix->set").unwrap();
assert_eq!(set_call.call_count, 900);
}
#[test]
fn parse_main_calls_buildrandom() {
let idx = ProfileIndex::parse(SAMPLE);
let main = idx.functions.iter().find(|f| f.name == "main").unwrap();
let br_calls: Vec<&CallRecord> = main.calls.iter().filter(|c| c.callee == "buildRandom").collect();
assert_eq!(br_calls.len(), 1);
assert_eq!(br_calls[0].call_count, 2);
}
#[test]
fn cmd_hotspots_returns_sorted() {
let idx = ProfileIndex::parse(SAMPLE);
let out = idx.cmd_hotspots(3, "");
let lines: Vec<&str> = out.lines().collect();
assert!(lines[0].contains("main") || lines[0].contains("{main}"));
}
#[test]
fn cmd_flat_returns_sorted_by_self() {
let idx = ProfileIndex::parse(SAMPLE);
let out = idx.cmd_flat(3, "");
let lines: Vec<&str> = out.lines().collect();
assert!(lines[2].contains("multiply"));
}
#[test]
fn cmd_calls_shows_callees() {
let idx = ProfileIndex::parse(SAMPLE);
let out = idx.cmd_calls("multiply");
assert!(out.contains("Matrix->set"));
assert!(out.contains("900x"));
}
#[test]
fn cmd_callers_shows_callers() {
let idx = ProfileIndex::parse(SAMPLE);
let out = idx.cmd_callers("multiply");
assert!(out.contains("main"));
}
#[test]
fn cmd_callers_buildrandom() {
let idx = ProfileIndex::parse(SAMPLE);
let out = idx.cmd_callers("buildRandom");
assert!(out.contains("main"));
assert!(out.contains("2x"));
}
#[test]
fn cmd_stats_all() {
let idx = ProfileIndex::parse(SAMPLE);
let out = idx.cmd_stats("");
assert!(out.contains("Functions: 11"));
assert!(out.contains("/tmp/demo.php"));
}
#[test]
fn cmd_stats_filtered() {
let idx = ProfileIndex::parse(SAMPLE);
let out = idx.cmd_stats("Matrix");
assert!(out.contains("filter: Matrix"));
assert!(out.contains("Functions: 5"));
}
#[test]
fn cmd_inspect_shows_detail() {
let idx = ProfileIndex::parse(SAMPLE);
let out = idx.cmd_inspect("multiply");
assert!(out.contains("Matrix->multiply"));
assert!(out.contains("Self:"));
assert!(out.contains("Inclusive:"));
assert!(out.contains("Callees:"));
assert!(out.contains("Matrix->set"));
}
#[test]
fn cmd_memory_shows_allocations() {
let idx = ProfileIndex::parse(SAMPLE);
let out = idx.cmd_memory(5, "");
assert!(out.contains("Matrix->set"));
}
#[test]
fn format_time_units() {
assert_eq!(ProfileIndex::format_time(5), "50ns");
assert_eq!(ProfileIndex::format_time(1500), "15.0µs");
assert_eq!(ProfileIndex::format_time(1_500_000), "15.0ms");
}
#[test]
fn format_memory_units() {
assert_eq!(ProfileIndex::format_memory(500), "500B");
assert_eq!(ProfileIndex::format_memory(2048), "2.0KB");
assert_eq!(ProfileIndex::format_memory(1_048_576), "1.0MB");
assert_eq!(ProfileIndex::format_memory(-500), "-500B");
}
#[test]
fn parse_id_assignment_with_name() {
let mut bare = HashMap::new();
let mut next = 500_000;
let (id, name) = parse_id_assignment("(2) /tmp/demo.php", &mut bare, &mut next).unwrap();
assert_eq!(id, 2);
assert_eq!(name, "/tmp/demo.php");
}
#[test]
fn parse_id_assignment_without_name() {
let mut bare = HashMap::new();
let mut next = 500_000;
let (id, name) = parse_id_assignment("(2)", &mut bare, &mut next).unwrap();
assert_eq!(id, 2);
assert_eq!(name, "");
}
#[test]
fn parse_id_assignment_bare_name() {
let mut bare = HashMap::new();
let mut next = 500_000;
let (id, name) = parse_id_assignment("Object#fibonacci", &mut bare, &mut next).unwrap();
assert_eq!(id, 500_000);
assert_eq!(name, "Object#fibonacci");
let (id2, _) = parse_id_assignment("Object#fibonacci", &mut bare, &mut next).unwrap();
assert_eq!(id2, 500_000);
let (id3, _) = parse_id_assignment("Object#compute", &mut bare, &mut next).unwrap();
assert_eq!(id3, 500_001);
}
#[test]
fn cmd_search_finds_matches() {
let idx = ProfileIndex::parse(SAMPLE);
let out = idx.cmd_search("Matrix");
assert!(out.contains("5 matches"));
assert!(out.contains("Matrix->multiply"));
assert!(out.contains("Matrix->set"));
}
#[test]
fn cmd_search_no_matches() {
let idx = ProfileIndex::parse(SAMPLE);
let out = idx.cmd_search("nonexistent");
assert!(out.contains("no functions matching"));
}
#[test]
fn cmd_search_case_insensitive() {
let idx = ProfileIndex::parse(SAMPLE);
let out = idx.cmd_search("matrix");
assert!(out.contains("5 matches"));
}
#[test]
fn cmd_tree_shows_hierarchy() {
let idx = ProfileIndex::parse(SAMPLE);
let out = idx.cmd_tree(5);
assert!(out.contains("{main}"));
assert!(out.contains("main"));
}
#[test]
fn cmd_hotpath_shows_chain() {
let idx = ProfileIndex::parse(SAMPLE);
let out = idx.cmd_hotpath();
assert!(out.contains("hottest path"));
assert!(out.contains("→"));
assert!(out.contains("main") || out.contains("{main}"));
}
#[test]
fn cmd_hotpath_terminates_on_recursion() {
let cg = "\
events: Time Memory
summary: 1000 0
fl=(1) test.rb
fn=(1) main
1 100 0
cfn=(2)
calls=1 1
1 900 0
fn=(2) fib
1 500 0
cfn=(2)
calls=100 1
1 400 0
";
let idx = ProfileIndex::parse(cg);
let out = idx.cmd_hotpath();
assert!(out.contains("recursive"), "output: {}", out);
assert!(out.lines().count() < 10);
}
#[test]
fn cmd_tree_terminates_on_recursion() {
let cg = "\
events: Time Memory
summary: 1000 0
fl=(1) test.rb
fn=(1) main
1 100 0
cfn=(2)
calls=1 1
1 900 0
fn=(2) fib
1 500 0
cfn=(2)
calls=100 1
1 400 0
";
let idx = ProfileIndex::parse(cg);
let out = idx.cmd_tree(5);
assert!(out.contains("recursive"));
assert!(out.lines().count() < 10);
}
#[test]
fn focus_filters_hotspots() {
let mut idx = ProfileIndex::parse(SAMPLE);
idx.focus = Some("Matrix".to_string());
let out = idx.cmd_hotspots(20, "");
assert!(out.contains("Matrix->multiply"));
assert!(!out.contains("buildRandom"));
assert!(!out.contains("php::mt_rand"));
}
#[test]
fn ignore_filters_hotspots() {
let mut idx = ProfileIndex::parse(SAMPLE);
idx.ignore = Some("Matrix".to_string());
let out = idx.cmd_hotspots(20, "");
assert!(!out.contains("Matrix->multiply"));
assert!(!out.contains("Matrix->set"));
assert!(out.contains("buildRandom"));
}
#[test]
fn focus_and_ignore_combined() {
let mut idx = ProfileIndex::parse(SAMPLE);
idx.focus = Some("Matrix".to_string());
idx.ignore = Some("set".to_string());
let out = idx.cmd_hotspots(20, "");
assert!(out.contains("Matrix->multiply"));
assert!(!out.contains("Matrix->set"));
assert!(!out.contains("buildRandom"));
}
#[test]
fn reset_clears_filters() {
let mut idx = ProfileIndex::parse(SAMPLE);
idx.focus = Some("Matrix".to_string());
idx.focus = None;
idx.ignore = None;
let out = idx.cmd_hotspots(20, "");
assert!(out.contains("buildRandom"));
assert!(out.contains("Matrix->multiply"));
}
#[test]
fn focus_filters_hotpath() {
let mut idx = ProfileIndex::parse(SAMPLE);
idx.focus = Some("Matrix".to_string());
let out = idx.cmd_hotpath();
for line in out.lines().skip(1) {
if let Some(name_part) = line.trim().strip_prefix("→ ") {
let name = name_part.split(" ").next().unwrap_or("");
assert!(name.contains("Matrix"), "unexpected function in focused hotpath: {}", name);
}
}
assert!(!out.contains("buildRandom"));
assert!(!out.contains("{main}"));
}
#[test]
fn focus_filters_tree() {
let mut idx = ProfileIndex::parse(SAMPLE);
idx.focus = Some("Matrix".to_string());
let out = idx.cmd_tree(5);
assert!(!out.contains("buildRandom"), "tree output: {}", out);
assert!(!out.contains("{main}"), "tree output: {}", out);
assert!(!out.contains("php::"), "tree output: {}", out);
assert!(out.contains("Matrix"), "tree output: {}", out);
}
#[test]
fn ignore_filters_hotpath() {
let mut idx = ProfileIndex::parse(SAMPLE);
idx.ignore = Some("multiply".to_string());
let out = idx.cmd_hotpath();
assert!(!out.contains("multiply"), "hotpath output: {}", out);
}
#[test]
fn ignore_filters_tree() {
let mut idx = ProfileIndex::parse(SAMPLE);
idx.ignore = Some("multiply".to_string());
let out = idx.cmd_tree(5);
assert!(!out.contains("multiply"), "tree output: {}", out);
}
mod stackprof {
use super::*;
const SAMPLE: &str = include_str!("../tests/fixtures/stackprof_callgrind_sample.out");
#[test]
fn parse_finds_all_functions() {
let idx = ProfileIndex::parse(SAMPLE);
assert_eq!(idx.functions.len(), 7);
}
#[test]
fn parse_totals() {
let idx = ProfileIndex::parse(SAMPLE);
assert_eq!(idx.total_time, 313470);
}
#[test]
fn parse_bare_name_self_time() {
let idx = ProfileIndex::parse(SAMPLE);
let fib = idx.functions.iter().find(|f| f.name == "Object#fibonacci").unwrap();
assert_eq!(fib.self_time, 16200);
}
#[test]
fn parse_bare_name_calls() {
let idx = ProfileIndex::parse(SAMPLE);
let fib = idx.functions.iter().find(|f| f.name == "Object#fibonacci").unwrap();
assert_eq!(fib.calls.len(), 1);
let self_call = &fib.calls[0];
assert_eq!(self_call.callee, "Object#fibonacci");
assert_eq!(self_call.call_count, 2624);
}
#[test]
fn parse_inclusive_time() {
let idx = ProfileIndex::parse(SAMPLE);
let fib = idx.functions.iter().find(|f| f.name == "Object#fibonacci").unwrap();
assert_eq!(fib.inclusive_time, 16200 + 262400);
}
#[test]
fn cmd_hotspots() {
let idx = ProfileIndex::parse(SAMPLE);
let out = idx.cmd_hotspots(5, "");
assert!(out.contains("Object#fibonacci"));
}
#[test]
fn cmd_flat() {
let idx = ProfileIndex::parse(SAMPLE);
let out = idx.cmd_flat(5, "");
let lines: Vec<&str> = out.lines().collect();
assert!(lines[2].contains("Object#fibonacci"));
}
#[test]
fn cmd_calls() {
let idx = ProfileIndex::parse(SAMPLE);
let out = idx.cmd_calls("fibonacci");
assert!(out.contains("Object#fibonacci"));
assert!(out.contains("2624x"));
}
#[test]
fn cmd_callers() {
let idx = ProfileIndex::parse(SAMPLE);
let out = idx.cmd_callers("fibonacci");
assert!(out.contains("block in <top (required)>"));
assert!(out.contains("Object#fibonacci"));
}
#[test]
fn cmd_inspect() {
let idx = ProfileIndex::parse(SAMPLE);
let out = idx.cmd_inspect("fibonacci");
assert!(out.contains("Object#fibonacci"));
assert!(out.contains("Self:"));
assert!(out.contains("Inclusive:"));
}
#[test]
fn cmd_search() {
let idx = ProfileIndex::parse(SAMPLE);
let out = idx.cmd_search("Object");
assert!(out.contains("Object#fibonacci"));
}
#[test]
fn cmd_stats() {
let idx = ProfileIndex::parse(SAMPLE);
let out = idx.cmd_stats("");
assert!(out.contains("Functions: 7"));
}
#[test]
fn cmd_hotpath_terminates_on_recursion() {
let idx = ProfileIndex::parse(SAMPLE);
let out = idx.cmd_hotpath();
assert!(out.contains("hottest path"), "output: {}", out);
assert!(out.lines().count() < 20, "output: {}", out);
if out.contains("Object#fibonacci") {
let fib_count = out.matches("Object#fibonacci").count();
assert!(fib_count <= 2, "fibonacci appears {} times: {}", fib_count, out);
}
}
#[test]
fn cmd_tree_terminates_on_recursion() {
let idx = ProfileIndex::parse(SAMPLE);
let out = idx.cmd_tree(5);
assert!(out.lines().count() < 30, "output: {}", out);
}
}
mod callgrind_native {
use super::*;
const SAMPLE: &str = include_str!("../tests/fixtures/callgrind_native_sample.out");
#[test]
fn parse_finds_all_functions() {
let idx = ProfileIndex::parse(SAMPLE);
assert_eq!(idx.functions.len(), 7);
}
#[test]
fn parse_totals() {
let idx = ProfileIndex::parse(SAMPLE);
assert_eq!(idx.total_time, 2473500);
}
#[test]
fn parse_self_time() {
let idx = ProfileIndex::parse(SAMPLE);
let mul = idx.functions.iter().find(|f| f.name == "matrix_multiply").unwrap();
assert_eq!(mul.self_time, 175000);
}
#[test]
fn parse_forward_ref_callee() {
let idx = ProfileIndex::parse(SAMPLE);
let main = idx.functions.iter().find(|f| f.name == "main").unwrap();
let br = main.calls.iter().find(|c| c.callee == "build_random").unwrap();
assert_eq!(br.call_count, 2);
}
#[test]
fn parse_back_ref_callee() {
let idx = ProfileIndex::parse(SAMPLE);
let mul = idx.functions.iter().find(|f| f.name == "matrix_multiply").unwrap();
let set = mul.calls.iter().find(|c| c.callee == "matrix_set").unwrap();
assert_eq!(set.call_count, 900);
}
#[test]
fn parse_recursive_calls() {
let idx = ProfileIndex::parse(SAMPLE);
let qsort = idx.functions.iter().find(|f| f.name == "qsort_compare").unwrap();
assert_eq!(qsort.calls.len(), 1);
assert_eq!(qsort.calls[0].callee, "qsort_compare");
assert_eq!(qsort.calls[0].call_count, 3100);
}
#[test]
fn cmd_hotspots() {
let idx = ProfileIndex::parse(SAMPLE);
let out = idx.cmd_hotspots(5, "");
assert!(out.contains("main"));
assert!(out.contains("qsort_compare"));
}
#[test]
fn cmd_flat() {
let idx = ProfileIndex::parse(SAMPLE);
let out = idx.cmd_flat(5, "");
assert!(out.contains("matrix_multiply"));
}
#[test]
fn cmd_calls() {
let idx = ProfileIndex::parse(SAMPLE);
let out = idx.cmd_calls("main");
assert!(out.contains("build_random"));
assert!(out.contains("matrix_multiply"));
assert!(out.contains("matrix_trace"));
}
#[test]
fn cmd_callers() {
let idx = ProfileIndex::parse(SAMPLE);
let out = idx.cmd_callers("matrix_set");
assert!(out.contains("matrix_multiply"));
assert!(out.contains("build_random"));
}
#[test]
fn cmd_inspect() {
let idx = ProfileIndex::parse(SAMPLE);
let out = idx.cmd_inspect("qsort_compare");
assert!(out.contains("Self:"));
assert!(out.contains("Callees:"));
assert!(out.contains("qsort_compare"));
}
#[test]
fn cmd_search() {
let idx = ProfileIndex::parse(SAMPLE);
let out = idx.cmd_search("matrix");
assert!(out.contains("matrix_multiply"));
assert!(out.contains("matrix_set"));
assert!(out.contains("matrix_trace"));
}
#[test]
fn cmd_stats() {
let idx = ProfileIndex::parse(SAMPLE);
let out = idx.cmd_stats("");
assert!(out.contains("Functions: 7"));
}
#[test]
fn cmd_hotpath_terminates_on_recursion() {
let idx = ProfileIndex::parse(SAMPLE);
let out = idx.cmd_hotpath();
assert!(out.contains("hottest path"), "output: {}", out);
assert!(out.lines().count() < 20, "output: {}", out);
if out.contains("qsort_compare") {
let count = out.matches("qsort_compare").count();
assert!(count <= 2, "qsort_compare appears {} times: {}", count, out);
}
}
#[test]
fn cmd_tree_terminates_on_recursion() {
let idx = ProfileIndex::parse(SAMPLE);
let out = idx.cmd_tree(5);
assert!(out.lines().count() < 30, "output: {}", out);
if out.contains("qsort_compare") {
let count = out.matches("qsort_compare").count();
assert!(count <= 3, "qsort_compare appears {} times: {}", count, out);
}
}
#[test]
fn cmd_hotpath_follows_through_forward_refs() {
let idx = ProfileIndex::parse(SAMPLE);
let out = idx.cmd_hotpath();
assert!(out.contains("main"), "output: {}", out);
let lines = out.lines().count();
assert!(lines >= 3, "hotpath too shallow ({}): {}", lines, out);
}
}
mod xdebug_recursion {
use super::*;
const RECURSIVE_PHP: &str = "\
events: Time_(10ns) Memory_(bytes)
fl=(1) /app/scan.php
fn=(1) {main}
1 500 0
cfl=(1)
cfn=(2)
calls=1 5
1 90000 40000
fl=(1)
fn=(2) scan_dir
5 3000 1000
cfl=(1)
cfn=(2)
calls=50 5
6 85000 38000
cfl=(1)
cfn=(3)
calls=200 20
10 2000 1000
fl=(1)
fn=(3) process_file
20 2000 1000
summary: 182500 80000
";
#[test]
fn parse_finds_all_functions() {
let idx = ProfileIndex::parse(RECURSIVE_PHP);
assert_eq!(idx.functions.len(), 3);
}
#[test]
fn parse_recursive_self_call() {
let idx = ProfileIndex::parse(RECURSIVE_PHP);
let scan = idx.functions.iter().find(|f| f.name == "scan_dir").unwrap();
let self_call = scan.calls.iter().find(|c| c.callee == "scan_dir").unwrap();
assert_eq!(self_call.call_count, 50);
}
#[test]
fn cmd_hotpath_terminates() {
let idx = ProfileIndex::parse(RECURSIVE_PHP);
let out = idx.cmd_hotpath();
assert!(out.contains("hottest path"), "output: {}", out);
assert!(out.contains("recursive"), "output: {}", out);
assert!(out.lines().count() < 10, "output: {}", out);
}
#[test]
fn cmd_tree_terminates() {
let idx = ProfileIndex::parse(RECURSIVE_PHP);
let out = idx.cmd_tree(5);
assert!(out.contains("recursive"), "output: {}", out);
assert!(out.lines().count() < 15, "output: {}", out);
}
#[test]
fn cmd_hotspots() {
let idx = ProfileIndex::parse(RECURSIVE_PHP);
let out = idx.cmd_hotspots(5, "");
assert!(out.contains("scan_dir"));
}
#[test]
fn cmd_calls() {
let idx = ProfileIndex::parse(RECURSIVE_PHP);
let out = idx.cmd_calls("scan_dir");
assert!(out.contains("scan_dir"));
assert!(out.contains("50x"));
assert!(out.contains("process_file"));
}
#[test]
fn cmd_callers() {
let idx = ProfileIndex::parse(RECURSIVE_PHP);
let out = idx.cmd_callers("scan_dir");
assert!(out.contains("{main}"));
assert!(out.contains("scan_dir")); }
#[test]
fn cmd_inspect() {
let idx = ProfileIndex::parse(RECURSIVE_PHP);
let out = idx.cmd_inspect("scan_dir");
assert!(out.contains("Self:"));
assert!(out.contains("Inclusive:"));
assert!(out.contains("Callees:"));
}
#[test]
fn cmd_stats() {
let idx = ProfileIndex::parse(RECURSIVE_PHP);
let out = idx.cmd_stats("");
assert!(out.contains("Functions: 3"));
}
#[test]
fn focus_on_recursive_fn_shows_hotpath() {
let mut idx = ProfileIndex::parse(RECURSIVE_PHP);
idx.focus = Some("scan_dir".to_string());
let out = idx.cmd_hotpath();
assert!(out.contains("scan_dir"), "output: {}", out);
assert!(out.contains("recursive"), "output: {}", out);
}
#[test]
fn focus_on_recursive_fn_shows_tree() {
let mut idx = ProfileIndex::parse(RECURSIVE_PHP);
idx.focus = Some("scan_dir".to_string());
let out = idx.cmd_tree(5);
assert!(out.contains("scan_dir"), "output: {}", out);
assert!(!out.contains("{main}"), "output: {}", out);
assert!(!out.contains("process_file"), "output: {}", out);
}
}
mod cross_recursion_filters {
use super::*;
const CROSS_RECURSIVE: &str = "\
events: Time_(10ns) Memory_(bytes)
fl=(1) /app/test.php
fn=(1) {main}
1 500 0
cfn=(2)
calls=10 5
1 30000 0
cfn=(4)
calls=5 20
1 50000 0
fl=(1)
fn=(2) mutual_a
5 2000 0
cfn=(3)
calls=20000 10
6 15000 0
cfn=(2)
calls=20000 10
6 13000 0
fl=(1)
fn=(3) mutual_b
10 1500 0
cfn=(2)
calls=20000 5
11 14000 0
fl=(1)
fn=(4) fibonacci
20 25000 0
cfn=(4)
calls=5000000 20
20 25000 0
summary: 176000 0
";
#[test]
fn parse_cross_recursion() {
let idx = ProfileIndex::parse(CROSS_RECURSIVE);
let a = idx.functions.iter().find(|f| f.name == "mutual_a").unwrap();
let b_call = a.calls.iter().find(|c| c.callee == "mutual_b").unwrap();
assert_eq!(b_call.call_count, 20000);
let b = idx.functions.iter().find(|f| f.name == "mutual_b").unwrap();
let a_call = b.calls.iter().find(|c| c.callee == "mutual_a").unwrap();
assert_eq!(a_call.call_count, 20000);
}
#[test]
fn hotpath_terminates_with_cross_recursion() {
let idx = ProfileIndex::parse(CROSS_RECURSIVE);
let out = idx.cmd_hotpath();
assert!(out.lines().count() < 15, "output: {}", out);
}
#[test]
fn tree_terminates_with_cross_recursion() {
let idx = ProfileIndex::parse(CROSS_RECURSIVE);
let out = idx.cmd_tree(5);
assert!(out.lines().count() < 20, "output: {}", out);
}
#[test]
fn focus_mutual_shows_cross_recursive_hotpath() {
let mut idx = ProfileIndex::parse(CROSS_RECURSIVE);
idx.focus = Some("mutual".to_string());
let out = idx.cmd_hotpath();
assert!(out.contains("mutual_a"), "output: {}", out);
assert!(out.contains("mutual_b"), "output: {}", out);
assert!(out.contains("recursive"), "output: {}", out);
assert!(!out.contains("fibonacci"), "output: {}", out);
assert!(!out.contains("{main}"), "output: {}", out);
}
#[test]
fn focus_mutual_shows_cross_recursive_tree() {
let mut idx = ProfileIndex::parse(CROSS_RECURSIVE);
idx.focus = Some("mutual".to_string());
let out = idx.cmd_tree(5);
assert!(out.contains("mutual_a"), "output: {}", out);
assert!(out.contains("mutual_b"), "output: {}", out);
assert!(!out.contains("fibonacci"), "output: {}", out);
assert!(!out.contains("{main}"), "output: {}", out);
}
#[test]
fn ignore_fibonacci_keeps_mutual() {
let mut idx = ProfileIndex::parse(CROSS_RECURSIVE);
idx.ignore = Some("fibonacci".to_string());
let out = idx.cmd_tree(5);
assert!(out.contains("mutual_a"), "output: {}", out);
assert!(!out.contains("fibonacci"), "output: {}", out);
}
#[test]
fn ignore_mutual_keeps_fibonacci() {
let mut idx = ProfileIndex::parse(CROSS_RECURSIVE);
idx.ignore = Some("mutual".to_string());
let out = idx.cmd_hotpath();
assert!(!out.contains("mutual"), "output: {}", out);
assert!(out.contains("fibonacci"), "output: {}", out);
}
}
}