Skip to main content

perl_semantic_analyzer/analysis/
index.rs

1//! Cross-file workspace indexing for Perl symbols
2//!
3//! This module provides efficient indexing of symbols across all files in a workspace,
4//! enabling fast cross-file navigation, references, and refactoring.
5
6use 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/// Symbol kinds for cross-file indexing during Index/Navigate workflows.
14#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
15pub enum SymKind {
16    /// Variable symbol ($, @, or % sigil)
17    Var,
18    /// Subroutine definition (sub foo)
19    Sub,
20    /// Package declaration (package Foo)
21    Pack,
22}
23
24/// A normalized symbol key for cross-file lookups in Index/Navigate workflows.
25#[derive(Clone, Debug, Eq, PartialEq, Hash)]
26pub struct SymbolKey {
27    /// Package name containing this symbol
28    pub pkg: Arc<str>,
29    /// Bare name without sigil prefix
30    pub name: Arc<str>,
31    /// Variable sigil ($, @, or %) if applicable
32    pub sigil: Option<char>,
33    /// Kind of symbol (variable, subroutine, package)
34    pub kind: SymKind,
35}
36
37/// A symbol definition in the workspace
38#[derive(Clone, Debug)]
39pub struct SymbolDef {
40    /// The name of the symbol
41    pub name: String,
42    /// The kind of symbol (function, variable, package, etc.)
43    pub kind: SymbolKind,
44    /// The URI of the file containing this symbol
45    pub uri: String,
46    /// Start byte offset in the file
47    pub start: usize,
48    /// End byte offset in the file
49    pub end: usize,
50}
51
52/// Workspace-wide index for fast symbol lookups
53#[derive(Default)]
54pub struct WorkspaceIndex {
55    /// Index from symbol name to all its definitions
56    by_name: HashMap<String, Vec<SymbolDef>>,
57    /// Index from URI to all symbol names in that file (for fast removal)
58    by_uri: HashMap<String, HashSet<String>>,
59    /// Import/module dependencies by URI.
60    imports_by_uri: RwLock<HashMap<String, HashSet<String>>>,
61}
62
63impl WorkspaceIndex {
64    /// Create a new empty workspace index
65    pub fn new() -> Self {
66        Self::default()
67    }
68
69    /// Update the index with symbols from a document
70    pub fn update_from_document(&mut self, uri: &str, content: &str, symtab: &SymbolTable) {
71        // Remove old symbols from this file
72        self.remove_document(uri);
73
74        // Track all symbol names in this file
75        let mut names_in_file = HashSet::new();
76
77        // Add all symbols from the symbol table
78        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        // Track which names are in this file
96        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    /// Remove all symbols from a document
106    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    /// Index import dependencies from raw file contents.
121    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    /// Return modules imported or required by a file.
132    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    /// Find all definitions of a symbol by name
209    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    /// Find all references to a symbol (simplified version)
215    /// In a full implementation, this would analyze usage sites
216    pub fn find_refs(&self, name: &str) -> Vec<SymbolDef> {
217        // For now, return all definitions as references
218        // A full implementation would scan all files for usage sites
219        self.find_defs(name).to_vec()
220    }
221
222    /// Get all symbols in the workspace matching a query
223    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    /// Get the total number of indexed symbols
237    pub fn symbol_count(&self) -> usize {
238        self.by_name.values().map(|v| v.len()).sum()
239    }
240
241    /// Get the number of indexed files
242    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    /// Regression: `use parent qw [..]` with whitespace before the delimiter
254    /// must extract each base module as a dependency. Previously the leading
255    /// space was treated as the `qw` delimiter, so `parse_qw_content` returned
256    /// garbage and the whole `qw [..]` token was recorded as a single bogus
257    /// dependency. See `parse_qw_content`.
258    #[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    /// `parse_qw_content` unit coverage: whitespace before the delimiter is
273    /// tolerated; compact form still works.
274    /// Covers space, tab, and newline — all `trim_start` whitespace variants.
275    #[test]
276    fn parse_qw_content_tolerates_leading_space() {
277        // Space before delimiter.
278        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        // Tab before delimiter.
282        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        // Newline before delimiter.
285        assert_eq!(WorkspaceIndex::parse_qw_content("qw\n[a b]"), Some("a b"));
286        // Multiple mixed whitespace.
287        assert_eq!(WorkspaceIndex::parse_qw_content("qw  \t [a b]"), Some("a b"));
288        // A bareword directly after qw is not a valid delimiter.
289        assert_eq!(WorkspaceIndex::parse_qw_content("qwfoo"), None);
290    }
291
292    /// Direct boundary test for the `end < start` guard in `parse_qw_content`.
293    ///
294    /// `rfind(close)` can land at index 0 when the opening character is also
295    /// the only occurrence of `close` in `rest` (e.g. `qwf` where `f` is both
296    /// open and close for the symmetric-delimiter arm).  In that case
297    /// `end (= 0) < start (= open.len_utf8() = 1)`, and the guard must return
298    /// `None` without constructing the invalid slice `&rest[1..0]`.
299    ///
300    /// This test is a RIPR seam anchor: it asserts the exact discriminating
301    /// input that sits on the `end < start` boundary so that any mutation
302    /// removing that guard is caught immediately.
303    #[test]
304    fn parse_qw_content_end_lt_start_guard_fires() {
305        // "qwf" → rest = "f", open = 'f', close = 'f' (symmetric),
306        // start = 1, rfind('f') = 0 → end = 0 < start = 1 → None.
307        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        // Longer bareword: same logic, rfind finds the *last* occurrence of
313        // 'f' at index 2, which is still < start = 1? No — "qwfoo":
314        // rest = "foo", open = 'f', close = 'f', rfind('f') in "foo" = 0
315        // → end = 0 < start = 1 → None.
316        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        // Create a mock symbol table
328        let mut symtab = SymbolTable::new();
329
330        // Add a symbol to the symbol table
331        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        // Add document to index
345        index.update_from_document("file:///test.pl", "", &symtab);
346
347        // Find definitions
348        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        // Remove document
354        index.remove_document("file:///test.pl");
355        assert_eq!(index.find_defs("test_func").len(), 0);
356    }
357}