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