//! Workspace loading and per-workspace validation. //! //! Two phases, exposed as separate public fns so callers can compose //! them around an injection point (e.g. surface's prelude inject): //! //! - [`load_modules_with`] — DFS over `imports`, returns a modules //! map with no validation and no implicit modules. The caller //! supplies a per-module loader. //! - [`build_workspace`] — runs the three-stage validation pipeline //! (`validate_canonical_type_names`, `validate_classdefs`, //! `build_registry`) plus [`Workspace`] assembly. Takes //! `implicit_imports: &[&str]` so the diagnostic helpers can name //! modules that exist but are not in any user import list. //! //! The public composition lives in `ailang-surface`'s `load_workspace` //! (which adds a prelude inject step between the two phases). Direct //! consumers of these core fns are `ailang-surface` and a handful of //! tests; CLI dispatch goes through surface. //! //! This module is responsible only for **finding**, **assembling**, //! and **schema-validating** the module graph. Cross-module //! typechecking lives in `ailang-check`. Cross-module monomorphisation //! and codegen live in `ailang-codegen`. //! //! [`module_hash`] is the module-granularity counterpart to //! [`crate::def_hash`]; used internally to detect a re-loaded module //! with different content, and by the CLI for stable per-module //! identifiers. use crate::ast::{ClassDef, Def, InstanceDef, Module, NewArg, Term, Type}; use crate::canonical; use crate::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. /// /// `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, /// workspace-global typeclass instance registry. pub registry: Registry, } /// workspace-global instance registry (the typeclass design). /// /// 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 the typeclass design /// 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>, /// workspace-wide map from user-defined type-name to its /// defining module. Used by [`Self::normalize_type_for_lookup`] to /// rewrite a bare `Type::Con.name` to its always-qualified form /// before computing the registry key. Primitives are not present. /// Populated in `build_registry` from the same scan that builds /// the entry map. /// /// Keyed by `(owning_module, bare_name)`, value is the /// defining module. The tuple key disambiguates same-named /// types declared in different modules — bare `Foo` from /// module M is `(M, "Foo")`, bare `Foo` from module N is /// `(N, "Foo")`, and the two carry distinct canonical /// qualifications under /// `normalize_type_for_registry`. Pre-ctt.2 the key was /// the bare name alone, and a workspace with two `type Foo` /// declarations silently overwrote one entry, then tripped /// `DuplicateInstance` on the loser-side instance after /// both qualified to `.Foo`. pub type_def_module: BTreeMap<(String, String), String>, } impl Registry { /// the canonical-form normalisation step: produce the canonical form of `t` for /// registry-key hashing. Bare-non-primitive `Type::Con` names /// get qualified to `.`; already-qualified /// names stay; bare names whose defining module is unknown stay /// as-is. `Type::Fn`/`Type::Forall`/`Type::Var` recurse / pass /// through structurally. /// /// `caller_module` is the module in whose scope `t` was authored. /// Bare-name lookups are keyed by `(caller_module, name)`, so a /// bare `Foo` written in module M resolves only to M's `Foo`, /// never to a same-named type from another module. /// /// Every consumer that hashes an `inst.type_`-shaped expression /// to look it up in [`Self::entries`] must funnel through this /// helper, otherwise the registered-form and the queried-form /// disagree on whether the leading qualifier is present. pub fn normalize_type_for_lookup(&self, caller_module: &str, t: &Type) -> Type { normalize_type_for_registry(caller_module, t, &self.type_def_module) } } /// 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, }, /// Source file failed to parse via the surface crate. /// /// Used when a `.ail` (Form A) input is given to a path-taking /// subcommand. The message is the formatted `ailang_surface::ParseError` /// — held as a `String` here because pulling the surface error type /// into core would create a circular crate dependency. #[error("surface parse error in {path}: {message}")] SurfaceParse { path: std::path::PathBuf, message: String, }, /// 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 }, /// 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 /// the orphan-freedom axis of the typeclass design. 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, }, /// 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 the typeclass design 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, }, /// 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 the typeclass design §"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, }, /// class-schema validation. A class's `superclass.type` /// does not equal its own `param`. the typeclass design's 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, }, /// 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, }, /// 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, }, /// an instance `C T` was declared, but `C`'s /// superclass `S` does not have an instance for the same type /// `T`. the typeclass design's 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, }, /// A user workspace contains a module whose name collides with /// a built-in kernel-tier module that the loader auto-injects. /// The current built-in set is `prelude` and `raw_buf` (the /// `raw_buf` base extension is injected unconditionally in all /// builds; future base extensions may add more). /// Previously this variant fired specifically for `prelude`; /// since prep.3 of the kernel-extension-mechanics milestone, it /// fires for any built-in kernel module name. #[error("workspace module `{name}` collides with a built-in kernel-tier module name (reserved)")] ReservedModuleName { name: String }, /// the canonical-form rule for type references: a `Type::Con` whose `name` is /// neither a primitive nor a local TypeDef of the owning module /// was encountered. Under the canonical-form rule, bare = /// local; a bare cross-module ref is a schema violation. /// `candidates` lists the qualified forms found by scanning the /// owning module's imports in declaration order. #[error( "module `{module}` references bare type `{name}`, which is not in scope. \ Add `(import )` to bring it into scope (candidates: {candidates:?}), \ or rewrite the call to type-scoped form `.`." )] BareCrossModuleTypeRef { module: String, name: String, candidates: Vec, }, /// a qualified `Type::Con` of the form `.` /// was encountered, but `` is not a known module in the /// workspace, or `` is known but declares no TypeDef /// named ``. #[error( "module `{module}` references qualified type `{name}` but the owner module \ is not known in the workspace, or it declares no type by that name. \ Use the bare type-name from an imported module instead." )] BadCrossModuleTypeRef { module: String, name: String, }, /// the canonical-form rule for class references: a class-reference field /// (`InstanceDef.class`, `Constraint.class`, or /// `SuperclassRef.class`) carries a bare name that does not resolve /// to a local class of the owning module. Under the canonical-form /// rule extended for class references, bare = local-class-of-owning-module; a /// bare cross-module class reference is a schema violation. /// `candidates` lists the qualified forms found by scanning the /// owning module's imports for matching class declarations. #[error( "module `{module}` contains bare class name `{name}` that does not resolve to a local class. \ AILang's `.ail.json` requires cross-module class references to be qualified. \ Candidates from imports: {candidates:?}." )] BareCrossModuleClassRef { module: String, name: String, candidates: Vec, }, /// the canonical-form rule for class references: a qualified class reference of /// the form `.` was encountered, but `` is /// not a known module in the workspace, or `` is known /// but declares no class by that name. Sibling of /// `BadCrossModuleTypeRef` for class references. #[error( "module `{module}` references qualified class `{name}` but the owner module is not known \ or does not declare a class by that name" )] BadCrossModuleClassRef { module: String, name: String, }, /// a class-reference field (`InstanceDef.class`, /// `SuperclassRef.class`, `Constraint.class`, or /// `ClassDef.name`) contains a `.` — under this milestone class /// names are NOT module-qualified (see DESIGN spec §"Out of /// scope: Class names"). The schema rejects qualified forms so /// half-migrated files cannot silently load. /// /// narrowed to `ClassDef.name` only; the three other /// fields now follow the canonical-form rule and use /// `BareCrossModuleClassRef` / `BadCrossModuleClassRef`. #[error( "module `{module}` contains qualified class name `{name}` in field `{field}`. \ Class names are not module-qualified at the defining site; \ keep the bare form for `ClassDef.name`." )] QualifiedClassName { module: String, name: String, field: &'static str, }, } /// 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() } /// pd.1: extract the DFS pre-amble of `load_workspace_with` into a public /// loader-only fn. Returns `(entry_name, root_dir, modules)` with NO prelude /// injected and NO validation run. The caller is responsible for injecting /// any implicit modules (e.g. prelude) and then handing the result to /// `build_workspace` for validation + registry construction. /// /// Surface composes this with a prelude-injection step in pd.2; pd.1 keeps /// the shim `load_workspace_with` callable so surface is unchanged. /// /// 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_modules_with( entry_path: &Path, loader: F, ) -> Result<(String, PathBuf, BTreeMap), WorkspaceLoadError> where F: Fn(&Path) -> Result + Copy, { let entry_path = entry_path.to_path_buf(); let root_dir = entry_path .parent() .map(Path::to_path_buf) .unwrap_or_else(|| PathBuf::from(".")); let entry_module = loader(&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, loader, )?; Ok((entry_name, root_dir, modules)) } /// pd.1: extract the validation + registry-construction tail of /// `load_workspace_with` into a public fn that operates on a pre-assembled /// modules map. The caller (typically `ailang_surface::loader::load_workspace`) /// is responsible for injecting any implicit modules (e.g. prelude) into /// `modules` BEFORE calling this fn, AND for naming those implicit modules /// in `implicit_imports` so the diagnostic helpers (`check_class_ref`, /// `check_type_con_name`) include them as fallback candidates. /// /// `implicit_imports` is the list of module names every consumer /// should auto-import without an explicit `(import …)` declaration. /// The caller derives this list from the kernel-tier filter: every /// loaded module with `kernel: true` enters the list. Since prep.3 /// of the kernel-extension-mechanics milestone, this is the flag- /// driven generalisation of the prior hardcoded `&["prelude"]` /// literal. /// /// Runs the three-stage pipeline: /// 1. `validate_canonical_type_names` (with `implicit_imports`) /// 2. `validate_classdefs` (no `implicit_imports` — does not consume it) /// 3. `build_registry` pub fn build_workspace( entry_name: String, root_dir: PathBuf, modules: BTreeMap, implicit_imports: &[&str], ) -> Result { validate_canonical_type_names(&modules, implicit_imports)?; validate_classdefs(&modules)?; let registry = build_registry(&modules)?; Ok(Workspace { entry: entry_name, modules, root_dir, registry, }) } // pd.1: `load_workspace_with` shim retired in pd.2. The public entry // point is now `ailang_surface::load_workspace`, which composes // `load_modules_with` + caller-side prelude inject + `build_workspace` // directly. Surface owns the prelude inject step (the embed lives at // `ailang_surface::PRELUDE_AIL` + `ailang_surface::parse_prelude`); core // exposes the two-phase composition. /// take a class-ref field value (`InstanceDef.class`, /// `SuperclassRef.class`, `Constraint.class`) and a defining context, /// and produce the qualified workspace key. Bare ⇒ prepend the /// `caller_module` argument; qualified ⇒ as-is. Symmetric to the canonical-form rule's /// `normalize_type_for_registry` for `Type::Con`. /// /// For an `InstanceDef.class` or `Constraint.class` field the caller /// is the defining module of the owning def. For a `SuperclassRef.class` /// the caller is the parent class's defining module. fn qualify_class_ref(class_ref: &str, caller_module: &str) -> String { if class_ref.contains('.') { class_ref.to_string() } else { format!("{caller_module}.{class_ref}") } } /// 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). // // `class_def_module` and `class_by_name` are keyed by the // qualified class name `.`. All consumers // (Pass-2 coherence lookup, method-completeness check, superclass // walk, etc.) query with `qualify_class_ref` applied to the field // value so bare same-module and qualified cross-module refs both // resolve to the same key. let mut class_def_module: BTreeMap = BTreeMap::new(); let mut type_def_module: BTreeMap<(String, String), String> = 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) => { let qualified = format!("{mod_name}.{}", c.name); class_def_module.insert(qualified.clone(), mod_name.clone()); class_by_name.insert(qualified, c); } Def::Type(t) => { type_def_module.insert( (mod_name.clone(), t.name.clone()), mod_name.clone(), ); } _ => {} } } } // the `MethodNameCollision` pre-pass (variant + Origin enum + // per-def loop) was retired here. Bare-method resolution no longer // requires workspace-wide method-name uniqueness — synth's // `Term::Var` arm consults `Env.method_to_candidate_classes` // (built workspace-flat in `ailang-check`) and runs the type-driven // dispatch rule, with `AmbiguousMethodResolution` / // `class-method-shadowed-by-fn` as the per-call-site outcomes. // See the type-driven-dispatch decision-record for the rationale. // 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. // // lookup keys are qualified // (`.`); the `inst.class` // field is the canonical-form value (bare or qualified) // which `qualify_class_ref` lifts to the workspace key. let inst_class_key = qualify_class_ref(&inst.class, mod_name); let class_mod = class_def_module .get(&inst_class_key) .cloned() .unwrap_or_else(|| "".into()); // type-leg lookup must accept the canonical form // for the head name. Bare head ⇒ key by // (caller_module, head); qualified `.` ⇒ // key by (owner, bare) directly (the owner IS the // defining module under the canonical-form rule). let type_mod = if let Some((owner, bare)) = type_repr.split_once('.') { type_def_module .get(&(owner.to_string(), bare.to_string())) .cloned() .unwrap_or_else(|| "".into()) } else { type_def_module .get(&(mod_name.clone(), type_repr.clone())) .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. The // type expression is normalised to its always-qualified // form before hashing so a bare-local declaration in // the type's defining module and a qualified-cross-module // declaration from elsewhere produce the same key (both // refer to the same type under the canonical-form rule). // // registry key is keyed by the qualified class // form too, so a bare same-module instance and a // qualified cross-module instance on the same // (class, type) collide on this check. let type_hash = canonical::type_hash( &normalize_type_for_registry( mod_name, &inst.type_, &type_def_module, ), ); let key = (inst_class_key.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_key) { 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. The typeclass design 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(), }, ); } } } // superclass-instance completeness. For every entry, // walk the class's superclass chain and require an entry for each // step at the same type-hash. // // `class_name` (the entries key first half) is the qualified // class name; the superclass-ref field `sc.class` is canonical-form, // which `qualify_class_ref` lifts to the qualified key — using the // parent class's defining module as the caller context (the // superclass declaration lives inside the parent class's module). for (key, entry) in entries.iter() { let (class_name, type_hash) = key; let type_repr = type_head_name(&entry.instance.type_); // Superclass-cycle detection is left 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(); let mut current_class_module = class_def_module .get(class_name.as_str()) .cloned() .unwrap_or_default(); while let Some(c) = current { if !visited.insert(c.name.as_str()) { break; } if let Some(sc) = &c.superclass { let sc_class_key = qualify_class_ref(&sc.class, ¤t_class_module); let sc_key = (sc_class_key.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(), }); } // walk lookup uses the qualified key; the next // step's owning-module context is the superclass's // defining module (read from `class_def_module`). current = class_by_name.get(sc_class_key.as_str()).copied(); current_class_module = class_def_module .get(&sc_class_key) .cloned() .unwrap_or_default(); } else { break; } } } Ok(Registry { entries, type_def_module, }) } /// class-schema validation. Runs before `build_registry`. /// Two diagnostics fire from here: `invalid-superclass-param`, /// `constraint-references-unbound-type-var`. The `kind-mismatch` /// diagnostic was retired at ctt.3 — the malformed shape now /// fires `BareCrossModuleTypeRef` from the canonical-form /// validator before `validate_classdefs` runs. fn validate_classdefs( modules: &BTreeMap, ) -> Result<(), WorkspaceLoadError> { for m in modules.values() { for def in &m.defs { if let Def::Class(c) = def { 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(()) } /// the five primitive type names that are always bare under /// the canonical-form rule. Kept in sync with `Type::int`, /// `Type::bool_`, `Type::str_`, `Type::unit`, `Type::float` in /// `crate::ast`. fn is_primitive_type_name(name: &str) -> bool { matches!(name, "Int" | "Bool" | "Str" | "Unit" | "Float") } /// normalize a `Type` by qualifying every bare-non-primitive /// `Type::Con.name` to its always-qualified form /// (`.`). Primitives stay bare; already-qualified /// names stay as-is; bare names whose defining module is unknown stay /// as-is (downstream diagnostics will catch them). `Type::Fn` / /// `Type::Forall` / `Type::Var` recurse / pass through structurally. /// /// Used by [`build_registry`] for registry-key canonicalisation so the /// duplicate-instance check survives the asymmetric "bare = local, /// qualified = cross-module" canonical-form rule: an instance declared /// bare-local in the type's defining module and an equivalent one /// declared qualified-cross-module from elsewhere produce the same /// registry key. /// /// The qualifier is the type's *defining* module (looked up in /// `type_def_module`), not the instance's owning module. The two /// coincide under a canonical-form-compliant workspace (bare implies /// local-to-caller-module), but using the defining-module lookup is /// robust against pre-`validate_canonical_type_names`-wired fixtures /// that may still carry bare cross-module refs. /// /// ctt.2: bare-name lookups are keyed by `(caller_module, name)`, /// not by `name` alone. A bare `Foo` written from module M resolves /// to M's `Foo` only; same-named types in other modules are /// distinct entries under their own caller-keyed tuple. fn normalize_type_for_registry( caller_module: &str, t: &Type, type_def_module: &BTreeMap<(String, String), String>, ) -> Type { match t { Type::Con { name, args } => { let new_name = if name.contains('.') || is_primitive_type_name(name) { name.clone() } else if let Some(owner) = type_def_module.get(&(caller_module.to_string(), name.clone())) { format!("{owner}.{name}") } else { // Unknown bare non-primitive — leave as-is. Either it is // a class-param Type::Var miscoded as a Con (which is a // separate well-formedness problem) or a stale ref that // downstream diagnostics will catch. name.clone() }; Type::Con { name: new_name, args: args .iter() .map(|a| normalize_type_for_registry(caller_module, a, type_def_module)) .collect(), } } Type::Fn { params, param_modes, ret, ret_mode, effects } => Type::Fn { params: params .iter() .map(|p| normalize_type_for_registry(caller_module, p, type_def_module)) .collect(), param_modes: param_modes.clone(), ret: Box::new(normalize_type_for_registry(caller_module, ret, type_def_module)), ret_mode: *ret_mode, effects: effects.clone(), }, Type::Forall { vars, constraints, body } => Type::Forall { vars: vars.clone(), constraints: constraints .iter() .map(|c| crate::ast::Constraint { class: c.class.clone(), type_: normalize_type_for_registry(caller_module, &c.type_, type_def_module), }) .collect(), body: Box::new(normalize_type_for_registry(caller_module, body, type_def_module)), }, Type::Var { name } => Type::Var { name: name.clone() }, } } /// enforce the canonical-form rule on every `Type::Con` and /// `Term::Ctor.type_name` reference in every loaded module. Runs /// after prelude injection and before class-schema validation, so a /// stale bare cross-module ref fires the canonical-form diagnostic /// rather than a downstream one. /// /// Rule per spec §Architecture: /// 1. Qualified `.`: `` must be a known module, /// `` must be one of its TypeDefs. Else `BadCrossModuleTypeRef`. /// 2. Bare primitive (`Int`/`Bool`/`Str`/`Unit`/`Float`): accepted. /// 3. Bare non-primitive: must be a TypeDef in the owning module. /// Else `BareCrossModuleTypeRef` with `candidates` = qualified /// forms found by scanning the owning module's imports. pub(crate) fn validate_canonical_type_names( modules: &BTreeMap, implicit_imports: &[&str], ) -> Result<(), WorkspaceLoadError> { // Pre-pass: for each module, build a `BTreeSet` of local // TypeDef names. Used for both the owning-module local lookup // and the per-import owner lookup. let mut local_types: BTreeMap> = BTreeMap::new(); for (mod_name, m) in modules { let mut s = BTreeSet::new(); for def in &m.defs { if let Def::Type(t) = def { s.insert(t.name.clone()); } } local_types.insert(mod_name.clone(), s); } // symmetric pre-pass over `Def::Class` names. The map is // module → bare-class-name set; used by `check_class_ref` to apply // the canonical-form rule to the three migrated class-ref fields // (`InstanceDef.class`, `Constraint.class`, `SuperclassRef.class`). // `ClassDef.name` stays bare per spec and never queries this map. let mut local_classes: BTreeMap> = BTreeMap::new(); for (mod_name, m) in modules { let mut s = BTreeSet::new(); for def in &m.defs { if let Def::Class(c) = def { s.insert(c.name.clone()); } } local_classes.insert(mod_name.clone(), s); } for (mod_name, m) in modules { // Imports in declaration order — used for both the // qualified-`` known-module check and the bare-non-primitive // candidates list. let import_names: Vec<&str> = m.imports.iter().map(|i| i.module.as_str()).collect(); // Walk every Type in this module's defs and check each Type::Con name. for def in &m.defs { walk_def_types(def, &mut |t: &Type| { if let Type::Con { name, .. } = t { check_type_con_name( name, mod_name, &local_types, &import_names, implicit_imports, )?; } Ok(()) })?; walk_def_terms(def, &mut |type_name: &str| { check_type_con_name( type_name, mod_name, &local_types, &import_names, implicit_imports, ) })?; check_class_name_fields(def, mod_name)?; // apply the canonical-form rule to the three // migrated class-ref fields. match def { Def::Instance(id) => { check_class_ref(&id.class, mod_name, &local_classes, &import_names, implicit_imports)?; } Def::Class(cd) => { if let Some(sc) = &cd.superclass { check_class_ref(&sc.class, mod_name, &local_classes, &import_names, implicit_imports)?; } for cmth in &cd.methods { if let Type::Forall { constraints, .. } = &cmth.ty { for c in constraints { check_class_ref(&c.class, mod_name, &local_classes, &import_names, implicit_imports)?; } } } } Def::Fn(fd) => { if let Type::Forall { constraints, .. } = &fd.ty { for c in constraints { check_class_ref(&c.class, mod_name, &local_classes, &import_names, implicit_imports)?; } } } _ => {} } } } Ok(()) } /// apply the canonical-form rule to a class-reference field /// value (`InstanceDef.class`, `Constraint.class`, or /// `SuperclassRef.class`). Sibling of `check_type_con_name` for /// class refs. /// /// Three rules, symmetric to the type-ref rule: /// 1. Qualified `.`: `` must be a known module /// that declares the class. Else `BadCrossModuleClassRef`. /// 2. Bare same-module-class: `` is in the owning module's /// `Def::Class` set. Accepted. /// 3. Bare cross-module: fire `BareCrossModuleClassRef` with /// `candidates` = qualified forms found by scanning the owning /// module's imports (plus implicit `prelude` if not already /// imported, mirroring `check_type_con_name`). fn check_class_ref( class_ref: &str, owning_module: &str, local_classes: &BTreeMap>, import_names: &[&str], implicit_imports: &[&str], ) -> Result<(), WorkspaceLoadError> { if let Some((owner, bare)) = class_ref.split_once('.') { // Rule 1: qualified. let owner_classes = local_classes .get(owner) .ok_or_else(|| WorkspaceLoadError::BadCrossModuleClassRef { module: owning_module.to_string(), name: class_ref.to_string(), })?; if !owner_classes.contains(bare) { return Err(WorkspaceLoadError::BadCrossModuleClassRef { module: owning_module.to_string(), name: class_ref.to_string(), }); } return Ok(()); } // Bare: must be local to the owning module. if local_classes .get(owning_module) .map(|s| s.contains(class_ref)) .unwrap_or(false) { return Ok(()); } // Bare cross-module: collect qualified candidates from imports in // declaration order; add implicit `prelude` last if not already an // import (mirrors `check_type_con_name`). let mut candidates: Vec = Vec::new(); for imp in import_names { if local_classes .get(*imp) .map(|s| s.contains(class_ref)) .unwrap_or(false) { candidates.push(format!("{imp}.{class_ref}")); } } for implicit in implicit_imports { if import_names.contains(implicit) { continue; // already added above } if local_classes .get(*implicit) .map(|s| s.contains(class_ref)) .unwrap_or(false) { candidates.push(format!("{implicit}.{class_ref}")); } } Err(WorkspaceLoadError::BareCrossModuleClassRef { module: owning_module.to_string(), name: class_ref.to_string(), candidates, }) } /// apply the canonical-form rule to one `Type::Con.name` /// (also reused for `Term::Ctor.type_name` in Task 2). fn check_type_con_name( name: &str, owning_module: &str, local_types: &BTreeMap>, import_names: &[&str], implicit_imports: &[&str], ) -> Result<(), WorkspaceLoadError> { if let Some((prefix, suffix)) = name.split_once('.') { // Rule 1: qualified. let owner_types = local_types .get(prefix) .ok_or_else(|| WorkspaceLoadError::BadCrossModuleTypeRef { module: owning_module.to_string(), name: name.to_string(), })?; if !owner_types.contains(suffix) { return Err(WorkspaceLoadError::BadCrossModuleTypeRef { module: owning_module.to_string(), name: name.to_string(), }); } return Ok(()); } // Bare. if is_primitive_type_name(name) { return Ok(()); // Rule 2. } // Rule 3: must be local. if local_types .get(owning_module) .map(|s| s.contains(name)) .unwrap_or(false) { return Ok(()); } // prep.1: a bare cross-module type-name is ACCEPTED if it is in // scope via an explicit import or an implicit (prelude / kernel- // tier) auto-import. Candidates from those paths drive both // acceptance and (on miss) the error message's suggested fix. let mut in_scope = false; let mut candidates: Vec = Vec::new(); for imp in import_names { if local_types .get(*imp) .map(|s| s.contains(name)) .unwrap_or(false) { in_scope = true; candidates.push(format!("{imp}.{name}")); } } for implicit in implicit_imports { if import_names.contains(implicit) { continue; // already added above } if local_types .get(*implicit) .map(|s| s.contains(name)) .unwrap_or(false) { in_scope = true; candidates.push(format!("{implicit}.{name}")); } } if in_scope { return Ok(()); } Err(WorkspaceLoadError::BareCrossModuleTypeRef { module: owning_module.to_string(), name: name.to_string(), candidates, }) } /// walk every `Type` reachable from a single `Def`, calling /// `f` on each. Recurses into `Type::Fn.params/ret`, `Type::Con.args`, /// `Type::Forall.constraints/body`, plus the obvious top-level fields /// of each `Def` variant AND every Type annotation embedded in a Term /// (`Term::Lam.param_tys`, `Term::Lam.ret_ty`, `Term::LetRec.ty`) — /// because those are Type-position occurrences too. Term::Ctor name /// walking is a separate concern handled in Task 2 since that field /// is a `String`, not a `Type`. fn walk_def_types(def: &Def, f: &mut F) -> Result<(), WorkspaceLoadError> where F: FnMut(&Type) -> Result<(), WorkspaceLoadError>, { match def { Def::Fn(fd) => { walk_type(&fd.ty, f)?; walk_term_embedded_types(&fd.body, f) } Def::Const(cd) => { walk_type(&cd.ty, f)?; walk_term_embedded_types(&cd.value, f) } Def::Type(td) => { for c in &td.ctors { for fty in &c.fields { walk_type(fty, f)?; } } Ok(()) } Def::Class(cd) => { for cm in &cd.methods { walk_type(&cm.ty, f)?; if let Some(body) = &cm.default { walk_term_embedded_types(body, f)?; } } Ok(()) } Def::Instance(id) => { walk_type(&id.type_, f)?; for im in &id.methods { walk_term_embedded_types(&im.body, f)?; } Ok(()) } } } /// walk a `Term` and call `f` on every Type annotation embedded /// in it (Lam.param_tys, Lam.ret_ty, LetRec.ty). Recurses through /// every Term sub-position. Does NOT fire on `Term::Ctor.type_name` /// (that's a `String`, not a `Type`; handled by `walk_def_terms` in /// Task 2). fn walk_term_embedded_types(t: &Term, f: &mut F) -> Result<(), WorkspaceLoadError> where F: FnMut(&Type) -> Result<(), WorkspaceLoadError>, { match t { Term::Lit { .. } | Term::Var { .. } => Ok(()), Term::App { callee, args, .. } => { walk_term_embedded_types(callee, f)?; for a in args { walk_term_embedded_types(a, f)?; } Ok(()) } Term::Let { value, body, .. } => { walk_term_embedded_types(value, f)?; walk_term_embedded_types(body, f) } Term::LetRec { ty, body, in_term, .. } => { walk_type(ty, f)?; walk_term_embedded_types(body, f)?; walk_term_embedded_types(in_term, f) } Term::If { cond, then, else_ } => { walk_term_embedded_types(cond, f)?; walk_term_embedded_types(then, f)?; walk_term_embedded_types(else_, f) } Term::Do { args, .. } => { for a in args { walk_term_embedded_types(a, f)?; } Ok(()) } Term::Ctor { args, .. } => { for a in args { walk_term_embedded_types(a, f)?; } Ok(()) } Term::Match { scrutinee, arms } => { walk_term_embedded_types(scrutinee, f)?; for arm in arms { walk_term_embedded_types(&arm.body, f)?; } Ok(()) } Term::Lam { param_tys, ret_ty, body, .. } => { for pt in param_tys { walk_type(pt, f)?; } walk_type(ret_ty, f)?; walk_term_embedded_types(body, f) } Term::Seq { lhs, rhs } => { walk_term_embedded_types(lhs, f)?; walk_term_embedded_types(rhs, f) } Term::Clone { value } => walk_term_embedded_types(value, f), Term::ReuseAs { source, body } => { walk_term_embedded_types(source, f)?; walk_term_embedded_types(body, f) } Term::Loop { binders, body } => { for b in binders { walk_type(&b.ty, f)?; walk_term_embedded_types(&b.init, f)?; } walk_term_embedded_types(body, f) } Term::Recur { args } => { for a in args { walk_term_embedded_types(a, f)?; } Ok(()) } Term::New { args, .. } => { for arg in args { match arg { NewArg::Type(t) => walk_type(t, f)?, NewArg::Value(v) => walk_term_embedded_types(v, f)?, } } Ok(()) } Term::Intrinsic => Ok(()), } } /// recursive walk of a `Type`, calling `f` at every node. fn walk_type(t: &Type, f: &mut F) -> Result<(), WorkspaceLoadError> where F: FnMut(&Type) -> Result<(), WorkspaceLoadError>, { f(t)?; match t { Type::Con { args, .. } => { for a in args { walk_type(a, f)?; } Ok(()) } Type::Fn { params, ret, .. } => { for p in params { walk_type(p, f)?; } walk_type(ret, f) } Type::Forall { body, constraints, .. } => { for c in constraints { walk_type(&c.type_, f)?; } walk_type(body, f) } Type::Var { .. } => Ok(()), } } /// walk every `Term::Ctor.type_name` reachable from a single /// `Def`, calling `f` on the type_name strings. Used to enforce the /// canonical-form rule on term-side type references. fn walk_def_terms(def: &Def, f: &mut F) -> Result<(), WorkspaceLoadError> where F: FnMut(&str) -> Result<(), WorkspaceLoadError>, { match def { Def::Fn(fd) => walk_term(&fd.body, f), Def::Const(cd) => walk_term(&cd.value, f), Def::Type(_) => Ok(()), Def::Class(cd) => { for cm in &cd.methods { if let Some(body) = &cm.default { walk_term(body, f)?; } } Ok(()) } Def::Instance(id) => { for im in &id.methods { walk_term(&im.body, f)?; } Ok(()) } } } /// recursive walk of a `Term`, calling `f` on every /// `Term::Ctor.type_name`. Embedded `Type` annotations (Lam param / /// return types, LetRec types) ride the `walk_def_types` / /// `walk_term_embedded_types` path — but `Term::Ctor.type_name` is /// a `String`, not a `Type`, so it lives here. fn walk_term(t: &Term, f: &mut F) -> Result<(), WorkspaceLoadError> where F: FnMut(&str) -> Result<(), WorkspaceLoadError>, { match t { Term::Lit { .. } | Term::Var { .. } => Ok(()), Term::App { callee, args, .. } => { walk_term(callee, f)?; for a in args { walk_term(a, f)?; } Ok(()) } Term::Let { value, body, .. } => { walk_term(value, f)?; walk_term(body, f) } Term::LetRec { body, in_term, .. } => { walk_term(body, f)?; walk_term(in_term, f) } Term::If { cond, then, else_ } => { walk_term(cond, f)?; walk_term(then, f)?; walk_term(else_, f) } Term::Do { args, .. } => { for a in args { walk_term(a, f)?; } Ok(()) } Term::Ctor { type_name, args, .. } => { f(type_name)?; for a in args { walk_term(a, f)?; } Ok(()) } Term::Match { scrutinee, arms } => { walk_term(scrutinee, f)?; for arm in arms { walk_pattern(&arm.pat, f)?; walk_term(&arm.body, f)?; } Ok(()) } Term::Lam { body, .. } => walk_term(body, f), Term::Seq { lhs, rhs } => { walk_term(lhs, f)?; walk_term(rhs, f) } Term::Clone { value } => walk_term(value, f), Term::ReuseAs { source, body } => { walk_term(source, f)?; walk_term(body, f) } Term::Loop { binders, body } => { for b in binders { walk_term(&b.init, f)?; } walk_term(body, f) } Term::Recur { args } => { for a in args { walk_term(a, f)?; } Ok(()) } Term::New { type_name, args } => { f(type_name)?; for arg in args { if let NewArg::Value(v) = arg { walk_term(v, f)?; } } Ok(()) } Term::Intrinsic => Ok(()), } } /// Pattern::Ctor carries a `ctor` name (matches against a /// scrutinee's TypeDef) but NOT a type_name field — the type is /// inferred from the scrutinee. So Pattern walking only recurses; /// no canonical-form check fires here. `f` is kept in the signature /// to match the sibling `walk_*` framework (uniform plumbing). #[allow(clippy::only_used_in_recursion)] fn walk_pattern(p: &crate::ast::Pattern, f: &mut F) -> Result<(), WorkspaceLoadError> where F: FnMut(&str) -> Result<(), WorkspaceLoadError>, { use crate::ast::Pattern; match p { Pattern::Wild | Pattern::Var { .. } | Pattern::Lit { .. } => Ok(()), Pattern::Ctor { fields, .. } => { for sub in fields { walk_pattern(sub, f)?; } Ok(()) } } } /// reject any `.` in `ClassDef.name`. The defining-site name is /// bare by convention (symmetric to `TypeDef.name`); a qualified form /// indicates a malformed file. /// /// narrowed from the four class-reference fields to /// `ClassDef.name` only. The three referencing fields /// (`InstanceDef.class`, `Constraint.class`, `SuperclassRef.class`) /// now follow the canonical-form rule and are validated by /// `check_class_ref` (fires `BareCrossModuleClassRef` / /// `BadCrossModuleClassRef`). fn check_class_name_fields( def: &Def, owning_module: &str, ) -> Result<(), WorkspaceLoadError> { if let Def::Class(cd) = def { if cd.name.contains('.') { return Err(WorkspaceLoadError::QualifiedClassName { module: owning_module.to_string(), name: cd.name.clone(), field: "ClassDef.name", }); } } Ok(()) } /// 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 — the typeclass /// design 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, loader: F, ) -> Result<(), WorkspaceLoadError> where F: Fn(&Path) -> Result + Copy, { 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 { // ext-cli.1: prefer a sibling `.ail` (Form A) over // `.ail.json` (Form B). The injected `loader` is // responsible for reading whichever extension wins. let imp_path = { let surface_path = root_dir.join(format!("{}.ail", imp.module)); let json_path = root_dir.join(format!("{}.ail.json", imp.module)); if surface_path.is_file() { surface_path } else { json_path } }; 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 loader(&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 = loader(&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, loader)?; } visiting.pop(); visiting_set.remove(&name); modules.insert(name, module); Ok(()) } // pd.2: `load_one` deleted along with the `load_workspace_with` shim. // The two surviving in-mod tests that need a JSON-only single-module // loader (`load_modules_with_returns_modules_without_prelude` and // `load_modules_with_custom_loader_is_called`) inline the // `crate::load_module` adapter directly. 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; /// pd.2: tests that need a JSON-only single-module loader (the /// pre-pd.2 `load_one` shape) inline this adapter. Wraps /// `crate::load_module` and converts the `Error` variants into /// `WorkspaceLoadError`. Used by the pd.1-introduced tests that /// exercise `load_modules_with` directly. fn load_one(path: &Path) -> Result { match crate::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 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 } // `loads_example_workspace_happy_path` + `loads_workspace_auto_injects_prelude` // relocated to `crates/ailang-core/tests/workspace_pin.rs` in iter // form-a.1 Task 5 (use `ailang_surface::load_workspace` on `.ail` // fixtures; the original `.ail.json` fixtures are deleted in T8). // (See workspace_pin.rs relocation marker above.) // pd.2: `user_module_named_prelude_is_rejected`, // `detects_import_cycle`, and `module_not_found_yields_structured_error` // relocated to `crates/ailang-core/tests/workspace_pin.rs` (the // ailang-surface dev-dep cycle disallows `surface::load_workspace` // calls from this in-mod test crate). // a workspace whose own modules contain no // `Def::Instance` defs produces no non-prelude registry entries. // This is the happy-path baseline for the registry-build pass — // every pre-22b workspace falls into this case. With the // auto-loaded prelude (iter 23.2) the registry is no longer // strictly empty, so the invariant is now "no entries whose // `defining_module` is anything other than `prelude`". // `iter22b1_workspace_with_no_classes_has_empty_registry` relocated // to `crates/ailang-core/tests/workspace_pin.rs` in iter form-a.1 Task 5. fn examples_dir() -> PathBuf { let manifest_dir = env!("CARGO_MANIFEST_DIR"); Path::new(manifest_dir) .parent() .unwrap() .parent() .unwrap() .join("examples") } // a coherent instance (in the class's module) // loads cleanly and produces one registry entry from the // fixture itself. With the auto-loaded prelude (iter 23.2) // the registry also contains the prelude's own entries; the // invariant is filtered to fixture-only entries. // `iter22b1_instance_in_class_module_loads_clean` relocated to // `crates/ailang-core/tests/workspace_pin.rs` in iter form-a.1 Task 5. // 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 TShow`) and the entry module declares `instance // TShow Int` itself — but the entry is not the class's module // and `Int` is primitive, so neither leg of coherence is // satisfied. (24.2: class renamed `Show` → `TShow`.) // `iter22b1_orphan_instance_fires_diagnostic` relocated to // `crates/ailang-core/tests/workspace_pin.rs` in iter form-a.1 Task 5. // Two instances of the same `(class, type)` pair collide on the // registry's uniqueness check. // // Pre-canonical-class-form the test used a two-module fixture // (`test_22b1_dup_a` declared the class + first instance, // `test_22b1_dup_b` declared the type + second instance) which // relied on bare cross-module class refs. The canonical-form rule // canonical-form rule makes that shape structurally // unrepresentable (any second instance in a module that owns // neither the class nor the type fires `OrphanInstance` first). // Post-canonical-class-form the only way to land two instances on the same // canonical key is to have them in the same module (the class's // or the type's module). The fixture now declares both // instances in `test_22b1_dup_same_module` — both class-leg // coherent, both collide on `(test_22b1_dup_same_module.TShow, // type_hash(Int))`. (24.2: class renamed `Show` → `TShow`.) // `iter22b1_duplicate_instance_fires_diagnostic` relocated to // `crates/ailang-core/tests/workspace_pin.rs` in iter form-a.1 Task 5. // an instance that omits a required (non-default) // method of its class fires `MissingMethod`. The fixture's // `class TEq` declares `teq` and `tne` as both non-default; the // instance only specifies `tne`, leaving `teq` missing. // (Class is `TEq` (and method `tne`) rather than `Eq` / `ne` to // avoid colliding with the auto-loaded prelude's `class Eq` and // — since iter 23.5 — the prelude's polymorphic free fn `ne`.) // `iter22b1_missing_method_fires_diagnostic` relocated to // `crates/ailang-core/tests/workspace_pin.rs` in iter form-a.1 Task 5. // `class_param_in_applied_position_fires_canonical_form_rejection` // and `superclass_with_wrong_param_fires_invalid_superclass_param` // relocated to `crates/ailang-core/tests/workspace_pin.rs` in iter // pd.2 (post-shim retirement; in-mod tests cannot reach // `ailang_surface::load_workspace` due to the dev-dep cycle). // 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. // The typeclass design forbids ad-hoc additions to a class's method set // at the instance site. // (Class is `TEq` rather than `Eq` to avoid colliding with the // auto-loaded prelude's `class Eq`.) // `instance_overriding_nonexistent_method_fires` relocated to // `crates/ailang-core/tests/workspace_pin.rs` in iter form-a.1 Task 5. // `constraint_with_unbound_var_fires_unbound_constraint_type_var` // relocated to `crates/ailang-core/tests/workspace_pin.rs` in iter // pd.2. // the two `..._method_name_collision_fires` pin tests // (class-class and class-fn variants) were retired here and // re-located as positive-load tests in // `crates/ailang-check/tests/method_collision_pin.rs`. Post- // Now those on-disk fixtures load cleanly and the equivalent // observations move to `env.method_to_candidate_classes` // (multi-entry set) plus the call-site warning. // 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 TEq a`, `class TOrd a // extends TEq a`, and `instance TOrd Int` — but no `instance TEq // Int` — so registry build must fire // `MissingSuperclassInstance`. the typeclass design's single-superclass // model requires `instance S T` whenever `instance C T` exists. // (Classes are `TEq` / `TOrd` rather than `Eq` / `Ord` to avoid // colliding with the auto-loaded prelude's `class Eq`.) // `instance_without_superclass_instance_fires` relocated to // `crates/ailang-core/tests/workspace_pin.rs` in iter form-a.1 Task 5. fn module_with_type_def(name: &str, type_name: &str) -> Module { serde_json::from_value(serde_json::json!({ "schema": crate::SCHEMA, "name": name, "imports": [], "defs": [ { "kind": "type", "name": type_name, "ctors": [] } ], })).unwrap() } fn single_module_with_type_con(name: &str, type_con: &str) -> BTreeMap { let m: Module = serde_json::from_value(serde_json::json!({ "schema": crate::SCHEMA, "name": name, "imports": [], "defs": [{ "kind": "fn", "name": "f", "type": { "k": "fn", "params": [], "param_modes": [], "ret": { "k": "con", "name": type_con }, "ret_mode": "own", "effects": [] }, "params": [], "body": { "t": "lit", "lit": { "kind": "unit" } } }], })).unwrap(); let mut map = BTreeMap::new(); map.insert(name.to_string(), m); map } fn single_module_with_local_type_and_ref(name: &str, type_name: &str) -> BTreeMap { let m: Module = serde_json::from_value(serde_json::json!({ "schema": crate::SCHEMA, "name": name, "imports": [], "defs": [ { "kind": "type", "name": type_name, "ctors": [] }, { "kind": "fn", "name": "f", "type": { "k": "fn", "params": [], "param_modes": [], "ret": { "k": "con", "name": type_name }, "ret_mode": "own", "effects": [] }, "params": [], "body": { "t": "lit", "lit": { "kind": "unit" } } } ], })).unwrap(); let mut map = BTreeMap::new(); map.insert(name.to_string(), m); map } fn module_with_import_and_type_con(name: &str, import: &str, type_con: &str) -> Module { serde_json::from_value(serde_json::json!({ "schema": crate::SCHEMA, "name": name, "imports": [{ "module": import }], "defs": [{ "kind": "fn", "name": "f", "type": { "k": "fn", "params": [], "param_modes": [], "ret": { "k": "con", "name": type_con }, "ret_mode": "own", "effects": [] }, "params": [], "body": { "t": "lit", "lit": { "kind": "unit" } } }], })).unwrap() } /// a `Type::Con` whose `name` is a primitive (`Int` / `Bool` / /// `Str` / `Unit` / `Float`) must be accepted bare. The five /// primitive names are the only legal bare-non-local Type::Con names /// under the canonical-form rule. #[test] fn ct1_validator_accepts_primitive_type_cons() { let modules = single_module_with_type_con("m", "Int"); validate_canonical_type_names(&modules, &["prelude"]).expect("Int must be accepted"); let modules = single_module_with_type_con("m", "Float"); validate_canonical_type_names(&modules, &["prelude"]).expect("Float must be accepted"); } /// a bare Type::Con whose `name` matches a local TypeDef in /// the same module must be accepted (the canonical-form rule: bare = /// local). #[test] fn ct1_validator_accepts_bare_local_type_con() { let modules = single_module_with_local_type_and_ref("m", "Foo"); validate_canonical_type_names(&modules, &["prelude"]) .expect("local Foo must be accepted"); } /// a bare Type::Con whose `name` is neither a primitive nor a /// local TypeDef must fire `BareCrossModuleTypeRef`. With no imports, /// the candidates list is empty. #[test] fn ct1_validator_rejects_bare_xmod_no_imports() { let modules = single_module_with_type_con("m", "Ordering"); let err = validate_canonical_type_names(&modules, &["prelude"]) .expect_err("Ordering must be rejected"); match err { WorkspaceLoadError::BareCrossModuleTypeRef { module, name, candidates } => { assert_eq!(module, "m"); assert_eq!(name, "Ordering"); assert!(candidates.is_empty(), "no imports => no candidates; got {candidates:?}"); } other => panic!("expected BareCrossModuleTypeRef, got {other:?}"), } } /// prep.1: a bare `Type::Con` whose `name` matches a `TypeDef` in /// an EXPLICITLY imported module must be ACCEPTED. (Pre-prep.1 this /// case was rejected as `BareCrossModuleTypeRef`; type-scoped form /// is canonical and importing the owner module brings the bare /// type-name into scope.) #[test] fn ct1_validator_accepts_bare_with_explicit_import() { let mut modules = BTreeMap::new(); modules.insert("other".to_string(), module_with_type_def("other", "Ordering")); modules.insert("m".to_string(), module_with_import_and_type_con("m", "other", "Ordering")); validate_canonical_type_names(&modules, &["prelude"]) .expect("bare `Ordering` must be accepted when `other` is imported and declares it"); } /// prep.1: a bare `Type::Con` whose `name` is a `TypeDef` in some /// workspace module BUT the owning module does NOT import that /// module (and the type is not implicit-imported either) must still /// fire `BareCrossModuleTypeRef`. The presence of `other` in the /// workspace is not enough; the bare-in-scope path requires `m` to /// declare an `(import other)`. #[test] fn ct1_validator_rejects_bare_when_owner_not_imported() { let mut modules = BTreeMap::new(); modules.insert("other".to_string(), module_with_type_def("other", "Ordering")); // `m` references `Ordering` but does NOT import `other`. modules.insert("m".to_string(), single_module_with_type_con("m", "Ordering").remove("m").unwrap()); let err = validate_canonical_type_names(&modules, &["prelude"]) .expect_err("bare `Ordering` must be rejected when `other` not imported"); match err { WorkspaceLoadError::BareCrossModuleTypeRef { name, candidates, module } => { assert_eq!(module, "m"); assert_eq!(name, "Ordering"); // No imports on `m` => no candidates list (today's // implementation scans `import_names`, which is empty // here; the workspace-wide scan is not part of the // candidate-list construction). assert!( candidates.is_empty(), "no imports => no candidates; got {candidates:?}" ); } other => panic!("expected BareCrossModuleTypeRef, got {other:?}"), } } /// a qualified Type::Con `.` where `` is a /// known module AND `` is one of its TypeDefs must be accepted. #[test] fn ct1_validator_accepts_qualified_xmod_ref() { let mut modules = BTreeMap::new(); modules.insert("other".to_string(), module_with_type_def("other", "Ordering")); modules.insert("m".to_string(), module_with_import_and_type_con("m", "other", "other.Ordering")); validate_canonical_type_names(&modules, &["prelude"]) .expect("other.Ordering must be accepted"); } /// a qualified Type::Con `.` where `` is /// NOT a known module must fire `BadCrossModuleTypeRef`. Symmetric /// case: `` known but no TypeDef `` in it. #[test] fn ct1_validator_rejects_bad_qualified_ref() { let modules = single_module_with_type_con("m", "Mystery.Type"); let err = validate_canonical_type_names(&modules, &["prelude"]) .expect_err("Mystery.Type must be rejected"); match err { WorkspaceLoadError::BadCrossModuleTypeRef { module, name } => { assert_eq!(module, "m"); assert_eq!(name, "Mystery.Type"); } other => panic!("expected BadCrossModuleTypeRef, got {other:?}"), } } /// a Type::Con embedded inside a `Term::Lam.param_tys` is a /// Type-position occurrence, just inside a Term tree. The validator /// must walk into Lam-internal types so an LLM author can't smuggle /// a bare cross-module ref past it by hiding it in a lambda /// annotation. #[test] fn ct1_validator_walks_lam_embedded_types() { let m: Module = serde_json::from_value(serde_json::json!({ "schema": crate::SCHEMA, "name": "m", "imports": [], "defs": [{ "kind": "fn", "name": "f", "type": { "k": "fn", "params": [], "param_modes": [], "ret": { "k": "con", "name": "Unit" }, "ret_mode": "own", "effects": [] }, "params": [], "body": { "t": "lam", "params": ["x"], "param-types": [{ "k": "con", "name": "Ordering" }], "ret-type": { "k": "con", "name": "Unit" }, "effects": [], "body": { "t": "lit", "lit": { "kind": "unit" } } } }], })).unwrap(); let mut modules = BTreeMap::new(); modules.insert("m".to_string(), m); let err = validate_canonical_type_names(&modules, &["prelude"]) .expect_err("Lam-embedded Ordering must be rejected"); match err { WorkspaceLoadError::BareCrossModuleTypeRef { name, .. } => { assert_eq!(name, "Ordering"); } other => panic!("expected BareCrossModuleTypeRef, got {other:?}"), } } /// prep.1: a `Term::Ctor` whose `type_name` is a bare type-name /// from a module in scope (here `prelude`, via implicit imports) /// is ACCEPTED. The symmetry with the `Type::Con` rule holds: /// bare-in-scope is canonical post-prep.1, and the implicit /// import path is one of the in-scope paths. #[test] fn ct1_validator_accepts_bare_term_ctor_via_implicit_import() { let mut modules = BTreeMap::new(); modules.insert("prelude".to_string(), module_with_type_def("prelude", "Ordering")); // Module `m` has no explicit imports, no local Ordering, but a // Term::Ctor referencing bare `Ordering` — accepted via the // implicit-prelude path. let m: Module = serde_json::from_value(serde_json::json!({ "schema": crate::SCHEMA, "name": "m", "imports": [], "defs": [{ "kind": "fn", "name": "f", "type": { "k": "fn", "params": [], "param_modes": [], "ret": { "k": "con", "name": "Unit" }, "ret_mode": "own", "effects": [] }, "params": [], "body": { "t": "ctor", "type": "Ordering", "ctor": "LT", "args": [] } }], })).unwrap(); modules.insert("m".to_string(), m); validate_canonical_type_names(&modules, &["prelude"]) .expect("bare Term::Ctor type must be accepted via implicit prelude import"); } /// a qualified `ClassDef.name` (e.g. `other.Eq`) must fire /// `QualifiedClassName`. Class names stay bare in this milestone. #[test] fn ct1_validator_rejects_qualified_classdef_name() { let m: Module = serde_json::from_value(serde_json::json!({ "schema": crate::SCHEMA, "name": "m", "imports": [], "defs": [{ "kind": "class", "name": "other.MyEq", "param": "a", "methods": [] }], })).unwrap(); let mut modules = BTreeMap::new(); modules.insert("m".to_string(), m); let err = validate_canonical_type_names(&modules, &["prelude"]) .expect_err("qualified ClassDef.name must be rejected"); match err { WorkspaceLoadError::QualifiedClassName { module, name, field } => { assert_eq!(module, "m"); assert_eq!(name, "other.MyEq"); assert_eq!(field, "ClassDef.name"); } other => panic!("expected QualifiedClassName, got {other:?}"), } } /// a qualified `InstanceDef.class` referencing a class /// declared in another known module is the canonical form post-canonical-class-form /// and is accepted by the validator. Inverted from the pre-canonical-class-form /// `ct1_validator_rejects_qualified_instancedef_class` test: /// `InstanceDef.class` moved bare→canonical. #[test] fn ct1_validator_accepts_qualified_instancedef_class() { let other: Module = serde_json::from_value(serde_json::json!({ "schema": crate::SCHEMA, "name": "other", "imports": [], "defs": [{ "kind": "class", "name": "MyEq", "param": "a", "methods": [] }], })).unwrap(); let m: Module = serde_json::from_value(serde_json::json!({ "schema": crate::SCHEMA, "name": "m", "imports": [{ "module": "other" }], "defs": [{ "kind": "instance", "class": "other.MyEq", "type": { "k": "con", "name": "Int" }, "methods": [] }], })).unwrap(); let mut modules = BTreeMap::new(); modules.insert("other".to_string(), other); modules.insert("m".to_string(), m); validate_canonical_type_names(&modules, &["prelude"]) .expect("qualified InstanceDef.class is the canonical form post-canonical-class-form"); } /// a qualified `SuperclassRef.class` referencing a class in /// another known module is the canonical form post-canonical-class-form and is /// accepted. Inverted from /// `ct1_validator_rejects_qualified_superclassref_class`. #[test] fn ct1_validator_accepts_qualified_superclassref_class() { let other: Module = serde_json::from_value(serde_json::json!({ "schema": crate::SCHEMA, "name": "other", "imports": [], "defs": [{ "kind": "class", "name": "MyEq", "param": "a", "methods": [] }], })).unwrap(); let m: Module = serde_json::from_value(serde_json::json!({ "schema": crate::SCHEMA, "name": "m", "imports": [{ "module": "other" }], "defs": [{ "kind": "class", "name": "MyOrd", "param": "a", "superclass": { "class": "other.MyEq", "type": "a" }, "methods": [] }], })).unwrap(); let mut modules = BTreeMap::new(); modules.insert("other".to_string(), other); modules.insert("m".to_string(), m); validate_canonical_type_names(&modules, &["prelude"]) .expect("qualified SuperclassRef.class is the canonical form post-canonical-class-form"); } /// a qualified `Constraint.class` (inside a `Type::Forall`) /// referencing a class in another known module is the canonical /// form post-canonical-class-form and is accepted. Inverted from /// `ct1_validator_rejects_qualified_constraint_class`. #[test] fn ct1_validator_accepts_qualified_constraint_class() { let other: Module = serde_json::from_value(serde_json::json!({ "schema": crate::SCHEMA, "name": "other", "imports": [], "defs": [{ "kind": "class", "name": "MyEq", "param": "a", "methods": [] }], })).unwrap(); let m: Module = serde_json::from_value(serde_json::json!({ "schema": crate::SCHEMA, "name": "m", "imports": [{ "module": "other" }], "defs": [{ "kind": "fn", "name": "f", "type": { "k": "forall", "vars": ["a"], "constraints": [ { "class": "other.MyEq", "type": { "k": "var", "name": "a" } } ], "body": { "k": "fn", "params": [{ "k": "var", "name": "a" }], "param_modes": ["own"], "ret": { "k": "con", "name": "Unit" }, "ret_mode": "own", "effects": [] } }, "params": ["x"], "body": { "t": "lit", "lit": { "kind": "unit" } } }], })).unwrap(); let mut modules = BTreeMap::new(); modules.insert("other".to_string(), other); modules.insert("m".to_string(), m); validate_canonical_type_names(&modules, &["prelude"]) .expect("qualified Constraint.class is the canonical form post-canonical-class-form"); } /// registry-side duplicate detection survives the /// asymmetric canonical-form representation. Two coherent instances /// on the same type-def, one declared bare-local (in the type's /// defining module) and one declared qualified-cross-module (in the /// class's defining module), must collide on the registry's /// `(class, type-hash)` key. /// /// This is the regression that broke during the canonical-form migration /// dry-run: after migrating `test_22b1_dup_a.ail.json` to qualified /// `test_22b1_dup_b.MyInt`, the unmigrated `test_22b1_dup_b.ail.json` /// (which keeps bare `MyInt` because the type IS local there) /// produced a different type-hash, and the duplicate detection /// silently missed. /// /// The fixture is constructed in-memory rather than from disk so /// the test stays focused on the registry behaviour rather than the /// migration tool. Class `TShow` is named distinctly from the /// prelude's `Eq`/`Show` to avoid collisions with the auto-loaded /// prelude — but `build_registry` here is called directly on a flat /// `BTreeMap` (no auto-prelude injection), so the choice is purely /// belt-and-braces. #[test] fn ct1_registry_duplicate_detection_survives_mixed_canonical_form() { // Module `cls`: declares `class TShow a` (custom name to avoid // any future prelude collision) and the qualified-cross-module // instance `instance TShow other.MyInt`. let cls: Module = serde_json::from_value(serde_json::json!({ "schema": crate::SCHEMA, "name": "cls", "imports": [{ "module": "other" }], "defs": [ { "kind": "class", "name": "TShow", "param": "a", "methods": [ { "name": "tshow", "type": { "k": "fn", "params": [{ "k": "var", "name": "a" }], "param_modes": ["own"], "ret": { "k": "con", "name": "Str" }, "ret_mode": "own", "effects": [] } } ] }, { "kind": "instance", "class": "TShow", "type": { "k": "con", "name": "other.MyInt" }, "methods": [ { "name": "tshow", "body": { "t": "lit", "lit": { "kind": "str", "value": "cls-side" } } } ] } ], })).unwrap(); // Module `other`: declares `type MyInt` and the qualified- // cross-module instance `instance cls.TShow MyInt`. Imports // `cls` for the class ref. The `class` field carries the // canonical form (qualified for cross-module per the canonical-class-form rule); the // `type` field stays bare-local. let other: Module = serde_json::from_value(serde_json::json!({ "schema": crate::SCHEMA, "name": "other", "imports": [{ "module": "cls" }], "defs": [ { "kind": "type", "name": "MyInt", "ctors": [{ "name": "MkMyInt", "fields": [] }] }, { "kind": "instance", "class": "cls.TShow", "type": { "k": "con", "name": "MyInt" }, "methods": [ { "name": "tshow", "body": { "t": "lit", "lit": { "kind": "str", "value": "other-side" } } } ] } ], })).unwrap(); let mut modules = BTreeMap::new(); modules.insert("cls".to_string(), cls); modules.insert("other".to_string(), other); let err = build_registry(&modules).expect_err( "registry build must fire DuplicateInstance for mixed canonical-form" ); match err { WorkspaceLoadError::DuplicateInstance { class, first_module, second_module, .. } => { // The `class` field carries the second-arrival // instance's `inst.class` value (canonical-form). // `other`'s instance writes the qualified form. assert_eq!(class, "cls.TShow"); let mods: BTreeSet<&str> = [first_module.as_str(), second_module.as_str()].into_iter().collect(); assert!(mods.contains("cls"), "got {first_module}, {second_module}"); assert!(mods.contains("other"), "got {first_module}, {second_module}"); } other => panic!("expected DuplicateInstance, got {other:?}"), } } /// canonical-form normalisation follow-up: `normalize_type_for_registry` must recurse into /// `Type::Forall.constraints[].type_` just like it does into /// `Type::Forall.body`. A bare `Type::Con` nested inside a Forall /// constraint that lives in a different module must be rewritten to /// its qualified form, otherwise the registry key for a constrained /// polymorphic type would disagree between bare-local and /// qualified-cross-module call sites — the same divergence /// `ct1_registry_duplicate_detection_survives_mixed_canonical_form` /// guards against at the outer level. #[test] fn ct1_5a_normalize_recurses_into_forall_constraints() { let mut type_def_module: BTreeMap<(String, String), String> = BTreeMap::new(); // Caller module is "caller" for this test; the type `MyInt` // lives in `other`. Under the tuple key the bare-name lookup // resolves only when the caller is "caller". type_def_module.insert( ("caller".to_string(), "MyInt".to_string()), "other".to_string(), ); // Forall a. (TShow MyInt) => a -> a // The constraint's type carries a bare `MyInt` that should be // rewritten to `other.MyInt`. let input = Type::Forall { vars: vec!["a".to_string()], constraints: vec![crate::ast::Constraint { class: "TShow".to_string(), type_: Type::Con { name: "MyInt".to_string(), args: vec![], }, }], body: Box::new(Type::Fn { params: vec![Type::Var { name: "a".to_string() }], param_modes: vec![crate::ast::ParamMode::Own], ret: Box::new(Type::Var { name: "a".to_string() }), ret_mode: crate::ast::ParamMode::Own, effects: vec![], }), }; let out = normalize_type_for_registry("caller", &input, &type_def_module); match out { Type::Forall { constraints, .. } => { assert_eq!(constraints.len(), 1); match &constraints[0].type_ { Type::Con { name, .. } => { assert_eq!( name, "other.MyInt", "constraint type was not normalised; got {name}" ); } other => panic!("expected Type::Con, got {other:?}"), } } other => panic!("expected Type::Forall, got {other:?}"), } } /// a qualified `Constraint.class` nested inside a /// `ClassDef.methods[].ty.Forall.constraints` must fire /// `QualifiedClassName`. Distinct from the `Def::Fn` site: the /// `Def::Class` branch's per-method Forall walk needs independent /// coverage. /// /// inverted — qualified `Constraint.class` inside a /// `ClassDef.methods` Forall is the canonical form post-canonical-class-form and is /// accepted by the validator. #[test] fn ct1_validator_accepts_qualified_constraint_class_in_classdef_method() { let other: Module = serde_json::from_value(serde_json::json!({ "schema": crate::SCHEMA, "name": "other", "imports": [], "defs": [{ "kind": "class", "name": "MyEq", "param": "a", "methods": [] }], })).unwrap(); let m: Module = serde_json::from_value(serde_json::json!({ "schema": crate::SCHEMA, "name": "m", "imports": [{ "module": "other" }], "defs": [{ "kind": "class", "name": "Foo", "param": "a", "methods": [{ "name": "m", "type": { "k": "forall", "vars": ["b"], "constraints": [ { "class": "other.MyEq", "type": { "k": "var", "name": "b" } } ], "body": { "k": "fn", "params": [{ "k": "var", "name": "b" }], "param_modes": ["own"], "ret": { "k": "con", "name": "Unit" }, "ret_mode": "own", "effects": [] } } }] }], })).unwrap(); let mut modules = BTreeMap::new(); modules.insert("other".to_string(), other); modules.insert("m".to_string(), m); validate_canonical_type_names(&modules, &["prelude"]) .expect("qualified Constraint.class in ClassDef-method Forall is the canonical form post-canonical-class-form"); } /// on-disk fixture for `BareCrossModuleTypeRef`. Bare /// `Ordering` Term::Ctor with no imports; the validator catches it /// after prelude injection (so the candidate list contains /// `prelude.Ordering`). // `ct1_fixture_bare_xmod_rejected` relocated to // `crates/ailang-core/tests/workspace_pin.rs` in iter pd.2. /// on-disk fixture for `BadCrossModuleTypeRef`. Qualified /// `Mystery.Type` with `Mystery` not a known module. // `ct1_fixture_bad_qualifier` relocated to // `crates/ailang-core/tests/workspace_pin.rs` in iter pd.2. /// on-disk fixture for the post-canonical-class-form rejection path. The /// fixture declares `instance prelude.Eq Int` outside the prelude /// and outside Int's defining module — under the canonical-form /// rule the qualified `prelude.Eq` ref is now schema-valid (post- /// class references in canonical form), and the downstream coherence check rejects the instance /// with `OrphanInstance` instead. /// /// Pre-canonical-class-form this test asserted `QualifiedClassName` on the same /// fixture; the rename + reshape is the on-disk-fixture half of /// the four in-test inversions further up. // `ct1_fixture_qualified_class_orphan_post_mq1` relocated to // `crates/ailang-core/tests/workspace_pin.rs` in iter pd.2. // on-disk fixture pin — a workspace where the consumer's // `Constraint.class` references a class in an imported module via // the qualified form loads cleanly. Symmetric to the canonical-form rule's positive // cross-module type-ref fixture (`ct1_validator_accepts_qualified_xmod_ref` // in-test sibling). Guards the full load → validator → registry // path on a real on-disk pair. // `mq1_xmod_constraint_class_fixture_loads` relocated to // `crates/ailang-core/tests/workspace_pin.rs` in iter form-a.1 Task 5. /// ext-cli.1 Task 1 / pd.2: the loader-injection contract is now /// owned by `load_modules_with` (the DFS-only loader) directly, /// since the `load_workspace_with` shim is gone. The custom loader /// must be invoked exactly once for the entry module of a single- /// module workspace. Protects against the extension-dispatching /// loader (in `ailang-surface`) silently falling back to a /// JSON-only path and so never being given a `.ail` to read. #[test] fn load_modules_with_custom_loader_is_called() { use std::sync::atomic::{AtomicUsize, Ordering}; let tmpdir = tempfile::tempdir().expect("tempdir"); let entry = tmpdir.path().join("dummy.ail.json"); std::fs::write(&entry, fixture_module_json("dummy")).expect("write"); static CALLS: AtomicUsize = AtomicUsize::new(0); CALLS.store(0, Ordering::SeqCst); let loader = |path: &std::path::Path| -> Result { CALLS.fetch_add(1, Ordering::SeqCst); crate::load_module(path).map_err(|e| match e { CoreError::Io(io) => WorkspaceLoadError::Io { path: path.to_path_buf(), source: io, }, other => WorkspaceLoadError::Schema { path: path.to_path_buf(), source: other, }, }) }; let (entry_name, _root_dir, modules) = load_modules_with(&entry, loader) .expect("workspace loads via custom loader"); assert_eq!(entry_name, "dummy"); assert!(modules.contains_key("dummy")); assert_eq!( CALLS.load(Ordering::SeqCst), 1, "custom loader called once for single-module workspace" ); } fn fixture_module_json(name: &str) -> String { format!(r#"{{"schema":"ailang/v0","name":"{name}","imports":[],"defs":[]}}"#) } /// `BareCrossModuleClassRef` is the sibling diagnostic for /// bare cross-module class references on `InstanceDef.class`, /// `Constraint.class`, and `SuperclassRef.class`. Verifies the /// variant is constructible and its Display surface names the /// "class" wording (vs. "type" in the sibling variant). #[test] fn mq1_bare_cross_module_class_ref_display_names_class() { let err = WorkspaceLoadError::BareCrossModuleClassRef { module: "user".to_string(), name: "Show".to_string(), candidates: vec!["prelude.Show".to_string()], }; let rendered = format!("{}", err); assert!(rendered.contains("class"), "Display must name 'class' wording, got: {rendered}"); assert!(rendered.contains("Show"), "Display must echo the offending class name, got: {rendered}"); assert!(rendered.contains("prelude.Show"), "Display must list candidates, got: {rendered}"); } /// `BadCrossModuleClassRef` is the sibling diagnostic for /// qualified class references whose owner module is unknown or /// declares no class by that name. Verifies the variant is /// constructible and its Display surface names the offending /// qualified form. #[test] fn mq1_bad_cross_module_class_ref_display_names_qualified() { let err = WorkspaceLoadError::BadCrossModuleClassRef { module: "user".to_string(), name: "unknownlib.Show".to_string(), }; let rendered = format!("{}", err); assert!(rendered.contains("unknownlib.Show"), "Display must echo the qualified form, got: {rendered}"); assert!(rendered.contains("class") || rendered.contains("module"), "Display must mention class or module context, got: {rendered}"); } /// `InstanceDef.class` carrying a bare name that does NOT /// resolve to a local class of the owning module must fire /// `BareCrossModuleClassRef`. The fixture imports a sibling module /// that declares the class under the bare name, so the candidate /// list is non-empty. #[test] fn mq1_bare_xmod_instancedef_class_fires() { let a: Module = serde_json::from_value(serde_json::json!({ "schema": crate::SCHEMA, "name": "A", "imports": [], "defs": [{ "kind": "class", "name": "Show", "param": "a", "methods": [] }] })).unwrap(); let b: Module = serde_json::from_value(serde_json::json!({ "schema": crate::SCHEMA, "name": "B", "imports": [{ "module": "A" }], "defs": [{ "kind": "instance", "class": "Show", "type": { "k": "con", "name": "Int" }, "methods": [] }] })).unwrap(); let mut modules = BTreeMap::new(); modules.insert("A".to_string(), a); modules.insert("B".to_string(), b); let err = validate_canonical_type_names(&modules, &["prelude"]).expect_err("expected reject"); match err { WorkspaceLoadError::BareCrossModuleClassRef { module, name, candidates } => { assert_eq!(module, "B"); assert_eq!(name, "Show"); assert!(candidates.contains(&"A.Show".to_string()), "candidates: {candidates:?}"); } other => panic!("expected BareCrossModuleClassRef, got {other:?}"), } } /// `Constraint.class` on a `Def::Fn` carrying a bare name /// that does not resolve to a local class fires /// `BareCrossModuleClassRef`. #[test] fn mq1_bare_xmod_constraint_class_on_fn_fires() { let a: Module = serde_json::from_value(serde_json::json!({ "schema": crate::SCHEMA, "name": "A", "imports": [], "defs": [{ "kind": "class", "name": "Show", "param": "a", "methods": [] }] })).unwrap(); let b: Module = serde_json::from_value(serde_json::json!({ "schema": crate::SCHEMA, "name": "B", "imports": [{ "module": "A" }], "defs": [{ "kind": "fn", "name": "f", "type": { "k": "forall", "vars": ["a"], "constraints": [ { "class": "Show", "type": { "k": "var", "name": "a" } } ], "body": { "k": "fn", "params": [{ "k": "var", "name": "a" }], "param_modes": ["own"], "ret": { "k": "con", "name": "Unit" }, "ret_mode": "own", "effects": [] } }, "params": ["x"], "body": { "t": "lit", "lit": { "kind": "unit" } } }] })).unwrap(); let mut modules = BTreeMap::new(); modules.insert("A".to_string(), a); modules.insert("B".to_string(), b); let err = validate_canonical_type_names(&modules, &["prelude"]).expect_err("expected reject"); match err { WorkspaceLoadError::BareCrossModuleClassRef { module, name, .. } => { assert_eq!(module, "B"); assert_eq!(name, "Show"); } other => panic!("expected BareCrossModuleClassRef, got {other:?}"), } } /// `SuperclassRef.class` on a `Def::Class` carrying a bare /// name that does not resolve to a local class fires /// `BareCrossModuleClassRef`. #[test] fn mq1_bare_xmod_superclassref_class_fires() { let a: Module = serde_json::from_value(serde_json::json!({ "schema": crate::SCHEMA, "name": "A", "imports": [], "defs": [{ "kind": "class", "name": "MyEq", "param": "a", "methods": [] }] })).unwrap(); let b: Module = serde_json::from_value(serde_json::json!({ "schema": crate::SCHEMA, "name": "B", "imports": [{ "module": "A" }], "defs": [{ "kind": "class", "name": "MyOrd", "param": "a", "superclass": { "class": "MyEq", "type": "a" }, "methods": [] }] })).unwrap(); let mut modules = BTreeMap::new(); modules.insert("A".to_string(), a); modules.insert("B".to_string(), b); let err = validate_canonical_type_names(&modules, &["prelude"]).expect_err("expected reject"); match err { WorkspaceLoadError::BareCrossModuleClassRef { module, name, .. } => { assert_eq!(module, "B"); assert_eq!(name, "MyEq"); } other => panic!("expected BareCrossModuleClassRef, got {other:?}"), } } /// a qualified `InstanceDef.class` referencing a class in /// an imported module is the canonical form post-canonical-class-form and is /// accepted by `validate_canonical_type_names`. Symmetric to the canonical-form rule's /// "qualified Type::Con resolves" positive path. #[test] fn mq1_qualified_instancedef_class_accepted() { let a: Module = serde_json::from_value(serde_json::json!({ "schema": crate::SCHEMA, "name": "A", "imports": [], "defs": [{ "kind": "class", "name": "Show", "param": "a", "methods": [] }] })).unwrap(); let b: Module = serde_json::from_value(serde_json::json!({ "schema": crate::SCHEMA, "name": "B", "imports": [{ "module": "A" }], "defs": [{ "kind": "instance", "class": "A.Show", "type": { "k": "con", "name": "Int" }, "methods": [] }] })).unwrap(); let mut modules = BTreeMap::new(); modules.insert("A".to_string(), a); modules.insert("B".to_string(), b); validate_canonical_type_names(&modules, &["prelude"]).expect("qualified A.Show must be accepted"); } /// pd.1 Task 1: `load_modules_with` returns modules without injecting /// prelude. The prelude inject is the caller's responsibility (the /// shim `load_workspace_with` does it in pd.1; surface owns it from /// pd.2 onward). #[test] fn load_modules_with_returns_modules_without_prelude() { let dir = tempfile::tempdir().unwrap(); write_module(dir.path(), "main", &[]); let entry = dir.path().join("main.ail.json"); let (entry_name, root_dir, modules) = load_modules_with(&entry, load_one).expect("load"); assert_eq!(entry_name, "main"); assert_eq!(root_dir, dir.path().to_path_buf()); assert!( !modules.contains_key("prelude"), "load_modules_with must not auto-inject prelude (that is build_workspace's caller's job)" ); assert!(modules.contains_key("main")); } // Note: tests that need a real prelude module via // `ailang_surface::parse_prelude()` live in // `crates/ailang-core/tests/workspace_pin.rs` (integration crate) // because the dev-dep cycle (`ailang-core` -> `ailang-surface` -> // `ailang-core`) leaves the lib-test target with two distinct // `ailang-core` compilations whose `Module` types do not unify. fn module_with_bare_classref(name: &str, class_ref: &str) -> Module { // A module containing a single Def::Instance whose `class` field is // a bare class reference. The instance's `type` field is a local // ADT defined in the same module to keep the canonical-form rule satisfied. serde_json::from_value(serde_json::json!({ "schema": crate::SCHEMA, "name": name, "imports": [], "defs": [ { "kind": "type", "name": "Foo", "ctors": [] }, { "kind": "instance", "class": class_ref, "type": { "k": "con", "name": "Foo" }, "methods": [] } ], })).unwrap() } fn module_with_class_def(name: &str, class_name: &str, param: &str) -> Module { serde_json::from_value(serde_json::json!({ "schema": crate::SCHEMA, "name": name, "imports": [], "defs": [ { "kind": "class", "name": class_name, "param": param, "methods": [] } ], })).unwrap() } /// pd.1 Task 3: `implicit_imports` drives whether the diagnostic /// helpers offer fallback candidate suggestions for the named /// implicit modules. With `&["prelude"]` the today-behaviour /// `prelude.Eq` candidate fires; with `&[]` it does not. #[test] fn implicit_imports_arg_drives_prelude_fallback_in_diagnostics() { let mut modules = BTreeMap::new(); modules.insert("main".to_string(), module_with_bare_classref("main", "Eq")); modules.insert( "prelude".to_string(), module_with_class_def("prelude", "Eq", "a"), ); let err_with = validate_canonical_type_names(&modules, &["prelude"]).unwrap_err(); match err_with { WorkspaceLoadError::BareCrossModuleClassRef { ref candidates, .. } => { assert!( candidates.iter().any(|c| c == "prelude.Eq"), "expected prelude.Eq in candidates, got {:?}", candidates ); } other => panic!("expected BareCrossModuleClassRef, got {other:?}"), } let err_without = validate_canonical_type_names(&modules, &[]).unwrap_err(); match err_without { WorkspaceLoadError::BareCrossModuleClassRef { ref candidates, .. } => { assert!( !candidates.iter().any(|c| c == "prelude.Eq"), "expected NO prelude.Eq in candidates, got {:?}", candidates ); } other => panic!("expected BareCrossModuleClassRef, got {other:?}"), } } // pd.2: `load_workspace_with_shim_preserves_today_semantics` deleted — // the shim it pinned is gone. Cross-form-identity / hash-pin // coverage now lives in // `crates/ailang-surface/tests/prelude_module_hash_pin.rs`. }