use crate::graph::unified::concurrent::GraphSnapshot;
use crate::graph::unified::node::id::NodeId;
use crate::graph::unified::resolution::{SymbolQuery, SymbolResolutionWitness};
use super::alias::AliasEntry;
use super::scope::provenance::{ScopeProvenance, ScopeStableId};
use super::scope::tree::scope_chain;
use super::scope::{Scope, ScopeId};
use super::shadow::ShadowEntry;
use super::witness::render::{WitnessRendering, render_witness};
use super::witness::step::ResolutionStep;
use super::{BindingResult, ResolvedBinding, SymbolClassification, classify_node};
use crate::graph::unified::resolution::{SymbolCandidateBucket, SymbolResolutionOutcome};
use crate::graph::unified::string::id::StringId;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BindingResolution {
pub result: BindingResult,
pub witness: SymbolResolutionWitness,
}
pub struct BindingPlane<'g> {
snapshot: &'g GraphSnapshot,
}
impl<'g> BindingPlane<'g> {
#[inline]
#[must_use]
pub fn new(snapshot: &'g GraphSnapshot) -> Self {
Self { snapshot }
}
#[must_use]
pub fn resolve(&self, query: &SymbolQuery<'_>) -> BindingResolution {
resolve_shared(query, self.snapshot)
}
#[must_use]
pub fn classify(&self, node_id: NodeId) -> SymbolClassification {
let entry = match self.snapshot.get_node(node_id) {
Some(e) => e,
None => return SymbolClassification::Unknown,
};
classify_node(self.snapshot, node_id, entry.kind)
}
#[must_use]
pub fn scope_of(&self, node_id: NodeId) -> Option<ScopeId> {
self.snapshot
.scope_arena()
.iter()
.find(|(_, scope)| scope.node == node_id)
.map(|(id, _)| id)
}
#[must_use]
pub fn scope_chain(&self, scope_id: ScopeId) -> Vec<ScopeId> {
scope_chain(self.snapshot.scope_arena(), scope_id)
}
#[must_use]
pub fn scope(&self, scope_id: ScopeId) -> Option<&Scope> {
self.snapshot.scope_arena().get(scope_id)
}
#[must_use]
pub fn scope_by_stable_id(&self, stable: ScopeStableId) -> Option<ScopeId> {
self.snapshot.scope_by_stable_id(stable)
}
#[must_use]
pub fn scope_provenance(&self, scope_id: ScopeId) -> Option<&ScopeProvenance> {
self.snapshot.scope_provenance(scope_id)
}
#[must_use]
pub fn aliases_in(&self, scope_id: ScopeId) -> &[AliasEntry] {
self.snapshot.alias_table().aliases_in(scope_id)
}
#[must_use]
pub fn resolve_alias(&self, scope_id: ScopeId, symbol: StringId) -> Option<StringId> {
self.snapshot.alias_table().resolve_alias(scope_id, symbol)
}
#[must_use]
pub fn shadows_in(&self, scope_id: ScopeId) -> Vec<&ShadowEntry> {
self.snapshot.shadow_table().shadows_in(scope_id)
}
#[must_use]
pub fn effective_binding(
&self,
scope_id: ScopeId,
symbol: StringId,
byte_offset: u32,
) -> Option<NodeId> {
self.snapshot
.shadow_table()
.effective_binding(scope_id, symbol, byte_offset)
}
#[must_use]
pub fn explain(&self, resolution: &BindingResolution) -> WitnessRendering {
render_witness(&resolution.witness)
}
}
pub(crate) fn resolve_shared(
query: &SymbolQuery<'_>,
snapshot: &GraphSnapshot,
) -> BindingResolution {
let mut witness = snapshot.resolve_symbol_with_witness(query);
emit_resolution_steps(&mut witness, query);
let bindings: Vec<ResolvedBinding> = witness
.candidates
.iter()
.filter_map(|candidate| {
let entry = snapshot.get_node(candidate.node_id)?;
Some(ResolvedBinding {
node_id: candidate.node_id,
classification: classify_node(snapshot, candidate.node_id, entry.kind),
bucket: candidate.bucket,
kind: entry.kind,
})
})
.collect();
let result = BindingResult {
query: witness.normalized_query.clone(),
bindings,
outcome: witness.outcome.clone(),
};
BindingResolution { result, witness }
}
fn emit_resolution_steps(witness: &mut SymbolResolutionWitness, query: &SymbolQuery<'_>) {
use super::witness::step::UnresolvedReason;
use smallvec::SmallVec;
let steps = &mut witness.steps;
steps.push(ResolutionStep::ApplyResolutionMode { mode: query.mode });
let winning_bucket = witness.selected_bucket;
match winning_bucket {
None => {
steps.push(ResolutionStep::LookupInBucket {
bucket: SymbolCandidateBucket::ExactQualified,
});
steps.push(ResolutionStep::LookupInBucket {
bucket: SymbolCandidateBucket::ExactSimple,
});
if query.mode
== crate::graph::unified::resolution::ResolutionMode::AllowSuffixCandidates
{
steps.push(ResolutionStep::LookupInBucket {
bucket: SymbolCandidateBucket::CanonicalSuffix,
});
}
}
Some(SymbolCandidateBucket::ExactQualified) => {
steps.push(ResolutionStep::LookupInBucket {
bucket: SymbolCandidateBucket::ExactQualified,
});
}
Some(SymbolCandidateBucket::ExactSimple) => {
steps.push(ResolutionStep::LookupInBucket {
bucket: SymbolCandidateBucket::ExactQualified,
});
steps.push(ResolutionStep::LookupInBucket {
bucket: SymbolCandidateBucket::ExactSimple,
});
}
Some(SymbolCandidateBucket::CanonicalSuffix) => {
steps.push(ResolutionStep::LookupInBucket {
bucket: SymbolCandidateBucket::ExactQualified,
});
steps.push(ResolutionStep::LookupInBucket {
bucket: SymbolCandidateBucket::ExactSimple,
});
steps.push(ResolutionStep::LookupInBucket {
bucket: SymbolCandidateBucket::CanonicalSuffix,
});
}
}
for (rank, candidate) in witness.candidates.iter().enumerate() {
steps.push(ResolutionStep::ConsiderCandidate {
node: candidate.node_id,
rank: u16::try_from(rank).unwrap_or(u16::MAX),
});
}
let unresolved_symbol = witness
.symbol
.unwrap_or_else(|| crate::graph::unified::string::id::StringId::new(0));
match &witness.outcome {
SymbolResolutionOutcome::Resolved(node_id) => {
debug_assert_eq!(
witness.candidates.len(),
1,
"Resolved outcome must have exactly one candidate"
);
steps.push(ResolutionStep::Chose { node: *node_id });
}
SymbolResolutionOutcome::Ambiguous(candidates) => {
let mut sv: SmallVec<[NodeId; 4]> = SmallVec::new();
sv.extend_from_slice(candidates);
steps.push(ResolutionStep::Ambiguous { candidates: sv });
}
SymbolResolutionOutcome::NotFound => {
steps.push(ResolutionStep::Unresolved {
symbol: unresolved_symbol,
reason: UnresolvedReason::NotInAnyScope,
});
}
SymbolResolutionOutcome::FileNotIndexed => {
steps.push(ResolutionStep::Unresolved {
symbol: unresolved_symbol,
reason: UnresolvedReason::FileNotIndexed,
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::node::Language;
use crate::graph::unified::concurrent::CodeGraph;
use crate::graph::unified::edge::kind::EdgeKind;
use crate::graph::unified::node::kind::NodeKind;
use crate::graph::unified::resolution::{FileScope, ResolutionMode, SymbolQuery};
use crate::graph::unified::storage::arena::NodeEntry;
fn make_graph_with_function(sym: &str) -> CodeGraph {
let mut graph = CodeGraph::new();
let path = std::path::PathBuf::from("/plane-tests/test.rs");
let file_id = graph
.files_mut()
.register_with_language(&path, Some(Language::Rust))
.expect("register file");
let name = graph.strings_mut().intern(sym).expect("intern sym");
let qn = graph
.strings_mut()
.intern(&format!("crate::{sym}"))
.expect("intern qn");
let mod_name = graph.strings_mut().intern("root").expect("intern root");
let mod_qn = graph.strings_mut().intern("crate").expect("intern crate");
let mod_id = graph
.nodes_mut()
.alloc(
NodeEntry::new(NodeKind::Module, mod_name, file_id)
.with_qualified_name(mod_qn)
.with_byte_range(0, 100),
)
.expect("alloc mod");
graph
.indices_mut()
.add(mod_id, NodeKind::Module, mod_name, Some(mod_qn), file_id);
let fn_id = graph
.nodes_mut()
.alloc(
NodeEntry::new(NodeKind::Function, name, file_id)
.with_qualified_name(qn)
.with_byte_range(5, 80),
)
.expect("alloc fn");
graph
.indices_mut()
.add(fn_id, NodeKind::Function, name, Some(qn), file_id);
graph
.edges_mut()
.add_edge(mod_id, fn_id, EdgeKind::Contains, file_id);
graph
}
#[test]
fn plane_resolve_returns_binding_result_and_witness() {
let graph = make_graph_with_function("my_fn");
let snapshot = graph.snapshot();
let plane = snapshot.binding_plane();
let query = SymbolQuery {
symbol: "my_fn",
file_scope: FileScope::Any,
mode: ResolutionMode::AllowSuffixCandidates,
};
let resolution = plane.resolve(&query);
assert!(
!resolution.result.bindings.is_empty(),
"expected at least one binding"
);
assert_eq!(
resolution.result.bindings[0].classification,
SymbolClassification::Declaration,
);
assert!(
!resolution.witness.steps.is_empty(),
"step trace must be non-empty after P2U07 emission"
);
}
#[test]
fn plane_resolve_not_found_emits_unresolved_step() {
let graph = make_graph_with_function("some_fn");
let snapshot = graph.snapshot();
let plane = snapshot.binding_plane();
let query = SymbolQuery {
symbol: "does_not_exist",
file_scope: FileScope::Any,
mode: ResolutionMode::Strict,
};
let resolution = plane.resolve(&query);
assert_eq!(resolution.result.outcome, SymbolResolutionOutcome::NotFound);
let has_unresolved = resolution.witness.steps.iter().any(|s| {
matches!(
s,
ResolutionStep::Unresolved {
reason: super::super::witness::step::UnresolvedReason::NotInAnyScope,
..
}
)
});
assert!(
has_unresolved,
"expected Unresolved step for not-found query"
);
}
#[test]
fn plane_resolve_found_emits_chose_step() {
let graph = make_graph_with_function("chosen_fn");
let snapshot = graph.snapshot();
let plane = snapshot.binding_plane();
let query = SymbolQuery {
symbol: "chosen_fn",
file_scope: FileScope::Any,
mode: ResolutionMode::AllowSuffixCandidates,
};
let resolution = plane.resolve(&query);
let has_chose = resolution
.witness
.steps
.iter()
.any(|s| matches!(s, ResolutionStep::Chose { .. }));
assert!(has_chose, "expected Chose terminal step for resolved query");
}
#[test]
fn plane_explain_produces_non_empty_text_and_json() {
let graph = make_graph_with_function("explainable_fn");
let snapshot = graph.snapshot();
let plane = snapshot.binding_plane();
let query = SymbolQuery {
symbol: "explainable_fn",
file_scope: FileScope::Any,
mode: ResolutionMode::AllowSuffixCandidates,
};
let resolution = plane.resolve(&query);
let rendering = plane.explain(&resolution);
assert!(!rendering.text.is_empty(), "explain text must be non-empty");
assert!(
rendering.json.get("steps").is_some(),
"explain JSON must have a 'steps' field"
);
}
#[test]
fn binding_query_resolve_matches_plane_resolve_result() {
let graph = make_graph_with_function("parity_fn");
let snapshot = graph.snapshot();
let query_result = crate::graph::unified::bind::BindingQuery::new("parity_fn")
.file_scope(FileScope::Any)
.mode(ResolutionMode::AllowSuffixCandidates)
.resolve(&snapshot);
let plane_result = snapshot.binding_plane().resolve(&SymbolQuery {
symbol: "parity_fn",
file_scope: FileScope::Any,
mode: ResolutionMode::AllowSuffixCandidates,
});
assert_eq!(
query_result, plane_result.result,
"BindingQuery::resolve() and BindingPlane::resolve().result must be identical"
);
}
#[test]
fn scope_of_returns_none_for_unknown_node() {
let graph = make_graph_with_function("any_fn");
let snapshot = graph.snapshot();
let plane = snapshot.binding_plane();
let invalid_id = NodeId::new(u32::MAX - 1, 99);
assert!(plane.scope_of(invalid_id).is_none());
}
#[test]
fn classify_returns_unknown_for_invalid_node() {
let graph = make_graph_with_function("any_fn2");
let snapshot = graph.snapshot();
let plane = snapshot.binding_plane();
let invalid_id = NodeId::new(u32::MAX - 2, 99);
assert_eq!(plane.classify(invalid_id), SymbolClassification::Unknown);
}
#[test]
fn step_trace_contains_apply_resolution_mode_first() {
let graph = make_graph_with_function("mode_fn");
let snapshot = graph.snapshot();
let plane = snapshot.binding_plane();
let query = SymbolQuery {
symbol: "mode_fn",
file_scope: FileScope::Any,
mode: ResolutionMode::Strict,
};
let resolution = plane.resolve(&query);
let first = resolution.witness.steps.first();
assert!(
matches!(
first,
Some(ResolutionStep::ApplyResolutionMode {
mode: ResolutionMode::Strict
})
),
"first step must be ApplyResolutionMode with the query mode"
);
}
#[test]
fn step_trace_exact_qualified_win_emits_single_bucket_step() {
let graph = make_graph_with_function("exact_fn");
let snapshot = graph.snapshot();
let plane = snapshot.binding_plane();
let query = SymbolQuery {
symbol: "crate::exact_fn",
file_scope: FileScope::Any,
mode: ResolutionMode::AllowSuffixCandidates,
};
let resolution = plane.resolve(&query);
let bucket_steps: Vec<_> = resolution
.witness
.steps
.iter()
.filter(|s| matches!(s, ResolutionStep::LookupInBucket { .. }))
.collect();
assert_eq!(
bucket_steps.len(),
1,
"expected exactly one LookupInBucket step when ExactQualified wins"
);
assert!(matches!(
bucket_steps[0],
ResolutionStep::LookupInBucket {
bucket: SymbolCandidateBucket::ExactQualified
}
));
}
}