fusabi_frontend/
loader.rs

1//! File loader for multi-file module system
2//!
3//! This module implements the `FileLoader` for loading and caching `.fsx` files
4//! referenced by `#load` directives. It handles:
5//! - Path resolution (relative and absolute paths)
6//! - Circular dependency detection
7//! - Caching of loaded files to avoid recompilation
8//!
9//! # Example
10//!
11//! ```rust
12//! use fusabi_frontend::loader::FileLoader;
13//! use std::path::PathBuf;
14//!
15//! let mut loader = FileLoader::new(PathBuf::from("."));
16//! // Load a file (with circular dependency detection and caching)
17//! // let loaded = loader.load("utils.fsx", &PathBuf::from("main.fsx")).unwrap();
18//! ```
19
20use crate::ast::Program;
21use crate::lexer::{LexError, Lexer};
22use crate::parser::{ParseError, Parser};
23use std::collections::{HashMap, HashSet};
24use std::fmt;
25use std::path::{Path, PathBuf};
26
27/// Errors that can occur during file loading
28#[derive(Debug, Clone)]
29pub enum LoadError {
30    /// File not found at the specified path
31    FileNotFound(PathBuf),
32    /// Circular dependency detected
33    CircularDependency(Vec<PathBuf>),
34    /// Parse error in loaded file
35    ParseError(PathBuf, ParseError),
36    /// Lexer error in loaded file
37    LexError(PathBuf, LexError),
38    /// IO error reading file
39    IoError(String),
40}
41
42impl fmt::Display for LoadError {
43    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44        match self {
45            LoadError::FileNotFound(path) => write!(f, "File not found: {}", path.display()),
46            LoadError::CircularDependency(paths) => {
47                write!(f, "Circular dependency detected: ")?;
48                for (i, path) in paths.iter().enumerate() {
49                    if i > 0 {
50                        write!(f, " -> ")?;
51                    }
52                    write!(f, "{}", path.display())?;
53                }
54                Ok(())
55            }
56            LoadError::ParseError(path, err) => {
57                write!(f, "Parse error in {}: {}", path.display(), err)
58            }
59            LoadError::LexError(path, err) => write!(f, "Lex error in {}: {}", path.display(), err),
60            LoadError::IoError(msg) => write!(f, "IO error: {}", msg),
61        }
62    }
63}
64
65impl std::error::Error for LoadError {}
66
67/// A loaded file with its parsed program
68#[derive(Debug, Clone)]
69pub struct LoadedFile {
70    /// Canonical path to the file
71    pub path: PathBuf,
72    /// Parsed program AST
73    pub program: Program,
74}
75
76/// File loader with caching and circular dependency detection
77pub struct FileLoader {
78    /// Cache of already-loaded files (canonical path -> LoadedFile)
79    cache: HashMap<PathBuf, LoadedFile>,
80    /// Currently loading files (for cycle detection)
81    loading: HashSet<PathBuf>,
82    /// Base directory for relative path resolution
83    base_dir: PathBuf,
84}
85
86impl FileLoader {
87    /// Create a new file loader with the given base directory
88    pub fn new(base_dir: PathBuf) -> Self {
89        Self {
90            cache: HashMap::new(),
91            loading: HashSet::new(),
92            base_dir,
93        }
94    }
95
96    /// Load a file and all its dependencies
97    ///
98    /// # Arguments
99    /// * `path` - Path to load (can be relative or absolute)
100    /// * `from_file` - The file containing the #load directive (for relative path resolution)
101    ///
102    /// # Returns
103    /// Reference to the loaded file (from cache)
104    pub fn load(&mut self, path: &str, from_file: &Path) -> Result<&LoadedFile, LoadError> {
105        let resolved = self.resolve_path(path, from_file)?;
106
107        // Check cache first
108        if self.cache.contains_key(&resolved) {
109            return Ok(self.cache.get(&resolved).unwrap());
110        }
111
112        // Check for cycles
113        if self.loading.contains(&resolved) {
114            let mut cycle: Vec<PathBuf> = self.loading.iter().cloned().collect();
115            cycle.push(resolved.clone());
116            return Err(LoadError::CircularDependency(cycle));
117        }
118
119        // Mark as loading
120        self.loading.insert(resolved.clone());
121
122        // Read and parse file
123        let source = std::fs::read_to_string(&resolved)
124            .map_err(|e| LoadError::IoError(format!("{}: {}", resolved.display(), e)))?;
125
126        let mut lexer = Lexer::new(&source);
127        let tokens = lexer
128            .tokenize()
129            .map_err(|e| LoadError::LexError(resolved.clone(), e))?;
130
131        let mut parser = Parser::new(tokens);
132        let program = parser
133            .parse_program()
134            .map_err(|e| LoadError::ParseError(resolved.clone(), e))?;
135
136        // Recursively load dependencies
137        for directive in &program.directives {
138            self.load(&directive.path, &resolved)?;
139        }
140
141        // Create loaded file
142        let loaded = LoadedFile {
143            path: resolved.clone(),
144            program,
145        };
146
147        // Remove from loading, add to cache
148        self.loading.remove(&resolved);
149        self.cache.insert(resolved.clone(), loaded);
150
151        Ok(self.cache.get(&resolved).unwrap())
152    }
153
154    /// Resolve a path relative to a source file
155    fn resolve_path(&self, path: &str, from_file: &Path) -> Result<PathBuf, LoadError> {
156        let resolved = if path.starts_with('/') {
157            // Absolute path
158            PathBuf::from(path)
159        } else {
160            // Relative to from_file's directory
161            from_file
162                .parent()
163                .unwrap_or(&self.base_dir)
164                .join(path)
165        };
166
167        // Canonicalize to get absolute path and resolve symlinks
168        resolved
169            .canonicalize()
170            .map_err(|_| LoadError::FileNotFound(resolved.clone()))
171    }
172
173    /// Get a loaded file from cache (if it exists)
174    pub fn get_cached(&self, path: &Path) -> Option<&LoadedFile> {
175        self.cache.get(path)
176    }
177
178    /// Clear the cache (useful for hot reload scenarios)
179    pub fn clear_cache(&mut self) {
180        self.cache.clear();
181    }
182
183    /// Invalidate a specific file in the cache
184    pub fn invalidate(&mut self, path: &Path) {
185        self.cache.remove(path);
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use std::fs;
193    use tempfile::TempDir;
194
195    #[test]
196    fn test_load_simple_file() {
197        let temp_dir = TempDir::new().unwrap();
198        let test_file = temp_dir.path().join("test.fsx");
199        let from_file = temp_dir.path().join("from.fsx");
200        fs::write(&test_file, "let x = 42").unwrap();
201
202        let mut loader = FileLoader::new(temp_dir.path().to_path_buf());
203        let loaded = loader.load("test.fsx", &from_file).unwrap();
204
205        assert_eq!(loaded.program.items.len(), 1);
206    }
207
208    #[test]
209    fn test_circular_dependency_detection() {
210        let temp_dir = TempDir::new().unwrap();
211        let a_file = temp_dir.path().join("a.fsx");
212        let b_file = temp_dir.path().join("b.fsx");
213
214        fs::write(&a_file, "#load \"b.fsx\"\nlet a = 1").unwrap();
215        fs::write(&b_file, "#load \"a.fsx\"\nlet b = 2").unwrap();
216
217        let mut loader = FileLoader::new(temp_dir.path().to_path_buf());
218        let result = loader.load("a.fsx", &a_file);
219
220        assert!(matches!(result, Err(LoadError::CircularDependency(_))));
221    }
222
223    #[test]
224    fn test_caching() {
225        let temp_dir = TempDir::new().unwrap();
226        let test_file = temp_dir.path().join("cached.fsx");
227        let from_file = temp_dir.path().join("from.fsx");
228        fs::write(&test_file, "let x = 42").unwrap();
229
230        let mut loader = FileLoader::new(temp_dir.path().to_path_buf());
231
232        // Load twice
233        loader.load("cached.fsx", &from_file).unwrap();
234        loader.load("cached.fsx", &from_file).unwrap();
235
236        // Should only be loaded once
237        assert_eq!(loader.cache.len(), 1);
238    }
239
240    #[test]
241    fn test_nested_loads() {
242        let temp_dir = TempDir::new().unwrap();
243        let utils_file = temp_dir.path().join("utils.fsx");
244        let main_file = temp_dir.path().join("main.fsx");
245
246        fs::write(&utils_file, "let helper x = x + 1").unwrap();
247        fs::write(&main_file, "#load \"utils.fsx\"\nlet result = helper 42").unwrap();
248
249        let mut loader = FileLoader::new(temp_dir.path().to_path_buf());
250        let loaded = loader.load("main.fsx", &main_file).unwrap();
251
252        // main.fsx should be loaded, and utils.fsx should be in cache
253        assert_eq!(loaded.program.directives.len(), 1);
254        assert_eq!(loader.cache.len(), 2); // both main and utils
255    }
256
257    #[test]
258    fn test_file_not_found() {
259        let temp_dir = TempDir::new().unwrap();
260        let from_file = temp_dir.path().join("from.fsx");
261        let mut loader = FileLoader::new(temp_dir.path().to_path_buf());
262        let result = loader.load("nonexistent.fsx", &from_file);
263
264        assert!(matches!(result, Err(LoadError::FileNotFound(_))));
265    }
266}