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::Json => &["."],
436        Language::Ruby => &["#", "."],
437        Language::Php => &["\\", "->"],
438        Language::C | Language::Cpp | Language::Rust | Language::Shell => &[],
439    }
440}
441
442fn native_display_separator(language: Language) -> Option<&'static str> {
443    match language {
444        Language::C
445        | Language::Cpp
446        | Language::Rust
447        | Language::Shell
448        | Language::Php
449        | Language::Ruby => None,
450        _ => Some("."),
451    }
452}
453
454fn display_ruby_qualified_name(qualified: &str, kind: NodeKind, is_static: bool) -> String {
455    if qualified.contains('#') || qualified.contains('.') || !qualified.contains("::") {
456        return qualified.to_string();
457    }
458
459    match kind {
460        NodeKind::Method => {
461            replace_last_separator(qualified, if is_static { "." } else { "#" }, false)
462        }
463        NodeKind::Variable if should_display_ruby_member_variable(qualified) => {
464            replace_last_separator(qualified, "#", false)
465        }
466        _ => qualified.to_string(),
467    }
468}
469
470fn should_display_ruby_member_variable(qualified: &str) -> bool {
471    let Some((_, suffix)) = qualified.rsplit_once("::") else {
472        return false;
473    };
474
475    if suffix.starts_with("@@")
476        || suffix
477            .chars()
478            .next()
479            .is_some_and(|character| character.is_ascii_uppercase())
480    {
481        return false;
482    }
483
484    suffix.starts_with('@')
485        || suffix
486            .chars()
487            .next()
488            .is_some_and(|character| character.is_ascii_lowercase() || character == '_')
489}
490
491fn display_php_qualified_name(qualified: &str, kind: NodeKind) -> String {
492    if !qualified.contains("::") {
493        return qualified.to_string();
494    }
495
496    if matches!(kind, NodeKind::Method | NodeKind::Property) {
497        return replace_last_separator(qualified, "::", true);
498    }
499
500    qualified.replace("::", "\\")
501}
502
503fn replace_last_separator(qualified: &str, final_separator: &str, preserve_prefix: bool) -> String {
504    let Some((prefix, suffix)) = qualified.rsplit_once("::") else {
505        return qualified.to_string();
506    };
507
508    let display_prefix = if preserve_prefix {
509        prefix.replace("::", "\\")
510    } else {
511        prefix.to_string()
512    };
513
514    if display_prefix.is_empty() {
515        suffix.to_string()
516    } else {
517        format!("{display_prefix}{final_separator}{suffix}")
518    }
519}
520
521#[cfg(test)]
522mod tests {
523    use std::path::{Path, PathBuf};
524
525    use crate::graph::node::Language;
526    use crate::graph::unified::concurrent::CodeGraph;
527    use crate::graph::unified::node::id::NodeId;
528    use crate::graph::unified::node::kind::NodeKind;
529    use crate::graph::unified::storage::arena::NodeEntry;
530
531    use super::{
532        FileScope, NormalizedSymbolQuery, ResolutionMode, ResolvedFileScope,
533        SymbolCandidateOutcome, SymbolQuery, SymbolResolutionOutcome,
534        canonicalize_graph_qualified_name, display_graph_qualified_name,
535    };
536
537    struct TestNode {
538        node_id: NodeId,
539    }
540
541    #[test]
542    fn test_resolve_symbol_exact_qualified_same_file() {
543        let mut graph = CodeGraph::new();
544        let file_path = abs_path("src/lib.rs");
545        let symbol = add_node(
546            &mut graph,
547            NodeKind::Function,
548            "target",
549            Some("pkg::target"),
550            &file_path,
551            Some(Language::Rust),
552            10,
553            2,
554        );
555
556        let snapshot = graph.snapshot();
557        let query = SymbolQuery {
558            symbol: "pkg::target",
559            file_scope: FileScope::Path(&file_path),
560            mode: ResolutionMode::Strict,
561        };
562
563        assert_eq!(
564            snapshot.resolve_symbol(&query),
565            SymbolResolutionOutcome::Resolved(symbol.node_id)
566        );
567    }
568
569    #[test]
570    fn test_resolve_symbol_exact_simple_same_file_wins() {
571        let mut graph = CodeGraph::new();
572        let requested_path = abs_path("src/requested.rs");
573        let other_path = abs_path("src/other.rs");
574
575        let requested = add_node(
576            &mut graph,
577            NodeKind::Function,
578            "target",
579            Some("requested::target"),
580            &requested_path,
581            Some(Language::Rust),
582            4,
583            0,
584        );
585        let _other = add_node(
586            &mut graph,
587            NodeKind::Function,
588            "target",
589            Some("other::target"),
590            &other_path,
591            Some(Language::Rust),
592            1,
593            0,
594        );
595
596        let snapshot = graph.snapshot();
597        let query = SymbolQuery {
598            symbol: "target",
599            file_scope: FileScope::Path(&requested_path),
600            mode: ResolutionMode::Strict,
601        };
602
603        assert_eq!(
604            snapshot.resolve_symbol(&query),
605            SymbolResolutionOutcome::Resolved(requested.node_id)
606        );
607    }
608
609    #[test]
610    fn test_resolve_symbol_returns_not_found_without_wrong_file_fallback() {
611        let mut graph = CodeGraph::new();
612        let requested_path = abs_path("src/requested.rs");
613        let other_path = abs_path("src/other.rs");
614
615        let _requested_index_anchor = add_node(
616            &mut graph,
617            NodeKind::Function,
618            "anchor",
619            Some("requested::anchor"),
620            &requested_path,
621            Some(Language::Rust),
622            1,
623            0,
624        );
625        let _other = add_node(
626            &mut graph,
627            NodeKind::Function,
628            "target",
629            Some("other::target"),
630            &other_path,
631            Some(Language::Rust),
632            3,
633            0,
634        );
635
636        let snapshot = graph.snapshot();
637        let query = SymbolQuery {
638            symbol: "target",
639            file_scope: FileScope::Path(&requested_path),
640            mode: ResolutionMode::Strict,
641        };
642
643        assert_eq!(
644            snapshot.resolve_symbol(&query),
645            SymbolResolutionOutcome::NotFound
646        );
647    }
648
649    #[test]
650    fn test_resolve_symbol_returns_file_not_indexed_for_valid_unindexed_path() {
651        let mut graph = CodeGraph::new();
652        let indexed_path = abs_path("src/indexed.rs");
653        let unindexed_path = abs_path("src/unindexed.rs");
654
655        add_node(
656            &mut graph,
657            NodeKind::Function,
658            "indexed",
659            Some("pkg::indexed"),
660            &indexed_path,
661            Some(Language::Rust),
662            1,
663            0,
664        );
665        graph
666            .files_mut()
667            .register_with_language(&unindexed_path, Some(Language::Rust))
668            .unwrap();
669
670        let snapshot = graph.snapshot();
671        let query = SymbolQuery {
672            symbol: "indexed",
673            file_scope: FileScope::Path(&unindexed_path),
674            mode: ResolutionMode::Strict,
675        };
676
677        assert_eq!(
678            snapshot.resolve_symbol(&query),
679            SymbolResolutionOutcome::FileNotIndexed
680        );
681    }
682
683    #[test]
684    fn test_resolve_symbol_returns_ambiguous_for_multi_match_bucket() {
685        let mut graph = CodeGraph::new();
686        let file_path = abs_path("src/lib.rs");
687
688        let first = add_node(
689            &mut graph,
690            NodeKind::Function,
691            "dup",
692            Some("pkg::dup"),
693            &file_path,
694            Some(Language::Rust),
695            2,
696            0,
697        );
698        let second = add_node(
699            &mut graph,
700            NodeKind::Method,
701            "dup",
702            Some("pkg::dup_method"),
703            &file_path,
704            Some(Language::Rust),
705            8,
706            0,
707        );
708
709        let snapshot = graph.snapshot();
710        let query = SymbolQuery {
711            symbol: "dup",
712            file_scope: FileScope::Path(&file_path),
713            mode: ResolutionMode::Strict,
714        };
715
716        assert_eq!(
717            snapshot.resolve_symbol(&query),
718            SymbolResolutionOutcome::Ambiguous(vec![first.node_id, second.node_id])
719        );
720    }
721
722    #[test]
723    fn test_find_symbol_candidates_uses_first_non_empty_bucket_only() {
724        let mut graph = CodeGraph::new();
725        let qualified_path = abs_path("src/qualified.rs");
726        let simple_path = abs_path("src/simple.rs");
727
728        let qualified = add_node(
729            &mut graph,
730            NodeKind::Function,
731            "target",
732            Some("pkg::target"),
733            &qualified_path,
734            Some(Language::Rust),
735            1,
736            0,
737        );
738        let simple_only = add_node(
739            &mut graph,
740            NodeKind::Function,
741            "pkg::target",
742            None,
743            &simple_path,
744            Some(Language::Rust),
745            1,
746            0,
747        );
748
749        let snapshot = graph.snapshot();
750        let query = SymbolQuery {
751            symbol: "pkg::target",
752            file_scope: FileScope::Any,
753            mode: ResolutionMode::AllowSuffixCandidates,
754        };
755
756        assert_eq!(
757            snapshot.find_symbol_candidates(&query),
758            SymbolCandidateOutcome::Candidates(vec![qualified.node_id])
759        );
760        assert_ne!(qualified.node_id, simple_only.node_id);
761    }
762
763    #[test]
764    fn test_find_symbol_candidates_preserves_file_not_indexed() {
765        let mut graph = CodeGraph::new();
766        let indexed_path = abs_path("src/indexed.rs");
767        let unindexed_path = abs_path("src/unindexed.rs");
768
769        add_node(
770            &mut graph,
771            NodeKind::Function,
772            "target",
773            Some("pkg::target"),
774            &indexed_path,
775            Some(Language::Rust),
776            1,
777            0,
778        );
779        let unindexed_file_id = graph
780            .files_mut()
781            .register_with_language(&unindexed_path, Some(Language::Rust))
782            .unwrap();
783
784        let snapshot = graph.snapshot();
785        let query = SymbolQuery {
786            symbol: "target",
787            file_scope: FileScope::FileId(unindexed_file_id),
788            mode: ResolutionMode::AllowSuffixCandidates,
789        };
790
791        assert_eq!(
792            snapshot.find_symbol_candidates(&query),
793            SymbolCandidateOutcome::FileNotIndexed
794        );
795    }
796
797    #[test]
798    fn test_suffix_candidates_disabled_in_strict_mode() {
799        let mut graph = CodeGraph::new();
800        let file_path = abs_path("src/lib.rs");
801
802        let suffix_match = add_node(
803            &mut graph,
804            NodeKind::Function,
805            "target",
806            Some("outer::pkg::target"),
807            &file_path,
808            Some(Language::Rust),
809            1,
810            0,
811        );
812
813        let snapshot = graph.snapshot();
814        let strict_query = SymbolQuery {
815            symbol: "pkg::target",
816            file_scope: FileScope::Any,
817            mode: ResolutionMode::Strict,
818        };
819        let suffix_query = SymbolQuery {
820            mode: ResolutionMode::AllowSuffixCandidates,
821            ..strict_query
822        };
823
824        assert_eq!(
825            snapshot.resolve_symbol(&strict_query),
826            SymbolResolutionOutcome::NotFound
827        );
828        assert_eq!(
829            snapshot.find_symbol_candidates(&suffix_query),
830            SymbolCandidateOutcome::Candidates(vec![suffix_match.node_id])
831        );
832    }
833
834    #[test]
835    fn test_suffix_candidates_require_canonical_qualified_query() {
836        let mut graph = CodeGraph::new();
837        let file_path = abs_path("src/mod.py");
838
839        add_node(
840            &mut graph,
841            NodeKind::Function,
842            "target",
843            Some("pkg::target"),
844            &file_path,
845            Some(Language::Python),
846            1,
847            0,
848        );
849
850        let snapshot = graph.snapshot();
851        let query = SymbolQuery {
852            symbol: "pkg.target",
853            file_scope: FileScope::Any,
854            mode: ResolutionMode::AllowSuffixCandidates,
855        };
856
857        assert_eq!(
858            snapshot.find_symbol_candidates(&query),
859            SymbolCandidateOutcome::NotFound
860        );
861    }
862
863    #[test]
864    fn test_suffix_candidates_filter_same_leaf_bucket_only() {
865        let mut graph = CodeGraph::new();
866        let file_path = abs_path("src/lib.rs");
867
868        let exact_suffix = add_node(
869            &mut graph,
870            NodeKind::Function,
871            "target",
872            Some("outer::pkg::target"),
873            &file_path,
874            Some(Language::Rust),
875            2,
876            0,
877        );
878        let another_suffix = add_node(
879            &mut graph,
880            NodeKind::Method,
881            "target",
882            Some("another::pkg::target"),
883            &file_path,
884            Some(Language::Rust),
885            4,
886            0,
887        );
888        let unrelated = add_node(
889            &mut graph,
890            NodeKind::Function,
891            "target",
892            Some("pkg::different::target"),
893            &file_path,
894            Some(Language::Rust),
895            6,
896            0,
897        );
898
899        let snapshot = graph.snapshot();
900        let query = SymbolQuery {
901            symbol: "pkg::target",
902            file_scope: FileScope::Any,
903            mode: ResolutionMode::AllowSuffixCandidates,
904        };
905
906        assert_eq!(
907            snapshot.find_symbol_candidates(&query),
908            SymbolCandidateOutcome::Candidates(vec![exact_suffix.node_id, another_suffix.node_id])
909        );
910        assert_ne!(unrelated.node_id, exact_suffix.node_id);
911    }
912
913    #[test]
914    fn test_normalize_symbol_query_rewrites_native_delimiter_when_file_scope_language_known() {
915        let mut graph = CodeGraph::new();
916        let file_path = abs_path("src/mod.py");
917        let file_id = graph
918            .files_mut()
919            .register_with_language(&file_path, Some(Language::Python))
920            .unwrap();
921        let snapshot = graph.snapshot();
922        let query = SymbolQuery {
923            symbol: "pkg.mod.fn",
924            file_scope: FileScope::Path(&file_path),
925            mode: ResolutionMode::Strict,
926        };
927
928        let normalized = snapshot.normalize_symbol_query(&query, &ResolvedFileScope::File(file_id));
929
930        assert_eq!(
931            normalized,
932            NormalizedSymbolQuery {
933                symbol: "pkg::mod::fn".to_string(),
934                file_scope: ResolvedFileScope::File(file_id),
935                mode: ResolutionMode::Strict,
936            }
937        );
938    }
939
940    #[test]
941    fn test_normalize_symbol_query_rewrites_native_delimiter_for_csharp() {
942        let mut graph = CodeGraph::new();
943        let file_path = abs_path("src/Program.cs");
944        let file_id = graph
945            .files_mut()
946            .register_with_language(&file_path, Some(Language::CSharp))
947            .unwrap();
948        let snapshot = graph.snapshot();
949        let query = SymbolQuery {
950            symbol: "System.Console.WriteLine",
951            file_scope: FileScope::Path(&file_path),
952            mode: ResolutionMode::Strict,
953        };
954
955        let normalized = snapshot.normalize_symbol_query(&query, &ResolvedFileScope::File(file_id));
956
957        assert_eq!(normalized.symbol, "System::Console::WriteLine".to_string());
958    }
959
960    #[test]
961    fn test_normalize_symbol_query_rewrites_native_delimiter_for_zig() {
962        let mut graph = CodeGraph::new();
963        let file_path = abs_path("src/main.zig");
964        let file_id = graph
965            .files_mut()
966            .register_with_language(&file_path, Some(Language::Zig))
967            .unwrap();
968        let snapshot = graph.snapshot();
969        let query = SymbolQuery {
970            symbol: "std.os.linux.exit",
971            file_scope: FileScope::Path(&file_path),
972            mode: ResolutionMode::Strict,
973        };
974
975        let normalized = snapshot.normalize_symbol_query(&query, &ResolvedFileScope::File(file_id));
976
977        assert_eq!(normalized.symbol, "std::os::linux::exit".to_string());
978    }
979
980    #[test]
981    fn test_normalize_symbol_query_does_not_rewrite_when_file_scope_any() {
982        let graph = CodeGraph::new();
983        let snapshot = graph.snapshot();
984        let query = SymbolQuery {
985            symbol: "pkg.mod.fn",
986            file_scope: FileScope::Any,
987            mode: ResolutionMode::Strict,
988        };
989
990        let normalized = snapshot.normalize_symbol_query(&query, &ResolvedFileScope::Any);
991
992        assert_eq!(
993            normalized,
994            NormalizedSymbolQuery {
995                symbol: "pkg.mod.fn".to_string(),
996                file_scope: ResolvedFileScope::Any,
997                mode: ResolutionMode::Strict,
998            }
999        );
1000    }
1001
1002    #[test]
1003    fn test_global_qualified_query_with_native_delimiter_is_exact_only_and_not_found() {
1004        let mut graph = CodeGraph::new();
1005        let file_path = abs_path("src/mod.py");
1006
1007        add_node(
1008            &mut graph,
1009            NodeKind::Function,
1010            "fn",
1011            Some("pkg::mod::fn"),
1012            &file_path,
1013            Some(Language::Python),
1014            1,
1015            0,
1016        );
1017
1018        let snapshot = graph.snapshot();
1019        let query = SymbolQuery {
1020            symbol: "pkg.mod.fn",
1021            file_scope: FileScope::Any,
1022            mode: ResolutionMode::AllowSuffixCandidates,
1023        };
1024
1025        assert_eq!(
1026            snapshot.resolve_symbol(&query),
1027            SymbolResolutionOutcome::NotFound
1028        );
1029    }
1030
1031    #[test]
1032    fn test_global_canonical_qualified_query_can_hit_exact_qualified_bucket() {
1033        let mut graph = CodeGraph::new();
1034        let file_path = abs_path("src/lib.rs");
1035        let expected = add_node(
1036            &mut graph,
1037            NodeKind::Function,
1038            "fn",
1039            Some("pkg::mod::fn"),
1040            &file_path,
1041            Some(Language::Rust),
1042            1,
1043            0,
1044        );
1045
1046        let snapshot = graph.snapshot();
1047        let query = SymbolQuery {
1048            symbol: "pkg::mod::fn",
1049            file_scope: FileScope::Any,
1050            mode: ResolutionMode::Strict,
1051        };
1052
1053        assert_eq!(
1054            snapshot.resolve_symbol(&query),
1055            SymbolResolutionOutcome::Resolved(expected.node_id)
1056        );
1057    }
1058
1059    #[test]
1060    fn test_candidate_order_uses_metadata_then_node_id() {
1061        let mut graph = CodeGraph::new();
1062        let file_path = abs_path("src/lib.rs");
1063
1064        let first = add_node(
1065            &mut graph,
1066            NodeKind::Function,
1067            "dup",
1068            Some("pkg::dup_a"),
1069            &file_path,
1070            Some(Language::Rust),
1071            1,
1072            0,
1073        );
1074        let second = add_node(
1075            &mut graph,
1076            NodeKind::Function,
1077            "dup",
1078            Some("pkg::dup_b"),
1079            &file_path,
1080            Some(Language::Rust),
1081            1,
1082            0,
1083        );
1084
1085        let snapshot = graph.snapshot();
1086        let query = SymbolQuery {
1087            symbol: "dup",
1088            file_scope: FileScope::Any,
1089            mode: ResolutionMode::Strict,
1090        };
1091
1092        assert_eq!(
1093            snapshot.find_symbol_candidates(&query),
1094            SymbolCandidateOutcome::Candidates(vec![first.node_id, second.node_id])
1095        );
1096    }
1097
1098    #[test]
1099    fn test_candidate_order_kind_sort_key_uses_node_kind_as_str() {
1100        let mut graph = CodeGraph::new();
1101        let file_path = abs_path("src/lib.rs");
1102
1103        let function_node = add_node(
1104            &mut graph,
1105            NodeKind::Function,
1106            "shared",
1107            Some("pkg::shared_fn"),
1108            &file_path,
1109            Some(Language::Rust),
1110            1,
1111            0,
1112        );
1113        let variable_node = add_node(
1114            &mut graph,
1115            NodeKind::Variable,
1116            "shared",
1117            Some("pkg::shared_var"),
1118            &file_path,
1119            Some(Language::Rust),
1120            1,
1121            0,
1122        );
1123
1124        let snapshot = graph.snapshot();
1125        let query = SymbolQuery {
1126            symbol: "shared",
1127            file_scope: FileScope::Any,
1128            mode: ResolutionMode::Strict,
1129        };
1130
1131        assert_eq!(
1132            snapshot.find_symbol_candidates(&query),
1133            SymbolCandidateOutcome::Candidates(vec![function_node.node_id, variable_node.node_id])
1134        );
1135    }
1136
1137    fn add_node(
1138        graph: &mut CodeGraph,
1139        kind: NodeKind,
1140        name: &str,
1141        qualified_name: Option<&str>,
1142        file_path: &Path,
1143        language: Option<Language>,
1144        start_line: u32,
1145        start_column: u32,
1146    ) -> TestNode {
1147        let name_id = graph.strings_mut().intern(name).unwrap();
1148        let qualified_name_id =
1149            qualified_name.map(|value| graph.strings_mut().intern(value).unwrap());
1150        let file_id = graph
1151            .files_mut()
1152            .register_with_language(file_path, language)
1153            .unwrap();
1154
1155        let entry = NodeEntry::new(kind, name_id, file_id)
1156            .with_qualified_name_opt(qualified_name_id)
1157            .with_location(start_line, start_column, start_line, start_column + 1);
1158
1159        let node_id = graph.nodes_mut().alloc(entry).unwrap();
1160        graph
1161            .indices_mut()
1162            .add(node_id, kind, name_id, qualified_name_id, file_id);
1163
1164        TestNode { node_id }
1165    }
1166
1167    trait NodeEntryExt {
1168        fn with_qualified_name_opt(
1169            self,
1170            qualified_name: Option<crate::graph::unified::string::id::StringId>,
1171        ) -> Self;
1172    }
1173
1174    impl NodeEntryExt for NodeEntry {
1175        fn with_qualified_name_opt(
1176            mut self,
1177            qualified_name: Option<crate::graph::unified::string::id::StringId>,
1178        ) -> Self {
1179            self.qualified_name = qualified_name;
1180            self
1181        }
1182    }
1183
1184    fn abs_path(relative: &str) -> PathBuf {
1185        PathBuf::from("/resolver-tests").join(relative)
1186    }
1187
1188    #[test]
1189    fn test_display_graph_qualified_name_dot_language() {
1190        let display = display_graph_qualified_name(
1191            Language::CSharp,
1192            "MyApp::User::GetName",
1193            NodeKind::Method,
1194            false,
1195        );
1196        assert_eq!(display, "MyApp.User.GetName");
1197    }
1198
1199    #[test]
1200    fn test_canonicalize_graph_qualified_name_r_private_name_preserved() {
1201        assert_eq!(
1202            canonicalize_graph_qualified_name(Language::R, ".private_func"),
1203            ".private_func"
1204        );
1205    }
1206
1207    #[test]
1208    fn test_canonicalize_graph_qualified_name_r_s3_method_uses_last_dot() {
1209        assert_eq!(
1210            canonicalize_graph_qualified_name(Language::R, "as.data.frame.myclass"),
1211            "as.data.frame::myclass"
1212        );
1213    }
1214
1215    #[test]
1216    fn test_canonicalize_graph_qualified_name_r_leading_dot_s3_generic() {
1217        assert_eq!(
1218            canonicalize_graph_qualified_name(Language::R, ".DollarNames.myclass"),
1219            ".DollarNames::myclass"
1220        );
1221    }
1222
1223    #[test]
1224    fn test_display_graph_qualified_name_ruby_instance_method() {
1225        let display = display_graph_qualified_name(
1226            Language::Ruby,
1227            "Admin::Users::Controller::show",
1228            NodeKind::Method,
1229            false,
1230        );
1231        assert_eq!(display, "Admin::Users::Controller#show");
1232    }
1233
1234    #[test]
1235    fn test_display_graph_qualified_name_ruby_singleton_method() {
1236        let display = display_graph_qualified_name(
1237            Language::Ruby,
1238            "Admin::Users::Controller::show",
1239            NodeKind::Method,
1240            true,
1241        );
1242        assert_eq!(display, "Admin::Users::Controller.show");
1243    }
1244
1245    #[test]
1246    fn test_display_graph_qualified_name_ruby_member_variable() {
1247        let display = display_graph_qualified_name(
1248            Language::Ruby,
1249            "Admin::Users::Controller::username",
1250            NodeKind::Variable,
1251            false,
1252        );
1253        assert_eq!(display, "Admin::Users::Controller#username");
1254    }
1255
1256    #[test]
1257    fn test_display_graph_qualified_name_ruby_instance_variable() {
1258        let display = display_graph_qualified_name(
1259            Language::Ruby,
1260            "Admin::Users::Controller::@current_user",
1261            NodeKind::Variable,
1262            false,
1263        );
1264        assert_eq!(display, "Admin::Users::Controller#@current_user");
1265    }
1266
1267    #[test]
1268    fn test_display_graph_qualified_name_ruby_constant_stays_canonical() {
1269        let display = display_graph_qualified_name(
1270            Language::Ruby,
1271            "Admin::Users::Controller::DEFAULT_ROLE",
1272            NodeKind::Variable,
1273            false,
1274        );
1275        assert_eq!(display, "Admin::Users::Controller::DEFAULT_ROLE");
1276    }
1277
1278    #[test]
1279    fn test_display_graph_qualified_name_ruby_class_variable_stays_canonical() {
1280        let display = display_graph_qualified_name(
1281            Language::Ruby,
1282            "Admin::Users::Controller::@@count",
1283            NodeKind::Variable,
1284            false,
1285        );
1286        assert_eq!(display, "Admin::Users::Controller::@@count");
1287    }
1288
1289    #[test]
1290    fn test_display_graph_qualified_name_php_namespace_function() {
1291        let display = display_graph_qualified_name(
1292            Language::Php,
1293            "App::Services::send_mail",
1294            NodeKind::Function,
1295            false,
1296        );
1297        assert_eq!(display, "App\\Services\\send_mail");
1298    }
1299
1300    #[test]
1301    fn test_display_graph_qualified_name_php_method() {
1302        let display = display_graph_qualified_name(
1303            Language::Php,
1304            "App::Services::Mailer::deliver",
1305            NodeKind::Method,
1306            false,
1307        );
1308        assert_eq!(display, "App\\Services\\Mailer::deliver");
1309    }
1310
1311    #[test]
1312    fn test_display_graph_qualified_name_preserves_path_like_symbols() {
1313        let display = display_graph_qualified_name(
1314            Language::Go,
1315            "route::GET::/health",
1316            NodeKind::Endpoint,
1317            false,
1318        );
1319        assert_eq!(display, "route::GET::/health");
1320    }
1321
1322    #[test]
1323    fn test_display_graph_qualified_name_preserves_ffi_symbols() {
1324        let display = display_graph_qualified_name(
1325            Language::Haskell,
1326            "ffi::C::sin",
1327            NodeKind::Function,
1328            false,
1329        );
1330        assert_eq!(display, "ffi::C::sin");
1331    }
1332
1333    #[test]
1334    fn test_display_graph_qualified_name_preserves_native_cffi_symbols() {
1335        let display = display_graph_qualified_name(
1336            Language::Python,
1337            "native::cffi::calculate",
1338            NodeKind::Function,
1339            false,
1340        );
1341        assert_eq!(display, "native::cffi::calculate");
1342    }
1343
1344    #[test]
1345    fn test_display_graph_qualified_name_preserves_native_php_ffi_symbols() {
1346        let display = display_graph_qualified_name(
1347            Language::Php,
1348            "native::ffi::crypto_encrypt",
1349            NodeKind::Function,
1350            false,
1351        );
1352        assert_eq!(display, "native::ffi::crypto_encrypt");
1353    }
1354
1355    #[test]
1356    fn test_display_graph_qualified_name_preserves_native_panama_symbols() {
1357        let display = display_graph_qualified_name(
1358            Language::Java,
1359            "native::panama::nativeLinker",
1360            NodeKind::Function,
1361            false,
1362        );
1363        assert_eq!(display, "native::panama::nativeLinker");
1364    }
1365
1366    #[test]
1367    fn test_canonicalize_graph_qualified_name_preserves_wasm_symbols() {
1368        assert_eq!(
1369            canonicalize_graph_qualified_name(Language::TypeScript, "wasm::module.wasm"),
1370            "wasm::module.wasm"
1371        );
1372    }
1373
1374    #[test]
1375    fn test_canonicalize_graph_qualified_name_preserves_native_symbols() {
1376        assert_eq!(
1377            canonicalize_graph_qualified_name(Language::TypeScript, "native::binding.node"),
1378            "native::binding.node"
1379        );
1380    }
1381
1382    #[test]
1383    fn test_display_graph_qualified_name_preserves_wasm_symbols() {
1384        let display = display_graph_qualified_name(
1385            Language::TypeScript,
1386            "wasm::module.wasm",
1387            NodeKind::Module,
1388            false,
1389        );
1390        assert_eq!(display, "wasm::module.wasm");
1391    }
1392
1393    #[test]
1394    fn test_display_graph_qualified_name_preserves_native_symbols() {
1395        let display = display_graph_qualified_name(
1396            Language::TypeScript,
1397            "native::binding.node",
1398            NodeKind::Module,
1399            false,
1400        );
1401        assert_eq!(display, "native::binding.node");
1402    }
1403
1404    #[test]
1405    fn test_canonicalize_graph_qualified_name_still_normalizes_dot_language_symbols() {
1406        assert_eq!(
1407            canonicalize_graph_qualified_name(Language::TypeScript, "Foo.bar"),
1408            "Foo::bar"
1409        );
1410    }
1411}