1use std::path::Path;
7
8use crate::graph::node::Language;
9use crate::graph::unified::concurrent::GraphSnapshot;
10use crate::graph::unified::file::id::FileId;
11use crate::graph::unified::node::id::NodeId;
12use crate::graph::unified::node::kind::NodeKind;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum FileScope<'a> {
17 Any,
19 Path(&'a Path),
21 FileId(FileId),
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum ResolutionMode {
28 Strict,
30 AllowSuffixCandidates,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub struct SymbolQuery<'a> {
37 pub symbol: &'a str,
39 pub file_scope: FileScope<'a>,
41 pub mode: ResolutionMode,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum ResolvedFileScope {
48 Any,
50 File(FileId),
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum FileScopeError {
57 FileNotIndexed,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct NormalizedSymbolQuery {
64 pub symbol: String,
66 pub file_scope: ResolvedFileScope,
68 pub mode: ResolutionMode,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum SymbolResolutionOutcome {
75 Resolved(NodeId),
77 NotFound,
79 FileNotIndexed,
81 Ambiguous(Vec<NodeId>),
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
87pub enum SymbolCandidateOutcome {
88 Candidates(Vec<NodeId>),
90 NotFound,
92 FileNotIndexed,
94}
95
96impl GraphSnapshot {
97 #[must_use]
99 pub fn resolve_symbol(&self, query: &SymbolQuery<'_>) -> SymbolResolutionOutcome {
100 match self.find_symbol_candidates(query) {
101 SymbolCandidateOutcome::Candidates(candidates) => match candidates.as_slice() {
102 [] => SymbolResolutionOutcome::NotFound,
103 [node_id] => SymbolResolutionOutcome::Resolved(*node_id),
104 _ => SymbolResolutionOutcome::Ambiguous(candidates),
105 },
106 SymbolCandidateOutcome::NotFound => SymbolResolutionOutcome::NotFound,
107 SymbolCandidateOutcome::FileNotIndexed => SymbolResolutionOutcome::FileNotIndexed,
108 }
109 }
110
111 #[must_use]
113 pub fn find_symbol_candidates(&self, query: &SymbolQuery<'_>) -> SymbolCandidateOutcome {
114 let resolved_file_scope = match self.resolve_file_scope(&query.file_scope) {
115 Ok(scope) => scope,
116 Err(FileScopeError::FileNotIndexed) => return SymbolCandidateOutcome::FileNotIndexed,
117 };
118
119 let normalized_query = self.normalize_symbol_query(query, &resolved_file_scope);
120
121 let exact_qualified = self.filtered_bucket(
122 self.exact_qualified_bucket(&normalized_query),
123 resolved_file_scope,
124 );
125 if !exact_qualified.is_empty() {
126 return SymbolCandidateOutcome::Candidates(exact_qualified);
127 }
128
129 let exact_simple = self.filtered_bucket(
130 self.exact_simple_bucket(&normalized_query),
131 resolved_file_scope,
132 );
133 if !exact_simple.is_empty() {
134 return SymbolCandidateOutcome::Candidates(exact_simple);
135 }
136
137 if matches!(normalized_query.mode, ResolutionMode::AllowSuffixCandidates) {
138 let suffix_candidates = self.filtered_bucket(
139 self.bounded_suffix_bucket(&normalized_query),
140 resolved_file_scope,
141 );
142 if !suffix_candidates.is_empty() {
143 return SymbolCandidateOutcome::Candidates(suffix_candidates);
144 }
145 }
146
147 SymbolCandidateOutcome::NotFound
148 }
149
150 pub fn resolve_file_scope(
157 &self,
158 file_scope: &FileScope<'_>,
159 ) -> Result<ResolvedFileScope, FileScopeError> {
160 match *file_scope {
161 FileScope::Any => Ok(ResolvedFileScope::Any),
162 FileScope::Path(path) => self
163 .files()
164 .get(path)
165 .filter(|file_id| !self.indices().by_file(*file_id).is_empty())
166 .map_or(Err(FileScopeError::FileNotIndexed), |file_id| {
167 Ok(ResolvedFileScope::File(file_id))
168 }),
169 FileScope::FileId(file_id) => {
170 let is_indexed = self.files().resolve(file_id).is_some()
171 && !self.indices().by_file(file_id).is_empty();
172 if is_indexed {
173 Ok(ResolvedFileScope::File(file_id))
174 } else {
175 Err(FileScopeError::FileNotIndexed)
176 }
177 }
178 }
179 }
180
181 #[must_use]
183 pub fn normalize_symbol_query(
184 &self,
185 query: &SymbolQuery<'_>,
186 file_scope: &ResolvedFileScope,
187 ) -> NormalizedSymbolQuery {
188 let normalized_symbol = match *file_scope {
189 ResolvedFileScope::Any => query.symbol.to_string(),
190 ResolvedFileScope::File(file_id) => {
191 self.files().language_for_file(file_id).map_or_else(
192 || query.symbol.to_string(),
193 |language| canonicalize_graph_qualified_name(language, query.symbol),
194 )
195 }
196 };
197
198 NormalizedSymbolQuery {
199 symbol: normalized_symbol,
200 file_scope: *file_scope,
201 mode: query.mode,
202 }
203 }
204
205 fn exact_qualified_bucket(&self, query: &NormalizedSymbolQuery) -> Vec<NodeId> {
206 self.strings()
207 .get(&query.symbol)
208 .map_or_else(Vec::new, |string_id| {
209 self.indices().by_qualified_name(string_id).to_vec()
210 })
211 }
212
213 fn exact_simple_bucket(&self, query: &NormalizedSymbolQuery) -> Vec<NodeId> {
214 self.strings()
215 .get(&query.symbol)
216 .map_or_else(Vec::new, |string_id| {
217 self.indices().by_name(string_id).to_vec()
218 })
219 }
220
221 fn bounded_suffix_bucket(&self, query: &NormalizedSymbolQuery) -> Vec<NodeId> {
222 if !query.symbol.contains("::") {
223 return Vec::new();
224 }
225
226 let Some(leaf_symbol) = query.symbol.rsplit("::").next() else {
227 return Vec::new();
228 };
229 let Some(leaf_id) = self.strings().get(leaf_symbol) else {
230 return Vec::new();
231 };
232 let suffix_pattern = format!("::{}", query.symbol);
233
234 self.indices()
235 .by_name(leaf_id)
236 .iter()
237 .copied()
238 .filter(|node_id| {
239 self.get_node(*node_id)
240 .and_then(|entry| entry.qualified_name)
241 .and_then(|qualified_name_id| self.strings().resolve(qualified_name_id))
242 .is_some_and(|qualified_name| {
243 qualified_name.as_ref() == query.symbol
244 || qualified_name.as_ref().ends_with(&suffix_pattern)
245 })
246 })
247 .collect()
248 }
249
250 fn filtered_bucket(
251 &self,
252 mut bucket: Vec<NodeId>,
253 file_scope: ResolvedFileScope,
254 ) -> Vec<NodeId> {
255 if let ResolvedFileScope::File(file_id) = file_scope {
256 let file_nodes = self.indices().by_file(file_id);
257 bucket.retain(|node_id| file_nodes.contains(node_id));
258 }
259
260 bucket.sort_by(|left, right| {
261 self.candidate_sort_key(*left)
262 .cmp(&self.candidate_sort_key(*right))
263 });
264 bucket.dedup();
265 bucket
266 }
267
268 fn candidate_sort_key(&self, node_id: NodeId) -> CandidateSortKey {
269 let Some(entry) = self.get_node(node_id) else {
270 return CandidateSortKey::default_for(node_id);
271 };
272
273 let file_path = self
274 .files()
275 .resolve(entry.file)
276 .map_or_else(String::new, |path| path.to_string_lossy().into_owned());
277 let qualified_name = entry
278 .qualified_name
279 .and_then(|string_id| self.strings().resolve(string_id))
280 .map_or_else(String::new, |value| value.to_string());
281 let simple_name = self
282 .strings()
283 .resolve(entry.name)
284 .map_or_else(String::new, |value| value.to_string());
285
286 CandidateSortKey {
287 file_path,
288 start_line: entry.start_line,
289 start_column: entry.start_column,
290 end_line: entry.end_line,
291 end_column: entry.end_column,
292 kind: entry.kind.as_str().to_string(),
293 qualified_name,
294 simple_name,
295 node_id,
296 }
297 }
298}
299
300#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
301struct CandidateSortKey {
302 file_path: String,
303 start_line: u32,
304 start_column: u32,
305 end_line: u32,
306 end_column: u32,
307 kind: String,
308 qualified_name: String,
309 simple_name: String,
310 node_id: NodeId,
311}
312
313impl CandidateSortKey {
314 fn default_for(node_id: NodeId) -> Self {
315 Self {
316 file_path: String::new(),
317 start_line: 0,
318 start_column: 0,
319 end_line: 0,
320 end_column: 0,
321 kind: String::new(),
322 qualified_name: String::new(),
323 simple_name: String::new(),
324 node_id,
325 }
326 }
327}
328
329#[must_use]
331pub(crate) fn canonicalize_graph_qualified_name(language: Language, symbol: &str) -> String {
332 if should_skip_qualified_name_normalization(symbol) {
333 return symbol.to_string();
334 }
335
336 if language == Language::R {
337 return canonicalize_r_qualified_name(symbol);
338 }
339
340 let mut normalized = symbol.to_string();
341 for delimiter in native_delimiters(language) {
342 if normalized.contains(delimiter) {
343 normalized = normalized.replace(delimiter, "::");
344 }
345 }
346 normalized
347}
348
349#[must_use]
351pub(crate) fn is_canonical_graph_qualified_name(language: Language, symbol: &str) -> bool {
352 should_skip_qualified_name_normalization(symbol)
353 || canonicalize_graph_qualified_name(language, symbol) == symbol
354}
355
356fn should_skip_qualified_name_normalization(symbol: &str) -> bool {
357 symbol.starts_with('<')
358 || symbol.contains('/')
359 || symbol.starts_with("wasm::")
360 || symbol.starts_with("ffi::")
361 || symbol.starts_with("extern::")
362 || symbol.starts_with("native::")
363}
364
365fn canonicalize_r_qualified_name(symbol: &str) -> String {
366 let search_start = usize::from(symbol.starts_with('.'));
367 let Some(relative_split_index) = symbol[search_start..].rfind('.') else {
368 return symbol.to_string();
369 };
370
371 let split_index = search_start + relative_split_index;
372 let prefix = &symbol[..split_index];
373 let suffix = &symbol[split_index + 1..];
374 if suffix.is_empty() {
375 return symbol.to_string();
376 }
377
378 format!("{prefix}::{suffix}")
379}
380
381#[must_use]
383pub fn display_graph_qualified_name(
384 language: Language,
385 qualified: &str,
386 kind: NodeKind,
387 is_static: bool,
388) -> String {
389 if should_skip_qualified_name_normalization(qualified) {
390 return qualified.to_string();
391 }
392
393 match language {
394 Language::Ruby => display_ruby_qualified_name(qualified, kind, is_static),
395 Language::Php => display_php_qualified_name(qualified, kind),
396 _ => native_display_separator(language).map_or_else(
397 || qualified.to_string(),
398 |separator| qualified.replace("::", separator),
399 ),
400 }
401}
402
403pub(crate) fn native_delimiters(language: Language) -> &'static [&'static str] {
404 match language {
405 Language::JavaScript
406 | Language::Python
407 | Language::TypeScript
408 | Language::Java
409 | Language::CSharp
410 | Language::Kotlin
411 | Language::Scala
412 | Language::Go
413 | Language::Css
414 | Language::Sql
415 | Language::Dart
416 | Language::Lua
417 | Language::Perl
418 | Language::Groovy
419 | Language::Elixir
420 | Language::R
421 | Language::Haskell
422 | Language::Html
423 | Language::Svelte
424 | Language::Vue
425 | Language::Terraform
426 | Language::Puppet
427 | Language::Pulumi
428 | Language::Http
429 | Language::Plsql
430 | Language::Apex
431 | Language::Abap
432 | Language::ServiceNow
433 | Language::Swift
434 | Language::Zig
435 | Language::Json => &["."],
436 Language::Ruby => &["#", "."],
437 Language::Php => &["\\", "->"],
438 Language::C | Language::Cpp | Language::Rust | Language::Shell => &[],
439 }
440}
441
442fn native_display_separator(language: Language) -> Option<&'static str> {
443 match language {
444 Language::C
445 | Language::Cpp
446 | Language::Rust
447 | Language::Shell
448 | Language::Php
449 | Language::Ruby => None,
450 _ => Some("."),
451 }
452}
453
454fn display_ruby_qualified_name(qualified: &str, kind: NodeKind, is_static: bool) -> String {
455 if qualified.contains('#') || qualified.contains('.') || !qualified.contains("::") {
456 return qualified.to_string();
457 }
458
459 match kind {
460 NodeKind::Method => {
461 replace_last_separator(qualified, if is_static { "." } else { "#" }, false)
462 }
463 NodeKind::Variable if should_display_ruby_member_variable(qualified) => {
464 replace_last_separator(qualified, "#", false)
465 }
466 _ => qualified.to_string(),
467 }
468}
469
470fn should_display_ruby_member_variable(qualified: &str) -> bool {
471 let Some((_, suffix)) = qualified.rsplit_once("::") else {
472 return false;
473 };
474
475 if suffix.starts_with("@@")
476 || suffix
477 .chars()
478 .next()
479 .is_some_and(|character| character.is_ascii_uppercase())
480 {
481 return false;
482 }
483
484 suffix.starts_with('@')
485 || suffix
486 .chars()
487 .next()
488 .is_some_and(|character| character.is_ascii_lowercase() || character == '_')
489}
490
491fn display_php_qualified_name(qualified: &str, kind: NodeKind) -> String {
492 if !qualified.contains("::") {
493 return qualified.to_string();
494 }
495
496 if matches!(kind, NodeKind::Method | NodeKind::Property) {
497 return replace_last_separator(qualified, "::", true);
498 }
499
500 qualified.replace("::", "\\")
501}
502
503fn replace_last_separator(qualified: &str, final_separator: &str, preserve_prefix: bool) -> String {
504 let Some((prefix, suffix)) = qualified.rsplit_once("::") else {
505 return qualified.to_string();
506 };
507
508 let display_prefix = if preserve_prefix {
509 prefix.replace("::", "\\")
510 } else {
511 prefix.to_string()
512 };
513
514 if display_prefix.is_empty() {
515 suffix.to_string()
516 } else {
517 format!("{display_prefix}{final_separator}{suffix}")
518 }
519}
520
521#[cfg(test)]
522mod tests {
523 use std::path::{Path, PathBuf};
524
525 use crate::graph::node::Language;
526 use crate::graph::unified::concurrent::CodeGraph;
527 use crate::graph::unified::node::id::NodeId;
528 use crate::graph::unified::node::kind::NodeKind;
529 use crate::graph::unified::storage::arena::NodeEntry;
530
531 use super::{
532 FileScope, NormalizedSymbolQuery, ResolutionMode, ResolvedFileScope,
533 SymbolCandidateOutcome, SymbolQuery, SymbolResolutionOutcome,
534 canonicalize_graph_qualified_name, display_graph_qualified_name,
535 };
536
537 struct TestNode {
538 node_id: NodeId,
539 }
540
541 #[test]
542 fn test_resolve_symbol_exact_qualified_same_file() {
543 let mut graph = CodeGraph::new();
544 let file_path = abs_path("src/lib.rs");
545 let symbol = add_node(
546 &mut graph,
547 NodeKind::Function,
548 "target",
549 Some("pkg::target"),
550 &file_path,
551 Some(Language::Rust),
552 10,
553 2,
554 );
555
556 let snapshot = graph.snapshot();
557 let query = SymbolQuery {
558 symbol: "pkg::target",
559 file_scope: FileScope::Path(&file_path),
560 mode: ResolutionMode::Strict,
561 };
562
563 assert_eq!(
564 snapshot.resolve_symbol(&query),
565 SymbolResolutionOutcome::Resolved(symbol.node_id)
566 );
567 }
568
569 #[test]
570 fn test_resolve_symbol_exact_simple_same_file_wins() {
571 let mut graph = CodeGraph::new();
572 let requested_path = abs_path("src/requested.rs");
573 let other_path = abs_path("src/other.rs");
574
575 let requested = add_node(
576 &mut graph,
577 NodeKind::Function,
578 "target",
579 Some("requested::target"),
580 &requested_path,
581 Some(Language::Rust),
582 4,
583 0,
584 );
585 let _other = add_node(
586 &mut graph,
587 NodeKind::Function,
588 "target",
589 Some("other::target"),
590 &other_path,
591 Some(Language::Rust),
592 1,
593 0,
594 );
595
596 let snapshot = graph.snapshot();
597 let query = SymbolQuery {
598 symbol: "target",
599 file_scope: FileScope::Path(&requested_path),
600 mode: ResolutionMode::Strict,
601 };
602
603 assert_eq!(
604 snapshot.resolve_symbol(&query),
605 SymbolResolutionOutcome::Resolved(requested.node_id)
606 );
607 }
608
609 #[test]
610 fn test_resolve_symbol_returns_not_found_without_wrong_file_fallback() {
611 let mut graph = CodeGraph::new();
612 let requested_path = abs_path("src/requested.rs");
613 let other_path = abs_path("src/other.rs");
614
615 let _requested_index_anchor = add_node(
616 &mut graph,
617 NodeKind::Function,
618 "anchor",
619 Some("requested::anchor"),
620 &requested_path,
621 Some(Language::Rust),
622 1,
623 0,
624 );
625 let _other = add_node(
626 &mut graph,
627 NodeKind::Function,
628 "target",
629 Some("other::target"),
630 &other_path,
631 Some(Language::Rust),
632 3,
633 0,
634 );
635
636 let snapshot = graph.snapshot();
637 let query = SymbolQuery {
638 symbol: "target",
639 file_scope: FileScope::Path(&requested_path),
640 mode: ResolutionMode::Strict,
641 };
642
643 assert_eq!(
644 snapshot.resolve_symbol(&query),
645 SymbolResolutionOutcome::NotFound
646 );
647 }
648
649 #[test]
650 fn test_resolve_symbol_returns_file_not_indexed_for_valid_unindexed_path() {
651 let mut graph = CodeGraph::new();
652 let indexed_path = abs_path("src/indexed.rs");
653 let unindexed_path = abs_path("src/unindexed.rs");
654
655 add_node(
656 &mut graph,
657 NodeKind::Function,
658 "indexed",
659 Some("pkg::indexed"),
660 &indexed_path,
661 Some(Language::Rust),
662 1,
663 0,
664 );
665 graph
666 .files_mut()
667 .register_with_language(&unindexed_path, Some(Language::Rust))
668 .unwrap();
669
670 let snapshot = graph.snapshot();
671 let query = SymbolQuery {
672 symbol: "indexed",
673 file_scope: FileScope::Path(&unindexed_path),
674 mode: ResolutionMode::Strict,
675 };
676
677 assert_eq!(
678 snapshot.resolve_symbol(&query),
679 SymbolResolutionOutcome::FileNotIndexed
680 );
681 }
682
683 #[test]
684 fn test_resolve_symbol_returns_ambiguous_for_multi_match_bucket() {
685 let mut graph = CodeGraph::new();
686 let file_path = abs_path("src/lib.rs");
687
688 let first = add_node(
689 &mut graph,
690 NodeKind::Function,
691 "dup",
692 Some("pkg::dup"),
693 &file_path,
694 Some(Language::Rust),
695 2,
696 0,
697 );
698 let second = add_node(
699 &mut graph,
700 NodeKind::Method,
701 "dup",
702 Some("pkg::dup_method"),
703 &file_path,
704 Some(Language::Rust),
705 8,
706 0,
707 );
708
709 let snapshot = graph.snapshot();
710 let query = SymbolQuery {
711 symbol: "dup",
712 file_scope: FileScope::Path(&file_path),
713 mode: ResolutionMode::Strict,
714 };
715
716 assert_eq!(
717 snapshot.resolve_symbol(&query),
718 SymbolResolutionOutcome::Ambiguous(vec![first.node_id, second.node_id])
719 );
720 }
721
722 #[test]
723 fn test_find_symbol_candidates_uses_first_non_empty_bucket_only() {
724 let mut graph = CodeGraph::new();
725 let qualified_path = abs_path("src/qualified.rs");
726 let simple_path = abs_path("src/simple.rs");
727
728 let qualified = add_node(
729 &mut graph,
730 NodeKind::Function,
731 "target",
732 Some("pkg::target"),
733 &qualified_path,
734 Some(Language::Rust),
735 1,
736 0,
737 );
738 let simple_only = add_node(
739 &mut graph,
740 NodeKind::Function,
741 "pkg::target",
742 None,
743 &simple_path,
744 Some(Language::Rust),
745 1,
746 0,
747 );
748
749 let snapshot = graph.snapshot();
750 let query = SymbolQuery {
751 symbol: "pkg::target",
752 file_scope: FileScope::Any,
753 mode: ResolutionMode::AllowSuffixCandidates,
754 };
755
756 assert_eq!(
757 snapshot.find_symbol_candidates(&query),
758 SymbolCandidateOutcome::Candidates(vec![qualified.node_id])
759 );
760 assert_ne!(qualified.node_id, simple_only.node_id);
761 }
762
763 #[test]
764 fn test_find_symbol_candidates_preserves_file_not_indexed() {
765 let mut graph = CodeGraph::new();
766 let indexed_path = abs_path("src/indexed.rs");
767 let unindexed_path = abs_path("src/unindexed.rs");
768
769 add_node(
770 &mut graph,
771 NodeKind::Function,
772 "target",
773 Some("pkg::target"),
774 &indexed_path,
775 Some(Language::Rust),
776 1,
777 0,
778 );
779 let unindexed_file_id = graph
780 .files_mut()
781 .register_with_language(&unindexed_path, Some(Language::Rust))
782 .unwrap();
783
784 let snapshot = graph.snapshot();
785 let query = SymbolQuery {
786 symbol: "target",
787 file_scope: FileScope::FileId(unindexed_file_id),
788 mode: ResolutionMode::AllowSuffixCandidates,
789 };
790
791 assert_eq!(
792 snapshot.find_symbol_candidates(&query),
793 SymbolCandidateOutcome::FileNotIndexed
794 );
795 }
796
797 #[test]
798 fn test_suffix_candidates_disabled_in_strict_mode() {
799 let mut graph = CodeGraph::new();
800 let file_path = abs_path("src/lib.rs");
801
802 let suffix_match = add_node(
803 &mut graph,
804 NodeKind::Function,
805 "target",
806 Some("outer::pkg::target"),
807 &file_path,
808 Some(Language::Rust),
809 1,
810 0,
811 );
812
813 let snapshot = graph.snapshot();
814 let strict_query = SymbolQuery {
815 symbol: "pkg::target",
816 file_scope: FileScope::Any,
817 mode: ResolutionMode::Strict,
818 };
819 let suffix_query = SymbolQuery {
820 mode: ResolutionMode::AllowSuffixCandidates,
821 ..strict_query
822 };
823
824 assert_eq!(
825 snapshot.resolve_symbol(&strict_query),
826 SymbolResolutionOutcome::NotFound
827 );
828 assert_eq!(
829 snapshot.find_symbol_candidates(&suffix_query),
830 SymbolCandidateOutcome::Candidates(vec![suffix_match.node_id])
831 );
832 }
833
834 #[test]
835 fn test_suffix_candidates_require_canonical_qualified_query() {
836 let mut graph = CodeGraph::new();
837 let file_path = abs_path("src/mod.py");
838
839 add_node(
840 &mut graph,
841 NodeKind::Function,
842 "target",
843 Some("pkg::target"),
844 &file_path,
845 Some(Language::Python),
846 1,
847 0,
848 );
849
850 let snapshot = graph.snapshot();
851 let query = SymbolQuery {
852 symbol: "pkg.target",
853 file_scope: FileScope::Any,
854 mode: ResolutionMode::AllowSuffixCandidates,
855 };
856
857 assert_eq!(
858 snapshot.find_symbol_candidates(&query),
859 SymbolCandidateOutcome::NotFound
860 );
861 }
862
863 #[test]
864 fn test_suffix_candidates_filter_same_leaf_bucket_only() {
865 let mut graph = CodeGraph::new();
866 let file_path = abs_path("src/lib.rs");
867
868 let exact_suffix = add_node(
869 &mut graph,
870 NodeKind::Function,
871 "target",
872 Some("outer::pkg::target"),
873 &file_path,
874 Some(Language::Rust),
875 2,
876 0,
877 );
878 let another_suffix = add_node(
879 &mut graph,
880 NodeKind::Method,
881 "target",
882 Some("another::pkg::target"),
883 &file_path,
884 Some(Language::Rust),
885 4,
886 0,
887 );
888 let unrelated = add_node(
889 &mut graph,
890 NodeKind::Function,
891 "target",
892 Some("pkg::different::target"),
893 &file_path,
894 Some(Language::Rust),
895 6,
896 0,
897 );
898
899 let snapshot = graph.snapshot();
900 let query = SymbolQuery {
901 symbol: "pkg::target",
902 file_scope: FileScope::Any,
903 mode: ResolutionMode::AllowSuffixCandidates,
904 };
905
906 assert_eq!(
907 snapshot.find_symbol_candidates(&query),
908 SymbolCandidateOutcome::Candidates(vec![exact_suffix.node_id, another_suffix.node_id])
909 );
910 assert_ne!(unrelated.node_id, exact_suffix.node_id);
911 }
912
913 #[test]
914 fn test_normalize_symbol_query_rewrites_native_delimiter_when_file_scope_language_known() {
915 let mut graph = CodeGraph::new();
916 let file_path = abs_path("src/mod.py");
917 let file_id = graph
918 .files_mut()
919 .register_with_language(&file_path, Some(Language::Python))
920 .unwrap();
921 let snapshot = graph.snapshot();
922 let query = SymbolQuery {
923 symbol: "pkg.mod.fn",
924 file_scope: FileScope::Path(&file_path),
925 mode: ResolutionMode::Strict,
926 };
927
928 let normalized = snapshot.normalize_symbol_query(&query, &ResolvedFileScope::File(file_id));
929
930 assert_eq!(
931 normalized,
932 NormalizedSymbolQuery {
933 symbol: "pkg::mod::fn".to_string(),
934 file_scope: ResolvedFileScope::File(file_id),
935 mode: ResolutionMode::Strict,
936 }
937 );
938 }
939
940 #[test]
941 fn test_normalize_symbol_query_rewrites_native_delimiter_for_csharp() {
942 let mut graph = CodeGraph::new();
943 let file_path = abs_path("src/Program.cs");
944 let file_id = graph
945 .files_mut()
946 .register_with_language(&file_path, Some(Language::CSharp))
947 .unwrap();
948 let snapshot = graph.snapshot();
949 let query = SymbolQuery {
950 symbol: "System.Console.WriteLine",
951 file_scope: FileScope::Path(&file_path),
952 mode: ResolutionMode::Strict,
953 };
954
955 let normalized = snapshot.normalize_symbol_query(&query, &ResolvedFileScope::File(file_id));
956
957 assert_eq!(normalized.symbol, "System::Console::WriteLine".to_string());
958 }
959
960 #[test]
961 fn test_normalize_symbol_query_rewrites_native_delimiter_for_zig() {
962 let mut graph = CodeGraph::new();
963 let file_path = abs_path("src/main.zig");
964 let file_id = graph
965 .files_mut()
966 .register_with_language(&file_path, Some(Language::Zig))
967 .unwrap();
968 let snapshot = graph.snapshot();
969 let query = SymbolQuery {
970 symbol: "std.os.linux.exit",
971 file_scope: FileScope::Path(&file_path),
972 mode: ResolutionMode::Strict,
973 };
974
975 let normalized = snapshot.normalize_symbol_query(&query, &ResolvedFileScope::File(file_id));
976
977 assert_eq!(normalized.symbol, "std::os::linux::exit".to_string());
978 }
979
980 #[test]
981 fn test_normalize_symbol_query_does_not_rewrite_when_file_scope_any() {
982 let graph = CodeGraph::new();
983 let snapshot = graph.snapshot();
984 let query = SymbolQuery {
985 symbol: "pkg.mod.fn",
986 file_scope: FileScope::Any,
987 mode: ResolutionMode::Strict,
988 };
989
990 let normalized = snapshot.normalize_symbol_query(&query, &ResolvedFileScope::Any);
991
992 assert_eq!(
993 normalized,
994 NormalizedSymbolQuery {
995 symbol: "pkg.mod.fn".to_string(),
996 file_scope: ResolvedFileScope::Any,
997 mode: ResolutionMode::Strict,
998 }
999 );
1000 }
1001
1002 #[test]
1003 fn test_global_qualified_query_with_native_delimiter_is_exact_only_and_not_found() {
1004 let mut graph = CodeGraph::new();
1005 let file_path = abs_path("src/mod.py");
1006
1007 add_node(
1008 &mut graph,
1009 NodeKind::Function,
1010 "fn",
1011 Some("pkg::mod::fn"),
1012 &file_path,
1013 Some(Language::Python),
1014 1,
1015 0,
1016 );
1017
1018 let snapshot = graph.snapshot();
1019 let query = SymbolQuery {
1020 symbol: "pkg.mod.fn",
1021 file_scope: FileScope::Any,
1022 mode: ResolutionMode::AllowSuffixCandidates,
1023 };
1024
1025 assert_eq!(
1026 snapshot.resolve_symbol(&query),
1027 SymbolResolutionOutcome::NotFound
1028 );
1029 }
1030
1031 #[test]
1032 fn test_global_canonical_qualified_query_can_hit_exact_qualified_bucket() {
1033 let mut graph = CodeGraph::new();
1034 let file_path = abs_path("src/lib.rs");
1035 let expected = add_node(
1036 &mut graph,
1037 NodeKind::Function,
1038 "fn",
1039 Some("pkg::mod::fn"),
1040 &file_path,
1041 Some(Language::Rust),
1042 1,
1043 0,
1044 );
1045
1046 let snapshot = graph.snapshot();
1047 let query = SymbolQuery {
1048 symbol: "pkg::mod::fn",
1049 file_scope: FileScope::Any,
1050 mode: ResolutionMode::Strict,
1051 };
1052
1053 assert_eq!(
1054 snapshot.resolve_symbol(&query),
1055 SymbolResolutionOutcome::Resolved(expected.node_id)
1056 );
1057 }
1058
1059 #[test]
1060 fn test_candidate_order_uses_metadata_then_node_id() {
1061 let mut graph = CodeGraph::new();
1062 let file_path = abs_path("src/lib.rs");
1063
1064 let first = add_node(
1065 &mut graph,
1066 NodeKind::Function,
1067 "dup",
1068 Some("pkg::dup_a"),
1069 &file_path,
1070 Some(Language::Rust),
1071 1,
1072 0,
1073 );
1074 let second = add_node(
1075 &mut graph,
1076 NodeKind::Function,
1077 "dup",
1078 Some("pkg::dup_b"),
1079 &file_path,
1080 Some(Language::Rust),
1081 1,
1082 0,
1083 );
1084
1085 let snapshot = graph.snapshot();
1086 let query = SymbolQuery {
1087 symbol: "dup",
1088 file_scope: FileScope::Any,
1089 mode: ResolutionMode::Strict,
1090 };
1091
1092 assert_eq!(
1093 snapshot.find_symbol_candidates(&query),
1094 SymbolCandidateOutcome::Candidates(vec![first.node_id, second.node_id])
1095 );
1096 }
1097
1098 #[test]
1099 fn test_candidate_order_kind_sort_key_uses_node_kind_as_str() {
1100 let mut graph = CodeGraph::new();
1101 let file_path = abs_path("src/lib.rs");
1102
1103 let function_node = add_node(
1104 &mut graph,
1105 NodeKind::Function,
1106 "shared",
1107 Some("pkg::shared_fn"),
1108 &file_path,
1109 Some(Language::Rust),
1110 1,
1111 0,
1112 );
1113 let variable_node = add_node(
1114 &mut graph,
1115 NodeKind::Variable,
1116 "shared",
1117 Some("pkg::shared_var"),
1118 &file_path,
1119 Some(Language::Rust),
1120 1,
1121 0,
1122 );
1123
1124 let snapshot = graph.snapshot();
1125 let query = SymbolQuery {
1126 symbol: "shared",
1127 file_scope: FileScope::Any,
1128 mode: ResolutionMode::Strict,
1129 };
1130
1131 assert_eq!(
1132 snapshot.find_symbol_candidates(&query),
1133 SymbolCandidateOutcome::Candidates(vec![function_node.node_id, variable_node.node_id])
1134 );
1135 }
1136
1137 fn add_node(
1138 graph: &mut CodeGraph,
1139 kind: NodeKind,
1140 name: &str,
1141 qualified_name: Option<&str>,
1142 file_path: &Path,
1143 language: Option<Language>,
1144 start_line: u32,
1145 start_column: u32,
1146 ) -> TestNode {
1147 let name_id = graph.strings_mut().intern(name).unwrap();
1148 let qualified_name_id =
1149 qualified_name.map(|value| graph.strings_mut().intern(value).unwrap());
1150 let file_id = graph
1151 .files_mut()
1152 .register_with_language(file_path, language)
1153 .unwrap();
1154
1155 let entry = NodeEntry::new(kind, name_id, file_id)
1156 .with_qualified_name_opt(qualified_name_id)
1157 .with_location(start_line, start_column, start_line, start_column + 1);
1158
1159 let node_id = graph.nodes_mut().alloc(entry).unwrap();
1160 graph
1161 .indices_mut()
1162 .add(node_id, kind, name_id, qualified_name_id, file_id);
1163
1164 TestNode { node_id }
1165 }
1166
1167 trait NodeEntryExt {
1168 fn with_qualified_name_opt(
1169 self,
1170 qualified_name: Option<crate::graph::unified::string::id::StringId>,
1171 ) -> Self;
1172 }
1173
1174 impl NodeEntryExt for NodeEntry {
1175 fn with_qualified_name_opt(
1176 mut self,
1177 qualified_name: Option<crate::graph::unified::string::id::StringId>,
1178 ) -> Self {
1179 self.qualified_name = qualified_name;
1180 self
1181 }
1182 }
1183
1184 fn abs_path(relative: &str) -> PathBuf {
1185 PathBuf::from("/resolver-tests").join(relative)
1186 }
1187
1188 #[test]
1189 fn test_display_graph_qualified_name_dot_language() {
1190 let display = display_graph_qualified_name(
1191 Language::CSharp,
1192 "MyApp::User::GetName",
1193 NodeKind::Method,
1194 false,
1195 );
1196 assert_eq!(display, "MyApp.User.GetName");
1197 }
1198
1199 #[test]
1200 fn test_canonicalize_graph_qualified_name_r_private_name_preserved() {
1201 assert_eq!(
1202 canonicalize_graph_qualified_name(Language::R, ".private_func"),
1203 ".private_func"
1204 );
1205 }
1206
1207 #[test]
1208 fn test_canonicalize_graph_qualified_name_r_s3_method_uses_last_dot() {
1209 assert_eq!(
1210 canonicalize_graph_qualified_name(Language::R, "as.data.frame.myclass"),
1211 "as.data.frame::myclass"
1212 );
1213 }
1214
1215 #[test]
1216 fn test_canonicalize_graph_qualified_name_r_leading_dot_s3_generic() {
1217 assert_eq!(
1218 canonicalize_graph_qualified_name(Language::R, ".DollarNames.myclass"),
1219 ".DollarNames::myclass"
1220 );
1221 }
1222
1223 #[test]
1224 fn test_display_graph_qualified_name_ruby_instance_method() {
1225 let display = display_graph_qualified_name(
1226 Language::Ruby,
1227 "Admin::Users::Controller::show",
1228 NodeKind::Method,
1229 false,
1230 );
1231 assert_eq!(display, "Admin::Users::Controller#show");
1232 }
1233
1234 #[test]
1235 fn test_display_graph_qualified_name_ruby_singleton_method() {
1236 let display = display_graph_qualified_name(
1237 Language::Ruby,
1238 "Admin::Users::Controller::show",
1239 NodeKind::Method,
1240 true,
1241 );
1242 assert_eq!(display, "Admin::Users::Controller.show");
1243 }
1244
1245 #[test]
1246 fn test_display_graph_qualified_name_ruby_member_variable() {
1247 let display = display_graph_qualified_name(
1248 Language::Ruby,
1249 "Admin::Users::Controller::username",
1250 NodeKind::Variable,
1251 false,
1252 );
1253 assert_eq!(display, "Admin::Users::Controller#username");
1254 }
1255
1256 #[test]
1257 fn test_display_graph_qualified_name_ruby_instance_variable() {
1258 let display = display_graph_qualified_name(
1259 Language::Ruby,
1260 "Admin::Users::Controller::@current_user",
1261 NodeKind::Variable,
1262 false,
1263 );
1264 assert_eq!(display, "Admin::Users::Controller#@current_user");
1265 }
1266
1267 #[test]
1268 fn test_display_graph_qualified_name_ruby_constant_stays_canonical() {
1269 let display = display_graph_qualified_name(
1270 Language::Ruby,
1271 "Admin::Users::Controller::DEFAULT_ROLE",
1272 NodeKind::Variable,
1273 false,
1274 );
1275 assert_eq!(display, "Admin::Users::Controller::DEFAULT_ROLE");
1276 }
1277
1278 #[test]
1279 fn test_display_graph_qualified_name_ruby_class_variable_stays_canonical() {
1280 let display = display_graph_qualified_name(
1281 Language::Ruby,
1282 "Admin::Users::Controller::@@count",
1283 NodeKind::Variable,
1284 false,
1285 );
1286 assert_eq!(display, "Admin::Users::Controller::@@count");
1287 }
1288
1289 #[test]
1290 fn test_display_graph_qualified_name_php_namespace_function() {
1291 let display = display_graph_qualified_name(
1292 Language::Php,
1293 "App::Services::send_mail",
1294 NodeKind::Function,
1295 false,
1296 );
1297 assert_eq!(display, "App\\Services\\send_mail");
1298 }
1299
1300 #[test]
1301 fn test_display_graph_qualified_name_php_method() {
1302 let display = display_graph_qualified_name(
1303 Language::Php,
1304 "App::Services::Mailer::deliver",
1305 NodeKind::Method,
1306 false,
1307 );
1308 assert_eq!(display, "App\\Services\\Mailer::deliver");
1309 }
1310
1311 #[test]
1312 fn test_display_graph_qualified_name_preserves_path_like_symbols() {
1313 let display = display_graph_qualified_name(
1314 Language::Go,
1315 "route::GET::/health",
1316 NodeKind::Endpoint,
1317 false,
1318 );
1319 assert_eq!(display, "route::GET::/health");
1320 }
1321
1322 #[test]
1323 fn test_display_graph_qualified_name_preserves_ffi_symbols() {
1324 let display = display_graph_qualified_name(
1325 Language::Haskell,
1326 "ffi::C::sin",
1327 NodeKind::Function,
1328 false,
1329 );
1330 assert_eq!(display, "ffi::C::sin");
1331 }
1332
1333 #[test]
1334 fn test_display_graph_qualified_name_preserves_native_cffi_symbols() {
1335 let display = display_graph_qualified_name(
1336 Language::Python,
1337 "native::cffi::calculate",
1338 NodeKind::Function,
1339 false,
1340 );
1341 assert_eq!(display, "native::cffi::calculate");
1342 }
1343
1344 #[test]
1345 fn test_display_graph_qualified_name_preserves_native_php_ffi_symbols() {
1346 let display = display_graph_qualified_name(
1347 Language::Php,
1348 "native::ffi::crypto_encrypt",
1349 NodeKind::Function,
1350 false,
1351 );
1352 assert_eq!(display, "native::ffi::crypto_encrypt");
1353 }
1354
1355 #[test]
1356 fn test_display_graph_qualified_name_preserves_native_panama_symbols() {
1357 let display = display_graph_qualified_name(
1358 Language::Java,
1359 "native::panama::nativeLinker",
1360 NodeKind::Function,
1361 false,
1362 );
1363 assert_eq!(display, "native::panama::nativeLinker");
1364 }
1365
1366 #[test]
1367 fn test_canonicalize_graph_qualified_name_preserves_wasm_symbols() {
1368 assert_eq!(
1369 canonicalize_graph_qualified_name(Language::TypeScript, "wasm::module.wasm"),
1370 "wasm::module.wasm"
1371 );
1372 }
1373
1374 #[test]
1375 fn test_canonicalize_graph_qualified_name_preserves_native_symbols() {
1376 assert_eq!(
1377 canonicalize_graph_qualified_name(Language::TypeScript, "native::binding.node"),
1378 "native::binding.node"
1379 );
1380 }
1381
1382 #[test]
1383 fn test_display_graph_qualified_name_preserves_wasm_symbols() {
1384 let display = display_graph_qualified_name(
1385 Language::TypeScript,
1386 "wasm::module.wasm",
1387 NodeKind::Module,
1388 false,
1389 );
1390 assert_eq!(display, "wasm::module.wasm");
1391 }
1392
1393 #[test]
1394 fn test_display_graph_qualified_name_preserves_native_symbols() {
1395 let display = display_graph_qualified_name(
1396 Language::TypeScript,
1397 "native::binding.node",
1398 NodeKind::Module,
1399 false,
1400 );
1401 assert_eq!(display, "native::binding.node");
1402 }
1403
1404 #[test]
1405 fn test_canonicalize_graph_qualified_name_still_normalizes_dot_language_symbols() {
1406 assert_eq!(
1407 canonicalize_graph_qualified_name(Language::TypeScript, "Foo.bar"),
1408 "Foo::bar"
1409 );
1410 }
1411}