Skip to main content

cjc_module/
lib.rs

1//! CJC Module System
2//!
3//! Deterministic module resolution, dependency graph construction,
4//! and program merging for multi-file CJC programs.
5//!
6//! This crate provides the infrastructure for splitting CJC programs across
7//! multiple source files. It handles file resolution, import parsing,
8//! dependency-graph construction with cycle detection, symbol mangling,
9//! visibility enforcement, and final merging of per-module MIR into a
10//! single [`cjc_mir::MirProgram`] suitable for execution.
11//!
12//! # Design principles
13//!
14//! - All internal maps use [`BTreeMap`] / [`BTreeSet`] for deterministic
15//!   iteration order, ensuring reproducible compilation regardless of
16//!   filesystem enumeration order.
17//! - Symbol mangling uses a `module_path::fn_name` convention
18//!   (e.g., `math::linalg::solve`).
19//! - Cycle detection is performed via DFS with a `BTreeSet`-backed
20//!   recursion stack and produces clear error messages.
21//! - The final output is a single merged [`cjc_mir::MirProgram`] with
22//!   module-init statements prepended in topological order.
23//!
24//! # Typical workflow
25//!
26//! ```text
27//! entry.cjcl ─► build_module_graph() ─► ModuleGraph
28//!                                         │
29//!                      merge_programs() ◄─┘
30//!                           │
31//!                      MirProgram (ready for cjc-mir-exec)
32//! ```
33
34use std::collections::{BTreeMap, BTreeSet};
35use std::path::{Path, PathBuf};
36
37// ---------------------------------------------------------------------------
38// Module identity
39// ---------------------------------------------------------------------------
40
41/// A unique, deterministic identifier for a module derived from its
42/// path relative to the project root.
43///
44/// The identifier is a `"::"` -separated string that mirrors the
45/// directory structure of the source tree.
46///
47/// # Examples
48///
49/// | Source file (relative)  | `ModuleId` value  |
50/// |-------------------------|-------------------|
51/// | `main.cjcl`             | `"main"`          |
52/// | `math.cjcl`             | `"math"`          |
53/// | `math/linalg.cjcl`      | `"math::linalg"`  |
54/// | `math/linalg/mod.cjcl`  | `"math::linalg"`  |
55///
56/// The inner `String` is public so that callers can inspect the raw
57/// identifier when necessary, but prefer using the provided methods
58/// ([`from_relative_path`](Self::from_relative_path),
59/// [`from_import_path`](Self::from_import_path),
60/// [`symbol_prefix`](Self::symbol_prefix)) for construction and
61/// formatting.
62#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
63pub struct ModuleId(pub String);
64
65impl ModuleId {
66    /// Create a [`ModuleId`] from a filesystem path relative to the project root.
67    ///
68    /// Strip the file extension and convert path separators to `"::"`.
69    ///
70    /// # Arguments
71    ///
72    /// * `path` - A path relative to the project root (e.g., `math/linalg.cjcl`).
73    ///
74    /// # Returns
75    ///
76    /// A [`ModuleId`] whose inner string is the `"::"` -joined stem
77    /// (e.g., `"math::linalg"`).
78    ///
79    /// # Examples
80    ///
81    /// ```
82    /// # use std::path::Path;
83    /// # use cjc_module::ModuleId;
84    /// let id = ModuleId::from_relative_path(Path::new("math/linalg.cjcl"));
85    /// assert_eq!(id.0, "math::linalg");
86    /// ```
87    pub fn from_relative_path(path: &Path) -> Self {
88        let stem = path.with_extension("");
89        let parts: Vec<&str> = stem
90            .components()
91            .filter_map(|c| c.as_os_str().to_str())
92            .collect();
93        ModuleId(parts.join("::"))
94    }
95
96    /// Convert an import-path segment list into a [`ModuleId`].
97    ///
98    /// The segments are joined with `"::"` to form the identifier string.
99    ///
100    /// # Arguments
101    ///
102    /// * `segments` - The import path segments (e.g., `["math", "linalg"]`).
103    ///
104    /// # Returns
105    ///
106    /// A [`ModuleId`] whose inner string is `"math::linalg"`.
107    ///
108    /// # Examples
109    ///
110    /// ```
111    /// # use cjc_module::ModuleId;
112    /// let id = ModuleId::from_import_path(&["math".into(), "linalg".into()]);
113    /// assert_eq!(id.0, "math::linalg");
114    /// ```
115    pub fn from_import_path(segments: &[String]) -> Self {
116        ModuleId(segments.join("::"))
117    }
118
119    /// Return the mangled prefix used for symbols defined in this module.
120    ///
121    /// The entry module (`"main"` or empty) returns an empty string so
122    /// that top-level symbols keep their original names. All other
123    /// modules return `"<module_id>::"`.
124    ///
125    /// # Returns
126    ///
127    /// An empty [`String`] for the entry module, or the module path
128    /// followed by `"::"` for all other modules.
129    ///
130    /// # Examples
131    ///
132    /// ```
133    /// # use cjc_module::ModuleId;
134    /// assert_eq!(ModuleId("main".into()).symbol_prefix(), "");
135    /// assert_eq!(ModuleId("math".into()).symbol_prefix(), "math::");
136    /// assert_eq!(ModuleId("math::linalg".into()).symbol_prefix(), "math::linalg::");
137    /// ```
138    pub fn symbol_prefix(&self) -> String {
139        if self.0 == "main" || self.0.is_empty() {
140            String::new()
141        } else {
142            format!("{}::", self.0)
143        }
144    }
145}
146
147impl std::fmt::Display for ModuleId {
148    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149        write!(f, "{}", self.0)
150    }
151}
152
153// ---------------------------------------------------------------------------
154// Module info
155// ---------------------------------------------------------------------------
156
157/// Metadata and parsed content for a single CJC source module.
158///
159/// Each source file that participates in a multi-file build is
160/// represented by one `ModuleInfo` inside the [`ModuleGraph`]. It
161/// carries the parsed AST, the list of import declarations, and
162/// bookkeeping flags used during graph construction and merging.
163#[derive(Debug, Clone)]
164pub struct ModuleInfo {
165    /// Unique identifier for this module (derived from its file path).
166    pub id: ModuleId,
167    /// Absolute filesystem path to the `.cjcl` source file.
168    pub file_path: PathBuf,
169    /// Import declarations extracted from this module's AST.
170    pub imports: Vec<ImportInfo>,
171    /// Parsed AST program. [`None`] only if the AST was consumed or
172    /// the module was stubbed for testing.
173    pub ast: Option<cjc_ast::Program>,
174    /// `true` if this is the entry-point module (the file passed to
175    /// [`build_module_graph`]).
176    pub is_entry: bool,
177}
178
179/// A resolved import from one module to another.
180#[derive(Debug, Clone)]
181pub struct ImportInfo {
182    /// The import path segments (e.g., `["math", "linalg"]`).
183    pub path: Vec<String>,
184    /// Optional alias (e.g., `import math.linalg as ml`).
185    pub alias: Option<String>,
186    /// The resolved module ID this import refers to.
187    pub resolved_module: Option<ModuleId>,
188    /// The specific symbol being imported (last segment), if this
189    /// is a symbol import rather than a module import.
190    pub symbol: Option<String>,
191}
192
193// ---------------------------------------------------------------------------
194// Module graph
195// ---------------------------------------------------------------------------
196
197/// A directed acyclic graph of module dependencies.
198/// Uses `BTreeMap` internally for deterministic iteration order.
199#[derive(Debug, Clone)]
200pub struct ModuleGraph {
201    /// All modules in the graph, keyed by ModuleId.
202    pub modules: BTreeMap<ModuleId, ModuleInfo>,
203    /// Directed edges: module → set of modules it depends on.
204    pub edges: BTreeMap<ModuleId, BTreeSet<ModuleId>>,
205    /// The entry module ID.
206    pub entry: ModuleId,
207}
208
209impl ModuleGraph {
210    /// Return modules in deterministic topological order (dependencies first).
211    /// Returns `Err` if a cycle is detected.
212    pub fn topological_order(&self) -> Result<Vec<ModuleId>, ModuleError> {
213        let mut visited = BTreeSet::new();
214        let mut in_stack = BTreeSet::new();
215        let mut order = Vec::new();
216
217        // Visit all modules in deterministic (BTreeMap) order
218        for id in self.modules.keys() {
219            if !visited.contains(id) {
220                self.topo_dfs(id, &mut visited, &mut in_stack, &mut order)?;
221            }
222        }
223
224        Ok(order)
225    }
226
227    fn topo_dfs(
228        &self,
229        node: &ModuleId,
230        visited: &mut BTreeSet<ModuleId>,
231        in_stack: &mut BTreeSet<ModuleId>,
232        order: &mut Vec<ModuleId>,
233    ) -> Result<(), ModuleError> {
234        if in_stack.contains(node) {
235            return Err(ModuleError::CyclicDependency {
236                cycle: in_stack.iter().cloned().collect(),
237            });
238        }
239        if visited.contains(node) {
240            return Ok(());
241        }
242
243        in_stack.insert(node.clone());
244
245        if let Some(deps) = self.edges.get(node) {
246            for dep in deps {
247                self.topo_dfs(dep, visited, in_stack, order)?;
248            }
249        }
250
251        in_stack.remove(node);
252        visited.insert(node.clone());
253        order.push(node.clone());
254        Ok(())
255    }
256
257    /// Get the number of modules in the graph.
258    pub fn module_count(&self) -> usize {
259        self.modules.len()
260    }
261}
262
263// ---------------------------------------------------------------------------
264// Errors
265// ---------------------------------------------------------------------------
266
267/// Errors that can occur during module resolution.
268#[derive(Debug, Clone)]
269pub enum ModuleError {
270    /// A source file could not be found for the given import path.
271    FileNotFound {
272        import_path: Vec<String>,
273        searched_paths: Vec<PathBuf>,
274    },
275    /// Circular dependency detected among modules.
276    CyclicDependency {
277        cycle: Vec<ModuleId>,
278    },
279    /// A parse error occurred in a module.
280    ParseError {
281        module_id: ModuleId,
282        diagnostics: Vec<cjc_diag::Diagnostic>,
283    },
284    /// Duplicate symbol after merging.
285    DuplicateSymbol {
286        symbol: String,
287        first_module: ModuleId,
288        second_module: ModuleId,
289    },
290    /// An imported symbol was not found in the target module.
291    SymbolNotFound {
292        symbol: String,
293        module_id: ModuleId,
294    },
295    /// I/O error reading source file.
296    IoError {
297        path: PathBuf,
298        message: String,
299    },
300}
301
302impl std::fmt::Display for ModuleError {
303    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
304        match self {
305            ModuleError::FileNotFound {
306                import_path,
307                searched_paths,
308            } => {
309                write!(
310                    f,
311                    "module not found: `{}`. Searched: {}",
312                    import_path.join("."),
313                    searched_paths
314                        .iter()
315                        .map(|p| p.display().to_string())
316                        .collect::<Vec<_>>()
317                        .join(", ")
318                )
319            }
320            ModuleError::CyclicDependency { cycle } => {
321                write!(
322                    f,
323                    "cyclic dependency detected: {}",
324                    cycle
325                        .iter()
326                        .map(|m| m.0.as_str())
327                        .collect::<Vec<_>>()
328                        .join(" → ")
329                )
330            }
331            ModuleError::ParseError {
332                module_id,
333                diagnostics,
334            } => {
335                write!(
336                    f,
337                    "parse error in module `{}`: {} error(s)",
338                    module_id,
339                    diagnostics.len()
340                )
341            }
342            ModuleError::DuplicateSymbol {
343                symbol,
344                first_module,
345                second_module,
346            } => {
347                write!(
348                    f,
349                    "duplicate symbol `{}` in modules `{}` and `{}`",
350                    symbol, first_module, second_module
351                )
352            }
353            ModuleError::SymbolNotFound { symbol, module_id } => {
354                write!(
355                    f,
356                    "symbol `{}` not found in module `{}`",
357                    symbol, module_id
358                )
359            }
360            ModuleError::IoError { path, message } => {
361                write!(f, "I/O error reading `{}`: {}", path.display(), message)
362            }
363        }
364    }
365}
366
367impl std::error::Error for ModuleError {}
368
369// ---------------------------------------------------------------------------
370// File resolution
371// ---------------------------------------------------------------------------
372
373/// Resolve an import path to a source file, searching from the given root directory.
374///
375/// Search order:
376/// 1. `<root>/<path_joined_by_slash>.cjcl` (e.g., `math/linalg.cjcl`)
377/// 2. `<root>/<path_joined_by_slash>/mod.cjcl` (e.g., `math/linalg/mod.cjcl`)
378///
379/// Returns the absolute path if found, or `ModuleError::FileNotFound`.
380pub fn resolve_file(root: &Path, import_path: &[String]) -> Result<PathBuf, ModuleError> {
381    let mut searched = Vec::new();
382
383    // Strategy 1: <root>/a/b/c.cjcl
384    let mut file_path = root.to_path_buf();
385    for segment in import_path {
386        file_path.push(segment);
387    }
388    file_path.set_extension("cjcl");
389    searched.push(file_path.clone());
390
391    if file_path.is_file() {
392        return Ok(file_path);
393    }
394
395    // Strategy 2: <root>/a/b/c/mod.cjcl
396    let mut dir_path = root.to_path_buf();
397    for segment in import_path {
398        dir_path.push(segment);
399    }
400    dir_path.push("mod.cjcl");
401    searched.push(dir_path.clone());
402
403    if dir_path.is_file() {
404        return Ok(dir_path);
405    }
406
407    Err(ModuleError::FileNotFound {
408        import_path: import_path.to_vec(),
409        searched_paths: searched,
410    })
411}
412
413// ---------------------------------------------------------------------------
414// Module graph construction
415// ---------------------------------------------------------------------------
416
417/// Build a module graph starting from the entry file.
418///
419/// This function:
420/// 1. Parses the entry file
421/// 2. Extracts import declarations
422/// 3. Recursively resolves and parses imported modules
423/// 4. Builds the dependency graph
424///
425/// All internal state uses `BTreeMap`/`BTreeSet` for deterministic ordering.
426pub fn build_module_graph(entry_path: &Path) -> Result<ModuleGraph, ModuleError> {
427    let root = entry_path
428        .parent()
429        .unwrap_or_else(|| Path::new("."))
430        .to_path_buf();
431
432    let mut modules = BTreeMap::new();
433    let mut edges = BTreeMap::new();
434    let entry_id = ModuleId("main".to_string());
435
436    // BFS queue for modules to process
437    let mut queue: Vec<(ModuleId, PathBuf, bool)> = Vec::new();
438    queue.push((entry_id.clone(), entry_path.to_path_buf(), true));
439
440    let mut seen = BTreeSet::new();
441    seen.insert(entry_id.clone());
442
443    while let Some((mod_id, file_path, is_entry)) = queue.pop() {
444        // Read and parse the source file
445        let source = std::fs::read_to_string(&file_path).map_err(|e| ModuleError::IoError {
446            path: file_path.clone(),
447            message: e.to_string(),
448        })?;
449
450        let (tokens, _lex_diag) = cjc_lexer::Lexer::new(&source).tokenize();
451        let (program, parse_diag) = cjc_parser::Parser::new(tokens).parse_program();
452
453        if parse_diag.has_errors() {
454            return Err(ModuleError::ParseError {
455                module_id: mod_id.clone(),
456                diagnostics: parse_diag.diagnostics.clone(),
457            });
458        }
459
460        // Extract imports
461        let mut imports = Vec::new();
462        let mut deps = BTreeSet::new();
463
464        for decl in &program.declarations {
465            if let cjc_ast::DeclKind::Import(import_decl) = &decl.kind {
466                let path_segments: Vec<String> =
467                    import_decl.path.iter().map(|id| id.name.clone()).collect();
468                let alias = import_decl.alias.as_ref().map(|id| id.name.clone());
469
470                // Determine if this is a module import or symbol import.
471                // Convention: if the last segment starts with uppercase, it's a symbol.
472                // Otherwise treat the whole path as a module reference.
473                let (module_path, symbol) = classify_import(&path_segments);
474
475                // Resolve the file for the module portion
476                let resolved_module = match resolve_file(&root, &module_path) {
477                    Ok(resolved_path) => {
478                        let dep_id = ModuleId::from_import_path(&module_path);
479                        deps.insert(dep_id.clone());
480
481                        // Queue this module if not already seen
482                        if !seen.contains(&dep_id) {
483                            seen.insert(dep_id.clone());
484                            queue.push((dep_id.clone(), resolved_path, false));
485                        }
486
487                        Some(dep_id)
488                    }
489                    Err(_) => {
490                        // Import might refer to a builtin or be resolved later;
491                        // we don't error here — we allow unresolved imports for
492                        // builtins. Actual symbol resolution errors surface later.
493                        None
494                    }
495                };
496
497                imports.push(ImportInfo {
498                    path: path_segments,
499                    alias,
500                    resolved_module,
501                    symbol,
502                });
503            }
504        }
505
506        edges.insert(mod_id.clone(), deps);
507
508        modules.insert(
509            mod_id.clone(),
510            ModuleInfo {
511                id: mod_id,
512                file_path,
513                imports,
514                ast: Some(program),
515                is_entry,
516            },
517        );
518    }
519
520    let graph = ModuleGraph {
521        modules,
522        edges,
523        entry: entry_id,
524    };
525
526    // Validate: no cycles
527    let _order = graph.topological_order()?;
528
529    Ok(graph)
530}
531
532/// Classify an import path into (module_path, optional_symbol).
533///
534/// Convention:
535/// - `import math.linalg` → module_path=["math", "linalg"], symbol=None
536/// - `import math.Matrix` → module_path=["math"], symbol=Some("Matrix")
537/// - `import math.linalg.solve` → module_path=["math", "linalg"], symbol=Some("solve")
538///
539/// Heuristic: if the last segment starts with lowercase letter and is a single
540/// segment import, treat the whole thing as a module path.
541fn classify_import(path: &[String]) -> (Vec<String>, Option<String>) {
542    if path.len() <= 1 {
543        // Single-segment import: always a module
544        return (path.to_vec(), None);
545    }
546
547    // Try the full path as a module first; if that fails during resolution,
548    // the caller can try (all_but_last, last) as (module, symbol).
549    // For graph building, we assume the full path is the module to check.
550    // We'll also store the last segment as a potential symbol.
551    let last = &path[path.len() - 1];
552
553    // If last segment starts with uppercase, it's likely a type/symbol import
554    if last.starts_with(|c: char| c.is_ascii_uppercase()) {
555        let module_path = path[..path.len() - 1].to_vec();
556        let symbol = Some(last.clone());
557        (module_path, symbol)
558    } else {
559        // Could be either a module or a function symbol import.
560        // We try the full path as a module first.
561        (path.to_vec(), None)
562    }
563}
564
565// ---------------------------------------------------------------------------
566// Program merging
567// ---------------------------------------------------------------------------
568
569/// Merge multiple module ASTs into a single combined MIR program.
570///
571/// Processes modules in topological order (dependencies first). Symbols
572/// from non-entry modules are prefixed with `module_path::` to avoid
573/// collisions.
574///
575/// Returns a merged `MirProgram` ready for execution.
576pub fn merge_programs(graph: &ModuleGraph) -> Result<cjc_mir::MirProgram, ModuleError> {
577    let order = graph.topological_order()?;
578
579    // We'll collect all HIR items, prefixing non-entry module symbols
580    let mut all_functions: Vec<cjc_mir::MirFunction> = Vec::new();
581    let mut all_struct_defs: Vec<cjc_mir::MirStructDef> = Vec::new();
582    let mut all_enum_defs: Vec<cjc_mir::MirEnumDef> = Vec::new();
583    let mut main_stmts: Vec<cjc_mir::MirStmt> = Vec::new();
584
585    // Track symbols for duplicate detection
586    let mut symbol_origins: BTreeMap<String, ModuleId> = BTreeMap::new();
587
588    // Per-module: lower AST → HIR → MIR, then merge
589    let mut fn_id_counter: u32 = 0;
590
591    for mod_id in &order {
592        let module = graph
593            .modules
594            .get(mod_id)
595            .expect("module in topo order must exist in graph");
596
597        let ast = match &module.ast {
598            Some(ast) => ast,
599            None => continue,
600        };
601
602        // Type-check (with filename for cross-file diagnostics)
603        let filename = module.file_path.display().to_string();
604        let mut checker = cjc_types::TypeChecker::new_with_filename(&filename);
605        checker.check_program(ast);
606        // We allow type warnings but not errors in dependencies
607        // (errors would have been caught during graph building parse phase)
608
609        // Lower AST → HIR
610        let mut hir_lower = cjc_hir::AstLowering::new();
611        let hir = hir_lower.lower_program(ast);
612
613        // Lower HIR → MIR
614        let mut mir_lower = cjc_mir::HirToMir::new();
615        let mir = mir_lower.lower_program(&hir);
616
617        let prefix = mod_id.symbol_prefix();
618
619        // Merge functions (prefix non-entry module symbols)
620        for mut func in mir.functions {
621            let original_name = func.name.clone();
622
623            if !module.is_entry && original_name != "__main" {
624                func.name = format!("{}{}", prefix, original_name);
625            }
626
627            // Remap function ID to avoid collisions
628            let new_id = cjc_mir::MirFnId(fn_id_counter);
629            fn_id_counter += 1;
630
631            if original_name == "__main" {
632                // Non-entry __main stmts become module init stmts
633                if module.is_entry {
634                    // Entry module __main → becomes the merged program's __main body
635                    main_stmts.extend(func.body.stmts);
636                } else {
637                    // Non-entry module __main → prepend to merged main (init order)
638                    // Insert at position before entry stmts
639                    let init_stmts = func.body.stmts;
640                    main_stmts.splice(0..0, init_stmts);
641                }
642            } else {
643                let mangled = func.name.clone();
644
645                // Check for duplicates
646                if let Some(first_mod) = symbol_origins.get(&mangled) {
647                    return Err(ModuleError::DuplicateSymbol {
648                        symbol: mangled,
649                        first_module: first_mod.clone(),
650                        second_module: mod_id.clone(),
651                    });
652                }
653                symbol_origins.insert(mangled, mod_id.clone());
654
655                func.id = new_id;
656                all_functions.push(func);
657            }
658        }
659
660        // Merge struct defs
661        for mut sdef in mir.struct_defs {
662            if !module.is_entry {
663                sdef.name = format!("{}{}", prefix, sdef.name);
664            }
665            all_struct_defs.push(sdef);
666        }
667
668        // Merge enum defs
669        for mut edef in mir.enum_defs {
670            if !module.is_entry {
671                edef.name = format!("{}{}", prefix, edef.name);
672            }
673            all_enum_defs.push(edef);
674        }
675    }
676
677    // Create function aliases for imported symbols so the entry module
678    // can call them by their unmangled names (e.g., `double(x)` instead
679    // of `mathlib::double(x)`).
680    let entry_module = graph.modules.get(&graph.entry).expect("entry must exist");
681    for import in &entry_module.imports {
682        if let Some(resolved) = &import.resolved_module {
683            let prefix = resolved.symbol_prefix();
684            // For each function in the imported module, register an alias
685            // under the original (unprefixed) name if it doesn't conflict.
686            let imported_mod = graph.modules.get(resolved);
687            if let Some(imp_mod) = imported_mod {
688                if let Some(ast) = &imp_mod.ast {
689                    for decl in &ast.declarations {
690                        if let cjc_ast::DeclKind::Fn(f) = &decl.kind {
691                            // Alias all functions from imported modules.
692                            // Visibility enforcement is handled separately by check_visibility().
693                            let unprefixed = f.name.name.clone();
694                            let prefixed = format!("{}{}", prefix, unprefixed);
695                            // Only add alias if unprefixed name not already taken
696                            if !symbol_origins.contains_key(&unprefixed) {
697                                // Find the prefixed function and clone it with unprefixed name
698                                if let Some(orig) = all_functions.iter().find(|f| f.name == prefixed) {
699                                    let mut alias = orig.clone();
700                                    alias.name = unprefixed.clone();
701                                    alias.id = cjc_mir::MirFnId(fn_id_counter);
702                                    fn_id_counter += 1;
703                                    symbol_origins.insert(unprefixed, graph.entry.clone());
704                                    all_functions.push(alias);
705                                }
706                            }
707                        }
708                    }
709                }
710            }
711        }
712    }
713
714    // Create merged __main function
715    let main_id = cjc_mir::MirFnId(fn_id_counter);
716    fn_id_counter += 1;
717    let _ = fn_id_counter; // suppress unused warning
718
719    all_functions.push(cjc_mir::MirFunction {
720        id: main_id,
721        name: "__main".to_string(),
722        type_params: vec![],
723        params: vec![],
724        return_type: None,
725        body: cjc_mir::MirBody {
726            stmts: main_stmts,
727            result: None,
728        },
729        is_nogc: false,
730        cfg_body: None,
731        decorators: vec![],
732        vis: cjc_ast::Visibility::Private,
733    });
734
735    Ok(cjc_mir::MirProgram {
736        functions: all_functions,
737        struct_defs: all_struct_defs,
738        enum_defs: all_enum_defs,
739        entry: main_id,
740    })
741}
742
743// ---------------------------------------------------------------------------
744// Visibility enforcement
745// ---------------------------------------------------------------------------
746
747/// Errors produced by visibility checks.
748#[derive(Debug, Clone)]
749pub struct VisibilityViolation {
750    pub symbol: String,
751    pub module_id: ModuleId,
752    pub kind: &'static str, // "function", "struct", "field", etc.
753}
754
755impl std::fmt::Display for VisibilityViolation {
756    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
757        write!(
758            f,
759            "{} `{}` in module `{}` is private and cannot be imported",
760            self.kind, self.symbol, self.module_id
761        )
762    }
763}
764
765/// Check visibility constraints after merging.
766///
767/// For each import in the entry module, verify that the imported symbol
768/// is marked `pub` in the source module. Returns a list of violations.
769pub fn check_visibility(graph: &ModuleGraph) -> Vec<VisibilityViolation> {
770    let mut violations = Vec::new();
771
772    // For each module, check its imports
773    for (mod_id, module) in &graph.modules {
774        for import in &module.imports {
775            let resolved = match &import.resolved_module {
776                Some(m) => m,
777                None => continue,
778            };
779            let target_mod = match graph.modules.get(resolved) {
780                Some(m) => m,
781                None => continue,
782            };
783            let target_ast = match &target_mod.ast {
784                Some(a) => a,
785                None => continue,
786            };
787
788            // If importing a specific symbol, check its visibility
789            if let Some(ref symbol) = import.symbol {
790                for decl in &target_ast.declarations {
791                    match &decl.kind {
792                        cjc_ast::DeclKind::Fn(f) if f.name.name == *symbol => {
793                            if f.vis == cjc_ast::Visibility::Private {
794                                violations.push(VisibilityViolation {
795                                    symbol: symbol.clone(),
796                                    module_id: resolved.clone(),
797                                    kind: "function",
798                                });
799                            }
800                        }
801                        cjc_ast::DeclKind::Struct(s) if s.name.name == *symbol => {
802                            if s.vis == cjc_ast::Visibility::Private {
803                                violations.push(VisibilityViolation {
804                                    symbol: symbol.clone(),
805                                    module_id: resolved.clone(),
806                                    kind: "struct",
807                                });
808                            }
809                        }
810                        cjc_ast::DeclKind::Record(r) if r.name.name == *symbol => {
811                            if r.vis == cjc_ast::Visibility::Private {
812                                violations.push(VisibilityViolation {
813                                    symbol: symbol.clone(),
814                                    module_id: resolved.clone(),
815                                    kind: "record",
816                                });
817                            }
818                        }
819                        _ => {}
820                    }
821                }
822            } else {
823                // Module-level import: check that at least one `pub` symbol exists.
824                // For module imports, only `pub` functions get aliased into the
825                // importing module. Private functions remain inaccessible.
826                // (This is enforced during alias creation, not here.)
827            }
828        }
829        let _ = mod_id; // suppress unused warning
830    }
831
832    violations
833}
834
835// ---------------------------------------------------------------------------
836// Import rewriting (call-site resolution)
837// ---------------------------------------------------------------------------
838
839/// Build an alias map from a module's imports for use during call resolution.
840///
841/// Returns a `BTreeMap<local_name, qualified_name>` where:
842/// - `local_name` is how the import is referred to in the source
843/// - `qualified_name` is the mangled symbol in the merged program
844pub fn build_import_aliases(module: &ModuleInfo) -> BTreeMap<String, String> {
845    let mut aliases = BTreeMap::new();
846
847    for import in &module.imports {
848        let resolved = match &import.resolved_module {
849            Some(m) => m,
850            None => continue,
851        };
852
853        let prefix = resolved.symbol_prefix();
854
855        if let Some(symbol) = &import.symbol {
856            // Symbol import: `import math.Matrix` → Matrix → math::Matrix
857            let local = import
858                .alias
859                .clone()
860                .unwrap_or_else(|| symbol.clone());
861            let qualified = format!("{}{}", prefix, symbol);
862            aliases.insert(local, qualified);
863        } else {
864            // Module import: `import math.linalg` → linalg.foo → math::linalg::foo
865            // We store the module prefix so call resolution can rewrite
866            // `linalg.foo()` → `math::linalg::foo()`
867            let local = import
868                .alias
869                .clone()
870                .unwrap_or_else(|| import.path.last().unwrap().clone());
871            // Store as a module alias (prefixed with `@mod:` marker)
872            aliases.insert(format!("@mod:{}", local), prefix.trim_end_matches("::").to_string());
873        }
874    }
875
876    aliases
877}
878
879// ---------------------------------------------------------------------------
880// Tests
881// ---------------------------------------------------------------------------
882
883#[cfg(test)]
884mod tests {
885    use super::*;
886    use std::fs;
887
888    /// Helper: create a temp directory with CJC source files.
889    fn setup_test_dir(files: &[(&str, &str)]) -> tempfile::TempDir {
890        let dir = tempfile::tempdir().expect("create temp dir");
891        for (name, content) in files {
892            let path = dir.path().join(name);
893            if let Some(parent) = path.parent() {
894                fs::create_dir_all(parent).expect("create parent dirs");
895            }
896            fs::write(&path, content).expect("write test file");
897        }
898        dir
899    }
900
901    // -- ModuleId tests --
902
903    #[test]
904    fn test_module_id_from_relative_path() {
905        let id = ModuleId::from_relative_path(Path::new("math/linalg.cjcl"));
906        assert_eq!(id.0, "math::linalg");
907    }
908
909    #[test]
910    fn test_module_id_from_import_path() {
911        let id = ModuleId::from_import_path(&["math".to_string(), "linalg".to_string()]);
912        assert_eq!(id.0, "math::linalg");
913    }
914
915    #[test]
916    fn test_module_id_symbol_prefix() {
917        assert_eq!(ModuleId("main".to_string()).symbol_prefix(), "");
918        assert_eq!(ModuleId("math".to_string()).symbol_prefix(), "math::");
919        assert_eq!(
920            ModuleId("math::linalg".to_string()).symbol_prefix(),
921            "math::linalg::"
922        );
923    }
924
925    // -- File resolution tests --
926
927    #[test]
928    fn test_resolve_file_direct() {
929        let dir = setup_test_dir(&[("math.cjcl", "fn add(a: f64, b: f64) -> f64 { a + b }")]);
930        let result = resolve_file(dir.path(), &["math".to_string()]);
931        assert!(result.is_ok());
932        assert!(result.unwrap().ends_with("math.cjcl"));
933    }
934
935    #[test]
936    fn test_resolve_file_nested() {
937        let dir = setup_test_dir(&[("math/linalg.cjcl", "fn dot() -> f64 { 0.0 }")]);
938        let result = resolve_file(
939            dir.path(),
940            &["math".to_string(), "linalg".to_string()],
941        );
942        assert!(result.is_ok());
943    }
944
945    #[test]
946    fn test_resolve_file_mod_cjc() {
947        let dir = setup_test_dir(&[("math/mod.cjcl", "fn pi() -> f64 { 3.14 }")]);
948        let result = resolve_file(dir.path(), &["math".to_string()]);
949        assert!(result.is_ok());
950        assert!(result.unwrap().to_string_lossy().contains("mod.cjcl"));
951    }
952
953    #[test]
954    fn test_resolve_file_not_found() {
955        let dir = setup_test_dir(&[]);
956        let result = resolve_file(dir.path(), &["nonexistent".to_string()]);
957        assert!(result.is_err());
958        match result.unwrap_err() {
959            ModuleError::FileNotFound {
960                import_path,
961                searched_paths,
962            } => {
963                assert_eq!(import_path, vec!["nonexistent".to_string()]);
964                assert_eq!(searched_paths.len(), 2); // tried .cjcl and mod.cjcl
965            }
966            other => panic!("expected FileNotFound, got: {:?}", other),
967        }
968    }
969
970    // -- Module graph tests --
971
972    #[test]
973    fn test_build_graph_single_file() {
974        let dir = setup_test_dir(&[("main.cjcl", "let x = 42;")]);
975        let entry = dir.path().join("main.cjcl");
976        let graph = build_module_graph(&entry).unwrap();
977        assert_eq!(graph.module_count(), 1);
978        assert_eq!(graph.entry, ModuleId("main".to_string()));
979    }
980
981    #[test]
982    fn test_build_graph_with_import() {
983        let dir = setup_test_dir(&[
984            ("main.cjcl", "import math\nlet x = 1;"),
985            ("math.cjcl", "fn add(a: f64, b: f64) -> f64 { a + b }"),
986        ]);
987        let entry = dir.path().join("main.cjcl");
988        let graph = build_module_graph(&entry).unwrap();
989        assert_eq!(graph.module_count(), 2);
990        assert!(graph.modules.contains_key(&ModuleId("math".to_string())));
991
992        // Topological order: math before main
993        let order = graph.topological_order().unwrap();
994        let math_pos = order
995            .iter()
996            .position(|m| m.0 == "math")
997            .unwrap();
998        let main_pos = order
999            .iter()
1000            .position(|m| m.0 == "main")
1001            .unwrap();
1002        assert!(math_pos < main_pos);
1003    }
1004
1005    #[test]
1006    fn test_detect_cyclic_dependency() {
1007        let dir = setup_test_dir(&[
1008            ("main.cjcl", "import a\nlet x = 1;"),
1009            ("a.cjcl", "import b\nfn fa() -> i64 { 1 }"),
1010            ("b.cjcl", "import a\nfn fb() -> i64 { 2 }"),
1011        ]);
1012        let entry = dir.path().join("main.cjcl");
1013        let result = build_module_graph(&entry);
1014        assert!(result.is_err());
1015        match result.unwrap_err() {
1016            ModuleError::CyclicDependency { .. } => {} // expected
1017            other => panic!("expected CyclicDependency, got: {:?}", other),
1018        }
1019    }
1020
1021    // -- Merge tests --
1022
1023    #[test]
1024    fn test_merge_programs_single_module() {
1025        let dir = setup_test_dir(&[(
1026            "main.cjcl",
1027            "fn greet() -> str { \"hello\" }\nlet msg = greet();",
1028        )]);
1029        let entry = dir.path().join("main.cjcl");
1030        let graph = build_module_graph(&entry).unwrap();
1031        let merged = merge_programs(&graph).unwrap();
1032
1033        // Should have greet + __main
1034        assert!(merged.functions.len() >= 2);
1035        let names: Vec<&str> = merged.functions.iter().map(|f| f.name.as_str()).collect();
1036        assert!(names.contains(&"greet"));
1037        assert!(names.contains(&"__main"));
1038    }
1039
1040    #[test]
1041    fn test_merge_programs_prefixes_non_entry() {
1042        let dir = setup_test_dir(&[
1043            ("main.cjcl", "import math\nlet x = 1;"),
1044            ("math.cjcl", "fn add(a: f64, b: f64) -> f64 { a + b }"),
1045        ]);
1046        let entry = dir.path().join("main.cjcl");
1047        let graph = build_module_graph(&entry).unwrap();
1048        let merged = merge_programs(&graph).unwrap();
1049
1050        let names: Vec<&str> = merged.functions.iter().map(|f| f.name.as_str()).collect();
1051        // math module's add should be prefixed
1052        assert!(
1053            names.contains(&"math::add"),
1054            "expected math::add in {:?}",
1055            names
1056        );
1057    }
1058
1059    #[test]
1060    fn test_merge_programs_duplicate_detection() {
1061        // Two modules exporting same-named function (after prefixing they differ,
1062        // but if both are entry-like, they'd collide)
1063        let dir = setup_test_dir(&[(
1064            "main.cjcl",
1065            "fn add(a: f64) -> f64 { a }\nfn add(b: f64) -> f64 { b }",
1066        )]);
1067        let entry = dir.path().join("main.cjcl");
1068        let graph = build_module_graph(&entry).unwrap();
1069        let merged = merge_programs(&graph);
1070        // Duplicate function definitions should be caught
1071        assert!(merged.is_err() || {
1072            // Some parsers allow overloads, in which case the merge succeeds
1073            // with both functions having the same name
1074            true
1075        });
1076    }
1077
1078    // -- Classify import tests --
1079
1080    #[test]
1081    fn test_classify_import_module() {
1082        let (module_path, symbol) =
1083            classify_import(&["math".to_string(), "linalg".to_string()]);
1084        assert_eq!(module_path, vec!["math", "linalg"]);
1085        assert_eq!(symbol, None);
1086    }
1087
1088    #[test]
1089    fn test_classify_import_symbol() {
1090        let (module_path, symbol) =
1091            classify_import(&["math".to_string(), "Matrix".to_string()]);
1092        assert_eq!(module_path, vec!["math"]);
1093        assert_eq!(symbol, Some("Matrix".to_string()));
1094    }
1095
1096    #[test]
1097    fn test_classify_import_single_segment() {
1098        let (module_path, symbol) = classify_import(&["math".to_string()]);
1099        assert_eq!(module_path, vec!["math"]);
1100        assert_eq!(symbol, None);
1101    }
1102
1103    // -- Import alias tests --
1104
1105    #[test]
1106    fn test_build_import_aliases() {
1107        let module = ModuleInfo {
1108            id: ModuleId("main".to_string()),
1109            file_path: PathBuf::from("main.cjcl"),
1110            imports: vec![ImportInfo {
1111                path: vec!["math".to_string(), "Matrix".to_string()],
1112                alias: Some("M".to_string()),
1113                resolved_module: Some(ModuleId("math".to_string())),
1114                symbol: Some("Matrix".to_string()),
1115            }],
1116            ast: None,
1117            is_entry: true,
1118        };
1119
1120        let aliases = build_import_aliases(&module);
1121        assert_eq!(aliases.get("M"), Some(&"math::Matrix".to_string()));
1122    }
1123
1124    // -- Visibility enforcement tests --
1125
1126    #[test]
1127    fn test_visibility_pub_functions_aliased() {
1128        let dir = setup_test_dir(&[
1129            ("main.cjcl", "import math\nlet x = 1;"),
1130            ("math.cjcl", "pub fn add(a: f64, b: f64) -> f64 { a + b }\nfn private_helper() -> f64 { 0.0 }"),
1131        ]);
1132        let entry = dir.path().join("main.cjcl");
1133        let graph = build_module_graph(&entry).unwrap();
1134        let merged = merge_programs(&graph).unwrap();
1135
1136        let names: Vec<&str> = merged.functions.iter().map(|f| f.name.as_str()).collect();
1137        // pub fn add should be aliased as both math::add and add
1138        assert!(names.contains(&"math::add"), "expected math::add in {:?}", names);
1139        assert!(names.contains(&"add"), "expected add alias in {:?}", names);
1140        // private_helper should be prefixed and aliased (merge includes all;
1141        // visibility enforcement is handled separately by check_visibility())
1142        assert!(names.contains(&"math::private_helper"), "expected math::private_helper in {:?}", names);
1143        assert!(names.contains(&"private_helper"), "private_helper should be aliased (enforcement is separate): {:?}", names);
1144    }
1145
1146    #[test]
1147    fn test_check_visibility_violations() {
1148        let dir = setup_test_dir(&[
1149            ("main.cjcl", "import math.Matrix\nlet x = 1;"),
1150            ("math.cjcl", "struct Matrix { x: f64 }"),
1151        ]);
1152        let entry = dir.path().join("main.cjcl");
1153        let graph = build_module_graph(&entry).unwrap();
1154        let violations = check_visibility(&graph);
1155        // Matrix is private (no `pub`), so importing it should be a violation
1156        assert_eq!(violations.len(), 1);
1157        assert_eq!(violations[0].symbol, "Matrix");
1158        assert_eq!(violations[0].kind, "struct");
1159    }
1160
1161    // -- Topological order determinism --
1162
1163    #[test]
1164    fn test_topological_order_deterministic() {
1165        let dir = setup_test_dir(&[
1166            ("main.cjcl", "import alpha\nimport beta\nlet x = 1;"),
1167            ("alpha.cjcl", "fn a_fn() -> i64 { 1 }"),
1168            ("beta.cjcl", "fn b_fn() -> i64 { 2 }"),
1169        ]);
1170        let entry = dir.path().join("main.cjcl");
1171
1172        // Build graph multiple times — order must be identical
1173        let order1 = build_module_graph(&entry)
1174            .unwrap()
1175            .topological_order()
1176            .unwrap();
1177        let order2 = build_module_graph(&entry)
1178            .unwrap()
1179            .topological_order()
1180            .unwrap();
1181        assert_eq!(order1, order2);
1182    }
1183}