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};
pub const FILE_SUBGRAPH_SCHEMA_VERSION: u32 = 1;
#[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,
pub type_only: bool,
#[cfg_attr(feature = "serde", serde(default))]
pub cross_artifact: bool,
}
pub(crate) fn edge_tags(
cross_artifact: bool,
base_confidence: Confidence,
) -> (Confidence, Provenance) {
if cross_artifact {
(Confidence::NameOnly, Provenance::CrossArtifact)
} else {
(base_confidence, Provenance::ScopeGraph)
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, PartialEq, Eq)]
pub(crate) struct ReExport {
pub module_path: Vec<String>,
pub name: String,
pub source_path: Vec<String>,
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone)]
pub struct FileSubgraph {
pub(crate) schema_version: u32,
pub(crate) owner_file: String,
pub(crate) symbols: Vec<Symbol>,
pub(crate) intra_edges: Vec<Edge>,
pub(crate) pending: Vec<PendingRef>,
#[cfg_attr(feature = "serde", serde(default))]
pub(crate) reexports: Vec<ReExport>,
}
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 reexports = collect_reexports(f, &symbols);
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,
type_only: false,
cross_artifact: r.cross_artifact,
});
continue;
}
if r.role == RefRole::TypeRef && r.type_ref_ctx.is_some() && r.qualifier.is_none() {
pending.push(PendingRef {
from,
name: r.name.clone(),
segs: Vec::new(),
role: r.role,
occ: r.occ.clone(),
confidence: Confidence::Scoped,
qualified: false,
type_only: true,
cross_artifact: r.cross_artifact,
});
continue;
}
if let Some(qual) = &r.qualifier {
let segs = normalize_from_path(qual);
let crate_root_type = r.role == RefRole::TypeRef
&& r.type_ref_ctx.is_some()
&& matches!(qual.as_str(), "crate" | "::");
if !segs.is_empty() || crate_root_type {
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,
type_only: r.role == RefRole::TypeRef && r.type_ref_ctx.is_some(),
cross_artifact: r.cross_artifact,
});
}
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,
type_only: r.type_ref_ctx.is_some(),
cross_artifact: r.cross_artifact,
});
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,
type_only: false,
cross_artifact: r.cross_artifact,
});
}
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);
let (confidence, provenance) = edge_tags(r.cross_artifact, Confidence::Exact);
intra_edges.push(Edge {
from,
to,
role: r.role,
confidence,
provenance,
occ: r.occ.clone(),
});
}
BindingKind::Definition => {
if let BindingTarget::Def(target_id) = &binding.target {
if &from != target_id {
let (confidence, provenance) =
edge_tags(r.cross_artifact, Confidence::Scoped);
intra_edges.push(Edge {
from,
to: target_id.clone(),
role: r.role,
confidence,
provenance,
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,
type_only: false,
cross_artifact: r.cross_artifact,
});
}
}
}
}
}
FileSubgraph {
schema_version: FILE_SUBGRAPH_SCHEMA_VERSION,
owner_file: f.file.clone(),
symbols,
intra_edges,
pending,
reexports,
}
}
fn collect_reexports(facts: &FileFacts, symbols: &[Symbol]) -> Vec<ReExport> {
facts
.references
.iter()
.filter(|reference| reference.role == RefRole::Import && reference.is_reexport)
.filter_map(|reference| {
let source = reference.from_path.as_deref()?;
let module = symbols
.iter()
.filter(|symbol| {
symbol.kind == crate::graph::types::SymbolKind::Module
&& symbol.span.contains(reference.occ.byte)
})
.min_by_key(|symbol| symbol.span.end.saturating_sub(symbol.span.start))?;
let mut module_path: Vec<String> =
module.id.namespaces_iter().map(str::to_owned).collect();
if module_path.len() == 1 && matches!(module_path[0].as_str(), "lib" | "main") {
module_path.clear();
}
let super_count = source
.split("::")
.take_while(|segment| *segment == "super")
.count();
let mut source_path = if source == "crate" || source.starts_with("crate::") {
normalize_from_path(source)
.into_iter()
.map(str::to_owned)
.collect()
} else if super_count > 0 {
let mut path = module_path.clone();
for _ in 0..super_count {
path.pop();
}
path.extend(normalize_from_path(source).into_iter().map(str::to_owned));
path
} else {
let mut path = module_path.clone();
path.extend(normalize_from_path(source).into_iter().map(str::to_owned));
path
};
source_path.push(
reference
.imported_name
.as_ref()
.unwrap_or(&reference.name)
.clone(),
);
Some(ReExport {
module_path,
name: reference.name.clone(),
source_path,
})
})
.collect()
}
pub(crate) 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;
for _ in 0..scopes.len() {
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);
}
}
current = scopes.get(current).and_then(|scope| scope.parent)?;
}
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(all(test, feature = "rust"))]
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.owner_file, &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 p1_sub = build_subgraph(&p1);
let p2_sub = build_subgraph(&p2);
let mut index = GlobalIndex::new();
index.insert_symbols(&p1_sub.owner_file, &p1_sub.symbols);
index.insert_symbols(&p2_sub.owner_file, &p2_sub.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)"
);
}
}