Skip to main content

tl_compiler/
module.rs

1// ThinkingLanguage — Module Resolution Engine
2// Licensed under MIT OR Apache-2.0
3//
4// Maps dot-paths to files, handles mod.tl directories,
5// caches modules, and detects circular imports.
6// Shared by both VM and interpreter.
7
8use std::collections::{HashMap, HashSet};
9use std::path::{Path, PathBuf};
10use tl_errors::{RuntimeError, TlError};
11
12/// Metadata about an exported item from a module.
13#[derive(Debug, Clone)]
14pub struct ExportedItem {
15    pub name: String,
16    pub is_public: bool,
17}
18
19/// The exports from a loaded module.
20#[derive(Debug, Clone)]
21pub struct ModuleExports {
22    pub items: HashMap<String, ExportedItem>,
23    pub file_path: PathBuf,
24}
25
26/// Result of resolving a dot-path.
27#[derive(Debug, Clone)]
28pub struct ResolvedModule {
29    /// Absolute file path to the module source
30    pub file_path: PathBuf,
31    /// If the last segment is an item within the module (not a file)
32    pub item_name: Option<String>,
33}
34
35/// Module resolver — maps dot-paths to files, detects circulars.
36pub struct ModuleResolver {
37    /// Project root (where tl.toml lives, or the directory of the entry file)
38    root: PathBuf,
39    /// File currently being processed (for relative resolution)
40    current_file: Option<PathBuf>,
41    /// Cache: canonical path → exports
42    module_cache: HashMap<PathBuf, ModuleExports>,
43    /// Files currently being imported (circular detection)
44    importing: HashSet<PathBuf>,
45}
46
47impl ModuleResolver {
48    pub fn new(root: PathBuf) -> Self {
49        Self {
50            root,
51            current_file: None,
52            module_cache: HashMap::new(),
53            importing: HashSet::new(),
54        }
55    }
56
57    pub fn set_current_file(&mut self, path: Option<PathBuf>) {
58        self.current_file = path;
59    }
60
61    pub fn root(&self) -> &Path {
62        &self.root
63    }
64
65    /// Resolve a dot-path (from `use` statement) to a file path.
66    ///
67    /// Given segments like `["data", "transforms", "clean"]`:
68    /// 1. Try `<base>/data/transforms/clean.tl` (file module)
69    /// 2. Try `<base>/data/transforms/clean/mod.tl` (directory module)
70    /// 3. Try `<base>/data/transforms.tl` with item "clean" (item within file)
71    /// 4. Try `<base>/data/transforms/mod.tl` with item "clean" (item within dir module)
72    pub fn resolve_path(&self, segments: &[String]) -> Result<ResolvedModule, TlError> {
73        let base = self.base_dir();
74
75        if segments.is_empty() {
76            return Err(module_err("Empty module path".to_string()));
77        }
78
79        // Build the full path from all segments
80        let rel_path: PathBuf = segments.iter().collect();
81
82        // 1. Try as file module: segments.tl
83        let file_path = base.join(&rel_path).with_extension("tl");
84        if file_path.exists() {
85            return Ok(ResolvedModule {
86                file_path,
87                item_name: None,
88            });
89        }
90
91        // 2. Try as directory module: segments/mod.tl
92        let dir_path = base.join(&rel_path).join("mod.tl");
93        if dir_path.exists() {
94            return Ok(ResolvedModule {
95                file_path: dir_path,
96                item_name: None,
97            });
98        }
99
100        // 3. If more than one segment, try parent as module, last as item
101        if segments.len() > 1 {
102            let (parent_segs, item_name) = segments.split_at(segments.len() - 1);
103            let parent_path: PathBuf = parent_segs.iter().collect();
104
105            // Try parent.tl with last segment as item
106            let parent_file = base.join(&parent_path).with_extension("tl");
107            if parent_file.exists() {
108                return Ok(ResolvedModule {
109                    file_path: parent_file,
110                    item_name: Some(item_name[0].clone()),
111                });
112            }
113
114            // Try parent/mod.tl with last segment as item
115            let parent_dir = base.join(&parent_path).join("mod.tl");
116            if parent_dir.exists() {
117                return Ok(ResolvedModule {
118                    file_path: parent_dir,
119                    item_name: Some(item_name[0].clone()),
120                });
121            }
122        }
123
124        Err(module_err(format!(
125            "Module not found: `{}`. Searched in: {}",
126            segments.join("."),
127            base.display()
128        )))
129    }
130
131    /// Resolve a prefix path for group/wildcard imports.
132    /// Returns the file path for the module containing the group items.
133    pub fn resolve_prefix(&self, segments: &[String]) -> Result<PathBuf, TlError> {
134        let base = self.base_dir();
135
136        if segments.is_empty() {
137            return Err(module_err("Empty module path".to_string()));
138        }
139
140        let rel_path: PathBuf = segments.iter().collect();
141
142        // Try as file module
143        let file_path = base.join(&rel_path).with_extension("tl");
144        if file_path.exists() {
145            return Ok(file_path);
146        }
147
148        // Try as directory module
149        let dir_path = base.join(&rel_path).join("mod.tl");
150        if dir_path.exists() {
151            return Ok(dir_path);
152        }
153
154        Err(module_err(format!(
155            "Module not found: `{}`",
156            segments.join(".")
157        )))
158    }
159
160    /// Check for circular dependency. Returns Err if circular.
161    pub fn begin_import(&mut self, path: &Path) -> Result<(), TlError> {
162        let canonical = self.canonicalize(path);
163        if self.importing.contains(&canonical) {
164            return Err(module_err(format!(
165                "Circular import detected: {}",
166                canonical.display()
167            )));
168        }
169        self.importing.insert(canonical);
170        Ok(())
171    }
172
173    /// Mark import as complete.
174    pub fn end_import(&mut self, path: &Path) {
175        let canonical = self.canonicalize(path);
176        self.importing.remove(&canonical);
177    }
178
179    /// Check if a module is cached.
180    pub fn get_cached(&self, path: &Path) -> Option<&ModuleExports> {
181        let canonical = self.canonicalize(path);
182        self.module_cache.get(&canonical)
183    }
184
185    /// Cache a module's exports.
186    pub fn cache_module(&mut self, path: &Path, exports: ModuleExports) {
187        let canonical = self.canonicalize(path);
188        self.module_cache.insert(canonical, exports);
189    }
190
191    fn base_dir(&self) -> PathBuf {
192        if let Some(ref current) = self.current_file {
193            current.parent().unwrap_or(Path::new(".")).to_path_buf()
194        } else {
195            self.root.clone()
196        }
197    }
198
199    fn canonicalize(&self, path: &Path) -> PathBuf {
200        path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
201    }
202
203    /// Resolve a module path against package roots.
204    /// Returns a ResolvedModule if the first segment matches a package name.
205    pub fn resolve_package_path(
206        &self,
207        segments: &[String],
208        package_roots: &HashMap<String, PathBuf>,
209    ) -> Option<ResolvedModule> {
210        if segments.is_empty() {
211            return None;
212        }
213
214        let pkg_name = &segments[0];
215        let pkg_name_hyphen = pkg_name.replace('_', "-");
216        let pkg_root = package_roots
217            .get(pkg_name.as_str())
218            .or_else(|| package_roots.get(&pkg_name_hyphen))?;
219
220        let remaining = &segments[1..];
221
222        if remaining.is_empty() {
223            // Import the package entry point
224            let src = pkg_root.join("src");
225            for entry in &["lib.tl", "mod.tl", "main.tl"] {
226                let p = src.join(entry);
227                if p.exists() {
228                    return Some(ResolvedModule {
229                        file_path: p,
230                        item_name: None,
231                    });
232                }
233            }
234            for entry in &["mod.tl", "lib.tl"] {
235                let p = pkg_root.join(entry);
236                if p.exists() {
237                    return Some(ResolvedModule {
238                        file_path: p,
239                        item_name: None,
240                    });
241                }
242            }
243            return None;
244        }
245
246        let rel: PathBuf = remaining.iter().collect();
247        let src = pkg_root.join("src");
248
249        // Try src/<rel>.tl
250        let file_path = src.join(&rel).with_extension("tl");
251        if file_path.exists() {
252            return Some(ResolvedModule {
253                file_path,
254                item_name: None,
255            });
256        }
257
258        // Try src/<rel>/mod.tl
259        let dir_path = src.join(&rel).join("mod.tl");
260        if dir_path.exists() {
261            return Some(ResolvedModule {
262                file_path: dir_path,
263                item_name: None,
264            });
265        }
266
267        // Try <root>/<rel>.tl
268        let file_path = pkg_root.join(&rel).with_extension("tl");
269        if file_path.exists() {
270            return Some(ResolvedModule {
271                file_path,
272                item_name: None,
273            });
274        }
275
276        // Parent fallback for item within module
277        if remaining.len() > 1 {
278            let parent: PathBuf = remaining[..remaining.len() - 1].iter().collect();
279            let item = remaining.last().unwrap().clone();
280            let parent_file = src.join(&parent).with_extension("tl");
281            if parent_file.exists() {
282                return Some(ResolvedModule {
283                    file_path: parent_file,
284                    item_name: Some(item),
285                });
286            }
287        }
288
289        None
290    }
291}
292
293fn module_err(message: String) -> TlError {
294    TlError::Runtime(RuntimeError {
295        message,
296        span: None,
297        stack_trace: vec![],
298    })
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use std::fs;
305
306    fn setup_test_dir() -> tempfile::TempDir {
307        let dir = tempfile::tempdir().unwrap();
308
309        // Create test module files
310        let src = dir.path();
311        fs::write(src.join("math.tl"), "pub fn add(a, b) { a + b }").unwrap();
312        fs::create_dir_all(src.join("data")).unwrap();
313        fs::write(src.join("data/transforms.tl"), "pub fn clean(x) { x }").unwrap();
314        fs::create_dir_all(src.join("utils")).unwrap();
315        fs::write(src.join("utils/mod.tl"), "pub fn helper() { 1 }").unwrap();
316        fs::create_dir_all(src.join("nested/deep")).unwrap();
317        fs::write(src.join("nested/deep/mod.tl"), "pub fn deep_fn() { 42 }").unwrap();
318
319        dir
320    }
321
322    #[test]
323    fn test_resolve_file_module() {
324        let dir = setup_test_dir();
325        let resolver = ModuleResolver::new(dir.path().to_path_buf());
326
327        let result = resolver.resolve_path(&["math".into()]).unwrap();
328        assert_eq!(result.file_path, dir.path().join("math.tl"));
329        assert!(result.item_name.is_none());
330    }
331
332    #[test]
333    fn test_resolve_nested_file_module() {
334        let dir = setup_test_dir();
335        let resolver = ModuleResolver::new(dir.path().to_path_buf());
336
337        let result = resolver
338            .resolve_path(&["data".into(), "transforms".into()])
339            .unwrap();
340        assert_eq!(result.file_path, dir.path().join("data/transforms.tl"));
341        assert!(result.item_name.is_none());
342    }
343
344    #[test]
345    fn test_resolve_directory_module() {
346        let dir = setup_test_dir();
347        let resolver = ModuleResolver::new(dir.path().to_path_buf());
348
349        let result = resolver.resolve_path(&["utils".into()]).unwrap();
350        assert_eq!(result.file_path, dir.path().join("utils/mod.tl"));
351        assert!(result.item_name.is_none());
352    }
353
354    #[test]
355    fn test_resolve_item_within_module() {
356        let dir = setup_test_dir();
357        let resolver = ModuleResolver::new(dir.path().to_path_buf());
358
359        // "math.add" → math.tl file with item "add"
360        let result = resolver
361            .resolve_path(&["math".into(), "add".into()])
362            .unwrap();
363        assert_eq!(result.file_path, dir.path().join("math.tl"));
364        assert_eq!(result.item_name, Some("add".into()));
365    }
366
367    #[test]
368    fn test_circular_detection() {
369        let dir = setup_test_dir();
370        let mut resolver = ModuleResolver::new(dir.path().to_path_buf());
371
372        let path = dir.path().join("math.tl");
373        resolver.begin_import(&path).unwrap();
374        let result = resolver.begin_import(&path);
375        assert!(result.is_err());
376        assert!(format!("{:?}", result).contains("Circular import"));
377    }
378
379    #[test]
380    fn test_module_not_found() {
381        let dir = setup_test_dir();
382        let resolver = ModuleResolver::new(dir.path().to_path_buf());
383
384        let result = resolver.resolve_path(&["nonexistent".into()]);
385        assert!(result.is_err());
386    }
387}