use std::collections::{BTreeSet, HashMap, HashSet};
use serde::{Deserialize, Serialize};
use tree_sitter::{Node as TsNode, Tree};
use crate::ast::{api as ast_api, Language, Symbol, SymbolKind};
use crate::ResolvedHarnReference;
use super::file_table::FileId;
pub type NodeId = u32;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum NodeKind {
Function,
Type,
Field,
EnumCase,
Module,
Import,
CallSite,
Macro,
}
impl NodeKind {
pub fn as_str(self) -> &'static str {
match self {
NodeKind::Function => "Function",
NodeKind::Type => "Type",
NodeKind::Field => "Field",
NodeKind::EnumCase => "EnumCase",
NodeKind::Module => "Module",
NodeKind::Import => "Import",
NodeKind::CallSite => "CallSite",
NodeKind::Macro => "Macro",
}
}
pub const ALL: [NodeKind; 8] = [
NodeKind::Function,
NodeKind::Type,
NodeKind::Field,
NodeKind::EnumCase,
NodeKind::Module,
NodeKind::Import,
NodeKind::CallSite,
NodeKind::Macro,
];
pub fn is_name_addressable(self) -> bool {
matches!(
self,
NodeKind::Function | NodeKind::Type | NodeKind::Macro | NodeKind::Module
)
}
pub fn parse(label: &str) -> Option<Self> {
match label {
"Function" => Some(NodeKind::Function),
"Type" => Some(NodeKind::Type),
"Field" => Some(NodeKind::Field),
"EnumCase" => Some(NodeKind::EnumCase),
"Module" => Some(NodeKind::Module),
"Import" => Some(NodeKind::Import),
"CallSite" => Some(NodeKind::CallSite),
"Macro" => Some(NodeKind::Macro),
_ => None,
}
}
}
const MIN_REF_WORD_LEN: usize = 3;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum EdgeKind {
Calls,
Refs,
Imports,
Contains,
Overrides,
}
impl EdgeKind {
pub fn as_str(self) -> &'static str {
match self {
EdgeKind::Calls => "CALLS",
EdgeKind::Refs => "REFS",
EdgeKind::Imports => "IMPORTS",
EdgeKind::Contains => "CONTAINS",
EdgeKind::Overrides => "OVERRIDES",
}
}
pub fn parse_with_direction(label: &str) -> Option<(Self, bool)> {
if let Some(kind) = forward_match(label) {
return Some((kind, false));
}
match label {
"CALLED_BY" => Some((EdgeKind::Calls, true)),
"REFERENCED_BY" => Some((EdgeKind::Refs, true)),
"IMPORTED_BY" => Some((EdgeKind::Imports, true)),
"CONTAINED_BY" => Some((EdgeKind::Contains, true)),
"OVERRIDDEN_BY" => Some((EdgeKind::Overrides, true)),
_ => None,
}
}
}
fn forward_match(label: &str) -> Option<EdgeKind> {
match label {
"CALLS" => Some(EdgeKind::Calls),
"REFS" => Some(EdgeKind::Refs),
"IMPORTS" => Some(EdgeKind::Imports),
"CONTAINS" => Some(EdgeKind::Contains),
"OVERRIDES" => Some(EdgeKind::Overrides),
_ => None,
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Node {
pub id: NodeId,
pub kind: NodeKind,
pub name: String,
pub file_id: FileId,
pub path: String,
pub line: u32,
pub signature: String,
pub container: Option<String>,
pub access_level: Option<String>,
pub language: String,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct Edge {
pub from: NodeId,
pub to: NodeId,
pub kind: EdgeKind,
}
#[derive(Debug, Clone, Default)]
pub struct RebuildOutcome {
pub node_count: usize,
pub symbols: Vec<Symbol>,
}
#[derive(Debug, Default, Clone)]
pub struct SymbolGraph {
nodes: HashMap<NodeId, Node>,
by_file: HashMap<FileId, Vec<NodeId>>,
by_name: HashMap<String, Vec<NodeId>>,
out_edges: HashMap<NodeId, Vec<Edge>>,
in_edges: HashMap<NodeId, Vec<Edge>>,
next_id: NodeId,
dirty_call_names: HashSet<String>,
dirty_call_files: HashSet<FileId>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(super) struct GraphSnapshot {
pub next_id: NodeId,
pub nodes: Vec<Node>,
pub edges: Vec<Edge>,
}
impl SymbolGraph {
pub fn new() -> Self {
Self {
next_id: 1,
..Self::default()
}
}
pub(super) fn snapshot(&self) -> GraphSnapshot {
let ids = self.all_node_ids();
let mut nodes = Vec::with_capacity(ids.len());
let mut edges = Vec::with_capacity(self.edge_count());
for id in ids {
let node = &self.nodes[&id];
nodes.push(node.clone());
edges.extend(self.outgoing(id).iter().copied().filter(|edge| {
!(node.kind == NodeKind::Module
&& node.language == "harn"
&& edge.kind == EdgeKind::Refs)
}));
}
GraphSnapshot {
next_id: self.next_id,
nodes,
edges,
}
}
pub(super) fn from_snapshot(snapshot: GraphSnapshot) -> Result<Self, &'static str> {
if snapshot.next_id == 0 {
return Err("symbol graph next id is zero");
}
let mut graph = Self {
next_id: snapshot.next_id,
..Self::default()
};
for node in snapshot.nodes {
if node.id == 0 || node.id >= graph.next_id || graph.nodes.contains_key(&node.id) {
return Err("symbol graph has an invalid or duplicate node id");
}
graph.by_file.entry(node.file_id).or_default().push(node.id);
graph
.by_name
.entry(node.name.clone())
.or_default()
.push(node.id);
graph.nodes.insert(node.id, node);
}
for edge in snapshot.edges {
if !graph.nodes.contains_key(&edge.from) || !graph.nodes.contains_key(&edge.to) {
return Err("symbol graph edge names an absent node");
}
graph.out_edges.entry(edge.from).or_default().push(edge);
graph.in_edges.entry(edge.to).or_default().push(edge);
}
Ok(graph)
}
pub fn node_count(&self) -> usize {
self.nodes.len()
}
pub fn edge_count(&self) -> usize {
self.out_edges.values().map(Vec::len).sum()
}
pub fn node(&self, id: NodeId) -> Option<&Node> {
self.nodes.get(&id)
}
pub fn iter_nodes(&self) -> impl Iterator<Item = &Node> {
self.nodes.values()
}
pub fn nodes_of_kind(&self, kind: NodeKind) -> Vec<NodeId> {
let mut out: Vec<NodeId> = self
.nodes
.values()
.filter(|n| n.kind == kind)
.map(|n| n.id)
.collect();
out.sort_unstable();
out
}
pub fn all_node_ids(&self) -> Vec<NodeId> {
let mut out: Vec<NodeId> = self.nodes.keys().copied().collect();
out.sort_unstable();
out
}
pub fn nodes_named(&self, name: &str) -> &[NodeId] {
match self.by_name.get(name) {
Some(v) => v.as_slice(),
None => &[],
}
}
pub fn outgoing(&self, id: NodeId) -> &[Edge] {
self.out_edges.get(&id).map(Vec::as_slice).unwrap_or(&[])
}
pub fn incoming(&self, id: NodeId) -> &[Edge] {
self.in_edges.get(&id).map(Vec::as_slice).unwrap_or(&[])
}
pub fn file_ids(&self) -> Vec<FileId> {
let mut out: Vec<FileId> = self.by_file.keys().copied().collect();
out.sort_unstable();
out
}
pub fn remove_file(&mut self, file_id: FileId) {
self.mark_function_names_dirty(file_id);
let Some(node_ids) = self.by_file.remove(&file_id) else {
return;
};
for id in node_ids {
self.drop_node(id);
}
}
fn drop_node(&mut self, id: NodeId) {
let Some(node) = self.nodes.remove(&id) else {
return;
};
if let Some(bucket) = self.by_name.get_mut(&node.name) {
bucket.retain(|n| *n != id);
if bucket.is_empty() {
self.by_name.remove(&node.name);
}
}
if let Some(outs) = self.out_edges.remove(&id) {
for e in outs {
if let Some(bucket) = self.in_edges.get_mut(&e.to) {
bucket.retain(|edge| edge.from != id);
}
}
}
if let Some(ins) = self.in_edges.remove(&id) {
for e in ins {
if let Some(bucket) = self.out_edges.get_mut(&e.from) {
bucket.retain(|edge| edge.to != id);
}
}
}
}
pub fn rebuild_file(
&mut self,
file_id: FileId,
path: &str,
language: Language,
source: &str,
import_strings: &[String],
imported_files: &[FileId],
) -> RebuildOutcome {
let visible: HashSet<FileId> = imported_files.iter().copied().collect();
self.rebuild_file_resolving(
file_id,
path,
language,
source,
import_strings,
Some(&visible),
)
}
pub(super) fn rebuild_file_deferring_calls(
&mut self,
file_id: FileId,
path: &str,
language: Language,
source: &str,
import_strings: &[String],
) -> RebuildOutcome {
self.dirty_call_files.insert(file_id);
self.rebuild_file_resolving(file_id, path, language, source, import_strings, None)
}
fn rebuild_file_resolving(
&mut self,
file_id: FileId,
path: &str,
language: Language,
source: &str,
import_strings: &[String],
visible: Option<&HashSet<FileId>>,
) -> RebuildOutcome {
self.remove_file(file_id);
let module_id = self.add_module_for_file(file_id, path, &language);
let (tree, symbols) = match ast_api::parse_with_symbols(source, language) {
Ok((t, s)) => (Some(t), s),
Err(err) => {
tracing::debug!(
"code_index: tree-sitter parse failed for `{path}`: {err}; \
symbol graph slice will be Module-only"
);
(None, Vec::new())
}
};
let mut container_ids: HashMap<String, NodeId> = HashMap::new();
for sym in &symbols {
let Some(kind) = map_symbol_kind(sym.kind) else {
continue;
};
let id = self.add_node(Node {
id: 0,
kind,
name: sym.name.clone(),
file_id,
path: path.to_string(),
line: sym.start_row.saturating_add(1),
signature: sym.signature.clone(),
container: sym.container.clone(),
access_level: sym.access_level.clone(),
language: language.name().to_string(),
});
if matches!(kind, NodeKind::Type | NodeKind::Module) {
container_ids.insert(sym.name.clone(), id);
}
let parent_id = sym
.container
.as_deref()
.and_then(|c| container_ids.get(c).copied())
.unwrap_or(module_id);
self.add_edge(parent_id, id, EdgeKind::Contains);
}
if let Some(tree) = tree.as_ref() {
for (callee_name, line) in extract_call_sites_from_tree(tree, source) {
let call_id = self.add_node(Node {
id: 0,
kind: NodeKind::CallSite,
name: callee_name.clone(),
file_id,
path: path.to_string(),
line,
signature: format!("{callee_name}(…)"),
container: None,
access_level: None,
language: language.name().to_string(),
});
self.add_edge(module_id, call_id, EdgeKind::Contains);
if let Some(visible) = visible {
let functions = self.functions_named(&callee_name);
for t in pick_call_targets(&functions, file_id, visible) {
self.add_edge(call_id, t, EdgeKind::Calls);
}
}
}
}
self.mark_function_names_dirty(file_id);
for raw in import_strings {
let imp_id = self.add_node(Node {
id: 0,
kind: NodeKind::Import,
name: raw.clone(),
file_id,
path: path.to_string(),
line: 1,
signature: format!("import {raw}"),
container: None,
access_level: None,
language: language.name().to_string(),
});
self.add_edge(module_id, imp_id, EdgeKind::Imports);
}
if language.name() != "harn" {
for target in self.collect_cross_file_refs(source, file_id) {
self.add_edge(module_id, target, EdgeKind::Refs);
}
}
let node_count = self.by_file.get(&file_id).map(Vec::len).unwrap_or_default();
RebuildOutcome {
node_count,
symbols,
}
}
pub fn link_imports(&mut self, resolved: &HashMap<FileId, Vec<FileId>>) {
for (src_file, targets) in resolved {
let Some(src_module) = self.module_node_for_file(*src_file) else {
continue;
};
for tgt_file in targets {
let Some(tgt_module) = self.module_node_for_file(*tgt_file) else {
continue;
};
let already_linked = self.out_edges.get(&src_module).is_some_and(|edges| {
edges
.iter()
.any(|e| e.to == tgt_module && e.kind == EdgeKind::Imports)
});
if !already_linked {
self.add_edge(src_module, tgt_module, EdgeKind::Imports);
}
}
}
}
pub fn replace_harn_references(&mut self, references: &[ResolvedHarnReference]) {
let harn_modules: BTreeSet<NodeId> = self
.nodes
.values()
.filter(|node| node.kind == NodeKind::Module && node.language == "harn")
.map(|node| node.id)
.collect();
for module in &harn_modules {
if let Some(edges) = self.out_edges.get_mut(module) {
let removed: Vec<Edge> = edges
.iter()
.copied()
.filter(|edge| edge.kind == EdgeKind::Refs)
.collect();
edges.retain(|edge| edge.kind != EdgeKind::Refs);
for edge in removed {
if let Some(incoming) = self.in_edges.get_mut(&edge.to) {
incoming.retain(|candidate| {
!(candidate.from == edge.from
&& candidate.to == edge.to
&& candidate.kind == EdgeKind::Refs)
});
}
}
}
}
for reference in references {
let Some(from) = self
.nodes
.values()
.find(|node| {
node.kind == NodeKind::Module
&& node.language == "harn"
&& node.path == reference.from_path
})
.map(|node| node.id)
else {
continue;
};
let targets: Vec<NodeId> = self
.nodes
.values()
.filter(|node| {
node.path == reference.to_path
&& node.name == reference.to_name
&& node.kind != NodeKind::Module
})
.map(|node| node.id)
.collect();
for target in targets {
let duplicate = self
.outgoing(from)
.iter()
.any(|edge| edge.kind == EdgeKind::Refs && edge.to == target);
if !duplicate {
self.add_edge(from, target, EdgeKind::Refs);
}
}
}
}
pub fn module_node_for_file(&self, file_id: FileId) -> Option<NodeId> {
let ids = self.by_file.get(&file_id)?;
ids.iter().copied().find(|id| {
self.nodes
.get(id)
.is_some_and(|n| matches!(n.kind, NodeKind::Module))
})
}
pub fn link_refs_into_file(
&mut self,
file_id: FileId,
files_naming: impl Fn(&str) -> Vec<FileId>,
) {
let Some(ids) = self.by_file.get(&file_id) else {
return;
};
let targets: Vec<(NodeId, String)> = ids
.iter()
.filter_map(|id| self.nodes.get(id))
.filter(|n| n.kind.is_name_addressable() && n.name.len() >= MIN_REF_WORD_LEN)
.map(|n| (n.id, n.name.clone()))
.collect();
for (target, name) in targets {
let files: BTreeSet<FileId> = files_naming(&name).into_iter().collect();
let mut linked: HashSet<NodeId> = self
.incoming(target)
.iter()
.filter(|e| e.kind == EdgeKind::Refs)
.map(|e| e.from)
.collect();
for file in files {
if file == file_id {
continue;
}
let Some(module) = self.module_node_for_file(file) else {
continue;
};
if self
.nodes
.get(&module)
.is_some_and(|m| m.language == "harn")
{
continue;
}
if linked.insert(module) {
self.add_edge(module, target, EdgeKind::Refs);
}
}
}
}
fn collect_cross_file_refs(&self, source: &str, this_file: FileId) -> BTreeSet<NodeId> {
let mut out: BTreeSet<NodeId> = BTreeSet::new();
if self.by_name.is_empty() {
return out;
}
super::words::tokenize(source, |word| {
self.absorb_word_refs(word, this_file, &mut out);
});
out
}
fn functions_named(&self, name: &str) -> Vec<(NodeId, FileId)> {
self.nodes_named(name)
.iter()
.filter_map(|id| self.nodes.get(id))
.filter(|n| n.kind == NodeKind::Function)
.map(|n| (n.id, n.file_id))
.collect()
}
fn mark_function_names_dirty(&mut self, file_id: FileId) {
let Some(ids) = self.by_file.get(&file_id) else {
return;
};
for id in ids {
if let Some(node) = self.nodes.get(id) {
if node.kind == NodeKind::Function {
self.dirty_call_names.insert(node.name.clone());
}
}
}
}
pub fn take_dirty_call_sites(&mut self) -> Vec<NodeId> {
let names = std::mem::take(&mut self.dirty_call_names);
let files = std::mem::take(&mut self.dirty_call_files);
let mut sites: BTreeSet<NodeId> = BTreeSet::new();
for file in files {
for id in self.by_file.get(&file).map(Vec::as_slice).unwrap_or(&[]) {
if self
.nodes
.get(id)
.is_some_and(|n| n.kind == NodeKind::CallSite)
{
sites.insert(*id);
}
}
}
for name in names {
for id in self.nodes_named(&name) {
if self
.nodes
.get(id)
.is_some_and(|n| n.kind == NodeKind::CallSite)
{
sites.insert(*id);
}
}
}
sites.into_iter().collect()
}
pub fn resolve_call_sites(
&mut self,
sites: &[NodeId],
visible: &HashMap<FileId, HashSet<FileId>>,
) {
let empty = HashSet::new();
let mut functions_by_name: HashMap<String, Vec<(NodeId, FileId)>> = HashMap::new();
for site in sites {
let Some((name, file_id)) = self
.nodes
.get(site)
.filter(|n| n.kind == NodeKind::CallSite)
.map(|n| (n.name.clone(), n.file_id))
else {
continue;
};
self.drop_out_edges(*site, EdgeKind::Calls);
let functions = functions_by_name
.entry(name)
.or_insert_with_key(|name| self.functions_named(name));
let targets =
pick_call_targets(functions, file_id, visible.get(&file_id).unwrap_or(&empty));
for target in targets {
self.add_edge(*site, target, EdgeKind::Calls);
}
}
}
fn drop_out_edges(&mut self, from: NodeId, kind: EdgeKind) {
let Some(edges) = self.out_edges.get_mut(&from) else {
return;
};
let mut dropped = Vec::new();
edges.retain(|edge| {
let keep = edge.kind != kind;
if !keep {
dropped.push(edge.to);
}
keep
});
for to in dropped {
if let Some(bucket) = self.in_edges.get_mut(&to) {
bucket.retain(|edge| !(edge.from == from && edge.kind == kind));
}
}
}
fn absorb_word_refs(&self, word: &str, this_file: FileId, bag: &mut BTreeSet<NodeId>) {
if word.len() < MIN_REF_WORD_LEN {
return;
}
let Some(ids) = self.by_name.get(word) else {
return;
};
for nid in ids {
let Some(node) = self.nodes.get(nid) else {
continue;
};
if node.file_id == this_file || !node.kind.is_name_addressable() {
continue;
}
bag.insert(*nid);
}
}
fn add_module_for_file(&mut self, file_id: FileId, path: &str, language: &Language) -> NodeId {
let name = module_name_from_path(path);
self.add_node(Node {
id: 0,
kind: NodeKind::Module,
name,
file_id,
path: path.to_string(),
line: 1,
signature: format!("module {path}"),
container: None,
access_level: None,
language: language.name().to_string(),
})
}
fn add_node(&mut self, mut node: Node) -> NodeId {
let id = self.next_id;
self.next_id = self.next_id.checked_add(1).expect("NodeId overflow");
node.id = id;
self.by_file.entry(node.file_id).or_default().push(id);
self.by_name.entry(node.name.clone()).or_default().push(id);
self.nodes.insert(id, node);
id
}
fn add_edge(&mut self, from: NodeId, to: NodeId, kind: EdgeKind) {
let edge = Edge { from, to, kind };
self.out_edges.entry(from).or_default().push(edge);
self.in_edges.entry(to).or_default().push(edge);
}
}
fn pick_call_targets(
functions: &[(NodeId, FileId)],
file_id: FileId,
visible: &HashSet<FileId>,
) -> Vec<NodeId> {
let local: Vec<NodeId> = functions
.iter()
.filter(|(_, file)| *file == file_id)
.map(|(id, _)| *id)
.collect();
if !local.is_empty() {
return local;
}
let imported: Vec<NodeId> = functions
.iter()
.filter(|(_, file)| visible.contains(file))
.map(|(id, _)| *id)
.collect();
if !imported.is_empty() {
return imported;
}
if functions.len() == 1 {
return vec![functions[0].0];
}
Vec::new()
}
pub fn module_name_from_path(path: &str) -> String {
let stem = path.rsplit_once('/').map(|(_, name)| name).unwrap_or(path);
let base = stem.rsplit_once('.').map(|(name, _)| name).unwrap_or(stem);
base.to_string()
}
fn map_symbol_kind(kind: SymbolKind) -> Option<NodeKind> {
match kind {
SymbolKind::Function | SymbolKind::Method => Some(NodeKind::Function),
SymbolKind::Field => Some(NodeKind::Field),
SymbolKind::EnumCase => Some(NodeKind::EnumCase),
SymbolKind::Class
| SymbolKind::Struct
| SymbolKind::Enum
| SymbolKind::Interface
| SymbolKind::Protocol
| SymbolKind::Type => Some(NodeKind::Type),
SymbolKind::Module => Some(NodeKind::Module),
SymbolKind::Variable | SymbolKind::Other => None,
}
}
fn extract_call_sites_from_tree(tree: &Tree, source: &str) -> Vec<(String, u32)> {
let mut out: Vec<(String, u32)> = Vec::new();
let mut cursor = tree.root_node().walk();
let mut stack: Vec<TsNode<'_>> = vec![tree.root_node()];
while let Some(node) = stack.pop() {
if is_call_kind(node.kind()) {
if let Some(name) = call_callee_name(node, source) {
let line = node.start_position().row as u32 + 1;
out.push((name, line));
}
}
for child in node.children(&mut cursor) {
stack.push(child);
}
}
out
}
fn is_call_kind(kind: &str) -> bool {
matches!(
kind,
"call_expression"
| "call"
| "function_call"
| "method_invocation"
| "method_call_expression"
| "invocation_expression"
| "function_call_expression"
| "macro_invocation"
)
}
fn call_callee_name(node: TsNode<'_>, source: &str) -> Option<String> {
let callee = node
.child_by_field_name("function")
.or_else(|| node.child_by_field_name("name"))
.or_else(|| node.child_by_field_name("method"))
.or_else(|| node.child(0u32))?;
#[expect(
clippy::string_slice,
reason = "tree-sitter node byte ranges lie on char boundaries of the parsed source"
)]
let text = &source[callee.start_byte()..callee.end_byte()];
let last = text.rsplit_once(['.', ':', '!']);
let raw = last.map(|(_, name)| name).unwrap_or(text);
let trimmed = raw.trim();
let plain: String = trimmed
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect();
if plain.is_empty() {
None
} else {
Some(plain)
}
}
#[cfg(test)]
#[path = "symbol_graph_tests.rs"]
mod tests;