Skip to main content

maat_module/
lib.rs

1//! Module resolution, dependency graph construction, and multi-module
2//! compilation for the Maat compiler.
3//!
4//! This crate builds a directed acyclic graph (DAG) of module dependencies
5//! before compilation begins. Each reachable source file is parsed independently,
6//! cycle detection is performed via DFS with gray/black coloring, and the final
7//! graph provides a topological ordering suitable for compilation.
8//!
9//! # File Resolution
10//!
11//! Resolution follows Rust's module conventions:
12//!
13//! - **Root entry files** and **`mod.maat`** files resolve submodules in
14//!   their own directory: `mod foo;` in `dir/mod.maat` resolves to
15//!   `dir/foo.maat` or `dir/foo/mod.maat`.
16//! - **All other files** resolve submodules in a subdirectory named after
17//!   the file stem: `mod bar;` in `dir/foo.maat` resolves to
18//!   `dir/foo/bar.maat` or `dir/foo/bar/mod.maat`.
19//!
20//! If both `foo.maat` and `foo/mod.maat` exist, the resolution is ambiguous
21//! and an error is produced. If neither exists, a resolution error is
22//! produced.
23#![forbid(unsafe_code)]
24
25mod exports;
26mod graph;
27mod imports;
28mod resolve;
29mod stdlib;
30
31use std::collections::HashMap;
32
33pub use exports::ModuleExports;
34pub use graph::{ModuleGraph, ModuleId, ModuleNode};
35pub use imports::{ImportKind, ResolvedImport};
36use maat_ast::{Program, Stmt, fold_constants};
37use maat_bytecode::Bytecode;
38use maat_codegen::Compiler;
39use maat_errors::{ModuleError, ModuleErrorKind};
40use maat_span::Span;
41use maat_types::TypeChecker;
42pub use resolve::resolve_module_graph;
43
44/// A specialized [`Result`] type for module resolution operations.
45pub type ModuleResult<T> = std::result::Result<T, ModuleError>;
46
47/// Per-module type-checking outputs: public exports and cached resolved imports.
48type TypeCheckResult = (
49    HashMap<ModuleId, ModuleExports>,
50    HashMap<ModuleId, Vec<ResolvedImport>>,
51);
52
53/// Type-checks, compiles, and links all modules in the given graph.
54///
55/// The pipeline operates in two phases:
56///
57/// 1. **Type checking** — Each module is type-checked independently with
58///    its own [`TypeEnv`](maat_types::TypeEnv), after injecting public exports from dependencies.
59///    This enforces module-level visibility while allowing cross-module
60///    type resolution.
61///
62/// 2. **Compilation** — All modules are compiled in topological order
63///    (leaves first, root last) using a single shared [`Compiler`]. This
64///    ensures that:
65///    - `define_symbol` reuses existing global indices for imported names
66///      rather than allocating duplicates
67///    - Constants and type definitions share a single pool
68///    - The resulting instruction stream naturally executes dependency
69///      initialization code before the root module
70///
71/// The output is a single [`Bytecode`] ready for VM execution or
72/// serialization to `.mtc`.
73///
74/// # Errors
75///
76/// Returns a [`ModuleError`] if type checking or
77/// compilation fails for any module.
78pub fn check_and_compile(graph: &mut ModuleGraph) -> ModuleResult<Bytecode> {
79    let topo_order = graph.topo_order().to_vec();
80    let (exports, cached_imports) = type_check_modules(graph, &topo_order)?;
81    compile_modules(graph, &topo_order, &exports, &cached_imports)
82}
83
84/// Type-checks each module independently and extracts public exports.
85fn type_check_modules(
86    graph: &mut ModuleGraph,
87    topo_order: &[ModuleId],
88) -> ModuleResult<TypeCheckResult> {
89    let mut exports: HashMap<ModuleId, ModuleExports> = HashMap::new();
90    let mut cached_imports: HashMap<ModuleId, Vec<ResolvedImport>> = HashMap::new();
91
92    for &module_id in topo_order {
93        let node = graph.node(module_id);
94        let file_path = node.path.clone();
95        let imports = resolve_imports(&node.program, &exports, graph)?;
96        let program = &mut graph.node_mut(module_id).program;
97        let mut checker = TypeChecker::new();
98        for import in &imports {
99            import.inject_into_env(checker.env_mut());
100        }
101        checker.check_program_mut(program);
102
103        let type_errors = checker.errors();
104        if !type_errors.is_empty() {
105            let messages = type_errors.iter().map(|e| e.kind.to_string()).collect();
106            return Err(ModuleErrorKind::TypeErrors {
107                file: file_path.clone(),
108                messages,
109            }
110            .at(Span::ZERO, file_path));
111        }
112        let module_exports = ModuleExports::from_checked(program, checker.env());
113        exports.insert(module_id, module_exports);
114
115        let fold_errors = fold_constants(program);
116        if !fold_errors.is_empty() {
117            let messages = fold_errors.iter().map(|e| e.kind.to_string()).collect();
118            return Err(ModuleErrorKind::TypeErrors {
119                file: file_path.clone(),
120                messages,
121            }
122            .at(Span::ZERO, file_path));
123        }
124        cached_imports.insert(module_id, imports);
125    }
126
127    Ok((exports, cached_imports))
128}
129
130/// Compiles all modules with a shared compiler and produces final bytecode.
131fn compile_modules(
132    graph: &ModuleGraph,
133    topo_order: &[ModuleId],
134    exports: &HashMap<ModuleId, ModuleExports>,
135    cached_imports: &HashMap<ModuleId, Vec<ResolvedImport>>,
136) -> ModuleResult<Bytecode> {
137    let _ = exports; // exports used only during type checking; kept for downstream linking
138    let mut compiler = Compiler::new();
139    for &module_id in topo_order {
140        let file_path = graph.node(module_id).path.clone();
141        if let Some(imports) = cached_imports.get(&module_id) {
142            for import in imports {
143                import.inject_into_compiler(&mut compiler);
144            }
145        }
146        let before = compiler.symbols_table_mut().global_symbol_names();
147        let program = &graph.node(module_id).program;
148        compiler.compile_program(program).map_err(|e| {
149            ModuleErrorKind::CompileErrors {
150                file: file_path.clone(),
151                messages: vec![e.to_string()],
152            }
153            .at(Span::ZERO, file_path.clone())
154        })?;
155
156        apply_module_visibility(&mut compiler, module_id, &before);
157    }
158    let root_path = graph.root().path.clone();
159    compiler.bytecode().map_err(|e| {
160        ModuleErrorKind::CompileErrors {
161            file: root_path.clone(),
162            messages: vec![e.to_string()],
163        }
164        .at(Span::ZERO, root_path)
165    })
166}
167
168/// Masks newly-defined globals after compiling a non-root module.
169///
170/// Masking hides symbols from resolution without removing their storage
171/// indices, so that `inject_import_into_compiler` in subsequent iterations
172/// can unmask and reuse the same global slot. This prevents both private
173/// and public symbols from leaking into modules that have not explicitly
174/// imported them.
175fn apply_module_visibility(compiler: &mut Compiler, module_id: ModuleId, before: &[String]) {
176    if module_id != ModuleId::ROOT {
177        let after = compiler.symbols_table_mut().global_symbol_names();
178        for name in after {
179            if !before.contains(&name) {
180                compiler.symbols_table_mut().mask_symbol(&name);
181            }
182        }
183    }
184}
185
186/// Returns the per-module public exports extracted during type checking.
187pub fn check_exports(graph: &mut ModuleGraph) -> ModuleResult<HashMap<ModuleId, ModuleExports>> {
188    let mut exports: HashMap<ModuleId, ModuleExports> = HashMap::new();
189    let topo_order = graph.topo_order().to_vec();
190    for &module_id in &topo_order {
191        let node = graph.node(module_id);
192        let file_path = node.path.clone();
193        let imports = resolve_imports(&node.program, &exports, graph)?;
194        let program = &mut graph.node_mut(module_id).program;
195        let mut checker = TypeChecker::new();
196        for import in &imports {
197            import.inject_into_env(checker.env_mut());
198        }
199        checker.check_program_mut(program);
200        let type_errors = checker.errors();
201        if !type_errors.is_empty() {
202            let messages = type_errors.iter().map(|e| e.kind.to_string()).collect();
203            return Err(ModuleErrorKind::TypeErrors {
204                file: file_path.clone(),
205                messages,
206            }
207            .at(Span::ZERO, file_path));
208        }
209        let module_exports = ModuleExports::from_checked(program, checker.env());
210        exports.insert(module_id, module_exports);
211    }
212
213    Ok(exports)
214}
215
216/// Resolves all `use` statements in a module's program against the available exports.
217fn resolve_imports(
218    program: &Program,
219    exports: &HashMap<ModuleId, ModuleExports>,
220    graph: &ModuleGraph,
221) -> ModuleResult<Vec<ResolvedImport>> {
222    let mut result = Vec::new();
223    for stmt in &program.statements {
224        let Stmt::Use(use_stmt) = stmt else {
225            continue;
226        };
227        // Determine the module path and items to import.
228        //
229        // For group imports (`use foo::{bar, baz};`), the full path
230        // identifies the module and `items` lists the imported names.
231        //
232        // For non-group imports (`use foo::bar;` or `use std::math::abs;`),
233        // everything except the last segment identifies the module, and
234        // the last segment is the imported item.
235        let (module_path, items_to_import) = if let Some(items) = &use_stmt.items {
236            (use_stmt.path.as_slice(), items.clone())
237        } else if use_stmt.path.len() >= 2 {
238            let split = use_stmt.path.len() - 1;
239            (&use_stmt.path[..split], vec![use_stmt.path[split].clone()])
240        } else {
241            // `use foo;` (bare module import) is intentionally a no-op.
242            // Maat requires explicit item imports (`use foo::bar;` or
243            // `use foo::{bar, baz};`) for ZK auditability. The bare
244            // form is silently skipped; any attempt to use unimported
245            // items will fail with an undefined variable error.
246            continue;
247        };
248        let target_id = find_module_by_path(graph, module_path);
249        let Some(target_id) = target_id else {
250            // Module not in the graph; use of its items will fail
251            // with an undefined variable error during compilation.
252            continue;
253        };
254        let Some(target_exports) = exports.get(&target_id) else {
255            continue;
256        };
257        for item_name in &items_to_import {
258            target_exports.resolve_item(item_name, &mut result);
259        }
260    }
261
262    Ok(result)
263}
264
265/// Finds a module in the graph by matching a use-path against qualified paths.
266///
267/// For a single-segment path like `["math"]`, matches modules whose
268/// qualified path ends with `"math"`. For multi-segment paths like
269/// `["std", "math"]`, requires an exact match against the full
270/// qualified path.
271fn find_module_by_path(graph: &ModuleGraph, module_path: &[String]) -> Option<ModuleId> {
272    graph
273        .nodes()
274        .find(|n| {
275            if module_path.len() == 1 {
276                n.qualified_path
277                    .last()
278                    .is_some_and(|last| last == &module_path[0])
279            } else {
280                n.qualified_path == module_path
281            }
282        })
283        .map(|n| n.id)
284}
285
286#[cfg(test)]
287mod tests {
288    use std::fs;
289
290    use super::*;
291
292    /// Creates a temporary directory tree from `(relative_path, content)` pairs.
293    fn setup_temp_project(pairs: &[(&str, &str)]) -> tempfile::TempDir {
294        let dir = tempfile::tempdir().expect("failed to create temp dir");
295        for (path, content) in pairs {
296            let full = dir.path().join(path);
297            if let Some(parent) = full.parent() {
298                fs::create_dir_all(parent).expect("failed to create directory");
299            }
300            fs::write(&full, content).expect("failed to write file");
301        }
302        dir
303    }
304
305    /// Resolves and compiles a project, returning the bytecode.
306    fn compile_project(
307        dir: &std::path::Path,
308        entry: &str,
309    ) -> ModuleResult<maat_bytecode::Bytecode> {
310        let mut graph = resolve_module_graph(&dir.join(entry))?;
311        check_and_compile(&mut graph)
312    }
313
314    #[test]
315    fn type_error_in_dependency_surfaces() {
316        let dir = setup_temp_project(&[
317            ("main.maat", "mod math; use math::add; add(1, 2);"),
318            (
319                "math.maat",
320                "pub fn add(a: i64, b: i64) -> i64 { a + b + true }",
321            ),
322        ]);
323        let result = compile_project(dir.path(), "main.maat");
324        assert!(result.is_err(), "type error in dependency should surface");
325        let err_msg = result.unwrap_err().to_string();
326        assert!(
327            err_msg.contains("type") || err_msg.contains("Type"),
328            "error should mention type: {err_msg}"
329        );
330    }
331
332    #[test]
333    fn cross_module_function_type_mismatch() {
334        let dir = setup_temp_project(&[
335            ("main.maat", "mod math; use math::add; add(true, false);"),
336            ("math.maat", "pub fn add(a: i64, b: i64) -> i64 { a + b }"),
337        ]);
338        let result = compile_project(dir.path(), "main.maat");
339        assert!(
340            result.is_err(),
341            "passing bool to i64 params should fail type check"
342        );
343    }
344
345    #[test]
346    fn valid_cross_module_compiles() {
347        let dir = setup_temp_project(&[
348            ("main.maat", "mod math; use math::double; double(21);"),
349            ("math.maat", "pub fn double(x: i64) -> i64 { x * 2 }"),
350        ]);
351        let result = compile_project(dir.path(), "main.maat");
352        assert!(
353            result.is_ok(),
354            "valid cross-module program should compile: {:?}",
355            result.err()
356        );
357    }
358
359    #[test]
360    fn bare_use_is_noop() {
361        let dir = setup_temp_project(&[
362            ("main.maat", "mod helper; use helper; let x: i64 = 42;"),
363            ("helper.maat", "pub fn noop() { }"),
364        ]);
365        let result = compile_project(dir.path(), "main.maat");
366        assert!(
367            result.is_ok(),
368            "bare `use helper;` should be a no-op: {:?}",
369            result.err()
370        );
371    }
372
373    #[test]
374    fn missing_module_import_produces_undefined_error() {
375        let dir = setup_temp_project(&[("main.maat", "use nonexistent::foo; foo();")]);
376        let result = compile_project(dir.path(), "main.maat");
377        assert!(
378            result.is_err(),
379            "importing from non-existent module should fail"
380        );
381    }
382
383    #[test]
384    fn grouped_imports() {
385        let dir = setup_temp_project(&[
386            (
387                "main.maat",
388                "mod math; use math::{add, sub}; add(1, 2); sub(3, 1);",
389            ),
390            (
391                "math.maat",
392                "pub fn add(a: i64, b: i64) -> i64 { a + b }\npub fn sub(a: i64, b: i64) -> i64 { a - b }",
393            ),
394        ]);
395        let result = compile_project(dir.path(), "main.maat");
396        assert!(
397            result.is_ok(),
398            "grouped imports should work: {:?}",
399            result.err()
400        );
401    }
402
403    #[test]
404    fn reexport_pub_use() {
405        let dir = setup_temp_project(&[
406            ("main.maat", "mod proxy; use proxy::double; double(5);"),
407            ("proxy.maat", "mod math; pub use math::double;"),
408            ("proxy/math.maat", "pub fn double(x: i64) -> i64 { x * 2 }"),
409        ]);
410        let result = compile_project(dir.path(), "main.maat");
411        assert!(
412            result.is_ok(),
413            "re-export via `pub use` should work: {:?}",
414            result.err()
415        );
416    }
417
418    #[test]
419    fn topo_order_compiles_dependencies_first() {
420        let dir = setup_temp_project(&[
421            (
422                "main.maat",
423                "mod a; mod b; use a::fa; use b::fb; fa(fb(1));",
424            ),
425            ("a.maat", "pub fn fa(x: i64) -> i64 { x + 10 }"),
426            ("b.maat", "pub fn fb(x: i64) -> i64 { x * 2 }"),
427        ]);
428        let result = compile_project(dir.path(), "main.maat");
429        assert!(
430            result.is_ok(),
431            "multi-dependency compilation should succeed: {:?}",
432            result.err()
433        );
434    }
435
436    #[test]
437    fn diamond_dependency_compiles() {
438        let dir = setup_temp_project(&[
439            (
440                "main.maat",
441                "mod a; mod b; use a::fa; use b::fb; fa(1); fb(2);",
442            ),
443            (
444                "a.maat",
445                "mod shared; use shared::helper; pub fn fa(x: i64) -> i64 { helper(x) }",
446            ),
447            (
448                "b.maat",
449                "mod shared; use shared::helper; pub fn fb(x: i64) -> i64 { helper(x) }",
450            ),
451            ("a/shared.maat", "pub fn helper(x: i64) -> i64 { x + 1 }"),
452            ("b/shared.maat", "pub fn helper(x: i64) -> i64 { x + 2 }"),
453        ]);
454        let result = compile_project(dir.path(), "main.maat");
455        assert!(
456            result.is_ok(),
457            "diamond dependency should compile: {:?}",
458            result.err()
459        );
460    }
461
462    #[test]
463    fn exports_only_pub_items() {
464        let dir = setup_temp_project(&[
465            ("main.maat", "mod lib; use lib::pub_fn; pub_fn();"),
466            ("lib.maat", "pub fn pub_fn() { }\nfn private_fn() { }"),
467        ]);
468        let mut graph = resolve_module_graph(&dir.path().join("main.maat")).unwrap();
469        let exports = check_exports(&mut graph).unwrap();
470        // Find the lib module's exports (not the root).
471        let lib_exports = exports
472            .iter()
473            .find(|&(&id, _)| id != ModuleId::ROOT)
474            .map(|(_, e)| e)
475            .expect("should have lib module exports");
476        let binding_names: Vec<&str> = lib_exports
477            .bindings
478            .iter()
479            .map(|(n, _)| n.as_str())
480            .collect();
481        assert!(binding_names.contains(&"pub_fn"), "should export pub_fn");
482        assert!(
483            !binding_names.contains(&"private_fn"),
484            "should not export private_fn"
485        );
486    }
487
488    #[test]
489    fn exports_pub_struct() {
490        let dir = setup_temp_project(&[
491            ("main.maat", "mod types; use types::Point;"),
492            ("types.maat", "pub struct Point { x: i64, y: i64 }"),
493        ]);
494        let mut graph = resolve_module_graph(&dir.path().join("main.maat")).unwrap();
495        let exports = check_exports(&mut graph).unwrap();
496        let types_exports = exports
497            .iter()
498            .find(|&(&id, _)| id != ModuleId::ROOT)
499            .map(|(_, e)| e)
500            .expect("should have types module exports");
501        assert_eq!(types_exports.structs.len(), 1);
502        assert_eq!(types_exports.structs[0].name, "Point");
503    }
504
505    #[test]
506    fn exports_pub_enum() {
507        let dir = setup_temp_project(&[
508            ("main.maat", "mod types; use types::Color;"),
509            ("types.maat", "pub enum Color { Red, Green, Blue }"),
510        ]);
511        let mut graph = resolve_module_graph(&dir.path().join("main.maat")).unwrap();
512        let exports = check_exports(&mut graph).unwrap();
513        let types_exports = exports
514            .iter()
515            .find(|&(&id, _)| id != ModuleId::ROOT)
516            .map(|(_, e)| e)
517            .expect("should have types module exports");
518        assert_eq!(types_exports.enums.len(), 1);
519        assert_eq!(types_exports.enums[0].name, "Color");
520    }
521
522    #[test]
523    fn private_symbols_do_not_leak_across_modules() {
524        let dir = setup_temp_project(&[
525            ("main.maat", "mod a; mod b; use b::result; result();"),
526            ("a.maat", "fn private_helper() -> i64 { 42 }"),
527            ("b.maat", "pub fn result() -> i64 { 1 }"),
528        ]);
529        // `a`'s private_helper should not be visible in `b` or `main`.
530        let result = compile_project(dir.path(), "main.maat");
531        assert!(
532            result.is_ok(),
533            "private symbols should not leak: {:?}",
534            result.err()
535        );
536    }
537
538    #[test]
539    fn find_module_single_segment() {
540        let dir = setup_temp_project(&[
541            ("main.maat", "mod math;"),
542            ("math.maat", "pub fn add(a: i64, b: i64) -> i64 { a + b }"),
543        ]);
544        let graph = resolve_module_graph(&dir.path().join("main.maat")).unwrap();
545        let found = find_module_by_path(&graph, &["math".to_string()]);
546        assert!(found.is_some(), "should find module by single segment");
547    }
548
549    #[test]
550    fn find_module_returns_none_for_unknown() {
551        let dir = setup_temp_project(&[("main.maat", "let x: i64 = 1;")]);
552        let graph = resolve_module_graph(&dir.path().join("main.maat")).unwrap();
553        let found = find_module_by_path(&graph, &["nonexistent".to_string()]);
554        assert!(found.is_none(), "should not find non-existent module");
555    }
556}