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::BuiltinFunction => SymbolKind::Function,
378        RuntimeKind::TypeAlias => SymbolKind::TypeAlias,
379        RuntimeKind::BuiltinType => SymbolKind::TypeAlias,
380        RuntimeKind::Interface => SymbolKind::Interface,
381        RuntimeKind::Enum => SymbolKind::Enum,
382        RuntimeKind::Annotation => SymbolKind::Annotation,
383        RuntimeKind::Value => SymbolKind::Variable,
384    }
385}
386
387/// Extract exported symbols from a program AST
388fn extract_exports(program: &Program) -> Vec<ExportedSymbol> {
389    shape_runtime::module_loader::collect_exported_symbols(program)
390        .unwrap_or_default()
391        .into_iter()
392        .map(|sym| ExportedSymbol {
393            name: sym.name,
394            alias: sym.alias,
395            kind: map_module_export_kind(sym.kind),
396            span: sym.span,
397        })
398        .collect()
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404
405    #[test]
406    fn test_resolve_stdlib_import() {
407        let cache = ModuleCache::new();
408        let current_file =
409            PathBuf::from("/home/dev/dev/finance/analysis-suite/shape/examples/test.shape");
410
411        let resolved = cache.resolve_import("std::core::math", &current_file, None);
412
413        assert!(resolved.is_some());
414        let path = resolved.unwrap();
415        let path_str = path.to_string_lossy();
416        assert!(
417            path_str.contains("stdlib/core/math.shape")
418                || path_str.contains("stdlib-src/core/math.shape"),
419            "Expected stdlib math path, got: {}",
420            path_str
421        );
422    }
423
424    #[test]
425    fn test_relative_import_is_supported() {
426        let tmp = tempfile::tempdir().unwrap();
427        let current_file = tmp.path().join("main.shape");
428        let util = tmp.path().join("utils.shape");
429        std::fs::write(&current_file, "from ./utils use { helper }").unwrap();
430        std::fs::write(&util, "pub fn helper() { 1 }").unwrap();
431
432        let cache = ModuleCache::new();
433
434        let resolved = cache.resolve_import("./utils", &current_file, None);
435        assert_eq!(resolved.as_deref(), Some(util.as_path()));
436    }
437
438    #[test]
439    fn test_non_std_import_returns_none() {
440        let cache = ModuleCache::new();
441        let current_file = PathBuf::from("/home/user/project/src/main.shape");
442
443        // Non-std imports return None (handled by dependency resolution elsewhere)
444        let resolved = cache.resolve_import("finance::indicators", &current_file, None);
445        assert!(resolved.is_none());
446    }
447
448    #[test]
449    fn test_exported_symbol_name() {
450        let symbol = ExportedSymbol {
451            name: "originalName".to_string(),
452            alias: Some("aliasName".to_string()),
453            kind: SymbolKind::Function,
454            span: Span::default(),
455        };
456
457        assert_eq!(symbol.exported_name(), "aliasName");
458
459        let symbol_no_alias = ExportedSymbol {
460            name: "originalName".to_string(),
461            alias: None,
462            kind: SymbolKind::Function,
463            span: Span::default(),
464        };
465
466        assert_eq!(symbol_no_alias.exported_name(), "originalName");
467    }
468
469    #[test]
470    fn test_extract_exports() {
471        let source = r#"
472pub fn myFunc(x) {
473    return x + 1;
474}
475
476fn localFunc() {
477    return 42;
478}
479"#;
480
481        let program = parse_program(source).unwrap();
482        let exports = extract_exports(&program);
483
484        assert_eq!(exports.len(), 1);
485        assert_eq!(exports[0].name, "myFunc");
486        assert_eq!(exports[0].kind, SymbolKind::Function);
487    }
488
489    #[test]
490    fn test_list_stdlib_modules_not_empty() {
491        let cache = ModuleCache::new();
492        let modules = cache.list_stdlib_modules();
493        assert!(
494            !modules.is_empty(),
495            "expected stdlib module list to be non-empty"
496        );
497        assert!(
498            modules.iter().all(|m| m.starts_with("std::")),
499            "all stdlib modules should be std::-prefixed: {:?}",
500            modules
501        );
502    }
503
504    #[test]
505    fn test_list_stdlib_children_for_std_prefix() {
506        let cache = ModuleCache::new();
507        let children = cache.list_stdlib_children("std");
508        assert!(
509            !children.is_empty(),
510            "expected stdlib root to have child modules"
511        );
512        assert!(
513            children.iter().any(|c| c.name == "core"),
514            "expected std.core child in stdlib tree"
515        );
516    }
517
518    #[test]
519    fn test_list_importable_modules_with_project_modules_and_deps() {
520        let tmp = tempfile::tempdir().unwrap();
521        let root = tmp.path();
522        std::fs::write(
523            root.join("shape.toml"),
524            r#"
525[modules]
526paths = ["lib"]
527
528[dependencies]
529mydep = { path = "deps/mydep" }
530"#,
531        )
532        .unwrap();
533
534        std::fs::create_dir_all(root.join("src")).unwrap();
535        std::fs::create_dir_all(root.join("lib")).unwrap();
536        std::fs::create_dir_all(root.join("deps/mydep")).unwrap();
537
538        std::fs::write(root.join("src/main.shape"), "let x = 1").unwrap();
539        std::fs::write(root.join("lib/tools.shape"), "pub fn tool() { 1 }").unwrap();
540        std::fs::write(root.join("deps/mydep/index.shape"), "pub fn root() { 1 }").unwrap();
541        std::fs::write(root.join("deps/mydep/util.shape"), "pub fn util() { 1 }").unwrap();
542
543        let cache = ModuleCache::new();
544        let modules =
545            cache.list_importable_modules_with_context(&root.join("src/main.shape"), None);
546
547        assert!(
548            modules.iter().any(|m| m == "tools"),
549            "expected module path from [modules].paths, got: {:?}",
550            modules
551        );
552        assert!(
553            modules.iter().any(|m| m == "mydep"),
554            "expected dependency index module path, got: {:?}",
555            modules
556        );
557        assert!(
558            modules.iter().any(|m| m == "mydep::util"),
559            "expected dependency submodule path, got: {:?}",
560            modules
561        );
562    }
563
564    #[test]
565    fn test_module_cache_invalidation() {
566        let cache = ModuleCache::new();
567        let path = PathBuf::from("/test/module.shape");
568
569        // Create a mock module info
570        let program = Arc::new(Program {
571            items: vec![],
572            docs: shape_ast::ast::ProgramDocs::default(),
573        });
574        let module_info = ModuleInfo {
575            path: path.clone(),
576            program,
577            exports: vec![],
578        };
579
580        // Insert into cache
581        cache.modules.insert(path.clone(), module_info.clone());
582        assert!(cache.get_module(&path).is_some());
583
584        // Invalidate
585        cache.invalidate(&path);
586        assert!(cache.get_module(&path).is_none());
587    }
588}