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
84pub use crate::workspace::monitoring::{
85 DegradationReason, EarlyExitReason, EarlyExitRecord, IndexInstrumentationSnapshot,
86 IndexMetrics, IndexPerformanceCaps, IndexPhase, IndexPhaseTransition, IndexResourceLimits,
87 IndexStateKind, IndexStateTransition, ResourceKind,
88};
89use perl_symbol::surface::decl::extract_symbol_decls;
90
91#[cfg(not(target_arch = "wasm32"))]
93pub use perl_uri::{fs_path_to_uri, uri_to_fs_path};
95pub use perl_uri::{is_file_uri, is_special_scheme, uri_extension, uri_key};
97
98#[derive(Clone, Debug)]
139pub enum IndexState {
140 Building {
142 phase: IndexPhase,
144 indexed_count: usize,
146 total_count: usize,
148 started_at: Instant,
150 },
151
152 Ready {
154 symbol_count: usize,
156 file_count: usize,
158 completed_at: Instant,
160 },
161
162 Degraded {
164 reason: DegradationReason,
166 available_symbols: usize,
168 since: Instant,
170 },
171}
172
173impl IndexState {
174 pub fn kind(&self) -> IndexStateKind {
176 match self {
177 IndexState::Building { .. } => IndexStateKind::Building,
178 IndexState::Ready { .. } => IndexStateKind::Ready,
179 IndexState::Degraded { .. } => IndexStateKind::Degraded,
180 }
181 }
182
183 pub fn phase(&self) -> Option<IndexPhase> {
185 match self {
186 IndexState::Building { phase, .. } => Some(*phase),
187 _ => None,
188 }
189 }
190
191 pub fn state_started_at(&self) -> Instant {
193 match self {
194 IndexState::Building { started_at, .. } => *started_at,
195 IndexState::Ready { completed_at, .. } => *completed_at,
196 IndexState::Degraded { since, .. } => *since,
197 }
198 }
199}
200
201pub struct IndexCoordinator {
253 state: Arc<RwLock<IndexState>>,
255
256 index: Arc<WorkspaceIndex>,
258
259 limits: IndexResourceLimits,
266
267 caps: IndexPerformanceCaps,
269
270 metrics: IndexMetrics,
272
273 instrumentation: IndexInstrumentation,
275}
276
277impl std::fmt::Debug for IndexCoordinator {
278 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
279 f.debug_struct("IndexCoordinator")
280 .field("state", &*self.state.read())
281 .field("limits", &self.limits)
282 .field("caps", &self.caps)
283 .finish_non_exhaustive()
284 }
285}
286
287impl IndexCoordinator {
288 pub fn new() -> Self {
305 Self {
306 state: Arc::new(RwLock::new(IndexState::Building {
307 phase: IndexPhase::Idle,
308 indexed_count: 0,
309 total_count: 0,
310 started_at: Instant::now(),
311 })),
312 index: Arc::new(WorkspaceIndex::new()),
313 limits: IndexResourceLimits::default(),
314 caps: IndexPerformanceCaps::default(),
315 metrics: IndexMetrics::new(),
316 instrumentation: IndexInstrumentation::new(),
317 }
318 }
319
320 pub fn with_limits(limits: IndexResourceLimits) -> Self {
339 Self {
340 state: Arc::new(RwLock::new(IndexState::Building {
341 phase: IndexPhase::Idle,
342 indexed_count: 0,
343 total_count: 0,
344 started_at: Instant::now(),
345 })),
346 index: Arc::new(WorkspaceIndex::new()),
347 limits,
348 caps: IndexPerformanceCaps::default(),
349 metrics: IndexMetrics::new(),
350 instrumentation: IndexInstrumentation::new(),
351 }
352 }
353
354 pub fn with_limits_and_caps(limits: IndexResourceLimits, caps: IndexPerformanceCaps) -> Self {
361 Self {
362 state: Arc::new(RwLock::new(IndexState::Building {
363 phase: IndexPhase::Idle,
364 indexed_count: 0,
365 total_count: 0,
366 started_at: Instant::now(),
367 })),
368 index: Arc::new(WorkspaceIndex::new()),
369 limits,
370 caps,
371 metrics: IndexMetrics::new(),
372 instrumentation: IndexInstrumentation::new(),
373 }
374 }
375
376 pub fn state(&self) -> IndexState {
401 self.state.read().clone()
402 }
403
404 pub fn index(&self) -> &Arc<WorkspaceIndex> {
422 &self.index
423 }
424
425 pub fn limits(&self) -> &IndexResourceLimits {
427 &self.limits
428 }
429
430 pub fn performance_caps(&self) -> &IndexPerformanceCaps {
432 &self.caps
433 }
434
435 pub fn instrumentation_snapshot(&self) -> IndexInstrumentationSnapshot {
437 self.instrumentation.snapshot()
438 }
439
440 pub fn notify_change(&self, _uri: &str) {
462 let pending = self.metrics.increment_pending_parses();
463
464 if self.metrics.is_parse_storm() {
466 self.transition_to_degraded(DegradationReason::ParseStorm { pending_parses: pending });
467 }
468 }
469
470 pub fn notify_parse_complete(&self, _uri: &str) {
492 let pending = self.metrics.decrement_pending_parses();
493
494 if pending == 0 {
496 if let IndexState::Degraded { reason: DegradationReason::ParseStorm { .. }, .. } =
497 self.state()
498 {
499 let mut state = self.state.write();
501 let from_kind = state.kind();
502 self.instrumentation.record_state_transition(from_kind, IndexStateKind::Building);
503 *state = IndexState::Building {
504 phase: IndexPhase::Idle,
505 indexed_count: 0,
506 total_count: 0,
507 started_at: Instant::now(),
508 };
509 }
510 }
511
512 self.enforce_limits();
514 }
515
516 pub fn transition_to_ready(&self, file_count: usize, symbol_count: usize) {
546 let mut state = self.state.write();
547 let from_kind = state.kind();
548
549 match &*state {
551 IndexState::Building { .. } | IndexState::Degraded { .. } => {
552 *state =
554 IndexState::Ready { symbol_count, file_count, completed_at: Instant::now() };
555 }
556 IndexState::Ready { .. } => {
557 *state =
559 IndexState::Ready { symbol_count, file_count, completed_at: Instant::now() };
560 }
561 }
562 self.instrumentation.record_state_transition(from_kind, IndexStateKind::Ready);
563 drop(state); self.enforce_limits();
567 }
568
569 pub fn transition_to_scanning(&self) {
573 let mut state = self.state.write();
574 let from_kind = state.kind();
575
576 match &*state {
577 IndexState::Building { phase, indexed_count, total_count, started_at } => {
578 if *phase != IndexPhase::Scanning {
579 self.instrumentation.record_phase_transition(*phase, IndexPhase::Scanning);
580 }
581 *state = IndexState::Building {
582 phase: IndexPhase::Scanning,
583 indexed_count: *indexed_count,
584 total_count: *total_count,
585 started_at: *started_at,
586 };
587 }
588 IndexState::Ready { .. } | IndexState::Degraded { .. } => {
589 self.instrumentation.record_state_transition(from_kind, IndexStateKind::Building);
590 self.instrumentation
591 .record_phase_transition(IndexPhase::Idle, IndexPhase::Scanning);
592 *state = IndexState::Building {
593 phase: IndexPhase::Scanning,
594 indexed_count: 0,
595 total_count: 0,
596 started_at: Instant::now(),
597 };
598 }
599 }
600 }
601
602 pub fn update_scan_progress(&self, total_count: usize) {
604 let mut state = self.state.write();
605 if let IndexState::Building { phase, indexed_count, started_at, .. } = &*state {
606 if *phase != IndexPhase::Scanning {
607 self.instrumentation.record_phase_transition(*phase, IndexPhase::Scanning);
608 }
609 *state = IndexState::Building {
610 phase: IndexPhase::Scanning,
611 indexed_count: *indexed_count,
612 total_count,
613 started_at: *started_at,
614 };
615 }
616 }
617
618 pub fn transition_to_indexing(&self, total_count: usize) {
622 let mut state = self.state.write();
623 let from_kind = state.kind();
624
625 match &*state {
626 IndexState::Building { phase, indexed_count, started_at, .. } => {
627 if *phase != IndexPhase::Indexing {
628 self.instrumentation.record_phase_transition(*phase, IndexPhase::Indexing);
629 }
630 *state = IndexState::Building {
631 phase: IndexPhase::Indexing,
632 indexed_count: *indexed_count,
633 total_count,
634 started_at: *started_at,
635 };
636 }
637 IndexState::Ready { .. } | IndexState::Degraded { .. } => {
638 self.instrumentation.record_state_transition(from_kind, IndexStateKind::Building);
639 self.instrumentation
640 .record_phase_transition(IndexPhase::Idle, IndexPhase::Indexing);
641 *state = IndexState::Building {
642 phase: IndexPhase::Indexing,
643 indexed_count: 0,
644 total_count,
645 started_at: Instant::now(),
646 };
647 }
648 }
649 }
650
651 pub fn transition_to_building(&self, total_count: usize) {
655 let mut state = self.state.write();
656 let from_kind = state.kind();
657
658 match &*state {
660 IndexState::Degraded { .. } | IndexState::Ready { .. } => {
661 self.instrumentation.record_state_transition(from_kind, IndexStateKind::Building);
662 self.instrumentation
663 .record_phase_transition(IndexPhase::Idle, IndexPhase::Indexing);
664 *state = IndexState::Building {
665 phase: IndexPhase::Indexing,
666 indexed_count: 0,
667 total_count,
668 started_at: Instant::now(),
669 };
670 }
671 IndexState::Building { phase, indexed_count, started_at, .. } => {
672 let mut next_phase = *phase;
673 if *phase == IndexPhase::Idle {
674 self.instrumentation
675 .record_phase_transition(IndexPhase::Idle, IndexPhase::Indexing);
676 next_phase = IndexPhase::Indexing;
677 }
678 *state = IndexState::Building {
679 phase: next_phase,
680 indexed_count: *indexed_count,
681 total_count,
682 started_at: *started_at,
683 };
684 }
685 }
686 }
687
688 pub fn update_building_progress(&self, indexed_count: usize) {
710 let mut state = self.state.write();
711
712 if let IndexState::Building { phase, started_at, total_count, .. } = &*state {
713 let elapsed = started_at.elapsed().as_millis() as u64;
714
715 if elapsed > self.limits.max_scan_duration_ms {
717 drop(state);
719 self.transition_to_degraded(DegradationReason::ScanTimeout { elapsed_ms: elapsed });
720 return;
721 }
722
723 *state = IndexState::Building {
725 phase: *phase,
726 indexed_count,
727 total_count: *total_count,
728 started_at: *started_at,
729 };
730 }
731 }
732
733 pub fn transition_to_degraded(&self, reason: DegradationReason) {
758 let mut state = self.state.write();
759 let from_kind = state.kind();
760
761 let available_symbols = match &*state {
763 IndexState::Ready { symbol_count, .. } => *symbol_count,
764 IndexState::Degraded { available_symbols, .. } => *available_symbols,
765 IndexState::Building { .. } => 0,
766 };
767
768 self.instrumentation.record_state_transition(from_kind, IndexStateKind::Degraded);
769 *state = IndexState::Degraded { reason, available_symbols, since: Instant::now() };
770 }
771
772 pub fn check_limits(&self) -> Option<DegradationReason> {
803 let files = self.index.files.read();
804
805 let file_count = files.len();
807 if file_count > self.limits.max_files {
808 return Some(DegradationReason::ResourceLimit { kind: ResourceKind::MaxFiles });
809 }
810
811 let total_symbols: usize = files.values().map(|fi| fi.symbols.len()).sum();
813 if total_symbols > self.limits.max_total_symbols {
814 return Some(DegradationReason::ResourceLimit { kind: ResourceKind::MaxSymbols });
815 }
816
817 None
818 }
819
820 pub fn enforce_limits(&self) {
846 if let Some(reason) = self.check_limits() {
847 self.transition_to_degraded(reason);
848 }
849 }
850
851 pub fn record_early_exit(
853 &self,
854 reason: EarlyExitReason,
855 elapsed_ms: u64,
856 indexed_files: usize,
857 total_files: usize,
858 ) {
859 self.instrumentation.record_early_exit(EarlyExitRecord {
860 reason,
861 elapsed_ms,
862 indexed_files,
863 total_files,
864 });
865 }
866
867 pub fn query<T, F1, F2>(&self, full_query: F1, partial_query: F2) -> T
900 where
901 F1: FnOnce(&WorkspaceIndex) -> T,
902 F2: FnOnce(&WorkspaceIndex) -> T,
903 {
904 match self.state() {
905 IndexState::Ready { .. } => full_query(&self.index),
906 _ => partial_query(&self.index),
907 }
908 }
909}
910
911impl Default for IndexCoordinator {
912 fn default() -> Self {
913 Self::new()
914 }
915}
916
917#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
922pub enum SymKind {
924 Var,
926 Sub,
928 Pack,
930}
931
932#[derive(Clone, Debug, Eq, PartialEq, Hash)]
933pub struct SymbolKey {
935 pub pkg: Arc<str>,
937 pub name: Arc<str>,
939 pub sigil: Option<char>,
941 pub kind: SymKind,
943}
944
945pub fn normalize_var(name: &str) -> (Option<char>, &str) {
966 if name.is_empty() {
967 return (None, "");
968 }
969
970 let Some(first_char) = name.chars().next() else {
972 return (None, name); };
974 match first_char {
975 '$' | '@' | '%' => {
976 if name.len() > 1 {
977 (Some(first_char), &name[1..])
978 } else {
979 (Some(first_char), "")
980 }
981 }
982 _ => (None, name),
983 }
984}
985
986#[derive(Debug, Clone, PartialEq, Eq)]
989pub struct Location {
991 pub uri: String,
993 pub range: Range,
995}
996
997#[derive(Debug, Clone, PartialEq, Eq)]
998pub struct SymbolIdentity {
1000 pub stable_key: String,
1002 pub name: String,
1004 pub qualified_name: Option<String>,
1006 pub kind: SymbolKind,
1008}
1009
1010#[derive(Debug, Clone, PartialEq, Eq)]
1011pub struct CrossFileReferenceQueryResult {
1013 pub symbol: SymbolIdentity,
1015 pub definition: Location,
1017 pub references: Vec<Location>,
1019}
1020
1021#[derive(Debug, Clone, Serialize, Deserialize)]
1022pub struct WorkspaceSymbol {
1024 pub name: String,
1026 pub kind: SymbolKind,
1028 pub uri: String,
1030 pub range: Range,
1032 pub qualified_name: Option<String>,
1034 pub documentation: Option<String>,
1036 pub container_name: Option<String>,
1038 #[serde(default = "default_has_body")]
1040 pub has_body: bool,
1041 pub workspace_folder_uri: Option<String>,
1043}
1044
1045fn default_has_body() -> bool {
1046 true
1047}
1048
1049pub use perl_symbol::{SymbolKind, VarKind};
1052
1053#[derive(Debug, Clone)]
1054pub struct SymbolReference {
1056 pub uri: String,
1058 pub range: Range,
1060 pub kind: ReferenceKind,
1062}
1063
1064#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1065pub enum ReferenceKind {
1067 Definition,
1069 Usage,
1071 Import,
1073 Read,
1075 Write,
1077}
1078
1079#[derive(Debug, Serialize)]
1080#[serde(rename_all = "camelCase")]
1081pub struct LspWorkspaceSymbol {
1083 pub name: String,
1085 pub kind: u32,
1087 pub location: WireLocation,
1089 #[serde(skip_serializing_if = "Option::is_none")]
1091 pub container_name: Option<String>,
1092 #[serde(skip_serializing_if = "Option::is_none")]
1094 pub workspace_folder_uri: Option<String>,
1095}
1096
1097impl From<&WorkspaceSymbol> for LspWorkspaceSymbol {
1098 fn from(sym: &WorkspaceSymbol) -> Self {
1099 let range = WireRange {
1100 start: WirePosition { line: sym.range.start.line, character: sym.range.start.column },
1101 end: WirePosition { line: sym.range.end.line, character: sym.range.end.column },
1102 };
1103
1104 Self {
1105 name: sym.name.clone(),
1106 kind: sym.kind.to_lsp_kind(),
1107 location: WireLocation { uri: sym.uri.clone(), range },
1108 container_name: sym.container_name.clone(),
1109 workspace_folder_uri: sym.workspace_folder_uri.clone(),
1110 }
1111 }
1112}
1113
1114#[derive(Default, Clone)]
1116pub struct FileIndex {
1117 source_uri: String,
1119 symbols: Vec<WorkspaceSymbol>,
1121 references: HashMap<String, Vec<SymbolReference>>,
1123 dependencies: HashSet<String>,
1125 content_hash: u64,
1127 folder_uri: Option<String>,
1129}
1130
1131#[derive(Clone)]
1133pub struct FileFactShard {
1134 pub source_uri: String,
1136 pub file_id: FileId,
1138 pub content_hash: u64,
1140 pub anchors_hash: Option<u64>,
1142 pub entities_hash: Option<u64>,
1144 pub occurrences_hash: Option<u64>,
1146 pub edges_hash: Option<u64>,
1148 pub anchors: Vec<AnchorFact>,
1150 pub entities: Vec<EntityFact>,
1152 pub occurrences: Vec<perl_semantic_facts::OccurrenceFact>,
1154 pub edges: Vec<EdgeFact>,
1156}
1157
1158pub struct WorkspaceIndex {
1160 files: Arc<RwLock<HashMap<String, FileIndex>>>,
1162 symbols: Arc<RwLock<HashMap<String, Vec<DefinitionCandidate>>>>,
1164 global_references: Arc<RwLock<HashMap<String, Vec<Location>>>>,
1169 fact_shards: Arc<RwLock<HashMap<String, FileFactShard>>>,
1171 document_store: DocumentStore,
1173 workspace_folders: Arc<RwLock<Vec<String>>>,
1178}
1179
1180#[derive(Debug, Clone, Eq, PartialEq)]
1181struct DefinitionCandidate {
1182 location: Location,
1183 kind: SymbolKind,
1184}
1185
1186impl WorkspaceIndex {
1187 fn location_sort_key(location: &Location) -> (&str, u32, u32, u32, u32) {
1188 (
1189 location.uri.as_str(),
1190 location.range.start.line,
1191 location.range.start.column,
1192 location.range.end.line,
1193 location.range.end.column,
1194 )
1195 }
1196
1197 fn sort_locations_deterministically(locations: &mut [Location]) {
1198 locations.sort_by(|left, right| {
1199 Self::location_sort_key(left).cmp(&Self::location_sort_key(right))
1200 });
1201 }
1202
1203 fn definition_candidate_sort_key(
1204 candidate: &DefinitionCandidate,
1205 ) -> (u8, &str, u32, u32, u32, u32) {
1206 let rank = match candidate.kind {
1207 SymbolKind::Subroutine | SymbolKind::Method => 0,
1208 SymbolKind::Constant => 1,
1209 _ => 2,
1210 };
1211 (
1212 rank,
1213 candidate.location.uri.as_str(),
1214 candidate.location.range.start.line,
1215 candidate.location.range.start.column,
1216 candidate.location.range.end.line,
1217 candidate.location.range.end.column,
1218 )
1219 }
1220
1221 fn rebuild_symbol_cache(
1222 files: &HashMap<String, FileIndex>,
1223 symbols: &mut HashMap<String, Vec<DefinitionCandidate>>,
1224 ) {
1225 symbols.clear();
1226
1227 for file_index in files.values() {
1228 for symbol in &file_index.symbols {
1229 if let Some(ref qname) = symbol.qualified_name {
1230 symbols.entry(qname.clone()).or_default().push(DefinitionCandidate {
1231 location: Location { uri: symbol.uri.clone(), range: symbol.range },
1232 kind: symbol.kind,
1233 });
1234 }
1235 symbols.entry(symbol.name.clone()).or_default().push(DefinitionCandidate {
1236 location: Location { uri: symbol.uri.clone(), range: symbol.range },
1237 kind: symbol.kind,
1238 });
1239 }
1240 }
1241 for entries in symbols.values_mut() {
1242 entries.sort_by(|left, right| {
1243 Self::definition_candidate_sort_key(left)
1244 .cmp(&Self::definition_candidate_sort_key(right))
1245 });
1246 entries.dedup();
1247 }
1248 }
1249
1250 fn incremental_remove_symbols(
1253 files: &HashMap<String, FileIndex>,
1254 symbols: &mut HashMap<String, Vec<DefinitionCandidate>>,
1255 old_file_index: &FileIndex,
1256 ) {
1257 let mut affected_names: Vec<String> = Vec::new();
1258 for sym in &old_file_index.symbols {
1259 if let Some(ref qname) = sym.qualified_name {
1260 let mut remove_key = false;
1261 if let Some(entries) = symbols.get_mut(qname) {
1262 entries.retain(|candidate| candidate.location.uri != sym.uri);
1263 remove_key = entries.is_empty();
1264 }
1265 if remove_key {
1266 symbols.remove(qname);
1267 affected_names.push(qname.clone());
1268 }
1269 }
1270 let mut remove_key = false;
1271 if let Some(entries) = symbols.get_mut(&sym.name) {
1272 entries.retain(|candidate| candidate.location.uri != sym.uri);
1273 remove_key = entries.is_empty();
1274 }
1275 if remove_key {
1276 symbols.remove(&sym.name);
1277 affected_names.push(sym.name.clone());
1278 }
1279 }
1280 if !affected_names.is_empty() {
1281 symbols.clear();
1282 for file_index in files
1283 .values()
1284 .filter(|file_index| file_index.source_uri != old_file_index.source_uri)
1285 {
1286 for symbol in &file_index.symbols {
1287 if let Some(ref qname) = symbol.qualified_name {
1288 symbols.entry(qname.clone()).or_default().push(DefinitionCandidate {
1289 location: Location { uri: symbol.uri.clone(), range: symbol.range },
1290 kind: symbol.kind,
1291 });
1292 }
1293 symbols.entry(symbol.name.clone()).or_default().push(DefinitionCandidate {
1294 location: Location { uri: symbol.uri.clone(), range: symbol.range },
1295 kind: symbol.kind,
1296 });
1297 }
1298 }
1299 for entries in symbols.values_mut() {
1300 entries.sort_by(|left, right| {
1301 Self::definition_candidate_sort_key(left)
1302 .cmp(&Self::definition_candidate_sort_key(right))
1303 });
1304 entries.dedup();
1305 }
1306 }
1307 }
1308
1309 fn incremental_add_symbols(
1311 symbols: &mut HashMap<String, Vec<DefinitionCandidate>>,
1312 file_index: &FileIndex,
1313 ) {
1314 for sym in &file_index.symbols {
1315 if let Some(ref qname) = sym.qualified_name {
1316 symbols.entry(qname.clone()).or_default().push(DefinitionCandidate {
1317 location: Location { uri: sym.uri.clone(), range: sym.range },
1318 kind: sym.kind,
1319 });
1320 }
1321 symbols.entry(sym.name.clone()).or_default().push(DefinitionCandidate {
1322 location: Location { uri: sym.uri.clone(), range: sym.range },
1323 kind: sym.kind,
1324 });
1325 }
1326 for entries in symbols.values_mut() {
1327 entries.sort_by(|left, right| {
1328 Self::definition_candidate_sort_key(left)
1329 .cmp(&Self::definition_candidate_sort_key(right))
1330 });
1331 entries.dedup();
1332 }
1333 }
1334
1335 fn determine_folder_uri(&self, file_uri: &str) -> Option<String> {
1364 let folders = self.workspace_folders.read();
1365 let mut best_match: Option<&String> = None;
1366 for folder_uri in folders.iter() {
1367 let folder_with_slash = if folder_uri.ends_with('/') {
1370 folder_uri.clone()
1371 } else {
1372 format!("{}/", folder_uri)
1373 };
1374 if file_uri.starts_with(&folder_with_slash) || file_uri == folder_uri {
1375 match best_match {
1376 Some(existing) if existing.len() >= folder_uri.len() => {}
1377 _ => best_match = Some(folder_uri),
1378 }
1379 }
1380 }
1381 best_match.cloned()
1382 }
1383
1384 fn find_definition_in_files(
1385 files: &HashMap<String, FileIndex>,
1386 symbol_name: &str,
1387 uri_filter: Option<&str>,
1388 ) -> Option<(Location, String)> {
1389 let mut candidates: Vec<(Location, String)> = Vec::new();
1390 for file_index in files.values() {
1391 if let Some(filter) = uri_filter
1392 && file_index.symbols.first().is_some_and(|symbol| symbol.uri != filter)
1393 {
1394 continue;
1395 }
1396
1397 for symbol in &file_index.symbols {
1398 if symbol.name == symbol_name
1399 || symbol.qualified_name.as_deref() == Some(symbol_name)
1400 {
1401 candidates.push((
1402 Location { uri: symbol.uri.clone(), range: symbol.range },
1403 symbol.uri.clone(),
1404 ));
1405 }
1406 }
1407 }
1408
1409 candidates.sort_by(|left, right| {
1410 Self::location_sort_key(&left.0).cmp(&Self::location_sort_key(&right.0))
1411 });
1412 candidates.into_iter().next()
1413 }
1414
1415 fn find_symbol_by_definition(
1416 &self,
1417 definition: &Location,
1418 symbol_name: &str,
1419 ) -> Option<WorkspaceSymbol> {
1420 let files = self.files.read();
1421 files
1422 .values()
1423 .flat_map(|file_index| file_index.symbols.iter())
1424 .filter(|symbol| {
1425 symbol.uri == definition.uri
1426 && symbol.range == definition.range
1427 && (symbol.name == symbol_name
1428 || symbol.qualified_name.as_deref() == Some(symbol_name))
1429 })
1430 .min_by(|left, right| {
1431 (
1432 left.qualified_name.as_deref().unwrap_or_default(),
1433 left.name.as_str(),
1434 left.kind.to_lsp_kind(),
1435 )
1436 .cmp(&(
1437 right.qualified_name.as_deref().unwrap_or_default(),
1438 right.name.as_str(),
1439 right.kind.to_lsp_kind(),
1440 ))
1441 })
1442 .cloned()
1443 }
1444
1445 fn has_unique_symbol_name_and_kind(&self, target: &WorkspaceSymbol) -> bool {
1446 let files = self.files.read();
1447 files
1448 .values()
1449 .flat_map(|file_index| file_index.symbols.iter())
1450 .filter(|symbol| symbol.name == target.name && symbol.kind == target.kind)
1451 .take(2)
1452 .count()
1453 == 1
1454 }
1455
1456 fn collect_symbol_references(&self, symbol: &WorkspaceSymbol) -> Vec<Location> {
1457 let mut names_to_query: Vec<&str> = Vec::new();
1458 if let Some(qualified_name) = symbol.qualified_name.as_deref() {
1459 names_to_query.push(qualified_name);
1460 if self.has_unique_symbol_name_and_kind(symbol) {
1461 names_to_query.push(symbol.name.as_str());
1462 }
1463 } else {
1464 names_to_query.push(symbol.name.as_str());
1465 }
1466
1467 let global_refs = self.global_references.read();
1468 let mut seen: HashSet<(String, u32, u32, u32, u32)> = HashSet::new();
1469 let mut locations = Vec::new();
1470
1471 for symbol_name in names_to_query {
1472 if let Some(refs) = global_refs.get(symbol_name) {
1473 for location in refs {
1474 let key = (
1475 location.uri.clone(),
1476 location.range.start.line,
1477 location.range.start.column,
1478 location.range.end.line,
1479 location.range.end.column,
1480 );
1481 if seen.insert(key) {
1482 locations.push(location.clone());
1483 }
1484 }
1485 }
1486 }
1487 drop(global_refs);
1488
1489 Self::sort_locations_deterministically(&mut locations);
1490 locations
1491 }
1492
1493 pub fn new() -> Self {
1508 Self {
1509 files: Arc::new(RwLock::new(HashMap::new())),
1510 symbols: Arc::new(RwLock::new(HashMap::new())),
1511 global_references: Arc::new(RwLock::new(HashMap::new())),
1512 fact_shards: Arc::new(RwLock::new(HashMap::new())),
1513 document_store: DocumentStore::new(),
1514 workspace_folders: Arc::new(RwLock::new(Vec::new())),
1515 }
1516 }
1517
1518 pub fn with_capacity(estimated_files: usize, avg_symbols_per_file: usize) -> Self {
1543 let sym_cap =
1545 estimated_files.saturating_mul(avg_symbols_per_file).saturating_mul(2).min(1_000_000);
1546 let ref_cap = (sym_cap / 4).min(1_000_000);
1547 Self {
1548 files: Arc::new(RwLock::new(HashMap::with_capacity(estimated_files))),
1549 symbols: Arc::new(RwLock::new(HashMap::with_capacity(sym_cap))),
1550 global_references: Arc::new(RwLock::new(HashMap::with_capacity(ref_cap))),
1551 fact_shards: Arc::new(RwLock::new(HashMap::with_capacity(estimated_files))),
1552 document_store: DocumentStore::new(),
1553 workspace_folders: Arc::new(RwLock::new(Vec::new())),
1554 }
1555 }
1556
1557 pub fn set_workspace_folders(&self, folders: Vec<String>) {
1578 let mut workspace_folders = self.workspace_folders.write();
1579 *workspace_folders = folders;
1580 }
1581
1582 #[must_use]
1588 pub fn workspace_folders(&self) -> Vec<String> {
1589 self.workspace_folders.read().clone()
1590 }
1591
1592 fn normalize_uri(uri: &str) -> String {
1594 perl_uri::normalize_uri(uri)
1595 }
1596
1597 fn remove_file_global_refs(
1602 global_refs: &mut HashMap<String, Vec<Location>>,
1603 file_index: &FileIndex,
1604 file_uri: &str,
1605 ) {
1606 for name in file_index.references.keys() {
1607 if let Some(locs) = global_refs.get_mut(name) {
1608 locs.retain(|loc| loc.uri != file_uri);
1609 if locs.is_empty() {
1610 global_refs.remove(name);
1611 }
1612 }
1613 }
1614 }
1615
1616 pub fn index_file(&self, uri: Url, text: String) -> Result<(), String> {
1647 let uri_str = uri.to_string();
1648
1649 let mut hasher = DefaultHasher::new();
1651 text.hash(&mut hasher);
1652 let content_hash = hasher.finish();
1653
1654 let key = DocumentStore::uri_key(&uri_str);
1656 {
1657 let files = self.files.read();
1658 if let Some(existing_index) = files.get(&key) {
1659 if existing_index.content_hash == content_hash {
1660 return Ok(());
1662 }
1663 }
1664 }
1665
1666 if self.document_store.is_open(&uri_str) {
1668 self.document_store.update(&uri_str, 1, text.clone());
1669 } else {
1670 self.document_store.open(uri_str.clone(), 1, text.clone());
1671 }
1672
1673 let mut parser = Parser::new(&text);
1675 let ast = match parser.parse() {
1676 Ok(ast) => ast,
1677 Err(e) => return Err(format!("Parse error: {}", e)),
1678 };
1679
1680 let mut doc = self.document_store.get(&uri_str).ok_or("Document not found")?;
1682
1683 let folder_uri = self.determine_folder_uri(&uri_str);
1685
1686 let mut file_index = FileIndex {
1688 source_uri: uri_str.clone(),
1689 content_hash,
1690 folder_uri: folder_uri.clone(),
1691 ..Default::default()
1692 };
1693 let mut visitor = IndexVisitor::new(&mut doc, uri_str.clone(), folder_uri);
1694 visitor.visit(&ast, &mut file_index);
1695
1696 let fact_shard = Self::build_fact_shard(&uri_str, content_hash, &file_index);
1697
1698 {
1701 let mut files = self.files.write();
1702
1703 if let Some(old_index) = files.get(&key) {
1705 let mut global_refs = self.global_references.write();
1706 Self::remove_file_global_refs(&mut global_refs, old_index, &uri_str);
1707 }
1708
1709 if let Some(old_index) = files.get(&key) {
1711 let mut symbols = self.symbols.write();
1712 Self::incremental_remove_symbols(&files, &mut symbols, old_index);
1713 drop(symbols);
1714 }
1715 files.insert(key.clone(), file_index);
1716 let mut symbols = self.symbols.write();
1717 if let Some(new_index) = files.get(&key) {
1718 Self::incremental_add_symbols(&mut symbols, new_index);
1719 }
1720
1721 if let Some(file_index) = files.get(&key) {
1722 let mut global_refs = self.global_references.write();
1723 for (name, refs) in &file_index.references {
1724 let entry = global_refs.entry(name.clone()).or_default();
1725 for reference in refs {
1726 entry.push(Location { uri: reference.uri.clone(), range: reference.range });
1727 }
1728 }
1729 }
1730 self.fact_shards.write().insert(key, fact_shard);
1731 }
1732
1733 Ok(())
1734 }
1735
1736 pub fn remove_file(&self, uri: &str) {
1755 let uri_str = Self::normalize_uri(uri);
1756 let key = DocumentStore::uri_key(&uri_str);
1757
1758 self.document_store.close(&uri_str);
1760
1761 let mut files = self.files.write();
1763 if let Some(file_index) = files.remove(&key) {
1764 self.fact_shards.write().remove(&key);
1765 let mut symbols = self.symbols.write();
1767 Self::incremental_remove_symbols(&files, &mut symbols, &file_index);
1768
1769 if let Some(indexed_uri) = file_index.symbols.first().map(|s| s.uri.as_str()) {
1778 symbols.retain(|_, candidates| {
1779 candidates.retain(|candidate| candidate.location.uri.as_str() != indexed_uri);
1780 !candidates.is_empty()
1781 });
1782 }
1783
1784 let mut global_refs = self.global_references.write();
1786 Self::remove_file_global_refs(&mut global_refs, &file_index, &uri_str);
1787 }
1788 }
1789
1790 pub fn remove_file_url(&self, uri: &Url) {
1814 self.remove_file(uri.as_str())
1815 }
1816
1817 pub fn clear_file(&self, uri: &str) {
1836 self.remove_file(uri);
1837 }
1838
1839 pub fn clear_file_url(&self, uri: &Url) {
1863 self.clear_file(uri.as_str())
1864 }
1865
1866 pub fn remove_folder(&self, folder_uri: &str) {
1886 let mut uris_to_remove = Vec::new();
1887 let files = self.files.read();
1888
1889 for file_index in files.values() {
1891 if file_index.folder_uri.as_deref() == Some(folder_uri) {
1892 uris_to_remove.push(file_index.source_uri.clone());
1893 }
1894 }
1895 drop(files);
1896
1897 for uri in uris_to_remove {
1900 self.remove_file(&uri);
1901 }
1902 }
1903
1904 #[cfg(not(target_arch = "wasm32"))]
1905 pub fn index_file_str(&self, uri: &str, text: &str) -> Result<(), String> {
1935 let path = Path::new(uri);
1936 let url = if path.is_absolute() {
1937 url::Url::from_file_path(path)
1938 .map_err(|_| format!("Invalid URI or file path: {}", uri))?
1939 } else {
1940 url::Url::parse(uri).or_else(|_| {
1943 url::Url::from_file_path(path)
1944 .map_err(|_| format!("Invalid URI or file path: {}", uri))
1945 })?
1946 };
1947 self.index_file(url, text.to_string())
1948 }
1949
1950 pub fn index_files_batch(&self, files_to_index: Vec<(Url, String)>) -> Vec<String> {
1959 let mut errors = Vec::new();
1960
1961 let mut parsed: Vec<(String, String, FileIndex)> = Vec::with_capacity(files_to_index.len());
1963 for (uri, text) in &files_to_index {
1964 let uri_str = uri.to_string();
1965
1966 let mut hasher = DefaultHasher::new();
1968 text.hash(&mut hasher);
1969 let content_hash = hasher.finish();
1970
1971 let key = DocumentStore::uri_key(&uri_str);
1972
1973 {
1975 let files = self.files.read();
1976 if let Some(existing) = files.get(&key) {
1977 if existing.content_hash == content_hash {
1978 continue;
1979 }
1980 }
1981 }
1982
1983 if self.document_store.is_open(&uri_str) {
1985 self.document_store.update(&uri_str, 1, text.clone());
1986 } else {
1987 self.document_store.open(uri_str.clone(), 1, text.clone());
1988 }
1989
1990 let mut parser = Parser::new(text);
1992 let ast = match parser.parse() {
1993 Ok(ast) => ast,
1994 Err(e) => {
1995 errors.push(format!("Parse error in {}: {}", uri_str, e));
1996 continue;
1997 }
1998 };
1999
2000 let mut doc = match self.document_store.get(&uri_str) {
2001 Some(d) => d,
2002 None => {
2003 errors.push(format!("Document not found: {}", uri_str));
2004 continue;
2005 }
2006 };
2007
2008 let folder_uri = self.determine_folder_uri(&uri_str);
2010
2011 let mut file_index = FileIndex {
2012 source_uri: uri_str.clone(),
2013 content_hash,
2014 folder_uri: folder_uri.clone(),
2015 ..Default::default()
2016 };
2017 let mut visitor = IndexVisitor::new(&mut doc, uri_str.clone(), folder_uri);
2018 visitor.visit(&ast, &mut file_index);
2019
2020 parsed.push((key, uri_str, file_index));
2021 }
2022
2023 {
2025 let mut files = self.files.write();
2026 let mut symbols = self.symbols.write();
2027 let mut global_refs = self.global_references.write();
2028
2029 files.reserve(parsed.len());
2032 symbols.reserve(parsed.len().saturating_mul(20).saturating_mul(2));
2033
2034 for (key, uri_str, file_index) in parsed {
2035 if let Some(old_index) = files.get(&key) {
2037 Self::remove_file_global_refs(&mut global_refs, old_index, &uri_str);
2038 }
2039
2040 files.insert(key.clone(), file_index);
2041
2042 if let Some(fi) = files.get(&key) {
2044 for (name, refs) in &fi.references {
2045 let entry = global_refs.entry(name.clone()).or_default();
2046 for reference in refs {
2047 entry.push(Location {
2048 uri: reference.uri.clone(),
2049 range: reference.range,
2050 });
2051 }
2052 }
2053 }
2054 }
2055
2056 Self::rebuild_symbol_cache(&files, &mut symbols);
2058 }
2059
2060 errors
2061 }
2062
2063 pub fn find_references(&self, symbol_name: &str) -> Vec<Location> {
2091 let global_refs = self.global_references.read();
2092 let mut seen: HashSet<(String, u32, u32, u32, u32)> = HashSet::new();
2093 let mut locations = Vec::new();
2094
2095 if let Some(refs) = global_refs.get(symbol_name) {
2097 for loc in refs {
2098 let key = (
2099 loc.uri.clone(),
2100 loc.range.start.line,
2101 loc.range.start.column,
2102 loc.range.end.line,
2103 loc.range.end.column,
2104 );
2105 if seen.insert(key) {
2106 locations.push(Location { uri: loc.uri.clone(), range: loc.range });
2107 }
2108 }
2109 }
2110
2111 if let Some(idx) = symbol_name.rfind("::") {
2113 let bare_name = &symbol_name[idx + 2..];
2114 if let Some(refs) = global_refs.get(bare_name) {
2115 for loc in refs {
2116 let key = (
2117 loc.uri.clone(),
2118 loc.range.start.line,
2119 loc.range.start.column,
2120 loc.range.end.line,
2121 loc.range.end.column,
2122 );
2123 if seen.insert(key) {
2124 locations.push(Location { uri: loc.uri.clone(), range: loc.range });
2125 }
2126 }
2127 }
2128 } else {
2129 for (name, refs) in global_refs.iter() {
2132 if !Self::is_qualified_variant_of(name, symbol_name) {
2133 continue;
2134 }
2135
2136 for loc in refs {
2137 let key = (
2138 loc.uri.clone(),
2139 loc.range.start.line,
2140 loc.range.start.column,
2141 loc.range.end.line,
2142 loc.range.end.column,
2143 );
2144 if seen.insert(key) {
2145 locations.push(Location { uri: loc.uri.clone(), range: loc.range });
2146 }
2147 }
2148 }
2149 }
2150
2151 Self::sort_locations_deterministically(&mut locations);
2152 locations
2153 }
2154
2155 pub fn query_symbol_references(
2159 &self,
2160 symbol_name: &str,
2161 ) -> Option<CrossFileReferenceQueryResult> {
2162 let definition = self.find_definition(symbol_name)?;
2163 let symbol = self.find_symbol_by_definition(&definition, symbol_name)?;
2164
2165 let stable_key = symbol.qualified_name.clone().unwrap_or_else(|| {
2166 format!(
2167 "{}@{}:{}:{}",
2168 symbol.name, symbol.uri, symbol.range.start.line, symbol.range.start.column
2169 )
2170 });
2171 let mut references = self.collect_symbol_references(&symbol);
2172 if !references.iter().any(|location| location == &definition) {
2173 references.push(definition.clone());
2174 Self::sort_locations_deterministically(&mut references);
2175 }
2176
2177 Some(CrossFileReferenceQueryResult {
2178 symbol: SymbolIdentity {
2179 stable_key,
2180 name: symbol.name,
2181 qualified_name: symbol.qualified_name,
2182 kind: symbol.kind,
2183 },
2184 definition,
2185 references,
2186 })
2187 }
2188
2189 pub fn count_usages(&self, symbol_name: &str) -> usize {
2195 let files = self.files.read();
2196 let mut seen: HashSet<(String, u32, u32, u32, u32)> = HashSet::new();
2197
2198 for (_uri_key, file_index) in files.iter() {
2199 if let Some(refs) = file_index.references.get(symbol_name) {
2200 for r in refs.iter().filter(|r| r.kind != ReferenceKind::Definition) {
2201 seen.insert((
2202 r.uri.clone(),
2203 r.range.start.line,
2204 r.range.start.column,
2205 r.range.end.line,
2206 r.range.end.column,
2207 ));
2208 }
2209 }
2210
2211 if let Some(idx) = symbol_name.rfind("::") {
2212 let bare_name = &symbol_name[idx + 2..];
2213 if let Some(refs) = file_index.references.get(bare_name) {
2214 for r in refs.iter().filter(|r| r.kind != ReferenceKind::Definition) {
2215 seen.insert((
2216 r.uri.clone(),
2217 r.range.start.line,
2218 r.range.start.column,
2219 r.range.end.line,
2220 r.range.end.column,
2221 ));
2222 }
2223 }
2224 } else {
2225 for (name, refs) in &file_index.references {
2226 if !Self::is_qualified_variant_of(name, symbol_name) {
2227 continue;
2228 }
2229
2230 for r in refs.iter().filter(|r| r.kind != ReferenceKind::Definition) {
2231 seen.insert((
2232 r.uri.clone(),
2233 r.range.start.line,
2234 r.range.start.column,
2235 r.range.end.line,
2236 r.range.end.column,
2237 ));
2238 }
2239 }
2240 }
2241 }
2242
2243 seen.len()
2244 }
2245
2246 fn is_qualified_variant_of(candidate: &str, bare_symbol: &str) -> bool {
2247 candidate.rsplit_once("::").is_some_and(|(_, candidate_bare)| candidate_bare == bare_symbol)
2248 }
2249
2250 pub fn find_definition(&self, symbol_name: &str) -> Option<Location> {
2269 if let Some(location) = self.definition_candidates(symbol_name).into_iter().next() {
2270 return Some(location);
2271 }
2272
2273 let files = self.files.read();
2274 let resolved = Self::find_definition_in_files(&files, symbol_name, None);
2275 drop(files);
2276
2277 if let Some((location, _uri)) = resolved {
2278 let mut symbols = self.symbols.write();
2279 symbols.entry(symbol_name.to_string()).or_default().push(DefinitionCandidate {
2280 location: location.clone(),
2281 kind: SymbolKind::Subroutine,
2282 });
2283 if let Some(candidates) = symbols.get_mut(symbol_name) {
2284 candidates.sort_by(|left, right| {
2285 Self::definition_candidate_sort_key(left)
2286 .cmp(&Self::definition_candidate_sort_key(right))
2287 });
2288 candidates.dedup();
2289 }
2290 return Some(location);
2291 }
2292
2293 None
2294 }
2295
2296 pub(crate) fn definition_candidates(&self, symbol_name: &str) -> Vec<Location> {
2297 let symbols = self.symbols.read();
2298 symbols
2299 .get(symbol_name)
2300 .map(|candidates| {
2301 candidates.iter().map(|candidate| candidate.location.clone()).collect()
2302 })
2303 .unwrap_or_default()
2304 }
2305
2306 pub fn all_symbols(&self) -> Vec<WorkspaceSymbol> {
2321 let files = self.files.read();
2322 let mut symbols = Vec::new();
2323
2324 for (_uri_key, file_index) in files.iter() {
2325 symbols.extend(file_index.symbols.clone());
2326 }
2327
2328 symbols
2329 }
2330
2331 pub fn clear(&self) {
2333 self.files.write().clear();
2334 self.symbols.write().clear();
2335 self.global_references.write().clear();
2336 self.fact_shards.write().clear();
2337 }
2338
2339 fn hash_uri_to_file_id(uri: &str) -> FileId {
2340 let mut hasher = DefaultHasher::new();
2341 uri.hash(&mut hasher);
2342 FileId(hasher.finish())
2343 }
2344
2345 fn build_fact_shard(uri: &str, content_hash: u64, file_index: &FileIndex) -> FileFactShard {
2346 let file_id = Self::hash_uri_to_file_id(uri);
2347 let mut anchors = Vec::new();
2348 let mut entities = Vec::new();
2349 for (idx, symbol) in file_index.symbols.iter().enumerate() {
2350 let anchor_id = AnchorId((idx + 1) as u64);
2351 anchors.push(AnchorFact {
2352 id: anchor_id,
2353 file_id,
2354 span_start_byte: 0,
2358 span_end_byte: 0,
2359 scope_id: None,
2360 provenance: Provenance::SearchFallback,
2361 confidence: Confidence::Low,
2362 });
2363 entities.push(EntityFact {
2364 id: EntityId((idx + 1) as u64),
2365 kind: EntityKind::Unknown,
2366 canonical_name: symbol
2367 .qualified_name
2368 .clone()
2369 .unwrap_or_else(|| symbol.name.clone()),
2370 anchor_id: Some(anchor_id),
2371 scope_id: None,
2372 provenance: Provenance::SearchFallback,
2373 confidence: Confidence::Low,
2374 });
2375 }
2376 let anchors_hash = {
2379 let mut h = DefaultHasher::new();
2380 anchors.len().hash(&mut h);
2381 for a in &anchors {
2382 a.id.hash(&mut h);
2383 a.span_start_byte.hash(&mut h);
2384 a.span_end_byte.hash(&mut h);
2385 }
2386 h.finish()
2387 };
2388 let entities_hash = {
2389 let mut h = DefaultHasher::new();
2390 entities.len().hash(&mut h);
2391 for e in &entities {
2392 e.id.hash(&mut h);
2393 e.canonical_name.hash(&mut h);
2394 }
2395 h.finish()
2396 };
2397 FileFactShard {
2398 source_uri: uri.to_string(),
2399 file_id,
2400 content_hash,
2401 anchors_hash: Some(anchors_hash),
2402 entities_hash: Some(entities_hash),
2403 occurrences_hash: Some(0),
2404 edges_hash: Some(0),
2405 anchors,
2406 entities,
2407 occurrences: Vec::new(),
2408 edges: Vec::new(),
2409 }
2410 }
2411
2412 pub fn fact_shard_count(&self) -> usize {
2414 self.fact_shards.read().len()
2415 }
2416
2417 pub fn file_fact_shard(&self, uri: &str) -> Option<FileFactShard> {
2419 let key = DocumentStore::uri_key(&Self::normalize_uri(uri));
2420 self.fact_shards.read().get(&key).cloned()
2421 }
2422
2423 pub fn file_count(&self) -> usize {
2425 let files = self.files.read();
2426 files.len()
2427 }
2428
2429 pub fn symbol_count(&self) -> usize {
2431 let files = self.files.read();
2432 files.values().map(|file_index| file_index.symbols.len()).sum()
2433 }
2434
2435 pub fn files_in_folder(&self, folder_uri: &str) -> Vec<FileIndex> {
2445 let files = self.files.read();
2446 files.values().filter(|f| f.folder_uri.as_deref() == Some(folder_uri)).cloned().collect()
2447 }
2448
2449 pub fn symbols_in_folder(&self, folder_uri: &str) -> Vec<WorkspaceSymbol> {
2459 let files = self.files.read();
2460 files
2461 .values()
2462 .filter(|f| f.folder_uri.as_deref() == Some(folder_uri))
2463 .flat_map(|f| f.symbols.iter().cloned())
2464 .collect()
2465 }
2466
2467 #[cfg(feature = "memory-profiling")]
2475 pub fn memory_snapshot(&self) -> crate::workspace::memory::MemorySnapshot {
2476 use std::mem::size_of;
2477
2478 let files_guard = self.files.read();
2479 let symbols_guard = self.symbols.read();
2480 let global_refs_guard = self.global_references.read();
2481
2482 let mut files_bytes: usize = 0;
2484 let mut total_symbol_count: usize = 0;
2485 for (uri_key, fi) in files_guard.iter() {
2486 files_bytes += uri_key.len();
2488 for sym in &fi.symbols {
2490 files_bytes += sym.name.len()
2491 + sym.uri.len()
2492 + sym.qualified_name.as_deref().map_or(0, str::len)
2493 + sym.documentation.as_deref().map_or(0, str::len)
2494 + sym.container_name.as_deref().map_or(0, str::len)
2495 + size_of::<WorkspaceSymbol>();
2497 }
2498 total_symbol_count += fi.symbols.len();
2499 for (ref_name, refs) in &fi.references {
2501 files_bytes += ref_name.len();
2502 for r in refs {
2503 files_bytes += r.uri.len() + size_of::<SymbolReference>();
2504 }
2505 }
2506 for dep in &fi.dependencies {
2508 files_bytes += dep.len();
2509 }
2510 files_bytes += size_of::<u64>();
2512 }
2513
2514 let mut symbols_bytes: usize = 0;
2516 for (qname, candidates) in symbols_guard.iter() {
2517 symbols_bytes += qname.len();
2518 for candidate in candidates {
2519 symbols_bytes += candidate.location.uri.len() + size_of::<Location>();
2520 }
2521 }
2522
2523 let mut global_refs_bytes: usize = 0;
2525 for (sym_name, locs) in global_refs_guard.iter() {
2526 global_refs_bytes += sym_name.len();
2527 for loc in locs {
2528 global_refs_bytes += loc.uri.len() + size_of::<Location>();
2529 }
2530 }
2531
2532 let document_store_bytes = self.document_store.total_text_bytes();
2534
2535 crate::workspace::memory::MemorySnapshot {
2536 file_count: files_guard.len(),
2537 symbol_count: total_symbol_count,
2538 files_bytes,
2539 symbols_bytes,
2540 global_refs_bytes,
2541 document_store_bytes,
2542 }
2543 }
2544
2545 pub fn has_symbols(&self) -> bool {
2564 let files = self.files.read();
2565 files.values().any(|file_index| !file_index.symbols.is_empty())
2566 }
2567
2568 pub fn search_symbols(&self, query: &str) -> Vec<WorkspaceSymbol> {
2587 let query_lower = query.to_lowercase();
2588 let files = self.files.read();
2589 let mut results = Vec::new();
2590 for file_index in files.values() {
2591 for symbol in &file_index.symbols {
2592 if symbol.name.to_lowercase().contains(&query_lower)
2593 || symbol
2594 .qualified_name
2595 .as_ref()
2596 .map(|qn| qn.to_lowercase().contains(&query_lower))
2597 .unwrap_or(false)
2598 {
2599 results.push(symbol.clone());
2600 }
2601 }
2602 }
2603 results
2604 }
2605
2606 pub fn find_symbols(&self, query: &str) -> Vec<WorkspaceSymbol> {
2625 self.search_symbols(query)
2626 }
2627
2628 pub fn rank_symbols_by_folder(
2651 &self,
2652 symbols: Vec<WorkspaceSymbol>,
2653 doc_uri: &str,
2654 ) -> Vec<WorkspaceSymbol> {
2655 let doc_folder = self.determine_folder_uri(doc_uri);
2656
2657 let mut ranked: Vec<(WorkspaceSymbol, i32)> = symbols
2658 .into_iter()
2659 .map(|symbol| {
2660 let rank = if let Some(ref doc_folder_uri) = doc_folder {
2661 if symbol.workspace_folder_uri.as_ref() == Some(doc_folder_uri) {
2662 0 } else {
2664 1 }
2666 } else {
2667 1 };
2669 (symbol, rank)
2670 })
2671 .collect();
2672
2673 ranked.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.name.cmp(&b.0.name)));
2675
2676 ranked.into_iter().map(|(symbol, _)| symbol).collect()
2677 }
2678
2679 pub fn search_symbols_ranked(&self, name: &str, doc_uri: &str) -> Vec<WorkspaceSymbol> {
2701 let symbols = self.search_symbols(name);
2702 self.rank_symbols_by_folder(symbols, doc_uri)
2703 }
2704
2705 #[allow(dead_code)]
2716 pub fn same_package(&self, symbol_a: &WorkspaceSymbol, symbol_b: &WorkspaceSymbol) -> bool {
2717 let package_a = self.extract_package_name(&symbol_a.name);
2718 let package_b = self.extract_package_name(&symbol_b.name);
2719 package_a == package_b
2720 }
2721
2722 #[allow(dead_code)]
2733 pub fn same_package_by_container(&self, package_a: &str, package_b: &str) -> bool {
2734 package_a == package_b
2735 }
2736
2737 #[allow(dead_code)]
2747 pub fn extract_package_name(&self, symbol_name: &str) -> Option<String> {
2748 let parts: Vec<&str> = symbol_name.split("::").collect();
2749 if parts.len() > 1 { Some(parts[..parts.len() - 1].join("::")) } else { None }
2750 }
2751
2752 pub fn file_symbols(&self, uri: &str) -> Vec<WorkspaceSymbol> {
2771 let normalized_uri = Self::normalize_uri(uri);
2772 let key = DocumentStore::uri_key(&normalized_uri);
2773 let files = self.files.read();
2774
2775 files.get(&key).map(|fi| fi.symbols.clone()).unwrap_or_default()
2776 }
2777
2778 pub fn file_dependencies(&self, uri: &str) -> HashSet<String> {
2797 let normalized_uri = Self::normalize_uri(uri);
2798 let key = DocumentStore::uri_key(&normalized_uri);
2799 let files = self.files.read();
2800
2801 files.get(&key).map(|fi| fi.dependencies.clone()).unwrap_or_default()
2802 }
2803
2804 pub fn find_dependents(&self, module_name: &str) -> Vec<String> {
2823 let canonical = canonicalize_perl_module_name(module_name);
2824 let legacy = legacy_perl_module_name(&canonical);
2825 let files = self.files.read();
2826 let mut dependents = Vec::new();
2827
2828 for (uri_key, file_index) in files.iter() {
2829 if file_index.dependencies.contains(module_name)
2830 || file_index.dependencies.contains(&canonical)
2831 || file_index.dependencies.contains(&legacy)
2832 {
2833 dependents.push(uri_key.clone());
2834 }
2835 }
2836
2837 dependents
2838 }
2839
2840 pub fn document_store(&self) -> &DocumentStore {
2855 &self.document_store
2856 }
2857
2858 pub fn find_unused_symbols(&self) -> Vec<WorkspaceSymbol> {
2873 let files = self.files.read();
2874 let mut unused = Vec::new();
2875
2876 for (_uri_key, file_index) in files.iter() {
2878 for symbol in &file_index.symbols {
2879 let has_usage = files.values().any(|fi| {
2881 if let Some(refs) = fi.references.get(&symbol.name) {
2882 refs.iter().any(|r| r.kind != ReferenceKind::Definition)
2883 } else {
2884 false
2885 }
2886 });
2887
2888 if !has_usage {
2889 unused.push(symbol.clone());
2890 }
2891 }
2892 }
2893
2894 unused
2895 }
2896
2897 pub fn get_package_members(&self, package_name: &str) -> Vec<WorkspaceSymbol> {
2916 let files = self.files.read();
2917 let mut members = Vec::new();
2918
2919 for (_uri_key, file_index) in files.iter() {
2920 for symbol in &file_index.symbols {
2921 if let Some(ref container) = symbol.container_name {
2923 if container == package_name {
2924 members.push(symbol.clone());
2925 }
2926 }
2927 if let Some(ref qname) = symbol.qualified_name {
2929 if qname.starts_with(&format!("{}::", package_name)) {
2930 if symbol.container_name.as_deref() != Some(package_name) {
2932 members.push(symbol.clone());
2933 }
2934 }
2935 }
2936 }
2937 }
2938
2939 members
2940 }
2941
2942 pub fn find_def(&self, key: &SymbolKey) -> Option<Location> {
2963 if let Some(sigil) = key.sigil {
2964 let var_name = format!("{}{}", sigil, key.name);
2966 self.find_definition(&var_name)
2967 } else if key.kind == SymKind::Pack {
2968 self.find_definition(key.pkg.as_ref())
2971 .or_else(|| self.find_definition(key.name.as_ref()))
2972 } else {
2973 let qualified_name = format!("{}::{}", key.pkg, key.name);
2975 self.find_definition(&qualified_name)
2976 }
2977 }
2978
2979 pub fn find_refs(&self, key: &SymbolKey) -> Vec<Location> {
3002 let files_locked = self.files.read();
3003 let mut all_refs = if let Some(sigil) = key.sigil {
3004 let var_name = format!("{}{}", sigil, key.name);
3006 let mut refs = Vec::new();
3007 for (_uri_key, file_index) in files_locked.iter() {
3008 if let Some(var_refs) = file_index.references.get(&var_name) {
3009 for reference in var_refs {
3010 refs.push(Location { uri: reference.uri.clone(), range: reference.range });
3011 }
3012 }
3013 }
3014 refs
3015 } else {
3016 if key.pkg.as_ref() == "main" {
3018 let mut refs = self.find_references(&format!("main::{}", key.name));
3020 for (_uri_key, file_index) in files_locked.iter() {
3022 if let Some(bare_refs) = file_index.references.get(key.name.as_ref()) {
3023 for reference in bare_refs {
3024 refs.push(Location {
3025 uri: reference.uri.clone(),
3026 range: reference.range,
3027 });
3028 }
3029 }
3030 }
3031 refs
3032 } else {
3033 let qualified_name = format!("{}::{}", key.pkg, key.name);
3034 self.find_references(&qualified_name)
3035 }
3036 };
3037 drop(files_locked);
3038
3039 if let Some(def) = self.find_def(key) {
3041 all_refs.retain(|loc| !(loc.uri == def.uri && loc.range == def.range));
3042 }
3043
3044 let mut seen = HashSet::new();
3046 all_refs.retain(|loc| {
3047 seen.insert((
3048 loc.uri.clone(),
3049 loc.range.start.line,
3050 loc.range.start.column,
3051 loc.range.end.line,
3052 loc.range.end.column,
3053 ))
3054 });
3055
3056 all_refs
3057 }
3058}
3059
3060struct IndexVisitor {
3062 document: Document,
3063 uri: String,
3064 current_package: Option<String>,
3065 workspace_folder_uri: Option<String>,
3066}
3067
3068fn is_interpolated_var_start(byte: u8) -> bool {
3069 byte.is_ascii_alphabetic() || byte == b'_'
3070}
3071
3072fn is_interpolated_var_continue(byte: u8) -> bool {
3073 byte.is_ascii_alphanumeric() || byte == b'_' || byte == b':'
3074}
3075
3076fn has_escaped_interpolation_marker(bytes: &[u8], index: usize) -> bool {
3077 if index == 0 {
3078 return false;
3079 }
3080
3081 let mut backslashes = 0usize;
3082 let mut cursor = index;
3083 while cursor > 0 && bytes[cursor - 1] == b'\\' {
3084 backslashes += 1;
3085 cursor -= 1;
3086 }
3087
3088 backslashes % 2 == 1
3089}
3090
3091fn strip_matching_quote_delimiters(raw_content: &str) -> &str {
3092 if raw_content.len() < 2 {
3093 return raw_content;
3094 }
3095
3096 let bytes = raw_content.as_bytes();
3097 match (bytes.first(), bytes.last()) {
3098 (Some(b'"'), Some(b'"')) | (Some(b'\''), Some(b'\'')) => {
3099 &raw_content[1..raw_content.len() - 1]
3100 }
3101 _ => raw_content,
3102 }
3103}
3104
3105impl IndexVisitor {
3106 fn new(document: &mut Document, uri: String, workspace_folder_uri: Option<String>) -> Self {
3107 Self {
3108 document: document.clone(),
3109 uri,
3110 current_package: Some("main".to_string()),
3111 workspace_folder_uri,
3112 }
3113 }
3114
3115 fn visit(&mut self, node: &Node, file_index: &mut FileIndex) {
3116 self.project_symbol_declarations(node, file_index);
3117 self.visit_node(node, file_index);
3118 }
3119
3120 fn project_symbol_declarations(&self, node: &Node, file_index: &mut FileIndex) {
3121 for decl in extract_symbol_decls(node, self.current_package.as_deref()) {
3122 let (start, end) = match decl.kind {
3123 SymbolKind::Variable(_) => match decl.anchor_span {
3124 Some(span) => span,
3125 None => decl.full_span,
3126 },
3127 _ => decl.full_span,
3128 };
3129 let ((start_line, start_col), (end_line, end_col)) =
3130 self.document.line_index.range(start, end);
3131 let range = Range {
3132 start: Position { byte: start, line: start_line, column: start_col },
3133 end: Position { byte: end, line: end_line, column: end_col },
3134 };
3135
3136 let symbol_name = symbol_decl_name(&decl.kind, &decl.name);
3137
3138 let qualified_name = match &decl.declarator {
3143 Some(d) if d == "my" || d == "state" => None,
3144 _ => (!decl.qualified_name.is_empty()).then_some(decl.qualified_name),
3145 };
3146
3147 let container_name = match decl.kind {
3150 SymbolKind::Package => None,
3151 _ => decl.container,
3152 };
3153
3154 file_index.symbols.push(WorkspaceSymbol {
3155 name: symbol_name.clone(),
3156 kind: decl.kind,
3157 uri: self.uri.clone(),
3158 range,
3159 qualified_name,
3160 documentation: None,
3161 container_name,
3162 has_body: true,
3163 workspace_folder_uri: self.workspace_folder_uri.clone(),
3164 });
3165
3166 file_index.references.entry(symbol_name).or_default().push(SymbolReference {
3167 uri: self.uri.clone(),
3168 range,
3169 kind: ReferenceKind::Definition,
3170 });
3171 }
3172 }
3173
3174 fn record_interpolated_variable_references(
3175 &self,
3176 raw_content: &str,
3177 range: Range,
3178 file_index: &mut FileIndex,
3179 ) {
3180 let content = strip_matching_quote_delimiters(raw_content);
3181 let bytes = content.as_bytes();
3182 let mut index = 0;
3183
3184 while index < bytes.len() {
3185 if has_escaped_interpolation_marker(bytes, index) {
3186 index += 1;
3187 continue;
3188 }
3189
3190 let sigil = match bytes[index] {
3191 b'$' => "$",
3192 b'@' => "@",
3193 _ => {
3194 index += 1;
3195 continue;
3196 }
3197 };
3198
3199 if index + 1 >= bytes.len() {
3200 break;
3201 }
3202
3203 let (start, needs_closing_brace) =
3204 if bytes[index + 1] == b'{' { (index + 2, true) } else { (index + 1, false) };
3205
3206 if start >= bytes.len() || !is_interpolated_var_start(bytes[start]) {
3207 index += 1;
3208 continue;
3209 }
3210
3211 let mut end = start + 1;
3212 while end < bytes.len() && is_interpolated_var_continue(bytes[end]) {
3213 end += 1;
3214 }
3215
3216 if needs_closing_brace && (end >= bytes.len() || bytes[end] != b'}') {
3217 index += 1;
3218 continue;
3219 }
3220
3221 if let Some(name) = content.get(start..end) {
3222 let var_name = format!("{sigil}{name}");
3223 file_index.references.entry(var_name).or_default().push(SymbolReference {
3224 uri: self.uri.clone(),
3225 range,
3226 kind: ReferenceKind::Read,
3227 });
3228 }
3229
3230 index = if needs_closing_brace { end + 1 } else { end };
3231 }
3232 }
3233
3234 fn visit_node(&mut self, node: &Node, file_index: &mut FileIndex) {
3235 match &node.kind {
3236 NodeKind::Package { name, .. } => {
3237 let package_name = name.clone();
3238
3239 self.current_package = Some(package_name.clone());
3241 }
3242
3243 NodeKind::Subroutine { body, .. } => {
3244 self.visit_node(body, file_index);
3246 }
3247
3248 NodeKind::VariableDeclaration { initializer, .. } => {
3249 if let Some(init) = initializer {
3251 self.visit_node(init, file_index);
3252 }
3253 }
3254
3255 NodeKind::VariableListDeclaration { initializer, .. } => {
3256 if let Some(init) = initializer {
3258 self.visit_node(init, file_index);
3259 }
3260 }
3261
3262 NodeKind::Variable { sigil, name } => {
3263 let var_name = format!("{}{}", sigil, name);
3264
3265 file_index.references.entry(var_name).or_default().push(SymbolReference {
3267 uri: self.uri.clone(),
3268 range: self.node_to_range(node),
3269 kind: ReferenceKind::Read, });
3271 }
3272
3273 NodeKind::FunctionCall { name, args, .. } => {
3274 let func_name = name.clone();
3275 let location = self.node_to_range(node);
3276
3277 let (pkg, bare_name) = if let Some(idx) = func_name.rfind("::") {
3279 (&func_name[..idx], &func_name[idx + 2..])
3280 } else {
3281 (self.current_package.as_deref().unwrap_or("main"), func_name.as_str())
3282 };
3283
3284 let qualified = format!("{}::{}", pkg, bare_name);
3285
3286 file_index.references.entry(bare_name.to_string()).or_default().push(
3290 SymbolReference {
3291 uri: self.uri.clone(),
3292 range: location,
3293 kind: ReferenceKind::Usage,
3294 },
3295 );
3296 file_index.references.entry(qualified).or_default().push(SymbolReference {
3297 uri: self.uri.clone(),
3298 range: location,
3299 kind: ReferenceKind::Usage,
3300 });
3301
3302 if name == "extends" || name == "with" {
3303 for module_name in extract_module_names_from_call_args(args) {
3304 file_index
3305 .dependencies
3306 .insert(normalize_dependency_module_name(&module_name));
3307 }
3308 } else if name == "require" {
3309 if let Some(module_name) = extract_module_name_from_require_args(args) {
3310 file_index
3311 .dependencies
3312 .insert(normalize_dependency_module_name(&module_name));
3313 }
3314 }
3315
3316 for arg in args {
3318 self.visit_node(arg, file_index);
3319 }
3320 }
3321
3322 NodeKind::Use { module, args, .. } => {
3323 let module_name = normalize_dependency_module_name(module);
3324 file_index.dependencies.insert(module_name.clone());
3325
3326 if module == "parent" || module == "base" {
3330 for name in extract_module_names_from_use_args(args) {
3331 file_index.dependencies.insert(normalize_dependency_module_name(&name));
3332 }
3333 }
3334
3335 file_index.references.entry(module_name).or_default().push(SymbolReference {
3337 uri: self.uri.clone(),
3338 range: self.node_to_range(node),
3339 kind: ReferenceKind::Import,
3340 });
3341 }
3342
3343 NodeKind::Assignment { lhs, rhs, op } => {
3345 let is_compound = op != "=";
3347
3348 if let NodeKind::Variable { sigil, name } = &lhs.kind {
3349 let var_name = format!("{}{}", sigil, name);
3350
3351 if is_compound {
3353 file_index.references.entry(var_name.clone()).or_default().push(
3354 SymbolReference {
3355 uri: self.uri.clone(),
3356 range: self.node_to_range(lhs),
3357 kind: ReferenceKind::Read,
3358 },
3359 );
3360 }
3361
3362 file_index.references.entry(var_name).or_default().push(SymbolReference {
3364 uri: self.uri.clone(),
3365 range: self.node_to_range(lhs),
3366 kind: ReferenceKind::Write,
3367 });
3368 }
3369
3370 self.visit_node(rhs, file_index);
3372 }
3373
3374 NodeKind::Block { statements } => {
3376 for stmt in statements {
3377 self.visit_node(stmt, file_index);
3378 }
3379 }
3380
3381 NodeKind::If { condition, then_branch, elsif_branches, else_branch } => {
3382 self.visit_node(condition, file_index);
3383 self.visit_node(then_branch, file_index);
3384 for (cond, branch) in elsif_branches {
3385 self.visit_node(cond, file_index);
3386 self.visit_node(branch, file_index);
3387 }
3388 if let Some(else_br) = else_branch {
3389 self.visit_node(else_br, file_index);
3390 }
3391 }
3392
3393 NodeKind::While { condition, body, continue_block } => {
3394 self.visit_node(condition, file_index);
3395 self.visit_node(body, file_index);
3396 if let Some(cont) = continue_block {
3397 self.visit_node(cont, file_index);
3398 }
3399 }
3400
3401 NodeKind::For { init, condition, update, body, continue_block } => {
3402 if let Some(i) = init {
3403 self.visit_node(i, file_index);
3404 }
3405 if let Some(c) = condition {
3406 self.visit_node(c, file_index);
3407 }
3408 if let Some(u) = update {
3409 self.visit_node(u, file_index);
3410 }
3411 self.visit_node(body, file_index);
3412 if let Some(cont) = continue_block {
3413 self.visit_node(cont, file_index);
3414 }
3415 }
3416
3417 NodeKind::Foreach { variable, list, body, continue_block } => {
3418 if let Some(cb) = continue_block {
3420 self.visit_node(cb, file_index);
3421 }
3422 if let NodeKind::Variable { sigil, name } = &variable.kind {
3423 let var_name = format!("{}{}", sigil, name);
3424 file_index.references.entry(var_name).or_default().push(SymbolReference {
3425 uri: self.uri.clone(),
3426 range: self.node_to_range(variable),
3427 kind: ReferenceKind::Write,
3428 });
3429 }
3430 self.visit_node(variable, file_index);
3431 self.visit_node(list, file_index);
3432 self.visit_node(body, file_index);
3433 }
3434
3435 NodeKind::MethodCall { object, method, args } => {
3436 let qualified_method = if let NodeKind::Identifier { name } = &object.kind {
3438 Some(format!("{}::{}", name, method))
3440 } else {
3441 None
3443 };
3444
3445 self.visit_node(object, file_index);
3447
3448 let location = self.node_to_range(node);
3455 if let Some(qualified_method) = qualified_method.as_ref() {
3456 file_index.references.entry(qualified_method.clone()).or_default().push(
3457 SymbolReference {
3458 uri: self.uri.clone(),
3459 range: location,
3460 kind: ReferenceKind::Usage,
3461 },
3462 );
3463 }
3464 file_index.references.entry(method.clone()).or_default().push(SymbolReference {
3465 uri: self.uri.clone(),
3466 range: location,
3467 kind: ReferenceKind::Usage,
3468 });
3469
3470 if method == "import"
3471 && let NodeKind::Identifier { name: module_name } = &object.kind
3472 {
3473 for symbol in extract_manual_import_symbols(args) {
3474 file_index.references.entry(symbol).or_default().push(SymbolReference {
3475 uri: self.uri.clone(),
3476 range: self.node_to_range(node),
3477 kind: ReferenceKind::Import,
3478 });
3479 }
3480 file_index.dependencies.insert(normalize_dependency_module_name(module_name));
3481 }
3482
3483 for arg in args {
3485 self.visit_node(arg, file_index);
3486 }
3487 }
3488
3489 NodeKind::No { module, .. } => {
3490 let module_name = normalize_dependency_module_name(module);
3491 file_index.dependencies.insert(module_name);
3492 }
3493
3494 NodeKind::Class { name, .. } => {
3495 self.current_package = Some(name.clone());
3496 }
3497
3498 NodeKind::Method { body, signature, .. } => {
3499 if let Some(sig) = signature {
3501 if let NodeKind::Signature { parameters } = &sig.kind {
3502 for param in parameters {
3503 self.visit_node(param, file_index);
3504 }
3505 }
3506 }
3507
3508 self.visit_node(body, file_index);
3510 }
3511
3512 NodeKind::String { value, interpolated } => {
3513 if *interpolated {
3514 let range = self.node_to_range(node);
3515 self.record_interpolated_variable_references(value, range, file_index);
3516 }
3517 }
3518
3519 NodeKind::Heredoc { content, interpolated, .. } => {
3520 if *interpolated {
3521 let range = self.node_to_range(node);
3522 self.record_interpolated_variable_references(content, range, file_index);
3523 }
3524 }
3525
3526 NodeKind::Unary { op, operand } if op == "++" || op == "--" => {
3528 if let NodeKind::Variable { sigil, name } = &operand.kind {
3530 let var_name = format!("{}{}", sigil, name);
3531
3532 file_index.references.entry(var_name.clone()).or_default().push(
3534 SymbolReference {
3535 uri: self.uri.clone(),
3536 range: self.node_to_range(operand),
3537 kind: ReferenceKind::Read,
3538 },
3539 );
3540
3541 file_index.references.entry(var_name).or_default().push(SymbolReference {
3542 uri: self.uri.clone(),
3543 range: self.node_to_range(operand),
3544 kind: ReferenceKind::Write,
3545 });
3546 }
3547 }
3548
3549 _ => {
3550 self.visit_children(node, file_index);
3552 }
3553 }
3554 }
3555
3556 fn visit_children(&mut self, node: &Node, file_index: &mut FileIndex) {
3557 match &node.kind {
3559 NodeKind::Program { statements } => {
3560 for stmt in statements {
3561 self.visit_node(stmt, file_index);
3562 }
3563 }
3564 NodeKind::ExpressionStatement { expression } => {
3565 self.visit_node(expression, file_index);
3566 }
3567 NodeKind::Unary { operand, .. } => {
3569 self.visit_node(operand, file_index);
3570 }
3571 NodeKind::Binary { left, right, .. } => {
3572 self.visit_node(left, file_index);
3573 self.visit_node(right, file_index);
3574 }
3575 NodeKind::Ternary { condition, then_expr, else_expr } => {
3576 self.visit_node(condition, file_index);
3577 self.visit_node(then_expr, file_index);
3578 self.visit_node(else_expr, file_index);
3579 }
3580 NodeKind::ArrayLiteral { elements } => {
3581 for elem in elements {
3582 self.visit_node(elem, file_index);
3583 }
3584 }
3585 NodeKind::HashLiteral { pairs } => {
3586 for (key, value) in pairs {
3587 self.visit_node(key, file_index);
3588 self.visit_node(value, file_index);
3589 }
3590 }
3591 NodeKind::Return { value } => {
3592 if let Some(val) = value {
3593 self.visit_node(val, file_index);
3594 }
3595 }
3596 NodeKind::Eval { block } | NodeKind::Do { block } | NodeKind::Defer { block } => {
3597 self.visit_node(block, file_index);
3598 }
3599 NodeKind::Try { body, catch_blocks, finally_block } => {
3600 self.visit_node(body, file_index);
3601 for (_, block) in catch_blocks {
3602 self.visit_node(block, file_index);
3603 }
3604 if let Some(finally) = finally_block {
3605 self.visit_node(finally, file_index);
3606 }
3607 }
3608 NodeKind::Given { expr, body } => {
3609 self.visit_node(expr, file_index);
3610 self.visit_node(body, file_index);
3611 }
3612 NodeKind::When { condition, body } => {
3613 self.visit_node(condition, file_index);
3614 self.visit_node(body, file_index);
3615 }
3616 NodeKind::Default { body } => {
3617 self.visit_node(body, file_index);
3618 }
3619 NodeKind::StatementModifier { statement, condition, .. } => {
3620 self.visit_node(statement, file_index);
3621 self.visit_node(condition, file_index);
3622 }
3623 NodeKind::VariableWithAttributes { variable, .. } => {
3624 self.visit_node(variable, file_index);
3625 }
3626 NodeKind::LabeledStatement { statement, .. } => {
3627 self.visit_node(statement, file_index);
3628 }
3629 _ => {
3630 }
3632 }
3633 }
3634
3635 fn node_to_range(&mut self, node: &Node) -> Range {
3636 let ((start_line, start_col), (end_line, end_col)) =
3638 self.document.line_index.range(node.location.start, node.location.end);
3639 Range {
3641 start: Position { byte: node.location.start, line: start_line, column: start_col },
3642 end: Position { byte: node.location.end, line: end_line, column: end_col },
3643 }
3644 }
3645}
3646
3647fn symbol_decl_name(kind: &SymbolKind, name: &str) -> String {
3648 match kind {
3649 SymbolKind::Variable(VarKind::Scalar) => format!("${name}"),
3650 SymbolKind::Variable(VarKind::Array) => format!("@{name}"),
3651 SymbolKind::Variable(VarKind::Hash) => format!("%{name}"),
3652 _ => name.to_string(),
3653 }
3654}
3655
3656fn extract_module_names_from_use_args(args: &[String]) -> Vec<String> {
3666 use std::collections::HashSet;
3667
3668 fn normalize_module_name(token: &str) -> Option<&str> {
3669 let stripped = token.trim_matches(|c: char| {
3670 matches!(c, '\'' | '"' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ';')
3671 });
3672
3673 if stripped.is_empty() || stripped.starts_with('-') {
3674 return None;
3675 }
3676
3677 stripped
3678 .chars()
3679 .all(|c| c.is_alphanumeric() || c == '_' || c == ':' || c == '\'')
3680 .then_some(stripped)
3681 }
3682
3683 let joined = args.join(" ");
3684
3685 let (qw_words, remainder) = extract_qw_words(&joined);
3686 let mut modules = Vec::new();
3687 let mut seen = HashSet::new();
3688 for word in qw_words {
3689 if let Some(candidate) = normalize_module_name(&word) {
3690 let canonical = canonicalize_perl_module_name(candidate);
3691 if seen.insert(canonical.clone()) {
3692 modules.push(canonical);
3693 }
3694 }
3695 }
3696
3697 for token in remainder.split_whitespace().flat_map(|t| t.split(',')) {
3698 if let Some(candidate) = normalize_module_name(token) {
3699 let canonical = canonicalize_perl_module_name(candidate);
3700 if seen.insert(canonical.clone()) {
3701 modules.push(canonical);
3702 }
3703 }
3704 }
3705
3706 modules
3707}
3708
3709fn extract_module_names_from_call_args(args: &[Node]) -> Vec<String> {
3710 fn collect_from_node(node: &Node, out: &mut Vec<String>) {
3711 match &node.kind {
3712 NodeKind::String { value, .. } => {
3713 out.extend(extract_module_names_from_use_args(std::slice::from_ref(value)));
3714 }
3715 NodeKind::Identifier { name } => {
3716 out.extend(extract_module_names_from_use_args(std::slice::from_ref(name)));
3717 }
3718 NodeKind::ArrayLiteral { elements } => {
3719 for element in elements {
3720 collect_from_node(element, out);
3721 }
3722 }
3723 NodeKind::FunctionCall { name, args, .. } if name == "qw" => {
3724 for arg in args {
3725 collect_from_node(arg, out);
3726 }
3727 }
3728 _ => {}
3729 }
3730 }
3731
3732 let mut modules = Vec::new();
3733 for arg in args {
3734 collect_from_node(arg, &mut modules);
3735 }
3736 modules
3737}
3738
3739fn canonicalize_perl_module_name(name: &str) -> String {
3740 name.replace('\'', "::")
3743}
3744
3745fn legacy_perl_module_name(name: &str) -> String {
3746 name.replace("::", "'")
3747}
3748
3749fn normalize_dependency_module_name(module_name: &str) -> String {
3752 canonicalize_perl_module_name(module_name)
3753}
3754
3755fn extract_qw_words(input: &str) -> (Vec<String>, String) {
3756 let chars: Vec<char> = input.chars().collect();
3757 let mut i = 0;
3758 let mut words = Vec::new();
3759 let mut remainder = String::new();
3760
3761 while i < chars.len() {
3762 if chars[i] == 'q'
3763 && i + 1 < chars.len()
3764 && chars[i + 1] == 'w'
3765 && (i == 0 || !chars[i - 1].is_alphanumeric())
3766 {
3767 let mut j = i + 2;
3768 while j < chars.len() && chars[j].is_whitespace() {
3769 j += 1;
3770 }
3771 if j >= chars.len() {
3772 remainder.push(chars[i]);
3773 i += 1;
3774 continue;
3775 }
3776
3777 let open = chars[j];
3778 let (close, is_paired_delimiter) = match open {
3779 '(' => (')', true),
3780 '[' => (']', true),
3781 '{' => ('}', true),
3782 '<' => ('>', true),
3783 _ => (open, false),
3784 };
3785 if open.is_alphanumeric() || open == '_' || open == '\'' || open == '"' {
3786 remainder.push(chars[i]);
3787 i += 1;
3788 continue;
3789 }
3790
3791 let mut k = j + 1;
3792 if is_paired_delimiter {
3793 let mut depth = 1usize;
3794 while k < chars.len() && depth > 0 {
3795 if chars[k] == open {
3796 depth += 1;
3797 } else if chars[k] == close {
3798 depth -= 1;
3799 }
3800 k += 1;
3801 }
3802 if depth != 0 {
3803 remainder.extend(chars[i..].iter());
3804 break;
3805 }
3806 k -= 1;
3807 } else {
3808 while k < chars.len() && chars[k] != close {
3809 k += 1;
3810 }
3811 if k >= chars.len() {
3812 remainder.extend(chars[i..].iter());
3813 break;
3814 }
3815 }
3816
3817 let content: String = chars[j + 1..k].iter().collect();
3818 for word in content.split_whitespace() {
3819 if !word.is_empty() {
3820 words.push(word.to_string());
3821 }
3822 }
3823 i = k + 1;
3824 continue;
3825 }
3826
3827 remainder.push(chars[i]);
3828 i += 1;
3829 }
3830
3831 (words, remainder)
3832}
3833
3834fn extract_module_name_from_require_args(args: &[Node]) -> Option<String> {
3835 let first = args.first()?;
3836 match &first.kind {
3837 NodeKind::Identifier { name } => Some(name.clone()),
3838 NodeKind::String { value, .. } => {
3839 let cleaned = value.trim_matches('\'').trim_matches('"').trim();
3840 if cleaned.is_empty() {
3841 return None;
3842 }
3843 Some(cleaned.trim_end_matches(".pm").replace('/', "::"))
3844 }
3845 _ => None,
3846 }
3847}
3848
3849fn extract_manual_import_symbols(args: &[Node]) -> Vec<String> {
3850 fn push_if_bareword(out: &mut Vec<String>, token: &str) {
3851 let bare = token.trim().trim_matches('"').trim_matches('\'').trim();
3852 if bare.is_empty() || bare == "," {
3853 return;
3854 }
3855 let is_bareword = bare.bytes().all(|ch| ch.is_ascii_alphanumeric() || ch == b'_')
3856 && bare.as_bytes().first().is_some_and(|ch| ch.is_ascii_alphabetic() || *ch == b'_');
3857 if is_bareword {
3858 out.push(bare.to_string());
3859 }
3860 }
3861
3862 let mut symbols = Vec::new();
3863 for arg in args {
3864 match &arg.kind {
3865 NodeKind::String { value, .. } => push_if_bareword(&mut symbols, value),
3866 NodeKind::Identifier { name } => {
3867 if name.starts_with("qw") {
3868 let content = name
3869 .trim_start_matches("qw")
3870 .trim_start_matches(|c: char| "([{/<|!".contains(c))
3871 .trim_end_matches(|c: char| ")]}/|!>".contains(c));
3872 for token in content.split_whitespace() {
3873 push_if_bareword(&mut symbols, token);
3874 }
3875 } else {
3876 push_if_bareword(&mut symbols, name);
3877 }
3878 }
3879 NodeKind::ArrayLiteral { elements } => {
3880 for element in elements {
3881 if let NodeKind::String { value, .. } = &element.kind {
3882 push_if_bareword(&mut symbols, value);
3883 }
3884 }
3885 }
3886 _ => {}
3887 }
3888 }
3889 symbols.sort();
3890 symbols.dedup();
3891 symbols
3892}
3893
3894#[cfg(test)]
3912fn extract_constant_names_from_use_args(args: &[String]) -> Vec<String> {
3913 use std::collections::HashSet;
3914
3915 fn push_unique(names: &mut Vec<String>, seen: &mut HashSet<String>, candidate: &str) {
3916 if seen.insert(candidate.to_string()) {
3917 names.push(candidate.to_string());
3918 }
3919 }
3920
3921 fn normalize_constant_name(token: &str) -> Option<&str> {
3922 let stripped = token.trim_matches(|c: char| {
3923 matches!(c, '\'' | '"' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ';')
3924 });
3925
3926 if stripped.is_empty() || stripped.starts_with('-') {
3927 return None;
3928 }
3929
3930 stripped.chars().all(|c| c.is_alphanumeric() || c == '_').then_some(stripped)
3931 }
3932
3933 let mut names = Vec::new();
3934 let mut seen = HashSet::new();
3935
3936 let first = match args.first() {
3940 Some(f) => f.as_str(),
3941 None => return names,
3942 };
3943
3944 if first.starts_with("qw") {
3946 let (qw_words, remainder) = extract_qw_words(first);
3947 if remainder.trim().is_empty() {
3948 for word in qw_words {
3949 if let Some(candidate) = normalize_constant_name(&word) {
3950 push_unique(&mut names, &mut seen, candidate);
3951 }
3952 }
3953 return names;
3954 }
3955
3956 let content = first.trim_start_matches("qw").trim_start();
3958 let content = content
3959 .trim_start_matches(|c: char| "([{/<|!".contains(c))
3960 .trim_end_matches(|c: char| ")]}/|!>".contains(c));
3961 for word in content.split_whitespace() {
3962 if let Some(candidate) = normalize_constant_name(word) {
3963 push_unique(&mut names, &mut seen, candidate);
3964 }
3965 }
3966 return names;
3967 }
3968
3969 let starts_hash_form = first == "{"
3971 || first == "+{"
3972 || (first == "+" && args.get(1).map(String::as_str) == Some("{"));
3973 if starts_hash_form {
3974 let mut skipped_leading_plus = false;
3975 let mut iter = args.iter().peekable();
3976 while let Some(arg) = iter.next() {
3977 if arg == "+{" {
3980 skipped_leading_plus = true;
3981 continue;
3982 }
3983 if arg == "+" && !skipped_leading_plus {
3984 skipped_leading_plus = true;
3985 continue;
3986 }
3987 if arg == "{" || arg == "}" || arg == "," || arg == "=>" {
3988 continue;
3989 }
3990 if let Some(candidate) = normalize_constant_name(arg)
3991 && iter.peek().map(|s| s.as_str()) == Some("=>")
3992 {
3993 push_unique(&mut names, &mut seen, candidate);
3994 }
3995 }
3996 return names;
3997 }
3998
3999 if let Some(candidate) = normalize_constant_name(first) {
4002 push_unique(&mut names, &mut seen, candidate);
4003 }
4004
4005 names
4006}
4007
4008impl Default for WorkspaceIndex {
4009 fn default() -> Self {
4010 Self::new()
4011 }
4012}
4013
4014#[cfg(all(feature = "workspace", feature = "lsp-compat"))]
4016pub mod lsp_adapter {
4018 use super::Location as IxLocation;
4019 use lsp_types::Location as LspLocation;
4020 type LspUrl = lsp_types::Uri;
4022
4023 pub fn to_lsp_location(ix: &IxLocation) -> Option<LspLocation> {
4043 parse_url(&ix.uri).map(|uri| {
4044 let start =
4045 lsp_types::Position { line: ix.range.start.line, character: ix.range.start.column };
4046 let end =
4047 lsp_types::Position { line: ix.range.end.line, character: ix.range.end.column };
4048 let range = lsp_types::Range { start, end };
4049 LspLocation { uri, range }
4050 })
4051 }
4052
4053 pub fn to_lsp_locations(all: impl IntoIterator<Item = IxLocation>) -> Vec<LspLocation> {
4074 all.into_iter().filter_map(|ix| to_lsp_location(&ix)).collect()
4075 }
4076
4077 #[cfg(not(target_arch = "wasm32"))]
4078 fn parse_url(s: &str) -> Option<LspUrl> {
4079 use std::str::FromStr;
4081
4082 LspUrl::from_str(s).ok().or_else(|| {
4084 std::path::Path::new(s).canonicalize().ok().and_then(|p| {
4086 crate::workspace_index::fs_path_to_uri(&p)
4088 .ok()
4089 .and_then(|uri_string| LspUrl::from_str(&uri_string).ok())
4090 })
4091 })
4092 }
4093
4094 #[cfg(target_arch = "wasm32")]
4096 fn parse_url(s: &str) -> Option<LspUrl> {
4097 use std::str::FromStr;
4098 LspUrl::from_str(s).ok()
4099 }
4100}
4101
4102#[cfg(test)]
4103mod tests {
4104 use super::*;
4105 use perl_tdd_support::{must, must_some};
4106
4107 #[test]
4108 fn test_use_constant_indexed_as_constant_symbol() {
4109 let index = WorkspaceIndex::new();
4110 let uri = "file:///lib/My/Config.pm";
4111 let code = r#"package My::Config;
4112use constant PI => 3.14159;
4113use constant {
4114 MAX_RETRIES => 3,
4115 TIMEOUT => 30,
4116};
41171;
4118"#;
4119 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4120
4121 let symbols = index.file_symbols(uri);
4122 assert!(
4123 symbols.iter().any(|s| s.name == "PI" && s.kind == SymbolKind::Constant),
4124 "PI should be indexed as a Constant symbol; got: {:?}",
4125 symbols.iter().map(|s| (&s.name, &s.kind)).collect::<Vec<_>>()
4126 );
4127 assert!(
4128 symbols.iter().any(|s| s.name == "MAX_RETRIES" && s.kind == SymbolKind::Constant),
4129 "MAX_RETRIES should be indexed"
4130 );
4131 assert!(
4132 symbols.iter().any(|s| s.name == "TIMEOUT" && s.kind == SymbolKind::Constant),
4133 "TIMEOUT should be indexed"
4134 );
4135
4136 let def = index.find_definition("My::Config::PI");
4138 assert!(def.is_some(), "find_definition('My::Config::PI') should succeed");
4139 }
4140
4141 #[test]
4142 fn test_extract_constant_names_deduplicates_qw_form() {
4143 let names = extract_constant_names_from_use_args(&["qw(FOO BAR FOO)".to_string()]);
4144 assert_eq!(names, vec!["FOO", "BAR"]);
4145 }
4146
4147 #[test]
4148 fn test_extract_constant_names_accepts_quoted_scalar_form() {
4149 let names = extract_constant_names_from_use_args(&[
4150 "'HTTP_OK'".to_string(),
4151 "=>".to_string(),
4152 "200".to_string(),
4153 ]);
4154 assert_eq!(names, vec!["HTTP_OK"]);
4155 }
4156
4157 #[test]
4158 fn test_extract_constant_names_accepts_quoted_hash_form() {
4159 let names = extract_constant_names_from_use_args(&[
4160 "{".to_string(),
4161 "'FOO'".to_string(),
4162 "=>".to_string(),
4163 "1".to_string(),
4164 ",".to_string(),
4165 "\"BAR\"".to_string(),
4166 "=>".to_string(),
4167 "2".to_string(),
4168 "}".to_string(),
4169 ]);
4170 assert_eq!(names, vec!["FOO", "BAR"]);
4171 }
4172
4173 #[test]
4174 fn test_extract_constant_names_accepts_plus_hash_form_split_tokens() {
4175 let names = extract_constant_names_from_use_args(&[
4176 "+".to_string(),
4177 "{".to_string(),
4178 "FOO".to_string(),
4179 "=>".to_string(),
4180 "1".to_string(),
4181 ",".to_string(),
4182 "BAR".to_string(),
4183 "=>".to_string(),
4184 "2".to_string(),
4185 "}".to_string(),
4186 ]);
4187 assert_eq!(names, vec!["FOO", "BAR"]);
4188 }
4189
4190 #[test]
4191 fn test_extract_constant_names_accepts_plus_hash_form_combined_token() {
4192 let names = extract_constant_names_from_use_args(&[
4193 "+{".to_string(),
4194 "FOO".to_string(),
4195 "=>".to_string(),
4196 "1".to_string(),
4197 ",".to_string(),
4198 "BAR".to_string(),
4199 "=>".to_string(),
4200 "2".to_string(),
4201 "}".to_string(),
4202 ]);
4203 assert_eq!(names, vec!["FOO", "BAR"]);
4204 }
4205 #[test]
4206 fn test_use_constant_duplicate_names_indexed_once() {
4207 let index = WorkspaceIndex::new();
4208 let uri = "file:///lib/My/DedupConfig.pm";
4209 let code = r#"package My::DedupConfig;
4210use constant {
4211 RETRY_COUNT => 3,
4212 RETRY_COUNT => 5,
4213};
42141;
4215"#;
4216 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4217
4218 let symbols = index.file_symbols(uri);
4219 let retry_count_symbols = symbols.iter().filter(|s| s.name == "RETRY_COUNT").count();
4220 assert_eq!(
4221 retry_count_symbols, 1,
4222 "RETRY_COUNT should be indexed once even when repeated in use constant hash form"
4223 );
4224 }
4225
4226 #[test]
4227 fn test_use_constant_plus_hash_form_indexes_keys() {
4228 let index = WorkspaceIndex::new();
4229 let uri = "file:///lib/My/PlusHash.pm";
4230 let code = r#"package My::PlusHash;
4231use constant +{
4232 FOO => 1,
4233 BAR => 2,
4234};
42351;
4236"#;
4237 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4238
4239 assert!(index.find_definition("My::PlusHash::FOO").is_some());
4240 assert!(index.find_definition("My::PlusHash::BAR").is_some());
4241 }
4242
4243 #[test]
4244 fn test_basic_indexing() {
4245 let index = WorkspaceIndex::new();
4246 let uri = "file:///test.pl";
4247
4248 let code = r#"
4249package MyPackage;
4250
4251sub hello {
4252 print "Hello";
4253}
4254
4255my $var = 42;
4256"#;
4257
4258 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4259
4260 let symbols = index.file_symbols(uri);
4262 assert!(symbols.iter().any(|s| s.name == "MyPackage" && s.kind == SymbolKind::Package));
4263 assert!(symbols.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
4264 assert!(symbols.iter().any(|s| s.name == "$var" && s.kind.is_variable()));
4265 }
4266
4267 #[test]
4268 fn test_package_symbol_has_no_container_name() {
4269 let index = WorkspaceIndex::new();
4274 let uri = "file:///lib/Foo.pm";
4275 let code = "package Foo;\nsub bar { }\n";
4276 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4277
4278 let symbols = index.file_symbols(uri);
4279 let pkg_sym = symbols.iter().find(|s| s.name == "Foo" && s.kind == SymbolKind::Package);
4280 assert!(pkg_sym.is_some(), "Package symbol not found");
4281 assert_eq!(
4282 pkg_sym.unwrap().container_name,
4283 None,
4284 "Package symbol must not carry a container (was 'main')"
4285 );
4286 }
4287
4288 #[test]
4289 fn test_my_variable_has_no_qualified_name() {
4290 let index = WorkspaceIndex::new();
4295 let uri = "file:///lib/Foo.pm";
4296 let code = "package Foo;\nsub bar { my $x = 1; }\n";
4297 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4298
4299 let symbols = index.file_symbols(uri);
4300 let var_sym = symbols.iter().find(|s| s.name == "$x" && s.kind.is_variable());
4301 assert!(var_sym.is_some(), "$x variable not indexed");
4302 assert_eq!(
4303 var_sym.unwrap().qualified_name,
4304 None,
4305 "my variable must not have a qualified_name"
4306 );
4307
4308 assert!(
4310 index.find_definition("Foo::x").is_none(),
4311 "find_definition(\"Foo::x\") must not return a lexical my variable"
4312 );
4313 }
4314
4315 fn reference_kinds_for(
4316 index: &WorkspaceIndex,
4317 uri: &str,
4318 symbol_name: &str,
4319 ) -> Vec<ReferenceKind> {
4320 let files = index.files.read();
4321 let file = must_some(files.get(uri));
4322 file.references
4323 .get(symbol_name)
4324 .map(|refs| refs.iter().map(|r| r.kind).collect())
4325 .unwrap_or_default()
4326 }
4327
4328 #[test]
4329 fn test_reference_kinds_sub_definition_and_call_are_distinct() {
4330 let index = WorkspaceIndex::new();
4331 let uri = "file:///typed-refs-sub.pl";
4332 let code = "package TypedRefs;
4333sub foo { return 1; }
4334foo();
4335";
4336 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4337
4338 let kinds = reference_kinds_for(&index, uri, "foo");
4339 assert!(kinds.contains(&ReferenceKind::Definition));
4340 assert!(kinds.contains(&ReferenceKind::Usage));
4341 }
4342
4343 #[test]
4344 fn test_reference_kinds_variable_read_and_write_are_distinct() {
4345 let index = WorkspaceIndex::new();
4346 let uri = "file:///typed-refs-var.pl";
4347 let code = "my $value = 1;
4348$value = 2;
4349print $value;
4350";
4351 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4352
4353 let kinds = reference_kinds_for(&index, uri, "$value");
4354 assert!(kinds.contains(&ReferenceKind::Definition));
4355 assert!(kinds.contains(&ReferenceKind::Write));
4356 assert!(kinds.contains(&ReferenceKind::Read));
4357 }
4358
4359 #[test]
4360 fn test_reference_kinds_import_parent_and_export_ok_are_currently_import_only() {
4361 let index = WorkspaceIndex::new();
4362 let uri = "file:///typed-refs-import-export.pm";
4363 let code = "package Child;
4364use parent 'Base';
4365our @EXPORT_OK = qw(foo);
43661;
4367";
4368 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4369
4370 let parent_kinds = reference_kinds_for(&index, uri, "Base");
4371 assert!(
4372 parent_kinds.is_empty(),
4373 "use parent inheritance edges are currently not stored as typed references"
4374 );
4375
4376 let export_symbol_kinds = reference_kinds_for(&index, uri, "foo");
4377 assert!(
4378 export_symbol_kinds.is_empty(),
4379 "EXPORT_OK entries are currently not represented as reference edges"
4380 );
4381 }
4382
4383 #[test]
4384 fn test_reference_kinds_dynamic_and_meta_edges_are_not_typed_yet() {
4385 let index = WorkspaceIndex::new();
4386 let uri = "file:///typed-refs-dynamic.pl";
4387 let code = r#"package TypedRefs;
4388sub foo { 1 }
4389&foo;
4390my $code = \&foo;
4391goto &foo;
4392*alias = \&foo;
4393eval "foo()";
4394with 'RoleName';
4395has 'name' => (is => 'ro');
43961;
4397"#;
4398 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4399
4400 let foo_kinds = reference_kinds_for(&index, uri, "foo");
4401 assert!(
4402 foo_kinds
4403 .iter()
4404 .all(|kind| matches!(kind, ReferenceKind::Definition | ReferenceKind::Usage)),
4405 r"dynamic call forms (&foo, \&foo, goto &foo) are currently flattened to Usage"
4406 );
4407
4408 assert!(
4409 reference_kinds_for(&index, uri, "RoleName").is_empty(),
4410 "role composition edges (`with 'RoleName'`) are not indexed as typed references yet"
4411 );
4412 }
4413
4414 #[test]
4415 fn test_find_references() {
4416 let index = WorkspaceIndex::new();
4417 let uri = "file:///test.pl";
4418
4419 let code = r#"
4420sub test {
4421 my $x = 1;
4422 $x = 2;
4423 print $x;
4424}
4425"#;
4426
4427 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4428
4429 let refs = index.find_references("$x");
4430 assert!(refs.len() >= 2); }
4432
4433 #[test]
4434 fn test_find_references_bare_name_includes_qualified_calls() {
4435 let index = WorkspaceIndex::new();
4436 let uri = "file:///refs.pl";
4437 let code = r#"
4438package RefDemo;
4439sub helper {
4440 return 1;
4441}
4442
4443helper();
4444RefDemo::helper();
4445"#;
4446
4447 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4448
4449 let bare_refs = index.find_references("helper");
4450 let qualified_refs = index.find_references("RefDemo::helper");
4451
4452 assert!(
4453 bare_refs.len() >= qualified_refs.len(),
4454 "bare-name reference lookup should include qualified calls"
4455 );
4456 }
4457
4458 #[test]
4459 fn test_count_usages_bare_name_includes_qualified_calls() {
4460 let index = WorkspaceIndex::new();
4461 let uri = "file:///usage.pl";
4462 let code = r#"
4463package UsageDemo;
4464sub helper {
4465 return 1;
4466}
4467
4468helper();
4469UsageDemo::helper();
4470"#;
4471
4472 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4473
4474 let bare_usage_count = index.count_usages("helper");
4475 let qualified_usage_count = index.count_usages("UsageDemo::helper");
4476
4477 assert!(
4478 bare_usage_count >= qualified_usage_count,
4479 "bare-name usage count should include qualified call sites"
4480 );
4481 }
4482
4483 #[test]
4484 fn test_dependencies() {
4485 let index = WorkspaceIndex::new();
4486 let uri = "file:///test.pl";
4487
4488 let code = r#"
4489use strict;
4490use warnings;
4491use Data::Dumper;
4492"#;
4493
4494 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4495
4496 let deps = index.file_dependencies(uri);
4497 assert!(deps.contains("strict"));
4498 assert!(deps.contains("warnings"));
4499 assert!(deps.contains("Data::Dumper"));
4500 }
4501
4502 #[test]
4503 fn test_uri_to_fs_path_basic() {
4504 if let Some(path) = uri_to_fs_path("file:///tmp/test.pl") {
4506 assert_eq!(path, std::path::PathBuf::from("/tmp/test.pl"));
4507 }
4508
4509 assert!(uri_to_fs_path("not-a-uri").is_none());
4511
4512 assert!(uri_to_fs_path("http://example.com").is_none());
4514 }
4515
4516 #[test]
4517 fn test_uri_to_fs_path_with_spaces() {
4518 if let Some(path) = uri_to_fs_path("file:///tmp/path%20with%20spaces/test.pl") {
4520 assert_eq!(path, std::path::PathBuf::from("/tmp/path with spaces/test.pl"));
4521 }
4522
4523 if let Some(path) = uri_to_fs_path("file:///tmp/My%20Documents/test%20file.pl") {
4525 assert_eq!(path, std::path::PathBuf::from("/tmp/My Documents/test file.pl"));
4526 }
4527 }
4528
4529 #[test]
4530 fn test_uri_to_fs_path_with_unicode() {
4531 if let Some(path) = uri_to_fs_path("file:///tmp/caf%C3%A9/test.pl") {
4533 assert_eq!(path, std::path::PathBuf::from("/tmp/café/test.pl"));
4534 }
4535
4536 if let Some(path) = uri_to_fs_path("file:///tmp/emoji%F0%9F%98%80/test.pl") {
4538 assert_eq!(path, std::path::PathBuf::from("/tmp/emoji😀/test.pl"));
4539 }
4540 }
4541
4542 #[test]
4543 fn test_fs_path_to_uri_basic() {
4544 let result = fs_path_to_uri("/tmp/test.pl");
4546 assert!(result.is_ok());
4547 let uri = must(result);
4548 assert!(uri.starts_with("file://"));
4549 assert!(uri.contains("/tmp/test.pl"));
4550 }
4551
4552 #[test]
4553 fn test_fs_path_to_uri_with_spaces() {
4554 let result = fs_path_to_uri("/tmp/path with spaces/test.pl");
4556 assert!(result.is_ok());
4557 let uri = must(result);
4558 assert!(uri.starts_with("file://"));
4559 assert!(uri.contains("path%20with%20spaces"));
4561 }
4562
4563 #[test]
4564 fn test_fs_path_to_uri_with_unicode() {
4565 let result = fs_path_to_uri("/tmp/café/test.pl");
4567 assert!(result.is_ok());
4568 let uri = must(result);
4569 assert!(uri.starts_with("file://"));
4570 assert!(uri.contains("caf%C3%A9"));
4572 }
4573
4574 #[test]
4575 fn test_normalize_uri_file_schemes() {
4576 let uri = WorkspaceIndex::normalize_uri("file:///tmp/test.pl");
4578 assert_eq!(uri, "file:///tmp/test.pl");
4579
4580 let uri = WorkspaceIndex::normalize_uri("file:///tmp/path%20with%20spaces/test.pl");
4582 assert_eq!(uri, "file:///tmp/path%20with%20spaces/test.pl");
4583 }
4584
4585 #[test]
4586 fn test_normalize_uri_absolute_paths() {
4587 let uri = WorkspaceIndex::normalize_uri("/tmp/test.pl");
4589 assert!(uri.starts_with("file://"));
4590 assert!(uri.contains("/tmp/test.pl"));
4591 }
4592
4593 #[test]
4594 fn test_normalize_uri_special_schemes() {
4595 let uri = WorkspaceIndex::normalize_uri("untitled:Untitled-1");
4597 assert_eq!(uri, "untitled:Untitled-1");
4598 }
4599
4600 #[test]
4601 fn test_roundtrip_conversion() {
4602 let original_uri = "file:///tmp/path%20with%20spaces/caf%C3%A9.pl";
4604
4605 if let Some(path) = uri_to_fs_path(original_uri) {
4606 if let Ok(converted_uri) = fs_path_to_uri(&path) {
4607 assert!(converted_uri.starts_with("file://"));
4609
4610 if let Some(roundtrip_path) = uri_to_fs_path(&converted_uri) {
4612 #[cfg(windows)]
4613 if let Ok(rootless) = path.strip_prefix(std::path::Path::new(r"\")) {
4614 assert!(roundtrip_path.ends_with(rootless));
4615 } else {
4616 assert_eq!(path, roundtrip_path);
4617 }
4618
4619 #[cfg(not(windows))]
4620 assert_eq!(path, roundtrip_path);
4621 }
4622 }
4623 }
4624 }
4625
4626 #[cfg(target_os = "windows")]
4627 #[test]
4628 fn test_windows_paths() {
4629 let result = fs_path_to_uri(r"C:\Users\test\Documents\script.pl");
4631 assert!(result.is_ok());
4632 let uri = must(result);
4633 assert!(uri.starts_with("file://"));
4634
4635 let result = fs_path_to_uri(r"C:\Program Files\My App\script.pl");
4637 assert!(result.is_ok());
4638 let uri = must(result);
4639 assert!(uri.starts_with("file://"));
4640 assert!(uri.contains("Program%20Files"));
4641 }
4642
4643 #[test]
4648 fn test_coordinator_initial_state() {
4649 let coordinator = IndexCoordinator::new();
4650 assert!(matches!(
4651 coordinator.state(),
4652 IndexState::Building { phase: IndexPhase::Idle, .. }
4653 ));
4654 }
4655
4656 #[test]
4657 fn test_transition_to_scanning_phase() {
4658 let coordinator = IndexCoordinator::new();
4659 coordinator.transition_to_scanning();
4660
4661 let state = coordinator.state();
4662 assert!(
4663 matches!(state, IndexState::Building { phase: IndexPhase::Scanning, .. }),
4664 "Expected Building state after scanning, got: {:?}",
4665 state
4666 );
4667 }
4668
4669 #[test]
4670 fn test_transition_to_indexing_phase() {
4671 let coordinator = IndexCoordinator::new();
4672 coordinator.transition_to_scanning();
4673 coordinator.update_scan_progress(3);
4674 coordinator.transition_to_indexing(3);
4675
4676 let state = coordinator.state();
4677 assert!(
4678 matches!(
4679 state,
4680 IndexState::Building { phase: IndexPhase::Indexing, total_count: 3, .. }
4681 ),
4682 "Expected Building state after indexing with total_count 3, got: {:?}",
4683 state
4684 );
4685 }
4686
4687 #[test]
4688 fn test_transition_to_ready() {
4689 let coordinator = IndexCoordinator::new();
4690 coordinator.transition_to_ready(100, 5000);
4691
4692 let state = coordinator.state();
4693 if let IndexState::Ready { file_count, symbol_count, .. } = state {
4694 assert_eq!(file_count, 100);
4695 assert_eq!(symbol_count, 5000);
4696 } else {
4697 unreachable!("Expected Ready state, got: {:?}", state);
4698 }
4699 }
4700
4701 #[test]
4702 fn test_parse_storm_degradation() {
4703 let coordinator = IndexCoordinator::new();
4704 coordinator.transition_to_ready(100, 5000);
4705
4706 for _ in 0..15 {
4708 coordinator.notify_change("file.pm");
4709 }
4710
4711 let state = coordinator.state();
4712 assert!(
4713 matches!(state, IndexState::Degraded { .. }),
4714 "Expected Degraded state, got: {:?}",
4715 state
4716 );
4717 if let IndexState::Degraded { reason, .. } = state {
4718 assert!(matches!(reason, DegradationReason::ParseStorm { .. }));
4719 }
4720 }
4721
4722 #[test]
4723 fn test_recovery_from_parse_storm() {
4724 let coordinator = IndexCoordinator::new();
4725 coordinator.transition_to_ready(100, 5000);
4726
4727 for _ in 0..15 {
4729 coordinator.notify_change("file.pm");
4730 }
4731
4732 for _ in 0..15 {
4734 coordinator.notify_parse_complete("file.pm");
4735 }
4736
4737 assert!(matches!(coordinator.state(), IndexState::Building { .. }));
4739 }
4740
4741 #[test]
4742 fn test_query_dispatch_ready() {
4743 let coordinator = IndexCoordinator::new();
4744 coordinator.transition_to_ready(100, 5000);
4745
4746 let result = coordinator.query(|_index| "full_query", |_index| "partial_query");
4747
4748 assert_eq!(result, "full_query");
4749 }
4750
4751 #[test]
4752 fn test_query_dispatch_degraded() {
4753 let coordinator = IndexCoordinator::new();
4754 let result = coordinator.query(|_index| "full_query", |_index| "partial_query");
4757
4758 assert_eq!(result, "partial_query");
4759 }
4760
4761 #[test]
4762 fn test_metrics_pending_count() {
4763 let coordinator = IndexCoordinator::new();
4764
4765 coordinator.notify_change("file1.pm");
4766 coordinator.notify_change("file2.pm");
4767
4768 assert_eq!(coordinator.metrics.pending_count(), 2);
4769
4770 coordinator.notify_parse_complete("file1.pm");
4771 assert_eq!(coordinator.metrics.pending_count(), 1);
4772 }
4773
4774 #[test]
4775 fn test_instrumentation_records_transitions() {
4776 let coordinator = IndexCoordinator::new();
4777 coordinator.transition_to_ready(10, 100);
4778
4779 let snapshot = coordinator.instrumentation_snapshot();
4780 let transition =
4781 IndexStateTransition { from: IndexStateKind::Building, to: IndexStateKind::Ready };
4782 let count = snapshot.state_transition_counts.get(&transition).copied().unwrap_or(0);
4783 assert_eq!(count, 1);
4784 }
4785
4786 #[test]
4787 fn test_instrumentation_records_early_exit() {
4788 let coordinator = IndexCoordinator::new();
4789 coordinator.record_early_exit(EarlyExitReason::InitialTimeBudget, 25, 1, 10);
4790
4791 let snapshot = coordinator.instrumentation_snapshot();
4792 let count = snapshot
4793 .early_exit_counts
4794 .get(&EarlyExitReason::InitialTimeBudget)
4795 .copied()
4796 .unwrap_or(0);
4797 assert_eq!(count, 1);
4798 assert!(snapshot.last_early_exit.is_some());
4799 }
4800
4801 #[test]
4802 fn test_custom_limits() {
4803 let limits = IndexResourceLimits {
4804 max_files: 5000,
4805 max_symbols_per_file: 1000,
4806 max_total_symbols: 100_000,
4807 max_ast_cache_bytes: 128 * 1024 * 1024,
4808 max_ast_cache_items: 50,
4809 max_scan_duration_ms: 30_000,
4810 };
4811
4812 let coordinator = IndexCoordinator::with_limits(limits.clone());
4813 assert_eq!(coordinator.limits.max_files, 5000);
4814 assert_eq!(coordinator.limits.max_total_symbols, 100_000);
4815 }
4816
4817 #[test]
4818 fn test_degradation_preserves_symbol_count() {
4819 let coordinator = IndexCoordinator::new();
4820 coordinator.transition_to_ready(100, 5000);
4821
4822 coordinator.transition_to_degraded(DegradationReason::IoError {
4823 message: "Test error".to_string(),
4824 });
4825
4826 let state = coordinator.state();
4827 assert!(
4828 matches!(state, IndexState::Degraded { .. }),
4829 "Expected Degraded state, got: {:?}",
4830 state
4831 );
4832 if let IndexState::Degraded { available_symbols, .. } = state {
4833 assert_eq!(available_symbols, 5000);
4834 }
4835 }
4836
4837 #[test]
4838 fn test_index_access() {
4839 let coordinator = IndexCoordinator::new();
4840 let index = coordinator.index();
4841
4842 assert!(index.all_symbols().is_empty());
4844 }
4845
4846 #[test]
4847 fn test_resource_limit_enforcement_max_files() {
4848 let limits = IndexResourceLimits {
4849 max_files: 5,
4850 max_symbols_per_file: 1000,
4851 max_total_symbols: 50_000,
4852 max_ast_cache_bytes: 128 * 1024 * 1024,
4853 max_ast_cache_items: 50,
4854 max_scan_duration_ms: 30_000,
4855 };
4856
4857 let coordinator = IndexCoordinator::with_limits(limits);
4858 coordinator.transition_to_ready(10, 100);
4859
4860 for i in 0..10 {
4862 let uri_str = format!("file:///test{}.pl", i);
4863 let uri = must(url::Url::parse(&uri_str));
4864 let code = "sub test { }";
4865 must(coordinator.index().index_file(uri, code.to_string()));
4866 }
4867
4868 coordinator.enforce_limits();
4870
4871 let state = coordinator.state();
4872 assert!(
4873 matches!(
4874 state,
4875 IndexState::Degraded {
4876 reason: DegradationReason::ResourceLimit { kind: ResourceKind::MaxFiles },
4877 ..
4878 }
4879 ),
4880 "Expected Degraded state with ResourceLimit(MaxFiles), got: {:?}",
4881 state
4882 );
4883 }
4884
4885 #[test]
4886 fn test_resource_limit_enforcement_max_symbols() {
4887 let limits = IndexResourceLimits {
4888 max_files: 100,
4889 max_symbols_per_file: 10,
4890 max_total_symbols: 50, max_ast_cache_bytes: 128 * 1024 * 1024,
4892 max_ast_cache_items: 50,
4893 max_scan_duration_ms: 30_000,
4894 };
4895
4896 let coordinator = IndexCoordinator::with_limits(limits);
4897 coordinator.transition_to_ready(0, 0);
4898
4899 for i in 0..10 {
4901 let uri_str = format!("file:///test{}.pl", i);
4902 let uri = must(url::Url::parse(&uri_str));
4903 let code = r#"
4905package Test;
4906sub sub1 { }
4907sub sub2 { }
4908sub sub3 { }
4909sub sub4 { }
4910sub sub5 { }
4911sub sub6 { }
4912sub sub7 { }
4913sub sub8 { }
4914sub sub9 { }
4915sub sub10 { }
4916"#;
4917 must(coordinator.index().index_file(uri, code.to_string()));
4918 }
4919
4920 coordinator.enforce_limits();
4922
4923 let state = coordinator.state();
4924 assert!(
4925 matches!(
4926 state,
4927 IndexState::Degraded {
4928 reason: DegradationReason::ResourceLimit { kind: ResourceKind::MaxSymbols },
4929 ..
4930 }
4931 ),
4932 "Expected Degraded state with ResourceLimit(MaxSymbols), got: {:?}",
4933 state
4934 );
4935 }
4936
4937 #[test]
4938 fn test_check_limits_returns_none_within_bounds() {
4939 let coordinator = IndexCoordinator::new();
4940 coordinator.transition_to_ready(0, 0);
4941
4942 for i in 0..5 {
4944 let uri_str = format!("file:///test{}.pl", i);
4945 let uri = must(url::Url::parse(&uri_str));
4946 let code = "sub test { }";
4947 must(coordinator.index().index_file(uri, code.to_string()));
4948 }
4949
4950 let limit_check = coordinator.check_limits();
4952 assert!(limit_check.is_none(), "check_limits should return None when within bounds");
4953
4954 assert!(
4956 matches!(coordinator.state(), IndexState::Ready { .. }),
4957 "State should remain Ready when within limits"
4958 );
4959 }
4960
4961 #[test]
4962 fn test_enforce_limits_called_on_transition_to_ready() {
4963 let limits = IndexResourceLimits {
4964 max_files: 3,
4965 max_symbols_per_file: 1000,
4966 max_total_symbols: 50_000,
4967 max_ast_cache_bytes: 128 * 1024 * 1024,
4968 max_ast_cache_items: 50,
4969 max_scan_duration_ms: 30_000,
4970 };
4971
4972 let coordinator = IndexCoordinator::with_limits(limits);
4973
4974 for i in 0..5 {
4976 let uri_str = format!("file:///test{}.pl", i);
4977 let uri = must(url::Url::parse(&uri_str));
4978 let code = "sub test { }";
4979 must(coordinator.index().index_file(uri, code.to_string()));
4980 }
4981
4982 coordinator.transition_to_ready(5, 100);
4984
4985 let state = coordinator.state();
4986 assert!(
4987 matches!(
4988 state,
4989 IndexState::Degraded {
4990 reason: DegradationReason::ResourceLimit { kind: ResourceKind::MaxFiles },
4991 ..
4992 }
4993 ),
4994 "Expected Degraded state after transition_to_ready with exceeded limits, got: {:?}",
4995 state
4996 );
4997 }
4998
4999 #[test]
5000 fn test_state_transition_guard_ready_to_ready() {
5001 let coordinator = IndexCoordinator::new();
5003 coordinator.transition_to_ready(100, 5000);
5004
5005 coordinator.transition_to_ready(150, 7500);
5007
5008 let state = coordinator.state();
5009 assert!(
5010 matches!(state, IndexState::Ready { file_count: 150, symbol_count: 7500, .. }),
5011 "Expected Ready state with updated metrics, got: {:?}",
5012 state
5013 );
5014 }
5015
5016 #[test]
5017 fn test_state_transition_guard_building_to_building() {
5018 let coordinator = IndexCoordinator::new();
5020
5021 coordinator.transition_to_building(100);
5023
5024 let state = coordinator.state();
5025 assert!(
5026 matches!(state, IndexState::Building { indexed_count: 0, total_count: 100, .. }),
5027 "Expected Building state, got: {:?}",
5028 state
5029 );
5030
5031 coordinator.transition_to_building(200);
5033
5034 let state = coordinator.state();
5035 assert!(
5036 matches!(state, IndexState::Building { indexed_count: 0, total_count: 200, .. }),
5037 "Expected Building state, got: {:?}",
5038 state
5039 );
5040 }
5041
5042 #[test]
5043 fn test_state_transition_ready_to_building() {
5044 let coordinator = IndexCoordinator::new();
5046 coordinator.transition_to_ready(100, 5000);
5047
5048 coordinator.transition_to_building(150);
5050
5051 let state = coordinator.state();
5052 assert!(
5053 matches!(state, IndexState::Building { indexed_count: 0, total_count: 150, .. }),
5054 "Expected Building state after re-scan, got: {:?}",
5055 state
5056 );
5057 }
5058
5059 #[test]
5060 fn test_state_transition_degraded_to_building() {
5061 let coordinator = IndexCoordinator::new();
5063 coordinator.transition_to_degraded(DegradationReason::IoError {
5064 message: "Test error".to_string(),
5065 });
5066
5067 coordinator.transition_to_building(100);
5069
5070 let state = coordinator.state();
5071 assert!(
5072 matches!(state, IndexState::Building { indexed_count: 0, total_count: 100, .. }),
5073 "Expected Building state after recovery, got: {:?}",
5074 state
5075 );
5076 }
5077
5078 #[test]
5079 fn test_update_building_progress() {
5080 let coordinator = IndexCoordinator::new();
5081 coordinator.transition_to_building(100);
5082
5083 coordinator.update_building_progress(50);
5085
5086 let state = coordinator.state();
5087 assert!(
5088 matches!(state, IndexState::Building { indexed_count: 50, total_count: 100, .. }),
5089 "Expected Building state with updated progress, got: {:?}",
5090 state
5091 );
5092
5093 coordinator.update_building_progress(100);
5095
5096 let state = coordinator.state();
5097 assert!(
5098 matches!(state, IndexState::Building { indexed_count: 100, total_count: 100, .. }),
5099 "Expected Building state with completed progress, got: {:?}",
5100 state
5101 );
5102 }
5103
5104 #[test]
5105 fn test_scan_timeout_detection() {
5106 let limits = IndexResourceLimits {
5108 max_scan_duration_ms: 0, ..Default::default()
5110 };
5111
5112 let coordinator = IndexCoordinator::with_limits(limits);
5113 coordinator.transition_to_building(100);
5114
5115 std::thread::sleep(std::time::Duration::from_millis(1));
5117
5118 coordinator.update_building_progress(10);
5120
5121 let state = coordinator.state();
5122 assert!(
5123 matches!(
5124 state,
5125 IndexState::Degraded { reason: DegradationReason::ScanTimeout { .. }, .. }
5126 ),
5127 "Expected Degraded state with ScanTimeout, got: {:?}",
5128 state
5129 );
5130 }
5131
5132 #[test]
5133 fn test_scan_timeout_does_not_trigger_within_limit() {
5134 let limits = IndexResourceLimits {
5136 max_scan_duration_ms: 10_000, ..Default::default()
5138 };
5139
5140 let coordinator = IndexCoordinator::with_limits(limits);
5141 coordinator.transition_to_building(100);
5142
5143 coordinator.update_building_progress(50);
5145
5146 let state = coordinator.state();
5147 assert!(
5148 matches!(state, IndexState::Building { indexed_count: 50, .. }),
5149 "Expected Building state (no timeout), got: {:?}",
5150 state
5151 );
5152 }
5153
5154 #[test]
5155 fn test_early_exit_optimization_unchanged_content() {
5156 let index = WorkspaceIndex::new();
5157 let uri = must(url::Url::parse("file:///test.pl"));
5158 let code = r#"
5159package MyPackage;
5160
5161sub hello {
5162 print "Hello";
5163}
5164"#;
5165
5166 must(index.index_file(uri.clone(), code.to_string()));
5168 let symbols1 = index.file_symbols(uri.as_str());
5169 assert!(symbols1.iter().any(|s| s.name == "MyPackage" && s.kind == SymbolKind::Package));
5170 assert!(symbols1.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
5171
5172 must(index.index_file(uri.clone(), code.to_string()));
5175 let symbols2 = index.file_symbols(uri.as_str());
5176 assert_eq!(symbols1.len(), symbols2.len());
5177 assert!(symbols2.iter().any(|s| s.name == "MyPackage" && s.kind == SymbolKind::Package));
5178 assert!(symbols2.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
5179 }
5180
5181 #[test]
5182 fn test_early_exit_optimization_changed_content() {
5183 let index = WorkspaceIndex::new();
5184 let uri = must(url::Url::parse("file:///test.pl"));
5185 let code1 = r#"
5186package MyPackage;
5187
5188sub hello {
5189 print "Hello";
5190}
5191"#;
5192
5193 let code2 = r#"
5194package MyPackage;
5195
5196sub goodbye {
5197 print "Goodbye";
5198}
5199"#;
5200
5201 must(index.index_file(uri.clone(), code1.to_string()));
5203 let symbols1 = index.file_symbols(uri.as_str());
5204 assert!(symbols1.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
5205 assert!(!symbols1.iter().any(|s| s.name == "goodbye"));
5206
5207 must(index.index_file(uri.clone(), code2.to_string()));
5209 let symbols2 = index.file_symbols(uri.as_str());
5210 assert!(!symbols2.iter().any(|s| s.name == "hello"));
5211 assert!(symbols2.iter().any(|s| s.name == "goodbye" && s.kind == SymbolKind::Subroutine));
5212 }
5213
5214 #[test]
5215 fn test_early_exit_optimization_whitespace_only_change() {
5216 let index = WorkspaceIndex::new();
5217 let uri = must(url::Url::parse("file:///test.pl"));
5218 let code1 = r#"
5219package MyPackage;
5220
5221sub hello {
5222 print "Hello";
5223}
5224"#;
5225
5226 let code2 = r#"
5227package MyPackage;
5228
5229
5230sub hello {
5231 print "Hello";
5232}
5233"#;
5234
5235 must(index.index_file(uri.clone(), code1.to_string()));
5237 let symbols1 = index.file_symbols(uri.as_str());
5238 assert!(symbols1.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
5239
5240 must(index.index_file(uri.clone(), code2.to_string()));
5242 let symbols2 = index.file_symbols(uri.as_str());
5243 assert!(symbols2.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
5245 }
5246
5247 #[test]
5248 fn test_reindex_file_refreshes_symbol_cache_for_removed_names() {
5249 let index = WorkspaceIndex::new();
5250 let uri1 = must(url::Url::parse("file:///lib/A.pm"));
5251 let uri2 = must(url::Url::parse("file:///lib/B.pm"));
5252 let code1 = "package A;\nsub foo { return 1; }\n1;\n";
5253 let code2 = "package B;\nsub foo { return 2; }\n1;\n";
5254 let code2_reindexed = "package B;\nsub bar { return 3; }\n1;\n";
5255
5256 must(index.index_file(uri1.clone(), code1.to_string()));
5257 must(index.index_file(uri2.clone(), code2.to_string()));
5258 must(index.index_file(uri2.clone(), code2_reindexed.to_string()));
5259
5260 let foo_location = must_some(index.find_definition("foo"));
5261 assert_eq!(foo_location.uri, uri1.to_string());
5262
5263 let bar_location = must_some(index.find_definition("bar"));
5264 assert_eq!(bar_location.uri, uri2.to_string());
5265 }
5266
5267 #[test]
5268 fn test_remove_file_preserves_other_colliding_symbol_entries() {
5269 let index = WorkspaceIndex::new();
5270 let uri1 = must(url::Url::parse("file:///lib/A.pm"));
5271 let uri2 = must(url::Url::parse("file:///lib/B.pm"));
5272 let code1 = "package A;\nsub foo { return 1; }\n1;\n";
5273 let code2 = "package B;\nsub foo { return 2; }\n1;\n";
5274
5275 must(index.index_file(uri1.clone(), code1.to_string()));
5276 must(index.index_file(uri2.clone(), code2.to_string()));
5277
5278 index.remove_file(uri2.as_str());
5279
5280 let foo_location = must_some(index.find_definition("foo"));
5281 assert_eq!(foo_location.uri, uri1.to_string());
5282 }
5283
5284 #[test]
5285 fn test_count_usages_no_double_counting_for_qualified_calls() {
5286 let index = WorkspaceIndex::new();
5287
5288 let uri1 = "file:///lib/Utils.pm";
5290 let code1 = r#"
5291package Utils;
5292
5293sub process_data {
5294 return 1;
5295}
5296"#;
5297 must(index.index_file(must(url::Url::parse(uri1)), code1.to_string()));
5298
5299 let uri2 = "file:///app.pl";
5301 let code2 = r#"
5302use Utils;
5303Utils::process_data();
5304Utils::process_data();
5305"#;
5306 must(index.index_file(must(url::Url::parse(uri2)), code2.to_string()));
5307
5308 let count = index.count_usages("Utils::process_data");
5312
5313 assert_eq!(
5316 count, 2,
5317 "count_usages should not double-count qualified calls, got {} (expected 2)",
5318 count
5319 );
5320
5321 let refs = index.find_references("Utils::process_data");
5323 let non_def_refs: Vec<_> =
5324 refs.iter().filter(|loc| loc.uri != "file:///lib/Utils.pm").collect();
5325 assert_eq!(
5326 non_def_refs.len(),
5327 2,
5328 "find_references should not return duplicates for qualified calls, got {} non-def refs",
5329 non_def_refs.len()
5330 );
5331 }
5332
5333 #[test]
5334 fn test_batch_indexing() {
5335 let index = WorkspaceIndex::new();
5336 let files: Vec<(Url, String)> = (0..5)
5337 .map(|i| {
5338 let uri = must(Url::parse(&format!("file:///batch/module{}.pm", i)));
5339 let code =
5340 format!("package Batch::Mod{};\nsub func_{} {{ return {}; }}\n1;", i, i, i);
5341 (uri, code)
5342 })
5343 .collect();
5344
5345 let errors = index.index_files_batch(files);
5346 assert!(errors.is_empty(), "batch indexing errors: {:?}", errors);
5347 assert_eq!(index.file_count(), 5);
5348 assert!(index.find_definition("Batch::Mod0::func_0").is_some());
5349 assert!(index.find_definition("Batch::Mod4::func_4").is_some());
5350 }
5351
5352 #[test]
5353 fn test_batch_indexing_skips_unchanged() {
5354 let index = WorkspaceIndex::new();
5355 let uri = must(Url::parse("file:///batch/skip.pm"));
5356 let code = "package Skip;\nsub skip_fn { 1 }\n1;".to_string();
5357
5358 index.index_file(uri.clone(), code.clone()).ok();
5359 assert_eq!(index.file_count(), 1);
5360
5361 let errors = index.index_files_batch(vec![(uri, code)]);
5362 assert!(errors.is_empty());
5363 assert_eq!(index.file_count(), 1);
5364 }
5365
5366 #[test]
5367 fn test_incremental_update_preserves_other_symbols() {
5368 let index = WorkspaceIndex::new();
5369
5370 let uri_a = must(Url::parse("file:///incr/a.pm"));
5371 let uri_b = must(Url::parse("file:///incr/b.pm"));
5372 index.index_file(uri_a.clone(), "package A;\nsub a_func { 1 }\n1;".into()).ok();
5373 index.index_file(uri_b.clone(), "package B;\nsub b_func { 2 }\n1;".into()).ok();
5374
5375 assert!(index.find_definition("A::a_func").is_some());
5376 assert!(index.find_definition("B::b_func").is_some());
5377
5378 index.index_file(uri_a, "package A;\nsub a_func_v2 { 11 }\n1;".into()).ok();
5379
5380 assert!(index.find_definition("A::a_func_v2").is_some());
5381 assert!(index.find_definition("B::b_func").is_some());
5382 }
5383
5384 #[test]
5385 fn test_remove_file_preserves_shadowed_symbols() {
5386 let index = WorkspaceIndex::new();
5387
5388 let uri_a = must(Url::parse("file:///shadow/a.pm"));
5389 let uri_b = must(Url::parse("file:///shadow/b.pm"));
5390 index.index_file(uri_a.clone(), "package ShadowA;\nsub helper { 1 }\n1;".into()).ok();
5391 index.index_file(uri_b.clone(), "package ShadowB;\nsub helper { 2 }\n1;".into()).ok();
5392
5393 assert!(index.find_definition("helper").is_some());
5394
5395 index.remove_file_url(&uri_a);
5396 assert!(index.find_definition("helper").is_some());
5397 assert!(index.find_definition("ShadowB::helper").is_some());
5398 }
5399
5400 #[test]
5405 fn test_index_dependency_via_use_parent_end_to_end() {
5406 let index = WorkspaceIndex::new();
5412
5413 let base_url = must(url::Url::parse("file:///test/workspace/lib/MyBase.pm"));
5414 must(index.index_file(
5415 base_url,
5416 "package MyBase;\nsub new { bless {}, shift }\n1;\n".to_string(),
5417 ));
5418
5419 let child_url = must(url::Url::parse("file:///test/workspace/child.pl"));
5420 must(index.index_file(child_url, "package Child;\nuse parent 'MyBase';\n1;\n".to_string()));
5421
5422 let dependents = index.find_dependents("MyBase");
5423 assert!(
5424 !dependents.is_empty(),
5425 "find_dependents('MyBase') returned empty — \
5426 use parent 'MyBase' should register MyBase as a dependency. \
5427 Dependencies in index: {:?}",
5428 {
5429 let files = index.files.read();
5430 files
5431 .iter()
5432 .map(|(k, v)| (k.clone(), v.dependencies.iter().cloned().collect::<Vec<_>>()))
5433 .collect::<Vec<_>>()
5434 }
5435 );
5436 assert!(
5437 dependents.contains(&"file:///test/workspace/child.pl".to_string()),
5438 "child.pl should be in dependents, got: {:?}",
5439 dependents
5440 );
5441 }
5442
5443 #[test]
5444 fn test_find_dependents_normalizes_legacy_separator_in_query() {
5445 let index = WorkspaceIndex::new();
5446 let uri = must(url::Url::parse("file:///test/workspace/legacy-query.pl"));
5447 let src = "package Child;\nuse parent 'My::Base';\n1;\n";
5448 must(index.index_file(uri, src.to_string()));
5449
5450 let dependents = index.find_dependents("My'Base");
5451 assert_eq!(dependents, vec!["file:///test/workspace/legacy-query.pl".to_string()]);
5452 }
5453
5454 #[test]
5455 fn test_file_dependencies_normalize_legacy_separator_in_source() {
5456 let index = WorkspaceIndex::new();
5457 let uri = must(url::Url::parse("file:///test/workspace/legacy-source.pl"));
5458 let src = "package Child;\nuse parent \"My'Base\";\n1;\n";
5459 must(index.index_file(uri.clone(), src.to_string()));
5460
5461 let deps = index.file_dependencies(uri.as_str());
5462 assert!(deps.contains("My::Base"));
5463 assert!(!deps.contains("My'Base"));
5464 }
5465
5466 #[test]
5467 fn test_index_dependency_via_moose_extends_end_to_end() -> Result<(), Box<dyn std::error::Error>>
5468 {
5469 let index = WorkspaceIndex::new();
5470
5471 let parent_url = must(url::Url::parse("file:///test/workspace/lib/My/App/Parent.pm"));
5472 must(index.index_file(parent_url, "package My::App::Parent;\n1;\n".to_string()));
5473
5474 let child_url = must(url::Url::parse("file:///test/workspace/child-moose.pl"));
5475 let child_src = "package Child;\nuse Moose;\nextends 'My::App::Parent';\n1;\n";
5476 must(index.index_file(child_url, child_src.to_string()));
5477
5478 let dependents = index.find_dependents("My::App::Parent");
5479 assert!(
5480 dependents.contains(&"file:///test/workspace/child-moose.pl".to_string()),
5481 "expected child-moose.pl in dependents, got: {dependents:?}"
5482 );
5483 Ok(())
5484 }
5485
5486 #[test]
5487 fn test_index_dependency_via_moo_with_role_end_to_end() -> Result<(), Box<dyn std::error::Error>>
5488 {
5489 let index = WorkspaceIndex::new();
5490
5491 let role_url = must(url::Url::parse("file:///test/workspace/lib/My/App/Role.pm"));
5492 must(index.index_file(role_url, "package My::App::Role;\n1;\n".to_string()));
5493
5494 let consumer_url = must(url::Url::parse("file:///test/workspace/consumer-moo.pl"));
5495 let consumer_src = "package Consumer;\nuse Moo;\nwith 'My::App::Role';\n1;\n";
5496 must(index.index_file(consumer_url.clone(), consumer_src.to_string()));
5497
5498 let dependents = index.find_dependents("My::App::Role");
5499 assert!(
5500 dependents.contains(&"file:///test/workspace/consumer-moo.pl".to_string()),
5501 "expected consumer-moo.pl in dependents, got: {dependents:?}"
5502 );
5503
5504 let deps = index.file_dependencies(consumer_url.as_str());
5505 assert!(deps.contains("My::App::Role"));
5506 Ok(())
5507 }
5508
5509 #[test]
5510 fn test_index_dependency_via_literal_require_end_to_end()
5511 -> Result<(), Box<dyn std::error::Error>> {
5512 let index = WorkspaceIndex::new();
5513 let uri = must(url::Url::parse("file:///test/workspace/require-consumer.pl"));
5514 let src = "package Consumer;\nrequire My::Loader;\n1;\n";
5515 must(index.index_file(uri.clone(), src.to_string()));
5516
5517 let deps = index.file_dependencies(uri.as_str());
5518 assert!(
5519 deps.contains("My::Loader"),
5520 "literal require should register module dependency, got: {deps:?}"
5521 );
5522 Ok(())
5523 }
5524
5525 #[test]
5526 fn test_manual_import_symbols_are_indexed_as_import_references()
5527 -> Result<(), Box<dyn std::error::Error>> {
5528 let index = WorkspaceIndex::new();
5529 let uri = must(url::Url::parse("file:///test/workspace/manual-import.pl"));
5530 let src = r#"package Consumer;
5531require My::Tools;
5532My::Tools->import(qw(helper_one helper_two));
5533helper_one();
55341;
5535"#;
5536 must(index.index_file(uri.clone(), src.to_string()));
5537
5538 let deps = index.file_dependencies(uri.as_str());
5539 assert!(
5540 deps.contains("My::Tools"),
5541 "manual import target should be tracked as dependency, got: {deps:?}"
5542 );
5543
5544 for symbol in ["helper_one", "helper_two"] {
5545 let refs = index.find_references(symbol);
5546 assert!(
5547 !refs.is_empty(),
5548 "expected at least one indexed reference for imported symbol `{symbol}`"
5549 );
5550 }
5551 Ok(())
5552 }
5553
5554 #[test]
5555 fn test_parser_produces_correct_args_for_use_parent() {
5556 use crate::Parser;
5560 let mut p = Parser::new("package Child;\nuse parent 'MyBase';\n1;\n");
5561 let ast = must(p.parse());
5562 assert!(
5563 matches!(ast.kind, NodeKind::Program { .. }),
5564 "Expected Program root, got {:?}",
5565 ast.kind
5566 );
5567 let NodeKind::Program { statements } = &ast.kind else {
5568 return;
5569 };
5570 let mut found_parent_use = false;
5571 for stmt in statements {
5572 if let NodeKind::Use { module, args, .. } = &stmt.kind {
5573 if module == "parent" {
5574 found_parent_use = true;
5575 assert_eq!(
5576 args,
5577 &["'MyBase'".to_string()],
5578 "Expected args=[\"'MyBase'\"] for `use parent 'MyBase'`, got: {:?}",
5579 args
5580 );
5581 let extracted = extract_module_names_from_use_args(args);
5582 assert_eq!(
5583 extracted,
5584 vec!["MyBase".to_string()],
5585 "extract_module_names_from_use_args should return [\"MyBase\"], got {:?}",
5586 extracted
5587 );
5588 }
5589 }
5590 }
5591 assert!(found_parent_use, "No Use node with module='parent' found in AST");
5592 }
5593
5594 #[test]
5599 fn test_extract_module_names_single_quoted() {
5600 let names = extract_module_names_from_use_args(&["'Foo::Bar'".to_string()]);
5601 assert_eq!(names, vec!["Foo::Bar"]);
5602 }
5603
5604 #[test]
5605 fn test_extract_module_names_double_quoted() {
5606 let names = extract_module_names_from_use_args(&["\"Foo::Bar\"".to_string()]);
5607 assert_eq!(names, vec!["Foo::Bar"]);
5608 }
5609
5610 #[test]
5611 fn test_extract_module_names_qw_list() {
5612 let names = extract_module_names_from_use_args(&["qw(Foo::Bar Other::Base)".to_string()]);
5613 assert_eq!(names, vec!["Foo::Bar", "Other::Base"]);
5614 }
5615
5616 #[test]
5617 fn test_extract_module_names_qw_slash_delimiter() {
5618 let names = extract_module_names_from_use_args(&["qw/Foo::Bar Other::Base/".to_string()]);
5619 assert_eq!(names, vec!["Foo::Bar", "Other::Base"]);
5620 }
5621
5622 #[test]
5623 fn test_extract_module_names_qw_with_space_before_delimiter() {
5624 let names = extract_module_names_from_use_args(&["qw [Foo::Bar Other::Base]".to_string()]);
5625 assert_eq!(names, vec!["Foo::Bar", "Other::Base"]);
5626 }
5627
5628 #[test]
5629 fn test_extract_module_names_qw_list_trims_wrapped_punctuation() {
5630 let names =
5631 extract_module_names_from_use_args(&["qw((Foo::Bar) [Other::Base],)".to_string()]);
5632 assert_eq!(names, vec!["Foo::Bar", "Other::Base"]);
5633 }
5634
5635 #[test]
5636 fn test_extract_module_names_norequire_flag() {
5637 let names = extract_module_names_from_use_args(&[
5638 "-norequire".to_string(),
5639 "'Foo::Bar'".to_string(),
5640 ]);
5641 assert_eq!(names, vec!["Foo::Bar"]);
5642 }
5643
5644 #[test]
5645 fn test_extract_module_names_empty_args() {
5646 let names = extract_module_names_from_use_args(&[]);
5647 assert!(names.is_empty());
5648 }
5649
5650 #[test]
5651 fn test_extract_module_names_legacy_separator() {
5652 let names = extract_module_names_from_use_args(&["'Foo'Bar'".to_string()]);
5654 assert_eq!(names, vec!["Foo::Bar"]);
5656 }
5657
5658 #[test]
5659 fn test_find_dependents_matches_legacy_separator_queries() {
5660 let index = WorkspaceIndex::new();
5661 let base_uri = must(url::Url::parse("file:///test/workspace/lib/Foo/Bar.pm"));
5662 let child_uri = must(url::Url::parse("file:///test/workspace/child.pl"));
5663
5664 must(index.index_file(base_uri, "package Foo::Bar;\n1;\n".to_string()));
5665 must(index.index_file(
5666 child_uri.clone(),
5667 "package Child;\nuse parent qw(Foo'Bar);\n1;\n".to_string(),
5668 ));
5669
5670 let dependents_modern = index.find_dependents("Foo::Bar");
5671 assert!(
5672 dependents_modern.contains(&child_uri.to_string()),
5673 "Expected dependency match when queried with modern separator"
5674 );
5675
5676 let dependents_legacy = index.find_dependents("Foo'Bar");
5677 assert!(
5678 dependents_legacy.contains(&child_uri.to_string()),
5679 "Expected dependency match when queried with legacy separator"
5680 );
5681 }
5682
5683 #[test]
5684 fn test_extract_module_names_comma_adjacent_tokens() {
5685 let names = extract_module_names_from_use_args(&[
5686 "'Foo::Bar',".to_string(),
5687 "\"Other::Base\",".to_string(),
5688 "'Last::One'".to_string(),
5689 ]);
5690 assert_eq!(names, vec!["Foo::Bar", "Other::Base", "Last::One"]);
5691 }
5692
5693 #[test]
5694 fn test_extract_module_names_parenthesized_without_spaces() {
5695 let names = extract_module_names_from_use_args(&["('Foo::Bar','Other::Base')".to_string()]);
5696 assert_eq!(names, vec!["Foo::Bar", "Other::Base"]);
5697 }
5698
5699 #[test]
5700 fn test_extract_module_names_deduplicates_identical_entries() {
5701 let names = extract_module_names_from_use_args(&[
5702 "qw(Foo::Bar Foo::Bar)".to_string(),
5703 "'Foo::Bar'".to_string(),
5704 ]);
5705 assert_eq!(names, vec!["Foo::Bar"]);
5706 }
5707
5708 #[test]
5709 fn test_extract_module_names_trims_semicolon_suffix() {
5710 let names = extract_module_names_from_use_args(&[
5711 "'Foo::Bar',".to_string(),
5712 "'Other::Base',".to_string(),
5713 "'Third::Leaf';".to_string(),
5714 ]);
5715 assert_eq!(names, vec!["Foo::Bar", "Other::Base", "Third::Leaf"]);
5716 }
5717
5718 #[test]
5719 fn test_extract_module_names_trims_wrapped_punctuation() {
5720 let names = extract_module_names_from_use_args(&[
5721 "('Foo::Bar',".to_string(),
5722 "'Other::Base')".to_string(),
5723 ]);
5724 assert_eq!(names, vec!["Foo::Bar", "Other::Base"]);
5725 }
5726
5727 #[test]
5728 fn test_extract_constant_names_qw_with_space_before_delimiter() {
5729 let names = extract_constant_names_from_use_args(&["qw [FOO BAR]".to_string()]);
5730 assert_eq!(names, vec!["FOO", "BAR"]);
5731 }
5732
5733 #[test]
5734 #[ignore = "qw delimiter with leading space not yet parsed; tracked in debt-ledger.yaml"]
5735 fn test_index_use_constant_qw_with_space_before_delimiter() {
5736 let index = WorkspaceIndex::new();
5737 let uri = must(url::Url::parse("file:///workspace/lib/My/Config.pm"));
5738 let source = "package My::Config;\nuse constant qw [FOO BAR];\n1;\n";
5739
5740 must(index.index_file(uri, source.to_string()));
5741
5742 let foo = index.find_definition("My::Config::FOO");
5743 let bar = index.find_definition("My::Config::BAR");
5744 assert!(foo.is_some(), "Expected My::Config::FOO to be indexed");
5745 assert!(bar.is_some(), "Expected My::Config::BAR to be indexed");
5746 }
5747
5748 #[test]
5749 fn test_with_capacity_accepts_large_batch_without_panic() {
5750 let index = WorkspaceIndex::with_capacity(100, 20);
5751 for i in 0..100 {
5752 let uri = must(url::Url::parse(&format!("file:///lib/Mod{}.pm", i)));
5753 let src = format!("package Mod{};\nsub foo_{} {{ 1 }}\n1;\n", i, i);
5754 index.index_file(uri, src).ok();
5755 }
5756 assert!(index.has_symbols());
5757 }
5758
5759 #[test]
5760 fn test_with_capacity_zero_does_not_panic() {
5761 let index = WorkspaceIndex::with_capacity(0, 0);
5762 assert!(!index.has_symbols());
5763 }
5764
5765 #[test]
5773 fn test_remove_file_clears_symbol_cache_qualified_and_bare() {
5774 let index = WorkspaceIndex::new();
5775 let uri_a = must(url::Url::parse("file:///lib/A.pm"));
5776 let code_a = "package A;\nsub foo { return 1; }\n1;\n";
5777
5778 must(index.index_file(uri_a.clone(), code_a.to_string()));
5779
5780 let before_qual = must_some(index.find_definition("A::foo"));
5782 assert_eq!(
5783 before_qual.uri,
5784 uri_a.to_string(),
5785 "qualified lookup should point to A.pm before removal"
5786 );
5787 let before_bare = must_some(index.find_definition("foo"));
5788 assert_eq!(
5789 before_bare.uri,
5790 uri_a.to_string(),
5791 "bare-name lookup should point to A.pm before removal"
5792 );
5793
5794 index.remove_file(uri_a.as_str());
5796
5797 assert!(
5799 index.find_definition("A::foo").is_none(),
5800 "qualified lookup 'A::foo' should return None after file deletion"
5801 );
5802 assert!(
5803 index.find_definition("foo").is_none(),
5804 "bare-name lookup 'foo' should return None after file deletion"
5805 );
5806
5807 assert_eq!(
5809 index.symbol_count(),
5810 0,
5811 "symbol_count should be 0 after removing the only file"
5812 );
5813 assert!(!index.has_symbols(), "has_symbols should be false after removing the only file");
5814 }
5815
5816 #[test]
5819 fn test_remove_file_bare_name_falls_back_to_surviving_file() {
5820 let index = WorkspaceIndex::new();
5821 let uri_a = must(url::Url::parse("file:///lib/A.pm"));
5822 let uri_b = must(url::Url::parse("file:///lib/B.pm"));
5823 let code_a = "package A;\nsub shared_fn { return 1; }\n1;\n";
5824 let code_b = "package B;\nsub shared_fn { return 2; }\n1;\n";
5825
5826 must(index.index_file(uri_a.clone(), code_a.to_string()));
5827 must(index.index_file(uri_b.clone(), code_b.to_string()));
5828
5829 index.remove_file(uri_a.as_str());
5831
5832 let loc = must_some(index.find_definition("shared_fn"));
5833 assert_eq!(
5834 loc.uri,
5835 uri_b.to_string(),
5836 "bare-name 'shared_fn' should resolve to B.pm after A.pm is deleted"
5837 );
5838
5839 assert!(
5840 index.find_definition("A::shared_fn").is_none(),
5841 "qualified 'A::shared_fn' must be gone after A.pm deletion"
5842 );
5843 assert!(
5844 index.find_definition("B::shared_fn").is_some(),
5845 "qualified 'B::shared_fn' must remain after A.pm deletion"
5846 );
5847 }
5848
5849 #[test]
5850 fn test_definition_candidates_include_ambiguous_bare_symbols_in_stable_order() {
5851 let index = WorkspaceIndex::new();
5852 let uri_b = must(url::Url::parse("file:///lib/B.pm"));
5853 let uri_a = must(url::Url::parse("file:///lib/A.pm"));
5854 must(index.index_file(uri_b, "package B;\nsub shared { 1 }\n1;\n".to_string()));
5855 must(index.index_file(uri_a, "package A;\nsub shared { 1 }\n1;\n".to_string()));
5856
5857 let candidates = index.definition_candidates("shared");
5858 assert_eq!(candidates.len(), 2);
5859 assert_eq!(candidates[0].uri, "file:///lib/A.pm");
5860 assert_eq!(candidates[1].uri, "file:///lib/B.pm");
5861 assert_eq!(must_some(index.find_definition("shared")).uri, "file:///lib/A.pm");
5862 }
5863
5864 #[test]
5865 fn test_definition_candidates_include_duplicate_qualified_name_across_files() {
5866 let index = WorkspaceIndex::new();
5867 let uri_v2 = must(url::Url::parse("file:///lib/A-v2.pm"));
5868 let uri_v1 = must(url::Url::parse("file:///lib/A-v1.pm"));
5869 let source = "package A;\nsub foo { 1 }\n1;\n".to_string();
5870 must(index.index_file(uri_v2, source.clone()));
5871 must(index.index_file(uri_v1, source));
5872
5873 let candidates = index.definition_candidates("A::foo");
5874 assert_eq!(candidates.len(), 2);
5875 assert_eq!(candidates[0].uri, "file:///lib/A-v1.pm");
5876 assert_eq!(candidates[1].uri, "file:///lib/A-v2.pm");
5877 }
5878
5879 #[test]
5880 fn test_definition_candidates_are_cleaned_on_remove_and_reindex() {
5881 let index = WorkspaceIndex::new();
5882 let uri = must(url::Url::parse("file:///lib/A.pm"));
5883 must(index.index_file(uri.clone(), "package A;\nsub foo { 1 }\n1;\n".to_string()));
5884 assert_eq!(index.definition_candidates("A::foo").len(), 1);
5885
5886 index.remove_file(uri.as_str());
5887 assert!(index.definition_candidates("A::foo").is_empty());
5888
5889 must(index.index_file(uri, "package A;\nsub foo { 2 }\n1;\n".to_string()));
5890 assert_eq!(index.definition_candidates("A::foo").len(), 1);
5891 }
5892
5893 #[test]
5899 fn test_definition_candidates_shared_symbol_survives_removal_of_sole_owner_of_other_symbol() {
5900 let index = WorkspaceIndex::new();
5901 let uri_a = must(url::Url::parse("file:///lib/A.pm"));
5902 let uri_b = must(url::Url::parse("file:///lib/B.pm"));
5903
5904 must(index.index_file(
5906 uri_a.clone(),
5907 "package A;\nsub unique_to_a { 1 }\nsub shared { 1 }\n1;\n".to_string(),
5908 ));
5909 must(index.index_file(uri_b.clone(), "package B;\nsub shared { 1 }\n1;\n".to_string()));
5910
5911 assert_eq!(index.definition_candidates("shared").len(), 2);
5913 assert_eq!(index.definition_candidates("unique_to_a").len(), 1);
5914
5915 index.remove_file(uri_a.as_str());
5918
5919 assert!(
5920 index.definition_candidates("unique_to_a").is_empty(),
5921 "unique_to_a should be gone after removing A"
5922 );
5923 assert_eq!(
5924 index.definition_candidates("shared").len(),
5925 1,
5926 "shared should still have B's candidate after removing A"
5927 );
5928 assert_eq!(
5929 index.definition_candidates("shared")[0].uri,
5930 "file:///lib/B.pm",
5931 "remaining shared candidate must be from B"
5932 );
5933 }
5934
5935 #[test]
5936 fn test_folder_context_in_file_index() {
5937 let index = WorkspaceIndex::new();
5938
5939 index.set_workspace_folders(vec![
5941 "file:///project1".to_string(),
5942 "file:///project2".to_string(),
5943 ]);
5944
5945 let uri1 = "file:///project1/lib/Module.pm";
5946 let code1 = r#"
5947package Module;
5948
5949sub test_sub {
5950 return 1;
5951}
5952"#;
5953 must(index.index_file(must(url::Url::parse(uri1)), code1.to_string()));
5954
5955 let uri2 = "file:///project2/lib/Other.pm";
5956 let code2 = r#"
5957package Other;
5958
5959sub other_sub {
5960 return 2;
5961}
5962"#;
5963 must(index.index_file(must(url::Url::parse(uri2)), code2.to_string()));
5964
5965 let symbols1 = index.file_symbols(uri1);
5967 assert_eq!(symbols1.len(), 2, "Should have 2 symbols in Module.pm");
5968 for symbol in &symbols1 {
5969 assert_eq!(symbol.uri, uri1, "Symbol URI should match file URI");
5970 }
5971
5972 let symbols2 = index.file_symbols(uri2);
5973 assert_eq!(symbols2.len(), 2, "Should have 2 symbols in Other.pm");
5974 for symbol in &symbols2 {
5975 assert_eq!(symbol.uri, uri2, "Symbol URI should match file URI");
5976 }
5977
5978 let files = index.files.read();
5980 let file_index1 = must_some(files.get(&DocumentStore::uri_key(uri1)));
5981 assert_eq!(
5982 file_index1.folder_uri,
5983 Some("file:///project1".to_string()),
5984 "File should be attributed to correct workspace folder"
5985 );
5986
5987 let file_index2 = must_some(files.get(&DocumentStore::uri_key(uri2)));
5988 assert_eq!(
5989 file_index2.folder_uri,
5990 Some("file:///project2".to_string()),
5991 "File should be attributed to correct workspace folder"
5992 );
5993 }
5994
5995 #[test]
5996 fn test_determine_folder_uri() {
5997 let index = WorkspaceIndex::new();
5998
5999 index.set_workspace_folders(vec![
6001 "file:///project1".to_string(),
6002 "file:///project2".to_string(),
6003 ]);
6004
6005 let folder1 = index.determine_folder_uri("file:///project1/lib/Module.pm");
6007 assert_eq!(
6008 folder1,
6009 Some("file:///project1".to_string()),
6010 "Should determine folder for file in project1"
6011 );
6012
6013 let folder2 = index.determine_folder_uri("file:///project2/lib/Other.pm");
6015 assert_eq!(
6016 folder2,
6017 Some("file:///project2".to_string()),
6018 "Should determine folder for file in project2"
6019 );
6020
6021 let folder_none = index.determine_folder_uri("file:///other/project/Module.pm");
6023 assert_eq!(folder_none, None, "Should return None for file outside workspace folders");
6024 }
6025
6026 #[test]
6027 fn test_determine_folder_uri_prefers_most_specific_match() {
6028 let index = WorkspaceIndex::new();
6029
6030 index.set_workspace_folders(vec![
6032 "file:///project".to_string(),
6033 "file:///project/lib".to_string(),
6034 ]);
6035
6036 let folder = index.determine_folder_uri("file:///project/lib/My/Module.pm");
6037 assert_eq!(
6038 folder,
6039 Some("file:///project/lib".to_string()),
6040 "Nested workspace folders should attribute files to the most specific folder"
6041 );
6042 }
6043
6044 #[test]
6045 fn test_remove_folder() {
6046 let index = WorkspaceIndex::new();
6047
6048 index.set_workspace_folders(vec![
6050 "file:///project1".to_string(),
6051 "file:///project2".to_string(),
6052 ]);
6053
6054 let uri1 = "file:///project1/lib/Module.pm";
6056 let code1 = r#"
6057package Module;
6058
6059sub test_sub {
6060 return 1;
6061}
6062"#;
6063 must(index.index_file(must(url::Url::parse(uri1)), code1.to_string()));
6064
6065 let uri2 = "file:///project2/lib/Other.pm";
6066 let code2 = r#"
6067package Other;
6068
6069sub other_sub {
6070 return 2;
6071}
6072"#;
6073 must(index.index_file(must(url::Url::parse(uri2)), code2.to_string()));
6074
6075 assert_eq!(index.file_count(), 2, "Should have 2 files indexed");
6077 assert_eq!(index.document_store().count(), 2, "Document store should track both files");
6078
6079 index.remove_folder("file:///project1");
6081
6082 assert_eq!(index.file_count(), 1, "Should have 1 file after removing folder");
6084 assert_eq!(
6085 index.document_store().count(),
6086 1,
6087 "Document store should drop files removed via folder deletion"
6088 );
6089 assert!(index.file_symbols(uri1).is_empty(), "File from removed folder should be gone");
6090 assert_eq!(
6091 index.file_symbols(uri2).len(),
6092 2,
6093 "File from remaining folder should still be present"
6094 );
6095 }
6096
6097 #[test]
6098 fn test_remove_folder_removes_symbol_free_files() {
6099 let index = WorkspaceIndex::new();
6100 index.set_workspace_folders(vec!["file:///project1".to_string()]);
6101
6102 let uri = "file:///project1/empty.pl";
6103 must(index.index_file(must(url::Url::parse(uri)), "# comments only".to_string()));
6104 assert_eq!(index.file_count(), 1, "Expected file to be indexed");
6105
6106 index.remove_folder("file:///project1");
6107
6108 assert_eq!(index.file_count(), 0, "Folder removal should delete symbol-free files");
6109 assert_eq!(
6110 index.document_store().count(),
6111 0,
6112 "Document store should stay in sync for symbol-free files"
6113 );
6114 }
6115
6116 #[test]
6121 fn test_require_with_variable_target_is_not_indexed() -> Result<(), Box<dyn std::error::Error>>
6122 {
6123 let index = WorkspaceIndex::new();
6124 let uri = must(url::Url::parse("file:///test/require-var.pl"));
6125 let src = r#"package Test;
6126my $loader = 'MyModule';
6127require $loader;
61281;
6129"#;
6130 must(index.index_file(uri.clone(), src.to_string()));
6131 let deps = index.file_dependencies(uri.as_str());
6132 assert!(
6133 !deps.contains("MyModule"),
6134 "require with variable target should not register static dependency"
6135 );
6136 Ok(())
6137 }
6138
6139 #[test]
6140 fn test_multiple_import_calls_on_same_module() -> Result<(), Box<dyn std::error::Error>> {
6141 let index = WorkspaceIndex::new();
6142 let uri = must(url::Url::parse("file:///test/multi-import.pl"));
6143 let src = r#"package Test;
6144require Toolkit;
6145Toolkit->import('func_a');
6146Toolkit->import(qw(func_b func_c));
61471;
6148"#;
6149 must(index.index_file(uri.clone(), src.to_string()));
6150 let deps = index.file_dependencies(uri.as_str());
6151 assert!(deps.contains("Toolkit"), "module should be tracked as dependency");
6152 for symbol in &["func_a", "func_b", "func_c"] {
6153 let refs = index.find_references(symbol);
6154 assert!(!refs.is_empty(), "all imported symbols should be indexed: {}", symbol);
6155 }
6156 Ok(())
6157 }
6158
6159 #[test]
6160 fn test_require_string_vs_bareword_normalization() -> Result<(), Box<dyn std::error::Error>> {
6161 let index = WorkspaceIndex::new();
6162 let uri = must(url::Url::parse("file:///test/require-string.pl"));
6163 let src = r#"package Consumer;
6164require "String/Based/Module.pm";
6165String::Based::Module->import('exported');
61661;
6167"#;
6168 must(index.index_file(uri.clone(), src.to_string()));
6169 let deps = index.file_dependencies(uri.as_str());
6170 assert!(
6171 deps.contains("String::Based::Module"),
6172 "require string form should normalize path separators to ::"
6173 );
6174 let refs = index.find_references("exported");
6175 assert!(!refs.is_empty(), "import should be indexed even with string-form require");
6176 Ok(())
6177 }
6178
6179 #[test]
6180 fn test_import_without_require_registers_as_method_call()
6181 -> Result<(), Box<dyn std::error::Error>> {
6182 let index = WorkspaceIndex::new();
6186 let uri = must(url::Url::parse("file:///test/orphan-import.pl"));
6187 let src = r#"package Test;
6188Unrelated::Module->import('orphaned');
6189orphaned();
61901;
6191"#;
6192 must(index.index_file(uri.clone(), src.to_string()));
6193
6194 let _refs = index.find_references("orphaned");
6198 Ok(())
6201 }
6202
6203 #[test]
6204 fn test_nested_blocks_preserve_require_scope() -> Result<(), Box<dyn std::error::Error>> {
6205 let index = WorkspaceIndex::new();
6206 let uri = must(url::Url::parse("file:///test/nested.pl"));
6207 let src = r#"package Test;
6208{
6209 require Outer;
6210 {
6211 Outer->import('nested_sym');
6212 }
6213}
62141;
6215"#;
6216 must(index.index_file(uri.clone(), src.to_string()));
6217 let deps = index.file_dependencies(uri.as_str());
6218 assert!(
6219 deps.contains("Outer"),
6220 "require in outer block should be visible to nested import"
6221 );
6222 let refs = index.find_references("nested_sym");
6223 assert!(!refs.is_empty(), "symbol imported in nested block should still be indexed");
6224 Ok(())
6225 }
6226
6227 #[test]
6228 fn test_require_path_without_pm_extension() -> Result<(), Box<dyn std::error::Error>> {
6229 let index = WorkspaceIndex::new();
6230 let uri = must(url::Url::parse("file:///test/no-ext.pl"));
6231 let src = r#"package Test;
6232require "My/Module";
6233My::Module->import('func');
62341;
6235"#;
6236 must(index.index_file(uri.clone(), src.to_string()));
6237 let deps = index.file_dependencies(uri.as_str());
6238 assert!(
6239 deps.contains("My::Module"),
6240 "require without .pm extension should normalize to module path"
6241 );
6242 Ok(())
6243 }
6244
6245 #[test]
6246 fn test_qw_with_bracket_delimiters() -> Result<(), Box<dyn std::error::Error>> {
6247 let index = WorkspaceIndex::new();
6248 let uri = must(url::Url::parse("file:///test/qw-delim.pl"));
6249 let src = r#"package Test;
6250require DelimModule;
6251DelimModule->import(qw[sym1 sym2]);
6252DelimModule->import(qw{sym3 sym4});
62531;
6254"#;
6255 must(index.index_file(uri.clone(), src.to_string()));
6256 for symbol in &["sym1", "sym2", "sym3", "sym4"] {
6257 let refs = index.find_references(symbol);
6258 assert!(
6259 !refs.is_empty(),
6260 "symbols from qw with bracket delimiters should be indexed: {}",
6261 symbol
6262 );
6263 }
6264 Ok(())
6265 }
6266
6267 #[test]
6268 fn test_array_literal_import_args() -> Result<(), Box<dyn std::error::Error>> {
6269 let index = WorkspaceIndex::new();
6270 let uri = must(url::Url::parse("file:///test/array-import.pl"));
6271 let src = r#"package Test;
6272require ArrayModule;
6273ArrayModule->import(['sym_x', 'sym_y']);
62741;
6275"#;
6276 must(index.index_file(uri.clone(), src.to_string()));
6277 for symbol in &["sym_x", "sym_y"] {
6278 let refs = index.find_references(symbol);
6279 assert!(
6280 !refs.is_empty(),
6281 "symbols from array literal import should be indexed: {}",
6282 symbol
6283 );
6284 }
6285 Ok(())
6286 }
6287
6288 #[test]
6289 fn test_require_inside_conditional_still_registers_dependency()
6290 -> Result<(), Box<dyn std::error::Error>> {
6291 let index = WorkspaceIndex::new();
6292 let uri = must(url::Url::parse("file:///test/cond-require.pl"));
6293 let src = r#"package Test;
6294if (1) {
6295 require ConditionalMod;
6296 ConditionalMod->import('cond_func');
6297}
62981;
6299"#;
6300 must(index.index_file(uri.clone(), src.to_string()));
6301 let deps = index.file_dependencies(uri.as_str());
6302 assert!(
6303 deps.contains("ConditionalMod"),
6304 "require inside conditional should still register as dependency"
6305 );
6306 let refs = index.find_references("cond_func");
6307 assert!(!refs.is_empty(), "import inside conditional should still index symbols");
6308 Ok(())
6309 }
6310
6311 #[test]
6312 fn test_mixed_string_and_bareword_imports() -> Result<(), Box<dyn std::error::Error>> {
6313 let index = WorkspaceIndex::new();
6314 let uri = must(url::Url::parse("file:///test/mixed-import.pl"));
6315 let src = r#"package Test;
6316require MixedMod;
6317MixedMod->import('string_sym');
6318MixedMod->import(qw(qw_one qw_two));
63191;
6320"#;
6321 must(index.index_file(uri.clone(), src.to_string()));
6322 let deps = index.file_dependencies(uri.as_str());
6323 assert!(deps.contains("MixedMod"), "require should register dependency");
6324 for symbol in &["string_sym", "qw_one", "qw_two"] {
6325 let refs = index.find_references(symbol);
6326 assert!(!refs.is_empty(), "all import forms should index symbols: {}", symbol);
6327 }
6328 Ok(())
6329 }
6330}