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    /// Resolve a symbol to one [`NodeId`] with a typed ambiguity error.
413    ///
414    /// This is the single, shared resolver used by every CLI, LSP, and MCP
415    /// surface that must collapse a user-supplied symbol name to one
416    /// canonical node. It accepts both bare names (`NeedTags`) and
417    /// fully-qualified names (`main.SelectorSource.NeedTags` or
418    /// `main::SelectorSource::NeedTags`):
419    ///
420    /// * For a fully-qualified name, the resolver normalizes native
421    ///   delimiters (`.`) to graph-canonical `::` form and looks up the
422    ///   exact-qualified bucket. A unique match is the **only** acceptable
423    ///   resolution — there is no fuzzy fallback to simple-name candidates
424    ///   even if the qualified form has zero hits, because qualified names
425    ///   are a user contract.
426    /// * For a bare name, the resolver tries the exact-simple bucket and
427    ///   resolves the unique match. If two or more nodes share the simple
428    ///   name (e.g. a struct field and a local variable), it returns
429    ///   [`SymbolResolveError::Ambiguous`] with the candidate list.
430    ///
431    /// The candidate list is sorted lexicographically by
432    /// `(qualified_name, file_path, start_line, start_column)` and capped
433    /// at [`AMBIGUOUS_SYMBOL_CANDIDATE_CAP`].
434    ///
435    /// # Errors
436    ///
437    /// * [`SymbolResolveError::NotFound`] — no nodes matched after both
438    ///   raw-form and dot-normalized lookups.
439    /// * [`SymbolResolveError::Ambiguous`] — two or more nodes matched
440    ///   the requested name in the most-specific eligible bucket.
441    ///
442    /// # File scope
443    ///
444    /// `file_scope` follows the same semantics as
445    /// [`SymbolQuery::file_scope`]:
446    ///
447    /// * [`FileScope::Any`] — global resolution. Used by `sqry impact`,
448    ///   `sqry-mcp dependency_impact`, etc.
449    /// * [`FileScope::Path`] / [`FileScope::FileId`] — file-scoped
450    ///   resolution. Used by `sqry explain` and similar
451    ///   single-file-anchored commands.
452    pub fn resolve_global_symbol_ambiguity_aware(
453        &self,
454        symbol: &str,
455        file_scope: FileScope<'_>,
456    ) -> Result<NodeId, SymbolResolveError> {
457        self.resolve_global_symbol_ambiguity_aware_with_line(symbol, file_scope, None)
458    }
459
460    /// Compute the raw single-node resolution outcome for `symbol` under
461    /// `file_scope`, including the display-normalized (`.` / `#` to `::`)
462    /// fallback.
463    ///
464    /// Extracted so both the plain ambiguity-aware resolver and the
465    /// line-disambiguating variant share one canonical bucket-selection plus
466    /// dot-norm-fallback path.
467    fn resolve_global_symbol_outcome(
468        &self,
469        symbol: &str,
470        file_scope: FileScope<'_>,
471    ) -> SymbolResolutionOutcome {
472        // Strict mode rejects suffix candidates: only exact-qualified
473        // and exact-simple buckets are eligible. That's correct here,
474        // canonical-suffix matching is a fuzzy fallback that has no place
475        // in a "resolve to one canonical node" contract.
476        let primary = self.resolve_symbol(&SymbolQuery {
477            symbol,
478            file_scope,
479            mode: ResolutionMode::Strict,
480        });
481
482        match primary {
483            // Successful resolution short-circuits before the dot-norm fallback.
484            SymbolResolutionOutcome::Resolved(_) | SymbolResolutionOutcome::Ambiguous(_) => primary,
485            // Display-normalized fallback: a user passing `pkg.subpkg.fn`
486            // or Ruby-style `Class#field` against a graph that internally
487            // stores `pkg::subpkg::fn` / `Class::field` lands here. We only
488            // attempt the rewrite when the symbol has display separators and
489            // no existing `::`, to avoid shadowing native-form symbols.
490            SymbolResolutionOutcome::NotFound | SymbolResolutionOutcome::FileNotIndexed => {
491                if !symbol.contains("::") && (symbol.contains('.') || symbol.contains('#')) {
492                    let normalized = symbol.replace(['.', '#'], "::");
493                    self.resolve_symbol(&SymbolQuery {
494                        symbol: &normalized,
495                        file_scope,
496                        mode: ResolutionMode::Strict,
497                    })
498                } else {
499                    primary
500                }
501            }
502        }
503    }
504
505    /// Resolve `symbol` to a single node, disambiguating an ambiguous match
506    /// set by the definition's one-based start line.
507    ///
508    /// Behaves exactly like [`resolve_global_symbol_ambiguity_aware`] when
509    /// `line` is `None`. When `line` is `Some(n)` and the raw resolution is
510    /// ambiguous, the candidate set is narrowed to nodes whose definition
511    /// starts on line `n`:
512    ///
513    /// * exactly one survivor: resolved to that node.
514    /// * zero survivors: [`SymbolResolveError::NotFound`] (no definition on
515    ///   that line).
516    /// * two or more survivors (two definitions sharing a start line, a rare
517    ///   generated-code case): [`SymbolResolveError::Ambiguous`] over the
518    ///   narrowed set.
519    ///
520    /// This is the on-disk counterpart to `--in <file>` file scoping: `--in`
521    /// narrows by file (via `file_scope`), `--line` narrows by start line.
522    /// Together they disambiguate the same-file / same-qualified-name case
523    /// that a file path alone cannot (for example two methods named `summary`
524    /// in one file). Shared by `sqry explain`, `sqry impact`, and
525    /// `sqry visualize`.
526    ///
527    /// # Errors
528    ///
529    /// * [`SymbolResolveError::NotFound`]: no nodes matched, or `line` was
530    ///   supplied and no candidate starts on that line.
531    /// * [`SymbolResolveError::Ambiguous`]: two or more nodes remain after
532    ///   the (optional) line filter.
533    pub fn resolve_global_symbol_ambiguity_aware_with_line(
534        &self,
535        symbol: &str,
536        file_scope: FileScope<'_>,
537        line: Option<u32>,
538    ) -> Result<NodeId, SymbolResolveError> {
539        match self.resolve_global_symbol_outcome(symbol, file_scope) {
540            SymbolResolutionOutcome::Resolved(node_id) => Ok(node_id),
541            SymbolResolutionOutcome::NotFound | SymbolResolutionOutcome::FileNotIndexed => {
542                Err(SymbolResolveError::NotFound {
543                    name: symbol.to_string(),
544                })
545            }
546            SymbolResolutionOutcome::Ambiguous(candidates) => {
547                let candidates = match line {
548                    Some(target_line) => {
549                        let narrowed: Vec<NodeId> = candidates
550                            .iter()
551                            .copied()
552                            .filter(|node_id| {
553                                self.get_node(*node_id)
554                                    .is_some_and(|entry| entry.start_line == target_line)
555                            })
556                            .collect();
557                        match narrowed.len() {
558                            1 => return Ok(narrowed[0]),
559                            0 => {
560                                return Err(SymbolResolveError::NotFound {
561                                    name: symbol.to_string(),
562                                });
563                            }
564                            _ => narrowed,
565                        }
566                    }
567                    None => candidates,
568                };
569                Err(SymbolResolveError::Ambiguous(
570                    self.build_ambiguous_symbol_error(symbol, &candidates),
571                ))
572            }
573        }
574    }
575
576    /// Materialize a list of node ids into a stable, capped
577    /// [`AmbiguousSymbolError`] payload.
578    fn build_ambiguous_symbol_error(
579        &self,
580        symbol: &str,
581        candidates: &[NodeId],
582    ) -> AmbiguousSymbolError {
583        let mut materialized: Vec<AmbiguousSymbolCandidate> = candidates
584            .iter()
585            .filter_map(|node_id| self.materialize_ambiguous_candidate(*node_id))
586            .collect();
587
588        // Stable lexicographic ordering on the wire payload — independent
589        // of bucket ordering / arena insertion order. Kept here (not in
590        // the resolver) so the cap-and-truncate decision is taken on the
591        // user-visible projection.
592        materialized.sort_by(|left, right| {
593            left.qualified_name
594                .cmp(&right.qualified_name)
595                .then(left.file_path.cmp(&right.file_path))
596                .then(left.start_line.cmp(&right.start_line))
597                .then(left.start_column.cmp(&right.start_column))
598        });
599
600        let truncated = materialized.len() > AMBIGUOUS_SYMBOL_CANDIDATE_CAP;
601        materialized.truncate(AMBIGUOUS_SYMBOL_CANDIDATE_CAP);
602
603        AmbiguousSymbolError {
604            name: symbol.to_string(),
605            candidates: materialized,
606            truncated,
607        }
608    }
609
610    fn materialize_ambiguous_candidate(&self, node_id: NodeId) -> Option<AmbiguousSymbolCandidate> {
611        let entry = self.get_node(node_id)?;
612        let strings = self.strings();
613        let files = self.files();
614
615        let simple_name = strings
616            .resolve(entry.name)
617            .map_or_else(String::new, |s| s.to_string());
618        let qualified_name = entry
619            .qualified_name
620            .and_then(|id| strings.resolve(id))
621            .map_or_else(|| simple_name.clone(), |s| s.to_string());
622        let file_path = files
623            .resolve(entry.file)
624            .map_or_else(String::new, |p| p.display().to_string());
625
626        Some(AmbiguousSymbolCandidate {
627            qualified_name,
628            kind: entry.kind.as_str().to_string(),
629            file_path,
630            start_line: entry.start_line,
631            start_column: entry.start_column,
632        })
633    }
634
635    fn candidate_sort_key(&self, node_id: NodeId) -> CandidateSortKey {
636        let Some(entry) = self.get_node(node_id) else {
637            return CandidateSortKey::default_for(node_id);
638        };
639
640        let file_path = self
641            .files()
642            .resolve(entry.file)
643            .map_or_else(String::new, |path| path.to_string_lossy().into_owned());
644        let qualified_name = entry
645            .qualified_name
646            .and_then(|string_id| self.strings().resolve(string_id))
647            .map_or_else(String::new, |value| value.to_string());
648        let simple_name = self
649            .strings()
650            .resolve(entry.name)
651            .map_or_else(String::new, |value| value.to_string());
652
653        CandidateSortKey {
654            file_path,
655            start_line: entry.start_line,
656            start_column: entry.start_column,
657            end_line: entry.end_line,
658            end_column: entry.end_column,
659            kind: entry.kind.as_str().to_string(),
660            qualified_name,
661            simple_name,
662            node_id,
663        }
664    }
665}
666
667/// Witness-bearing candidate-search result.
668#[derive(Debug, Clone, PartialEq, Eq)]
669pub struct SymbolCandidateSearchWitness {
670    /// Normalized query when file scoping resolved successfully.
671    pub normalized_query: Option<NormalizedSymbolQuery>,
672    /// Legacy candidate-search outcome.
673    pub outcome: SymbolCandidateOutcome,
674    /// Winning bucket when a non-empty bucket exists.
675    pub selected_bucket: Option<SymbolCandidateBucket>,
676    /// Ordered candidate witnesses from the first non-empty bucket.
677    pub candidates: Vec<SymbolCandidateWitness>,
678}
679
680/// Maximum number of candidates surfaced by [`AmbiguousSymbolError`].
681///
682/// The cap prevents pathological responses on simple names that happen to
683/// match hundreds of nodes (e.g. `init`); when the bucket contains more
684/// than this many candidates the rest are dropped and
685/// [`AmbiguousSymbolError::truncated`] is set to `true`.
686pub const AMBIGUOUS_SYMBOL_CANDIDATE_CAP: usize = 20;
687
688/// One candidate surfaced by an [`AmbiguousSymbolError`].
689///
690/// The fields are deliberately denormalized strings/integers — the wire
691/// envelope on the CLI/MCP boundary serializes this struct directly via
692/// `serde`, and consumers (humans + agents) read the displayed fields
693/// without having to look them up against the live snapshot.
694#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
695pub struct AmbiguousSymbolCandidate {
696    /// Canonical qualified name with `::` separators (or simple name when
697    /// the node has no qualified name).
698    pub qualified_name: String,
699    /// Lowercase node-kind label (`"function"`, `"property"`, `"variable"`, …).
700    pub kind: String,
701    /// Display path of the source file the candidate is defined in.
702    pub file_path: String,
703    /// One-based start line of the candidate's definition span.
704    pub start_line: u32,
705    /// Zero-based start column of the candidate's definition span.
706    pub start_column: u32,
707}
708
709/// Typed payload for an ambiguous symbol resolution.
710///
711/// Surfaced by [`GraphSnapshot::resolve_global_symbol_ambiguity_aware`] when
712/// a bare symbol name resolves to multiple nodes. Consumers (CLI / MCP /
713/// LSP) serialize this directly into their wire envelope under the stable
714/// error code `sqry::ambiguous_symbol`.
715///
716/// Candidates are sorted by `(qualified_name, file_path, start_line,
717/// start_column)` lexicographically and capped at
718/// [`AMBIGUOUS_SYMBOL_CANDIDATE_CAP`]. When the cap fires, `truncated` is
719/// set to `true` so consumers can surface the cap to the user.
720#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
721pub struct AmbiguousSymbolError {
722    /// The original (un-normalized) symbol the caller asked for.
723    pub name: String,
724    /// Bounded list of candidate definitions, deterministically ordered.
725    pub candidates: Vec<AmbiguousSymbolCandidate>,
726    /// `true` when more than [`AMBIGUOUS_SYMBOL_CANDIDATE_CAP`] candidates
727    /// matched and the tail was dropped.
728    pub truncated: bool,
729}
730
731impl std::fmt::Display for AmbiguousSymbolError {
732    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
733        write!(
734            f,
735            "Symbol '{}' is ambiguous; specify the qualified name",
736            self.name
737        )
738    }
739}
740
741/// Single-result resolver outcome surfaced to CLI / MCP boundaries.
742///
743/// Distinct from [`SymbolResolutionOutcome`] because the boundary error
744/// shape needs typed metadata (kind, file, span) per candidate, not just
745/// `NodeId`s. CLI/MCP layers downcast through `anyhow::Error` chains and
746/// convert this to the `sqry::ambiguous_symbol` envelope verbatim.
747#[derive(Debug, Clone, PartialEq, Eq)]
748pub enum SymbolResolveError {
749    /// No node matched the requested symbol.
750    NotFound {
751        /// The symbol the caller asked for.
752        name: String,
753    },
754    /// Multiple nodes matched and the resolver refuses to choose.
755    Ambiguous(AmbiguousSymbolError),
756}
757
758impl std::fmt::Display for SymbolResolveError {
759    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
760        match self {
761            Self::NotFound { name } => write!(f, "Symbol '{name}' not found in graph"),
762            Self::Ambiguous(err) => write!(f, "{err}"),
763        }
764    }
765}
766
767impl std::error::Error for SymbolResolveError {}
768
769#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
770struct CandidateSortKey {
771    file_path: String,
772    start_line: u32,
773    start_column: u32,
774    end_line: u32,
775    end_column: u32,
776    kind: String,
777    qualified_name: String,
778    simple_name: String,
779    node_id: NodeId,
780}
781
782impl CandidateSortKey {
783    fn default_for(node_id: NodeId) -> Self {
784        Self {
785            file_path: String::new(),
786            start_line: 0,
787            start_column: 0,
788            end_line: 0,
789            end_column: 0,
790            kind: String::new(),
791            qualified_name: String::new(),
792            simple_name: String::new(),
793            node_id,
794        }
795    }
796}
797
798/// Canonicalize a language-native qualified name into graph-internal `::` form.
799#[must_use]
800pub fn canonicalize_graph_qualified_name(language: Language, symbol: &str) -> String {
801    if should_skip_qualified_name_normalization(symbol) {
802        return symbol.to_string();
803    }
804
805    if language == Language::R {
806        return canonicalize_r_qualified_name(symbol);
807    }
808
809    let mut normalized = symbol.to_string();
810    for delimiter in native_delimiters(language) {
811        if normalized.contains(delimiter) {
812            normalized = normalized.replace(delimiter, "::");
813        }
814    }
815    normalized
816}
817
818/// Returns `true` when a qualified name is already in graph-canonical form.
819#[must_use]
820pub(crate) fn is_canonical_graph_qualified_name(language: Language, symbol: &str) -> bool {
821    should_skip_qualified_name_normalization(symbol)
822        || canonicalize_graph_qualified_name(language, symbol) == symbol
823}
824
825fn should_skip_qualified_name_normalization(symbol: &str) -> bool {
826    symbol.starts_with('<')
827        || symbol.contains('/')
828        || symbol.starts_with("wasm::")
829        || symbol.starts_with("ffi::")
830        || symbol.starts_with("extern::")
831        || symbol.starts_with("native::")
832}
833
834fn canonicalize_r_qualified_name(symbol: &str) -> String {
835    let search_start = usize::from(symbol.starts_with('.'));
836    let Some(relative_split_index) = symbol[search_start..].rfind('.') else {
837        return symbol.to_string();
838    };
839
840    let split_index = search_start + relative_split_index;
841    let prefix = &symbol[..split_index];
842    let suffix = &symbol[split_index + 1..];
843    if suffix.is_empty() {
844        return symbol.to_string();
845    }
846
847    format!("{prefix}::{suffix}")
848}
849
850/// Convert a canonical graph qualified name into native language display form.
851#[must_use]
852pub fn display_graph_qualified_name(
853    language: Language,
854    qualified: &str,
855    kind: NodeKind,
856    is_static: bool,
857) -> String {
858    if should_skip_qualified_name_normalization(qualified) {
859        return qualified.to_string();
860    }
861
862    match language {
863        Language::Ruby => display_ruby_qualified_name(qualified, kind, is_static),
864        Language::Php => display_php_qualified_name(qualified, kind),
865        _ => native_display_separator(language).map_or_else(
866            || qualified.to_string(),
867            |separator| qualified.replace("::", separator),
868        ),
869    }
870}
871
872pub(crate) fn native_delimiters(language: Language) -> &'static [&'static str] {
873    match language {
874        Language::JavaScript
875        | Language::Python
876        | Language::TypeScript
877        | Language::Java
878        | Language::CSharp
879        | Language::Kotlin
880        | Language::Scala
881        | Language::Go
882        | Language::Css
883        | Language::Sql
884        | Language::Dart
885        | Language::Lua
886        | Language::Perl
887        | Language::Groovy
888        | Language::Elixir
889        | Language::R
890        | Language::Haskell
891        | Language::Html
892        | Language::Svelte
893        | Language::Vue
894        | Language::Terraform
895        | Language::Puppet
896        | Language::Pulumi
897        | Language::Http
898        | Language::Plsql
899        | Language::Apex
900        | Language::Abap
901        | Language::ServiceNow
902        | Language::Swift
903        | Language::Zig
904        | Language::Json => &["."],
905        Language::Ruby => &["#", "."],
906        Language::Php => &["\\", "->"],
907        Language::C | Language::Cpp | Language::Rust | Language::Shell => &[],
908    }
909}
910
911fn native_display_separator(language: Language) -> Option<&'static str> {
912    match language {
913        Language::C
914        | Language::Cpp
915        | Language::Rust
916        | Language::Shell
917        | Language::Php
918        | Language::Ruby => None,
919        _ => Some("."),
920    }
921}
922
923fn display_ruby_qualified_name(qualified: &str, kind: NodeKind, is_static: bool) -> String {
924    if qualified.contains('#') || qualified.contains('.') || !qualified.contains("::") {
925        return qualified.to_string();
926    }
927
928    match kind {
929        NodeKind::Method => {
930            replace_last_separator(qualified, if is_static { "." } else { "#" }, false)
931        }
932        NodeKind::Variable if should_display_ruby_member_variable(qualified) => {
933            replace_last_separator(qualified, "#", false)
934        }
935        // Ruby `attr_reader` (Constant) / `attr_writer` / `attr_accessor`
936        // (Property) declarations canonicalize to `Class::attr` for graph
937        // identity but render as `Class#attr` per design §3.1.3 (the Ruby
938        // RDoc / YARD instance-method idiom). Static-side attrs (rare —
939        // e.g. `class << self; attr_accessor :x; end`) keep `::` to mirror
940        // the singleton-method case handled by NodeKind::Method above.
941        NodeKind::Property | NodeKind::Constant
942            if !is_static && should_display_ruby_member_variable(qualified) =>
943        {
944            replace_last_separator(qualified, "#", false)
945        }
946        _ => qualified.to_string(),
947    }
948}
949
950fn should_display_ruby_member_variable(qualified: &str) -> bool {
951    let Some((_, suffix)) = qualified.rsplit_once("::") else {
952        return false;
953    };
954
955    if suffix.starts_with("@@")
956        || suffix
957            .chars()
958            .next()
959            .is_some_and(|character| character.is_ascii_uppercase())
960    {
961        return false;
962    }
963
964    suffix.starts_with('@')
965        || suffix
966            .chars()
967            .next()
968            .is_some_and(|character| character.is_ascii_lowercase() || character == '_')
969}
970
971fn display_php_qualified_name(qualified: &str, kind: NodeKind) -> String {
972    if !qualified.contains("::") {
973        return qualified.to_string();
974    }
975
976    if matches!(kind, NodeKind::Method | NodeKind::Property) {
977        return replace_last_separator(qualified, "::", true);
978    }
979
980    qualified.replace("::", "\\")
981}
982
983fn replace_last_separator(qualified: &str, final_separator: &str, preserve_prefix: bool) -> String {
984    let Some((prefix, suffix)) = qualified.rsplit_once("::") else {
985        return qualified.to_string();
986    };
987
988    let display_prefix = if preserve_prefix {
989        prefix.replace("::", "\\")
990    } else {
991        prefix.to_string()
992    };
993
994    if display_prefix.is_empty() {
995        suffix.to_string()
996    } else {
997        format!("{display_prefix}{final_separator}{suffix}")
998    }
999}
1000
1001#[cfg(test)]
1002mod tests {
1003    use std::path::{Path, PathBuf};
1004
1005    use crate::graph::node::Language;
1006    use crate::graph::unified::concurrent::CodeGraph;
1007    use crate::graph::unified::node::id::NodeId;
1008    use crate::graph::unified::node::kind::NodeKind;
1009    use crate::graph::unified::storage::arena::NodeEntry;
1010
1011    use super::{
1012        FileScope, NormalizedSymbolQuery, ResolutionMode, ResolvedFileScope, SymbolCandidateBucket,
1013        SymbolCandidateOutcome, SymbolQuery, SymbolResolutionOutcome, SymbolResolveError,
1014        canonicalize_graph_qualified_name, display_graph_qualified_name,
1015    };
1016
1017    struct TestNode {
1018        node_id: NodeId,
1019    }
1020
1021    #[test]
1022    fn test_resolve_symbol_exact_qualified_same_file() {
1023        let mut graph = CodeGraph::new();
1024        let file_path = abs_path("src/lib.rs");
1025        let symbol = add_node(
1026            &mut graph,
1027            NodeKind::Function,
1028            "target",
1029            Some("pkg::target"),
1030            &file_path,
1031            Some(Language::Rust),
1032            10,
1033            2,
1034        );
1035
1036        let snapshot = graph.snapshot();
1037        let query = SymbolQuery {
1038            symbol: "pkg::target",
1039            file_scope: FileScope::Path(&file_path),
1040            mode: ResolutionMode::Strict,
1041        };
1042
1043        assert_eq!(
1044            snapshot.resolve_symbol(&query),
1045            SymbolResolutionOutcome::Resolved(symbol.node_id)
1046        );
1047    }
1048
1049    #[test]
1050    fn test_resolve_symbol_exact_simple_same_file_wins() {
1051        let mut graph = CodeGraph::new();
1052        let requested_path = abs_path("src/requested.rs");
1053        let other_path = abs_path("src/other.rs");
1054
1055        let requested = add_node(
1056            &mut graph,
1057            NodeKind::Function,
1058            "target",
1059            Some("requested::target"),
1060            &requested_path,
1061            Some(Language::Rust),
1062            4,
1063            0,
1064        );
1065        let _other = add_node(
1066            &mut graph,
1067            NodeKind::Function,
1068            "target",
1069            Some("other::target"),
1070            &other_path,
1071            Some(Language::Rust),
1072            1,
1073            0,
1074        );
1075
1076        let snapshot = graph.snapshot();
1077        let query = SymbolQuery {
1078            symbol: "target",
1079            file_scope: FileScope::Path(&requested_path),
1080            mode: ResolutionMode::Strict,
1081        };
1082
1083        assert_eq!(
1084            snapshot.resolve_symbol(&query),
1085            SymbolResolutionOutcome::Resolved(requested.node_id)
1086        );
1087    }
1088
1089    #[test]
1090    fn test_resolve_symbol_returns_not_found_without_wrong_file_fallback() {
1091        let mut graph = CodeGraph::new();
1092        let requested_path = abs_path("src/requested.rs");
1093        let other_path = abs_path("src/other.rs");
1094
1095        let _requested_index_anchor = add_node(
1096            &mut graph,
1097            NodeKind::Function,
1098            "anchor",
1099            Some("requested::anchor"),
1100            &requested_path,
1101            Some(Language::Rust),
1102            1,
1103            0,
1104        );
1105        let _other = add_node(
1106            &mut graph,
1107            NodeKind::Function,
1108            "target",
1109            Some("other::target"),
1110            &other_path,
1111            Some(Language::Rust),
1112            3,
1113            0,
1114        );
1115
1116        let snapshot = graph.snapshot();
1117        let query = SymbolQuery {
1118            symbol: "target",
1119            file_scope: FileScope::Path(&requested_path),
1120            mode: ResolutionMode::Strict,
1121        };
1122
1123        assert_eq!(
1124            snapshot.resolve_symbol(&query),
1125            SymbolResolutionOutcome::NotFound
1126        );
1127    }
1128
1129    #[test]
1130    fn test_resolve_symbol_returns_file_not_indexed_for_valid_unindexed_path() {
1131        let mut graph = CodeGraph::new();
1132        let indexed_path = abs_path("src/indexed.rs");
1133        let unindexed_path = abs_path("src/unindexed.rs");
1134
1135        add_node(
1136            &mut graph,
1137            NodeKind::Function,
1138            "indexed",
1139            Some("pkg::indexed"),
1140            &indexed_path,
1141            Some(Language::Rust),
1142            1,
1143            0,
1144        );
1145        graph
1146            .files_mut()
1147            .register_with_language(&unindexed_path, Some(Language::Rust))
1148            .unwrap();
1149
1150        let snapshot = graph.snapshot();
1151        let query = SymbolQuery {
1152            symbol: "indexed",
1153            file_scope: FileScope::Path(&unindexed_path),
1154            mode: ResolutionMode::Strict,
1155        };
1156
1157        assert_eq!(
1158            snapshot.resolve_symbol(&query),
1159            SymbolResolutionOutcome::FileNotIndexed
1160        );
1161    }
1162
1163    #[test]
1164    fn test_resolve_symbol_returns_ambiguous_for_multi_match_bucket() {
1165        let mut graph = CodeGraph::new();
1166        let file_path = abs_path("src/lib.rs");
1167
1168        let first = add_node(
1169            &mut graph,
1170            NodeKind::Function,
1171            "dup",
1172            Some("pkg::dup"),
1173            &file_path,
1174            Some(Language::Rust),
1175            2,
1176            0,
1177        );
1178        let second = add_node(
1179            &mut graph,
1180            NodeKind::Method,
1181            "dup",
1182            Some("pkg::dup_method"),
1183            &file_path,
1184            Some(Language::Rust),
1185            8,
1186            0,
1187        );
1188
1189        let snapshot = graph.snapshot();
1190        let query = SymbolQuery {
1191            symbol: "dup",
1192            file_scope: FileScope::Path(&file_path),
1193            mode: ResolutionMode::Strict,
1194        };
1195
1196        assert_eq!(
1197            snapshot.resolve_symbol(&query),
1198            SymbolResolutionOutcome::Ambiguous(vec![first.node_id, second.node_id])
1199        );
1200    }
1201
1202    #[test]
1203    fn test_find_symbol_candidates_uses_first_non_empty_bucket_only() {
1204        let mut graph = CodeGraph::new();
1205        let qualified_path = abs_path("src/qualified.rs");
1206        let simple_path = abs_path("src/simple.rs");
1207
1208        let qualified = add_node(
1209            &mut graph,
1210            NodeKind::Function,
1211            "target",
1212            Some("pkg::target"),
1213            &qualified_path,
1214            Some(Language::Rust),
1215            1,
1216            0,
1217        );
1218        let simple_only = add_node(
1219            &mut graph,
1220            NodeKind::Function,
1221            "pkg::target",
1222            None,
1223            &simple_path,
1224            Some(Language::Rust),
1225            1,
1226            0,
1227        );
1228
1229        let snapshot = graph.snapshot();
1230        let query = SymbolQuery {
1231            symbol: "pkg::target",
1232            file_scope: FileScope::Any,
1233            mode: ResolutionMode::AllowSuffixCandidates,
1234        };
1235
1236        assert_eq!(
1237            snapshot.find_symbol_candidates(&query),
1238            SymbolCandidateOutcome::Candidates(vec![qualified.node_id])
1239        );
1240        assert_ne!(qualified.node_id, simple_only.node_id);
1241    }
1242
1243    #[test]
1244    fn test_find_symbol_candidates_with_witness_reports_exact_qualified_bucket() {
1245        let mut graph = CodeGraph::new();
1246        let qualified_path = abs_path("src/qualified.rs");
1247        let simple_path = abs_path("src/simple.rs");
1248
1249        let qualified = add_node(
1250            &mut graph,
1251            NodeKind::Function,
1252            "target",
1253            Some("pkg::target"),
1254            &qualified_path,
1255            Some(Language::Rust),
1256            1,
1257            0,
1258        );
1259        let _simple_only = add_node(
1260            &mut graph,
1261            NodeKind::Function,
1262            "pkg::target",
1263            None,
1264            &simple_path,
1265            Some(Language::Rust),
1266            1,
1267            0,
1268        );
1269
1270        let snapshot = graph.snapshot();
1271        let query = SymbolQuery {
1272            symbol: "pkg::target",
1273            file_scope: FileScope::Any,
1274            mode: ResolutionMode::AllowSuffixCandidates,
1275        };
1276
1277        let witness = snapshot.find_symbol_candidates_with_witness(&query);
1278
1279        assert_eq!(
1280            witness.outcome,
1281            SymbolCandidateOutcome::Candidates(vec![qualified.node_id])
1282        );
1283        assert_eq!(
1284            witness.selected_bucket,
1285            Some(SymbolCandidateBucket::ExactQualified)
1286        );
1287        assert_eq!(
1288            witness.candidates,
1289            vec![super::SymbolCandidateWitness {
1290                node_id: qualified.node_id,
1291                bucket: SymbolCandidateBucket::ExactQualified,
1292            }]
1293        );
1294        assert_eq!(
1295            witness.normalized_query,
1296            Some(NormalizedSymbolQuery {
1297                symbol: "pkg::target".to_string(),
1298                file_scope: ResolvedFileScope::Any,
1299                mode: ResolutionMode::AllowSuffixCandidates,
1300            })
1301        );
1302    }
1303
1304    #[test]
1305    fn test_find_symbol_candidates_preserves_file_not_indexed() {
1306        let mut graph = CodeGraph::new();
1307        let indexed_path = abs_path("src/indexed.rs");
1308        let unindexed_path = abs_path("src/unindexed.rs");
1309
1310        add_node(
1311            &mut graph,
1312            NodeKind::Function,
1313            "target",
1314            Some("pkg::target"),
1315            &indexed_path,
1316            Some(Language::Rust),
1317            1,
1318            0,
1319        );
1320        let unindexed_file_id = graph
1321            .files_mut()
1322            .register_with_language(&unindexed_path, Some(Language::Rust))
1323            .unwrap();
1324
1325        let snapshot = graph.snapshot();
1326        let query = SymbolQuery {
1327            symbol: "target",
1328            file_scope: FileScope::FileId(unindexed_file_id),
1329            mode: ResolutionMode::AllowSuffixCandidates,
1330        };
1331
1332        assert_eq!(
1333            snapshot.find_symbol_candidates(&query),
1334            SymbolCandidateOutcome::FileNotIndexed
1335        );
1336    }
1337
1338    #[test]
1339    fn test_resolve_symbol_with_witness_reports_ambiguous_bucket_candidates() {
1340        let mut graph = CodeGraph::new();
1341        let file_path = abs_path("src/lib.rs");
1342
1343        let first = add_node(
1344            &mut graph,
1345            NodeKind::Function,
1346            "dup",
1347            Some("pkg::dup"),
1348            &file_path,
1349            Some(Language::Rust),
1350            2,
1351            0,
1352        );
1353        let second = add_node(
1354            &mut graph,
1355            NodeKind::Method,
1356            "dup",
1357            Some("pkg::dup_method"),
1358            &file_path,
1359            Some(Language::Rust),
1360            8,
1361            0,
1362        );
1363
1364        let snapshot = graph.snapshot();
1365        let query = SymbolQuery {
1366            symbol: "dup",
1367            file_scope: FileScope::Path(&file_path),
1368            mode: ResolutionMode::Strict,
1369        };
1370
1371        let witness = snapshot.resolve_symbol_with_witness(&query);
1372
1373        assert_eq!(
1374            witness.outcome,
1375            SymbolResolutionOutcome::Ambiguous(vec![first.node_id, second.node_id])
1376        );
1377        assert_eq!(
1378            witness.selected_bucket,
1379            Some(SymbolCandidateBucket::ExactSimple)
1380        );
1381        assert_eq!(
1382            witness.candidates,
1383            vec![
1384                super::SymbolCandidateWitness {
1385                    node_id: first.node_id,
1386                    bucket: SymbolCandidateBucket::ExactSimple,
1387                },
1388                super::SymbolCandidateWitness {
1389                    node_id: second.node_id,
1390                    bucket: SymbolCandidateBucket::ExactSimple,
1391                },
1392            ]
1393        );
1394    }
1395
1396    #[test]
1397    fn test_suffix_candidates_disabled_in_strict_mode() {
1398        let mut graph = CodeGraph::new();
1399        let file_path = abs_path("src/lib.rs");
1400
1401        let suffix_match = add_node(
1402            &mut graph,
1403            NodeKind::Function,
1404            "target",
1405            Some("outer::pkg::target"),
1406            &file_path,
1407            Some(Language::Rust),
1408            1,
1409            0,
1410        );
1411
1412        let snapshot = graph.snapshot();
1413        let strict_query = SymbolQuery {
1414            symbol: "pkg::target",
1415            file_scope: FileScope::Any,
1416            mode: ResolutionMode::Strict,
1417        };
1418        let suffix_query = SymbolQuery {
1419            mode: ResolutionMode::AllowSuffixCandidates,
1420            ..strict_query
1421        };
1422
1423        assert_eq!(
1424            snapshot.resolve_symbol(&strict_query),
1425            SymbolResolutionOutcome::NotFound
1426        );
1427        assert_eq!(
1428            snapshot.find_symbol_candidates(&suffix_query),
1429            SymbolCandidateOutcome::Candidates(vec![suffix_match.node_id])
1430        );
1431    }
1432
1433    #[test]
1434    fn test_suffix_candidates_require_canonical_qualified_query() {
1435        let mut graph = CodeGraph::new();
1436        let file_path = abs_path("src/mod.py");
1437
1438        add_node(
1439            &mut graph,
1440            NodeKind::Function,
1441            "target",
1442            Some("pkg::target"),
1443            &file_path,
1444            Some(Language::Python),
1445            1,
1446            0,
1447        );
1448
1449        let snapshot = graph.snapshot();
1450        let query = SymbolQuery {
1451            symbol: "pkg.target",
1452            file_scope: FileScope::Any,
1453            mode: ResolutionMode::AllowSuffixCandidates,
1454        };
1455
1456        assert_eq!(
1457            snapshot.find_symbol_candidates(&query),
1458            SymbolCandidateOutcome::NotFound
1459        );
1460    }
1461
1462    #[test]
1463    fn test_suffix_candidates_filter_same_leaf_bucket_only() {
1464        let mut graph = CodeGraph::new();
1465        let file_path = abs_path("src/lib.rs");
1466
1467        let exact_suffix = add_node(
1468            &mut graph,
1469            NodeKind::Function,
1470            "target",
1471            Some("outer::pkg::target"),
1472            &file_path,
1473            Some(Language::Rust),
1474            2,
1475            0,
1476        );
1477        let another_suffix = add_node(
1478            &mut graph,
1479            NodeKind::Method,
1480            "target",
1481            Some("another::pkg::target"),
1482            &file_path,
1483            Some(Language::Rust),
1484            4,
1485            0,
1486        );
1487        let unrelated = add_node(
1488            &mut graph,
1489            NodeKind::Function,
1490            "target",
1491            Some("pkg::different::target"),
1492            &file_path,
1493            Some(Language::Rust),
1494            6,
1495            0,
1496        );
1497
1498        let snapshot = graph.snapshot();
1499        let query = SymbolQuery {
1500            symbol: "pkg::target",
1501            file_scope: FileScope::Any,
1502            mode: ResolutionMode::AllowSuffixCandidates,
1503        };
1504
1505        assert_eq!(
1506            snapshot.find_symbol_candidates(&query),
1507            SymbolCandidateOutcome::Candidates(vec![exact_suffix.node_id, another_suffix.node_id])
1508        );
1509        assert_ne!(unrelated.node_id, exact_suffix.node_id);
1510    }
1511
1512    #[test]
1513    fn test_normalize_symbol_query_rewrites_native_delimiter_when_file_scope_language_known() {
1514        let mut graph = CodeGraph::new();
1515        let file_path = abs_path("src/mod.py");
1516        let file_id = graph
1517            .files_mut()
1518            .register_with_language(&file_path, Some(Language::Python))
1519            .unwrap();
1520        let snapshot = graph.snapshot();
1521        let query = SymbolQuery {
1522            symbol: "pkg.mod.fn",
1523            file_scope: FileScope::Path(&file_path),
1524            mode: ResolutionMode::Strict,
1525        };
1526
1527        let normalized = snapshot.normalize_symbol_query(&query, &ResolvedFileScope::File(file_id));
1528
1529        assert_eq!(
1530            normalized,
1531            NormalizedSymbolQuery {
1532                symbol: "pkg::mod::fn".to_string(),
1533                file_scope: ResolvedFileScope::File(file_id),
1534                mode: ResolutionMode::Strict,
1535            }
1536        );
1537    }
1538
1539    #[test]
1540    fn test_normalize_symbol_query_rewrites_native_delimiter_for_csharp() {
1541        let mut graph = CodeGraph::new();
1542        let file_path = abs_path("src/Program.cs");
1543        let file_id = graph
1544            .files_mut()
1545            .register_with_language(&file_path, Some(Language::CSharp))
1546            .unwrap();
1547        let snapshot = graph.snapshot();
1548        let query = SymbolQuery {
1549            symbol: "System.Console.WriteLine",
1550            file_scope: FileScope::Path(&file_path),
1551            mode: ResolutionMode::Strict,
1552        };
1553
1554        let normalized = snapshot.normalize_symbol_query(&query, &ResolvedFileScope::File(file_id));
1555
1556        assert_eq!(normalized.symbol, "System::Console::WriteLine".to_string());
1557    }
1558
1559    #[test]
1560    fn test_normalize_symbol_query_rewrites_native_delimiter_for_zig() {
1561        let mut graph = CodeGraph::new();
1562        let file_path = abs_path("src/main.zig");
1563        let file_id = graph
1564            .files_mut()
1565            .register_with_language(&file_path, Some(Language::Zig))
1566            .unwrap();
1567        let snapshot = graph.snapshot();
1568        let query = SymbolQuery {
1569            symbol: "std.os.linux.exit",
1570            file_scope: FileScope::Path(&file_path),
1571            mode: ResolutionMode::Strict,
1572        };
1573
1574        let normalized = snapshot.normalize_symbol_query(&query, &ResolvedFileScope::File(file_id));
1575
1576        assert_eq!(normalized.symbol, "std::os::linux::exit".to_string());
1577    }
1578
1579    #[test]
1580    fn test_normalize_symbol_query_does_not_rewrite_when_file_scope_any() {
1581        let graph = CodeGraph::new();
1582        let snapshot = graph.snapshot();
1583        let query = SymbolQuery {
1584            symbol: "pkg.mod.fn",
1585            file_scope: FileScope::Any,
1586            mode: ResolutionMode::Strict,
1587        };
1588
1589        let normalized = snapshot.normalize_symbol_query(&query, &ResolvedFileScope::Any);
1590
1591        assert_eq!(
1592            normalized,
1593            NormalizedSymbolQuery {
1594                symbol: "pkg.mod.fn".to_string(),
1595                file_scope: ResolvedFileScope::Any,
1596                mode: ResolutionMode::Strict,
1597            }
1598        );
1599    }
1600
1601    #[test]
1602    fn test_global_qualified_query_with_native_delimiter_is_exact_only_and_not_found() {
1603        let mut graph = CodeGraph::new();
1604        let file_path = abs_path("src/mod.py");
1605
1606        add_node(
1607            &mut graph,
1608            NodeKind::Function,
1609            "fn",
1610            Some("pkg::mod::fn"),
1611            &file_path,
1612            Some(Language::Python),
1613            1,
1614            0,
1615        );
1616
1617        let snapshot = graph.snapshot();
1618        let query = SymbolQuery {
1619            symbol: "pkg.mod.fn",
1620            file_scope: FileScope::Any,
1621            mode: ResolutionMode::AllowSuffixCandidates,
1622        };
1623
1624        assert_eq!(
1625            snapshot.resolve_symbol(&query),
1626            SymbolResolutionOutcome::NotFound
1627        );
1628    }
1629
1630    #[test]
1631    fn test_global_canonical_qualified_query_can_hit_exact_qualified_bucket() {
1632        let mut graph = CodeGraph::new();
1633        let file_path = abs_path("src/lib.rs");
1634        let expected = add_node(
1635            &mut graph,
1636            NodeKind::Function,
1637            "fn",
1638            Some("pkg::mod::fn"),
1639            &file_path,
1640            Some(Language::Rust),
1641            1,
1642            0,
1643        );
1644
1645        let snapshot = graph.snapshot();
1646        let query = SymbolQuery {
1647            symbol: "pkg::mod::fn",
1648            file_scope: FileScope::Any,
1649            mode: ResolutionMode::Strict,
1650        };
1651
1652        assert_eq!(
1653            snapshot.resolve_symbol(&query),
1654            SymbolResolutionOutcome::Resolved(expected.node_id)
1655        );
1656    }
1657
1658    #[test]
1659    fn test_candidate_order_uses_metadata_then_node_id() {
1660        let mut graph = CodeGraph::new();
1661        let file_path = abs_path("src/lib.rs");
1662
1663        let first = add_node(
1664            &mut graph,
1665            NodeKind::Function,
1666            "dup",
1667            Some("pkg::dup_a"),
1668            &file_path,
1669            Some(Language::Rust),
1670            1,
1671            0,
1672        );
1673        let second = add_node(
1674            &mut graph,
1675            NodeKind::Function,
1676            "dup",
1677            Some("pkg::dup_b"),
1678            &file_path,
1679            Some(Language::Rust),
1680            1,
1681            0,
1682        );
1683
1684        let snapshot = graph.snapshot();
1685        let query = SymbolQuery {
1686            symbol: "dup",
1687            file_scope: FileScope::Any,
1688            mode: ResolutionMode::Strict,
1689        };
1690
1691        assert_eq!(
1692            snapshot.find_symbol_candidates(&query),
1693            SymbolCandidateOutcome::Candidates(vec![first.node_id, second.node_id])
1694        );
1695    }
1696
1697    #[test]
1698    fn test_candidate_order_kind_sort_key_uses_node_kind_as_str() {
1699        let mut graph = CodeGraph::new();
1700        let file_path = abs_path("src/lib.rs");
1701
1702        let function_node = add_node(
1703            &mut graph,
1704            NodeKind::Function,
1705            "shared",
1706            Some("pkg::shared_fn"),
1707            &file_path,
1708            Some(Language::Rust),
1709            1,
1710            0,
1711        );
1712        let variable_node = add_node(
1713            &mut graph,
1714            NodeKind::Variable,
1715            "shared",
1716            Some("pkg::shared_var"),
1717            &file_path,
1718            Some(Language::Rust),
1719            1,
1720            0,
1721        );
1722
1723        let snapshot = graph.snapshot();
1724        let query = SymbolQuery {
1725            symbol: "shared",
1726            file_scope: FileScope::Any,
1727            mode: ResolutionMode::Strict,
1728        };
1729
1730        assert_eq!(
1731            snapshot.find_symbol_candidates(&query),
1732            SymbolCandidateOutcome::Candidates(vec![function_node.node_id, variable_node.node_id])
1733        );
1734    }
1735
1736    /// Two definitions of `summary` in the same file, on lines 4 and 10, model
1737    /// the verivus-oss/sqry#512 same-file collision.
1738    fn same_file_collision_snapshot() -> (CodeGraph, TestNode, TestNode) {
1739        let mut graph = CodeGraph::new();
1740        let file_path = abs_path("src/lib.rs");
1741        let report = add_node(
1742            &mut graph,
1743            NodeKind::Method,
1744            "summary",
1745            Some("Report::summary"),
1746            &file_path,
1747            Some(Language::Rust),
1748            4,
1749            4,
1750        );
1751        let ledger = add_node(
1752            &mut graph,
1753            NodeKind::Method,
1754            "summary",
1755            Some("Ledger::summary"),
1756            &file_path,
1757            Some(Language::Rust),
1758            10,
1759            4,
1760        );
1761        (graph, report, ledger)
1762    }
1763
1764    #[test]
1765    fn with_line_disambiguates_same_file_collision() {
1766        let (graph, report, ledger) = same_file_collision_snapshot();
1767        let snapshot = graph.snapshot();
1768
1769        assert_eq!(
1770            snapshot.resolve_global_symbol_ambiguity_aware_with_line(
1771                "summary",
1772                FileScope::Any,
1773                Some(4)
1774            ),
1775            Ok(report.node_id)
1776        );
1777        assert_eq!(
1778            snapshot.resolve_global_symbol_ambiguity_aware_with_line(
1779                "summary",
1780                FileScope::Any,
1781                Some(10)
1782            ),
1783            Ok(ledger.node_id)
1784        );
1785    }
1786
1787    #[test]
1788    fn with_line_none_is_ambiguous_for_collision() {
1789        let (graph, _report, _ledger) = same_file_collision_snapshot();
1790        let snapshot = graph.snapshot();
1791
1792        match snapshot.resolve_global_symbol_ambiguity_aware_with_line(
1793            "summary",
1794            FileScope::Any,
1795            None,
1796        ) {
1797            Err(SymbolResolveError::Ambiguous(err)) => {
1798                assert_eq!(err.candidates.len(), 2);
1799            }
1800            other => panic!("expected ambiguity without a line, got {other:?}"),
1801        }
1802    }
1803
1804    #[test]
1805    fn with_line_absent_line_is_not_found() {
1806        let (graph, _report, _ledger) = same_file_collision_snapshot();
1807        let snapshot = graph.snapshot();
1808
1809        assert_eq!(
1810            snapshot.resolve_global_symbol_ambiguity_aware_with_line(
1811                "summary",
1812                FileScope::Any,
1813                Some(999)
1814            ),
1815            Err(SymbolResolveError::NotFound {
1816                name: "summary".to_string()
1817            })
1818        );
1819    }
1820
1821    fn add_node(
1822        graph: &mut CodeGraph,
1823        kind: NodeKind,
1824        name: &str,
1825        qualified_name: Option<&str>,
1826        file_path: &Path,
1827        language: Option<Language>,
1828        start_line: u32,
1829        start_column: u32,
1830    ) -> TestNode {
1831        let name_id = graph.strings_mut().intern(name).unwrap();
1832        let qualified_name_id =
1833            qualified_name.map(|value| graph.strings_mut().intern(value).unwrap());
1834        let file_id = graph
1835            .files_mut()
1836            .register_with_language(file_path, language)
1837            .unwrap();
1838
1839        let entry = NodeEntry::new(kind, name_id, file_id)
1840            .with_qualified_name_opt(qualified_name_id)
1841            .with_location(start_line, start_column, start_line, start_column + 1);
1842
1843        let node_id = graph.nodes_mut().alloc(entry).unwrap();
1844        graph
1845            .indices_mut()
1846            .add(node_id, kind, name_id, qualified_name_id, file_id);
1847
1848        TestNode { node_id }
1849    }
1850
1851    trait NodeEntryExt {
1852        fn with_qualified_name_opt(
1853            self,
1854            qualified_name: Option<crate::graph::unified::string::id::StringId>,
1855        ) -> Self;
1856    }
1857
1858    impl NodeEntryExt for NodeEntry {
1859        fn with_qualified_name_opt(
1860            mut self,
1861            qualified_name: Option<crate::graph::unified::string::id::StringId>,
1862        ) -> Self {
1863            self.qualified_name = qualified_name;
1864            self
1865        }
1866    }
1867
1868    fn abs_path(relative: &str) -> PathBuf {
1869        PathBuf::from("/resolver-tests").join(relative)
1870    }
1871
1872    #[test]
1873    fn test_display_graph_qualified_name_dot_language() {
1874        let display = display_graph_qualified_name(
1875            Language::CSharp,
1876            "MyApp::User::GetName",
1877            NodeKind::Method,
1878            false,
1879        );
1880        assert_eq!(display, "MyApp.User.GetName");
1881    }
1882
1883    #[test]
1884    fn test_canonicalize_graph_qualified_name_r_private_name_preserved() {
1885        assert_eq!(
1886            canonicalize_graph_qualified_name(Language::R, ".private_func"),
1887            ".private_func"
1888        );
1889    }
1890
1891    #[test]
1892    fn test_canonicalize_graph_qualified_name_r_s3_method_uses_last_dot() {
1893        assert_eq!(
1894            canonicalize_graph_qualified_name(Language::R, "as.data.frame.myclass"),
1895            "as.data.frame::myclass"
1896        );
1897    }
1898
1899    #[test]
1900    fn test_canonicalize_graph_qualified_name_r_leading_dot_s3_generic() {
1901        assert_eq!(
1902            canonicalize_graph_qualified_name(Language::R, ".DollarNames.myclass"),
1903            ".DollarNames::myclass"
1904        );
1905    }
1906
1907    #[test]
1908    fn test_display_graph_qualified_name_ruby_instance_method() {
1909        let display = display_graph_qualified_name(
1910            Language::Ruby,
1911            "Admin::Users::Controller::show",
1912            NodeKind::Method,
1913            false,
1914        );
1915        assert_eq!(display, "Admin::Users::Controller#show");
1916    }
1917
1918    #[test]
1919    fn test_display_graph_qualified_name_ruby_singleton_method() {
1920        let display = display_graph_qualified_name(
1921            Language::Ruby,
1922            "Admin::Users::Controller::show",
1923            NodeKind::Method,
1924            true,
1925        );
1926        assert_eq!(display, "Admin::Users::Controller.show");
1927    }
1928
1929    #[test]
1930    fn test_display_graph_qualified_name_ruby_member_variable() {
1931        let display = display_graph_qualified_name(
1932            Language::Ruby,
1933            "Admin::Users::Controller::username",
1934            NodeKind::Variable,
1935            false,
1936        );
1937        assert_eq!(display, "Admin::Users::Controller#username");
1938    }
1939
1940    #[test]
1941    fn test_display_graph_qualified_name_ruby_instance_variable() {
1942        let display = display_graph_qualified_name(
1943            Language::Ruby,
1944            "Admin::Users::Controller::@current_user",
1945            NodeKind::Variable,
1946            false,
1947        );
1948        assert_eq!(display, "Admin::Users::Controller#@current_user");
1949    }
1950
1951    #[test]
1952    fn test_display_graph_qualified_name_ruby_constant_stays_canonical() {
1953        let display = display_graph_qualified_name(
1954            Language::Ruby,
1955            "Admin::Users::Controller::DEFAULT_ROLE",
1956            NodeKind::Variable,
1957            false,
1958        );
1959        assert_eq!(display, "Admin::Users::Controller::DEFAULT_ROLE");
1960    }
1961
1962    #[test]
1963    fn test_display_graph_qualified_name_ruby_class_variable_stays_canonical() {
1964        let display = display_graph_qualified_name(
1965            Language::Ruby,
1966            "Admin::Users::Controller::@@count",
1967            NodeKind::Variable,
1968            false,
1969        );
1970        assert_eq!(display, "Admin::Users::Controller::@@count");
1971    }
1972
1973    // REQ:R0017 + cross-language-field-emission/02_DESIGN §3.1.3:
1974    // Ruby `attr_accessor` / `attr_writer` declarations canonicalize as
1975    // `Class::attr` (Property) but render as `Class#attr` per the RDoc /
1976    // YARD instance-method idiom. Mirrors the NodeKind::Method instance
1977    // arm above.
1978    #[test]
1979    fn test_display_graph_qualified_name_ruby_property_renders_as_instance_member() {
1980        let display = display_graph_qualified_name(
1981            Language::Ruby,
1982            "Counter::name",
1983            NodeKind::Property,
1984            false,
1985        );
1986        assert_eq!(display, "Counter#name");
1987    }
1988
1989    // REQ:R0017 + cross-language-field-emission/02_DESIGN §3.1.3:
1990    // Ruby `attr_reader` declarations land on `NodeKind::Constant` in the
1991    // graph but, when the suffix is lowercase (i.e. an attribute name, not
1992    // a Ruby SCREAMING_SNAKE_CASE constant), they must render as
1993    // `Class#attr` to match the Property arm. Uppercase suffixes preserve
1994    // the existing `Class::CONSTANT` carve-out — see the
1995    // `should_display_ruby_member_variable` ascii_uppercase guard above.
1996    #[test]
1997    fn test_display_graph_qualified_name_ruby_constant_lowercase_renders_as_instance_member() {
1998        let display = display_graph_qualified_name(
1999            Language::Ruby,
2000            "Counter::name",
2001            NodeKind::Constant,
2002            false,
2003        );
2004        assert_eq!(display, "Counter#name");
2005    }
2006
2007    // REQ:R0017 carve-out: a true `NodeKind::Constant` whose suffix begins
2008    // with an uppercase character (Ruby SCREAMING_SNAKE_CASE) keeps the
2009    // canonical `Class::CONSTANT` form. The lowercase-suffix arm above
2010    // must not regress this case.
2011    #[test]
2012    fn test_display_graph_qualified_name_ruby_constant_uppercase_stays_canonical() {
2013        let display = display_graph_qualified_name(
2014            Language::Ruby,
2015            "Counter::CONSTANT",
2016            NodeKind::Constant,
2017            false,
2018        );
2019        assert_eq!(display, "Counter::CONSTANT");
2020    }
2021
2022    // REQ:R0017 static-side carve-out: when `is_static` is true (e.g.
2023    // `class << self; attr_accessor :x; end`), a Property must keep the
2024    // singleton `::` separator to mirror the singleton-method arm.
2025    #[test]
2026    fn test_display_graph_qualified_name_ruby_static_property_stays_canonical() {
2027        let display =
2028            display_graph_qualified_name(Language::Ruby, "Counter::name", NodeKind::Property, true);
2029        assert_eq!(display, "Counter::name");
2030    }
2031
2032    #[test]
2033    fn test_display_graph_qualified_name_php_namespace_function() {
2034        let display = display_graph_qualified_name(
2035            Language::Php,
2036            "App::Services::send_mail",
2037            NodeKind::Function,
2038            false,
2039        );
2040        assert_eq!(display, "App\\Services\\send_mail");
2041    }
2042
2043    #[test]
2044    fn test_display_graph_qualified_name_php_method() {
2045        let display = display_graph_qualified_name(
2046            Language::Php,
2047            "App::Services::Mailer::deliver",
2048            NodeKind::Method,
2049            false,
2050        );
2051        assert_eq!(display, "App\\Services\\Mailer::deliver");
2052    }
2053
2054    #[test]
2055    fn test_display_graph_qualified_name_preserves_path_like_symbols() {
2056        let display = display_graph_qualified_name(
2057            Language::Go,
2058            "route::GET::/health",
2059            NodeKind::Endpoint,
2060            false,
2061        );
2062        assert_eq!(display, "route::GET::/health");
2063    }
2064
2065    #[test]
2066    fn test_display_graph_qualified_name_preserves_ffi_symbols() {
2067        let display = display_graph_qualified_name(
2068            Language::Haskell,
2069            "ffi::C::sin",
2070            NodeKind::Function,
2071            false,
2072        );
2073        assert_eq!(display, "ffi::C::sin");
2074    }
2075
2076    #[test]
2077    fn test_display_graph_qualified_name_preserves_native_cffi_symbols() {
2078        let display = display_graph_qualified_name(
2079            Language::Python,
2080            "native::cffi::calculate",
2081            NodeKind::Function,
2082            false,
2083        );
2084        assert_eq!(display, "native::cffi::calculate");
2085    }
2086
2087    #[test]
2088    fn test_display_graph_qualified_name_preserves_native_php_ffi_symbols() {
2089        let display = display_graph_qualified_name(
2090            Language::Php,
2091            "native::ffi::crypto_encrypt",
2092            NodeKind::Function,
2093            false,
2094        );
2095        assert_eq!(display, "native::ffi::crypto_encrypt");
2096    }
2097
2098    #[test]
2099    fn test_display_graph_qualified_name_preserves_native_panama_symbols() {
2100        let display = display_graph_qualified_name(
2101            Language::Java,
2102            "native::panama::nativeLinker",
2103            NodeKind::Function,
2104            false,
2105        );
2106        assert_eq!(display, "native::panama::nativeLinker");
2107    }
2108
2109    #[test]
2110    fn test_canonicalize_graph_qualified_name_preserves_wasm_symbols() {
2111        assert_eq!(
2112            canonicalize_graph_qualified_name(Language::TypeScript, "wasm::module.wasm"),
2113            "wasm::module.wasm"
2114        );
2115    }
2116
2117    #[test]
2118    fn test_canonicalize_graph_qualified_name_preserves_native_symbols() {
2119        assert_eq!(
2120            canonicalize_graph_qualified_name(Language::TypeScript, "native::binding.node"),
2121            "native::binding.node"
2122        );
2123    }
2124
2125    #[test]
2126    fn test_display_graph_qualified_name_preserves_wasm_symbols() {
2127        let display = display_graph_qualified_name(
2128            Language::TypeScript,
2129            "wasm::module.wasm",
2130            NodeKind::Module,
2131            false,
2132        );
2133        assert_eq!(display, "wasm::module.wasm");
2134    }
2135
2136    #[test]
2137    fn test_display_graph_qualified_name_preserves_native_symbols() {
2138        let display = display_graph_qualified_name(
2139            Language::TypeScript,
2140            "native::binding.node",
2141            NodeKind::Module,
2142            false,
2143        );
2144        assert_eq!(display, "native::binding.node");
2145    }
2146
2147    #[test]
2148    fn test_canonicalize_graph_qualified_name_still_normalizes_dot_language_symbols() {
2149        assert_eq!(
2150            canonicalize_graph_qualified_name(Language::TypeScript, "Foo.bar"),
2151            "Foo::bar"
2152        );
2153    }
2154
2155    // ── P2U06 tests ──────────────────────────────────────────────────────────
2156
2157    /// `SymbolResolutionWitness` constructed by `resolve_symbol_with_witness`
2158    /// must carry an empty `steps` field (P2U07 is the emission point).
2159    #[test]
2160    fn p2u06_witness_steps_field_defaults_to_empty() {
2161        let mut graph = CodeGraph::new();
2162        let file_path = abs_path("src/lib.rs");
2163
2164        let symbol = add_node(
2165            &mut graph,
2166            NodeKind::Function,
2167            "my_fn",
2168            Some("pkg::my_fn"),
2169            &file_path,
2170            Some(Language::Rust),
2171            1,
2172            0,
2173        );
2174
2175        let snapshot = graph.snapshot();
2176        let query = SymbolQuery {
2177            symbol: "pkg::my_fn",
2178            file_scope: FileScope::Any,
2179            mode: ResolutionMode::Strict,
2180        };
2181
2182        let witness = snapshot.resolve_symbol_with_witness(&query);
2183        assert_eq!(
2184            witness.outcome,
2185            SymbolResolutionOutcome::Resolved(symbol.node_id)
2186        );
2187        assert!(
2188            witness.steps.is_empty(),
2189            "P2U06 initialises steps to Vec::new(); emission is deferred to P2U07"
2190        );
2191    }
2192
2193    /// `steps` field is assignable and holds `ResolutionStep` values; `Eq` is
2194    /// preserved on the struct even after the new field is added.
2195    #[test]
2196    fn p2u06_witness_steps_field_is_eq_compatible() {
2197        use crate::graph::unified::bind::witness::step::ResolutionStep;
2198        use crate::graph::unified::file::id::FileId;
2199
2200        let step = ResolutionStep::EnterFileScope {
2201            file: FileId::new(0),
2202        };
2203
2204        let witness = super::SymbolResolutionWitness {
2205            normalized_query: None,
2206            outcome: super::SymbolResolutionOutcome::NotFound,
2207            selected_bucket: None,
2208            candidates: Vec::new(),
2209            symbol: None,
2210            steps: vec![step.clone()],
2211        };
2212        let expected = super::SymbolResolutionWitness {
2213            normalized_query: None,
2214            outcome: super::SymbolResolutionOutcome::NotFound,
2215            selected_bucket: None,
2216            candidates: Vec::new(),
2217            symbol: None,
2218            steps: vec![step],
2219        };
2220        assert_eq!(witness, expected);
2221    }
2222
2223    /// `steps` field survives a `Clone`.
2224    #[test]
2225    fn p2u06_witness_steps_field_clones_correctly() {
2226        use crate::graph::unified::bind::witness::step::ResolutionStep;
2227        use crate::graph::unified::node::id::NodeId;
2228
2229        let step = ResolutionStep::Chose {
2230            node: NodeId::new(99, 2),
2231        };
2232        let witness = super::SymbolResolutionWitness {
2233            normalized_query: None,
2234            outcome: super::SymbolResolutionOutcome::NotFound,
2235            selected_bucket: None,
2236            candidates: Vec::new(),
2237            symbol: None,
2238            steps: vec![step],
2239        };
2240        let cloned = witness.clone();
2241        assert_eq!(witness.steps.len(), 1);
2242        assert_eq!(cloned.steps.len(), 1);
2243        assert_eq!(witness, cloned);
2244    }
2245
2246    // ── C_AMBIGUOUS tests (typed AmbiguousSymbolError surface) ─────────────
2247
2248    /// Bare-name lookup with two same-name nodes returns the typed
2249    /// [`super::SymbolResolveError::Ambiguous`] payload with both candidates
2250    /// in stable lex order.
2251    #[test]
2252    fn resolve_global_symbol_ambiguity_aware_returns_ambiguous_for_simple_name_collision() {
2253        let mut graph = CodeGraph::new();
2254        let file_path = abs_path("src/main.go");
2255
2256        let property_node = add_node(
2257            &mut graph,
2258            NodeKind::Property,
2259            "NeedTags",
2260            Some("main::SelectorSource::NeedTags"),
2261            &file_path,
2262            Some(Language::Go),
2263            4,
2264            6,
2265        );
2266        let variable_node = add_node(
2267            &mut graph,
2268            NodeKind::Variable,
2269            "NeedTags",
2270            Some("main::unrelated::NeedTags"),
2271            &file_path,
2272            Some(Language::Go),
2273            25,
2274            6,
2275        );
2276
2277        let snapshot = graph.snapshot();
2278        let result =
2279            snapshot.resolve_global_symbol_ambiguity_aware("NeedTags", super::FileScope::Any);
2280
2281        let err = result.expect_err("two same-name nodes must produce Ambiguous");
2282        let super::SymbolResolveError::Ambiguous(payload) = err else {
2283            panic!("expected Ambiguous variant, got {err:?}");
2284        };
2285        assert_eq!(payload.name, "NeedTags");
2286        assert!(!payload.truncated);
2287        assert_eq!(payload.candidates.len(), 2);
2288
2289        // Stable lex sort by qualified_name puts SelectorSource before unrelated.
2290        assert_eq!(
2291            payload.candidates[0].qualified_name,
2292            "main::SelectorSource::NeedTags"
2293        );
2294        assert_eq!(payload.candidates[0].kind, "property");
2295        assert_eq!(payload.candidates[0].start_line, 4);
2296        assert_eq!(payload.candidates[0].start_column, 6);
2297
2298        assert_eq!(
2299            payload.candidates[1].qualified_name,
2300            "main::unrelated::NeedTags"
2301        );
2302        assert_eq!(payload.candidates[1].kind, "variable");
2303        assert_eq!(payload.candidates[1].start_line, 25);
2304
2305        assert_ne!(property_node.node_id, variable_node.node_id);
2306    }
2307
2308    /// Fully-qualified-name lookup resolves unambiguously when the
2309    /// qualified bucket contains exactly one node.
2310    #[test]
2311    fn resolve_global_symbol_ambiguity_aware_resolves_qualified_name_uniquely() {
2312        let mut graph = CodeGraph::new();
2313        let file_path = abs_path("src/main.go");
2314
2315        let property_node = add_node(
2316            &mut graph,
2317            NodeKind::Property,
2318            "NeedTags",
2319            Some("main::SelectorSource::NeedTags"),
2320            &file_path,
2321            Some(Language::Go),
2322            4,
2323            6,
2324        );
2325        let _variable_node = add_node(
2326            &mut graph,
2327            NodeKind::Variable,
2328            "NeedTags",
2329            Some("main::unrelated::NeedTags"),
2330            &file_path,
2331            Some(Language::Go),
2332            25,
2333            6,
2334        );
2335
2336        let snapshot = graph.snapshot();
2337        let result = snapshot.resolve_global_symbol_ambiguity_aware(
2338            "main::SelectorSource::NeedTags",
2339            super::FileScope::Any,
2340        );
2341        assert_eq!(result, Ok(property_node.node_id));
2342    }
2343
2344    /// The qualified-name normalization path accepts native dot
2345    /// delimiters (Go, Python, Java, …) and resolves to the
2346    /// `::`-canonical node.
2347    #[test]
2348    fn resolve_global_symbol_ambiguity_aware_normalizes_dot_delimiter() {
2349        let mut graph = CodeGraph::new();
2350        let file_path = abs_path("src/main.go");
2351
2352        let property_node = add_node(
2353            &mut graph,
2354            NodeKind::Property,
2355            "NeedTags",
2356            Some("main::SelectorSource::NeedTags"),
2357            &file_path,
2358            Some(Language::Go),
2359            4,
2360            6,
2361        );
2362        let _variable_node = add_node(
2363            &mut graph,
2364            NodeKind::Variable,
2365            "NeedTags",
2366            Some("main::unrelated::NeedTags"),
2367            &file_path,
2368            Some(Language::Go),
2369            25,
2370            6,
2371        );
2372
2373        let snapshot = graph.snapshot();
2374        let result = snapshot.resolve_global_symbol_ambiguity_aware(
2375            "main.SelectorSource.NeedTags",
2376            super::FileScope::Any,
2377        );
2378        assert_eq!(result, Ok(property_node.node_id));
2379    }
2380
2381    /// Missing symbol returns the typed `NotFound` variant carrying the
2382    /// caller's input symbol verbatim.
2383    #[test]
2384    fn resolve_global_symbol_ambiguity_aware_returns_not_found_for_missing_symbol() {
2385        let graph = CodeGraph::new();
2386        let snapshot = graph.snapshot();
2387        let result =
2388            snapshot.resolve_global_symbol_ambiguity_aware("does_not_exist", super::FileScope::Any);
2389        assert_eq!(
2390            result,
2391            Err(super::SymbolResolveError::NotFound {
2392                name: "does_not_exist".to_string(),
2393            })
2394        );
2395    }
2396
2397    /// More than [`super::AMBIGUOUS_SYMBOL_CANDIDATE_CAP`] candidates trips
2398    /// the truncation flag and caps the candidate list at the constant.
2399    #[test]
2400    fn resolve_global_symbol_ambiguity_aware_caps_candidates_at_truncation_limit() {
2401        let mut graph = CodeGraph::new();
2402        let file_path = abs_path("src/main.go");
2403        let total = super::AMBIGUOUS_SYMBOL_CANDIDATE_CAP + 5;
2404
2405        for index in 0..total {
2406            // Vary the qualified name so each candidate is distinct under
2407            // the lexicographic sort used by the materializer.
2408            let qualified = format!("pkg::module_{index:03}::collide");
2409            add_node(
2410                &mut graph,
2411                NodeKind::Function,
2412                "collide",
2413                Some(qualified.as_str()),
2414                &file_path,
2415                Some(Language::Go),
2416                u32::try_from(index + 1).unwrap_or(1),
2417                0,
2418            );
2419        }
2420
2421        let snapshot = graph.snapshot();
2422        let err = snapshot
2423            .resolve_global_symbol_ambiguity_aware("collide", super::FileScope::Any)
2424            .expect_err("collisions across many nodes must surface as Ambiguous");
2425        let super::SymbolResolveError::Ambiguous(payload) = err else {
2426            panic!("expected Ambiguous variant");
2427        };
2428
2429        assert!(payload.truncated, "truncated flag must be set above cap");
2430        assert_eq!(
2431            payload.candidates.len(),
2432            super::AMBIGUOUS_SYMBOL_CANDIDATE_CAP
2433        );
2434        // Stable lex sort: module_000, module_001, ... module_019.
2435        assert_eq!(
2436            payload.candidates[0].qualified_name,
2437            "pkg::module_000::collide"
2438        );
2439    }
2440
2441    /// File-scoped resolver narrows ambiguity to candidates inside the
2442    /// requested file.
2443    #[test]
2444    fn resolve_global_symbol_ambiguity_aware_respects_file_scope() {
2445        let mut graph = CodeGraph::new();
2446        let scope_file = abs_path("src/in_scope.go");
2447        let other_file = abs_path("src/other.go");
2448
2449        let scoped_property = add_node(
2450            &mut graph,
2451            NodeKind::Property,
2452            "Same",
2453            Some("main::Owner::Same"),
2454            &scope_file,
2455            Some(Language::Go),
2456            10,
2457            6,
2458        );
2459        let _outside = add_node(
2460            &mut graph,
2461            NodeKind::Property,
2462            "Same",
2463            Some("main::Other::Same"),
2464            &other_file,
2465            Some(Language::Go),
2466            10,
2467            6,
2468        );
2469
2470        let snapshot = graph.snapshot();
2471        let result = snapshot
2472            .resolve_global_symbol_ambiguity_aware("Same", super::FileScope::Path(&scope_file));
2473        assert_eq!(result, Ok(scoped_property.node_id));
2474    }
2475}