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 serde::{Deserialize, Serialize};
72use std::collections::hash_map::DefaultHasher;
73use std::collections::{HashMap, HashSet};
74use std::hash::{Hash, Hasher};
75use std::path::Path;
76use std::sync::Arc;
77use std::time::Instant;
78use url::Url;
79
80pub use crate::workspace::monitoring::{
81 DegradationReason, EarlyExitReason, EarlyExitRecord, IndexInstrumentationSnapshot,
82 IndexMetrics, IndexPerformanceCaps, IndexPhase, IndexPhaseTransition, IndexResourceLimits,
83 IndexStateKind, IndexStateTransition, ResourceKind,
84};
85
86#[cfg(not(target_arch = "wasm32"))]
88pub use perl_uri::{fs_path_to_uri, uri_to_fs_path};
90pub use perl_uri::{is_file_uri, is_special_scheme, uri_extension, uri_key};
92
93#[derive(Clone, Debug)]
134pub enum IndexState {
135 Building {
137 phase: IndexPhase,
139 indexed_count: usize,
141 total_count: usize,
143 started_at: Instant,
145 },
146
147 Ready {
149 symbol_count: usize,
151 file_count: usize,
153 completed_at: Instant,
155 },
156
157 Degraded {
159 reason: DegradationReason,
161 available_symbols: usize,
163 since: Instant,
165 },
166}
167
168impl IndexState {
169 pub fn kind(&self) -> IndexStateKind {
171 match self {
172 IndexState::Building { .. } => IndexStateKind::Building,
173 IndexState::Ready { .. } => IndexStateKind::Ready,
174 IndexState::Degraded { .. } => IndexStateKind::Degraded,
175 }
176 }
177
178 pub fn phase(&self) -> Option<IndexPhase> {
180 match self {
181 IndexState::Building { phase, .. } => Some(*phase),
182 _ => None,
183 }
184 }
185
186 pub fn state_started_at(&self) -> Instant {
188 match self {
189 IndexState::Building { started_at, .. } => *started_at,
190 IndexState::Ready { completed_at, .. } => *completed_at,
191 IndexState::Degraded { since, .. } => *since,
192 }
193 }
194}
195
196pub struct IndexCoordinator {
248 state: Arc<RwLock<IndexState>>,
250
251 index: Arc<WorkspaceIndex>,
253
254 limits: IndexResourceLimits,
261
262 caps: IndexPerformanceCaps,
264
265 metrics: IndexMetrics,
267
268 instrumentation: IndexInstrumentation,
270}
271
272impl std::fmt::Debug for IndexCoordinator {
273 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
274 f.debug_struct("IndexCoordinator")
275 .field("state", &*self.state.read())
276 .field("limits", &self.limits)
277 .field("caps", &self.caps)
278 .finish_non_exhaustive()
279 }
280}
281
282impl IndexCoordinator {
283 pub fn new() -> Self {
300 Self {
301 state: Arc::new(RwLock::new(IndexState::Building {
302 phase: IndexPhase::Idle,
303 indexed_count: 0,
304 total_count: 0,
305 started_at: Instant::now(),
306 })),
307 index: Arc::new(WorkspaceIndex::new()),
308 limits: IndexResourceLimits::default(),
309 caps: IndexPerformanceCaps::default(),
310 metrics: IndexMetrics::new(),
311 instrumentation: IndexInstrumentation::new(),
312 }
313 }
314
315 pub fn with_limits(limits: IndexResourceLimits) -> Self {
334 Self {
335 state: Arc::new(RwLock::new(IndexState::Building {
336 phase: IndexPhase::Idle,
337 indexed_count: 0,
338 total_count: 0,
339 started_at: Instant::now(),
340 })),
341 index: Arc::new(WorkspaceIndex::new()),
342 limits,
343 caps: IndexPerformanceCaps::default(),
344 metrics: IndexMetrics::new(),
345 instrumentation: IndexInstrumentation::new(),
346 }
347 }
348
349 pub fn with_limits_and_caps(limits: IndexResourceLimits, caps: IndexPerformanceCaps) -> Self {
356 Self {
357 state: Arc::new(RwLock::new(IndexState::Building {
358 phase: IndexPhase::Idle,
359 indexed_count: 0,
360 total_count: 0,
361 started_at: Instant::now(),
362 })),
363 index: Arc::new(WorkspaceIndex::new()),
364 limits,
365 caps,
366 metrics: IndexMetrics::new(),
367 instrumentation: IndexInstrumentation::new(),
368 }
369 }
370
371 pub fn state(&self) -> IndexState {
396 self.state.read().clone()
397 }
398
399 pub fn index(&self) -> &Arc<WorkspaceIndex> {
417 &self.index
418 }
419
420 pub fn limits(&self) -> &IndexResourceLimits {
422 &self.limits
423 }
424
425 pub fn performance_caps(&self) -> &IndexPerformanceCaps {
427 &self.caps
428 }
429
430 pub fn instrumentation_snapshot(&self) -> IndexInstrumentationSnapshot {
432 self.instrumentation.snapshot()
433 }
434
435 pub fn notify_change(&self, _uri: &str) {
457 let pending = self.metrics.increment_pending_parses();
458
459 if self.metrics.is_parse_storm() {
461 self.transition_to_degraded(DegradationReason::ParseStorm { pending_parses: pending });
462 }
463 }
464
465 pub fn notify_parse_complete(&self, _uri: &str) {
487 let pending = self.metrics.decrement_pending_parses();
488
489 if pending == 0 {
491 if let IndexState::Degraded { reason: DegradationReason::ParseStorm { .. }, .. } =
492 self.state()
493 {
494 let mut state = self.state.write();
496 let from_kind = state.kind();
497 self.instrumentation.record_state_transition(from_kind, IndexStateKind::Building);
498 *state = IndexState::Building {
499 phase: IndexPhase::Idle,
500 indexed_count: 0,
501 total_count: 0,
502 started_at: Instant::now(),
503 };
504 }
505 }
506
507 self.enforce_limits();
509 }
510
511 pub fn transition_to_ready(&self, file_count: usize, symbol_count: usize) {
541 let mut state = self.state.write();
542 let from_kind = state.kind();
543
544 match &*state {
546 IndexState::Building { .. } | IndexState::Degraded { .. } => {
547 *state =
549 IndexState::Ready { symbol_count, file_count, completed_at: Instant::now() };
550 }
551 IndexState::Ready { .. } => {
552 *state =
554 IndexState::Ready { symbol_count, file_count, completed_at: Instant::now() };
555 }
556 }
557 self.instrumentation.record_state_transition(from_kind, IndexStateKind::Ready);
558 drop(state); self.enforce_limits();
562 }
563
564 pub fn transition_to_scanning(&self) {
568 let mut state = self.state.write();
569 let from_kind = state.kind();
570
571 match &*state {
572 IndexState::Building { phase, indexed_count, total_count, started_at } => {
573 if *phase != IndexPhase::Scanning {
574 self.instrumentation.record_phase_transition(*phase, IndexPhase::Scanning);
575 }
576 *state = IndexState::Building {
577 phase: IndexPhase::Scanning,
578 indexed_count: *indexed_count,
579 total_count: *total_count,
580 started_at: *started_at,
581 };
582 }
583 IndexState::Ready { .. } | IndexState::Degraded { .. } => {
584 self.instrumentation.record_state_transition(from_kind, IndexStateKind::Building);
585 self.instrumentation
586 .record_phase_transition(IndexPhase::Idle, IndexPhase::Scanning);
587 *state = IndexState::Building {
588 phase: IndexPhase::Scanning,
589 indexed_count: 0,
590 total_count: 0,
591 started_at: Instant::now(),
592 };
593 }
594 }
595 }
596
597 pub fn update_scan_progress(&self, total_count: usize) {
599 let mut state = self.state.write();
600 if let IndexState::Building { phase, indexed_count, started_at, .. } = &*state {
601 if *phase != IndexPhase::Scanning {
602 self.instrumentation.record_phase_transition(*phase, IndexPhase::Scanning);
603 }
604 *state = IndexState::Building {
605 phase: IndexPhase::Scanning,
606 indexed_count: *indexed_count,
607 total_count,
608 started_at: *started_at,
609 };
610 }
611 }
612
613 pub fn transition_to_indexing(&self, total_count: usize) {
617 let mut state = self.state.write();
618 let from_kind = state.kind();
619
620 match &*state {
621 IndexState::Building { phase, indexed_count, started_at, .. } => {
622 if *phase != IndexPhase::Indexing {
623 self.instrumentation.record_phase_transition(*phase, IndexPhase::Indexing);
624 }
625 *state = IndexState::Building {
626 phase: IndexPhase::Indexing,
627 indexed_count: *indexed_count,
628 total_count,
629 started_at: *started_at,
630 };
631 }
632 IndexState::Ready { .. } | IndexState::Degraded { .. } => {
633 self.instrumentation.record_state_transition(from_kind, IndexStateKind::Building);
634 self.instrumentation
635 .record_phase_transition(IndexPhase::Idle, IndexPhase::Indexing);
636 *state = IndexState::Building {
637 phase: IndexPhase::Indexing,
638 indexed_count: 0,
639 total_count,
640 started_at: Instant::now(),
641 };
642 }
643 }
644 }
645
646 pub fn transition_to_building(&self, total_count: usize) {
650 let mut state = self.state.write();
651 let from_kind = state.kind();
652
653 match &*state {
655 IndexState::Degraded { .. } | IndexState::Ready { .. } => {
656 self.instrumentation.record_state_transition(from_kind, IndexStateKind::Building);
657 self.instrumentation
658 .record_phase_transition(IndexPhase::Idle, IndexPhase::Indexing);
659 *state = IndexState::Building {
660 phase: IndexPhase::Indexing,
661 indexed_count: 0,
662 total_count,
663 started_at: Instant::now(),
664 };
665 }
666 IndexState::Building { phase, indexed_count, started_at, .. } => {
667 let mut next_phase = *phase;
668 if *phase == IndexPhase::Idle {
669 self.instrumentation
670 .record_phase_transition(IndexPhase::Idle, IndexPhase::Indexing);
671 next_phase = IndexPhase::Indexing;
672 }
673 *state = IndexState::Building {
674 phase: next_phase,
675 indexed_count: *indexed_count,
676 total_count,
677 started_at: *started_at,
678 };
679 }
680 }
681 }
682
683 pub fn update_building_progress(&self, indexed_count: usize) {
705 let mut state = self.state.write();
706
707 if let IndexState::Building { phase, started_at, total_count, .. } = &*state {
708 let elapsed = started_at.elapsed().as_millis() as u64;
709
710 if elapsed > self.limits.max_scan_duration_ms {
712 drop(state);
714 self.transition_to_degraded(DegradationReason::ScanTimeout { elapsed_ms: elapsed });
715 return;
716 }
717
718 *state = IndexState::Building {
720 phase: *phase,
721 indexed_count,
722 total_count: *total_count,
723 started_at: *started_at,
724 };
725 }
726 }
727
728 pub fn transition_to_degraded(&self, reason: DegradationReason) {
753 let mut state = self.state.write();
754 let from_kind = state.kind();
755
756 let available_symbols = match &*state {
758 IndexState::Ready { symbol_count, .. } => *symbol_count,
759 IndexState::Degraded { available_symbols, .. } => *available_symbols,
760 IndexState::Building { .. } => 0,
761 };
762
763 self.instrumentation.record_state_transition(from_kind, IndexStateKind::Degraded);
764 *state = IndexState::Degraded { reason, available_symbols, since: Instant::now() };
765 }
766
767 pub fn check_limits(&self) -> Option<DegradationReason> {
798 let files = self.index.files.read();
799
800 let file_count = files.len();
802 if file_count > self.limits.max_files {
803 return Some(DegradationReason::ResourceLimit { kind: ResourceKind::MaxFiles });
804 }
805
806 let total_symbols: usize = files.values().map(|fi| fi.symbols.len()).sum();
808 if total_symbols > self.limits.max_total_symbols {
809 return Some(DegradationReason::ResourceLimit { kind: ResourceKind::MaxSymbols });
810 }
811
812 None
813 }
814
815 pub fn enforce_limits(&self) {
841 if let Some(reason) = self.check_limits() {
842 self.transition_to_degraded(reason);
843 }
844 }
845
846 pub fn record_early_exit(
848 &self,
849 reason: EarlyExitReason,
850 elapsed_ms: u64,
851 indexed_files: usize,
852 total_files: usize,
853 ) {
854 self.instrumentation.record_early_exit(EarlyExitRecord {
855 reason,
856 elapsed_ms,
857 indexed_files,
858 total_files,
859 });
860 }
861
862 pub fn query<T, F1, F2>(&self, full_query: F1, partial_query: F2) -> T
895 where
896 F1: FnOnce(&WorkspaceIndex) -> T,
897 F2: FnOnce(&WorkspaceIndex) -> T,
898 {
899 match self.state() {
900 IndexState::Ready { .. } => full_query(&self.index),
901 _ => partial_query(&self.index),
902 }
903 }
904}
905
906impl Default for IndexCoordinator {
907 fn default() -> Self {
908 Self::new()
909 }
910}
911
912#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
917pub enum SymKind {
919 Var,
921 Sub,
923 Pack,
925}
926
927#[derive(Clone, Debug, Eq, PartialEq, Hash)]
928pub struct SymbolKey {
930 pub pkg: Arc<str>,
932 pub name: Arc<str>,
934 pub sigil: Option<char>,
936 pub kind: SymKind,
938}
939
940pub fn normalize_var(name: &str) -> (Option<char>, &str) {
961 if name.is_empty() {
962 return (None, "");
963 }
964
965 let Some(first_char) = name.chars().next() else {
967 return (None, name); };
969 match first_char {
970 '$' | '@' | '%' => {
971 if name.len() > 1 {
972 (Some(first_char), &name[1..])
973 } else {
974 (Some(first_char), "")
975 }
976 }
977 _ => (None, name),
978 }
979}
980
981#[derive(Debug, Clone)]
984pub struct Location {
986 pub uri: String,
988 pub range: Range,
990}
991
992#[derive(Debug, Clone, Serialize, Deserialize)]
993pub struct WorkspaceSymbol {
995 pub name: String,
997 pub kind: SymbolKind,
999 pub uri: String,
1001 pub range: Range,
1003 pub qualified_name: Option<String>,
1005 pub documentation: Option<String>,
1007 pub container_name: Option<String>,
1009 #[serde(default = "default_has_body")]
1011 pub has_body: bool,
1012}
1013
1014fn default_has_body() -> bool {
1015 true
1016}
1017
1018pub use perl_symbol_types::{SymbolKind, VarKind};
1021
1022fn sigil_to_var_kind(sigil: &str) -> VarKind {
1024 match sigil {
1025 "@" => VarKind::Array,
1026 "%" => VarKind::Hash,
1027 _ => VarKind::Scalar, }
1029}
1030
1031#[derive(Debug, Clone)]
1032pub struct SymbolReference {
1034 pub uri: String,
1036 pub range: Range,
1038 pub kind: ReferenceKind,
1040}
1041
1042#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1043pub enum ReferenceKind {
1045 Definition,
1047 Usage,
1049 Import,
1051 Read,
1053 Write,
1055}
1056
1057#[derive(Debug, Serialize)]
1058#[serde(rename_all = "camelCase")]
1059pub struct LspWorkspaceSymbol {
1061 pub name: String,
1063 pub kind: u32,
1065 pub location: WireLocation,
1067 #[serde(skip_serializing_if = "Option::is_none")]
1069 pub container_name: Option<String>,
1070}
1071
1072impl From<&WorkspaceSymbol> for LspWorkspaceSymbol {
1073 fn from(sym: &WorkspaceSymbol) -> Self {
1074 let range = WireRange {
1075 start: WirePosition { line: sym.range.start.line, character: sym.range.start.column },
1076 end: WirePosition { line: sym.range.end.line, character: sym.range.end.column },
1077 };
1078
1079 Self {
1080 name: sym.name.clone(),
1081 kind: sym.kind.to_lsp_kind(),
1082 location: WireLocation { uri: sym.uri.clone(), range },
1083 container_name: sym.container_name.clone(),
1084 }
1085 }
1086}
1087
1088#[derive(Default)]
1090struct FileIndex {
1091 symbols: Vec<WorkspaceSymbol>,
1093 references: HashMap<String, Vec<SymbolReference>>,
1095 dependencies: HashSet<String>,
1097 content_hash: u64,
1099}
1100
1101pub struct WorkspaceIndex {
1103 files: Arc<RwLock<HashMap<String, FileIndex>>>,
1105 symbols: Arc<RwLock<HashMap<String, String>>>,
1107 global_references: Arc<RwLock<HashMap<String, Vec<Location>>>>,
1112 document_store: DocumentStore,
1114}
1115
1116impl WorkspaceIndex {
1117 fn rebuild_symbol_cache(
1118 files: &HashMap<String, FileIndex>,
1119 symbols: &mut HashMap<String, String>,
1120 ) {
1121 symbols.clear();
1122
1123 for file_index in files.values() {
1124 for symbol in &file_index.symbols {
1125 if let Some(ref qname) = symbol.qualified_name {
1126 symbols.insert(qname.clone(), symbol.uri.clone());
1127 }
1128 symbols.insert(symbol.name.clone(), symbol.uri.clone());
1129 }
1130 }
1131 }
1132
1133 fn incremental_remove_symbols(
1136 files: &HashMap<String, FileIndex>,
1137 symbols: &mut HashMap<String, String>,
1138 old_file_index: &FileIndex,
1139 ) {
1140 let mut affected_names: Vec<String> = Vec::new();
1141 for sym in &old_file_index.symbols {
1142 if let Some(ref qname) = sym.qualified_name {
1143 if symbols.get(qname) == Some(&sym.uri) {
1144 symbols.remove(qname);
1145 affected_names.push(qname.clone());
1146 }
1147 }
1148 if symbols.get(&sym.name) == Some(&sym.uri) {
1149 symbols.remove(&sym.name);
1150 affected_names.push(sym.name.clone());
1151 }
1152 }
1153 if !affected_names.is_empty() {
1154 for file_index in files.values() {
1155 for sym in &file_index.symbols {
1156 if let Some(ref qname) = sym.qualified_name {
1157 if !symbols.contains_key(qname) && affected_names.contains(qname) {
1158 symbols.insert(qname.clone(), sym.uri.clone());
1159 }
1160 }
1161 if !symbols.contains_key(&sym.name) && affected_names.contains(&sym.name) {
1162 symbols.insert(sym.name.clone(), sym.uri.clone());
1163 }
1164 }
1165 }
1166 }
1167 }
1168
1169 fn incremental_add_symbols(symbols: &mut HashMap<String, String>, file_index: &FileIndex) {
1171 for sym in &file_index.symbols {
1172 if let Some(ref qname) = sym.qualified_name {
1173 symbols.insert(qname.clone(), sym.uri.clone());
1174 }
1175 symbols.insert(sym.name.clone(), sym.uri.clone());
1176 }
1177 }
1178
1179 fn find_definition_in_files(
1180 files: &HashMap<String, FileIndex>,
1181 symbol_name: &str,
1182 uri_filter: Option<&str>,
1183 ) -> Option<(Location, String)> {
1184 for file_index in files.values() {
1185 if let Some(filter) = uri_filter
1186 && file_index.symbols.first().is_some_and(|symbol| symbol.uri != filter)
1187 {
1188 continue;
1189 }
1190
1191 for symbol in &file_index.symbols {
1192 if symbol.name == symbol_name
1193 || symbol.qualified_name.as_deref() == Some(symbol_name)
1194 {
1195 return Some((
1196 Location { uri: symbol.uri.clone(), range: symbol.range },
1197 symbol.uri.clone(),
1198 ));
1199 }
1200 }
1201 }
1202
1203 None
1204 }
1205
1206 pub fn new() -> Self {
1221 Self {
1222 files: Arc::new(RwLock::new(HashMap::new())),
1223 symbols: Arc::new(RwLock::new(HashMap::new())),
1224 global_references: Arc::new(RwLock::new(HashMap::new())),
1225 document_store: DocumentStore::new(),
1226 }
1227 }
1228
1229 fn normalize_uri(uri: &str) -> String {
1231 perl_uri::normalize_uri(uri)
1232 }
1233
1234 fn remove_file_global_refs(
1239 global_refs: &mut HashMap<String, Vec<Location>>,
1240 file_index: &FileIndex,
1241 file_uri: &str,
1242 ) {
1243 for name in file_index.references.keys() {
1244 if let Some(locs) = global_refs.get_mut(name) {
1245 locs.retain(|loc| loc.uri != file_uri);
1246 if locs.is_empty() {
1247 global_refs.remove(name);
1248 }
1249 }
1250 }
1251 }
1252
1253 pub fn index_file(&self, uri: Url, text: String) -> Result<(), String> {
1284 let uri_str = uri.to_string();
1285
1286 let mut hasher = DefaultHasher::new();
1288 text.hash(&mut hasher);
1289 let content_hash = hasher.finish();
1290
1291 let key = DocumentStore::uri_key(&uri_str);
1293 {
1294 let files = self.files.read();
1295 if let Some(existing_index) = files.get(&key) {
1296 if existing_index.content_hash == content_hash {
1297 return Ok(());
1299 }
1300 }
1301 }
1302
1303 if self.document_store.is_open(&uri_str) {
1305 self.document_store.update(&uri_str, 1, text.clone());
1306 } else {
1307 self.document_store.open(uri_str.clone(), 1, text.clone());
1308 }
1309
1310 let mut parser = Parser::new(&text);
1312 let ast = match parser.parse() {
1313 Ok(ast) => ast,
1314 Err(e) => return Err(format!("Parse error: {}", e)),
1315 };
1316
1317 let mut doc = self.document_store.get(&uri_str).ok_or("Document not found")?;
1319
1320 let mut file_index = FileIndex { content_hash, ..Default::default() };
1322 let mut visitor = IndexVisitor::new(&mut doc, uri_str.clone());
1323 visitor.visit(&ast, &mut file_index);
1324
1325 {
1328 let mut files = self.files.write();
1329
1330 if let Some(old_index) = files.get(&key) {
1332 let mut global_refs = self.global_references.write();
1333 Self::remove_file_global_refs(&mut global_refs, old_index, &uri_str);
1334 }
1335
1336 if let Some(old_index) = files.get(&key) {
1338 let mut symbols = self.symbols.write();
1339 Self::incremental_remove_symbols(&files, &mut symbols, old_index);
1340 drop(symbols);
1341 }
1342 files.insert(key.clone(), file_index);
1343 let mut symbols = self.symbols.write();
1344 if let Some(new_index) = files.get(&key) {
1345 Self::incremental_add_symbols(&mut symbols, new_index);
1346 }
1347
1348 if let Some(file_index) = files.get(&key) {
1349 let mut global_refs = self.global_references.write();
1350 for (name, refs) in &file_index.references {
1351 let entry = global_refs.entry(name.clone()).or_default();
1352 for reference in refs {
1353 entry.push(Location { uri: reference.uri.clone(), range: reference.range });
1354 }
1355 }
1356 }
1357 }
1358
1359 Ok(())
1360 }
1361
1362 pub fn remove_file(&self, uri: &str) {
1381 let uri_str = Self::normalize_uri(uri);
1382 let key = DocumentStore::uri_key(&uri_str);
1383
1384 self.document_store.close(&uri_str);
1386
1387 let mut files = self.files.write();
1389 if let Some(file_index) = files.remove(&key) {
1390 let mut symbols = self.symbols.write();
1392 Self::incremental_remove_symbols(&files, &mut symbols, &file_index);
1393
1394 let mut global_refs = self.global_references.write();
1396 Self::remove_file_global_refs(&mut global_refs, &file_index, &uri_str);
1397 }
1398 }
1399
1400 pub fn remove_file_url(&self, uri: &Url) {
1424 self.remove_file(uri.as_str())
1425 }
1426
1427 pub fn clear_file(&self, uri: &str) {
1446 self.remove_file(uri);
1447 }
1448
1449 pub fn clear_file_url(&self, uri: &Url) {
1473 self.clear_file(uri.as_str())
1474 }
1475
1476 #[cfg(not(target_arch = "wasm32"))]
1477 pub fn index_file_str(&self, uri: &str, text: &str) -> Result<(), String> {
1507 let path = Path::new(uri);
1508 let url = if path.is_absolute() {
1509 url::Url::from_file_path(path)
1510 .map_err(|_| format!("Invalid URI or file path: {}", uri))?
1511 } else {
1512 url::Url::parse(uri).or_else(|_| {
1515 url::Url::from_file_path(path)
1516 .map_err(|_| format!("Invalid URI or file path: {}", uri))
1517 })?
1518 };
1519 self.index_file(url, text.to_string())
1520 }
1521
1522 pub fn index_files_batch(&self, files_to_index: Vec<(Url, String)>) -> Vec<String> {
1531 let mut errors = Vec::new();
1532
1533 let mut parsed: Vec<(String, String, FileIndex)> = Vec::with_capacity(files_to_index.len());
1535 for (uri, text) in &files_to_index {
1536 let uri_str = uri.to_string();
1537
1538 let mut hasher = DefaultHasher::new();
1540 text.hash(&mut hasher);
1541 let content_hash = hasher.finish();
1542
1543 let key = DocumentStore::uri_key(&uri_str);
1544
1545 {
1547 let files = self.files.read();
1548 if let Some(existing) = files.get(&key) {
1549 if existing.content_hash == content_hash {
1550 continue;
1551 }
1552 }
1553 }
1554
1555 if self.document_store.is_open(&uri_str) {
1557 self.document_store.update(&uri_str, 1, text.clone());
1558 } else {
1559 self.document_store.open(uri_str.clone(), 1, text.clone());
1560 }
1561
1562 let mut parser = Parser::new(text);
1564 let ast = match parser.parse() {
1565 Ok(ast) => ast,
1566 Err(e) => {
1567 errors.push(format!("Parse error in {}: {}", uri_str, e));
1568 continue;
1569 }
1570 };
1571
1572 let mut doc = match self.document_store.get(&uri_str) {
1573 Some(d) => d,
1574 None => {
1575 errors.push(format!("Document not found: {}", uri_str));
1576 continue;
1577 }
1578 };
1579
1580 let mut file_index = FileIndex { content_hash, ..Default::default() };
1581 let mut visitor = IndexVisitor::new(&mut doc, uri_str.clone());
1582 visitor.visit(&ast, &mut file_index);
1583
1584 parsed.push((key, uri_str, file_index));
1585 }
1586
1587 {
1589 let mut files = self.files.write();
1590 let mut symbols = self.symbols.write();
1591 let mut global_refs = self.global_references.write();
1592
1593 for (key, uri_str, file_index) in parsed {
1594 if let Some(old_index) = files.get(&key) {
1596 Self::remove_file_global_refs(&mut global_refs, old_index, &uri_str);
1597 }
1598
1599 files.insert(key.clone(), file_index);
1600
1601 if let Some(fi) = files.get(&key) {
1603 for (name, refs) in &fi.references {
1604 let entry = global_refs.entry(name.clone()).or_default();
1605 for reference in refs {
1606 entry.push(Location {
1607 uri: reference.uri.clone(),
1608 range: reference.range,
1609 });
1610 }
1611 }
1612 }
1613 }
1614
1615 Self::rebuild_symbol_cache(&files, &mut symbols);
1617 }
1618
1619 errors
1620 }
1621
1622 pub fn find_references(&self, symbol_name: &str) -> Vec<Location> {
1650 let global_refs = self.global_references.read();
1651 let mut seen: HashSet<(String, u32, u32, u32, u32)> = HashSet::new();
1652 let mut locations = Vec::new();
1653
1654 if let Some(refs) = global_refs.get(symbol_name) {
1656 for loc in refs {
1657 let key = (
1658 loc.uri.clone(),
1659 loc.range.start.line,
1660 loc.range.start.column,
1661 loc.range.end.line,
1662 loc.range.end.column,
1663 );
1664 if seen.insert(key) {
1665 locations.push(Location { uri: loc.uri.clone(), range: loc.range });
1666 }
1667 }
1668 }
1669
1670 if let Some(idx) = symbol_name.rfind("::") {
1672 let bare_name = &symbol_name[idx + 2..];
1673 if let Some(refs) = global_refs.get(bare_name) {
1674 for loc in refs {
1675 let key = (
1676 loc.uri.clone(),
1677 loc.range.start.line,
1678 loc.range.start.column,
1679 loc.range.end.line,
1680 loc.range.end.column,
1681 );
1682 if seen.insert(key) {
1683 locations.push(Location { uri: loc.uri.clone(), range: loc.range });
1684 }
1685 }
1686 }
1687 }
1688
1689 locations
1690 }
1691
1692 pub fn count_usages(&self, symbol_name: &str) -> usize {
1698 let files = self.files.read();
1699 let mut seen: HashSet<(String, u32, u32, u32, u32)> = HashSet::new();
1700
1701 for (_uri_key, file_index) in files.iter() {
1702 if let Some(refs) = file_index.references.get(symbol_name) {
1703 for r in refs.iter().filter(|r| r.kind != ReferenceKind::Definition) {
1704 seen.insert((
1705 r.uri.clone(),
1706 r.range.start.line,
1707 r.range.start.column,
1708 r.range.end.line,
1709 r.range.end.column,
1710 ));
1711 }
1712 }
1713
1714 if let Some(idx) = symbol_name.rfind("::") {
1715 let bare_name = &symbol_name[idx + 2..];
1716 if let Some(refs) = file_index.references.get(bare_name) {
1717 for r in refs.iter().filter(|r| r.kind != ReferenceKind::Definition) {
1718 seen.insert((
1719 r.uri.clone(),
1720 r.range.start.line,
1721 r.range.start.column,
1722 r.range.end.line,
1723 r.range.end.column,
1724 ));
1725 }
1726 }
1727 }
1728 }
1729
1730 seen.len()
1731 }
1732
1733 pub fn find_definition(&self, symbol_name: &str) -> Option<Location> {
1752 let cached_uri = {
1753 let symbols = self.symbols.read();
1754 symbols.get(symbol_name).cloned()
1755 };
1756
1757 let files = self.files.read();
1758 if let Some(ref uri_str) = cached_uri
1759 && let Some((location, _uri)) =
1760 Self::find_definition_in_files(&files, symbol_name, Some(uri_str))
1761 {
1762 return Some(location);
1763 }
1764
1765 let resolved = Self::find_definition_in_files(&files, symbol_name, None);
1766 drop(files);
1767
1768 if let Some((location, uri)) = resolved {
1769 let mut symbols = self.symbols.write();
1770 symbols.insert(symbol_name.to_string(), uri);
1771 return Some(location);
1772 }
1773
1774 None
1775 }
1776
1777 pub fn all_symbols(&self) -> Vec<WorkspaceSymbol> {
1792 let files = self.files.read();
1793 let mut symbols = Vec::new();
1794
1795 for (_uri_key, file_index) in files.iter() {
1796 symbols.extend(file_index.symbols.clone());
1797 }
1798
1799 symbols
1800 }
1801
1802 pub fn clear(&self) {
1804 self.files.write().clear();
1805 self.symbols.write().clear();
1806 self.global_references.write().clear();
1807 }
1808
1809 pub fn file_count(&self) -> usize {
1811 let files = self.files.read();
1812 files.len()
1813 }
1814
1815 pub fn symbol_count(&self) -> usize {
1817 let files = self.files.read();
1818 files.values().map(|file_index| file_index.symbols.len()).sum()
1819 }
1820
1821 pub fn has_symbols(&self) -> bool {
1840 let files = self.files.read();
1841 files.values().any(|file_index| !file_index.symbols.is_empty())
1842 }
1843
1844 pub fn search_symbols(&self, query: &str) -> Vec<WorkspaceSymbol> {
1863 let query_lower = query.to_lowercase();
1864 let files = self.files.read();
1865 let mut results = Vec::new();
1866 for file_index in files.values() {
1867 for symbol in &file_index.symbols {
1868 if symbol.name.to_lowercase().contains(&query_lower)
1869 || symbol
1870 .qualified_name
1871 .as_ref()
1872 .map(|qn| qn.to_lowercase().contains(&query_lower))
1873 .unwrap_or(false)
1874 {
1875 results.push(symbol.clone());
1876 }
1877 }
1878 }
1879 results
1880 }
1881
1882 pub fn find_symbols(&self, query: &str) -> Vec<WorkspaceSymbol> {
1901 self.search_symbols(query)
1902 }
1903
1904 pub fn file_symbols(&self, uri: &str) -> Vec<WorkspaceSymbol> {
1923 let normalized_uri = Self::normalize_uri(uri);
1924 let key = DocumentStore::uri_key(&normalized_uri);
1925 let files = self.files.read();
1926
1927 files.get(&key).map(|fi| fi.symbols.clone()).unwrap_or_default()
1928 }
1929
1930 pub fn file_dependencies(&self, uri: &str) -> HashSet<String> {
1949 let normalized_uri = Self::normalize_uri(uri);
1950 let key = DocumentStore::uri_key(&normalized_uri);
1951 let files = self.files.read();
1952
1953 files.get(&key).map(|fi| fi.dependencies.clone()).unwrap_or_default()
1954 }
1955
1956 pub fn find_dependents(&self, module_name: &str) -> Vec<String> {
1975 let files = self.files.read();
1976 let mut dependents = Vec::new();
1977
1978 for (uri_key, file_index) in files.iter() {
1979 if file_index.dependencies.contains(module_name) {
1980 dependents.push(uri_key.clone());
1981 }
1982 }
1983
1984 dependents
1985 }
1986
1987 pub fn document_store(&self) -> &DocumentStore {
2002 &self.document_store
2003 }
2004
2005 pub fn find_unused_symbols(&self) -> Vec<WorkspaceSymbol> {
2020 let files = self.files.read();
2021 let mut unused = Vec::new();
2022
2023 for (_uri_key, file_index) in files.iter() {
2025 for symbol in &file_index.symbols {
2026 let has_usage = files.values().any(|fi| {
2028 if let Some(refs) = fi.references.get(&symbol.name) {
2029 refs.iter().any(|r| r.kind != ReferenceKind::Definition)
2030 } else {
2031 false
2032 }
2033 });
2034
2035 if !has_usage {
2036 unused.push(symbol.clone());
2037 }
2038 }
2039 }
2040
2041 unused
2042 }
2043
2044 pub fn get_package_members(&self, package_name: &str) -> Vec<WorkspaceSymbol> {
2063 let files = self.files.read();
2064 let mut members = Vec::new();
2065
2066 for (_uri_key, file_index) in files.iter() {
2067 for symbol in &file_index.symbols {
2068 if let Some(ref container) = symbol.container_name {
2070 if container == package_name {
2071 members.push(symbol.clone());
2072 }
2073 }
2074 if let Some(ref qname) = symbol.qualified_name {
2076 if qname.starts_with(&format!("{}::", package_name)) {
2077 if symbol.container_name.as_deref() != Some(package_name) {
2079 members.push(symbol.clone());
2080 }
2081 }
2082 }
2083 }
2084 }
2085
2086 members
2087 }
2088
2089 pub fn find_def(&self, key: &SymbolKey) -> Option<Location> {
2110 if let Some(sigil) = key.sigil {
2111 let var_name = format!("{}{}", sigil, key.name);
2113 self.find_definition(&var_name)
2114 } else if key.kind == SymKind::Pack {
2115 self.find_definition(key.pkg.as_ref())
2118 .or_else(|| self.find_definition(key.name.as_ref()))
2119 } else {
2120 let qualified_name = format!("{}::{}", key.pkg, key.name);
2122 self.find_definition(&qualified_name)
2123 }
2124 }
2125
2126 pub fn find_refs(&self, key: &SymbolKey) -> Vec<Location> {
2149 let files_locked = self.files.read();
2150 let mut all_refs = if let Some(sigil) = key.sigil {
2151 let var_name = format!("{}{}", sigil, key.name);
2153 let mut refs = Vec::new();
2154 for (_uri_key, file_index) in files_locked.iter() {
2155 if let Some(var_refs) = file_index.references.get(&var_name) {
2156 for reference in var_refs {
2157 refs.push(Location { uri: reference.uri.clone(), range: reference.range });
2158 }
2159 }
2160 }
2161 refs
2162 } else {
2163 if key.pkg.as_ref() == "main" {
2165 let mut refs = self.find_references(&format!("main::{}", key.name));
2167 for (_uri_key, file_index) in files_locked.iter() {
2169 if let Some(bare_refs) = file_index.references.get(key.name.as_ref()) {
2170 for reference in bare_refs {
2171 refs.push(Location {
2172 uri: reference.uri.clone(),
2173 range: reference.range,
2174 });
2175 }
2176 }
2177 }
2178 refs
2179 } else {
2180 let qualified_name = format!("{}::{}", key.pkg, key.name);
2181 self.find_references(&qualified_name)
2182 }
2183 };
2184 drop(files_locked);
2185
2186 if let Some(def) = self.find_def(key) {
2188 all_refs.retain(|loc| !(loc.uri == def.uri && loc.range == def.range));
2189 }
2190
2191 let mut seen = HashSet::new();
2193 all_refs.retain(|loc| {
2194 seen.insert((
2195 loc.uri.clone(),
2196 loc.range.start.line,
2197 loc.range.start.column,
2198 loc.range.end.line,
2199 loc.range.end.column,
2200 ))
2201 });
2202
2203 all_refs
2204 }
2205}
2206
2207struct IndexVisitor {
2209 document: Document,
2210 uri: String,
2211 current_package: Option<String>,
2212}
2213
2214fn is_interpolated_var_start(byte: u8) -> bool {
2215 byte.is_ascii_alphabetic() || byte == b'_'
2216}
2217
2218fn is_interpolated_var_continue(byte: u8) -> bool {
2219 byte.is_ascii_alphanumeric() || byte == b'_' || byte == b':'
2220}
2221
2222fn has_escaped_interpolation_marker(bytes: &[u8], index: usize) -> bool {
2223 if index == 0 {
2224 return false;
2225 }
2226
2227 let mut backslashes = 0usize;
2228 let mut cursor = index;
2229 while cursor > 0 && bytes[cursor - 1] == b'\\' {
2230 backslashes += 1;
2231 cursor -= 1;
2232 }
2233
2234 backslashes % 2 == 1
2235}
2236
2237fn strip_matching_quote_delimiters(raw_content: &str) -> &str {
2238 if raw_content.len() < 2 {
2239 return raw_content;
2240 }
2241
2242 let bytes = raw_content.as_bytes();
2243 match (bytes.first(), bytes.last()) {
2244 (Some(b'"'), Some(b'"')) | (Some(b'\''), Some(b'\'')) => {
2245 &raw_content[1..raw_content.len() - 1]
2246 }
2247 _ => raw_content,
2248 }
2249}
2250
2251impl IndexVisitor {
2252 fn new(document: &mut Document, uri: String) -> Self {
2253 Self { document: document.clone(), uri, current_package: Some("main".to_string()) }
2254 }
2255
2256 fn visit(&mut self, node: &Node, file_index: &mut FileIndex) {
2257 self.visit_node(node, file_index);
2258 }
2259
2260 fn record_interpolated_variable_references(
2261 &self,
2262 raw_content: &str,
2263 range: Range,
2264 file_index: &mut FileIndex,
2265 ) {
2266 let content = strip_matching_quote_delimiters(raw_content);
2267 let bytes = content.as_bytes();
2268 let mut index = 0;
2269
2270 while index < bytes.len() {
2271 if has_escaped_interpolation_marker(bytes, index) {
2272 index += 1;
2273 continue;
2274 }
2275
2276 let sigil = match bytes[index] {
2277 b'$' => "$",
2278 b'@' => "@",
2279 _ => {
2280 index += 1;
2281 continue;
2282 }
2283 };
2284
2285 if index + 1 >= bytes.len() {
2286 break;
2287 }
2288
2289 let (start, needs_closing_brace) =
2290 if bytes[index + 1] == b'{' { (index + 2, true) } else { (index + 1, false) };
2291
2292 if start >= bytes.len() || !is_interpolated_var_start(bytes[start]) {
2293 index += 1;
2294 continue;
2295 }
2296
2297 let mut end = start + 1;
2298 while end < bytes.len() && is_interpolated_var_continue(bytes[end]) {
2299 end += 1;
2300 }
2301
2302 if needs_closing_brace && (end >= bytes.len() || bytes[end] != b'}') {
2303 index += 1;
2304 continue;
2305 }
2306
2307 if let Some(name) = content.get(start..end) {
2308 let var_name = format!("{sigil}{name}");
2309 file_index.references.entry(var_name).or_default().push(SymbolReference {
2310 uri: self.uri.clone(),
2311 range,
2312 kind: ReferenceKind::Read,
2313 });
2314 }
2315
2316 index = if needs_closing_brace { end + 1 } else { end };
2317 }
2318 }
2319
2320 fn visit_node(&mut self, node: &Node, file_index: &mut FileIndex) {
2321 match &node.kind {
2322 NodeKind::Package { name, .. } => {
2323 let package_name = name.clone();
2324
2325 self.current_package = Some(package_name.clone());
2327
2328 file_index.symbols.push(WorkspaceSymbol {
2329 name: package_name.clone(),
2330 kind: SymbolKind::Package,
2331 uri: self.uri.clone(),
2332 range: self.node_to_range(node),
2333 qualified_name: Some(package_name),
2334 documentation: None,
2335 container_name: None,
2336 has_body: true,
2337 });
2338 }
2339
2340 NodeKind::Subroutine { name, body, .. } => {
2341 if let Some(name_str) = name.clone() {
2342 let qualified_name = if let Some(ref pkg) = self.current_package {
2343 format!("{}::{}", pkg, name_str)
2344 } else {
2345 name_str.clone()
2346 };
2347
2348 let existing_symbol_idx = file_index.symbols.iter().position(|s| {
2350 s.name == name_str && s.container_name == self.current_package
2351 });
2352
2353 if let Some(idx) = existing_symbol_idx {
2354 file_index.symbols[idx].range = self.node_to_range(node);
2356 } else {
2357 file_index.symbols.push(WorkspaceSymbol {
2359 name: name_str.clone(),
2360 kind: SymbolKind::Subroutine,
2361 uri: self.uri.clone(),
2362 range: self.node_to_range(node),
2363 qualified_name: Some(qualified_name),
2364 documentation: None,
2365 container_name: self.current_package.clone(),
2366 has_body: true, });
2368 }
2369
2370 file_index.references.entry(name_str.clone()).or_default().push(
2372 SymbolReference {
2373 uri: self.uri.clone(),
2374 range: self.node_to_range(node),
2375 kind: ReferenceKind::Definition,
2376 },
2377 );
2378 }
2379
2380 self.visit_node(body, file_index);
2382 }
2383
2384 NodeKind::VariableDeclaration { variable, initializer, .. } => {
2385 if let NodeKind::Variable { sigil, name } = &variable.kind {
2386 let var_name = format!("{}{}", sigil, name);
2387
2388 file_index.symbols.push(WorkspaceSymbol {
2389 name: var_name.clone(),
2390 kind: SymbolKind::Variable(sigil_to_var_kind(sigil)),
2391 uri: self.uri.clone(),
2392 range: self.node_to_range(variable),
2393 qualified_name: None,
2394 documentation: None,
2395 container_name: self.current_package.clone(),
2396 has_body: true, });
2398
2399 file_index.references.entry(var_name.clone()).or_default().push(
2401 SymbolReference {
2402 uri: self.uri.clone(),
2403 range: self.node_to_range(variable),
2404 kind: ReferenceKind::Definition,
2405 },
2406 );
2407 }
2408
2409 if let Some(init) = initializer {
2411 self.visit_node(init, file_index);
2412 }
2413 }
2414
2415 NodeKind::VariableListDeclaration { variables, initializer, .. } => {
2416 for var in variables {
2418 if let NodeKind::Variable { sigil, name } = &var.kind {
2419 let var_name = format!("{}{}", sigil, name);
2420
2421 file_index.symbols.push(WorkspaceSymbol {
2422 name: var_name.clone(),
2423 kind: SymbolKind::Variable(sigil_to_var_kind(sigil)),
2424 uri: self.uri.clone(),
2425 range: self.node_to_range(var),
2426 qualified_name: None,
2427 documentation: None,
2428 container_name: self.current_package.clone(),
2429 has_body: true,
2430 });
2431
2432 file_index.references.entry(var_name).or_default().push(SymbolReference {
2434 uri: self.uri.clone(),
2435 range: self.node_to_range(var),
2436 kind: ReferenceKind::Definition,
2437 });
2438 }
2439 }
2440
2441 if let Some(init) = initializer {
2443 self.visit_node(init, file_index);
2444 }
2445 }
2446
2447 NodeKind::Variable { sigil, name } => {
2448 let var_name = format!("{}{}", sigil, name);
2449
2450 file_index.references.entry(var_name).or_default().push(SymbolReference {
2452 uri: self.uri.clone(),
2453 range: self.node_to_range(node),
2454 kind: ReferenceKind::Read, });
2456 }
2457
2458 NodeKind::FunctionCall { name, args, .. } => {
2459 let func_name = name.clone();
2460 let location = self.node_to_range(node);
2461
2462 let (pkg, bare_name) = if let Some(idx) = func_name.rfind("::") {
2464 (&func_name[..idx], &func_name[idx + 2..])
2465 } else {
2466 (self.current_package.as_deref().unwrap_or("main"), func_name.as_str())
2467 };
2468
2469 let qualified = format!("{}::{}", pkg, bare_name);
2470
2471 file_index.references.entry(bare_name.to_string()).or_default().push(
2475 SymbolReference {
2476 uri: self.uri.clone(),
2477 range: location,
2478 kind: ReferenceKind::Usage,
2479 },
2480 );
2481 file_index.references.entry(qualified).or_default().push(SymbolReference {
2482 uri: self.uri.clone(),
2483 range: location,
2484 kind: ReferenceKind::Usage,
2485 });
2486
2487 for arg in args {
2489 self.visit_node(arg, file_index);
2490 }
2491 }
2492
2493 NodeKind::Use { module, args, .. } => {
2494 let module_name = module.clone();
2495 file_index.dependencies.insert(module_name.clone());
2496
2497 if module == "parent" || module == "base" {
2501 for name in extract_module_names_from_use_args(args) {
2502 file_index.dependencies.insert(name);
2503 }
2504 }
2505
2506 file_index.references.entry(module_name).or_default().push(SymbolReference {
2508 uri: self.uri.clone(),
2509 range: self.node_to_range(node),
2510 kind: ReferenceKind::Import,
2511 });
2512 }
2513
2514 NodeKind::Assignment { lhs, rhs, op } => {
2516 let is_compound = op != "=";
2518
2519 if let NodeKind::Variable { sigil, name } = &lhs.kind {
2520 let var_name = format!("{}{}", sigil, name);
2521
2522 if is_compound {
2524 file_index.references.entry(var_name.clone()).or_default().push(
2525 SymbolReference {
2526 uri: self.uri.clone(),
2527 range: self.node_to_range(lhs),
2528 kind: ReferenceKind::Read,
2529 },
2530 );
2531 }
2532
2533 file_index.references.entry(var_name).or_default().push(SymbolReference {
2535 uri: self.uri.clone(),
2536 range: self.node_to_range(lhs),
2537 kind: ReferenceKind::Write,
2538 });
2539 }
2540
2541 self.visit_node(rhs, file_index);
2543 }
2544
2545 NodeKind::Block { statements } => {
2547 for stmt in statements {
2548 self.visit_node(stmt, file_index);
2549 }
2550 }
2551
2552 NodeKind::If { condition, then_branch, elsif_branches, else_branch } => {
2553 self.visit_node(condition, file_index);
2554 self.visit_node(then_branch, file_index);
2555 for (cond, branch) in elsif_branches {
2556 self.visit_node(cond, file_index);
2557 self.visit_node(branch, file_index);
2558 }
2559 if let Some(else_br) = else_branch {
2560 self.visit_node(else_br, file_index);
2561 }
2562 }
2563
2564 NodeKind::While { condition, body, continue_block } => {
2565 self.visit_node(condition, file_index);
2566 self.visit_node(body, file_index);
2567 if let Some(cont) = continue_block {
2568 self.visit_node(cont, file_index);
2569 }
2570 }
2571
2572 NodeKind::For { init, condition, update, body, continue_block } => {
2573 if let Some(i) = init {
2574 self.visit_node(i, file_index);
2575 }
2576 if let Some(c) = condition {
2577 self.visit_node(c, file_index);
2578 }
2579 if let Some(u) = update {
2580 self.visit_node(u, file_index);
2581 }
2582 self.visit_node(body, file_index);
2583 if let Some(cont) = continue_block {
2584 self.visit_node(cont, file_index);
2585 }
2586 }
2587
2588 NodeKind::Foreach { variable, list, body, continue_block } => {
2589 if let Some(cb) = continue_block {
2591 self.visit_node(cb, file_index);
2592 }
2593 if let NodeKind::Variable { sigil, name } = &variable.kind {
2594 let var_name = format!("{}{}", sigil, name);
2595 file_index.references.entry(var_name).or_default().push(SymbolReference {
2596 uri: self.uri.clone(),
2597 range: self.node_to_range(variable),
2598 kind: ReferenceKind::Write,
2599 });
2600 }
2601 self.visit_node(variable, file_index);
2602 self.visit_node(list, file_index);
2603 self.visit_node(body, file_index);
2604 }
2605
2606 NodeKind::MethodCall { object, method, args } => {
2607 let qualified_method = if let NodeKind::Identifier { name } = &object.kind {
2609 Some(format!("{}::{}", name, method))
2611 } else {
2612 None
2614 };
2615
2616 self.visit_node(object, file_index);
2618
2619 let method_key = qualified_method.as_ref().unwrap_or(method);
2621 file_index.references.entry(method_key.clone()).or_default().push(
2622 SymbolReference {
2623 uri: self.uri.clone(),
2624 range: self.node_to_range(node),
2625 kind: ReferenceKind::Usage,
2626 },
2627 );
2628
2629 for arg in args {
2631 self.visit_node(arg, file_index);
2632 }
2633 }
2634
2635 NodeKind::No { module, .. } => {
2636 let module_name = module.clone();
2637 file_index.dependencies.insert(module_name.clone());
2638 }
2639
2640 NodeKind::Class { name, .. } => {
2641 let class_name = name.clone();
2642 self.current_package = Some(class_name.clone());
2643
2644 file_index.symbols.push(WorkspaceSymbol {
2645 name: class_name.clone(),
2646 kind: SymbolKind::Class,
2647 uri: self.uri.clone(),
2648 range: self.node_to_range(node),
2649 qualified_name: Some(class_name),
2650 documentation: None,
2651 container_name: None,
2652 has_body: true,
2653 });
2654 }
2655
2656 NodeKind::Method { name, body, signature, .. } => {
2657 let method_name = name.clone();
2658 let qualified_name = if let Some(ref pkg) = self.current_package {
2659 format!("{}::{}", pkg, method_name)
2660 } else {
2661 method_name.clone()
2662 };
2663
2664 file_index.symbols.push(WorkspaceSymbol {
2665 name: method_name.clone(),
2666 kind: SymbolKind::Method,
2667 uri: self.uri.clone(),
2668 range: self.node_to_range(node),
2669 qualified_name: Some(qualified_name),
2670 documentation: None,
2671 container_name: self.current_package.clone(),
2672 has_body: true,
2673 });
2674
2675 if let Some(sig) = signature {
2677 if let NodeKind::Signature { parameters } = &sig.kind {
2678 for param in parameters {
2679 self.visit_node(param, file_index);
2680 }
2681 }
2682 }
2683
2684 self.visit_node(body, file_index);
2686 }
2687
2688 NodeKind::String { value, interpolated } => {
2689 if *interpolated {
2690 let range = self.node_to_range(node);
2691 self.record_interpolated_variable_references(value, range, file_index);
2692 }
2693 }
2694
2695 NodeKind::Heredoc { content, interpolated, .. } => {
2696 if *interpolated {
2697 let range = self.node_to_range(node);
2698 self.record_interpolated_variable_references(content, range, file_index);
2699 }
2700 }
2701
2702 NodeKind::Unary { op, operand } if op == "++" || op == "--" => {
2704 if let NodeKind::Variable { sigil, name } = &operand.kind {
2706 let var_name = format!("{}{}", sigil, name);
2707
2708 file_index.references.entry(var_name.clone()).or_default().push(
2710 SymbolReference {
2711 uri: self.uri.clone(),
2712 range: self.node_to_range(operand),
2713 kind: ReferenceKind::Read,
2714 },
2715 );
2716
2717 file_index.references.entry(var_name).or_default().push(SymbolReference {
2718 uri: self.uri.clone(),
2719 range: self.node_to_range(operand),
2720 kind: ReferenceKind::Write,
2721 });
2722 }
2723 }
2724
2725 _ => {
2726 self.visit_children(node, file_index);
2728 }
2729 }
2730 }
2731
2732 fn visit_children(&mut self, node: &Node, file_index: &mut FileIndex) {
2733 match &node.kind {
2735 NodeKind::Program { statements } => {
2736 for stmt in statements {
2737 self.visit_node(stmt, file_index);
2738 }
2739 }
2740 NodeKind::ExpressionStatement { expression } => {
2741 self.visit_node(expression, file_index);
2742 }
2743 NodeKind::Unary { operand, .. } => {
2745 self.visit_node(operand, file_index);
2746 }
2747 NodeKind::Binary { left, right, .. } => {
2748 self.visit_node(left, file_index);
2749 self.visit_node(right, file_index);
2750 }
2751 NodeKind::Ternary { condition, then_expr, else_expr } => {
2752 self.visit_node(condition, file_index);
2753 self.visit_node(then_expr, file_index);
2754 self.visit_node(else_expr, file_index);
2755 }
2756 NodeKind::ArrayLiteral { elements } => {
2757 for elem in elements {
2758 self.visit_node(elem, file_index);
2759 }
2760 }
2761 NodeKind::HashLiteral { pairs } => {
2762 for (key, value) in pairs {
2763 self.visit_node(key, file_index);
2764 self.visit_node(value, file_index);
2765 }
2766 }
2767 NodeKind::Return { value } => {
2768 if let Some(val) = value {
2769 self.visit_node(val, file_index);
2770 }
2771 }
2772 NodeKind::Eval { block } | NodeKind::Do { block } => {
2773 self.visit_node(block, file_index);
2774 }
2775 NodeKind::Try { body, catch_blocks, finally_block } => {
2776 self.visit_node(body, file_index);
2777 for (_, block) in catch_blocks {
2778 self.visit_node(block, file_index);
2779 }
2780 if let Some(finally) = finally_block {
2781 self.visit_node(finally, file_index);
2782 }
2783 }
2784 NodeKind::Given { expr, body } => {
2785 self.visit_node(expr, file_index);
2786 self.visit_node(body, file_index);
2787 }
2788 NodeKind::When { condition, body } => {
2789 self.visit_node(condition, file_index);
2790 self.visit_node(body, file_index);
2791 }
2792 NodeKind::Default { body } => {
2793 self.visit_node(body, file_index);
2794 }
2795 NodeKind::StatementModifier { statement, condition, .. } => {
2796 self.visit_node(statement, file_index);
2797 self.visit_node(condition, file_index);
2798 }
2799 NodeKind::VariableWithAttributes { variable, .. } => {
2800 self.visit_node(variable, file_index);
2801 }
2802 NodeKind::LabeledStatement { statement, .. } => {
2803 self.visit_node(statement, file_index);
2804 }
2805 _ => {
2806 }
2808 }
2809 }
2810
2811 fn node_to_range(&mut self, node: &Node) -> Range {
2812 let ((start_line, start_col), (end_line, end_col)) =
2814 self.document.line_index.range(node.location.start, node.location.end);
2815 Range {
2817 start: Position { byte: node.location.start, line: start_line, column: start_col },
2818 end: Position { byte: node.location.end, line: end_line, column: end_col },
2819 }
2820 }
2821}
2822
2823fn extract_module_names_from_use_args(args: &[String]) -> Vec<String> {
2833 let joined = args.join(" ");
2834
2835 let inner = if let Some(start) = joined.find("qw(") {
2837 if let Some(end) = joined[start..].find(')') {
2838 joined[start + 3..start + end].to_string()
2839 } else {
2840 joined.clone()
2841 }
2842 } else {
2843 joined.clone()
2844 };
2845
2846 inner
2847 .split_whitespace()
2848 .filter_map(|token| {
2849 if token.starts_with('-') {
2851 return None;
2852 }
2853 let stripped = token.trim_matches('\'').trim_matches('"');
2855 let stripped = stripped.trim_matches('(').trim_matches(')');
2857 let stripped = stripped.trim_matches('\'').trim_matches('"');
2858 if stripped.is_empty() {
2860 return None;
2861 }
2862 if stripped.chars().all(|c| c.is_alphanumeric() || c == '_' || c == ':' || c == '\'') {
2863 Some(stripped.to_string())
2864 } else {
2865 None
2866 }
2867 })
2868 .collect()
2869}
2870
2871impl Default for WorkspaceIndex {
2872 fn default() -> Self {
2873 Self::new()
2874 }
2875}
2876
2877#[cfg(all(feature = "workspace", feature = "lsp-compat"))]
2879pub mod lsp_adapter {
2881 use super::Location as IxLocation;
2882 use lsp_types::Location as LspLocation;
2883 type LspUrl = lsp_types::Uri;
2885
2886 pub fn to_lsp_location(ix: &IxLocation) -> Option<LspLocation> {
2906 parse_url(&ix.uri).map(|uri| {
2907 let start =
2908 lsp_types::Position { line: ix.range.start.line, character: ix.range.start.column };
2909 let end =
2910 lsp_types::Position { line: ix.range.end.line, character: ix.range.end.column };
2911 let range = lsp_types::Range { start, end };
2912 LspLocation { uri, range }
2913 })
2914 }
2915
2916 pub fn to_lsp_locations(all: impl IntoIterator<Item = IxLocation>) -> Vec<LspLocation> {
2937 all.into_iter().filter_map(|ix| to_lsp_location(&ix)).collect()
2938 }
2939
2940 #[cfg(not(target_arch = "wasm32"))]
2941 fn parse_url(s: &str) -> Option<LspUrl> {
2942 use std::str::FromStr;
2944
2945 LspUrl::from_str(s).ok().or_else(|| {
2947 std::path::Path::new(s).canonicalize().ok().and_then(|p| {
2949 crate::workspace_index::fs_path_to_uri(&p)
2951 .ok()
2952 .and_then(|uri_string| LspUrl::from_str(&uri_string).ok())
2953 })
2954 })
2955 }
2956
2957 #[cfg(target_arch = "wasm32")]
2959 fn parse_url(s: &str) -> Option<LspUrl> {
2960 use std::str::FromStr;
2961 LspUrl::from_str(s).ok()
2962 }
2963}
2964
2965#[cfg(test)]
2966mod tests {
2967 use super::*;
2968 use perl_tdd_support::{must, must_some};
2969
2970 #[test]
2971 fn test_basic_indexing() {
2972 let index = WorkspaceIndex::new();
2973 let uri = "file:///test.pl";
2974
2975 let code = r#"
2976package MyPackage;
2977
2978sub hello {
2979 print "Hello";
2980}
2981
2982my $var = 42;
2983"#;
2984
2985 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
2986
2987 let symbols = index.file_symbols(uri);
2989 assert!(symbols.iter().any(|s| s.name == "MyPackage" && s.kind == SymbolKind::Package));
2990 assert!(symbols.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
2991 assert!(symbols.iter().any(|s| s.name == "$var" && s.kind.is_variable()));
2992 }
2993
2994 #[test]
2995 fn test_find_references() {
2996 let index = WorkspaceIndex::new();
2997 let uri = "file:///test.pl";
2998
2999 let code = r#"
3000sub test {
3001 my $x = 1;
3002 $x = 2;
3003 print $x;
3004}
3005"#;
3006
3007 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
3008
3009 let refs = index.find_references("$x");
3010 assert!(refs.len() >= 2); }
3012
3013 #[test]
3014 fn test_dependencies() {
3015 let index = WorkspaceIndex::new();
3016 let uri = "file:///test.pl";
3017
3018 let code = r#"
3019use strict;
3020use warnings;
3021use Data::Dumper;
3022"#;
3023
3024 must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
3025
3026 let deps = index.file_dependencies(uri);
3027 assert!(deps.contains("strict"));
3028 assert!(deps.contains("warnings"));
3029 assert!(deps.contains("Data::Dumper"));
3030 }
3031
3032 #[test]
3033 fn test_uri_to_fs_path_basic() {
3034 if let Some(path) = uri_to_fs_path("file:///tmp/test.pl") {
3036 assert_eq!(path, std::path::PathBuf::from("/tmp/test.pl"));
3037 }
3038
3039 assert!(uri_to_fs_path("not-a-uri").is_none());
3041
3042 assert!(uri_to_fs_path("http://example.com").is_none());
3044 }
3045
3046 #[test]
3047 fn test_uri_to_fs_path_with_spaces() {
3048 if let Some(path) = uri_to_fs_path("file:///tmp/path%20with%20spaces/test.pl") {
3050 assert_eq!(path, std::path::PathBuf::from("/tmp/path with spaces/test.pl"));
3051 }
3052
3053 if let Some(path) = uri_to_fs_path("file:///tmp/My%20Documents/test%20file.pl") {
3055 assert_eq!(path, std::path::PathBuf::from("/tmp/My Documents/test file.pl"));
3056 }
3057 }
3058
3059 #[test]
3060 fn test_uri_to_fs_path_with_unicode() {
3061 if let Some(path) = uri_to_fs_path("file:///tmp/caf%C3%A9/test.pl") {
3063 assert_eq!(path, std::path::PathBuf::from("/tmp/café/test.pl"));
3064 }
3065
3066 if let Some(path) = uri_to_fs_path("file:///tmp/emoji%F0%9F%98%80/test.pl") {
3068 assert_eq!(path, std::path::PathBuf::from("/tmp/emoji😀/test.pl"));
3069 }
3070 }
3071
3072 #[test]
3073 fn test_fs_path_to_uri_basic() {
3074 let result = fs_path_to_uri("/tmp/test.pl");
3076 assert!(result.is_ok());
3077 let uri = must(result);
3078 assert!(uri.starts_with("file://"));
3079 assert!(uri.contains("/tmp/test.pl"));
3080 }
3081
3082 #[test]
3083 fn test_fs_path_to_uri_with_spaces() {
3084 let result = fs_path_to_uri("/tmp/path with spaces/test.pl");
3086 assert!(result.is_ok());
3087 let uri = must(result);
3088 assert!(uri.starts_with("file://"));
3089 assert!(uri.contains("path%20with%20spaces"));
3091 }
3092
3093 #[test]
3094 fn test_fs_path_to_uri_with_unicode() {
3095 let result = fs_path_to_uri("/tmp/café/test.pl");
3097 assert!(result.is_ok());
3098 let uri = must(result);
3099 assert!(uri.starts_with("file://"));
3100 assert!(uri.contains("caf%C3%A9"));
3102 }
3103
3104 #[test]
3105 fn test_normalize_uri_file_schemes() {
3106 let uri = WorkspaceIndex::normalize_uri("file:///tmp/test.pl");
3108 assert_eq!(uri, "file:///tmp/test.pl");
3109
3110 let uri = WorkspaceIndex::normalize_uri("file:///tmp/path%20with%20spaces/test.pl");
3112 assert_eq!(uri, "file:///tmp/path%20with%20spaces/test.pl");
3113 }
3114
3115 #[test]
3116 fn test_normalize_uri_absolute_paths() {
3117 let uri = WorkspaceIndex::normalize_uri("/tmp/test.pl");
3119 assert!(uri.starts_with("file://"));
3120 assert!(uri.contains("/tmp/test.pl"));
3121 }
3122
3123 #[test]
3124 fn test_normalize_uri_special_schemes() {
3125 let uri = WorkspaceIndex::normalize_uri("untitled:Untitled-1");
3127 assert_eq!(uri, "untitled:Untitled-1");
3128 }
3129
3130 #[test]
3131 fn test_roundtrip_conversion() {
3132 let original_uri = "file:///tmp/path%20with%20spaces/caf%C3%A9.pl";
3134
3135 if let Some(path) = uri_to_fs_path(original_uri) {
3136 if let Ok(converted_uri) = fs_path_to_uri(&path) {
3137 assert!(converted_uri.starts_with("file://"));
3139
3140 if let Some(roundtrip_path) = uri_to_fs_path(&converted_uri) {
3142 #[cfg(windows)]
3143 if let Ok(rootless) = path.strip_prefix(std::path::Path::new(r"\")) {
3144 assert!(roundtrip_path.ends_with(rootless));
3145 } else {
3146 assert_eq!(path, roundtrip_path);
3147 }
3148
3149 #[cfg(not(windows))]
3150 assert_eq!(path, roundtrip_path);
3151 }
3152 }
3153 }
3154 }
3155
3156 #[cfg(target_os = "windows")]
3157 #[test]
3158 fn test_windows_paths() {
3159 let result = fs_path_to_uri(r"C:\Users\test\Documents\script.pl");
3161 assert!(result.is_ok());
3162 let uri = must(result);
3163 assert!(uri.starts_with("file://"));
3164
3165 let result = fs_path_to_uri(r"C:\Program Files\My App\script.pl");
3167 assert!(result.is_ok());
3168 let uri = must(result);
3169 assert!(uri.starts_with("file://"));
3170 assert!(uri.contains("Program%20Files"));
3171 }
3172
3173 #[test]
3178 fn test_coordinator_initial_state() {
3179 let coordinator = IndexCoordinator::new();
3180 assert!(matches!(
3181 coordinator.state(),
3182 IndexState::Building { phase: IndexPhase::Idle, .. }
3183 ));
3184 }
3185
3186 #[test]
3187 fn test_transition_to_scanning_phase() {
3188 let coordinator = IndexCoordinator::new();
3189 coordinator.transition_to_scanning();
3190
3191 let state = coordinator.state();
3192 assert!(
3193 matches!(state, IndexState::Building { phase: IndexPhase::Scanning, .. }),
3194 "Expected Building state after scanning, got: {:?}",
3195 state
3196 );
3197 }
3198
3199 #[test]
3200 fn test_transition_to_indexing_phase() {
3201 let coordinator = IndexCoordinator::new();
3202 coordinator.transition_to_scanning();
3203 coordinator.update_scan_progress(3);
3204 coordinator.transition_to_indexing(3);
3205
3206 let state = coordinator.state();
3207 assert!(
3208 matches!(
3209 state,
3210 IndexState::Building { phase: IndexPhase::Indexing, total_count: 3, .. }
3211 ),
3212 "Expected Building state after indexing with total_count 3, got: {:?}",
3213 state
3214 );
3215 }
3216
3217 #[test]
3218 fn test_transition_to_ready() {
3219 let coordinator = IndexCoordinator::new();
3220 coordinator.transition_to_ready(100, 5000);
3221
3222 let state = coordinator.state();
3223 if let IndexState::Ready { file_count, symbol_count, .. } = state {
3224 assert_eq!(file_count, 100);
3225 assert_eq!(symbol_count, 5000);
3226 } else {
3227 unreachable!("Expected Ready state, got: {:?}", state);
3228 }
3229 }
3230
3231 #[test]
3232 fn test_parse_storm_degradation() {
3233 let coordinator = IndexCoordinator::new();
3234 coordinator.transition_to_ready(100, 5000);
3235
3236 for _ in 0..15 {
3238 coordinator.notify_change("file.pm");
3239 }
3240
3241 let state = coordinator.state();
3242 assert!(
3243 matches!(state, IndexState::Degraded { .. }),
3244 "Expected Degraded state, got: {:?}",
3245 state
3246 );
3247 if let IndexState::Degraded { reason, .. } = state {
3248 assert!(matches!(reason, DegradationReason::ParseStorm { .. }));
3249 }
3250 }
3251
3252 #[test]
3253 fn test_recovery_from_parse_storm() {
3254 let coordinator = IndexCoordinator::new();
3255 coordinator.transition_to_ready(100, 5000);
3256
3257 for _ in 0..15 {
3259 coordinator.notify_change("file.pm");
3260 }
3261
3262 for _ in 0..15 {
3264 coordinator.notify_parse_complete("file.pm");
3265 }
3266
3267 assert!(matches!(coordinator.state(), IndexState::Building { .. }));
3269 }
3270
3271 #[test]
3272 fn test_query_dispatch_ready() {
3273 let coordinator = IndexCoordinator::new();
3274 coordinator.transition_to_ready(100, 5000);
3275
3276 let result = coordinator.query(|_index| "full_query", |_index| "partial_query");
3277
3278 assert_eq!(result, "full_query");
3279 }
3280
3281 #[test]
3282 fn test_query_dispatch_degraded() {
3283 let coordinator = IndexCoordinator::new();
3284 let result = coordinator.query(|_index| "full_query", |_index| "partial_query");
3287
3288 assert_eq!(result, "partial_query");
3289 }
3290
3291 #[test]
3292 fn test_metrics_pending_count() {
3293 let coordinator = IndexCoordinator::new();
3294
3295 coordinator.notify_change("file1.pm");
3296 coordinator.notify_change("file2.pm");
3297
3298 assert_eq!(coordinator.metrics.pending_count(), 2);
3299
3300 coordinator.notify_parse_complete("file1.pm");
3301 assert_eq!(coordinator.metrics.pending_count(), 1);
3302 }
3303
3304 #[test]
3305 fn test_instrumentation_records_transitions() {
3306 let coordinator = IndexCoordinator::new();
3307 coordinator.transition_to_ready(10, 100);
3308
3309 let snapshot = coordinator.instrumentation_snapshot();
3310 let transition =
3311 IndexStateTransition { from: IndexStateKind::Building, to: IndexStateKind::Ready };
3312 let count = snapshot.state_transition_counts.get(&transition).copied().unwrap_or(0);
3313 assert_eq!(count, 1);
3314 }
3315
3316 #[test]
3317 fn test_instrumentation_records_early_exit() {
3318 let coordinator = IndexCoordinator::new();
3319 coordinator.record_early_exit(EarlyExitReason::InitialTimeBudget, 25, 1, 10);
3320
3321 let snapshot = coordinator.instrumentation_snapshot();
3322 let count = snapshot
3323 .early_exit_counts
3324 .get(&EarlyExitReason::InitialTimeBudget)
3325 .copied()
3326 .unwrap_or(0);
3327 assert_eq!(count, 1);
3328 assert!(snapshot.last_early_exit.is_some());
3329 }
3330
3331 #[test]
3332 fn test_custom_limits() {
3333 let limits = IndexResourceLimits {
3334 max_files: 5000,
3335 max_symbols_per_file: 1000,
3336 max_total_symbols: 100_000,
3337 max_ast_cache_bytes: 128 * 1024 * 1024,
3338 max_ast_cache_items: 50,
3339 max_scan_duration_ms: 30_000,
3340 };
3341
3342 let coordinator = IndexCoordinator::with_limits(limits.clone());
3343 assert_eq!(coordinator.limits.max_files, 5000);
3344 assert_eq!(coordinator.limits.max_total_symbols, 100_000);
3345 }
3346
3347 #[test]
3348 fn test_degradation_preserves_symbol_count() {
3349 let coordinator = IndexCoordinator::new();
3350 coordinator.transition_to_ready(100, 5000);
3351
3352 coordinator.transition_to_degraded(DegradationReason::IoError {
3353 message: "Test error".to_string(),
3354 });
3355
3356 let state = coordinator.state();
3357 assert!(
3358 matches!(state, IndexState::Degraded { .. }),
3359 "Expected Degraded state, got: {:?}",
3360 state
3361 );
3362 if let IndexState::Degraded { available_symbols, .. } = state {
3363 assert_eq!(available_symbols, 5000);
3364 }
3365 }
3366
3367 #[test]
3368 fn test_index_access() {
3369 let coordinator = IndexCoordinator::new();
3370 let index = coordinator.index();
3371
3372 assert!(index.all_symbols().is_empty());
3374 }
3375
3376 #[test]
3377 fn test_resource_limit_enforcement_max_files() {
3378 let limits = IndexResourceLimits {
3379 max_files: 5,
3380 max_symbols_per_file: 1000,
3381 max_total_symbols: 50_000,
3382 max_ast_cache_bytes: 128 * 1024 * 1024,
3383 max_ast_cache_items: 50,
3384 max_scan_duration_ms: 30_000,
3385 };
3386
3387 let coordinator = IndexCoordinator::with_limits(limits);
3388 coordinator.transition_to_ready(10, 100);
3389
3390 for i in 0..10 {
3392 let uri_str = format!("file:///test{}.pl", i);
3393 let uri = must(url::Url::parse(&uri_str));
3394 let code = "sub test { }";
3395 must(coordinator.index().index_file(uri, code.to_string()));
3396 }
3397
3398 coordinator.enforce_limits();
3400
3401 let state = coordinator.state();
3402 assert!(
3403 matches!(
3404 state,
3405 IndexState::Degraded {
3406 reason: DegradationReason::ResourceLimit { kind: ResourceKind::MaxFiles },
3407 ..
3408 }
3409 ),
3410 "Expected Degraded state with ResourceLimit(MaxFiles), got: {:?}",
3411 state
3412 );
3413 }
3414
3415 #[test]
3416 fn test_resource_limit_enforcement_max_symbols() {
3417 let limits = IndexResourceLimits {
3418 max_files: 100,
3419 max_symbols_per_file: 10,
3420 max_total_symbols: 50, max_ast_cache_bytes: 128 * 1024 * 1024,
3422 max_ast_cache_items: 50,
3423 max_scan_duration_ms: 30_000,
3424 };
3425
3426 let coordinator = IndexCoordinator::with_limits(limits);
3427 coordinator.transition_to_ready(0, 0);
3428
3429 for i in 0..10 {
3431 let uri_str = format!("file:///test{}.pl", i);
3432 let uri = must(url::Url::parse(&uri_str));
3433 let code = r#"
3435package Test;
3436sub sub1 { }
3437sub sub2 { }
3438sub sub3 { }
3439sub sub4 { }
3440sub sub5 { }
3441sub sub6 { }
3442sub sub7 { }
3443sub sub8 { }
3444sub sub9 { }
3445sub sub10 { }
3446"#;
3447 must(coordinator.index().index_file(uri, code.to_string()));
3448 }
3449
3450 coordinator.enforce_limits();
3452
3453 let state = coordinator.state();
3454 assert!(
3455 matches!(
3456 state,
3457 IndexState::Degraded {
3458 reason: DegradationReason::ResourceLimit { kind: ResourceKind::MaxSymbols },
3459 ..
3460 }
3461 ),
3462 "Expected Degraded state with ResourceLimit(MaxSymbols), got: {:?}",
3463 state
3464 );
3465 }
3466
3467 #[test]
3468 fn test_check_limits_returns_none_within_bounds() {
3469 let coordinator = IndexCoordinator::new();
3470 coordinator.transition_to_ready(0, 0);
3471
3472 for i in 0..5 {
3474 let uri_str = format!("file:///test{}.pl", i);
3475 let uri = must(url::Url::parse(&uri_str));
3476 let code = "sub test { }";
3477 must(coordinator.index().index_file(uri, code.to_string()));
3478 }
3479
3480 let limit_check = coordinator.check_limits();
3482 assert!(limit_check.is_none(), "check_limits should return None when within bounds");
3483
3484 assert!(
3486 matches!(coordinator.state(), IndexState::Ready { .. }),
3487 "State should remain Ready when within limits"
3488 );
3489 }
3490
3491 #[test]
3492 fn test_enforce_limits_called_on_transition_to_ready() {
3493 let limits = IndexResourceLimits {
3494 max_files: 3,
3495 max_symbols_per_file: 1000,
3496 max_total_symbols: 50_000,
3497 max_ast_cache_bytes: 128 * 1024 * 1024,
3498 max_ast_cache_items: 50,
3499 max_scan_duration_ms: 30_000,
3500 };
3501
3502 let coordinator = IndexCoordinator::with_limits(limits);
3503
3504 for i in 0..5 {
3506 let uri_str = format!("file:///test{}.pl", i);
3507 let uri = must(url::Url::parse(&uri_str));
3508 let code = "sub test { }";
3509 must(coordinator.index().index_file(uri, code.to_string()));
3510 }
3511
3512 coordinator.transition_to_ready(5, 100);
3514
3515 let state = coordinator.state();
3516 assert!(
3517 matches!(
3518 state,
3519 IndexState::Degraded {
3520 reason: DegradationReason::ResourceLimit { kind: ResourceKind::MaxFiles },
3521 ..
3522 }
3523 ),
3524 "Expected Degraded state after transition_to_ready with exceeded limits, got: {:?}",
3525 state
3526 );
3527 }
3528
3529 #[test]
3530 fn test_state_transition_guard_ready_to_ready() {
3531 let coordinator = IndexCoordinator::new();
3533 coordinator.transition_to_ready(100, 5000);
3534
3535 coordinator.transition_to_ready(150, 7500);
3537
3538 let state = coordinator.state();
3539 assert!(
3540 matches!(state, IndexState::Ready { file_count: 150, symbol_count: 7500, .. }),
3541 "Expected Ready state with updated metrics, got: {:?}",
3542 state
3543 );
3544 }
3545
3546 #[test]
3547 fn test_state_transition_guard_building_to_building() {
3548 let coordinator = IndexCoordinator::new();
3550
3551 coordinator.transition_to_building(100);
3553
3554 let state = coordinator.state();
3555 assert!(
3556 matches!(state, IndexState::Building { indexed_count: 0, total_count: 100, .. }),
3557 "Expected Building state, got: {:?}",
3558 state
3559 );
3560
3561 coordinator.transition_to_building(200);
3563
3564 let state = coordinator.state();
3565 assert!(
3566 matches!(state, IndexState::Building { indexed_count: 0, total_count: 200, .. }),
3567 "Expected Building state, got: {:?}",
3568 state
3569 );
3570 }
3571
3572 #[test]
3573 fn test_state_transition_ready_to_building() {
3574 let coordinator = IndexCoordinator::new();
3576 coordinator.transition_to_ready(100, 5000);
3577
3578 coordinator.transition_to_building(150);
3580
3581 let state = coordinator.state();
3582 assert!(
3583 matches!(state, IndexState::Building { indexed_count: 0, total_count: 150, .. }),
3584 "Expected Building state after re-scan, got: {:?}",
3585 state
3586 );
3587 }
3588
3589 #[test]
3590 fn test_state_transition_degraded_to_building() {
3591 let coordinator = IndexCoordinator::new();
3593 coordinator.transition_to_degraded(DegradationReason::IoError {
3594 message: "Test error".to_string(),
3595 });
3596
3597 coordinator.transition_to_building(100);
3599
3600 let state = coordinator.state();
3601 assert!(
3602 matches!(state, IndexState::Building { indexed_count: 0, total_count: 100, .. }),
3603 "Expected Building state after recovery, got: {:?}",
3604 state
3605 );
3606 }
3607
3608 #[test]
3609 fn test_update_building_progress() {
3610 let coordinator = IndexCoordinator::new();
3611 coordinator.transition_to_building(100);
3612
3613 coordinator.update_building_progress(50);
3615
3616 let state = coordinator.state();
3617 assert!(
3618 matches!(state, IndexState::Building { indexed_count: 50, total_count: 100, .. }),
3619 "Expected Building state with updated progress, got: {:?}",
3620 state
3621 );
3622
3623 coordinator.update_building_progress(100);
3625
3626 let state = coordinator.state();
3627 assert!(
3628 matches!(state, IndexState::Building { indexed_count: 100, total_count: 100, .. }),
3629 "Expected Building state with completed progress, got: {:?}",
3630 state
3631 );
3632 }
3633
3634 #[test]
3635 fn test_scan_timeout_detection() {
3636 let limits = IndexResourceLimits {
3638 max_scan_duration_ms: 0, ..Default::default()
3640 };
3641
3642 let coordinator = IndexCoordinator::with_limits(limits);
3643 coordinator.transition_to_building(100);
3644
3645 std::thread::sleep(std::time::Duration::from_millis(1));
3647
3648 coordinator.update_building_progress(10);
3650
3651 let state = coordinator.state();
3652 assert!(
3653 matches!(
3654 state,
3655 IndexState::Degraded { reason: DegradationReason::ScanTimeout { .. }, .. }
3656 ),
3657 "Expected Degraded state with ScanTimeout, got: {:?}",
3658 state
3659 );
3660 }
3661
3662 #[test]
3663 fn test_scan_timeout_does_not_trigger_within_limit() {
3664 let limits = IndexResourceLimits {
3666 max_scan_duration_ms: 10_000, ..Default::default()
3668 };
3669
3670 let coordinator = IndexCoordinator::with_limits(limits);
3671 coordinator.transition_to_building(100);
3672
3673 coordinator.update_building_progress(50);
3675
3676 let state = coordinator.state();
3677 assert!(
3678 matches!(state, IndexState::Building { indexed_count: 50, .. }),
3679 "Expected Building state (no timeout), got: {:?}",
3680 state
3681 );
3682 }
3683
3684 #[test]
3685 fn test_early_exit_optimization_unchanged_content() {
3686 let index = WorkspaceIndex::new();
3687 let uri = must(url::Url::parse("file:///test.pl"));
3688 let code = r#"
3689package MyPackage;
3690
3691sub hello {
3692 print "Hello";
3693}
3694"#;
3695
3696 must(index.index_file(uri.clone(), code.to_string()));
3698 let symbols1 = index.file_symbols(uri.as_str());
3699 assert!(symbols1.iter().any(|s| s.name == "MyPackage" && s.kind == SymbolKind::Package));
3700 assert!(symbols1.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
3701
3702 must(index.index_file(uri.clone(), code.to_string()));
3705 let symbols2 = index.file_symbols(uri.as_str());
3706 assert_eq!(symbols1.len(), symbols2.len());
3707 assert!(symbols2.iter().any(|s| s.name == "MyPackage" && s.kind == SymbolKind::Package));
3708 assert!(symbols2.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
3709 }
3710
3711 #[test]
3712 fn test_early_exit_optimization_changed_content() {
3713 let index = WorkspaceIndex::new();
3714 let uri = must(url::Url::parse("file:///test.pl"));
3715 let code1 = r#"
3716package MyPackage;
3717
3718sub hello {
3719 print "Hello";
3720}
3721"#;
3722
3723 let code2 = r#"
3724package MyPackage;
3725
3726sub goodbye {
3727 print "Goodbye";
3728}
3729"#;
3730
3731 must(index.index_file(uri.clone(), code1.to_string()));
3733 let symbols1 = index.file_symbols(uri.as_str());
3734 assert!(symbols1.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
3735 assert!(!symbols1.iter().any(|s| s.name == "goodbye"));
3736
3737 must(index.index_file(uri.clone(), code2.to_string()));
3739 let symbols2 = index.file_symbols(uri.as_str());
3740 assert!(!symbols2.iter().any(|s| s.name == "hello"));
3741 assert!(symbols2.iter().any(|s| s.name == "goodbye" && s.kind == SymbolKind::Subroutine));
3742 }
3743
3744 #[test]
3745 fn test_early_exit_optimization_whitespace_only_change() {
3746 let index = WorkspaceIndex::new();
3747 let uri = must(url::Url::parse("file:///test.pl"));
3748 let code1 = r#"
3749package MyPackage;
3750
3751sub hello {
3752 print "Hello";
3753}
3754"#;
3755
3756 let code2 = r#"
3757package MyPackage;
3758
3759
3760sub hello {
3761 print "Hello";
3762}
3763"#;
3764
3765 must(index.index_file(uri.clone(), code1.to_string()));
3767 let symbols1 = index.file_symbols(uri.as_str());
3768 assert!(symbols1.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
3769
3770 must(index.index_file(uri.clone(), code2.to_string()));
3772 let symbols2 = index.file_symbols(uri.as_str());
3773 assert!(symbols2.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
3775 }
3776
3777 #[test]
3778 fn test_reindex_file_refreshes_symbol_cache_for_removed_names() {
3779 let index = WorkspaceIndex::new();
3780 let uri1 = must(url::Url::parse("file:///lib/A.pm"));
3781 let uri2 = must(url::Url::parse("file:///lib/B.pm"));
3782 let code1 = "package A;\nsub foo { return 1; }\n1;\n";
3783 let code2 = "package B;\nsub foo { return 2; }\n1;\n";
3784 let code2_reindexed = "package B;\nsub bar { return 3; }\n1;\n";
3785
3786 must(index.index_file(uri1.clone(), code1.to_string()));
3787 must(index.index_file(uri2.clone(), code2.to_string()));
3788 must(index.index_file(uri2.clone(), code2_reindexed.to_string()));
3789
3790 let foo_location = must_some(index.find_definition("foo"));
3791 assert_eq!(foo_location.uri, uri1.to_string());
3792
3793 let bar_location = must_some(index.find_definition("bar"));
3794 assert_eq!(bar_location.uri, uri2.to_string());
3795 }
3796
3797 #[test]
3798 fn test_remove_file_preserves_other_colliding_symbol_entries() {
3799 let index = WorkspaceIndex::new();
3800 let uri1 = must(url::Url::parse("file:///lib/A.pm"));
3801 let uri2 = must(url::Url::parse("file:///lib/B.pm"));
3802 let code1 = "package A;\nsub foo { return 1; }\n1;\n";
3803 let code2 = "package B;\nsub foo { return 2; }\n1;\n";
3804
3805 must(index.index_file(uri1.clone(), code1.to_string()));
3806 must(index.index_file(uri2.clone(), code2.to_string()));
3807
3808 index.remove_file(uri2.as_str());
3809
3810 let foo_location = must_some(index.find_definition("foo"));
3811 assert_eq!(foo_location.uri, uri1.to_string());
3812 }
3813
3814 #[test]
3815 fn test_count_usages_no_double_counting_for_qualified_calls() {
3816 let index = WorkspaceIndex::new();
3817
3818 let uri1 = "file:///lib/Utils.pm";
3820 let code1 = r#"
3821package Utils;
3822
3823sub process_data {
3824 return 1;
3825}
3826"#;
3827 must(index.index_file(must(url::Url::parse(uri1)), code1.to_string()));
3828
3829 let uri2 = "file:///app.pl";
3831 let code2 = r#"
3832use Utils;
3833Utils::process_data();
3834Utils::process_data();
3835"#;
3836 must(index.index_file(must(url::Url::parse(uri2)), code2.to_string()));
3837
3838 let count = index.count_usages("Utils::process_data");
3842
3843 assert_eq!(
3846 count, 2,
3847 "count_usages should not double-count qualified calls, got {} (expected 2)",
3848 count
3849 );
3850
3851 let refs = index.find_references("Utils::process_data");
3853 let non_def_refs: Vec<_> =
3854 refs.iter().filter(|loc| loc.uri != "file:///lib/Utils.pm").collect();
3855 assert_eq!(
3856 non_def_refs.len(),
3857 2,
3858 "find_references should not return duplicates for qualified calls, got {} non-def refs",
3859 non_def_refs.len()
3860 );
3861 }
3862
3863 #[test]
3864 fn test_batch_indexing() {
3865 let index = WorkspaceIndex::new();
3866 let files: Vec<(Url, String)> = (0..5)
3867 .map(|i| {
3868 let uri = must(Url::parse(&format!("file:///batch/module{}.pm", i)));
3869 let code =
3870 format!("package Batch::Mod{};\nsub func_{} {{ return {}; }}\n1;", i, i, i);
3871 (uri, code)
3872 })
3873 .collect();
3874
3875 let errors = index.index_files_batch(files);
3876 assert!(errors.is_empty(), "batch indexing errors: {:?}", errors);
3877 assert_eq!(index.file_count(), 5);
3878 assert!(index.find_definition("Batch::Mod0::func_0").is_some());
3879 assert!(index.find_definition("Batch::Mod4::func_4").is_some());
3880 }
3881
3882 #[test]
3883 fn test_batch_indexing_skips_unchanged() {
3884 let index = WorkspaceIndex::new();
3885 let uri = must(Url::parse("file:///batch/skip.pm"));
3886 let code = "package Skip;\nsub skip_fn { 1 }\n1;".to_string();
3887
3888 index.index_file(uri.clone(), code.clone()).ok();
3889 assert_eq!(index.file_count(), 1);
3890
3891 let errors = index.index_files_batch(vec![(uri, code)]);
3892 assert!(errors.is_empty());
3893 assert_eq!(index.file_count(), 1);
3894 }
3895
3896 #[test]
3897 fn test_incremental_update_preserves_other_symbols() {
3898 let index = WorkspaceIndex::new();
3899
3900 let uri_a = must(Url::parse("file:///incr/a.pm"));
3901 let uri_b = must(Url::parse("file:///incr/b.pm"));
3902 index.index_file(uri_a.clone(), "package A;\nsub a_func { 1 }\n1;".into()).ok();
3903 index.index_file(uri_b.clone(), "package B;\nsub b_func { 2 }\n1;".into()).ok();
3904
3905 assert!(index.find_definition("A::a_func").is_some());
3906 assert!(index.find_definition("B::b_func").is_some());
3907
3908 index.index_file(uri_a, "package A;\nsub a_func_v2 { 11 }\n1;".into()).ok();
3909
3910 assert!(index.find_definition("A::a_func_v2").is_some());
3911 assert!(index.find_definition("B::b_func").is_some());
3912 }
3913
3914 #[test]
3915 fn test_remove_file_preserves_shadowed_symbols() {
3916 let index = WorkspaceIndex::new();
3917
3918 let uri_a = must(Url::parse("file:///shadow/a.pm"));
3919 let uri_b = must(Url::parse("file:///shadow/b.pm"));
3920 index.index_file(uri_a.clone(), "package ShadowA;\nsub helper { 1 }\n1;".into()).ok();
3921 index.index_file(uri_b.clone(), "package ShadowB;\nsub helper { 2 }\n1;".into()).ok();
3922
3923 assert!(index.find_definition("helper").is_some());
3924
3925 index.remove_file_url(&uri_a);
3926 assert!(index.find_definition("helper").is_some());
3927 assert!(index.find_definition("ShadowB::helper").is_some());
3928 }
3929
3930 #[test]
3935 fn test_index_dependency_via_use_parent_end_to_end() {
3936 let index = WorkspaceIndex::new();
3942
3943 let base_url = url::Url::parse("file:///test/workspace/lib/MyBase.pm").unwrap();
3944 index
3945 .index_file(base_url, "package MyBase;\nsub new { bless {}, shift }\n1;\n".to_string())
3946 .expect("indexing MyBase.pm");
3947
3948 let child_url = url::Url::parse("file:///test/workspace/child.pl").unwrap();
3949 index
3950 .index_file(child_url, "package Child;\nuse parent 'MyBase';\n1;\n".to_string())
3951 .expect("indexing child.pl");
3952
3953 let dependents = index.find_dependents("MyBase");
3954 assert!(
3955 !dependents.is_empty(),
3956 "find_dependents('MyBase') returned empty — \
3957 use parent 'MyBase' should register MyBase as a dependency. \
3958 Dependencies in index: {:?}",
3959 {
3960 let files = index.files.read();
3961 files
3962 .iter()
3963 .map(|(k, v)| (k.clone(), v.dependencies.iter().cloned().collect::<Vec<_>>()))
3964 .collect::<Vec<_>>()
3965 }
3966 );
3967 assert!(
3968 dependents.contains(&"file:///test/workspace/child.pl".to_string()),
3969 "child.pl should be in dependents, got: {:?}",
3970 dependents
3971 );
3972 }
3973
3974 #[test]
3975 fn test_parser_produces_correct_args_for_use_parent() {
3976 use crate::Parser;
3980 let mut p = Parser::new("package Child;\nuse parent 'MyBase';\n1;\n");
3981 let ast = p.parse().expect("parse succeeded");
3982 if let NodeKind::Program { statements } = &ast.kind {
3983 for stmt in statements {
3984 if let NodeKind::Use { module, args, .. } = &stmt.kind {
3985 if module == "parent" {
3986 assert_eq!(
3987 args,
3988 &["'MyBase'".to_string()],
3989 "Expected args=[\"'MyBase'\"] for `use parent 'MyBase'`, got: {:?}",
3990 args
3991 );
3992 let extracted = extract_module_names_from_use_args(args);
3993 assert_eq!(
3994 extracted,
3995 vec!["MyBase".to_string()],
3996 "extract_module_names_from_use_args should return [\"MyBase\"], got {:?}",
3997 extracted
3998 );
3999 return; }
4001 }
4002 }
4003 panic!("No Use node with module='parent' found in AST");
4004 } else {
4005 panic!("Expected Program root");
4006 }
4007 }
4008
4009 #[test]
4014 fn test_extract_module_names_single_quoted() {
4015 let names = extract_module_names_from_use_args(&["'Foo::Bar'".to_string()]);
4016 assert_eq!(names, vec!["Foo::Bar"]);
4017 }
4018
4019 #[test]
4020 fn test_extract_module_names_double_quoted() {
4021 let names = extract_module_names_from_use_args(&["\"Foo::Bar\"".to_string()]);
4022 assert_eq!(names, vec!["Foo::Bar"]);
4023 }
4024
4025 #[test]
4026 fn test_extract_module_names_qw_list() {
4027 let names = extract_module_names_from_use_args(&["qw(Foo::Bar Other::Base)".to_string()]);
4028 assert_eq!(names, vec!["Foo::Bar", "Other::Base"]);
4029 }
4030
4031 #[test]
4032 fn test_extract_module_names_norequire_flag() {
4033 let names = extract_module_names_from_use_args(&[
4034 "-norequire".to_string(),
4035 "'Foo::Bar'".to_string(),
4036 ]);
4037 assert_eq!(names, vec!["Foo::Bar"]);
4038 }
4039
4040 #[test]
4041 fn test_extract_module_names_empty_args() {
4042 let names = extract_module_names_from_use_args(&[]);
4043 assert!(names.is_empty());
4044 }
4045
4046 #[test]
4047 fn test_extract_module_names_legacy_separator() {
4048 let names = extract_module_names_from_use_args(&["'Foo'Bar'".to_string()]);
4050 assert_eq!(names, vec!["Foo'Bar"]);
4052 }
4053}