use crate::calcit::Calcit;
use crate::program::{PROGRAM_CODE_DATA, ProgramCodeData};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CallTreeNode {
pub ns: String,
pub def: String,
pub fqn: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub doc: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub calls: Vec<CallTreeNode>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub circular: bool,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub seen: bool,
pub source: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CallTreeResult {
pub entry: String,
pub tree: CallTreeNode,
pub stats: CallTreeStats,
#[serde(skip_serializing_if = "Option::is_none")]
pub unused_definitions: Option<Vec<UnusedDefinition>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CallTreeStats {
pub reachable_count: usize,
pub circular_count: usize,
pub project_defs: usize,
pub core_defs: usize,
pub max_depth: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UnusedDefinition {
pub ns: String,
pub def: String,
pub fqn: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub doc: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct CallTreeConfig {
pub include_core: bool,
pub max_depth: usize,
pub show_unused: bool,
pub package_name: Option<String>,
pub ns_prefix: Option<String>,
}
pub struct CallTreeAnalyzer {
config: CallTreeConfig,
visited: HashSet<String>,
expanded: HashMap<String, bool>,
reachable: HashSet<String>,
circular_count: usize,
max_depth: usize,
}
impl CallTreeAnalyzer {
pub fn new(config: CallTreeConfig) -> Self {
CallTreeAnalyzer {
config,
visited: HashSet::new(),
expanded: HashMap::new(),
reachable: HashSet::new(),
circular_count: 0,
max_depth: 0,
}
}
pub fn analyze(&mut self, entry_ns: &str, entry_def: &str) -> Result<CallTreeResult, String> {
let fqn = format!("{entry_ns}/{entry_def}");
let mut tree = self.build_tree(entry_ns, entry_def, 0)?;
if let Some(ref prefix) = self.config.ns_prefix {
tree = prune_tree_by_ns_prefix(tree, prefix);
}
let program_code = PROGRAM_CODE_DATA.read().map_err(|e| format!("Failed to read program code: {e}"))?;
let (reachable_count, project_defs, core_defs, circular_count, max_depth) = if self.config.ns_prefix.is_some() {
compute_stats_from_tree(&tree, |ns| self.is_core_ns(ns))
} else {
let (project_defs, core_defs) = self.count_def_types(&program_code);
(self.reachable.len(), project_defs, core_defs, self.circular_count, self.max_depth)
};
let stats = CallTreeStats {
reachable_count,
circular_count,
project_defs,
core_defs,
max_depth,
};
let unused_definitions = if self.config.show_unused {
Some(self.find_unused_definitions(&program_code))
} else {
None
};
Ok(CallTreeResult {
entry: fqn,
tree,
stats,
unused_definitions,
})
}
fn build_tree(&mut self, ns: &str, def: &str, depth: usize) -> Result<CallTreeNode, String> {
let fqn = format!("{ns}/{def}");
if depth > self.max_depth {
self.max_depth = depth;
}
if self.config.max_depth > 0 && depth >= self.config.max_depth {
return Ok(CallTreeNode {
ns: ns.to_string(),
def: def.to_string(),
fqn: fqn.clone(),
doc: None,
calls: vec![],
circular: false,
seen: false,
source: self.get_source_type(ns),
});
}
if self.visited.contains(&fqn) {
self.circular_count += 1;
return Ok(CallTreeNode {
ns: ns.to_string(),
def: def.to_string(),
fqn: fqn.clone(),
doc: None,
calls: vec![],
circular: true,
seen: false,
source: self.get_source_type(ns),
});
}
if let Some(&had_children) = self.expanded.get(&fqn) {
return Ok(CallTreeNode {
ns: ns.to_string(),
def: def.to_string(),
fqn: fqn.clone(),
doc: None,
calls: vec![],
circular: false,
seen: had_children,
source: self.get_source_type(ns),
});
}
self.visited.insert(fqn.clone());
self.reachable.insert(fqn.clone());
let program_code = PROGRAM_CODE_DATA.read().map_err(|e| format!("Failed to read program code: {e}"))?;
let (code_entry, doc) = match program_code.get(ns) {
Some(file_data) => match file_data.defs.get(def) {
Some(entry) => {
let doc = if entry.doc.is_empty() { None } else { Some(entry.doc.to_string()) };
(Some(&entry.code), doc)
}
None => (None, None),
},
None => (None, None),
};
let mut calls = vec![];
if let Some(code) = code_entry {
let call_refs = self.extract_calls(code, ns);
drop(program_code);
for (call_ns, call_def) in call_refs {
if !self.config.include_core && self.is_core_ns(&call_ns) {
continue;
}
if call_ns == ns && call_def == def {
continue;
}
let child_tree = self.build_tree(&call_ns, &call_def, depth + 1)?;
calls.push(child_tree);
}
} else {
drop(program_code);
}
self.visited.remove(&fqn);
self.expanded.insert(fqn.clone(), !calls.is_empty());
Ok(CallTreeNode {
ns: ns.to_string(),
def: def.to_string(),
fqn,
doc,
calls,
circular: false,
seen: false,
source: self.get_source_type(ns),
})
}
fn extract_calls(&self, code: &Calcit, current_ns: &str) -> Vec<(String, String)> {
let mut calls = vec![];
Self::extract_calls_recursive(code, current_ns, &mut calls);
let mut seen = HashSet::new();
calls.retain(|item| seen.insert(item.clone()));
calls
}
fn extract_calls_recursive(code: &Calcit, current_ns: &str, calls: &mut Vec<(String, String)>) {
match code {
Calcit::Import(import) => {
calls.push((import.ns.to_string(), import.def.to_string()));
}
Calcit::Symbol { sym, info, .. } => {
let program_code = PROGRAM_CODE_DATA.read().ok();
if let Some(ref code_data) = program_code {
let mut found = false;
if let Some(file_data) = code_data.get(current_ns) {
if file_data.defs.contains_key(sym.as_ref()) {
calls.push((current_ns.to_string(), sym.to_string()));
found = true;
}
}
if !found {
if let Some(file_data) = code_data.get(info.at_ns.as_ref()) {
if let Some(import_rule) = file_data.import_map.get(sym.as_ref()) {
match &**import_rule {
crate::program::ImportRule::NsReferDef(ns, def) => {
calls.push((ns.to_string(), def.to_string()));
found = true;
}
crate::program::ImportRule::NsAs(ns) => {
let _ = ns;
}
crate::program::ImportRule::NsDefault(ns) => {
calls.push((ns.to_string(), "default".to_string()));
found = true;
}
}
}
}
}
if !found && current_ns != "calcit.core" {
if let Some(core_data) = code_data.get("calcit.core") {
if core_data.defs.contains_key(sym.as_ref()) {
calls.push(("calcit.core".to_string(), sym.to_string()));
}
}
}
}
}
Calcit::List(list) => {
list
.traverse_result::<String>(&mut |item| {
Self::extract_calls_recursive(item, current_ns, calls);
Ok(())
})
.ok();
}
Calcit::Fn { info, .. } => {
for expr in info.body.iter() {
Self::extract_calls_recursive(expr, current_ns, calls);
}
}
Calcit::Macro { info, .. } => {
for expr in info.body.iter() {
Self::extract_calls_recursive(expr, current_ns, calls);
}
}
Calcit::Thunk(thunk) => match thunk {
crate::calcit::CalcitThunk::Code { code, .. } => {
Self::extract_calls_recursive(code, current_ns, calls);
}
},
Calcit::Tuple(tuple) => {
for item in &tuple.extra {
Self::extract_calls_recursive(item, current_ns, calls);
}
}
Calcit::Map(map) => {
for (k, v) in map.iter() {
Self::extract_calls_recursive(k, current_ns, calls);
Self::extract_calls_recursive(v, current_ns, calls);
}
}
Calcit::Set(set) => {
for item in set.iter() {
Self::extract_calls_recursive(item, current_ns, calls);
}
}
_ => {}
}
}
fn get_source_type(&self, ns: &str) -> String {
if self.is_core_ns(ns) {
"core".to_string()
} else if let Some(ref pkg) = self.config.package_name {
if ns.starts_with(pkg) || ns.starts_with(&format!("{pkg}.")) {
"project".to_string()
} else {
"external".to_string()
}
} else {
"project".to_string()
}
}
fn is_core_ns(&self, ns: &str) -> bool {
ns.starts_with("calcit.") || ns == "calcit.core"
}
fn count_def_types(&self, _program_code: &ProgramCodeData) -> (usize, usize) {
let mut project = 0;
let mut core = 0;
for fqn in &self.reachable {
if let Some((ns, _)) = fqn.split_once('/') {
if self.is_core_ns(ns) {
core += 1;
} else {
project += 1;
}
}
}
(project, core)
}
fn find_unused_definitions(&self, program_code: &ProgramCodeData) -> Vec<UnusedDefinition> {
let mut unused = vec![];
for (ns, file_data) in program_code.iter() {
if self.is_core_ns(ns) {
continue;
}
if let Some(ref pkg) = self.config.package_name {
if !ns.starts_with(pkg) && !ns.starts_with(&format!("{pkg}.")) {
continue;
}
}
if let Some(ref prefix) = self.config.ns_prefix {
if !ns.starts_with(prefix) {
continue;
}
}
for (def, entry) in &file_data.defs {
let fqn = format!("{ns}/{def}");
if !self.reachable.contains(&fqn) {
unused.push(UnusedDefinition {
ns: ns.to_string(),
def: def.to_string(),
fqn,
doc: if entry.doc.is_empty() { None } else { Some(entry.doc.to_string()) },
});
}
}
}
unused.sort_by(|a, b| match a.ns.cmp(&b.ns) {
std::cmp::Ordering::Equal => a.def.cmp(&b.def),
other => other,
});
unused
}
}
fn prune_tree_by_ns_prefix(mut node: CallTreeNode, prefix: &str) -> CallTreeNode {
let children = std::mem::take(&mut node.calls);
let pruned_children: Vec<CallTreeNode> = children
.into_iter()
.map(|child| prune_tree_by_ns_prefix(child, prefix))
.filter(|child| tree_contains_ns_prefix(child, prefix))
.collect();
node.calls = pruned_children;
node
}
fn tree_contains_ns_prefix(node: &CallTreeNode, prefix: &str) -> bool {
if node.ns.starts_with(prefix) {
return true;
}
for c in &node.calls {
if tree_contains_ns_prefix(c, prefix) {
return true;
}
}
false
}
fn compute_stats_from_tree<F>(root: &CallTreeNode, is_core_ns: F) -> (usize, usize, usize, usize, usize)
where
F: Fn(&str) -> bool,
{
use std::collections::HashSet;
struct WalkState {
visited: HashSet<String>,
project: usize,
core: usize,
circular: usize,
max_depth: usize,
}
fn walk<F>(n: &CallTreeNode, depth: usize, state: &mut WalkState, is_core_ns: &F)
where
F: Fn(&str) -> bool,
{
if depth > state.max_depth {
state.max_depth = depth;
}
if n.circular {
state.circular += 1;
}
if !state.visited.contains(&n.fqn) {
state.visited.insert(n.fqn.clone());
if is_core_ns(&n.ns) {
state.core += 1;
} else {
state.project += 1;
}
}
for c in &n.calls {
walk(c, depth + 1, state, is_core_ns);
}
}
let mut state = WalkState {
visited: HashSet::new(),
project: 0,
core: 0,
circular: 0,
max_depth: 0,
};
walk(root, 0, &mut state, &is_core_ns);
(state.visited.len(), state.project, state.core, state.circular, state.max_depth)
}
pub fn format_for_llm(result: &CallTreeResult) -> String {
let mut output = String::new();
output.push_str("# Call Tree Analysis\n\n");
output.push_str(&format!("**Entry Point:** `{}`\n\n", result.entry));
output.push_str("## Statistics\n\n");
output.push_str(&format!("- Reachable definitions: {}\n", result.stats.reachable_count));
output.push_str(&format!("- Project definitions: {}\n", result.stats.project_defs));
output.push_str(&format!("- Core/library definitions: {}\n", result.stats.core_defs));
output.push_str(&format!("- Maximum call depth: {}\n", result.stats.max_depth));
if result.stats.circular_count > 0 {
output.push_str(&format!("- Circular references: {}\n", result.stats.circular_count));
}
output.push('\n');
output.push_str("## Call Tree Structure\n\n");
output.push_str("```\n");
format_tree_node(&result.tree, &mut output, "", true);
output.push_str("```\n\n");
if let Some(ref unused) = result.unused_definitions {
output.push_str("## Unused Definitions\n\n");
if unused.is_empty() {
output.push_str("No unused definitions found.\n");
} else {
output.push_str(&format!("Found {} unused definition(s):\n\n", unused.len()));
for def in unused {
output.push_str(&format!("- `{}`", def.fqn));
if let Some(ref doc) = def.doc {
output.push_str(&format!(" - {doc}"));
}
output.push('\n');
}
}
}
output
}
fn format_tree_node(node: &CallTreeNode, output: &mut String, prefix: &str, is_last: bool) {
let connector = if is_last { "└── " } else { "├── " };
let marker = if node.circular {
" [CIRCULAR]"
} else if node.seen {
" [seen]"
} else if node.source == "core" {
" [core]"
} else if node.source == "external" {
" [ext]"
} else {
""
};
output.push_str(&format!("{}{}{}{}\n", prefix, connector, node.fqn, marker));
let child_prefix = format!("{}{} ", prefix, if is_last { " " } else { "│" });
for (i, child) in node.calls.iter().enumerate() {
let is_last_child = i == node.calls.len() - 1;
format_tree_node(child, output, &child_prefix, is_last_child);
}
}
pub fn format_as_json(result: &CallTreeResult) -> Result<String, String> {
serde_json::to_string_pretty(result).map_err(|e| format!("Failed to serialize to JSON: {e}"))
}
pub fn analyze_call_graph(
entry_ns: &str,
entry_def: &str,
include_core: bool,
max_depth: usize,
show_unused: bool,
package_name: Option<String>,
ns_prefix: Option<String>,
) -> Result<CallTreeResult, String> {
let config = CallTreeConfig {
include_core,
max_depth,
show_unused,
package_name,
ns_prefix,
};
let mut analyzer = CallTreeAnalyzer::new(config);
analyzer.analyze(entry_ns, entry_def)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CallCountResult {
pub entry: String,
pub counts: Vec<CallCountEntry>,
pub total_definitions: usize,
pub total_calls: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CallCountEntry {
pub fqn: String,
pub ns: String,
pub def: String,
pub count: usize,
pub source: String,
pub has_doc: bool,
pub examples_count: usize,
}
struct CallCountAnalyzer {
include_core: bool,
ns_prefix: Option<String>,
visited: HashSet<String>,
call_counts: HashMap<String, usize>,
}
impl CallCountAnalyzer {
fn new(include_core: bool, ns_prefix: Option<String>) -> Self {
CallCountAnalyzer {
include_core,
ns_prefix,
visited: HashSet::new(),
call_counts: HashMap::new(),
}
}
fn analyze(&mut self, entry_ns: &str, entry_def: &str) -> Result<CallCountResult, String> {
let fqn = format!("{entry_ns}/{entry_def}");
let entry_is_core = self.is_core_ns(entry_ns);
let entry_matches_core = self.include_core || !entry_is_core;
let entry_matches_prefix = self.ns_prefix.as_ref().map(|p| entry_ns.starts_with(p)).unwrap_or(true);
if entry_matches_core && entry_matches_prefix {
*self.call_counts.entry(fqn.clone()).or_insert(0) += 1;
}
self.traverse(entry_ns, entry_def)?;
let program_code = PROGRAM_CODE_DATA.read().map_err(|e| format!("Failed to read program code: {e}"))?;
let mut counts: Vec<CallCountEntry> = self
.call_counts
.iter()
.map(|(fqn, &count)| {
let (ns, def) = fqn.split_once('/').unwrap_or(("", fqn));
let (has_doc, examples_count) = program_code
.get(ns)
.and_then(|file_data| file_data.defs.get(def))
.map(|code_entry| {
let has_doc = !code_entry.doc.is_empty();
let examples_count = code_entry.examples.len();
(has_doc, examples_count)
})
.unwrap_or((false, 0));
CallCountEntry {
fqn: fqn.clone(),
ns: ns.to_string(),
def: def.to_string(),
count,
source: self.get_source_type(ns),
has_doc,
examples_count,
}
})
.collect();
counts.sort_by(|a, b| b.count.cmp(&a.count));
let total_calls: usize = counts.iter().map(|e| e.count).sum();
Ok(CallCountResult {
entry: format!("{entry_ns}/{entry_def}"),
total_definitions: counts.len(),
total_calls,
counts,
})
}
fn traverse(&mut self, ns: &str, def: &str) -> Result<(), String> {
let fqn = format!("{ns}/{def}");
if self.visited.contains(&fqn) {
return Ok(());
}
self.visited.insert(fqn.clone());
let program_code = PROGRAM_CODE_DATA.read().map_err(|e| format!("Failed to read program code: {e}"))?;
let code_entry = match program_code.get(ns) {
Some(file_data) => file_data.defs.get(def).map(|e| &e.code),
None => None,
};
if let Some(code) = code_entry {
let call_refs = self.extract_calls(code, ns);
drop(program_code);
for (call_ns, call_def) in call_refs {
let is_core = self.is_core_ns(&call_ns);
let matches_core = self.include_core || !is_core;
let matches_prefix = self.ns_prefix.as_ref().map(|p| call_ns.starts_with(p)).unwrap_or(true);
if matches_core && matches_prefix {
let call_fqn = format!("{call_ns}/{call_def}");
*self.call_counts.entry(call_fqn).or_insert(0) += 1;
}
self.traverse(&call_ns, &call_def)?;
}
}
Ok(())
}
fn extract_calls(&self, code: &Calcit, current_ns: &str) -> Vec<(String, String)> {
let mut calls = vec![];
CallTreeAnalyzer::extract_calls_recursive(code, current_ns, &mut calls);
calls
}
fn is_core_ns(&self, ns: &str) -> bool {
ns.starts_with("calcit.") || ns == "calcit.core"
}
fn get_source_type(&self, ns: &str) -> String {
if self.is_core_ns(ns) {
"core".to_string()
} else {
"project".to_string()
}
}
}
pub fn count_calls(entry_ns: &str, entry_def: &str, include_core: bool, ns_prefix: Option<String>) -> Result<CallCountResult, String> {
let mut analyzer = CallCountAnalyzer::new(include_core, ns_prefix);
analyzer.analyze(entry_ns, entry_def)
}
pub fn format_count_for_display(result: &CallCountResult, sort: &str) -> String {
let mut output = String::new();
output.push_str("# Call Count Analysis\n\n");
output.push_str(&format!("**Entry Point:** `{}`\n\n", result.entry));
output.push_str(&format!("**Total Definitions:** {}\n", result.total_definitions));
output.push_str(&format!("**Total Calls:** {}\n\n", result.total_calls));
output.push_str("## Call Counts\n\n");
output.push_str("| Count | Definition | Doc | Examples |\n");
output.push_str("|------:|:-----------------------------------------|:---:|:--------:|\n");
let mut counts = result.counts.clone();
match sort {
"name" => counts.sort_by(|a, b| a.fqn.cmp(&b.fqn)),
_ => counts.sort_by(|a, b| b.count.cmp(&a.count)),
}
for entry in &counts {
let doc_mark = if entry.has_doc { "✓" } else { " " };
let examples_str = if entry.examples_count > 0 {
format!("{:>8}", entry.examples_count)
} else {
format!("{:>8}", "")
};
let count_str = format!("{:>5}", entry.count);
let def_str = format!("{:<40}", entry.fqn);
output.push_str(&format!("| {count_str} | {def_str} | {doc_mark} | {examples_str} |\n"));
}
output
}
pub fn format_count_as_json(result: &CallCountResult) -> Result<String, String> {
serde_json::to_string_pretty(result).map_err(|e| format!("Failed to serialize to JSON: {e}"))
}