Skip to main content

sqry_core/graph/unified/
resolution.rs

1//! Shared, file-aware symbol resolution for unified graph snapshots.
2//!
3//! This module centralizes strict single-symbol lookup and ordered candidate
4//! discovery so MCP, LSP, and CLI consumers do not drift semantically.
5
6use std::path::Path;
7
8use crate::graph::node::Language;
9use crate::graph::unified::concurrent::GraphSnapshot;
10use crate::graph::unified::file::id::FileId;
11use crate::graph::unified::node::id::NodeId;
12use crate::graph::unified::node::kind::NodeKind;
13
14/// File scoping policy for a symbol query.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum FileScope<'a> {
17    /// Search without file restriction.
18    Any,
19    /// Restrict lookup to a concrete file path.
20    Path(&'a Path),
21    /// Restrict lookup to a resolved file id.
22    FileId(FileId),
23}
24
25/// Resolution mode for a symbol query.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum ResolutionMode {
28    /// Only exact qualified-name and exact simple-name buckets are eligible.
29    Strict,
30    /// Allow bounded canonical `::` suffix candidates after exact buckets.
31    AllowSuffixCandidates,
32}
33
34/// Symbol lookup input.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub struct SymbolQuery<'a> {
37    /// Raw symbol text from the caller.
38    pub symbol: &'a str,
39    /// File scoping policy.
40    pub file_scope: FileScope<'a>,
41    /// Resolution mode.
42    pub mode: ResolutionMode,
43}
44
45/// Resolved file scope once path normalization has completed.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum ResolvedFileScope {
48    /// No file restriction.
49    Any,
50    /// Restriction to one indexed file.
51    File(FileId),
52}
53
54/// File-scope resolution failure.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum FileScopeError {
57    /// Requested file is not indexed in the current graph snapshot.
58    FileNotIndexed,
59}
60
61/// Normalized symbol query used internally by the resolver.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct NormalizedSymbolQuery {
64    /// Canonical graph symbol text.
65    pub symbol: String,
66    /// Resolved file scope.
67    pub file_scope: ResolvedFileScope,
68    /// Resolution mode.
69    pub mode: ResolutionMode,
70}
71
72/// Single-node resolution outcome.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum SymbolResolutionOutcome {
75    /// Exactly one node matched.
76    Resolved(NodeId),
77    /// No node matched.
78    NotFound,
79    /// Requested file is valid but absent from indexed graph data.
80    FileNotIndexed,
81    /// More than one node matched after deterministic ordering.
82    Ambiguous(Vec<NodeId>),
83}
84
85/// Candidate enumeration outcome.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub enum SymbolCandidateOutcome {
88    /// Ordered candidates from the first non-empty bucket.
89    Candidates(Vec<NodeId>),
90    /// No node matched.
91    NotFound,
92    /// Requested file is valid but absent from indexed graph data.
93    FileNotIndexed,
94}
95
96impl GraphSnapshot {
97    /// Resolves one symbol with explicit file-aware outcome classification.
98    #[must_use]
99    pub fn resolve_symbol(&self, query: &SymbolQuery<'_>) -> SymbolResolutionOutcome {
100        match self.find_symbol_candidates(query) {
101            SymbolCandidateOutcome::Candidates(candidates) => match candidates.as_slice() {
102                [] => SymbolResolutionOutcome::NotFound,
103                [node_id] => SymbolResolutionOutcome::Resolved(*node_id),
104                _ => SymbolResolutionOutcome::Ambiguous(candidates),
105            },
106            SymbolCandidateOutcome::NotFound => SymbolResolutionOutcome::NotFound,
107            SymbolCandidateOutcome::FileNotIndexed => SymbolResolutionOutcome::FileNotIndexed,
108        }
109    }
110
111    /// Finds ordered candidates from the first eligible non-empty bucket.
112    #[must_use]
113    pub fn find_symbol_candidates(&self, query: &SymbolQuery<'_>) -> SymbolCandidateOutcome {
114        let resolved_file_scope = match self.resolve_file_scope(&query.file_scope) {
115            Ok(scope) => scope,
116            Err(FileScopeError::FileNotIndexed) => return SymbolCandidateOutcome::FileNotIndexed,
117        };
118
119        let normalized_query = self.normalize_symbol_query(query, &resolved_file_scope);
120
121        let exact_qualified = self.filtered_bucket(
122            self.exact_qualified_bucket(&normalized_query),
123            resolved_file_scope,
124        );
125        if !exact_qualified.is_empty() {
126            return SymbolCandidateOutcome::Candidates(exact_qualified);
127        }
128
129        let exact_simple = self.filtered_bucket(
130            self.exact_simple_bucket(&normalized_query),
131            resolved_file_scope,
132        );
133        if !exact_simple.is_empty() {
134            return SymbolCandidateOutcome::Candidates(exact_simple);
135        }
136
137        if matches!(normalized_query.mode, ResolutionMode::AllowSuffixCandidates) {
138            let suffix_candidates = self.filtered_bucket(
139                self.bounded_suffix_bucket(&normalized_query),
140                resolved_file_scope,
141            );
142            if !suffix_candidates.is_empty() {
143                return SymbolCandidateOutcome::Candidates(suffix_candidates);
144            }
145        }
146
147        SymbolCandidateOutcome::NotFound
148    }
149
150    /// Resolves an external file scope into an indexed file scope.
151    ///
152    /// # Errors
153    ///
154    /// Returns [`FileScopeError::FileNotIndexed`] when the requested file scope
155    /// is not present in the loaded graph indices.
156    pub fn resolve_file_scope(
157        &self,
158        file_scope: &FileScope<'_>,
159    ) -> Result<ResolvedFileScope, FileScopeError> {
160        match *file_scope {
161            FileScope::Any => Ok(ResolvedFileScope::Any),
162            FileScope::Path(path) => self
163                .files()
164                .get(path)
165                .filter(|file_id| !self.indices().by_file(*file_id).is_empty())
166                .map_or(Err(FileScopeError::FileNotIndexed), |file_id| {
167                    Ok(ResolvedFileScope::File(file_id))
168                }),
169            FileScope::FileId(file_id) => {
170                let is_indexed = self.files().resolve(file_id).is_some()
171                    && !self.indices().by_file(file_id).is_empty();
172                if is_indexed {
173                    Ok(ResolvedFileScope::File(file_id))
174                } else {
175                    Err(FileScopeError::FileNotIndexed)
176                }
177            }
178        }
179    }
180
181    /// Normalizes a raw symbol query into canonical graph form.
182    #[must_use]
183    pub fn normalize_symbol_query(
184        &self,
185        query: &SymbolQuery<'_>,
186        file_scope: &ResolvedFileScope,
187    ) -> NormalizedSymbolQuery {
188        let normalized_symbol = match *file_scope {
189            ResolvedFileScope::Any => query.symbol.to_string(),
190            ResolvedFileScope::File(file_id) => {
191                self.files().language_for_file(file_id).map_or_else(
192                    || query.symbol.to_string(),
193                    |language| canonicalize_graph_qualified_name(language, query.symbol),
194                )
195            }
196        };
197
198        NormalizedSymbolQuery {
199            symbol: normalized_symbol,
200            file_scope: *file_scope,
201            mode: query.mode,
202        }
203    }
204
205    fn exact_qualified_bucket(&self, query: &NormalizedSymbolQuery) -> Vec<NodeId> {
206        self.strings()
207            .get(&query.symbol)
208            .map_or_else(Vec::new, |string_id| {
209                self.indices().by_qualified_name(string_id).to_vec()
210            })
211    }
212
213    fn exact_simple_bucket(&self, query: &NormalizedSymbolQuery) -> Vec<NodeId> {
214        self.strings()
215            .get(&query.symbol)
216            .map_or_else(Vec::new, |string_id| {
217                self.indices().by_name(string_id).to_vec()
218            })
219    }
220
221    fn bounded_suffix_bucket(&self, query: &NormalizedSymbolQuery) -> Vec<NodeId> {
222        if !query.symbol.contains("::") {
223            return Vec::new();
224        }
225
226        let Some(leaf_symbol) = query.symbol.rsplit("::").next() else {
227            return Vec::new();
228        };
229        let Some(leaf_id) = self.strings().get(leaf_symbol) else {
230            return Vec::new();
231        };
232        let suffix_pattern = format!("::{}", query.symbol);
233
234        self.indices()
235            .by_name(leaf_id)
236            .iter()
237            .copied()
238            .filter(|node_id| {
239                self.get_node(*node_id)
240                    .and_then(|entry| entry.qualified_name)
241                    .and_then(|qualified_name_id| self.strings().resolve(qualified_name_id))
242                    .is_some_and(|qualified_name| {
243                        qualified_name.as_ref() == query.symbol
244                            || qualified_name.as_ref().ends_with(&suffix_pattern)
245                    })
246            })
247            .collect()
248    }
249
250    fn filtered_bucket(
251        &self,
252        mut bucket: Vec<NodeId>,
253        file_scope: ResolvedFileScope,
254    ) -> Vec<NodeId> {
255        if let ResolvedFileScope::File(file_id) = file_scope {
256            let file_nodes = self.indices().by_file(file_id);
257            bucket.retain(|node_id| file_nodes.contains(node_id));
258        }
259
260        bucket.sort_by(|left, right| {
261            self.candidate_sort_key(*left)
262                .cmp(&self.candidate_sort_key(*right))
263        });
264        bucket.dedup();
265        bucket
266    }
267
268    fn candidate_sort_key(&self, node_id: NodeId) -> CandidateSortKey {
269        let Some(entry) = self.get_node(node_id) else {
270            return CandidateSortKey::default_for(node_id);
271        };
272
273        let file_path = self
274            .files()
275            .resolve(entry.file)
276            .map_or_else(String::new, |path| path.to_string_lossy().into_owned());
277        let qualified_name = entry
278            .qualified_name
279            .and_then(|string_id| self.strings().resolve(string_id))
280            .map_or_else(String::new, |value| value.to_string());
281        let simple_name = self
282            .strings()
283            .resolve(entry.name)
284            .map_or_else(String::new, |value| value.to_string());
285
286        CandidateSortKey {
287            file_path,
288            start_line: entry.start_line,
289            start_column: entry.start_column,
290            end_line: entry.end_line,
291            end_column: entry.end_column,
292            kind: entry.kind.as_str().to_string(),
293            qualified_name,
294            simple_name,
295            node_id,
296        }
297    }
298}
299
300#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
301struct CandidateSortKey {
302    file_path: String,
303    start_line: u32,
304    start_column: u32,
305    end_line: u32,
306    end_column: u32,
307    kind: String,
308    qualified_name: String,
309    simple_name: String,
310    node_id: NodeId,
311}
312
313impl CandidateSortKey {
314    fn default_for(node_id: NodeId) -> Self {
315        Self {
316            file_path: String::new(),
317            start_line: 0,
318            start_column: 0,
319            end_line: 0,
320            end_column: 0,
321            kind: String::new(),
322            qualified_name: String::new(),
323            simple_name: String::new(),
324            node_id,
325        }
326    }
327}
328
329/// Canonicalize a language-native qualified name into graph-internal `::` form.
330#[must_use]
331pub(crate) fn canonicalize_graph_qualified_name(language: Language, symbol: &str) -> String {
332    if should_skip_qualified_name_normalization(symbol) {
333        return symbol.to_string();
334    }
335
336    if language == Language::R {
337        return canonicalize_r_qualified_name(symbol);
338    }
339
340    let mut normalized = symbol.to_string();
341    for delimiter in native_delimiters(language) {
342        if normalized.contains(delimiter) {
343            normalized = normalized.replace(delimiter, "::");
344        }
345    }
346    normalized
347}
348
349/// Returns `true` when a qualified name is already in graph-canonical form.
350#[must_use]
351pub(crate) fn is_canonical_graph_qualified_name(language: Language, symbol: &str) -> bool {
352    should_skip_qualified_name_normalization(symbol)
353        || canonicalize_graph_qualified_name(language, symbol) == symbol
354}
355
356fn should_skip_qualified_name_normalization(symbol: &str) -> bool {
357    symbol.starts_with('<')
358        || symbol.contains('/')
359        || symbol.starts_with("wasm::")
360        || symbol.starts_with("ffi::")
361        || symbol.starts_with("extern::")
362        || symbol.starts_with("native::")
363}
364
365fn canonicalize_r_qualified_name(symbol: &str) -> String {
366    let search_start = usize::from(symbol.starts_with('.'));
367    let Some(relative_split_index) = symbol[search_start..].rfind('.') else {
368        return symbol.to_string();
369    };
370
371    let split_index = search_start + relative_split_index;
372    let prefix = &symbol[..split_index];
373    let suffix = &symbol[split_index + 1..];
374    if suffix.is_empty() {
375        return symbol.to_string();
376    }
377
378    format!("{prefix}::{suffix}")
379}
380
381/// Convert a canonical graph qualified name into native language display form.
382#[must_use]
383pub fn display_graph_qualified_name(
384    language: Language,
385    qualified: &str,
386    kind: NodeKind,
387    is_static: bool,
388) -> String {
389    if should_skip_qualified_name_normalization(qualified) {
390        return qualified.to_string();
391    }
392
393    match language {
394        Language::Ruby => display_ruby_qualified_name(qualified, kind, is_static),
395        Language::Php => display_php_qualified_name(qualified, kind),
396        _ => native_display_separator(language).map_or_else(
397            || qualified.to_string(),
398            |separator| qualified.replace("::", separator),
399        ),
400    }
401}
402
403pub(crate) fn native_delimiters(language: Language) -> &'static [&'static str] {
404    match language {
405        Language::JavaScript
406        | Language::Python
407        | Language::TypeScript
408        | Language::Java
409        | Language::CSharp
410        | Language::Kotlin
411        | Language::Scala
412        | Language::Go
413        | Language::Css
414        | Language::Sql
415        | Language::Dart
416        | Language::Lua
417        | Language::Perl
418        | Language::Groovy
419        | Language::Elixir
420        | Language::R
421        | Language::Haskell
422        | Language::Html
423        | Language::Svelte
424        | Language::Vue
425        | Language::Terraform
426        | Language::Puppet
427        | Language::Pulumi
428        | Language::Http
429        | Language::Plsql
430        | Language::Apex
431        | Language::Abap
432        | Language::ServiceNow
433        | Language::Swift
434        | Language::Zig => &["."],
435        Language::Ruby => &["#", "."],
436        Language::Php => &["\\", "->"],
437        Language::C | Language::Cpp | Language::Rust | Language::Shell => &[],
438    }
439}
440
441fn native_display_separator(language: Language) -> Option<&'static str> {
442    match language {
443        Language::C
444        | Language::Cpp
445        | Language::Rust
446        | Language::Shell
447        | Language::Php
448        | Language::Ruby => None,
449        _ => Some("."),
450    }
451}
452
453fn display_ruby_qualified_name(qualified: &str, kind: NodeKind, is_static: bool) -> String {
454    if qualified.contains('#') || qualified.contains('.') || !qualified.contains("::") {
455        return qualified.to_string();
456    }
457
458    match kind {
459        NodeKind::Method => {
460            replace_last_separator(qualified, if is_static { "." } else { "#" }, false)
461        }
462        NodeKind::Variable if should_display_ruby_member_variable(qualified) => {
463            replace_last_separator(qualified, "#", false)
464        }
465        _ => qualified.to_string(),
466    }
467}
468
469fn should_display_ruby_member_variable(qualified: &str) -> bool {
470    let Some((_, suffix)) = qualified.rsplit_once("::") else {
471        return false;
472    };
473
474    if suffix.starts_with("@@")
475        || suffix
476            .chars()
477            .next()
478            .is_some_and(|character| character.is_ascii_uppercase())
479    {
480        return false;
481    }
482
483    suffix.starts_with('@')
484        || suffix
485            .chars()
486            .next()
487            .is_some_and(|character| character.is_ascii_lowercase() || character == '_')
488}
489
490fn display_php_qualified_name(qualified: &str, kind: NodeKind) -> String {
491    if !qualified.contains("::") {
492        return qualified.to_string();
493    }
494
495    if matches!(kind, NodeKind::Method | NodeKind::Property) {
496        return replace_last_separator(qualified, "::", true);
497    }
498
499    qualified.replace("::", "\\")
500}
501
502fn replace_last_separator(qualified: &str, final_separator: &str, preserve_prefix: bool) -> String {
503    let Some((prefix, suffix)) = qualified.rsplit_once("::") else {
504        return qualified.to_string();
505    };
506
507    let display_prefix = if preserve_prefix {
508        prefix.replace("::", "\\")
509    } else {
510        prefix.to_string()
511    };
512
513    if display_prefix.is_empty() {
514        suffix.to_string()
515    } else {
516        format!("{display_prefix}{final_separator}{suffix}")
517    }
518}
519
520#[cfg(test)]
521mod tests {
522    use std::path::{Path, PathBuf};
523
524    use crate::graph::node::Language;
525    use crate::graph::unified::concurrent::CodeGraph;
526    use crate::graph::unified::node::id::NodeId;
527    use crate::graph::unified::node::kind::NodeKind;
528    use crate::graph::unified::storage::arena::NodeEntry;
529
530    use super::{
531        FileScope, NormalizedSymbolQuery, ResolutionMode, ResolvedFileScope,
532        SymbolCandidateOutcome, SymbolQuery, SymbolResolutionOutcome,
533        canonicalize_graph_qualified_name, display_graph_qualified_name,
534    };
535
536    struct TestNode {
537        node_id: NodeId,
538    }
539
540    #[test]
541    fn test_resolve_symbol_exact_qualified_same_file() {
542        let mut graph = CodeGraph::new();
543        let file_path = abs_path("src/lib.rs");
544        let symbol = add_node(
545            &mut graph,
546            NodeKind::Function,
547            "target",
548            Some("pkg::target"),
549            &file_path,
550            Some(Language::Rust),
551            10,
552            2,
553        );
554
555        let snapshot = graph.snapshot();
556        let query = SymbolQuery {
557            symbol: "pkg::target",
558            file_scope: FileScope::Path(&file_path),
559            mode: ResolutionMode::Strict,
560        };
561
562        assert_eq!(
563            snapshot.resolve_symbol(&query),
564            SymbolResolutionOutcome::Resolved(symbol.node_id)
565        );
566    }
567
568    #[test]
569    fn test_resolve_symbol_exact_simple_same_file_wins() {
570        let mut graph = CodeGraph::new();
571        let requested_path = abs_path("src/requested.rs");
572        let other_path = abs_path("src/other.rs");
573
574        let requested = add_node(
575            &mut graph,
576            NodeKind::Function,
577            "target",
578            Some("requested::target"),
579            &requested_path,
580            Some(Language::Rust),
581            4,
582            0,
583        );
584        let _other = add_node(
585            &mut graph,
586            NodeKind::Function,
587            "target",
588            Some("other::target"),
589            &other_path,
590            Some(Language::Rust),
591            1,
592            0,
593        );
594
595        let snapshot = graph.snapshot();
596        let query = SymbolQuery {
597            symbol: "target",
598            file_scope: FileScope::Path(&requested_path),
599            mode: ResolutionMode::Strict,
600        };
601
602        assert_eq!(
603            snapshot.resolve_symbol(&query),
604            SymbolResolutionOutcome::Resolved(requested.node_id)
605        );
606    }
607
608    #[test]
609    fn test_resolve_symbol_returns_not_found_without_wrong_file_fallback() {
610        let mut graph = CodeGraph::new();
611        let requested_path = abs_path("src/requested.rs");
612        let other_path = abs_path("src/other.rs");
613
614        let _requested_index_anchor = add_node(
615            &mut graph,
616            NodeKind::Function,
617            "anchor",
618            Some("requested::anchor"),
619            &requested_path,
620            Some(Language::Rust),
621            1,
622            0,
623        );
624        let _other = add_node(
625            &mut graph,
626            NodeKind::Function,
627            "target",
628            Some("other::target"),
629            &other_path,
630            Some(Language::Rust),
631            3,
632            0,
633        );
634
635        let snapshot = graph.snapshot();
636        let query = SymbolQuery {
637            symbol: "target",
638            file_scope: FileScope::Path(&requested_path),
639            mode: ResolutionMode::Strict,
640        };
641
642        assert_eq!(
643            snapshot.resolve_symbol(&query),
644            SymbolResolutionOutcome::NotFound
645        );
646    }
647
648    #[test]
649    fn test_resolve_symbol_returns_file_not_indexed_for_valid_unindexed_path() {
650        let mut graph = CodeGraph::new();
651        let indexed_path = abs_path("src/indexed.rs");
652        let unindexed_path = abs_path("src/unindexed.rs");
653
654        add_node(
655            &mut graph,
656            NodeKind::Function,
657            "indexed",
658            Some("pkg::indexed"),
659            &indexed_path,
660            Some(Language::Rust),
661            1,
662            0,
663        );
664        graph
665            .files_mut()
666            .register_with_language(&unindexed_path, Some(Language::Rust))
667            .unwrap();
668
669        let snapshot = graph.snapshot();
670        let query = SymbolQuery {
671            symbol: "indexed",
672            file_scope: FileScope::Path(&unindexed_path),
673            mode: ResolutionMode::Strict,
674        };
675
676        assert_eq!(
677            snapshot.resolve_symbol(&query),
678            SymbolResolutionOutcome::FileNotIndexed
679        );
680    }
681
682    #[test]
683    fn test_resolve_symbol_returns_ambiguous_for_multi_match_bucket() {
684        let mut graph = CodeGraph::new();
685        let file_path = abs_path("src/lib.rs");
686
687        let first = add_node(
688            &mut graph,
689            NodeKind::Function,
690            "dup",
691            Some("pkg::dup"),
692            &file_path,
693            Some(Language::Rust),
694            2,
695            0,
696        );
697        let second = add_node(
698            &mut graph,
699            NodeKind::Method,
700            "dup",
701            Some("pkg::dup_method"),
702            &file_path,
703            Some(Language::Rust),
704            8,
705            0,
706        );
707
708        let snapshot = graph.snapshot();
709        let query = SymbolQuery {
710            symbol: "dup",
711            file_scope: FileScope::Path(&file_path),
712            mode: ResolutionMode::Strict,
713        };
714
715        assert_eq!(
716            snapshot.resolve_symbol(&query),
717            SymbolResolutionOutcome::Ambiguous(vec![first.node_id, second.node_id])
718        );
719    }
720
721    #[test]
722    fn test_find_symbol_candidates_uses_first_non_empty_bucket_only() {
723        let mut graph = CodeGraph::new();
724        let qualified_path = abs_path("src/qualified.rs");
725        let simple_path = abs_path("src/simple.rs");
726
727        let qualified = add_node(
728            &mut graph,
729            NodeKind::Function,
730            "target",
731            Some("pkg::target"),
732            &qualified_path,
733            Some(Language::Rust),
734            1,
735            0,
736        );
737        let simple_only = add_node(
738            &mut graph,
739            NodeKind::Function,
740            "pkg::target",
741            None,
742            &simple_path,
743            Some(Language::Rust),
744            1,
745            0,
746        );
747
748        let snapshot = graph.snapshot();
749        let query = SymbolQuery {
750            symbol: "pkg::target",
751            file_scope: FileScope::Any,
752            mode: ResolutionMode::AllowSuffixCandidates,
753        };
754
755        assert_eq!(
756            snapshot.find_symbol_candidates(&query),
757            SymbolCandidateOutcome::Candidates(vec![qualified.node_id])
758        );
759        assert_ne!(qualified.node_id, simple_only.node_id);
760    }
761
762    #[test]
763    fn test_find_symbol_candidates_preserves_file_not_indexed() {
764        let mut graph = CodeGraph::new();
765        let indexed_path = abs_path("src/indexed.rs");
766        let unindexed_path = abs_path("src/unindexed.rs");
767
768        add_node(
769            &mut graph,
770            NodeKind::Function,
771            "target",
772            Some("pkg::target"),
773            &indexed_path,
774            Some(Language::Rust),
775            1,
776            0,
777        );
778        let unindexed_file_id = graph
779            .files_mut()
780            .register_with_language(&unindexed_path, Some(Language::Rust))
781            .unwrap();
782
783        let snapshot = graph.snapshot();
784        let query = SymbolQuery {
785            symbol: "target",
786            file_scope: FileScope::FileId(unindexed_file_id),
787            mode: ResolutionMode::AllowSuffixCandidates,
788        };
789
790        assert_eq!(
791            snapshot.find_symbol_candidates(&query),
792            SymbolCandidateOutcome::FileNotIndexed
793        );
794    }
795
796    #[test]
797    fn test_suffix_candidates_disabled_in_strict_mode() {
798        let mut graph = CodeGraph::new();
799        let file_path = abs_path("src/lib.rs");
800
801        let suffix_match = add_node(
802            &mut graph,
803            NodeKind::Function,
804            "target",
805            Some("outer::pkg::target"),
806            &file_path,
807            Some(Language::Rust),
808            1,
809            0,
810        );
811
812        let snapshot = graph.snapshot();
813        let strict_query = SymbolQuery {
814            symbol: "pkg::target",
815            file_scope: FileScope::Any,
816            mode: ResolutionMode::Strict,
817        };
818        let suffix_query = SymbolQuery {
819            mode: ResolutionMode::AllowSuffixCandidates,
820            ..strict_query
821        };
822
823        assert_eq!(
824            snapshot.resolve_symbol(&strict_query),
825            SymbolResolutionOutcome::NotFound
826        );
827        assert_eq!(
828            snapshot.find_symbol_candidates(&suffix_query),
829            SymbolCandidateOutcome::Candidates(vec![suffix_match.node_id])
830        );
831    }
832
833    #[test]
834    fn test_suffix_candidates_require_canonical_qualified_query() {
835        let mut graph = CodeGraph::new();
836        let file_path = abs_path("src/mod.py");
837
838        add_node(
839            &mut graph,
840            NodeKind::Function,
841            "target",
842            Some("pkg::target"),
843            &file_path,
844            Some(Language::Python),
845            1,
846            0,
847        );
848
849        let snapshot = graph.snapshot();
850        let query = SymbolQuery {
851            symbol: "pkg.target",
852            file_scope: FileScope::Any,
853            mode: ResolutionMode::AllowSuffixCandidates,
854        };
855
856        assert_eq!(
857            snapshot.find_symbol_candidates(&query),
858            SymbolCandidateOutcome::NotFound
859        );
860    }
861
862    #[test]
863    fn test_suffix_candidates_filter_same_leaf_bucket_only() {
864        let mut graph = CodeGraph::new();
865        let file_path = abs_path("src/lib.rs");
866
867        let exact_suffix = add_node(
868            &mut graph,
869            NodeKind::Function,
870            "target",
871            Some("outer::pkg::target"),
872            &file_path,
873            Some(Language::Rust),
874            2,
875            0,
876        );
877        let another_suffix = add_node(
878            &mut graph,
879            NodeKind::Method,
880            "target",
881            Some("another::pkg::target"),
882            &file_path,
883            Some(Language::Rust),
884            4,
885            0,
886        );
887        let unrelated = add_node(
888            &mut graph,
889            NodeKind::Function,
890            "target",
891            Some("pkg::different::target"),
892            &file_path,
893            Some(Language::Rust),
894            6,
895            0,
896        );
897
898        let snapshot = graph.snapshot();
899        let query = SymbolQuery {
900            symbol: "pkg::target",
901            file_scope: FileScope::Any,
902            mode: ResolutionMode::AllowSuffixCandidates,
903        };
904
905        assert_eq!(
906            snapshot.find_symbol_candidates(&query),
907            SymbolCandidateOutcome::Candidates(vec![exact_suffix.node_id, another_suffix.node_id])
908        );
909        assert_ne!(unrelated.node_id, exact_suffix.node_id);
910    }
911
912    #[test]
913    fn test_normalize_symbol_query_rewrites_native_delimiter_when_file_scope_language_known() {
914        let mut graph = CodeGraph::new();
915        let file_path = abs_path("src/mod.py");
916        let file_id = graph
917            .files_mut()
918            .register_with_language(&file_path, Some(Language::Python))
919            .unwrap();
920        let snapshot = graph.snapshot();
921        let query = SymbolQuery {
922            symbol: "pkg.mod.fn",
923            file_scope: FileScope::Path(&file_path),
924            mode: ResolutionMode::Strict,
925        };
926
927        let normalized = snapshot.normalize_symbol_query(&query, &ResolvedFileScope::File(file_id));
928
929        assert_eq!(
930            normalized,
931            NormalizedSymbolQuery {
932                symbol: "pkg::mod::fn".to_string(),
933                file_scope: ResolvedFileScope::File(file_id),
934                mode: ResolutionMode::Strict,
935            }
936        );
937    }
938
939    #[test]
940    fn test_normalize_symbol_query_rewrites_native_delimiter_for_csharp() {
941        let mut graph = CodeGraph::new();
942        let file_path = abs_path("src/Program.cs");
943        let file_id = graph
944            .files_mut()
945            .register_with_language(&file_path, Some(Language::CSharp))
946            .unwrap();
947        let snapshot = graph.snapshot();
948        let query = SymbolQuery {
949            symbol: "System.Console.WriteLine",
950            file_scope: FileScope::Path(&file_path),
951            mode: ResolutionMode::Strict,
952        };
953
954        let normalized = snapshot.normalize_symbol_query(&query, &ResolvedFileScope::File(file_id));
955
956        assert_eq!(normalized.symbol, "System::Console::WriteLine".to_string());
957    }
958
959    #[test]
960    fn test_normalize_symbol_query_rewrites_native_delimiter_for_zig() {
961        let mut graph = CodeGraph::new();
962        let file_path = abs_path("src/main.zig");
963        let file_id = graph
964            .files_mut()
965            .register_with_language(&file_path, Some(Language::Zig))
966            .unwrap();
967        let snapshot = graph.snapshot();
968        let query = SymbolQuery {
969            symbol: "std.os.linux.exit",
970            file_scope: FileScope::Path(&file_path),
971            mode: ResolutionMode::Strict,
972        };
973
974        let normalized = snapshot.normalize_symbol_query(&query, &ResolvedFileScope::File(file_id));
975
976        assert_eq!(normalized.symbol, "std::os::linux::exit".to_string());
977    }
978
979    #[test]
980    fn test_normalize_symbol_query_does_not_rewrite_when_file_scope_any() {
981        let graph = CodeGraph::new();
982        let snapshot = graph.snapshot();
983        let query = SymbolQuery {
984            symbol: "pkg.mod.fn",
985            file_scope: FileScope::Any,
986            mode: ResolutionMode::Strict,
987        };
988
989        let normalized = snapshot.normalize_symbol_query(&query, &ResolvedFileScope::Any);
990
991        assert_eq!(
992            normalized,
993            NormalizedSymbolQuery {
994                symbol: "pkg.mod.fn".to_string(),
995                file_scope: ResolvedFileScope::Any,
996                mode: ResolutionMode::Strict,
997            }
998        );
999    }
1000
1001    #[test]
1002    fn test_global_qualified_query_with_native_delimiter_is_exact_only_and_not_found() {
1003        let mut graph = CodeGraph::new();
1004        let file_path = abs_path("src/mod.py");
1005
1006        add_node(
1007            &mut graph,
1008            NodeKind::Function,
1009            "fn",
1010            Some("pkg::mod::fn"),
1011            &file_path,
1012            Some(Language::Python),
1013            1,
1014            0,
1015        );
1016
1017        let snapshot = graph.snapshot();
1018        let query = SymbolQuery {
1019            symbol: "pkg.mod.fn",
1020            file_scope: FileScope::Any,
1021            mode: ResolutionMode::AllowSuffixCandidates,
1022        };
1023
1024        assert_eq!(
1025            snapshot.resolve_symbol(&query),
1026            SymbolResolutionOutcome::NotFound
1027        );
1028    }
1029
1030    #[test]
1031    fn test_global_canonical_qualified_query_can_hit_exact_qualified_bucket() {
1032        let mut graph = CodeGraph::new();
1033        let file_path = abs_path("src/lib.rs");
1034        let expected = add_node(
1035            &mut graph,
1036            NodeKind::Function,
1037            "fn",
1038            Some("pkg::mod::fn"),
1039            &file_path,
1040            Some(Language::Rust),
1041            1,
1042            0,
1043        );
1044
1045        let snapshot = graph.snapshot();
1046        let query = SymbolQuery {
1047            symbol: "pkg::mod::fn",
1048            file_scope: FileScope::Any,
1049            mode: ResolutionMode::Strict,
1050        };
1051
1052        assert_eq!(
1053            snapshot.resolve_symbol(&query),
1054            SymbolResolutionOutcome::Resolved(expected.node_id)
1055        );
1056    }
1057
1058    #[test]
1059    fn test_candidate_order_uses_metadata_then_node_id() {
1060        let mut graph = CodeGraph::new();
1061        let file_path = abs_path("src/lib.rs");
1062
1063        let first = add_node(
1064            &mut graph,
1065            NodeKind::Function,
1066            "dup",
1067            Some("pkg::dup_a"),
1068            &file_path,
1069            Some(Language::Rust),
1070            1,
1071            0,
1072        );
1073        let second = add_node(
1074            &mut graph,
1075            NodeKind::Function,
1076            "dup",
1077            Some("pkg::dup_b"),
1078            &file_path,
1079            Some(Language::Rust),
1080            1,
1081            0,
1082        );
1083
1084        let snapshot = graph.snapshot();
1085        let query = SymbolQuery {
1086            symbol: "dup",
1087            file_scope: FileScope::Any,
1088            mode: ResolutionMode::Strict,
1089        };
1090
1091        assert_eq!(
1092            snapshot.find_symbol_candidates(&query),
1093            SymbolCandidateOutcome::Candidates(vec![first.node_id, second.node_id])
1094        );
1095    }
1096
1097    #[test]
1098    fn test_candidate_order_kind_sort_key_uses_node_kind_as_str() {
1099        let mut graph = CodeGraph::new();
1100        let file_path = abs_path("src/lib.rs");
1101
1102        let function_node = add_node(
1103            &mut graph,
1104            NodeKind::Function,
1105            "shared",
1106            Some("pkg::shared_fn"),
1107            &file_path,
1108            Some(Language::Rust),
1109            1,
1110            0,
1111        );
1112        let variable_node = add_node(
1113            &mut graph,
1114            NodeKind::Variable,
1115            "shared",
1116            Some("pkg::shared_var"),
1117            &file_path,
1118            Some(Language::Rust),
1119            1,
1120            0,
1121        );
1122
1123        let snapshot = graph.snapshot();
1124        let query = SymbolQuery {
1125            symbol: "shared",
1126            file_scope: FileScope::Any,
1127            mode: ResolutionMode::Strict,
1128        };
1129
1130        assert_eq!(
1131            snapshot.find_symbol_candidates(&query),
1132            SymbolCandidateOutcome::Candidates(vec![function_node.node_id, variable_node.node_id])
1133        );
1134    }
1135
1136    fn add_node(
1137        graph: &mut CodeGraph,
1138        kind: NodeKind,
1139        name: &str,
1140        qualified_name: Option<&str>,
1141        file_path: &Path,
1142        language: Option<Language>,
1143        start_line: u32,
1144        start_column: u32,
1145    ) -> TestNode {
1146        let name_id = graph.strings_mut().intern(name).unwrap();
1147        let qualified_name_id =
1148            qualified_name.map(|value| graph.strings_mut().intern(value).unwrap());
1149        let file_id = graph
1150            .files_mut()
1151            .register_with_language(file_path, language)
1152            .unwrap();
1153
1154        let entry = NodeEntry::new(kind, name_id, file_id)
1155            .with_qualified_name_opt(qualified_name_id)
1156            .with_location(start_line, start_column, start_line, start_column + 1);
1157
1158        let node_id = graph.nodes_mut().alloc(entry).unwrap();
1159        graph
1160            .indices_mut()
1161            .add(node_id, kind, name_id, qualified_name_id, file_id);
1162
1163        TestNode { node_id }
1164    }
1165
1166    trait NodeEntryExt {
1167        fn with_qualified_name_opt(
1168            self,
1169            qualified_name: Option<crate::graph::unified::string::id::StringId>,
1170        ) -> Self;
1171    }
1172
1173    impl NodeEntryExt for NodeEntry {
1174        fn with_qualified_name_opt(
1175            mut self,
1176            qualified_name: Option<crate::graph::unified::string::id::StringId>,
1177        ) -> Self {
1178            self.qualified_name = qualified_name;
1179            self
1180        }
1181    }
1182
1183    fn abs_path(relative: &str) -> PathBuf {
1184        PathBuf::from("/resolver-tests").join(relative)
1185    }
1186
1187    #[test]
1188    fn test_display_graph_qualified_name_dot_language() {
1189        let display = display_graph_qualified_name(
1190            Language::CSharp,
1191            "MyApp::User::GetName",
1192            NodeKind::Method,
1193            false,
1194        );
1195        assert_eq!(display, "MyApp.User.GetName");
1196    }
1197
1198    #[test]
1199    fn test_canonicalize_graph_qualified_name_r_private_name_preserved() {
1200        assert_eq!(
1201            canonicalize_graph_qualified_name(Language::R, ".private_func"),
1202            ".private_func"
1203        );
1204    }
1205
1206    #[test]
1207    fn test_canonicalize_graph_qualified_name_r_s3_method_uses_last_dot() {
1208        assert_eq!(
1209            canonicalize_graph_qualified_name(Language::R, "as.data.frame.myclass"),
1210            "as.data.frame::myclass"
1211        );
1212    }
1213
1214    #[test]
1215    fn test_canonicalize_graph_qualified_name_r_leading_dot_s3_generic() {
1216        assert_eq!(
1217            canonicalize_graph_qualified_name(Language::R, ".DollarNames.myclass"),
1218            ".DollarNames::myclass"
1219        );
1220    }
1221
1222    #[test]
1223    fn test_display_graph_qualified_name_ruby_instance_method() {
1224        let display = display_graph_qualified_name(
1225            Language::Ruby,
1226            "Admin::Users::Controller::show",
1227            NodeKind::Method,
1228            false,
1229        );
1230        assert_eq!(display, "Admin::Users::Controller#show");
1231    }
1232
1233    #[test]
1234    fn test_display_graph_qualified_name_ruby_singleton_method() {
1235        let display = display_graph_qualified_name(
1236            Language::Ruby,
1237            "Admin::Users::Controller::show",
1238            NodeKind::Method,
1239            true,
1240        );
1241        assert_eq!(display, "Admin::Users::Controller.show");
1242    }
1243
1244    #[test]
1245    fn test_display_graph_qualified_name_ruby_member_variable() {
1246        let display = display_graph_qualified_name(
1247            Language::Ruby,
1248            "Admin::Users::Controller::username",
1249            NodeKind::Variable,
1250            false,
1251        );
1252        assert_eq!(display, "Admin::Users::Controller#username");
1253    }
1254
1255    #[test]
1256    fn test_display_graph_qualified_name_ruby_instance_variable() {
1257        let display = display_graph_qualified_name(
1258            Language::Ruby,
1259            "Admin::Users::Controller::@current_user",
1260            NodeKind::Variable,
1261            false,
1262        );
1263        assert_eq!(display, "Admin::Users::Controller#@current_user");
1264    }
1265
1266    #[test]
1267    fn test_display_graph_qualified_name_ruby_constant_stays_canonical() {
1268        let display = display_graph_qualified_name(
1269            Language::Ruby,
1270            "Admin::Users::Controller::DEFAULT_ROLE",
1271            NodeKind::Variable,
1272            false,
1273        );
1274        assert_eq!(display, "Admin::Users::Controller::DEFAULT_ROLE");
1275    }
1276
1277    #[test]
1278    fn test_display_graph_qualified_name_ruby_class_variable_stays_canonical() {
1279        let display = display_graph_qualified_name(
1280            Language::Ruby,
1281            "Admin::Users::Controller::@@count",
1282            NodeKind::Variable,
1283            false,
1284        );
1285        assert_eq!(display, "Admin::Users::Controller::@@count");
1286    }
1287
1288    #[test]
1289    fn test_display_graph_qualified_name_php_namespace_function() {
1290        let display = display_graph_qualified_name(
1291            Language::Php,
1292            "App::Services::send_mail",
1293            NodeKind::Function,
1294            false,
1295        );
1296        assert_eq!(display, "App\\Services\\send_mail");
1297    }
1298
1299    #[test]
1300    fn test_display_graph_qualified_name_php_method() {
1301        let display = display_graph_qualified_name(
1302            Language::Php,
1303            "App::Services::Mailer::deliver",
1304            NodeKind::Method,
1305            false,
1306        );
1307        assert_eq!(display, "App\\Services\\Mailer::deliver");
1308    }
1309
1310    #[test]
1311    fn test_display_graph_qualified_name_preserves_path_like_symbols() {
1312        let display = display_graph_qualified_name(
1313            Language::Go,
1314            "route::GET::/health",
1315            NodeKind::Endpoint,
1316            false,
1317        );
1318        assert_eq!(display, "route::GET::/health");
1319    }
1320
1321    #[test]
1322    fn test_display_graph_qualified_name_preserves_ffi_symbols() {
1323        let display = display_graph_qualified_name(
1324            Language::Haskell,
1325            "ffi::C::sin",
1326            NodeKind::Function,
1327            false,
1328        );
1329        assert_eq!(display, "ffi::C::sin");
1330    }
1331
1332    #[test]
1333    fn test_display_graph_qualified_name_preserves_native_cffi_symbols() {
1334        let display = display_graph_qualified_name(
1335            Language::Python,
1336            "native::cffi::calculate",
1337            NodeKind::Function,
1338            false,
1339        );
1340        assert_eq!(display, "native::cffi::calculate");
1341    }
1342
1343    #[test]
1344    fn test_display_graph_qualified_name_preserves_native_php_ffi_symbols() {
1345        let display = display_graph_qualified_name(
1346            Language::Php,
1347            "native::ffi::crypto_encrypt",
1348            NodeKind::Function,
1349            false,
1350        );
1351        assert_eq!(display, "native::ffi::crypto_encrypt");
1352    }
1353
1354    #[test]
1355    fn test_display_graph_qualified_name_preserves_native_panama_symbols() {
1356        let display = display_graph_qualified_name(
1357            Language::Java,
1358            "native::panama::nativeLinker",
1359            NodeKind::Function,
1360            false,
1361        );
1362        assert_eq!(display, "native::panama::nativeLinker");
1363    }
1364
1365    #[test]
1366    fn test_canonicalize_graph_qualified_name_preserves_wasm_symbols() {
1367        assert_eq!(
1368            canonicalize_graph_qualified_name(Language::TypeScript, "wasm::module.wasm"),
1369            "wasm::module.wasm"
1370        );
1371    }
1372
1373    #[test]
1374    fn test_canonicalize_graph_qualified_name_preserves_native_symbols() {
1375        assert_eq!(
1376            canonicalize_graph_qualified_name(Language::TypeScript, "native::binding.node"),
1377            "native::binding.node"
1378        );
1379    }
1380
1381    #[test]
1382    fn test_display_graph_qualified_name_preserves_wasm_symbols() {
1383        let display = display_graph_qualified_name(
1384            Language::TypeScript,
1385            "wasm::module.wasm",
1386            NodeKind::Module,
1387            false,
1388        );
1389        assert_eq!(display, "wasm::module.wasm");
1390    }
1391
1392    #[test]
1393    fn test_display_graph_qualified_name_preserves_native_symbols() {
1394        let display = display_graph_qualified_name(
1395            Language::TypeScript,
1396            "native::binding.node",
1397            NodeKind::Module,
1398            false,
1399        );
1400        assert_eq!(display, "native::binding.node");
1401    }
1402
1403    #[test]
1404    fn test_canonicalize_graph_qualified_name_still_normalizes_dot_language_symbols() {
1405        assert_eq!(
1406            canonicalize_graph_qualified_name(Language::TypeScript, "Foo.bar"),
1407            "Foo::bar"
1408        );
1409    }
1410}