use std::collections::HashMap;
use crate::graph::types::{
Binding, BindingKind, BindingTarget, Confidence, Edge, FileFacts, Occurrence, Provenance,
RefRole, Scope, ScopeId, Symbol,
};
use crate::symbol::SymbolId;
use super::super::{enclosing_symbol_index, normalize_from_path};
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone)]
pub(crate) struct PendingRef {
pub from: SymbolId,
pub name: String,
pub segs: Vec<String>,
pub role: RefRole,
pub occ: Occurrence,
pub confidence: Confidence,
pub qualified: bool,
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone)]
pub struct FileSubgraph {
pub(crate) symbols: Vec<Symbol>,
pub(crate) intra_edges: Vec<Edge>,
pub(crate) pending: Vec<PendingRef>,
}
pub(crate) fn build_subgraph(f: &FileFacts) -> FileSubgraph {
let symbols = f.symbols.clone();
let all_idxs: Vec<usize> = (0..symbols.len()).collect();
let mut bindings_by_scope: HashMap<ScopeId, Vec<&Binding>> = HashMap::new();
for b in &f.bindings {
bindings_by_scope.entry(b.scope).or_default().push(b);
}
let mut import_segs_cache: HashMap<&str, Vec<&str>> = HashMap::new();
for b in &f.bindings {
if let BindingTarget::Import(fp) = &b.target {
import_segs_cache
.entry(fp.as_str())
.or_insert_with(|| normalize_from_path(fp.as_str()));
}
}
let file_namespace: Vec<String> = symbols
.iter()
.find(|s| s.id.namespaces_iter().next().is_some())
.map(|s| s.id.namespaces_iter().map(str::to_owned).collect())
.unwrap_or_default();
let mut intra_edges: Vec<Edge> = Vec::new();
let mut pending: Vec<PendingRef> = Vec::new();
for r in &f.references {
let Some(from_idx) = enclosing_symbol_index(&symbols, &all_idxs, r.occ.byte) else {
continue; };
let from = symbols[from_idx].id.clone();
if r.role == RefRole::ModuleRef {
pending.push(PendingRef {
from,
name: r.name.clone(),
segs: Vec::new(),
role: RefRole::ModuleRef,
occ: r.occ.clone(),
confidence: Confidence::Scoped,
qualified: false,
});
continue;
}
if let Some(qual) = &r.qualifier {
let segs = normalize_from_path(qual);
if !segs.is_empty() {
pending.push(PendingRef {
from,
name: r.name.clone(),
segs: segs.into_iter().map(str::to_string).collect(),
role: r.role,
occ: r.occ.clone(),
confidence: Confidence::Exact,
qualified: true,
});
}
continue; }
let Some(start) = r.scope else { continue };
let Some(binding) = scope_walk(&r.name, r.occ.byte, start, &f.scopes, &bindings_by_scope)
else {
if r.role == RefRole::TypeRef {
pending.push(PendingRef {
from,
name: r.name.clone(),
segs: Vec::new(),
role: r.role,
occ: r.occ.clone(),
confidence: Confidence::Scoped,
qualified: false,
});
continue;
}
if !file_namespace.is_empty() {
pending.push(PendingRef {
from,
name: r.name.clone(),
segs: file_namespace.clone(),
role: r.role,
occ: r.occ.clone(),
confidence: Confidence::Scoped,
qualified: false,
});
}
continue;
};
match binding.kind {
BindingKind::Local | BindingKind::Param => {
if binding.intro == r.occ.byte {
continue;
}
let local_id = format!(
"{}@{}:{}@{}",
f.file, binding.scope, binding.name, binding.intro
);
let to = SymbolId::local(f.file.clone(), local_id);
intra_edges.push(Edge {
from,
to,
role: r.role,
confidence: Confidence::Exact,
provenance: Provenance::ScopeGraph,
occ: r.occ.clone(),
});
}
BindingKind::Definition => {
if let BindingTarget::Def(target_id) = &binding.target {
if &from != target_id {
intra_edges.push(Edge {
from,
to: target_id.clone(),
role: r.role,
confidence: Confidence::Scoped,
provenance: Provenance::ScopeGraph,
occ: r.occ.clone(),
});
}
}
}
BindingKind::Import => {
if let BindingTarget::Import(from_path) = &binding.target {
let segs: &[&str] = import_segs_cache
.get(from_path.as_str())
.map(Vec::as_slice)
.unwrap_or(&[]);
if !segs.is_empty() {
pending.push(PendingRef {
from,
name: binding.name.clone(),
segs: segs.iter().copied().map(str::to_string).collect(),
role: r.role,
occ: r.occ.clone(),
confidence: Confidence::Exact,
qualified: false,
});
}
}
}
}
}
FileSubgraph {
symbols,
intra_edges,
pending,
}
}
fn scope_walk<'b>(
name: &str,
ref_byte: usize,
start: ScopeId,
scopes: &[Scope],
bindings_by_scope: &HashMap<ScopeId, Vec<&'b Binding>>,
) -> Option<&'b Binding> {
let mut current = start;
loop {
if let Some(cands) = bindings_by_scope.get(¤t) {
let winner = cands
.iter()
.copied()
.filter(|b| b.name == name && is_visible(b, ref_byte))
.max_by_key(|b| b.intro);
if let Some(b) = winner {
return Some(b);
}
}
match scopes.get(current).and_then(|s| s.parent) {
Some(p) => current = p,
None => return None, }
}
}
fn is_visible(b: &Binding, ref_byte: usize) -> bool {
match b.kind {
BindingKind::Local => b.intro <= ref_byte,
BindingKind::Param | BindingKind::Definition | BindingKind::Import => true,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::extract::{Extractor, RustExtractor};
fn typeref_pendings(f: &FileFacts) -> Vec<PendingRef> {
build_subgraph(f)
.pending
.into_iter()
.filter(|p| p.role == RefRole::TypeRef)
.collect()
}
#[test]
fn unbound_typeref_defers_with_empty_segs() {
let consumer = RustExtractor
.extract("pub struct Order { who: Users }", "src/model.rs")
.unwrap();
let users_pending: Vec<PendingRef> = typeref_pendings(&consumer)
.into_iter()
.filter(|p| p.name == "Users")
.collect();
assert_eq!(
users_pending.len(),
1,
"expected exactly one deferred TypeRef for the unbound `Users`"
);
let p = &users_pending[0];
assert!(
p.segs.is_empty(),
"cross-artifact TypeRef must defer with empty segs (match by unique \
name across artifacts), got {:?}",
p.segs
);
assert_eq!(
p.confidence,
Confidence::Scoped,
"deferred TypeRef stays Scoped (never fakes Exact)"
);
}
#[test]
fn cross_artifact_unique_typeref_resolves_one_edge() {
use super::super::stitch::{GlobalIndex, stitch};
let provider = RustExtractor
.extract("pub struct Users {}", "src/lib.rs")
.unwrap();
let consumer = RustExtractor
.extract("pub struct Order { who: Users }", "src/model.rs")
.unwrap();
let provider_sub = build_subgraph(&provider);
let consumer_sub = build_subgraph(&consumer);
let mut index = GlobalIndex::new();
index.insert_symbols(&provider_sub.symbols);
let type_edges: Vec<Edge> = stitch(&consumer_sub.pending, &index)
.into_iter()
.filter(|e| e.role == RefRole::TypeRef && e.to.leaf_name() == Some("Users"))
.collect();
assert_eq!(
type_edges.len(),
1,
"a globally-unique cross-namespace TypeRef must resolve to exactly \
one ScopeGraph edge"
);
assert_eq!(type_edges[0].provenance, Provenance::ScopeGraph);
assert_eq!(type_edges[0].confidence, Confidence::Scoped);
}
#[test]
fn ambiguous_typeref_resolves_no_edge() {
use super::super::stitch::{GlobalIndex, stitch};
let p1 = RustExtractor
.extract("pub struct Users {}", "src/a.rs")
.unwrap();
let p2 = RustExtractor
.extract("pub struct Users {}", "src/b.rs")
.unwrap();
let consumer = RustExtractor
.extract("pub struct Order { who: Users }", "src/model.rs")
.unwrap();
let consumer_sub = build_subgraph(&consumer);
let mut index = GlobalIndex::new();
index.insert_symbols(&build_subgraph(&p1).symbols);
index.insert_symbols(&build_subgraph(&p2).symbols);
let type_edges = stitch(&consumer_sub.pending, &index)
.into_iter()
.filter(|e| e.role == RefRole::TypeRef && e.to.leaf_name() == Some("Users"))
.count();
assert_eq!(
type_edges, 0,
"two same-named definitions → ambiguous → no edge (precision preserved)"
);
}
}