use crate::analysis::import_extractor::ImportExtractor;
use crate::symbol::{SymbolKind, SymbolTable};
use crate::{Node, NodeKind, Parser};
use perl_semantic_facts::FileId;
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, RwLock};
#[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,
}
#[derive(Clone, Debug)]
pub struct SymbolDef {
pub name: String,
pub kind: SymbolKind,
pub uri: String,
pub start: usize,
pub end: usize,
}
#[derive(Default)]
pub struct WorkspaceIndex {
by_name: HashMap<String, Vec<SymbolDef>>,
by_uri: HashMap<String, HashSet<String>>,
imports_by_uri: RwLock<HashMap<String, HashSet<String>>>,
}
impl WorkspaceIndex {
pub fn new() -> Self {
Self::default()
}
pub fn update_from_document(&mut self, uri: &str, content: &str, symtab: &SymbolTable) {
self.remove_document(uri);
let mut names_in_file = HashSet::new();
for symbols in symtab.symbols.values() {
for symbol in symbols {
let name = symbol.name.clone();
names_in_file.insert(name.clone());
let def = SymbolDef {
name: symbol.name.clone(),
kind: symbol.kind,
uri: uri.to_string(),
start: symbol.location.start,
end: symbol.location.end,
};
self.by_name.entry(name).or_default().push(def);
}
}
self.by_uri.insert(uri.to_string(), names_in_file);
if !content.is_empty()
&& let Ok(dependencies) = Self::extract_dependencies(content)
{
self.set_file_dependencies(uri, dependencies);
}
}
pub fn remove_document(&mut self, uri: &str) {
if let Some(names) = self.by_uri.remove(uri) {
for name in names {
if let Some(defs) = self.by_name.get_mut(&name) {
defs.retain(|d| d.uri != uri);
if defs.is_empty() {
self.by_name.remove(&name);
}
}
}
}
self.remove_file_dependencies(uri);
}
pub fn index_file_str(&self, uri: &str, content: &str) -> Result<(), String> {
let dependencies = Self::extract_dependencies(content)?;
let mut imports = self
.imports_by_uri
.write()
.map_err(|_| "workspace import index lock poisoned".to_string())?;
imports.insert(uri.to_string(), dependencies);
Ok(())
}
pub fn file_dependencies(&self, uri: &str) -> HashSet<String> {
let Ok(imports) = self.imports_by_uri.read() else {
return HashSet::new();
};
imports.get(uri).cloned().unwrap_or_default()
}
fn set_file_dependencies(&self, uri: &str, dependencies: HashSet<String>) {
if let Ok(mut imports) = self.imports_by_uri.write() {
imports.insert(uri.to_string(), dependencies);
}
}
fn remove_file_dependencies(&self, uri: &str) {
if let Ok(mut imports) = self.imports_by_uri.write() {
imports.remove(uri);
}
}
fn extract_dependencies(content: &str) -> Result<HashSet<String>, String> {
let mut parser = Parser::new(content);
let ast = parser.parse().map_err(|err| format!("Parse error: {err}"))?;
let mut dependencies: HashSet<String> = ImportExtractor::extract(&ast, FileId(0))
.into_iter()
.filter_map(|spec| {
if spec.module.is_empty() || matches!(spec.module.as_str(), "parent" | "base") {
None
} else {
Some(spec.module)
}
})
.collect();
Self::collect_parent_dependencies(&ast, &mut dependencies);
Ok(dependencies)
}
fn collect_parent_dependencies(node: &Node, dependencies: &mut HashSet<String>) {
if let NodeKind::Use { module, args, .. } = &node.kind
&& matches!(module.as_str(), "parent" | "base")
{
for name in Self::parent_names_from_args(args) {
dependencies.insert(name);
}
}
for child in node.children() {
Self::collect_parent_dependencies(child, dependencies);
}
}
fn parent_names_from_args(args: &[String]) -> Vec<String> {
args.iter()
.flat_map(|arg| Self::expand_parent_arg(arg))
.filter(|name| !name.starts_with('-'))
.collect()
}
fn expand_parent_arg(arg: &str) -> Vec<String> {
let trimmed = arg.trim();
if trimmed.is_empty() {
return Vec::new();
}
if let Some(content) = Self::parse_qw_content(trimmed) {
return content.split_whitespace().map(str::to_string).collect();
}
let unquoted = trimmed.trim_matches('\'').trim_matches('"').trim();
if unquoted.is_empty() { Vec::new() } else { vec![unquoted.to_string()] }
}
fn parse_qw_content(arg: &str) -> Option<&str> {
let rest = arg.strip_prefix("qw")?;
let mut chars = rest.chars();
let open = chars.next()?;
let close = match open {
'(' => ')',
'{' => '}',
'[' => ']',
'<' => '>',
delimiter => delimiter,
};
let start = open.len_utf8();
let end = rest.rfind(close)?;
(end >= start).then_some(&rest[start..end])
}
pub fn find_defs(&self, name: &str) -> &[SymbolDef] {
static EMPTY: Vec<SymbolDef> = Vec::new();
self.by_name.get(name).map(|v| v.as_slice()).unwrap_or(&EMPTY[..])
}
pub fn find_refs(&self, name: &str) -> Vec<SymbolDef> {
self.find_defs(name).to_vec()
}
pub fn search_symbols(&self, query: &str) -> Vec<SymbolDef> {
let query_lower = query.to_lowercase();
let mut results = Vec::new();
for (name, defs) in &self.by_name {
if name.to_lowercase().contains(&query_lower) {
results.extend(defs.clone());
}
}
results
}
pub fn symbol_count(&self) -> usize {
self.by_name.values().map(|v| v.len()).sum()
}
pub fn file_count(&self) -> usize {
self.by_uri.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::SourceLocation;
use crate::symbol::Symbol;
#[test]
fn test_workspace_index() {
let mut index = WorkspaceIndex::new();
let mut symtab = SymbolTable::new();
let symbol = Symbol {
name: "test_func".to_string(),
qualified_name: "main::test_func".to_string(),
kind: SymbolKind::Subroutine,
location: SourceLocation { start: 0, end: 10 },
scope_id: 0,
declaration: Some("sub".to_string()),
documentation: None,
attributes: Vec::new(),
};
symtab.symbols.entry("test_func".to_string()).or_default().push(symbol);
index.update_from_document("file:///test.pl", "", &symtab);
let defs = index.find_defs("test_func");
assert_eq!(defs.len(), 1);
assert_eq!(defs[0].name, "test_func");
assert_eq!(defs[0].uri, "file:///test.pl");
index.remove_document("file:///test.pl");
assert_eq!(index.find_defs("test_func").len(), 0);
}
}