1use crate::Parser;
65use crate::ast::{Node, NodeKind};
66use crate::document_store::{Document, DocumentStore};
67use crate::position::{Position, Range};
68use crate::workspace::monitoring::IndexInstrumentation;
69use parking_lot::RwLock;
70use perl_position_tracking::{WireLocation, WirePosition, WireRange};
71use perl_semantic_facts::{
72 AnchorFact, AnchorId, Confidence, EdgeFact, EntityFact, EntityId, EntityKind, FileId,
73 Provenance,
74};
75use serde::{Deserialize, Serialize};
76use std::collections::hash_map::DefaultHasher;
77use std::collections::{HashMap, HashSet};
78use std::hash::{Hash, Hasher};
79use std::path::Path;
80use std::sync::Arc;
81use std::time::Instant;
82use url::Url;
83
84use crate::semantic::imports::ImportExportIndex;
85pub use crate::semantic::invalidation::ShardReplaceResult;
86use crate::semantic::invalidation::{ShardCategoryHashes, plan_shard_replacement};
87use crate::semantic::references::ReferenceIndex;
88pub use crate::workspace::monitoring::{
89 DegradationReason, EarlyExitReason, EarlyExitRecord, IndexInstrumentationSnapshot,
90 IndexMetrics, IndexPerformanceCaps, IndexPhase, IndexPhaseTransition, IndexResourceLimits,
91 IndexStateKind, IndexStateTransition, ResourceKind,
92};
93use perl_symbol::surface::decl::extract_symbol_decls;
94use perl_symbol::surface::facts::{symbol_decls_to_semantic_facts, symbol_refs_to_semantic_facts};
95use perl_symbol::surface::r#ref::extract_symbol_refs;
96
97#[cfg(not(target_arch = "wasm32"))]
99pub use perl_uri::{fs_path_to_uri, uri_to_fs_path};
101pub use perl_uri::{is_file_uri, is_special_scheme, uri_extension, uri_key};
103
104#[derive(Clone, Debug)]
145pub enum IndexState {
146 Building {
148 phase: IndexPhase,
150 indexed_count: usize,
152 total_count: usize,
154 started_at: Instant,
156 },
157
158 Ready {
160 symbol_count: usize,
162 file_count: usize,
164 completed_at: Instant,
166 },
167
168 Degraded {
170 reason: DegradationReason,
172 available_symbols: usize,
174 since: Instant,
176 },
177}
178
179impl IndexState {
180 pub fn kind(&self) -> IndexStateKind {
182 match self {
183 IndexState::Building { .. } => IndexStateKind::Building,
184 IndexState::Ready { .. } => IndexStateKind::Ready,
185 IndexState::Degraded { .. } => IndexStateKind::Degraded,
186 }
187 }
188
189 pub fn phase(&self) -> Option<IndexPhase> {
191 match self {
192 IndexState::Building { phase, .. } => Some(*phase),
193 _ => None,
194 }
195 }
196
197 pub fn state_started_at(&self) -> Instant {
199 match self {
200 IndexState::Building { started_at, .. } => *started_at,
201 IndexState::Ready { completed_at, .. } => *completed_at,
202 IndexState::Degraded { since, .. } => *since,
203 }
204 }
205}
206
207pub struct IndexCoordinator {
259 state: Arc<RwLock<IndexState>>,
261
262 index: Arc<WorkspaceIndex>,
264
265 limits: IndexResourceLimits,
272
273 caps: IndexPerformanceCaps,
275
276 metrics: IndexMetrics,
278
279 instrumentation: IndexInstrumentation,
281}
282
283impl std::fmt::Debug for IndexCoordinator {
284 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
285 f.debug_struct("IndexCoordinator")
286 .field("state", &*self.state.read())
287 .field("limits", &self.limits)
288 .field("caps", &self.caps)
289 .finish_non_exhaustive()
290 }
291}
292
293impl IndexCoordinator {
294 pub fn new() -> Self {
311 Self {
312 state: Arc::new(RwLock::new(IndexState::Building {
313 phase: IndexPhase::Idle,
314 indexed_count: 0,
315 total_count: 0,
316 started_at: Instant::now(),
317 })),
318 index: Arc::new(WorkspaceIndex::new()),
319 limits: IndexResourceLimits::default(),
320 caps: IndexPerformanceCaps::default(),
321 metrics: IndexMetrics::new(),
322 instrumentation: IndexInstrumentation::new(),
323 }
324 }
325
326 pub fn with_limits(limits: IndexResourceLimits) -> Self {
345 Self {
346 state: Arc::new(RwLock::new(IndexState::Building {
347 phase: IndexPhase::Idle,
348 indexed_count: 0,
349 total_count: 0,
350 started_at: Instant::now(),
351 })),
352 index: Arc::new(WorkspaceIndex::new()),
353 limits,
354 caps: IndexPerformanceCaps::default(),
355 metrics: IndexMetrics::new(),
356 instrumentation: IndexInstrumentation::new(),
357 }
358 }
359
360 pub fn with_limits_and_caps(limits: IndexResourceLimits, caps: IndexPerformanceCaps) -> Self {
367 Self {
368 state: Arc::new(RwLock::new(IndexState::Building {
369 phase: IndexPhase::Idle,
370 indexed_count: 0,
371 total_count: 0,
372 started_at: Instant::now(),
373 })),
374 index: Arc::new(WorkspaceIndex::new()),
375 limits,
376 caps,
377 metrics: IndexMetrics::new(),
378 instrumentation: IndexInstrumentation::new(),
379 }
380 }
381
382 pub fn state(&self) -> IndexState {
407 self.state.read().clone()
408 }
409
410 pub fn index(&self) -> &Arc<WorkspaceIndex> {
428 &self.index
429 }
430
431 pub fn limits(&self) -> &IndexResourceLimits {
433 &self.limits
434 }
435
436 pub fn performance_caps(&self) -> &IndexPerformanceCaps {
438 &self.caps
439 }
440
441 pub fn instrumentation_snapshot(&self) -> IndexInstrumentationSnapshot {
443 self.instrumentation.snapshot()
444 }
445
446 pub fn notify_change(&self, _uri: &str) {
468 let pending = self.metrics.increment_pending_parses();
469
470 if self.metrics.is_parse_storm() {
472 self.transition_to_degraded(DegradationReason::ParseStorm { pending_parses: pending });
473 }
474 }
475
476 pub fn notify_parse_complete(&self, _uri: &str) {
498 let pending = self.metrics.decrement_pending_parses();
499
500 if pending == 0 {
502 if let IndexState::Degraded { reason: DegradationReason::ParseStorm { .. }, .. } =
503 self.state()
504 {
505 let mut state = self.state.write();
507 let from_kind = state.kind();
508 self.instrumentation.record_state_transition(from_kind, IndexStateKind::Building);
509 *state = IndexState::Building {
510 phase: IndexPhase::Idle,
511 indexed_count: 0,
512 total_count: 0,
513 started_at: Instant::now(),
514 };
515 }
516 }
517
518 self.enforce_limits();
520 }
521
522 pub fn transition_to_ready(&self, file_count: usize, symbol_count: usize) {
552 let mut state = self.state.write();
553 let from_kind = state.kind();
554
555 match &*state {
557 IndexState::Building { .. } | IndexState::Degraded { .. } => {
558 *state =
560 IndexState::Ready { symbol_count, file_count, completed_at: Instant::now() };
561 }
562 IndexState::Ready { .. } => {
563 *state =
565 IndexState::Ready { symbol_count, file_count, completed_at: Instant::now() };
566 }
567 }
568 self.instrumentation.record_state_transition(from_kind, IndexStateKind::Ready);
569 drop(state); self.enforce_limits();
573 }
574
575 pub fn transition_to_scanning(&self) {
579 let mut state = self.state.write();
580 let from_kind = state.kind();
581
582 match &*state {
583 IndexState::Building { phase, indexed_count, total_count, started_at } => {
584 if *phase != IndexPhase::Scanning {
585 self.instrumentation.record_phase_transition(*phase, IndexPhase::Scanning);
586 }
587 *state = IndexState::Building {
588 phase: IndexPhase::Scanning,
589 indexed_count: *indexed_count,
590 total_count: *total_count,
591 started_at: *started_at,
592 };
593 }
594 IndexState::Ready { .. } | IndexState::Degraded { .. } => {
595 self.instrumentation.record_state_transition(from_kind, IndexStateKind::Building);
596 self.instrumentation
597 .record_phase_transition(IndexPhase::Idle, IndexPhase::Scanning);
598 *state = IndexState::Building {
599 phase: IndexPhase::Scanning,
600 indexed_count: 0,
601 total_count: 0,
602 started_at: Instant::now(),
603 };
604 }
605 }
606 }
607
608 pub fn update_scan_progress(&self, total_count: usize) {
610 let mut state = self.state.write();
611 if let IndexState::Building { phase, indexed_count, started_at, .. } = &*state {
612 if *phase != IndexPhase::Scanning {
613 self.instrumentation.record_phase_transition(*phase, IndexPhase::Scanning);
614 }
615 *state = IndexState::Building {
616 phase: IndexPhase::Scanning,
617 indexed_count: *indexed_count,
618 total_count,
619 started_at: *started_at,
620 };
621 }
622 }
623
624 pub fn transition_to_indexing(&self, total_count: usize) {
628 let mut state = self.state.write();
629 let from_kind = state.kind();
630
631 match &*state {
632 IndexState::Building { phase, indexed_count, started_at, .. } => {
633 if *phase != IndexPhase::Indexing {
634 self.instrumentation.record_phase_transition(*phase, IndexPhase::Indexing);
635 }
636 *state = IndexState::Building {
637 phase: IndexPhase::Indexing,
638 indexed_count: *indexed_count,
639 total_count,
640 started_at: *started_at,
641 };
642 }
643 IndexState::Ready { .. } | IndexState::Degraded { .. } => {
644 self.instrumentation.record_state_transition(from_kind, IndexStateKind::Building);
645 self.instrumentation
646 .record_phase_transition(IndexPhase::Idle, IndexPhase::Indexing);
647 *state = IndexState::Building {
648 phase: IndexPhase::Indexing,
649 indexed_count: 0,
650 total_count,
651 started_at: Instant::now(),
652 };
653 }
654 }
655 }
656
657 pub fn transition_to_building(&self, total_count: usize) {
661 let mut state = self.state.write();
662 let from_kind = state.kind();
663
664 match &*state {
666 IndexState::Degraded { .. } | IndexState::Ready { .. } => {
667 self.instrumentation.record_state_transition(from_kind, IndexStateKind::Building);
668 self.instrumentation
669 .record_phase_transition(IndexPhase::Idle, IndexPhase::Indexing);
670 *state = IndexState::Building {
671 phase: IndexPhase::Indexing,
672 indexed_count: 0,
673 total_count,
674 started_at: Instant::now(),
675 };
676 }
677 IndexState::Building { phase, indexed_count, started_at, .. } => {
678 let mut next_phase = *phase;
679 if *phase == IndexPhase::Idle {
680 self.instrumentation
681 .record_phase_transition(IndexPhase::Idle, IndexPhase::Indexing);
682 next_phase = IndexPhase::Indexing;
683 }
684 *state = IndexState::Building {
685 phase: next_phase,
686 indexed_count: *indexed_count,
687 total_count,
688 started_at: *started_at,
689 };
690 }
691 }
692 }
693
694 pub fn update_building_progress(&self, indexed_count: usize) {
716 let mut state = self.state.write();
717
718 if let IndexState::Building { phase, started_at, total_count, .. } = &*state {
719 let elapsed = started_at.elapsed().as_millis() as u64;
720
721 if elapsed > self.limits.max_scan_duration_ms {
723 drop(state);
725 self.transition_to_degraded(DegradationReason::ScanTimeout { elapsed_ms: elapsed });
726 return;
727 }
728
729 *state = IndexState::Building {
731 phase: *phase,
732 indexed_count,
733 total_count: *total_count,
734 started_at: *started_at,
735 };
736 }
737 }
738
739 pub fn transition_to_degraded(&self, reason: DegradationReason) {
764 let mut state = self.state.write();
765 let from_kind = state.kind();
766
767 let available_symbols = match &*state {
769 IndexState::Ready { symbol_count, .. } => *symbol_count,
770 IndexState::Degraded { available_symbols, .. } => *available_symbols,
771 IndexState::Building { .. } => 0,
772 };
773
774 self.instrumentation.record_state_transition(from_kind, IndexStateKind::Degraded);
775 *state = IndexState::Degraded { reason, available_symbols, since: Instant::now() };
776 }
777
778 pub fn check_limits(&self) -> Option<DegradationReason> {
809 let files = self.index.files.read();
810
811 let file_count = files.len();
813 if file_count > self.limits.max_files {
814 return Some(DegradationReason::ResourceLimit { kind: ResourceKind::MaxFiles });
815 }
816
817 let total_symbols: usize = files.values().map(|fi| fi.symbols.len()).sum();
819 if total_symbols > self.limits.max_total_symbols {
820 return Some(DegradationReason::ResourceLimit { kind: ResourceKind::MaxSymbols });
821 }
822
823 None
824 }
825
826 pub fn enforce_limits(&self) {
852 if let Some(reason) = self.check_limits() {
853 self.transition_to_degraded(reason);
854 }
855 }
856
857 pub fn record_early_exit(
859 &self,
860 reason: EarlyExitReason,
861 elapsed_ms: u64,
862 indexed_files: usize,
863 total_files: usize,
864 ) {
865 self.instrumentation.record_early_exit(EarlyExitRecord {
866 reason,
867 elapsed_ms,
868 indexed_files,
869 total_files,
870 });
871 }
872
873 pub fn query<T, F1, F2>(&self, full_query: F1, partial_query: F2) -> T
906 where
907 F1: FnOnce(&WorkspaceIndex) -> T,
908 F2: FnOnce(&WorkspaceIndex) -> T,
909 {
910 match self.state() {
911 IndexState::Ready { .. } => full_query(&self.index),
912 _ => partial_query(&self.index),
913 }
914 }
915}
916
917impl Default for IndexCoordinator {
918 fn default() -> Self {
919 Self::new()
920 }
921}
922
923#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
928pub enum SymKind {
930 Var,
932 Sub,
934 Pack,
936}
937
938#[derive(Clone, Debug, Eq, PartialEq, Hash)]
939pub struct SymbolKey {
941 pub pkg: Arc<str>,
943 pub name: Arc<str>,
945 pub sigil: Option<char>,
947 pub kind: SymKind,
949}
950
951pub fn normalize_var(name: &str) -> (Option<char>, &str) {
972 if name.is_empty() {
973 return (None, "");
974 }
975
976 let Some(first_char) = name.chars().next() else {
978 return (None, name); };
980 match first_char {
981 '$' | '@' | '%' => {
982 if name.len() > 1 {
983 (Some(first_char), &name[1..])
984 } else {
985 (Some(first_char), "")
986 }
987 }
988 _ => (None, name),
989 }
990}
991
992#[derive(Debug, Clone, PartialEq, Eq)]
995pub struct Location {
997 pub uri: String,
999 pub range: Range,
1001}
1002
1003#[derive(Debug, Clone, PartialEq, Eq)]
1004pub struct SymbolIdentity {
1006 pub stable_key: String,
1008 pub name: String,
1010 pub qualified_name: Option<String>,
1012 pub kind: SymbolKind,
1014}
1015
1016#[derive(Debug, Clone, PartialEq, Eq)]
1017pub struct CrossFileReferenceQueryResult {
1019 pub symbol: SymbolIdentity,
1021 pub definition: Location,
1023 pub references: Vec<Location>,
1025}
1026
1027#[derive(Debug, Clone, Serialize, Deserialize)]
1028pub struct WorkspaceSymbol {
1030 pub name: String,
1032 pub kind: SymbolKind,
1034 pub uri: String,
1036 pub range: Range,
1038 pub qualified_name: Option<String>,
1040 pub documentation: Option<String>,
1042 pub container_name: Option<String>,
1044 #[serde(default = "default_has_body")]
1046 pub has_body: bool,
1047 pub workspace_folder_uri: Option<String>,
1049}
1050
1051fn default_has_body() -> bool {
1052 true
1053}
1054
1055pub use perl_symbol::{SymbolKind, VarKind};
1058
1059#[derive(Debug, Clone)]
1060pub struct SymbolReference {
1062 pub uri: String,
1064 pub range: Range,
1066 pub kind: ReferenceKind,
1068}
1069
1070#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1071pub enum ReferenceKind {
1073 Definition,
1075 Usage,
1077 Import,
1079 Read,
1081 Write,
1083}
1084
1085#[derive(Debug, Serialize)]
1086#[serde(rename_all = "camelCase")]
1087pub struct LspWorkspaceSymbol {
1089 pub name: String,
1091 pub kind: u32,
1093 pub location: WireLocation,
1095 #[serde(skip_serializing_if = "Option::is_none")]
1097 pub container_name: Option<String>,
1098 #[serde(skip_serializing_if = "Option::is_none")]
1100 pub workspace_folder_uri: Option<String>,
1101}
1102
1103impl From<&WorkspaceSymbol> for LspWorkspaceSymbol {
1104 fn from(sym: &WorkspaceSymbol) -> Self {
1105 let range = WireRange {
1106 start: WirePosition { line: sym.range.start.line, character: sym.range.start.column },
1107 end: WirePosition { line: sym.range.end.line, character: sym.range.end.column },
1108 };
1109
1110 Self {
1111 name: sym.name.clone(),
1112 kind: sym.kind.to_lsp_kind(),
1113 location: WireLocation { uri: sym.uri.clone(), range },
1114 container_name: sym.container_name.clone(),
1115 workspace_folder_uri: sym.workspace_folder_uri.clone(),
1116 }
1117 }
1118}
1119
1120#[derive(Default, Clone)]
1122pub struct FileIndex {
1123 source_uri: String,
1125 symbols: Vec<WorkspaceSymbol>,
1127 references: HashMap<String, Vec<SymbolReference>>,
1129 dependencies: HashSet<String>,
1131 content_hash: u64,
1133 folder_uri: Option<String>,
1135}
1136
1137#[derive(Clone, Debug)]
1139pub struct FileFactShard {
1140 pub source_uri: String,
1142 pub file_id: FileId,
1144 pub content_hash: u64,
1146 pub anchors_hash: Option<u64>,
1148 pub entities_hash: Option<u64>,
1150 pub occurrences_hash: Option<u64>,
1152 pub edges_hash: Option<u64>,
1154 pub anchors: Vec<AnchorFact>,
1156 pub entities: Vec<EntityFact>,
1158 pub occurrences: Vec<perl_semantic_facts::OccurrenceFact>,
1160 pub edges: Vec<EdgeFact>,
1162}
1163
1164pub struct WorkspaceIndex {
1166 files: Arc<RwLock<HashMap<String, FileIndex>>>,
1168 symbols: Arc<RwLock<HashMap<String, Vec<DefinitionCandidate>>>>,
1170 global_references: Arc<RwLock<HashMap<String, Vec<Location>>>>,
1175 fact_shards: Arc<RwLock<HashMap<String, FileFactShard>>>,
1177 semantic_reference_index: Arc<RwLock<ReferenceIndex>>,
1179 semantic_import_export_index: Arc<RwLock<ImportExportIndex>>,
1181 document_store: DocumentStore,
1183 workspace_folders: Arc<RwLock<Vec<String>>>,
1188}
1189
1190#[derive(Debug, Clone, Eq, PartialEq)]
1191struct DefinitionCandidate {
1192 location: Location,
1193 kind: SymbolKind,
1194}
1195
1196impl WorkspaceIndex {
1197 fn location_sort_key(location: &Location) -> (&str, u32, u32, u32, u32) {
1198 (
1199 location.uri.as_str(),
1200 location.range.start.line,
1201 location.range.start.column,
1202 location.range.end.line,
1203 location.range.end.column,
1204 )
1205 }
1206
1207 fn sort_locations_deterministically(locations: &mut [Location]) {
1208 locations.sort_by(|left, right| {
1209 Self::location_sort_key(left).cmp(&Self::location_sort_key(right))
1210 });
1211 }
1212
1213 fn definition_candidate_sort_key(
1214 candidate: &DefinitionCandidate,
1215 ) -> (u8, &str, u32, u32, u32, u32) {
1216 let rank = match candidate.kind {
1217 SymbolKind::Subroutine | SymbolKind::Method => 0,
1218 SymbolKind::Constant => 1,
1219 _ => 2,
1220 };
1221 (
1222 rank,
1223 candidate.location.uri.as_str(),
1224 candidate.location.range.start.line,
1225 candidate.location.range.start.column,
1226 candidate.location.range.end.line,
1227 candidate.location.range.end.column,
1228 )
1229 }
1230
1231 fn rebuild_symbol_cache(
1232 files: &HashMap<String, FileIndex>,
1233 symbols: &mut HashMap<String, Vec<DefinitionCandidate>>,
1234 ) {
1235 symbols.clear();
1236
1237 for file_index in files.values() {
1238 for symbol in &file_index.symbols {
1239 if let Some(ref qname) = symbol.qualified_name {
1240 symbols.entry(qname.clone()).or_default().push(DefinitionCandidate {
1241 location: Location { uri: symbol.uri.clone(), range: symbol.range },
1242 kind: symbol.kind,
1243 });
1244 }
1245 symbols.entry(symbol.name.clone()).or_default().push(DefinitionCandidate {
1246 location: Location { uri: symbol.uri.clone(), range: symbol.range },
1247 kind: symbol.kind,
1248 });
1249 }
1250 }
1251 for entries in symbols.values_mut() {
1252 entries.sort_by(|left, right| {
1253 Self::definition_candidate_sort_key(left)
1254 .cmp(&Self::definition_candidate_sort_key(right))
1255 });
1256 entries.dedup();
1257 }
1258 }
1259
1260 fn incremental_remove_symbols(
1263 files: &HashMap<String, FileIndex>,
1264 symbols: &mut HashMap<String, Vec<DefinitionCandidate>>,
1265 old_file_index: &FileIndex,
1266 ) {
1267 let mut affected_names: Vec<String> = Vec::new();
1268 for sym in &old_file_index.symbols {
1269 if let Some(ref qname) = sym.qualified_name {
1270 let mut remove_key = false;
1271 if let Some(entries) = symbols.get_mut(qname) {
1272 entries.retain(|candidate| candidate.location.uri != sym.uri);
1273 remove_key = entries.is_empty();
1274 }
1275 if remove_key {
1276 symbols.remove(qname);
1277 affected_names.push(qname.clone());
1278 }
1279 }
1280 let mut remove_key = false;
1281 if let Some(entries) = symbols.get_mut(&sym.name) {
1282 entries.retain(|candidate| candidate.location.uri != sym.uri);
1283 remove_key = entries.is_empty();
1284 }
1285 if remove_key {
1286 symbols.remove(&sym.name);
1287 affected_names.push(sym.name.clone());
1288 }
1289 }
1290 if !affected_names.is_empty() {
1291 symbols.clear();
1292 for file_index in files
1293 .values()
1294 .filter(|file_index| file_index.source_uri != old_file_index.source_uri)
1295 {
1296 for symbol in &file_index.symbols {
1297 if let Some(ref qname) = symbol.qualified_name {
1298 symbols.entry(qname.clone()).or_default().push(DefinitionCandidate {
1299 location: Location { uri: symbol.uri.clone(), range: symbol.range },
1300 kind: symbol.kind,
1301 });
1302 }
1303 symbols.entry(symbol.name.clone()).or_default().push(DefinitionCandidate {
1304 location: Location { uri: symbol.uri.clone(), range: symbol.range },
1305 kind: symbol.kind,
1306 });
1307 }
1308 }
1309 for entries in symbols.values_mut() {
1310 entries.sort_by(|left, right| {
1311 Self::definition_candidate_sort_key(left)
1312 .cmp(&Self::definition_candidate_sort_key(right))
1313 });
1314 entries.dedup();
1315 }
1316 }
1317 }
1318
1319 fn incremental_add_symbols(
1321 symbols: &mut HashMap<String, Vec<DefinitionCandidate>>,
1322 file_index: &FileIndex,
1323 ) {
1324 for sym in &file_index.symbols {
1325 if let Some(ref qname) = sym.qualified_name {
1326 symbols.entry(qname.clone()).or_default().push(DefinitionCandidate {
1327 location: Location { uri: sym.uri.clone(), range: sym.range },
1328 kind: sym.kind,
1329 });
1330 }
1331 symbols.entry(sym.name.clone()).or_default().push(DefinitionCandidate {
1332 location: Location { uri: sym.uri.clone(), range: sym.range },
1333 kind: sym.kind,
1334 });
1335 }
1336 for entries in symbols.values_mut() {
1337 entries.sort_by(|left, right| {
1338 Self::definition_candidate_sort_key(left)
1339 .cmp(&Self::definition_candidate_sort_key(right))
1340 });
1341 entries.dedup();
1342 }
1343 }
1344
1345 fn determine_folder_uri(&self, file_uri: &str) -> Option<String> {
1374 let folders = self.workspace_folders.read();
1375 let mut best_match: Option<&String> = None;
1376 for folder_uri in folders.iter() {
1377 let folder_with_slash = if folder_uri.ends_with('/') {
1380 folder_uri.clone()
1381 } else {
1382 format!("{}/", folder_uri)
1383 };
1384 if file_uri.starts_with(&folder_with_slash) || file_uri == folder_uri {
1385 match best_match {
1386 Some(existing) if existing.len() >= folder_uri.len() => {}
1387 _ => best_match = Some(folder_uri),
1388 }
1389 }
1390 }
1391 best_match.cloned()
1392 }
1393
1394 fn find_definition_in_files(
1395 files: &HashMap<String, FileIndex>,
1396 symbol_name: &str,
1397 uri_filter: Option<&str>,
1398 ) -> Option<(Location, String)> {
1399 let mut candidates: Vec<(Location, String)> = Vec::new();
1400 for file_index in files.values() {
1401 if let Some(filter) = uri_filter
1402 && file_index.symbols.first().is_some_and(|symbol| symbol.uri != filter)
1403 {
1404 continue;
1405 }
1406
1407 for symbol in &file_index.symbols {
1408 if symbol.name == symbol_name
1409 || symbol.qualified_name.as_deref() == Some(symbol_name)
1410 {
1411 candidates.push((
1412 Location { uri: symbol.uri.clone(), range: symbol.range },
1413 symbol.uri.clone(),
1414 ));
1415 }
1416 }
1417 }
1418
1419 candidates.sort_by(|left, right| {
1420 Self::location_sort_key(&left.0).cmp(&Self::location_sort_key(&right.0))
1421 });
1422 candidates.into_iter().next()
1423 }
1424
1425 fn find_symbol_by_definition(
1426 &self,
1427 definition: &Location,
1428 symbol_name: &str,
1429 ) -> Option<WorkspaceSymbol> {
1430 let files = self.files.read();
1431 files
1432 .values()
1433 .flat_map(|file_index| file_index.symbols.iter())
1434 .filter(|symbol| {
1435 symbol.uri == definition.uri
1436 && symbol.range == definition.range
1437 && (symbol.name == symbol_name
1438 || symbol.qualified_name.as_deref() == Some(symbol_name))
1439 })
1440 .min_by(|left, right| {
1441 (
1442 left.qualified_name.as_deref().unwrap_or_default(),
1443 left.name.as_str(),
1444 left.kind.to_lsp_kind(),
1445 )
1446 .cmp(&(
1447 right.qualified_name.as_deref().unwrap_or_default(),
1448 right.name.as_str(),
1449 right.kind.to_lsp_kind(),
1450 ))
1451 })
1452 .cloned()
1453 }
1454
1455 fn has_unique_symbol_name_and_kind(&self, target: &WorkspaceSymbol) -> bool {
1456 let files = self.files.read();
1457 files
1458 .values()
1459 .flat_map(|file_index| file_index.symbols.iter())
1460 .filter(|symbol| symbol.name == target.name && symbol.kind == target.kind)
1461 .take(2)
1462 .count()
1463 == 1
1464 }
1465
1466 fn collect_symbol_references(&self, symbol: &WorkspaceSymbol) -> Vec<Location> {
1467 let mut names_to_query: Vec<&str> = Vec::new();
1468 if let Some(qualified_name) = symbol.qualified_name.as_deref() {
1469 names_to_query.push(qualified_name);
1470 if self.has_unique_symbol_name_and_kind(symbol) {
1471 names_to_query.push(symbol.name.as_str());
1472 }
1473 } else {
1474 names_to_query.push(symbol.name.as_str());
1475 }
1476
1477 let global_refs = self.global_references.read();
1478 let mut seen: HashSet<(String, u32, u32, u32, u32)> = HashSet::new();
1479 let mut locations = Vec::new();
1480
1481 for symbol_name in names_to_query {
1482 if let Some(refs) = global_refs.get(symbol_name) {
1483 for location in refs {
1484 let key = (
1485 location.uri.clone(),
1486 location.range.start.line,
1487 location.range.start.column,
1488 location.range.end.line,
1489 location.range.end.column,
1490 );
1491 if seen.insert(key) {
1492 locations.push(location.clone());
1493 }
1494 }
1495 }
1496 }
1497 drop(global_refs);
1498
1499 Self::sort_locations_deterministically(&mut locations);
1500 locations
1501 }
1502
1503 pub fn new() -> Self {
1518 Self {
1519 files: Arc::new(RwLock::new(HashMap::new())),
1520 symbols: Arc::new(RwLock::new(HashMap::new())),
1521 global_references: Arc::new(RwLock::new(HashMap::new())),
1522 fact_shards: Arc::new(RwLock::new(HashMap::new())),
1523 semantic_reference_index: Arc::new(RwLock::new(ReferenceIndex::new())),
1524 semantic_import_export_index: Arc::new(RwLock::new(ImportExportIndex::new())),
1525 document_store: DocumentStore::new(),
1526 workspace_folders: Arc::new(RwLock::new(Vec::new())),
1527 }
1528 }
1529
1530 pub fn with_capacity(estimated_files: usize, avg_symbols_per_file: usize) -> Self {
1555 let sym_cap =
1557 estimated_files.saturating_mul(avg_symbols_per_file).saturating_mul(2).min(1_000_000);
1558 let ref_cap = (sym_cap / 4).min(1_000_000);
1559 Self {
1560 files: Arc::new(RwLock::new(HashMap::with_capacity(estimated_files))),
1561 symbols: Arc::new(RwLock::new(HashMap::with_capacity(sym_cap))),
1562 global_references: Arc::new(RwLock::new(HashMap::with_capacity(ref_cap))),
1563 fact_shards: Arc::new(RwLock::new(HashMap::with_capacity(estimated_files))),
1564 semantic_reference_index: Arc::new(RwLock::new(ReferenceIndex::new())),
1565 semantic_import_export_index: Arc::new(RwLock::new(ImportExportIndex::new())),
1566 document_store: DocumentStore::new(),
1567 workspace_folders: Arc::new(RwLock::new(Vec::new())),
1568 }
1569 }
1570
1571 pub fn set_workspace_folders(&self, folders: Vec<String>) {
1592 let mut workspace_folders = self.workspace_folders.write();
1593 *workspace_folders = folders;
1594 }
1595
1596 #[must_use]
1602 pub fn workspace_folders(&self) -> Vec<String> {
1603 self.workspace_folders.read().clone()
1604 }
1605
1606 fn normalize_uri(uri: &str) -> String {
1608 perl_uri::normalize_uri(uri)
1609 }
1610
1611 fn remove_file_global_refs(
1616 global_refs: &mut HashMap<String, Vec<Location>>,
1617 file_index: &FileIndex,
1618 file_uri: &str,
1619 ) {
1620 for name in file_index.references.keys() {
1621 if let Some(locs) = global_refs.get_mut(name) {
1622 locs.retain(|loc| loc.uri != file_uri);
1623 if locs.is_empty() {
1624 global_refs.remove(name);
1625 }
1626 }
1627 }
1628 }
1629
1630 pub fn index_file(&self, uri: Url, text: String) -> Result<(), String> {
1661 let uri_str = uri.to_string();
1662
1663 let mut hasher = DefaultHasher::new();
1665 text.hash(&mut hasher);
1666 let content_hash = hasher.finish();
1667
1668 let key = DocumentStore::uri_key(&uri_str);
1670 {
1671 let files = self.files.read();
1672 if let Some(existing_index) = files.get(&key) {
1673 if existing_index.content_hash == content_hash {
1674 return Ok(());
1676 }
1677 }
1678 }
1679
1680 if self.document_store.is_open(&uri_str) {
1682 self.document_store.update(&uri_str, 1, text.clone());
1683 } else {
1684 self.document_store.open(uri_str.clone(), 1, text.clone());
1685 }
1686
1687 let mut parser = Parser::new(&text);
1689 let ast = match parser.parse() {
1690 Ok(ast) => ast,
1691 Err(e) => return Err(format!("Parse error: {}", e)),
1692 };
1693
1694 let mut doc = self.document_store.get(&uri_str).ok_or("Document not found")?;
1696
1697 let folder_uri = self.determine_folder_uri(&uri_str);
1699
1700 let mut file_index = FileIndex {
1702 source_uri: uri_str.clone(),
1703 content_hash,
1704 folder_uri: folder_uri.clone(),
1705 ..Default::default()
1706 };
1707 let mut visitor = IndexVisitor::new(&mut doc, uri_str.clone(), folder_uri);
1708 visitor.visit(&ast, &mut file_index);
1709
1710 let canonical_shard =
1711 Self::build_canonical_fact_shard_for_ast(&uri_str, content_hash, &ast);
1712 let fact_shard = if canonical_shard.anchors.is_empty()
1713 && canonical_shard.entities.is_empty()
1714 && canonical_shard.occurrences.is_empty()
1715 && canonical_shard.edges.is_empty()
1716 {
1717 Self::build_fact_shard(&uri_str, content_hash, &file_index)
1718 } else {
1719 canonical_shard
1720 };
1721
1722 let file_id = Self::hash_uri_to_file_id(&uri_str);
1731 let import_specs =
1732 crate::semantic::workspace_import_extractor::extract_import_specs(&ast, file_id);
1733
1734 {
1737 let mut files = self.files.write();
1738
1739 if let Some(old_index) = files.get(&key) {
1741 let mut global_refs = self.global_references.write();
1742 Self::remove_file_global_refs(&mut global_refs, old_index, &uri_str);
1743 }
1744
1745 if let Some(old_index) = files.get(&key) {
1747 let mut symbols = self.symbols.write();
1748 Self::incremental_remove_symbols(&files, &mut symbols, old_index);
1749 drop(symbols);
1750 }
1751 files.insert(key.clone(), file_index);
1752 let mut symbols = self.symbols.write();
1753 if let Some(new_index) = files.get(&key) {
1754 Self::incremental_add_symbols(&mut symbols, new_index);
1755 }
1756
1757 if let Some(file_index) = files.get(&key) {
1758 let mut global_refs = self.global_references.write();
1759 for (name, refs) in &file_index.references {
1760 let entry = global_refs.entry(name.clone()).or_default();
1761 for reference in refs {
1762 entry.push(Location { uri: reference.uri.clone(), range: reference.range });
1763 }
1764 }
1765 }
1766 self.replace_fact_shard_incremental(&key, fact_shard);
1767 }
1768
1769 {
1774 let mut ie_idx = self.semantic_import_export_index.write();
1775 ie_idx.remove_file_imports(&uri_str);
1776 ie_idx.add_file_imports(&uri_str, file_id, import_specs);
1777 }
1778
1779 Ok(())
1780 }
1781
1782 pub fn remove_file(&self, uri: &str) {
1801 let uri_str = Self::normalize_uri(uri);
1802 let key = DocumentStore::uri_key(&uri_str);
1803
1804 self.document_store.close(&uri_str);
1806
1807 let mut files = self.files.write();
1809 if let Some(file_index) = files.remove(&key) {
1810 self.fact_shards.write().remove(&key);
1811
1812 self.semantic_reference_index.write().remove_file(&uri_str);
1814 {
1815 let mut ie_idx = self.semantic_import_export_index.write();
1816 ie_idx.remove_file_imports(&uri_str);
1817 ie_idx.remove_module_exports(&uri_str);
1818 }
1819
1820 let mut symbols = self.symbols.write();
1822 Self::incremental_remove_symbols(&files, &mut symbols, &file_index);
1823
1824 let mut removed_uris = vec![uri_str.as_str()];
1835 for observed_uri in file_index.symbols.iter().map(|s| s.uri.as_str()).chain(
1836 file_index.references.values().flat_map(|refs| refs.iter().map(|r| r.uri.as_str())),
1837 ) {
1838 if !removed_uris.contains(&observed_uri) {
1839 removed_uris.push(observed_uri);
1840 }
1841 }
1842 symbols.retain(|_, candidates| {
1843 candidates.retain(|candidate| {
1844 let cand_uri = candidate.location.uri.as_str();
1845 !removed_uris.contains(&cand_uri)
1846 });
1847 !candidates.is_empty()
1848 });
1849
1850 let mut global_refs = self.global_references.write();
1857 Self::remove_file_global_refs(&mut global_refs, &file_index, &uri_str);
1858 global_refs.retain(|_, locs| {
1859 locs.retain(|loc| !removed_uris.contains(&loc.uri.as_str()));
1860 !locs.is_empty()
1861 });
1862 }
1863 }
1864
1865 pub fn remove_file_url(&self, uri: &Url) {
1889 self.remove_file(uri.as_str())
1890 }
1891
1892 pub fn clear_file(&self, uri: &str) {
1911 self.remove_file(uri);
1912 }
1913
1914 pub fn clear_file_url(&self, uri: &Url) {
1938 self.clear_file(uri.as_str())
1939 }
1940
1941 pub fn remove_folder(&self, folder_uri: &str) {
1961 let mut uris_to_remove = Vec::new();
1962 let files = self.files.read();
1963
1964 for file_index in files.values() {
1966 if file_index.folder_uri.as_deref() == Some(folder_uri) {
1967 uris_to_remove.push(file_index.source_uri.clone());
1968 }
1969 }
1970 drop(files);
1971
1972 for uri in uris_to_remove {
1975 self.remove_file(&uri);
1976 }
1977 }
1978
1979 #[cfg(not(target_arch = "wasm32"))]
1980 pub fn index_file_str(&self, uri: &str, text: &str) -> Result<(), String> {
2010 let path = Path::new(uri);
2011 let url = if path.is_absolute() {
2012 url::Url::from_file_path(path)
2013 .map_err(|_| format!("Invalid URI or file path: {}", uri))?
2014 } else {
2015 url::Url::parse(uri).or_else(|_| {
2018 url::Url::from_file_path(path)
2019 .map_err(|_| format!("Invalid URI or file path: {}", uri))
2020 })?
2021 };
2022 self.index_file(url, text.to_string())
2023 }
2024
2025 pub fn index_files_batch(&self, files_to_index: Vec<(Url, String)>) -> Vec<String> {
2034 let mut errors = Vec::new();
2035
2036 let mut parsed: Vec<(String, String, FileIndex)> = Vec::with_capacity(files_to_index.len());
2038 for (uri, text) in &files_to_index {
2039 let uri_str = uri.to_string();
2040
2041 let mut hasher = DefaultHasher::new();
2043 text.hash(&mut hasher);
2044 let content_hash = hasher.finish();
2045
2046 let key = DocumentStore::uri_key(&uri_str);
2047
2048 {
2050 let files = self.files.read();
2051 if let Some(existing) = files.get(&key) {
2052 if existing.content_hash == content_hash {
2053 continue;
2054 }
2055 }
2056 }
2057
2058 if self.document_store.is_open(&uri_str) {
2060 self.document_store.update(&uri_str, 1, text.clone());
2061 } else {
2062 self.document_store.open(uri_str.clone(), 1, text.clone());
2063 }
2064
2065 let mut parser = Parser::new(text);
2067 let ast = match parser.parse() {
2068 Ok(ast) => ast,
2069 Err(e) => {
2070 errors.push(format!("Parse error in {}: {}", uri_str, e));
2071 continue;
2072 }
2073 };
2074
2075 let mut doc = match self.document_store.get(&uri_str) {
2076 Some(d) => d,
2077 None => {
2078 errors.push(format!("Document not found: {}", uri_str));
2079 continue;
2080 }
2081 };
2082
2083 let folder_uri = self.determine_folder_uri(&uri_str);
2085
2086 let mut file_index = FileIndex {
2087 source_uri: uri_str.clone(),
2088 content_hash,
2089 folder_uri: folder_uri.clone(),
2090 ..Default::default()
2091 };
2092 let mut visitor = IndexVisitor::new(&mut doc, uri_str.clone(), folder_uri);
2093 visitor.visit(&ast, &mut file_index);
2094
2095 parsed.push((key, uri_str, file_index));
2096 }
2097
2098 {
2100 let mut files = self.files.write();
2101 let mut symbols = self.symbols.write();
2102 let mut global_refs = self.global_references.write();
2103
2104 files.reserve(parsed.len());
2107 symbols.reserve(parsed.len().saturating_mul(20).saturating_mul(2));
2108
2109 for (key, uri_str, file_index) in parsed {
2110 if let Some(old_index) = files.get(&key) {
2112 Self::remove_file_global_refs(&mut global_refs, old_index, &uri_str);
2113 }
2114
2115 files.insert(key.clone(), file_index);
2116
2117 if let Some(fi) = files.get(&key) {
2119 for (name, refs) in &fi.references {
2120 let entry = global_refs.entry(name.clone()).or_default();
2121 for reference in refs {
2122 entry.push(Location {
2123 uri: reference.uri.clone(),
2124 range: reference.range,
2125 });
2126 }
2127 }
2128 }
2129 }
2130
2131 Self::rebuild_symbol_cache(&files, &mut symbols);
2133 }
2134
2135 errors
2136 }
2137
2138 pub fn find_references(&self, symbol_name: &str) -> Vec<Location> {
2166 let global_refs = self.global_references.read();
2167 let mut seen: HashSet<(String, u32, u32, u32, u32)> = HashSet::new();
2168 let mut locations = Vec::new();
2169
2170 if let Some(refs) = global_refs.get(symbol_name) {
2172 for loc in refs {
2173 let key = (
2174 loc.uri.clone(),
2175 loc.range.start.line,
2176 loc.range.start.column,
2177 loc.range.end.line,
2178 loc.range.end.column,
2179 );
2180 if seen.insert(key) {
2181 locations.push(Location { uri: loc.uri.clone(), range: loc.range });
2182 }
2183 }
2184 }
2185
2186 if let Some(idx) = symbol_name.rfind("::") {
2188 let bare_name = &symbol_name[idx + 2..];
2189 if let Some(refs) = global_refs.get(bare_name) {
2190 for loc in refs {
2191 let key = (
2192 loc.uri.clone(),
2193 loc.range.start.line,
2194 loc.range.start.column,
2195 loc.range.end.line,
2196 loc.range.end.column,
2197 );
2198 if seen.insert(key) {
2199 locations.push(Location { uri: loc.uri.clone(), range: loc.range });
2200 }
2201 }
2202 }
2203 } else {
2204 for (name, refs) in global_refs.iter() {
2207 if !Self::is_qualified_variant_of(name, symbol_name) {
2208 continue;
2209 }
2210
2211 for loc in refs {
2212 let key = (
2213 loc.uri.clone(),
2214 loc.range.start.line,
2215 loc.range.start.column,
2216 loc.range.end.line,
2217 loc.range.end.column,
2218 );
2219 if seen.insert(key) {
2220 locations.push(Location { uri: loc.uri.clone(), range: loc.range });
2221 }
2222 }
2223 }
2224 }
2225
2226 Self::sort_locations_deterministically(&mut locations);
2227 locations
2228 }
2229
2230 pub fn query_symbol_references(
2234 &self,
2235 symbol_name: &str,
2236 ) -> Option<CrossFileReferenceQueryResult> {
2237 let definition = self.find_definition(symbol_name)?;
2238 let symbol = self.find_symbol_by_definition(&definition, symbol_name)?;
2239
2240 let stable_key = symbol.qualified_name.clone().unwrap_or_else(|| {
2241 format!(
2242 "{}@{}:{}:{}",
2243 symbol.name, symbol.uri, symbol.range.start.line, symbol.range.start.column
2244 )
2245 });
2246 let mut references = self.collect_symbol_references(&symbol);
2247 if !references.iter().any(|location| location == &definition) {
2248 references.push(definition.clone());
2249 Self::sort_locations_deterministically(&mut references);
2250 }
2251
2252 Some(CrossFileReferenceQueryResult {
2253 symbol: SymbolIdentity {
2254 stable_key,
2255 name: symbol.name,
2256 qualified_name: symbol.qualified_name,
2257 kind: symbol.kind,
2258 },
2259 definition,
2260 references,
2261 })
2262 }
2263
2264 pub fn count_usages(&self, symbol_name: &str) -> usize {
2270 let files = self.files.read();
2271 let mut seen: HashSet<(String, u32, u32, u32, u32)> = HashSet::new();
2272
2273 for (_uri_key, file_index) in files.iter() {
2274 if let Some(refs) = file_index.references.get(symbol_name) {
2275 for r in refs.iter().filter(|r| r.kind != ReferenceKind::Definition) {
2276 seen.insert((
2277 r.uri.clone(),
2278 r.range.start.line,
2279 r.range.start.column,
2280 r.range.end.line,
2281 r.range.end.column,
2282 ));
2283 }
2284 }
2285
2286 if let Some(idx) = symbol_name.rfind("::") {
2287 let bare_name = &symbol_name[idx + 2..];
2288 if let Some(refs) = file_index.references.get(bare_name) {
2289 for r in refs.iter().filter(|r| r.kind != ReferenceKind::Definition) {
2290 seen.insert((
2291 r.uri.clone(),
2292 r.range.start.line,
2293 r.range.start.column,
2294 r.range.end.line,
2295 r.range.end.column,
2296 ));
2297 }
2298 }
2299 } else {
2300 for (name, refs) in &file_index.references {
2301 if !Self::is_qualified_variant_of(name, symbol_name) {
2302 continue;
2303 }
2304
2305 for r in refs.iter().filter(|r| r.kind != ReferenceKind::Definition) {
2306 seen.insert((
2307 r.uri.clone(),
2308 r.range.start.line,
2309 r.range.start.column,
2310 r.range.end.line,
2311 r.range.end.column,
2312 ));
2313 }
2314 }
2315 }
2316 }
2317
2318 seen.len()
2319 }
2320
2321 fn is_qualified_variant_of(candidate: &str, bare_symbol: &str) -> bool {
2322 candidate.rsplit_once("::").is_some_and(|(_, candidate_bare)| candidate_bare == bare_symbol)
2323 }
2324
2325 pub fn find_definition(&self, symbol_name: &str) -> Option<Location> {
2344 if let Some(location) = self.definition_candidates(symbol_name).into_iter().next() {
2345 return Some(location);
2346 }
2347
2348 let files = self.files.read();
2358 Self::find_definition_in_files(&files, symbol_name, None).map(|(location, _uri)| location)
2359 }
2360
2361 pub(crate) fn definition_candidates(&self, symbol_name: &str) -> Vec<Location> {
2362 let symbols = self.symbols.read();
2363 symbols
2364 .get(symbol_name)
2365 .map(|candidates| {
2366 candidates.iter().map(|candidate| candidate.location.clone()).collect()
2367 })
2368 .unwrap_or_default()
2369 }
2370
2371 pub fn all_symbols(&self) -> Vec<WorkspaceSymbol> {
2386 let files = self.files.read();
2387 let mut symbols = Vec::new();
2388
2389 for (_uri_key, file_index) in files.iter() {
2390 symbols.extend(file_index.symbols.clone());
2391 }
2392
2393 symbols
2394 }
2395
2396 pub fn clear(&self) {
2398 self.files.write().clear();
2399 self.symbols.write().clear();
2400 self.global_references.write().clear();
2401 self.fact_shards.write().clear();
2402 *self.semantic_reference_index.write() = ReferenceIndex::new();
2403 *self.semantic_import_export_index.write() = ImportExportIndex::new();
2404 }
2405
2406 fn hash_uri_to_file_id(uri: &str) -> FileId {
2407 let mut hasher = DefaultHasher::new();
2408 uri.hash(&mut hasher);
2409 FileId(hasher.finish())
2410 }
2411
2412 fn build_fact_shard(uri: &str, content_hash: u64, file_index: &FileIndex) -> FileFactShard {
2413 let file_id = Self::hash_uri_to_file_id(uri);
2414 let mut anchors = Vec::new();
2415 let mut entities = Vec::new();
2416 for (idx, symbol) in file_index.symbols.iter().enumerate() {
2417 let anchor_id = AnchorId((idx + 1) as u64);
2418 anchors.push(AnchorFact {
2419 id: anchor_id,
2420 file_id,
2421 span_start_byte: 0,
2425 span_end_byte: 0,
2426 scope_id: None,
2427 provenance: Provenance::SearchFallback,
2428 confidence: Confidence::Low,
2429 });
2430 entities.push(EntityFact {
2431 id: EntityId((idx + 1) as u64),
2432 kind: EntityKind::Unknown,
2433 canonical_name: symbol
2434 .qualified_name
2435 .clone()
2436 .unwrap_or_else(|| symbol.name.clone()),
2437 anchor_id: Some(anchor_id),
2438 scope_id: None,
2439 provenance: Provenance::SearchFallback,
2440 confidence: Confidence::Low,
2441 });
2442 }
2443 let anchors_hash = {
2446 let mut h = DefaultHasher::new();
2447 anchors.len().hash(&mut h);
2448 for a in &anchors {
2449 a.id.hash(&mut h);
2450 a.span_start_byte.hash(&mut h);
2451 a.span_end_byte.hash(&mut h);
2452 }
2453 h.finish()
2454 };
2455 let entities_hash = {
2456 let mut h = DefaultHasher::new();
2457 entities.len().hash(&mut h);
2458 for e in &entities {
2459 e.id.hash(&mut h);
2460 e.canonical_name.hash(&mut h);
2461 }
2462 h.finish()
2463 };
2464 FileFactShard {
2465 source_uri: uri.to_string(),
2466 file_id,
2467 content_hash,
2468 anchors_hash: Some(anchors_hash),
2469 entities_hash: Some(entities_hash),
2470 occurrences_hash: Some(0),
2471 edges_hash: Some(0),
2472 anchors,
2473 entities,
2474 occurrences: Vec::new(),
2475 edges: Vec::new(),
2476 }
2477 }
2478
2479 fn build_canonical_fact_shard_for_ast(
2487 uri: &str,
2488 content_hash: u64,
2489 ast: &Node,
2490 ) -> FileFactShard {
2491 let file_id = Self::hash_uri_to_file_id(uri);
2492
2493 let decls = extract_symbol_decls(ast, None);
2495 let refs = extract_symbol_refs(ast);
2496
2497 let decl_facts = symbol_decls_to_semantic_facts(&decls, file_id);
2499
2500 let entity_ids_by_name: std::collections::BTreeMap<String, EntityId> =
2502 decl_facts.entities.iter().map(|e| (e.canonical_name.clone(), e.id)).collect();
2503 let ref_facts = symbol_refs_to_semantic_facts(&refs, file_id, &entity_ids_by_name);
2504
2505 let eval_sub_triples =
2509 crate::semantic::eval_sub_extractor::extract_eval_sub_boundaries(ast, file_id);
2510 let dynamic_boundaries: Vec<perl_semantic_facts::OccurrenceFact> =
2511 eval_sub_triples.iter().map(|(_, _, occ)| occ.clone()).collect();
2512 let generated_member_facts =
2513 crate::semantic::generated_member_extractor::extract_generated_member_facts(
2514 ast, file_id,
2515 );
2516
2517 let mut shard = crate::semantic::facts::build_canonical_fact_shard(
2521 uri,
2522 content_hash,
2523 &decl_facts,
2524 &ref_facts,
2525 &[],
2526 &dynamic_boundaries,
2527 );
2528
2529 for (entity, anchor, _) in eval_sub_triples {
2541 shard.entities.push(entity);
2542 shard.anchors.push(anchor);
2543 }
2544 for fact in generated_member_facts {
2545 shard.entities.push(fact.entity);
2546 shard.anchors.push(fact.anchor);
2547 }
2548
2549 shard
2550 }
2551
2552 pub fn replace_fact_shard_incremental(
2563 &self,
2564 key: &str,
2565 new_shard: FileFactShard,
2566 ) -> ShardReplaceResult {
2567 let mut shards = self.fact_shards.write();
2568 let old_shard = shards.get(key);
2569
2570 let replacement = plan_shard_replacement(
2571 old_shard.map(Self::shard_category_hashes),
2572 Self::shard_category_hashes(&new_shard),
2573 );
2574
2575 if replacement.content_unchanged {
2576 return replacement;
2577 }
2578
2579 let source_uri = new_shard.source_uri.clone();
2580
2581 if replacement.occurrences_updated || replacement.edges_updated {
2585 let mut ref_idx = self.semantic_reference_index.write();
2586 if old_shard.is_some() {
2587 ref_idx.remove_file(&source_uri);
2588 }
2589 ref_idx.add_file(&new_shard);
2590 }
2591
2592 if replacement.entities_updated {
2596 let mut ie_idx = self.semantic_import_export_index.write();
2597 ie_idx.remove_file_imports(&source_uri);
2598 ie_idx.remove_module_exports(&source_uri);
2599 }
2602
2603 shards.insert(key.to_string(), new_shard);
2605
2606 replacement
2607 }
2608
2609 fn shard_category_hashes(shard: &FileFactShard) -> ShardCategoryHashes {
2610 ShardCategoryHashes {
2611 content_hash: shard.content_hash,
2612 anchors_hash: shard.anchors_hash,
2613 entities_hash: shard.entities_hash,
2614 occurrences_hash: shard.occurrences_hash,
2615 edges_hash: shard.edges_hash,
2616 }
2617 }
2618
2619 pub fn fact_shard_count(&self) -> usize {
2621 self.fact_shards.read().len()
2622 }
2623
2624 pub fn file_fact_shard(&self, uri: &str) -> Option<FileFactShard> {
2626 let key = DocumentStore::uri_key(&Self::normalize_uri(uri));
2627 self.fact_shards.read().get(&key).cloned()
2628 }
2629
2630 pub fn file_id_for_uri(&self, uri: &str) -> Option<FileId> {
2634 let key = DocumentStore::uri_key(&Self::normalize_uri(uri));
2635 self.fact_shards.read().get(&key).map(|shard| shard.file_id)
2636 }
2637
2638 pub fn with_semantic_queries_for_uri<R>(
2649 &self,
2650 uri: &str,
2651 f: impl FnOnce(FileId, crate::semantic::queries::WorkspaceSemanticQueries<'_>) -> R,
2652 ) -> Option<R> {
2653 let key = DocumentStore::uri_key(&Self::normalize_uri(uri));
2654
2655 let shards_guard = self.fact_shards.read();
2659 let ref_guard = self.semantic_reference_index.read();
2660 let ie_guard = self.semantic_import_export_index.read();
2661
2662 let file_id = shards_guard.get(&key)?.file_id;
2664
2665 let queries = crate::semantic::queries::WorkspaceSemanticQueries::new(
2666 &ref_guard,
2667 &ie_guard,
2668 &shards_guard,
2669 );
2670
2671 Some(f(file_id, queries))
2672 }
2673
2674 pub fn file_count(&self) -> usize {
2676 let files = self.files.read();
2677 files.len()
2678 }
2679
2680 pub fn symbol_count(&self) -> usize {
2682 let files = self.files.read();
2683 files.values().map(|file_index| file_index.symbols.len()).sum()
2684 }
2685
2686 pub fn files_in_folder(&self, folder_uri: &str) -> Vec<FileIndex> {
2696 let files = self.files.read();
2697 files.values().filter(|f| f.folder_uri.as_deref() == Some(folder_uri)).cloned().collect()
2698 }
2699
2700 pub fn symbols_in_folder(&self, folder_uri: &str) -> Vec<WorkspaceSymbol> {
2710 let files = self.files.read();
2711 files
2712 .values()
2713 .filter(|f| f.folder_uri.as_deref() == Some(folder_uri))
2714 .flat_map(|f| f.symbols.iter().cloned())
2715 .collect()
2716 }
2717
2718 #[cfg(feature = "memory-profiling")]
2726 pub fn memory_snapshot(&self) -> crate::workspace::memory::MemorySnapshot {
2727 use std::mem::size_of;
2728
2729 let files_guard = self.files.read();
2730 let symbols_guard = self.symbols.read();
2731 let global_refs_guard = self.global_references.read();
2732
2733 let mut files_bytes: usize = 0;
2735 let mut total_symbol_count: usize = 0;
2736 for (uri_key, fi) in files_guard.iter() {
2737 files_bytes += uri_key.len();
2739 for sym in &fi.symbols {
2741 files_bytes += sym.name.len()
2742 + sym.uri.len()
2743 + sym.qualified_name.as_deref().map_or(0, str::len)
2744 + sym.documentation.as_deref().map_or(0, str::len)
2745 + sym.container_name.as_deref().map_or(0, str::len)
2746 + size_of::<WorkspaceSymbol>();
2748 }
2749 total_symbol_count += fi.symbols.len();
2750 for (ref_name, refs) in &fi.references {
2752 files_bytes += ref_name.len();
2753 for r in refs {
2754 files_bytes += r.uri.len() + size_of::<SymbolReference>();
2755 }
2756 }
2757 for dep in &fi.dependencies {
2759 files_bytes += dep.len();
2760 }
2761 files_bytes += size_of::<u64>();
2763 }
2764
2765 let mut symbols_bytes: usize = 0;
2767 for (qname, candidates) in symbols_guard.iter() {
2768 symbols_bytes += qname.len();
2769 for candidate in candidates {
2770 symbols_bytes += candidate.location.uri.len() + size_of::<Location>();
2771 }
2772 }
2773
2774 let mut global_refs_bytes: usize = 0;
2776 for (sym_name, locs) in global_refs_guard.iter() {
2777 global_refs_bytes += sym_name.len();
2778 for loc in locs {
2779 global_refs_bytes += loc.uri.len() + size_of::<Location>();
2780 }
2781 }
2782
2783 let document_store_bytes = self.document_store.total_text_bytes();
2785
2786 crate::workspace::memory::MemorySnapshot {
2787 file_count: files_guard.len(),
2788 symbol_count: total_symbol_count,
2789 files_bytes,
2790 symbols_bytes,
2791 global_refs_bytes,
2792 document_store_bytes,
2793 }
2794 }
2795
2796 pub fn has_symbols(&self) -> bool {
2815 let files = self.files.read();
2816 files.values().any(|file_index| !file_index.symbols.is_empty())
2817 }
2818
2819 pub fn search_symbols(&self, query: &str) -> Vec<WorkspaceSymbol> {
2838 let query_lower = query.to_lowercase();
2839 let files = self.files.read();
2840 let mut results = Vec::new();
2841 for file_index in files.values() {
2842 for symbol in &file_index.symbols {
2843 if symbol.name.to_lowercase().contains(&query_lower)
2844 || symbol
2845 .qualified_name
2846 .as_ref()
2847 .map(|qn| qn.to_lowercase().contains(&query_lower))
2848 .unwrap_or(false)
2849 {
2850 results.push(symbol.clone());
2851 }
2852 }
2853 }
2854 results
2855 }
2856
2857 pub fn find_symbols(&self, query: &str) -> Vec<WorkspaceSymbol> {
2876 self.search_symbols(query)
2877 }
2878
2879 pub fn rank_symbols_by_folder(
2902 &self,
2903 symbols: Vec<WorkspaceSymbol>,
2904 doc_uri: &str,
2905 ) -> Vec<WorkspaceSymbol> {
2906 let doc_folder = self.determine_folder_uri(doc_uri);
2907
2908 let mut ranked: Vec<(WorkspaceSymbol, i32)> = symbols
2909 .into_iter()
2910 .map(|symbol| {
2911 let rank = if let Some(ref doc_folder_uri) = doc_folder {
2912 if symbol.workspace_folder_uri.as_ref() == Some(doc_folder_uri) {
2913 0 } else {
2915 1 }
2917 } else {
2918 1 };
2920 (symbol, rank)
2921 })
2922 .collect();
2923
2924 ranked.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.name.cmp(&b.0.name)));
2926
2927 ranked.into_iter().map(|(symbol, _)| symbol).collect()
2928 }
2929
2930 pub fn search_symbols_ranked(&self, name: &str, doc_uri: &str) -> Vec<WorkspaceSymbol> {
2952 let symbols = self.search_symbols(name);
2953 self.rank_symbols_by_folder(symbols, doc_uri)
2954 }
2955
2956 #[allow(dead_code)]
2967 pub fn same_package(&self, symbol_a: &WorkspaceSymbol, symbol_b: &WorkspaceSymbol) -> bool {
2968 let package_a = self.extract_package_name(&symbol_a.name);
2969 let package_b = self.extract_package_name(&symbol_b.name);
2970 package_a == package_b
2971 }
2972
2973 #[allow(dead_code)]
2984 pub fn same_package_by_container(&self, package_a: &str, package_b: &str) -> bool {
2985 package_a == package_b
2986 }
2987
2988 #[allow(dead_code)]
2998 pub fn extract_package_name(&self, symbol_name: &str) -> Option<String> {
2999 let parts: Vec<&str> = symbol_name.split("::").collect();
3000 if parts.len() > 1 { Some(parts[..parts.len() - 1].join("::")) } else { None }
3001 }
3002
3003 pub fn file_symbols(&self, uri: &str) -> Vec<WorkspaceSymbol> {
3022 let normalized_uri = Self::normalize_uri(uri);
3023 let key = DocumentStore::uri_key(&normalized_uri);
3024 let files = self.files.read();
3025
3026 files.get(&key).map(|fi| fi.symbols.clone()).unwrap_or_default()
3027 }
3028
3029 pub fn file_dependencies(&self, uri: &str) -> HashSet<String> {
3048 let normalized_uri = Self::normalize_uri(uri);
3049 let key = DocumentStore::uri_key(&normalized_uri);
3050 let files = self.files.read();
3051
3052 files.get(&key).map(|fi| fi.dependencies.clone()).unwrap_or_default()
3053 }
3054
3055 pub fn find_dependents(&self, module_name: &str) -> Vec<String> {
3074 let canonical = canonicalize_perl_module_name(module_name);
3075 let legacy = legacy_perl_module_name(&canonical);
3076 let files = self.files.read();
3077 let mut dependents = Vec::new();
3078
3079 for (uri_key, file_index) in files.iter() {
3080 if file_index.dependencies.contains(module_name)
3081 || file_index.dependencies.contains(&canonical)
3082 || file_index.dependencies.contains(&legacy)
3083 {
3084 dependents.push(uri_key.clone());
3085 }
3086 }
3087
3088 dependents
3089 }
3090
3091 pub fn document_store(&self) -> &DocumentStore {
3106 &self.document_store
3107 }
3108
3109 pub fn find_unused_symbols(&self) -> Vec<WorkspaceSymbol> {
3124 let files = self.files.read();
3125 let mut unused = Vec::new();
3126
3127 for (_uri_key, file_index) in files.iter() {
3129 for symbol in &file_index.symbols {
3130 let has_usage = files.values().any(|fi| {
3132 if let Some(refs) = fi.references.get(&symbol.name) {
3133 refs.iter().any(|r| r.kind != ReferenceKind::Definition)
3134 } else {
3135 false
3136 }
3137 });
3138
3139 if !has_usage {
3140 unused.push(symbol.clone());
3141 }
3142 }
3143 }
3144
3145 unused
3146 }
3147
3148 pub fn get_package_members(&self, package_name: &str) -> Vec<WorkspaceSymbol> {
3167 let files = self.files.read();
3168 let mut members = Vec::new();
3169
3170 for (_uri_key, file_index) in files.iter() {
3171 for symbol in &file_index.symbols {
3172 if let Some(ref container) = symbol.container_name {
3174 if container == package_name {
3175 members.push(symbol.clone());
3176 }
3177 }
3178 if let Some(ref qname) = symbol.qualified_name {
3180 if qname.starts_with(&format!("{}::", package_name)) {
3181 if symbol.container_name.as_deref() != Some(package_name) {
3183 members.push(symbol.clone());
3184 }
3185 }
3186 }
3187 }
3188 }
3189
3190 members
3191 }
3192
3193 pub fn find_def(&self, key: &SymbolKey) -> Option<Location> {
3214 if let Some(sigil) = key.sigil {
3215 let var_name = format!("{}{}", sigil, key.name);
3217 self.find_definition(&var_name)
3218 } else if key.kind == SymKind::Pack {
3219 self.find_definition(key.pkg.as_ref())
3222 .or_else(|| self.find_definition(key.name.as_ref()))
3223 } else {
3224 let qualified_name = format!("{}::{}", key.pkg, key.name);
3226 self.find_definition(&qualified_name)
3227 }
3228 }
3229
3230 pub fn find_refs(&self, key: &SymbolKey) -> Vec<Location> {
3253 let files_locked = self.files.read();
3254 let mut all_refs = if let Some(sigil) = key.sigil {
3255 let var_name = format!("{}{}", sigil, key.name);
3257 let mut refs = Vec::new();
3258 for (_uri_key, file_index) in files_locked.iter() {
3259 if let Some(var_refs) = file_index.references.get(&var_name) {
3260 for reference in var_refs {
3261 refs.push(Location { uri: reference.uri.clone(), range: reference.range });
3262 }
3263 }
3264 }
3265 refs
3266 } else {
3267 if key.pkg.as_ref() == "main" {
3269 let mut refs = self.find_references(&format!("main::{}", key.name));
3271 for (_uri_key, file_index) in files_locked.iter() {
3273 if let Some(bare_refs) = file_index.references.get(key.name.as_ref()) {
3274 for reference in bare_refs {
3275 refs.push(Location {
3276 uri: reference.uri.clone(),
3277 range: reference.range,
3278 });
3279 }
3280 }
3281 }
3282 refs
3283 } else {
3284 let qualified_name = format!("{}::{}", key.pkg, key.name);
3285 self.find_references(&qualified_name)
3286 }
3287 };
3288 drop(files_locked);
3289
3290 if let Some(def) = self.find_def(key) {
3292 all_refs.retain(|loc| !(loc.uri == def.uri && loc.range == def.range));
3293 }
3294
3295 let mut seen = HashSet::new();
3297 all_refs.retain(|loc| {
3298 seen.insert((
3299 loc.uri.clone(),
3300 loc.range.start.line,
3301 loc.range.start.column,
3302 loc.range.end.line,
3303 loc.range.end.column,
3304 ))
3305 });
3306
3307 all_refs
3308 }
3309}
3310
3311struct IndexVisitor {
3313 document: Document,
3314 uri: String,
3315 current_package: Option<String>,
3316 workspace_folder_uri: Option<String>,
3317}
3318
3319fn is_interpolated_var_start(byte: u8) -> bool {
3320 byte.is_ascii_alphabetic() || byte == b'_'
3321}
3322
3323fn is_interpolated_var_continue(byte: u8) -> bool {
3324 byte.is_ascii_alphanumeric() || byte == b'_' || byte == b':'
3325}
3326
3327fn has_escaped_interpolation_marker(bytes: &[u8], index: usize) -> bool {
3328 if index == 0 {
3329 return false;
3330 }
3331
3332 let mut backslashes = 0usize;
3333 let mut cursor = index;
3334 while cursor > 0 && bytes[cursor - 1] == b'\\' {
3335 backslashes += 1;
3336 cursor -= 1;
3337 }
3338
3339 backslashes % 2 == 1
3340}
3341
3342fn strip_matching_quote_delimiters(raw_content: &str) -> &str {
3343 if raw_content.len() < 2 {
3344 return raw_content;
3345 }
3346
3347 let bytes = raw_content.as_bytes();
3348 match (bytes.first(), bytes.last()) {
3349 (Some(b'"'), Some(b'"')) | (Some(b'\''), Some(b'\'')) => {
3350 &raw_content[1..raw_content.len() - 1]
3351 }
3352 _ => raw_content,
3353 }
3354}
3355
3356impl IndexVisitor {
3357 fn new(document: &mut Document, uri: String, workspace_folder_uri: Option<String>) -> Self {
3358 Self {
3359 document: document.clone(),
3360 uri,
3361 current_package: Some("main".to_string()),
3362 workspace_folder_uri,
3363 }
3364 }
3365
3366 fn visit(&mut self, node: &Node, file_index: &mut FileIndex) {
3367 self.project_symbol_declarations(node, file_index);
3368 self.visit_node(node, file_index);
3369 }
3370
3371 fn project_symbol_declarations(&self, node: &Node, file_index: &mut FileIndex) {
3372 for decl in extract_symbol_decls(node, self.current_package.as_deref()) {
3373 let (start, end) = match decl.kind {
3374 SymbolKind::Variable(_) => match decl.anchor_span {
3375 Some(span) => span,
3376 None => decl.full_span,
3377 },
3378 _ => decl.full_span,
3379 };
3380 let ((start_line, start_col), (end_line, end_col)) =
3381 self.document.line_index.range(start, end);
3382 let range = Range {
3383 start: Position { byte: start, line: start_line, column: start_col },
3384 end: Position { byte: end, line: end_line, column: end_col },
3385 };
3386
3387 let symbol_name = symbol_decl_name(&decl.kind, &decl.name);
3388
3389 let qualified_name = match &decl.declarator {
3394 Some(d) if d == "my" || d == "state" => None,
3395 _ => (!decl.qualified_name.is_empty()).then_some(decl.qualified_name),
3396 };
3397
3398 let container_name = match decl.kind {
3401 SymbolKind::Package => None,
3402 _ => decl.container,
3403 };
3404
3405 file_index.symbols.push(WorkspaceSymbol {
3406 name: symbol_name.clone(),
3407 kind: decl.kind,
3408 uri: self.uri.clone(),
3409 range,
3410 qualified_name,
3411 documentation: None,
3412 container_name,
3413 has_body: true,
3414 workspace_folder_uri: self.workspace_folder_uri.clone(),
3415 });
3416
3417 file_index.references.entry(symbol_name).or_default().push(SymbolReference {
3418 uri: self.uri.clone(),
3419 range,
3420 kind: ReferenceKind::Definition,
3421 });
3422 }
3423 }
3424
3425 fn record_interpolated_variable_references(
3426 &self,
3427 raw_content: &str,
3428 range: Range,
3429 file_index: &mut FileIndex,
3430 ) {
3431 let content = strip_matching_quote_delimiters(raw_content);
3432 let bytes = content.as_bytes();
3433 let mut index = 0;
3434
3435 while index < bytes.len() {
3436 if has_escaped_interpolation_marker(bytes, index) {
3437 index += 1;
3438 continue;
3439 }
3440
3441 let sigil = match bytes[index] {
3442 b'$' => "$",
3443 b'@' => "@",
3444 _ => {
3445 index += 1;
3446 continue;
3447 }
3448 };
3449
3450 if index + 1 >= bytes.len() {
3451 break;
3452 }
3453
3454 let (start, needs_closing_brace) =
3455 if bytes[index + 1] == b'{' { (index + 2, true) } else { (index + 1, false) };
3456
3457 if start >= bytes.len() || !is_interpolated_var_start(bytes[start]) {
3458 index += 1;
3459 continue;
3460 }
3461
3462 let mut end = start + 1;
3463 while end < bytes.len() && is_interpolated_var_continue(bytes[end]) {
3464 end += 1;
3465 }
3466
3467 if needs_closing_brace && (end >= bytes.len() || bytes[end] != b'}') {
3468 index += 1;
3469 continue;
3470 }
3471
3472 if let Some(name) = content.get(start..end) {
3473 let var_name = format!("{sigil}{name}");
3474 file_index.references.entry(var_name).or_default().push(SymbolReference {
3475 uri: self.uri.clone(),
3476 range,
3477 kind: ReferenceKind::Read,
3478 });
3479 }
3480
3481 index = if needs_closing_brace { end + 1 } else { end };
3482 }
3483 }
3484
3485 fn visit_node(&mut self, node: &Node, file_index: &mut FileIndex) {
3486 match &node.kind {
3487 NodeKind::Package { name, .. } => {
3488 let package_name = name.clone();
3489
3490 self.current_package = Some(package_name.clone());
3492 }
3493
3494 NodeKind::Subroutine { body, .. } => {
3495 self.visit_node(body, file_index);
3497 }
3498
3499 NodeKind::VariableDeclaration { initializer, .. } => {
3500 if let Some(init) = initializer {
3502 self.visit_node(init, file_index);
3503 }
3504 }
3505
3506 NodeKind::VariableListDeclaration { initializer, .. } => {
3507 if let Some(init) = initializer {
3509 self.visit_node(init, file_index);
3510 }
3511 }
3512
3513 NodeKind::Variable { sigil, name } => {
3514 let var_name = format!("{}{}", sigil, name);
3515
3516 file_index.references.entry(var_name).or_default().push(SymbolReference {
3518 uri: self.uri.clone(),
3519 range: self.node_to_range(node),
3520 kind: ReferenceKind::Read, });
3522 }
3523
3524 NodeKind::FunctionCall { name, args, .. } => {
3525 let func_name = name.clone();
3526 let location = self.node_to_range(node);
3527
3528 let (pkg, bare_name) = if let Some(idx) = func_name.rfind("::") {
3530 (&func_name[..idx], &func_name[idx + 2..])
3531 } else {
3532 (self.current_package.as_deref().unwrap_or("main"), func_name.as_str())
3533 };
3534
3535 let qualified = format!("{}::{}", pkg, bare_name);
3536
3537 file_index.references.entry(bare_name.to_string()).or_default().push(
3541 SymbolReference {
3542 uri: self.uri.clone(),
3543 range: location,
3544 kind: ReferenceKind::Usage,
3545 },
3546 );
3547 file_index.references.entry(qualified).or_default().push(SymbolReference {
3548 uri: self.uri.clone(),
3549 range: location,
3550 kind: ReferenceKind::Usage,
3551 });
3552
3553 if name == "extends" || name == "with" {
3554 for module_name in extract_module_names_from_call_args(args) {
3555 file_index
3556 .dependencies
3557 .insert(normalize_dependency_module_name(&module_name));
3558 }
3559 } else if name == "require" {
3560 if let Some(module_name) = extract_module_name_from_require_args(args) {
3561 file_index
3562 .dependencies
3563 .insert(normalize_dependency_module_name(&module_name));
3564 }
3565 }
3566
3567 for arg in args {
3569 self.visit_node(arg, file_index);
3570 }
3571 }
3572
3573 NodeKind::Use { module, args, .. } => {
3574 let module_name = normalize_dependency_module_name(module);
3575 file_index.dependencies.insert(module_name.clone());
3576
3577 if module == "parent" || module == "base" {
3581 for name in extract_module_names_from_use_args(args) {
3582 file_index.dependencies.insert(normalize_dependency_module_name(&name));
3583 }
3584 }
3585
3586 file_index.references.entry(module_name).or_default().push(SymbolReference {
3588 uri: self.uri.clone(),
3589 range: self.node_to_range(node),
3590 kind: ReferenceKind::Import,
3591 });
3592 }
3593
3594 NodeKind::Assignment { lhs, rhs, op } => {
3596 let is_compound = op != "=";
3598
3599 if let NodeKind::Variable { sigil, name } = &lhs.kind {
3600 let var_name = format!("{}{}", sigil, name);
3601
3602 if is_compound {
3604 file_index.references.entry(var_name.clone()).or_default().push(
3605 SymbolReference {
3606 uri: self.uri.clone(),
3607 range: self.node_to_range(lhs),
3608 kind: ReferenceKind::Read,
3609 },
3610 );
3611 }
3612
3613 file_index.references.entry(var_name).or_default().push(SymbolReference {
3615 uri: self.uri.clone(),
3616 range: self.node_to_range(lhs),
3617 kind: ReferenceKind::Write,
3618 });
3619 }
3620
3621 self.visit_node(rhs, file_index);
3623 }
3624
3625 NodeKind::Block { statements } => {
3627 for stmt in statements {
3628 self.visit_node(stmt, file_index);
3629 }
3630 }
3631
3632 NodeKind::If { condition, then_branch, elsif_branches, else_branch } => {
3633 self.visit_node(condition, file_index);
3634 self.visit_node(then_branch, file_index);
3635 for (cond, branch) in elsif_branches {
3636 self.visit_node(cond, file_index);
3637 self.visit_node(branch, file_index);
3638 }
3639 if let Some(else_br) = else_branch {
3640 self.visit_node(else_br, file_index);
3641 }
3642 }
3643
3644 NodeKind::While { condition, body, continue_block } => {
3645 self.visit_node(condition, file_index);
3646 self.visit_node(body, file_index);
3647 if let Some(cont) = continue_block {
3648 self.visit_node(cont, file_index);
3649 }
3650 }
3651
3652 NodeKind::For { init, condition, update, body, continue_block } => {
3653 if let Some(i) = init {
3654 self.visit_node(i, file_index);
3655 }
3656 if let Some(c) = condition {
3657 self.visit_node(c, file_index);
3658 }
3659 if let Some(u) = update {
3660 self.visit_node(u, file_index);
3661 }
3662 self.visit_node(body, file_index);
3663 if let Some(cont) = continue_block {
3664 self.visit_node(cont, file_index);
3665 }
3666 }
3667
3668 NodeKind::Foreach { variable, list, body, continue_block } => {
3669 if let Some(cb) = continue_block {
3671 self.visit_node(cb, file_index);
3672 }
3673 if let NodeKind::Variable { sigil, name } = &variable.kind {
3674 let var_name = format!("{}{}", sigil, name);
3675 file_index.references.entry(var_name).or_default().push(SymbolReference {
3676 uri: self.uri.clone(),
3677 range: self.node_to_range(variable),
3678 kind: ReferenceKind::Write,
3679 });
3680 }
3681 self.visit_node(variable, file_index);
3682 self.visit_node(list, file_index);
3683 self.visit_node(body, file_index);
3684 }
3685
3686 NodeKind::MethodCall { object, method, args } => {
3687 let qualified_method = if let NodeKind::Identifier { name } = &object.kind {
3689 Some(format!("{}::{}", name, method))
3691 } else {
3692 None
3694 };
3695
3696 self.visit_node(object, file_index);
3698
3699 let location = self.node_to_range(node);
3706 if let Some(qualified_method) = qualified_method.as_ref() {
3707 file_index.references.entry(qualified_method.clone()).or_default().push(
3708 SymbolReference {
3709 uri: self.uri.clone(),
3710 range: location,
3711 kind: ReferenceKind::Usage,
3712 },
3713 );
3714 }
3715 file_index.references.entry(method.clone()).or_default().push(SymbolReference {
3716 uri: self.uri.clone(),
3717 range: location,
3718 kind: ReferenceKind::Usage,
3719 });
3720
3721 if method == "import"
3722 && let NodeKind::Identifier { name: module_name } = &object.kind
3723 {
3724 for symbol in extract_manual_import_symbols(args) {
3725 file_index.references.entry(symbol).or_default().push(SymbolReference {
3726 uri: self.uri.clone(),
3727 range: self.node_to_range(node),
3728 kind: ReferenceKind::Import,
3729 });
3730 }
3731 file_index.dependencies.insert(normalize_dependency_module_name(module_name));
3732 }
3733
3734 for arg in args {
3736 self.visit_node(arg, file_index);
3737 }
3738 }
3739
3740 NodeKind::No { module, .. } => {
3741 let module_name = normalize_dependency_module_name(module);
3742 file_index.dependencies.insert(module_name);
3743 }
3744
3745 NodeKind::Class { name, .. } => {
3746 self.current_package = Some(name.clone());
3747 }
3748
3749 NodeKind::Method { body, signature, .. } => {
3750 if let Some(sig) = signature {
3752 if let NodeKind::Signature { parameters } = &sig.kind {
3753 for param in parameters {
3754 self.visit_node(param, file_index);
3755 }
3756 }
3757 }
3758
3759 self.visit_node(body, file_index);
3761 }
3762
3763 NodeKind::String { value, interpolated } => {
3764 if *interpolated {
3765 let range = self.node_to_range(node);
3766 self.record_interpolated_variable_references(value, range, file_index);
3767 }
3768 }
3769
3770 NodeKind::Heredoc { content, interpolated, .. } => {
3771 if *interpolated {
3772 let range = self.node_to_range(node);
3773 self.record_interpolated_variable_references(content, range, file_index);
3774 }
3775 }
3776
3777 NodeKind::Unary { op, operand } if op == "++" || op == "--" => {
3779 if let NodeKind::Variable { sigil, name } = &operand.kind {
3781 let var_name = format!("{}{}", sigil, name);
3782
3783 file_index.references.entry(var_name.clone()).or_default().push(
3785 SymbolReference {
3786 uri: self.uri.clone(),
3787 range: self.node_to_range(operand),
3788 kind: ReferenceKind::Read,
3789 },
3790 );
3791
3792 file_index.references.entry(var_name).or_default().push(SymbolReference {
3793 uri: self.uri.clone(),
3794 range: self.node_to_range(operand),
3795 kind: ReferenceKind::Write,
3796 });
3797 }
3798 }
3799
3800 _ => {
3801 self.visit_children(node, file_index);
3803 }
3804 }
3805 }
3806
3807 fn visit_children(&mut self, node: &Node, file_index: &mut FileIndex) {
3808 match &node.kind {
3810 NodeKind::Program { statements } => {
3811 for stmt in statements {
3812 self.visit_node(stmt, file_index);
3813 }
3814 }
3815 NodeKind::ExpressionStatement { expression } => {
3816 self.visit_node(expression, file_index);
3817 }
3818 NodeKind::Unary { operand, .. } => {
3820 self.visit_node(operand, file_index);
3821 }
3822 NodeKind::Binary { left, right, .. } => {
3823 self.visit_node(left, file_index);
3824 self.visit_node(right, file_index);
3825 }
3826 NodeKind::Ternary { condition, then_expr, else_expr } => {
3827 self.visit_node(condition, file_index);
3828 self.visit_node(then_expr, file_index);
3829 self.visit_node(else_expr, file_index);
3830 }
3831 NodeKind::ArrayLiteral { elements } => {
3832 for elem in elements {
3833 self.visit_node(elem, file_index);
3834 }
3835 }
3836 NodeKind::HashLiteral { pairs } => {
3837 for (key, value) in pairs {
3838 self.visit_node(key, file_index);
3839 self.visit_node(value, file_index);
3840 }
3841 }
3842 NodeKind::Return { value } => {
3843 if let Some(val) = value {
3844 self.visit_node(val, file_index);
3845 }
3846 }
3847 NodeKind::Eval { block } | NodeKind::Do { block } | NodeKind::Defer { block } => {
3848 self.visit_node(block, file_index);
3849 }
3850 NodeKind::Try { body, catch_blocks, finally_block } => {
3851 self.visit_node(body, file_index);
3852 for (_, block) in catch_blocks {
3853 self.visit_node(block, file_index);
3854 }
3855 if let Some(finally) = finally_block {
3856 self.visit_node(finally, file_index);
3857 }
3858 }
3859 NodeKind::Given { expr, body } => {
3860 self.visit_node(expr, file_index);
3861 self.visit_node(body, file_index);
3862 }
3863 NodeKind::When { condition, body } => {
3864 self.visit_node(condition, file_index);
3865 self.visit_node(body, file_index);
3866 }
3867 NodeKind::Default { body } => {
3868 self.visit_node(body, file_index);
3869 }
3870 NodeKind::StatementModifier { statement, condition, .. } => {
3871 self.visit_node(statement, file_index);
3872 self.visit_node(condition, file_index);
3873 }
3874 NodeKind::VariableWithAttributes { variable, .. } => {
3875 self.visit_node(variable, file_index);
3876 }
3877 NodeKind::LabeledStatement { statement, .. } => {
3878 self.visit_node(statement, file_index);
3879 }
3880 _ => {
3881 }
3883 }
3884 }
3885
3886 fn node_to_range(&mut self, node: &Node) -> Range {
3887 let ((start_line, start_col), (end_line, end_col)) =
3889 self.document.line_index.range(node.location.start, node.location.end);
3890 Range {
3892 start: Position { byte: node.location.start, line: start_line, column: start_col },
3893 end: Position { byte: node.location.end, line: end_line, column: end_col },
3894 }
3895 }
3896}
3897
3898fn symbol_decl_name(kind: &SymbolKind, name: &str) -> String {
3899 match kind {
3900 SymbolKind::Variable(VarKind::Scalar) => format!("${name}"),
3901 SymbolKind::Variable(VarKind::Array) => format!("@{name}"),
3902 SymbolKind::Variable(VarKind::Hash) => format!("%{name}"),
3903 _ => name.to_string(),
3904 }
3905}
3906
3907fn extract_module_names_from_use_args(args: &[String]) -> Vec<String> {
3917 use std::collections::HashSet;
3918
3919 fn normalize_module_name(token: &str) -> Option<&str> {
3920 let stripped = token.trim_matches(|c: char| {
3921 matches!(c, '\'' | '"' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ';')
3922 });
3923
3924 if stripped.is_empty() || stripped.starts_with('-') {
3925 return None;
3926 }
3927
3928 stripped
3929 .chars()
3930 .all(|c| c.is_alphanumeric() || c == '_' || c == ':' || c == '\'')
3931 .then_some(stripped)
3932 }
3933
3934 let joined = args.join(" ");
3935
3936 let (qw_words, remainder) = extract_qw_words(&joined);
3937 let mut modules = Vec::new();
3938 let mut seen = HashSet::new();
3939 for word in qw_words {
3940 if let Some(candidate) = normalize_module_name(&word) {
3941 let canonical = canonicalize_perl_module_name(candidate);
3942 if seen.insert(canonical.clone()) {
3943 modules.push(canonical);
3944 }
3945 }
3946 }
3947
3948 for token in remainder.split_whitespace().flat_map(|t| t.split(',')) {
3949 if let Some(candidate) = normalize_module_name(token) {
3950 let canonical = canonicalize_perl_module_name(candidate);
3951 if seen.insert(canonical.clone()) {
3952 modules.push(canonical);
3953 }
3954 }
3955 }
3956
3957 modules
3958}
3959
3960fn extract_module_names_from_call_args(args: &[Node]) -> Vec<String> {
3961 fn collect_from_node(node: &Node, out: &mut Vec<String>) {
3962 match &node.kind {
3963 NodeKind::String { value, .. } => {
3964 out.extend(extract_module_names_from_use_args(std::slice::from_ref(value)));
3965 }
3966 NodeKind::Identifier { name } => {
3967 out.extend(extract_module_names_from_use_args(std::slice::from_ref(name)));
3968 }
3969 NodeKind::ArrayLiteral { elements } => {
3970 for element in elements {
3971 collect_from_node(element, out);
3972 }
3973 }
3974 NodeKind::FunctionCall { name, args, .. } if name == "qw" => {
3975 for arg in args {
3976 collect_from_node(arg, out);
3977 }
3978 }
3979 _ => {}
3980 }
3981 }
3982
3983 let mut modules = Vec::new();
3984 for arg in args {
3985 collect_from_node(arg, &mut modules);
3986 }
3987 modules
3988}
3989
3990fn canonicalize_perl_module_name(name: &str) -> String {
3991 name.replace('\'', "::")
3994}
3995
3996fn legacy_perl_module_name(name: &str) -> String {
3997 name.replace("::", "'")
3998}
3999
4000fn normalize_dependency_module_name(module_name: &str) -> String {
4003 canonicalize_perl_module_name(module_name)
4004}
4005
4006fn extract_qw_words(input: &str) -> (Vec<String>, String) {
4007 let chars: Vec<char> = input.chars().collect();
4008 let mut i = 0;
4009 let mut words = Vec::new();
4010 let mut remainder = String::new();
4011
4012 while i < chars.len() {
4013 if chars[i] == 'q'
4014 && i + 1 < chars.len()
4015 && chars[i + 1] == 'w'
4016 && (i == 0 || !chars[i - 1].is_alphanumeric())
4017 {
4018 let mut j = i + 2;
4019 while j < chars.len() && chars[j].is_whitespace() {
4020 j += 1;
4021 }
4022 if j >= chars.len() {
4023 remainder.push(chars[i]);
4024 i += 1;
4025 continue;
4026 }
4027
4028 let open = chars[j];
4029 let (close, is_paired_delimiter) = match open {
4030 '(' => (')', true),
4031 '[' => (']', true),
4032 '{' => ('}', true),
4033 '<' => ('>', true),
4034 _ => (open, false),
4035 };
4036 if open.is_alphanumeric() || open == '_' || open == '\'' || open == '"' {
4037 remainder.push(chars[i]);
4038 i += 1;
4039 continue;
4040 }
4041
4042 let mut k = j + 1;
4043 if is_paired_delimiter {
4044 let mut depth = 1usize;
4045 while k < chars.len() && depth > 0 {
4046 if chars[k] == open {
4047 depth += 1;
4048 } else if chars[k] == close {
4049 depth -= 1;
4050 }
4051 k += 1;
4052 }
4053 if depth != 0 {
4054 remainder.extend(chars[i..].iter());
4055 break;
4056 }
4057 k -= 1;
4058 } else {
4059 while k < chars.len() && chars[k] != close {
4060 k += 1;
4061 }
4062 if k >= chars.len() {
4063 remainder.extend(chars[i..].iter());
4064 break;
4065 }
4066 }
4067
4068 let content: String = chars[j + 1..k].iter().collect();
4069 for word in content.split_whitespace() {
4070 if !word.is_empty() {
4071 words.push(word.to_string());
4072 }
4073 }
4074 i = k + 1;
4075 continue;
4076 }
4077
4078 remainder.push(chars[i]);
4079 i += 1;
4080 }
4081
4082 (words, remainder)
4083}
4084
4085fn extract_module_name_from_require_args(args: &[Node]) -> Option<String> {
4086 let first = args.first()?;
4087 match &first.kind {
4088 NodeKind::Identifier { name } => Some(name.clone()),
4089 NodeKind::String { value, .. } => {
4090 let cleaned = value.trim_matches('\'').trim_matches('"').trim();
4091 if cleaned.is_empty() {
4092 return None;
4093 }
4094 Some(cleaned.trim_end_matches(".pm").replace('/', "::"))
4095 }
4096 _ => None,
4097 }
4098}
4099
4100fn extract_manual_import_symbols(args: &[Node]) -> Vec<String> {
4101 fn push_if_bareword(out: &mut Vec<String>, token: &str) {
4102 let bare = token.trim().trim_matches('"').trim_matches('\'').trim();
4103 if bare.is_empty() || bare == "," {
4104 return;
4105 }
4106 let is_bareword = bare.bytes().all(|ch| ch.is_ascii_alphanumeric() || ch == b'_')
4107 && bare.as_bytes().first().is_some_and(|ch| ch.is_ascii_alphabetic() || *ch == b'_');
4108 if is_bareword {
4109 out.push(bare.to_string());
4110 }
4111 }
4112
4113 let mut symbols = Vec::new();
4114 for arg in args {
4115 match &arg.kind {
4116 NodeKind::String { value, .. } => push_if_bareword(&mut symbols, value),
4117 NodeKind::Identifier { name } => {
4118 if name.starts_with("qw") {
4119 let content = name
4120 .trim_start_matches("qw")
4121 .trim_start_matches(|c: char| "([{/<|!".contains(c))
4122 .trim_end_matches(|c: char| ")]}/|!>".contains(c));
4123 for token in content.split_whitespace() {
4124 push_if_bareword(&mut symbols, token);
4125 }
4126 } else {
4127 push_if_bareword(&mut symbols, name);
4128 }
4129 }
4130 NodeKind::ArrayLiteral { elements } => {
4131 for element in elements {
4132 if let NodeKind::String { value, .. } = &element.kind {
4133 push_if_bareword(&mut symbols, value);
4134 }
4135 }
4136 }
4137 _ => {}
4138 }
4139 }
4140 symbols.sort();
4141 symbols.dedup();
4142 symbols
4143}
4144
4145#[cfg(test)]
4163fn extract_constant_names_from_use_args(args: &[String]) -> Vec<String> {
4164 use std::collections::HashSet;
4165
4166 fn push_unique(names: &mut Vec<String>, seen: &mut HashSet<String>, candidate: &str) {
4167 if seen.insert(candidate.to_string()) {
4168 names.push(candidate.to_string());
4169 }
4170 }
4171
4172 fn normalize_constant_name(token: &str) -> Option<&str> {
4173 let stripped = token.trim_matches(|c: char| {
4174 matches!(c, '\'' | '"' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ';')
4175 });
4176
4177 if stripped.is_empty() || stripped.starts_with('-') {
4178 return None;
4179 }
4180
4181 stripped.chars().all(|c| c.is_alphanumeric() || c == '_').then_some(stripped)
4182 }
4183
4184 let mut names = Vec::new();
4185 let mut seen = HashSet::new();
4186
4187 let first = match args.first() {
4191 Some(f) => f.as_str(),
4192 None => return names,
4193 };
4194
4195 if first.starts_with("qw") {
4197 let (qw_words, remainder) = extract_qw_words(first);
4198 if remainder.trim().is_empty() {
4199 for word in qw_words {
4200 if let Some(candidate) = normalize_constant_name(&word) {
4201 push_unique(&mut names, &mut seen, candidate);
4202 }
4203 }
4204 return names;
4205 }
4206
4207 let content = first.trim_start_matches("qw").trim_start();
4209 let content = content
4210 .trim_start_matches(|c: char| "([{/<|!".contains(c))
4211 .trim_end_matches(|c: char| ")]}/|!>".contains(c));
4212 for word in content.split_whitespace() {
4213 if let Some(candidate) = normalize_constant_name(word) {
4214 push_unique(&mut names, &mut seen, candidate);
4215 }
4216 }
4217 return names;
4218 }
4219
4220 let starts_hash_form = first == "{"
4222 || first == "+{"
4223 || (first == "+" && args.get(1).map(String::as_str) == Some("{"));
4224 if starts_hash_form {
4225 let mut skipped_leading_plus = false;
4226 let mut iter = args.iter().peekable();
4227 while let Some(arg) = iter.next() {
4228 if arg == "+{" {
4231 skipped_leading_plus = true;
4232 continue;
4233 }
4234 if arg == "+" && !skipped_leading_plus {
4235 skipped_leading_plus = true;
4236 continue;
4237 }
4238 if arg == "{" || arg == "}" || arg == "," || arg == "=>" {
4239 continue;
4240 }
4241 if let Some(candidate) = normalize_constant_name(arg)
4242 && iter.peek().map(|s| s.as_str()) == Some("=>")
4243 {
4244 push_unique(&mut names, &mut seen, candidate);
4245 }
4246 }
4247 return names;
4248 }
4249
4250 if let Some(candidate) = normalize_constant_name(first) {
4253 push_unique(&mut names, &mut seen, candidate);
4254 }
4255
4256 names
4257}
4258
4259impl Default for WorkspaceIndex {
4260 fn default() -> Self {
4261 Self::new()
4262 }
4263}
4264
4265#[cfg(all(feature = "workspace", feature = "lsp-compat"))]
4267pub mod lsp_adapter {
4269 use super::Location as IxLocation;
4270 use lsp_types::Location as LspLocation;
4271 type LspUrl = lsp_types::Uri;
4273
4274 pub fn to_lsp_location(ix: &IxLocation) -> Option<LspLocation> {
4294 parse_url(&ix.uri).map(|uri| {
4295 let start =
4296 lsp_types::Position { line: ix.range.start.line, character: ix.range.start.column };
4297 let end =
4298 lsp_types::Position { line: ix.range.end.line, character: ix.range.end.column };
4299 let range = lsp_types::Range { start, end };
4300 LspLocation { uri, range }
4301 })
4302 }
4303
4304 pub fn to_lsp_locations(all: impl IntoIterator<Item = IxLocation>) -> Vec<LspLocation> {
4325 all.into_iter().filter_map(|ix| to_lsp_location(&ix)).collect()
4326 }
4327
4328 #[cfg(not(target_arch = "wasm32"))]
4329 fn parse_url(s: &str) -> Option<LspUrl> {
4330 use std::str::FromStr;
4332
4333 LspUrl::from_str(s).ok().or_else(|| {
4335 std::path::Path::new(s).canonicalize().ok().and_then(|p| {
4337 crate::workspace_index::fs_path_to_uri(&p)
4339 .ok()
4340 .and_then(|uri_string| LspUrl::from_str(&uri_string).ok())
4341 })
4342 })
4343 }
4344
4345 #[cfg(target_arch = "wasm32")]
4347 fn parse_url(s: &str) -> Option<LspUrl> {
4348 use std::str::FromStr;
4349 LspUrl::from_str(s).ok()
4350 }
4351}
4352
4353#[cfg(test)]
4354mod tests {
4355 use super::*;
4356 use perl_tdd_support::{must, must_some};
4357
4358 #[test]
4359 fn test_use_constant_indexed_as_constant_symbol() {
4360 let index = WorkspaceIndex::new();
4361 let uri = "file:///lib/My/Config.pm";
4362 let code = r#"package My::Config;
4363use constant PI => 3.14159;
4364use constant {
4365 MAX_RETRIES => 3,
4366 TIMEOUT => 30,
4367};
43681;
4369"#;
4370 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4371
4372 let symbols = index.file_symbols(uri);
4373 assert!(
4374 symbols.iter().any(|s| s.name == "PI" && s.kind == SymbolKind::Constant),
4375 "PI should be indexed as a Constant symbol; got: {:?}",
4376 symbols.iter().map(|s| (&s.name, &s.kind)).collect::<Vec<_>>()
4377 );
4378 assert!(
4379 symbols.iter().any(|s| s.name == "MAX_RETRIES" && s.kind == SymbolKind::Constant),
4380 "MAX_RETRIES should be indexed"
4381 );
4382 assert!(
4383 symbols.iter().any(|s| s.name == "TIMEOUT" && s.kind == SymbolKind::Constant),
4384 "TIMEOUT should be indexed"
4385 );
4386
4387 let def = index.find_definition("My::Config::PI");
4389 assert!(def.is_some(), "find_definition('My::Config::PI') should succeed");
4390 }
4391
4392 #[test]
4393 fn test_extract_constant_names_deduplicates_qw_form() {
4394 let names = extract_constant_names_from_use_args(&["qw(FOO BAR FOO)".to_string()]);
4395 assert_eq!(names, vec!["FOO", "BAR"]);
4396 }
4397
4398 #[test]
4399 fn test_extract_constant_names_accepts_quoted_scalar_form() {
4400 let names = extract_constant_names_from_use_args(&[
4401 "'HTTP_OK'".to_string(),
4402 "=>".to_string(),
4403 "200".to_string(),
4404 ]);
4405 assert_eq!(names, vec!["HTTP_OK"]);
4406 }
4407
4408 #[test]
4409 fn test_extract_constant_names_accepts_quoted_hash_form() {
4410 let names = extract_constant_names_from_use_args(&[
4411 "{".to_string(),
4412 "'FOO'".to_string(),
4413 "=>".to_string(),
4414 "1".to_string(),
4415 ",".to_string(),
4416 "\"BAR\"".to_string(),
4417 "=>".to_string(),
4418 "2".to_string(),
4419 "}".to_string(),
4420 ]);
4421 assert_eq!(names, vec!["FOO", "BAR"]);
4422 }
4423
4424 #[test]
4425 fn test_extract_constant_names_accepts_plus_hash_form_split_tokens() {
4426 let names = extract_constant_names_from_use_args(&[
4427 "+".to_string(),
4428 "{".to_string(),
4429 "FOO".to_string(),
4430 "=>".to_string(),
4431 "1".to_string(),
4432 ",".to_string(),
4433 "BAR".to_string(),
4434 "=>".to_string(),
4435 "2".to_string(),
4436 "}".to_string(),
4437 ]);
4438 assert_eq!(names, vec!["FOO", "BAR"]);
4439 }
4440
4441 #[test]
4442 fn test_extract_constant_names_accepts_plus_hash_form_combined_token() {
4443 let names = extract_constant_names_from_use_args(&[
4444 "+{".to_string(),
4445 "FOO".to_string(),
4446 "=>".to_string(),
4447 "1".to_string(),
4448 ",".to_string(),
4449 "BAR".to_string(),
4450 "=>".to_string(),
4451 "2".to_string(),
4452 "}".to_string(),
4453 ]);
4454 assert_eq!(names, vec!["FOO", "BAR"]);
4455 }
4456 #[test]
4457 fn test_use_constant_duplicate_names_indexed_once() {
4458 let index = WorkspaceIndex::new();
4459 let uri = "file:///lib/My/DedupConfig.pm";
4460 let code = r#"package My::DedupConfig;
4461use constant {
4462 RETRY_COUNT => 3,
4463 RETRY_COUNT => 5,
4464};
44651;
4466"#;
4467 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4468
4469 let symbols = index.file_symbols(uri);
4470 let retry_count_symbols = symbols.iter().filter(|s| s.name == "RETRY_COUNT").count();
4471 assert_eq!(
4472 retry_count_symbols, 1,
4473 "RETRY_COUNT should be indexed once even when repeated in use constant hash form"
4474 );
4475 }
4476
4477 #[test]
4478 fn test_use_constant_plus_hash_form_indexes_keys() {
4479 let index = WorkspaceIndex::new();
4480 let uri = "file:///lib/My/PlusHash.pm";
4481 let code = r#"package My::PlusHash;
4482use constant +{
4483 FOO => 1,
4484 BAR => 2,
4485};
44861;
4487"#;
4488 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4489
4490 assert!(index.find_definition("My::PlusHash::FOO").is_some());
4491 assert!(index.find_definition("My::PlusHash::BAR").is_some());
4492 }
4493
4494 #[test]
4495 fn test_basic_indexing() {
4496 let index = WorkspaceIndex::new();
4497 let uri = "file:///test.pl";
4498
4499 let code = r#"
4500package MyPackage;
4501
4502sub hello {
4503 print "Hello";
4504}
4505
4506my $var = 42;
4507"#;
4508
4509 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4510
4511 let symbols = index.file_symbols(uri);
4513 assert!(symbols.iter().any(|s| s.name == "MyPackage" && s.kind == SymbolKind::Package));
4514 assert!(symbols.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
4515 assert!(symbols.iter().any(|s| s.name == "$var" && s.kind.is_variable()));
4516 }
4517
4518 #[test]
4519 fn test_package_symbol_has_no_container_name() {
4520 let index = WorkspaceIndex::new();
4525 let uri = "file:///lib/Foo.pm";
4526 let code = "package Foo;\nsub bar { }\n";
4527 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4528
4529 let symbols = index.file_symbols(uri);
4530 let pkg_sym = symbols.iter().find(|s| s.name == "Foo" && s.kind == SymbolKind::Package);
4531 assert!(pkg_sym.is_some(), "Package symbol not found");
4532 assert_eq!(
4533 pkg_sym.unwrap().container_name,
4534 None,
4535 "Package symbol must not carry a container (was 'main')"
4536 );
4537 }
4538
4539 #[test]
4540 fn test_my_variable_has_no_qualified_name() {
4541 let index = WorkspaceIndex::new();
4546 let uri = "file:///lib/Foo.pm";
4547 let code = "package Foo;\nsub bar { my $x = 1; }\n";
4548 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4549
4550 let symbols = index.file_symbols(uri);
4551 let var_sym = symbols.iter().find(|s| s.name == "$x" && s.kind.is_variable());
4552 assert!(var_sym.is_some(), "$x variable not indexed");
4553 assert_eq!(
4554 var_sym.unwrap().qualified_name,
4555 None,
4556 "my variable must not have a qualified_name"
4557 );
4558
4559 assert!(
4561 index.find_definition("Foo::x").is_none(),
4562 "find_definition(\"Foo::x\") must not return a lexical my variable"
4563 );
4564 }
4565
4566 fn reference_kinds_for(
4567 index: &WorkspaceIndex,
4568 uri: &str,
4569 symbol_name: &str,
4570 ) -> Vec<ReferenceKind> {
4571 let files = index.files.read();
4572 let file = must_some(files.get(uri));
4573 file.references
4574 .get(symbol_name)
4575 .map(|refs| refs.iter().map(|r| r.kind).collect())
4576 .unwrap_or_default()
4577 }
4578
4579 #[test]
4580 fn test_reference_kinds_sub_definition_and_call_are_distinct() {
4581 let index = WorkspaceIndex::new();
4582 let uri = "file:///typed-refs-sub.pl";
4583 let code = "package TypedRefs;
4584sub foo { return 1; }
4585foo();
4586";
4587 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4588
4589 let kinds = reference_kinds_for(&index, uri, "foo");
4590 assert!(kinds.contains(&ReferenceKind::Definition));
4591 assert!(kinds.contains(&ReferenceKind::Usage));
4592 }
4593
4594 #[test]
4595 fn test_reference_kinds_variable_read_and_write_are_distinct() {
4596 let index = WorkspaceIndex::new();
4597 let uri = "file:///typed-refs-var.pl";
4598 let code = "my $value = 1;
4599$value = 2;
4600print $value;
4601";
4602 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4603
4604 let kinds = reference_kinds_for(&index, uri, "$value");
4605 assert!(kinds.contains(&ReferenceKind::Definition));
4606 assert!(kinds.contains(&ReferenceKind::Write));
4607 assert!(kinds.contains(&ReferenceKind::Read));
4608 }
4609
4610 #[test]
4611 fn test_reference_kinds_import_parent_and_export_ok_are_currently_import_only() {
4612 let index = WorkspaceIndex::new();
4613 let uri = "file:///typed-refs-import-export.pm";
4614 let code = "package Child;
4615use parent 'Base';
4616our @EXPORT_OK = qw(foo);
46171;
4618";
4619 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4620
4621 let parent_kinds = reference_kinds_for(&index, uri, "Base");
4622 assert!(
4623 parent_kinds.is_empty(),
4624 "use parent inheritance edges are currently not stored as typed references"
4625 );
4626
4627 let export_symbol_kinds = reference_kinds_for(&index, uri, "foo");
4628 assert!(
4629 export_symbol_kinds.is_empty(),
4630 "EXPORT_OK entries are currently not represented as reference edges"
4631 );
4632 }
4633
4634 #[test]
4635 fn test_reference_kinds_dynamic_and_meta_edges_are_not_typed_yet() {
4636 let index = WorkspaceIndex::new();
4637 let uri = "file:///typed-refs-dynamic.pl";
4638 let code = r#"package TypedRefs;
4639sub foo { 1 }
4640&foo;
4641my $code = \&foo;
4642goto &foo;
4643*alias = \&foo;
4644eval "foo()";
4645with 'RoleName';
4646has 'name' => (is => 'ro');
46471;
4648"#;
4649 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4650
4651 let foo_kinds = reference_kinds_for(&index, uri, "foo");
4652 assert!(
4653 foo_kinds
4654 .iter()
4655 .all(|kind| matches!(kind, ReferenceKind::Definition | ReferenceKind::Usage)),
4656 r"dynamic call forms (&foo, \&foo, goto &foo) are currently flattened to Usage"
4657 );
4658
4659 assert!(
4660 reference_kinds_for(&index, uri, "RoleName").is_empty(),
4661 "role composition edges (`with 'RoleName'`) are not indexed as typed references yet"
4662 );
4663 }
4664
4665 #[test]
4666 fn test_find_references() {
4667 let index = WorkspaceIndex::new();
4668 let uri = "file:///test.pl";
4669
4670 let code = r#"
4671sub test {
4672 my $x = 1;
4673 $x = 2;
4674 print $x;
4675}
4676"#;
4677
4678 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4679
4680 let refs = index.find_references("$x");
4681 assert!(refs.len() >= 2); }
4683
4684 #[test]
4685 fn test_find_references_bare_name_includes_qualified_calls() {
4686 let index = WorkspaceIndex::new();
4687 let uri = "file:///refs.pl";
4688 let code = r#"
4689package RefDemo;
4690sub helper {
4691 return 1;
4692}
4693
4694helper();
4695RefDemo::helper();
4696"#;
4697
4698 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4699
4700 let bare_refs = index.find_references("helper");
4701 let qualified_refs = index.find_references("RefDemo::helper");
4702
4703 assert!(
4704 bare_refs.len() >= qualified_refs.len(),
4705 "bare-name reference lookup should include qualified calls"
4706 );
4707 }
4708
4709 #[test]
4710 fn test_count_usages_bare_name_includes_qualified_calls() {
4711 let index = WorkspaceIndex::new();
4712 let uri = "file:///usage.pl";
4713 let code = r#"
4714package UsageDemo;
4715sub helper {
4716 return 1;
4717}
4718
4719helper();
4720UsageDemo::helper();
4721"#;
4722
4723 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4724
4725 let bare_usage_count = index.count_usages("helper");
4726 let qualified_usage_count = index.count_usages("UsageDemo::helper");
4727
4728 assert!(
4729 bare_usage_count >= qualified_usage_count,
4730 "bare-name usage count should include qualified call sites"
4731 );
4732 }
4733
4734 #[test]
4735 fn test_dependencies() {
4736 let index = WorkspaceIndex::new();
4737 let uri = "file:///test.pl";
4738
4739 let code = r#"
4740use strict;
4741use warnings;
4742use Data::Dumper;
4743"#;
4744
4745 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4746
4747 let deps = index.file_dependencies(uri);
4748 assert!(deps.contains("strict"));
4749 assert!(deps.contains("warnings"));
4750 assert!(deps.contains("Data::Dumper"));
4751 }
4752
4753 #[test]
4754 fn test_uri_to_fs_path_basic() {
4755 if let Some(path) = uri_to_fs_path("file:///tmp/test.pl") {
4757 assert_eq!(path, std::path::PathBuf::from("/tmp/test.pl"));
4758 }
4759
4760 assert!(uri_to_fs_path("not-a-uri").is_none());
4762
4763 assert!(uri_to_fs_path("http://example.com").is_none());
4765 }
4766
4767 #[test]
4768 fn test_uri_to_fs_path_with_spaces() {
4769 if let Some(path) = uri_to_fs_path("file:///tmp/path%20with%20spaces/test.pl") {
4771 assert_eq!(path, std::path::PathBuf::from("/tmp/path with spaces/test.pl"));
4772 }
4773
4774 if let Some(path) = uri_to_fs_path("file:///tmp/My%20Documents/test%20file.pl") {
4776 assert_eq!(path, std::path::PathBuf::from("/tmp/My Documents/test file.pl"));
4777 }
4778 }
4779
4780 #[test]
4781 fn test_uri_to_fs_path_with_unicode() {
4782 if let Some(path) = uri_to_fs_path("file:///tmp/caf%C3%A9/test.pl") {
4784 assert_eq!(path, std::path::PathBuf::from("/tmp/café/test.pl"));
4785 }
4786
4787 if let Some(path) = uri_to_fs_path("file:///tmp/emoji%F0%9F%98%80/test.pl") {
4789 assert_eq!(path, std::path::PathBuf::from("/tmp/emoji😀/test.pl"));
4790 }
4791 }
4792
4793 #[test]
4794 fn test_fs_path_to_uri_basic() {
4795 let result = fs_path_to_uri("/tmp/test.pl");
4797 assert!(result.is_ok());
4798 let uri = must(result);
4799 assert!(uri.starts_with("file://"));
4800 assert!(uri.contains("/tmp/test.pl"));
4801 }
4802
4803 #[test]
4804 fn test_fs_path_to_uri_with_spaces() {
4805 let result = fs_path_to_uri("/tmp/path with spaces/test.pl");
4807 assert!(result.is_ok());
4808 let uri = must(result);
4809 assert!(uri.starts_with("file://"));
4810 assert!(uri.contains("path%20with%20spaces"));
4812 }
4813
4814 #[test]
4815 fn test_fs_path_to_uri_with_unicode() {
4816 let result = fs_path_to_uri("/tmp/café/test.pl");
4818 assert!(result.is_ok());
4819 let uri = must(result);
4820 assert!(uri.starts_with("file://"));
4821 assert!(uri.contains("caf%C3%A9"));
4823 }
4824
4825 #[test]
4826 fn test_normalize_uri_file_schemes() {
4827 let uri = WorkspaceIndex::normalize_uri("file:///tmp/test.pl");
4829 assert_eq!(uri, "file:///tmp/test.pl");
4830
4831 let uri = WorkspaceIndex::normalize_uri("file:///tmp/path%20with%20spaces/test.pl");
4833 assert_eq!(uri, "file:///tmp/path%20with%20spaces/test.pl");
4834 }
4835
4836 #[test]
4837 fn test_normalize_uri_absolute_paths() {
4838 let uri = WorkspaceIndex::normalize_uri("/tmp/test.pl");
4840 assert!(uri.starts_with("file://"));
4841 assert!(uri.contains("/tmp/test.pl"));
4842 }
4843
4844 #[test]
4845 fn test_normalize_uri_special_schemes() {
4846 let uri = WorkspaceIndex::normalize_uri("untitled:Untitled-1");
4848 assert_eq!(uri, "untitled:Untitled-1");
4849 }
4850
4851 #[test]
4852 fn test_roundtrip_conversion() {
4853 let original_uri = "file:///tmp/path%20with%20spaces/caf%C3%A9.pl";
4855
4856 if let Some(path) = uri_to_fs_path(original_uri) {
4857 if let Ok(converted_uri) = fs_path_to_uri(&path) {
4858 assert!(converted_uri.starts_with("file://"));
4860
4861 if let Some(roundtrip_path) = uri_to_fs_path(&converted_uri) {
4863 #[cfg(windows)]
4864 if let Ok(rootless) = path.strip_prefix(std::path::Path::new(r"\")) {
4865 assert!(roundtrip_path.ends_with(rootless));
4866 } else {
4867 assert_eq!(path, roundtrip_path);
4868 }
4869
4870 #[cfg(not(windows))]
4871 assert_eq!(path, roundtrip_path);
4872 }
4873 }
4874 }
4875 }
4876
4877 #[cfg(target_os = "windows")]
4878 #[test]
4879 fn test_windows_paths() {
4880 let result = fs_path_to_uri(r"C:\Users\test\Documents\script.pl");
4882 assert!(result.is_ok());
4883 let uri = must(result);
4884 assert!(uri.starts_with("file://"));
4885
4886 let result = fs_path_to_uri(r"C:\Program Files\My App\script.pl");
4888 assert!(result.is_ok());
4889 let uri = must(result);
4890 assert!(uri.starts_with("file://"));
4891 assert!(uri.contains("Program%20Files"));
4892 }
4893
4894 #[test]
4899 fn test_coordinator_initial_state() {
4900 let coordinator = IndexCoordinator::new();
4901 assert!(matches!(
4902 coordinator.state(),
4903 IndexState::Building { phase: IndexPhase::Idle, .. }
4904 ));
4905 }
4906
4907 #[test]
4908 fn test_transition_to_scanning_phase() {
4909 let coordinator = IndexCoordinator::new();
4910 coordinator.transition_to_scanning();
4911
4912 let state = coordinator.state();
4913 assert!(
4914 matches!(state, IndexState::Building { phase: IndexPhase::Scanning, .. }),
4915 "Expected Building state after scanning, got: {:?}",
4916 state
4917 );
4918 }
4919
4920 #[test]
4921 fn test_transition_to_indexing_phase() {
4922 let coordinator = IndexCoordinator::new();
4923 coordinator.transition_to_scanning();
4924 coordinator.update_scan_progress(3);
4925 coordinator.transition_to_indexing(3);
4926
4927 let state = coordinator.state();
4928 assert!(
4929 matches!(
4930 state,
4931 IndexState::Building { phase: IndexPhase::Indexing, total_count: 3, .. }
4932 ),
4933 "Expected Building state after indexing with total_count 3, got: {:?}",
4934 state
4935 );
4936 }
4937
4938 #[test]
4939 fn test_transition_to_ready() {
4940 let coordinator = IndexCoordinator::new();
4941 coordinator.transition_to_ready(100, 5000);
4942
4943 let state = coordinator.state();
4944 if let IndexState::Ready { file_count, symbol_count, .. } = state {
4945 assert_eq!(file_count, 100);
4946 assert_eq!(symbol_count, 5000);
4947 } else {
4948 unreachable!("Expected Ready state, got: {:?}", state);
4949 }
4950 }
4951
4952 #[test]
4953 fn test_parse_storm_degradation() {
4954 let coordinator = IndexCoordinator::new();
4955 coordinator.transition_to_ready(100, 5000);
4956
4957 for _ in 0..15 {
4959 coordinator.notify_change("file.pm");
4960 }
4961
4962 let state = coordinator.state();
4963 assert!(
4964 matches!(state, IndexState::Degraded { .. }),
4965 "Expected Degraded state, got: {:?}",
4966 state
4967 );
4968 if let IndexState::Degraded { reason, .. } = state {
4969 assert!(matches!(reason, DegradationReason::ParseStorm { .. }));
4970 }
4971 }
4972
4973 #[test]
4974 fn test_recovery_from_parse_storm() {
4975 let coordinator = IndexCoordinator::new();
4976 coordinator.transition_to_ready(100, 5000);
4977
4978 for _ in 0..15 {
4980 coordinator.notify_change("file.pm");
4981 }
4982
4983 for _ in 0..15 {
4985 coordinator.notify_parse_complete("file.pm");
4986 }
4987
4988 assert!(matches!(coordinator.state(), IndexState::Building { .. }));
4990 }
4991
4992 #[test]
4993 fn test_query_dispatch_ready() {
4994 let coordinator = IndexCoordinator::new();
4995 coordinator.transition_to_ready(100, 5000);
4996
4997 let result = coordinator.query(|_index| "full_query", |_index| "partial_query");
4998
4999 assert_eq!(result, "full_query");
5000 }
5001
5002 #[test]
5003 fn test_query_dispatch_degraded() {
5004 let coordinator = IndexCoordinator::new();
5005 let result = coordinator.query(|_index| "full_query", |_index| "partial_query");
5008
5009 assert_eq!(result, "partial_query");
5010 }
5011
5012 #[test]
5013 fn test_metrics_pending_count() {
5014 let coordinator = IndexCoordinator::new();
5015
5016 coordinator.notify_change("file1.pm");
5017 coordinator.notify_change("file2.pm");
5018
5019 assert_eq!(coordinator.metrics.pending_count(), 2);
5020
5021 coordinator.notify_parse_complete("file1.pm");
5022 assert_eq!(coordinator.metrics.pending_count(), 1);
5023 }
5024
5025 #[test]
5026 fn test_instrumentation_records_transitions() {
5027 let coordinator = IndexCoordinator::new();
5028 coordinator.transition_to_ready(10, 100);
5029
5030 let snapshot = coordinator.instrumentation_snapshot();
5031 let transition =
5032 IndexStateTransition { from: IndexStateKind::Building, to: IndexStateKind::Ready };
5033 let count = snapshot.state_transition_counts.get(&transition).copied().unwrap_or(0);
5034 assert_eq!(count, 1);
5035 }
5036
5037 #[test]
5038 fn test_instrumentation_records_early_exit() {
5039 let coordinator = IndexCoordinator::new();
5040 coordinator.record_early_exit(EarlyExitReason::InitialTimeBudget, 25, 1, 10);
5041
5042 let snapshot = coordinator.instrumentation_snapshot();
5043 let count = snapshot
5044 .early_exit_counts
5045 .get(&EarlyExitReason::InitialTimeBudget)
5046 .copied()
5047 .unwrap_or(0);
5048 assert_eq!(count, 1);
5049 assert!(snapshot.last_early_exit.is_some());
5050 }
5051
5052 #[test]
5053 fn test_custom_limits() {
5054 let limits = IndexResourceLimits {
5055 max_files: 5000,
5056 max_symbols_per_file: 1000,
5057 max_total_symbols: 100_000,
5058 max_ast_cache_bytes: 128 * 1024 * 1024,
5059 max_ast_cache_items: 50,
5060 max_scan_duration_ms: 30_000,
5061 };
5062
5063 let coordinator = IndexCoordinator::with_limits(limits.clone());
5064 assert_eq!(coordinator.limits.max_files, 5000);
5065 assert_eq!(coordinator.limits.max_total_symbols, 100_000);
5066 }
5067
5068 #[test]
5069 fn test_degradation_preserves_symbol_count() {
5070 let coordinator = IndexCoordinator::new();
5071 coordinator.transition_to_ready(100, 5000);
5072
5073 coordinator.transition_to_degraded(DegradationReason::IoError {
5074 message: "Test error".to_string(),
5075 });
5076
5077 let state = coordinator.state();
5078 assert!(
5079 matches!(state, IndexState::Degraded { .. }),
5080 "Expected Degraded state, got: {:?}",
5081 state
5082 );
5083 if let IndexState::Degraded { available_symbols, .. } = state {
5084 assert_eq!(available_symbols, 5000);
5085 }
5086 }
5087
5088 #[test]
5089 fn test_index_access() {
5090 let coordinator = IndexCoordinator::new();
5091 let index = coordinator.index();
5092
5093 assert!(index.all_symbols().is_empty());
5095 }
5096
5097 #[test]
5098 fn test_resource_limit_enforcement_max_files() {
5099 let limits = IndexResourceLimits {
5100 max_files: 5,
5101 max_symbols_per_file: 1000,
5102 max_total_symbols: 50_000,
5103 max_ast_cache_bytes: 128 * 1024 * 1024,
5104 max_ast_cache_items: 50,
5105 max_scan_duration_ms: 30_000,
5106 };
5107
5108 let coordinator = IndexCoordinator::with_limits(limits);
5109 coordinator.transition_to_ready(10, 100);
5110
5111 for i in 0..10 {
5113 let uri_str = format!("file:///test{}.pl", i);
5114 let uri = must(url::Url::parse(&uri_str));
5115 let code = "sub test { }";
5116 must(coordinator.index().index_file(uri, code.to_string()));
5117 }
5118
5119 coordinator.enforce_limits();
5121
5122 let state = coordinator.state();
5123 assert!(
5124 matches!(
5125 state,
5126 IndexState::Degraded {
5127 reason: DegradationReason::ResourceLimit { kind: ResourceKind::MaxFiles },
5128 ..
5129 }
5130 ),
5131 "Expected Degraded state with ResourceLimit(MaxFiles), got: {:?}",
5132 state
5133 );
5134 }
5135
5136 #[test]
5137 fn test_resource_limit_enforcement_max_symbols() {
5138 let limits = IndexResourceLimits {
5139 max_files: 100,
5140 max_symbols_per_file: 10,
5141 max_total_symbols: 50, max_ast_cache_bytes: 128 * 1024 * 1024,
5143 max_ast_cache_items: 50,
5144 max_scan_duration_ms: 30_000,
5145 };
5146
5147 let coordinator = IndexCoordinator::with_limits(limits);
5148 coordinator.transition_to_ready(0, 0);
5149
5150 for i in 0..10 {
5152 let uri_str = format!("file:///test{}.pl", i);
5153 let uri = must(url::Url::parse(&uri_str));
5154 let code = r#"
5156package Test;
5157sub sub1 { }
5158sub sub2 { }
5159sub sub3 { }
5160sub sub4 { }
5161sub sub5 { }
5162sub sub6 { }
5163sub sub7 { }
5164sub sub8 { }
5165sub sub9 { }
5166sub sub10 { }
5167"#;
5168 must(coordinator.index().index_file(uri, code.to_string()));
5169 }
5170
5171 coordinator.enforce_limits();
5173
5174 let state = coordinator.state();
5175 assert!(
5176 matches!(
5177 state,
5178 IndexState::Degraded {
5179 reason: DegradationReason::ResourceLimit { kind: ResourceKind::MaxSymbols },
5180 ..
5181 }
5182 ),
5183 "Expected Degraded state with ResourceLimit(MaxSymbols), got: {:?}",
5184 state
5185 );
5186 }
5187
5188 #[test]
5189 fn test_check_limits_returns_none_within_bounds() {
5190 let coordinator = IndexCoordinator::new();
5191 coordinator.transition_to_ready(0, 0);
5192
5193 for i in 0..5 {
5195 let uri_str = format!("file:///test{}.pl", i);
5196 let uri = must(url::Url::parse(&uri_str));
5197 let code = "sub test { }";
5198 must(coordinator.index().index_file(uri, code.to_string()));
5199 }
5200
5201 let limit_check = coordinator.check_limits();
5203 assert!(limit_check.is_none(), "check_limits should return None when within bounds");
5204
5205 assert!(
5207 matches!(coordinator.state(), IndexState::Ready { .. }),
5208 "State should remain Ready when within limits"
5209 );
5210 }
5211
5212 #[test]
5213 fn test_enforce_limits_called_on_transition_to_ready() {
5214 let limits = IndexResourceLimits {
5215 max_files: 3,
5216 max_symbols_per_file: 1000,
5217 max_total_symbols: 50_000,
5218 max_ast_cache_bytes: 128 * 1024 * 1024,
5219 max_ast_cache_items: 50,
5220 max_scan_duration_ms: 30_000,
5221 };
5222
5223 let coordinator = IndexCoordinator::with_limits(limits);
5224
5225 for i in 0..5 {
5227 let uri_str = format!("file:///test{}.pl", i);
5228 let uri = must(url::Url::parse(&uri_str));
5229 let code = "sub test { }";
5230 must(coordinator.index().index_file(uri, code.to_string()));
5231 }
5232
5233 coordinator.transition_to_ready(5, 100);
5235
5236 let state = coordinator.state();
5237 assert!(
5238 matches!(
5239 state,
5240 IndexState::Degraded {
5241 reason: DegradationReason::ResourceLimit { kind: ResourceKind::MaxFiles },
5242 ..
5243 }
5244 ),
5245 "Expected Degraded state after transition_to_ready with exceeded limits, got: {:?}",
5246 state
5247 );
5248 }
5249
5250 #[test]
5251 fn test_state_transition_guard_ready_to_ready() {
5252 let coordinator = IndexCoordinator::new();
5254 coordinator.transition_to_ready(100, 5000);
5255
5256 coordinator.transition_to_ready(150, 7500);
5258
5259 let state = coordinator.state();
5260 assert!(
5261 matches!(state, IndexState::Ready { file_count: 150, symbol_count: 7500, .. }),
5262 "Expected Ready state with updated metrics, got: {:?}",
5263 state
5264 );
5265 }
5266
5267 #[test]
5268 fn test_state_transition_guard_building_to_building() {
5269 let coordinator = IndexCoordinator::new();
5271
5272 coordinator.transition_to_building(100);
5274
5275 let state = coordinator.state();
5276 assert!(
5277 matches!(state, IndexState::Building { indexed_count: 0, total_count: 100, .. }),
5278 "Expected Building state, got: {:?}",
5279 state
5280 );
5281
5282 coordinator.transition_to_building(200);
5284
5285 let state = coordinator.state();
5286 assert!(
5287 matches!(state, IndexState::Building { indexed_count: 0, total_count: 200, .. }),
5288 "Expected Building state, got: {:?}",
5289 state
5290 );
5291 }
5292
5293 #[test]
5294 fn test_state_transition_ready_to_building() {
5295 let coordinator = IndexCoordinator::new();
5297 coordinator.transition_to_ready(100, 5000);
5298
5299 coordinator.transition_to_building(150);
5301
5302 let state = coordinator.state();
5303 assert!(
5304 matches!(state, IndexState::Building { indexed_count: 0, total_count: 150, .. }),
5305 "Expected Building state after re-scan, got: {:?}",
5306 state
5307 );
5308 }
5309
5310 #[test]
5311 fn test_state_transition_degraded_to_building() {
5312 let coordinator = IndexCoordinator::new();
5314 coordinator.transition_to_degraded(DegradationReason::IoError {
5315 message: "Test error".to_string(),
5316 });
5317
5318 coordinator.transition_to_building(100);
5320
5321 let state = coordinator.state();
5322 assert!(
5323 matches!(state, IndexState::Building { indexed_count: 0, total_count: 100, .. }),
5324 "Expected Building state after recovery, got: {:?}",
5325 state
5326 );
5327 }
5328
5329 #[test]
5330 fn test_update_building_progress() {
5331 let coordinator = IndexCoordinator::new();
5332 coordinator.transition_to_building(100);
5333
5334 coordinator.update_building_progress(50);
5336
5337 let state = coordinator.state();
5338 assert!(
5339 matches!(state, IndexState::Building { indexed_count: 50, total_count: 100, .. }),
5340 "Expected Building state with updated progress, got: {:?}",
5341 state
5342 );
5343
5344 coordinator.update_building_progress(100);
5346
5347 let state = coordinator.state();
5348 assert!(
5349 matches!(state, IndexState::Building { indexed_count: 100, total_count: 100, .. }),
5350 "Expected Building state with completed progress, got: {:?}",
5351 state
5352 );
5353 }
5354
5355 #[test]
5356 fn test_scan_timeout_detection() {
5357 let limits = IndexResourceLimits {
5359 max_scan_duration_ms: 0, ..Default::default()
5361 };
5362
5363 let coordinator = IndexCoordinator::with_limits(limits);
5364 coordinator.transition_to_building(100);
5365
5366 std::thread::sleep(std::time::Duration::from_millis(1));
5368
5369 coordinator.update_building_progress(10);
5371
5372 let state = coordinator.state();
5373 assert!(
5374 matches!(
5375 state,
5376 IndexState::Degraded { reason: DegradationReason::ScanTimeout { .. }, .. }
5377 ),
5378 "Expected Degraded state with ScanTimeout, got: {:?}",
5379 state
5380 );
5381 }
5382
5383 #[test]
5384 fn test_scan_timeout_does_not_trigger_within_limit() {
5385 let limits = IndexResourceLimits {
5387 max_scan_duration_ms: 10_000, ..Default::default()
5389 };
5390
5391 let coordinator = IndexCoordinator::with_limits(limits);
5392 coordinator.transition_to_building(100);
5393
5394 coordinator.update_building_progress(50);
5396
5397 let state = coordinator.state();
5398 assert!(
5399 matches!(state, IndexState::Building { indexed_count: 50, .. }),
5400 "Expected Building state (no timeout), got: {:?}",
5401 state
5402 );
5403 }
5404
5405 #[test]
5406 fn test_early_exit_optimization_unchanged_content() {
5407 let index = WorkspaceIndex::new();
5408 let uri = must(url::Url::parse("file:///test.pl"));
5409 let code = r#"
5410package MyPackage;
5411
5412sub hello {
5413 print "Hello";
5414}
5415"#;
5416
5417 must(index.index_file(uri.clone(), code.to_string()));
5419 let symbols1 = index.file_symbols(uri.as_str());
5420 assert!(symbols1.iter().any(|s| s.name == "MyPackage" && s.kind == SymbolKind::Package));
5421 assert!(symbols1.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
5422
5423 must(index.index_file(uri.clone(), code.to_string()));
5426 let symbols2 = index.file_symbols(uri.as_str());
5427 assert_eq!(symbols1.len(), symbols2.len());
5428 assert!(symbols2.iter().any(|s| s.name == "MyPackage" && s.kind == SymbolKind::Package));
5429 assert!(symbols2.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
5430 }
5431
5432 #[test]
5433 fn test_early_exit_optimization_changed_content() {
5434 let index = WorkspaceIndex::new();
5435 let uri = must(url::Url::parse("file:///test.pl"));
5436 let code1 = r#"
5437package MyPackage;
5438
5439sub hello {
5440 print "Hello";
5441}
5442"#;
5443
5444 let code2 = r#"
5445package MyPackage;
5446
5447sub goodbye {
5448 print "Goodbye";
5449}
5450"#;
5451
5452 must(index.index_file(uri.clone(), code1.to_string()));
5454 let symbols1 = index.file_symbols(uri.as_str());
5455 assert!(symbols1.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
5456 assert!(!symbols1.iter().any(|s| s.name == "goodbye"));
5457
5458 must(index.index_file(uri.clone(), code2.to_string()));
5460 let symbols2 = index.file_symbols(uri.as_str());
5461 assert!(!symbols2.iter().any(|s| s.name == "hello"));
5462 assert!(symbols2.iter().any(|s| s.name == "goodbye" && s.kind == SymbolKind::Subroutine));
5463 }
5464
5465 #[test]
5466 fn test_early_exit_optimization_whitespace_only_change() {
5467 let index = WorkspaceIndex::new();
5468 let uri = must(url::Url::parse("file:///test.pl"));
5469 let code1 = r#"
5470package MyPackage;
5471
5472sub hello {
5473 print "Hello";
5474}
5475"#;
5476
5477 let code2 = r#"
5478package MyPackage;
5479
5480
5481sub hello {
5482 print "Hello";
5483}
5484"#;
5485
5486 must(index.index_file(uri.clone(), code1.to_string()));
5488 let symbols1 = index.file_symbols(uri.as_str());
5489 assert!(symbols1.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
5490
5491 must(index.index_file(uri.clone(), code2.to_string()));
5493 let symbols2 = index.file_symbols(uri.as_str());
5494 assert!(symbols2.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
5496 }
5497
5498 #[test]
5499 fn test_reindex_file_refreshes_symbol_cache_for_removed_names() {
5500 let index = WorkspaceIndex::new();
5501 let uri1 = must(url::Url::parse("file:///lib/A.pm"));
5502 let uri2 = must(url::Url::parse("file:///lib/B.pm"));
5503 let code1 = "package A;\nsub foo { return 1; }\n1;\n";
5504 let code2 = "package B;\nsub foo { return 2; }\n1;\n";
5505 let code2_reindexed = "package B;\nsub bar { return 3; }\n1;\n";
5506
5507 must(index.index_file(uri1.clone(), code1.to_string()));
5508 must(index.index_file(uri2.clone(), code2.to_string()));
5509 must(index.index_file(uri2.clone(), code2_reindexed.to_string()));
5510
5511 let foo_location = must_some(index.find_definition("foo"));
5512 assert_eq!(foo_location.uri, uri1.to_string());
5513
5514 let bar_location = must_some(index.find_definition("bar"));
5515 assert_eq!(bar_location.uri, uri2.to_string());
5516 }
5517
5518 #[test]
5519 fn test_remove_file_preserves_other_colliding_symbol_entries() {
5520 let index = WorkspaceIndex::new();
5521 let uri1 = must(url::Url::parse("file:///lib/A.pm"));
5522 let uri2 = must(url::Url::parse("file:///lib/B.pm"));
5523 let code1 = "package A;\nsub foo { return 1; }\n1;\n";
5524 let code2 = "package B;\nsub foo { return 2; }\n1;\n";
5525
5526 must(index.index_file(uri1.clone(), code1.to_string()));
5527 must(index.index_file(uri2.clone(), code2.to_string()));
5528
5529 index.remove_file(uri2.as_str());
5530
5531 let foo_location = must_some(index.find_definition("foo"));
5532 assert_eq!(foo_location.uri, uri1.to_string());
5533 }
5534
5535 #[test]
5536 fn test_count_usages_no_double_counting_for_qualified_calls() {
5537 let index = WorkspaceIndex::new();
5538
5539 let uri1 = "file:///lib/Utils.pm";
5541 let code1 = r#"
5542package Utils;
5543
5544sub process_data {
5545 return 1;
5546}
5547"#;
5548 must(index.index_file(must(url::Url::parse(uri1)), code1.to_string()));
5549
5550 let uri2 = "file:///app.pl";
5552 let code2 = r#"
5553use Utils;
5554Utils::process_data();
5555Utils::process_data();
5556"#;
5557 must(index.index_file(must(url::Url::parse(uri2)), code2.to_string()));
5558
5559 let count = index.count_usages("Utils::process_data");
5563
5564 assert_eq!(
5567 count, 2,
5568 "count_usages should not double-count qualified calls, got {} (expected 2)",
5569 count
5570 );
5571
5572 let refs = index.find_references("Utils::process_data");
5574 let non_def_refs: Vec<_> =
5575 refs.iter().filter(|loc| loc.uri != "file:///lib/Utils.pm").collect();
5576 assert_eq!(
5577 non_def_refs.len(),
5578 2,
5579 "find_references should not return duplicates for qualified calls, got {} non-def refs",
5580 non_def_refs.len()
5581 );
5582 }
5583
5584 #[test]
5585 fn test_batch_indexing() {
5586 let index = WorkspaceIndex::new();
5587 let files: Vec<(Url, String)> = (0..5)
5588 .map(|i| {
5589 let uri = must(Url::parse(&format!("file:///batch/module{}.pm", i)));
5590 let code =
5591 format!("package Batch::Mod{};\nsub func_{} {{ return {}; }}\n1;", i, i, i);
5592 (uri, code)
5593 })
5594 .collect();
5595
5596 let errors = index.index_files_batch(files);
5597 assert!(errors.is_empty(), "batch indexing errors: {:?}", errors);
5598 assert_eq!(index.file_count(), 5);
5599 assert!(index.find_definition("Batch::Mod0::func_0").is_some());
5600 assert!(index.find_definition("Batch::Mod4::func_4").is_some());
5601 }
5602
5603 #[test]
5604 fn test_batch_indexing_skips_unchanged() {
5605 let index = WorkspaceIndex::new();
5606 let uri = must(Url::parse("file:///batch/skip.pm"));
5607 let code = "package Skip;\nsub skip_fn { 1 }\n1;".to_string();
5608
5609 index.index_file(uri.clone(), code.clone()).ok();
5610 assert_eq!(index.file_count(), 1);
5611
5612 let errors = index.index_files_batch(vec![(uri, code)]);
5613 assert!(errors.is_empty());
5614 assert_eq!(index.file_count(), 1);
5615 }
5616
5617 #[test]
5618 fn test_incremental_update_preserves_other_symbols() {
5619 let index = WorkspaceIndex::new();
5620
5621 let uri_a = must(Url::parse("file:///incr/a.pm"));
5622 let uri_b = must(Url::parse("file:///incr/b.pm"));
5623 index.index_file(uri_a.clone(), "package A;\nsub a_func { 1 }\n1;".into()).ok();
5624 index.index_file(uri_b.clone(), "package B;\nsub b_func { 2 }\n1;".into()).ok();
5625
5626 assert!(index.find_definition("A::a_func").is_some());
5627 assert!(index.find_definition("B::b_func").is_some());
5628
5629 index.index_file(uri_a, "package A;\nsub a_func_v2 { 11 }\n1;".into()).ok();
5630
5631 assert!(index.find_definition("A::a_func_v2").is_some());
5632 assert!(index.find_definition("B::b_func").is_some());
5633 }
5634
5635 #[test]
5636 fn test_remove_file_preserves_shadowed_symbols() {
5637 let index = WorkspaceIndex::new();
5638
5639 let uri_a = must(Url::parse("file:///shadow/a.pm"));
5640 let uri_b = must(Url::parse("file:///shadow/b.pm"));
5641 index.index_file(uri_a.clone(), "package ShadowA;\nsub helper { 1 }\n1;".into()).ok();
5642 index.index_file(uri_b.clone(), "package ShadowB;\nsub helper { 2 }\n1;".into()).ok();
5643
5644 assert!(index.find_definition("helper").is_some());
5645
5646 index.remove_file_url(&uri_a);
5647 assert!(index.find_definition("helper").is_some());
5648 assert!(index.find_definition("ShadowB::helper").is_some());
5649 }
5650
5651 #[test]
5656 fn test_index_dependency_via_use_parent_end_to_end() {
5657 let index = WorkspaceIndex::new();
5663
5664 let base_url = must(url::Url::parse("file:///test/workspace/lib/MyBase.pm"));
5665 must(index.index_file(
5666 base_url,
5667 "package MyBase;\nsub new { bless {}, shift }\n1;\n".to_string(),
5668 ));
5669
5670 let child_url = must(url::Url::parse("file:///test/workspace/child.pl"));
5671 must(index.index_file(child_url, "package Child;\nuse parent 'MyBase';\n1;\n".to_string()));
5672
5673 let dependents = index.find_dependents("MyBase");
5674 assert!(
5675 !dependents.is_empty(),
5676 "find_dependents('MyBase') returned empty — \
5677 use parent 'MyBase' should register MyBase as a dependency. \
5678 Dependencies in index: {:?}",
5679 {
5680 let files = index.files.read();
5681 files
5682 .iter()
5683 .map(|(k, v)| (k.clone(), v.dependencies.iter().cloned().collect::<Vec<_>>()))
5684 .collect::<Vec<_>>()
5685 }
5686 );
5687 assert!(
5688 dependents.contains(&"file:///test/workspace/child.pl".to_string()),
5689 "child.pl should be in dependents, got: {:?}",
5690 dependents
5691 );
5692 }
5693
5694 #[test]
5695 fn test_find_dependents_normalizes_legacy_separator_in_query() {
5696 let index = WorkspaceIndex::new();
5697 let uri = must(url::Url::parse("file:///test/workspace/legacy-query.pl"));
5698 let src = "package Child;\nuse parent 'My::Base';\n1;\n";
5699 must(index.index_file(uri, src.to_string()));
5700
5701 let dependents = index.find_dependents("My'Base");
5702 assert_eq!(dependents, vec!["file:///test/workspace/legacy-query.pl".to_string()]);
5703 }
5704
5705 #[test]
5706 fn test_file_dependencies_normalize_legacy_separator_in_source() {
5707 let index = WorkspaceIndex::new();
5708 let uri = must(url::Url::parse("file:///test/workspace/legacy-source.pl"));
5709 let src = "package Child;\nuse parent \"My'Base\";\n1;\n";
5710 must(index.index_file(uri.clone(), src.to_string()));
5711
5712 let deps = index.file_dependencies(uri.as_str());
5713 assert!(deps.contains("My::Base"));
5714 assert!(!deps.contains("My'Base"));
5715 }
5716
5717 #[test]
5718 fn test_index_dependency_via_moose_extends_end_to_end() -> Result<(), Box<dyn std::error::Error>>
5719 {
5720 let index = WorkspaceIndex::new();
5721
5722 let parent_url = must(url::Url::parse("file:///test/workspace/lib/My/App/Parent.pm"));
5723 must(index.index_file(parent_url, "package My::App::Parent;\n1;\n".to_string()));
5724
5725 let child_url = must(url::Url::parse("file:///test/workspace/child-moose.pl"));
5726 let child_src = "package Child;\nuse Moose;\nextends 'My::App::Parent';\n1;\n";
5727 must(index.index_file(child_url, child_src.to_string()));
5728
5729 let dependents = index.find_dependents("My::App::Parent");
5730 assert!(
5731 dependents.contains(&"file:///test/workspace/child-moose.pl".to_string()),
5732 "expected child-moose.pl in dependents, got: {dependents:?}"
5733 );
5734 Ok(())
5735 }
5736
5737 #[test]
5738 fn test_index_dependency_via_moo_with_role_end_to_end() -> Result<(), Box<dyn std::error::Error>>
5739 {
5740 let index = WorkspaceIndex::new();
5741
5742 let role_url = must(url::Url::parse("file:///test/workspace/lib/My/App/Role.pm"));
5743 must(index.index_file(role_url, "package My::App::Role;\n1;\n".to_string()));
5744
5745 let consumer_url = must(url::Url::parse("file:///test/workspace/consumer-moo.pl"));
5746 let consumer_src = "package Consumer;\nuse Moo;\nwith 'My::App::Role';\n1;\n";
5747 must(index.index_file(consumer_url.clone(), consumer_src.to_string()));
5748
5749 let dependents = index.find_dependents("My::App::Role");
5750 assert!(
5751 dependents.contains(&"file:///test/workspace/consumer-moo.pl".to_string()),
5752 "expected consumer-moo.pl in dependents, got: {dependents:?}"
5753 );
5754
5755 let deps = index.file_dependencies(consumer_url.as_str());
5756 assert!(deps.contains("My::App::Role"));
5757 Ok(())
5758 }
5759
5760 #[test]
5761 fn test_index_dependency_via_literal_require_end_to_end()
5762 -> Result<(), Box<dyn std::error::Error>> {
5763 let index = WorkspaceIndex::new();
5764 let uri = must(url::Url::parse("file:///test/workspace/require-consumer.pl"));
5765 let src = "package Consumer;\nrequire My::Loader;\n1;\n";
5766 must(index.index_file(uri.clone(), src.to_string()));
5767
5768 let deps = index.file_dependencies(uri.as_str());
5769 assert!(
5770 deps.contains("My::Loader"),
5771 "literal require should register module dependency, got: {deps:?}"
5772 );
5773 Ok(())
5774 }
5775
5776 #[test]
5777 fn test_manual_import_symbols_are_indexed_as_import_references()
5778 -> Result<(), Box<dyn std::error::Error>> {
5779 let index = WorkspaceIndex::new();
5780 let uri = must(url::Url::parse("file:///test/workspace/manual-import.pl"));
5781 let src = r#"package Consumer;
5782require My::Tools;
5783My::Tools->import(qw(helper_one helper_two));
5784helper_one();
57851;
5786"#;
5787 must(index.index_file(uri.clone(), src.to_string()));
5788
5789 let deps = index.file_dependencies(uri.as_str());
5790 assert!(
5791 deps.contains("My::Tools"),
5792 "manual import target should be tracked as dependency, got: {deps:?}"
5793 );
5794
5795 for symbol in ["helper_one", "helper_two"] {
5796 let refs = index.find_references(symbol);
5797 assert!(
5798 !refs.is_empty(),
5799 "expected at least one indexed reference for imported symbol `{symbol}`"
5800 );
5801 }
5802 Ok(())
5803 }
5804
5805 #[test]
5806 fn test_parser_produces_correct_args_for_use_parent() {
5807 use crate::Parser;
5811 let mut p = Parser::new("package Child;\nuse parent 'MyBase';\n1;\n");
5812 let ast = must(p.parse());
5813 assert!(
5814 matches!(ast.kind, NodeKind::Program { .. }),
5815 "Expected Program root, got {:?}",
5816 ast.kind
5817 );
5818 let NodeKind::Program { statements } = &ast.kind else {
5819 return;
5820 };
5821 let mut found_parent_use = false;
5822 for stmt in statements {
5823 if let NodeKind::Use { module, args, .. } = &stmt.kind {
5824 if module == "parent" {
5825 found_parent_use = true;
5826 assert_eq!(
5827 args,
5828 &["'MyBase'".to_string()],
5829 "Expected args=[\"'MyBase'\"] for `use parent 'MyBase'`, got: {:?}",
5830 args
5831 );
5832 let extracted = extract_module_names_from_use_args(args);
5833 assert_eq!(
5834 extracted,
5835 vec!["MyBase".to_string()],
5836 "extract_module_names_from_use_args should return [\"MyBase\"], got {:?}",
5837 extracted
5838 );
5839 }
5840 }
5841 }
5842 assert!(found_parent_use, "No Use node with module='parent' found in AST");
5843 }
5844
5845 #[test]
5850 fn test_extract_module_names_single_quoted() {
5851 let names = extract_module_names_from_use_args(&["'Foo::Bar'".to_string()]);
5852 assert_eq!(names, vec!["Foo::Bar"]);
5853 }
5854
5855 #[test]
5856 fn test_extract_module_names_double_quoted() {
5857 let names = extract_module_names_from_use_args(&["\"Foo::Bar\"".to_string()]);
5858 assert_eq!(names, vec!["Foo::Bar"]);
5859 }
5860
5861 #[test]
5862 fn test_extract_module_names_qw_list() {
5863 let names = extract_module_names_from_use_args(&["qw(Foo::Bar Other::Base)".to_string()]);
5864 assert_eq!(names, vec!["Foo::Bar", "Other::Base"]);
5865 }
5866
5867 #[test]
5868 fn test_extract_module_names_qw_slash_delimiter() {
5869 let names = extract_module_names_from_use_args(&["qw/Foo::Bar Other::Base/".to_string()]);
5870 assert_eq!(names, vec!["Foo::Bar", "Other::Base"]);
5871 }
5872
5873 #[test]
5874 fn test_extract_module_names_qw_with_space_before_delimiter() {
5875 let names = extract_module_names_from_use_args(&["qw [Foo::Bar Other::Base]".to_string()]);
5876 assert_eq!(names, vec!["Foo::Bar", "Other::Base"]);
5877 }
5878
5879 #[test]
5880 fn test_extract_module_names_qw_list_trims_wrapped_punctuation() {
5881 let names =
5882 extract_module_names_from_use_args(&["qw((Foo::Bar) [Other::Base],)".to_string()]);
5883 assert_eq!(names, vec!["Foo::Bar", "Other::Base"]);
5884 }
5885
5886 #[test]
5887 fn test_extract_module_names_norequire_flag() {
5888 let names = extract_module_names_from_use_args(&[
5889 "-norequire".to_string(),
5890 "'Foo::Bar'".to_string(),
5891 ]);
5892 assert_eq!(names, vec!["Foo::Bar"]);
5893 }
5894
5895 #[test]
5896 fn test_extract_module_names_empty_args() {
5897 let names = extract_module_names_from_use_args(&[]);
5898 assert!(names.is_empty());
5899 }
5900
5901 #[test]
5902 fn test_extract_module_names_legacy_separator() {
5903 let names = extract_module_names_from_use_args(&["'Foo'Bar'".to_string()]);
5905 assert_eq!(names, vec!["Foo::Bar"]);
5907 }
5908
5909 #[test]
5910 fn test_find_dependents_matches_legacy_separator_queries() {
5911 let index = WorkspaceIndex::new();
5912 let base_uri = must(url::Url::parse("file:///test/workspace/lib/Foo/Bar.pm"));
5913 let child_uri = must(url::Url::parse("file:///test/workspace/child.pl"));
5914
5915 must(index.index_file(base_uri, "package Foo::Bar;\n1;\n".to_string()));
5916 must(index.index_file(
5917 child_uri.clone(),
5918 "package Child;\nuse parent qw(Foo'Bar);\n1;\n".to_string(),
5919 ));
5920
5921 let dependents_modern = index.find_dependents("Foo::Bar");
5922 assert!(
5923 dependents_modern.contains(&child_uri.to_string()),
5924 "Expected dependency match when queried with modern separator"
5925 );
5926
5927 let dependents_legacy = index.find_dependents("Foo'Bar");
5928 assert!(
5929 dependents_legacy.contains(&child_uri.to_string()),
5930 "Expected dependency match when queried with legacy separator"
5931 );
5932 }
5933
5934 #[test]
5935 fn test_extract_module_names_comma_adjacent_tokens() {
5936 let names = extract_module_names_from_use_args(&[
5937 "'Foo::Bar',".to_string(),
5938 "\"Other::Base\",".to_string(),
5939 "'Last::One'".to_string(),
5940 ]);
5941 assert_eq!(names, vec!["Foo::Bar", "Other::Base", "Last::One"]);
5942 }
5943
5944 #[test]
5945 fn test_extract_module_names_parenthesized_without_spaces() {
5946 let names = extract_module_names_from_use_args(&["('Foo::Bar','Other::Base')".to_string()]);
5947 assert_eq!(names, vec!["Foo::Bar", "Other::Base"]);
5948 }
5949
5950 #[test]
5951 fn test_extract_module_names_deduplicates_identical_entries() {
5952 let names = extract_module_names_from_use_args(&[
5953 "qw(Foo::Bar Foo::Bar)".to_string(),
5954 "'Foo::Bar'".to_string(),
5955 ]);
5956 assert_eq!(names, vec!["Foo::Bar"]);
5957 }
5958
5959 #[test]
5960 fn test_extract_module_names_trims_semicolon_suffix() {
5961 let names = extract_module_names_from_use_args(&[
5962 "'Foo::Bar',".to_string(),
5963 "'Other::Base',".to_string(),
5964 "'Third::Leaf';".to_string(),
5965 ]);
5966 assert_eq!(names, vec!["Foo::Bar", "Other::Base", "Third::Leaf"]);
5967 }
5968
5969 #[test]
5970 fn test_extract_module_names_trims_wrapped_punctuation() {
5971 let names = extract_module_names_from_use_args(&[
5972 "('Foo::Bar',".to_string(),
5973 "'Other::Base')".to_string(),
5974 ]);
5975 assert_eq!(names, vec!["Foo::Bar", "Other::Base"]);
5976 }
5977
5978 #[test]
5979 fn test_extract_constant_names_qw_with_space_before_delimiter() {
5980 let names = extract_constant_names_from_use_args(&["qw [FOO BAR]".to_string()]);
5981 assert_eq!(names, vec!["FOO", "BAR"]);
5982 }
5983
5984 #[test]
5985 #[ignore = "qw delimiter with leading space not yet parsed; tracked in debt-ledger.yaml"]
5986 fn test_index_use_constant_qw_with_space_before_delimiter() {
5987 let index = WorkspaceIndex::new();
5988 let uri = must(url::Url::parse("file:///workspace/lib/My/Config.pm"));
5989 let source = "package My::Config;\nuse constant qw [FOO BAR];\n1;\n";
5990
5991 must(index.index_file(uri, source.to_string()));
5992
5993 let foo = index.find_definition("My::Config::FOO");
5994 let bar = index.find_definition("My::Config::BAR");
5995 assert!(foo.is_some(), "Expected My::Config::FOO to be indexed");
5996 assert!(bar.is_some(), "Expected My::Config::BAR to be indexed");
5997 }
5998
5999 #[test]
6000 fn test_with_capacity_accepts_large_batch_without_panic() {
6001 let index = WorkspaceIndex::with_capacity(100, 20);
6002 for i in 0..100 {
6003 let uri = must(url::Url::parse(&format!("file:///lib/Mod{}.pm", i)));
6004 let src = format!("package Mod{};\nsub foo_{} {{ 1 }}\n1;\n", i, i);
6005 index.index_file(uri, src).ok();
6006 }
6007 assert!(index.has_symbols());
6008 }
6009
6010 #[test]
6011 fn test_with_capacity_zero_does_not_panic() {
6012 let index = WorkspaceIndex::with_capacity(0, 0);
6013 assert!(!index.has_symbols());
6014 }
6015
6016 #[test]
6024 fn test_remove_file_clears_symbol_cache_qualified_and_bare() {
6025 let index = WorkspaceIndex::new();
6026 let uri_a = must(url::Url::parse("file:///lib/A.pm"));
6027 let code_a = "package A;\nsub foo { return 1; }\n1;\n";
6028
6029 must(index.index_file(uri_a.clone(), code_a.to_string()));
6030
6031 let before_qual = must_some(index.find_definition("A::foo"));
6033 assert_eq!(
6034 before_qual.uri,
6035 uri_a.to_string(),
6036 "qualified lookup should point to A.pm before removal"
6037 );
6038 let before_bare = must_some(index.find_definition("foo"));
6039 assert_eq!(
6040 before_bare.uri,
6041 uri_a.to_string(),
6042 "bare-name lookup should point to A.pm before removal"
6043 );
6044
6045 index.remove_file(uri_a.as_str());
6047
6048 assert!(
6050 index.find_definition("A::foo").is_none(),
6051 "qualified lookup 'A::foo' should return None after file deletion"
6052 );
6053 assert!(
6054 index.find_definition("foo").is_none(),
6055 "bare-name lookup 'foo' should return None after file deletion"
6056 );
6057
6058 assert_eq!(
6060 index.symbol_count(),
6061 0,
6062 "symbol_count should be 0 after removing the only file"
6063 );
6064 assert!(!index.has_symbols(), "has_symbols should be false after removing the only file");
6065 }
6066
6067 #[test]
6070 fn test_remove_file_bare_name_falls_back_to_surviving_file() {
6071 let index = WorkspaceIndex::new();
6072 let uri_a = must(url::Url::parse("file:///lib/A.pm"));
6073 let uri_b = must(url::Url::parse("file:///lib/B.pm"));
6074 let code_a = "package A;\nsub shared_fn { return 1; }\n1;\n";
6075 let code_b = "package B;\nsub shared_fn { return 2; }\n1;\n";
6076
6077 must(index.index_file(uri_a.clone(), code_a.to_string()));
6078 must(index.index_file(uri_b.clone(), code_b.to_string()));
6079
6080 index.remove_file(uri_a.as_str());
6082
6083 let loc = must_some(index.find_definition("shared_fn"));
6084 assert_eq!(
6085 loc.uri,
6086 uri_b.to_string(),
6087 "bare-name 'shared_fn' should resolve to B.pm after A.pm is deleted"
6088 );
6089
6090 assert!(
6091 index.find_definition("A::shared_fn").is_none(),
6092 "qualified 'A::shared_fn' must be gone after A.pm deletion"
6093 );
6094 assert!(
6095 index.find_definition("B::shared_fn").is_some(),
6096 "qualified 'B::shared_fn' must remain after A.pm deletion"
6097 );
6098 }
6099
6100 #[test]
6101 fn test_definition_candidates_include_ambiguous_bare_symbols_in_stable_order() {
6102 let index = WorkspaceIndex::new();
6103 let uri_b = must(url::Url::parse("file:///lib/B.pm"));
6104 let uri_a = must(url::Url::parse("file:///lib/A.pm"));
6105 must(index.index_file(uri_b, "package B;\nsub shared { 1 }\n1;\n".to_string()));
6106 must(index.index_file(uri_a, "package A;\nsub shared { 1 }\n1;\n".to_string()));
6107
6108 let candidates = index.definition_candidates("shared");
6109 assert_eq!(candidates.len(), 2);
6110 assert_eq!(candidates[0].uri, "file:///lib/A.pm");
6111 assert_eq!(candidates[1].uri, "file:///lib/B.pm");
6112 assert_eq!(must_some(index.find_definition("shared")).uri, "file:///lib/A.pm");
6113 }
6114
6115 #[test]
6116 fn test_definition_candidates_include_duplicate_qualified_name_across_files() {
6117 let index = WorkspaceIndex::new();
6118 let uri_v2 = must(url::Url::parse("file:///lib/A-v2.pm"));
6119 let uri_v1 = must(url::Url::parse("file:///lib/A-v1.pm"));
6120 let source = "package A;\nsub foo { 1 }\n1;\n".to_string();
6121 must(index.index_file(uri_v2, source.clone()));
6122 must(index.index_file(uri_v1, source));
6123
6124 let candidates = index.definition_candidates("A::foo");
6125 assert_eq!(candidates.len(), 2);
6126 assert_eq!(candidates[0].uri, "file:///lib/A-v1.pm");
6127 assert_eq!(candidates[1].uri, "file:///lib/A-v2.pm");
6128 }
6129
6130 #[test]
6131 fn test_definition_candidates_are_cleaned_on_remove_and_reindex() {
6132 let index = WorkspaceIndex::new();
6133 let uri = must(url::Url::parse("file:///lib/A.pm"));
6134 must(index.index_file(uri.clone(), "package A;\nsub foo { 1 }\n1;\n".to_string()));
6135 assert_eq!(index.definition_candidates("A::foo").len(), 1);
6136
6137 index.remove_file(uri.as_str());
6138 assert!(index.definition_candidates("A::foo").is_empty());
6139
6140 must(index.index_file(uri, "package A;\nsub foo { 2 }\n1;\n".to_string()));
6141 assert_eq!(index.definition_candidates("A::foo").len(), 1);
6142 }
6143
6144 #[test]
6150 fn test_definition_candidates_shared_symbol_survives_removal_of_sole_owner_of_other_symbol() {
6151 let index = WorkspaceIndex::new();
6152 let uri_a = must(url::Url::parse("file:///lib/A.pm"));
6153 let uri_b = must(url::Url::parse("file:///lib/B.pm"));
6154
6155 must(index.index_file(
6157 uri_a.clone(),
6158 "package A;\nsub unique_to_a { 1 }\nsub shared { 1 }\n1;\n".to_string(),
6159 ));
6160 must(index.index_file(uri_b.clone(), "package B;\nsub shared { 1 }\n1;\n".to_string()));
6161
6162 assert_eq!(index.definition_candidates("shared").len(), 2);
6164 assert_eq!(index.definition_candidates("unique_to_a").len(), 1);
6165
6166 index.remove_file(uri_a.as_str());
6169
6170 assert!(
6171 index.definition_candidates("unique_to_a").is_empty(),
6172 "unique_to_a should be gone after removing A"
6173 );
6174 assert_eq!(
6175 index.definition_candidates("shared").len(),
6176 1,
6177 "shared should still have B's candidate after removing A"
6178 );
6179 assert_eq!(
6180 index.definition_candidates("shared")[0].uri,
6181 "file:///lib/B.pm",
6182 "remaining shared candidate must be from B"
6183 );
6184 }
6185
6186 #[test]
6187 fn test_folder_context_in_file_index() {
6188 let index = WorkspaceIndex::new();
6189
6190 index.set_workspace_folders(vec![
6192 "file:///project1".to_string(),
6193 "file:///project2".to_string(),
6194 ]);
6195
6196 let uri1 = "file:///project1/lib/Module.pm";
6197 let code1 = r#"
6198package Module;
6199
6200sub test_sub {
6201 return 1;
6202}
6203"#;
6204 must(index.index_file(must(url::Url::parse(uri1)), code1.to_string()));
6205
6206 let uri2 = "file:///project2/lib/Other.pm";
6207 let code2 = r#"
6208package Other;
6209
6210sub other_sub {
6211 return 2;
6212}
6213"#;
6214 must(index.index_file(must(url::Url::parse(uri2)), code2.to_string()));
6215
6216 let symbols1 = index.file_symbols(uri1);
6218 assert_eq!(symbols1.len(), 2, "Should have 2 symbols in Module.pm");
6219 for symbol in &symbols1 {
6220 assert_eq!(symbol.uri, uri1, "Symbol URI should match file URI");
6221 }
6222
6223 let symbols2 = index.file_symbols(uri2);
6224 assert_eq!(symbols2.len(), 2, "Should have 2 symbols in Other.pm");
6225 for symbol in &symbols2 {
6226 assert_eq!(symbol.uri, uri2, "Symbol URI should match file URI");
6227 }
6228
6229 let files = index.files.read();
6231 let file_index1 = must_some(files.get(&DocumentStore::uri_key(uri1)));
6232 assert_eq!(
6233 file_index1.folder_uri,
6234 Some("file:///project1".to_string()),
6235 "File should be attributed to correct workspace folder"
6236 );
6237
6238 let file_index2 = must_some(files.get(&DocumentStore::uri_key(uri2)));
6239 assert_eq!(
6240 file_index2.folder_uri,
6241 Some("file:///project2".to_string()),
6242 "File should be attributed to correct workspace folder"
6243 );
6244 }
6245
6246 #[test]
6247 fn test_determine_folder_uri() {
6248 let index = WorkspaceIndex::new();
6249
6250 index.set_workspace_folders(vec![
6252 "file:///project1".to_string(),
6253 "file:///project2".to_string(),
6254 ]);
6255
6256 let folder1 = index.determine_folder_uri("file:///project1/lib/Module.pm");
6258 assert_eq!(
6259 folder1,
6260 Some("file:///project1".to_string()),
6261 "Should determine folder for file in project1"
6262 );
6263
6264 let folder2 = index.determine_folder_uri("file:///project2/lib/Other.pm");
6266 assert_eq!(
6267 folder2,
6268 Some("file:///project2".to_string()),
6269 "Should determine folder for file in project2"
6270 );
6271
6272 let folder_none = index.determine_folder_uri("file:///other/project/Module.pm");
6274 assert_eq!(folder_none, None, "Should return None for file outside workspace folders");
6275 }
6276
6277 #[test]
6278 fn test_determine_folder_uri_prefers_most_specific_match() {
6279 let index = WorkspaceIndex::new();
6280
6281 index.set_workspace_folders(vec![
6283 "file:///project".to_string(),
6284 "file:///project/lib".to_string(),
6285 ]);
6286
6287 let folder = index.determine_folder_uri("file:///project/lib/My/Module.pm");
6288 assert_eq!(
6289 folder,
6290 Some("file:///project/lib".to_string()),
6291 "Nested workspace folders should attribute files to the most specific folder"
6292 );
6293 }
6294
6295 #[test]
6296 fn test_remove_folder() {
6297 let index = WorkspaceIndex::new();
6298
6299 index.set_workspace_folders(vec![
6301 "file:///project1".to_string(),
6302 "file:///project2".to_string(),
6303 ]);
6304
6305 let uri1 = "file:///project1/lib/Module.pm";
6307 let code1 = r#"
6308package Module;
6309
6310sub test_sub {
6311 return 1;
6312}
6313"#;
6314 must(index.index_file(must(url::Url::parse(uri1)), code1.to_string()));
6315
6316 let uri2 = "file:///project2/lib/Other.pm";
6317 let code2 = r#"
6318package Other;
6319
6320sub other_sub {
6321 return 2;
6322}
6323"#;
6324 must(index.index_file(must(url::Url::parse(uri2)), code2.to_string()));
6325
6326 assert_eq!(index.file_count(), 2, "Should have 2 files indexed");
6328 assert_eq!(index.document_store().count(), 2, "Document store should track both files");
6329
6330 index.remove_folder("file:///project1");
6332
6333 assert_eq!(index.file_count(), 1, "Should have 1 file after removing folder");
6335 assert_eq!(
6336 index.document_store().count(),
6337 1,
6338 "Document store should drop files removed via folder deletion"
6339 );
6340 assert!(index.file_symbols(uri1).is_empty(), "File from removed folder should be gone");
6341 assert_eq!(
6342 index.file_symbols(uri2).len(),
6343 2,
6344 "File from remaining folder should still be present"
6345 );
6346 }
6347
6348 #[test]
6349 fn test_remove_folder_removes_symbol_free_files() {
6350 let index = WorkspaceIndex::new();
6351 index.set_workspace_folders(vec!["file:///project1".to_string()]);
6352
6353 let uri = "file:///project1/empty.pl";
6354 must(index.index_file(must(url::Url::parse(uri)), "# comments only".to_string()));
6355 assert_eq!(index.file_count(), 1, "Expected file to be indexed");
6356
6357 index.remove_folder("file:///project1");
6358
6359 assert_eq!(index.file_count(), 0, "Folder removal should delete symbol-free files");
6360 assert_eq!(
6361 index.document_store().count(),
6362 0,
6363 "Document store should stay in sync for symbol-free files"
6364 );
6365 }
6366
6367 #[test]
6372 fn test_require_with_variable_target_is_not_indexed() -> Result<(), Box<dyn std::error::Error>>
6373 {
6374 let index = WorkspaceIndex::new();
6375 let uri = must(url::Url::parse("file:///test/require-var.pl"));
6376 let src = r#"package Test;
6377my $loader = 'MyModule';
6378require $loader;
63791;
6380"#;
6381 must(index.index_file(uri.clone(), src.to_string()));
6382 let deps = index.file_dependencies(uri.as_str());
6383 assert!(
6384 !deps.contains("MyModule"),
6385 "require with variable target should not register static dependency"
6386 );
6387 Ok(())
6388 }
6389
6390 #[test]
6391 fn test_multiple_import_calls_on_same_module() -> Result<(), Box<dyn std::error::Error>> {
6392 let index = WorkspaceIndex::new();
6393 let uri = must(url::Url::parse("file:///test/multi-import.pl"));
6394 let src = r#"package Test;
6395require Toolkit;
6396Toolkit->import('func_a');
6397Toolkit->import(qw(func_b func_c));
63981;
6399"#;
6400 must(index.index_file(uri.clone(), src.to_string()));
6401 let deps = index.file_dependencies(uri.as_str());
6402 assert!(deps.contains("Toolkit"), "module should be tracked as dependency");
6403 for symbol in &["func_a", "func_b", "func_c"] {
6404 let refs = index.find_references(symbol);
6405 assert!(!refs.is_empty(), "all imported symbols should be indexed: {}", symbol);
6406 }
6407 Ok(())
6408 }
6409
6410 #[test]
6411 fn test_require_string_vs_bareword_normalization() -> Result<(), Box<dyn std::error::Error>> {
6412 let index = WorkspaceIndex::new();
6413 let uri = must(url::Url::parse("file:///test/require-string.pl"));
6414 let src = r#"package Consumer;
6415require "String/Based/Module.pm";
6416String::Based::Module->import('exported');
64171;
6418"#;
6419 must(index.index_file(uri.clone(), src.to_string()));
6420 let deps = index.file_dependencies(uri.as_str());
6421 assert!(
6422 deps.contains("String::Based::Module"),
6423 "require string form should normalize path separators to ::"
6424 );
6425 let refs = index.find_references("exported");
6426 assert!(!refs.is_empty(), "import should be indexed even with string-form require");
6427 Ok(())
6428 }
6429
6430 #[test]
6431 fn test_import_without_require_registers_as_method_call()
6432 -> Result<(), Box<dyn std::error::Error>> {
6433 let index = WorkspaceIndex::new();
6437 let uri = must(url::Url::parse("file:///test/orphan-import.pl"));
6438 let src = r#"package Test;
6439Unrelated::Module->import('orphaned');
6440orphaned();
64411;
6442"#;
6443 must(index.index_file(uri.clone(), src.to_string()));
6444
6445 let _refs = index.find_references("orphaned");
6449 Ok(())
6452 }
6453
6454 #[test]
6455 fn test_nested_blocks_preserve_require_scope() -> Result<(), Box<dyn std::error::Error>> {
6456 let index = WorkspaceIndex::new();
6457 let uri = must(url::Url::parse("file:///test/nested.pl"));
6458 let src = r#"package Test;
6459{
6460 require Outer;
6461 {
6462 Outer->import('nested_sym');
6463 }
6464}
64651;
6466"#;
6467 must(index.index_file(uri.clone(), src.to_string()));
6468 let deps = index.file_dependencies(uri.as_str());
6469 assert!(
6470 deps.contains("Outer"),
6471 "require in outer block should be visible to nested import"
6472 );
6473 let refs = index.find_references("nested_sym");
6474 assert!(!refs.is_empty(), "symbol imported in nested block should still be indexed");
6475 Ok(())
6476 }
6477
6478 #[test]
6479 fn test_require_path_without_pm_extension() -> Result<(), Box<dyn std::error::Error>> {
6480 let index = WorkspaceIndex::new();
6481 let uri = must(url::Url::parse("file:///test/no-ext.pl"));
6482 let src = r#"package Test;
6483require "My/Module";
6484My::Module->import('func');
64851;
6486"#;
6487 must(index.index_file(uri.clone(), src.to_string()));
6488 let deps = index.file_dependencies(uri.as_str());
6489 assert!(
6490 deps.contains("My::Module"),
6491 "require without .pm extension should normalize to module path"
6492 );
6493 Ok(())
6494 }
6495
6496 #[test]
6497 fn test_qw_with_bracket_delimiters() -> Result<(), Box<dyn std::error::Error>> {
6498 let index = WorkspaceIndex::new();
6499 let uri = must(url::Url::parse("file:///test/qw-delim.pl"));
6500 let src = r#"package Test;
6501require DelimModule;
6502DelimModule->import(qw[sym1 sym2]);
6503DelimModule->import(qw{sym3 sym4});
65041;
6505"#;
6506 must(index.index_file(uri.clone(), src.to_string()));
6507 for symbol in &["sym1", "sym2", "sym3", "sym4"] {
6508 let refs = index.find_references(symbol);
6509 assert!(
6510 !refs.is_empty(),
6511 "symbols from qw with bracket delimiters should be indexed: {}",
6512 symbol
6513 );
6514 }
6515 Ok(())
6516 }
6517
6518 #[test]
6519 fn test_array_literal_import_args() -> Result<(), Box<dyn std::error::Error>> {
6520 let index = WorkspaceIndex::new();
6521 let uri = must(url::Url::parse("file:///test/array-import.pl"));
6522 let src = r#"package Test;
6523require ArrayModule;
6524ArrayModule->import(['sym_x', 'sym_y']);
65251;
6526"#;
6527 must(index.index_file(uri.clone(), src.to_string()));
6528 for symbol in &["sym_x", "sym_y"] {
6529 let refs = index.find_references(symbol);
6530 assert!(
6531 !refs.is_empty(),
6532 "symbols from array literal import should be indexed: {}",
6533 symbol
6534 );
6535 }
6536 Ok(())
6537 }
6538
6539 #[test]
6540 fn test_require_inside_conditional_still_registers_dependency()
6541 -> Result<(), Box<dyn std::error::Error>> {
6542 let index = WorkspaceIndex::new();
6543 let uri = must(url::Url::parse("file:///test/cond-require.pl"));
6544 let src = r#"package Test;
6545if (1) {
6546 require ConditionalMod;
6547 ConditionalMod->import('cond_func');
6548}
65491;
6550"#;
6551 must(index.index_file(uri.clone(), src.to_string()));
6552 let deps = index.file_dependencies(uri.as_str());
6553 assert!(
6554 deps.contains("ConditionalMod"),
6555 "require inside conditional should still register as dependency"
6556 );
6557 let refs = index.find_references("cond_func");
6558 assert!(!refs.is_empty(), "import inside conditional should still index symbols");
6559 Ok(())
6560 }
6561
6562 #[test]
6563 fn test_mixed_string_and_bareword_imports() -> Result<(), Box<dyn std::error::Error>> {
6564 let index = WorkspaceIndex::new();
6565 let uri = must(url::Url::parse("file:///test/mixed-import.pl"));
6566 let src = r#"package Test;
6567require MixedMod;
6568MixedMod->import('string_sym');
6569MixedMod->import(qw(qw_one qw_two));
65701;
6571"#;
6572 must(index.index_file(uri.clone(), src.to_string()));
6573 let deps = index.file_dependencies(uri.as_str());
6574 assert!(deps.contains("MixedMod"), "require should register dependency");
6575 for symbol in &["string_sym", "qw_one", "qw_two"] {
6576 let refs = index.find_references(symbol);
6577 assert!(!refs.is_empty(), "all import forms should index symbols: {}", symbol);
6578 }
6579 Ok(())
6580 }
6581
6582 fn make_shard(
6588 uri: &str,
6589 content_hash: u64,
6590 anchors_hash: Option<u64>,
6591 entities_hash: Option<u64>,
6592 occurrences_hash: Option<u64>,
6593 edges_hash: Option<u64>,
6594 ) -> FileFactShard {
6595 let file_id = {
6596 let mut h = DefaultHasher::new();
6597 uri.hash(&mut h);
6598 FileId(h.finish())
6599 };
6600 FileFactShard {
6601 source_uri: uri.to_string(),
6602 file_id,
6603 content_hash,
6604 anchors_hash,
6605 entities_hash,
6606 occurrences_hash,
6607 edges_hash,
6608 anchors: Vec::new(),
6609 entities: Vec::new(),
6610 occurrences: Vec::new(),
6611 edges: Vec::new(),
6612 }
6613 }
6614
6615 #[test]
6618 fn incremental_replace_skips_when_content_hash_unchanged()
6619 -> Result<(), Box<dyn std::error::Error>> {
6620 let index = WorkspaceIndex::new();
6621 let uri = "file:///lib/Same.pm";
6622 let key = DocumentStore::uri_key(uri);
6623
6624 let shard_v1 = make_shard(uri, 42, Some(1), Some(2), Some(3), Some(4));
6625 let r1 = index.replace_fact_shard_incremental(&key, shard_v1);
6627 assert!(!r1.content_unchanged);
6628
6629 let shard_v2 = make_shard(uri, 42, Some(100), Some(200), Some(300), Some(400));
6631 let r2 = index.replace_fact_shard_incremental(&key, shard_v2);
6632 assert!(r2.content_unchanged);
6633 assert!(!r2.anchors_updated);
6634 assert!(!r2.entities_updated);
6635 assert!(!r2.occurrences_updated);
6636 assert!(!r2.edges_updated);
6637
6638 let stored = must_some(index.file_fact_shard(uri));
6640 assert_eq!(stored.anchors_hash, Some(1));
6641 Ok(())
6642 }
6643
6644 #[test]
6647 fn incremental_replace_skips_unchanged_categories() -> Result<(), Box<dyn std::error::Error>> {
6648 let index = WorkspaceIndex::new();
6649 let uri = "file:///lib/Partial.pm";
6650 let key = DocumentStore::uri_key(uri);
6651
6652 let shard_v1 = make_shard(uri, 1, Some(10), Some(20), Some(30), Some(40));
6653 index.replace_fact_shard_incremental(&key, shard_v1);
6654
6655 let shard_v2 = make_shard(uri, 2, Some(10), Some(20), Some(99), Some(88));
6658 let result = index.replace_fact_shard_incremental(&key, shard_v2);
6659
6660 assert!(!result.content_unchanged);
6661 assert!(!result.anchors_updated, "anchors hash unchanged → skip");
6662 assert!(!result.entities_updated, "entities hash unchanged → skip");
6663 assert!(result.occurrences_updated, "occurrences hash changed → update");
6664 assert!(result.edges_updated, "edges hash changed → update");
6665 Ok(())
6666 }
6667
6668 #[test]
6671 fn incremental_replace_updates_changed_categories() -> Result<(), Box<dyn std::error::Error>> {
6672 let index = WorkspaceIndex::new();
6673 let uri = "file:///lib/Changed.pm";
6674 let key = DocumentStore::uri_key(uri);
6675
6676 let shard_v1 = make_shard(uri, 1, Some(10), Some(20), Some(30), Some(40));
6677 index.replace_fact_shard_incremental(&key, shard_v1);
6678
6679 let shard_v2 = make_shard(uri, 2, Some(11), Some(21), Some(31), Some(41));
6681 let result = index.replace_fact_shard_incremental(&key, shard_v2);
6682
6683 assert!(!result.content_unchanged);
6684 assert!(result.anchors_updated);
6685 assert!(result.entities_updated);
6686 assert!(result.occurrences_updated);
6687 assert!(result.edges_updated);
6688
6689 let stored = must_some(index.file_fact_shard(uri));
6691 assert_eq!(stored.content_hash, 2);
6692 assert_eq!(stored.anchors_hash, Some(11));
6693 Ok(())
6694 }
6695
6696 #[test]
6699 fn incremental_replace_first_insert_updates_all() -> Result<(), Box<dyn std::error::Error>> {
6700 let index = WorkspaceIndex::new();
6701 let uri = "file:///lib/New.pm";
6702 let key = DocumentStore::uri_key(uri);
6703
6704 let shard = make_shard(uri, 1, Some(10), Some(20), Some(30), Some(40));
6705 let result = index.replace_fact_shard_incremental(&key, shard);
6706
6707 assert!(!result.content_unchanged);
6708 assert!(result.anchors_updated);
6709 assert!(result.entities_updated);
6710 assert!(result.occurrences_updated);
6711 assert!(result.edges_updated);
6712 Ok(())
6713 }
6714
6715 #[test]
6718 fn incremental_replace_none_hashes_treated_as_changed() -> Result<(), Box<dyn std::error::Error>>
6719 {
6720 let index = WorkspaceIndex::new();
6721 let uri = "file:///lib/Legacy.pm";
6722 let key = DocumentStore::uri_key(uri);
6723
6724 let shard_v1 = make_shard(uri, 1, Some(10), Some(20), Some(30), Some(40));
6726 index.replace_fact_shard_incremental(&key, shard_v1);
6727
6728 let shard_v2 = make_shard(uri, 2, None, Some(20), None, Some(40));
6729 let result = index.replace_fact_shard_incremental(&key, shard_v2);
6730
6731 assert!(!result.content_unchanged);
6732 assert!(result.anchors_updated, "None new hash → changed");
6733 assert!(!result.entities_updated, "same hash → skip");
6734 assert!(result.occurrences_updated, "None new hash → changed");
6735 assert!(!result.edges_updated, "same hash → skip");
6736 Ok(())
6737 }
6738
6739 #[test]
6742 fn incremental_replace_updates_reference_index_on_occurrence_change()
6743 -> Result<(), Box<dyn std::error::Error>> {
6744 use perl_semantic_facts::{AnchorId, Confidence, OccurrenceId, OccurrenceKind, Provenance};
6745
6746 let index = WorkspaceIndex::new();
6747 let uri = "file:///lib/RefIdx.pm";
6748 let key = DocumentStore::uri_key(uri);
6749 let file_id = {
6750 let mut h = DefaultHasher::new();
6751 uri.hash(&mut h);
6752 FileId(h.finish())
6753 };
6754
6755 let mut shard_v1 = make_shard(uri, 1, Some(10), Some(20), Some(30), Some(40));
6757 let anchor_id = AnchorId(1);
6758 shard_v1.anchors.push(perl_semantic_facts::AnchorFact {
6759 id: anchor_id,
6760 file_id,
6761 span_start_byte: 0,
6762 span_end_byte: 5,
6763 scope_id: None,
6764 provenance: Provenance::ExactAst,
6765 confidence: Confidence::High,
6766 });
6767 shard_v1.occurrences.push(perl_semantic_facts::OccurrenceFact {
6768 id: OccurrenceId(1),
6769 kind: OccurrenceKind::Call,
6770 entity_id: Some(EntityId(100)),
6771 anchor_id,
6772 scope_id: None,
6773 provenance: Provenance::ExactAst,
6774 confidence: Confidence::High,
6775 });
6776 shard_v1.entities.push(perl_semantic_facts::EntityFact {
6777 id: EntityId(100),
6778 kind: EntityKind::Subroutine,
6779 canonical_name: "RefIdx::foo".to_string(),
6780 anchor_id: Some(anchor_id),
6781 scope_id: None,
6782 provenance: Provenance::ExactAst,
6783 confidence: Confidence::High,
6784 });
6785 index.replace_fact_shard_incremental(&key, shard_v1);
6786
6787 assert!(
6789 index.semantic_reference_index.read().name_count() > 0
6790 || index.semantic_reference_index.read().entity_count() > 0,
6791 "reference index should be populated after first insert"
6792 );
6793
6794 let shard_v2_same = make_shard(uri, 1, Some(10), Some(20), Some(99), Some(99));
6796 let r = index.replace_fact_shard_incremental(&key, shard_v2_same);
6797 assert!(r.content_unchanged);
6798
6799 let mut shard_v3 = make_shard(uri, 3, Some(11), Some(21), Some(30), Some(40));
6801 shard_v3.anchors.push(perl_semantic_facts::AnchorFact {
6802 id: anchor_id,
6803 file_id,
6804 span_start_byte: 0,
6805 span_end_byte: 5,
6806 scope_id: None,
6807 provenance: Provenance::ExactAst,
6808 confidence: Confidence::High,
6809 });
6810 shard_v3.occurrences.push(perl_semantic_facts::OccurrenceFact {
6811 id: OccurrenceId(1),
6812 kind: OccurrenceKind::Call,
6813 entity_id: Some(EntityId(100)),
6814 anchor_id,
6815 scope_id: None,
6816 provenance: Provenance::ExactAst,
6817 confidence: Confidence::High,
6818 });
6819 shard_v3.entities.push(perl_semantic_facts::EntityFact {
6820 id: EntityId(100),
6821 kind: EntityKind::Subroutine,
6822 canonical_name: "RefIdx::foo".to_string(),
6823 anchor_id: Some(anchor_id),
6824 scope_id: None,
6825 provenance: Provenance::ExactAst,
6826 confidence: Confidence::High,
6827 });
6828 let r3 = index.replace_fact_shard_incremental(&key, shard_v3);
6829 assert!(!r3.occurrences_updated, "occurrence hash unchanged → skip");
6830 assert!(!r3.edges_updated, "edge hash unchanged → skip");
6831
6832 Ok(())
6833 }
6834
6835 #[test]
6838 fn index_file_stores_fact_shard_incrementally() -> Result<(), Box<dyn std::error::Error>> {
6839 let index = WorkspaceIndex::new();
6840 let uri = "file:///lib/Incr.pm";
6841 let code = "package Incr;\nsub foo { 1 }\n1;\n";
6842
6843 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
6844 let shard1 = must_some(index.file_fact_shard(uri));
6845 assert!(shard1.anchors_hash.is_some());
6846 assert!(
6847 shard1.anchors.iter().any(|anchor| anchor.provenance == Provenance::ExactAst),
6848 "index_file should store the canonical semantic shard when adapters produce facts"
6849 );
6850 assert!(
6851 shard1.entities.iter().any(|entity| entity.provenance == Provenance::ExactAst),
6852 "index_file should store canonical entities rather than legacy fallback entities"
6853 );
6854
6855 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
6857 let shard2 = must_some(index.file_fact_shard(uri));
6861 assert_eq!(shard1.content_hash, shard2.content_hash);
6862
6863 let code2 = "package Incr;\nsub bar { 2 }\n1;\n";
6865 must(index.index_file(must(url::Url::parse(uri)), code2.to_string()));
6866 let shard3 = must_some(index.file_fact_shard(uri));
6867 assert_ne!(shard1.content_hash, shard3.content_hash);
6868
6869 Ok(())
6870 }
6871
6872 mod prop_incremental_invalidation {
6875 use super::*;
6876 use proptest::prelude::*;
6877 use proptest::test_runner::Config as ProptestConfig;
6878
6879 fn arb_category_hash() -> impl Strategy<Value = Option<u64>> {
6884 prop_oneof![
6885 1 => Just(None),
6886 9 => any::<u64>().prop_map(Some),
6887 ]
6888 }
6889
6890 fn arb_shard(uri: &'static str) -> impl Strategy<Value = FileFactShard> {
6893 (
6894 any::<u64>(), arb_category_hash(), arb_category_hash(), arb_category_hash(), arb_category_hash(), )
6900 .prop_map(move |(content_hash, ah, eh, oh, edh)| {
6901 make_shard(uri, content_hash, ah, eh, oh, edh)
6902 })
6903 }
6904
6905 proptest! {
6917 #![proptest_config(ProptestConfig {
6918 failure_persistence: None,
6919 ..ProptestConfig::default()
6920 })]
6921
6922 #[test]
6923 fn prop_incremental_invalidation_correctness(
6924 old_shard in arb_shard("file:///lib/Prop.pm"),
6925 new_shard in arb_shard("file:///lib/Prop.pm"),
6926 ) {
6927 let index = WorkspaceIndex::new();
6928 let key = DocumentStore::uri_key("file:///lib/Prop.pm");
6929
6930 index.replace_fact_shard_incremental(&key, old_shard.clone());
6932
6933 let result = index.replace_fact_shard_incremental(&key, new_shard.clone());
6935
6936 if old_shard.content_hash == new_shard.content_hash {
6938 prop_assert!(
6939 result.content_unchanged,
6940 "content_unchanged must be true when content_hash is the same"
6941 );
6942 prop_assert!(
6943 !result.anchors_updated,
6944 "anchors_updated must be false when content_hash unchanged"
6945 );
6946 prop_assert!(
6947 !result.entities_updated,
6948 "entities_updated must be false when content_hash unchanged"
6949 );
6950 prop_assert!(
6951 !result.occurrences_updated,
6952 "occurrences_updated must be false when content_hash unchanged"
6953 );
6954 prop_assert!(
6955 !result.edges_updated,
6956 "edges_updated must be false when content_hash unchanged"
6957 );
6958 } else {
6959 prop_assert!(
6960 !result.content_unchanged,
6961 "content_unchanged must be false when content_hash differs"
6962 );
6963
6964 let anchors_should_update = crate::semantic::invalidation::category_hash_changed(
6970 old_shard.anchors_hash,
6971 new_shard.anchors_hash,
6972 );
6973 prop_assert_eq!(
6974 result.anchors_updated,
6975 anchors_should_update,
6976 "anchors_updated mismatch: old={:?} new={:?}",
6977 old_shard.anchors_hash,
6978 new_shard.anchors_hash,
6979 );
6980
6981 let entities_should_update =
6982 crate::semantic::invalidation::category_hash_changed(
6983 old_shard.entities_hash,
6984 new_shard.entities_hash,
6985 );
6986 prop_assert_eq!(
6987 result.entities_updated,
6988 entities_should_update,
6989 "entities_updated mismatch: old={:?} new={:?}",
6990 old_shard.entities_hash,
6991 new_shard.entities_hash,
6992 );
6993
6994 let occurrences_should_update =
6995 crate::semantic::invalidation::category_hash_changed(
6996 old_shard.occurrences_hash,
6997 new_shard.occurrences_hash,
6998 );
6999 prop_assert_eq!(
7000 result.occurrences_updated,
7001 occurrences_should_update,
7002 "occurrences_updated mismatch: old={:?} new={:?}",
7003 old_shard.occurrences_hash,
7004 new_shard.occurrences_hash,
7005 );
7006
7007 let edges_should_update = crate::semantic::invalidation::category_hash_changed(
7008 old_shard.edges_hash,
7009 new_shard.edges_hash,
7010 );
7011 prop_assert_eq!(
7012 result.edges_updated,
7013 edges_should_update,
7014 "edges_updated mismatch: old={:?} new={:?}",
7015 old_shard.edges_hash,
7016 new_shard.edges_hash,
7017 );
7018 }
7019 }
7020 }
7021 }
7022}
7023
7024#[cfg(test)]
7027mod semantic_query_callback_tests {
7028 use super::*;
7029 use perl_tdd_support::{must, must_some};
7030
7031 #[test]
7032 fn with_semantic_queries_for_uri_indexed_uri_invokes_callback()
7033 -> Result<(), Box<dyn std::error::Error>> {
7034 let index = WorkspaceIndex::new();
7035 let uri = "file:///lib/Foo.pm";
7036 must(index.index_file(must(url::Url::parse(uri)), "sub foo { 1 }".to_string()));
7037
7038 let result = index.with_semantic_queries_for_uri(uri, |file_id, _queries| {
7039 assert_ne!(file_id.0, 0, "file_id should be non-zero");
7041 42u32 });
7043
7044 assert_eq!(result, Some(42u32), "callback must run when URI is indexed");
7045 Ok(())
7046 }
7047
7048 #[test]
7049 fn with_semantic_queries_for_uri_unknown_uri_returns_none()
7050 -> Result<(), Box<dyn std::error::Error>> {
7051 let index = WorkspaceIndex::new();
7052 let result = index.with_semantic_queries_for_uri("file:///not/indexed.pl", |_, _| 99u32);
7054 assert!(result.is_none(), "unindexed URI must return None without invoking callback");
7055 Ok(())
7056 }
7057
7058 #[test]
7059 fn with_semantic_queries_for_uri_file_id_matches_file_id_for_uri()
7060 -> Result<(), Box<dyn std::error::Error>> {
7061 let index = WorkspaceIndex::new();
7062 let uri = "file:///lib/Bar.pm";
7063 must(index.index_file(must(url::Url::parse(uri)), "sub bar { 1 }".to_string()));
7064
7065 let direct_id = must_some(index.file_id_for_uri(uri));
7066 let callback_id =
7067 must_some(index.with_semantic_queries_for_uri(uri, |file_id, _q| file_id));
7068
7069 assert_eq!(
7070 direct_id, callback_id,
7071 "file_id_for_uri and with_semantic_queries_for_uri must agree"
7072 );
7073 Ok(())
7074 }
7075
7076 #[test]
7077 fn with_semantic_queries_for_uri_callback_not_called_when_not_indexed()
7078 -> Result<(), Box<dyn std::error::Error>> {
7079 let index = WorkspaceIndex::new();
7080 let mut called = false;
7081 let _ = index.with_semantic_queries_for_uri("file:///ghost.pl", |_, _| {
7082 called = true;
7083 });
7084 assert!(!called, "callback must not be invoked for unindexed URI");
7085 Ok(())
7086 }
7087}