/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 | | 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 | 104 | pub fn new() -> Self { |
54 | 104 | Self { |
55 | 104 | module_loader: ModuleLoader::new(), |
56 | 104 | } |
57 | 104 | } |
58 | | |
59 | | /// Add a directory to the module search path |
60 | | /// |
61 | | /// # Arguments |
62 | | /// |
63 | | /// * `path` - Directory to search for modules |
64 | 4 | pub fn add_search_path<P: AsRef<std::path::Path>>(&mut self, path: P) { |
65 | 4 | self.module_loader.add_search_path(path); |
66 | 4 | } |
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 | 101 | pub fn resolve_imports(&mut self, ast: Expr) -> Result<Expr> { |
88 | 101 | self.resolve_expr(ast) |
89 | 101 | } |
90 | | |
91 | | /// Recursively resolve imports in an expression |
92 | 156 | fn resolve_expr(&mut self, expr: Expr) -> Result<Expr> { |
93 | 156 | match expr.kind { |
94 | 5 | ExprKind::Import { ref path, ref items } => { |
95 | | // Check if this is a file import (no :: and not std:: or http) |
96 | 5 | if self.is_file_import(path) { |
97 | | // Load the module file |
98 | 3 | let parsed_module = self.module_loader.load_module(path) |
99 | 3 | .with_context(|| format!("Failed to resolve import '{path}'"0 ))?0 ; |
100 | | |
101 | | // Recursively resolve imports in the loaded module |
102 | 3 | let resolved_module_ast = self.resolve_expr(parsed_module.ast)?0 ; |
103 | | |
104 | | // Create an inline module declaration |
105 | 3 | let module_expr = Expr::new( |
106 | 3 | ExprKind::Module { |
107 | 3 | name: path.clone(), |
108 | 3 | body: Box::new(resolved_module_ast), |
109 | 3 | }, |
110 | 3 | expr.span, |
111 | | ); |
112 | | |
113 | | // Create a use statement to import from the module |
114 | 3 | let use_statement = if items.iter().any(|item| matches!(item, ImportItem::Wildcard)) || items0 .is_empty0 () { |
115 | | // Wildcard import: use module::*; |
116 | 3 | Expr::new( |
117 | 3 | ExprKind::Import { |
118 | 3 | path: path.clone(), |
119 | 3 | items: vec![ImportItem::Wildcard], |
120 | 3 | }, |
121 | 3 | 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 | 3 | Ok(Expr::new( |
130 | 3 | ExprKind::Block(vec![module_expr, use_statement]), |
131 | 3 | expr.span, |
132 | 3 | )) |
133 | | } else { |
134 | | // Not a file import, keep as-is |
135 | 2 | Ok(expr) |
136 | | } |
137 | | } |
138 | 22 | ExprKind::Block(exprs) => { |
139 | | // Resolve imports in all block expressions |
140 | 22 | let resolved_exprs: Result<Vec<_>> = exprs |
141 | 22 | .into_iter() |
142 | 26 | .map22 (|e| self.resolve_expr(e)) |
143 | 22 | .collect(); |
144 | 22 | Ok(Expr::new(ExprKind::Block(resolved_exprs?0 ), 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 | 11 | name, |
159 | 11 | type_params, |
160 | 11 | params, |
161 | 11 | body, |
162 | 11 | is_async, |
163 | 11 | return_type, |
164 | 11 | is_pub, |
165 | | } => { |
166 | | // Resolve imports in function body |
167 | 11 | let resolved_body = self.resolve_expr(*body)?0 ; |
168 | 11 | Ok(Expr::new( |
169 | 11 | ExprKind::Function { |
170 | 11 | name, |
171 | 11 | type_params, |
172 | 11 | params, |
173 | 11 | body: Box::new(resolved_body), |
174 | 11 | is_async, |
175 | 11 | return_type, |
176 | 11 | is_pub, |
177 | 11 | }, |
178 | 11 | expr.span, |
179 | 11 | )) |
180 | | } |
181 | 5 | ExprKind::If { condition, then_branch, else_branch } => { |
182 | 5 | let resolved_condition = self.resolve_expr(*condition)?0 ; |
183 | 5 | let resolved_then = self.resolve_expr(*then_branch)?0 ; |
184 | 5 | let resolved_else = else_branch.map(|e| self.resolve_expr(*e)).transpose()?0 ; |
185 | 5 | Ok(Expr::new( |
186 | 5 | ExprKind::If { |
187 | 5 | condition: Box::new(resolved_condition), |
188 | 5 | then_branch: Box::new(resolved_then), |
189 | 5 | else_branch: resolved_else.map(Box::new), |
190 | 5 | }, |
191 | 5 | expr.span, |
192 | 5 | )) |
193 | | } |
194 | | // For other expression types, recursively process children as needed |
195 | | // For now, just return the expression as-is |
196 | 113 | _ => Ok(expr), |
197 | | } |
198 | 156 | } |
199 | | |
200 | | /// Check if an import path represents a file import |
201 | 13 | fn is_file_import(&self, path: &str) -> bool { |
202 | 13 | !path.contains("::") |
203 | 9 | && !path.starts_with("std::") |
204 | 9 | && !path.starts_with("http") |
205 | 7 | && !path.is_empty() |
206 | 13 | } |
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 | 4 | pub fn stats(&self) -> crate::backend::module_loader::ModuleLoaderStats { |
224 | 4 | self.module_loader.stats() |
225 | 4 | } |
226 | | |
227 | | /// Clear the module cache |
228 | | /// |
229 | | /// Forces all modules to be reloaded from disk on next access. |
230 | 1 | pub fn clear_cache(&mut self) { |
231 | 1 | self.module_loader.clear_cache(); |
232 | 1 | } |
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 | 3 | fn create_test_module(temp_dir: &TempDir, name: &str, content: &str) -> Result<()> { |
249 | 3 | let file_path = temp_dir.path().join(format!("{name}.ruchy")); |
250 | 3 | fs::write(file_path, content)?0 ; |
251 | 3 | Ok(()) |
252 | 3 | } |
253 | | |
254 | | #[test] |
255 | 1 | fn test_module_resolver_creation() { |
256 | 1 | let resolver = ModuleResolver::new(); |
257 | 1 | let stats = resolver.stats(); |
258 | 1 | assert_eq!(stats.cached_modules, 0); |
259 | 1 | } |
260 | | |
261 | | #[test] |
262 | 1 | fn test_add_search_path() { |
263 | 1 | let mut resolver = ModuleResolver::new(); |
264 | 1 | 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 | 1 | } |
268 | | |
269 | | #[test] |
270 | 1 | fn test_is_file_import() { |
271 | 1 | let resolver = ModuleResolver::new(); |
272 | | |
273 | | // Should be file imports |
274 | 1 | assert!(resolver.is_file_import("math")); |
275 | 1 | assert!(resolver.is_file_import("utils")); |
276 | 1 | assert!(resolver.is_file_import("snake_case_module")); |
277 | | |
278 | | // Should NOT be file imports |
279 | 1 | assert!(!resolver.is_file_import("std::collections")); |
280 | 1 | assert!(!resolver.is_file_import("std::io::Read")); |
281 | 1 | assert!(!resolver.is_file_import("https://example.com/module.ruchy")); |
282 | 1 | assert!(!resolver.is_file_import("http://localhost/module.ruchy")); |
283 | 1 | assert!(!resolver.is_file_import("")); |
284 | 1 | } |
285 | | |
286 | | #[test] |
287 | 1 | fn test_resolve_simple_file_import() -> Result<()> { |
288 | 1 | let temp_dir = TempDir::new()?0 ; |
289 | 1 | let mut resolver = ModuleResolver::new(); |
290 | 1 | resolver.add_search_path(temp_dir.path()); |
291 | | |
292 | | // Create a simple math module |
293 | 1 | create_test_module(&temp_dir, "math", r" |
294 | 1 | pub fun add(a: i32, b: i32) -> i32 { |
295 | 1 | a + b |
296 | 1 | } |
297 | 1 | ")?0 ; |
298 | | |
299 | | // Create an import expression |
300 | 1 | let import_expr = Expr::new( |
301 | 1 | ExprKind::Import { |
302 | 1 | path: "math".to_string(), |
303 | 1 | items: vec![ImportItem::Wildcard], |
304 | 1 | }, |
305 | 1 | Span { start: 0, end: 0 }, |
306 | | ); |
307 | | |
308 | | // Resolve the import |
309 | 1 | let resolved_expr = resolver.resolve_imports(import_expr)?0 ; |
310 | | |
311 | | // Should be converted to a Block with Module declaration and use statement |
312 | 1 | match resolved_expr.kind { |
313 | 1 | ExprKind::Block(exprs) => { |
314 | 1 | assert_eq!(exprs.len(), 2); |
315 | | // First should be Module declaration |
316 | 1 | match &exprs[0].kind { |
317 | 1 | ExprKind::Module { name, .. } => { |
318 | 1 | assert_eq!(name, "math"); |
319 | | } |
320 | 0 | _ => unreachable!("Expected first element to be Module, got {:?}", exprs[0].kind), |
321 | | } |
322 | | // Second should be use statement |
323 | 1 | match &exprs[1].kind { |
324 | 1 | ExprKind::Import { path, items } => { |
325 | 1 | assert_eq!(path, "math"); |
326 | 1 | assert_eq!(items.len(), 1); |
327 | 1 | assert!(matches!0 (items[0], ImportItem::Wildcard)); |
328 | | } |
329 | 0 | _ => unreachable!("Expected second element to be Import, got {:?}", exprs[1].kind), |
330 | | } |
331 | | } |
332 | 0 | _ => unreachable!("Expected Block expression, got {:?}", resolved_expr.kind), |
333 | | } |
334 | | |
335 | 1 | Ok(()) |
336 | 1 | } |
337 | | |
338 | | #[test] |
339 | 1 | fn test_resolve_non_file_import() -> Result<()> { |
340 | 1 | let mut resolver = ModuleResolver::new(); |
341 | | |
342 | | // Create a standard library import |
343 | 1 | let import_expr = Expr::new( |
344 | 1 | ExprKind::Import { |
345 | 1 | path: "std::collections".to_string(), |
346 | 1 | items: vec![ImportItem::Named("HashMap".to_string())], |
347 | 1 | }, |
348 | 1 | Span { start: 0, end: 0 }, |
349 | | ); |
350 | | |
351 | | // Resolve the import - should remain unchanged |
352 | 1 | let resolved_expr = resolver.resolve_imports(import_expr)?0 ; |
353 | | |
354 | 1 | match resolved_expr.kind { |
355 | 1 | ExprKind::Import { path, items } => { |
356 | 1 | assert_eq!(path, "std::collections"); |
357 | 1 | assert_eq!(items.len(), 1); |
358 | | } |
359 | 0 | _ => unreachable!("Expected Import expression to remain unchanged"), |
360 | | } |
361 | | |
362 | 1 | Ok(()) |
363 | 1 | } |
364 | | |
365 | | #[test] |
366 | 1 | fn test_resolve_block_with_imports() -> Result<()> { |
367 | 1 | let temp_dir = TempDir::new()?0 ; |
368 | 1 | let mut resolver = ModuleResolver::new(); |
369 | 1 | resolver.add_search_path(temp_dir.path()); |
370 | | |
371 | 1 | create_test_module(&temp_dir, "math", "pub fun add() {}")?0 ; |
372 | | |
373 | | // Create a block with mixed imports |
374 | 1 | let block_expr = Expr::new( |
375 | 1 | ExprKind::Block(vec![ |
376 | 1 | Expr::new( |
377 | 1 | ExprKind::Import { |
378 | 1 | path: "math".to_string(), |
379 | 1 | items: vec![ImportItem::Wildcard], |
380 | 1 | }, |
381 | 1 | Span { start: 0, end: 0 }, |
382 | 1 | ), |
383 | 1 | Expr::new( |
384 | 1 | ExprKind::Import { |
385 | 1 | path: "std::io".to_string(), |
386 | 1 | items: vec![ImportItem::Named("Read".to_string())], |
387 | 1 | }, |
388 | 1 | Span { start: 0, end: 0 }, |
389 | 1 | ), |
390 | 1 | Expr::new( |
391 | 1 | ExprKind::Literal(Literal::Integer(42)), |
392 | 1 | Span { start: 0, end: 0 }, |
393 | 1 | ), |
394 | 1 | ]), |
395 | 1 | Span { start: 0, end: 0 }, |
396 | | ); |
397 | | |
398 | 1 | let resolved_block = resolver.resolve_imports(block_expr)?0 ; |
399 | | |
400 | 1 | if let ExprKind::Block(exprs) = resolved_block.kind { |
401 | 1 | assert_eq!(exprs.len(), 3); |
402 | | |
403 | | // First should be Block containing Module and use statement (from file import) |
404 | 1 | match &exprs[0].kind { |
405 | 1 | ExprKind::Block(inner_exprs) => { |
406 | 1 | assert_eq!(inner_exprs.len(), 2); |
407 | 1 | assert!(matches!0 (inner_exprs[0].kind, ExprKind::Module { .. })); |
408 | 1 | assert!(matches!0 (inner_exprs[1].kind, ExprKind::Import { .. })); |
409 | | } |
410 | 0 | _ => 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 | 1 | assert!(matches!0 (exprs[1].kind, ExprKind::Import { .. })); |
415 | | |
416 | | // Third should remain as Literal |
417 | 1 | assert!(matches!0 (exprs[2].kind, ExprKind::Literal(Literal::Integer(42)))); |
418 | | } else { |
419 | 0 | unreachable!("Expected Block expression"); |
420 | | } |
421 | | |
422 | 1 | Ok(()) |
423 | 1 | } |
424 | | |
425 | | #[test] |
426 | 1 | fn test_stats_and_cache() -> Result<()> { |
427 | 1 | let temp_dir = TempDir::new()?0 ; |
428 | 1 | let mut resolver = ModuleResolver::new(); |
429 | 1 | resolver.add_search_path(temp_dir.path()); |
430 | | |
431 | 1 | create_test_module(&temp_dir, "test", "pub fun test() {}")?0 ; |
432 | | |
433 | 1 | let initial_stats = resolver.stats(); |
434 | 1 | assert_eq!(initial_stats.files_loaded, 0); |
435 | | |
436 | | // Load a module |
437 | 1 | let import_expr = Expr::new( |
438 | 1 | ExprKind::Import { |
439 | 1 | path: "test".to_string(), |
440 | 1 | items: vec![ImportItem::Wildcard], |
441 | 1 | }, |
442 | 1 | Span { start: 0, end: 0 }, |
443 | | ); |
444 | | |
445 | 1 | resolver.resolve_imports(import_expr)?0 ; |
446 | | |
447 | 1 | let after_stats = resolver.stats(); |
448 | 1 | assert_eq!(after_stats.files_loaded, 1); |
449 | 1 | assert_eq!(after_stats.cached_modules, 1); |
450 | | |
451 | | // Clear cache |
452 | 1 | resolver.clear_cache(); |
453 | 1 | let cleared_stats = resolver.stats(); |
454 | 1 | assert_eq!(cleared_stats.files_loaded, 0); |
455 | 1 | assert_eq!(cleared_stats.cached_modules, 0); |
456 | | |
457 | 1 | Ok(()) |
458 | 1 | } |
459 | | } |