Skip to main content

sqry_core/graph/unified/
bind.rs

1//! Binding query facade with declaration/reference classification.
2//!
3//! Provides `BindingQuery`, a builder-pattern API for resolving symbols and
4//! classifying them as declarations, references, imports, or ambiguous. This
5//! module builds on the witness-bearing resolution API in `resolution.rs`.
6
7use super::concurrent::GraphSnapshot;
8use super::edge::kind::{EdgeKind, ExportKind};
9use super::node::id::NodeId;
10use super::node::kind::NodeKind;
11use super::resolution::{
12    FileScope, NormalizedSymbolQuery, ResolutionMode, SymbolCandidateBucket,
13    SymbolCandidateOutcome, SymbolQuery, SymbolResolutionOutcome,
14};
15
16/// How a resolved symbol relates to its definition site.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum SymbolClassification {
19    /// The node IS the declaration (target of `Defines`/`Contains` edges,
20    /// `NodeKind` is not `CallSite`/`Import`/`Export`).
21    Declaration,
22    /// The node is a reference/use of a declaration elsewhere.
23    Reference,
24    /// The node is an import statement (has `Defines` edge but is not
25    /// the source-of-truth declaration).
26    Import,
27    /// Multiple interpretations possible (e.g., re-export that is both
28    /// a reference and a local declaration).
29    Ambiguous,
30    /// Classification could not be determined from graph structure.
31    Unknown,
32}
33
34/// A single resolved binding with classification and provenance.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct ResolvedBinding {
37    /// The resolved node ID.
38    pub node_id: NodeId,
39    /// Declaration vs reference vs import classification.
40    pub classification: SymbolClassification,
41    /// Which resolution bucket produced this candidate.
42    pub bucket: SymbolCandidateBucket,
43    /// The node's kind (saves consumers a `get_node()` round-trip).
44    pub kind: NodeKind,
45}
46
47/// Result of a binding query.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct BindingResult {
50    /// Normalized query (`None` if file scope resolution failed).
51    pub query: Option<NormalizedSymbolQuery>,
52    /// Resolved bindings with classification and provenance.
53    pub bindings: Vec<ResolvedBinding>,
54    /// Resolution outcome (same semantics as `resolve_symbol`).
55    pub outcome: SymbolResolutionOutcome,
56}
57
58/// Builder for binding queries.
59///
60/// # Example
61///
62/// ```rust,ignore
63/// let result = BindingQuery::new("MyClass")
64///     .file_scope(FileScope::Any)
65///     .mode(ResolutionMode::AllowSuffixCandidates)
66///     .resolve(&snapshot);
67/// ```
68pub struct BindingQuery<'a> {
69    symbol: &'a str,
70    file_scope: FileScope<'a>,
71    mode: ResolutionMode,
72}
73
74impl<'a> BindingQuery<'a> {
75    /// Creates a new binding query for the given symbol.
76    ///
77    /// Defaults to `FileScope::Any` and `ResolutionMode::AllowSuffixCandidates`.
78    #[must_use]
79    pub fn new(symbol: &'a str) -> Self {
80        Self {
81            symbol,
82            file_scope: FileScope::Any,
83            mode: ResolutionMode::AllowSuffixCandidates,
84        }
85    }
86
87    /// Restricts the query to a specific file scope.
88    #[must_use]
89    pub fn file_scope(mut self, scope: FileScope<'a>) -> Self {
90        self.file_scope = scope;
91        self
92    }
93
94    /// Sets the resolution mode.
95    #[must_use]
96    pub fn mode(mut self, mode: ResolutionMode) -> Self {
97        self.mode = mode;
98        self
99    }
100
101    /// Resolves the query against the given snapshot.
102    ///
103    /// Performs symbol resolution via the witness-bearing API, then classifies
104    /// each candidate based on its `NodeKind` and incoming edge structure.
105    #[must_use]
106    pub fn resolve(self, snapshot: &GraphSnapshot) -> BindingResult {
107        let witness = snapshot.find_symbol_candidates_with_witness(&SymbolQuery {
108            symbol: self.symbol,
109            file_scope: self.file_scope,
110            mode: self.mode,
111        });
112
113        // Determine outcome
114        let outcome = match &witness.outcome {
115            SymbolCandidateOutcome::Candidates(candidates) => match candidates.as_slice() {
116                [] => SymbolResolutionOutcome::NotFound,
117                [node_id] => SymbolResolutionOutcome::Resolved(*node_id),
118                _ => SymbolResolutionOutcome::Ambiguous(candidates.clone()),
119            },
120            SymbolCandidateOutcome::NotFound => SymbolResolutionOutcome::NotFound,
121            SymbolCandidateOutcome::FileNotIndexed => SymbolResolutionOutcome::FileNotIndexed,
122        };
123
124        // Build bindings with classification
125        let bindings: Vec<ResolvedBinding> = witness
126            .candidates
127            .iter()
128            .filter_map(|candidate| {
129                let entry = snapshot.get_node(candidate.node_id)?;
130                let classification = classify_node(snapshot, candidate.node_id, entry.kind);
131                Some(ResolvedBinding {
132                    node_id: candidate.node_id,
133                    classification,
134                    bucket: candidate.bucket,
135                    kind: entry.kind,
136                })
137            })
138            .collect();
139
140        BindingResult {
141            query: witness.normalized_query,
142            bindings,
143            outcome,
144        }
145    }
146}
147
148/// Classify a node as declaration, reference, import, or ambiguous.
149///
150/// Classification logic:
151/// 1. `NodeKind::Import` → `Import`
152/// 2. `NodeKind::Export` → check for re-export edge → `Ambiguous` if re-export, else `Import`
153/// 3. `NodeKind::CallSite` → `Reference`
154/// 4. Has incoming `Defines` or `Contains` edge → `Declaration`
155/// 5. No structural incoming edges → `Reference`
156/// 6. Fallback → `Unknown`
157fn classify_node(
158    snapshot: &GraphSnapshot,
159    node_id: NodeId,
160    kind: NodeKind,
161) -> SymbolClassification {
162    // Step 1: Check NodeKind first
163    if kind == NodeKind::Import {
164        return SymbolClassification::Import;
165    }
166
167    if kind == NodeKind::Export {
168        // Check if this export has a re-export edge
169        let incoming = snapshot.edges().edges_to(node_id);
170        let has_reexport = incoming.iter().any(|e| {
171            matches!(
172                &e.kind,
173                EdgeKind::Exports {
174                    kind: ExportKind::Reexport | ExportKind::Namespace,
175                    ..
176                }
177            )
178        });
179        // Also check outgoing exports for re-export classification
180        let outgoing = snapshot.edges().edges_from(node_id);
181        let has_reexport_outgoing = outgoing.iter().any(|e| {
182            matches!(
183                &e.kind,
184                EdgeKind::Exports {
185                    kind: ExportKind::Reexport | ExportKind::Namespace,
186                    ..
187                }
188            )
189        });
190
191        return if has_reexport || has_reexport_outgoing {
192            SymbolClassification::Ambiguous
193        } else {
194            SymbolClassification::Import
195        };
196    }
197
198    if kind == NodeKind::CallSite {
199        return SymbolClassification::Reference;
200    }
201
202    // Step 2: Check incoming edges for structural (Defines/Contains)
203    let incoming = snapshot.edges().edges_to(node_id);
204    let has_structural = incoming
205        .iter()
206        .any(|e| matches!(&e.kind, EdgeKind::Defines | EdgeKind::Contains));
207
208    if has_structural {
209        return SymbolClassification::Declaration;
210    }
211
212    // Step 3: No structural incoming edges → Reference
213    if !incoming.is_empty() {
214        return SymbolClassification::Reference;
215    }
216
217    // Step 4: No incoming edges at all — could be a root declaration or orphan
218    // If the node has outgoing Defines/Contains edges, it's likely a root module/declaration
219    let outgoing = snapshot.edges().edges_from(node_id);
220    let has_outgoing_structural = outgoing
221        .iter()
222        .any(|e| matches!(&e.kind, EdgeKind::Defines | EdgeKind::Contains));
223
224    if has_outgoing_structural {
225        return SymbolClassification::Declaration;
226    }
227
228    SymbolClassification::Unknown
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use crate::graph::node::Language;
235    use crate::graph::unified::concurrent::CodeGraph;
236    use crate::graph::unified::file::FileId;
237    use crate::graph::unified::storage::arena::NodeEntry;
238
239    struct TestGraph {
240        graph: CodeGraph,
241        file_id: Option<FileId>,
242    }
243
244    impl TestGraph {
245        fn new() -> Self {
246            Self {
247                graph: CodeGraph::new(),
248                file_id: None,
249            }
250        }
251
252        fn ensure_file_id(&mut self) -> FileId {
253            if let Some(fid) = self.file_id {
254                return fid;
255            }
256            let file_path = std::path::PathBuf::from("/bind-tests/test.rs");
257            let fid = self
258                .graph
259                .files_mut()
260                .register_with_language(&file_path, Some(Language::Rust))
261                .unwrap();
262            self.file_id = Some(fid);
263            fid
264        }
265
266        fn add_node(&mut self, name: &str, kind: NodeKind) -> NodeId {
267            let file_id = self.ensure_file_id();
268            let name_id = self.graph.strings_mut().intern(name).unwrap();
269            let qn_id = self
270                .graph
271                .strings_mut()
272                .intern(&format!("test::{name}"))
273                .unwrap();
274
275            let entry = NodeEntry::new(kind, name_id, file_id)
276                .with_qualified_name(qn_id)
277                .with_location(1, 0, 10, 0);
278
279            let node_id = self.graph.nodes_mut().alloc(entry).unwrap();
280            self.graph
281                .indices_mut()
282                .add(node_id, kind, name_id, Some(qn_id), file_id);
283            node_id
284        }
285
286        fn add_edge(&mut self, source: NodeId, target: NodeId, kind: EdgeKind) {
287            let file_id = self.ensure_file_id();
288            self.graph
289                .edges_mut()
290                .add_edge(source, target, kind, file_id);
291        }
292
293        fn snapshot(&self) -> GraphSnapshot {
294            self.graph.snapshot()
295        }
296    }
297
298    #[test]
299    fn declaration_classification() {
300        let mut tg = TestGraph::new();
301        let module_node = tg.add_node("my_module", NodeKind::Module);
302        let func_node = tg.add_node("my_func", NodeKind::Function);
303        tg.add_edge(module_node, func_node, EdgeKind::Defines);
304
305        let snapshot = tg.snapshot();
306        let result = BindingQuery::new("my_func").resolve(&snapshot);
307
308        assert!(!result.bindings.is_empty(), "expected at least one binding");
309        let binding = result
310            .bindings
311            .iter()
312            .find(|b| b.node_id == func_node)
313            .expect("expected binding for func_node");
314        assert_eq!(binding.classification, SymbolClassification::Declaration);
315        assert_eq!(binding.kind, NodeKind::Function);
316    }
317
318    #[test]
319    fn reference_classification_callsite() {
320        let mut tg = TestGraph::new();
321        let _call_node = tg.add_node("some_call", NodeKind::CallSite);
322
323        let snapshot = tg.snapshot();
324        let result = BindingQuery::new("some_call").resolve(&snapshot);
325
326        assert!(!result.bindings.is_empty());
327        assert_eq!(
328            result.bindings[0].classification,
329            SymbolClassification::Reference
330        );
331    }
332
333    #[test]
334    fn import_classification() {
335        let mut tg = TestGraph::new();
336        let _import_node = tg.add_node("imported_sym", NodeKind::Import);
337
338        let snapshot = tg.snapshot();
339        let result = BindingQuery::new("imported_sym").resolve(&snapshot);
340
341        assert!(!result.bindings.is_empty());
342        assert_eq!(
343            result.bindings[0].classification,
344            SymbolClassification::Import
345        );
346    }
347
348    #[test]
349    fn export_direct_classification() {
350        let mut tg = TestGraph::new();
351        let _export_node = tg.add_node("exported_sym", NodeKind::Export);
352
353        let snapshot = tg.snapshot();
354        let result = BindingQuery::new("exported_sym").resolve(&snapshot);
355
356        assert!(!result.bindings.is_empty());
357        // Direct export without re-export edges → Import classification
358        assert_eq!(
359            result.bindings[0].classification,
360            SymbolClassification::Import
361        );
362    }
363
364    #[test]
365    fn export_reexport_ambiguous() {
366        let mut tg = TestGraph::new();
367        let source = tg.add_node("source_mod", NodeKind::Module);
368        let export_node = tg.add_node("reexported", NodeKind::Export);
369        tg.add_edge(
370            source,
371            export_node,
372            EdgeKind::Exports {
373                kind: ExportKind::Reexport,
374                alias: None,
375            },
376        );
377
378        let snapshot = tg.snapshot();
379        let result = BindingQuery::new("reexported").resolve(&snapshot);
380
381        assert!(!result.bindings.is_empty());
382        let binding = result
383            .bindings
384            .iter()
385            .find(|b| b.node_id == export_node)
386            .expect("expected binding for export_node");
387        assert_eq!(binding.classification, SymbolClassification::Ambiguous);
388    }
389
390    #[test]
391    fn builder_defaults() {
392        let query = BindingQuery::new("test_sym");
393        assert_eq!(query.symbol, "test_sym");
394        assert_eq!(query.file_scope, FileScope::Any);
395        assert_eq!(query.mode, ResolutionMode::AllowSuffixCandidates);
396    }
397
398    #[test]
399    fn not_found_result() {
400        let tg = TestGraph::new();
401        let snapshot = tg.snapshot();
402        let result = BindingQuery::new("nonexistent_symbol_xyz").resolve(&snapshot);
403
404        assert!(result.bindings.is_empty());
405        assert_eq!(result.outcome, SymbolResolutionOutcome::NotFound);
406    }
407
408    #[test]
409    fn declaration_via_contains_edge() {
410        let mut tg = TestGraph::new();
411        let class_node = tg.add_node("MyClass", NodeKind::Class);
412        let method_node = tg.add_node("my_method", NodeKind::Method);
413        tg.add_edge(class_node, method_node, EdgeKind::Contains);
414
415        let snapshot = tg.snapshot();
416        let result = BindingQuery::new("my_method").resolve(&snapshot);
417
418        assert!(!result.bindings.is_empty());
419        let binding = result
420            .bindings
421            .iter()
422            .find(|b| b.node_id == method_node)
423            .expect("expected binding for method_node");
424        assert_eq!(binding.classification, SymbolClassification::Declaration);
425    }
426
427    #[test]
428    fn root_declaration_with_outgoing_defines() {
429        let mut tg = TestGraph::new();
430        let module_node = tg.add_node("root_mod", NodeKind::Module);
431        let child = tg.add_node("child_func", NodeKind::Function);
432        tg.add_edge(module_node, child, EdgeKind::Defines);
433
434        let snapshot = tg.snapshot();
435        let result = BindingQuery::new("root_mod").resolve(&snapshot);
436
437        assert!(!result.bindings.is_empty());
438        let binding = result
439            .bindings
440            .iter()
441            .find(|b| b.node_id == module_node)
442            .expect("expected binding for module_node");
443        // Root module has no incoming edges but has outgoing Defines → Declaration
444        assert_eq!(binding.classification, SymbolClassification::Declaration);
445    }
446}