## Coverage Report

#### Created: 2025-09-08 21:26

next uncovered line (L), next uncovered region (R), next uncovered branch (B)

    /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
    }