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::Ruby => &["#", "."],
436 Language::Php => &["\\", "->"],
437 Language::C | Language::Cpp | Language::Rust | Language::Shell => &[],
438 }
439}
440
441fn native_display_separator(language: Language) -> Option<&'static str> {
442 match language {
443 Language::C
444 | Language::Cpp
445 | Language::Rust
446 | Language::Shell
447 | Language::Php
448 | Language::Ruby => None,
449 _ => Some("."),
450 }
451}
452
453fn display_ruby_qualified_name(qualified: &str, kind: NodeKind, is_static: bool) -> String {
454 if qualified.contains('#') || qualified.contains('.') || !qualified.contains("::") {
455 return qualified.to_string();
456 }
457
458 match kind {
459 NodeKind::Method => {
460 replace_last_separator(qualified, if is_static { "." } else { "#" }, false)
461 }
462 NodeKind::Variable if should_display_ruby_member_variable(qualified) => {
463 replace_last_separator(qualified, "#", false)
464 }
465 _ => qualified.to_string(),
466 }
467}
468
469fn should_display_ruby_member_variable(qualified: &str) -> bool {
470 let Some((_, suffix)) = qualified.rsplit_once("::") else {
471 return false;
472 };
473
474 if suffix.starts_with("@@")
475 || suffix
476 .chars()
477 .next()
478 .is_some_and(|character| character.is_ascii_uppercase())
479 {
480 return false;
481 }
482
483 suffix.starts_with('@')
484 || suffix
485 .chars()
486 .next()
487 .is_some_and(|character| character.is_ascii_lowercase() || character == '_')
488}
489
490fn display_php_qualified_name(qualified: &str, kind: NodeKind) -> String {
491 if !qualified.contains("::") {
492 return qualified.to_string();
493 }
494
495 if matches!(kind, NodeKind::Method | NodeKind::Property) {
496 return replace_last_separator(qualified, "::", true);
497 }
498
499 qualified.replace("::", "\\")
500}
501
502fn replace_last_separator(qualified: &str, final_separator: &str, preserve_prefix: bool) -> String {
503 let Some((prefix, suffix)) = qualified.rsplit_once("::") else {
504 return qualified.to_string();
505 };
506
507 let display_prefix = if preserve_prefix {
508 prefix.replace("::", "\\")
509 } else {
510 prefix.to_string()
511 };
512
513 if display_prefix.is_empty() {
514 suffix.to_string()
515 } else {
516 format!("{display_prefix}{final_separator}{suffix}")
517 }
518}
519
520#[cfg(test)]
521mod tests {
522 use std::path::{Path, PathBuf};
523
524 use crate::graph::node::Language;
525 use crate::graph::unified::concurrent::CodeGraph;
526 use crate::graph::unified::node::id::NodeId;
527 use crate::graph::unified::node::kind::NodeKind;
528 use crate::graph::unified::storage::arena::NodeEntry;
529
530 use super::{
531 FileScope, NormalizedSymbolQuery, ResolutionMode, ResolvedFileScope,
532 SymbolCandidateOutcome, SymbolQuery, SymbolResolutionOutcome,
533 canonicalize_graph_qualified_name, display_graph_qualified_name,
534 };
535
536 struct TestNode {
537 node_id: NodeId,
538 }
539
540 #[test]
541 fn test_resolve_symbol_exact_qualified_same_file() {
542 let mut graph = CodeGraph::new();
543 let file_path = abs_path("src/lib.rs");
544 let symbol = add_node(
545 &mut graph,
546 NodeKind::Function,
547 "target",
548 Some("pkg::target"),
549 &file_path,
550 Some(Language::Rust),
551 10,
552 2,
553 );
554
555 let snapshot = graph.snapshot();
556 let query = SymbolQuery {
557 symbol: "pkg::target",
558 file_scope: FileScope::Path(&file_path),
559 mode: ResolutionMode::Strict,
560 };
561
562 assert_eq!(
563 snapshot.resolve_symbol(&query),
564 SymbolResolutionOutcome::Resolved(symbol.node_id)
565 );
566 }
567
568 #[test]
569 fn test_resolve_symbol_exact_simple_same_file_wins() {
570 let mut graph = CodeGraph::new();
571 let requested_path = abs_path("src/requested.rs");
572 let other_path = abs_path("src/other.rs");
573
574 let requested = add_node(
575 &mut graph,
576 NodeKind::Function,
577 "target",
578 Some("requested::target"),
579 &requested_path,
580 Some(Language::Rust),
581 4,
582 0,
583 );
584 let _other = add_node(
585 &mut graph,
586 NodeKind::Function,
587 "target",
588 Some("other::target"),
589 &other_path,
590 Some(Language::Rust),
591 1,
592 0,
593 );
594
595 let snapshot = graph.snapshot();
596 let query = SymbolQuery {
597 symbol: "target",
598 file_scope: FileScope::Path(&requested_path),
599 mode: ResolutionMode::Strict,
600 };
601
602 assert_eq!(
603 snapshot.resolve_symbol(&query),
604 SymbolResolutionOutcome::Resolved(requested.node_id)
605 );
606 }
607
608 #[test]
609 fn test_resolve_symbol_returns_not_found_without_wrong_file_fallback() {
610 let mut graph = CodeGraph::new();
611 let requested_path = abs_path("src/requested.rs");
612 let other_path = abs_path("src/other.rs");
613
614 let _requested_index_anchor = add_node(
615 &mut graph,
616 NodeKind::Function,
617 "anchor",
618 Some("requested::anchor"),
619 &requested_path,
620 Some(Language::Rust),
621 1,
622 0,
623 );
624 let _other = add_node(
625 &mut graph,
626 NodeKind::Function,
627 "target",
628 Some("other::target"),
629 &other_path,
630 Some(Language::Rust),
631 3,
632 0,
633 );
634
635 let snapshot = graph.snapshot();
636 let query = SymbolQuery {
637 symbol: "target",
638 file_scope: FileScope::Path(&requested_path),
639 mode: ResolutionMode::Strict,
640 };
641
642 assert_eq!(
643 snapshot.resolve_symbol(&query),
644 SymbolResolutionOutcome::NotFound
645 );
646 }
647
648 #[test]
649 fn test_resolve_symbol_returns_file_not_indexed_for_valid_unindexed_path() {
650 let mut graph = CodeGraph::new();
651 let indexed_path = abs_path("src/indexed.rs");
652 let unindexed_path = abs_path("src/unindexed.rs");
653
654 add_node(
655 &mut graph,
656 NodeKind::Function,
657 "indexed",
658 Some("pkg::indexed"),
659 &indexed_path,
660 Some(Language::Rust),
661 1,
662 0,
663 );
664 graph
665 .files_mut()
666 .register_with_language(&unindexed_path, Some(Language::Rust))
667 .unwrap();
668
669 let snapshot = graph.snapshot();
670 let query = SymbolQuery {
671 symbol: "indexed",
672 file_scope: FileScope::Path(&unindexed_path),
673 mode: ResolutionMode::Strict,
674 };
675
676 assert_eq!(
677 snapshot.resolve_symbol(&query),
678 SymbolResolutionOutcome::FileNotIndexed
679 );
680 }
681
682 #[test]
683 fn test_resolve_symbol_returns_ambiguous_for_multi_match_bucket() {
684 let mut graph = CodeGraph::new();
685 let file_path = abs_path("src/lib.rs");
686
687 let first = add_node(
688 &mut graph,
689 NodeKind::Function,
690 "dup",
691 Some("pkg::dup"),
692 &file_path,
693 Some(Language::Rust),
694 2,
695 0,
696 );
697 let second = add_node(
698 &mut graph,
699 NodeKind::Method,
700 "dup",
701 Some("pkg::dup_method"),
702 &file_path,
703 Some(Language::Rust),
704 8,
705 0,
706 );
707
708 let snapshot = graph.snapshot();
709 let query = SymbolQuery {
710 symbol: "dup",
711 file_scope: FileScope::Path(&file_path),
712 mode: ResolutionMode::Strict,
713 };
714
715 assert_eq!(
716 snapshot.resolve_symbol(&query),
717 SymbolResolutionOutcome::Ambiguous(vec![first.node_id, second.node_id])
718 );
719 }
720
721 #[test]
722 fn test_find_symbol_candidates_uses_first_non_empty_bucket_only() {
723 let mut graph = CodeGraph::new();
724 let qualified_path = abs_path("src/qualified.rs");
725 let simple_path = abs_path("src/simple.rs");
726
727 let qualified = add_node(
728 &mut graph,
729 NodeKind::Function,
730 "target",
731 Some("pkg::target"),
732 &qualified_path,
733 Some(Language::Rust),
734 1,
735 0,
736 );
737 let simple_only = add_node(
738 &mut graph,
739 NodeKind::Function,
740 "pkg::target",
741 None,
742 &simple_path,
743 Some(Language::Rust),
744 1,
745 0,
746 );
747
748 let snapshot = graph.snapshot();
749 let query = SymbolQuery {
750 symbol: "pkg::target",
751 file_scope: FileScope::Any,
752 mode: ResolutionMode::AllowSuffixCandidates,
753 };
754
755 assert_eq!(
756 snapshot.find_symbol_candidates(&query),
757 SymbolCandidateOutcome::Candidates(vec![qualified.node_id])
758 );
759 assert_ne!(qualified.node_id, simple_only.node_id);
760 }
761
762 #[test]
763 fn test_find_symbol_candidates_preserves_file_not_indexed() {
764 let mut graph = CodeGraph::new();
765 let indexed_path = abs_path("src/indexed.rs");
766 let unindexed_path = abs_path("src/unindexed.rs");
767
768 add_node(
769 &mut graph,
770 NodeKind::Function,
771 "target",
772 Some("pkg::target"),
773 &indexed_path,
774 Some(Language::Rust),
775 1,
776 0,
777 );
778 let unindexed_file_id = graph
779 .files_mut()
780 .register_with_language(&unindexed_path, Some(Language::Rust))
781 .unwrap();
782
783 let snapshot = graph.snapshot();
784 let query = SymbolQuery {
785 symbol: "target",
786 file_scope: FileScope::FileId(unindexed_file_id),
787 mode: ResolutionMode::AllowSuffixCandidates,
788 };
789
790 assert_eq!(
791 snapshot.find_symbol_candidates(&query),
792 SymbolCandidateOutcome::FileNotIndexed
793 );
794 }
795
796 #[test]
797 fn test_suffix_candidates_disabled_in_strict_mode() {
798 let mut graph = CodeGraph::new();
799 let file_path = abs_path("src/lib.rs");
800
801 let suffix_match = add_node(
802 &mut graph,
803 NodeKind::Function,
804 "target",
805 Some("outer::pkg::target"),
806 &file_path,
807 Some(Language::Rust),
808 1,
809 0,
810 );
811
812 let snapshot = graph.snapshot();
813 let strict_query = SymbolQuery {
814 symbol: "pkg::target",
815 file_scope: FileScope::Any,
816 mode: ResolutionMode::Strict,
817 };
818 let suffix_query = SymbolQuery {
819 mode: ResolutionMode::AllowSuffixCandidates,
820 ..strict_query
821 };
822
823 assert_eq!(
824 snapshot.resolve_symbol(&strict_query),
825 SymbolResolutionOutcome::NotFound
826 );
827 assert_eq!(
828 snapshot.find_symbol_candidates(&suffix_query),
829 SymbolCandidateOutcome::Candidates(vec![suffix_match.node_id])
830 );
831 }
832
833 #[test]
834 fn test_suffix_candidates_require_canonical_qualified_query() {
835 let mut graph = CodeGraph::new();
836 let file_path = abs_path("src/mod.py");
837
838 add_node(
839 &mut graph,
840 NodeKind::Function,
841 "target",
842 Some("pkg::target"),
843 &file_path,
844 Some(Language::Python),
845 1,
846 0,
847 );
848
849 let snapshot = graph.snapshot();
850 let query = SymbolQuery {
851 symbol: "pkg.target",
852 file_scope: FileScope::Any,
853 mode: ResolutionMode::AllowSuffixCandidates,
854 };
855
856 assert_eq!(
857 snapshot.find_symbol_candidates(&query),
858 SymbolCandidateOutcome::NotFound
859 );
860 }
861
862 #[test]
863 fn test_suffix_candidates_filter_same_leaf_bucket_only() {
864 let mut graph = CodeGraph::new();
865 let file_path = abs_path("src/lib.rs");
866
867 let exact_suffix = add_node(
868 &mut graph,
869 NodeKind::Function,
870 "target",
871 Some("outer::pkg::target"),
872 &file_path,
873 Some(Language::Rust),
874 2,
875 0,
876 );
877 let another_suffix = add_node(
878 &mut graph,
879 NodeKind::Method,
880 "target",
881 Some("another::pkg::target"),
882 &file_path,
883 Some(Language::Rust),
884 4,
885 0,
886 );
887 let unrelated = add_node(
888 &mut graph,
889 NodeKind::Function,
890 "target",
891 Some("pkg::different::target"),
892 &file_path,
893 Some(Language::Rust),
894 6,
895 0,
896 );
897
898 let snapshot = graph.snapshot();
899 let query = SymbolQuery {
900 symbol: "pkg::target",
901 file_scope: FileScope::Any,
902 mode: ResolutionMode::AllowSuffixCandidates,
903 };
904
905 assert_eq!(
906 snapshot.find_symbol_candidates(&query),
907 SymbolCandidateOutcome::Candidates(vec![exact_suffix.node_id, another_suffix.node_id])
908 );
909 assert_ne!(unrelated.node_id, exact_suffix.node_id);
910 }
911
912 #[test]
913 fn test_normalize_symbol_query_rewrites_native_delimiter_when_file_scope_language_known() {
914 let mut graph = CodeGraph::new();
915 let file_path = abs_path("src/mod.py");
916 let file_id = graph
917 .files_mut()
918 .register_with_language(&file_path, Some(Language::Python))
919 .unwrap();
920 let snapshot = graph.snapshot();
921 let query = SymbolQuery {
922 symbol: "pkg.mod.fn",
923 file_scope: FileScope::Path(&file_path),
924 mode: ResolutionMode::Strict,
925 };
926
927 let normalized = snapshot.normalize_symbol_query(&query, &ResolvedFileScope::File(file_id));
928
929 assert_eq!(
930 normalized,
931 NormalizedSymbolQuery {
932 symbol: "pkg::mod::fn".to_string(),
933 file_scope: ResolvedFileScope::File(file_id),
934 mode: ResolutionMode::Strict,
935 }
936 );
937 }
938
939 #[test]
940 fn test_normalize_symbol_query_rewrites_native_delimiter_for_csharp() {
941 let mut graph = CodeGraph::new();
942 let file_path = abs_path("src/Program.cs");
943 let file_id = graph
944 .files_mut()
945 .register_with_language(&file_path, Some(Language::CSharp))
946 .unwrap();
947 let snapshot = graph.snapshot();
948 let query = SymbolQuery {
949 symbol: "System.Console.WriteLine",
950 file_scope: FileScope::Path(&file_path),
951 mode: ResolutionMode::Strict,
952 };
953
954 let normalized = snapshot.normalize_symbol_query(&query, &ResolvedFileScope::File(file_id));
955
956 assert_eq!(normalized.symbol, "System::Console::WriteLine".to_string());
957 }
958
959 #[test]
960 fn test_normalize_symbol_query_rewrites_native_delimiter_for_zig() {
961 let mut graph = CodeGraph::new();
962 let file_path = abs_path("src/main.zig");
963 let file_id = graph
964 .files_mut()
965 .register_with_language(&file_path, Some(Language::Zig))
966 .unwrap();
967 let snapshot = graph.snapshot();
968 let query = SymbolQuery {
969 symbol: "std.os.linux.exit",
970 file_scope: FileScope::Path(&file_path),
971 mode: ResolutionMode::Strict,
972 };
973
974 let normalized = snapshot.normalize_symbol_query(&query, &ResolvedFileScope::File(file_id));
975
976 assert_eq!(normalized.symbol, "std::os::linux::exit".to_string());
977 }
978
979 #[test]
980 fn test_normalize_symbol_query_does_not_rewrite_when_file_scope_any() {
981 let graph = CodeGraph::new();
982 let snapshot = graph.snapshot();
983 let query = SymbolQuery {
984 symbol: "pkg.mod.fn",
985 file_scope: FileScope::Any,
986 mode: ResolutionMode::Strict,
987 };
988
989 let normalized = snapshot.normalize_symbol_query(&query, &ResolvedFileScope::Any);
990
991 assert_eq!(
992 normalized,
993 NormalizedSymbolQuery {
994 symbol: "pkg.mod.fn".to_string(),
995 file_scope: ResolvedFileScope::Any,
996 mode: ResolutionMode::Strict,
997 }
998 );
999 }
1000
1001 #[test]
1002 fn test_global_qualified_query_with_native_delimiter_is_exact_only_and_not_found() {
1003 let mut graph = CodeGraph::new();
1004 let file_path = abs_path("src/mod.py");
1005
1006 add_node(
1007 &mut graph,
1008 NodeKind::Function,
1009 "fn",
1010 Some("pkg::mod::fn"),
1011 &file_path,
1012 Some(Language::Python),
1013 1,
1014 0,
1015 );
1016
1017 let snapshot = graph.snapshot();
1018 let query = SymbolQuery {
1019 symbol: "pkg.mod.fn",
1020 file_scope: FileScope::Any,
1021 mode: ResolutionMode::AllowSuffixCandidates,
1022 };
1023
1024 assert_eq!(
1025 snapshot.resolve_symbol(&query),
1026 SymbolResolutionOutcome::NotFound
1027 );
1028 }
1029
1030 #[test]
1031 fn test_global_canonical_qualified_query_can_hit_exact_qualified_bucket() {
1032 let mut graph = CodeGraph::new();
1033 let file_path = abs_path("src/lib.rs");
1034 let expected = add_node(
1035 &mut graph,
1036 NodeKind::Function,
1037 "fn",
1038 Some("pkg::mod::fn"),
1039 &file_path,
1040 Some(Language::Rust),
1041 1,
1042 0,
1043 );
1044
1045 let snapshot = graph.snapshot();
1046 let query = SymbolQuery {
1047 symbol: "pkg::mod::fn",
1048 file_scope: FileScope::Any,
1049 mode: ResolutionMode::Strict,
1050 };
1051
1052 assert_eq!(
1053 snapshot.resolve_symbol(&query),
1054 SymbolResolutionOutcome::Resolved(expected.node_id)
1055 );
1056 }
1057
1058 #[test]
1059 fn test_candidate_order_uses_metadata_then_node_id() {
1060 let mut graph = CodeGraph::new();
1061 let file_path = abs_path("src/lib.rs");
1062
1063 let first = add_node(
1064 &mut graph,
1065 NodeKind::Function,
1066 "dup",
1067 Some("pkg::dup_a"),
1068 &file_path,
1069 Some(Language::Rust),
1070 1,
1071 0,
1072 );
1073 let second = add_node(
1074 &mut graph,
1075 NodeKind::Function,
1076 "dup",
1077 Some("pkg::dup_b"),
1078 &file_path,
1079 Some(Language::Rust),
1080 1,
1081 0,
1082 );
1083
1084 let snapshot = graph.snapshot();
1085 let query = SymbolQuery {
1086 symbol: "dup",
1087 file_scope: FileScope::Any,
1088 mode: ResolutionMode::Strict,
1089 };
1090
1091 assert_eq!(
1092 snapshot.find_symbol_candidates(&query),
1093 SymbolCandidateOutcome::Candidates(vec![first.node_id, second.node_id])
1094 );
1095 }
1096
1097 #[test]
1098 fn test_candidate_order_kind_sort_key_uses_node_kind_as_str() {
1099 let mut graph = CodeGraph::new();
1100 let file_path = abs_path("src/lib.rs");
1101
1102 let function_node = add_node(
1103 &mut graph,
1104 NodeKind::Function,
1105 "shared",
1106 Some("pkg::shared_fn"),
1107 &file_path,
1108 Some(Language::Rust),
1109 1,
1110 0,
1111 );
1112 let variable_node = add_node(
1113 &mut graph,
1114 NodeKind::Variable,
1115 "shared",
1116 Some("pkg::shared_var"),
1117 &file_path,
1118 Some(Language::Rust),
1119 1,
1120 0,
1121 );
1122
1123 let snapshot = graph.snapshot();
1124 let query = SymbolQuery {
1125 symbol: "shared",
1126 file_scope: FileScope::Any,
1127 mode: ResolutionMode::Strict,
1128 };
1129
1130 assert_eq!(
1131 snapshot.find_symbol_candidates(&query),
1132 SymbolCandidateOutcome::Candidates(vec![function_node.node_id, variable_node.node_id])
1133 );
1134 }
1135
1136 fn add_node(
1137 graph: &mut CodeGraph,
1138 kind: NodeKind,
1139 name: &str,
1140 qualified_name: Option<&str>,
1141 file_path: &Path,
1142 language: Option<Language>,
1143 start_line: u32,
1144 start_column: u32,
1145 ) -> TestNode {
1146 let name_id = graph.strings_mut().intern(name).unwrap();
1147 let qualified_name_id =
1148 qualified_name.map(|value| graph.strings_mut().intern(value).unwrap());
1149 let file_id = graph
1150 .files_mut()
1151 .register_with_language(file_path, language)
1152 .unwrap();
1153
1154 let entry = NodeEntry::new(kind, name_id, file_id)
1155 .with_qualified_name_opt(qualified_name_id)
1156 .with_location(start_line, start_column, start_line, start_column + 1);
1157
1158 let node_id = graph.nodes_mut().alloc(entry).unwrap();
1159 graph
1160 .indices_mut()
1161 .add(node_id, kind, name_id, qualified_name_id, file_id);
1162
1163 TestNode { node_id }
1164 }
1165
1166 trait NodeEntryExt {
1167 fn with_qualified_name_opt(
1168 self,
1169 qualified_name: Option<crate::graph::unified::string::id::StringId>,
1170 ) -> Self;
1171 }
1172
1173 impl NodeEntryExt for NodeEntry {
1174 fn with_qualified_name_opt(
1175 mut self,
1176 qualified_name: Option<crate::graph::unified::string::id::StringId>,
1177 ) -> Self {
1178 self.qualified_name = qualified_name;
1179 self
1180 }
1181 }
1182
1183 fn abs_path(relative: &str) -> PathBuf {
1184 PathBuf::from("/resolver-tests").join(relative)
1185 }
1186
1187 #[test]
1188 fn test_display_graph_qualified_name_dot_language() {
1189 let display = display_graph_qualified_name(
1190 Language::CSharp,
1191 "MyApp::User::GetName",
1192 NodeKind::Method,
1193 false,
1194 );
1195 assert_eq!(display, "MyApp.User.GetName");
1196 }
1197
1198 #[test]
1199 fn test_canonicalize_graph_qualified_name_r_private_name_preserved() {
1200 assert_eq!(
1201 canonicalize_graph_qualified_name(Language::R, ".private_func"),
1202 ".private_func"
1203 );
1204 }
1205
1206 #[test]
1207 fn test_canonicalize_graph_qualified_name_r_s3_method_uses_last_dot() {
1208 assert_eq!(
1209 canonicalize_graph_qualified_name(Language::R, "as.data.frame.myclass"),
1210 "as.data.frame::myclass"
1211 );
1212 }
1213
1214 #[test]
1215 fn test_canonicalize_graph_qualified_name_r_leading_dot_s3_generic() {
1216 assert_eq!(
1217 canonicalize_graph_qualified_name(Language::R, ".DollarNames.myclass"),
1218 ".DollarNames::myclass"
1219 );
1220 }
1221
1222 #[test]
1223 fn test_display_graph_qualified_name_ruby_instance_method() {
1224 let display = display_graph_qualified_name(
1225 Language::Ruby,
1226 "Admin::Users::Controller::show",
1227 NodeKind::Method,
1228 false,
1229 );
1230 assert_eq!(display, "Admin::Users::Controller#show");
1231 }
1232
1233 #[test]
1234 fn test_display_graph_qualified_name_ruby_singleton_method() {
1235 let display = display_graph_qualified_name(
1236 Language::Ruby,
1237 "Admin::Users::Controller::show",
1238 NodeKind::Method,
1239 true,
1240 );
1241 assert_eq!(display, "Admin::Users::Controller.show");
1242 }
1243
1244 #[test]
1245 fn test_display_graph_qualified_name_ruby_member_variable() {
1246 let display = display_graph_qualified_name(
1247 Language::Ruby,
1248 "Admin::Users::Controller::username",
1249 NodeKind::Variable,
1250 false,
1251 );
1252 assert_eq!(display, "Admin::Users::Controller#username");
1253 }
1254
1255 #[test]
1256 fn test_display_graph_qualified_name_ruby_instance_variable() {
1257 let display = display_graph_qualified_name(
1258 Language::Ruby,
1259 "Admin::Users::Controller::@current_user",
1260 NodeKind::Variable,
1261 false,
1262 );
1263 assert_eq!(display, "Admin::Users::Controller#@current_user");
1264 }
1265
1266 #[test]
1267 fn test_display_graph_qualified_name_ruby_constant_stays_canonical() {
1268 let display = display_graph_qualified_name(
1269 Language::Ruby,
1270 "Admin::Users::Controller::DEFAULT_ROLE",
1271 NodeKind::Variable,
1272 false,
1273 );
1274 assert_eq!(display, "Admin::Users::Controller::DEFAULT_ROLE");
1275 }
1276
1277 #[test]
1278 fn test_display_graph_qualified_name_ruby_class_variable_stays_canonical() {
1279 let display = display_graph_qualified_name(
1280 Language::Ruby,
1281 "Admin::Users::Controller::@@count",
1282 NodeKind::Variable,
1283 false,
1284 );
1285 assert_eq!(display, "Admin::Users::Controller::@@count");
1286 }
1287
1288 #[test]
1289 fn test_display_graph_qualified_name_php_namespace_function() {
1290 let display = display_graph_qualified_name(
1291 Language::Php,
1292 "App::Services::send_mail",
1293 NodeKind::Function,
1294 false,
1295 );
1296 assert_eq!(display, "App\\Services\\send_mail");
1297 }
1298
1299 #[test]
1300 fn test_display_graph_qualified_name_php_method() {
1301 let display = display_graph_qualified_name(
1302 Language::Php,
1303 "App::Services::Mailer::deliver",
1304 NodeKind::Method,
1305 false,
1306 );
1307 assert_eq!(display, "App\\Services\\Mailer::deliver");
1308 }
1309
1310 #[test]
1311 fn test_display_graph_qualified_name_preserves_path_like_symbols() {
1312 let display = display_graph_qualified_name(
1313 Language::Go,
1314 "route::GET::/health",
1315 NodeKind::Endpoint,
1316 false,
1317 );
1318 assert_eq!(display, "route::GET::/health");
1319 }
1320
1321 #[test]
1322 fn test_display_graph_qualified_name_preserves_ffi_symbols() {
1323 let display = display_graph_qualified_name(
1324 Language::Haskell,
1325 "ffi::C::sin",
1326 NodeKind::Function,
1327 false,
1328 );
1329 assert_eq!(display, "ffi::C::sin");
1330 }
1331
1332 #[test]
1333 fn test_display_graph_qualified_name_preserves_native_cffi_symbols() {
1334 let display = display_graph_qualified_name(
1335 Language::Python,
1336 "native::cffi::calculate",
1337 NodeKind::Function,
1338 false,
1339 );
1340 assert_eq!(display, "native::cffi::calculate");
1341 }
1342
1343 #[test]
1344 fn test_display_graph_qualified_name_preserves_native_php_ffi_symbols() {
1345 let display = display_graph_qualified_name(
1346 Language::Php,
1347 "native::ffi::crypto_encrypt",
1348 NodeKind::Function,
1349 false,
1350 );
1351 assert_eq!(display, "native::ffi::crypto_encrypt");
1352 }
1353
1354 #[test]
1355 fn test_display_graph_qualified_name_preserves_native_panama_symbols() {
1356 let display = display_graph_qualified_name(
1357 Language::Java,
1358 "native::panama::nativeLinker",
1359 NodeKind::Function,
1360 false,
1361 );
1362 assert_eq!(display, "native::panama::nativeLinker");
1363 }
1364
1365 #[test]
1366 fn test_canonicalize_graph_qualified_name_preserves_wasm_symbols() {
1367 assert_eq!(
1368 canonicalize_graph_qualified_name(Language::TypeScript, "wasm::module.wasm"),
1369 "wasm::module.wasm"
1370 );
1371 }
1372
1373 #[test]
1374 fn test_canonicalize_graph_qualified_name_preserves_native_symbols() {
1375 assert_eq!(
1376 canonicalize_graph_qualified_name(Language::TypeScript, "native::binding.node"),
1377 "native::binding.node"
1378 );
1379 }
1380
1381 #[test]
1382 fn test_display_graph_qualified_name_preserves_wasm_symbols() {
1383 let display = display_graph_qualified_name(
1384 Language::TypeScript,
1385 "wasm::module.wasm",
1386 NodeKind::Module,
1387 false,
1388 );
1389 assert_eq!(display, "wasm::module.wasm");
1390 }
1391
1392 #[test]
1393 fn test_display_graph_qualified_name_preserves_native_symbols() {
1394 let display = display_graph_qualified_name(
1395 Language::TypeScript,
1396 "native::binding.node",
1397 NodeKind::Module,
1398 false,
1399 );
1400 assert_eq!(display, "native::binding.node");
1401 }
1402
1403 #[test]
1404 fn test_canonicalize_graph_qualified_name_still_normalizes_dot_language_symbols() {
1405 assert_eq!(
1406 canonicalize_graph_qualified_name(Language::TypeScript, "Foo.bar"),
1407 "Foo::bar"
1408 );
1409 }
1410}