Skip to main content

arcane_engine/scripting/
module_loader.rs

1use deno_ast::MediaType;
2use deno_ast::ParseParams;
3use deno_ast::TranspileModuleOptions;
4use deno_core::ModuleLoadResponse;
5use deno_core::ModuleLoader;
6use deno_core::ModuleSourceCode;
7use deno_core::ModuleSpecifier;
8use deno_error::JsErrorBox;
9use std::collections::HashMap;
10
11/// Import map for resolving bare specifiers to file paths
12#[derive(Debug, Clone, Default)]
13pub struct ImportMap {
14    pub imports: HashMap<String, String>,
15}
16
17impl ImportMap {
18    /// Create a new empty import map
19    pub fn new() -> Self {
20        Self {
21            imports: HashMap::new(),
22        }
23    }
24
25    /// Add a mapping from a bare specifier to a file path
26    pub fn add(&mut self, specifier: String, path: String) {
27        self.imports.insert(specifier, path);
28    }
29
30    /// Resolve a specifier using the import map
31    /// Returns the mapped path if found, otherwise None
32    pub fn resolve(&self, specifier: &str) -> Option<&str> {
33        // Check for exact match first
34        if let Some(mapped) = self.imports.get(specifier) {
35            return Some(mapped.as_str());
36        }
37
38        // Check for prefix match (e.g., "@arcane/runtime/state" matches "@arcane/runtime/")
39        for (key, value) in &self.imports {
40            if key.ends_with('/') && specifier.starts_with(key) {
41                // Replace the prefix
42                let suffix = &specifier[key.len()..];
43                // For now, return the base path + suffix
44                // This requires string allocation, so we'll handle it differently
45                // in the actual resolve method
46                continue;
47            }
48        }
49
50        None
51    }
52}
53
54/// Loads `.ts` and `.js` files from the filesystem with import map support.
55/// TypeScript files are transpiled via `deno_ast` (type stripping).
56/// JavaScript files pass through unchanged.
57pub struct TsModuleLoader {
58    import_map: ImportMap,
59}
60
61impl TsModuleLoader {
62    pub fn new() -> Self {
63        Self {
64            import_map: ImportMap::new(),
65        }
66    }
67
68    pub fn with_import_map(import_map: ImportMap) -> Self {
69        Self { import_map }
70    }
71}
72
73impl Default for TsModuleLoader {
74    fn default() -> Self {
75        Self::new()
76    }
77}
78
79impl ModuleLoader for TsModuleLoader {
80    fn resolve(
81        &self,
82        specifier: &str,
83        referrer: &str,
84        _kind: deno_core::ResolutionKind,
85    ) -> Result<ModuleSpecifier, deno_core::error::ModuleLoaderError> {
86        // Try import map resolution first
87        let resolved_specifier = self.resolve_with_import_map(specifier, referrer)?;
88
89        deno_core::resolve_import(&resolved_specifier, referrer).map_err(JsErrorBox::from_err)
90    }
91
92    fn load(
93        &self,
94        module_specifier: &ModuleSpecifier,
95        _maybe_referrer: Option<&deno_core::ModuleLoadReferrer>,
96        _options: deno_core::ModuleLoadOptions,
97    ) -> ModuleLoadResponse {
98        let module_specifier = module_specifier.clone();
99
100        ModuleLoadResponse::Sync(load_module(&module_specifier))
101    }
102}
103
104impl TsModuleLoader {
105    /// Resolve specifier using import map, returning either mapped path or original specifier
106    fn resolve_with_import_map(
107        &self,
108        specifier: &str,
109        referrer: &str,
110    ) -> Result<String, deno_core::error::ModuleLoaderError> {
111        // If it's already a relative or absolute path, don't use import map
112        if specifier.starts_with("./")
113            || specifier.starts_with("../")
114            || specifier.starts_with('/')
115            || specifier.starts_with("file:")
116            || specifier.starts_with("http:")
117            || specifier.starts_with("https:")
118        {
119            return Ok(specifier.to_string());
120        }
121
122        // Check for exact match
123        if let Some(mapped) = self.import_map.imports.get(specifier) {
124            return Ok(mapped.clone());
125        }
126
127        // Check for prefix match (e.g., "@arcane/runtime/state" matches "@arcane/runtime/")
128        for (key, value) in &self.import_map.imports {
129            if key.ends_with('/') && specifier.starts_with(key) {
130                let suffix = &specifier[key.len()..];
131                let resolved = format!("{}{}", value, suffix);
132                return Ok(resolved);
133            }
134        }
135
136        // No mapping found, return original specifier
137        Ok(specifier.to_string())
138    }
139}
140
141fn load_module(
142    specifier: &ModuleSpecifier,
143) -> Result<deno_core::ModuleSource, deno_core::error::ModuleLoaderError> {
144    let path = specifier.to_file_path().map_err(|_| {
145        JsErrorBox::generic(format!(
146            "Cannot convert module specifier to file path: {specifier}"
147        ))
148    })?;
149
150    let media_type = MediaType::from_path(&path);
151
152    let (module_type, should_transpile) = match media_type {
153        MediaType::JavaScript | MediaType::Mjs | MediaType::Cjs => {
154            (deno_core::ModuleType::JavaScript, false)
155        }
156        MediaType::Jsx => (deno_core::ModuleType::JavaScript, true),
157        MediaType::TypeScript
158        | MediaType::Mts
159        | MediaType::Cts
160        | MediaType::Dts
161        | MediaType::Dmts
162        | MediaType::Dcts
163        | MediaType::Tsx => (deno_core::ModuleType::JavaScript, true),
164        MediaType::Json => (deno_core::ModuleType::Json, false),
165        _ => {
166            return Err(JsErrorBox::generic(format!(
167                "Unsupported file type: {}",
168                path.display()
169            )));
170        }
171    };
172
173    let code = std::fs::read_to_string(&path).map_err(|e| {
174        JsErrorBox::generic(format!("Failed to read {}: {e}", path.display()))
175    })?;
176
177    let code = if should_transpile {
178        let parsed = deno_ast::parse_module(ParseParams {
179            specifier: specifier.clone(),
180            text: code.into(),
181            media_type,
182            capture_tokens: false,
183            scope_analysis: false,
184            maybe_syntax: None,
185        })
186        .map_err(|e| JsErrorBox::generic(format!("Parse error: {e}")))?;
187
188        let transpiled = parsed
189            .transpile(
190                &deno_ast::TranspileOptions::default(),
191                &TranspileModuleOptions::default(),
192                &deno_ast::EmitOptions::default(),
193            )
194            .map_err(|e| JsErrorBox::generic(format!("Transpile error: {e}")))?;
195
196        transpiled.into_source().text
197    } else {
198        code
199    };
200
201    let module = deno_core::ModuleSource::new(
202        module_type,
203        ModuleSourceCode::String(code.into()),
204        specifier,
205        None,
206    );
207
208    Ok(module)
209}