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 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;
pub use crate::workspace::monitoring::{
DegradationReason, EarlyExitReason, EarlyExitRecord, IndexInstrumentationSnapshot,
IndexMetrics, IndexPerformanceCaps, IndexPhase, IndexPhaseTransition, IndexResourceLimits,
IndexStateKind, IndexStateTransition, ResourceKind,
};
#[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)]
pub struct Location {
pub uri: String,
pub range: Range,
}
#[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,
}
fn default_has_body() -> bool {
true
}
pub use perl_symbol_types::{SymbolKind, VarKind};
fn sigil_to_var_kind(sigil: &str) -> VarKind {
match sigil {
"@" => VarKind::Array,
"%" => VarKind::Hash,
_ => VarKind::Scalar, }
}
#[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>,
}
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(),
}
}
}
#[derive(Default)]
struct FileIndex {
symbols: Vec<WorkspaceSymbol>,
references: HashMap<String, Vec<SymbolReference>>,
dependencies: HashSet<String>,
content_hash: u64,
}
pub struct WorkspaceIndex {
files: Arc<RwLock<HashMap<String, FileIndex>>>,
symbols: Arc<RwLock<HashMap<String, String>>>,
global_references: Arc<RwLock<HashMap<String, Vec<Location>>>>,
document_store: DocumentStore,
}
impl WorkspaceIndex {
fn rebuild_symbol_cache(
files: &HashMap<String, FileIndex>,
symbols: &mut HashMap<String, String>,
) {
symbols.clear();
for file_index in files.values() {
for symbol in &file_index.symbols {
if let Some(ref qname) = symbol.qualified_name {
symbols.insert(qname.clone(), symbol.uri.clone());
}
symbols.insert(symbol.name.clone(), symbol.uri.clone());
}
}
}
fn incremental_remove_symbols(
files: &HashMap<String, FileIndex>,
symbols: &mut HashMap<String, String>,
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 {
if symbols.get(qname) == Some(&sym.uri) {
symbols.remove(qname);
affected_names.push(qname.clone());
}
}
if symbols.get(&sym.name) == Some(&sym.uri) {
symbols.remove(&sym.name);
affected_names.push(sym.name.clone());
}
}
if !affected_names.is_empty() {
for file_index in files.values() {
for sym in &file_index.symbols {
if let Some(ref qname) = sym.qualified_name {
if !symbols.contains_key(qname) && affected_names.contains(qname) {
symbols.insert(qname.clone(), sym.uri.clone());
}
}
if !symbols.contains_key(&sym.name) && affected_names.contains(&sym.name) {
symbols.insert(sym.name.clone(), sym.uri.clone());
}
}
}
}
}
fn incremental_add_symbols(symbols: &mut HashMap<String, String>, file_index: &FileIndex) {
for sym in &file_index.symbols {
if let Some(ref qname) = sym.qualified_name {
symbols.insert(qname.clone(), sym.uri.clone());
}
symbols.insert(sym.name.clone(), sym.uri.clone());
}
}
fn find_definition_in_files(
files: &HashMap<String, FileIndex>,
symbol_name: &str,
uri_filter: Option<&str>,
) -> Option<(Location, String)> {
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)
{
return Some((
Location { uri: symbol.uri.clone(), range: symbol.range },
symbol.uri.clone(),
));
}
}
}
None
}
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())),
document_store: DocumentStore::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))),
document_store: DocumentStore::new(),
}
}
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 mut file_index = FileIndex { content_hash, ..Default::default() };
let mut visitor = IndexVisitor::new(&mut doc, uri_str.clone());
visitor.visit(&ast, &mut file_index);
{
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 });
}
}
}
}
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) {
let mut symbols = self.symbols.write();
Self::incremental_remove_symbols(&files, &mut symbols, &file_index);
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())
}
#[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 mut file_index = FileIndex { content_hash, ..Default::default() };
let mut visitor = IndexVisitor::new(&mut doc, uri_str.clone());
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 });
}
}
}
}
locations
}
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,
));
}
}
}
}
seen.len()
}
pub fn find_definition(&self, symbol_name: &str) -> Option<Location> {
let cached_uri = {
let symbols = self.symbols.read();
symbols.get(symbol_name).cloned()
};
let files = self.files.read();
if let Some(ref uri_str) = cached_uri
&& let Some((location, _uri)) =
Self::find_definition_in_files(&files, symbol_name, Some(uri_str))
{
return Some(location);
}
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.insert(symbol_name.to_string(), uri);
return Some(location);
}
None
}
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();
}
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()
}
#[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, uri) in symbols_guard.iter() {
symbols_bytes += qname.len() + uri.len();
}
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 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 files = self.files.read();
let mut dependents = Vec::new();
for (uri_key, file_index) in files.iter() {
if file_index.dependencies.contains(module_name) {
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>,
}
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) -> Self {
Self { document: document.clone(), uri, current_package: Some("main".to_string()) }
}
fn visit(&mut self, node: &Node, file_index: &mut FileIndex) {
self.visit_node(node, file_index);
}
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());
file_index.symbols.push(WorkspaceSymbol {
name: package_name.clone(),
kind: SymbolKind::Package,
uri: self.uri.clone(),
range: self.node_to_range(node),
qualified_name: Some(package_name),
documentation: None,
container_name: None,
has_body: true,
});
}
NodeKind::Subroutine { name, body, .. } => {
if let Some(name_str) = name.clone() {
let qualified_name = if let Some(ref pkg) = self.current_package {
format!("{}::{}", pkg, name_str)
} else {
name_str.clone()
};
let existing_symbol_idx = file_index.symbols.iter().position(|s| {
s.name == name_str && s.container_name == self.current_package
});
if let Some(idx) = existing_symbol_idx {
file_index.symbols[idx].range = self.node_to_range(node);
} else {
file_index.symbols.push(WorkspaceSymbol {
name: name_str.clone(),
kind: SymbolKind::Subroutine,
uri: self.uri.clone(),
range: self.node_to_range(node),
qualified_name: Some(qualified_name),
documentation: None,
container_name: self.current_package.clone(),
has_body: true, });
}
file_index.references.entry(name_str.clone()).or_default().push(
SymbolReference {
uri: self.uri.clone(),
range: self.node_to_range(node),
kind: ReferenceKind::Definition,
},
);
}
self.visit_node(body, file_index);
}
NodeKind::VariableDeclaration { variable, initializer, .. } => {
if let NodeKind::Variable { sigil, name } = &variable.kind {
let var_name = format!("{}{}", sigil, name);
file_index.symbols.push(WorkspaceSymbol {
name: var_name.clone(),
kind: SymbolKind::Variable(sigil_to_var_kind(sigil)),
uri: self.uri.clone(),
range: self.node_to_range(variable),
qualified_name: None,
documentation: None,
container_name: self.current_package.clone(),
has_body: true, });
file_index.references.entry(var_name.clone()).or_default().push(
SymbolReference {
uri: self.uri.clone(),
range: self.node_to_range(variable),
kind: ReferenceKind::Definition,
},
);
}
if let Some(init) = initializer {
self.visit_node(init, file_index);
}
}
NodeKind::VariableListDeclaration { variables, initializer, .. } => {
for var in variables {
if let NodeKind::Variable { sigil, name } = &var.kind {
let var_name = format!("{}{}", sigil, name);
file_index.symbols.push(WorkspaceSymbol {
name: var_name.clone(),
kind: SymbolKind::Variable(sigil_to_var_kind(sigil)),
uri: self.uri.clone(),
range: self.node_to_range(var),
qualified_name: None,
documentation: None,
container_name: self.current_package.clone(),
has_body: true,
});
file_index.references.entry(var_name).or_default().push(SymbolReference {
uri: self.uri.clone(),
range: self.node_to_range(var),
kind: ReferenceKind::Definition,
});
}
}
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,
});
for arg in args {
self.visit_node(arg, file_index);
}
}
NodeKind::Use { module, args, .. } => {
let module_name = module.clone();
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(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 method_key = qualified_method.as_ref().unwrap_or(method);
file_index.references.entry(method_key.clone()).or_default().push(
SymbolReference {
uri: self.uri.clone(),
range: self.node_to_range(node),
kind: ReferenceKind::Usage,
},
);
for arg in args {
self.visit_node(arg, file_index);
}
}
NodeKind::No { module, .. } => {
let module_name = module.clone();
file_index.dependencies.insert(module_name.clone());
}
NodeKind::Class { name, .. } => {
let class_name = name.clone();
self.current_package = Some(class_name.clone());
file_index.symbols.push(WorkspaceSymbol {
name: class_name.clone(),
kind: SymbolKind::Class,
uri: self.uri.clone(),
range: self.node_to_range(node),
qualified_name: Some(class_name),
documentation: None,
container_name: None,
has_body: true,
});
}
NodeKind::Method { name, body, signature, .. } => {
let method_name = name.clone();
let qualified_name = if let Some(ref pkg) = self.current_package {
format!("{}::{}", pkg, method_name)
} else {
method_name.clone()
};
file_index.symbols.push(WorkspaceSymbol {
name: method_name.clone(),
kind: SymbolKind::Method,
uri: self.uri.clone(),
range: self.node_to_range(node),
qualified_name: Some(qualified_name),
documentation: None,
container_name: self.current_package.clone(),
has_body: true,
});
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 } => {
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 extract_module_names_from_use_args(args: &[String]) -> Vec<String> {
let joined = args.join(" ");
let inner = if let Some(start) = joined.find("qw(") {
if let Some(end) = joined[start..].find(')') {
joined[start + 3..start + end].to_string()
} else {
joined.clone()
}
} else {
joined.clone()
};
inner
.split_whitespace()
.filter_map(|token| {
if token.starts_with('-') {
return None;
}
let stripped = token.trim_matches('\'').trim_matches('"');
let stripped = stripped.trim_matches('(').trim_matches(')');
let stripped = stripped.trim_matches('\'').trim_matches('"');
if stripped.is_empty() {
return None;
}
if stripped.chars().all(|c| c.is_alphanumeric() || c == '_' || c == ':' || c == '\'') {
Some(stripped.to_string())
} else {
None
}
})
.collect()
}
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_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_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_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 = url::Url::parse("file:///test/workspace/lib/MyBase.pm").unwrap();
index
.index_file(base_url, "package MyBase;\nsub new { bless {}, shift }\n1;\n".to_string())
.expect("indexing MyBase.pm");
let child_url = url::Url::parse("file:///test/workspace/child.pl").unwrap();
index
.index_file(child_url, "package Child;\nuse parent 'MyBase';\n1;\n".to_string())
.expect("indexing child.pl");
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_parser_produces_correct_args_for_use_parent() {
use crate::Parser;
let mut p = Parser::new("package Child;\nuse parent 'MyBase';\n1;\n");
let ast = p.parse().expect("parse succeeded");
if let NodeKind::Program { statements } = &ast.kind {
for stmt in statements {
if let NodeKind::Use { module, args, .. } = &stmt.kind {
if module == "parent" {
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
);
return; }
}
}
panic!("No Use node with module='parent' found in AST");
} else {
panic!("Expected Program root");
}
}
#[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_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_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());
}
}