1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum FileScope<'a> {
21 Any,
23 Path(&'a Path),
25 FileId(FileId),
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31pub enum ResolutionMode {
32 Strict,
34 AllowSuffixCandidates,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub struct SymbolQuery<'a> {
41 pub symbol: &'a str,
43 pub file_scope: FileScope<'a>,
45 pub mode: ResolutionMode,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51pub enum ResolvedFileScope {
52 Any,
54 File(FileId),
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum FileScopeError {
61 FileNotIndexed,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct NormalizedSymbolQuery {
68 pub symbol: String,
70 pub file_scope: ResolvedFileScope,
72 pub mode: ResolutionMode,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
84pub enum SymbolCandidateBucket {
85 ExactQualified,
87 ExactSimple,
89 CanonicalSuffix,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct SymbolCandidateWitness {
96 pub node_id: NodeId,
98 pub bucket: SymbolCandidateBucket,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104pub enum SymbolResolutionOutcome {
105 Resolved(NodeId),
107 NotFound,
109 FileNotIndexed,
111 Ambiguous(Vec<NodeId>),
113}
114
115#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum SymbolCandidateOutcome {
118 Candidates(Vec<NodeId>),
120 NotFound,
122 FileNotIndexed,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct SymbolResolutionWitness {
133 pub normalized_query: Option<NormalizedSymbolQuery>,
135 pub outcome: SymbolResolutionOutcome,
137 pub selected_bucket: Option<SymbolCandidateBucket>,
139 pub candidates: Vec<SymbolCandidateWitness>,
141 pub symbol: Option<StringId>,
151 pub steps: Vec<crate::graph::unified::bind::witness::step::ResolutionStep>,
160}
161
162impl GraphSnapshot {
163 #[must_use]
165 pub fn resolve_symbol(&self, query: &SymbolQuery<'_>) -> SymbolResolutionOutcome {
166 self.resolve_symbol_with_witness(query).outcome
167 }
168
169 #[must_use]
171 pub fn find_symbol_candidates(&self, query: &SymbolQuery<'_>) -> SymbolCandidateOutcome {
172 self.find_symbol_candidates_with_witness(query).outcome
173 }
174
175 #[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 #[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 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 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 #[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 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 fn resolve_global_symbol_outcome(
468 &self,
469 symbol: &str,
470 file_scope: FileScope<'_>,
471 ) -> SymbolResolutionOutcome {
472 let primary = self.resolve_symbol(&SymbolQuery {
477 symbol,
478 file_scope,
479 mode: ResolutionMode::Strict,
480 });
481
482 match primary {
483 SymbolResolutionOutcome::Resolved(_) | SymbolResolutionOutcome::Ambiguous(_) => primary,
485 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
669pub struct SymbolCandidateSearchWitness {
670 pub normalized_query: Option<NormalizedSymbolQuery>,
672 pub outcome: SymbolCandidateOutcome,
674 pub selected_bucket: Option<SymbolCandidateBucket>,
676 pub candidates: Vec<SymbolCandidateWitness>,
678}
679
680pub const AMBIGUOUS_SYMBOL_CANDIDATE_CAP: usize = 20;
687
688#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
695pub struct AmbiguousSymbolCandidate {
696 pub qualified_name: String,
699 pub kind: String,
701 pub file_path: String,
703 pub start_line: u32,
705 pub start_column: u32,
707}
708
709#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
721pub struct AmbiguousSymbolError {
722 pub name: String,
724 pub candidates: Vec<AmbiguousSymbolCandidate>,
726 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#[derive(Debug, Clone, PartialEq, Eq)]
748pub enum SymbolResolveError {
749 NotFound {
751 name: String,
753 },
754 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#[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#[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#[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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 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 assert_eq!(
2436 payload.candidates[0].qualified_name,
2437 "pkg::module_000::collide"
2438 );
2439 }
2440
2441 #[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}