use crate::Parser;
use crate::ast::{Node, NodeKind};
use crate::document_store::{Document, DocumentStore};
use crate::position::{Position, Range};
use crate::workspace::monitoring::IndexInstrumentation;
use parking_lot::RwLock;
use perl_position_tracking::{WireLocation, WirePosition, WireRange};
use perl_semantic_facts::{
AnchorFact, AnchorId, Confidence, EdgeFact, EntityFact, EntityId, EntityKind, FileId,
Provenance,
};
use serde::{Deserialize, Serialize};
use std::collections::hash_map::DefaultHasher;
use std::collections::{HashMap, HashSet};
use std::hash::{Hash, Hasher};
use std::path::Path;
use std::sync::Arc;
use std::time::Instant;
use url::Url;
use crate::semantic::imports::ImportExportIndex;
pub use crate::semantic::invalidation::ShardReplaceResult;
use crate::semantic::invalidation::{ShardCategoryHashes, plan_shard_replacement};
use crate::semantic::references::ReferenceIndex;
pub use crate::workspace::monitoring::{
DegradationReason, EarlyExitReason, EarlyExitRecord, IndexInstrumentationSnapshot,
IndexMetrics, IndexPerformanceCaps, IndexPhase, IndexPhaseTransition, IndexResourceLimits,
IndexStateKind, IndexStateTransition, ResourceKind,
};
use perl_symbol::surface::decl::extract_symbol_decls;
use perl_symbol::surface::facts::{symbol_decls_to_semantic_facts, symbol_refs_to_semantic_facts};
use perl_symbol::surface::r#ref::extract_symbol_refs;
#[cfg(not(target_arch = "wasm32"))]
pub use perl_uri::{fs_path_to_uri, uri_to_fs_path};
pub use perl_uri::{is_file_uri, is_special_scheme, uri_extension, uri_key};
#[derive(Clone, Debug)]
pub enum IndexState {
Building {
phase: IndexPhase,
indexed_count: usize,
total_count: usize,
started_at: Instant,
},
Ready {
symbol_count: usize,
file_count: usize,
completed_at: Instant,
},
Degraded {
reason: DegradationReason,
available_symbols: usize,
since: Instant,
},
}
impl IndexState {
pub fn kind(&self) -> IndexStateKind {
match self {
IndexState::Building { .. } => IndexStateKind::Building,
IndexState::Ready { .. } => IndexStateKind::Ready,
IndexState::Degraded { .. } => IndexStateKind::Degraded,
}
}
pub fn phase(&self) -> Option<IndexPhase> {
match self {
IndexState::Building { phase, .. } => Some(*phase),
_ => None,
}
}
pub fn state_started_at(&self) -> Instant {
match self {
IndexState::Building { started_at, .. } => *started_at,
IndexState::Ready { completed_at, .. } => *completed_at,
IndexState::Degraded { since, .. } => *since,
}
}
}
pub struct IndexCoordinator {
state: Arc<RwLock<IndexState>>,
index: Arc<WorkspaceIndex>,
limits: IndexResourceLimits,
caps: IndexPerformanceCaps,
metrics: IndexMetrics,
instrumentation: IndexInstrumentation,
}
impl std::fmt::Debug for IndexCoordinator {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("IndexCoordinator")
.field("state", &*self.state.read())
.field("limits", &self.limits)
.field("caps", &self.caps)
.finish_non_exhaustive()
}
}
impl IndexCoordinator {
pub fn new() -> Self {
Self {
state: Arc::new(RwLock::new(IndexState::Building {
phase: IndexPhase::Idle,
indexed_count: 0,
total_count: 0,
started_at: Instant::now(),
})),
index: Arc::new(WorkspaceIndex::new()),
limits: IndexResourceLimits::default(),
caps: IndexPerformanceCaps::default(),
metrics: IndexMetrics::new(),
instrumentation: IndexInstrumentation::new(),
}
}
pub fn with_limits(limits: IndexResourceLimits) -> Self {
Self {
state: Arc::new(RwLock::new(IndexState::Building {
phase: IndexPhase::Idle,
indexed_count: 0,
total_count: 0,
started_at: Instant::now(),
})),
index: Arc::new(WorkspaceIndex::new()),
limits,
caps: IndexPerformanceCaps::default(),
metrics: IndexMetrics::new(),
instrumentation: IndexInstrumentation::new(),
}
}
pub fn with_limits_and_caps(limits: IndexResourceLimits, caps: IndexPerformanceCaps) -> Self {
Self {
state: Arc::new(RwLock::new(IndexState::Building {
phase: IndexPhase::Idle,
indexed_count: 0,
total_count: 0,
started_at: Instant::now(),
})),
index: Arc::new(WorkspaceIndex::new()),
limits,
caps,
metrics: IndexMetrics::new(),
instrumentation: IndexInstrumentation::new(),
}
}
pub fn state(&self) -> IndexState {
self.state.read().clone()
}
pub fn index(&self) -> &Arc<WorkspaceIndex> {
&self.index
}
pub fn limits(&self) -> &IndexResourceLimits {
&self.limits
}
pub fn performance_caps(&self) -> &IndexPerformanceCaps {
&self.caps
}
pub fn instrumentation_snapshot(&self) -> IndexInstrumentationSnapshot {
self.instrumentation.snapshot()
}
pub fn notify_change(&self, _uri: &str) {
let pending = self.metrics.increment_pending_parses();
if self.metrics.is_parse_storm() {
self.transition_to_degraded(DegradationReason::ParseStorm { pending_parses: pending });
}
}
pub fn notify_parse_complete(&self, _uri: &str) {
let pending = self.metrics.decrement_pending_parses();
if pending == 0 {
if let IndexState::Degraded { reason: DegradationReason::ParseStorm { .. }, .. } =
self.state()
{
let mut state = self.state.write();
let from_kind = state.kind();
self.instrumentation.record_state_transition(from_kind, IndexStateKind::Building);
*state = IndexState::Building {
phase: IndexPhase::Idle,
indexed_count: 0,
total_count: 0,
started_at: Instant::now(),
};
}
}
self.enforce_limits();
}
pub fn transition_to_ready(&self, file_count: usize, symbol_count: usize) {
let mut state = self.state.write();
let from_kind = state.kind();
match &*state {
IndexState::Building { .. } | IndexState::Degraded { .. } => {
*state =
IndexState::Ready { symbol_count, file_count, completed_at: Instant::now() };
}
IndexState::Ready { .. } => {
*state =
IndexState::Ready { symbol_count, file_count, completed_at: Instant::now() };
}
}
self.instrumentation.record_state_transition(from_kind, IndexStateKind::Ready);
drop(state);
self.enforce_limits();
}
pub fn transition_to_scanning(&self) {
let mut state = self.state.write();
let from_kind = state.kind();
match &*state {
IndexState::Building { phase, indexed_count, total_count, started_at } => {
if *phase != IndexPhase::Scanning {
self.instrumentation.record_phase_transition(*phase, IndexPhase::Scanning);
}
*state = IndexState::Building {
phase: IndexPhase::Scanning,
indexed_count: *indexed_count,
total_count: *total_count,
started_at: *started_at,
};
}
IndexState::Ready { .. } | IndexState::Degraded { .. } => {
self.instrumentation.record_state_transition(from_kind, IndexStateKind::Building);
self.instrumentation
.record_phase_transition(IndexPhase::Idle, IndexPhase::Scanning);
*state = IndexState::Building {
phase: IndexPhase::Scanning,
indexed_count: 0,
total_count: 0,
started_at: Instant::now(),
};
}
}
}
pub fn update_scan_progress(&self, total_count: usize) {
let mut state = self.state.write();
if let IndexState::Building { phase, indexed_count, started_at, .. } = &*state {
if *phase != IndexPhase::Scanning {
self.instrumentation.record_phase_transition(*phase, IndexPhase::Scanning);
}
*state = IndexState::Building {
phase: IndexPhase::Scanning,
indexed_count: *indexed_count,
total_count,
started_at: *started_at,
};
}
}
pub fn transition_to_indexing(&self, total_count: usize) {
let mut state = self.state.write();
let from_kind = state.kind();
match &*state {
IndexState::Building { phase, indexed_count, started_at, .. } => {
if *phase != IndexPhase::Indexing {
self.instrumentation.record_phase_transition(*phase, IndexPhase::Indexing);
}
*state = IndexState::Building {
phase: IndexPhase::Indexing,
indexed_count: *indexed_count,
total_count,
started_at: *started_at,
};
}
IndexState::Ready { .. } | IndexState::Degraded { .. } => {
self.instrumentation.record_state_transition(from_kind, IndexStateKind::Building);
self.instrumentation
.record_phase_transition(IndexPhase::Idle, IndexPhase::Indexing);
*state = IndexState::Building {
phase: IndexPhase::Indexing,
indexed_count: 0,
total_count,
started_at: Instant::now(),
};
}
}
}
pub fn transition_to_building(&self, total_count: usize) {
let mut state = self.state.write();
let from_kind = state.kind();
match &*state {
IndexState::Degraded { .. } | IndexState::Ready { .. } => {
self.instrumentation.record_state_transition(from_kind, IndexStateKind::Building);
self.instrumentation
.record_phase_transition(IndexPhase::Idle, IndexPhase::Indexing);
*state = IndexState::Building {
phase: IndexPhase::Indexing,
indexed_count: 0,
total_count,
started_at: Instant::now(),
};
}
IndexState::Building { phase, indexed_count, started_at, .. } => {
let mut next_phase = *phase;
if *phase == IndexPhase::Idle {
self.instrumentation
.record_phase_transition(IndexPhase::Idle, IndexPhase::Indexing);
next_phase = IndexPhase::Indexing;
}
*state = IndexState::Building {
phase: next_phase,
indexed_count: *indexed_count,
total_count,
started_at: *started_at,
};
}
}
}
pub fn update_building_progress(&self, indexed_count: usize) {
let mut state = self.state.write();
if let IndexState::Building { phase, started_at, total_count, .. } = &*state {
let elapsed = started_at.elapsed().as_millis() as u64;
if elapsed > self.limits.max_scan_duration_ms {
drop(state);
self.transition_to_degraded(DegradationReason::ScanTimeout { elapsed_ms: elapsed });
return;
}
*state = IndexState::Building {
phase: *phase,
indexed_count,
total_count: *total_count,
started_at: *started_at,
};
}
}
pub fn transition_to_degraded(&self, reason: DegradationReason) {
let mut state = self.state.write();
let from_kind = state.kind();
let available_symbols = match &*state {
IndexState::Ready { symbol_count, .. } => *symbol_count,
IndexState::Degraded { available_symbols, .. } => *available_symbols,
IndexState::Building { .. } => 0,
};
self.instrumentation.record_state_transition(from_kind, IndexStateKind::Degraded);
*state = IndexState::Degraded { reason, available_symbols, since: Instant::now() };
}
pub fn check_limits(&self) -> Option<DegradationReason> {
let files = self.index.files.read();
let file_count = files.len();
if file_count > self.limits.max_files {
return Some(DegradationReason::ResourceLimit { kind: ResourceKind::MaxFiles });
}
let total_symbols: usize = files.values().map(|fi| fi.symbols.len()).sum();
if total_symbols > self.limits.max_total_symbols {
return Some(DegradationReason::ResourceLimit { kind: ResourceKind::MaxSymbols });
}
None
}
pub fn enforce_limits(&self) {
if let Some(reason) = self.check_limits() {
self.transition_to_degraded(reason);
}
}
pub fn record_early_exit(
&self,
reason: EarlyExitReason,
elapsed_ms: u64,
indexed_files: usize,
total_files: usize,
) {
self.instrumentation.record_early_exit(EarlyExitRecord {
reason,
elapsed_ms,
indexed_files,
total_files,
});
}
pub fn query<T, F1, F2>(&self, full_query: F1, partial_query: F2) -> T
where
F1: FnOnce(&WorkspaceIndex) -> T,
F2: FnOnce(&WorkspaceIndex) -> T,
{
match self.state() {
IndexState::Ready { .. } => full_query(&self.index),
_ => partial_query(&self.index),
}
}
}
impl Default for IndexCoordinator {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub enum SymKind {
Var,
Sub,
Pack,
}
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
pub struct SymbolKey {
pub pkg: Arc<str>,
pub name: Arc<str>,
pub sigil: Option<char>,
pub kind: SymKind,
}
pub fn normalize_var(name: &str) -> (Option<char>, &str) {
if name.is_empty() {
return (None, "");
}
let Some(first_char) = name.chars().next() else {
return (None, name); };
match first_char {
'$' | '@' | '%' => {
if name.len() > 1 {
(Some(first_char), &name[1..])
} else {
(Some(first_char), "")
}
}
_ => (None, name),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Location {
pub uri: String,
pub range: Range,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SymbolIdentity {
pub stable_key: String,
pub name: String,
pub qualified_name: Option<String>,
pub kind: SymbolKind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CrossFileReferenceQueryResult {
pub symbol: SymbolIdentity,
pub definition: Location,
pub references: Vec<Location>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceSymbol {
pub name: String,
pub kind: SymbolKind,
pub uri: String,
pub range: Range,
pub qualified_name: Option<String>,
pub documentation: Option<String>,
pub container_name: Option<String>,
#[serde(default = "default_has_body")]
pub has_body: bool,
pub workspace_folder_uri: Option<String>,
}
fn default_has_body() -> bool {
true
}
pub use perl_symbol::{SymbolKind, VarKind};
#[derive(Debug, Clone)]
pub struct SymbolReference {
pub uri: String,
pub range: Range,
pub kind: ReferenceKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReferenceKind {
Definition,
Usage,
Import,
Read,
Write,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LspWorkspaceSymbol {
pub name: String,
pub kind: u32,
pub location: WireLocation,
#[serde(skip_serializing_if = "Option::is_none")]
pub container_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub workspace_folder_uri: Option<String>,
}
impl From<&WorkspaceSymbol> for LspWorkspaceSymbol {
fn from(sym: &WorkspaceSymbol) -> Self {
let range = WireRange {
start: WirePosition { line: sym.range.start.line, character: sym.range.start.column },
end: WirePosition { line: sym.range.end.line, character: sym.range.end.column },
};
Self {
name: sym.name.clone(),
kind: sym.kind.to_lsp_kind(),
location: WireLocation { uri: sym.uri.clone(), range },
container_name: sym.container_name.clone(),
workspace_folder_uri: sym.workspace_folder_uri.clone(),
}
}
}
#[derive(Default, Clone)]
pub struct FileIndex {
source_uri: String,
symbols: Vec<WorkspaceSymbol>,
references: HashMap<String, Vec<SymbolReference>>,
dependencies: HashSet<String>,
content_hash: u64,
folder_uri: Option<String>,
}
#[derive(Clone, Debug)]
pub struct FileFactShard {
pub source_uri: String,
pub file_id: FileId,
pub content_hash: u64,
pub anchors_hash: Option<u64>,
pub entities_hash: Option<u64>,
pub occurrences_hash: Option<u64>,
pub edges_hash: Option<u64>,
pub anchors: Vec<AnchorFact>,
pub entities: Vec<EntityFact>,
pub occurrences: Vec<perl_semantic_facts::OccurrenceFact>,
pub edges: Vec<EdgeFact>,
}
pub struct WorkspaceIndex {
files: Arc<RwLock<HashMap<String, FileIndex>>>,
symbols: Arc<RwLock<HashMap<String, Vec<DefinitionCandidate>>>>,
global_references: Arc<RwLock<HashMap<String, Vec<Location>>>>,
fact_shards: Arc<RwLock<HashMap<String, FileFactShard>>>,
semantic_reference_index: Arc<RwLock<ReferenceIndex>>,
semantic_import_export_index: Arc<RwLock<ImportExportIndex>>,
document_store: DocumentStore,
workspace_folders: Arc<RwLock<Vec<String>>>,
}
#[derive(Debug, Clone, Eq, PartialEq)]
struct DefinitionCandidate {
location: Location,
kind: SymbolKind,
}
impl WorkspaceIndex {
fn location_sort_key(location: &Location) -> (&str, u32, u32, u32, u32) {
(
location.uri.as_str(),
location.range.start.line,
location.range.start.column,
location.range.end.line,
location.range.end.column,
)
}
fn sort_locations_deterministically(locations: &mut [Location]) {
locations.sort_by(|left, right| {
Self::location_sort_key(left).cmp(&Self::location_sort_key(right))
});
}
fn definition_candidate_sort_key(
candidate: &DefinitionCandidate,
) -> (u8, &str, u32, u32, u32, u32) {
let rank = match candidate.kind {
SymbolKind::Subroutine | SymbolKind::Method => 0,
SymbolKind::Constant => 1,
_ => 2,
};
(
rank,
candidate.location.uri.as_str(),
candidate.location.range.start.line,
candidate.location.range.start.column,
candidate.location.range.end.line,
candidate.location.range.end.column,
)
}
fn rebuild_symbol_cache(
files: &HashMap<String, FileIndex>,
symbols: &mut HashMap<String, Vec<DefinitionCandidate>>,
) {
symbols.clear();
for file_index in files.values() {
for symbol in &file_index.symbols {
if let Some(ref qname) = symbol.qualified_name {
symbols.entry(qname.clone()).or_default().push(DefinitionCandidate {
location: Location { uri: symbol.uri.clone(), range: symbol.range },
kind: symbol.kind,
});
}
symbols.entry(symbol.name.clone()).or_default().push(DefinitionCandidate {
location: Location { uri: symbol.uri.clone(), range: symbol.range },
kind: symbol.kind,
});
}
}
for entries in symbols.values_mut() {
entries.sort_by(|left, right| {
Self::definition_candidate_sort_key(left)
.cmp(&Self::definition_candidate_sort_key(right))
});
entries.dedup();
}
}
fn incremental_remove_symbols(
files: &HashMap<String, FileIndex>,
symbols: &mut HashMap<String, Vec<DefinitionCandidate>>,
old_file_index: &FileIndex,
) {
let mut affected_names: Vec<String> = Vec::new();
for sym in &old_file_index.symbols {
if let Some(ref qname) = sym.qualified_name {
let mut remove_key = false;
if let Some(entries) = symbols.get_mut(qname) {
entries.retain(|candidate| candidate.location.uri != sym.uri);
remove_key = entries.is_empty();
}
if remove_key {
symbols.remove(qname);
affected_names.push(qname.clone());
}
}
let mut remove_key = false;
if let Some(entries) = symbols.get_mut(&sym.name) {
entries.retain(|candidate| candidate.location.uri != sym.uri);
remove_key = entries.is_empty();
}
if remove_key {
symbols.remove(&sym.name);
affected_names.push(sym.name.clone());
}
}
if !affected_names.is_empty() {
symbols.clear();
for file_index in files
.values()
.filter(|file_index| file_index.source_uri != old_file_index.source_uri)
{
for symbol in &file_index.symbols {
if let Some(ref qname) = symbol.qualified_name {
symbols.entry(qname.clone()).or_default().push(DefinitionCandidate {
location: Location { uri: symbol.uri.clone(), range: symbol.range },
kind: symbol.kind,
});
}
symbols.entry(symbol.name.clone()).or_default().push(DefinitionCandidate {
location: Location { uri: symbol.uri.clone(), range: symbol.range },
kind: symbol.kind,
});
}
}
for entries in symbols.values_mut() {
entries.sort_by(|left, right| {
Self::definition_candidate_sort_key(left)
.cmp(&Self::definition_candidate_sort_key(right))
});
entries.dedup();
}
}
}
fn incremental_add_symbols(
symbols: &mut HashMap<String, Vec<DefinitionCandidate>>,
file_index: &FileIndex,
) {
for sym in &file_index.symbols {
if let Some(ref qname) = sym.qualified_name {
symbols.entry(qname.clone()).or_default().push(DefinitionCandidate {
location: Location { uri: sym.uri.clone(), range: sym.range },
kind: sym.kind,
});
}
symbols.entry(sym.name.clone()).or_default().push(DefinitionCandidate {
location: Location { uri: sym.uri.clone(), range: sym.range },
kind: sym.kind,
});
}
for entries in symbols.values_mut() {
entries.sort_by(|left, right| {
Self::definition_candidate_sort_key(left)
.cmp(&Self::definition_candidate_sort_key(right))
});
entries.dedup();
}
}
fn determine_folder_uri(&self, file_uri: &str) -> Option<String> {
let folders = self.workspace_folders.read();
let mut best_match: Option<&String> = None;
for folder_uri in folders.iter() {
let folder_with_slash = if folder_uri.ends_with('/') {
folder_uri.clone()
} else {
format!("{}/", folder_uri)
};
if file_uri.starts_with(&folder_with_slash) || file_uri == folder_uri {
match best_match {
Some(existing) if existing.len() >= folder_uri.len() => {}
_ => best_match = Some(folder_uri),
}
}
}
best_match.cloned()
}
fn find_definition_in_files(
files: &HashMap<String, FileIndex>,
symbol_name: &str,
uri_filter: Option<&str>,
) -> Option<(Location, String)> {
let mut candidates: Vec<(Location, String)> = Vec::new();
for file_index in files.values() {
if let Some(filter) = uri_filter
&& file_index.symbols.first().is_some_and(|symbol| symbol.uri != filter)
{
continue;
}
for symbol in &file_index.symbols {
if symbol.name == symbol_name
|| symbol.qualified_name.as_deref() == Some(symbol_name)
{
candidates.push((
Location { uri: symbol.uri.clone(), range: symbol.range },
symbol.uri.clone(),
));
}
}
}
candidates.sort_by(|left, right| {
Self::location_sort_key(&left.0).cmp(&Self::location_sort_key(&right.0))
});
candidates.into_iter().next()
}
fn find_symbol_by_definition(
&self,
definition: &Location,
symbol_name: &str,
) -> Option<WorkspaceSymbol> {
let files = self.files.read();
files
.values()
.flat_map(|file_index| file_index.symbols.iter())
.filter(|symbol| {
symbol.uri == definition.uri
&& symbol.range == definition.range
&& (symbol.name == symbol_name
|| symbol.qualified_name.as_deref() == Some(symbol_name))
})
.min_by(|left, right| {
(
left.qualified_name.as_deref().unwrap_or_default(),
left.name.as_str(),
left.kind.to_lsp_kind(),
)
.cmp(&(
right.qualified_name.as_deref().unwrap_or_default(),
right.name.as_str(),
right.kind.to_lsp_kind(),
))
})
.cloned()
}
fn has_unique_symbol_name_and_kind(&self, target: &WorkspaceSymbol) -> bool {
let files = self.files.read();
files
.values()
.flat_map(|file_index| file_index.symbols.iter())
.filter(|symbol| symbol.name == target.name && symbol.kind == target.kind)
.take(2)
.count()
== 1
}
fn collect_symbol_references(&self, symbol: &WorkspaceSymbol) -> Vec<Location> {
let mut names_to_query: Vec<&str> = Vec::new();
if let Some(qualified_name) = symbol.qualified_name.as_deref() {
names_to_query.push(qualified_name);
if self.has_unique_symbol_name_and_kind(symbol) {
names_to_query.push(symbol.name.as_str());
}
} else {
names_to_query.push(symbol.name.as_str());
}
let global_refs = self.global_references.read();
let mut seen: HashSet<(String, u32, u32, u32, u32)> = HashSet::new();
let mut locations = Vec::new();
for symbol_name in names_to_query {
if let Some(refs) = global_refs.get(symbol_name) {
for location in refs {
let key = (
location.uri.clone(),
location.range.start.line,
location.range.start.column,
location.range.end.line,
location.range.end.column,
);
if seen.insert(key) {
locations.push(location.clone());
}
}
}
}
drop(global_refs);
Self::sort_locations_deterministically(&mut locations);
locations
}
pub fn new() -> Self {
Self {
files: Arc::new(RwLock::new(HashMap::new())),
symbols: Arc::new(RwLock::new(HashMap::new())),
global_references: Arc::new(RwLock::new(HashMap::new())),
fact_shards: Arc::new(RwLock::new(HashMap::new())),
semantic_reference_index: Arc::new(RwLock::new(ReferenceIndex::new())),
semantic_import_export_index: Arc::new(RwLock::new(ImportExportIndex::new())),
document_store: DocumentStore::new(),
workspace_folders: Arc::new(RwLock::new(Vec::new())),
}
}
pub fn with_capacity(estimated_files: usize, avg_symbols_per_file: usize) -> Self {
let sym_cap =
estimated_files.saturating_mul(avg_symbols_per_file).saturating_mul(2).min(1_000_000);
let ref_cap = (sym_cap / 4).min(1_000_000);
Self {
files: Arc::new(RwLock::new(HashMap::with_capacity(estimated_files))),
symbols: Arc::new(RwLock::new(HashMap::with_capacity(sym_cap))),
global_references: Arc::new(RwLock::new(HashMap::with_capacity(ref_cap))),
fact_shards: Arc::new(RwLock::new(HashMap::with_capacity(estimated_files))),
semantic_reference_index: Arc::new(RwLock::new(ReferenceIndex::new())),
semantic_import_export_index: Arc::new(RwLock::new(ImportExportIndex::new())),
document_store: DocumentStore::new(),
workspace_folders: Arc::new(RwLock::new(Vec::new())),
}
}
pub fn set_workspace_folders(&self, folders: Vec<String>) {
let mut workspace_folders = self.workspace_folders.write();
*workspace_folders = folders;
}
#[must_use]
pub fn workspace_folders(&self) -> Vec<String> {
self.workspace_folders.read().clone()
}
fn normalize_uri(uri: &str) -> String {
perl_uri::normalize_uri(uri)
}
fn remove_file_global_refs(
global_refs: &mut HashMap<String, Vec<Location>>,
file_index: &FileIndex,
file_uri: &str,
) {
for name in file_index.references.keys() {
if let Some(locs) = global_refs.get_mut(name) {
locs.retain(|loc| loc.uri != file_uri);
if locs.is_empty() {
global_refs.remove(name);
}
}
}
}
pub fn index_file(&self, uri: Url, text: String) -> Result<(), String> {
let uri_str = uri.to_string();
let mut hasher = DefaultHasher::new();
text.hash(&mut hasher);
let content_hash = hasher.finish();
let key = DocumentStore::uri_key(&uri_str);
{
let files = self.files.read();
if let Some(existing_index) = files.get(&key) {
if existing_index.content_hash == content_hash {
return Ok(());
}
}
}
if self.document_store.is_open(&uri_str) {
self.document_store.update(&uri_str, 1, text.clone());
} else {
self.document_store.open(uri_str.clone(), 1, text.clone());
}
let mut parser = Parser::new(&text);
let ast = match parser.parse() {
Ok(ast) => ast,
Err(e) => return Err(format!("Parse error: {}", e)),
};
let mut doc = self.document_store.get(&uri_str).ok_or("Document not found")?;
let folder_uri = self.determine_folder_uri(&uri_str);
let mut file_index = FileIndex {
source_uri: uri_str.clone(),
content_hash,
folder_uri: folder_uri.clone(),
..Default::default()
};
let mut visitor = IndexVisitor::new(&mut doc, uri_str.clone(), folder_uri);
visitor.visit(&ast, &mut file_index);
let canonical_shard =
Self::build_canonical_fact_shard_for_ast(&uri_str, content_hash, &ast);
let fact_shard = if canonical_shard.anchors.is_empty()
&& canonical_shard.entities.is_empty()
&& canonical_shard.occurrences.is_empty()
&& canonical_shard.edges.is_empty()
{
Self::build_fact_shard(&uri_str, content_hash, &file_index)
} else {
canonical_shard
};
{
let mut files = self.files.write();
if let Some(old_index) = files.get(&key) {
let mut global_refs = self.global_references.write();
Self::remove_file_global_refs(&mut global_refs, old_index, &uri_str);
}
if let Some(old_index) = files.get(&key) {
let mut symbols = self.symbols.write();
Self::incremental_remove_symbols(&files, &mut symbols, old_index);
drop(symbols);
}
files.insert(key.clone(), file_index);
let mut symbols = self.symbols.write();
if let Some(new_index) = files.get(&key) {
Self::incremental_add_symbols(&mut symbols, new_index);
}
if let Some(file_index) = files.get(&key) {
let mut global_refs = self.global_references.write();
for (name, refs) in &file_index.references {
let entry = global_refs.entry(name.clone()).or_default();
for reference in refs {
entry.push(Location { uri: reference.uri.clone(), range: reference.range });
}
}
}
self.replace_fact_shard_incremental(&key, fact_shard);
}
Ok(())
}
pub fn remove_file(&self, uri: &str) {
let uri_str = Self::normalize_uri(uri);
let key = DocumentStore::uri_key(&uri_str);
self.document_store.close(&uri_str);
let mut files = self.files.write();
if let Some(file_index) = files.remove(&key) {
self.fact_shards.write().remove(&key);
self.semantic_reference_index.write().remove_file(&uri_str);
{
let mut ie_idx = self.semantic_import_export_index.write();
ie_idx.remove_file_imports(&uri_str);
ie_idx.remove_module_exports(&uri_str);
}
let mut symbols = self.symbols.write();
Self::incremental_remove_symbols(&files, &mut symbols, &file_index);
if let Some(indexed_uri) = file_index.symbols.first().map(|s| s.uri.as_str()) {
symbols.retain(|_, candidates| {
candidates.retain(|candidate| candidate.location.uri.as_str() != indexed_uri);
!candidates.is_empty()
});
}
let mut global_refs = self.global_references.write();
Self::remove_file_global_refs(&mut global_refs, &file_index, &uri_str);
}
}
pub fn remove_file_url(&self, uri: &Url) {
self.remove_file(uri.as_str())
}
pub fn clear_file(&self, uri: &str) {
self.remove_file(uri);
}
pub fn clear_file_url(&self, uri: &Url) {
self.clear_file(uri.as_str())
}
pub fn remove_folder(&self, folder_uri: &str) {
let mut uris_to_remove = Vec::new();
let files = self.files.read();
for file_index in files.values() {
if file_index.folder_uri.as_deref() == Some(folder_uri) {
uris_to_remove.push(file_index.source_uri.clone());
}
}
drop(files);
for uri in uris_to_remove {
self.remove_file(&uri);
}
}
#[cfg(not(target_arch = "wasm32"))]
pub fn index_file_str(&self, uri: &str, text: &str) -> Result<(), String> {
let path = Path::new(uri);
let url = if path.is_absolute() {
url::Url::from_file_path(path)
.map_err(|_| format!("Invalid URI or file path: {}", uri))?
} else {
url::Url::parse(uri).or_else(|_| {
url::Url::from_file_path(path)
.map_err(|_| format!("Invalid URI or file path: {}", uri))
})?
};
self.index_file(url, text.to_string())
}
pub fn index_files_batch(&self, files_to_index: Vec<(Url, String)>) -> Vec<String> {
let mut errors = Vec::new();
let mut parsed: Vec<(String, String, FileIndex)> = Vec::with_capacity(files_to_index.len());
for (uri, text) in &files_to_index {
let uri_str = uri.to_string();
let mut hasher = DefaultHasher::new();
text.hash(&mut hasher);
let content_hash = hasher.finish();
let key = DocumentStore::uri_key(&uri_str);
{
let files = self.files.read();
if let Some(existing) = files.get(&key) {
if existing.content_hash == content_hash {
continue;
}
}
}
if self.document_store.is_open(&uri_str) {
self.document_store.update(&uri_str, 1, text.clone());
} else {
self.document_store.open(uri_str.clone(), 1, text.clone());
}
let mut parser = Parser::new(text);
let ast = match parser.parse() {
Ok(ast) => ast,
Err(e) => {
errors.push(format!("Parse error in {}: {}", uri_str, e));
continue;
}
};
let mut doc = match self.document_store.get(&uri_str) {
Some(d) => d,
None => {
errors.push(format!("Document not found: {}", uri_str));
continue;
}
};
let folder_uri = self.determine_folder_uri(&uri_str);
let mut file_index = FileIndex {
source_uri: uri_str.clone(),
content_hash,
folder_uri: folder_uri.clone(),
..Default::default()
};
let mut visitor = IndexVisitor::new(&mut doc, uri_str.clone(), folder_uri);
visitor.visit(&ast, &mut file_index);
parsed.push((key, uri_str, file_index));
}
{
let mut files = self.files.write();
let mut symbols = self.symbols.write();
let mut global_refs = self.global_references.write();
files.reserve(parsed.len());
symbols.reserve(parsed.len().saturating_mul(20).saturating_mul(2));
for (key, uri_str, file_index) in parsed {
if let Some(old_index) = files.get(&key) {
Self::remove_file_global_refs(&mut global_refs, old_index, &uri_str);
}
files.insert(key.clone(), file_index);
if let Some(fi) = files.get(&key) {
for (name, refs) in &fi.references {
let entry = global_refs.entry(name.clone()).or_default();
for reference in refs {
entry.push(Location {
uri: reference.uri.clone(),
range: reference.range,
});
}
}
}
}
Self::rebuild_symbol_cache(&files, &mut symbols);
}
errors
}
pub fn find_references(&self, symbol_name: &str) -> Vec<Location> {
let global_refs = self.global_references.read();
let mut seen: HashSet<(String, u32, u32, u32, u32)> = HashSet::new();
let mut locations = Vec::new();
if let Some(refs) = global_refs.get(symbol_name) {
for loc in refs {
let key = (
loc.uri.clone(),
loc.range.start.line,
loc.range.start.column,
loc.range.end.line,
loc.range.end.column,
);
if seen.insert(key) {
locations.push(Location { uri: loc.uri.clone(), range: loc.range });
}
}
}
if let Some(idx) = symbol_name.rfind("::") {
let bare_name = &symbol_name[idx + 2..];
if let Some(refs) = global_refs.get(bare_name) {
for loc in refs {
let key = (
loc.uri.clone(),
loc.range.start.line,
loc.range.start.column,
loc.range.end.line,
loc.range.end.column,
);
if seen.insert(key) {
locations.push(Location { uri: loc.uri.clone(), range: loc.range });
}
}
}
} else {
for (name, refs) in global_refs.iter() {
if !Self::is_qualified_variant_of(name, symbol_name) {
continue;
}
for loc in refs {
let key = (
loc.uri.clone(),
loc.range.start.line,
loc.range.start.column,
loc.range.end.line,
loc.range.end.column,
);
if seen.insert(key) {
locations.push(Location { uri: loc.uri.clone(), range: loc.range });
}
}
}
}
Self::sort_locations_deterministically(&mut locations);
locations
}
pub fn query_symbol_references(
&self,
symbol_name: &str,
) -> Option<CrossFileReferenceQueryResult> {
let definition = self.find_definition(symbol_name)?;
let symbol = self.find_symbol_by_definition(&definition, symbol_name)?;
let stable_key = symbol.qualified_name.clone().unwrap_or_else(|| {
format!(
"{}@{}:{}:{}",
symbol.name, symbol.uri, symbol.range.start.line, symbol.range.start.column
)
});
let mut references = self.collect_symbol_references(&symbol);
if !references.iter().any(|location| location == &definition) {
references.push(definition.clone());
Self::sort_locations_deterministically(&mut references);
}
Some(CrossFileReferenceQueryResult {
symbol: SymbolIdentity {
stable_key,
name: symbol.name,
qualified_name: symbol.qualified_name,
kind: symbol.kind,
},
definition,
references,
})
}
pub fn count_usages(&self, symbol_name: &str) -> usize {
let files = self.files.read();
let mut seen: HashSet<(String, u32, u32, u32, u32)> = HashSet::new();
for (_uri_key, file_index) in files.iter() {
if let Some(refs) = file_index.references.get(symbol_name) {
for r in refs.iter().filter(|r| r.kind != ReferenceKind::Definition) {
seen.insert((
r.uri.clone(),
r.range.start.line,
r.range.start.column,
r.range.end.line,
r.range.end.column,
));
}
}
if let Some(idx) = symbol_name.rfind("::") {
let bare_name = &symbol_name[idx + 2..];
if let Some(refs) = file_index.references.get(bare_name) {
for r in refs.iter().filter(|r| r.kind != ReferenceKind::Definition) {
seen.insert((
r.uri.clone(),
r.range.start.line,
r.range.start.column,
r.range.end.line,
r.range.end.column,
));
}
}
} else {
for (name, refs) in &file_index.references {
if !Self::is_qualified_variant_of(name, symbol_name) {
continue;
}
for r in refs.iter().filter(|r| r.kind != ReferenceKind::Definition) {
seen.insert((
r.uri.clone(),
r.range.start.line,
r.range.start.column,
r.range.end.line,
r.range.end.column,
));
}
}
}
}
seen.len()
}
fn is_qualified_variant_of(candidate: &str, bare_symbol: &str) -> bool {
candidate.rsplit_once("::").is_some_and(|(_, candidate_bare)| candidate_bare == bare_symbol)
}
pub fn find_definition(&self, symbol_name: &str) -> Option<Location> {
if let Some(location) = self.definition_candidates(symbol_name).into_iter().next() {
return Some(location);
}
let files = self.files.read();
let resolved = Self::find_definition_in_files(&files, symbol_name, None);
drop(files);
if let Some((location, _uri)) = resolved {
let mut symbols = self.symbols.write();
symbols.entry(symbol_name.to_string()).or_default().push(DefinitionCandidate {
location: location.clone(),
kind: SymbolKind::Subroutine,
});
if let Some(candidates) = symbols.get_mut(symbol_name) {
candidates.sort_by(|left, right| {
Self::definition_candidate_sort_key(left)
.cmp(&Self::definition_candidate_sort_key(right))
});
candidates.dedup();
}
return Some(location);
}
None
}
pub(crate) fn definition_candidates(&self, symbol_name: &str) -> Vec<Location> {
let symbols = self.symbols.read();
symbols
.get(symbol_name)
.map(|candidates| {
candidates.iter().map(|candidate| candidate.location.clone()).collect()
})
.unwrap_or_default()
}
pub fn all_symbols(&self) -> Vec<WorkspaceSymbol> {
let files = self.files.read();
let mut symbols = Vec::new();
for (_uri_key, file_index) in files.iter() {
symbols.extend(file_index.symbols.clone());
}
symbols
}
pub fn clear(&self) {
self.files.write().clear();
self.symbols.write().clear();
self.global_references.write().clear();
self.fact_shards.write().clear();
*self.semantic_reference_index.write() = ReferenceIndex::new();
*self.semantic_import_export_index.write() = ImportExportIndex::new();
}
fn hash_uri_to_file_id(uri: &str) -> FileId {
let mut hasher = DefaultHasher::new();
uri.hash(&mut hasher);
FileId(hasher.finish())
}
fn build_fact_shard(uri: &str, content_hash: u64, file_index: &FileIndex) -> FileFactShard {
let file_id = Self::hash_uri_to_file_id(uri);
let mut anchors = Vec::new();
let mut entities = Vec::new();
for (idx, symbol) in file_index.symbols.iter().enumerate() {
let anchor_id = AnchorId((idx + 1) as u64);
anchors.push(AnchorFact {
id: anchor_id,
file_id,
span_start_byte: 0,
span_end_byte: 0,
scope_id: None,
provenance: Provenance::SearchFallback,
confidence: Confidence::Low,
});
entities.push(EntityFact {
id: EntityId((idx + 1) as u64),
kind: EntityKind::Unknown,
canonical_name: symbol
.qualified_name
.clone()
.unwrap_or_else(|| symbol.name.clone()),
anchor_id: Some(anchor_id),
scope_id: None,
provenance: Provenance::SearchFallback,
confidence: Confidence::Low,
});
}
let anchors_hash = {
let mut h = DefaultHasher::new();
anchors.len().hash(&mut h);
for a in &anchors {
a.id.hash(&mut h);
a.span_start_byte.hash(&mut h);
a.span_end_byte.hash(&mut h);
}
h.finish()
};
let entities_hash = {
let mut h = DefaultHasher::new();
entities.len().hash(&mut h);
for e in &entities {
e.id.hash(&mut h);
e.canonical_name.hash(&mut h);
}
h.finish()
};
FileFactShard {
source_uri: uri.to_string(),
file_id,
content_hash,
anchors_hash: Some(anchors_hash),
entities_hash: Some(entities_hash),
occurrences_hash: Some(0),
edges_hash: Some(0),
anchors,
entities,
occurrences: Vec::new(),
edges: Vec::new(),
}
}
fn build_canonical_fact_shard_for_ast(
uri: &str,
content_hash: u64,
ast: &Node,
) -> FileFactShard {
let file_id = Self::hash_uri_to_file_id(uri);
let decls = extract_symbol_decls(ast, None);
let refs = extract_symbol_refs(ast);
let decl_facts = symbol_decls_to_semantic_facts(&decls, file_id);
let entity_ids_by_name: std::collections::BTreeMap<String, EntityId> =
decl_facts.entities.iter().map(|e| (e.canonical_name.clone(), e.id)).collect();
let ref_facts = symbol_refs_to_semantic_facts(&refs, file_id, &entity_ids_by_name);
crate::semantic::facts::build_canonical_fact_shard(
uri,
content_hash,
&decl_facts,
&ref_facts,
&[],
&[],
)
}
pub fn replace_fact_shard_incremental(
&self,
key: &str,
new_shard: FileFactShard,
) -> ShardReplaceResult {
let mut shards = self.fact_shards.write();
let old_shard = shards.get(key);
let replacement = plan_shard_replacement(
old_shard.map(Self::shard_category_hashes),
Self::shard_category_hashes(&new_shard),
);
if replacement.content_unchanged {
return replacement;
}
let source_uri = new_shard.source_uri.clone();
if replacement.occurrences_updated || replacement.edges_updated {
let mut ref_idx = self.semantic_reference_index.write();
if old_shard.is_some() {
ref_idx.remove_file(&source_uri);
}
ref_idx.add_file(&new_shard);
}
if replacement.entities_updated {
let mut ie_idx = self.semantic_import_export_index.write();
ie_idx.remove_file_imports(&source_uri);
ie_idx.remove_module_exports(&source_uri);
}
shards.insert(key.to_string(), new_shard);
replacement
}
fn shard_category_hashes(shard: &FileFactShard) -> ShardCategoryHashes {
ShardCategoryHashes {
content_hash: shard.content_hash,
anchors_hash: shard.anchors_hash,
entities_hash: shard.entities_hash,
occurrences_hash: shard.occurrences_hash,
edges_hash: shard.edges_hash,
}
}
pub fn fact_shard_count(&self) -> usize {
self.fact_shards.read().len()
}
pub fn file_fact_shard(&self, uri: &str) -> Option<FileFactShard> {
let key = DocumentStore::uri_key(&Self::normalize_uri(uri));
self.fact_shards.read().get(&key).cloned()
}
pub fn file_count(&self) -> usize {
let files = self.files.read();
files.len()
}
pub fn symbol_count(&self) -> usize {
let files = self.files.read();
files.values().map(|file_index| file_index.symbols.len()).sum()
}
pub fn files_in_folder(&self, folder_uri: &str) -> Vec<FileIndex> {
let files = self.files.read();
files.values().filter(|f| f.folder_uri.as_deref() == Some(folder_uri)).cloned().collect()
}
pub fn symbols_in_folder(&self, folder_uri: &str) -> Vec<WorkspaceSymbol> {
let files = self.files.read();
files
.values()
.filter(|f| f.folder_uri.as_deref() == Some(folder_uri))
.flat_map(|f| f.symbols.iter().cloned())
.collect()
}
#[cfg(feature = "memory-profiling")]
pub fn memory_snapshot(&self) -> crate::workspace::memory::MemorySnapshot {
use std::mem::size_of;
let files_guard = self.files.read();
let symbols_guard = self.symbols.read();
let global_refs_guard = self.global_references.read();
let mut files_bytes: usize = 0;
let mut total_symbol_count: usize = 0;
for (uri_key, fi) in files_guard.iter() {
files_bytes += uri_key.len();
for sym in &fi.symbols {
files_bytes += sym.name.len()
+ sym.uri.len()
+ sym.qualified_name.as_deref().map_or(0, str::len)
+ sym.documentation.as_deref().map_or(0, str::len)
+ sym.container_name.as_deref().map_or(0, str::len)
+ size_of::<WorkspaceSymbol>();
}
total_symbol_count += fi.symbols.len();
for (ref_name, refs) in &fi.references {
files_bytes += ref_name.len();
for r in refs {
files_bytes += r.uri.len() + size_of::<SymbolReference>();
}
}
for dep in &fi.dependencies {
files_bytes += dep.len();
}
files_bytes += size_of::<u64>();
}
let mut symbols_bytes: usize = 0;
for (qname, candidates) in symbols_guard.iter() {
symbols_bytes += qname.len();
for candidate in candidates {
symbols_bytes += candidate.location.uri.len() + size_of::<Location>();
}
}
let mut global_refs_bytes: usize = 0;
for (sym_name, locs) in global_refs_guard.iter() {
global_refs_bytes += sym_name.len();
for loc in locs {
global_refs_bytes += loc.uri.len() + size_of::<Location>();
}
}
let document_store_bytes = self.document_store.total_text_bytes();
crate::workspace::memory::MemorySnapshot {
file_count: files_guard.len(),
symbol_count: total_symbol_count,
files_bytes,
symbols_bytes,
global_refs_bytes,
document_store_bytes,
}
}
pub fn has_symbols(&self) -> bool {
let files = self.files.read();
files.values().any(|file_index| !file_index.symbols.is_empty())
}
pub fn search_symbols(&self, query: &str) -> Vec<WorkspaceSymbol> {
let query_lower = query.to_lowercase();
let files = self.files.read();
let mut results = Vec::new();
for file_index in files.values() {
for symbol in &file_index.symbols {
if symbol.name.to_lowercase().contains(&query_lower)
|| symbol
.qualified_name
.as_ref()
.map(|qn| qn.to_lowercase().contains(&query_lower))
.unwrap_or(false)
{
results.push(symbol.clone());
}
}
}
results
}
pub fn find_symbols(&self, query: &str) -> Vec<WorkspaceSymbol> {
self.search_symbols(query)
}
pub fn rank_symbols_by_folder(
&self,
symbols: Vec<WorkspaceSymbol>,
doc_uri: &str,
) -> Vec<WorkspaceSymbol> {
let doc_folder = self.determine_folder_uri(doc_uri);
let mut ranked: Vec<(WorkspaceSymbol, i32)> = symbols
.into_iter()
.map(|symbol| {
let rank = if let Some(ref doc_folder_uri) = doc_folder {
if symbol.workspace_folder_uri.as_ref() == Some(doc_folder_uri) {
0 } else {
1 }
} else {
1 };
(symbol, rank)
})
.collect();
ranked.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.name.cmp(&b.0.name)));
ranked.into_iter().map(|(symbol, _)| symbol).collect()
}
pub fn search_symbols_ranked(&self, name: &str, doc_uri: &str) -> Vec<WorkspaceSymbol> {
let symbols = self.search_symbols(name);
self.rank_symbols_by_folder(symbols, doc_uri)
}
#[allow(dead_code)]
pub fn same_package(&self, symbol_a: &WorkspaceSymbol, symbol_b: &WorkspaceSymbol) -> bool {
let package_a = self.extract_package_name(&symbol_a.name);
let package_b = self.extract_package_name(&symbol_b.name);
package_a == package_b
}
#[allow(dead_code)]
pub fn same_package_by_container(&self, package_a: &str, package_b: &str) -> bool {
package_a == package_b
}
#[allow(dead_code)]
pub fn extract_package_name(&self, symbol_name: &str) -> Option<String> {
let parts: Vec<&str> = symbol_name.split("::").collect();
if parts.len() > 1 { Some(parts[..parts.len() - 1].join("::")) } else { None }
}
pub fn file_symbols(&self, uri: &str) -> Vec<WorkspaceSymbol> {
let normalized_uri = Self::normalize_uri(uri);
let key = DocumentStore::uri_key(&normalized_uri);
let files = self.files.read();
files.get(&key).map(|fi| fi.symbols.clone()).unwrap_or_default()
}
pub fn file_dependencies(&self, uri: &str) -> HashSet<String> {
let normalized_uri = Self::normalize_uri(uri);
let key = DocumentStore::uri_key(&normalized_uri);
let files = self.files.read();
files.get(&key).map(|fi| fi.dependencies.clone()).unwrap_or_default()
}
pub fn find_dependents(&self, module_name: &str) -> Vec<String> {
let canonical = canonicalize_perl_module_name(module_name);
let legacy = legacy_perl_module_name(&canonical);
let files = self.files.read();
let mut dependents = Vec::new();
for (uri_key, file_index) in files.iter() {
if file_index.dependencies.contains(module_name)
|| file_index.dependencies.contains(&canonical)
|| file_index.dependencies.contains(&legacy)
{
dependents.push(uri_key.clone());
}
}
dependents
}
pub fn document_store(&self) -> &DocumentStore {
&self.document_store
}
pub fn find_unused_symbols(&self) -> Vec<WorkspaceSymbol> {
let files = self.files.read();
let mut unused = Vec::new();
for (_uri_key, file_index) in files.iter() {
for symbol in &file_index.symbols {
let has_usage = files.values().any(|fi| {
if let Some(refs) = fi.references.get(&symbol.name) {
refs.iter().any(|r| r.kind != ReferenceKind::Definition)
} else {
false
}
});
if !has_usage {
unused.push(symbol.clone());
}
}
}
unused
}
pub fn get_package_members(&self, package_name: &str) -> Vec<WorkspaceSymbol> {
let files = self.files.read();
let mut members = Vec::new();
for (_uri_key, file_index) in files.iter() {
for symbol in &file_index.symbols {
if let Some(ref container) = symbol.container_name {
if container == package_name {
members.push(symbol.clone());
}
}
if let Some(ref qname) = symbol.qualified_name {
if qname.starts_with(&format!("{}::", package_name)) {
if symbol.container_name.as_deref() != Some(package_name) {
members.push(symbol.clone());
}
}
}
}
}
members
}
pub fn find_def(&self, key: &SymbolKey) -> Option<Location> {
if let Some(sigil) = key.sigil {
let var_name = format!("{}{}", sigil, key.name);
self.find_definition(&var_name)
} else if key.kind == SymKind::Pack {
self.find_definition(key.pkg.as_ref())
.or_else(|| self.find_definition(key.name.as_ref()))
} else {
let qualified_name = format!("{}::{}", key.pkg, key.name);
self.find_definition(&qualified_name)
}
}
pub fn find_refs(&self, key: &SymbolKey) -> Vec<Location> {
let files_locked = self.files.read();
let mut all_refs = if let Some(sigil) = key.sigil {
let var_name = format!("{}{}", sigil, key.name);
let mut refs = Vec::new();
for (_uri_key, file_index) in files_locked.iter() {
if let Some(var_refs) = file_index.references.get(&var_name) {
for reference in var_refs {
refs.push(Location { uri: reference.uri.clone(), range: reference.range });
}
}
}
refs
} else {
if key.pkg.as_ref() == "main" {
let mut refs = self.find_references(&format!("main::{}", key.name));
for (_uri_key, file_index) in files_locked.iter() {
if let Some(bare_refs) = file_index.references.get(key.name.as_ref()) {
for reference in bare_refs {
refs.push(Location {
uri: reference.uri.clone(),
range: reference.range,
});
}
}
}
refs
} else {
let qualified_name = format!("{}::{}", key.pkg, key.name);
self.find_references(&qualified_name)
}
};
drop(files_locked);
if let Some(def) = self.find_def(key) {
all_refs.retain(|loc| !(loc.uri == def.uri && loc.range == def.range));
}
let mut seen = HashSet::new();
all_refs.retain(|loc| {
seen.insert((
loc.uri.clone(),
loc.range.start.line,
loc.range.start.column,
loc.range.end.line,
loc.range.end.column,
))
});
all_refs
}
}
struct IndexVisitor {
document: Document,
uri: String,
current_package: Option<String>,
workspace_folder_uri: Option<String>,
}
fn is_interpolated_var_start(byte: u8) -> bool {
byte.is_ascii_alphabetic() || byte == b'_'
}
fn is_interpolated_var_continue(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || byte == b'_' || byte == b':'
}
fn has_escaped_interpolation_marker(bytes: &[u8], index: usize) -> bool {
if index == 0 {
return false;
}
let mut backslashes = 0usize;
let mut cursor = index;
while cursor > 0 && bytes[cursor - 1] == b'\\' {
backslashes += 1;
cursor -= 1;
}
backslashes % 2 == 1
}
fn strip_matching_quote_delimiters(raw_content: &str) -> &str {
if raw_content.len() < 2 {
return raw_content;
}
let bytes = raw_content.as_bytes();
match (bytes.first(), bytes.last()) {
(Some(b'"'), Some(b'"')) | (Some(b'\''), Some(b'\'')) => {
&raw_content[1..raw_content.len() - 1]
}
_ => raw_content,
}
}
impl IndexVisitor {
fn new(document: &mut Document, uri: String, workspace_folder_uri: Option<String>) -> Self {
Self {
document: document.clone(),
uri,
current_package: Some("main".to_string()),
workspace_folder_uri,
}
}
fn visit(&mut self, node: &Node, file_index: &mut FileIndex) {
self.project_symbol_declarations(node, file_index);
self.visit_node(node, file_index);
}
fn project_symbol_declarations(&self, node: &Node, file_index: &mut FileIndex) {
for decl in extract_symbol_decls(node, self.current_package.as_deref()) {
let (start, end) = match decl.kind {
SymbolKind::Variable(_) => match decl.anchor_span {
Some(span) => span,
None => decl.full_span,
},
_ => decl.full_span,
};
let ((start_line, start_col), (end_line, end_col)) =
self.document.line_index.range(start, end);
let range = Range {
start: Position { byte: start, line: start_line, column: start_col },
end: Position { byte: end, line: end_line, column: end_col },
};
let symbol_name = symbol_decl_name(&decl.kind, &decl.name);
let qualified_name = match &decl.declarator {
Some(d) if d == "my" || d == "state" => None,
_ => (!decl.qualified_name.is_empty()).then_some(decl.qualified_name),
};
let container_name = match decl.kind {
SymbolKind::Package => None,
_ => decl.container,
};
file_index.symbols.push(WorkspaceSymbol {
name: symbol_name.clone(),
kind: decl.kind,
uri: self.uri.clone(),
range,
qualified_name,
documentation: None,
container_name,
has_body: true,
workspace_folder_uri: self.workspace_folder_uri.clone(),
});
file_index.references.entry(symbol_name).or_default().push(SymbolReference {
uri: self.uri.clone(),
range,
kind: ReferenceKind::Definition,
});
}
}
fn record_interpolated_variable_references(
&self,
raw_content: &str,
range: Range,
file_index: &mut FileIndex,
) {
let content = strip_matching_quote_delimiters(raw_content);
let bytes = content.as_bytes();
let mut index = 0;
while index < bytes.len() {
if has_escaped_interpolation_marker(bytes, index) {
index += 1;
continue;
}
let sigil = match bytes[index] {
b'$' => "$",
b'@' => "@",
_ => {
index += 1;
continue;
}
};
if index + 1 >= bytes.len() {
break;
}
let (start, needs_closing_brace) =
if bytes[index + 1] == b'{' { (index + 2, true) } else { (index + 1, false) };
if start >= bytes.len() || !is_interpolated_var_start(bytes[start]) {
index += 1;
continue;
}
let mut end = start + 1;
while end < bytes.len() && is_interpolated_var_continue(bytes[end]) {
end += 1;
}
if needs_closing_brace && (end >= bytes.len() || bytes[end] != b'}') {
index += 1;
continue;
}
if let Some(name) = content.get(start..end) {
let var_name = format!("{sigil}{name}");
file_index.references.entry(var_name).or_default().push(SymbolReference {
uri: self.uri.clone(),
range,
kind: ReferenceKind::Read,
});
}
index = if needs_closing_brace { end + 1 } else { end };
}
}
fn visit_node(&mut self, node: &Node, file_index: &mut FileIndex) {
match &node.kind {
NodeKind::Package { name, .. } => {
let package_name = name.clone();
self.current_package = Some(package_name.clone());
}
NodeKind::Subroutine { body, .. } => {
self.visit_node(body, file_index);
}
NodeKind::VariableDeclaration { initializer, .. } => {
if let Some(init) = initializer {
self.visit_node(init, file_index);
}
}
NodeKind::VariableListDeclaration { initializer, .. } => {
if let Some(init) = initializer {
self.visit_node(init, file_index);
}
}
NodeKind::Variable { sigil, name } => {
let var_name = format!("{}{}", sigil, name);
file_index.references.entry(var_name).or_default().push(SymbolReference {
uri: self.uri.clone(),
range: self.node_to_range(node),
kind: ReferenceKind::Read, });
}
NodeKind::FunctionCall { name, args, .. } => {
let func_name = name.clone();
let location = self.node_to_range(node);
let (pkg, bare_name) = if let Some(idx) = func_name.rfind("::") {
(&func_name[..idx], &func_name[idx + 2..])
} else {
(self.current_package.as_deref().unwrap_or("main"), func_name.as_str())
};
let qualified = format!("{}::{}", pkg, bare_name);
file_index.references.entry(bare_name.to_string()).or_default().push(
SymbolReference {
uri: self.uri.clone(),
range: location,
kind: ReferenceKind::Usage,
},
);
file_index.references.entry(qualified).or_default().push(SymbolReference {
uri: self.uri.clone(),
range: location,
kind: ReferenceKind::Usage,
});
if name == "extends" || name == "with" {
for module_name in extract_module_names_from_call_args(args) {
file_index
.dependencies
.insert(normalize_dependency_module_name(&module_name));
}
} else if name == "require" {
if let Some(module_name) = extract_module_name_from_require_args(args) {
file_index
.dependencies
.insert(normalize_dependency_module_name(&module_name));
}
}
for arg in args {
self.visit_node(arg, file_index);
}
}
NodeKind::Use { module, args, .. } => {
let module_name = normalize_dependency_module_name(module);
file_index.dependencies.insert(module_name.clone());
if module == "parent" || module == "base" {
for name in extract_module_names_from_use_args(args) {
file_index.dependencies.insert(normalize_dependency_module_name(&name));
}
}
file_index.references.entry(module_name).or_default().push(SymbolReference {
uri: self.uri.clone(),
range: self.node_to_range(node),
kind: ReferenceKind::Import,
});
}
NodeKind::Assignment { lhs, rhs, op } => {
let is_compound = op != "=";
if let NodeKind::Variable { sigil, name } = &lhs.kind {
let var_name = format!("{}{}", sigil, name);
if is_compound {
file_index.references.entry(var_name.clone()).or_default().push(
SymbolReference {
uri: self.uri.clone(),
range: self.node_to_range(lhs),
kind: ReferenceKind::Read,
},
);
}
file_index.references.entry(var_name).or_default().push(SymbolReference {
uri: self.uri.clone(),
range: self.node_to_range(lhs),
kind: ReferenceKind::Write,
});
}
self.visit_node(rhs, file_index);
}
NodeKind::Block { statements } => {
for stmt in statements {
self.visit_node(stmt, file_index);
}
}
NodeKind::If { condition, then_branch, elsif_branches, else_branch } => {
self.visit_node(condition, file_index);
self.visit_node(then_branch, file_index);
for (cond, branch) in elsif_branches {
self.visit_node(cond, file_index);
self.visit_node(branch, file_index);
}
if let Some(else_br) = else_branch {
self.visit_node(else_br, file_index);
}
}
NodeKind::While { condition, body, continue_block } => {
self.visit_node(condition, file_index);
self.visit_node(body, file_index);
if let Some(cont) = continue_block {
self.visit_node(cont, file_index);
}
}
NodeKind::For { init, condition, update, body, continue_block } => {
if let Some(i) = init {
self.visit_node(i, file_index);
}
if let Some(c) = condition {
self.visit_node(c, file_index);
}
if let Some(u) = update {
self.visit_node(u, file_index);
}
self.visit_node(body, file_index);
if let Some(cont) = continue_block {
self.visit_node(cont, file_index);
}
}
NodeKind::Foreach { variable, list, body, continue_block } => {
if let Some(cb) = continue_block {
self.visit_node(cb, file_index);
}
if let NodeKind::Variable { sigil, name } = &variable.kind {
let var_name = format!("{}{}", sigil, name);
file_index.references.entry(var_name).or_default().push(SymbolReference {
uri: self.uri.clone(),
range: self.node_to_range(variable),
kind: ReferenceKind::Write,
});
}
self.visit_node(variable, file_index);
self.visit_node(list, file_index);
self.visit_node(body, file_index);
}
NodeKind::MethodCall { object, method, args } => {
let qualified_method = if let NodeKind::Identifier { name } = &object.kind {
Some(format!("{}::{}", name, method))
} else {
None
};
self.visit_node(object, file_index);
let location = self.node_to_range(node);
if let Some(qualified_method) = qualified_method.as_ref() {
file_index.references.entry(qualified_method.clone()).or_default().push(
SymbolReference {
uri: self.uri.clone(),
range: location,
kind: ReferenceKind::Usage,
},
);
}
file_index.references.entry(method.clone()).or_default().push(SymbolReference {
uri: self.uri.clone(),
range: location,
kind: ReferenceKind::Usage,
});
if method == "import"
&& let NodeKind::Identifier { name: module_name } = &object.kind
{
for symbol in extract_manual_import_symbols(args) {
file_index.references.entry(symbol).or_default().push(SymbolReference {
uri: self.uri.clone(),
range: self.node_to_range(node),
kind: ReferenceKind::Import,
});
}
file_index.dependencies.insert(normalize_dependency_module_name(module_name));
}
for arg in args {
self.visit_node(arg, file_index);
}
}
NodeKind::No { module, .. } => {
let module_name = normalize_dependency_module_name(module);
file_index.dependencies.insert(module_name);
}
NodeKind::Class { name, .. } => {
self.current_package = Some(name.clone());
}
NodeKind::Method { body, signature, .. } => {
if let Some(sig) = signature {
if let NodeKind::Signature { parameters } = &sig.kind {
for param in parameters {
self.visit_node(param, file_index);
}
}
}
self.visit_node(body, file_index);
}
NodeKind::String { value, interpolated } => {
if *interpolated {
let range = self.node_to_range(node);
self.record_interpolated_variable_references(value, range, file_index);
}
}
NodeKind::Heredoc { content, interpolated, .. } => {
if *interpolated {
let range = self.node_to_range(node);
self.record_interpolated_variable_references(content, range, file_index);
}
}
NodeKind::Unary { op, operand } if op == "++" || op == "--" => {
if let NodeKind::Variable { sigil, name } = &operand.kind {
let var_name = format!("{}{}", sigil, name);
file_index.references.entry(var_name.clone()).or_default().push(
SymbolReference {
uri: self.uri.clone(),
range: self.node_to_range(operand),
kind: ReferenceKind::Read,
},
);
file_index.references.entry(var_name).or_default().push(SymbolReference {
uri: self.uri.clone(),
range: self.node_to_range(operand),
kind: ReferenceKind::Write,
});
}
}
_ => {
self.visit_children(node, file_index);
}
}
}
fn visit_children(&mut self, node: &Node, file_index: &mut FileIndex) {
match &node.kind {
NodeKind::Program { statements } => {
for stmt in statements {
self.visit_node(stmt, file_index);
}
}
NodeKind::ExpressionStatement { expression } => {
self.visit_node(expression, file_index);
}
NodeKind::Unary { operand, .. } => {
self.visit_node(operand, file_index);
}
NodeKind::Binary { left, right, .. } => {
self.visit_node(left, file_index);
self.visit_node(right, file_index);
}
NodeKind::Ternary { condition, then_expr, else_expr } => {
self.visit_node(condition, file_index);
self.visit_node(then_expr, file_index);
self.visit_node(else_expr, file_index);
}
NodeKind::ArrayLiteral { elements } => {
for elem in elements {
self.visit_node(elem, file_index);
}
}
NodeKind::HashLiteral { pairs } => {
for (key, value) in pairs {
self.visit_node(key, file_index);
self.visit_node(value, file_index);
}
}
NodeKind::Return { value } => {
if let Some(val) = value {
self.visit_node(val, file_index);
}
}
NodeKind::Eval { block } | NodeKind::Do { block } | NodeKind::Defer { block } => {
self.visit_node(block, file_index);
}
NodeKind::Try { body, catch_blocks, finally_block } => {
self.visit_node(body, file_index);
for (_, block) in catch_blocks {
self.visit_node(block, file_index);
}
if let Some(finally) = finally_block {
self.visit_node(finally, file_index);
}
}
NodeKind::Given { expr, body } => {
self.visit_node(expr, file_index);
self.visit_node(body, file_index);
}
NodeKind::When { condition, body } => {
self.visit_node(condition, file_index);
self.visit_node(body, file_index);
}
NodeKind::Default { body } => {
self.visit_node(body, file_index);
}
NodeKind::StatementModifier { statement, condition, .. } => {
self.visit_node(statement, file_index);
self.visit_node(condition, file_index);
}
NodeKind::VariableWithAttributes { variable, .. } => {
self.visit_node(variable, file_index);
}
NodeKind::LabeledStatement { statement, .. } => {
self.visit_node(statement, file_index);
}
_ => {
}
}
}
fn node_to_range(&mut self, node: &Node) -> Range {
let ((start_line, start_col), (end_line, end_col)) =
self.document.line_index.range(node.location.start, node.location.end);
Range {
start: Position { byte: node.location.start, line: start_line, column: start_col },
end: Position { byte: node.location.end, line: end_line, column: end_col },
}
}
}
fn symbol_decl_name(kind: &SymbolKind, name: &str) -> String {
match kind {
SymbolKind::Variable(VarKind::Scalar) => format!("${name}"),
SymbolKind::Variable(VarKind::Array) => format!("@{name}"),
SymbolKind::Variable(VarKind::Hash) => format!("%{name}"),
_ => name.to_string(),
}
}
fn extract_module_names_from_use_args(args: &[String]) -> Vec<String> {
use std::collections::HashSet;
fn normalize_module_name(token: &str) -> Option<&str> {
let stripped = token.trim_matches(|c: char| {
matches!(c, '\'' | '"' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ';')
});
if stripped.is_empty() || stripped.starts_with('-') {
return None;
}
stripped
.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == ':' || c == '\'')
.then_some(stripped)
}
let joined = args.join(" ");
let (qw_words, remainder) = extract_qw_words(&joined);
let mut modules = Vec::new();
let mut seen = HashSet::new();
for word in qw_words {
if let Some(candidate) = normalize_module_name(&word) {
let canonical = canonicalize_perl_module_name(candidate);
if seen.insert(canonical.clone()) {
modules.push(canonical);
}
}
}
for token in remainder.split_whitespace().flat_map(|t| t.split(',')) {
if let Some(candidate) = normalize_module_name(token) {
let canonical = canonicalize_perl_module_name(candidate);
if seen.insert(canonical.clone()) {
modules.push(canonical);
}
}
}
modules
}
fn extract_module_names_from_call_args(args: &[Node]) -> Vec<String> {
fn collect_from_node(node: &Node, out: &mut Vec<String>) {
match &node.kind {
NodeKind::String { value, .. } => {
out.extend(extract_module_names_from_use_args(std::slice::from_ref(value)));
}
NodeKind::Identifier { name } => {
out.extend(extract_module_names_from_use_args(std::slice::from_ref(name)));
}
NodeKind::ArrayLiteral { elements } => {
for element in elements {
collect_from_node(element, out);
}
}
NodeKind::FunctionCall { name, args, .. } if name == "qw" => {
for arg in args {
collect_from_node(arg, out);
}
}
_ => {}
}
}
let mut modules = Vec::new();
for arg in args {
collect_from_node(arg, &mut modules);
}
modules
}
fn canonicalize_perl_module_name(name: &str) -> String {
name.replace('\'', "::")
}
fn legacy_perl_module_name(name: &str) -> String {
name.replace("::", "'")
}
fn normalize_dependency_module_name(module_name: &str) -> String {
canonicalize_perl_module_name(module_name)
}
fn extract_qw_words(input: &str) -> (Vec<String>, String) {
let chars: Vec<char> = input.chars().collect();
let mut i = 0;
let mut words = Vec::new();
let mut remainder = String::new();
while i < chars.len() {
if chars[i] == 'q'
&& i + 1 < chars.len()
&& chars[i + 1] == 'w'
&& (i == 0 || !chars[i - 1].is_alphanumeric())
{
let mut j = i + 2;
while j < chars.len() && chars[j].is_whitespace() {
j += 1;
}
if j >= chars.len() {
remainder.push(chars[i]);
i += 1;
continue;
}
let open = chars[j];
let (close, is_paired_delimiter) = match open {
'(' => (')', true),
'[' => (']', true),
'{' => ('}', true),
'<' => ('>', true),
_ => (open, false),
};
if open.is_alphanumeric() || open == '_' || open == '\'' || open == '"' {
remainder.push(chars[i]);
i += 1;
continue;
}
let mut k = j + 1;
if is_paired_delimiter {
let mut depth = 1usize;
while k < chars.len() && depth > 0 {
if chars[k] == open {
depth += 1;
} else if chars[k] == close {
depth -= 1;
}
k += 1;
}
if depth != 0 {
remainder.extend(chars[i..].iter());
break;
}
k -= 1;
} else {
while k < chars.len() && chars[k] != close {
k += 1;
}
if k >= chars.len() {
remainder.extend(chars[i..].iter());
break;
}
}
let content: String = chars[j + 1..k].iter().collect();
for word in content.split_whitespace() {
if !word.is_empty() {
words.push(word.to_string());
}
}
i = k + 1;
continue;
}
remainder.push(chars[i]);
i += 1;
}
(words, remainder)
}
fn extract_module_name_from_require_args(args: &[Node]) -> Option<String> {
let first = args.first()?;
match &first.kind {
NodeKind::Identifier { name } => Some(name.clone()),
NodeKind::String { value, .. } => {
let cleaned = value.trim_matches('\'').trim_matches('"').trim();
if cleaned.is_empty() {
return None;
}
Some(cleaned.trim_end_matches(".pm").replace('/', "::"))
}
_ => None,
}
}
fn extract_manual_import_symbols(args: &[Node]) -> Vec<String> {
fn push_if_bareword(out: &mut Vec<String>, token: &str) {
let bare = token.trim().trim_matches('"').trim_matches('\'').trim();
if bare.is_empty() || bare == "," {
return;
}
let is_bareword = bare.bytes().all(|ch| ch.is_ascii_alphanumeric() || ch == b'_')
&& bare.as_bytes().first().is_some_and(|ch| ch.is_ascii_alphabetic() || *ch == b'_');
if is_bareword {
out.push(bare.to_string());
}
}
let mut symbols = Vec::new();
for arg in args {
match &arg.kind {
NodeKind::String { value, .. } => push_if_bareword(&mut symbols, value),
NodeKind::Identifier { name } => {
if name.starts_with("qw") {
let content = name
.trim_start_matches("qw")
.trim_start_matches(|c: char| "([{/<|!".contains(c))
.trim_end_matches(|c: char| ")]}/|!>".contains(c));
for token in content.split_whitespace() {
push_if_bareword(&mut symbols, token);
}
} else {
push_if_bareword(&mut symbols, name);
}
}
NodeKind::ArrayLiteral { elements } => {
for element in elements {
if let NodeKind::String { value, .. } = &element.kind {
push_if_bareword(&mut symbols, value);
}
}
}
_ => {}
}
}
symbols.sort();
symbols.dedup();
symbols
}
#[cfg(test)]
fn extract_constant_names_from_use_args(args: &[String]) -> Vec<String> {
use std::collections::HashSet;
fn push_unique(names: &mut Vec<String>, seen: &mut HashSet<String>, candidate: &str) {
if seen.insert(candidate.to_string()) {
names.push(candidate.to_string());
}
}
fn normalize_constant_name(token: &str) -> Option<&str> {
let stripped = token.trim_matches(|c: char| {
matches!(c, '\'' | '"' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ';')
});
if stripped.is_empty() || stripped.starts_with('-') {
return None;
}
stripped.chars().all(|c| c.is_alphanumeric() || c == '_').then_some(stripped)
}
let mut names = Vec::new();
let mut seen = HashSet::new();
let first = match args.first() {
Some(f) => f.as_str(),
None => return names,
};
if first.starts_with("qw") {
let (qw_words, remainder) = extract_qw_words(first);
if remainder.trim().is_empty() {
for word in qw_words {
if let Some(candidate) = normalize_constant_name(&word) {
push_unique(&mut names, &mut seen, candidate);
}
}
return names;
}
let content = first.trim_start_matches("qw").trim_start();
let content = content
.trim_start_matches(|c: char| "([{/<|!".contains(c))
.trim_end_matches(|c: char| ")]}/|!>".contains(c));
for word in content.split_whitespace() {
if let Some(candidate) = normalize_constant_name(word) {
push_unique(&mut names, &mut seen, candidate);
}
}
return names;
}
let starts_hash_form = first == "{"
|| first == "+{"
|| (first == "+" && args.get(1).map(String::as_str) == Some("{"));
if starts_hash_form {
let mut skipped_leading_plus = false;
let mut iter = args.iter().peekable();
while let Some(arg) = iter.next() {
if arg == "+{" {
skipped_leading_plus = true;
continue;
}
if arg == "+" && !skipped_leading_plus {
skipped_leading_plus = true;
continue;
}
if arg == "{" || arg == "}" || arg == "," || arg == "=>" {
continue;
}
if let Some(candidate) = normalize_constant_name(arg)
&& iter.peek().map(|s| s.as_str()) == Some("=>")
{
push_unique(&mut names, &mut seen, candidate);
}
}
return names;
}
if let Some(candidate) = normalize_constant_name(first) {
push_unique(&mut names, &mut seen, candidate);
}
names
}
impl Default for WorkspaceIndex {
fn default() -> Self {
Self::new()
}
}
#[cfg(all(feature = "workspace", feature = "lsp-compat"))]
pub mod lsp_adapter {
use super::Location as IxLocation;
use lsp_types::Location as LspLocation;
type LspUrl = lsp_types::Uri;
pub fn to_lsp_location(ix: &IxLocation) -> Option<LspLocation> {
parse_url(&ix.uri).map(|uri| {
let start =
lsp_types::Position { line: ix.range.start.line, character: ix.range.start.column };
let end =
lsp_types::Position { line: ix.range.end.line, character: ix.range.end.column };
let range = lsp_types::Range { start, end };
LspLocation { uri, range }
})
}
pub fn to_lsp_locations(all: impl IntoIterator<Item = IxLocation>) -> Vec<LspLocation> {
all.into_iter().filter_map(|ix| to_lsp_location(&ix)).collect()
}
#[cfg(not(target_arch = "wasm32"))]
fn parse_url(s: &str) -> Option<LspUrl> {
use std::str::FromStr;
LspUrl::from_str(s).ok().or_else(|| {
std::path::Path::new(s).canonicalize().ok().and_then(|p| {
crate::workspace_index::fs_path_to_uri(&p)
.ok()
.and_then(|uri_string| LspUrl::from_str(&uri_string).ok())
})
})
}
#[cfg(target_arch = "wasm32")]
fn parse_url(s: &str) -> Option<LspUrl> {
use std::str::FromStr;
LspUrl::from_str(s).ok()
}
}
#[cfg(test)]
mod tests {
use super::*;
use perl_tdd_support::{must, must_some};
#[test]
fn test_use_constant_indexed_as_constant_symbol() {
let index = WorkspaceIndex::new();
let uri = "file:///lib/My/Config.pm";
let code = r#"package My::Config;
use constant PI => 3.14159;
use constant {
MAX_RETRIES => 3,
TIMEOUT => 30,
};
1;
"#;
must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
let symbols = index.file_symbols(uri);
assert!(
symbols.iter().any(|s| s.name == "PI" && s.kind == SymbolKind::Constant),
"PI should be indexed as a Constant symbol; got: {:?}",
symbols.iter().map(|s| (&s.name, &s.kind)).collect::<Vec<_>>()
);
assert!(
symbols.iter().any(|s| s.name == "MAX_RETRIES" && s.kind == SymbolKind::Constant),
"MAX_RETRIES should be indexed"
);
assert!(
symbols.iter().any(|s| s.name == "TIMEOUT" && s.kind == SymbolKind::Constant),
"TIMEOUT should be indexed"
);
let def = index.find_definition("My::Config::PI");
assert!(def.is_some(), "find_definition('My::Config::PI') should succeed");
}
#[test]
fn test_extract_constant_names_deduplicates_qw_form() {
let names = extract_constant_names_from_use_args(&["qw(FOO BAR FOO)".to_string()]);
assert_eq!(names, vec!["FOO", "BAR"]);
}
#[test]
fn test_extract_constant_names_accepts_quoted_scalar_form() {
let names = extract_constant_names_from_use_args(&[
"'HTTP_OK'".to_string(),
"=>".to_string(),
"200".to_string(),
]);
assert_eq!(names, vec!["HTTP_OK"]);
}
#[test]
fn test_extract_constant_names_accepts_quoted_hash_form() {
let names = extract_constant_names_from_use_args(&[
"{".to_string(),
"'FOO'".to_string(),
"=>".to_string(),
"1".to_string(),
",".to_string(),
"\"BAR\"".to_string(),
"=>".to_string(),
"2".to_string(),
"}".to_string(),
]);
assert_eq!(names, vec!["FOO", "BAR"]);
}
#[test]
fn test_extract_constant_names_accepts_plus_hash_form_split_tokens() {
let names = extract_constant_names_from_use_args(&[
"+".to_string(),
"{".to_string(),
"FOO".to_string(),
"=>".to_string(),
"1".to_string(),
",".to_string(),
"BAR".to_string(),
"=>".to_string(),
"2".to_string(),
"}".to_string(),
]);
assert_eq!(names, vec!["FOO", "BAR"]);
}
#[test]
fn test_extract_constant_names_accepts_plus_hash_form_combined_token() {
let names = extract_constant_names_from_use_args(&[
"+{".to_string(),
"FOO".to_string(),
"=>".to_string(),
"1".to_string(),
",".to_string(),
"BAR".to_string(),
"=>".to_string(),
"2".to_string(),
"}".to_string(),
]);
assert_eq!(names, vec!["FOO", "BAR"]);
}
#[test]
fn test_use_constant_duplicate_names_indexed_once() {
let index = WorkspaceIndex::new();
let uri = "file:///lib/My/DedupConfig.pm";
let code = r#"package My::DedupConfig;
use constant {
RETRY_COUNT => 3,
RETRY_COUNT => 5,
};
1;
"#;
must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
let symbols = index.file_symbols(uri);
let retry_count_symbols = symbols.iter().filter(|s| s.name == "RETRY_COUNT").count();
assert_eq!(
retry_count_symbols, 1,
"RETRY_COUNT should be indexed once even when repeated in use constant hash form"
);
}
#[test]
fn test_use_constant_plus_hash_form_indexes_keys() {
let index = WorkspaceIndex::new();
let uri = "file:///lib/My/PlusHash.pm";
let code = r#"package My::PlusHash;
use constant +{
FOO => 1,
BAR => 2,
};
1;
"#;
must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
assert!(index.find_definition("My::PlusHash::FOO").is_some());
assert!(index.find_definition("My::PlusHash::BAR").is_some());
}
#[test]
fn test_basic_indexing() {
let index = WorkspaceIndex::new();
let uri = "file:///test.pl";
let code = r#"
package MyPackage;
sub hello {
print "Hello";
}
my $var = 42;
"#;
must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
let symbols = index.file_symbols(uri);
assert!(symbols.iter().any(|s| s.name == "MyPackage" && s.kind == SymbolKind::Package));
assert!(symbols.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
assert!(symbols.iter().any(|s| s.name == "$var" && s.kind.is_variable()));
}
#[test]
fn test_package_symbol_has_no_container_name() {
let index = WorkspaceIndex::new();
let uri = "file:///lib/Foo.pm";
let code = "package Foo;\nsub bar { }\n";
must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
let symbols = index.file_symbols(uri);
let pkg_sym = symbols.iter().find(|s| s.name == "Foo" && s.kind == SymbolKind::Package);
assert!(pkg_sym.is_some(), "Package symbol not found");
assert_eq!(
pkg_sym.unwrap().container_name,
None,
"Package symbol must not carry a container (was 'main')"
);
}
#[test]
fn test_my_variable_has_no_qualified_name() {
let index = WorkspaceIndex::new();
let uri = "file:///lib/Foo.pm";
let code = "package Foo;\nsub bar { my $x = 1; }\n";
must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
let symbols = index.file_symbols(uri);
let var_sym = symbols.iter().find(|s| s.name == "$x" && s.kind.is_variable());
assert!(var_sym.is_some(), "$x variable not indexed");
assert_eq!(
var_sym.unwrap().qualified_name,
None,
"my variable must not have a qualified_name"
);
assert!(
index.find_definition("Foo::x").is_none(),
"find_definition(\"Foo::x\") must not return a lexical my variable"
);
}
fn reference_kinds_for(
index: &WorkspaceIndex,
uri: &str,
symbol_name: &str,
) -> Vec<ReferenceKind> {
let files = index.files.read();
let file = must_some(files.get(uri));
file.references
.get(symbol_name)
.map(|refs| refs.iter().map(|r| r.kind).collect())
.unwrap_or_default()
}
#[test]
fn test_reference_kinds_sub_definition_and_call_are_distinct() {
let index = WorkspaceIndex::new();
let uri = "file:///typed-refs-sub.pl";
let code = "package TypedRefs;
sub foo { return 1; }
foo();
";
must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
let kinds = reference_kinds_for(&index, uri, "foo");
assert!(kinds.contains(&ReferenceKind::Definition));
assert!(kinds.contains(&ReferenceKind::Usage));
}
#[test]
fn test_reference_kinds_variable_read_and_write_are_distinct() {
let index = WorkspaceIndex::new();
let uri = "file:///typed-refs-var.pl";
let code = "my $value = 1;
$value = 2;
print $value;
";
must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
let kinds = reference_kinds_for(&index, uri, "$value");
assert!(kinds.contains(&ReferenceKind::Definition));
assert!(kinds.contains(&ReferenceKind::Write));
assert!(kinds.contains(&ReferenceKind::Read));
}
#[test]
fn test_reference_kinds_import_parent_and_export_ok_are_currently_import_only() {
let index = WorkspaceIndex::new();
let uri = "file:///typed-refs-import-export.pm";
let code = "package Child;
use parent 'Base';
our @EXPORT_OK = qw(foo);
1;
";
must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
let parent_kinds = reference_kinds_for(&index, uri, "Base");
assert!(
parent_kinds.is_empty(),
"use parent inheritance edges are currently not stored as typed references"
);
let export_symbol_kinds = reference_kinds_for(&index, uri, "foo");
assert!(
export_symbol_kinds.is_empty(),
"EXPORT_OK entries are currently not represented as reference edges"
);
}
#[test]
fn test_reference_kinds_dynamic_and_meta_edges_are_not_typed_yet() {
let index = WorkspaceIndex::new();
let uri = "file:///typed-refs-dynamic.pl";
let code = r#"package TypedRefs;
sub foo { 1 }
&foo;
my $code = \&foo;
goto &foo;
*alias = \&foo;
eval "foo()";
with 'RoleName';
has 'name' => (is => 'ro');
1;
"#;
must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
let foo_kinds = reference_kinds_for(&index, uri, "foo");
assert!(
foo_kinds
.iter()
.all(|kind| matches!(kind, ReferenceKind::Definition | ReferenceKind::Usage)),
r"dynamic call forms (&foo, \&foo, goto &foo) are currently flattened to Usage"
);
assert!(
reference_kinds_for(&index, uri, "RoleName").is_empty(),
"role composition edges (`with 'RoleName'`) are not indexed as typed references yet"
);
}
#[test]
fn test_find_references() {
let index = WorkspaceIndex::new();
let uri = "file:///test.pl";
let code = r#"
sub test {
my $x = 1;
$x = 2;
print $x;
}
"#;
must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
let refs = index.find_references("$x");
assert!(refs.len() >= 2); }
#[test]
fn test_find_references_bare_name_includes_qualified_calls() {
let index = WorkspaceIndex::new();
let uri = "file:///refs.pl";
let code = r#"
package RefDemo;
sub helper {
return 1;
}
helper();
RefDemo::helper();
"#;
must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
let bare_refs = index.find_references("helper");
let qualified_refs = index.find_references("RefDemo::helper");
assert!(
bare_refs.len() >= qualified_refs.len(),
"bare-name reference lookup should include qualified calls"
);
}
#[test]
fn test_count_usages_bare_name_includes_qualified_calls() {
let index = WorkspaceIndex::new();
let uri = "file:///usage.pl";
let code = r#"
package UsageDemo;
sub helper {
return 1;
}
helper();
UsageDemo::helper();
"#;
must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
let bare_usage_count = index.count_usages("helper");
let qualified_usage_count = index.count_usages("UsageDemo::helper");
assert!(
bare_usage_count >= qualified_usage_count,
"bare-name usage count should include qualified call sites"
);
}
#[test]
fn test_dependencies() {
let index = WorkspaceIndex::new();
let uri = "file:///test.pl";
let code = r#"
use strict;
use warnings;
use Data::Dumper;
"#;
must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
let deps = index.file_dependencies(uri);
assert!(deps.contains("strict"));
assert!(deps.contains("warnings"));
assert!(deps.contains("Data::Dumper"));
}
#[test]
fn test_uri_to_fs_path_basic() {
if let Some(path) = uri_to_fs_path("file:///tmp/test.pl") {
assert_eq!(path, std::path::PathBuf::from("/tmp/test.pl"));
}
assert!(uri_to_fs_path("not-a-uri").is_none());
assert!(uri_to_fs_path("http://example.com").is_none());
}
#[test]
fn test_uri_to_fs_path_with_spaces() {
if let Some(path) = uri_to_fs_path("file:///tmp/path%20with%20spaces/test.pl") {
assert_eq!(path, std::path::PathBuf::from("/tmp/path with spaces/test.pl"));
}
if let Some(path) = uri_to_fs_path("file:///tmp/My%20Documents/test%20file.pl") {
assert_eq!(path, std::path::PathBuf::from("/tmp/My Documents/test file.pl"));
}
}
#[test]
fn test_uri_to_fs_path_with_unicode() {
if let Some(path) = uri_to_fs_path("file:///tmp/caf%C3%A9/test.pl") {
assert_eq!(path, std::path::PathBuf::from("/tmp/café/test.pl"));
}
if let Some(path) = uri_to_fs_path("file:///tmp/emoji%F0%9F%98%80/test.pl") {
assert_eq!(path, std::path::PathBuf::from("/tmp/emoji😀/test.pl"));
}
}
#[test]
fn test_fs_path_to_uri_basic() {
let result = fs_path_to_uri("/tmp/test.pl");
assert!(result.is_ok());
let uri = must(result);
assert!(uri.starts_with("file://"));
assert!(uri.contains("/tmp/test.pl"));
}
#[test]
fn test_fs_path_to_uri_with_spaces() {
let result = fs_path_to_uri("/tmp/path with spaces/test.pl");
assert!(result.is_ok());
let uri = must(result);
assert!(uri.starts_with("file://"));
assert!(uri.contains("path%20with%20spaces"));
}
#[test]
fn test_fs_path_to_uri_with_unicode() {
let result = fs_path_to_uri("/tmp/café/test.pl");
assert!(result.is_ok());
let uri = must(result);
assert!(uri.starts_with("file://"));
assert!(uri.contains("caf%C3%A9"));
}
#[test]
fn test_normalize_uri_file_schemes() {
let uri = WorkspaceIndex::normalize_uri("file:///tmp/test.pl");
assert_eq!(uri, "file:///tmp/test.pl");
let uri = WorkspaceIndex::normalize_uri("file:///tmp/path%20with%20spaces/test.pl");
assert_eq!(uri, "file:///tmp/path%20with%20spaces/test.pl");
}
#[test]
fn test_normalize_uri_absolute_paths() {
let uri = WorkspaceIndex::normalize_uri("/tmp/test.pl");
assert!(uri.starts_with("file://"));
assert!(uri.contains("/tmp/test.pl"));
}
#[test]
fn test_normalize_uri_special_schemes() {
let uri = WorkspaceIndex::normalize_uri("untitled:Untitled-1");
assert_eq!(uri, "untitled:Untitled-1");
}
#[test]
fn test_roundtrip_conversion() {
let original_uri = "file:///tmp/path%20with%20spaces/caf%C3%A9.pl";
if let Some(path) = uri_to_fs_path(original_uri) {
if let Ok(converted_uri) = fs_path_to_uri(&path) {
assert!(converted_uri.starts_with("file://"));
if let Some(roundtrip_path) = uri_to_fs_path(&converted_uri) {
#[cfg(windows)]
if let Ok(rootless) = path.strip_prefix(std::path::Path::new(r"\")) {
assert!(roundtrip_path.ends_with(rootless));
} else {
assert_eq!(path, roundtrip_path);
}
#[cfg(not(windows))]
assert_eq!(path, roundtrip_path);
}
}
}
}
#[cfg(target_os = "windows")]
#[test]
fn test_windows_paths() {
let result = fs_path_to_uri(r"C:\Users\test\Documents\script.pl");
assert!(result.is_ok());
let uri = must(result);
assert!(uri.starts_with("file://"));
let result = fs_path_to_uri(r"C:\Program Files\My App\script.pl");
assert!(result.is_ok());
let uri = must(result);
assert!(uri.starts_with("file://"));
assert!(uri.contains("Program%20Files"));
}
#[test]
fn test_coordinator_initial_state() {
let coordinator = IndexCoordinator::new();
assert!(matches!(
coordinator.state(),
IndexState::Building { phase: IndexPhase::Idle, .. }
));
}
#[test]
fn test_transition_to_scanning_phase() {
let coordinator = IndexCoordinator::new();
coordinator.transition_to_scanning();
let state = coordinator.state();
assert!(
matches!(state, IndexState::Building { phase: IndexPhase::Scanning, .. }),
"Expected Building state after scanning, got: {:?}",
state
);
}
#[test]
fn test_transition_to_indexing_phase() {
let coordinator = IndexCoordinator::new();
coordinator.transition_to_scanning();
coordinator.update_scan_progress(3);
coordinator.transition_to_indexing(3);
let state = coordinator.state();
assert!(
matches!(
state,
IndexState::Building { phase: IndexPhase::Indexing, total_count: 3, .. }
),
"Expected Building state after indexing with total_count 3, got: {:?}",
state
);
}
#[test]
fn test_transition_to_ready() {
let coordinator = IndexCoordinator::new();
coordinator.transition_to_ready(100, 5000);
let state = coordinator.state();
if let IndexState::Ready { file_count, symbol_count, .. } = state {
assert_eq!(file_count, 100);
assert_eq!(symbol_count, 5000);
} else {
unreachable!("Expected Ready state, got: {:?}", state);
}
}
#[test]
fn test_parse_storm_degradation() {
let coordinator = IndexCoordinator::new();
coordinator.transition_to_ready(100, 5000);
for _ in 0..15 {
coordinator.notify_change("file.pm");
}
let state = coordinator.state();
assert!(
matches!(state, IndexState::Degraded { .. }),
"Expected Degraded state, got: {:?}",
state
);
if let IndexState::Degraded { reason, .. } = state {
assert!(matches!(reason, DegradationReason::ParseStorm { .. }));
}
}
#[test]
fn test_recovery_from_parse_storm() {
let coordinator = IndexCoordinator::new();
coordinator.transition_to_ready(100, 5000);
for _ in 0..15 {
coordinator.notify_change("file.pm");
}
for _ in 0..15 {
coordinator.notify_parse_complete("file.pm");
}
assert!(matches!(coordinator.state(), IndexState::Building { .. }));
}
#[test]
fn test_query_dispatch_ready() {
let coordinator = IndexCoordinator::new();
coordinator.transition_to_ready(100, 5000);
let result = coordinator.query(|_index| "full_query", |_index| "partial_query");
assert_eq!(result, "full_query");
}
#[test]
fn test_query_dispatch_degraded() {
let coordinator = IndexCoordinator::new();
let result = coordinator.query(|_index| "full_query", |_index| "partial_query");
assert_eq!(result, "partial_query");
}
#[test]
fn test_metrics_pending_count() {
let coordinator = IndexCoordinator::new();
coordinator.notify_change("file1.pm");
coordinator.notify_change("file2.pm");
assert_eq!(coordinator.metrics.pending_count(), 2);
coordinator.notify_parse_complete("file1.pm");
assert_eq!(coordinator.metrics.pending_count(), 1);
}
#[test]
fn test_instrumentation_records_transitions() {
let coordinator = IndexCoordinator::new();
coordinator.transition_to_ready(10, 100);
let snapshot = coordinator.instrumentation_snapshot();
let transition =
IndexStateTransition { from: IndexStateKind::Building, to: IndexStateKind::Ready };
let count = snapshot.state_transition_counts.get(&transition).copied().unwrap_or(0);
assert_eq!(count, 1);
}
#[test]
fn test_instrumentation_records_early_exit() {
let coordinator = IndexCoordinator::new();
coordinator.record_early_exit(EarlyExitReason::InitialTimeBudget, 25, 1, 10);
let snapshot = coordinator.instrumentation_snapshot();
let count = snapshot
.early_exit_counts
.get(&EarlyExitReason::InitialTimeBudget)
.copied()
.unwrap_or(0);
assert_eq!(count, 1);
assert!(snapshot.last_early_exit.is_some());
}
#[test]
fn test_custom_limits() {
let limits = IndexResourceLimits {
max_files: 5000,
max_symbols_per_file: 1000,
max_total_symbols: 100_000,
max_ast_cache_bytes: 128 * 1024 * 1024,
max_ast_cache_items: 50,
max_scan_duration_ms: 30_000,
};
let coordinator = IndexCoordinator::with_limits(limits.clone());
assert_eq!(coordinator.limits.max_files, 5000);
assert_eq!(coordinator.limits.max_total_symbols, 100_000);
}
#[test]
fn test_degradation_preserves_symbol_count() {
let coordinator = IndexCoordinator::new();
coordinator.transition_to_ready(100, 5000);
coordinator.transition_to_degraded(DegradationReason::IoError {
message: "Test error".to_string(),
});
let state = coordinator.state();
assert!(
matches!(state, IndexState::Degraded { .. }),
"Expected Degraded state, got: {:?}",
state
);
if let IndexState::Degraded { available_symbols, .. } = state {
assert_eq!(available_symbols, 5000);
}
}
#[test]
fn test_index_access() {
let coordinator = IndexCoordinator::new();
let index = coordinator.index();
assert!(index.all_symbols().is_empty());
}
#[test]
fn test_resource_limit_enforcement_max_files() {
let limits = IndexResourceLimits {
max_files: 5,
max_symbols_per_file: 1000,
max_total_symbols: 50_000,
max_ast_cache_bytes: 128 * 1024 * 1024,
max_ast_cache_items: 50,
max_scan_duration_ms: 30_000,
};
let coordinator = IndexCoordinator::with_limits(limits);
coordinator.transition_to_ready(10, 100);
for i in 0..10 {
let uri_str = format!("file:///test{}.pl", i);
let uri = must(url::Url::parse(&uri_str));
let code = "sub test { }";
must(coordinator.index().index_file(uri, code.to_string()));
}
coordinator.enforce_limits();
let state = coordinator.state();
assert!(
matches!(
state,
IndexState::Degraded {
reason: DegradationReason::ResourceLimit { kind: ResourceKind::MaxFiles },
..
}
),
"Expected Degraded state with ResourceLimit(MaxFiles), got: {:?}",
state
);
}
#[test]
fn test_resource_limit_enforcement_max_symbols() {
let limits = IndexResourceLimits {
max_files: 100,
max_symbols_per_file: 10,
max_total_symbols: 50, max_ast_cache_bytes: 128 * 1024 * 1024,
max_ast_cache_items: 50,
max_scan_duration_ms: 30_000,
};
let coordinator = IndexCoordinator::with_limits(limits);
coordinator.transition_to_ready(0, 0);
for i in 0..10 {
let uri_str = format!("file:///test{}.pl", i);
let uri = must(url::Url::parse(&uri_str));
let code = r#"
package Test;
sub sub1 { }
sub sub2 { }
sub sub3 { }
sub sub4 { }
sub sub5 { }
sub sub6 { }
sub sub7 { }
sub sub8 { }
sub sub9 { }
sub sub10 { }
"#;
must(coordinator.index().index_file(uri, code.to_string()));
}
coordinator.enforce_limits();
let state = coordinator.state();
assert!(
matches!(
state,
IndexState::Degraded {
reason: DegradationReason::ResourceLimit { kind: ResourceKind::MaxSymbols },
..
}
),
"Expected Degraded state with ResourceLimit(MaxSymbols), got: {:?}",
state
);
}
#[test]
fn test_check_limits_returns_none_within_bounds() {
let coordinator = IndexCoordinator::new();
coordinator.transition_to_ready(0, 0);
for i in 0..5 {
let uri_str = format!("file:///test{}.pl", i);
let uri = must(url::Url::parse(&uri_str));
let code = "sub test { }";
must(coordinator.index().index_file(uri, code.to_string()));
}
let limit_check = coordinator.check_limits();
assert!(limit_check.is_none(), "check_limits should return None when within bounds");
assert!(
matches!(coordinator.state(), IndexState::Ready { .. }),
"State should remain Ready when within limits"
);
}
#[test]
fn test_enforce_limits_called_on_transition_to_ready() {
let limits = IndexResourceLimits {
max_files: 3,
max_symbols_per_file: 1000,
max_total_symbols: 50_000,
max_ast_cache_bytes: 128 * 1024 * 1024,
max_ast_cache_items: 50,
max_scan_duration_ms: 30_000,
};
let coordinator = IndexCoordinator::with_limits(limits);
for i in 0..5 {
let uri_str = format!("file:///test{}.pl", i);
let uri = must(url::Url::parse(&uri_str));
let code = "sub test { }";
must(coordinator.index().index_file(uri, code.to_string()));
}
coordinator.transition_to_ready(5, 100);
let state = coordinator.state();
assert!(
matches!(
state,
IndexState::Degraded {
reason: DegradationReason::ResourceLimit { kind: ResourceKind::MaxFiles },
..
}
),
"Expected Degraded state after transition_to_ready with exceeded limits, got: {:?}",
state
);
}
#[test]
fn test_state_transition_guard_ready_to_ready() {
let coordinator = IndexCoordinator::new();
coordinator.transition_to_ready(100, 5000);
coordinator.transition_to_ready(150, 7500);
let state = coordinator.state();
assert!(
matches!(state, IndexState::Ready { file_count: 150, symbol_count: 7500, .. }),
"Expected Ready state with updated metrics, got: {:?}",
state
);
}
#[test]
fn test_state_transition_guard_building_to_building() {
let coordinator = IndexCoordinator::new();
coordinator.transition_to_building(100);
let state = coordinator.state();
assert!(
matches!(state, IndexState::Building { indexed_count: 0, total_count: 100, .. }),
"Expected Building state, got: {:?}",
state
);
coordinator.transition_to_building(200);
let state = coordinator.state();
assert!(
matches!(state, IndexState::Building { indexed_count: 0, total_count: 200, .. }),
"Expected Building state, got: {:?}",
state
);
}
#[test]
fn test_state_transition_ready_to_building() {
let coordinator = IndexCoordinator::new();
coordinator.transition_to_ready(100, 5000);
coordinator.transition_to_building(150);
let state = coordinator.state();
assert!(
matches!(state, IndexState::Building { indexed_count: 0, total_count: 150, .. }),
"Expected Building state after re-scan, got: {:?}",
state
);
}
#[test]
fn test_state_transition_degraded_to_building() {
let coordinator = IndexCoordinator::new();
coordinator.transition_to_degraded(DegradationReason::IoError {
message: "Test error".to_string(),
});
coordinator.transition_to_building(100);
let state = coordinator.state();
assert!(
matches!(state, IndexState::Building { indexed_count: 0, total_count: 100, .. }),
"Expected Building state after recovery, got: {:?}",
state
);
}
#[test]
fn test_update_building_progress() {
let coordinator = IndexCoordinator::new();
coordinator.transition_to_building(100);
coordinator.update_building_progress(50);
let state = coordinator.state();
assert!(
matches!(state, IndexState::Building { indexed_count: 50, total_count: 100, .. }),
"Expected Building state with updated progress, got: {:?}",
state
);
coordinator.update_building_progress(100);
let state = coordinator.state();
assert!(
matches!(state, IndexState::Building { indexed_count: 100, total_count: 100, .. }),
"Expected Building state with completed progress, got: {:?}",
state
);
}
#[test]
fn test_scan_timeout_detection() {
let limits = IndexResourceLimits {
max_scan_duration_ms: 0, ..Default::default()
};
let coordinator = IndexCoordinator::with_limits(limits);
coordinator.transition_to_building(100);
std::thread::sleep(std::time::Duration::from_millis(1));
coordinator.update_building_progress(10);
let state = coordinator.state();
assert!(
matches!(
state,
IndexState::Degraded { reason: DegradationReason::ScanTimeout { .. }, .. }
),
"Expected Degraded state with ScanTimeout, got: {:?}",
state
);
}
#[test]
fn test_scan_timeout_does_not_trigger_within_limit() {
let limits = IndexResourceLimits {
max_scan_duration_ms: 10_000, ..Default::default()
};
let coordinator = IndexCoordinator::with_limits(limits);
coordinator.transition_to_building(100);
coordinator.update_building_progress(50);
let state = coordinator.state();
assert!(
matches!(state, IndexState::Building { indexed_count: 50, .. }),
"Expected Building state (no timeout), got: {:?}",
state
);
}
#[test]
fn test_early_exit_optimization_unchanged_content() {
let index = WorkspaceIndex::new();
let uri = must(url::Url::parse("file:///test.pl"));
let code = r#"
package MyPackage;
sub hello {
print "Hello";
}
"#;
must(index.index_file(uri.clone(), code.to_string()));
let symbols1 = index.file_symbols(uri.as_str());
assert!(symbols1.iter().any(|s| s.name == "MyPackage" && s.kind == SymbolKind::Package));
assert!(symbols1.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
must(index.index_file(uri.clone(), code.to_string()));
let symbols2 = index.file_symbols(uri.as_str());
assert_eq!(symbols1.len(), symbols2.len());
assert!(symbols2.iter().any(|s| s.name == "MyPackage" && s.kind == SymbolKind::Package));
assert!(symbols2.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
}
#[test]
fn test_early_exit_optimization_changed_content() {
let index = WorkspaceIndex::new();
let uri = must(url::Url::parse("file:///test.pl"));
let code1 = r#"
package MyPackage;
sub hello {
print "Hello";
}
"#;
let code2 = r#"
package MyPackage;
sub goodbye {
print "Goodbye";
}
"#;
must(index.index_file(uri.clone(), code1.to_string()));
let symbols1 = index.file_symbols(uri.as_str());
assert!(symbols1.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
assert!(!symbols1.iter().any(|s| s.name == "goodbye"));
must(index.index_file(uri.clone(), code2.to_string()));
let symbols2 = index.file_symbols(uri.as_str());
assert!(!symbols2.iter().any(|s| s.name == "hello"));
assert!(symbols2.iter().any(|s| s.name == "goodbye" && s.kind == SymbolKind::Subroutine));
}
#[test]
fn test_early_exit_optimization_whitespace_only_change() {
let index = WorkspaceIndex::new();
let uri = must(url::Url::parse("file:///test.pl"));
let code1 = r#"
package MyPackage;
sub hello {
print "Hello";
}
"#;
let code2 = r#"
package MyPackage;
sub hello {
print "Hello";
}
"#;
must(index.index_file(uri.clone(), code1.to_string()));
let symbols1 = index.file_symbols(uri.as_str());
assert!(symbols1.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
must(index.index_file(uri.clone(), code2.to_string()));
let symbols2 = index.file_symbols(uri.as_str());
assert!(symbols2.iter().any(|s| s.name == "hello" && s.kind == SymbolKind::Subroutine));
}
#[test]
fn test_reindex_file_refreshes_symbol_cache_for_removed_names() {
let index = WorkspaceIndex::new();
let uri1 = must(url::Url::parse("file:///lib/A.pm"));
let uri2 = must(url::Url::parse("file:///lib/B.pm"));
let code1 = "package A;\nsub foo { return 1; }\n1;\n";
let code2 = "package B;\nsub foo { return 2; }\n1;\n";
let code2_reindexed = "package B;\nsub bar { return 3; }\n1;\n";
must(index.index_file(uri1.clone(), code1.to_string()));
must(index.index_file(uri2.clone(), code2.to_string()));
must(index.index_file(uri2.clone(), code2_reindexed.to_string()));
let foo_location = must_some(index.find_definition("foo"));
assert_eq!(foo_location.uri, uri1.to_string());
let bar_location = must_some(index.find_definition("bar"));
assert_eq!(bar_location.uri, uri2.to_string());
}
#[test]
fn test_remove_file_preserves_other_colliding_symbol_entries() {
let index = WorkspaceIndex::new();
let uri1 = must(url::Url::parse("file:///lib/A.pm"));
let uri2 = must(url::Url::parse("file:///lib/B.pm"));
let code1 = "package A;\nsub foo { return 1; }\n1;\n";
let code2 = "package B;\nsub foo { return 2; }\n1;\n";
must(index.index_file(uri1.clone(), code1.to_string()));
must(index.index_file(uri2.clone(), code2.to_string()));
index.remove_file(uri2.as_str());
let foo_location = must_some(index.find_definition("foo"));
assert_eq!(foo_location.uri, uri1.to_string());
}
#[test]
fn test_count_usages_no_double_counting_for_qualified_calls() {
let index = WorkspaceIndex::new();
let uri1 = "file:///lib/Utils.pm";
let code1 = r#"
package Utils;
sub process_data {
return 1;
}
"#;
must(index.index_file(must(url::Url::parse(uri1)), code1.to_string()));
let uri2 = "file:///app.pl";
let code2 = r#"
use Utils;
Utils::process_data();
Utils::process_data();
"#;
must(index.index_file(must(url::Url::parse(uri2)), code2.to_string()));
let count = index.count_usages("Utils::process_data");
assert_eq!(
count, 2,
"count_usages should not double-count qualified calls, got {} (expected 2)",
count
);
let refs = index.find_references("Utils::process_data");
let non_def_refs: Vec<_> =
refs.iter().filter(|loc| loc.uri != "file:///lib/Utils.pm").collect();
assert_eq!(
non_def_refs.len(),
2,
"find_references should not return duplicates for qualified calls, got {} non-def refs",
non_def_refs.len()
);
}
#[test]
fn test_batch_indexing() {
let index = WorkspaceIndex::new();
let files: Vec<(Url, String)> = (0..5)
.map(|i| {
let uri = must(Url::parse(&format!("file:///batch/module{}.pm", i)));
let code =
format!("package Batch::Mod{};\nsub func_{} {{ return {}; }}\n1;", i, i, i);
(uri, code)
})
.collect();
let errors = index.index_files_batch(files);
assert!(errors.is_empty(), "batch indexing errors: {:?}", errors);
assert_eq!(index.file_count(), 5);
assert!(index.find_definition("Batch::Mod0::func_0").is_some());
assert!(index.find_definition("Batch::Mod4::func_4").is_some());
}
#[test]
fn test_batch_indexing_skips_unchanged() {
let index = WorkspaceIndex::new();
let uri = must(Url::parse("file:///batch/skip.pm"));
let code = "package Skip;\nsub skip_fn { 1 }\n1;".to_string();
index.index_file(uri.clone(), code.clone()).ok();
assert_eq!(index.file_count(), 1);
let errors = index.index_files_batch(vec![(uri, code)]);
assert!(errors.is_empty());
assert_eq!(index.file_count(), 1);
}
#[test]
fn test_incremental_update_preserves_other_symbols() {
let index = WorkspaceIndex::new();
let uri_a = must(Url::parse("file:///incr/a.pm"));
let uri_b = must(Url::parse("file:///incr/b.pm"));
index.index_file(uri_a.clone(), "package A;\nsub a_func { 1 }\n1;".into()).ok();
index.index_file(uri_b.clone(), "package B;\nsub b_func { 2 }\n1;".into()).ok();
assert!(index.find_definition("A::a_func").is_some());
assert!(index.find_definition("B::b_func").is_some());
index.index_file(uri_a, "package A;\nsub a_func_v2 { 11 }\n1;".into()).ok();
assert!(index.find_definition("A::a_func_v2").is_some());
assert!(index.find_definition("B::b_func").is_some());
}
#[test]
fn test_remove_file_preserves_shadowed_symbols() {
let index = WorkspaceIndex::new();
let uri_a = must(Url::parse("file:///shadow/a.pm"));
let uri_b = must(Url::parse("file:///shadow/b.pm"));
index.index_file(uri_a.clone(), "package ShadowA;\nsub helper { 1 }\n1;".into()).ok();
index.index_file(uri_b.clone(), "package ShadowB;\nsub helper { 2 }\n1;".into()).ok();
assert!(index.find_definition("helper").is_some());
index.remove_file_url(&uri_a);
assert!(index.find_definition("helper").is_some());
assert!(index.find_definition("ShadowB::helper").is_some());
}
#[test]
fn test_index_dependency_via_use_parent_end_to_end() {
let index = WorkspaceIndex::new();
let base_url = must(url::Url::parse("file:///test/workspace/lib/MyBase.pm"));
must(index.index_file(
base_url,
"package MyBase;\nsub new { bless {}, shift }\n1;\n".to_string(),
));
let child_url = must(url::Url::parse("file:///test/workspace/child.pl"));
must(index.index_file(child_url, "package Child;\nuse parent 'MyBase';\n1;\n".to_string()));
let dependents = index.find_dependents("MyBase");
assert!(
!dependents.is_empty(),
"find_dependents('MyBase') returned empty — \
use parent 'MyBase' should register MyBase as a dependency. \
Dependencies in index: {:?}",
{
let files = index.files.read();
files
.iter()
.map(|(k, v)| (k.clone(), v.dependencies.iter().cloned().collect::<Vec<_>>()))
.collect::<Vec<_>>()
}
);
assert!(
dependents.contains(&"file:///test/workspace/child.pl".to_string()),
"child.pl should be in dependents, got: {:?}",
dependents
);
}
#[test]
fn test_find_dependents_normalizes_legacy_separator_in_query() {
let index = WorkspaceIndex::new();
let uri = must(url::Url::parse("file:///test/workspace/legacy-query.pl"));
let src = "package Child;\nuse parent 'My::Base';\n1;\n";
must(index.index_file(uri, src.to_string()));
let dependents = index.find_dependents("My'Base");
assert_eq!(dependents, vec!["file:///test/workspace/legacy-query.pl".to_string()]);
}
#[test]
fn test_file_dependencies_normalize_legacy_separator_in_source() {
let index = WorkspaceIndex::new();
let uri = must(url::Url::parse("file:///test/workspace/legacy-source.pl"));
let src = "package Child;\nuse parent \"My'Base\";\n1;\n";
must(index.index_file(uri.clone(), src.to_string()));
let deps = index.file_dependencies(uri.as_str());
assert!(deps.contains("My::Base"));
assert!(!deps.contains("My'Base"));
}
#[test]
fn test_index_dependency_via_moose_extends_end_to_end() -> Result<(), Box<dyn std::error::Error>>
{
let index = WorkspaceIndex::new();
let parent_url = must(url::Url::parse("file:///test/workspace/lib/My/App/Parent.pm"));
must(index.index_file(parent_url, "package My::App::Parent;\n1;\n".to_string()));
let child_url = must(url::Url::parse("file:///test/workspace/child-moose.pl"));
let child_src = "package Child;\nuse Moose;\nextends 'My::App::Parent';\n1;\n";
must(index.index_file(child_url, child_src.to_string()));
let dependents = index.find_dependents("My::App::Parent");
assert!(
dependents.contains(&"file:///test/workspace/child-moose.pl".to_string()),
"expected child-moose.pl in dependents, got: {dependents:?}"
);
Ok(())
}
#[test]
fn test_index_dependency_via_moo_with_role_end_to_end() -> Result<(), Box<dyn std::error::Error>>
{
let index = WorkspaceIndex::new();
let role_url = must(url::Url::parse("file:///test/workspace/lib/My/App/Role.pm"));
must(index.index_file(role_url, "package My::App::Role;\n1;\n".to_string()));
let consumer_url = must(url::Url::parse("file:///test/workspace/consumer-moo.pl"));
let consumer_src = "package Consumer;\nuse Moo;\nwith 'My::App::Role';\n1;\n";
must(index.index_file(consumer_url.clone(), consumer_src.to_string()));
let dependents = index.find_dependents("My::App::Role");
assert!(
dependents.contains(&"file:///test/workspace/consumer-moo.pl".to_string()),
"expected consumer-moo.pl in dependents, got: {dependents:?}"
);
let deps = index.file_dependencies(consumer_url.as_str());
assert!(deps.contains("My::App::Role"));
Ok(())
}
#[test]
fn test_index_dependency_via_literal_require_end_to_end()
-> Result<(), Box<dyn std::error::Error>> {
let index = WorkspaceIndex::new();
let uri = must(url::Url::parse("file:///test/workspace/require-consumer.pl"));
let src = "package Consumer;\nrequire My::Loader;\n1;\n";
must(index.index_file(uri.clone(), src.to_string()));
let deps = index.file_dependencies(uri.as_str());
assert!(
deps.contains("My::Loader"),
"literal require should register module dependency, got: {deps:?}"
);
Ok(())
}
#[test]
fn test_manual_import_symbols_are_indexed_as_import_references()
-> Result<(), Box<dyn std::error::Error>> {
let index = WorkspaceIndex::new();
let uri = must(url::Url::parse("file:///test/workspace/manual-import.pl"));
let src = r#"package Consumer;
require My::Tools;
My::Tools->import(qw(helper_one helper_two));
helper_one();
1;
"#;
must(index.index_file(uri.clone(), src.to_string()));
let deps = index.file_dependencies(uri.as_str());
assert!(
deps.contains("My::Tools"),
"manual import target should be tracked as dependency, got: {deps:?}"
);
for symbol in ["helper_one", "helper_two"] {
let refs = index.find_references(symbol);
assert!(
!refs.is_empty(),
"expected at least one indexed reference for imported symbol `{symbol}`"
);
}
Ok(())
}
#[test]
fn test_parser_produces_correct_args_for_use_parent() {
use crate::Parser;
let mut p = Parser::new("package Child;\nuse parent 'MyBase';\n1;\n");
let ast = must(p.parse());
assert!(
matches!(ast.kind, NodeKind::Program { .. }),
"Expected Program root, got {:?}",
ast.kind
);
let NodeKind::Program { statements } = &ast.kind else {
return;
};
let mut found_parent_use = false;
for stmt in statements {
if let NodeKind::Use { module, args, .. } = &stmt.kind {
if module == "parent" {
found_parent_use = true;
assert_eq!(
args,
&["'MyBase'".to_string()],
"Expected args=[\"'MyBase'\"] for `use parent 'MyBase'`, got: {:?}",
args
);
let extracted = extract_module_names_from_use_args(args);
assert_eq!(
extracted,
vec!["MyBase".to_string()],
"extract_module_names_from_use_args should return [\"MyBase\"], got {:?}",
extracted
);
}
}
}
assert!(found_parent_use, "No Use node with module='parent' found in AST");
}
#[test]
fn test_extract_module_names_single_quoted() {
let names = extract_module_names_from_use_args(&["'Foo::Bar'".to_string()]);
assert_eq!(names, vec!["Foo::Bar"]);
}
#[test]
fn test_extract_module_names_double_quoted() {
let names = extract_module_names_from_use_args(&["\"Foo::Bar\"".to_string()]);
assert_eq!(names, vec!["Foo::Bar"]);
}
#[test]
fn test_extract_module_names_qw_list() {
let names = extract_module_names_from_use_args(&["qw(Foo::Bar Other::Base)".to_string()]);
assert_eq!(names, vec!["Foo::Bar", "Other::Base"]);
}
#[test]
fn test_extract_module_names_qw_slash_delimiter() {
let names = extract_module_names_from_use_args(&["qw/Foo::Bar Other::Base/".to_string()]);
assert_eq!(names, vec!["Foo::Bar", "Other::Base"]);
}
#[test]
fn test_extract_module_names_qw_with_space_before_delimiter() {
let names = extract_module_names_from_use_args(&["qw [Foo::Bar Other::Base]".to_string()]);
assert_eq!(names, vec!["Foo::Bar", "Other::Base"]);
}
#[test]
fn test_extract_module_names_qw_list_trims_wrapped_punctuation() {
let names =
extract_module_names_from_use_args(&["qw((Foo::Bar) [Other::Base],)".to_string()]);
assert_eq!(names, vec!["Foo::Bar", "Other::Base"]);
}
#[test]
fn test_extract_module_names_norequire_flag() {
let names = extract_module_names_from_use_args(&[
"-norequire".to_string(),
"'Foo::Bar'".to_string(),
]);
assert_eq!(names, vec!["Foo::Bar"]);
}
#[test]
fn test_extract_module_names_empty_args() {
let names = extract_module_names_from_use_args(&[]);
assert!(names.is_empty());
}
#[test]
fn test_extract_module_names_legacy_separator() {
let names = extract_module_names_from_use_args(&["'Foo'Bar'".to_string()]);
assert_eq!(names, vec!["Foo::Bar"]);
}
#[test]
fn test_find_dependents_matches_legacy_separator_queries() {
let index = WorkspaceIndex::new();
let base_uri = must(url::Url::parse("file:///test/workspace/lib/Foo/Bar.pm"));
let child_uri = must(url::Url::parse("file:///test/workspace/child.pl"));
must(index.index_file(base_uri, "package Foo::Bar;\n1;\n".to_string()));
must(index.index_file(
child_uri.clone(),
"package Child;\nuse parent qw(Foo'Bar);\n1;\n".to_string(),
));
let dependents_modern = index.find_dependents("Foo::Bar");
assert!(
dependents_modern.contains(&child_uri.to_string()),
"Expected dependency match when queried with modern separator"
);
let dependents_legacy = index.find_dependents("Foo'Bar");
assert!(
dependents_legacy.contains(&child_uri.to_string()),
"Expected dependency match when queried with legacy separator"
);
}
#[test]
fn test_extract_module_names_comma_adjacent_tokens() {
let names = extract_module_names_from_use_args(&[
"'Foo::Bar',".to_string(),
"\"Other::Base\",".to_string(),
"'Last::One'".to_string(),
]);
assert_eq!(names, vec!["Foo::Bar", "Other::Base", "Last::One"]);
}
#[test]
fn test_extract_module_names_parenthesized_without_spaces() {
let names = extract_module_names_from_use_args(&["('Foo::Bar','Other::Base')".to_string()]);
assert_eq!(names, vec!["Foo::Bar", "Other::Base"]);
}
#[test]
fn test_extract_module_names_deduplicates_identical_entries() {
let names = extract_module_names_from_use_args(&[
"qw(Foo::Bar Foo::Bar)".to_string(),
"'Foo::Bar'".to_string(),
]);
assert_eq!(names, vec!["Foo::Bar"]);
}
#[test]
fn test_extract_module_names_trims_semicolon_suffix() {
let names = extract_module_names_from_use_args(&[
"'Foo::Bar',".to_string(),
"'Other::Base',".to_string(),
"'Third::Leaf';".to_string(),
]);
assert_eq!(names, vec!["Foo::Bar", "Other::Base", "Third::Leaf"]);
}
#[test]
fn test_extract_module_names_trims_wrapped_punctuation() {
let names = extract_module_names_from_use_args(&[
"('Foo::Bar',".to_string(),
"'Other::Base')".to_string(),
]);
assert_eq!(names, vec!["Foo::Bar", "Other::Base"]);
}
#[test]
fn test_extract_constant_names_qw_with_space_before_delimiter() {
let names = extract_constant_names_from_use_args(&["qw [FOO BAR]".to_string()]);
assert_eq!(names, vec!["FOO", "BAR"]);
}
#[test]
#[ignore = "qw delimiter with leading space not yet parsed; tracked in debt-ledger.yaml"]
fn test_index_use_constant_qw_with_space_before_delimiter() {
let index = WorkspaceIndex::new();
let uri = must(url::Url::parse("file:///workspace/lib/My/Config.pm"));
let source = "package My::Config;\nuse constant qw [FOO BAR];\n1;\n";
must(index.index_file(uri, source.to_string()));
let foo = index.find_definition("My::Config::FOO");
let bar = index.find_definition("My::Config::BAR");
assert!(foo.is_some(), "Expected My::Config::FOO to be indexed");
assert!(bar.is_some(), "Expected My::Config::BAR to be indexed");
}
#[test]
fn test_with_capacity_accepts_large_batch_without_panic() {
let index = WorkspaceIndex::with_capacity(100, 20);
for i in 0..100 {
let uri = must(url::Url::parse(&format!("file:///lib/Mod{}.pm", i)));
let src = format!("package Mod{};\nsub foo_{} {{ 1 }}\n1;\n", i, i);
index.index_file(uri, src).ok();
}
assert!(index.has_symbols());
}
#[test]
fn test_with_capacity_zero_does_not_panic() {
let index = WorkspaceIndex::with_capacity(0, 0);
assert!(!index.has_symbols());
}
#[test]
fn test_remove_file_clears_symbol_cache_qualified_and_bare() {
let index = WorkspaceIndex::new();
let uri_a = must(url::Url::parse("file:///lib/A.pm"));
let code_a = "package A;\nsub foo { return 1; }\n1;\n";
must(index.index_file(uri_a.clone(), code_a.to_string()));
let before_qual = must_some(index.find_definition("A::foo"));
assert_eq!(
before_qual.uri,
uri_a.to_string(),
"qualified lookup should point to A.pm before removal"
);
let before_bare = must_some(index.find_definition("foo"));
assert_eq!(
before_bare.uri,
uri_a.to_string(),
"bare-name lookup should point to A.pm before removal"
);
index.remove_file(uri_a.as_str());
assert!(
index.find_definition("A::foo").is_none(),
"qualified lookup 'A::foo' should return None after file deletion"
);
assert!(
index.find_definition("foo").is_none(),
"bare-name lookup 'foo' should return None after file deletion"
);
assert_eq!(
index.symbol_count(),
0,
"symbol_count should be 0 after removing the only file"
);
assert!(!index.has_symbols(), "has_symbols should be false after removing the only file");
}
#[test]
fn test_remove_file_bare_name_falls_back_to_surviving_file() {
let index = WorkspaceIndex::new();
let uri_a = must(url::Url::parse("file:///lib/A.pm"));
let uri_b = must(url::Url::parse("file:///lib/B.pm"));
let code_a = "package A;\nsub shared_fn { return 1; }\n1;\n";
let code_b = "package B;\nsub shared_fn { return 2; }\n1;\n";
must(index.index_file(uri_a.clone(), code_a.to_string()));
must(index.index_file(uri_b.clone(), code_b.to_string()));
index.remove_file(uri_a.as_str());
let loc = must_some(index.find_definition("shared_fn"));
assert_eq!(
loc.uri,
uri_b.to_string(),
"bare-name 'shared_fn' should resolve to B.pm after A.pm is deleted"
);
assert!(
index.find_definition("A::shared_fn").is_none(),
"qualified 'A::shared_fn' must be gone after A.pm deletion"
);
assert!(
index.find_definition("B::shared_fn").is_some(),
"qualified 'B::shared_fn' must remain after A.pm deletion"
);
}
#[test]
fn test_definition_candidates_include_ambiguous_bare_symbols_in_stable_order() {
let index = WorkspaceIndex::new();
let uri_b = must(url::Url::parse("file:///lib/B.pm"));
let uri_a = must(url::Url::parse("file:///lib/A.pm"));
must(index.index_file(uri_b, "package B;\nsub shared { 1 }\n1;\n".to_string()));
must(index.index_file(uri_a, "package A;\nsub shared { 1 }\n1;\n".to_string()));
let candidates = index.definition_candidates("shared");
assert_eq!(candidates.len(), 2);
assert_eq!(candidates[0].uri, "file:///lib/A.pm");
assert_eq!(candidates[1].uri, "file:///lib/B.pm");
assert_eq!(must_some(index.find_definition("shared")).uri, "file:///lib/A.pm");
}
#[test]
fn test_definition_candidates_include_duplicate_qualified_name_across_files() {
let index = WorkspaceIndex::new();
let uri_v2 = must(url::Url::parse("file:///lib/A-v2.pm"));
let uri_v1 = must(url::Url::parse("file:///lib/A-v1.pm"));
let source = "package A;\nsub foo { 1 }\n1;\n".to_string();
must(index.index_file(uri_v2, source.clone()));
must(index.index_file(uri_v1, source));
let candidates = index.definition_candidates("A::foo");
assert_eq!(candidates.len(), 2);
assert_eq!(candidates[0].uri, "file:///lib/A-v1.pm");
assert_eq!(candidates[1].uri, "file:///lib/A-v2.pm");
}
#[test]
fn test_definition_candidates_are_cleaned_on_remove_and_reindex() {
let index = WorkspaceIndex::new();
let uri = must(url::Url::parse("file:///lib/A.pm"));
must(index.index_file(uri.clone(), "package A;\nsub foo { 1 }\n1;\n".to_string()));
assert_eq!(index.definition_candidates("A::foo").len(), 1);
index.remove_file(uri.as_str());
assert!(index.definition_candidates("A::foo").is_empty());
must(index.index_file(uri, "package A;\nsub foo { 2 }\n1;\n".to_string()));
assert_eq!(index.definition_candidates("A::foo").len(), 1);
}
#[test]
fn test_definition_candidates_shared_symbol_survives_removal_of_sole_owner_of_other_symbol() {
let index = WorkspaceIndex::new();
let uri_a = must(url::Url::parse("file:///lib/A.pm"));
let uri_b = must(url::Url::parse("file:///lib/B.pm"));
must(index.index_file(
uri_a.clone(),
"package A;\nsub unique_to_a { 1 }\nsub shared { 1 }\n1;\n".to_string(),
));
must(index.index_file(uri_b.clone(), "package B;\nsub shared { 1 }\n1;\n".to_string()));
assert_eq!(index.definition_candidates("shared").len(), 2);
assert_eq!(index.definition_candidates("unique_to_a").len(), 1);
index.remove_file(uri_a.as_str());
assert!(
index.definition_candidates("unique_to_a").is_empty(),
"unique_to_a should be gone after removing A"
);
assert_eq!(
index.definition_candidates("shared").len(),
1,
"shared should still have B's candidate after removing A"
);
assert_eq!(
index.definition_candidates("shared")[0].uri,
"file:///lib/B.pm",
"remaining shared candidate must be from B"
);
}
#[test]
fn test_folder_context_in_file_index() {
let index = WorkspaceIndex::new();
index.set_workspace_folders(vec![
"file:///project1".to_string(),
"file:///project2".to_string(),
]);
let uri1 = "file:///project1/lib/Module.pm";
let code1 = r#"
package Module;
sub test_sub {
return 1;
}
"#;
must(index.index_file(must(url::Url::parse(uri1)), code1.to_string()));
let uri2 = "file:///project2/lib/Other.pm";
let code2 = r#"
package Other;
sub other_sub {
return 2;
}
"#;
must(index.index_file(must(url::Url::parse(uri2)), code2.to_string()));
let symbols1 = index.file_symbols(uri1);
assert_eq!(symbols1.len(), 2, "Should have 2 symbols in Module.pm");
for symbol in &symbols1 {
assert_eq!(symbol.uri, uri1, "Symbol URI should match file URI");
}
let symbols2 = index.file_symbols(uri2);
assert_eq!(symbols2.len(), 2, "Should have 2 symbols in Other.pm");
for symbol in &symbols2 {
assert_eq!(symbol.uri, uri2, "Symbol URI should match file URI");
}
let files = index.files.read();
let file_index1 = must_some(files.get(&DocumentStore::uri_key(uri1)));
assert_eq!(
file_index1.folder_uri,
Some("file:///project1".to_string()),
"File should be attributed to correct workspace folder"
);
let file_index2 = must_some(files.get(&DocumentStore::uri_key(uri2)));
assert_eq!(
file_index2.folder_uri,
Some("file:///project2".to_string()),
"File should be attributed to correct workspace folder"
);
}
#[test]
fn test_determine_folder_uri() {
let index = WorkspaceIndex::new();
index.set_workspace_folders(vec![
"file:///project1".to_string(),
"file:///project2".to_string(),
]);
let folder1 = index.determine_folder_uri("file:///project1/lib/Module.pm");
assert_eq!(
folder1,
Some("file:///project1".to_string()),
"Should determine folder for file in project1"
);
let folder2 = index.determine_folder_uri("file:///project2/lib/Other.pm");
assert_eq!(
folder2,
Some("file:///project2".to_string()),
"Should determine folder for file in project2"
);
let folder_none = index.determine_folder_uri("file:///other/project/Module.pm");
assert_eq!(folder_none, None, "Should return None for file outside workspace folders");
}
#[test]
fn test_determine_folder_uri_prefers_most_specific_match() {
let index = WorkspaceIndex::new();
index.set_workspace_folders(vec![
"file:///project".to_string(),
"file:///project/lib".to_string(),
]);
let folder = index.determine_folder_uri("file:///project/lib/My/Module.pm");
assert_eq!(
folder,
Some("file:///project/lib".to_string()),
"Nested workspace folders should attribute files to the most specific folder"
);
}
#[test]
fn test_remove_folder() {
let index = WorkspaceIndex::new();
index.set_workspace_folders(vec![
"file:///project1".to_string(),
"file:///project2".to_string(),
]);
let uri1 = "file:///project1/lib/Module.pm";
let code1 = r#"
package Module;
sub test_sub {
return 1;
}
"#;
must(index.index_file(must(url::Url::parse(uri1)), code1.to_string()));
let uri2 = "file:///project2/lib/Other.pm";
let code2 = r#"
package Other;
sub other_sub {
return 2;
}
"#;
must(index.index_file(must(url::Url::parse(uri2)), code2.to_string()));
assert_eq!(index.file_count(), 2, "Should have 2 files indexed");
assert_eq!(index.document_store().count(), 2, "Document store should track both files");
index.remove_folder("file:///project1");
assert_eq!(index.file_count(), 1, "Should have 1 file after removing folder");
assert_eq!(
index.document_store().count(),
1,
"Document store should drop files removed via folder deletion"
);
assert!(index.file_symbols(uri1).is_empty(), "File from removed folder should be gone");
assert_eq!(
index.file_symbols(uri2).len(),
2,
"File from remaining folder should still be present"
);
}
#[test]
fn test_remove_folder_removes_symbol_free_files() {
let index = WorkspaceIndex::new();
index.set_workspace_folders(vec!["file:///project1".to_string()]);
let uri = "file:///project1/empty.pl";
must(index.index_file(must(url::Url::parse(uri)), "# comments only".to_string()));
assert_eq!(index.file_count(), 1, "Expected file to be indexed");
index.remove_folder("file:///project1");
assert_eq!(index.file_count(), 0, "Folder removal should delete symbol-free files");
assert_eq!(
index.document_store().count(),
0,
"Document store should stay in sync for symbol-free files"
);
}
#[test]
fn test_require_with_variable_target_is_not_indexed() -> Result<(), Box<dyn std::error::Error>>
{
let index = WorkspaceIndex::new();
let uri = must(url::Url::parse("file:///test/require-var.pl"));
let src = r#"package Test;
my $loader = 'MyModule';
require $loader;
1;
"#;
must(index.index_file(uri.clone(), src.to_string()));
let deps = index.file_dependencies(uri.as_str());
assert!(
!deps.contains("MyModule"),
"require with variable target should not register static dependency"
);
Ok(())
}
#[test]
fn test_multiple_import_calls_on_same_module() -> Result<(), Box<dyn std::error::Error>> {
let index = WorkspaceIndex::new();
let uri = must(url::Url::parse("file:///test/multi-import.pl"));
let src = r#"package Test;
require Toolkit;
Toolkit->import('func_a');
Toolkit->import(qw(func_b func_c));
1;
"#;
must(index.index_file(uri.clone(), src.to_string()));
let deps = index.file_dependencies(uri.as_str());
assert!(deps.contains("Toolkit"), "module should be tracked as dependency");
for symbol in &["func_a", "func_b", "func_c"] {
let refs = index.find_references(symbol);
assert!(!refs.is_empty(), "all imported symbols should be indexed: {}", symbol);
}
Ok(())
}
#[test]
fn test_require_string_vs_bareword_normalization() -> Result<(), Box<dyn std::error::Error>> {
let index = WorkspaceIndex::new();
let uri = must(url::Url::parse("file:///test/require-string.pl"));
let src = r#"package Consumer;
require "String/Based/Module.pm";
String::Based::Module->import('exported');
1;
"#;
must(index.index_file(uri.clone(), src.to_string()));
let deps = index.file_dependencies(uri.as_str());
assert!(
deps.contains("String::Based::Module"),
"require string form should normalize path separators to ::"
);
let refs = index.find_references("exported");
assert!(!refs.is_empty(), "import should be indexed even with string-form require");
Ok(())
}
#[test]
fn test_import_without_require_registers_as_method_call()
-> Result<(), Box<dyn std::error::Error>> {
let index = WorkspaceIndex::new();
let uri = must(url::Url::parse("file:///test/orphan-import.pl"));
let src = r#"package Test;
Unrelated::Module->import('orphaned');
orphaned();
1;
"#;
must(index.index_file(uri.clone(), src.to_string()));
let _refs = index.find_references("orphaned");
Ok(())
}
#[test]
fn test_nested_blocks_preserve_require_scope() -> Result<(), Box<dyn std::error::Error>> {
let index = WorkspaceIndex::new();
let uri = must(url::Url::parse("file:///test/nested.pl"));
let src = r#"package Test;
{
require Outer;
{
Outer->import('nested_sym');
}
}
1;
"#;
must(index.index_file(uri.clone(), src.to_string()));
let deps = index.file_dependencies(uri.as_str());
assert!(
deps.contains("Outer"),
"require in outer block should be visible to nested import"
);
let refs = index.find_references("nested_sym");
assert!(!refs.is_empty(), "symbol imported in nested block should still be indexed");
Ok(())
}
#[test]
fn test_require_path_without_pm_extension() -> Result<(), Box<dyn std::error::Error>> {
let index = WorkspaceIndex::new();
let uri = must(url::Url::parse("file:///test/no-ext.pl"));
let src = r#"package Test;
require "My/Module";
My::Module->import('func');
1;
"#;
must(index.index_file(uri.clone(), src.to_string()));
let deps = index.file_dependencies(uri.as_str());
assert!(
deps.contains("My::Module"),
"require without .pm extension should normalize to module path"
);
Ok(())
}
#[test]
fn test_qw_with_bracket_delimiters() -> Result<(), Box<dyn std::error::Error>> {
let index = WorkspaceIndex::new();
let uri = must(url::Url::parse("file:///test/qw-delim.pl"));
let src = r#"package Test;
require DelimModule;
DelimModule->import(qw[sym1 sym2]);
DelimModule->import(qw{sym3 sym4});
1;
"#;
must(index.index_file(uri.clone(), src.to_string()));
for symbol in &["sym1", "sym2", "sym3", "sym4"] {
let refs = index.find_references(symbol);
assert!(
!refs.is_empty(),
"symbols from qw with bracket delimiters should be indexed: {}",
symbol
);
}
Ok(())
}
#[test]
fn test_array_literal_import_args() -> Result<(), Box<dyn std::error::Error>> {
let index = WorkspaceIndex::new();
let uri = must(url::Url::parse("file:///test/array-import.pl"));
let src = r#"package Test;
require ArrayModule;
ArrayModule->import(['sym_x', 'sym_y']);
1;
"#;
must(index.index_file(uri.clone(), src.to_string()));
for symbol in &["sym_x", "sym_y"] {
let refs = index.find_references(symbol);
assert!(
!refs.is_empty(),
"symbols from array literal import should be indexed: {}",
symbol
);
}
Ok(())
}
#[test]
fn test_require_inside_conditional_still_registers_dependency()
-> Result<(), Box<dyn std::error::Error>> {
let index = WorkspaceIndex::new();
let uri = must(url::Url::parse("file:///test/cond-require.pl"));
let src = r#"package Test;
if (1) {
require ConditionalMod;
ConditionalMod->import('cond_func');
}
1;
"#;
must(index.index_file(uri.clone(), src.to_string()));
let deps = index.file_dependencies(uri.as_str());
assert!(
deps.contains("ConditionalMod"),
"require inside conditional should still register as dependency"
);
let refs = index.find_references("cond_func");
assert!(!refs.is_empty(), "import inside conditional should still index symbols");
Ok(())
}
#[test]
fn test_mixed_string_and_bareword_imports() -> Result<(), Box<dyn std::error::Error>> {
let index = WorkspaceIndex::new();
let uri = must(url::Url::parse("file:///test/mixed-import.pl"));
let src = r#"package Test;
require MixedMod;
MixedMod->import('string_sym');
MixedMod->import(qw(qw_one qw_two));
1;
"#;
must(index.index_file(uri.clone(), src.to_string()));
let deps = index.file_dependencies(uri.as_str());
assert!(deps.contains("MixedMod"), "require should register dependency");
for symbol in &["string_sym", "qw_one", "qw_two"] {
let refs = index.find_references(symbol);
assert!(!refs.is_empty(), "all import forms should index symbols: {}", symbol);
}
Ok(())
}
fn make_shard(
uri: &str,
content_hash: u64,
anchors_hash: Option<u64>,
entities_hash: Option<u64>,
occurrences_hash: Option<u64>,
edges_hash: Option<u64>,
) -> FileFactShard {
let file_id = {
let mut h = DefaultHasher::new();
uri.hash(&mut h);
FileId(h.finish())
};
FileFactShard {
source_uri: uri.to_string(),
file_id,
content_hash,
anchors_hash,
entities_hash,
occurrences_hash,
edges_hash,
anchors: Vec::new(),
entities: Vec::new(),
occurrences: Vec::new(),
edges: Vec::new(),
}
}
#[test]
fn incremental_replace_skips_when_content_hash_unchanged()
-> Result<(), Box<dyn std::error::Error>> {
let index = WorkspaceIndex::new();
let uri = "file:///lib/Same.pm";
let key = DocumentStore::uri_key(uri);
let shard_v1 = make_shard(uri, 42, Some(1), Some(2), Some(3), Some(4));
let r1 = index.replace_fact_shard_incremental(&key, shard_v1);
assert!(!r1.content_unchanged);
let shard_v2 = make_shard(uri, 42, Some(100), Some(200), Some(300), Some(400));
let r2 = index.replace_fact_shard_incremental(&key, shard_v2);
assert!(r2.content_unchanged);
assert!(!r2.anchors_updated);
assert!(!r2.entities_updated);
assert!(!r2.occurrences_updated);
assert!(!r2.edges_updated);
let stored = must_some(index.file_fact_shard(uri));
assert_eq!(stored.anchors_hash, Some(1));
Ok(())
}
#[test]
fn incremental_replace_skips_unchanged_categories() -> Result<(), Box<dyn std::error::Error>> {
let index = WorkspaceIndex::new();
let uri = "file:///lib/Partial.pm";
let key = DocumentStore::uri_key(uri);
let shard_v1 = make_shard(uri, 1, Some(10), Some(20), Some(30), Some(40));
index.replace_fact_shard_incremental(&key, shard_v1);
let shard_v2 = make_shard(uri, 2, Some(10), Some(20), Some(99), Some(88));
let result = index.replace_fact_shard_incremental(&key, shard_v2);
assert!(!result.content_unchanged);
assert!(!result.anchors_updated, "anchors hash unchanged → skip");
assert!(!result.entities_updated, "entities hash unchanged → skip");
assert!(result.occurrences_updated, "occurrences hash changed → update");
assert!(result.edges_updated, "edges hash changed → update");
Ok(())
}
#[test]
fn incremental_replace_updates_changed_categories() -> Result<(), Box<dyn std::error::Error>> {
let index = WorkspaceIndex::new();
let uri = "file:///lib/Changed.pm";
let key = DocumentStore::uri_key(uri);
let shard_v1 = make_shard(uri, 1, Some(10), Some(20), Some(30), Some(40));
index.replace_fact_shard_incremental(&key, shard_v1);
let shard_v2 = make_shard(uri, 2, Some(11), Some(21), Some(31), Some(41));
let result = index.replace_fact_shard_incremental(&key, shard_v2);
assert!(!result.content_unchanged);
assert!(result.anchors_updated);
assert!(result.entities_updated);
assert!(result.occurrences_updated);
assert!(result.edges_updated);
let stored = must_some(index.file_fact_shard(uri));
assert_eq!(stored.content_hash, 2);
assert_eq!(stored.anchors_hash, Some(11));
Ok(())
}
#[test]
fn incremental_replace_first_insert_updates_all() -> Result<(), Box<dyn std::error::Error>> {
let index = WorkspaceIndex::new();
let uri = "file:///lib/New.pm";
let key = DocumentStore::uri_key(uri);
let shard = make_shard(uri, 1, Some(10), Some(20), Some(30), Some(40));
let result = index.replace_fact_shard_incremental(&key, shard);
assert!(!result.content_unchanged);
assert!(result.anchors_updated);
assert!(result.entities_updated);
assert!(result.occurrences_updated);
assert!(result.edges_updated);
Ok(())
}
#[test]
fn incremental_replace_none_hashes_treated_as_changed() -> Result<(), Box<dyn std::error::Error>>
{
let index = WorkspaceIndex::new();
let uri = "file:///lib/Legacy.pm";
let key = DocumentStore::uri_key(uri);
let shard_v1 = make_shard(uri, 1, Some(10), Some(20), Some(30), Some(40));
index.replace_fact_shard_incremental(&key, shard_v1);
let shard_v2 = make_shard(uri, 2, None, Some(20), None, Some(40));
let result = index.replace_fact_shard_incremental(&key, shard_v2);
assert!(!result.content_unchanged);
assert!(result.anchors_updated, "None new hash → changed");
assert!(!result.entities_updated, "same hash → skip");
assert!(result.occurrences_updated, "None new hash → changed");
assert!(!result.edges_updated, "same hash → skip");
Ok(())
}
#[test]
fn incremental_replace_updates_reference_index_on_occurrence_change()
-> Result<(), Box<dyn std::error::Error>> {
use perl_semantic_facts::{AnchorId, Confidence, OccurrenceId, OccurrenceKind, Provenance};
let index = WorkspaceIndex::new();
let uri = "file:///lib/RefIdx.pm";
let key = DocumentStore::uri_key(uri);
let file_id = {
let mut h = DefaultHasher::new();
uri.hash(&mut h);
FileId(h.finish())
};
let mut shard_v1 = make_shard(uri, 1, Some(10), Some(20), Some(30), Some(40));
let anchor_id = AnchorId(1);
shard_v1.anchors.push(perl_semantic_facts::AnchorFact {
id: anchor_id,
file_id,
span_start_byte: 0,
span_end_byte: 5,
scope_id: None,
provenance: Provenance::ExactAst,
confidence: Confidence::High,
});
shard_v1.occurrences.push(perl_semantic_facts::OccurrenceFact {
id: OccurrenceId(1),
kind: OccurrenceKind::Call,
entity_id: Some(EntityId(100)),
anchor_id,
scope_id: None,
provenance: Provenance::ExactAst,
confidence: Confidence::High,
});
shard_v1.entities.push(perl_semantic_facts::EntityFact {
id: EntityId(100),
kind: EntityKind::Subroutine,
canonical_name: "RefIdx::foo".to_string(),
anchor_id: Some(anchor_id),
scope_id: None,
provenance: Provenance::ExactAst,
confidence: Confidence::High,
});
index.replace_fact_shard_incremental(&key, shard_v1);
assert!(
index.semantic_reference_index.read().name_count() > 0
|| index.semantic_reference_index.read().entity_count() > 0,
"reference index should be populated after first insert"
);
let shard_v2_same = make_shard(uri, 1, Some(10), Some(20), Some(99), Some(99));
let r = index.replace_fact_shard_incremental(&key, shard_v2_same);
assert!(r.content_unchanged);
let mut shard_v3 = make_shard(uri, 3, Some(11), Some(21), Some(30), Some(40));
shard_v3.anchors.push(perl_semantic_facts::AnchorFact {
id: anchor_id,
file_id,
span_start_byte: 0,
span_end_byte: 5,
scope_id: None,
provenance: Provenance::ExactAst,
confidence: Confidence::High,
});
shard_v3.occurrences.push(perl_semantic_facts::OccurrenceFact {
id: OccurrenceId(1),
kind: OccurrenceKind::Call,
entity_id: Some(EntityId(100)),
anchor_id,
scope_id: None,
provenance: Provenance::ExactAst,
confidence: Confidence::High,
});
shard_v3.entities.push(perl_semantic_facts::EntityFact {
id: EntityId(100),
kind: EntityKind::Subroutine,
canonical_name: "RefIdx::foo".to_string(),
anchor_id: Some(anchor_id),
scope_id: None,
provenance: Provenance::ExactAst,
confidence: Confidence::High,
});
let r3 = index.replace_fact_shard_incremental(&key, shard_v3);
assert!(!r3.occurrences_updated, "occurrence hash unchanged → skip");
assert!(!r3.edges_updated, "edge hash unchanged → skip");
Ok(())
}
#[test]
fn index_file_stores_fact_shard_incrementally() -> Result<(), Box<dyn std::error::Error>> {
let index = WorkspaceIndex::new();
let uri = "file:///lib/Incr.pm";
let code = "package Incr;\nsub foo { 1 }\n1;\n";
must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
let shard1 = must_some(index.file_fact_shard(uri));
assert!(shard1.anchors_hash.is_some());
assert!(
shard1.anchors.iter().any(|anchor| anchor.provenance == Provenance::ExactAst),
"index_file should store the canonical semantic shard when adapters produce facts"
);
assert!(
shard1.entities.iter().any(|entity| entity.provenance == Provenance::ExactAst),
"index_file should store canonical entities rather than legacy fallback entities"
);
must(index.index_file(must(url::Url::parse(uri)), code.to_string()));
let shard2 = must_some(index.file_fact_shard(uri));
assert_eq!(shard1.content_hash, shard2.content_hash);
let code2 = "package Incr;\nsub bar { 2 }\n1;\n";
must(index.index_file(must(url::Url::parse(uri)), code2.to_string()));
let shard3 = must_some(index.file_fact_shard(uri));
assert_ne!(shard1.content_hash, shard3.content_hash);
Ok(())
}
mod prop_incremental_invalidation {
use super::*;
use proptest::prelude::*;
use proptest::test_runner::Config as ProptestConfig;
fn arb_category_hash() -> impl Strategy<Value = Option<u64>> {
prop_oneof![
1 => Just(None),
9 => any::<u64>().prop_map(Some),
]
}
fn arb_shard(uri: &'static str) -> impl Strategy<Value = FileFactShard> {
(
any::<u64>(), arb_category_hash(), arb_category_hash(), arb_category_hash(), arb_category_hash(), )
.prop_map(move |(content_hash, ah, eh, oh, edh)| {
make_shard(uri, content_hash, ah, eh, oh, edh)
})
}
proptest! {
#![proptest_config(ProptestConfig {
failure_persistence: None,
..ProptestConfig::default()
})]
#[test]
fn prop_incremental_invalidation_correctness(
old_shard in arb_shard("file:///lib/Prop.pm"),
new_shard in arb_shard("file:///lib/Prop.pm"),
) {
let index = WorkspaceIndex::new();
let key = DocumentStore::uri_key("file:///lib/Prop.pm");
index.replace_fact_shard_incremental(&key, old_shard.clone());
let result = index.replace_fact_shard_incremental(&key, new_shard.clone());
if old_shard.content_hash == new_shard.content_hash {
prop_assert!(
result.content_unchanged,
"content_unchanged must be true when content_hash is the same"
);
prop_assert!(
!result.anchors_updated,
"anchors_updated must be false when content_hash unchanged"
);
prop_assert!(
!result.entities_updated,
"entities_updated must be false when content_hash unchanged"
);
prop_assert!(
!result.occurrences_updated,
"occurrences_updated must be false when content_hash unchanged"
);
prop_assert!(
!result.edges_updated,
"edges_updated must be false when content_hash unchanged"
);
} else {
prop_assert!(
!result.content_unchanged,
"content_unchanged must be false when content_hash differs"
);
let anchors_should_update = crate::semantic::invalidation::category_hash_changed(
old_shard.anchors_hash,
new_shard.anchors_hash,
);
prop_assert_eq!(
result.anchors_updated,
anchors_should_update,
"anchors_updated mismatch: old={:?} new={:?}",
old_shard.anchors_hash,
new_shard.anchors_hash,
);
let entities_should_update =
crate::semantic::invalidation::category_hash_changed(
old_shard.entities_hash,
new_shard.entities_hash,
);
prop_assert_eq!(
result.entities_updated,
entities_should_update,
"entities_updated mismatch: old={:?} new={:?}",
old_shard.entities_hash,
new_shard.entities_hash,
);
let occurrences_should_update =
crate::semantic::invalidation::category_hash_changed(
old_shard.occurrences_hash,
new_shard.occurrences_hash,
);
prop_assert_eq!(
result.occurrences_updated,
occurrences_should_update,
"occurrences_updated mismatch: old={:?} new={:?}",
old_shard.occurrences_hash,
new_shard.occurrences_hash,
);
let edges_should_update = crate::semantic::invalidation::category_hash_changed(
old_shard.edges_hash,
new_shard.edges_hash,
);
prop_assert_eq!(
result.edges_updated,
edges_should_update,
"edges_updated mismatch: old={:?} new={:?}",
old_shard.edges_hash,
new_shard.edges_hash,
);
}
}
}
}
}