//! Workspace loader: loads an entry module and recursively follows its //! `imports`. //! //! Convention: an `import { module: "foo" }` is resolved relative to //! the entry file's directory as `/foo.ail.json`. Module //! names must match the file stem (the loader rejects mismatches). //! //! The single entry point is [`load_workspace`]; it returns a fully //! populated [`Workspace`] or a structured [`WorkspaceLoadError`]. //! [`module_hash`] is the module-granularity counterpart to //! [`crate::def_hash`] and is used both internally (to detect a module //! re-loaded from disk with different content) and by the CLI. //! //! This module is responsible only for **finding** and consistently //! **loading** all reachable modules. Cross-module typechecking lives //! in `ailang-check`; codegen in `ailang-codegen`. Neither is run from //! here. //! //! # Examples //! //! ```ignore //! use ailang_core::load_workspace; //! use std::path::Path; //! //! let ws = load_workspace(Path::new("examples/ws_main.ail.json"))?; //! assert_eq!(ws.entry, "ws_main"); //! // All transitively imported modules are now in `ws.modules`. //! for (name, m) in &ws.modules { //! println!("{name}: {} defs", m.defs.len()); //! } //! # Ok::<(), ailang_core::workspace::WorkspaceLoadError>(()) //! ``` use crate::ast::Module; use crate::canonical; use crate::{load_module, Error as CoreError}; use std::collections::{BTreeMap, HashSet}; use std::path::{Path, PathBuf}; /// Fully loaded workspace. /// /// `entry` names the entry module (module name, **not** a path). All /// transitively reachable modules are contained in `modules` and indexed /// by `Module.name`. `root_dir` is the directory the entry file lives /// in; all imports are resolved relative to it. #[derive(Debug, Clone)] pub struct Workspace { /// Name of the entry module (the one passed to [`load_workspace`]). pub entry: String, /// Every module reachable from `entry`, indexed by module name. /// `BTreeMap` is used so iteration order is deterministic, which /// matters for downstream codegen and reporting. pub modules: BTreeMap, /// Directory the entry file lives in; all imports are resolved /// relative to it. pub root_dir: PathBuf, } /// Structured errors of the workspace loader. /// /// `Cycle.path` is the chain of module names in which the cycle was /// closed — the last element is the name already present in `visiting`. #[derive(Debug, thiserror::Error)] pub enum WorkspaceLoadError { /// File I/O failed while reading a module file (typically: file /// missing, permission denied). #[error("io error for {path}: {source}")] Io { path: PathBuf, #[source] source: std::io::Error, }, /// File contents failed to parse as a [`Module`] or had the wrong /// schema tag. Wraps a [`CoreError`] (the single-module loader's /// error type). #[error("schema/parse error in {path}: {source}")] Schema { path: PathBuf, #[source] source: CoreError, }, /// An `import { module: "foo" }` did not resolve to an existing /// file at the expected path. #[error("module `{name}` not found (expected at {expected_path})")] ModuleNotFound { name: String, expected_path: PathBuf }, /// The `name` field in a loaded module file disagrees with the /// file-stem-derived name the loader expected (i.e. the /// `.ail.json` convention is broken). #[error( "module name in file ({name_in_file:?}) does not match expected name from path ({name_from_path:?})" )] ModuleNameMismatch { name_in_file: String, name_from_path: String, }, /// An import cycle was detected. `path` is the chain of module /// names in which the cycle was closed — the last element is the /// name that was already on the visit stack. #[error("import cycle detected: {}", path.join(" -> "))] Cycle { path: Vec }, /// A module was reachable through two import paths, and its /// on-disk content (compared via [`module_hash`]) differs between /// the two reads. This typically means the file changed mid-load. #[error( "module `{name}` was loaded twice with differing content (hashes differ)" )] ModuleHashMismatch { name: String }, } /// Hash over the canonical bytes of a complete module. /// /// Parallel to `def_hash`, but at module level. The workspace loader /// uses this to verify double-loads; the CLI uses it to emit a stable /// per-module identifier. pub fn module_hash(m: &Module) -> String { let bytes = canonical::to_bytes(m); let h = blake3::hash(&bytes); h.to_hex().as_str()[..16].to_string() } /// Load entry module plus all transitively reachable modules. /// /// Algorithm: DFS over `imports`, with two sets: /// - `loaded` (= `modules` map): modules whose subtree is already fully /// processed. On a re-hit only hash consistency is checked. /// - `visiting`: stack of modules whose DFS descent is still running. /// A hit here = cycle. pub fn load_workspace(entry_path: &Path) -> Result { let entry_path = entry_path.to_path_buf(); let root_dir = entry_path .parent() .map(Path::to_path_buf) .unwrap_or_else(|| PathBuf::from(".")); // Load entry module + check name<->filename convention. let entry_module = load_one(&entry_path)?; let expected_entry_name = module_name_from_path(&entry_path); if entry_module.name != expected_entry_name { return Err(WorkspaceLoadError::ModuleNameMismatch { name_in_file: entry_module.name.clone(), name_from_path: expected_entry_name, }); } let entry_name = entry_module.name.clone(); let mut modules: BTreeMap = BTreeMap::new(); let mut visiting: Vec = Vec::new(); let mut visiting_set: HashSet = HashSet::new(); visit( entry_module, &root_dir, &mut modules, &mut visiting, &mut visiting_set, )?; Ok(Workspace { entry: entry_name, modules, root_dir, }) } fn visit( module: Module, root_dir: &Path, modules: &mut BTreeMap, visiting: &mut Vec, visiting_set: &mut HashSet, ) -> Result<(), WorkspaceLoadError> { let name = module.name.clone(); // Already fully loaded? Then do nothing. Hash consistency is checked // on re-encounter via imports (see below in the loop). if modules.contains_key(&name) { return Ok(()); } // Cycle: same name currently on the DFS stack. if visiting_set.contains(&name) { let mut path = visiting.clone(); path.push(name); return Err(WorkspaceLoadError::Cycle { path }); } visiting.push(name.clone()); visiting_set.insert(name.clone()); // Process imports recursively. let imports = module.imports.clone(); for imp in &imports { let imp_path = root_dir.join(format!("{}.ail.json", imp.module)); if let Some(existing) = modules.get(&imp.module) { // Already fully loaded — check hash consistency, in case the // file on disk has changed. let on_disk = match load_one(&imp_path) { Ok(m) => m, Err(WorkspaceLoadError::Io { .. }) => continue, Err(e) => return Err(e), }; if module_hash(existing) != module_hash(&on_disk) { return Err(WorkspaceLoadError::ModuleHashMismatch { name: imp.module.clone(), }); } continue; } if visiting_set.contains(&imp.module) { let mut path = visiting.clone(); path.push(imp.module.clone()); return Err(WorkspaceLoadError::Cycle { path }); } if !imp_path.exists() { return Err(WorkspaceLoadError::ModuleNotFound { name: imp.module.clone(), expected_path: imp_path, }); } let imported = load_one(&imp_path)?; if imported.name != imp.module { return Err(WorkspaceLoadError::ModuleNameMismatch { name_in_file: imported.name, name_from_path: imp.module.clone(), }); } visit(imported, root_dir, modules, visiting, visiting_set)?; } visiting.pop(); visiting_set.remove(&name); modules.insert(name, module); Ok(()) } fn load_one(path: &Path) -> Result { match load_module(path) { Ok(m) => Ok(m), Err(CoreError::Io(e)) => Err(WorkspaceLoadError::Io { path: path.to_path_buf(), source: e, }), Err(e) => Err(WorkspaceLoadError::Schema { path: path.to_path_buf(), source: e, }), } } fn module_name_from_path(p: &Path) -> String { let file = p.file_name().and_then(|s| s.to_str()).unwrap_or(""); // Convention: `.ail.json`. if let Some(stripped) = file.strip_suffix(".ail.json") { return stripped.to_string(); } // Fallback: strip only the last extension. Path::new(file) .file_stem() .and_then(|s| s.to_str()) .unwrap_or(file) .to_string() } #[cfg(test)] mod tests { use super::*; use std::fs; fn write_module(dir: &Path, name: &str, imports: &[&str]) -> PathBuf { let imports_json: Vec = imports .iter() .map(|m| serde_json::json!({ "module": m })) .collect(); let module = serde_json::json!({ "schema": crate::SCHEMA, "name": name, "imports": imports_json, "defs": [], }); let path = dir.join(format!("{name}.ail.json")); fs::write(&path, serde_json::to_vec_pretty(&module).unwrap()).unwrap(); path } fn tmp_dir(tag: &str) -> PathBuf { let d = std::env::temp_dir().join(format!( "ailang_workspace_test_{tag}_{}", std::process::id() )); let _ = fs::remove_dir_all(&d); fs::create_dir_all(&d).unwrap(); d } #[test] fn loads_example_workspace_happy_path() { // Uses the canonical example files under `examples/`. This // test documents that the loader relies on the committed // workspace example. let manifest_dir = env!("CARGO_MANIFEST_DIR"); let workspace_root = Path::new(manifest_dir).parent().unwrap().parent().unwrap(); let entry = workspace_root.join("examples").join("ws_main.ail.json"); let ws = load_workspace(&entry).expect("load workspace"); assert_eq!(ws.entry, "ws_main"); assert!(ws.modules.contains_key("ws_main")); assert!(ws.modules.contains_key("ws_lib")); assert_eq!(ws.modules.len(), 2); } #[test] fn detects_import_cycle() { let dir = tmp_dir("cycle"); write_module(&dir, "a", &["b"]); write_module(&dir, "b", &["a"]); let entry = dir.join("a.ail.json"); let err = load_workspace(&entry).expect_err("must error on cycle"); match err { WorkspaceLoadError::Cycle { path } => { assert!(path.contains(&"a".to_string())); assert!(path.contains(&"b".to_string())); } other => panic!("expected Cycle, got {other:?}"), } } #[test] fn module_not_found_yields_structured_error() { let dir = tmp_dir("notfound"); write_module(&dir, "main", &["does_not_exist"]); let entry = dir.join("main.ail.json"); let err = load_workspace(&entry).expect_err("must error on missing module"); match err { WorkspaceLoadError::ModuleNotFound { name, expected_path } => { assert_eq!(name, "does_not_exist"); assert!(expected_path.ends_with("does_not_exist.ail.json")); } other => panic!("expected ModuleNotFound, got {other:?}"), } } }