1use crate::Parser;
65use crate::ast::{Node, NodeKind};
66use crate::document_store::{Document, DocumentStore};
67use crate::position::{Position, Range};
68use crate::workspace::monitoring::IndexInstrumentation;
69use parking_lot::RwLock;
70use perl_position_tracking::{WireLocation, WirePosition, WireRange};
71use perl_semantic_facts::{
72 AnchorFact, AnchorId, Confidence, EdgeFact, EntityFact, EntityId, EntityKind, FileId,
73 Provenance,
74};
75use serde::{Deserialize, Serialize};
76use std::collections::hash_map::DefaultHasher;
77use std::collections::{HashMap, HashSet};
78use std::hash::{Hash, Hasher};
79use std::path::Path;
80use std::sync::Arc;
81use std::time::Instant;
82use url::Url;
83
84use crate::semantic::facts::PRODUCER_SCHEMA_VERSION;
85use crate::semantic::imports::ImportExportIndex;
86pub use crate::semantic::invalidation::ShardReplaceResult;
87use crate::semantic::invalidation::{ShardCategoryHashes, plan_shard_replacement};
88use crate::semantic::references::ReferenceIndex;
89pub use crate::workspace::monitoring::{
90 DegradationReason, EarlyExitReason, EarlyExitRecord, IndexInstrumentationSnapshot,
91 IndexMetrics, IndexPerformanceCaps, IndexPhase, IndexPhaseTransition, IndexResourceLimits,
92 IndexStateKind, IndexStateTransition, ResourceKind,
93};
94use perl_symbol::surface::decl::extract_symbol_decls;
95use perl_symbol::surface::facts::{symbol_decls_to_semantic_facts, symbol_refs_to_semantic_facts};
96use perl_symbol::surface::r#ref::extract_symbol_refs;
97
98#[cfg(not(target_arch = "wasm32"))]
100pub use perl_uri::{fs_path_to_uri, uri_to_fs_path};
102pub use perl_uri::{is_file_uri, is_special_scheme, uri_extension, uri_key};
104
105#[derive(Clone, Debug)]
146pub enum IndexState {
147 Building {
149 phase: IndexPhase,
151 indexed_count: usize,
153 total_count: usize,
155 started_at: Instant,
157 },
158
159 Ready {
161 symbol_count: usize,
163 file_count: usize,
165 completed_at: Instant,
167 },
168
169 Degraded {
171 reason: DegradationReason,
173 available_symbols: usize,
175 since: Instant,
177 },
178}
179
180impl IndexState {
181 pub fn kind(&self) -> IndexStateKind {
183 match self {
184 IndexState::Building { .. } => IndexStateKind::Building,
185 IndexState::Ready { .. } => IndexStateKind::Ready,
186 IndexState::Degraded { .. } => IndexStateKind::Degraded,
187 }
188 }
189
190 pub fn phase(&self) -> Option<IndexPhase> {
192 match self {
193 IndexState::Building { phase, .. } => Some(*phase),
194 _ => None,
195 }
196 }
197
198 pub fn state_started_at(&self) -> Instant {
200 match self {
201 IndexState::Building { started_at, .. } => *started_at,
202 IndexState::Ready { completed_at, .. } => *completed_at,
203 IndexState::Degraded { since, .. } => *since,
204 }
205 }
206}
207
208pub struct IndexCoordinator {
260 state: Arc<RwLock<IndexState>>,
262
263 index: Arc<WorkspaceIndex>,
265
266 limits: IndexResourceLimits,
273
274 caps: IndexPerformanceCaps,
276
277 metrics: IndexMetrics,
279
280 instrumentation: IndexInstrumentation,
282}
283
284impl std::fmt::Debug for IndexCoordinator {
285 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
286 f.debug_struct("IndexCoordinator")
287 .field("state", &*self.state.read())
288 .field("limits", &self.limits)
289 .field("caps", &self.caps)
290 .finish_non_exhaustive()
291 }
292}
293
294impl IndexCoordinator {
295 pub fn new() -> Self {
312 Self {
313 state: Arc::new(RwLock::new(IndexState::Building {
314 phase: IndexPhase::Idle,
315 indexed_count: 0,
316 total_count: 0,
317 started_at: Instant::now(),
318 })),
319 index: Arc::new(WorkspaceIndex::new()),
320 limits: IndexResourceLimits::default(),
321 caps: IndexPerformanceCaps::default(),
322 metrics: IndexMetrics::new(),
323 instrumentation: IndexInstrumentation::new(),
324 }
325 }
326
327 pub fn with_limits(limits: IndexResourceLimits) -> Self {
346 Self {
347 state: Arc::new(RwLock::new(IndexState::Building {
348 phase: IndexPhase::Idle,
349 indexed_count: 0,
350 total_count: 0,
351 started_at: Instant::now(),
352 })),
353 index: Arc::new(WorkspaceIndex::new()),
354 limits,
355 caps: IndexPerformanceCaps::default(),
356 metrics: IndexMetrics::new(),
357 instrumentation: IndexInstrumentation::new(),
358 }
359 }
360
361 pub fn with_limits_and_caps(limits: IndexResourceLimits, caps: IndexPerformanceCaps) -> Self {
368 Self {
369 state: Arc::new(RwLock::new(IndexState::Building {
370 phase: IndexPhase::Idle,
371 indexed_count: 0,
372 total_count: 0,
373 started_at: Instant::now(),
374 })),
375 index: Arc::new(WorkspaceIndex::new()),
376 limits,
377 caps,
378 metrics: IndexMetrics::new(),
379 instrumentation: IndexInstrumentation::new(),
380 }
381 }
382
383 pub fn state(&self) -> IndexState {
408 self.state.read().clone()
409 }
410
411 pub fn index(&self) -> &Arc<WorkspaceIndex> {
429 &self.index
430 }
431
432 pub fn limits(&self) -> &IndexResourceLimits {
434 &self.limits
435 }
436
437 pub fn performance_caps(&self) -> &IndexPerformanceCaps {
439 &self.caps
440 }
441
442 pub fn instrumentation_snapshot(&self) -> IndexInstrumentationSnapshot {
444 self.instrumentation.snapshot()
445 }
446
447 pub fn notify_change(&self, _uri: &str) {
469 let pending = self.metrics.increment_pending_parses();
470
471 if self.metrics.is_parse_storm() {
473 self.transition_to_degraded(DegradationReason::ParseStorm { pending_parses: pending });
474 }
475 }
476
477 pub fn notify_parse_complete(&self, _uri: &str) {
499 let pending = self.metrics.decrement_pending_parses();
500
501 if pending == 0 {
503 if let IndexState::Degraded { reason: DegradationReason::ParseStorm { .. }, .. } =
504 self.state()
505 {
506 let mut state = self.state.write();
508 let from_kind = state.kind();
509 self.instrumentation.record_state_transition(from_kind, IndexStateKind::Building);
510 *state = IndexState::Building {
511 phase: IndexPhase::Idle,
512 indexed_count: 0,
513 total_count: 0,
514 started_at: Instant::now(),
515 };
516 }
517 }
518
519 self.enforce_limits();
521 }
522
523 pub fn transition_to_ready(&self, file_count: usize, symbol_count: usize) {
553 let mut state = self.state.write();
554 let from_kind = state.kind();
555
556 match &*state {
558 IndexState::Building { .. } | IndexState::Degraded { .. } => {
559 *state =
561 IndexState::Ready { symbol_count, file_count, completed_at: Instant::now() };
562 }
563 IndexState::Ready { .. } => {
564 *state =
566 IndexState::Ready { symbol_count, file_count, completed_at: Instant::now() };
567 }
568 }
569 self.instrumentation.record_state_transition(from_kind, IndexStateKind::Ready);
570 drop(state); self.enforce_limits();
574 }
575
576 pub fn transition_to_scanning(&self) {
580 let mut state = self.state.write();
581 let from_kind = state.kind();
582
583 match &*state {
584 IndexState::Building { phase, indexed_count, total_count, started_at } => {
585 if *phase != IndexPhase::Scanning {
586 self.instrumentation.record_phase_transition(*phase, IndexPhase::Scanning);
587 }
588 *state = IndexState::Building {
589 phase: IndexPhase::Scanning,
590 indexed_count: *indexed_count,
591 total_count: *total_count,
592 started_at: *started_at,
593 };
594 }
595 IndexState::Ready { .. } | IndexState::Degraded { .. } => {
596 self.instrumentation.record_state_transition(from_kind, IndexStateKind::Building);
597 self.instrumentation
598 .record_phase_transition(IndexPhase::Idle, IndexPhase::Scanning);
599 *state = IndexState::Building {
600 phase: IndexPhase::Scanning,
601 indexed_count: 0,
602 total_count: 0,
603 started_at: Instant::now(),
604 };
605 }
606 }
607 }
608
609 pub fn update_scan_progress(&self, total_count: usize) {
611 let mut state = self.state.write();
612 if let IndexState::Building { phase, indexed_count, started_at, .. } = &*state {
613 if *phase != IndexPhase::Scanning {
614 self.instrumentation.record_phase_transition(*phase, IndexPhase::Scanning);
615 }
616 *state = IndexState::Building {
617 phase: IndexPhase::Scanning,
618 indexed_count: *indexed_count,
619 total_count,
620 started_at: *started_at,
621 };
622 }
623 }
624
625 pub fn transition_to_indexing(&self, total_count: usize) {
629 let mut state = self.state.write();
630 let from_kind = state.kind();
631
632 match &*state {
633 IndexState::Building { phase, indexed_count, started_at, .. } => {
634 if *phase != IndexPhase::Indexing {
635 self.instrumentation.record_phase_transition(*phase, IndexPhase::Indexing);
636 }
637 *state = IndexState::Building {
638 phase: IndexPhase::Indexing,
639 indexed_count: *indexed_count,
640 total_count,
641 started_at: *started_at,
642 };
643 }
644 IndexState::Ready { .. } | IndexState::Degraded { .. } => {
645 self.instrumentation.record_state_transition(from_kind, IndexStateKind::Building);
646 self.instrumentation
647 .record_phase_transition(IndexPhase::Idle, IndexPhase::Indexing);
648 *state = IndexState::Building {
649 phase: IndexPhase::Indexing,
650 indexed_count: 0,
651 total_count,
652 started_at: Instant::now(),
653 };
654 }
655 }
656 }
657
658 pub fn transition_to_building(&self, total_count: usize) {
662 let mut state = self.state.write();
663 let from_kind = state.kind();
664
665 match &*state {
667 IndexState::Degraded { .. } | IndexState::Ready { .. } => {
668 self.instrumentation.record_state_transition(from_kind, IndexStateKind::Building);
669 self.instrumentation
670 .record_phase_transition(IndexPhase::Idle, IndexPhase::Indexing);
671 *state = IndexState::Building {
672 phase: IndexPhase::Indexing,
673 indexed_count: 0,
674 total_count,
675 started_at: Instant::now(),
676 };
677 }
678 IndexState::Building { phase, indexed_count, started_at, .. } => {
679 let mut next_phase = *phase;
680 if *phase == IndexPhase::Idle {
681 self.instrumentation
682 .record_phase_transition(IndexPhase::Idle, IndexPhase::Indexing);
683 next_phase = IndexPhase::Indexing;
684 }
685 *state = IndexState::Building {
686 phase: next_phase,
687 indexed_count: *indexed_count,
688 total_count,
689 started_at: *started_at,
690 };
691 }
692 }
693 }
694
695 pub fn update_building_progress(&self, indexed_count: usize) {
717 let mut state = self.state.write();
718
719 if let IndexState::Building { phase, started_at, total_count, .. } = &*state {
720 let elapsed = started_at.elapsed().as_millis() as u64;
721
722 if elapsed > self.limits.max_scan_duration_ms {
724 drop(state);
726 self.transition_to_degraded(DegradationReason::ScanTimeout { elapsed_ms: elapsed });
727 return;
728 }
729
730 *state = IndexState::Building {
732 phase: *phase,
733 indexed_count,
734 total_count: *total_count,
735 started_at: *started_at,
736 };
737 }
738 }
739
740 pub fn transition_to_degraded(&self, reason: DegradationReason) {
765 let mut state = self.state.write();
766 let from_kind = state.kind();
767
768 let available_symbols = match &*state {
770 IndexState::Ready { symbol_count, .. } => *symbol_count,
771 IndexState::Degraded { available_symbols, .. } => *available_symbols,
772 IndexState::Building { .. } => 0,
773 };
774
775 self.instrumentation.record_state_transition(from_kind, IndexStateKind::Degraded);
776 *state = IndexState::Degraded { reason, available_symbols, since: Instant::now() };
777 }
778
779 pub fn check_limits(&self) -> Option<DegradationReason> {
810 let files = self.index.files.read();
811
812 let file_count = files.len();
814 if file_count > self.limits.max_files {
815 return Some(DegradationReason::ResourceLimit { kind: ResourceKind::MaxFiles });
816 }
817
818 let total_symbols: usize = files.values().map(|fi| fi.symbols.len()).sum();
820 if total_symbols > self.limits.max_total_symbols {
821 return Some(DegradationReason::ResourceLimit { kind: ResourceKind::MaxSymbols });
822 }
823
824 None
825 }
826
827 pub fn enforce_limits(&self) {
853 if let Some(reason) = self.check_limits() {
854 self.transition_to_degraded(reason);
855 }
856 }
857
858 pub fn record_early_exit(
860 &self,
861 reason: EarlyExitReason,
862 elapsed_ms: u64,
863 indexed_files: usize,
864 total_files: usize,
865 ) {
866 self.instrumentation.record_early_exit(EarlyExitRecord {
867 reason,
868 elapsed_ms,
869 indexed_files,
870 total_files,
871 });
872 }
873
874 pub fn query<T, F1, F2>(&self, full_query: F1, partial_query: F2) -> T
907 where
908 F1: FnOnce(&WorkspaceIndex) -> T,
909 F2: FnOnce(&WorkspaceIndex) -> T,
910 {
911 match self.state() {
912 IndexState::Ready { .. } => full_query(&self.index),
913 _ => partial_query(&self.index),
914 }
915 }
916}
917
918impl Default for IndexCoordinator {
919 fn default() -> Self {
920 Self::new()
921 }
922}
923
924#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
929pub enum SymKind {
931 Var,
933 Sub,
935 Pack,
937}
938
939#[derive(Clone, Debug, Eq, PartialEq, Hash)]
940pub struct SymbolKey {
942 pub pkg: Arc<str>,
944 pub name: Arc<str>,
946 pub sigil: Option<char>,
948 pub kind: SymKind,
950}
951
952pub fn normalize_var(name: &str) -> (Option<char>, &str) {
973 if name.is_empty() {
974 return (None, "");
975 }
976
977 let Some(first_char) = name.chars().next() else {
979 return (None, name); };
981 match first_char {
982 '$' | '@' | '%' => {
983 if name.len() > 1 {
984 (Some(first_char), &name[1..])
985 } else {
986 (Some(first_char), "")
987 }
988 }
989 _ => (None, name),
990 }
991}
992
993#[derive(Debug, Clone, PartialEq, Eq)]
996pub struct Location {
998 pub uri: String,
1000 pub range: Range,
1002}
1003
1004#[derive(Debug, Clone, PartialEq, Eq)]
1005pub struct SymbolIdentity {
1007 pub stable_key: String,
1009 pub name: String,
1011 pub qualified_name: Option<String>,
1013 pub kind: SymbolKind,
1015}
1016
1017#[derive(Debug, Clone, PartialEq, Eq)]
1018pub struct CrossFileReferenceQueryResult {
1020 pub symbol: SymbolIdentity,
1022 pub definition: Location,
1024 pub references: Vec<Location>,
1026}
1027
1028#[derive(Debug, Clone, Serialize, Deserialize)]
1029pub struct WorkspaceSymbol {
1031 pub name: String,
1033 pub kind: SymbolKind,
1035 pub uri: String,
1037 pub range: Range,
1039 pub qualified_name: Option<String>,
1041 pub documentation: Option<String>,
1043 pub container_name: Option<String>,
1045 #[serde(default = "default_has_body")]
1047 pub has_body: bool,
1048 pub workspace_folder_uri: Option<String>,
1050}
1051
1052fn default_has_body() -> bool {
1053 true
1054}
1055
1056pub use perl_symbol::{SymbolKind, VarKind};
1059
1060#[derive(Debug, Clone)]
1061pub struct SymbolReference {
1063 pub uri: String,
1065 pub range: Range,
1067 pub kind: ReferenceKind,
1069}
1070
1071#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1072pub enum ReferenceKind {
1074 Definition,
1076 Usage,
1078 Import,
1080 Read,
1082 Write,
1084}
1085
1086#[derive(Debug, Serialize)]
1087#[serde(rename_all = "camelCase")]
1088pub struct LspWorkspaceSymbol {
1090 pub name: String,
1092 pub kind: u32,
1094 pub location: WireLocation,
1096 #[serde(skip_serializing_if = "Option::is_none")]
1098 pub container_name: Option<String>,
1099 #[serde(skip_serializing_if = "Option::is_none")]
1101 pub workspace_folder_uri: Option<String>,
1102}
1103
1104impl From<&WorkspaceSymbol> for LspWorkspaceSymbol {
1105 fn from(sym: &WorkspaceSymbol) -> Self {
1106 let range = WireRange {
1107 start: WirePosition { line: sym.range.start.line, character: sym.range.start.column },
1108 end: WirePosition { line: sym.range.end.line, character: sym.range.end.column },
1109 };
1110
1111 Self {
1112 name: sym.name.clone(),
1113 kind: sym.kind.to_lsp_kind(),
1114 location: WireLocation { uri: sym.uri.clone(), range },
1115 container_name: sym.container_name.clone(),
1116 workspace_folder_uri: sym.workspace_folder_uri.clone(),
1117 }
1118 }
1119}
1120
1121#[derive(Default, Clone)]
1123pub struct FileIndex {
1124 source_uri: String,
1126 symbols: Vec<WorkspaceSymbol>,
1128 references: HashMap<String, Vec<SymbolReference>>,
1130 dependencies: HashSet<String>,
1132 content_hash: u64,
1134 generation: u32,
1136 folder_uri: Option<String>,
1138}
1139
1140#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
1146pub struct FileFactShard {
1147 pub source_uri: String,
1149 pub file_id: FileId,
1151 pub content_hash: u64,
1153 pub producer_schema_version: u32,
1159 pub anchors_hash: Option<u64>,
1161 pub entities_hash: Option<u64>,
1163 pub occurrences_hash: Option<u64>,
1165 pub edges_hash: Option<u64>,
1167 pub anchors: Vec<AnchorFact>,
1169 pub entities: Vec<EntityFact>,
1171 pub occurrences: Vec<perl_semantic_facts::OccurrenceFact>,
1173 pub edges: Vec<EdgeFact>,
1175}
1176
1177pub struct WorkspaceIndex {
1179 files: Arc<RwLock<HashMap<String, FileIndex>>>,
1181 symbols: Arc<RwLock<HashMap<String, Vec<DefinitionCandidate>>>>,
1183 global_references: Arc<RwLock<HashMap<String, Vec<Location>>>>,
1188 fact_shards: Arc<RwLock<HashMap<String, FileFactShard>>>,
1190 semantic_reference_index: Arc<RwLock<ReferenceIndex>>,
1192 semantic_import_export_index: Arc<RwLock<ImportExportIndex>>,
1194 document_store: DocumentStore,
1196 workspace_folders: Arc<RwLock<Vec<String>>>,
1201}
1202
1203#[derive(Debug, Clone, Eq, PartialEq)]
1204struct DefinitionCandidate {
1205 location: Location,
1206 kind: SymbolKind,
1207}
1208
1209impl WorkspaceIndex {
1210 fn location_sort_key(location: &Location) -> (&str, u32, u32, u32, u32) {
1211 (
1212 location.uri.as_str(),
1213 location.range.start.line,
1214 location.range.start.column,
1215 location.range.end.line,
1216 location.range.end.column,
1217 )
1218 }
1219
1220 fn sort_locations_deterministically(locations: &mut [Location]) {
1221 locations.sort_by(|left, right| {
1222 Self::location_sort_key(left).cmp(&Self::location_sort_key(right))
1223 });
1224 }
1225
1226 fn definition_candidate_sort_key(
1227 candidate: &DefinitionCandidate,
1228 ) -> (u8, &str, u32, u32, u32, u32) {
1229 let rank = match candidate.kind {
1230 SymbolKind::Subroutine | SymbolKind::Method => 0,
1231 SymbolKind::Constant => 1,
1232 _ => 2,
1233 };
1234 (
1235 rank,
1236 candidate.location.uri.as_str(),
1237 candidate.location.range.start.line,
1238 candidate.location.range.start.column,
1239 candidate.location.range.end.line,
1240 candidate.location.range.end.column,
1241 )
1242 }
1243
1244 fn rebuild_symbol_cache(
1245 files: &HashMap<String, FileIndex>,
1246 symbols: &mut HashMap<String, Vec<DefinitionCandidate>>,
1247 ) {
1248 symbols.clear();
1249
1250 for file_index in files.values() {
1251 for symbol in &file_index.symbols {
1252 if let Some(ref qname) = symbol.qualified_name {
1253 symbols.entry(qname.clone()).or_default().push(DefinitionCandidate {
1254 location: Location { uri: symbol.uri.clone(), range: symbol.range },
1255 kind: symbol.kind,
1256 });
1257 }
1258 symbols.entry(symbol.name.clone()).or_default().push(DefinitionCandidate {
1259 location: Location { uri: symbol.uri.clone(), range: symbol.range },
1260 kind: symbol.kind,
1261 });
1262 }
1263 }
1264 for entries in symbols.values_mut() {
1265 entries.sort_by(|left, right| {
1266 Self::definition_candidate_sort_key(left)
1267 .cmp(&Self::definition_candidate_sort_key(right))
1268 });
1269 entries.dedup();
1270 }
1271 }
1272
1273 fn incremental_remove_symbols(
1276 files: &HashMap<String, FileIndex>,
1277 symbols: &mut HashMap<String, Vec<DefinitionCandidate>>,
1278 old_file_index: &FileIndex,
1279 ) {
1280 let mut affected_names: Vec<String> = Vec::new();
1281 for sym in &old_file_index.symbols {
1282 if let Some(ref qname) = sym.qualified_name {
1283 let mut remove_key = false;
1284 if let Some(entries) = symbols.get_mut(qname) {
1285 entries.retain(|candidate| candidate.location.uri != sym.uri);
1286 remove_key = entries.is_empty();
1287 }
1288 if remove_key {
1289 symbols.remove(qname);
1290 affected_names.push(qname.clone());
1291 }
1292 }
1293 let mut remove_key = false;
1294 if let Some(entries) = symbols.get_mut(&sym.name) {
1295 entries.retain(|candidate| candidate.location.uri != sym.uri);
1296 remove_key = entries.is_empty();
1297 }
1298 if remove_key {
1299 symbols.remove(&sym.name);
1300 affected_names.push(sym.name.clone());
1301 }
1302 }
1303 if !affected_names.is_empty() {
1304 symbols.clear();
1305 for file_index in files
1306 .values()
1307 .filter(|file_index| file_index.source_uri != old_file_index.source_uri)
1308 {
1309 for symbol in &file_index.symbols {
1310 if let Some(ref qname) = symbol.qualified_name {
1311 symbols.entry(qname.clone()).or_default().push(DefinitionCandidate {
1312 location: Location { uri: symbol.uri.clone(), range: symbol.range },
1313 kind: symbol.kind,
1314 });
1315 }
1316 symbols.entry(symbol.name.clone()).or_default().push(DefinitionCandidate {
1317 location: Location { uri: symbol.uri.clone(), range: symbol.range },
1318 kind: symbol.kind,
1319 });
1320 }
1321 }
1322 for entries in symbols.values_mut() {
1323 entries.sort_by(|left, right| {
1324 Self::definition_candidate_sort_key(left)
1325 .cmp(&Self::definition_candidate_sort_key(right))
1326 });
1327 entries.dedup();
1328 }
1329 }
1330 }
1331
1332 fn incremental_add_symbols(
1334 symbols: &mut HashMap<String, Vec<DefinitionCandidate>>,
1335 file_index: &FileIndex,
1336 ) {
1337 for sym in &file_index.symbols {
1338 if let Some(ref qname) = sym.qualified_name {
1339 symbols.entry(qname.clone()).or_default().push(DefinitionCandidate {
1340 location: Location { uri: sym.uri.clone(), range: sym.range },
1341 kind: sym.kind,
1342 });
1343 }
1344 symbols.entry(sym.name.clone()).or_default().push(DefinitionCandidate {
1345 location: Location { uri: sym.uri.clone(), range: sym.range },
1346 kind: sym.kind,
1347 });
1348 }
1349 for entries in symbols.values_mut() {
1350 entries.sort_by(|left, right| {
1351 Self::definition_candidate_sort_key(left)
1352 .cmp(&Self::definition_candidate_sort_key(right))
1353 });
1354 entries.dedup();
1355 }
1356 }
1357
1358 fn determine_folder_uri(&self, file_uri: &str) -> Option<String> {
1387 let folders = self.workspace_folders.read();
1388 let mut best_match: Option<&String> = None;
1389 for folder_uri in folders.iter() {
1390 let folder_with_slash = if folder_uri.ends_with('/') {
1393 folder_uri.clone()
1394 } else {
1395 format!("{}/", folder_uri)
1396 };
1397 if file_uri.starts_with(&folder_with_slash) || file_uri == folder_uri {
1398 match best_match {
1399 Some(existing) if existing.len() >= folder_uri.len() => {}
1400 _ => best_match = Some(folder_uri),
1401 }
1402 }
1403 }
1404 best_match.cloned()
1405 }
1406
1407 fn find_definition_in_files(
1408 files: &HashMap<String, FileIndex>,
1409 symbol_name: &str,
1410 uri_filter: Option<&str>,
1411 ) -> Option<(Location, String)> {
1412 let mut candidates: Vec<(Location, String)> = Vec::new();
1413 for file_index in files.values() {
1414 if let Some(filter) = uri_filter
1415 && file_index.symbols.first().is_some_and(|symbol| symbol.uri != filter)
1416 {
1417 continue;
1418 }
1419
1420 for symbol in &file_index.symbols {
1421 if symbol.name == symbol_name
1422 || symbol.qualified_name.as_deref() == Some(symbol_name)
1423 {
1424 candidates.push((
1425 Location { uri: symbol.uri.clone(), range: symbol.range },
1426 symbol.uri.clone(),
1427 ));
1428 }
1429 }
1430 }
1431
1432 candidates.sort_by(|left, right| {
1433 Self::location_sort_key(&left.0).cmp(&Self::location_sort_key(&right.0))
1434 });
1435 candidates.into_iter().next()
1436 }
1437
1438 fn find_symbol_by_definition(
1439 &self,
1440 definition: &Location,
1441 symbol_name: &str,
1442 ) -> Option<WorkspaceSymbol> {
1443 let files = self.files.read();
1444 files
1445 .values()
1446 .flat_map(|file_index| file_index.symbols.iter())
1447 .filter(|symbol| {
1448 symbol.uri == definition.uri
1449 && symbol.range == definition.range
1450 && (symbol.name == symbol_name
1451 || symbol.qualified_name.as_deref() == Some(symbol_name))
1452 })
1453 .min_by(|left, right| {
1454 (
1455 left.qualified_name.as_deref().unwrap_or_default(),
1456 left.name.as_str(),
1457 left.kind.to_lsp_kind(),
1458 )
1459 .cmp(&(
1460 right.qualified_name.as_deref().unwrap_or_default(),
1461 right.name.as_str(),
1462 right.kind.to_lsp_kind(),
1463 ))
1464 })
1465 .cloned()
1466 }
1467
1468 fn has_unique_symbol_name_and_kind(&self, target: &WorkspaceSymbol) -> bool {
1469 let files = self.files.read();
1470 files
1471 .values()
1472 .flat_map(|file_index| file_index.symbols.iter())
1473 .filter(|symbol| symbol.name == target.name && symbol.kind == target.kind)
1474 .take(2)
1475 .count()
1476 == 1
1477 }
1478
1479 fn collect_symbol_references(&self, symbol: &WorkspaceSymbol) -> Vec<Location> {
1480 let mut names_to_query: Vec<&str> = Vec::new();
1481 if let Some(qualified_name) = symbol.qualified_name.as_deref() {
1482 names_to_query.push(qualified_name);
1483 if self.has_unique_symbol_name_and_kind(symbol) {
1484 names_to_query.push(symbol.name.as_str());
1485 }
1486 } else {
1487 names_to_query.push(symbol.name.as_str());
1488 }
1489
1490 let global_refs = self.global_references.read();
1491 let mut seen: HashSet<(String, u32, u32, u32, u32)> = HashSet::new();
1492 let mut locations = Vec::new();
1493
1494 for symbol_name in names_to_query {
1495 if let Some(refs) = global_refs.get(symbol_name) {
1496 for location in refs {
1497 let key = (
1498 location.uri.clone(),
1499 location.range.start.line,
1500 location.range.start.column,
1501 location.range.end.line,
1502 location.range.end.column,
1503 );
1504 if seen.insert(key) {
1505 locations.push(location.clone());
1506 }
1507 }
1508 }
1509 }
1510 drop(global_refs);
1511
1512 Self::sort_locations_deterministically(&mut locations);
1513 locations
1514 }
1515
1516 pub fn new() -> Self {
1531 Self {
1532 files: Arc::new(RwLock::new(HashMap::new())),
1533 symbols: Arc::new(RwLock::new(HashMap::new())),
1534 global_references: Arc::new(RwLock::new(HashMap::new())),
1535 fact_shards: Arc::new(RwLock::new(HashMap::new())),
1536 semantic_reference_index: Arc::new(RwLock::new(ReferenceIndex::new())),
1537 semantic_import_export_index: Arc::new(RwLock::new(ImportExportIndex::new())),
1538 document_store: DocumentStore::new(),
1539 workspace_folders: Arc::new(RwLock::new(Vec::new())),
1540 }
1541 }
1542
1543 pub fn with_capacity(estimated_files: usize, avg_symbols_per_file: usize) -> Self {
1568 let sym_cap =
1570 estimated_files.saturating_mul(avg_symbols_per_file).saturating_mul(2).min(1_000_000);
1571 let ref_cap = (sym_cap / 4).min(1_000_000);
1572 Self {
1573 files: Arc::new(RwLock::new(HashMap::with_capacity(estimated_files))),
1574 symbols: Arc::new(RwLock::new(HashMap::with_capacity(sym_cap))),
1575 global_references: Arc::new(RwLock::new(HashMap::with_capacity(ref_cap))),
1576 fact_shards: Arc::new(RwLock::new(HashMap::with_capacity(estimated_files))),
1577 semantic_reference_index: Arc::new(RwLock::new(ReferenceIndex::new())),
1578 semantic_import_export_index: Arc::new(RwLock::new(ImportExportIndex::new())),
1579 document_store: DocumentStore::new(),
1580 workspace_folders: Arc::new(RwLock::new(Vec::new())),
1581 }
1582 }
1583
1584 pub fn set_workspace_folders(&self, folders: Vec<String>) {
1605 let mut workspace_folders = self.workspace_folders.write();
1606 *workspace_folders = folders;
1607 }
1608
1609 #[must_use]
1615 pub fn workspace_folders(&self) -> Vec<String> {
1616 self.workspace_folders.read().clone()
1617 }
1618
1619 #[must_use]
1621 pub fn indexed_generation(&self, uri: &str) -> Option<u32> {
1622 let uri_str = Self::normalize_uri(uri);
1623 let key = DocumentStore::uri_key(&uri_str);
1624 self.files.read().get(&key).map(|file_index| file_index.generation)
1625 }
1626
1627 #[must_use]
1629 pub fn is_index_generation_stale(&self, uri: &str, expected_generation: u32) -> bool {
1630 self.indexed_generation(uri)
1631 .is_some_and(|indexed_generation| indexed_generation < expected_generation)
1632 }
1633
1634 fn normalize_uri(uri: &str) -> String {
1636 perl_uri::normalize_uri(uri)
1637 }
1638
1639 fn remove_file_global_refs(
1644 global_refs: &mut HashMap<String, Vec<Location>>,
1645 file_index: &FileIndex,
1646 file_uri: &str,
1647 ) {
1648 for name in file_index.references.keys() {
1649 if let Some(locs) = global_refs.get_mut(name) {
1650 locs.retain(|loc| loc.uri != file_uri);
1651 if locs.is_empty() {
1652 global_refs.remove(name);
1653 }
1654 }
1655 }
1656 }
1657
1658 pub fn index_file(&self, uri: Url, text: String) -> Result<(), String> {
1689 self.index_file_with_generation(uri, text, 0)
1690 }
1691
1692 pub fn index_file_with_generation(
1694 &self,
1695 uri: Url,
1696 text: String,
1697 generation: u32,
1698 ) -> Result<(), String> {
1699 let uri_str = uri.to_string();
1700
1701 let mut hasher = DefaultHasher::new();
1703 text.hash(&mut hasher);
1704 let content_hash = hasher.finish();
1705
1706 let key = DocumentStore::uri_key(&uri_str);
1708 {
1709 let mut files = self.files.write();
1710 if let Some(existing_index) = files.get_mut(&key) {
1711 if existing_index.content_hash == content_hash {
1712 existing_index.generation = existing_index.generation.max(generation);
1713 return Ok(());
1715 }
1716 }
1717 }
1718
1719 if self.document_store.is_open(&uri_str) {
1721 self.document_store.update(&uri_str, 1, text.clone());
1722 } else {
1723 self.document_store.open(uri_str.clone(), 1, text.clone());
1724 }
1725
1726 let mut parser = Parser::new(&text);
1728 let ast = match parser.parse() {
1729 Ok(ast) => ast,
1730 Err(e) => return Err(format!("Parse error: {}", e)),
1731 };
1732
1733 let mut doc = self.document_store.get(&uri_str).ok_or("Document not found")?;
1735
1736 let folder_uri = self.determine_folder_uri(&uri_str);
1738
1739 let mut file_index = FileIndex {
1741 source_uri: uri_str.clone(),
1742 content_hash,
1743 generation,
1744 folder_uri: folder_uri.clone(),
1745 ..Default::default()
1746 };
1747 let mut visitor = IndexVisitor::new(&mut doc, uri_str.clone(), folder_uri);
1748 visitor.visit(&ast, &mut file_index);
1749
1750 let canonical_shard =
1751 Self::build_canonical_fact_shard_for_ast(&uri_str, content_hash, &ast);
1752 let fact_shard = if canonical_shard.anchors.is_empty()
1753 && canonical_shard.entities.is_empty()
1754 && canonical_shard.occurrences.is_empty()
1755 && canonical_shard.edges.is_empty()
1756 {
1757 Self::build_fact_shard(&uri_str, content_hash, &file_index)
1758 } else {
1759 canonical_shard
1760 };
1761
1762 let file_id = Self::hash_uri_to_file_id(&uri_str);
1771 let import_specs =
1772 crate::semantic::workspace_import_extractor::extract_import_specs(&ast, file_id);
1773 let use_lib_facts =
1774 crate::semantic::workspace_import_extractor::extract_use_lib_facts(&ast, file_id);
1775
1776 {
1779 let mut files = self.files.write();
1780
1781 if let Some(old_index) = files.get(&key) {
1783 let mut global_refs = self.global_references.write();
1784 Self::remove_file_global_refs(&mut global_refs, old_index, &uri_str);
1785 }
1786
1787 if let Some(old_index) = files.get(&key) {
1789 let mut symbols = self.symbols.write();
1790 Self::incremental_remove_symbols(&files, &mut symbols, old_index);
1791 drop(symbols);
1792 }
1793 files.insert(key.clone(), file_index);
1794 let mut symbols = self.symbols.write();
1795 if let Some(new_index) = files.get(&key) {
1796 Self::incremental_add_symbols(&mut symbols, new_index);
1797 }
1798
1799 if let Some(file_index) = files.get(&key) {
1800 let mut global_refs = self.global_references.write();
1801 for (name, refs) in &file_index.references {
1802 let entry = global_refs.entry(name.clone()).or_default();
1803 for reference in refs {
1804 entry.push(Location { uri: reference.uri.clone(), range: reference.range });
1805 }
1806 }
1807 }
1808 self.replace_fact_shard_incremental(&key, fact_shard);
1809 }
1810
1811 {
1817 let mut ie_idx = self.semantic_import_export_index.write();
1818 ie_idx.remove_file_imports(&uri_str);
1819 ie_idx.add_file_imports(&uri_str, file_id, import_specs);
1820 ie_idx.remove_file_use_lib(&uri_str);
1821 ie_idx.add_file_use_lib(&uri_str, file_id, use_lib_facts);
1822 }
1823
1824 Ok(())
1825 }
1826
1827 pub fn remove_file(&self, uri: &str) {
1846 let uri_str = Self::normalize_uri(uri);
1847 let key = DocumentStore::uri_key(&uri_str);
1848
1849 self.document_store.close(&uri_str);
1851
1852 let mut files = self.files.write();
1854 if let Some(file_index) = files.remove(&key) {
1855 self.fact_shards.write().remove(&key);
1856
1857 self.semantic_reference_index.write().remove_file(&uri_str);
1859 {
1860 let mut ie_idx = self.semantic_import_export_index.write();
1861 ie_idx.remove_file_imports(&uri_str);
1862 ie_idx.remove_module_exports(&uri_str);
1863 ie_idx.remove_file_use_lib(&uri_str);
1864 }
1865
1866 let mut symbols = self.symbols.write();
1868 Self::incremental_remove_symbols(&files, &mut symbols, &file_index);
1869
1870 let mut removed_uris = vec![uri_str.as_str()];
1881 for observed_uri in file_index.symbols.iter().map(|s| s.uri.as_str()).chain(
1882 file_index.references.values().flat_map(|refs| refs.iter().map(|r| r.uri.as_str())),
1883 ) {
1884 if !removed_uris.contains(&observed_uri) {
1885 removed_uris.push(observed_uri);
1886 }
1887 }
1888 symbols.retain(|_, candidates| {
1889 candidates.retain(|candidate| {
1890 let cand_uri = candidate.location.uri.as_str();
1891 !removed_uris.contains(&cand_uri)
1892 });
1893 !candidates.is_empty()
1894 });
1895
1896 let mut global_refs = self.global_references.write();
1903 Self::remove_file_global_refs(&mut global_refs, &file_index, &uri_str);
1904 global_refs.retain(|_, locs| {
1905 locs.retain(|loc| !removed_uris.contains(&loc.uri.as_str()));
1906 !locs.is_empty()
1907 });
1908 }
1909 }
1910
1911 pub fn remove_file_url(&self, uri: &Url) {
1935 self.remove_file(uri.as_str())
1936 }
1937
1938 pub fn clear_file(&self, uri: &str) {
1957 self.remove_file(uri);
1958 }
1959
1960 pub fn clear_file_url(&self, uri: &Url) {
1984 self.clear_file(uri.as_str())
1985 }
1986
1987 pub fn remove_folder(&self, folder_uri: &str) {
2007 let mut uris_to_remove = Vec::new();
2008 let files = self.files.read();
2009
2010 for file_index in files.values() {
2012 if file_index.folder_uri.as_deref() == Some(folder_uri) {
2013 uris_to_remove.push(file_index.source_uri.clone());
2014 }
2015 }
2016 drop(files);
2017
2018 for uri in uris_to_remove {
2021 self.remove_file(&uri);
2022 }
2023 }
2024
2025 #[cfg(not(target_arch = "wasm32"))]
2026 pub fn index_file_str(&self, uri: &str, text: &str) -> Result<(), String> {
2056 let path = Path::new(uri);
2057 let url = if path.is_absolute() {
2058 url::Url::from_file_path(path)
2059 .map_err(|_| format!("Invalid URI or file path: {}", uri))?
2060 } else {
2061 url::Url::parse(uri).or_else(|_| {
2064 url::Url::from_file_path(path)
2065 .map_err(|_| format!("Invalid URI or file path: {}", uri))
2066 })?
2067 };
2068 self.index_file(url, text.to_string())
2069 }
2070
2071 pub fn index_files_batch(&self, files_to_index: Vec<(Url, String)>) -> Vec<String> {
2080 let mut errors = Vec::new();
2081
2082 let mut parsed: Vec<(String, String, FileIndex)> = Vec::with_capacity(files_to_index.len());
2084 for (uri, text) in &files_to_index {
2085 let uri_str = uri.to_string();
2086
2087 let mut hasher = DefaultHasher::new();
2089 text.hash(&mut hasher);
2090 let content_hash = hasher.finish();
2091
2092 let key = DocumentStore::uri_key(&uri_str);
2093
2094 {
2096 let files = self.files.read();
2097 if let Some(existing) = files.get(&key) {
2098 if existing.content_hash == content_hash {
2099 continue;
2100 }
2101 }
2102 }
2103
2104 if self.document_store.is_open(&uri_str) {
2106 self.document_store.update(&uri_str, 1, text.clone());
2107 } else {
2108 self.document_store.open(uri_str.clone(), 1, text.clone());
2109 }
2110
2111 let mut parser = Parser::new(text);
2113 let ast = match parser.parse() {
2114 Ok(ast) => ast,
2115 Err(e) => {
2116 errors.push(format!("Parse error in {}: {}", uri_str, e));
2117 continue;
2118 }
2119 };
2120
2121 let mut doc = match self.document_store.get(&uri_str) {
2122 Some(d) => d,
2123 None => {
2124 errors.push(format!("Document not found: {}", uri_str));
2125 continue;
2126 }
2127 };
2128
2129 let folder_uri = self.determine_folder_uri(&uri_str);
2131
2132 let mut file_index = FileIndex {
2133 source_uri: uri_str.clone(),
2134 content_hash,
2135 folder_uri: folder_uri.clone(),
2136 ..Default::default()
2137 };
2138 let mut visitor = IndexVisitor::new(&mut doc, uri_str.clone(), folder_uri);
2139 visitor.visit(&ast, &mut file_index);
2140
2141 parsed.push((key, uri_str, file_index));
2142 }
2143
2144 {
2146 let mut files = self.files.write();
2147 let mut symbols = self.symbols.write();
2148 let mut global_refs = self.global_references.write();
2149
2150 files.reserve(parsed.len());
2153 symbols.reserve(parsed.len().saturating_mul(20).saturating_mul(2));
2154
2155 for (key, uri_str, file_index) in parsed {
2156 if let Some(old_index) = files.get(&key) {
2158 Self::remove_file_global_refs(&mut global_refs, old_index, &uri_str);
2159 }
2160
2161 files.insert(key.clone(), file_index);
2162
2163 if let Some(fi) = files.get(&key) {
2165 for (name, refs) in &fi.references {
2166 let entry = global_refs.entry(name.clone()).or_default();
2167 for reference in refs {
2168 entry.push(Location {
2169 uri: reference.uri.clone(),
2170 range: reference.range,
2171 });
2172 }
2173 }
2174 }
2175 }
2176
2177 Self::rebuild_symbol_cache(&files, &mut symbols);
2179 }
2180
2181 errors
2182 }
2183
2184 pub fn find_references(&self, symbol_name: &str) -> Vec<Location> {
2212 let global_refs = self.global_references.read();
2213 let mut seen: HashSet<(String, u32, u32, u32, u32)> = HashSet::new();
2214 let mut locations = Vec::new();
2215
2216 if let Some(refs) = global_refs.get(symbol_name) {
2218 for loc in refs {
2219 let key = (
2220 loc.uri.clone(),
2221 loc.range.start.line,
2222 loc.range.start.column,
2223 loc.range.end.line,
2224 loc.range.end.column,
2225 );
2226 if seen.insert(key) {
2227 locations.push(Location { uri: loc.uri.clone(), range: loc.range });
2228 }
2229 }
2230 }
2231
2232 if let Some(idx) = symbol_name.rfind("::") {
2234 let bare_name = &symbol_name[idx + 2..];
2235 if let Some(refs) = global_refs.get(bare_name) {
2236 for loc in refs {
2237 let key = (
2238 loc.uri.clone(),
2239 loc.range.start.line,
2240 loc.range.start.column,
2241 loc.range.end.line,
2242 loc.range.end.column,
2243 );
2244 if seen.insert(key) {
2245 locations.push(Location { uri: loc.uri.clone(), range: loc.range });
2246 }
2247 }
2248 }
2249 } else {
2250 for (name, refs) in global_refs.iter() {
2253 if !Self::is_qualified_variant_of(name, symbol_name) {
2254 continue;
2255 }
2256
2257 for loc in refs {
2258 let key = (
2259 loc.uri.clone(),
2260 loc.range.start.line,
2261 loc.range.start.column,
2262 loc.range.end.line,
2263 loc.range.end.column,
2264 );
2265 if seen.insert(key) {
2266 locations.push(Location { uri: loc.uri.clone(), range: loc.range });
2267 }
2268 }
2269 }
2270 }
2271
2272 Self::sort_locations_deterministically(&mut locations);
2273 locations
2274 }
2275
2276 pub fn query_symbol_references(
2280 &self,
2281 symbol_name: &str,
2282 ) -> Option<CrossFileReferenceQueryResult> {
2283 let definition = self.find_definition(symbol_name)?;
2284 let symbol = self.find_symbol_by_definition(&definition, symbol_name)?;
2285
2286 let stable_key = symbol.qualified_name.clone().unwrap_or_else(|| {
2287 format!(
2288 "{}@{}:{}:{}",
2289 symbol.name, symbol.uri, symbol.range.start.line, symbol.range.start.column
2290 )
2291 });
2292 let mut references = self.collect_symbol_references(&symbol);
2293 if !references.iter().any(|location| location == &definition) {
2294 references.push(definition.clone());
2295 Self::sort_locations_deterministically(&mut references);
2296 }
2297
2298 Some(CrossFileReferenceQueryResult {
2299 symbol: SymbolIdentity {
2300 stable_key,
2301 name: symbol.name,
2302 qualified_name: symbol.qualified_name,
2303 kind: symbol.kind,
2304 },
2305 definition,
2306 references,
2307 })
2308 }
2309
2310 pub fn count_usages(&self, symbol_name: &str) -> usize {
2316 let files = self.files.read();
2317 let mut seen: HashSet<(String, u32, u32, u32, u32)> = HashSet::new();
2318
2319 for (_uri_key, file_index) in files.iter() {
2320 if let Some(refs) = file_index.references.get(symbol_name) {
2321 for r in refs.iter().filter(|r| r.kind != ReferenceKind::Definition) {
2322 seen.insert((
2323 r.uri.clone(),
2324 r.range.start.line,
2325 r.range.start.column,
2326 r.range.end.line,
2327 r.range.end.column,
2328 ));
2329 }
2330 }
2331
2332 if let Some(idx) = symbol_name.rfind("::") {
2333 let bare_name = &symbol_name[idx + 2..];
2334 if let Some(refs) = file_index.references.get(bare_name) {
2335 for r in refs.iter().filter(|r| r.kind != ReferenceKind::Definition) {
2336 seen.insert((
2337 r.uri.clone(),
2338 r.range.start.line,
2339 r.range.start.column,
2340 r.range.end.line,
2341 r.range.end.column,
2342 ));
2343 }
2344 }
2345 } else {
2346 for (name, refs) in &file_index.references {
2347 if !Self::is_qualified_variant_of(name, symbol_name) {
2348 continue;
2349 }
2350
2351 for r in refs.iter().filter(|r| r.kind != ReferenceKind::Definition) {
2352 seen.insert((
2353 r.uri.clone(),
2354 r.range.start.line,
2355 r.range.start.column,
2356 r.range.end.line,
2357 r.range.end.column,
2358 ));
2359 }
2360 }
2361 }
2362 }
2363
2364 seen.len()
2365 }
2366
2367 fn is_qualified_variant_of(candidate: &str, bare_symbol: &str) -> bool {
2368 candidate.rsplit_once("::").is_some_and(|(_, candidate_bare)| candidate_bare == bare_symbol)
2369 }
2370
2371 pub fn find_definition(&self, symbol_name: &str) -> Option<Location> {
2390 if let Some(location) = self.definition_candidates(symbol_name).into_iter().next() {
2391 return Some(location);
2392 }
2393
2394 let files = self.files.read();
2404 Self::find_definition_in_files(&files, symbol_name, None).map(|(location, _uri)| location)
2405 }
2406
2407 pub(crate) fn definition_candidates(&self, symbol_name: &str) -> Vec<Location> {
2408 let symbols = self.symbols.read();
2409 symbols
2410 .get(symbol_name)
2411 .map(|candidates| {
2412 candidates.iter().map(|candidate| candidate.location.clone()).collect()
2413 })
2414 .unwrap_or_default()
2415 }
2416
2417 pub fn all_symbols(&self) -> Vec<WorkspaceSymbol> {
2432 let files = self.files.read();
2433 let mut symbols = Vec::new();
2434
2435 for (_uri_key, file_index) in files.iter() {
2436 symbols.extend(file_index.symbols.clone());
2437 }
2438
2439 symbols
2440 }
2441
2442 pub fn clear(&self) {
2444 self.files.write().clear();
2445 self.symbols.write().clear();
2446 self.global_references.write().clear();
2447 self.fact_shards.write().clear();
2448 *self.semantic_reference_index.write() = ReferenceIndex::new();
2449 *self.semantic_import_export_index.write() = ImportExportIndex::new();
2450 }
2451
2452 fn hash_uri_to_file_id(uri: &str) -> FileId {
2453 let mut hasher = DefaultHasher::new();
2454 uri.hash(&mut hasher);
2455 FileId(hasher.finish())
2456 }
2457
2458 fn build_fact_shard(uri: &str, content_hash: u64, file_index: &FileIndex) -> FileFactShard {
2459 let file_id = Self::hash_uri_to_file_id(uri);
2460 let mut anchors = Vec::new();
2461 let mut entities = Vec::new();
2462 for (idx, symbol) in file_index.symbols.iter().enumerate() {
2463 let anchor_id = AnchorId((idx + 1) as u64);
2464 anchors.push(AnchorFact {
2465 id: anchor_id,
2466 file_id,
2467 span_start_byte: 0,
2471 span_end_byte: 0,
2472 scope_id: None,
2473 provenance: Provenance::SearchFallback,
2474 confidence: Confidence::Low,
2475 });
2476 entities.push(EntityFact {
2477 id: EntityId((idx + 1) as u64),
2478 kind: EntityKind::Unknown,
2479 canonical_name: symbol
2480 .qualified_name
2481 .clone()
2482 .unwrap_or_else(|| symbol.name.clone()),
2483 anchor_id: Some(anchor_id),
2484 scope_id: None,
2485 provenance: Provenance::SearchFallback,
2486 confidence: Confidence::Low,
2487 });
2488 }
2489 let anchors_hash = {
2492 let mut h = DefaultHasher::new();
2493 anchors.len().hash(&mut h);
2494 for a in &anchors {
2495 a.id.hash(&mut h);
2496 a.span_start_byte.hash(&mut h);
2497 a.span_end_byte.hash(&mut h);
2498 }
2499 h.finish()
2500 };
2501 let entities_hash = {
2502 let mut h = DefaultHasher::new();
2503 entities.len().hash(&mut h);
2504 for e in &entities {
2505 e.id.hash(&mut h);
2506 e.canonical_name.hash(&mut h);
2507 }
2508 h.finish()
2509 };
2510 FileFactShard {
2511 source_uri: uri.to_string(),
2512 file_id,
2513 content_hash,
2514 producer_schema_version: PRODUCER_SCHEMA_VERSION,
2515 anchors_hash: Some(anchors_hash),
2516 entities_hash: Some(entities_hash),
2517 occurrences_hash: Some(0),
2518 edges_hash: Some(0),
2519 anchors,
2520 entities,
2521 occurrences: Vec::new(),
2522 edges: Vec::new(),
2523 }
2524 }
2525
2526 fn build_canonical_fact_shard_for_ast(
2534 uri: &str,
2535 content_hash: u64,
2536 ast: &Node,
2537 ) -> FileFactShard {
2538 let file_id = Self::hash_uri_to_file_id(uri);
2539
2540 let decls = extract_symbol_decls(ast, None);
2542 let refs = extract_symbol_refs(ast);
2543
2544 let decl_facts = symbol_decls_to_semantic_facts(&decls, file_id);
2546
2547 let entity_ids_by_name: std::collections::BTreeMap<String, EntityId> =
2549 decl_facts.entities.iter().map(|e| (e.canonical_name.clone(), e.id)).collect();
2550 let ref_facts = symbol_refs_to_semantic_facts(&refs, file_id, &entity_ids_by_name);
2551
2552 let eval_sub_triples =
2556 crate::semantic::eval_sub_extractor::extract_eval_sub_boundaries(ast, file_id);
2557 let dynamic_boundaries: Vec<perl_semantic_facts::OccurrenceFact> =
2558 eval_sub_triples.iter().map(|(_, _, occ)| occ.clone()).collect();
2559 let generated_member_facts =
2560 crate::semantic::generated_member_extractor::extract_generated_member_facts(
2561 ast, file_id,
2562 );
2563
2564 let synthetic_entities_from_eval: Vec<perl_semantic_facts::EntityFact> =
2570 eval_sub_triples.iter().map(|(entity, _, _)| entity.clone()).collect();
2571 let synthetic_anchors_from_eval: Vec<perl_semantic_facts::AnchorFact> =
2572 eval_sub_triples.iter().map(|(_, anchor, _)| anchor.clone()).collect();
2573
2574 let synthetic_entities_from_generated: Vec<perl_semantic_facts::EntityFact> =
2576 generated_member_facts.iter().map(|f| f.entity.clone()).collect();
2577 let synthetic_anchors_from_generated: Vec<perl_semantic_facts::AnchorFact> =
2578 generated_member_facts.iter().map(|f| f.anchor.clone()).collect();
2579
2580 let mut all_synthetic_entities = synthetic_entities_from_eval;
2582 all_synthetic_entities.extend(synthetic_entities_from_generated);
2583 let mut all_synthetic_anchors = synthetic_anchors_from_eval;
2584 all_synthetic_anchors.extend(synthetic_anchors_from_generated);
2585
2586 crate::semantic::facts::build_canonical_fact_shard(
2592 uri,
2593 content_hash,
2594 &decl_facts,
2595 &ref_facts,
2596 &[],
2597 &dynamic_boundaries,
2598 &all_synthetic_entities,
2599 &all_synthetic_anchors,
2600 )
2601 }
2602
2603 pub fn replace_fact_shard_incremental(
2614 &self,
2615 key: &str,
2616 new_shard: FileFactShard,
2617 ) -> ShardReplaceResult {
2618 let mut shards = self.fact_shards.write();
2619 let old_shard = shards.get(key);
2620
2621 let replacement = plan_shard_replacement(
2622 old_shard.map(Self::shard_category_hashes),
2623 Self::shard_category_hashes(&new_shard),
2624 );
2625
2626 if replacement.content_unchanged {
2627 return replacement;
2628 }
2629
2630 let source_uri = new_shard.source_uri.clone();
2631
2632 if replacement.occurrences_updated || replacement.edges_updated {
2636 let mut ref_idx = self.semantic_reference_index.write();
2637 if old_shard.is_some() {
2638 ref_idx.remove_file(&source_uri);
2639 }
2640 ref_idx.add_file(&new_shard);
2641 }
2642
2643 if replacement.entities_updated {
2647 let mut ie_idx = self.semantic_import_export_index.write();
2648 ie_idx.remove_file_imports(&source_uri);
2649 ie_idx.remove_module_exports(&source_uri);
2650 }
2653
2654 shards.insert(key.to_string(), new_shard);
2656
2657 replacement
2658 }
2659
2660 fn shard_category_hashes(shard: &FileFactShard) -> ShardCategoryHashes {
2661 ShardCategoryHashes {
2662 content_hash: shard.content_hash,
2663 anchors_hash: shard.anchors_hash,
2664 entities_hash: shard.entities_hash,
2665 occurrences_hash: shard.occurrences_hash,
2666 edges_hash: shard.edges_hash,
2667 }
2668 }
2669
2670 pub fn fact_shard_count(&self) -> usize {
2672 self.fact_shards.read().len()
2673 }
2674
2675 pub fn file_fact_shard(&self, uri: &str) -> Option<FileFactShard> {
2677 let key = DocumentStore::uri_key(&Self::normalize_uri(uri));
2678 self.fact_shards.read().get(&key).cloned()
2679 }
2680
2681 pub fn semantic_anchor_wire_location(&self, anchor_id: AnchorId) -> Option<WireLocation> {
2688 let shards = self.fact_shards.read();
2689 let mut location = None;
2690
2691 for shard in shards.values() {
2692 for anchor in shard.anchors.iter().filter(|anchor| anchor.id == anchor_id) {
2693 if anchor.span_end_byte <= anchor.span_start_byte {
2694 return None;
2695 }
2696
2697 let doc = self.document_store.get(&shard.source_uri)?;
2698 let start = usize::try_from(anchor.span_start_byte).ok()?;
2699 let end = usize::try_from(anchor.span_end_byte).ok()?;
2700 let next_location = WireLocation::new(
2701 shard.source_uri.clone(),
2702 WireRange::from_byte_offsets(&doc.text, start, end),
2703 );
2704 if location.replace(next_location).is_some() {
2705 return None;
2706 }
2707 }
2708 }
2709
2710 location
2711 }
2712
2713 pub fn semantic_anchor_wire_location_for_file(
2720 &self,
2721 file_id: FileId,
2722 anchor_id: AnchorId,
2723 ) -> Option<WireLocation> {
2724 let shards = self.fact_shards.read();
2725 let shard = shards.values().find(|shard| shard.file_id == file_id)?;
2726 let anchor = shard
2727 .anchors
2728 .iter()
2729 .find(|anchor| anchor.id == anchor_id && anchor.file_id == file_id)?;
2730
2731 if anchor.span_end_byte <= anchor.span_start_byte {
2732 return None;
2733 }
2734
2735 let doc = self.document_store.get(&shard.source_uri)?;
2736 let start = usize::try_from(anchor.span_start_byte).ok()?;
2737 let end = usize::try_from(anchor.span_end_byte).ok()?;
2738 doc.text.get(start..end)?;
2739
2740 Some(WireLocation::new(
2741 shard.source_uri.clone(),
2742 WireRange::from_byte_offsets(&doc.text, start, end),
2743 ))
2744 }
2745
2746 pub fn file_id_for_uri(&self, uri: &str) -> Option<FileId> {
2750 let key = DocumentStore::uri_key(&Self::normalize_uri(uri));
2751 self.fact_shards.read().get(&key).map(|shard| shard.file_id)
2752 }
2753
2754 pub fn with_semantic_queries_for_uri<R>(
2765 &self,
2766 uri: &str,
2767 f: impl FnOnce(FileId, crate::semantic::queries::WorkspaceSemanticQueries<'_>) -> R,
2768 ) -> Option<R> {
2769 let key = DocumentStore::uri_key(&Self::normalize_uri(uri));
2770
2771 let shards_guard = self.fact_shards.read();
2775 let ref_guard = self.semantic_reference_index.read();
2776 let ie_guard = self.semantic_import_export_index.read();
2777
2778 let file_id = shards_guard.get(&key)?.file_id;
2780
2781 let queries = crate::semantic::queries::WorkspaceSemanticQueries::new(
2782 &ref_guard,
2783 &ie_guard,
2784 &shards_guard,
2785 );
2786
2787 Some(f(file_id, queries))
2788 }
2789
2790 pub fn file_count(&self) -> usize {
2792 let files = self.files.read();
2793 files.len()
2794 }
2795
2796 pub fn symbol_count(&self) -> usize {
2798 let files = self.files.read();
2799 files.values().map(|file_index| file_index.symbols.len()).sum()
2800 }
2801
2802 pub fn files_in_folder(&self, folder_uri: &str) -> Vec<FileIndex> {
2812 let files = self.files.read();
2813 files.values().filter(|f| f.folder_uri.as_deref() == Some(folder_uri)).cloned().collect()
2814 }
2815
2816 pub fn symbols_in_folder(&self, folder_uri: &str) -> Vec<WorkspaceSymbol> {
2826 let files = self.files.read();
2827 files
2828 .values()
2829 .filter(|f| f.folder_uri.as_deref() == Some(folder_uri))
2830 .flat_map(|f| f.symbols.iter().cloned())
2831 .collect()
2832 }
2833
2834 #[cfg(feature = "memory-profiling")]
2842 pub fn memory_snapshot(&self) -> crate::workspace::memory::MemorySnapshot {
2843 use std::mem::size_of;
2844
2845 let files_guard = self.files.read();
2846 let symbols_guard = self.symbols.read();
2847 let global_refs_guard = self.global_references.read();
2848
2849 let mut files_bytes: usize = 0;
2851 let mut total_symbol_count: usize = 0;
2852 for (uri_key, fi) in files_guard.iter() {
2853 files_bytes += uri_key.len();
2855 for sym in &fi.symbols {
2857 files_bytes += sym.name.len()
2858 + sym.uri.len()
2859 + sym.qualified_name.as_deref().map_or(0, str::len)
2860 + sym.documentation.as_deref().map_or(0, str::len)
2861 + sym.container_name.as_deref().map_or(0, str::len)
2862 + size_of::<WorkspaceSymbol>();
2864 }
2865 total_symbol_count += fi.symbols.len();
2866 for (ref_name, refs) in &fi.references {
2868 files_bytes += ref_name.len();
2869 for r in refs {
2870 files_bytes += r.uri.len() + size_of::<SymbolReference>();
2871 }
2872 }
2873 for dep in &fi.dependencies {
2875 files_bytes += dep.len();
2876 }
2877 files_bytes += size_of::<u64>();
2879 }
2880
2881 let mut symbols_bytes: usize = 0;
2883 for (qname, candidates) in symbols_guard.iter() {
2884 symbols_bytes += qname.len();
2885 for candidate in candidates {
2886 symbols_bytes += candidate.location.uri.len() + size_of::<Location>();
2887 }
2888 }
2889
2890 let mut global_refs_bytes: usize = 0;
2892 for (sym_name, locs) in global_refs_guard.iter() {
2893 global_refs_bytes += sym_name.len();
2894 for loc in locs {
2895 global_refs_bytes += loc.uri.len() + size_of::<Location>();
2896 }
2897 }
2898
2899 let document_store_bytes = self.document_store.total_text_bytes();
2901
2902 crate::workspace::memory::MemorySnapshot {
2903 file_count: files_guard.len(),
2904 symbol_count: total_symbol_count,
2905 files_bytes,
2906 symbols_bytes,
2907 global_refs_bytes,
2908 document_store_bytes,
2909 }
2910 }
2911
2912 pub fn has_symbols(&self) -> bool {
2931 let files = self.files.read();
2932 files.values().any(|file_index| !file_index.symbols.is_empty())
2933 }
2934
2935 pub fn search_symbols(&self, query: &str) -> Vec<WorkspaceSymbol> {
2954 self.search_source_symbols(query)
2955 }
2956
2957 pub fn search_source_symbols(&self, query: &str) -> Vec<WorkspaceSymbol> {
2963 let query = query.trim();
2964 let query_lower = query.to_lowercase();
2965 let files = self.files.read();
2966 let mut results = Vec::new();
2967 for file_index in files.values() {
2968 for symbol in &file_index.symbols {
2969 if symbol.name.to_lowercase().contains(&query_lower)
2970 || symbol
2971 .qualified_name
2972 .as_ref()
2973 .map(|qn| qn.to_lowercase().contains(&query_lower))
2974 .unwrap_or(false)
2975 {
2976 results.push(symbol.clone());
2977 }
2978 }
2979 }
2980 results
2981 }
2982
2983 pub fn search_generated_workspace_symbols(&self, query: &str) -> Vec<WorkspaceSymbol> {
2989 let query = query.trim();
2990 if query.is_empty() {
2991 return Vec::new();
2992 }
2993
2994 let query_lower = query.to_lowercase();
2995 let source_backed_qualified_names = self.source_backed_qualified_names();
2996 let shards = self.fact_shards.read();
2997 let mut results = Vec::new();
2998
2999 for shard in shards.values() {
3000 for entity in &shard.entities {
3001 if entity.kind != EntityKind::GeneratedMember {
3002 continue;
3003 }
3004 if !is_framework_generated_member_entity(entity) {
3005 continue;
3006 }
3007 if source_backed_qualified_names.contains(&entity.canonical_name) {
3008 continue;
3009 }
3010 let Some((container_name, bare_name)) =
3011 split_qualified_symbol_name(&entity.canonical_name)
3012 else {
3013 continue;
3014 };
3015 if !bare_name.to_lowercase().contains(&query_lower)
3016 && !entity.canonical_name.to_lowercase().contains(&query_lower)
3017 {
3018 continue;
3019 }
3020 let Some(anchor_id) = entity.anchor_id else {
3021 continue;
3022 };
3023 let Some(range) = self.generated_member_anchor_range(shard, anchor_id) else {
3024 continue;
3025 };
3026
3027 results.push(WorkspaceSymbol {
3028 name: format!("{bare_name} [generated/framework]"),
3029 kind: SymbolKind::Method,
3030 uri: shard.source_uri.clone(),
3031 range,
3032 qualified_name: Some(entity.canonical_name.clone()),
3033 documentation: Some(
3034 "Generated/framework member; virtual symbol anchored to source declaration"
3035 .to_string(),
3036 ),
3037 container_name: Some(format!("{container_name} [generated/framework]")),
3038 has_body: false,
3039 workspace_folder_uri: self.determine_folder_uri(&shard.source_uri),
3040 });
3041 }
3042 }
3043
3044 sort_workspace_symbols(&mut results);
3045 results
3046 }
3047
3048 fn source_backed_qualified_names(&self) -> HashSet<String> {
3049 let files = self.files.read();
3050 let mut qualified_names = HashSet::new();
3051 for file_index in files.values() {
3052 for symbol in &file_index.symbols {
3053 if let Some(name) = &symbol.qualified_name {
3054 qualified_names.insert(name.clone());
3055 continue;
3056 }
3057 if let Some(container) = &symbol.container_name {
3058 qualified_names.insert(format!("{container}::{}", symbol.name));
3059 }
3060 }
3061 }
3062 qualified_names
3063 }
3064
3065 fn generated_member_anchor_range(
3066 &self,
3067 shard: &FileFactShard,
3068 anchor_id: AnchorId,
3069 ) -> Option<Range> {
3070 let anchor = shard
3071 .anchors
3072 .iter()
3073 .find(|anchor| anchor.id == anchor_id && anchor.file_id == shard.file_id)?;
3074 if anchor.provenance != Provenance::FrameworkSynthesis
3075 || anchor.confidence != Confidence::Medium
3076 {
3077 return None;
3078 }
3079 if anchor.span_end_byte <= anchor.span_start_byte {
3080 return None;
3081 }
3082
3083 let doc = self.document_store.get(&shard.source_uri)?;
3084 let start = usize::try_from(anchor.span_start_byte).ok()?;
3085 let end = usize::try_from(anchor.span_end_byte).ok()?;
3086 doc.text.get(start..end)?;
3087 let ((start_line, start_col), (end_line, end_col)) = doc.line_index.range(start, end);
3088 Some(Range {
3089 start: Position { byte: start, line: start_line, column: start_col },
3090 end: Position { byte: end, line: end_line, column: end_col },
3091 })
3092 }
3093
3094 pub fn find_symbols(&self, query: &str) -> Vec<WorkspaceSymbol> {
3113 self.search_symbols(query)
3114 }
3115
3116 pub fn rank_symbols_by_folder(
3139 &self,
3140 symbols: Vec<WorkspaceSymbol>,
3141 doc_uri: &str,
3142 ) -> Vec<WorkspaceSymbol> {
3143 let doc_folder = self.determine_folder_uri(doc_uri);
3144
3145 let mut ranked: Vec<(WorkspaceSymbol, i32)> = symbols
3146 .into_iter()
3147 .map(|symbol| {
3148 let rank = if let Some(ref doc_folder_uri) = doc_folder {
3149 if symbol.workspace_folder_uri.as_ref() == Some(doc_folder_uri) {
3150 0 } else {
3152 1 }
3154 } else {
3155 1 };
3157 (symbol, rank)
3158 })
3159 .collect();
3160
3161 ranked.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.name.cmp(&b.0.name)));
3163
3164 ranked.into_iter().map(|(symbol, _)| symbol).collect()
3165 }
3166
3167 pub fn search_symbols_ranked(&self, name: &str, doc_uri: &str) -> Vec<WorkspaceSymbol> {
3189 let symbols = self.search_symbols(name);
3190 self.rank_symbols_by_folder(symbols, doc_uri)
3191 }
3192
3193 #[allow(dead_code)]
3204 pub fn same_package(&self, symbol_a: &WorkspaceSymbol, symbol_b: &WorkspaceSymbol) -> bool {
3205 let package_a = self.extract_package_name(&symbol_a.name);
3206 let package_b = self.extract_package_name(&symbol_b.name);
3207 package_a == package_b
3208 }
3209
3210 #[allow(dead_code)]
3221 pub fn same_package_by_container(&self, package_a: &str, package_b: &str) -> bool {
3222 package_a == package_b
3223 }
3224
3225 #[allow(dead_code)]
3235 pub fn extract_package_name(&self, symbol_name: &str) -> Option<String> {
3236 let parts: Vec<&str> = symbol_name.split("::").collect();
3237 if parts.len() > 1 { Some(parts[..parts.len() - 1].join("::")) } else { None }
3238 }
3239
3240 pub fn file_symbols(&self, uri: &str) -> Vec<WorkspaceSymbol> {
3259 let normalized_uri = Self::normalize_uri(uri);
3260 let key = DocumentStore::uri_key(&normalized_uri);
3261 let files = self.files.read();
3262
3263 files.get(&key).map(|fi| fi.symbols.clone()).unwrap_or_default()
3264 }
3265
3266 pub fn file_dependencies(&self, uri: &str) -> HashSet<String> {
3285 let normalized_uri = Self::normalize_uri(uri);
3286 let key = DocumentStore::uri_key(&normalized_uri);
3287 let files = self.files.read();
3288
3289 files.get(&key).map(|fi| fi.dependencies.clone()).unwrap_or_default()
3290 }
3291
3292 pub fn find_dependents(&self, module_name: &str) -> Vec<String> {
3311 let canonical = canonicalize_perl_module_name(module_name);
3312 let legacy = legacy_perl_module_name(&canonical);
3313 let files = self.files.read();
3314 let mut dependents = Vec::new();
3315
3316 for (uri_key, file_index) in files.iter() {
3317 if file_index.dependencies.contains(module_name)
3318 || file_index.dependencies.contains(&canonical)
3319 || file_index.dependencies.contains(&legacy)
3320 {
3321 dependents.push(uri_key.clone());
3322 }
3323 }
3324
3325 dependents
3326 }
3327
3328 pub fn document_store(&self) -> &DocumentStore {
3343 &self.document_store
3344 }
3345
3346 pub fn find_unused_symbols(&self) -> Vec<WorkspaceSymbol> {
3361 let files = self.files.read();
3362 let mut unused = Vec::new();
3363
3364 for (_uri_key, file_index) in files.iter() {
3366 for symbol in &file_index.symbols {
3367 let has_usage = files.values().any(|fi| {
3369 if let Some(refs) = fi.references.get(&symbol.name) {
3370 refs.iter().any(|r| r.kind != ReferenceKind::Definition)
3371 } else {
3372 false
3373 }
3374 });
3375
3376 if !has_usage {
3377 unused.push(symbol.clone());
3378 }
3379 }
3380 }
3381
3382 unused
3383 }
3384
3385 pub fn get_package_members(&self, package_name: &str) -> Vec<WorkspaceSymbol> {
3404 let files = self.files.read();
3405 let mut members = Vec::new();
3406
3407 for (_uri_key, file_index) in files.iter() {
3408 for symbol in &file_index.symbols {
3409 if let Some(ref container) = symbol.container_name {
3411 if container == package_name {
3412 members.push(symbol.clone());
3413 }
3414 }
3415 if let Some(ref qname) = symbol.qualified_name {
3417 if qname.starts_with(&format!("{}::", package_name)) {
3418 if symbol.container_name.as_deref() != Some(package_name) {
3420 members.push(symbol.clone());
3421 }
3422 }
3423 }
3424 }
3425 }
3426
3427 members
3428 }
3429
3430 pub fn file_packages(&self, uri: &str) -> Vec<String> {
3446 let normalized = Self::normalize_uri(uri);
3447 let key = DocumentStore::uri_key(&normalized);
3448 let files = self.files.read();
3449 let Some(file) = files.get(&key) else {
3450 return Vec::new();
3451 };
3452
3453 let mut packages = Vec::new();
3454 for symbol in &file.symbols {
3455 if symbol.kind == SymbolKind::Package {
3456 packages.push(symbol.name.clone());
3457 }
3458 }
3459 packages
3460 }
3461
3462 pub fn file_package_symbols(&self, uri: &str, package_name: &str) -> Vec<WorkspaceSymbol> {
3478 let normalized = Self::normalize_uri(uri);
3479 let key = DocumentStore::uri_key(&normalized);
3480 let files = self.files.read();
3481 let Some(file) = files.get(&key) else {
3482 return Vec::new();
3483 };
3484
3485 let mut symbols = Vec::new();
3486 for symbol in &file.symbols {
3487 if Self::symbol_belongs_to_package(symbol, package_name) {
3488 symbols.push(symbol.clone());
3489 }
3490 }
3491 symbols
3492 }
3493
3494 fn symbol_belongs_to_package(symbol: &WorkspaceSymbol, package_name: &str) -> bool {
3495 symbol.container_name.as_ref().is_some_and(|container| package_name.eq(container.as_str()))
3496 }
3497
3498 pub fn find_def(&self, key: &SymbolKey) -> Option<Location> {
3519 if let Some(sigil) = key.sigil {
3520 let var_name = format!("{}{}", sigil, key.name);
3522 self.find_definition(&var_name)
3523 } else if key.kind == SymKind::Pack {
3524 self.find_definition(key.pkg.as_ref())
3527 .or_else(|| self.find_definition(key.name.as_ref()))
3528 } else {
3529 let qualified_name = format!("{}::{}", key.pkg, key.name);
3531 self.find_definition(&qualified_name)
3532 }
3533 }
3534
3535 pub fn find_refs(&self, key: &SymbolKey) -> Vec<Location> {
3558 let files_locked = self.files.read();
3559 let mut all_refs = if let Some(sigil) = key.sigil {
3560 let var_name = format!("{}{}", sigil, key.name);
3562 let mut refs = Vec::new();
3563 for (_uri_key, file_index) in files_locked.iter() {
3564 if let Some(var_refs) = file_index.references.get(&var_name) {
3565 for reference in var_refs {
3566 refs.push(Location { uri: reference.uri.clone(), range: reference.range });
3567 }
3568 }
3569 }
3570 refs
3571 } else {
3572 if key.pkg.as_ref() == "main" {
3574 let mut refs = self.find_references(&format!("main::{}", key.name));
3576 for (_uri_key, file_index) in files_locked.iter() {
3578 if let Some(bare_refs) = file_index.references.get(key.name.as_ref()) {
3579 for reference in bare_refs {
3580 refs.push(Location {
3581 uri: reference.uri.clone(),
3582 range: reference.range,
3583 });
3584 }
3585 }
3586 }
3587 refs
3588 } else {
3589 let qualified_name = format!("{}::{}", key.pkg, key.name);
3590 self.find_references(&qualified_name)
3591 }
3592 };
3593 drop(files_locked);
3594
3595 if let Some(def) = self.find_def(key) {
3597 all_refs.retain(|loc| !(loc.uri == def.uri && loc.range == def.range));
3598 }
3599
3600 let mut seen = HashSet::new();
3602 all_refs.retain(|loc| {
3603 seen.insert((
3604 loc.uri.clone(),
3605 loc.range.start.line,
3606 loc.range.start.column,
3607 loc.range.end.line,
3608 loc.range.end.column,
3609 ))
3610 });
3611
3612 all_refs
3613 }
3614}
3615
3616struct IndexVisitor {
3618 document: Document,
3619 uri: String,
3620 current_package: Option<String>,
3621 workspace_folder_uri: Option<String>,
3622}
3623
3624fn is_interpolated_var_start(byte: u8) -> bool {
3625 byte.is_ascii_alphabetic() || byte == b'_'
3626}
3627
3628fn is_interpolated_var_continue(byte: u8) -> bool {
3629 byte.is_ascii_alphanumeric() || byte == b'_' || byte == b':'
3630}
3631
3632fn has_escaped_interpolation_marker(bytes: &[u8], index: usize) -> bool {
3633 if index == 0 {
3634 return false;
3635 }
3636
3637 let mut backslashes = 0usize;
3638 let mut cursor = index;
3639 while cursor > 0 && bytes[cursor - 1] == b'\\' {
3640 backslashes += 1;
3641 cursor -= 1;
3642 }
3643
3644 backslashes % 2 == 1
3645}
3646
3647fn strip_matching_quote_delimiters(raw_content: &str) -> &str {
3648 if raw_content.len() < 2 {
3649 return raw_content;
3650 }
3651
3652 let bytes = raw_content.as_bytes();
3653 match (bytes.first(), bytes.last()) {
3654 (Some(b'"'), Some(b'"')) | (Some(b'\''), Some(b'\'')) => {
3655 &raw_content[1..raw_content.len() - 1]
3656 }
3657 _ => raw_content,
3658 }
3659}
3660
3661impl IndexVisitor {
3662 fn new(document: &mut Document, uri: String, workspace_folder_uri: Option<String>) -> Self {
3663 Self {
3664 document: document.clone(),
3665 uri,
3666 current_package: Some("main".to_string()),
3667 workspace_folder_uri,
3668 }
3669 }
3670
3671 fn visit(&mut self, node: &Node, file_index: &mut FileIndex) {
3672 self.project_symbol_declarations(node, file_index);
3673 self.visit_node(node, file_index);
3674 }
3675
3676 fn project_symbol_declarations(&self, node: &Node, file_index: &mut FileIndex) {
3677 for decl in extract_symbol_decls(node, self.current_package.as_deref()) {
3678 let (start, end) = match decl.kind {
3679 SymbolKind::Variable(_) => match decl.anchor_span {
3680 Some(span) => span,
3681 None => decl.full_span,
3682 },
3683 _ => decl.full_span,
3684 };
3685 let ((start_line, start_col), (end_line, end_col)) =
3686 self.document.line_index.range(start, end);
3687 let range = Range {
3688 start: Position { byte: start, line: start_line, column: start_col },
3689 end: Position { byte: end, line: end_line, column: end_col },
3690 };
3691
3692 let symbol_name = symbol_decl_name(&decl.kind, &decl.name);
3693
3694 let qualified_name = match &decl.declarator {
3699 Some(d) if d == "my" || d == "state" => None,
3700 _ => (!decl.qualified_name.is_empty()).then_some(decl.qualified_name),
3701 };
3702
3703 let container_name = match decl.kind {
3706 SymbolKind::Package => None,
3707 _ => decl.container,
3708 };
3709
3710 file_index.symbols.push(WorkspaceSymbol {
3711 name: symbol_name.clone(),
3712 kind: decl.kind,
3713 uri: self.uri.clone(),
3714 range,
3715 qualified_name,
3716 documentation: None,
3717 container_name,
3718 has_body: true,
3719 workspace_folder_uri: self.workspace_folder_uri.clone(),
3720 });
3721
3722 file_index.references.entry(symbol_name).or_default().push(SymbolReference {
3723 uri: self.uri.clone(),
3724 range,
3725 kind: ReferenceKind::Definition,
3726 });
3727 }
3728 }
3729
3730 fn record_interpolated_variable_references(
3731 &self,
3732 raw_content: &str,
3733 range: Range,
3734 file_index: &mut FileIndex,
3735 ) {
3736 let content = strip_matching_quote_delimiters(raw_content);
3737 let bytes = content.as_bytes();
3738 let mut index = 0;
3739
3740 while index < bytes.len() {
3741 if has_escaped_interpolation_marker(bytes, index) {
3742 index += 1;
3743 continue;
3744 }
3745
3746 let sigil = match bytes[index] {
3747 b'$' => "$",
3748 b'@' => "@",
3749 _ => {
3750 index += 1;
3751 continue;
3752 }
3753 };
3754
3755 if index + 1 >= bytes.len() {
3756 break;
3757 }
3758
3759 let (start, needs_closing_brace) =
3760 if bytes[index + 1] == b'{' { (index + 2, true) } else { (index + 1, false) };
3761
3762 if start >= bytes.len() || !is_interpolated_var_start(bytes[start]) {
3763 index += 1;
3764 continue;
3765 }
3766
3767 let mut end = start + 1;
3768 while end < bytes.len() && is_interpolated_var_continue(bytes[end]) {
3769 end += 1;
3770 }
3771
3772 if needs_closing_brace && (end >= bytes.len() || bytes[end] != b'}') {
3773 index += 1;
3774 continue;
3775 }
3776
3777 if let Some(name) = content.get(start..end) {
3778 let var_name = format!("{sigil}{name}");
3779 file_index.references.entry(var_name).or_default().push(SymbolReference {
3780 uri: self.uri.clone(),
3781 range,
3782 kind: ReferenceKind::Read,
3783 });
3784 }
3785
3786 index = if needs_closing_brace { end + 1 } else { end };
3787 }
3788 }
3789
3790 fn visit_node(&mut self, node: &Node, file_index: &mut FileIndex) {
3791 match &node.kind {
3792 NodeKind::Package { name, .. } => {
3793 let package_name = name.clone();
3794
3795 self.current_package = Some(package_name.clone());
3797 }
3798
3799 NodeKind::Subroutine { body, .. } => {
3800 self.visit_node(body, file_index);
3802 }
3803
3804 NodeKind::VariableDeclaration { initializer, .. } => {
3805 if let Some(init) = initializer {
3807 self.visit_node(init, file_index);
3808 }
3809 }
3810
3811 NodeKind::VariableListDeclaration { initializer, .. } => {
3812 if let Some(init) = initializer {
3814 self.visit_node(init, file_index);
3815 }
3816 }
3817
3818 NodeKind::Variable { sigil, name } => {
3819 let var_name = format!("{}{}", sigil, name);
3820
3821 file_index.references.entry(var_name).or_default().push(SymbolReference {
3823 uri: self.uri.clone(),
3824 range: self.node_to_range(node),
3825 kind: ReferenceKind::Read, });
3827 }
3828
3829 NodeKind::FunctionCall { name, args, .. } => {
3830 let func_name = name.clone();
3831 let location = self.node_to_range(node);
3832
3833 let (pkg, bare_name) = if let Some(idx) = func_name.rfind("::") {
3835 (&func_name[..idx], &func_name[idx + 2..])
3836 } else {
3837 (self.current_package.as_deref().unwrap_or("main"), func_name.as_str())
3838 };
3839
3840 let qualified = format!("{}::{}", pkg, bare_name);
3841
3842 file_index.references.entry(bare_name.to_string()).or_default().push(
3846 SymbolReference {
3847 uri: self.uri.clone(),
3848 range: location,
3849 kind: ReferenceKind::Usage,
3850 },
3851 );
3852 file_index.references.entry(qualified).or_default().push(SymbolReference {
3853 uri: self.uri.clone(),
3854 range: location,
3855 kind: ReferenceKind::Usage,
3856 });
3857
3858 if name == "extends" || name == "with" {
3859 for module_name in extract_module_names_from_call_args(args) {
3860 file_index
3861 .dependencies
3862 .insert(normalize_dependency_module_name(&module_name));
3863 }
3864 } else if name == "require" {
3865 if let Some(module_name) = extract_module_name_from_require_args(args) {
3866 file_index
3867 .dependencies
3868 .insert(normalize_dependency_module_name(&module_name));
3869 }
3870 }
3871
3872 for arg in args {
3874 self.visit_node(arg, file_index);
3875 }
3876 }
3877
3878 NodeKind::Use { module, args, .. } => {
3879 let module_name = normalize_dependency_module_name(module);
3880 file_index.dependencies.insert(module_name.clone());
3881
3882 if module == "parent" || module == "base" {
3886 for name in extract_module_names_from_use_args(args) {
3887 file_index.dependencies.insert(normalize_dependency_module_name(&name));
3888 }
3889 }
3890
3891 file_index.references.entry(module_name).or_default().push(SymbolReference {
3893 uri: self.uri.clone(),
3894 range: self.node_to_range(node),
3895 kind: ReferenceKind::Import,
3896 });
3897 }
3898
3899 NodeKind::Assignment { lhs, rhs, op } => {
3901 let is_compound = op != "=";
3903
3904 if let NodeKind::Variable { sigil, name } = &lhs.kind {
3905 let var_name = format!("{}{}", sigil, name);
3906
3907 if is_compound {
3909 file_index.references.entry(var_name.clone()).or_default().push(
3910 SymbolReference {
3911 uri: self.uri.clone(),
3912 range: self.node_to_range(lhs),
3913 kind: ReferenceKind::Read,
3914 },
3915 );
3916 }
3917
3918 file_index.references.entry(var_name).or_default().push(SymbolReference {
3920 uri: self.uri.clone(),
3921 range: self.node_to_range(lhs),
3922 kind: ReferenceKind::Write,
3923 });
3924 }
3925
3926 self.visit_node(rhs, file_index);
3928 }
3929
3930 NodeKind::Block { statements } => {
3932 for stmt in statements {
3933 self.visit_node(stmt, file_index);
3934 }
3935 }
3936
3937 NodeKind::If { condition, then_branch, elsif_branches, else_branch, .. } => {
3938 self.visit_node(condition, file_index);
3939 self.visit_node(then_branch, file_index);
3940 for (cond, branch) in elsif_branches {
3941 self.visit_node(cond, file_index);
3942 self.visit_node(branch, file_index);
3943 }
3944 if let Some(else_br) = else_branch {
3945 self.visit_node(else_br, file_index);
3946 }
3947 }
3948
3949 NodeKind::While { condition, body, continue_block, .. } => {
3950 self.visit_node(condition, file_index);
3951 self.visit_node(body, file_index);
3952 if let Some(cont) = continue_block {
3953 self.visit_node(cont, file_index);
3954 }
3955 }
3956
3957 NodeKind::For { init, condition, update, body, continue_block } => {
3958 if let Some(i) = init {
3959 self.visit_node(i, file_index);
3960 }
3961 if let Some(c) = condition {
3962 self.visit_node(c, file_index);
3963 }
3964 if let Some(u) = update {
3965 self.visit_node(u, file_index);
3966 }
3967 self.visit_node(body, file_index);
3968 if let Some(cont) = continue_block {
3969 self.visit_node(cont, file_index);
3970 }
3971 }
3972
3973 NodeKind::Foreach { variable, list, body, continue_block } => {
3974 if let Some(cb) = continue_block {
3976 self.visit_node(cb, file_index);
3977 }
3978 if let NodeKind::Variable { sigil, name } = &variable.kind {
3979 let var_name = format!("{}{}", sigil, name);
3980 file_index.references.entry(var_name).or_default().push(SymbolReference {
3981 uri: self.uri.clone(),
3982 range: self.node_to_range(variable),
3983 kind: ReferenceKind::Write,
3984 });
3985 }
3986 self.visit_node(variable, file_index);
3987 self.visit_node(list, file_index);
3988 self.visit_node(body, file_index);
3989 }
3990
3991 NodeKind::MethodCall { object, method, args } => {
3992 let qualified_method = if let NodeKind::Identifier { name } = &object.kind {
3994 Some(format!("{}::{}", name, method))
3996 } else {
3997 None
3999 };
4000
4001 self.visit_node(object, file_index);
4003
4004 let location = self.node_to_range(node);
4011 if let Some(qualified_method) = qualified_method.as_ref() {
4012 file_index.references.entry(qualified_method.clone()).or_default().push(
4013 SymbolReference {
4014 uri: self.uri.clone(),
4015 range: location,
4016 kind: ReferenceKind::Usage,
4017 },
4018 );
4019 }
4020 file_index.references.entry(method.clone()).or_default().push(SymbolReference {
4021 uri: self.uri.clone(),
4022 range: location,
4023 kind: ReferenceKind::Usage,
4024 });
4025
4026 if method == "import"
4027 && let NodeKind::Identifier { name: module_name } = &object.kind
4028 {
4029 for symbol in extract_manual_import_symbols(args) {
4030 file_index.references.entry(symbol).or_default().push(SymbolReference {
4031 uri: self.uri.clone(),
4032 range: self.node_to_range(node),
4033 kind: ReferenceKind::Import,
4034 });
4035 }
4036 file_index.dependencies.insert(normalize_dependency_module_name(module_name));
4037 }
4038
4039 for arg in args {
4041 self.visit_node(arg, file_index);
4042 }
4043 }
4044
4045 NodeKind::No { module, .. } => {
4046 let module_name = normalize_dependency_module_name(module);
4047 file_index.dependencies.insert(module_name);
4048 }
4049
4050 NodeKind::Class { name, .. } => {
4051 self.current_package = Some(name.clone());
4052 }
4053
4054 NodeKind::Method { body, signature, .. } => {
4055 if let Some(sig) = signature {
4057 if let NodeKind::Signature { parameters } = &sig.kind {
4058 for param in parameters {
4059 self.visit_node(param, file_index);
4060 }
4061 }
4062 }
4063
4064 self.visit_node(body, file_index);
4066 }
4067
4068 NodeKind::String { value, interpolated } => {
4069 if *interpolated {
4070 let range = self.node_to_range(node);
4071 self.record_interpolated_variable_references(value, range, file_index);
4072 }
4073 }
4074
4075 NodeKind::Heredoc { content, interpolated, .. } => {
4076 if *interpolated {
4077 let range = self.node_to_range(node);
4078 self.record_interpolated_variable_references(content, range, file_index);
4079 }
4080 }
4081
4082 NodeKind::Unary { op, operand } if op == "++" || op == "--" => {
4084 if let NodeKind::Variable { sigil, name } = &operand.kind {
4086 let var_name = format!("{}{}", sigil, name);
4087
4088 file_index.references.entry(var_name.clone()).or_default().push(
4090 SymbolReference {
4091 uri: self.uri.clone(),
4092 range: self.node_to_range(operand),
4093 kind: ReferenceKind::Read,
4094 },
4095 );
4096
4097 file_index.references.entry(var_name).or_default().push(SymbolReference {
4098 uri: self.uri.clone(),
4099 range: self.node_to_range(operand),
4100 kind: ReferenceKind::Write,
4101 });
4102 }
4103 }
4104
4105 _ => {
4106 self.visit_children(node, file_index);
4108 }
4109 }
4110 }
4111
4112 fn visit_children(&mut self, node: &Node, file_index: &mut FileIndex) {
4113 match &node.kind {
4115 NodeKind::Program { statements } => {
4116 for stmt in statements {
4117 self.visit_node(stmt, file_index);
4118 }
4119 }
4120 NodeKind::ExpressionStatement { expression } => {
4121 self.visit_node(expression, file_index);
4122 }
4123 NodeKind::Unary { operand, .. } => {
4125 self.visit_node(operand, file_index);
4126 }
4127 NodeKind::Binary { left, right, .. } => {
4128 self.visit_node(left, file_index);
4129 self.visit_node(right, file_index);
4130 }
4131 NodeKind::Ternary { condition, then_expr, else_expr } => {
4132 self.visit_node(condition, file_index);
4133 self.visit_node(then_expr, file_index);
4134 self.visit_node(else_expr, file_index);
4135 }
4136 NodeKind::ArrayLiteral { elements } => {
4137 for elem in elements {
4138 self.visit_node(elem, file_index);
4139 }
4140 }
4141 NodeKind::HashLiteral { pairs } => {
4142 for (key, value) in pairs {
4143 self.visit_node(key, file_index);
4144 self.visit_node(value, file_index);
4145 }
4146 }
4147 NodeKind::Return { value } => {
4148 if let Some(val) = value {
4149 self.visit_node(val, file_index);
4150 }
4151 }
4152 NodeKind::Eval { block } | NodeKind::Do { block } | NodeKind::Defer { block } => {
4153 self.visit_node(block, file_index);
4154 }
4155 NodeKind::Try { body, catch_blocks, finally_block } => {
4156 self.visit_node(body, file_index);
4157 for (_, block) in catch_blocks {
4158 self.visit_node(block, file_index);
4159 }
4160 if let Some(finally) = finally_block {
4161 self.visit_node(finally, file_index);
4162 }
4163 }
4164 NodeKind::Given { expr, body } => {
4165 self.visit_node(expr, file_index);
4166 self.visit_node(body, file_index);
4167 }
4168 NodeKind::When { condition, body } => {
4169 self.visit_node(condition, file_index);
4170 self.visit_node(body, file_index);
4171 }
4172 NodeKind::Default { body } => {
4173 self.visit_node(body, file_index);
4174 }
4175 NodeKind::StatementModifier { statement, condition, .. } => {
4176 self.visit_node(statement, file_index);
4177 self.visit_node(condition, file_index);
4178 }
4179 NodeKind::VariableWithAttributes { variable, .. } => {
4180 self.visit_node(variable, file_index);
4181 }
4182 NodeKind::LabeledStatement { statement, .. } => {
4183 self.visit_node(statement, file_index);
4184 }
4185 NodeKind::NestedVariableList { items } => {
4186 for item in items {
4188 self.visit_node(item, file_index);
4189 }
4190 }
4191 _ => {
4192 }
4194 }
4195 }
4196
4197 fn node_to_range(&mut self, node: &Node) -> Range {
4198 let ((start_line, start_col), (end_line, end_col)) =
4200 self.document.line_index.range(node.location.start, node.location.end);
4201 Range {
4203 start: Position { byte: node.location.start, line: start_line, column: start_col },
4204 end: Position { byte: node.location.end, line: end_line, column: end_col },
4205 }
4206 }
4207}
4208
4209fn symbol_decl_name(kind: &SymbolKind, name: &str) -> String {
4210 match kind {
4211 SymbolKind::Variable(VarKind::Scalar) => format!("${name}"),
4212 SymbolKind::Variable(VarKind::Array) => format!("@{name}"),
4213 SymbolKind::Variable(VarKind::Hash) => format!("%{name}"),
4214 _ => name.to_string(),
4215 }
4216}
4217
4218fn split_qualified_symbol_name(canonical_name: &str) -> Option<(&str, &str)> {
4219 let (container, bare_name) = canonical_name.rsplit_once("::")?;
4220 if container.is_empty() || bare_name.is_empty() {
4221 return None;
4222 }
4223 Some((container, bare_name))
4224}
4225
4226fn is_framework_generated_member_entity(entity: &EntityFact) -> bool {
4227 entity.provenance == Provenance::FrameworkSynthesis && entity.confidence == Confidence::Medium
4228}
4229
4230fn sort_workspace_symbols(symbols: &mut [WorkspaceSymbol]) {
4231 symbols.sort_by(|left, right| {
4232 left.name
4233 .cmp(&right.name)
4234 .then_with(|| left.uri.cmp(&right.uri))
4235 .then_with(|| left.range.start.line.cmp(&right.range.start.line))
4236 .then_with(|| left.range.start.column.cmp(&right.range.start.column))
4237 .then_with(|| left.range.end.line.cmp(&right.range.end.line))
4238 .then_with(|| left.range.end.column.cmp(&right.range.end.column))
4239 });
4240}
4241
4242fn extract_module_names_from_use_args(args: &[String]) -> Vec<String> {
4252 use std::collections::HashSet;
4253
4254 fn normalize_module_name(token: &str) -> Option<&str> {
4255 let stripped = token.trim_matches(|c: char| {
4256 matches!(c, '\'' | '"' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ';')
4257 });
4258
4259 if stripped.is_empty() || stripped.starts_with('-') {
4260 return None;
4261 }
4262
4263 stripped
4264 .chars()
4265 .all(|c| c.is_alphanumeric() || c == '_' || c == ':' || c == '\'')
4266 .then_some(stripped)
4267 }
4268
4269 let joined = args.join(" ");
4270
4271 let (qw_words, remainder) = extract_qw_words(&joined);
4272 let mut modules = Vec::new();
4273 let mut seen = HashSet::new();
4274 for word in qw_words {
4275 if let Some(candidate) = normalize_module_name(&word) {
4276 let canonical = canonicalize_perl_module_name(candidate);
4277 if seen.insert(canonical.clone()) {
4278 modules.push(canonical);
4279 }
4280 }
4281 }
4282
4283 for token in remainder.split_whitespace().flat_map(|t| t.split(',')) {
4284 if let Some(candidate) = normalize_module_name(token) {
4285 let canonical = canonicalize_perl_module_name(candidate);
4286 if seen.insert(canonical.clone()) {
4287 modules.push(canonical);
4288 }
4289 }
4290 }
4291
4292 modules
4293}
4294
4295fn extract_module_names_from_call_args(args: &[Node]) -> Vec<String> {
4296 fn collect_from_node(node: &Node, out: &mut Vec<String>) {
4297 match &node.kind {
4298 NodeKind::String { value, .. } => {
4299 out.extend(extract_module_names_from_use_args(std::slice::from_ref(value)));
4300 }
4301 NodeKind::Identifier { name } => {
4302 out.extend(extract_module_names_from_use_args(std::slice::from_ref(name)));
4303 }
4304 NodeKind::ArrayLiteral { elements } => {
4305 for element in elements {
4306 collect_from_node(element, out);
4307 }
4308 }
4309 NodeKind::FunctionCall { name, args, .. } if name == "qw" => {
4310 for arg in args {
4311 collect_from_node(arg, out);
4312 }
4313 }
4314 _ => {}
4315 }
4316 }
4317
4318 let mut modules = Vec::new();
4319 for arg in args {
4320 collect_from_node(arg, &mut modules);
4321 }
4322 modules
4323}
4324
4325fn canonicalize_perl_module_name(name: &str) -> String {
4326 name.replace('\'', "::")
4329}
4330
4331fn legacy_perl_module_name(name: &str) -> String {
4332 name.replace("::", "'")
4333}
4334
4335fn normalize_dependency_module_name(module_name: &str) -> String {
4338 canonicalize_perl_module_name(module_name)
4339}
4340
4341fn extract_qw_words(input: &str) -> (Vec<String>, String) {
4342 let chars: Vec<char> = input.chars().collect();
4343 let mut i = 0;
4344 let mut words = Vec::new();
4345 let mut remainder = String::new();
4346
4347 while i < chars.len() {
4348 if chars[i] == 'q'
4349 && i + 1 < chars.len()
4350 && chars[i + 1] == 'w'
4351 && (i == 0 || !chars[i - 1].is_alphanumeric())
4352 {
4353 let mut j = i + 2;
4354 while j < chars.len() && chars[j].is_whitespace() {
4355 j += 1;
4356 }
4357 if j >= chars.len() {
4358 remainder.push(chars[i]);
4359 i += 1;
4360 continue;
4361 }
4362
4363 let open = chars[j];
4364 let (close, is_paired_delimiter) = match open {
4365 '(' => (')', true),
4366 '[' => (']', true),
4367 '{' => ('}', true),
4368 '<' => ('>', true),
4369 _ => (open, false),
4370 };
4371 if open.is_alphanumeric() || open == '_' || open == '\'' || open == '"' {
4372 remainder.push(chars[i]);
4373 i += 1;
4374 continue;
4375 }
4376
4377 let mut k = j + 1;
4378 if is_paired_delimiter {
4379 let mut depth = 1usize;
4380 while k < chars.len() && depth > 0 {
4381 if chars[k] == open {
4382 depth += 1;
4383 } else if chars[k] == close {
4384 depth -= 1;
4385 }
4386 k += 1;
4387 }
4388 if depth != 0 {
4389 remainder.extend(chars[i..].iter());
4390 break;
4391 }
4392 k -= 1;
4393 } else {
4394 while k < chars.len() && chars[k] != close {
4395 k += 1;
4396 }
4397 if k >= chars.len() {
4398 remainder.extend(chars[i..].iter());
4399 break;
4400 }
4401 }
4402
4403 let content: String = chars[j + 1..k].iter().collect();
4404 for word in content.split_whitespace() {
4405 if !word.is_empty() {
4406 words.push(word.to_string());
4407 }
4408 }
4409 i = k + 1;
4410 continue;
4411 }
4412
4413 remainder.push(chars[i]);
4414 i += 1;
4415 }
4416
4417 (words, remainder)
4418}
4419
4420fn extract_module_name_from_require_args(args: &[Node]) -> Option<String> {
4421 let first = args.first()?;
4422 match &first.kind {
4423 NodeKind::Identifier { name } => Some(name.clone()),
4424 NodeKind::String { value, .. } => {
4425 let cleaned = value.trim_matches('\'').trim_matches('"').trim();
4426 if cleaned.is_empty() {
4427 return None;
4428 }
4429 Some(cleaned.trim_end_matches(".pm").replace('/', "::"))
4430 }
4431 _ => None,
4432 }
4433}
4434
4435fn extract_manual_import_symbols(args: &[Node]) -> Vec<String> {
4436 fn push_if_bareword(out: &mut Vec<String>, token: &str) {
4437 let bare = token.trim().trim_matches('"').trim_matches('\'').trim();
4438 if bare.is_empty() || bare == "," {
4439 return;
4440 }
4441 let is_bareword = bare.bytes().all(|ch| ch.is_ascii_alphanumeric() || ch == b'_')
4442 && bare.as_bytes().first().is_some_and(|ch| ch.is_ascii_alphabetic() || *ch == b'_');
4443 if is_bareword {
4444 out.push(bare.to_string());
4445 }
4446 }
4447
4448 let mut symbols = Vec::new();
4449 for arg in args {
4450 match &arg.kind {
4451 NodeKind::String { value, .. } => push_if_bareword(&mut symbols, value),
4452 NodeKind::Identifier { name } => {
4453 if name.starts_with("qw") {
4454 let content = name
4455 .trim_start_matches("qw")
4456 .trim_start_matches(|c: char| "([{/<|!".contains(c))
4457 .trim_end_matches(|c: char| ")]}/|!>".contains(c));
4458 for token in content.split_whitespace() {
4459 push_if_bareword(&mut symbols, token);
4460 }
4461 } else {
4462 push_if_bareword(&mut symbols, name);
4463 }
4464 }
4465 NodeKind::ArrayLiteral { elements } => {
4466 for element in elements {
4467 if let NodeKind::String { value, .. } = &element.kind {
4468 push_if_bareword(&mut symbols, value);
4469 }
4470 }
4471 }
4472 _ => {}
4473 }
4474 }
4475 symbols.sort();
4476 symbols.dedup();
4477 symbols
4478}
4479
4480#[cfg(test)]
4498fn extract_constant_names_from_use_args(args: &[String]) -> Vec<String> {
4499 use std::collections::HashSet;
4500
4501 fn push_unique(names: &mut Vec<String>, seen: &mut HashSet<String>, candidate: &str) {
4502 if seen.insert(candidate.to_string()) {
4503 names.push(candidate.to_string());
4504 }
4505 }
4506
4507 fn normalize_constant_name(token: &str) -> Option<&str> {
4508 let stripped = token.trim_matches(|c: char| {
4509 matches!(c, '\'' | '"' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ';')
4510 });
4511
4512 if stripped.is_empty() || stripped.starts_with('-') {
4513 return None;
4514 }
4515
4516 stripped.chars().all(|c| c.is_alphanumeric() || c == '_').then_some(stripped)
4517 }
4518
4519 let mut names = Vec::new();
4520 let mut seen = HashSet::new();
4521
4522 let first = match args.first() {
4526 Some(f) => f.as_str(),
4527 None => return names,
4528 };
4529
4530 if first.starts_with("qw") {
4532 let (qw_words, remainder) = extract_qw_words(first);
4533 if remainder.trim().is_empty() {
4534 for word in qw_words {
4535 if let Some(candidate) = normalize_constant_name(&word) {
4536 push_unique(&mut names, &mut seen, candidate);
4537 }
4538 }
4539 return names;
4540 }
4541
4542 let content = first.trim_start_matches("qw").trim_start();
4544 let content = content
4545 .trim_start_matches(|c: char| "([{/<|!".contains(c))
4546 .trim_end_matches(|c: char| ")]}/|!>".contains(c));
4547 for word in content.split_whitespace() {
4548 if let Some(candidate) = normalize_constant_name(word) {
4549 push_unique(&mut names, &mut seen, candidate);
4550 }
4551 }
4552 return names;
4553 }
4554
4555 let starts_hash_form = first == "{"
4557 || first == "+{"
4558 || (first == "+" && args.get(1).map(String::as_str) == Some("{"));
4559 if starts_hash_form {
4560 let mut skipped_leading_plus = false;
4561 let mut iter = args.iter().peekable();
4562 while let Some(arg) = iter.next() {
4563 if arg == "+{" {
4566 skipped_leading_plus = true;
4567 continue;
4568 }
4569 if arg == "+" && !skipped_leading_plus {
4570 skipped_leading_plus = true;
4571 continue;
4572 }
4573 if arg == "{" || arg == "}" || arg == "," || arg == "=>" {
4574 continue;
4575 }
4576 if let Some(candidate) = normalize_constant_name(arg)
4577 && iter.peek().map(|s| s.as_str()) == Some("=>")
4578 {
4579 push_unique(&mut names, &mut seen, candidate);
4580 }
4581 }
4582 return names;
4583 }
4584
4585 if let Some(candidate) = normalize_constant_name(first) {
4588 push_unique(&mut names, &mut seen, candidate);
4589 }
4590
4591 names
4592}
4593
4594impl Default for WorkspaceIndex {
4595 fn default() -> Self {
4596 Self::new()
4597 }
4598}
4599
4600#[cfg(all(feature = "workspace", feature = "lsp-compat"))]
4602pub mod lsp_adapter {
4604 use super::Location as IxLocation;
4605 use lsp_types::Location as LspLocation;
4606 type LspUrl = lsp_types::Uri;
4608
4609 pub fn to_lsp_location(ix: &IxLocation) -> Option<LspLocation> {
4629 parse_url(&ix.uri).map(|uri| {
4630 let start =
4631 lsp_types::Position { line: ix.range.start.line, character: ix.range.start.column };
4632 let end =
4633 lsp_types::Position { line: ix.range.end.line, character: ix.range.end.column };
4634 let range = lsp_types::Range { start, end };
4635 LspLocation { uri, range }
4636 })
4637 }
4638
4639 pub fn to_lsp_locations(all: impl IntoIterator<Item = IxLocation>) -> Vec<LspLocation> {
4660 all.into_iter().filter_map(|ix| to_lsp_location(&ix)).collect()
4661 }
4662
4663 #[cfg(not(target_arch = "wasm32"))]
4664 fn parse_url(s: &str) -> Option<LspUrl> {
4665 use std::str::FromStr;
4667
4668 LspUrl::from_str(s).ok().or_else(|| {
4670 std::path::Path::new(s).canonicalize().ok().and_then(|p| {
4672 crate::workspace_index::fs_path_to_uri(&p)
4674 .ok()
4675 .and_then(|uri_string| LspUrl::from_str(&uri_string).ok())
4676 })
4677 })
4678 }
4679
4680 #[cfg(target_arch = "wasm32")]
4682 fn parse_url(s: &str) -> Option<LspUrl> {
4683 use std::str::FromStr;
4684 LspUrl::from_str(s).ok()
4685 }
4686}
4687
4688#[cfg(test)]
4689mod tests {
4690 use super::*;
4691 use perl_tdd_support::{must, must_some};
4692
4693 #[test]
4694 fn test_use_constant_indexed_as_constant_symbol() {
4695 let index = WorkspaceIndex::new();
4696 let uri = "file:///lib/My/Config.pm";
4697 let code = r#"package My::Config;
4698use constant PI => 3.14159;
4699use constant {
4700 MAX_RETRIES => 3,
4701 TIMEOUT => 30,
4702};
47031;
4704"#;
4705 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4706
4707 let symbols = index.file_symbols(uri);
4708 assert!(
4709 symbols.iter().any(|s| s.name == "PI" && s.kind == SymbolKind::Constant),
4710 "PI should be indexed as a Constant symbol; got: {:?}",
4711 symbols.iter().map(|s| (&s.name, &s.kind)).collect::<Vec<_>>()
4712 );
4713 assert!(
4714 symbols.iter().any(|s| s.name == "MAX_RETRIES" && s.kind == SymbolKind::Constant),
4715 "MAX_RETRIES should be indexed"
4716 );
4717 assert!(
4718 symbols.iter().any(|s| s.name == "TIMEOUT" && s.kind == SymbolKind::Constant),
4719 "TIMEOUT should be indexed"
4720 );
4721
4722 let def = index.find_definition("My::Config::PI");
4724 assert!(def.is_some(), "find_definition('My::Config::PI') should succeed");
4725 }
4726
4727 #[test]
4728 fn test_extract_constant_names_deduplicates_qw_form() {
4729 let names = extract_constant_names_from_use_args(&["qw(FOO BAR FOO)".to_string()]);
4730 assert_eq!(names, vec!["FOO", "BAR"]);
4731 }
4732
4733 #[test]
4734 fn test_extract_constant_names_accepts_quoted_scalar_form() {
4735 let names = extract_constant_names_from_use_args(&[
4736 "'HTTP_OK'".to_string(),
4737 "=>".to_string(),
4738 "200".to_string(),
4739 ]);
4740 assert_eq!(names, vec!["HTTP_OK"]);
4741 }
4742
4743 #[test]
4744 fn search_symbols_returns_labeled_generated_framework_members()
4745 -> Result<(), Box<dyn std::error::Error>> {
4746 let index = WorkspaceIndex::new();
4747 let uri = "file:///lib/Generated/Pilot.pm";
4748 let code = r#"package Generated::Pilot;
4749use Moo;
4750has display_name => (is => 'rw');
47511;
4752"#;
4753 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4754
4755 let source_symbols = index.search_source_symbols("display_name");
4756 assert!(
4757 source_symbols.is_empty(),
4758 "generated framework members must not enter the exact source-symbol slice"
4759 );
4760 let trimmed_source_symbols = index.search_source_symbols(" display_name ");
4761 assert!(
4762 trimmed_source_symbols.is_empty(),
4763 "trimmed generated framework member queries must not enter the exact source-symbol slice"
4764 );
4765
4766 let generated_symbols = index.search_generated_workspace_symbols("display_name");
4767 assert_eq!(generated_symbols.len(), 1);
4768 let trimmed_generated_symbols =
4769 index.search_generated_workspace_symbols(" display_name ");
4770 assert_eq!(trimmed_generated_symbols.len(), 1);
4771 assert_eq!(trimmed_generated_symbols[0].name, "display_name [generated/framework]");
4772 assert!(index.search_generated_workspace_symbols(" ").is_empty());
4773 let symbol = &generated_symbols[0];
4774 assert_eq!(symbol.name, "display_name [generated/framework]");
4775 assert_eq!(symbol.kind, SymbolKind::Method);
4776 assert_eq!(symbol.qualified_name.as_deref(), Some("Generated::Pilot::display_name"));
4777 assert_eq!(
4778 symbol.container_name.as_deref(),
4779 Some("Generated::Pilot [generated/framework]")
4780 );
4781 assert!(!symbol.has_body);
4782 assert_eq!(symbol.uri, uri);
4783 assert!(
4784 symbol.range.end.byte > symbol.range.start.byte,
4785 "generated symbol must be anchored to the source framework declaration"
4786 );
4787
4788 let live_symbols = index.search_symbols("display_name");
4789 assert!(
4790 live_symbols.is_empty(),
4791 "general workspace index search must stay source-backed; generated pilot symbols are opt-in"
4792 );
4793
4794 {
4795 let mut shards = index.fact_shards.write();
4796 let shard = shards.values_mut().next().ok_or("missing generated-member shard")?;
4797 let entity = shard
4798 .entities
4799 .iter_mut()
4800 .find(|entity| entity.canonical_name == "Generated::Pilot::display_name")
4801 .ok_or("missing generated member entity")?;
4802 entity.provenance = Provenance::ExactAst;
4803 }
4804 let non_framework_symbols = index.search_generated_workspace_symbols("display_name");
4805 assert!(
4806 non_framework_symbols.is_empty(),
4807 "generated workspace-symbol pilot must require framework-synthesis provenance"
4808 );
4809 Ok(())
4810 }
4811
4812 #[test]
4813 fn search_symbols_returns_labeled_predicate_generated_members()
4814 -> Result<(), Box<dyn std::error::Error>> {
4815 let index = WorkspaceIndex::new();
4816 let uri = "file:///lib/Generated/PredicatePilot.pm";
4817 let code = r#"package Generated::PredicatePilot;
4818use Moo;
4819has status => (is => 'rw', predicate => 1);
48201;
4821"#;
4822 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4823
4824 let source_symbols = index.search_source_symbols("has_status");
4825 assert!(
4826 source_symbols.is_empty(),
4827 "predicate generated members must not enter the exact source-symbol slice"
4828 );
4829
4830 let generated_symbols = index.search_generated_workspace_symbols("has_status");
4831 assert_eq!(generated_symbols.len(), 1);
4832 let symbol = &generated_symbols[0];
4833 assert_eq!(symbol.name, "has_status [generated/framework]");
4834 assert_eq!(symbol.kind, SymbolKind::Method);
4835 assert_eq!(symbol.qualified_name.as_deref(), Some("Generated::PredicatePilot::has_status"));
4836 assert_eq!(
4837 symbol.container_name.as_deref(),
4838 Some("Generated::PredicatePilot [generated/framework]")
4839 );
4840 assert!(!symbol.has_body);
4841 assert_eq!(symbol.uri, uri);
4842 assert!(
4843 symbol.range.end.byte > symbol.range.start.byte,
4844 "predicate generated symbol must be anchored to the source framework declaration"
4845 );
4846
4847 let live_symbols = index.search_symbols("has_status");
4848 assert!(
4849 live_symbols.is_empty(),
4850 "general workspace index search must stay source-backed for predicate generated members"
4851 );
4852 Ok(())
4853 }
4854
4855 #[test]
4856 fn test_extract_constant_names_accepts_quoted_hash_form() {
4857 let names = extract_constant_names_from_use_args(&[
4858 "{".to_string(),
4859 "'FOO'".to_string(),
4860 "=>".to_string(),
4861 "1".to_string(),
4862 ",".to_string(),
4863 "\"BAR\"".to_string(),
4864 "=>".to_string(),
4865 "2".to_string(),
4866 "}".to_string(),
4867 ]);
4868 assert_eq!(names, vec!["FOO", "BAR"]);
4869 }
4870
4871 #[test]
4872 fn test_extract_constant_names_accepts_plus_hash_form_split_tokens() {
4873 let names = extract_constant_names_from_use_args(&[
4874 "+".to_string(),
4875 "{".to_string(),
4876 "FOO".to_string(),
4877 "=>".to_string(),
4878 "1".to_string(),
4879 ",".to_string(),
4880 "BAR".to_string(),
4881 "=>".to_string(),
4882 "2".to_string(),
4883 "}".to_string(),
4884 ]);
4885 assert_eq!(names, vec!["FOO", "BAR"]);
4886 }
4887
4888 #[test]
4889 fn test_extract_constant_names_accepts_plus_hash_form_combined_token() {
4890 let names = extract_constant_names_from_use_args(&[
4891 "+{".to_string(),
4892 "FOO".to_string(),
4893 "=>".to_string(),
4894 "1".to_string(),
4895 ",".to_string(),
4896 "BAR".to_string(),
4897 "=>".to_string(),
4898 "2".to_string(),
4899 "}".to_string(),
4900 ]);
4901 assert_eq!(names, vec!["FOO", "BAR"]);
4902 }
4903 #[test]
4904 fn test_use_constant_duplicate_names_indexed_once() {
4905 let index = WorkspaceIndex::new();
4906 let uri = "file:///lib/My/DedupConfig.pm";
4907 let code = r#"package My::DedupConfig;
4908use constant {
4909 RETRY_COUNT => 3,
4910 RETRY_COUNT => 5,
4911};
49121;
4913"#;
4914 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4915
4916 let symbols = index.file_symbols(uri);
4917 let retry_count_symbols = symbols.iter().filter(|s| s.name == "RETRY_COUNT").count();
4918 assert_eq!(
4919 retry_count_symbols, 1,
4920 "RETRY_COUNT should be indexed once even when repeated in use constant hash form"
4921 );
4922 }
4923
4924 #[test]
4925 fn test_use_constant_plus_hash_form_indexes_keys() {
4926 let index = WorkspaceIndex::new();
4927 let uri = "file:///lib/My/PlusHash.pm";
4928 let code = r#"package My::PlusHash;
4929use constant +{
4930 FOO => 1,
4931 BAR => 2,
4932};
49331;
4934"#;
4935 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4936
4937 assert!(index.find_definition("My::PlusHash::FOO").is_some());
4938 assert!(index.find_definition("My::PlusHash::BAR").is_some());
4939 }
4940
4941 #[test]
4942 fn test_basic_indexing() {
4943 let index = WorkspaceIndex::new();
4944 let uri = "file:///test.pl";
4945
4946 let code = r#"
4947package MyPackage;
4948
4949sub hello {
4950 print "Hello";
4951}
4952
4953my $var = 42;
4954"#;
4955
4956 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4957
4958 let symbols = index.file_symbols(uri);
4960 assert!(symbols.iter().any(|s| s.name == "MyPackage" && s.kind == SymbolKind::Package));
4961 assert!(symbols.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
4962 assert!(symbols.iter().any(|s| s.name == "$var" && s.kind.is_variable()));
4963 }
4964
4965 #[test]
4966 fn test_package_symbol_has_no_container_name() {
4967 let index = WorkspaceIndex::new();
4972 let uri = "file:///lib/Foo.pm";
4973 let code = "package Foo;\nsub bar { }\n";
4974 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4975
4976 let symbols = index.file_symbols(uri);
4977 let pkg_sym =
4978 must_some(symbols.iter().find(|s| s.name == "Foo" && s.kind == SymbolKind::Package));
4979 assert_eq!(
4980 pkg_sym.container_name, None,
4981 "Package symbol must not carry a container (was 'main')"
4982 );
4983 }
4984
4985 #[test]
4986 fn test_file_packages_returns_only_package_symbol_names() {
4987 let index = WorkspaceIndex::new();
4988 let uri = "file:///lib/OnlyPackages.pm";
4989 let code = "package Foo;\nsub hello { 1 }\npackage Bar { sub greet { 2 } }\n";
4990 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4991
4992 let mut package_names = index.file_packages(uri);
4993 package_names.sort();
4994 let mut expected_package_names: Vec<String> = index
4995 .file_symbols(uri)
4996 .into_iter()
4997 .filter(|s| s.kind == SymbolKind::Package)
4998 .map(|s| s.name)
4999 .collect();
5000 expected_package_names.sort();
5001
5002 assert_eq!(package_names, expected_package_names);
5003 assert_eq!(package_names, vec!["Bar", "Foo"]);
5004 assert!(!package_names.iter().any(|name| name == "hello"));
5005 assert!(!package_names.iter().any(|name| name == "greet"));
5006 }
5007
5008 #[test]
5009 fn test_file_package_symbols_returns_exact_container_match() {
5010 let index = WorkspaceIndex::new();
5011 let uri = "file:///lib/PackageMembers.pm";
5012 let code = "package Foo;\nsub hello { 1 }\npackage Bar;\nsub greet { 2 }\n";
5013 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
5014
5015 let all_symbols = index.file_symbols(uri);
5016 let package_name = "Bar";
5017 let greet_symbol = must_some(all_symbols.iter().find(|s| s.name == "greet"));
5018 let bar_package = must_some(
5019 all_symbols.iter().find(|s| s.name == "Bar" && s.kind == SymbolKind::Package),
5020 );
5021 assert!(WorkspaceIndex::symbol_belongs_to_package(greet_symbol, package_name));
5022 assert!(!WorkspaceIndex::symbol_belongs_to_package(greet_symbol, "Foo"));
5023 assert!(!WorkspaceIndex::symbol_belongs_to_package(bar_package, package_name));
5024
5025 let mut expected_bar_names: Vec<String> = all_symbols
5026 .iter()
5027 .filter(|s| s.container_name.as_deref() == Some(package_name))
5028 .map(|s| s.name.clone())
5029 .collect();
5030 expected_bar_names.sort();
5031
5032 let mut bar_names: Vec<String> =
5033 index.file_package_symbols(uri, package_name).into_iter().map(|s| s.name).collect();
5034 bar_names.sort();
5035 assert_eq!(bar_names, expected_bar_names);
5036 assert_eq!(bar_names, vec!["greet"]);
5037
5038 let mut foo_names: Vec<String> =
5039 index.file_package_symbols(uri, "Foo").into_iter().map(|s| s.name).collect();
5040 foo_names.sort();
5041 assert_eq!(foo_names, vec!["hello"]);
5042 assert!(index.file_package_symbols(uri, "Missing").is_empty());
5043 }
5044
5045 #[test]
5046 fn test_my_variable_has_no_qualified_name() {
5047 let index = WorkspaceIndex::new();
5052 let uri = "file:///lib/Foo.pm";
5053 let code = "package Foo;\nsub bar { my $x = 1; }\n";
5054 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
5055
5056 let symbols = index.file_symbols(uri);
5057 let var_sym = must_some(symbols.iter().find(|s| s.name == "$x" && s.kind.is_variable()));
5058 assert_eq!(var_sym.qualified_name, None, "my variable must not have a qualified_name");
5059
5060 assert!(
5062 index.find_definition("Foo::x").is_none(),
5063 "find_definition(\"Foo::x\") must not return a lexical my variable"
5064 );
5065 }
5066
5067 fn reference_kinds_for(
5068 index: &WorkspaceIndex,
5069 uri: &str,
5070 symbol_name: &str,
5071 ) -> Vec<ReferenceKind> {
5072 let files = index.files.read();
5073 let file = must_some(files.get(uri));
5074 file.references
5075 .get(symbol_name)
5076 .map(|refs| refs.iter().map(|r| r.kind).collect())
5077 .unwrap_or_default()
5078 }
5079
5080 #[test]
5081 fn test_reference_kinds_sub_definition_and_call_are_distinct() {
5082 let index = WorkspaceIndex::new();
5083 let uri = "file:///typed-refs-sub.pl";
5084 let code = "package TypedRefs;
5085sub foo { return 1; }
5086foo();
5087";
5088 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
5089
5090 let kinds = reference_kinds_for(&index, uri, "foo");
5091 assert!(kinds.contains(&ReferenceKind::Definition));
5092 assert!(kinds.contains(&ReferenceKind::Usage));
5093 }
5094
5095 #[test]
5096 fn test_reference_kinds_variable_read_and_write_are_distinct() {
5097 let index = WorkspaceIndex::new();
5098 let uri = "file:///typed-refs-var.pl";
5099 let code = "my $value = 1;
5100$value = 2;
5101print $value;
5102";
5103 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
5104
5105 let kinds = reference_kinds_for(&index, uri, "$value");
5106 assert!(kinds.contains(&ReferenceKind::Definition));
5107 assert!(kinds.contains(&ReferenceKind::Write));
5108 assert!(kinds.contains(&ReferenceKind::Read));
5109 }
5110
5111 #[test]
5112 fn test_reference_kinds_import_parent_and_export_ok_are_currently_import_only() {
5113 let index = WorkspaceIndex::new();
5114 let uri = "file:///typed-refs-import-export.pm";
5115 let code = "package Child;
5116use parent 'Base';
5117our @EXPORT_OK = qw(foo);
51181;
5119";
5120 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
5121
5122 let parent_kinds = reference_kinds_for(&index, uri, "Base");
5123 assert!(
5124 parent_kinds.is_empty(),
5125 "use parent inheritance edges are currently not stored as typed references"
5126 );
5127
5128 let export_symbol_kinds = reference_kinds_for(&index, uri, "foo");
5129 assert!(
5130 export_symbol_kinds.is_empty(),
5131 "EXPORT_OK entries are currently not represented as reference edges"
5132 );
5133 }
5134
5135 #[test]
5136 fn test_reference_kinds_dynamic_and_meta_edges_are_not_typed_yet() {
5137 let index = WorkspaceIndex::new();
5138 let uri = "file:///typed-refs-dynamic.pl";
5139 let code = r#"package TypedRefs;
5140sub foo { 1 }
5141&foo;
5142my $code = \&foo;
5143goto &foo;
5144*alias = \&foo;
5145eval "foo()";
5146with 'RoleName';
5147has 'name' => (is => 'ro');
51481;
5149"#;
5150 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
5151
5152 let foo_kinds = reference_kinds_for(&index, uri, "foo");
5153 assert!(
5154 foo_kinds
5155 .iter()
5156 .all(|kind| matches!(kind, ReferenceKind::Definition | ReferenceKind::Usage)),
5157 r"dynamic call forms (&foo, \&foo, goto &foo) are currently flattened to Usage"
5158 );
5159
5160 assert!(
5161 reference_kinds_for(&index, uri, "RoleName").is_empty(),
5162 "role composition edges (`with 'RoleName'`) are not indexed as typed references yet"
5163 );
5164 }
5165
5166 #[test]
5167 fn test_find_references() {
5168 let index = WorkspaceIndex::new();
5169 let uri = "file:///test.pl";
5170
5171 let code = r#"
5172sub test {
5173 my $x = 1;
5174 $x = 2;
5175 print $x;
5176}
5177"#;
5178
5179 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
5180
5181 let refs = index.find_references("$x");
5182 assert!(refs.len() >= 2); }
5184
5185 #[test]
5186 fn test_find_references_bare_name_includes_qualified_calls() {
5187 let index = WorkspaceIndex::new();
5188 let uri = "file:///refs.pl";
5189 let code = r#"
5190package RefDemo;
5191sub helper {
5192 return 1;
5193}
5194
5195helper();
5196RefDemo::helper();
5197"#;
5198
5199 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
5200
5201 let bare_refs = index.find_references("helper");
5202 let qualified_refs = index.find_references("RefDemo::helper");
5203
5204 assert!(
5205 bare_refs.len() >= qualified_refs.len(),
5206 "bare-name reference lookup should include qualified calls"
5207 );
5208 }
5209
5210 #[test]
5211 fn test_count_usages_bare_name_includes_qualified_calls() {
5212 let index = WorkspaceIndex::new();
5213 let uri = "file:///usage.pl";
5214 let code = r#"
5215package UsageDemo;
5216sub helper {
5217 return 1;
5218}
5219
5220helper();
5221UsageDemo::helper();
5222"#;
5223
5224 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
5225
5226 let bare_usage_count = index.count_usages("helper");
5227 let qualified_usage_count = index.count_usages("UsageDemo::helper");
5228
5229 assert!(
5230 bare_usage_count >= qualified_usage_count,
5231 "bare-name usage count should include qualified call sites"
5232 );
5233 }
5234
5235 #[test]
5236 fn test_dependencies() {
5237 let index = WorkspaceIndex::new();
5238 let uri = "file:///test.pl";
5239
5240 let code = r#"
5241use strict;
5242use warnings;
5243use Data::Dumper;
5244"#;
5245
5246 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
5247
5248 let deps = index.file_dependencies(uri);
5249 assert!(deps.contains("strict"));
5250 assert!(deps.contains("warnings"));
5251 assert!(deps.contains("Data::Dumper"));
5252 }
5253
5254 #[test]
5255 fn test_uri_to_fs_path_basic() {
5256 if let Some(path) = uri_to_fs_path("file:///tmp/test.pl") {
5258 assert_eq!(path, std::path::PathBuf::from("/tmp/test.pl"));
5259 }
5260
5261 assert!(uri_to_fs_path("not-a-uri").is_none());
5263
5264 assert!(uri_to_fs_path("http://example.com").is_none());
5266 }
5267
5268 #[test]
5269 fn test_uri_to_fs_path_with_spaces() {
5270 if let Some(path) = uri_to_fs_path("file:///tmp/path%20with%20spaces/test.pl") {
5272 assert_eq!(path, std::path::PathBuf::from("/tmp/path with spaces/test.pl"));
5273 }
5274
5275 if let Some(path) = uri_to_fs_path("file:///tmp/My%20Documents/test%20file.pl") {
5277 assert_eq!(path, std::path::PathBuf::from("/tmp/My Documents/test file.pl"));
5278 }
5279 }
5280
5281 #[test]
5282 fn test_uri_to_fs_path_with_unicode() {
5283 if let Some(path) = uri_to_fs_path("file:///tmp/caf%C3%A9/test.pl") {
5285 assert_eq!(path, std::path::PathBuf::from("/tmp/café/test.pl"));
5286 }
5287
5288 if let Some(path) = uri_to_fs_path("file:///tmp/emoji%F0%9F%98%80/test.pl") {
5290 assert_eq!(path, std::path::PathBuf::from("/tmp/emoji😀/test.pl"));
5291 }
5292 }
5293
5294 #[test]
5295 fn test_fs_path_to_uri_basic() {
5296 let result = fs_path_to_uri("/tmp/test.pl");
5298 assert!(result.is_ok());
5299 let uri = must(result);
5300 assert!(uri.starts_with("file://"));
5301 assert!(uri.contains("/tmp/test.pl"));
5302 }
5303
5304 #[test]
5305 fn test_fs_path_to_uri_with_spaces() {
5306 let result = fs_path_to_uri("/tmp/path with spaces/test.pl");
5308 assert!(result.is_ok());
5309 let uri = must(result);
5310 assert!(uri.starts_with("file://"));
5311 assert!(uri.contains("path%20with%20spaces"));
5313 }
5314
5315 #[test]
5316 fn test_fs_path_to_uri_with_unicode() {
5317 let result = fs_path_to_uri("/tmp/café/test.pl");
5319 assert!(result.is_ok());
5320 let uri = must(result);
5321 assert!(uri.starts_with("file://"));
5322 assert!(uri.contains("caf%C3%A9"));
5324 }
5325
5326 #[test]
5327 fn test_normalize_uri_file_schemes() {
5328 let uri = WorkspaceIndex::normalize_uri("file:///tmp/test.pl");
5330 assert_eq!(uri, "file:///tmp/test.pl");
5331
5332 let uri = WorkspaceIndex::normalize_uri("file:///tmp/path%20with%20spaces/test.pl");
5334 assert_eq!(uri, "file:///tmp/path%20with%20spaces/test.pl");
5335 }
5336
5337 #[test]
5338 fn test_normalize_uri_absolute_paths() {
5339 let uri = WorkspaceIndex::normalize_uri("/tmp/test.pl");
5341 assert!(uri.starts_with("file://"));
5342 assert!(uri.contains("/tmp/test.pl"));
5343 }
5344
5345 #[test]
5346 fn test_normalize_uri_special_schemes() {
5347 let uri = WorkspaceIndex::normalize_uri("untitled:Untitled-1");
5349 assert_eq!(uri, "untitled:Untitled-1");
5350 }
5351
5352 #[test]
5353 fn test_roundtrip_conversion() {
5354 let original_uri = "file:///tmp/path%20with%20spaces/caf%C3%A9.pl";
5356
5357 if let Some(path) = uri_to_fs_path(original_uri) {
5358 if let Ok(converted_uri) = fs_path_to_uri(&path) {
5359 assert!(converted_uri.starts_with("file://"));
5361
5362 if let Some(roundtrip_path) = uri_to_fs_path(&converted_uri) {
5364 #[cfg(windows)]
5365 if let Ok(rootless) = path.strip_prefix(std::path::Path::new(r"\")) {
5366 assert!(roundtrip_path.ends_with(rootless));
5367 } else {
5368 assert_eq!(path, roundtrip_path);
5369 }
5370
5371 #[cfg(not(windows))]
5372 assert_eq!(path, roundtrip_path);
5373 }
5374 }
5375 }
5376 }
5377
5378 #[cfg(target_os = "windows")]
5379 #[test]
5380 fn test_windows_paths() {
5381 let result = fs_path_to_uri(r"C:\Users\test\Documents\script.pl");
5383 assert!(result.is_ok());
5384 let uri = must(result);
5385 assert!(uri.starts_with("file://"));
5386
5387 let result = fs_path_to_uri(r"C:\Program Files\My App\script.pl");
5389 assert!(result.is_ok());
5390 let uri = must(result);
5391 assert!(uri.starts_with("file://"));
5392 assert!(uri.contains("Program%20Files"));
5393 }
5394
5395 #[test]
5400 fn test_coordinator_initial_state() {
5401 let coordinator = IndexCoordinator::new();
5402 assert!(matches!(
5403 coordinator.state(),
5404 IndexState::Building { phase: IndexPhase::Idle, .. }
5405 ));
5406 }
5407
5408 #[test]
5409 fn test_transition_to_scanning_phase() {
5410 let coordinator = IndexCoordinator::new();
5411 coordinator.transition_to_scanning();
5412
5413 let state = coordinator.state();
5414 assert!(
5415 matches!(state, IndexState::Building { phase: IndexPhase::Scanning, .. }),
5416 "Expected Building state after scanning, got: {:?}",
5417 state
5418 );
5419 }
5420
5421 #[test]
5422 fn test_transition_to_indexing_phase() {
5423 let coordinator = IndexCoordinator::new();
5424 coordinator.transition_to_scanning();
5425 coordinator.update_scan_progress(3);
5426 coordinator.transition_to_indexing(3);
5427
5428 let state = coordinator.state();
5429 assert!(
5430 matches!(
5431 state,
5432 IndexState::Building { phase: IndexPhase::Indexing, total_count: 3, .. }
5433 ),
5434 "Expected Building state after indexing with total_count 3, got: {:?}",
5435 state
5436 );
5437 }
5438
5439 #[test]
5440 fn test_transition_to_ready() {
5441 let coordinator = IndexCoordinator::new();
5442 coordinator.transition_to_ready(100, 5000);
5443
5444 let state = coordinator.state();
5445 if let IndexState::Ready { file_count, symbol_count, .. } = state {
5446 assert_eq!(file_count, 100);
5447 assert_eq!(symbol_count, 5000);
5448 } else {
5449 unreachable!("Expected Ready state, got: {:?}", state);
5450 }
5451 }
5452
5453 #[test]
5454 fn test_parse_storm_degradation() {
5455 let coordinator = IndexCoordinator::new();
5456 coordinator.transition_to_ready(100, 5000);
5457
5458 for _ in 0..15 {
5460 coordinator.notify_change("file.pm");
5461 }
5462
5463 let state = coordinator.state();
5464 assert!(
5465 matches!(state, IndexState::Degraded { .. }),
5466 "Expected Degraded state, got: {:?}",
5467 state
5468 );
5469 if let IndexState::Degraded { reason, .. } = state {
5470 assert!(matches!(reason, DegradationReason::ParseStorm { .. }));
5471 }
5472 }
5473
5474 #[test]
5475 fn test_recovery_from_parse_storm() {
5476 let coordinator = IndexCoordinator::new();
5477 coordinator.transition_to_ready(100, 5000);
5478
5479 for _ in 0..15 {
5481 coordinator.notify_change("file.pm");
5482 }
5483
5484 for _ in 0..15 {
5486 coordinator.notify_parse_complete("file.pm");
5487 }
5488
5489 assert!(matches!(coordinator.state(), IndexState::Building { .. }));
5491 }
5492
5493 #[test]
5494 fn test_query_dispatch_ready() {
5495 let coordinator = IndexCoordinator::new();
5496 coordinator.transition_to_ready(100, 5000);
5497
5498 let result = coordinator.query(|_index| "full_query", |_index| "partial_query");
5499
5500 assert_eq!(result, "full_query");
5501 }
5502
5503 #[test]
5504 fn test_query_dispatch_degraded() {
5505 let coordinator = IndexCoordinator::new();
5506 let result = coordinator.query(|_index| "full_query", |_index| "partial_query");
5509
5510 assert_eq!(result, "partial_query");
5511 }
5512
5513 #[test]
5514 fn test_metrics_pending_count() {
5515 let coordinator = IndexCoordinator::new();
5516
5517 coordinator.notify_change("file1.pm");
5518 coordinator.notify_change("file2.pm");
5519
5520 assert_eq!(coordinator.metrics.pending_count(), 2);
5521
5522 coordinator.notify_parse_complete("file1.pm");
5523 assert_eq!(coordinator.metrics.pending_count(), 1);
5524 }
5525
5526 #[test]
5527 fn test_instrumentation_records_transitions() {
5528 let coordinator = IndexCoordinator::new();
5529 coordinator.transition_to_ready(10, 100);
5530
5531 let snapshot = coordinator.instrumentation_snapshot();
5532 let transition =
5533 IndexStateTransition { from: IndexStateKind::Building, to: IndexStateKind::Ready };
5534 let count = snapshot.state_transition_counts.get(&transition).copied().unwrap_or(0);
5535 assert_eq!(count, 1);
5536 }
5537
5538 #[test]
5539 fn test_instrumentation_records_early_exit() {
5540 let coordinator = IndexCoordinator::new();
5541 coordinator.record_early_exit(EarlyExitReason::InitialTimeBudget, 25, 1, 10);
5542
5543 let snapshot = coordinator.instrumentation_snapshot();
5544 let count = snapshot
5545 .early_exit_counts
5546 .get(&EarlyExitReason::InitialTimeBudget)
5547 .copied()
5548 .unwrap_or(0);
5549 assert_eq!(count, 1);
5550 assert!(snapshot.last_early_exit.is_some());
5551 }
5552
5553 #[test]
5554 fn test_custom_limits() {
5555 let limits = IndexResourceLimits {
5556 max_files: 5000,
5557 max_symbols_per_file: 1000,
5558 max_total_symbols: 100_000,
5559 max_ast_cache_bytes: 128 * 1024 * 1024,
5560 max_ast_cache_items: 50,
5561 max_scan_duration_ms: 30_000,
5562 };
5563
5564 let coordinator = IndexCoordinator::with_limits(limits.clone());
5565 assert_eq!(coordinator.limits.max_files, 5000);
5566 assert_eq!(coordinator.limits.max_total_symbols, 100_000);
5567 }
5568
5569 #[test]
5570 fn test_degradation_preserves_symbol_count() {
5571 let coordinator = IndexCoordinator::new();
5572 coordinator.transition_to_ready(100, 5000);
5573
5574 coordinator.transition_to_degraded(DegradationReason::IoError {
5575 message: "Test error".to_string(),
5576 });
5577
5578 let state = coordinator.state();
5579 assert!(
5580 matches!(state, IndexState::Degraded { .. }),
5581 "Expected Degraded state, got: {:?}",
5582 state
5583 );
5584 if let IndexState::Degraded { available_symbols, .. } = state {
5585 assert_eq!(available_symbols, 5000);
5586 }
5587 }
5588
5589 #[test]
5590 fn test_index_access() {
5591 let coordinator = IndexCoordinator::new();
5592 let index = coordinator.index();
5593
5594 assert!(index.all_symbols().is_empty());
5596 }
5597
5598 #[test]
5599 fn test_resource_limit_enforcement_max_files() {
5600 let limits = IndexResourceLimits {
5601 max_files: 5,
5602 max_symbols_per_file: 1000,
5603 max_total_symbols: 50_000,
5604 max_ast_cache_bytes: 128 * 1024 * 1024,
5605 max_ast_cache_items: 50,
5606 max_scan_duration_ms: 30_000,
5607 };
5608
5609 let coordinator = IndexCoordinator::with_limits(limits);
5610 coordinator.transition_to_ready(10, 100);
5611
5612 for i in 0..10 {
5614 let uri_str = format!("file:///test{}.pl", i);
5615 let uri = must(url::Url::parse(&uri_str));
5616 let code = "sub test { }";
5617 must(coordinator.index().index_file(uri, code.to_string()));
5618 }
5619
5620 coordinator.enforce_limits();
5622
5623 let state = coordinator.state();
5624 assert!(
5625 matches!(
5626 state,
5627 IndexState::Degraded {
5628 reason: DegradationReason::ResourceLimit { kind: ResourceKind::MaxFiles },
5629 ..
5630 }
5631 ),
5632 "Expected Degraded state with ResourceLimit(MaxFiles), got: {:?}",
5633 state
5634 );
5635 }
5636
5637 #[test]
5638 fn test_resource_limit_enforcement_max_symbols() {
5639 let limits = IndexResourceLimits {
5640 max_files: 100,
5641 max_symbols_per_file: 10,
5642 max_total_symbols: 50, max_ast_cache_bytes: 128 * 1024 * 1024,
5644 max_ast_cache_items: 50,
5645 max_scan_duration_ms: 30_000,
5646 };
5647
5648 let coordinator = IndexCoordinator::with_limits(limits);
5649 coordinator.transition_to_ready(0, 0);
5650
5651 for i in 0..10 {
5653 let uri_str = format!("file:///test{}.pl", i);
5654 let uri = must(url::Url::parse(&uri_str));
5655 let code = r#"
5657package Test;
5658sub sub1 { }
5659sub sub2 { }
5660sub sub3 { }
5661sub sub4 { }
5662sub sub5 { }
5663sub sub6 { }
5664sub sub7 { }
5665sub sub8 { }
5666sub sub9 { }
5667sub sub10 { }
5668"#;
5669 must(coordinator.index().index_file(uri, code.to_string()));
5670 }
5671
5672 coordinator.enforce_limits();
5674
5675 let state = coordinator.state();
5676 assert!(
5677 matches!(
5678 state,
5679 IndexState::Degraded {
5680 reason: DegradationReason::ResourceLimit { kind: ResourceKind::MaxSymbols },
5681 ..
5682 }
5683 ),
5684 "Expected Degraded state with ResourceLimit(MaxSymbols), got: {:?}",
5685 state
5686 );
5687 }
5688
5689 #[test]
5690 fn test_check_limits_returns_none_within_bounds() {
5691 let coordinator = IndexCoordinator::new();
5692 coordinator.transition_to_ready(0, 0);
5693
5694 for i in 0..5 {
5696 let uri_str = format!("file:///test{}.pl", i);
5697 let uri = must(url::Url::parse(&uri_str));
5698 let code = "sub test { }";
5699 must(coordinator.index().index_file(uri, code.to_string()));
5700 }
5701
5702 let limit_check = coordinator.check_limits();
5704 assert!(limit_check.is_none(), "check_limits should return None when within bounds");
5705
5706 assert!(
5708 matches!(coordinator.state(), IndexState::Ready { .. }),
5709 "State should remain Ready when within limits"
5710 );
5711 }
5712
5713 #[test]
5714 fn test_enforce_limits_called_on_transition_to_ready() {
5715 let limits = IndexResourceLimits {
5716 max_files: 3,
5717 max_symbols_per_file: 1000,
5718 max_total_symbols: 50_000,
5719 max_ast_cache_bytes: 128 * 1024 * 1024,
5720 max_ast_cache_items: 50,
5721 max_scan_duration_ms: 30_000,
5722 };
5723
5724 let coordinator = IndexCoordinator::with_limits(limits);
5725
5726 for i in 0..5 {
5728 let uri_str = format!("file:///test{}.pl", i);
5729 let uri = must(url::Url::parse(&uri_str));
5730 let code = "sub test { }";
5731 must(coordinator.index().index_file(uri, code.to_string()));
5732 }
5733
5734 coordinator.transition_to_ready(5, 100);
5736
5737 let state = coordinator.state();
5738 assert!(
5739 matches!(
5740 state,
5741 IndexState::Degraded {
5742 reason: DegradationReason::ResourceLimit { kind: ResourceKind::MaxFiles },
5743 ..
5744 }
5745 ),
5746 "Expected Degraded state after transition_to_ready with exceeded limits, got: {:?}",
5747 state
5748 );
5749 }
5750
5751 #[test]
5752 fn test_state_transition_guard_ready_to_ready() {
5753 let coordinator = IndexCoordinator::new();
5755 coordinator.transition_to_ready(100, 5000);
5756
5757 coordinator.transition_to_ready(150, 7500);
5759
5760 let state = coordinator.state();
5761 assert!(
5762 matches!(state, IndexState::Ready { file_count: 150, symbol_count: 7500, .. }),
5763 "Expected Ready state with updated metrics, got: {:?}",
5764 state
5765 );
5766 }
5767
5768 #[test]
5769 fn test_state_transition_guard_building_to_building() {
5770 let coordinator = IndexCoordinator::new();
5772
5773 coordinator.transition_to_building(100);
5775
5776 let state = coordinator.state();
5777 assert!(
5778 matches!(state, IndexState::Building { indexed_count: 0, total_count: 100, .. }),
5779 "Expected Building state, got: {:?}",
5780 state
5781 );
5782
5783 coordinator.transition_to_building(200);
5785
5786 let state = coordinator.state();
5787 assert!(
5788 matches!(state, IndexState::Building { indexed_count: 0, total_count: 200, .. }),
5789 "Expected Building state, got: {:?}",
5790 state
5791 );
5792 }
5793
5794 #[test]
5795 fn test_state_transition_ready_to_building() {
5796 let coordinator = IndexCoordinator::new();
5798 coordinator.transition_to_ready(100, 5000);
5799
5800 coordinator.transition_to_building(150);
5802
5803 let state = coordinator.state();
5804 assert!(
5805 matches!(state, IndexState::Building { indexed_count: 0, total_count: 150, .. }),
5806 "Expected Building state after re-scan, got: {:?}",
5807 state
5808 );
5809 }
5810
5811 #[test]
5812 fn test_state_transition_degraded_to_building() {
5813 let coordinator = IndexCoordinator::new();
5815 coordinator.transition_to_degraded(DegradationReason::IoError {
5816 message: "Test error".to_string(),
5817 });
5818
5819 coordinator.transition_to_building(100);
5821
5822 let state = coordinator.state();
5823 assert!(
5824 matches!(state, IndexState::Building { indexed_count: 0, total_count: 100, .. }),
5825 "Expected Building state after recovery, got: {:?}",
5826 state
5827 );
5828 }
5829
5830 #[test]
5831 fn test_update_building_progress() {
5832 let coordinator = IndexCoordinator::new();
5833 coordinator.transition_to_building(100);
5834
5835 coordinator.update_building_progress(50);
5837
5838 let state = coordinator.state();
5839 assert!(
5840 matches!(state, IndexState::Building { indexed_count: 50, total_count: 100, .. }),
5841 "Expected Building state with updated progress, got: {:?}",
5842 state
5843 );
5844
5845 coordinator.update_building_progress(100);
5847
5848 let state = coordinator.state();
5849 assert!(
5850 matches!(state, IndexState::Building { indexed_count: 100, total_count: 100, .. }),
5851 "Expected Building state with completed progress, got: {:?}",
5852 state
5853 );
5854 }
5855
5856 #[test]
5857 fn test_scan_timeout_detection() {
5858 let limits = IndexResourceLimits {
5860 max_scan_duration_ms: 0, ..Default::default()
5862 };
5863
5864 let coordinator = IndexCoordinator::with_limits(limits);
5865 coordinator.transition_to_building(100);
5866
5867 std::thread::sleep(std::time::Duration::from_millis(1));
5869
5870 coordinator.update_building_progress(10);
5872
5873 let state = coordinator.state();
5874 assert!(
5875 matches!(
5876 state,
5877 IndexState::Degraded { reason: DegradationReason::ScanTimeout { .. }, .. }
5878 ),
5879 "Expected Degraded state with ScanTimeout, got: {:?}",
5880 state
5881 );
5882 }
5883
5884 #[test]
5885 fn test_scan_timeout_does_not_trigger_within_limit() {
5886 let limits = IndexResourceLimits {
5888 max_scan_duration_ms: 10_000, ..Default::default()
5890 };
5891
5892 let coordinator = IndexCoordinator::with_limits(limits);
5893 coordinator.transition_to_building(100);
5894
5895 coordinator.update_building_progress(50);
5897
5898 let state = coordinator.state();
5899 assert!(
5900 matches!(state, IndexState::Building { indexed_count: 50, .. }),
5901 "Expected Building state (no timeout), got: {:?}",
5902 state
5903 );
5904 }
5905
5906 #[test]
5907 fn test_early_exit_optimization_unchanged_content() {
5908 let index = WorkspaceIndex::new();
5909 let uri = must(url::Url::parse("file:///test.pl"));
5910 let code = r#"
5911package MyPackage;
5912
5913sub hello {
5914 print "Hello";
5915}
5916"#;
5917
5918 must(index.index_file(uri.clone(), code.to_string()));
5920 let symbols1 = index.file_symbols(uri.as_str());
5921 assert!(symbols1.iter().any(|s| s.name == "MyPackage" && s.kind == SymbolKind::Package));
5922 assert!(symbols1.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
5923
5924 must(index.index_file(uri.clone(), code.to_string()));
5927 let symbols2 = index.file_symbols(uri.as_str());
5928 assert_eq!(symbols1.len(), symbols2.len());
5929 assert!(symbols2.iter().any(|s| s.name == "MyPackage" && s.kind == SymbolKind::Package));
5930 assert!(symbols2.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
5931 }
5932
5933 #[test]
5934 fn test_index_file_generation_updates_on_same_content_reindex() {
5935 let index = WorkspaceIndex::new();
5936 let uri = must(url::Url::parse("file:///generation.pl"));
5937 let code = "package Generation;\nsub stable { 1 }\n1;\n";
5938
5939 must(index.index_file_with_generation(uri.clone(), code.to_string(), 1));
5940 assert_eq!(index.indexed_generation(uri.as_str()), Some(1));
5941 assert!(!index.is_index_generation_stale(uri.as_str(), 1));
5942 assert!(index.is_index_generation_stale(uri.as_str(), 2));
5943
5944 must(index.index_file_with_generation(uri.clone(), code.to_string(), 2));
5945 assert_eq!(index.indexed_generation(uri.as_str()), Some(2));
5946 assert!(!index.is_index_generation_stale(uri.as_str(), 2));
5947 }
5948
5949 #[test]
5950 fn is_index_generation_stale_boundary_discriminator_indexed_generation_less_than_expected_generation()
5951 {
5952 let index = WorkspaceIndex::new();
5953 let uri = must(url::Url::parse("file:///generation-boundary.pl"));
5954 let code = "package GenerationBoundary;\nsub stable { 1 }\n1;\n";
5955
5956 must(index.index_file_with_generation(uri.clone(), code.to_string(), 2));
5957
5958 assert_eq!(
5959 index.indexed_generation(uri.as_str()),
5960 Some(2),
5961 "test setup must index the boundary generation"
5962 );
5963 assert!(
5964 !index.is_index_generation_stale(uri.as_str(), 1),
5965 "indexed_generation > expected_generation must not be stale"
5966 );
5967 assert!(
5968 !index.is_index_generation_stale(uri.as_str(), 2),
5969 "indexed_generation == expected_generation must not be stale"
5970 );
5971 assert!(
5972 index.is_index_generation_stale(uri.as_str(), 3),
5973 "indexed_generation < expected_generation must be stale"
5974 );
5975 }
5976
5977 #[test]
5978 fn test_early_exit_optimization_changed_content() {
5979 let index = WorkspaceIndex::new();
5980 let uri = must(url::Url::parse("file:///test.pl"));
5981 let code1 = r#"
5982package MyPackage;
5983
5984sub hello {
5985 print "Hello";
5986}
5987"#;
5988
5989 let code2 = r#"
5990package MyPackage;
5991
5992sub goodbye {
5993 print "Goodbye";
5994}
5995"#;
5996
5997 must(index.index_file(uri.clone(), code1.to_string()));
5999 let symbols1 = index.file_symbols(uri.as_str());
6000 assert!(symbols1.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
6001 assert!(!symbols1.iter().any(|s| s.name == "goodbye"));
6002
6003 must(index.index_file(uri.clone(), code2.to_string()));
6005 let symbols2 = index.file_symbols(uri.as_str());
6006 assert!(!symbols2.iter().any(|s| s.name == "hello"));
6007 assert!(symbols2.iter().any(|s| s.name == "goodbye" && s.kind == SymbolKind::Subroutine));
6008 }
6009
6010 #[test]
6011 fn test_early_exit_optimization_whitespace_only_change() {
6012 let index = WorkspaceIndex::new();
6013 let uri = must(url::Url::parse("file:///test.pl"));
6014 let code1 = r#"
6015package MyPackage;
6016
6017sub hello {
6018 print "Hello";
6019}
6020"#;
6021
6022 let code2 = r#"
6023package MyPackage;
6024
6025
6026sub hello {
6027 print "Hello";
6028}
6029"#;
6030
6031 must(index.index_file(uri.clone(), code1.to_string()));
6033 let symbols1 = index.file_symbols(uri.as_str());
6034 assert!(symbols1.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
6035
6036 must(index.index_file(uri.clone(), code2.to_string()));
6038 let symbols2 = index.file_symbols(uri.as_str());
6039 assert!(symbols2.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
6041 }
6042
6043 #[test]
6044 fn test_reindex_file_refreshes_symbol_cache_for_removed_names() {
6045 let index = WorkspaceIndex::new();
6046 let uri1 = must(url::Url::parse("file:///lib/A.pm"));
6047 let uri2 = must(url::Url::parse("file:///lib/B.pm"));
6048 let code1 = "package A;\nsub foo { return 1; }\n1;\n";
6049 let code2 = "package B;\nsub foo { return 2; }\n1;\n";
6050 let code2_reindexed = "package B;\nsub bar { return 3; }\n1;\n";
6051
6052 must(index.index_file(uri1.clone(), code1.to_string()));
6053 must(index.index_file(uri2.clone(), code2.to_string()));
6054 must(index.index_file(uri2.clone(), code2_reindexed.to_string()));
6055
6056 let foo_location = must_some(index.find_definition("foo"));
6057 assert_eq!(foo_location.uri, uri1.to_string());
6058
6059 let bar_location = must_some(index.find_definition("bar"));
6060 assert_eq!(bar_location.uri, uri2.to_string());
6061 }
6062
6063 #[test]
6064 fn test_remove_file_preserves_other_colliding_symbol_entries() {
6065 let index = WorkspaceIndex::new();
6066 let uri1 = must(url::Url::parse("file:///lib/A.pm"));
6067 let uri2 = must(url::Url::parse("file:///lib/B.pm"));
6068 let code1 = "package A;\nsub foo { return 1; }\n1;\n";
6069 let code2 = "package B;\nsub foo { return 2; }\n1;\n";
6070
6071 must(index.index_file(uri1.clone(), code1.to_string()));
6072 must(index.index_file(uri2.clone(), code2.to_string()));
6073
6074 index.remove_file(uri2.as_str());
6075
6076 let foo_location = must_some(index.find_definition("foo"));
6077 assert_eq!(foo_location.uri, uri1.to_string());
6078 }
6079
6080 #[test]
6081 fn test_count_usages_no_double_counting_for_qualified_calls() {
6082 let index = WorkspaceIndex::new();
6083
6084 let uri1 = "file:///lib/Utils.pm";
6086 let code1 = r#"
6087package Utils;
6088
6089sub process_data {
6090 return 1;
6091}
6092"#;
6093 must(index.index_file(must(url::Url::parse(uri1)), code1.to_string()));
6094
6095 let uri2 = "file:///app.pl";
6097 let code2 = r#"
6098use Utils;
6099Utils::process_data();
6100Utils::process_data();
6101"#;
6102 must(index.index_file(must(url::Url::parse(uri2)), code2.to_string()));
6103
6104 let count = index.count_usages("Utils::process_data");
6108
6109 assert_eq!(
6112 count, 2,
6113 "count_usages should not double-count qualified calls, got {} (expected 2)",
6114 count
6115 );
6116
6117 let refs = index.find_references("Utils::process_data");
6119 let non_def_refs: Vec<_> =
6120 refs.iter().filter(|loc| loc.uri != "file:///lib/Utils.pm").collect();
6121 assert_eq!(
6122 non_def_refs.len(),
6123 2,
6124 "find_references should not return duplicates for qualified calls, got {} non-def refs",
6125 non_def_refs.len()
6126 );
6127 }
6128
6129 #[test]
6130 fn test_batch_indexing() {
6131 let index = WorkspaceIndex::new();
6132 let files: Vec<(Url, String)> = (0..5)
6133 .map(|i| {
6134 let uri = must(Url::parse(&format!("file:///batch/module{}.pm", i)));
6135 let code =
6136 format!("package Batch::Mod{};\nsub func_{} {{ return {}; }}\n1;", i, i, i);
6137 (uri, code)
6138 })
6139 .collect();
6140
6141 let errors = index.index_files_batch(files);
6142 assert!(errors.is_empty(), "batch indexing errors: {:?}", errors);
6143 assert_eq!(index.file_count(), 5);
6144 assert!(index.find_definition("Batch::Mod0::func_0").is_some());
6145 assert!(index.find_definition("Batch::Mod4::func_4").is_some());
6146 }
6147
6148 #[test]
6149 fn test_batch_indexing_skips_unchanged() {
6150 let index = WorkspaceIndex::new();
6151 let uri = must(Url::parse("file:///batch/skip.pm"));
6152 let code = "package Skip;\nsub skip_fn { 1 }\n1;".to_string();
6153
6154 index.index_file(uri.clone(), code.clone()).ok();
6155 assert_eq!(index.file_count(), 1);
6156
6157 let errors = index.index_files_batch(vec![(uri, code)]);
6158 assert!(errors.is_empty());
6159 assert_eq!(index.file_count(), 1);
6160 }
6161
6162 #[test]
6163 fn test_incremental_update_preserves_other_symbols() {
6164 let index = WorkspaceIndex::new();
6165
6166 let uri_a = must(Url::parse("file:///incr/a.pm"));
6167 let uri_b = must(Url::parse("file:///incr/b.pm"));
6168 index.index_file(uri_a.clone(), "package A;\nsub a_func { 1 }\n1;".into()).ok();
6169 index.index_file(uri_b.clone(), "package B;\nsub b_func { 2 }\n1;".into()).ok();
6170
6171 assert!(index.find_definition("A::a_func").is_some());
6172 assert!(index.find_definition("B::b_func").is_some());
6173
6174 index.index_file(uri_a, "package A;\nsub a_func_v2 { 11 }\n1;".into()).ok();
6175
6176 assert!(index.find_definition("A::a_func_v2").is_some());
6177 assert!(index.find_definition("B::b_func").is_some());
6178 }
6179
6180 #[test]
6181 fn test_remove_file_preserves_shadowed_symbols() {
6182 let index = WorkspaceIndex::new();
6183
6184 let uri_a = must(Url::parse("file:///shadow/a.pm"));
6185 let uri_b = must(Url::parse("file:///shadow/b.pm"));
6186 index.index_file(uri_a.clone(), "package ShadowA;\nsub helper { 1 }\n1;".into()).ok();
6187 index.index_file(uri_b.clone(), "package ShadowB;\nsub helper { 2 }\n1;".into()).ok();
6188
6189 assert!(index.find_definition("helper").is_some());
6190
6191 index.remove_file_url(&uri_a);
6192 assert!(index.find_definition("helper").is_some());
6193 assert!(index.find_definition("ShadowB::helper").is_some());
6194 }
6195
6196 #[test]
6201 fn test_index_dependency_via_use_parent_end_to_end() {
6202 let index = WorkspaceIndex::new();
6208
6209 let base_url = must(url::Url::parse("file:///test/workspace/lib/MyBase.pm"));
6210 must(index.index_file(
6211 base_url,
6212 "package MyBase;\nsub new { bless {}, shift }\n1;\n".to_string(),
6213 ));
6214
6215 let child_url = must(url::Url::parse("file:///test/workspace/child.pl"));
6216 must(index.index_file(child_url, "package Child;\nuse parent 'MyBase';\n1;\n".to_string()));
6217
6218 let dependents = index.find_dependents("MyBase");
6219 assert!(
6220 !dependents.is_empty(),
6221 "find_dependents('MyBase') returned empty — \
6222 use parent 'MyBase' should register MyBase as a dependency. \
6223 Dependencies in index: {:?}",
6224 {
6225 let files = index.files.read();
6226 files
6227 .iter()
6228 .map(|(k, v)| (k.clone(), v.dependencies.iter().cloned().collect::<Vec<_>>()))
6229 .collect::<Vec<_>>()
6230 }
6231 );
6232 assert!(
6233 dependents.contains(&"file:///test/workspace/child.pl".to_string()),
6234 "child.pl should be in dependents, got: {:?}",
6235 dependents
6236 );
6237 }
6238
6239 #[test]
6240 fn test_find_dependents_normalizes_legacy_separator_in_query() {
6241 let index = WorkspaceIndex::new();
6242 let uri = must(url::Url::parse("file:///test/workspace/legacy-query.pl"));
6243 let src = "package Child;\nuse parent 'My::Base';\n1;\n";
6244 must(index.index_file(uri, src.to_string()));
6245
6246 let dependents = index.find_dependents("My'Base");
6247 assert_eq!(dependents, vec!["file:///test/workspace/legacy-query.pl".to_string()]);
6248 }
6249
6250 #[test]
6251 fn test_file_dependencies_normalize_legacy_separator_in_source() {
6252 let index = WorkspaceIndex::new();
6253 let uri = must(url::Url::parse("file:///test/workspace/legacy-source.pl"));
6254 let src = "package Child;\nuse parent \"My'Base\";\n1;\n";
6255 must(index.index_file(uri.clone(), src.to_string()));
6256
6257 let deps = index.file_dependencies(uri.as_str());
6258 assert!(deps.contains("My::Base"));
6259 assert!(!deps.contains("My'Base"));
6260 }
6261
6262 #[test]
6263 fn test_index_dependency_via_moose_extends_end_to_end() -> Result<(), Box<dyn std::error::Error>>
6264 {
6265 let index = WorkspaceIndex::new();
6266
6267 let parent_url = must(url::Url::parse("file:///test/workspace/lib/My/App/Parent.pm"));
6268 must(index.index_file(parent_url, "package My::App::Parent;\n1;\n".to_string()));
6269
6270 let child_url = must(url::Url::parse("file:///test/workspace/child-moose.pl"));
6271 let child_src = "package Child;\nuse Moose;\nextends 'My::App::Parent';\n1;\n";
6272 must(index.index_file(child_url, child_src.to_string()));
6273
6274 let dependents = index.find_dependents("My::App::Parent");
6275 assert!(
6276 dependents.contains(&"file:///test/workspace/child-moose.pl".to_string()),
6277 "expected child-moose.pl in dependents, got: {dependents:?}"
6278 );
6279 Ok(())
6280 }
6281
6282 #[test]
6283 fn test_index_dependency_via_moo_with_role_end_to_end() -> Result<(), Box<dyn std::error::Error>>
6284 {
6285 let index = WorkspaceIndex::new();
6286
6287 let role_url = must(url::Url::parse("file:///test/workspace/lib/My/App/Role.pm"));
6288 must(index.index_file(role_url, "package My::App::Role;\n1;\n".to_string()));
6289
6290 let consumer_url = must(url::Url::parse("file:///test/workspace/consumer-moo.pl"));
6291 let consumer_src = "package Consumer;\nuse Moo;\nwith 'My::App::Role';\n1;\n";
6292 must(index.index_file(consumer_url.clone(), consumer_src.to_string()));
6293
6294 let dependents = index.find_dependents("My::App::Role");
6295 assert!(
6296 dependents.contains(&"file:///test/workspace/consumer-moo.pl".to_string()),
6297 "expected consumer-moo.pl in dependents, got: {dependents:?}"
6298 );
6299
6300 let deps = index.file_dependencies(consumer_url.as_str());
6301 assert!(deps.contains("My::App::Role"));
6302 Ok(())
6303 }
6304
6305 #[test]
6306 fn test_index_dependency_via_literal_require_end_to_end()
6307 -> Result<(), Box<dyn std::error::Error>> {
6308 let index = WorkspaceIndex::new();
6309 let uri = must(url::Url::parse("file:///test/workspace/require-consumer.pl"));
6310 let src = "package Consumer;\nrequire My::Loader;\n1;\n";
6311 must(index.index_file(uri.clone(), src.to_string()));
6312
6313 let deps = index.file_dependencies(uri.as_str());
6314 assert!(
6315 deps.contains("My::Loader"),
6316 "literal require should register module dependency, got: {deps:?}"
6317 );
6318 Ok(())
6319 }
6320
6321 #[test]
6322 fn test_manual_import_symbols_are_indexed_as_import_references()
6323 -> Result<(), Box<dyn std::error::Error>> {
6324 let index = WorkspaceIndex::new();
6325 let uri = must(url::Url::parse("file:///test/workspace/manual-import.pl"));
6326 let src = r#"package Consumer;
6327require My::Tools;
6328My::Tools->import(qw(helper_one helper_two));
6329helper_one();
63301;
6331"#;
6332 must(index.index_file(uri.clone(), src.to_string()));
6333
6334 let deps = index.file_dependencies(uri.as_str());
6335 assert!(
6336 deps.contains("My::Tools"),
6337 "manual import target should be tracked as dependency, got: {deps:?}"
6338 );
6339
6340 for symbol in ["helper_one", "helper_two"] {
6341 let refs = index.find_references(symbol);
6342 assert!(
6343 !refs.is_empty(),
6344 "expected at least one indexed reference for imported symbol `{symbol}`"
6345 );
6346 }
6347 Ok(())
6348 }
6349
6350 #[test]
6351 fn test_parser_produces_correct_args_for_use_parent() {
6352 use crate::Parser;
6356 let mut p = Parser::new("package Child;\nuse parent 'MyBase';\n1;\n");
6357 let ast = must(p.parse());
6358 assert!(
6359 matches!(ast.kind, NodeKind::Program { .. }),
6360 "Expected Program root, got {:?}",
6361 ast.kind
6362 );
6363 let NodeKind::Program { statements } = &ast.kind else {
6364 return;
6365 };
6366 let mut found_parent_use = false;
6367 for stmt in statements {
6368 if let NodeKind::Use { module, args, .. } = &stmt.kind {
6369 if module == "parent" {
6370 found_parent_use = true;
6371 assert_eq!(
6372 args,
6373 &["'MyBase'".to_string()],
6374 "Expected args=[\"'MyBase'\"] for `use parent 'MyBase'`, got: {:?}",
6375 args
6376 );
6377 let extracted = extract_module_names_from_use_args(args);
6378 assert_eq!(
6379 extracted,
6380 vec!["MyBase".to_string()],
6381 "extract_module_names_from_use_args should return [\"MyBase\"], got {:?}",
6382 extracted
6383 );
6384 }
6385 }
6386 }
6387 assert!(found_parent_use, "No Use node with module='parent' found in AST");
6388 }
6389
6390 #[test]
6395 fn test_extract_module_names_single_quoted() {
6396 let names = extract_module_names_from_use_args(&["'Foo::Bar'".to_string()]);
6397 assert_eq!(names, vec!["Foo::Bar"]);
6398 }
6399
6400 #[test]
6401 fn test_extract_module_names_double_quoted() {
6402 let names = extract_module_names_from_use_args(&["\"Foo::Bar\"".to_string()]);
6403 assert_eq!(names, vec!["Foo::Bar"]);
6404 }
6405
6406 #[test]
6407 fn test_extract_module_names_qw_list() {
6408 let names = extract_module_names_from_use_args(&["qw(Foo::Bar Other::Base)".to_string()]);
6409 assert_eq!(names, vec!["Foo::Bar", "Other::Base"]);
6410 }
6411
6412 #[test]
6413 fn test_extract_module_names_qw_slash_delimiter() {
6414 let names = extract_module_names_from_use_args(&["qw/Foo::Bar Other::Base/".to_string()]);
6415 assert_eq!(names, vec!["Foo::Bar", "Other::Base"]);
6416 }
6417
6418 #[test]
6419 fn test_extract_module_names_qw_with_space_before_delimiter() {
6420 let names = extract_module_names_from_use_args(&["qw [Foo::Bar Other::Base]".to_string()]);
6421 assert_eq!(names, vec!["Foo::Bar", "Other::Base"]);
6422 }
6423
6424 #[test]
6425 fn test_extract_module_names_qw_list_trims_wrapped_punctuation() {
6426 let names =
6427 extract_module_names_from_use_args(&["qw((Foo::Bar) [Other::Base],)".to_string()]);
6428 assert_eq!(names, vec!["Foo::Bar", "Other::Base"]);
6429 }
6430
6431 #[test]
6432 fn test_extract_module_names_norequire_flag() {
6433 let names = extract_module_names_from_use_args(&[
6434 "-norequire".to_string(),
6435 "'Foo::Bar'".to_string(),
6436 ]);
6437 assert_eq!(names, vec!["Foo::Bar"]);
6438 }
6439
6440 #[test]
6441 fn test_extract_module_names_empty_args() {
6442 let names = extract_module_names_from_use_args(&[]);
6443 assert!(names.is_empty());
6444 }
6445
6446 #[test]
6447 fn test_extract_module_names_legacy_separator() {
6448 let names = extract_module_names_from_use_args(&["'Foo'Bar'".to_string()]);
6450 assert_eq!(names, vec!["Foo::Bar"]);
6452 }
6453
6454 #[test]
6455 fn test_find_dependents_matches_legacy_separator_queries() {
6456 let index = WorkspaceIndex::new();
6457 let base_uri = must(url::Url::parse("file:///test/workspace/lib/Foo/Bar.pm"));
6458 let child_uri = must(url::Url::parse("file:///test/workspace/child.pl"));
6459
6460 must(index.index_file(base_uri, "package Foo::Bar;\n1;\n".to_string()));
6461 must(index.index_file(
6462 child_uri.clone(),
6463 "package Child;\nuse parent qw(Foo'Bar);\n1;\n".to_string(),
6464 ));
6465
6466 let dependents_modern = index.find_dependents("Foo::Bar");
6467 assert!(
6468 dependents_modern.contains(&child_uri.to_string()),
6469 "Expected dependency match when queried with modern separator"
6470 );
6471
6472 let dependents_legacy = index.find_dependents("Foo'Bar");
6473 assert!(
6474 dependents_legacy.contains(&child_uri.to_string()),
6475 "Expected dependency match when queried with legacy separator"
6476 );
6477 }
6478
6479 #[test]
6480 fn test_extract_module_names_comma_adjacent_tokens() {
6481 let names = extract_module_names_from_use_args(&[
6482 "'Foo::Bar',".to_string(),
6483 "\"Other::Base\",".to_string(),
6484 "'Last::One'".to_string(),
6485 ]);
6486 assert_eq!(names, vec!["Foo::Bar", "Other::Base", "Last::One"]);
6487 }
6488
6489 #[test]
6490 fn test_extract_module_names_parenthesized_without_spaces() {
6491 let names = extract_module_names_from_use_args(&["('Foo::Bar','Other::Base')".to_string()]);
6492 assert_eq!(names, vec!["Foo::Bar", "Other::Base"]);
6493 }
6494
6495 #[test]
6496 fn test_extract_module_names_deduplicates_identical_entries() {
6497 let names = extract_module_names_from_use_args(&[
6498 "qw(Foo::Bar Foo::Bar)".to_string(),
6499 "'Foo::Bar'".to_string(),
6500 ]);
6501 assert_eq!(names, vec!["Foo::Bar"]);
6502 }
6503
6504 #[test]
6505 fn test_extract_module_names_trims_semicolon_suffix() {
6506 let names = extract_module_names_from_use_args(&[
6507 "'Foo::Bar',".to_string(),
6508 "'Other::Base',".to_string(),
6509 "'Third::Leaf';".to_string(),
6510 ]);
6511 assert_eq!(names, vec!["Foo::Bar", "Other::Base", "Third::Leaf"]);
6512 }
6513
6514 #[test]
6515 fn test_extract_module_names_trims_wrapped_punctuation() {
6516 let names = extract_module_names_from_use_args(&[
6517 "('Foo::Bar',".to_string(),
6518 "'Other::Base')".to_string(),
6519 ]);
6520 assert_eq!(names, vec!["Foo::Bar", "Other::Base"]);
6521 }
6522
6523 #[test]
6524 fn test_extract_constant_names_qw_with_space_before_delimiter() {
6525 let names = extract_constant_names_from_use_args(&["qw [FOO BAR]".to_string()]);
6526 assert_eq!(names, vec!["FOO", "BAR"]);
6527 }
6528
6529 #[test]
6530 #[ignore = "qw delimiter with leading space not yet parsed; tracked in debt-ledger.yaml"]
6531 fn test_index_use_constant_qw_with_space_before_delimiter() {
6532 let index = WorkspaceIndex::new();
6533 let uri = must(url::Url::parse("file:///workspace/lib/My/Config.pm"));
6534 let source = "package My::Config;\nuse constant qw [FOO BAR];\n1;\n";
6535
6536 must(index.index_file(uri, source.to_string()));
6537
6538 let foo = index.find_definition("My::Config::FOO");
6539 let bar = index.find_definition("My::Config::BAR");
6540 assert!(foo.is_some(), "Expected My::Config::FOO to be indexed");
6541 assert!(bar.is_some(), "Expected My::Config::BAR to be indexed");
6542 }
6543
6544 #[test]
6545 fn test_with_capacity_accepts_large_batch_without_panic() {
6546 let index = WorkspaceIndex::with_capacity(100, 20);
6547 for i in 0..100 {
6548 let uri = must(url::Url::parse(&format!("file:///lib/Mod{}.pm", i)));
6549 let src = format!("package Mod{};\nsub foo_{} {{ 1 }}\n1;\n", i, i);
6550 index.index_file(uri, src).ok();
6551 }
6552 assert!(index.has_symbols());
6553 }
6554
6555 #[test]
6556 fn test_with_capacity_zero_does_not_panic() {
6557 let index = WorkspaceIndex::with_capacity(0, 0);
6558 assert!(!index.has_symbols());
6559 }
6560
6561 #[test]
6569 fn test_remove_file_clears_symbol_cache_qualified_and_bare() {
6570 let index = WorkspaceIndex::new();
6571 let uri_a = must(url::Url::parse("file:///lib/A.pm"));
6572 let code_a = "package A;\nsub foo { return 1; }\n1;\n";
6573
6574 must(index.index_file(uri_a.clone(), code_a.to_string()));
6575
6576 let before_qual = must_some(index.find_definition("A::foo"));
6578 assert_eq!(
6579 before_qual.uri,
6580 uri_a.to_string(),
6581 "qualified lookup should point to A.pm before removal"
6582 );
6583 let before_bare = must_some(index.find_definition("foo"));
6584 assert_eq!(
6585 before_bare.uri,
6586 uri_a.to_string(),
6587 "bare-name lookup should point to A.pm before removal"
6588 );
6589
6590 index.remove_file(uri_a.as_str());
6592
6593 assert!(
6595 index.find_definition("A::foo").is_none(),
6596 "qualified lookup 'A::foo' should return None after file deletion"
6597 );
6598 assert!(
6599 index.find_definition("foo").is_none(),
6600 "bare-name lookup 'foo' should return None after file deletion"
6601 );
6602
6603 assert_eq!(
6605 index.symbol_count(),
6606 0,
6607 "symbol_count should be 0 after removing the only file"
6608 );
6609 assert!(!index.has_symbols(), "has_symbols should be false after removing the only file");
6610 }
6611
6612 #[test]
6615 fn test_remove_file_bare_name_falls_back_to_surviving_file() {
6616 let index = WorkspaceIndex::new();
6617 let uri_a = must(url::Url::parse("file:///lib/A.pm"));
6618 let uri_b = must(url::Url::parse("file:///lib/B.pm"));
6619 let code_a = "package A;\nsub shared_fn { return 1; }\n1;\n";
6620 let code_b = "package B;\nsub shared_fn { return 2; }\n1;\n";
6621
6622 must(index.index_file(uri_a.clone(), code_a.to_string()));
6623 must(index.index_file(uri_b.clone(), code_b.to_string()));
6624
6625 index.remove_file(uri_a.as_str());
6627
6628 let loc = must_some(index.find_definition("shared_fn"));
6629 assert_eq!(
6630 loc.uri,
6631 uri_b.to_string(),
6632 "bare-name 'shared_fn' should resolve to B.pm after A.pm is deleted"
6633 );
6634
6635 assert!(
6636 index.find_definition("A::shared_fn").is_none(),
6637 "qualified 'A::shared_fn' must be gone after A.pm deletion"
6638 );
6639 assert!(
6640 index.find_definition("B::shared_fn").is_some(),
6641 "qualified 'B::shared_fn' must remain after A.pm deletion"
6642 );
6643 }
6644
6645 #[test]
6646 fn test_definition_candidates_include_ambiguous_bare_symbols_in_stable_order() {
6647 let index = WorkspaceIndex::new();
6648 let uri_b = must(url::Url::parse("file:///lib/B.pm"));
6649 let uri_a = must(url::Url::parse("file:///lib/A.pm"));
6650 must(index.index_file(uri_b, "package B;\nsub shared { 1 }\n1;\n".to_string()));
6651 must(index.index_file(uri_a, "package A;\nsub shared { 1 }\n1;\n".to_string()));
6652
6653 let candidates = index.definition_candidates("shared");
6654 assert_eq!(candidates.len(), 2);
6655 assert_eq!(candidates[0].uri, "file:///lib/A.pm");
6656 assert_eq!(candidates[1].uri, "file:///lib/B.pm");
6657 assert_eq!(must_some(index.find_definition("shared")).uri, "file:///lib/A.pm");
6658 }
6659
6660 #[test]
6661 fn test_definition_candidates_include_duplicate_qualified_name_across_files() {
6662 let index = WorkspaceIndex::new();
6663 let uri_v2 = must(url::Url::parse("file:///lib/A-v2.pm"));
6664 let uri_v1 = must(url::Url::parse("file:///lib/A-v1.pm"));
6665 let source = "package A;\nsub foo { 1 }\n1;\n".to_string();
6666 must(index.index_file(uri_v2, source.clone()));
6667 must(index.index_file(uri_v1, source));
6668
6669 let candidates = index.definition_candidates("A::foo");
6670 assert_eq!(candidates.len(), 2);
6671 assert_eq!(candidates[0].uri, "file:///lib/A-v1.pm");
6672 assert_eq!(candidates[1].uri, "file:///lib/A-v2.pm");
6673 }
6674
6675 #[test]
6676 fn test_definition_candidates_are_cleaned_on_remove_and_reindex() {
6677 let index = WorkspaceIndex::new();
6678 let uri = must(url::Url::parse("file:///lib/A.pm"));
6679 must(index.index_file(uri.clone(), "package A;\nsub foo { 1 }\n1;\n".to_string()));
6680 assert_eq!(index.definition_candidates("A::foo").len(), 1);
6681
6682 index.remove_file(uri.as_str());
6683 assert!(index.definition_candidates("A::foo").is_empty());
6684
6685 must(index.index_file(uri, "package A;\nsub foo { 2 }\n1;\n".to_string()));
6686 assert_eq!(index.definition_candidates("A::foo").len(), 1);
6687 }
6688
6689 #[test]
6695 fn test_definition_candidates_shared_symbol_survives_removal_of_sole_owner_of_other_symbol() {
6696 let index = WorkspaceIndex::new();
6697 let uri_a = must(url::Url::parse("file:///lib/A.pm"));
6698 let uri_b = must(url::Url::parse("file:///lib/B.pm"));
6699
6700 must(index.index_file(
6702 uri_a.clone(),
6703 "package A;\nsub unique_to_a { 1 }\nsub shared { 1 }\n1;\n".to_string(),
6704 ));
6705 must(index.index_file(uri_b.clone(), "package B;\nsub shared { 1 }\n1;\n".to_string()));
6706
6707 assert_eq!(index.definition_candidates("shared").len(), 2);
6709 assert_eq!(index.definition_candidates("unique_to_a").len(), 1);
6710
6711 index.remove_file(uri_a.as_str());
6714
6715 assert!(
6716 index.definition_candidates("unique_to_a").is_empty(),
6717 "unique_to_a should be gone after removing A"
6718 );
6719 assert_eq!(
6720 index.definition_candidates("shared").len(),
6721 1,
6722 "shared should still have B's candidate after removing A"
6723 );
6724 assert_eq!(
6725 index.definition_candidates("shared")[0].uri,
6726 "file:///lib/B.pm",
6727 "remaining shared candidate must be from B"
6728 );
6729 }
6730
6731 #[test]
6732 fn test_folder_context_in_file_index() {
6733 let index = WorkspaceIndex::new();
6734
6735 index.set_workspace_folders(vec![
6737 "file:///project1".to_string(),
6738 "file:///project2".to_string(),
6739 ]);
6740
6741 let uri1 = "file:///project1/lib/Module.pm";
6742 let code1 = r#"
6743package Module;
6744
6745sub test_sub {
6746 return 1;
6747}
6748"#;
6749 must(index.index_file(must(url::Url::parse(uri1)), code1.to_string()));
6750
6751 let uri2 = "file:///project2/lib/Other.pm";
6752 let code2 = r#"
6753package Other;
6754
6755sub other_sub {
6756 return 2;
6757}
6758"#;
6759 must(index.index_file(must(url::Url::parse(uri2)), code2.to_string()));
6760
6761 let symbols1 = index.file_symbols(uri1);
6763 assert_eq!(symbols1.len(), 2, "Should have 2 symbols in Module.pm");
6764 for symbol in &symbols1 {
6765 assert_eq!(symbol.uri, uri1, "Symbol URI should match file URI");
6766 }
6767
6768 let symbols2 = index.file_symbols(uri2);
6769 assert_eq!(symbols2.len(), 2, "Should have 2 symbols in Other.pm");
6770 for symbol in &symbols2 {
6771 assert_eq!(symbol.uri, uri2, "Symbol URI should match file URI");
6772 }
6773
6774 let files = index.files.read();
6776 let file_index1 = must_some(files.get(&DocumentStore::uri_key(uri1)));
6777 assert_eq!(
6778 file_index1.folder_uri,
6779 Some("file:///project1".to_string()),
6780 "File should be attributed to correct workspace folder"
6781 );
6782
6783 let file_index2 = must_some(files.get(&DocumentStore::uri_key(uri2)));
6784 assert_eq!(
6785 file_index2.folder_uri,
6786 Some("file:///project2".to_string()),
6787 "File should be attributed to correct workspace folder"
6788 );
6789 }
6790
6791 #[test]
6792 fn test_determine_folder_uri() {
6793 let index = WorkspaceIndex::new();
6794
6795 index.set_workspace_folders(vec![
6797 "file:///project1".to_string(),
6798 "file:///project2".to_string(),
6799 ]);
6800
6801 let folder1 = index.determine_folder_uri("file:///project1/lib/Module.pm");
6803 assert_eq!(
6804 folder1,
6805 Some("file:///project1".to_string()),
6806 "Should determine folder for file in project1"
6807 );
6808
6809 let folder2 = index.determine_folder_uri("file:///project2/lib/Other.pm");
6811 assert_eq!(
6812 folder2,
6813 Some("file:///project2".to_string()),
6814 "Should determine folder for file in project2"
6815 );
6816
6817 let folder_none = index.determine_folder_uri("file:///other/project/Module.pm");
6819 assert_eq!(folder_none, None, "Should return None for file outside workspace folders");
6820 }
6821
6822 #[test]
6823 fn test_determine_folder_uri_prefers_most_specific_match() {
6824 let index = WorkspaceIndex::new();
6825
6826 index.set_workspace_folders(vec![
6828 "file:///project".to_string(),
6829 "file:///project/lib".to_string(),
6830 ]);
6831
6832 let folder = index.determine_folder_uri("file:///project/lib/My/Module.pm");
6833 assert_eq!(
6834 folder,
6835 Some("file:///project/lib".to_string()),
6836 "Nested workspace folders should attribute files to the most specific folder"
6837 );
6838 }
6839
6840 #[test]
6841 fn test_remove_folder() {
6842 let index = WorkspaceIndex::new();
6843
6844 index.set_workspace_folders(vec![
6846 "file:///project1".to_string(),
6847 "file:///project2".to_string(),
6848 ]);
6849
6850 let uri1 = "file:///project1/lib/Module.pm";
6852 let code1 = r#"
6853package Module;
6854
6855sub test_sub {
6856 return 1;
6857}
6858"#;
6859 must(index.index_file(must(url::Url::parse(uri1)), code1.to_string()));
6860
6861 let uri2 = "file:///project2/lib/Other.pm";
6862 let code2 = r#"
6863package Other;
6864
6865sub other_sub {
6866 return 2;
6867}
6868"#;
6869 must(index.index_file(must(url::Url::parse(uri2)), code2.to_string()));
6870
6871 assert_eq!(index.file_count(), 2, "Should have 2 files indexed");
6873 assert_eq!(index.document_store().count(), 2, "Document store should track both files");
6874
6875 index.remove_folder("file:///project1");
6877
6878 assert_eq!(index.file_count(), 1, "Should have 1 file after removing folder");
6880 assert_eq!(
6881 index.document_store().count(),
6882 1,
6883 "Document store should drop files removed via folder deletion"
6884 );
6885 assert!(index.file_symbols(uri1).is_empty(), "File from removed folder should be gone");
6886 assert_eq!(
6887 index.file_symbols(uri2).len(),
6888 2,
6889 "File from remaining folder should still be present"
6890 );
6891 }
6892
6893 #[test]
6894 fn test_remove_folder_removes_symbol_free_files() {
6895 let index = WorkspaceIndex::new();
6896 index.set_workspace_folders(vec!["file:///project1".to_string()]);
6897
6898 let uri = "file:///project1/empty.pl";
6899 must(index.index_file(must(url::Url::parse(uri)), "# comments only".to_string()));
6900 assert_eq!(index.file_count(), 1, "Expected file to be indexed");
6901
6902 index.remove_folder("file:///project1");
6903
6904 assert_eq!(index.file_count(), 0, "Folder removal should delete symbol-free files");
6905 assert_eq!(
6906 index.document_store().count(),
6907 0,
6908 "Document store should stay in sync for symbol-free files"
6909 );
6910 }
6911
6912 #[test]
6917 fn test_require_with_variable_target_is_not_indexed() -> Result<(), Box<dyn std::error::Error>>
6918 {
6919 let index = WorkspaceIndex::new();
6920 let uri = must(url::Url::parse("file:///test/require-var.pl"));
6921 let src = r#"package Test;
6922my $loader = 'MyModule';
6923require $loader;
69241;
6925"#;
6926 must(index.index_file(uri.clone(), src.to_string()));
6927 let deps = index.file_dependencies(uri.as_str());
6928 assert!(
6929 !deps.contains("MyModule"),
6930 "require with variable target should not register static dependency"
6931 );
6932 Ok(())
6933 }
6934
6935 #[test]
6936 fn test_multiple_import_calls_on_same_module() -> Result<(), Box<dyn std::error::Error>> {
6937 let index = WorkspaceIndex::new();
6938 let uri = must(url::Url::parse("file:///test/multi-import.pl"));
6939 let src = r#"package Test;
6940require Toolkit;
6941Toolkit->import('func_a');
6942Toolkit->import(qw(func_b func_c));
69431;
6944"#;
6945 must(index.index_file(uri.clone(), src.to_string()));
6946 let deps = index.file_dependencies(uri.as_str());
6947 assert!(deps.contains("Toolkit"), "module should be tracked as dependency");
6948 for symbol in &["func_a", "func_b", "func_c"] {
6949 let refs = index.find_references(symbol);
6950 assert!(!refs.is_empty(), "all imported symbols should be indexed: {}", symbol);
6951 }
6952 Ok(())
6953 }
6954
6955 #[test]
6956 fn test_require_string_vs_bareword_normalization() -> Result<(), Box<dyn std::error::Error>> {
6957 let index = WorkspaceIndex::new();
6958 let uri = must(url::Url::parse("file:///test/require-string.pl"));
6959 let src = r#"package Consumer;
6960require "String/Based/Module.pm";
6961String::Based::Module->import('exported');
69621;
6963"#;
6964 must(index.index_file(uri.clone(), src.to_string()));
6965 let deps = index.file_dependencies(uri.as_str());
6966 assert!(
6967 deps.contains("String::Based::Module"),
6968 "require string form should normalize path separators to ::"
6969 );
6970 let refs = index.find_references("exported");
6971 assert!(!refs.is_empty(), "import should be indexed even with string-form require");
6972 Ok(())
6973 }
6974
6975 #[test]
6976 fn test_import_without_require_registers_as_method_call()
6977 -> Result<(), Box<dyn std::error::Error>> {
6978 let index = WorkspaceIndex::new();
6982 let uri = must(url::Url::parse("file:///test/orphan-import.pl"));
6983 let src = r#"package Test;
6984Unrelated::Module->import('orphaned');
6985orphaned();
69861;
6987"#;
6988 must(index.index_file(uri.clone(), src.to_string()));
6989
6990 let _refs = index.find_references("orphaned");
6994 Ok(())
6997 }
6998
6999 #[test]
7000 fn test_nested_blocks_preserve_require_scope() -> Result<(), Box<dyn std::error::Error>> {
7001 let index = WorkspaceIndex::new();
7002 let uri = must(url::Url::parse("file:///test/nested.pl"));
7003 let src = r#"package Test;
7004{
7005 require Outer;
7006 {
7007 Outer->import('nested_sym');
7008 }
7009}
70101;
7011"#;
7012 must(index.index_file(uri.clone(), src.to_string()));
7013 let deps = index.file_dependencies(uri.as_str());
7014 assert!(
7015 deps.contains("Outer"),
7016 "require in outer block should be visible to nested import"
7017 );
7018 let refs = index.find_references("nested_sym");
7019 assert!(!refs.is_empty(), "symbol imported in nested block should still be indexed");
7020 Ok(())
7021 }
7022
7023 #[test]
7024 fn test_require_path_without_pm_extension() -> Result<(), Box<dyn std::error::Error>> {
7025 let index = WorkspaceIndex::new();
7026 let uri = must(url::Url::parse("file:///test/no-ext.pl"));
7027 let src = r#"package Test;
7028require "My/Module";
7029My::Module->import('func');
70301;
7031"#;
7032 must(index.index_file(uri.clone(), src.to_string()));
7033 let deps = index.file_dependencies(uri.as_str());
7034 assert!(
7035 deps.contains("My::Module"),
7036 "require without .pm extension should normalize to module path"
7037 );
7038 Ok(())
7039 }
7040
7041 #[test]
7042 fn test_qw_with_bracket_delimiters() -> Result<(), Box<dyn std::error::Error>> {
7043 let index = WorkspaceIndex::new();
7044 let uri = must(url::Url::parse("file:///test/qw-delim.pl"));
7045 let src = r#"package Test;
7046require DelimModule;
7047DelimModule->import(qw[sym1 sym2]);
7048DelimModule->import(qw{sym3 sym4});
70491;
7050"#;
7051 must(index.index_file(uri.clone(), src.to_string()));
7052 for symbol in &["sym1", "sym2", "sym3", "sym4"] {
7053 let refs = index.find_references(symbol);
7054 assert!(
7055 !refs.is_empty(),
7056 "symbols from qw with bracket delimiters should be indexed: {}",
7057 symbol
7058 );
7059 }
7060 Ok(())
7061 }
7062
7063 #[test]
7064 fn test_array_literal_import_args() -> Result<(), Box<dyn std::error::Error>> {
7065 let index = WorkspaceIndex::new();
7066 let uri = must(url::Url::parse("file:///test/array-import.pl"));
7067 let src = r#"package Test;
7068require ArrayModule;
7069ArrayModule->import(['sym_x', 'sym_y']);
70701;
7071"#;
7072 must(index.index_file(uri.clone(), src.to_string()));
7073 for symbol in &["sym_x", "sym_y"] {
7074 let refs = index.find_references(symbol);
7075 assert!(
7076 !refs.is_empty(),
7077 "symbols from array literal import should be indexed: {}",
7078 symbol
7079 );
7080 }
7081 Ok(())
7082 }
7083
7084 #[test]
7085 fn test_require_inside_conditional_still_registers_dependency()
7086 -> Result<(), Box<dyn std::error::Error>> {
7087 let index = WorkspaceIndex::new();
7088 let uri = must(url::Url::parse("file:///test/cond-require.pl"));
7089 let src = r#"package Test;
7090if (1) {
7091 require ConditionalMod;
7092 ConditionalMod->import('cond_func');
7093}
70941;
7095"#;
7096 must(index.index_file(uri.clone(), src.to_string()));
7097 let deps = index.file_dependencies(uri.as_str());
7098 assert!(
7099 deps.contains("ConditionalMod"),
7100 "require inside conditional should still register as dependency"
7101 );
7102 let refs = index.find_references("cond_func");
7103 assert!(!refs.is_empty(), "import inside conditional should still index symbols");
7104 Ok(())
7105 }
7106
7107 #[test]
7108 fn test_mixed_string_and_bareword_imports() -> Result<(), Box<dyn std::error::Error>> {
7109 let index = WorkspaceIndex::new();
7110 let uri = must(url::Url::parse("file:///test/mixed-import.pl"));
7111 let src = r#"package Test;
7112require MixedMod;
7113MixedMod->import('string_sym');
7114MixedMod->import(qw(qw_one qw_two));
71151;
7116"#;
7117 must(index.index_file(uri.clone(), src.to_string()));
7118 let deps = index.file_dependencies(uri.as_str());
7119 assert!(deps.contains("MixedMod"), "require should register dependency");
7120 for symbol in &["string_sym", "qw_one", "qw_two"] {
7121 let refs = index.find_references(symbol);
7122 assert!(!refs.is_empty(), "all import forms should index symbols: {}", symbol);
7123 }
7124 Ok(())
7125 }
7126
7127 fn make_shard(
7133 uri: &str,
7134 content_hash: u64,
7135 anchors_hash: Option<u64>,
7136 entities_hash: Option<u64>,
7137 occurrences_hash: Option<u64>,
7138 edges_hash: Option<u64>,
7139 ) -> FileFactShard {
7140 let file_id = {
7141 let mut h = DefaultHasher::new();
7142 uri.hash(&mut h);
7143 FileId(h.finish())
7144 };
7145 FileFactShard {
7146 source_uri: uri.to_string(),
7147 file_id,
7148 content_hash,
7149 producer_schema_version: PRODUCER_SCHEMA_VERSION,
7150 anchors_hash,
7151 entities_hash,
7152 occurrences_hash,
7153 edges_hash,
7154 anchors: Vec::new(),
7155 entities: Vec::new(),
7156 occurrences: Vec::new(),
7157 edges: Vec::new(),
7158 }
7159 }
7160
7161 #[test]
7164 fn incremental_replace_skips_when_content_hash_unchanged()
7165 -> Result<(), Box<dyn std::error::Error>> {
7166 let index = WorkspaceIndex::new();
7167 let uri = "file:///lib/Same.pm";
7168 let key = DocumentStore::uri_key(uri);
7169
7170 let shard_v1 = make_shard(uri, 42, Some(1), Some(2), Some(3), Some(4));
7171 let r1 = index.replace_fact_shard_incremental(&key, shard_v1);
7173 assert!(!r1.content_unchanged);
7174
7175 let shard_v2 = make_shard(uri, 42, Some(100), Some(200), Some(300), Some(400));
7177 let r2 = index.replace_fact_shard_incremental(&key, shard_v2);
7178 assert!(r2.content_unchanged);
7179 assert!(!r2.anchors_updated);
7180 assert!(!r2.entities_updated);
7181 assert!(!r2.occurrences_updated);
7182 assert!(!r2.edges_updated);
7183
7184 let stored = must_some(index.file_fact_shard(uri));
7186 assert_eq!(stored.anchors_hash, Some(1));
7187 Ok(())
7188 }
7189
7190 #[test]
7193 fn incremental_replace_skips_unchanged_categories() -> Result<(), Box<dyn std::error::Error>> {
7194 let index = WorkspaceIndex::new();
7195 let uri = "file:///lib/Partial.pm";
7196 let key = DocumentStore::uri_key(uri);
7197
7198 let shard_v1 = make_shard(uri, 1, Some(10), Some(20), Some(30), Some(40));
7199 index.replace_fact_shard_incremental(&key, shard_v1);
7200
7201 let shard_v2 = make_shard(uri, 2, Some(10), Some(20), Some(99), Some(88));
7204 let result = index.replace_fact_shard_incremental(&key, shard_v2);
7205
7206 assert!(!result.content_unchanged);
7207 assert!(!result.anchors_updated, "anchors hash unchanged → skip");
7208 assert!(!result.entities_updated, "entities hash unchanged → skip");
7209 assert!(result.occurrences_updated, "occurrences hash changed → update");
7210 assert!(result.edges_updated, "edges hash changed → update");
7211 Ok(())
7212 }
7213
7214 #[test]
7217 fn incremental_replace_updates_changed_categories() -> Result<(), Box<dyn std::error::Error>> {
7218 let index = WorkspaceIndex::new();
7219 let uri = "file:///lib/Changed.pm";
7220 let key = DocumentStore::uri_key(uri);
7221
7222 let shard_v1 = make_shard(uri, 1, Some(10), Some(20), Some(30), Some(40));
7223 index.replace_fact_shard_incremental(&key, shard_v1);
7224
7225 let shard_v2 = make_shard(uri, 2, Some(11), Some(21), Some(31), Some(41));
7227 let result = index.replace_fact_shard_incremental(&key, shard_v2);
7228
7229 assert!(!result.content_unchanged);
7230 assert!(result.anchors_updated);
7231 assert!(result.entities_updated);
7232 assert!(result.occurrences_updated);
7233 assert!(result.edges_updated);
7234
7235 let stored = must_some(index.file_fact_shard(uri));
7237 assert_eq!(stored.content_hash, 2);
7238 assert_eq!(stored.anchors_hash, Some(11));
7239 Ok(())
7240 }
7241
7242 #[test]
7245 fn incremental_replace_first_insert_updates_all() -> Result<(), Box<dyn std::error::Error>> {
7246 let index = WorkspaceIndex::new();
7247 let uri = "file:///lib/New.pm";
7248 let key = DocumentStore::uri_key(uri);
7249
7250 let shard = make_shard(uri, 1, Some(10), Some(20), Some(30), Some(40));
7251 let result = index.replace_fact_shard_incremental(&key, shard);
7252
7253 assert!(!result.content_unchanged);
7254 assert!(result.anchors_updated);
7255 assert!(result.entities_updated);
7256 assert!(result.occurrences_updated);
7257 assert!(result.edges_updated);
7258 Ok(())
7259 }
7260
7261 #[test]
7264 fn incremental_replace_none_hashes_treated_as_changed() -> Result<(), Box<dyn std::error::Error>>
7265 {
7266 let index = WorkspaceIndex::new();
7267 let uri = "file:///lib/Legacy.pm";
7268 let key = DocumentStore::uri_key(uri);
7269
7270 let shard_v1 = make_shard(uri, 1, Some(10), Some(20), Some(30), Some(40));
7272 index.replace_fact_shard_incremental(&key, shard_v1);
7273
7274 let shard_v2 = make_shard(uri, 2, None, Some(20), None, Some(40));
7275 let result = index.replace_fact_shard_incremental(&key, shard_v2);
7276
7277 assert!(!result.content_unchanged);
7278 assert!(result.anchors_updated, "None new hash → changed");
7279 assert!(!result.entities_updated, "same hash → skip");
7280 assert!(result.occurrences_updated, "None new hash → changed");
7281 assert!(!result.edges_updated, "same hash → skip");
7282 Ok(())
7283 }
7284
7285 #[test]
7288 fn incremental_replace_updates_reference_index_on_occurrence_change()
7289 -> Result<(), Box<dyn std::error::Error>> {
7290 use perl_semantic_facts::{AnchorId, Confidence, OccurrenceId, OccurrenceKind, Provenance};
7291
7292 let index = WorkspaceIndex::new();
7293 let uri = "file:///lib/RefIdx.pm";
7294 let key = DocumentStore::uri_key(uri);
7295 let file_id = {
7296 let mut h = DefaultHasher::new();
7297 uri.hash(&mut h);
7298 FileId(h.finish())
7299 };
7300
7301 let mut shard_v1 = make_shard(uri, 1, Some(10), Some(20), Some(30), Some(40));
7303 let anchor_id = AnchorId(1);
7304 shard_v1.anchors.push(perl_semantic_facts::AnchorFact {
7305 id: anchor_id,
7306 file_id,
7307 span_start_byte: 0,
7308 span_end_byte: 5,
7309 scope_id: None,
7310 provenance: Provenance::ExactAst,
7311 confidence: Confidence::High,
7312 });
7313 shard_v1.occurrences.push(perl_semantic_facts::OccurrenceFact {
7314 id: OccurrenceId(1),
7315 kind: OccurrenceKind::Call,
7316 entity_id: Some(EntityId(100)),
7317 anchor_id,
7318 scope_id: None,
7319 provenance: Provenance::ExactAst,
7320 confidence: Confidence::High,
7321 });
7322 shard_v1.entities.push(perl_semantic_facts::EntityFact {
7323 id: EntityId(100),
7324 kind: EntityKind::Subroutine,
7325 canonical_name: "RefIdx::foo".to_string(),
7326 anchor_id: Some(anchor_id),
7327 scope_id: None,
7328 provenance: Provenance::ExactAst,
7329 confidence: Confidence::High,
7330 });
7331 index.replace_fact_shard_incremental(&key, shard_v1);
7332
7333 assert!(
7335 index.semantic_reference_index.read().name_count() > 0
7336 || index.semantic_reference_index.read().entity_count() > 0,
7337 "reference index should be populated after first insert"
7338 );
7339
7340 let shard_v2_same = make_shard(uri, 1, Some(10), Some(20), Some(99), Some(99));
7342 let r = index.replace_fact_shard_incremental(&key, shard_v2_same);
7343 assert!(r.content_unchanged);
7344
7345 let mut shard_v3 = make_shard(uri, 3, Some(11), Some(21), Some(30), Some(40));
7347 shard_v3.anchors.push(perl_semantic_facts::AnchorFact {
7348 id: anchor_id,
7349 file_id,
7350 span_start_byte: 0,
7351 span_end_byte: 5,
7352 scope_id: None,
7353 provenance: Provenance::ExactAst,
7354 confidence: Confidence::High,
7355 });
7356 shard_v3.occurrences.push(perl_semantic_facts::OccurrenceFact {
7357 id: OccurrenceId(1),
7358 kind: OccurrenceKind::Call,
7359 entity_id: Some(EntityId(100)),
7360 anchor_id,
7361 scope_id: None,
7362 provenance: Provenance::ExactAst,
7363 confidence: Confidence::High,
7364 });
7365 shard_v3.entities.push(perl_semantic_facts::EntityFact {
7366 id: EntityId(100),
7367 kind: EntityKind::Subroutine,
7368 canonical_name: "RefIdx::foo".to_string(),
7369 anchor_id: Some(anchor_id),
7370 scope_id: None,
7371 provenance: Provenance::ExactAst,
7372 confidence: Confidence::High,
7373 });
7374 let r3 = index.replace_fact_shard_incremental(&key, shard_v3);
7375 assert!(!r3.occurrences_updated, "occurrence hash unchanged → skip");
7376 assert!(!r3.edges_updated, "edge hash unchanged → skip");
7377
7378 Ok(())
7379 }
7380
7381 #[test]
7384 fn index_file_stores_fact_shard_incrementally() -> Result<(), Box<dyn std::error::Error>> {
7385 let index = WorkspaceIndex::new();
7386 let uri = "file:///lib/Incr.pm";
7387 let code = "package Incr;\nsub foo { 1 }\n1;\n";
7388
7389 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
7390 let shard1 = must_some(index.file_fact_shard(uri));
7391 assert!(shard1.anchors_hash.is_some());
7392 assert!(
7393 shard1.anchors.iter().any(|anchor| anchor.provenance == Provenance::ExactAst),
7394 "index_file should store the canonical semantic shard when adapters produce facts"
7395 );
7396 assert!(
7397 shard1.entities.iter().any(|entity| entity.provenance == Provenance::ExactAst),
7398 "index_file should store canonical entities rather than legacy fallback entities"
7399 );
7400
7401 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
7403 let shard2 = must_some(index.file_fact_shard(uri));
7407 assert_eq!(shard1.content_hash, shard2.content_hash);
7408
7409 let code2 = "package Incr;\nsub bar { 2 }\n1;\n";
7411 must(index.index_file(must(url::Url::parse(uri)), code2.to_string()));
7412 let shard3 = must_some(index.file_fact_shard(uri));
7413 assert_ne!(shard1.content_hash, shard3.content_hash);
7414
7415 Ok(())
7416 }
7417
7418 #[test]
7419 fn semantic_anchor_wire_location_uses_lsp_utf16_columns()
7420 -> Result<(), Box<dyn std::error::Error>> {
7421 use crate::semantic::queries::SemanticQueries;
7422
7423 let index = WorkspaceIndex::new();
7424 let uri = "file:///lib/UnicodeAnchor.pm";
7425 let code = "package UnicodeAnchor; my $emoji = \"😀\"; sub target { 1 }\n1;\n";
7426
7427 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
7428
7429 let candidates = index
7430 .with_semantic_queries_for_uri(uri, |file_id, queries| {
7431 let ctx = crate::semantic::queries::QueryContext::new(file_id, None, Some(0));
7432 queries.definitions("UnicodeAnchor::target", &ctx)
7433 })
7434 .ok_or("missing semantic queries")?;
7435 let anchor_id = candidates
7436 .first()
7437 .map(|candidate| candidate.anchor_id)
7438 .ok_or("missing unicode definition candidate")?;
7439 let shard = index.file_fact_shard(uri).ok_or("missing fact shard")?;
7440 let anchor = shard
7441 .anchors
7442 .iter()
7443 .find(|anchor| anchor.id == anchor_id)
7444 .ok_or("missing unicode anchor")?;
7445 let start = usize::try_from(anchor.span_start_byte)?;
7446 let end = usize::try_from(anchor.span_end_byte)?;
7447 let expected = WireRange::from_byte_offsets(code, start, end);
7448
7449 let location =
7450 index.semantic_anchor_wire_location(anchor_id).ok_or("missing wire location")?;
7451
7452 assert_eq!(location.range, expected);
7453 let wire_column = usize::try_from(location.range.start.character)?;
7454 let scalar_column = code[..start].chars().count();
7455 assert!(
7456 wire_column > scalar_column,
7457 "fixture must prove the wire column counts UTF-16 units, not Unicode scalar values"
7458 );
7459
7460 Ok(())
7461 }
7462
7463 #[test]
7464 fn semantic_anchor_wire_location_fails_closed_for_duplicate_anchor_ids()
7465 -> Result<(), Box<dyn std::error::Error>> {
7466 use crate::semantic::queries::SemanticQueries;
7467
7468 let index = WorkspaceIndex::new();
7473 let code = "package DuplicateAnchor;\nsub target { 1 }\n1;\n";
7474 let uri_a = "file:///lib/DuplicateA.pm";
7475 let uri_b = "file:///lib/DuplicateB.pm";
7476
7477 must(index.index_file(must(url::Url::parse(uri_a)), code.to_string()));
7478 must(index.index_file(must(url::Url::parse(uri_b)), code.to_string()));
7479
7480 let file_id_a = index.file_id_for_uri(uri_a).ok_or("file_id_a not found")?;
7481 let file_id_b = index.file_id_for_uri(uri_b).ok_or("file_id_b not found")?;
7482
7483 let all_candidates = index
7485 .with_semantic_queries_for_uri(uri_a, |file_id, queries| {
7486 let ctx = crate::semantic::queries::QueryContext::new(file_id, None, Some(0));
7487 queries.definitions("DuplicateAnchor::target", &ctx)
7488 })
7489 .ok_or("missing semantic queries")?;
7490
7491 let anchor_id_a = all_candidates
7492 .iter()
7493 .find_map(|c| {
7494 index
7495 .semantic_anchor_wire_location_for_file(file_id_a, c.anchor_id)
7496 .map(|_| c.anchor_id)
7497 })
7498 .ok_or("no candidate found for file A")?;
7499 let anchor_id_b = all_candidates
7500 .iter()
7501 .find_map(|c| {
7502 index
7503 .semantic_anchor_wire_location_for_file(file_id_b, c.anchor_id)
7504 .map(|_| c.anchor_id)
7505 })
7506 .ok_or("no candidate found for file B")?;
7507
7508 assert_ne!(anchor_id_a, anchor_id_b, "anchor IDs must be distinct after file-scoped fix");
7510
7511 let location_a = index
7513 .semantic_anchor_wire_location(anchor_id_a)
7514 .ok_or("global lookup for anchor_id_a must succeed (no collision after fix)")?;
7515 assert_eq!(location_a.uri, uri_a, "anchor_id_a must resolve to uri_a");
7516
7517 let location_b = index
7518 .semantic_anchor_wire_location(anchor_id_b)
7519 .ok_or("global lookup for anchor_id_b must succeed (no collision after fix)")?;
7520 assert_eq!(location_b.uri, uri_b, "anchor_id_b must resolve to uri_b");
7521
7522 Ok(())
7523 }
7524
7525 #[test]
7526 fn semantic_anchor_wire_location_for_file_resolves_duplicate_anchor_ids_by_file()
7527 -> Result<(), Box<dyn std::error::Error>> {
7528 use crate::semantic::queries::SemanticQueries;
7529
7530 let index = WorkspaceIndex::new();
7535 let code = "package DuplicateAnchor;\nsub target { 1 }\n1;\n";
7536 let uri_a = "file:///lib/DuplicateA.pm";
7537 let uri_b = "file:///lib/DuplicateB.pm";
7538
7539 must(index.index_file(must(url::Url::parse(uri_a)), code.to_string()));
7540 must(index.index_file(must(url::Url::parse(uri_b)), code.to_string()));
7541
7542 let file_id_a = index.file_id_for_uri(uri_a).ok_or("file_id_a not found")?;
7543
7544 let all_candidates = index
7545 .with_semantic_queries_for_uri(uri_a, |file_id, queries| {
7546 let ctx = crate::semantic::queries::QueryContext::new(file_id, None, Some(0));
7547 queries.definitions("DuplicateAnchor::target", &ctx)
7548 })
7549 .ok_or("missing semantic queries for uri_a")?;
7550
7551 let anchor_id_a = all_candidates
7553 .iter()
7554 .find_map(|c| {
7555 index
7556 .semantic_anchor_wire_location_for_file(file_id_a, c.anchor_id)
7557 .map(|_| c.anchor_id)
7558 })
7559 .ok_or("no candidate found for file A")?;
7560
7561 let global_location = index
7563 .semantic_anchor_wire_location(anchor_id_a)
7564 .ok_or("global anchor lookup must succeed post-fix (no collision)")?;
7565 assert_eq!(global_location.uri, uri_a, "global lookup of anchor_id_a must return uri_a");
7566
7567 let file_location = index
7569 .semantic_anchor_wire_location_for_file(file_id_a, anchor_id_a)
7570 .ok_or("file-scoped anchor lookup should resolve anchor ID for file A")?;
7571 assert_eq!(file_location.uri, uri_a, "file-scoped lookup of anchor_id_a must return uri_a");
7572
7573 Ok(())
7574 }
7575
7576 mod prop_incremental_invalidation {
7579 use super::*;
7580 use proptest::prelude::*;
7581 use proptest::test_runner::Config as ProptestConfig;
7582
7583 fn arb_category_hash() -> impl Strategy<Value = Option<u64>> {
7588 prop_oneof![
7589 1 => Just(None),
7590 9 => any::<u64>().prop_map(Some),
7591 ]
7592 }
7593
7594 fn arb_shard(uri: &'static str) -> impl Strategy<Value = FileFactShard> {
7597 (
7598 any::<u64>(), arb_category_hash(), arb_category_hash(), arb_category_hash(), arb_category_hash(), )
7604 .prop_map(move |(content_hash, ah, eh, oh, edh)| {
7605 make_shard(uri, content_hash, ah, eh, oh, edh)
7606 })
7607 }
7608
7609 proptest! {
7621 #![proptest_config(ProptestConfig {
7622 failure_persistence: None,
7623 ..ProptestConfig::default()
7624 })]
7625
7626 #[test]
7627 fn prop_incremental_invalidation_correctness(
7628 old_shard in arb_shard("file:///lib/Prop.pm"),
7629 new_shard in arb_shard("file:///lib/Prop.pm"),
7630 ) {
7631 let index = WorkspaceIndex::new();
7632 let key = DocumentStore::uri_key("file:///lib/Prop.pm");
7633
7634 index.replace_fact_shard_incremental(&key, old_shard.clone());
7636
7637 let result = index.replace_fact_shard_incremental(&key, new_shard.clone());
7639
7640 if old_shard.content_hash == new_shard.content_hash {
7642 prop_assert!(
7643 result.content_unchanged,
7644 "content_unchanged must be true when content_hash is the same"
7645 );
7646 prop_assert!(
7647 !result.anchors_updated,
7648 "anchors_updated must be false when content_hash unchanged"
7649 );
7650 prop_assert!(
7651 !result.entities_updated,
7652 "entities_updated must be false when content_hash unchanged"
7653 );
7654 prop_assert!(
7655 !result.occurrences_updated,
7656 "occurrences_updated must be false when content_hash unchanged"
7657 );
7658 prop_assert!(
7659 !result.edges_updated,
7660 "edges_updated must be false when content_hash unchanged"
7661 );
7662 } else {
7663 prop_assert!(
7664 !result.content_unchanged,
7665 "content_unchanged must be false when content_hash differs"
7666 );
7667
7668 let anchors_should_update = crate::semantic::invalidation::category_hash_changed(
7674 old_shard.anchors_hash,
7675 new_shard.anchors_hash,
7676 );
7677 prop_assert_eq!(
7678 result.anchors_updated,
7679 anchors_should_update,
7680 "anchors_updated mismatch: old={:?} new={:?}",
7681 old_shard.anchors_hash,
7682 new_shard.anchors_hash,
7683 );
7684
7685 let entities_should_update =
7686 crate::semantic::invalidation::category_hash_changed(
7687 old_shard.entities_hash,
7688 new_shard.entities_hash,
7689 );
7690 prop_assert_eq!(
7691 result.entities_updated,
7692 entities_should_update,
7693 "entities_updated mismatch: old={:?} new={:?}",
7694 old_shard.entities_hash,
7695 new_shard.entities_hash,
7696 );
7697
7698 let occurrences_should_update =
7699 crate::semantic::invalidation::category_hash_changed(
7700 old_shard.occurrences_hash,
7701 new_shard.occurrences_hash,
7702 );
7703 prop_assert_eq!(
7704 result.occurrences_updated,
7705 occurrences_should_update,
7706 "occurrences_updated mismatch: old={:?} new={:?}",
7707 old_shard.occurrences_hash,
7708 new_shard.occurrences_hash,
7709 );
7710
7711 let edges_should_update = crate::semantic::invalidation::category_hash_changed(
7712 old_shard.edges_hash,
7713 new_shard.edges_hash,
7714 );
7715 prop_assert_eq!(
7716 result.edges_updated,
7717 edges_should_update,
7718 "edges_updated mismatch: old={:?} new={:?}",
7719 old_shard.edges_hash,
7720 new_shard.edges_hash,
7721 );
7722 }
7723 }
7724 }
7725 }
7726}
7727
7728#[cfg(test)]
7731mod semantic_query_callback_tests {
7732 use super::*;
7733 use perl_tdd_support::{must, must_some};
7734
7735 #[test]
7736 fn with_semantic_queries_for_uri_indexed_uri_invokes_callback()
7737 -> Result<(), Box<dyn std::error::Error>> {
7738 let index = WorkspaceIndex::new();
7739 let uri = "file:///lib/Foo.pm";
7740 must(index.index_file(must(url::Url::parse(uri)), "sub foo { 1 }".to_string()));
7741
7742 let result = index.with_semantic_queries_for_uri(uri, |file_id, _queries| {
7743 assert_ne!(file_id.0, 0, "file_id should be non-zero");
7745 42u32 });
7747
7748 assert_eq!(result, Some(42u32), "callback must run when URI is indexed");
7749 Ok(())
7750 }
7751
7752 #[test]
7753 fn with_semantic_queries_for_uri_unknown_uri_returns_none()
7754 -> Result<(), Box<dyn std::error::Error>> {
7755 let index = WorkspaceIndex::new();
7756 let result = index.with_semantic_queries_for_uri("file:///not/indexed.pl", |_, _| 99u32);
7758 assert!(result.is_none(), "unindexed URI must return None without invoking callback");
7759 Ok(())
7760 }
7761
7762 #[test]
7763 fn with_semantic_queries_for_uri_file_id_matches_file_id_for_uri()
7764 -> Result<(), Box<dyn std::error::Error>> {
7765 let index = WorkspaceIndex::new();
7766 let uri = "file:///lib/Bar.pm";
7767 must(index.index_file(must(url::Url::parse(uri)), "sub bar { 1 }".to_string()));
7768
7769 let direct_id = must_some(index.file_id_for_uri(uri));
7770 let callback_id =
7771 must_some(index.with_semantic_queries_for_uri(uri, |file_id, _q| file_id));
7772
7773 assert_eq!(
7774 direct_id, callback_id,
7775 "file_id_for_uri and with_semantic_queries_for_uri must agree"
7776 );
7777 Ok(())
7778 }
7779
7780 #[test]
7781 fn with_semantic_queries_for_uri_callback_not_called_when_not_indexed()
7782 -> Result<(), Box<dyn std::error::Error>> {
7783 let index = WorkspaceIndex::new();
7784 let mut called = false;
7785 let _ = index.with_semantic_queries_for_uri("file:///ghost.pl", |_, _| {
7786 called = true;
7787 });
7788 assert!(!called, "callback must not be invoked for unindexed URI");
7789 Ok(())
7790 }
7791
7792 #[test]
7796 fn visit_node_nested_variable_list_is_indexed() -> Result<(), Box<dyn std::error::Error>> {
7797 let index = WorkspaceIndex::new();
7798 let uri = "file:///lib/Nested.pm";
7799 let code = r#"package Nested;
7800my ($a, ($b, $c)) = (1, (2, 3));
78011;
7802"#;
7803 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
7804 let symbols = index.file_symbols(uri);
7806 let _ = symbols;
7809 Ok(())
7810 }
7811}
7812
7813#[cfg(test)]
7816mod entity_id_file_scoped_tests {
7817 use super::*;
7818 use crate::semantic::queries::SemanticQueries;
7819 use perl_tdd_support::must;
7820
7821 #[test]
7824 fn semantic_anchor_id_stable_across_reparse_same_file() -> Result<(), Box<dyn std::error::Error>>
7825 {
7826 let index = WorkspaceIndex::new();
7827 let uri = "file:///lib/Example.pm";
7828 let code = "package Example;\nsub target { 1 }\n1;\n";
7829
7830 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
7832
7833 let candidates_1 = index
7835 .with_semantic_queries_for_uri(uri, |file_id, queries| {
7836 let ctx = crate::semantic::queries::QueryContext::new(file_id, None, Some(0));
7837 queries.definitions("Example::target", &ctx)
7838 })
7839 .ok_or("missing semantic queries on first index")?;
7840 let anchor_id_1 = candidates_1
7841 .first()
7842 .map(|candidate| candidate.anchor_id)
7843 .ok_or("missing definition candidate on first index")?;
7844
7845 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
7847
7848 let candidates_2 = index
7850 .with_semantic_queries_for_uri(uri, |file_id, queries| {
7851 let ctx = crate::semantic::queries::QueryContext::new(file_id, None, Some(0));
7852 queries.definitions("Example::target", &ctx)
7853 })
7854 .ok_or("missing semantic queries on second index")?;
7855 let anchor_id_2 = candidates_2
7856 .first()
7857 .map(|candidate| candidate.anchor_id)
7858 .ok_or("missing definition candidate on second index")?;
7859
7860 assert_eq!(
7862 anchor_id_1, anchor_id_2,
7863 "anchor ID must remain stable when re-parsing identical content in same file"
7864 );
7865 Ok(())
7866 }
7867
7868 #[test]
7878 fn semantic_anchor_ids_distinct_across_files_same_content()
7879 -> Result<(), Box<dyn std::error::Error>> {
7880 let index = WorkspaceIndex::new();
7881 let code = "package DuplicateAnchor;\nsub target { 1 }\n1;\n";
7882 let uri_a = "file:///lib/DuplicateA.pm";
7883 let uri_b = "file:///lib/DuplicateB.pm";
7884
7885 must(index.index_file(must(url::Url::parse(uri_a)), code.to_string()));
7887 must(index.index_file(must(url::Url::parse(uri_b)), code.to_string()));
7888
7889 let file_id_a = index.file_id_for_uri(uri_a).ok_or("file_id_a not found")?;
7891 let file_id_b = index.file_id_for_uri(uri_b).ok_or("file_id_b not found")?;
7892
7893 let all_candidates_a = index
7895 .with_semantic_queries_for_uri(uri_a, |file_id, queries| {
7896 let ctx = crate::semantic::queries::QueryContext::new(file_id, None, Some(0));
7897 queries.definitions("DuplicateAnchor::target", &ctx)
7898 })
7899 .ok_or("with_semantic_queries_for_uri failed for uri_a")?;
7900
7901 let anchor_id_a = all_candidates_a
7904 .iter()
7905 .find_map(|c| {
7906 index
7907 .semantic_anchor_wire_location_for_file(file_id_a, c.anchor_id)
7908 .map(|_| c.anchor_id)
7909 })
7910 .ok_or("no definition candidate found for file A")?;
7911
7912 let all_candidates_b = index
7914 .with_semantic_queries_for_uri(uri_b, |file_id, queries| {
7915 let ctx = crate::semantic::queries::QueryContext::new(file_id, None, Some(0));
7916 queries.definitions("DuplicateAnchor::target", &ctx)
7917 })
7918 .ok_or("with_semantic_queries_for_uri failed for uri_b")?;
7919
7920 let anchor_id_b = all_candidates_b
7922 .iter()
7923 .find_map(|c| {
7924 index
7925 .semantic_anchor_wire_location_for_file(file_id_b, c.anchor_id)
7926 .map(|_| c.anchor_id)
7927 })
7928 .ok_or("no definition candidate found for file B")?;
7929
7930 assert_ne!(
7932 file_id_a, file_id_b,
7933 "file_id_a and file_id_b must be distinct for different URIs"
7934 );
7935
7936 assert_ne!(
7938 anchor_id_a, anchor_id_b,
7939 "anchor IDs must be distinct for identical code in different files (after file-scoped fix)"
7940 );
7941
7942 let location_a = index
7944 .semantic_anchor_wire_location(anchor_id_a)
7945 .ok_or("global lookup of anchor_id_a should succeed post-fix")?;
7946 assert_eq!(location_a.uri, uri_a, "global lookup of anchor_id_a must return uri_a");
7947
7948 let location_b = index
7950 .semantic_anchor_wire_location(anchor_id_b)
7951 .ok_or("global lookup of anchor_id_b should succeed post-fix")?;
7952 assert_eq!(location_b.uri, uri_b, "global lookup of anchor_id_b must return uri_b");
7953
7954 let location_a_scoped = index
7956 .semantic_anchor_wire_location_for_file(file_id_a, anchor_id_a)
7957 .ok_or("file-scoped lookup for (file_id_a, anchor_id_a) should succeed")?;
7958 assert_eq!(
7959 location_a_scoped.uri, uri_a,
7960 "file-scoped lookup of anchor_id_a from file_id_a must return uri_a"
7961 );
7962
7963 let location_b_scoped = index
7964 .semantic_anchor_wire_location_for_file(file_id_b, anchor_id_b)
7965 .ok_or("file-scoped lookup for (file_id_b, anchor_id_b) should succeed")?;
7966 assert_eq!(
7967 location_b_scoped.uri, uri_b,
7968 "file-scoped lookup of anchor_id_b from file_id_b must return uri_b"
7969 );
7970
7971 Ok(())
7972 }
7973
7974 #[test]
7979 #[ignore]
7980 fn semantic_anchor_wire_location_fails_closed_for_manually_injected_duplicate()
7981 -> Result<(), Box<dyn std::error::Error>> {
7982 Err(std::io::Error::new(
7992 std::io::ErrorKind::Unsupported,
7993 "synthetic anchor injection API not yet available",
7994 )
7995 .into())
7996 }
7997}
7998
7999#[cfg(test)]
8002mod file_fact_shard_serde_tests {
8003 use super::*;
8004
8005 #[test]
8006 fn file_fact_shard_serializes_and_deserializes_round_trip() {
8007 let shard = FileFactShard {
8008 source_uri: "file:///lib/My/App.pm".to_string(),
8009 file_id: FileId(42),
8010 content_hash: 12345,
8011 producer_schema_version: 1,
8012 anchors_hash: Some(100),
8013 entities_hash: Some(200),
8014 occurrences_hash: None,
8015 edges_hash: None,
8016 anchors: vec![],
8017 entities: vec![],
8018 occurrences: vec![],
8019 edges: vec![],
8020 };
8021 let json = serde_json::to_string(&shard).expect("FileFactShard must serialize");
8022 let deserialized: FileFactShard =
8023 serde_json::from_str(&json).expect("FileFactShard must deserialize");
8024
8025 assert_eq!(deserialized.source_uri, shard.source_uri);
8026 assert_eq!(deserialized.file_id, shard.file_id);
8027 assert_eq!(deserialized.content_hash, shard.content_hash);
8028 assert_eq!(deserialized.producer_schema_version, shard.producer_schema_version);
8029 assert_eq!(deserialized.anchors_hash, shard.anchors_hash);
8030 assert_eq!(deserialized.anchors.len(), 0);
8031 }
8032
8033 #[test]
8034 fn file_fact_shard_with_facts_serializes() {
8035 let shard = FileFactShard {
8036 source_uri: "file:///t/app.t".to_string(),
8037 file_id: FileId(7),
8038 content_hash: 999,
8039 producer_schema_version: 1,
8040 anchors_hash: Some(1),
8041 entities_hash: Some(2),
8042 occurrences_hash: Some(3),
8043 edges_hash: Some(4),
8044 anchors: vec![],
8045 entities: vec![],
8046 occurrences: vec![],
8047 edges: vec![],
8048 };
8049 let json = serde_json::to_string(&shard).expect("must serialize with facts");
8051 assert!(json.contains("\"source_uri\":\"file:///t/app.t\""));
8052 assert!(json.contains("\"content_hash\":999"));
8053 }
8054}