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    /// Create a workspace index with pre-allocated capacity.
1230    ///
1231    /// Pre-allocating reduces the number of rehash operations during large-workspace
1232    /// startup. Use this instead of `new()` when the approximate workspace size is
1233    /// known in advance (e.g. from a file discovery scan).
1234    ///
1235    /// # Arguments
1236    ///
1237    /// * `estimated_files` - Expected number of source files in the workspace.
1238    /// * `avg_symbols_per_file` - Expected average number of symbols per file.
1239    ///
1240    /// # Panics
1241    ///
1242    /// Does not panic. Overflow is prevented via `saturating_mul` and an upper cap
1243    /// on the symbol/reference map capacity.
1244    ///
1245    /// # Examples
1246    ///
1247    /// ```rust,ignore
1248    /// use perl_workspace_index::workspace::workspace_index::WorkspaceIndex;
1249    ///
1250    /// let index = WorkspaceIndex::with_capacity(1000, 20);
1251    /// assert!(!index.has_symbols());
1252    /// ```
1253    pub fn with_capacity(estimated_files: usize, avg_symbols_per_file: usize) -> Self {
1254        // Each symbol is stored twice (qualified + bare name) due to dual indexing.
1255        let sym_cap =
1256            estimated_files.saturating_mul(avg_symbols_per_file).saturating_mul(2).min(1_000_000);
1257        let ref_cap = (sym_cap / 4).min(1_000_000);
1258        Self {
1259            files: Arc::new(RwLock::new(HashMap::with_capacity(estimated_files))),
1260            symbols: Arc::new(RwLock::new(HashMap::with_capacity(sym_cap))),
1261            global_references: Arc::new(RwLock::new(HashMap::with_capacity(ref_cap))),
1262            document_store: DocumentStore::new(),
1263        }
1264    }
1265
1266    /// Normalize a URI to a consistent form using proper URI handling
1267    fn normalize_uri(uri: &str) -> String {
1268        perl_uri::normalize_uri(uri)
1269    }
1270
1271    /// Remove a file's contributions from the global reference index.
1272    ///
1273    /// Retains only entries whose URI does not match `file_uri`.
1274    /// Empty keys are removed to avoid unbounded map growth.
1275    fn remove_file_global_refs(
1276        global_refs: &mut HashMap<String, Vec<Location>>,
1277        file_index: &FileIndex,
1278        file_uri: &str,
1279    ) {
1280        for name in file_index.references.keys() {
1281            if let Some(locs) = global_refs.get_mut(name) {
1282                locs.retain(|loc| loc.uri != file_uri);
1283                if locs.is_empty() {
1284                    global_refs.remove(name);
1285                }
1286            }
1287        }
1288    }
1289
1290    /// Index a file from its URI and text content
1291    ///
1292    /// # Arguments
1293    ///
1294    /// * `uri` - File URI identifying the document
1295    /// * `text` - Full Perl source text for indexing
1296    ///
1297    /// # Returns
1298    ///
1299    /// `Ok(())` when indexing succeeds, or an error message otherwise.
1300    ///
1301    /// # Errors
1302    ///
1303    /// Returns an error if parsing fails or the document store cannot be updated.
1304    ///
1305    /// # Examples
1306    ///
1307    /// ```rust,ignore
1308    /// use perl_parser::workspace_index::WorkspaceIndex;
1309    /// use url::Url;
1310    ///
1311    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
1312    /// let index = WorkspaceIndex::new();
1313    /// let uri = Url::parse("file:///example.pl")?;
1314    /// index.index_file(uri, "sub hello { return 1; }".to_string())?;
1315    /// # Ok(())
1316    /// # }
1317    /// ```
1318    ///
1319    /// Returns: `Ok(())` when indexing succeeds, otherwise an error string.
1320    pub fn index_file(&self, uri: Url, text: String) -> Result<(), String> {
1321        let uri_str = uri.to_string();
1322
1323        // Compute content hash for early-exit optimization
1324        let mut hasher = DefaultHasher::new();
1325        text.hash(&mut hasher);
1326        let content_hash = hasher.finish();
1327
1328        // Check if content is unchanged (early-exit optimization)
1329        let key = DocumentStore::uri_key(&uri_str);
1330        {
1331            let files = self.files.read();
1332            if let Some(existing_index) = files.get(&key) {
1333                if existing_index.content_hash == content_hash {
1334                    // Content unchanged, skip re-indexing
1335                    return Ok(());
1336                }
1337            }
1338        }
1339
1340        // Update document store
1341        if self.document_store.is_open(&uri_str) {
1342            self.document_store.update(&uri_str, 1, text.clone());
1343        } else {
1344            self.document_store.open(uri_str.clone(), 1, text.clone());
1345        }
1346
1347        // Parse the file
1348        let mut parser = Parser::new(&text);
1349        let ast = match parser.parse() {
1350            Ok(ast) => ast,
1351            Err(e) => return Err(format!("Parse error: {}", e)),
1352        };
1353
1354        // Get the document for line index
1355        let mut doc = self.document_store.get(&uri_str).ok_or("Document not found")?;
1356
1357        // Extract symbols and references
1358        let mut file_index = FileIndex { content_hash, ..Default::default() };
1359        let mut visitor = IndexVisitor::new(&mut doc, uri_str.clone());
1360        visitor.visit(&ast, &mut file_index);
1361
1362        // Update the index, refresh the global symbol cache, and replace this file's
1363        // contribution in the global reference index.
1364        {
1365            let mut files = self.files.write();
1366
1367            // Remove stale global references from previous version of this file
1368            if let Some(old_index) = files.get(&key) {
1369                let mut global_refs = self.global_references.write();
1370                Self::remove_file_global_refs(&mut global_refs, old_index, &uri_str);
1371            }
1372
1373            // Incrementally remove old symbols before inserting new file
1374            if let Some(old_index) = files.get(&key) {
1375                let mut symbols = self.symbols.write();
1376                Self::incremental_remove_symbols(&files, &mut symbols, old_index);
1377                drop(symbols);
1378            }
1379            files.insert(key.clone(), file_index);
1380            let mut symbols = self.symbols.write();
1381            if let Some(new_index) = files.get(&key) {
1382                Self::incremental_add_symbols(&mut symbols, new_index);
1383            }
1384
1385            if let Some(file_index) = files.get(&key) {
1386                let mut global_refs = self.global_references.write();
1387                for (name, refs) in &file_index.references {
1388                    let entry = global_refs.entry(name.clone()).or_default();
1389                    for reference in refs {
1390                        entry.push(Location { uri: reference.uri.clone(), range: reference.range });
1391                    }
1392                }
1393            }
1394        }
1395
1396        Ok(())
1397    }
1398
1399    /// Remove a file from the index
1400    ///
1401    /// # Arguments
1402    ///
1403    /// * `uri` - File URI (string form) to remove
1404    ///
1405    /// # Returns
1406    ///
1407    /// Nothing. The index is updated in-place.
1408    ///
1409    /// # Examples
1410    ///
1411    /// ```rust,ignore
1412    /// use perl_parser::workspace_index::WorkspaceIndex;
1413    ///
1414    /// let index = WorkspaceIndex::new();
1415    /// index.remove_file("file:///example.pl");
1416    /// ```
1417    pub fn remove_file(&self, uri: &str) {
1418        let uri_str = Self::normalize_uri(uri);
1419        let key = DocumentStore::uri_key(&uri_str);
1420
1421        // Remove from document store
1422        self.document_store.close(&uri_str);
1423
1424        // Remove file index
1425        let mut files = self.files.write();
1426        if let Some(file_index) = files.remove(&key) {
1427            // Incrementally remove symbols and re-insert any shadowed names.
1428            let mut symbols = self.symbols.write();
1429            Self::incremental_remove_symbols(&files, &mut symbols, &file_index);
1430
1431            // Remove from global reference index
1432            let mut global_refs = self.global_references.write();
1433            Self::remove_file_global_refs(&mut global_refs, &file_index, &uri_str);
1434        }
1435    }
1436
1437    /// Remove a file from the index (URL variant for compatibility)
1438    ///
1439    /// # Arguments
1440    ///
1441    /// * `uri` - File URI as a parsed `Url`
1442    ///
1443    /// # Returns
1444    ///
1445    /// Nothing. The index is updated in-place.
1446    ///
1447    /// # Examples
1448    ///
1449    /// ```rust,ignore
1450    /// use perl_parser::workspace_index::WorkspaceIndex;
1451    /// use url::Url;
1452    ///
1453    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
1454    /// let index = WorkspaceIndex::new();
1455    /// let uri = Url::parse("file:///example.pl")?;
1456    /// index.remove_file_url(&uri);
1457    /// # Ok(())
1458    /// # }
1459    /// ```
1460    pub fn remove_file_url(&self, uri: &Url) {
1461        self.remove_file(uri.as_str())
1462    }
1463
1464    /// Clear a file from the index (alias for remove_file)
1465    ///
1466    /// # Arguments
1467    ///
1468    /// * `uri` - File URI (string form) to remove
1469    ///
1470    /// # Returns
1471    ///
1472    /// Nothing. The index is updated in-place.
1473    ///
1474    /// # Examples
1475    ///
1476    /// ```rust,ignore
1477    /// use perl_parser::workspace_index::WorkspaceIndex;
1478    ///
1479    /// let index = WorkspaceIndex::new();
1480    /// index.clear_file("file:///example.pl");
1481    /// ```
1482    pub fn clear_file(&self, uri: &str) {
1483        self.remove_file(uri);
1484    }
1485
1486    /// Clear a file from the index (URL variant for compatibility)
1487    ///
1488    /// # Arguments
1489    ///
1490    /// * `uri` - File URI as a parsed `Url`
1491    ///
1492    /// # Returns
1493    ///
1494    /// Nothing. The index is updated in-place.
1495    ///
1496    /// # Examples
1497    ///
1498    /// ```rust,ignore
1499    /// use perl_parser::workspace_index::WorkspaceIndex;
1500    /// use url::Url;
1501    ///
1502    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
1503    /// let index = WorkspaceIndex::new();
1504    /// let uri = Url::parse("file:///example.pl")?;
1505    /// index.clear_file_url(&uri);
1506    /// # Ok(())
1507    /// # }
1508    /// ```
1509    pub fn clear_file_url(&self, uri: &Url) {
1510        self.clear_file(uri.as_str())
1511    }
1512
1513    #[cfg(not(target_arch = "wasm32"))]
1514    /// Index a file from a URI string for the Index/Analyze workflow.
1515    ///
1516    /// Accepts either a `file://` URI or a filesystem path. Not available on
1517    /// wasm32 targets (requires filesystem path conversion).
1518    ///
1519    /// # Arguments
1520    ///
1521    /// * `uri` - File URI string or filesystem path.
1522    /// * `text` - Full Perl source text for indexing.
1523    ///
1524    /// # Returns
1525    ///
1526    /// `Ok(())` when indexing succeeds, or an error message otherwise.
1527    ///
1528    /// # Errors
1529    ///
1530    /// Returns an error if the URI is invalid or parsing fails.
1531    ///
1532    /// # Examples
1533    ///
1534    /// ```rust,ignore
1535    /// use perl_parser::workspace_index::WorkspaceIndex;
1536    ///
1537    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
1538    /// let index = WorkspaceIndex::new();
1539    /// index.index_file_str("file:///example.pl", "sub hello { }")?;
1540    /// # Ok(())
1541    /// # }
1542    /// ```
1543    pub fn index_file_str(&self, uri: &str, text: &str) -> Result<(), String> {
1544        let path = Path::new(uri);
1545        let url = if path.is_absolute() {
1546            url::Url::from_file_path(path)
1547                .map_err(|_| format!("Invalid URI or file path: {}", uri))?
1548        } else {
1549            // Raw absolute Windows paths like C:\foo can parse as a bogus URI
1550            // (`c:` scheme). Prefer URL parsing only for non-path inputs.
1551            url::Url::parse(uri).or_else(|_| {
1552                url::Url::from_file_path(path)
1553                    .map_err(|_| format!("Invalid URI or file path: {}", uri))
1554            })?
1555        };
1556        self.index_file(url, text.to_string())
1557    }
1558
1559    /// Index multiple files in a single batch operation.
1560    ///
1561    /// This is significantly faster than calling `index_file` in a loop for
1562    /// initial workspace scans because it defers the global symbol cache
1563    /// rebuild to a single pass at the end.
1564    ///
1565    /// Phase 1: Parse all files without holding locks.
1566    /// Phase 2: Bulk-insert file indices and rebuild the symbol cache once.
1567    pub fn index_files_batch(&self, files_to_index: Vec<(Url, String)>) -> Vec<String> {
1568        let mut errors = Vec::new();
1569
1570        // Phase 1: Parse all files without locks
1571        let mut parsed: Vec<(String, String, FileIndex)> = Vec::with_capacity(files_to_index.len());
1572        for (uri, text) in &files_to_index {
1573            let uri_str = uri.to_string();
1574
1575            // Content hash for early-exit
1576            let mut hasher = DefaultHasher::new();
1577            text.hash(&mut hasher);
1578            let content_hash = hasher.finish();
1579
1580            let key = DocumentStore::uri_key(&uri_str);
1581
1582            // Check if content unchanged
1583            {
1584                let files = self.files.read();
1585                if let Some(existing) = files.get(&key) {
1586                    if existing.content_hash == content_hash {
1587                        continue;
1588                    }
1589                }
1590            }
1591
1592            // Update document store
1593            if self.document_store.is_open(&uri_str) {
1594                self.document_store.update(&uri_str, 1, text.clone());
1595            } else {
1596                self.document_store.open(uri_str.clone(), 1, text.clone());
1597            }
1598
1599            // Parse
1600            let mut parser = Parser::new(text);
1601            let ast = match parser.parse() {
1602                Ok(ast) => ast,
1603                Err(e) => {
1604                    errors.push(format!("Parse error in {}: {}", uri_str, e));
1605                    continue;
1606                }
1607            };
1608
1609            let mut doc = match self.document_store.get(&uri_str) {
1610                Some(d) => d,
1611                None => {
1612                    errors.push(format!("Document not found: {}", uri_str));
1613                    continue;
1614                }
1615            };
1616
1617            let mut file_index = FileIndex { content_hash, ..Default::default() };
1618            let mut visitor = IndexVisitor::new(&mut doc, uri_str.clone());
1619            visitor.visit(&ast, &mut file_index);
1620
1621            parsed.push((key, uri_str, file_index));
1622        }
1623
1624        // Phase 2: Bulk insert with single cache rebuild
1625        {
1626            let mut files = self.files.write();
1627            let mut symbols = self.symbols.write();
1628            let mut global_refs = self.global_references.write();
1629
1630            // Pre-allocate capacity for the incoming batch to avoid rehashing.
1631            // Each symbol is indexed under both its qualified name and bare name.
1632            files.reserve(parsed.len());
1633            symbols.reserve(parsed.len().saturating_mul(20).saturating_mul(2));
1634
1635            for (key, uri_str, file_index) in parsed {
1636                // Remove stale global references
1637                if let Some(old_index) = files.get(&key) {
1638                    Self::remove_file_global_refs(&mut global_refs, old_index, &uri_str);
1639                }
1640
1641                files.insert(key.clone(), file_index);
1642
1643                // Add global references for this file
1644                if let Some(fi) = files.get(&key) {
1645                    for (name, refs) in &fi.references {
1646                        let entry = global_refs.entry(name.clone()).or_default();
1647                        for reference in refs {
1648                            entry.push(Location {
1649                                uri: reference.uri.clone(),
1650                                range: reference.range,
1651                            });
1652                        }
1653                    }
1654                }
1655            }
1656
1657            // Single rebuild at the end
1658            Self::rebuild_symbol_cache(&files, &mut symbols);
1659        }
1660
1661        errors
1662    }
1663
1664    /// Find all references to a symbol using dual indexing strategy
1665    ///
1666    /// This function searches for both exact matches and bare name matches when
1667    /// the symbol is qualified. For example, when searching for "Utils::process_data":
1668    /// - First searches for exact "Utils::process_data" references
1669    /// - Then searches for bare "process_data" references that might refer to the same function
1670    ///
1671    /// This dual approach handles cases where functions are called both as:
1672    /// - Qualified: `Utils::process_data()`
1673    /// - Unqualified: `process_data()` (when in the same package or imported)
1674    ///
1675    /// # Arguments
1676    ///
1677    /// * `symbol_name` - Symbol name or qualified name to search
1678    ///
1679    /// # Returns
1680    ///
1681    /// All reference locations found for the requested symbol.
1682    ///
1683    /// # Examples
1684    ///
1685    /// ```rust,ignore
1686    /// use perl_parser::workspace_index::WorkspaceIndex;
1687    ///
1688    /// let index = WorkspaceIndex::new();
1689    /// let _refs = index.find_references("Utils::process_data");
1690    /// ```
1691    pub fn find_references(&self, symbol_name: &str) -> Vec<Location> {
1692        let global_refs = self.global_references.read();
1693        let mut seen: HashSet<(String, u32, u32, u32, u32)> = HashSet::new();
1694        let mut locations = Vec::new();
1695
1696        // O(1) lookup for exact symbol name
1697        if let Some(refs) = global_refs.get(symbol_name) {
1698            for loc in refs {
1699                let key = (
1700                    loc.uri.clone(),
1701                    loc.range.start.line,
1702                    loc.range.start.column,
1703                    loc.range.end.line,
1704                    loc.range.end.column,
1705                );
1706                if seen.insert(key) {
1707                    locations.push(Location { uri: loc.uri.clone(), range: loc.range });
1708                }
1709            }
1710        }
1711
1712        // If the symbol is qualified, also collect bare name references
1713        if let Some(idx) = symbol_name.rfind("::") {
1714            let bare_name = &symbol_name[idx + 2..];
1715            if let Some(refs) = global_refs.get(bare_name) {
1716                for loc in refs {
1717                    let key = (
1718                        loc.uri.clone(),
1719                        loc.range.start.line,
1720                        loc.range.start.column,
1721                        loc.range.end.line,
1722                        loc.range.end.column,
1723                    );
1724                    if seen.insert(key) {
1725                        locations.push(Location { uri: loc.uri.clone(), range: loc.range });
1726                    }
1727                }
1728            }
1729        }
1730
1731        locations
1732    }
1733
1734    /// Count non-definition references (usages) of a symbol.
1735    ///
1736    /// Like `find_references` but excludes `ReferenceKind::Definition` entries,
1737    /// returning only actual usage sites. This is used by code lens to show
1738    /// "N references" where N means call sites, not the definition itself.
1739    pub fn count_usages(&self, symbol_name: &str) -> usize {
1740        let files = self.files.read();
1741        let mut seen: HashSet<(String, u32, u32, u32, u32)> = HashSet::new();
1742
1743        for (_uri_key, file_index) in files.iter() {
1744            if let Some(refs) = file_index.references.get(symbol_name) {
1745                for r in refs.iter().filter(|r| r.kind != ReferenceKind::Definition) {
1746                    seen.insert((
1747                        r.uri.clone(),
1748                        r.range.start.line,
1749                        r.range.start.column,
1750                        r.range.end.line,
1751                        r.range.end.column,
1752                    ));
1753                }
1754            }
1755
1756            if let Some(idx) = symbol_name.rfind("::") {
1757                let bare_name = &symbol_name[idx + 2..];
1758                if let Some(refs) = file_index.references.get(bare_name) {
1759                    for r in refs.iter().filter(|r| r.kind != ReferenceKind::Definition) {
1760                        seen.insert((
1761                            r.uri.clone(),
1762                            r.range.start.line,
1763                            r.range.start.column,
1764                            r.range.end.line,
1765                            r.range.end.column,
1766                        ));
1767                    }
1768                }
1769            }
1770        }
1771
1772        seen.len()
1773    }
1774
1775    /// Find the definition of a symbol
1776    ///
1777    /// # Arguments
1778    ///
1779    /// * `symbol_name` - Symbol name or qualified name to resolve
1780    ///
1781    /// # Returns
1782    ///
1783    /// The first matching definition location, if found.
1784    ///
1785    /// # Examples
1786    ///
1787    /// ```rust,ignore
1788    /// use perl_parser::workspace_index::WorkspaceIndex;
1789    ///
1790    /// let index = WorkspaceIndex::new();
1791    /// let _def = index.find_definition("MyPackage::example");
1792    /// ```
1793    pub fn find_definition(&self, symbol_name: &str) -> Option<Location> {
1794        let cached_uri = {
1795            let symbols = self.symbols.read();
1796            symbols.get(symbol_name).cloned()
1797        };
1798
1799        let files = self.files.read();
1800        if let Some(ref uri_str) = cached_uri
1801            && let Some((location, _uri)) =
1802                Self::find_definition_in_files(&files, symbol_name, Some(uri_str))
1803        {
1804            return Some(location);
1805        }
1806
1807        let resolved = Self::find_definition_in_files(&files, symbol_name, None);
1808        drop(files);
1809
1810        if let Some((location, uri)) = resolved {
1811            let mut symbols = self.symbols.write();
1812            symbols.insert(symbol_name.to_string(), uri);
1813            return Some(location);
1814        }
1815
1816        None
1817    }
1818
1819    /// Get all symbols in the workspace
1820    ///
1821    /// # Returns
1822    ///
1823    /// A vector containing every symbol currently indexed.
1824    ///
1825    /// # Examples
1826    ///
1827    /// ```rust,ignore
1828    /// use perl_parser::workspace_index::WorkspaceIndex;
1829    ///
1830    /// let index = WorkspaceIndex::new();
1831    /// let _symbols = index.all_symbols();
1832    /// ```
1833    pub fn all_symbols(&self) -> Vec<WorkspaceSymbol> {
1834        let files = self.files.read();
1835        let mut symbols = Vec::new();
1836
1837        for (_uri_key, file_index) in files.iter() {
1838            symbols.extend(file_index.symbols.clone());
1839        }
1840
1841        symbols
1842    }
1843
1844    /// Clear all indexed files and symbols from the workspace.
1845    pub fn clear(&self) {
1846        self.files.write().clear();
1847        self.symbols.write().clear();
1848        self.global_references.write().clear();
1849    }
1850
1851    /// Return the number of indexed files in the workspace
1852    pub fn file_count(&self) -> usize {
1853        let files = self.files.read();
1854        files.len()
1855    }
1856
1857    /// Return the total number of symbols across all indexed files
1858    pub fn symbol_count(&self) -> usize {
1859        let files = self.files.read();
1860        files.values().map(|file_index| file_index.symbols.len()).sum()
1861    }
1862
1863    /// Capture a point-in-time memory estimate of the index.
1864    ///
1865    /// Acquires read locks on all index components and walks their contents
1866    /// to estimate heap usage. Intended for offline profiling; do not call
1867    /// on the LSP hot path.
1868    ///
1869    /// Only available when the `memory-profiling` feature is enabled.
1870    #[cfg(feature = "memory-profiling")]
1871    pub fn memory_snapshot(&self) -> crate::workspace::memory::MemorySnapshot {
1872        use std::mem::size_of;
1873
1874        let files_guard = self.files.read();
1875        let symbols_guard = self.symbols.read();
1876        let global_refs_guard = self.global_references.read();
1877
1878        // --- files map ---
1879        let mut files_bytes: usize = 0;
1880        let mut total_symbol_count: usize = 0;
1881        for (uri_key, fi) in files_guard.iter() {
1882            // key string
1883            files_bytes += uri_key.len();
1884            // per-symbol entries
1885            for sym in &fi.symbols {
1886                files_bytes += sym.name.len()
1887                    + sym.uri.len()
1888                    + sym.qualified_name.as_deref().map_or(0, str::len)
1889                    + sym.documentation.as_deref().map_or(0, str::len)
1890                    + sym.container_name.as_deref().map_or(0, str::len)
1891                    // stack portion: kind + range + has_body + option discriminants
1892                    + size_of::<WorkspaceSymbol>();
1893            }
1894            total_symbol_count += fi.symbols.len();
1895            // per-reference entries
1896            for (ref_name, refs) in &fi.references {
1897                files_bytes += ref_name.len();
1898                for r in refs {
1899                    files_bytes += r.uri.len() + size_of::<SymbolReference>();
1900                }
1901            }
1902            // dependencies
1903            for dep in &fi.dependencies {
1904                files_bytes += dep.len();
1905            }
1906            // content hash (u64) + vec/hashset capacity overhead (rough)
1907            files_bytes += size_of::<u64>();
1908        }
1909
1910        // --- global symbols map ---
1911        let mut symbols_bytes: usize = 0;
1912        for (qname, uri) in symbols_guard.iter() {
1913            symbols_bytes += qname.len() + uri.len();
1914        }
1915
1916        // --- global references map ---
1917        let mut global_refs_bytes: usize = 0;
1918        for (sym_name, locs) in global_refs_guard.iter() {
1919            global_refs_bytes += sym_name.len();
1920            for loc in locs {
1921                global_refs_bytes += loc.uri.len() + size_of::<Location>();
1922            }
1923        }
1924
1925        // --- document store ---
1926        let document_store_bytes = self.document_store.total_text_bytes();
1927
1928        crate::workspace::memory::MemorySnapshot {
1929            file_count: files_guard.len(),
1930            symbol_count: total_symbol_count,
1931            files_bytes,
1932            symbols_bytes,
1933            global_refs_bytes,
1934            document_store_bytes,
1935        }
1936    }
1937
1938    /// Check if the workspace index has symbols (soft readiness check)
1939    ///
1940    /// Returns true if the index contains any symbols, indicating that
1941    /// at least some files have been indexed and the workspace is ready
1942    /// for symbol-based operations like completion.
1943    ///
1944    /// # Returns
1945    ///
1946    /// `true` if any symbols are indexed, otherwise `false`.
1947    ///
1948    /// # Examples
1949    ///
1950    /// ```rust,ignore
1951    /// use perl_parser::workspace_index::WorkspaceIndex;
1952    ///
1953    /// let index = WorkspaceIndex::new();
1954    /// assert!(!index.has_symbols());
1955    /// ```
1956    pub fn has_symbols(&self) -> bool {
1957        let files = self.files.read();
1958        files.values().any(|file_index| !file_index.symbols.is_empty())
1959    }
1960
1961    /// Search for symbols by query
1962    ///
1963    /// # Arguments
1964    ///
1965    /// * `query` - Substring to match against symbol names
1966    ///
1967    /// # Returns
1968    ///
1969    /// Symbols whose names or qualified names contain the query string.
1970    ///
1971    /// # Examples
1972    ///
1973    /// ```rust,ignore
1974    /// use perl_parser::workspace_index::WorkspaceIndex;
1975    ///
1976    /// let index = WorkspaceIndex::new();
1977    /// let _results = index.search_symbols("example");
1978    /// ```
1979    pub fn search_symbols(&self, query: &str) -> Vec<WorkspaceSymbol> {
1980        let query_lower = query.to_lowercase();
1981        let files = self.files.read();
1982        let mut results = Vec::new();
1983        for file_index in files.values() {
1984            for symbol in &file_index.symbols {
1985                if symbol.name.to_lowercase().contains(&query_lower)
1986                    || symbol
1987                        .qualified_name
1988                        .as_ref()
1989                        .map(|qn| qn.to_lowercase().contains(&query_lower))
1990                        .unwrap_or(false)
1991                {
1992                    results.push(symbol.clone());
1993                }
1994            }
1995        }
1996        results
1997    }
1998
1999    /// Find symbols by query (alias for search_symbols for compatibility)
2000    ///
2001    /// # Arguments
2002    ///
2003    /// * `query` - Substring to match against symbol names
2004    ///
2005    /// # Returns
2006    ///
2007    /// Symbols whose names or qualified names contain the query string.
2008    ///
2009    /// # Examples
2010    ///
2011    /// ```rust,ignore
2012    /// use perl_parser::workspace_index::WorkspaceIndex;
2013    ///
2014    /// let index = WorkspaceIndex::new();
2015    /// let _results = index.find_symbols("example");
2016    /// ```
2017    pub fn find_symbols(&self, query: &str) -> Vec<WorkspaceSymbol> {
2018        self.search_symbols(query)
2019    }
2020
2021    /// Get symbols in a specific file
2022    ///
2023    /// # Arguments
2024    ///
2025    /// * `uri` - File URI to inspect
2026    ///
2027    /// # Returns
2028    ///
2029    /// All symbols indexed for the requested file.
2030    ///
2031    /// # Examples
2032    ///
2033    /// ```rust,ignore
2034    /// use perl_parser::workspace_index::WorkspaceIndex;
2035    ///
2036    /// let index = WorkspaceIndex::new();
2037    /// let _symbols = index.file_symbols("file:///example.pl");
2038    /// ```
2039    pub fn file_symbols(&self, uri: &str) -> Vec<WorkspaceSymbol> {
2040        let normalized_uri = Self::normalize_uri(uri);
2041        let key = DocumentStore::uri_key(&normalized_uri);
2042        let files = self.files.read();
2043
2044        files.get(&key).map(|fi| fi.symbols.clone()).unwrap_or_default()
2045    }
2046
2047    /// Get dependencies of a file
2048    ///
2049    /// # Arguments
2050    ///
2051    /// * `uri` - File URI to inspect
2052    ///
2053    /// # Returns
2054    ///
2055    /// A set of module names imported by the file.
2056    ///
2057    /// # Examples
2058    ///
2059    /// ```rust,ignore
2060    /// use perl_parser::workspace_index::WorkspaceIndex;
2061    ///
2062    /// let index = WorkspaceIndex::new();
2063    /// let _deps = index.file_dependencies("file:///example.pl");
2064    /// ```
2065    pub fn file_dependencies(&self, uri: &str) -> HashSet<String> {
2066        let normalized_uri = Self::normalize_uri(uri);
2067        let key = DocumentStore::uri_key(&normalized_uri);
2068        let files = self.files.read();
2069
2070        files.get(&key).map(|fi| fi.dependencies.clone()).unwrap_or_default()
2071    }
2072
2073    /// Find all files that depend on a module
2074    ///
2075    /// # Arguments
2076    ///
2077    /// * `module_name` - Module name to search for in file dependencies
2078    ///
2079    /// # Returns
2080    ///
2081    /// A list of file URIs that import or depend on the module.
2082    ///
2083    /// # Examples
2084    ///
2085    /// ```rust,ignore
2086    /// use perl_parser::workspace_index::WorkspaceIndex;
2087    ///
2088    /// let index = WorkspaceIndex::new();
2089    /// let _files = index.find_dependents("My::Module");
2090    /// ```
2091    pub fn find_dependents(&self, module_name: &str) -> Vec<String> {
2092        let files = self.files.read();
2093        let mut dependents = Vec::new();
2094
2095        for (uri_key, file_index) in files.iter() {
2096            if file_index.dependencies.contains(module_name) {
2097                dependents.push(uri_key.clone());
2098            }
2099        }
2100
2101        dependents
2102    }
2103
2104    /// Get the document store
2105    ///
2106    /// # Returns
2107    ///
2108    /// A reference to the in-memory document store.
2109    ///
2110    /// # Examples
2111    ///
2112    /// ```rust,ignore
2113    /// use perl_parser::workspace_index::WorkspaceIndex;
2114    ///
2115    /// let index = WorkspaceIndex::new();
2116    /// let _store = index.document_store();
2117    /// ```
2118    pub fn document_store(&self) -> &DocumentStore {
2119        &self.document_store
2120    }
2121
2122    /// Find unused symbols in the workspace
2123    ///
2124    /// # Returns
2125    ///
2126    /// Symbols that have no non-definition references in the workspace.
2127    ///
2128    /// # Examples
2129    ///
2130    /// ```rust,ignore
2131    /// use perl_parser::workspace_index::WorkspaceIndex;
2132    ///
2133    /// let index = WorkspaceIndex::new();
2134    /// let _unused = index.find_unused_symbols();
2135    /// ```
2136    pub fn find_unused_symbols(&self) -> Vec<WorkspaceSymbol> {
2137        let files = self.files.read();
2138        let mut unused = Vec::new();
2139
2140        // Collect all defined symbols
2141        for (_uri_key, file_index) in files.iter() {
2142            for symbol in &file_index.symbols {
2143                // Check if this symbol has any references beyond its definition
2144                let has_usage = files.values().any(|fi| {
2145                    if let Some(refs) = fi.references.get(&symbol.name) {
2146                        refs.iter().any(|r| r.kind != ReferenceKind::Definition)
2147                    } else {
2148                        false
2149                    }
2150                });
2151
2152                if !has_usage {
2153                    unused.push(symbol.clone());
2154                }
2155            }
2156        }
2157
2158        unused
2159    }
2160
2161    /// Get all symbols that belong to a specific package
2162    ///
2163    /// # Arguments
2164    ///
2165    /// * `package_name` - Package name to match (e.g., `My::Package`)
2166    ///
2167    /// # Returns
2168    ///
2169    /// Symbols defined within the requested package.
2170    ///
2171    /// # Examples
2172    ///
2173    /// ```rust,ignore
2174    /// use perl_parser::workspace_index::WorkspaceIndex;
2175    ///
2176    /// let index = WorkspaceIndex::new();
2177    /// let _members = index.get_package_members("My::Package");
2178    /// ```
2179    pub fn get_package_members(&self, package_name: &str) -> Vec<WorkspaceSymbol> {
2180        let files = self.files.read();
2181        let mut members = Vec::new();
2182
2183        for (_uri_key, file_index) in files.iter() {
2184            for symbol in &file_index.symbols {
2185                // Check if symbol belongs to this package
2186                if let Some(ref container) = symbol.container_name {
2187                    if container == package_name {
2188                        members.push(symbol.clone());
2189                    }
2190                }
2191                // Also check qualified names
2192                if let Some(ref qname) = symbol.qualified_name {
2193                    if qname.starts_with(&format!("{}::", package_name)) {
2194                        // Avoid duplicates - only add if not already in via container_name
2195                        if symbol.container_name.as_deref() != Some(package_name) {
2196                            members.push(symbol.clone());
2197                        }
2198                    }
2199                }
2200            }
2201        }
2202
2203        members
2204    }
2205
2206    /// Find the definition location for a symbol key during Index/Navigate stages.
2207    ///
2208    /// # Arguments
2209    ///
2210    /// * `key` - Normalized symbol key to resolve.
2211    ///
2212    /// # Returns
2213    ///
2214    /// The definition location for the symbol, if found.
2215    ///
2216    /// # Examples
2217    ///
2218    /// ```rust,ignore
2219    /// use perl_parser::workspace_index::{SymKind, SymbolKey, WorkspaceIndex};
2220    /// use std::sync::Arc;
2221    ///
2222    /// let index = WorkspaceIndex::new();
2223    /// let key = SymbolKey { pkg: Arc::from("My::Package"), name: Arc::from("example"), sigil: None, kind: SymKind::Sub };
2224    /// let _def = index.find_def(&key);
2225    /// ```
2226    pub fn find_def(&self, key: &SymbolKey) -> Option<Location> {
2227        if let Some(sigil) = key.sigil {
2228            // It's a variable
2229            let var_name = format!("{}{}", sigil, key.name);
2230            self.find_definition(&var_name)
2231        } else if key.kind == SymKind::Pack {
2232            // It's a package lookup (e.g., from `use Module::Name`)
2233            // Search for the package declaration by name
2234            self.find_definition(key.pkg.as_ref())
2235                .or_else(|| self.find_definition(key.name.as_ref()))
2236        } else {
2237            // It's a subroutine or package
2238            let qualified_name = format!("{}::{}", key.pkg, key.name);
2239            self.find_definition(&qualified_name)
2240        }
2241    }
2242
2243    /// Find reference locations for a symbol key using dual indexing.
2244    ///
2245    /// Searches both qualified and bare names to support Navigate/Analyze workflows.
2246    ///
2247    /// # Arguments
2248    ///
2249    /// * `key` - Normalized symbol key to search for.
2250    ///
2251    /// # Returns
2252    ///
2253    /// All reference locations for the symbol, excluding the definition.
2254    ///
2255    /// # Examples
2256    ///
2257    /// ```rust,ignore
2258    /// use perl_parser::workspace_index::{SymKind, SymbolKey, WorkspaceIndex};
2259    /// use std::sync::Arc;
2260    ///
2261    /// let index = WorkspaceIndex::new();
2262    /// let key = SymbolKey { pkg: Arc::from("main"), name: Arc::from("example"), sigil: None, kind: SymKind::Sub };
2263    /// let _refs = index.find_refs(&key);
2264    /// ```
2265    pub fn find_refs(&self, key: &SymbolKey) -> Vec<Location> {
2266        let files_locked = self.files.read();
2267        let mut all_refs = if let Some(sigil) = key.sigil {
2268            // It's a variable - search through all files for this variable name
2269            let var_name = format!("{}{}", sigil, key.name);
2270            let mut refs = Vec::new();
2271            for (_uri_key, file_index) in files_locked.iter() {
2272                if let Some(var_refs) = file_index.references.get(&var_name) {
2273                    for reference in var_refs {
2274                        refs.push(Location { uri: reference.uri.clone(), range: reference.range });
2275                    }
2276                }
2277            }
2278            refs
2279        } else {
2280            // It's a subroutine or package
2281            if key.pkg.as_ref() == "main" {
2282                // For main package, we search for both "main::foo" and bare "foo"
2283                let mut refs = self.find_references(&format!("main::{}", key.name));
2284                // Add bare name references
2285                for (_uri_key, file_index) in files_locked.iter() {
2286                    if let Some(bare_refs) = file_index.references.get(key.name.as_ref()) {
2287                        for reference in bare_refs {
2288                            refs.push(Location {
2289                                uri: reference.uri.clone(),
2290                                range: reference.range,
2291                            });
2292                        }
2293                    }
2294                }
2295                refs
2296            } else {
2297                let qualified_name = format!("{}::{}", key.pkg, key.name);
2298                self.find_references(&qualified_name)
2299            }
2300        };
2301        drop(files_locked);
2302
2303        // Remove the definition; the caller will include it separately if needed
2304        if let Some(def) = self.find_def(key) {
2305            all_refs.retain(|loc| !(loc.uri == def.uri && loc.range == def.range));
2306        }
2307
2308        // Deduplicate by URI and range
2309        let mut seen = HashSet::new();
2310        all_refs.retain(|loc| {
2311            seen.insert((
2312                loc.uri.clone(),
2313                loc.range.start.line,
2314                loc.range.start.column,
2315                loc.range.end.line,
2316                loc.range.end.column,
2317            ))
2318        });
2319
2320        all_refs
2321    }
2322}
2323
2324/// AST visitor for extracting symbols and references
2325struct IndexVisitor {
2326    document: Document,
2327    uri: String,
2328    current_package: Option<String>,
2329}
2330
2331fn is_interpolated_var_start(byte: u8) -> bool {
2332    byte.is_ascii_alphabetic() || byte == b'_'
2333}
2334
2335fn is_interpolated_var_continue(byte: u8) -> bool {
2336    byte.is_ascii_alphanumeric() || byte == b'_' || byte == b':'
2337}
2338
2339fn has_escaped_interpolation_marker(bytes: &[u8], index: usize) -> bool {
2340    if index == 0 {
2341        return false;
2342    }
2343
2344    let mut backslashes = 0usize;
2345    let mut cursor = index;
2346    while cursor > 0 && bytes[cursor - 1] == b'\\' {
2347        backslashes += 1;
2348        cursor -= 1;
2349    }
2350
2351    backslashes % 2 == 1
2352}
2353
2354fn strip_matching_quote_delimiters(raw_content: &str) -> &str {
2355    if raw_content.len() < 2 {
2356        return raw_content;
2357    }
2358
2359    let bytes = raw_content.as_bytes();
2360    match (bytes.first(), bytes.last()) {
2361        (Some(b'"'), Some(b'"')) | (Some(b'\''), Some(b'\'')) => {
2362            &raw_content[1..raw_content.len() - 1]
2363        }
2364        _ => raw_content,
2365    }
2366}
2367
2368impl IndexVisitor {
2369    fn new(document: &mut Document, uri: String) -> Self {
2370        Self { document: document.clone(), uri, current_package: Some("main".to_string()) }
2371    }
2372
2373    fn visit(&mut self, node: &Node, file_index: &mut FileIndex) {
2374        self.visit_node(node, file_index);
2375    }
2376
2377    fn record_interpolated_variable_references(
2378        &self,
2379        raw_content: &str,
2380        range: Range,
2381        file_index: &mut FileIndex,
2382    ) {
2383        let content = strip_matching_quote_delimiters(raw_content);
2384        let bytes = content.as_bytes();
2385        let mut index = 0;
2386
2387        while index < bytes.len() {
2388            if has_escaped_interpolation_marker(bytes, index) {
2389                index += 1;
2390                continue;
2391            }
2392
2393            let sigil = match bytes[index] {
2394                b'$' => "$",
2395                b'@' => "@",
2396                _ => {
2397                    index += 1;
2398                    continue;
2399                }
2400            };
2401
2402            if index + 1 >= bytes.len() {
2403                break;
2404            }
2405
2406            let (start, needs_closing_brace) =
2407                if bytes[index + 1] == b'{' { (index + 2, true) } else { (index + 1, false) };
2408
2409            if start >= bytes.len() || !is_interpolated_var_start(bytes[start]) {
2410                index += 1;
2411                continue;
2412            }
2413
2414            let mut end = start + 1;
2415            while end < bytes.len() && is_interpolated_var_continue(bytes[end]) {
2416                end += 1;
2417            }
2418
2419            if needs_closing_brace && (end >= bytes.len() || bytes[end] != b'}') {
2420                index += 1;
2421                continue;
2422            }
2423
2424            if let Some(name) = content.get(start..end) {
2425                let var_name = format!("{sigil}{name}");
2426                file_index.references.entry(var_name).or_default().push(SymbolReference {
2427                    uri: self.uri.clone(),
2428                    range,
2429                    kind: ReferenceKind::Read,
2430                });
2431            }
2432
2433            index = if needs_closing_brace { end + 1 } else { end };
2434        }
2435    }
2436
2437    fn visit_node(&mut self, node: &Node, file_index: &mut FileIndex) {
2438        match &node.kind {
2439            NodeKind::Package { name, .. } => {
2440                let package_name = name.clone();
2441
2442                // Update the current package (replaces the previous one, not a stack)
2443                self.current_package = Some(package_name.clone());
2444
2445                file_index.symbols.push(WorkspaceSymbol {
2446                    name: package_name.clone(),
2447                    kind: SymbolKind::Package,
2448                    uri: self.uri.clone(),
2449                    range: self.node_to_range(node),
2450                    qualified_name: Some(package_name),
2451                    documentation: None,
2452                    container_name: None,
2453                    has_body: true,
2454                });
2455            }
2456
2457            NodeKind::Subroutine { name, body, .. } => {
2458                if let Some(name_str) = name.clone() {
2459                    let qualified_name = if let Some(ref pkg) = self.current_package {
2460                        format!("{}::{}", pkg, name_str)
2461                    } else {
2462                        name_str.clone()
2463                    };
2464
2465                    // Check if this is a forward declaration or update to existing symbol
2466                    let existing_symbol_idx = file_index.symbols.iter().position(|s| {
2467                        s.name == name_str && s.container_name == self.current_package
2468                    });
2469
2470                    if let Some(idx) = existing_symbol_idx {
2471                        // Update existing forward declaration with body
2472                        file_index.symbols[idx].range = self.node_to_range(node);
2473                    } else {
2474                        // New symbol
2475                        file_index.symbols.push(WorkspaceSymbol {
2476                            name: name_str.clone(),
2477                            kind: SymbolKind::Subroutine,
2478                            uri: self.uri.clone(),
2479                            range: self.node_to_range(node),
2480                            qualified_name: Some(qualified_name),
2481                            documentation: None,
2482                            container_name: self.current_package.clone(),
2483                            has_body: true, // Subroutine node always has body
2484                        });
2485                    }
2486
2487                    // Mark as definition
2488                    file_index.references.entry(name_str.clone()).or_default().push(
2489                        SymbolReference {
2490                            uri: self.uri.clone(),
2491                            range: self.node_to_range(node),
2492                            kind: ReferenceKind::Definition,
2493                        },
2494                    );
2495                }
2496
2497                // Visit body
2498                self.visit_node(body, file_index);
2499            }
2500
2501            NodeKind::VariableDeclaration { variable, initializer, .. } => {
2502                if let NodeKind::Variable { sigil, name } = &variable.kind {
2503                    let var_name = format!("{}{}", sigil, name);
2504
2505                    file_index.symbols.push(WorkspaceSymbol {
2506                        name: var_name.clone(),
2507                        kind: SymbolKind::Variable(sigil_to_var_kind(sigil)),
2508                        uri: self.uri.clone(),
2509                        range: self.node_to_range(variable),
2510                        qualified_name: None,
2511                        documentation: None,
2512                        container_name: self.current_package.clone(),
2513                        has_body: true, // Variables always have body
2514                    });
2515
2516                    // Mark as definition
2517                    file_index.references.entry(var_name.clone()).or_default().push(
2518                        SymbolReference {
2519                            uri: self.uri.clone(),
2520                            range: self.node_to_range(variable),
2521                            kind: ReferenceKind::Definition,
2522                        },
2523                    );
2524                }
2525
2526                // Visit initializer
2527                if let Some(init) = initializer {
2528                    self.visit_node(init, file_index);
2529                }
2530            }
2531
2532            NodeKind::VariableListDeclaration { variables, initializer, .. } => {
2533                // Handle each variable in the list declaration
2534                for var in variables {
2535                    if let NodeKind::Variable { sigil, name } = &var.kind {
2536                        let var_name = format!("{}{}", sigil, name);
2537
2538                        file_index.symbols.push(WorkspaceSymbol {
2539                            name: var_name.clone(),
2540                            kind: SymbolKind::Variable(sigil_to_var_kind(sigil)),
2541                            uri: self.uri.clone(),
2542                            range: self.node_to_range(var),
2543                            qualified_name: None,
2544                            documentation: None,
2545                            container_name: self.current_package.clone(),
2546                            has_body: true,
2547                        });
2548
2549                        // Mark as definition
2550                        file_index.references.entry(var_name).or_default().push(SymbolReference {
2551                            uri: self.uri.clone(),
2552                            range: self.node_to_range(var),
2553                            kind: ReferenceKind::Definition,
2554                        });
2555                    }
2556                }
2557
2558                // Visit the initializer
2559                if let Some(init) = initializer {
2560                    self.visit_node(init, file_index);
2561                }
2562            }
2563
2564            NodeKind::Variable { sigil, name } => {
2565                let var_name = format!("{}{}", sigil, name);
2566
2567                // Track as usage (could be read or write based on context)
2568                file_index.references.entry(var_name).or_default().push(SymbolReference {
2569                    uri: self.uri.clone(),
2570                    range: self.node_to_range(node),
2571                    kind: ReferenceKind::Read, // Default to read, would need context for write
2572                });
2573            }
2574
2575            NodeKind::FunctionCall { name, args, .. } => {
2576                let func_name = name.clone();
2577                let location = self.node_to_range(node);
2578
2579                // Determine package and bare name
2580                let (pkg, bare_name) = if let Some(idx) = func_name.rfind("::") {
2581                    (&func_name[..idx], &func_name[idx + 2..])
2582                } else {
2583                    (self.current_package.as_deref().unwrap_or("main"), func_name.as_str())
2584                };
2585
2586                let qualified = format!("{}::{}", pkg, bare_name);
2587
2588                // Track as usage for both qualified and bare forms
2589                // This dual indexing allows finding references whether the function is called
2590                // as `process_data()` or `Utils::process_data()`
2591                file_index.references.entry(bare_name.to_string()).or_default().push(
2592                    SymbolReference {
2593                        uri: self.uri.clone(),
2594                        range: location,
2595                        kind: ReferenceKind::Usage,
2596                    },
2597                );
2598                file_index.references.entry(qualified).or_default().push(SymbolReference {
2599                    uri: self.uri.clone(),
2600                    range: location,
2601                    kind: ReferenceKind::Usage,
2602                });
2603
2604                // Visit arguments
2605                for arg in args {
2606                    self.visit_node(arg, file_index);
2607                }
2608            }
2609
2610            NodeKind::Use { module, args, .. } => {
2611                let module_name = module.clone();
2612                file_index.dependencies.insert(module_name.clone());
2613
2614                // Also track actual parent/base class names for dependency discovery.
2615                // `use parent 'Foo::Bar'` stores module="parent" and args=["'Foo::Bar'"],
2616                // so find_dependents("Foo::Bar") would miss files with only use parent.
2617                if module == "parent" || module == "base" {
2618                    for name in extract_module_names_from_use_args(args) {
2619                        file_index.dependencies.insert(name);
2620                    }
2621                }
2622
2623                // Track as import
2624                file_index.references.entry(module_name).or_default().push(SymbolReference {
2625                    uri: self.uri.clone(),
2626                    range: self.node_to_range(node),
2627                    kind: ReferenceKind::Import,
2628                });
2629            }
2630
2631            // Handle assignment to detect writes
2632            NodeKind::Assignment { lhs, rhs, op } => {
2633                // For compound assignments (+=, -=, .=, etc.), the LHS is both read and written
2634                let is_compound = op != "=";
2635
2636                if let NodeKind::Variable { sigil, name } = &lhs.kind {
2637                    let var_name = format!("{}{}", sigil, name);
2638
2639                    // For compound assignments, it's a read first
2640                    if is_compound {
2641                        file_index.references.entry(var_name.clone()).or_default().push(
2642                            SymbolReference {
2643                                uri: self.uri.clone(),
2644                                range: self.node_to_range(lhs),
2645                                kind: ReferenceKind::Read,
2646                            },
2647                        );
2648                    }
2649
2650                    // Then it's always a write
2651                    file_index.references.entry(var_name).or_default().push(SymbolReference {
2652                        uri: self.uri.clone(),
2653                        range: self.node_to_range(lhs),
2654                        kind: ReferenceKind::Write,
2655                    });
2656                }
2657
2658                // Right side could have reads
2659                self.visit_node(rhs, file_index);
2660            }
2661
2662            // Recursively visit child nodes
2663            NodeKind::Block { statements } => {
2664                for stmt in statements {
2665                    self.visit_node(stmt, file_index);
2666                }
2667            }
2668
2669            NodeKind::If { condition, then_branch, elsif_branches, else_branch } => {
2670                self.visit_node(condition, file_index);
2671                self.visit_node(then_branch, file_index);
2672                for (cond, branch) in elsif_branches {
2673                    self.visit_node(cond, file_index);
2674                    self.visit_node(branch, file_index);
2675                }
2676                if let Some(else_br) = else_branch {
2677                    self.visit_node(else_br, file_index);
2678                }
2679            }
2680
2681            NodeKind::While { condition, body, continue_block } => {
2682                self.visit_node(condition, file_index);
2683                self.visit_node(body, file_index);
2684                if let Some(cont) = continue_block {
2685                    self.visit_node(cont, file_index);
2686                }
2687            }
2688
2689            NodeKind::For { init, condition, update, body, continue_block } => {
2690                if let Some(i) = init {
2691                    self.visit_node(i, file_index);
2692                }
2693                if let Some(c) = condition {
2694                    self.visit_node(c, file_index);
2695                }
2696                if let Some(u) = update {
2697                    self.visit_node(u, file_index);
2698                }
2699                self.visit_node(body, file_index);
2700                if let Some(cont) = continue_block {
2701                    self.visit_node(cont, file_index);
2702                }
2703            }
2704
2705            NodeKind::Foreach { variable, list, body, continue_block } => {
2706                // Iterator is a write context
2707                if let Some(cb) = continue_block {
2708                    self.visit_node(cb, file_index);
2709                }
2710                if let NodeKind::Variable { sigil, name } = &variable.kind {
2711                    let var_name = format!("{}{}", sigil, name);
2712                    file_index.references.entry(var_name).or_default().push(SymbolReference {
2713                        uri: self.uri.clone(),
2714                        range: self.node_to_range(variable),
2715                        kind: ReferenceKind::Write,
2716                    });
2717                }
2718                self.visit_node(variable, file_index);
2719                self.visit_node(list, file_index);
2720                self.visit_node(body, file_index);
2721            }
2722
2723            NodeKind::MethodCall { object, method, args } => {
2724                // Check if this is a static method call (Package->method)
2725                let qualified_method = if let NodeKind::Identifier { name } = &object.kind {
2726                    // Static method call: Package->method
2727                    Some(format!("{}::{}", name, method))
2728                } else {
2729                    // Instance method call: $obj->method
2730                    None
2731                };
2732
2733                // Object is a read context
2734                self.visit_node(object, file_index);
2735
2736                // Track method call with qualified name if applicable
2737                let method_key = qualified_method.as_ref().unwrap_or(method);
2738                file_index.references.entry(method_key.clone()).or_default().push(
2739                    SymbolReference {
2740                        uri: self.uri.clone(),
2741                        range: self.node_to_range(node),
2742                        kind: ReferenceKind::Usage,
2743                    },
2744                );
2745
2746                // Visit arguments
2747                for arg in args {
2748                    self.visit_node(arg, file_index);
2749                }
2750            }
2751
2752            NodeKind::No { module, .. } => {
2753                let module_name = module.clone();
2754                file_index.dependencies.insert(module_name.clone());
2755            }
2756
2757            NodeKind::Class { name, .. } => {
2758                let class_name = name.clone();
2759                self.current_package = Some(class_name.clone());
2760
2761                file_index.symbols.push(WorkspaceSymbol {
2762                    name: class_name.clone(),
2763                    kind: SymbolKind::Class,
2764                    uri: self.uri.clone(),
2765                    range: self.node_to_range(node),
2766                    qualified_name: Some(class_name),
2767                    documentation: None,
2768                    container_name: None,
2769                    has_body: true,
2770                });
2771            }
2772
2773            NodeKind::Method { name, body, signature, .. } => {
2774                let method_name = name.clone();
2775                let qualified_name = if let Some(ref pkg) = self.current_package {
2776                    format!("{}::{}", pkg, method_name)
2777                } else {
2778                    method_name.clone()
2779                };
2780
2781                file_index.symbols.push(WorkspaceSymbol {
2782                    name: method_name.clone(),
2783                    kind: SymbolKind::Method,
2784                    uri: self.uri.clone(),
2785                    range: self.node_to_range(node),
2786                    qualified_name: Some(qualified_name),
2787                    documentation: None,
2788                    container_name: self.current_package.clone(),
2789                    has_body: true,
2790                });
2791
2792                // Visit params
2793                if let Some(sig) = signature {
2794                    if let NodeKind::Signature { parameters } = &sig.kind {
2795                        for param in parameters {
2796                            self.visit_node(param, file_index);
2797                        }
2798                    }
2799                }
2800
2801                // Visit body
2802                self.visit_node(body, file_index);
2803            }
2804
2805            NodeKind::String { value, interpolated } => {
2806                if *interpolated {
2807                    let range = self.node_to_range(node);
2808                    self.record_interpolated_variable_references(value, range, file_index);
2809                }
2810            }
2811
2812            NodeKind::Heredoc { content, interpolated, .. } => {
2813                if *interpolated {
2814                    let range = self.node_to_range(node);
2815                    self.record_interpolated_variable_references(content, range, file_index);
2816                }
2817            }
2818
2819            // Handle special assignments (++ and --)
2820            NodeKind::Unary { op, operand } if op == "++" || op == "--" => {
2821                // Pre/post increment/decrement are both read and write
2822                if let NodeKind::Variable { sigil, name } = &operand.kind {
2823                    let var_name = format!("{}{}", sigil, name);
2824
2825                    // It's both a read and a write
2826                    file_index.references.entry(var_name.clone()).or_default().push(
2827                        SymbolReference {
2828                            uri: self.uri.clone(),
2829                            range: self.node_to_range(operand),
2830                            kind: ReferenceKind::Read,
2831                        },
2832                    );
2833
2834                    file_index.references.entry(var_name).or_default().push(SymbolReference {
2835                        uri: self.uri.clone(),
2836                        range: self.node_to_range(operand),
2837                        kind: ReferenceKind::Write,
2838                    });
2839                }
2840            }
2841
2842            _ => {
2843                // For other node types, just visit children
2844                self.visit_children(node, file_index);
2845            }
2846        }
2847    }
2848
2849    fn visit_children(&mut self, node: &Node, file_index: &mut FileIndex) {
2850        // Generic visitor for unhandled node types - visit all nested nodes
2851        match &node.kind {
2852            NodeKind::Program { statements } => {
2853                for stmt in statements {
2854                    self.visit_node(stmt, file_index);
2855                }
2856            }
2857            NodeKind::ExpressionStatement { expression } => {
2858                self.visit_node(expression, file_index);
2859            }
2860            // Expression nodes
2861            NodeKind::Unary { operand, .. } => {
2862                self.visit_node(operand, file_index);
2863            }
2864            NodeKind::Binary { left, right, .. } => {
2865                self.visit_node(left, file_index);
2866                self.visit_node(right, file_index);
2867            }
2868            NodeKind::Ternary { condition, then_expr, else_expr } => {
2869                self.visit_node(condition, file_index);
2870                self.visit_node(then_expr, file_index);
2871                self.visit_node(else_expr, file_index);
2872            }
2873            NodeKind::ArrayLiteral { elements } => {
2874                for elem in elements {
2875                    self.visit_node(elem, file_index);
2876                }
2877            }
2878            NodeKind::HashLiteral { pairs } => {
2879                for (key, value) in pairs {
2880                    self.visit_node(key, file_index);
2881                    self.visit_node(value, file_index);
2882                }
2883            }
2884            NodeKind::Return { value } => {
2885                if let Some(val) = value {
2886                    self.visit_node(val, file_index);
2887                }
2888            }
2889            NodeKind::Eval { block } | NodeKind::Do { block } => {
2890                self.visit_node(block, file_index);
2891            }
2892            NodeKind::Try { body, catch_blocks, finally_block } => {
2893                self.visit_node(body, file_index);
2894                for (_, block) in catch_blocks {
2895                    self.visit_node(block, file_index);
2896                }
2897                if let Some(finally) = finally_block {
2898                    self.visit_node(finally, file_index);
2899                }
2900            }
2901            NodeKind::Given { expr, body } => {
2902                self.visit_node(expr, file_index);
2903                self.visit_node(body, file_index);
2904            }
2905            NodeKind::When { condition, body } => {
2906                self.visit_node(condition, file_index);
2907                self.visit_node(body, file_index);
2908            }
2909            NodeKind::Default { body } => {
2910                self.visit_node(body, file_index);
2911            }
2912            NodeKind::StatementModifier { statement, condition, .. } => {
2913                self.visit_node(statement, file_index);
2914                self.visit_node(condition, file_index);
2915            }
2916            NodeKind::VariableWithAttributes { variable, .. } => {
2917                self.visit_node(variable, file_index);
2918            }
2919            NodeKind::LabeledStatement { statement, .. } => {
2920                self.visit_node(statement, file_index);
2921            }
2922            _ => {
2923                // For other node types, no children to visit
2924            }
2925        }
2926    }
2927
2928    fn node_to_range(&mut self, node: &Node) -> Range {
2929        // LineIndex.range returns line numbers and UTF-16 code unit columns
2930        let ((start_line, start_col), (end_line, end_col)) =
2931            self.document.line_index.range(node.location.start, node.location.end);
2932        // Use byte offsets from node.location directly
2933        Range {
2934            start: Position { byte: node.location.start, line: start_line, column: start_col },
2935            end: Position { byte: node.location.end, line: end_line, column: end_col },
2936        }
2937    }
2938}
2939
2940/// Extract bare module names from the argument list of a `use parent` / `use base` statement.
2941///
2942/// The `args` field of `NodeKind::Use` stores raw argument strings as the parser captured them.
2943/// For `use parent 'Foo::Bar'` this is `["'Foo::Bar'"]`.
2944/// For `use parent qw(Foo::Bar Other::Base)` this is `["qw(Foo::Bar Other::Base)"]`.
2945/// For `use parent -norequire, 'Foo::Bar'` this is `["-norequire", "'Foo::Bar'"]`.
2946///
2947/// Returns the module names with surrounding quotes/qw wrappers stripped.
2948/// Tokens starting with `-` or not matching `[\w::']+` are silently skipped.
2949fn extract_module_names_from_use_args(args: &[String]) -> Vec<String> {
2950    let joined = args.join(" ");
2951
2952    // Strip qw(...) wrapper and collect the inner tokens
2953    let inner = if let Some(start) = joined.find("qw(") {
2954        if let Some(end) = joined[start..].find(')') {
2955            joined[start + 3..start + end].to_string()
2956        } else {
2957            joined.clone()
2958        }
2959    } else {
2960        joined.clone()
2961    };
2962
2963    inner
2964        .split_whitespace()
2965        .filter_map(|token| {
2966            // Skip flags like -norequire and bare punctuation from qw() or parens
2967            if token.starts_with('-') {
2968                return None;
2969            }
2970            // Strip surrounding single or double quotes
2971            let stripped = token.trim_matches('\'').trim_matches('"');
2972            // Strip surrounding parentheses (e.g. `use parent ('Foo')`)
2973            let stripped = stripped.trim_matches('(').trim_matches(')');
2974            let stripped = stripped.trim_matches('\'').trim_matches('"');
2975            // Accept tokens containing only word characters, `::`, or `'` (legacy separator)
2976            if stripped.is_empty() {
2977                return None;
2978            }
2979            if stripped.chars().all(|c| c.is_alphanumeric() || c == '_' || c == ':' || c == '\'') {
2980                Some(stripped.to_string())
2981            } else {
2982                None
2983            }
2984        })
2985        .collect()
2986}
2987
2988impl Default for WorkspaceIndex {
2989    fn default() -> Self {
2990        Self::new()
2991    }
2992}
2993
2994/// LSP adapter for converting internal Location types to LSP types
2995#[cfg(all(feature = "workspace", feature = "lsp-compat"))]
2996/// LSP adapter utilities for Navigate/Analyze workflows.
2997pub mod lsp_adapter {
2998    use super::Location as IxLocation;
2999    use lsp_types::Location as LspLocation;
3000    // lsp_types uses Uri, not Url
3001    type LspUrl = lsp_types::Uri;
3002
3003    /// Convert an internal location to an LSP Location for Navigate workflows.
3004    ///
3005    /// # Arguments
3006    ///
3007    /// * `ix` - Internal index location with URI and range information.
3008    ///
3009    /// # Returns
3010    ///
3011    /// `Some(LspLocation)` when conversion succeeds, or `None` if URI parsing fails.
3012    ///
3013    /// # Examples
3014    ///
3015    /// ```rust,ignore
3016    /// use perl_parser::workspace_index::{Location as IxLocation, lsp_adapter::to_lsp_location};
3017    /// use lsp_types::Range;
3018    ///
3019    /// let ix_loc = IxLocation { uri: "file:///path.pl".to_string(), range: Range::default() };
3020    /// let _ = to_lsp_location(&ix_loc);
3021    /// ```
3022    pub fn to_lsp_location(ix: &IxLocation) -> Option<LspLocation> {
3023        parse_url(&ix.uri).map(|uri| {
3024            let start =
3025                lsp_types::Position { line: ix.range.start.line, character: ix.range.start.column };
3026            let end =
3027                lsp_types::Position { line: ix.range.end.line, character: ix.range.end.column };
3028            let range = lsp_types::Range { start, end };
3029            LspLocation { uri, range }
3030        })
3031    }
3032
3033    /// Convert multiple index locations to LSP Locations for Navigate/Analyze workflows.
3034    ///
3035    /// # Arguments
3036    ///
3037    /// * `all` - Iterator of internal index locations to convert.
3038    ///
3039    /// # Returns
3040    ///
3041    /// Vector of successfully converted LSP locations, with invalid entries filtered out.
3042    ///
3043    /// # Examples
3044    ///
3045    /// ```rust,ignore
3046    /// use perl_parser::workspace_index::{Location as IxLocation, lsp_adapter::to_lsp_locations};
3047    /// use lsp_types::Range;
3048    ///
3049    /// let locations = vec![IxLocation { uri: "file:///script1.pl".to_string(), range: Range::default() }];
3050    /// let lsp_locations = to_lsp_locations(locations);
3051    /// assert_eq!(lsp_locations.len(), 1);
3052    /// ```
3053    pub fn to_lsp_locations(all: impl IntoIterator<Item = IxLocation>) -> Vec<LspLocation> {
3054        all.into_iter().filter_map(|ix| to_lsp_location(&ix)).collect()
3055    }
3056
3057    #[cfg(not(target_arch = "wasm32"))]
3058    fn parse_url(s: &str) -> Option<LspUrl> {
3059        // lsp_types::Uri uses FromStr, not TryFrom
3060        use std::str::FromStr;
3061
3062        // Try parsing as URI first
3063        LspUrl::from_str(s).ok().or_else(|| {
3064            // Try as a file path if URI parsing fails
3065            std::path::Path::new(s).canonicalize().ok().and_then(|p| {
3066                // Use proper URI construction with percent-encoding
3067                crate::workspace_index::fs_path_to_uri(&p)
3068                    .ok()
3069                    .and_then(|uri_string| LspUrl::from_str(&uri_string).ok())
3070            })
3071        })
3072    }
3073
3074    /// Parse a string as a URL (wasm32 version - no filesystem fallback)
3075    #[cfg(target_arch = "wasm32")]
3076    fn parse_url(s: &str) -> Option<LspUrl> {
3077        use std::str::FromStr;
3078        LspUrl::from_str(s).ok()
3079    }
3080}
3081
3082#[cfg(test)]
3083mod tests {
3084    use super::*;
3085    use perl_tdd_support::{must, must_some};
3086
3087    #[test]
3088    fn test_basic_indexing() {
3089        let index = WorkspaceIndex::new();
3090        let uri = "file:///test.pl";
3091
3092        let code = r#"
3093package MyPackage;
3094
3095sub hello {
3096    print "Hello";
3097}
3098
3099my $var = 42;
3100"#;
3101
3102        must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
3103
3104        // Should have indexed the package and subroutine
3105        let symbols = index.file_symbols(uri);
3106        assert!(symbols.iter().any(|s| s.name == "MyPackage" && s.kind == SymbolKind::Package));
3107        assert!(symbols.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
3108        assert!(symbols.iter().any(|s| s.name == "$var" && s.kind.is_variable()));
3109    }
3110
3111    #[test]
3112    fn test_find_references() {
3113        let index = WorkspaceIndex::new();
3114        let uri = "file:///test.pl";
3115
3116        let code = r#"
3117sub test {
3118    my $x = 1;
3119    $x = 2;
3120    print $x;
3121}
3122"#;
3123
3124        must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
3125
3126        let refs = index.find_references("$x");
3127        assert!(refs.len() >= 2); // Definition + at least one usage
3128    }
3129
3130    #[test]
3131    fn test_dependencies() {
3132        let index = WorkspaceIndex::new();
3133        let uri = "file:///test.pl";
3134
3135        let code = r#"
3136use strict;
3137use warnings;
3138use Data::Dumper;
3139"#;
3140
3141        must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
3142
3143        let deps = index.file_dependencies(uri);
3144        assert!(deps.contains("strict"));
3145        assert!(deps.contains("warnings"));
3146        assert!(deps.contains("Data::Dumper"));
3147    }
3148
3149    #[test]
3150    fn test_uri_to_fs_path_basic() {
3151        // Test basic file:// URI conversion
3152        if let Some(path) = uri_to_fs_path("file:///tmp/test.pl") {
3153            assert_eq!(path, std::path::PathBuf::from("/tmp/test.pl"));
3154        }
3155
3156        // Test with invalid URI
3157        assert!(uri_to_fs_path("not-a-uri").is_none());
3158
3159        // Test with non-file scheme
3160        assert!(uri_to_fs_path("http://example.com").is_none());
3161    }
3162
3163    #[test]
3164    fn test_uri_to_fs_path_with_spaces() {
3165        // Test with percent-encoded spaces
3166        if let Some(path) = uri_to_fs_path("file:///tmp/path%20with%20spaces/test.pl") {
3167            assert_eq!(path, std::path::PathBuf::from("/tmp/path with spaces/test.pl"));
3168        }
3169
3170        // Test with multiple spaces and special characters
3171        if let Some(path) = uri_to_fs_path("file:///tmp/My%20Documents/test%20file.pl") {
3172            assert_eq!(path, std::path::PathBuf::from("/tmp/My Documents/test file.pl"));
3173        }
3174    }
3175
3176    #[test]
3177    fn test_uri_to_fs_path_with_unicode() {
3178        // Test with Unicode characters (percent-encoded)
3179        if let Some(path) = uri_to_fs_path("file:///tmp/caf%C3%A9/test.pl") {
3180            assert_eq!(path, std::path::PathBuf::from("/tmp/café/test.pl"));
3181        }
3182
3183        // Test with Unicode emoji (percent-encoded)
3184        if let Some(path) = uri_to_fs_path("file:///tmp/emoji%F0%9F%98%80/test.pl") {
3185            assert_eq!(path, std::path::PathBuf::from("/tmp/emoji😀/test.pl"));
3186        }
3187    }
3188
3189    #[test]
3190    fn test_fs_path_to_uri_basic() {
3191        // Test basic path to URI conversion
3192        let result = fs_path_to_uri("/tmp/test.pl");
3193        assert!(result.is_ok());
3194        let uri = must(result);
3195        assert!(uri.starts_with("file://"));
3196        assert!(uri.contains("/tmp/test.pl"));
3197    }
3198
3199    #[test]
3200    fn test_fs_path_to_uri_with_spaces() {
3201        // Test path with spaces
3202        let result = fs_path_to_uri("/tmp/path with spaces/test.pl");
3203        assert!(result.is_ok());
3204        let uri = must(result);
3205        assert!(uri.starts_with("file://"));
3206        // Should contain percent-encoded spaces
3207        assert!(uri.contains("path%20with%20spaces"));
3208    }
3209
3210    #[test]
3211    fn test_fs_path_to_uri_with_unicode() {
3212        // Test path with Unicode characters
3213        let result = fs_path_to_uri("/tmp/café/test.pl");
3214        assert!(result.is_ok());
3215        let uri = must(result);
3216        assert!(uri.starts_with("file://"));
3217        // Should contain percent-encoded Unicode
3218        assert!(uri.contains("caf%C3%A9"));
3219    }
3220
3221    #[test]
3222    fn test_normalize_uri_file_schemes() {
3223        // Test normalization of valid file URIs
3224        let uri = WorkspaceIndex::normalize_uri("file:///tmp/test.pl");
3225        assert_eq!(uri, "file:///tmp/test.pl");
3226
3227        // Test normalization of URIs with spaces
3228        let uri = WorkspaceIndex::normalize_uri("file:///tmp/path%20with%20spaces/test.pl");
3229        assert_eq!(uri, "file:///tmp/path%20with%20spaces/test.pl");
3230    }
3231
3232    #[test]
3233    fn test_normalize_uri_absolute_paths() {
3234        // Test normalization of absolute paths (convert to file:// URI)
3235        let uri = WorkspaceIndex::normalize_uri("/tmp/test.pl");
3236        assert!(uri.starts_with("file://"));
3237        assert!(uri.contains("/tmp/test.pl"));
3238    }
3239
3240    #[test]
3241    fn test_normalize_uri_special_schemes() {
3242        // Test that special schemes like untitled: are preserved
3243        let uri = WorkspaceIndex::normalize_uri("untitled:Untitled-1");
3244        assert_eq!(uri, "untitled:Untitled-1");
3245    }
3246
3247    #[test]
3248    fn test_roundtrip_conversion() {
3249        // Test that URI -> path -> URI conversion preserves the URI
3250        let original_uri = "file:///tmp/path%20with%20spaces/caf%C3%A9.pl";
3251
3252        if let Some(path) = uri_to_fs_path(original_uri) {
3253            if let Ok(converted_uri) = fs_path_to_uri(&path) {
3254                // Should be able to round-trip back to an equivalent URI
3255                assert!(converted_uri.starts_with("file://"));
3256
3257                // The path component should decode correctly
3258                if let Some(roundtrip_path) = uri_to_fs_path(&converted_uri) {
3259                    #[cfg(windows)]
3260                    if let Ok(rootless) = path.strip_prefix(std::path::Path::new(r"\")) {
3261                        assert!(roundtrip_path.ends_with(rootless));
3262                    } else {
3263                        assert_eq!(path, roundtrip_path);
3264                    }
3265
3266                    #[cfg(not(windows))]
3267                    assert_eq!(path, roundtrip_path);
3268                }
3269            }
3270        }
3271    }
3272
3273    #[cfg(target_os = "windows")]
3274    #[test]
3275    fn test_windows_paths() {
3276        // Test Windows-style paths
3277        let result = fs_path_to_uri(r"C:\Users\test\Documents\script.pl");
3278        assert!(result.is_ok());
3279        let uri = must(result);
3280        assert!(uri.starts_with("file://"));
3281
3282        // Test Windows path with spaces
3283        let result = fs_path_to_uri(r"C:\Program Files\My App\script.pl");
3284        assert!(result.is_ok());
3285        let uri = must(result);
3286        assert!(uri.starts_with("file://"));
3287        assert!(uri.contains("Program%20Files"));
3288    }
3289
3290    // ========================================================================
3291    // IndexCoordinator Tests
3292    // ========================================================================
3293
3294    #[test]
3295    fn test_coordinator_initial_state() {
3296        let coordinator = IndexCoordinator::new();
3297        assert!(matches!(
3298            coordinator.state(),
3299            IndexState::Building { phase: IndexPhase::Idle, .. }
3300        ));
3301    }
3302
3303    #[test]
3304    fn test_transition_to_scanning_phase() {
3305        let coordinator = IndexCoordinator::new();
3306        coordinator.transition_to_scanning();
3307
3308        let state = coordinator.state();
3309        assert!(
3310            matches!(state, IndexState::Building { phase: IndexPhase::Scanning, .. }),
3311            "Expected Building state after scanning, got: {:?}",
3312            state
3313        );
3314    }
3315
3316    #[test]
3317    fn test_transition_to_indexing_phase() {
3318        let coordinator = IndexCoordinator::new();
3319        coordinator.transition_to_scanning();
3320        coordinator.update_scan_progress(3);
3321        coordinator.transition_to_indexing(3);
3322
3323        let state = coordinator.state();
3324        assert!(
3325            matches!(
3326                state,
3327                IndexState::Building { phase: IndexPhase::Indexing, total_count: 3, .. }
3328            ),
3329            "Expected Building state after indexing with total_count 3, got: {:?}",
3330            state
3331        );
3332    }
3333
3334    #[test]
3335    fn test_transition_to_ready() {
3336        let coordinator = IndexCoordinator::new();
3337        coordinator.transition_to_ready(100, 5000);
3338
3339        let state = coordinator.state();
3340        if let IndexState::Ready { file_count, symbol_count, .. } = state {
3341            assert_eq!(file_count, 100);
3342            assert_eq!(symbol_count, 5000);
3343        } else {
3344            unreachable!("Expected Ready state, got: {:?}", state);
3345        }
3346    }
3347
3348    #[test]
3349    fn test_parse_storm_degradation() {
3350        let coordinator = IndexCoordinator::new();
3351        coordinator.transition_to_ready(100, 5000);
3352
3353        // Trigger parse storm
3354        for _ in 0..15 {
3355            coordinator.notify_change("file.pm");
3356        }
3357
3358        let state = coordinator.state();
3359        assert!(
3360            matches!(state, IndexState::Degraded { .. }),
3361            "Expected Degraded state, got: {:?}",
3362            state
3363        );
3364        if let IndexState::Degraded { reason, .. } = state {
3365            assert!(matches!(reason, DegradationReason::ParseStorm { .. }));
3366        }
3367    }
3368
3369    #[test]
3370    fn test_recovery_from_parse_storm() {
3371        let coordinator = IndexCoordinator::new();
3372        coordinator.transition_to_ready(100, 5000);
3373
3374        // Trigger parse storm
3375        for _ in 0..15 {
3376            coordinator.notify_change("file.pm");
3377        }
3378
3379        // Complete all parses
3380        for _ in 0..15 {
3381            coordinator.notify_parse_complete("file.pm");
3382        }
3383
3384        // Should recover to Building state
3385        assert!(matches!(coordinator.state(), IndexState::Building { .. }));
3386    }
3387
3388    #[test]
3389    fn test_query_dispatch_ready() {
3390        let coordinator = IndexCoordinator::new();
3391        coordinator.transition_to_ready(100, 5000);
3392
3393        let result = coordinator.query(|_index| "full_query", |_index| "partial_query");
3394
3395        assert_eq!(result, "full_query");
3396    }
3397
3398    #[test]
3399    fn test_query_dispatch_degraded() {
3400        let coordinator = IndexCoordinator::new();
3401        // Building state should use partial query
3402
3403        let result = coordinator.query(|_index| "full_query", |_index| "partial_query");
3404
3405        assert_eq!(result, "partial_query");
3406    }
3407
3408    #[test]
3409    fn test_metrics_pending_count() {
3410        let coordinator = IndexCoordinator::new();
3411
3412        coordinator.notify_change("file1.pm");
3413        coordinator.notify_change("file2.pm");
3414
3415        assert_eq!(coordinator.metrics.pending_count(), 2);
3416
3417        coordinator.notify_parse_complete("file1.pm");
3418        assert_eq!(coordinator.metrics.pending_count(), 1);
3419    }
3420
3421    #[test]
3422    fn test_instrumentation_records_transitions() {
3423        let coordinator = IndexCoordinator::new();
3424        coordinator.transition_to_ready(10, 100);
3425
3426        let snapshot = coordinator.instrumentation_snapshot();
3427        let transition =
3428            IndexStateTransition { from: IndexStateKind::Building, to: IndexStateKind::Ready };
3429        let count = snapshot.state_transition_counts.get(&transition).copied().unwrap_or(0);
3430        assert_eq!(count, 1);
3431    }
3432
3433    #[test]
3434    fn test_instrumentation_records_early_exit() {
3435        let coordinator = IndexCoordinator::new();
3436        coordinator.record_early_exit(EarlyExitReason::InitialTimeBudget, 25, 1, 10);
3437
3438        let snapshot = coordinator.instrumentation_snapshot();
3439        let count = snapshot
3440            .early_exit_counts
3441            .get(&EarlyExitReason::InitialTimeBudget)
3442            .copied()
3443            .unwrap_or(0);
3444        assert_eq!(count, 1);
3445        assert!(snapshot.last_early_exit.is_some());
3446    }
3447
3448    #[test]
3449    fn test_custom_limits() {
3450        let limits = IndexResourceLimits {
3451            max_files: 5000,
3452            max_symbols_per_file: 1000,
3453            max_total_symbols: 100_000,
3454            max_ast_cache_bytes: 128 * 1024 * 1024,
3455            max_ast_cache_items: 50,
3456            max_scan_duration_ms: 30_000,
3457        };
3458
3459        let coordinator = IndexCoordinator::with_limits(limits.clone());
3460        assert_eq!(coordinator.limits.max_files, 5000);
3461        assert_eq!(coordinator.limits.max_total_symbols, 100_000);
3462    }
3463
3464    #[test]
3465    fn test_degradation_preserves_symbol_count() {
3466        let coordinator = IndexCoordinator::new();
3467        coordinator.transition_to_ready(100, 5000);
3468
3469        coordinator.transition_to_degraded(DegradationReason::IoError {
3470            message: "Test error".to_string(),
3471        });
3472
3473        let state = coordinator.state();
3474        assert!(
3475            matches!(state, IndexState::Degraded { .. }),
3476            "Expected Degraded state, got: {:?}",
3477            state
3478        );
3479        if let IndexState::Degraded { available_symbols, .. } = state {
3480            assert_eq!(available_symbols, 5000);
3481        }
3482    }
3483
3484    #[test]
3485    fn test_index_access() {
3486        let coordinator = IndexCoordinator::new();
3487        let index = coordinator.index();
3488
3489        // Should have access to underlying WorkspaceIndex
3490        assert!(index.all_symbols().is_empty());
3491    }
3492
3493    #[test]
3494    fn test_resource_limit_enforcement_max_files() {
3495        let limits = IndexResourceLimits {
3496            max_files: 5,
3497            max_symbols_per_file: 1000,
3498            max_total_symbols: 50_000,
3499            max_ast_cache_bytes: 128 * 1024 * 1024,
3500            max_ast_cache_items: 50,
3501            max_scan_duration_ms: 30_000,
3502        };
3503
3504        let coordinator = IndexCoordinator::with_limits(limits);
3505        coordinator.transition_to_ready(10, 100);
3506
3507        // Index 10 files (exceeds limit of 5)
3508        for i in 0..10 {
3509            let uri_str = format!("file:///test{}.pl", i);
3510            let uri = must(url::Url::parse(&uri_str));
3511            let code = "sub test { }";
3512            must(coordinator.index().index_file(uri, code.to_string()));
3513        }
3514
3515        // Enforce limits
3516        coordinator.enforce_limits();
3517
3518        let state = coordinator.state();
3519        assert!(
3520            matches!(
3521                state,
3522                IndexState::Degraded {
3523                    reason: DegradationReason::ResourceLimit { kind: ResourceKind::MaxFiles },
3524                    ..
3525                }
3526            ),
3527            "Expected Degraded state with ResourceLimit(MaxFiles), got: {:?}",
3528            state
3529        );
3530    }
3531
3532    #[test]
3533    fn test_resource_limit_enforcement_max_symbols() {
3534        let limits = IndexResourceLimits {
3535            max_files: 100,
3536            max_symbols_per_file: 10,
3537            max_total_symbols: 50, // Very low limit for testing
3538            max_ast_cache_bytes: 128 * 1024 * 1024,
3539            max_ast_cache_items: 50,
3540            max_scan_duration_ms: 30_000,
3541        };
3542
3543        let coordinator = IndexCoordinator::with_limits(limits);
3544        coordinator.transition_to_ready(0, 0);
3545
3546        // Index files with many symbols to exceed total symbol limit
3547        for i in 0..10 {
3548            let uri_str = format!("file:///test{}.pl", i);
3549            let uri = must(url::Url::parse(&uri_str));
3550            // Each file has 10 subroutines = 100 total symbols (exceeds limit of 50)
3551            let code = r#"
3552package Test;
3553sub sub1 { }
3554sub sub2 { }
3555sub sub3 { }
3556sub sub4 { }
3557sub sub5 { }
3558sub sub6 { }
3559sub sub7 { }
3560sub sub8 { }
3561sub sub9 { }
3562sub sub10 { }
3563"#;
3564            must(coordinator.index().index_file(uri, code.to_string()));
3565        }
3566
3567        // Enforce limits
3568        coordinator.enforce_limits();
3569
3570        let state = coordinator.state();
3571        assert!(
3572            matches!(
3573                state,
3574                IndexState::Degraded {
3575                    reason: DegradationReason::ResourceLimit { kind: ResourceKind::MaxSymbols },
3576                    ..
3577                }
3578            ),
3579            "Expected Degraded state with ResourceLimit(MaxSymbols), got: {:?}",
3580            state
3581        );
3582    }
3583
3584    #[test]
3585    fn test_check_limits_returns_none_within_bounds() {
3586        let coordinator = IndexCoordinator::new();
3587        coordinator.transition_to_ready(0, 0);
3588
3589        // Index a few files well within default limits
3590        for i in 0..5 {
3591            let uri_str = format!("file:///test{}.pl", i);
3592            let uri = must(url::Url::parse(&uri_str));
3593            let code = "sub test { }";
3594            must(coordinator.index().index_file(uri, code.to_string()));
3595        }
3596
3597        // Should not trigger degradation
3598        let limit_check = coordinator.check_limits();
3599        assert!(limit_check.is_none(), "check_limits should return None when within bounds");
3600
3601        // State should still be Ready
3602        assert!(
3603            matches!(coordinator.state(), IndexState::Ready { .. }),
3604            "State should remain Ready when within limits"
3605        );
3606    }
3607
3608    #[test]
3609    fn test_enforce_limits_called_on_transition_to_ready() {
3610        let limits = IndexResourceLimits {
3611            max_files: 3,
3612            max_symbols_per_file: 1000,
3613            max_total_symbols: 50_000,
3614            max_ast_cache_bytes: 128 * 1024 * 1024,
3615            max_ast_cache_items: 50,
3616            max_scan_duration_ms: 30_000,
3617        };
3618
3619        let coordinator = IndexCoordinator::with_limits(limits);
3620
3621        // Index files before transitioning to ready
3622        for i in 0..5 {
3623            let uri_str = format!("file:///test{}.pl", i);
3624            let uri = must(url::Url::parse(&uri_str));
3625            let code = "sub test { }";
3626            must(coordinator.index().index_file(uri, code.to_string()));
3627        }
3628
3629        // Transition to ready - should automatically enforce limits
3630        coordinator.transition_to_ready(5, 100);
3631
3632        let state = coordinator.state();
3633        assert!(
3634            matches!(
3635                state,
3636                IndexState::Degraded {
3637                    reason: DegradationReason::ResourceLimit { kind: ResourceKind::MaxFiles },
3638                    ..
3639                }
3640            ),
3641            "Expected Degraded state after transition_to_ready with exceeded limits, got: {:?}",
3642            state
3643        );
3644    }
3645
3646    #[test]
3647    fn test_state_transition_guard_ready_to_ready() {
3648        // Test that Ready → Ready is allowed (metrics update)
3649        let coordinator = IndexCoordinator::new();
3650        coordinator.transition_to_ready(100, 5000);
3651
3652        // Transition to Ready again with different metrics
3653        coordinator.transition_to_ready(150, 7500);
3654
3655        let state = coordinator.state();
3656        assert!(
3657            matches!(state, IndexState::Ready { file_count: 150, symbol_count: 7500, .. }),
3658            "Expected Ready state with updated metrics, got: {:?}",
3659            state
3660        );
3661    }
3662
3663    #[test]
3664    fn test_state_transition_guard_building_to_building() {
3665        // Test that Building → Building is allowed (progress update)
3666        let coordinator = IndexCoordinator::new();
3667
3668        // Initial building state
3669        coordinator.transition_to_building(100);
3670
3671        let state = coordinator.state();
3672        assert!(
3673            matches!(state, IndexState::Building { indexed_count: 0, total_count: 100, .. }),
3674            "Expected Building state, got: {:?}",
3675            state
3676        );
3677
3678        // Update total count
3679        coordinator.transition_to_building(200);
3680
3681        let state = coordinator.state();
3682        assert!(
3683            matches!(state, IndexState::Building { indexed_count: 0, total_count: 200, .. }),
3684            "Expected Building state, got: {:?}",
3685            state
3686        );
3687    }
3688
3689    #[test]
3690    fn test_state_transition_ready_to_building() {
3691        // Test that Ready → Building is allowed (re-scan)
3692        let coordinator = IndexCoordinator::new();
3693        coordinator.transition_to_ready(100, 5000);
3694
3695        // Trigger re-scan
3696        coordinator.transition_to_building(150);
3697
3698        let state = coordinator.state();
3699        assert!(
3700            matches!(state, IndexState::Building { indexed_count: 0, total_count: 150, .. }),
3701            "Expected Building state after re-scan, got: {:?}",
3702            state
3703        );
3704    }
3705
3706    #[test]
3707    fn test_state_transition_degraded_to_building() {
3708        // Test that Degraded → Building is allowed (recovery)
3709        let coordinator = IndexCoordinator::new();
3710        coordinator.transition_to_degraded(DegradationReason::IoError {
3711            message: "Test error".to_string(),
3712        });
3713
3714        // Attempt recovery
3715        coordinator.transition_to_building(100);
3716
3717        let state = coordinator.state();
3718        assert!(
3719            matches!(state, IndexState::Building { indexed_count: 0, total_count: 100, .. }),
3720            "Expected Building state after recovery, got: {:?}",
3721            state
3722        );
3723    }
3724
3725    #[test]
3726    fn test_update_building_progress() {
3727        let coordinator = IndexCoordinator::new();
3728        coordinator.transition_to_building(100);
3729
3730        // Update progress
3731        coordinator.update_building_progress(50);
3732
3733        let state = coordinator.state();
3734        assert!(
3735            matches!(state, IndexState::Building { indexed_count: 50, total_count: 100, .. }),
3736            "Expected Building state with updated progress, got: {:?}",
3737            state
3738        );
3739
3740        // Update progress again
3741        coordinator.update_building_progress(100);
3742
3743        let state = coordinator.state();
3744        assert!(
3745            matches!(state, IndexState::Building { indexed_count: 100, total_count: 100, .. }),
3746            "Expected Building state with completed progress, got: {:?}",
3747            state
3748        );
3749    }
3750
3751    #[test]
3752    fn test_scan_timeout_detection() {
3753        // Test that scan timeout triggers degradation
3754        let limits = IndexResourceLimits {
3755            max_scan_duration_ms: 0, // Immediate timeout for testing
3756            ..Default::default()
3757        };
3758
3759        let coordinator = IndexCoordinator::with_limits(limits);
3760        coordinator.transition_to_building(100);
3761
3762        // Small sleep to ensure elapsed time > 0
3763        std::thread::sleep(std::time::Duration::from_millis(1));
3764
3765        // Update progress should detect timeout
3766        coordinator.update_building_progress(10);
3767
3768        let state = coordinator.state();
3769        assert!(
3770            matches!(
3771                state,
3772                IndexState::Degraded { reason: DegradationReason::ScanTimeout { .. }, .. }
3773            ),
3774            "Expected Degraded state with ScanTimeout, got: {:?}",
3775            state
3776        );
3777    }
3778
3779    #[test]
3780    fn test_scan_timeout_does_not_trigger_within_limit() {
3781        // Test that scan doesn't timeout within the limit
3782        let limits = IndexResourceLimits {
3783            max_scan_duration_ms: 10_000, // 10 seconds - should not trigger
3784            ..Default::default()
3785        };
3786
3787        let coordinator = IndexCoordinator::with_limits(limits);
3788        coordinator.transition_to_building(100);
3789
3790        // Update progress immediately (well within limit)
3791        coordinator.update_building_progress(50);
3792
3793        let state = coordinator.state();
3794        assert!(
3795            matches!(state, IndexState::Building { indexed_count: 50, .. }),
3796            "Expected Building state (no timeout), got: {:?}",
3797            state
3798        );
3799    }
3800
3801    #[test]
3802    fn test_early_exit_optimization_unchanged_content() {
3803        let index = WorkspaceIndex::new();
3804        let uri = must(url::Url::parse("file:///test.pl"));
3805        let code = r#"
3806package MyPackage;
3807
3808sub hello {
3809    print "Hello";
3810}
3811"#;
3812
3813        // First indexing should parse and index
3814        must(index.index_file(uri.clone(), code.to_string()));
3815        let symbols1 = index.file_symbols(uri.as_str());
3816        assert!(symbols1.iter().any(|s| s.name == "MyPackage" && s.kind == SymbolKind::Package));
3817        assert!(symbols1.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
3818
3819        // Second indexing with same content should early-exit
3820        // We can verify this by checking that the index still works correctly
3821        must(index.index_file(uri.clone(), code.to_string()));
3822        let symbols2 = index.file_symbols(uri.as_str());
3823        assert_eq!(symbols1.len(), symbols2.len());
3824        assert!(symbols2.iter().any(|s| s.name == "MyPackage" && s.kind == SymbolKind::Package));
3825        assert!(symbols2.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
3826    }
3827
3828    #[test]
3829    fn test_early_exit_optimization_changed_content() {
3830        let index = WorkspaceIndex::new();
3831        let uri = must(url::Url::parse("file:///test.pl"));
3832        let code1 = r#"
3833package MyPackage;
3834
3835sub hello {
3836    print "Hello";
3837}
3838"#;
3839
3840        let code2 = r#"
3841package MyPackage;
3842
3843sub goodbye {
3844    print "Goodbye";
3845}
3846"#;
3847
3848        // First indexing
3849        must(index.index_file(uri.clone(), code1.to_string()));
3850        let symbols1 = index.file_symbols(uri.as_str());
3851        assert!(symbols1.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
3852        assert!(!symbols1.iter().any(|s| s.name == "goodbye"));
3853
3854        // Second indexing with different content should re-parse
3855        must(index.index_file(uri.clone(), code2.to_string()));
3856        let symbols2 = index.file_symbols(uri.as_str());
3857        assert!(!symbols2.iter().any(|s| s.name == "hello"));
3858        assert!(symbols2.iter().any(|s| s.name == "goodbye" && s.kind == SymbolKind::Subroutine));
3859    }
3860
3861    #[test]
3862    fn test_early_exit_optimization_whitespace_only_change() {
3863        let index = WorkspaceIndex::new();
3864        let uri = must(url::Url::parse("file:///test.pl"));
3865        let code1 = r#"
3866package MyPackage;
3867
3868sub hello {
3869    print "Hello";
3870}
3871"#;
3872
3873        let code2 = r#"
3874package MyPackage;
3875
3876
3877sub hello {
3878    print "Hello";
3879}
3880"#;
3881
3882        // First indexing
3883        must(index.index_file(uri.clone(), code1.to_string()));
3884        let symbols1 = index.file_symbols(uri.as_str());
3885        assert!(symbols1.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
3886
3887        // Second indexing with whitespace change should re-parse (hash will differ)
3888        must(index.index_file(uri.clone(), code2.to_string()));
3889        let symbols2 = index.file_symbols(uri.as_str());
3890        // Symbols should still be found, but content hash differs so it re-indexed
3891        assert!(symbols2.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
3892    }
3893
3894    #[test]
3895    fn test_reindex_file_refreshes_symbol_cache_for_removed_names() {
3896        let index = WorkspaceIndex::new();
3897        let uri1 = must(url::Url::parse("file:///lib/A.pm"));
3898        let uri2 = must(url::Url::parse("file:///lib/B.pm"));
3899        let code1 = "package A;\nsub foo { return 1; }\n1;\n";
3900        let code2 = "package B;\nsub foo { return 2; }\n1;\n";
3901        let code2_reindexed = "package B;\nsub bar { return 3; }\n1;\n";
3902
3903        must(index.index_file(uri1.clone(), code1.to_string()));
3904        must(index.index_file(uri2.clone(), code2.to_string()));
3905        must(index.index_file(uri2.clone(), code2_reindexed.to_string()));
3906
3907        let foo_location = must_some(index.find_definition("foo"));
3908        assert_eq!(foo_location.uri, uri1.to_string());
3909
3910        let bar_location = must_some(index.find_definition("bar"));
3911        assert_eq!(bar_location.uri, uri2.to_string());
3912    }
3913
3914    #[test]
3915    fn test_remove_file_preserves_other_colliding_symbol_entries() {
3916        let index = WorkspaceIndex::new();
3917        let uri1 = must(url::Url::parse("file:///lib/A.pm"));
3918        let uri2 = must(url::Url::parse("file:///lib/B.pm"));
3919        let code1 = "package A;\nsub foo { return 1; }\n1;\n";
3920        let code2 = "package B;\nsub foo { return 2; }\n1;\n";
3921
3922        must(index.index_file(uri1.clone(), code1.to_string()));
3923        must(index.index_file(uri2.clone(), code2.to_string()));
3924
3925        index.remove_file(uri2.as_str());
3926
3927        let foo_location = must_some(index.find_definition("foo"));
3928        assert_eq!(foo_location.uri, uri1.to_string());
3929    }
3930
3931    #[test]
3932    fn test_count_usages_no_double_counting_for_qualified_calls() {
3933        let index = WorkspaceIndex::new();
3934
3935        // File 1: defines Utils::process_data
3936        let uri1 = "file:///lib/Utils.pm";
3937        let code1 = r#"
3938package Utils;
3939
3940sub process_data {
3941    return 1;
3942}
3943"#;
3944        must(index.index_file(must(url::Url::parse(uri1)), code1.to_string()));
3945
3946        // File 2: calls Utils::process_data (qualified call)
3947        let uri2 = "file:///app.pl";
3948        let code2 = r#"
3949use Utils;
3950Utils::process_data();
3951Utils::process_data();
3952"#;
3953        must(index.index_file(must(url::Url::parse(uri2)), code2.to_string()));
3954
3955        // Each qualified call is stored under both "process_data" and "Utils::process_data"
3956        // by the dual indexing strategy. count_usages should deduplicate so we get the
3957        // actual number of call sites, not double.
3958        let count = index.count_usages("Utils::process_data");
3959
3960        // We expect exactly 2 usage sites (the two calls in app.pl),
3961        // not 4 (which would be the double-counted result).
3962        assert_eq!(
3963            count, 2,
3964            "count_usages should not double-count qualified calls, got {} (expected 2)",
3965            count
3966        );
3967
3968        // find_references should also deduplicate
3969        let refs = index.find_references("Utils::process_data");
3970        let non_def_refs: Vec<_> =
3971            refs.iter().filter(|loc| loc.uri != "file:///lib/Utils.pm").collect();
3972        assert_eq!(
3973            non_def_refs.len(),
3974            2,
3975            "find_references should not return duplicates for qualified calls, got {} non-def refs",
3976            non_def_refs.len()
3977        );
3978    }
3979
3980    #[test]
3981    fn test_batch_indexing() {
3982        let index = WorkspaceIndex::new();
3983        let files: Vec<(Url, String)> = (0..5)
3984            .map(|i| {
3985                let uri = must(Url::parse(&format!("file:///batch/module{}.pm", i)));
3986                let code =
3987                    format!("package Batch::Mod{};\nsub func_{} {{ return {}; }}\n1;", i, i, i);
3988                (uri, code)
3989            })
3990            .collect();
3991
3992        let errors = index.index_files_batch(files);
3993        assert!(errors.is_empty(), "batch indexing errors: {:?}", errors);
3994        assert_eq!(index.file_count(), 5);
3995        assert!(index.find_definition("Batch::Mod0::func_0").is_some());
3996        assert!(index.find_definition("Batch::Mod4::func_4").is_some());
3997    }
3998
3999    #[test]
4000    fn test_batch_indexing_skips_unchanged() {
4001        let index = WorkspaceIndex::new();
4002        let uri = must(Url::parse("file:///batch/skip.pm"));
4003        let code = "package Skip;\nsub skip_fn { 1 }\n1;".to_string();
4004
4005        index.index_file(uri.clone(), code.clone()).ok();
4006        assert_eq!(index.file_count(), 1);
4007
4008        let errors = index.index_files_batch(vec![(uri, code)]);
4009        assert!(errors.is_empty());
4010        assert_eq!(index.file_count(), 1);
4011    }
4012
4013    #[test]
4014    fn test_incremental_update_preserves_other_symbols() {
4015        let index = WorkspaceIndex::new();
4016
4017        let uri_a = must(Url::parse("file:///incr/a.pm"));
4018        let uri_b = must(Url::parse("file:///incr/b.pm"));
4019        index.index_file(uri_a.clone(), "package A;\nsub a_func { 1 }\n1;".into()).ok();
4020        index.index_file(uri_b.clone(), "package B;\nsub b_func { 2 }\n1;".into()).ok();
4021
4022        assert!(index.find_definition("A::a_func").is_some());
4023        assert!(index.find_definition("B::b_func").is_some());
4024
4025        index.index_file(uri_a, "package A;\nsub a_func_v2 { 11 }\n1;".into()).ok();
4026
4027        assert!(index.find_definition("A::a_func_v2").is_some());
4028        assert!(index.find_definition("B::b_func").is_some());
4029    }
4030
4031    #[test]
4032    fn test_remove_file_preserves_shadowed_symbols() {
4033        let index = WorkspaceIndex::new();
4034
4035        let uri_a = must(Url::parse("file:///shadow/a.pm"));
4036        let uri_b = must(Url::parse("file:///shadow/b.pm"));
4037        index.index_file(uri_a.clone(), "package ShadowA;\nsub helper { 1 }\n1;".into()).ok();
4038        index.index_file(uri_b.clone(), "package ShadowB;\nsub helper { 2 }\n1;".into()).ok();
4039
4040        assert!(index.find_definition("helper").is_some());
4041
4042        index.remove_file_url(&uri_a);
4043        assert!(index.find_definition("helper").is_some());
4044        assert!(index.find_definition("ShadowB::helper").is_some());
4045    }
4046
4047    // -------------------------------------------------------------------------
4048    // find_dependents — use parent / use base integration (#2747)
4049    // -------------------------------------------------------------------------
4050
4051    #[test]
4052    fn test_index_dependency_via_use_parent_end_to_end() {
4053        // Regression for #2747: index a file with `use parent 'MyBase'` and verify
4054        // that find_dependents("MyBase") returns that file.
4055        // 1. Index MyBase.pm
4056        // 2. Index child.pl with `use parent 'MyBase'`
4057        // 3. find_dependents("MyBase") should return child.pl
4058        let index = WorkspaceIndex::new();
4059
4060        let base_url = url::Url::parse("file:///test/workspace/lib/MyBase.pm").unwrap();
4061        index
4062            .index_file(base_url, "package MyBase;\nsub new { bless {}, shift }\n1;\n".to_string())
4063            .expect("indexing MyBase.pm");
4064
4065        let child_url = url::Url::parse("file:///test/workspace/child.pl").unwrap();
4066        index
4067            .index_file(child_url, "package Child;\nuse parent 'MyBase';\n1;\n".to_string())
4068            .expect("indexing child.pl");
4069
4070        let dependents = index.find_dependents("MyBase");
4071        assert!(
4072            !dependents.is_empty(),
4073            "find_dependents('MyBase') returned empty — \
4074             use parent 'MyBase' should register MyBase as a dependency. \
4075             Dependencies in index: {:?}",
4076            {
4077                let files = index.files.read();
4078                files
4079                    .iter()
4080                    .map(|(k, v)| (k.clone(), v.dependencies.iter().cloned().collect::<Vec<_>>()))
4081                    .collect::<Vec<_>>()
4082            }
4083        );
4084        assert!(
4085            dependents.contains(&"file:///test/workspace/child.pl".to_string()),
4086            "child.pl should be in dependents, got: {:?}",
4087            dependents
4088        );
4089    }
4090
4091    #[test]
4092    fn test_parser_produces_correct_args_for_use_parent() {
4093        // Regression for #2747: verify that the parser produces args=["'MyBase'"]
4094        // for `use parent 'MyBase'`, so extract_module_names_from_use_args strips
4095        // the quotes and registers the dependency under the bare name "MyBase".
4096        use crate::Parser;
4097        let mut p = Parser::new("package Child;\nuse parent 'MyBase';\n1;\n");
4098        let ast = p.parse().expect("parse succeeded");
4099        if let NodeKind::Program { statements } = &ast.kind {
4100            for stmt in statements {
4101                if let NodeKind::Use { module, args, .. } = &stmt.kind {
4102                    if module == "parent" {
4103                        assert_eq!(
4104                            args,
4105                            &["'MyBase'".to_string()],
4106                            "Expected args=[\"'MyBase'\"] for `use parent 'MyBase'`, got: {:?}",
4107                            args
4108                        );
4109                        let extracted = extract_module_names_from_use_args(args);
4110                        assert_eq!(
4111                            extracted,
4112                            vec!["MyBase".to_string()],
4113                            "extract_module_names_from_use_args should return [\"MyBase\"], got {:?}",
4114                            extracted
4115                        );
4116                        return; // Test passed
4117                    }
4118                }
4119            }
4120            panic!("No Use node with module='parent' found in AST");
4121        } else {
4122            panic!("Expected Program root");
4123        }
4124    }
4125
4126    // -------------------------------------------------------------------------
4127    // extract_module_names_from_use_args — unit tests (#2747)
4128    // -------------------------------------------------------------------------
4129
4130    #[test]
4131    fn test_extract_module_names_single_quoted() {
4132        let names = extract_module_names_from_use_args(&["'Foo::Bar'".to_string()]);
4133        assert_eq!(names, vec!["Foo::Bar"]);
4134    }
4135
4136    #[test]
4137    fn test_extract_module_names_double_quoted() {
4138        let names = extract_module_names_from_use_args(&["\"Foo::Bar\"".to_string()]);
4139        assert_eq!(names, vec!["Foo::Bar"]);
4140    }
4141
4142    #[test]
4143    fn test_extract_module_names_qw_list() {
4144        let names = extract_module_names_from_use_args(&["qw(Foo::Bar Other::Base)".to_string()]);
4145        assert_eq!(names, vec!["Foo::Bar", "Other::Base"]);
4146    }
4147
4148    #[test]
4149    fn test_extract_module_names_norequire_flag() {
4150        let names = extract_module_names_from_use_args(&[
4151            "-norequire".to_string(),
4152            "'Foo::Bar'".to_string(),
4153        ]);
4154        assert_eq!(names, vec!["Foo::Bar"]);
4155    }
4156
4157    #[test]
4158    fn test_extract_module_names_empty_args() {
4159        let names = extract_module_names_from_use_args(&[]);
4160        assert!(names.is_empty());
4161    }
4162
4163    #[test]
4164    fn test_extract_module_names_legacy_separator() {
4165        // Perl legacy package separator ' (tick) inside module name
4166        let names = extract_module_names_from_use_args(&["'Foo'Bar'".to_string()]);
4167        // After stripping outer quotes the raw token is Foo'Bar — a valid legacy name
4168        assert_eq!(names, vec!["Foo'Bar"]);
4169    }
4170
4171    #[test]
4172    fn test_with_capacity_accepts_large_batch_without_panic() {
4173        let index = WorkspaceIndex::with_capacity(100, 20);
4174        for i in 0..100 {
4175            let uri = must(url::Url::parse(&format!("file:///lib/Mod{}.pm", i)));
4176            let src = format!("package Mod{};\nsub foo_{} {{ 1 }}\n1;\n", i, i);
4177            index.index_file(uri, src).ok();
4178        }
4179        assert!(index.has_symbols());
4180    }
4181
4182    #[test]
4183    fn test_with_capacity_zero_does_not_panic() {
4184        let index = WorkspaceIndex::with_capacity(0, 0);
4185        assert!(!index.has_symbols());
4186    }
4187}