//! 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::{ClassDef, Def, InstanceDef, Module, Type}; use crate::canonical; use crate::{load_module, Error as CoreError}; use std::collections::{BTreeMap, BTreeSet, 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. /// /// Iter 22b.1 (Decision 11): `registry` is the workspace-global /// instance registry, built at the end of [`load_workspace`] after the /// DFS over imports completes. It is empty for any workspace whose /// modules contain no [`crate::ast::Def::Instance`] defs. #[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, /// Iter 22b.1: workspace-global typeclass instance registry. pub registry: Registry, } /// Iter 22b.1: workspace-global instance registry (Decision 11). /// /// Built at the end of [`load_workspace`] after all modules are /// loaded. Keyed by `(class-name, canonical-type-hash)`; values are /// the matching [`crate::ast::InstanceDef`] plus the name of the /// module it was declared in. The hash key uses /// [`canonical::type_hash`], so the key is stable against unrelated /// whitespace / field-order differences in the source JSON. /// /// 22b.1 enforces three coherence checks during build: /// /// 1. **Coherence (orphan-freedom).** Every `instance C T` lives in /// the module of `C` or in the module of `T` (per Decision 11 /// axis 3). Otherwise → [`WorkspaceLoadError::OrphanInstance`]. /// 2. **Uniqueness.** No two entries share a key. Otherwise → /// [`WorkspaceLoadError::DuplicateInstance`]. /// 3. **Method completeness.** Each instance specifies a body for /// every required (non-default) method of its class. Otherwise → /// [`WorkspaceLoadError::MissingMethod`]. #[derive(Debug, Clone, Default)] pub struct Registry { /// Map from `(class-name, type-hash)` to the registry entry. pub entries: BTreeMap<(String, String), RegistryEntry>, } /// One entry in the [`Registry`]. #[derive(Debug, Clone)] pub struct RegistryEntry { /// The instance declaration itself. pub instance: InstanceDef, /// Name of the module the instance was declared in. pub defining_module: String, } /// 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 }, /// Iter 22b.1: an [`crate::ast::Def::Instance`] was declared in a /// module that is neither the class's defining module nor the /// instance type's defining module. Coherence violation per /// Decision 11 axis 3 ("orphan-freedom"). The lookup is hard: /// AILang does not provide a `--allow-orphans` flag. #[error( "orphan instance: `instance {class} {type_repr}` declared in module `{defining_module}`, \ but neither `{class}` (in `{class_module}`) nor `{type_repr}` (in `{type_module}`) lives there" )] OrphanInstance { class: String, type_repr: String, defining_module: String, class_module: String, type_module: String, }, /// Iter 22b.1: two [`crate::ast::Def::Instance`]s share the same /// `(class, canonical-type-hash)` key. Coherence requires /// uniqueness; the registry has no way to disambiguate at /// resolution time. Per Decision 11 there is no /// `AmbiguousInstance` diagnostic — coherence makes the lookup /// unambiguous by construction, and a duplicate is a workspace /// configuration error, not a per-call-site one. #[error( "duplicate instance: `instance {class} {type_repr}` declared in both `{first_module}` and `{second_module}`" )] DuplicateInstance { class: String, type_repr: String, first_module: String, second_module: String, }, /// Iter 22b.1: an [`crate::ast::Def::Instance`] does not specify /// a body for a required (non-default) method of its class. /// Default-bearing methods may be inherited; non-default /// (abstract-required) methods must be specified by every /// instance. Per Decision 11 §"Defaults and superclasses". #[error( "instance `{class} {type_repr}` is missing a body for method `{method}` (no default)" )] MissingMethod { class: String, type_repr: String, method: String, }, /// Iter 22b.2: class-schema validation. The class parameter /// appears in applied position (e.g. as the head of a /// `Type::Con { name == param, args.len() > 0 }`) inside a method /// signature. Decision 11 axis 5 forbids HKTs — class params are /// kind `*` only. #[error( "kind mismatch in class `{class}`: parameter `{param}` is used in applied position \ inside method `{method}` (Decision 11 axis 5: class params are kind `*` only)" )] KindMismatch { class: String, param: String, method: String, defining_module: String, }, /// Iter 22b.2: class-schema validation. A class's `superclass.type` /// does not equal its own `param`. Decision 11 single-superclass /// model requires the superclass to be applied to the same param /// (e.g. `class Ord a extends Eq a`, not `extends Eq b`). #[error( "class `{class}` declares superclass `{superclass} {got_type}`, but its own parameter is `{expected_param}` \ — superclass `type` must equal class `param`" )] InvalidSuperclassParam { class: String, superclass: String, expected_param: String, got_type: String, }, /// Iter 22b.2: class-schema validation. A class method's /// signature contains a constraint referencing a type variable /// that is neither bound by the method's `Forall.vars` nor equal /// to the class's `param`. #[error( "in class `{class}` method `{method}`: constraint `{constraint_class} {var}` references unbound type variable `{var}`" )] UnboundConstraintTypeVar { class: String, method: String, constraint_class: String, var: String, }, /// Iter 22b.2: an instance specifies a body for a method name /// that the corresponding class does not declare. Symmetric to /// `MissingMethod` but in the opposite direction. #[error( "instance `{class} {type_repr}` provides body for method `{method}`, but class `{class}` does not declare it" )] OverridingNonExistentMethod { class: String, type_repr: String, method: String, }, /// Iter 22b.2: a class-method name collides with another /// class-method or with a top-level fn. `kind` is /// `"class-class"` or `"class-fn"`. #[error( "method name `{method}` collides ({kind}): defined in `{first_origin}` and `{second_origin}`" )] MethodNameCollision { method: String, kind: &'static str, first_origin: String, second_origin: String, }, /// Iter 22b.2: an instance `C T` was declared, but `C`'s /// superclass `S` does not have an instance for the same type /// `T`. Decision 11 single-superclass model requires `instance S /// T` to exist whenever `instance C T` exists. #[error( "instance `{class} {type_repr}` requires superclass instance `{superclass} {type_repr}`, but none was found" )] MissingSuperclassInstance { class: String, superclass: String, type_repr: 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, )?; // Iter 22b.2: class-schema validation runs before registry // construction so that ill-kinded class definitions are rejected // before any instance against them is registered. validate_classdefs(&modules)?; // Iter 22b.1: build the workspace-global instance registry. Three // coherence checks fire here (Orphan / Duplicate / Missing-method); // any violation is surfaced as a `WorkspaceLoadError`, not as a // per-call-site diagnostic. let registry = build_registry(&modules)?; Ok(Workspace { entry: entry_name, modules, root_dir, registry, }) } /// Iter 22b.1: build the workspace-global typeclass instance registry. /// /// Two passes: /// /// 1. Scan every loaded module to build a "where is X defined" lookup /// for class names and type names — needed for the coherence /// (orphan) check below. /// 2. For each [`crate::ast::Def::Instance`] in declaration order, /// apply the three checks (coherence / uniqueness / method /// completeness) and insert the entry on success. /// /// Iteration is over the modules `BTreeMap` (alphabetical by name) so /// the order in which collisions are detected is deterministic across /// runs. Within a module, defs are scanned in source order. fn build_registry( modules: &BTreeMap, ) -> Result { // Pass 1: collect "where is X defined" maps, plus a class lookup // by name (needed for the method-completeness check). let mut class_def_module: BTreeMap = BTreeMap::new(); let mut type_def_module: BTreeMap = BTreeMap::new(); let mut class_by_name: BTreeMap = BTreeMap::new(); for (mod_name, m) in modules { for def in &m.defs { match def { Def::Class(c) => { class_def_module.insert(c.name.clone(), mod_name.clone()); class_by_name.insert(c.name.clone(), c); } Def::Type(t) => { type_def_module.insert(t.name.clone(), mod_name.clone()); } _ => {} } } } // Iter 22b.2: method-name-collision pre-pass. Bare-name resolution // (`foo x` rather than `A.foo x`) requires that a method name // appears in at most one origin across the whole workspace. We // walk every `Def::Class` and `Def::Fn` once, building a // `method_origins` map; the first repeat fires the diagnostic. // The `kind` field distinguishes class-method ↔ class-method from // class-method ↔ top-level-fn; fn-fn collisions are a separate // concern surfaced by `CheckError::DuplicateDef` in `ailang-check`, // not here. // // Origins are kept structural (the `Origin` enum below) so the // `kind` discriminator is a `match` on variants rather than a // string-prefix check on a display form. enum Origin { Class { class_name: String, module: String }, Fn { name: String, module: String }, } impl Origin { fn format(&self) -> String { match self { Origin::Class { class_name, module } => { format!("class {class_name} (in {module})") } Origin::Fn { name, module } => format!("fn {name} (in {module})"), } } } let mut method_origins: BTreeMap = BTreeMap::new(); for (mod_name, m) in modules { for def in &m.defs { match def { Def::Class(c) => { for method in &c.methods { let origin = Origin::Class { class_name: c.name.clone(), module: mod_name.clone(), }; if let Some(prior) = method_origins.get(&method.name) { let kind = match prior { Origin::Class { .. } => "class-class", Origin::Fn { .. } => "class-fn", }; return Err(WorkspaceLoadError::MethodNameCollision { method: method.name.clone(), kind, first_origin: prior.format(), second_origin: origin.format(), }); } method_origins.insert(method.name.clone(), origin); } } Def::Fn(f) => { let origin = Origin::Fn { name: f.name.clone(), module: mod_name.clone(), }; if let Some(prior) = method_origins.get(&f.name) { // Only fire on class-fn collisions here. fn-fn // collisions (two `Def::Fn` with the same name) // are `CheckError::DuplicateDef`'s job in // `ailang-check`; firing here with `kind: // "class-fn"` would misreport. if matches!(prior, Origin::Class { .. }) { return Err(WorkspaceLoadError::MethodNameCollision { method: f.name.clone(), kind: "class-fn", first_origin: prior.format(), second_origin: origin.format(), }); } // prior is a fn: skip; do not overwrite. } else { method_origins.insert(f.name.clone(), origin); } } _ => {} } } } // Pass 2: register each instance, with coherence / uniqueness / // method-completeness checks. let mut entries: BTreeMap<(String, String), RegistryEntry> = BTreeMap::new(); for (mod_name, m) in modules { for def in &m.defs { if let Def::Instance(inst) = def { let type_repr = type_head_name(&inst.type_); // Coherence (orphan-freedom): instance's module must // equal the class's module or the type's module. A // class declared inside the same module as the // instance always satisfies the first leg; a // user-defined type declared in the instance's module // satisfies the second. Primitives have no // user-defined module — instances on primitives // therefore must live in the class's module. let class_mod = class_def_module .get(&inst.class) .cloned() .unwrap_or_else(|| "".into()); let type_mod = type_def_module .get(&type_repr) .cloned() .unwrap_or_else(|| "".into()); let coherent = mod_name == &class_mod || mod_name == &type_mod; if !coherent { return Err(WorkspaceLoadError::OrphanInstance { class: inst.class.clone(), type_repr, defining_module: mod_name.clone(), class_module: class_mod, type_module: type_mod, }); } // Uniqueness: a `(class, type-hash)` key must appear // at most once across the whole workspace. let type_hash = canonical::type_hash(&inst.type_); let key = (inst.class.clone(), type_hash); if let Some(prior) = entries.get(&key) { return Err(WorkspaceLoadError::DuplicateInstance { class: inst.class.clone(), type_repr, first_module: prior.defining_module.clone(), second_module: mod_name.clone(), }); } // Method completeness: every non-default method of // the class must have a body in this instance. // Defaults may be inherited (no body required). // Missing class declaration is deferred to 22b.2's // typecheck arms — for 22b.1 we skip the // completeness check rather than firing a separate // diagnostic. if let Some(class_def) = class_by_name.get(&inst.class) { let provided: BTreeSet<&str> = inst.methods.iter().map(|m| m.name.as_str()).collect(); for class_method in &class_def.methods { if class_method.default.is_none() && !provided.contains(class_method.name.as_str()) { return Err(WorkspaceLoadError::MissingMethod { class: inst.class.clone(), type_repr, method: class_method.name.clone(), }); } } // Symmetric to MissingMethod: an instance must // not specify a body for a method name the class // never declared. Decision 11 forbids ad-hoc // additions to a class's method set at the // instance site. let declared: BTreeSet<&str> = class_def.methods.iter().map(|m| m.name.as_str()).collect(); for inst_method in &inst.methods { if !declared.contains(inst_method.name.as_str()) { return Err(WorkspaceLoadError::OverridingNonExistentMethod { class: inst.class.clone(), type_repr: type_repr.clone(), method: inst_method.name.clone(), }); } } } entries.insert( key, RegistryEntry { instance: inst.clone(), defining_module: mod_name.clone(), }, ); } } } // Iter 22b.2: superclass-instance completeness. For every entry, // walk the class's superclass chain and require an entry for each // step at the same type-hash. for (key, entry) in entries.iter() { let (class_name, type_hash) = key; let type_repr = type_head_name(&entry.instance.type_); // Iter 22b.2 leaves superclass-cycle detection to a future arm; // here we just terminate the walk. let mut visited: BTreeSet<&str> = BTreeSet::new(); let mut current = class_by_name.get(class_name.as_str()).copied(); while let Some(c) = current { if !visited.insert(c.name.as_str()) { break; } if let Some(sc) = &c.superclass { let sc_key = (sc.class.clone(), type_hash.clone()); if !entries.contains_key(&sc_key) { return Err(WorkspaceLoadError::MissingSuperclassInstance { class: class_name.clone(), superclass: sc.class.clone(), type_repr: type_repr.clone(), }); } current = class_by_name.get(sc.class.as_str()).copied(); } else { break; } } } Ok(Registry { entries }) } /// Iter 22b.2: class-schema validation. Runs before `build_registry`. /// Three diagnostics fire from here: `kind-mismatch`, /// `invalid-superclass-param`, `constraint-references-unbound-type-var`. fn validate_classdefs( modules: &BTreeMap, ) -> Result<(), WorkspaceLoadError> { for (mod_name, m) in modules { for def in &m.defs { if let Def::Class(c) = def { for method in &c.methods { walk_kind_mismatch(&method.ty, &c.param) .map_err(|()| WorkspaceLoadError::KindMismatch { class: c.name.clone(), param: c.param.clone(), method: method.name.clone(), defining_module: mod_name.clone(), })?; } if let Some(sc) = &c.superclass { if sc.type_ != c.param { return Err(WorkspaceLoadError::InvalidSuperclassParam { class: c.name.clone(), superclass: sc.class.clone(), expected_param: c.param.clone(), got_type: sc.type_.clone(), }); } } for method in &c.methods { if let Type::Forall { vars, constraints, .. } = &method.ty { let mut bound: BTreeSet<&str> = vars.iter().map(String::as_str).collect(); bound.insert(c.param.as_str()); for constr in constraints { if let Type::Var { name } = &constr.type_ { if !bound.contains(name.as_str()) { return Err(WorkspaceLoadError::UnboundConstraintTypeVar { class: c.name.clone(), method: method.name.clone(), constraint_class: constr.class.clone(), var: name.clone(), }); } } } } } } } } Ok(()) } /// Walks a `Type` looking for any `Type::Con { name == param, args /// non-empty }`. The class param is kind `*`; appearing as a /// constructor with arguments is a kind-mismatch. fn walk_kind_mismatch(t: &Type, param: &str) -> Result<(), ()> { match t { Type::Con { name, args } => { if name == param && !args.is_empty() { return Err(()); } for a in args { walk_kind_mismatch(a, param)?; } Ok(()) } Type::Fn { params, ret, .. } => { for p in params { walk_kind_mismatch(p, param)?; } walk_kind_mismatch(ret, param) } Type::Forall { body, .. } => walk_kind_mismatch(body, param), Type::Var { .. } => Ok(()), } } /// Iter 22b.1: extract the head-constructor name of a type, for the /// "where is this type defined" lookup and for diagnostic-message /// rendering. /// /// For `Type::Con { name, .. }` (the only legal head shape for a /// non-orphan instance) returns `name`. Other variants (`Var`, /// `Forall`, `Fn`) are not legal as instance heads — Decision 11 /// requires a concrete type expression at the instance head — so we /// emit a stable fallback string. The fallback prevents diagnostic /// rendering from panicking on a malformed fixture; the "real" /// rejection of non-concrete instance heads will arrive as a /// schema-validation diagnostic in 22b.2. fn type_head_name(t: &Type) -> String { match t { Type::Con { name, .. } => name.clone(), Type::Var { name } => format!(""), Type::Forall { .. } => "".into(), Type::Fn { .. } => "".into(), } } 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:?}"), } } /// Iter 22b.1: a workspace whose modules contain no /// `Def::Instance` defs has an empty registry. This is the /// happy-path baseline for the registry-build pass — every /// pre-22b workspace falls into this case. #[test] fn iter22b1_workspace_with_no_classes_has_empty_registry() { 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("sum.ail.json"); let ws = load_workspace(&entry).expect("sum.ail.json loads"); assert!( ws.registry.entries.is_empty(), "pre-22b fixture has no class/instance defs, registry must be empty" ); } fn examples_dir() -> PathBuf { let manifest_dir = env!("CARGO_MANIFEST_DIR"); Path::new(manifest_dir) .parent() .unwrap() .parent() .unwrap() .join("examples") } /// Iter 22b.1: a coherent instance (in the class's module) /// loads cleanly and produces one registry entry. #[test] fn iter22b1_instance_in_class_module_loads_clean() { let entry = examples_dir().join("test_22b1_orphan_class.ail.json"); let ws = load_workspace(&entry).expect("coherent instance loads"); assert_eq!(ws.registry.entries.len(), 1); let (key, entry) = ws.registry.entries.iter().next().unwrap(); assert_eq!(&key.0, "Show"); assert_eq!(entry.defining_module, "test_22b1_orphan_class"); assert_eq!(entry.instance.class, "Show"); } /// Iter 22b.1: an instance declared in a module that is neither /// the class's module nor the type's module fires `OrphanInstance`. /// The fixture imports `test_22b1_orphan_third_classmod` (which /// owns `class Show`) and the entry module declares `instance /// Show Int` itself — but the entry is not the class's module /// and `Int` is primitive, so neither leg of coherence is /// satisfied. #[test] fn iter22b1_orphan_instance_fires_diagnostic() { let entry = examples_dir().join("test_22b1_orphan_third.ail.json"); let err = load_workspace(&entry).expect_err("must fire orphan"); match err { WorkspaceLoadError::OrphanInstance { class, type_repr, defining_module, .. } => { assert_eq!(class, "Show"); assert_eq!(type_repr, "Int"); assert_eq!(defining_module, "test_22b1_orphan_third"); } other => panic!("expected OrphanInstance, got {other:?}"), } } /// Iter 22b.1: two instances of the same `(class, type)` pair /// declared from coherent positions (one in the class's module, /// one in the type's module) collide on the registry's /// uniqueness check. Setup per the JOURNAL hint: /// - module A defines `class Show` and declares /// `instance Show MyInt` (legal, A is class's module). /// - module B defines `type MyInt` and declares /// `instance Show MyInt` (legal, B is type's module). /// - entry module imports both A and B. /// The build_registry pass sees both instances under the same /// `(Show, hash-of-MyInt)` key and fires `DuplicateInstance`. #[test] fn iter22b1_duplicate_instance_fires_diagnostic() { let entry = examples_dir().join("test_22b1_dup_entry.ail.json"); let err = load_workspace(&entry).expect_err("must fire duplicate"); match err { WorkspaceLoadError::DuplicateInstance { class, type_repr, first_module, second_module, } => { assert_eq!(class, "Show"); assert_eq!(type_repr, "MyInt"); assert_ne!(first_module, second_module); let modules: BTreeSet<&str> = [first_module.as_str(), second_module.as_str()].iter().copied().collect(); assert!(modules.contains("test_22b1_dup_a")); assert!(modules.contains("test_22b1_dup_b")); } other => panic!("expected DuplicateInstance, got {other:?}"), } } /// Iter 22b.1: an instance that omits a required (non-default) /// method of its class fires `MissingMethod`. The fixture's /// `class Eq` declares `eq` and `ne` as both non-default; the /// instance only specifies `ne`, leaving `eq` missing. #[test] fn iter22b1_missing_method_fires_diagnostic() { let entry = examples_dir().join("test_22b1_missing_method.ail.json"); let err = load_workspace(&entry).expect_err("must fire missing-method"); match err { WorkspaceLoadError::MissingMethod { class, type_repr, method, } => { assert_eq!(class, "Eq"); assert_eq!(type_repr, "Int"); assert_eq!(method, "eq"); } other => panic!("expected MissingMethod, got {other:?}"), } } /// Iter 22b.2: a class whose parameter `f` appears in applied /// position (`Type::Con { name == "f", args.len() > 0 }`) inside /// a method signature must fire `KindMismatch`. Decision 11 axis /// 5 forbids HKTs — class params are kind `*` only, never /// constructors. #[test] fn class_param_in_applied_position_fires_kind_mismatch() { let entry = std::path::PathBuf::from( "../../examples/test_22b2_kind_mismatch.ail.json", ); let err = load_workspace(&entry) .expect_err("must fire kind-mismatch"); match err { WorkspaceLoadError::KindMismatch { class, param, method, .. } => { assert_eq!(class, "Functor"); assert_eq!(param, "f"); assert_eq!(method, "fmap"); } other => panic!("expected KindMismatch, got {other:?}"), } } /// Iter 22b.2: a class whose `superclass.type` differs from its /// own `param` (e.g. `class Ord a extends Eq b`) must fire /// `InvalidSuperclassParam`. Decision 11 axis 1 ("single /// superclass, applied to the same param") makes the only legal /// shape `extends Eq a` when the parent class has `param: "a"`. #[test] fn superclass_with_wrong_param_fires_invalid_superclass_param() { let entry = std::path::PathBuf::from( "../../examples/test_22b2_invalid_superclass_param.ail.json", ); let err = load_workspace(&entry) .expect_err("must fire invalid-superclass-param"); match err { WorkspaceLoadError::InvalidSuperclassParam { class, superclass, expected_param, got_type, } => { assert_eq!(class, "Ord"); assert_eq!(superclass, "Eq"); assert_eq!(expected_param, "a"); assert_eq!(got_type, "b"); } other => panic!("expected InvalidSuperclassParam, got {other:?}"), } } /// Iter 22b.2: an instance that specifies a body for a method /// name the class never declared must fire /// `OverridingNonExistentMethod`. Symmetric counterpart to /// `MissingMethod`: the latter fires when the class declares a /// non-default method that the instance omits; this one fires /// when the instance provides a body the class did not ask for. /// Decision 11 forbids ad-hoc additions to a class's method set /// at the instance site. #[test] fn instance_overriding_nonexistent_method_fires() { let entry = std::path::PathBuf::from( "../../examples/test_22b2_overriding_nonexistent.ail.json", ); let err = load_workspace(&entry) .expect_err("must fire overriding-non-existent-method"); match err { WorkspaceLoadError::OverridingNonExistentMethod { class, type_repr, method, } => { assert_eq!(class, "Eq"); assert_eq!(type_repr, "Int"); assert_eq!(method, "ne"); } other => panic!("expected OverridingNonExistentMethod, got {other:?}"), } } /// Iter 22b.2: a class method's `Type::Forall` whose `constraints` /// reference a type variable that is neither bound by `Forall.vars` /// nor equal to the class's `param` must fire /// `UnboundConstraintTypeVar`. The fixture's `class Foo a` declares /// method `foo` with `forall a. (Bar z) => a -> Unit`; `z` is /// bound nowhere, so the constraint is unsatisfiable by /// construction. #[test] fn constraint_with_unbound_var_fires_unbound_constraint_type_var() { let entry = std::path::PathBuf::from( "../../examples/test_22b2_unbound_constraint_var.ail.json", ); let err = load_workspace(&entry) .expect_err("must fire constraint-references-unbound-type-var"); match err { WorkspaceLoadError::UnboundConstraintTypeVar { class, method, var, .. } => { assert_eq!(class, "Foo"); assert_eq!(method, "foo"); assert_eq!(var, "z"); } other => panic!("expected UnboundConstraintTypeVar, got {other:?}"), } } /// Iter 22b.2: two classes that declare a method with the same /// name must fire `MethodNameCollision` with `kind == "class-class"`. /// Decision 11 keeps method names workspace-unique so that bare /// method-name resolution (`foo x` rather than `A.foo x`) is /// unambiguous; if two classes both export `foo`, the registry has /// no way to choose between them at a use site. #[test] fn class_class_method_name_collision_fires() { let entry = examples_dir() .join("test_22b2_method_name_collision_class_class.ail.json"); let err = load_workspace(&entry) .expect_err("must fire method-name-collision"); match err { WorkspaceLoadError::MethodNameCollision { method, kind, first_origin, second_origin, } => { assert_eq!(method, "foo"); assert_eq!(kind, "class-class"); assert!( first_origin.starts_with("class A"), "first_origin = {first_origin:?}", ); assert!( second_origin.starts_with("class B"), "second_origin = {second_origin:?}", ); } other => panic!("expected MethodNameCollision, got {other:?}"), } } /// Iter 22b.2: a class-method name that collides with a top-level /// `fn` of the same name must fire `MethodNameCollision` with /// `kind == "class-fn"`. Same rationale as the class-class case: /// bare-name resolution must be unambiguous, and a class method /// shadowing (or being shadowed by) a free function silently is /// the worst possible failure mode. #[test] fn class_fn_method_name_collision_fires() { let entry = examples_dir() .join("test_22b2_method_name_collision_class_fn.ail.json"); let err = load_workspace(&entry) .expect_err("must fire method-name-collision"); match err { WorkspaceLoadError::MethodNameCollision { method, kind, first_origin, second_origin, } => { assert_eq!(method, "greet"); assert_eq!(kind, "class-fn"); assert!( first_origin.starts_with("class Greet"), "first_origin = {first_origin:?}", ); assert!( second_origin.starts_with("fn greet"), "second_origin = {second_origin:?}", ); } other => panic!("expected MethodNameCollision, got {other:?}"), } } /// Iter 22b.2: an instance `C T` whose class `C` declares a /// superclass `S` requires that `instance S T` also exist in the /// workspace. The fixture declares `class Eq a`, `class Ord a /// extends Eq a`, and `instance Ord Int` — but no `instance Eq /// Int` — so registry build must fire /// `MissingSuperclassInstance`. Decision 11 single-superclass /// model requires `instance S T` whenever `instance C T` exists. #[test] fn instance_without_superclass_instance_fires() { let entry = examples_dir().join("test_22b2_missing_superclass_instance.ail.json"); let err = load_workspace(&entry) .expect_err("must fire missing-superclass-instance"); match err { WorkspaceLoadError::MissingSuperclassInstance { class, superclass, type_repr, } => { assert_eq!(class, "Ord"); assert_eq!(superclass, "Eq"); assert_eq!(type_repr, "Int"); } other => panic!("expected MissingSuperclassInstance, got {other:?}"), } } }