/home/noah/src/ruchy/src/backend/module_loader.rs
Line | Count | Source |
1 | | //! Multi-file module system implementation |
2 | | //! |
3 | | //! Enables `use external_file;` imports for larger Ruchy programs while preserving |
4 | | //! 100% compatibility with existing inline modules. |
5 | | //! |
6 | | //! # Architecture |
7 | | //! |
8 | | //! - **`ModuleLoader`**: Core component handling file discovery, parsing, and caching |
9 | | //! - **`ParsedModule`**: Represents a loaded module with metadata and dependencies |
10 | | //! - **Search Path Resolution**: Multiple directory support with fallback patterns |
11 | | //! - **Circular Dependency Detection**: Prevents infinite loading loops |
12 | | //! - **Caching**: Avoids re-parsing unchanged files for performance |
13 | | //! |
14 | | //! # Usage |
15 | | //! |
16 | | //! ```rust |
17 | | //! use ruchy::backend::module_loader::ModuleLoader; |
18 | | //! |
19 | | //! let mut loader = ModuleLoader::new(); |
20 | | //! loader.add_search_path("./src"); |
21 | | //! loader.add_search_path("./modules"); |
22 | | //! |
23 | | //! let module = loader.load_module("math")?; // Loads math.ruchy |
24 | | //! ``` |
25 | | |
26 | | use std::collections::HashMap; |
27 | | use std::path::{Path, PathBuf}; |
28 | | use std::fs; |
29 | | use std::time::SystemTime; |
30 | | use anyhow::{Result, bail, Context}; |
31 | | use crate::frontend::parser::Parser; |
32 | | use crate::frontend::ast::{Expr, ExprKind}; |
33 | | |
34 | | /// Core module loading and caching system |
35 | | /// |
36 | | /// Handles file discovery, parsing, dependency resolution, and caching |
37 | | /// for multi-file Ruchy programs. |
38 | | pub struct ModuleLoader { |
39 | | /// Cache of parsed modules to avoid re-parsing unchanged files |
40 | | cache: HashMap<String, ParsedModule>, |
41 | | /// Directories to search for module files |
42 | | pub(crate) search_paths: Vec<PathBuf>, |
43 | | /// Stack of currently loading modules for circular dependency detection |
44 | | loading_stack: Vec<String>, |
45 | | /// Total number of files loaded (for metrics) |
46 | | files_loaded: usize, |
47 | | /// Total cache hits (for performance monitoring) |
48 | | cache_hits: usize, |
49 | | } |
50 | | |
51 | | impl ModuleLoader { |
52 | | /// Create a new `ModuleLoader` with default search paths |
53 | | /// |
54 | | /// Default search paths: |
55 | | /// - `.` (current directory) |
56 | | /// - `./src` (source directory) |
57 | | /// - `./modules` (modules directory) |
58 | | #[must_use] |
59 | 0 | pub fn new() -> Self { |
60 | 0 | Self { |
61 | 0 | cache: HashMap::new(), |
62 | 0 | search_paths: vec![ |
63 | 0 | PathBuf::from("."), // Current directory |
64 | 0 | PathBuf::from("./src"), // Source directory |
65 | 0 | PathBuf::from("./modules"), // Modules directory |
66 | 0 | ], |
67 | 0 | loading_stack: Vec::new(), |
68 | 0 | files_loaded: 0, |
69 | 0 | cache_hits: 0, |
70 | 0 | } |
71 | 0 | } |
72 | | |
73 | | /// Add a directory to the module search path |
74 | | /// |
75 | | /// Modules will be searched in the order paths were added. |
76 | | /// |
77 | | /// # Arguments |
78 | | /// |
79 | | /// * `path` - Directory to search for modules |
80 | 0 | pub fn add_search_path<P: AsRef<Path>>(&mut self, path: P) { |
81 | 0 | self.search_paths.push(path.as_ref().to_path_buf()); |
82 | 0 | } |
83 | | |
84 | | /// Load a module from the file system |
85 | | /// |
86 | | /// Supports these patterns: |
87 | | /// - `module_name.ruchy` - Direct file |
88 | | /// - `module_name/mod.ruchy` - Directory module |
89 | | /// - `module_name.rchy` - Short extension |
90 | | /// |
91 | | /// # Arguments |
92 | | /// |
93 | | /// * `module_name` - Name of the module to load |
94 | | /// |
95 | | /// # Returns |
96 | | /// |
97 | | /// Clone of the parsed module with AST and metadata |
98 | | /// |
99 | | /// # Errors |
100 | | /// |
101 | | /// Returns an error if: |
102 | | /// - Module file not found in any search path |
103 | | /// - Circular dependency detected |
104 | | /// - File parsing fails |
105 | | /// - I/O errors reading the file |
106 | 0 | pub fn load_module(&mut self, module_name: &str) -> Result<ParsedModule> { |
107 | | // Check circular dependencies first |
108 | 0 | if self.loading_stack.contains(&module_name.to_string()) { |
109 | 0 | let stack = self.loading_stack.join(" -> "); |
110 | 0 | let cycle_path = format!("{stack} -> {module_name}"); |
111 | 0 | bail!("Circular dependency detected: {}", cycle_path); |
112 | 0 | } |
113 | | |
114 | | // Check cache for already loaded modules |
115 | 0 | if let Some(cached) = self.cache.get(module_name) { |
116 | 0 | if self.is_cache_valid(cached)? { |
117 | 0 | self.cache_hits += 1; |
118 | 0 | return Ok(cached.clone()); |
119 | 0 | } |
120 | 0 | } |
121 | | |
122 | | // Find the module file in search paths |
123 | 0 | let file_path = self.resolve_module_path(module_name) |
124 | 0 | .with_context(|| format!("Failed to find module '{module_name}'"))?; |
125 | | |
126 | | // Read and parse the module file |
127 | 0 | let content = fs::read_to_string(&file_path) |
128 | 0 | .with_context(|| { |
129 | 0 | let path = file_path.display(); |
130 | 0 | format!("Failed to read module file: {path}") |
131 | 0 | })?; |
132 | | |
133 | | // Track loading for circular dependency detection |
134 | 0 | self.loading_stack.push(module_name.to_string()); |
135 | | |
136 | | // Parse the module content |
137 | 0 | let mut parser = Parser::new(&content); |
138 | 0 | let ast = parser.parse() |
139 | 0 | .with_context(|| format!("Failed to parse module '{module_name}'"))?; |
140 | | |
141 | | // Extract dependencies from the parsed AST |
142 | 0 | let dependencies = self.extract_dependencies(&ast)?; |
143 | | |
144 | | // Create parsed module metadata |
145 | 0 | let parsed_module = ParsedModule { |
146 | 0 | ast, |
147 | 0 | file_path: file_path.clone(), |
148 | 0 | dependencies: dependencies.clone(), |
149 | 0 | last_modified: fs::metadata(&file_path)?.modified()?, |
150 | | }; |
151 | | |
152 | | // Load dependencies recursively - check for circular dependencies first |
153 | 0 | for dep in &dependencies { |
154 | 0 | if self.loading_stack.contains(&dep.to_string()) { |
155 | 0 | let stack = self.loading_stack.join(" -> "); |
156 | 0 | let cycle_path = format!("{stack} -> {module_name} -> {dep}"); |
157 | 0 | bail!("Circular dependency detected: {}", cycle_path); |
158 | 0 | } |
159 | 0 | self.load_module(dep) |
160 | 0 | .with_context(|| format!("Failed to load dependency '{dep}' for module '{module_name}'"))?; |
161 | | } |
162 | | |
163 | | // Remove from loading stack and cache the result |
164 | 0 | self.loading_stack.pop(); |
165 | | |
166 | | // Cache invalid entry removal and insertion |
167 | 0 | self.cache.remove(module_name); |
168 | 0 | self.cache.insert(module_name.to_string(), parsed_module.clone()); |
169 | 0 | self.files_loaded += 1; |
170 | | |
171 | 0 | Ok(parsed_module) |
172 | 0 | } |
173 | | |
174 | | /// Resolve module name to file system path |
175 | | /// |
176 | | /// Tries these patterns in each search path: |
177 | | /// 1. `{module_name}.ruchy` |
178 | | /// 2. `{module_name}/mod.ruchy` |
179 | | /// 3. `{module_name}.rchy` |
180 | 0 | fn resolve_module_path(&self, module_name: &str) -> Result<PathBuf> { |
181 | 0 | let possible_names = [ |
182 | 0 | format!("{module_name}.ruchy"), |
183 | 0 | format!("{module_name}/mod.ruchy"), |
184 | 0 | format!("{module_name}.rchy"), |
185 | 0 | ]; |
186 | | |
187 | 0 | for search_path in &self.search_paths { |
188 | 0 | for name in &possible_names { |
189 | 0 | let candidate = search_path.join(name); |
190 | 0 | if candidate.exists() && candidate.is_file() { |
191 | 0 | return Ok(candidate); |
192 | 0 | } |
193 | | } |
194 | | } |
195 | | |
196 | 0 | bail!( |
197 | 0 | "Module '{}' not found. Searched in: {}\nLooked for: {}", |
198 | | module_name, |
199 | 0 | self.search_paths.iter() |
200 | 0 | .map(|p| p.display().to_string()) |
201 | 0 | .collect::<Vec<_>>() |
202 | 0 | .join(", "), |
203 | 0 | possible_names.join(", ") |
204 | | ); |
205 | 0 | } |
206 | | |
207 | | /// Check if a cached module is still valid (file not modified since parsing) |
208 | 0 | fn is_cache_valid(&self, module: &ParsedModule) -> Result<bool> { |
209 | 0 | let current_modified = fs::metadata(&module.file_path)?.modified()?; |
210 | 0 | Ok(current_modified <= module.last_modified) |
211 | 0 | } |
212 | | |
213 | | /// Extract module dependencies from AST |
214 | | /// |
215 | | /// Traverses the AST looking for Import nodes that reference other files |
216 | | /// (not inline modules or standard library imports). |
217 | 0 | fn extract_dependencies(&self, ast: &Expr) -> Result<Vec<String>> { |
218 | 0 | let mut dependencies = Vec::new(); |
219 | 0 | self.collect_dependencies(ast, &mut dependencies); |
220 | 0 | Ok(dependencies) |
221 | 0 | } |
222 | | |
223 | | /// Recursive helper to collect dependencies from AST nodes |
224 | 0 | fn collect_dependencies(&self, expr: &Expr, dependencies: &mut Vec<String>) { |
225 | 0 | match &expr.kind { |
226 | 0 | ExprKind::Import { path, .. } => { |
227 | | // Only treat simple names (no ::) as potential file imports |
228 | 0 | if !path.contains("::") && !path.starts_with("std::") && !path.starts_with("http") { |
229 | 0 | dependencies.push(path.clone()); |
230 | 0 | } |
231 | | } |
232 | 0 | ExprKind::Block(exprs) => { |
233 | 0 | for expr in exprs { |
234 | 0 | self.collect_dependencies(expr, dependencies); |
235 | 0 | } |
236 | | } |
237 | 0 | ExprKind::Module { body, .. } => { |
238 | 0 | self.collect_dependencies(body, dependencies); |
239 | 0 | } |
240 | 0 | ExprKind::Function { body, .. } => { |
241 | 0 | self.collect_dependencies(body, dependencies); |
242 | 0 | } |
243 | | // Add other expression types that can contain imports |
244 | 0 | _ => { |
245 | 0 | // For now, basic dependency extraction |
246 | 0 | // Future: Add comprehensive AST traversal for all expression types if needed |
247 | 0 | } |
248 | | } |
249 | 0 | } |
250 | | |
251 | | /// Get module loading statistics for performance monitoring |
252 | | #[must_use] |
253 | 0 | pub fn stats(&self) -> ModuleLoaderStats { |
254 | 0 | ModuleLoaderStats { |
255 | 0 | cached_modules: self.cache.len(), |
256 | 0 | files_loaded: self.files_loaded, |
257 | 0 | cache_hits: self.cache_hits, |
258 | 0 | search_paths: self.search_paths.len(), |
259 | 0 | } |
260 | 0 | } |
261 | | |
262 | | /// Clear the module cache |
263 | | /// |
264 | | /// Forces all modules to be reloaded from disk on next access. |
265 | | /// Useful for development when module files are frequently changing. |
266 | 0 | pub fn clear_cache(&mut self) { |
267 | 0 | self.cache.clear(); |
268 | 0 | self.files_loaded = 0; |
269 | 0 | self.cache_hits = 0; |
270 | 0 | } |
271 | | |
272 | | /// Check if a module is currently being loaded (for debugging) |
273 | | #[must_use] |
274 | 0 | pub fn is_loading(&self, module_name: &str) -> bool { |
275 | 0 | self.loading_stack.contains(&module_name.to_string()) |
276 | 0 | } |
277 | | } |
278 | | |
279 | | impl Default for ModuleLoader { |
280 | 0 | fn default() -> Self { |
281 | 0 | Self::new() |
282 | 0 | } |
283 | | } |
284 | | |
285 | | /// Represents a parsed module with metadata and dependencies |
286 | | #[derive(Debug, Clone)] |
287 | | pub struct ParsedModule { |
288 | | /// Parsed AST of the module |
289 | | pub ast: Expr, |
290 | | /// File system path where the module was loaded from |
291 | | pub file_path: PathBuf, |
292 | | /// List of other modules this module depends on |
293 | | pub dependencies: Vec<String>, |
294 | | /// Last modification time of the source file |
295 | | pub last_modified: SystemTime, |
296 | | } |
297 | | |
298 | | impl ParsedModule { |
299 | | /// Get the module name from the file path |
300 | | #[must_use] |
301 | 0 | pub fn name(&self) -> Option<String> { |
302 | 0 | self.file_path |
303 | 0 | .file_stem() |
304 | 0 | .and_then(|stem| stem.to_str()) |
305 | 0 | .map(std::string::ToString::to_string) |
306 | 0 | } |
307 | | |
308 | | /// Check if this module has any dependencies |
309 | | #[must_use] |
310 | 0 | pub fn has_dependencies(&self) -> bool { |
311 | 0 | !self.dependencies.is_empty() |
312 | 0 | } |
313 | | } |
314 | | |
315 | | /// Statistics about module loader performance |
316 | | #[derive(Debug, Clone, Copy)] |
317 | | pub struct ModuleLoaderStats { |
318 | | /// Number of modules currently cached in memory |
319 | | pub cached_modules: usize, |
320 | | /// Total number of files loaded from disk |
321 | | pub files_loaded: usize, |
322 | | /// Number of cache hits (avoided file I/O) |
323 | | pub cache_hits: usize, |
324 | | /// Number of search paths configured |
325 | | pub search_paths: usize, |
326 | | } |
327 | | |
328 | | impl ModuleLoaderStats { |
329 | | /// Calculate cache hit ratio as a percentage |
330 | | #[must_use] |
331 | 0 | pub fn cache_hit_ratio(&self) -> f64 { |
332 | 0 | if self.files_loaded + self.cache_hits == 0 { |
333 | 0 | 0.0 |
334 | | } else { |
335 | 0 | f64::from(self.cache_hits as u32) / f64::from((self.files_loaded + self.cache_hits) as u32) * 100.0 |
336 | | } |
337 | 0 | } |
338 | | } |
339 | | |
340 | | #[cfg(test)] |
341 | | mod tests { |
342 | | use super::*; |
343 | | use tempfile::TempDir; |
344 | | use std::fs; |
345 | | |
346 | | fn create_test_module(temp_dir: &TempDir, name: &str, content: &str) -> Result<()> { |
347 | | let file_path = temp_dir.path().join(format!("{name}.ruchy")); |
348 | | fs::write(file_path, content)?; |
349 | | Ok(()) |
350 | | } |
351 | | |
352 | | #[test] |
353 | | fn test_module_loader_creation() { |
354 | | let loader = ModuleLoader::new(); |
355 | | assert_eq!(loader.cache.len(), 0); |
356 | | assert_eq!(loader.search_paths.len(), 3); |
357 | | assert!(loader.search_paths.contains(&PathBuf::from("."))); |
358 | | assert!(loader.search_paths.contains(&PathBuf::from("./src"))); |
359 | | assert!(loader.search_paths.contains(&PathBuf::from("./modules"))); |
360 | | } |
361 | | |
362 | | #[test] |
363 | | fn test_add_search_path() { |
364 | | let mut loader = ModuleLoader::new(); |
365 | | loader.add_search_path("/custom/path"); |
366 | | |
367 | | assert_eq!(loader.search_paths.len(), 4); |
368 | | assert!(loader.search_paths.contains(&PathBuf::from("/custom/path"))); |
369 | | } |
370 | | |
371 | | #[test] |
372 | | fn test_resolve_module_path_success() -> Result<()> { |
373 | | let temp_dir = TempDir::new()?; |
374 | | let mut loader = ModuleLoader::new(); |
375 | | loader.add_search_path(temp_dir.path()); |
376 | | |
377 | | // Create a test module file |
378 | | create_test_module(&temp_dir, "math", "42")?; |
379 | | |
380 | | let resolved = loader.resolve_module_path("math")?; |
381 | | assert_eq!(resolved, temp_dir.path().join("math.ruchy")); |
382 | | assert!(resolved.exists()); |
383 | | |
384 | | Ok(()) |
385 | | } |
386 | | |
387 | | #[test] |
388 | | fn test_resolve_module_path_not_found() { |
389 | | let loader = ModuleLoader::new(); |
390 | | let result = loader.resolve_module_path("nonexistent"); |
391 | | |
392 | | assert!(result.is_err()); |
393 | | let error_msg = result.unwrap_err().to_string(); |
394 | | assert!(error_msg.contains("Module 'nonexistent' not found")); |
395 | | } |
396 | | |
397 | | #[test] |
398 | | fn test_circular_dependency_detection() -> Result<()> { |
399 | | let temp_dir = TempDir::new()?; |
400 | | let mut loader = ModuleLoader::new(); |
401 | | loader.add_search_path(temp_dir.path()); |
402 | | |
403 | | // Create circular dependencies: a imports b, b imports a |
404 | | create_test_module(&temp_dir, "a", "use b;")?; |
405 | | create_test_module(&temp_dir, "b", "use a;")?; |
406 | | |
407 | | let result = loader.load_module("a"); |
408 | | assert!(result.is_err()); |
409 | | |
410 | | let error = result.unwrap_err(); |
411 | | let error_msg = format!("{error:?}"); // Use Debug formatting to get full error chain |
412 | | |
413 | | // Check if the full error chain contains circular dependency |
414 | | let found_circular_dep = error_msg.contains("Circular dependency detected") |
415 | | || error_msg.contains("circular dependency"); |
416 | | |
417 | | assert!(found_circular_dep, "Expected circular dependency error, got: {error_msg}"); |
418 | | |
419 | | Ok(()) |
420 | | } |
421 | | |
422 | | #[test] |
423 | | fn test_stats_tracking() -> Result<()> { |
424 | | let temp_dir = TempDir::new()?; |
425 | | let mut loader = ModuleLoader::new(); |
426 | | loader.search_paths.clear(); // Remove default paths |
427 | | loader.add_search_path(temp_dir.path()); |
428 | | |
429 | | create_test_module(&temp_dir, "test", "42")?; |
430 | | |
431 | | let initial_stats = loader.stats(); |
432 | | assert_eq!(initial_stats.files_loaded, 0); |
433 | | assert_eq!(initial_stats.cache_hits, 0); |
434 | | assert_eq!(initial_stats.cached_modules, 0); |
435 | | |
436 | | // Load module first time |
437 | | loader.load_module("test")?; |
438 | | let after_load = loader.stats(); |
439 | | assert_eq!(after_load.files_loaded, 1); |
440 | | assert_eq!(after_load.cached_modules, 1); |
441 | | |
442 | | // Load same module again (should hit cache) |
443 | | loader.load_module("test")?; |
444 | | let after_cache = loader.stats(); |
445 | | assert_eq!(after_cache.files_loaded, 1); // Same as before |
446 | | assert_eq!(after_cache.cache_hits, 1); // Incremented |
447 | | |
448 | | Ok(()) |
449 | | } |
450 | | |
451 | | #[test] |
452 | | fn test_cache_hit_ratio_calculation() { |
453 | | let stats = ModuleLoaderStats { |
454 | | cached_modules: 5, |
455 | | files_loaded: 10, |
456 | | cache_hits: 20, |
457 | | search_paths: 3, |
458 | | }; |
459 | | |
460 | | let ratio = stats.cache_hit_ratio(); |
461 | | assert!((ratio - 66.67).abs() < 0.01); // 20/(10+20) * 100 = 66.67% |
462 | | } |
463 | | |
464 | | #[test] |
465 | | fn test_clear_cache() -> Result<()> { |
466 | | let temp_dir = TempDir::new()?; |
467 | | let mut loader = ModuleLoader::new(); |
468 | | loader.search_paths.clear(); // Remove default paths |
469 | | loader.add_search_path(temp_dir.path()); |
470 | | |
471 | | create_test_module(&temp_dir, "test", "42")?; |
472 | | |
473 | | // Load module to populate cache |
474 | | loader.load_module("test")?; |
475 | | assert_eq!(loader.cache.len(), 1); |
476 | | |
477 | | // Clear cache |
478 | | loader.clear_cache(); |
479 | | assert_eq!(loader.cache.len(), 0); |
480 | | assert_eq!(loader.files_loaded, 0); |
481 | | assert_eq!(loader.cache_hits, 0); |
482 | | |
483 | | Ok(()) |
484 | | } |
485 | | |
486 | | #[test] |
487 | | fn test_parsed_module_name() -> Result<()> { |
488 | | let temp_dir = TempDir::new()?; |
489 | | let path = temp_dir.path().join("math.ruchy"); |
490 | | |
491 | | let module = ParsedModule { |
492 | | ast: Expr::new(crate::frontend::ast::ExprKind::Literal(crate::frontend::ast::Literal::Unit), |
493 | | crate::frontend::ast::Span { start: 0, end: 0 }), |
494 | | file_path: path, |
495 | | dependencies: Vec::new(), |
496 | | last_modified: SystemTime::now(), |
497 | | }; |
498 | | |
499 | | assert_eq!(module.name(), Some("math".to_string())); |
500 | | assert!(!module.has_dependencies()); |
501 | | |
502 | | Ok(()) |
503 | | } |
504 | | } |