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
84use crate::semantic::facts::PRODUCER_SCHEMA_VERSION;
85use crate::semantic::imports::ImportExportIndex;
86pub use crate::semantic::invalidation::ShardReplaceResult;
87use crate::semantic::invalidation::{ShardCategoryHashes, plan_shard_replacement};
88use crate::semantic::references::ReferenceIndex;
89pub use crate::workspace::monitoring::{
90    DegradationReason, EarlyExitReason, EarlyExitRecord, IndexInstrumentationSnapshot,
91    IndexMetrics, IndexPerformanceCaps, IndexPhase, IndexPhaseTransition, IndexResourceLimits,
92    IndexStateKind, IndexStateTransition, ResourceKind,
93};
94use perl_symbol::surface::decl::extract_symbol_decls;
95use perl_symbol::surface::facts::{symbol_decls_to_semantic_facts, symbol_refs_to_semantic_facts};
96use perl_symbol::surface::r#ref::extract_symbol_refs;
97
98// Re-export URI utilities for backward compatibility
99#[cfg(not(target_arch = "wasm32"))]
100/// URI ↔ filesystem helpers used during Index/Analyze workflows.
101pub use perl_uri::{fs_path_to_uri, uri_to_fs_path};
102/// URI inspection helpers used during Index/Analyze workflows.
103pub use perl_uri::{is_file_uri, is_special_scheme, uri_extension, uri_key};
104
105// ============================================================================
106// Index Lifecycle Types (Index Lifecycle v1 Specification)
107// ============================================================================
108
109/// Index readiness state - explicit lifecycle management
110///
111/// Represents the current operational state of the workspace index, enabling
112/// LSP handlers to provide appropriate responses based on index availability.
113/// This state machine prevents blocking operations and ensures graceful
114/// degradation when the index is not fully ready.
115///
116/// # State Transitions
117///
118/// - `Building` → `Ready`: Workspace scan completes successfully
119/// - `Building` → `Degraded`: Scan timeout, IO error, or resource limit
120/// - `Ready` → `Building`: Workspace folder change or file watching events
121/// - `Ready` → `Degraded`: Parse storm (>10 pending) or IO error
122/// - `Degraded` → `Building`: Recovery attempt after cooldown
123/// - `Degraded` → `Ready`: Successful re-scan after recovery
124///
125/// # Invariants
126///
127/// - During a single build attempt, `phase` advances monotonically
128///   (`Idle` → `Scanning` → `Indexing`).
129/// - `indexed_count` must not exceed `total_count`; callers should keep totals updated.
130/// - `Ready` and `Degraded` counts are snapshots captured at transition time.
131///
132/// # Usage
133///
134/// ```rust,ignore
135/// use perl_parser::workspace_index::{IndexPhase, IndexState};
136/// use std::time::Instant;
137///
138/// let state = IndexState::Building {
139///     phase: IndexPhase::Indexing,
140///     indexed_count: 50,
141///     total_count: 100,
142///     started_at: Instant::now(),
143/// };
144/// ```
145#[derive(Clone, Debug)]
146pub enum IndexState {
147    /// Index is being constructed (workspace scan in progress)
148    Building {
149        /// Current build phase (Idle → Scanning → Indexing)
150        phase: IndexPhase,
151        /// Files indexed so far
152        indexed_count: usize,
153        /// Total files discovered
154        total_count: usize,
155        /// Started at
156        started_at: Instant,
157    },
158
159    /// Index is consistent and ready for queries
160    Ready {
161        /// Total symbols indexed
162        symbol_count: usize,
163        /// Total files indexed
164        file_count: usize,
165        /// Timestamp of last successful index
166        completed_at: Instant,
167    },
168
169    /// Index is serving but degraded
170    Degraded {
171        /// Why we degraded
172        reason: DegradationReason,
173        /// What's still available
174        available_symbols: usize,
175        /// When degradation occurred
176        since: Instant,
177    },
178}
179
180impl IndexState {
181    /// Return the coarse state kind for instrumentation and routing decisions
182    pub fn kind(&self) -> IndexStateKind {
183        match self {
184            IndexState::Building { .. } => IndexStateKind::Building,
185            IndexState::Ready { .. } => IndexStateKind::Ready,
186            IndexState::Degraded { .. } => IndexStateKind::Degraded,
187        }
188    }
189
190    /// Return the current build phase when in `Building` state
191    pub fn phase(&self) -> Option<IndexPhase> {
192        match self {
193            IndexState::Building { phase, .. } => Some(*phase),
194            _ => None,
195        }
196    }
197
198    /// Timestamp of when the current state began
199    pub fn state_started_at(&self) -> Instant {
200        match self {
201            IndexState::Building { started_at, .. } => *started_at,
202            IndexState::Ready { completed_at, .. } => *completed_at,
203            IndexState::Degraded { since, .. } => *since,
204        }
205    }
206}
207
208/// Coordinates index lifecycle, state transitions, and handler queries
209///
210/// The IndexCoordinator wraps `WorkspaceIndex` with explicit state management,
211/// enabling LSP handlers to query the index readiness and implement appropriate
212/// fallback behavior when the index is not fully ready.
213///
214/// # Architecture
215///
216/// ```text
217/// LspServer
218///   └── IndexCoordinator
219///         ├── state: Arc<RwLock<IndexState>>
220///         ├── index: Arc<WorkspaceIndex>
221///         ├── limits: IndexResourceLimits
222///         ├── caps: IndexPerformanceCaps
223///         ├── metrics: IndexMetrics
224///         └── instrumentation: IndexInstrumentation
225/// ```
226///
227/// # State Management
228///
229/// The coordinator manages three states:
230/// - `Building`: Initial scan or recovery in progress
231/// - `Ready`: Fully indexed and available for queries
232/// - `Degraded`: Available but with reduced functionality
233///
234/// # Performance Characteristics
235///
236/// - State checks are lock-free reads (cloned state, <100ns)
237/// - State transitions use write locks (rare, <1μs)
238/// - Query dispatch has zero overhead in Ready state
239/// - Degradation detection is atomic (<10ns per check)
240///
241/// # Usage
242///
243/// ```rust,ignore
244/// use perl_parser::workspace_index::{IndexCoordinator, IndexState};
245///
246/// let coordinator = IndexCoordinator::new();
247/// assert!(matches!(coordinator.state(), IndexState::Building { .. }));
248///
249/// // Transition to ready after indexing
250/// coordinator.transition_to_ready(100, 5000);
251/// assert!(matches!(coordinator.state(), IndexState::Ready { .. }));
252///
253/// // Query with degradation handling
254/// let _result = coordinator.query(
255///     |index| index.find_definition("my_function"), // full query
256///     |_index| None                                 // partial fallback
257/// );
258/// ```
259pub struct IndexCoordinator {
260    /// Current index state (RwLock for state transitions)
261    state: Arc<RwLock<IndexState>>,
262
263    /// The actual workspace index
264    index: Arc<WorkspaceIndex>,
265
266    /// Resource limits configuration
267    ///
268    /// Enforces bounded resource usage to prevent unbounded memory growth:
269    /// - max_files: Triggers degradation when file count exceeds limit
270    /// - max_total_symbols: Triggers degradation when symbol count exceeds limit
271    /// - max_symbols_per_file: Used for per-file validation during indexing
272    limits: IndexResourceLimits,
273
274    /// Performance caps for early-exit heuristics
275    caps: IndexPerformanceCaps,
276
277    /// Runtime metrics for degradation detection
278    metrics: IndexMetrics,
279
280    /// Instrumentation for lifecycle transitions and durations
281    instrumentation: IndexInstrumentation,
282}
283
284impl std::fmt::Debug for IndexCoordinator {
285    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
286        f.debug_struct("IndexCoordinator")
287            .field("state", &*self.state.read())
288            .field("limits", &self.limits)
289            .field("caps", &self.caps)
290            .finish_non_exhaustive()
291    }
292}
293
294impl IndexCoordinator {
295    /// Create a new coordinator in Building state
296    ///
297    /// Initializes the coordinator with default resource limits and
298    /// an empty workspace index ready for initial scan.
299    ///
300    /// # Returns
301    ///
302    /// A coordinator initialized in `IndexState::Building`.
303    ///
304    /// # Examples
305    ///
306    /// ```rust,ignore
307    /// use perl_parser::workspace_index::IndexCoordinator;
308    ///
309    /// let coordinator = IndexCoordinator::new();
310    /// ```
311    pub fn new() -> Self {
312        Self {
313            state: Arc::new(RwLock::new(IndexState::Building {
314                phase: IndexPhase::Idle,
315                indexed_count: 0,
316                total_count: 0,
317                started_at: Instant::now(),
318            })),
319            index: Arc::new(WorkspaceIndex::new()),
320            limits: IndexResourceLimits::default(),
321            caps: IndexPerformanceCaps::default(),
322            metrics: IndexMetrics::new(),
323            instrumentation: IndexInstrumentation::new(),
324        }
325    }
326
327    /// Create a coordinator with custom resource limits
328    ///
329    /// # Arguments
330    ///
331    /// * `limits` - Custom resource limits for this workspace
332    ///
333    /// # Returns
334    ///
335    /// A coordinator configured with the provided resource limits.
336    ///
337    /// # Examples
338    ///
339    /// ```rust,ignore
340    /// use perl_parser::workspace_index::{IndexCoordinator, IndexResourceLimits};
341    ///
342    /// let limits = IndexResourceLimits::default();
343    /// let coordinator = IndexCoordinator::with_limits(limits);
344    /// ```
345    pub fn with_limits(limits: IndexResourceLimits) -> Self {
346        Self {
347            state: Arc::new(RwLock::new(IndexState::Building {
348                phase: IndexPhase::Idle,
349                indexed_count: 0,
350                total_count: 0,
351                started_at: Instant::now(),
352            })),
353            index: Arc::new(WorkspaceIndex::new()),
354            limits,
355            caps: IndexPerformanceCaps::default(),
356            metrics: IndexMetrics::new(),
357            instrumentation: IndexInstrumentation::new(),
358        }
359    }
360
361    /// Create a coordinator with custom limits and performance caps
362    ///
363    /// # Arguments
364    ///
365    /// * `limits` - Resource limits for this workspace
366    /// * `caps` - Performance caps for indexing budgets
367    pub fn with_limits_and_caps(limits: IndexResourceLimits, caps: IndexPerformanceCaps) -> Self {
368        Self {
369            state: Arc::new(RwLock::new(IndexState::Building {
370                phase: IndexPhase::Idle,
371                indexed_count: 0,
372                total_count: 0,
373                started_at: Instant::now(),
374            })),
375            index: Arc::new(WorkspaceIndex::new()),
376            limits,
377            caps,
378            metrics: IndexMetrics::new(),
379            instrumentation: IndexInstrumentation::new(),
380        }
381    }
382
383    /// Get current state (lock-free read via clone)
384    ///
385    /// Returns a cloned copy of the current state for lock-free access
386    /// in hot path LSP handlers.
387    ///
388    /// # Returns
389    ///
390    /// The current `IndexState` snapshot.
391    ///
392    /// # Examples
393    ///
394    /// ```rust,ignore
395    /// use perl_parser::workspace_index::{IndexCoordinator, IndexState};
396    ///
397    /// let coordinator = IndexCoordinator::new();
398    /// match coordinator.state() {
399    ///     IndexState::Ready { .. } => {
400    ///         // Full query path
401    ///     }
402    ///     _ => {
403    ///         // Degraded/building fallback
404    ///     }
405    /// }
406    /// ```
407    pub fn state(&self) -> IndexState {
408        self.state.read().clone()
409    }
410
411    /// Get reference to the underlying workspace index
412    ///
413    /// Provides direct access to the `WorkspaceIndex` for operations
414    /// that don't require state checking (e.g., document store access).
415    ///
416    /// # Returns
417    ///
418    /// A shared reference to the underlying workspace index.
419    ///
420    /// # Examples
421    ///
422    /// ```rust,ignore
423    /// use perl_parser::workspace_index::IndexCoordinator;
424    ///
425    /// let coordinator = IndexCoordinator::new();
426    /// let _index = coordinator.index();
427    /// ```
428    pub fn index(&self) -> &Arc<WorkspaceIndex> {
429        &self.index
430    }
431
432    /// Access the configured resource limits
433    pub fn limits(&self) -> &IndexResourceLimits {
434        &self.limits
435    }
436
437    /// Access the configured performance caps
438    pub fn performance_caps(&self) -> &IndexPerformanceCaps {
439        &self.caps
440    }
441
442    /// Snapshot lifecycle instrumentation (durations, transitions, early exits)
443    pub fn instrumentation_snapshot(&self) -> IndexInstrumentationSnapshot {
444        self.instrumentation.snapshot()
445    }
446
447    /// Notify of file change (may trigger state transition)
448    ///
449    /// Increments the pending parse count and may transition to degraded
450    /// state if a parse storm is detected.
451    ///
452    /// # Arguments
453    ///
454    /// * `_uri` - URI of the changed file (reserved for future use).
455    ///
456    /// # Returns
457    ///
458    /// Nothing. Updates coordinator metrics and state for the LSP workflow.
459    ///
460    /// # Examples
461    ///
462    /// ```rust,ignore
463    /// use perl_parser::workspace_index::IndexCoordinator;
464    ///
465    /// let coordinator = IndexCoordinator::new();
466    /// coordinator.notify_change("file:///example.pl");
467    /// ```
468    pub fn notify_change(&self, _uri: &str) {
469        let pending = self.metrics.increment_pending_parses();
470
471        // Check for parse storm
472        if self.metrics.is_parse_storm() {
473            self.transition_to_degraded(DegradationReason::ParseStorm { pending_parses: pending });
474        }
475    }
476
477    /// Notify parse completion for the Index/Analyze workflow stages.
478    ///
479    /// Decrements the pending parse count, enforces resource limits, and may
480    /// attempt recovery when parse storms clear.
481    ///
482    /// # Arguments
483    ///
484    /// * `_uri` - URI of the parsed file (reserved for future use).
485    ///
486    /// # Returns
487    ///
488    /// Nothing. Updates coordinator metrics and state for the LSP workflow.
489    ///
490    /// # Examples
491    ///
492    /// ```rust,ignore
493    /// use perl_parser::workspace_index::IndexCoordinator;
494    ///
495    /// let coordinator = IndexCoordinator::new();
496    /// coordinator.notify_parse_complete("file:///example.pl");
497    /// ```
498    pub fn notify_parse_complete(&self, _uri: &str) {
499        let pending = self.metrics.decrement_pending_parses();
500
501        // Check for recovery from parse storm
502        if pending == 0 {
503            if let IndexState::Degraded { reason: DegradationReason::ParseStorm { .. }, .. } =
504                self.state()
505            {
506                // Attempt recovery - transition back to Building for re-scan
507                let mut state = self.state.write();
508                let from_kind = state.kind();
509                self.instrumentation.record_state_transition(from_kind, IndexStateKind::Building);
510                *state = IndexState::Building {
511                    phase: IndexPhase::Idle,
512                    indexed_count: 0,
513                    total_count: 0,
514                    started_at: Instant::now(),
515                };
516            }
517        }
518
519        // Enforce resource limits after parse completion
520        self.enforce_limits();
521    }
522
523    /// Transition to Ready state
524    ///
525    /// Marks the index as fully ready for queries after successful workspace
526    /// scan. Records the file count, symbol count, and completion timestamp.
527    /// Enforces resource limits after transition.
528    ///
529    /// # State Transition Guards
530    ///
531    /// Only valid transitions:
532    /// - `Building` → `Ready` (normal completion)
533    /// - `Degraded` → `Ready` (recovery after fix)
534    ///
535    /// # Arguments
536    ///
537    /// * `file_count` - Total number of files indexed
538    /// * `symbol_count` - Total number of symbols extracted
539    ///
540    /// # Returns
541    ///
542    /// Nothing. The coordinator state is updated in-place.
543    ///
544    /// # Examples
545    ///
546    /// ```rust,ignore
547    /// use perl_parser::workspace_index::IndexCoordinator;
548    ///
549    /// let coordinator = IndexCoordinator::new();
550    /// coordinator.transition_to_ready(100, 5000);
551    /// ```
552    pub fn transition_to_ready(&self, file_count: usize, symbol_count: usize) {
553        let mut state = self.state.write();
554        let from_kind = state.kind();
555
556        // State transition guard: validate current state allows transition to Ready
557        match &*state {
558            IndexState::Building { .. } | IndexState::Degraded { .. } => {
559                // Valid transition - proceed
560                *state =
561                    IndexState::Ready { symbol_count, file_count, completed_at: Instant::now() };
562            }
563            IndexState::Ready { .. } => {
564                // Already Ready - update metrics but don't log as transition
565                *state =
566                    IndexState::Ready { symbol_count, file_count, completed_at: Instant::now() };
567            }
568        }
569        self.instrumentation.record_state_transition(from_kind, IndexStateKind::Ready);
570        drop(state); // Release write lock before checking limits
571
572        // Enforce resource limits after transition
573        self.enforce_limits();
574    }
575
576    /// Transition to Scanning phase (Idle → Scanning)
577    ///
578    /// Resets build counters and marks the index as scanning workspace folders.
579    pub fn transition_to_scanning(&self) {
580        let mut state = self.state.write();
581        let from_kind = state.kind();
582
583        match &*state {
584            IndexState::Building { phase, indexed_count, total_count, started_at } => {
585                if *phase != IndexPhase::Scanning {
586                    self.instrumentation.record_phase_transition(*phase, IndexPhase::Scanning);
587                }
588                *state = IndexState::Building {
589                    phase: IndexPhase::Scanning,
590                    indexed_count: *indexed_count,
591                    total_count: *total_count,
592                    started_at: *started_at,
593                };
594            }
595            IndexState::Ready { .. } | IndexState::Degraded { .. } => {
596                self.instrumentation.record_state_transition(from_kind, IndexStateKind::Building);
597                self.instrumentation
598                    .record_phase_transition(IndexPhase::Idle, IndexPhase::Scanning);
599                *state = IndexState::Building {
600                    phase: IndexPhase::Scanning,
601                    indexed_count: 0,
602                    total_count: 0,
603                    started_at: Instant::now(),
604                };
605            }
606        }
607    }
608
609    /// Update scanning progress with the latest discovered file count
610    pub fn update_scan_progress(&self, total_count: usize) {
611        let mut state = self.state.write();
612        if let IndexState::Building { phase, indexed_count, started_at, .. } = &*state {
613            if *phase != IndexPhase::Scanning {
614                self.instrumentation.record_phase_transition(*phase, IndexPhase::Scanning);
615            }
616            *state = IndexState::Building {
617                phase: IndexPhase::Scanning,
618                indexed_count: *indexed_count,
619                total_count,
620                started_at: *started_at,
621            };
622        }
623    }
624
625    /// Transition to Indexing phase (Scanning → Indexing)
626    ///
627    /// Uses the discovered file count as the total index target.
628    pub fn transition_to_indexing(&self, total_count: usize) {
629        let mut state = self.state.write();
630        let from_kind = state.kind();
631
632        match &*state {
633            IndexState::Building { phase, indexed_count, started_at, .. } => {
634                if *phase != IndexPhase::Indexing {
635                    self.instrumentation.record_phase_transition(*phase, IndexPhase::Indexing);
636                }
637                *state = IndexState::Building {
638                    phase: IndexPhase::Indexing,
639                    indexed_count: *indexed_count,
640                    total_count,
641                    started_at: *started_at,
642                };
643            }
644            IndexState::Ready { .. } | IndexState::Degraded { .. } => {
645                self.instrumentation.record_state_transition(from_kind, IndexStateKind::Building);
646                self.instrumentation
647                    .record_phase_transition(IndexPhase::Idle, IndexPhase::Indexing);
648                *state = IndexState::Building {
649                    phase: IndexPhase::Indexing,
650                    indexed_count: 0,
651                    total_count,
652                    started_at: Instant::now(),
653                };
654            }
655        }
656    }
657
658    /// Transition to Building state (Indexing phase)
659    ///
660    /// Marks the index as indexing with a known total file count.
661    pub fn transition_to_building(&self, total_count: usize) {
662        let mut state = self.state.write();
663        let from_kind = state.kind();
664
665        // State transition guard: validate transition is allowed
666        match &*state {
667            IndexState::Degraded { .. } | IndexState::Ready { .. } => {
668                self.instrumentation.record_state_transition(from_kind, IndexStateKind::Building);
669                self.instrumentation
670                    .record_phase_transition(IndexPhase::Idle, IndexPhase::Indexing);
671                *state = IndexState::Building {
672                    phase: IndexPhase::Indexing,
673                    indexed_count: 0,
674                    total_count,
675                    started_at: Instant::now(),
676                };
677            }
678            IndexState::Building { phase, indexed_count, started_at, .. } => {
679                let mut next_phase = *phase;
680                if *phase == IndexPhase::Idle {
681                    self.instrumentation
682                        .record_phase_transition(IndexPhase::Idle, IndexPhase::Indexing);
683                    next_phase = IndexPhase::Indexing;
684                }
685                *state = IndexState::Building {
686                    phase: next_phase,
687                    indexed_count: *indexed_count,
688                    total_count,
689                    started_at: *started_at,
690                };
691            }
692        }
693    }
694
695    /// Update Building state progress for the Index/Analyze workflow stages.
696    ///
697    /// Increments the indexed file count and checks for scan timeouts.
698    ///
699    /// # Arguments
700    ///
701    /// * `indexed_count` - Number of files indexed so far.
702    ///
703    /// # Returns
704    ///
705    /// Nothing. Updates coordinator state and may transition to `Degraded`.
706    ///
707    /// # Examples
708    ///
709    /// ```rust,ignore
710    /// use perl_parser::workspace_index::IndexCoordinator;
711    ///
712    /// let coordinator = IndexCoordinator::new();
713    /// coordinator.transition_to_building(100);
714    /// coordinator.update_building_progress(1);
715    /// ```
716    pub fn update_building_progress(&self, indexed_count: usize) {
717        let mut state = self.state.write();
718
719        if let IndexState::Building { phase, started_at, total_count, .. } = &*state {
720            let elapsed = started_at.elapsed().as_millis() as u64;
721
722            // Check for scan timeout
723            if elapsed > self.limits.max_scan_duration_ms {
724                // Timeout exceeded - transition to degraded
725                drop(state);
726                self.transition_to_degraded(DegradationReason::ScanTimeout { elapsed_ms: elapsed });
727                return;
728            }
729
730            // Update progress
731            *state = IndexState::Building {
732                phase: *phase,
733                indexed_count,
734                total_count: *total_count,
735                started_at: *started_at,
736            };
737        }
738    }
739
740    /// Transition to Degraded state
741    ///
742    /// Marks the index as degraded with the specified reason. Preserves
743    /// the current symbol count (if available) to indicate partial
744    /// functionality remains.
745    ///
746    /// # Arguments
747    ///
748    /// * `reason` - Why the index degraded (ParseStorm, IoError, etc.)
749    ///
750    /// # Returns
751    ///
752    /// Nothing. The coordinator state is updated in-place.
753    ///
754    /// # Examples
755    ///
756    /// ```rust,ignore
757    /// use perl_parser::workspace_index::{DegradationReason, IndexCoordinator, ResourceKind};
758    ///
759    /// let coordinator = IndexCoordinator::new();
760    /// coordinator.transition_to_degraded(DegradationReason::ResourceLimit {
761    ///     kind: ResourceKind::MaxFiles,
762    /// });
763    /// ```
764    pub fn transition_to_degraded(&self, reason: DegradationReason) {
765        let mut state = self.state.write();
766        let from_kind = state.kind();
767
768        // Get available symbols count from current state
769        let available_symbols = match &*state {
770            IndexState::Ready { symbol_count, .. } => *symbol_count,
771            IndexState::Degraded { available_symbols, .. } => *available_symbols,
772            IndexState::Building { .. } => 0,
773        };
774
775        self.instrumentation.record_state_transition(from_kind, IndexStateKind::Degraded);
776        *state = IndexState::Degraded { reason, available_symbols, since: Instant::now() };
777    }
778
779    /// Check resource limits and return degradation reason if exceeded
780    ///
781    /// Examines current workspace index state against configured resource limits.
782    /// Returns the first exceeded limit found, enabling targeted degradation.
783    ///
784    /// # Returns
785    ///
786    /// * `Some(DegradationReason)` - Resource limit exceeded, contains specific limit type
787    /// * `None` - All limits within acceptable bounds
788    ///
789    /// # Checked Limits
790    ///
791    /// - `max_files`: Total number of indexed files
792    /// - `max_total_symbols`: Aggregate symbol count across workspace
793    ///
794    /// # Performance
795    ///
796    /// - Lock-free read of index state (<100ns)
797    /// - Symbol counting is O(n) where n is number of files
798    ///
799    /// Returns: `Some(DegradationReason)` when a limit is exceeded, otherwise `None`.
800    ///
801    /// # Examples
802    ///
803    /// ```rust,ignore
804    /// use perl_parser::workspace_index::IndexCoordinator;
805    ///
806    /// let coordinator = IndexCoordinator::new();
807    /// let _reason = coordinator.check_limits();
808    /// ```
809    pub fn check_limits(&self) -> Option<DegradationReason> {
810        let files = self.index.files.read();
811
812        // Check max_files limit
813        let file_count = files.len();
814        if file_count > self.limits.max_files {
815            return Some(DegradationReason::ResourceLimit { kind: ResourceKind::MaxFiles });
816        }
817
818        // Check max_total_symbols limit
819        let total_symbols: usize = files.values().map(|fi| fi.symbols.len()).sum();
820        if total_symbols > self.limits.max_total_symbols {
821            return Some(DegradationReason::ResourceLimit { kind: ResourceKind::MaxSymbols });
822        }
823
824        None
825    }
826
827    /// Enforce resource limits and trigger degradation if exceeded
828    ///
829    /// Checks current resource usage against configured limits and automatically
830    /// transitions to Degraded state if any limit is exceeded. This method should
831    /// be called after operations that modify index size (file additions, parse
832    /// completions, etc.).
833    ///
834    /// # State Transitions
835    ///
836    /// - `Ready` → `Degraded(ResourceLimit)` if limits exceeded
837    /// - `Building` → `Degraded(ResourceLimit)` if limits exceeded
838    ///
839    /// # Returns
840    ///
841    /// Nothing. The coordinator state is updated in-place when limits are exceeded.
842    ///
843    /// # Examples
844    ///
845    /// ```rust,ignore
846    /// use perl_parser::workspace_index::IndexCoordinator;
847    ///
848    /// let coordinator = IndexCoordinator::new();
849    /// // ... index some files ...
850    /// coordinator.enforce_limits();  // Check and degrade if needed
851    /// ```
852    pub fn enforce_limits(&self) {
853        if let Some(reason) = self.check_limits() {
854            self.transition_to_degraded(reason);
855        }
856    }
857
858    /// Record an early-exit event for indexing instrumentation
859    pub fn record_early_exit(
860        &self,
861        reason: EarlyExitReason,
862        elapsed_ms: u64,
863        indexed_files: usize,
864        total_files: usize,
865    ) {
866        self.instrumentation.record_early_exit(EarlyExitRecord {
867            reason,
868            elapsed_ms,
869            indexed_files,
870            total_files,
871        });
872    }
873
874    /// Query with automatic degradation handling
875    ///
876    /// Dispatches to full query if index is Ready, or partial query otherwise.
877    /// This pattern enables LSP handlers to provide appropriate responses
878    /// based on index state without explicit state checking.
879    ///
880    /// # Type Parameters
881    ///
882    /// * `T` - Return type of the query functions
883    /// * `F1` - Full query function type accepting `&WorkspaceIndex` and returning `T`
884    /// * `F2` - Partial query function type accepting `&WorkspaceIndex` and returning `T`
885    ///
886    /// # Arguments
887    ///
888    /// * `full_query` - Function to execute when index is Ready
889    /// * `partial_query` - Function to execute when index is Building/Degraded
890    ///
891    /// # Returns
892    ///
893    /// The value returned by the selected query function.
894    ///
895    /// # Examples
896    ///
897    /// ```rust,ignore
898    /// use perl_parser::workspace_index::IndexCoordinator;
899    ///
900    /// let coordinator = IndexCoordinator::new();
901    /// let locations = coordinator.query(
902    ///     |index| index.find_references("my_function"),  // Full workspace search
903    ///     |index| vec![]                                 // Empty fallback
904    /// );
905    /// ```
906    pub fn query<T, F1, F2>(&self, full_query: F1, partial_query: F2) -> T
907    where
908        F1: FnOnce(&WorkspaceIndex) -> T,
909        F2: FnOnce(&WorkspaceIndex) -> T,
910    {
911        match self.state() {
912            IndexState::Ready { .. } => full_query(&self.index),
913            _ => partial_query(&self.index),
914        }
915    }
916}
917
918impl Default for IndexCoordinator {
919    fn default() -> Self {
920        Self::new()
921    }
922}
923
924// ============================================================================
925// Symbol Indexing Types
926// ============================================================================
927
928#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
929/// Symbol kinds for cross-file indexing during Index/Navigate workflows.
930pub enum SymKind {
931    /// Variable symbol ($, @, or % sigil)
932    Var,
933    /// Subroutine definition (sub foo)
934    Sub,
935    /// Package declaration (package Foo)
936    Pack,
937}
938
939#[derive(Clone, Debug, Eq, PartialEq, Hash)]
940/// A normalized symbol key for cross-file lookups in Index/Navigate workflows.
941pub struct SymbolKey {
942    /// Package name containing this symbol
943    pub pkg: Arc<str>,
944    /// Bare name without sigil prefix
945    pub name: Arc<str>,
946    /// Variable sigil ($, @, or %) if applicable
947    pub sigil: Option<char>,
948    /// Kind of symbol (variable, subroutine, package)
949    pub kind: SymKind,
950}
951
952/// Normalize a Perl variable name for Index/Analyze workflows.
953///
954/// Extracts an optional sigil and bare name for consistent symbol indexing.
955///
956/// # Arguments
957///
958/// * `name` - Variable name from Perl source, with or without sigil.
959///
960/// # Returns
961///
962/// `(sigil, name)` tuple with the optional sigil and normalized identifier.
963///
964/// # Examples
965///
966/// ```rust,ignore
967/// use perl_parser::workspace_index::normalize_var;
968///
969/// assert_eq!(normalize_var("$count"), (Some('$'), "count"));
970/// assert_eq!(normalize_var("process_emails"), (None, "process_emails"));
971/// ```
972pub fn normalize_var(name: &str) -> (Option<char>, &str) {
973    if name.is_empty() {
974        return (None, "");
975    }
976
977    // Safe: we've checked that name is not empty
978    let Some(first_char) = name.chars().next() else {
979        return (None, name); // Should never happen but handle gracefully
980    };
981    match first_char {
982        '$' | '@' | '%' => {
983            if name.len() > 1 {
984                (Some(first_char), &name[1..])
985            } else {
986                (Some(first_char), "")
987            }
988        }
989        _ => (None, name),
990    }
991}
992
993// Using lsp_types for Position and Range
994
995#[derive(Debug, Clone, PartialEq, Eq)]
996/// Internal location type used during Navigate/Analyze workflows.
997pub struct Location {
998    /// File URI where the symbol is located
999    pub uri: String,
1000    /// Line and character range within the file
1001    pub range: Range,
1002}
1003
1004#[derive(Debug, Clone, PartialEq, Eq)]
1005/// Stable symbol identity returned by cross-file reference queries.
1006pub struct SymbolIdentity {
1007    /// Canonical stable key for the symbol (qualified when available).
1008    pub stable_key: String,
1009    /// Bare symbol name.
1010    pub name: String,
1011    /// Fully qualified symbol name when available.
1012    pub qualified_name: Option<String>,
1013    /// Symbol kind (subroutine, package, variable, ...).
1014    pub kind: SymbolKind,
1015}
1016
1017#[derive(Debug, Clone, PartialEq, Eq)]
1018/// Read-only cross-file query result used by rename/safe-delete planners.
1019pub struct CrossFileReferenceQueryResult {
1020    /// Identity for the resolved symbol.
1021    pub symbol: SymbolIdentity,
1022    /// Definition site for the resolved symbol.
1023    pub definition: Location,
1024    /// All reference locations (including definition) in deterministic order.
1025    pub references: Vec<Location>,
1026}
1027
1028#[derive(Debug, Clone, Serialize, Deserialize)]
1029/// A symbol in the workspace for Index/Navigate workflows.
1030pub struct WorkspaceSymbol {
1031    /// Symbol name without package qualification
1032    pub name: String,
1033    /// Type of symbol (subroutine, variable, package, etc.)
1034    pub kind: SymbolKind,
1035    /// File URI where the symbol is defined
1036    pub uri: String,
1037    /// Line and character range of the symbol definition
1038    pub range: Range,
1039    /// Fully qualified name including package (e.g., "Package::function")
1040    pub qualified_name: Option<String>,
1041    /// POD documentation associated with the symbol
1042    pub documentation: Option<String>,
1043    /// Name of the containing package or class
1044    pub container_name: Option<String>,
1045    /// Whether this symbol has a body (false for forward declarations)
1046    #[serde(default = "default_has_body")]
1047    pub has_body: bool,
1048    /// Workspace folder URI this symbol belongs to (for multi-root workspace support)
1049    pub workspace_folder_uri: Option<String>,
1050}
1051
1052fn default_has_body() -> bool {
1053    true
1054}
1055
1056// Re-export the unified symbol types from perl-symbol
1057/// Symbol kind enums used during Index/Analyze workflows.
1058pub use perl_symbol::{SymbolKind, VarKind};
1059
1060#[derive(Debug, Clone)]
1061/// Reference to a symbol for Navigate/Analyze workflows.
1062pub struct SymbolReference {
1063    /// File URI where the reference occurs
1064    pub uri: String,
1065    /// Line and character range of the reference
1066    pub range: Range,
1067    /// How the symbol is being referenced (definition, usage, etc.)
1068    pub kind: ReferenceKind,
1069}
1070
1071#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1072/// Classification of how a symbol is referenced in Navigate/Analyze workflows.
1073pub enum ReferenceKind {
1074    /// Symbol definition site (sub declaration, variable declaration)
1075    Definition,
1076    /// General usage of the symbol (function call, method call)
1077    Usage,
1078    /// Import via use statement
1079    Import,
1080    /// Variable read access
1081    Read,
1082    /// Variable write access (assignment target)
1083    Write,
1084}
1085
1086#[derive(Debug, Serialize)]
1087#[serde(rename_all = "camelCase")]
1088/// LSP-compliant workspace symbol for wire format in Navigate/Analyze workflows.
1089pub struct LspWorkspaceSymbol {
1090    /// Symbol name as displayed to the user
1091    pub name: String,
1092    /// LSP symbol kind number (see lsp_types::SymbolKind)
1093    pub kind: u32,
1094    /// Location of the symbol definition
1095    pub location: WireLocation,
1096    /// Name of the containing symbol (package, class)
1097    #[serde(skip_serializing_if = "Option::is_none")]
1098    pub container_name: Option<String>,
1099    /// Workspace folder URI this symbol belongs to (for multi-root workspace disambiguation)
1100    #[serde(skip_serializing_if = "Option::is_none")]
1101    pub workspace_folder_uri: Option<String>,
1102}
1103
1104impl From<&WorkspaceSymbol> for LspWorkspaceSymbol {
1105    fn from(sym: &WorkspaceSymbol) -> Self {
1106        let range = WireRange {
1107            start: WirePosition { line: sym.range.start.line, character: sym.range.start.column },
1108            end: WirePosition { line: sym.range.end.line, character: sym.range.end.column },
1109        };
1110
1111        Self {
1112            name: sym.name.clone(),
1113            kind: sym.kind.to_lsp_kind(),
1114            location: WireLocation { uri: sym.uri.clone(), range },
1115            container_name: sym.container_name.clone(),
1116            workspace_folder_uri: sym.workspace_folder_uri.clone(),
1117        }
1118    }
1119}
1120
1121/// File-level index data
1122#[derive(Default, Clone)]
1123pub struct FileIndex {
1124    /// Canonical file URI for this index entry.
1125    source_uri: String,
1126    /// Symbols defined in this file
1127    symbols: Vec<WorkspaceSymbol>,
1128    /// References in this file (symbol name -> references)
1129    references: HashMap<String, Vec<SymbolReference>>,
1130    /// Dependencies (modules this file imports)
1131    dependencies: HashSet<String>,
1132    /// Content hash for early-exit optimization
1133    content_hash: u64,
1134    /// Document generation represented by this indexed snapshot.
1135    generation: u32,
1136    /// Workspace folder URI this file belongs to (for multi-root workspace support)
1137    folder_uri: Option<String>,
1138}
1139
1140/// Write-through semantic fact storage for one indexed file.
1141///
1142/// Derives `Serialize, Deserialize` (Campaign 31 PR 5, perl-lsp-swarm#2592)
1143/// so the `perllsp ripr-facts` exporter can serialize the shard into the
1144/// `ripr-perl-facts-v1` packet. Previously derived only `Clone, Debug`.
1145#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
1146pub struct FileFactShard {
1147    /// Canonical file URI for this shard.
1148    pub source_uri: String,
1149    /// Stable file identifier derived from normalized URI.
1150    pub file_id: FileId,
1151    /// Whole-file content hash used for stale-shard replacement.
1152    pub content_hash: u64,
1153    /// Schema version of the semantic fact producer that built this shard.
1154    ///
1155    /// Set to [`crate::semantic::facts::PRODUCER_SCHEMA_VERSION`] at
1156    /// construction time.  Consumers (e.g. the snapshot layer in #1601)
1157    /// compare this field against the constant to detect schema drift.
1158    pub producer_schema_version: u32,
1159    /// Optional per-category hashes for change diagnostics.
1160    pub anchors_hash: Option<u64>,
1161    /// Optional per-category hashes for change diagnostics.
1162    pub entities_hash: Option<u64>,
1163    /// Optional per-category hashes for change diagnostics.
1164    pub occurrences_hash: Option<u64>,
1165    /// Optional per-category hashes for change diagnostics.
1166    pub edges_hash: Option<u64>,
1167    /// Anchor facts for this file.
1168    pub anchors: Vec<AnchorFact>,
1169    /// Entity facts for this file.
1170    pub entities: Vec<EntityFact>,
1171    /// Occurrence facts for this file.
1172    pub occurrences: Vec<perl_semantic_facts::OccurrenceFact>,
1173    /// Edge facts for this file.
1174    pub edges: Vec<EdgeFact>,
1175}
1176
1177/// Thread-safe workspace index
1178pub struct WorkspaceIndex {
1179    /// Index data per file URI (normalized key -> data)
1180    files: Arc<RwLock<HashMap<String, FileIndex>>>,
1181    /// Global symbol multimap (qualified/bare name -> ordered definition candidates)
1182    symbols: Arc<RwLock<HashMap<String, Vec<DefinitionCandidate>>>>,
1183    /// Global reference index (symbol name -> locations across all files)
1184    ///
1185    /// Aggregated from per-file `FileIndex::references` during `index_file()`.
1186    /// Provides O(1) lookup for `find_references()` instead of iterating all files.
1187    global_references: Arc<RwLock<HashMap<String, Vec<Location>>>>,
1188    /// Write-through semantic fact shards keyed by normalized URI.
1189    fact_shards: Arc<RwLock<HashMap<String, FileFactShard>>>,
1190    /// Semantic cross-file reference index (typed occurrences by name and entity).
1191    semantic_reference_index: Arc<RwLock<ReferenceIndex>>,
1192    /// Semantic cross-file import/export index.
1193    semantic_import_export_index: Arc<RwLock<ImportExportIndex>>,
1194    /// Document store for in-memory text
1195    document_store: DocumentStore,
1196    /// Workspace folder URIs for multi-root workspace support
1197    ///
1198    /// Used to determine which workspace folder a file belongs to for
1199    /// proper folder attribution in multi-root workspaces.
1200    workspace_folders: Arc<RwLock<Vec<String>>>,
1201}
1202
1203#[derive(Debug, Clone, Eq, PartialEq)]
1204struct DefinitionCandidate {
1205    location: Location,
1206    kind: SymbolKind,
1207}
1208
1209impl WorkspaceIndex {
1210    fn location_sort_key(location: &Location) -> (&str, u32, u32, u32, u32) {
1211        (
1212            location.uri.as_str(),
1213            location.range.start.line,
1214            location.range.start.column,
1215            location.range.end.line,
1216            location.range.end.column,
1217        )
1218    }
1219
1220    fn sort_locations_deterministically(locations: &mut [Location]) {
1221        locations.sort_by(|left, right| {
1222            Self::location_sort_key(left).cmp(&Self::location_sort_key(right))
1223        });
1224    }
1225
1226    fn definition_candidate_sort_key(
1227        candidate: &DefinitionCandidate,
1228    ) -> (u8, &str, u32, u32, u32, u32) {
1229        let rank = match candidate.kind {
1230            SymbolKind::Subroutine | SymbolKind::Method => 0,
1231            SymbolKind::Constant => 1,
1232            _ => 2,
1233        };
1234        (
1235            rank,
1236            candidate.location.uri.as_str(),
1237            candidate.location.range.start.line,
1238            candidate.location.range.start.column,
1239            candidate.location.range.end.line,
1240            candidate.location.range.end.column,
1241        )
1242    }
1243
1244    fn rebuild_symbol_cache(
1245        files: &HashMap<String, FileIndex>,
1246        symbols: &mut HashMap<String, Vec<DefinitionCandidate>>,
1247    ) {
1248        symbols.clear();
1249
1250        for file_index in files.values() {
1251            for symbol in &file_index.symbols {
1252                if let Some(ref qname) = symbol.qualified_name {
1253                    symbols.entry(qname.clone()).or_default().push(DefinitionCandidate {
1254                        location: Location { uri: symbol.uri.clone(), range: symbol.range },
1255                        kind: symbol.kind,
1256                    });
1257                }
1258                symbols.entry(symbol.name.clone()).or_default().push(DefinitionCandidate {
1259                    location: Location { uri: symbol.uri.clone(), range: symbol.range },
1260                    kind: symbol.kind,
1261                });
1262            }
1263        }
1264        for entries in symbols.values_mut() {
1265            entries.sort_by(|left, right| {
1266                Self::definition_candidate_sort_key(left)
1267                    .cmp(&Self::definition_candidate_sort_key(right))
1268            });
1269            entries.dedup();
1270        }
1271    }
1272
1273    /// Incrementally remove one file's symbols from the global cache,
1274    /// re-inserting shadowed symbols from remaining files.
1275    fn incremental_remove_symbols(
1276        files: &HashMap<String, FileIndex>,
1277        symbols: &mut HashMap<String, Vec<DefinitionCandidate>>,
1278        old_file_index: &FileIndex,
1279    ) {
1280        let mut affected_names: Vec<String> = Vec::new();
1281        for sym in &old_file_index.symbols {
1282            if let Some(ref qname) = sym.qualified_name {
1283                let mut remove_key = false;
1284                if let Some(entries) = symbols.get_mut(qname) {
1285                    entries.retain(|candidate| candidate.location.uri != sym.uri);
1286                    remove_key = entries.is_empty();
1287                }
1288                if remove_key {
1289                    symbols.remove(qname);
1290                    affected_names.push(qname.clone());
1291                }
1292            }
1293            let mut remove_key = false;
1294            if let Some(entries) = symbols.get_mut(&sym.name) {
1295                entries.retain(|candidate| candidate.location.uri != sym.uri);
1296                remove_key = entries.is_empty();
1297            }
1298            if remove_key {
1299                symbols.remove(&sym.name);
1300                affected_names.push(sym.name.clone());
1301            }
1302        }
1303        if !affected_names.is_empty() {
1304            symbols.clear();
1305            for file_index in files
1306                .values()
1307                .filter(|file_index| file_index.source_uri != old_file_index.source_uri)
1308            {
1309                for symbol in &file_index.symbols {
1310                    if let Some(ref qname) = symbol.qualified_name {
1311                        symbols.entry(qname.clone()).or_default().push(DefinitionCandidate {
1312                            location: Location { uri: symbol.uri.clone(), range: symbol.range },
1313                            kind: symbol.kind,
1314                        });
1315                    }
1316                    symbols.entry(symbol.name.clone()).or_default().push(DefinitionCandidate {
1317                        location: Location { uri: symbol.uri.clone(), range: symbol.range },
1318                        kind: symbol.kind,
1319                    });
1320                }
1321            }
1322            for entries in symbols.values_mut() {
1323                entries.sort_by(|left, right| {
1324                    Self::definition_candidate_sort_key(left)
1325                        .cmp(&Self::definition_candidate_sort_key(right))
1326                });
1327                entries.dedup();
1328            }
1329        }
1330    }
1331
1332    /// Incrementally add one file's symbols to the global cache.
1333    fn incremental_add_symbols(
1334        symbols: &mut HashMap<String, Vec<DefinitionCandidate>>,
1335        file_index: &FileIndex,
1336    ) {
1337        for sym in &file_index.symbols {
1338            if let Some(ref qname) = sym.qualified_name {
1339                symbols.entry(qname.clone()).or_default().push(DefinitionCandidate {
1340                    location: Location { uri: sym.uri.clone(), range: sym.range },
1341                    kind: sym.kind,
1342                });
1343            }
1344            symbols.entry(sym.name.clone()).or_default().push(DefinitionCandidate {
1345                location: Location { uri: sym.uri.clone(), range: sym.range },
1346                kind: sym.kind,
1347            });
1348        }
1349        for entries in symbols.values_mut() {
1350            entries.sort_by(|left, right| {
1351                Self::definition_candidate_sort_key(left)
1352                    .cmp(&Self::definition_candidate_sort_key(right))
1353            });
1354            entries.dedup();
1355        }
1356    }
1357
1358    /// Determine the workspace folder URI for a given file URI.
1359    ///
1360    /// Returns the workspace folder URI that contains the given file URI.
1361    /// This is used for multi-root workspace support to properly attribute
1362    /// files and symbols to their originating workspace folder.
1363    ///
1364    /// # Arguments
1365    ///
1366    /// * `file_uri` - The file URI to find the containing workspace folder for
1367    ///
1368    /// # Returns
1369    ///
1370    /// `Some(folder_uri)` if the file is within a workspace folder, `None` otherwise.
1371    ///
1372    /// # Examples
1373    ///
1374    /// ```rust,ignore
1375    /// use perl_workspace::workspace::workspace_index::WorkspaceIndex;
1376    ///
1377    /// let index = WorkspaceIndex::new();
1378    /// index.set_workspace_folders(vec![
1379    ///     "file:///project1".to_string(),
1380    ///     "file:///project2".to_string(),
1381    /// ]);
1382    ///
1383    /// let folder = index.determine_folder_uri("file:///project1/src/main.pl");
1384    /// assert_eq!(folder, Some("file:///project1".to_string()));
1385    /// ```
1386    fn determine_folder_uri(&self, file_uri: &str) -> Option<String> {
1387        let folders = self.workspace_folders.read();
1388        let mut best_match: Option<&String> = None;
1389        for folder_uri in folders.iter() {
1390            // Check if the file URI starts with the folder URI
1391            // We need to ensure proper URI matching (with or without trailing slash)
1392            let folder_with_slash = if folder_uri.ends_with('/') {
1393                folder_uri.clone()
1394            } else {
1395                format!("{}/", folder_uri)
1396            };
1397            if file_uri.starts_with(&folder_with_slash) || file_uri == folder_uri {
1398                match best_match {
1399                    Some(existing) if existing.len() >= folder_uri.len() => {}
1400                    _ => best_match = Some(folder_uri),
1401                }
1402            }
1403        }
1404        best_match.cloned()
1405    }
1406
1407    fn find_definition_in_files(
1408        files: &HashMap<String, FileIndex>,
1409        symbol_name: &str,
1410        uri_filter: Option<&str>,
1411    ) -> Option<(Location, String)> {
1412        let mut candidates: Vec<(Location, String)> = Vec::new();
1413        for file_index in files.values() {
1414            if let Some(filter) = uri_filter
1415                && file_index.symbols.first().is_some_and(|symbol| symbol.uri != filter)
1416            {
1417                continue;
1418            }
1419
1420            for symbol in &file_index.symbols {
1421                if symbol.name == symbol_name
1422                    || symbol.qualified_name.as_deref() == Some(symbol_name)
1423                {
1424                    candidates.push((
1425                        Location { uri: symbol.uri.clone(), range: symbol.range },
1426                        symbol.uri.clone(),
1427                    ));
1428                }
1429            }
1430        }
1431
1432        candidates.sort_by(|left, right| {
1433            Self::location_sort_key(&left.0).cmp(&Self::location_sort_key(&right.0))
1434        });
1435        candidates.into_iter().next()
1436    }
1437
1438    fn find_symbol_by_definition(
1439        &self,
1440        definition: &Location,
1441        symbol_name: &str,
1442    ) -> Option<WorkspaceSymbol> {
1443        let files = self.files.read();
1444        files
1445            .values()
1446            .flat_map(|file_index| file_index.symbols.iter())
1447            .filter(|symbol| {
1448                symbol.uri == definition.uri
1449                    && symbol.range == definition.range
1450                    && (symbol.name == symbol_name
1451                        || symbol.qualified_name.as_deref() == Some(symbol_name))
1452            })
1453            .min_by(|left, right| {
1454                (
1455                    left.qualified_name.as_deref().unwrap_or_default(),
1456                    left.name.as_str(),
1457                    left.kind.to_lsp_kind(),
1458                )
1459                    .cmp(&(
1460                        right.qualified_name.as_deref().unwrap_or_default(),
1461                        right.name.as_str(),
1462                        right.kind.to_lsp_kind(),
1463                    ))
1464            })
1465            .cloned()
1466    }
1467
1468    fn has_unique_symbol_name_and_kind(&self, target: &WorkspaceSymbol) -> bool {
1469        let files = self.files.read();
1470        files
1471            .values()
1472            .flat_map(|file_index| file_index.symbols.iter())
1473            .filter(|symbol| symbol.name == target.name && symbol.kind == target.kind)
1474            .take(2)
1475            .count()
1476            == 1
1477    }
1478
1479    fn collect_symbol_references(&self, symbol: &WorkspaceSymbol) -> Vec<Location> {
1480        let mut names_to_query: Vec<&str> = Vec::new();
1481        if let Some(qualified_name) = symbol.qualified_name.as_deref() {
1482            names_to_query.push(qualified_name);
1483            if self.has_unique_symbol_name_and_kind(symbol) {
1484                names_to_query.push(symbol.name.as_str());
1485            }
1486        } else {
1487            names_to_query.push(symbol.name.as_str());
1488        }
1489
1490        let global_refs = self.global_references.read();
1491        let mut seen: HashSet<(String, u32, u32, u32, u32)> = HashSet::new();
1492        let mut locations = Vec::new();
1493
1494        for symbol_name in names_to_query {
1495            if let Some(refs) = global_refs.get(symbol_name) {
1496                for location in refs {
1497                    let key = (
1498                        location.uri.clone(),
1499                        location.range.start.line,
1500                        location.range.start.column,
1501                        location.range.end.line,
1502                        location.range.end.column,
1503                    );
1504                    if seen.insert(key) {
1505                        locations.push(location.clone());
1506                    }
1507                }
1508            }
1509        }
1510        drop(global_refs);
1511
1512        Self::sort_locations_deterministically(&mut locations);
1513        locations
1514    }
1515
1516    /// Create a new empty index
1517    ///
1518    /// # Returns
1519    ///
1520    /// A workspace index with empty file and symbol tables.
1521    ///
1522    /// # Examples
1523    ///
1524    /// ```rust,ignore
1525    /// use perl_parser::workspace_index::WorkspaceIndex;
1526    ///
1527    /// let index = WorkspaceIndex::new();
1528    /// assert!(!index.has_symbols());
1529    /// ```
1530    pub fn new() -> Self {
1531        Self {
1532            files: Arc::new(RwLock::new(HashMap::new())),
1533            symbols: Arc::new(RwLock::new(HashMap::new())),
1534            global_references: Arc::new(RwLock::new(HashMap::new())),
1535            fact_shards: Arc::new(RwLock::new(HashMap::new())),
1536            semantic_reference_index: Arc::new(RwLock::new(ReferenceIndex::new())),
1537            semantic_import_export_index: Arc::new(RwLock::new(ImportExportIndex::new())),
1538            document_store: DocumentStore::new(),
1539            workspace_folders: Arc::new(RwLock::new(Vec::new())),
1540        }
1541    }
1542
1543    /// Create a workspace index with pre-allocated capacity.
1544    ///
1545    /// Pre-allocating reduces the number of rehash operations during large-workspace
1546    /// startup. Use this instead of `new()` when the approximate workspace size is
1547    /// known in advance (e.g. from a file discovery scan).
1548    ///
1549    /// # Arguments
1550    ///
1551    /// * `estimated_files` - Expected number of source files in the workspace.
1552    /// * `avg_symbols_per_file` - Expected average number of symbols per file.
1553    ///
1554    /// # Panics
1555    ///
1556    /// Does not panic. Overflow is prevented via `saturating_mul` and an upper cap
1557    /// on the symbol/reference map capacity.
1558    ///
1559    /// # Examples
1560    ///
1561    /// ```rust,ignore
1562    /// use perl_workspace::workspace::workspace_index::WorkspaceIndex;
1563    ///
1564    /// let index = WorkspaceIndex::with_capacity(1000, 20);
1565    /// assert!(!index.has_symbols());
1566    /// ```
1567    pub fn with_capacity(estimated_files: usize, avg_symbols_per_file: usize) -> Self {
1568        // Each symbol is stored twice (qualified + bare name) due to dual indexing.
1569        let sym_cap =
1570            estimated_files.saturating_mul(avg_symbols_per_file).saturating_mul(2).min(1_000_000);
1571        let ref_cap = (sym_cap / 4).min(1_000_000);
1572        Self {
1573            files: Arc::new(RwLock::new(HashMap::with_capacity(estimated_files))),
1574            symbols: Arc::new(RwLock::new(HashMap::with_capacity(sym_cap))),
1575            global_references: Arc::new(RwLock::new(HashMap::with_capacity(ref_cap))),
1576            fact_shards: Arc::new(RwLock::new(HashMap::with_capacity(estimated_files))),
1577            semantic_reference_index: Arc::new(RwLock::new(ReferenceIndex::new())),
1578            semantic_import_export_index: Arc::new(RwLock::new(ImportExportIndex::new())),
1579            document_store: DocumentStore::new(),
1580            workspace_folders: Arc::new(RwLock::new(Vec::new())),
1581        }
1582    }
1583
1584    /// Set the workspace folder URIs for multi-root workspace support.
1585    ///
1586    /// This method updates the list of workspace folders that the index
1587    /// uses to determine folder attribution for files and symbols.
1588    ///
1589    /// # Arguments
1590    ///
1591    /// * `folders` - A vector of workspace folder URIs
1592    ///
1593    /// # Examples
1594    ///
1595    /// ```rust,ignore
1596    /// use perl_workspace::workspace::workspace_index::WorkspaceIndex;
1597    ///
1598    /// let index = WorkspaceIndex::new();
1599    /// index.set_workspace_folders(vec![
1600    ///     "file:///project1".to_string(),
1601    ///     "file:///project2".to_string(),
1602    /// ]);
1603    /// ```
1604    pub fn set_workspace_folders(&self, folders: Vec<String>) {
1605        let mut workspace_folders = self.workspace_folders.write();
1606        *workspace_folders = folders;
1607    }
1608
1609    /// Get the current workspace folder URIs.
1610    ///
1611    /// # Returns
1612    ///
1613    /// A vector of workspace folder URIs.
1614    #[must_use]
1615    pub fn workspace_folders(&self) -> Vec<String> {
1616        self.workspace_folders.read().clone()
1617    }
1618
1619    /// Return the document generation represented by the indexed file snapshot.
1620    #[must_use]
1621    pub fn indexed_generation(&self, uri: &str) -> Option<u32> {
1622        let uri_str = Self::normalize_uri(uri);
1623        let key = DocumentStore::uri_key(&uri_str);
1624        self.files.read().get(&key).map(|file_index| file_index.generation)
1625    }
1626
1627    /// Whether the indexed snapshot for `uri` is older than `expected_generation`.
1628    #[must_use]
1629    pub fn is_index_generation_stale(&self, uri: &str, expected_generation: u32) -> bool {
1630        self.indexed_generation(uri)
1631            .is_some_and(|indexed_generation| indexed_generation < expected_generation)
1632    }
1633
1634    /// Normalize a URI to a consistent form using proper URI handling
1635    fn normalize_uri(uri: &str) -> String {
1636        perl_uri::normalize_uri(uri)
1637    }
1638
1639    /// Remove a file's contributions from the global reference index.
1640    ///
1641    /// Retains only entries whose URI does not match `file_uri`.
1642    /// Empty keys are removed to avoid unbounded map growth.
1643    fn remove_file_global_refs(
1644        global_refs: &mut HashMap<String, Vec<Location>>,
1645        file_index: &FileIndex,
1646        file_uri: &str,
1647    ) {
1648        for name in file_index.references.keys() {
1649            if let Some(locs) = global_refs.get_mut(name) {
1650                locs.retain(|loc| loc.uri != file_uri);
1651                if locs.is_empty() {
1652                    global_refs.remove(name);
1653                }
1654            }
1655        }
1656    }
1657
1658    /// Index a file from its URI and text content
1659    ///
1660    /// # Arguments
1661    ///
1662    /// * `uri` - File URI identifying the document
1663    /// * `text` - Full Perl source text for indexing
1664    ///
1665    /// # Returns
1666    ///
1667    /// `Ok(())` when indexing succeeds, or an error message otherwise.
1668    ///
1669    /// # Errors
1670    ///
1671    /// Returns an error if parsing fails or the document store cannot be updated.
1672    ///
1673    /// # Examples
1674    ///
1675    /// ```rust,ignore
1676    /// use perl_parser::workspace_index::WorkspaceIndex;
1677    /// use url::Url;
1678    ///
1679    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
1680    /// let index = WorkspaceIndex::new();
1681    /// let uri = Url::parse("file:///example.pl")?;
1682    /// index.index_file(uri, "sub hello { return 1; }".to_string())?;
1683    /// # Ok(())
1684    /// # }
1685    /// ```
1686    ///
1687    /// Returns: `Ok(())` when indexing succeeds, otherwise an error string.
1688    pub fn index_file(&self, uri: Url, text: String) -> Result<(), String> {
1689        self.index_file_with_generation(uri, text, 0)
1690    }
1691
1692    /// Index a file from its URI, text content, and document generation.
1693    pub fn index_file_with_generation(
1694        &self,
1695        uri: Url,
1696        text: String,
1697        generation: u32,
1698    ) -> Result<(), String> {
1699        let uri_str = uri.to_string();
1700
1701        // Compute content hash for early-exit optimization
1702        let mut hasher = DefaultHasher::new();
1703        text.hash(&mut hasher);
1704        let content_hash = hasher.finish();
1705
1706        // Check if content is unchanged (early-exit optimization)
1707        let key = DocumentStore::uri_key(&uri_str);
1708        {
1709            let mut files = self.files.write();
1710            if let Some(existing_index) = files.get_mut(&key) {
1711                if existing_index.content_hash == content_hash {
1712                    existing_index.generation = existing_index.generation.max(generation);
1713                    // Content unchanged, skip re-indexing
1714                    return Ok(());
1715                }
1716            }
1717        }
1718
1719        // Update document store
1720        if self.document_store.is_open(&uri_str) {
1721            self.document_store.update(&uri_str, 1, text.clone());
1722        } else {
1723            self.document_store.open(uri_str.clone(), 1, text.clone());
1724        }
1725
1726        // Parse the file
1727        let mut parser = Parser::new(&text);
1728        let ast = match parser.parse() {
1729            Ok(ast) => ast,
1730            Err(e) => return Err(format!("Parse error: {}", e)),
1731        };
1732
1733        // Get the document for line index
1734        let mut doc = self.document_store.get(&uri_str).ok_or("Document not found")?;
1735
1736        // Determine workspace folder URI from the file URI
1737        let folder_uri = self.determine_folder_uri(&uri_str);
1738
1739        // Extract symbols and references
1740        let mut file_index = FileIndex {
1741            source_uri: uri_str.clone(),
1742            content_hash,
1743            generation,
1744            folder_uri: folder_uri.clone(),
1745            ..Default::default()
1746        };
1747        let mut visitor = IndexVisitor::new(&mut doc, uri_str.clone(), folder_uri);
1748        visitor.visit(&ast, &mut file_index);
1749
1750        let canonical_shard =
1751            Self::build_canonical_fact_shard_for_ast(&uri_str, content_hash, &ast);
1752        let fact_shard = if canonical_shard.anchors.is_empty()
1753            && canonical_shard.entities.is_empty()
1754            && canonical_shard.occurrences.is_empty()
1755            && canonical_shard.edges.is_empty()
1756        {
1757            Self::build_fact_shard(&uri_str, content_hash, &file_index)
1758        } else {
1759            canonical_shard
1760        };
1761
1762        // Extract import specs from the AST — populates ImportExportIndex so
1763        // that `Foo->import(@names)` dynamic-import suppression is live in
1764        // production.  This runs outside the write lock to avoid holding it
1765        // longer than necessary.
1766        //
1767        // Lock ordering note: `semantic_import_export_index` is acquired write
1768        // separately from (and after) `files`/`symbols`/`global_references` to
1769        // match the consistent lock-order used throughout this file.
1770        let file_id = Self::hash_uri_to_file_id(&uri_str);
1771        let import_specs =
1772            crate::semantic::workspace_import_extractor::extract_import_specs(&ast, file_id);
1773        let use_lib_facts =
1774            crate::semantic::workspace_import_extractor::extract_use_lib_facts(&ast, file_id);
1775
1776        // Update the index, refresh the global symbol cache, and replace this file's
1777        // contribution in the global reference index.
1778        {
1779            let mut files = self.files.write();
1780
1781            // Remove stale global references from previous version of this file
1782            if let Some(old_index) = files.get(&key) {
1783                let mut global_refs = self.global_references.write();
1784                Self::remove_file_global_refs(&mut global_refs, old_index, &uri_str);
1785            }
1786
1787            // Incrementally remove old symbols before inserting new file
1788            if let Some(old_index) = files.get(&key) {
1789                let mut symbols = self.symbols.write();
1790                Self::incremental_remove_symbols(&files, &mut symbols, old_index);
1791                drop(symbols);
1792            }
1793            files.insert(key.clone(), file_index);
1794            let mut symbols = self.symbols.write();
1795            if let Some(new_index) = files.get(&key) {
1796                Self::incremental_add_symbols(&mut symbols, new_index);
1797            }
1798
1799            if let Some(file_index) = files.get(&key) {
1800                let mut global_refs = self.global_references.write();
1801                for (name, refs) in &file_index.references {
1802                    let entry = global_refs.entry(name.clone()).or_default();
1803                    for reference in refs {
1804                        entry.push(Location { uri: reference.uri.clone(), range: reference.range });
1805                    }
1806                }
1807            }
1808            self.replace_fact_shard_incremental(&key, fact_shard);
1809        }
1810
1811        // Update the import/export index with the freshly extracted import specs
1812        // and use-lib facts.  Stale entries for this URI are removed first
1813        // (incremental re-indexing).  This is done after the main write lock
1814        // block to follow the established lock ordering
1815        // (shards → reference_index → import_export_index).
1816        {
1817            let mut ie_idx = self.semantic_import_export_index.write();
1818            ie_idx.remove_file_imports(&uri_str);
1819            ie_idx.add_file_imports(&uri_str, file_id, import_specs);
1820            ie_idx.remove_file_use_lib(&uri_str);
1821            ie_idx.add_file_use_lib(&uri_str, file_id, use_lib_facts);
1822        }
1823
1824        Ok(())
1825    }
1826
1827    /// Remove a file from the index
1828    ///
1829    /// # Arguments
1830    ///
1831    /// * `uri` - File URI (string form) to remove
1832    ///
1833    /// # Returns
1834    ///
1835    /// Nothing. The index is updated in-place.
1836    ///
1837    /// # Examples
1838    ///
1839    /// ```rust,ignore
1840    /// use perl_parser::workspace_index::WorkspaceIndex;
1841    ///
1842    /// let index = WorkspaceIndex::new();
1843    /// index.remove_file("file:///example.pl");
1844    /// ```
1845    pub fn remove_file(&self, uri: &str) {
1846        let uri_str = Self::normalize_uri(uri);
1847        let key = DocumentStore::uri_key(&uri_str);
1848
1849        // Remove from document store
1850        self.document_store.close(&uri_str);
1851
1852        // Remove file index
1853        let mut files = self.files.write();
1854        if let Some(file_index) = files.remove(&key) {
1855            self.fact_shards.write().remove(&key);
1856
1857            // Clean up semantic cross-file indexes for this file.
1858            self.semantic_reference_index.write().remove_file(&uri_str);
1859            {
1860                let mut ie_idx = self.semantic_import_export_index.write();
1861                ie_idx.remove_file_imports(&uri_str);
1862                ie_idx.remove_module_exports(&uri_str);
1863                ie_idx.remove_file_use_lib(&uri_str);
1864            }
1865
1866            // Incrementally remove symbols and re-insert any shadowed names.
1867            let mut symbols = self.symbols.write();
1868            Self::incremental_remove_symbols(&files, &mut symbols, &file_index);
1869
1870            // Defensive sweep: purge any remaining cache entries whose value
1871            // points to this file's URI.  incremental_remove_symbols already
1872            // handles known symbol names; this sweep guarantees no stale
1873            // candidates survive even when:
1874            //   * the file had zero symbols (nothing for incremental_remove
1875            //     to walk), or
1876            //   * a symbol's stored uri differs from the canonical normalize_uri
1877            //     output (URI normalization edge cases).
1878            // Match against every URI spelling observed in this file index plus
1879            // the canonical uri_str so raw/normalized variants are all caught.
1880            let mut removed_uris = vec![uri_str.as_str()];
1881            for observed_uri in file_index.symbols.iter().map(|s| s.uri.as_str()).chain(
1882                file_index.references.values().flat_map(|refs| refs.iter().map(|r| r.uri.as_str())),
1883            ) {
1884                if !removed_uris.contains(&observed_uri) {
1885                    removed_uris.push(observed_uri);
1886                }
1887            }
1888            symbols.retain(|_, candidates| {
1889                candidates.retain(|candidate| {
1890                    let cand_uri = candidate.location.uri.as_str();
1891                    !removed_uris.contains(&cand_uri)
1892                });
1893                !candidates.is_empty()
1894            });
1895
1896            // Remove from global reference index. Two-phase cleanup: first
1897            // remove names this file was known to reference (cheap path), then
1898            // a defensive sweep over all remaining entries to catch any that
1899            // were inserted under names not present in this file's
1900            // FileIndex::references map (e.g. via aggregated/global insertion
1901            // paths). Empty buckets are dropped.
1902            let mut global_refs = self.global_references.write();
1903            Self::remove_file_global_refs(&mut global_refs, &file_index, &uri_str);
1904            global_refs.retain(|_, locs| {
1905                locs.retain(|loc| !removed_uris.contains(&loc.uri.as_str()));
1906                !locs.is_empty()
1907            });
1908        }
1909    }
1910
1911    /// Remove a file from the index (URL variant for compatibility)
1912    ///
1913    /// # Arguments
1914    ///
1915    /// * `uri` - File URI as a parsed `Url`
1916    ///
1917    /// # Returns
1918    ///
1919    /// Nothing. The index is updated in-place.
1920    ///
1921    /// # Examples
1922    ///
1923    /// ```rust,ignore
1924    /// use perl_parser::workspace_index::WorkspaceIndex;
1925    /// use url::Url;
1926    ///
1927    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
1928    /// let index = WorkspaceIndex::new();
1929    /// let uri = Url::parse("file:///example.pl")?;
1930    /// index.remove_file_url(&uri);
1931    /// # Ok(())
1932    /// # }
1933    /// ```
1934    pub fn remove_file_url(&self, uri: &Url) {
1935        self.remove_file(uri.as_str())
1936    }
1937
1938    /// Clear a file from the index (alias for remove_file)
1939    ///
1940    /// # Arguments
1941    ///
1942    /// * `uri` - File URI (string form) to remove
1943    ///
1944    /// # Returns
1945    ///
1946    /// Nothing. The index is updated in-place.
1947    ///
1948    /// # Examples
1949    ///
1950    /// ```rust,ignore
1951    /// use perl_parser::workspace_index::WorkspaceIndex;
1952    ///
1953    /// let index = WorkspaceIndex::new();
1954    /// index.clear_file("file:///example.pl");
1955    /// ```
1956    pub fn clear_file(&self, uri: &str) {
1957        self.remove_file(uri);
1958    }
1959
1960    /// Clear a file from the index (URL variant for compatibility)
1961    ///
1962    /// # Arguments
1963    ///
1964    /// * `uri` - File URI as a parsed `Url`
1965    ///
1966    /// # Returns
1967    ///
1968    /// Nothing. The index is updated in-place.
1969    ///
1970    /// # Examples
1971    ///
1972    /// ```rust,ignore
1973    /// use perl_parser::workspace_index::WorkspaceIndex;
1974    /// use url::Url;
1975    ///
1976    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
1977    /// let index = WorkspaceIndex::new();
1978    /// let uri = Url::parse("file:///example.pl")?;
1979    /// index.clear_file_url(&uri);
1980    /// # Ok(())
1981    /// # }
1982    /// ```
1983    pub fn clear_file_url(&self, uri: &Url) {
1984        self.clear_file(uri.as_str())
1985    }
1986
1987    /// Remove all files from a specific workspace folder.
1988    ///
1989    /// This method removes all indexed files that belong to the given
1990    /// workspace folder URI. This is useful when a workspace folder is
1991    /// removed from the multi-root workspace.
1992    ///
1993    /// # Arguments
1994    ///
1995    /// * `folder_uri` - The workspace folder URI to remove files from
1996    ///
1997    /// # Examples
1998    ///
1999    /// ```rust,ignore
2000    /// use perl_workspace::workspace::workspace_index::WorkspaceIndex;
2001    ///
2002    /// let index = WorkspaceIndex::new();
2003    /// // Index files from multiple folders...
2004    /// index.remove_folder("file:///project1");
2005    /// ```
2006    pub fn remove_folder(&self, folder_uri: &str) {
2007        let mut uris_to_remove = Vec::new();
2008        let files = self.files.read();
2009
2010        // Collect all files that belong to this folder
2011        for file_index in files.values() {
2012            if file_index.folder_uri.as_deref() == Some(folder_uri) {
2013                uris_to_remove.push(file_index.source_uri.clone());
2014            }
2015        }
2016        drop(files);
2017
2018        // Remove each file through the full removal path to keep
2019        // symbol/reference caches and document store in sync.
2020        for uri in uris_to_remove {
2021            self.remove_file(&uri);
2022        }
2023    }
2024
2025    #[cfg(not(target_arch = "wasm32"))]
2026    /// Index a file from a URI string for the Index/Analyze workflow.
2027    ///
2028    /// Accepts either a `file://` URI or a filesystem path. Not available on
2029    /// wasm32 targets (requires filesystem path conversion).
2030    ///
2031    /// # Arguments
2032    ///
2033    /// * `uri` - File URI string or filesystem path.
2034    /// * `text` - Full Perl source text for indexing.
2035    ///
2036    /// # Returns
2037    ///
2038    /// `Ok(())` when indexing succeeds, or an error message otherwise.
2039    ///
2040    /// # Errors
2041    ///
2042    /// Returns an error if the URI is invalid or parsing fails.
2043    ///
2044    /// # Examples
2045    ///
2046    /// ```rust,ignore
2047    /// use perl_parser::workspace_index::WorkspaceIndex;
2048    ///
2049    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
2050    /// let index = WorkspaceIndex::new();
2051    /// index.index_file_str("file:///example.pl", "sub hello { }")?;
2052    /// # Ok(())
2053    /// # }
2054    /// ```
2055    pub fn index_file_str(&self, uri: &str, text: &str) -> Result<(), String> {
2056        let path = Path::new(uri);
2057        let url = if path.is_absolute() {
2058            url::Url::from_file_path(path)
2059                .map_err(|_| format!("Invalid URI or file path: {}", uri))?
2060        } else {
2061            // Raw absolute Windows paths like C:\foo can parse as a bogus URI
2062            // (`c:` scheme). Prefer URL parsing only for non-path inputs.
2063            url::Url::parse(uri).or_else(|_| {
2064                url::Url::from_file_path(path)
2065                    .map_err(|_| format!("Invalid URI or file path: {}", uri))
2066            })?
2067        };
2068        self.index_file(url, text.to_string())
2069    }
2070
2071    /// Index multiple files in a single batch operation.
2072    ///
2073    /// This is significantly faster than calling `index_file` in a loop for
2074    /// initial workspace scans because it defers the global symbol cache
2075    /// rebuild to a single pass at the end.
2076    ///
2077    /// Phase 1: Parse all files without holding locks.
2078    /// Phase 2: Bulk-insert file indices and rebuild the symbol cache once.
2079    pub fn index_files_batch(&self, files_to_index: Vec<(Url, String)>) -> Vec<String> {
2080        let mut errors = Vec::new();
2081
2082        // Phase 1: Parse all files without locks
2083        let mut parsed: Vec<(String, String, FileIndex)> = Vec::with_capacity(files_to_index.len());
2084        for (uri, text) in &files_to_index {
2085            let uri_str = uri.to_string();
2086
2087            // Content hash for early-exit
2088            let mut hasher = DefaultHasher::new();
2089            text.hash(&mut hasher);
2090            let content_hash = hasher.finish();
2091
2092            let key = DocumentStore::uri_key(&uri_str);
2093
2094            // Check if content unchanged
2095            {
2096                let files = self.files.read();
2097                if let Some(existing) = files.get(&key) {
2098                    if existing.content_hash == content_hash {
2099                        continue;
2100                    }
2101                }
2102            }
2103
2104            // Update document store
2105            if self.document_store.is_open(&uri_str) {
2106                self.document_store.update(&uri_str, 1, text.clone());
2107            } else {
2108                self.document_store.open(uri_str.clone(), 1, text.clone());
2109            }
2110
2111            // Parse
2112            let mut parser = Parser::new(text);
2113            let ast = match parser.parse() {
2114                Ok(ast) => ast,
2115                Err(e) => {
2116                    errors.push(format!("Parse error in {}: {}", uri_str, e));
2117                    continue;
2118                }
2119            };
2120
2121            let mut doc = match self.document_store.get(&uri_str) {
2122                Some(d) => d,
2123                None => {
2124                    errors.push(format!("Document not found: {}", uri_str));
2125                    continue;
2126                }
2127            };
2128
2129            // Determine workspace folder URI from the file URI
2130            let folder_uri = self.determine_folder_uri(&uri_str);
2131
2132            let mut file_index = FileIndex {
2133                source_uri: uri_str.clone(),
2134                content_hash,
2135                folder_uri: folder_uri.clone(),
2136                ..Default::default()
2137            };
2138            let mut visitor = IndexVisitor::new(&mut doc, uri_str.clone(), folder_uri);
2139            visitor.visit(&ast, &mut file_index);
2140
2141            parsed.push((key, uri_str, file_index));
2142        }
2143
2144        // Phase 2: Bulk insert with single cache rebuild
2145        {
2146            let mut files = self.files.write();
2147            let mut symbols = self.symbols.write();
2148            let mut global_refs = self.global_references.write();
2149
2150            // Pre-allocate capacity for the incoming batch to avoid rehashing.
2151            // Each symbol is indexed under both its qualified name and bare name.
2152            files.reserve(parsed.len());
2153            symbols.reserve(parsed.len().saturating_mul(20).saturating_mul(2));
2154
2155            for (key, uri_str, file_index) in parsed {
2156                // Remove stale global references
2157                if let Some(old_index) = files.get(&key) {
2158                    Self::remove_file_global_refs(&mut global_refs, old_index, &uri_str);
2159                }
2160
2161                files.insert(key.clone(), file_index);
2162
2163                // Add global references for this file
2164                if let Some(fi) = files.get(&key) {
2165                    for (name, refs) in &fi.references {
2166                        let entry = global_refs.entry(name.clone()).or_default();
2167                        for reference in refs {
2168                            entry.push(Location {
2169                                uri: reference.uri.clone(),
2170                                range: reference.range,
2171                            });
2172                        }
2173                    }
2174                }
2175            }
2176
2177            // Single rebuild at the end
2178            Self::rebuild_symbol_cache(&files, &mut symbols);
2179        }
2180
2181        errors
2182    }
2183
2184    /// Find all references to a symbol using dual indexing strategy
2185    ///
2186    /// This function searches for both exact matches and bare name matches when
2187    /// the symbol is qualified. For example, when searching for "Utils::process_data":
2188    /// - First searches for exact "Utils::process_data" references
2189    /// - Then searches for bare "process_data" references that might refer to the same function
2190    ///
2191    /// This dual approach handles cases where functions are called both as:
2192    /// - Qualified: `Utils::process_data()`
2193    /// - Unqualified: `process_data()` (when in the same package or imported)
2194    ///
2195    /// # Arguments
2196    ///
2197    /// * `symbol_name` - Symbol name or qualified name to search
2198    ///
2199    /// # Returns
2200    ///
2201    /// All reference locations found for the requested symbol.
2202    ///
2203    /// # Examples
2204    ///
2205    /// ```rust,ignore
2206    /// use perl_parser::workspace_index::WorkspaceIndex;
2207    ///
2208    /// let index = WorkspaceIndex::new();
2209    /// let _refs = index.find_references("Utils::process_data");
2210    /// ```
2211    pub fn find_references(&self, symbol_name: &str) -> Vec<Location> {
2212        let global_refs = self.global_references.read();
2213        let mut seen: HashSet<(String, u32, u32, u32, u32)> = HashSet::new();
2214        let mut locations = Vec::new();
2215
2216        // O(1) lookup for exact symbol name
2217        if let Some(refs) = global_refs.get(symbol_name) {
2218            for loc in refs {
2219                let key = (
2220                    loc.uri.clone(),
2221                    loc.range.start.line,
2222                    loc.range.start.column,
2223                    loc.range.end.line,
2224                    loc.range.end.column,
2225                );
2226                if seen.insert(key) {
2227                    locations.push(Location { uri: loc.uri.clone(), range: loc.range });
2228                }
2229            }
2230        }
2231
2232        // If the symbol is qualified, also collect bare name references
2233        if let Some(idx) = symbol_name.rfind("::") {
2234            let bare_name = &symbol_name[idx + 2..];
2235            if let Some(refs) = global_refs.get(bare_name) {
2236                for loc in refs {
2237                    let key = (
2238                        loc.uri.clone(),
2239                        loc.range.start.line,
2240                        loc.range.start.column,
2241                        loc.range.end.line,
2242                        loc.range.end.column,
2243                    );
2244                    if seen.insert(key) {
2245                        locations.push(Location { uri: loc.uri.clone(), range: loc.range });
2246                    }
2247                }
2248            }
2249        } else {
2250            // If the symbol is bare, also collect qualified references that end
2251            // with the same bare name, e.g. `Pkg::foo` when searching for `foo`.
2252            for (name, refs) in global_refs.iter() {
2253                if !Self::is_qualified_variant_of(name, symbol_name) {
2254                    continue;
2255                }
2256
2257                for loc in refs {
2258                    let key = (
2259                        loc.uri.clone(),
2260                        loc.range.start.line,
2261                        loc.range.start.column,
2262                        loc.range.end.line,
2263                        loc.range.end.column,
2264                    );
2265                    if seen.insert(key) {
2266                        locations.push(Location { uri: loc.uri.clone(), range: loc.range });
2267                    }
2268                }
2269            }
2270        }
2271
2272        Self::sort_locations_deterministically(&mut locations);
2273        locations
2274    }
2275
2276    /// Resolve a symbol and return its definition/reference set for cross-file planning.
2277    ///
2278    /// Returns `None` when no definition can be resolved for `symbol_name`.
2279    pub fn query_symbol_references(
2280        &self,
2281        symbol_name: &str,
2282    ) -> Option<CrossFileReferenceQueryResult> {
2283        let definition = self.find_definition(symbol_name)?;
2284        let symbol = self.find_symbol_by_definition(&definition, symbol_name)?;
2285
2286        let stable_key = symbol.qualified_name.clone().unwrap_or_else(|| {
2287            format!(
2288                "{}@{}:{}:{}",
2289                symbol.name, symbol.uri, symbol.range.start.line, symbol.range.start.column
2290            )
2291        });
2292        let mut references = self.collect_symbol_references(&symbol);
2293        if !references.iter().any(|location| location == &definition) {
2294            references.push(definition.clone());
2295            Self::sort_locations_deterministically(&mut references);
2296        }
2297
2298        Some(CrossFileReferenceQueryResult {
2299            symbol: SymbolIdentity {
2300                stable_key,
2301                name: symbol.name,
2302                qualified_name: symbol.qualified_name,
2303                kind: symbol.kind,
2304            },
2305            definition,
2306            references,
2307        })
2308    }
2309
2310    /// Count non-definition references (usages) of a symbol.
2311    ///
2312    /// Like `find_references` but excludes `ReferenceKind::Definition` entries,
2313    /// returning only actual usage sites. This is used by code lens to show
2314    /// "N references" where N means call sites, not the definition itself.
2315    pub fn count_usages(&self, symbol_name: &str) -> usize {
2316        let files = self.files.read();
2317        let mut seen: HashSet<(String, u32, u32, u32, u32)> = HashSet::new();
2318
2319        for (_uri_key, file_index) in files.iter() {
2320            if let Some(refs) = file_index.references.get(symbol_name) {
2321                for r in refs.iter().filter(|r| r.kind != ReferenceKind::Definition) {
2322                    seen.insert((
2323                        r.uri.clone(),
2324                        r.range.start.line,
2325                        r.range.start.column,
2326                        r.range.end.line,
2327                        r.range.end.column,
2328                    ));
2329                }
2330            }
2331
2332            if let Some(idx) = symbol_name.rfind("::") {
2333                let bare_name = &symbol_name[idx + 2..];
2334                if let Some(refs) = file_index.references.get(bare_name) {
2335                    for r in refs.iter().filter(|r| r.kind != ReferenceKind::Definition) {
2336                        seen.insert((
2337                            r.uri.clone(),
2338                            r.range.start.line,
2339                            r.range.start.column,
2340                            r.range.end.line,
2341                            r.range.end.column,
2342                        ));
2343                    }
2344                }
2345            } else {
2346                for (name, refs) in &file_index.references {
2347                    if !Self::is_qualified_variant_of(name, symbol_name) {
2348                        continue;
2349                    }
2350
2351                    for r in refs.iter().filter(|r| r.kind != ReferenceKind::Definition) {
2352                        seen.insert((
2353                            r.uri.clone(),
2354                            r.range.start.line,
2355                            r.range.start.column,
2356                            r.range.end.line,
2357                            r.range.end.column,
2358                        ));
2359                    }
2360                }
2361            }
2362        }
2363
2364        seen.len()
2365    }
2366
2367    fn is_qualified_variant_of(candidate: &str, bare_symbol: &str) -> bool {
2368        candidate.rsplit_once("::").is_some_and(|(_, candidate_bare)| candidate_bare == bare_symbol)
2369    }
2370
2371    /// Find the definition of a symbol
2372    ///
2373    /// # Arguments
2374    ///
2375    /// * `symbol_name` - Symbol name or qualified name to resolve
2376    ///
2377    /// # Returns
2378    ///
2379    /// The first matching definition location, if found.
2380    ///
2381    /// # Examples
2382    ///
2383    /// ```rust,ignore
2384    /// use perl_parser::workspace_index::WorkspaceIndex;
2385    ///
2386    /// let index = WorkspaceIndex::new();
2387    /// let _def = index.find_definition("MyPackage::example");
2388    /// ```
2389    pub fn find_definition(&self, symbol_name: &str) -> Option<Location> {
2390        if let Some(location) = self.definition_candidates(symbol_name).into_iter().next() {
2391            return Some(location);
2392        }
2393
2394        // Fall back to a full files scan for this query. The result is intentionally
2395        // NOT written back to `self.symbols`: every indexed symbol is already
2396        // inserted under both qualified and bare names by `incremental_add_symbols`,
2397        // so any cache miss here is for a key that does not correspond to an
2398        // indexed symbol (e.g. a typo or alias). Caching such queries is unsound
2399        // (entries become stale on file edits and were never tracked for cleanup
2400        // in `remove_file`/`incremental_remove_symbols`) and lets the cache grow
2401        // unboundedly across long sessions. Returning the resolved location
2402        // directly preserves correctness without retaining state.
2403        let files = self.files.read();
2404        Self::find_definition_in_files(&files, symbol_name, None).map(|(location, _uri)| location)
2405    }
2406
2407    pub(crate) fn definition_candidates(&self, symbol_name: &str) -> Vec<Location> {
2408        let symbols = self.symbols.read();
2409        symbols
2410            .get(symbol_name)
2411            .map(|candidates| {
2412                candidates.iter().map(|candidate| candidate.location.clone()).collect()
2413            })
2414            .unwrap_or_default()
2415    }
2416
2417    /// Get all symbols in the workspace
2418    ///
2419    /// # Returns
2420    ///
2421    /// A vector containing every symbol currently indexed.
2422    ///
2423    /// # Examples
2424    ///
2425    /// ```rust,ignore
2426    /// use perl_parser::workspace_index::WorkspaceIndex;
2427    ///
2428    /// let index = WorkspaceIndex::new();
2429    /// let _symbols = index.all_symbols();
2430    /// ```
2431    pub fn all_symbols(&self) -> Vec<WorkspaceSymbol> {
2432        let files = self.files.read();
2433        let mut symbols = Vec::new();
2434
2435        for (_uri_key, file_index) in files.iter() {
2436            symbols.extend(file_index.symbols.clone());
2437        }
2438
2439        symbols
2440    }
2441
2442    /// Clear all indexed files and symbols from the workspace.
2443    pub fn clear(&self) {
2444        self.files.write().clear();
2445        self.symbols.write().clear();
2446        self.global_references.write().clear();
2447        self.fact_shards.write().clear();
2448        *self.semantic_reference_index.write() = ReferenceIndex::new();
2449        *self.semantic_import_export_index.write() = ImportExportIndex::new();
2450    }
2451
2452    fn hash_uri_to_file_id(uri: &str) -> FileId {
2453        let mut hasher = DefaultHasher::new();
2454        uri.hash(&mut hasher);
2455        FileId(hasher.finish())
2456    }
2457
2458    fn build_fact_shard(uri: &str, content_hash: u64, file_index: &FileIndex) -> FileFactShard {
2459        let file_id = Self::hash_uri_to_file_id(uri);
2460        let mut anchors = Vec::new();
2461        let mut entities = Vec::new();
2462        for (idx, symbol) in file_index.symbols.iter().enumerate() {
2463            let anchor_id = AnchorId((idx + 1) as u64);
2464            anchors.push(AnchorFact {
2465                id: anchor_id,
2466                file_id,
2467                // WorkspaceSymbol provides line/column coordinates only, not byte
2468                // offsets.  Zero-initialize span_*_byte until a byte-offset source
2469                // is plumbed through the indexing pipeline.
2470                span_start_byte: 0,
2471                span_end_byte: 0,
2472                scope_id: None,
2473                provenance: Provenance::SearchFallback,
2474                confidence: Confidence::Low,
2475            });
2476            entities.push(EntityFact {
2477                id: EntityId((idx + 1) as u64),
2478                kind: EntityKind::Unknown,
2479                canonical_name: symbol
2480                    .qualified_name
2481                    .clone()
2482                    .unwrap_or_else(|| symbol.name.clone()),
2483                anchor_id: Some(anchor_id),
2484                scope_id: None,
2485                provenance: Provenance::SearchFallback,
2486                confidence: Confidence::Low,
2487            });
2488        }
2489        // Hash the per-category fact vectors so consumers can detect staleness
2490        // without re-reading the full shard.
2491        let anchors_hash = {
2492            let mut h = DefaultHasher::new();
2493            anchors.len().hash(&mut h);
2494            for a in &anchors {
2495                a.id.hash(&mut h);
2496                a.span_start_byte.hash(&mut h);
2497                a.span_end_byte.hash(&mut h);
2498            }
2499            h.finish()
2500        };
2501        let entities_hash = {
2502            let mut h = DefaultHasher::new();
2503            entities.len().hash(&mut h);
2504            for e in &entities {
2505                e.id.hash(&mut h);
2506                e.canonical_name.hash(&mut h);
2507            }
2508            h.finish()
2509        };
2510        FileFactShard {
2511            source_uri: uri.to_string(),
2512            file_id,
2513            content_hash,
2514            producer_schema_version: PRODUCER_SCHEMA_VERSION,
2515            anchors_hash: Some(anchors_hash),
2516            entities_hash: Some(entities_hash),
2517            occurrences_hash: Some(0),
2518            edges_hash: Some(0),
2519            anchors,
2520            entities,
2521            occurrences: Vec::new(),
2522            edges: Vec::new(),
2523        }
2524    }
2525
2526    /// Build a canonical [`FileFactShard`] from the AST using the semantic
2527    /// fact adapters in `perl-symbol`.
2528    ///
2529    /// This is the canonical population path that produces facts with real
2530    /// byte spans, `ExactAst` provenance, and per-category hashes. It runs
2531    /// alongside the legacy `build_fact_shard` path during the migration
2532    /// period.
2533    fn build_canonical_fact_shard_for_ast(
2534        uri: &str,
2535        content_hash: u64,
2536        ast: &Node,
2537    ) -> FileFactShard {
2538        let file_id = Self::hash_uri_to_file_id(uri);
2539
2540        // Extract declarations and references from the AST.
2541        let decls = extract_symbol_decls(ast, None);
2542        let refs = extract_symbol_refs(ast);
2543
2544        // Run the canonical adapters.
2545        let decl_facts = symbol_decls_to_semantic_facts(&decls, file_id);
2546
2547        // Build an entity lookup map for reference resolution.
2548        let entity_ids_by_name: std::collections::BTreeMap<String, EntityId> =
2549            decl_facts.entities.iter().map(|e| (e.canonical_name.clone(), e.id)).collect();
2550        let ref_facts = symbol_refs_to_semantic_facts(&refs, file_id, &entity_ids_by_name);
2551
2552        // Extract dynamic boundary evidence for `eval "sub NAME { ... }"` patterns.
2553        // Non-literal evals (e.g. `eval $code`) are intentionally skipped — the
2554        // sub name is not statically known and no evidence is emitted.
2555        let eval_sub_triples =
2556            crate::semantic::eval_sub_extractor::extract_eval_sub_boundaries(ast, file_id);
2557        let dynamic_boundaries: Vec<perl_semantic_facts::OccurrenceFact> =
2558            eval_sub_triples.iter().map(|(_, _, occ)| occ.clone()).collect();
2559        let generated_member_facts =
2560            crate::semantic::generated_member_extractor::extract_generated_member_facts(
2561                ast, file_id,
2562            );
2563
2564        // Build synthetic entity/anchor slices from eval-sub triples.
2565        // IMPORTANT: The triple is (entity, anchor, occurrence).  Only idx 0
2566        // (entity) and idx 1 (anchor) belong in the synthetic slices.  Idx 2
2567        // (occurrence) already flows through `dynamic_boundaries` above — do
2568        // NOT move it here or `occurrences_hash` will double-count.
2569        let synthetic_entities_from_eval: Vec<perl_semantic_facts::EntityFact> =
2570            eval_sub_triples.iter().map(|(entity, _, _)| entity.clone()).collect();
2571        let synthetic_anchors_from_eval: Vec<perl_semantic_facts::AnchorFact> =
2572            eval_sub_triples.iter().map(|(_, anchor, _)| anchor.clone()).collect();
2573
2574        // Build synthetic entity/anchor slices from generated member facts.
2575        let synthetic_entities_from_generated: Vec<perl_semantic_facts::EntityFact> =
2576            generated_member_facts.iter().map(|f| f.entity.clone()).collect();
2577        let synthetic_anchors_from_generated: Vec<perl_semantic_facts::AnchorFact> =
2578            generated_member_facts.iter().map(|f| f.anchor.clone()).collect();
2579
2580        // Merge into single synthetic slices for the canonical builder.
2581        let mut all_synthetic_entities = synthetic_entities_from_eval;
2582        all_synthetic_entities.extend(synthetic_entities_from_generated);
2583        let mut all_synthetic_anchors = synthetic_anchors_from_eval;
2584        all_synthetic_anchors.extend(synthetic_anchors_from_generated);
2585
2586        // Build the canonical fact shard.
2587        // Synthetic entities/anchors are now passed to the builder so that
2588        // `entities_hash` and `anchors_hash` cover the COMPLETE set.
2589        // Import specs (for `use`, `require`, `ClassName->import()`) and
2590        // use-lib facts are populated separately via ImportExportIndex — not passed here.
2591        crate::semantic::facts::build_canonical_fact_shard(
2592            uri,
2593            content_hash,
2594            &decl_facts,
2595            &ref_facts,
2596            &[],
2597            &dynamic_boundaries,
2598            &all_synthetic_entities,
2599            &all_synthetic_anchors,
2600        )
2601    }
2602
2603    /// Replace a [`FileFactShard`] with per-category incremental invalidation.
2604    ///
2605    /// Compares the whole-file `content_hash` first; when unchanged the
2606    /// replacement is skipped entirely.  Otherwise each per-category hash
2607    /// (`anchors_hash`, `entities_hash`, `occurrences_hash`, `edges_hash`)
2608    /// is compared individually.  Only categories whose hash changed trigger
2609    /// removal of old entries and insertion of new ones in the cross-file
2610    /// semantic indexes.
2611    ///
2612    /// **Validates: Requirements 18.1, 18.2, 18.3, 18.4, 18.5**
2613    pub fn replace_fact_shard_incremental(
2614        &self,
2615        key: &str,
2616        new_shard: FileFactShard,
2617    ) -> ShardReplaceResult {
2618        let mut shards = self.fact_shards.write();
2619        let old_shard = shards.get(key);
2620
2621        let replacement = plan_shard_replacement(
2622            old_shard.map(Self::shard_category_hashes),
2623            Self::shard_category_hashes(&new_shard),
2624        );
2625
2626        if replacement.content_unchanged {
2627            return replacement;
2628        }
2629
2630        let source_uri = new_shard.source_uri.clone();
2631
2632        // ── Update cross-file semantic indexes per category ──
2633        // Occurrences and edges are both managed by the ReferenceIndex.
2634        // When either changes we must remove+re-add the file in that index.
2635        if replacement.occurrences_updated || replacement.edges_updated {
2636            let mut ref_idx = self.semantic_reference_index.write();
2637            if old_shard.is_some() {
2638                ref_idx.remove_file(&source_uri);
2639            }
2640            ref_idx.add_file(&new_shard);
2641        }
2642
2643        // Entities feed into the import/export index (export sets are keyed
2644        // by module name derived from entity canonical names).  When entities
2645        // change we refresh the import/export index for this file.
2646        if replacement.entities_updated {
2647            let mut ie_idx = self.semantic_import_export_index.write();
2648            ie_idx.remove_file_imports(&source_uri);
2649            ie_idx.remove_module_exports(&source_uri);
2650            // Re-add is handled by the caller or future wiring; for now we
2651            // ensure stale entries are purged.
2652        }
2653
2654        // Store the new shard (always, since content_hash differs).
2655        shards.insert(key.to_string(), new_shard);
2656
2657        replacement
2658    }
2659
2660    fn shard_category_hashes(shard: &FileFactShard) -> ShardCategoryHashes {
2661        ShardCategoryHashes {
2662            content_hash: shard.content_hash,
2663            anchors_hash: shard.anchors_hash,
2664            entities_hash: shard.entities_hash,
2665            occurrences_hash: shard.occurrences_hash,
2666            edges_hash: shard.edges_hash,
2667        }
2668    }
2669
2670    /// Number of stored file fact shards.
2671    pub fn fact_shard_count(&self) -> usize {
2672        self.fact_shards.read().len()
2673    }
2674
2675    /// Fetch a file fact shard for test/inspection.
2676    pub fn file_fact_shard(&self, uri: &str) -> Option<FileFactShard> {
2677        let key = DocumentStore::uri_key(&Self::normalize_uri(uri));
2678        self.fact_shards.read().get(&key).cloned()
2679    }
2680
2681    /// Resolve a semantic anchor to a source-backed LSP-wire location.
2682    ///
2683    /// Returns `None` for missing anchors, zero-width fallback anchors, or
2684    /// anchors whose source text is unavailable from the document store. If
2685    /// more than one shard contains the same anchor ID, this fails closed
2686    /// instead of choosing an arbitrary hash-map iteration result.
2687    pub fn semantic_anchor_wire_location(&self, anchor_id: AnchorId) -> Option<WireLocation> {
2688        let shards = self.fact_shards.read();
2689        let mut location = None;
2690
2691        for shard in shards.values() {
2692            for anchor in shard.anchors.iter().filter(|anchor| anchor.id == anchor_id) {
2693                if anchor.span_end_byte <= anchor.span_start_byte {
2694                    return None;
2695                }
2696
2697                let doc = self.document_store.get(&shard.source_uri)?;
2698                let start = usize::try_from(anchor.span_start_byte).ok()?;
2699                let end = usize::try_from(anchor.span_end_byte).ok()?;
2700                let next_location = WireLocation::new(
2701                    shard.source_uri.clone(),
2702                    WireRange::from_byte_offsets(&doc.text, start, end),
2703                );
2704                if location.replace(next_location).is_some() {
2705                    return None;
2706                }
2707            }
2708        }
2709
2710        location
2711    }
2712
2713    /// Resolve a semantic anchor to a source-backed LSP-wire location in a
2714    /// specific indexed file.
2715    ///
2716    /// This is the edit-safe variant of [`Self::semantic_anchor_wire_location`]:
2717    /// callers that already have `(file_id, anchor_id)` from a semantic plan do
2718    /// not need the global duplicate-anchor fail-closed behavior.
2719    pub fn semantic_anchor_wire_location_for_file(
2720        &self,
2721        file_id: FileId,
2722        anchor_id: AnchorId,
2723    ) -> Option<WireLocation> {
2724        let shards = self.fact_shards.read();
2725        let shard = shards.values().find(|shard| shard.file_id == file_id)?;
2726        let anchor = shard
2727            .anchors
2728            .iter()
2729            .find(|anchor| anchor.id == anchor_id && anchor.file_id == file_id)?;
2730
2731        if anchor.span_end_byte <= anchor.span_start_byte {
2732            return None;
2733        }
2734
2735        let doc = self.document_store.get(&shard.source_uri)?;
2736        let start = usize::try_from(anchor.span_start_byte).ok()?;
2737        let end = usize::try_from(anchor.span_end_byte).ok()?;
2738        doc.text.get(start..end)?;
2739
2740        Some(WireLocation::new(
2741            shard.source_uri.clone(),
2742            WireRange::from_byte_offsets(&doc.text, start, end),
2743        ))
2744    }
2745
2746    /// Compute the [`FileId`] for a URI using the same hash used during indexing.
2747    ///
2748    /// Returns `None` if the URI has not been indexed (no fact shard is present).
2749    pub fn file_id_for_uri(&self, uri: &str) -> Option<FileId> {
2750        let key = DocumentStore::uri_key(&Self::normalize_uri(uri));
2751        self.fact_shards.read().get(&key).map(|shard| shard.file_id)
2752    }
2753
2754    /// Invoke a scoped callback with [`WorkspaceSemanticQueries`] built from
2755    /// the current semantic indexes for the given URI.
2756    ///
2757    /// The callback receives the resolved [`FileId`] and a
2758    /// [`WorkspaceSemanticQueries`] facade that borrows from read-locked
2759    /// semantic indexes. Locks are released when `f` returns.
2760    ///
2761    /// Returns `Some(result)` if the URI is indexed and semantic data is
2762    /// available, `None` if the URI has not been indexed or its fact shard is
2763    /// absent (the caller should fall back to legacy diagnostics).
2764    pub fn with_semantic_queries_for_uri<R>(
2765        &self,
2766        uri: &str,
2767        f: impl FnOnce(FileId, crate::semantic::queries::WorkspaceSemanticQueries<'_>) -> R,
2768    ) -> Option<R> {
2769        let key = DocumentStore::uri_key(&Self::normalize_uri(uri));
2770
2771        // Acquire all three read guards simultaneously. The lock order must be
2772        // consistent with every other site that acquires multiple locks to avoid
2773        // deadlock: shards → reference_index → import_export_index.
2774        let shards_guard = self.fact_shards.read();
2775        let ref_guard = self.semantic_reference_index.read();
2776        let ie_guard = self.semantic_import_export_index.read();
2777
2778        // Verify the URI is indexed before entering the callback.
2779        let file_id = shards_guard.get(&key)?.file_id;
2780
2781        let queries = crate::semantic::queries::WorkspaceSemanticQueries::new(
2782            &ref_guard,
2783            &ie_guard,
2784            &shards_guard,
2785        );
2786
2787        Some(f(file_id, queries))
2788    }
2789
2790    /// Return the number of indexed files in the workspace
2791    pub fn file_count(&self) -> usize {
2792        let files = self.files.read();
2793        files.len()
2794    }
2795
2796    /// Return the total number of symbols across all indexed files
2797    pub fn symbol_count(&self) -> usize {
2798        let files = self.files.read();
2799        files.values().map(|file_index| file_index.symbols.len()).sum()
2800    }
2801
2802    /// Get all files in a specific workspace folder
2803    ///
2804    /// # Arguments
2805    ///
2806    /// * `folder_uri` - Workspace folder URI to filter by
2807    ///
2808    /// # Returns
2809    ///
2810    /// A vector of file indices belonging to the specified folder
2811    pub fn files_in_folder(&self, folder_uri: &str) -> Vec<FileIndex> {
2812        let files = self.files.read();
2813        files.values().filter(|f| f.folder_uri.as_deref() == Some(folder_uri)).cloned().collect()
2814    }
2815
2816    /// Get all symbols in a specific workspace folder
2817    ///
2818    /// # Arguments
2819    ///
2820    /// * `folder_uri` - Workspace folder URI to filter by
2821    ///
2822    /// # Returns
2823    ///
2824    /// A vector of symbols belonging to the specified folder
2825    pub fn symbols_in_folder(&self, folder_uri: &str) -> Vec<WorkspaceSymbol> {
2826        let files = self.files.read();
2827        files
2828            .values()
2829            .filter(|f| f.folder_uri.as_deref() == Some(folder_uri))
2830            .flat_map(|f| f.symbols.iter().cloned())
2831            .collect()
2832    }
2833
2834    /// Capture a point-in-time memory estimate of the index.
2835    ///
2836    /// Acquires read locks on all index components and walks their contents
2837    /// to estimate heap usage. Intended for offline profiling; do not call
2838    /// on the LSP hot path.
2839    ///
2840    /// Only available when the `memory-profiling` feature is enabled.
2841    #[cfg(feature = "memory-profiling")]
2842    pub fn memory_snapshot(&self) -> crate::workspace::memory::MemorySnapshot {
2843        use std::mem::size_of;
2844
2845        let files_guard = self.files.read();
2846        let symbols_guard = self.symbols.read();
2847        let global_refs_guard = self.global_references.read();
2848
2849        // --- files map ---
2850        let mut files_bytes: usize = 0;
2851        let mut total_symbol_count: usize = 0;
2852        for (uri_key, fi) in files_guard.iter() {
2853            // key string
2854            files_bytes += uri_key.len();
2855            // per-symbol entries
2856            for sym in &fi.symbols {
2857                files_bytes += sym.name.len()
2858                    + sym.uri.len()
2859                    + sym.qualified_name.as_deref().map_or(0, str::len)
2860                    + sym.documentation.as_deref().map_or(0, str::len)
2861                    + sym.container_name.as_deref().map_or(0, str::len)
2862                    // stack portion: kind + range + has_body + option discriminants
2863                    + size_of::<WorkspaceSymbol>();
2864            }
2865            total_symbol_count += fi.symbols.len();
2866            // per-reference entries
2867            for (ref_name, refs) in &fi.references {
2868                files_bytes += ref_name.len();
2869                for r in refs {
2870                    files_bytes += r.uri.len() + size_of::<SymbolReference>();
2871                }
2872            }
2873            // dependencies
2874            for dep in &fi.dependencies {
2875                files_bytes += dep.len();
2876            }
2877            // content hash (u64) + vec/hashset capacity overhead (rough)
2878            files_bytes += size_of::<u64>();
2879        }
2880
2881        // --- global symbols map ---
2882        let mut symbols_bytes: usize = 0;
2883        for (qname, candidates) in symbols_guard.iter() {
2884            symbols_bytes += qname.len();
2885            for candidate in candidates {
2886                symbols_bytes += candidate.location.uri.len() + size_of::<Location>();
2887            }
2888        }
2889
2890        // --- global references map ---
2891        let mut global_refs_bytes: usize = 0;
2892        for (sym_name, locs) in global_refs_guard.iter() {
2893            global_refs_bytes += sym_name.len();
2894            for loc in locs {
2895                global_refs_bytes += loc.uri.len() + size_of::<Location>();
2896            }
2897        }
2898
2899        // --- document store ---
2900        let document_store_bytes = self.document_store.total_text_bytes();
2901
2902        crate::workspace::memory::MemorySnapshot {
2903            file_count: files_guard.len(),
2904            symbol_count: total_symbol_count,
2905            files_bytes,
2906            symbols_bytes,
2907            global_refs_bytes,
2908            document_store_bytes,
2909        }
2910    }
2911
2912    /// Check if the workspace index has symbols (soft readiness check)
2913    ///
2914    /// Returns true if the index contains any symbols, indicating that
2915    /// at least some files have been indexed and the workspace is ready
2916    /// for symbol-based operations like completion.
2917    ///
2918    /// # Returns
2919    ///
2920    /// `true` if any symbols are indexed, otherwise `false`.
2921    ///
2922    /// # Examples
2923    ///
2924    /// ```rust,ignore
2925    /// use perl_parser::workspace_index::WorkspaceIndex;
2926    ///
2927    /// let index = WorkspaceIndex::new();
2928    /// assert!(!index.has_symbols());
2929    /// ```
2930    pub fn has_symbols(&self) -> bool {
2931        let files = self.files.read();
2932        files.values().any(|file_index| !file_index.symbols.is_empty())
2933    }
2934
2935    /// Search for symbols by query
2936    ///
2937    /// # Arguments
2938    ///
2939    /// * `query` - Substring to match against symbol names
2940    ///
2941    /// # Returns
2942    ///
2943    /// Symbols whose names or qualified names contain the query string.
2944    ///
2945    /// # Examples
2946    ///
2947    /// ```rust,ignore
2948    /// use perl_parser::workspace_index::WorkspaceIndex;
2949    ///
2950    /// let index = WorkspaceIndex::new();
2951    /// let _results = index.search_symbols("example");
2952    /// ```
2953    pub fn search_symbols(&self, query: &str) -> Vec<WorkspaceSymbol> {
2954        self.search_source_symbols(query)
2955    }
2956
2957    /// Search only source-backed syntax symbols from the workspace index.
2958    ///
2959    /// Generated/framework members are excluded. Use this when a caller needs
2960    /// to preserve the historical source-backed live slice for trust receipts
2961    /// or fallback paths.
2962    pub fn search_source_symbols(&self, query: &str) -> Vec<WorkspaceSymbol> {
2963        let query = query.trim();
2964        let query_lower = query.to_lowercase();
2965        let files = self.files.read();
2966        let mut results = Vec::new();
2967        for file_index in files.values() {
2968            for symbol in &file_index.symbols {
2969                if symbol.name.to_lowercase().contains(&query_lower)
2970                    || symbol
2971                        .qualified_name
2972                        .as_ref()
2973                        .map(|qn| qn.to_lowercase().contains(&query_lower))
2974                        .unwrap_or(false)
2975                {
2976                    results.push(symbol.clone());
2977                }
2978            }
2979        }
2980        results
2981    }
2982
2983    /// Search labeled generated/framework members backed by semantic source anchors.
2984    ///
2985    /// This is a narrow workspace-symbol pilot: returned symbols are explicitly
2986    /// labeled as generated/framework members and point at the source declaration
2987    /// that produced the member, not at an exact generated method body.
2988    pub fn search_generated_workspace_symbols(&self, query: &str) -> Vec<WorkspaceSymbol> {
2989        let query = query.trim();
2990        if query.is_empty() {
2991            return Vec::new();
2992        }
2993
2994        let query_lower = query.to_lowercase();
2995        let source_backed_qualified_names = self.source_backed_qualified_names();
2996        let shards = self.fact_shards.read();
2997        let mut results = Vec::new();
2998
2999        for shard in shards.values() {
3000            for entity in &shard.entities {
3001                if entity.kind != EntityKind::GeneratedMember {
3002                    continue;
3003                }
3004                if !is_framework_generated_member_entity(entity) {
3005                    continue;
3006                }
3007                if source_backed_qualified_names.contains(&entity.canonical_name) {
3008                    continue;
3009                }
3010                let Some((container_name, bare_name)) =
3011                    split_qualified_symbol_name(&entity.canonical_name)
3012                else {
3013                    continue;
3014                };
3015                if !bare_name.to_lowercase().contains(&query_lower)
3016                    && !entity.canonical_name.to_lowercase().contains(&query_lower)
3017                {
3018                    continue;
3019                }
3020                let Some(anchor_id) = entity.anchor_id else {
3021                    continue;
3022                };
3023                let Some(range) = self.generated_member_anchor_range(shard, anchor_id) else {
3024                    continue;
3025                };
3026
3027                results.push(WorkspaceSymbol {
3028                    name: format!("{bare_name} [generated/framework]"),
3029                    kind: SymbolKind::Method,
3030                    uri: shard.source_uri.clone(),
3031                    range,
3032                    qualified_name: Some(entity.canonical_name.clone()),
3033                    documentation: Some(
3034                        "Generated/framework member; virtual symbol anchored to source declaration"
3035                            .to_string(),
3036                    ),
3037                    container_name: Some(format!("{container_name} [generated/framework]")),
3038                    has_body: false,
3039                    workspace_folder_uri: self.determine_folder_uri(&shard.source_uri),
3040                });
3041            }
3042        }
3043
3044        sort_workspace_symbols(&mut results);
3045        results
3046    }
3047
3048    fn source_backed_qualified_names(&self) -> HashSet<String> {
3049        let files = self.files.read();
3050        let mut qualified_names = HashSet::new();
3051        for file_index in files.values() {
3052            for symbol in &file_index.symbols {
3053                if let Some(name) = &symbol.qualified_name {
3054                    qualified_names.insert(name.clone());
3055                    continue;
3056                }
3057                if let Some(container) = &symbol.container_name {
3058                    qualified_names.insert(format!("{container}::{}", symbol.name));
3059                }
3060            }
3061        }
3062        qualified_names
3063    }
3064
3065    fn generated_member_anchor_range(
3066        &self,
3067        shard: &FileFactShard,
3068        anchor_id: AnchorId,
3069    ) -> Option<Range> {
3070        let anchor = shard
3071            .anchors
3072            .iter()
3073            .find(|anchor| anchor.id == anchor_id && anchor.file_id == shard.file_id)?;
3074        if anchor.provenance != Provenance::FrameworkSynthesis
3075            || anchor.confidence != Confidence::Medium
3076        {
3077            return None;
3078        }
3079        if anchor.span_end_byte <= anchor.span_start_byte {
3080            return None;
3081        }
3082
3083        let doc = self.document_store.get(&shard.source_uri)?;
3084        let start = usize::try_from(anchor.span_start_byte).ok()?;
3085        let end = usize::try_from(anchor.span_end_byte).ok()?;
3086        doc.text.get(start..end)?;
3087        let ((start_line, start_col), (end_line, end_col)) = doc.line_index.range(start, end);
3088        Some(Range {
3089            start: Position { byte: start, line: start_line, column: start_col },
3090            end: Position { byte: end, line: end_line, column: end_col },
3091        })
3092    }
3093
3094    /// Find symbols by query (alias for search_symbols for compatibility)
3095    ///
3096    /// # Arguments
3097    ///
3098    /// * `query` - Substring to match against symbol names
3099    ///
3100    /// # Returns
3101    ///
3102    /// Symbols whose names or qualified names contain the query string.
3103    ///
3104    /// # Examples
3105    ///
3106    /// ```rust,ignore
3107    /// use perl_parser::workspace_index::WorkspaceIndex;
3108    ///
3109    /// let index = WorkspaceIndex::new();
3110    /// let _results = index.find_symbols("example");
3111    /// ```
3112    pub fn find_symbols(&self, query: &str) -> Vec<WorkspaceSymbol> {
3113        self.search_symbols(query)
3114    }
3115
3116    /// Rank symbols by folder proximity to a document
3117    ///
3118    /// Returns symbols sorted by: same folder > other folders
3119    ///
3120    /// # Arguments
3121    ///
3122    /// * `symbols` - Symbols to rank
3123    /// * `doc_uri` - Document URI to determine folder context
3124    ///
3125    /// # Returns
3126    ///
3127    /// Symbols ranked by folder proximity (same folder first)
3128    ///
3129    /// # Examples
3130    ///
3131    /// ```rust,ignore
3132    /// use perl_parser::workspace_index::WorkspaceIndex;
3133    ///
3134    /// let index = WorkspaceIndex::new();
3135    /// let symbols = index.search_symbols("example");
3136    /// let ranked = index.rank_symbols_by_folder(symbols, "file:///project1/src/main.pl");
3137    /// ```
3138    pub fn rank_symbols_by_folder(
3139        &self,
3140        symbols: Vec<WorkspaceSymbol>,
3141        doc_uri: &str,
3142    ) -> Vec<WorkspaceSymbol> {
3143        let doc_folder = self.determine_folder_uri(doc_uri);
3144
3145        let mut ranked: Vec<(WorkspaceSymbol, i32)> = symbols
3146            .into_iter()
3147            .map(|symbol| {
3148                let rank = if let Some(ref doc_folder_uri) = doc_folder {
3149                    if symbol.workspace_folder_uri.as_ref() == Some(doc_folder_uri) {
3150                        0 // Same folder - highest priority
3151                    } else {
3152                        1 // Different folder - lower priority
3153                    }
3154                } else {
3155                    1 // No document context - treat as different folder
3156                };
3157                (symbol, rank)
3158            })
3159            .collect();
3160
3161        // Sort by rank (lower is better), then by name for stability
3162        ranked.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.name.cmp(&b.0.name)));
3163
3164        ranked.into_iter().map(|(symbol, _)| symbol).collect()
3165    }
3166
3167    /// Search for symbols with folder-aware ranking
3168    ///
3169    /// Combines symbol search with folder proximity ranking
3170    ///
3171    /// # Arguments
3172    ///
3173    /// * `name` - Symbol name to search for
3174    /// * `doc_uri` - Document URI for ranking context
3175    ///
3176    /// # Returns
3177    ///
3178    /// Ranked symbols with same-folder results first
3179    ///
3180    /// # Examples
3181    ///
3182    /// ```rust,ignore
3183    /// use perl_parser::workspace_index::WorkspaceIndex;
3184    ///
3185    /// let index = WorkspaceIndex::new();
3186    /// let ranked = index.search_symbols_ranked("example", "file:///project1/src/main.pl");
3187    /// ```
3188    pub fn search_symbols_ranked(&self, name: &str, doc_uri: &str) -> Vec<WorkspaceSymbol> {
3189        let symbols = self.search_symbols(name);
3190        self.rank_symbols_by_folder(symbols, doc_uri)
3191    }
3192
3193    /// Determine if two symbols are in the same package
3194    ///
3195    /// # Arguments
3196    ///
3197    /// * `symbol_a` - First symbol
3198    /// * `symbol_b` - Second symbol
3199    ///
3200    /// # Returns
3201    ///
3202    /// `true` if both symbols are in the same package
3203    #[allow(dead_code)]
3204    pub fn same_package(&self, symbol_a: &WorkspaceSymbol, symbol_b: &WorkspaceSymbol) -> bool {
3205        let package_a = self.extract_package_name(&symbol_a.name);
3206        let package_b = self.extract_package_name(&symbol_b.name);
3207        package_a == package_b
3208    }
3209
3210    /// Determine if two package names are the same (helper for testing)
3211    ///
3212    /// # Arguments
3213    ///
3214    /// * `package_a` - First package name
3215    /// * `package_b` - Second package name
3216    ///
3217    /// # Returns
3218    ///
3219    /// `true` if both package names are equal
3220    #[allow(dead_code)]
3221    pub fn same_package_by_container(&self, package_a: &str, package_b: &str) -> bool {
3222        package_a == package_b
3223    }
3224
3225    /// Extract package name from a symbol name
3226    ///
3227    /// # Arguments
3228    ///
3229    /// * `symbol_name` - Symbol name (e.g., "Foo::Bar::baz" or "baz")
3230    ///
3231    /// # Returns
3232    ///
3233    /// Package name (e.g., "Foo::Bar") or None for main package
3234    #[allow(dead_code)]
3235    pub fn extract_package_name(&self, symbol_name: &str) -> Option<String> {
3236        let parts: Vec<&str> = symbol_name.split("::").collect();
3237        if parts.len() > 1 { Some(parts[..parts.len() - 1].join("::")) } else { None }
3238    }
3239
3240    /// Get symbols in a specific file
3241    ///
3242    /// # Arguments
3243    ///
3244    /// * `uri` - File URI to inspect
3245    ///
3246    /// # Returns
3247    ///
3248    /// All symbols indexed for the requested file.
3249    ///
3250    /// # Examples
3251    ///
3252    /// ```rust,ignore
3253    /// use perl_parser::workspace_index::WorkspaceIndex;
3254    ///
3255    /// let index = WorkspaceIndex::new();
3256    /// let _symbols = index.file_symbols("file:///example.pl");
3257    /// ```
3258    pub fn file_symbols(&self, uri: &str) -> Vec<WorkspaceSymbol> {
3259        let normalized_uri = Self::normalize_uri(uri);
3260        let key = DocumentStore::uri_key(&normalized_uri);
3261        let files = self.files.read();
3262
3263        files.get(&key).map(|fi| fi.symbols.clone()).unwrap_or_default()
3264    }
3265
3266    /// Get dependencies of a file
3267    ///
3268    /// # Arguments
3269    ///
3270    /// * `uri` - File URI to inspect
3271    ///
3272    /// # Returns
3273    ///
3274    /// A set of module names imported by the file.
3275    ///
3276    /// # Examples
3277    ///
3278    /// ```rust,ignore
3279    /// use perl_parser::workspace_index::WorkspaceIndex;
3280    ///
3281    /// let index = WorkspaceIndex::new();
3282    /// let _deps = index.file_dependencies("file:///example.pl");
3283    /// ```
3284    pub fn file_dependencies(&self, uri: &str) -> HashSet<String> {
3285        let normalized_uri = Self::normalize_uri(uri);
3286        let key = DocumentStore::uri_key(&normalized_uri);
3287        let files = self.files.read();
3288
3289        files.get(&key).map(|fi| fi.dependencies.clone()).unwrap_or_default()
3290    }
3291
3292    /// Find all files that depend on a module
3293    ///
3294    /// # Arguments
3295    ///
3296    /// * `module_name` - Module name to search for in file dependencies
3297    ///
3298    /// # Returns
3299    ///
3300    /// A list of file URIs that import or depend on the module.
3301    ///
3302    /// # Examples
3303    ///
3304    /// ```rust,ignore
3305    /// use perl_parser::workspace_index::WorkspaceIndex;
3306    ///
3307    /// let index = WorkspaceIndex::new();
3308    /// let _files = index.find_dependents("My::Module");
3309    /// ```
3310    pub fn find_dependents(&self, module_name: &str) -> Vec<String> {
3311        let canonical = canonicalize_perl_module_name(module_name);
3312        let legacy = legacy_perl_module_name(&canonical);
3313        let files = self.files.read();
3314        let mut dependents = Vec::new();
3315
3316        for (uri_key, file_index) in files.iter() {
3317            if file_index.dependencies.contains(module_name)
3318                || file_index.dependencies.contains(&canonical)
3319                || file_index.dependencies.contains(&legacy)
3320            {
3321                dependents.push(uri_key.clone());
3322            }
3323        }
3324
3325        dependents
3326    }
3327
3328    /// Get the document store
3329    ///
3330    /// # Returns
3331    ///
3332    /// A reference to the in-memory document store.
3333    ///
3334    /// # Examples
3335    ///
3336    /// ```rust,ignore
3337    /// use perl_parser::workspace_index::WorkspaceIndex;
3338    ///
3339    /// let index = WorkspaceIndex::new();
3340    /// let _store = index.document_store();
3341    /// ```
3342    pub fn document_store(&self) -> &DocumentStore {
3343        &self.document_store
3344    }
3345
3346    /// Find unused symbols in the workspace
3347    ///
3348    /// # Returns
3349    ///
3350    /// Symbols that have no non-definition references in the workspace.
3351    ///
3352    /// # Examples
3353    ///
3354    /// ```rust,ignore
3355    /// use perl_parser::workspace_index::WorkspaceIndex;
3356    ///
3357    /// let index = WorkspaceIndex::new();
3358    /// let _unused = index.find_unused_symbols();
3359    /// ```
3360    pub fn find_unused_symbols(&self) -> Vec<WorkspaceSymbol> {
3361        let files = self.files.read();
3362        let mut unused = Vec::new();
3363
3364        // Collect all defined symbols
3365        for (_uri_key, file_index) in files.iter() {
3366            for symbol in &file_index.symbols {
3367                // Check if this symbol has any references beyond its definition
3368                let has_usage = files.values().any(|fi| {
3369                    if let Some(refs) = fi.references.get(&symbol.name) {
3370                        refs.iter().any(|r| r.kind != ReferenceKind::Definition)
3371                    } else {
3372                        false
3373                    }
3374                });
3375
3376                if !has_usage {
3377                    unused.push(symbol.clone());
3378                }
3379            }
3380        }
3381
3382        unused
3383    }
3384
3385    /// Get all symbols that belong to a specific package
3386    ///
3387    /// # Arguments
3388    ///
3389    /// * `package_name` - Package name to match (e.g., `My::Package`)
3390    ///
3391    /// # Returns
3392    ///
3393    /// Symbols defined within the requested package.
3394    ///
3395    /// # Examples
3396    ///
3397    /// ```rust,ignore
3398    /// use perl_parser::workspace_index::WorkspaceIndex;
3399    ///
3400    /// let index = WorkspaceIndex::new();
3401    /// let _members = index.get_package_members("My::Package");
3402    /// ```
3403    pub fn get_package_members(&self, package_name: &str) -> Vec<WorkspaceSymbol> {
3404        let files = self.files.read();
3405        let mut members = Vec::new();
3406
3407        for (_uri_key, file_index) in files.iter() {
3408            for symbol in &file_index.symbols {
3409                // Check if symbol belongs to this package
3410                if let Some(ref container) = symbol.container_name {
3411                    if container == package_name {
3412                        members.push(symbol.clone());
3413                    }
3414                }
3415                // Also check qualified names
3416                if let Some(ref qname) = symbol.qualified_name {
3417                    if qname.starts_with(&format!("{}::", package_name)) {
3418                        // Avoid duplicates - only add if not already in via container_name
3419                        if symbol.container_name.as_deref() != Some(package_name) {
3420                            members.push(symbol.clone());
3421                        }
3422                    }
3423                }
3424            }
3425        }
3426
3427        members
3428    }
3429
3430    /// Names of all packages explicitly declared in a file.
3431    ///
3432    /// Returns the bare declared name for each `package` statement or block in
3433    /// the file (e.g. `"Foo"`, `"Bar"`, `"Foo::Nested"`).  A file with no
3434    /// explicit `package` declaration returns an empty vec; there is no implicit
3435    /// `"main"` symbol to surface.  A file containing `package main;` explicitly
3436    /// WILL appear in results.
3437    ///
3438    /// # Arguments
3439    ///
3440    /// * `uri` - File URI to inspect (normalized via `normalize_uri`)
3441    ///
3442    /// # Returns
3443    ///
3444    /// Declared package names in declaration order (AST walk order).
3445    pub fn file_packages(&self, uri: &str) -> Vec<String> {
3446        let normalized = Self::normalize_uri(uri);
3447        let key = DocumentStore::uri_key(&normalized);
3448        let files = self.files.read();
3449        let Some(file) = files.get(&key) else {
3450            return Vec::new();
3451        };
3452
3453        let mut packages = Vec::new();
3454        for symbol in &file.symbols {
3455            if symbol.kind == SymbolKind::Package {
3456                packages.push(symbol.name.clone());
3457            }
3458        }
3459        packages
3460    }
3461
3462    /// Symbols declared inside a specific package within a file.
3463    ///
3464    /// Returns all `WorkspaceSymbol` entries whose `container_name` equals
3465    /// `package_name` (bare name match, e.g. `"Bar"` or `"Foo::Nested"`).
3466    /// Package declaration symbols themselves are excluded (they carry
3467    /// `container_name = None`).
3468    ///
3469    /// # Arguments
3470    ///
3471    /// * `uri`          - File URI to inspect
3472    /// * `package_name` - Bare package name to filter by (e.g. `"Foo::Bar"`)
3473    ///
3474    /// # Returns
3475    ///
3476    /// Symbols belonging to the package, in declaration order.
3477    pub fn file_package_symbols(&self, uri: &str, package_name: &str) -> Vec<WorkspaceSymbol> {
3478        let normalized = Self::normalize_uri(uri);
3479        let key = DocumentStore::uri_key(&normalized);
3480        let files = self.files.read();
3481        let Some(file) = files.get(&key) else {
3482            return Vec::new();
3483        };
3484
3485        let mut symbols = Vec::new();
3486        for symbol in &file.symbols {
3487            if Self::symbol_belongs_to_package(symbol, package_name) {
3488                symbols.push(symbol.clone());
3489            }
3490        }
3491        symbols
3492    }
3493
3494    fn symbol_belongs_to_package(symbol: &WorkspaceSymbol, package_name: &str) -> bool {
3495        symbol.container_name.as_ref().is_some_and(|container| package_name.eq(container.as_str()))
3496    }
3497
3498    /// Find the definition location for a symbol key during Index/Navigate stages.
3499    ///
3500    /// # Arguments
3501    ///
3502    /// * `key` - Normalized symbol key to resolve.
3503    ///
3504    /// # Returns
3505    ///
3506    /// The definition location for the symbol, if found.
3507    ///
3508    /// # Examples
3509    ///
3510    /// ```rust,ignore
3511    /// use perl_parser::workspace_index::{SymKind, SymbolKey, WorkspaceIndex};
3512    /// use std::sync::Arc;
3513    ///
3514    /// let index = WorkspaceIndex::new();
3515    /// let key = SymbolKey { pkg: Arc::from("My::Package"), name: Arc::from("example"), sigil: None, kind: SymKind::Sub };
3516    /// let _def = index.find_def(&key);
3517    /// ```
3518    pub fn find_def(&self, key: &SymbolKey) -> Option<Location> {
3519        if let Some(sigil) = key.sigil {
3520            // It's a variable
3521            let var_name = format!("{}{}", sigil, key.name);
3522            self.find_definition(&var_name)
3523        } else if key.kind == SymKind::Pack {
3524            // It's a package lookup (e.g., from `use Module::Name`)
3525            // Search for the package declaration by name
3526            self.find_definition(key.pkg.as_ref())
3527                .or_else(|| self.find_definition(key.name.as_ref()))
3528        } else {
3529            // It's a subroutine or package
3530            let qualified_name = format!("{}::{}", key.pkg, key.name);
3531            self.find_definition(&qualified_name)
3532        }
3533    }
3534
3535    /// Find reference locations for a symbol key using dual indexing.
3536    ///
3537    /// Searches both qualified and bare names to support Navigate/Analyze workflows.
3538    ///
3539    /// # Arguments
3540    ///
3541    /// * `key` - Normalized symbol key to search for.
3542    ///
3543    /// # Returns
3544    ///
3545    /// All reference locations for the symbol, excluding the definition.
3546    ///
3547    /// # Examples
3548    ///
3549    /// ```rust,ignore
3550    /// use perl_parser::workspace_index::{SymKind, SymbolKey, WorkspaceIndex};
3551    /// use std::sync::Arc;
3552    ///
3553    /// let index = WorkspaceIndex::new();
3554    /// let key = SymbolKey { pkg: Arc::from("main"), name: Arc::from("example"), sigil: None, kind: SymKind::Sub };
3555    /// let _refs = index.find_refs(&key);
3556    /// ```
3557    pub fn find_refs(&self, key: &SymbolKey) -> Vec<Location> {
3558        let files_locked = self.files.read();
3559        let mut all_refs = if let Some(sigil) = key.sigil {
3560            // It's a variable - search through all files for this variable name
3561            let var_name = format!("{}{}", sigil, key.name);
3562            let mut refs = Vec::new();
3563            for (_uri_key, file_index) in files_locked.iter() {
3564                if let Some(var_refs) = file_index.references.get(&var_name) {
3565                    for reference in var_refs {
3566                        refs.push(Location { uri: reference.uri.clone(), range: reference.range });
3567                    }
3568                }
3569            }
3570            refs
3571        } else {
3572            // It's a subroutine or package
3573            if key.pkg.as_ref() == "main" {
3574                // For main package, we search for both "main::foo" and bare "foo"
3575                let mut refs = self.find_references(&format!("main::{}", key.name));
3576                // Add bare name references
3577                for (_uri_key, file_index) in files_locked.iter() {
3578                    if let Some(bare_refs) = file_index.references.get(key.name.as_ref()) {
3579                        for reference in bare_refs {
3580                            refs.push(Location {
3581                                uri: reference.uri.clone(),
3582                                range: reference.range,
3583                            });
3584                        }
3585                    }
3586                }
3587                refs
3588            } else {
3589                let qualified_name = format!("{}::{}", key.pkg, key.name);
3590                self.find_references(&qualified_name)
3591            }
3592        };
3593        drop(files_locked);
3594
3595        // Remove the definition; the caller will include it separately if needed
3596        if let Some(def) = self.find_def(key) {
3597            all_refs.retain(|loc| !(loc.uri == def.uri && loc.range == def.range));
3598        }
3599
3600        // Deduplicate by URI and range
3601        let mut seen = HashSet::new();
3602        all_refs.retain(|loc| {
3603            seen.insert((
3604                loc.uri.clone(),
3605                loc.range.start.line,
3606                loc.range.start.column,
3607                loc.range.end.line,
3608                loc.range.end.column,
3609            ))
3610        });
3611
3612        all_refs
3613    }
3614}
3615
3616/// AST visitor for extracting symbols and references
3617struct IndexVisitor {
3618    document: Document,
3619    uri: String,
3620    current_package: Option<String>,
3621    workspace_folder_uri: Option<String>,
3622}
3623
3624fn is_interpolated_var_start(byte: u8) -> bool {
3625    byte.is_ascii_alphabetic() || byte == b'_'
3626}
3627
3628fn is_interpolated_var_continue(byte: u8) -> bool {
3629    byte.is_ascii_alphanumeric() || byte == b'_' || byte == b':'
3630}
3631
3632fn has_escaped_interpolation_marker(bytes: &[u8], index: usize) -> bool {
3633    if index == 0 {
3634        return false;
3635    }
3636
3637    let mut backslashes = 0usize;
3638    let mut cursor = index;
3639    while cursor > 0 && bytes[cursor - 1] == b'\\' {
3640        backslashes += 1;
3641        cursor -= 1;
3642    }
3643
3644    backslashes % 2 == 1
3645}
3646
3647fn strip_matching_quote_delimiters(raw_content: &str) -> &str {
3648    if raw_content.len() < 2 {
3649        return raw_content;
3650    }
3651
3652    let bytes = raw_content.as_bytes();
3653    match (bytes.first(), bytes.last()) {
3654        (Some(b'"'), Some(b'"')) | (Some(b'\''), Some(b'\'')) => {
3655            &raw_content[1..raw_content.len() - 1]
3656        }
3657        _ => raw_content,
3658    }
3659}
3660
3661impl IndexVisitor {
3662    fn new(document: &mut Document, uri: String, workspace_folder_uri: Option<String>) -> Self {
3663        Self {
3664            document: document.clone(),
3665            uri,
3666            current_package: Some("main".to_string()),
3667            workspace_folder_uri,
3668        }
3669    }
3670
3671    fn visit(&mut self, node: &Node, file_index: &mut FileIndex) {
3672        self.project_symbol_declarations(node, file_index);
3673        self.visit_node(node, file_index);
3674    }
3675
3676    fn project_symbol_declarations(&self, node: &Node, file_index: &mut FileIndex) {
3677        for decl in extract_symbol_decls(node, self.current_package.as_deref()) {
3678            let (start, end) = match decl.kind {
3679                SymbolKind::Variable(_) => match decl.anchor_span {
3680                    Some(span) => span,
3681                    None => decl.full_span,
3682                },
3683                _ => decl.full_span,
3684            };
3685            let ((start_line, start_col), (end_line, end_col)) =
3686                self.document.line_index.range(start, end);
3687            let range = Range {
3688                start: Position { byte: start, line: start_line, column: start_col },
3689                end: Position { byte: end, line: end_line, column: end_col },
3690            };
3691
3692            let symbol_name = symbol_decl_name(&decl.kind, &decl.name);
3693
3694            // Suppress qualified_name for lexically-scoped variables (my, state): they
3695            // are not package-visible and must not be found by a qualified lookup such
3696            // as `Foo::x`.  `our` and `local` variables keep the qualified name because
3697            // they participate in the package namespace.
3698            let qualified_name = match &decl.declarator {
3699                Some(d) if d == "my" || d == "state" => None,
3700                _ => (!decl.qualified_name.is_empty()).then_some(decl.qualified_name),
3701            };
3702
3703            // Top-level package declarations have no containing package; suppress the
3704            // spurious "main" container that comes from the walker's initial context.
3705            let container_name = match decl.kind {
3706                SymbolKind::Package => None,
3707                _ => decl.container,
3708            };
3709
3710            file_index.symbols.push(WorkspaceSymbol {
3711                name: symbol_name.clone(),
3712                kind: decl.kind,
3713                uri: self.uri.clone(),
3714                range,
3715                qualified_name,
3716                documentation: None,
3717                container_name,
3718                has_body: true,
3719                workspace_folder_uri: self.workspace_folder_uri.clone(),
3720            });
3721
3722            file_index.references.entry(symbol_name).or_default().push(SymbolReference {
3723                uri: self.uri.clone(),
3724                range,
3725                kind: ReferenceKind::Definition,
3726            });
3727        }
3728    }
3729
3730    fn record_interpolated_variable_references(
3731        &self,
3732        raw_content: &str,
3733        range: Range,
3734        file_index: &mut FileIndex,
3735    ) {
3736        let content = strip_matching_quote_delimiters(raw_content);
3737        let bytes = content.as_bytes();
3738        let mut index = 0;
3739
3740        while index < bytes.len() {
3741            if has_escaped_interpolation_marker(bytes, index) {
3742                index += 1;
3743                continue;
3744            }
3745
3746            let sigil = match bytes[index] {
3747                b'$' => "$",
3748                b'@' => "@",
3749                _ => {
3750                    index += 1;
3751                    continue;
3752                }
3753            };
3754
3755            if index + 1 >= bytes.len() {
3756                break;
3757            }
3758
3759            let (start, needs_closing_brace) =
3760                if bytes[index + 1] == b'{' { (index + 2, true) } else { (index + 1, false) };
3761
3762            if start >= bytes.len() || !is_interpolated_var_start(bytes[start]) {
3763                index += 1;
3764                continue;
3765            }
3766
3767            let mut end = start + 1;
3768            while end < bytes.len() && is_interpolated_var_continue(bytes[end]) {
3769                end += 1;
3770            }
3771
3772            if needs_closing_brace && (end >= bytes.len() || bytes[end] != b'}') {
3773                index += 1;
3774                continue;
3775            }
3776
3777            if let Some(name) = content.get(start..end) {
3778                let var_name = format!("{sigil}{name}");
3779                file_index.references.entry(var_name).or_default().push(SymbolReference {
3780                    uri: self.uri.clone(),
3781                    range,
3782                    kind: ReferenceKind::Read,
3783                });
3784            }
3785
3786            index = if needs_closing_brace { end + 1 } else { end };
3787        }
3788    }
3789
3790    fn visit_node(&mut self, node: &Node, file_index: &mut FileIndex) {
3791        match &node.kind {
3792            NodeKind::Package { name, .. } => {
3793                let package_name = name.clone();
3794
3795                // Update the current package (replaces the previous one, not a stack)
3796                self.current_package = Some(package_name.clone());
3797            }
3798
3799            NodeKind::Subroutine { body, .. } => {
3800                // Visit body
3801                self.visit_node(body, file_index);
3802            }
3803
3804            NodeKind::VariableDeclaration { initializer, .. } => {
3805                // Visit initializer
3806                if let Some(init) = initializer {
3807                    self.visit_node(init, file_index);
3808                }
3809            }
3810
3811            NodeKind::VariableListDeclaration { initializer, .. } => {
3812                // Visit the initializer
3813                if let Some(init) = initializer {
3814                    self.visit_node(init, file_index);
3815                }
3816            }
3817
3818            NodeKind::Variable { sigil, name } => {
3819                let var_name = format!("{}{}", sigil, name);
3820
3821                // Track as usage (could be read or write based on context)
3822                file_index.references.entry(var_name).or_default().push(SymbolReference {
3823                    uri: self.uri.clone(),
3824                    range: self.node_to_range(node),
3825                    kind: ReferenceKind::Read, // Default to read, would need context for write
3826                });
3827            }
3828
3829            NodeKind::FunctionCall { name, args, .. } => {
3830                let func_name = name.clone();
3831                let location = self.node_to_range(node);
3832
3833                // Determine package and bare name
3834                let (pkg, bare_name) = if let Some(idx) = func_name.rfind("::") {
3835                    (&func_name[..idx], &func_name[idx + 2..])
3836                } else {
3837                    (self.current_package.as_deref().unwrap_or("main"), func_name.as_str())
3838                };
3839
3840                let qualified = format!("{}::{}", pkg, bare_name);
3841
3842                // Track as usage for both qualified and bare forms
3843                // This dual indexing allows finding references whether the function is called
3844                // as `process_data()` or `Utils::process_data()`
3845                file_index.references.entry(bare_name.to_string()).or_default().push(
3846                    SymbolReference {
3847                        uri: self.uri.clone(),
3848                        range: location,
3849                        kind: ReferenceKind::Usage,
3850                    },
3851                );
3852                file_index.references.entry(qualified).or_default().push(SymbolReference {
3853                    uri: self.uri.clone(),
3854                    range: location,
3855                    kind: ReferenceKind::Usage,
3856                });
3857
3858                if name == "extends" || name == "with" {
3859                    for module_name in extract_module_names_from_call_args(args) {
3860                        file_index
3861                            .dependencies
3862                            .insert(normalize_dependency_module_name(&module_name));
3863                    }
3864                } else if name == "require" {
3865                    if let Some(module_name) = extract_module_name_from_require_args(args) {
3866                        file_index
3867                            .dependencies
3868                            .insert(normalize_dependency_module_name(&module_name));
3869                    }
3870                }
3871
3872                // Visit arguments
3873                for arg in args {
3874                    self.visit_node(arg, file_index);
3875                }
3876            }
3877
3878            NodeKind::Use { module, args, .. } => {
3879                let module_name = normalize_dependency_module_name(module);
3880                file_index.dependencies.insert(module_name.clone());
3881
3882                // Also track actual parent/base class names for dependency discovery.
3883                // `use parent 'Foo::Bar'` stores module="parent" and args=["'Foo::Bar'"],
3884                // so find_dependents("Foo::Bar") would miss files with only use parent.
3885                if module == "parent" || module == "base" {
3886                    for name in extract_module_names_from_use_args(args) {
3887                        file_index.dependencies.insert(normalize_dependency_module_name(&name));
3888                    }
3889                }
3890
3891                // Track as import
3892                file_index.references.entry(module_name).or_default().push(SymbolReference {
3893                    uri: self.uri.clone(),
3894                    range: self.node_to_range(node),
3895                    kind: ReferenceKind::Import,
3896                });
3897            }
3898
3899            // Handle assignment to detect writes
3900            NodeKind::Assignment { lhs, rhs, op } => {
3901                // For compound assignments (+=, -=, .=, etc.), the LHS is both read and written
3902                let is_compound = op != "=";
3903
3904                if let NodeKind::Variable { sigil, name } = &lhs.kind {
3905                    let var_name = format!("{}{}", sigil, name);
3906
3907                    // For compound assignments, it's a read first
3908                    if is_compound {
3909                        file_index.references.entry(var_name.clone()).or_default().push(
3910                            SymbolReference {
3911                                uri: self.uri.clone(),
3912                                range: self.node_to_range(lhs),
3913                                kind: ReferenceKind::Read,
3914                            },
3915                        );
3916                    }
3917
3918                    // Then it's always a write
3919                    file_index.references.entry(var_name).or_default().push(SymbolReference {
3920                        uri: self.uri.clone(),
3921                        range: self.node_to_range(lhs),
3922                        kind: ReferenceKind::Write,
3923                    });
3924                }
3925
3926                // Right side could have reads
3927                self.visit_node(rhs, file_index);
3928            }
3929
3930            // Recursively visit child nodes
3931            NodeKind::Block { statements } => {
3932                for stmt in statements {
3933                    self.visit_node(stmt, file_index);
3934                }
3935            }
3936
3937            NodeKind::If { condition, then_branch, elsif_branches, else_branch, .. } => {
3938                self.visit_node(condition, file_index);
3939                self.visit_node(then_branch, file_index);
3940                for (cond, branch) in elsif_branches {
3941                    self.visit_node(cond, file_index);
3942                    self.visit_node(branch, file_index);
3943                }
3944                if let Some(else_br) = else_branch {
3945                    self.visit_node(else_br, file_index);
3946                }
3947            }
3948
3949            NodeKind::While { condition, body, continue_block, .. } => {
3950                self.visit_node(condition, file_index);
3951                self.visit_node(body, file_index);
3952                if let Some(cont) = continue_block {
3953                    self.visit_node(cont, file_index);
3954                }
3955            }
3956
3957            NodeKind::For { init, condition, update, body, continue_block } => {
3958                if let Some(i) = init {
3959                    self.visit_node(i, file_index);
3960                }
3961                if let Some(c) = condition {
3962                    self.visit_node(c, file_index);
3963                }
3964                if let Some(u) = update {
3965                    self.visit_node(u, file_index);
3966                }
3967                self.visit_node(body, file_index);
3968                if let Some(cont) = continue_block {
3969                    self.visit_node(cont, file_index);
3970                }
3971            }
3972
3973            NodeKind::Foreach { variable, list, body, continue_block } => {
3974                // Iterator is a write context
3975                if let Some(cb) = continue_block {
3976                    self.visit_node(cb, file_index);
3977                }
3978                if let NodeKind::Variable { sigil, name } = &variable.kind {
3979                    let var_name = format!("{}{}", sigil, name);
3980                    file_index.references.entry(var_name).or_default().push(SymbolReference {
3981                        uri: self.uri.clone(),
3982                        range: self.node_to_range(variable),
3983                        kind: ReferenceKind::Write,
3984                    });
3985                }
3986                self.visit_node(variable, file_index);
3987                self.visit_node(list, file_index);
3988                self.visit_node(body, file_index);
3989            }
3990
3991            NodeKind::MethodCall { object, method, args } => {
3992                // Check if this is a static method call (Package->method)
3993                let qualified_method = if let NodeKind::Identifier { name } = &object.kind {
3994                    // Static method call: Package->method
3995                    Some(format!("{}::{}", name, method))
3996                } else {
3997                    // Instance method call: $obj->method
3998                    None
3999                };
4000
4001                // Object is a read context
4002                self.visit_node(object, file_index);
4003
4004                // Track method call under BOTH the qualified form (for static calls
4005                // like `Pkg->method`) AND the bare method name. This mirrors the
4006                // FunctionCall dual-key storage above (PR #122 dual-indexing pattern)
4007                // so that bare-name lookups (e.g. `find_unused_symbols`,
4008                // `count_usages("method")`) consistently find static method call sites.
4009                // See #6799 for the original asymmetric-storage bug report.
4010                let location = self.node_to_range(node);
4011                if let Some(qualified_method) = qualified_method.as_ref() {
4012                    file_index.references.entry(qualified_method.clone()).or_default().push(
4013                        SymbolReference {
4014                            uri: self.uri.clone(),
4015                            range: location,
4016                            kind: ReferenceKind::Usage,
4017                        },
4018                    );
4019                }
4020                file_index.references.entry(method.clone()).or_default().push(SymbolReference {
4021                    uri: self.uri.clone(),
4022                    range: location,
4023                    kind: ReferenceKind::Usage,
4024                });
4025
4026                if method == "import"
4027                    && let NodeKind::Identifier { name: module_name } = &object.kind
4028                {
4029                    for symbol in extract_manual_import_symbols(args) {
4030                        file_index.references.entry(symbol).or_default().push(SymbolReference {
4031                            uri: self.uri.clone(),
4032                            range: self.node_to_range(node),
4033                            kind: ReferenceKind::Import,
4034                        });
4035                    }
4036                    file_index.dependencies.insert(normalize_dependency_module_name(module_name));
4037                }
4038
4039                // Visit arguments
4040                for arg in args {
4041                    self.visit_node(arg, file_index);
4042                }
4043            }
4044
4045            NodeKind::No { module, .. } => {
4046                let module_name = normalize_dependency_module_name(module);
4047                file_index.dependencies.insert(module_name);
4048            }
4049
4050            NodeKind::Class { name, .. } => {
4051                self.current_package = Some(name.clone());
4052            }
4053
4054            NodeKind::Method { body, signature, .. } => {
4055                // Visit params
4056                if let Some(sig) = signature {
4057                    if let NodeKind::Signature { parameters } = &sig.kind {
4058                        for param in parameters {
4059                            self.visit_node(param, file_index);
4060                        }
4061                    }
4062                }
4063
4064                // Visit body
4065                self.visit_node(body, file_index);
4066            }
4067
4068            NodeKind::String { value, interpolated } => {
4069                if *interpolated {
4070                    let range = self.node_to_range(node);
4071                    self.record_interpolated_variable_references(value, range, file_index);
4072                }
4073            }
4074
4075            NodeKind::Heredoc { content, interpolated, .. } => {
4076                if *interpolated {
4077                    let range = self.node_to_range(node);
4078                    self.record_interpolated_variable_references(content, range, file_index);
4079                }
4080            }
4081
4082            // Handle special assignments (++ and --)
4083            NodeKind::Unary { op, operand } if op == "++" || op == "--" => {
4084                // Pre/post increment/decrement are both read and write
4085                if let NodeKind::Variable { sigil, name } = &operand.kind {
4086                    let var_name = format!("{}{}", sigil, name);
4087
4088                    // It's both a read and a write
4089                    file_index.references.entry(var_name.clone()).or_default().push(
4090                        SymbolReference {
4091                            uri: self.uri.clone(),
4092                            range: self.node_to_range(operand),
4093                            kind: ReferenceKind::Read,
4094                        },
4095                    );
4096
4097                    file_index.references.entry(var_name).or_default().push(SymbolReference {
4098                        uri: self.uri.clone(),
4099                        range: self.node_to_range(operand),
4100                        kind: ReferenceKind::Write,
4101                    });
4102                }
4103            }
4104
4105            _ => {
4106                // For other node types, just visit children
4107                self.visit_children(node, file_index);
4108            }
4109        }
4110    }
4111
4112    fn visit_children(&mut self, node: &Node, file_index: &mut FileIndex) {
4113        // Generic visitor for unhandled node types - visit all nested nodes
4114        match &node.kind {
4115            NodeKind::Program { statements } => {
4116                for stmt in statements {
4117                    self.visit_node(stmt, file_index);
4118                }
4119            }
4120            NodeKind::ExpressionStatement { expression } => {
4121                self.visit_node(expression, file_index);
4122            }
4123            // Expression nodes
4124            NodeKind::Unary { operand, .. } => {
4125                self.visit_node(operand, file_index);
4126            }
4127            NodeKind::Binary { left, right, .. } => {
4128                self.visit_node(left, file_index);
4129                self.visit_node(right, file_index);
4130            }
4131            NodeKind::Ternary { condition, then_expr, else_expr } => {
4132                self.visit_node(condition, file_index);
4133                self.visit_node(then_expr, file_index);
4134                self.visit_node(else_expr, file_index);
4135            }
4136            NodeKind::ArrayLiteral { elements } => {
4137                for elem in elements {
4138                    self.visit_node(elem, file_index);
4139                }
4140            }
4141            NodeKind::HashLiteral { pairs } => {
4142                for (key, value) in pairs {
4143                    self.visit_node(key, file_index);
4144                    self.visit_node(value, file_index);
4145                }
4146            }
4147            NodeKind::Return { value } => {
4148                if let Some(val) = value {
4149                    self.visit_node(val, file_index);
4150                }
4151            }
4152            NodeKind::Eval { block } | NodeKind::Do { block } | NodeKind::Defer { block } => {
4153                self.visit_node(block, file_index);
4154            }
4155            NodeKind::Try { body, catch_blocks, finally_block } => {
4156                self.visit_node(body, file_index);
4157                for (_, block) in catch_blocks {
4158                    self.visit_node(block, file_index);
4159                }
4160                if let Some(finally) = finally_block {
4161                    self.visit_node(finally, file_index);
4162                }
4163            }
4164            NodeKind::Given { expr, body } => {
4165                self.visit_node(expr, file_index);
4166                self.visit_node(body, file_index);
4167            }
4168            NodeKind::When { condition, body } => {
4169                self.visit_node(condition, file_index);
4170                self.visit_node(body, file_index);
4171            }
4172            NodeKind::Default { body } => {
4173                self.visit_node(body, file_index);
4174            }
4175            NodeKind::StatementModifier { statement, condition, .. } => {
4176                self.visit_node(statement, file_index);
4177                self.visit_node(condition, file_index);
4178            }
4179            NodeKind::VariableWithAttributes { variable, .. } => {
4180                self.visit_node(variable, file_index);
4181            }
4182            NodeKind::LabeledStatement { statement, .. } => {
4183                self.visit_node(statement, file_index);
4184            }
4185            NodeKind::NestedVariableList { items } => {
4186                // Recurse into items so nested-declared variables are indexed.
4187                for item in items {
4188                    self.visit_node(item, file_index);
4189                }
4190            }
4191            _ => {
4192                // For other node types, no children to visit
4193            }
4194        }
4195    }
4196
4197    fn node_to_range(&mut self, node: &Node) -> Range {
4198        // LineIndex.range returns line numbers and UTF-16 code unit columns
4199        let ((start_line, start_col), (end_line, end_col)) =
4200            self.document.line_index.range(node.location.start, node.location.end);
4201        // Use byte offsets from node.location directly
4202        Range {
4203            start: Position { byte: node.location.start, line: start_line, column: start_col },
4204            end: Position { byte: node.location.end, line: end_line, column: end_col },
4205        }
4206    }
4207}
4208
4209fn symbol_decl_name(kind: &SymbolKind, name: &str) -> String {
4210    match kind {
4211        SymbolKind::Variable(VarKind::Scalar) => format!("${name}"),
4212        SymbolKind::Variable(VarKind::Array) => format!("@{name}"),
4213        SymbolKind::Variable(VarKind::Hash) => format!("%{name}"),
4214        _ => name.to_string(),
4215    }
4216}
4217
4218fn split_qualified_symbol_name(canonical_name: &str) -> Option<(&str, &str)> {
4219    let (container, bare_name) = canonical_name.rsplit_once("::")?;
4220    if container.is_empty() || bare_name.is_empty() {
4221        return None;
4222    }
4223    Some((container, bare_name))
4224}
4225
4226fn is_framework_generated_member_entity(entity: &EntityFact) -> bool {
4227    entity.provenance == Provenance::FrameworkSynthesis && entity.confidence == Confidence::Medium
4228}
4229
4230fn sort_workspace_symbols(symbols: &mut [WorkspaceSymbol]) {
4231    symbols.sort_by(|left, right| {
4232        left.name
4233            .cmp(&right.name)
4234            .then_with(|| left.uri.cmp(&right.uri))
4235            .then_with(|| left.range.start.line.cmp(&right.range.start.line))
4236            .then_with(|| left.range.start.column.cmp(&right.range.start.column))
4237            .then_with(|| left.range.end.line.cmp(&right.range.end.line))
4238            .then_with(|| left.range.end.column.cmp(&right.range.end.column))
4239    });
4240}
4241
4242/// Extract bare module names from the argument list of a `use parent` / `use base` statement.
4243///
4244/// The `args` field of `NodeKind::Use` stores raw argument strings as the parser captured them.
4245/// For `use parent 'Foo::Bar'` this is `["'Foo::Bar'"]`.
4246/// For `use parent qw(Foo::Bar Other::Base)` this is `["qw(Foo::Bar Other::Base)"]`.
4247/// For `use parent -norequire, 'Foo::Bar'` this is `["-norequire", "'Foo::Bar'"]`.
4248///
4249/// Returns the module names with surrounding quotes/qw wrappers stripped.
4250/// Tokens starting with `-` or not matching `[\w::']+` are silently skipped.
4251fn extract_module_names_from_use_args(args: &[String]) -> Vec<String> {
4252    use std::collections::HashSet;
4253
4254    fn normalize_module_name(token: &str) -> Option<&str> {
4255        let stripped = token.trim_matches(|c: char| {
4256            matches!(c, '\'' | '"' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ';')
4257        });
4258
4259        if stripped.is_empty() || stripped.starts_with('-') {
4260            return None;
4261        }
4262
4263        stripped
4264            .chars()
4265            .all(|c| c.is_alphanumeric() || c == '_' || c == ':' || c == '\'')
4266            .then_some(stripped)
4267    }
4268
4269    let joined = args.join(" ");
4270
4271    let (qw_words, remainder) = extract_qw_words(&joined);
4272    let mut modules = Vec::new();
4273    let mut seen = HashSet::new();
4274    for word in qw_words {
4275        if let Some(candidate) = normalize_module_name(&word) {
4276            let canonical = canonicalize_perl_module_name(candidate);
4277            if seen.insert(canonical.clone()) {
4278                modules.push(canonical);
4279            }
4280        }
4281    }
4282
4283    for token in remainder.split_whitespace().flat_map(|t| t.split(',')) {
4284        if let Some(candidate) = normalize_module_name(token) {
4285            let canonical = canonicalize_perl_module_name(candidate);
4286            if seen.insert(canonical.clone()) {
4287                modules.push(canonical);
4288            }
4289        }
4290    }
4291
4292    modules
4293}
4294
4295fn extract_module_names_from_call_args(args: &[Node]) -> Vec<String> {
4296    fn collect_from_node(node: &Node, out: &mut Vec<String>) {
4297        match &node.kind {
4298            NodeKind::String { value, .. } => {
4299                out.extend(extract_module_names_from_use_args(std::slice::from_ref(value)));
4300            }
4301            NodeKind::Identifier { name } => {
4302                out.extend(extract_module_names_from_use_args(std::slice::from_ref(name)));
4303            }
4304            NodeKind::ArrayLiteral { elements } => {
4305                for element in elements {
4306                    collect_from_node(element, out);
4307                }
4308            }
4309            NodeKind::FunctionCall { name, args, .. } if name == "qw" => {
4310                for arg in args {
4311                    collect_from_node(arg, out);
4312                }
4313            }
4314            _ => {}
4315        }
4316    }
4317
4318    let mut modules = Vec::new();
4319    for arg in args {
4320        collect_from_node(arg, &mut modules);
4321    }
4322    modules
4323}
4324
4325fn canonicalize_perl_module_name(name: &str) -> String {
4326    // Perl supports the legacy `'` package separator (e.g. Foo'Bar).
4327    // Canonicalize to `::` so lookups and dependency matching share one key shape.
4328    name.replace('\'', "::")
4329}
4330
4331fn legacy_perl_module_name(name: &str) -> String {
4332    name.replace("::", "'")
4333}
4334
4335/// Normalize a module name for dependency storage and lookup.
4336/// Converts legacy `'` separators to `::` so stored keys are canonical.
4337fn normalize_dependency_module_name(module_name: &str) -> String {
4338    canonicalize_perl_module_name(module_name)
4339}
4340
4341fn extract_qw_words(input: &str) -> (Vec<String>, String) {
4342    let chars: Vec<char> = input.chars().collect();
4343    let mut i = 0;
4344    let mut words = Vec::new();
4345    let mut remainder = String::new();
4346
4347    while i < chars.len() {
4348        if chars[i] == 'q'
4349            && i + 1 < chars.len()
4350            && chars[i + 1] == 'w'
4351            && (i == 0 || !chars[i - 1].is_alphanumeric())
4352        {
4353            let mut j = i + 2;
4354            while j < chars.len() && chars[j].is_whitespace() {
4355                j += 1;
4356            }
4357            if j >= chars.len() {
4358                remainder.push(chars[i]);
4359                i += 1;
4360                continue;
4361            }
4362
4363            let open = chars[j];
4364            let (close, is_paired_delimiter) = match open {
4365                '(' => (')', true),
4366                '[' => (']', true),
4367                '{' => ('}', true),
4368                '<' => ('>', true),
4369                _ => (open, false),
4370            };
4371            if open.is_alphanumeric() || open == '_' || open == '\'' || open == '"' {
4372                remainder.push(chars[i]);
4373                i += 1;
4374                continue;
4375            }
4376
4377            let mut k = j + 1;
4378            if is_paired_delimiter {
4379                let mut depth = 1usize;
4380                while k < chars.len() && depth > 0 {
4381                    if chars[k] == open {
4382                        depth += 1;
4383                    } else if chars[k] == close {
4384                        depth -= 1;
4385                    }
4386                    k += 1;
4387                }
4388                if depth != 0 {
4389                    remainder.extend(chars[i..].iter());
4390                    break;
4391                }
4392                k -= 1;
4393            } else {
4394                while k < chars.len() && chars[k] != close {
4395                    k += 1;
4396                }
4397                if k >= chars.len() {
4398                    remainder.extend(chars[i..].iter());
4399                    break;
4400                }
4401            }
4402
4403            let content: String = chars[j + 1..k].iter().collect();
4404            for word in content.split_whitespace() {
4405                if !word.is_empty() {
4406                    words.push(word.to_string());
4407                }
4408            }
4409            i = k + 1;
4410            continue;
4411        }
4412
4413        remainder.push(chars[i]);
4414        i += 1;
4415    }
4416
4417    (words, remainder)
4418}
4419
4420fn extract_module_name_from_require_args(args: &[Node]) -> Option<String> {
4421    let first = args.first()?;
4422    match &first.kind {
4423        NodeKind::Identifier { name } => Some(name.clone()),
4424        NodeKind::String { value, .. } => {
4425            let cleaned = value.trim_matches('\'').trim_matches('"').trim();
4426            if cleaned.is_empty() {
4427                return None;
4428            }
4429            Some(cleaned.trim_end_matches(".pm").replace('/', "::"))
4430        }
4431        _ => None,
4432    }
4433}
4434
4435fn extract_manual_import_symbols(args: &[Node]) -> Vec<String> {
4436    fn push_if_bareword(out: &mut Vec<String>, token: &str) {
4437        let bare = token.trim().trim_matches('"').trim_matches('\'').trim();
4438        if bare.is_empty() || bare == "," {
4439            return;
4440        }
4441        let is_bareword = bare.bytes().all(|ch| ch.is_ascii_alphanumeric() || ch == b'_')
4442            && bare.as_bytes().first().is_some_and(|ch| ch.is_ascii_alphabetic() || *ch == b'_');
4443        if is_bareword {
4444            out.push(bare.to_string());
4445        }
4446    }
4447
4448    let mut symbols = Vec::new();
4449    for arg in args {
4450        match &arg.kind {
4451            NodeKind::String { value, .. } => push_if_bareword(&mut symbols, value),
4452            NodeKind::Identifier { name } => {
4453                if name.starts_with("qw") {
4454                    let content = name
4455                        .trim_start_matches("qw")
4456                        .trim_start_matches(|c: char| "([{/<|!".contains(c))
4457                        .trim_end_matches(|c: char| ")]}/|!>".contains(c));
4458                    for token in content.split_whitespace() {
4459                        push_if_bareword(&mut symbols, token);
4460                    }
4461                } else {
4462                    push_if_bareword(&mut symbols, name);
4463                }
4464            }
4465            NodeKind::ArrayLiteral { elements } => {
4466                for element in elements {
4467                    if let NodeKind::String { value, .. } = &element.kind {
4468                        push_if_bareword(&mut symbols, value);
4469                    }
4470                }
4471            }
4472            _ => {}
4473        }
4474    }
4475    symbols.sort();
4476    symbols.dedup();
4477    symbols
4478}
4479
4480/// Extract constant names from the `args` field of a `use constant` `NodeKind::Use` node.
4481///
4482/// The parser serialises `use constant` args in two distinct forms:
4483///
4484/// **Scalar form** — `use constant FOO => 42;`
4485///   → args: `["FOO", "42"]`  (the `=>` is consumed by the parser, not stored)
4486///   → The first arg is the constant name; remaining args are the value.
4487///
4488/// **Hash form** — `use constant { FOO => 1, BAR => 2 };`
4489///   → args: `["{", "FOO", "=>", "1", ",", "BAR", "=>", "2", "}"]`
4490///   → Identifiers immediately followed by `=>` are constant names.
4491///
4492/// **qw form** — `use constant qw(FOO BAR);`
4493///   → args: `["qw(FOO BAR)"]`
4494///   → Words inside the qw list are constant names.
4495///
4496/// Returns a deduplicated list of bare constant names (e.g. `["FOO", "BAR"]`).
4497#[cfg(test)]
4498fn extract_constant_names_from_use_args(args: &[String]) -> Vec<String> {
4499    use std::collections::HashSet;
4500
4501    fn push_unique(names: &mut Vec<String>, seen: &mut HashSet<String>, candidate: &str) {
4502        if seen.insert(candidate.to_string()) {
4503            names.push(candidate.to_string());
4504        }
4505    }
4506
4507    fn normalize_constant_name(token: &str) -> Option<&str> {
4508        let stripped = token.trim_matches(|c: char| {
4509            matches!(c, '\'' | '"' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ';')
4510        });
4511
4512        if stripped.is_empty() || stripped.starts_with('-') {
4513            return None;
4514        }
4515
4516        stripped.chars().all(|c| c.is_alphanumeric() || c == '_').then_some(stripped)
4517    }
4518
4519    let mut names = Vec::new();
4520    let mut seen = HashSet::new();
4521
4522    // Scalar form (most common): args = ["FOO", <value...>]
4523    // The first arg is a plain identifier with no `=>` in args at all.
4524    // Hash form starts with `{`; qw form starts with `qw`.
4525    let first = match args.first() {
4526        Some(f) => f.as_str(),
4527        None => return names,
4528    };
4529
4530    // qw form: single arg starting with "qw"
4531    if first.starts_with("qw") {
4532        let (qw_words, remainder) = extract_qw_words(first);
4533        if remainder.trim().is_empty() {
4534            for word in qw_words {
4535                if let Some(candidate) = normalize_constant_name(&word) {
4536                    push_unique(&mut names, &mut seen, candidate);
4537                }
4538            }
4539            return names;
4540        }
4541
4542        // Fallback for odd tokenisation: tolerate `qw` followed by spacing before the opener.
4543        let content = first.trim_start_matches("qw").trim_start();
4544        let content = content
4545            .trim_start_matches(|c: char| "([{/<|!".contains(c))
4546            .trim_end_matches(|c: char| ")]}/|!>".contains(c));
4547        for word in content.split_whitespace() {
4548            if let Some(candidate) = normalize_constant_name(word) {
4549                push_unique(&mut names, &mut seen, candidate);
4550            }
4551        }
4552        return names;
4553    }
4554
4555    // Hash form: args start with "{", "+{", or "+" followed by "{"
4556    let starts_hash_form = first == "{"
4557        || first == "+{"
4558        || (first == "+" && args.get(1).map(String::as_str) == Some("{"));
4559    if starts_hash_form {
4560        let mut skipped_leading_plus = false;
4561        let mut iter = args.iter().peekable();
4562        while let Some(arg) = iter.next() {
4563            // Some parser/tokenizer variants can emit "+{" as a single token for
4564            // `use constant +{ ... }`. Treat it as structural punctuation.
4565            if arg == "+{" {
4566                skipped_leading_plus = true;
4567                continue;
4568            }
4569            if arg == "+" && !skipped_leading_plus {
4570                skipped_leading_plus = true;
4571                continue;
4572            }
4573            if arg == "{" || arg == "}" || arg == "," || arg == "=>" {
4574                continue;
4575            }
4576            if let Some(candidate) = normalize_constant_name(arg)
4577                && iter.peek().map(|s| s.as_str()) == Some("=>")
4578            {
4579                push_unique(&mut names, &mut seen, candidate);
4580            }
4581        }
4582        return names;
4583    }
4584
4585    // Scalar form: first arg is the constant name (if it is a plain identifier)
4586    // Remaining args are the value and are skipped.
4587    if let Some(candidate) = normalize_constant_name(first) {
4588        push_unique(&mut names, &mut seen, candidate);
4589    }
4590
4591    names
4592}
4593
4594impl Default for WorkspaceIndex {
4595    fn default() -> Self {
4596        Self::new()
4597    }
4598}
4599
4600/// LSP adapter for converting internal Location types to LSP types
4601#[cfg(all(feature = "workspace", feature = "lsp-compat"))]
4602/// LSP adapter utilities for Navigate/Analyze workflows.
4603pub mod lsp_adapter {
4604    use super::Location as IxLocation;
4605    use lsp_types::Location as LspLocation;
4606    // lsp_types uses Uri, not Url
4607    type LspUrl = lsp_types::Uri;
4608
4609    /// Convert an internal location to an LSP Location for Navigate workflows.
4610    ///
4611    /// # Arguments
4612    ///
4613    /// * `ix` - Internal index location with URI and range information.
4614    ///
4615    /// # Returns
4616    ///
4617    /// `Some(LspLocation)` when conversion succeeds, or `None` if URI parsing fails.
4618    ///
4619    /// # Examples
4620    ///
4621    /// ```rust,ignore
4622    /// use perl_parser::workspace_index::{Location as IxLocation, lsp_adapter::to_lsp_location};
4623    /// use lsp_types::Range;
4624    ///
4625    /// let ix_loc = IxLocation { uri: "file:///path.pl".to_string(), range: Range::default() };
4626    /// let _ = to_lsp_location(&ix_loc);
4627    /// ```
4628    pub fn to_lsp_location(ix: &IxLocation) -> Option<LspLocation> {
4629        parse_url(&ix.uri).map(|uri| {
4630            let start =
4631                lsp_types::Position { line: ix.range.start.line, character: ix.range.start.column };
4632            let end =
4633                lsp_types::Position { line: ix.range.end.line, character: ix.range.end.column };
4634            let range = lsp_types::Range { start, end };
4635            LspLocation { uri, range }
4636        })
4637    }
4638
4639    /// Convert multiple index locations to LSP Locations for Navigate/Analyze workflows.
4640    ///
4641    /// # Arguments
4642    ///
4643    /// * `all` - Iterator of internal index locations to convert.
4644    ///
4645    /// # Returns
4646    ///
4647    /// Vector of successfully converted LSP locations, with invalid entries filtered out.
4648    ///
4649    /// # Examples
4650    ///
4651    /// ```rust,ignore
4652    /// use perl_parser::workspace_index::{Location as IxLocation, lsp_adapter::to_lsp_locations};
4653    /// use lsp_types::Range;
4654    ///
4655    /// let locations = vec![IxLocation { uri: "file:///script1.pl".to_string(), range: Range::default() }];
4656    /// let lsp_locations = to_lsp_locations(locations);
4657    /// assert_eq!(lsp_locations.len(), 1);
4658    /// ```
4659    pub fn to_lsp_locations(all: impl IntoIterator<Item = IxLocation>) -> Vec<LspLocation> {
4660        all.into_iter().filter_map(|ix| to_lsp_location(&ix)).collect()
4661    }
4662
4663    #[cfg(not(target_arch = "wasm32"))]
4664    fn parse_url(s: &str) -> Option<LspUrl> {
4665        // lsp_types::Uri uses FromStr, not TryFrom
4666        use std::str::FromStr;
4667
4668        // Try parsing as URI first
4669        LspUrl::from_str(s).ok().or_else(|| {
4670            // Try as a file path if URI parsing fails
4671            std::path::Path::new(s).canonicalize().ok().and_then(|p| {
4672                // Use proper URI construction with percent-encoding
4673                crate::workspace_index::fs_path_to_uri(&p)
4674                    .ok()
4675                    .and_then(|uri_string| LspUrl::from_str(&uri_string).ok())
4676            })
4677        })
4678    }
4679
4680    /// Parse a string as a URL (wasm32 version - no filesystem fallback)
4681    #[cfg(target_arch = "wasm32")]
4682    fn parse_url(s: &str) -> Option<LspUrl> {
4683        use std::str::FromStr;
4684        LspUrl::from_str(s).ok()
4685    }
4686}
4687
4688#[cfg(test)]
4689mod tests {
4690    use super::*;
4691    use perl_tdd_support::{must, must_some};
4692
4693    #[test]
4694    fn test_use_constant_indexed_as_constant_symbol() {
4695        let index = WorkspaceIndex::new();
4696        let uri = "file:///lib/My/Config.pm";
4697        let code = r#"package My::Config;
4698use constant PI => 3.14159;
4699use constant {
4700    MAX_RETRIES => 3,
4701    TIMEOUT     => 30,
4702};
47031;
4704"#;
4705        must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4706
4707        let symbols = index.file_symbols(uri);
4708        assert!(
4709            symbols.iter().any(|s| s.name == "PI" && s.kind == SymbolKind::Constant),
4710            "PI should be indexed as a Constant symbol; got: {:?}",
4711            symbols.iter().map(|s| (&s.name, &s.kind)).collect::<Vec<_>>()
4712        );
4713        assert!(
4714            symbols.iter().any(|s| s.name == "MAX_RETRIES" && s.kind == SymbolKind::Constant),
4715            "MAX_RETRIES should be indexed"
4716        );
4717        assert!(
4718            symbols.iter().any(|s| s.name == "TIMEOUT" && s.kind == SymbolKind::Constant),
4719            "TIMEOUT should be indexed"
4720        );
4721
4722        // Qualified lookup should also work
4723        let def = index.find_definition("My::Config::PI");
4724        assert!(def.is_some(), "find_definition('My::Config::PI') should succeed");
4725    }
4726
4727    #[test]
4728    fn test_extract_constant_names_deduplicates_qw_form() {
4729        let names = extract_constant_names_from_use_args(&["qw(FOO BAR FOO)".to_string()]);
4730        assert_eq!(names, vec!["FOO", "BAR"]);
4731    }
4732
4733    #[test]
4734    fn test_extract_constant_names_accepts_quoted_scalar_form() {
4735        let names = extract_constant_names_from_use_args(&[
4736            "'HTTP_OK'".to_string(),
4737            "=>".to_string(),
4738            "200".to_string(),
4739        ]);
4740        assert_eq!(names, vec!["HTTP_OK"]);
4741    }
4742
4743    #[test]
4744    fn search_symbols_returns_labeled_generated_framework_members()
4745    -> Result<(), Box<dyn std::error::Error>> {
4746        let index = WorkspaceIndex::new();
4747        let uri = "file:///lib/Generated/Pilot.pm";
4748        let code = r#"package Generated::Pilot;
4749use Moo;
4750has display_name => (is => 'rw');
47511;
4752"#;
4753        must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4754
4755        let source_symbols = index.search_source_symbols("display_name");
4756        assert!(
4757            source_symbols.is_empty(),
4758            "generated framework members must not enter the exact source-symbol slice"
4759        );
4760        let trimmed_source_symbols = index.search_source_symbols("  display_name  ");
4761        assert!(
4762            trimmed_source_symbols.is_empty(),
4763            "trimmed generated framework member queries must not enter the exact source-symbol slice"
4764        );
4765
4766        let generated_symbols = index.search_generated_workspace_symbols("display_name");
4767        assert_eq!(generated_symbols.len(), 1);
4768        let trimmed_generated_symbols =
4769            index.search_generated_workspace_symbols("  display_name  ");
4770        assert_eq!(trimmed_generated_symbols.len(), 1);
4771        assert_eq!(trimmed_generated_symbols[0].name, "display_name [generated/framework]");
4772        assert!(index.search_generated_workspace_symbols("   ").is_empty());
4773        let symbol = &generated_symbols[0];
4774        assert_eq!(symbol.name, "display_name [generated/framework]");
4775        assert_eq!(symbol.kind, SymbolKind::Method);
4776        assert_eq!(symbol.qualified_name.as_deref(), Some("Generated::Pilot::display_name"));
4777        assert_eq!(
4778            symbol.container_name.as_deref(),
4779            Some("Generated::Pilot [generated/framework]")
4780        );
4781        assert!(!symbol.has_body);
4782        assert_eq!(symbol.uri, uri);
4783        assert!(
4784            symbol.range.end.byte > symbol.range.start.byte,
4785            "generated symbol must be anchored to the source framework declaration"
4786        );
4787
4788        let live_symbols = index.search_symbols("display_name");
4789        assert!(
4790            live_symbols.is_empty(),
4791            "general workspace index search must stay source-backed; generated pilot symbols are opt-in"
4792        );
4793
4794        {
4795            let mut shards = index.fact_shards.write();
4796            let shard = shards.values_mut().next().ok_or("missing generated-member shard")?;
4797            let entity = shard
4798                .entities
4799                .iter_mut()
4800                .find(|entity| entity.canonical_name == "Generated::Pilot::display_name")
4801                .ok_or("missing generated member entity")?;
4802            entity.provenance = Provenance::ExactAst;
4803        }
4804        let non_framework_symbols = index.search_generated_workspace_symbols("display_name");
4805        assert!(
4806            non_framework_symbols.is_empty(),
4807            "generated workspace-symbol pilot must require framework-synthesis provenance"
4808        );
4809        Ok(())
4810    }
4811
4812    #[test]
4813    fn search_symbols_returns_labeled_predicate_generated_members()
4814    -> Result<(), Box<dyn std::error::Error>> {
4815        let index = WorkspaceIndex::new();
4816        let uri = "file:///lib/Generated/PredicatePilot.pm";
4817        let code = r#"package Generated::PredicatePilot;
4818use Moo;
4819has status => (is => 'rw', predicate => 1);
48201;
4821"#;
4822        must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4823
4824        let source_symbols = index.search_source_symbols("has_status");
4825        assert!(
4826            source_symbols.is_empty(),
4827            "predicate generated members must not enter the exact source-symbol slice"
4828        );
4829
4830        let generated_symbols = index.search_generated_workspace_symbols("has_status");
4831        assert_eq!(generated_symbols.len(), 1);
4832        let symbol = &generated_symbols[0];
4833        assert_eq!(symbol.name, "has_status [generated/framework]");
4834        assert_eq!(symbol.kind, SymbolKind::Method);
4835        assert_eq!(symbol.qualified_name.as_deref(), Some("Generated::PredicatePilot::has_status"));
4836        assert_eq!(
4837            symbol.container_name.as_deref(),
4838            Some("Generated::PredicatePilot [generated/framework]")
4839        );
4840        assert!(!symbol.has_body);
4841        assert_eq!(symbol.uri, uri);
4842        assert!(
4843            symbol.range.end.byte > symbol.range.start.byte,
4844            "predicate generated symbol must be anchored to the source framework declaration"
4845        );
4846
4847        let live_symbols = index.search_symbols("has_status");
4848        assert!(
4849            live_symbols.is_empty(),
4850            "general workspace index search must stay source-backed for predicate generated members"
4851        );
4852        Ok(())
4853    }
4854
4855    #[test]
4856    fn test_extract_constant_names_accepts_quoted_hash_form() {
4857        let names = extract_constant_names_from_use_args(&[
4858            "{".to_string(),
4859            "'FOO'".to_string(),
4860            "=>".to_string(),
4861            "1".to_string(),
4862            ",".to_string(),
4863            "\"BAR\"".to_string(),
4864            "=>".to_string(),
4865            "2".to_string(),
4866            "}".to_string(),
4867        ]);
4868        assert_eq!(names, vec!["FOO", "BAR"]);
4869    }
4870
4871    #[test]
4872    fn test_extract_constant_names_accepts_plus_hash_form_split_tokens() {
4873        let names = extract_constant_names_from_use_args(&[
4874            "+".to_string(),
4875            "{".to_string(),
4876            "FOO".to_string(),
4877            "=>".to_string(),
4878            "1".to_string(),
4879            ",".to_string(),
4880            "BAR".to_string(),
4881            "=>".to_string(),
4882            "2".to_string(),
4883            "}".to_string(),
4884        ]);
4885        assert_eq!(names, vec!["FOO", "BAR"]);
4886    }
4887
4888    #[test]
4889    fn test_extract_constant_names_accepts_plus_hash_form_combined_token() {
4890        let names = extract_constant_names_from_use_args(&[
4891            "+{".to_string(),
4892            "FOO".to_string(),
4893            "=>".to_string(),
4894            "1".to_string(),
4895            ",".to_string(),
4896            "BAR".to_string(),
4897            "=>".to_string(),
4898            "2".to_string(),
4899            "}".to_string(),
4900        ]);
4901        assert_eq!(names, vec!["FOO", "BAR"]);
4902    }
4903    #[test]
4904    fn test_use_constant_duplicate_names_indexed_once() {
4905        let index = WorkspaceIndex::new();
4906        let uri = "file:///lib/My/DedupConfig.pm";
4907        let code = r#"package My::DedupConfig;
4908use constant {
4909    RETRY_COUNT => 3,
4910    RETRY_COUNT => 5,
4911};
49121;
4913"#;
4914        must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4915
4916        let symbols = index.file_symbols(uri);
4917        let retry_count_symbols = symbols.iter().filter(|s| s.name == "RETRY_COUNT").count();
4918        assert_eq!(
4919            retry_count_symbols, 1,
4920            "RETRY_COUNT should be indexed once even when repeated in use constant hash form"
4921        );
4922    }
4923
4924    #[test]
4925    fn test_use_constant_plus_hash_form_indexes_keys() {
4926        let index = WorkspaceIndex::new();
4927        let uri = "file:///lib/My/PlusHash.pm";
4928        let code = r#"package My::PlusHash;
4929use constant +{
4930    FOO => 1,
4931    BAR => 2,
4932};
49331;
4934"#;
4935        must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4936
4937        assert!(index.find_definition("My::PlusHash::FOO").is_some());
4938        assert!(index.find_definition("My::PlusHash::BAR").is_some());
4939    }
4940
4941    #[test]
4942    fn test_basic_indexing() {
4943        let index = WorkspaceIndex::new();
4944        let uri = "file:///test.pl";
4945
4946        let code = r#"
4947package MyPackage;
4948
4949sub hello {
4950    print "Hello";
4951}
4952
4953my $var = 42;
4954"#;
4955
4956        must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4957
4958        // Should have indexed the package and subroutine
4959        let symbols = index.file_symbols(uri);
4960        assert!(symbols.iter().any(|s| s.name == "MyPackage" && s.kind == SymbolKind::Package));
4961        assert!(symbols.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
4962        assert!(symbols.iter().any(|s| s.name == "$var" && s.kind.is_variable()));
4963    }
4964
4965    #[test]
4966    fn test_package_symbol_has_no_container_name() {
4967        // Regression: project_symbol_declarations used to set container_name = Some("main")
4968        // for top-level package declarations because the IndexVisitor starts with
4969        // current_package = Some("main").  Package symbols are top-level declarations
4970        // and must have container_name = None.
4971        let index = WorkspaceIndex::new();
4972        let uri = "file:///lib/Foo.pm";
4973        let code = "package Foo;\nsub bar { }\n";
4974        must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4975
4976        let symbols = index.file_symbols(uri);
4977        let pkg_sym =
4978            must_some(symbols.iter().find(|s| s.name == "Foo" && s.kind == SymbolKind::Package));
4979        assert_eq!(
4980            pkg_sym.container_name, None,
4981            "Package symbol must not carry a container (was 'main')"
4982        );
4983    }
4984
4985    #[test]
4986    fn test_file_packages_returns_only_package_symbol_names() {
4987        let index = WorkspaceIndex::new();
4988        let uri = "file:///lib/OnlyPackages.pm";
4989        let code = "package Foo;\nsub hello { 1 }\npackage Bar { sub greet { 2 } }\n";
4990        must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
4991
4992        let mut package_names = index.file_packages(uri);
4993        package_names.sort();
4994        let mut expected_package_names: Vec<String> = index
4995            .file_symbols(uri)
4996            .into_iter()
4997            .filter(|s| s.kind == SymbolKind::Package)
4998            .map(|s| s.name)
4999            .collect();
5000        expected_package_names.sort();
5001
5002        assert_eq!(package_names, expected_package_names);
5003        assert_eq!(package_names, vec!["Bar", "Foo"]);
5004        assert!(!package_names.iter().any(|name| name == "hello"));
5005        assert!(!package_names.iter().any(|name| name == "greet"));
5006    }
5007
5008    #[test]
5009    fn test_file_package_symbols_returns_exact_container_match() {
5010        let index = WorkspaceIndex::new();
5011        let uri = "file:///lib/PackageMembers.pm";
5012        let code = "package Foo;\nsub hello { 1 }\npackage Bar;\nsub greet { 2 }\n";
5013        must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
5014
5015        let all_symbols = index.file_symbols(uri);
5016        let package_name = "Bar";
5017        let greet_symbol = must_some(all_symbols.iter().find(|s| s.name == "greet"));
5018        let bar_package = must_some(
5019            all_symbols.iter().find(|s| s.name == "Bar" && s.kind == SymbolKind::Package),
5020        );
5021        assert!(WorkspaceIndex::symbol_belongs_to_package(greet_symbol, package_name));
5022        assert!(!WorkspaceIndex::symbol_belongs_to_package(greet_symbol, "Foo"));
5023        assert!(!WorkspaceIndex::symbol_belongs_to_package(bar_package, package_name));
5024
5025        let mut expected_bar_names: Vec<String> = all_symbols
5026            .iter()
5027            .filter(|s| s.container_name.as_deref() == Some(package_name))
5028            .map(|s| s.name.clone())
5029            .collect();
5030        expected_bar_names.sort();
5031
5032        let mut bar_names: Vec<String> =
5033            index.file_package_symbols(uri, package_name).into_iter().map(|s| s.name).collect();
5034        bar_names.sort();
5035        assert_eq!(bar_names, expected_bar_names);
5036        assert_eq!(bar_names, vec!["greet"]);
5037
5038        let mut foo_names: Vec<String> =
5039            index.file_package_symbols(uri, "Foo").into_iter().map(|s| s.name).collect();
5040        foo_names.sort();
5041        assert_eq!(foo_names, vec!["hello"]);
5042        assert!(index.file_package_symbols(uri, "Missing").is_empty());
5043    }
5044
5045    #[test]
5046    fn test_my_variable_has_no_qualified_name() {
5047        // Regression: project_symbol_declarations used to set qualified_name = Some("Foo::x")
5048        // for `my $x` inside `package Foo`, making `find_definition("Foo::x")` return the
5049        // lexical variable.  `my` variables are not package-visible and must have
5050        // qualified_name = None so qualified lookups don't match them.
5051        let index = WorkspaceIndex::new();
5052        let uri = "file:///lib/Foo.pm";
5053        let code = "package Foo;\nsub bar { my $x = 1; }\n";
5054        must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
5055
5056        let symbols = index.file_symbols(uri);
5057        let var_sym = must_some(symbols.iter().find(|s| s.name == "$x" && s.kind.is_variable()));
5058        assert_eq!(var_sym.qualified_name, None, "my variable must not have a qualified_name");
5059
5060        // `find_definition("Foo::x")` must not accidentally resolve to a lexical variable.
5061        assert!(
5062            index.find_definition("Foo::x").is_none(),
5063            "find_definition(\"Foo::x\") must not return a lexical my variable"
5064        );
5065    }
5066
5067    fn reference_kinds_for(
5068        index: &WorkspaceIndex,
5069        uri: &str,
5070        symbol_name: &str,
5071    ) -> Vec<ReferenceKind> {
5072        let files = index.files.read();
5073        let file = must_some(files.get(uri));
5074        file.references
5075            .get(symbol_name)
5076            .map(|refs| refs.iter().map(|r| r.kind).collect())
5077            .unwrap_or_default()
5078    }
5079
5080    #[test]
5081    fn test_reference_kinds_sub_definition_and_call_are_distinct() {
5082        let index = WorkspaceIndex::new();
5083        let uri = "file:///typed-refs-sub.pl";
5084        let code = "package TypedRefs;
5085sub foo { return 1; }
5086foo();
5087";
5088        must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
5089
5090        let kinds = reference_kinds_for(&index, uri, "foo");
5091        assert!(kinds.contains(&ReferenceKind::Definition));
5092        assert!(kinds.contains(&ReferenceKind::Usage));
5093    }
5094
5095    #[test]
5096    fn test_reference_kinds_variable_read_and_write_are_distinct() {
5097        let index = WorkspaceIndex::new();
5098        let uri = "file:///typed-refs-var.pl";
5099        let code = "my $value = 1;
5100$value = 2;
5101print $value;
5102";
5103        must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
5104
5105        let kinds = reference_kinds_for(&index, uri, "$value");
5106        assert!(kinds.contains(&ReferenceKind::Definition));
5107        assert!(kinds.contains(&ReferenceKind::Write));
5108        assert!(kinds.contains(&ReferenceKind::Read));
5109    }
5110
5111    #[test]
5112    fn test_reference_kinds_import_parent_and_export_ok_are_currently_import_only() {
5113        let index = WorkspaceIndex::new();
5114        let uri = "file:///typed-refs-import-export.pm";
5115        let code = "package Child;
5116use parent 'Base';
5117our @EXPORT_OK = qw(foo);
51181;
5119";
5120        must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
5121
5122        let parent_kinds = reference_kinds_for(&index, uri, "Base");
5123        assert!(
5124            parent_kinds.is_empty(),
5125            "use parent inheritance edges are currently not stored as typed references"
5126        );
5127
5128        let export_symbol_kinds = reference_kinds_for(&index, uri, "foo");
5129        assert!(
5130            export_symbol_kinds.is_empty(),
5131            "EXPORT_OK entries are currently not represented as reference edges"
5132        );
5133    }
5134
5135    #[test]
5136    fn test_reference_kinds_dynamic_and_meta_edges_are_not_typed_yet() {
5137        let index = WorkspaceIndex::new();
5138        let uri = "file:///typed-refs-dynamic.pl";
5139        let code = r#"package TypedRefs;
5140sub foo { 1 }
5141&foo;
5142my $code = \&foo;
5143goto &foo;
5144*alias = \&foo;
5145eval "foo()";
5146with 'RoleName';
5147has 'name' => (is => 'ro');
51481;
5149"#;
5150        must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
5151
5152        let foo_kinds = reference_kinds_for(&index, uri, "foo");
5153        assert!(
5154            foo_kinds
5155                .iter()
5156                .all(|kind| matches!(kind, ReferenceKind::Definition | ReferenceKind::Usage)),
5157            r"dynamic call forms (&foo, \&foo, goto &foo) are currently flattened to Usage"
5158        );
5159
5160        assert!(
5161            reference_kinds_for(&index, uri, "RoleName").is_empty(),
5162            "role composition edges (`with 'RoleName'`) are not indexed as typed references yet"
5163        );
5164    }
5165
5166    #[test]
5167    fn test_find_references() {
5168        let index = WorkspaceIndex::new();
5169        let uri = "file:///test.pl";
5170
5171        let code = r#"
5172sub test {
5173    my $x = 1;
5174    $x = 2;
5175    print $x;
5176}
5177"#;
5178
5179        must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
5180
5181        let refs = index.find_references("$x");
5182        assert!(refs.len() >= 2); // Definition + at least one usage
5183    }
5184
5185    #[test]
5186    fn test_find_references_bare_name_includes_qualified_calls() {
5187        let index = WorkspaceIndex::new();
5188        let uri = "file:///refs.pl";
5189        let code = r#"
5190package RefDemo;
5191sub helper {
5192    return 1;
5193}
5194
5195helper();
5196RefDemo::helper();
5197"#;
5198
5199        must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
5200
5201        let bare_refs = index.find_references("helper");
5202        let qualified_refs = index.find_references("RefDemo::helper");
5203
5204        assert!(
5205            bare_refs.len() >= qualified_refs.len(),
5206            "bare-name reference lookup should include qualified calls"
5207        );
5208    }
5209
5210    #[test]
5211    fn test_count_usages_bare_name_includes_qualified_calls() {
5212        let index = WorkspaceIndex::new();
5213        let uri = "file:///usage.pl";
5214        let code = r#"
5215package UsageDemo;
5216sub helper {
5217    return 1;
5218}
5219
5220helper();
5221UsageDemo::helper();
5222"#;
5223
5224        must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
5225
5226        let bare_usage_count = index.count_usages("helper");
5227        let qualified_usage_count = index.count_usages("UsageDemo::helper");
5228
5229        assert!(
5230            bare_usage_count >= qualified_usage_count,
5231            "bare-name usage count should include qualified call sites"
5232        );
5233    }
5234
5235    #[test]
5236    fn test_dependencies() {
5237        let index = WorkspaceIndex::new();
5238        let uri = "file:///test.pl";
5239
5240        let code = r#"
5241use strict;
5242use warnings;
5243use Data::Dumper;
5244"#;
5245
5246        must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
5247
5248        let deps = index.file_dependencies(uri);
5249        assert!(deps.contains("strict"));
5250        assert!(deps.contains("warnings"));
5251        assert!(deps.contains("Data::Dumper"));
5252    }
5253
5254    #[test]
5255    fn test_uri_to_fs_path_basic() {
5256        // Test basic file:// URI conversion
5257        if let Some(path) = uri_to_fs_path("file:///tmp/test.pl") {
5258            assert_eq!(path, std::path::PathBuf::from("/tmp/test.pl"));
5259        }
5260
5261        // Test with invalid URI
5262        assert!(uri_to_fs_path("not-a-uri").is_none());
5263
5264        // Test with non-file scheme
5265        assert!(uri_to_fs_path("http://example.com").is_none());
5266    }
5267
5268    #[test]
5269    fn test_uri_to_fs_path_with_spaces() {
5270        // Test with percent-encoded spaces
5271        if let Some(path) = uri_to_fs_path("file:///tmp/path%20with%20spaces/test.pl") {
5272            assert_eq!(path, std::path::PathBuf::from("/tmp/path with spaces/test.pl"));
5273        }
5274
5275        // Test with multiple spaces and special characters
5276        if let Some(path) = uri_to_fs_path("file:///tmp/My%20Documents/test%20file.pl") {
5277            assert_eq!(path, std::path::PathBuf::from("/tmp/My Documents/test file.pl"));
5278        }
5279    }
5280
5281    #[test]
5282    fn test_uri_to_fs_path_with_unicode() {
5283        // Test with Unicode characters (percent-encoded)
5284        if let Some(path) = uri_to_fs_path("file:///tmp/caf%C3%A9/test.pl") {
5285            assert_eq!(path, std::path::PathBuf::from("/tmp/café/test.pl"));
5286        }
5287
5288        // Test with Unicode emoji (percent-encoded)
5289        if let Some(path) = uri_to_fs_path("file:///tmp/emoji%F0%9F%98%80/test.pl") {
5290            assert_eq!(path, std::path::PathBuf::from("/tmp/emoji😀/test.pl"));
5291        }
5292    }
5293
5294    #[test]
5295    fn test_fs_path_to_uri_basic() {
5296        // Test basic path to URI conversion
5297        let result = fs_path_to_uri("/tmp/test.pl");
5298        assert!(result.is_ok());
5299        let uri = must(result);
5300        assert!(uri.starts_with("file://"));
5301        assert!(uri.contains("/tmp/test.pl"));
5302    }
5303
5304    #[test]
5305    fn test_fs_path_to_uri_with_spaces() {
5306        // Test path with spaces
5307        let result = fs_path_to_uri("/tmp/path with spaces/test.pl");
5308        assert!(result.is_ok());
5309        let uri = must(result);
5310        assert!(uri.starts_with("file://"));
5311        // Should contain percent-encoded spaces
5312        assert!(uri.contains("path%20with%20spaces"));
5313    }
5314
5315    #[test]
5316    fn test_fs_path_to_uri_with_unicode() {
5317        // Test path with Unicode characters
5318        let result = fs_path_to_uri("/tmp/café/test.pl");
5319        assert!(result.is_ok());
5320        let uri = must(result);
5321        assert!(uri.starts_with("file://"));
5322        // Should contain percent-encoded Unicode
5323        assert!(uri.contains("caf%C3%A9"));
5324    }
5325
5326    #[test]
5327    fn test_normalize_uri_file_schemes() {
5328        // Test normalization of valid file URIs
5329        let uri = WorkspaceIndex::normalize_uri("file:///tmp/test.pl");
5330        assert_eq!(uri, "file:///tmp/test.pl");
5331
5332        // Test normalization of URIs with spaces
5333        let uri = WorkspaceIndex::normalize_uri("file:///tmp/path%20with%20spaces/test.pl");
5334        assert_eq!(uri, "file:///tmp/path%20with%20spaces/test.pl");
5335    }
5336
5337    #[test]
5338    fn test_normalize_uri_absolute_paths() {
5339        // Test normalization of absolute paths (convert to file:// URI)
5340        let uri = WorkspaceIndex::normalize_uri("/tmp/test.pl");
5341        assert!(uri.starts_with("file://"));
5342        assert!(uri.contains("/tmp/test.pl"));
5343    }
5344
5345    #[test]
5346    fn test_normalize_uri_special_schemes() {
5347        // Test that special schemes like untitled: are preserved
5348        let uri = WorkspaceIndex::normalize_uri("untitled:Untitled-1");
5349        assert_eq!(uri, "untitled:Untitled-1");
5350    }
5351
5352    #[test]
5353    fn test_roundtrip_conversion() {
5354        // Test that URI -> path -> URI conversion preserves the URI
5355        let original_uri = "file:///tmp/path%20with%20spaces/caf%C3%A9.pl";
5356
5357        if let Some(path) = uri_to_fs_path(original_uri) {
5358            if let Ok(converted_uri) = fs_path_to_uri(&path) {
5359                // Should be able to round-trip back to an equivalent URI
5360                assert!(converted_uri.starts_with("file://"));
5361
5362                // The path component should decode correctly
5363                if let Some(roundtrip_path) = uri_to_fs_path(&converted_uri) {
5364                    #[cfg(windows)]
5365                    if let Ok(rootless) = path.strip_prefix(std::path::Path::new(r"\")) {
5366                        assert!(roundtrip_path.ends_with(rootless));
5367                    } else {
5368                        assert_eq!(path, roundtrip_path);
5369                    }
5370
5371                    #[cfg(not(windows))]
5372                    assert_eq!(path, roundtrip_path);
5373                }
5374            }
5375        }
5376    }
5377
5378    #[cfg(target_os = "windows")]
5379    #[test]
5380    fn test_windows_paths() {
5381        // Test Windows-style paths
5382        let result = fs_path_to_uri(r"C:\Users\test\Documents\script.pl");
5383        assert!(result.is_ok());
5384        let uri = must(result);
5385        assert!(uri.starts_with("file://"));
5386
5387        // Test Windows path with spaces
5388        let result = fs_path_to_uri(r"C:\Program Files\My App\script.pl");
5389        assert!(result.is_ok());
5390        let uri = must(result);
5391        assert!(uri.starts_with("file://"));
5392        assert!(uri.contains("Program%20Files"));
5393    }
5394
5395    // ========================================================================
5396    // IndexCoordinator Tests
5397    // ========================================================================
5398
5399    #[test]
5400    fn test_coordinator_initial_state() {
5401        let coordinator = IndexCoordinator::new();
5402        assert!(matches!(
5403            coordinator.state(),
5404            IndexState::Building { phase: IndexPhase::Idle, .. }
5405        ));
5406    }
5407
5408    #[test]
5409    fn test_transition_to_scanning_phase() {
5410        let coordinator = IndexCoordinator::new();
5411        coordinator.transition_to_scanning();
5412
5413        let state = coordinator.state();
5414        assert!(
5415            matches!(state, IndexState::Building { phase: IndexPhase::Scanning, .. }),
5416            "Expected Building state after scanning, got: {:?}",
5417            state
5418        );
5419    }
5420
5421    #[test]
5422    fn test_transition_to_indexing_phase() {
5423        let coordinator = IndexCoordinator::new();
5424        coordinator.transition_to_scanning();
5425        coordinator.update_scan_progress(3);
5426        coordinator.transition_to_indexing(3);
5427
5428        let state = coordinator.state();
5429        assert!(
5430            matches!(
5431                state,
5432                IndexState::Building { phase: IndexPhase::Indexing, total_count: 3, .. }
5433            ),
5434            "Expected Building state after indexing with total_count 3, got: {:?}",
5435            state
5436        );
5437    }
5438
5439    #[test]
5440    fn test_transition_to_ready() {
5441        let coordinator = IndexCoordinator::new();
5442        coordinator.transition_to_ready(100, 5000);
5443
5444        let state = coordinator.state();
5445        if let IndexState::Ready { file_count, symbol_count, .. } = state {
5446            assert_eq!(file_count, 100);
5447            assert_eq!(symbol_count, 5000);
5448        } else {
5449            unreachable!("Expected Ready state, got: {:?}", state);
5450        }
5451    }
5452
5453    #[test]
5454    fn test_parse_storm_degradation() {
5455        let coordinator = IndexCoordinator::new();
5456        coordinator.transition_to_ready(100, 5000);
5457
5458        // Trigger parse storm
5459        for _ in 0..15 {
5460            coordinator.notify_change("file.pm");
5461        }
5462
5463        let state = coordinator.state();
5464        assert!(
5465            matches!(state, IndexState::Degraded { .. }),
5466            "Expected Degraded state, got: {:?}",
5467            state
5468        );
5469        if let IndexState::Degraded { reason, .. } = state {
5470            assert!(matches!(reason, DegradationReason::ParseStorm { .. }));
5471        }
5472    }
5473
5474    #[test]
5475    fn test_recovery_from_parse_storm() {
5476        let coordinator = IndexCoordinator::new();
5477        coordinator.transition_to_ready(100, 5000);
5478
5479        // Trigger parse storm
5480        for _ in 0..15 {
5481            coordinator.notify_change("file.pm");
5482        }
5483
5484        // Complete all parses
5485        for _ in 0..15 {
5486            coordinator.notify_parse_complete("file.pm");
5487        }
5488
5489        // Should recover to Building state
5490        assert!(matches!(coordinator.state(), IndexState::Building { .. }));
5491    }
5492
5493    #[test]
5494    fn test_query_dispatch_ready() {
5495        let coordinator = IndexCoordinator::new();
5496        coordinator.transition_to_ready(100, 5000);
5497
5498        let result = coordinator.query(|_index| "full_query", |_index| "partial_query");
5499
5500        assert_eq!(result, "full_query");
5501    }
5502
5503    #[test]
5504    fn test_query_dispatch_degraded() {
5505        let coordinator = IndexCoordinator::new();
5506        // Building state should use partial query
5507
5508        let result = coordinator.query(|_index| "full_query", |_index| "partial_query");
5509
5510        assert_eq!(result, "partial_query");
5511    }
5512
5513    #[test]
5514    fn test_metrics_pending_count() {
5515        let coordinator = IndexCoordinator::new();
5516
5517        coordinator.notify_change("file1.pm");
5518        coordinator.notify_change("file2.pm");
5519
5520        assert_eq!(coordinator.metrics.pending_count(), 2);
5521
5522        coordinator.notify_parse_complete("file1.pm");
5523        assert_eq!(coordinator.metrics.pending_count(), 1);
5524    }
5525
5526    #[test]
5527    fn test_instrumentation_records_transitions() {
5528        let coordinator = IndexCoordinator::new();
5529        coordinator.transition_to_ready(10, 100);
5530
5531        let snapshot = coordinator.instrumentation_snapshot();
5532        let transition =
5533            IndexStateTransition { from: IndexStateKind::Building, to: IndexStateKind::Ready };
5534        let count = snapshot.state_transition_counts.get(&transition).copied().unwrap_or(0);
5535        assert_eq!(count, 1);
5536    }
5537
5538    #[test]
5539    fn test_instrumentation_records_early_exit() {
5540        let coordinator = IndexCoordinator::new();
5541        coordinator.record_early_exit(EarlyExitReason::InitialTimeBudget, 25, 1, 10);
5542
5543        let snapshot = coordinator.instrumentation_snapshot();
5544        let count = snapshot
5545            .early_exit_counts
5546            .get(&EarlyExitReason::InitialTimeBudget)
5547            .copied()
5548            .unwrap_or(0);
5549        assert_eq!(count, 1);
5550        assert!(snapshot.last_early_exit.is_some());
5551    }
5552
5553    #[test]
5554    fn test_custom_limits() {
5555        let limits = IndexResourceLimits {
5556            max_files: 5000,
5557            max_symbols_per_file: 1000,
5558            max_total_symbols: 100_000,
5559            max_ast_cache_bytes: 128 * 1024 * 1024,
5560            max_ast_cache_items: 50,
5561            max_scan_duration_ms: 30_000,
5562        };
5563
5564        let coordinator = IndexCoordinator::with_limits(limits.clone());
5565        assert_eq!(coordinator.limits.max_files, 5000);
5566        assert_eq!(coordinator.limits.max_total_symbols, 100_000);
5567    }
5568
5569    #[test]
5570    fn test_degradation_preserves_symbol_count() {
5571        let coordinator = IndexCoordinator::new();
5572        coordinator.transition_to_ready(100, 5000);
5573
5574        coordinator.transition_to_degraded(DegradationReason::IoError {
5575            message: "Test error".to_string(),
5576        });
5577
5578        let state = coordinator.state();
5579        assert!(
5580            matches!(state, IndexState::Degraded { .. }),
5581            "Expected Degraded state, got: {:?}",
5582            state
5583        );
5584        if let IndexState::Degraded { available_symbols, .. } = state {
5585            assert_eq!(available_symbols, 5000);
5586        }
5587    }
5588
5589    #[test]
5590    fn test_index_access() {
5591        let coordinator = IndexCoordinator::new();
5592        let index = coordinator.index();
5593
5594        // Should have access to underlying WorkspaceIndex
5595        assert!(index.all_symbols().is_empty());
5596    }
5597
5598    #[test]
5599    fn test_resource_limit_enforcement_max_files() {
5600        let limits = IndexResourceLimits {
5601            max_files: 5,
5602            max_symbols_per_file: 1000,
5603            max_total_symbols: 50_000,
5604            max_ast_cache_bytes: 128 * 1024 * 1024,
5605            max_ast_cache_items: 50,
5606            max_scan_duration_ms: 30_000,
5607        };
5608
5609        let coordinator = IndexCoordinator::with_limits(limits);
5610        coordinator.transition_to_ready(10, 100);
5611
5612        // Index 10 files (exceeds limit of 5)
5613        for i in 0..10 {
5614            let uri_str = format!("file:///test{}.pl", i);
5615            let uri = must(url::Url::parse(&uri_str));
5616            let code = "sub test { }";
5617            must(coordinator.index().index_file(uri, code.to_string()));
5618        }
5619
5620        // Enforce limits
5621        coordinator.enforce_limits();
5622
5623        let state = coordinator.state();
5624        assert!(
5625            matches!(
5626                state,
5627                IndexState::Degraded {
5628                    reason: DegradationReason::ResourceLimit { kind: ResourceKind::MaxFiles },
5629                    ..
5630                }
5631            ),
5632            "Expected Degraded state with ResourceLimit(MaxFiles), got: {:?}",
5633            state
5634        );
5635    }
5636
5637    #[test]
5638    fn test_resource_limit_enforcement_max_symbols() {
5639        let limits = IndexResourceLimits {
5640            max_files: 100,
5641            max_symbols_per_file: 10,
5642            max_total_symbols: 50, // Very low limit for testing
5643            max_ast_cache_bytes: 128 * 1024 * 1024,
5644            max_ast_cache_items: 50,
5645            max_scan_duration_ms: 30_000,
5646        };
5647
5648        let coordinator = IndexCoordinator::with_limits(limits);
5649        coordinator.transition_to_ready(0, 0);
5650
5651        // Index files with many symbols to exceed total symbol limit
5652        for i in 0..10 {
5653            let uri_str = format!("file:///test{}.pl", i);
5654            let uri = must(url::Url::parse(&uri_str));
5655            // Each file has 10 subroutines = 100 total symbols (exceeds limit of 50)
5656            let code = r#"
5657package Test;
5658sub sub1 { }
5659sub sub2 { }
5660sub sub3 { }
5661sub sub4 { }
5662sub sub5 { }
5663sub sub6 { }
5664sub sub7 { }
5665sub sub8 { }
5666sub sub9 { }
5667sub sub10 { }
5668"#;
5669            must(coordinator.index().index_file(uri, code.to_string()));
5670        }
5671
5672        // Enforce limits
5673        coordinator.enforce_limits();
5674
5675        let state = coordinator.state();
5676        assert!(
5677            matches!(
5678                state,
5679                IndexState::Degraded {
5680                    reason: DegradationReason::ResourceLimit { kind: ResourceKind::MaxSymbols },
5681                    ..
5682                }
5683            ),
5684            "Expected Degraded state with ResourceLimit(MaxSymbols), got: {:?}",
5685            state
5686        );
5687    }
5688
5689    #[test]
5690    fn test_check_limits_returns_none_within_bounds() {
5691        let coordinator = IndexCoordinator::new();
5692        coordinator.transition_to_ready(0, 0);
5693
5694        // Index a few files well within default limits
5695        for i in 0..5 {
5696            let uri_str = format!("file:///test{}.pl", i);
5697            let uri = must(url::Url::parse(&uri_str));
5698            let code = "sub test { }";
5699            must(coordinator.index().index_file(uri, code.to_string()));
5700        }
5701
5702        // Should not trigger degradation
5703        let limit_check = coordinator.check_limits();
5704        assert!(limit_check.is_none(), "check_limits should return None when within bounds");
5705
5706        // State should still be Ready
5707        assert!(
5708            matches!(coordinator.state(), IndexState::Ready { .. }),
5709            "State should remain Ready when within limits"
5710        );
5711    }
5712
5713    #[test]
5714    fn test_enforce_limits_called_on_transition_to_ready() {
5715        let limits = IndexResourceLimits {
5716            max_files: 3,
5717            max_symbols_per_file: 1000,
5718            max_total_symbols: 50_000,
5719            max_ast_cache_bytes: 128 * 1024 * 1024,
5720            max_ast_cache_items: 50,
5721            max_scan_duration_ms: 30_000,
5722        };
5723
5724        let coordinator = IndexCoordinator::with_limits(limits);
5725
5726        // Index files before transitioning to ready
5727        for i in 0..5 {
5728            let uri_str = format!("file:///test{}.pl", i);
5729            let uri = must(url::Url::parse(&uri_str));
5730            let code = "sub test { }";
5731            must(coordinator.index().index_file(uri, code.to_string()));
5732        }
5733
5734        // Transition to ready - should automatically enforce limits
5735        coordinator.transition_to_ready(5, 100);
5736
5737        let state = coordinator.state();
5738        assert!(
5739            matches!(
5740                state,
5741                IndexState::Degraded {
5742                    reason: DegradationReason::ResourceLimit { kind: ResourceKind::MaxFiles },
5743                    ..
5744                }
5745            ),
5746            "Expected Degraded state after transition_to_ready with exceeded limits, got: {:?}",
5747            state
5748        );
5749    }
5750
5751    #[test]
5752    fn test_state_transition_guard_ready_to_ready() {
5753        // Test that Ready → Ready is allowed (metrics update)
5754        let coordinator = IndexCoordinator::new();
5755        coordinator.transition_to_ready(100, 5000);
5756
5757        // Transition to Ready again with different metrics
5758        coordinator.transition_to_ready(150, 7500);
5759
5760        let state = coordinator.state();
5761        assert!(
5762            matches!(state, IndexState::Ready { file_count: 150, symbol_count: 7500, .. }),
5763            "Expected Ready state with updated metrics, got: {:?}",
5764            state
5765        );
5766    }
5767
5768    #[test]
5769    fn test_state_transition_guard_building_to_building() {
5770        // Test that Building → Building is allowed (progress update)
5771        let coordinator = IndexCoordinator::new();
5772
5773        // Initial building state
5774        coordinator.transition_to_building(100);
5775
5776        let state = coordinator.state();
5777        assert!(
5778            matches!(state, IndexState::Building { indexed_count: 0, total_count: 100, .. }),
5779            "Expected Building state, got: {:?}",
5780            state
5781        );
5782
5783        // Update total count
5784        coordinator.transition_to_building(200);
5785
5786        let state = coordinator.state();
5787        assert!(
5788            matches!(state, IndexState::Building { indexed_count: 0, total_count: 200, .. }),
5789            "Expected Building state, got: {:?}",
5790            state
5791        );
5792    }
5793
5794    #[test]
5795    fn test_state_transition_ready_to_building() {
5796        // Test that Ready → Building is allowed (re-scan)
5797        let coordinator = IndexCoordinator::new();
5798        coordinator.transition_to_ready(100, 5000);
5799
5800        // Trigger re-scan
5801        coordinator.transition_to_building(150);
5802
5803        let state = coordinator.state();
5804        assert!(
5805            matches!(state, IndexState::Building { indexed_count: 0, total_count: 150, .. }),
5806            "Expected Building state after re-scan, got: {:?}",
5807            state
5808        );
5809    }
5810
5811    #[test]
5812    fn test_state_transition_degraded_to_building() {
5813        // Test that Degraded → Building is allowed (recovery)
5814        let coordinator = IndexCoordinator::new();
5815        coordinator.transition_to_degraded(DegradationReason::IoError {
5816            message: "Test error".to_string(),
5817        });
5818
5819        // Attempt recovery
5820        coordinator.transition_to_building(100);
5821
5822        let state = coordinator.state();
5823        assert!(
5824            matches!(state, IndexState::Building { indexed_count: 0, total_count: 100, .. }),
5825            "Expected Building state after recovery, got: {:?}",
5826            state
5827        );
5828    }
5829
5830    #[test]
5831    fn test_update_building_progress() {
5832        let coordinator = IndexCoordinator::new();
5833        coordinator.transition_to_building(100);
5834
5835        // Update progress
5836        coordinator.update_building_progress(50);
5837
5838        let state = coordinator.state();
5839        assert!(
5840            matches!(state, IndexState::Building { indexed_count: 50, total_count: 100, .. }),
5841            "Expected Building state with updated progress, got: {:?}",
5842            state
5843        );
5844
5845        // Update progress again
5846        coordinator.update_building_progress(100);
5847
5848        let state = coordinator.state();
5849        assert!(
5850            matches!(state, IndexState::Building { indexed_count: 100, total_count: 100, .. }),
5851            "Expected Building state with completed progress, got: {:?}",
5852            state
5853        );
5854    }
5855
5856    #[test]
5857    fn test_scan_timeout_detection() {
5858        // Test that scan timeout triggers degradation
5859        let limits = IndexResourceLimits {
5860            max_scan_duration_ms: 0, // Immediate timeout for testing
5861            ..Default::default()
5862        };
5863
5864        let coordinator = IndexCoordinator::with_limits(limits);
5865        coordinator.transition_to_building(100);
5866
5867        // Small sleep to ensure elapsed time > 0
5868        std::thread::sleep(std::time::Duration::from_millis(1));
5869
5870        // Update progress should detect timeout
5871        coordinator.update_building_progress(10);
5872
5873        let state = coordinator.state();
5874        assert!(
5875            matches!(
5876                state,
5877                IndexState::Degraded { reason: DegradationReason::ScanTimeout { .. }, .. }
5878            ),
5879            "Expected Degraded state with ScanTimeout, got: {:?}",
5880            state
5881        );
5882    }
5883
5884    #[test]
5885    fn test_scan_timeout_does_not_trigger_within_limit() {
5886        // Test that scan doesn't timeout within the limit
5887        let limits = IndexResourceLimits {
5888            max_scan_duration_ms: 10_000, // 10 seconds - should not trigger
5889            ..Default::default()
5890        };
5891
5892        let coordinator = IndexCoordinator::with_limits(limits);
5893        coordinator.transition_to_building(100);
5894
5895        // Update progress immediately (well within limit)
5896        coordinator.update_building_progress(50);
5897
5898        let state = coordinator.state();
5899        assert!(
5900            matches!(state, IndexState::Building { indexed_count: 50, .. }),
5901            "Expected Building state (no timeout), got: {:?}",
5902            state
5903        );
5904    }
5905
5906    #[test]
5907    fn test_early_exit_optimization_unchanged_content() {
5908        let index = WorkspaceIndex::new();
5909        let uri = must(url::Url::parse("file:///test.pl"));
5910        let code = r#"
5911package MyPackage;
5912
5913sub hello {
5914    print "Hello";
5915}
5916"#;
5917
5918        // First indexing should parse and index
5919        must(index.index_file(uri.clone(), code.to_string()));
5920        let symbols1 = index.file_symbols(uri.as_str());
5921        assert!(symbols1.iter().any(|s| s.name == "MyPackage" && s.kind == SymbolKind::Package));
5922        assert!(symbols1.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
5923
5924        // Second indexing with same content should early-exit
5925        // We can verify this by checking that the index still works correctly
5926        must(index.index_file(uri.clone(), code.to_string()));
5927        let symbols2 = index.file_symbols(uri.as_str());
5928        assert_eq!(symbols1.len(), symbols2.len());
5929        assert!(symbols2.iter().any(|s| s.name == "MyPackage" && s.kind == SymbolKind::Package));
5930        assert!(symbols2.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
5931    }
5932
5933    #[test]
5934    fn test_index_file_generation_updates_on_same_content_reindex() {
5935        let index = WorkspaceIndex::new();
5936        let uri = must(url::Url::parse("file:///generation.pl"));
5937        let code = "package Generation;\nsub stable { 1 }\n1;\n";
5938
5939        must(index.index_file_with_generation(uri.clone(), code.to_string(), 1));
5940        assert_eq!(index.indexed_generation(uri.as_str()), Some(1));
5941        assert!(!index.is_index_generation_stale(uri.as_str(), 1));
5942        assert!(index.is_index_generation_stale(uri.as_str(), 2));
5943
5944        must(index.index_file_with_generation(uri.clone(), code.to_string(), 2));
5945        assert_eq!(index.indexed_generation(uri.as_str()), Some(2));
5946        assert!(!index.is_index_generation_stale(uri.as_str(), 2));
5947    }
5948
5949    #[test]
5950    fn is_index_generation_stale_boundary_discriminator_indexed_generation_less_than_expected_generation()
5951     {
5952        let index = WorkspaceIndex::new();
5953        let uri = must(url::Url::parse("file:///generation-boundary.pl"));
5954        let code = "package GenerationBoundary;\nsub stable { 1 }\n1;\n";
5955
5956        must(index.index_file_with_generation(uri.clone(), code.to_string(), 2));
5957
5958        assert_eq!(
5959            index.indexed_generation(uri.as_str()),
5960            Some(2),
5961            "test setup must index the boundary generation"
5962        );
5963        assert!(
5964            !index.is_index_generation_stale(uri.as_str(), 1),
5965            "indexed_generation > expected_generation must not be stale"
5966        );
5967        assert!(
5968            !index.is_index_generation_stale(uri.as_str(), 2),
5969            "indexed_generation == expected_generation must not be stale"
5970        );
5971        assert!(
5972            index.is_index_generation_stale(uri.as_str(), 3),
5973            "indexed_generation < expected_generation must be stale"
5974        );
5975    }
5976
5977    #[test]
5978    fn test_early_exit_optimization_changed_content() {
5979        let index = WorkspaceIndex::new();
5980        let uri = must(url::Url::parse("file:///test.pl"));
5981        let code1 = r#"
5982package MyPackage;
5983
5984sub hello {
5985    print "Hello";
5986}
5987"#;
5988
5989        let code2 = r#"
5990package MyPackage;
5991
5992sub goodbye {
5993    print "Goodbye";
5994}
5995"#;
5996
5997        // First indexing
5998        must(index.index_file(uri.clone(), code1.to_string()));
5999        let symbols1 = index.file_symbols(uri.as_str());
6000        assert!(symbols1.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
6001        assert!(!symbols1.iter().any(|s| s.name == "goodbye"));
6002
6003        // Second indexing with different content should re-parse
6004        must(index.index_file(uri.clone(), code2.to_string()));
6005        let symbols2 = index.file_symbols(uri.as_str());
6006        assert!(!symbols2.iter().any(|s| s.name == "hello"));
6007        assert!(symbols2.iter().any(|s| s.name == "goodbye" && s.kind == SymbolKind::Subroutine));
6008    }
6009
6010    #[test]
6011    fn test_early_exit_optimization_whitespace_only_change() {
6012        let index = WorkspaceIndex::new();
6013        let uri = must(url::Url::parse("file:///test.pl"));
6014        let code1 = r#"
6015package MyPackage;
6016
6017sub hello {
6018    print "Hello";
6019}
6020"#;
6021
6022        let code2 = r#"
6023package MyPackage;
6024
6025
6026sub hello {
6027    print "Hello";
6028}
6029"#;
6030
6031        // First indexing
6032        must(index.index_file(uri.clone(), code1.to_string()));
6033        let symbols1 = index.file_symbols(uri.as_str());
6034        assert!(symbols1.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
6035
6036        // Second indexing with whitespace change should re-parse (hash will differ)
6037        must(index.index_file(uri.clone(), code2.to_string()));
6038        let symbols2 = index.file_symbols(uri.as_str());
6039        // Symbols should still be found, but content hash differs so it re-indexed
6040        assert!(symbols2.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
6041    }
6042
6043    #[test]
6044    fn test_reindex_file_refreshes_symbol_cache_for_removed_names() {
6045        let index = WorkspaceIndex::new();
6046        let uri1 = must(url::Url::parse("file:///lib/A.pm"));
6047        let uri2 = must(url::Url::parse("file:///lib/B.pm"));
6048        let code1 = "package A;\nsub foo { return 1; }\n1;\n";
6049        let code2 = "package B;\nsub foo { return 2; }\n1;\n";
6050        let code2_reindexed = "package B;\nsub bar { return 3; }\n1;\n";
6051
6052        must(index.index_file(uri1.clone(), code1.to_string()));
6053        must(index.index_file(uri2.clone(), code2.to_string()));
6054        must(index.index_file(uri2.clone(), code2_reindexed.to_string()));
6055
6056        let foo_location = must_some(index.find_definition("foo"));
6057        assert_eq!(foo_location.uri, uri1.to_string());
6058
6059        let bar_location = must_some(index.find_definition("bar"));
6060        assert_eq!(bar_location.uri, uri2.to_string());
6061    }
6062
6063    #[test]
6064    fn test_remove_file_preserves_other_colliding_symbol_entries() {
6065        let index = WorkspaceIndex::new();
6066        let uri1 = must(url::Url::parse("file:///lib/A.pm"));
6067        let uri2 = must(url::Url::parse("file:///lib/B.pm"));
6068        let code1 = "package A;\nsub foo { return 1; }\n1;\n";
6069        let code2 = "package B;\nsub foo { return 2; }\n1;\n";
6070
6071        must(index.index_file(uri1.clone(), code1.to_string()));
6072        must(index.index_file(uri2.clone(), code2.to_string()));
6073
6074        index.remove_file(uri2.as_str());
6075
6076        let foo_location = must_some(index.find_definition("foo"));
6077        assert_eq!(foo_location.uri, uri1.to_string());
6078    }
6079
6080    #[test]
6081    fn test_count_usages_no_double_counting_for_qualified_calls() {
6082        let index = WorkspaceIndex::new();
6083
6084        // File 1: defines Utils::process_data
6085        let uri1 = "file:///lib/Utils.pm";
6086        let code1 = r#"
6087package Utils;
6088
6089sub process_data {
6090    return 1;
6091}
6092"#;
6093        must(index.index_file(must(url::Url::parse(uri1)), code1.to_string()));
6094
6095        // File 2: calls Utils::process_data (qualified call)
6096        let uri2 = "file:///app.pl";
6097        let code2 = r#"
6098use Utils;
6099Utils::process_data();
6100Utils::process_data();
6101"#;
6102        must(index.index_file(must(url::Url::parse(uri2)), code2.to_string()));
6103
6104        // Each qualified call is stored under both "process_data" and "Utils::process_data"
6105        // by the dual indexing strategy. count_usages should deduplicate so we get the
6106        // actual number of call sites, not double.
6107        let count = index.count_usages("Utils::process_data");
6108
6109        // We expect exactly 2 usage sites (the two calls in app.pl),
6110        // not 4 (which would be the double-counted result).
6111        assert_eq!(
6112            count, 2,
6113            "count_usages should not double-count qualified calls, got {} (expected 2)",
6114            count
6115        );
6116
6117        // find_references should also deduplicate
6118        let refs = index.find_references("Utils::process_data");
6119        let non_def_refs: Vec<_> =
6120            refs.iter().filter(|loc| loc.uri != "file:///lib/Utils.pm").collect();
6121        assert_eq!(
6122            non_def_refs.len(),
6123            2,
6124            "find_references should not return duplicates for qualified calls, got {} non-def refs",
6125            non_def_refs.len()
6126        );
6127    }
6128
6129    #[test]
6130    fn test_batch_indexing() {
6131        let index = WorkspaceIndex::new();
6132        let files: Vec<(Url, String)> = (0..5)
6133            .map(|i| {
6134                let uri = must(Url::parse(&format!("file:///batch/module{}.pm", i)));
6135                let code =
6136                    format!("package Batch::Mod{};\nsub func_{} {{ return {}; }}\n1;", i, i, i);
6137                (uri, code)
6138            })
6139            .collect();
6140
6141        let errors = index.index_files_batch(files);
6142        assert!(errors.is_empty(), "batch indexing errors: {:?}", errors);
6143        assert_eq!(index.file_count(), 5);
6144        assert!(index.find_definition("Batch::Mod0::func_0").is_some());
6145        assert!(index.find_definition("Batch::Mod4::func_4").is_some());
6146    }
6147
6148    #[test]
6149    fn test_batch_indexing_skips_unchanged() {
6150        let index = WorkspaceIndex::new();
6151        let uri = must(Url::parse("file:///batch/skip.pm"));
6152        let code = "package Skip;\nsub skip_fn { 1 }\n1;".to_string();
6153
6154        index.index_file(uri.clone(), code.clone()).ok();
6155        assert_eq!(index.file_count(), 1);
6156
6157        let errors = index.index_files_batch(vec![(uri, code)]);
6158        assert!(errors.is_empty());
6159        assert_eq!(index.file_count(), 1);
6160    }
6161
6162    #[test]
6163    fn test_incremental_update_preserves_other_symbols() {
6164        let index = WorkspaceIndex::new();
6165
6166        let uri_a = must(Url::parse("file:///incr/a.pm"));
6167        let uri_b = must(Url::parse("file:///incr/b.pm"));
6168        index.index_file(uri_a.clone(), "package A;\nsub a_func { 1 }\n1;".into()).ok();
6169        index.index_file(uri_b.clone(), "package B;\nsub b_func { 2 }\n1;".into()).ok();
6170
6171        assert!(index.find_definition("A::a_func").is_some());
6172        assert!(index.find_definition("B::b_func").is_some());
6173
6174        index.index_file(uri_a, "package A;\nsub a_func_v2 { 11 }\n1;".into()).ok();
6175
6176        assert!(index.find_definition("A::a_func_v2").is_some());
6177        assert!(index.find_definition("B::b_func").is_some());
6178    }
6179
6180    #[test]
6181    fn test_remove_file_preserves_shadowed_symbols() {
6182        let index = WorkspaceIndex::new();
6183
6184        let uri_a = must(Url::parse("file:///shadow/a.pm"));
6185        let uri_b = must(Url::parse("file:///shadow/b.pm"));
6186        index.index_file(uri_a.clone(), "package ShadowA;\nsub helper { 1 }\n1;".into()).ok();
6187        index.index_file(uri_b.clone(), "package ShadowB;\nsub helper { 2 }\n1;".into()).ok();
6188
6189        assert!(index.find_definition("helper").is_some());
6190
6191        index.remove_file_url(&uri_a);
6192        assert!(index.find_definition("helper").is_some());
6193        assert!(index.find_definition("ShadowB::helper").is_some());
6194    }
6195
6196    // -------------------------------------------------------------------------
6197    // find_dependents — use parent / use base integration (#2747)
6198    // -------------------------------------------------------------------------
6199
6200    #[test]
6201    fn test_index_dependency_via_use_parent_end_to_end() {
6202        // Regression for #2747: index a file with `use parent 'MyBase'` and verify
6203        // that find_dependents("MyBase") returns that file.
6204        // 1. Index MyBase.pm
6205        // 2. Index child.pl with `use parent 'MyBase'`
6206        // 3. find_dependents("MyBase") should return child.pl
6207        let index = WorkspaceIndex::new();
6208
6209        let base_url = must(url::Url::parse("file:///test/workspace/lib/MyBase.pm"));
6210        must(index.index_file(
6211            base_url,
6212            "package MyBase;\nsub new { bless {}, shift }\n1;\n".to_string(),
6213        ));
6214
6215        let child_url = must(url::Url::parse("file:///test/workspace/child.pl"));
6216        must(index.index_file(child_url, "package Child;\nuse parent 'MyBase';\n1;\n".to_string()));
6217
6218        let dependents = index.find_dependents("MyBase");
6219        assert!(
6220            !dependents.is_empty(),
6221            "find_dependents('MyBase') returned empty — \
6222             use parent 'MyBase' should register MyBase as a dependency. \
6223             Dependencies in index: {:?}",
6224            {
6225                let files = index.files.read();
6226                files
6227                    .iter()
6228                    .map(|(k, v)| (k.clone(), v.dependencies.iter().cloned().collect::<Vec<_>>()))
6229                    .collect::<Vec<_>>()
6230            }
6231        );
6232        assert!(
6233            dependents.contains(&"file:///test/workspace/child.pl".to_string()),
6234            "child.pl should be in dependents, got: {:?}",
6235            dependents
6236        );
6237    }
6238
6239    #[test]
6240    fn test_find_dependents_normalizes_legacy_separator_in_query() {
6241        let index = WorkspaceIndex::new();
6242        let uri = must(url::Url::parse("file:///test/workspace/legacy-query.pl"));
6243        let src = "package Child;\nuse parent 'My::Base';\n1;\n";
6244        must(index.index_file(uri, src.to_string()));
6245
6246        let dependents = index.find_dependents("My'Base");
6247        assert_eq!(dependents, vec!["file:///test/workspace/legacy-query.pl".to_string()]);
6248    }
6249
6250    #[test]
6251    fn test_file_dependencies_normalize_legacy_separator_in_source() {
6252        let index = WorkspaceIndex::new();
6253        let uri = must(url::Url::parse("file:///test/workspace/legacy-source.pl"));
6254        let src = "package Child;\nuse parent \"My'Base\";\n1;\n";
6255        must(index.index_file(uri.clone(), src.to_string()));
6256
6257        let deps = index.file_dependencies(uri.as_str());
6258        assert!(deps.contains("My::Base"));
6259        assert!(!deps.contains("My'Base"));
6260    }
6261
6262    #[test]
6263    fn test_index_dependency_via_moose_extends_end_to_end() -> Result<(), Box<dyn std::error::Error>>
6264    {
6265        let index = WorkspaceIndex::new();
6266
6267        let parent_url = must(url::Url::parse("file:///test/workspace/lib/My/App/Parent.pm"));
6268        must(index.index_file(parent_url, "package My::App::Parent;\n1;\n".to_string()));
6269
6270        let child_url = must(url::Url::parse("file:///test/workspace/child-moose.pl"));
6271        let child_src = "package Child;\nuse Moose;\nextends 'My::App::Parent';\n1;\n";
6272        must(index.index_file(child_url, child_src.to_string()));
6273
6274        let dependents = index.find_dependents("My::App::Parent");
6275        assert!(
6276            dependents.contains(&"file:///test/workspace/child-moose.pl".to_string()),
6277            "expected child-moose.pl in dependents, got: {dependents:?}"
6278        );
6279        Ok(())
6280    }
6281
6282    #[test]
6283    fn test_index_dependency_via_moo_with_role_end_to_end() -> Result<(), Box<dyn std::error::Error>>
6284    {
6285        let index = WorkspaceIndex::new();
6286
6287        let role_url = must(url::Url::parse("file:///test/workspace/lib/My/App/Role.pm"));
6288        must(index.index_file(role_url, "package My::App::Role;\n1;\n".to_string()));
6289
6290        let consumer_url = must(url::Url::parse("file:///test/workspace/consumer-moo.pl"));
6291        let consumer_src = "package Consumer;\nuse Moo;\nwith 'My::App::Role';\n1;\n";
6292        must(index.index_file(consumer_url.clone(), consumer_src.to_string()));
6293
6294        let dependents = index.find_dependents("My::App::Role");
6295        assert!(
6296            dependents.contains(&"file:///test/workspace/consumer-moo.pl".to_string()),
6297            "expected consumer-moo.pl in dependents, got: {dependents:?}"
6298        );
6299
6300        let deps = index.file_dependencies(consumer_url.as_str());
6301        assert!(deps.contains("My::App::Role"));
6302        Ok(())
6303    }
6304
6305    #[test]
6306    fn test_index_dependency_via_literal_require_end_to_end()
6307    -> Result<(), Box<dyn std::error::Error>> {
6308        let index = WorkspaceIndex::new();
6309        let uri = must(url::Url::parse("file:///test/workspace/require-consumer.pl"));
6310        let src = "package Consumer;\nrequire My::Loader;\n1;\n";
6311        must(index.index_file(uri.clone(), src.to_string()));
6312
6313        let deps = index.file_dependencies(uri.as_str());
6314        assert!(
6315            deps.contains("My::Loader"),
6316            "literal require should register module dependency, got: {deps:?}"
6317        );
6318        Ok(())
6319    }
6320
6321    #[test]
6322    fn test_manual_import_symbols_are_indexed_as_import_references()
6323    -> Result<(), Box<dyn std::error::Error>> {
6324        let index = WorkspaceIndex::new();
6325        let uri = must(url::Url::parse("file:///test/workspace/manual-import.pl"));
6326        let src = r#"package Consumer;
6327require My::Tools;
6328My::Tools->import(qw(helper_one helper_two));
6329helper_one();
63301;
6331"#;
6332        must(index.index_file(uri.clone(), src.to_string()));
6333
6334        let deps = index.file_dependencies(uri.as_str());
6335        assert!(
6336            deps.contains("My::Tools"),
6337            "manual import target should be tracked as dependency, got: {deps:?}"
6338        );
6339
6340        for symbol in ["helper_one", "helper_two"] {
6341            let refs = index.find_references(symbol);
6342            assert!(
6343                !refs.is_empty(),
6344                "expected at least one indexed reference for imported symbol `{symbol}`"
6345            );
6346        }
6347        Ok(())
6348    }
6349
6350    #[test]
6351    fn test_parser_produces_correct_args_for_use_parent() {
6352        // Regression for #2747: verify that the parser produces args=["'MyBase'"]
6353        // for `use parent 'MyBase'`, so extract_module_names_from_use_args strips
6354        // the quotes and registers the dependency under the bare name "MyBase".
6355        use crate::Parser;
6356        let mut p = Parser::new("package Child;\nuse parent 'MyBase';\n1;\n");
6357        let ast = must(p.parse());
6358        assert!(
6359            matches!(ast.kind, NodeKind::Program { .. }),
6360            "Expected Program root, got {:?}",
6361            ast.kind
6362        );
6363        let NodeKind::Program { statements } = &ast.kind else {
6364            return;
6365        };
6366        let mut found_parent_use = false;
6367        for stmt in statements {
6368            if let NodeKind::Use { module, args, .. } = &stmt.kind {
6369                if module == "parent" {
6370                    found_parent_use = true;
6371                    assert_eq!(
6372                        args,
6373                        &["'MyBase'".to_string()],
6374                        "Expected args=[\"'MyBase'\"] for `use parent 'MyBase'`, got: {:?}",
6375                        args
6376                    );
6377                    let extracted = extract_module_names_from_use_args(args);
6378                    assert_eq!(
6379                        extracted,
6380                        vec!["MyBase".to_string()],
6381                        "extract_module_names_from_use_args should return [\"MyBase\"], got {:?}",
6382                        extracted
6383                    );
6384                }
6385            }
6386        }
6387        assert!(found_parent_use, "No Use node with module='parent' found in AST");
6388    }
6389
6390    // -------------------------------------------------------------------------
6391    // extract_module_names_from_use_args — unit tests (#2747)
6392    // -------------------------------------------------------------------------
6393
6394    #[test]
6395    fn test_extract_module_names_single_quoted() {
6396        let names = extract_module_names_from_use_args(&["'Foo::Bar'".to_string()]);
6397        assert_eq!(names, vec!["Foo::Bar"]);
6398    }
6399
6400    #[test]
6401    fn test_extract_module_names_double_quoted() {
6402        let names = extract_module_names_from_use_args(&["\"Foo::Bar\"".to_string()]);
6403        assert_eq!(names, vec!["Foo::Bar"]);
6404    }
6405
6406    #[test]
6407    fn test_extract_module_names_qw_list() {
6408        let names = extract_module_names_from_use_args(&["qw(Foo::Bar Other::Base)".to_string()]);
6409        assert_eq!(names, vec!["Foo::Bar", "Other::Base"]);
6410    }
6411
6412    #[test]
6413    fn test_extract_module_names_qw_slash_delimiter() {
6414        let names = extract_module_names_from_use_args(&["qw/Foo::Bar Other::Base/".to_string()]);
6415        assert_eq!(names, vec!["Foo::Bar", "Other::Base"]);
6416    }
6417
6418    #[test]
6419    fn test_extract_module_names_qw_with_space_before_delimiter() {
6420        let names = extract_module_names_from_use_args(&["qw [Foo::Bar Other::Base]".to_string()]);
6421        assert_eq!(names, vec!["Foo::Bar", "Other::Base"]);
6422    }
6423
6424    #[test]
6425    fn test_extract_module_names_qw_list_trims_wrapped_punctuation() {
6426        let names =
6427            extract_module_names_from_use_args(&["qw((Foo::Bar) [Other::Base],)".to_string()]);
6428        assert_eq!(names, vec!["Foo::Bar", "Other::Base"]);
6429    }
6430
6431    #[test]
6432    fn test_extract_module_names_norequire_flag() {
6433        let names = extract_module_names_from_use_args(&[
6434            "-norequire".to_string(),
6435            "'Foo::Bar'".to_string(),
6436        ]);
6437        assert_eq!(names, vec!["Foo::Bar"]);
6438    }
6439
6440    #[test]
6441    fn test_extract_module_names_empty_args() {
6442        let names = extract_module_names_from_use_args(&[]);
6443        assert!(names.is_empty());
6444    }
6445
6446    #[test]
6447    fn test_extract_module_names_legacy_separator() {
6448        // Perl legacy package separator ' (tick) inside module name
6449        let names = extract_module_names_from_use_args(&["'Foo'Bar'".to_string()]);
6450        // Legacy separators are normalized for downstream dependency matching.
6451        assert_eq!(names, vec!["Foo::Bar"]);
6452    }
6453
6454    #[test]
6455    fn test_find_dependents_matches_legacy_separator_queries() {
6456        let index = WorkspaceIndex::new();
6457        let base_uri = must(url::Url::parse("file:///test/workspace/lib/Foo/Bar.pm"));
6458        let child_uri = must(url::Url::parse("file:///test/workspace/child.pl"));
6459
6460        must(index.index_file(base_uri, "package Foo::Bar;\n1;\n".to_string()));
6461        must(index.index_file(
6462            child_uri.clone(),
6463            "package Child;\nuse parent qw(Foo'Bar);\n1;\n".to_string(),
6464        ));
6465
6466        let dependents_modern = index.find_dependents("Foo::Bar");
6467        assert!(
6468            dependents_modern.contains(&child_uri.to_string()),
6469            "Expected dependency match when queried with modern separator"
6470        );
6471
6472        let dependents_legacy = index.find_dependents("Foo'Bar");
6473        assert!(
6474            dependents_legacy.contains(&child_uri.to_string()),
6475            "Expected dependency match when queried with legacy separator"
6476        );
6477    }
6478
6479    #[test]
6480    fn test_extract_module_names_comma_adjacent_tokens() {
6481        let names = extract_module_names_from_use_args(&[
6482            "'Foo::Bar',".to_string(),
6483            "\"Other::Base\",".to_string(),
6484            "'Last::One'".to_string(),
6485        ]);
6486        assert_eq!(names, vec!["Foo::Bar", "Other::Base", "Last::One"]);
6487    }
6488
6489    #[test]
6490    fn test_extract_module_names_parenthesized_without_spaces() {
6491        let names = extract_module_names_from_use_args(&["('Foo::Bar','Other::Base')".to_string()]);
6492        assert_eq!(names, vec!["Foo::Bar", "Other::Base"]);
6493    }
6494
6495    #[test]
6496    fn test_extract_module_names_deduplicates_identical_entries() {
6497        let names = extract_module_names_from_use_args(&[
6498            "qw(Foo::Bar Foo::Bar)".to_string(),
6499            "'Foo::Bar'".to_string(),
6500        ]);
6501        assert_eq!(names, vec!["Foo::Bar"]);
6502    }
6503
6504    #[test]
6505    fn test_extract_module_names_trims_semicolon_suffix() {
6506        let names = extract_module_names_from_use_args(&[
6507            "'Foo::Bar',".to_string(),
6508            "'Other::Base',".to_string(),
6509            "'Third::Leaf';".to_string(),
6510        ]);
6511        assert_eq!(names, vec!["Foo::Bar", "Other::Base", "Third::Leaf"]);
6512    }
6513
6514    #[test]
6515    fn test_extract_module_names_trims_wrapped_punctuation() {
6516        let names = extract_module_names_from_use_args(&[
6517            "('Foo::Bar',".to_string(),
6518            "'Other::Base')".to_string(),
6519        ]);
6520        assert_eq!(names, vec!["Foo::Bar", "Other::Base"]);
6521    }
6522
6523    #[test]
6524    fn test_extract_constant_names_qw_with_space_before_delimiter() {
6525        let names = extract_constant_names_from_use_args(&["qw [FOO BAR]".to_string()]);
6526        assert_eq!(names, vec!["FOO", "BAR"]);
6527    }
6528
6529    #[test]
6530    #[ignore = "qw delimiter with leading space not yet parsed; tracked in debt-ledger.yaml"]
6531    fn test_index_use_constant_qw_with_space_before_delimiter() {
6532        let index = WorkspaceIndex::new();
6533        let uri = must(url::Url::parse("file:///workspace/lib/My/Config.pm"));
6534        let source = "package My::Config;\nuse constant qw [FOO BAR];\n1;\n";
6535
6536        must(index.index_file(uri, source.to_string()));
6537
6538        let foo = index.find_definition("My::Config::FOO");
6539        let bar = index.find_definition("My::Config::BAR");
6540        assert!(foo.is_some(), "Expected My::Config::FOO to be indexed");
6541        assert!(bar.is_some(), "Expected My::Config::BAR to be indexed");
6542    }
6543
6544    #[test]
6545    fn test_with_capacity_accepts_large_batch_without_panic() {
6546        let index = WorkspaceIndex::with_capacity(100, 20);
6547        for i in 0..100 {
6548            let uri = must(url::Url::parse(&format!("file:///lib/Mod{}.pm", i)));
6549            let src = format!("package Mod{};\nsub foo_{} {{ 1 }}\n1;\n", i, i);
6550            index.index_file(uri, src).ok();
6551        }
6552        assert!(index.has_symbols());
6553    }
6554
6555    #[test]
6556    fn test_with_capacity_zero_does_not_panic() {
6557        let index = WorkspaceIndex::with_capacity(0, 0);
6558        assert!(!index.has_symbols());
6559    }
6560
6561    // -------------------------------------------------------------------------
6562    // remove_file — symbol cache cleanup (#3494)
6563    // -------------------------------------------------------------------------
6564
6565    /// After removing the only file that defines a symbol, both qualified and
6566    /// bare-name lookups must return None.  The symbols cache must not retain
6567    /// stale entries pointing to the deleted file.
6568    #[test]
6569    fn test_remove_file_clears_symbol_cache_qualified_and_bare() {
6570        let index = WorkspaceIndex::new();
6571        let uri_a = must(url::Url::parse("file:///lib/A.pm"));
6572        let code_a = "package A;\nsub foo { return 1; }\n1;\n";
6573
6574        must(index.index_file(uri_a.clone(), code_a.to_string()));
6575
6576        // Pre-condition: both qualified and bare-name lookups resolve to file A.
6577        let before_qual = must_some(index.find_definition("A::foo"));
6578        assert_eq!(
6579            before_qual.uri,
6580            uri_a.to_string(),
6581            "qualified lookup should point to A.pm before removal"
6582        );
6583        let before_bare = must_some(index.find_definition("foo"));
6584        assert_eq!(
6585            before_bare.uri,
6586            uri_a.to_string(),
6587            "bare-name lookup should point to A.pm before removal"
6588        );
6589
6590        // Remove file A from the index (simulates file deletion).
6591        index.remove_file(uri_a.as_str());
6592
6593        // Post-condition: the symbol cache must be clean — no stale entries.
6594        assert!(
6595            index.find_definition("A::foo").is_none(),
6596            "qualified lookup 'A::foo' should return None after file deletion"
6597        );
6598        assert!(
6599            index.find_definition("foo").is_none(),
6600            "bare-name lookup 'foo' should return None after file deletion"
6601        );
6602
6603        // Verify no symbols remain in the index.
6604        assert_eq!(
6605            index.symbol_count(),
6606            0,
6607            "symbol_count should be 0 after removing the only file"
6608        );
6609        assert!(!index.has_symbols(), "has_symbols should be false after removing the only file");
6610    }
6611
6612    /// Deleting file A when file B has the same bare-name symbol must leave
6613    /// the bare-name cache pointing to B (not remove it entirely).
6614    #[test]
6615    fn test_remove_file_bare_name_falls_back_to_surviving_file() {
6616        let index = WorkspaceIndex::new();
6617        let uri_a = must(url::Url::parse("file:///lib/A.pm"));
6618        let uri_b = must(url::Url::parse("file:///lib/B.pm"));
6619        let code_a = "package A;\nsub shared_fn { return 1; }\n1;\n";
6620        let code_b = "package B;\nsub shared_fn { return 2; }\n1;\n";
6621
6622        must(index.index_file(uri_a.clone(), code_a.to_string()));
6623        must(index.index_file(uri_b.clone(), code_b.to_string()));
6624
6625        // Remove file A — shared_fn should still resolve via B.
6626        index.remove_file(uri_a.as_str());
6627
6628        let loc = must_some(index.find_definition("shared_fn"));
6629        assert_eq!(
6630            loc.uri,
6631            uri_b.to_string(),
6632            "bare-name 'shared_fn' should resolve to B.pm after A.pm is deleted"
6633        );
6634
6635        assert!(
6636            index.find_definition("A::shared_fn").is_none(),
6637            "qualified 'A::shared_fn' must be gone after A.pm deletion"
6638        );
6639        assert!(
6640            index.find_definition("B::shared_fn").is_some(),
6641            "qualified 'B::shared_fn' must remain after A.pm deletion"
6642        );
6643    }
6644
6645    #[test]
6646    fn test_definition_candidates_include_ambiguous_bare_symbols_in_stable_order() {
6647        let index = WorkspaceIndex::new();
6648        let uri_b = must(url::Url::parse("file:///lib/B.pm"));
6649        let uri_a = must(url::Url::parse("file:///lib/A.pm"));
6650        must(index.index_file(uri_b, "package B;\nsub shared { 1 }\n1;\n".to_string()));
6651        must(index.index_file(uri_a, "package A;\nsub shared { 1 }\n1;\n".to_string()));
6652
6653        let candidates = index.definition_candidates("shared");
6654        assert_eq!(candidates.len(), 2);
6655        assert_eq!(candidates[0].uri, "file:///lib/A.pm");
6656        assert_eq!(candidates[1].uri, "file:///lib/B.pm");
6657        assert_eq!(must_some(index.find_definition("shared")).uri, "file:///lib/A.pm");
6658    }
6659
6660    #[test]
6661    fn test_definition_candidates_include_duplicate_qualified_name_across_files() {
6662        let index = WorkspaceIndex::new();
6663        let uri_v2 = must(url::Url::parse("file:///lib/A-v2.pm"));
6664        let uri_v1 = must(url::Url::parse("file:///lib/A-v1.pm"));
6665        let source = "package A;\nsub foo { 1 }\n1;\n".to_string();
6666        must(index.index_file(uri_v2, source.clone()));
6667        must(index.index_file(uri_v1, source));
6668
6669        let candidates = index.definition_candidates("A::foo");
6670        assert_eq!(candidates.len(), 2);
6671        assert_eq!(candidates[0].uri, "file:///lib/A-v1.pm");
6672        assert_eq!(candidates[1].uri, "file:///lib/A-v2.pm");
6673    }
6674
6675    #[test]
6676    fn test_definition_candidates_are_cleaned_on_remove_and_reindex() {
6677        let index = WorkspaceIndex::new();
6678        let uri = must(url::Url::parse("file:///lib/A.pm"));
6679        must(index.index_file(uri.clone(), "package A;\nsub foo { 1 }\n1;\n".to_string()));
6680        assert_eq!(index.definition_candidates("A::foo").len(), 1);
6681
6682        index.remove_file(uri.as_str());
6683        assert!(index.definition_candidates("A::foo").is_empty());
6684
6685        must(index.index_file(uri, "package A;\nsub foo { 2 }\n1;\n".to_string()));
6686        assert_eq!(index.definition_candidates("A::foo").len(), 1);
6687    }
6688
6689    /// Verify that `incremental_remove_symbols` correctly retains candidates owned by
6690    /// other files when the removed file had BOTH exclusively-owned names (triggering the
6691    /// full-rebuild path) AND shared names. Before this fix, the full-rebuild path cleared
6692    /// all candidates and relied on the subsequent rebuild to re-add shared ones — correct
6693    /// in effect, but the test documents the expected observable behavior.
6694    #[test]
6695    fn test_definition_candidates_shared_symbol_survives_removal_of_sole_owner_of_other_symbol() {
6696        let index = WorkspaceIndex::new();
6697        let uri_a = must(url::Url::parse("file:///lib/A.pm"));
6698        let uri_b = must(url::Url::parse("file:///lib/B.pm"));
6699
6700        // A defines both `unique_to_a` (no other file) and `shared` (also in B)
6701        must(index.index_file(
6702            uri_a.clone(),
6703            "package A;\nsub unique_to_a { 1 }\nsub shared { 1 }\n1;\n".to_string(),
6704        ));
6705        must(index.index_file(uri_b.clone(), "package B;\nsub shared { 1 }\n1;\n".to_string()));
6706
6707        // Before removal: both shared candidates and unique_to_a are present
6708        assert_eq!(index.definition_candidates("shared").len(), 2);
6709        assert_eq!(index.definition_candidates("unique_to_a").len(), 1);
6710
6711        // Remove A — triggers the affected_names path for `unique_to_a`, but `shared`
6712        // still has B's candidate.
6713        index.remove_file(uri_a.as_str());
6714
6715        assert!(
6716            index.definition_candidates("unique_to_a").is_empty(),
6717            "unique_to_a should be gone after removing A"
6718        );
6719        assert_eq!(
6720            index.definition_candidates("shared").len(),
6721            1,
6722            "shared should still have B's candidate after removing A"
6723        );
6724        assert_eq!(
6725            index.definition_candidates("shared")[0].uri,
6726            "file:///lib/B.pm",
6727            "remaining shared candidate must be from B"
6728        );
6729    }
6730
6731    #[test]
6732    fn test_folder_context_in_file_index() {
6733        let index = WorkspaceIndex::new();
6734
6735        // Set up workspace folders
6736        index.set_workspace_folders(vec![
6737            "file:///project1".to_string(),
6738            "file:///project2".to_string(),
6739        ]);
6740
6741        let uri1 = "file:///project1/lib/Module.pm";
6742        let code1 = r#"
6743package Module;
6744
6745sub test_sub {
6746    return 1;
6747}
6748"#;
6749        must(index.index_file(must(url::Url::parse(uri1)), code1.to_string()));
6750
6751        let uri2 = "file:///project2/lib/Other.pm";
6752        let code2 = r#"
6753package Other;
6754
6755sub other_sub {
6756    return 2;
6757}
6758"#;
6759        must(index.index_file(must(url::Url::parse(uri2)), code2.to_string()));
6760
6761        // Verify folder context is set correctly
6762        let symbols1 = index.file_symbols(uri1);
6763        assert_eq!(symbols1.len(), 2, "Should have 2 symbols in Module.pm");
6764        for symbol in &symbols1 {
6765            assert_eq!(symbol.uri, uri1, "Symbol URI should match file URI");
6766        }
6767
6768        let symbols2 = index.file_symbols(uri2);
6769        assert_eq!(symbols2.len(), 2, "Should have 2 symbols in Other.pm");
6770        for symbol in &symbols2 {
6771            assert_eq!(symbol.uri, uri2, "Symbol URI should match file URI");
6772        }
6773
6774        // Verify folder attribution
6775        let files = index.files.read();
6776        let file_index1 = must_some(files.get(&DocumentStore::uri_key(uri1)));
6777        assert_eq!(
6778            file_index1.folder_uri,
6779            Some("file:///project1".to_string()),
6780            "File should be attributed to correct workspace folder"
6781        );
6782
6783        let file_index2 = must_some(files.get(&DocumentStore::uri_key(uri2)));
6784        assert_eq!(
6785            file_index2.folder_uri,
6786            Some("file:///project2".to_string()),
6787            "File should be attributed to correct workspace folder"
6788        );
6789    }
6790
6791    #[test]
6792    fn test_determine_folder_uri() {
6793        let index = WorkspaceIndex::new();
6794
6795        // Set up workspace folders
6796        index.set_workspace_folders(vec![
6797            "file:///project1".to_string(),
6798            "file:///project2".to_string(),
6799        ]);
6800
6801        // Test file in project1
6802        let folder1 = index.determine_folder_uri("file:///project1/lib/Module.pm");
6803        assert_eq!(
6804            folder1,
6805            Some("file:///project1".to_string()),
6806            "Should determine folder for file in project1"
6807        );
6808
6809        // Test file in project2
6810        let folder2 = index.determine_folder_uri("file:///project2/lib/Other.pm");
6811        assert_eq!(
6812            folder2,
6813            Some("file:///project2".to_string()),
6814            "Should determine folder for file in project2"
6815        );
6816
6817        // Test file not in any workspace folder
6818        let folder_none = index.determine_folder_uri("file:///other/project/Module.pm");
6819        assert_eq!(folder_none, None, "Should return None for file outside workspace folders");
6820    }
6821
6822    #[test]
6823    fn test_determine_folder_uri_prefers_most_specific_match() {
6824        let index = WorkspaceIndex::new();
6825
6826        // Keep broad folder first to ensure we don't rely on insertion order.
6827        index.set_workspace_folders(vec![
6828            "file:///project".to_string(),
6829            "file:///project/lib".to_string(),
6830        ]);
6831
6832        let folder = index.determine_folder_uri("file:///project/lib/My/Module.pm");
6833        assert_eq!(
6834            folder,
6835            Some("file:///project/lib".to_string()),
6836            "Nested workspace folders should attribute files to the most specific folder"
6837        );
6838    }
6839
6840    #[test]
6841    fn test_remove_folder() {
6842        let index = WorkspaceIndex::new();
6843
6844        // Set up workspace folders
6845        index.set_workspace_folders(vec![
6846            "file:///project1".to_string(),
6847            "file:///project2".to_string(),
6848        ]);
6849
6850        // Index files from both folders
6851        let uri1 = "file:///project1/lib/Module.pm";
6852        let code1 = r#"
6853package Module;
6854
6855sub test_sub {
6856    return 1;
6857}
6858"#;
6859        must(index.index_file(must(url::Url::parse(uri1)), code1.to_string()));
6860
6861        let uri2 = "file:///project2/lib/Other.pm";
6862        let code2 = r#"
6863package Other;
6864
6865sub other_sub {
6866    return 2;
6867}
6868"#;
6869        must(index.index_file(must(url::Url::parse(uri2)), code2.to_string()));
6870
6871        // Verify both files are indexed
6872        assert_eq!(index.file_count(), 2, "Should have 2 files indexed");
6873        assert_eq!(index.document_store().count(), 2, "Document store should track both files");
6874
6875        // Remove project1 folder
6876        index.remove_folder("file:///project1");
6877
6878        // Verify only project2 file remains
6879        assert_eq!(index.file_count(), 1, "Should have 1 file after removing folder");
6880        assert_eq!(
6881            index.document_store().count(),
6882            1,
6883            "Document store should drop files removed via folder deletion"
6884        );
6885        assert!(index.file_symbols(uri1).is_empty(), "File from removed folder should be gone");
6886        assert_eq!(
6887            index.file_symbols(uri2).len(),
6888            2,
6889            "File from remaining folder should still be present"
6890        );
6891    }
6892
6893    #[test]
6894    fn test_remove_folder_removes_symbol_free_files() {
6895        let index = WorkspaceIndex::new();
6896        index.set_workspace_folders(vec!["file:///project1".to_string()]);
6897
6898        let uri = "file:///project1/empty.pl";
6899        must(index.index_file(must(url::Url::parse(uri)), "# comments only".to_string()));
6900        assert_eq!(index.file_count(), 1, "Expected file to be indexed");
6901
6902        index.remove_folder("file:///project1");
6903
6904        assert_eq!(index.file_count(), 0, "Folder removal should delete symbol-free files");
6905        assert_eq!(
6906            index.document_store().count(),
6907            0,
6908            "Document store should stay in sync for symbol-free files"
6909        );
6910    }
6911
6912    // ========================================================================
6913    // GREEN-TDD EDGE CASE TESTS FOR ISSUE #6061 (static require + manual import)
6914    // ========================================================================
6915
6916    #[test]
6917    fn test_require_with_variable_target_is_not_indexed() -> Result<(), Box<dyn std::error::Error>>
6918    {
6919        let index = WorkspaceIndex::new();
6920        let uri = must(url::Url::parse("file:///test/require-var.pl"));
6921        let src = r#"package Test;
6922my $loader = 'MyModule';
6923require $loader;
69241;
6925"#;
6926        must(index.index_file(uri.clone(), src.to_string()));
6927        let deps = index.file_dependencies(uri.as_str());
6928        assert!(
6929            !deps.contains("MyModule"),
6930            "require with variable target should not register static dependency"
6931        );
6932        Ok(())
6933    }
6934
6935    #[test]
6936    fn test_multiple_import_calls_on_same_module() -> Result<(), Box<dyn std::error::Error>> {
6937        let index = WorkspaceIndex::new();
6938        let uri = must(url::Url::parse("file:///test/multi-import.pl"));
6939        let src = r#"package Test;
6940require Toolkit;
6941Toolkit->import('func_a');
6942Toolkit->import(qw(func_b func_c));
69431;
6944"#;
6945        must(index.index_file(uri.clone(), src.to_string()));
6946        let deps = index.file_dependencies(uri.as_str());
6947        assert!(deps.contains("Toolkit"), "module should be tracked as dependency");
6948        for symbol in &["func_a", "func_b", "func_c"] {
6949            let refs = index.find_references(symbol);
6950            assert!(!refs.is_empty(), "all imported symbols should be indexed: {}", symbol);
6951        }
6952        Ok(())
6953    }
6954
6955    #[test]
6956    fn test_require_string_vs_bareword_normalization() -> Result<(), Box<dyn std::error::Error>> {
6957        let index = WorkspaceIndex::new();
6958        let uri = must(url::Url::parse("file:///test/require-string.pl"));
6959        let src = r#"package Consumer;
6960require "String/Based/Module.pm";
6961String::Based::Module->import('exported');
69621;
6963"#;
6964        must(index.index_file(uri.clone(), src.to_string()));
6965        let deps = index.file_dependencies(uri.as_str());
6966        assert!(
6967            deps.contains("String::Based::Module"),
6968            "require string form should normalize path separators to ::"
6969        );
6970        let refs = index.find_references("exported");
6971        assert!(!refs.is_empty(), "import should be indexed even with string-form require");
6972        Ok(())
6973    }
6974
6975    #[test]
6976    fn test_import_without_require_registers_as_method_call()
6977    -> Result<(), Box<dyn std::error::Error>> {
6978        // Edge case: ->import() without preceding require is treated as a normal method call,
6979        // not as the static manual-import pattern, so the module is still visited/tracked
6980        // but the symbols are NOT marked as imports from the static require+import logic.
6981        let index = WorkspaceIndex::new();
6982        let uri = must(url::Url::parse("file:///test/orphan-import.pl"));
6983        let src = r#"package Test;
6984Unrelated::Module->import('orphaned');
6985orphaned();
69861;
6987"#;
6988        must(index.index_file(uri.clone(), src.to_string()));
6989
6990        // The module reference may still be tracked as a method call target,
6991        // but the key regression is: the orphaned symbol should not be indexed
6992        // as an import reference due to the missing require.
6993        let _refs = index.find_references("orphaned");
6994        // Symbol may be referenced but should not be specially treated as an import.
6995        // The main point is: without require, the pairing doesn't activate.
6996        Ok(())
6997    }
6998
6999    #[test]
7000    fn test_nested_blocks_preserve_require_scope() -> Result<(), Box<dyn std::error::Error>> {
7001        let index = WorkspaceIndex::new();
7002        let uri = must(url::Url::parse("file:///test/nested.pl"));
7003        let src = r#"package Test;
7004{
7005    require Outer;
7006    {
7007        Outer->import('nested_sym');
7008    }
7009}
70101;
7011"#;
7012        must(index.index_file(uri.clone(), src.to_string()));
7013        let deps = index.file_dependencies(uri.as_str());
7014        assert!(
7015            deps.contains("Outer"),
7016            "require in outer block should be visible to nested import"
7017        );
7018        let refs = index.find_references("nested_sym");
7019        assert!(!refs.is_empty(), "symbol imported in nested block should still be indexed");
7020        Ok(())
7021    }
7022
7023    #[test]
7024    fn test_require_path_without_pm_extension() -> Result<(), Box<dyn std::error::Error>> {
7025        let index = WorkspaceIndex::new();
7026        let uri = must(url::Url::parse("file:///test/no-ext.pl"));
7027        let src = r#"package Test;
7028require "My/Module";
7029My::Module->import('func');
70301;
7031"#;
7032        must(index.index_file(uri.clone(), src.to_string()));
7033        let deps = index.file_dependencies(uri.as_str());
7034        assert!(
7035            deps.contains("My::Module"),
7036            "require without .pm extension should normalize to module path"
7037        );
7038        Ok(())
7039    }
7040
7041    #[test]
7042    fn test_qw_with_bracket_delimiters() -> Result<(), Box<dyn std::error::Error>> {
7043        let index = WorkspaceIndex::new();
7044        let uri = must(url::Url::parse("file:///test/qw-delim.pl"));
7045        let src = r#"package Test;
7046require DelimModule;
7047DelimModule->import(qw[sym1 sym2]);
7048DelimModule->import(qw{sym3 sym4});
70491;
7050"#;
7051        must(index.index_file(uri.clone(), src.to_string()));
7052        for symbol in &["sym1", "sym2", "sym3", "sym4"] {
7053            let refs = index.find_references(symbol);
7054            assert!(
7055                !refs.is_empty(),
7056                "symbols from qw with bracket delimiters should be indexed: {}",
7057                symbol
7058            );
7059        }
7060        Ok(())
7061    }
7062
7063    #[test]
7064    fn test_array_literal_import_args() -> Result<(), Box<dyn std::error::Error>> {
7065        let index = WorkspaceIndex::new();
7066        let uri = must(url::Url::parse("file:///test/array-import.pl"));
7067        let src = r#"package Test;
7068require ArrayModule;
7069ArrayModule->import(['sym_x', 'sym_y']);
70701;
7071"#;
7072        must(index.index_file(uri.clone(), src.to_string()));
7073        for symbol in &["sym_x", "sym_y"] {
7074            let refs = index.find_references(symbol);
7075            assert!(
7076                !refs.is_empty(),
7077                "symbols from array literal import should be indexed: {}",
7078                symbol
7079            );
7080        }
7081        Ok(())
7082    }
7083
7084    #[test]
7085    fn test_require_inside_conditional_still_registers_dependency()
7086    -> Result<(), Box<dyn std::error::Error>> {
7087        let index = WorkspaceIndex::new();
7088        let uri = must(url::Url::parse("file:///test/cond-require.pl"));
7089        let src = r#"package Test;
7090if (1) {
7091    require ConditionalMod;
7092    ConditionalMod->import('cond_func');
7093}
70941;
7095"#;
7096        must(index.index_file(uri.clone(), src.to_string()));
7097        let deps = index.file_dependencies(uri.as_str());
7098        assert!(
7099            deps.contains("ConditionalMod"),
7100            "require inside conditional should still register as dependency"
7101        );
7102        let refs = index.find_references("cond_func");
7103        assert!(!refs.is_empty(), "import inside conditional should still index symbols");
7104        Ok(())
7105    }
7106
7107    #[test]
7108    fn test_mixed_string_and_bareword_imports() -> Result<(), Box<dyn std::error::Error>> {
7109        let index = WorkspaceIndex::new();
7110        let uri = must(url::Url::parse("file:///test/mixed-import.pl"));
7111        let src = r#"package Test;
7112require MixedMod;
7113MixedMod->import('string_sym');
7114MixedMod->import(qw(qw_one qw_two));
71151;
7116"#;
7117        must(index.index_file(uri.clone(), src.to_string()));
7118        let deps = index.file_dependencies(uri.as_str());
7119        assert!(deps.contains("MixedMod"), "require should register dependency");
7120        for symbol in &["string_sym", "qw_one", "qw_two"] {
7121            let refs = index.find_references(symbol);
7122            assert!(!refs.is_empty(), "all import forms should index symbols: {}", symbol);
7123        }
7124        Ok(())
7125    }
7126
7127    // -------------------------------------------------------------------------
7128    // Per-category incremental invalidation (Req 18.1–18.5)
7129    // -------------------------------------------------------------------------
7130
7131    /// Helper: build a minimal `FileFactShard` with configurable hashes.
7132    fn make_shard(
7133        uri: &str,
7134        content_hash: u64,
7135        anchors_hash: Option<u64>,
7136        entities_hash: Option<u64>,
7137        occurrences_hash: Option<u64>,
7138        edges_hash: Option<u64>,
7139    ) -> FileFactShard {
7140        let file_id = {
7141            let mut h = DefaultHasher::new();
7142            uri.hash(&mut h);
7143            FileId(h.finish())
7144        };
7145        FileFactShard {
7146            source_uri: uri.to_string(),
7147            file_id,
7148            content_hash,
7149            producer_schema_version: PRODUCER_SCHEMA_VERSION,
7150            anchors_hash,
7151            entities_hash,
7152            occurrences_hash,
7153            edges_hash,
7154            anchors: Vec::new(),
7155            entities: Vec::new(),
7156            occurrences: Vec::new(),
7157            edges: Vec::new(),
7158        }
7159    }
7160
7161    /// Req 18.5: When content_hash is unchanged, skip all per-category
7162    /// comparisons — no index modifications happen.
7163    #[test]
7164    fn incremental_replace_skips_when_content_hash_unchanged()
7165    -> Result<(), Box<dyn std::error::Error>> {
7166        let index = WorkspaceIndex::new();
7167        let uri = "file:///lib/Same.pm";
7168        let key = DocumentStore::uri_key(uri);
7169
7170        let shard_v1 = make_shard(uri, 42, Some(1), Some(2), Some(3), Some(4));
7171        // First insert — no old shard, so all categories are "changed".
7172        let r1 = index.replace_fact_shard_incremental(&key, shard_v1);
7173        assert!(!r1.content_unchanged);
7174
7175        // Second insert with same content_hash → skip entirely.
7176        let shard_v2 = make_shard(uri, 42, Some(100), Some(200), Some(300), Some(400));
7177        let r2 = index.replace_fact_shard_incremental(&key, shard_v2);
7178        assert!(r2.content_unchanged);
7179        assert!(!r2.anchors_updated);
7180        assert!(!r2.entities_updated);
7181        assert!(!r2.occurrences_updated);
7182        assert!(!r2.edges_updated);
7183
7184        // The stored shard should still be v1 (unchanged).
7185        let stored = must_some(index.file_fact_shard(uri));
7186        assert_eq!(stored.anchors_hash, Some(1));
7187        Ok(())
7188    }
7189
7190    /// Req 18.3: When a category hash is unchanged, skip re-indexing that
7191    /// category's cross-file indexes.
7192    #[test]
7193    fn incremental_replace_skips_unchanged_categories() -> Result<(), Box<dyn std::error::Error>> {
7194        let index = WorkspaceIndex::new();
7195        let uri = "file:///lib/Partial.pm";
7196        let key = DocumentStore::uri_key(uri);
7197
7198        let shard_v1 = make_shard(uri, 1, Some(10), Some(20), Some(30), Some(40));
7199        index.replace_fact_shard_incremental(&key, shard_v1);
7200
7201        // Change content_hash but keep anchors and entities the same.
7202        // Only occurrences and edges change.
7203        let shard_v2 = make_shard(uri, 2, Some(10), Some(20), Some(99), Some(88));
7204        let result = index.replace_fact_shard_incremental(&key, shard_v2);
7205
7206        assert!(!result.content_unchanged);
7207        assert!(!result.anchors_updated, "anchors hash unchanged → skip");
7208        assert!(!result.entities_updated, "entities hash unchanged → skip");
7209        assert!(result.occurrences_updated, "occurrences hash changed → update");
7210        assert!(result.edges_updated, "edges hash changed → update");
7211        Ok(())
7212    }
7213
7214    /// Req 18.4: When a category hash has changed, remove old entries and
7215    /// insert new ones for that category.
7216    #[test]
7217    fn incremental_replace_updates_changed_categories() -> Result<(), Box<dyn std::error::Error>> {
7218        let index = WorkspaceIndex::new();
7219        let uri = "file:///lib/Changed.pm";
7220        let key = DocumentStore::uri_key(uri);
7221
7222        let shard_v1 = make_shard(uri, 1, Some(10), Some(20), Some(30), Some(40));
7223        index.replace_fact_shard_incremental(&key, shard_v1);
7224
7225        // Change all category hashes.
7226        let shard_v2 = make_shard(uri, 2, Some(11), Some(21), Some(31), Some(41));
7227        let result = index.replace_fact_shard_incremental(&key, shard_v2);
7228
7229        assert!(!result.content_unchanged);
7230        assert!(result.anchors_updated);
7231        assert!(result.entities_updated);
7232        assert!(result.occurrences_updated);
7233        assert!(result.edges_updated);
7234
7235        // The stored shard should be v2.
7236        let stored = must_some(index.file_fact_shard(uri));
7237        assert_eq!(stored.content_hash, 2);
7238        assert_eq!(stored.anchors_hash, Some(11));
7239        Ok(())
7240    }
7241
7242    /// When there is no old shard (first index), all categories are treated
7243    /// as changed.
7244    #[test]
7245    fn incremental_replace_first_insert_updates_all() -> Result<(), Box<dyn std::error::Error>> {
7246        let index = WorkspaceIndex::new();
7247        let uri = "file:///lib/New.pm";
7248        let key = DocumentStore::uri_key(uri);
7249
7250        let shard = make_shard(uri, 1, Some(10), Some(20), Some(30), Some(40));
7251        let result = index.replace_fact_shard_incremental(&key, shard);
7252
7253        assert!(!result.content_unchanged);
7254        assert!(result.anchors_updated);
7255        assert!(result.entities_updated);
7256        assert!(result.occurrences_updated);
7257        assert!(result.edges_updated);
7258        Ok(())
7259    }
7260
7261    /// When per-category hashes are `None` (legacy shard), the category is
7262    /// conservatively treated as changed.
7263    #[test]
7264    fn incremental_replace_none_hashes_treated_as_changed() -> Result<(), Box<dyn std::error::Error>>
7265    {
7266        let index = WorkspaceIndex::new();
7267        let uri = "file:///lib/Legacy.pm";
7268        let key = DocumentStore::uri_key(uri);
7269
7270        // Old shard has hashes, new shard has None for some.
7271        let shard_v1 = make_shard(uri, 1, Some(10), Some(20), Some(30), Some(40));
7272        index.replace_fact_shard_incremental(&key, shard_v1);
7273
7274        let shard_v2 = make_shard(uri, 2, None, Some(20), None, Some(40));
7275        let result = index.replace_fact_shard_incremental(&key, shard_v2);
7276
7277        assert!(!result.content_unchanged);
7278        assert!(result.anchors_updated, "None new hash → changed");
7279        assert!(!result.entities_updated, "same hash → skip");
7280        assert!(result.occurrences_updated, "None new hash → changed");
7281        assert!(!result.edges_updated, "same hash → skip");
7282        Ok(())
7283    }
7284
7285    /// Verify that the semantic reference index is updated only when
7286    /// occurrences or edges change.
7287    #[test]
7288    fn incremental_replace_updates_reference_index_on_occurrence_change()
7289    -> Result<(), Box<dyn std::error::Error>> {
7290        use perl_semantic_facts::{AnchorId, Confidence, OccurrenceId, OccurrenceKind, Provenance};
7291
7292        let index = WorkspaceIndex::new();
7293        let uri = "file:///lib/RefIdx.pm";
7294        let key = DocumentStore::uri_key(uri);
7295        let file_id = {
7296            let mut h = DefaultHasher::new();
7297            uri.hash(&mut h);
7298            FileId(h.finish())
7299        };
7300
7301        // v1: shard with one reference occurrence.
7302        let mut shard_v1 = make_shard(uri, 1, Some(10), Some(20), Some(30), Some(40));
7303        let anchor_id = AnchorId(1);
7304        shard_v1.anchors.push(perl_semantic_facts::AnchorFact {
7305            id: anchor_id,
7306            file_id,
7307            span_start_byte: 0,
7308            span_end_byte: 5,
7309            scope_id: None,
7310            provenance: Provenance::ExactAst,
7311            confidence: Confidence::High,
7312        });
7313        shard_v1.occurrences.push(perl_semantic_facts::OccurrenceFact {
7314            id: OccurrenceId(1),
7315            kind: OccurrenceKind::Call,
7316            entity_id: Some(EntityId(100)),
7317            anchor_id,
7318            scope_id: None,
7319            provenance: Provenance::ExactAst,
7320            confidence: Confidence::High,
7321        });
7322        shard_v1.entities.push(perl_semantic_facts::EntityFact {
7323            id: EntityId(100),
7324            kind: EntityKind::Subroutine,
7325            canonical_name: "RefIdx::foo".to_string(),
7326            anchor_id: Some(anchor_id),
7327            scope_id: None,
7328            provenance: Provenance::ExactAst,
7329            confidence: Confidence::High,
7330        });
7331        index.replace_fact_shard_incremental(&key, shard_v1);
7332
7333        // Reference index should have entries.
7334        assert!(
7335            index.semantic_reference_index.read().name_count() > 0
7336                || index.semantic_reference_index.read().entity_count() > 0,
7337            "reference index should be populated after first insert"
7338        );
7339
7340        // v2: same content_hash → skip entirely, reference index untouched.
7341        let shard_v2_same = make_shard(uri, 1, Some(10), Some(20), Some(99), Some(99));
7342        let r = index.replace_fact_shard_incremental(&key, shard_v2_same);
7343        assert!(r.content_unchanged);
7344
7345        // v3: different content_hash, same occurrence/edge hashes → skip ref index.
7346        let mut shard_v3 = make_shard(uri, 3, Some(11), Some(21), Some(30), Some(40));
7347        shard_v3.anchors.push(perl_semantic_facts::AnchorFact {
7348            id: anchor_id,
7349            file_id,
7350            span_start_byte: 0,
7351            span_end_byte: 5,
7352            scope_id: None,
7353            provenance: Provenance::ExactAst,
7354            confidence: Confidence::High,
7355        });
7356        shard_v3.occurrences.push(perl_semantic_facts::OccurrenceFact {
7357            id: OccurrenceId(1),
7358            kind: OccurrenceKind::Call,
7359            entity_id: Some(EntityId(100)),
7360            anchor_id,
7361            scope_id: None,
7362            provenance: Provenance::ExactAst,
7363            confidence: Confidence::High,
7364        });
7365        shard_v3.entities.push(perl_semantic_facts::EntityFact {
7366            id: EntityId(100),
7367            kind: EntityKind::Subroutine,
7368            canonical_name: "RefIdx::foo".to_string(),
7369            anchor_id: Some(anchor_id),
7370            scope_id: None,
7371            provenance: Provenance::ExactAst,
7372            confidence: Confidence::High,
7373        });
7374        let r3 = index.replace_fact_shard_incremental(&key, shard_v3);
7375        assert!(!r3.occurrences_updated, "occurrence hash unchanged → skip");
7376        assert!(!r3.edges_updated, "edge hash unchanged → skip");
7377
7378        Ok(())
7379    }
7380
7381    /// Verify that `index_file` uses incremental replacement (the fact shard
7382    /// is stored and updated correctly through the full indexing path).
7383    #[test]
7384    fn index_file_stores_fact_shard_incrementally() -> Result<(), Box<dyn std::error::Error>> {
7385        let index = WorkspaceIndex::new();
7386        let uri = "file:///lib/Incr.pm";
7387        let code = "package Incr;\nsub foo { 1 }\n1;\n";
7388
7389        must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
7390        let shard1 = must_some(index.file_fact_shard(uri));
7391        assert!(shard1.anchors_hash.is_some());
7392        assert!(
7393            shard1.anchors.iter().any(|anchor| anchor.provenance == Provenance::ExactAst),
7394            "index_file should store the canonical semantic shard when adapters produce facts"
7395        );
7396        assert!(
7397            shard1.entities.iter().any(|entity| entity.provenance == Provenance::ExactAst),
7398            "index_file should store canonical entities rather than legacy fallback entities"
7399        );
7400
7401        // Re-index with same content → shard should be unchanged.
7402        must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
7403        // The early-exit in index_file checks content_hash at the FileIndex
7404        // level, so the fact shard replacement is never reached for identical
7405        // content. Verify the shard is still present.
7406        let shard2 = must_some(index.file_fact_shard(uri));
7407        assert_eq!(shard1.content_hash, shard2.content_hash);
7408
7409        // Re-index with different content → shard should be replaced.
7410        let code2 = "package Incr;\nsub bar { 2 }\n1;\n";
7411        must(index.index_file(must(url::Url::parse(uri)), code2.to_string()));
7412        let shard3 = must_some(index.file_fact_shard(uri));
7413        assert_ne!(shard1.content_hash, shard3.content_hash);
7414
7415        Ok(())
7416    }
7417
7418    #[test]
7419    fn semantic_anchor_wire_location_uses_lsp_utf16_columns()
7420    -> Result<(), Box<dyn std::error::Error>> {
7421        use crate::semantic::queries::SemanticQueries;
7422
7423        let index = WorkspaceIndex::new();
7424        let uri = "file:///lib/UnicodeAnchor.pm";
7425        let code = "package UnicodeAnchor; my $emoji = \"😀\"; sub target { 1 }\n1;\n";
7426
7427        must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
7428
7429        let candidates = index
7430            .with_semantic_queries_for_uri(uri, |file_id, queries| {
7431                let ctx = crate::semantic::queries::QueryContext::new(file_id, None, Some(0));
7432                queries.definitions("UnicodeAnchor::target", &ctx)
7433            })
7434            .ok_or("missing semantic queries")?;
7435        let anchor_id = candidates
7436            .first()
7437            .map(|candidate| candidate.anchor_id)
7438            .ok_or("missing unicode definition candidate")?;
7439        let shard = index.file_fact_shard(uri).ok_or("missing fact shard")?;
7440        let anchor = shard
7441            .anchors
7442            .iter()
7443            .find(|anchor| anchor.id == anchor_id)
7444            .ok_or("missing unicode anchor")?;
7445        let start = usize::try_from(anchor.span_start_byte)?;
7446        let end = usize::try_from(anchor.span_end_byte)?;
7447        let expected = WireRange::from_byte_offsets(code, start, end);
7448
7449        let location =
7450            index.semantic_anchor_wire_location(anchor_id).ok_or("missing wire location")?;
7451
7452        assert_eq!(location.range, expected);
7453        let wire_column = usize::try_from(location.range.start.character)?;
7454        let scalar_column = code[..start].chars().count();
7455        assert!(
7456            wire_column > scalar_column,
7457            "fixture must prove the wire column counts UTF-16 units, not Unicode scalar values"
7458        );
7459
7460        Ok(())
7461    }
7462
7463    #[test]
7464    fn semantic_anchor_wire_location_fails_closed_for_duplicate_anchor_ids()
7465    -> Result<(), Box<dyn std::error::Error>> {
7466        use crate::semantic::queries::SemanticQueries;
7467
7468        // After the file-scoped stable_id fix (#1600), two files with identical Perl source
7469        // now produce DISTINCT anchor IDs (the file_id is included in the hash). This test
7470        // verifies that both global lookups succeed and return the correct URIs — the old
7471        // "fail-closed" scenario (None on collision) no longer applies in production.
7472        let index = WorkspaceIndex::new();
7473        let code = "package DuplicateAnchor;\nsub target { 1 }\n1;\n";
7474        let uri_a = "file:///lib/DuplicateA.pm";
7475        let uri_b = "file:///lib/DuplicateB.pm";
7476
7477        must(index.index_file(must(url::Url::parse(uri_a)), code.to_string()));
7478        must(index.index_file(must(url::Url::parse(uri_b)), code.to_string()));
7479
7480        let file_id_a = index.file_id_for_uri(uri_a).ok_or("file_id_a not found")?;
7481        let file_id_b = index.file_id_for_uri(uri_b).ok_or("file_id_b not found")?;
7482
7483        // Find anchor for file A by file-scoped resolution.
7484        let all_candidates = index
7485            .with_semantic_queries_for_uri(uri_a, |file_id, queries| {
7486                let ctx = crate::semantic::queries::QueryContext::new(file_id, None, Some(0));
7487                queries.definitions("DuplicateAnchor::target", &ctx)
7488            })
7489            .ok_or("missing semantic queries")?;
7490
7491        let anchor_id_a = all_candidates
7492            .iter()
7493            .find_map(|c| {
7494                index
7495                    .semantic_anchor_wire_location_for_file(file_id_a, c.anchor_id)
7496                    .map(|_| c.anchor_id)
7497            })
7498            .ok_or("no candidate found for file A")?;
7499        let anchor_id_b = all_candidates
7500            .iter()
7501            .find_map(|c| {
7502                index
7503                    .semantic_anchor_wire_location_for_file(file_id_b, c.anchor_id)
7504                    .map(|_| c.anchor_id)
7505            })
7506            .ok_or("no candidate found for file B")?;
7507
7508        // After the fix, anchor IDs are distinct — no collision.
7509        assert_ne!(anchor_id_a, anchor_id_b, "anchor IDs must be distinct after file-scoped fix");
7510
7511        // Global lookup now succeeds for both because each anchor_id is unique across shards.
7512        let location_a = index
7513            .semantic_anchor_wire_location(anchor_id_a)
7514            .ok_or("global lookup for anchor_id_a must succeed (no collision after fix)")?;
7515        assert_eq!(location_a.uri, uri_a, "anchor_id_a must resolve to uri_a");
7516
7517        let location_b = index
7518            .semantic_anchor_wire_location(anchor_id_b)
7519            .ok_or("global lookup for anchor_id_b must succeed (no collision after fix)")?;
7520        assert_eq!(location_b.uri, uri_b, "anchor_id_b must resolve to uri_b");
7521
7522        Ok(())
7523    }
7524
7525    #[test]
7526    fn semantic_anchor_wire_location_for_file_resolves_duplicate_anchor_ids_by_file()
7527    -> Result<(), Box<dyn std::error::Error>> {
7528        use crate::semantic::queries::SemanticQueries;
7529
7530        // After the file-scoped stable_id fix (#1600), two files with identical Perl source
7531        // produce DISTINCT anchor IDs. The file-scoped lookup continues to work, and
7532        // the global lookup also succeeds (no longer fails closed) because there are no
7533        // collisions. Both assertions validate the new correct post-fix behavior.
7534        let index = WorkspaceIndex::new();
7535        let code = "package DuplicateAnchor;\nsub target { 1 }\n1;\n";
7536        let uri_a = "file:///lib/DuplicateA.pm";
7537        let uri_b = "file:///lib/DuplicateB.pm";
7538
7539        must(index.index_file(must(url::Url::parse(uri_a)), code.to_string()));
7540        must(index.index_file(must(url::Url::parse(uri_b)), code.to_string()));
7541
7542        let file_id_a = index.file_id_for_uri(uri_a).ok_or("file_id_a not found")?;
7543
7544        let all_candidates = index
7545            .with_semantic_queries_for_uri(uri_a, |file_id, queries| {
7546                let ctx = crate::semantic::queries::QueryContext::new(file_id, None, Some(0));
7547                queries.definitions("DuplicateAnchor::target", &ctx)
7548            })
7549            .ok_or("missing semantic queries for uri_a")?;
7550
7551        // After the fix, find anchor_id for file A via file-scoped resolution.
7552        let anchor_id_a = all_candidates
7553            .iter()
7554            .find_map(|c| {
7555                index
7556                    .semantic_anchor_wire_location_for_file(file_id_a, c.anchor_id)
7557                    .map(|_| c.anchor_id)
7558            })
7559            .ok_or("no candidate found for file A")?;
7560
7561        // Global lookup now succeeds (no duplicate AnchorIds after the fix).
7562        let global_location = index
7563            .semantic_anchor_wire_location(anchor_id_a)
7564            .ok_or("global anchor lookup must succeed post-fix (no collision)")?;
7565        assert_eq!(global_location.uri, uri_a, "global lookup of anchor_id_a must return uri_a");
7566
7567        // File-scoped lookup also works.
7568        let file_location = index
7569            .semantic_anchor_wire_location_for_file(file_id_a, anchor_id_a)
7570            .ok_or("file-scoped anchor lookup should resolve anchor ID for file A")?;
7571        assert_eq!(file_location.uri, uri_a, "file-scoped lookup of anchor_id_a must return uri_a");
7572
7573        Ok(())
7574    }
7575
7576    // ── Property-based tests for incremental invalidation ──
7577
7578    mod prop_incremental_invalidation {
7579        use super::*;
7580        use proptest::prelude::*;
7581        use proptest::test_runner::Config as ProptestConfig;
7582
7583        /// Strategy for an optional per-category hash.
7584        ///
7585        /// ~10% of the time produces `None` (simulating legacy shards
7586        /// without per-category hashes); otherwise a random `u64`.
7587        fn arb_category_hash() -> impl Strategy<Value = Option<u64>> {
7588            prop_oneof![
7589                1 => Just(None),
7590                9 => any::<u64>().prop_map(Some),
7591            ]
7592        }
7593
7594        /// Strategy for a `FileFactShard` with the given URI and
7595        /// randomly-chosen hashes.
7596        fn arb_shard(uri: &'static str) -> impl Strategy<Value = FileFactShard> {
7597            (
7598                any::<u64>(),        // content_hash
7599                arb_category_hash(), // anchors_hash
7600                arb_category_hash(), // entities_hash
7601                arb_category_hash(), // occurrences_hash
7602                arb_category_hash(), // edges_hash
7603            )
7604                .prop_map(move |(content_hash, ah, eh, oh, edh)| {
7605                    make_shard(uri, content_hash, ah, eh, oh, edh)
7606                })
7607        }
7608
7609        // Property 15: Incremental Invalidation Correctness
7610        //
7611        // **Validates: Requirements 18.3, 18.4, 18.5**
7612        //
7613        // For any file re-indexing where the whole-file content_hash is
7614        // unchanged, the workspace store shall not modify any cross-file
7615        // indexes.  For any file re-indexing where a per-category hash is
7616        // unchanged, the workspace store shall skip re-indexing that
7617        // category.  For any file re-indexing where a per-category hash
7618        // has changed, the workspace store shall remove old entries and
7619        // insert new ones for that category.
7620        proptest! {
7621            #![proptest_config(ProptestConfig {
7622                failure_persistence: None,
7623                ..ProptestConfig::default()
7624            })]
7625
7626            #[test]
7627            fn prop_incremental_invalidation_correctness(
7628                old_shard in arb_shard("file:///lib/Prop.pm"),
7629                new_shard in arb_shard("file:///lib/Prop.pm"),
7630            ) {
7631                let index = WorkspaceIndex::new();
7632                let key = DocumentStore::uri_key("file:///lib/Prop.pm");
7633
7634                // Seed the index with the old shard.
7635                index.replace_fact_shard_incremental(&key, old_shard.clone());
7636
7637                // Replace with the new shard and capture the result.
7638                let result = index.replace_fact_shard_incremental(&key, new_shard.clone());
7639
7640                // ── Req 18.5: content_hash unchanged → skip entirely ──
7641                if old_shard.content_hash == new_shard.content_hash {
7642                    prop_assert!(
7643                        result.content_unchanged,
7644                        "content_unchanged must be true when content_hash is the same"
7645                    );
7646                    prop_assert!(
7647                        !result.anchors_updated,
7648                        "anchors_updated must be false when content_hash unchanged"
7649                    );
7650                    prop_assert!(
7651                        !result.entities_updated,
7652                        "entities_updated must be false when content_hash unchanged"
7653                    );
7654                    prop_assert!(
7655                        !result.occurrences_updated,
7656                        "occurrences_updated must be false when content_hash unchanged"
7657                    );
7658                    prop_assert!(
7659                        !result.edges_updated,
7660                        "edges_updated must be false when content_hash unchanged"
7661                    );
7662                } else {
7663                    prop_assert!(
7664                        !result.content_unchanged,
7665                        "content_unchanged must be false when content_hash differs"
7666                    );
7667
7668                    // ── Req 18.3 / 18.4: per-category hash comparison ──
7669                    // A category is "unchanged" when both old and new have
7670                    // Some(h) and the values are equal.  Otherwise the
7671                    // category is conservatively treated as changed.
7672
7673                    let anchors_should_update = crate::semantic::invalidation::category_hash_changed(
7674                        old_shard.anchors_hash,
7675                        new_shard.anchors_hash,
7676                    );
7677                    prop_assert_eq!(
7678                        result.anchors_updated,
7679                        anchors_should_update,
7680                        "anchors_updated mismatch: old={:?} new={:?}",
7681                        old_shard.anchors_hash,
7682                        new_shard.anchors_hash,
7683                    );
7684
7685                    let entities_should_update =
7686                        crate::semantic::invalidation::category_hash_changed(
7687                            old_shard.entities_hash,
7688                            new_shard.entities_hash,
7689                        );
7690                    prop_assert_eq!(
7691                        result.entities_updated,
7692                        entities_should_update,
7693                        "entities_updated mismatch: old={:?} new={:?}",
7694                        old_shard.entities_hash,
7695                        new_shard.entities_hash,
7696                    );
7697
7698                    let occurrences_should_update =
7699                        crate::semantic::invalidation::category_hash_changed(
7700                            old_shard.occurrences_hash,
7701                            new_shard.occurrences_hash,
7702                        );
7703                    prop_assert_eq!(
7704                        result.occurrences_updated,
7705                        occurrences_should_update,
7706                        "occurrences_updated mismatch: old={:?} new={:?}",
7707                        old_shard.occurrences_hash,
7708                        new_shard.occurrences_hash,
7709                    );
7710
7711                    let edges_should_update = crate::semantic::invalidation::category_hash_changed(
7712                        old_shard.edges_hash,
7713                        new_shard.edges_hash,
7714                    );
7715                    prop_assert_eq!(
7716                        result.edges_updated,
7717                        edges_should_update,
7718                        "edges_updated mismatch: old={:?} new={:?}",
7719                        old_shard.edges_hash,
7720                        new_shard.edges_hash,
7721                    );
7722                }
7723            }
7724        }
7725    }
7726}
7727
7728// ── with_semantic_queries_for_uri tests ──
7729
7730#[cfg(test)]
7731mod semantic_query_callback_tests {
7732    use super::*;
7733    use perl_tdd_support::{must, must_some};
7734
7735    #[test]
7736    fn with_semantic_queries_for_uri_indexed_uri_invokes_callback()
7737    -> Result<(), Box<dyn std::error::Error>> {
7738        let index = WorkspaceIndex::new();
7739        let uri = "file:///lib/Foo.pm";
7740        must(index.index_file(must(url::Url::parse(uri)), "sub foo { 1 }".to_string()));
7741
7742        let result = index.with_semantic_queries_for_uri(uri, |file_id, _queries| {
7743            // Verify the file_id is consistent with the URI (non-zero hash).
7744            assert_ne!(file_id.0, 0, "file_id should be non-zero");
7745            42u32 // sentinel return value
7746        });
7747
7748        assert_eq!(result, Some(42u32), "callback must run when URI is indexed");
7749        Ok(())
7750    }
7751
7752    #[test]
7753    fn with_semantic_queries_for_uri_unknown_uri_returns_none()
7754    -> Result<(), Box<dyn std::error::Error>> {
7755        let index = WorkspaceIndex::new();
7756        // Do NOT index anything.
7757        let result = index.with_semantic_queries_for_uri("file:///not/indexed.pl", |_, _| 99u32);
7758        assert!(result.is_none(), "unindexed URI must return None without invoking callback");
7759        Ok(())
7760    }
7761
7762    #[test]
7763    fn with_semantic_queries_for_uri_file_id_matches_file_id_for_uri()
7764    -> Result<(), Box<dyn std::error::Error>> {
7765        let index = WorkspaceIndex::new();
7766        let uri = "file:///lib/Bar.pm";
7767        must(index.index_file(must(url::Url::parse(uri)), "sub bar { 1 }".to_string()));
7768
7769        let direct_id = must_some(index.file_id_for_uri(uri));
7770        let callback_id =
7771            must_some(index.with_semantic_queries_for_uri(uri, |file_id, _q| file_id));
7772
7773        assert_eq!(
7774            direct_id, callback_id,
7775            "file_id_for_uri and with_semantic_queries_for_uri must agree"
7776        );
7777        Ok(())
7778    }
7779
7780    #[test]
7781    fn with_semantic_queries_for_uri_callback_not_called_when_not_indexed()
7782    -> Result<(), Box<dyn std::error::Error>> {
7783        let index = WorkspaceIndex::new();
7784        let mut called = false;
7785        let _ = index.with_semantic_queries_for_uri("file:///ghost.pl", |_, _| {
7786            called = true;
7787        });
7788        assert!(!called, "callback must not be invoked for unindexed URI");
7789        Ok(())
7790    }
7791
7792    // Covers lines 4140-4144: NodeKind::NestedVariableList arm in visit_node.
7793    // Indexing a file that produces a NestedVariableList in the AST ensures the
7794    // workspace indexer recurses into it to discover nested-declared variables.
7795    #[test]
7796    fn visit_node_nested_variable_list_is_indexed() -> Result<(), Box<dyn std::error::Error>> {
7797        let index = WorkspaceIndex::new();
7798        let uri = "file:///lib/Nested.pm";
7799        let code = r#"package Nested;
7800my ($a, ($b, $c)) = (1, (2, 3));
78011;
7802"#;
7803        must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
7804        // Verify indexing completed without error (the NestedVariableList arm was traversed).
7805        let symbols = index.file_symbols(uri);
7806        // The nested declaration may or may not surface individual symbols depending on
7807        // the indexer; the key invariant is that the file was successfully indexed.
7808        let _ = symbols;
7809        Ok(())
7810    }
7811}
7812
7813// ── Entity ID file-scoped collision tests (#1600) ──
7814
7815#[cfg(test)]
7816mod entity_id_file_scoped_tests {
7817    use super::*;
7818    use crate::semantic::queries::SemanticQueries;
7819    use perl_tdd_support::must;
7820
7821    /// Test A: IDs remain stable across re-parse of identical content in the same file.
7822    /// After fix, ID stability within a file should be maintained because file_id is constant.
7823    #[test]
7824    fn semantic_anchor_id_stable_across_reparse_same_file() -> Result<(), Box<dyn std::error::Error>>
7825    {
7826        let index = WorkspaceIndex::new();
7827        let uri = "file:///lib/Example.pm";
7828        let code = "package Example;\nsub target { 1 }\n1;\n";
7829
7830        // Index the file once.
7831        must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
7832
7833        // Extract anchor_id from first index.
7834        let candidates_1 = index
7835            .with_semantic_queries_for_uri(uri, |file_id, queries| {
7836                let ctx = crate::semantic::queries::QueryContext::new(file_id, None, Some(0));
7837                queries.definitions("Example::target", &ctx)
7838            })
7839            .ok_or("missing semantic queries on first index")?;
7840        let anchor_id_1 = candidates_1
7841            .first()
7842            .map(|candidate| candidate.anchor_id)
7843            .ok_or("missing definition candidate on first index")?;
7844
7845        // Re-index with identical content.
7846        must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
7847
7848        // Extract anchor_id from second index.
7849        let candidates_2 = index
7850            .with_semantic_queries_for_uri(uri, |file_id, queries| {
7851                let ctx = crate::semantic::queries::QueryContext::new(file_id, None, Some(0));
7852                queries.definitions("Example::target", &ctx)
7853            })
7854            .ok_or("missing semantic queries on second index")?;
7855        let anchor_id_2 = candidates_2
7856            .first()
7857            .map(|candidate| candidate.anchor_id)
7858            .ok_or("missing definition candidate on second index")?;
7859
7860        // Assertion: IDs must be identical across re-index with same content.
7861        assert_eq!(
7862            anchor_id_1, anchor_id_2,
7863            "anchor ID must remain stable when re-parsing identical content in same file"
7864        );
7865        Ok(())
7866    }
7867
7868    /// Test B: Two files with identical Perl source produce distinct EntityIds and AnchorIds.
7869    /// After file-scoped identity fix, anchor_id_a != anchor_id_b even though they have
7870    /// identical qualified_name and byte offsets. Global lookup must succeed for both.
7871    ///
7872    /// Note on extraction: `definitions()` is a global query that returns candidates from
7873    /// ALL indexed files (sorted by rank, then URI). When two files have identical content,
7874    /// both files' entities match — we must filter by `semantic_anchor_wire_location_for_file`
7875    /// to find the candidate that belongs to EACH specific file, rather than taking `.first()`
7876    /// which always picks the alphabetically first file.
7877    #[test]
7878    fn semantic_anchor_ids_distinct_across_files_same_content()
7879    -> Result<(), Box<dyn std::error::Error>> {
7880        let index = WorkspaceIndex::new();
7881        let code = "package DuplicateAnchor;\nsub target { 1 }\n1;\n";
7882        let uri_a = "file:///lib/DuplicateA.pm";
7883        let uri_b = "file:///lib/DuplicateB.pm";
7884
7885        // Index both files with identical Perl source.
7886        must(index.index_file(must(url::Url::parse(uri_a)), code.to_string()));
7887        must(index.index_file(must(url::Url::parse(uri_b)), code.to_string()));
7888
7889        // Resolve file IDs for each URI.
7890        let file_id_a = index.file_id_for_uri(uri_a).ok_or("file_id_a not found")?;
7891        let file_id_b = index.file_id_for_uri(uri_b).ok_or("file_id_b not found")?;
7892
7893        // Get all definition candidates (from all files) when querying from uri_a context.
7894        let all_candidates_a = index
7895            .with_semantic_queries_for_uri(uri_a, |file_id, queries| {
7896                let ctx = crate::semantic::queries::QueryContext::new(file_id, None, Some(0));
7897                queries.definitions("DuplicateAnchor::target", &ctx)
7898            })
7899            .ok_or("with_semantic_queries_for_uri failed for uri_a")?;
7900
7901        // Find the candidate whose anchor belongs to file A (file-scoped lookup succeeds).
7902        // This disambiguates when two files have identical content and canonical names.
7903        let anchor_id_a = all_candidates_a
7904            .iter()
7905            .find_map(|c| {
7906                index
7907                    .semantic_anchor_wire_location_for_file(file_id_a, c.anchor_id)
7908                    .map(|_| c.anchor_id)
7909            })
7910            .ok_or("no definition candidate found for file A")?;
7911
7912        // Get all definition candidates when querying from uri_b context.
7913        let all_candidates_b = index
7914            .with_semantic_queries_for_uri(uri_b, |file_id, queries| {
7915                let ctx = crate::semantic::queries::QueryContext::new(file_id, None, Some(0));
7916                queries.definitions("DuplicateAnchor::target", &ctx)
7917            })
7918            .ok_or("with_semantic_queries_for_uri failed for uri_b")?;
7919
7920        // Find the candidate whose anchor belongs to file B.
7921        let anchor_id_b = all_candidates_b
7922            .iter()
7923            .find_map(|c| {
7924                index
7925                    .semantic_anchor_wire_location_for_file(file_id_b, c.anchor_id)
7926                    .map(|_| c.anchor_id)
7927            })
7928            .ok_or("no definition candidate found for file B")?;
7929
7930        // Assertion 1: File IDs must be distinct.
7931        assert_ne!(
7932            file_id_a, file_id_b,
7933            "file_id_a and file_id_b must be distinct for different URIs"
7934        );
7935
7936        // Assertion 2: Anchor IDs must be distinct across files.
7937        assert_ne!(
7938            anchor_id_a, anchor_id_b,
7939            "anchor IDs must be distinct for identical code in different files (after file-scoped fix)"
7940        );
7941
7942        // Assertion 3: Global lookup must succeed for anchor from file A.
7943        let location_a = index
7944            .semantic_anchor_wire_location(anchor_id_a)
7945            .ok_or("global lookup of anchor_id_a should succeed post-fix")?;
7946        assert_eq!(location_a.uri, uri_a, "global lookup of anchor_id_a must return uri_a");
7947
7948        // Assertion 4: Global lookup must succeed for anchor from file B.
7949        let location_b = index
7950            .semantic_anchor_wire_location(anchor_id_b)
7951            .ok_or("global lookup of anchor_id_b should succeed post-fix")?;
7952        assert_eq!(location_b.uri, uri_b, "global lookup of anchor_id_b must return uri_b");
7953
7954        // Assertion 5: File-scoped lookup must work for both.
7955        let location_a_scoped = index
7956            .semantic_anchor_wire_location_for_file(file_id_a, anchor_id_a)
7957            .ok_or("file-scoped lookup for (file_id_a, anchor_id_a) should succeed")?;
7958        assert_eq!(
7959            location_a_scoped.uri, uri_a,
7960            "file-scoped lookup of anchor_id_a from file_id_a must return uri_a"
7961        );
7962
7963        let location_b_scoped = index
7964            .semantic_anchor_wire_location_for_file(file_id_b, anchor_id_b)
7965            .ok_or("file-scoped lookup for (file_id_b, anchor_id_b) should succeed")?;
7966        assert_eq!(
7967            location_b_scoped.uri, uri_b,
7968            "file-scoped lookup of anchor_id_b from file_id_b must return uri_b"
7969        );
7970
7971        Ok(())
7972    }
7973
7974    /// Test C: Defense-in-depth — manually injected colliding AnchorIds still fail closed.
7975    /// This test verifies that the fail-closed guard at workspace_index.rs:2659 remains
7976    /// a valid defense mechanism even after the fix, in case a bug allows collisions.
7977    /// Marked #[ignore] pending a synthetic anchor injection API (follow-up issue).
7978    #[test]
7979    #[ignore]
7980    fn semantic_anchor_wire_location_fails_closed_for_manually_injected_duplicate()
7981    -> Result<(), Box<dyn std::error::Error>> {
7982        // TODO: This test requires a way to manually inject FileFactShards with
7983        // colliding AnchorIds, bypassing the normal stable_id() call path.
7984        // Requires either:
7985        // - A test-only inject API on WorkspaceIndex, or
7986        // - Direct access to modify the internal shard map.
7987        // For now, this is deferred as an enhancement to the test harness.
7988        // The normal operation case (Test B) ensures collisions don't occur in production.
7989        // The fail-closed path at line 2659 will be exercised only if a future bug
7990        // creates a collision despite the file_id inclusion.
7991        Err(std::io::Error::new(
7992            std::io::ErrorKind::Unsupported,
7993            "synthetic anchor injection API not yet available",
7994        )
7995        .into())
7996    }
7997}
7998
7999// ── FileFactShard serde round-trip (Campaign 31 PR 5, perl-lsp-swarm#2592) ──
8000
8001#[cfg(test)]
8002mod file_fact_shard_serde_tests {
8003    use super::*;
8004
8005    #[test]
8006    fn file_fact_shard_serializes_and_deserializes_round_trip() {
8007        let shard = FileFactShard {
8008            source_uri: "file:///lib/My/App.pm".to_string(),
8009            file_id: FileId(42),
8010            content_hash: 12345,
8011            producer_schema_version: 1,
8012            anchors_hash: Some(100),
8013            entities_hash: Some(200),
8014            occurrences_hash: None,
8015            edges_hash: None,
8016            anchors: vec![],
8017            entities: vec![],
8018            occurrences: vec![],
8019            edges: vec![],
8020        };
8021        let json = serde_json::to_string(&shard).expect("FileFactShard must serialize");
8022        let deserialized: FileFactShard =
8023            serde_json::from_str(&json).expect("FileFactShard must deserialize");
8024
8025        assert_eq!(deserialized.source_uri, shard.source_uri);
8026        assert_eq!(deserialized.file_id, shard.file_id);
8027        assert_eq!(deserialized.content_hash, shard.content_hash);
8028        assert_eq!(deserialized.producer_schema_version, shard.producer_schema_version);
8029        assert_eq!(deserialized.anchors_hash, shard.anchors_hash);
8030        assert_eq!(deserialized.anchors.len(), 0);
8031    }
8032
8033    #[test]
8034    fn file_fact_shard_with_facts_serializes() {
8035        let shard = FileFactShard {
8036            source_uri: "file:///t/app.t".to_string(),
8037            file_id: FileId(7),
8038            content_hash: 999,
8039            producer_schema_version: 1,
8040            anchors_hash: Some(1),
8041            entities_hash: Some(2),
8042            occurrences_hash: Some(3),
8043            edges_hash: Some(4),
8044            anchors: vec![],
8045            entities: vec![],
8046            occurrences: vec![],
8047            edges: vec![],
8048        };
8049        // Must serialize without error — the ripr-facts emitter relies on this.
8050        let json = serde_json::to_string(&shard).expect("must serialize with facts");
8051        assert!(json.contains("\"source_uri\":\"file:///t/app.t\""));
8052        assert!(json.contains("\"content_hash\":999"));
8053    }
8054}