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 let use_lib_facts =
1734 crate::semantic::workspace_import_extractor::extract_use_lib_facts(&ast, file_id);
1735
1736 {
1739 let mut files = self.files.write();
1740
1741 if let Some(old_index) = files.get(&key) {
1743 let mut global_refs = self.global_references.write();
1744 Self::remove_file_global_refs(&mut global_refs, old_index, &uri_str);
1745 }
1746
1747 if let Some(old_index) = files.get(&key) {
1749 let mut symbols = self.symbols.write();
1750 Self::incremental_remove_symbols(&files, &mut symbols, old_index);
1751 drop(symbols);
1752 }
1753 files.insert(key.clone(), file_index);
1754 let mut symbols = self.symbols.write();
1755 if let Some(new_index) = files.get(&key) {
1756 Self::incremental_add_symbols(&mut symbols, new_index);
1757 }
1758
1759 if let Some(file_index) = files.get(&key) {
1760 let mut global_refs = self.global_references.write();
1761 for (name, refs) in &file_index.references {
1762 let entry = global_refs.entry(name.clone()).or_default();
1763 for reference in refs {
1764 entry.push(Location { uri: reference.uri.clone(), range: reference.range });
1765 }
1766 }
1767 }
1768 self.replace_fact_shard_incremental(&key, fact_shard);
1769 }
1770
1771 {
1777 let mut ie_idx = self.semantic_import_export_index.write();
1778 ie_idx.remove_file_imports(&uri_str);
1779 ie_idx.add_file_imports(&uri_str, file_id, import_specs);
1780 ie_idx.remove_file_use_lib(&uri_str);
1781 ie_idx.add_file_use_lib(&uri_str, file_id, use_lib_facts);
1782 }
1783
1784 Ok(())
1785 }
1786
1787 pub fn remove_file(&self, uri: &str) {
1806 let uri_str = Self::normalize_uri(uri);
1807 let key = DocumentStore::uri_key(&uri_str);
1808
1809 self.document_store.close(&uri_str);
1811
1812 let mut files = self.files.write();
1814 if let Some(file_index) = files.remove(&key) {
1815 self.fact_shards.write().remove(&key);
1816
1817 self.semantic_reference_index.write().remove_file(&uri_str);
1819 {
1820 let mut ie_idx = self.semantic_import_export_index.write();
1821 ie_idx.remove_file_imports(&uri_str);
1822 ie_idx.remove_module_exports(&uri_str);
1823 ie_idx.remove_file_use_lib(&uri_str);
1824 }
1825
1826 let mut symbols = self.symbols.write();
1828 Self::incremental_remove_symbols(&files, &mut symbols, &file_index);
1829
1830 let mut removed_uris = vec![uri_str.as_str()];
1841 for observed_uri in file_index.symbols.iter().map(|s| s.uri.as_str()).chain(
1842 file_index.references.values().flat_map(|refs| refs.iter().map(|r| r.uri.as_str())),
1843 ) {
1844 if !removed_uris.contains(&observed_uri) {
1845 removed_uris.push(observed_uri);
1846 }
1847 }
1848 symbols.retain(|_, candidates| {
1849 candidates.retain(|candidate| {
1850 let cand_uri = candidate.location.uri.as_str();
1851 !removed_uris.contains(&cand_uri)
1852 });
1853 !candidates.is_empty()
1854 });
1855
1856 let mut global_refs = self.global_references.write();
1863 Self::remove_file_global_refs(&mut global_refs, &file_index, &uri_str);
1864 global_refs.retain(|_, locs| {
1865 locs.retain(|loc| !removed_uris.contains(&loc.uri.as_str()));
1866 !locs.is_empty()
1867 });
1868 }
1869 }
1870
1871 pub fn remove_file_url(&self, uri: &Url) {
1895 self.remove_file(uri.as_str())
1896 }
1897
1898 pub fn clear_file(&self, uri: &str) {
1917 self.remove_file(uri);
1918 }
1919
1920 pub fn clear_file_url(&self, uri: &Url) {
1944 self.clear_file(uri.as_str())
1945 }
1946
1947 pub fn remove_folder(&self, folder_uri: &str) {
1967 let mut uris_to_remove = Vec::new();
1968 let files = self.files.read();
1969
1970 for file_index in files.values() {
1972 if file_index.folder_uri.as_deref() == Some(folder_uri) {
1973 uris_to_remove.push(file_index.source_uri.clone());
1974 }
1975 }
1976 drop(files);
1977
1978 for uri in uris_to_remove {
1981 self.remove_file(&uri);
1982 }
1983 }
1984
1985 #[cfg(not(target_arch = "wasm32"))]
1986 pub fn index_file_str(&self, uri: &str, text: &str) -> Result<(), String> {
2016 let path = Path::new(uri);
2017 let url = if path.is_absolute() {
2018 url::Url::from_file_path(path)
2019 .map_err(|_| format!("Invalid URI or file path: {}", uri))?
2020 } else {
2021 url::Url::parse(uri).or_else(|_| {
2024 url::Url::from_file_path(path)
2025 .map_err(|_| format!("Invalid URI or file path: {}", uri))
2026 })?
2027 };
2028 self.index_file(url, text.to_string())
2029 }
2030
2031 pub fn index_files_batch(&self, files_to_index: Vec<(Url, String)>) -> Vec<String> {
2040 let mut errors = Vec::new();
2041
2042 let mut parsed: Vec<(String, String, FileIndex)> = Vec::with_capacity(files_to_index.len());
2044 for (uri, text) in &files_to_index {
2045 let uri_str = uri.to_string();
2046
2047 let mut hasher = DefaultHasher::new();
2049 text.hash(&mut hasher);
2050 let content_hash = hasher.finish();
2051
2052 let key = DocumentStore::uri_key(&uri_str);
2053
2054 {
2056 let files = self.files.read();
2057 if let Some(existing) = files.get(&key) {
2058 if existing.content_hash == content_hash {
2059 continue;
2060 }
2061 }
2062 }
2063
2064 if self.document_store.is_open(&uri_str) {
2066 self.document_store.update(&uri_str, 1, text.clone());
2067 } else {
2068 self.document_store.open(uri_str.clone(), 1, text.clone());
2069 }
2070
2071 let mut parser = Parser::new(text);
2073 let ast = match parser.parse() {
2074 Ok(ast) => ast,
2075 Err(e) => {
2076 errors.push(format!("Parse error in {}: {}", uri_str, e));
2077 continue;
2078 }
2079 };
2080
2081 let mut doc = match self.document_store.get(&uri_str) {
2082 Some(d) => d,
2083 None => {
2084 errors.push(format!("Document not found: {}", uri_str));
2085 continue;
2086 }
2087 };
2088
2089 let folder_uri = self.determine_folder_uri(&uri_str);
2091
2092 let mut file_index = FileIndex {
2093 source_uri: uri_str.clone(),
2094 content_hash,
2095 folder_uri: folder_uri.clone(),
2096 ..Default::default()
2097 };
2098 let mut visitor = IndexVisitor::new(&mut doc, uri_str.clone(), folder_uri);
2099 visitor.visit(&ast, &mut file_index);
2100
2101 parsed.push((key, uri_str, file_index));
2102 }
2103
2104 {
2106 let mut files = self.files.write();
2107 let mut symbols = self.symbols.write();
2108 let mut global_refs = self.global_references.write();
2109
2110 files.reserve(parsed.len());
2113 symbols.reserve(parsed.len().saturating_mul(20).saturating_mul(2));
2114
2115 for (key, uri_str, file_index) in parsed {
2116 if let Some(old_index) = files.get(&key) {
2118 Self::remove_file_global_refs(&mut global_refs, old_index, &uri_str);
2119 }
2120
2121 files.insert(key.clone(), file_index);
2122
2123 if let Some(fi) = files.get(&key) {
2125 for (name, refs) in &fi.references {
2126 let entry = global_refs.entry(name.clone()).or_default();
2127 for reference in refs {
2128 entry.push(Location {
2129 uri: reference.uri.clone(),
2130 range: reference.range,
2131 });
2132 }
2133 }
2134 }
2135 }
2136
2137 Self::rebuild_symbol_cache(&files, &mut symbols);
2139 }
2140
2141 errors
2142 }
2143
2144 pub fn find_references(&self, symbol_name: &str) -> Vec<Location> {
2172 let global_refs = self.global_references.read();
2173 let mut seen: HashSet<(String, u32, u32, u32, u32)> = HashSet::new();
2174 let mut locations = Vec::new();
2175
2176 if let Some(refs) = global_refs.get(symbol_name) {
2178 for loc in refs {
2179 let key = (
2180 loc.uri.clone(),
2181 loc.range.start.line,
2182 loc.range.start.column,
2183 loc.range.end.line,
2184 loc.range.end.column,
2185 );
2186 if seen.insert(key) {
2187 locations.push(Location { uri: loc.uri.clone(), range: loc.range });
2188 }
2189 }
2190 }
2191
2192 if let Some(idx) = symbol_name.rfind("::") {
2194 let bare_name = &symbol_name[idx + 2..];
2195 if let Some(refs) = global_refs.get(bare_name) {
2196 for loc in refs {
2197 let key = (
2198 loc.uri.clone(),
2199 loc.range.start.line,
2200 loc.range.start.column,
2201 loc.range.end.line,
2202 loc.range.end.column,
2203 );
2204 if seen.insert(key) {
2205 locations.push(Location { uri: loc.uri.clone(), range: loc.range });
2206 }
2207 }
2208 }
2209 } else {
2210 for (name, refs) in global_refs.iter() {
2213 if !Self::is_qualified_variant_of(name, symbol_name) {
2214 continue;
2215 }
2216
2217 for loc in refs {
2218 let key = (
2219 loc.uri.clone(),
2220 loc.range.start.line,
2221 loc.range.start.column,
2222 loc.range.end.line,
2223 loc.range.end.column,
2224 );
2225 if seen.insert(key) {
2226 locations.push(Location { uri: loc.uri.clone(), range: loc.range });
2227 }
2228 }
2229 }
2230 }
2231
2232 Self::sort_locations_deterministically(&mut locations);
2233 locations
2234 }
2235
2236 pub fn query_symbol_references(
2240 &self,
2241 symbol_name: &str,
2242 ) -> Option<CrossFileReferenceQueryResult> {
2243 let definition = self.find_definition(symbol_name)?;
2244 let symbol = self.find_symbol_by_definition(&definition, symbol_name)?;
2245
2246 let stable_key = symbol.qualified_name.clone().unwrap_or_else(|| {
2247 format!(
2248 "{}@{}:{}:{}",
2249 symbol.name, symbol.uri, symbol.range.start.line, symbol.range.start.column
2250 )
2251 });
2252 let mut references = self.collect_symbol_references(&symbol);
2253 if !references.iter().any(|location| location == &definition) {
2254 references.push(definition.clone());
2255 Self::sort_locations_deterministically(&mut references);
2256 }
2257
2258 Some(CrossFileReferenceQueryResult {
2259 symbol: SymbolIdentity {
2260 stable_key,
2261 name: symbol.name,
2262 qualified_name: symbol.qualified_name,
2263 kind: symbol.kind,
2264 },
2265 definition,
2266 references,
2267 })
2268 }
2269
2270 pub fn count_usages(&self, symbol_name: &str) -> usize {
2276 let files = self.files.read();
2277 let mut seen: HashSet<(String, u32, u32, u32, u32)> = HashSet::new();
2278
2279 for (_uri_key, file_index) in files.iter() {
2280 if let Some(refs) = file_index.references.get(symbol_name) {
2281 for r in refs.iter().filter(|r| r.kind != ReferenceKind::Definition) {
2282 seen.insert((
2283 r.uri.clone(),
2284 r.range.start.line,
2285 r.range.start.column,
2286 r.range.end.line,
2287 r.range.end.column,
2288 ));
2289 }
2290 }
2291
2292 if let Some(idx) = symbol_name.rfind("::") {
2293 let bare_name = &symbol_name[idx + 2..];
2294 if let Some(refs) = file_index.references.get(bare_name) {
2295 for r in refs.iter().filter(|r| r.kind != ReferenceKind::Definition) {
2296 seen.insert((
2297 r.uri.clone(),
2298 r.range.start.line,
2299 r.range.start.column,
2300 r.range.end.line,
2301 r.range.end.column,
2302 ));
2303 }
2304 }
2305 } else {
2306 for (name, refs) in &file_index.references {
2307 if !Self::is_qualified_variant_of(name, symbol_name) {
2308 continue;
2309 }
2310
2311 for r in refs.iter().filter(|r| r.kind != ReferenceKind::Definition) {
2312 seen.insert((
2313 r.uri.clone(),
2314 r.range.start.line,
2315 r.range.start.column,
2316 r.range.end.line,
2317 r.range.end.column,
2318 ));
2319 }
2320 }
2321 }
2322 }
2323
2324 seen.len()
2325 }
2326
2327 fn is_qualified_variant_of(candidate: &str, bare_symbol: &str) -> bool {
2328 candidate.rsplit_once("::").is_some_and(|(_, candidate_bare)| candidate_bare == bare_symbol)
2329 }
2330
2331 pub fn find_definition(&self, symbol_name: &str) -> Option<Location> {
2350 if let Some(location) = self.definition_candidates(symbol_name).into_iter().next() {
2351 return Some(location);
2352 }
2353
2354 let files = self.files.read();
2364 Self::find_definition_in_files(&files, symbol_name, None).map(|(location, _uri)| location)
2365 }
2366
2367 pub(crate) fn definition_candidates(&self, symbol_name: &str) -> Vec<Location> {
2368 let symbols = self.symbols.read();
2369 symbols
2370 .get(symbol_name)
2371 .map(|candidates| {
2372 candidates.iter().map(|candidate| candidate.location.clone()).collect()
2373 })
2374 .unwrap_or_default()
2375 }
2376
2377 pub fn all_symbols(&self) -> Vec<WorkspaceSymbol> {
2392 let files = self.files.read();
2393 let mut symbols = Vec::new();
2394
2395 for (_uri_key, file_index) in files.iter() {
2396 symbols.extend(file_index.symbols.clone());
2397 }
2398
2399 symbols
2400 }
2401
2402 pub fn clear(&self) {
2404 self.files.write().clear();
2405 self.symbols.write().clear();
2406 self.global_references.write().clear();
2407 self.fact_shards.write().clear();
2408 *self.semantic_reference_index.write() = ReferenceIndex::new();
2409 *self.semantic_import_export_index.write() = ImportExportIndex::new();
2410 }
2411
2412 fn hash_uri_to_file_id(uri: &str) -> FileId {
2413 let mut hasher = DefaultHasher::new();
2414 uri.hash(&mut hasher);
2415 FileId(hasher.finish())
2416 }
2417
2418 fn build_fact_shard(uri: &str, content_hash: u64, file_index: &FileIndex) -> FileFactShard {
2419 let file_id = Self::hash_uri_to_file_id(uri);
2420 let mut anchors = Vec::new();
2421 let mut entities = Vec::new();
2422 for (idx, symbol) in file_index.symbols.iter().enumerate() {
2423 let anchor_id = AnchorId((idx + 1) as u64);
2424 anchors.push(AnchorFact {
2425 id: anchor_id,
2426 file_id,
2427 span_start_byte: 0,
2431 span_end_byte: 0,
2432 scope_id: None,
2433 provenance: Provenance::SearchFallback,
2434 confidence: Confidence::Low,
2435 });
2436 entities.push(EntityFact {
2437 id: EntityId((idx + 1) as u64),
2438 kind: EntityKind::Unknown,
2439 canonical_name: symbol
2440 .qualified_name
2441 .clone()
2442 .unwrap_or_else(|| symbol.name.clone()),
2443 anchor_id: Some(anchor_id),
2444 scope_id: None,
2445 provenance: Provenance::SearchFallback,
2446 confidence: Confidence::Low,
2447 });
2448 }
2449 let anchors_hash = {
2452 let mut h = DefaultHasher::new();
2453 anchors.len().hash(&mut h);
2454 for a in &anchors {
2455 a.id.hash(&mut h);
2456 a.span_start_byte.hash(&mut h);
2457 a.span_end_byte.hash(&mut h);
2458 }
2459 h.finish()
2460 };
2461 let entities_hash = {
2462 let mut h = DefaultHasher::new();
2463 entities.len().hash(&mut h);
2464 for e in &entities {
2465 e.id.hash(&mut h);
2466 e.canonical_name.hash(&mut h);
2467 }
2468 h.finish()
2469 };
2470 FileFactShard {
2471 source_uri: uri.to_string(),
2472 file_id,
2473 content_hash,
2474 anchors_hash: Some(anchors_hash),
2475 entities_hash: Some(entities_hash),
2476 occurrences_hash: Some(0),
2477 edges_hash: Some(0),
2478 anchors,
2479 entities,
2480 occurrences: Vec::new(),
2481 edges: Vec::new(),
2482 }
2483 }
2484
2485 fn build_canonical_fact_shard_for_ast(
2493 uri: &str,
2494 content_hash: u64,
2495 ast: &Node,
2496 ) -> FileFactShard {
2497 let file_id = Self::hash_uri_to_file_id(uri);
2498
2499 let decls = extract_symbol_decls(ast, None);
2501 let refs = extract_symbol_refs(ast);
2502
2503 let decl_facts = symbol_decls_to_semantic_facts(&decls, file_id);
2505
2506 let entity_ids_by_name: std::collections::BTreeMap<String, EntityId> =
2508 decl_facts.entities.iter().map(|e| (e.canonical_name.clone(), e.id)).collect();
2509 let ref_facts = symbol_refs_to_semantic_facts(&refs, file_id, &entity_ids_by_name);
2510
2511 let eval_sub_triples =
2515 crate::semantic::eval_sub_extractor::extract_eval_sub_boundaries(ast, file_id);
2516 let dynamic_boundaries: Vec<perl_semantic_facts::OccurrenceFact> =
2517 eval_sub_triples.iter().map(|(_, _, occ)| occ.clone()).collect();
2518 let generated_member_facts =
2519 crate::semantic::generated_member_extractor::extract_generated_member_facts(
2520 ast, file_id,
2521 );
2522
2523 let mut shard = crate::semantic::facts::build_canonical_fact_shard(
2527 uri,
2528 content_hash,
2529 &decl_facts,
2530 &ref_facts,
2531 &[],
2532 &dynamic_boundaries,
2533 );
2534
2535 for (entity, anchor, _) in eval_sub_triples {
2547 shard.entities.push(entity);
2548 shard.anchors.push(anchor);
2549 }
2550 for fact in generated_member_facts {
2551 shard.entities.push(fact.entity);
2552 shard.anchors.push(fact.anchor);
2553 }
2554
2555 shard
2556 }
2557
2558 pub fn replace_fact_shard_incremental(
2569 &self,
2570 key: &str,
2571 new_shard: FileFactShard,
2572 ) -> ShardReplaceResult {
2573 let mut shards = self.fact_shards.write();
2574 let old_shard = shards.get(key);
2575
2576 let replacement = plan_shard_replacement(
2577 old_shard.map(Self::shard_category_hashes),
2578 Self::shard_category_hashes(&new_shard),
2579 );
2580
2581 if replacement.content_unchanged {
2582 return replacement;
2583 }
2584
2585 let source_uri = new_shard.source_uri.clone();
2586
2587 if replacement.occurrences_updated || replacement.edges_updated {
2591 let mut ref_idx = self.semantic_reference_index.write();
2592 if old_shard.is_some() {
2593 ref_idx.remove_file(&source_uri);
2594 }
2595 ref_idx.add_file(&new_shard);
2596 }
2597
2598 if replacement.entities_updated {
2602 let mut ie_idx = self.semantic_import_export_index.write();
2603 ie_idx.remove_file_imports(&source_uri);
2604 ie_idx.remove_module_exports(&source_uri);
2605 }
2608
2609 shards.insert(key.to_string(), new_shard);
2611
2612 replacement
2613 }
2614
2615 fn shard_category_hashes(shard: &FileFactShard) -> ShardCategoryHashes {
2616 ShardCategoryHashes {
2617 content_hash: shard.content_hash,
2618 anchors_hash: shard.anchors_hash,
2619 entities_hash: shard.entities_hash,
2620 occurrences_hash: shard.occurrences_hash,
2621 edges_hash: shard.edges_hash,
2622 }
2623 }
2624
2625 pub fn fact_shard_count(&self) -> usize {
2627 self.fact_shards.read().len()
2628 }
2629
2630 pub fn file_fact_shard(&self, uri: &str) -> Option<FileFactShard> {
2632 let key = DocumentStore::uri_key(&Self::normalize_uri(uri));
2633 self.fact_shards.read().get(&key).cloned()
2634 }
2635
2636 pub fn semantic_anchor_wire_location(&self, anchor_id: AnchorId) -> Option<WireLocation> {
2643 let shards = self.fact_shards.read();
2644 let mut location = None;
2645
2646 for shard in shards.values() {
2647 for anchor in shard.anchors.iter().filter(|anchor| anchor.id == anchor_id) {
2648 if anchor.span_end_byte <= anchor.span_start_byte {
2649 return None;
2650 }
2651
2652 let doc = self.document_store.get(&shard.source_uri)?;
2653 let start = usize::try_from(anchor.span_start_byte).ok()?;
2654 let end = usize::try_from(anchor.span_end_byte).ok()?;
2655 let next_location = WireLocation::new(
2656 shard.source_uri.clone(),
2657 WireRange::from_byte_offsets(&doc.text, start, end),
2658 );
2659 if location.replace(next_location).is_some() {
2660 return None;
2661 }
2662 }
2663 }
2664
2665 location
2666 }
2667
2668 pub fn semantic_anchor_wire_location_for_file(
2675 &self,
2676 file_id: FileId,
2677 anchor_id: AnchorId,
2678 ) -> Option<WireLocation> {
2679 let shards = self.fact_shards.read();
2680 let shard = shards.values().find(|shard| shard.file_id == file_id)?;
2681 let anchor = shard
2682 .anchors
2683 .iter()
2684 .find(|anchor| anchor.id == anchor_id && anchor.file_id == file_id)?;
2685
2686 if anchor.span_end_byte <= anchor.span_start_byte {
2687 return None;
2688 }
2689
2690 let doc = self.document_store.get(&shard.source_uri)?;
2691 let start = usize::try_from(anchor.span_start_byte).ok()?;
2692 let end = usize::try_from(anchor.span_end_byte).ok()?;
2693 doc.text.get(start..end)?;
2694
2695 Some(WireLocation::new(
2696 shard.source_uri.clone(),
2697 WireRange::from_byte_offsets(&doc.text, start, end),
2698 ))
2699 }
2700
2701 pub fn file_id_for_uri(&self, uri: &str) -> Option<FileId> {
2705 let key = DocumentStore::uri_key(&Self::normalize_uri(uri));
2706 self.fact_shards.read().get(&key).map(|shard| shard.file_id)
2707 }
2708
2709 pub fn with_semantic_queries_for_uri<R>(
2720 &self,
2721 uri: &str,
2722 f: impl FnOnce(FileId, crate::semantic::queries::WorkspaceSemanticQueries<'_>) -> R,
2723 ) -> Option<R> {
2724 let key = DocumentStore::uri_key(&Self::normalize_uri(uri));
2725
2726 let shards_guard = self.fact_shards.read();
2730 let ref_guard = self.semantic_reference_index.read();
2731 let ie_guard = self.semantic_import_export_index.read();
2732
2733 let file_id = shards_guard.get(&key)?.file_id;
2735
2736 let queries = crate::semantic::queries::WorkspaceSemanticQueries::new(
2737 &ref_guard,
2738 &ie_guard,
2739 &shards_guard,
2740 );
2741
2742 Some(f(file_id, queries))
2743 }
2744
2745 pub fn file_count(&self) -> usize {
2747 let files = self.files.read();
2748 files.len()
2749 }
2750
2751 pub fn symbol_count(&self) -> usize {
2753 let files = self.files.read();
2754 files.values().map(|file_index| file_index.symbols.len()).sum()
2755 }
2756
2757 pub fn files_in_folder(&self, folder_uri: &str) -> Vec<FileIndex> {
2767 let files = self.files.read();
2768 files.values().filter(|f| f.folder_uri.as_deref() == Some(folder_uri)).cloned().collect()
2769 }
2770
2771 pub fn symbols_in_folder(&self, folder_uri: &str) -> Vec<WorkspaceSymbol> {
2781 let files = self.files.read();
2782 files
2783 .values()
2784 .filter(|f| f.folder_uri.as_deref() == Some(folder_uri))
2785 .flat_map(|f| f.symbols.iter().cloned())
2786 .collect()
2787 }
2788
2789 #[cfg(feature = "memory-profiling")]
2797 pub fn memory_snapshot(&self) -> crate::workspace::memory::MemorySnapshot {
2798 use std::mem::size_of;
2799
2800 let files_guard = self.files.read();
2801 let symbols_guard = self.symbols.read();
2802 let global_refs_guard = self.global_references.read();
2803
2804 let mut files_bytes: usize = 0;
2806 let mut total_symbol_count: usize = 0;
2807 for (uri_key, fi) in files_guard.iter() {
2808 files_bytes += uri_key.len();
2810 for sym in &fi.symbols {
2812 files_bytes += sym.name.len()
2813 + sym.uri.len()
2814 + sym.qualified_name.as_deref().map_or(0, str::len)
2815 + sym.documentation.as_deref().map_or(0, str::len)
2816 + sym.container_name.as_deref().map_or(0, str::len)
2817 + size_of::<WorkspaceSymbol>();
2819 }
2820 total_symbol_count += fi.symbols.len();
2821 for (ref_name, refs) in &fi.references {
2823 files_bytes += ref_name.len();
2824 for r in refs {
2825 files_bytes += r.uri.len() + size_of::<SymbolReference>();
2826 }
2827 }
2828 for dep in &fi.dependencies {
2830 files_bytes += dep.len();
2831 }
2832 files_bytes += size_of::<u64>();
2834 }
2835
2836 let mut symbols_bytes: usize = 0;
2838 for (qname, candidates) in symbols_guard.iter() {
2839 symbols_bytes += qname.len();
2840 for candidate in candidates {
2841 symbols_bytes += candidate.location.uri.len() + size_of::<Location>();
2842 }
2843 }
2844
2845 let mut global_refs_bytes: usize = 0;
2847 for (sym_name, locs) in global_refs_guard.iter() {
2848 global_refs_bytes += sym_name.len();
2849 for loc in locs {
2850 global_refs_bytes += loc.uri.len() + size_of::<Location>();
2851 }
2852 }
2853
2854 let document_store_bytes = self.document_store.total_text_bytes();
2856
2857 crate::workspace::memory::MemorySnapshot {
2858 file_count: files_guard.len(),
2859 symbol_count: total_symbol_count,
2860 files_bytes,
2861 symbols_bytes,
2862 global_refs_bytes,
2863 document_store_bytes,
2864 }
2865 }
2866
2867 pub fn has_symbols(&self) -> bool {
2886 let files = self.files.read();
2887 files.values().any(|file_index| !file_index.symbols.is_empty())
2888 }
2889
2890 pub fn search_symbols(&self, query: &str) -> Vec<WorkspaceSymbol> {
2909 self.search_source_symbols(query)
2910 }
2911
2912 pub fn search_source_symbols(&self, query: &str) -> Vec<WorkspaceSymbol> {
2918 let query = query.trim();
2919 let query_lower = query.to_lowercase();
2920 let files = self.files.read();
2921 let mut results = Vec::new();
2922 for file_index in files.values() {
2923 for symbol in &file_index.symbols {
2924 if symbol.name.to_lowercase().contains(&query_lower)
2925 || symbol
2926 .qualified_name
2927 .as_ref()
2928 .map(|qn| qn.to_lowercase().contains(&query_lower))
2929 .unwrap_or(false)
2930 {
2931 results.push(symbol.clone());
2932 }
2933 }
2934 }
2935 results
2936 }
2937
2938 pub fn search_generated_workspace_symbols(&self, query: &str) -> Vec<WorkspaceSymbol> {
2944 let query = query.trim();
2945 if query.is_empty() {
2946 return Vec::new();
2947 }
2948
2949 let query_lower = query.to_lowercase();
2950 let source_backed_qualified_names = self.source_backed_qualified_names();
2951 let shards = self.fact_shards.read();
2952 let mut results = Vec::new();
2953
2954 for shard in shards.values() {
2955 for entity in &shard.entities {
2956 if entity.kind != EntityKind::GeneratedMember {
2957 continue;
2958 }
2959 if !is_framework_generated_member_entity(entity) {
2960 continue;
2961 }
2962 if source_backed_qualified_names.contains(&entity.canonical_name) {
2963 continue;
2964 }
2965 let Some((container_name, bare_name)) =
2966 split_qualified_symbol_name(&entity.canonical_name)
2967 else {
2968 continue;
2969 };
2970 if !bare_name.to_lowercase().contains(&query_lower)
2971 && !entity.canonical_name.to_lowercase().contains(&query_lower)
2972 {
2973 continue;
2974 }
2975 let Some(anchor_id) = entity.anchor_id else {
2976 continue;
2977 };
2978 let Some(range) = self.generated_member_anchor_range(shard, anchor_id) else {
2979 continue;
2980 };
2981
2982 results.push(WorkspaceSymbol {
2983 name: format!("{bare_name} [generated/framework]"),
2984 kind: SymbolKind::Method,
2985 uri: shard.source_uri.clone(),
2986 range,
2987 qualified_name: Some(entity.canonical_name.clone()),
2988 documentation: Some(
2989 "Generated/framework member; virtual symbol anchored to source declaration"
2990 .to_string(),
2991 ),
2992 container_name: Some(format!("{container_name} [generated/framework]")),
2993 has_body: false,
2994 workspace_folder_uri: self.determine_folder_uri(&shard.source_uri),
2995 });
2996 }
2997 }
2998
2999 sort_workspace_symbols(&mut results);
3000 results
3001 }
3002
3003 fn source_backed_qualified_names(&self) -> HashSet<String> {
3004 let files = self.files.read();
3005 let mut qualified_names = HashSet::new();
3006 for file_index in files.values() {
3007 for symbol in &file_index.symbols {
3008 if let Some(name) = &symbol.qualified_name {
3009 qualified_names.insert(name.clone());
3010 continue;
3011 }
3012 if let Some(container) = &symbol.container_name {
3013 qualified_names.insert(format!("{container}::{}", symbol.name));
3014 }
3015 }
3016 }
3017 qualified_names
3018 }
3019
3020 fn generated_member_anchor_range(
3021 &self,
3022 shard: &FileFactShard,
3023 anchor_id: AnchorId,
3024 ) -> Option<Range> {
3025 let anchor = shard
3026 .anchors
3027 .iter()
3028 .find(|anchor| anchor.id == anchor_id && anchor.file_id == shard.file_id)?;
3029 if anchor.provenance != Provenance::FrameworkSynthesis
3030 || anchor.confidence != Confidence::Medium
3031 {
3032 return None;
3033 }
3034 if anchor.span_end_byte <= anchor.span_start_byte {
3035 return None;
3036 }
3037
3038 let doc = self.document_store.get(&shard.source_uri)?;
3039 let start = usize::try_from(anchor.span_start_byte).ok()?;
3040 let end = usize::try_from(anchor.span_end_byte).ok()?;
3041 doc.text.get(start..end)?;
3042 let ((start_line, start_col), (end_line, end_col)) = doc.line_index.range(start, end);
3043 Some(Range {
3044 start: Position { byte: start, line: start_line, column: start_col },
3045 end: Position { byte: end, line: end_line, column: end_col },
3046 })
3047 }
3048
3049 pub fn find_symbols(&self, query: &str) -> Vec<WorkspaceSymbol> {
3068 self.search_symbols(query)
3069 }
3070
3071 pub fn rank_symbols_by_folder(
3094 &self,
3095 symbols: Vec<WorkspaceSymbol>,
3096 doc_uri: &str,
3097 ) -> Vec<WorkspaceSymbol> {
3098 let doc_folder = self.determine_folder_uri(doc_uri);
3099
3100 let mut ranked: Vec<(WorkspaceSymbol, i32)> = symbols
3101 .into_iter()
3102 .map(|symbol| {
3103 let rank = if let Some(ref doc_folder_uri) = doc_folder {
3104 if symbol.workspace_folder_uri.as_ref() == Some(doc_folder_uri) {
3105 0 } else {
3107 1 }
3109 } else {
3110 1 };
3112 (symbol, rank)
3113 })
3114 .collect();
3115
3116 ranked.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.name.cmp(&b.0.name)));
3118
3119 ranked.into_iter().map(|(symbol, _)| symbol).collect()
3120 }
3121
3122 pub fn search_symbols_ranked(&self, name: &str, doc_uri: &str) -> Vec<WorkspaceSymbol> {
3144 let symbols = self.search_symbols(name);
3145 self.rank_symbols_by_folder(symbols, doc_uri)
3146 }
3147
3148 #[allow(dead_code)]
3159 pub fn same_package(&self, symbol_a: &WorkspaceSymbol, symbol_b: &WorkspaceSymbol) -> bool {
3160 let package_a = self.extract_package_name(&symbol_a.name);
3161 let package_b = self.extract_package_name(&symbol_b.name);
3162 package_a == package_b
3163 }
3164
3165 #[allow(dead_code)]
3176 pub fn same_package_by_container(&self, package_a: &str, package_b: &str) -> bool {
3177 package_a == package_b
3178 }
3179
3180 #[allow(dead_code)]
3190 pub fn extract_package_name(&self, symbol_name: &str) -> Option<String> {
3191 let parts: Vec<&str> = symbol_name.split("::").collect();
3192 if parts.len() > 1 { Some(parts[..parts.len() - 1].join("::")) } else { None }
3193 }
3194
3195 pub fn file_symbols(&self, uri: &str) -> Vec<WorkspaceSymbol> {
3214 let normalized_uri = Self::normalize_uri(uri);
3215 let key = DocumentStore::uri_key(&normalized_uri);
3216 let files = self.files.read();
3217
3218 files.get(&key).map(|fi| fi.symbols.clone()).unwrap_or_default()
3219 }
3220
3221 pub fn file_dependencies(&self, uri: &str) -> HashSet<String> {
3240 let normalized_uri = Self::normalize_uri(uri);
3241 let key = DocumentStore::uri_key(&normalized_uri);
3242 let files = self.files.read();
3243
3244 files.get(&key).map(|fi| fi.dependencies.clone()).unwrap_or_default()
3245 }
3246
3247 pub fn find_dependents(&self, module_name: &str) -> Vec<String> {
3266 let canonical = canonicalize_perl_module_name(module_name);
3267 let legacy = legacy_perl_module_name(&canonical);
3268 let files = self.files.read();
3269 let mut dependents = Vec::new();
3270
3271 for (uri_key, file_index) in files.iter() {
3272 if file_index.dependencies.contains(module_name)
3273 || file_index.dependencies.contains(&canonical)
3274 || file_index.dependencies.contains(&legacy)
3275 {
3276 dependents.push(uri_key.clone());
3277 }
3278 }
3279
3280 dependents
3281 }
3282
3283 pub fn document_store(&self) -> &DocumentStore {
3298 &self.document_store
3299 }
3300
3301 pub fn find_unused_symbols(&self) -> Vec<WorkspaceSymbol> {
3316 let files = self.files.read();
3317 let mut unused = Vec::new();
3318
3319 for (_uri_key, file_index) in files.iter() {
3321 for symbol in &file_index.symbols {
3322 let has_usage = files.values().any(|fi| {
3324 if let Some(refs) = fi.references.get(&symbol.name) {
3325 refs.iter().any(|r| r.kind != ReferenceKind::Definition)
3326 } else {
3327 false
3328 }
3329 });
3330
3331 if !has_usage {
3332 unused.push(symbol.clone());
3333 }
3334 }
3335 }
3336
3337 unused
3338 }
3339
3340 pub fn get_package_members(&self, package_name: &str) -> Vec<WorkspaceSymbol> {
3359 let files = self.files.read();
3360 let mut members = Vec::new();
3361
3362 for (_uri_key, file_index) in files.iter() {
3363 for symbol in &file_index.symbols {
3364 if let Some(ref container) = symbol.container_name {
3366 if container == package_name {
3367 members.push(symbol.clone());
3368 }
3369 }
3370 if let Some(ref qname) = symbol.qualified_name {
3372 if qname.starts_with(&format!("{}::", package_name)) {
3373 if symbol.container_name.as_deref() != Some(package_name) {
3375 members.push(symbol.clone());
3376 }
3377 }
3378 }
3379 }
3380 }
3381
3382 members
3383 }
3384
3385 pub fn file_packages(&self, uri: &str) -> Vec<String> {
3401 let normalized = Self::normalize_uri(uri);
3402 let key = DocumentStore::uri_key(&normalized);
3403 let files = self.files.read();
3404 let Some(file) = files.get(&key) else {
3405 return Vec::new();
3406 };
3407
3408 let mut packages = Vec::new();
3409 for symbol in &file.symbols {
3410 if symbol.kind == SymbolKind::Package {
3411 packages.push(symbol.name.clone());
3412 }
3413 }
3414 packages
3415 }
3416
3417 pub fn file_package_symbols(&self, uri: &str, package_name: &str) -> Vec<WorkspaceSymbol> {
3433 let normalized = Self::normalize_uri(uri);
3434 let key = DocumentStore::uri_key(&normalized);
3435 let files = self.files.read();
3436 let Some(file) = files.get(&key) else {
3437 return Vec::new();
3438 };
3439
3440 let mut symbols = Vec::new();
3441 for symbol in &file.symbols {
3442 if Self::symbol_belongs_to_package(symbol, package_name) {
3443 symbols.push(symbol.clone());
3444 }
3445 }
3446 symbols
3447 }
3448
3449 fn symbol_belongs_to_package(symbol: &WorkspaceSymbol, package_name: &str) -> bool {
3450 symbol.container_name.as_ref().is_some_and(|container| package_name.eq(container.as_str()))
3451 }
3452
3453 pub fn find_def(&self, key: &SymbolKey) -> Option<Location> {
3474 if let Some(sigil) = key.sigil {
3475 let var_name = format!("{}{}", sigil, key.name);
3477 self.find_definition(&var_name)
3478 } else if key.kind == SymKind::Pack {
3479 self.find_definition(key.pkg.as_ref())
3482 .or_else(|| self.find_definition(key.name.as_ref()))
3483 } else {
3484 let qualified_name = format!("{}::{}", key.pkg, key.name);
3486 self.find_definition(&qualified_name)
3487 }
3488 }
3489
3490 pub fn find_refs(&self, key: &SymbolKey) -> Vec<Location> {
3513 let files_locked = self.files.read();
3514 let mut all_refs = if let Some(sigil) = key.sigil {
3515 let var_name = format!("{}{}", sigil, key.name);
3517 let mut refs = Vec::new();
3518 for (_uri_key, file_index) in files_locked.iter() {
3519 if let Some(var_refs) = file_index.references.get(&var_name) {
3520 for reference in var_refs {
3521 refs.push(Location { uri: reference.uri.clone(), range: reference.range });
3522 }
3523 }
3524 }
3525 refs
3526 } else {
3527 if key.pkg.as_ref() == "main" {
3529 let mut refs = self.find_references(&format!("main::{}", key.name));
3531 for (_uri_key, file_index) in files_locked.iter() {
3533 if let Some(bare_refs) = file_index.references.get(key.name.as_ref()) {
3534 for reference in bare_refs {
3535 refs.push(Location {
3536 uri: reference.uri.clone(),
3537 range: reference.range,
3538 });
3539 }
3540 }
3541 }
3542 refs
3543 } else {
3544 let qualified_name = format!("{}::{}", key.pkg, key.name);
3545 self.find_references(&qualified_name)
3546 }
3547 };
3548 drop(files_locked);
3549
3550 if let Some(def) = self.find_def(key) {
3552 all_refs.retain(|loc| !(loc.uri == def.uri && loc.range == def.range));
3553 }
3554
3555 let mut seen = HashSet::new();
3557 all_refs.retain(|loc| {
3558 seen.insert((
3559 loc.uri.clone(),
3560 loc.range.start.line,
3561 loc.range.start.column,
3562 loc.range.end.line,
3563 loc.range.end.column,
3564 ))
3565 });
3566
3567 all_refs
3568 }
3569}
3570
3571struct IndexVisitor {
3573 document: Document,
3574 uri: String,
3575 current_package: Option<String>,
3576 workspace_folder_uri: Option<String>,
3577}
3578
3579fn is_interpolated_var_start(byte: u8) -> bool {
3580 byte.is_ascii_alphabetic() || byte == b'_'
3581}
3582
3583fn is_interpolated_var_continue(byte: u8) -> bool {
3584 byte.is_ascii_alphanumeric() || byte == b'_' || byte == b':'
3585}
3586
3587fn has_escaped_interpolation_marker(bytes: &[u8], index: usize) -> bool {
3588 if index == 0 {
3589 return false;
3590 }
3591
3592 let mut backslashes = 0usize;
3593 let mut cursor = index;
3594 while cursor > 0 && bytes[cursor - 1] == b'\\' {
3595 backslashes += 1;
3596 cursor -= 1;
3597 }
3598
3599 backslashes % 2 == 1
3600}
3601
3602fn strip_matching_quote_delimiters(raw_content: &str) -> &str {
3603 if raw_content.len() < 2 {
3604 return raw_content;
3605 }
3606
3607 let bytes = raw_content.as_bytes();
3608 match (bytes.first(), bytes.last()) {
3609 (Some(b'"'), Some(b'"')) | (Some(b'\''), Some(b'\'')) => {
3610 &raw_content[1..raw_content.len() - 1]
3611 }
3612 _ => raw_content,
3613 }
3614}
3615
3616impl IndexVisitor {
3617 fn new(document: &mut Document, uri: String, workspace_folder_uri: Option<String>) -> Self {
3618 Self {
3619 document: document.clone(),
3620 uri,
3621 current_package: Some("main".to_string()),
3622 workspace_folder_uri,
3623 }
3624 }
3625
3626 fn visit(&mut self, node: &Node, file_index: &mut FileIndex) {
3627 self.project_symbol_declarations(node, file_index);
3628 self.visit_node(node, file_index);
3629 }
3630
3631 fn project_symbol_declarations(&self, node: &Node, file_index: &mut FileIndex) {
3632 for decl in extract_symbol_decls(node, self.current_package.as_deref()) {
3633 let (start, end) = match decl.kind {
3634 SymbolKind::Variable(_) => match decl.anchor_span {
3635 Some(span) => span,
3636 None => decl.full_span,
3637 },
3638 _ => decl.full_span,
3639 };
3640 let ((start_line, start_col), (end_line, end_col)) =
3641 self.document.line_index.range(start, end);
3642 let range = Range {
3643 start: Position { byte: start, line: start_line, column: start_col },
3644 end: Position { byte: end, line: end_line, column: end_col },
3645 };
3646
3647 let symbol_name = symbol_decl_name(&decl.kind, &decl.name);
3648
3649 let qualified_name = match &decl.declarator {
3654 Some(d) if d == "my" || d == "state" => None,
3655 _ => (!decl.qualified_name.is_empty()).then_some(decl.qualified_name),
3656 };
3657
3658 let container_name = match decl.kind {
3661 SymbolKind::Package => None,
3662 _ => decl.container,
3663 };
3664
3665 file_index.symbols.push(WorkspaceSymbol {
3666 name: symbol_name.clone(),
3667 kind: decl.kind,
3668 uri: self.uri.clone(),
3669 range,
3670 qualified_name,
3671 documentation: None,
3672 container_name,
3673 has_body: true,
3674 workspace_folder_uri: self.workspace_folder_uri.clone(),
3675 });
3676
3677 file_index.references.entry(symbol_name).or_default().push(SymbolReference {
3678 uri: self.uri.clone(),
3679 range,
3680 kind: ReferenceKind::Definition,
3681 });
3682 }
3683 }
3684
3685 fn record_interpolated_variable_references(
3686 &self,
3687 raw_content: &str,
3688 range: Range,
3689 file_index: &mut FileIndex,
3690 ) {
3691 let content = strip_matching_quote_delimiters(raw_content);
3692 let bytes = content.as_bytes();
3693 let mut index = 0;
3694
3695 while index < bytes.len() {
3696 if has_escaped_interpolation_marker(bytes, index) {
3697 index += 1;
3698 continue;
3699 }
3700
3701 let sigil = match bytes[index] {
3702 b'$' => "$",
3703 b'@' => "@",
3704 _ => {
3705 index += 1;
3706 continue;
3707 }
3708 };
3709
3710 if index + 1 >= bytes.len() {
3711 break;
3712 }
3713
3714 let (start, needs_closing_brace) =
3715 if bytes[index + 1] == b'{' { (index + 2, true) } else { (index + 1, false) };
3716
3717 if start >= bytes.len() || !is_interpolated_var_start(bytes[start]) {
3718 index += 1;
3719 continue;
3720 }
3721
3722 let mut end = start + 1;
3723 while end < bytes.len() && is_interpolated_var_continue(bytes[end]) {
3724 end += 1;
3725 }
3726
3727 if needs_closing_brace && (end >= bytes.len() || bytes[end] != b'}') {
3728 index += 1;
3729 continue;
3730 }
3731
3732 if let Some(name) = content.get(start..end) {
3733 let var_name = format!("{sigil}{name}");
3734 file_index.references.entry(var_name).or_default().push(SymbolReference {
3735 uri: self.uri.clone(),
3736 range,
3737 kind: ReferenceKind::Read,
3738 });
3739 }
3740
3741 index = if needs_closing_brace { end + 1 } else { end };
3742 }
3743 }
3744
3745 fn visit_node(&mut self, node: &Node, file_index: &mut FileIndex) {
3746 match &node.kind {
3747 NodeKind::Package { name, .. } => {
3748 let package_name = name.clone();
3749
3750 self.current_package = Some(package_name.clone());
3752 }
3753
3754 NodeKind::Subroutine { body, .. } => {
3755 self.visit_node(body, file_index);
3757 }
3758
3759 NodeKind::VariableDeclaration { initializer, .. } => {
3760 if let Some(init) = initializer {
3762 self.visit_node(init, file_index);
3763 }
3764 }
3765
3766 NodeKind::VariableListDeclaration { initializer, .. } => {
3767 if let Some(init) = initializer {
3769 self.visit_node(init, file_index);
3770 }
3771 }
3772
3773 NodeKind::Variable { sigil, name } => {
3774 let var_name = format!("{}{}", sigil, name);
3775
3776 file_index.references.entry(var_name).or_default().push(SymbolReference {
3778 uri: self.uri.clone(),
3779 range: self.node_to_range(node),
3780 kind: ReferenceKind::Read, });
3782 }
3783
3784 NodeKind::FunctionCall { name, args, .. } => {
3785 let func_name = name.clone();
3786 let location = self.node_to_range(node);
3787
3788 let (pkg, bare_name) = if let Some(idx) = func_name.rfind("::") {
3790 (&func_name[..idx], &func_name[idx + 2..])
3791 } else {
3792 (self.current_package.as_deref().unwrap_or("main"), func_name.as_str())
3793 };
3794
3795 let qualified = format!("{}::{}", pkg, bare_name);
3796
3797 file_index.references.entry(bare_name.to_string()).or_default().push(
3801 SymbolReference {
3802 uri: self.uri.clone(),
3803 range: location,
3804 kind: ReferenceKind::Usage,
3805 },
3806 );
3807 file_index.references.entry(qualified).or_default().push(SymbolReference {
3808 uri: self.uri.clone(),
3809 range: location,
3810 kind: ReferenceKind::Usage,
3811 });
3812
3813 if name == "extends" || name == "with" {
3814 for module_name in extract_module_names_from_call_args(args) {
3815 file_index
3816 .dependencies
3817 .insert(normalize_dependency_module_name(&module_name));
3818 }
3819 } else if name == "require" {
3820 if let Some(module_name) = extract_module_name_from_require_args(args) {
3821 file_index
3822 .dependencies
3823 .insert(normalize_dependency_module_name(&module_name));
3824 }
3825 }
3826
3827 for arg in args {
3829 self.visit_node(arg, file_index);
3830 }
3831 }
3832
3833 NodeKind::Use { module, args, .. } => {
3834 let module_name = normalize_dependency_module_name(module);
3835 file_index.dependencies.insert(module_name.clone());
3836
3837 if module == "parent" || module == "base" {
3841 for name in extract_module_names_from_use_args(args) {
3842 file_index.dependencies.insert(normalize_dependency_module_name(&name));
3843 }
3844 }
3845
3846 file_index.references.entry(module_name).or_default().push(SymbolReference {
3848 uri: self.uri.clone(),
3849 range: self.node_to_range(node),
3850 kind: ReferenceKind::Import,
3851 });
3852 }
3853
3854 NodeKind::Assignment { lhs, rhs, op } => {
3856 let is_compound = op != "=";
3858
3859 if let NodeKind::Variable { sigil, name } = &lhs.kind {
3860 let var_name = format!("{}{}", sigil, name);
3861
3862 if is_compound {
3864 file_index.references.entry(var_name.clone()).or_default().push(
3865 SymbolReference {
3866 uri: self.uri.clone(),
3867 range: self.node_to_range(lhs),
3868 kind: ReferenceKind::Read,
3869 },
3870 );
3871 }
3872
3873 file_index.references.entry(var_name).or_default().push(SymbolReference {
3875 uri: self.uri.clone(),
3876 range: self.node_to_range(lhs),
3877 kind: ReferenceKind::Write,
3878 });
3879 }
3880
3881 self.visit_node(rhs, file_index);
3883 }
3884
3885 NodeKind::Block { statements } => {
3887 for stmt in statements {
3888 self.visit_node(stmt, file_index);
3889 }
3890 }
3891
3892 NodeKind::If { condition, then_branch, elsif_branches, else_branch, .. } => {
3893 self.visit_node(condition, file_index);
3894 self.visit_node(then_branch, file_index);
3895 for (cond, branch) in elsif_branches {
3896 self.visit_node(cond, file_index);
3897 self.visit_node(branch, file_index);
3898 }
3899 if let Some(else_br) = else_branch {
3900 self.visit_node(else_br, file_index);
3901 }
3902 }
3903
3904 NodeKind::While { condition, body, continue_block, .. } => {
3905 self.visit_node(condition, file_index);
3906 self.visit_node(body, file_index);
3907 if let Some(cont) = continue_block {
3908 self.visit_node(cont, file_index);
3909 }
3910 }
3911
3912 NodeKind::For { init, condition, update, body, continue_block } => {
3913 if let Some(i) = init {
3914 self.visit_node(i, file_index);
3915 }
3916 if let Some(c) = condition {
3917 self.visit_node(c, file_index);
3918 }
3919 if let Some(u) = update {
3920 self.visit_node(u, file_index);
3921 }
3922 self.visit_node(body, file_index);
3923 if let Some(cont) = continue_block {
3924 self.visit_node(cont, file_index);
3925 }
3926 }
3927
3928 NodeKind::Foreach { variable, list, body, continue_block } => {
3929 if let Some(cb) = continue_block {
3931 self.visit_node(cb, file_index);
3932 }
3933 if let NodeKind::Variable { sigil, name } = &variable.kind {
3934 let var_name = format!("{}{}", sigil, name);
3935 file_index.references.entry(var_name).or_default().push(SymbolReference {
3936 uri: self.uri.clone(),
3937 range: self.node_to_range(variable),
3938 kind: ReferenceKind::Write,
3939 });
3940 }
3941 self.visit_node(variable, file_index);
3942 self.visit_node(list, file_index);
3943 self.visit_node(body, file_index);
3944 }
3945
3946 NodeKind::MethodCall { object, method, args } => {
3947 let qualified_method = if let NodeKind::Identifier { name } = &object.kind {
3949 Some(format!("{}::{}", name, method))
3951 } else {
3952 None
3954 };
3955
3956 self.visit_node(object, file_index);
3958
3959 let location = self.node_to_range(node);
3966 if let Some(qualified_method) = qualified_method.as_ref() {
3967 file_index.references.entry(qualified_method.clone()).or_default().push(
3968 SymbolReference {
3969 uri: self.uri.clone(),
3970 range: location,
3971 kind: ReferenceKind::Usage,
3972 },
3973 );
3974 }
3975 file_index.references.entry(method.clone()).or_default().push(SymbolReference {
3976 uri: self.uri.clone(),
3977 range: location,
3978 kind: ReferenceKind::Usage,
3979 });
3980
3981 if method == "import"
3982 && let NodeKind::Identifier { name: module_name } = &object.kind
3983 {
3984 for symbol in extract_manual_import_symbols(args) {
3985 file_index.references.entry(symbol).or_default().push(SymbolReference {
3986 uri: self.uri.clone(),
3987 range: self.node_to_range(node),
3988 kind: ReferenceKind::Import,
3989 });
3990 }
3991 file_index.dependencies.insert(normalize_dependency_module_name(module_name));
3992 }
3993
3994 for arg in args {
3996 self.visit_node(arg, file_index);
3997 }
3998 }
3999
4000 NodeKind::No { module, .. } => {
4001 let module_name = normalize_dependency_module_name(module);
4002 file_index.dependencies.insert(module_name);
4003 }
4004
4005 NodeKind::Class { name, .. } => {
4006 self.current_package = Some(name.clone());
4007 }
4008
4009 NodeKind::Method { body, signature, .. } => {
4010 if let Some(sig) = signature {
4012 if let NodeKind::Signature { parameters } = &sig.kind {
4013 for param in parameters {
4014 self.visit_node(param, file_index);
4015 }
4016 }
4017 }
4018
4019 self.visit_node(body, file_index);
4021 }
4022
4023 NodeKind::String { value, interpolated } => {
4024 if *interpolated {
4025 let range = self.node_to_range(node);
4026 self.record_interpolated_variable_references(value, range, file_index);
4027 }
4028 }
4029
4030 NodeKind::Heredoc { content, interpolated, .. } => {
4031 if *interpolated {
4032 let range = self.node_to_range(node);
4033 self.record_interpolated_variable_references(content, range, file_index);
4034 }
4035 }
4036
4037 NodeKind::Unary { op, operand } if op == "++" || op == "--" => {
4039 if let NodeKind::Variable { sigil, name } = &operand.kind {
4041 let var_name = format!("{}{}", sigil, name);
4042
4043 file_index.references.entry(var_name.clone()).or_default().push(
4045 SymbolReference {
4046 uri: self.uri.clone(),
4047 range: self.node_to_range(operand),
4048 kind: ReferenceKind::Read,
4049 },
4050 );
4051
4052 file_index.references.entry(var_name).or_default().push(SymbolReference {
4053 uri: self.uri.clone(),
4054 range: self.node_to_range(operand),
4055 kind: ReferenceKind::Write,
4056 });
4057 }
4058 }
4059
4060 _ => {
4061 self.visit_children(node, file_index);
4063 }
4064 }
4065 }
4066
4067 fn visit_children(&mut self, node: &Node, file_index: &mut FileIndex) {
4068 match &node.kind {
4070 NodeKind::Program { statements } => {
4071 for stmt in statements {
4072 self.visit_node(stmt, file_index);
4073 }
4074 }
4075 NodeKind::ExpressionStatement { expression } => {
4076 self.visit_node(expression, file_index);
4077 }
4078 NodeKind::Unary { operand, .. } => {
4080 self.visit_node(operand, file_index);
4081 }
4082 NodeKind::Binary { left, right, .. } => {
4083 self.visit_node(left, file_index);
4084 self.visit_node(right, file_index);
4085 }
4086 NodeKind::Ternary { condition, then_expr, else_expr } => {
4087 self.visit_node(condition, file_index);
4088 self.visit_node(then_expr, file_index);
4089 self.visit_node(else_expr, file_index);
4090 }
4091 NodeKind::ArrayLiteral { elements } => {
4092 for elem in elements {
4093 self.visit_node(elem, file_index);
4094 }
4095 }
4096 NodeKind::HashLiteral { pairs } => {
4097 for (key, value) in pairs {
4098 self.visit_node(key, file_index);
4099 self.visit_node(value, file_index);
4100 }
4101 }
4102 NodeKind::Return { value } => {
4103 if let Some(val) = value {
4104 self.visit_node(val, file_index);
4105 }
4106 }
4107 NodeKind::Eval { block } | NodeKind::Do { block } | NodeKind::Defer { block } => {
4108 self.visit_node(block, file_index);
4109 }
4110 NodeKind::Try { body, catch_blocks, finally_block } => {
4111 self.visit_node(body, file_index);
4112 for (_, block) in catch_blocks {
4113 self.visit_node(block, file_index);
4114 }
4115 if let Some(finally) = finally_block {
4116 self.visit_node(finally, file_index);
4117 }
4118 }
4119 NodeKind::Given { expr, body } => {
4120 self.visit_node(expr, file_index);
4121 self.visit_node(body, file_index);
4122 }
4123 NodeKind::When { condition, body } => {
4124 self.visit_node(condition, file_index);
4125 self.visit_node(body, file_index);
4126 }
4127 NodeKind::Default { body } => {
4128 self.visit_node(body, file_index);
4129 }
4130 NodeKind::StatementModifier { statement, condition, .. } => {
4131 self.visit_node(statement, file_index);
4132 self.visit_node(condition, file_index);
4133 }
4134 NodeKind::VariableWithAttributes { variable, .. } => {
4135 self.visit_node(variable, file_index);
4136 }
4137 NodeKind::LabeledStatement { statement, .. } => {
4138 self.visit_node(statement, file_index);
4139 }
4140 _ => {
4141 }
4143 }
4144 }
4145
4146 fn node_to_range(&mut self, node: &Node) -> Range {
4147 let ((start_line, start_col), (end_line, end_col)) =
4149 self.document.line_index.range(node.location.start, node.location.end);
4150 Range {
4152 start: Position { byte: node.location.start, line: start_line, column: start_col },
4153 end: Position { byte: node.location.end, line: end_line, column: end_col },
4154 }
4155 }
4156}
4157
4158fn symbol_decl_name(kind: &SymbolKind, name: &str) -> String {
4159 match kind {
4160 SymbolKind::Variable(VarKind::Scalar) => format!("${name}"),
4161 SymbolKind::Variable(VarKind::Array) => format!("@{name}"),
4162 SymbolKind::Variable(VarKind::Hash) => format!("%{name}"),
4163 _ => name.to_string(),
4164 }
4165}
4166
4167fn split_qualified_symbol_name(canonical_name: &str) -> Option<(&str, &str)> {
4168 let (container, bare_name) = canonical_name.rsplit_once("::")?;
4169 if container.is_empty() || bare_name.is_empty() {
4170 return None;
4171 }
4172 Some((container, bare_name))
4173}
4174
4175fn is_framework_generated_member_entity(entity: &EntityFact) -> bool {
4176 entity.provenance == Provenance::FrameworkSynthesis && entity.confidence == Confidence::Medium
4177}
4178
4179fn sort_workspace_symbols(symbols: &mut [WorkspaceSymbol]) {
4180 symbols.sort_by(|left, right| {
4181 left.name
4182 .cmp(&right.name)
4183 .then_with(|| left.uri.cmp(&right.uri))
4184 .then_with(|| left.range.start.line.cmp(&right.range.start.line))
4185 .then_with(|| left.range.start.column.cmp(&right.range.start.column))
4186 .then_with(|| left.range.end.line.cmp(&right.range.end.line))
4187 .then_with(|| left.range.end.column.cmp(&right.range.end.column))
4188 });
4189}
4190
4191fn extract_module_names_from_use_args(args: &[String]) -> Vec<String> {
4201 use std::collections::HashSet;
4202
4203 fn normalize_module_name(token: &str) -> Option<&str> {
4204 let stripped = token.trim_matches(|c: char| {
4205 matches!(c, '\'' | '"' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ';')
4206 });
4207
4208 if stripped.is_empty() || stripped.starts_with('-') {
4209 return None;
4210 }
4211
4212 stripped
4213 .chars()
4214 .all(|c| c.is_alphanumeric() || c == '_' || c == ':' || c == '\'')
4215 .then_some(stripped)
4216 }
4217
4218 let joined = args.join(" ");
4219
4220 let (qw_words, remainder) = extract_qw_words(&joined);
4221 let mut modules = Vec::new();
4222 let mut seen = HashSet::new();
4223 for word in qw_words {
4224 if let Some(candidate) = normalize_module_name(&word) {
4225 let canonical = canonicalize_perl_module_name(candidate);
4226 if seen.insert(canonical.clone()) {
4227 modules.push(canonical);
4228 }
4229 }
4230 }
4231
4232 for token in remainder.split_whitespace().flat_map(|t| t.split(',')) {
4233 if let Some(candidate) = normalize_module_name(token) {
4234 let canonical = canonicalize_perl_module_name(candidate);
4235 if seen.insert(canonical.clone()) {
4236 modules.push(canonical);
4237 }
4238 }
4239 }
4240
4241 modules
4242}
4243
4244fn extract_module_names_from_call_args(args: &[Node]) -> Vec<String> {
4245 fn collect_from_node(node: &Node, out: &mut Vec<String>) {
4246 match &node.kind {
4247 NodeKind::String { value, .. } => {
4248 out.extend(extract_module_names_from_use_args(std::slice::from_ref(value)));
4249 }
4250 NodeKind::Identifier { name } => {
4251 out.extend(extract_module_names_from_use_args(std::slice::from_ref(name)));
4252 }
4253 NodeKind::ArrayLiteral { elements } => {
4254 for element in elements {
4255 collect_from_node(element, out);
4256 }
4257 }
4258 NodeKind::FunctionCall { name, args, .. } if name == "qw" => {
4259 for arg in args {
4260 collect_from_node(arg, out);
4261 }
4262 }
4263 _ => {}
4264 }
4265 }
4266
4267 let mut modules = Vec::new();
4268 for arg in args {
4269 collect_from_node(arg, &mut modules);
4270 }
4271 modules
4272}
4273
4274fn canonicalize_perl_module_name(name: &str) -> String {
4275 name.replace('\'', "::")
4278}
4279
4280fn legacy_perl_module_name(name: &str) -> String {
4281 name.replace("::", "'")
4282}
4283
4284fn normalize_dependency_module_name(module_name: &str) -> String {
4287 canonicalize_perl_module_name(module_name)
4288}
4289
4290fn extract_qw_words(input: &str) -> (Vec<String>, String) {
4291 let chars: Vec<char> = input.chars().collect();
4292 let mut i = 0;
4293 let mut words = Vec::new();
4294 let mut remainder = String::new();
4295
4296 while i < chars.len() {
4297 if chars[i] == 'q'
4298 && i + 1 < chars.len()
4299 && chars[i + 1] == 'w'
4300 && (i == 0 || !chars[i - 1].is_alphanumeric())
4301 {
4302 let mut j = i + 2;
4303 while j < chars.len() && chars[j].is_whitespace() {
4304 j += 1;
4305 }
4306 if j >= chars.len() {
4307 remainder.push(chars[i]);
4308 i += 1;
4309 continue;
4310 }
4311
4312 let open = chars[j];
4313 let (close, is_paired_delimiter) = match open {
4314 '(' => (')', true),
4315 '[' => (']', true),
4316 '{' => ('}', true),
4317 '<' => ('>', true),
4318 _ => (open, false),
4319 };
4320 if open.is_alphanumeric() || open == '_' || open == '\'' || open == '"' {
4321 remainder.push(chars[i]);
4322 i += 1;
4323 continue;
4324 }
4325
4326 let mut k = j + 1;
4327 if is_paired_delimiter {
4328 let mut depth = 1usize;
4329 while k < chars.len() && depth > 0 {
4330 if chars[k] == open {
4331 depth += 1;
4332 } else if chars[k] == close {
4333 depth -= 1;
4334 }
4335 k += 1;
4336 }
4337 if depth != 0 {
4338 remainder.extend(chars[i..].iter());
4339 break;
4340 }
4341 k -= 1;
4342 } else {
4343 while k < chars.len() && chars[k] != close {
4344 k += 1;
4345 }
4346 if k >= chars.len() {
4347 remainder.extend(chars[i..].iter());
4348 break;
4349 }
4350 }
4351
4352 let content: String = chars[j + 1..k].iter().collect();
4353 for word in content.split_whitespace() {
4354 if !word.is_empty() {
4355 words.push(word.to_string());
4356 }
4357 }
4358 i = k + 1;
4359 continue;
4360 }
4361
4362 remainder.push(chars[i]);
4363 i += 1;
4364 }
4365
4366 (words, remainder)
4367}
4368
4369fn extract_module_name_from_require_args(args: &[Node]) -> Option<String> {
4370 let first = args.first()?;
4371 match &first.kind {
4372 NodeKind::Identifier { name } => Some(name.clone()),
4373 NodeKind::String { value, .. } => {
4374 let cleaned = value.trim_matches('\'').trim_matches('"').trim();
4375 if cleaned.is_empty() {
4376 return None;
4377 }
4378 Some(cleaned.trim_end_matches(".pm").replace('/', "::"))
4379 }
4380 _ => None,
4381 }
4382}
4383
4384fn extract_manual_import_symbols(args: &[Node]) -> Vec<String> {
4385 fn push_if_bareword(out: &mut Vec<String>, token: &str) {
4386 let bare = token.trim().trim_matches('"').trim_matches('\'').trim();
4387 if bare.is_empty() || bare == "," {
4388 return;
4389 }
4390 let is_bareword = bare.bytes().all(|ch| ch.is_ascii_alphanumeric() || ch == b'_')
4391 && bare.as_bytes().first().is_some_and(|ch| ch.is_ascii_alphabetic() || *ch == b'_');
4392 if is_bareword {
4393 out.push(bare.to_string());
4394 }
4395 }
4396
4397 let mut symbols = Vec::new();
4398 for arg in args {
4399 match &arg.kind {
4400 NodeKind::String { value, .. } => push_if_bareword(&mut symbols, value),
4401 NodeKind::Identifier { name } => {
4402 if name.starts_with("qw") {
4403 let content = name
4404 .trim_start_matches("qw")
4405 .trim_start_matches(|c: char| "([{/<|!".contains(c))
4406 .trim_end_matches(|c: char| ")]}/|!>".contains(c));
4407 for token in content.split_whitespace() {
4408 push_if_bareword(&mut symbols, token);
4409 }
4410 } else {
4411 push_if_bareword(&mut symbols, name);
4412 }
4413 }
4414 NodeKind::ArrayLiteral { elements } => {
4415 for element in elements {
4416 if let NodeKind::String { value, .. } = &element.kind {
4417 push_if_bareword(&mut symbols, value);
4418 }
4419 }
4420 }
4421 _ => {}
4422 }
4423 }
4424 symbols.sort();
4425 symbols.dedup();
4426 symbols
4427}
4428
4429#[cfg(test)]
4447fn extract_constant_names_from_use_args(args: &[String]) -> Vec<String> {
4448 use std::collections::HashSet;
4449
4450 fn push_unique(names: &mut Vec<String>, seen: &mut HashSet<String>, candidate: &str) {
4451 if seen.insert(candidate.to_string()) {
4452 names.push(candidate.to_string());
4453 }
4454 }
4455
4456 fn normalize_constant_name(token: &str) -> Option<&str> {
4457 let stripped = token.trim_matches(|c: char| {
4458 matches!(c, '\'' | '"' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ';')
4459 });
4460
4461 if stripped.is_empty() || stripped.starts_with('-') {
4462 return None;
4463 }
4464
4465 stripped.chars().all(|c| c.is_alphanumeric() || c == '_').then_some(stripped)
4466 }
4467
4468 let mut names = Vec::new();
4469 let mut seen = HashSet::new();
4470
4471 let first = match args.first() {
4475 Some(f) => f.as_str(),
4476 None => return names,
4477 };
4478
4479 if first.starts_with("qw") {
4481 let (qw_words, remainder) = extract_qw_words(first);
4482 if remainder.trim().is_empty() {
4483 for word in qw_words {
4484 if let Some(candidate) = normalize_constant_name(&word) {
4485 push_unique(&mut names, &mut seen, candidate);
4486 }
4487 }
4488 return names;
4489 }
4490
4491 let content = first.trim_start_matches("qw").trim_start();
4493 let content = content
4494 .trim_start_matches(|c: char| "([{/<|!".contains(c))
4495 .trim_end_matches(|c: char| ")]}/|!>".contains(c));
4496 for word in content.split_whitespace() {
4497 if let Some(candidate) = normalize_constant_name(word) {
4498 push_unique(&mut names, &mut seen, candidate);
4499 }
4500 }
4501 return names;
4502 }
4503
4504 let starts_hash_form = first == "{"
4506 || first == "+{"
4507 || (first == "+" && args.get(1).map(String::as_str) == Some("{"));
4508 if starts_hash_form {
4509 let mut skipped_leading_plus = false;
4510 let mut iter = args.iter().peekable();
4511 while let Some(arg) = iter.next() {
4512 if arg == "+{" {
4515 skipped_leading_plus = true;
4516 continue;
4517 }
4518 if arg == "+" && !skipped_leading_plus {
4519 skipped_leading_plus = true;
4520 continue;
4521 }
4522 if arg == "{" || arg == "}" || arg == "," || arg == "=>" {
4523 continue;
4524 }
4525 if let Some(candidate) = normalize_constant_name(arg)
4526 && iter.peek().map(|s| s.as_str()) == Some("=>")
4527 {
4528 push_unique(&mut names, &mut seen, candidate);
4529 }
4530 }
4531 return names;
4532 }
4533
4534 if let Some(candidate) = normalize_constant_name(first) {
4537 push_unique(&mut names, &mut seen, candidate);
4538 }
4539
4540 names
4541}
4542
4543impl Default for WorkspaceIndex {
4544 fn default() -> Self {
4545 Self::new()
4546 }
4547}
4548
4549#[cfg(all(feature = "workspace", feature = "lsp-compat"))]
4551pub mod lsp_adapter {
4553 use super::Location as IxLocation;
4554 use lsp_types::Location as LspLocation;
4555 type LspUrl = lsp_types::Uri;
4557
4558 pub fn to_lsp_location(ix: &IxLocation) -> Option<LspLocation> {
4578 parse_url(&ix.uri).map(|uri| {
4579 let start =
4580 lsp_types::Position { line: ix.range.start.line, character: ix.range.start.column };
4581 let end =
4582 lsp_types::Position { line: ix.range.end.line, character: ix.range.end.column };
4583 let range = lsp_types::Range { start, end };
4584 LspLocation { uri, range }
4585 })
4586 }
4587
4588 pub fn to_lsp_locations(all: impl IntoIterator<Item = IxLocation>) -> Vec<LspLocation> {
4609 all.into_iter().filter_map(|ix| to_lsp_location(&ix)).collect()
4610 }
4611
4612 #[cfg(not(target_arch = "wasm32"))]
4613 fn parse_url(s: &str) -> Option<LspUrl> {
4614 use std::str::FromStr;
4616
4617 LspUrl::from_str(s).ok().or_else(|| {
4619 std::path::Path::new(s).canonicalize().ok().and_then(|p| {
4621 crate::workspace_index::fs_path_to_uri(&p)
4623 .ok()
4624 .and_then(|uri_string| LspUrl::from_str(&uri_string).ok())
4625 })
4626 })
4627 }
4628
4629 #[cfg(target_arch = "wasm32")]
4631 fn parse_url(s: &str) -> Option<LspUrl> {
4632 use std::str::FromStr;
4633 LspUrl::from_str(s).ok()
4634 }
4635}
4636
4637#[cfg(test)]
4638mod tests {
4639 use super::*;
4640 use perl_tdd_support::{must, must_some};
4641
4642 #[test]
4643 fn test_use_constant_indexed_as_constant_symbol() {
4644 let index = WorkspaceIndex::new();
4645 let uri = "file:///lib/My/Config.pm";
4646 let code = r#"package My::Config;
4647use constant PI => 3.14159;
4648use constant {
4649 MAX_RETRIES => 3,
4650 TIMEOUT => 30,
4651};
46521;
4653"#;
4654 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4655
4656 let symbols = index.file_symbols(uri);
4657 assert!(
4658 symbols.iter().any(|s| s.name == "PI" && s.kind == SymbolKind::Constant),
4659 "PI should be indexed as a Constant symbol; got: {:?}",
4660 symbols.iter().map(|s| (&s.name, &s.kind)).collect::<Vec<_>>()
4661 );
4662 assert!(
4663 symbols.iter().any(|s| s.name == "MAX_RETRIES" && s.kind == SymbolKind::Constant),
4664 "MAX_RETRIES should be indexed"
4665 );
4666 assert!(
4667 symbols.iter().any(|s| s.name == "TIMEOUT" && s.kind == SymbolKind::Constant),
4668 "TIMEOUT should be indexed"
4669 );
4670
4671 let def = index.find_definition("My::Config::PI");
4673 assert!(def.is_some(), "find_definition('My::Config::PI') should succeed");
4674 }
4675
4676 #[test]
4677 fn test_extract_constant_names_deduplicates_qw_form() {
4678 let names = extract_constant_names_from_use_args(&["qw(FOO BAR FOO)".to_string()]);
4679 assert_eq!(names, vec!["FOO", "BAR"]);
4680 }
4681
4682 #[test]
4683 fn test_extract_constant_names_accepts_quoted_scalar_form() {
4684 let names = extract_constant_names_from_use_args(&[
4685 "'HTTP_OK'".to_string(),
4686 "=>".to_string(),
4687 "200".to_string(),
4688 ]);
4689 assert_eq!(names, vec!["HTTP_OK"]);
4690 }
4691
4692 #[test]
4693 fn search_symbols_returns_labeled_generated_framework_members()
4694 -> Result<(), Box<dyn std::error::Error>> {
4695 let index = WorkspaceIndex::new();
4696 let uri = "file:///lib/Generated/Pilot.pm";
4697 let code = r#"package Generated::Pilot;
4698use Moo;
4699has display_name => (is => 'rw');
47001;
4701"#;
4702 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4703
4704 let source_symbols = index.search_source_symbols("display_name");
4705 assert!(
4706 source_symbols.is_empty(),
4707 "generated framework members must not enter the exact source-symbol slice"
4708 );
4709 let trimmed_source_symbols = index.search_source_symbols(" display_name ");
4710 assert!(
4711 trimmed_source_symbols.is_empty(),
4712 "trimmed generated framework member queries must not enter the exact source-symbol slice"
4713 );
4714
4715 let generated_symbols = index.search_generated_workspace_symbols("display_name");
4716 assert_eq!(generated_symbols.len(), 1);
4717 let trimmed_generated_symbols =
4718 index.search_generated_workspace_symbols(" display_name ");
4719 assert_eq!(trimmed_generated_symbols.len(), 1);
4720 assert_eq!(trimmed_generated_symbols[0].name, "display_name [generated/framework]");
4721 assert!(index.search_generated_workspace_symbols(" ").is_empty());
4722 let symbol = &generated_symbols[0];
4723 assert_eq!(symbol.name, "display_name [generated/framework]");
4724 assert_eq!(symbol.kind, SymbolKind::Method);
4725 assert_eq!(symbol.qualified_name.as_deref(), Some("Generated::Pilot::display_name"));
4726 assert_eq!(
4727 symbol.container_name.as_deref(),
4728 Some("Generated::Pilot [generated/framework]")
4729 );
4730 assert!(!symbol.has_body);
4731 assert_eq!(symbol.uri, uri);
4732 assert!(
4733 symbol.range.end.byte > symbol.range.start.byte,
4734 "generated symbol must be anchored to the source framework declaration"
4735 );
4736
4737 let live_symbols = index.search_symbols("display_name");
4738 assert!(
4739 live_symbols.is_empty(),
4740 "general workspace index search must stay source-backed; generated pilot symbols are opt-in"
4741 );
4742
4743 {
4744 let mut shards = index.fact_shards.write();
4745 let shard = shards.values_mut().next().ok_or("missing generated-member shard")?;
4746 let entity = shard
4747 .entities
4748 .iter_mut()
4749 .find(|entity| entity.canonical_name == "Generated::Pilot::display_name")
4750 .ok_or("missing generated member entity")?;
4751 entity.provenance = Provenance::ExactAst;
4752 }
4753 let non_framework_symbols = index.search_generated_workspace_symbols("display_name");
4754 assert!(
4755 non_framework_symbols.is_empty(),
4756 "generated workspace-symbol pilot must require framework-synthesis provenance"
4757 );
4758 Ok(())
4759 }
4760
4761 #[test]
4762 fn search_symbols_returns_labeled_predicate_generated_members()
4763 -> Result<(), Box<dyn std::error::Error>> {
4764 let index = WorkspaceIndex::new();
4765 let uri = "file:///lib/Generated/PredicatePilot.pm";
4766 let code = r#"package Generated::PredicatePilot;
4767use Moo;
4768has status => (is => 'rw', predicate => 1);
47691;
4770"#;
4771 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4772
4773 let source_symbols = index.search_source_symbols("has_status");
4774 assert!(
4775 source_symbols.is_empty(),
4776 "predicate generated members must not enter the exact source-symbol slice"
4777 );
4778
4779 let generated_symbols = index.search_generated_workspace_symbols("has_status");
4780 assert_eq!(generated_symbols.len(), 1);
4781 let symbol = &generated_symbols[0];
4782 assert_eq!(symbol.name, "has_status [generated/framework]");
4783 assert_eq!(symbol.kind, SymbolKind::Method);
4784 assert_eq!(symbol.qualified_name.as_deref(), Some("Generated::PredicatePilot::has_status"));
4785 assert_eq!(
4786 symbol.container_name.as_deref(),
4787 Some("Generated::PredicatePilot [generated/framework]")
4788 );
4789 assert!(!symbol.has_body);
4790 assert_eq!(symbol.uri, uri);
4791 assert!(
4792 symbol.range.end.byte > symbol.range.start.byte,
4793 "predicate generated symbol must be anchored to the source framework declaration"
4794 );
4795
4796 let live_symbols = index.search_symbols("has_status");
4797 assert!(
4798 live_symbols.is_empty(),
4799 "general workspace index search must stay source-backed for predicate generated members"
4800 );
4801 Ok(())
4802 }
4803
4804 #[test]
4805 fn test_extract_constant_names_accepts_quoted_hash_form() {
4806 let names = extract_constant_names_from_use_args(&[
4807 "{".to_string(),
4808 "'FOO'".to_string(),
4809 "=>".to_string(),
4810 "1".to_string(),
4811 ",".to_string(),
4812 "\"BAR\"".to_string(),
4813 "=>".to_string(),
4814 "2".to_string(),
4815 "}".to_string(),
4816 ]);
4817 assert_eq!(names, vec!["FOO", "BAR"]);
4818 }
4819
4820 #[test]
4821 fn test_extract_constant_names_accepts_plus_hash_form_split_tokens() {
4822 let names = extract_constant_names_from_use_args(&[
4823 "+".to_string(),
4824 "{".to_string(),
4825 "FOO".to_string(),
4826 "=>".to_string(),
4827 "1".to_string(),
4828 ",".to_string(),
4829 "BAR".to_string(),
4830 "=>".to_string(),
4831 "2".to_string(),
4832 "}".to_string(),
4833 ]);
4834 assert_eq!(names, vec!["FOO", "BAR"]);
4835 }
4836
4837 #[test]
4838 fn test_extract_constant_names_accepts_plus_hash_form_combined_token() {
4839 let names = extract_constant_names_from_use_args(&[
4840 "+{".to_string(),
4841 "FOO".to_string(),
4842 "=>".to_string(),
4843 "1".to_string(),
4844 ",".to_string(),
4845 "BAR".to_string(),
4846 "=>".to_string(),
4847 "2".to_string(),
4848 "}".to_string(),
4849 ]);
4850 assert_eq!(names, vec!["FOO", "BAR"]);
4851 }
4852 #[test]
4853 fn test_use_constant_duplicate_names_indexed_once() {
4854 let index = WorkspaceIndex::new();
4855 let uri = "file:///lib/My/DedupConfig.pm";
4856 let code = r#"package My::DedupConfig;
4857use constant {
4858 RETRY_COUNT => 3,
4859 RETRY_COUNT => 5,
4860};
48611;
4862"#;
4863 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4864
4865 let symbols = index.file_symbols(uri);
4866 let retry_count_symbols = symbols.iter().filter(|s| s.name == "RETRY_COUNT").count();
4867 assert_eq!(
4868 retry_count_symbols, 1,
4869 "RETRY_COUNT should be indexed once even when repeated in use constant hash form"
4870 );
4871 }
4872
4873 #[test]
4874 fn test_use_constant_plus_hash_form_indexes_keys() {
4875 let index = WorkspaceIndex::new();
4876 let uri = "file:///lib/My/PlusHash.pm";
4877 let code = r#"package My::PlusHash;
4878use constant +{
4879 FOO => 1,
4880 BAR => 2,
4881};
48821;
4883"#;
4884 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4885
4886 assert!(index.find_definition("My::PlusHash::FOO").is_some());
4887 assert!(index.find_definition("My::PlusHash::BAR").is_some());
4888 }
4889
4890 #[test]
4891 fn test_basic_indexing() {
4892 let index = WorkspaceIndex::new();
4893 let uri = "file:///test.pl";
4894
4895 let code = r#"
4896package MyPackage;
4897
4898sub hello {
4899 print "Hello";
4900}
4901
4902my $var = 42;
4903"#;
4904
4905 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4906
4907 let symbols = index.file_symbols(uri);
4909 assert!(symbols.iter().any(|s| s.name == "MyPackage" && s.kind == SymbolKind::Package));
4910 assert!(symbols.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
4911 assert!(symbols.iter().any(|s| s.name == "$var" && s.kind.is_variable()));
4912 }
4913
4914 #[test]
4915 fn test_package_symbol_has_no_container_name() {
4916 let index = WorkspaceIndex::new();
4921 let uri = "file:///lib/Foo.pm";
4922 let code = "package Foo;\nsub bar { }\n";
4923 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4924
4925 let symbols = index.file_symbols(uri);
4926 let pkg_sym =
4927 must_some(symbols.iter().find(|s| s.name == "Foo" && s.kind == SymbolKind::Package));
4928 assert_eq!(
4929 pkg_sym.container_name, None,
4930 "Package symbol must not carry a container (was 'main')"
4931 );
4932 }
4933
4934 #[test]
4935 fn test_file_packages_returns_only_package_symbol_names() {
4936 let index = WorkspaceIndex::new();
4937 let uri = "file:///lib/OnlyPackages.pm";
4938 let code = "package Foo;\nsub hello { 1 }\npackage Bar { sub greet { 2 } }\n";
4939 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4940
4941 let mut package_names = index.file_packages(uri);
4942 package_names.sort();
4943 let mut expected_package_names: Vec<String> = index
4944 .file_symbols(uri)
4945 .into_iter()
4946 .filter(|s| s.kind == SymbolKind::Package)
4947 .map(|s| s.name)
4948 .collect();
4949 expected_package_names.sort();
4950
4951 assert_eq!(package_names, expected_package_names);
4952 assert_eq!(package_names, vec!["Bar", "Foo"]);
4953 assert!(!package_names.iter().any(|name| name == "hello"));
4954 assert!(!package_names.iter().any(|name| name == "greet"));
4955 }
4956
4957 #[test]
4958 fn test_file_package_symbols_returns_exact_container_match() {
4959 let index = WorkspaceIndex::new();
4960 let uri = "file:///lib/PackageMembers.pm";
4961 let code = "package Foo;\nsub hello { 1 }\npackage Bar;\nsub greet { 2 }\n";
4962 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4963
4964 let all_symbols = index.file_symbols(uri);
4965 let package_name = "Bar";
4966 let greet_symbol = must_some(all_symbols.iter().find(|s| s.name == "greet"));
4967 let bar_package = must_some(
4968 all_symbols.iter().find(|s| s.name == "Bar" && s.kind == SymbolKind::Package),
4969 );
4970 assert!(WorkspaceIndex::symbol_belongs_to_package(greet_symbol, package_name));
4971 assert!(!WorkspaceIndex::symbol_belongs_to_package(greet_symbol, "Foo"));
4972 assert!(!WorkspaceIndex::symbol_belongs_to_package(bar_package, package_name));
4973
4974 let mut expected_bar_names: Vec<String> = all_symbols
4975 .iter()
4976 .filter(|s| s.container_name.as_deref() == Some(package_name))
4977 .map(|s| s.name.clone())
4978 .collect();
4979 expected_bar_names.sort();
4980
4981 let mut bar_names: Vec<String> =
4982 index.file_package_symbols(uri, package_name).into_iter().map(|s| s.name).collect();
4983 bar_names.sort();
4984 assert_eq!(bar_names, expected_bar_names);
4985 assert_eq!(bar_names, vec!["greet"]);
4986
4987 let mut foo_names: Vec<String> =
4988 index.file_package_symbols(uri, "Foo").into_iter().map(|s| s.name).collect();
4989 foo_names.sort();
4990 assert_eq!(foo_names, vec!["hello"]);
4991 assert!(index.file_package_symbols(uri, "Missing").is_empty());
4992 }
4993
4994 #[test]
4995 fn test_my_variable_has_no_qualified_name() {
4996 let index = WorkspaceIndex::new();
5001 let uri = "file:///lib/Foo.pm";
5002 let code = "package Foo;\nsub bar { my $x = 1; }\n";
5003 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
5004
5005 let symbols = index.file_symbols(uri);
5006 let var_sym = must_some(symbols.iter().find(|s| s.name == "$x" && s.kind.is_variable()));
5007 assert_eq!(var_sym.qualified_name, None, "my variable must not have a qualified_name");
5008
5009 assert!(
5011 index.find_definition("Foo::x").is_none(),
5012 "find_definition(\"Foo::x\") must not return a lexical my variable"
5013 );
5014 }
5015
5016 fn reference_kinds_for(
5017 index: &WorkspaceIndex,
5018 uri: &str,
5019 symbol_name: &str,
5020 ) -> Vec<ReferenceKind> {
5021 let files = index.files.read();
5022 let file = must_some(files.get(uri));
5023 file.references
5024 .get(symbol_name)
5025 .map(|refs| refs.iter().map(|r| r.kind).collect())
5026 .unwrap_or_default()
5027 }
5028
5029 #[test]
5030 fn test_reference_kinds_sub_definition_and_call_are_distinct() {
5031 let index = WorkspaceIndex::new();
5032 let uri = "file:///typed-refs-sub.pl";
5033 let code = "package TypedRefs;
5034sub foo { return 1; }
5035foo();
5036";
5037 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
5038
5039 let kinds = reference_kinds_for(&index, uri, "foo");
5040 assert!(kinds.contains(&ReferenceKind::Definition));
5041 assert!(kinds.contains(&ReferenceKind::Usage));
5042 }
5043
5044 #[test]
5045 fn test_reference_kinds_variable_read_and_write_are_distinct() {
5046 let index = WorkspaceIndex::new();
5047 let uri = "file:///typed-refs-var.pl";
5048 let code = "my $value = 1;
5049$value = 2;
5050print $value;
5051";
5052 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
5053
5054 let kinds = reference_kinds_for(&index, uri, "$value");
5055 assert!(kinds.contains(&ReferenceKind::Definition));
5056 assert!(kinds.contains(&ReferenceKind::Write));
5057 assert!(kinds.contains(&ReferenceKind::Read));
5058 }
5059
5060 #[test]
5061 fn test_reference_kinds_import_parent_and_export_ok_are_currently_import_only() {
5062 let index = WorkspaceIndex::new();
5063 let uri = "file:///typed-refs-import-export.pm";
5064 let code = "package Child;
5065use parent 'Base';
5066our @EXPORT_OK = qw(foo);
50671;
5068";
5069 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
5070
5071 let parent_kinds = reference_kinds_for(&index, uri, "Base");
5072 assert!(
5073 parent_kinds.is_empty(),
5074 "use parent inheritance edges are currently not stored as typed references"
5075 );
5076
5077 let export_symbol_kinds = reference_kinds_for(&index, uri, "foo");
5078 assert!(
5079 export_symbol_kinds.is_empty(),
5080 "EXPORT_OK entries are currently not represented as reference edges"
5081 );
5082 }
5083
5084 #[test]
5085 fn test_reference_kinds_dynamic_and_meta_edges_are_not_typed_yet() {
5086 let index = WorkspaceIndex::new();
5087 let uri = "file:///typed-refs-dynamic.pl";
5088 let code = r#"package TypedRefs;
5089sub foo { 1 }
5090&foo;
5091my $code = \&foo;
5092goto &foo;
5093*alias = \&foo;
5094eval "foo()";
5095with 'RoleName';
5096has 'name' => (is => 'ro');
50971;
5098"#;
5099 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
5100
5101 let foo_kinds = reference_kinds_for(&index, uri, "foo");
5102 assert!(
5103 foo_kinds
5104 .iter()
5105 .all(|kind| matches!(kind, ReferenceKind::Definition | ReferenceKind::Usage)),
5106 r"dynamic call forms (&foo, \&foo, goto &foo) are currently flattened to Usage"
5107 );
5108
5109 assert!(
5110 reference_kinds_for(&index, uri, "RoleName").is_empty(),
5111 "role composition edges (`with 'RoleName'`) are not indexed as typed references yet"
5112 );
5113 }
5114
5115 #[test]
5116 fn test_find_references() {
5117 let index = WorkspaceIndex::new();
5118 let uri = "file:///test.pl";
5119
5120 let code = r#"
5121sub test {
5122 my $x = 1;
5123 $x = 2;
5124 print $x;
5125}
5126"#;
5127
5128 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
5129
5130 let refs = index.find_references("$x");
5131 assert!(refs.len() >= 2); }
5133
5134 #[test]
5135 fn test_find_references_bare_name_includes_qualified_calls() {
5136 let index = WorkspaceIndex::new();
5137 let uri = "file:///refs.pl";
5138 let code = r#"
5139package RefDemo;
5140sub helper {
5141 return 1;
5142}
5143
5144helper();
5145RefDemo::helper();
5146"#;
5147
5148 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
5149
5150 let bare_refs = index.find_references("helper");
5151 let qualified_refs = index.find_references("RefDemo::helper");
5152
5153 assert!(
5154 bare_refs.len() >= qualified_refs.len(),
5155 "bare-name reference lookup should include qualified calls"
5156 );
5157 }
5158
5159 #[test]
5160 fn test_count_usages_bare_name_includes_qualified_calls() {
5161 let index = WorkspaceIndex::new();
5162 let uri = "file:///usage.pl";
5163 let code = r#"
5164package UsageDemo;
5165sub helper {
5166 return 1;
5167}
5168
5169helper();
5170UsageDemo::helper();
5171"#;
5172
5173 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
5174
5175 let bare_usage_count = index.count_usages("helper");
5176 let qualified_usage_count = index.count_usages("UsageDemo::helper");
5177
5178 assert!(
5179 bare_usage_count >= qualified_usage_count,
5180 "bare-name usage count should include qualified call sites"
5181 );
5182 }
5183
5184 #[test]
5185 fn test_dependencies() {
5186 let index = WorkspaceIndex::new();
5187 let uri = "file:///test.pl";
5188
5189 let code = r#"
5190use strict;
5191use warnings;
5192use Data::Dumper;
5193"#;
5194
5195 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
5196
5197 let deps = index.file_dependencies(uri);
5198 assert!(deps.contains("strict"));
5199 assert!(deps.contains("warnings"));
5200 assert!(deps.contains("Data::Dumper"));
5201 }
5202
5203 #[test]
5204 fn test_uri_to_fs_path_basic() {
5205 if let Some(path) = uri_to_fs_path("file:///tmp/test.pl") {
5207 assert_eq!(path, std::path::PathBuf::from("/tmp/test.pl"));
5208 }
5209
5210 assert!(uri_to_fs_path("not-a-uri").is_none());
5212
5213 assert!(uri_to_fs_path("http://example.com").is_none());
5215 }
5216
5217 #[test]
5218 fn test_uri_to_fs_path_with_spaces() {
5219 if let Some(path) = uri_to_fs_path("file:///tmp/path%20with%20spaces/test.pl") {
5221 assert_eq!(path, std::path::PathBuf::from("/tmp/path with spaces/test.pl"));
5222 }
5223
5224 if let Some(path) = uri_to_fs_path("file:///tmp/My%20Documents/test%20file.pl") {
5226 assert_eq!(path, std::path::PathBuf::from("/tmp/My Documents/test file.pl"));
5227 }
5228 }
5229
5230 #[test]
5231 fn test_uri_to_fs_path_with_unicode() {
5232 if let Some(path) = uri_to_fs_path("file:///tmp/caf%C3%A9/test.pl") {
5234 assert_eq!(path, std::path::PathBuf::from("/tmp/café/test.pl"));
5235 }
5236
5237 if let Some(path) = uri_to_fs_path("file:///tmp/emoji%F0%9F%98%80/test.pl") {
5239 assert_eq!(path, std::path::PathBuf::from("/tmp/emoji😀/test.pl"));
5240 }
5241 }
5242
5243 #[test]
5244 fn test_fs_path_to_uri_basic() {
5245 let result = fs_path_to_uri("/tmp/test.pl");
5247 assert!(result.is_ok());
5248 let uri = must(result);
5249 assert!(uri.starts_with("file://"));
5250 assert!(uri.contains("/tmp/test.pl"));
5251 }
5252
5253 #[test]
5254 fn test_fs_path_to_uri_with_spaces() {
5255 let result = fs_path_to_uri("/tmp/path with spaces/test.pl");
5257 assert!(result.is_ok());
5258 let uri = must(result);
5259 assert!(uri.starts_with("file://"));
5260 assert!(uri.contains("path%20with%20spaces"));
5262 }
5263
5264 #[test]
5265 fn test_fs_path_to_uri_with_unicode() {
5266 let result = fs_path_to_uri("/tmp/café/test.pl");
5268 assert!(result.is_ok());
5269 let uri = must(result);
5270 assert!(uri.starts_with("file://"));
5271 assert!(uri.contains("caf%C3%A9"));
5273 }
5274
5275 #[test]
5276 fn test_normalize_uri_file_schemes() {
5277 let uri = WorkspaceIndex::normalize_uri("file:///tmp/test.pl");
5279 assert_eq!(uri, "file:///tmp/test.pl");
5280
5281 let uri = WorkspaceIndex::normalize_uri("file:///tmp/path%20with%20spaces/test.pl");
5283 assert_eq!(uri, "file:///tmp/path%20with%20spaces/test.pl");
5284 }
5285
5286 #[test]
5287 fn test_normalize_uri_absolute_paths() {
5288 let uri = WorkspaceIndex::normalize_uri("/tmp/test.pl");
5290 assert!(uri.starts_with("file://"));
5291 assert!(uri.contains("/tmp/test.pl"));
5292 }
5293
5294 #[test]
5295 fn test_normalize_uri_special_schemes() {
5296 let uri = WorkspaceIndex::normalize_uri("untitled:Untitled-1");
5298 assert_eq!(uri, "untitled:Untitled-1");
5299 }
5300
5301 #[test]
5302 fn test_roundtrip_conversion() {
5303 let original_uri = "file:///tmp/path%20with%20spaces/caf%C3%A9.pl";
5305
5306 if let Some(path) = uri_to_fs_path(original_uri) {
5307 if let Ok(converted_uri) = fs_path_to_uri(&path) {
5308 assert!(converted_uri.starts_with("file://"));
5310
5311 if let Some(roundtrip_path) = uri_to_fs_path(&converted_uri) {
5313 #[cfg(windows)]
5314 if let Ok(rootless) = path.strip_prefix(std::path::Path::new(r"\")) {
5315 assert!(roundtrip_path.ends_with(rootless));
5316 } else {
5317 assert_eq!(path, roundtrip_path);
5318 }
5319
5320 #[cfg(not(windows))]
5321 assert_eq!(path, roundtrip_path);
5322 }
5323 }
5324 }
5325 }
5326
5327 #[cfg(target_os = "windows")]
5328 #[test]
5329 fn test_windows_paths() {
5330 let result = fs_path_to_uri(r"C:\Users\test\Documents\script.pl");
5332 assert!(result.is_ok());
5333 let uri = must(result);
5334 assert!(uri.starts_with("file://"));
5335
5336 let result = fs_path_to_uri(r"C:\Program Files\My App\script.pl");
5338 assert!(result.is_ok());
5339 let uri = must(result);
5340 assert!(uri.starts_with("file://"));
5341 assert!(uri.contains("Program%20Files"));
5342 }
5343
5344 #[test]
5349 fn test_coordinator_initial_state() {
5350 let coordinator = IndexCoordinator::new();
5351 assert!(matches!(
5352 coordinator.state(),
5353 IndexState::Building { phase: IndexPhase::Idle, .. }
5354 ));
5355 }
5356
5357 #[test]
5358 fn test_transition_to_scanning_phase() {
5359 let coordinator = IndexCoordinator::new();
5360 coordinator.transition_to_scanning();
5361
5362 let state = coordinator.state();
5363 assert!(
5364 matches!(state, IndexState::Building { phase: IndexPhase::Scanning, .. }),
5365 "Expected Building state after scanning, got: {:?}",
5366 state
5367 );
5368 }
5369
5370 #[test]
5371 fn test_transition_to_indexing_phase() {
5372 let coordinator = IndexCoordinator::new();
5373 coordinator.transition_to_scanning();
5374 coordinator.update_scan_progress(3);
5375 coordinator.transition_to_indexing(3);
5376
5377 let state = coordinator.state();
5378 assert!(
5379 matches!(
5380 state,
5381 IndexState::Building { phase: IndexPhase::Indexing, total_count: 3, .. }
5382 ),
5383 "Expected Building state after indexing with total_count 3, got: {:?}",
5384 state
5385 );
5386 }
5387
5388 #[test]
5389 fn test_transition_to_ready() {
5390 let coordinator = IndexCoordinator::new();
5391 coordinator.transition_to_ready(100, 5000);
5392
5393 let state = coordinator.state();
5394 if let IndexState::Ready { file_count, symbol_count, .. } = state {
5395 assert_eq!(file_count, 100);
5396 assert_eq!(symbol_count, 5000);
5397 } else {
5398 unreachable!("Expected Ready state, got: {:?}", state);
5399 }
5400 }
5401
5402 #[test]
5403 fn test_parse_storm_degradation() {
5404 let coordinator = IndexCoordinator::new();
5405 coordinator.transition_to_ready(100, 5000);
5406
5407 for _ in 0..15 {
5409 coordinator.notify_change("file.pm");
5410 }
5411
5412 let state = coordinator.state();
5413 assert!(
5414 matches!(state, IndexState::Degraded { .. }),
5415 "Expected Degraded state, got: {:?}",
5416 state
5417 );
5418 if let IndexState::Degraded { reason, .. } = state {
5419 assert!(matches!(reason, DegradationReason::ParseStorm { .. }));
5420 }
5421 }
5422
5423 #[test]
5424 fn test_recovery_from_parse_storm() {
5425 let coordinator = IndexCoordinator::new();
5426 coordinator.transition_to_ready(100, 5000);
5427
5428 for _ in 0..15 {
5430 coordinator.notify_change("file.pm");
5431 }
5432
5433 for _ in 0..15 {
5435 coordinator.notify_parse_complete("file.pm");
5436 }
5437
5438 assert!(matches!(coordinator.state(), IndexState::Building { .. }));
5440 }
5441
5442 #[test]
5443 fn test_query_dispatch_ready() {
5444 let coordinator = IndexCoordinator::new();
5445 coordinator.transition_to_ready(100, 5000);
5446
5447 let result = coordinator.query(|_index| "full_query", |_index| "partial_query");
5448
5449 assert_eq!(result, "full_query");
5450 }
5451
5452 #[test]
5453 fn test_query_dispatch_degraded() {
5454 let coordinator = IndexCoordinator::new();
5455 let result = coordinator.query(|_index| "full_query", |_index| "partial_query");
5458
5459 assert_eq!(result, "partial_query");
5460 }
5461
5462 #[test]
5463 fn test_metrics_pending_count() {
5464 let coordinator = IndexCoordinator::new();
5465
5466 coordinator.notify_change("file1.pm");
5467 coordinator.notify_change("file2.pm");
5468
5469 assert_eq!(coordinator.metrics.pending_count(), 2);
5470
5471 coordinator.notify_parse_complete("file1.pm");
5472 assert_eq!(coordinator.metrics.pending_count(), 1);
5473 }
5474
5475 #[test]
5476 fn test_instrumentation_records_transitions() {
5477 let coordinator = IndexCoordinator::new();
5478 coordinator.transition_to_ready(10, 100);
5479
5480 let snapshot = coordinator.instrumentation_snapshot();
5481 let transition =
5482 IndexStateTransition { from: IndexStateKind::Building, to: IndexStateKind::Ready };
5483 let count = snapshot.state_transition_counts.get(&transition).copied().unwrap_or(0);
5484 assert_eq!(count, 1);
5485 }
5486
5487 #[test]
5488 fn test_instrumentation_records_early_exit() {
5489 let coordinator = IndexCoordinator::new();
5490 coordinator.record_early_exit(EarlyExitReason::InitialTimeBudget, 25, 1, 10);
5491
5492 let snapshot = coordinator.instrumentation_snapshot();
5493 let count = snapshot
5494 .early_exit_counts
5495 .get(&EarlyExitReason::InitialTimeBudget)
5496 .copied()
5497 .unwrap_or(0);
5498 assert_eq!(count, 1);
5499 assert!(snapshot.last_early_exit.is_some());
5500 }
5501
5502 #[test]
5503 fn test_custom_limits() {
5504 let limits = IndexResourceLimits {
5505 max_files: 5000,
5506 max_symbols_per_file: 1000,
5507 max_total_symbols: 100_000,
5508 max_ast_cache_bytes: 128 * 1024 * 1024,
5509 max_ast_cache_items: 50,
5510 max_scan_duration_ms: 30_000,
5511 };
5512
5513 let coordinator = IndexCoordinator::with_limits(limits.clone());
5514 assert_eq!(coordinator.limits.max_files, 5000);
5515 assert_eq!(coordinator.limits.max_total_symbols, 100_000);
5516 }
5517
5518 #[test]
5519 fn test_degradation_preserves_symbol_count() {
5520 let coordinator = IndexCoordinator::new();
5521 coordinator.transition_to_ready(100, 5000);
5522
5523 coordinator.transition_to_degraded(DegradationReason::IoError {
5524 message: "Test error".to_string(),
5525 });
5526
5527 let state = coordinator.state();
5528 assert!(
5529 matches!(state, IndexState::Degraded { .. }),
5530 "Expected Degraded state, got: {:?}",
5531 state
5532 );
5533 if let IndexState::Degraded { available_symbols, .. } = state {
5534 assert_eq!(available_symbols, 5000);
5535 }
5536 }
5537
5538 #[test]
5539 fn test_index_access() {
5540 let coordinator = IndexCoordinator::new();
5541 let index = coordinator.index();
5542
5543 assert!(index.all_symbols().is_empty());
5545 }
5546
5547 #[test]
5548 fn test_resource_limit_enforcement_max_files() {
5549 let limits = IndexResourceLimits {
5550 max_files: 5,
5551 max_symbols_per_file: 1000,
5552 max_total_symbols: 50_000,
5553 max_ast_cache_bytes: 128 * 1024 * 1024,
5554 max_ast_cache_items: 50,
5555 max_scan_duration_ms: 30_000,
5556 };
5557
5558 let coordinator = IndexCoordinator::with_limits(limits);
5559 coordinator.transition_to_ready(10, 100);
5560
5561 for i in 0..10 {
5563 let uri_str = format!("file:///test{}.pl", i);
5564 let uri = must(url::Url::parse(&uri_str));
5565 let code = "sub test { }";
5566 must(coordinator.index().index_file(uri, code.to_string()));
5567 }
5568
5569 coordinator.enforce_limits();
5571
5572 let state = coordinator.state();
5573 assert!(
5574 matches!(
5575 state,
5576 IndexState::Degraded {
5577 reason: DegradationReason::ResourceLimit { kind: ResourceKind::MaxFiles },
5578 ..
5579 }
5580 ),
5581 "Expected Degraded state with ResourceLimit(MaxFiles), got: {:?}",
5582 state
5583 );
5584 }
5585
5586 #[test]
5587 fn test_resource_limit_enforcement_max_symbols() {
5588 let limits = IndexResourceLimits {
5589 max_files: 100,
5590 max_symbols_per_file: 10,
5591 max_total_symbols: 50, max_ast_cache_bytes: 128 * 1024 * 1024,
5593 max_ast_cache_items: 50,
5594 max_scan_duration_ms: 30_000,
5595 };
5596
5597 let coordinator = IndexCoordinator::with_limits(limits);
5598 coordinator.transition_to_ready(0, 0);
5599
5600 for i in 0..10 {
5602 let uri_str = format!("file:///test{}.pl", i);
5603 let uri = must(url::Url::parse(&uri_str));
5604 let code = r#"
5606package Test;
5607sub sub1 { }
5608sub sub2 { }
5609sub sub3 { }
5610sub sub4 { }
5611sub sub5 { }
5612sub sub6 { }
5613sub sub7 { }
5614sub sub8 { }
5615sub sub9 { }
5616sub sub10 { }
5617"#;
5618 must(coordinator.index().index_file(uri, code.to_string()));
5619 }
5620
5621 coordinator.enforce_limits();
5623
5624 let state = coordinator.state();
5625 assert!(
5626 matches!(
5627 state,
5628 IndexState::Degraded {
5629 reason: DegradationReason::ResourceLimit { kind: ResourceKind::MaxSymbols },
5630 ..
5631 }
5632 ),
5633 "Expected Degraded state with ResourceLimit(MaxSymbols), got: {:?}",
5634 state
5635 );
5636 }
5637
5638 #[test]
5639 fn test_check_limits_returns_none_within_bounds() {
5640 let coordinator = IndexCoordinator::new();
5641 coordinator.transition_to_ready(0, 0);
5642
5643 for i in 0..5 {
5645 let uri_str = format!("file:///test{}.pl", i);
5646 let uri = must(url::Url::parse(&uri_str));
5647 let code = "sub test { }";
5648 must(coordinator.index().index_file(uri, code.to_string()));
5649 }
5650
5651 let limit_check = coordinator.check_limits();
5653 assert!(limit_check.is_none(), "check_limits should return None when within bounds");
5654
5655 assert!(
5657 matches!(coordinator.state(), IndexState::Ready { .. }),
5658 "State should remain Ready when within limits"
5659 );
5660 }
5661
5662 #[test]
5663 fn test_enforce_limits_called_on_transition_to_ready() {
5664 let limits = IndexResourceLimits {
5665 max_files: 3,
5666 max_symbols_per_file: 1000,
5667 max_total_symbols: 50_000,
5668 max_ast_cache_bytes: 128 * 1024 * 1024,
5669 max_ast_cache_items: 50,
5670 max_scan_duration_ms: 30_000,
5671 };
5672
5673 let coordinator = IndexCoordinator::with_limits(limits);
5674
5675 for i in 0..5 {
5677 let uri_str = format!("file:///test{}.pl", i);
5678 let uri = must(url::Url::parse(&uri_str));
5679 let code = "sub test { }";
5680 must(coordinator.index().index_file(uri, code.to_string()));
5681 }
5682
5683 coordinator.transition_to_ready(5, 100);
5685
5686 let state = coordinator.state();
5687 assert!(
5688 matches!(
5689 state,
5690 IndexState::Degraded {
5691 reason: DegradationReason::ResourceLimit { kind: ResourceKind::MaxFiles },
5692 ..
5693 }
5694 ),
5695 "Expected Degraded state after transition_to_ready with exceeded limits, got: {:?}",
5696 state
5697 );
5698 }
5699
5700 #[test]
5701 fn test_state_transition_guard_ready_to_ready() {
5702 let coordinator = IndexCoordinator::new();
5704 coordinator.transition_to_ready(100, 5000);
5705
5706 coordinator.transition_to_ready(150, 7500);
5708
5709 let state = coordinator.state();
5710 assert!(
5711 matches!(state, IndexState::Ready { file_count: 150, symbol_count: 7500, .. }),
5712 "Expected Ready state with updated metrics, got: {:?}",
5713 state
5714 );
5715 }
5716
5717 #[test]
5718 fn test_state_transition_guard_building_to_building() {
5719 let coordinator = IndexCoordinator::new();
5721
5722 coordinator.transition_to_building(100);
5724
5725 let state = coordinator.state();
5726 assert!(
5727 matches!(state, IndexState::Building { indexed_count: 0, total_count: 100, .. }),
5728 "Expected Building state, got: {:?}",
5729 state
5730 );
5731
5732 coordinator.transition_to_building(200);
5734
5735 let state = coordinator.state();
5736 assert!(
5737 matches!(state, IndexState::Building { indexed_count: 0, total_count: 200, .. }),
5738 "Expected Building state, got: {:?}",
5739 state
5740 );
5741 }
5742
5743 #[test]
5744 fn test_state_transition_ready_to_building() {
5745 let coordinator = IndexCoordinator::new();
5747 coordinator.transition_to_ready(100, 5000);
5748
5749 coordinator.transition_to_building(150);
5751
5752 let state = coordinator.state();
5753 assert!(
5754 matches!(state, IndexState::Building { indexed_count: 0, total_count: 150, .. }),
5755 "Expected Building state after re-scan, got: {:?}",
5756 state
5757 );
5758 }
5759
5760 #[test]
5761 fn test_state_transition_degraded_to_building() {
5762 let coordinator = IndexCoordinator::new();
5764 coordinator.transition_to_degraded(DegradationReason::IoError {
5765 message: "Test error".to_string(),
5766 });
5767
5768 coordinator.transition_to_building(100);
5770
5771 let state = coordinator.state();
5772 assert!(
5773 matches!(state, IndexState::Building { indexed_count: 0, total_count: 100, .. }),
5774 "Expected Building state after recovery, got: {:?}",
5775 state
5776 );
5777 }
5778
5779 #[test]
5780 fn test_update_building_progress() {
5781 let coordinator = IndexCoordinator::new();
5782 coordinator.transition_to_building(100);
5783
5784 coordinator.update_building_progress(50);
5786
5787 let state = coordinator.state();
5788 assert!(
5789 matches!(state, IndexState::Building { indexed_count: 50, total_count: 100, .. }),
5790 "Expected Building state with updated progress, got: {:?}",
5791 state
5792 );
5793
5794 coordinator.update_building_progress(100);
5796
5797 let state = coordinator.state();
5798 assert!(
5799 matches!(state, IndexState::Building { indexed_count: 100, total_count: 100, .. }),
5800 "Expected Building state with completed progress, got: {:?}",
5801 state
5802 );
5803 }
5804
5805 #[test]
5806 fn test_scan_timeout_detection() {
5807 let limits = IndexResourceLimits {
5809 max_scan_duration_ms: 0, ..Default::default()
5811 };
5812
5813 let coordinator = IndexCoordinator::with_limits(limits);
5814 coordinator.transition_to_building(100);
5815
5816 std::thread::sleep(std::time::Duration::from_millis(1));
5818
5819 coordinator.update_building_progress(10);
5821
5822 let state = coordinator.state();
5823 assert!(
5824 matches!(
5825 state,
5826 IndexState::Degraded { reason: DegradationReason::ScanTimeout { .. }, .. }
5827 ),
5828 "Expected Degraded state with ScanTimeout, got: {:?}",
5829 state
5830 );
5831 }
5832
5833 #[test]
5834 fn test_scan_timeout_does_not_trigger_within_limit() {
5835 let limits = IndexResourceLimits {
5837 max_scan_duration_ms: 10_000, ..Default::default()
5839 };
5840
5841 let coordinator = IndexCoordinator::with_limits(limits);
5842 coordinator.transition_to_building(100);
5843
5844 coordinator.update_building_progress(50);
5846
5847 let state = coordinator.state();
5848 assert!(
5849 matches!(state, IndexState::Building { indexed_count: 50, .. }),
5850 "Expected Building state (no timeout), got: {:?}",
5851 state
5852 );
5853 }
5854
5855 #[test]
5856 fn test_early_exit_optimization_unchanged_content() {
5857 let index = WorkspaceIndex::new();
5858 let uri = must(url::Url::parse("file:///test.pl"));
5859 let code = r#"
5860package MyPackage;
5861
5862sub hello {
5863 print "Hello";
5864}
5865"#;
5866
5867 must(index.index_file(uri.clone(), code.to_string()));
5869 let symbols1 = index.file_symbols(uri.as_str());
5870 assert!(symbols1.iter().any(|s| s.name == "MyPackage" && s.kind == SymbolKind::Package));
5871 assert!(symbols1.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
5872
5873 must(index.index_file(uri.clone(), code.to_string()));
5876 let symbols2 = index.file_symbols(uri.as_str());
5877 assert_eq!(symbols1.len(), symbols2.len());
5878 assert!(symbols2.iter().any(|s| s.name == "MyPackage" && s.kind == SymbolKind::Package));
5879 assert!(symbols2.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
5880 }
5881
5882 #[test]
5883 fn test_early_exit_optimization_changed_content() {
5884 let index = WorkspaceIndex::new();
5885 let uri = must(url::Url::parse("file:///test.pl"));
5886 let code1 = r#"
5887package MyPackage;
5888
5889sub hello {
5890 print "Hello";
5891}
5892"#;
5893
5894 let code2 = r#"
5895package MyPackage;
5896
5897sub goodbye {
5898 print "Goodbye";
5899}
5900"#;
5901
5902 must(index.index_file(uri.clone(), code1.to_string()));
5904 let symbols1 = index.file_symbols(uri.as_str());
5905 assert!(symbols1.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
5906 assert!(!symbols1.iter().any(|s| s.name == "goodbye"));
5907
5908 must(index.index_file(uri.clone(), code2.to_string()));
5910 let symbols2 = index.file_symbols(uri.as_str());
5911 assert!(!symbols2.iter().any(|s| s.name == "hello"));
5912 assert!(symbols2.iter().any(|s| s.name == "goodbye" && s.kind == SymbolKind::Subroutine));
5913 }
5914
5915 #[test]
5916 fn test_early_exit_optimization_whitespace_only_change() {
5917 let index = WorkspaceIndex::new();
5918 let uri = must(url::Url::parse("file:///test.pl"));
5919 let code1 = r#"
5920package MyPackage;
5921
5922sub hello {
5923 print "Hello";
5924}
5925"#;
5926
5927 let code2 = r#"
5928package MyPackage;
5929
5930
5931sub hello {
5932 print "Hello";
5933}
5934"#;
5935
5936 must(index.index_file(uri.clone(), code1.to_string()));
5938 let symbols1 = index.file_symbols(uri.as_str());
5939 assert!(symbols1.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
5940
5941 must(index.index_file(uri.clone(), code2.to_string()));
5943 let symbols2 = index.file_symbols(uri.as_str());
5944 assert!(symbols2.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
5946 }
5947
5948 #[test]
5949 fn test_reindex_file_refreshes_symbol_cache_for_removed_names() {
5950 let index = WorkspaceIndex::new();
5951 let uri1 = must(url::Url::parse("file:///lib/A.pm"));
5952 let uri2 = must(url::Url::parse("file:///lib/B.pm"));
5953 let code1 = "package A;\nsub foo { return 1; }\n1;\n";
5954 let code2 = "package B;\nsub foo { return 2; }\n1;\n";
5955 let code2_reindexed = "package B;\nsub bar { return 3; }\n1;\n";
5956
5957 must(index.index_file(uri1.clone(), code1.to_string()));
5958 must(index.index_file(uri2.clone(), code2.to_string()));
5959 must(index.index_file(uri2.clone(), code2_reindexed.to_string()));
5960
5961 let foo_location = must_some(index.find_definition("foo"));
5962 assert_eq!(foo_location.uri, uri1.to_string());
5963
5964 let bar_location = must_some(index.find_definition("bar"));
5965 assert_eq!(bar_location.uri, uri2.to_string());
5966 }
5967
5968 #[test]
5969 fn test_remove_file_preserves_other_colliding_symbol_entries() {
5970 let index = WorkspaceIndex::new();
5971 let uri1 = must(url::Url::parse("file:///lib/A.pm"));
5972 let uri2 = must(url::Url::parse("file:///lib/B.pm"));
5973 let code1 = "package A;\nsub foo { return 1; }\n1;\n";
5974 let code2 = "package B;\nsub foo { return 2; }\n1;\n";
5975
5976 must(index.index_file(uri1.clone(), code1.to_string()));
5977 must(index.index_file(uri2.clone(), code2.to_string()));
5978
5979 index.remove_file(uri2.as_str());
5980
5981 let foo_location = must_some(index.find_definition("foo"));
5982 assert_eq!(foo_location.uri, uri1.to_string());
5983 }
5984
5985 #[test]
5986 fn test_count_usages_no_double_counting_for_qualified_calls() {
5987 let index = WorkspaceIndex::new();
5988
5989 let uri1 = "file:///lib/Utils.pm";
5991 let code1 = r#"
5992package Utils;
5993
5994sub process_data {
5995 return 1;
5996}
5997"#;
5998 must(index.index_file(must(url::Url::parse(uri1)), code1.to_string()));
5999
6000 let uri2 = "file:///app.pl";
6002 let code2 = r#"
6003use Utils;
6004Utils::process_data();
6005Utils::process_data();
6006"#;
6007 must(index.index_file(must(url::Url::parse(uri2)), code2.to_string()));
6008
6009 let count = index.count_usages("Utils::process_data");
6013
6014 assert_eq!(
6017 count, 2,
6018 "count_usages should not double-count qualified calls, got {} (expected 2)",
6019 count
6020 );
6021
6022 let refs = index.find_references("Utils::process_data");
6024 let non_def_refs: Vec<_> =
6025 refs.iter().filter(|loc| loc.uri != "file:///lib/Utils.pm").collect();
6026 assert_eq!(
6027 non_def_refs.len(),
6028 2,
6029 "find_references should not return duplicates for qualified calls, got {} non-def refs",
6030 non_def_refs.len()
6031 );
6032 }
6033
6034 #[test]
6035 fn test_batch_indexing() {
6036 let index = WorkspaceIndex::new();
6037 let files: Vec<(Url, String)> = (0..5)
6038 .map(|i| {
6039 let uri = must(Url::parse(&format!("file:///batch/module{}.pm", i)));
6040 let code =
6041 format!("package Batch::Mod{};\nsub func_{} {{ return {}; }}\n1;", i, i, i);
6042 (uri, code)
6043 })
6044 .collect();
6045
6046 let errors = index.index_files_batch(files);
6047 assert!(errors.is_empty(), "batch indexing errors: {:?}", errors);
6048 assert_eq!(index.file_count(), 5);
6049 assert!(index.find_definition("Batch::Mod0::func_0").is_some());
6050 assert!(index.find_definition("Batch::Mod4::func_4").is_some());
6051 }
6052
6053 #[test]
6054 fn test_batch_indexing_skips_unchanged() {
6055 let index = WorkspaceIndex::new();
6056 let uri = must(Url::parse("file:///batch/skip.pm"));
6057 let code = "package Skip;\nsub skip_fn { 1 }\n1;".to_string();
6058
6059 index.index_file(uri.clone(), code.clone()).ok();
6060 assert_eq!(index.file_count(), 1);
6061
6062 let errors = index.index_files_batch(vec![(uri, code)]);
6063 assert!(errors.is_empty());
6064 assert_eq!(index.file_count(), 1);
6065 }
6066
6067 #[test]
6068 fn test_incremental_update_preserves_other_symbols() {
6069 let index = WorkspaceIndex::new();
6070
6071 let uri_a = must(Url::parse("file:///incr/a.pm"));
6072 let uri_b = must(Url::parse("file:///incr/b.pm"));
6073 index.index_file(uri_a.clone(), "package A;\nsub a_func { 1 }\n1;".into()).ok();
6074 index.index_file(uri_b.clone(), "package B;\nsub b_func { 2 }\n1;".into()).ok();
6075
6076 assert!(index.find_definition("A::a_func").is_some());
6077 assert!(index.find_definition("B::b_func").is_some());
6078
6079 index.index_file(uri_a, "package A;\nsub a_func_v2 { 11 }\n1;".into()).ok();
6080
6081 assert!(index.find_definition("A::a_func_v2").is_some());
6082 assert!(index.find_definition("B::b_func").is_some());
6083 }
6084
6085 #[test]
6086 fn test_remove_file_preserves_shadowed_symbols() {
6087 let index = WorkspaceIndex::new();
6088
6089 let uri_a = must(Url::parse("file:///shadow/a.pm"));
6090 let uri_b = must(Url::parse("file:///shadow/b.pm"));
6091 index.index_file(uri_a.clone(), "package ShadowA;\nsub helper { 1 }\n1;".into()).ok();
6092 index.index_file(uri_b.clone(), "package ShadowB;\nsub helper { 2 }\n1;".into()).ok();
6093
6094 assert!(index.find_definition("helper").is_some());
6095
6096 index.remove_file_url(&uri_a);
6097 assert!(index.find_definition("helper").is_some());
6098 assert!(index.find_definition("ShadowB::helper").is_some());
6099 }
6100
6101 #[test]
6106 fn test_index_dependency_via_use_parent_end_to_end() {
6107 let index = WorkspaceIndex::new();
6113
6114 let base_url = must(url::Url::parse("file:///test/workspace/lib/MyBase.pm"));
6115 must(index.index_file(
6116 base_url,
6117 "package MyBase;\nsub new { bless {}, shift }\n1;\n".to_string(),
6118 ));
6119
6120 let child_url = must(url::Url::parse("file:///test/workspace/child.pl"));
6121 must(index.index_file(child_url, "package Child;\nuse parent 'MyBase';\n1;\n".to_string()));
6122
6123 let dependents = index.find_dependents("MyBase");
6124 assert!(
6125 !dependents.is_empty(),
6126 "find_dependents('MyBase') returned empty — \
6127 use parent 'MyBase' should register MyBase as a dependency. \
6128 Dependencies in index: {:?}",
6129 {
6130 let files = index.files.read();
6131 files
6132 .iter()
6133 .map(|(k, v)| (k.clone(), v.dependencies.iter().cloned().collect::<Vec<_>>()))
6134 .collect::<Vec<_>>()
6135 }
6136 );
6137 assert!(
6138 dependents.contains(&"file:///test/workspace/child.pl".to_string()),
6139 "child.pl should be in dependents, got: {:?}",
6140 dependents
6141 );
6142 }
6143
6144 #[test]
6145 fn test_find_dependents_normalizes_legacy_separator_in_query() {
6146 let index = WorkspaceIndex::new();
6147 let uri = must(url::Url::parse("file:///test/workspace/legacy-query.pl"));
6148 let src = "package Child;\nuse parent 'My::Base';\n1;\n";
6149 must(index.index_file(uri, src.to_string()));
6150
6151 let dependents = index.find_dependents("My'Base");
6152 assert_eq!(dependents, vec!["file:///test/workspace/legacy-query.pl".to_string()]);
6153 }
6154
6155 #[test]
6156 fn test_file_dependencies_normalize_legacy_separator_in_source() {
6157 let index = WorkspaceIndex::new();
6158 let uri = must(url::Url::parse("file:///test/workspace/legacy-source.pl"));
6159 let src = "package Child;\nuse parent \"My'Base\";\n1;\n";
6160 must(index.index_file(uri.clone(), src.to_string()));
6161
6162 let deps = index.file_dependencies(uri.as_str());
6163 assert!(deps.contains("My::Base"));
6164 assert!(!deps.contains("My'Base"));
6165 }
6166
6167 #[test]
6168 fn test_index_dependency_via_moose_extends_end_to_end() -> Result<(), Box<dyn std::error::Error>>
6169 {
6170 let index = WorkspaceIndex::new();
6171
6172 let parent_url = must(url::Url::parse("file:///test/workspace/lib/My/App/Parent.pm"));
6173 must(index.index_file(parent_url, "package My::App::Parent;\n1;\n".to_string()));
6174
6175 let child_url = must(url::Url::parse("file:///test/workspace/child-moose.pl"));
6176 let child_src = "package Child;\nuse Moose;\nextends 'My::App::Parent';\n1;\n";
6177 must(index.index_file(child_url, child_src.to_string()));
6178
6179 let dependents = index.find_dependents("My::App::Parent");
6180 assert!(
6181 dependents.contains(&"file:///test/workspace/child-moose.pl".to_string()),
6182 "expected child-moose.pl in dependents, got: {dependents:?}"
6183 );
6184 Ok(())
6185 }
6186
6187 #[test]
6188 fn test_index_dependency_via_moo_with_role_end_to_end() -> Result<(), Box<dyn std::error::Error>>
6189 {
6190 let index = WorkspaceIndex::new();
6191
6192 let role_url = must(url::Url::parse("file:///test/workspace/lib/My/App/Role.pm"));
6193 must(index.index_file(role_url, "package My::App::Role;\n1;\n".to_string()));
6194
6195 let consumer_url = must(url::Url::parse("file:///test/workspace/consumer-moo.pl"));
6196 let consumer_src = "package Consumer;\nuse Moo;\nwith 'My::App::Role';\n1;\n";
6197 must(index.index_file(consumer_url.clone(), consumer_src.to_string()));
6198
6199 let dependents = index.find_dependents("My::App::Role");
6200 assert!(
6201 dependents.contains(&"file:///test/workspace/consumer-moo.pl".to_string()),
6202 "expected consumer-moo.pl in dependents, got: {dependents:?}"
6203 );
6204
6205 let deps = index.file_dependencies(consumer_url.as_str());
6206 assert!(deps.contains("My::App::Role"));
6207 Ok(())
6208 }
6209
6210 #[test]
6211 fn test_index_dependency_via_literal_require_end_to_end()
6212 -> Result<(), Box<dyn std::error::Error>> {
6213 let index = WorkspaceIndex::new();
6214 let uri = must(url::Url::parse("file:///test/workspace/require-consumer.pl"));
6215 let src = "package Consumer;\nrequire My::Loader;\n1;\n";
6216 must(index.index_file(uri.clone(), src.to_string()));
6217
6218 let deps = index.file_dependencies(uri.as_str());
6219 assert!(
6220 deps.contains("My::Loader"),
6221 "literal require should register module dependency, got: {deps:?}"
6222 );
6223 Ok(())
6224 }
6225
6226 #[test]
6227 fn test_manual_import_symbols_are_indexed_as_import_references()
6228 -> Result<(), Box<dyn std::error::Error>> {
6229 let index = WorkspaceIndex::new();
6230 let uri = must(url::Url::parse("file:///test/workspace/manual-import.pl"));
6231 let src = r#"package Consumer;
6232require My::Tools;
6233My::Tools->import(qw(helper_one helper_two));
6234helper_one();
62351;
6236"#;
6237 must(index.index_file(uri.clone(), src.to_string()));
6238
6239 let deps = index.file_dependencies(uri.as_str());
6240 assert!(
6241 deps.contains("My::Tools"),
6242 "manual import target should be tracked as dependency, got: {deps:?}"
6243 );
6244
6245 for symbol in ["helper_one", "helper_two"] {
6246 let refs = index.find_references(symbol);
6247 assert!(
6248 !refs.is_empty(),
6249 "expected at least one indexed reference for imported symbol `{symbol}`"
6250 );
6251 }
6252 Ok(())
6253 }
6254
6255 #[test]
6256 fn test_parser_produces_correct_args_for_use_parent() {
6257 use crate::Parser;
6261 let mut p = Parser::new("package Child;\nuse parent 'MyBase';\n1;\n");
6262 let ast = must(p.parse());
6263 assert!(
6264 matches!(ast.kind, NodeKind::Program { .. }),
6265 "Expected Program root, got {:?}",
6266 ast.kind
6267 );
6268 let NodeKind::Program { statements } = &ast.kind else {
6269 return;
6270 };
6271 let mut found_parent_use = false;
6272 for stmt in statements {
6273 if let NodeKind::Use { module, args, .. } = &stmt.kind {
6274 if module == "parent" {
6275 found_parent_use = true;
6276 assert_eq!(
6277 args,
6278 &["'MyBase'".to_string()],
6279 "Expected args=[\"'MyBase'\"] for `use parent 'MyBase'`, got: {:?}",
6280 args
6281 );
6282 let extracted = extract_module_names_from_use_args(args);
6283 assert_eq!(
6284 extracted,
6285 vec!["MyBase".to_string()],
6286 "extract_module_names_from_use_args should return [\"MyBase\"], got {:?}",
6287 extracted
6288 );
6289 }
6290 }
6291 }
6292 assert!(found_parent_use, "No Use node with module='parent' found in AST");
6293 }
6294
6295 #[test]
6300 fn test_extract_module_names_single_quoted() {
6301 let names = extract_module_names_from_use_args(&["'Foo::Bar'".to_string()]);
6302 assert_eq!(names, vec!["Foo::Bar"]);
6303 }
6304
6305 #[test]
6306 fn test_extract_module_names_double_quoted() {
6307 let names = extract_module_names_from_use_args(&["\"Foo::Bar\"".to_string()]);
6308 assert_eq!(names, vec!["Foo::Bar"]);
6309 }
6310
6311 #[test]
6312 fn test_extract_module_names_qw_list() {
6313 let names = extract_module_names_from_use_args(&["qw(Foo::Bar Other::Base)".to_string()]);
6314 assert_eq!(names, vec!["Foo::Bar", "Other::Base"]);
6315 }
6316
6317 #[test]
6318 fn test_extract_module_names_qw_slash_delimiter() {
6319 let names = extract_module_names_from_use_args(&["qw/Foo::Bar Other::Base/".to_string()]);
6320 assert_eq!(names, vec!["Foo::Bar", "Other::Base"]);
6321 }
6322
6323 #[test]
6324 fn test_extract_module_names_qw_with_space_before_delimiter() {
6325 let names = extract_module_names_from_use_args(&["qw [Foo::Bar Other::Base]".to_string()]);
6326 assert_eq!(names, vec!["Foo::Bar", "Other::Base"]);
6327 }
6328
6329 #[test]
6330 fn test_extract_module_names_qw_list_trims_wrapped_punctuation() {
6331 let names =
6332 extract_module_names_from_use_args(&["qw((Foo::Bar) [Other::Base],)".to_string()]);
6333 assert_eq!(names, vec!["Foo::Bar", "Other::Base"]);
6334 }
6335
6336 #[test]
6337 fn test_extract_module_names_norequire_flag() {
6338 let names = extract_module_names_from_use_args(&[
6339 "-norequire".to_string(),
6340 "'Foo::Bar'".to_string(),
6341 ]);
6342 assert_eq!(names, vec!["Foo::Bar"]);
6343 }
6344
6345 #[test]
6346 fn test_extract_module_names_empty_args() {
6347 let names = extract_module_names_from_use_args(&[]);
6348 assert!(names.is_empty());
6349 }
6350
6351 #[test]
6352 fn test_extract_module_names_legacy_separator() {
6353 let names = extract_module_names_from_use_args(&["'Foo'Bar'".to_string()]);
6355 assert_eq!(names, vec!["Foo::Bar"]);
6357 }
6358
6359 #[test]
6360 fn test_find_dependents_matches_legacy_separator_queries() {
6361 let index = WorkspaceIndex::new();
6362 let base_uri = must(url::Url::parse("file:///test/workspace/lib/Foo/Bar.pm"));
6363 let child_uri = must(url::Url::parse("file:///test/workspace/child.pl"));
6364
6365 must(index.index_file(base_uri, "package Foo::Bar;\n1;\n".to_string()));
6366 must(index.index_file(
6367 child_uri.clone(),
6368 "package Child;\nuse parent qw(Foo'Bar);\n1;\n".to_string(),
6369 ));
6370
6371 let dependents_modern = index.find_dependents("Foo::Bar");
6372 assert!(
6373 dependents_modern.contains(&child_uri.to_string()),
6374 "Expected dependency match when queried with modern separator"
6375 );
6376
6377 let dependents_legacy = index.find_dependents("Foo'Bar");
6378 assert!(
6379 dependents_legacy.contains(&child_uri.to_string()),
6380 "Expected dependency match when queried with legacy separator"
6381 );
6382 }
6383
6384 #[test]
6385 fn test_extract_module_names_comma_adjacent_tokens() {
6386 let names = extract_module_names_from_use_args(&[
6387 "'Foo::Bar',".to_string(),
6388 "\"Other::Base\",".to_string(),
6389 "'Last::One'".to_string(),
6390 ]);
6391 assert_eq!(names, vec!["Foo::Bar", "Other::Base", "Last::One"]);
6392 }
6393
6394 #[test]
6395 fn test_extract_module_names_parenthesized_without_spaces() {
6396 let names = extract_module_names_from_use_args(&["('Foo::Bar','Other::Base')".to_string()]);
6397 assert_eq!(names, vec!["Foo::Bar", "Other::Base"]);
6398 }
6399
6400 #[test]
6401 fn test_extract_module_names_deduplicates_identical_entries() {
6402 let names = extract_module_names_from_use_args(&[
6403 "qw(Foo::Bar Foo::Bar)".to_string(),
6404 "'Foo::Bar'".to_string(),
6405 ]);
6406 assert_eq!(names, vec!["Foo::Bar"]);
6407 }
6408
6409 #[test]
6410 fn test_extract_module_names_trims_semicolon_suffix() {
6411 let names = extract_module_names_from_use_args(&[
6412 "'Foo::Bar',".to_string(),
6413 "'Other::Base',".to_string(),
6414 "'Third::Leaf';".to_string(),
6415 ]);
6416 assert_eq!(names, vec!["Foo::Bar", "Other::Base", "Third::Leaf"]);
6417 }
6418
6419 #[test]
6420 fn test_extract_module_names_trims_wrapped_punctuation() {
6421 let names = extract_module_names_from_use_args(&[
6422 "('Foo::Bar',".to_string(),
6423 "'Other::Base')".to_string(),
6424 ]);
6425 assert_eq!(names, vec!["Foo::Bar", "Other::Base"]);
6426 }
6427
6428 #[test]
6429 fn test_extract_constant_names_qw_with_space_before_delimiter() {
6430 let names = extract_constant_names_from_use_args(&["qw [FOO BAR]".to_string()]);
6431 assert_eq!(names, vec!["FOO", "BAR"]);
6432 }
6433
6434 #[test]
6435 #[ignore = "qw delimiter with leading space not yet parsed; tracked in debt-ledger.yaml"]
6436 fn test_index_use_constant_qw_with_space_before_delimiter() {
6437 let index = WorkspaceIndex::new();
6438 let uri = must(url::Url::parse("file:///workspace/lib/My/Config.pm"));
6439 let source = "package My::Config;\nuse constant qw [FOO BAR];\n1;\n";
6440
6441 must(index.index_file(uri, source.to_string()));
6442
6443 let foo = index.find_definition("My::Config::FOO");
6444 let bar = index.find_definition("My::Config::BAR");
6445 assert!(foo.is_some(), "Expected My::Config::FOO to be indexed");
6446 assert!(bar.is_some(), "Expected My::Config::BAR to be indexed");
6447 }
6448
6449 #[test]
6450 fn test_with_capacity_accepts_large_batch_without_panic() {
6451 let index = WorkspaceIndex::with_capacity(100, 20);
6452 for i in 0..100 {
6453 let uri = must(url::Url::parse(&format!("file:///lib/Mod{}.pm", i)));
6454 let src = format!("package Mod{};\nsub foo_{} {{ 1 }}\n1;\n", i, i);
6455 index.index_file(uri, src).ok();
6456 }
6457 assert!(index.has_symbols());
6458 }
6459
6460 #[test]
6461 fn test_with_capacity_zero_does_not_panic() {
6462 let index = WorkspaceIndex::with_capacity(0, 0);
6463 assert!(!index.has_symbols());
6464 }
6465
6466 #[test]
6474 fn test_remove_file_clears_symbol_cache_qualified_and_bare() {
6475 let index = WorkspaceIndex::new();
6476 let uri_a = must(url::Url::parse("file:///lib/A.pm"));
6477 let code_a = "package A;\nsub foo { return 1; }\n1;\n";
6478
6479 must(index.index_file(uri_a.clone(), code_a.to_string()));
6480
6481 let before_qual = must_some(index.find_definition("A::foo"));
6483 assert_eq!(
6484 before_qual.uri,
6485 uri_a.to_string(),
6486 "qualified lookup should point to A.pm before removal"
6487 );
6488 let before_bare = must_some(index.find_definition("foo"));
6489 assert_eq!(
6490 before_bare.uri,
6491 uri_a.to_string(),
6492 "bare-name lookup should point to A.pm before removal"
6493 );
6494
6495 index.remove_file(uri_a.as_str());
6497
6498 assert!(
6500 index.find_definition("A::foo").is_none(),
6501 "qualified lookup 'A::foo' should return None after file deletion"
6502 );
6503 assert!(
6504 index.find_definition("foo").is_none(),
6505 "bare-name lookup 'foo' should return None after file deletion"
6506 );
6507
6508 assert_eq!(
6510 index.symbol_count(),
6511 0,
6512 "symbol_count should be 0 after removing the only file"
6513 );
6514 assert!(!index.has_symbols(), "has_symbols should be false after removing the only file");
6515 }
6516
6517 #[test]
6520 fn test_remove_file_bare_name_falls_back_to_surviving_file() {
6521 let index = WorkspaceIndex::new();
6522 let uri_a = must(url::Url::parse("file:///lib/A.pm"));
6523 let uri_b = must(url::Url::parse("file:///lib/B.pm"));
6524 let code_a = "package A;\nsub shared_fn { return 1; }\n1;\n";
6525 let code_b = "package B;\nsub shared_fn { return 2; }\n1;\n";
6526
6527 must(index.index_file(uri_a.clone(), code_a.to_string()));
6528 must(index.index_file(uri_b.clone(), code_b.to_string()));
6529
6530 index.remove_file(uri_a.as_str());
6532
6533 let loc = must_some(index.find_definition("shared_fn"));
6534 assert_eq!(
6535 loc.uri,
6536 uri_b.to_string(),
6537 "bare-name 'shared_fn' should resolve to B.pm after A.pm is deleted"
6538 );
6539
6540 assert!(
6541 index.find_definition("A::shared_fn").is_none(),
6542 "qualified 'A::shared_fn' must be gone after A.pm deletion"
6543 );
6544 assert!(
6545 index.find_definition("B::shared_fn").is_some(),
6546 "qualified 'B::shared_fn' must remain after A.pm deletion"
6547 );
6548 }
6549
6550 #[test]
6551 fn test_definition_candidates_include_ambiguous_bare_symbols_in_stable_order() {
6552 let index = WorkspaceIndex::new();
6553 let uri_b = must(url::Url::parse("file:///lib/B.pm"));
6554 let uri_a = must(url::Url::parse("file:///lib/A.pm"));
6555 must(index.index_file(uri_b, "package B;\nsub shared { 1 }\n1;\n".to_string()));
6556 must(index.index_file(uri_a, "package A;\nsub shared { 1 }\n1;\n".to_string()));
6557
6558 let candidates = index.definition_candidates("shared");
6559 assert_eq!(candidates.len(), 2);
6560 assert_eq!(candidates[0].uri, "file:///lib/A.pm");
6561 assert_eq!(candidates[1].uri, "file:///lib/B.pm");
6562 assert_eq!(must_some(index.find_definition("shared")).uri, "file:///lib/A.pm");
6563 }
6564
6565 #[test]
6566 fn test_definition_candidates_include_duplicate_qualified_name_across_files() {
6567 let index = WorkspaceIndex::new();
6568 let uri_v2 = must(url::Url::parse("file:///lib/A-v2.pm"));
6569 let uri_v1 = must(url::Url::parse("file:///lib/A-v1.pm"));
6570 let source = "package A;\nsub foo { 1 }\n1;\n".to_string();
6571 must(index.index_file(uri_v2, source.clone()));
6572 must(index.index_file(uri_v1, source));
6573
6574 let candidates = index.definition_candidates("A::foo");
6575 assert_eq!(candidates.len(), 2);
6576 assert_eq!(candidates[0].uri, "file:///lib/A-v1.pm");
6577 assert_eq!(candidates[1].uri, "file:///lib/A-v2.pm");
6578 }
6579
6580 #[test]
6581 fn test_definition_candidates_are_cleaned_on_remove_and_reindex() {
6582 let index = WorkspaceIndex::new();
6583 let uri = must(url::Url::parse("file:///lib/A.pm"));
6584 must(index.index_file(uri.clone(), "package A;\nsub foo { 1 }\n1;\n".to_string()));
6585 assert_eq!(index.definition_candidates("A::foo").len(), 1);
6586
6587 index.remove_file(uri.as_str());
6588 assert!(index.definition_candidates("A::foo").is_empty());
6589
6590 must(index.index_file(uri, "package A;\nsub foo { 2 }\n1;\n".to_string()));
6591 assert_eq!(index.definition_candidates("A::foo").len(), 1);
6592 }
6593
6594 #[test]
6600 fn test_definition_candidates_shared_symbol_survives_removal_of_sole_owner_of_other_symbol() {
6601 let index = WorkspaceIndex::new();
6602 let uri_a = must(url::Url::parse("file:///lib/A.pm"));
6603 let uri_b = must(url::Url::parse("file:///lib/B.pm"));
6604
6605 must(index.index_file(
6607 uri_a.clone(),
6608 "package A;\nsub unique_to_a { 1 }\nsub shared { 1 }\n1;\n".to_string(),
6609 ));
6610 must(index.index_file(uri_b.clone(), "package B;\nsub shared { 1 }\n1;\n".to_string()));
6611
6612 assert_eq!(index.definition_candidates("shared").len(), 2);
6614 assert_eq!(index.definition_candidates("unique_to_a").len(), 1);
6615
6616 index.remove_file(uri_a.as_str());
6619
6620 assert!(
6621 index.definition_candidates("unique_to_a").is_empty(),
6622 "unique_to_a should be gone after removing A"
6623 );
6624 assert_eq!(
6625 index.definition_candidates("shared").len(),
6626 1,
6627 "shared should still have B's candidate after removing A"
6628 );
6629 assert_eq!(
6630 index.definition_candidates("shared")[0].uri,
6631 "file:///lib/B.pm",
6632 "remaining shared candidate must be from B"
6633 );
6634 }
6635
6636 #[test]
6637 fn test_folder_context_in_file_index() {
6638 let index = WorkspaceIndex::new();
6639
6640 index.set_workspace_folders(vec![
6642 "file:///project1".to_string(),
6643 "file:///project2".to_string(),
6644 ]);
6645
6646 let uri1 = "file:///project1/lib/Module.pm";
6647 let code1 = r#"
6648package Module;
6649
6650sub test_sub {
6651 return 1;
6652}
6653"#;
6654 must(index.index_file(must(url::Url::parse(uri1)), code1.to_string()));
6655
6656 let uri2 = "file:///project2/lib/Other.pm";
6657 let code2 = r#"
6658package Other;
6659
6660sub other_sub {
6661 return 2;
6662}
6663"#;
6664 must(index.index_file(must(url::Url::parse(uri2)), code2.to_string()));
6665
6666 let symbols1 = index.file_symbols(uri1);
6668 assert_eq!(symbols1.len(), 2, "Should have 2 symbols in Module.pm");
6669 for symbol in &symbols1 {
6670 assert_eq!(symbol.uri, uri1, "Symbol URI should match file URI");
6671 }
6672
6673 let symbols2 = index.file_symbols(uri2);
6674 assert_eq!(symbols2.len(), 2, "Should have 2 symbols in Other.pm");
6675 for symbol in &symbols2 {
6676 assert_eq!(symbol.uri, uri2, "Symbol URI should match file URI");
6677 }
6678
6679 let files = index.files.read();
6681 let file_index1 = must_some(files.get(&DocumentStore::uri_key(uri1)));
6682 assert_eq!(
6683 file_index1.folder_uri,
6684 Some("file:///project1".to_string()),
6685 "File should be attributed to correct workspace folder"
6686 );
6687
6688 let file_index2 = must_some(files.get(&DocumentStore::uri_key(uri2)));
6689 assert_eq!(
6690 file_index2.folder_uri,
6691 Some("file:///project2".to_string()),
6692 "File should be attributed to correct workspace folder"
6693 );
6694 }
6695
6696 #[test]
6697 fn test_determine_folder_uri() {
6698 let index = WorkspaceIndex::new();
6699
6700 index.set_workspace_folders(vec![
6702 "file:///project1".to_string(),
6703 "file:///project2".to_string(),
6704 ]);
6705
6706 let folder1 = index.determine_folder_uri("file:///project1/lib/Module.pm");
6708 assert_eq!(
6709 folder1,
6710 Some("file:///project1".to_string()),
6711 "Should determine folder for file in project1"
6712 );
6713
6714 let folder2 = index.determine_folder_uri("file:///project2/lib/Other.pm");
6716 assert_eq!(
6717 folder2,
6718 Some("file:///project2".to_string()),
6719 "Should determine folder for file in project2"
6720 );
6721
6722 let folder_none = index.determine_folder_uri("file:///other/project/Module.pm");
6724 assert_eq!(folder_none, None, "Should return None for file outside workspace folders");
6725 }
6726
6727 #[test]
6728 fn test_determine_folder_uri_prefers_most_specific_match() {
6729 let index = WorkspaceIndex::new();
6730
6731 index.set_workspace_folders(vec![
6733 "file:///project".to_string(),
6734 "file:///project/lib".to_string(),
6735 ]);
6736
6737 let folder = index.determine_folder_uri("file:///project/lib/My/Module.pm");
6738 assert_eq!(
6739 folder,
6740 Some("file:///project/lib".to_string()),
6741 "Nested workspace folders should attribute files to the most specific folder"
6742 );
6743 }
6744
6745 #[test]
6746 fn test_remove_folder() {
6747 let index = WorkspaceIndex::new();
6748
6749 index.set_workspace_folders(vec![
6751 "file:///project1".to_string(),
6752 "file:///project2".to_string(),
6753 ]);
6754
6755 let uri1 = "file:///project1/lib/Module.pm";
6757 let code1 = r#"
6758package Module;
6759
6760sub test_sub {
6761 return 1;
6762}
6763"#;
6764 must(index.index_file(must(url::Url::parse(uri1)), code1.to_string()));
6765
6766 let uri2 = "file:///project2/lib/Other.pm";
6767 let code2 = r#"
6768package Other;
6769
6770sub other_sub {
6771 return 2;
6772}
6773"#;
6774 must(index.index_file(must(url::Url::parse(uri2)), code2.to_string()));
6775
6776 assert_eq!(index.file_count(), 2, "Should have 2 files indexed");
6778 assert_eq!(index.document_store().count(), 2, "Document store should track both files");
6779
6780 index.remove_folder("file:///project1");
6782
6783 assert_eq!(index.file_count(), 1, "Should have 1 file after removing folder");
6785 assert_eq!(
6786 index.document_store().count(),
6787 1,
6788 "Document store should drop files removed via folder deletion"
6789 );
6790 assert!(index.file_symbols(uri1).is_empty(), "File from removed folder should be gone");
6791 assert_eq!(
6792 index.file_symbols(uri2).len(),
6793 2,
6794 "File from remaining folder should still be present"
6795 );
6796 }
6797
6798 #[test]
6799 fn test_remove_folder_removes_symbol_free_files() {
6800 let index = WorkspaceIndex::new();
6801 index.set_workspace_folders(vec!["file:///project1".to_string()]);
6802
6803 let uri = "file:///project1/empty.pl";
6804 must(index.index_file(must(url::Url::parse(uri)), "# comments only".to_string()));
6805 assert_eq!(index.file_count(), 1, "Expected file to be indexed");
6806
6807 index.remove_folder("file:///project1");
6808
6809 assert_eq!(index.file_count(), 0, "Folder removal should delete symbol-free files");
6810 assert_eq!(
6811 index.document_store().count(),
6812 0,
6813 "Document store should stay in sync for symbol-free files"
6814 );
6815 }
6816
6817 #[test]
6822 fn test_require_with_variable_target_is_not_indexed() -> Result<(), Box<dyn std::error::Error>>
6823 {
6824 let index = WorkspaceIndex::new();
6825 let uri = must(url::Url::parse("file:///test/require-var.pl"));
6826 let src = r#"package Test;
6827my $loader = 'MyModule';
6828require $loader;
68291;
6830"#;
6831 must(index.index_file(uri.clone(), src.to_string()));
6832 let deps = index.file_dependencies(uri.as_str());
6833 assert!(
6834 !deps.contains("MyModule"),
6835 "require with variable target should not register static dependency"
6836 );
6837 Ok(())
6838 }
6839
6840 #[test]
6841 fn test_multiple_import_calls_on_same_module() -> Result<(), Box<dyn std::error::Error>> {
6842 let index = WorkspaceIndex::new();
6843 let uri = must(url::Url::parse("file:///test/multi-import.pl"));
6844 let src = r#"package Test;
6845require Toolkit;
6846Toolkit->import('func_a');
6847Toolkit->import(qw(func_b func_c));
68481;
6849"#;
6850 must(index.index_file(uri.clone(), src.to_string()));
6851 let deps = index.file_dependencies(uri.as_str());
6852 assert!(deps.contains("Toolkit"), "module should be tracked as dependency");
6853 for symbol in &["func_a", "func_b", "func_c"] {
6854 let refs = index.find_references(symbol);
6855 assert!(!refs.is_empty(), "all imported symbols should be indexed: {}", symbol);
6856 }
6857 Ok(())
6858 }
6859
6860 #[test]
6861 fn test_require_string_vs_bareword_normalization() -> Result<(), Box<dyn std::error::Error>> {
6862 let index = WorkspaceIndex::new();
6863 let uri = must(url::Url::parse("file:///test/require-string.pl"));
6864 let src = r#"package Consumer;
6865require "String/Based/Module.pm";
6866String::Based::Module->import('exported');
68671;
6868"#;
6869 must(index.index_file(uri.clone(), src.to_string()));
6870 let deps = index.file_dependencies(uri.as_str());
6871 assert!(
6872 deps.contains("String::Based::Module"),
6873 "require string form should normalize path separators to ::"
6874 );
6875 let refs = index.find_references("exported");
6876 assert!(!refs.is_empty(), "import should be indexed even with string-form require");
6877 Ok(())
6878 }
6879
6880 #[test]
6881 fn test_import_without_require_registers_as_method_call()
6882 -> Result<(), Box<dyn std::error::Error>> {
6883 let index = WorkspaceIndex::new();
6887 let uri = must(url::Url::parse("file:///test/orphan-import.pl"));
6888 let src = r#"package Test;
6889Unrelated::Module->import('orphaned');
6890orphaned();
68911;
6892"#;
6893 must(index.index_file(uri.clone(), src.to_string()));
6894
6895 let _refs = index.find_references("orphaned");
6899 Ok(())
6902 }
6903
6904 #[test]
6905 fn test_nested_blocks_preserve_require_scope() -> Result<(), Box<dyn std::error::Error>> {
6906 let index = WorkspaceIndex::new();
6907 let uri = must(url::Url::parse("file:///test/nested.pl"));
6908 let src = r#"package Test;
6909{
6910 require Outer;
6911 {
6912 Outer->import('nested_sym');
6913 }
6914}
69151;
6916"#;
6917 must(index.index_file(uri.clone(), src.to_string()));
6918 let deps = index.file_dependencies(uri.as_str());
6919 assert!(
6920 deps.contains("Outer"),
6921 "require in outer block should be visible to nested import"
6922 );
6923 let refs = index.find_references("nested_sym");
6924 assert!(!refs.is_empty(), "symbol imported in nested block should still be indexed");
6925 Ok(())
6926 }
6927
6928 #[test]
6929 fn test_require_path_without_pm_extension() -> Result<(), Box<dyn std::error::Error>> {
6930 let index = WorkspaceIndex::new();
6931 let uri = must(url::Url::parse("file:///test/no-ext.pl"));
6932 let src = r#"package Test;
6933require "My/Module";
6934My::Module->import('func');
69351;
6936"#;
6937 must(index.index_file(uri.clone(), src.to_string()));
6938 let deps = index.file_dependencies(uri.as_str());
6939 assert!(
6940 deps.contains("My::Module"),
6941 "require without .pm extension should normalize to module path"
6942 );
6943 Ok(())
6944 }
6945
6946 #[test]
6947 fn test_qw_with_bracket_delimiters() -> Result<(), Box<dyn std::error::Error>> {
6948 let index = WorkspaceIndex::new();
6949 let uri = must(url::Url::parse("file:///test/qw-delim.pl"));
6950 let src = r#"package Test;
6951require DelimModule;
6952DelimModule->import(qw[sym1 sym2]);
6953DelimModule->import(qw{sym3 sym4});
69541;
6955"#;
6956 must(index.index_file(uri.clone(), src.to_string()));
6957 for symbol in &["sym1", "sym2", "sym3", "sym4"] {
6958 let refs = index.find_references(symbol);
6959 assert!(
6960 !refs.is_empty(),
6961 "symbols from qw with bracket delimiters should be indexed: {}",
6962 symbol
6963 );
6964 }
6965 Ok(())
6966 }
6967
6968 #[test]
6969 fn test_array_literal_import_args() -> Result<(), Box<dyn std::error::Error>> {
6970 let index = WorkspaceIndex::new();
6971 let uri = must(url::Url::parse("file:///test/array-import.pl"));
6972 let src = r#"package Test;
6973require ArrayModule;
6974ArrayModule->import(['sym_x', 'sym_y']);
69751;
6976"#;
6977 must(index.index_file(uri.clone(), src.to_string()));
6978 for symbol in &["sym_x", "sym_y"] {
6979 let refs = index.find_references(symbol);
6980 assert!(
6981 !refs.is_empty(),
6982 "symbols from array literal import should be indexed: {}",
6983 symbol
6984 );
6985 }
6986 Ok(())
6987 }
6988
6989 #[test]
6990 fn test_require_inside_conditional_still_registers_dependency()
6991 -> Result<(), Box<dyn std::error::Error>> {
6992 let index = WorkspaceIndex::new();
6993 let uri = must(url::Url::parse("file:///test/cond-require.pl"));
6994 let src = r#"package Test;
6995if (1) {
6996 require ConditionalMod;
6997 ConditionalMod->import('cond_func');
6998}
69991;
7000"#;
7001 must(index.index_file(uri.clone(), src.to_string()));
7002 let deps = index.file_dependencies(uri.as_str());
7003 assert!(
7004 deps.contains("ConditionalMod"),
7005 "require inside conditional should still register as dependency"
7006 );
7007 let refs = index.find_references("cond_func");
7008 assert!(!refs.is_empty(), "import inside conditional should still index symbols");
7009 Ok(())
7010 }
7011
7012 #[test]
7013 fn test_mixed_string_and_bareword_imports() -> Result<(), Box<dyn std::error::Error>> {
7014 let index = WorkspaceIndex::new();
7015 let uri = must(url::Url::parse("file:///test/mixed-import.pl"));
7016 let src = r#"package Test;
7017require MixedMod;
7018MixedMod->import('string_sym');
7019MixedMod->import(qw(qw_one qw_two));
70201;
7021"#;
7022 must(index.index_file(uri.clone(), src.to_string()));
7023 let deps = index.file_dependencies(uri.as_str());
7024 assert!(deps.contains("MixedMod"), "require should register dependency");
7025 for symbol in &["string_sym", "qw_one", "qw_two"] {
7026 let refs = index.find_references(symbol);
7027 assert!(!refs.is_empty(), "all import forms should index symbols: {}", symbol);
7028 }
7029 Ok(())
7030 }
7031
7032 fn make_shard(
7038 uri: &str,
7039 content_hash: u64,
7040 anchors_hash: Option<u64>,
7041 entities_hash: Option<u64>,
7042 occurrences_hash: Option<u64>,
7043 edges_hash: Option<u64>,
7044 ) -> FileFactShard {
7045 let file_id = {
7046 let mut h = DefaultHasher::new();
7047 uri.hash(&mut h);
7048 FileId(h.finish())
7049 };
7050 FileFactShard {
7051 source_uri: uri.to_string(),
7052 file_id,
7053 content_hash,
7054 anchors_hash,
7055 entities_hash,
7056 occurrences_hash,
7057 edges_hash,
7058 anchors: Vec::new(),
7059 entities: Vec::new(),
7060 occurrences: Vec::new(),
7061 edges: Vec::new(),
7062 }
7063 }
7064
7065 #[test]
7068 fn incremental_replace_skips_when_content_hash_unchanged()
7069 -> Result<(), Box<dyn std::error::Error>> {
7070 let index = WorkspaceIndex::new();
7071 let uri = "file:///lib/Same.pm";
7072 let key = DocumentStore::uri_key(uri);
7073
7074 let shard_v1 = make_shard(uri, 42, Some(1), Some(2), Some(3), Some(4));
7075 let r1 = index.replace_fact_shard_incremental(&key, shard_v1);
7077 assert!(!r1.content_unchanged);
7078
7079 let shard_v2 = make_shard(uri, 42, Some(100), Some(200), Some(300), Some(400));
7081 let r2 = index.replace_fact_shard_incremental(&key, shard_v2);
7082 assert!(r2.content_unchanged);
7083 assert!(!r2.anchors_updated);
7084 assert!(!r2.entities_updated);
7085 assert!(!r2.occurrences_updated);
7086 assert!(!r2.edges_updated);
7087
7088 let stored = must_some(index.file_fact_shard(uri));
7090 assert_eq!(stored.anchors_hash, Some(1));
7091 Ok(())
7092 }
7093
7094 #[test]
7097 fn incremental_replace_skips_unchanged_categories() -> Result<(), Box<dyn std::error::Error>> {
7098 let index = WorkspaceIndex::new();
7099 let uri = "file:///lib/Partial.pm";
7100 let key = DocumentStore::uri_key(uri);
7101
7102 let shard_v1 = make_shard(uri, 1, Some(10), Some(20), Some(30), Some(40));
7103 index.replace_fact_shard_incremental(&key, shard_v1);
7104
7105 let shard_v2 = make_shard(uri, 2, Some(10), Some(20), Some(99), Some(88));
7108 let result = index.replace_fact_shard_incremental(&key, shard_v2);
7109
7110 assert!(!result.content_unchanged);
7111 assert!(!result.anchors_updated, "anchors hash unchanged → skip");
7112 assert!(!result.entities_updated, "entities hash unchanged → skip");
7113 assert!(result.occurrences_updated, "occurrences hash changed → update");
7114 assert!(result.edges_updated, "edges hash changed → update");
7115 Ok(())
7116 }
7117
7118 #[test]
7121 fn incremental_replace_updates_changed_categories() -> Result<(), Box<dyn std::error::Error>> {
7122 let index = WorkspaceIndex::new();
7123 let uri = "file:///lib/Changed.pm";
7124 let key = DocumentStore::uri_key(uri);
7125
7126 let shard_v1 = make_shard(uri, 1, Some(10), Some(20), Some(30), Some(40));
7127 index.replace_fact_shard_incremental(&key, shard_v1);
7128
7129 let shard_v2 = make_shard(uri, 2, Some(11), Some(21), Some(31), Some(41));
7131 let result = index.replace_fact_shard_incremental(&key, shard_v2);
7132
7133 assert!(!result.content_unchanged);
7134 assert!(result.anchors_updated);
7135 assert!(result.entities_updated);
7136 assert!(result.occurrences_updated);
7137 assert!(result.edges_updated);
7138
7139 let stored = must_some(index.file_fact_shard(uri));
7141 assert_eq!(stored.content_hash, 2);
7142 assert_eq!(stored.anchors_hash, Some(11));
7143 Ok(())
7144 }
7145
7146 #[test]
7149 fn incremental_replace_first_insert_updates_all() -> Result<(), Box<dyn std::error::Error>> {
7150 let index = WorkspaceIndex::new();
7151 let uri = "file:///lib/New.pm";
7152 let key = DocumentStore::uri_key(uri);
7153
7154 let shard = make_shard(uri, 1, Some(10), Some(20), Some(30), Some(40));
7155 let result = index.replace_fact_shard_incremental(&key, shard);
7156
7157 assert!(!result.content_unchanged);
7158 assert!(result.anchors_updated);
7159 assert!(result.entities_updated);
7160 assert!(result.occurrences_updated);
7161 assert!(result.edges_updated);
7162 Ok(())
7163 }
7164
7165 #[test]
7168 fn incremental_replace_none_hashes_treated_as_changed() -> Result<(), Box<dyn std::error::Error>>
7169 {
7170 let index = WorkspaceIndex::new();
7171 let uri = "file:///lib/Legacy.pm";
7172 let key = DocumentStore::uri_key(uri);
7173
7174 let shard_v1 = make_shard(uri, 1, Some(10), Some(20), Some(30), Some(40));
7176 index.replace_fact_shard_incremental(&key, shard_v1);
7177
7178 let shard_v2 = make_shard(uri, 2, None, Some(20), None, Some(40));
7179 let result = index.replace_fact_shard_incremental(&key, shard_v2);
7180
7181 assert!(!result.content_unchanged);
7182 assert!(result.anchors_updated, "None new hash → changed");
7183 assert!(!result.entities_updated, "same hash → skip");
7184 assert!(result.occurrences_updated, "None new hash → changed");
7185 assert!(!result.edges_updated, "same hash → skip");
7186 Ok(())
7187 }
7188
7189 #[test]
7192 fn incremental_replace_updates_reference_index_on_occurrence_change()
7193 -> Result<(), Box<dyn std::error::Error>> {
7194 use perl_semantic_facts::{AnchorId, Confidence, OccurrenceId, OccurrenceKind, Provenance};
7195
7196 let index = WorkspaceIndex::new();
7197 let uri = "file:///lib/RefIdx.pm";
7198 let key = DocumentStore::uri_key(uri);
7199 let file_id = {
7200 let mut h = DefaultHasher::new();
7201 uri.hash(&mut h);
7202 FileId(h.finish())
7203 };
7204
7205 let mut shard_v1 = make_shard(uri, 1, Some(10), Some(20), Some(30), Some(40));
7207 let anchor_id = AnchorId(1);
7208 shard_v1.anchors.push(perl_semantic_facts::AnchorFact {
7209 id: anchor_id,
7210 file_id,
7211 span_start_byte: 0,
7212 span_end_byte: 5,
7213 scope_id: None,
7214 provenance: Provenance::ExactAst,
7215 confidence: Confidence::High,
7216 });
7217 shard_v1.occurrences.push(perl_semantic_facts::OccurrenceFact {
7218 id: OccurrenceId(1),
7219 kind: OccurrenceKind::Call,
7220 entity_id: Some(EntityId(100)),
7221 anchor_id,
7222 scope_id: None,
7223 provenance: Provenance::ExactAst,
7224 confidence: Confidence::High,
7225 });
7226 shard_v1.entities.push(perl_semantic_facts::EntityFact {
7227 id: EntityId(100),
7228 kind: EntityKind::Subroutine,
7229 canonical_name: "RefIdx::foo".to_string(),
7230 anchor_id: Some(anchor_id),
7231 scope_id: None,
7232 provenance: Provenance::ExactAst,
7233 confidence: Confidence::High,
7234 });
7235 index.replace_fact_shard_incremental(&key, shard_v1);
7236
7237 assert!(
7239 index.semantic_reference_index.read().name_count() > 0
7240 || index.semantic_reference_index.read().entity_count() > 0,
7241 "reference index should be populated after first insert"
7242 );
7243
7244 let shard_v2_same = make_shard(uri, 1, Some(10), Some(20), Some(99), Some(99));
7246 let r = index.replace_fact_shard_incremental(&key, shard_v2_same);
7247 assert!(r.content_unchanged);
7248
7249 let mut shard_v3 = make_shard(uri, 3, Some(11), Some(21), Some(30), Some(40));
7251 shard_v3.anchors.push(perl_semantic_facts::AnchorFact {
7252 id: anchor_id,
7253 file_id,
7254 span_start_byte: 0,
7255 span_end_byte: 5,
7256 scope_id: None,
7257 provenance: Provenance::ExactAst,
7258 confidence: Confidence::High,
7259 });
7260 shard_v3.occurrences.push(perl_semantic_facts::OccurrenceFact {
7261 id: OccurrenceId(1),
7262 kind: OccurrenceKind::Call,
7263 entity_id: Some(EntityId(100)),
7264 anchor_id,
7265 scope_id: None,
7266 provenance: Provenance::ExactAst,
7267 confidence: Confidence::High,
7268 });
7269 shard_v3.entities.push(perl_semantic_facts::EntityFact {
7270 id: EntityId(100),
7271 kind: EntityKind::Subroutine,
7272 canonical_name: "RefIdx::foo".to_string(),
7273 anchor_id: Some(anchor_id),
7274 scope_id: None,
7275 provenance: Provenance::ExactAst,
7276 confidence: Confidence::High,
7277 });
7278 let r3 = index.replace_fact_shard_incremental(&key, shard_v3);
7279 assert!(!r3.occurrences_updated, "occurrence hash unchanged → skip");
7280 assert!(!r3.edges_updated, "edge hash unchanged → skip");
7281
7282 Ok(())
7283 }
7284
7285 #[test]
7288 fn index_file_stores_fact_shard_incrementally() -> Result<(), Box<dyn std::error::Error>> {
7289 let index = WorkspaceIndex::new();
7290 let uri = "file:///lib/Incr.pm";
7291 let code = "package Incr;\nsub foo { 1 }\n1;\n";
7292
7293 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
7294 let shard1 = must_some(index.file_fact_shard(uri));
7295 assert!(shard1.anchors_hash.is_some());
7296 assert!(
7297 shard1.anchors.iter().any(|anchor| anchor.provenance == Provenance::ExactAst),
7298 "index_file should store the canonical semantic shard when adapters produce facts"
7299 );
7300 assert!(
7301 shard1.entities.iter().any(|entity| entity.provenance == Provenance::ExactAst),
7302 "index_file should store canonical entities rather than legacy fallback entities"
7303 );
7304
7305 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
7307 let shard2 = must_some(index.file_fact_shard(uri));
7311 assert_eq!(shard1.content_hash, shard2.content_hash);
7312
7313 let code2 = "package Incr;\nsub bar { 2 }\n1;\n";
7315 must(index.index_file(must(url::Url::parse(uri)), code2.to_string()));
7316 let shard3 = must_some(index.file_fact_shard(uri));
7317 assert_ne!(shard1.content_hash, shard3.content_hash);
7318
7319 Ok(())
7320 }
7321
7322 #[test]
7323 fn semantic_anchor_wire_location_uses_lsp_utf16_columns()
7324 -> Result<(), Box<dyn std::error::Error>> {
7325 use crate::semantic::queries::SemanticQueries;
7326
7327 let index = WorkspaceIndex::new();
7328 let uri = "file:///lib/UnicodeAnchor.pm";
7329 let code = "package UnicodeAnchor; my $emoji = \"😀\"; sub target { 1 }\n1;\n";
7330
7331 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
7332
7333 let candidates = index
7334 .with_semantic_queries_for_uri(uri, |file_id, queries| {
7335 let ctx = crate::semantic::queries::QueryContext::new(file_id, None, Some(0));
7336 queries.definitions("UnicodeAnchor::target", &ctx)
7337 })
7338 .ok_or("missing semantic queries")?;
7339 let anchor_id = candidates
7340 .first()
7341 .map(|candidate| candidate.anchor_id)
7342 .ok_or("missing unicode definition candidate")?;
7343 let shard = index.file_fact_shard(uri).ok_or("missing fact shard")?;
7344 let anchor = shard
7345 .anchors
7346 .iter()
7347 .find(|anchor| anchor.id == anchor_id)
7348 .ok_or("missing unicode anchor")?;
7349 let start = usize::try_from(anchor.span_start_byte)?;
7350 let end = usize::try_from(anchor.span_end_byte)?;
7351 let expected = WireRange::from_byte_offsets(code, start, end);
7352
7353 let location =
7354 index.semantic_anchor_wire_location(anchor_id).ok_or("missing wire location")?;
7355
7356 assert_eq!(location.range, expected);
7357 let wire_column = usize::try_from(location.range.start.character)?;
7358 let scalar_column = code[..start].chars().count();
7359 assert!(
7360 wire_column > scalar_column,
7361 "fixture must prove the wire column counts UTF-16 units, not Unicode scalar values"
7362 );
7363
7364 Ok(())
7365 }
7366
7367 #[test]
7368 fn semantic_anchor_wire_location_fails_closed_for_duplicate_anchor_ids()
7369 -> Result<(), Box<dyn std::error::Error>> {
7370 use crate::semantic::queries::SemanticQueries;
7371
7372 let index = WorkspaceIndex::new();
7373 let code = "package DuplicateAnchor;\nsub target { 1 }\n1;\n";
7374
7375 must(
7376 index.index_file(must(url::Url::parse("file:///lib/DuplicateA.pm")), code.to_string()),
7377 );
7378 must(
7379 index.index_file(must(url::Url::parse("file:///lib/DuplicateB.pm")), code.to_string()),
7380 );
7381
7382 let candidates = index
7383 .with_semantic_queries_for_uri("file:///lib/DuplicateA.pm", |file_id, queries| {
7384 let ctx = crate::semantic::queries::QueryContext::new(file_id, None, Some(0));
7385 queries.definitions("DuplicateAnchor::target", &ctx)
7386 })
7387 .ok_or("missing semantic queries")?;
7388
7389 let anchor_id = candidates
7390 .first()
7391 .map(|candidate| candidate.anchor_id)
7392 .ok_or("missing duplicate definition candidate")?;
7393 assert!(
7394 candidates.iter().filter(|candidate| candidate.anchor_id == anchor_id).count() > 1,
7395 "fixture must produce duplicate anchor IDs to prove fail-closed behavior"
7396 );
7397 assert_eq!(
7398 index.semantic_anchor_wire_location(anchor_id),
7399 None,
7400 "duplicate source-backed anchors must not resolve to an arbitrary file"
7401 );
7402
7403 Ok(())
7404 }
7405
7406 #[test]
7407 fn semantic_anchor_wire_location_for_file_resolves_duplicate_anchor_ids_by_file()
7408 -> Result<(), Box<dyn std::error::Error>> {
7409 use crate::semantic::queries::SemanticQueries;
7410
7411 let index = WorkspaceIndex::new();
7412 let code = "package DuplicateAnchor;\nsub target { 1 }\n1;\n";
7413 let uri_a = "file:///lib/DuplicateA.pm";
7414 let uri_b = "file:///lib/DuplicateB.pm";
7415
7416 must(index.index_file(must(url::Url::parse(uri_a)), code.to_string()));
7417 must(index.index_file(must(url::Url::parse(uri_b)), code.to_string()));
7418
7419 let (file_id_a, anchor_id) = index
7420 .with_semantic_queries_for_uri(uri_a, |file_id, queries| {
7421 let ctx = crate::semantic::queries::QueryContext::new(file_id, None, Some(0));
7422 queries
7423 .definitions("DuplicateAnchor::target", &ctx)
7424 .first()
7425 .map(|candidate| (file_id, candidate.anchor_id))
7426 })
7427 .flatten()
7428 .ok_or("missing duplicate definition candidate")?;
7429
7430 assert_eq!(
7431 index.semantic_anchor_wire_location(anchor_id),
7432 None,
7433 "global anchor lookup must still fail closed for duplicate anchor IDs"
7434 );
7435
7436 let location = index
7437 .semantic_anchor_wire_location_for_file(file_id_a, anchor_id)
7438 .ok_or("file-scoped anchor lookup should resolve duplicate anchor ID")?;
7439 assert_eq!(location.uri, uri_a);
7440
7441 Ok(())
7442 }
7443
7444 mod prop_incremental_invalidation {
7447 use super::*;
7448 use proptest::prelude::*;
7449 use proptest::test_runner::Config as ProptestConfig;
7450
7451 fn arb_category_hash() -> impl Strategy<Value = Option<u64>> {
7456 prop_oneof![
7457 1 => Just(None),
7458 9 => any::<u64>().prop_map(Some),
7459 ]
7460 }
7461
7462 fn arb_shard(uri: &'static str) -> impl Strategy<Value = FileFactShard> {
7465 (
7466 any::<u64>(), arb_category_hash(), arb_category_hash(), arb_category_hash(), arb_category_hash(), )
7472 .prop_map(move |(content_hash, ah, eh, oh, edh)| {
7473 make_shard(uri, content_hash, ah, eh, oh, edh)
7474 })
7475 }
7476
7477 proptest! {
7489 #![proptest_config(ProptestConfig {
7490 failure_persistence: None,
7491 ..ProptestConfig::default()
7492 })]
7493
7494 #[test]
7495 fn prop_incremental_invalidation_correctness(
7496 old_shard in arb_shard("file:///lib/Prop.pm"),
7497 new_shard in arb_shard("file:///lib/Prop.pm"),
7498 ) {
7499 let index = WorkspaceIndex::new();
7500 let key = DocumentStore::uri_key("file:///lib/Prop.pm");
7501
7502 index.replace_fact_shard_incremental(&key, old_shard.clone());
7504
7505 let result = index.replace_fact_shard_incremental(&key, new_shard.clone());
7507
7508 if old_shard.content_hash == new_shard.content_hash {
7510 prop_assert!(
7511 result.content_unchanged,
7512 "content_unchanged must be true when content_hash is the same"
7513 );
7514 prop_assert!(
7515 !result.anchors_updated,
7516 "anchors_updated must be false when content_hash unchanged"
7517 );
7518 prop_assert!(
7519 !result.entities_updated,
7520 "entities_updated must be false when content_hash unchanged"
7521 );
7522 prop_assert!(
7523 !result.occurrences_updated,
7524 "occurrences_updated must be false when content_hash unchanged"
7525 );
7526 prop_assert!(
7527 !result.edges_updated,
7528 "edges_updated must be false when content_hash unchanged"
7529 );
7530 } else {
7531 prop_assert!(
7532 !result.content_unchanged,
7533 "content_unchanged must be false when content_hash differs"
7534 );
7535
7536 let anchors_should_update = crate::semantic::invalidation::category_hash_changed(
7542 old_shard.anchors_hash,
7543 new_shard.anchors_hash,
7544 );
7545 prop_assert_eq!(
7546 result.anchors_updated,
7547 anchors_should_update,
7548 "anchors_updated mismatch: old={:?} new={:?}",
7549 old_shard.anchors_hash,
7550 new_shard.anchors_hash,
7551 );
7552
7553 let entities_should_update =
7554 crate::semantic::invalidation::category_hash_changed(
7555 old_shard.entities_hash,
7556 new_shard.entities_hash,
7557 );
7558 prop_assert_eq!(
7559 result.entities_updated,
7560 entities_should_update,
7561 "entities_updated mismatch: old={:?} new={:?}",
7562 old_shard.entities_hash,
7563 new_shard.entities_hash,
7564 );
7565
7566 let occurrences_should_update =
7567 crate::semantic::invalidation::category_hash_changed(
7568 old_shard.occurrences_hash,
7569 new_shard.occurrences_hash,
7570 );
7571 prop_assert_eq!(
7572 result.occurrences_updated,
7573 occurrences_should_update,
7574 "occurrences_updated mismatch: old={:?} new={:?}",
7575 old_shard.occurrences_hash,
7576 new_shard.occurrences_hash,
7577 );
7578
7579 let edges_should_update = crate::semantic::invalidation::category_hash_changed(
7580 old_shard.edges_hash,
7581 new_shard.edges_hash,
7582 );
7583 prop_assert_eq!(
7584 result.edges_updated,
7585 edges_should_update,
7586 "edges_updated mismatch: old={:?} new={:?}",
7587 old_shard.edges_hash,
7588 new_shard.edges_hash,
7589 );
7590 }
7591 }
7592 }
7593 }
7594}
7595
7596#[cfg(test)]
7599mod semantic_query_callback_tests {
7600 use super::*;
7601 use perl_tdd_support::{must, must_some};
7602
7603 #[test]
7604 fn with_semantic_queries_for_uri_indexed_uri_invokes_callback()
7605 -> Result<(), Box<dyn std::error::Error>> {
7606 let index = WorkspaceIndex::new();
7607 let uri = "file:///lib/Foo.pm";
7608 must(index.index_file(must(url::Url::parse(uri)), "sub foo { 1 }".to_string()));
7609
7610 let result = index.with_semantic_queries_for_uri(uri, |file_id, _queries| {
7611 assert_ne!(file_id.0, 0, "file_id should be non-zero");
7613 42u32 });
7615
7616 assert_eq!(result, Some(42u32), "callback must run when URI is indexed");
7617 Ok(())
7618 }
7619
7620 #[test]
7621 fn with_semantic_queries_for_uri_unknown_uri_returns_none()
7622 -> Result<(), Box<dyn std::error::Error>> {
7623 let index = WorkspaceIndex::new();
7624 let result = index.with_semantic_queries_for_uri("file:///not/indexed.pl", |_, _| 99u32);
7626 assert!(result.is_none(), "unindexed URI must return None without invoking callback");
7627 Ok(())
7628 }
7629
7630 #[test]
7631 fn with_semantic_queries_for_uri_file_id_matches_file_id_for_uri()
7632 -> Result<(), Box<dyn std::error::Error>> {
7633 let index = WorkspaceIndex::new();
7634 let uri = "file:///lib/Bar.pm";
7635 must(index.index_file(must(url::Url::parse(uri)), "sub bar { 1 }".to_string()));
7636
7637 let direct_id = must_some(index.file_id_for_uri(uri));
7638 let callback_id =
7639 must_some(index.with_semantic_queries_for_uri(uri, |file_id, _q| file_id));
7640
7641 assert_eq!(
7642 direct_id, callback_id,
7643 "file_id_for_uri and with_semantic_queries_for_uri must agree"
7644 );
7645 Ok(())
7646 }
7647
7648 #[test]
7649 fn with_semantic_queries_for_uri_callback_not_called_when_not_indexed()
7650 -> Result<(), Box<dyn std::error::Error>> {
7651 let index = WorkspaceIndex::new();
7652 let mut called = false;
7653 let _ = index.with_semantic_queries_for_uri("file:///ghost.pl", |_, _| {
7654 called = true;
7655 });
7656 assert!(!called, "callback must not be invoked for unindexed URI");
7657 Ok(())
7658 }
7659}