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