perl_semantic_analyzer/analysis/
index.rs1use crate::analysis::import_extractor::ImportExtractor;
7use crate::symbol::{SymbolKind, SymbolTable};
8use crate::{Node, NodeKind, Parser};
9use perl_semantic_facts::FileId;
10use std::collections::{HashMap, HashSet};
11use std::sync::{Arc, RwLock};
12
13#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
15pub enum SymKind {
16 Var,
18 Sub,
20 Pack,
22}
23
24#[derive(Clone, Debug, Eq, PartialEq, Hash)]
26pub struct SymbolKey {
27 pub pkg: Arc<str>,
29 pub name: Arc<str>,
31 pub sigil: Option<char>,
33 pub kind: SymKind,
35}
36
37#[derive(Clone, Debug)]
39pub struct SymbolDef {
40 pub name: String,
42 pub kind: SymbolKind,
44 pub uri: String,
46 pub start: usize,
48 pub end: usize,
50}
51
52#[derive(Default)]
54pub struct WorkspaceIndex {
55 by_name: HashMap<String, Vec<SymbolDef>>,
57 by_uri: HashMap<String, HashSet<String>>,
59 imports_by_uri: RwLock<HashMap<String, HashSet<String>>>,
61}
62
63impl WorkspaceIndex {
64 pub fn new() -> Self {
66 Self::default()
67 }
68
69 pub fn update_from_document(&mut self, uri: &str, content: &str, symtab: &SymbolTable) {
71 self.remove_document(uri);
73
74 let mut names_in_file = HashSet::new();
76
77 for symbols in symtab.symbols.values() {
79 for symbol in symbols {
80 let name = symbol.name.clone();
81 names_in_file.insert(name.clone());
82
83 let def = SymbolDef {
84 name: symbol.name.clone(),
85 kind: symbol.kind,
86 uri: uri.to_string(),
87 start: symbol.location.start,
88 end: symbol.location.end,
89 };
90
91 self.by_name.entry(name).or_default().push(def);
92 }
93 }
94
95 self.by_uri.insert(uri.to_string(), names_in_file);
97
98 if !content.is_empty()
99 && let Ok(dependencies) = Self::extract_dependencies(content)
100 {
101 self.set_file_dependencies(uri, dependencies);
102 }
103 }
104
105 pub fn remove_document(&mut self, uri: &str) {
107 if let Some(names) = self.by_uri.remove(uri) {
108 for name in names {
109 if let Some(defs) = self.by_name.get_mut(&name) {
110 defs.retain(|d| d.uri != uri);
111 if defs.is_empty() {
112 self.by_name.remove(&name);
113 }
114 }
115 }
116 }
117 self.remove_file_dependencies(uri);
118 }
119
120 pub fn index_file_str(&self, uri: &str, content: &str) -> Result<(), String> {
122 let dependencies = Self::extract_dependencies(content)?;
123 let mut imports = self
124 .imports_by_uri
125 .write()
126 .map_err(|_| "workspace import index lock poisoned".to_string())?;
127 imports.insert(uri.to_string(), dependencies);
128 Ok(())
129 }
130
131 pub fn file_dependencies(&self, uri: &str) -> HashSet<String> {
133 let Ok(imports) = self.imports_by_uri.read() else {
134 return HashSet::new();
135 };
136 imports.get(uri).cloned().unwrap_or_default()
137 }
138
139 fn set_file_dependencies(&self, uri: &str, dependencies: HashSet<String>) {
140 if let Ok(mut imports) = self.imports_by_uri.write() {
141 imports.insert(uri.to_string(), dependencies);
142 }
143 }
144
145 fn remove_file_dependencies(&self, uri: &str) {
146 if let Ok(mut imports) = self.imports_by_uri.write() {
147 imports.remove(uri);
148 }
149 }
150
151 fn extract_dependencies(content: &str) -> Result<HashSet<String>, String> {
152 let mut parser = Parser::new(content);
153 let ast = parser.parse().map_err(|err| format!("Parse error: {err}"))?;
154 let mut dependencies: HashSet<String> = ImportExtractor::extract(&ast, FileId(0))
155 .into_iter()
156 .filter_map(|spec| {
157 if spec.module.is_empty() || matches!(spec.module.as_str(), "parent" | "base") {
158 None
159 } else {
160 Some(spec.module)
161 }
162 })
163 .collect();
164
165 Self::collect_parent_dependencies(&ast, &mut dependencies);
166 Ok(dependencies)
167 }
168
169 fn collect_parent_dependencies(node: &Node, dependencies: &mut HashSet<String>) {
170 if let NodeKind::Use { module, args, .. } = &node.kind
171 && matches!(module.as_str(), "parent" | "base")
172 {
173 for name in Self::parent_names_from_args(args) {
174 dependencies.insert(name);
175 }
176 }
177
178 for child in node.children() {
179 Self::collect_parent_dependencies(child, dependencies);
180 }
181 }
182
183 fn parent_names_from_args(args: &[String]) -> Vec<String> {
184 args.iter()
185 .flat_map(|arg| Self::expand_parent_arg(arg))
186 .filter(|name| !name.starts_with('-'))
187 .collect()
188 }
189
190 fn expand_parent_arg(arg: &str) -> Vec<String> {
191 let trimmed = arg.trim();
192 if trimmed.is_empty() {
193 return Vec::new();
194 }
195
196 if let Some(content) = Self::parse_qw_content(trimmed) {
197 return content.split_whitespace().map(str::to_string).collect();
198 }
199
200 let unquoted = trimmed.trim_matches('\'').trim_matches('"').trim();
201 if unquoted.is_empty() { Vec::new() } else { vec![unquoted.to_string()] }
202 }
203
204 pub(crate) fn parse_qw_content(arg: &str) -> Option<&str> {
205 perl_parser_core::parse_quote_operator_content(arg, "qw")
206 }
207
208 pub fn find_defs(&self, name: &str) -> &[SymbolDef] {
210 static EMPTY: Vec<SymbolDef> = Vec::new();
211 self.by_name.get(name).map(|v| v.as_slice()).unwrap_or(&EMPTY[..])
212 }
213
214 pub fn find_refs(&self, name: &str) -> Vec<SymbolDef> {
217 self.find_defs(name).to_vec()
220 }
221
222 pub fn search_symbols(&self, query: &str) -> Vec<SymbolDef> {
224 let query_lower = query.to_lowercase();
225 let mut results = Vec::new();
226
227 for (name, defs) in &self.by_name {
228 if name.to_lowercase().contains(&query_lower) {
229 results.extend(defs.clone());
230 }
231 }
232
233 results
234 }
235
236 pub fn symbol_count(&self) -> usize {
238 self.by_name.values().map(|v| v.len()).sum()
239 }
240
241 pub fn file_count(&self) -> usize {
243 self.by_uri.len()
244 }
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250 use crate::SourceLocation;
251 use crate::symbol::Symbol;
252
253 #[test]
259 fn parent_qw_space_before_delimiter_extracts_each_base() -> Result<(), String> {
260 let index = WorkspaceIndex::new();
261 index.index_file_str("file:///c.pl", "use parent qw [Foo::Base Bar::Base];\n1;\n")?;
262 let deps = index.file_dependencies("file:///c.pl");
263 assert!(deps.contains("Foo::Base"), "deps: {deps:?}");
264 assert!(deps.contains("Bar::Base"), "deps: {deps:?}");
265 assert!(
266 !deps.iter().any(|d| d.contains("qw")),
267 "no bogus qw-prefixed dependency should be recorded, got {deps:?}"
268 );
269 Ok(())
270 }
271
272 #[test]
276 fn parse_qw_content_tolerates_leading_space() {
277 assert_eq!(WorkspaceIndex::parse_qw_content("qw [a b]"), Some("a b"));
279 assert_eq!(WorkspaceIndex::parse_qw_content("qw(a b)"), Some("a b"));
280 assert_eq!(WorkspaceIndex::parse_qw_content("qw/a b/"), Some("a b"));
281 assert_eq!(WorkspaceIndex::parse_qw_content("qw\t[a b]"), Some("a b"));
283 assert_eq!(WorkspaceIndex::parse_qw_content("qw\t(a b)"), Some("a b"));
284 assert_eq!(WorkspaceIndex::parse_qw_content("qw\n[a b]"), Some("a b"));
286 assert_eq!(WorkspaceIndex::parse_qw_content("qw \t [a b]"), Some("a b"));
288 assert_eq!(WorkspaceIndex::parse_qw_content("qwfoo"), None);
290 }
291
292 #[test]
304 fn parse_qw_content_end_lt_start_guard_fires() {
305 assert_eq!(
308 WorkspaceIndex::parse_qw_content("qwf"),
309 None,
310 "end < start boundary: single-char symmetric delimiter must return None, not panic"
311 );
312 assert_eq!(
317 WorkspaceIndex::parse_qw_content("qwfoo"),
318 None,
319 "end < start boundary: bareword after qw must return None"
320 );
321 }
322
323 #[test]
324 fn test_workspace_index() {
325 let mut index = WorkspaceIndex::new();
326
327 let mut symtab = SymbolTable::new();
329
330 let symbol = Symbol {
332 name: "test_func".to_string(),
333 qualified_name: "main::test_func".to_string(),
334 kind: SymbolKind::Subroutine,
335 location: SourceLocation { start: 0, end: 10 },
336 scope_id: 0,
337 declaration: Some("sub".to_string()),
338 documentation: None,
339 attributes: Vec::new(),
340 };
341
342 symtab.symbols.entry("test_func".to_string()).or_default().push(symbol);
343
344 index.update_from_document("file:///test.pl", "", &symtab);
346
347 let defs = index.find_defs("test_func");
349 assert_eq!(defs.len(), 1);
350 assert_eq!(defs[0].name, "test_func");
351 assert_eq!(defs[0].uri, "file:///test.pl");
352
353 index.remove_document("file:///test.pl");
355 assert_eq!(index.find_defs("test_func").len(), 0);
356 }
357}