Skip to main content

perl_workspace/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::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 perl_semantic_facts::{
72    AnchorFact, AnchorId, Confidence, EdgeFact, EntityFact, EntityId, EntityKind, FileId,
73    Provenance,
74};
75use serde::{Deserialize, Serialize};
76use std::collections::hash_map::DefaultHasher;
77use std::collections::{HashMap, HashSet};
78use std::hash::{Hash, Hasher};
79use std::path::Path;
80use std::sync::Arc;
81use std::time::Instant;
82use url::Url;
83
84pub use crate::workspace::monitoring::{
85    DegradationReason, EarlyExitReason, EarlyExitRecord, IndexInstrumentationSnapshot,
86    IndexMetrics, IndexPerformanceCaps, IndexPhase, IndexPhaseTransition, IndexResourceLimits,
87    IndexStateKind, IndexStateTransition, ResourceKind,
88};
89use perl_symbol::surface::decl::extract_symbol_decls;
90
91// Re-export URI utilities for backward compatibility
92#[cfg(not(target_arch = "wasm32"))]
93/// URI ↔ filesystem helpers used during Index/Analyze workflows.
94pub use perl_uri::{fs_path_to_uri, uri_to_fs_path};
95/// URI inspection helpers used during Index/Analyze workflows.
96pub use perl_uri::{is_file_uri, is_special_scheme, uri_extension, uri_key};
97
98// ============================================================================
99// Index Lifecycle Types (Index Lifecycle v1 Specification)
100// ============================================================================
101
102/// Index readiness state - explicit lifecycle management
103///
104/// Represents the current operational state of the workspace index, enabling
105/// LSP handlers to provide appropriate responses based on index availability.
106/// This state machine prevents blocking operations and ensures graceful
107/// degradation when the index is not fully ready.
108///
109/// # State Transitions
110///
111/// - `Building` → `Ready`: Workspace scan completes successfully
112/// - `Building` → `Degraded`: Scan timeout, IO error, or resource limit
113/// - `Ready` → `Building`: Workspace folder change or file watching events
114/// - `Ready` → `Degraded`: Parse storm (>10 pending) or IO error
115/// - `Degraded` → `Building`: Recovery attempt after cooldown
116/// - `Degraded` → `Ready`: Successful re-scan after recovery
117///
118/// # Invariants
119///
120/// - During a single build attempt, `phase` advances monotonically
121///   (`Idle` → `Scanning` → `Indexing`).
122/// - `indexed_count` must not exceed `total_count`; callers should keep totals updated.
123/// - `Ready` and `Degraded` counts are snapshots captured at transition time.
124///
125/// # Usage
126///
127/// ```rust,ignore
128/// use perl_parser::workspace_index::{IndexPhase, IndexState};
129/// use std::time::Instant;
130///
131/// let state = IndexState::Building {
132///     phase: IndexPhase::Indexing,
133///     indexed_count: 50,
134///     total_count: 100,
135///     started_at: Instant::now(),
136/// };
137/// ```
138#[derive(Clone, Debug)]
139pub enum IndexState {
140    /// Index is being constructed (workspace scan in progress)
141    Building {
142        /// Current build phase (Idle → Scanning → Indexing)
143        phase: IndexPhase,
144        /// Files indexed so far
145        indexed_count: usize,
146        /// Total files discovered
147        total_count: usize,
148        /// Started at
149        started_at: Instant,
150    },
151
152    /// Index is consistent and ready for queries
153    Ready {
154        /// Total symbols indexed
155        symbol_count: usize,
156        /// Total files indexed
157        file_count: usize,
158        /// Timestamp of last successful index
159        completed_at: Instant,
160    },
161
162    /// Index is serving but degraded
163    Degraded {
164        /// Why we degraded
165        reason: DegradationReason,
166        /// What's still available
167        available_symbols: usize,
168        /// When degradation occurred
169        since: Instant,
170    },
171}
172
173impl IndexState {
174    /// Return the coarse state kind for instrumentation and routing decisions
175    pub fn kind(&self) -> IndexStateKind {
176        match self {
177            IndexState::Building { .. } => IndexStateKind::Building,
178            IndexState::Ready { .. } => IndexStateKind::Ready,
179            IndexState::Degraded { .. } => IndexStateKind::Degraded,
180        }
181    }
182
183    /// Return the current build phase when in `Building` state
184    pub fn phase(&self) -> Option<IndexPhase> {
185        match self {
186            IndexState::Building { phase, .. } => Some(*phase),
187            _ => None,
188        }
189    }
190
191    /// Timestamp of when the current state began
192    pub fn state_started_at(&self) -> Instant {
193        match self {
194            IndexState::Building { started_at, .. } => *started_at,
195            IndexState::Ready { completed_at, .. } => *completed_at,
196            IndexState::Degraded { since, .. } => *since,
197        }
198    }
199}
200
201/// Coordinates index lifecycle, state transitions, and handler queries
202///
203/// The IndexCoordinator wraps `WorkspaceIndex` with explicit state management,
204/// enabling LSP handlers to query the index readiness and implement appropriate
205/// fallback behavior when the index is not fully ready.
206///
207/// # Architecture
208///
209/// ```text
210/// LspServer
211///   └── IndexCoordinator
212///         ├── state: Arc<RwLock<IndexState>>
213///         ├── index: Arc<WorkspaceIndex>
214///         ├── limits: IndexResourceLimits
215///         ├── caps: IndexPerformanceCaps
216///         ├── metrics: IndexMetrics
217///         └── instrumentation: IndexInstrumentation
218/// ```
219///
220/// # State Management
221///
222/// The coordinator manages three states:
223/// - `Building`: Initial scan or recovery in progress
224/// - `Ready`: Fully indexed and available for queries
225/// - `Degraded`: Available but with reduced functionality
226///
227/// # Performance Characteristics
228///
229/// - State checks are lock-free reads (cloned state, <100ns)
230/// - State transitions use write locks (rare, <1μs)
231/// - Query dispatch has zero overhead in Ready state
232/// - Degradation detection is atomic (<10ns per check)
233///
234/// # Usage
235///
236/// ```rust,ignore
237/// use perl_parser::workspace_index::{IndexCoordinator, IndexState};
238///
239/// let coordinator = IndexCoordinator::new();
240/// assert!(matches!(coordinator.state(), IndexState::Building { .. }));
241///
242/// // Transition to ready after indexing
243/// coordinator.transition_to_ready(100, 5000);
244/// assert!(matches!(coordinator.state(), IndexState::Ready { .. }));
245///
246/// // Query with degradation handling
247/// let _result = coordinator.query(
248///     |index| index.find_definition("my_function"), // full query
249///     |_index| None                                 // partial fallback
250/// );
251/// ```
252pub struct IndexCoordinator {
253    /// Current index state (RwLock for state transitions)
254    state: Arc<RwLock<IndexState>>,
255
256    /// The actual workspace index
257    index: Arc<WorkspaceIndex>,
258
259    /// Resource limits configuration
260    ///
261    /// Enforces bounded resource usage to prevent unbounded memory growth:
262    /// - max_files: Triggers degradation when file count exceeds limit
263    /// - max_total_symbols: Triggers degradation when symbol count exceeds limit
264    /// - max_symbols_per_file: Used for per-file validation during indexing
265    limits: IndexResourceLimits,
266
267    /// Performance caps for early-exit heuristics
268    caps: IndexPerformanceCaps,
269
270    /// Runtime metrics for degradation detection
271    metrics: IndexMetrics,
272
273    /// Instrumentation for lifecycle transitions and durations
274    instrumentation: IndexInstrumentation,
275}
276
277impl std::fmt::Debug for IndexCoordinator {
278    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
279        f.debug_struct("IndexCoordinator")
280            .field("state", &*self.state.read())
281            .field("limits", &self.limits)
282            .field("caps", &self.caps)
283            .finish_non_exhaustive()
284    }
285}
286
287impl IndexCoordinator {
288    /// Create a new coordinator in Building state
289    ///
290    /// Initializes the coordinator with default resource limits and
291    /// an empty workspace index ready for initial scan.
292    ///
293    /// # Returns
294    ///
295    /// A coordinator initialized in `IndexState::Building`.
296    ///
297    /// # Examples
298    ///
299    /// ```rust,ignore
300    /// use perl_parser::workspace_index::IndexCoordinator;
301    ///
302    /// let coordinator = IndexCoordinator::new();
303    /// ```
304    pub fn new() -> Self {
305        Self {
306            state: Arc::new(RwLock::new(IndexState::Building {
307                phase: IndexPhase::Idle,
308                indexed_count: 0,
309                total_count: 0,
310                started_at: Instant::now(),
311            })),
312            index: Arc::new(WorkspaceIndex::new()),
313            limits: IndexResourceLimits::default(),
314            caps: IndexPerformanceCaps::default(),
315            metrics: IndexMetrics::new(),
316            instrumentation: IndexInstrumentation::new(),
317        }
318    }
319
320    /// Create a coordinator with custom resource limits
321    ///
322    /// # Arguments
323    ///
324    /// * `limits` - Custom resource limits for this workspace
325    ///
326    /// # Returns
327    ///
328    /// A coordinator configured with the provided resource limits.
329    ///
330    /// # Examples
331    ///
332    /// ```rust,ignore
333    /// use perl_parser::workspace_index::{IndexCoordinator, IndexResourceLimits};
334    ///
335    /// let limits = IndexResourceLimits::default();
336    /// let coordinator = IndexCoordinator::with_limits(limits);
337    /// ```
338    pub fn with_limits(limits: IndexResourceLimits) -> Self {
339        Self {
340            state: Arc::new(RwLock::new(IndexState::Building {
341                phase: IndexPhase::Idle,
342                indexed_count: 0,
343                total_count: 0,
344                started_at: Instant::now(),
345            })),
346            index: Arc::new(WorkspaceIndex::new()),
347            limits,
348            caps: IndexPerformanceCaps::default(),
349            metrics: IndexMetrics::new(),
350            instrumentation: IndexInstrumentation::new(),
351        }
352    }
353
354    /// Create a coordinator with custom limits and performance caps
355    ///
356    /// # Arguments
357    ///
358    /// * `limits` - Resource limits for this workspace
359    /// * `caps` - Performance caps for indexing budgets
360    pub fn with_limits_and_caps(limits: IndexResourceLimits, caps: IndexPerformanceCaps) -> Self {
361        Self {
362            state: Arc::new(RwLock::new(IndexState::Building {
363                phase: IndexPhase::Idle,
364                indexed_count: 0,
365                total_count: 0,
366                started_at: Instant::now(),
367            })),
368            index: Arc::new(WorkspaceIndex::new()),
369            limits,
370            caps,
371            metrics: IndexMetrics::new(),
372            instrumentation: IndexInstrumentation::new(),
373        }
374    }
375
376    /// Get current state (lock-free read via clone)
377    ///
378    /// Returns a cloned copy of the current state for lock-free access
379    /// in hot path LSP handlers.
380    ///
381    /// # Returns
382    ///
383    /// The current `IndexState` snapshot.
384    ///
385    /// # Examples
386    ///
387    /// ```rust,ignore
388    /// use perl_parser::workspace_index::{IndexCoordinator, IndexState};
389    ///
390    /// let coordinator = IndexCoordinator::new();
391    /// match coordinator.state() {
392    ///     IndexState::Ready { .. } => {
393    ///         // Full query path
394    ///     }
395    ///     _ => {
396    ///         // Degraded/building fallback
397    ///     }
398    /// }
399    /// ```
400    pub fn state(&self) -> IndexState {
401        self.state.read().clone()
402    }
403
404    /// Get reference to the underlying workspace index
405    ///
406    /// Provides direct access to the `WorkspaceIndex` for operations
407    /// that don't require state checking (e.g., document store access).
408    ///
409    /// # Returns
410    ///
411    /// A shared reference to the underlying workspace index.
412    ///
413    /// # Examples
414    ///
415    /// ```rust,ignore
416    /// use perl_parser::workspace_index::IndexCoordinator;
417    ///
418    /// let coordinator = IndexCoordinator::new();
419    /// let _index = coordinator.index();
420    /// ```
421    pub fn index(&self) -> &Arc<WorkspaceIndex> {
422        &self.index
423    }
424
425    /// Access the configured resource limits
426    pub fn limits(&self) -> &IndexResourceLimits {
427        &self.limits
428    }
429
430    /// Access the configured performance caps
431    pub fn performance_caps(&self) -> &IndexPerformanceCaps {
432        &self.caps
433    }
434
435    /// Snapshot lifecycle instrumentation (durations, transitions, early exits)
436    pub fn instrumentation_snapshot(&self) -> IndexInstrumentationSnapshot {
437        self.instrumentation.snapshot()
438    }
439
440    /// Notify of file change (may trigger state transition)
441    ///
442    /// Increments the pending parse count and may transition to degraded
443    /// state if a parse storm is detected.
444    ///
445    /// # Arguments
446    ///
447    /// * `_uri` - URI of the changed file (reserved for future use).
448    ///
449    /// # Returns
450    ///
451    /// Nothing. Updates coordinator metrics and state for the LSP workflow.
452    ///
453    /// # Examples
454    ///
455    /// ```rust,ignore
456    /// use perl_parser::workspace_index::IndexCoordinator;
457    ///
458    /// let coordinator = IndexCoordinator::new();
459    /// coordinator.notify_change("file:///example.pl");
460    /// ```
461    pub fn notify_change(&self, _uri: &str) {
462        let pending = self.metrics.increment_pending_parses();
463
464        // Check for parse storm
465        if self.metrics.is_parse_storm() {
466            self.transition_to_degraded(DegradationReason::ParseStorm { pending_parses: pending });
467        }
468    }
469
470    /// Notify parse completion for the Index/Analyze workflow stages.
471    ///
472    /// Decrements the pending parse count, enforces resource limits, and may
473    /// attempt recovery when parse storms clear.
474    ///
475    /// # Arguments
476    ///
477    /// * `_uri` - URI of the parsed file (reserved for future use).
478    ///
479    /// # Returns
480    ///
481    /// Nothing. Updates coordinator metrics and state for the LSP workflow.
482    ///
483    /// # Examples
484    ///
485    /// ```rust,ignore
486    /// use perl_parser::workspace_index::IndexCoordinator;
487    ///
488    /// let coordinator = IndexCoordinator::new();
489    /// coordinator.notify_parse_complete("file:///example.pl");
490    /// ```
491    pub fn notify_parse_complete(&self, _uri: &str) {
492        let pending = self.metrics.decrement_pending_parses();
493
494        // Check for recovery from parse storm
495        if pending == 0 {
496            if let IndexState::Degraded { reason: DegradationReason::ParseStorm { .. }, .. } =
497                self.state()
498            {
499                // Attempt recovery - transition back to Building for re-scan
500                let mut state = self.state.write();
501                let from_kind = state.kind();
502                self.instrumentation.record_state_transition(from_kind, IndexStateKind::Building);
503                *state = IndexState::Building {
504                    phase: IndexPhase::Idle,
505                    indexed_count: 0,
506                    total_count: 0,
507                    started_at: Instant::now(),
508                };
509            }
510        }
511
512        // Enforce resource limits after parse completion
513        self.enforce_limits();
514    }
515
516    /// Transition to Ready state
517    ///
518    /// Marks the index as fully ready for queries after successful workspace
519    /// scan. Records the file count, symbol count, and completion timestamp.
520    /// Enforces resource limits after transition.
521    ///
522    /// # State Transition Guards
523    ///
524    /// Only valid transitions:
525    /// - `Building` → `Ready` (normal completion)
526    /// - `Degraded` → `Ready` (recovery after fix)
527    ///
528    /// # Arguments
529    ///
530    /// * `file_count` - Total number of files indexed
531    /// * `symbol_count` - Total number of symbols extracted
532    ///
533    /// # Returns
534    ///
535    /// Nothing. The coordinator state is updated in-place.
536    ///
537    /// # Examples
538    ///
539    /// ```rust,ignore
540    /// use perl_parser::workspace_index::IndexCoordinator;
541    ///
542    /// let coordinator = IndexCoordinator::new();
543    /// coordinator.transition_to_ready(100, 5000);
544    /// ```
545    pub fn transition_to_ready(&self, file_count: usize, symbol_count: usize) {
546        let mut state = self.state.write();
547        let from_kind = state.kind();
548
549        // State transition guard: validate current state allows transition to Ready
550        match &*state {
551            IndexState::Building { .. } | IndexState::Degraded { .. } => {
552                // Valid transition - proceed
553                *state =
554                    IndexState::Ready { symbol_count, file_count, completed_at: Instant::now() };
555            }
556            IndexState::Ready { .. } => {
557                // Already Ready - update metrics but don't log as transition
558                *state =
559                    IndexState::Ready { symbol_count, file_count, completed_at: Instant::now() };
560            }
561        }
562        self.instrumentation.record_state_transition(from_kind, IndexStateKind::Ready);
563        drop(state); // Release write lock before checking limits
564
565        // Enforce resource limits after transition
566        self.enforce_limits();
567    }
568
569    /// Transition to Scanning phase (Idle → Scanning)
570    ///
571    /// Resets build counters and marks the index as scanning workspace folders.
572    pub fn transition_to_scanning(&self) {
573        let mut state = self.state.write();
574        let from_kind = state.kind();
575
576        match &*state {
577            IndexState::Building { phase, indexed_count, total_count, started_at } => {
578                if *phase != IndexPhase::Scanning {
579                    self.instrumentation.record_phase_transition(*phase, IndexPhase::Scanning);
580                }
581                *state = IndexState::Building {
582                    phase: IndexPhase::Scanning,
583                    indexed_count: *indexed_count,
584                    total_count: *total_count,
585                    started_at: *started_at,
586                };
587            }
588            IndexState::Ready { .. } | IndexState::Degraded { .. } => {
589                self.instrumentation.record_state_transition(from_kind, IndexStateKind::Building);
590                self.instrumentation
591                    .record_phase_transition(IndexPhase::Idle, IndexPhase::Scanning);
592                *state = IndexState::Building {
593                    phase: IndexPhase::Scanning,
594                    indexed_count: 0,
595                    total_count: 0,
596                    started_at: Instant::now(),
597                };
598            }
599        }
600    }
601
602    /// Update scanning progress with the latest discovered file count
603    pub fn update_scan_progress(&self, total_count: usize) {
604        let mut state = self.state.write();
605        if let IndexState::Building { phase, indexed_count, started_at, .. } = &*state {
606            if *phase != IndexPhase::Scanning {
607                self.instrumentation.record_phase_transition(*phase, IndexPhase::Scanning);
608            }
609            *state = IndexState::Building {
610                phase: IndexPhase::Scanning,
611                indexed_count: *indexed_count,
612                total_count,
613                started_at: *started_at,
614            };
615        }
616    }
617
618    /// Transition to Indexing phase (Scanning → Indexing)
619    ///
620    /// Uses the discovered file count as the total index target.
621    pub fn transition_to_indexing(&self, total_count: usize) {
622        let mut state = self.state.write();
623        let from_kind = state.kind();
624
625        match &*state {
626            IndexState::Building { phase, indexed_count, started_at, .. } => {
627                if *phase != IndexPhase::Indexing {
628                    self.instrumentation.record_phase_transition(*phase, IndexPhase::Indexing);
629                }
630                *state = IndexState::Building {
631                    phase: IndexPhase::Indexing,
632                    indexed_count: *indexed_count,
633                    total_count,
634                    started_at: *started_at,
635                };
636            }
637            IndexState::Ready { .. } | IndexState::Degraded { .. } => {
638                self.instrumentation.record_state_transition(from_kind, IndexStateKind::Building);
639                self.instrumentation
640                    .record_phase_transition(IndexPhase::Idle, IndexPhase::Indexing);
641                *state = IndexState::Building {
642                    phase: IndexPhase::Indexing,
643                    indexed_count: 0,
644                    total_count,
645                    started_at: Instant::now(),
646                };
647            }
648        }
649    }
650
651    /// Transition to Building state (Indexing phase)
652    ///
653    /// Marks the index as indexing with a known total file count.
654    pub fn transition_to_building(&self, total_count: usize) {
655        let mut state = self.state.write();
656        let from_kind = state.kind();
657
658        // State transition guard: validate transition is allowed
659        match &*state {
660            IndexState::Degraded { .. } | IndexState::Ready { .. } => {
661                self.instrumentation.record_state_transition(from_kind, IndexStateKind::Building);
662                self.instrumentation
663                    .record_phase_transition(IndexPhase::Idle, IndexPhase::Indexing);
664                *state = IndexState::Building {
665                    phase: IndexPhase::Indexing,
666                    indexed_count: 0,
667                    total_count,
668                    started_at: Instant::now(),
669                };
670            }
671            IndexState::Building { phase, indexed_count, started_at, .. } => {
672                let mut next_phase = *phase;
673                if *phase == IndexPhase::Idle {
674                    self.instrumentation
675                        .record_phase_transition(IndexPhase::Idle, IndexPhase::Indexing);
676                    next_phase = IndexPhase::Indexing;
677                }
678                *state = IndexState::Building {
679                    phase: next_phase,
680                    indexed_count: *indexed_count,
681                    total_count,
682                    started_at: *started_at,
683                };
684            }
685        }
686    }
687
688    /// Update Building state progress for the Index/Analyze workflow stages.
689    ///
690    /// Increments the indexed file count and checks for scan timeouts.
691    ///
692    /// # Arguments
693    ///
694    /// * `indexed_count` - Number of files indexed so far.
695    ///
696    /// # Returns
697    ///
698    /// Nothing. Updates coordinator state and may transition to `Degraded`.
699    ///
700    /// # Examples
701    ///
702    /// ```rust,ignore
703    /// use perl_parser::workspace_index::IndexCoordinator;
704    ///
705    /// let coordinator = IndexCoordinator::new();
706    /// coordinator.transition_to_building(100);
707    /// coordinator.update_building_progress(1);
708    /// ```
709    pub fn update_building_progress(&self, indexed_count: usize) {
710        let mut state = self.state.write();
711
712        if let IndexState::Building { phase, started_at, total_count, .. } = &*state {
713            let elapsed = started_at.elapsed().as_millis() as u64;
714
715            // Check for scan timeout
716            if elapsed > self.limits.max_scan_duration_ms {
717                // Timeout exceeded - transition to degraded
718                drop(state);
719                self.transition_to_degraded(DegradationReason::ScanTimeout { elapsed_ms: elapsed });
720                return;
721            }
722
723            // Update progress
724            *state = IndexState::Building {
725                phase: *phase,
726                indexed_count,
727                total_count: *total_count,
728                started_at: *started_at,
729            };
730        }
731    }
732
733    /// Transition to Degraded state
734    ///
735    /// Marks the index as degraded with the specified reason. Preserves
736    /// the current symbol count (if available) to indicate partial
737    /// functionality remains.
738    ///
739    /// # Arguments
740    ///
741    /// * `reason` - Why the index degraded (ParseStorm, IoError, etc.)
742    ///
743    /// # Returns
744    ///
745    /// Nothing. The coordinator state is updated in-place.
746    ///
747    /// # Examples
748    ///
749    /// ```rust,ignore
750    /// use perl_parser::workspace_index::{DegradationReason, IndexCoordinator, ResourceKind};
751    ///
752    /// let coordinator = IndexCoordinator::new();
753    /// coordinator.transition_to_degraded(DegradationReason::ResourceLimit {
754    ///     kind: ResourceKind::MaxFiles,
755    /// });
756    /// ```
757    pub fn transition_to_degraded(&self, reason: DegradationReason) {
758        let mut state = self.state.write();
759        let from_kind = state.kind();
760
761        // Get available symbols count from current state
762        let available_symbols = match &*state {
763            IndexState::Ready { symbol_count, .. } => *symbol_count,
764            IndexState::Degraded { available_symbols, .. } => *available_symbols,
765            IndexState::Building { .. } => 0,
766        };
767
768        self.instrumentation.record_state_transition(from_kind, IndexStateKind::Degraded);
769        *state = IndexState::Degraded { reason, available_symbols, since: Instant::now() };
770    }
771
772    /// Check resource limits and return degradation reason if exceeded
773    ///
774    /// Examines current workspace index state against configured resource limits.
775    /// Returns the first exceeded limit found, enabling targeted degradation.
776    ///
777    /// # Returns
778    ///
779    /// * `Some(DegradationReason)` - Resource limit exceeded, contains specific limit type
780    /// * `None` - All limits within acceptable bounds
781    ///
782    /// # Checked Limits
783    ///
784    /// - `max_files`: Total number of indexed files
785    /// - `max_total_symbols`: Aggregate symbol count across workspace
786    ///
787    /// # Performance
788    ///
789    /// - Lock-free read of index state (<100ns)
790    /// - Symbol counting is O(n) where n is number of files
791    ///
792    /// Returns: `Some(DegradationReason)` when a limit is exceeded, otherwise `None`.
793    ///
794    /// # Examples
795    ///
796    /// ```rust,ignore
797    /// use perl_parser::workspace_index::IndexCoordinator;
798    ///
799    /// let coordinator = IndexCoordinator::new();
800    /// let _reason = coordinator.check_limits();
801    /// ```
802    pub fn check_limits(&self) -> Option<DegradationReason> {
803        let files = self.index.files.read();
804
805        // Check max_files limit
806        let file_count = files.len();
807        if file_count > self.limits.max_files {
808            return Some(DegradationReason::ResourceLimit { kind: ResourceKind::MaxFiles });
809        }
810
811        // Check max_total_symbols limit
812        let total_symbols: usize = files.values().map(|fi| fi.symbols.len()).sum();
813        if total_symbols > self.limits.max_total_symbols {
814            return Some(DegradationReason::ResourceLimit { kind: ResourceKind::MaxSymbols });
815        }
816
817        None
818    }
819
820    /// Enforce resource limits and trigger degradation if exceeded
821    ///
822    /// Checks current resource usage against configured limits and automatically
823    /// transitions to Degraded state if any limit is exceeded. This method should
824    /// be called after operations that modify index size (file additions, parse
825    /// completions, etc.).
826    ///
827    /// # State Transitions
828    ///
829    /// - `Ready` → `Degraded(ResourceLimit)` if limits exceeded
830    /// - `Building` → `Degraded(ResourceLimit)` if limits exceeded
831    ///
832    /// # Returns
833    ///
834    /// Nothing. The coordinator state is updated in-place when limits are exceeded.
835    ///
836    /// # Examples
837    ///
838    /// ```rust,ignore
839    /// use perl_parser::workspace_index::IndexCoordinator;
840    ///
841    /// let coordinator = IndexCoordinator::new();
842    /// // ... index some files ...
843    /// coordinator.enforce_limits();  // Check and degrade if needed
844    /// ```
845    pub fn enforce_limits(&self) {
846        if let Some(reason) = self.check_limits() {
847            self.transition_to_degraded(reason);
848        }
849    }
850
851    /// Record an early-exit event for indexing instrumentation
852    pub fn record_early_exit(
853        &self,
854        reason: EarlyExitReason,
855        elapsed_ms: u64,
856        indexed_files: usize,
857        total_files: usize,
858    ) {
859        self.instrumentation.record_early_exit(EarlyExitRecord {
860            reason,
861            elapsed_ms,
862            indexed_files,
863            total_files,
864        });
865    }
866
867    /// Query with automatic degradation handling
868    ///
869    /// Dispatches to full query if index is Ready, or partial query otherwise.
870    /// This pattern enables LSP handlers to provide appropriate responses
871    /// based on index state without explicit state checking.
872    ///
873    /// # Type Parameters
874    ///
875    /// * `T` - Return type of the query functions
876    /// * `F1` - Full query function type accepting `&WorkspaceIndex` and returning `T`
877    /// * `F2` - Partial query function type accepting `&WorkspaceIndex` and returning `T`
878    ///
879    /// # Arguments
880    ///
881    /// * `full_query` - Function to execute when index is Ready
882    /// * `partial_query` - Function to execute when index is Building/Degraded
883    ///
884    /// # Returns
885    ///
886    /// The value returned by the selected query function.
887    ///
888    /// # Examples
889    ///
890    /// ```rust,ignore
891    /// use perl_parser::workspace_index::IndexCoordinator;
892    ///
893    /// let coordinator = IndexCoordinator::new();
894    /// let locations = coordinator.query(
895    ///     |index| index.find_references("my_function"),  // Full workspace search
896    ///     |index| vec![]                                 // Empty fallback
897    /// );
898    /// ```
899    pub fn query<T, F1, F2>(&self, full_query: F1, partial_query: F2) -> T
900    where
901        F1: FnOnce(&WorkspaceIndex) -> T,
902        F2: FnOnce(&WorkspaceIndex) -> T,
903    {
904        match self.state() {
905            IndexState::Ready { .. } => full_query(&self.index),
906            _ => partial_query(&self.index),
907        }
908    }
909}
910
911impl Default for IndexCoordinator {
912    fn default() -> Self {
913        Self::new()
914    }
915}
916
917// ============================================================================
918// Symbol Indexing Types
919// ============================================================================
920
921#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
922/// Symbol kinds for cross-file indexing during Index/Navigate workflows.
923pub enum SymKind {
924    /// Variable symbol ($, @, or % sigil)
925    Var,
926    /// Subroutine definition (sub foo)
927    Sub,
928    /// Package declaration (package Foo)
929    Pack,
930}
931
932#[derive(Clone, Debug, Eq, PartialEq, Hash)]
933/// A normalized symbol key for cross-file lookups in Index/Navigate workflows.
934pub struct SymbolKey {
935    /// Package name containing this symbol
936    pub pkg: Arc<str>,
937    /// Bare name without sigil prefix
938    pub name: Arc<str>,
939    /// Variable sigil ($, @, or %) if applicable
940    pub sigil: Option<char>,
941    /// Kind of symbol (variable, subroutine, package)
942    pub kind: SymKind,
943}
944
945/// Normalize a Perl variable name for Index/Analyze workflows.
946///
947/// Extracts an optional sigil and bare name for consistent symbol indexing.
948///
949/// # Arguments
950///
951/// * `name` - Variable name from Perl source, with or without sigil.
952///
953/// # Returns
954///
955/// `(sigil, name)` tuple with the optional sigil and normalized identifier.
956///
957/// # Examples
958///
959/// ```rust,ignore
960/// use perl_parser::workspace_index::normalize_var;
961///
962/// assert_eq!(normalize_var("$count"), (Some('$'), "count"));
963/// assert_eq!(normalize_var("process_emails"), (None, "process_emails"));
964/// ```
965pub fn normalize_var(name: &str) -> (Option<char>, &str) {
966    if name.is_empty() {
967        return (None, "");
968    }
969
970    // Safe: we've checked that name is not empty
971    let Some(first_char) = name.chars().next() else {
972        return (None, name); // Should never happen but handle gracefully
973    };
974    match first_char {
975        '$' | '@' | '%' => {
976            if name.len() > 1 {
977                (Some(first_char), &name[1..])
978            } else {
979                (Some(first_char), "")
980            }
981        }
982        _ => (None, name),
983    }
984}
985
986// Using lsp_types for Position and Range
987
988#[derive(Debug, Clone, PartialEq, Eq)]
989/// Internal location type used during Navigate/Analyze workflows.
990pub struct Location {
991    /// File URI where the symbol is located
992    pub uri: String,
993    /// Line and character range within the file
994    pub range: Range,
995}
996
997#[derive(Debug, Clone, PartialEq, Eq)]
998/// Stable symbol identity returned by cross-file reference queries.
999pub struct SymbolIdentity {
1000    /// Canonical stable key for the symbol (qualified when available).
1001    pub stable_key: String,
1002    /// Bare symbol name.
1003    pub name: String,
1004    /// Fully qualified symbol name when available.
1005    pub qualified_name: Option<String>,
1006    /// Symbol kind (subroutine, package, variable, ...).
1007    pub kind: SymbolKind,
1008}
1009
1010#[derive(Debug, Clone, PartialEq, Eq)]
1011/// Read-only cross-file query result used by rename/safe-delete planners.
1012pub struct CrossFileReferenceQueryResult {
1013    /// Identity for the resolved symbol.
1014    pub symbol: SymbolIdentity,
1015    /// Definition site for the resolved symbol.
1016    pub definition: Location,
1017    /// All reference locations (including definition) in deterministic order.
1018    pub references: Vec<Location>,
1019}
1020
1021#[derive(Debug, Clone, Serialize, Deserialize)]
1022/// A symbol in the workspace for Index/Navigate workflows.
1023pub struct WorkspaceSymbol {
1024    /// Symbol name without package qualification
1025    pub name: String,
1026    /// Type of symbol (subroutine, variable, package, etc.)
1027    pub kind: SymbolKind,
1028    /// File URI where the symbol is defined
1029    pub uri: String,
1030    /// Line and character range of the symbol definition
1031    pub range: Range,
1032    /// Fully qualified name including package (e.g., "Package::function")
1033    pub qualified_name: Option<String>,
1034    /// POD documentation associated with the symbol
1035    pub documentation: Option<String>,
1036    /// Name of the containing package or class
1037    pub container_name: Option<String>,
1038    /// Whether this symbol has a body (false for forward declarations)
1039    #[serde(default = "default_has_body")]
1040    pub has_body: bool,
1041    /// Workspace folder URI this symbol belongs to (for multi-root workspace support)
1042    pub workspace_folder_uri: Option<String>,
1043}
1044
1045fn default_has_body() -> bool {
1046    true
1047}
1048
1049// Re-export the unified symbol types from perl-symbol
1050/// Symbol kind enums used during Index/Analyze workflows.
1051pub use perl_symbol::{SymbolKind, VarKind};
1052
1053#[derive(Debug, Clone)]
1054/// Reference to a symbol for Navigate/Analyze workflows.
1055pub struct SymbolReference {
1056    /// File URI where the reference occurs
1057    pub uri: String,
1058    /// Line and character range of the reference
1059    pub range: Range,
1060    /// How the symbol is being referenced (definition, usage, etc.)
1061    pub kind: ReferenceKind,
1062}
1063
1064#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1065/// Classification of how a symbol is referenced in Navigate/Analyze workflows.
1066pub enum ReferenceKind {
1067    /// Symbol definition site (sub declaration, variable declaration)
1068    Definition,
1069    /// General usage of the symbol (function call, method call)
1070    Usage,
1071    /// Import via use statement
1072    Import,
1073    /// Variable read access
1074    Read,
1075    /// Variable write access (assignment target)
1076    Write,
1077}
1078
1079#[derive(Debug, Serialize)]
1080#[serde(rename_all = "camelCase")]
1081/// LSP-compliant workspace symbol for wire format in Navigate/Analyze workflows.
1082pub struct LspWorkspaceSymbol {
1083    /// Symbol name as displayed to the user
1084    pub name: String,
1085    /// LSP symbol kind number (see lsp_types::SymbolKind)
1086    pub kind: u32,
1087    /// Location of the symbol definition
1088    pub location: WireLocation,
1089    /// Name of the containing symbol (package, class)
1090    #[serde(skip_serializing_if = "Option::is_none")]
1091    pub container_name: Option<String>,
1092    /// Workspace folder URI this symbol belongs to (for multi-root workspace disambiguation)
1093    #[serde(skip_serializing_if = "Option::is_none")]
1094    pub workspace_folder_uri: Option<String>,
1095}
1096
1097impl From<&WorkspaceSymbol> for LspWorkspaceSymbol {
1098    fn from(sym: &WorkspaceSymbol) -> Self {
1099        let range = WireRange {
1100            start: WirePosition { line: sym.range.start.line, character: sym.range.start.column },
1101            end: WirePosition { line: sym.range.end.line, character: sym.range.end.column },
1102        };
1103
1104        Self {
1105            name: sym.name.clone(),
1106            kind: sym.kind.to_lsp_kind(),
1107            location: WireLocation { uri: sym.uri.clone(), range },
1108            container_name: sym.container_name.clone(),
1109            workspace_folder_uri: sym.workspace_folder_uri.clone(),
1110        }
1111    }
1112}
1113
1114/// File-level index data
1115#[derive(Default, Clone)]
1116pub struct FileIndex {
1117    /// Canonical file URI for this index entry.
1118    source_uri: String,
1119    /// Symbols defined in this file
1120    symbols: Vec<WorkspaceSymbol>,
1121    /// References in this file (symbol name -> references)
1122    references: HashMap<String, Vec<SymbolReference>>,
1123    /// Dependencies (modules this file imports)
1124    dependencies: HashSet<String>,
1125    /// Content hash for early-exit optimization
1126    content_hash: u64,
1127    /// Workspace folder URI this file belongs to (for multi-root workspace support)
1128    folder_uri: Option<String>,
1129}
1130
1131/// Write-through semantic fact storage for one indexed file.
1132#[derive(Clone)]
1133pub struct FileFactShard {
1134    /// Canonical file URI for this shard.
1135    pub source_uri: String,
1136    /// Stable file identifier derived from normalized URI.
1137    pub file_id: FileId,
1138    /// Whole-file content hash used for stale-shard replacement.
1139    pub content_hash: u64,
1140    /// Optional per-category hashes for change diagnostics.
1141    pub anchors_hash: Option<u64>,
1142    /// Optional per-category hashes for change diagnostics.
1143    pub entities_hash: Option<u64>,
1144    /// Optional per-category hashes for change diagnostics.
1145    pub occurrences_hash: Option<u64>,
1146    /// Optional per-category hashes for change diagnostics.
1147    pub edges_hash: Option<u64>,
1148    /// Anchor facts for this file.
1149    pub anchors: Vec<AnchorFact>,
1150    /// Entity facts for this file.
1151    pub entities: Vec<EntityFact>,
1152    /// Occurrence facts for this file.
1153    pub occurrences: Vec<perl_semantic_facts::OccurrenceFact>,
1154    /// Edge facts for this file.
1155    pub edges: Vec<EdgeFact>,
1156}
1157
1158/// Thread-safe workspace index
1159pub struct WorkspaceIndex {
1160    /// Index data per file URI (normalized key -> data)
1161    files: Arc<RwLock<HashMap<String, FileIndex>>>,
1162    /// Global symbol multimap (qualified/bare name -> ordered definition candidates)
1163    symbols: Arc<RwLock<HashMap<String, Vec<DefinitionCandidate>>>>,
1164    /// Global reference index (symbol name -> locations across all files)
1165    ///
1166    /// Aggregated from per-file `FileIndex::references` during `index_file()`.
1167    /// Provides O(1) lookup for `find_references()` instead of iterating all files.
1168    global_references: Arc<RwLock<HashMap<String, Vec<Location>>>>,
1169    /// Write-through semantic fact shards keyed by normalized URI.
1170    fact_shards: Arc<RwLock<HashMap<String, FileFactShard>>>,
1171    /// Document store for in-memory text
1172    document_store: DocumentStore,
1173    /// Workspace folder URIs for multi-root workspace support
1174    ///
1175    /// Used to determine which workspace folder a file belongs to for
1176    /// proper folder attribution in multi-root workspaces.
1177    workspace_folders: Arc<RwLock<Vec<String>>>,
1178}
1179
1180#[derive(Debug, Clone, Eq, PartialEq)]
1181struct DefinitionCandidate {
1182    location: Location,
1183    kind: SymbolKind,
1184}
1185
1186impl WorkspaceIndex {
1187    fn location_sort_key(location: &Location) -> (&str, u32, u32, u32, u32) {
1188        (
1189            location.uri.as_str(),
1190            location.range.start.line,
1191            location.range.start.column,
1192            location.range.end.line,
1193            location.range.end.column,
1194        )
1195    }
1196
1197    fn sort_locations_deterministically(locations: &mut [Location]) {
1198        locations.sort_by(|left, right| {
1199            Self::location_sort_key(left).cmp(&Self::location_sort_key(right))
1200        });
1201    }
1202
1203    fn definition_candidate_sort_key(
1204        candidate: &DefinitionCandidate,
1205    ) -> (u8, &str, u32, u32, u32, u32) {
1206        let rank = match candidate.kind {
1207            SymbolKind::Subroutine | SymbolKind::Method => 0,
1208            SymbolKind::Constant => 1,
1209            _ => 2,
1210        };
1211        (
1212            rank,
1213            candidate.location.uri.as_str(),
1214            candidate.location.range.start.line,
1215            candidate.location.range.start.column,
1216            candidate.location.range.end.line,
1217            candidate.location.range.end.column,
1218        )
1219    }
1220
1221    fn rebuild_symbol_cache(
1222        files: &HashMap<String, FileIndex>,
1223        symbols: &mut HashMap<String, Vec<DefinitionCandidate>>,
1224    ) {
1225        symbols.clear();
1226
1227        for file_index in files.values() {
1228            for symbol in &file_index.symbols {
1229                if let Some(ref qname) = symbol.qualified_name {
1230                    symbols.entry(qname.clone()).or_default().push(DefinitionCandidate {
1231                        location: Location { uri: symbol.uri.clone(), range: symbol.range },
1232                        kind: symbol.kind,
1233                    });
1234                }
1235                symbols.entry(symbol.name.clone()).or_default().push(DefinitionCandidate {
1236                    location: Location { uri: symbol.uri.clone(), range: symbol.range },
1237                    kind: symbol.kind,
1238                });
1239            }
1240        }
1241        for entries in symbols.values_mut() {
1242            entries.sort_by(|left, right| {
1243                Self::definition_candidate_sort_key(left)
1244                    .cmp(&Self::definition_candidate_sort_key(right))
1245            });
1246            entries.dedup();
1247        }
1248    }
1249
1250    /// Incrementally remove one file's symbols from the global cache,
1251    /// re-inserting shadowed symbols from remaining files.
1252    fn incremental_remove_symbols(
1253        files: &HashMap<String, FileIndex>,
1254        symbols: &mut HashMap<String, Vec<DefinitionCandidate>>,
1255        old_file_index: &FileIndex,
1256    ) {
1257        let mut affected_names: Vec<String> = Vec::new();
1258        for sym in &old_file_index.symbols {
1259            if let Some(ref qname) = sym.qualified_name {
1260                let mut remove_key = false;
1261                if let Some(entries) = symbols.get_mut(qname) {
1262                    entries.retain(|candidate| candidate.location.uri != sym.uri);
1263                    remove_key = entries.is_empty();
1264                }
1265                if remove_key {
1266                    symbols.remove(qname);
1267                    affected_names.push(qname.clone());
1268                }
1269            }
1270            let mut remove_key = false;
1271            if let Some(entries) = symbols.get_mut(&sym.name) {
1272                entries.retain(|candidate| candidate.location.uri != sym.uri);
1273                remove_key = entries.is_empty();
1274            }
1275            if remove_key {
1276                symbols.remove(&sym.name);
1277                affected_names.push(sym.name.clone());
1278            }
1279        }
1280        if !affected_names.is_empty() {
1281            symbols.clear();
1282            for file_index in files
1283                .values()
1284                .filter(|file_index| file_index.source_uri != old_file_index.source_uri)
1285            {
1286                for symbol in &file_index.symbols {
1287                    if let Some(ref qname) = symbol.qualified_name {
1288                        symbols.entry(qname.clone()).or_default().push(DefinitionCandidate {
1289                            location: Location { uri: symbol.uri.clone(), range: symbol.range },
1290                            kind: symbol.kind,
1291                        });
1292                    }
1293                    symbols.entry(symbol.name.clone()).or_default().push(DefinitionCandidate {
1294                        location: Location { uri: symbol.uri.clone(), range: symbol.range },
1295                        kind: symbol.kind,
1296                    });
1297                }
1298            }
1299            for entries in symbols.values_mut() {
1300                entries.sort_by(|left, right| {
1301                    Self::definition_candidate_sort_key(left)
1302                        .cmp(&Self::definition_candidate_sort_key(right))
1303                });
1304                entries.dedup();
1305            }
1306        }
1307    }
1308
1309    /// Incrementally add one file's symbols to the global cache.
1310    fn incremental_add_symbols(
1311        symbols: &mut HashMap<String, Vec<DefinitionCandidate>>,
1312        file_index: &FileIndex,
1313    ) {
1314        for sym in &file_index.symbols {
1315            if let Some(ref qname) = sym.qualified_name {
1316                symbols.entry(qname.clone()).or_default().push(DefinitionCandidate {
1317                    location: Location { uri: sym.uri.clone(), range: sym.range },
1318                    kind: sym.kind,
1319                });
1320            }
1321            symbols.entry(sym.name.clone()).or_default().push(DefinitionCandidate {
1322                location: Location { uri: sym.uri.clone(), range: sym.range },
1323                kind: sym.kind,
1324            });
1325        }
1326        for entries in symbols.values_mut() {
1327            entries.sort_by(|left, right| {
1328                Self::definition_candidate_sort_key(left)
1329                    .cmp(&Self::definition_candidate_sort_key(right))
1330            });
1331            entries.dedup();
1332        }
1333    }
1334
1335    /// Determine the workspace folder URI for a given file URI.
1336    ///
1337    /// Returns the workspace folder URI that contains the given file URI.
1338    /// This is used for multi-root workspace support to properly attribute
1339    /// files and symbols to their originating workspace folder.
1340    ///
1341    /// # Arguments
1342    ///
1343    /// * `file_uri` - The file URI to find the containing workspace folder for
1344    ///
1345    /// # Returns
1346    ///
1347    /// `Some(folder_uri)` if the file is within a workspace folder, `None` otherwise.
1348    ///
1349    /// # Examples
1350    ///
1351    /// ```rust,ignore
1352    /// use perl_workspace::workspace::workspace_index::WorkspaceIndex;
1353    ///
1354    /// let index = WorkspaceIndex::new();
1355    /// index.set_workspace_folders(vec![
1356    ///     "file:///project1".to_string(),
1357    ///     "file:///project2".to_string(),
1358    /// ]);
1359    ///
1360    /// let folder = index.determine_folder_uri("file:///project1/src/main.pl");
1361    /// assert_eq!(folder, Some("file:///project1".to_string()));
1362    /// ```
1363    fn determine_folder_uri(&self, file_uri: &str) -> Option<String> {
1364        let folders = self.workspace_folders.read();
1365        let mut best_match: Option<&String> = None;
1366        for folder_uri in folders.iter() {
1367            // Check if the file URI starts with the folder URI
1368            // We need to ensure proper URI matching (with or without trailing slash)
1369            let folder_with_slash = if folder_uri.ends_with('/') {
1370                folder_uri.clone()
1371            } else {
1372                format!("{}/", folder_uri)
1373            };
1374            if file_uri.starts_with(&folder_with_slash) || file_uri == folder_uri {
1375                match best_match {
1376                    Some(existing) if existing.len() >= folder_uri.len() => {}
1377                    _ => best_match = Some(folder_uri),
1378                }
1379            }
1380        }
1381        best_match.cloned()
1382    }
1383
1384    fn find_definition_in_files(
1385        files: &HashMap<String, FileIndex>,
1386        symbol_name: &str,
1387        uri_filter: Option<&str>,
1388    ) -> Option<(Location, String)> {
1389        let mut candidates: Vec<(Location, String)> = Vec::new();
1390        for file_index in files.values() {
1391            if let Some(filter) = uri_filter
1392                && file_index.symbols.first().is_some_and(|symbol| symbol.uri != filter)
1393            {
1394                continue;
1395            }
1396
1397            for symbol in &file_index.symbols {
1398                if symbol.name == symbol_name
1399                    || symbol.qualified_name.as_deref() == Some(symbol_name)
1400                {
1401                    candidates.push((
1402                        Location { uri: symbol.uri.clone(), range: symbol.range },
1403                        symbol.uri.clone(),
1404                    ));
1405                }
1406            }
1407        }
1408
1409        candidates.sort_by(|left, right| {
1410            Self::location_sort_key(&left.0).cmp(&Self::location_sort_key(&right.0))
1411        });
1412        candidates.into_iter().next()
1413    }
1414
1415    fn find_symbol_by_definition(
1416        &self,
1417        definition: &Location,
1418        symbol_name: &str,
1419    ) -> Option<WorkspaceSymbol> {
1420        let files = self.files.read();
1421        files
1422            .values()
1423            .flat_map(|file_index| file_index.symbols.iter())
1424            .filter(|symbol| {
1425                symbol.uri == definition.uri
1426                    && symbol.range == definition.range
1427                    && (symbol.name == symbol_name
1428                        || symbol.qualified_name.as_deref() == Some(symbol_name))
1429            })
1430            .min_by(|left, right| {
1431                (
1432                    left.qualified_name.as_deref().unwrap_or_default(),
1433                    left.name.as_str(),
1434                    left.kind.to_lsp_kind(),
1435                )
1436                    .cmp(&(
1437                        right.qualified_name.as_deref().unwrap_or_default(),
1438                        right.name.as_str(),
1439                        right.kind.to_lsp_kind(),
1440                    ))
1441            })
1442            .cloned()
1443    }
1444
1445    fn has_unique_symbol_name_and_kind(&self, target: &WorkspaceSymbol) -> bool {
1446        let files = self.files.read();
1447        files
1448            .values()
1449            .flat_map(|file_index| file_index.symbols.iter())
1450            .filter(|symbol| symbol.name == target.name && symbol.kind == target.kind)
1451            .take(2)
1452            .count()
1453            == 1
1454    }
1455
1456    fn collect_symbol_references(&self, symbol: &WorkspaceSymbol) -> Vec<Location> {
1457        let mut names_to_query: Vec<&str> = Vec::new();
1458        if let Some(qualified_name) = symbol.qualified_name.as_deref() {
1459            names_to_query.push(qualified_name);
1460            if self.has_unique_symbol_name_and_kind(symbol) {
1461                names_to_query.push(symbol.name.as_str());
1462            }
1463        } else {
1464            names_to_query.push(symbol.name.as_str());
1465        }
1466
1467        let global_refs = self.global_references.read();
1468        let mut seen: HashSet<(String, u32, u32, u32, u32)> = HashSet::new();
1469        let mut locations = Vec::new();
1470
1471        for symbol_name in names_to_query {
1472            if let Some(refs) = global_refs.get(symbol_name) {
1473                for location in refs {
1474                    let key = (
1475                        location.uri.clone(),
1476                        location.range.start.line,
1477                        location.range.start.column,
1478                        location.range.end.line,
1479                        location.range.end.column,
1480                    );
1481                    if seen.insert(key) {
1482                        locations.push(location.clone());
1483                    }
1484                }
1485            }
1486        }
1487        drop(global_refs);
1488
1489        Self::sort_locations_deterministically(&mut locations);
1490        locations
1491    }
1492
1493    /// Create a new empty index
1494    ///
1495    /// # Returns
1496    ///
1497    /// A workspace index with empty file and symbol tables.
1498    ///
1499    /// # Examples
1500    ///
1501    /// ```rust,ignore
1502    /// use perl_parser::workspace_index::WorkspaceIndex;
1503    ///
1504    /// let index = WorkspaceIndex::new();
1505    /// assert!(!index.has_symbols());
1506    /// ```
1507    pub fn new() -> Self {
1508        Self {
1509            files: Arc::new(RwLock::new(HashMap::new())),
1510            symbols: Arc::new(RwLock::new(HashMap::new())),
1511            global_references: Arc::new(RwLock::new(HashMap::new())),
1512            fact_shards: Arc::new(RwLock::new(HashMap::new())),
1513            document_store: DocumentStore::new(),
1514            workspace_folders: Arc::new(RwLock::new(Vec::new())),
1515        }
1516    }
1517
1518    /// Create a workspace index with pre-allocated capacity.
1519    ///
1520    /// Pre-allocating reduces the number of rehash operations during large-workspace
1521    /// startup. Use this instead of `new()` when the approximate workspace size is
1522    /// known in advance (e.g. from a file discovery scan).
1523    ///
1524    /// # Arguments
1525    ///
1526    /// * `estimated_files` - Expected number of source files in the workspace.
1527    /// * `avg_symbols_per_file` - Expected average number of symbols per file.
1528    ///
1529    /// # Panics
1530    ///
1531    /// Does not panic. Overflow is prevented via `saturating_mul` and an upper cap
1532    /// on the symbol/reference map capacity.
1533    ///
1534    /// # Examples
1535    ///
1536    /// ```rust,ignore
1537    /// use perl_workspace::workspace::workspace_index::WorkspaceIndex;
1538    ///
1539    /// let index = WorkspaceIndex::with_capacity(1000, 20);
1540    /// assert!(!index.has_symbols());
1541    /// ```
1542    pub fn with_capacity(estimated_files: usize, avg_symbols_per_file: usize) -> Self {
1543        // Each symbol is stored twice (qualified + bare name) due to dual indexing.
1544        let sym_cap =
1545            estimated_files.saturating_mul(avg_symbols_per_file).saturating_mul(2).min(1_000_000);
1546        let ref_cap = (sym_cap / 4).min(1_000_000);
1547        Self {
1548            files: Arc::new(RwLock::new(HashMap::with_capacity(estimated_files))),
1549            symbols: Arc::new(RwLock::new(HashMap::with_capacity(sym_cap))),
1550            global_references: Arc::new(RwLock::new(HashMap::with_capacity(ref_cap))),
1551            fact_shards: Arc::new(RwLock::new(HashMap::with_capacity(estimated_files))),
1552            document_store: DocumentStore::new(),
1553            workspace_folders: Arc::new(RwLock::new(Vec::new())),
1554        }
1555    }
1556
1557    /// Set the workspace folder URIs for multi-root workspace support.
1558    ///
1559    /// This method updates the list of workspace folders that the index
1560    /// uses to determine folder attribution for files and symbols.
1561    ///
1562    /// # Arguments
1563    ///
1564    /// * `folders` - A vector of workspace folder URIs
1565    ///
1566    /// # Examples
1567    ///
1568    /// ```rust,ignore
1569    /// use perl_workspace::workspace::workspace_index::WorkspaceIndex;
1570    ///
1571    /// let index = WorkspaceIndex::new();
1572    /// index.set_workspace_folders(vec![
1573    ///     "file:///project1".to_string(),
1574    ///     "file:///project2".to_string(),
1575    /// ]);
1576    /// ```
1577    pub fn set_workspace_folders(&self, folders: Vec<String>) {
1578        let mut workspace_folders = self.workspace_folders.write();
1579        *workspace_folders = folders;
1580    }
1581
1582    /// Get the current workspace folder URIs.
1583    ///
1584    /// # Returns
1585    ///
1586    /// A vector of workspace folder URIs.
1587    #[must_use]
1588    pub fn workspace_folders(&self) -> Vec<String> {
1589        self.workspace_folders.read().clone()
1590    }
1591
1592    /// Normalize a URI to a consistent form using proper URI handling
1593    fn normalize_uri(uri: &str) -> String {
1594        perl_uri::normalize_uri(uri)
1595    }
1596
1597    /// Remove a file's contributions from the global reference index.
1598    ///
1599    /// Retains only entries whose URI does not match `file_uri`.
1600    /// Empty keys are removed to avoid unbounded map growth.
1601    fn remove_file_global_refs(
1602        global_refs: &mut HashMap<String, Vec<Location>>,
1603        file_index: &FileIndex,
1604        file_uri: &str,
1605    ) {
1606        for name in file_index.references.keys() {
1607            if let Some(locs) = global_refs.get_mut(name) {
1608                locs.retain(|loc| loc.uri != file_uri);
1609                if locs.is_empty() {
1610                    global_refs.remove(name);
1611                }
1612            }
1613        }
1614    }
1615
1616    /// Index a file from its URI and text content
1617    ///
1618    /// # Arguments
1619    ///
1620    /// * `uri` - File URI identifying the document
1621    /// * `text` - Full Perl source text for indexing
1622    ///
1623    /// # Returns
1624    ///
1625    /// `Ok(())` when indexing succeeds, or an error message otherwise.
1626    ///
1627    /// # Errors
1628    ///
1629    /// Returns an error if parsing fails or the document store cannot be updated.
1630    ///
1631    /// # Examples
1632    ///
1633    /// ```rust,ignore
1634    /// use perl_parser::workspace_index::WorkspaceIndex;
1635    /// use url::Url;
1636    ///
1637    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
1638    /// let index = WorkspaceIndex::new();
1639    /// let uri = Url::parse("file:///example.pl")?;
1640    /// index.index_file(uri, "sub hello { return 1; }".to_string())?;
1641    /// # Ok(())
1642    /// # }
1643    /// ```
1644    ///
1645    /// Returns: `Ok(())` when indexing succeeds, otherwise an error string.
1646    pub fn index_file(&self, uri: Url, text: String) -> Result<(), String> {
1647        let uri_str = uri.to_string();
1648
1649        // Compute content hash for early-exit optimization
1650        let mut hasher = DefaultHasher::new();
1651        text.hash(&mut hasher);
1652        let content_hash = hasher.finish();
1653
1654        // Check if content is unchanged (early-exit optimization)
1655        let key = DocumentStore::uri_key(&uri_str);
1656        {
1657            let files = self.files.read();
1658            if let Some(existing_index) = files.get(&key) {
1659                if existing_index.content_hash == content_hash {
1660                    // Content unchanged, skip re-indexing
1661                    return Ok(());
1662                }
1663            }
1664        }
1665
1666        // Update document store
1667        if self.document_store.is_open(&uri_str) {
1668            self.document_store.update(&uri_str, 1, text.clone());
1669        } else {
1670            self.document_store.open(uri_str.clone(), 1, text.clone());
1671        }
1672
1673        // Parse the file
1674        let mut parser = Parser::new(&text);
1675        let ast = match parser.parse() {
1676            Ok(ast) => ast,
1677            Err(e) => return Err(format!("Parse error: {}", e)),
1678        };
1679
1680        // Get the document for line index
1681        let mut doc = self.document_store.get(&uri_str).ok_or("Document not found")?;
1682
1683        // Determine workspace folder URI from the file URI
1684        let folder_uri = self.determine_folder_uri(&uri_str);
1685
1686        // Extract symbols and references
1687        let mut file_index = FileIndex {
1688            source_uri: uri_str.clone(),
1689            content_hash,
1690            folder_uri: folder_uri.clone(),
1691            ..Default::default()
1692        };
1693        let mut visitor = IndexVisitor::new(&mut doc, uri_str.clone(), folder_uri);
1694        visitor.visit(&ast, &mut file_index);
1695
1696        let fact_shard = Self::build_fact_shard(&uri_str, content_hash, &file_index);
1697
1698        // Update the index, refresh the global symbol cache, and replace this file's
1699        // contribution in the global reference index.
1700        {
1701            let mut files = self.files.write();
1702
1703            // Remove stale global references from previous version of this file
1704            if let Some(old_index) = files.get(&key) {
1705                let mut global_refs = self.global_references.write();
1706                Self::remove_file_global_refs(&mut global_refs, old_index, &uri_str);
1707            }
1708
1709            // Incrementally remove old symbols before inserting new file
1710            if let Some(old_index) = files.get(&key) {
1711                let mut symbols = self.symbols.write();
1712                Self::incremental_remove_symbols(&files, &mut symbols, old_index);
1713                drop(symbols);
1714            }
1715            files.insert(key.clone(), file_index);
1716            let mut symbols = self.symbols.write();
1717            if let Some(new_index) = files.get(&key) {
1718                Self::incremental_add_symbols(&mut symbols, new_index);
1719            }
1720
1721            if let Some(file_index) = files.get(&key) {
1722                let mut global_refs = self.global_references.write();
1723                for (name, refs) in &file_index.references {
1724                    let entry = global_refs.entry(name.clone()).or_default();
1725                    for reference in refs {
1726                        entry.push(Location { uri: reference.uri.clone(), range: reference.range });
1727                    }
1728                }
1729            }
1730            self.fact_shards.write().insert(key, fact_shard);
1731        }
1732
1733        Ok(())
1734    }
1735
1736    /// Remove a file from the index
1737    ///
1738    /// # Arguments
1739    ///
1740    /// * `uri` - File URI (string form) to remove
1741    ///
1742    /// # Returns
1743    ///
1744    /// Nothing. The index is updated in-place.
1745    ///
1746    /// # Examples
1747    ///
1748    /// ```rust,ignore
1749    /// use perl_parser::workspace_index::WorkspaceIndex;
1750    ///
1751    /// let index = WorkspaceIndex::new();
1752    /// index.remove_file("file:///example.pl");
1753    /// ```
1754    pub fn remove_file(&self, uri: &str) {
1755        let uri_str = Self::normalize_uri(uri);
1756        let key = DocumentStore::uri_key(&uri_str);
1757
1758        // Remove from document store
1759        self.document_store.close(&uri_str);
1760
1761        // Remove file index
1762        let mut files = self.files.write();
1763        if let Some(file_index) = files.remove(&key) {
1764            self.fact_shards.write().remove(&key);
1765            // Incrementally remove symbols and re-insert any shadowed names.
1766            let mut symbols = self.symbols.write();
1767            Self::incremental_remove_symbols(&files, &mut symbols, &file_index);
1768
1769            // Defensive sweep: purge any remaining cache entries whose value
1770            // points to this file's URI.  incremental_remove_symbols already
1771            // handles known symbol names; this sweep catches any entries that
1772            // were inserted via the find_definition fallback path using a key
1773            // that differs from both sym.name and sym.qualified_name.
1774            // Use the URI stored in the file_index itself (not the caller-supplied
1775            // uri_str) so the comparison is always against the exact string that
1776            // was stored during indexing.
1777            if let Some(indexed_uri) = file_index.symbols.first().map(|s| s.uri.as_str()) {
1778                symbols.retain(|_, candidates| {
1779                    candidates.retain(|candidate| candidate.location.uri.as_str() != indexed_uri);
1780                    !candidates.is_empty()
1781                });
1782            }
1783
1784            // Remove from global reference index
1785            let mut global_refs = self.global_references.write();
1786            Self::remove_file_global_refs(&mut global_refs, &file_index, &uri_str);
1787        }
1788    }
1789
1790    /// Remove a file from the index (URL variant for compatibility)
1791    ///
1792    /// # Arguments
1793    ///
1794    /// * `uri` - File URI as a parsed `Url`
1795    ///
1796    /// # Returns
1797    ///
1798    /// Nothing. The index is updated in-place.
1799    ///
1800    /// # Examples
1801    ///
1802    /// ```rust,ignore
1803    /// use perl_parser::workspace_index::WorkspaceIndex;
1804    /// use url::Url;
1805    ///
1806    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
1807    /// let index = WorkspaceIndex::new();
1808    /// let uri = Url::parse("file:///example.pl")?;
1809    /// index.remove_file_url(&uri);
1810    /// # Ok(())
1811    /// # }
1812    /// ```
1813    pub fn remove_file_url(&self, uri: &Url) {
1814        self.remove_file(uri.as_str())
1815    }
1816
1817    /// Clear a file from the index (alias for remove_file)
1818    ///
1819    /// # Arguments
1820    ///
1821    /// * `uri` - File URI (string form) to remove
1822    ///
1823    /// # Returns
1824    ///
1825    /// Nothing. The index is updated in-place.
1826    ///
1827    /// # Examples
1828    ///
1829    /// ```rust,ignore
1830    /// use perl_parser::workspace_index::WorkspaceIndex;
1831    ///
1832    /// let index = WorkspaceIndex::new();
1833    /// index.clear_file("file:///example.pl");
1834    /// ```
1835    pub fn clear_file(&self, uri: &str) {
1836        self.remove_file(uri);
1837    }
1838
1839    /// Clear a file from the index (URL variant for compatibility)
1840    ///
1841    /// # Arguments
1842    ///
1843    /// * `uri` - File URI as a parsed `Url`
1844    ///
1845    /// # Returns
1846    ///
1847    /// Nothing. The index is updated in-place.
1848    ///
1849    /// # Examples
1850    ///
1851    /// ```rust,ignore
1852    /// use perl_parser::workspace_index::WorkspaceIndex;
1853    /// use url::Url;
1854    ///
1855    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
1856    /// let index = WorkspaceIndex::new();
1857    /// let uri = Url::parse("file:///example.pl")?;
1858    /// index.clear_file_url(&uri);
1859    /// # Ok(())
1860    /// # }
1861    /// ```
1862    pub fn clear_file_url(&self, uri: &Url) {
1863        self.clear_file(uri.as_str())
1864    }
1865
1866    /// Remove all files from a specific workspace folder.
1867    ///
1868    /// This method removes all indexed files that belong to the given
1869    /// workspace folder URI. This is useful when a workspace folder is
1870    /// removed from the multi-root workspace.
1871    ///
1872    /// # Arguments
1873    ///
1874    /// * `folder_uri` - The workspace folder URI to remove files from
1875    ///
1876    /// # Examples
1877    ///
1878    /// ```rust,ignore
1879    /// use perl_workspace::workspace::workspace_index::WorkspaceIndex;
1880    ///
1881    /// let index = WorkspaceIndex::new();
1882    /// // Index files from multiple folders...
1883    /// index.remove_folder("file:///project1");
1884    /// ```
1885    pub fn remove_folder(&self, folder_uri: &str) {
1886        let mut uris_to_remove = Vec::new();
1887        let files = self.files.read();
1888
1889        // Collect all files that belong to this folder
1890        for file_index in files.values() {
1891            if file_index.folder_uri.as_deref() == Some(folder_uri) {
1892                uris_to_remove.push(file_index.source_uri.clone());
1893            }
1894        }
1895        drop(files);
1896
1897        // Remove each file through the full removal path to keep
1898        // symbol/reference caches and document store in sync.
1899        for uri in uris_to_remove {
1900            self.remove_file(&uri);
1901        }
1902    }
1903
1904    #[cfg(not(target_arch = "wasm32"))]
1905    /// Index a file from a URI string for the Index/Analyze workflow.
1906    ///
1907    /// Accepts either a `file://` URI or a filesystem path. Not available on
1908    /// wasm32 targets (requires filesystem path conversion).
1909    ///
1910    /// # Arguments
1911    ///
1912    /// * `uri` - File URI string or filesystem path.
1913    /// * `text` - Full Perl source text for indexing.
1914    ///
1915    /// # Returns
1916    ///
1917    /// `Ok(())` when indexing succeeds, or an error message otherwise.
1918    ///
1919    /// # Errors
1920    ///
1921    /// Returns an error if the URI is invalid or parsing fails.
1922    ///
1923    /// # Examples
1924    ///
1925    /// ```rust,ignore
1926    /// use perl_parser::workspace_index::WorkspaceIndex;
1927    ///
1928    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
1929    /// let index = WorkspaceIndex::new();
1930    /// index.index_file_str("file:///example.pl", "sub hello { }")?;
1931    /// # Ok(())
1932    /// # }
1933    /// ```
1934    pub fn index_file_str(&self, uri: &str, text: &str) -> Result<(), String> {
1935        let path = Path::new(uri);
1936        let url = if path.is_absolute() {
1937            url::Url::from_file_path(path)
1938                .map_err(|_| format!("Invalid URI or file path: {}", uri))?
1939        } else {
1940            // Raw absolute Windows paths like C:\foo can parse as a bogus URI
1941            // (`c:` scheme). Prefer URL parsing only for non-path inputs.
1942            url::Url::parse(uri).or_else(|_| {
1943                url::Url::from_file_path(path)
1944                    .map_err(|_| format!("Invalid URI or file path: {}", uri))
1945            })?
1946        };
1947        self.index_file(url, text.to_string())
1948    }
1949
1950    /// Index multiple files in a single batch operation.
1951    ///
1952    /// This is significantly faster than calling `index_file` in a loop for
1953    /// initial workspace scans because it defers the global symbol cache
1954    /// rebuild to a single pass at the end.
1955    ///
1956    /// Phase 1: Parse all files without holding locks.
1957    /// Phase 2: Bulk-insert file indices and rebuild the symbol cache once.
1958    pub fn index_files_batch(&self, files_to_index: Vec<(Url, String)>) -> Vec<String> {
1959        let mut errors = Vec::new();
1960
1961        // Phase 1: Parse all files without locks
1962        let mut parsed: Vec<(String, String, FileIndex)> = Vec::with_capacity(files_to_index.len());
1963        for (uri, text) in &files_to_index {
1964            let uri_str = uri.to_string();
1965
1966            // Content hash for early-exit
1967            let mut hasher = DefaultHasher::new();
1968            text.hash(&mut hasher);
1969            let content_hash = hasher.finish();
1970
1971            let key = DocumentStore::uri_key(&uri_str);
1972
1973            // Check if content unchanged
1974            {
1975                let files = self.files.read();
1976                if let Some(existing) = files.get(&key) {
1977                    if existing.content_hash == content_hash {
1978                        continue;
1979                    }
1980                }
1981            }
1982
1983            // Update document store
1984            if self.document_store.is_open(&uri_str) {
1985                self.document_store.update(&uri_str, 1, text.clone());
1986            } else {
1987                self.document_store.open(uri_str.clone(), 1, text.clone());
1988            }
1989
1990            // Parse
1991            let mut parser = Parser::new(text);
1992            let ast = match parser.parse() {
1993                Ok(ast) => ast,
1994                Err(e) => {
1995                    errors.push(format!("Parse error in {}: {}", uri_str, e));
1996                    continue;
1997                }
1998            };
1999
2000            let mut doc = match self.document_store.get(&uri_str) {
2001                Some(d) => d,
2002                None => {
2003                    errors.push(format!("Document not found: {}", uri_str));
2004                    continue;
2005                }
2006            };
2007
2008            // Determine workspace folder URI from the file URI
2009            let folder_uri = self.determine_folder_uri(&uri_str);
2010
2011            let mut file_index = FileIndex {
2012                source_uri: uri_str.clone(),
2013                content_hash,
2014                folder_uri: folder_uri.clone(),
2015                ..Default::default()
2016            };
2017            let mut visitor = IndexVisitor::new(&mut doc, uri_str.clone(), folder_uri);
2018            visitor.visit(&ast, &mut file_index);
2019
2020            parsed.push((key, uri_str, file_index));
2021        }
2022
2023        // Phase 2: Bulk insert with single cache rebuild
2024        {
2025            let mut files = self.files.write();
2026            let mut symbols = self.symbols.write();
2027            let mut global_refs = self.global_references.write();
2028
2029            // Pre-allocate capacity for the incoming batch to avoid rehashing.
2030            // Each symbol is indexed under both its qualified name and bare name.
2031            files.reserve(parsed.len());
2032            symbols.reserve(parsed.len().saturating_mul(20).saturating_mul(2));
2033
2034            for (key, uri_str, file_index) in parsed {
2035                // Remove stale global references
2036                if let Some(old_index) = files.get(&key) {
2037                    Self::remove_file_global_refs(&mut global_refs, old_index, &uri_str);
2038                }
2039
2040                files.insert(key.clone(), file_index);
2041
2042                // Add global references for this file
2043                if let Some(fi) = files.get(&key) {
2044                    for (name, refs) in &fi.references {
2045                        let entry = global_refs.entry(name.clone()).or_default();
2046                        for reference in refs {
2047                            entry.push(Location {
2048                                uri: reference.uri.clone(),
2049                                range: reference.range,
2050                            });
2051                        }
2052                    }
2053                }
2054            }
2055
2056            // Single rebuild at the end
2057            Self::rebuild_symbol_cache(&files, &mut symbols);
2058        }
2059
2060        errors
2061    }
2062
2063    /// Find all references to a symbol using dual indexing strategy
2064    ///
2065    /// This function searches for both exact matches and bare name matches when
2066    /// the symbol is qualified. For example, when searching for "Utils::process_data":
2067    /// - First searches for exact "Utils::process_data" references
2068    /// - Then searches for bare "process_data" references that might refer to the same function
2069    ///
2070    /// This dual approach handles cases where functions are called both as:
2071    /// - Qualified: `Utils::process_data()`
2072    /// - Unqualified: `process_data()` (when in the same package or imported)
2073    ///
2074    /// # Arguments
2075    ///
2076    /// * `symbol_name` - Symbol name or qualified name to search
2077    ///
2078    /// # Returns
2079    ///
2080    /// All reference locations found for the requested symbol.
2081    ///
2082    /// # Examples
2083    ///
2084    /// ```rust,ignore
2085    /// use perl_parser::workspace_index::WorkspaceIndex;
2086    ///
2087    /// let index = WorkspaceIndex::new();
2088    /// let _refs = index.find_references("Utils::process_data");
2089    /// ```
2090    pub fn find_references(&self, symbol_name: &str) -> Vec<Location> {
2091        let global_refs = self.global_references.read();
2092        let mut seen: HashSet<(String, u32, u32, u32, u32)> = HashSet::new();
2093        let mut locations = Vec::new();
2094
2095        // O(1) lookup for exact symbol name
2096        if let Some(refs) = global_refs.get(symbol_name) {
2097            for loc in refs {
2098                let key = (
2099                    loc.uri.clone(),
2100                    loc.range.start.line,
2101                    loc.range.start.column,
2102                    loc.range.end.line,
2103                    loc.range.end.column,
2104                );
2105                if seen.insert(key) {
2106                    locations.push(Location { uri: loc.uri.clone(), range: loc.range });
2107                }
2108            }
2109        }
2110
2111        // If the symbol is qualified, also collect bare name references
2112        if let Some(idx) = symbol_name.rfind("::") {
2113            let bare_name = &symbol_name[idx + 2..];
2114            if let Some(refs) = global_refs.get(bare_name) {
2115                for loc in refs {
2116                    let key = (
2117                        loc.uri.clone(),
2118                        loc.range.start.line,
2119                        loc.range.start.column,
2120                        loc.range.end.line,
2121                        loc.range.end.column,
2122                    );
2123                    if seen.insert(key) {
2124                        locations.push(Location { uri: loc.uri.clone(), range: loc.range });
2125                    }
2126                }
2127            }
2128        } else {
2129            // If the symbol is bare, also collect qualified references that end
2130            // with the same bare name, e.g. `Pkg::foo` when searching for `foo`.
2131            for (name, refs) in global_refs.iter() {
2132                if !Self::is_qualified_variant_of(name, symbol_name) {
2133                    continue;
2134                }
2135
2136                for loc in refs {
2137                    let key = (
2138                        loc.uri.clone(),
2139                        loc.range.start.line,
2140                        loc.range.start.column,
2141                        loc.range.end.line,
2142                        loc.range.end.column,
2143                    );
2144                    if seen.insert(key) {
2145                        locations.push(Location { uri: loc.uri.clone(), range: loc.range });
2146                    }
2147                }
2148            }
2149        }
2150
2151        Self::sort_locations_deterministically(&mut locations);
2152        locations
2153    }
2154
2155    /// Resolve a symbol and return its definition/reference set for cross-file planning.
2156    ///
2157    /// Returns `None` when no definition can be resolved for `symbol_name`.
2158    pub fn query_symbol_references(
2159        &self,
2160        symbol_name: &str,
2161    ) -> Option<CrossFileReferenceQueryResult> {
2162        let definition = self.find_definition(symbol_name)?;
2163        let symbol = self.find_symbol_by_definition(&definition, symbol_name)?;
2164
2165        let stable_key = symbol.qualified_name.clone().unwrap_or_else(|| {
2166            format!(
2167                "{}@{}:{}:{}",
2168                symbol.name, symbol.uri, symbol.range.start.line, symbol.range.start.column
2169            )
2170        });
2171        let mut references = self.collect_symbol_references(&symbol);
2172        if !references.iter().any(|location| location == &definition) {
2173            references.push(definition.clone());
2174            Self::sort_locations_deterministically(&mut references);
2175        }
2176
2177        Some(CrossFileReferenceQueryResult {
2178            symbol: SymbolIdentity {
2179                stable_key,
2180                name: symbol.name,
2181                qualified_name: symbol.qualified_name,
2182                kind: symbol.kind,
2183            },
2184            definition,
2185            references,
2186        })
2187    }
2188
2189    /// Count non-definition references (usages) of a symbol.
2190    ///
2191    /// Like `find_references` but excludes `ReferenceKind::Definition` entries,
2192    /// returning only actual usage sites. This is used by code lens to show
2193    /// "N references" where N means call sites, not the definition itself.
2194    pub fn count_usages(&self, symbol_name: &str) -> usize {
2195        let files = self.files.read();
2196        let mut seen: HashSet<(String, u32, u32, u32, u32)> = HashSet::new();
2197
2198        for (_uri_key, file_index) in files.iter() {
2199            if let Some(refs) = file_index.references.get(symbol_name) {
2200                for r in refs.iter().filter(|r| r.kind != ReferenceKind::Definition) {
2201                    seen.insert((
2202                        r.uri.clone(),
2203                        r.range.start.line,
2204                        r.range.start.column,
2205                        r.range.end.line,
2206                        r.range.end.column,
2207                    ));
2208                }
2209            }
2210
2211            if let Some(idx) = symbol_name.rfind("::") {
2212                let bare_name = &symbol_name[idx + 2..];
2213                if let Some(refs) = file_index.references.get(bare_name) {
2214                    for r in refs.iter().filter(|r| r.kind != ReferenceKind::Definition) {
2215                        seen.insert((
2216                            r.uri.clone(),
2217                            r.range.start.line,
2218                            r.range.start.column,
2219                            r.range.end.line,
2220                            r.range.end.column,
2221                        ));
2222                    }
2223                }
2224            } else {
2225                for (name, refs) in &file_index.references {
2226                    if !Self::is_qualified_variant_of(name, symbol_name) {
2227                        continue;
2228                    }
2229
2230                    for r in refs.iter().filter(|r| r.kind != ReferenceKind::Definition) {
2231                        seen.insert((
2232                            r.uri.clone(),
2233                            r.range.start.line,
2234                            r.range.start.column,
2235                            r.range.end.line,
2236                            r.range.end.column,
2237                        ));
2238                    }
2239                }
2240            }
2241        }
2242
2243        seen.len()
2244    }
2245
2246    fn is_qualified_variant_of(candidate: &str, bare_symbol: &str) -> bool {
2247        candidate.rsplit_once("::").is_some_and(|(_, candidate_bare)| candidate_bare == bare_symbol)
2248    }
2249
2250    /// Find the definition of a symbol
2251    ///
2252    /// # Arguments
2253    ///
2254    /// * `symbol_name` - Symbol name or qualified name to resolve
2255    ///
2256    /// # Returns
2257    ///
2258    /// The first matching definition location, if found.
2259    ///
2260    /// # Examples
2261    ///
2262    /// ```rust,ignore
2263    /// use perl_parser::workspace_index::WorkspaceIndex;
2264    ///
2265    /// let index = WorkspaceIndex::new();
2266    /// let _def = index.find_definition("MyPackage::example");
2267    /// ```
2268    pub fn find_definition(&self, symbol_name: &str) -> Option<Location> {
2269        if let Some(location) = self.definition_candidates(symbol_name).into_iter().next() {
2270            return Some(location);
2271        }
2272
2273        let files = self.files.read();
2274        let resolved = Self::find_definition_in_files(&files, symbol_name, None);
2275        drop(files);
2276
2277        if let Some((location, _uri)) = resolved {
2278            let mut symbols = self.symbols.write();
2279            symbols.entry(symbol_name.to_string()).or_default().push(DefinitionCandidate {
2280                location: location.clone(),
2281                kind: SymbolKind::Subroutine,
2282            });
2283            if let Some(candidates) = symbols.get_mut(symbol_name) {
2284                candidates.sort_by(|left, right| {
2285                    Self::definition_candidate_sort_key(left)
2286                        .cmp(&Self::definition_candidate_sort_key(right))
2287                });
2288                candidates.dedup();
2289            }
2290            return Some(location);
2291        }
2292
2293        None
2294    }
2295
2296    pub(crate) fn definition_candidates(&self, symbol_name: &str) -> Vec<Location> {
2297        let symbols = self.symbols.read();
2298        symbols
2299            .get(symbol_name)
2300            .map(|candidates| {
2301                candidates.iter().map(|candidate| candidate.location.clone()).collect()
2302            })
2303            .unwrap_or_default()
2304    }
2305
2306    /// Get all symbols in the workspace
2307    ///
2308    /// # Returns
2309    ///
2310    /// A vector containing every symbol currently indexed.
2311    ///
2312    /// # Examples
2313    ///
2314    /// ```rust,ignore
2315    /// use perl_parser::workspace_index::WorkspaceIndex;
2316    ///
2317    /// let index = WorkspaceIndex::new();
2318    /// let _symbols = index.all_symbols();
2319    /// ```
2320    pub fn all_symbols(&self) -> Vec<WorkspaceSymbol> {
2321        let files = self.files.read();
2322        let mut symbols = Vec::new();
2323
2324        for (_uri_key, file_index) in files.iter() {
2325            symbols.extend(file_index.symbols.clone());
2326        }
2327
2328        symbols
2329    }
2330
2331    /// Clear all indexed files and symbols from the workspace.
2332    pub fn clear(&self) {
2333        self.files.write().clear();
2334        self.symbols.write().clear();
2335        self.global_references.write().clear();
2336        self.fact_shards.write().clear();
2337    }
2338
2339    fn hash_uri_to_file_id(uri: &str) -> FileId {
2340        let mut hasher = DefaultHasher::new();
2341        uri.hash(&mut hasher);
2342        FileId(hasher.finish())
2343    }
2344
2345    fn build_fact_shard(uri: &str, content_hash: u64, file_index: &FileIndex) -> FileFactShard {
2346        let file_id = Self::hash_uri_to_file_id(uri);
2347        let mut anchors = Vec::new();
2348        let mut entities = Vec::new();
2349        for (idx, symbol) in file_index.symbols.iter().enumerate() {
2350            let anchor_id = AnchorId((idx + 1) as u64);
2351            anchors.push(AnchorFact {
2352                id: anchor_id,
2353                file_id,
2354                // WorkspaceSymbol provides line/column coordinates only, not byte
2355                // offsets.  Zero-initialize span_*_byte until a byte-offset source
2356                // is plumbed through the indexing pipeline.
2357                span_start_byte: 0,
2358                span_end_byte: 0,
2359                scope_id: None,
2360                provenance: Provenance::SearchFallback,
2361                confidence: Confidence::Low,
2362            });
2363            entities.push(EntityFact {
2364                id: EntityId((idx + 1) as u64),
2365                kind: EntityKind::Unknown,
2366                canonical_name: symbol
2367                    .qualified_name
2368                    .clone()
2369                    .unwrap_or_else(|| symbol.name.clone()),
2370                anchor_id: Some(anchor_id),
2371                scope_id: None,
2372                provenance: Provenance::SearchFallback,
2373                confidence: Confidence::Low,
2374            });
2375        }
2376        // Hash the per-category fact vectors so consumers can detect staleness
2377        // without re-reading the full shard.
2378        let anchors_hash = {
2379            let mut h = DefaultHasher::new();
2380            anchors.len().hash(&mut h);
2381            for a in &anchors {
2382                a.id.hash(&mut h);
2383                a.span_start_byte.hash(&mut h);
2384                a.span_end_byte.hash(&mut h);
2385            }
2386            h.finish()
2387        };
2388        let entities_hash = {
2389            let mut h = DefaultHasher::new();
2390            entities.len().hash(&mut h);
2391            for e in &entities {
2392                e.id.hash(&mut h);
2393                e.canonical_name.hash(&mut h);
2394            }
2395            h.finish()
2396        };
2397        FileFactShard {
2398            source_uri: uri.to_string(),
2399            file_id,
2400            content_hash,
2401            anchors_hash: Some(anchors_hash),
2402            entities_hash: Some(entities_hash),
2403            occurrences_hash: Some(0),
2404            edges_hash: Some(0),
2405            anchors,
2406            entities,
2407            occurrences: Vec::new(),
2408            edges: Vec::new(),
2409        }
2410    }
2411
2412    /// Number of stored file fact shards.
2413    pub fn fact_shard_count(&self) -> usize {
2414        self.fact_shards.read().len()
2415    }
2416
2417    /// Fetch a file fact shard for test/inspection.
2418    pub fn file_fact_shard(&self, uri: &str) -> Option<FileFactShard> {
2419        let key = DocumentStore::uri_key(&Self::normalize_uri(uri));
2420        self.fact_shards.read().get(&key).cloned()
2421    }
2422
2423    /// Return the number of indexed files in the workspace
2424    pub fn file_count(&self) -> usize {
2425        let files = self.files.read();
2426        files.len()
2427    }
2428
2429    /// Return the total number of symbols across all indexed files
2430    pub fn symbol_count(&self) -> usize {
2431        let files = self.files.read();
2432        files.values().map(|file_index| file_index.symbols.len()).sum()
2433    }
2434
2435    /// Get all files in a specific workspace folder
2436    ///
2437    /// # Arguments
2438    ///
2439    /// * `folder_uri` - Workspace folder URI to filter by
2440    ///
2441    /// # Returns
2442    ///
2443    /// A vector of file indices belonging to the specified folder
2444    pub fn files_in_folder(&self, folder_uri: &str) -> Vec<FileIndex> {
2445        let files = self.files.read();
2446        files.values().filter(|f| f.folder_uri.as_deref() == Some(folder_uri)).cloned().collect()
2447    }
2448
2449    /// Get all symbols in a specific workspace folder
2450    ///
2451    /// # Arguments
2452    ///
2453    /// * `folder_uri` - Workspace folder URI to filter by
2454    ///
2455    /// # Returns
2456    ///
2457    /// A vector of symbols belonging to the specified folder
2458    pub fn symbols_in_folder(&self, folder_uri: &str) -> Vec<WorkspaceSymbol> {
2459        let files = self.files.read();
2460        files
2461            .values()
2462            .filter(|f| f.folder_uri.as_deref() == Some(folder_uri))
2463            .flat_map(|f| f.symbols.iter().cloned())
2464            .collect()
2465    }
2466
2467    /// Capture a point-in-time memory estimate of the index.
2468    ///
2469    /// Acquires read locks on all index components and walks their contents
2470    /// to estimate heap usage. Intended for offline profiling; do not call
2471    /// on the LSP hot path.
2472    ///
2473    /// Only available when the `memory-profiling` feature is enabled.
2474    #[cfg(feature = "memory-profiling")]
2475    pub fn memory_snapshot(&self) -> crate::workspace::memory::MemorySnapshot {
2476        use std::mem::size_of;
2477
2478        let files_guard = self.files.read();
2479        let symbols_guard = self.symbols.read();
2480        let global_refs_guard = self.global_references.read();
2481
2482        // --- files map ---
2483        let mut files_bytes: usize = 0;
2484        let mut total_symbol_count: usize = 0;
2485        for (uri_key, fi) in files_guard.iter() {
2486            // key string
2487            files_bytes += uri_key.len();
2488            // per-symbol entries
2489            for sym in &fi.symbols {
2490                files_bytes += sym.name.len()
2491                    + sym.uri.len()
2492                    + sym.qualified_name.as_deref().map_or(0, str::len)
2493                    + sym.documentation.as_deref().map_or(0, str::len)
2494                    + sym.container_name.as_deref().map_or(0, str::len)
2495                    // stack portion: kind + range + has_body + option discriminants
2496                    + size_of::<WorkspaceSymbol>();
2497            }
2498            total_symbol_count += fi.symbols.len();
2499            // per-reference entries
2500            for (ref_name, refs) in &fi.references {
2501                files_bytes += ref_name.len();
2502                for r in refs {
2503                    files_bytes += r.uri.len() + size_of::<SymbolReference>();
2504                }
2505            }
2506            // dependencies
2507            for dep in &fi.dependencies {
2508                files_bytes += dep.len();
2509            }
2510            // content hash (u64) + vec/hashset capacity overhead (rough)
2511            files_bytes += size_of::<u64>();
2512        }
2513
2514        // --- global symbols map ---
2515        let mut symbols_bytes: usize = 0;
2516        for (qname, candidates) in symbols_guard.iter() {
2517            symbols_bytes += qname.len();
2518            for candidate in candidates {
2519                symbols_bytes += candidate.location.uri.len() + size_of::<Location>();
2520            }
2521        }
2522
2523        // --- global references map ---
2524        let mut global_refs_bytes: usize = 0;
2525        for (sym_name, locs) in global_refs_guard.iter() {
2526            global_refs_bytes += sym_name.len();
2527            for loc in locs {
2528                global_refs_bytes += loc.uri.len() + size_of::<Location>();
2529            }
2530        }
2531
2532        // --- document store ---
2533        let document_store_bytes = self.document_store.total_text_bytes();
2534
2535        crate::workspace::memory::MemorySnapshot {
2536            file_count: files_guard.len(),
2537            symbol_count: total_symbol_count,
2538            files_bytes,
2539            symbols_bytes,
2540            global_refs_bytes,
2541            document_store_bytes,
2542        }
2543    }
2544
2545    /// Check if the workspace index has symbols (soft readiness check)
2546    ///
2547    /// Returns true if the index contains any symbols, indicating that
2548    /// at least some files have been indexed and the workspace is ready
2549    /// for symbol-based operations like completion.
2550    ///
2551    /// # Returns
2552    ///
2553    /// `true` if any symbols are indexed, otherwise `false`.
2554    ///
2555    /// # Examples
2556    ///
2557    /// ```rust,ignore
2558    /// use perl_parser::workspace_index::WorkspaceIndex;
2559    ///
2560    /// let index = WorkspaceIndex::new();
2561    /// assert!(!index.has_symbols());
2562    /// ```
2563    pub fn has_symbols(&self) -> bool {
2564        let files = self.files.read();
2565        files.values().any(|file_index| !file_index.symbols.is_empty())
2566    }
2567
2568    /// Search for symbols by query
2569    ///
2570    /// # Arguments
2571    ///
2572    /// * `query` - Substring to match against symbol names
2573    ///
2574    /// # Returns
2575    ///
2576    /// Symbols whose names or qualified names contain the query string.
2577    ///
2578    /// # Examples
2579    ///
2580    /// ```rust,ignore
2581    /// use perl_parser::workspace_index::WorkspaceIndex;
2582    ///
2583    /// let index = WorkspaceIndex::new();
2584    /// let _results = index.search_symbols("example");
2585    /// ```
2586    pub fn search_symbols(&self, query: &str) -> Vec<WorkspaceSymbol> {
2587        let query_lower = query.to_lowercase();
2588        let files = self.files.read();
2589        let mut results = Vec::new();
2590        for file_index in files.values() {
2591            for symbol in &file_index.symbols {
2592                if symbol.name.to_lowercase().contains(&query_lower)
2593                    || symbol
2594                        .qualified_name
2595                        .as_ref()
2596                        .map(|qn| qn.to_lowercase().contains(&query_lower))
2597                        .unwrap_or(false)
2598                {
2599                    results.push(symbol.clone());
2600                }
2601            }
2602        }
2603        results
2604    }
2605
2606    /// Find symbols by query (alias for search_symbols for compatibility)
2607    ///
2608    /// # Arguments
2609    ///
2610    /// * `query` - Substring to match against symbol names
2611    ///
2612    /// # Returns
2613    ///
2614    /// Symbols whose names or qualified names contain the query string.
2615    ///
2616    /// # Examples
2617    ///
2618    /// ```rust,ignore
2619    /// use perl_parser::workspace_index::WorkspaceIndex;
2620    ///
2621    /// let index = WorkspaceIndex::new();
2622    /// let _results = index.find_symbols("example");
2623    /// ```
2624    pub fn find_symbols(&self, query: &str) -> Vec<WorkspaceSymbol> {
2625        self.search_symbols(query)
2626    }
2627
2628    /// Rank symbols by folder proximity to a document
2629    ///
2630    /// Returns symbols sorted by: same folder > other folders
2631    ///
2632    /// # Arguments
2633    ///
2634    /// * `symbols` - Symbols to rank
2635    /// * `doc_uri` - Document URI to determine folder context
2636    ///
2637    /// # Returns
2638    ///
2639    /// Symbols ranked by folder proximity (same folder first)
2640    ///
2641    /// # Examples
2642    ///
2643    /// ```rust,ignore
2644    /// use perl_parser::workspace_index::WorkspaceIndex;
2645    ///
2646    /// let index = WorkspaceIndex::new();
2647    /// let symbols = index.search_symbols("example");
2648    /// let ranked = index.rank_symbols_by_folder(symbols, "file:///project1/src/main.pl");
2649    /// ```
2650    pub fn rank_symbols_by_folder(
2651        &self,
2652        symbols: Vec<WorkspaceSymbol>,
2653        doc_uri: &str,
2654    ) -> Vec<WorkspaceSymbol> {
2655        let doc_folder = self.determine_folder_uri(doc_uri);
2656
2657        let mut ranked: Vec<(WorkspaceSymbol, i32)> = symbols
2658            .into_iter()
2659            .map(|symbol| {
2660                let rank = if let Some(ref doc_folder_uri) = doc_folder {
2661                    if symbol.workspace_folder_uri.as_ref() == Some(doc_folder_uri) {
2662                        0 // Same folder - highest priority
2663                    } else {
2664                        1 // Different folder - lower priority
2665                    }
2666                } else {
2667                    1 // No document context - treat as different folder
2668                };
2669                (symbol, rank)
2670            })
2671            .collect();
2672
2673        // Sort by rank (lower is better), then by name for stability
2674        ranked.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.name.cmp(&b.0.name)));
2675
2676        ranked.into_iter().map(|(symbol, _)| symbol).collect()
2677    }
2678
2679    /// Search for symbols with folder-aware ranking
2680    ///
2681    /// Combines symbol search with folder proximity ranking
2682    ///
2683    /// # Arguments
2684    ///
2685    /// * `name` - Symbol name to search for
2686    /// * `doc_uri` - Document URI for ranking context
2687    ///
2688    /// # Returns
2689    ///
2690    /// Ranked symbols with same-folder results first
2691    ///
2692    /// # Examples
2693    ///
2694    /// ```rust,ignore
2695    /// use perl_parser::workspace_index::WorkspaceIndex;
2696    ///
2697    /// let index = WorkspaceIndex::new();
2698    /// let ranked = index.search_symbols_ranked("example", "file:///project1/src/main.pl");
2699    /// ```
2700    pub fn search_symbols_ranked(&self, name: &str, doc_uri: &str) -> Vec<WorkspaceSymbol> {
2701        let symbols = self.search_symbols(name);
2702        self.rank_symbols_by_folder(symbols, doc_uri)
2703    }
2704
2705    /// Determine if two symbols are in the same package
2706    ///
2707    /// # Arguments
2708    ///
2709    /// * `symbol_a` - First symbol
2710    /// * `symbol_b` - Second symbol
2711    ///
2712    /// # Returns
2713    ///
2714    /// `true` if both symbols are in the same package
2715    #[allow(dead_code)]
2716    pub fn same_package(&self, symbol_a: &WorkspaceSymbol, symbol_b: &WorkspaceSymbol) -> bool {
2717        let package_a = self.extract_package_name(&symbol_a.name);
2718        let package_b = self.extract_package_name(&symbol_b.name);
2719        package_a == package_b
2720    }
2721
2722    /// Determine if two package names are the same (helper for testing)
2723    ///
2724    /// # Arguments
2725    ///
2726    /// * `package_a` - First package name
2727    /// * `package_b` - Second package name
2728    ///
2729    /// # Returns
2730    ///
2731    /// `true` if both package names are equal
2732    #[allow(dead_code)]
2733    pub fn same_package_by_container(&self, package_a: &str, package_b: &str) -> bool {
2734        package_a == package_b
2735    }
2736
2737    /// Extract package name from a symbol name
2738    ///
2739    /// # Arguments
2740    ///
2741    /// * `symbol_name` - Symbol name (e.g., "Foo::Bar::baz" or "baz")
2742    ///
2743    /// # Returns
2744    ///
2745    /// Package name (e.g., "Foo::Bar") or None for main package
2746    #[allow(dead_code)]
2747    pub fn extract_package_name(&self, symbol_name: &str) -> Option<String> {
2748        let parts: Vec<&str> = symbol_name.split("::").collect();
2749        if parts.len() > 1 { Some(parts[..parts.len() - 1].join("::")) } else { None }
2750    }
2751
2752    /// Get symbols in a specific file
2753    ///
2754    /// # Arguments
2755    ///
2756    /// * `uri` - File URI to inspect
2757    ///
2758    /// # Returns
2759    ///
2760    /// All symbols indexed for the requested file.
2761    ///
2762    /// # Examples
2763    ///
2764    /// ```rust,ignore
2765    /// use perl_parser::workspace_index::WorkspaceIndex;
2766    ///
2767    /// let index = WorkspaceIndex::new();
2768    /// let _symbols = index.file_symbols("file:///example.pl");
2769    /// ```
2770    pub fn file_symbols(&self, uri: &str) -> Vec<WorkspaceSymbol> {
2771        let normalized_uri = Self::normalize_uri(uri);
2772        let key = DocumentStore::uri_key(&normalized_uri);
2773        let files = self.files.read();
2774
2775        files.get(&key).map(|fi| fi.symbols.clone()).unwrap_or_default()
2776    }
2777
2778    /// Get dependencies of a file
2779    ///
2780    /// # Arguments
2781    ///
2782    /// * `uri` - File URI to inspect
2783    ///
2784    /// # Returns
2785    ///
2786    /// A set of module names imported by the file.
2787    ///
2788    /// # Examples
2789    ///
2790    /// ```rust,ignore
2791    /// use perl_parser::workspace_index::WorkspaceIndex;
2792    ///
2793    /// let index = WorkspaceIndex::new();
2794    /// let _deps = index.file_dependencies("file:///example.pl");
2795    /// ```
2796    pub fn file_dependencies(&self, uri: &str) -> HashSet<String> {
2797        let normalized_uri = Self::normalize_uri(uri);
2798        let key = DocumentStore::uri_key(&normalized_uri);
2799        let files = self.files.read();
2800
2801        files.get(&key).map(|fi| fi.dependencies.clone()).unwrap_or_default()
2802    }
2803
2804    /// Find all files that depend on a module
2805    ///
2806    /// # Arguments
2807    ///
2808    /// * `module_name` - Module name to search for in file dependencies
2809    ///
2810    /// # Returns
2811    ///
2812    /// A list of file URIs that import or depend on the module.
2813    ///
2814    /// # Examples
2815    ///
2816    /// ```rust,ignore
2817    /// use perl_parser::workspace_index::WorkspaceIndex;
2818    ///
2819    /// let index = WorkspaceIndex::new();
2820    /// let _files = index.find_dependents("My::Module");
2821    /// ```
2822    pub fn find_dependents(&self, module_name: &str) -> Vec<String> {
2823        let canonical = canonicalize_perl_module_name(module_name);
2824        let legacy = legacy_perl_module_name(&canonical);
2825        let files = self.files.read();
2826        let mut dependents = Vec::new();
2827
2828        for (uri_key, file_index) in files.iter() {
2829            if file_index.dependencies.contains(module_name)
2830                || file_index.dependencies.contains(&canonical)
2831                || file_index.dependencies.contains(&legacy)
2832            {
2833                dependents.push(uri_key.clone());
2834            }
2835        }
2836
2837        dependents
2838    }
2839
2840    /// Get the document store
2841    ///
2842    /// # Returns
2843    ///
2844    /// A reference to the in-memory document store.
2845    ///
2846    /// # Examples
2847    ///
2848    /// ```rust,ignore
2849    /// use perl_parser::workspace_index::WorkspaceIndex;
2850    ///
2851    /// let index = WorkspaceIndex::new();
2852    /// let _store = index.document_store();
2853    /// ```
2854    pub fn document_store(&self) -> &DocumentStore {
2855        &self.document_store
2856    }
2857
2858    /// Find unused symbols in the workspace
2859    ///
2860    /// # Returns
2861    ///
2862    /// Symbols that have no non-definition references in the workspace.
2863    ///
2864    /// # Examples
2865    ///
2866    /// ```rust,ignore
2867    /// use perl_parser::workspace_index::WorkspaceIndex;
2868    ///
2869    /// let index = WorkspaceIndex::new();
2870    /// let _unused = index.find_unused_symbols();
2871    /// ```
2872    pub fn find_unused_symbols(&self) -> Vec<WorkspaceSymbol> {
2873        let files = self.files.read();
2874        let mut unused = Vec::new();
2875
2876        // Collect all defined symbols
2877        for (_uri_key, file_index) in files.iter() {
2878            for symbol in &file_index.symbols {
2879                // Check if this symbol has any references beyond its definition
2880                let has_usage = files.values().any(|fi| {
2881                    if let Some(refs) = fi.references.get(&symbol.name) {
2882                        refs.iter().any(|r| r.kind != ReferenceKind::Definition)
2883                    } else {
2884                        false
2885                    }
2886                });
2887
2888                if !has_usage {
2889                    unused.push(symbol.clone());
2890                }
2891            }
2892        }
2893
2894        unused
2895    }
2896
2897    /// Get all symbols that belong to a specific package
2898    ///
2899    /// # Arguments
2900    ///
2901    /// * `package_name` - Package name to match (e.g., `My::Package`)
2902    ///
2903    /// # Returns
2904    ///
2905    /// Symbols defined within the requested package.
2906    ///
2907    /// # Examples
2908    ///
2909    /// ```rust,ignore
2910    /// use perl_parser::workspace_index::WorkspaceIndex;
2911    ///
2912    /// let index = WorkspaceIndex::new();
2913    /// let _members = index.get_package_members("My::Package");
2914    /// ```
2915    pub fn get_package_members(&self, package_name: &str) -> Vec<WorkspaceSymbol> {
2916        let files = self.files.read();
2917        let mut members = Vec::new();
2918
2919        for (_uri_key, file_index) in files.iter() {
2920            for symbol in &file_index.symbols {
2921                // Check if symbol belongs to this package
2922                if let Some(ref container) = symbol.container_name {
2923                    if container == package_name {
2924                        members.push(symbol.clone());
2925                    }
2926                }
2927                // Also check qualified names
2928                if let Some(ref qname) = symbol.qualified_name {
2929                    if qname.starts_with(&format!("{}::", package_name)) {
2930                        // Avoid duplicates - only add if not already in via container_name
2931                        if symbol.container_name.as_deref() != Some(package_name) {
2932                            members.push(symbol.clone());
2933                        }
2934                    }
2935                }
2936            }
2937        }
2938
2939        members
2940    }
2941
2942    /// Find the definition location for a symbol key during Index/Navigate stages.
2943    ///
2944    /// # Arguments
2945    ///
2946    /// * `key` - Normalized symbol key to resolve.
2947    ///
2948    /// # Returns
2949    ///
2950    /// The definition location for the symbol, if found.
2951    ///
2952    /// # Examples
2953    ///
2954    /// ```rust,ignore
2955    /// use perl_parser::workspace_index::{SymKind, SymbolKey, WorkspaceIndex};
2956    /// use std::sync::Arc;
2957    ///
2958    /// let index = WorkspaceIndex::new();
2959    /// let key = SymbolKey { pkg: Arc::from("My::Package"), name: Arc::from("example"), sigil: None, kind: SymKind::Sub };
2960    /// let _def = index.find_def(&key);
2961    /// ```
2962    pub fn find_def(&self, key: &SymbolKey) -> Option<Location> {
2963        if let Some(sigil) = key.sigil {
2964            // It's a variable
2965            let var_name = format!("{}{}", sigil, key.name);
2966            self.find_definition(&var_name)
2967        } else if key.kind == SymKind::Pack {
2968            // It's a package lookup (e.g., from `use Module::Name`)
2969            // Search for the package declaration by name
2970            self.find_definition(key.pkg.as_ref())
2971                .or_else(|| self.find_definition(key.name.as_ref()))
2972        } else {
2973            // It's a subroutine or package
2974            let qualified_name = format!("{}::{}", key.pkg, key.name);
2975            self.find_definition(&qualified_name)
2976        }
2977    }
2978
2979    /// Find reference locations for a symbol key using dual indexing.
2980    ///
2981    /// Searches both qualified and bare names to support Navigate/Analyze workflows.
2982    ///
2983    /// # Arguments
2984    ///
2985    /// * `key` - Normalized symbol key to search for.
2986    ///
2987    /// # Returns
2988    ///
2989    /// All reference locations for the symbol, excluding the definition.
2990    ///
2991    /// # Examples
2992    ///
2993    /// ```rust,ignore
2994    /// use perl_parser::workspace_index::{SymKind, SymbolKey, WorkspaceIndex};
2995    /// use std::sync::Arc;
2996    ///
2997    /// let index = WorkspaceIndex::new();
2998    /// let key = SymbolKey { pkg: Arc::from("main"), name: Arc::from("example"), sigil: None, kind: SymKind::Sub };
2999    /// let _refs = index.find_refs(&key);
3000    /// ```
3001    pub fn find_refs(&self, key: &SymbolKey) -> Vec<Location> {
3002        let files_locked = self.files.read();
3003        let mut all_refs = if let Some(sigil) = key.sigil {
3004            // It's a variable - search through all files for this variable name
3005            let var_name = format!("{}{}", sigil, key.name);
3006            let mut refs = Vec::new();
3007            for (_uri_key, file_index) in files_locked.iter() {
3008                if let Some(var_refs) = file_index.references.get(&var_name) {
3009                    for reference in var_refs {
3010                        refs.push(Location { uri: reference.uri.clone(), range: reference.range });
3011                    }
3012                }
3013            }
3014            refs
3015        } else {
3016            // It's a subroutine or package
3017            if key.pkg.as_ref() == "main" {
3018                // For main package, we search for both "main::foo" and bare "foo"
3019                let mut refs = self.find_references(&format!("main::{}", key.name));
3020                // Add bare name references
3021                for (_uri_key, file_index) in files_locked.iter() {
3022                    if let Some(bare_refs) = file_index.references.get(key.name.as_ref()) {
3023                        for reference in bare_refs {
3024                            refs.push(Location {
3025                                uri: reference.uri.clone(),
3026                                range: reference.range,
3027                            });
3028                        }
3029                    }
3030                }
3031                refs
3032            } else {
3033                let qualified_name = format!("{}::{}", key.pkg, key.name);
3034                self.find_references(&qualified_name)
3035            }
3036        };
3037        drop(files_locked);
3038
3039        // Remove the definition; the caller will include it separately if needed
3040        if let Some(def) = self.find_def(key) {
3041            all_refs.retain(|loc| !(loc.uri == def.uri && loc.range == def.range));
3042        }
3043
3044        // Deduplicate by URI and range
3045        let mut seen = HashSet::new();
3046        all_refs.retain(|loc| {
3047            seen.insert((
3048                loc.uri.clone(),
3049                loc.range.start.line,
3050                loc.range.start.column,
3051                loc.range.end.line,
3052                loc.range.end.column,
3053            ))
3054        });
3055
3056        all_refs
3057    }
3058}
3059
3060/// AST visitor for extracting symbols and references
3061struct IndexVisitor {
3062    document: Document,
3063    uri: String,
3064    current_package: Option<String>,
3065    workspace_folder_uri: Option<String>,
3066}
3067
3068fn is_interpolated_var_start(byte: u8) -> bool {
3069    byte.is_ascii_alphabetic() || byte == b'_'
3070}
3071
3072fn is_interpolated_var_continue(byte: u8) -> bool {
3073    byte.is_ascii_alphanumeric() || byte == b'_' || byte == b':'
3074}
3075
3076fn has_escaped_interpolation_marker(bytes: &[u8], index: usize) -> bool {
3077    if index == 0 {
3078        return false;
3079    }
3080
3081    let mut backslashes = 0usize;
3082    let mut cursor = index;
3083    while cursor > 0 && bytes[cursor - 1] == b'\\' {
3084        backslashes += 1;
3085        cursor -= 1;
3086    }
3087
3088    backslashes % 2 == 1
3089}
3090
3091fn strip_matching_quote_delimiters(raw_content: &str) -> &str {
3092    if raw_content.len() < 2 {
3093        return raw_content;
3094    }
3095
3096    let bytes = raw_content.as_bytes();
3097    match (bytes.first(), bytes.last()) {
3098        (Some(b'"'), Some(b'"')) | (Some(b'\''), Some(b'\'')) => {
3099            &raw_content[1..raw_content.len() - 1]
3100        }
3101        _ => raw_content,
3102    }
3103}
3104
3105impl IndexVisitor {
3106    fn new(document: &mut Document, uri: String, workspace_folder_uri: Option<String>) -> Self {
3107        Self {
3108            document: document.clone(),
3109            uri,
3110            current_package: Some("main".to_string()),
3111            workspace_folder_uri,
3112        }
3113    }
3114
3115    fn visit(&mut self, node: &Node, file_index: &mut FileIndex) {
3116        self.project_symbol_declarations(node, file_index);
3117        self.visit_node(node, file_index);
3118    }
3119
3120    fn project_symbol_declarations(&self, node: &Node, file_index: &mut FileIndex) {
3121        for decl in extract_symbol_decls(node, self.current_package.as_deref()) {
3122            let (start, end) = match decl.kind {
3123                SymbolKind::Variable(_) => match decl.anchor_span {
3124                    Some(span) => span,
3125                    None => decl.full_span,
3126                },
3127                _ => decl.full_span,
3128            };
3129            let ((start_line, start_col), (end_line, end_col)) =
3130                self.document.line_index.range(start, end);
3131            let range = Range {
3132                start: Position { byte: start, line: start_line, column: start_col },
3133                end: Position { byte: end, line: end_line, column: end_col },
3134            };
3135
3136            let symbol_name = symbol_decl_name(&decl.kind, &decl.name);
3137
3138            // Suppress qualified_name for lexically-scoped variables (my, state): they
3139            // are not package-visible and must not be found by a qualified lookup such
3140            // as `Foo::x`.  `our` and `local` variables keep the qualified name because
3141            // they participate in the package namespace.
3142            let qualified_name = match &decl.declarator {
3143                Some(d) if d == "my" || d == "state" => None,
3144                _ => (!decl.qualified_name.is_empty()).then_some(decl.qualified_name),
3145            };
3146
3147            // Top-level package declarations have no containing package; suppress the
3148            // spurious "main" container that comes from the walker's initial context.
3149            let container_name = match decl.kind {
3150                SymbolKind::Package => None,
3151                _ => decl.container,
3152            };
3153
3154            file_index.symbols.push(WorkspaceSymbol {
3155                name: symbol_name.clone(),
3156                kind: decl.kind,
3157                uri: self.uri.clone(),
3158                range,
3159                qualified_name,
3160                documentation: None,
3161                container_name,
3162                has_body: true,
3163                workspace_folder_uri: self.workspace_folder_uri.clone(),
3164            });
3165
3166            file_index.references.entry(symbol_name).or_default().push(SymbolReference {
3167                uri: self.uri.clone(),
3168                range,
3169                kind: ReferenceKind::Definition,
3170            });
3171        }
3172    }
3173
3174    fn record_interpolated_variable_references(
3175        &self,
3176        raw_content: &str,
3177        range: Range,
3178        file_index: &mut FileIndex,
3179    ) {
3180        let content = strip_matching_quote_delimiters(raw_content);
3181        let bytes = content.as_bytes();
3182        let mut index = 0;
3183
3184        while index < bytes.len() {
3185            if has_escaped_interpolation_marker(bytes, index) {
3186                index += 1;
3187                continue;
3188            }
3189
3190            let sigil = match bytes[index] {
3191                b'$' => "$",
3192                b'@' => "@",
3193                _ => {
3194                    index += 1;
3195                    continue;
3196                }
3197            };
3198
3199            if index + 1 >= bytes.len() {
3200                break;
3201            }
3202
3203            let (start, needs_closing_brace) =
3204                if bytes[index + 1] == b'{' { (index + 2, true) } else { (index + 1, false) };
3205
3206            if start >= bytes.len() || !is_interpolated_var_start(bytes[start]) {
3207                index += 1;
3208                continue;
3209            }
3210
3211            let mut end = start + 1;
3212            while end < bytes.len() && is_interpolated_var_continue(bytes[end]) {
3213                end += 1;
3214            }
3215
3216            if needs_closing_brace && (end >= bytes.len() || bytes[end] != b'}') {
3217                index += 1;
3218                continue;
3219            }
3220
3221            if let Some(name) = content.get(start..end) {
3222                let var_name = format!("{sigil}{name}");
3223                file_index.references.entry(var_name).or_default().push(SymbolReference {
3224                    uri: self.uri.clone(),
3225                    range,
3226                    kind: ReferenceKind::Read,
3227                });
3228            }
3229
3230            index = if needs_closing_brace { end + 1 } else { end };
3231        }
3232    }
3233
3234    fn visit_node(&mut self, node: &Node, file_index: &mut FileIndex) {
3235        match &node.kind {
3236            NodeKind::Package { name, .. } => {
3237                let package_name = name.clone();
3238
3239                // Update the current package (replaces the previous one, not a stack)
3240                self.current_package = Some(package_name.clone());
3241            }
3242
3243            NodeKind::Subroutine { body, .. } => {
3244                // Visit body
3245                self.visit_node(body, file_index);
3246            }
3247
3248            NodeKind::VariableDeclaration { initializer, .. } => {
3249                // Visit initializer
3250                if let Some(init) = initializer {
3251                    self.visit_node(init, file_index);
3252                }
3253            }
3254
3255            NodeKind::VariableListDeclaration { initializer, .. } => {
3256                // Visit the initializer
3257                if let Some(init) = initializer {
3258                    self.visit_node(init, file_index);
3259                }
3260            }
3261
3262            NodeKind::Variable { sigil, name } => {
3263                let var_name = format!("{}{}", sigil, name);
3264
3265                // Track as usage (could be read or write based on context)
3266                file_index.references.entry(var_name).or_default().push(SymbolReference {
3267                    uri: self.uri.clone(),
3268                    range: self.node_to_range(node),
3269                    kind: ReferenceKind::Read, // Default to read, would need context for write
3270                });
3271            }
3272
3273            NodeKind::FunctionCall { name, args, .. } => {
3274                let func_name = name.clone();
3275                let location = self.node_to_range(node);
3276
3277                // Determine package and bare name
3278                let (pkg, bare_name) = if let Some(idx) = func_name.rfind("::") {
3279                    (&func_name[..idx], &func_name[idx + 2..])
3280                } else {
3281                    (self.current_package.as_deref().unwrap_or("main"), func_name.as_str())
3282                };
3283
3284                let qualified = format!("{}::{}", pkg, bare_name);
3285
3286                // Track as usage for both qualified and bare forms
3287                // This dual indexing allows finding references whether the function is called
3288                // as `process_data()` or `Utils::process_data()`
3289                file_index.references.entry(bare_name.to_string()).or_default().push(
3290                    SymbolReference {
3291                        uri: self.uri.clone(),
3292                        range: location,
3293                        kind: ReferenceKind::Usage,
3294                    },
3295                );
3296                file_index.references.entry(qualified).or_default().push(SymbolReference {
3297                    uri: self.uri.clone(),
3298                    range: location,
3299                    kind: ReferenceKind::Usage,
3300                });
3301
3302                if name == "extends" || name == "with" {
3303                    for module_name in extract_module_names_from_call_args(args) {
3304                        file_index
3305                            .dependencies
3306                            .insert(normalize_dependency_module_name(&module_name));
3307                    }
3308                } else if name == "require" {
3309                    if let Some(module_name) = extract_module_name_from_require_args(args) {
3310                        file_index
3311                            .dependencies
3312                            .insert(normalize_dependency_module_name(&module_name));
3313                    }
3314                }
3315
3316                // Visit arguments
3317                for arg in args {
3318                    self.visit_node(arg, file_index);
3319                }
3320            }
3321
3322            NodeKind::Use { module, args, .. } => {
3323                let module_name = normalize_dependency_module_name(module);
3324                file_index.dependencies.insert(module_name.clone());
3325
3326                // Also track actual parent/base class names for dependency discovery.
3327                // `use parent 'Foo::Bar'` stores module="parent" and args=["'Foo::Bar'"],
3328                // so find_dependents("Foo::Bar") would miss files with only use parent.
3329                if module == "parent" || module == "base" {
3330                    for name in extract_module_names_from_use_args(args) {
3331                        file_index.dependencies.insert(normalize_dependency_module_name(&name));
3332                    }
3333                }
3334
3335                // Track as import
3336                file_index.references.entry(module_name).or_default().push(SymbolReference {
3337                    uri: self.uri.clone(),
3338                    range: self.node_to_range(node),
3339                    kind: ReferenceKind::Import,
3340                });
3341            }
3342
3343            // Handle assignment to detect writes
3344            NodeKind::Assignment { lhs, rhs, op } => {
3345                // For compound assignments (+=, -=, .=, etc.), the LHS is both read and written
3346                let is_compound = op != "=";
3347
3348                if let NodeKind::Variable { sigil, name } = &lhs.kind {
3349                    let var_name = format!("{}{}", sigil, name);
3350
3351                    // For compound assignments, it's a read first
3352                    if is_compound {
3353                        file_index.references.entry(var_name.clone()).or_default().push(
3354                            SymbolReference {
3355                                uri: self.uri.clone(),
3356                                range: self.node_to_range(lhs),
3357                                kind: ReferenceKind::Read,
3358                            },
3359                        );
3360                    }
3361
3362                    // Then it's always a write
3363                    file_index.references.entry(var_name).or_default().push(SymbolReference {
3364                        uri: self.uri.clone(),
3365                        range: self.node_to_range(lhs),
3366                        kind: ReferenceKind::Write,
3367                    });
3368                }
3369
3370                // Right side could have reads
3371                self.visit_node(rhs, file_index);
3372            }
3373
3374            // Recursively visit child nodes
3375            NodeKind::Block { statements } => {
3376                for stmt in statements {
3377                    self.visit_node(stmt, file_index);
3378                }
3379            }
3380
3381            NodeKind::If { condition, then_branch, elsif_branches, else_branch } => {
3382                self.visit_node(condition, file_index);
3383                self.visit_node(then_branch, file_index);
3384                for (cond, branch) in elsif_branches {
3385                    self.visit_node(cond, file_index);
3386                    self.visit_node(branch, file_index);
3387                }
3388                if let Some(else_br) = else_branch {
3389                    self.visit_node(else_br, file_index);
3390                }
3391            }
3392
3393            NodeKind::While { condition, body, continue_block } => {
3394                self.visit_node(condition, file_index);
3395                self.visit_node(body, file_index);
3396                if let Some(cont) = continue_block {
3397                    self.visit_node(cont, file_index);
3398                }
3399            }
3400
3401            NodeKind::For { init, condition, update, body, continue_block } => {
3402                if let Some(i) = init {
3403                    self.visit_node(i, file_index);
3404                }
3405                if let Some(c) = condition {
3406                    self.visit_node(c, file_index);
3407                }
3408                if let Some(u) = update {
3409                    self.visit_node(u, file_index);
3410                }
3411                self.visit_node(body, file_index);
3412                if let Some(cont) = continue_block {
3413                    self.visit_node(cont, file_index);
3414                }
3415            }
3416
3417            NodeKind::Foreach { variable, list, body, continue_block } => {
3418                // Iterator is a write context
3419                if let Some(cb) = continue_block {
3420                    self.visit_node(cb, file_index);
3421                }
3422                if let NodeKind::Variable { sigil, name } = &variable.kind {
3423                    let var_name = format!("{}{}", sigil, name);
3424                    file_index.references.entry(var_name).or_default().push(SymbolReference {
3425                        uri: self.uri.clone(),
3426                        range: self.node_to_range(variable),
3427                        kind: ReferenceKind::Write,
3428                    });
3429                }
3430                self.visit_node(variable, file_index);
3431                self.visit_node(list, file_index);
3432                self.visit_node(body, file_index);
3433            }
3434
3435            NodeKind::MethodCall { object, method, args } => {
3436                // Check if this is a static method call (Package->method)
3437                let qualified_method = if let NodeKind::Identifier { name } = &object.kind {
3438                    // Static method call: Package->method
3439                    Some(format!("{}::{}", name, method))
3440                } else {
3441                    // Instance method call: $obj->method
3442                    None
3443                };
3444
3445                // Object is a read context
3446                self.visit_node(object, file_index);
3447
3448                // Track method call under BOTH the qualified form (for static calls
3449                // like `Pkg->method`) AND the bare method name. This mirrors the
3450                // FunctionCall dual-key storage above (PR #122 dual-indexing pattern)
3451                // so that bare-name lookups (e.g. `find_unused_symbols`,
3452                // `count_usages("method")`) consistently find static method call sites.
3453                // See #6799 for the original asymmetric-storage bug report.
3454                let location = self.node_to_range(node);
3455                if let Some(qualified_method) = qualified_method.as_ref() {
3456                    file_index.references.entry(qualified_method.clone()).or_default().push(
3457                        SymbolReference {
3458                            uri: self.uri.clone(),
3459                            range: location,
3460                            kind: ReferenceKind::Usage,
3461                        },
3462                    );
3463                }
3464                file_index.references.entry(method.clone()).or_default().push(SymbolReference {
3465                    uri: self.uri.clone(),
3466                    range: location,
3467                    kind: ReferenceKind::Usage,
3468                });
3469
3470                if method == "import"
3471                    && let NodeKind::Identifier { name: module_name } = &object.kind
3472                {
3473                    for symbol in extract_manual_import_symbols(args) {
3474                        file_index.references.entry(symbol).or_default().push(SymbolReference {
3475                            uri: self.uri.clone(),
3476                            range: self.node_to_range(node),
3477                            kind: ReferenceKind::Import,
3478                        });
3479                    }
3480                    file_index.dependencies.insert(normalize_dependency_module_name(module_name));
3481                }
3482
3483                // Visit arguments
3484                for arg in args {
3485                    self.visit_node(arg, file_index);
3486                }
3487            }
3488
3489            NodeKind::No { module, .. } => {
3490                let module_name = normalize_dependency_module_name(module);
3491                file_index.dependencies.insert(module_name);
3492            }
3493
3494            NodeKind::Class { name, .. } => {
3495                self.current_package = Some(name.clone());
3496            }
3497
3498            NodeKind::Method { body, signature, .. } => {
3499                // Visit params
3500                if let Some(sig) = signature {
3501                    if let NodeKind::Signature { parameters } = &sig.kind {
3502                        for param in parameters {
3503                            self.visit_node(param, file_index);
3504                        }
3505                    }
3506                }
3507
3508                // Visit body
3509                self.visit_node(body, file_index);
3510            }
3511
3512            NodeKind::String { value, interpolated } => {
3513                if *interpolated {
3514                    let range = self.node_to_range(node);
3515                    self.record_interpolated_variable_references(value, range, file_index);
3516                }
3517            }
3518
3519            NodeKind::Heredoc { content, interpolated, .. } => {
3520                if *interpolated {
3521                    let range = self.node_to_range(node);
3522                    self.record_interpolated_variable_references(content, range, file_index);
3523                }
3524            }
3525
3526            // Handle special assignments (++ and --)
3527            NodeKind::Unary { op, operand } if op == "++" || op == "--" => {
3528                // Pre/post increment/decrement are both read and write
3529                if let NodeKind::Variable { sigil, name } = &operand.kind {
3530                    let var_name = format!("{}{}", sigil, name);
3531
3532                    // It's both a read and a write
3533                    file_index.references.entry(var_name.clone()).or_default().push(
3534                        SymbolReference {
3535                            uri: self.uri.clone(),
3536                            range: self.node_to_range(operand),
3537                            kind: ReferenceKind::Read,
3538                        },
3539                    );
3540
3541                    file_index.references.entry(var_name).or_default().push(SymbolReference {
3542                        uri: self.uri.clone(),
3543                        range: self.node_to_range(operand),
3544                        kind: ReferenceKind::Write,
3545                    });
3546                }
3547            }
3548
3549            _ => {
3550                // For other node types, just visit children
3551                self.visit_children(node, file_index);
3552            }
3553        }
3554    }
3555
3556    fn visit_children(&mut self, node: &Node, file_index: &mut FileIndex) {
3557        // Generic visitor for unhandled node types - visit all nested nodes
3558        match &node.kind {
3559            NodeKind::Program { statements } => {
3560                for stmt in statements {
3561                    self.visit_node(stmt, file_index);
3562                }
3563            }
3564            NodeKind::ExpressionStatement { expression } => {
3565                self.visit_node(expression, file_index);
3566            }
3567            // Expression nodes
3568            NodeKind::Unary { operand, .. } => {
3569                self.visit_node(operand, file_index);
3570            }
3571            NodeKind::Binary { left, right, .. } => {
3572                self.visit_node(left, file_index);
3573                self.visit_node(right, file_index);
3574            }
3575            NodeKind::Ternary { condition, then_expr, else_expr } => {
3576                self.visit_node(condition, file_index);
3577                self.visit_node(then_expr, file_index);
3578                self.visit_node(else_expr, file_index);
3579            }
3580            NodeKind::ArrayLiteral { elements } => {
3581                for elem in elements {
3582                    self.visit_node(elem, file_index);
3583                }
3584            }
3585            NodeKind::HashLiteral { pairs } => {
3586                for (key, value) in pairs {
3587                    self.visit_node(key, file_index);
3588                    self.visit_node(value, file_index);
3589                }
3590            }
3591            NodeKind::Return { value } => {
3592                if let Some(val) = value {
3593                    self.visit_node(val, file_index);
3594                }
3595            }
3596            NodeKind::Eval { block } | NodeKind::Do { block } | NodeKind::Defer { block } => {
3597                self.visit_node(block, file_index);
3598            }
3599            NodeKind::Try { body, catch_blocks, finally_block } => {
3600                self.visit_node(body, file_index);
3601                for (_, block) in catch_blocks {
3602                    self.visit_node(block, file_index);
3603                }
3604                if let Some(finally) = finally_block {
3605                    self.visit_node(finally, file_index);
3606                }
3607            }
3608            NodeKind::Given { expr, body } => {
3609                self.visit_node(expr, file_index);
3610                self.visit_node(body, file_index);
3611            }
3612            NodeKind::When { condition, body } => {
3613                self.visit_node(condition, file_index);
3614                self.visit_node(body, file_index);
3615            }
3616            NodeKind::Default { body } => {
3617                self.visit_node(body, file_index);
3618            }
3619            NodeKind::StatementModifier { statement, condition, .. } => {
3620                self.visit_node(statement, file_index);
3621                self.visit_node(condition, file_index);
3622            }
3623            NodeKind::VariableWithAttributes { variable, .. } => {
3624                self.visit_node(variable, file_index);
3625            }
3626            NodeKind::LabeledStatement { statement, .. } => {
3627                self.visit_node(statement, file_index);
3628            }
3629            _ => {
3630                // For other node types, no children to visit
3631            }
3632        }
3633    }
3634
3635    fn node_to_range(&mut self, node: &Node) -> Range {
3636        // LineIndex.range returns line numbers and UTF-16 code unit columns
3637        let ((start_line, start_col), (end_line, end_col)) =
3638            self.document.line_index.range(node.location.start, node.location.end);
3639        // Use byte offsets from node.location directly
3640        Range {
3641            start: Position { byte: node.location.start, line: start_line, column: start_col },
3642            end: Position { byte: node.location.end, line: end_line, column: end_col },
3643        }
3644    }
3645}
3646
3647fn symbol_decl_name(kind: &SymbolKind, name: &str) -> String {
3648    match kind {
3649        SymbolKind::Variable(VarKind::Scalar) => format!("${name}"),
3650        SymbolKind::Variable(VarKind::Array) => format!("@{name}"),
3651        SymbolKind::Variable(VarKind::Hash) => format!("%{name}"),
3652        _ => name.to_string(),
3653    }
3654}
3655
3656/// Extract bare module names from the argument list of a `use parent` / `use base` statement.
3657///
3658/// The `args` field of `NodeKind::Use` stores raw argument strings as the parser captured them.
3659/// For `use parent 'Foo::Bar'` this is `["'Foo::Bar'"]`.
3660/// For `use parent qw(Foo::Bar Other::Base)` this is `["qw(Foo::Bar Other::Base)"]`.
3661/// For `use parent -norequire, 'Foo::Bar'` this is `["-norequire", "'Foo::Bar'"]`.
3662///
3663/// Returns the module names with surrounding quotes/qw wrappers stripped.
3664/// Tokens starting with `-` or not matching `[\w::']+` are silently skipped.
3665fn extract_module_names_from_use_args(args: &[String]) -> Vec<String> {
3666    use std::collections::HashSet;
3667
3668    fn normalize_module_name(token: &str) -> Option<&str> {
3669        let stripped = token.trim_matches(|c: char| {
3670            matches!(c, '\'' | '"' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ';')
3671        });
3672
3673        if stripped.is_empty() || stripped.starts_with('-') {
3674            return None;
3675        }
3676
3677        stripped
3678            .chars()
3679            .all(|c| c.is_alphanumeric() || c == '_' || c == ':' || c == '\'')
3680            .then_some(stripped)
3681    }
3682
3683    let joined = args.join(" ");
3684
3685    let (qw_words, remainder) = extract_qw_words(&joined);
3686    let mut modules = Vec::new();
3687    let mut seen = HashSet::new();
3688    for word in qw_words {
3689        if let Some(candidate) = normalize_module_name(&word) {
3690            let canonical = canonicalize_perl_module_name(candidate);
3691            if seen.insert(canonical.clone()) {
3692                modules.push(canonical);
3693            }
3694        }
3695    }
3696
3697    for token in remainder.split_whitespace().flat_map(|t| t.split(',')) {
3698        if let Some(candidate) = normalize_module_name(token) {
3699            let canonical = canonicalize_perl_module_name(candidate);
3700            if seen.insert(canonical.clone()) {
3701                modules.push(canonical);
3702            }
3703        }
3704    }
3705
3706    modules
3707}
3708
3709fn extract_module_names_from_call_args(args: &[Node]) -> Vec<String> {
3710    fn collect_from_node(node: &Node, out: &mut Vec<String>) {
3711        match &node.kind {
3712            NodeKind::String { value, .. } => {
3713                out.extend(extract_module_names_from_use_args(std::slice::from_ref(value)));
3714            }
3715            NodeKind::Identifier { name } => {
3716                out.extend(extract_module_names_from_use_args(std::slice::from_ref(name)));
3717            }
3718            NodeKind::ArrayLiteral { elements } => {
3719                for element in elements {
3720                    collect_from_node(element, out);
3721                }
3722            }
3723            NodeKind::FunctionCall { name, args, .. } if name == "qw" => {
3724                for arg in args {
3725                    collect_from_node(arg, out);
3726                }
3727            }
3728            _ => {}
3729        }
3730    }
3731
3732    let mut modules = Vec::new();
3733    for arg in args {
3734        collect_from_node(arg, &mut modules);
3735    }
3736    modules
3737}
3738
3739fn canonicalize_perl_module_name(name: &str) -> String {
3740    // Perl supports the legacy `'` package separator (e.g. Foo'Bar).
3741    // Canonicalize to `::` so lookups and dependency matching share one key shape.
3742    name.replace('\'', "::")
3743}
3744
3745fn legacy_perl_module_name(name: &str) -> String {
3746    name.replace("::", "'")
3747}
3748
3749/// Normalize a module name for dependency storage and lookup.
3750/// Converts legacy `'` separators to `::` so stored keys are canonical.
3751fn normalize_dependency_module_name(module_name: &str) -> String {
3752    canonicalize_perl_module_name(module_name)
3753}
3754
3755fn extract_qw_words(input: &str) -> (Vec<String>, String) {
3756    let chars: Vec<char> = input.chars().collect();
3757    let mut i = 0;
3758    let mut words = Vec::new();
3759    let mut remainder = String::new();
3760
3761    while i < chars.len() {
3762        if chars[i] == 'q'
3763            && i + 1 < chars.len()
3764            && chars[i + 1] == 'w'
3765            && (i == 0 || !chars[i - 1].is_alphanumeric())
3766        {
3767            let mut j = i + 2;
3768            while j < chars.len() && chars[j].is_whitespace() {
3769                j += 1;
3770            }
3771            if j >= chars.len() {
3772                remainder.push(chars[i]);
3773                i += 1;
3774                continue;
3775            }
3776
3777            let open = chars[j];
3778            let (close, is_paired_delimiter) = match open {
3779                '(' => (')', true),
3780                '[' => (']', true),
3781                '{' => ('}', true),
3782                '<' => ('>', true),
3783                _ => (open, false),
3784            };
3785            if open.is_alphanumeric() || open == '_' || open == '\'' || open == '"' {
3786                remainder.push(chars[i]);
3787                i += 1;
3788                continue;
3789            }
3790
3791            let mut k = j + 1;
3792            if is_paired_delimiter {
3793                let mut depth = 1usize;
3794                while k < chars.len() && depth > 0 {
3795                    if chars[k] == open {
3796                        depth += 1;
3797                    } else if chars[k] == close {
3798                        depth -= 1;
3799                    }
3800                    k += 1;
3801                }
3802                if depth != 0 {
3803                    remainder.extend(chars[i..].iter());
3804                    break;
3805                }
3806                k -= 1;
3807            } else {
3808                while k < chars.len() && chars[k] != close {
3809                    k += 1;
3810                }
3811                if k >= chars.len() {
3812                    remainder.extend(chars[i..].iter());
3813                    break;
3814                }
3815            }
3816
3817            let content: String = chars[j + 1..k].iter().collect();
3818            for word in content.split_whitespace() {
3819                if !word.is_empty() {
3820                    words.push(word.to_string());
3821                }
3822            }
3823            i = k + 1;
3824            continue;
3825        }
3826
3827        remainder.push(chars[i]);
3828        i += 1;
3829    }
3830
3831    (words, remainder)
3832}
3833
3834fn extract_module_name_from_require_args(args: &[Node]) -> Option<String> {
3835    let first = args.first()?;
3836    match &first.kind {
3837        NodeKind::Identifier { name } => Some(name.clone()),
3838        NodeKind::String { value, .. } => {
3839            let cleaned = value.trim_matches('\'').trim_matches('"').trim();
3840            if cleaned.is_empty() {
3841                return None;
3842            }
3843            Some(cleaned.trim_end_matches(".pm").replace('/', "::"))
3844        }
3845        _ => None,
3846    }
3847}
3848
3849fn extract_manual_import_symbols(args: &[Node]) -> Vec<String> {
3850    fn push_if_bareword(out: &mut Vec<String>, token: &str) {
3851        let bare = token.trim().trim_matches('"').trim_matches('\'').trim();
3852        if bare.is_empty() || bare == "," {
3853            return;
3854        }
3855        let is_bareword = bare.bytes().all(|ch| ch.is_ascii_alphanumeric() || ch == b'_')
3856            && bare.as_bytes().first().is_some_and(|ch| ch.is_ascii_alphabetic() || *ch == b'_');
3857        if is_bareword {
3858            out.push(bare.to_string());
3859        }
3860    }
3861
3862    let mut symbols = Vec::new();
3863    for arg in args {
3864        match &arg.kind {
3865            NodeKind::String { value, .. } => push_if_bareword(&mut symbols, value),
3866            NodeKind::Identifier { name } => {
3867                if name.starts_with("qw") {
3868                    let content = name
3869                        .trim_start_matches("qw")
3870                        .trim_start_matches(|c: char| "([{/<|!".contains(c))
3871                        .trim_end_matches(|c: char| ")]}/|!>".contains(c));
3872                    for token in content.split_whitespace() {
3873                        push_if_bareword(&mut symbols, token);
3874                    }
3875                } else {
3876                    push_if_bareword(&mut symbols, name);
3877                }
3878            }
3879            NodeKind::ArrayLiteral { elements } => {
3880                for element in elements {
3881                    if let NodeKind::String { value, .. } = &element.kind {
3882                        push_if_bareword(&mut symbols, value);
3883                    }
3884                }
3885            }
3886            _ => {}
3887        }
3888    }
3889    symbols.sort();
3890    symbols.dedup();
3891    symbols
3892}
3893
3894/// Extract constant names from the `args` field of a `use constant` `NodeKind::Use` node.
3895///
3896/// The parser serialises `use constant` args in two distinct forms:
3897///
3898/// **Scalar form** — `use constant FOO => 42;`
3899///   → args: `["FOO", "42"]`  (the `=>` is consumed by the parser, not stored)
3900///   → The first arg is the constant name; remaining args are the value.
3901///
3902/// **Hash form** — `use constant { FOO => 1, BAR => 2 };`
3903///   → args: `["{", "FOO", "=>", "1", ",", "BAR", "=>", "2", "}"]`
3904///   → Identifiers immediately followed by `=>` are constant names.
3905///
3906/// **qw form** — `use constant qw(FOO BAR);`
3907///   → args: `["qw(FOO BAR)"]`
3908///   → Words inside the qw list are constant names.
3909///
3910/// Returns a deduplicated list of bare constant names (e.g. `["FOO", "BAR"]`).
3911#[cfg(test)]
3912fn extract_constant_names_from_use_args(args: &[String]) -> Vec<String> {
3913    use std::collections::HashSet;
3914
3915    fn push_unique(names: &mut Vec<String>, seen: &mut HashSet<String>, candidate: &str) {
3916        if seen.insert(candidate.to_string()) {
3917            names.push(candidate.to_string());
3918        }
3919    }
3920
3921    fn normalize_constant_name(token: &str) -> Option<&str> {
3922        let stripped = token.trim_matches(|c: char| {
3923            matches!(c, '\'' | '"' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ';')
3924        });
3925
3926        if stripped.is_empty() || stripped.starts_with('-') {
3927            return None;
3928        }
3929
3930        stripped.chars().all(|c| c.is_alphanumeric() || c == '_').then_some(stripped)
3931    }
3932
3933    let mut names = Vec::new();
3934    let mut seen = HashSet::new();
3935
3936    // Scalar form (most common): args = ["FOO", <value...>]
3937    // The first arg is a plain identifier with no `=>` in args at all.
3938    // Hash form starts with `{`; qw form starts with `qw`.
3939    let first = match args.first() {
3940        Some(f) => f.as_str(),
3941        None => return names,
3942    };
3943
3944    // qw form: single arg starting with "qw"
3945    if first.starts_with("qw") {
3946        let (qw_words, remainder) = extract_qw_words(first);
3947        if remainder.trim().is_empty() {
3948            for word in qw_words {
3949                if let Some(candidate) = normalize_constant_name(&word) {
3950                    push_unique(&mut names, &mut seen, candidate);
3951                }
3952            }
3953            return names;
3954        }
3955
3956        // Fallback for odd tokenisation: tolerate `qw` followed by spacing before the opener.
3957        let content = first.trim_start_matches("qw").trim_start();
3958        let content = content
3959            .trim_start_matches(|c: char| "([{/<|!".contains(c))
3960            .trim_end_matches(|c: char| ")]}/|!>".contains(c));
3961        for word in content.split_whitespace() {
3962            if let Some(candidate) = normalize_constant_name(word) {
3963                push_unique(&mut names, &mut seen, candidate);
3964            }
3965        }
3966        return names;
3967    }
3968
3969    // Hash form: args start with "{", "+{", or "+" followed by "{"
3970    let starts_hash_form = first == "{"
3971        || first == "+{"
3972        || (first == "+" && args.get(1).map(String::as_str) == Some("{"));
3973    if starts_hash_form {
3974        let mut skipped_leading_plus = false;
3975        let mut iter = args.iter().peekable();
3976        while let Some(arg) = iter.next() {
3977            // Some parser/tokenizer variants can emit "+{" as a single token for
3978            // `use constant +{ ... }`. Treat it as structural punctuation.
3979            if arg == "+{" {
3980                skipped_leading_plus = true;
3981                continue;
3982            }
3983            if arg == "+" && !skipped_leading_plus {
3984                skipped_leading_plus = true;
3985                continue;
3986            }
3987            if arg == "{" || arg == "}" || arg == "," || arg == "=>" {
3988                continue;
3989            }
3990            if let Some(candidate) = normalize_constant_name(arg)
3991                && iter.peek().map(|s| s.as_str()) == Some("=>")
3992            {
3993                push_unique(&mut names, &mut seen, candidate);
3994            }
3995        }
3996        return names;
3997    }
3998
3999    // Scalar form: first arg is the constant name (if it is a plain identifier)
4000    // Remaining args are the value and are skipped.
4001    if let Some(candidate) = normalize_constant_name(first) {
4002        push_unique(&mut names, &mut seen, candidate);
4003    }
4004
4005    names
4006}
4007
4008impl Default for WorkspaceIndex {
4009    fn default() -> Self {
4010        Self::new()
4011    }
4012}
4013
4014/// LSP adapter for converting internal Location types to LSP types
4015#[cfg(all(feature = "workspace", feature = "lsp-compat"))]
4016/// LSP adapter utilities for Navigate/Analyze workflows.
4017pub mod lsp_adapter {
4018    use super::Location as IxLocation;
4019    use lsp_types::Location as LspLocation;
4020    // lsp_types uses Uri, not Url
4021    type LspUrl = lsp_types::Uri;
4022
4023    /// Convert an internal location to an LSP Location for Navigate workflows.
4024    ///
4025    /// # Arguments
4026    ///
4027    /// * `ix` - Internal index location with URI and range information.
4028    ///
4029    /// # Returns
4030    ///
4031    /// `Some(LspLocation)` when conversion succeeds, or `None` if URI parsing fails.
4032    ///
4033    /// # Examples
4034    ///
4035    /// ```rust,ignore
4036    /// use perl_parser::workspace_index::{Location as IxLocation, lsp_adapter::to_lsp_location};
4037    /// use lsp_types::Range;
4038    ///
4039    /// let ix_loc = IxLocation { uri: "file:///path.pl".to_string(), range: Range::default() };
4040    /// let _ = to_lsp_location(&ix_loc);
4041    /// ```
4042    pub fn to_lsp_location(ix: &IxLocation) -> Option<LspLocation> {
4043        parse_url(&ix.uri).map(|uri| {
4044            let start =
4045                lsp_types::Position { line: ix.range.start.line, character: ix.range.start.column };
4046            let end =
4047                lsp_types::Position { line: ix.range.end.line, character: ix.range.end.column };
4048            let range = lsp_types::Range { start, end };
4049            LspLocation { uri, range }
4050        })
4051    }
4052
4053    /// Convert multiple index locations to LSP Locations for Navigate/Analyze workflows.
4054    ///
4055    /// # Arguments
4056    ///
4057    /// * `all` - Iterator of internal index locations to convert.
4058    ///
4059    /// # Returns
4060    ///
4061    /// Vector of successfully converted LSP locations, with invalid entries filtered out.
4062    ///
4063    /// # Examples
4064    ///
4065    /// ```rust,ignore
4066    /// use perl_parser::workspace_index::{Location as IxLocation, lsp_adapter::to_lsp_locations};
4067    /// use lsp_types::Range;
4068    ///
4069    /// let locations = vec![IxLocation { uri: "file:///script1.pl".to_string(), range: Range::default() }];
4070    /// let lsp_locations = to_lsp_locations(locations);
4071    /// assert_eq!(lsp_locations.len(), 1);
4072    /// ```
4073    pub fn to_lsp_locations(all: impl IntoIterator<Item = IxLocation>) -> Vec<LspLocation> {
4074        all.into_iter().filter_map(|ix| to_lsp_location(&ix)).collect()
4075    }
4076
4077    #[cfg(not(target_arch = "wasm32"))]
4078    fn parse_url(s: &str) -> Option<LspUrl> {
4079        // lsp_types::Uri uses FromStr, not TryFrom
4080        use std::str::FromStr;
4081
4082        // Try parsing as URI first
4083        LspUrl::from_str(s).ok().or_else(|| {
4084            // Try as a file path if URI parsing fails
4085            std::path::Path::new(s).canonicalize().ok().and_then(|p| {
4086                // Use proper URI construction with percent-encoding
4087                crate::workspace_index::fs_path_to_uri(&p)
4088                    .ok()
4089                    .and_then(|uri_string| LspUrl::from_str(&uri_string).ok())
4090            })
4091        })
4092    }
4093
4094    /// Parse a string as a URL (wasm32 version - no filesystem fallback)
4095    #[cfg(target_arch = "wasm32")]
4096    fn parse_url(s: &str) -> Option<LspUrl> {
4097        use std::str::FromStr;
4098        LspUrl::from_str(s).ok()
4099    }
4100}
4101
4102#[cfg(test)]
4103mod tests {
4104    use super::*;
4105    use perl_tdd_support::{must, must_some};
4106
4107    #[test]
4108    fn test_use_constant_indexed_as_constant_symbol() {
4109        let index = WorkspaceIndex::new();
4110        let uri = "file:///lib/My/Config.pm";
4111        let code = r#"package My::Config;
4112use constant PI => 3.14159;
4113use constant {
4114    MAX_RETRIES => 3,
4115    TIMEOUT     => 30,
4116};
41171;
4118"#;
4119        must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4120
4121        let symbols = index.file_symbols(uri);
4122        assert!(
4123            symbols.iter().any(|s| s.name == "PI" && s.kind == SymbolKind::Constant),
4124            "PI should be indexed as a Constant symbol; got: {:?}",
4125            symbols.iter().map(|s| (&s.name, &s.kind)).collect::<Vec<_>>()
4126        );
4127        assert!(
4128            symbols.iter().any(|s| s.name == "MAX_RETRIES" && s.kind == SymbolKind::Constant),
4129            "MAX_RETRIES should be indexed"
4130        );
4131        assert!(
4132            symbols.iter().any(|s| s.name == "TIMEOUT" && s.kind == SymbolKind::Constant),
4133            "TIMEOUT should be indexed"
4134        );
4135
4136        // Qualified lookup should also work
4137        let def = index.find_definition("My::Config::PI");
4138        assert!(def.is_some(), "find_definition('My::Config::PI') should succeed");
4139    }
4140
4141    #[test]
4142    fn test_extract_constant_names_deduplicates_qw_form() {
4143        let names = extract_constant_names_from_use_args(&["qw(FOO BAR FOO)".to_string()]);
4144        assert_eq!(names, vec!["FOO", "BAR"]);
4145    }
4146
4147    #[test]
4148    fn test_extract_constant_names_accepts_quoted_scalar_form() {
4149        let names = extract_constant_names_from_use_args(&[
4150            "'HTTP_OK'".to_string(),
4151            "=>".to_string(),
4152            "200".to_string(),
4153        ]);
4154        assert_eq!(names, vec!["HTTP_OK"]);
4155    }
4156
4157    #[test]
4158    fn test_extract_constant_names_accepts_quoted_hash_form() {
4159        let names = extract_constant_names_from_use_args(&[
4160            "{".to_string(),
4161            "'FOO'".to_string(),
4162            "=>".to_string(),
4163            "1".to_string(),
4164            ",".to_string(),
4165            "\"BAR\"".to_string(),
4166            "=>".to_string(),
4167            "2".to_string(),
4168            "}".to_string(),
4169        ]);
4170        assert_eq!(names, vec!["FOO", "BAR"]);
4171    }
4172
4173    #[test]
4174    fn test_extract_constant_names_accepts_plus_hash_form_split_tokens() {
4175        let names = extract_constant_names_from_use_args(&[
4176            "+".to_string(),
4177            "{".to_string(),
4178            "FOO".to_string(),
4179            "=>".to_string(),
4180            "1".to_string(),
4181            ",".to_string(),
4182            "BAR".to_string(),
4183            "=>".to_string(),
4184            "2".to_string(),
4185            "}".to_string(),
4186        ]);
4187        assert_eq!(names, vec!["FOO", "BAR"]);
4188    }
4189
4190    #[test]
4191    fn test_extract_constant_names_accepts_plus_hash_form_combined_token() {
4192        let names = extract_constant_names_from_use_args(&[
4193            "+{".to_string(),
4194            "FOO".to_string(),
4195            "=>".to_string(),
4196            "1".to_string(),
4197            ",".to_string(),
4198            "BAR".to_string(),
4199            "=>".to_string(),
4200            "2".to_string(),
4201            "}".to_string(),
4202        ]);
4203        assert_eq!(names, vec!["FOO", "BAR"]);
4204    }
4205    #[test]
4206    fn test_use_constant_duplicate_names_indexed_once() {
4207        let index = WorkspaceIndex::new();
4208        let uri = "file:///lib/My/DedupConfig.pm";
4209        let code = r#"package My::DedupConfig;
4210use constant {
4211    RETRY_COUNT => 3,
4212    RETRY_COUNT => 5,
4213};
42141;
4215"#;
4216        must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4217
4218        let symbols = index.file_symbols(uri);
4219        let retry_count_symbols = symbols.iter().filter(|s| s.name == "RETRY_COUNT").count();
4220        assert_eq!(
4221            retry_count_symbols, 1,
4222            "RETRY_COUNT should be indexed once even when repeated in use constant hash form"
4223        );
4224    }
4225
4226    #[test]
4227    fn test_use_constant_plus_hash_form_indexes_keys() {
4228        let index = WorkspaceIndex::new();
4229        let uri = "file:///lib/My/PlusHash.pm";
4230        let code = r#"package My::PlusHash;
4231use constant +{
4232    FOO => 1,
4233    BAR => 2,
4234};
42351;
4236"#;
4237        must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4238
4239        assert!(index.find_definition("My::PlusHash::FOO").is_some());
4240        assert!(index.find_definition("My::PlusHash::BAR").is_some());
4241    }
4242
4243    #[test]
4244    fn test_basic_indexing() {
4245        let index = WorkspaceIndex::new();
4246        let uri = "file:///test.pl";
4247
4248        let code = r#"
4249package MyPackage;
4250
4251sub hello {
4252    print "Hello";
4253}
4254
4255my $var = 42;
4256"#;
4257
4258        must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4259
4260        // Should have indexed the package and subroutine
4261        let symbols = index.file_symbols(uri);
4262        assert!(symbols.iter().any(|s| s.name == "MyPackage" && s.kind == SymbolKind::Package));
4263        assert!(symbols.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
4264        assert!(symbols.iter().any(|s| s.name == "$var" && s.kind.is_variable()));
4265    }
4266
4267    #[test]
4268    fn test_package_symbol_has_no_container_name() {
4269        // Regression: project_symbol_declarations used to set container_name = Some("main")
4270        // for top-level package declarations because the IndexVisitor starts with
4271        // current_package = Some("main").  Package symbols are top-level declarations
4272        // and must have container_name = None.
4273        let index = WorkspaceIndex::new();
4274        let uri = "file:///lib/Foo.pm";
4275        let code = "package Foo;\nsub bar { }\n";
4276        must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4277
4278        let symbols = index.file_symbols(uri);
4279        let pkg_sym = symbols.iter().find(|s| s.name == "Foo" && s.kind == SymbolKind::Package);
4280        assert!(pkg_sym.is_some(), "Package symbol not found");
4281        assert_eq!(
4282            pkg_sym.unwrap().container_name,
4283            None,
4284            "Package symbol must not carry a container (was 'main')"
4285        );
4286    }
4287
4288    #[test]
4289    fn test_my_variable_has_no_qualified_name() {
4290        // Regression: project_symbol_declarations used to set qualified_name = Some("Foo::x")
4291        // for `my $x` inside `package Foo`, making `find_definition("Foo::x")` return the
4292        // lexical variable.  `my` variables are not package-visible and must have
4293        // qualified_name = None so qualified lookups don't match them.
4294        let index = WorkspaceIndex::new();
4295        let uri = "file:///lib/Foo.pm";
4296        let code = "package Foo;\nsub bar { my $x = 1; }\n";
4297        must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4298
4299        let symbols = index.file_symbols(uri);
4300        let var_sym = symbols.iter().find(|s| s.name == "$x" && s.kind.is_variable());
4301        assert!(var_sym.is_some(), "$x variable not indexed");
4302        assert_eq!(
4303            var_sym.unwrap().qualified_name,
4304            None,
4305            "my variable must not have a qualified_name"
4306        );
4307
4308        // `find_definition("Foo::x")` must not accidentally resolve to a lexical variable.
4309        assert!(
4310            index.find_definition("Foo::x").is_none(),
4311            "find_definition(\"Foo::x\") must not return a lexical my variable"
4312        );
4313    }
4314
4315    fn reference_kinds_for(
4316        index: &WorkspaceIndex,
4317        uri: &str,
4318        symbol_name: &str,
4319    ) -> Vec<ReferenceKind> {
4320        let files = index.files.read();
4321        let file = must_some(files.get(uri));
4322        file.references
4323            .get(symbol_name)
4324            .map(|refs| refs.iter().map(|r| r.kind).collect())
4325            .unwrap_or_default()
4326    }
4327
4328    #[test]
4329    fn test_reference_kinds_sub_definition_and_call_are_distinct() {
4330        let index = WorkspaceIndex::new();
4331        let uri = "file:///typed-refs-sub.pl";
4332        let code = "package TypedRefs;
4333sub foo { return 1; }
4334foo();
4335";
4336        must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4337
4338        let kinds = reference_kinds_for(&index, uri, "foo");
4339        assert!(kinds.contains(&ReferenceKind::Definition));
4340        assert!(kinds.contains(&ReferenceKind::Usage));
4341    }
4342
4343    #[test]
4344    fn test_reference_kinds_variable_read_and_write_are_distinct() {
4345        let index = WorkspaceIndex::new();
4346        let uri = "file:///typed-refs-var.pl";
4347        let code = "my $value = 1;
4348$value = 2;
4349print $value;
4350";
4351        must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4352
4353        let kinds = reference_kinds_for(&index, uri, "$value");
4354        assert!(kinds.contains(&ReferenceKind::Definition));
4355        assert!(kinds.contains(&ReferenceKind::Write));
4356        assert!(kinds.contains(&ReferenceKind::Read));
4357    }
4358
4359    #[test]
4360    fn test_reference_kinds_import_parent_and_export_ok_are_currently_import_only() {
4361        let index = WorkspaceIndex::new();
4362        let uri = "file:///typed-refs-import-export.pm";
4363        let code = "package Child;
4364use parent 'Base';
4365our @EXPORT_OK = qw(foo);
43661;
4367";
4368        must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4369
4370        let parent_kinds = reference_kinds_for(&index, uri, "Base");
4371        assert!(
4372            parent_kinds.is_empty(),
4373            "use parent inheritance edges are currently not stored as typed references"
4374        );
4375
4376        let export_symbol_kinds = reference_kinds_for(&index, uri, "foo");
4377        assert!(
4378            export_symbol_kinds.is_empty(),
4379            "EXPORT_OK entries are currently not represented as reference edges"
4380        );
4381    }
4382
4383    #[test]
4384    fn test_reference_kinds_dynamic_and_meta_edges_are_not_typed_yet() {
4385        let index = WorkspaceIndex::new();
4386        let uri = "file:///typed-refs-dynamic.pl";
4387        let code = r#"package TypedRefs;
4388sub foo { 1 }
4389&foo;
4390my $code = \&foo;
4391goto &foo;
4392*alias = \&foo;
4393eval "foo()";
4394with 'RoleName';
4395has 'name' => (is => 'ro');
43961;
4397"#;
4398        must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4399
4400        let foo_kinds = reference_kinds_for(&index, uri, "foo");
4401        assert!(
4402            foo_kinds
4403                .iter()
4404                .all(|kind| matches!(kind, ReferenceKind::Definition | ReferenceKind::Usage)),
4405            r"dynamic call forms (&foo, \&foo, goto &foo) are currently flattened to Usage"
4406        );
4407
4408        assert!(
4409            reference_kinds_for(&index, uri, "RoleName").is_empty(),
4410            "role composition edges (`with 'RoleName'`) are not indexed as typed references yet"
4411        );
4412    }
4413
4414    #[test]
4415    fn test_find_references() {
4416        let index = WorkspaceIndex::new();
4417        let uri = "file:///test.pl";
4418
4419        let code = r#"
4420sub test {
4421    my $x = 1;
4422    $x = 2;
4423    print $x;
4424}
4425"#;
4426
4427        must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4428
4429        let refs = index.find_references("$x");
4430        assert!(refs.len() >= 2); // Definition + at least one usage
4431    }
4432
4433    #[test]
4434    fn test_find_references_bare_name_includes_qualified_calls() {
4435        let index = WorkspaceIndex::new();
4436        let uri = "file:///refs.pl";
4437        let code = r#"
4438package RefDemo;
4439sub helper {
4440    return 1;
4441}
4442
4443helper();
4444RefDemo::helper();
4445"#;
4446
4447        must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4448
4449        let bare_refs = index.find_references("helper");
4450        let qualified_refs = index.find_references("RefDemo::helper");
4451
4452        assert!(
4453            bare_refs.len() >= qualified_refs.len(),
4454            "bare-name reference lookup should include qualified calls"
4455        );
4456    }
4457
4458    #[test]
4459    fn test_count_usages_bare_name_includes_qualified_calls() {
4460        let index = WorkspaceIndex::new();
4461        let uri = "file:///usage.pl";
4462        let code = r#"
4463package UsageDemo;
4464sub helper {
4465    return 1;
4466}
4467
4468helper();
4469UsageDemo::helper();
4470"#;
4471
4472        must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4473
4474        let bare_usage_count = index.count_usages("helper");
4475        let qualified_usage_count = index.count_usages("UsageDemo::helper");
4476
4477        assert!(
4478            bare_usage_count >= qualified_usage_count,
4479            "bare-name usage count should include qualified call sites"
4480        );
4481    }
4482
4483    #[test]
4484    fn test_dependencies() {
4485        let index = WorkspaceIndex::new();
4486        let uri = "file:///test.pl";
4487
4488        let code = r#"
4489use strict;
4490use warnings;
4491use Data::Dumper;
4492"#;
4493
4494        must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4495
4496        let deps = index.file_dependencies(uri);
4497        assert!(deps.contains("strict"));
4498        assert!(deps.contains("warnings"));
4499        assert!(deps.contains("Data::Dumper"));
4500    }
4501
4502    #[test]
4503    fn test_uri_to_fs_path_basic() {
4504        // Test basic file:// URI conversion
4505        if let Some(path) = uri_to_fs_path("file:///tmp/test.pl") {
4506            assert_eq!(path, std::path::PathBuf::from("/tmp/test.pl"));
4507        }
4508
4509        // Test with invalid URI
4510        assert!(uri_to_fs_path("not-a-uri").is_none());
4511
4512        // Test with non-file scheme
4513        assert!(uri_to_fs_path("http://example.com").is_none());
4514    }
4515
4516    #[test]
4517    fn test_uri_to_fs_path_with_spaces() {
4518        // Test with percent-encoded spaces
4519        if let Some(path) = uri_to_fs_path("file:///tmp/path%20with%20spaces/test.pl") {
4520            assert_eq!(path, std::path::PathBuf::from("/tmp/path with spaces/test.pl"));
4521        }
4522
4523        // Test with multiple spaces and special characters
4524        if let Some(path) = uri_to_fs_path("file:///tmp/My%20Documents/test%20file.pl") {
4525            assert_eq!(path, std::path::PathBuf::from("/tmp/My Documents/test file.pl"));
4526        }
4527    }
4528
4529    #[test]
4530    fn test_uri_to_fs_path_with_unicode() {
4531        // Test with Unicode characters (percent-encoded)
4532        if let Some(path) = uri_to_fs_path("file:///tmp/caf%C3%A9/test.pl") {
4533            assert_eq!(path, std::path::PathBuf::from("/tmp/café/test.pl"));
4534        }
4535
4536        // Test with Unicode emoji (percent-encoded)
4537        if let Some(path) = uri_to_fs_path("file:///tmp/emoji%F0%9F%98%80/test.pl") {
4538            assert_eq!(path, std::path::PathBuf::from("/tmp/emoji😀/test.pl"));
4539        }
4540    }
4541
4542    #[test]
4543    fn test_fs_path_to_uri_basic() {
4544        // Test basic path to URI conversion
4545        let result = fs_path_to_uri("/tmp/test.pl");
4546        assert!(result.is_ok());
4547        let uri = must(result);
4548        assert!(uri.starts_with("file://"));
4549        assert!(uri.contains("/tmp/test.pl"));
4550    }
4551
4552    #[test]
4553    fn test_fs_path_to_uri_with_spaces() {
4554        // Test path with spaces
4555        let result = fs_path_to_uri("/tmp/path with spaces/test.pl");
4556        assert!(result.is_ok());
4557        let uri = must(result);
4558        assert!(uri.starts_with("file://"));
4559        // Should contain percent-encoded spaces
4560        assert!(uri.contains("path%20with%20spaces"));
4561    }
4562
4563    #[test]
4564    fn test_fs_path_to_uri_with_unicode() {
4565        // Test path with Unicode characters
4566        let result = fs_path_to_uri("/tmp/café/test.pl");
4567        assert!(result.is_ok());
4568        let uri = must(result);
4569        assert!(uri.starts_with("file://"));
4570        // Should contain percent-encoded Unicode
4571        assert!(uri.contains("caf%C3%A9"));
4572    }
4573
4574    #[test]
4575    fn test_normalize_uri_file_schemes() {
4576        // Test normalization of valid file URIs
4577        let uri = WorkspaceIndex::normalize_uri("file:///tmp/test.pl");
4578        assert_eq!(uri, "file:///tmp/test.pl");
4579
4580        // Test normalization of URIs with spaces
4581        let uri = WorkspaceIndex::normalize_uri("file:///tmp/path%20with%20spaces/test.pl");
4582        assert_eq!(uri, "file:///tmp/path%20with%20spaces/test.pl");
4583    }
4584
4585    #[test]
4586    fn test_normalize_uri_absolute_paths() {
4587        // Test normalization of absolute paths (convert to file:// URI)
4588        let uri = WorkspaceIndex::normalize_uri("/tmp/test.pl");
4589        assert!(uri.starts_with("file://"));
4590        assert!(uri.contains("/tmp/test.pl"));
4591    }
4592
4593    #[test]
4594    fn test_normalize_uri_special_schemes() {
4595        // Test that special schemes like untitled: are preserved
4596        let uri = WorkspaceIndex::normalize_uri("untitled:Untitled-1");
4597        assert_eq!(uri, "untitled:Untitled-1");
4598    }
4599
4600    #[test]
4601    fn test_roundtrip_conversion() {
4602        // Test that URI -> path -> URI conversion preserves the URI
4603        let original_uri = "file:///tmp/path%20with%20spaces/caf%C3%A9.pl";
4604
4605        if let Some(path) = uri_to_fs_path(original_uri) {
4606            if let Ok(converted_uri) = fs_path_to_uri(&path) {
4607                // Should be able to round-trip back to an equivalent URI
4608                assert!(converted_uri.starts_with("file://"));
4609
4610                // The path component should decode correctly
4611                if let Some(roundtrip_path) = uri_to_fs_path(&converted_uri) {
4612                    #[cfg(windows)]
4613                    if let Ok(rootless) = path.strip_prefix(std::path::Path::new(r"\")) {
4614                        assert!(roundtrip_path.ends_with(rootless));
4615                    } else {
4616                        assert_eq!(path, roundtrip_path);
4617                    }
4618
4619                    #[cfg(not(windows))]
4620                    assert_eq!(path, roundtrip_path);
4621                }
4622            }
4623        }
4624    }
4625
4626    #[cfg(target_os = "windows")]
4627    #[test]
4628    fn test_windows_paths() {
4629        // Test Windows-style paths
4630        let result = fs_path_to_uri(r"C:\Users\test\Documents\script.pl");
4631        assert!(result.is_ok());
4632        let uri = must(result);
4633        assert!(uri.starts_with("file://"));
4634
4635        // Test Windows path with spaces
4636        let result = fs_path_to_uri(r"C:\Program Files\My App\script.pl");
4637        assert!(result.is_ok());
4638        let uri = must(result);
4639        assert!(uri.starts_with("file://"));
4640        assert!(uri.contains("Program%20Files"));
4641    }
4642
4643    // ========================================================================
4644    // IndexCoordinator Tests
4645    // ========================================================================
4646
4647    #[test]
4648    fn test_coordinator_initial_state() {
4649        let coordinator = IndexCoordinator::new();
4650        assert!(matches!(
4651            coordinator.state(),
4652            IndexState::Building { phase: IndexPhase::Idle, .. }
4653        ));
4654    }
4655
4656    #[test]
4657    fn test_transition_to_scanning_phase() {
4658        let coordinator = IndexCoordinator::new();
4659        coordinator.transition_to_scanning();
4660
4661        let state = coordinator.state();
4662        assert!(
4663            matches!(state, IndexState::Building { phase: IndexPhase::Scanning, .. }),
4664            "Expected Building state after scanning, got: {:?}",
4665            state
4666        );
4667    }
4668
4669    #[test]
4670    fn test_transition_to_indexing_phase() {
4671        let coordinator = IndexCoordinator::new();
4672        coordinator.transition_to_scanning();
4673        coordinator.update_scan_progress(3);
4674        coordinator.transition_to_indexing(3);
4675
4676        let state = coordinator.state();
4677        assert!(
4678            matches!(
4679                state,
4680                IndexState::Building { phase: IndexPhase::Indexing, total_count: 3, .. }
4681            ),
4682            "Expected Building state after indexing with total_count 3, got: {:?}",
4683            state
4684        );
4685    }
4686
4687    #[test]
4688    fn test_transition_to_ready() {
4689        let coordinator = IndexCoordinator::new();
4690        coordinator.transition_to_ready(100, 5000);
4691
4692        let state = coordinator.state();
4693        if let IndexState::Ready { file_count, symbol_count, .. } = state {
4694            assert_eq!(file_count, 100);
4695            assert_eq!(symbol_count, 5000);
4696        } else {
4697            unreachable!("Expected Ready state, got: {:?}", state);
4698        }
4699    }
4700
4701    #[test]
4702    fn test_parse_storm_degradation() {
4703        let coordinator = IndexCoordinator::new();
4704        coordinator.transition_to_ready(100, 5000);
4705
4706        // Trigger parse storm
4707        for _ in 0..15 {
4708            coordinator.notify_change("file.pm");
4709        }
4710
4711        let state = coordinator.state();
4712        assert!(
4713            matches!(state, IndexState::Degraded { .. }),
4714            "Expected Degraded state, got: {:?}",
4715            state
4716        );
4717        if let IndexState::Degraded { reason, .. } = state {
4718            assert!(matches!(reason, DegradationReason::ParseStorm { .. }));
4719        }
4720    }
4721
4722    #[test]
4723    fn test_recovery_from_parse_storm() {
4724        let coordinator = IndexCoordinator::new();
4725        coordinator.transition_to_ready(100, 5000);
4726
4727        // Trigger parse storm
4728        for _ in 0..15 {
4729            coordinator.notify_change("file.pm");
4730        }
4731
4732        // Complete all parses
4733        for _ in 0..15 {
4734            coordinator.notify_parse_complete("file.pm");
4735        }
4736
4737        // Should recover to Building state
4738        assert!(matches!(coordinator.state(), IndexState::Building { .. }));
4739    }
4740
4741    #[test]
4742    fn test_query_dispatch_ready() {
4743        let coordinator = IndexCoordinator::new();
4744        coordinator.transition_to_ready(100, 5000);
4745
4746        let result = coordinator.query(|_index| "full_query", |_index| "partial_query");
4747
4748        assert_eq!(result, "full_query");
4749    }
4750
4751    #[test]
4752    fn test_query_dispatch_degraded() {
4753        let coordinator = IndexCoordinator::new();
4754        // Building state should use partial query
4755
4756        let result = coordinator.query(|_index| "full_query", |_index| "partial_query");
4757
4758        assert_eq!(result, "partial_query");
4759    }
4760
4761    #[test]
4762    fn test_metrics_pending_count() {
4763        let coordinator = IndexCoordinator::new();
4764
4765        coordinator.notify_change("file1.pm");
4766        coordinator.notify_change("file2.pm");
4767
4768        assert_eq!(coordinator.metrics.pending_count(), 2);
4769
4770        coordinator.notify_parse_complete("file1.pm");
4771        assert_eq!(coordinator.metrics.pending_count(), 1);
4772    }
4773
4774    #[test]
4775    fn test_instrumentation_records_transitions() {
4776        let coordinator = IndexCoordinator::new();
4777        coordinator.transition_to_ready(10, 100);
4778
4779        let snapshot = coordinator.instrumentation_snapshot();
4780        let transition =
4781            IndexStateTransition { from: IndexStateKind::Building, to: IndexStateKind::Ready };
4782        let count = snapshot.state_transition_counts.get(&transition).copied().unwrap_or(0);
4783        assert_eq!(count, 1);
4784    }
4785
4786    #[test]
4787    fn test_instrumentation_records_early_exit() {
4788        let coordinator = IndexCoordinator::new();
4789        coordinator.record_early_exit(EarlyExitReason::InitialTimeBudget, 25, 1, 10);
4790
4791        let snapshot = coordinator.instrumentation_snapshot();
4792        let count = snapshot
4793            .early_exit_counts
4794            .get(&EarlyExitReason::InitialTimeBudget)
4795            .copied()
4796            .unwrap_or(0);
4797        assert_eq!(count, 1);
4798        assert!(snapshot.last_early_exit.is_some());
4799    }
4800
4801    #[test]
4802    fn test_custom_limits() {
4803        let limits = IndexResourceLimits {
4804            max_files: 5000,
4805            max_symbols_per_file: 1000,
4806            max_total_symbols: 100_000,
4807            max_ast_cache_bytes: 128 * 1024 * 1024,
4808            max_ast_cache_items: 50,
4809            max_scan_duration_ms: 30_000,
4810        };
4811
4812        let coordinator = IndexCoordinator::with_limits(limits.clone());
4813        assert_eq!(coordinator.limits.max_files, 5000);
4814        assert_eq!(coordinator.limits.max_total_symbols, 100_000);
4815    }
4816
4817    #[test]
4818    fn test_degradation_preserves_symbol_count() {
4819        let coordinator = IndexCoordinator::new();
4820        coordinator.transition_to_ready(100, 5000);
4821
4822        coordinator.transition_to_degraded(DegradationReason::IoError {
4823            message: "Test error".to_string(),
4824        });
4825
4826        let state = coordinator.state();
4827        assert!(
4828            matches!(state, IndexState::Degraded { .. }),
4829            "Expected Degraded state, got: {:?}",
4830            state
4831        );
4832        if let IndexState::Degraded { available_symbols, .. } = state {
4833            assert_eq!(available_symbols, 5000);
4834        }
4835    }
4836
4837    #[test]
4838    fn test_index_access() {
4839        let coordinator = IndexCoordinator::new();
4840        let index = coordinator.index();
4841
4842        // Should have access to underlying WorkspaceIndex
4843        assert!(index.all_symbols().is_empty());
4844    }
4845
4846    #[test]
4847    fn test_resource_limit_enforcement_max_files() {
4848        let limits = IndexResourceLimits {
4849            max_files: 5,
4850            max_symbols_per_file: 1000,
4851            max_total_symbols: 50_000,
4852            max_ast_cache_bytes: 128 * 1024 * 1024,
4853            max_ast_cache_items: 50,
4854            max_scan_duration_ms: 30_000,
4855        };
4856
4857        let coordinator = IndexCoordinator::with_limits(limits);
4858        coordinator.transition_to_ready(10, 100);
4859
4860        // Index 10 files (exceeds limit of 5)
4861        for i in 0..10 {
4862            let uri_str = format!("file:///test{}.pl", i);
4863            let uri = must(url::Url::parse(&uri_str));
4864            let code = "sub test { }";
4865            must(coordinator.index().index_file(uri, code.to_string()));
4866        }
4867
4868        // Enforce limits
4869        coordinator.enforce_limits();
4870
4871        let state = coordinator.state();
4872        assert!(
4873            matches!(
4874                state,
4875                IndexState::Degraded {
4876                    reason: DegradationReason::ResourceLimit { kind: ResourceKind::MaxFiles },
4877                    ..
4878                }
4879            ),
4880            "Expected Degraded state with ResourceLimit(MaxFiles), got: {:?}",
4881            state
4882        );
4883    }
4884
4885    #[test]
4886    fn test_resource_limit_enforcement_max_symbols() {
4887        let limits = IndexResourceLimits {
4888            max_files: 100,
4889            max_symbols_per_file: 10,
4890            max_total_symbols: 50, // Very low limit for testing
4891            max_ast_cache_bytes: 128 * 1024 * 1024,
4892            max_ast_cache_items: 50,
4893            max_scan_duration_ms: 30_000,
4894        };
4895
4896        let coordinator = IndexCoordinator::with_limits(limits);
4897        coordinator.transition_to_ready(0, 0);
4898
4899        // Index files with many symbols to exceed total symbol limit
4900        for i in 0..10 {
4901            let uri_str = format!("file:///test{}.pl", i);
4902            let uri = must(url::Url::parse(&uri_str));
4903            // Each file has 10 subroutines = 100 total symbols (exceeds limit of 50)
4904            let code = r#"
4905package Test;
4906sub sub1 { }
4907sub sub2 { }
4908sub sub3 { }
4909sub sub4 { }
4910sub sub5 { }
4911sub sub6 { }
4912sub sub7 { }
4913sub sub8 { }
4914sub sub9 { }
4915sub sub10 { }
4916"#;
4917            must(coordinator.index().index_file(uri, code.to_string()));
4918        }
4919
4920        // Enforce limits
4921        coordinator.enforce_limits();
4922
4923        let state = coordinator.state();
4924        assert!(
4925            matches!(
4926                state,
4927                IndexState::Degraded {
4928                    reason: DegradationReason::ResourceLimit { kind: ResourceKind::MaxSymbols },
4929                    ..
4930                }
4931            ),
4932            "Expected Degraded state with ResourceLimit(MaxSymbols), got: {:?}",
4933            state
4934        );
4935    }
4936
4937    #[test]
4938    fn test_check_limits_returns_none_within_bounds() {
4939        let coordinator = IndexCoordinator::new();
4940        coordinator.transition_to_ready(0, 0);
4941
4942        // Index a few files well within default limits
4943        for i in 0..5 {
4944            let uri_str = format!("file:///test{}.pl", i);
4945            let uri = must(url::Url::parse(&uri_str));
4946            let code = "sub test { }";
4947            must(coordinator.index().index_file(uri, code.to_string()));
4948        }
4949
4950        // Should not trigger degradation
4951        let limit_check = coordinator.check_limits();
4952        assert!(limit_check.is_none(), "check_limits should return None when within bounds");
4953
4954        // State should still be Ready
4955        assert!(
4956            matches!(coordinator.state(), IndexState::Ready { .. }),
4957            "State should remain Ready when within limits"
4958        );
4959    }
4960
4961    #[test]
4962    fn test_enforce_limits_called_on_transition_to_ready() {
4963        let limits = IndexResourceLimits {
4964            max_files: 3,
4965            max_symbols_per_file: 1000,
4966            max_total_symbols: 50_000,
4967            max_ast_cache_bytes: 128 * 1024 * 1024,
4968            max_ast_cache_items: 50,
4969            max_scan_duration_ms: 30_000,
4970        };
4971
4972        let coordinator = IndexCoordinator::with_limits(limits);
4973
4974        // Index files before transitioning to ready
4975        for i in 0..5 {
4976            let uri_str = format!("file:///test{}.pl", i);
4977            let uri = must(url::Url::parse(&uri_str));
4978            let code = "sub test { }";
4979            must(coordinator.index().index_file(uri, code.to_string()));
4980        }
4981
4982        // Transition to ready - should automatically enforce limits
4983        coordinator.transition_to_ready(5, 100);
4984
4985        let state = coordinator.state();
4986        assert!(
4987            matches!(
4988                state,
4989                IndexState::Degraded {
4990                    reason: DegradationReason::ResourceLimit { kind: ResourceKind::MaxFiles },
4991                    ..
4992                }
4993            ),
4994            "Expected Degraded state after transition_to_ready with exceeded limits, got: {:?}",
4995            state
4996        );
4997    }
4998
4999    #[test]
5000    fn test_state_transition_guard_ready_to_ready() {
5001        // Test that Ready → Ready is allowed (metrics update)
5002        let coordinator = IndexCoordinator::new();
5003        coordinator.transition_to_ready(100, 5000);
5004
5005        // Transition to Ready again with different metrics
5006        coordinator.transition_to_ready(150, 7500);
5007
5008        let state = coordinator.state();
5009        assert!(
5010            matches!(state, IndexState::Ready { file_count: 150, symbol_count: 7500, .. }),
5011            "Expected Ready state with updated metrics, got: {:?}",
5012            state
5013        );
5014    }
5015
5016    #[test]
5017    fn test_state_transition_guard_building_to_building() {
5018        // Test that Building → Building is allowed (progress update)
5019        let coordinator = IndexCoordinator::new();
5020
5021        // Initial building state
5022        coordinator.transition_to_building(100);
5023
5024        let state = coordinator.state();
5025        assert!(
5026            matches!(state, IndexState::Building { indexed_count: 0, total_count: 100, .. }),
5027            "Expected Building state, got: {:?}",
5028            state
5029        );
5030
5031        // Update total count
5032        coordinator.transition_to_building(200);
5033
5034        let state = coordinator.state();
5035        assert!(
5036            matches!(state, IndexState::Building { indexed_count: 0, total_count: 200, .. }),
5037            "Expected Building state, got: {:?}",
5038            state
5039        );
5040    }
5041
5042    #[test]
5043    fn test_state_transition_ready_to_building() {
5044        // Test that Ready → Building is allowed (re-scan)
5045        let coordinator = IndexCoordinator::new();
5046        coordinator.transition_to_ready(100, 5000);
5047
5048        // Trigger re-scan
5049        coordinator.transition_to_building(150);
5050
5051        let state = coordinator.state();
5052        assert!(
5053            matches!(state, IndexState::Building { indexed_count: 0, total_count: 150, .. }),
5054            "Expected Building state after re-scan, got: {:?}",
5055            state
5056        );
5057    }
5058
5059    #[test]
5060    fn test_state_transition_degraded_to_building() {
5061        // Test that Degraded → Building is allowed (recovery)
5062        let coordinator = IndexCoordinator::new();
5063        coordinator.transition_to_degraded(DegradationReason::IoError {
5064            message: "Test error".to_string(),
5065        });
5066
5067        // Attempt recovery
5068        coordinator.transition_to_building(100);
5069
5070        let state = coordinator.state();
5071        assert!(
5072            matches!(state, IndexState::Building { indexed_count: 0, total_count: 100, .. }),
5073            "Expected Building state after recovery, got: {:?}",
5074            state
5075        );
5076    }
5077
5078    #[test]
5079    fn test_update_building_progress() {
5080        let coordinator = IndexCoordinator::new();
5081        coordinator.transition_to_building(100);
5082
5083        // Update progress
5084        coordinator.update_building_progress(50);
5085
5086        let state = coordinator.state();
5087        assert!(
5088            matches!(state, IndexState::Building { indexed_count: 50, total_count: 100, .. }),
5089            "Expected Building state with updated progress, got: {:?}",
5090            state
5091        );
5092
5093        // Update progress again
5094        coordinator.update_building_progress(100);
5095
5096        let state = coordinator.state();
5097        assert!(
5098            matches!(state, IndexState::Building { indexed_count: 100, total_count: 100, .. }),
5099            "Expected Building state with completed progress, got: {:?}",
5100            state
5101        );
5102    }
5103
5104    #[test]
5105    fn test_scan_timeout_detection() {
5106        // Test that scan timeout triggers degradation
5107        let limits = IndexResourceLimits {
5108            max_scan_duration_ms: 0, // Immediate timeout for testing
5109            ..Default::default()
5110        };
5111
5112        let coordinator = IndexCoordinator::with_limits(limits);
5113        coordinator.transition_to_building(100);
5114
5115        // Small sleep to ensure elapsed time > 0
5116        std::thread::sleep(std::time::Duration::from_millis(1));
5117
5118        // Update progress should detect timeout
5119        coordinator.update_building_progress(10);
5120
5121        let state = coordinator.state();
5122        assert!(
5123            matches!(
5124                state,
5125                IndexState::Degraded { reason: DegradationReason::ScanTimeout { .. }, .. }
5126            ),
5127            "Expected Degraded state with ScanTimeout, got: {:?}",
5128            state
5129        );
5130    }
5131
5132    #[test]
5133    fn test_scan_timeout_does_not_trigger_within_limit() {
5134        // Test that scan doesn't timeout within the limit
5135        let limits = IndexResourceLimits {
5136            max_scan_duration_ms: 10_000, // 10 seconds - should not trigger
5137            ..Default::default()
5138        };
5139
5140        let coordinator = IndexCoordinator::with_limits(limits);
5141        coordinator.transition_to_building(100);
5142
5143        // Update progress immediately (well within limit)
5144        coordinator.update_building_progress(50);
5145
5146        let state = coordinator.state();
5147        assert!(
5148            matches!(state, IndexState::Building { indexed_count: 50, .. }),
5149            "Expected Building state (no timeout), got: {:?}",
5150            state
5151        );
5152    }
5153
5154    #[test]
5155    fn test_early_exit_optimization_unchanged_content() {
5156        let index = WorkspaceIndex::new();
5157        let uri = must(url::Url::parse("file:///test.pl"));
5158        let code = r#"
5159package MyPackage;
5160
5161sub hello {
5162    print "Hello";
5163}
5164"#;
5165
5166        // First indexing should parse and index
5167        must(index.index_file(uri.clone(), code.to_string()));
5168        let symbols1 = index.file_symbols(uri.as_str());
5169        assert!(symbols1.iter().any(|s| s.name == "MyPackage" && s.kind == SymbolKind::Package));
5170        assert!(symbols1.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
5171
5172        // Second indexing with same content should early-exit
5173        // We can verify this by checking that the index still works correctly
5174        must(index.index_file(uri.clone(), code.to_string()));
5175        let symbols2 = index.file_symbols(uri.as_str());
5176        assert_eq!(symbols1.len(), symbols2.len());
5177        assert!(symbols2.iter().any(|s| s.name == "MyPackage" && s.kind == SymbolKind::Package));
5178        assert!(symbols2.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
5179    }
5180
5181    #[test]
5182    fn test_early_exit_optimization_changed_content() {
5183        let index = WorkspaceIndex::new();
5184        let uri = must(url::Url::parse("file:///test.pl"));
5185        let code1 = r#"
5186package MyPackage;
5187
5188sub hello {
5189    print "Hello";
5190}
5191"#;
5192
5193        let code2 = r#"
5194package MyPackage;
5195
5196sub goodbye {
5197    print "Goodbye";
5198}
5199"#;
5200
5201        // First indexing
5202        must(index.index_file(uri.clone(), code1.to_string()));
5203        let symbols1 = index.file_symbols(uri.as_str());
5204        assert!(symbols1.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
5205        assert!(!symbols1.iter().any(|s| s.name == "goodbye"));
5206
5207        // Second indexing with different content should re-parse
5208        must(index.index_file(uri.clone(), code2.to_string()));
5209        let symbols2 = index.file_symbols(uri.as_str());
5210        assert!(!symbols2.iter().any(|s| s.name == "hello"));
5211        assert!(symbols2.iter().any(|s| s.name == "goodbye" && s.kind == SymbolKind::Subroutine));
5212    }
5213
5214    #[test]
5215    fn test_early_exit_optimization_whitespace_only_change() {
5216        let index = WorkspaceIndex::new();
5217        let uri = must(url::Url::parse("file:///test.pl"));
5218        let code1 = r#"
5219package MyPackage;
5220
5221sub hello {
5222    print "Hello";
5223}
5224"#;
5225
5226        let code2 = r#"
5227package MyPackage;
5228
5229
5230sub hello {
5231    print "Hello";
5232}
5233"#;
5234
5235        // First indexing
5236        must(index.index_file(uri.clone(), code1.to_string()));
5237        let symbols1 = index.file_symbols(uri.as_str());
5238        assert!(symbols1.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
5239
5240        // Second indexing with whitespace change should re-parse (hash will differ)
5241        must(index.index_file(uri.clone(), code2.to_string()));
5242        let symbols2 = index.file_symbols(uri.as_str());
5243        // Symbols should still be found, but content hash differs so it re-indexed
5244        assert!(symbols2.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
5245    }
5246
5247    #[test]
5248    fn test_reindex_file_refreshes_symbol_cache_for_removed_names() {
5249        let index = WorkspaceIndex::new();
5250        let uri1 = must(url::Url::parse("file:///lib/A.pm"));
5251        let uri2 = must(url::Url::parse("file:///lib/B.pm"));
5252        let code1 = "package A;\nsub foo { return 1; }\n1;\n";
5253        let code2 = "package B;\nsub foo { return 2; }\n1;\n";
5254        let code2_reindexed = "package B;\nsub bar { return 3; }\n1;\n";
5255
5256        must(index.index_file(uri1.clone(), code1.to_string()));
5257        must(index.index_file(uri2.clone(), code2.to_string()));
5258        must(index.index_file(uri2.clone(), code2_reindexed.to_string()));
5259
5260        let foo_location = must_some(index.find_definition("foo"));
5261        assert_eq!(foo_location.uri, uri1.to_string());
5262
5263        let bar_location = must_some(index.find_definition("bar"));
5264        assert_eq!(bar_location.uri, uri2.to_string());
5265    }
5266
5267    #[test]
5268    fn test_remove_file_preserves_other_colliding_symbol_entries() {
5269        let index = WorkspaceIndex::new();
5270        let uri1 = must(url::Url::parse("file:///lib/A.pm"));
5271        let uri2 = must(url::Url::parse("file:///lib/B.pm"));
5272        let code1 = "package A;\nsub foo { return 1; }\n1;\n";
5273        let code2 = "package B;\nsub foo { return 2; }\n1;\n";
5274
5275        must(index.index_file(uri1.clone(), code1.to_string()));
5276        must(index.index_file(uri2.clone(), code2.to_string()));
5277
5278        index.remove_file(uri2.as_str());
5279
5280        let foo_location = must_some(index.find_definition("foo"));
5281        assert_eq!(foo_location.uri, uri1.to_string());
5282    }
5283
5284    #[test]
5285    fn test_count_usages_no_double_counting_for_qualified_calls() {
5286        let index = WorkspaceIndex::new();
5287
5288        // File 1: defines Utils::process_data
5289        let uri1 = "file:///lib/Utils.pm";
5290        let code1 = r#"
5291package Utils;
5292
5293sub process_data {
5294    return 1;
5295}
5296"#;
5297        must(index.index_file(must(url::Url::parse(uri1)), code1.to_string()));
5298
5299        // File 2: calls Utils::process_data (qualified call)
5300        let uri2 = "file:///app.pl";
5301        let code2 = r#"
5302use Utils;
5303Utils::process_data();
5304Utils::process_data();
5305"#;
5306        must(index.index_file(must(url::Url::parse(uri2)), code2.to_string()));
5307
5308        // Each qualified call is stored under both "process_data" and "Utils::process_data"
5309        // by the dual indexing strategy. count_usages should deduplicate so we get the
5310        // actual number of call sites, not double.
5311        let count = index.count_usages("Utils::process_data");
5312
5313        // We expect exactly 2 usage sites (the two calls in app.pl),
5314        // not 4 (which would be the double-counted result).
5315        assert_eq!(
5316            count, 2,
5317            "count_usages should not double-count qualified calls, got {} (expected 2)",
5318            count
5319        );
5320
5321        // find_references should also deduplicate
5322        let refs = index.find_references("Utils::process_data");
5323        let non_def_refs: Vec<_> =
5324            refs.iter().filter(|loc| loc.uri != "file:///lib/Utils.pm").collect();
5325        assert_eq!(
5326            non_def_refs.len(),
5327            2,
5328            "find_references should not return duplicates for qualified calls, got {} non-def refs",
5329            non_def_refs.len()
5330        );
5331    }
5332
5333    #[test]
5334    fn test_batch_indexing() {
5335        let index = WorkspaceIndex::new();
5336        let files: Vec<(Url, String)> = (0..5)
5337            .map(|i| {
5338                let uri = must(Url::parse(&format!("file:///batch/module{}.pm", i)));
5339                let code =
5340                    format!("package Batch::Mod{};\nsub func_{} {{ return {}; }}\n1;", i, i, i);
5341                (uri, code)
5342            })
5343            .collect();
5344
5345        let errors = index.index_files_batch(files);
5346        assert!(errors.is_empty(), "batch indexing errors: {:?}", errors);
5347        assert_eq!(index.file_count(), 5);
5348        assert!(index.find_definition("Batch::Mod0::func_0").is_some());
5349        assert!(index.find_definition("Batch::Mod4::func_4").is_some());
5350    }
5351
5352    #[test]
5353    fn test_batch_indexing_skips_unchanged() {
5354        let index = WorkspaceIndex::new();
5355        let uri = must(Url::parse("file:///batch/skip.pm"));
5356        let code = "package Skip;\nsub skip_fn { 1 }\n1;".to_string();
5357
5358        index.index_file(uri.clone(), code.clone()).ok();
5359        assert_eq!(index.file_count(), 1);
5360
5361        let errors = index.index_files_batch(vec![(uri, code)]);
5362        assert!(errors.is_empty());
5363        assert_eq!(index.file_count(), 1);
5364    }
5365
5366    #[test]
5367    fn test_incremental_update_preserves_other_symbols() {
5368        let index = WorkspaceIndex::new();
5369
5370        let uri_a = must(Url::parse("file:///incr/a.pm"));
5371        let uri_b = must(Url::parse("file:///incr/b.pm"));
5372        index.index_file(uri_a.clone(), "package A;\nsub a_func { 1 }\n1;".into()).ok();
5373        index.index_file(uri_b.clone(), "package B;\nsub b_func { 2 }\n1;".into()).ok();
5374
5375        assert!(index.find_definition("A::a_func").is_some());
5376        assert!(index.find_definition("B::b_func").is_some());
5377
5378        index.index_file(uri_a, "package A;\nsub a_func_v2 { 11 }\n1;".into()).ok();
5379
5380        assert!(index.find_definition("A::a_func_v2").is_some());
5381        assert!(index.find_definition("B::b_func").is_some());
5382    }
5383
5384    #[test]
5385    fn test_remove_file_preserves_shadowed_symbols() {
5386        let index = WorkspaceIndex::new();
5387
5388        let uri_a = must(Url::parse("file:///shadow/a.pm"));
5389        let uri_b = must(Url::parse("file:///shadow/b.pm"));
5390        index.index_file(uri_a.clone(), "package ShadowA;\nsub helper { 1 }\n1;".into()).ok();
5391        index.index_file(uri_b.clone(), "package ShadowB;\nsub helper { 2 }\n1;".into()).ok();
5392
5393        assert!(index.find_definition("helper").is_some());
5394
5395        index.remove_file_url(&uri_a);
5396        assert!(index.find_definition("helper").is_some());
5397        assert!(index.find_definition("ShadowB::helper").is_some());
5398    }
5399
5400    // -------------------------------------------------------------------------
5401    // find_dependents — use parent / use base integration (#2747)
5402    // -------------------------------------------------------------------------
5403
5404    #[test]
5405    fn test_index_dependency_via_use_parent_end_to_end() {
5406        // Regression for #2747: index a file with `use parent 'MyBase'` and verify
5407        // that find_dependents("MyBase") returns that file.
5408        // 1. Index MyBase.pm
5409        // 2. Index child.pl with `use parent 'MyBase'`
5410        // 3. find_dependents("MyBase") should return child.pl
5411        let index = WorkspaceIndex::new();
5412
5413        let base_url = must(url::Url::parse("file:///test/workspace/lib/MyBase.pm"));
5414        must(index.index_file(
5415            base_url,
5416            "package MyBase;\nsub new { bless {}, shift }\n1;\n".to_string(),
5417        ));
5418
5419        let child_url = must(url::Url::parse("file:///test/workspace/child.pl"));
5420        must(index.index_file(child_url, "package Child;\nuse parent 'MyBase';\n1;\n".to_string()));
5421
5422        let dependents = index.find_dependents("MyBase");
5423        assert!(
5424            !dependents.is_empty(),
5425            "find_dependents('MyBase') returned empty — \
5426             use parent 'MyBase' should register MyBase as a dependency. \
5427             Dependencies in index: {:?}",
5428            {
5429                let files = index.files.read();
5430                files
5431                    .iter()
5432                    .map(|(k, v)| (k.clone(), v.dependencies.iter().cloned().collect::<Vec<_>>()))
5433                    .collect::<Vec<_>>()
5434            }
5435        );
5436        assert!(
5437            dependents.contains(&"file:///test/workspace/child.pl".to_string()),
5438            "child.pl should be in dependents, got: {:?}",
5439            dependents
5440        );
5441    }
5442
5443    #[test]
5444    fn test_find_dependents_normalizes_legacy_separator_in_query() {
5445        let index = WorkspaceIndex::new();
5446        let uri = must(url::Url::parse("file:///test/workspace/legacy-query.pl"));
5447        let src = "package Child;\nuse parent 'My::Base';\n1;\n";
5448        must(index.index_file(uri, src.to_string()));
5449
5450        let dependents = index.find_dependents("My'Base");
5451        assert_eq!(dependents, vec!["file:///test/workspace/legacy-query.pl".to_string()]);
5452    }
5453
5454    #[test]
5455    fn test_file_dependencies_normalize_legacy_separator_in_source() {
5456        let index = WorkspaceIndex::new();
5457        let uri = must(url::Url::parse("file:///test/workspace/legacy-source.pl"));
5458        let src = "package Child;\nuse parent \"My'Base\";\n1;\n";
5459        must(index.index_file(uri.clone(), src.to_string()));
5460
5461        let deps = index.file_dependencies(uri.as_str());
5462        assert!(deps.contains("My::Base"));
5463        assert!(!deps.contains("My'Base"));
5464    }
5465
5466    #[test]
5467    fn test_index_dependency_via_moose_extends_end_to_end() -> Result<(), Box<dyn std::error::Error>>
5468    {
5469        let index = WorkspaceIndex::new();
5470
5471        let parent_url = must(url::Url::parse("file:///test/workspace/lib/My/App/Parent.pm"));
5472        must(index.index_file(parent_url, "package My::App::Parent;\n1;\n".to_string()));
5473
5474        let child_url = must(url::Url::parse("file:///test/workspace/child-moose.pl"));
5475        let child_src = "package Child;\nuse Moose;\nextends 'My::App::Parent';\n1;\n";
5476        must(index.index_file(child_url, child_src.to_string()));
5477
5478        let dependents = index.find_dependents("My::App::Parent");
5479        assert!(
5480            dependents.contains(&"file:///test/workspace/child-moose.pl".to_string()),
5481            "expected child-moose.pl in dependents, got: {dependents:?}"
5482        );
5483        Ok(())
5484    }
5485
5486    #[test]
5487    fn test_index_dependency_via_moo_with_role_end_to_end() -> Result<(), Box<dyn std::error::Error>>
5488    {
5489        let index = WorkspaceIndex::new();
5490
5491        let role_url = must(url::Url::parse("file:///test/workspace/lib/My/App/Role.pm"));
5492        must(index.index_file(role_url, "package My::App::Role;\n1;\n".to_string()));
5493
5494        let consumer_url = must(url::Url::parse("file:///test/workspace/consumer-moo.pl"));
5495        let consumer_src = "package Consumer;\nuse Moo;\nwith 'My::App::Role';\n1;\n";
5496        must(index.index_file(consumer_url.clone(), consumer_src.to_string()));
5497
5498        let dependents = index.find_dependents("My::App::Role");
5499        assert!(
5500            dependents.contains(&"file:///test/workspace/consumer-moo.pl".to_string()),
5501            "expected consumer-moo.pl in dependents, got: {dependents:?}"
5502        );
5503
5504        let deps = index.file_dependencies(consumer_url.as_str());
5505        assert!(deps.contains("My::App::Role"));
5506        Ok(())
5507    }
5508
5509    #[test]
5510    fn test_index_dependency_via_literal_require_end_to_end()
5511    -> Result<(), Box<dyn std::error::Error>> {
5512        let index = WorkspaceIndex::new();
5513        let uri = must(url::Url::parse("file:///test/workspace/require-consumer.pl"));
5514        let src = "package Consumer;\nrequire My::Loader;\n1;\n";
5515        must(index.index_file(uri.clone(), src.to_string()));
5516
5517        let deps = index.file_dependencies(uri.as_str());
5518        assert!(
5519            deps.contains("My::Loader"),
5520            "literal require should register module dependency, got: {deps:?}"
5521        );
5522        Ok(())
5523    }
5524
5525    #[test]
5526    fn test_manual_import_symbols_are_indexed_as_import_references()
5527    -> Result<(), Box<dyn std::error::Error>> {
5528        let index = WorkspaceIndex::new();
5529        let uri = must(url::Url::parse("file:///test/workspace/manual-import.pl"));
5530        let src = r#"package Consumer;
5531require My::Tools;
5532My::Tools->import(qw(helper_one helper_two));
5533helper_one();
55341;
5535"#;
5536        must(index.index_file(uri.clone(), src.to_string()));
5537
5538        let deps = index.file_dependencies(uri.as_str());
5539        assert!(
5540            deps.contains("My::Tools"),
5541            "manual import target should be tracked as dependency, got: {deps:?}"
5542        );
5543
5544        for symbol in ["helper_one", "helper_two"] {
5545            let refs = index.find_references(symbol);
5546            assert!(
5547                !refs.is_empty(),
5548                "expected at least one indexed reference for imported symbol `{symbol}`"
5549            );
5550        }
5551        Ok(())
5552    }
5553
5554    #[test]
5555    fn test_parser_produces_correct_args_for_use_parent() {
5556        // Regression for #2747: verify that the parser produces args=["'MyBase'"]
5557        // for `use parent 'MyBase'`, so extract_module_names_from_use_args strips
5558        // the quotes and registers the dependency under the bare name "MyBase".
5559        use crate::Parser;
5560        let mut p = Parser::new("package Child;\nuse parent 'MyBase';\n1;\n");
5561        let ast = must(p.parse());
5562        assert!(
5563            matches!(ast.kind, NodeKind::Program { .. }),
5564            "Expected Program root, got {:?}",
5565            ast.kind
5566        );
5567        let NodeKind::Program { statements } = &ast.kind else {
5568            return;
5569        };
5570        let mut found_parent_use = false;
5571        for stmt in statements {
5572            if let NodeKind::Use { module, args, .. } = &stmt.kind {
5573                if module == "parent" {
5574                    found_parent_use = true;
5575                    assert_eq!(
5576                        args,
5577                        &["'MyBase'".to_string()],
5578                        "Expected args=[\"'MyBase'\"] for `use parent 'MyBase'`, got: {:?}",
5579                        args
5580                    );
5581                    let extracted = extract_module_names_from_use_args(args);
5582                    assert_eq!(
5583                        extracted,
5584                        vec!["MyBase".to_string()],
5585                        "extract_module_names_from_use_args should return [\"MyBase\"], got {:?}",
5586                        extracted
5587                    );
5588                }
5589            }
5590        }
5591        assert!(found_parent_use, "No Use node with module='parent' found in AST");
5592    }
5593
5594    // -------------------------------------------------------------------------
5595    // extract_module_names_from_use_args — unit tests (#2747)
5596    // -------------------------------------------------------------------------
5597
5598    #[test]
5599    fn test_extract_module_names_single_quoted() {
5600        let names = extract_module_names_from_use_args(&["'Foo::Bar'".to_string()]);
5601        assert_eq!(names, vec!["Foo::Bar"]);
5602    }
5603
5604    #[test]
5605    fn test_extract_module_names_double_quoted() {
5606        let names = extract_module_names_from_use_args(&["\"Foo::Bar\"".to_string()]);
5607        assert_eq!(names, vec!["Foo::Bar"]);
5608    }
5609
5610    #[test]
5611    fn test_extract_module_names_qw_list() {
5612        let names = extract_module_names_from_use_args(&["qw(Foo::Bar Other::Base)".to_string()]);
5613        assert_eq!(names, vec!["Foo::Bar", "Other::Base"]);
5614    }
5615
5616    #[test]
5617    fn test_extract_module_names_qw_slash_delimiter() {
5618        let names = extract_module_names_from_use_args(&["qw/Foo::Bar Other::Base/".to_string()]);
5619        assert_eq!(names, vec!["Foo::Bar", "Other::Base"]);
5620    }
5621
5622    #[test]
5623    fn test_extract_module_names_qw_with_space_before_delimiter() {
5624        let names = extract_module_names_from_use_args(&["qw [Foo::Bar Other::Base]".to_string()]);
5625        assert_eq!(names, vec!["Foo::Bar", "Other::Base"]);
5626    }
5627
5628    #[test]
5629    fn test_extract_module_names_qw_list_trims_wrapped_punctuation() {
5630        let names =
5631            extract_module_names_from_use_args(&["qw((Foo::Bar) [Other::Base],)".to_string()]);
5632        assert_eq!(names, vec!["Foo::Bar", "Other::Base"]);
5633    }
5634
5635    #[test]
5636    fn test_extract_module_names_norequire_flag() {
5637        let names = extract_module_names_from_use_args(&[
5638            "-norequire".to_string(),
5639            "'Foo::Bar'".to_string(),
5640        ]);
5641        assert_eq!(names, vec!["Foo::Bar"]);
5642    }
5643
5644    #[test]
5645    fn test_extract_module_names_empty_args() {
5646        let names = extract_module_names_from_use_args(&[]);
5647        assert!(names.is_empty());
5648    }
5649
5650    #[test]
5651    fn test_extract_module_names_legacy_separator() {
5652        // Perl legacy package separator ' (tick) inside module name
5653        let names = extract_module_names_from_use_args(&["'Foo'Bar'".to_string()]);
5654        // Legacy separators are normalized for downstream dependency matching.
5655        assert_eq!(names, vec!["Foo::Bar"]);
5656    }
5657
5658    #[test]
5659    fn test_find_dependents_matches_legacy_separator_queries() {
5660        let index = WorkspaceIndex::new();
5661        let base_uri = must(url::Url::parse("file:///test/workspace/lib/Foo/Bar.pm"));
5662        let child_uri = must(url::Url::parse("file:///test/workspace/child.pl"));
5663
5664        must(index.index_file(base_uri, "package Foo::Bar;\n1;\n".to_string()));
5665        must(index.index_file(
5666            child_uri.clone(),
5667            "package Child;\nuse parent qw(Foo'Bar);\n1;\n".to_string(),
5668        ));
5669
5670        let dependents_modern = index.find_dependents("Foo::Bar");
5671        assert!(
5672            dependents_modern.contains(&child_uri.to_string()),
5673            "Expected dependency match when queried with modern separator"
5674        );
5675
5676        let dependents_legacy = index.find_dependents("Foo'Bar");
5677        assert!(
5678            dependents_legacy.contains(&child_uri.to_string()),
5679            "Expected dependency match when queried with legacy separator"
5680        );
5681    }
5682
5683    #[test]
5684    fn test_extract_module_names_comma_adjacent_tokens() {
5685        let names = extract_module_names_from_use_args(&[
5686            "'Foo::Bar',".to_string(),
5687            "\"Other::Base\",".to_string(),
5688            "'Last::One'".to_string(),
5689        ]);
5690        assert_eq!(names, vec!["Foo::Bar", "Other::Base", "Last::One"]);
5691    }
5692
5693    #[test]
5694    fn test_extract_module_names_parenthesized_without_spaces() {
5695        let names = extract_module_names_from_use_args(&["('Foo::Bar','Other::Base')".to_string()]);
5696        assert_eq!(names, vec!["Foo::Bar", "Other::Base"]);
5697    }
5698
5699    #[test]
5700    fn test_extract_module_names_deduplicates_identical_entries() {
5701        let names = extract_module_names_from_use_args(&[
5702            "qw(Foo::Bar Foo::Bar)".to_string(),
5703            "'Foo::Bar'".to_string(),
5704        ]);
5705        assert_eq!(names, vec!["Foo::Bar"]);
5706    }
5707
5708    #[test]
5709    fn test_extract_module_names_trims_semicolon_suffix() {
5710        let names = extract_module_names_from_use_args(&[
5711            "'Foo::Bar',".to_string(),
5712            "'Other::Base',".to_string(),
5713            "'Third::Leaf';".to_string(),
5714        ]);
5715        assert_eq!(names, vec!["Foo::Bar", "Other::Base", "Third::Leaf"]);
5716    }
5717
5718    #[test]
5719    fn test_extract_module_names_trims_wrapped_punctuation() {
5720        let names = extract_module_names_from_use_args(&[
5721            "('Foo::Bar',".to_string(),
5722            "'Other::Base')".to_string(),
5723        ]);
5724        assert_eq!(names, vec!["Foo::Bar", "Other::Base"]);
5725    }
5726
5727    #[test]
5728    fn test_extract_constant_names_qw_with_space_before_delimiter() {
5729        let names = extract_constant_names_from_use_args(&["qw [FOO BAR]".to_string()]);
5730        assert_eq!(names, vec!["FOO", "BAR"]);
5731    }
5732
5733    #[test]
5734    #[ignore = "qw delimiter with leading space not yet parsed; tracked in debt-ledger.yaml"]
5735    fn test_index_use_constant_qw_with_space_before_delimiter() {
5736        let index = WorkspaceIndex::new();
5737        let uri = must(url::Url::parse("file:///workspace/lib/My/Config.pm"));
5738        let source = "package My::Config;\nuse constant qw [FOO BAR];\n1;\n";
5739
5740        must(index.index_file(uri, source.to_string()));
5741
5742        let foo = index.find_definition("My::Config::FOO");
5743        let bar = index.find_definition("My::Config::BAR");
5744        assert!(foo.is_some(), "Expected My::Config::FOO to be indexed");
5745        assert!(bar.is_some(), "Expected My::Config::BAR to be indexed");
5746    }
5747
5748    #[test]
5749    fn test_with_capacity_accepts_large_batch_without_panic() {
5750        let index = WorkspaceIndex::with_capacity(100, 20);
5751        for i in 0..100 {
5752            let uri = must(url::Url::parse(&format!("file:///lib/Mod{}.pm", i)));
5753            let src = format!("package Mod{};\nsub foo_{} {{ 1 }}\n1;\n", i, i);
5754            index.index_file(uri, src).ok();
5755        }
5756        assert!(index.has_symbols());
5757    }
5758
5759    #[test]
5760    fn test_with_capacity_zero_does_not_panic() {
5761        let index = WorkspaceIndex::with_capacity(0, 0);
5762        assert!(!index.has_symbols());
5763    }
5764
5765    // -------------------------------------------------------------------------
5766    // remove_file — symbol cache cleanup (#3494)
5767    // -------------------------------------------------------------------------
5768
5769    /// After removing the only file that defines a symbol, both qualified and
5770    /// bare-name lookups must return None.  The symbols cache must not retain
5771    /// stale entries pointing to the deleted file.
5772    #[test]
5773    fn test_remove_file_clears_symbol_cache_qualified_and_bare() {
5774        let index = WorkspaceIndex::new();
5775        let uri_a = must(url::Url::parse("file:///lib/A.pm"));
5776        let code_a = "package A;\nsub foo { return 1; }\n1;\n";
5777
5778        must(index.index_file(uri_a.clone(), code_a.to_string()));
5779
5780        // Pre-condition: both qualified and bare-name lookups resolve to file A.
5781        let before_qual = must_some(index.find_definition("A::foo"));
5782        assert_eq!(
5783            before_qual.uri,
5784            uri_a.to_string(),
5785            "qualified lookup should point to A.pm before removal"
5786        );
5787        let before_bare = must_some(index.find_definition("foo"));
5788        assert_eq!(
5789            before_bare.uri,
5790            uri_a.to_string(),
5791            "bare-name lookup should point to A.pm before removal"
5792        );
5793
5794        // Remove file A from the index (simulates file deletion).
5795        index.remove_file(uri_a.as_str());
5796
5797        // Post-condition: the symbol cache must be clean — no stale entries.
5798        assert!(
5799            index.find_definition("A::foo").is_none(),
5800            "qualified lookup 'A::foo' should return None after file deletion"
5801        );
5802        assert!(
5803            index.find_definition("foo").is_none(),
5804            "bare-name lookup 'foo' should return None after file deletion"
5805        );
5806
5807        // Verify no symbols remain in the index.
5808        assert_eq!(
5809            index.symbol_count(),
5810            0,
5811            "symbol_count should be 0 after removing the only file"
5812        );
5813        assert!(!index.has_symbols(), "has_symbols should be false after removing the only file");
5814    }
5815
5816    /// Deleting file A when file B has the same bare-name symbol must leave
5817    /// the bare-name cache pointing to B (not remove it entirely).
5818    #[test]
5819    fn test_remove_file_bare_name_falls_back_to_surviving_file() {
5820        let index = WorkspaceIndex::new();
5821        let uri_a = must(url::Url::parse("file:///lib/A.pm"));
5822        let uri_b = must(url::Url::parse("file:///lib/B.pm"));
5823        let code_a = "package A;\nsub shared_fn { return 1; }\n1;\n";
5824        let code_b = "package B;\nsub shared_fn { return 2; }\n1;\n";
5825
5826        must(index.index_file(uri_a.clone(), code_a.to_string()));
5827        must(index.index_file(uri_b.clone(), code_b.to_string()));
5828
5829        // Remove file A — shared_fn should still resolve via B.
5830        index.remove_file(uri_a.as_str());
5831
5832        let loc = must_some(index.find_definition("shared_fn"));
5833        assert_eq!(
5834            loc.uri,
5835            uri_b.to_string(),
5836            "bare-name 'shared_fn' should resolve to B.pm after A.pm is deleted"
5837        );
5838
5839        assert!(
5840            index.find_definition("A::shared_fn").is_none(),
5841            "qualified 'A::shared_fn' must be gone after A.pm deletion"
5842        );
5843        assert!(
5844            index.find_definition("B::shared_fn").is_some(),
5845            "qualified 'B::shared_fn' must remain after A.pm deletion"
5846        );
5847    }
5848
5849    #[test]
5850    fn test_definition_candidates_include_ambiguous_bare_symbols_in_stable_order() {
5851        let index = WorkspaceIndex::new();
5852        let uri_b = must(url::Url::parse("file:///lib/B.pm"));
5853        let uri_a = must(url::Url::parse("file:///lib/A.pm"));
5854        must(index.index_file(uri_b, "package B;\nsub shared { 1 }\n1;\n".to_string()));
5855        must(index.index_file(uri_a, "package A;\nsub shared { 1 }\n1;\n".to_string()));
5856
5857        let candidates = index.definition_candidates("shared");
5858        assert_eq!(candidates.len(), 2);
5859        assert_eq!(candidates[0].uri, "file:///lib/A.pm");
5860        assert_eq!(candidates[1].uri, "file:///lib/B.pm");
5861        assert_eq!(must_some(index.find_definition("shared")).uri, "file:///lib/A.pm");
5862    }
5863
5864    #[test]
5865    fn test_definition_candidates_include_duplicate_qualified_name_across_files() {
5866        let index = WorkspaceIndex::new();
5867        let uri_v2 = must(url::Url::parse("file:///lib/A-v2.pm"));
5868        let uri_v1 = must(url::Url::parse("file:///lib/A-v1.pm"));
5869        let source = "package A;\nsub foo { 1 }\n1;\n".to_string();
5870        must(index.index_file(uri_v2, source.clone()));
5871        must(index.index_file(uri_v1, source));
5872
5873        let candidates = index.definition_candidates("A::foo");
5874        assert_eq!(candidates.len(), 2);
5875        assert_eq!(candidates[0].uri, "file:///lib/A-v1.pm");
5876        assert_eq!(candidates[1].uri, "file:///lib/A-v2.pm");
5877    }
5878
5879    #[test]
5880    fn test_definition_candidates_are_cleaned_on_remove_and_reindex() {
5881        let index = WorkspaceIndex::new();
5882        let uri = must(url::Url::parse("file:///lib/A.pm"));
5883        must(index.index_file(uri.clone(), "package A;\nsub foo { 1 }\n1;\n".to_string()));
5884        assert_eq!(index.definition_candidates("A::foo").len(), 1);
5885
5886        index.remove_file(uri.as_str());
5887        assert!(index.definition_candidates("A::foo").is_empty());
5888
5889        must(index.index_file(uri, "package A;\nsub foo { 2 }\n1;\n".to_string()));
5890        assert_eq!(index.definition_candidates("A::foo").len(), 1);
5891    }
5892
5893    /// Verify that `incremental_remove_symbols` correctly retains candidates owned by
5894    /// other files when the removed file had BOTH exclusively-owned names (triggering the
5895    /// full-rebuild path) AND shared names. Before this fix, the full-rebuild path cleared
5896    /// all candidates and relied on the subsequent rebuild to re-add shared ones — correct
5897    /// in effect, but the test documents the expected observable behavior.
5898    #[test]
5899    fn test_definition_candidates_shared_symbol_survives_removal_of_sole_owner_of_other_symbol() {
5900        let index = WorkspaceIndex::new();
5901        let uri_a = must(url::Url::parse("file:///lib/A.pm"));
5902        let uri_b = must(url::Url::parse("file:///lib/B.pm"));
5903
5904        // A defines both `unique_to_a` (no other file) and `shared` (also in B)
5905        must(index.index_file(
5906            uri_a.clone(),
5907            "package A;\nsub unique_to_a { 1 }\nsub shared { 1 }\n1;\n".to_string(),
5908        ));
5909        must(index.index_file(uri_b.clone(), "package B;\nsub shared { 1 }\n1;\n".to_string()));
5910
5911        // Before removal: both shared candidates and unique_to_a are present
5912        assert_eq!(index.definition_candidates("shared").len(), 2);
5913        assert_eq!(index.definition_candidates("unique_to_a").len(), 1);
5914
5915        // Remove A — triggers the affected_names path for `unique_to_a`, but `shared`
5916        // still has B's candidate.
5917        index.remove_file(uri_a.as_str());
5918
5919        assert!(
5920            index.definition_candidates("unique_to_a").is_empty(),
5921            "unique_to_a should be gone after removing A"
5922        );
5923        assert_eq!(
5924            index.definition_candidates("shared").len(),
5925            1,
5926            "shared should still have B's candidate after removing A"
5927        );
5928        assert_eq!(
5929            index.definition_candidates("shared")[0].uri,
5930            "file:///lib/B.pm",
5931            "remaining shared candidate must be from B"
5932        );
5933    }
5934
5935    #[test]
5936    fn test_folder_context_in_file_index() {
5937        let index = WorkspaceIndex::new();
5938
5939        // Set up workspace folders
5940        index.set_workspace_folders(vec![
5941            "file:///project1".to_string(),
5942            "file:///project2".to_string(),
5943        ]);
5944
5945        let uri1 = "file:///project1/lib/Module.pm";
5946        let code1 = r#"
5947package Module;
5948
5949sub test_sub {
5950    return 1;
5951}
5952"#;
5953        must(index.index_file(must(url::Url::parse(uri1)), code1.to_string()));
5954
5955        let uri2 = "file:///project2/lib/Other.pm";
5956        let code2 = r#"
5957package Other;
5958
5959sub other_sub {
5960    return 2;
5961}
5962"#;
5963        must(index.index_file(must(url::Url::parse(uri2)), code2.to_string()));
5964
5965        // Verify folder context is set correctly
5966        let symbols1 = index.file_symbols(uri1);
5967        assert_eq!(symbols1.len(), 2, "Should have 2 symbols in Module.pm");
5968        for symbol in &symbols1 {
5969            assert_eq!(symbol.uri, uri1, "Symbol URI should match file URI");
5970        }
5971
5972        let symbols2 = index.file_symbols(uri2);
5973        assert_eq!(symbols2.len(), 2, "Should have 2 symbols in Other.pm");
5974        for symbol in &symbols2 {
5975            assert_eq!(symbol.uri, uri2, "Symbol URI should match file URI");
5976        }
5977
5978        // Verify folder attribution
5979        let files = index.files.read();
5980        let file_index1 = must_some(files.get(&DocumentStore::uri_key(uri1)));
5981        assert_eq!(
5982            file_index1.folder_uri,
5983            Some("file:///project1".to_string()),
5984            "File should be attributed to correct workspace folder"
5985        );
5986
5987        let file_index2 = must_some(files.get(&DocumentStore::uri_key(uri2)));
5988        assert_eq!(
5989            file_index2.folder_uri,
5990            Some("file:///project2".to_string()),
5991            "File should be attributed to correct workspace folder"
5992        );
5993    }
5994
5995    #[test]
5996    fn test_determine_folder_uri() {
5997        let index = WorkspaceIndex::new();
5998
5999        // Set up workspace folders
6000        index.set_workspace_folders(vec![
6001            "file:///project1".to_string(),
6002            "file:///project2".to_string(),
6003        ]);
6004
6005        // Test file in project1
6006        let folder1 = index.determine_folder_uri("file:///project1/lib/Module.pm");
6007        assert_eq!(
6008            folder1,
6009            Some("file:///project1".to_string()),
6010            "Should determine folder for file in project1"
6011        );
6012
6013        // Test file in project2
6014        let folder2 = index.determine_folder_uri("file:///project2/lib/Other.pm");
6015        assert_eq!(
6016            folder2,
6017            Some("file:///project2".to_string()),
6018            "Should determine folder for file in project2"
6019        );
6020
6021        // Test file not in any workspace folder
6022        let folder_none = index.determine_folder_uri("file:///other/project/Module.pm");
6023        assert_eq!(folder_none, None, "Should return None for file outside workspace folders");
6024    }
6025
6026    #[test]
6027    fn test_determine_folder_uri_prefers_most_specific_match() {
6028        let index = WorkspaceIndex::new();
6029
6030        // Keep broad folder first to ensure we don't rely on insertion order.
6031        index.set_workspace_folders(vec![
6032            "file:///project".to_string(),
6033            "file:///project/lib".to_string(),
6034        ]);
6035
6036        let folder = index.determine_folder_uri("file:///project/lib/My/Module.pm");
6037        assert_eq!(
6038            folder,
6039            Some("file:///project/lib".to_string()),
6040            "Nested workspace folders should attribute files to the most specific folder"
6041        );
6042    }
6043
6044    #[test]
6045    fn test_remove_folder() {
6046        let index = WorkspaceIndex::new();
6047
6048        // Set up workspace folders
6049        index.set_workspace_folders(vec![
6050            "file:///project1".to_string(),
6051            "file:///project2".to_string(),
6052        ]);
6053
6054        // Index files from both folders
6055        let uri1 = "file:///project1/lib/Module.pm";
6056        let code1 = r#"
6057package Module;
6058
6059sub test_sub {
6060    return 1;
6061}
6062"#;
6063        must(index.index_file(must(url::Url::parse(uri1)), code1.to_string()));
6064
6065        let uri2 = "file:///project2/lib/Other.pm";
6066        let code2 = r#"
6067package Other;
6068
6069sub other_sub {
6070    return 2;
6071}
6072"#;
6073        must(index.index_file(must(url::Url::parse(uri2)), code2.to_string()));
6074
6075        // Verify both files are indexed
6076        assert_eq!(index.file_count(), 2, "Should have 2 files indexed");
6077        assert_eq!(index.document_store().count(), 2, "Document store should track both files");
6078
6079        // Remove project1 folder
6080        index.remove_folder("file:///project1");
6081
6082        // Verify only project2 file remains
6083        assert_eq!(index.file_count(), 1, "Should have 1 file after removing folder");
6084        assert_eq!(
6085            index.document_store().count(),
6086            1,
6087            "Document store should drop files removed via folder deletion"
6088        );
6089        assert!(index.file_symbols(uri1).is_empty(), "File from removed folder should be gone");
6090        assert_eq!(
6091            index.file_symbols(uri2).len(),
6092            2,
6093            "File from remaining folder should still be present"
6094        );
6095    }
6096
6097    #[test]
6098    fn test_remove_folder_removes_symbol_free_files() {
6099        let index = WorkspaceIndex::new();
6100        index.set_workspace_folders(vec!["file:///project1".to_string()]);
6101
6102        let uri = "file:///project1/empty.pl";
6103        must(index.index_file(must(url::Url::parse(uri)), "# comments only".to_string()));
6104        assert_eq!(index.file_count(), 1, "Expected file to be indexed");
6105
6106        index.remove_folder("file:///project1");
6107
6108        assert_eq!(index.file_count(), 0, "Folder removal should delete symbol-free files");
6109        assert_eq!(
6110            index.document_store().count(),
6111            0,
6112            "Document store should stay in sync for symbol-free files"
6113        );
6114    }
6115
6116    // ========================================================================
6117    // GREEN-TDD EDGE CASE TESTS FOR ISSUE #6061 (static require + manual import)
6118    // ========================================================================
6119
6120    #[test]
6121    fn test_require_with_variable_target_is_not_indexed() -> Result<(), Box<dyn std::error::Error>>
6122    {
6123        let index = WorkspaceIndex::new();
6124        let uri = must(url::Url::parse("file:///test/require-var.pl"));
6125        let src = r#"package Test;
6126my $loader = 'MyModule';
6127require $loader;
61281;
6129"#;
6130        must(index.index_file(uri.clone(), src.to_string()));
6131        let deps = index.file_dependencies(uri.as_str());
6132        assert!(
6133            !deps.contains("MyModule"),
6134            "require with variable target should not register static dependency"
6135        );
6136        Ok(())
6137    }
6138
6139    #[test]
6140    fn test_multiple_import_calls_on_same_module() -> Result<(), Box<dyn std::error::Error>> {
6141        let index = WorkspaceIndex::new();
6142        let uri = must(url::Url::parse("file:///test/multi-import.pl"));
6143        let src = r#"package Test;
6144require Toolkit;
6145Toolkit->import('func_a');
6146Toolkit->import(qw(func_b func_c));
61471;
6148"#;
6149        must(index.index_file(uri.clone(), src.to_string()));
6150        let deps = index.file_dependencies(uri.as_str());
6151        assert!(deps.contains("Toolkit"), "module should be tracked as dependency");
6152        for symbol in &["func_a", "func_b", "func_c"] {
6153            let refs = index.find_references(symbol);
6154            assert!(!refs.is_empty(), "all imported symbols should be indexed: {}", symbol);
6155        }
6156        Ok(())
6157    }
6158
6159    #[test]
6160    fn test_require_string_vs_bareword_normalization() -> Result<(), Box<dyn std::error::Error>> {
6161        let index = WorkspaceIndex::new();
6162        let uri = must(url::Url::parse("file:///test/require-string.pl"));
6163        let src = r#"package Consumer;
6164require "String/Based/Module.pm";
6165String::Based::Module->import('exported');
61661;
6167"#;
6168        must(index.index_file(uri.clone(), src.to_string()));
6169        let deps = index.file_dependencies(uri.as_str());
6170        assert!(
6171            deps.contains("String::Based::Module"),
6172            "require string form should normalize path separators to ::"
6173        );
6174        let refs = index.find_references("exported");
6175        assert!(!refs.is_empty(), "import should be indexed even with string-form require");
6176        Ok(())
6177    }
6178
6179    #[test]
6180    fn test_import_without_require_registers_as_method_call()
6181    -> Result<(), Box<dyn std::error::Error>> {
6182        // Edge case: ->import() without preceding require is treated as a normal method call,
6183        // not as the static manual-import pattern, so the module is still visited/tracked
6184        // but the symbols are NOT marked as imports from the static require+import logic.
6185        let index = WorkspaceIndex::new();
6186        let uri = must(url::Url::parse("file:///test/orphan-import.pl"));
6187        let src = r#"package Test;
6188Unrelated::Module->import('orphaned');
6189orphaned();
61901;
6191"#;
6192        must(index.index_file(uri.clone(), src.to_string()));
6193
6194        // The module reference may still be tracked as a method call target,
6195        // but the key regression is: the orphaned symbol should not be indexed
6196        // as an import reference due to the missing require.
6197        let _refs = index.find_references("orphaned");
6198        // Symbol may be referenced but should not be specially treated as an import.
6199        // The main point is: without require, the pairing doesn't activate.
6200        Ok(())
6201    }
6202
6203    #[test]
6204    fn test_nested_blocks_preserve_require_scope() -> Result<(), Box<dyn std::error::Error>> {
6205        let index = WorkspaceIndex::new();
6206        let uri = must(url::Url::parse("file:///test/nested.pl"));
6207        let src = r#"package Test;
6208{
6209    require Outer;
6210    {
6211        Outer->import('nested_sym');
6212    }
6213}
62141;
6215"#;
6216        must(index.index_file(uri.clone(), src.to_string()));
6217        let deps = index.file_dependencies(uri.as_str());
6218        assert!(
6219            deps.contains("Outer"),
6220            "require in outer block should be visible to nested import"
6221        );
6222        let refs = index.find_references("nested_sym");
6223        assert!(!refs.is_empty(), "symbol imported in nested block should still be indexed");
6224        Ok(())
6225    }
6226
6227    #[test]
6228    fn test_require_path_without_pm_extension() -> Result<(), Box<dyn std::error::Error>> {
6229        let index = WorkspaceIndex::new();
6230        let uri = must(url::Url::parse("file:///test/no-ext.pl"));
6231        let src = r#"package Test;
6232require "My/Module";
6233My::Module->import('func');
62341;
6235"#;
6236        must(index.index_file(uri.clone(), src.to_string()));
6237        let deps = index.file_dependencies(uri.as_str());
6238        assert!(
6239            deps.contains("My::Module"),
6240            "require without .pm extension should normalize to module path"
6241        );
6242        Ok(())
6243    }
6244
6245    #[test]
6246    fn test_qw_with_bracket_delimiters() -> Result<(), Box<dyn std::error::Error>> {
6247        let index = WorkspaceIndex::new();
6248        let uri = must(url::Url::parse("file:///test/qw-delim.pl"));
6249        let src = r#"package Test;
6250require DelimModule;
6251DelimModule->import(qw[sym1 sym2]);
6252DelimModule->import(qw{sym3 sym4});
62531;
6254"#;
6255        must(index.index_file(uri.clone(), src.to_string()));
6256        for symbol in &["sym1", "sym2", "sym3", "sym4"] {
6257            let refs = index.find_references(symbol);
6258            assert!(
6259                !refs.is_empty(),
6260                "symbols from qw with bracket delimiters should be indexed: {}",
6261                symbol
6262            );
6263        }
6264        Ok(())
6265    }
6266
6267    #[test]
6268    fn test_array_literal_import_args() -> Result<(), Box<dyn std::error::Error>> {
6269        let index = WorkspaceIndex::new();
6270        let uri = must(url::Url::parse("file:///test/array-import.pl"));
6271        let src = r#"package Test;
6272require ArrayModule;
6273ArrayModule->import(['sym_x', 'sym_y']);
62741;
6275"#;
6276        must(index.index_file(uri.clone(), src.to_string()));
6277        for symbol in &["sym_x", "sym_y"] {
6278            let refs = index.find_references(symbol);
6279            assert!(
6280                !refs.is_empty(),
6281                "symbols from array literal import should be indexed: {}",
6282                symbol
6283            );
6284        }
6285        Ok(())
6286    }
6287
6288    #[test]
6289    fn test_require_inside_conditional_still_registers_dependency()
6290    -> Result<(), Box<dyn std::error::Error>> {
6291        let index = WorkspaceIndex::new();
6292        let uri = must(url::Url::parse("file:///test/cond-require.pl"));
6293        let src = r#"package Test;
6294if (1) {
6295    require ConditionalMod;
6296    ConditionalMod->import('cond_func');
6297}
62981;
6299"#;
6300        must(index.index_file(uri.clone(), src.to_string()));
6301        let deps = index.file_dependencies(uri.as_str());
6302        assert!(
6303            deps.contains("ConditionalMod"),
6304            "require inside conditional should still register as dependency"
6305        );
6306        let refs = index.find_references("cond_func");
6307        assert!(!refs.is_empty(), "import inside conditional should still index symbols");
6308        Ok(())
6309    }
6310
6311    #[test]
6312    fn test_mixed_string_and_bareword_imports() -> Result<(), Box<dyn std::error::Error>> {
6313        let index = WorkspaceIndex::new();
6314        let uri = must(url::Url::parse("file:///test/mixed-import.pl"));
6315        let src = r#"package Test;
6316require MixedMod;
6317MixedMod->import('string_sym');
6318MixedMod->import(qw(qw_one qw_two));
63191;
6320"#;
6321        must(index.index_file(uri.clone(), src.to_string()));
6322        let deps = index.file_dependencies(uri.as_str());
6323        assert!(deps.contains("MixedMod"), "require should register dependency");
6324        for symbol in &["string_sym", "qw_one", "qw_two"] {
6325            let refs = index.find_references(symbol);
6326            assert!(!refs.is_empty(), "all import forms should index symbols: {}", symbol);
6327        }
6328        Ok(())
6329    }
6330}