use dashmap::DashMap;
use shape_ast::ast::{Program, Span};
#[cfg(test)]
use shape_ast::parser::parse_program;
use shape_runtime::extension_context::ExtensionModuleSchemaCache;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock};
pub(crate) fn lsp_extension_schema_cache() -> &'static Arc<ExtensionModuleSchemaCache> {
static CACHE: OnceLock<Arc<ExtensionModuleSchemaCache>> = OnceLock::new();
CACHE.get_or_init(|| Arc::new(ExtensionModuleSchemaCache::new()))
}
fn module_path_segments(path: &str) -> Vec<&str> {
if path.contains("::") {
path.split("::")
.filter(|segment| !segment.is_empty())
.collect()
} else {
path.split('.')
.filter(|segment| !segment.is_empty())
.collect()
}
}
fn is_std_module_path(path: &str) -> bool {
module_path_segments(path)
.first()
.is_some_and(|segment| *segment == "std")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SymbolKind {
Function,
Pattern,
Variable,
TypeAlias,
Trait,
Enum,
Annotation,
}
#[derive(Debug, Clone)]
pub struct ExportedSymbol {
pub name: String,
pub alias: Option<String>,
pub kind: SymbolKind,
pub span: Span,
}
impl ExportedSymbol {
pub fn exported_name(&self) -> &str {
self.alias.as_ref().unwrap_or(&self.name)
}
}
#[derive(Debug, Clone)]
pub struct ModuleInfo {
pub path: PathBuf,
pub program: Arc<Program>,
pub exports: Vec<ExportedSymbol>,
}
#[derive(Debug, Default)]
pub struct ModuleCache {
modules: DashMap<PathBuf, ModuleInfo>,
}
impl ModuleCache {
pub fn new() -> Self {
Self {
modules: DashMap::new(),
}
}
fn loader_for_context(
current_file: &Path,
workspace_root: Option<&Path>,
current_source: Option<&str>,
) -> shape_runtime::module_loader::ModuleLoader {
let mut loader = shape_runtime::module_loader::ModuleLoader::new();
loader.configure_for_context_with_source(
current_file,
workspace_root,
current_source,
lsp_extension_schema_cache(),
);
loader
}
pub fn resolve_import(
&self,
import_path: &str,
current_file: &Path,
workspace_root: Option<&Path>,
) -> Option<PathBuf> {
let loader = Self::loader_for_context(current_file, workspace_root, None);
let context_dir = current_file.parent().map(Path::to_path_buf);
let resolved = loader.resolve_module_path_with_context(import_path, context_dir.as_ref());
if let Ok(path) = resolved {
return Some(path);
}
if import_path.contains("::")
|| import_path.starts_with("./")
|| import_path.starts_with("../")
|| import_path.starts_with('/')
{
return None;
}
let canonical = import_path.replace('.', "::");
loader
.resolve_module_path_with_context(&canonical, context_dir.as_ref())
.ok()
}
pub fn load_module(&self, path: &Path) -> Option<ModuleInfo> {
self.load_module_with_context(path, path, None)
}
pub fn load_module_with_context(
&self,
path: &Path,
current_file: &Path,
workspace_root: Option<&Path>,
) -> Option<ModuleInfo> {
if let Some(cached) = self.modules.get(path) {
return Some(cached.clone());
}
let mut loader = Self::loader_for_context(current_file, workspace_root, None);
let module = loader.load_module_from_file(path).ok()?;
let program = Arc::new(module.ast.clone());
let exports = extract_exports(&program);
let module_info = ModuleInfo {
path: path.to_path_buf(),
program: program.clone(),
exports,
};
self.modules.insert(path.to_path_buf(), module_info.clone());
Some(module_info)
}
pub fn load_module_by_import_with_context_and_source(
&self,
import_path: &str,
current_file: &Path,
workspace_root: Option<&Path>,
current_source: Option<&str>,
) -> Option<ModuleInfo> {
let mut loader = Self::loader_for_context(current_file, workspace_root, current_source);
let context_dir = current_file.parent().map(Path::to_path_buf);
let module = loader
.load_module_with_context(import_path, context_dir.as_ref())
.ok()?;
let cache_path = PathBuf::from(format!(
"__shape_lsp_virtual__/{}.shape",
import_path.replace("::", "/").replace('.', "/")
));
let program = Arc::new(module.ast.clone());
let exports = extract_exports(&program);
let module_info = ModuleInfo {
path: cache_path.clone(),
program: program.clone(),
exports,
};
self.modules.insert(cache_path, module_info.clone());
Some(module_info)
}
pub fn get_module(&self, path: &Path) -> Option<ModuleInfo> {
self.modules.get(path).map(|entry| entry.clone())
}
pub fn invalidate(&self, path: &Path) {
self.modules.remove(path);
}
pub fn clear(&self) {
self.modules.clear();
}
pub fn list_importable_modules_with_context(
&self,
current_file: &Path,
workspace_root: Option<&Path>,
) -> Vec<String> {
self.list_importable_modules_with_context_and_source(current_file, workspace_root, None)
}
pub fn list_importable_modules_with_context_and_source(
&self,
current_file: &Path,
workspace_root: Option<&Path>,
current_source: Option<&str>,
) -> Vec<String> {
let mut loader = shape_runtime::module_loader::ModuleLoader::new();
loader.configure_for_context_with_source(
current_file,
workspace_root,
current_source,
lsp_extension_schema_cache(),
);
loader.list_importable_modules_with_context(current_file, workspace_root)
}
pub fn list_importable_modules(&self) -> Vec<String> {
let current_file = std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join("__shape_lsp__.shape");
self.list_importable_modules_with_context(¤t_file, None)
}
pub fn list_stdlib_modules(&self) -> Vec<String> {
self.list_importable_modules()
.into_iter()
.filter(|module_path| is_std_module_path(module_path))
.collect()
}
pub fn list_stdlib_children(&self, prefix: &str) -> Vec<ModuleChild> {
let effective_prefix = if prefix.is_empty() { "std" } else { prefix };
if !is_std_module_path(effective_prefix) {
return Vec::new();
}
self.list_module_children(effective_prefix)
}
pub fn list_module_children_with_context(
&self,
prefix: &str,
current_file: &Path,
workspace_root: Option<&Path>,
) -> Vec<ModuleChild> {
let base = if prefix.is_empty() {
"std".to_string()
} else {
prefix.to_string()
};
let mut children: HashMap<String, ModuleChild> = HashMap::new();
let base_segments = module_path_segments(&base);
let base_len = base_segments.len();
for module_path in self.list_importable_modules_with_context(current_file, workspace_root) {
let module_segments = module_path_segments(&module_path);
if module_segments.len() <= base_len {
continue;
}
if module_segments[..base_len] != base_segments[..] {
continue;
}
let child = module_segments[base_len];
let has_children = module_segments.len() > base_len + 1;
let entry = children.entry(child.to_string()).or_insert(ModuleChild {
name: child.to_string(),
has_leaf_module: false,
has_children: false,
});
if has_children {
entry.has_children = true;
} else {
entry.has_leaf_module = true;
}
}
let mut out: Vec<ModuleChild> = children.into_values().collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
pub fn list_module_children(&self, prefix: &str) -> Vec<ModuleChild> {
let current_file = std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join("__shape_lsp__.shape");
self.list_module_children_with_context(prefix, ¤t_file, None)
}
pub fn find_exported_symbol_with_context(
&self,
name: &str,
current_file: &Path,
workspace_root: Option<&Path>,
) -> Vec<(String, ExportedSymbol)> {
let mut results = Vec::new();
for import_path in self.list_importable_modules_with_context(current_file, workspace_root) {
let Some(resolved) = self.resolve_import(&import_path, current_file, workspace_root)
else {
continue;
};
let Some(module_info) =
self.load_module_with_context(&resolved, current_file, workspace_root)
else {
continue;
};
for export in &module_info.exports {
if export.exported_name() == name {
results.push((import_path.clone(), export.clone()));
}
}
}
results
}
pub fn find_exported_symbol(&self, name: &str) -> Vec<(String, ExportedSymbol)> {
let current_file = std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join("__shape_lsp__.shape");
self.find_exported_symbol_with_context(name, ¤t_file, None)
}
pub fn enumerate_workspace_shape_files(&self, workspace_root: &Path) -> Vec<PathBuf> {
let mut files = Vec::new();
let mut stack = vec![workspace_root.to_path_buf()];
while let Some(dir) = stack.pop() {
if files.len() >= MAX_WORKSPACE_FILES {
break;
}
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if name.starts_with('.')
|| matches!(name, "target" | "node_modules" | "dist" | "build")
{
continue;
}
if path.is_dir() {
stack.push(path);
} else if path.extension().and_then(|e| e.to_str()) == Some("shape") {
files.push(path);
if files.len() >= MAX_WORKSPACE_FILES {
break;
}
}
}
}
files
}
}
pub const MAX_WORKSPACE_FILES: usize = 4096;
pub fn path_to_module_path(file: &Path, workspace_root: &Path) -> Option<String> {
let rel = file.strip_prefix(workspace_root).ok()?;
let stem = rel.file_stem().and_then(|s| s.to_str())?;
if rel.extension().and_then(|e| e.to_str()) != Some("shape") {
return None;
}
let mut segments: Vec<String> = Vec::new();
for component in rel.parent()?.components() {
let std::path::Component::Normal(seg) = component else {
continue;
};
let seg = seg.to_str()?;
if !is_valid_shape_identifier(seg) {
return None;
}
segments.push(seg.to_string());
}
if !is_valid_shape_identifier(stem) {
return None;
}
segments.push(stem.to_string());
if segments.is_empty() {
return None;
}
Some(segments.join("::"))
}
fn is_valid_shape_identifier(s: &str) -> bool {
let mut chars = s.chars();
let Some(first) = chars.next() else {
return false;
};
if !(first.is_ascii_alphabetic() || first == '_') {
return false;
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
#[derive(Debug, Clone)]
pub struct ModuleChild {
pub name: String,
pub has_leaf_module: bool,
pub has_children: bool,
}
fn map_module_export_kind(kind: shape_runtime::module_loader::ModuleExportKind) -> SymbolKind {
use shape_runtime::module_loader::ModuleExportKind as RuntimeKind;
match kind {
RuntimeKind::Function => SymbolKind::Function,
RuntimeKind::BuiltinFunction => SymbolKind::Function,
RuntimeKind::TypeAlias => SymbolKind::TypeAlias,
RuntimeKind::BuiltinType => SymbolKind::TypeAlias,
RuntimeKind::Trait => SymbolKind::Trait,
RuntimeKind::Enum => SymbolKind::Enum,
RuntimeKind::Annotation => SymbolKind::Annotation,
RuntimeKind::Value => SymbolKind::Variable,
}
}
fn extract_exports(program: &Program) -> Vec<ExportedSymbol> {
shape_runtime::module_loader::collect_exported_symbols(program)
.unwrap_or_default()
.into_iter()
.map(|sym| ExportedSymbol {
name: sym.name,
alias: sym.alias,
kind: map_module_export_kind(sym.kind),
span: sym.span,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_resolve_stdlib_import() {
let cache = ModuleCache::new();
let current_file =
PathBuf::from("/home/dev/dev/finance/analysis-suite/shape/examples/test.shape");
let resolved = cache.resolve_import("std::core::math", ¤t_file, None);
assert!(resolved.is_some());
let path = resolved.unwrap();
let path_str = path.to_string_lossy();
assert!(
path_str.contains("stdlib/core/math.shape")
|| path_str.contains("stdlib-src/core/math.shape"),
"Expected stdlib math path, got: {}",
path_str
);
}
#[test]
fn test_relative_import_is_supported() {
let tmp = tempfile::tempdir().unwrap();
let current_file = tmp.path().join("main.shape");
let util = tmp.path().join("utils.shape");
std::fs::write(¤t_file, "from ./utils use { helper }").unwrap();
std::fs::write(&util, "pub fn helper() { 1 }").unwrap();
let cache = ModuleCache::new();
let resolved = cache.resolve_import("./utils", ¤t_file, None);
assert_eq!(resolved.as_deref(), Some(util.as_path()));
}
#[test]
fn test_non_std_import_returns_none() {
let cache = ModuleCache::new();
let current_file = PathBuf::from("/home/user/project/src/main.shape");
let resolved = cache.resolve_import("finance::indicators", ¤t_file, None);
assert!(resolved.is_none());
}
#[test]
fn test_exported_symbol_name() {
let symbol = ExportedSymbol {
name: "originalName".to_string(),
alias: Some("aliasName".to_string()),
kind: SymbolKind::Function,
span: Span::default(),
};
assert_eq!(symbol.exported_name(), "aliasName");
let symbol_no_alias = ExportedSymbol {
name: "originalName".to_string(),
alias: None,
kind: SymbolKind::Function,
span: Span::default(),
};
assert_eq!(symbol_no_alias.exported_name(), "originalName");
}
#[test]
fn test_extract_exports() {
let source = r#"
pub fn myFunc(x) {
return x + 1;
}
fn localFunc() {
return 42;
}
"#;
let program = parse_program(source).unwrap();
let exports = extract_exports(&program);
assert_eq!(exports.len(), 1);
assert_eq!(exports[0].name, "myFunc");
assert_eq!(exports[0].kind, SymbolKind::Function);
}
#[test]
fn test_list_stdlib_modules_not_empty() {
let cache = ModuleCache::new();
let modules = cache.list_stdlib_modules();
assert!(
!modules.is_empty(),
"expected stdlib module list to be non-empty"
);
assert!(
modules.iter().all(|m| m.starts_with("std::")),
"all stdlib modules should be std::-prefixed: {:?}",
modules
);
}
#[test]
fn test_list_stdlib_children_for_std_prefix() {
let cache = ModuleCache::new();
let children = cache.list_stdlib_children("std");
assert!(
!children.is_empty(),
"expected stdlib root to have child modules"
);
assert!(
children.iter().any(|c| c.name == "core"),
"expected std.core child in stdlib tree"
);
}
#[test]
fn test_list_importable_modules_with_project_modules_and_deps() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(
root.join("shape.toml"),
r#"
[modules]
paths = ["lib"]
[dependencies]
mydep = { path = "deps/mydep" }
"#,
)
.unwrap();
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::create_dir_all(root.join("lib")).unwrap();
std::fs::create_dir_all(root.join("deps/mydep")).unwrap();
std::fs::write(root.join("src/main.shape"), "let x = 1").unwrap();
std::fs::write(root.join("lib/tools.shape"), "pub fn tool() { 1 }").unwrap();
std::fs::write(root.join("deps/mydep/index.shape"), "pub fn root() { 1 }").unwrap();
std::fs::write(root.join("deps/mydep/util.shape"), "pub fn util() { 1 }").unwrap();
let cache = ModuleCache::new();
let modules =
cache.list_importable_modules_with_context(&root.join("src/main.shape"), None);
assert!(
modules.iter().any(|m| m == "tools"),
"expected module path from [modules].paths, got: {:?}",
modules
);
assert!(
modules.iter().any(|m| m == "mydep"),
"expected dependency index module path, got: {:?}",
modules
);
assert!(
modules.iter().any(|m| m == "mydep::util"),
"expected dependency submodule path, got: {:?}",
modules
);
}
#[test]
fn test_enumerate_workspace_shape_files() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("a.shape"), "let x = 1").unwrap();
std::fs::create_dir_all(root.join("sub")).unwrap();
std::fs::write(root.join("sub/b.shape"), "fn foo() { 2 }").unwrap();
std::fs::create_dir_all(root.join(".git")).unwrap();
std::fs::write(root.join(".git/c.shape"), "ignored").unwrap();
std::fs::create_dir_all(root.join("target")).unwrap();
std::fs::write(root.join("target/d.shape"), "ignored").unwrap();
std::fs::write(root.join("e.txt"), "ignored").unwrap();
let cache = ModuleCache::new();
let files = cache.enumerate_workspace_shape_files(root);
let names: Vec<String> = files
.iter()
.filter_map(|p| p.file_name().and_then(|n| n.to_str()).map(String::from))
.collect();
assert!(names.contains(&"a.shape".to_string()), "expected a.shape, got {:?}", names);
assert!(names.contains(&"b.shape".to_string()), "expected b.shape, got {:?}", names);
assert!(
!names.contains(&"c.shape".to_string()),
"hidden dir .git must be excluded, got {:?}",
names
);
assert!(
!names.contains(&"d.shape".to_string()),
"build dir target must be excluded, got {:?}",
names
);
assert!(
!names.contains(&"e.txt".to_string()),
"non-.shape file must be excluded, got {:?}",
names
);
}
#[test]
fn test_path_to_module_path_w27() {
let root = PathBuf::from("/ws");
assert_eq!(
path_to_module_path(&root.join("foo.shape"), &root).as_deref(),
Some("foo")
);
assert_eq!(
path_to_module_path(&root.join("lib/util.shape"), &root).as_deref(),
Some("lib::util")
);
assert_eq!(
path_to_module_path(&root.join("src/sub/mod.shape"), &root).as_deref(),
Some("src::sub::mod")
);
assert!(path_to_module_path(&root.join("foo.txt"), &root).is_none());
assert!(path_to_module_path(&PathBuf::from("/other/x.shape"), &root).is_none());
assert!(path_to_module_path(&root.join("2bad.shape"), &root).is_none());
assert!(path_to_module_path(&root.join("a-b/x.shape"), &root).is_none());
assert_eq!(
path_to_module_path(&root.join("_priv/_x.shape"), &root).as_deref(),
Some("_priv::_x")
);
}
#[test]
fn test_module_cache_invalidation() {
let cache = ModuleCache::new();
let path = PathBuf::from("/test/module.shape");
let program = Arc::new(Program {
items: vec![],
docs: shape_ast::ast::ProgramDocs::default(),
});
let module_info = ModuleInfo {
path: path.clone(),
program,
exports: vec![],
};
cache.modules.insert(path.clone(), module_info.clone());
assert!(cache.get_module(&path).is_some());
cache.invalidate(&path);
assert!(cache.get_module(&path).is_none());
}
}