Skip to main content

shape_lsp/
module_cache.rs

1//! Module cache for cross-file navigation
2//!
3//! Provides module resolution and caching for import statements, enabling
4//! go-to-definition and find-references across .shape files.
5
6use dashmap::DashMap;
7use shape_ast::ast::{Program, Span};
8#[cfg(test)]
9use shape_ast::parser::parse_program;
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13
14fn module_path_segments(path: &str) -> Vec<&str> {
15    if path.contains("::") {
16        path.split("::")
17            .filter(|segment| !segment.is_empty())
18            .collect()
19    } else {
20        path.split('.')
21            .filter(|segment| !segment.is_empty())
22            .collect()
23    }
24}
25
26fn is_std_module_path(path: &str) -> bool {
27    module_path_segments(path)
28        .first()
29        .is_some_and(|segment| *segment == "std")
30}
31
32/// Kind of exported symbol
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum SymbolKind {
35    Function,
36    Pattern,
37    Variable,
38    TypeAlias,
39    Interface,
40    Enum,
41    Annotation,
42}
43
44/// An exported symbol from a module
45#[derive(Debug, Clone)]
46pub struct ExportedSymbol {
47    /// The symbol name
48    pub name: String,
49    /// Optional alias (if exported with 'as')
50    pub alias: Option<String>,
51    /// Kind of symbol
52    pub kind: SymbolKind,
53    /// Location in source file
54    pub span: Span,
55}
56
57impl ExportedSymbol {
58    /// Get the exported name (alias if present, otherwise original name)
59    pub fn exported_name(&self) -> &str {
60        self.alias.as_ref().unwrap_or(&self.name)
61    }
62}
63
64/// Information about a loaded module
65#[derive(Debug, Clone)]
66pub struct ModuleInfo {
67    /// Absolute path to the module file
68    pub path: PathBuf,
69    /// Parsed program (shared to avoid cloning)
70    pub program: Arc<Program>,
71    /// Exported symbols from this module
72    pub exports: Vec<ExportedSymbol>,
73}
74
75/// Module cache for tracking and resolving imports
76#[derive(Debug, Default)]
77pub struct ModuleCache {
78    /// Map of module path to module info
79    modules: DashMap<PathBuf, ModuleInfo>,
80}
81
82impl ModuleCache {
83    /// Create a new module cache
84    pub fn new() -> Self {
85        Self {
86            modules: DashMap::new(),
87        }
88    }
89
90    fn loader_for_context(
91        current_file: &Path,
92        workspace_root: Option<&Path>,
93        current_source: Option<&str>,
94    ) -> shape_runtime::module_loader::ModuleLoader {
95        let mut loader = shape_runtime::module_loader::ModuleLoader::new();
96        loader.configure_for_context_with_source(current_file, workspace_root, current_source);
97        loader
98    }
99
100    /// Resolve an import path to an absolute file path using runtime module resolution.
101    pub fn resolve_import(
102        &self,
103        import_path: &str,
104        current_file: &Path,
105        workspace_root: Option<&Path>,
106    ) -> Option<PathBuf> {
107        let loader = Self::loader_for_context(current_file, workspace_root, None);
108
109        let context_dir = current_file.parent().map(Path::to_path_buf);
110        let resolved = loader.resolve_module_path_with_context(import_path, context_dir.as_ref());
111        if let Ok(path) = resolved {
112            return Some(path);
113        }
114
115        // Compatibility fallback for legacy dot-separated import paths.
116        if import_path.contains("::")
117            || import_path.starts_with("./")
118            || import_path.starts_with("../")
119            || import_path.starts_with('/')
120        {
121            return None;
122        }
123
124        let canonical = import_path.replace('.', "::");
125        loader
126            .resolve_module_path_with_context(&canonical, context_dir.as_ref())
127            .ok()
128    }
129
130    /// Load a module from a file path
131    ///
132    /// If the module is already cached, returns the cached version.
133    /// Otherwise, reads and parses the file, extracts exports, and caches it.
134    pub fn load_module(&self, path: &Path) -> Option<ModuleInfo> {
135        self.load_module_with_context(path, path, None)
136    }
137
138    /// Context-aware module load using the same module-loader setup as import resolution.
139    pub fn load_module_with_context(
140        &self,
141        path: &Path,
142        current_file: &Path,
143        workspace_root: Option<&Path>,
144    ) -> Option<ModuleInfo> {
145        // Check cache first
146        if let Some(cached) = self.modules.get(path) {
147            return Some(cached.clone());
148        }
149
150        // Load via runtime module loader for unified parse/export semantics.
151        let mut loader = Self::loader_for_context(current_file, workspace_root, None);
152        let module = loader.load_module_from_file(path).ok()?;
153        let program = Arc::new(module.ast.clone());
154
155        // Extract exports from the program
156        let exports = extract_exports(&program);
157
158        let module_info = ModuleInfo {
159            path: path.to_path_buf(),
160            program: program.clone(),
161            exports,
162        };
163
164        // Cache the module
165        self.modules.insert(path.to_path_buf(), module_info.clone());
166
167        Some(module_info)
168    }
169
170    /// Load a module by import path using unified module-loader context.
171    ///
172    /// This supports both filesystem modules and in-memory extension artifacts.
173    pub fn load_module_by_import_with_context_and_source(
174        &self,
175        import_path: &str,
176        current_file: &Path,
177        workspace_root: Option<&Path>,
178        current_source: Option<&str>,
179    ) -> Option<ModuleInfo> {
180        let mut loader = Self::loader_for_context(current_file, workspace_root, current_source);
181        let context_dir = current_file.parent().map(Path::to_path_buf);
182        let module = loader
183            .load_module_with_context(import_path, context_dir.as_ref())
184            .ok()?;
185
186        let cache_path = PathBuf::from(format!(
187            "__shape_lsp_virtual__/{}.shape",
188            import_path.replace("::", "/").replace('.', "/")
189        ));
190        let program = Arc::new(module.ast.clone());
191        let exports = extract_exports(&program);
192        let module_info = ModuleInfo {
193            path: cache_path.clone(),
194            program: program.clone(),
195            exports,
196        };
197        self.modules.insert(cache_path, module_info.clone());
198        Some(module_info)
199    }
200
201    /// Get a cached module (without loading if not present)
202    pub fn get_module(&self, path: &Path) -> Option<ModuleInfo> {
203        self.modules.get(path).map(|entry| entry.clone())
204    }
205
206    /// Invalidate a module in the cache (when file changes)
207    pub fn invalidate(&self, path: &Path) {
208        self.modules.remove(path);
209    }
210
211    /// Clear the entire cache
212    pub fn clear(&self) {
213        self.modules.clear();
214    }
215
216    /// List importable module paths for the current workspace context.
217    ///
218    /// Includes:
219    /// - `std.*` modules from stdlib
220    /// - project module search paths from `shape.toml` (`[modules].paths`)
221    /// - path dependencies from `shape.toml` (`[dependencies]`)
222    pub fn list_importable_modules_with_context(
223        &self,
224        current_file: &Path,
225        workspace_root: Option<&Path>,
226    ) -> Vec<String> {
227        self.list_importable_modules_with_context_and_source(current_file, workspace_root, None)
228    }
229
230    /// List importable module paths with optional current source for frontmatter-aware context.
231    pub fn list_importable_modules_with_context_and_source(
232        &self,
233        current_file: &Path,
234        workspace_root: Option<&Path>,
235        current_source: Option<&str>,
236    ) -> Vec<String> {
237        let mut loader = shape_runtime::module_loader::ModuleLoader::new();
238        loader.configure_for_context_with_source(current_file, workspace_root, current_source);
239        loader.list_importable_modules_with_context(current_file, workspace_root)
240    }
241
242    /// List importable module paths using the process CWD as context.
243    pub fn list_importable_modules(&self) -> Vec<String> {
244        let current_file = std::env::current_dir()
245            .unwrap_or_else(|_| PathBuf::from("."))
246            .join("__shape_lsp__.shape");
247        self.list_importable_modules_with_context(&current_file, None)
248    }
249
250    /// List all stdlib module import paths (e.g., `std::core::math`).
251    pub fn list_stdlib_modules(&self) -> Vec<String> {
252        self.list_importable_modules()
253            .into_iter()
254            .filter(|module_path| is_std_module_path(module_path))
255            .collect()
256    }
257
258    /// Return direct children under a stdlib prefix for hierarchical completion.
259    ///
260    /// For example, with prefix `std::core` it can return entries like:
261    /// - `math` (leaf, no children)
262    /// - `indicators` (non-leaf)
263    pub fn list_stdlib_children(&self, prefix: &str) -> Vec<ModuleChild> {
264        let effective_prefix = if prefix.is_empty() { "std" } else { prefix };
265        if !is_std_module_path(effective_prefix) {
266            return Vec::new();
267        }
268
269        self.list_module_children(effective_prefix)
270    }
271
272    /// Return direct children under an import prefix for hierarchical completion.
273    pub fn list_module_children_with_context(
274        &self,
275        prefix: &str,
276        current_file: &Path,
277        workspace_root: Option<&Path>,
278    ) -> Vec<ModuleChild> {
279        let base = if prefix.is_empty() {
280            "std".to_string()
281        } else {
282            prefix.to_string()
283        };
284
285        let mut children: HashMap<String, ModuleChild> = HashMap::new();
286        let base_segments = module_path_segments(&base);
287        let base_len = base_segments.len();
288        for module_path in self.list_importable_modules_with_context(current_file, workspace_root) {
289            let module_segments = module_path_segments(&module_path);
290            if module_segments.len() <= base_len {
291                continue;
292            }
293            if module_segments[..base_len] != base_segments[..] {
294                continue;
295            }
296
297            let child = module_segments[base_len];
298            let has_children = module_segments.len() > base_len + 1;
299
300            let entry = children.entry(child.to_string()).or_insert(ModuleChild {
301                name: child.to_string(),
302                has_leaf_module: false,
303                has_children: false,
304            });
305            if has_children {
306                entry.has_children = true;
307            } else {
308                entry.has_leaf_module = true;
309            }
310        }
311
312        let mut out: Vec<ModuleChild> = children.into_values().collect();
313        out.sort_by(|a, b| a.name.cmp(&b.name));
314        out
315    }
316
317    /// Return direct children under an import prefix using process CWD as context.
318    pub fn list_module_children(&self, prefix: &str) -> Vec<ModuleChild> {
319        let current_file = std::env::current_dir()
320            .unwrap_or_else(|_| PathBuf::from("."))
321            .join("__shape_lsp__.shape");
322        self.list_module_children_with_context(prefix, &current_file, None)
323    }
324
325    /// Find all modules that export a symbol with the given name.
326    /// Scans importable modules and returns (import_path, ExportedSymbol) pairs.
327    pub fn find_exported_symbol_with_context(
328        &self,
329        name: &str,
330        current_file: &Path,
331        workspace_root: Option<&Path>,
332    ) -> Vec<(String, ExportedSymbol)> {
333        let mut results = Vec::new();
334
335        for import_path in self.list_importable_modules_with_context(current_file, workspace_root) {
336            let Some(resolved) = self.resolve_import(&import_path, current_file, workspace_root)
337            else {
338                continue;
339            };
340            let Some(module_info) =
341                self.load_module_with_context(&resolved, current_file, workspace_root)
342            else {
343                continue;
344            };
345
346            for export in &module_info.exports {
347                if export.exported_name() == name {
348                    results.push((import_path.clone(), export.clone()));
349                }
350            }
351        }
352
353        results
354    }
355
356    /// Scans importable modules using process CWD as context.
357    pub fn find_exported_symbol(&self, name: &str) -> Vec<(String, ExportedSymbol)> {
358        let current_file = std::env::current_dir()
359            .unwrap_or_else(|_| PathBuf::from("."))
360            .join("__shape_lsp__.shape");
361        self.find_exported_symbol_with_context(name, &current_file, None)
362    }
363}
364
365/// Child entry used for hierarchical module completion.
366#[derive(Debug, Clone)]
367pub struct ModuleChild {
368    pub name: String,
369    pub has_leaf_module: bool,
370    pub has_children: bool,
371}
372
373fn map_module_export_kind(kind: shape_runtime::module_loader::ModuleExportKind) -> SymbolKind {
374    use shape_runtime::module_loader::ModuleExportKind as RuntimeKind;
375    match kind {
376        RuntimeKind::Function => SymbolKind::Function,
377        RuntimeKind::TypeAlias => SymbolKind::TypeAlias,
378        RuntimeKind::Interface => SymbolKind::Interface,
379        RuntimeKind::Enum => SymbolKind::Enum,
380        RuntimeKind::Value => SymbolKind::Variable,
381    }
382}
383
384/// Extract exported symbols from a program AST
385fn extract_exports(program: &Program) -> Vec<ExportedSymbol> {
386    shape_runtime::module_loader::collect_exported_symbols(program)
387        .unwrap_or_default()
388        .into_iter()
389        .map(|sym| ExportedSymbol {
390            name: sym.name,
391            alias: sym.alias,
392            kind: map_module_export_kind(sym.kind),
393            span: sym.span,
394        })
395        .collect()
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    #[test]
403    fn test_resolve_stdlib_import() {
404        let cache = ModuleCache::new();
405        let current_file =
406            PathBuf::from("/home/dev/dev/finance/analysis-suite/shape/examples/test.shape");
407
408        let resolved = cache.resolve_import("std::core::math", &current_file, None);
409
410        assert!(resolved.is_some());
411        let path = resolved.unwrap();
412        let path_str = path.to_string_lossy();
413        assert!(path_str.contains("stdlib/core/math.shape"));
414    }
415
416    #[test]
417    fn test_relative_import_is_supported() {
418        let tmp = tempfile::tempdir().unwrap();
419        let current_file = tmp.path().join("main.shape");
420        let util = tmp.path().join("utils.shape");
421        std::fs::write(&current_file, "from ./utils use { helper }").unwrap();
422        std::fs::write(&util, "pub fn helper() { 1 }").unwrap();
423
424        let cache = ModuleCache::new();
425
426        let resolved = cache.resolve_import("./utils", &current_file, None);
427        assert_eq!(resolved.as_deref(), Some(util.as_path()));
428    }
429
430    #[test]
431    fn test_non_std_import_returns_none() {
432        let cache = ModuleCache::new();
433        let current_file = PathBuf::from("/home/user/project/src/main.shape");
434
435        // Non-std imports return None (handled by dependency resolution elsewhere)
436        let resolved = cache.resolve_import("finance::indicators", &current_file, None);
437        assert!(resolved.is_none());
438    }
439
440    #[test]
441    fn test_exported_symbol_name() {
442        let symbol = ExportedSymbol {
443            name: "originalName".to_string(),
444            alias: Some("aliasName".to_string()),
445            kind: SymbolKind::Function,
446            span: Span::default(),
447        };
448
449        assert_eq!(symbol.exported_name(), "aliasName");
450
451        let symbol_no_alias = ExportedSymbol {
452            name: "originalName".to_string(),
453            alias: None,
454            kind: SymbolKind::Function,
455            span: Span::default(),
456        };
457
458        assert_eq!(symbol_no_alias.exported_name(), "originalName");
459    }
460
461    #[test]
462    fn test_extract_exports() {
463        let source = r#"
464pub fn myFunc(x) {
465    return x + 1;
466}
467
468fn localFunc() {
469    return 42;
470}
471"#;
472
473        let program = parse_program(source).unwrap();
474        let exports = extract_exports(&program);
475
476        assert_eq!(exports.len(), 1);
477        assert_eq!(exports[0].name, "myFunc");
478        assert_eq!(exports[0].kind, SymbolKind::Function);
479    }
480
481    #[test]
482    fn test_list_stdlib_modules_not_empty() {
483        let cache = ModuleCache::new();
484        let modules = cache.list_stdlib_modules();
485        assert!(
486            !modules.is_empty(),
487            "expected stdlib module list to be non-empty"
488        );
489        assert!(
490            modules.iter().all(|m| m.starts_with("std::")),
491            "all stdlib modules should be std::-prefixed: {:?}",
492            modules
493        );
494    }
495
496    #[test]
497    fn test_list_stdlib_children_for_std_prefix() {
498        let cache = ModuleCache::new();
499        let children = cache.list_stdlib_children("std");
500        assert!(
501            !children.is_empty(),
502            "expected stdlib root to have child modules"
503        );
504        assert!(
505            children.iter().any(|c| c.name == "core"),
506            "expected std.core child in stdlib tree"
507        );
508    }
509
510    #[test]
511    fn test_list_importable_modules_with_project_modules_and_deps() {
512        let tmp = tempfile::tempdir().unwrap();
513        let root = tmp.path();
514        std::fs::write(
515            root.join("shape.toml"),
516            r#"
517[modules]
518paths = ["lib"]
519
520[dependencies]
521mydep = { path = "deps/mydep" }
522"#,
523        )
524        .unwrap();
525
526        std::fs::create_dir_all(root.join("src")).unwrap();
527        std::fs::create_dir_all(root.join("lib")).unwrap();
528        std::fs::create_dir_all(root.join("deps/mydep")).unwrap();
529
530        std::fs::write(root.join("src/main.shape"), "let x = 1").unwrap();
531        std::fs::write(root.join("lib/tools.shape"), "pub fn tool() { 1 }").unwrap();
532        std::fs::write(root.join("deps/mydep/index.shape"), "pub fn root() { 1 }").unwrap();
533        std::fs::write(root.join("deps/mydep/util.shape"), "pub fn util() { 1 }").unwrap();
534
535        let cache = ModuleCache::new();
536        let modules =
537            cache.list_importable_modules_with_context(&root.join("src/main.shape"), None);
538
539        assert!(
540            modules.iter().any(|m| m == "tools"),
541            "expected module path from [modules].paths, got: {:?}",
542            modules
543        );
544        assert!(
545            modules.iter().any(|m| m == "mydep"),
546            "expected dependency index module path, got: {:?}",
547            modules
548        );
549        assert!(
550            modules.iter().any(|m| m == "mydep::util"),
551            "expected dependency submodule path, got: {:?}",
552            modules
553        );
554    }
555
556    #[test]
557    fn test_module_cache_invalidation() {
558        let cache = ModuleCache::new();
559        let path = PathBuf::from("/test/module.shape");
560
561        // Create a mock module info
562        let program = Arc::new(Program {
563            items: vec![],
564            docs: shape_ast::ast::ProgramDocs::default(),
565        });
566        let module_info = ModuleInfo {
567            path: path.clone(),
568            program,
569            exports: vec![],
570        };
571
572        // Insert into cache
573        cache.modules.insert(path.clone(), module_info.clone());
574        assert!(cache.get_module(&path).is_some());
575
576        // Invalidate
577        cache.invalidate(&path);
578        assert!(cache.get_module(&path).is_none());
579    }
580}