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#[cfg(test)]
55mod import_map_tests {
56    use super::*;
57
58    #[test]
59    fn empty_import_map_resolves_nothing() {
60        let map = ImportMap::new();
61        assert_eq!(map.resolve("foo"), None);
62        assert_eq!(map.resolve("@arcane/runtime"), None);
63    }
64
65    #[test]
66    fn exact_match_resolves() {
67        let mut map = ImportMap::new();
68        map.add("@arcane/runtime".to_string(), "file:///path/to/runtime/index.ts".to_string());
69
70        assert_eq!(map.resolve("@arcane/runtime"), Some("file:///path/to/runtime/index.ts"));
71    }
72
73    #[test]
74    fn prefix_match_is_not_implemented_yet() {
75        let mut map = ImportMap::new();
76        map.add("@arcane/runtime/".to_string(), "file:///path/to/runtime/".to_string());
77
78        // The current implementation doesn't return prefix matches
79        // (it has TODO code that continues)
80        assert_eq!(map.resolve("@arcane/runtime/state"), None);
81    }
82
83    #[test]
84    fn multiple_mappings_work() {
85        let mut map = ImportMap::new();
86        map.add("foo".to_string(), "file:///foo.ts".to_string());
87        map.add("bar".to_string(), "file:///bar.ts".to_string());
88        map.add("baz".to_string(), "file:///baz.ts".to_string());
89
90        assert_eq!(map.resolve("foo"), Some("file:///foo.ts"));
91        assert_eq!(map.resolve("bar"), Some("file:///bar.ts"));
92        assert_eq!(map.resolve("baz"), Some("file:///baz.ts"));
93        assert_eq!(map.resolve("qux"), None);
94    }
95
96    #[test]
97    fn last_add_wins_for_same_specifier() {
98        let mut map = ImportMap::new();
99        map.add("foo".to_string(), "file:///first.ts".to_string());
100        map.add("foo".to_string(), "file:///second.ts".to_string());
101
102        assert_eq!(map.resolve("foo"), Some("file:///second.ts"));
103    }
104
105    #[test]
106    fn clone_preserves_mappings() {
107        let mut map = ImportMap::new();
108        map.add("foo".to_string(), "file:///foo.ts".to_string());
109
110        let cloned = map.clone();
111        assert_eq!(cloned.resolve("foo"), Some("file:///foo.ts"));
112    }
113
114    #[test]
115    fn default_is_empty() {
116        let map = ImportMap::default();
117        assert_eq!(map.imports.len(), 0);
118    }
119}
120
121/// Loads `.ts` and `.js` files from the filesystem with import map support.
122/// TypeScript files are transpiled via `deno_ast` (type stripping).
123/// JavaScript files pass through unchanged.
124pub struct TsModuleLoader {
125    import_map: ImportMap,
126}
127
128impl TsModuleLoader {
129    pub fn new() -> Self {
130        Self {
131            import_map: ImportMap::new(),
132        }
133    }
134
135    pub fn with_import_map(import_map: ImportMap) -> Self {
136        Self { import_map }
137    }
138}
139
140impl Default for TsModuleLoader {
141    fn default() -> Self {
142        Self::new()
143    }
144}
145
146impl ModuleLoader for TsModuleLoader {
147    fn resolve(
148        &self,
149        specifier: &str,
150        referrer: &str,
151        _kind: deno_core::ResolutionKind,
152    ) -> Result<ModuleSpecifier, deno_core::error::ModuleLoaderError> {
153        // Try import map resolution first
154        let resolved_specifier = self.resolve_with_import_map(specifier, referrer)?;
155
156        deno_core::resolve_import(&resolved_specifier, referrer).map_err(JsErrorBox::from_err)
157    }
158
159    fn load(
160        &self,
161        module_specifier: &ModuleSpecifier,
162        _maybe_referrer: Option<&deno_core::ModuleLoadReferrer>,
163        _options: deno_core::ModuleLoadOptions,
164    ) -> ModuleLoadResponse {
165        let module_specifier = module_specifier.clone();
166
167        ModuleLoadResponse::Sync(load_module(&module_specifier))
168    }
169}
170
171impl TsModuleLoader {
172    /// Resolve specifier using import map, returning either mapped path or original specifier
173    fn resolve_with_import_map(
174        &self,
175        specifier: &str,
176        _referrer: &str,
177    ) -> Result<String, deno_core::error::ModuleLoaderError> {
178        // If it's already a relative or absolute path, don't use import map
179        if specifier.starts_with("./")
180            || specifier.starts_with("../")
181            || specifier.starts_with('/')
182            || specifier.starts_with("file:")
183            || specifier.starts_with("http:")
184            || specifier.starts_with("https:")
185        {
186            return Ok(specifier.to_string());
187        }
188
189        // Check for exact match
190        if let Some(mapped) = self.import_map.imports.get(specifier) {
191            return Ok(mapped.clone());
192        }
193
194        // Check for prefix match (e.g., "@arcane/runtime/state" matches "@arcane/runtime/")
195        for (key, value) in &self.import_map.imports {
196            if key.ends_with('/') && specifier.starts_with(key) {
197                let suffix = &specifier[key.len()..];
198                let resolved = format!("{}{}", value, suffix);
199                return Ok(resolved);
200            }
201        }
202
203        // No mapping found, return original specifier
204        Ok(specifier.to_string())
205    }
206}
207
208fn load_module(
209    specifier: &ModuleSpecifier,
210) -> Result<deno_core::ModuleSource, deno_core::error::ModuleLoaderError> {
211    let path = specifier.to_file_path().map_err(|_| {
212        JsErrorBox::generic(format!(
213            "Cannot convert module specifier to file path: {specifier}"
214        ))
215    })?;
216
217    let media_type = MediaType::from_path(&path);
218
219    let (module_type, should_transpile) = match media_type {
220        MediaType::JavaScript | MediaType::Mjs | MediaType::Cjs => {
221            (deno_core::ModuleType::JavaScript, false)
222        }
223        MediaType::Jsx => (deno_core::ModuleType::JavaScript, true),
224        MediaType::TypeScript
225        | MediaType::Mts
226        | MediaType::Cts
227        | MediaType::Dts
228        | MediaType::Dmts
229        | MediaType::Dcts
230        | MediaType::Tsx => (deno_core::ModuleType::JavaScript, true),
231        MediaType::Json => (deno_core::ModuleType::Json, false),
232        _ => {
233            return Err(JsErrorBox::generic(format!(
234                "Unsupported file type: {}",
235                path.display()
236            )));
237        }
238    };
239
240    let code = std::fs::read_to_string(&path).map_err(|e| {
241        JsErrorBox::generic(format!("Failed to read {}: {e}", path.display()))
242    })?;
243
244    let code = if should_transpile {
245        let parsed = deno_ast::parse_module(ParseParams {
246            specifier: specifier.clone(),
247            text: code.into(),
248            media_type,
249            capture_tokens: false,
250            scope_analysis: false,
251            maybe_syntax: None,
252        })
253        .map_err(|e| JsErrorBox::generic(format!("Parse error: {e}")))?;
254
255        let transpiled = parsed
256            .transpile(
257                &deno_ast::TranspileOptions::default(),
258                &TranspileModuleOptions::default(),
259                &deno_ast::EmitOptions::default(),
260            )
261            .map_err(|e| JsErrorBox::generic(format!("Transpile error: {e}")))?;
262
263        transpiled.into_source().text
264    } else {
265        code
266    };
267
268    let module = deno_core::ModuleSource::new(
269        module_type,
270        ModuleSourceCode::String(code.into()),
271        specifier,
272        None,
273    );
274
275    Ok(module)
276}