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