Skip to main content

perl_workspace_index/workspace/
workspace_index.rs

1//! Workspace-wide symbol index for fast cross-file lookups in Perl LSP.
2//!
3//! This module provides efficient indexing of symbols across an entire Perl workspace,
4//! enabling enterprise-grade features like find-references, rename refactoring, and
5//! workspace symbol search with ≤1ms response times.
6//!
7//! # LSP Workflow Integration
8//!
9//! Core component in the Parse → Index → Navigate → Complete → Analyze pipeline:
10//! 1. **Parse**: AST generation from Perl source files
11//! 2. **Index**: Workspace symbol table construction with dual indexing strategy
12//! 3. **Navigate**: Cross-file symbol resolution and go-to-definition
13//! 4. **Complete**: Context-aware completion with workspace symbol awareness
14//! 5. **Analyze**: Cross-reference analysis and workspace refactoring operations
15//!
16//! # Performance Characteristics
17//!
18//! - **Symbol indexing**: O(n) where n is total workspace symbols
19//! - **Symbol lookup**: O(1) average with hash table indexing
20//! - **Cross-file queries**: <50μs for typical workspace sizes
21//! - **Memory usage**: ~1MB per 10K symbols with optimized storage
22//! - **Incremental updates**: ≤1ms for file-level symbol changes
23//! - **Large workspace scaling**: Designed to scale to 50K+ files and large codebases
24//! - **Benchmark targets**: <50μs lookups and ≤1ms incremental updates at scale
25//!
26//! # Dual Indexing Strategy
27//!
28//! Implements dual indexing for comprehensive Perl symbol resolution:
29//! - **Qualified names**: `Package::function` for explicit references
30//! - **Bare names**: `function` for context-dependent resolution
31//! - **98% reference coverage**: Handles both qualified and unqualified calls
32//! - **Automatic deduplication**: Prevents duplicate results in queries
33//!
34//! # Usage Examples
35//!
36//! ```rust
37//! use perl_workspace_index::workspace::workspace_index::WorkspaceIndex;
38//! use url::Url;
39//!
40//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
41//! let index = WorkspaceIndex::new();
42//!
43//! // Index a Perl file
44//! let uri = Url::parse("file:///example.pl")?;
45//! let code = "package MyPackage;\nsub example { return 42; }";
46//! index.index_file(uri, code.to_string())?;
47//!
48//! // Find symbol definitions
49//! let definition = index.find_definition("MyPackage::example");
50//! assert!(definition.is_some());
51//!
52//! // Workspace symbol search
53//! let symbols = index.find_symbols("example");
54//! assert!(!symbols.is_empty());
55//! # Ok(())
56//! # }
57//! ```
58//!
59//! # Related Modules
60//!
61//! See also the symbol extraction, reference finding, and semantic token classification
62//! modules in the workspace index implementation.
63
64use 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// Re-export URI utilities for backward compatibility
87#[cfg(not(target_arch = "wasm32"))]
88/// URI ↔ filesystem helpers used during Index/Analyze workflows.
89pub use perl_uri::{fs_path_to_uri, uri_to_fs_path};
90/// URI inspection helpers used during Index/Analyze workflows.
91pub use perl_uri::{is_file_uri, is_special_scheme, uri_extension, uri_key};
92
93// ============================================================================
94// Index Lifecycle Types (Index Lifecycle v1 Specification)
95// ============================================================================
96
97/// Index readiness state - explicit lifecycle management
98///
99/// Represents the current operational state of the workspace index, enabling
100/// LSP handlers to provide appropriate responses based on index availability.
101/// This state machine prevents blocking operations and ensures graceful
102/// degradation when the index is not fully ready.
103///
104/// # State Transitions
105///
106/// - `Building` → `Ready`: Workspace scan completes successfully
107/// - `Building` → `Degraded`: Scan timeout, IO error, or resource limit
108/// - `Ready` → `Building`: Workspace folder change or file watching events
109/// - `Ready` → `Degraded`: Parse storm (>10 pending) or IO error
110/// - `Degraded` → `Building`: Recovery attempt after cooldown
111/// - `Degraded` → `Ready`: Successful re-scan after recovery
112///
113/// # Invariants
114///
115/// - During a single build attempt, `phase` advances monotonically
116///   (`Idle` → `Scanning` → `Indexing`).
117/// - `indexed_count` must not exceed `total_count`; callers should keep totals updated.
118/// - `Ready` and `Degraded` counts are snapshots captured at transition time.
119///
120/// # Usage
121///
122/// ```rust,ignore
123/// use perl_parser::workspace_index::{IndexPhase, IndexState};
124/// use std::time::Instant;
125///
126/// let state = IndexState::Building {
127///     phase: IndexPhase::Indexing,
128///     indexed_count: 50,
129///     total_count: 100,
130///     started_at: Instant::now(),
131/// };
132/// ```
133#[derive(Clone, Debug)]
134pub enum IndexState {
135    /// Index is being constructed (workspace scan in progress)
136    Building {
137        /// Current build phase (Idle → Scanning → Indexing)
138        phase: IndexPhase,
139        /// Files indexed so far
140        indexed_count: usize,
141        /// Total files discovered
142        total_count: usize,
143        /// Started at
144        started_at: Instant,
145    },
146
147    /// Index is consistent and ready for queries
148    Ready {
149        /// Total symbols indexed
150        symbol_count: usize,
151        /// Total files indexed
152        file_count: usize,
153        /// Timestamp of last successful index
154        completed_at: Instant,
155    },
156
157    /// Index is serving but degraded
158    Degraded {
159        /// Why we degraded
160        reason: DegradationReason,
161        /// What's still available
162        available_symbols: usize,
163        /// When degradation occurred
164        since: Instant,
165    },
166}
167
168impl IndexState {
169    /// Return the coarse state kind for instrumentation and routing decisions
170    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    /// Return the current build phase when in `Building` state
179    pub fn phase(&self) -> Option<IndexPhase> {
180        match self {
181            IndexState::Building { phase, .. } => Some(*phase),
182            _ => None,
183        }
184    }
185
186    /// Timestamp of when the current state began
187    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
196/// Coordinates index lifecycle, state transitions, and handler queries
197///
198/// The IndexCoordinator wraps `WorkspaceIndex` with explicit state management,
199/// enabling LSP handlers to query the index readiness and implement appropriate
200/// fallback behavior when the index is not fully ready.
201///
202/// # Architecture
203///
204/// ```text
205/// LspServer
206///   └── IndexCoordinator
207///         ├── state: Arc<RwLock<IndexState>>
208///         ├── index: Arc<WorkspaceIndex>
209///         ├── limits: IndexResourceLimits
210///         ├── caps: IndexPerformanceCaps
211///         ├── metrics: IndexMetrics
212///         └── instrumentation: IndexInstrumentation
213/// ```
214///
215/// # State Management
216///
217/// The coordinator manages three states:
218/// - `Building`: Initial scan or recovery in progress
219/// - `Ready`: Fully indexed and available for queries
220/// - `Degraded`: Available but with reduced functionality
221///
222/// # Performance Characteristics
223///
224/// - State checks are lock-free reads (cloned state, <100ns)
225/// - State transitions use write locks (rare, <1μs)
226/// - Query dispatch has zero overhead in Ready state
227/// - Degradation detection is atomic (<10ns per check)
228///
229/// # Usage
230///
231/// ```rust,ignore
232/// use perl_parser::workspace_index::{IndexCoordinator, IndexState};
233///
234/// let coordinator = IndexCoordinator::new();
235/// assert!(matches!(coordinator.state(), IndexState::Building { .. }));
236///
237/// // Transition to ready after indexing
238/// coordinator.transition_to_ready(100, 5000);
239/// assert!(matches!(coordinator.state(), IndexState::Ready { .. }));
240///
241/// // Query with degradation handling
242/// let _result = coordinator.query(
243///     |index| index.find_definition("my_function"), // full query
244///     |_index| None                                 // partial fallback
245/// );
246/// ```
247pub struct IndexCoordinator {
248    /// Current index state (RwLock for state transitions)
249    state: Arc<RwLock<IndexState>>,
250
251    /// The actual workspace index
252    index: Arc<WorkspaceIndex>,
253
254    /// Resource limits configuration
255    ///
256    /// Enforces bounded resource usage to prevent unbounded memory growth:
257    /// - max_files: Triggers degradation when file count exceeds limit
258    /// - max_total_symbols: Triggers degradation when symbol count exceeds limit
259    /// - max_symbols_per_file: Used for per-file validation during indexing
260    limits: IndexResourceLimits,
261
262    /// Performance caps for early-exit heuristics
263    caps: IndexPerformanceCaps,
264
265    /// Runtime metrics for degradation detection
266    metrics: IndexMetrics,
267
268    /// Instrumentation for lifecycle transitions and durations
269    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    /// Create a new coordinator in Building state
284    ///
285    /// Initializes the coordinator with default resource limits and
286    /// an empty workspace index ready for initial scan.
287    ///
288    /// # Returns
289    ///
290    /// A coordinator initialized in `IndexState::Building`.
291    ///
292    /// # Examples
293    ///
294    /// ```rust,ignore
295    /// use perl_parser::workspace_index::IndexCoordinator;
296    ///
297    /// let coordinator = IndexCoordinator::new();
298    /// ```
299    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    /// Create a coordinator with custom resource limits
316    ///
317    /// # Arguments
318    ///
319    /// * `limits` - Custom resource limits for this workspace
320    ///
321    /// # Returns
322    ///
323    /// A coordinator configured with the provided resource limits.
324    ///
325    /// # Examples
326    ///
327    /// ```rust,ignore
328    /// use perl_parser::workspace_index::{IndexCoordinator, IndexResourceLimits};
329    ///
330    /// let limits = IndexResourceLimits::default();
331    /// let coordinator = IndexCoordinator::with_limits(limits);
332    /// ```
333    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    /// Create a coordinator with custom limits and performance caps
350    ///
351    /// # Arguments
352    ///
353    /// * `limits` - Resource limits for this workspace
354    /// * `caps` - Performance caps for indexing budgets
355    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    /// Get current state (lock-free read via clone)
372    ///
373    /// Returns a cloned copy of the current state for lock-free access
374    /// in hot path LSP handlers.
375    ///
376    /// # Returns
377    ///
378    /// The current `IndexState` snapshot.
379    ///
380    /// # Examples
381    ///
382    /// ```rust,ignore
383    /// use perl_parser::workspace_index::{IndexCoordinator, IndexState};
384    ///
385    /// let coordinator = IndexCoordinator::new();
386    /// match coordinator.state() {
387    ///     IndexState::Ready { .. } => {
388    ///         // Full query path
389    ///     }
390    ///     _ => {
391    ///         // Degraded/building fallback
392    ///     }
393    /// }
394    /// ```
395    pub fn state(&self) -> IndexState {
396        self.state.read().clone()
397    }
398
399    /// Get reference to the underlying workspace index
400    ///
401    /// Provides direct access to the `WorkspaceIndex` for operations
402    /// that don't require state checking (e.g., document store access).
403    ///
404    /// # Returns
405    ///
406    /// A shared reference to the underlying workspace index.
407    ///
408    /// # Examples
409    ///
410    /// ```rust,ignore
411    /// use perl_parser::workspace_index::IndexCoordinator;
412    ///
413    /// let coordinator = IndexCoordinator::new();
414    /// let _index = coordinator.index();
415    /// ```
416    pub fn index(&self) -> &Arc<WorkspaceIndex> {
417        &self.index
418    }
419
420    /// Access the configured resource limits
421    pub fn limits(&self) -> &IndexResourceLimits {
422        &self.limits
423    }
424
425    /// Access the configured performance caps
426    pub fn performance_caps(&self) -> &IndexPerformanceCaps {
427        &self.caps
428    }
429
430    /// Snapshot lifecycle instrumentation (durations, transitions, early exits)
431    pub fn instrumentation_snapshot(&self) -> IndexInstrumentationSnapshot {
432        self.instrumentation.snapshot()
433    }
434
435    /// Notify of file change (may trigger state transition)
436    ///
437    /// Increments the pending parse count and may transition to degraded
438    /// state if a parse storm is detected.
439    ///
440    /// # Arguments
441    ///
442    /// * `_uri` - URI of the changed file (reserved for future use).
443    ///
444    /// # Returns
445    ///
446    /// Nothing. Updates coordinator metrics and state for the LSP workflow.
447    ///
448    /// # Examples
449    ///
450    /// ```rust,ignore
451    /// use perl_parser::workspace_index::IndexCoordinator;
452    ///
453    /// let coordinator = IndexCoordinator::new();
454    /// coordinator.notify_change("file:///example.pl");
455    /// ```
456    pub fn notify_change(&self, _uri: &str) {
457        let pending = self.metrics.increment_pending_parses();
458
459        // Check for parse storm
460        if self.metrics.is_parse_storm() {
461            self.transition_to_degraded(DegradationReason::ParseStorm { pending_parses: pending });
462        }
463    }
464
465    /// Notify parse completion for the Index/Analyze workflow stages.
466    ///
467    /// Decrements the pending parse count, enforces resource limits, and may
468    /// attempt recovery when parse storms clear.
469    ///
470    /// # Arguments
471    ///
472    /// * `_uri` - URI of the parsed file (reserved for future use).
473    ///
474    /// # Returns
475    ///
476    /// Nothing. Updates coordinator metrics and state for the LSP workflow.
477    ///
478    /// # Examples
479    ///
480    /// ```rust,ignore
481    /// use perl_parser::workspace_index::IndexCoordinator;
482    ///
483    /// let coordinator = IndexCoordinator::new();
484    /// coordinator.notify_parse_complete("file:///example.pl");
485    /// ```
486    pub fn notify_parse_complete(&self, _uri: &str) {
487        let pending = self.metrics.decrement_pending_parses();
488
489        // Check for recovery from parse storm
490        if pending == 0 {
491            if let IndexState::Degraded { reason: DegradationReason::ParseStorm { .. }, .. } =
492                self.state()
493            {
494                // Attempt recovery - transition back to Building for re-scan
495                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        // Enforce resource limits after parse completion
508        self.enforce_limits();
509    }
510
511    /// Transition to Ready state
512    ///
513    /// Marks the index as fully ready for queries after successful workspace
514    /// scan. Records the file count, symbol count, and completion timestamp.
515    /// Enforces resource limits after transition.
516    ///
517    /// # State Transition Guards
518    ///
519    /// Only valid transitions:
520    /// - `Building` → `Ready` (normal completion)
521    /// - `Degraded` → `Ready` (recovery after fix)
522    ///
523    /// # Arguments
524    ///
525    /// * `file_count` - Total number of files indexed
526    /// * `symbol_count` - Total number of symbols extracted
527    ///
528    /// # Returns
529    ///
530    /// Nothing. The coordinator state is updated in-place.
531    ///
532    /// # Examples
533    ///
534    /// ```rust,ignore
535    /// use perl_parser::workspace_index::IndexCoordinator;
536    ///
537    /// let coordinator = IndexCoordinator::new();
538    /// coordinator.transition_to_ready(100, 5000);
539    /// ```
540    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        // State transition guard: validate current state allows transition to Ready
545        match &*state {
546            IndexState::Building { .. } | IndexState::Degraded { .. } => {
547                // Valid transition - proceed
548                *state =
549                    IndexState::Ready { symbol_count, file_count, completed_at: Instant::now() };
550            }
551            IndexState::Ready { .. } => {
552                // Already Ready - update metrics but don't log as transition
553                *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); // Release write lock before checking limits
559
560        // Enforce resource limits after transition
561        self.enforce_limits();
562    }
563
564    /// Transition to Scanning phase (Idle → Scanning)
565    ///
566    /// Resets build counters and marks the index as scanning workspace folders.
567    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    /// Update scanning progress with the latest discovered file count
598    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    /// Transition to Indexing phase (Scanning → Indexing)
614    ///
615    /// Uses the discovered file count as the total index target.
616    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    /// Transition to Building state (Indexing phase)
647    ///
648    /// Marks the index as indexing with a known total file count.
649    pub fn transition_to_building(&self, total_count: usize) {
650        let mut state = self.state.write();
651        let from_kind = state.kind();
652
653        // State transition guard: validate transition is allowed
654        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    /// Update Building state progress for the Index/Analyze workflow stages.
684    ///
685    /// Increments the indexed file count and checks for scan timeouts.
686    ///
687    /// # Arguments
688    ///
689    /// * `indexed_count` - Number of files indexed so far.
690    ///
691    /// # Returns
692    ///
693    /// Nothing. Updates coordinator state and may transition to `Degraded`.
694    ///
695    /// # Examples
696    ///
697    /// ```rust,ignore
698    /// use perl_parser::workspace_index::IndexCoordinator;
699    ///
700    /// let coordinator = IndexCoordinator::new();
701    /// coordinator.transition_to_building(100);
702    /// coordinator.update_building_progress(1);
703    /// ```
704    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            // Check for scan timeout
711            if elapsed > self.limits.max_scan_duration_ms {
712                // Timeout exceeded - transition to degraded
713                drop(state);
714                self.transition_to_degraded(DegradationReason::ScanTimeout { elapsed_ms: elapsed });
715                return;
716            }
717
718            // Update progress
719            *state = IndexState::Building {
720                phase: *phase,
721                indexed_count,
722                total_count: *total_count,
723                started_at: *started_at,
724            };
725        }
726    }
727
728    /// Transition to Degraded state
729    ///
730    /// Marks the index as degraded with the specified reason. Preserves
731    /// the current symbol count (if available) to indicate partial
732    /// functionality remains.
733    ///
734    /// # Arguments
735    ///
736    /// * `reason` - Why the index degraded (ParseStorm, IoError, etc.)
737    ///
738    /// # Returns
739    ///
740    /// Nothing. The coordinator state is updated in-place.
741    ///
742    /// # Examples
743    ///
744    /// ```rust,ignore
745    /// use perl_parser::workspace_index::{DegradationReason, IndexCoordinator, ResourceKind};
746    ///
747    /// let coordinator = IndexCoordinator::new();
748    /// coordinator.transition_to_degraded(DegradationReason::ResourceLimit {
749    ///     kind: ResourceKind::MaxFiles,
750    /// });
751    /// ```
752    pub fn transition_to_degraded(&self, reason: DegradationReason) {
753        let mut state = self.state.write();
754        let from_kind = state.kind();
755
756        // Get available symbols count from current state
757        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    /// Check resource limits and return degradation reason if exceeded
768    ///
769    /// Examines current workspace index state against configured resource limits.
770    /// Returns the first exceeded limit found, enabling targeted degradation.
771    ///
772    /// # Returns
773    ///
774    /// * `Some(DegradationReason)` - Resource limit exceeded, contains specific limit type
775    /// * `None` - All limits within acceptable bounds
776    ///
777    /// # Checked Limits
778    ///
779    /// - `max_files`: Total number of indexed files
780    /// - `max_total_symbols`: Aggregate symbol count across workspace
781    ///
782    /// # Performance
783    ///
784    /// - Lock-free read of index state (<100ns)
785    /// - Symbol counting is O(n) where n is number of files
786    ///
787    /// Returns: `Some(DegradationReason)` when a limit is exceeded, otherwise `None`.
788    ///
789    /// # Examples
790    ///
791    /// ```rust,ignore
792    /// use perl_parser::workspace_index::IndexCoordinator;
793    ///
794    /// let coordinator = IndexCoordinator::new();
795    /// let _reason = coordinator.check_limits();
796    /// ```
797    pub fn check_limits(&self) -> Option<DegradationReason> {
798        let files = self.index.files.read();
799
800        // Check max_files limit
801        let file_count = files.len();
802        if file_count > self.limits.max_files {
803            return Some(DegradationReason::ResourceLimit { kind: ResourceKind::MaxFiles });
804        }
805
806        // Check max_total_symbols limit
807        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    /// Enforce resource limits and trigger degradation if exceeded
816    ///
817    /// Checks current resource usage against configured limits and automatically
818    /// transitions to Degraded state if any limit is exceeded. This method should
819    /// be called after operations that modify index size (file additions, parse
820    /// completions, etc.).
821    ///
822    /// # State Transitions
823    ///
824    /// - `Ready` → `Degraded(ResourceLimit)` if limits exceeded
825    /// - `Building` → `Degraded(ResourceLimit)` if limits exceeded
826    ///
827    /// # Returns
828    ///
829    /// Nothing. The coordinator state is updated in-place when limits are exceeded.
830    ///
831    /// # Examples
832    ///
833    /// ```rust,ignore
834    /// use perl_parser::workspace_index::IndexCoordinator;
835    ///
836    /// let coordinator = IndexCoordinator::new();
837    /// // ... index some files ...
838    /// coordinator.enforce_limits();  // Check and degrade if needed
839    /// ```
840    pub fn enforce_limits(&self) {
841        if let Some(reason) = self.check_limits() {
842            self.transition_to_degraded(reason);
843        }
844    }
845
846    /// Record an early-exit event for indexing instrumentation
847    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    /// Query with automatic degradation handling
863    ///
864    /// Dispatches to full query if index is Ready, or partial query otherwise.
865    /// This pattern enables LSP handlers to provide appropriate responses
866    /// based on index state without explicit state checking.
867    ///
868    /// # Type Parameters
869    ///
870    /// * `T` - Return type of the query functions
871    /// * `F1` - Full query function type accepting `&WorkspaceIndex` and returning `T`
872    /// * `F2` - Partial query function type accepting `&WorkspaceIndex` and returning `T`
873    ///
874    /// # Arguments
875    ///
876    /// * `full_query` - Function to execute when index is Ready
877    /// * `partial_query` - Function to execute when index is Building/Degraded
878    ///
879    /// # Returns
880    ///
881    /// The value returned by the selected query function.
882    ///
883    /// # Examples
884    ///
885    /// ```rust,ignore
886    /// use perl_parser::workspace_index::IndexCoordinator;
887    ///
888    /// let coordinator = IndexCoordinator::new();
889    /// let locations = coordinator.query(
890    ///     |index| index.find_references("my_function"),  // Full workspace search
891    ///     |index| vec![]                                 // Empty fallback
892    /// );
893    /// ```
894    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// ============================================================================
913// Symbol Indexing Types
914// ============================================================================
915
916#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
917/// Symbol kinds for cross-file indexing during Index/Navigate workflows.
918pub enum SymKind {
919    /// Variable symbol ($, @, or % sigil)
920    Var,
921    /// Subroutine definition (sub foo)
922    Sub,
923    /// Package declaration (package Foo)
924    Pack,
925}
926
927#[derive(Clone, Debug, Eq, PartialEq, Hash)]
928/// A normalized symbol key for cross-file lookups in Index/Navigate workflows.
929pub struct SymbolKey {
930    /// Package name containing this symbol
931    pub pkg: Arc<str>,
932    /// Bare name without sigil prefix
933    pub name: Arc<str>,
934    /// Variable sigil ($, @, or %) if applicable
935    pub sigil: Option<char>,
936    /// Kind of symbol (variable, subroutine, package)
937    pub kind: SymKind,
938}
939
940/// Normalize a Perl variable name for Index/Analyze workflows.
941///
942/// Extracts an optional sigil and bare name for consistent symbol indexing.
943///
944/// # Arguments
945///
946/// * `name` - Variable name from Perl source, with or without sigil.
947///
948/// # Returns
949///
950/// `(sigil, name)` tuple with the optional sigil and normalized identifier.
951///
952/// # Examples
953///
954/// ```rust,ignore
955/// use perl_parser::workspace_index::normalize_var;
956///
957/// assert_eq!(normalize_var("$count"), (Some('$'), "count"));
958/// assert_eq!(normalize_var("process_emails"), (None, "process_emails"));
959/// ```
960pub fn normalize_var(name: &str) -> (Option<char>, &str) {
961    if name.is_empty() {
962        return (None, "");
963    }
964
965    // Safe: we've checked that name is not empty
966    let Some(first_char) = name.chars().next() else {
967        return (None, name); // Should never happen but handle gracefully
968    };
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// Using lsp_types for Position and Range
982
983#[derive(Debug, Clone)]
984/// Internal location type used during Navigate/Analyze workflows.
985pub struct Location {
986    /// File URI where the symbol is located
987    pub uri: String,
988    /// Line and character range within the file
989    pub range: Range,
990}
991
992#[derive(Debug, Clone, Serialize, Deserialize)]
993/// A symbol in the workspace for Index/Navigate workflows.
994pub struct WorkspaceSymbol {
995    /// Symbol name without package qualification
996    pub name: String,
997    /// Type of symbol (subroutine, variable, package, etc.)
998    pub kind: SymbolKind,
999    /// File URI where the symbol is defined
1000    pub uri: String,
1001    /// Line and character range of the symbol definition
1002    pub range: Range,
1003    /// Fully qualified name including package (e.g., "Package::function")
1004    pub qualified_name: Option<String>,
1005    /// POD documentation associated with the symbol
1006    pub documentation: Option<String>,
1007    /// Name of the containing package or class
1008    pub container_name: Option<String>,
1009    /// Whether this symbol has a body (false for forward declarations)
1010    #[serde(default = "default_has_body")]
1011    pub has_body: bool,
1012}
1013
1014fn default_has_body() -> bool {
1015    true
1016}
1017
1018// Re-export the unified symbol types from perl-symbol-types
1019/// Symbol kind enums used during Index/Analyze workflows.
1020pub use perl_symbol_types::{SymbolKind, VarKind};
1021
1022/// Helper function to convert sigil to VarKind
1023fn sigil_to_var_kind(sigil: &str) -> VarKind {
1024    match sigil {
1025        "@" => VarKind::Array,
1026        "%" => VarKind::Hash,
1027        _ => VarKind::Scalar, // Default to scalar for $ and unknown
1028    }
1029}
1030
1031#[derive(Debug, Clone)]
1032/// Reference to a symbol for Navigate/Analyze workflows.
1033pub struct SymbolReference {
1034    /// File URI where the reference occurs
1035    pub uri: String,
1036    /// Line and character range of the reference
1037    pub range: Range,
1038    /// How the symbol is being referenced (definition, usage, etc.)
1039    pub kind: ReferenceKind,
1040}
1041
1042#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1043/// Classification of how a symbol is referenced in Navigate/Analyze workflows.
1044pub enum ReferenceKind {
1045    /// Symbol definition site (sub declaration, variable declaration)
1046    Definition,
1047    /// General usage of the symbol (function call, method call)
1048    Usage,
1049    /// Import via use statement
1050    Import,
1051    /// Variable read access
1052    Read,
1053    /// Variable write access (assignment target)
1054    Write,
1055}
1056
1057#[derive(Debug, Serialize)]
1058#[serde(rename_all = "camelCase")]
1059/// LSP-compliant workspace symbol for wire format in Navigate/Analyze workflows.
1060pub struct LspWorkspaceSymbol {
1061    /// Symbol name as displayed to the user
1062    pub name: String,
1063    /// LSP symbol kind number (see lsp_types::SymbolKind)
1064    pub kind: u32,
1065    /// Location of the symbol definition
1066    pub location: WireLocation,
1067    /// Name of the containing symbol (package, class)
1068    #[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/// File-level index data
1089#[derive(Default)]
1090struct FileIndex {
1091    /// Symbols defined in this file
1092    symbols: Vec<WorkspaceSymbol>,
1093    /// References in this file (symbol name -> references)
1094    references: HashMap<String, Vec<SymbolReference>>,
1095    /// Dependencies (modules this file imports)
1096    dependencies: HashSet<String>,
1097    /// Content hash for early-exit optimization
1098    content_hash: u64,
1099}
1100
1101/// Thread-safe workspace index
1102pub struct WorkspaceIndex {
1103    /// Index data per file URI (normalized key -> data)
1104    files: Arc<RwLock<HashMap<String, FileIndex>>>,
1105    /// Global symbol map (qualified name -> defining URI)
1106    symbols: Arc<RwLock<HashMap<String, String>>>,
1107    /// Global reference index (symbol name -> locations across all files)
1108    ///
1109    /// Aggregated from per-file `FileIndex::references` during `index_file()`.
1110    /// Provides O(1) lookup for `find_references()` instead of iterating all files.
1111    global_references: Arc<RwLock<HashMap<String, Vec<Location>>>>,
1112    /// Document store for in-memory text
1113    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    /// Incrementally remove one file's symbols from the global cache,
1134    /// re-inserting shadowed symbols from remaining files.
1135    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    /// Incrementally add one file's symbols to the global cache.
1170    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    /// Create a new empty index
1207    ///
1208    /// # Returns
1209    ///
1210    /// A workspace index with empty file and symbol tables.
1211    ///
1212    /// # Examples
1213    ///
1214    /// ```rust,ignore
1215    /// use perl_parser::workspace_index::WorkspaceIndex;
1216    ///
1217    /// let index = WorkspaceIndex::new();
1218    /// assert!(!index.has_symbols());
1219    /// ```
1220    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    /// Normalize a URI to a consistent form using proper URI handling
1230    fn normalize_uri(uri: &str) -> String {
1231        perl_uri::normalize_uri(uri)
1232    }
1233
1234    /// Remove a file's contributions from the global reference index.
1235    ///
1236    /// Retains only entries whose URI does not match `file_uri`.
1237    /// Empty keys are removed to avoid unbounded map growth.
1238    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    /// Index a file from its URI and text content
1254    ///
1255    /// # Arguments
1256    ///
1257    /// * `uri` - File URI identifying the document
1258    /// * `text` - Full Perl source text for indexing
1259    ///
1260    /// # Returns
1261    ///
1262    /// `Ok(())` when indexing succeeds, or an error message otherwise.
1263    ///
1264    /// # Errors
1265    ///
1266    /// Returns an error if parsing fails or the document store cannot be updated.
1267    ///
1268    /// # Examples
1269    ///
1270    /// ```rust,ignore
1271    /// use perl_parser::workspace_index::WorkspaceIndex;
1272    /// use url::Url;
1273    ///
1274    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
1275    /// let index = WorkspaceIndex::new();
1276    /// let uri = Url::parse("file:///example.pl")?;
1277    /// index.index_file(uri, "sub hello { return 1; }".to_string())?;
1278    /// # Ok(())
1279    /// # }
1280    /// ```
1281    ///
1282    /// Returns: `Ok(())` when indexing succeeds, otherwise an error string.
1283    pub fn index_file(&self, uri: Url, text: String) -> Result<(), String> {
1284        let uri_str = uri.to_string();
1285
1286        // Compute content hash for early-exit optimization
1287        let mut hasher = DefaultHasher::new();
1288        text.hash(&mut hasher);
1289        let content_hash = hasher.finish();
1290
1291        // Check if content is unchanged (early-exit optimization)
1292        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                    // Content unchanged, skip re-indexing
1298                    return Ok(());
1299                }
1300            }
1301        }
1302
1303        // Update document store
1304        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        // Parse the file
1311        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        // Get the document for line index
1318        let mut doc = self.document_store.get(&uri_str).ok_or("Document not found")?;
1319
1320        // Extract symbols and references
1321        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        // Update the index, refresh the global symbol cache, and replace this file's
1326        // contribution in the global reference index.
1327        {
1328            let mut files = self.files.write();
1329
1330            // Remove stale global references from previous version of this file
1331            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            // Incrementally remove old symbols before inserting new file
1337            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    /// Remove a file from the index
1363    ///
1364    /// # Arguments
1365    ///
1366    /// * `uri` - File URI (string form) to remove
1367    ///
1368    /// # Returns
1369    ///
1370    /// Nothing. The index is updated in-place.
1371    ///
1372    /// # Examples
1373    ///
1374    /// ```rust,ignore
1375    /// use perl_parser::workspace_index::WorkspaceIndex;
1376    ///
1377    /// let index = WorkspaceIndex::new();
1378    /// index.remove_file("file:///example.pl");
1379    /// ```
1380    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        // Remove from document store
1385        self.document_store.close(&uri_str);
1386
1387        // Remove file index
1388        let mut files = self.files.write();
1389        if let Some(file_index) = files.remove(&key) {
1390            // Incrementally remove symbols and re-insert any shadowed names.
1391            let mut symbols = self.symbols.write();
1392            Self::incremental_remove_symbols(&files, &mut symbols, &file_index);
1393
1394            // Remove from global reference index
1395            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    /// Remove a file from the index (URL variant for compatibility)
1401    ///
1402    /// # Arguments
1403    ///
1404    /// * `uri` - File URI as a parsed `Url`
1405    ///
1406    /// # Returns
1407    ///
1408    /// Nothing. The index is updated in-place.
1409    ///
1410    /// # Examples
1411    ///
1412    /// ```rust,ignore
1413    /// use perl_parser::workspace_index::WorkspaceIndex;
1414    /// use url::Url;
1415    ///
1416    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
1417    /// let index = WorkspaceIndex::new();
1418    /// let uri = Url::parse("file:///example.pl")?;
1419    /// index.remove_file_url(&uri);
1420    /// # Ok(())
1421    /// # }
1422    /// ```
1423    pub fn remove_file_url(&self, uri: &Url) {
1424        self.remove_file(uri.as_str())
1425    }
1426
1427    /// Clear a file from the index (alias for remove_file)
1428    ///
1429    /// # Arguments
1430    ///
1431    /// * `uri` - File URI (string form) to remove
1432    ///
1433    /// # Returns
1434    ///
1435    /// Nothing. The index is updated in-place.
1436    ///
1437    /// # Examples
1438    ///
1439    /// ```rust,ignore
1440    /// use perl_parser::workspace_index::WorkspaceIndex;
1441    ///
1442    /// let index = WorkspaceIndex::new();
1443    /// index.clear_file("file:///example.pl");
1444    /// ```
1445    pub fn clear_file(&self, uri: &str) {
1446        self.remove_file(uri);
1447    }
1448
1449    /// Clear a file from the index (URL variant for compatibility)
1450    ///
1451    /// # Arguments
1452    ///
1453    /// * `uri` - File URI as a parsed `Url`
1454    ///
1455    /// # Returns
1456    ///
1457    /// Nothing. The index is updated in-place.
1458    ///
1459    /// # Examples
1460    ///
1461    /// ```rust,ignore
1462    /// use perl_parser::workspace_index::WorkspaceIndex;
1463    /// use url::Url;
1464    ///
1465    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
1466    /// let index = WorkspaceIndex::new();
1467    /// let uri = Url::parse("file:///example.pl")?;
1468    /// index.clear_file_url(&uri);
1469    /// # Ok(())
1470    /// # }
1471    /// ```
1472    pub fn clear_file_url(&self, uri: &Url) {
1473        self.clear_file(uri.as_str())
1474    }
1475
1476    #[cfg(not(target_arch = "wasm32"))]
1477    /// Index a file from a URI string for the Index/Analyze workflow.
1478    ///
1479    /// Accepts either a `file://` URI or a filesystem path. Not available on
1480    /// wasm32 targets (requires filesystem path conversion).
1481    ///
1482    /// # Arguments
1483    ///
1484    /// * `uri` - File URI string or filesystem path.
1485    /// * `text` - Full Perl source text for indexing.
1486    ///
1487    /// # Returns
1488    ///
1489    /// `Ok(())` when indexing succeeds, or an error message otherwise.
1490    ///
1491    /// # Errors
1492    ///
1493    /// Returns an error if the URI is invalid or parsing fails.
1494    ///
1495    /// # Examples
1496    ///
1497    /// ```rust,ignore
1498    /// use perl_parser::workspace_index::WorkspaceIndex;
1499    ///
1500    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
1501    /// let index = WorkspaceIndex::new();
1502    /// index.index_file_str("file:///example.pl", "sub hello { }")?;
1503    /// # Ok(())
1504    /// # }
1505    /// ```
1506    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            // Raw absolute Windows paths like C:\foo can parse as a bogus URI
1513            // (`c:` scheme). Prefer URL parsing only for non-path inputs.
1514            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    /// Index multiple files in a single batch operation.
1523    ///
1524    /// This is significantly faster than calling `index_file` in a loop for
1525    /// initial workspace scans because it defers the global symbol cache
1526    /// rebuild to a single pass at the end.
1527    ///
1528    /// Phase 1: Parse all files without holding locks.
1529    /// Phase 2: Bulk-insert file indices and rebuild the symbol cache once.
1530    pub fn index_files_batch(&self, files_to_index: Vec<(Url, String)>) -> Vec<String> {
1531        let mut errors = Vec::new();
1532
1533        // Phase 1: Parse all files without locks
1534        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            // Content hash for early-exit
1539            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            // Check if content unchanged
1546            {
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            // Update document store
1556            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            // Parse
1563            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        // Phase 2: Bulk insert with single cache rebuild
1588        {
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                // Remove stale global references
1595                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                // Add global references for this file
1602                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            // Single rebuild at the end
1616            Self::rebuild_symbol_cache(&files, &mut symbols);
1617        }
1618
1619        errors
1620    }
1621
1622    /// Find all references to a symbol using dual indexing strategy
1623    ///
1624    /// This function searches for both exact matches and bare name matches when
1625    /// the symbol is qualified. For example, when searching for "Utils::process_data":
1626    /// - First searches for exact "Utils::process_data" references
1627    /// - Then searches for bare "process_data" references that might refer to the same function
1628    ///
1629    /// This dual approach handles cases where functions are called both as:
1630    /// - Qualified: `Utils::process_data()`
1631    /// - Unqualified: `process_data()` (when in the same package or imported)
1632    ///
1633    /// # Arguments
1634    ///
1635    /// * `symbol_name` - Symbol name or qualified name to search
1636    ///
1637    /// # Returns
1638    ///
1639    /// All reference locations found for the requested symbol.
1640    ///
1641    /// # Examples
1642    ///
1643    /// ```rust,ignore
1644    /// use perl_parser::workspace_index::WorkspaceIndex;
1645    ///
1646    /// let index = WorkspaceIndex::new();
1647    /// let _refs = index.find_references("Utils::process_data");
1648    /// ```
1649    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        // O(1) lookup for exact symbol name
1655        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 the symbol is qualified, also collect bare name references
1671        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    /// Count non-definition references (usages) of a symbol.
1693    ///
1694    /// Like `find_references` but excludes `ReferenceKind::Definition` entries,
1695    /// returning only actual usage sites. This is used by code lens to show
1696    /// "N references" where N means call sites, not the definition itself.
1697    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    /// Find the definition of a symbol
1734    ///
1735    /// # Arguments
1736    ///
1737    /// * `symbol_name` - Symbol name or qualified name to resolve
1738    ///
1739    /// # Returns
1740    ///
1741    /// The first matching definition location, if found.
1742    ///
1743    /// # Examples
1744    ///
1745    /// ```rust,ignore
1746    /// use perl_parser::workspace_index::WorkspaceIndex;
1747    ///
1748    /// let index = WorkspaceIndex::new();
1749    /// let _def = index.find_definition("MyPackage::example");
1750    /// ```
1751    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    /// Get all symbols in the workspace
1778    ///
1779    /// # Returns
1780    ///
1781    /// A vector containing every symbol currently indexed.
1782    ///
1783    /// # Examples
1784    ///
1785    /// ```rust,ignore
1786    /// use perl_parser::workspace_index::WorkspaceIndex;
1787    ///
1788    /// let index = WorkspaceIndex::new();
1789    /// let _symbols = index.all_symbols();
1790    /// ```
1791    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    /// Clear all indexed files and symbols from the workspace.
1803    pub fn clear(&self) {
1804        self.files.write().clear();
1805        self.symbols.write().clear();
1806        self.global_references.write().clear();
1807    }
1808
1809    /// Return the number of indexed files in the workspace
1810    pub fn file_count(&self) -> usize {
1811        let files = self.files.read();
1812        files.len()
1813    }
1814
1815    /// Return the total number of symbols across all indexed files
1816    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    /// Check if the workspace index has symbols (soft readiness check)
1822    ///
1823    /// Returns true if the index contains any symbols, indicating that
1824    /// at least some files have been indexed and the workspace is ready
1825    /// for symbol-based operations like completion.
1826    ///
1827    /// # Returns
1828    ///
1829    /// `true` if any symbols are indexed, otherwise `false`.
1830    ///
1831    /// # Examples
1832    ///
1833    /// ```rust,ignore
1834    /// use perl_parser::workspace_index::WorkspaceIndex;
1835    ///
1836    /// let index = WorkspaceIndex::new();
1837    /// assert!(!index.has_symbols());
1838    /// ```
1839    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    /// Search for symbols by query
1845    ///
1846    /// # Arguments
1847    ///
1848    /// * `query` - Substring to match against symbol names
1849    ///
1850    /// # Returns
1851    ///
1852    /// Symbols whose names or qualified names contain the query string.
1853    ///
1854    /// # Examples
1855    ///
1856    /// ```rust,ignore
1857    /// use perl_parser::workspace_index::WorkspaceIndex;
1858    ///
1859    /// let index = WorkspaceIndex::new();
1860    /// let _results = index.search_symbols("example");
1861    /// ```
1862    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    /// Find symbols by query (alias for search_symbols for compatibility)
1883    ///
1884    /// # Arguments
1885    ///
1886    /// * `query` - Substring to match against symbol names
1887    ///
1888    /// # Returns
1889    ///
1890    /// Symbols whose names or qualified names contain the query string.
1891    ///
1892    /// # Examples
1893    ///
1894    /// ```rust,ignore
1895    /// use perl_parser::workspace_index::WorkspaceIndex;
1896    ///
1897    /// let index = WorkspaceIndex::new();
1898    /// let _results = index.find_symbols("example");
1899    /// ```
1900    pub fn find_symbols(&self, query: &str) -> Vec<WorkspaceSymbol> {
1901        self.search_symbols(query)
1902    }
1903
1904    /// Get symbols in a specific file
1905    ///
1906    /// # Arguments
1907    ///
1908    /// * `uri` - File URI to inspect
1909    ///
1910    /// # Returns
1911    ///
1912    /// All symbols indexed for the requested file.
1913    ///
1914    /// # Examples
1915    ///
1916    /// ```rust,ignore
1917    /// use perl_parser::workspace_index::WorkspaceIndex;
1918    ///
1919    /// let index = WorkspaceIndex::new();
1920    /// let _symbols = index.file_symbols("file:///example.pl");
1921    /// ```
1922    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    /// Get dependencies of a file
1931    ///
1932    /// # Arguments
1933    ///
1934    /// * `uri` - File URI to inspect
1935    ///
1936    /// # Returns
1937    ///
1938    /// A set of module names imported by the file.
1939    ///
1940    /// # Examples
1941    ///
1942    /// ```rust,ignore
1943    /// use perl_parser::workspace_index::WorkspaceIndex;
1944    ///
1945    /// let index = WorkspaceIndex::new();
1946    /// let _deps = index.file_dependencies("file:///example.pl");
1947    /// ```
1948    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    /// Find all files that depend on a module
1957    ///
1958    /// # Arguments
1959    ///
1960    /// * `module_name` - Module name to search for in file dependencies
1961    ///
1962    /// # Returns
1963    ///
1964    /// A list of file URIs that import or depend on the module.
1965    ///
1966    /// # Examples
1967    ///
1968    /// ```rust,ignore
1969    /// use perl_parser::workspace_index::WorkspaceIndex;
1970    ///
1971    /// let index = WorkspaceIndex::new();
1972    /// let _files = index.find_dependents("My::Module");
1973    /// ```
1974    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    /// Get the document store
1988    ///
1989    /// # Returns
1990    ///
1991    /// A reference to the in-memory document store.
1992    ///
1993    /// # Examples
1994    ///
1995    /// ```rust,ignore
1996    /// use perl_parser::workspace_index::WorkspaceIndex;
1997    ///
1998    /// let index = WorkspaceIndex::new();
1999    /// let _store = index.document_store();
2000    /// ```
2001    pub fn document_store(&self) -> &DocumentStore {
2002        &self.document_store
2003    }
2004
2005    /// Find unused symbols in the workspace
2006    ///
2007    /// # Returns
2008    ///
2009    /// Symbols that have no non-definition references in the workspace.
2010    ///
2011    /// # Examples
2012    ///
2013    /// ```rust,ignore
2014    /// use perl_parser::workspace_index::WorkspaceIndex;
2015    ///
2016    /// let index = WorkspaceIndex::new();
2017    /// let _unused = index.find_unused_symbols();
2018    /// ```
2019    pub fn find_unused_symbols(&self) -> Vec<WorkspaceSymbol> {
2020        let files = self.files.read();
2021        let mut unused = Vec::new();
2022
2023        // Collect all defined symbols
2024        for (_uri_key, file_index) in files.iter() {
2025            for symbol in &file_index.symbols {
2026                // Check if this symbol has any references beyond its definition
2027                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    /// Get all symbols that belong to a specific package
2045    ///
2046    /// # Arguments
2047    ///
2048    /// * `package_name` - Package name to match (e.g., `My::Package`)
2049    ///
2050    /// # Returns
2051    ///
2052    /// Symbols defined within the requested package.
2053    ///
2054    /// # Examples
2055    ///
2056    /// ```rust,ignore
2057    /// use perl_parser::workspace_index::WorkspaceIndex;
2058    ///
2059    /// let index = WorkspaceIndex::new();
2060    /// let _members = index.get_package_members("My::Package");
2061    /// ```
2062    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                // Check if symbol belongs to this package
2069                if let Some(ref container) = symbol.container_name {
2070                    if container == package_name {
2071                        members.push(symbol.clone());
2072                    }
2073                }
2074                // Also check qualified names
2075                if let Some(ref qname) = symbol.qualified_name {
2076                    if qname.starts_with(&format!("{}::", package_name)) {
2077                        // Avoid duplicates - only add if not already in via container_name
2078                        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    /// Find the definition location for a symbol key during Index/Navigate stages.
2090    ///
2091    /// # Arguments
2092    ///
2093    /// * `key` - Normalized symbol key to resolve.
2094    ///
2095    /// # Returns
2096    ///
2097    /// The definition location for the symbol, if found.
2098    ///
2099    /// # Examples
2100    ///
2101    /// ```rust,ignore
2102    /// use perl_parser::workspace_index::{SymKind, SymbolKey, WorkspaceIndex};
2103    /// use std::sync::Arc;
2104    ///
2105    /// let index = WorkspaceIndex::new();
2106    /// let key = SymbolKey { pkg: Arc::from("My::Package"), name: Arc::from("example"), sigil: None, kind: SymKind::Sub };
2107    /// let _def = index.find_def(&key);
2108    /// ```
2109    pub fn find_def(&self, key: &SymbolKey) -> Option<Location> {
2110        if let Some(sigil) = key.sigil {
2111            // It's a variable
2112            let var_name = format!("{}{}", sigil, key.name);
2113            self.find_definition(&var_name)
2114        } else if key.kind == SymKind::Pack {
2115            // It's a package lookup (e.g., from `use Module::Name`)
2116            // Search for the package declaration by name
2117            self.find_definition(key.pkg.as_ref())
2118                .or_else(|| self.find_definition(key.name.as_ref()))
2119        } else {
2120            // It's a subroutine or package
2121            let qualified_name = format!("{}::{}", key.pkg, key.name);
2122            self.find_definition(&qualified_name)
2123        }
2124    }
2125
2126    /// Find reference locations for a symbol key using dual indexing.
2127    ///
2128    /// Searches both qualified and bare names to support Navigate/Analyze workflows.
2129    ///
2130    /// # Arguments
2131    ///
2132    /// * `key` - Normalized symbol key to search for.
2133    ///
2134    /// # Returns
2135    ///
2136    /// All reference locations for the symbol, excluding the definition.
2137    ///
2138    /// # Examples
2139    ///
2140    /// ```rust,ignore
2141    /// use perl_parser::workspace_index::{SymKind, SymbolKey, WorkspaceIndex};
2142    /// use std::sync::Arc;
2143    ///
2144    /// let index = WorkspaceIndex::new();
2145    /// let key = SymbolKey { pkg: Arc::from("main"), name: Arc::from("example"), sigil: None, kind: SymKind::Sub };
2146    /// let _refs = index.find_refs(&key);
2147    /// ```
2148    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            // It's a variable - search through all files for this variable name
2152            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            // It's a subroutine or package
2164            if key.pkg.as_ref() == "main" {
2165                // For main package, we search for both "main::foo" and bare "foo"
2166                let mut refs = self.find_references(&format!("main::{}", key.name));
2167                // Add bare name references
2168                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        // Remove the definition; the caller will include it separately if needed
2187        if let Some(def) = self.find_def(key) {
2188            all_refs.retain(|loc| !(loc.uri == def.uri && loc.range == def.range));
2189        }
2190
2191        // Deduplicate by URI and range
2192        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
2207/// AST visitor for extracting symbols and references
2208struct 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                // Update the current package (replaces the previous one, not a stack)
2326                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                    // Check if this is a forward declaration or update to existing symbol
2349                    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                        // Update existing forward declaration with body
2355                        file_index.symbols[idx].range = self.node_to_range(node);
2356                    } else {
2357                        // New symbol
2358                        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, // Subroutine node always has body
2367                        });
2368                    }
2369
2370                    // Mark as definition
2371                    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                // Visit body
2381                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, // Variables always have body
2397                    });
2398
2399                    // Mark as definition
2400                    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                // Visit initializer
2410                if let Some(init) = initializer {
2411                    self.visit_node(init, file_index);
2412                }
2413            }
2414
2415            NodeKind::VariableListDeclaration { variables, initializer, .. } => {
2416                // Handle each variable in the list declaration
2417                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                        // Mark as definition
2433                        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                // Visit the initializer
2442                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                // Track as usage (could be read or write based on context)
2451                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, // Default to read, would need context for write
2455                });
2456            }
2457
2458            NodeKind::FunctionCall { name, args, .. } => {
2459                let func_name = name.clone();
2460                let location = self.node_to_range(node);
2461
2462                // Determine package and bare name
2463                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                // Track as usage for both qualified and bare forms
2472                // This dual indexing allows finding references whether the function is called
2473                // as `process_data()` or `Utils::process_data()`
2474                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                // Visit arguments
2488                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                // Also track actual parent/base class names for dependency discovery.
2498                // `use parent 'Foo::Bar'` stores module="parent" and args=["'Foo::Bar'"],
2499                // so find_dependents("Foo::Bar") would miss files with only use parent.
2500                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                // Track as import
2507                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            // Handle assignment to detect writes
2515            NodeKind::Assignment { lhs, rhs, op } => {
2516                // For compound assignments (+=, -=, .=, etc.), the LHS is both read and written
2517                let is_compound = op != "=";
2518
2519                if let NodeKind::Variable { sigil, name } = &lhs.kind {
2520                    let var_name = format!("{}{}", sigil, name);
2521
2522                    // For compound assignments, it's a read first
2523                    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                    // Then it's always a write
2534                    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                // Right side could have reads
2542                self.visit_node(rhs, file_index);
2543            }
2544
2545            // Recursively visit child nodes
2546            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                // Iterator is a write context
2590                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                // Check if this is a static method call (Package->method)
2608                let qualified_method = if let NodeKind::Identifier { name } = &object.kind {
2609                    // Static method call: Package->method
2610                    Some(format!("{}::{}", name, method))
2611                } else {
2612                    // Instance method call: $obj->method
2613                    None
2614                };
2615
2616                // Object is a read context
2617                self.visit_node(object, file_index);
2618
2619                // Track method call with qualified name if applicable
2620                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                // Visit arguments
2630                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                // Visit params
2676                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                // Visit body
2685                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            // Handle special assignments (++ and --)
2703            NodeKind::Unary { op, operand } if op == "++" || op == "--" => {
2704                // Pre/post increment/decrement are both read and write
2705                if let NodeKind::Variable { sigil, name } = &operand.kind {
2706                    let var_name = format!("{}{}", sigil, name);
2707
2708                    // It's both a read and a write
2709                    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                // For other node types, just visit children
2727                self.visit_children(node, file_index);
2728            }
2729        }
2730    }
2731
2732    fn visit_children(&mut self, node: &Node, file_index: &mut FileIndex) {
2733        // Generic visitor for unhandled node types - visit all nested nodes
2734        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            // Expression nodes
2744            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                // For other node types, no children to visit
2807            }
2808        }
2809    }
2810
2811    fn node_to_range(&mut self, node: &Node) -> Range {
2812        // LineIndex.range returns line numbers and UTF-16 code unit columns
2813        let ((start_line, start_col), (end_line, end_col)) =
2814            self.document.line_index.range(node.location.start, node.location.end);
2815        // Use byte offsets from node.location directly
2816        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
2823/// Extract bare module names from the argument list of a `use parent` / `use base` statement.
2824///
2825/// The `args` field of `NodeKind::Use` stores raw argument strings as the parser captured them.
2826/// For `use parent 'Foo::Bar'` this is `["'Foo::Bar'"]`.
2827/// For `use parent qw(Foo::Bar Other::Base)` this is `["qw(Foo::Bar Other::Base)"]`.
2828/// For `use parent -norequire, 'Foo::Bar'` this is `["-norequire", "'Foo::Bar'"]`.
2829///
2830/// Returns the module names with surrounding quotes/qw wrappers stripped.
2831/// Tokens starting with `-` or not matching `[\w::']+` are silently skipped.
2832fn extract_module_names_from_use_args(args: &[String]) -> Vec<String> {
2833    let joined = args.join(" ");
2834
2835    // Strip qw(...) wrapper and collect the inner tokens
2836    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            // Skip flags like -norequire and bare punctuation from qw() or parens
2850            if token.starts_with('-') {
2851                return None;
2852            }
2853            // Strip surrounding single or double quotes
2854            let stripped = token.trim_matches('\'').trim_matches('"');
2855            // Strip surrounding parentheses (e.g. `use parent ('Foo')`)
2856            let stripped = stripped.trim_matches('(').trim_matches(')');
2857            let stripped = stripped.trim_matches('\'').trim_matches('"');
2858            // Accept tokens containing only word characters, `::`, or `'` (legacy separator)
2859            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/// LSP adapter for converting internal Location types to LSP types
2878#[cfg(all(feature = "workspace", feature = "lsp-compat"))]
2879/// LSP adapter utilities for Navigate/Analyze workflows.
2880pub mod lsp_adapter {
2881    use super::Location as IxLocation;
2882    use lsp_types::Location as LspLocation;
2883    // lsp_types uses Uri, not Url
2884    type LspUrl = lsp_types::Uri;
2885
2886    /// Convert an internal location to an LSP Location for Navigate workflows.
2887    ///
2888    /// # Arguments
2889    ///
2890    /// * `ix` - Internal index location with URI and range information.
2891    ///
2892    /// # Returns
2893    ///
2894    /// `Some(LspLocation)` when conversion succeeds, or `None` if URI parsing fails.
2895    ///
2896    /// # Examples
2897    ///
2898    /// ```rust,ignore
2899    /// use perl_parser::workspace_index::{Location as IxLocation, lsp_adapter::to_lsp_location};
2900    /// use lsp_types::Range;
2901    ///
2902    /// let ix_loc = IxLocation { uri: "file:///path.pl".to_string(), range: Range::default() };
2903    /// let _ = to_lsp_location(&ix_loc);
2904    /// ```
2905    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    /// Convert multiple index locations to LSP Locations for Navigate/Analyze workflows.
2917    ///
2918    /// # Arguments
2919    ///
2920    /// * `all` - Iterator of internal index locations to convert.
2921    ///
2922    /// # Returns
2923    ///
2924    /// Vector of successfully converted LSP locations, with invalid entries filtered out.
2925    ///
2926    /// # Examples
2927    ///
2928    /// ```rust,ignore
2929    /// use perl_parser::workspace_index::{Location as IxLocation, lsp_adapter::to_lsp_locations};
2930    /// use lsp_types::Range;
2931    ///
2932    /// let locations = vec![IxLocation { uri: "file:///script1.pl".to_string(), range: Range::default() }];
2933    /// let lsp_locations = to_lsp_locations(locations);
2934    /// assert_eq!(lsp_locations.len(), 1);
2935    /// ```
2936    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        // lsp_types::Uri uses FromStr, not TryFrom
2943        use std::str::FromStr;
2944
2945        // Try parsing as URI first
2946        LspUrl::from_str(s).ok().or_else(|| {
2947            // Try as a file path if URI parsing fails
2948            std::path::Path::new(s).canonicalize().ok().and_then(|p| {
2949                // Use proper URI construction with percent-encoding
2950                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    /// Parse a string as a URL (wasm32 version - no filesystem fallback)
2958    #[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        // Should have indexed the package and subroutine
2988        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); // Definition + at least one usage
3011    }
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        // Test basic file:// URI conversion
3035        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        // Test with invalid URI
3040        assert!(uri_to_fs_path("not-a-uri").is_none());
3041
3042        // Test with non-file scheme
3043        assert!(uri_to_fs_path("http://example.com").is_none());
3044    }
3045
3046    #[test]
3047    fn test_uri_to_fs_path_with_spaces() {
3048        // Test with percent-encoded spaces
3049        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        // Test with multiple spaces and special characters
3054        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        // Test with Unicode characters (percent-encoded)
3062        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        // Test with Unicode emoji (percent-encoded)
3067        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        // Test basic path to URI conversion
3075        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        // Test path with spaces
3085        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        // Should contain percent-encoded spaces
3090        assert!(uri.contains("path%20with%20spaces"));
3091    }
3092
3093    #[test]
3094    fn test_fs_path_to_uri_with_unicode() {
3095        // Test path with Unicode characters
3096        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        // Should contain percent-encoded Unicode
3101        assert!(uri.contains("caf%C3%A9"));
3102    }
3103
3104    #[test]
3105    fn test_normalize_uri_file_schemes() {
3106        // Test normalization of valid file URIs
3107        let uri = WorkspaceIndex::normalize_uri("file:///tmp/test.pl");
3108        assert_eq!(uri, "file:///tmp/test.pl");
3109
3110        // Test normalization of URIs with spaces
3111        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        // Test normalization of absolute paths (convert to file:// URI)
3118        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        // Test that special schemes like untitled: are preserved
3126        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        // Test that URI -> path -> URI conversion preserves the URI
3133        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                // Should be able to round-trip back to an equivalent URI
3138                assert!(converted_uri.starts_with("file://"));
3139
3140                // The path component should decode correctly
3141                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        // Test Windows-style paths
3160        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        // Test Windows path with spaces
3166        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    // ========================================================================
3174    // IndexCoordinator Tests
3175    // ========================================================================
3176
3177    #[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        // Trigger parse storm
3237        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        // Trigger parse storm
3258        for _ in 0..15 {
3259            coordinator.notify_change("file.pm");
3260        }
3261
3262        // Complete all parses
3263        for _ in 0..15 {
3264            coordinator.notify_parse_complete("file.pm");
3265        }
3266
3267        // Should recover to Building state
3268        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        // Building state should use partial query
3285
3286        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        // Should have access to underlying WorkspaceIndex
3373        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        // Index 10 files (exceeds limit of 5)
3391        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        // Enforce limits
3399        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, // Very low limit for testing
3421            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        // Index files with many symbols to exceed total symbol limit
3430        for i in 0..10 {
3431            let uri_str = format!("file:///test{}.pl", i);
3432            let uri = must(url::Url::parse(&uri_str));
3433            // Each file has 10 subroutines = 100 total symbols (exceeds limit of 50)
3434            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        // Enforce limits
3451        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        // Index a few files well within default limits
3473        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        // Should not trigger degradation
3481        let limit_check = coordinator.check_limits();
3482        assert!(limit_check.is_none(), "check_limits should return None when within bounds");
3483
3484        // State should still be Ready
3485        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        // Index files before transitioning to ready
3505        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        // Transition to ready - should automatically enforce limits
3513        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        // Test that Ready → Ready is allowed (metrics update)
3532        let coordinator = IndexCoordinator::new();
3533        coordinator.transition_to_ready(100, 5000);
3534
3535        // Transition to Ready again with different metrics
3536        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        // Test that Building → Building is allowed (progress update)
3549        let coordinator = IndexCoordinator::new();
3550
3551        // Initial building state
3552        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        // Update total count
3562        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        // Test that Ready → Building is allowed (re-scan)
3575        let coordinator = IndexCoordinator::new();
3576        coordinator.transition_to_ready(100, 5000);
3577
3578        // Trigger re-scan
3579        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        // Test that Degraded → Building is allowed (recovery)
3592        let coordinator = IndexCoordinator::new();
3593        coordinator.transition_to_degraded(DegradationReason::IoError {
3594            message: "Test error".to_string(),
3595        });
3596
3597        // Attempt recovery
3598        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        // Update progress
3614        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        // Update progress again
3624        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        // Test that scan timeout triggers degradation
3637        let limits = IndexResourceLimits {
3638            max_scan_duration_ms: 0, // Immediate timeout for testing
3639            ..Default::default()
3640        };
3641
3642        let coordinator = IndexCoordinator::with_limits(limits);
3643        coordinator.transition_to_building(100);
3644
3645        // Small sleep to ensure elapsed time > 0
3646        std::thread::sleep(std::time::Duration::from_millis(1));
3647
3648        // Update progress should detect timeout
3649        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        // Test that scan doesn't timeout within the limit
3665        let limits = IndexResourceLimits {
3666            max_scan_duration_ms: 10_000, // 10 seconds - should not trigger
3667            ..Default::default()
3668        };
3669
3670        let coordinator = IndexCoordinator::with_limits(limits);
3671        coordinator.transition_to_building(100);
3672
3673        // Update progress immediately (well within limit)
3674        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        // First indexing should parse and index
3697        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        // Second indexing with same content should early-exit
3703        // We can verify this by checking that the index still works correctly
3704        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        // First indexing
3732        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        // Second indexing with different content should re-parse
3738        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        // First indexing
3766        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        // Second indexing with whitespace change should re-parse (hash will differ)
3771        must(index.index_file(uri.clone(), code2.to_string()));
3772        let symbols2 = index.file_symbols(uri.as_str());
3773        // Symbols should still be found, but content hash differs so it re-indexed
3774        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        // File 1: defines Utils::process_data
3819        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        // File 2: calls Utils::process_data (qualified call)
3830        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        // Each qualified call is stored under both "process_data" and "Utils::process_data"
3839        // by the dual indexing strategy. count_usages should deduplicate so we get the
3840        // actual number of call sites, not double.
3841        let count = index.count_usages("Utils::process_data");
3842
3843        // We expect exactly 2 usage sites (the two calls in app.pl),
3844        // not 4 (which would be the double-counted result).
3845        assert_eq!(
3846            count, 2,
3847            "count_usages should not double-count qualified calls, got {} (expected 2)",
3848            count
3849        );
3850
3851        // find_references should also deduplicate
3852        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    // -------------------------------------------------------------------------
3931    // find_dependents — use parent / use base integration (#2747)
3932    // -------------------------------------------------------------------------
3933
3934    #[test]
3935    fn test_index_dependency_via_use_parent_end_to_end() {
3936        // Regression for #2747: index a file with `use parent 'MyBase'` and verify
3937        // that find_dependents("MyBase") returns that file.
3938        // 1. Index MyBase.pm
3939        // 2. Index child.pl with `use parent 'MyBase'`
3940        // 3. find_dependents("MyBase") should return child.pl
3941        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        // Regression for #2747: verify that the parser produces args=["'MyBase'"]
3977        // for `use parent 'MyBase'`, so extract_module_names_from_use_args strips
3978        // the quotes and registers the dependency under the bare name "MyBase".
3979        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; // Test passed
4000                    }
4001                }
4002            }
4003            panic!("No Use node with module='parent' found in AST");
4004        } else {
4005            panic!("Expected Program root");
4006        }
4007    }
4008
4009    // -------------------------------------------------------------------------
4010    // extract_module_names_from_use_args — unit tests (#2747)
4011    // -------------------------------------------------------------------------
4012
4013    #[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        // Perl legacy package separator ' (tick) inside module name
4049        let names = extract_module_names_from_use_args(&["'Foo'Bar'".to_string()]);
4050        // After stripping outer quotes the raw token is Foo'Bar — a valid legacy name
4051        assert_eq!(names, vec!["Foo'Bar"]);
4052    }
4053}