/home/noah/src/ruchy/src/backend/module_resolver.rs
Line | Count | Source |
1 | | //! Module resolver for multi-file imports |
2 | | //! |
3 | | //! This module provides functionality to resolve file imports in Ruchy programs |
4 | | //! by pre-processing the AST to inline external modules before transpilation. |
5 | | //! |
6 | | //! # Architecture |
7 | | //! |
8 | | //! The module resolver works as a pre-processing step before transpilation: |
9 | | //! 1. Parse the main file into an AST |
10 | | //! 2. Scan for file imports (`use module_name;` where `module_name` has no `::`) |
11 | | //! 3. Load and parse external module files |
12 | | //! 4. Replace Import nodes with inline Module nodes |
13 | | //! 5. Pass the resolved AST to the transpiler |
14 | | //! |
15 | | //! # Usage |
16 | | //! |
17 | | //! ```rust |
18 | | //! use ruchy::{ModuleResolver, Parser, Transpiler}; |
19 | | //! |
20 | | //! let mut resolver = ModuleResolver::new(); |
21 | | //! resolver.add_search_path("./src"); |
22 | | //! |
23 | | //! let mut parser = Parser::new("use math; math::add(1, 2)"); |
24 | | //! let ast = parser.parse()?; |
25 | | //! |
26 | | //! let resolved_ast = resolver.resolve_imports(ast)?; |
27 | | //! |
28 | | //! let transpiler = Transpiler::new(); |
29 | | //! let rust_code = transpiler.transpile(&resolved_ast)?; |
30 | | //! ``` |
31 | | |
32 | | use crate::frontend::ast::{Expr, ExprKind, ImportItem, Span}; |
33 | | use crate::backend::module_loader::ModuleLoader; |
34 | | use anyhow::{Result, Context}; |
35 | | |
36 | | /// Module resolver for processing file imports |
37 | | /// |
38 | | /// Resolves file imports by loading external modules and inlining them |
39 | | /// as Module declarations in the AST before transpilation. |
40 | | pub struct ModuleResolver { |
41 | | /// Module loader for file system operations |
42 | | pub(crate) module_loader: ModuleLoader, |
43 | | } |
44 | | |
45 | | impl ModuleResolver { |
46 | | /// Create a new module resolver with default search paths |
47 | | /// |
48 | | /// Default search paths: |
49 | | /// - `.` (current directory) |
50 | | /// - `./src` (source directory) |
51 | | /// - `./modules` (modules directory) |
52 | | #[must_use] |
53 | 0 | pub fn new() -> Self { |
54 | 0 | Self { |
55 | 0 | module_loader: ModuleLoader::new(), |
56 | 0 | } |
57 | 0 | } |
58 | | |
59 | | /// Add a directory to the module search path |
60 | | /// |
61 | | /// # Arguments |
62 | | /// |
63 | | /// * `path` - Directory to search for modules |
64 | 0 | pub fn add_search_path<P: AsRef<std::path::Path>>(&mut self, path: P) { |
65 | 0 | self.module_loader.add_search_path(path); |
66 | 0 | } |
67 | | |
68 | | /// Resolve all file imports in an AST |
69 | | /// |
70 | | /// Recursively processes the AST to find file imports, loads the corresponding |
71 | | /// modules, and replaces Import nodes with inline Module nodes. |
72 | | /// |
73 | | /// # Arguments |
74 | | /// |
75 | | /// * `ast` - The AST to process |
76 | | /// |
77 | | /// # Returns |
78 | | /// |
79 | | /// A new AST with all file imports resolved to inline modules |
80 | | /// |
81 | | /// # Errors |
82 | | /// |
83 | | /// Returns an error if: |
84 | | /// - Module files cannot be found or loaded |
85 | | /// - Module files contain invalid syntax |
86 | | /// - Circular dependencies are detected |
87 | 0 | pub fn resolve_imports(&mut self, ast: Expr) -> Result<Expr> { |
88 | 0 | self.resolve_expr(ast) |
89 | 0 | } |
90 | | |
91 | | /// Recursively resolve imports in an expression |
92 | 0 | fn resolve_expr(&mut self, expr: Expr) -> Result<Expr> { |
93 | 0 | match expr.kind { |
94 | 0 | ExprKind::Import { ref path, ref items } => { |
95 | | // Check if this is a file import (no :: and not std:: or http) |
96 | 0 | if self.is_file_import(path) { |
97 | | // Load the module file |
98 | 0 | let parsed_module = self.module_loader.load_module(path) |
99 | 0 | .with_context(|| format!("Failed to resolve import '{path}'"))?; |
100 | | |
101 | | // Recursively resolve imports in the loaded module |
102 | 0 | let resolved_module_ast = self.resolve_expr(parsed_module.ast)?; |
103 | | |
104 | | // Create an inline module declaration |
105 | 0 | let module_expr = Expr::new( |
106 | 0 | ExprKind::Module { |
107 | 0 | name: path.clone(), |
108 | 0 | body: Box::new(resolved_module_ast), |
109 | 0 | }, |
110 | 0 | expr.span, |
111 | | ); |
112 | | |
113 | | // Create a use statement to import from the module |
114 | 0 | let use_statement = if items.iter().any(|item| matches!(item, ImportItem::Wildcard)) || items.is_empty() { |
115 | | // Wildcard import: use module::*; |
116 | 0 | Expr::new( |
117 | 0 | ExprKind::Import { |
118 | 0 | path: path.clone(), |
119 | 0 | items: vec![ImportItem::Wildcard], |
120 | 0 | }, |
121 | 0 | Span { start: 0, end: 0 }, |
122 | | ) |
123 | | } else { |
124 | | // Specific imports: use module::{item1, item2}; |
125 | 0 | self.create_use_statements(path, items) |
126 | | }; |
127 | | |
128 | | // Return a block with the module declaration and use statement |
129 | 0 | Ok(Expr::new( |
130 | 0 | ExprKind::Block(vec![module_expr, use_statement]), |
131 | 0 | expr.span, |
132 | 0 | )) |
133 | | } else { |
134 | | // Not a file import, keep as-is |
135 | 0 | Ok(expr) |
136 | | } |
137 | | } |
138 | 0 | ExprKind::Block(exprs) => { |
139 | | // Resolve imports in all block expressions |
140 | 0 | let resolved_exprs: Result<Vec<_>> = exprs |
141 | 0 | .into_iter() |
142 | 0 | .map(|e| self.resolve_expr(e)) |
143 | 0 | .collect(); |
144 | 0 | Ok(Expr::new(ExprKind::Block(resolved_exprs?), expr.span)) |
145 | | } |
146 | 0 | ExprKind::Module { name, body } => { |
147 | | // Resolve imports in module body |
148 | 0 | let resolved_body = self.resolve_expr(*body)?; |
149 | 0 | Ok(Expr::new( |
150 | 0 | ExprKind::Module { |
151 | 0 | name, |
152 | 0 | body: Box::new(resolved_body), |
153 | 0 | }, |
154 | 0 | expr.span, |
155 | 0 | )) |
156 | | } |
157 | | ExprKind::Function { |
158 | 0 | name, |
159 | 0 | type_params, |
160 | 0 | params, |
161 | 0 | body, |
162 | 0 | is_async, |
163 | 0 | return_type, |
164 | 0 | is_pub, |
165 | | } => { |
166 | | // Resolve imports in function body |
167 | 0 | let resolved_body = self.resolve_expr(*body)?; |
168 | 0 | Ok(Expr::new( |
169 | 0 | ExprKind::Function { |
170 | 0 | name, |
171 | 0 | type_params, |
172 | 0 | params, |
173 | 0 | body: Box::new(resolved_body), |
174 | 0 | is_async, |
175 | 0 | return_type, |
176 | 0 | is_pub, |
177 | 0 | }, |
178 | 0 | expr.span, |
179 | 0 | )) |
180 | | } |
181 | 0 | ExprKind::If { condition, then_branch, else_branch } => { |
182 | 0 | let resolved_condition = self.resolve_expr(*condition)?; |
183 | 0 | let resolved_then = self.resolve_expr(*then_branch)?; |
184 | 0 | let resolved_else = else_branch.map(|e| self.resolve_expr(*e)).transpose()?; |
185 | 0 | Ok(Expr::new( |
186 | 0 | ExprKind::If { |
187 | 0 | condition: Box::new(resolved_condition), |
188 | 0 | then_branch: Box::new(resolved_then), |
189 | 0 | else_branch: resolved_else.map(Box::new), |
190 | 0 | }, |
191 | 0 | expr.span, |
192 | 0 | )) |
193 | | } |
194 | | // For other expression types, recursively process children as needed |
195 | | // For now, just return the expression as-is |
196 | 0 | _ => Ok(expr), |
197 | | } |
198 | 0 | } |
199 | | |
200 | | /// Check if an import path represents a file import |
201 | 0 | fn is_file_import(&self, path: &str) -> bool { |
202 | 0 | !path.contains("::") |
203 | 0 | && !path.starts_with("std::") |
204 | 0 | && !path.starts_with("http") |
205 | 0 | && !path.is_empty() |
206 | 0 | } |
207 | | |
208 | | /// Create use statements for specific imports |
209 | 0 | fn create_use_statements(&self, module_path: &str, items: &[ImportItem]) -> Expr { |
210 | | // Create a use statement that imports specific items from the module |
211 | | // This will be transpiled to proper Rust use statements |
212 | 0 | Expr::new( |
213 | 0 | ExprKind::Import { |
214 | 0 | path: module_path.to_string(), // Use the module path as-is |
215 | 0 | items: items.to_vec(), |
216 | 0 | }, |
217 | 0 | Span { start: 0, end: 0 }, |
218 | | ) |
219 | 0 | } |
220 | | |
221 | | /// Get module loading statistics |
222 | | #[must_use] |
223 | 0 | pub fn stats(&self) -> crate::backend::module_loader::ModuleLoaderStats { |
224 | 0 | self.module_loader.stats() |
225 | 0 | } |
226 | | |
227 | | /// Clear the module cache |
228 | | /// |
229 | | /// Forces all modules to be reloaded from disk on next access. |
230 | 0 | pub fn clear_cache(&mut self) { |
231 | 0 | self.module_loader.clear_cache(); |
232 | 0 | } |
233 | | } |
234 | | |
235 | | impl Default for ModuleResolver { |
236 | 0 | fn default() -> Self { |
237 | 0 | Self::new() |
238 | 0 | } |
239 | | } |
240 | | |
241 | | #[cfg(test)] |
242 | | mod tests { |
243 | | use super::*; |
244 | | use tempfile::TempDir; |
245 | | use std::fs; |
246 | | use crate::frontend::ast::Literal; |
247 | | |
248 | | fn create_test_module(temp_dir: &TempDir, name: &str, content: &str) -> Result<()> { |
249 | | let file_path = temp_dir.path().join(format!("{name}.ruchy")); |
250 | | fs::write(file_path, content)?; |
251 | | Ok(()) |
252 | | } |
253 | | |
254 | | #[test] |
255 | | fn test_module_resolver_creation() { |
256 | | let resolver = ModuleResolver::new(); |
257 | | let stats = resolver.stats(); |
258 | | assert_eq!(stats.cached_modules, 0); |
259 | | } |
260 | | |
261 | | #[test] |
262 | | fn test_add_search_path() { |
263 | | let mut resolver = ModuleResolver::new(); |
264 | | resolver.add_search_path("/custom/path"); |
265 | | // Module loader doesn't expose search paths, so we can't directly test this |
266 | | // But we can test that it doesn't panic |
267 | | } |
268 | | |
269 | | #[test] |
270 | | fn test_is_file_import() { |
271 | | let resolver = ModuleResolver::new(); |
272 | | |
273 | | // Should be file imports |
274 | | assert!(resolver.is_file_import("math")); |
275 | | assert!(resolver.is_file_import("utils")); |
276 | | assert!(resolver.is_file_import("snake_case_module")); |
277 | | |
278 | | // Should NOT be file imports |
279 | | assert!(!resolver.is_file_import("std::collections")); |
280 | | assert!(!resolver.is_file_import("std::io::Read")); |
281 | | assert!(!resolver.is_file_import("https://example.com/module.ruchy")); |
282 | | assert!(!resolver.is_file_import("http://localhost/module.ruchy")); |
283 | | assert!(!resolver.is_file_import("")); |
284 | | } |
285 | | |
286 | | #[test] |
287 | | fn test_resolve_simple_file_import() -> Result<()> { |
288 | | let temp_dir = TempDir::new()?; |
289 | | let mut resolver = ModuleResolver::new(); |
290 | | resolver.add_search_path(temp_dir.path()); |
291 | | |
292 | | // Create a simple math module |
293 | | create_test_module(&temp_dir, "math", r" |
294 | | pub fun add(a: i32, b: i32) -> i32 { |
295 | | a + b |
296 | | } |
297 | | ")?; |
298 | | |
299 | | // Create an import expression |
300 | | let import_expr = Expr::new( |
301 | | ExprKind::Import { |
302 | | path: "math".to_string(), |
303 | | items: vec![ImportItem::Wildcard], |
304 | | }, |
305 | | Span { start: 0, end: 0 }, |
306 | | ); |
307 | | |
308 | | // Resolve the import |
309 | | let resolved_expr = resolver.resolve_imports(import_expr)?; |
310 | | |
311 | | // Should be converted to a Block with Module declaration and use statement |
312 | | match resolved_expr.kind { |
313 | | ExprKind::Block(exprs) => { |
314 | | assert_eq!(exprs.len(), 2); |
315 | | // First should be Module declaration |
316 | | match &exprs[0].kind { |
317 | | ExprKind::Module { name, .. } => { |
318 | | assert_eq!(name, "math"); |
319 | | } |
320 | | _ => unreachable!("Expected first element to be Module, got {:?}", exprs[0].kind), |
321 | | } |
322 | | // Second should be use statement |
323 | | match &exprs[1].kind { |
324 | | ExprKind::Import { path, items } => { |
325 | | assert_eq!(path, "math"); |
326 | | assert_eq!(items.len(), 1); |
327 | | assert!(matches!(items[0], ImportItem::Wildcard)); |
328 | | } |
329 | | _ => unreachable!("Expected second element to be Import, got {:?}", exprs[1].kind), |
330 | | } |
331 | | } |
332 | | _ => unreachable!("Expected Block expression, got {:?}", resolved_expr.kind), |
333 | | } |
334 | | |
335 | | Ok(()) |
336 | | } |
337 | | |
338 | | #[test] |
339 | | fn test_resolve_non_file_import() -> Result<()> { |
340 | | let mut resolver = ModuleResolver::new(); |
341 | | |
342 | | // Create a standard library import |
343 | | let import_expr = Expr::new( |
344 | | ExprKind::Import { |
345 | | path: "std::collections".to_string(), |
346 | | items: vec![ImportItem::Named("HashMap".to_string())], |
347 | | }, |
348 | | Span { start: 0, end: 0 }, |
349 | | ); |
350 | | |
351 | | // Resolve the import - should remain unchanged |
352 | | let resolved_expr = resolver.resolve_imports(import_expr)?; |
353 | | |
354 | | match resolved_expr.kind { |
355 | | ExprKind::Import { path, items } => { |
356 | | assert_eq!(path, "std::collections"); |
357 | | assert_eq!(items.len(), 1); |
358 | | } |
359 | | _ => unreachable!("Expected Import expression to remain unchanged"), |
360 | | } |
361 | | |
362 | | Ok(()) |
363 | | } |
364 | | |
365 | | #[test] |
366 | | fn test_resolve_block_with_imports() -> Result<()> { |
367 | | let temp_dir = TempDir::new()?; |
368 | | let mut resolver = ModuleResolver::new(); |
369 | | resolver.add_search_path(temp_dir.path()); |
370 | | |
371 | | create_test_module(&temp_dir, "math", "42")?; |
372 | | |
373 | | // Create a block with mixed imports |
374 | | let block_expr = Expr::new( |
375 | | ExprKind::Block(vec![ |
376 | | Expr::new( |
377 | | ExprKind::Import { |
378 | | path: "math".to_string(), |
379 | | items: vec![ImportItem::Wildcard], |
380 | | }, |
381 | | Span { start: 0, end: 0 }, |
382 | | ), |
383 | | Expr::new( |
384 | | ExprKind::Import { |
385 | | path: "std::io".to_string(), |
386 | | items: vec![ImportItem::Named("Read".to_string())], |
387 | | }, |
388 | | Span { start: 0, end: 0 }, |
389 | | ), |
390 | | Expr::new( |
391 | | ExprKind::Literal(Literal::Integer(42)), |
392 | | Span { start: 0, end: 0 }, |
393 | | ), |
394 | | ]), |
395 | | Span { start: 0, end: 0 }, |
396 | | ); |
397 | | |
398 | | let resolved_block = resolver.resolve_imports(block_expr)?; |
399 | | |
400 | | if let ExprKind::Block(exprs) = resolved_block.kind { |
401 | | assert_eq!(exprs.len(), 3); |
402 | | |
403 | | // First should be Block containing Module and use statement (from file import) |
404 | | match &exprs[0].kind { |
405 | | ExprKind::Block(inner_exprs) => { |
406 | | assert_eq!(inner_exprs.len(), 2); |
407 | | assert!(matches!(inner_exprs[0].kind, ExprKind::Module { .. })); |
408 | | assert!(matches!(inner_exprs[1].kind, ExprKind::Import { .. })); |
409 | | } |
410 | | _ => unreachable!("Expected first element to be Block, got {:?}", exprs[0].kind), |
411 | | } |
412 | | |
413 | | // Second should remain as Import (std::io - not a file import) |
414 | | assert!(matches!(exprs[1].kind, ExprKind::Import { .. })); |
415 | | |
416 | | // Third should remain as Literal |
417 | | assert!(matches!(exprs[2].kind, ExprKind::Literal(Literal::Integer(42)))); |
418 | | } else { |
419 | | unreachable!("Expected Block expression"); |
420 | | } |
421 | | |
422 | | Ok(()) |
423 | | } |
424 | | |
425 | | #[test] |
426 | | fn test_stats_and_cache() -> Result<()> { |
427 | | let temp_dir = TempDir::new()?; |
428 | | let mut resolver = ModuleResolver::new(); |
429 | | resolver.module_loader.search_paths.clear(); // Remove default paths |
430 | | resolver.add_search_path(temp_dir.path()); |
431 | | |
432 | | create_test_module(&temp_dir, "test", "42")?; |
433 | | |
434 | | let initial_stats = resolver.stats(); |
435 | | assert_eq!(initial_stats.files_loaded, 0); |
436 | | |
437 | | // Load a module |
438 | | let import_expr = Expr::new( |
439 | | ExprKind::Import { |
440 | | path: "test".to_string(), |
441 | | items: vec![ImportItem::Wildcard], |
442 | | }, |
443 | | Span { start: 0, end: 0 }, |
444 | | ); |
445 | | |
446 | | resolver.resolve_imports(import_expr)?; |
447 | | |
448 | | let after_stats = resolver.stats(); |
449 | | assert_eq!(after_stats.files_loaded, 1); |
450 | | assert_eq!(after_stats.cached_modules, 1); |
451 | | |
452 | | // Clear cache |
453 | | resolver.clear_cache(); |
454 | | let cleared_stats = resolver.stats(); |
455 | | assert_eq!(cleared_stats.files_loaded, 0); |
456 | | assert_eq!(cleared_stats.cached_modules, 0); |
457 | | |
458 | | Ok(()) |
459 | | } |
460 | | } |