use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use stack_graphs::arena::Handle;
use stack_graphs::graph::{File, Node, StackGraph};
use stack_graphs::partial::{PartialPath, PartialPaths};
use stack_graphs::stitching::{
Database, DatabaseCandidates, ForwardCandidates, ForwardPartialPathStitcher, StitcherConfig,
};
use tree_sitter_stack_graphs::{NoCancellation, StackGraphLanguage, Variables};
pub static RUST_TSG: &str = include_str!("../../rules/rust/stack-graphs.tsg");
pub static PYTHON_TSG: &str = include_str!("../../rules/python/stack-graphs.tsg");
pub static TYPESCRIPT_TSG: &str = include_str!("../../rules/typescript/stack-graphs.tsg");
pub fn load_rust_language() -> anyhow::Result<StackGraphLanguage> {
let language = tree_sitter_rust::LANGUAGE;
let sgl = StackGraphLanguage::from_str(language.into(), RUST_TSG)
.map_err(|e| anyhow::anyhow!("Failed to parse Rust TSG rules: {e}"))?;
Ok(sgl)
}
pub fn load_python_language() -> anyhow::Result<StackGraphLanguage> {
let language = tree_sitter_python::LANGUAGE;
let sgl = StackGraphLanguage::from_str(language.into(), PYTHON_TSG)
.map_err(|e| anyhow::anyhow!("Failed to parse Python TSG rules: {e}"))?;
Ok(sgl)
}
pub fn load_typescript_language() -> anyhow::Result<StackGraphLanguage> {
let language = tree_sitter_typescript::LANGUAGE_TYPESCRIPT;
let sgl = StackGraphLanguage::from_str(language.into(), TYPESCRIPT_TSG)
.map_err(|e| anyhow::anyhow!("Failed to parse TypeScript TSG rules: {e}"))?;
Ok(sgl)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedDefinition {
pub symbol_name: String,
pub file_path: PathBuf,
pub syntax_type: Option<String>,
pub start_line: u32,
pub start_col: u32,
pub end_line: u32,
pub end_col: u32,
pub is_external: bool,
pub crate_name: Option<String>,
}
pub struct StepBoundedCancellationFlag<'a> {
pub max_steps: usize,
pub steps: Arc<AtomicUsize>,
pub inner: Option<&'a dyn stack_graphs::CancellationFlag>,
}
impl<'a> StepBoundedCancellationFlag<'a> {
pub fn new(max_steps: usize) -> Self {
Self {
max_steps,
steps: Arc::new(AtomicUsize::new(0)),
inner: None,
}
}
pub fn with_inner(max_steps: usize, inner: &'a dyn stack_graphs::CancellationFlag) -> Self {
Self {
max_steps,
steps: Arc::new(AtomicUsize::new(0)),
inner: Some(inner),
}
}
}
impl stack_graphs::CancellationFlag for StepBoundedCancellationFlag<'_> {
fn check(&self, at: &'static str) -> Result<(), stack_graphs::CancellationError> {
let count = self.steps.fetch_add(1, Ordering::Relaxed);
if count >= self.max_steps {
return Err(stack_graphs::CancellationError(
"Execution step limit exceeded (path explosion protection)",
));
}
if let Some(inner) = self.inner {
inner.check(at)?;
}
Ok(())
}
}
pub struct StackGraphEngine {
pub graph: StackGraph,
pub partials: PartialPaths,
pub database: Database,
pub sgl: Arc<StackGraphLanguage>,
pub languages: HashMap<String, Arc<StackGraphLanguage>>,
pub files: HashMap<PathBuf, Handle<File>>,
pub max_steps: usize,
sources: Vec<(PathBuf, String)>,
precomputed: HashSet<Handle<File>>,
stale: bool,
pub crate_roots: HashMap<String, Handle<Node>>,
pub dependency_files: HashSet<Handle<File>>,
pub file_to_crate: HashMap<Handle<File>, String>,
registered_crates: Vec<(String, PathBuf)>,
registered_reexports: Vec<(PathBuf, Option<String>, String, String)>,
}
impl StackGraphEngine {
pub fn new(sgl: Arc<StackGraphLanguage>) -> Self {
let mut languages = HashMap::new();
languages.insert("rs".to_string(), Arc::clone(&sgl));
match load_python_language() {
Ok(py_sgl) => {
languages.insert("py".to_string(), Arc::new(py_sgl));
}
Err(e) => tracing::warn!(
"Python stack-graph rules failed to load; Python resolution falls back to heuristics: {e}"
),
}
match load_typescript_language() {
Ok(ts_sgl) => {
let ts_arc = Arc::new(ts_sgl);
for ext in ["ts", "tsx", "js", "jsx"] {
languages.insert(ext.to_string(), Arc::clone(&ts_arc));
}
}
Err(e) => tracing::warn!(
"TypeScript stack-graph rules failed to load; TypeScript resolution falls back to heuristics: {e}"
),
}
Self {
graph: StackGraph::new(),
partials: PartialPaths::new(),
database: Database::new(),
sgl,
languages,
files: HashMap::new(),
max_steps: 1000,
sources: Vec::new(),
precomputed: HashSet::new(),
stale: false,
crate_roots: HashMap::new(),
dependency_files: HashSet::new(),
file_to_crate: HashMap::new(),
registered_crates: Vec::new(),
registered_reexports: Vec::new(),
}
}
pub fn new_rust() -> anyhow::Result<Self> {
let sgl = Arc::new(load_rust_language()?);
Ok(Self::new(sgl))
}
pub fn with_max_steps(mut self, max_steps: usize) -> Self {
self.max_steps = max_steps;
self
}
pub fn add_file(&mut self, rel_path: &Path, content: &str) -> anyhow::Result<Handle<File>> {
if let Some(&handle) = self.files.get(rel_path) {
if let Some((_, source)) = self.sources.iter_mut().find(|(p, _)| p == rel_path)
&& source != content
{
*source = content.to_string();
self.stale = true;
}
return Ok(handle);
}
self.sources
.push((rel_path.to_path_buf(), content.to_string()));
self.build_file(rel_path, content)
}
pub fn remove_file(&mut self, rel_path: &Path) {
if self.files.remove(rel_path).is_some() {
self.sources.retain(|(p, _)| p != rel_path);
self.stale = true;
}
}
fn build_file(&mut self, rel_path: &Path, content: &str) -> anyhow::Result<Handle<File>> {
let path_str = rel_path.to_string_lossy().to_string();
let file_handle = self.graph.get_or_create_file(&path_str);
self.files.insert(rel_path.to_path_buf(), file_handle);
let globals = Variables::new();
let cancellation = NoCancellation;
let ext = rel_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("rs")
.to_ascii_lowercase();
let Some(sgl) = self.languages.get(&ext) else {
anyhow::bail!(
"no stack-graph rules for '.{ext}' files ({}); not building a graph for it",
rel_path.display()
);
};
sgl.build_stack_graph_into(
&mut self.graph,
file_handle,
content,
&globals,
&cancellation,
)
.map_err(|e| anyhow::anyhow!("TSG build error in {}: {e:?}", rel_path.display()))?;
Ok(file_handle)
}
fn refresh(&mut self, cancellation: &dyn stack_graphs::CancellationFlag) {
if !self.stale {
return;
}
self.stale = false;
self.graph = StackGraph::new();
self.partials = PartialPaths::new();
self.database = Database::new();
self.files.clear();
self.precomputed.clear();
self.crate_roots.clear();
self.dependency_files.clear();
self.file_to_crate.clear();
for (path, content) in std::mem::take(&mut self.sources) {
if let Err(e) = self.build_file(&path, &content) {
tracing::debug!("{e}");
}
self.sources.push((path, content));
}
let reg_crates = self.registered_crates.clone();
for (c_name, entry) in reg_crates {
let _ = self.register_crate_root(&c_name, &entry);
}
let reg_reexports = self.registered_reexports.clone();
for (file_p, _, exp_name, tgt_path) in reg_reexports {
if let Some(&handle) = self.files.get(&file_p) {
let _ = self.add_reexport_alias(handle, None, &exp_name, &tgt_path);
}
}
let _ = self.precompute_all_files(cancellation);
}
pub fn register_crate_root(
&mut self,
crate_name: &str,
entry_file_path: &Path,
) -> anyhow::Result<Handle<Node>> {
let file_handle = if let Some(&handle) = self.files.get(entry_file_path) {
handle
} else {
self.add_file(entry_file_path, "")?
};
self.dependency_files.insert(file_handle);
self.file_to_crate
.insert(file_handle, crate_name.to_string());
let node_id = self.graph.new_node_id(file_handle);
let sym = self.graph.add_symbol(crate_name);
let crate_root_node = self
.graph
.add_pop_symbol_node(node_id, sym, true)
.ok_or_else(|| anyhow::anyhow!("Failed to add crate root node for {crate_name}"))?;
self.graph
.add_edge(StackGraph::root_node(), crate_root_node, 0);
self.crate_roots
.insert(crate_name.to_string(), crate_root_node);
if !self.registered_crates.iter().any(|(c, _)| c == crate_name) {
self.registered_crates
.push((crate_name.to_string(), entry_file_path.to_path_buf()));
}
Ok(crate_root_node)
}
pub fn add_module_item(
&mut self,
file_handle: Handle<File>,
parent_node: Handle<Node>,
item_name: &str,
is_definition: bool,
) -> anyhow::Result<Handle<Node>> {
let node_id = self.graph.new_node_id(file_handle);
let sym = self.graph.add_symbol(item_name);
let item_node = self
.graph
.add_pop_symbol_node(node_id, sym, is_definition)
.ok_or_else(|| anyhow::anyhow!("Failed to add module item node {item_name}"))?;
self.graph.add_edge(parent_node, item_node, 0);
Ok(item_node)
}
pub fn add_reexport_alias(
&mut self,
file_handle: Handle<File>,
parent_node: Option<Handle<Node>>,
exported_name: &str,
target_path: &str,
) -> anyhow::Result<Handle<Node>> {
let pop_id = self.graph.new_node_id(file_handle);
let pop_sym = self.graph.add_symbol(exported_name);
let pop_node = self
.graph
.add_pop_symbol_node(pop_id, pop_sym, true)
.ok_or_else(|| anyhow::anyhow!("Failed to add reexport pop node {exported_name}"))?;
if let Some(parent) = parent_node {
self.graph.add_edge(parent, pop_node, 0);
} else {
self.graph.add_edge(StackGraph::root_node(), pop_node, 0);
}
let segments: Vec<&str> = target_path
.trim_start_matches("::")
.split("::")
.filter(|s| !s.is_empty())
.collect();
if segments.is_empty() {
return Ok(pop_node);
}
let mut prev = pop_node;
for &seg in segments.iter().rev() {
let push_id = self.graph.new_node_id(file_handle);
let push_sym = self.graph.add_symbol(seg);
let push_node = self
.graph
.add_push_symbol_node(push_id, push_sym, false)
.ok_or_else(|| anyhow::anyhow!("Failed to add push symbol node {seg}"))?;
self.graph.add_edge(prev, push_node, 0);
prev = push_node;
}
self.graph.add_edge(prev, StackGraph::root_node(), 0);
let file_path = PathBuf::from(self.graph[file_handle].name());
if !self
.registered_reexports
.iter()
.any(|(p, _, e, t)| p == &file_path && e == exported_name && t == target_path)
{
self.registered_reexports.push((
file_path,
None,
exported_name.to_string(),
target_path.to_string(),
));
}
Ok(pop_node)
}
pub fn add_import_stitching(
&mut self,
file_handle: Handle<File>,
local_name: &str,
target_path: &str,
) -> anyhow::Result<Handle<Node>> {
let pop_id = self.graph.new_node_id(file_handle);
let pop_sym = self.graph.add_symbol(local_name);
let pop_node = self
.graph
.add_pop_symbol_node(pop_id, pop_sym, true)
.ok_or_else(|| anyhow::anyhow!("Failed to add import pop node {local_name}"))?;
self.graph.add_edge(StackGraph::root_node(), pop_node, 0);
let segments: Vec<&str> = target_path
.trim_start_matches("::")
.split("::")
.filter(|s| !s.is_empty())
.collect();
if segments.is_empty() {
return Ok(pop_node);
}
let mut prev = pop_node;
for &seg in segments.iter().rev() {
let push_id = self.graph.new_node_id(file_handle);
let push_sym = self.graph.add_symbol(seg);
let push_node = self
.graph
.add_push_symbol_node(push_id, push_sym, false)
.ok_or_else(|| anyhow::anyhow!("Failed to add push node {seg}"))?;
self.graph.add_edge(prev, push_node, 0);
prev = push_node;
}
self.graph.add_edge(prev, StackGraph::root_node(), 0);
Ok(pop_node)
}
pub fn precompute_file_paths(
&mut self,
file: Handle<File>,
cancellation: &dyn stack_graphs::CancellationFlag,
) -> anyhow::Result<usize> {
if !self.precomputed.insert(file) {
return Ok(0);
}
let mut added = 0;
let config = StitcherConfig::default();
let mut discovered = Vec::new();
let bounded = StepBoundedCancellationFlag::with_inner(self.max_steps, cancellation);
let res = ForwardPartialPathStitcher::find_minimal_partial_path_set_in_file(
&self.graph,
&mut self.partials,
file,
config,
&bounded,
|_graph, _partials, path| {
discovered.push(path.clone());
},
);
if res.is_ok() {
for path in discovered {
self.database
.add_partial_path(&self.graph, &mut self.partials, path);
added += 1;
}
} else {
self.precomputed.remove(&file);
tracing::debug!(
"Partial path precompute hit the {}-step limit for {}",
self.max_steps,
self.graph[file].name()
);
}
Ok(added)
}
pub fn precompute_all_files(
&mut self,
cancellation: &dyn stack_graphs::CancellationFlag,
) -> anyhow::Result<usize> {
self.refresh(cancellation);
let mut total = 0;
let file_handles: Vec<Handle<File>> = self.files.values().copied().collect();
for file in file_handles {
total += self.precompute_file_paths(file, cancellation)?;
}
Ok(total)
}
fn node_to_definition(&self, node: Handle<Node>) -> Option<ResolvedDefinition> {
let node_id = self.graph[node].id();
let file_handle = node_id.file()?;
let file_path = PathBuf::from(self.graph[file_handle].name());
let symbol_name = match &self.graph[node] {
Node::PopSymbol(n) => self.graph[n.symbol].to_string(),
Node::PopScopedSymbol(n) => self.graph[n.symbol].to_string(),
Node::PushSymbol(n) => self.graph[n.symbol].to_string(),
Node::PushScopedSymbol(n) => self.graph[n.symbol].to_string(),
_ => return None,
};
let (start_line, start_col, end_line, end_col, syntax_type) =
if let Some(src) = self.graph.source_info(node) {
let st = src
.syntax_type
.into_option()
.map(|h| self.graph[h].to_string());
(
src.span.start.line as u32 + 1,
src.span.start.column.utf8_offset as u32,
src.span.end.line as u32 + 1,
src.span.end.column.utf8_offset as u32,
st,
)
} else {
(1, 0, 1, 0, None)
};
let is_external = self.dependency_files.contains(&file_handle) || file_path.is_absolute();
let crate_name = self.file_to_crate.get(&file_handle).cloned();
Some(ResolvedDefinition {
symbol_name,
file_path,
syntax_type,
start_line,
start_col,
end_line,
end_col,
is_external,
crate_name,
})
}
pub fn resolve_node(
&mut self,
ref_node: Handle<Node>,
cancellation: &dyn stack_graphs::CancellationFlag,
) -> anyhow::Result<Vec<ResolvedDefinition>> {
if !self.graph[ref_node].is_reference() {
return Ok(Vec::new());
}
let mut initial_path = PartialPath::from_node(&self.graph, &mut self.partials, ref_node);
initial_path.eliminate_precondition_stack_variables(&mut self.partials);
let mut stitcher = ForwardPartialPathStitcher::from_partial_paths(
&self.graph,
&mut self.partials,
std::iter::once(initial_path),
);
stitcher.set_similar_path_detection(true);
stitcher.set_check_only_join_nodes(true);
let mut candidates =
DatabaseCandidates::new(&self.graph, &mut self.partials, &mut self.database);
let mut steps = 0;
let mut complete_paths = Vec::new();
while !stitcher.is_complete() {
cancellation.check("resolving stack graph reference")?;
if steps >= self.max_steps {
tracing::warn!(
"Path stitching reached max_steps ({}) boundary limit, terminating search",
self.max_steps
);
break;
}
steps += 1;
for path in stitcher.previous_phase_partial_paths() {
candidates.load_forward_candidates(path, cancellation)?;
}
stitcher.process_next_phase(&mut candidates, |_, _, _| true);
for path in stitcher.previous_phase_partial_paths() {
if path.is_complete(&self.graph) {
complete_paths.push(path.clone());
}
}
}
let mut definitions = Vec::new();
for path in complete_paths {
let def_node = path.end_node;
if !self.graph[def_node].is_definition() {
continue;
}
let Some(def) = self.node_to_definition(def_node) else {
continue;
};
if !definitions.contains(&def) {
definitions.push(def);
}
}
Ok(definitions)
}
pub fn resolve_at_location(
&mut self,
rel_path: &Path,
line: u32,
col: u32,
) -> anyhow::Result<Option<ResolvedDefinition>> {
self.refresh(&stack_graphs::NoCancellation);
let file_handle = match self.files.get(rel_path) {
Some(h) => *h,
None => return Ok(None),
};
let ref_nodes: Vec<Handle<Node>> = self
.graph
.nodes_for_file(file_handle)
.filter(|&n| self.graph[n].is_reference())
.collect();
let target_line_0 = line.saturating_sub(1);
let mut best_node = None;
for node in ref_nodes {
if let Some(src) = self.graph.source_info(node) {
let start_line = src.span.start.line as u32;
let end_line = src.span.end.line as u32;
let start_col = src.span.start.column.utf8_offset as u32;
let end_col = src.span.end.column.utf8_offset as u32;
if target_line_0 >= start_line && target_line_0 <= end_line {
if target_line_0 == start_line && target_line_0 == end_line {
if col >= start_col && col <= end_col {
best_node = Some(node);
break;
}
} else {
best_node = Some(node);
break;
}
}
}
}
let node_to_resolve = match best_node {
Some(n) => n,
None => return Ok(None),
};
let step_cancellation = StepBoundedCancellationFlag::new(self.max_steps);
let results = self.resolve_node(node_to_resolve, &step_cancellation)?;
Ok(results.into_iter().next())
}
}