76b21c00eb
Deletes `ParamMode::Implicit`. `ParamMode` is now `{Own, Borrow}`:
every fn-type slot on every signature carries an explicit `own` or
`borrow`, no defaulted position survives anywhere (model 0008 §2,
spec 0062). The parser rejects a bare fn-type slot; `borrow-return`
and `borrow-over-value` reject at the signature; the corpus is
migrated to minimal-ownership modes (consumed ⇒ own, read-only-heap
⇒ borrow, value ⇒ trivial-own). The documented `Implicit`-ret-mode
leak is fixed: an owned heap return now drops exactly once (live=0,
acceptance criterion 5).
This was the easy half. Removing the default ACTIVATED a family of
drop paths that `Implicit` had silently skipped — the pre-cutover
language was leaking (and in places mis-dropping) here rather than
crashing, because an Implicit scrutinee turned the drop off. Making
the modes explicit (Own) turned those paths on and exposed two
latent-bug clusters, all fixed RED-first as part of this cutover:
Drop-soundness family (four legs):
A. lit-sub-pattern double-free — the desugar re-matched the same
owned scrutinee in the lit fall-through; fixed by grouping
consecutive same-ctor arms into one match (bind fields once),
in ailang-core desugar.
B. Cons-husk leak on non-tail arm bodies — the lit-sub-pattern
desugar rebound the owned scrutinee via `Let $mp = xs`, which
bumped consume_count and suppressed the existing fn-return
partial_drop. Fixed by not rebinding a bare-Var scrutinee
(one husk-freeing mechanism, not two).
C. polymorphic `drop_<T>` rc_dec'd monomorphised value fields —
the per-ADT drop fn was emitted once from the polymorphic
TypeDef, defaulting type-var fields to ptr and rc_dec'ing
inline Ints (segfault). Fixed with per-monomorph drop
functions (new ailang-codegen::dropmono): the drop set is
collected from the lowered MIR, value-type fields are skipped,
heap fields still freed once; monomorphic-concrete ADTs keep
their byte-identical un-suffixed drop symbol.
D. static Str literal passed to an `(own Str)` param — the
literal lowers to a header-less rodata constant; the callee's
now-active rc_dec read its length field as a refcount and
freed a static address (segfault). Fixed with the missing
fourth StrRep::Static→Heap promotion in lower_to_mir's App arm,
gated on Own mode (borrow args stay static, no regression).
over-strict-mode lint over-fired: it suggested `(borrow V)` for
value-typed params (which `borrow-over-value` rejects — own is the
only legal mode there) and fired on `(intrinsic)` bodies (whose
consumption the linearity walk cannot observe). Tightened to skip
both; contract 0008 updated to the narrowed firing scope.
Irreversible step — canonical-form hash reset (model 0008 §6,
acceptance criterion 6). Every signature now carries explicit modes,
so the hashable canonical JSON changed for every module. RATIFY:
the corpus-wide hash-pin reset (hash_pin, prelude_module_hash_pin,
mono_hash_stability, eq_ord_e2e, embed_export_hash_stable, the
ct4/iter*/loop_recur schema-extension pins) and the list ir_snapshot
golden were regenerated once, deliberately, as the intended one-time
consequence of removing the mode elision from the canonical form —
not a regression. Each regenerated hash verified deterministic across
two runs.
Also fixes a pre-existing latent failure surfaced by the verification
gate, unrelated to this cutover: the `every_contract_names_a_resolvable_
ratifying_test` resolver (design_index_pin) could not resolve the
" + " dual-link ratifying-test form (`uniqueness.rs + linearity.rs`)
that the #57 audit-close (dfdc65f) introduced — it shipped red on that
commit. Resolver taught the dual-link form, mirroring its sibling.
Verification: cargo test --workspace = 731 passed, 0 failed (twice,
stable); e2e 102 passed, no binary exits non-zero (corpus crash-free);
grep-clean for Implicit/fn_implicit/mode_eq across crates; every drop
fix confirmed via emitted IR + AILANG_RC_STATS balance on the head==K,
head!=K, and Nil paths. Three BLOCKEDs en route (the unsound first
husk-dec attempt, the over-strict derivation premise, the leg-B fix
direction) were each treated as a real design/spec gap and rediagnosed,
not patched over.
Supersedes #54 (return-position-only leak patch). Precondition #57
(linearity hardening) was already met. Spec docs/specs/0062, plan
docs/plans/0121.
closes #55
2803 lines
114 KiB
Rust
2803 lines
114 KiB
Rust
//! 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<String, Module>,
|
|
/// 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 `<winner>.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 `<defining_module>.<name>`; 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
|
|
/// `<name>.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<String> },
|
|
|
|
/// 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 <module>)` to bring it into scope (candidates: {candidates:?}), \
|
|
or rewrite the call to type-scoped form `<TypeName>.<member>`."
|
|
)]
|
|
BareCrossModuleTypeRef {
|
|
module: String,
|
|
name: String,
|
|
candidates: Vec<String>,
|
|
},
|
|
|
|
/// a qualified `Type::Con` of the form `<owner>.<type>`
|
|
/// was encountered, but `<owner>` is not a known module in the
|
|
/// workspace, or `<owner>` is known but declares no TypeDef
|
|
/// named `<type>`.
|
|
#[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<String>,
|
|
},
|
|
|
|
/// the canonical-form rule for class references: a qualified class reference of
|
|
/// the form `<owner>.<class>` was encountered, but `<owner>` is
|
|
/// not a known module in the workspace, or `<owner>` 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<F>(
|
|
entry_path: &Path,
|
|
loader: F,
|
|
) -> Result<(String, PathBuf, BTreeMap<String, Module>), WorkspaceLoadError>
|
|
where
|
|
F: Fn(&Path) -> Result<Module, WorkspaceLoadError> + 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<String, Module> = BTreeMap::new();
|
|
let mut visiting: Vec<String> = Vec::new();
|
|
let mut visiting_set: HashSet<String> = 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<String, Module>,
|
|
implicit_imports: &[&str],
|
|
) -> Result<Workspace, WorkspaceLoadError> {
|
|
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<String, Module>,
|
|
) -> Result<Registry, WorkspaceLoadError> {
|
|
// 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 `<defining_module>.<class>`. 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<String, String> = BTreeMap::new();
|
|
let mut type_def_module: BTreeMap<(String, String), String> = BTreeMap::new();
|
|
let mut class_by_name: BTreeMap<String, &ClassDef> = 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
|
|
// (`<defining_module>.<class>`); 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(|| "<unknown-class>".into());
|
|
// type-leg lookup must accept the canonical form
|
|
// for the head name. Bare head ⇒ key by
|
|
// (caller_module, head); qualified `<owner>.<bare>` ⇒
|
|
// 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(|| "<primitive-or-unknown>".into())
|
|
} else {
|
|
type_def_module
|
|
.get(&(mod_name.clone(), type_repr.clone()))
|
|
.cloned()
|
|
.unwrap_or_else(|| "<primitive-or-unknown>".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<String, Module>,
|
|
) -> 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
|
|
/// (`<defining_module>.<name>`). 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 `<owner>.<type>`: `<owner>` must be a known module,
|
|
/// `<type>` 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<String, Module>,
|
|
implicit_imports: &[&str],
|
|
) -> Result<(), WorkspaceLoadError> {
|
|
// Pre-pass: for each module, build a `BTreeSet<String>` of local
|
|
// TypeDef names. Used for both the owning-module local lookup
|
|
// and the per-import owner lookup.
|
|
let mut local_types: BTreeMap<String, BTreeSet<String>> = 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<String, BTreeSet<String>> = 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-`<owner>` 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 `<owner>.<class>`: `<owner>` must be a known module
|
|
/// that declares the class. Else `BadCrossModuleClassRef`.
|
|
/// 2. Bare same-module-class: `<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<String, BTreeSet<String>>,
|
|
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<String> = 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<String, BTreeSet<String>>,
|
|
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<String> = 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<F>(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<F>(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<F>(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<F>(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<F>(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<F>(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!("<var:{name}>"),
|
|
Type::Forall { .. } => "<forall>".into(),
|
|
Type::Fn { .. } => "<fn>".into(),
|
|
}
|
|
}
|
|
|
|
fn visit<F>(
|
|
module: Module,
|
|
root_dir: &Path,
|
|
modules: &mut BTreeMap<String, Module>,
|
|
visiting: &mut Vec<String>,
|
|
visiting_set: &mut HashSet<String>,
|
|
loader: F,
|
|
) -> Result<(), WorkspaceLoadError>
|
|
where
|
|
F: Fn(&Path) -> Result<Module, WorkspaceLoadError> + 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 `<module>.ail` (Form A) over
|
|
// `<module>.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: `<name>.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<Module, WorkspaceLoadError> {
|
|
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<serde_json::Value> = 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<String, Module> {
|
|
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<String, Module> {
|
|
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 `<owner>.<type>` where `<owner>` is a
|
|
/// known module AND `<type>` 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 `<owner>.<type>` where `<owner>` is
|
|
/// NOT a known module must fire `BadCrossModuleTypeRef`. Symmetric
|
|
/// case: `<owner>` known but no TypeDef `<type>` 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<Module, WorkspaceLoadError> {
|
|
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`.
|
|
}
|