Skip to main content

logicaffeine_compile/
loader.rs

1//! Module loader for multi-file LOGOS projects.
2//!
3//! Handles resolution and loading of module sources from various URI schemes,
4//! with caching to prevent duplicate loading.
5//!
6//! # Supported URI Schemes
7//!
8//! | Scheme | Example | Description |
9//! |--------|---------|-------------|
10//! | `file:` | `file:./geometry.md` | Local filesystem (relative) |
11//! | `logos:` | `logos:std` | Built-in standard library |
12//! | (none) | `geometry.md` | Defaults to `file:` scheme |
13//!
14//! # Security
15//!
16//! The loader prevents path traversal attacks by checking that resolved
17//! paths remain within the project root directory.
18//!
19//! # Caching
20//!
21//! Modules are cached by their normalized URI. The same module loaded from
22//! different base paths will be cached separately.
23//!
24//! # Example
25//!
26//! ```no_run
27//! # use logicaffeine_compile::loader::Loader;
28//! # use std::path::{Path, PathBuf};
29//! # fn main() -> Result<(), String> {
30//! # let project_root = PathBuf::from(".");
31//! let mut loader = Loader::new(project_root);
32//! let source = loader.resolve(Path::new("main.md"), "file:./lib/math.md")?;
33//! println!("Loaded: {}", source.path.display());
34//! # Ok(())
35//! # }
36//! ```
37
38use std::collections::HashMap;
39use std::fs;
40use std::path::{Path, PathBuf};
41
42/// A loaded module's source content and metadata.
43#[derive(Debug, Clone)]
44pub struct ModuleSource {
45    /// The source content of the module
46    pub content: String,
47    /// The resolved path (for error reporting and relative resolution)
48    pub path: PathBuf,
49}
50
51/// Module loader that handles multiple URI schemes.
52///
53/// Caches loaded modules to prevent duplicate loading and supports
54/// cycle detection through the cache.
55pub struct Loader {
56    /// Cache of loaded modules (URI -> ModuleSource)
57    cache: HashMap<String, ModuleSource>,
58    /// Root directory of the project (for relative path resolution)
59    root_path: PathBuf,
60}
61
62impl Loader {
63    /// Creates a new Loader with the given root path.
64    pub fn new(root_path: PathBuf) -> Self {
65        Loader {
66            cache: HashMap::new(),
67            root_path,
68        }
69    }
70
71    /// Resolves a URI to a module source.
72    ///
73    /// Supports:
74    /// - `file:./path.md` - Local filesystem (relative to base_path)
75    /// - `logos:std` - Built-in standard library
76    /// - `logos:core` - Built-in core types
77    pub fn resolve(&mut self, base_path: &Path, uri: &str) -> Result<&ModuleSource, String> {
78        // Normalize the URI for caching
79        let cache_key = self.normalize_uri(base_path, uri)?;
80
81        // Check cache first
82        if self.cache.contains_key(&cache_key) {
83            return Ok(&self.cache[&cache_key]);
84        }
85
86        // Load based on scheme
87        let source = if uri.starts_with("file:") {
88            self.load_file(base_path, uri)?
89        } else if uri.starts_with("logos:") {
90            self.load_intrinsic(uri)?
91        } else if uri.starts_with("https://") || uri.starts_with("http://") {
92            // Remote loading not supported in base loader
93            return Err(format!(
94                "Remote module loading not supported for '{}'. \
95                 Use the CLI's 'logos fetch' command to download dependencies locally.",
96                uri
97            ));
98        } else {
99            // Default to file: scheme if no scheme provided
100            self.load_file(base_path, &format!("file:{}", uri))?
101        };
102
103        // Cache and return
104        self.cache.insert(cache_key.clone(), source);
105        Ok(&self.cache[&cache_key])
106    }
107
108    /// Normalizes a URI for consistent caching.
109    fn normalize_uri(&self, base_path: &Path, uri: &str) -> Result<String, String> {
110        if uri.starts_with("file:") {
111            let path_str = uri.trim_start_matches("file:");
112            let base_dir = base_path.parent().unwrap_or(&self.root_path);
113            let resolved = base_dir.join(path_str);
114            Ok(format!("file:{}", resolved.display()))
115        } else {
116            Ok(uri.to_string())
117        }
118    }
119
120    /// Loads a module from the local filesystem.
121    fn load_file(&self, base_path: &Path, uri: &str) -> Result<ModuleSource, String> {
122        let path_str = uri.trim_start_matches("file:");
123
124        // Resolve relative to the base file's directory
125        let base_dir = base_path.parent().unwrap_or(&self.root_path);
126        let resolved_path = base_dir.join(path_str);
127
128        // Security: Check that we're not escaping the root path
129        let canonical_root = self.root_path.canonicalize()
130            .unwrap_or_else(|_| self.root_path.clone());
131
132        // Read the file
133        let content = fs::read_to_string(&resolved_path)
134            .map_err(|e| format!("Failed to read '{}': {}", resolved_path.display(), e))?;
135
136        // Check if escaping root (after we know the file exists)
137        if let Ok(canonical_path) = resolved_path.canonicalize() {
138            if !canonical_path.starts_with(&canonical_root) {
139                return Err(format!(
140                    "Security: Cannot load '{}' - path escapes project root",
141                    uri
142                ));
143            }
144        }
145
146        Ok(ModuleSource {
147            content,
148            path: resolved_path,
149        })
150    }
151
152    /// Loads a built-in module (embedded at compile time).
153    fn load_intrinsic(&self, uri: &str) -> Result<ModuleSource, String> {
154        let name = uri.trim_start_matches("logos:");
155
156        match name {
157            "std" => Ok(ModuleSource {
158                content: include_str!("../assets/std/std.md").to_string(),
159                path: PathBuf::from("logos:std"),
160            }),
161            "core" => Ok(ModuleSource {
162                content: include_str!("../assets/std/core.md").to_string(),
163                path: PathBuf::from("logos:core"),
164            }),
165            _ => Err(format!("Unknown intrinsic module: '{}'", uri)),
166        }
167    }
168
169    /// Checks if a module has already been loaded (for cycle detection).
170    pub fn is_loaded(&self, uri: &str) -> bool {
171        self.cache.contains_key(uri)
172    }
173
174    /// Returns all loaded module URIs (for debugging).
175    pub fn loaded_modules(&self) -> Vec<&str> {
176        self.cache.keys().map(|s| s.as_str()).collect()
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use tempfile::tempdir;
184
185    #[test]
186    fn test_file_scheme_resolution() {
187        let temp_dir = tempdir().unwrap();
188        let geo_path = temp_dir.path().join("geo.md");
189        fs::write(&geo_path, "## Definition\nA Point has:\n    an x, which is Int.\n").unwrap();
190
191        let mut loader = Loader::new(temp_dir.path().to_path_buf());
192        let result = loader.resolve(&temp_dir.path().join("main.md"), "file:./geo.md");
193
194        assert!(result.is_ok(), "Should resolve file: scheme: {:?}", result);
195        assert!(result.unwrap().content.contains("Point"));
196    }
197
198    #[test]
199    fn test_logos_std_scheme() {
200        let mut loader = Loader::new(PathBuf::from("."));
201        let result = loader.resolve(&PathBuf::from("main.md"), "logos:std");
202
203        assert!(result.is_ok(), "Should resolve logos:std: {:?}", result);
204    }
205
206    #[test]
207    fn test_logos_core_scheme() {
208        let mut loader = Loader::new(PathBuf::from("."));
209        let result = loader.resolve(&PathBuf::from("main.md"), "logos:core");
210
211        assert!(result.is_ok(), "Should resolve logos:core: {:?}", result);
212    }
213
214    #[test]
215    fn test_unknown_intrinsic() {
216        let mut loader = Loader::new(PathBuf::from("."));
217        let result = loader.resolve(&PathBuf::from("main.md"), "logos:unknown");
218
219        assert!(result.is_err());
220        assert!(result.unwrap_err().contains("Unknown intrinsic"));
221    }
222
223    #[test]
224    fn test_caching() {
225        let temp_dir = tempdir().unwrap();
226        let geo_path = temp_dir.path().join("geo.md");
227        fs::write(&geo_path, "content").unwrap();
228
229        let mut loader = Loader::new(temp_dir.path().to_path_buf());
230
231        // First load
232        let _ = loader.resolve(&temp_dir.path().join("main.md"), "file:./geo.md");
233
234        // Should be cached now
235        assert!(loader.loaded_modules().len() == 1);
236    }
237
238    #[test]
239    fn test_missing_file() {
240        let temp_dir = tempdir().unwrap();
241        let mut loader = Loader::new(temp_dir.path().to_path_buf());
242
243        let result = loader.resolve(&temp_dir.path().join("main.md"), "file:./nonexistent.md");
244
245        assert!(result.is_err());
246        assert!(result.unwrap_err().contains("Failed to read"));
247    }
248}