ee4107c721
A `(new RawBuf (con Int) 3)` whose buffer is never observed by a later
get/set passed `ail check` but crashed `ail build` with
`RawBuf_new__Unit has no registered intercept`, and a forbidden
`(new RawBuf (con Unit) 3)` was wrongly accepted at check. Both legs
share one root: the `Term::New` desugar discarded the author's written
`NewArg::Type` before anything could use it.
This was never an inference problem. The element type is EXPLICITLY
known — the author wrote `(con Int)`. The defect was that the desugar
threw that known type away and relied on re-discovering it from
downstream use; with no use (size-only), `RawBuf.new`'s result-only
forall var stayed unpinned, mono Unit-defaulted it, and codegen aborted
on the unregistered `RawBuf_new__Unit` intercept. The fix USES the
written type instead of inferring it.
Mechanism:
- desugar.rs: a polymorphic `(new T <elem> …)` (carrying a written
`NewArg::Type`) now SURVIVES into check instead of being lowered to
`(app T.new …)`. A monomorphic `(new Counter 42)` (no type-arg) keeps
the raw-buf.4 app-lowering unchanged.
- lib.rs synth `Term::New` arm: (a) validates the written element type
against the constructed type's `param_in` set via
`check_type_well_formed`, firing `ParamNotInRestrictedSet` naming the
forbidden element (leg 2) — synth is the correct site because it has
Env/registry access, unlike `pre_desugar_validation`; (b) binds the
result-only forall var DIRECTLY to the written type (`?a := Int`, a
trivial binding, no inference channel) by pushing a `FreeFnCall` whose
metas are the written concrete types, so mono mints
`RawBuf_new__<elem>` and never `__Unit`.
- mono.rs: `rewrite_mono_calls` and `interleave_slots` gain matching
`Term::New` arms that each consume exactly one free-fn slot at a
type-arg-bearing `Term::New` (mirroring synth's single observation,
pushed before the value-args), and `rewrite_mono_calls` reduces the
node to `(app <mangled> <value-args>)` so codegen never sees
`Term::New` and the lib.rs:2200 `unreachable!` stays valid. The two
walkers stay in lockstep.
The Unit-default policy in mono stays correct for genuinely-unobservable
vars (`is_empty(Nil)` etc.); only the case with a written `NewArg::Type`
is changed.
Alternatives rejected: an additive `type_args` field on `Term::App`
(~100 construction sites across 7 crates — a detour that builds a new
transport slot only to copy the already-known type into it); a runtime
type-witness parameter on `RawBuf.new` (invents a runtime value for a
pure type property); an identity-lambda wrapper carrying the element in
its param-type (hides a semantic property in a runtime construct). The
written type belongs where the author put it, carried to the one point
that consumes it.
Verification: both RED legs (committed 61b9d3f) now green; full
check/codegen/core/surface suites green; intercepts bijection and
hash-pin tests green; existing raw-buf int/float/bool fixtures still
build and run leak-clean (live=0).
Out of scope (pre-existing at HEAD, separate issue): a monomorphic
`(new Counter 42)` over a user ADT fails with `unknown variable:
Counter.new` — unrelated to the type-arg drop fixed here.
closes #51
8506 lines
369 KiB
Rust
8506 lines
369 KiB
Rust
//! Typechecker for AILang (MVP).
|
||
//!
|
||
//! Sits between `ailang-core` (AST + canonical JSON + content hash) and
|
||
//! `ailang-codegen` (LLVM IR emit) in the pipeline `core → check →
|
||
//! codegen → ail`. Top-level entry points: [`check_module`] (one module,
|
||
//! returns [`Diagnostic`]s), [`check_workspace`] (multi-module), and
|
||
//! [`check`] (single-error legacy form returning a [`CheckedModule`]).
|
||
//!
|
||
//! HM with explicit top-level polymorphism. All top-level defs must carry
|
||
//! their full type annotation; lambda bodies inside defs are checked
|
||
//! monomorphically against their declared types. Polymorphism is opt-in
|
||
//! via `Type::Forall { vars, body }` at top-level def types and is
|
||
//! instantiated at every use site (the textbook ML rule).
|
||
//!
|
||
//! Effects are propagated as a set and reconciled against the annotation
|
||
//! on the function type.
|
||
//!
|
||
//! Built-in operations are resolved via the [`builtins`] table installed
|
||
//! into every fresh [`Env`].
|
||
//!
|
||
//! ## Internals
|
||
//!
|
||
//! Type variables come in two flavours during checking:
|
||
//! - **Rigid vars** — universally quantified vars from a `Forall` that
|
||
//! end up in scope while checking a polymorphic def's body. They are
|
||
//! `Type::Var { name: "<name>" }` where `<name>` is the source-level
|
||
//! name (`a`, `b`, ...). They unify only with themselves.
|
||
//! - **Metavars** — fresh placeholders for instantiated forall vars at
|
||
//! use sites. Encoded inside the existing `Type::Var` as
|
||
//! `Type::Var { name: "$m<id>" }`. The naming convention keeps the AST
|
||
//! schema untouched (no new variant, hashes stay stable). Source-level
|
||
//! names cannot collide with the `$m` prefix because identifiers may
|
||
//! not start with `$`.
|
||
|
||
use ailang_core::ast::*;
|
||
use ailang_core::Workspace;
|
||
use indexmap::IndexMap;
|
||
use std::collections::{BTreeMap, BTreeSet};
|
||
|
||
mod linearity;
|
||
mod pre_desugar_validation;
|
||
mod reuse_shape;
|
||
mod suppress_filter;
|
||
pub mod uniqueness;
|
||
|
||
/// Metavariable substitution. Maps fresh metavar ids (from `$m<id>` in
|
||
/// `Type::Var.name`) to the type they have been unified against.
|
||
#[derive(Debug, Default, Clone)]
|
||
pub struct Subst {
|
||
map: BTreeMap<u32, Type>,
|
||
}
|
||
|
||
impl Subst {
|
||
/// Allocates a fresh metavar. The counter is owned by the caller so
|
||
/// that ids stay deterministic across `synth` calls within one
|
||
/// def-check.
|
||
fn fresh(counter: &mut u32) -> Type {
|
||
let id = *counter;
|
||
*counter += 1;
|
||
Type::Var { name: format!("$m{id}") }
|
||
}
|
||
|
||
/// `Some(id)` iff the var name is the metavar encoding `$m<id>`.
|
||
fn meta_id(name: &str) -> Option<u32> {
|
||
name.strip_prefix("$m").and_then(|s| s.parse::<u32>().ok())
|
||
}
|
||
|
||
fn lookup(&self, id: u32) -> Option<&Type> {
|
||
self.map.get(&id)
|
||
}
|
||
|
||
fn extend(&mut self, id: u32, t: Type) {
|
||
self.map.insert(id, t);
|
||
}
|
||
|
||
/// Walks `t`, replacing every metavar with its current binding (if
|
||
/// any) — recursively so chains collapse. Rigid vars and concrete
|
||
/// types pass through unchanged.
|
||
pub fn apply(&self, t: &Type) -> Type {
|
||
match t {
|
||
Type::Var { name } => {
|
||
if let Some(id) = Self::meta_id(name) {
|
||
if let Some(bound) = self.lookup(id) {
|
||
return self.apply(&bound.clone());
|
||
}
|
||
}
|
||
Type::Var { name: name.clone() }
|
||
}
|
||
Type::Con { name, args } => Type::Con {
|
||
name: name.clone(),
|
||
args: args.iter().map(|a| self.apply(a)).collect(),
|
||
},
|
||
Type::Fn { params, ret, effects, .. } => Type::Fn {
|
||
params: params.iter().map(|p| self.apply(p)).collect(),
|
||
ret: Box::new(self.apply(ret)),
|
||
effects: effects.clone(),
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
},
|
||
Type::Forall { vars, constraints, body } => Type::Forall {
|
||
vars: vars.clone(),
|
||
constraints: constraints.clone(),
|
||
body: Box::new(self.apply(body)),
|
||
},
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Replaces every var named in `vars` inside `body` with a fresh metavar.
|
||
/// Used at use sites to instantiate a `Forall` type. Returns the
|
||
/// instantiated body and the parallel list of fresh metavars (one per
|
||
/// forall var, same order) — useful for codegen monomorphisation.
|
||
fn instantiate(forall_vars: &[String], body: &Type, counter: &mut u32) -> (Vec<Type>, Type) {
|
||
let mut mapping: BTreeMap<String, Type> = BTreeMap::new();
|
||
let mut metas: Vec<Type> = Vec::with_capacity(forall_vars.len());
|
||
for v in forall_vars {
|
||
let m = Subst::fresh(counter);
|
||
mapping.insert(v.clone(), m.clone());
|
||
metas.push(m);
|
||
}
|
||
(metas, substitute_rigids(body, &mapping))
|
||
}
|
||
|
||
/// Substitutes named rigid vars throughout a type (used by
|
||
/// `instantiate`). Unlike `Subst::apply`, this targets vars by name —
|
||
/// it has no notion of metavar ids.
|
||
pub(crate) fn substitute_rigids(t: &Type, mapping: &BTreeMap<String, Type>) -> Type {
|
||
match t {
|
||
Type::Var { name } => mapping.get(name).cloned().unwrap_or_else(|| t.clone()),
|
||
Type::Con { name, args } => Type::Con {
|
||
name: name.clone(),
|
||
args: args.iter().map(|a| substitute_rigids(a, mapping)).collect(),
|
||
},
|
||
Type::Fn { params, param_modes, ret, ret_mode, effects } => Type::Fn {
|
||
params: params.iter().map(|p| substitute_rigids(p, mapping)).collect(),
|
||
ret: Box::new(substitute_rigids(ret, mapping)),
|
||
effects: effects.clone(),
|
||
// 2026-05-14 bugfix-print-leak-show-ret-mode: preserve
|
||
// param_modes and ret_mode through rigid substitution.
|
||
// The earlier `..` discarded both and hard-reset to
|
||
// Implicit, which silently stripped the Show class
|
||
// method's `ret_mode: Own` (declared in
|
||
// `examples/prelude.ail.json`) when mono synthesised
|
||
// `show__Int` via `synthesise_mono_fn`'s
|
||
// `substitute_rigids(&class_method.ty, …)`. Codegen's
|
||
// `is_rc_heap_allocated` App-arm then declined to track
|
||
// print's `let s = show x` binder, leaking the heap-Str.
|
||
param_modes: param_modes.clone(),
|
||
ret_mode: *ret_mode,
|
||
},
|
||
Type::Forall { vars, constraints, body } => {
|
||
// Inner forall shadows: only substitute vars not re-bound here.
|
||
let inner: BTreeMap<String, Type> = mapping
|
||
.iter()
|
||
.filter(|(k, _)| !vars.contains(k))
|
||
.map(|(k, v)| (k.clone(), v.clone()))
|
||
.collect();
|
||
Type::Forall {
|
||
vars: vars.clone(),
|
||
constraints: constraints.clone(),
|
||
body: Box::new(substitute_rigids(body, &inner)),
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// substitute named rigid vars throughout a `Term`,
|
||
/// recursing into every sub-Term and applying [`substitute_rigids`]
|
||
/// to every embedded `Type` (in `Term::Lam.param_tys`,
|
||
/// `Term::Lam.ret_ty`). All other Term variants carry no inline
|
||
/// types and recurse structurally.
|
||
///
|
||
/// Used by `mono::synthesise_mono_fn_for_free_fn` to specialise a
|
||
/// polymorphic free-fn body for a specific instantiation. The
|
||
/// class-method arm doesn't need this — instance bodies are
|
||
/// Lam-unwrapped during synthesis, dropping their `param_tys` —
|
||
/// but a free-fn body may carry arbitrary nested Lams whose
|
||
/// `param_tys` reference the outer Forall vars.
|
||
pub(crate) fn substitute_rigids_in_term(t: &Term, mapping: &BTreeMap<String, Type>) -> Term {
|
||
use ailang_core::ast::Arm;
|
||
match t {
|
||
Term::Lit { .. } | Term::Var { .. } => t.clone(),
|
||
Term::App { callee, args, tail } => Term::App {
|
||
callee: Box::new(substitute_rigids_in_term(callee, mapping)),
|
||
args: args.iter().map(|a| substitute_rigids_in_term(a, mapping)).collect(),
|
||
tail: *tail,
|
||
},
|
||
Term::Let { name, value, body } => Term::Let {
|
||
name: name.clone(),
|
||
value: Box::new(substitute_rigids_in_term(value, mapping)),
|
||
body: Box::new(substitute_rigids_in_term(body, mapping)),
|
||
},
|
||
Term::LetRec { name, ty, params, body, in_term } => Term::LetRec {
|
||
name: name.clone(),
|
||
ty: substitute_rigids(ty, mapping),
|
||
params: params.clone(),
|
||
body: Box::new(substitute_rigids_in_term(body, mapping)),
|
||
in_term: Box::new(substitute_rigids_in_term(in_term, mapping)),
|
||
},
|
||
Term::If { cond, then, else_ } => Term::If {
|
||
cond: Box::new(substitute_rigids_in_term(cond, mapping)),
|
||
then: Box::new(substitute_rigids_in_term(then, mapping)),
|
||
else_: Box::new(substitute_rigids_in_term(else_, mapping)),
|
||
},
|
||
Term::Do { op, args, tail } => Term::Do {
|
||
op: op.clone(),
|
||
args: args.iter().map(|a| substitute_rigids_in_term(a, mapping)).collect(),
|
||
tail: *tail,
|
||
},
|
||
Term::Ctor { type_name, ctor, args } => Term::Ctor {
|
||
type_name: type_name.clone(),
|
||
ctor: ctor.clone(),
|
||
args: args.iter().map(|a| substitute_rigids_in_term(a, mapping)).collect(),
|
||
},
|
||
Term::Match { scrutinee, arms } => Term::Match {
|
||
scrutinee: Box::new(substitute_rigids_in_term(scrutinee, mapping)),
|
||
arms: arms
|
||
.iter()
|
||
.map(|a| Arm {
|
||
pat: a.pat.clone(),
|
||
body: substitute_rigids_in_term(&a.body, mapping),
|
||
})
|
||
.collect(),
|
||
},
|
||
Term::Lam { params, param_tys, ret_ty, effects, body } => Term::Lam {
|
||
params: params.clone(),
|
||
param_tys: param_tys
|
||
.iter()
|
||
.map(|t| substitute_rigids(t, mapping))
|
||
.collect(),
|
||
ret_ty: Box::new(substitute_rigids(ret_ty, mapping)),
|
||
effects: effects.clone(),
|
||
body: Box::new(substitute_rigids_in_term(body, mapping)),
|
||
},
|
||
Term::Seq { lhs, rhs } => Term::Seq {
|
||
lhs: Box::new(substitute_rigids_in_term(lhs, mapping)),
|
||
rhs: Box::new(substitute_rigids_in_term(rhs, mapping)),
|
||
},
|
||
Term::Clone { value } => Term::Clone {
|
||
value: Box::new(substitute_rigids_in_term(value, mapping)),
|
||
},
|
||
Term::ReuseAs { source, body } => Term::ReuseAs {
|
||
source: Box::new(substitute_rigids_in_term(source, mapping)),
|
||
body: Box::new(substitute_rigids_in_term(body, mapping)),
|
||
},
|
||
Term::Loop { binders, body } => Term::Loop {
|
||
binders: binders
|
||
.iter()
|
||
.map(|b| ailang_core::ast::LoopBinder {
|
||
name: b.name.clone(),
|
||
ty: substitute_rigids(&b.ty, mapping),
|
||
init: substitute_rigids_in_term(&b.init, mapping),
|
||
})
|
||
.collect(),
|
||
body: Box::new(substitute_rigids_in_term(body, mapping)),
|
||
},
|
||
Term::Recur { args } => Term::Recur {
|
||
args: args
|
||
.iter()
|
||
.map(|a| substitute_rigids_in_term(a, mapping))
|
||
.collect(),
|
||
},
|
||
Term::New { type_name, args } => Term::New {
|
||
type_name: type_name.clone(),
|
||
args: args
|
||
.iter()
|
||
.map(|arg| match arg {
|
||
NewArg::Value(v) => NewArg::Value(substitute_rigids_in_term(v, mapping)),
|
||
NewArg::Type(t) => NewArg::Type(substitute_rigids(t, mapping)),
|
||
})
|
||
.collect(),
|
||
},
|
||
Term::Intrinsic => t.clone(),
|
||
}
|
||
}
|
||
|
||
/// Returns true if metavar `id` occurs in `t` (after applying the
|
||
/// current substitution). Used as the occurs check during unification.
|
||
fn occurs(id: u32, t: &Type, subst: &Subst) -> bool {
|
||
let t = subst.apply(t);
|
||
match &t {
|
||
Type::Var { name } => Subst::meta_id(name) == Some(id),
|
||
Type::Con { args, .. } => args.iter().any(|a| occurs(id, a, subst)),
|
||
Type::Fn { params, ret, .. } => {
|
||
params.iter().any(|p| occurs(id, p, subst)) || occurs(id, ret, subst)
|
||
}
|
||
Type::Forall { body, .. } => occurs(id, body, subst),
|
||
}
|
||
}
|
||
|
||
/// Unifies two types under the current substitution. Symmetric. Extends
|
||
/// the substitution as needed, with the standard occurs check.
|
||
fn unify(a: &Type, b: &Type, subst: &mut Subst) -> Result<()> {
|
||
let a = subst.apply(a);
|
||
let b = subst.apply(b);
|
||
match (&a, &b) {
|
||
// Metavar unification — bind the metavar to the other side.
|
||
(Type::Var { name }, _) if Subst::meta_id(name).is_some() => {
|
||
let id = Subst::meta_id(name).unwrap();
|
||
if let Type::Var { name: bn } = &b {
|
||
if Subst::meta_id(bn) == Some(id) {
|
||
return Ok(()); // same metavar, done
|
||
}
|
||
}
|
||
if occurs(id, &b, subst) {
|
||
return Err(CheckError::TypeMismatch {
|
||
expected: ailang_core::pretty::type_to_string(&a),
|
||
got: ailang_core::pretty::type_to_string(&b),
|
||
});
|
||
}
|
||
subst.extend(id, b);
|
||
Ok(())
|
||
}
|
||
(_, Type::Var { name }) if Subst::meta_id(name).is_some() => {
|
||
let id = Subst::meta_id(name).unwrap();
|
||
if occurs(id, &a, subst) {
|
||
return Err(CheckError::TypeMismatch {
|
||
expected: ailang_core::pretty::type_to_string(&a),
|
||
got: ailang_core::pretty::type_to_string(&b),
|
||
});
|
||
}
|
||
subst.extend(id, a);
|
||
Ok(())
|
||
}
|
||
// Rigid vars: only unify with the same name.
|
||
(Type::Var { name: an }, Type::Var { name: bn }) if an == bn => Ok(()),
|
||
// Concrete con: same name and same arity, then unify args
|
||
// pointwise. Iter 13a: parameterised ADTs unify arg-by-arg.
|
||
(
|
||
Type::Con { name: an, args: aa },
|
||
Type::Con { name: bn, args: ba },
|
||
) if an == bn && aa.len() == ba.len() => {
|
||
for (x, y) in aa.iter().zip(ba.iter()) {
|
||
unify(x, y, subst)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
// Function types: zip params, unify ret, effects must match as a set.
|
||
(
|
||
Type::Fn { params: ap, ret: ar, effects: ae, .. },
|
||
Type::Fn { params: bp, ret: br, effects: be, .. },
|
||
) => {
|
||
if ap.len() != bp.len() {
|
||
return Err(CheckError::TypeMismatch {
|
||
expected: ailang_core::pretty::type_to_string(&a),
|
||
got: ailang_core::pretty::type_to_string(&b),
|
||
});
|
||
}
|
||
for (x, y) in ap.iter().zip(bp.iter()) {
|
||
unify(x, y, subst)?;
|
||
}
|
||
unify(ar, br, subst)?;
|
||
let mut ae = ae.clone();
|
||
let mut be = be.clone();
|
||
ae.sort();
|
||
be.sort();
|
||
if ae != be {
|
||
return Err(CheckError::TypeMismatch {
|
||
expected: ailang_core::pretty::type_to_string(&a),
|
||
got: ailang_core::pretty::type_to_string(&b),
|
||
});
|
||
}
|
||
Ok(())
|
||
}
|
||
_ => Err(CheckError::TypeMismatch {
|
||
expected: ailang_core::pretty::type_to_string(&a),
|
||
got: ailang_core::pretty::type_to_string(&b),
|
||
}),
|
||
}
|
||
}
|
||
|
||
pub mod builtins;
|
||
pub mod diagnostic;
|
||
pub mod lift;
|
||
pub mod mono;
|
||
|
||
pub use diagnostic::{Diagnostic, Severity};
|
||
pub use lift::lift_letrecs;
|
||
pub use mono::monomorphise_workspace;
|
||
|
||
/// Internal error type produced by the typechecker.
|
||
///
|
||
/// One variant per stable diagnostic code (see [`CheckError::code`]). The
|
||
/// [`CheckError::Def`] wrapper attaches the name of the def in which the
|
||
/// inner error was raised — recursive accessors ([`CheckError::code`],
|
||
/// [`CheckError::ctx`], [`CheckError::message`]) transparently see
|
||
/// through the wrapping.
|
||
///
|
||
/// Typically this enum is converted to [`Diagnostic`] via
|
||
/// [`CheckError::to_diagnostic`] before crossing the crate boundary;
|
||
/// public API functions [`check_module`] and [`check_workspace`] already
|
||
/// return `Vec<Diagnostic>`.
|
||
#[derive(Debug, thiserror::Error)]
|
||
pub enum CheckError {
|
||
/// Wraps an inner error with the name of the def it occurred in. The
|
||
/// inner error carries the actual code and ctx; the wrapper only the
|
||
/// def context that is later projected onto [`Diagnostic::def`].
|
||
#[error("def `{0}`: {1}")]
|
||
Def(String, Box<CheckError>),
|
||
|
||
/// Two types failed to unify. Code: `type-mismatch`.
|
||
#[error("type mismatch: expected {expected}, got {got}")]
|
||
TypeMismatch { expected: String, got: String },
|
||
|
||
/// A `Term::Var { name }` could not be resolved against locals,
|
||
/// globals, or the qualified-import path. Code: `unbound-var`.
|
||
#[error("unknown identifier: `{0}`")]
|
||
UnknownIdent(String),
|
||
|
||
/// `Term::Do { op }` referenced an op not in
|
||
/// [`Env::effect_ops`]. Code: `unknown-effect-op`.
|
||
#[error("unknown effect operation: `{0}`")]
|
||
UnknownEffectOp(String),
|
||
|
||
/// A non-function value was applied with `Term::App`. Code:
|
||
/// `not-a-function`.
|
||
#[error("`{0}` is not a function (got {1})")]
|
||
NotAFunction(String, String),
|
||
|
||
/// Wrong number of args at a call site. Code: `arity-mismatch`.
|
||
#[error("arity mismatch for `{name}`: expected {expected} args, got {got}")]
|
||
ArityMismatch {
|
||
name: String,
|
||
expected: usize,
|
||
got: usize,
|
||
},
|
||
|
||
/// An effect was raised in a body but not listed on the enclosing
|
||
/// fn's effect row. Code: `undeclared-effect`.
|
||
#[error("undeclared effect `{0}` used in body")]
|
||
UndeclaredEffect(String),
|
||
|
||
/// A `Def::Fn` was declared with a non-`Type::Fn` (and non-`Forall<Fn>`)
|
||
/// type. Code: `fn-type-required`.
|
||
#[error("function type required for fn `{0}`, got {1}")]
|
||
FnTypeRequired(String, String),
|
||
|
||
/// `FnDef.params.len()` does not match the parameter list of
|
||
/// `FnDef.ty`. Code: `param-count-mismatch`.
|
||
#[error("param count mismatch in `{name}`: type has {ty_count}, params has {param_count}")]
|
||
ParamCountMismatch {
|
||
name: String,
|
||
ty_count: usize,
|
||
param_count: usize,
|
||
},
|
||
|
||
/// A `Type::Forall` showed up where the MVP doesn't support
|
||
/// generalisation (e.g. inside `Def::Const`). Code:
|
||
/// `polymorphic-not-supported`.
|
||
#[error("polymorphic types not supported in MVP body of `{0}`")]
|
||
PolymorphicNotSupported(String),
|
||
|
||
/// A `Def::Const` body raised an effect — illegal because consts are
|
||
/// pure by construction. Code: `const-has-effects`.
|
||
#[error("const `{0}` may not have effects (got !{1:?})")]
|
||
ConstHasEffects(String, Vec<String>),
|
||
|
||
/// An `(export …)` fn has a parameter or return type that is not
|
||
/// a permitted embedding-ABI type. The embedding ABI is frozen as
|
||
/// of M3 (design/contracts/0003-embedding-abi.md):
|
||
/// `Int`/`Float` or a single-constructor record of those; every
|
||
/// other shape (multi-ctor sums, `Str`/`List`/nested-record
|
||
/// fields) is rejected at typecheck (the feature-acceptance
|
||
/// clause-3 discriminator, in code).
|
||
/// Code: `export-non-scalar-signature`.
|
||
#[error("export `{0}`: embedding ABI accepts `Int`/`Float` or a single-constructor record of those — {1} type `{2}` is not permitted (multi-constructor sums, and `Str`/`List`/nested-record fields, are a future M4 layer)")]
|
||
ExportNonScalarSignature(String, &'static str, String),
|
||
|
||
/// An `(export …)` fn has a non-empty effect set. An embedding
|
||
/// kernel must be pure (a `(State,Chunk)->State` fold has no
|
||
/// effects). Code: `export-has-effects`.
|
||
#[error("export `{0}` must be pure — declared effects !{1:?} are not allowed on an embedding boundary")]
|
||
ExportHasEffects(String, Vec<String>),
|
||
|
||
/// A `Type::Con` named a type not in [`Env::types`] or with a wrong
|
||
/// arity for a parameterised ADT. Code: `unknown-type`.
|
||
#[error("unknown type: `{0}`")]
|
||
UnknownType(String),
|
||
|
||
/// A `Term::Ctor` referenced a ctor that doesn't belong to the
|
||
/// declared ADT. Code: `unknown-ctor`.
|
||
#[error("type `{ty}` has no constructor `{ctor}`")]
|
||
UnknownCtor { ty: String, ctor: String },
|
||
|
||
/// A pattern referenced a ctor that does not exist in the
|
||
/// scrutinee's resolved [`TypeDef`]. The Pattern::Ctor lookup
|
||
/// is type-driven (post-canonical-type-form): it walks `td.ctors` for the
|
||
/// `expected` Type::Con's TypeDef and reports this code when
|
||
/// no ctor of that name is present. Code: `unknown-ctor-in-pattern`.
|
||
#[error("unknown constructor `{0}` in pattern")]
|
||
UnknownCtorInPattern(String),
|
||
|
||
/// Wrong number of fields in a ctor application or pattern. Code:
|
||
/// `arity-mismatch` (shared with `ArityMismatch`).
|
||
#[error("constructor `{ty}/{ctor}` arity: expected {expected} fields, got {got}")]
|
||
CtorArity {
|
||
ty: String,
|
||
ctor: String,
|
||
expected: usize,
|
||
got: usize,
|
||
},
|
||
|
||
/// A `Term::Match` does not cover every ctor of an ADT (and has no
|
||
/// open arm). Code: `non-exhaustive-match`. `ctx` carries the
|
||
/// `missing` ctor names.
|
||
#[error("non-exhaustive match on `{ty}`: missing cases {missing:?}")]
|
||
NonExhaustive { ty: String, missing: Vec<String> },
|
||
|
||
/// A `Term::Match` on a primitive type lacks a wildcard or var arm.
|
||
/// Code: `primitive-needs-wildcard`.
|
||
#[error("primitive type `{0}` requires a wildcard or variable arm in match")]
|
||
PrimitiveNeedsWildcard(String),
|
||
|
||
/// A `Pattern::Lit` matched against a `Literal::Float`. Float
|
||
/// patterns are semantically dubious (NaN never matches via
|
||
/// IEEE-`==`; equality is bit-exact not approximate), so the
|
||
/// typechecker hard-rejects them. The surface lex / parser
|
||
/// accept the syntax (iter 2); this diagnostic surfaces at
|
||
/// typecheck time. Code: `float-pattern-not-allowed`.
|
||
#[error("float-literal patterns are not allowed: use ordering operators (`<`, `>`, ...) and `is_nan` to discriminate floats")]
|
||
FloatPatternNotAllowed,
|
||
|
||
/// A `Pattern::Ctor` was matched against a value of a different ADT.
|
||
/// Code: `pattern-type-mismatch`.
|
||
#[error("cannot match constructor pattern `{ctor}` against type `{ty}`")]
|
||
PatternTypeMismatch { ctor: String, ty: String },
|
||
|
||
/// Two `Def::Type` defs share a name. Code: `duplicate-type`.
|
||
#[error("duplicate type definition: `{0}`")]
|
||
DuplicateType(String),
|
||
|
||
/// Same ctor name registered in two different ADTs. Code:
|
||
/// `duplicate-ctor`.
|
||
#[error("duplicate constructor: `{ctor}` (in types `{a}` and `{b}`)")]
|
||
DuplicateCtor { ctor: String, a: String, b: String },
|
||
|
||
/// Two top-level defs in the same module share a name. Code:
|
||
/// `duplicate-def`.
|
||
#[error("duplicate definition: `{0}`")]
|
||
DuplicateDef(String),
|
||
|
||
/// A `Pattern::Ctor` has a [`Pattern::Lit`] sub-pattern. Iter 16a
|
||
/// added an AST-level desugar that flattens nested **Ctor**
|
||
/// sub-patterns before the checker sees the module, so this
|
||
/// diagnostic now fires only for literal sub-patterns inside a
|
||
/// Ctor (still out of scope; a future iter would lower them as a
|
||
/// nested Match on the field). Code:
|
||
/// `nested-ctor-pattern-not-allowed`.
|
||
#[error("nested constructor pattern not allowed in MVP: `{0}`")]
|
||
NestedCtorPatternNotAllowed(String),
|
||
|
||
/// A qualified `Term::Var { name: "<m>.<n>" }` references a module
|
||
/// alias not declared in the current module's imports. Code:
|
||
/// `unknown-module`.
|
||
#[error("unknown module prefix `{module}` in qualified reference")]
|
||
UnknownModule { module: String },
|
||
|
||
/// The aliased module exists but does not export the named def.
|
||
/// Code: `unknown-import`.
|
||
#[error("module `{module}` has no top-level def `{name}`")]
|
||
UnknownImport { module: String, name: String },
|
||
|
||
/// A def was declared with a name containing `.`, which is reserved
|
||
/// for qualified cross-module references. Code: `invalid-def-name`.
|
||
#[error("invalid def name `{name}`: contains `.` (reserved for qualified refs)")]
|
||
InvalidDefName { name: String },
|
||
|
||
/// a `Term::App { tail: true, .. }` or
|
||
/// `Term::Do { tail: true, .. }` was found in a non-tail position.
|
||
/// Code: `tail-call-not-in-tail-position`. See `design/contracts/0012-tail-calls.md`.
|
||
#[error("call marked `tail` is not in tail position")]
|
||
TailCallNotInTailPosition,
|
||
|
||
/// a `Term::ReuseAs { body, .. }` was found whose `body`
|
||
/// is not an allocating Term variant (i.e. not `Term::Ctor` and not
|
||
/// `Term::Lam`). `(reuse-as ...)` only makes sense when the body
|
||
/// produces a fresh allocation that can take over the source's
|
||
/// memory slot; otherwise the wrapper is meaningless and is
|
||
/// rejected at typecheck. Code: `reuse-as-non-allocating-body`.
|
||
/// `ctx`: `{"got": "<term-tag>"}`. The `body_form_a` field carries
|
||
/// the form-A spelling of the inner body so [`Self::to_diagnostic`]
|
||
/// can attach a `SuggestedRewrite` that drops the wrapper.
|
||
#[error("reuse-as body must be an allocating term (Term::Ctor or Term::Lam), got {got}")]
|
||
ReuseAsNonAllocatingBody { got: String, body_form_a: String },
|
||
|
||
/// a polymorphic `FnDef` calls a class method
|
||
/// (e.g. `show x` where `x: a`), producing a residual class
|
||
/// constraint `(class, a)` at the call site, but the fn's declared
|
||
/// `Forall.constraints` does not list that constraint (and the
|
||
/// one-step superclass-expansion of the declared set still does
|
||
/// not cover it). Without an in-scope constraint, monomorphisation
|
||
/// (22b.3) would have no dictionary to thread, so this is a hard
|
||
/// soundness error. Code: `missing-constraint`. `ctx`:
|
||
/// `{"class": "<C>", "method": "<m>", "at_type": "<a>"}`.
|
||
/// Concrete-type residuals (`Type::Con`) are deferred to Task 10's
|
||
/// `no-instance` diagnostic.
|
||
#[error(
|
||
"fn `{def}` calls class method `{method}` (class `{class}`) at type `{at_type}`, \
|
||
but its declared constraints do not include `{class} {at_type}`"
|
||
)]
|
||
MissingConstraint {
|
||
def: String,
|
||
method: String,
|
||
class: String,
|
||
at_type: String,
|
||
},
|
||
|
||
/// a `FnDef` calls a class method at a
|
||
/// fully-concrete type (e.g. `show 42` resolves the residual class
|
||
/// param to `Int`), but the workspace registry has no
|
||
/// `instance class at_type` entry. This is the dual of
|
||
/// `MissingConstraint`: when the residual type is concrete, the fn
|
||
/// cannot push the obligation to a caller via `Forall.constraints`
|
||
/// — it can only be discharged by an existing instance. Without a
|
||
/// matching registry entry, monomorphisation (22b.3) would have no
|
||
/// dictionary to substitute. Code: `no-instance`. `ctx`:
|
||
/// `{"class": "<C>", "method": "<m>", "at_type": "<T>"}`.
|
||
#[error(
|
||
"fn `{def}` calls `{method}` requiring `instance {class} {at_type}`, \
|
||
but no such instance exists in the workspace"
|
||
)]
|
||
NoInstance {
|
||
def: String,
|
||
method: String,
|
||
class: String,
|
||
at_type: String,
|
||
/// additive candidate-class list. Empty on residuals from
|
||
/// the single-class dispatch path (pre-canonical-class-form shape preserved).
|
||
/// Non-empty when the diagnostic originated from the
|
||
/// multi-candidate refinement path with zero registry survivors.
|
||
candidate_classes: Vec<String>,
|
||
},
|
||
|
||
/// a monomorphic call site `<method> x` with multiple
|
||
/// candidate classes (each declaring `<method>` and each having an
|
||
/// instance for `x`'s concrete type) cannot be resolved unambiguously.
|
||
/// The LLM-author writes an explicit qualifier
|
||
/// (`<class-qualifier>.<method> x`) to disambiguate.
|
||
/// Code: `ambiguous-method-resolution`. `ctx`:
|
||
/// `{"method": "<m>", "at_type": "<T>", "candidate_classes": ["<C1>", ...]}`.
|
||
#[error(
|
||
"method `{method}` at type `{at_type}` is ambiguous: classes \
|
||
{candidate_classes:?} all declare it and provide an instance. \
|
||
Disambiguate with `<ClassQualifier>.{method}`."
|
||
)]
|
||
AmbiguousMethodResolution {
|
||
method: String,
|
||
at_type: String,
|
||
candidate_classes: Vec<String>,
|
||
},
|
||
|
||
/// an explicit class qualifier in `Term::Var.name` names a
|
||
/// qualified class that is not in the workspace registry of
|
||
/// candidate classes for the method. Code: `unknown-class`. `ctx`:
|
||
/// `{"name": "<qualified-class>"}`.
|
||
#[error("class `{name}` is not declared in any module of this workspace")]
|
||
UnknownClass {
|
||
name: String,
|
||
},
|
||
|
||
/// loop-recur iter loop-recur.tidy: `Term::Lam` reached typecheck
|
||
/// inside a lexically-enclosing `Term::Loop`, and the lambda's body
|
||
/// references a loop binder declared by that enclosing loop. Loop
|
||
/// binders are alloca-resident (codegen reuses the
|
||
/// `binder_allocas` registry, `std::mem::take`-emptied at the
|
||
/// lambda frame boundary). Capturing one into a closure would
|
||
/// require lifting it to the heap (deferred) or rejecting the
|
||
/// capture; this milestone takes the latter route rather than
|
||
/// panicking codegen on type-correct input.
|
||
#[error("loop binder `{name}` cannot be captured by a lambda — loop binders are alloca-resident and do not escape their enclosing loop. Either move the lambda outside the loop, or restructure to pass `{name}` as a lambda parameter (deferring escape to a future milestone with ref-types and the !Mut effect).")]
|
||
LoopBinderCapturedByLambda { name: String },
|
||
|
||
/// loop-recur iter 2: `Term::Recur` reached typecheck with no
|
||
/// lexically-enclosing `Term::Loop` (the `loop_stack` was empty).
|
||
#[error("`recur` is only valid inside a `loop` — there is no enclosing loop here")]
|
||
RecurOutsideLoop,
|
||
|
||
/// loop-recur iter 2: `Term::Recur`'s argument count differs from
|
||
/// the enclosing loop's binder count. `recur` rebinds every loop
|
||
/// binder positionally, so the counts must match exactly.
|
||
#[error("`recur` passes {got} argument(s) but the enclosing loop has {expected} binder(s) — recur must rebind every binder positionally")]
|
||
RecurArityMismatch { expected: usize, got: usize },
|
||
|
||
/// loop-recur iter 2: a `recur` argument's type differs from the
|
||
/// corresponding loop binder's declared type (0-based position).
|
||
#[error("`recur` argument {position} has type `{got}` but the enclosing loop's binder at that position is `{expected}` — every recur argument must match its binder's type")]
|
||
RecurTypeMismatch {
|
||
position: usize,
|
||
expected: String,
|
||
got: String,
|
||
},
|
||
|
||
/// loop-recur iter 2: `Term::Recur` appears somewhere other than
|
||
/// the tail position of its enclosing loop body. `recur` is a
|
||
/// structural back-jump; it cannot be a sub-expression.
|
||
#[error("`recur` must be in tail position of its enclosing `loop` body — it is a back-jump, not a value-producing sub-expression")]
|
||
RecurNotInTailPosition,
|
||
|
||
/// prep.1 (kernel-extension-mechanics): a dot-qualified
|
||
/// `Term::Var` whose receiver is a known `TypeDef` but the suffix
|
||
/// does not name a def in that type's home module. Code:
|
||
/// `type-scoped-member-not-found`.
|
||
#[error("type `{type_name}` (home module `{home_module}`) has no member `{member}`")]
|
||
TypeScopedMemberNotFound {
|
||
type_name: String,
|
||
member: String,
|
||
home_module: String,
|
||
},
|
||
|
||
/// prep.1 (kernel-extension-mechanics): a dot-qualified
|
||
/// `Term::Var` whose receiver is neither a known `TypeDef` nor an
|
||
/// imported module. Code: `type-scoped-receiver-not-a-type`.
|
||
#[error("`{name}` is neither a known type nor an imported module")]
|
||
TypeScopedReceiverNotAType {
|
||
name: String,
|
||
},
|
||
|
||
/// prep.2 (kernel-extension-mechanics): a `(new T args...)` call
|
||
/// where `T`'s home module declares no `new` def. Code:
|
||
/// `new-type-not-constructible`.
|
||
#[error("type `{type_name}` is not constructible: home module `{home_module}` declares no `new` def")]
|
||
NewTypeNotConstructible {
|
||
type_name: String,
|
||
home_module: String,
|
||
},
|
||
|
||
/// prep.2 (kernel-extension-mechanics): a `(new T args...)` call
|
||
/// whose Type-arg count vs. Value-arg count does not match `new`'s
|
||
/// declared signature (e.g. `new : forall a. (a) -> T a` needs one
|
||
/// Type-arg at position 0 and one Value-arg at position 1; a
|
||
/// call site with two Value-args trips this). Code:
|
||
/// `new-arg-kind-mismatch`.
|
||
#[error("`(new {type_name} …)` arg at position {position}: expected {expected}, got {got}")]
|
||
NewArgKindMismatch {
|
||
type_name: String,
|
||
position: usize,
|
||
expected: &'static str,
|
||
got: &'static str,
|
||
},
|
||
|
||
/// prep.3 (kernel-extension-mechanics): `param-in` violation. A
|
||
/// `Type::Con` for a TypeDef that restricts a type variable was
|
||
/// instantiated with a type-arg outside the allowed set. The
|
||
/// diagnostic carries the TypeDef name (which TypeDef
|
||
/// restricts), the variable name (which var was violated), the
|
||
/// rejected concrete type name (`found`), and the allowed set
|
||
/// (`allowed`, deterministically alphabetical from the BTreeSet
|
||
/// source). Code: `param-not-in-restricted-set`.
|
||
#[error(
|
||
"type-arg `{found}` is not in the restricted set for type-variable `{var}` of `{type_name}` (allowed: one of {allowed:?})"
|
||
)]
|
||
ParamNotInRestrictedSet {
|
||
type_name: String,
|
||
var: String,
|
||
found: String,
|
||
allowed: Vec<String>,
|
||
},
|
||
|
||
/// An `(intrinsic)` body appears in a module that is neither
|
||
/// kernel-tier nor the prelude. User code may not declare a body as
|
||
/// compiler-supplied — the honesty-rule guard at the workspace
|
||
/// boundary (design/contracts/0007-honesty-rule.md).
|
||
/// Code: `intrinsic-outside-kernel-tier`.
|
||
#[error("def `{def}` in module `{module}` uses an `(intrinsic)` body, which is allowed only in a kernel-tier module or the prelude")]
|
||
IntrinsicOutsideKernelTier { def: String, module: String },
|
||
|
||
/// an internal invariant in the typechecker / mono pass
|
||
/// was violated — surfaced as an error so callers can propagate
|
||
/// rather than abort, but in well-formed inputs (typecheck has
|
||
/// succeeded) it must never fire. Code: `internal`.
|
||
#[error("internal: {0}")]
|
||
Internal(String),
|
||
}
|
||
|
||
pub(crate) type Result<T> = std::result::Result<T, CheckError>;
|
||
|
||
impl CheckError {
|
||
/// Stable kebab-case code for machine consumption (`ail check --json`).
|
||
/// Passed through recursively via the `Def` wrapping — the inner error
|
||
/// carries the actual code, the wrapper only the def context.
|
||
pub fn code(&self) -> &'static str {
|
||
match self {
|
||
CheckError::Def(_, inner) => inner.code(),
|
||
CheckError::TypeMismatch { .. } => "type-mismatch",
|
||
CheckError::UnknownIdent(_) => "unbound-var",
|
||
CheckError::UnknownEffectOp(_) => "unknown-effect-op",
|
||
CheckError::NotAFunction(..) => "not-a-function",
|
||
CheckError::ArityMismatch { .. } => "arity-mismatch",
|
||
CheckError::UndeclaredEffect(_) => "undeclared-effect",
|
||
CheckError::FnTypeRequired(..) => "fn-type-required",
|
||
CheckError::ParamCountMismatch { .. } => "param-count-mismatch",
|
||
CheckError::PolymorphicNotSupported(_) => "polymorphic-not-supported",
|
||
CheckError::ConstHasEffects(..) => "const-has-effects",
|
||
CheckError::ExportNonScalarSignature(..) => "export-non-scalar-signature",
|
||
CheckError::ExportHasEffects(..) => "export-has-effects",
|
||
CheckError::UnknownType(_) => "unknown-type",
|
||
CheckError::UnknownCtor { .. } => "unknown-ctor",
|
||
CheckError::UnknownCtorInPattern(_) => "unknown-ctor-in-pattern",
|
||
CheckError::CtorArity { .. } => "arity-mismatch",
|
||
CheckError::NonExhaustive { .. } => "non-exhaustive-match",
|
||
CheckError::PrimitiveNeedsWildcard(_) => "primitive-needs-wildcard",
|
||
CheckError::FloatPatternNotAllowed => "float-pattern-not-allowed",
|
||
CheckError::PatternTypeMismatch { .. } => "pattern-type-mismatch",
|
||
CheckError::DuplicateType(_) => "duplicate-type",
|
||
CheckError::DuplicateCtor { .. } => "duplicate-ctor",
|
||
CheckError::DuplicateDef(_) => "duplicate-def",
|
||
CheckError::NestedCtorPatternNotAllowed(_) => "nested-ctor-pattern-not-allowed",
|
||
CheckError::UnknownModule { .. } => "unknown-module",
|
||
CheckError::UnknownImport { .. } => "unknown-import",
|
||
CheckError::InvalidDefName { .. } => "invalid-def-name",
|
||
CheckError::TailCallNotInTailPosition => "tail-call-not-in-tail-position",
|
||
CheckError::ReuseAsNonAllocatingBody { .. } => "reuse-as-non-allocating-body",
|
||
CheckError::MissingConstraint { .. } => "missing-constraint",
|
||
CheckError::NoInstance { .. } => "no-instance",
|
||
CheckError::AmbiguousMethodResolution { .. } => "ambiguous-method-resolution",
|
||
CheckError::UnknownClass { .. } => "unknown-class",
|
||
CheckError::LoopBinderCapturedByLambda { .. } => "loop-binder-captured-by-lambda",
|
||
CheckError::RecurOutsideLoop => "recur-outside-loop",
|
||
CheckError::RecurArityMismatch { .. } => "recur-arity-mismatch",
|
||
CheckError::RecurTypeMismatch { .. } => "recur-type-mismatch",
|
||
CheckError::RecurNotInTailPosition => "recur-not-in-tail-position",
|
||
CheckError::TypeScopedMemberNotFound { .. } => "type-scoped-member-not-found",
|
||
CheckError::TypeScopedReceiverNotAType { .. } => "type-scoped-receiver-not-a-type",
|
||
CheckError::NewTypeNotConstructible { .. } => "new-type-not-constructible",
|
||
CheckError::NewArgKindMismatch { .. } => "new-arg-kind-mismatch",
|
||
CheckError::ParamNotInRestrictedSet { .. } => "param-not-in-restricted-set",
|
||
CheckError::IntrinsicOutsideKernelTier { .. } => "intrinsic-outside-kernel-tier",
|
||
CheckError::Internal(_) => "internal",
|
||
}
|
||
}
|
||
|
||
/// Structured context for a diagnostic. Lands directly in the JSON
|
||
/// under the key `ctx`. Empty object when no context is available.
|
||
pub fn ctx(&self) -> serde_json::Value {
|
||
match self {
|
||
CheckError::Def(_, inner) => inner.ctx(),
|
||
CheckError::TypeMismatch { expected, got } => {
|
||
serde_json::json!({"expected": expected, "actual": got})
|
||
}
|
||
CheckError::ArityMismatch { expected, got, .. } => {
|
||
serde_json::json!({"expected": expected, "actual": got})
|
||
}
|
||
CheckError::CtorArity {
|
||
expected, got, ..
|
||
} => serde_json::json!({"expected": expected, "actual": got}),
|
||
CheckError::ParamCountMismatch {
|
||
ty_count,
|
||
param_count,
|
||
..
|
||
} => serde_json::json!({"expected": ty_count, "actual": param_count}),
|
||
CheckError::NonExhaustive { missing, .. } => {
|
||
serde_json::json!({"missing": missing})
|
||
}
|
||
CheckError::UnknownModule { module } => {
|
||
serde_json::json!({"module": module})
|
||
}
|
||
CheckError::UnknownImport { module, name } => {
|
||
serde_json::json!({"module": module, "name": name})
|
||
}
|
||
CheckError::InvalidDefName { name } => {
|
||
serde_json::json!({"name": name, "reason": "contains-dot"})
|
||
}
|
||
CheckError::ReuseAsNonAllocatingBody { got, .. } => {
|
||
serde_json::json!({"got": got})
|
||
}
|
||
CheckError::MissingConstraint { class, method, at_type, .. } => {
|
||
serde_json::json!({"class": class, "method": method, "at_type": at_type})
|
||
}
|
||
CheckError::NoInstance { class, method, at_type, candidate_classes, .. } => {
|
||
let mut obj = serde_json::json!({
|
||
"class": class,
|
||
"method": method,
|
||
"at_type": at_type,
|
||
});
|
||
if !candidate_classes.is_empty() {
|
||
obj["candidate_classes"] = serde_json::json!(candidate_classes);
|
||
}
|
||
obj
|
||
}
|
||
CheckError::AmbiguousMethodResolution { method, at_type, candidate_classes } => {
|
||
serde_json::json!({
|
||
"method": method,
|
||
"at_type": at_type,
|
||
"candidate_classes": candidate_classes,
|
||
})
|
||
}
|
||
CheckError::UnknownClass { name } => {
|
||
serde_json::json!({ "name": name })
|
||
}
|
||
CheckError::LoopBinderCapturedByLambda { name } => {
|
||
serde_json::json!({"name": name})
|
||
}
|
||
CheckError::RecurArityMismatch { expected, got } => {
|
||
serde_json::json!({"expected": expected, "actual": got})
|
||
}
|
||
CheckError::RecurTypeMismatch { position, expected, got } => {
|
||
serde_json::json!({"position": position, "expected": expected, "actual": got})
|
||
}
|
||
CheckError::TypeScopedMemberNotFound { type_name, member, home_module } => {
|
||
serde_json::json!({"type": type_name, "member": member, "home_module": home_module})
|
||
}
|
||
CheckError::TypeScopedReceiverNotAType { name } => {
|
||
serde_json::json!({"name": name})
|
||
}
|
||
CheckError::NewTypeNotConstructible { type_name, home_module } => {
|
||
serde_json::json!({"type": type_name, "home_module": home_module})
|
||
}
|
||
CheckError::NewArgKindMismatch { type_name, position, expected, got } => {
|
||
serde_json::json!({
|
||
"type": type_name,
|
||
"position": position,
|
||
"expected": expected,
|
||
"actual": got,
|
||
})
|
||
}
|
||
CheckError::ParamNotInRestrictedSet { type_name, var, found, allowed } => {
|
||
serde_json::json!({
|
||
"type": type_name,
|
||
"var": var,
|
||
"found": found,
|
||
"allowed": allowed,
|
||
})
|
||
}
|
||
_ => serde_json::Value::Object(serde_json::Map::new()),
|
||
}
|
||
}
|
||
|
||
/// If this error is wrapped by [`CheckError::Def`], returns the name
|
||
/// of the affected def. Otherwise `None`.
|
||
pub fn def(&self) -> Option<&str> {
|
||
match self {
|
||
CheckError::Def(n, _) => Some(n.as_str()),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Unwraps the error potentially wrapped by [`CheckError::Def`].
|
||
pub fn inner(&self) -> &CheckError {
|
||
match self {
|
||
CheckError::Def(_, inner) => inner.inner(),
|
||
other => other,
|
||
}
|
||
}
|
||
|
||
/// Non-`Def`-wrapped message. Without the `def: ...` prefix.
|
||
pub fn message(&self) -> String {
|
||
format!("{}", self.inner())
|
||
}
|
||
|
||
/// Lowers this error into the public [`Diagnostic`] shape consumed
|
||
/// by `ail check --json`. Pulls `code`, `message`, and `ctx` through
|
||
/// the recursive accessors and attaches the optional def name from
|
||
/// any wrapping [`CheckError::Def`].
|
||
pub fn to_diagnostic(&self) -> Diagnostic {
|
||
let mut d = Diagnostic::error(self.code(), self.message()).with_ctx(self.ctx());
|
||
if let Some(name) = self.def() {
|
||
d = d.with_def(name);
|
||
}
|
||
// typecheck-side suggested_rewrites for the
|
||
// reuse-as-non-allocating-body diagnostic. The replacement is
|
||
// simply the body without the `(reuse-as ...)` wrapper — the
|
||
// hint is meaningless here, so drop it.
|
||
if let CheckError::ReuseAsNonAllocatingBody { body_form_a, .. } = self.inner() {
|
||
d = d.with_suggested_rewrite(
|
||
"drop the meaningless reuse-as wrapper around the non-allocating body",
|
||
body_form_a.clone(),
|
||
);
|
||
}
|
||
// Float-aware addendum on `NoInstance`. When the
|
||
// unresolved class is `Eq` or `Ord` AND the type is `Float`,
|
||
// append a cross-reference to design/contracts/0005-float-semantics.md so
|
||
// the LLM author learns the partial-Float story immediately
|
||
// rather than seeing a bare "no instance" message. Float has
|
||
// neither Eq nor Ord by design (partial orderability per
|
||
// IEEE-754), and the polymorphic prelude helpers
|
||
// (`ne` / `lt` / `le` / `gt` / `ge` / direct `eq` / `compare`)
|
||
// are the natural surface where the LLM hits this.
|
||
if let CheckError::NoInstance { class, at_type, .. } = self.inner() {
|
||
// `class` carries the qualified workspace-key shape
|
||
// (`prelude.Eq` / `prelude.Ord`), not the bare name.
|
||
if (class == "prelude.Eq" || class == "prelude.Ord") && at_type == "Float" {
|
||
d.message = format!(
|
||
"{} — Float has no Eq/Ord instance by design (partial \
|
||
orderability per IEEE-754); use float_eq / float_lt \
|
||
(and siblings float_ne / float_le / float_gt / float_ge) \
|
||
for explicit IEEE-aware comparison. \
|
||
See design/contracts/0005-float-semantics.md.",
|
||
d.message
|
||
);
|
||
} else if class == "prelude.Show" {
|
||
// Show-aware addendum on `NoInstance`. Fires
|
||
// for any type lacking a Show instance — most commonly
|
||
// a function type (`print f` where f is bare-fn-typed)
|
||
// or a user type the LLM-author forgot to give an
|
||
// instance for. Cross-references
|
||
// design/contracts/0013-typeclasses.md so the author learns
|
||
// which types ship with built-in Show immediately.
|
||
d.message = format!(
|
||
"{} — `print` and `show` require a Show instance. \
|
||
Built-in Show ships for Int, Bool, Str, Float in \
|
||
the prelude; see design/contracts/0013-typeclasses.md. \
|
||
User types declare their own \
|
||
`instance prelude.Show <T>` in the type's defining \
|
||
module per the typeclass design coherence.",
|
||
d.message
|
||
);
|
||
}
|
||
}
|
||
d
|
||
}
|
||
}
|
||
|
||
/// Top-level API for structured diagnostics.
|
||
///
|
||
/// Empty Vec = green. From Iter 6 onwards the body-check phase is
|
||
/// multi-diagnose: each def in each module is checked independently and
|
||
/// failures accumulate, so a single `ail check` run reports every
|
||
/// independent body error in the workspace.
|
||
///
|
||
/// Pass-1 errors (top-level symbol-table construction:
|
||
/// `invalid-def-name`, `duplicate-def`) are still fail-fast — those
|
||
/// errors corrupt the symbol table, and any further diagnostic would be
|
||
/// unreliable. Likewise, the type-def installation is fail-fast within
|
||
/// a single module, but other modules continue being checked.
|
||
///
|
||
/// Backwards compatibility: a bare `&Module` is internally lifted into a
|
||
/// trivial workspace (`modules = {m.name: m}`, `entry = m.name`) so that
|
||
/// tooling checking individual modules avoids building a `Workspace`.
|
||
/// Modules with imports on other modules not present in the trivial
|
||
/// workspace will inevitably produce `unknown-module` errors on qualified
|
||
/// references — which is correct.
|
||
pub fn check_module(m: &Module) -> Vec<Diagnostic> {
|
||
// flatten nested constructor patterns before any check
|
||
// logic touches the AST. The rewrite is pure and runs in memory;
|
||
// canonical-JSON hashes (computed by `ailang_core::load_module` /
|
||
// `def_hash`) are unaffected because they use the on-disk form.
|
||
let m = ailang_core::desugar::desugar_module(m);
|
||
let mut modules = BTreeMap::new();
|
||
modules.insert(m.name.clone(), m.clone());
|
||
let ws = Workspace {
|
||
entry: m.name.clone(),
|
||
modules,
|
||
root_dir: std::path::PathBuf::from("."),
|
||
// single-module check_module entry point does not
|
||
// build a registry. The registry is workspace-load-time
|
||
// metadata; check_module is invoked from in-memory paths
|
||
// (tests, single-file CLI) where no workspace DFS happened.
|
||
// Once 22b.2 typecheck arms read the registry, those paths
|
||
// will need to populate it from the in-memory module too.
|
||
registry: ailang_core::workspace::Registry::default(),
|
||
};
|
||
check_workspace(&ws)
|
||
}
|
||
|
||
/// Top-level API for cross-module typecheck.
|
||
///
|
||
/// prep.1 (kernel-extension-mechanics): apply the pre-typecheck
|
||
/// preparation steps the workspace needs both for typechecking and
|
||
/// monomorphisation:
|
||
///
|
||
/// 1. Desugar every module via `ailang_core::desugar::desugar_module`.
|
||
/// 2. Normalize every consumer module's bare cross-module `Type::Con`
|
||
/// references to qualified `<home>.<Type>` form (so consumer-side
|
||
/// declared types unify with imported-fn signatures, which the
|
||
/// existing [`qualify_local_types`] step already qualifies at the
|
||
/// owner's side).
|
||
///
|
||
/// Both [`check_workspace`] and [`monomorphise_workspace`] call this
|
||
/// helper at their entry. The registry passes through unchanged.
|
||
pub fn prepare_workspace_for_check(ws: &Workspace) -> Workspace {
|
||
let workspace_types_pre: BTreeMap<String, IndexMap<String, TypeDef>> =
|
||
ws.modules
|
||
.iter()
|
||
.map(|(name, m)| {
|
||
let mut tys: IndexMap<String, TypeDef> = IndexMap::new();
|
||
for def in &m.defs {
|
||
if let Def::Type(td) = def {
|
||
tys.insert(td.name.clone(), td.clone());
|
||
}
|
||
}
|
||
(name.clone(), tys)
|
||
})
|
||
.collect();
|
||
// raw-buf.4: per-module set of *monomorphic* (non-`Forall`)
|
||
// top-level fn names. A type-scoped call `T.f` to a monomorphic
|
||
// op (e.g. the `raw_buf.RawBuf.new` produced by the Term::New
|
||
// desugar) is normalised to the module-qualified `<home>.f` form
|
||
// by `qualify_workspace_term` — codegen recognises that spelling
|
||
// directly. Polymorphic type-scoped ops stay in the `T.f` form so
|
||
// monomorphisation can mint their scope-qualified `T_f__<elem>`
|
||
// symbols (the raw-buf.3 mechanism RawBuf's ops use).
|
||
let workspace_mono_fns: BTreeMap<String, BTreeSet<String>> = ws
|
||
.modules
|
||
.iter()
|
||
.map(|(name, m)| {
|
||
let fns: BTreeSet<String> = m
|
||
.defs
|
||
.iter()
|
||
.filter_map(|def| match def {
|
||
Def::Fn(f) if !matches!(f.ty, Type::Forall { .. }) => Some(f.name.clone()),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
(name.clone(), fns)
|
||
})
|
||
.collect();
|
||
Workspace {
|
||
entry: ws.entry.clone(),
|
||
modules: ws
|
||
.modules
|
||
.iter()
|
||
.map(|(k, m)| {
|
||
let m = ailang_core::desugar::desugar_module(m);
|
||
let own_local_types = workspace_types_pre
|
||
.get(k)
|
||
.cloned()
|
||
.unwrap_or_default();
|
||
(
|
||
k.clone(),
|
||
qualify_workspace_module(
|
||
m,
|
||
&own_local_types,
|
||
&workspace_types_pre,
|
||
&workspace_mono_fns,
|
||
),
|
||
)
|
||
})
|
||
.collect(),
|
||
root_dir: ws.root_dir.clone(),
|
||
registry: ws.registry.clone(),
|
||
}
|
||
}
|
||
|
||
/// Iterates over all modules of the workspace and checks each with access
|
||
/// to the top-level symbol tables of all other modules. Qualified
|
||
/// references are resolved via the import map of the respective module:
|
||
/// `Term::Var { name }` with exactly one dot in the name is interpreted
|
||
/// as `<prefix>.<def>`; `<prefix>` is an import alias (or the module name,
|
||
/// if imported without an alias).
|
||
///
|
||
/// Multi-diagnose: pass-2 collects diagnostics per def across all modules.
|
||
/// Pass-1 (symbol table) stays fail-fast — see [`check_module`].
|
||
/// Module iteration order is deterministic: entry first, then the rest in
|
||
/// BTreeMap order, so output ordering is stable.
|
||
pub fn check_workspace(ws: &Workspace) -> Vec<Diagnostic> {
|
||
// Floats milestone post-fieldtest B1: hard-reject
|
||
// `Pattern::Lit { Literal::Float }` before desugar rewrites lit
|
||
// arms into `(== scrutinee lit)`. See `pre_desugar_validation`
|
||
// for the full rationale. Walked per-def so the diagnostic
|
||
// carries the offending def's name; modules are visited in
|
||
// BTreeMap order, defs in declaration order. All hits in this
|
||
// pass are accumulated and returned together (consistent with
|
||
// pass-2 multi-diagnose); if any are present, later passes do
|
||
// not run, since a violating Float-lit pattern would otherwise
|
||
// be silently rewritten by desugar before typecheck.
|
||
{
|
||
let mut pre_desugar_errors: Vec<Diagnostic> = Vec::new();
|
||
for m in ws.modules.values() {
|
||
for def in &m.defs {
|
||
if let Err(e) = pre_desugar_validation::reject_float_patterns_in_def(def) {
|
||
let wrapped = CheckError::Def(def.name().to_string(), Box::new(e));
|
||
pre_desugar_errors.push(wrapped.to_diagnostic());
|
||
}
|
||
}
|
||
}
|
||
if !pre_desugar_errors.is_empty() {
|
||
return pre_desugar_errors;
|
||
}
|
||
}
|
||
// desugar every module of the workspace before any check
|
||
// logic runs. The rewrite is pure and per-module; we rebuild a
|
||
// workspace shell around the desugared modules (paths and entry
|
||
// are unchanged).
|
||
//
|
||
// prep.1 (kernel-extension-mechanics): after desugar, normalize
|
||
// each consumer module's bare cross-module `Type::Con` references
|
||
// to qualified form via [`prepare_workspace_for_check`]. The
|
||
// monomorphisation pass mirrors this step in its own entry
|
||
// (`monomorphise_workspace`); the two passes consume identically
|
||
// shaped workspaces. The registry passes through unchanged — the
|
||
// desugar pass does not touch class/instance defs (see desugar.rs:
|
||
// 22b.1 passthrough), so the registry built at load time remains
|
||
// valid against the prepared workspace.
|
||
let ws_owned = prepare_workspace_for_check(ws);
|
||
let ws = &ws_owned;
|
||
// Pass 1: build per-module top-level symbol table — without checking
|
||
// bodies. This lets module A access defs from module B even when B
|
||
// comes later in the BTreeMap. Duplicate def names and dot-in-def
|
||
// names are reported here immediately, because without clean symbol
|
||
// tables all further diagnostics would be unreliable.
|
||
let module_globals = match build_module_globals(ws) {
|
||
Ok(g) => g,
|
||
Err(e) => return vec![e.to_diagnostic()],
|
||
};
|
||
|
||
// Pass 2: body-check per module. `check_in_workspace` builds the env
|
||
// with additional cross-module globals and an import map. Errors
|
||
// accumulate across modules.
|
||
let mut order: Vec<&String> = Vec::new();
|
||
if ws.modules.contains_key(&ws.entry) {
|
||
order.push(&ws.entry);
|
||
}
|
||
for name in ws.modules.keys() {
|
||
if name != &ws.entry {
|
||
order.push(name);
|
||
}
|
||
}
|
||
let mut diagnostics: Vec<Diagnostic> = Vec::new();
|
||
for name in order {
|
||
let m = &ws.modules[name];
|
||
// synth-time warnings collected per module then merged
|
||
// into the per-module diagnostic stream alongside typecheck
|
||
// errors. The synth warnings flow through `check_in_workspace`
|
||
// even when `had_typecheck_errors` is true; surface them so a
|
||
// shadowed-class-method warning is visible even when the
|
||
// module also has unrelated errors.
|
||
let mut synth_warnings: Vec<Diagnostic> = Vec::new();
|
||
let typecheck_errors = check_in_workspace(m, ws, &module_globals, &mut synth_warnings);
|
||
let had_typecheck_errors = !typecheck_errors.is_empty();
|
||
// Per-module diagnostic accumulator. The suppress filter
|
||
// (Iter 19b) runs at the end of the per-module block, so
|
||
// we keep this module's diagnostics in their own vec while
|
||
// accumulating, then merge into the workspace-wide list.
|
||
let mut module_diags: Vec<Diagnostic> = Vec::new();
|
||
for e in typecheck_errors {
|
||
module_diags.push(e.to_diagnostic());
|
||
}
|
||
module_diags.extend(synth_warnings);
|
||
// linearity check runs only on modules that
|
||
// typechecked clean. Running it on a body that already has a
|
||
// type error would wade into a partly-defined IR (e.g.
|
||
// unresolved Var lookups, mismatched ctor arities) and produce
|
||
// noise. Cleanly-typechecked modules with at least one
|
||
// all-explicit-mode fn are exactly the surface the check is
|
||
// designed to inspect.
|
||
if !had_typecheck_errors {
|
||
// linearity check needs visibility into
|
||
// polymorphic free fns + class methods from implicitly-
|
||
// imported modules (currently just the auto-injected
|
||
// prelude). Without this, a Borrow-mode user fn that
|
||
// forwards its borrow params into a prelude poly fn
|
||
// (e.g. `gt x y`) fires `consume-while-borrowed`
|
||
// false positives because `callee_arg_modes` defaults
|
||
// to Consume when it cannot see the callee's
|
||
// `param_modes`. Symmetric to the class-method case
|
||
// closed in iter 23.4-prep.
|
||
let visible_extra: Vec<&Module> = ws
|
||
.modules
|
||
.iter()
|
||
.filter_map(|(other_name, other_mod)| {
|
||
if other_name == name {
|
||
None
|
||
} else {
|
||
Some(other_mod)
|
||
}
|
||
})
|
||
.collect();
|
||
module_diags.extend(linearity::check_module_with_visible(m, &visible_extra));
|
||
// reuse-as shape compatibility check. Runs on
|
||
// the same activation gate as linearity (all-explicit-mode
|
||
// fns only). The check resolves each `(reuse-as <var>
|
||
// <body-ctor>)` site against the path-ctor of `<var>` and
|
||
// rejects mismatches with `reuse-as-shape-mismatch`. Runs
|
||
// after linearity so its output appears after linearity's
|
||
// diagnostics for the same def — stable ordering for the
|
||
// JSON consumer.
|
||
module_diags.extend(reuse_shape::check_module(m));
|
||
}
|
||
// apply per-fn `suppress` filter. Runs after every
|
||
// diagnostic source has appended so any code the author
|
||
// listed can actually be matched. Drops matching diagnostics
|
||
// and pushes `empty-suppress-reason` (Error) for malformed
|
||
// entries. See [`crate::suppress_filter`] for details.
|
||
suppress_filter::apply(m, &mut module_diags);
|
||
diagnostics.extend(module_diags);
|
||
}
|
||
diagnostics
|
||
}
|
||
|
||
/// Result of typechecking a module: mapping from symbol name to
|
||
/// (type, hash) — ready for `manifest` output.
|
||
#[derive(Debug, Clone)]
|
||
pub struct CheckedModule {
|
||
/// Top-level symbols of the module in declaration order. The value
|
||
/// is `(declared_type, content_hash)` — the hash is the BLAKE3 def
|
||
/// hash from `ailang_core::hash::def_hash`, used by `ail diff` and
|
||
/// the cross-module manifest.
|
||
pub symbols: IndexMap<String, (Type, String)>,
|
||
}
|
||
|
||
/// Single-error entry point. Typechecks `m` standalone (trivial
|
||
/// workspace), returning a [`CheckedModule`] with the symbol-to-hash
|
||
/// table on success or the **first** [`CheckError`] on failure.
|
||
///
|
||
/// Prefer [`check_module`] for new callers — it returns
|
||
/// `Vec<Diagnostic>` (multi-diagnose, JSON-ready). This function is
|
||
/// kept for legacy callers (snapshot tests, the `manifest` codepath
|
||
/// that needs the hash-to-type mapping).
|
||
pub fn check(m: &Module) -> Result<CheckedModule> {
|
||
// Floats milestone post-fieldtest B1: hard-reject
|
||
// `Pattern::Lit { Literal::Float }` before desugar runs, because
|
||
// `desugar::build_eq` rewrites lit-pattern arms (Float included)
|
||
// into `(== scrutinee lit)` and would otherwise hide the
|
||
// unreachable Float-pattern arm in `type_check_pattern`. See
|
||
// `pre_desugar_validation` for the full rationale.
|
||
pre_desugar_validation::reject_float_patterns_in_module(m)?;
|
||
// desugar nested ctor patterns before constructing the
|
||
// trivial workspace. See `check_module` for the rationale. The
|
||
// returned `CheckedModule.symbols` content hashes are derived from
|
||
// the *original* on-disk module so they keep the canonical-bytes
|
||
// identity that `ail diff` / `ail manifest` rely on.
|
||
let original = m;
|
||
let desugared = ailang_core::desugar::desugar_module(m);
|
||
let m = &desugared;
|
||
// Trivial workspace: the module alone, without cross-module resolution.
|
||
let mut modules = BTreeMap::new();
|
||
modules.insert(m.name.clone(), m.clone());
|
||
let ws = Workspace {
|
||
entry: m.name.clone(),
|
||
modules,
|
||
root_dir: std::path::PathBuf::from("."),
|
||
// the legacy single-module `check` entry point
|
||
// builds an empty registry. See `check_module` for the same
|
||
// pattern; once 22b.2 typecheck arms read the registry, both
|
||
// paths must populate it from the in-memory module.
|
||
registry: ailang_core::workspace::Registry::default(),
|
||
};
|
||
let module_globals = build_module_globals(&ws)?;
|
||
// `check` keeps single-error semantics for callers (snapshot tests,
|
||
// legacy code). Multi-diagnose is exposed via `check_module` /
|
||
// `check_workspace`. Synth warnings are collected here but
|
||
// discarded — `check`'s legacy contract returns only the first
|
||
// error, and warnings on this path have no surface to flow to.
|
||
let mut synth_warnings: Vec<Diagnostic> = Vec::new();
|
||
if let Some(first) = check_in_workspace(m, &ws, &module_globals, &mut synth_warnings).into_iter().next() {
|
||
return Err(first);
|
||
}
|
||
// Collect symbols for the return value (existing semantics).
|
||
// Hashes are computed over the *original* defs, so callers like
|
||
// `ail diff` see the on-disk identity, not a post-desugar one.
|
||
let mut symbols = IndexMap::new();
|
||
for def in &original.defs {
|
||
let h = ailang_core::hash::def_hash(def);
|
||
let ty = match def {
|
||
Def::Fn(f) => f.ty.clone(),
|
||
Def::Const(c) => c.ty.clone(),
|
||
Def::Type(_) => Type::Con {
|
||
name: def.name().to_string(),
|
||
args: vec![],
|
||
},
|
||
// class/instance defs are not yet checked.
|
||
// The symbols map is keyed by definition name; class/
|
||
// instance contribute their class name, but with no
|
||
// concrete pre-monomorphisation type we represent them
|
||
// as a placeholder Con. Tools that consume this map
|
||
// (`ail diff`, `ail manifest`) read kind separately
|
||
// via `def_kind`, so the placeholder type does not
|
||
// mislead them.
|
||
Def::Class(_) | Def::Instance(_) => Type::Con {
|
||
name: def.name().to_string(),
|
||
args: vec![],
|
||
},
|
||
};
|
||
symbols.insert(def.name().to_string(), (ty, h));
|
||
}
|
||
Ok(CheckedModule { symbols })
|
||
}
|
||
|
||
/// typecheck `m` and return both the [`CheckedModule`]
|
||
/// (for tooling that wants the original on-disk symbol identities)
|
||
/// AND the lifted module ready for codegen — i.e. the desugared
|
||
/// module with every surviving `Term::LetRec` replaced by a
|
||
/// synthetic top-level `Def::Fn`.
|
||
///
|
||
/// The check phase runs unchanged: same desugar, same typecheck,
|
||
/// same `CheckedModule.symbols` (built from the original `m`'s
|
||
/// defs). On success, the desugared module is fed through
|
||
/// [`lift_letrecs`] and the result is returned alongside.
|
||
///
|
||
/// `build` / `run` go through this entry; the `check` subcommand
|
||
/// stays on the legacy [`check`] entry (no lift needed for
|
||
/// type-checking only).
|
||
pub fn check_and_lift(m: &Module) -> Result<(CheckedModule, Module)> {
|
||
let cm = check(m)?;
|
||
// Run desugar exactly as `check` does. The `check` call already
|
||
// ran desugar internally, but its result is discarded (only the
|
||
// CheckedModule survives), so we have to re-run it here to get
|
||
// the post-desugar form for the lift.
|
||
let desugared = ailang_core::desugar::desugar_module(m);
|
||
let lifted = lift_letrecs(&desugared)?;
|
||
Ok((cm, lifted))
|
||
}
|
||
|
||
/// builds the ADT type-def table per module. Sibling of
|
||
/// [`build_module_globals`]: gives the body checker O(1) lookup of any
|
||
/// type declared anywhere in the workspace, keyed by module name.
|
||
/// Read by qualified `Type::Con.name` resolution
|
||
/// (`module.Type`), qualified `Term::Ctor.type_name`, and the
|
||
/// cross-module `Pattern::Ctor` fallback. Duplicate type / ctor errors
|
||
/// inside a single module surface during the per-module body-check
|
||
/// phase, not here.
|
||
pub(crate) fn build_module_types(
|
||
ws: &Workspace,
|
||
) -> BTreeMap<String, IndexMap<String, TypeDef>> {
|
||
let mut out: BTreeMap<String, IndexMap<String, TypeDef>> = BTreeMap::new();
|
||
for (mname, m) in &ws.modules {
|
||
let mut tys = IndexMap::new();
|
||
for def in &m.defs {
|
||
if let Def::Type(td) = def {
|
||
// Last definition wins on duplicate; the per-module
|
||
// body-check phase reports `duplicate-type` separately.
|
||
tys.insert(td.name.clone(), td.clone());
|
||
}
|
||
}
|
||
out.insert(mname.clone(), tys);
|
||
}
|
||
out
|
||
}
|
||
|
||
/// per-module top-level symbol table. Holds the classic
|
||
/// fn/const/type-name-to-type mapping in [`Self::fns`] and class-method
|
||
/// names in [`Self::class_methods`]. The two channels are kept separate
|
||
/// because class-method resolution at a call site needs more context
|
||
/// than a plain `Type` lookup (the owning class, the class param name,
|
||
/// and the defining module — see [`ClassMethodEntry`]). Pre-22b.2 the
|
||
/// inner shape was `IndexMap<String, Type>`; 22b.2 wraps that in this
|
||
/// struct so call sites that only need fn lookup can keep using
|
||
/// `mod_globals.fns` while typeclass code goes through `class_methods`.
|
||
#[derive(Debug, Default, Clone)]
|
||
pub struct ModuleGlobals {
|
||
/// Top-level fn / const / type symbols, by name. The pre-22b.2 inner
|
||
/// shape — every call site that only needs HM lookup goes through
|
||
/// here.
|
||
pub fns: IndexMap<String, Type>,
|
||
/// re-keyed by `(qualified-class-name, method-name)` tuple —
|
||
/// post-retirement of `MethodNameCollision`, two classes can declare
|
||
/// same-named methods within the same workspace. The tuple key
|
||
/// disambiguates without losing the per-module ordering guarantee
|
||
/// (`IndexMap` preserves insertion order). Lookups by method-name-
|
||
/// only consult `method_to_candidate_classes` (Env-level) to get
|
||
/// the candidate-class set, then index into `class_methods` with
|
||
/// `(resolved_class, method)`.
|
||
pub class_methods: IndexMap<(String, String), ClassMethodEntry>,
|
||
}
|
||
|
||
impl ModuleGlobals {
|
||
/// True if class `class` declares a method named `name` in this
|
||
/// module. Tuple-keyed lookup after `MethodNameCollision`
|
||
/// retirement.
|
||
pub fn has_class_method(&self, class: &str, name: &str) -> bool {
|
||
self.class_methods.contains_key(&(class.to_string(), name.to_string()))
|
||
}
|
||
|
||
/// The class that declared the method `name` in class `class`, or
|
||
/// `None` if no such entry exists. (Pre-canonical-class-form callers passed only the
|
||
/// method name; post-canonical-class-form they must disambiguate by class.)
|
||
pub fn class_method_class(&self, class: &str, name: &str) -> Option<&str> {
|
||
self.class_methods
|
||
.get(&(class.to_string(), name.to_string()))
|
||
.map(|e| e.class_name.as_str())
|
||
}
|
||
|
||
/// The full [`ClassMethodEntry`] for method `name` in class `class`,
|
||
/// or `None` if no such entry exists.
|
||
pub fn class_method(&self, class: &str, name: &str) -> Option<&ClassMethodEntry> {
|
||
self.class_methods.get(&(class.to_string(), name.to_string()))
|
||
}
|
||
|
||
/// enumerate all `(class, method) -> entry` pairs whose
|
||
/// method name matches `name`. Returns zero-or-more entries —
|
||
/// pre-canonical-class-form always at most one, post-canonical-class-form a workspace may have
|
||
/// multiple classes declaring the same method.
|
||
pub fn class_method_candidates(&self, name: &str) -> Vec<(&String, &ClassMethodEntry)> {
|
||
self.class_methods
|
||
.iter()
|
||
.filter_map(|((c, m), e)| if m == name { Some((c, e)) } else { None })
|
||
.collect()
|
||
}
|
||
}
|
||
|
||
/// per-class-method metadata stored in
|
||
/// [`ModuleGlobals::class_methods`]. Carries everything the 22b.2
|
||
/// typecheck arms (`missing-constraint`, `no-instance`) need to resolve
|
||
/// a `Term::Var { name }` that refers to a class method into a residual
|
||
/// class constraint at the call site.
|
||
#[derive(Debug, Clone)]
|
||
pub struct ClassMethodEntry {
|
||
/// The class that declared the method (e.g. `Show` for `show`).
|
||
///
|
||
/// carries the qualified form `<defining_module>.<Class>`;
|
||
/// flows downstream into `ResidualConstraint.class` and
|
||
/// `MonoTarget::ClassMethod.class` unchanged. Match the registry
|
||
/// key shape so discharge / mono lookups land on the right entry.
|
||
pub class_name: String,
|
||
/// The class's single type parameter name (e.g. `a` in
|
||
/// `class Show a`). The class param appears at this name inside
|
||
/// [`Self::method_ty`].
|
||
pub class_param: String,
|
||
/// The method's declared signature — a `Type::Fn` whose param /
|
||
/// return positions may contain `Type::Var { name: class_param }`.
|
||
/// Task 9 instantiates the class param with a fresh metavar at the
|
||
/// call site to build the residual constraint.
|
||
pub method_ty: Type,
|
||
/// Module that declared the class. Tasks 9 / 10 use this to find
|
||
/// the matching `Registry` entries when checking constraints.
|
||
pub defining_module: String,
|
||
}
|
||
|
||
/// Builds the top-level symbol table per module (for cross-module lookup),
|
||
/// without checking bodies. Duplicates and dot-in-def names are reported
|
||
/// here as errors immediately — they would taint all further diagnostics.
|
||
///
|
||
/// also collects class-method names from `Def::Class`
|
||
/// into [`ModuleGlobals::class_methods`], so that pass-2 lookup of a
|
||
/// class method via `Term::Var { name }` resolves to a known global
|
||
/// rather than firing `unbound-var`. The class-method channel is kept
|
||
/// separate from the fn / const / type channel because resolution
|
||
/// downstream needs the owning class and class-param name to build a
|
||
/// residual constraint at the call site.
|
||
pub fn build_module_globals(
|
||
ws: &Workspace,
|
||
) -> Result<BTreeMap<String, ModuleGlobals>> {
|
||
let mut out: BTreeMap<String, ModuleGlobals> = BTreeMap::new();
|
||
for (mname, m) in &ws.modules {
|
||
let mut globals = ModuleGlobals::default();
|
||
for def in &m.defs {
|
||
let def_name = def.name();
|
||
// for `Def::Instance`, `def.name()` returns
|
||
// `inst.class` which carries the canonical-form value
|
||
// (qualified for cross-module). The dot-in-def-name check
|
||
// is meant for `Fn`/`Const`/`Type` whose names are author-
|
||
// chosen identifiers — instances have no author-chosen
|
||
// name and are excluded.
|
||
if !matches!(def, Def::Instance(_)) && def_name.contains('.') {
|
||
return Err(CheckError::Def(
|
||
def_name.to_string(),
|
||
Box::new(CheckError::InvalidDefName {
|
||
name: def_name.to_string(),
|
||
}),
|
||
));
|
||
}
|
||
// pre-22b.2 the dup check ran across the single
|
||
// `IndexMap<String, Type>`, but class / instance defs early-
|
||
// `continue`d before the insert, so they never participated
|
||
// in dup detection (and `instance Foo` re-using the class
|
||
// name is legal). Preserving that, dup detection here is
|
||
// scoped to the `fns` channel exactly as before. Class /
|
||
// instance coherence (orphan / duplicate-instance /
|
||
// missing-method / method-name-collision) is enforced
|
||
// earlier at `workspace::build_registry`.
|
||
match def {
|
||
Def::Fn(f) => {
|
||
if globals.fns.contains_key(def_name) {
|
||
return Err(CheckError::Def(
|
||
def_name.to_string(),
|
||
Box::new(CheckError::DuplicateDef(def_name.to_string())),
|
||
));
|
||
}
|
||
globals.fns.insert(def_name.to_string(), f.ty.clone());
|
||
}
|
||
Def::Const(c) => {
|
||
if globals.fns.contains_key(def_name) {
|
||
return Err(CheckError::Def(
|
||
def_name.to_string(),
|
||
Box::new(CheckError::DuplicateDef(def_name.to_string())),
|
||
));
|
||
}
|
||
globals.fns.insert(def_name.to_string(), c.ty.clone());
|
||
}
|
||
Def::Type(_) => {
|
||
if globals.fns.contains_key(def_name) {
|
||
return Err(CheckError::Def(
|
||
def_name.to_string(),
|
||
Box::new(CheckError::DuplicateDef(def_name.to_string())),
|
||
));
|
||
}
|
||
globals.fns.insert(
|
||
def_name.to_string(),
|
||
Type::Con {
|
||
name: def_name.to_string(),
|
||
args: vec![],
|
||
},
|
||
);
|
||
}
|
||
Def::Class(cd) => {
|
||
// register every method
|
||
// declared by the class as a class-method global,
|
||
// with enough metadata for the typecheck arms in
|
||
// Tasks 9 / 10 to build a residual constraint at
|
||
// the call site.
|
||
//
|
||
// store the qualified class name
|
||
// (`<defining_module>.<Class>`) so the residual
|
||
// emitted at the call site matches the workspace
|
||
// registry's qualified key.
|
||
let qualified_class = format!("{mname}.{}", cd.name);
|
||
for meth in &cd.methods {
|
||
globals.class_methods.insert(
|
||
(qualified_class.clone(), meth.name.clone()),
|
||
ClassMethodEntry {
|
||
class_name: qualified_class.clone(),
|
||
class_param: cd.param.clone(),
|
||
method_ty: meth.ty.clone(),
|
||
defining_module: mname.clone(),
|
||
},
|
||
);
|
||
}
|
||
}
|
||
Def::Instance(_) => {
|
||
// Instances do not contribute globals; coherence
|
||
// is enforced earlier at
|
||
// `workspace::build_registry`.
|
||
}
|
||
}
|
||
}
|
||
out.insert(mname.clone(), globals);
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
/// Build the workspace-flat `Env` shared by `check_in_workspace`
|
||
/// and `mono::build_workspace_env`. Populates every field whose
|
||
/// contents are derivable from `Workspace` alone — no
|
||
/// `current_module` overlay applied. Both call sites are thin
|
||
/// consumers: each clones this env and applies the per-call
|
||
/// overlay (`current_module`, `globals` augmented with module fns,
|
||
/// `imports`, `rigid_vars`) at the call site.
|
||
///
|
||
/// Pre-condition: typecheck has succeeded for the workspace
|
||
/// (otherwise `build_module_globals` panics — same caller-contract
|
||
/// as the pre-refactor `mono::build_workspace_env`).
|
||
pub fn build_check_env(ws: &Workspace) -> Env {
|
||
let mut env = Env::default();
|
||
builtins::install(&mut env);
|
||
|
||
// env.types + env.ctor_index — workspace-flat from every
|
||
// module's Def::Type. Safe to flatten because `check_workspace`
|
||
// has already accepted the workspace, so duplicate names cannot
|
||
// occur. Mirrors the per-module loop in the pre-refactor
|
||
// `check_in_workspace` (which fail-fast aborted on duplicates,
|
||
// unreachable here).
|
||
for m in ws.modules.values() {
|
||
for d in &m.defs {
|
||
if let Def::Type(td) = d {
|
||
for c in &td.ctors {
|
||
env.ctor_index.insert(
|
||
c.name.clone(),
|
||
CtorRef { type_name: td.name.clone() },
|
||
);
|
||
}
|
||
env.types.insert(td.name.clone(), td.clone());
|
||
}
|
||
}
|
||
}
|
||
|
||
// env.module_globals + env.class_methods — built from
|
||
// `build_module_globals(ws)`. The .fns projection feeds
|
||
// `module_globals`; the .class_methods entries are merged
|
||
// workspace-wide into `class_methods` (uniqueness enforced
|
||
// earlier by `workspace::build_registry`'s
|
||
// method-name-collision check).
|
||
let mg = build_module_globals(ws)
|
||
.expect("build_module_globals: pre-condition (typecheck succeeded) violated");
|
||
env.module_globals = mg
|
||
.iter()
|
||
.map(|(k, v)| (k.clone(), v.fns.clone()))
|
||
.collect();
|
||
for g in mg.values() {
|
||
for ((qualified_class, method_name), e) in &g.class_methods {
|
||
// env.class_methods is tuple-keyed by
|
||
// `(qualified-class, method-name)`. Multiple classes may
|
||
// share a method name post-`MethodNameCollision`-retirement.
|
||
env.class_methods.insert(
|
||
(qualified_class.clone(), method_name.clone()),
|
||
e.clone(),
|
||
);
|
||
// build the inverse method → candidate-class set in
|
||
// the same loop. `e.class_name` already carries the
|
||
// qualified form (canonical-class-form invariant), so the set is keyed on
|
||
// workspace-flat qualified class names directly.
|
||
env.method_to_candidate_classes
|
||
.entry(method_name.clone())
|
||
.or_default()
|
||
.insert(e.class_name.clone());
|
||
}
|
||
}
|
||
|
||
// env.module_types — per-module ADT index.
|
||
env.module_types = build_module_types(ws);
|
||
|
||
// env.module_imports — per-module alias-or-name -> module-name.
|
||
// Per-module because aliases collide across modules; sibling
|
||
// shape to `module_globals`.
|
||
//
|
||
// Implicit-import set: the literal name `"prelude"` (the
|
||
// legacy singleton implicit import, retained unconditionally
|
||
// for back-compat with workspaces whose prelude predates the
|
||
// kernel-flag) PLUS every workspace module flagged
|
||
// `kernel: true`. The kernel-flag half mirrors the loader's
|
||
// filter at `crates/ailang-surface/src/loader.rs`
|
||
// (`modules.filter(|m| m.kernel)`) that drives
|
||
// `implicit_imports` into `build_workspace`. The pre-pass
|
||
// `qualify_workspace_types` freely produces qualifiers like
|
||
// `raw_buf.RawBuf`; without this injection the type-
|
||
// validity check at ~line 1968 would reject `raw_buf` as
|
||
// an unknown module prefix. Fieldtest F3 surfaced the gap
|
||
// (the previous code force-injected only `prelude`, leaving
|
||
// `raw_buf` and any future kernel-tier module unreachable
|
||
// from consumers). The production prelude carries
|
||
// `kernel: true`, so the two halves are idempotent for it; the
|
||
// `"prelude"` literal stays so unit-test workspaces that
|
||
// construct a `kernel: false` prelude still see the contract.
|
||
let kernel_modules: Vec<String> = ws
|
||
.modules
|
||
.values()
|
||
.filter(|m| m.kernel)
|
||
.map(|m| m.name.clone())
|
||
.collect();
|
||
for m in ws.modules.values() {
|
||
let mut import_map: BTreeMap<String, String> = BTreeMap::new();
|
||
for imp in &m.imports {
|
||
let key = imp.alias.clone().unwrap_or_else(|| imp.module.clone());
|
||
import_map.insert(key, imp.module.clone());
|
||
}
|
||
// A user-declared explicit `import` of any implicit name
|
||
// would already have placed it in the map above
|
||
// (idempotent insert via `entry`). A module is not
|
||
// auto-imported into its own body.
|
||
if m.name != "prelude" {
|
||
import_map
|
||
.entry("prelude".to_string())
|
||
.or_insert_with(|| "prelude".to_string());
|
||
}
|
||
for k in &kernel_modules {
|
||
if &m.name != k {
|
||
import_map.entry(k.clone()).or_insert_with(|| k.clone());
|
||
}
|
||
}
|
||
env.module_imports.insert(m.name.clone(), import_map);
|
||
}
|
||
|
||
// env.class_superclasses — walk every module's Def::Class.
|
||
//
|
||
// key + value both carry the qualified class name. The map
|
||
// is queried by `expand_declared_constraints` with `c.class`
|
||
// (a `Constraint.class` value, which is canonical-form on the
|
||
// residual side post-canonical-class-form), so both halves of the map must match
|
||
// the same workspace-key shape. A bare `SuperclassRef.class`
|
||
// (same-module superclass) is lifted via `qualify_class_ref` —
|
||
// mirrored inline below to avoid pulling in the workspace helper.
|
||
for (mod_name, m) in &ws.modules {
|
||
for d in &m.defs {
|
||
if let Def::Class(cd) = d {
|
||
if let Some(sc) = &cd.superclass {
|
||
let class_qualified = format!("{mod_name}.{}", cd.name);
|
||
let sc_qualified = if sc.class.contains('.') {
|
||
sc.class.clone()
|
||
} else {
|
||
format!("{mod_name}.{}", sc.class)
|
||
};
|
||
env.class_superclasses.insert(class_qualified, sc_qualified);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// env.workspace_registry — clone of ws.registry. Used by
|
||
// `check_fn`'s concrete-residual no-instance check.
|
||
env.workspace_registry = ws.registry.clone();
|
||
|
||
env
|
||
}
|
||
|
||
/// Checks the bodies of a single module in the context of the workspace.
|
||
/// Assumption: `module_globals` already contains the top-level symbol
|
||
/// tables for **all** modules of the workspace (including `m`) — built
|
||
/// by `build_module_globals`.
|
||
///
|
||
/// Returns **all** errors found in this module, in def declaration order:
|
||
///
|
||
/// - The type-def setup phase is fail-fast within the module (duplicate
|
||
/// type or ctor names corrupt the env, so we abort *this* module after
|
||
/// the first such error and let the outer loop continue with others).
|
||
/// - The body-check phase is multi-diagnose: each def is checked
|
||
/// independently against the assembled env; a failure is recorded and
|
||
/// the next def is attempted.
|
||
fn check_in_workspace(
|
||
m: &Module,
|
||
ws: &Workspace,
|
||
module_globals: &BTreeMap<String, ModuleGlobals>,
|
||
// synth-time warnings collected here so the caller can
|
||
// surface them into the workspace-wide diagnostic stream. Today
|
||
// only `class-method-shadowed-by-fn` flows through this channel
|
||
// (emitted from `synth` via `check_fn`'s warnings accumulator).
|
||
out_warnings: &mut Vec<Diagnostic>,
|
||
) -> Vec<CheckError> {
|
||
let mut env = build_check_env(ws);
|
||
let mut errors: Vec<CheckError> = Vec::new();
|
||
|
||
// Per-module overlay for `env.types` / `env.ctor_index`:
|
||
// `build_check_env` populates these workspace-flat, but the
|
||
// per-module pass needs only THIS module's local entries.
|
||
// Clear and rebuild so that:
|
||
// - duplicate-type / duplicate-ctor detection (the in-band
|
||
// errors below) sees only this module's defs;
|
||
// - bare-name lookups (e.g. local `Term::Ctor` synth)
|
||
// resolve against this module's types, not against the
|
||
// workspace-flat union.
|
||
// Cross-module `Pattern::Ctor` resolution no longer touches
|
||
// this overlay — that path is type-driven via `expected` and
|
||
// consults `env.module_types` directly (the canonical-type lookup refactor).
|
||
env.types.clear();
|
||
env.ctor_index.clear();
|
||
for def in &m.defs {
|
||
if let Def::Type(td) = def {
|
||
if env.types.contains_key(&td.name) {
|
||
errors.push(CheckError::Def(
|
||
td.name.clone(),
|
||
Box::new(CheckError::DuplicateType(td.name.clone())),
|
||
));
|
||
return errors;
|
||
}
|
||
for c in &td.ctors {
|
||
if let Some(prev) = env.ctor_index.get(&c.name) {
|
||
errors.push(CheckError::Def(
|
||
td.name.clone(),
|
||
Box::new(CheckError::DuplicateCtor {
|
||
ctor: c.name.clone(),
|
||
a: prev.type_name.clone(),
|
||
b: td.name.clone(),
|
||
}),
|
||
));
|
||
return errors;
|
||
}
|
||
env.ctor_index.insert(
|
||
c.name.clone(),
|
||
CtorRef { type_name: td.name.clone() },
|
||
);
|
||
}
|
||
env.types.insert(td.name.clone(), td.clone());
|
||
}
|
||
}
|
||
|
||
if let Some(g) = module_globals.get(&m.name) {
|
||
for (n, t) in &g.fns {
|
||
env.globals.insert(n.clone(), t.clone());
|
||
}
|
||
}
|
||
|
||
let mut import_map: BTreeMap<String, String> = BTreeMap::new();
|
||
for imp in &m.imports {
|
||
let key = imp.alias.clone().unwrap_or_else(|| imp.module.clone());
|
||
import_map.insert(key, imp.module.clone());
|
||
}
|
||
// Implicit-import set for the per-module body-check env
|
||
// (mirrors `build_check_env`'s sibling injection into
|
||
// `env.module_imports`): the literal name `"prelude"` (legacy
|
||
// singleton, kept unconditionally) PLUS every workspace module
|
||
// flagged `kernel: true`. The kernel-flag half mirrors the
|
||
// loader's filter at `crates/ailang-surface/src/loader.rs`
|
||
// that drives `implicit_imports` into `build_workspace`. The
|
||
// two halves are populated from different code paths (the
|
||
// workspace-wide table in `build_check_env`, the per-module
|
||
// table here), hence the apparent duplication. A user-declared
|
||
// explicit `import` of the same name is idempotent via
|
||
// `entry`. A module is not auto-imported into its own body.
|
||
// Fieldtest F3 surfaced that the previous code force-injected
|
||
// only `prelude`, leaving `raw_buf` and any future
|
||
// kernel-tier module unreachable from consumers even though
|
||
// the pre-pass freely produces such qualifiers.
|
||
if m.name != "prelude" {
|
||
import_map
|
||
.entry("prelude".to_string())
|
||
.or_insert_with(|| "prelude".to_string());
|
||
}
|
||
for km in ws.modules.values().filter(|km| km.kernel) {
|
||
if km.name != m.name {
|
||
import_map
|
||
.entry(km.name.clone())
|
||
.or_insert_with(|| km.name.clone());
|
||
}
|
||
}
|
||
env.imports = import_map;
|
||
env.current_module = m.name.clone();
|
||
env.current_module_kernel_tier = m.kernel || m.name == "prelude";
|
||
|
||
for def in &m.defs {
|
||
match check_def(def, &env, out_warnings) {
|
||
Ok(()) => {}
|
||
Err(e) => {
|
||
errors.push(CheckError::Def(def.name().to_string(), Box::new(e)));
|
||
}
|
||
}
|
||
}
|
||
// stamp the synth warnings with the module's name as `def`
|
||
// so the JSON consumer can correlate them with the surrounding
|
||
// module. The warning's `def` field carries the call-site def name
|
||
// already (set inside `check_fn`); the workspace-wide caller
|
||
// extends without further annotation.
|
||
errors
|
||
}
|
||
|
||
fn check_def(
|
||
def: &Def,
|
||
env: &Env,
|
||
out_warnings: &mut Vec<Diagnostic>,
|
||
) -> Result<()> {
|
||
match def {
|
||
Def::Fn(f) => check_fn(f, env, out_warnings),
|
||
Def::Const(c) => check_const(c, env, out_warnings),
|
||
Def::Type(td) => check_type_def(td, env),
|
||
// bugfix-instance-body-unbound-var (2026-05-13): each instance
|
||
// method body is walked through `check_fn` via a synthetic
|
||
// `FnDef` lifted from the `Term::Lam` the InstanceMethod body
|
||
// carries. This routes instance bodies through the same
|
||
// identifier-resolution path as fn bodies, so an unbound
|
||
// identifier inside an instance method fires `[unbound-var]`
|
||
// at `ail check` instead of slipping past and surfacing as a
|
||
// degraded "monomorphise_workspace: unknown identifier"
|
||
// diagnostic at `ail build`. Class-method substitution
|
||
// (class param → instance type) happens inside
|
||
// `check_instance` before the body-walk. Class-schema
|
||
// validation (InvalidSuperclassParam, etc.) and the workspace-
|
||
// load coherence checks (Orphan / Duplicate / MissingMethod)
|
||
// fire from `workspace::build_registry` independently, so a
|
||
// malformed instance never reaches this body-walk. `Def::Class`
|
||
// remains schema-only at this layer.
|
||
Def::Class(_) => Ok(()),
|
||
Def::Instance(inst) => check_instance(inst, env, out_warnings),
|
||
}
|
||
}
|
||
|
||
/// Route each `InstanceMethod` body through `check_fn` by wrapping
|
||
/// it in a synthetic `FnDef`. The InstanceMethod body is by
|
||
/// convention a `Term::Lam` carrying its own `param-types` / `ret-type`
|
||
/// (the canonical class-form shape); we extract its `param_tys` /
|
||
/// `ret_ty` / `params` / `body` into an `FnDef` shape and reuse the
|
||
/// existing fn-body-check machinery.
|
||
///
|
||
/// Class-method substitution: the on-disk Lam's `param_tys` / `ret_ty`
|
||
/// reference the class's type parameter directly (e.g. the prelude
|
||
/// `instance Eq Int` body declares `(typed x a) (typed y a)` with `a`
|
||
/// being `Eq`'s class param). Before handing to `check_fn` we look up
|
||
/// the class's `param` name via `env.class_methods` and substitute it
|
||
/// to the instance's concrete `type_`, using the existing
|
||
/// `substitute_rigids` helper on type positions and
|
||
/// `substitute_rigids_in_term` on the body (which targets any embedded
|
||
/// Type annotations on nested Lams / LetRecs). Without this step
|
||
/// `check_fn` would fire `polymorphic-not-supported` on the class
|
||
/// param appearing in non-Forall position.
|
||
///
|
||
/// The synthetic fn name is `"<class>::<method>"`, used only for
|
||
/// diagnostic strings.
|
||
///
|
||
/// Non-Lam method bodies are not part of the current schema (all
|
||
/// fixtures in the corpus and the convention reaffirmed when method dispatch became type-driven
|
||
/// use `Term::Lam`); they are silently skipped here. If a future
|
||
/// schema admits non-Lam shapes, this arm must be extended to walk
|
||
/// them — silent skip is the conservative choice today because
|
||
/// the pre-fix behaviour was also silent.
|
||
fn check_instance(
|
||
inst: &InstanceDef,
|
||
env: &Env,
|
||
out_warnings: &mut Vec<Diagnostic>,
|
||
) -> Result<()> {
|
||
let qualified_class = qualify_class_ref_in_check(&inst.class, &env.current_module);
|
||
for im in &inst.methods {
|
||
// Look up the class's param name. Workspace-load's MissingMethod
|
||
// gates this — if the (class, method) pair isn't in the table,
|
||
// the instance was structurally rejected upstream and we never
|
||
// get here in practice. Defensive fallback: skip substitution
|
||
// (body walks without class-param resolution).
|
||
let subst_map: BTreeMap<String, Type> = env
|
||
.class_methods
|
||
.get(&(qualified_class.clone(), im.name.clone()))
|
||
.map(|entry| {
|
||
let mut m = BTreeMap::new();
|
||
m.insert(entry.class_param.clone(), inst.type_.clone());
|
||
m
|
||
})
|
||
.unwrap_or_default();
|
||
|
||
let Term::Lam { params, param_tys, ret_ty, effects, body } = &im.body else {
|
||
// Non-Lam method body: skip (see fn-level doc-comment).
|
||
continue;
|
||
};
|
||
let subst_param_tys: Vec<Type> = param_tys
|
||
.iter()
|
||
.map(|t| substitute_rigids(t, &subst_map))
|
||
.collect();
|
||
let subst_ret_ty = substitute_rigids(ret_ty, &subst_map);
|
||
let subst_body = substitute_rigids_in_term(body, &subst_map);
|
||
let fn_ty = Type::fn_implicit(
|
||
subst_param_tys,
|
||
subst_ret_ty,
|
||
effects.clone(),
|
||
);
|
||
let synthetic = FnDef {
|
||
name: format!("{}::{}", inst.class, im.name),
|
||
ty: fn_ty,
|
||
params: params.clone(),
|
||
body: subst_body,
|
||
doc: None,
|
||
suppress: Vec::new(),
|
||
export: None,
|
||
};
|
||
check_fn(&synthetic, env, out_warnings)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn check_type_def(td: &TypeDef, env: &Env) -> Result<()> {
|
||
// a parameterised ADT (`vars` non-empty) installs its
|
||
// type parameters as rigid vars while checking the ctor field
|
||
// types, so `List a = ... | Cons(a, List a)` resolves both
|
||
// occurrences of `a` and the recursive use of `List a` correctly.
|
||
let mut env = env.clone();
|
||
for v in &td.vars {
|
||
env.rigid_vars.insert(v.clone());
|
||
}
|
||
for c in &td.ctors {
|
||
for f in &c.fields {
|
||
check_type_well_formed(f, &env)?;
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn check_type_well_formed(t: &Type, env: &Env) -> Result<()> {
|
||
match t {
|
||
Type::Con { name, args } => {
|
||
let is_primitive = ailang_core::primitives::is_primitive_name(name);
|
||
if is_primitive {
|
||
if !args.is_empty() {
|
||
return Err(CheckError::UnknownType(format!(
|
||
"{name} (primitive does not take type args)"
|
||
)));
|
||
}
|
||
return Ok(());
|
||
}
|
||
// a qualified type name `module.Type` resolves
|
||
// through the import map and the per-module type table.
|
||
// prep.1: a bare type name also resolves to any workspace
|
||
// TypeDef of that name (TypeDef-first ladder), symmetric
|
||
// to the `Term::Var` and `Term::Ctor` arms. This makes
|
||
// `(con Maybe a)` in a consumer module's type signature
|
||
// resolve when `Maybe` lives in an imported (explicit or
|
||
// implicit) module — matching the canonical-form rule
|
||
// already accepted by the workspace validator.
|
||
let td_opt: Option<&TypeDef> = if name.matches('.').count() == 1 {
|
||
let (prefix, suffix) = name.split_once('.').expect("checked");
|
||
let target_module = match env.imports.get(prefix) {
|
||
Some(m) => m.as_str(),
|
||
None => {
|
||
return Err(CheckError::UnknownModule {
|
||
module: prefix.to_string(),
|
||
});
|
||
}
|
||
};
|
||
env.module_types
|
||
.get(target_module)
|
||
.and_then(|tys| tys.get(suffix))
|
||
} else if let Some(td) = env.types.get(name) {
|
||
Some(td)
|
||
} else {
|
||
env.module_types
|
||
.values()
|
||
.find_map(|types| types.get(name))
|
||
};
|
||
if let Some(td) = td_opt {
|
||
if td.vars.len() != args.len() {
|
||
return Err(CheckError::UnknownType(format!(
|
||
"{name} expects {} type arg(s), got {}",
|
||
td.vars.len(),
|
||
args.len()
|
||
)));
|
||
}
|
||
// prep.3 (kernel-extension-mechanics): `param-in`
|
||
// enforcement. Every restricted var must have its
|
||
// corresponding positional arg's outermost type-name
|
||
// in the allowed set. Non-Con arg-types (vars, fn
|
||
// types, etc.) are not currently restrictable — the
|
||
// spec scopes `param-in` to closed-set named types.
|
||
for (var, allowed) in &td.param_in {
|
||
let Some(pos) = td.vars.iter().position(|v| v == var) else {
|
||
continue;
|
||
};
|
||
let Some(arg_ty) = args.get(pos) else {
|
||
continue;
|
||
};
|
||
if let Type::Con { name: arg_name, .. } = arg_ty {
|
||
if !allowed.contains(arg_name) {
|
||
return Err(CheckError::ParamNotInRestrictedSet {
|
||
type_name: name.clone(),
|
||
var: var.clone(),
|
||
found: arg_name.clone(),
|
||
allowed: allowed.iter().cloned().collect(),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
for a in args {
|
||
check_type_well_formed(a, env)?;
|
||
}
|
||
Ok(())
|
||
} else {
|
||
Err(CheckError::UnknownType(name.clone()))
|
||
}
|
||
}
|
||
Type::Fn { params, ret, .. } => {
|
||
for p in params {
|
||
check_type_well_formed(p, env)?;
|
||
}
|
||
check_type_well_formed(ret, env)
|
||
}
|
||
Type::Var { name } => {
|
||
// Rigid var: legal iff it is in scope. Used inside a forall
|
||
// body when checking a polymorphic def, or inside a
|
||
// parameterised ADT's ctor fields.
|
||
if env.rigid_vars.contains(name) {
|
||
Ok(())
|
||
} else {
|
||
Err(CheckError::PolymorphicNotSupported("type def".into()))
|
||
}
|
||
}
|
||
Type::Forall { .. } => {
|
||
// ADT fields and lambda annotations cannot themselves be
|
||
// polymorphic — only top-level def types may carry a Forall.
|
||
Err(CheckError::PolymorphicNotSupported("type def".into()))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// `true` when `f`'s body is an `(intrinsic)` marker: either the body
|
||
/// is `Term::Intrinsic` directly (a top-level intrinsic fn) or it is a
|
||
/// `Term::Lam` whose inner body is `Term::Intrinsic` (a synthesised
|
||
/// instance method). Such a body is signature-only — the typechecker
|
||
/// validates the signature and skips body inference, and the mono pass
|
||
/// collects no targets from it. Shared by `check_fn` and the mono
|
||
/// pass's `synth`-re-entry sites (`mono::collect_mono_targets`,
|
||
/// `mono::collect_residuals_ordered`) so every synth-on-body path
|
||
/// applies the same skip.
|
||
pub(crate) fn is_intrinsic_body(f: &FnDef) -> bool {
|
||
matches!(f.body, Term::Intrinsic)
|
||
|| matches!(&f.body, Term::Lam { body, .. } if matches!(**body, Term::Intrinsic))
|
||
}
|
||
|
||
fn check_fn(f: &FnDef, env: &Env, out_warnings: &mut Vec<Diagnostic>) -> Result<()> {
|
||
// Peel an outer Forall (Iter 12a). The vars become rigid in the
|
||
// inner env so they pass `check_type_well_formed` and unify only
|
||
// with themselves. An empty `vars` list (vacuously polymorphic)
|
||
// gets normalised to a non-polymorphic body.
|
||
let (rigids, inner_ty): (Vec<String>, Type) = match &f.ty {
|
||
Type::Forall { vars, constraints: _, body } => (vars.clone(), (**body).clone()),
|
||
other => (vec![], other.clone()),
|
||
};
|
||
|
||
let (param_tys, ret_ty, declared_effs) = match &inner_ty {
|
||
Type::Fn { params, ret, effects, .. } => {
|
||
(params.clone(), (**ret).clone(), effects.clone())
|
||
}
|
||
other => {
|
||
return Err(CheckError::FnTypeRequired(
|
||
f.name.clone(),
|
||
ailang_core::pretty::type_to_string(other),
|
||
));
|
||
}
|
||
};
|
||
|
||
if f.params.len() != param_tys.len() {
|
||
return Err(CheckError::ParamCountMismatch {
|
||
name: f.name.clone(),
|
||
ty_count: param_tys.len(),
|
||
param_count: f.params.len(),
|
||
});
|
||
}
|
||
|
||
// Install rigid vars. Cloning the env keeps the parent immutable
|
||
// and the rigid set scoped to this def.
|
||
let mut env = env.clone();
|
||
for v in &rigids {
|
||
env.rigid_vars.insert(v.clone());
|
||
}
|
||
|
||
// validate the declared parameter and return types
|
||
// against the type environment. Catches misuses like
|
||
// `Box<Int, Bool>` (arity mismatch on a parameterised ADT) before
|
||
// they leak into the body and produce confusing downstream errors.
|
||
for p in ¶m_tys {
|
||
check_type_well_formed(p, &env)?;
|
||
}
|
||
check_type_well_formed(&ret_ty, &env)?;
|
||
|
||
// An `(intrinsic)` body is signature-only: the signature is already
|
||
// validated above; codegen supplies the implementation via the
|
||
// intercept registry. The body is `Term::Intrinsic` directly (a
|
||
// top-level intrinsic fn) or a `Term::Lam` whose inner body is
|
||
// `Term::Intrinsic` (a synthesised instance method). Skip body
|
||
// inference, but first gate the honesty rule: an intrinsic body is
|
||
// legal only in a kernel-tier module or the prelude.
|
||
if is_intrinsic_body(f) {
|
||
if !env.current_module_kernel_tier {
|
||
return Err(CheckError::IntrinsicOutsideKernelTier {
|
||
def: f.name.clone(),
|
||
module: env.current_module.clone(),
|
||
});
|
||
}
|
||
return Ok(());
|
||
}
|
||
|
||
// Embedding-ABI gate (M1 scalars / M2 ctx / M3 record): an
|
||
// `(export …)` fn is the C call boundary. Its signature must be
|
||
// C-ABI-permitted — `Int`/`Float`, or a single-constructor
|
||
// record of those (M3) — and pure. Gate order: params → ret →
|
||
// effects, first violation wins (so a combined Str+IO export
|
||
// deterministically fails on the signature). Unconditional at
|
||
// check-time — independent of the codegen emit mode — this IS
|
||
// the clause-3 discriminator.
|
||
if f.export.is_some() {
|
||
fn is_c_scalar(t: &Type) -> bool {
|
||
matches!(t, Type::Con { name, args, .. }
|
||
if args.is_empty() && (name == "Int" || name == "Float"))
|
||
}
|
||
// M3 (spec 2026-05-18-embedding-abi-m3 §Architecture): a C
|
||
// scalar, OR a single-constructor `data` whose every field is a
|
||
// *bare* C scalar. Two-level by design — a record-typed field
|
||
// stays rejected (M4 owns nested-record recursion). Uses the
|
||
// in-scope `env.types: String -> TypeDef` (cf. lib.rs:1826).
|
||
fn is_c_abi_type(t: &Type, env: &Env) -> bool {
|
||
if is_c_scalar(t) {
|
||
return true;
|
||
}
|
||
if let Type::Con { name, args, .. } = t {
|
||
if args.is_empty() {
|
||
if let Some(td) = env.types.get(name) {
|
||
if td.ctors.len() == 1 {
|
||
return td.ctors[0]
|
||
.fields
|
||
.iter()
|
||
.all(is_c_scalar);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
false
|
||
}
|
||
for p in ¶m_tys {
|
||
if !is_c_abi_type(p, &env) {
|
||
return Err(CheckError::ExportNonScalarSignature(
|
||
f.name.clone(),
|
||
"parameter",
|
||
ailang_core::pretty::type_to_string(p),
|
||
));
|
||
}
|
||
}
|
||
if !is_c_abi_type(&ret_ty, &env) {
|
||
return Err(CheckError::ExportNonScalarSignature(
|
||
f.name.clone(),
|
||
"return",
|
||
ailang_core::pretty::type_to_string(&ret_ty),
|
||
));
|
||
}
|
||
if !declared_effs.is_empty() {
|
||
return Err(CheckError::ExportHasEffects(
|
||
f.name.clone(),
|
||
declared_effs.clone(),
|
||
));
|
||
}
|
||
}
|
||
|
||
// build the post-superclass-expansion declared-constraint
|
||
// set ONCE here and stash on env, so synth's Var-arm class-method
|
||
// branch can hand it to `resolve_method_dispatch`'s constraint-
|
||
// driven filter. The post-synth missing-constraint check below
|
||
// re-reads the same expanded list from env to keep one source of
|
||
// truth.
|
||
//
|
||
// Canonical-class-form follow-on: declared constraints carry the canonical-form
|
||
// `class` value (bare for same-module, qualified for cross-module).
|
||
// Lift to the workspace-key shape (always qualified) before
|
||
// expanding — residuals always carry qualified `class` because
|
||
// they come from `ClassMethodEntry.class_name` which is qualified
|
||
// post-canonical-class-form
|
||
let declared_constraints: Vec<Constraint> = match &f.ty {
|
||
Type::Forall { constraints, .. } => constraints
|
||
.iter()
|
||
.map(|c| Constraint {
|
||
class: qualify_class_ref_in_check(&c.class, &env.current_module),
|
||
type_: c.type_.clone(),
|
||
})
|
||
.collect(),
|
||
_ => Vec::new(),
|
||
};
|
||
env.active_declared_constraints =
|
||
expand_declared_constraints(&declared_constraints, &env.class_superclasses);
|
||
|
||
let mut locals = IndexMap::new();
|
||
for (n, t) in f.params.iter().zip(param_tys.iter()) {
|
||
locals.insert(n.clone(), t.clone());
|
||
}
|
||
let mut effects = BTreeSet::new();
|
||
let mut subst = Subst::default();
|
||
let mut counter: u32 = 0;
|
||
// Per-fn residual class constraints. Every call to a class method
|
||
// inside the body pushes a [`ResidualConstraint`] here; after the
|
||
// body finishes, we compare against the expanded declared set.
|
||
let mut residuals: Vec<ResidualConstraint> = Vec::new();
|
||
let mut free_fn_calls: Vec<FreeFnCall> = Vec::new();
|
||
let mut warnings: Vec<Diagnostic> = Vec::new();
|
||
// loop-recur iter 2: fresh per-fn-body loop-binder-type stack.
|
||
// Empty at fn entry; pushed/popped by `Term::Loop` arms during
|
||
// the synth walk; discarded after the body type-checks.
|
||
let mut loop_stack: Vec<Vec<(String, Type)>> = Vec::new();
|
||
let body_ty = synth(&f.body, &env, &mut locals, &mut loop_stack, &mut effects, &f.name, &mut subst, &mut counter, &mut residuals, &mut free_fn_calls, &mut warnings)?;
|
||
unify(&ret_ty, &body_ty, &mut subst)?;
|
||
// surface synth-time warnings into the caller-supplied
|
||
// accumulator. The warnings already carry `def: Some(f.name)`
|
||
// set inside synth's emission site.
|
||
out_warnings.extend(warnings);
|
||
|
||
// tail-position verification (the explicit-tail-call contract). Runs after
|
||
// the main type-check so that a tail-call marker on a malformed
|
||
// call doesn't drown out the underlying type error.
|
||
verify_tail_positions(&f.body, true)?;
|
||
|
||
// loop-recur iter 2: recur-in-tail-position verification. Runs
|
||
// after synth (so RecurOutsideLoop/arity/type take code-
|
||
// precedence) and alongside verify_tail_positions (sibling, not
|
||
// a repurpose — verify_tail_positions' tail-app role is frozen).
|
||
verify_loop_body(&f.body, false)?;
|
||
|
||
let declared: BTreeSet<String> = declared_effs.into_iter().collect();
|
||
for e in &effects {
|
||
if !declared.contains(e) {
|
||
return Err(CheckError::UndeclaredEffect(e.clone()));
|
||
}
|
||
}
|
||
|
||
// missing-constraint check. The expanded
|
||
// declared-constraint set (declared + one-step superclass per
|
||
// the typeclass design) was computed pre-synth and stashed on
|
||
// `env.active_declared_constraints`. Var-shaped residuals
|
||
// not covered by the expanded set fire `MissingConstraint`. Concrete
|
||
// residuals are deferred to Task 10's `no-instance` arm.
|
||
let expanded = &env.active_declared_constraints;
|
||
for r in &residuals {
|
||
let r_ty = subst.apply(&r.type_);
|
||
|
||
// multi-candidate residuals refine before the existing
|
||
// single-class discharge path. `MethodNameCollision` keeps the
|
||
// candidate set singleton in real workspaces today; this branch
|
||
// is exercised exclusively by unit tests until the type-driven dispatch refactor.
|
||
if r.candidates.is_some() {
|
||
// Normalize the type before hashing — same contract as the
|
||
// single-class path below.
|
||
let r_ty_norm = env
|
||
.workspace_registry
|
||
.normalize_type_for_lookup(env.current_module.as_str(), &r_ty);
|
||
let normalized_residual = ResidualConstraint {
|
||
class: r.class.clone(),
|
||
type_: r_ty_norm,
|
||
method: r.method.clone(),
|
||
candidates: r.candidates.clone(),
|
||
};
|
||
let registry_unit: BTreeMap<(String, String), ()> = env
|
||
.workspace_registry
|
||
.entries
|
||
.keys()
|
||
.map(|k| (k.clone(), ()))
|
||
.collect();
|
||
match refine_multi_candidate_residual(
|
||
&normalized_residual,
|
||
expanded,
|
||
®istry_unit,
|
||
) {
|
||
RefineOutcome::Resolved(_) => {
|
||
// Discharge succeeded — the resolved class has an
|
||
// instance for this concrete type (or the rigid
|
||
// var has a matching declared constraint). Move
|
||
// on to the next residual; nothing to push.
|
||
continue;
|
||
}
|
||
RefineOutcome::NoInstance { method, at_type, candidate_classes } => {
|
||
return Err(CheckError::NoInstance {
|
||
def: f.name.clone(),
|
||
method,
|
||
class: r.class.clone(),
|
||
at_type,
|
||
candidate_classes,
|
||
});
|
||
}
|
||
RefineOutcome::Ambiguous { method, at_type, candidate_classes } => {
|
||
return Err(CheckError::AmbiguousMethodResolution {
|
||
method,
|
||
at_type,
|
||
candidate_classes,
|
||
});
|
||
}
|
||
RefineOutcome::MissingConstraint { method, candidate_classes } => {
|
||
return Err(CheckError::MissingConstraint {
|
||
def: f.name.clone(),
|
||
method,
|
||
class: candidate_classes.first().cloned().unwrap_or_default(),
|
||
at_type: ailang_core::pretty::type_to_string(&r_ty),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
if let Type::Var { name } = &r_ty {
|
||
// Skip residuals over metavars (`$mN`) — those mean the
|
||
// residual is over a value the body never connected to a
|
||
// rigid var; treat as concrete-uncovered, which Task 10
|
||
// will handle. Only rigid (source-level) vars participate
|
||
// in the missing-constraint check here.
|
||
if Subst::meta_id(name).is_some() {
|
||
continue;
|
||
}
|
||
let satisfied = expanded
|
||
.iter()
|
||
.any(|d| d.class == r.class && constraint_type_matches(&d.type_, &r_ty));
|
||
if !satisfied {
|
||
return Err(CheckError::MissingConstraint {
|
||
def: f.name.clone(),
|
||
method: r.method.clone(),
|
||
class: r.class.clone(),
|
||
at_type: name.clone(),
|
||
});
|
||
}
|
||
} else if is_fully_concrete(&r_ty) {
|
||
// fully-concrete residual — the class
|
||
// method was called at a concrete type, so the obligation
|
||
// can only be discharged by an existing workspace instance.
|
||
// Look up `(class, type_hash(r_ty))` in the registry; absence
|
||
// → no-instance. The hash uses `canonical::type_hash`, the
|
||
// same key shape `workspace::build_registry` writes with, so
|
||
// representation-equal types match deterministically.
|
||
// normalize to match Registry::normalize_type_for_lookup contract
|
||
let r_ty_norm = env
|
||
.workspace_registry
|
||
.normalize_type_for_lookup(env.current_module.as_str(), &r_ty);
|
||
let key = (
|
||
r.class.clone(),
|
||
ailang_core::canonical::type_hash(&r_ty_norm),
|
||
);
|
||
if !env.workspace_registry.entries.contains_key(&key) {
|
||
return Err(CheckError::NoInstance {
|
||
def: f.name.clone(),
|
||
method: r.method.clone(),
|
||
class: r.class.clone(),
|
||
at_type: ailang_core::pretty::type_to_string(&r_ty),
|
||
candidate_classes: vec![],
|
||
});
|
||
}
|
||
}
|
||
// Other shapes (unsolved metavars inside a `Type::Con`,
|
||
// `Type::Fn`, `Type::Forall`) are out of scope for the 22b.2
|
||
// diagnostics: metavars mean the body never connected the
|
||
// class param to anything observable, and `Fn`/`Forall` cannot
|
||
// appear as instance heads (the typeclass design).
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// true iff the type tree contains no `Type::Var`
|
||
/// at any depth. A fully-concrete type is the only shape eligible for
|
||
/// the no-instance lookup — anything carrying a metavar / rigid-var
|
||
/// component is either covered by the missing-constraint check or
|
||
/// genuinely under-specified at the call site.
|
||
pub(crate) fn is_fully_concrete(t: &Type) -> bool {
|
||
match t {
|
||
Type::Con { args, .. } => args.iter().all(is_fully_concrete),
|
||
Type::Var { .. } => false,
|
||
Type::Fn { params, ret, .. } => {
|
||
params.iter().all(is_fully_concrete) && is_fully_concrete(ret)
|
||
}
|
||
Type::Forall { body, .. } => is_fully_concrete(body),
|
||
}
|
||
}
|
||
|
||
/// per-call-site residual class constraint
|
||
/// recorded by `synth` when a `Term::Var` resolves to a class method.
|
||
/// Carries the method name (for diagnostic rendering), the class, and
|
||
/// the type the class is applied to at the call site. The type is
|
||
/// typically a fresh metavar (which unification then ties to the
|
||
/// argument's actual type) or, after `subst.apply`, a rigid var or a
|
||
/// concrete `Type::Con`.
|
||
#[derive(Debug, Clone)]
|
||
pub struct ResidualConstraint {
|
||
/// Class name (e.g. `Show`).
|
||
///
|
||
/// When [`Self::candidates`] is `None`, this is the resolved class
|
||
/// (single-class dispatch path). When `Some(set)`, this is a
|
||
/// tentative value (typically the first set element in BTreeSet
|
||
/// order); discharge / mono refinement overwrites it with the
|
||
/// actually-resolved class.
|
||
pub class: String,
|
||
/// Class param's instance at this call site. Pre-substitution: the
|
||
/// fresh metavar inserted at the Var site. Post-substitution: the
|
||
/// solved type — rigid var, concrete con, or unsolved metavar.
|
||
pub type_: Type,
|
||
/// Method name (e.g. `show`); used by the missing-constraint
|
||
/// diagnostic to point the author at which call site triggered
|
||
/// the residual.
|
||
pub method: String,
|
||
/// candidate-class set for the multi-candidate dispatch
|
||
/// path. `None` ⇒ single-class semantics (pre-canonical-class-form behaviour);
|
||
/// the [`Self::class`] field is the resolved class. `Some(set)`
|
||
/// ⇒ multi-candidate residual; discharge / mono refinement filters
|
||
/// the set at fn-body-end. With `MethodNameCollision` still active
|
||
/// (pre-canonical-class-form), real workspaces only ever construct `None`-candidate
|
||
/// residuals; the `Some`-path is exercised exclusively by unit
|
||
/// tests until the type-driven dispatch refactor retires the workaround.
|
||
pub candidates: Option<BTreeSet<String>>,
|
||
}
|
||
|
||
/// outcome of the dispatch-resolution helper
|
||
/// [`resolve_method_dispatch`]. The synth Var-arm consumer translates
|
||
/// this enum into either a singleton [`ResidualConstraint`]
|
||
/// (`Resolved`) or a multi-candidate residual (`Multi`) for discharge-
|
||
/// time refinement, or a `CheckError` (`Unknown` / `Ambiguous`).
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub enum MethodDispatchOutcome {
|
||
/// Single class resolved unambiguously.
|
||
Resolved(String),
|
||
/// Explicit qualifier names a class not in the candidate set.
|
||
UnknownClass(String),
|
||
/// Bare-method call site survived both type-driven and
|
||
/// constraint-driven filters with > 1 candidate.
|
||
Ambiguous {
|
||
method: String,
|
||
at_type: String,
|
||
candidates: Vec<String>,
|
||
},
|
||
/// Bare-method call site with multiple candidates that need
|
||
/// discharge-time refinement (rigid var type at synth time;
|
||
/// concrete type only available at discharge).
|
||
Multi {
|
||
method: String,
|
||
candidates: BTreeSet<String>,
|
||
},
|
||
}
|
||
|
||
/// resolve a method-call site per the spec's 5-step rule. Pure
|
||
/// function — no Env mutation, no residual emission.
|
||
///
|
||
/// `qualifier_prefix` is the dot-stripped class prefix (e.g.
|
||
/// `"prelude.Show"` for a `Term::Var.name == "prelude.Show.show"`).
|
||
/// `None` = bare method form.
|
||
///
|
||
/// `concrete_arg_type` is `Some` when the caller has the arg type
|
||
/// available (e.g. discharge-time refinement); `None` when synth is at
|
||
/// the Var arm before App-arm unification. When `None`, the helper
|
||
/// emits `Multi` if the candidate set is non-singleton (no early
|
||
/// type-driven filter possible). When `Some(t)` with `t` a non-rigid
|
||
/// concrete type, the type-driven filter applies; when `t` is a rigid
|
||
/// `Type::Var`, the constraint-driven filter applies.
|
||
///
|
||
/// `declared_constraints` are the active forall-bound constraints in
|
||
/// scope at the call site; consulted for the rigid-var fallback.
|
||
///
|
||
/// `registry` is the workspace registry keyed by `(qualified_class,
|
||
/// type_hash)` for instance-existence checks. Values are unit; only
|
||
/// key presence matters.
|
||
pub fn resolve_method_dispatch(
|
||
method: &str,
|
||
qualifier_prefix: Option<&str>,
|
||
candidates: &BTreeSet<String>,
|
||
concrete_arg_type: Option<&Type>,
|
||
declared_constraints: &[ailang_core::ast::Constraint],
|
||
registry: &BTreeMap<(String, String), ()>,
|
||
) -> MethodDispatchOutcome {
|
||
// Step 3 (5-step rule): explicit qualifier present.
|
||
if let Some(q) = qualifier_prefix {
|
||
if candidates.contains(q) {
|
||
return MethodDispatchOutcome::Resolved(q.to_string());
|
||
}
|
||
return MethodDispatchOutcome::UnknownClass(q.to_string());
|
||
}
|
||
|
||
// Step 4: bare-method form, singleton candidate set — pre-canonical-class-form
|
||
// path. Returns immediately so the post-canonical-class-form multi-candidate
|
||
// logic below stays gated on a non-singleton candidate set.
|
||
if candidates.len() == 1 {
|
||
return MethodDispatchOutcome::Resolved(
|
||
candidates.iter().next().cloned().unwrap(),
|
||
);
|
||
}
|
||
|
||
// Multi-candidate path: type-driven filter first when the arg
|
||
// type is concrete (non-Var).
|
||
let concrete = concrete_arg_type
|
||
.filter(|t| !matches!(t, Type::Var { .. }));
|
||
if let Some(t) = concrete {
|
||
let type_h = ailang_core::canonical::type_hash(t);
|
||
let survivors: BTreeSet<String> = candidates
|
||
.iter()
|
||
.filter(|c| registry.contains_key(&((**c).clone(), type_h.clone())))
|
||
.cloned()
|
||
.collect();
|
||
if survivors.len() == 1 {
|
||
return MethodDispatchOutcome::Resolved(
|
||
survivors.into_iter().next().unwrap(),
|
||
);
|
||
}
|
||
if survivors.len() > 1 {
|
||
let mut sorted: Vec<String> = survivors.into_iter().collect();
|
||
sorted.sort();
|
||
let at_type = ailang_core::pretty::type_to_string(t);
|
||
return MethodDispatchOutcome::Ambiguous {
|
||
method: method.to_string(),
|
||
at_type,
|
||
candidates: sorted,
|
||
};
|
||
}
|
||
// Zero survivors after the type-driven filter: fall through
|
||
// to constraint-driven filter, then `Multi` for discharge-
|
||
// time refinement (where `NoInstance` with `candidate_classes`
|
||
// is fired against the full candidate set).
|
||
}
|
||
|
||
// Constraint-driven filter (rigid-var case or no concrete arg).
|
||
let survivors: BTreeSet<String> = candidates
|
||
.iter()
|
||
.filter(|c| {
|
||
declared_constraints
|
||
.iter()
|
||
.any(|dc| &dc.class == *c)
|
||
})
|
||
.cloned()
|
||
.collect();
|
||
if survivors.len() == 1 {
|
||
return MethodDispatchOutcome::Resolved(
|
||
survivors.into_iter().next().unwrap(),
|
||
);
|
||
}
|
||
|
||
// No filter narrowed; emit `Multi` for discharge-time refinement.
|
||
MethodDispatchOutcome::Multi {
|
||
method: method.to_string(),
|
||
candidates: candidates.clone(),
|
||
}
|
||
}
|
||
|
||
/// outcome of multi-candidate residual refinement at discharge
|
||
/// time. Consumed by `check_fn`'s residual loop and by mono's
|
||
/// residual-to-target mapping.
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub enum RefineOutcome {
|
||
/// Refinement converged on a single class.
|
||
Resolved(String),
|
||
/// Concrete `type_` with zero registry survivors — the call site
|
||
/// must be discharged via an existing instance, but none of the
|
||
/// candidate classes has one for this type. Surfaces as a
|
||
/// `CheckError::NoInstance` with the full candidate set populated.
|
||
NoInstance {
|
||
method: String,
|
||
at_type: String,
|
||
candidate_classes: Vec<String>,
|
||
},
|
||
/// Concrete `type_` with multiple registry survivors — every
|
||
/// surviving candidate has an instance for this type; the author
|
||
/// must disambiguate explicitly. Surfaces as a
|
||
/// `CheckError::AmbiguousMethodResolution`.
|
||
Ambiguous {
|
||
method: String,
|
||
at_type: String,
|
||
candidate_classes: Vec<String>,
|
||
},
|
||
/// Rigid-var residual with non-singleton constraint-set survivors.
|
||
/// Surfaces as a `CheckError::MissingConstraint` (the body must
|
||
/// declare a constraint to commit to one of the candidates).
|
||
MissingConstraint {
|
||
method: String,
|
||
candidate_classes: Vec<String>,
|
||
},
|
||
}
|
||
|
||
/// refine a multi-candidate residual at discharge time per the
|
||
/// spec's 5-step rule (constraint-discharge refinement subsection).
|
||
/// Caller MUST pass a residual whose `candidates` is `Some(...)`;
|
||
/// single-candidate residuals (`candidates: None`) bypass this helper
|
||
/// and discharge directly against the registry as in pre-canonical-class-form
|
||
///
|
||
/// Refinement strategy:
|
||
/// - Concrete `type_`: filter the candidate set against the registry
|
||
/// keyed on `(class, type_hash(type_))`. 0 survivors → `NoInstance`;
|
||
/// 1 → `Resolved`; >1 → `Ambiguous`.
|
||
/// - Rigid-var `type_`: filter the candidate set against the active
|
||
/// declared-constraint list. 1 survivor → `Resolved`; otherwise
|
||
/// → `MissingConstraint`.
|
||
pub fn refine_multi_candidate_residual(
|
||
residual: &ResidualConstraint,
|
||
declared_constraints: &[ailang_core::ast::Constraint],
|
||
registry: &BTreeMap<(String, String), ()>,
|
||
) -> RefineOutcome {
|
||
let candidates = residual
|
||
.candidates
|
||
.as_ref()
|
||
.expect("refine_multi_candidate_residual called on single-candidate residual");
|
||
|
||
// Concrete-type path.
|
||
if !matches!(residual.type_, Type::Var { .. }) {
|
||
let type_h = ailang_core::canonical::type_hash(&residual.type_);
|
||
let survivors: Vec<String> = candidates
|
||
.iter()
|
||
.filter(|c| registry.contains_key(&((**c).clone(), type_h.clone())))
|
||
.cloned()
|
||
.collect();
|
||
let at_type = ailang_core::pretty::type_to_string(&residual.type_);
|
||
return match survivors.len() {
|
||
0 => RefineOutcome::NoInstance {
|
||
method: residual.method.clone(),
|
||
at_type,
|
||
candidate_classes: candidates.iter().cloned().collect(),
|
||
},
|
||
1 => RefineOutcome::Resolved(survivors.into_iter().next().unwrap()),
|
||
_ => {
|
||
let mut sorted = survivors;
|
||
sorted.sort();
|
||
RefineOutcome::Ambiguous {
|
||
method: residual.method.clone(),
|
||
at_type,
|
||
candidate_classes: sorted,
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
// Rigid-var path: filter against declared constraints requiring
|
||
// BOTH class-name match AND type-unification with the residual's
|
||
// `type_` (per spec §"Constraint-discharge refinement" 130-138).
|
||
// `constraint_type_matches` is the post-`subst.apply` Var/Con
|
||
// recursive matcher; the residual's `type_` has been `subst.apply`-d
|
||
// by the discharge caller, so rigid-var identity (Var("a") vs
|
||
// Var("b")) is a direct name comparison.
|
||
let survivors: Vec<String> = candidates
|
||
.iter()
|
||
.filter(|c| {
|
||
declared_constraints.iter().any(|dc| {
|
||
&dc.class == *c && constraint_type_matches(&dc.type_, &residual.type_)
|
||
})
|
||
})
|
||
.cloned()
|
||
.collect();
|
||
match survivors.len() {
|
||
1 => RefineOutcome::Resolved(survivors.into_iter().next().unwrap()),
|
||
_ => RefineOutcome::MissingConstraint {
|
||
method: residual.method.clone(),
|
||
candidate_classes: candidates.iter().cloned().collect(),
|
||
},
|
||
}
|
||
}
|
||
|
||
/// per-call-site polymorphic-free-fn observation recorded by
|
||
/// `synth` when a `Term::Var` resolves to a `Type::Forall`-quantified
|
||
/// top-level def (NOT a class method — those are pushed to
|
||
/// [`ResidualConstraint`] instead). Carries the bare name, the owning
|
||
/// module (resolved through the same lookup ladder as `synth`'s Var arm),
|
||
/// the polymorphic source's `Forall.vars` (declaration order), and the
|
||
/// fresh metavars instantiated for those vars at the call site.
|
||
///
|
||
/// Consumed by the mono pass: after `synth` completes a body, each
|
||
/// `metas\[i\]` is `subst.apply`'d to recover the concrete type at `vars\[i\]`.
|
||
/// Fully-concrete observations become [`crate::mono::MonoTarget::FreeFn`]
|
||
/// targets; non-concrete observations are silently dropped (covered by
|
||
/// the missing-constraint check on residuals if relevant).
|
||
#[derive(Debug, Clone)]
|
||
pub struct FreeFnCall {
|
||
/// Bare name of the polymorphic def at the call site.
|
||
pub name: String,
|
||
/// Module in which the polymorphic `Def::Fn` is declared, resolved
|
||
/// through `synth`'s lookup ladder (current module's globals,
|
||
/// implicitly-imported module's globals, or the dot-qualified
|
||
/// `prefix.name` path's target module).
|
||
pub owner_module: String,
|
||
/// The `Forall.vars` of the source def (declaration order).
|
||
pub forall_vars: Vec<String>,
|
||
/// Fresh metavars instantiated at this call site — same length as
|
||
/// `forall_vars`, same order. Post-`synth`, `subst.apply(&metas[i])`
|
||
/// yields the concrete type for `forall_vars[i]` iff the call site's
|
||
/// substitution was fully pinned.
|
||
pub metas: Vec<Type>,
|
||
/// The TypeDef scope the call resolved through, when it was
|
||
/// spelled type-scoped (`T.f`, prep.1) — `Some("RawBuf")` for
|
||
/// `RawBuf.new`, `Some("RawBuf")` for `RawBuf.get`. `None` for a
|
||
/// bare / dot-qualified / local resolution. raw-buf.3: drives the
|
||
/// scope-qualified mono symbol `T_f__<elem>` so two types' ops
|
||
/// of the same name never collide.
|
||
pub scope: Option<String>,
|
||
}
|
||
|
||
/// lift a canonical-form class-ref value to the qualified
|
||
/// workspace-key shape. Bare ⇒ prepend `caller_module` (the bare
|
||
/// form names the caller-module-local class under the canonical-form
|
||
/// rule); qualified ⇒ as-is. Sibling of `workspace::qualify_class_ref`
|
||
/// for the check-side; duplicated to keep `ailang-check` from
|
||
/// reaching across crates for one private helper.
|
||
pub(crate) fn qualify_class_ref_in_check(class_ref: &str, caller_module: &str) -> String {
|
||
if class_ref.contains('.') {
|
||
class_ref.to_string()
|
||
} else {
|
||
format!("{caller_module}.{class_ref}")
|
||
}
|
||
}
|
||
|
||
/// split a `Term::Var.name` into `(method_name,
|
||
/// optional_qualifier_prefix)` at the last dot.
|
||
///
|
||
/// - `"show"` → `("show", None)`
|
||
/// - `"prelude.Show.show"` → `("show", Some("prelude.Show"))`
|
||
/// - `"Show.show"` → `("show", Some("Show"))`
|
||
/// - `"std_list.length"` → `("length", Some("std_list"))`
|
||
///
|
||
/// Callers distinguish class qualifiers from cross-module-fn
|
||
/// qualifiers via [`qualifier_is_class_shape`]: PascalCase
|
||
/// single-segment prefixes (e.g. `"Show"`) and multi-segment
|
||
/// prefixes (e.g. `"prelude.Show"`) are class qualifiers per
|
||
/// the canonical-class-form rule; lowercase single-segment prefixes
|
||
/// (e.g. `"std_list"`) are module names and the caller falls
|
||
/// through to the cross-module-fn path.
|
||
pub(crate) fn parse_method_qualifier(name: &str) -> (&str, Option<String>) {
|
||
if let Some(dot_idx) = name.rfind('.') {
|
||
(&name[dot_idx + 1..], Some(name[..dot_idx].to_string()))
|
||
} else {
|
||
(name, None)
|
||
}
|
||
}
|
||
|
||
/// mq.tidy: predicate gating the synth Var-arm's class-method
|
||
/// dispatch entry on the qualifier shape. Accepts:
|
||
/// - bare-method form (qualifier absent), e.g. `"show"`.
|
||
/// - bare-class qualifier, e.g. `"Show.show"` — class names are
|
||
/// PascalCase per repo convention (first character uppercase).
|
||
/// - module-qualified class qualifier, e.g. `"prelude.Show.show"`.
|
||
///
|
||
/// Rejects qualifier shapes that look like cross-module-fn calls
|
||
/// (`"std_list.length"`): qualifier starts with lowercase, no
|
||
/// inner dot — falls through to the qualified-fn arm.
|
||
pub(crate) fn qualifier_is_class_shape(qualifier_opt: &Option<String>) -> bool {
|
||
match qualifier_opt {
|
||
None => true,
|
||
Some(q) => {
|
||
// Contains inner dot: module-qualified class
|
||
// (`prelude.Show`).
|
||
if q.contains('.') {
|
||
return true;
|
||
}
|
||
// Single segment: class iff the first char is uppercase
|
||
// (PascalCase convention).
|
||
q.chars().next().map(|c| c.is_uppercase()).unwrap_or(false)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Type-driven dispatch entry predicate: the synth Var-arm runs this to
|
||
/// decide whether `name` should be routed through the class-method
|
||
/// dispatch helper. Matches a bare method name (e.g. `"show"`) or a
|
||
/// class-shaped qualifier (`"Show.show"`, `"prelude.Show.show"`).
|
||
/// A 1-dot lowercase name (`"std_list.length"`) is structurally a
|
||
/// cross-module-fn call per the canonical-class-form rule's canonical-form rule and falls
|
||
/// through to the qualified-fn arm.
|
||
fn is_class_method_dispatch(name: &str, env: &Env) -> bool {
|
||
let (method_name, qualifier_opt) = parse_method_qualifier(name);
|
||
qualifier_is_class_shape(&qualifier_opt)
|
||
&& env.method_to_candidate_classes.contains_key(method_name)
|
||
}
|
||
|
||
/// mq.tidy: predicate for the `class-method-shadowed-by-fn`
|
||
/// warning emission. The full spec rule (§"Class-fn collisions"
|
||
/// 161-162) requires a class candidate WITH AN INSTANCE FOR THE
|
||
/// ACTUAL ARG TYPE; the arg type is unavailable at the synth
|
||
/// Var-arm where the warning fires (App-arm unification has not
|
||
/// run yet). This conservative approximation requires only that
|
||
/// at least one candidate class has SOME instance in the
|
||
/// workspace registry — stricter than today's "any class
|
||
/// declares the method" (which fires even for class methods
|
||
/// with zero instances anywhere), looser than the full spec
|
||
/// rule. A future tighten can defer the warning emission to a
|
||
/// later pass where the arg type is known.
|
||
pub(crate) fn any_candidate_class_has_instance(
|
||
method_to_candidate_classes: &BTreeMap<String, BTreeSet<String>>,
|
||
workspace_registry_entries: &BTreeMap<(String, String), ()>,
|
||
method_name: &str,
|
||
) -> bool {
|
||
let Some(cands) = method_to_candidate_classes.get(method_name) else {
|
||
return false;
|
||
};
|
||
cands.iter().any(|c| {
|
||
// BTreeMap is ordered lexicographically on the tuple key;
|
||
// `range((c, "")..).next()` is the first entry whose first
|
||
// component is >= `c`. The check `k == c` rejects spillover
|
||
// into the next class. O(log n) per candidate.
|
||
workspace_registry_entries
|
||
.range((c.clone(), String::new())..)
|
||
.next()
|
||
.map(|((k, _), _)| k == c)
|
||
.unwrap_or(false)
|
||
})
|
||
}
|
||
|
||
/// expand a list of declared class constraints
|
||
/// with their one-step superclass closure (the typeclass design). For every
|
||
/// declared `(C, t)`, append `(S, t)` if class `C` has a superclass
|
||
/// `S`. Only one step — The typeclass design forbids deeper chains. The
|
||
/// expanded list is what [`check_fn`] uses to decide whether a
|
||
/// residual is satisfied.
|
||
fn expand_declared_constraints(
|
||
declared: &[Constraint],
|
||
class_superclasses: &BTreeMap<String, String>,
|
||
) -> Vec<Constraint> {
|
||
let mut out: Vec<Constraint> = declared.to_vec();
|
||
for c in declared {
|
||
if let Some(sc) = class_superclasses.get(&c.class) {
|
||
out.push(Constraint {
|
||
class: sc.clone(),
|
||
type_: c.type_.clone(),
|
||
});
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// equality of constraint types for the
|
||
/// missing-constraint match. The residual type has been
|
||
/// `subst.apply`-d, so two rigid vars match iff their names are equal;
|
||
/// a `Type::Con` matches another `Type::Con` iff their head names and
|
||
/// args agree. Conservative: anything outside `Var` / `Con` is rejected
|
||
/// (the schema bounds Constraint.type_ to plain types, and Task 9 only
|
||
/// fires on Var residuals anyway).
|
||
fn constraint_type_matches(declared: &Type, residual: &Type) -> bool {
|
||
match (declared, residual) {
|
||
(Type::Var { name: a }, Type::Var { name: b }) => a == b,
|
||
(Type::Con { name: a, args: aa }, Type::Con { name: b, args: ba }) => {
|
||
a == b
|
||
&& aa.len() == ba.len()
|
||
&& aa.iter().zip(ba.iter()).all(|(x, y)| constraint_type_matches(x, y))
|
||
}
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
/// verifies that every `Term::App { tail: true, .. }` and
|
||
/// `Term::Do { tail: true, .. }` actually sits in tail position, per
|
||
/// the explicit-tail-call contract
|
||
/// (`design/contracts/0012-tail-calls.md`).
|
||
///
|
||
/// `is_tail` is the tail-context flag for the term currently being
|
||
/// visited. The propagation rules (from design/contracts/0012-tail-calls.md):
|
||
///
|
||
/// - The body of a fn / Lam is visited with `is_tail = true`.
|
||
/// - `Match { scrutinee, arms }`: the scrutinee is **not** in tail
|
||
/// position; each arm body inherits the same `is_tail` as the match.
|
||
/// - `Seq { lhs, rhs }`: lhs is non-tail; rhs inherits.
|
||
/// - `Let { value, body, .. }`: value is non-tail; body inherits.
|
||
/// - `App { callee, args, tail }`: the callee and all args are
|
||
/// non-tail. If `tail == true`, the App itself must have arrived
|
||
/// with `is_tail == true`; otherwise diagnostic.
|
||
/// - `Do { args, tail }`: same rule as App; all args are non-tail.
|
||
/// - `Ctor { args }`: all args are non-tail.
|
||
/// - `Lam { body }`: the Lam value is at whatever `is_tail` was; the
|
||
/// recursion **into** the body opens a fresh tail scope (the body
|
||
/// is visited with `is_tail = true`).
|
||
/// - `Lit`, `Var`: leaves; no further descent.
|
||
pub fn verify_tail_positions(t: &Term, is_tail: bool) -> Result<()> {
|
||
match t {
|
||
Term::Lit { .. } | Term::Var { .. } => Ok(()),
|
||
Term::App { callee, args, tail } => {
|
||
if *tail && !is_tail {
|
||
return Err(CheckError::TailCallNotInTailPosition);
|
||
}
|
||
verify_tail_positions(callee, false)?;
|
||
for a in args {
|
||
verify_tail_positions(a, false)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
Term::Do { args, tail, .. } => {
|
||
if *tail && !is_tail {
|
||
return Err(CheckError::TailCallNotInTailPosition);
|
||
}
|
||
for a in args {
|
||
verify_tail_positions(a, false)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
Term::Let { value, body, .. } => {
|
||
verify_tail_positions(value, false)?;
|
||
verify_tail_positions(body, is_tail)
|
||
}
|
||
Term::If { cond, then, else_ } => {
|
||
verify_tail_positions(cond, false)?;
|
||
verify_tail_positions(then, is_tail)?;
|
||
verify_tail_positions(else_, is_tail)
|
||
}
|
||
Term::Seq { lhs, rhs } => {
|
||
verify_tail_positions(lhs, false)?;
|
||
verify_tail_positions(rhs, is_tail)
|
||
}
|
||
Term::Match { scrutinee, arms } => {
|
||
verify_tail_positions(scrutinee, false)?;
|
||
for arm in arms {
|
||
verify_tail_positions(&arm.body, is_tail)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
Term::Ctor { args, .. } => {
|
||
for a in args {
|
||
verify_tail_positions(a, false)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
Term::Lam { body, .. } => {
|
||
// Entering a Lam body opens a fresh tail scope.
|
||
verify_tail_positions(body, true)
|
||
}
|
||
Term::LetRec { body, in_term, .. } => {
|
||
// `Term::LetRec` may now survive the desugar
|
||
// pass (when it captures `Term::Let`-bound names whose
|
||
// types are only known after typecheck). The body is the
|
||
// body of a fn-typed binding; the recursive name is what
|
||
// gets tail-called, so `body` is NOT in tail position. The
|
||
// in-clause IS in tail position iff the enclosing context
|
||
// is — same propagation rule as `Term::Let.body`.
|
||
verify_tail_positions(body, false)?;
|
||
verify_tail_positions(in_term, is_tail)
|
||
}
|
||
Term::Clone { value } => {
|
||
// clone is identity. Tail-position propagates
|
||
// through unchanged — `(clone tail-call)` is a tail call.
|
||
verify_tail_positions(value, is_tail)
|
||
}
|
||
Term::ReuseAs { source, body } => {
|
||
// reuse-as is identity at codegen. The `source`
|
||
// is a side-effect-free Var in shipping fixtures, so it is
|
||
// not a tail call; the `body` is the value of the whole
|
||
// expression and inherits the enclosing tail position.
|
||
verify_tail_positions(source, false)?;
|
||
verify_tail_positions(body, is_tail)
|
||
}
|
||
// loop-recur iter 1: binder inits are evaluated once on loop
|
||
// entry, NOT in tail position. The loop body inherits the
|
||
// enclosing tail position (the loop's value is the body's
|
||
// value on the exiting iteration). This arm only defines
|
||
// tail-app descent through the new node; it makes NO claim
|
||
// about recur tail-position (that is loop-recur iter 2's
|
||
// `verify_loop_body`). The tail-app verification role is
|
||
// unchanged — no pre-existing fixture contains a
|
||
// `loop`/`recur` node.
|
||
Term::Loop { binders, body } => {
|
||
for b in binders {
|
||
verify_tail_positions(&b.init, false)?;
|
||
}
|
||
verify_tail_positions(body, is_tail)
|
||
}
|
||
// recur transfers control and does not fall through, so there
|
||
// is no tail position to propagate. Its args are evaluated in
|
||
// non-tail position (call-argument-like). Introduces no
|
||
// tail-app violation.
|
||
Term::Recur { args } => {
|
||
for a in args {
|
||
verify_tail_positions(a, false)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
// prep.2 (kernel-extension-mechanics): `(new T args...)` is
|
||
// resolved by synth to a call into the home module's `new` def.
|
||
// Its value-args are evaluated in non-tail position (call-arg
|
||
// semantics); type-args carry no runtime term. The Term::New
|
||
// node itself is not a tail-app — codegen lowers it via a
|
||
// synth/desugar step in a later milestone.
|
||
Term::New { args, .. } => {
|
||
for arg in args {
|
||
if let NewArg::Value(v) = arg {
|
||
verify_tail_positions(v, false)?;
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
Term::Intrinsic => Ok(()),
|
||
}
|
||
}
|
||
|
||
/// loop-recur iter 2: enforces that every `Term::Recur` occurs in
|
||
/// tail position of its lexically-innermost enclosing `Term::Loop`
|
||
/// body. A NEW private pass — a *sibling* of
|
||
/// `verify_tail_positions` (which is spec-frozen for its tail-app
|
||
/// role and unchanged). Invoked from `check_fn` *after* `synth`, so
|
||
/// `RecurOutsideLoop`/arity/type (raised in `synth`) take code-
|
||
/// precedence; by the time a `Recur` is reached here it is known to
|
||
/// be inside a loop with matching arity/types — this pass adds only
|
||
/// the tail-position rule. `in_loop_tail` is the loop analogue of
|
||
/// `verify_tail_positions`' `is_tail`, scoped to the innermost loop.
|
||
fn verify_loop_body(t: &Term, in_loop_tail: bool) -> Result<()> {
|
||
match t {
|
||
Term::Lit { .. } | Term::Var { .. } => Ok(()),
|
||
Term::Recur { args } => {
|
||
if !in_loop_tail {
|
||
return Err(CheckError::RecurNotInTailPosition);
|
||
}
|
||
// recur args are evaluated in non-tail position; a recur
|
||
// nested inside a recur arg is itself non-tail.
|
||
for a in args {
|
||
verify_loop_body(a, false)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
Term::Loop { binders, body } => {
|
||
// Binder inits run once on loop entry — not in the
|
||
// loop's tail scope. The loop body opens a FRESH
|
||
// innermost-loop tail scope (independent of where the
|
||
// loop itself sits — the loop's tail scope is intrinsic).
|
||
for b in binders {
|
||
verify_loop_body(&b.init, false)?;
|
||
}
|
||
verify_loop_body(body, true)
|
||
}
|
||
Term::App { callee, args, .. } => {
|
||
verify_loop_body(callee, false)?;
|
||
for a in args {
|
||
verify_loop_body(a, false)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
Term::Do { args, .. } => {
|
||
for a in args {
|
||
verify_loop_body(a, false)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
Term::Let { value, body, .. } => {
|
||
verify_loop_body(value, false)?;
|
||
verify_loop_body(body, in_loop_tail)
|
||
}
|
||
Term::If { cond, then, else_ } => {
|
||
verify_loop_body(cond, false)?;
|
||
verify_loop_body(then, in_loop_tail)?;
|
||
verify_loop_body(else_, in_loop_tail)
|
||
}
|
||
Term::Seq { lhs, rhs } => {
|
||
verify_loop_body(lhs, false)?;
|
||
verify_loop_body(rhs, in_loop_tail)
|
||
}
|
||
Term::Match { scrutinee, arms } => {
|
||
verify_loop_body(scrutinee, false)?;
|
||
for arm in arms {
|
||
verify_loop_body(&arm.body, in_loop_tail)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
Term::Ctor { args, .. } => {
|
||
for a in args {
|
||
verify_loop_body(a, false)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
Term::Lam { body, .. } => {
|
||
// A `recur` cannot cross a lambda boundary (different
|
||
// control frame). Reset: a recur inside a lam is outside
|
||
// every enclosing loop's tail scope — and synth's
|
||
// loop_stack likewise does not cross the lam, so such a
|
||
// recur is already a synth `RecurOutsideLoop`.
|
||
verify_loop_body(body, false)
|
||
}
|
||
Term::LetRec { body, in_term, .. } => {
|
||
verify_loop_body(body, false)?;
|
||
verify_loop_body(in_term, in_loop_tail)
|
||
}
|
||
Term::Clone { value } => verify_loop_body(value, in_loop_tail),
|
||
Term::ReuseAs { source, body } => {
|
||
verify_loop_body(source, false)?;
|
||
verify_loop_body(body, in_loop_tail)
|
||
}
|
||
// prep.2 (kernel-extension-mechanics): Term::New's value-args
|
||
// are non-tail (call-arg semantics). The Term::New node itself
|
||
// is opaque to the loop/recur control transfer — value args
|
||
// are evaluated in non-tail position; type-args carry no term.
|
||
Term::New { args, .. } => {
|
||
for arg in args {
|
||
if let NewArg::Value(v) = arg {
|
||
verify_loop_body(v, false)?;
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
Term::Intrinsic => Ok(()),
|
||
}
|
||
}
|
||
|
||
fn check_const(c: &ConstDef, env: &Env, out_warnings: &mut Vec<Diagnostic>) -> Result<()> {
|
||
// Const types are never polymorphic — a Forall here is rejected
|
||
// outright. Any other type passes through to `synth` as before.
|
||
if matches!(&c.ty, Type::Forall { .. }) {
|
||
return Err(CheckError::PolymorphicNotSupported(c.name.clone()));
|
||
}
|
||
let mut locals = IndexMap::new();
|
||
let mut effects = BTreeSet::new();
|
||
let mut subst = Subst::default();
|
||
let mut counter: u32 = 0;
|
||
// const bodies don't carry a `Forall.constraints`
|
||
// (rejected upstream), so any residual would have to be concrete —
|
||
// Task 10's no-instance arm will pick those up. We thread an
|
||
// accumulator only to keep `synth`'s signature uniform.
|
||
let mut residuals: Vec<ResidualConstraint> = Vec::new();
|
||
let mut free_fn_calls: Vec<FreeFnCall> = Vec::new();
|
||
let mut warnings: Vec<Diagnostic> = Vec::new();
|
||
// loop-recur iter 2: a const value has no enclosing loop; any
|
||
// `recur` in a const body correctly fires `RecurOutsideLoop`.
|
||
let mut loop_stack: Vec<Vec<(String, Type)>> = Vec::new();
|
||
let v = synth(&c.value, env, &mut locals, &mut loop_stack, &mut effects, &c.name, &mut subst, &mut counter, &mut residuals, &mut free_fn_calls, &mut warnings)?;
|
||
out_warnings.extend(warnings);
|
||
unify(&c.ty, &v, &mut subst)?;
|
||
if !effects.is_empty() {
|
||
return Err(CheckError::ConstHasEffects(
|
||
c.name.clone(),
|
||
effects.into_iter().collect(),
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub(crate) fn synth(
|
||
t: &Term,
|
||
env: &Env,
|
||
locals: &mut IndexMap<String, Type>,
|
||
// loop-recur iter 2: per-walk loop-binder stack. Each frame is one
|
||
// `Term::Loop`'s binders in declaration order. `recur` rebinds
|
||
// binders by position, not name, so the type list is positional;
|
||
// iter loop-recur.tidy added the binder name alongside the type
|
||
// so `Term::Lam`'s escape guard can reject a free var that is an
|
||
// in-scope loop binder. Pushed on `Term::Loop` body entry, popped
|
||
// on exit; consulted innermost-first by `Term::Recur` for arity +
|
||
// per-position type, and across all frames by `Term::Lam` for the
|
||
// escape check. Position: locals-adjacent — per-fn-body
|
||
// lexical-control scope state.
|
||
loop_stack: &mut Vec<Vec<(String, Type)>>,
|
||
effects: &mut BTreeSet<String>,
|
||
in_def: &str,
|
||
subst: &mut Subst,
|
||
counter: &mut u32,
|
||
residuals: &mut Vec<ResidualConstraint>,
|
||
free_fn_calls: &mut Vec<FreeFnCall>,
|
||
// out-parameter for synth-time structured warnings. Symmetric
|
||
// to `residuals` / `free_fn_calls`. Currently only the
|
||
// `class-method-shadowed-by-fn` warning is emitted here; future
|
||
// synth-time warnings (if any) would flow through the same channel.
|
||
warnings: &mut Vec<crate::diagnostic::Diagnostic>,
|
||
) -> Result<Type> {
|
||
match t {
|
||
Term::Lit { lit } => Ok(match lit {
|
||
Literal::Int { .. } => Type::int(),
|
||
Literal::Bool { .. } => Type::bool_(),
|
||
Literal::Str { .. } => Type::str_(),
|
||
Literal::Unit => Type::unit(),
|
||
Literal::Float { .. } => Type::float(),
|
||
}),
|
||
Term::Var { name } => {
|
||
// Lookup precedence: locals → local globals → class-method
|
||
// table → qualified cross-module ref. A `Forall` resolved at
|
||
// any of these levels is instantiated with fresh metavars at
|
||
// the use site (textbook ML rule); rigid vars and concrete
|
||
// types pass through unchanged.
|
||
//
|
||
// the class-method channel sits between
|
||
// local globals and qualified cross-module ref. When a name
|
||
// resolves through `env.class_methods`, we substitute the
|
||
// class param with a fresh metavar and record a residual
|
||
// class constraint — `check_fn` later compares the residual
|
||
// set against the declared `Forall.constraints`.
|
||
// alongside the bare type, capture
|
||
// (owner_module, unqualified_name_in_owner_module)
|
||
// for any non-local resolution that lands on a
|
||
// `Type::Forall` AND is a real workspace-declared
|
||
// `Def::Fn` (NOT a builtin operator like `==`). The
|
||
// discriminator is whether the name appears in
|
||
// `env.module_globals[<owner>]` — builtins are installed
|
||
// into `env.globals` only. The unqualified-name capture
|
||
// matters for dot-qualified call sites: the FreeFnCall
|
||
// records the suffix (e.g. `length`), not the full
|
||
// `List.length` (prep.1's type-scoped form) or
|
||
// `std_list.length` (module-qualified form), so
|
||
// `synthesise_mono_fn_for_free_fn` can look up the
|
||
// source def by its bare name in the owner module's
|
||
// def list.
|
||
//
|
||
// After the resolution ladder, if `raw` is a `Type::Forall`
|
||
// AND a `free_fn_owner` is known, push a `FreeFnCall`
|
||
// observation; the mono pass `subst.apply`s the metas
|
||
// post-synth to recover concrete type-args at each
|
||
// polymorphic free-fn call site.
|
||
// a helper closure that emits the
|
||
// `class-method-shadowed-by-fn` warning when a fn-precedence
|
||
// branch resolves a name that ALSO has class-method
|
||
// candidates in the workspace. The fn resolution proceeds;
|
||
// the warning surfaces the shadow so the LLM-author can
|
||
// disambiguate via explicit class-qualified call if the
|
||
// shadow was unintentional. Locals/same-module-fn/implicit-
|
||
// import-fn all share this rule.
|
||
let emit_shadow_warning_if_class_method =
|
||
|name: &str, owner_module: &str, warnings: &mut Vec<crate::diagnostic::Diagnostic>| {
|
||
let (method_name, _) = parse_method_qualifier(name);
|
||
// mq.tidy: tighten emission per spec §"Class-fn
|
||
// collisions" — fire only when at least one
|
||
// candidate class has a registry instance (any
|
||
// type). Without this, the warning fires on any
|
||
// class declaring the method, including class
|
||
// methods with zero instances anywhere in the
|
||
// workspace.
|
||
let registry_unit_view: BTreeMap<(String, String), ()> = env
|
||
.workspace_registry
|
||
.entries
|
||
.keys()
|
||
.map(|k| (k.clone(), ()))
|
||
.collect();
|
||
if !any_candidate_class_has_instance(
|
||
&env.method_to_candidate_classes,
|
||
®istry_unit_view,
|
||
method_name,
|
||
) {
|
||
return;
|
||
}
|
||
if let Some(cands) = env.method_to_candidate_classes.get(method_name) {
|
||
let mut candidate_list: Vec<String> = cands.iter().cloned().collect();
|
||
candidate_list.sort();
|
||
let d = crate::diagnostic::Diagnostic::warning(
|
||
"class-method-shadowed-by-fn",
|
||
format!(
|
||
"free fn `{name}` in module `{owner_module}` shadows class method `{method_name}` \
|
||
declared in classes {candidate_list:?}. \
|
||
To call the class method instead, write `<ClassQualifier>.{method_name} ...`.",
|
||
),
|
||
)
|
||
.with_def(in_def.to_string())
|
||
.with_ctx(serde_json::json!({
|
||
"name": name,
|
||
"method": method_name,
|
||
"fn_owner_module": owner_module,
|
||
"candidate_classes": candidate_list,
|
||
}));
|
||
warnings.push(d);
|
||
}
|
||
};
|
||
|
||
let (raw, free_fn_owner): (Type, Option<(String, String, Option<String>)>) = if let Some(t) = locals.get(name) {
|
||
// Locals (lambda / let / fn-params) — fn wins per
|
||
// lookup precedence. A class method shadowed by a
|
||
// local binding fires the shadow warning so the
|
||
// author can disambiguate if unintentional.
|
||
emit_shadow_warning_if_class_method(name, env.current_module.as_str(), warnings);
|
||
(t.clone(), None)
|
||
} else if let Some(t) = env.globals.get(name) {
|
||
// Same-module global. Owner is the current module IFF
|
||
// the name is in this module's declared globals
|
||
// (i.e. it's a `Def::Fn`, not a builtin). The bare
|
||
// `name` works as the in-module key.
|
||
let owner = env
|
||
.module_globals
|
||
.get(&env.current_module)
|
||
.filter(|m| m.contains_key(name))
|
||
.map(|_| (env.current_module.clone(), name.clone(), None));
|
||
emit_shadow_warning_if_class_method(name, env.current_module.as_str(), warnings);
|
||
(t.clone(), owner)
|
||
} else if let Some((owner_module, raw_ty)) = env
|
||
.imports
|
||
.values()
|
||
.find_map(|mod_name| {
|
||
env.module_globals
|
||
.get(mod_name)
|
||
.and_then(|mg| mg.get(name).map(|ty| (mod_name.clone(), ty.clone())))
|
||
})
|
||
{
|
||
// implicit-import free-fn precedence runs BEFORE
|
||
// the class-method branch (fn wins per spec
|
||
// §"Class-fn collisions"). When the name has class-
|
||
// method candidates too, emit the shadow warning.
|
||
//
|
||
// bare-name fall-through to free fns of
|
||
// implicitly-imported modules. Today only the prelude is
|
||
// implicitly imported (see `build_check_env` and
|
||
// `check_in_workspace`'s prelude-injection), so the
|
||
// first match is unambiguous. If a second implicit
|
||
// import lands, the single-match-wins rule becomes a
|
||
// real ambiguity and this branch needs a
|
||
// duplicate-detection diagnostic.
|
||
emit_shadow_warning_if_class_method(name, owner_module.as_str(), warnings);
|
||
// Qualify any bare type-cons referring to a type defined in
|
||
// the owning module — same treatment the dot-qualified arm
|
||
// below applies — so signatures cross the module boundary
|
||
// with fully-qualified Type::Cons.
|
||
let owner_types = env
|
||
.module_types
|
||
.get(&owner_module)
|
||
.cloned()
|
||
.unwrap_or_default();
|
||
let qualified = qualify_local_types(&raw_ty, &owner_module, &owner_types);
|
||
// Bare name resolves through implicit import; the
|
||
// unqualified name in the owner module is the same `name`.
|
||
(qualified, Some((owner_module, name.clone(), None)))
|
||
} else if is_class_method_dispatch(name, env) {
|
||
// type-driven dispatch entry. The Var arm runs
|
||
// before App-arm unification, so the arg type is not
|
||
// available here. The dispatch helper resolves on
|
||
// (a) explicit class qualifier, (b) singleton candidate
|
||
// set (today's invariant), or (c) constraint-driven
|
||
// filter (rigid-var case); otherwise it emits `Multi`
|
||
// for discharge-time refinement.
|
||
//
|
||
// With `MethodNameCollision` still active (pre-canonical-class-form),
|
||
// real workspaces never reach the `Multi` branch — the
|
||
// candidate set is always singleton. The branch is
|
||
// installed here for the type-driven dispatch refactor to exercise without further
|
||
// dispatch-side edits.
|
||
let (method_name, qualifier_opt) = parse_method_qualifier(name);
|
||
let candidates = env
|
||
.method_to_candidate_classes
|
||
.get(method_name)
|
||
.expect("contains_key invariant: gate above guarantees presence");
|
||
|
||
// Build a unit-view of the workspace registry keyed by
|
||
// (qualified_class, type_hash). Values are unit; only
|
||
// key presence matters for the type-driven filter.
|
||
let registry_unit: BTreeMap<(String, String), ()> = env
|
||
.workspace_registry
|
||
.entries
|
||
.keys()
|
||
.map(|k| (k.clone(), ()))
|
||
.collect();
|
||
|
||
let outcome = resolve_method_dispatch(
|
||
method_name,
|
||
qualifier_opt.as_deref(),
|
||
candidates,
|
||
/*concrete_arg_type*/ None,
|
||
/*declared_constraints*/ &env.active_declared_constraints,
|
||
/*registry*/ ®istry_unit,
|
||
);
|
||
|
||
let (residual_class, residual_candidates) = match outcome {
|
||
MethodDispatchOutcome::Resolved(class) => (class, None),
|
||
MethodDispatchOutcome::Multi { candidates: cs, method: _ } => {
|
||
// Tentative class: first BTreeSet element.
|
||
// Refinement at discharge / mono time overwrites it.
|
||
let tentative = cs.iter().next().cloned().unwrap_or_default();
|
||
(tentative, Some(cs))
|
||
}
|
||
MethodDispatchOutcome::UnknownClass(qname) => {
|
||
return Err(CheckError::UnknownClass { name: qname });
|
||
}
|
||
MethodDispatchOutcome::Ambiguous { method, at_type, candidates: cs } => {
|
||
return Err(CheckError::AmbiguousMethodResolution {
|
||
method,
|
||
at_type,
|
||
candidate_classes: cs,
|
||
});
|
||
}
|
||
};
|
||
|
||
// class_methods is tuple-keyed by
|
||
// `(qualified-class, method)`. The dispatcher above
|
||
// resolved a tentative class (single-survivor or first
|
||
// BTreeSet element for the multi-candidate path) — use
|
||
// it to disambiguate the lookup. The `expect` is sound
|
||
// by construction: `method_to_candidate_classes` and
|
||
// `class_methods` are built from the same source loop
|
||
// (every entry in one has a matching entry in the
|
||
// other; see `build_check_env`).
|
||
let cm = env
|
||
.class_methods
|
||
.get(&(residual_class.clone(), method_name.to_string()))
|
||
.expect(
|
||
"method_to_candidate_classes invariant: \
|
||
(resolved class, method) present implies class_methods entry",
|
||
);
|
||
let owner_types = env
|
||
.module_types
|
||
.get(&cm.defining_module)
|
||
.cloned()
|
||
.unwrap_or_default();
|
||
let qualified_method_ty =
|
||
qualify_local_types(&cm.method_ty, &cm.defining_module, &owner_types);
|
||
let fresh = Subst::fresh(counter);
|
||
let mut mapping: BTreeMap<String, Type> = BTreeMap::new();
|
||
mapping.insert(cm.class_param.clone(), fresh.clone());
|
||
let inst_ty = substitute_rigids(&qualified_method_ty, &mapping);
|
||
residuals.push(ResidualConstraint {
|
||
class: residual_class,
|
||
type_: fresh,
|
||
method: method_name.to_string(),
|
||
candidates: residual_candidates,
|
||
});
|
||
return Ok(inst_ty);
|
||
} else if name.matches('.').count() == 1 {
|
||
let (prefix, suffix) = name.split_once('.').expect("checked");
|
||
// prep.1: TypeDef-first ladder. If `prefix` names a
|
||
// `TypeDef` anywhere in the workspace, that type's home
|
||
// module is the resolution target — type-scoped form
|
||
// `<TypeName>.<member>`. Otherwise fall back to the
|
||
// legacy module-as-receiver path via `env.imports`.
|
||
let type_home: Option<String> = env
|
||
.module_types
|
||
.iter()
|
||
.find_map(|(mod_name, types)| {
|
||
if types.contains_key(prefix) {
|
||
Some(mod_name.clone())
|
||
} else {
|
||
None
|
||
}
|
||
});
|
||
let target_module: String = if let Some(home) = type_home.clone() {
|
||
home
|
||
} else if let Some(m) = env.imports.get(prefix) {
|
||
m.clone()
|
||
} else {
|
||
return Err(CheckError::TypeScopedReceiverNotAType {
|
||
name: prefix.to_string(),
|
||
});
|
||
};
|
||
let g = env.module_globals.get(&target_module).ok_or_else(|| {
|
||
CheckError::UnknownModule {
|
||
module: target_module.clone(),
|
||
}
|
||
})?;
|
||
let raw_ty = g.get(suffix).cloned().ok_or_else(|| {
|
||
// If receiver resolved as a TypeDef but the suffix
|
||
// is missing in the home module, emit the
|
||
// type-scoped diagnostic; otherwise the legacy
|
||
// `UnknownImport`.
|
||
if type_home.is_some() {
|
||
CheckError::TypeScopedMemberNotFound {
|
||
type_name: prefix.to_string(),
|
||
member: suffix.to_string(),
|
||
home_module: target_module.clone(),
|
||
}
|
||
} else {
|
||
CheckError::UnknownImport {
|
||
module: target_module.clone(),
|
||
name: suffix.to_string(),
|
||
}
|
||
}
|
||
})?;
|
||
// qualify any bare type-cons referring to a
|
||
// type defined in the owning module so that signatures
|
||
// pulled across the boundary unify against
|
||
// qualified-form ctors and types in the consumer
|
||
// module.
|
||
//
|
||
// Same-module case: the pre-pass leaves own-module
|
||
// names bare (see `qualify_workspace_types`'s
|
||
// own-module rule), and the local `Term::Ctor` arm
|
||
// (~line 3644) also returns bare names. Qualifying
|
||
// here would produce `<this>.T` for sites the rest
|
||
// of the resolver represents as bare `T`, tripping
|
||
// a phantom type-mismatch on a same-module
|
||
// `(app Type.member …)` call. Fieldtest F1 surfaced
|
||
// this asymmetry; the fix is to no-op the qualifier
|
||
// when `target_module == env.current_module`.
|
||
let qualified = if target_module == env.current_module {
|
||
raw_ty
|
||
} else {
|
||
let owner_types = env
|
||
.module_types
|
||
.get(&target_module)
|
||
.cloned()
|
||
.unwrap_or_default();
|
||
qualify_local_types(&raw_ty, &target_module, &owner_types)
|
||
};
|
||
// Dot-qualified `prefix.suffix`: the unqualified name in
|
||
// the owner module is `suffix`. raw-buf.3: when the
|
||
// resolution went through the TypeDef-first ladder
|
||
// (`type_home.is_some()`), the call is type-scoped — carry
|
||
// the TypeDef prefix as the mono-symbol scope. The legacy
|
||
// module-as-receiver sub-case (`type_home == None`) is not
|
||
// type-scoped → `scope = None`.
|
||
let scope = if type_home.is_some() {
|
||
Some(prefix.to_string())
|
||
} else {
|
||
None
|
||
};
|
||
(qualified, Some((target_module, suffix.to_string(), scope)))
|
||
} else {
|
||
return Err(CheckError::UnknownIdent(name.clone()));
|
||
};
|
||
// if raw is `Type::Forall` AND we know the
|
||
// owner module (i.e. the resolution went through a non-
|
||
// local branch), record a FreeFnCall observation BEFORE
|
||
// instantiating. The metas are the fresh metavars we
|
||
// about to use; the mono pass `subst.apply`s them later.
|
||
// Note: `unqualified_name` is the name as seen inside the
|
||
// owner module (so the mono pass can find the `Def::Fn`
|
||
// by bare name); the source-level `name` may be a
|
||
// dot-qualified form (`prefix.suffix`) the synth started
|
||
// with.
|
||
if let (Type::Forall { vars, constraints, body }, Some((owner, unqualified_name, scope))) =
|
||
(&raw, &free_fn_owner)
|
||
{
|
||
let (metas, inst) = instantiate(vars, body, counter);
|
||
// push a residual for each declared constraint
|
||
// of the poly free fn with the rigid var substituted by
|
||
// its fresh metavar. The discharge loop at the enclosing
|
||
// `check_fn`'s post-synth phase resolves the residual
|
||
// against the workspace registry (fully-concrete after
|
||
// App-arm unification) and fires `NoInstance` if no
|
||
// instance ships for the unified type. Without this push,
|
||
// `print f` at a function type `f : Int -> Int` would
|
||
// typecheck silently — the `Show f`-shaped obligation
|
||
// would only surface at codegen as an `unknown variable`
|
||
// error from the synthesised mono body.
|
||
//
|
||
// The substitution mirrors `instantiate`'s body-side
|
||
// mapping: position-by-position pairing of `vars` with
|
||
// the freshly-generated `metas`. The discharge path
|
||
// post-`subst.apply` produces the same concrete type
|
||
// that the App-arm unified into the metavars.
|
||
let cmapping: BTreeMap<String, Type> = vars
|
||
.iter()
|
||
.cloned()
|
||
.zip(metas.iter().cloned())
|
||
.collect();
|
||
for c in constraints {
|
||
let c_class = qualify_class_ref_in_check(&c.class, owner);
|
||
let c_ty = substitute_rigids(&c.type_, &cmapping);
|
||
// Look up the class's method name via the workspace
|
||
// class index; we need it for the `ResidualConstraint`'s
|
||
// `method` field (used by `NoInstance`'s rendering).
|
||
let method_name = env
|
||
.class_methods
|
||
.iter()
|
||
.find_map(|((cls, m), _)| {
|
||
if *cls == c_class { Some(m.clone()) } else { None }
|
||
})
|
||
.expect(
|
||
"class_methods registry coherence — a declared constraint's \
|
||
class is missing from env.class_methods. The pre-pass \
|
||
`MissingClass` diagnostic should have rejected this earlier; \
|
||
reaching here means workspace-registry / class-index drift. \
|
||
Surface to debug skill rather than silently render NoInstance \
|
||
with an empty method name.",
|
||
);
|
||
residuals.push(ResidualConstraint {
|
||
class: c_class,
|
||
type_: c_ty,
|
||
method: method_name,
|
||
candidates: None,
|
||
});
|
||
}
|
||
free_fn_calls.push(FreeFnCall {
|
||
name: unqualified_name.clone(),
|
||
owner_module: owner.clone(),
|
||
forall_vars: vars.clone(),
|
||
metas: metas.clone(),
|
||
scope: scope.clone(),
|
||
});
|
||
return Ok(inst);
|
||
}
|
||
Ok(maybe_instantiate(raw, counter))
|
||
}
|
||
Term::App { callee, args, .. } => {
|
||
let cty = synth(callee, env, locals, loop_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?;
|
||
let cty = subst.apply(&cty);
|
||
let (params, ret, fx) = match &cty {
|
||
Type::Fn { params, ret, effects: fx, .. } => {
|
||
(params.clone(), (**ret).clone(), fx.clone())
|
||
}
|
||
Type::Forall { vars, constraints: _, body } => {
|
||
// Defensive — Var should already have instantiated.
|
||
let (_, body) = instantiate(vars, body, counter);
|
||
match &body {
|
||
Type::Fn { params, ret, effects: fx, .. } => {
|
||
(params.clone(), (**ret).clone(), fx.clone())
|
||
}
|
||
other => {
|
||
return Err(CheckError::NotAFunction(
|
||
callee_name(callee),
|
||
ailang_core::pretty::type_to_string(other),
|
||
));
|
||
}
|
||
}
|
||
}
|
||
other => {
|
||
return Err(CheckError::NotAFunction(
|
||
callee_name(callee),
|
||
ailang_core::pretty::type_to_string(other),
|
||
));
|
||
}
|
||
};
|
||
if args.len() != params.len() {
|
||
return Err(CheckError::ArityMismatch {
|
||
name: callee_name(callee),
|
||
expected: params.len(),
|
||
got: args.len(),
|
||
});
|
||
}
|
||
for (a, exp) in args.iter().zip(params.iter()) {
|
||
let actual = synth(a, env, locals, loop_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?;
|
||
unify(exp, &actual, subst)?;
|
||
}
|
||
for e in fx {
|
||
effects.insert(e);
|
||
}
|
||
Ok(subst.apply(&ret))
|
||
}
|
||
Term::Let { name, value, body } => {
|
||
let v = synth(value, env, locals, loop_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?;
|
||
let prev = locals.insert(name.clone(), v);
|
||
let r = synth(body, env, locals, loop_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?;
|
||
match prev {
|
||
Some(p) => {
|
||
locals.insert(name.clone(), p);
|
||
}
|
||
None => {
|
||
locals.shift_remove(name);
|
||
}
|
||
}
|
||
Ok(r)
|
||
}
|
||
Term::If { cond, then, else_ } => {
|
||
let c = synth(cond, env, locals, loop_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?;
|
||
unify(&Type::bool_(), &c, subst)?;
|
||
let t1 = synth(then, env, locals, loop_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?;
|
||
let t2 = synth(else_, env, locals, loop_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?;
|
||
unify(&t1, &t2, subst)?;
|
||
Ok(subst.apply(&t1))
|
||
}
|
||
Term::Do { op, args, .. } => {
|
||
let sig = env
|
||
.effect_ops
|
||
.get(op)
|
||
.ok_or_else(|| CheckError::UnknownEffectOp(op.clone()))?
|
||
.clone();
|
||
if args.len() != sig.params.len() {
|
||
return Err(CheckError::ArityMismatch {
|
||
name: op.clone(),
|
||
expected: sig.params.len(),
|
||
got: args.len(),
|
||
});
|
||
}
|
||
for (a, exp) in args.iter().zip(sig.params.iter()) {
|
||
let actual = synth(a, env, locals, loop_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?;
|
||
unify(exp, &actual, subst)?;
|
||
}
|
||
effects.insert(sig.effect.clone());
|
||
Ok(sig.ret)
|
||
}
|
||
Term::Ctor { type_name, ctor, args } => {
|
||
// Canonical-type-lookup: Term::Ctor lookup is direct post-canonical-type-form
|
||
// Canonical type_name: bare = local TypeDef OR (prep.1) any
|
||
// bare type-name in scope via an imported module's TypeDef;
|
||
// qualified = explicit cross-module. The imports-fallback
|
||
// (iter 23.1.3) is gone — bare cross-module refs that are
|
||
// NOT in scope are rejected upstream by the workspace
|
||
// validator (via the canonical-form validator).
|
||
//
|
||
// prep.1: a bare `type_name` that does not match a local
|
||
// TypeDef falls back to a workspace-wide TypeDef-first
|
||
// lookup (symmetric to the `Term::Var` dot-qualified arm
|
||
// at lib.rs:3189). This makes `(term-ctor Maybe Just x)`
|
||
// resolve when `Maybe` is declared in an imported (explicit
|
||
// or implicit) module — matching the canonical-form rule
|
||
// already accepted by the workspace validator.
|
||
//
|
||
// when the type is cross-module, `cdef.fields` is
|
||
// written in the owning module's local namespace. A recursive
|
||
// self-reference like `Cons a (List a)` carries `Con
|
||
// { name: "List" }` even though, from the consumer's view,
|
||
// the type is `std_list.List`. Apply `qualify_local_types`
|
||
// with the owning module so that recursive ctor field types
|
||
// (and any other locally-named cross-module type-cons) are
|
||
// qualified before substitution / unification.
|
||
let owning_module: Option<String>;
|
||
let result_type_name: String;
|
||
let td = if type_name.matches('.').count() == 1 {
|
||
let (prefix, suffix) = type_name.split_once('.').expect("checked");
|
||
let target_module = match env.imports.get(prefix) {
|
||
Some(m) => m.clone(),
|
||
None => {
|
||
return Err(CheckError::UnknownModule {
|
||
module: prefix.to_string(),
|
||
});
|
||
}
|
||
};
|
||
let td = env.module_types
|
||
.get(&target_module)
|
||
.and_then(|tys| tys.get(suffix))
|
||
.cloned()
|
||
.ok_or_else(|| CheckError::UnknownType(type_name.clone()))?;
|
||
owning_module = Some(target_module);
|
||
result_type_name = type_name.clone();
|
||
td
|
||
} else if let Some(td) = env.types.get(type_name) {
|
||
owning_module = None;
|
||
result_type_name = type_name.clone();
|
||
td.clone()
|
||
} else if let Some((home, td)) = env
|
||
.module_types
|
||
.iter()
|
||
.find_map(|(m, types)| types.get(type_name).map(|td| (m.clone(), td.clone())))
|
||
{
|
||
// prep.1: TypeDef-first cross-module bare resolution.
|
||
// The result-type Con.name is qualified to the home
|
||
// module so it unifies against signatures already
|
||
// carrying the qualified form (e.g. `from_maybe`'s
|
||
// second arg of type `std_maybe.Maybe<a>`).
|
||
result_type_name = format!("{home}.{type_name}");
|
||
owning_module = Some(home);
|
||
td
|
||
} else {
|
||
return Err(CheckError::UnknownType(type_name.clone()));
|
||
};
|
||
let cdef = td
|
||
.ctors
|
||
.iter()
|
||
.find(|c| &c.name == ctor)
|
||
.ok_or_else(|| CheckError::UnknownCtor {
|
||
ty: type_name.clone(),
|
||
ctor: ctor.clone(),
|
||
})?
|
||
.clone();
|
||
if args.len() != cdef.fields.len() {
|
||
return Err(CheckError::CtorArity {
|
||
ty: type_name.clone(),
|
||
ctor: ctor.clone(),
|
||
expected: cdef.fields.len(),
|
||
got: args.len(),
|
||
});
|
||
}
|
||
// qualify local type-cons in the field types when
|
||
// the ctor's owning type is cross-module. No-op when the
|
||
// type is local (owning_module is None).
|
||
let qualified_fields: Vec<Type> = match &owning_module {
|
||
Some(m) => {
|
||
let owner_types = env
|
||
.module_types
|
||
.get(m)
|
||
.cloned()
|
||
.unwrap_or_default();
|
||
cdef.fields
|
||
.iter()
|
||
.map(|f| qualify_local_types(f, m, &owner_types))
|
||
.collect()
|
||
}
|
||
None => cdef.fields.clone(),
|
||
};
|
||
// parameterised ADT — instantiate the type's vars
|
||
// with fresh metavars, substitute them through every ctor
|
||
// field type, and let the field types' metavars be solved by
|
||
// unifying against the actual arg types. The result type
|
||
// carries the same metavars as type-args, so the surrounding
|
||
// context can pin them down.
|
||
let mut mapping: BTreeMap<String, Type> = BTreeMap::new();
|
||
let mut type_args: Vec<Type> = Vec::with_capacity(td.vars.len());
|
||
for v in &td.vars {
|
||
let m = Subst::fresh(counter);
|
||
mapping.insert(v.clone(), m.clone());
|
||
type_args.push(m);
|
||
}
|
||
for (a, exp) in args.iter().zip(qualified_fields.iter()) {
|
||
let exp_inst = substitute_rigids(exp, &mapping);
|
||
let actual = synth(a, env, locals, loop_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?;
|
||
unify(&exp_inst, &actual, subst)?;
|
||
}
|
||
Ok(Type::Con {
|
||
name: result_type_name,
|
||
args: type_args,
|
||
})
|
||
}
|
||
Term::Match { scrutinee, arms } => {
|
||
let s_ty = synth(scrutinee, env, locals, loop_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?;
|
||
if arms.is_empty() {
|
||
return Err(CheckError::NonExhaustive {
|
||
ty: ailang_core::pretty::type_to_string(&s_ty),
|
||
missing: vec!["(no arms)".into()],
|
||
});
|
||
}
|
||
let mut covered_ctors: BTreeSet<String> = BTreeSet::new();
|
||
let mut has_open_arm = false;
|
||
let mut result_ty: Option<Type> = None;
|
||
|
||
for arm in arms {
|
||
let bindings = type_check_pattern(&arm.pat, &s_ty, env)?;
|
||
let mut pushed = Vec::new();
|
||
for (n, t) in &bindings {
|
||
let prev = locals.insert(n.clone(), t.clone());
|
||
pushed.push((n.clone(), prev));
|
||
}
|
||
let body_ty = synth(&arm.body, env, locals, loop_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?;
|
||
for (n, prev) in pushed.into_iter().rev() {
|
||
match prev {
|
||
Some(p) => {
|
||
locals.insert(n, p);
|
||
}
|
||
None => {
|
||
locals.shift_remove(&n);
|
||
}
|
||
}
|
||
}
|
||
|
||
if let Some(rt) = &result_ty {
|
||
unify(rt, &body_ty, subst)?;
|
||
} else {
|
||
result_ty = Some(body_ty);
|
||
}
|
||
|
||
match &arm.pat {
|
||
Pattern::Wild | Pattern::Var { .. } => {
|
||
has_open_arm = true;
|
||
}
|
||
Pattern::Ctor { ctor, .. } => {
|
||
covered_ctors.insert(ctor.clone());
|
||
}
|
||
Pattern::Lit { .. } => {
|
||
// Lit patterns don't structurally cover anything.
|
||
}
|
||
}
|
||
}
|
||
|
||
if !has_open_arm {
|
||
// a qualified scrutinee type (`module.Type`,
|
||
// produced by a cross-module ctor) resolves through
|
||
// `env.module_types`; bare names use `env.types` as
|
||
// before.
|
||
let td_opt: Option<&TypeDef> = match &s_ty {
|
||
Type::Con { name, .. } => {
|
||
if name.matches('.').count() == 1 {
|
||
let (prefix, suffix) = name.split_once('.').expect("checked");
|
||
env.module_types
|
||
.get(prefix)
|
||
.and_then(|tys| tys.get(suffix))
|
||
} else {
|
||
env.types.get(name)
|
||
}
|
||
}
|
||
_ => None,
|
||
};
|
||
match (td_opt, &s_ty) {
|
||
(Some(td), Type::Con { name, .. }) => {
|
||
let missing: Vec<String> = td
|
||
.ctors
|
||
.iter()
|
||
.filter(|c| !covered_ctors.contains(&c.name))
|
||
.map(|c| c.name.clone())
|
||
.collect();
|
||
if !missing.is_empty() {
|
||
return Err(CheckError::NonExhaustive {
|
||
ty: name.clone(),
|
||
missing,
|
||
});
|
||
}
|
||
}
|
||
_ => {
|
||
return Err(CheckError::PrimitiveNeedsWildcard(
|
||
ailang_core::pretty::type_to_string(&s_ty),
|
||
));
|
||
}
|
||
}
|
||
}
|
||
|
||
Ok(subst.apply(&result_ty.expect("checked arms is non-empty")))
|
||
}
|
||
Term::Seq { lhs, rhs } => {
|
||
let lty = synth(lhs, env, locals, loop_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?;
|
||
unify(&Type::unit(), <y, subst)?;
|
||
synth(rhs, env, locals, loop_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)
|
||
}
|
||
Term::Lam { params, param_tys, ret_ty, effects: lam_effects, body } => {
|
||
// reject lambda-captures-of-loop-binder.
|
||
// Walk the lambda body for free Vars (names not bound by
|
||
// the lambda's own params) that match any loop binder on
|
||
// the enclosing `loop_stack`. Loop binders are
|
||
// alloca-resident and `std::mem::take`-emptied at the
|
||
// lambda frame boundary in codegen — capturing one panics
|
||
// `unreachable!()` at codegen, so the type checker rejects
|
||
// it here instead. Reuses `desugar::free_vars_in_term`
|
||
// (same walker `lift.rs` uses) which honours pattern
|
||
// bindings inside `Term::Match` arms; gated on a non-empty
|
||
// `loop_stack` so lambdas outside any loop typecheck
|
||
// unchanged.
|
||
if !loop_stack.is_empty() {
|
||
let mut lam_bound: BTreeSet<String> = BTreeSet::new();
|
||
for p in params {
|
||
lam_bound.insert(p.clone());
|
||
}
|
||
let mut frees: BTreeSet<String> = BTreeSet::new();
|
||
ailang_core::desugar::free_vars_in_term(body, &lam_bound, &mut frees);
|
||
for free_name in &frees {
|
||
for frame in loop_stack.iter() {
|
||
if frame.iter().any(|(n, _)| n == free_name) {
|
||
return Err(CheckError::LoopBinderCapturedByLambda {
|
||
name: free_name.clone(),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
let mut pushed: Vec<(String, Option<Type>)> = Vec::new();
|
||
for (n, t) in params.iter().zip(param_tys.iter()) {
|
||
let prev = locals.insert(n.clone(), t.clone());
|
||
pushed.push((n.clone(), prev));
|
||
}
|
||
let mut body_effects: BTreeSet<String> = BTreeSet::new();
|
||
let body_ty = synth(body, env, locals, loop_stack, &mut body_effects, in_def, subst, counter, residuals, free_fn_calls, warnings);
|
||
for (n, prev) in pushed.into_iter().rev() {
|
||
match prev {
|
||
Some(p) => {
|
||
locals.insert(n, p);
|
||
}
|
||
None => {
|
||
locals.shift_remove(&n);
|
||
}
|
||
}
|
||
}
|
||
let body_ty = body_ty?;
|
||
unify(ret_ty, &body_ty, subst)?;
|
||
let declared: BTreeSet<String> = lam_effects.iter().cloned().collect();
|
||
for e in &body_effects {
|
||
if !declared.contains(e) {
|
||
return Err(CheckError::UndeclaredEffect(e.clone()));
|
||
}
|
||
}
|
||
Ok(Type::Fn {
|
||
params: param_tys.clone(),
|
||
ret: Box::new((**ret_ty).clone()),
|
||
effects: lam_effects.clone(),
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
})
|
||
}
|
||
Term::LetRec { name, ty, params, body, in_term } => {
|
||
// a `Term::LetRec` reaches `synth` only when
|
||
// the desugar pass deferred it (some capture is
|
||
// `Term::Let`-bound and its type is only knowable here).
|
||
// We synthesize it as if it were a recursive fn-typed
|
||
// local binding:
|
||
// 1. Peel any `Forall` defensively (16b.6 still rejects
|
||
// Forall-typed LetRecs at desugar — this is just for
|
||
// shape uniformity with `check_fn`).
|
||
// 2. Validate that `params.len() == ty.params.len()`.
|
||
// 3. Extend `locals` with `name: ty` for the body
|
||
// (recursive self-reference) and each
|
||
// `params[i]: ty.params[i]`.
|
||
// 4. Synth the body, unify against `ty.ret`, check
|
||
// effects-subset against `ty.effects`.
|
||
// 5. Restore locals; extend with `name: ty`; synth
|
||
// `in_term`. Restore. Return `in_term`'s type.
|
||
let inner_ty = match ty {
|
||
Type::Forall { body, .. } => (**body).clone(),
|
||
other => other.clone(),
|
||
};
|
||
let (param_tys, ret_ty, declared_effs) = match &inner_ty {
|
||
Type::Fn { params: ps, ret, effects, .. } => {
|
||
(ps.clone(), (**ret).clone(), effects.clone())
|
||
}
|
||
other => {
|
||
return Err(CheckError::FnTypeRequired(
|
||
name.clone(),
|
||
ailang_core::pretty::type_to_string(other),
|
||
));
|
||
}
|
||
};
|
||
if param_tys.len() != params.len() {
|
||
return Err(CheckError::ParamCountMismatch {
|
||
name: name.clone(),
|
||
ty_count: param_tys.len(),
|
||
param_count: params.len(),
|
||
});
|
||
}
|
||
|
||
// Validate the LetRec's declared param/ret types against
|
||
// the type env (catches malformed types like ADT arity
|
||
// mismatches before they leak into the body).
|
||
for p in ¶m_tys {
|
||
check_type_well_formed(p, env)?;
|
||
}
|
||
check_type_well_formed(&ret_ty, env)?;
|
||
|
||
// Save and extend locals: name + params for the body's scope.
|
||
let mut pushed: Vec<(String, Option<Type>)> = Vec::new();
|
||
let prev_name = locals.insert(name.clone(), ty.clone());
|
||
pushed.push((name.clone(), prev_name));
|
||
for (n, t) in params.iter().zip(param_tys.iter()) {
|
||
let prev = locals.insert(n.clone(), t.clone());
|
||
pushed.push((n.clone(), prev));
|
||
}
|
||
|
||
// Body effects are tracked separately so we can check the
|
||
// subset rule against `declared_effs` — exactly like
|
||
// `Term::Lam`.
|
||
let mut body_effects: BTreeSet<String> = BTreeSet::new();
|
||
let body_ty = synth(body, env, locals, loop_stack, &mut body_effects, in_def, subst, counter, residuals, free_fn_calls, warnings);
|
||
|
||
// Restore body-scope locals (params + name).
|
||
for (n, prev) in pushed.into_iter().rev() {
|
||
match prev {
|
||
Some(p) => {
|
||
locals.insert(n, p);
|
||
}
|
||
None => {
|
||
locals.shift_remove(&n);
|
||
}
|
||
}
|
||
}
|
||
let body_ty = body_ty?;
|
||
unify(&ret_ty, &body_ty, subst)?;
|
||
|
||
let declared: BTreeSet<String> = declared_effs.into_iter().collect();
|
||
for e in &body_effects {
|
||
if !declared.contains(e) {
|
||
return Err(CheckError::UndeclaredEffect(e.clone()));
|
||
}
|
||
}
|
||
|
||
// Now extend locals with `name: ty` for the in-clause's
|
||
// scope (params are not visible here; only the recursive
|
||
// binding is).
|
||
let prev_in = locals.insert(name.clone(), ty.clone());
|
||
let in_ty = synth(in_term, env, locals, loop_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings);
|
||
match prev_in {
|
||
Some(p) => {
|
||
locals.insert(name.clone(), p);
|
||
}
|
||
None => {
|
||
locals.shift_remove(name);
|
||
}
|
||
}
|
||
in_ty
|
||
}
|
||
Term::Clone { value } => {
|
||
// `(clone X)` typechecks identically to `X`.
|
||
// No constraint generated, no environment change. The
|
||
// wrapper records author intent for the future RC inc/dec
|
||
// emission pass (18c.3); typing is pure passthrough.
|
||
synth(value, env, locals, loop_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)
|
||
}
|
||
Term::ReuseAs { source, body } => {
|
||
// `(reuse-as SRC NEW-CTOR)` requires `body` to
|
||
// be an allocating term — `Term::Ctor` or `Term::Lam`. Any
|
||
// other shape is a structural error: the wrapper has
|
||
// nothing to rewrite. The `source` term has no body-shape
|
||
// constraint here — linearity (which runs only on
|
||
// all-explicit-mode fns) enforces "source must be a bare
|
||
// Var referring to an in-scope owned binder". Source-type
|
||
// and body-type need not match — that's a future
|
||
// shape-compatibility check (18d.2 will add a
|
||
// `reuse-as-shape-mismatch` diagnostic when codegen has
|
||
// the actual size info).
|
||
let _ = synth(source, env, locals, loop_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)?;
|
||
match body.as_ref() {
|
||
Term::Ctor { .. } | Term::Lam { .. } => {}
|
||
other => {
|
||
let tag = match other {
|
||
Term::Lit { .. } => "lit",
|
||
Term::Var { .. } => "var",
|
||
Term::App { .. } => "app",
|
||
Term::Let { .. } => "let",
|
||
Term::LetRec { .. } => "let-rec",
|
||
Term::If { .. } => "if",
|
||
Term::Do { .. } => "do",
|
||
Term::Match { .. } => "match",
|
||
Term::Seq { .. } => "seq",
|
||
Term::Clone { .. } => "clone",
|
||
Term::ReuseAs { .. } => "reuse-as",
|
||
Term::Loop { .. } => "loop",
|
||
Term::Recur { .. } => "recur",
|
||
Term::New { .. } => "new",
|
||
Term::Intrinsic => "intrinsic",
|
||
Term::Ctor { .. } | Term::Lam { .. } => unreachable!(),
|
||
};
|
||
return Err(CheckError::ReuseAsNonAllocatingBody {
|
||
got: tag.to_string(),
|
||
body_form_a: ailang_surface::print::term_to_form_a(other),
|
||
});
|
||
}
|
||
}
|
||
// Body's type is the result type of the whole reuse-as
|
||
// expression.
|
||
synth(body, env, locals, loop_stack, effects, in_def, subst, counter, residuals, free_fn_calls, warnings)
|
||
}
|
||
// loop-recur iter 2: each binder init is synthed in the
|
||
// outer scope plus already-declared binder names of THIS
|
||
// loop (declaration order; later inits + body see earlier
|
||
// binders via `locals`, exactly as `Term::Let` binds its
|
||
// name). The loop's static type is the body's type. The
|
||
// ordered binders (name + type) are pushed onto `loop_stack`
|
||
// for the body walk so an inner `Term::Recur` can
|
||
// position-check the types and `Term::Lam`'s escape guard
|
||
// (iter loop-recur.tidy) can reject capture of a binder name;
|
||
// popped on exit.
|
||
Term::Loop { binders, body } => {
|
||
let mut binder_frame: Vec<(String, Type)> = Vec::new();
|
||
let mut restore: Vec<(String, Option<Type>)> = Vec::new();
|
||
for b in binders {
|
||
let init_ty = synth(
|
||
&b.init, env, locals, loop_stack, effects,
|
||
in_def, subst, counter, residuals, free_fn_calls, warnings,
|
||
)?;
|
||
unify(&b.ty, &init_ty, subst)?;
|
||
binder_frame.push((b.name.clone(), b.ty.clone()));
|
||
let prev = locals.insert(b.name.clone(), b.ty.clone());
|
||
restore.push((b.name.clone(), prev));
|
||
}
|
||
loop_stack.push(binder_frame);
|
||
let body_result = synth(
|
||
body, env, locals, loop_stack, effects,
|
||
in_def, subst, counter, residuals, free_fn_calls, warnings,
|
||
);
|
||
loop_stack.pop();
|
||
for (name, prev) in restore.into_iter().rev() {
|
||
match prev {
|
||
Some(p) => {
|
||
locals.insert(name, p);
|
||
}
|
||
None => {
|
||
locals.shift_remove(&name);
|
||
}
|
||
}
|
||
}
|
||
body_result
|
||
}
|
||
// loop-recur iter 2: recur re-enters the innermost enclosing
|
||
// loop, rebinding its binders positionally. RecurOutsideLoop
|
||
// when `loop_stack` is empty; RecurArityMismatch on count
|
||
// disagreement; RecurTypeMismatch via the Assign-style
|
||
// subst-apply-then-structural-compare (Boss call 1) so the
|
||
// dedicated code fires point-exactly. recur's OWN type is a
|
||
// fresh metavar — it transfers control and never falls
|
||
// through, so it unifies with whatever sibling branch the
|
||
// enclosing if/match requires.
|
||
Term::Recur { args } => {
|
||
let binder_frame = match loop_stack.last() {
|
||
None => return Err(CheckError::RecurOutsideLoop),
|
||
Some(frame) => frame.clone(),
|
||
};
|
||
if args.len() != binder_frame.len() {
|
||
return Err(CheckError::RecurArityMismatch {
|
||
expected: binder_frame.len(),
|
||
got: args.len(),
|
||
});
|
||
}
|
||
for (i, a) in args.iter().enumerate() {
|
||
let arg_ty = synth(
|
||
a, env, locals, loop_stack, effects,
|
||
in_def, subst, counter, residuals, free_fn_calls, warnings,
|
||
)?;
|
||
let applied_binder = subst.apply(&binder_frame[i].1);
|
||
let applied_arg = subst.apply(&arg_ty);
|
||
if applied_binder != applied_arg {
|
||
return Err(CheckError::RecurTypeMismatch {
|
||
position: i,
|
||
expected: ailang_core::pretty::type_to_string(&applied_binder),
|
||
got: ailang_core::pretty::type_to_string(&applied_arg),
|
||
});
|
||
}
|
||
}
|
||
Ok(Subst::fresh(counter))
|
||
}
|
||
// prep.2 (kernel-extension-mechanics): functional construction
|
||
// `(new T args...)` calls the `new` def in T's home module.
|
||
// After the prep.1 pre-pass, `type_name` is qualified to
|
||
// `<home>.<TypeName>` whenever T is cross-module; it stays
|
||
// bare when T is local to the current module. Steps:
|
||
// 1. resolve home_module + bare_type from type_name.
|
||
// 2. look up `new` in the home module's globals →
|
||
// NewTypeNotConstructible if absent.
|
||
// 3. peel Forall (if any) → vars + (params, ret, effects).
|
||
// Count Type-positional vs. Value-positional args; the
|
||
// kind-pattern of the args (which positions are Type vs.
|
||
// Value) must match the sig — for each Forall var the
|
||
// corresponding arg slot is Type, for each value-param it
|
||
// is Value. Anything else → NewArgKindMismatch.
|
||
// 4. build the rigid→concrete map from Type-args (in order),
|
||
// substitute through param types and the return type.
|
||
// 5. synth each value-arg and unify against the substituted
|
||
// param type.
|
||
// 6. inherit `new`'s effects; the result type is the
|
||
// substituted ret.
|
||
Term::New { type_name, args } => {
|
||
// Step 1: resolve home module.
|
||
let (home_module, bare_type) = if let Some((m, t)) = type_name.split_once('.') {
|
||
(m.to_string(), t.to_string())
|
||
} else {
|
||
(env.current_module.clone(), type_name.clone())
|
||
};
|
||
// Step 2: look up `new` in home module's globals. Empty
|
||
// module_globals (e.g. when the home is the current
|
||
// module and globals live in env.globals instead) also
|
||
// counts as "no new def for this type" — we don't fall
|
||
// back to a different channel because spec semantics
|
||
// require `new` to be defined in the type's home module.
|
||
let new_ty = env
|
||
.module_globals
|
||
.get(&home_module)
|
||
.and_then(|g| g.get("new"))
|
||
.cloned()
|
||
.ok_or_else(|| CheckError::NewTypeNotConstructible {
|
||
type_name: bare_type.clone(),
|
||
home_module: home_module.clone(),
|
||
})?;
|
||
// Qualify any bare local Type::Cons in `new`'s signature
|
||
// against the home module's TypeDefs — mirrors the
|
||
// module-as-receiver path in Term::Ctor. Skip the
|
||
// qualification when `new` lives in the CURRENT module:
|
||
// the current module sees its own types bare (the
|
||
// workspace-wide qualify pre-pass leaves own-module names
|
||
// alone), so qualifying here would produce a mismatch
|
||
// against own-module declared types at unify time.
|
||
let qualified = if home_module == env.current_module {
|
||
new_ty
|
||
} else {
|
||
let owner_types = env
|
||
.module_types
|
||
.get(&home_module)
|
||
.cloned()
|
||
.unwrap_or_default();
|
||
qualify_local_types(&new_ty, &home_module, &owner_types)
|
||
};
|
||
// Step 3: peel Forall to (vars, params, ret, effects).
|
||
let (vars, param_tys, ret_ty, effects_sig) = match &qualified {
|
||
Type::Forall { vars, body, .. } => match body.as_ref() {
|
||
Type::Fn { params, ret, effects, .. } => (
|
||
vars.clone(),
|
||
params.clone(),
|
||
(**ret).clone(),
|
||
effects.clone(),
|
||
),
|
||
other => {
|
||
return Err(CheckError::FnTypeRequired(
|
||
"new".into(),
|
||
ailang_core::pretty::type_to_string(other),
|
||
));
|
||
}
|
||
},
|
||
Type::Fn { params, ret, effects, .. } => (
|
||
Vec::new(),
|
||
params.clone(),
|
||
(**ret).clone(),
|
||
effects.clone(),
|
||
),
|
||
other => {
|
||
return Err(CheckError::FnTypeRequired(
|
||
"new".into(),
|
||
ailang_core::pretty::type_to_string(other),
|
||
));
|
||
}
|
||
};
|
||
// Validate the arg-kind pattern positionally: the first
|
||
// `vars.len()` args must be NewArg::Type, the remaining
|
||
// `param_tys.len()` args must be NewArg::Value. The first
|
||
// mismatch fires NewArgKindMismatch with `position` and
|
||
// the expected/got kind labels.
|
||
let total_expected = vars.len() + param_tys.len();
|
||
if args.len() != total_expected {
|
||
// Mismatch on overall count maps to a kind-mismatch on
|
||
// the first position where the args run out (or the
|
||
// first extra), with `expected` set to what should
|
||
// have been there.
|
||
let pos = args.len().min(total_expected);
|
||
let expected_kind = if pos < vars.len() {
|
||
"type-arg"
|
||
} else if pos < total_expected {
|
||
"value-arg"
|
||
} else {
|
||
"no arg"
|
||
};
|
||
let got_kind = if pos < args.len() {
|
||
match &args[pos] {
|
||
NewArg::Type(_) => "type-arg",
|
||
NewArg::Value(_) => "value-arg",
|
||
}
|
||
} else {
|
||
"no arg"
|
||
};
|
||
return Err(CheckError::NewArgKindMismatch {
|
||
type_name: bare_type.clone(),
|
||
position: pos,
|
||
expected: expected_kind,
|
||
got: got_kind,
|
||
});
|
||
}
|
||
for (i, arg) in args.iter().enumerate() {
|
||
let expected_is_type = i < vars.len();
|
||
let got_is_type = matches!(arg, NewArg::Type(_));
|
||
if expected_is_type != got_is_type {
|
||
return Err(CheckError::NewArgKindMismatch {
|
||
type_name: bare_type.clone(),
|
||
position: i,
|
||
expected: if expected_is_type { "type-arg" } else { "value-arg" },
|
||
got: if got_is_type { "type-arg" } else { "value-arg" },
|
||
});
|
||
}
|
||
}
|
||
// Step 4: build the Forall-var → concrete-Type mapping
|
||
// from the Type-positional args (in order). The written type
|
||
// is the binding declaration of each result-only forall var
|
||
// (#51): the author's `(con Int)` IS the element type, never
|
||
// inferred from later use.
|
||
let mut mapping: BTreeMap<String, Type> = BTreeMap::new();
|
||
let mut written_type_args: Vec<Type> = Vec::with_capacity(vars.len());
|
||
for (var, arg) in vars.iter().zip(args.iter()) {
|
||
if let NewArg::Type(t) = arg {
|
||
mapping.insert(var.clone(), t.clone());
|
||
written_type_args.push(t.clone());
|
||
}
|
||
// The kind-check loop above guarantees this branch
|
||
// matches; defensive: a Value arg in a Type slot
|
||
// would have raised NewArgKindMismatch already.
|
||
}
|
||
// Steps 4b/4c (#51): only for a polymorphic `new` (the call
|
||
// carries written type-args binding result-only forall vars).
|
||
if !vars.is_empty() {
|
||
// Step 4b: the written element type is subject to the
|
||
// constructed type's `param-in` restricted set. synth is
|
||
// the correct site for this enforcement (unlike
|
||
// `pre_desugar_validation`, which has no registry
|
||
// access): construct the result `Type::Con` with the
|
||
// written args and run it through
|
||
// `check_type_well_formed`, which fires
|
||
// `ParamNotInRestrictedSet` naming the forbidden element
|
||
// (e.g. `Unit`). This is the leg-2 reject; it must run
|
||
// before codegen ever mints a `RawBuf_new__<elem>` symbol.
|
||
let constructed = Type::Con {
|
||
name: type_name.clone(),
|
||
args: written_type_args.clone(),
|
||
};
|
||
check_type_well_formed(&constructed, env)?;
|
||
// Step 4c: push a `FreeFnCall` observation so the mono
|
||
// pass mints the `RawBuf_new__<elem>` specialisation for
|
||
// the written element type — bound DIRECTLY (`?a := Int`),
|
||
// not via an inference channel. The metas carry the
|
||
// written concrete types, so `subst.apply` recovers them
|
||
// verbatim and the unobservable-var Unit-default (which
|
||
// still applies to genuinely-unpinned vars elsewhere) is
|
||
// never reached here. Pushed BEFORE the value-args are
|
||
// synthesised so the observation order matches the mono
|
||
// walker's `Term::New` arm (which consumes this site's
|
||
// slot before descending into the value-args). `new`
|
||
// carries no class constraints, so no residuals are
|
||
// pushed alongside.
|
||
free_fn_calls.push(FreeFnCall {
|
||
name: "new".to_string(),
|
||
owner_module: home_module.clone(),
|
||
forall_vars: vars.clone(),
|
||
metas: written_type_args.clone(),
|
||
scope: Some(bare_type.clone()),
|
||
});
|
||
}
|
||
// Step 5: synth each value-arg, unify against the
|
||
// (substituted) corresponding param type.
|
||
let value_args = &args[vars.len()..];
|
||
for (val_arg, expected_param) in value_args.iter().zip(param_tys.iter()) {
|
||
let v = match val_arg {
|
||
NewArg::Value(v) => v,
|
||
NewArg::Type(_) => unreachable!("kind-check above"),
|
||
};
|
||
let expected_inst = substitute_rigids(expected_param, &mapping);
|
||
let actual = synth(
|
||
v, env, locals, loop_stack, effects, in_def, subst, counter,
|
||
residuals, free_fn_calls, warnings,
|
||
)?;
|
||
unify(&expected_inst, &actual, subst)?;
|
||
}
|
||
// Step 6: inherit `new`'s declared effects, return the
|
||
// substituted result type.
|
||
for e in &effects_sig {
|
||
effects.insert(e.clone());
|
||
}
|
||
Ok(substitute_rigids(&ret_ty, &mapping))
|
||
}
|
||
// An intrinsic body is signature-only: the per-fn body check
|
||
// (`check_fn`) validates the signature from `f.ty` and never
|
||
// calls `synth` on a `Term::Intrinsic`. Reaching here means the
|
||
// skip-guard was bypassed — a checker-internal invariant break.
|
||
Term::Intrinsic => unreachable!(
|
||
"synth reached Term::Intrinsic; an intrinsic body is signature-only and must be \
|
||
skipped by check_fn before synth runs"
|
||
),
|
||
}
|
||
}
|
||
|
||
|
||
/// If `t` is a `Forall`, instantiate it with fresh metavars; otherwise
|
||
/// pass through unchanged. Used at every var-resolution site.
|
||
fn maybe_instantiate(t: Type, counter: &mut u32) -> Type {
|
||
if let Type::Forall { vars, constraints: _, body } = &t {
|
||
let (_, inst) = instantiate(vars, body, counter);
|
||
inst
|
||
} else {
|
||
t
|
||
}
|
||
}
|
||
|
||
/// rewrites bare `Type::Con` references that resolve against
|
||
/// `local_types` into qualified `module.Type` form. Used when pulling a
|
||
/// fn type across module boundaries: a `Maybe a` declared inside
|
||
/// `std_maybe` becomes `std_maybe.Maybe a` when seen from a consumer
|
||
/// module — otherwise it would fail to unify with terms whose types
|
||
/// the consumer module already qualifies.
|
||
///
|
||
/// Already-qualified names, primitives (`Int`, `Bool`, `Unit`, `Str`),
|
||
/// rigid type vars, and type names that are not in `local_types` pass
|
||
/// through unchanged.
|
||
fn qualify_local_types(t: &Type, owner_module: &str, local_types: &IndexMap<String, TypeDef>) -> Type {
|
||
match t {
|
||
Type::Con { name, args } => {
|
||
// The first two branches share the body `name.clone()` but
|
||
// express semantically distinct reasons (already qualified;
|
||
// primitive needs no qualification). Combining them with
|
||
// `||` would obscure why each disqualifies the name.
|
||
#[allow(clippy::if_same_then_else)]
|
||
let qualified_name = if name.contains('.') {
|
||
name.clone()
|
||
} else if ailang_core::primitives::is_primitive_name(name) {
|
||
name.clone()
|
||
} else if local_types.contains_key(name) {
|
||
format!("{owner_module}.{name}")
|
||
} else {
|
||
name.clone()
|
||
};
|
||
Type::Con {
|
||
name: qualified_name,
|
||
args: args
|
||
.iter()
|
||
.map(|a| qualify_local_types(a, owner_module, local_types))
|
||
.collect(),
|
||
}
|
||
}
|
||
Type::Fn { params, ret, effects, .. } => Type::Fn {
|
||
params: params
|
||
.iter()
|
||
.map(|p| qualify_local_types(p, owner_module, local_types))
|
||
.collect(),
|
||
ret: Box::new(qualify_local_types(ret, owner_module, local_types)),
|
||
effects: effects.clone(),
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
},
|
||
Type::Forall { vars, constraints, body } => Type::Forall {
|
||
vars: vars.clone(),
|
||
constraints: constraints
|
||
.iter()
|
||
.map(|c| Constraint {
|
||
class: c.class.clone(),
|
||
type_: qualify_local_types(&c.type_, owner_module, local_types),
|
||
})
|
||
.collect(),
|
||
body: Box::new(qualify_local_types(body, owner_module, local_types)),
|
||
},
|
||
Type::Var { .. } => t.clone(),
|
||
}
|
||
}
|
||
|
||
/// prep.1 (kernel-extension-mechanics): rewrites bare cross-module
|
||
/// `Type::Con` references in a consumer module's declared types into
|
||
/// qualified `<home>.<Type>` form. Symmetric to [`qualify_local_types`]
|
||
/// but operates from the consumer's perspective: a bare type-name
|
||
/// that is not local AND not primitive AND resolves to a `TypeDef`
|
||
/// in some other module is rewritten to the qualified form so that
|
||
/// the consumer's signature unifies with pulled-across-boundary
|
||
/// signatures (which `qualify_local_types` already rewrites bare ->
|
||
/// qualified at the owner's side).
|
||
pub(crate) fn qualify_workspace_types(
|
||
t: &Type,
|
||
own_local_types: &IndexMap<String, TypeDef>,
|
||
module_types: &BTreeMap<String, IndexMap<String, TypeDef>>,
|
||
) -> Type {
|
||
match t {
|
||
Type::Con { name, args } => {
|
||
let qualified_name = if name.contains('.') {
|
||
name.clone()
|
||
} else if ailang_core::primitives::is_primitive_name(name) {
|
||
name.clone()
|
||
} else if own_local_types.contains_key(name) {
|
||
name.clone()
|
||
} else if let Some(home) = module_types
|
||
.iter()
|
||
.find_map(|(m, types)| if types.contains_key(name) { Some(m.clone()) } else { None })
|
||
{
|
||
format!("{home}.{name}")
|
||
} else {
|
||
name.clone()
|
||
};
|
||
Type::Con {
|
||
name: qualified_name,
|
||
args: args
|
||
.iter()
|
||
.map(|a| qualify_workspace_types(a, own_local_types, module_types))
|
||
.collect(),
|
||
}
|
||
}
|
||
Type::Fn { params, ret, effects, param_modes, ret_mode } => Type::Fn {
|
||
params: params
|
||
.iter()
|
||
.map(|p| qualify_workspace_types(p, own_local_types, module_types))
|
||
.collect(),
|
||
ret: Box::new(qualify_workspace_types(ret, own_local_types, module_types)),
|
||
effects: effects.clone(),
|
||
param_modes: param_modes.clone(),
|
||
ret_mode: ret_mode.clone(),
|
||
},
|
||
Type::Forall { vars, constraints, body } => Type::Forall {
|
||
vars: vars.clone(),
|
||
constraints: constraints
|
||
.iter()
|
||
.map(|c| Constraint {
|
||
class: c.class.clone(),
|
||
type_: qualify_workspace_types(&c.type_, own_local_types, module_types),
|
||
})
|
||
.collect(),
|
||
body: Box::new(qualify_workspace_types(body, own_local_types, module_types)),
|
||
},
|
||
Type::Var { .. } => t.clone(),
|
||
}
|
||
}
|
||
|
||
/// prep.1: walk a [`Module`] and rewrite every `Type` annotation
|
||
/// (function signatures, const types, `Term::Lam.param_tys`/`ret_ty`,
|
||
/// `Term::LetRec.ty`) by applying [`qualify_workspace_types`]. Bare
|
||
/// cross-module `Type::Con` names are upgraded to qualified
|
||
/// `<home>.<Type>` form so that consumer-side declared types unify
|
||
/// against imported-fn signatures (which the existing
|
||
/// [`qualify_local_types`] step already qualifies on the owner side).
|
||
pub fn qualify_workspace_module(
|
||
mut m: Module,
|
||
own_local_types: &IndexMap<String, TypeDef>,
|
||
module_types: &BTreeMap<String, IndexMap<String, TypeDef>>,
|
||
module_mono_fns: &BTreeMap<String, BTreeSet<String>>,
|
||
) -> Module {
|
||
for def in &mut m.defs {
|
||
match def {
|
||
Def::Fn(f) => {
|
||
f.ty = qualify_workspace_types(&f.ty, own_local_types, module_types);
|
||
qualify_workspace_term(&mut f.body, own_local_types, module_types, module_mono_fns);
|
||
}
|
||
Def::Const(c) => {
|
||
c.ty = qualify_workspace_types(&c.ty, own_local_types, module_types);
|
||
qualify_workspace_term(&mut c.value, own_local_types, module_types, module_mono_fns);
|
||
}
|
||
Def::Type(td) => {
|
||
// #50: a consumer module's ADT field can reference a
|
||
// *cross-module* (kernel-tier auto-imported) type-con by
|
||
// its bare name (`(con RawBuf …)`). The owner's own type
|
||
// names stay bare — `qualify_workspace_types` skips
|
||
// `own_local_types` and primitives — so only genuinely
|
||
// cross-module field types are upgraded to `<home>.<T>`,
|
||
// matching the qualification the op/value positions and
|
||
// the imported-fn signatures already receive.
|
||
for ctor in &mut td.ctors {
|
||
for field in &mut ctor.fields {
|
||
*field =
|
||
qualify_workspace_types(field, own_local_types, module_types);
|
||
}
|
||
}
|
||
}
|
||
Def::Class(_) | Def::Instance(_) => {
|
||
// Class / Instance defs hold types qualified through
|
||
// other mechanisms.
|
||
}
|
||
}
|
||
}
|
||
m
|
||
}
|
||
|
||
pub(crate) fn qualify_workspace_term(
|
||
t: &mut Term,
|
||
own_local_types: &IndexMap<String, TypeDef>,
|
||
module_types: &BTreeMap<String, IndexMap<String, TypeDef>>,
|
||
module_mono_fns: &BTreeMap<String, BTreeSet<String>>,
|
||
) {
|
||
match t {
|
||
Term::Lit { .. } | Term::Recur { .. } => {}
|
||
Term::Var { name } => {
|
||
// raw-buf.4: normalise a *cross-module* type-scoped call
|
||
// `T.f` to a *monomorphic* op into the module-qualified
|
||
// `<home>.f` form. The Term::New desugar emits
|
||
// `(app T.new …)` for every type; for a cross-module
|
||
// monomorphic `new` (e.g. raw_buf's RawBuf.new) nothing
|
||
// downstream rewrites the `T.new` callee, so codegen would
|
||
// hit `unknown variable`. Three carve-outs:
|
||
// - a same-module type (`own_local_types`) keeps `T.f` —
|
||
// synth's TypeDef-first ladder resolves it directly and
|
||
// rewriting to the bare module self-name breaks the
|
||
// receiver resolution (`type-scoped-receiver-not-a-type`);
|
||
// - a *polymorphic* op keeps `T.f` so the raw-buf.3
|
||
// monomorphisation mechanism mints its scope-qualified
|
||
// `T_f__<elem>` symbol from that spelling (RawBuf's ops);
|
||
// - any non-TypeDef prefix is untouched (ordinary module
|
||
// or class-method dotted names).
|
||
if name.matches('.').count() == 1 {
|
||
let (prefix, suffix) = name.split_once('.').expect("checked");
|
||
if !own_local_types.contains_key(prefix) {
|
||
if let Some(home) = module_types.iter().find_map(|(m, types)| {
|
||
if types.contains_key(prefix) {
|
||
Some(m.clone())
|
||
} else {
|
||
None
|
||
}
|
||
}) {
|
||
let is_mono = module_mono_fns
|
||
.get(&home)
|
||
.is_some_and(|fns| fns.contains(suffix));
|
||
if is_mono {
|
||
*name = format!("{home}.{suffix}");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Term::App { callee, args, .. } => {
|
||
qualify_workspace_term(callee, own_local_types, module_types, module_mono_fns);
|
||
for a in args {
|
||
qualify_workspace_term(a, own_local_types, module_types, module_mono_fns);
|
||
}
|
||
}
|
||
Term::Let { value, body, .. } => {
|
||
qualify_workspace_term(value, own_local_types, module_types, module_mono_fns);
|
||
qualify_workspace_term(body, own_local_types, module_types, module_mono_fns);
|
||
}
|
||
Term::LetRec { ty, body, in_term, .. } => {
|
||
*ty = qualify_workspace_types(ty, own_local_types, module_types);
|
||
qualify_workspace_term(body, own_local_types, module_types, module_mono_fns);
|
||
qualify_workspace_term(in_term, own_local_types, module_types, module_mono_fns);
|
||
}
|
||
Term::If { cond, then, else_ } => {
|
||
qualify_workspace_term(cond, own_local_types, module_types, module_mono_fns);
|
||
qualify_workspace_term(then, own_local_types, module_types, module_mono_fns);
|
||
qualify_workspace_term(else_, own_local_types, module_types, module_mono_fns);
|
||
}
|
||
Term::Do { args, .. } => {
|
||
for a in args {
|
||
qualify_workspace_term(a, own_local_types, module_types, module_mono_fns);
|
||
}
|
||
}
|
||
Term::Ctor { type_name, args, .. } => {
|
||
// prep.1: normalize bare cross-module `type_name` to
|
||
// qualified form, mirroring [`qualify_workspace_types`]
|
||
// for the `Type::Con.name` path.
|
||
if !type_name.contains('.')
|
||
&& !ailang_core::primitives::is_primitive_name(type_name)
|
||
&& !own_local_types.contains_key(type_name)
|
||
{
|
||
if let Some(home) = module_types
|
||
.iter()
|
||
.find_map(|(m, types)| if types.contains_key(type_name) { Some(m.clone()) } else { None })
|
||
{
|
||
*type_name = format!("{home}.{type_name}");
|
||
}
|
||
}
|
||
for a in args {
|
||
qualify_workspace_term(a, own_local_types, module_types, module_mono_fns);
|
||
}
|
||
}
|
||
Term::Match { scrutinee, arms } => {
|
||
qualify_workspace_term(scrutinee, own_local_types, module_types, module_mono_fns);
|
||
for arm in arms {
|
||
qualify_workspace_term(&mut arm.body, own_local_types, module_types, module_mono_fns);
|
||
}
|
||
}
|
||
Term::Lam { param_tys, ret_ty, body, .. } => {
|
||
for p in param_tys.iter_mut() {
|
||
*p = qualify_workspace_types(p, own_local_types, module_types);
|
||
}
|
||
**ret_ty = qualify_workspace_types(ret_ty, own_local_types, module_types);
|
||
qualify_workspace_term(body, own_local_types, module_types, module_mono_fns);
|
||
}
|
||
Term::Seq { lhs, rhs } => {
|
||
qualify_workspace_term(lhs, own_local_types, module_types, module_mono_fns);
|
||
qualify_workspace_term(rhs, own_local_types, module_types, module_mono_fns);
|
||
}
|
||
Term::Clone { value } => {
|
||
qualify_workspace_term(value, own_local_types, module_types, module_mono_fns)
|
||
}
|
||
Term::ReuseAs { source, body } => {
|
||
qualify_workspace_term(source, own_local_types, module_types, module_mono_fns);
|
||
qualify_workspace_term(body, own_local_types, module_types, module_mono_fns);
|
||
}
|
||
Term::Loop { binders, body } => {
|
||
for b in binders {
|
||
b.ty = qualify_workspace_types(&b.ty, own_local_types, module_types);
|
||
qualify_workspace_term(&mut b.init, own_local_types, module_types, module_mono_fns);
|
||
}
|
||
qualify_workspace_term(body, own_local_types, module_types, module_mono_fns);
|
||
}
|
||
// prep.2 (kernel-extension-mechanics): prep.1 pre-pass partner.
|
||
// Mirror the Term::Ctor arm above — normalize a bare
|
||
// cross-module `type_name` to qualified `<home>.<Type>` form
|
||
// before the synth pass elaborates the `new`-call. NewArg::Type
|
||
// values are recursively type-qualified; NewArg::Value subterms
|
||
// are recursively term-qualified.
|
||
Term::New { type_name, args } => {
|
||
if !type_name.contains('.')
|
||
&& !ailang_core::primitives::is_primitive_name(type_name)
|
||
&& !own_local_types.contains_key(type_name)
|
||
{
|
||
if let Some(home) = module_types.iter().find_map(|(m, types)| {
|
||
if types.contains_key(type_name) {
|
||
Some(m.clone())
|
||
} else {
|
||
None
|
||
}
|
||
}) {
|
||
*type_name = format!("{home}.{type_name}");
|
||
}
|
||
}
|
||
for arg in args.iter_mut() {
|
||
match arg {
|
||
NewArg::Value(v) => {
|
||
qualify_workspace_term(v, own_local_types, module_types, module_mono_fns)
|
||
}
|
||
NewArg::Type(t) => {
|
||
*t = qualify_workspace_types(t, own_local_types, module_types)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Term::Intrinsic => {}
|
||
}
|
||
}
|
||
|
||
/// Checks a pattern against an expected type and returns the bindings
|
||
/// introduced by the pattern.
|
||
fn type_check_pattern(
|
||
p: &Pattern,
|
||
expected: &Type,
|
||
env: &Env,
|
||
) -> Result<Vec<(String, Type)>> {
|
||
match p {
|
||
Pattern::Wild => Ok(vec![]),
|
||
Pattern::Var { name } => Ok(vec![(name.clone(), expected.clone())]),
|
||
Pattern::Lit { lit } => {
|
||
let lt = match lit {
|
||
Literal::Int { .. } => Type::int(),
|
||
Literal::Bool { .. } => Type::bool_(),
|
||
Literal::Str { .. } => Type::str_(),
|
||
Literal::Unit => Type::unit(),
|
||
Literal::Float { .. } => {
|
||
return Err(CheckError::FloatPatternNotAllowed);
|
||
}
|
||
};
|
||
expect_eq(expected, <)?;
|
||
Ok(vec![])
|
||
}
|
||
Pattern::Ctor { ctor, fields } => {
|
||
// nested **Ctor** sub-patterns are removed by
|
||
// `ailang_core::desugar::desugar_module` before this checker
|
||
// runs, so we only have to defend against the still-rejected
|
||
// **Lit** sub-pattern case. The `nested-ctor-pattern-not-allowed`
|
||
// error code is reused for that — its meaning is now narrower
|
||
// ("non-flat: a Lit sub-pattern was found"), and the docstring
|
||
// on the variant reflects that.
|
||
for sub in fields {
|
||
match sub {
|
||
Pattern::Var { .. } | Pattern::Wild => {}
|
||
Pattern::Lit { .. } => {
|
||
return Err(CheckError::NestedCtorPatternNotAllowed(ctor.clone()));
|
||
}
|
||
Pattern::Ctor { .. } => {
|
||
unreachable!(
|
||
"nested Ctor sub-patterns are removed by \
|
||
ailang_core::desugar before check"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
// Canonical-type-pattern lookup: Pattern::Ctor lookup is type-driven post-canonical-type-form
|
||
// The scrutinee's Type::Con name is canonical (bare = local
|
||
// TypeDef, qualified = explicit cross-module). Derive the
|
||
// TypeDef from `expected` and find the ctor by name within
|
||
// it; no env.ctor_index consult, no imports-walk.
|
||
let (resolved_type_name, resolved_td, resolved_owning_module, scrutinee_args) =
|
||
match expected {
|
||
Type::Con { name, args } => {
|
||
if let Some((owner, suffix)) = name.split_once('.') {
|
||
let td = env
|
||
.module_types
|
||
.get(owner)
|
||
.and_then(|tys| tys.get(suffix))
|
||
.cloned()
|
||
.ok_or_else(|| {
|
||
CheckError::PatternTypeMismatch {
|
||
ctor: ctor.clone(),
|
||
ty: ailang_core::pretty::type_to_string(expected),
|
||
}
|
||
})?;
|
||
(name.clone(), td, Some(owner.to_string()), args.clone())
|
||
} else {
|
||
let td = env.types.get(name).cloned().ok_or_else(|| {
|
||
CheckError::PatternTypeMismatch {
|
||
ctor: ctor.clone(),
|
||
ty: ailang_core::pretty::type_to_string(expected),
|
||
}
|
||
})?;
|
||
(name.clone(), td, None, args.clone())
|
||
}
|
||
}
|
||
_ => {
|
||
return Err(CheckError::PatternTypeMismatch {
|
||
ctor: ctor.clone(),
|
||
ty: ailang_core::pretty::type_to_string(expected),
|
||
});
|
||
}
|
||
};
|
||
let td = &resolved_td;
|
||
let cdef = td
|
||
.ctors
|
||
.iter()
|
||
.find(|c| &c.name == ctor)
|
||
.ok_or_else(|| CheckError::UnknownCtorInPattern(ctor.clone()))?;
|
||
if fields.len() != cdef.fields.len() {
|
||
return Err(CheckError::CtorArity {
|
||
ty: resolved_type_name.clone(),
|
||
ctor: ctor.clone(),
|
||
expected: cdef.fields.len(),
|
||
got: fields.len(),
|
||
});
|
||
}
|
||
// substitute the scrutinee's concrete type-args
|
||
// into each cdef field type (e.g. `Cons(a, List a)` checked
|
||
// against a `List Int` scrutinee yields field types
|
||
// `Int, List Int`).
|
||
//
|
||
// when the resolved ctor lives in an imported
|
||
// module, qualify any bare local type-cons in the field
|
||
// types first — symmetric to the term-ctor fix.
|
||
let qualified_fields: Vec<Type> = match &resolved_owning_module {
|
||
Some(m) => {
|
||
let owner_types = env
|
||
.module_types
|
||
.get(m)
|
||
.cloned()
|
||
.unwrap_or_default();
|
||
cdef.fields
|
||
.iter()
|
||
.map(|f| qualify_local_types(f, m, &owner_types))
|
||
.collect()
|
||
}
|
||
None => cdef.fields.clone(),
|
||
};
|
||
let mapping: BTreeMap<String, Type> = td
|
||
.vars
|
||
.iter()
|
||
.cloned()
|
||
.zip(scrutinee_args)
|
||
.collect();
|
||
let mut out = Vec::new();
|
||
for (sub, sub_ty) in fields.iter().zip(qualified_fields.iter()) {
|
||
let sub_ty_inst = substitute_rigids(sub_ty, &mapping);
|
||
out.extend(type_check_pattern(sub, &sub_ty_inst, env)?);
|
||
}
|
||
Ok(out)
|
||
}
|
||
}
|
||
}
|
||
|
||
fn callee_name(t: &Term) -> String {
|
||
match t {
|
||
Term::Var { name } => name.clone(),
|
||
_ => "<expr>".into(),
|
||
}
|
||
}
|
||
|
||
fn expect_eq(expected: &Type, got: &Type) -> Result<()> {
|
||
if expected == got {
|
||
Ok(())
|
||
} else {
|
||
Err(CheckError::TypeMismatch {
|
||
expected: ailang_core::pretty::type_to_string(expected),
|
||
got: ailang_core::pretty::type_to_string(got),
|
||
})
|
||
}
|
||
}
|
||
|
||
/// Typechecker environment for a single module-check pass.
|
||
///
|
||
/// Cloned cheaply when we need a scoped extension (e.g. installing
|
||
/// rigid vars while checking a polymorphic def's body) so that the
|
||
/// parent scope stays immutable. Construction goes through
|
||
/// [`Env::default`] / the internal `new`; population is the
|
||
/// responsibility of [`builtins::install`] and the per-module setup in
|
||
/// `check_in_workspace`.
|
||
///
|
||
/// Several of the fields are conceptually disjoint name-spaces (term
|
||
/// vars vs effect ops vs ADT names vs ctor names), kept as separate
|
||
/// maps because the AST already commits to which channel a reference
|
||
/// goes through (`Term::Var` vs `Term::Do` vs `Term::Ctor` vs
|
||
/// `Pattern::Ctor`).
|
||
#[derive(Debug, Default, Clone)]
|
||
pub struct Env {
|
||
/// Term-level names in scope — built-in operators, user fn/const
|
||
/// defs of the current module, and (after pass-1) an entry per
|
||
/// local def. Looked up by `Term::Var { name }` lookup.
|
||
pub globals: IndexMap<String, Type>,
|
||
/// Effect-op table. Looked up by `Term::Do { op }`. See
|
||
/// [`builtins::EffectOpSig`] for the payload shape.
|
||
pub effect_ops: IndexMap<String, builtins::EffectOpSig>,
|
||
/// User-declared ADTs of the current module, by name. Populated
|
||
/// from `Def::Type` entries; consulted by `Term::Ctor`,
|
||
/// `Pattern::Ctor`, and the well-formedness check on declared
|
||
/// types.
|
||
pub types: IndexMap<String, TypeDef>,
|
||
/// Inverse index: ctor name -> reference to the owning ADT.
|
||
pub ctor_index: IndexMap<String, CtorRef>,
|
||
/// Import map: alias-or-module-name → actual module name.
|
||
/// Used when `Term::Var { name }` contains a dot
|
||
/// (qualified cross-module reference).
|
||
pub imports: BTreeMap<String, String>,
|
||
/// Top-level symbol table per module of the workspace.
|
||
/// Populated by [`crate::build_check_env`] from
|
||
/// [`build_module_globals`].
|
||
pub module_globals: BTreeMap<String, IndexMap<String, Type>>,
|
||
/// Per-module import map: module-name → (alias-or-module-name →
|
||
/// actual-module-name). Sibling-shape to [`Self::module_globals`];
|
||
/// populated workspace-flat by [`crate::build_check_env`] so the
|
||
/// mono pass's per-fn entry points can re-seed the local
|
||
/// [`Self::imports`] before re-running `synth` on a body. Both
|
||
/// `check_in_workspace` and `mono::build_workspace_env` consume
|
||
/// the same env shape; `check_in_workspace` then overwrites
|
||
/// [`Self::imports`] with the current module's map at the call
|
||
/// site.
|
||
pub module_imports: BTreeMap<String, BTreeMap<String, String>>,
|
||
/// ADT type definitions per module of the workspace. Used
|
||
/// to resolve qualified type references (`module.Type` in
|
||
/// `Type::Con.name`) and qualified `Term::Ctor.type_name`, and to
|
||
/// fall back when a bare `Pattern::Ctor.ctor` cannot be resolved
|
||
/// against the local module. Populated by `check_in_workspace`.
|
||
pub module_types: BTreeMap<String, IndexMap<String, TypeDef>>,
|
||
/// Name of the currently checked module. Used during var lookup to
|
||
/// treat self-references (module name == own name) as local globals,
|
||
/// without touching the `imports` channel.
|
||
pub current_module: String,
|
||
/// `true` when the currently checked module is kernel-tier
|
||
/// (`module.kernel`) or the prelude. An `(intrinsic)` body is legal
|
||
/// only here; the per-fn body check reads this to gate
|
||
/// `IntrinsicOutsideKernelTier`. Set alongside `current_module`.
|
||
pub current_module_kernel_tier: bool,
|
||
/// Rigid type vars in scope (Iter 12a). Populated by `check_fn` when
|
||
/// it peels an outer `Forall` from the def's type. Inside the body,
|
||
/// these names are legal as `Type::Var { name }` and unify only
|
||
/// with themselves.
|
||
pub rigid_vars: BTreeSet<String>,
|
||
/// workspace-flat aggregate of every module's
|
||
/// [`ModuleGlobals::class_methods`], re-keyed to
|
||
/// `(qualified-class-name, method-name)` so post-retirement of
|
||
/// `MethodNameCollision` two classes can share a method name.
|
||
/// Consumed by `synth`'s `Term::Var` arm via the resolved class
|
||
/// (from `method_to_candidate_classes` + dispatch) plus the method
|
||
/// name. Mono's presence checks consult
|
||
/// [`Self::method_to_candidate_classes`] (method-keyed natively).
|
||
pub class_methods: BTreeMap<(String, String), ClassMethodEntry>,
|
||
/// workspace-flat inverse of [`Self::class_methods`] — for
|
||
/// each method name, the set of qualified class names that declare
|
||
/// it. Pre-canonical-class-form, the `MethodNameCollision` invariant guarantees
|
||
/// each set is singleton; the type-driven dispatch refactor lifts the invariant and any
|
||
/// cardinality above one becomes legal. Synth's `Term::Var` arm
|
||
/// consults this index for type-driven dispatch (spec
|
||
/// §Architecture's 5-step rule).
|
||
pub method_to_candidate_classes: BTreeMap<String, BTreeSet<String>>,
|
||
/// post-superclass-expansion declared constraints of the
|
||
/// active fn body being checked. Populated by `check_fn` before
|
||
/// invoking `synth` on the body; empty at workspace entry.
|
||
/// Consumed by `synth`'s Var-arm class-method branch when calling
|
||
/// `resolve_method_dispatch` — the constraint-driven filter
|
||
/// disambiguates multi-candidate residuals at the rigid-var
|
||
/// fallback (e.g. inside a polymorphic fn body where the arg
|
||
/// type is still a rigid type variable).
|
||
pub active_declared_constraints: Vec<Constraint>,
|
||
/// one-step superclass expansion table.
|
||
/// Maps each class name to its superclass class name. Absence from
|
||
/// the map means the class has no superclass — populated only for
|
||
/// classes that declare one. Used by `check_fn` to expand
|
||
/// `Forall.constraints` for the missing-constraint check (the
|
||
/// typeclass design caps superclass expansion at one step). The
|
||
/// schema permits at most one
|
||
/// superclass per class.
|
||
pub class_superclasses: BTreeMap<String, String>,
|
||
/// workspace-wide instance registry, threaded
|
||
/// through from [`Workspace::registry`]. Read by `check_fn` in the
|
||
/// concrete-residual path: when a class-method call at a fully-
|
||
/// concrete type produces a residual `(class, type_)` whose
|
||
/// `(class, type_hash(type_))` key is absent from `registry.entries`,
|
||
/// the fn fires `NoInstance`. Default-constructed (empty) for
|
||
/// the standalone [`check_module`] entry point — pre-22b fixtures
|
||
/// never invoke class methods, so the empty registry is harmless.
|
||
pub workspace_registry: ailang_core::workspace::Registry,
|
||
}
|
||
|
||
/// Back-pointer from a ctor name to the ADT that declared it. Stored
|
||
/// in [`Env::ctor_index`] so a `Pattern::Ctor` or `Term::Ctor` can
|
||
/// resolve the owning ADT in O(1).
|
||
#[derive(Debug, Clone)]
|
||
pub struct CtorRef {
|
||
/// Name of the ADT (i.e. the `Def::Type` name) that declared the
|
||
/// ctor.
|
||
pub type_name: String,
|
||
}
|
||
|
||
impl Env {
|
||
pub(crate) fn new() -> Self {
|
||
Self::default()
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use ailang_core::SCHEMA;
|
||
|
||
/// Helper that synths a single Term against a default empty env +
|
||
/// fresh local / scope / effect state. Returns the substituted
|
||
/// final type.
|
||
fn synth_standalone(term: &Term) -> Type {
|
||
let env = Env::default();
|
||
let mut locals: IndexMap<String, Type> = IndexMap::new();
|
||
// loop-recur iter 2: loop-free unit-test call site — fresh
|
||
// empty loop-stack.
|
||
let mut loop_stack: Vec<Vec<(String, Type)>> = Vec::new();
|
||
let mut effects: BTreeSet<String> = BTreeSet::new();
|
||
let mut subst = Subst::default();
|
||
let mut counter: u32 = 0;
|
||
let mut residuals: Vec<ResidualConstraint> = Vec::new();
|
||
let mut free_fn_calls: Vec<FreeFnCall> = Vec::new();
|
||
let mut warnings: Vec<Diagnostic> = Vec::new();
|
||
let ty = synth(
|
||
term,
|
||
&env,
|
||
&mut locals,
|
||
&mut loop_stack,
|
||
&mut effects,
|
||
"<test>",
|
||
&mut subst,
|
||
&mut counter,
|
||
&mut residuals,
|
||
&mut free_fn_calls,
|
||
&mut warnings,
|
||
)
|
||
.expect("synth must succeed");
|
||
subst.apply(&ty)
|
||
}
|
||
|
||
/// `CheckError::LoopBinderCapturedByLambda`
|
||
/// carries the stable kebab code `loop-binder-captured-by-lambda`
|
||
/// and a non-panicking `ctx()` arm carrying the captured name —
|
||
/// symmetric to `mut_var_captured_by_lambda_diagnostic_has_kebab_code`.
|
||
#[test]
|
||
fn loop_binder_captured_by_lambda_diagnostic_has_kebab_code() {
|
||
let e = CheckError::LoopBinderCapturedByLambda { name: "acc".into() };
|
||
assert_eq!(e.code(), "loop-binder-captured-by-lambda");
|
||
let _ = e.ctx();
|
||
}
|
||
|
||
/// loop-recur iter 2: the four `Recur*` `CheckError` variants carry
|
||
/// the exact stable kebab codes the spec acceptance mandates
|
||
/// (`recur-outside-loop` / `recur-arity-mismatch` /
|
||
/// `recur-type-mismatch` / `recur-not-in-tail-position`).
|
||
#[test]
|
||
fn recur_checkerror_codes_are_exact() {
|
||
assert_eq!(CheckError::RecurOutsideLoop.code(), "recur-outside-loop");
|
||
assert_eq!(
|
||
CheckError::RecurArityMismatch { expected: 2, got: 1 }.code(),
|
||
"recur-arity-mismatch"
|
||
);
|
||
assert_eq!(
|
||
CheckError::RecurTypeMismatch {
|
||
position: 0,
|
||
expected: "Int".into(),
|
||
got: "Bool".into(),
|
||
}
|
||
.code(),
|
||
"recur-type-mismatch"
|
||
);
|
||
assert_eq!(
|
||
CheckError::RecurNotInTailPosition.code(),
|
||
"recur-not-in-tail-position"
|
||
);
|
||
}
|
||
|
||
/// Regression guard — a lambda built without any enclosing loop
|
||
/// typechecks unchanged. Property protected: the free-var scan in
|
||
/// `Term::Lam`'s synth arm is gated on `!loop_stack.is_empty()`
|
||
/// and must not disturb lambdas outside loops. The body refers to
|
||
/// the lambda's own param (no builtin lookup needed) so the test
|
||
/// stays pure in `Env::default()`.
|
||
#[test]
|
||
fn lambda_outside_loop_still_typechecks_clean() {
|
||
let term = Term::Lam {
|
||
params: vec!["x".into()],
|
||
param_tys: vec![Type::int()],
|
||
ret_ty: Box::new(Type::int()),
|
||
effects: vec![],
|
||
body: Box::new(Term::Var { name: "x".into() }),
|
||
};
|
||
let _ty = synth_standalone(&term);
|
||
}
|
||
|
||
/// a lambda nested inside a `Term::Loop`
|
||
/// whose body references a loop binder declared by the enclosing
|
||
/// loop is rejected with `LoopBinderCapturedByLambda`. The
|
||
/// captured-name field reports the offending binder.
|
||
#[test]
|
||
fn lambda_inside_loop_capturing_binder_emits_loop_binder_captured_by_lambda() {
|
||
// (loop (acc : Int = 0)
|
||
// (let f = (lam () : Int -> acc) in 0))
|
||
let term = Term::Loop {
|
||
binders: vec![ailang_core::ast::LoopBinder {
|
||
name: "acc".into(),
|
||
ty: Type::int(),
|
||
init: Term::Lit { lit: Literal::Int { value: 0 } },
|
||
}],
|
||
body: Box::new(Term::Let {
|
||
name: "f".into(),
|
||
value: Box::new(Term::Lam {
|
||
params: vec![],
|
||
param_tys: vec![],
|
||
ret_ty: Box::new(Type::int()),
|
||
effects: vec![],
|
||
body: Box::new(Term::Var { name: "acc".into() }),
|
||
}),
|
||
body: Box::new(Term::Lit { lit: Literal::Int { value: 0 } }),
|
||
}),
|
||
};
|
||
let env = Env::default();
|
||
let mut locals: IndexMap<String, Type> = IndexMap::new();
|
||
let mut loop_stack: Vec<Vec<(String, Type)>> = Vec::new();
|
||
let mut effects: BTreeSet<String> = BTreeSet::new();
|
||
let mut subst = Subst::default();
|
||
let mut counter: u32 = 0;
|
||
let mut residuals: Vec<ResidualConstraint> = Vec::new();
|
||
let mut free_fn_calls: Vec<FreeFnCall> = Vec::new();
|
||
let mut warnings: Vec<Diagnostic> = Vec::new();
|
||
let err = synth(
|
||
&term, &env, &mut locals, &mut loop_stack, &mut effects,
|
||
"<test>", &mut subst, &mut counter, &mut residuals,
|
||
&mut free_fn_calls, &mut warnings,
|
||
)
|
||
.expect_err("lambda capturing loop binder must be rejected");
|
||
match err {
|
||
CheckError::LoopBinderCapturedByLambda { name } => assert_eq!(name, "acc"),
|
||
other => panic!("expected LoopBinderCapturedByLambda, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
fn fn_def(name: &str, ty: Type, params: Vec<&str>, body: Term) -> Def {
|
||
Def::Fn(FnDef {
|
||
name: name.into(),
|
||
ty,
|
||
params: params.into_iter().map(|s| s.into()).collect(),
|
||
body,
|
||
suppress: vec![],
|
||
doc: None,
|
||
export: None,
|
||
})
|
||
}
|
||
|
||
#[test]
|
||
fn checks_simple_arithmetic_fn() {
|
||
let m = Module {
|
||
schema: SCHEMA.into(),
|
||
name: "t".into(),
|
||
kernel: false,
|
||
imports: vec![],
|
||
defs: vec![fn_def(
|
||
"add",
|
||
Type::Fn {
|
||
params: vec![Type::int(), Type::int()],
|
||
ret: Box::new(Type::int()),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
},
|
||
vec!["a", "b"],
|
||
Term::App {
|
||
callee: Box::new(Term::Var { name: "+".into() }),
|
||
args: vec![
|
||
Term::Var { name: "a".into() },
|
||
Term::Var { name: "b".into() },
|
||
],
|
||
tail: false,
|
||
},
|
||
)],
|
||
};
|
||
check(&m).expect("should typecheck");
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_type_mismatch() {
|
||
let m = Module {
|
||
schema: SCHEMA.into(),
|
||
name: "t".into(),
|
||
kernel: false,
|
||
imports: vec![],
|
||
defs: vec![fn_def(
|
||
"bad",
|
||
Type::Fn {
|
||
params: vec![],
|
||
ret: Box::new(Type::int()),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
},
|
||
vec![],
|
||
Term::Lit {
|
||
lit: Literal::Bool { value: true },
|
||
},
|
||
)],
|
||
};
|
||
let err = check(&m).unwrap_err();
|
||
let msg = format!("{err}");
|
||
assert!(msg.contains("type mismatch"), "got: {msg}");
|
||
}
|
||
|
||
#[test]
|
||
fn requires_effect_to_be_declared() {
|
||
let m = Module {
|
||
schema: SCHEMA.into(),
|
||
name: "t".into(),
|
||
kernel: false,
|
||
imports: vec![],
|
||
defs: vec![fn_def(
|
||
"leaks",
|
||
Type::Fn {
|
||
params: vec![],
|
||
ret: Box::new(Type::unit()),
|
||
effects: vec![], // !IO missing,
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
},
|
||
vec![],
|
||
Term::Do {
|
||
op: "io/print_str".into(),
|
||
args: vec![Term::Lit {
|
||
lit: Literal::Str {
|
||
value: "hi".into(),
|
||
},
|
||
}],
|
||
tail: false,
|
||
},
|
||
)],
|
||
};
|
||
let err = check(&m).unwrap_err();
|
||
let msg = format!("{err}");
|
||
assert!(msg.contains("undeclared effect"), "got: {msg}");
|
||
}
|
||
|
||
#[test]
|
||
fn lets_local_shadow_global() {
|
||
let m = Module {
|
||
schema: SCHEMA.into(),
|
||
name: "t".into(),
|
||
kernel: false,
|
||
imports: vec![],
|
||
defs: vec![fn_def(
|
||
"f",
|
||
Type::Fn {
|
||
params: vec![],
|
||
ret: Box::new(Type::int()),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
},
|
||
vec![],
|
||
Term::Let {
|
||
name: "x".into(),
|
||
value: Box::new(Term::Lit {
|
||
lit: Literal::Int { value: 7 },
|
||
}),
|
||
body: Box::new(Term::Var { name: "x".into() }),
|
||
},
|
||
)],
|
||
};
|
||
check(&m).expect("should typecheck");
|
||
}
|
||
|
||
#[test]
|
||
fn match_must_be_exhaustive() {
|
||
// Type Maybe = None | Some(Int); fn f only matches None -> error.
|
||
let m = Module {
|
||
schema: SCHEMA.into(),
|
||
name: "t".into(),
|
||
kernel: false,
|
||
imports: vec![],
|
||
defs: vec![
|
||
Def::Type(TypeDef {
|
||
name: "Maybe".into(),
|
||
vars: vec![],
|
||
ctors: vec![
|
||
Ctor { name: "None".into(), fields: vec![] },
|
||
Ctor {
|
||
name: "Some".into(),
|
||
fields: vec![Type::int()],
|
||
},
|
||
],
|
||
doc: None,
|
||
drop_iterative: false,
|
||
param_in: BTreeMap::new(),
|
||
}),
|
||
fn_def(
|
||
"f",
|
||
Type::Fn {
|
||
params: vec![Type::Con { name: "Maybe".into(), args: vec![] }],
|
||
ret: Box::new(Type::int()),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
},
|
||
vec!["m"],
|
||
Term::Match {
|
||
scrutinee: Box::new(Term::Var { name: "m".into() }),
|
||
arms: vec![Arm {
|
||
pat: Pattern::Ctor {
|
||
ctor: "None".into(),
|
||
fields: vec![],
|
||
},
|
||
body: Term::Lit {
|
||
lit: Literal::Int { value: 0 },
|
||
},
|
||
}],
|
||
},
|
||
),
|
||
],
|
||
};
|
||
let err = check(&m).unwrap_err();
|
||
let msg = format!("{err}");
|
||
assert!(msg.contains("non-exhaustive"), "got: {msg}");
|
||
assert!(msg.contains("Some"), "got: {msg}");
|
||
}
|
||
|
||
#[test]
|
||
fn match_with_wildcard_is_exhaustive() {
|
||
let m = Module {
|
||
schema: SCHEMA.into(),
|
||
name: "t".into(),
|
||
kernel: false,
|
||
imports: vec![],
|
||
defs: vec![
|
||
Def::Type(TypeDef {
|
||
name: "Maybe".into(),
|
||
vars: vec![],
|
||
ctors: vec![
|
||
Ctor { name: "None".into(), fields: vec![] },
|
||
Ctor {
|
||
name: "Some".into(),
|
||
fields: vec![Type::int()],
|
||
},
|
||
],
|
||
doc: None,
|
||
drop_iterative: false,
|
||
param_in: BTreeMap::new(),
|
||
}),
|
||
fn_def(
|
||
"f",
|
||
Type::Fn {
|
||
params: vec![Type::Con { name: "Maybe".into(), args: vec![] }],
|
||
ret: Box::new(Type::int()),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
},
|
||
vec!["m"],
|
||
Term::Match {
|
||
scrutinee: Box::new(Term::Var { name: "m".into() }),
|
||
arms: vec![
|
||
Arm {
|
||
pat: Pattern::Ctor {
|
||
ctor: "None".into(),
|
||
fields: vec![],
|
||
},
|
||
body: Term::Lit {
|
||
lit: Literal::Int { value: 0 },
|
||
},
|
||
},
|
||
Arm {
|
||
pat: Pattern::Wild,
|
||
body: Term::Lit {
|
||
lit: Literal::Int { value: 1 },
|
||
},
|
||
},
|
||
],
|
||
},
|
||
),
|
||
],
|
||
};
|
||
check(&m).expect("wildcard must satisfy exhaustiveness");
|
||
}
|
||
|
||
#[test]
|
||
fn if_branches_must_match() {
|
||
let m = Module {
|
||
schema: SCHEMA.into(),
|
||
name: "t".into(),
|
||
kernel: false,
|
||
imports: vec![],
|
||
defs: vec![fn_def(
|
||
"f",
|
||
Type::Fn {
|
||
params: vec![],
|
||
ret: Box::new(Type::int()),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
},
|
||
vec![],
|
||
Term::If {
|
||
cond: Box::new(Term::Lit {
|
||
lit: Literal::Bool { value: true },
|
||
}),
|
||
then: Box::new(Term::Lit {
|
||
lit: Literal::Int { value: 1 },
|
||
}),
|
||
else_: Box::new(Term::Lit { lit: Literal::Unit }),
|
||
},
|
||
)],
|
||
};
|
||
let err = check(&m).unwrap_err();
|
||
assert!(format!("{err}").contains("type mismatch"));
|
||
}
|
||
|
||
/// a top-level def annotated `forall a. (a) -> a` is
|
||
/// admitted; its body checks against a rigid type var. The
|
||
/// var name (`a`) is in scope as a rigid throughout the body.
|
||
#[test]
|
||
fn polymorphic_id_def_typechecks() {
|
||
let m = Module {
|
||
schema: SCHEMA.into(),
|
||
name: "t".into(),
|
||
kernel: false,
|
||
imports: vec![],
|
||
defs: vec![fn_def(
|
||
"id",
|
||
Type::Forall {
|
||
vars: vec!["a".into()],
|
||
constraints: vec![],
|
||
body: Box::new(Type::Fn {
|
||
params: vec![Type::Var { name: "a".into() }],
|
||
ret: Box::new(Type::Var { name: "a".into() }),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
}),
|
||
},
|
||
vec!["x"],
|
||
Term::Var { name: "x".into() },
|
||
)],
|
||
};
|
||
check(&m).expect("polymorphic id should typecheck");
|
||
}
|
||
|
||
/// `id` instantiates fresh metavars at each use site.
|
||
/// Calling `id(42)` produces an `Int`; calling `id(true)` would
|
||
/// produce a `Bool`. Two distinct uses must not bleed into each
|
||
/// other (each gets its own metavar).
|
||
#[test]
|
||
fn polymorphic_id_can_be_used_at_int_and_bool() {
|
||
let id_def = fn_def(
|
||
"id",
|
||
Type::Forall {
|
||
vars: vec!["a".into()],
|
||
constraints: vec![],
|
||
body: Box::new(Type::Fn {
|
||
params: vec![Type::Var { name: "a".into() }],
|
||
ret: Box::new(Type::Var { name: "a".into() }),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
}),
|
||
},
|
||
vec!["x"],
|
||
Term::Var { name: "x".into() },
|
||
);
|
||
// `use_int` returns id(42) :: Int.
|
||
let use_int = fn_def(
|
||
"use_int",
|
||
Type::Fn {
|
||
params: vec![],
|
||
ret: Box::new(Type::int()),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
},
|
||
vec![],
|
||
Term::App {
|
||
callee: Box::new(Term::Var { name: "id".into() }),
|
||
args: vec![Term::Lit { lit: Literal::Int { value: 42 } }],
|
||
tail: false,
|
||
},
|
||
);
|
||
// `use_bool` returns id(true) :: Bool.
|
||
let use_bool = fn_def(
|
||
"use_bool",
|
||
Type::Fn {
|
||
params: vec![],
|
||
ret: Box::new(Type::bool_()),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
},
|
||
vec![],
|
||
Term::App {
|
||
callee: Box::new(Term::Var { name: "id".into() }),
|
||
args: vec![Term::Lit { lit: Literal::Bool { value: true } }],
|
||
tail: false,
|
||
},
|
||
);
|
||
let m = Module {
|
||
schema: SCHEMA.into(),
|
||
name: "t".into(),
|
||
kernel: false,
|
||
imports: vec![],
|
||
defs: vec![id_def, use_int, use_bool],
|
||
};
|
||
check(&m).expect("two distinct id instantiations should typecheck");
|
||
}
|
||
|
||
/// a `Forall` callee at App must instantiate consistently.
|
||
/// `id(42) :: Bool` is rejected because the metavar gets pinned to
|
||
/// `Int` by the arg, which conflicts with the declared `Bool` ret.
|
||
#[test]
|
||
fn polymorphic_id_consistency_is_enforced() {
|
||
let id_def = fn_def(
|
||
"id",
|
||
Type::Forall {
|
||
vars: vec!["a".into()],
|
||
constraints: vec![],
|
||
body: Box::new(Type::Fn {
|
||
params: vec![Type::Var { name: "a".into() }],
|
||
ret: Box::new(Type::Var { name: "a".into() }),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
}),
|
||
},
|
||
vec!["x"],
|
||
Term::Var { name: "x".into() },
|
||
);
|
||
// Returns id(42) but declared Bool — must fail.
|
||
let bad = fn_def(
|
||
"bad",
|
||
Type::Fn {
|
||
params: vec![],
|
||
ret: Box::new(Type::bool_()),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
},
|
||
vec![],
|
||
Term::App {
|
||
callee: Box::new(Term::Var { name: "id".into() }),
|
||
args: vec![Term::Lit { lit: Literal::Int { value: 42 } }],
|
||
tail: false,
|
||
},
|
||
);
|
||
let m = Module {
|
||
schema: SCHEMA.into(),
|
||
name: "t".into(),
|
||
kernel: false,
|
||
imports: vec![],
|
||
defs: vec![id_def, bad],
|
||
};
|
||
let err = check(&m).unwrap_err();
|
||
let msg = format!("{err}");
|
||
assert!(msg.contains("type mismatch"), "got: {msg}");
|
||
}
|
||
|
||
/// two-var polymorphism. `apply : forall a b. ((a -> b),
|
||
/// a) -> b`. Body applies the function. We instantiate at two
|
||
/// different use sites with different (a, b) pairs.
|
||
#[test]
|
||
fn polymorphic_apply_with_two_vars() {
|
||
let apply_def = fn_def(
|
||
"apply",
|
||
Type::Forall {
|
||
vars: vec!["a".into(), "b".into()],
|
||
constraints: vec![],
|
||
body: Box::new(Type::Fn {
|
||
params: vec![
|
||
Type::Fn {
|
||
params: vec![Type::Var { name: "a".into() }],
|
||
ret: Box::new(Type::Var { name: "b".into() }),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
},
|
||
Type::Var { name: "a".into() },
|
||
],
|
||
ret: Box::new(Type::Var { name: "b".into() }),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
}),
|
||
},
|
||
vec!["f", "x"],
|
||
Term::App {
|
||
callee: Box::new(Term::Var { name: "f".into() }),
|
||
args: vec![Term::Var { name: "x".into() }],
|
||
tail: false,
|
||
},
|
||
);
|
||
// A monomorphic helper to be passed to apply.
|
||
let succ = fn_def(
|
||
"succ",
|
||
Type::Fn {
|
||
params: vec![Type::int()],
|
||
ret: Box::new(Type::int()),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
},
|
||
vec!["n"],
|
||
Term::App {
|
||
callee: Box::new(Term::Var { name: "+".into() }),
|
||
args: vec![
|
||
Term::Var { name: "n".into() },
|
||
Term::Lit { lit: Literal::Int { value: 1 } },
|
||
],
|
||
tail: false,
|
||
},
|
||
);
|
||
let use_apply = fn_def(
|
||
"use_apply",
|
||
Type::Fn {
|
||
params: vec![],
|
||
ret: Box::new(Type::int()),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
},
|
||
vec![],
|
||
Term::App {
|
||
callee: Box::new(Term::Var { name: "apply".into() }),
|
||
args: vec![
|
||
Term::Var { name: "succ".into() },
|
||
Term::Lit { lit: Literal::Int { value: 41 } },
|
||
],
|
||
tail: false,
|
||
},
|
||
);
|
||
let m = Module {
|
||
schema: SCHEMA.into(),
|
||
name: "t".into(),
|
||
kernel: false,
|
||
imports: vec![],
|
||
defs: vec![apply_def, succ, use_apply],
|
||
};
|
||
check(&m).expect("apply instantiation should typecheck");
|
||
}
|
||
|
||
/// parameterised ADT — `type Box[a] = MkBox(a)` is well-
|
||
/// formed and `MkBox(42)` typechecks at result type `Box<Int>`.
|
||
#[test]
|
||
fn parameterised_adt_ctor_at_int() {
|
||
let box_def = Def::Type(TypeDef {
|
||
name: "Box".into(),
|
||
vars: vec!["a".into()],
|
||
ctors: vec![Ctor {
|
||
name: "MkBox".into(),
|
||
fields: vec![Type::Var { name: "a".into() }],
|
||
}],
|
||
doc: None,
|
||
drop_iterative: false,
|
||
param_in: BTreeMap::new(),
|
||
});
|
||
// fn make :: () -> Box<Int> = MkBox(42)
|
||
let make = fn_def(
|
||
"make",
|
||
Type::Fn {
|
||
params: vec![],
|
||
ret: Box::new(Type::Con {
|
||
name: "Box".into(),
|
||
args: vec![Type::int()],
|
||
}),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
},
|
||
vec![],
|
||
Term::Ctor {
|
||
type_name: "Box".into(),
|
||
ctor: "MkBox".into(),
|
||
args: vec![Term::Lit { lit: Literal::Int { value: 42 } }],
|
||
},
|
||
);
|
||
let m = Module {
|
||
schema: SCHEMA.into(),
|
||
name: "t".into(),
|
||
kernel: false,
|
||
imports: vec![],
|
||
defs: vec![box_def, make],
|
||
};
|
||
check(&m).expect("Box<Int> ctor should typecheck");
|
||
}
|
||
|
||
/// ctor field type substitution and match-arm bindings.
|
||
/// `unbox :: forall a. (Box<a>) -> a` extracts the wrapped value.
|
||
#[test]
|
||
fn parameterised_adt_polymorphic_unbox() {
|
||
let box_def = Def::Type(TypeDef {
|
||
name: "Box".into(),
|
||
vars: vec!["a".into()],
|
||
ctors: vec![Ctor {
|
||
name: "MkBox".into(),
|
||
fields: vec![Type::Var { name: "a".into() }],
|
||
}],
|
||
doc: None,
|
||
drop_iterative: false,
|
||
param_in: BTreeMap::new(),
|
||
});
|
||
let unbox = Def::Fn(FnDef {
|
||
name: "unbox".into(),
|
||
ty: Type::Forall {
|
||
vars: vec!["a".into()],
|
||
constraints: vec![],
|
||
body: Box::new(Type::Fn {
|
||
params: vec![Type::Con {
|
||
name: "Box".into(),
|
||
args: vec![Type::Var { name: "a".into() }],
|
||
}],
|
||
ret: Box::new(Type::Var { name: "a".into() }),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
}),
|
||
},
|
||
params: vec!["b".into()],
|
||
body: Term::Match {
|
||
scrutinee: Box::new(Term::Var { name: "b".into() }),
|
||
arms: vec![Arm {
|
||
pat: Pattern::Ctor {
|
||
ctor: "MkBox".into(),
|
||
fields: vec![Pattern::Var { name: "x".into() }],
|
||
},
|
||
body: Term::Var { name: "x".into() },
|
||
}],
|
||
},
|
||
suppress: vec![],
|
||
doc: None,
|
||
export: None,
|
||
});
|
||
// Use unbox at Int and at Bool — both must succeed and not
|
||
// cross-contaminate the polymorphic variable.
|
||
let use_int = fn_def(
|
||
"ui",
|
||
Type::Fn {
|
||
params: vec![],
|
||
ret: Box::new(Type::int()),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
},
|
||
vec![],
|
||
Term::App {
|
||
callee: Box::new(Term::Var { name: "unbox".into() }),
|
||
args: vec![Term::Ctor {
|
||
type_name: "Box".into(),
|
||
ctor: "MkBox".into(),
|
||
args: vec![Term::Lit { lit: Literal::Int { value: 7 } }],
|
||
}],
|
||
tail: false,
|
||
},
|
||
);
|
||
let use_bool = fn_def(
|
||
"ub",
|
||
Type::Fn {
|
||
params: vec![],
|
||
ret: Box::new(Type::bool_()),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
},
|
||
vec![],
|
||
Term::App {
|
||
callee: Box::new(Term::Var { name: "unbox".into() }),
|
||
args: vec![Term::Ctor {
|
||
type_name: "Box".into(),
|
||
ctor: "MkBox".into(),
|
||
args: vec![Term::Lit { lit: Literal::Bool { value: true } }],
|
||
}],
|
||
tail: false,
|
||
},
|
||
);
|
||
let m = Module {
|
||
schema: SCHEMA.into(),
|
||
name: "t".into(),
|
||
kernel: false,
|
||
imports: vec![],
|
||
defs: vec![box_def, unbox, use_int, use_bool],
|
||
};
|
||
check(&m).expect("polymorphic Box should typecheck at both instantiations");
|
||
}
|
||
|
||
/// parameterised-ADT arity is enforced. `Box` (1 var)
|
||
/// used as `Box<Int, Bool>` must error.
|
||
#[test]
|
||
fn parameterised_adt_arity_is_enforced() {
|
||
let box_def = Def::Type(TypeDef {
|
||
name: "Box".into(),
|
||
vars: vec!["a".into()],
|
||
ctors: vec![Ctor {
|
||
name: "MkBox".into(),
|
||
fields: vec![Type::Var { name: "a".into() }],
|
||
}],
|
||
doc: None,
|
||
drop_iterative: false,
|
||
param_in: BTreeMap::new(),
|
||
});
|
||
let bad = fn_def(
|
||
"bad",
|
||
Type::Fn {
|
||
params: vec![Type::Con {
|
||
name: "Box".into(),
|
||
args: vec![Type::int(), Type::bool_()],
|
||
}],
|
||
ret: Box::new(Type::int()),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
},
|
||
vec!["b"],
|
||
Term::Lit { lit: Literal::Int { value: 0 } },
|
||
);
|
||
let m = Module {
|
||
schema: SCHEMA.into(),
|
||
name: "t".into(),
|
||
kernel: false,
|
||
imports: vec![],
|
||
defs: vec![box_def, bad],
|
||
};
|
||
let err = check(&m).unwrap_err();
|
||
let msg = format!("{err}");
|
||
assert!(msg.contains("expects 1 type arg"), "got: {msg}");
|
||
}
|
||
|
||
/// `seq` requires lhs to be Unit. A non-Unit lhs is a
|
||
/// type error — the value of lhs gets discarded so a useful (non-
|
||
/// Unit) value would silently vanish.
|
||
#[test]
|
||
fn seq_lhs_must_be_unit() {
|
||
let m = Module {
|
||
schema: SCHEMA.into(),
|
||
name: "t".into(),
|
||
kernel: false,
|
||
imports: vec![],
|
||
defs: vec![fn_def(
|
||
"f",
|
||
Type::Fn {
|
||
params: vec![],
|
||
ret: Box::new(Type::int()),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
},
|
||
vec![],
|
||
Term::Seq {
|
||
lhs: Box::new(Term::Lit {
|
||
lit: Literal::Int { value: 7 },
|
||
}),
|
||
rhs: Box::new(Term::Lit {
|
||
lit: Literal::Int { value: 1 },
|
||
}),
|
||
},
|
||
)],
|
||
};
|
||
let err = check(&m).unwrap_err();
|
||
let msg = format!("{err}");
|
||
assert!(msg.contains("type mismatch"), "got: {msg}");
|
||
}
|
||
|
||
/// a `Term::App { tail: true, .. }` in non-tail position
|
||
/// must surface as `tail-call-not-in-tail-position`. Construction:
|
||
/// the recursive call sits as an argument to a Cons ctor (the
|
||
/// classic constructor-blocked recursion from the 14d survey).
|
||
#[test]
|
||
fn tail_call_in_non_tail_position_is_rejected() {
|
||
let m = Module {
|
||
schema: SCHEMA.into(),
|
||
name: "t".into(),
|
||
kernel: false,
|
||
imports: vec![],
|
||
defs: vec![
|
||
Def::Type(TypeDef {
|
||
name: "L".into(),
|
||
vars: vec![],
|
||
ctors: vec![
|
||
Ctor { name: "N".into(), fields: vec![] },
|
||
Ctor {
|
||
name: "C".into(),
|
||
fields: vec![
|
||
Type::int(),
|
||
Type::Con { name: "L".into(), args: vec![] },
|
||
],
|
||
},
|
||
],
|
||
doc: None,
|
||
drop_iterative: false,
|
||
param_in: BTreeMap::new(),
|
||
}),
|
||
fn_def(
|
||
"loop",
|
||
Type::Fn {
|
||
params: vec![Type::Con { name: "L".into(), args: vec![] }],
|
||
ret: Box::new(Type::Con { name: "L".into(), args: vec![] }),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
},
|
||
vec!["xs"],
|
||
// Body: C(0, tail-app loop xs) — the recursion is
|
||
// an arg to C, which is NOT a tail position.
|
||
Term::Ctor {
|
||
type_name: "L".into(),
|
||
ctor: "C".into(),
|
||
args: vec![
|
||
Term::Lit { lit: Literal::Int { value: 0 } },
|
||
Term::App {
|
||
callee: Box::new(Term::Var { name: "loop".into() }),
|
||
args: vec![Term::Var { name: "xs".into() }],
|
||
tail: true,
|
||
},
|
||
],
|
||
},
|
||
),
|
||
],
|
||
};
|
||
let err = check(&m).unwrap_err();
|
||
assert_eq!(err.code(), "tail-call-not-in-tail-position", "{err}");
|
||
}
|
||
|
||
// ----- Iter 15a: cross-module type / ctor resolution ------------------
|
||
|
||
/// Helper for the cross-module tests: build a workspace whose entry
|
||
/// imports `lib` and exposes its types via qualified names.
|
||
fn cross_module_ws(consumer: Module) -> Workspace {
|
||
let lib = Module {
|
||
schema: SCHEMA.into(),
|
||
name: "lib".into(),
|
||
kernel: false,
|
||
imports: vec![],
|
||
defs: vec![Def::Type(TypeDef {
|
||
name: "Box".into(),
|
||
vars: vec!["a".into()],
|
||
ctors: vec![Ctor {
|
||
name: "MkBox".into(),
|
||
fields: vec![Type::Var { name: "a".into() }],
|
||
}],
|
||
doc: None,
|
||
drop_iterative: false,
|
||
param_in: BTreeMap::new(),
|
||
})],
|
||
};
|
||
let mut modules = BTreeMap::new();
|
||
modules.insert("lib".into(), lib);
|
||
modules.insert(consumer.name.clone(), consumer.clone());
|
||
Workspace {
|
||
entry: consumer.name,
|
||
modules,
|
||
root_dir: std::path::PathBuf::from("."),
|
||
registry: ailang_core::workspace::Registry::default(),
|
||
}
|
||
}
|
||
|
||
/// a qualified `Type::Con` (`lib.Box`) resolves
|
||
/// through `module_types` rather than the local-module `types`.
|
||
#[test]
|
||
fn cross_module_qualified_type_in_param_resolves() {
|
||
let consumer = Module {
|
||
schema: SCHEMA.into(),
|
||
name: "use_lib".into(),
|
||
kernel: false,
|
||
imports: vec![Import { module: "lib".into(), alias: None }],
|
||
defs: vec![fn_def(
|
||
"noop",
|
||
Type::Fn {
|
||
params: vec![Type::Con {
|
||
name: "lib.Box".into(),
|
||
args: vec![Type::int()],
|
||
}],
|
||
ret: Box::new(Type::int()),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
},
|
||
vec!["b"],
|
||
Term::Lit { lit: Literal::Int { value: 0 } },
|
||
)],
|
||
};
|
||
let ws = cross_module_ws(consumer);
|
||
let diags = check_workspace(&ws);
|
||
assert!(diags.is_empty(), "expected green; got {diags:?}");
|
||
}
|
||
|
||
/// a qualified `Term::Ctor.type_name`
|
||
/// (`lib.Box`) resolves and constructs `lib.Box<Int>`.
|
||
#[test]
|
||
fn cross_module_qualified_term_ctor_resolves() {
|
||
let consumer = Module {
|
||
schema: SCHEMA.into(),
|
||
name: "use_lib".into(),
|
||
kernel: false,
|
||
imports: vec![Import { module: "lib".into(), alias: None }],
|
||
defs: vec![fn_def(
|
||
"make",
|
||
Type::Fn {
|
||
params: vec![],
|
||
ret: Box::new(Type::Con {
|
||
name: "lib.Box".into(),
|
||
args: vec![Type::int()],
|
||
}),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
},
|
||
vec![],
|
||
Term::Ctor {
|
||
type_name: "lib.Box".into(),
|
||
ctor: "MkBox".into(),
|
||
args: vec![Term::Lit { lit: Literal::Int { value: 7 } }],
|
||
},
|
||
)],
|
||
};
|
||
let ws = cross_module_ws(consumer);
|
||
let diags = check_workspace(&ws);
|
||
assert!(diags.is_empty(), "expected green; got {diags:?}");
|
||
}
|
||
|
||
/// a bare `Pattern::Ctor` (e.g. `MkBox x`)
|
||
/// against a scrutinee of type `lib.Box<Int>` resolves through
|
||
/// the type-driven lookup keyed on `expected.name`. Previously
|
||
/// this exercised an imports-fallback; post-canonical-type-form the lookup is
|
||
/// anchored to the scrutinee's qualified TypeDef directly.
|
||
#[test]
|
||
fn cross_module_pat_ctor_typedriven_resolves() {
|
||
let consumer = Module {
|
||
schema: SCHEMA.into(),
|
||
name: "use_lib".into(),
|
||
kernel: false,
|
||
imports: vec![Import { module: "lib".into(), alias: None }],
|
||
defs: vec![fn_def(
|
||
"open",
|
||
Type::Fn {
|
||
params: vec![Type::Con {
|
||
name: "lib.Box".into(),
|
||
args: vec![Type::int()],
|
||
}],
|
||
ret: Box::new(Type::int()),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
},
|
||
vec!["b"],
|
||
Term::Match {
|
||
scrutinee: Box::new(Term::Var { name: "b".into() }),
|
||
arms: vec![Arm {
|
||
pat: Pattern::Ctor {
|
||
ctor: "MkBox".into(),
|
||
fields: vec![Pattern::Var { name: "x".into() }],
|
||
},
|
||
body: Term::Var { name: "x".into() },
|
||
}],
|
||
},
|
||
)],
|
||
};
|
||
let ws = cross_module_ws(consumer);
|
||
let diags = check_workspace(&ws);
|
||
assert!(diags.is_empty(), "expected green; got {diags:?}");
|
||
}
|
||
|
||
/// a recursive cross-module ADT (`std_list.List a` with a
|
||
/// `Cons a (List a)` ctor) round-trips through both `Term::Ctor` synth
|
||
/// and pattern-ctor binding without unqualified-field-name unification
|
||
/// failures. The bug fixed in 15b: `cdef.fields` on the imported side
|
||
/// carries `Con("List", _)` (unqualified, owner's local namespace),
|
||
/// while the consumer-visible scrutinee/result type is
|
||
/// `Con("std_list.List", _)`. Without `qualify_local_types` applied
|
||
/// to the fields at use sites, `unify` rejected the mismatch.
|
||
#[test]
|
||
fn cross_module_recursive_adt_term_and_pat_ctor() {
|
||
// Library: `data List a = Nil | Cons a (List a)`. The `Cons`
|
||
// field types reference the local-namespace `List`, exactly
|
||
// mirroring the std_list shape that tripped the gap.
|
||
let lib = Module {
|
||
schema: SCHEMA.into(),
|
||
name: "lst".into(),
|
||
kernel: false,
|
||
imports: vec![],
|
||
defs: vec![Def::Type(TypeDef {
|
||
name: "List".into(),
|
||
vars: vec!["a".into()],
|
||
ctors: vec![
|
||
Ctor { name: "Nil".into(), fields: vec![] },
|
||
Ctor {
|
||
name: "Cons".into(),
|
||
fields: vec![
|
||
Type::Var { name: "a".into() },
|
||
Type::Con {
|
||
name: "List".into(),
|
||
args: vec![Type::Var { name: "a".into() }],
|
||
},
|
||
],
|
||
},
|
||
],
|
||
doc: None,
|
||
drop_iterative: false,
|
||
param_in: BTreeMap::new(),
|
||
})],
|
||
};
|
||
// Consumer: builds `Cons 1 (Cons 2 (Nil))` via qualified
|
||
// `lst.List/Cons` and pattern-matches it back out. The match
|
||
// arm exercises the symmetric pat-ctor fix.
|
||
let cons = |head: i64, tail: Term| Term::Ctor {
|
||
type_name: "lst.List".into(),
|
||
ctor: "Cons".into(),
|
||
args: vec![
|
||
Term::Lit { lit: Literal::Int { value: head } },
|
||
tail,
|
||
],
|
||
};
|
||
let nil = Term::Ctor {
|
||
type_name: "lst.List".into(),
|
||
ctor: "Nil".into(),
|
||
args: vec![],
|
||
};
|
||
let consumer = Module {
|
||
schema: SCHEMA.into(),
|
||
name: "uses_lst".into(),
|
||
kernel: false,
|
||
imports: vec![Import { module: "lst".into(), alias: None }],
|
||
defs: vec![fn_def(
|
||
"head_or_zero",
|
||
Type::Fn {
|
||
params: vec![],
|
||
ret: Box::new(Type::int()),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
},
|
||
vec![],
|
||
Term::Match {
|
||
scrutinee: Box::new(cons(1, cons(2, nil.clone()))),
|
||
arms: vec![
|
||
Arm {
|
||
pat: Pattern::Ctor {
|
||
ctor: "Cons".into(),
|
||
fields: vec![
|
||
Pattern::Var { name: "h".into() },
|
||
Pattern::Wild,
|
||
],
|
||
},
|
||
body: Term::Var { name: "h".into() },
|
||
},
|
||
Arm {
|
||
pat: Pattern::Ctor {
|
||
ctor: "Nil".into(),
|
||
fields: vec![],
|
||
},
|
||
body: Term::Lit { lit: Literal::Int { value: 0 } },
|
||
},
|
||
],
|
||
},
|
||
)],
|
||
};
|
||
let mut modules = BTreeMap::new();
|
||
modules.insert("lst".into(), lib);
|
||
modules.insert("uses_lst".into(), consumer);
|
||
let ws = Workspace {
|
||
entry: "uses_lst".into(),
|
||
modules,
|
||
root_dir: std::path::PathBuf::from("."),
|
||
registry: ailang_core::workspace::Registry::default(),
|
||
};
|
||
let diags = check_workspace(&ws);
|
||
assert!(diags.is_empty(), "expected green; got {diags:?}");
|
||
}
|
||
|
||
/// Canonical-type qualification: a class method whose declared return type is a
|
||
/// bare local Type::Con in the defining module (e.g.
|
||
/// `compare : a -> a -> Ordering` declared inside `prelude`) must
|
||
/// be presented to a consumer module's typecheck context with the
|
||
/// return type qualified (`prelude.Ordering`). Without this, the
|
||
/// consumer's `Pattern::Ctor LT` against the scrutinee no longer
|
||
/// resolves once the post-canonical-type-form canonical form removes the
|
||
/// imports-fallback that previously papered over the mismatch.
|
||
#[test]
|
||
fn ct2_class_method_cross_module_qualifies_return_type() {
|
||
// Defining module declares `Ordering` locally and a class
|
||
// `Ord` with a method `compare : a -> a -> Ordering`. The
|
||
// `Ordering` Type::Con is bare-local in the class method's ty,
|
||
// which is canonical post-canonical-type-form (bare = local to defining module).
|
||
let prelude = Module {
|
||
schema: SCHEMA.into(),
|
||
name: "p".into(),
|
||
kernel: false,
|
||
imports: vec![],
|
||
defs: vec![
|
||
Def::Type(TypeDef {
|
||
name: "Ordering".into(),
|
||
vars: vec![],
|
||
ctors: vec![
|
||
Ctor { name: "LT".into(), fields: vec![] },
|
||
Ctor { name: "EQ".into(), fields: vec![] },
|
||
Ctor { name: "GT".into(), fields: vec![] },
|
||
],
|
||
doc: None,
|
||
drop_iterative: false,
|
||
param_in: BTreeMap::new(),
|
||
}),
|
||
Def::Class(ClassDef {
|
||
name: "Ord".into(),
|
||
param: "a".into(),
|
||
superclass: None,
|
||
methods: vec![ClassMethod {
|
||
name: "compare".into(),
|
||
ty: Type::Forall {
|
||
vars: vec!["a".into()],
|
||
constraints: vec![],
|
||
body: Box::new(Type::Fn {
|
||
params: vec![
|
||
Type::Var { name: "a".into() },
|
||
Type::Var { name: "a".into() },
|
||
],
|
||
ret: Box::new(Type::Con {
|
||
name: "Ordering".into(),
|
||
args: vec![],
|
||
}),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
}),
|
||
},
|
||
default: None,
|
||
}],
|
||
doc: None,
|
||
}),
|
||
Def::Instance(InstanceDef {
|
||
class: "Ord".into(),
|
||
type_: Type::int(),
|
||
methods: vec![InstanceMethod {
|
||
name: "compare".into(),
|
||
body: Term::Lam {
|
||
params: vec!["x".into(), "y".into()],
|
||
param_tys: vec![Type::int(), Type::int()],
|
||
ret_ty: Box::new(Type::Con {
|
||
name: "Ordering".into(),
|
||
args: vec![],
|
||
}),
|
||
effects: vec![],
|
||
body: Box::new(Term::Ctor {
|
||
type_name: "Ordering".into(),
|
||
ctor: "EQ".into(),
|
||
args: vec![],
|
||
}),
|
||
},
|
||
}],
|
||
doc: None,
|
||
}),
|
||
],
|
||
};
|
||
// Consumer module imports prelude and calls `compare` (bare).
|
||
// The return type seen by the consumer must be `p.Ordering`,
|
||
// not bare `Ordering` — otherwise the match arm below fails to
|
||
// typecheck because the Pattern::Ctor lookup is type-driven and
|
||
// would not find `LT` in any TypeDef named bare `Ordering` in
|
||
// the consumer's local types.
|
||
let consumer = Module {
|
||
schema: SCHEMA.into(),
|
||
name: "u".into(),
|
||
kernel: false,
|
||
imports: vec![Import { module: "p".into(), alias: None }],
|
||
defs: vec![fn_def(
|
||
"use_compare",
|
||
Type::Fn {
|
||
params: vec![],
|
||
ret: Box::new(Type::int()),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
},
|
||
vec![],
|
||
Term::Match {
|
||
scrutinee: Box::new(Term::App {
|
||
callee: Box::new(Term::Var { name: "compare".into() }),
|
||
args: vec![
|
||
Term::Lit { lit: Literal::Int { value: 1 } },
|
||
Term::Lit { lit: Literal::Int { value: 2 } },
|
||
],
|
||
tail: false,
|
||
}),
|
||
arms: vec![
|
||
Arm {
|
||
pat: Pattern::Ctor {
|
||
ctor: "LT".into(),
|
||
fields: vec![],
|
||
},
|
||
body: Term::Lit { lit: Literal::Int { value: 1 } },
|
||
},
|
||
Arm {
|
||
pat: Pattern::Ctor {
|
||
ctor: "EQ".into(),
|
||
fields: vec![],
|
||
},
|
||
body: Term::Lit { lit: Literal::Int { value: 2 } },
|
||
},
|
||
Arm {
|
||
pat: Pattern::Ctor {
|
||
ctor: "GT".into(),
|
||
fields: vec![],
|
||
},
|
||
body: Term::Lit { lit: Literal::Int { value: 3 } },
|
||
},
|
||
],
|
||
},
|
||
)],
|
||
};
|
||
let mut modules = BTreeMap::new();
|
||
modules.insert("p".into(), prelude);
|
||
modules.insert("u".into(), consumer);
|
||
let ws = Workspace {
|
||
entry: "u".into(),
|
||
modules,
|
||
root_dir: std::path::PathBuf::from("."),
|
||
registry: ailang_core::workspace::Registry::default(),
|
||
};
|
||
let diags = check_workspace(&ws);
|
||
assert!(
|
||
diags.is_empty(),
|
||
"consumer module must typecheck cleanly when calling a class \
|
||
method whose return type is bare-local in its defining \
|
||
module; the class-method channel must apply \
|
||
qualify_local_types just like the qualified Term::Var path \
|
||
does. Got diagnostics: {:?}",
|
||
diags
|
||
);
|
||
}
|
||
|
||
/// Canonical-type qualification: `qualify_local_types`'s `Type::Forall` arm must
|
||
/// recurse into `constraints[].type_`. A class method whose
|
||
/// declared type is `Forall<a>{Ord a}. Fn[..]` carries the
|
||
/// constraint type `Type::Var { name: "a" }` — fine — but a
|
||
/// hypothetical `Forall<a>{Show p.Foo}. Fn[..]` would carry a
|
||
/// bare-local `Foo` if the defining module is `p`, and the
|
||
/// consumer must see `p.Foo` after the cross-module qualifier
|
||
/// runs. Symmetric to the canonical-form-normalisation follow-up fix on
|
||
/// `normalize_type_for_registry`.
|
||
#[test]
|
||
fn ct2_qualify_local_types_recurses_into_forall_constraints() {
|
||
use crate::qualify_local_types;
|
||
let mut local_types: IndexMap<String, TypeDef> = IndexMap::new();
|
||
local_types.insert("Foo".into(), TypeDef {
|
||
name: "Foo".into(),
|
||
vars: vec![],
|
||
ctors: vec![Ctor { name: "MkFoo".into(), fields: vec![] }],
|
||
doc: None,
|
||
drop_iterative: false,
|
||
param_in: BTreeMap::new(),
|
||
});
|
||
let input = Type::Forall {
|
||
vars: vec!["a".into()],
|
||
constraints: vec![Constraint {
|
||
class: "Show".into(),
|
||
type_: Type::Con { name: "Foo".into(), args: vec![] },
|
||
}],
|
||
body: Box::new(Type::Fn {
|
||
params: vec![Type::Var { name: "a".into() }],
|
||
ret: Box::new(Type::int()),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
}),
|
||
};
|
||
let out = qualify_local_types(&input, "p", &local_types);
|
||
match out {
|
||
Type::Forall { constraints, .. } => {
|
||
assert_eq!(constraints.len(), 1, "constraint count preserved");
|
||
match &constraints[0].type_ {
|
||
Type::Con { name, .. } => assert_eq!(
|
||
name, "p.Foo",
|
||
"constraint Type::Con must be qualified by qualify_local_types"
|
||
),
|
||
other => panic!("expected Type::Con, got {:?}", other),
|
||
}
|
||
}
|
||
other => panic!("expected Type::Forall, got {:?}", other),
|
||
}
|
||
}
|
||
|
||
/// a `Term::App { tail: true, .. }` that genuinely sits
|
||
/// in tail position (as the rhs of a `Seq` that is the body of a
|
||
/// `Match` arm that is the body of the fn) must pass.
|
||
#[test]
|
||
fn tail_call_in_tail_position_is_accepted() {
|
||
let m = Module {
|
||
schema: SCHEMA.into(),
|
||
name: "t".into(),
|
||
kernel: false,
|
||
imports: vec![],
|
||
defs: vec![
|
||
Def::Type(TypeDef {
|
||
name: "L".into(),
|
||
vars: vec![],
|
||
ctors: vec![
|
||
Ctor { name: "N".into(), fields: vec![] },
|
||
Ctor {
|
||
name: "C".into(),
|
||
fields: vec![
|
||
Type::int(),
|
||
Type::Con { name: "L".into(), args: vec![] },
|
||
],
|
||
},
|
||
],
|
||
doc: None,
|
||
drop_iterative: false,
|
||
param_in: BTreeMap::new(),
|
||
}),
|
||
fn_def(
|
||
"drain",
|
||
Type::Fn {
|
||
params: vec![Type::Con { name: "L".into(), args: vec![] }],
|
||
ret: Box::new(Type::unit()),
|
||
effects: vec!["IO".into()],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
},
|
||
vec!["xs"],
|
||
Term::Match {
|
||
scrutinee: Box::new(Term::Var { name: "xs".into() }),
|
||
arms: vec![
|
||
Arm {
|
||
pat: Pattern::Ctor {
|
||
ctor: "N".into(),
|
||
fields: vec![],
|
||
},
|
||
body: Term::Lit { lit: Literal::Unit },
|
||
},
|
||
Arm {
|
||
pat: Pattern::Ctor {
|
||
ctor: "C".into(),
|
||
fields: vec![
|
||
Pattern::Var { name: "h".into() },
|
||
Pattern::Var { name: "t".into() },
|
||
],
|
||
},
|
||
body: Term::Seq {
|
||
lhs: Box::new(Term::Do {
|
||
op: "io/print_str".into(),
|
||
args: vec![Term::Lit {
|
||
lit: Literal::Str {
|
||
value: "x".into(),
|
||
},
|
||
}],
|
||
tail: false,
|
||
}),
|
||
rhs: Box::new(Term::App {
|
||
callee: Box::new(Term::Var { name: "drain".into() }),
|
||
args: vec![Term::Var { name: "t".into() }],
|
||
tail: true,
|
||
}),
|
||
},
|
||
},
|
||
],
|
||
},
|
||
),
|
||
],
|
||
};
|
||
check(&m).expect("tail-call in tail position should typecheck");
|
||
}
|
||
|
||
// 2026-05-21 operator-routing-eq-ord: the
|
||
// `letrec_with_let_binding_capture_typechecks` test relied on
|
||
// the builtin `>=` to express its `if (>= i threshold) ...`
|
||
// condition. With `>=` deleted from the builtin table, the test
|
||
// cannot be reconstructed inside this single-module check_module
|
||
// env (which has no prelude auto-import, so `ge` is unbound).
|
||
// The LetRec-captures-Let-binding property remains pinned by
|
||
// the workspace e2e tests `local_rec_let_capture` (and siblings)
|
||
// which DO carry the auto-imported prelude.
|
||
|
||
/// a LetRec whose body returns the wrong type (Bool
|
||
/// instead of the declared Int) is caught by the new typing rule.
|
||
/// Property protected: the new arm in `synth` for `Term::LetRec`
|
||
/// runs `unify(&ret_ty, &body_ty, subst)` just like `Term::Lam`
|
||
/// and `check_fn`.
|
||
#[test]
|
||
fn letrec_body_wrong_return_type_is_rejected() {
|
||
// (let-rec wrong (params x) (type (Int) -> Int) (body true)
|
||
// (in (app wrong 0)))
|
||
let letrec = Term::LetRec {
|
||
name: "wrong".into(),
|
||
ty: Type::Fn {
|
||
params: vec![Type::int()],
|
||
ret: Box::new(Type::int()),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
},
|
||
params: vec!["x".into()],
|
||
body: Box::new(Term::Lit { lit: Literal::Bool { value: true } }),
|
||
in_term: Box::new(Term::App {
|
||
callee: Box::new(Term::Var { name: "wrong".into() }),
|
||
args: vec![Term::Lit { lit: Literal::Int { value: 0 } }],
|
||
tail: false,
|
||
}),
|
||
};
|
||
// Wrap in a Let so the desugar pass defers the LetRec (its
|
||
// body would otherwise lift cleanly with no captures, since
|
||
// `true` doesn't reference `x`).
|
||
let body = Term::Let {
|
||
name: "y".into(),
|
||
value: Box::new(Term::Lit { lit: Literal::Int { value: 0 } }),
|
||
// Reference `y` inside the LetRec body so it captures.
|
||
body: Box::new(Term::LetRec {
|
||
name: "wrong".into(),
|
||
ty: Type::Fn {
|
||
params: vec![Type::int()],
|
||
ret: Box::new(Type::int()),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
},
|
||
params: vec!["x".into()],
|
||
body: Box::new(Term::Seq {
|
||
// Use `y` so the LetRec captures it (forces deferral).
|
||
lhs: Box::new(Term::Lit { lit: Literal::Unit }),
|
||
rhs: Box::new(Term::App {
|
||
callee: Box::new(Term::Var { name: "+".into() }),
|
||
args: vec![
|
||
Term::Var { name: "y".into() },
|
||
Term::Lit { lit: Literal::Bool { value: true } },
|
||
],
|
||
tail: false,
|
||
}),
|
||
}),
|
||
in_term: Box::new(Term::App {
|
||
callee: Box::new(Term::Var { name: "wrong".into() }),
|
||
args: vec![Term::Lit { lit: Literal::Int { value: 0 } }],
|
||
tail: false,
|
||
}),
|
||
}),
|
||
};
|
||
// (Suppress dead-code warning on the unused unwrapped letrec.)
|
||
let _ = letrec;
|
||
let m = Module {
|
||
schema: SCHEMA.into(),
|
||
name: "t".into(),
|
||
kernel: false,
|
||
imports: vec![],
|
||
defs: vec![fn_def(
|
||
"outer",
|
||
Type::Fn {
|
||
params: vec![],
|
||
ret: Box::new(Type::int()),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
},
|
||
vec![],
|
||
body,
|
||
)],
|
||
};
|
||
let err = check(&m).expect_err("LetRec body returning Bool but declared Int must error");
|
||
let msg = format!("{err}");
|
||
assert!(
|
||
msg.contains("type mismatch"),
|
||
"expected a type-mismatch diagnostic, got: {msg}"
|
||
);
|
||
}
|
||
|
||
/// `lift_letrecs` on a module containing a deferred
|
||
/// LetRec produces a module whose defs include a synthetic
|
||
/// `<hint>$lr_N` FnDef and whose original fn body has rewritten
|
||
/// call sites.
|
||
#[test]
|
||
fn lift_letrecs_on_let_capture_produces_synthetic_fn() {
|
||
// fn outer : () -> Int = \.
|
||
// let y = 7
|
||
// in let-rec helper : (Int) -> Int = \x. + x y
|
||
// in helper 1
|
||
let m = Module {
|
||
schema: SCHEMA.into(),
|
||
name: "t".into(),
|
||
kernel: false,
|
||
imports: vec![],
|
||
defs: vec![fn_def(
|
||
"outer",
|
||
Type::Fn {
|
||
params: vec![],
|
||
ret: Box::new(Type::int()),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
},
|
||
vec![],
|
||
Term::Let {
|
||
name: "y".into(),
|
||
value: Box::new(Term::Lit { lit: Literal::Int { value: 7 } }),
|
||
body: Box::new(Term::LetRec {
|
||
name: "helper".into(),
|
||
ty: Type::Fn {
|
||
params: vec![Type::int()],
|
||
ret: Box::new(Type::int()),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
},
|
||
params: vec!["x".into()],
|
||
body: Box::new(Term::App {
|
||
callee: Box::new(Term::Var { name: "+".into() }),
|
||
args: vec![
|
||
Term::Var { name: "x".into() },
|
||
Term::Var { name: "y".into() },
|
||
],
|
||
tail: false,
|
||
}),
|
||
in_term: Box::new(Term::App {
|
||
callee: Box::new(Term::Var { name: "helper".into() }),
|
||
args: vec![Term::Lit { lit: Literal::Int { value: 1 } }],
|
||
tail: false,
|
||
}),
|
||
}),
|
||
},
|
||
)],
|
||
};
|
||
// Typecheck must succeed (the new LetRec rule accepts this).
|
||
check(&m).expect("typecheck before lift");
|
||
let desugared = ailang_core::desugar::desugar_module(&m);
|
||
// After desugar the LetRec is still present (Let-binding capture).
|
||
// After lift_letrecs it must be gone, replaced by a synthetic
|
||
// top-level fn with the capture appended to its signature.
|
||
let lifted = lift_letrecs(&desugared).expect("lift_letrecs");
|
||
assert_eq!(lifted.defs.len(), 2, "expected one synthetic fn appended");
|
||
let synth = match &lifted.defs[1] {
|
||
Def::Fn(f) => f,
|
||
_ => panic!("expected synthetic FnDef"),
|
||
};
|
||
assert!(
|
||
synth.name.starts_with("helper$lr_"),
|
||
"lifted name `{}` should start with `helper$lr_`",
|
||
synth.name
|
||
);
|
||
assert_eq!(
|
||
synth.ty,
|
||
Type::Fn {
|
||
params: vec![Type::int(), Type::int()],
|
||
ret: Box::new(Type::int()),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
},
|
||
"lifted ty should have capture appended; got {:?}",
|
||
synth.ty
|
||
);
|
||
assert_eq!(synth.params, vec!["x".to_string(), "y".to_string()]);
|
||
// `outer`'s body must have the `(app helper 1)` rewritten to
|
||
// `(app helper$lr_0 1 y)`. The body is now
|
||
// `let y = 7 in (app helper$lr_0 1 y)`.
|
||
let outer_body = match &lifted.defs[0] {
|
||
Def::Fn(f) => &f.body,
|
||
_ => unreachable!(),
|
||
};
|
||
let inner = match outer_body {
|
||
Term::Let { body, .. } => body.as_ref(),
|
||
other => panic!("expected outer Let, got {other:?}"),
|
||
};
|
||
match inner {
|
||
Term::App { callee, args, .. } => {
|
||
match callee.as_ref() {
|
||
Term::Var { name } => assert_eq!(name, &synth.name),
|
||
other => panic!("expected lifted callee, got {other:?}"),
|
||
}
|
||
assert_eq!(args.len(), 2, "expected 2 args (1 original + 1 capture)");
|
||
match &args[1] {
|
||
Term::Var { name } => assert_eq!(name, "y"),
|
||
other => panic!("expected y as second arg, got {other:?}"),
|
||
}
|
||
}
|
||
other => panic!("expected App after Let, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// a LetRec whose body captures a `Pattern::Ctor`-Var
|
||
/// match-arm binding is lifted by `lift_letrecs` to a synthetic
|
||
/// top-level fn whose signature has the binding's type appended.
|
||
/// Property protected: `type_check_pattern_for_lift` substitutes
|
||
/// the matched ADT's type args (`[Int, Int]`) into the ctor's
|
||
/// declared field types (`[a, b]`) and returns `[(x, Int),
|
||
/// (y, Int)]`; the lifted fn picks up `x: Int` as the capture
|
||
/// type. Without 16b.4, the desugar pass would have panicked
|
||
/// before reaching `lift_letrecs`.
|
||
#[test]
|
||
fn lift_letrecs_on_match_arm_capture_produces_synthetic_fn() {
|
||
// (data Pair (vars a b) (ctor MkPair a b))
|
||
// (fn outer : (Pair Int Int) -> Int = \p.
|
||
// match p
|
||
// case (pat-ctor MkPair x y) ->
|
||
// let-rec helper : (Int) -> Int = \z. + z x
|
||
// in (app helper 0))
|
||
let pair_td = TypeDef {
|
||
name: "Pair".into(),
|
||
vars: vec!["a".into(), "b".into()],
|
||
ctors: vec![Ctor {
|
||
name: "MkPair".into(),
|
||
fields: vec![
|
||
Type::Var { name: "a".into() },
|
||
Type::Var { name: "b".into() },
|
||
],
|
||
}],
|
||
doc: None,
|
||
drop_iterative: false,
|
||
param_in: BTreeMap::new(),
|
||
};
|
||
let letrec = Term::LetRec {
|
||
name: "helper".into(),
|
||
ty: Type::Fn {
|
||
params: vec![Type::int()],
|
||
ret: Box::new(Type::int()),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
},
|
||
params: vec!["z".into()],
|
||
body: Box::new(Term::App {
|
||
callee: Box::new(Term::Var { name: "+".into() }),
|
||
args: vec![
|
||
Term::Var { name: "z".into() },
|
||
Term::Var { name: "x".into() },
|
||
],
|
||
tail: false,
|
||
}),
|
||
in_term: Box::new(Term::App {
|
||
callee: Box::new(Term::Var { name: "helper".into() }),
|
||
args: vec![Term::Lit { lit: Literal::Int { value: 0 } }],
|
||
tail: false,
|
||
}),
|
||
};
|
||
let outer_body = Term::Match {
|
||
scrutinee: Box::new(Term::Var { name: "p".into() }),
|
||
arms: vec![Arm {
|
||
pat: Pattern::Ctor {
|
||
ctor: "MkPair".into(),
|
||
fields: vec![
|
||
Pattern::Var { name: "x".into() },
|
||
Pattern::Var { name: "y".into() },
|
||
],
|
||
},
|
||
body: letrec,
|
||
}],
|
||
};
|
||
let m = Module {
|
||
schema: SCHEMA.into(),
|
||
name: "t".into(),
|
||
kernel: false,
|
||
imports: vec![],
|
||
defs: vec![
|
||
Def::Type(pair_td),
|
||
fn_def(
|
||
"outer",
|
||
Type::Fn {
|
||
params: vec![Type::Con {
|
||
name: "Pair".into(),
|
||
args: vec![Type::int(), Type::int()],
|
||
}],
|
||
ret: Box::new(Type::int()),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
},
|
||
vec!["p"],
|
||
outer_body,
|
||
),
|
||
],
|
||
};
|
||
// Typecheck must succeed.
|
||
check(&m).expect("typecheck before lift");
|
||
let desugared = ailang_core::desugar::desugar_module(&m);
|
||
// Match-arm capture: desugar leaves the LetRec in place.
|
||
let lifted = lift_letrecs(&desugared).expect("lift_letrecs");
|
||
// Pair (Type) + outer (Fn) + helper$lr_0 (Fn) = 3.
|
||
assert_eq!(lifted.defs.len(), 3, "expected one synthetic fn appended");
|
||
let synth = match &lifted.defs[2] {
|
||
Def::Fn(f) => f,
|
||
_ => panic!("expected synthetic FnDef at index 2"),
|
||
};
|
||
assert!(
|
||
synth.name.starts_with("helper$lr_"),
|
||
"lifted name `{}` should start with `helper$lr_`",
|
||
synth.name
|
||
);
|
||
// Lifted ty: original (Int) -> Int with capture type Int
|
||
// appended → (Int, Int) -> Int.
|
||
assert_eq!(
|
||
synth.ty,
|
||
Type::Fn {
|
||
params: vec![Type::int(), Type::int()],
|
||
ret: Box::new(Type::int()),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
},
|
||
"lifted ty should have the match-arm-capture type Int appended; got {:?}",
|
||
synth.ty
|
||
);
|
||
assert_eq!(synth.params, vec!["z".to_string(), "x".to_string()]);
|
||
}
|
||
|
||
// 2026-05-21 operator-routing-eq-ord: the
|
||
// `lift_letrecs_under_polymorphic_enclosing_fn_produces_forall_fn`
|
||
// test (originally here) relied on the builtin `==` to express
|
||
// its `if (== k 0) ...` condition. With `==` deleted from the
|
||
// builtin table the test cannot be reconstructed inside this
|
||
// single-module check env (`eq` from prelude.Eq is not visible
|
||
// without auto-import). The lift-letrec-under-polymorphic-fn
|
||
// property remains exercised by the workspace e2e tests
|
||
// `local_rec_let_capture` and siblings, which DO carry the
|
||
// implicit prelude import.
|
||
|
||
// 2026-05-21 operator-routing-eq-ord: the five `eq_*` tests
|
||
// formerly here (`eq_typechecks_at_int` / `_bool` / `_str` /
|
||
// `_unit` and `eq_rejects_mixed_int_bool`) pinned the
|
||
// polymorphic-`==` builtin shape (`forall a. (a, a) -> Bool`
|
||
// resolves at any concrete arg pair, type-mismatch on Int+Bool).
|
||
// With `==` deleted from the builtin table, the surface
|
||
// equivalent is the class method `eq` dispatched via prelude.Eq
|
||
// — which is not visible in this single-module check_module test
|
||
// env (no prelude auto-import). The properties move to
|
||
// `eq_user_adt_smoke_e2e` (Eq Unit), `eq_demo` (Int/Bool/Str
|
||
// dispatch via class-method), and `eq_float_noinstance` (the
|
||
// "no instance" rejection for an unsupported type).
|
||
|
||
/// a `(reuse-as xs 42)` where the body is a literal
|
||
/// (not a `Term::Ctor` and not `Term::Lam`) must emit
|
||
/// `reuse-as-non-allocating-body` with `ctx.got = "lit"`. The
|
||
/// suggested rewrite drops the wrapper; the replacement parses
|
||
/// via `parse_term`.
|
||
#[test]
|
||
fn reuse_as_with_non_allocating_body_is_reported() {
|
||
// Body: (reuse-as x 42) — `x` is the param, body is a lit.
|
||
let body = Term::ReuseAs {
|
||
source: Box::new(Term::Var { name: "x".into() }),
|
||
body: Box::new(Term::Lit { lit: Literal::Int { value: 42 } }),
|
||
};
|
||
let m = Module {
|
||
schema: SCHEMA.into(),
|
||
name: "t".into(),
|
||
kernel: false,
|
||
imports: vec![],
|
||
defs: vec![fn_def(
|
||
"f",
|
||
Type::Fn {
|
||
params: vec![Type::int()],
|
||
ret: Box::new(Type::int()),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
},
|
||
vec!["x"],
|
||
body,
|
||
)],
|
||
};
|
||
let diags = check_module(&m);
|
||
let bad: Vec<&Diagnostic> = diags
|
||
.iter()
|
||
.filter(|d| d.code == "reuse-as-non-allocating-body")
|
||
.collect();
|
||
assert_eq!(
|
||
bad.len(),
|
||
1,
|
||
"want exactly one reuse-as-non-allocating-body; got: {diags:#?}"
|
||
);
|
||
let d = bad[0];
|
||
assert_eq!(d.severity, Severity::Error);
|
||
assert_eq!(d.def.as_deref(), Some("f"));
|
||
assert_eq!(d.ctx.get("got").and_then(|v| v.as_str()), Some("lit"));
|
||
assert_eq!(d.suggested_rewrites.len(), 1);
|
||
// Replacement is the body without the wrapper — must parse.
|
||
let rep = &d.suggested_rewrites[0].replacement;
|
||
ailang_surface::parse_term(rep)
|
||
.unwrap_or_else(|e| panic!("suggested rewrite must parse: {rep} ({e})"));
|
||
}
|
||
|
||
/// Floats milestone post-fieldtest B1 (RED): pattern-matching on a
|
||
/// `Literal::Float` literal must be hard-rejected at typecheck via
|
||
/// `CheckError::FloatPatternNotAllowed` per
|
||
/// design/contracts/0005-float-semantics.md — even when the match arm reaches `check` through
|
||
/// the *full* check pipeline (which runs `desugar_module` first).
|
||
///
|
||
/// The existing `reject_float_pattern_in_match` test in
|
||
/// `crates/ailang-check/src/builtins.rs` calls `synth(...)`
|
||
/// directly on a hand-built `Term::Match`, bypassing desugar; it
|
||
/// passes today but does not protect the property the spec
|
||
/// promises. The desugar pass `build_eq` rewrites
|
||
/// `Pattern::Lit { lit }` arms (including `Literal::Float`) into
|
||
/// `Term::If { cond: (== scrutinee lit), .. }` *before* typecheck
|
||
/// runs (see `crates/ailang-core/src/desugar.rs:1056`), so the
|
||
/// `Pattern::Lit { lit: Literal::Float { .. } }` arm at
|
||
/// `lib.rs:2316` is unreachable on input that flows through
|
||
/// `check`/`check_module`/`check_workspace`. This test exercises
|
||
/// the full pipeline and pins the Float-pattern hard-reject as
|
||
/// a property of the public check API.
|
||
#[test]
|
||
fn check_rejects_float_pattern_through_full_pipeline() {
|
||
// fn classify : (Float) -> Int = \x. match x { 0.0 -> 1; _ -> 0 }
|
||
let bits = 0.0_f64.to_bits();
|
||
let m = Module {
|
||
schema: SCHEMA.into(),
|
||
name: "t".into(),
|
||
kernel: false,
|
||
imports: vec![],
|
||
defs: vec![fn_def(
|
||
"classify",
|
||
Type::Fn {
|
||
params: vec![Type::float()],
|
||
ret: Box::new(Type::int()),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
},
|
||
vec!["x"],
|
||
Term::Match {
|
||
scrutinee: Box::new(Term::Var { name: "x".into() }),
|
||
arms: vec![
|
||
Arm {
|
||
pat: Pattern::Lit {
|
||
lit: Literal::Float { bits },
|
||
},
|
||
body: Term::Lit { lit: Literal::Int { value: 1 } },
|
||
},
|
||
Arm {
|
||
pat: Pattern::Wild,
|
||
body: Term::Lit { lit: Literal::Int { value: 0 } },
|
||
},
|
||
],
|
||
},
|
||
)],
|
||
};
|
||
let err = check(&m).expect_err(
|
||
"Pattern::Lit { Literal::Float } must be rejected at typecheck",
|
||
);
|
||
assert!(
|
||
matches!(err, CheckError::FloatPatternNotAllowed),
|
||
"expected CheckError::FloatPatternNotAllowed, got {err:?}"
|
||
);
|
||
}
|
||
|
||
/// Canonical-type-pattern lookup: a `Pattern::Ctor` against a scrutinee of type
|
||
/// `Type::Con { name: "p.T", .. }` looks up the TypeDef in
|
||
/// `env.module_types["p"]["T"]` and finds the ctor by name within
|
||
/// it — no `env.ctor_index` consult, no imports-walk. This pins the
|
||
/// new lookup strategy.
|
||
#[test]
|
||
fn ct2_pattern_ctor_type_driven_lookup_cross_module() {
|
||
let lib = Module {
|
||
schema: SCHEMA.into(),
|
||
name: "lib".into(),
|
||
kernel: false,
|
||
imports: vec![],
|
||
defs: vec![Def::Type(TypeDef {
|
||
name: "Box".into(),
|
||
vars: vec!["a".into()],
|
||
ctors: vec![Ctor {
|
||
name: "MkBox".into(),
|
||
fields: vec![Type::Var { name: "a".into() }],
|
||
}],
|
||
doc: None,
|
||
drop_iterative: false,
|
||
param_in: BTreeMap::new(),
|
||
})],
|
||
};
|
||
let consumer = Module {
|
||
schema: SCHEMA.into(),
|
||
name: "use_lib".into(),
|
||
kernel: false,
|
||
imports: vec![Import { module: "lib".into(), alias: None }],
|
||
defs: vec![fn_def(
|
||
"open",
|
||
Type::Fn {
|
||
params: vec![Type::Con {
|
||
name: "lib.Box".into(),
|
||
args: vec![Type::int()],
|
||
}],
|
||
ret: Box::new(Type::int()),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
},
|
||
vec!["b"],
|
||
Term::Match {
|
||
scrutinee: Box::new(Term::Var { name: "b".into() }),
|
||
arms: vec![Arm {
|
||
pat: Pattern::Ctor {
|
||
ctor: "MkBox".into(),
|
||
fields: vec![Pattern::Var { name: "x".into() }],
|
||
},
|
||
body: Term::Var { name: "x".into() },
|
||
}],
|
||
},
|
||
)],
|
||
};
|
||
let mut modules = BTreeMap::new();
|
||
modules.insert("lib".into(), lib);
|
||
modules.insert("use_lib".into(), consumer);
|
||
let ws = Workspace {
|
||
entry: "use_lib".into(),
|
||
modules,
|
||
root_dir: std::path::PathBuf::from("."),
|
||
registry: ailang_core::workspace::Registry::default(),
|
||
};
|
||
let diags = check_workspace(&ws);
|
||
assert!(diags.is_empty(), "expected green; got {diags:?}");
|
||
}
|
||
|
||
/// Canonical-type-pattern lookup: when the scrutinee carries a Type::Con name that
|
||
/// doesn't resolve to any TypeDef (workspace-wide), the pattern
|
||
/// lookup must fail closed. The post-canonical-type-form validator catches this
|
||
/// shape earlier for canonical inputs; this test pins the residual
|
||
/// runtime guard against constructed (non-loaded) AST.
|
||
#[test]
|
||
fn ct2_pattern_ctor_fails_closed_when_scrutinee_type_unknown() {
|
||
let consumer = Module {
|
||
schema: SCHEMA.into(),
|
||
name: "u".into(),
|
||
kernel: false,
|
||
imports: vec![],
|
||
defs: vec![fn_def(
|
||
"f",
|
||
Type::Fn {
|
||
params: vec![Type::Con {
|
||
name: "Nonexistent".into(),
|
||
args: vec![],
|
||
}],
|
||
ret: Box::new(Type::int()),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
},
|
||
vec!["x"],
|
||
Term::Match {
|
||
scrutinee: Box::new(Term::Var { name: "x".into() }),
|
||
arms: vec![Arm {
|
||
pat: Pattern::Ctor {
|
||
ctor: "Whatever".into(),
|
||
fields: vec![],
|
||
},
|
||
body: Term::Lit { lit: Literal::Int { value: 0 } },
|
||
}],
|
||
},
|
||
)],
|
||
};
|
||
let mut modules = BTreeMap::new();
|
||
modules.insert("u".into(), consumer);
|
||
let ws = Workspace {
|
||
entry: "u".into(),
|
||
modules,
|
||
root_dir: std::path::PathBuf::from("."),
|
||
registry: ailang_core::workspace::Registry::default(),
|
||
};
|
||
let diags = check_workspace(&ws);
|
||
assert!(
|
||
!diags.is_empty(),
|
||
"expected at least one diagnostic for unknown scrutinee \
|
||
type, got none"
|
||
);
|
||
}
|
||
|
||
/// prep.1 (kernel-extension-mechanics): a bare `Term::Ctor.type_name`
|
||
/// that does not resolve to a local TypeDef AND does not resolve
|
||
/// via the workspace-wide TypeDef-first ladder must error with
|
||
/// `UnknownType`. Pre-prep.1 a bare cross-module ref was always
|
||
/// rejected at this site; post-prep.1 it is *accepted* when an
|
||
/// imported module declares the type (covered by
|
||
/// `ct2_term_ctor_bare_cross_module_via_workspace_resolves`).
|
||
/// This test uses a name (`Widget`) no module declares so the
|
||
/// runtime guard still fires.
|
||
#[test]
|
||
fn ct2_term_ctor_bare_cross_module_fails_closed() {
|
||
let consumer = Module {
|
||
schema: SCHEMA.into(),
|
||
name: "u".into(),
|
||
kernel: false,
|
||
imports: vec![],
|
||
defs: vec![fn_def(
|
||
"stale",
|
||
Type::Fn {
|
||
params: vec![],
|
||
ret: Box::new(Type::Con {
|
||
name: "Widget".into(),
|
||
args: vec![],
|
||
}),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
},
|
||
vec![],
|
||
Term::Ctor {
|
||
type_name: "Widget".into(),
|
||
ctor: "Mk".into(),
|
||
args: vec![],
|
||
},
|
||
)],
|
||
};
|
||
let mut modules = BTreeMap::new();
|
||
modules.insert("u".into(), consumer);
|
||
let ws = Workspace {
|
||
entry: "u".into(),
|
||
modules,
|
||
root_dir: std::path::PathBuf::from("."),
|
||
registry: ailang_core::workspace::Registry::default(),
|
||
};
|
||
let diags = check_workspace(&ws);
|
||
assert!(
|
||
diags.iter().any(|d| d.code == "unknown-type"),
|
||
"expected unknown-type diagnostic for bare cross-module \
|
||
Term::Ctor (no module declares Widget); got {diags:?}"
|
||
);
|
||
}
|
||
|
||
/// prep.1 (kernel-extension-mechanics): a bare `Term::Ctor.type_name`
|
||
/// resolves via the workspace-wide TypeDef-first ladder when an
|
||
/// imported module declares the type. Companion to
|
||
/// `ct2_term_ctor_bare_cross_module_fails_closed` (the negative
|
||
/// shape).
|
||
#[test]
|
||
fn ct2_term_ctor_bare_cross_module_via_workspace_resolves() {
|
||
let owner = Module {
|
||
schema: SCHEMA.into(),
|
||
name: "p".into(),
|
||
kernel: false,
|
||
imports: vec![],
|
||
defs: vec![Def::Type(TypeDef {
|
||
name: "Ordering".into(),
|
||
vars: vec![],
|
||
ctors: vec![
|
||
Ctor { name: "LT".into(), fields: vec![] },
|
||
Ctor { name: "EQ".into(), fields: vec![] },
|
||
Ctor { name: "GT".into(), fields: vec![] },
|
||
],
|
||
doc: None,
|
||
drop_iterative: false,
|
||
param_in: BTreeMap::new(),
|
||
})],
|
||
};
|
||
let consumer = Module {
|
||
schema: SCHEMA.into(),
|
||
name: "u".into(),
|
||
kernel: false,
|
||
imports: vec![Import { module: "p".into(), alias: None }],
|
||
defs: vec![fn_def(
|
||
"f",
|
||
Type::Fn {
|
||
params: vec![],
|
||
ret: Box::new(Type::Con {
|
||
name: "p.Ordering".into(),
|
||
args: vec![],
|
||
}),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
},
|
||
vec![],
|
||
Term::Ctor {
|
||
type_name: "Ordering".into(),
|
||
ctor: "LT".into(),
|
||
args: vec![],
|
||
},
|
||
)],
|
||
};
|
||
let mut modules = BTreeMap::new();
|
||
modules.insert("p".into(), owner);
|
||
modules.insert("u".into(), consumer);
|
||
let ws = Workspace {
|
||
entry: "u".into(),
|
||
modules,
|
||
root_dir: std::path::PathBuf::from("."),
|
||
registry: ailang_core::workspace::Registry::default(),
|
||
};
|
||
let diags = check_workspace(&ws);
|
||
assert!(
|
||
diags.is_empty(),
|
||
"bare `Ordering` in Term::Ctor must resolve via workspace TypeDef-first ladder; got {diags:?}"
|
||
);
|
||
}
|
||
|
||
/// Canonical-type-lookup: a qualified `Term::Ctor.type_name`
|
||
/// (`p.Ordering` / `LT`) continues to resolve cleanly through the
|
||
/// qualified branch. This is the canonical form post-canonical-type-form and the
|
||
/// hot path for every Term::Ctor in a migrated fixture.
|
||
#[test]
|
||
fn ct2_term_ctor_qualified_cross_module_resolves() {
|
||
let prelude = Module {
|
||
schema: SCHEMA.into(),
|
||
name: "p".into(),
|
||
kernel: false,
|
||
imports: vec![],
|
||
defs: vec![Def::Type(TypeDef {
|
||
name: "Ordering".into(),
|
||
vars: vec![],
|
||
ctors: vec![
|
||
Ctor { name: "LT".into(), fields: vec![] },
|
||
Ctor { name: "EQ".into(), fields: vec![] },
|
||
Ctor { name: "GT".into(), fields: vec![] },
|
||
],
|
||
doc: None,
|
||
drop_iterative: false,
|
||
param_in: BTreeMap::new(),
|
||
})],
|
||
};
|
||
let consumer = Module {
|
||
schema: SCHEMA.into(),
|
||
name: "u".into(),
|
||
kernel: false,
|
||
imports: vec![Import { module: "p".into(), alias: None }],
|
||
defs: vec![fn_def(
|
||
"lt",
|
||
Type::Fn {
|
||
params: vec![],
|
||
ret: Box::new(Type::Con {
|
||
name: "p.Ordering".into(),
|
||
args: vec![],
|
||
}),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
},
|
||
vec![],
|
||
Term::Ctor {
|
||
type_name: "p.Ordering".into(),
|
||
ctor: "LT".into(),
|
||
args: vec![],
|
||
},
|
||
)],
|
||
};
|
||
let mut modules = BTreeMap::new();
|
||
modules.insert("p".into(), prelude);
|
||
modules.insert("u".into(), consumer);
|
||
let ws = Workspace {
|
||
entry: "u".into(),
|
||
modules,
|
||
root_dir: std::path::PathBuf::from("."),
|
||
registry: ailang_core::workspace::Registry::default(),
|
||
};
|
||
let diags = check_workspace(&ws);
|
||
assert!(diags.is_empty(), "expected green; got {diags:?}");
|
||
}
|
||
|
||
/// bare-name resolution falls through to free
|
||
/// fns of implicitly-imported modules. The consumer calls bare `dbl`,
|
||
/// declared in `prelude` (the singleton implicit import today). The
|
||
/// pre-fix behaviour is `unbound-var` because the `Term::Var` lookup
|
||
/// ladder consults locals → local globals → class_methods →
|
||
/// dot-qualified, but never the flat free-fn tables of implicit
|
||
/// imports.
|
||
#[test]
|
||
fn bare_name_resolves_through_implicit_import_to_free_fn() {
|
||
let prelude = Module {
|
||
schema: SCHEMA.into(),
|
||
name: "prelude".into(),
|
||
kernel: false,
|
||
imports: vec![],
|
||
defs: vec![fn_def(
|
||
"dbl",
|
||
Type::Fn {
|
||
params: vec![Type::int()],
|
||
ret: Box::new(Type::int()),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
},
|
||
vec!["n"],
|
||
Term::Var { name: "n".into() },
|
||
)],
|
||
};
|
||
let consumer = Module {
|
||
schema: SCHEMA.into(),
|
||
name: "m".into(),
|
||
kernel: false,
|
||
imports: vec![],
|
||
defs: vec![fn_def(
|
||
"main",
|
||
Type::Fn {
|
||
params: vec![],
|
||
ret: Box::new(Type::int()),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
},
|
||
vec![],
|
||
Term::App {
|
||
callee: Box::new(Term::Var { name: "dbl".into() }),
|
||
args: vec![Term::Lit { lit: Literal::Int { value: 5 } }],
|
||
tail: false,
|
||
},
|
||
)],
|
||
};
|
||
let mut modules = BTreeMap::new();
|
||
modules.insert("prelude".into(), prelude);
|
||
modules.insert("m".into(), consumer);
|
||
let ws = Workspace {
|
||
entry: "m".into(),
|
||
modules,
|
||
root_dir: std::path::PathBuf::from("."),
|
||
registry: ailang_core::workspace::Registry::default(),
|
||
};
|
||
let diags = check_workspace(&ws);
|
||
assert!(
|
||
diags.is_empty(),
|
||
"expected bare `dbl` to resolve through implicit prelude import; got {diags:?}"
|
||
);
|
||
}
|
||
|
||
/// `AmbiguousMethodResolution` is the new check-time
|
||
/// diagnostic for multi-candidate residuals that survive type-driven
|
||
/// filtering at a monomorphic call site. Verifies the variant is
|
||
/// constructible, its `code()` returns the structured-diagnostic key,
|
||
/// and the Display surface names the candidate classes.
|
||
#[test]
|
||
fn mq2_ambiguous_method_resolution_display_and_code() {
|
||
let err = CheckError::AmbiguousMethodResolution {
|
||
method: "show".to_string(),
|
||
at_type: "Int".to_string(),
|
||
candidate_classes: vec![
|
||
"prelude.Show".to_string(),
|
||
"userlib.Show".to_string(),
|
||
],
|
||
};
|
||
assert_eq!(err.code(), "ambiguous-method-resolution");
|
||
let rendered = format!("{}", err);
|
||
assert!(rendered.contains("show"), "Display must echo method, got: {rendered}");
|
||
assert!(rendered.contains("prelude.Show"), "Display must name candidate, got: {rendered}");
|
||
assert!(rendered.contains("userlib.Show"), "Display must name candidate, got: {rendered}");
|
||
}
|
||
|
||
/// `UnknownClass` is the new check-time diagnostic for an
|
||
/// explicit class qualifier in `Term::Var.name` that names a
|
||
/// qualified class not in the workspace.
|
||
#[test]
|
||
fn mq2_unknown_class_display_and_code() {
|
||
let err = CheckError::UnknownClass {
|
||
name: "unknownlib.Show".to_string(),
|
||
};
|
||
assert_eq!(err.code(), "unknown-class");
|
||
let rendered = format!("{}", err);
|
||
assert!(rendered.contains("unknownlib.Show"), "Display must echo the qualified name, got: {rendered}");
|
||
}
|
||
|
||
/// `NoInstance` carries an additive `candidate_classes`
|
||
/// list. When non-empty, the rendered ctx() carries the candidate
|
||
/// list; when empty, the ctx() shape is unchanged from pre-canonical-class-form
|
||
/// form (backwards compatible for single-class call sites).
|
||
#[test]
|
||
fn mq2_no_instance_with_candidate_classes_renders() {
|
||
let err = CheckError::NoInstance {
|
||
def: "f".to_string(),
|
||
class: "prelude.Show".to_string(),
|
||
method: "show".to_string(),
|
||
at_type: "MyType".to_string(),
|
||
candidate_classes: vec![
|
||
"prelude.Show".to_string(),
|
||
"userlib.Show".to_string(),
|
||
],
|
||
};
|
||
let rendered = format!("{}", err);
|
||
assert!(rendered.contains("show"), "Display must echo method, got: {rendered}");
|
||
assert!(rendered.contains("MyType"), "Display must echo type, got: {rendered}");
|
||
let ctx = err.ctx();
|
||
assert_eq!(ctx["candidate_classes"][0], "prelude.Show");
|
||
assert_eq!(ctx["candidate_classes"][1], "userlib.Show");
|
||
}
|
||
|
||
/// empty `candidate_classes` keeps the existing single-class
|
||
/// ctx() shape — no `candidate_classes` key in the JSON.
|
||
#[test]
|
||
fn mq2_no_instance_empty_candidates_back_compat() {
|
||
let err = CheckError::NoInstance {
|
||
def: "f".to_string(),
|
||
class: "prelude.Show".to_string(),
|
||
method: "show".to_string(),
|
||
at_type: "MyType".to_string(),
|
||
candidate_classes: vec![],
|
||
};
|
||
let rendered = format!("{}", err);
|
||
assert!(rendered.contains("prelude.Show"), "Display must echo class, got: {rendered}");
|
||
let ctx = err.ctx();
|
||
assert!(ctx.get("candidate_classes").is_none(),
|
||
"empty candidate_classes must not render in ctx; got: {ctx}");
|
||
}
|
||
|
||
/// `ResidualConstraint` carries an optional candidate-class
|
||
/// set for the multi-candidate dispatch path. `None` preserves the
|
||
/// pre-canonical-class-form single-class semantics (the `class` field is the
|
||
/// resolved class). `Some(...)` carries the candidate set; the
|
||
/// `class` field's content is a tentative value until discharge
|
||
/// resolves it.
|
||
#[test]
|
||
fn mq2_residual_constraint_candidates_field_exists() {
|
||
let single = ResidualConstraint {
|
||
class: "prelude.Eq".to_string(),
|
||
type_: Type::Con { name: "Int".to_string(), args: vec![] },
|
||
method: "eq".to_string(),
|
||
candidates: None,
|
||
};
|
||
assert!(single.candidates.is_none());
|
||
|
||
let mut multi_set = std::collections::BTreeSet::new();
|
||
multi_set.insert("prelude.Show".to_string());
|
||
multi_set.insert("userlib.Show".to_string());
|
||
let multi = ResidualConstraint {
|
||
class: "prelude.Show".to_string(),
|
||
type_: Type::Con { name: "Int".to_string(), args: vec![] },
|
||
method: "show".to_string(),
|
||
candidates: Some(multi_set.clone()),
|
||
};
|
||
assert_eq!(multi.candidates, Some(multi_set));
|
||
}
|
||
|
||
/// smoke test on `parse_method_qualifier` — the helper
|
||
/// underpinning the rewritten synth Var-arm. Bare names pass
|
||
/// through with `None` qualifier; dotted names split at the last
|
||
/// dot so the prefix is the (possibly-multi-dot) class qualifier.
|
||
#[test]
|
||
fn mq2_parse_method_qualifier_smoke() {
|
||
assert_eq!(parse_method_qualifier("show"), ("show", None));
|
||
assert_eq!(
|
||
parse_method_qualifier("prelude.Show.show"),
|
||
("show", Some("prelude.Show".to_string()))
|
||
);
|
||
assert_eq!(
|
||
parse_method_qualifier("std_list.length"),
|
||
("length", Some("std_list".to_string()))
|
||
);
|
||
}
|
||
|
||
/// a multi-candidate residual with a concrete `type_`
|
||
/// resolved post-unification to `Int` is filtered against the
|
||
/// registry. Single survivor → Resolved.
|
||
#[test]
|
||
fn mq2_discharge_multi_candidate_concrete_type_single_survivor() {
|
||
let mut candidates = BTreeSet::new();
|
||
candidates.insert("prelude.Show".to_string());
|
||
candidates.insert("userlib.Show".to_string());
|
||
let residual = ResidualConstraint {
|
||
class: "prelude.Show".to_string(), // tentative
|
||
type_: Type::Con { name: "Int".to_string(), args: vec![] },
|
||
method: "show".to_string(),
|
||
candidates: Some(candidates),
|
||
};
|
||
let mut registry: BTreeMap<(String, String), ()> = BTreeMap::new();
|
||
let int_h = ailang_core::canonical::type_hash(
|
||
&Type::Con { name: "Int".to_string(), args: vec![] }
|
||
);
|
||
registry.insert(("prelude.Show".to_string(), int_h), ());
|
||
|
||
let outcome = refine_multi_candidate_residual(
|
||
&residual,
|
||
/*declared_constraints*/ &[],
|
||
/*registry*/ ®istry,
|
||
);
|
||
assert_eq!(outcome, RefineOutcome::Resolved("prelude.Show".to_string()));
|
||
}
|
||
|
||
/// zero registry survivors → NoInstance with the full
|
||
/// candidate-class set echoed back to the author.
|
||
#[test]
|
||
fn mq2_discharge_multi_candidate_concrete_type_zero_survivors_no_instance() {
|
||
let mut candidates = BTreeSet::new();
|
||
candidates.insert("prelude.Show".to_string());
|
||
candidates.insert("userlib.Show".to_string());
|
||
let residual = ResidualConstraint {
|
||
class: "prelude.Show".to_string(),
|
||
type_: Type::Con { name: "MyType".to_string(), args: vec![] },
|
||
method: "show".to_string(),
|
||
candidates: Some(candidates.clone()),
|
||
};
|
||
let registry: BTreeMap<(String, String), ()> = BTreeMap::new();
|
||
let outcome = refine_multi_candidate_residual(&residual, &[], ®istry);
|
||
match outcome {
|
||
RefineOutcome::NoInstance { candidate_classes, .. } => {
|
||
assert_eq!(candidate_classes.len(), 2);
|
||
}
|
||
other => panic!("expected NoInstance, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// multi survivors → AmbiguousMethodResolution.
|
||
#[test]
|
||
fn mq2_discharge_multi_candidate_concrete_type_multi_survivors_ambiguous() {
|
||
let mut candidates = BTreeSet::new();
|
||
candidates.insert("prelude.Show".to_string());
|
||
candidates.insert("userlib.Show".to_string());
|
||
let residual = ResidualConstraint {
|
||
class: "prelude.Show".to_string(),
|
||
type_: Type::Con { name: "Int".to_string(), args: vec![] },
|
||
method: "show".to_string(),
|
||
candidates: Some(candidates.clone()),
|
||
};
|
||
let mut registry: BTreeMap<(String, String), ()> = BTreeMap::new();
|
||
let int_h = ailang_core::canonical::type_hash(
|
||
&Type::Con { name: "Int".to_string(), args: vec![] }
|
||
);
|
||
registry.insert(("prelude.Show".to_string(), int_h.clone()), ());
|
||
registry.insert(("userlib.Show".to_string(), int_h), ());
|
||
let outcome = refine_multi_candidate_residual(&residual, &[], ®istry);
|
||
match outcome {
|
||
RefineOutcome::Ambiguous { candidate_classes, .. } => {
|
||
assert_eq!(candidate_classes.len(), 2);
|
||
}
|
||
other => panic!("expected Ambiguous, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// `Env` carries a workspace-flat
|
||
/// `method_to_candidate_classes: BTreeMap<String, BTreeSet<String>>`
|
||
/// inverse map of `class_methods`. Today's `MethodNameCollision`
|
||
/// invariant guarantees each set is singleton; post-canonical-class-form the
|
||
/// cardinality > 1 case becomes legal.
|
||
#[test]
|
||
fn mq2_env_method_to_candidate_classes_built() {
|
||
use ailang_core::ast::{ClassDef, ClassMethod, Module};
|
||
let m = Module {
|
||
schema: SCHEMA.into(),
|
||
name: "m".to_string(),
|
||
kernel: false,
|
||
imports: vec![],
|
||
defs: vec![Def::Class(ClassDef {
|
||
name: "MyShow".to_string(),
|
||
param: "a".to_string(),
|
||
superclass: None,
|
||
methods: vec![ClassMethod {
|
||
name: "myshow".to_string(),
|
||
ty: Type::Fn {
|
||
params: vec![Type::Var { name: "a".to_string() }],
|
||
param_modes: vec![ParamMode::Borrow],
|
||
ret: Box::new(Type::Con { name: "Str".to_string(), args: vec![] }),
|
||
effects: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
},
|
||
default: None,
|
||
}],
|
||
doc: None,
|
||
})],
|
||
};
|
||
let mut modules = BTreeMap::new();
|
||
modules.insert("m".into(), m);
|
||
let ws = Workspace {
|
||
entry: "m".into(),
|
||
modules,
|
||
root_dir: std::path::PathBuf::from("."),
|
||
registry: ailang_core::workspace::Registry::default(),
|
||
};
|
||
let env = build_check_env(&ws);
|
||
let candidates = env.method_to_candidate_classes.get("myshow")
|
||
.expect("method_to_candidate_classes must contain `myshow`");
|
||
assert!(candidates.contains("m.MyShow"), "candidates: {candidates:?}");
|
||
assert_eq!(candidates.len(), 1, "MethodNameCollision invariant: singleton");
|
||
}
|
||
|
||
/// `Env` carries an `active_declared_constraints` field
|
||
/// holding the active fn's POST-superclass-expansion declared
|
||
/// constraints. Empty at workspace entry; populated by `check_fn`
|
||
/// before invoking `synth` on the fn body. Consumed by `synth`'s
|
||
/// Var-arm class-method branch when calling `resolve_method_dispatch`
|
||
/// (constraint-driven filter path).
|
||
#[test]
|
||
fn mq3_env_active_declared_constraints_field_exists() {
|
||
let env = Env::default();
|
||
assert_eq!(env.active_declared_constraints.len(), 0,
|
||
"active_declared_constraints empty at default-construction");
|
||
}
|
||
|
||
/// when synth's Var-arm resolves a name to a free fn
|
||
/// (locals / caller-module-fn / implicit-import fn) AND the same
|
||
/// method name has class-method candidates in the workspace, a
|
||
/// structured warning `class-method-shadowed-by-fn` is emitted
|
||
/// via the warnings out-parameter. The fn resolution proceeds;
|
||
/// the warning surfaces the shadow so the LLM-author can
|
||
/// disambiguate via explicit class-qualified call if the shadow
|
||
/// was unintentional.
|
||
#[test]
|
||
fn mq3_class_method_shadowed_by_fn_warning_fires() {
|
||
use ailang_core::ast::{ClassDef, ClassMethod, Module};
|
||
// Synthesise a workspace with `class Show { show: a -> Str }`
|
||
// in module `clsmod` and `fn show : (Int) -> Str` in module
|
||
// `fnmod`. Module `m` imports both implicitly; `synth` on
|
||
// `Term::Var { name: "show" }` should resolve to the fn AND
|
||
// emit the shadow warning.
|
||
let clsmod = Module {
|
||
schema: SCHEMA.into(),
|
||
name: "clsmod".into(),
|
||
kernel: false,
|
||
imports: vec![],
|
||
defs: vec![Def::Class(ClassDef {
|
||
name: "Show".into(),
|
||
param: "a".into(),
|
||
superclass: None,
|
||
methods: vec![ClassMethod {
|
||
name: "show".into(),
|
||
ty: Type::Fn {
|
||
params: vec![Type::Var { name: "a".into() }],
|
||
param_modes: vec![ParamMode::Borrow],
|
||
ret: Box::new(Type::str_()),
|
||
effects: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
},
|
||
default: None,
|
||
}],
|
||
doc: None,
|
||
})],
|
||
};
|
||
let show_fn_ty = Type::Fn {
|
||
params: vec![Type::int()],
|
||
param_modes: vec![ParamMode::Borrow],
|
||
ret: Box::new(Type::str_()),
|
||
effects: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
};
|
||
let fnmod = Module {
|
||
schema: SCHEMA.into(),
|
||
name: "fnmod".into(),
|
||
kernel: false,
|
||
imports: vec![],
|
||
defs: vec![Def::Fn(FnDef {
|
||
name: "show".into(),
|
||
ty: show_fn_ty.clone(),
|
||
params: vec!["x".into()],
|
||
body: Term::Lit { lit: Literal::Str { value: "_".into() } },
|
||
doc: None,
|
||
suppress: vec![],
|
||
export: None,
|
||
})],
|
||
};
|
||
let mut modules = BTreeMap::new();
|
||
modules.insert("clsmod".into(), clsmod);
|
||
modules.insert("fnmod".into(), fnmod);
|
||
// mq.tidy T3: post-tightening, the warning fires only when at
|
||
// least one candidate class has an instance in the workspace
|
||
// registry. Ship a `clsmod.Show Int` instance so the fixture
|
||
// continues to assert positive-fire behaviour under the
|
||
// post-tidy spec rule.
|
||
let mut registry = ailang_core::workspace::Registry::default();
|
||
let int_h = ailang_core::canonical::type_hash(&Type::int());
|
||
registry.entries.insert(
|
||
("clsmod.Show".into(), int_h),
|
||
ailang_core::workspace::RegistryEntry {
|
||
instance: ailang_core::ast::InstanceDef {
|
||
class: "clsmod.Show".into(),
|
||
type_: Type::int(),
|
||
methods: vec![],
|
||
doc: None,
|
||
},
|
||
defining_module: "clsmod".into(),
|
||
},
|
||
);
|
||
let ws = Workspace {
|
||
entry: "fnmod".into(),
|
||
modules,
|
||
root_dir: std::path::PathBuf::from("."),
|
||
registry,
|
||
};
|
||
let mut env = build_check_env(&ws);
|
||
// Treat both modules as implicitly imported by `env.imports`
|
||
// (the test-only equivalent of the prelude-injection convention).
|
||
env.imports.insert("clsmod".into(), "clsmod".into());
|
||
env.imports.insert("fnmod".into(), "fnmod".into());
|
||
env.current_module = "fnmod".into();
|
||
|
||
let term = Term::Var { name: "show".into() };
|
||
let mut locals: IndexMap<String, Type> = IndexMap::new();
|
||
// loop-recur iter 2: loop-free unit-test call site — fresh
|
||
// empty loop-stack.
|
||
let mut loop_stack: Vec<Vec<(String, Type)>> = Vec::new();
|
||
let mut effects: BTreeSet<String> = BTreeSet::new();
|
||
let mut subst = Subst::default();
|
||
let mut counter: u32 = 0;
|
||
let mut residuals: Vec<ResidualConstraint> = Vec::new();
|
||
let mut free_fn_calls: Vec<FreeFnCall> = Vec::new();
|
||
let mut warnings: Vec<crate::diagnostic::Diagnostic> = Vec::new();
|
||
let _ = synth(
|
||
&term,
|
||
&env,
|
||
&mut locals,
|
||
&mut loop_stack,
|
||
&mut effects,
|
||
"test_call_site",
|
||
&mut subst,
|
||
&mut counter,
|
||
&mut residuals,
|
||
&mut free_fn_calls,
|
||
&mut warnings,
|
||
)
|
||
.expect("synth must succeed (fn wins precedence)");
|
||
assert!(
|
||
warnings.iter().any(|d| d.code == "class-method-shadowed-by-fn"),
|
||
"expected at least one class-method-shadowed-by-fn warning, got: {warnings:?}",
|
||
);
|
||
}
|
||
|
||
/// `Env.class_methods` is keyed by `(QualifiedClassName,
|
||
/// MethodName)` tuple — post-canonical-class-form the workspace can carry multiple
|
||
/// classes sharing a method name, so the key must disambiguate.
|
||
/// O(1) lookup post-dispatch via `(resolved_class, method)`.
|
||
#[test]
|
||
fn mq3_env_class_methods_tuple_keyed() {
|
||
use ailang_core::ast::{ClassDef, ClassMethod, Module};
|
||
let m = Module {
|
||
schema: SCHEMA.into(),
|
||
name: "m".to_string(),
|
||
kernel: false,
|
||
imports: vec![],
|
||
defs: vec![Def::Class(ClassDef {
|
||
name: "MyCls".to_string(),
|
||
param: "a".to_string(),
|
||
superclass: None,
|
||
methods: vec![ClassMethod {
|
||
name: "mymethod".to_string(),
|
||
ty: Type::Fn {
|
||
params: vec![Type::Var { name: "a".to_string() }],
|
||
param_modes: vec![ParamMode::Borrow],
|
||
ret: Box::new(Type::Con { name: "Str".to_string(), args: vec![] }),
|
||
effects: vec![],
|
||
ret_mode: ParamMode::Implicit,
|
||
},
|
||
default: None,
|
||
}],
|
||
doc: None,
|
||
})],
|
||
};
|
||
let mut modules = BTreeMap::new();
|
||
modules.insert("m".into(), m);
|
||
let ws = Workspace {
|
||
entry: "m".into(),
|
||
modules,
|
||
root_dir: std::path::PathBuf::from("."),
|
||
registry: ailang_core::workspace::Registry::default(),
|
||
};
|
||
let env = build_check_env(&ws);
|
||
let key = ("m.MyCls".to_string(), "mymethod".to_string());
|
||
assert!(env.class_methods.contains_key(&key),
|
||
"env.class_methods must contain {key:?}");
|
||
}
|
||
|
||
/// mq.tidy T1: discharge-time rigid-var refinement must filter
|
||
/// declared constraints by BOTH class-name match AND
|
||
/// type-unification with the residual's `type_` (spec
|
||
/// §"Constraint-discharge refinement" lines 130-138). Two
|
||
/// same-class declared constraints on different typevars must
|
||
/// be discriminated by the residual's actual typevar identity.
|
||
#[test]
|
||
fn mq_tidy_rigid_var_filter_uses_type_unification() {
|
||
use ailang_core::ast::Constraint;
|
||
|
||
// Candidate set: two distinct classes both named "Show".
|
||
let mut candidates = BTreeSet::new();
|
||
candidates.insert("prelude.Show".to_string());
|
||
candidates.insert("userlib.Show".to_string());
|
||
|
||
// Residual on Var "a".
|
||
let residual = ResidualConstraint {
|
||
class: "prelude.Show".to_string(),
|
||
type_: Type::Var { name: "a".to_string() },
|
||
method: "show".to_string(),
|
||
candidates: Some(candidates),
|
||
};
|
||
|
||
// Declared: prelude.Show a (matches residual type),
|
||
// userlib.Show b (does NOT match residual type).
|
||
let declared = vec![
|
||
Constraint {
|
||
class: "prelude.Show".to_string(),
|
||
type_: Type::Var { name: "a".to_string() },
|
||
},
|
||
Constraint {
|
||
class: "userlib.Show".to_string(),
|
||
type_: Type::Var { name: "b".to_string() },
|
||
},
|
||
];
|
||
|
||
let registry: BTreeMap<(String, String), ()> = BTreeMap::new();
|
||
let outcome = refine_multi_candidate_residual(&residual, &declared, ®istry);
|
||
|
||
assert_eq!(
|
||
outcome,
|
||
RefineOutcome::Resolved("prelude.Show".to_string()),
|
||
"type-unification leg must drop userlib.Show because its declared type (Var b) does not match the residual type (Var a)"
|
||
);
|
||
}
|
||
|
||
/// mq.tidy T2: the synth Var-arm's class-method dispatch entry
|
||
/// gates on a qualifier-shape predicate. The predicate must
|
||
/// accept three shapes — bare-method (None), bare-class
|
||
/// PascalCase qualifier (`"Show"`), module-qualified class
|
||
/// qualifier (`"prelude.Show"`) — and reject bare-fn
|
||
/// qualifiers (`"std_list"`, lowercase, no inner dot) which
|
||
/// belong to the cross-module-fn arm.
|
||
#[test]
|
||
fn mq_tidy_bare_class_qualifier_shape_accepted() {
|
||
assert!(qualifier_is_class_shape(&None));
|
||
assert!(qualifier_is_class_shape(&Some("Show".to_string())));
|
||
assert!(qualifier_is_class_shape(&Some("prelude.Show".to_string())));
|
||
|
||
// Bare fn qualifier (lowercase, no inner dot) — NOT class-shape.
|
||
assert!(!qualifier_is_class_shape(&Some("std_list".to_string())));
|
||
}
|
||
|
||
/// mq.tidy T3: the `class-method-shadowed-by-fn` warning fires
|
||
/// only when at least one candidate class has a registry
|
||
/// instance (any type) per spec §"Class-fn collisions" 161-162.
|
||
/// Without this conservative-tightening, the warning fires
|
||
/// unconditionally on `method_to_candidate_classes` presence,
|
||
/// including class methods with zero instances anywhere.
|
||
#[test]
|
||
fn mq_tidy_shadow_warning_suppressed_when_no_instance() {
|
||
let mut method_to_candidate_classes: BTreeMap<String, BTreeSet<String>> =
|
||
BTreeMap::new();
|
||
let mut foo_cands = BTreeSet::new();
|
||
foo_cands.insert("userlib.Foo".to_string());
|
||
method_to_candidate_classes.insert("foo".to_string(), foo_cands);
|
||
|
||
// Empty registry → no candidate class has an instance → false.
|
||
let empty_registry: BTreeMap<(String, String), ()> = BTreeMap::new();
|
||
assert!(!any_candidate_class_has_instance(
|
||
&method_to_candidate_classes,
|
||
&empty_registry,
|
||
"foo",
|
||
));
|
||
|
||
// Add one instance for userlib.Foo → true.
|
||
let mut registry_with_inst: BTreeMap<(String, String), ()> = BTreeMap::new();
|
||
registry_with_inst.insert(
|
||
("userlib.Foo".to_string(), "Int_hash".to_string()),
|
||
(),
|
||
);
|
||
assert!(any_candidate_class_has_instance(
|
||
&method_to_candidate_classes,
|
||
®istry_with_inst,
|
||
"foo",
|
||
));
|
||
}
|
||
|
||
/// prep.1 (kernel-extension-mechanics): a dot-qualified `Term::Var`
|
||
/// `<TypeName>.<member>` resolves to the type's home module when
|
||
/// `<TypeName>` is a known `TypeDef` in the workspace, regardless
|
||
/// of whether the consumer module imports the home module under
|
||
/// that name. Here `Maybe.from_maybe` from `consumer` must resolve
|
||
/// to `std_maybe.from_maybe` because `Maybe` is declared in
|
||
/// `std_maybe` and `consumer` does import `std_maybe`.
|
||
#[test]
|
||
fn type_scoped_member_resolves() {
|
||
let std_maybe: Module = serde_json::from_value(serde_json::json!({
|
||
"schema": SCHEMA,
|
||
"name": "std_maybe",
|
||
"imports": [],
|
||
"defs": [
|
||
{"kind": "type", "name": "Maybe", "vars": ["a"], "ctors": [
|
||
{"name": "Nothing", "fields": []},
|
||
{"name": "Just", "fields": [{"k": "var", "name": "a"}]}
|
||
]},
|
||
{"kind": "fn", "name": "from_maybe",
|
||
"type": {"k": "forall", "vars": ["a"], "constraints": [], "body":
|
||
{"k": "fn", "params": [{"k": "var", "name": "a"},
|
||
{"k": "con", "name": "Maybe", "args": [{"k": "var", "name": "a"}]}],
|
||
"ret": {"k": "var", "name": "a"}, "effects": []}},
|
||
"params": ["default", "m"],
|
||
"body": {"t": "var", "name": "default"}}
|
||
]
|
||
})).unwrap();
|
||
// Consumer calls `Maybe.from_maybe` with arg types qualified to
|
||
// `std_maybe.Maybe` (post-resolution the type-scoped form
|
||
// resolves to `std_maybe.from_maybe`, but the ctor arg uses
|
||
// the qualified form so the bare-cross-module-type-validator
|
||
// is satisfied independently of Task 2's widening — Task 1's
|
||
// success criterion is only that the dot-qualified Var arm
|
||
// resolves through the TypeDef-first ladder).
|
||
let consumer: Module = serde_json::from_value(serde_json::json!({
|
||
"schema": SCHEMA,
|
||
"name": "consumer",
|
||
"imports": [{"module": "std_maybe"}],
|
||
"defs": [
|
||
{"kind": "fn", "name": "f",
|
||
"type": {"k": "fn", "params": [{"k": "con", "name": "Int"}],
|
||
"ret": {"k": "con", "name": "Int"}, "effects": []},
|
||
"params": ["x"],
|
||
"body": {"t": "app",
|
||
"fn": {"t": "var", "name": "Maybe.from_maybe"},
|
||
"args": [{"t": "var", "name": "x"},
|
||
{"t": "ctor", "type": "std_maybe.Maybe",
|
||
"ctor": "Nothing", "args": []}]}}
|
||
]
|
||
})).unwrap();
|
||
let mut modules = BTreeMap::new();
|
||
modules.insert("std_maybe".to_string(), std_maybe);
|
||
modules.insert("consumer".to_string(), consumer);
|
||
let ws = Workspace {
|
||
entry: "consumer".into(),
|
||
modules,
|
||
root_dir: std::path::PathBuf::from("."),
|
||
registry: ailang_core::workspace::Registry::default(),
|
||
};
|
||
let diags = check_workspace(&ws);
|
||
assert!(
|
||
diags.is_empty(),
|
||
"type-scoped Maybe.from_maybe must resolve cleanly, got {diags:?}"
|
||
);
|
||
}
|
||
|
||
/// prep.1 (kernel-extension-mechanics): a dot-qualified `Term::Var`
|
||
/// `<TypeName>.<member>` where `<TypeName>` is a known `TypeDef`
|
||
/// but `<member>` is not a def in the type's home module fires
|
||
/// `TypeScopedMemberNotFound`.
|
||
#[test]
|
||
fn type_scoped_member_not_found() {
|
||
let std_maybe: Module = serde_json::from_value(serde_json::json!({
|
||
"schema": SCHEMA,
|
||
"name": "std_maybe",
|
||
"imports": [],
|
||
"defs": [
|
||
{"kind": "type", "name": "Maybe", "vars": ["a"], "ctors": [
|
||
{"name": "Nothing", "fields": []},
|
||
{"name": "Just", "fields": [{"k": "var", "name": "a"}]}
|
||
]}
|
||
]
|
||
})).unwrap();
|
||
let consumer: Module = serde_json::from_value(serde_json::json!({
|
||
"schema": SCHEMA,
|
||
"name": "consumer",
|
||
"imports": [{"module": "std_maybe"}],
|
||
"defs": [
|
||
{"kind": "fn", "name": "f",
|
||
"type": {"k": "fn", "params": [],
|
||
"ret": {"k": "con", "name": "Int"}, "effects": []},
|
||
"params": [],
|
||
"body": {"t": "var", "name": "Maybe.bogus"}}
|
||
]
|
||
})).unwrap();
|
||
let mut modules = BTreeMap::new();
|
||
modules.insert("std_maybe".to_string(), std_maybe);
|
||
modules.insert("consumer".to_string(), consumer);
|
||
let ws = Workspace {
|
||
entry: "consumer".into(),
|
||
modules,
|
||
root_dir: std::path::PathBuf::from("."),
|
||
registry: ailang_core::workspace::Registry::default(),
|
||
};
|
||
let diags = check_workspace(&ws);
|
||
assert!(
|
||
diags.iter().any(|d| d.code == "type-scoped-member-not-found"),
|
||
"expected diagnostic code `type-scoped-member-not-found`, got {diags:?}"
|
||
);
|
||
}
|
||
|
||
/// prep.1 (kernel-extension-mechanics): a dot-qualified `Term::Var`
|
||
/// whose receiver is neither a known `TypeDef` nor an imported
|
||
/// module fires `TypeScopedReceiverNotAType`.
|
||
#[test]
|
||
fn type_scoped_receiver_not_a_type() {
|
||
let consumer: Module = serde_json::from_value(serde_json::json!({
|
||
"schema": SCHEMA,
|
||
"name": "consumer",
|
||
"imports": [],
|
||
"defs": [
|
||
{"kind": "fn", "name": "f",
|
||
"type": {"k": "fn", "params": [],
|
||
"ret": {"k": "con", "name": "Int"}, "effects": []},
|
||
"params": [],
|
||
"body": {"t": "var", "name": "SomethingRandom.x"}}
|
||
]
|
||
})).unwrap();
|
||
let mut modules = BTreeMap::new();
|
||
modules.insert("consumer".to_string(), consumer);
|
||
let ws = Workspace {
|
||
entry: "consumer".into(),
|
||
modules,
|
||
root_dir: std::path::PathBuf::from("."),
|
||
registry: ailang_core::workspace::Registry::default(),
|
||
};
|
||
let diags = check_workspace(&ws);
|
||
assert!(
|
||
diags.iter().any(|d| d.code == "type-scoped-receiver-not-a-type"),
|
||
"expected diagnostic code `type-scoped-receiver-not-a-type`, got {diags:?}"
|
||
);
|
||
}
|
||
|
||
/// prep.2 (kernel-extension-mechanics): the spec's worked example.
|
||
/// A `(new Counter 42)` call resolves to the home module's `new`
|
||
/// def, with `Counter` declared locally and `new : (Int) -> Counter`
|
||
/// constructing a `Counter` via `(term-ctor Counter MkCounter n)`.
|
||
/// Property: synth's Term::New arm walks the resolution + arity +
|
||
/// kind checks, substitutes type-vars (none here — `new` is
|
||
/// monomorphic), and unifies each value-arg against the
|
||
/// corresponding (substituted) param type.
|
||
#[test]
|
||
fn new_resolves_via_type_scope() {
|
||
let m: Module = serde_json::from_value(serde_json::json!({
|
||
"schema": SCHEMA,
|
||
"name": "new_demo",
|
||
"imports": [],
|
||
"defs": [
|
||
{"kind": "type", "name": "Counter", "ctors": [
|
||
{"name": "MkCounter", "fields": [{"k": "con", "name": "Int"}]}
|
||
]},
|
||
{"kind": "fn", "name": "new",
|
||
"type": {"k": "fn",
|
||
"params": [{"k": "con", "name": "Int"}],
|
||
"ret": {"k": "con", "name": "Counter"},
|
||
"effects": []},
|
||
"params": ["n"],
|
||
"body": {"t": "ctor", "type": "Counter", "ctor": "MkCounter",
|
||
"args": [{"t": "var", "name": "n"}]}},
|
||
{"kind": "fn", "name": "main",
|
||
"type": {"k": "fn", "params": [],
|
||
"ret": {"k": "con", "name": "Counter"},
|
||
"effects": []},
|
||
"params": [],
|
||
"body": {"t": "new", "type": "Counter",
|
||
"args": [
|
||
{"kind": "value", "value":
|
||
{"t": "lit", "lit": {"kind": "int", "value": 42}}}
|
||
]}}
|
||
]
|
||
})).unwrap();
|
||
let mut modules = BTreeMap::new();
|
||
modules.insert("new_demo".to_string(), m);
|
||
let ws = Workspace {
|
||
entry: "new_demo".into(),
|
||
modules,
|
||
root_dir: std::path::PathBuf::from("."),
|
||
registry: ailang_core::workspace::Registry::default(),
|
||
};
|
||
let diags = check_workspace(&ws);
|
||
assert!(
|
||
diags.is_empty(),
|
||
"(new Counter 42) must check cleanly; got {diags:?}"
|
||
);
|
||
}
|
||
|
||
/// raw-buf.4: a `(new T arg)` where T's home module declares no
|
||
/// `new` def is rejected. The Term::New desugar (raw-buf.4)
|
||
/// rewrites `(new T …)` to `(app T.new …)` *before* check, so the
|
||
/// rejection now arrives through the type-scoped resolution ladder
|
||
/// as `type-scoped-member-not-found` ("type `T` has no member
|
||
/// `new`") rather than the prep.2 `new-type-not-constructible`
|
||
/// code that synth's now-bypassed Term::New arm produced. The
|
||
/// rejection condition is identical (no constructor for T) and the
|
||
/// new diagnostic carries strictly more context (type + member +
|
||
/// home_module).
|
||
#[test]
|
||
fn new_type_not_constructible() {
|
||
let m: Module = serde_json::from_value(serde_json::json!({
|
||
"schema": SCHEMA,
|
||
"name": "no_new",
|
||
"imports": [],
|
||
"defs": [
|
||
{"kind": "type", "name": "T", "ctors": [
|
||
{"name": "MkT", "fields": []}
|
||
]},
|
||
// No `new` def for T.
|
||
{"kind": "fn", "name": "main",
|
||
"type": {"k": "fn", "params": [],
|
||
"ret": {"k": "con", "name": "T"},
|
||
"effects": []},
|
||
"params": [],
|
||
"body": {"t": "new", "type": "T",
|
||
"args": [
|
||
{"kind": "value", "value":
|
||
{"t": "lit", "lit": {"kind": "int", "value": 42}}}
|
||
]}}
|
||
]
|
||
})).unwrap();
|
||
let mut modules = BTreeMap::new();
|
||
modules.insert("no_new".to_string(), m);
|
||
let ws = Workspace {
|
||
entry: "no_new".into(),
|
||
modules,
|
||
root_dir: std::path::PathBuf::from("."),
|
||
registry: ailang_core::workspace::Registry::default(),
|
||
};
|
||
let diags = check_workspace(&ws);
|
||
assert!(
|
||
diags.iter().any(|d| d.code == "type-scoped-member-not-found"),
|
||
"expected the desugared `(app T.new …)` to be rejected with \
|
||
`type-scoped-member-not-found` (no `new` member on T), got {diags:?}"
|
||
);
|
||
}
|
||
|
||
/// raw-buf.4: a `(new T value)` against a polymorphic
|
||
/// `new : forall a. (a) -> T a` checks cleanly — the element type
|
||
/// `a` is recovered by inference from use (here the `main` return
|
||
/// `(con T (con Int))` fixes `a = Int`). This pins the desugar's
|
||
/// type-arg-dropping design: `(new T …)` rewrites to
|
||
/// `(app T.new …)` carrying only the value args, with no NewArg
|
||
/// kind distinction surviving into check. The prep.2
|
||
/// `new-arg-kind-mismatch` diagnostic is obsoleted by this design
|
||
/// (there are no longer type-args at a `new` site to mismatch);
|
||
/// the call shape that used to trip it is now the canonical,
|
||
/// accepted form.
|
||
#[test]
|
||
fn new_arg_kind_mismatch_value_where_type() {
|
||
let m: Module = serde_json::from_value(serde_json::json!({
|
||
"schema": SCHEMA,
|
||
"name": "kind_mismatch",
|
||
"imports": [],
|
||
"defs": [
|
||
{"kind": "type", "name": "T", "vars": ["a"], "ctors": [
|
||
{"name": "MkT", "fields": [{"k": "var", "name": "a"}]}
|
||
]},
|
||
// `new : forall a. (a) -> T a`. The new-site at `main`
|
||
// wrongly supplies a single Value-arg where the Forall
|
||
// expects one Type-arg (then a Value-arg).
|
||
{"kind": "fn", "name": "new",
|
||
"type": {"k": "forall", "vars": ["a"], "constraints": [],
|
||
"body": {"k": "fn",
|
||
"params": [{"k": "var", "name": "a"}],
|
||
"ret": {"k": "con", "name": "T",
|
||
"args": [{"k": "var", "name": "a"}]},
|
||
"effects": []}},
|
||
"params": ["x"],
|
||
"body": {"t": "ctor", "type": "T", "ctor": "MkT",
|
||
"args": [{"t": "var", "name": "x"}]}},
|
||
{"kind": "fn", "name": "main",
|
||
"type": {"k": "fn", "params": [],
|
||
"ret": {"k": "con", "name": "T",
|
||
"args": [{"k": "con", "name": "Int"}]},
|
||
"effects": []},
|
||
"params": [],
|
||
// Wrong: only one NewArg::Value supplied for a sig
|
||
// that needs 1 Type-arg + 1 Value-arg.
|
||
"body": {"t": "new", "type": "T", "args": [
|
||
{"kind": "value", "value":
|
||
{"t": "lit", "lit": {"kind": "int", "value": 42}}}
|
||
]}}
|
||
]
|
||
})).unwrap();
|
||
let mut modules = BTreeMap::new();
|
||
modules.insert("kind_mismatch".to_string(), m);
|
||
let ws = Workspace {
|
||
entry: "kind_mismatch".into(),
|
||
modules,
|
||
root_dir: std::path::PathBuf::from("."),
|
||
registry: ailang_core::workspace::Registry::default(),
|
||
};
|
||
let diags = check_workspace(&ws);
|
||
assert!(
|
||
diags.is_empty(),
|
||
"(new T 42) against `forall a. (a) -> T a` must check cleanly — \
|
||
the desugar drops type-args and `a` is inferred from use; got {diags:?}"
|
||
);
|
||
}
|
||
|
||
/// prep.3 (kernel-extension-mechanics): `param-in` enforcement
|
||
/// rejects a `Type::Con` whose positional arg's outermost
|
||
/// type-name is not in the allowed set. Property: a TypeDef
|
||
/// carrying `param-in = {"a": {"Int", "Float"}}` referenced as
|
||
/// `(con StubT (con Str))` fires `ParamNotInRestrictedSet` with
|
||
/// the right type-name, var, found, and allowed-set payload.
|
||
#[test]
|
||
fn param_in_rejects_disallowed_type() {
|
||
let m: Module = serde_json::from_value(serde_json::json!({
|
||
"schema": SCHEMA,
|
||
"name": "k",
|
||
"defs": [
|
||
{"kind": "type", "name": "StubT", "vars": ["a"],
|
||
"ctors": [{"name": "Stub", "fields": [{"k": "var", "name": "a"}]}],
|
||
"param-in": {"a": ["Int", "Float"]}},
|
||
// Reference `(con StubT (con Str))` in a fn return
|
||
// type — well-formedness check walks the return type.
|
||
{"kind": "fn", "name": "bad",
|
||
"type": {"k": "fn", "params": [],
|
||
"ret": {"k": "con", "name": "StubT",
|
||
"args": [{"k": "con", "name": "Str"}]},
|
||
"effects": []},
|
||
"params": [],
|
||
"body": {"t": "lit", "lit": {"kind": "unit"}}}
|
||
]
|
||
})).unwrap();
|
||
let mut modules = BTreeMap::new();
|
||
modules.insert("k".to_string(), m);
|
||
let ws = Workspace {
|
||
entry: "k".into(),
|
||
modules,
|
||
root_dir: std::path::PathBuf::from("."),
|
||
registry: ailang_core::workspace::Registry::default(),
|
||
};
|
||
let diags = check_workspace(&ws);
|
||
assert!(
|
||
diags.iter().any(|d| d.code == "param-not-in-restricted-set"),
|
||
"expected diagnostic code `param-not-in-restricted-set`, got {diags:?}"
|
||
);
|
||
}
|
||
|
||
/// fieldtest B3 (raw-buf, #7): the rendered `param-not-in-restricted-set`
|
||
/// message must name the *allowed set*, not only the offending type.
|
||
/// Property per design/models/0007-kernel-extensions.md §4
|
||
/// ("ParamNotInRestrictedSet names the offending type AND the allowed
|
||
/// set"): a downstream author rejected for `(con StubT (con Str))`
|
||
/// under `param-in = {"a": {"Int", "Float"}}` must be told `Int` and
|
||
/// `Float` are the permitted choices, so the message is actionable.
|
||
#[test]
|
||
fn param_in_reject_message_names_allowed_set() {
|
||
let m: Module = serde_json::from_value(serde_json::json!({
|
||
"schema": SCHEMA,
|
||
"name": "k",
|
||
"defs": [
|
||
{"kind": "type", "name": "StubT", "vars": ["a"],
|
||
"ctors": [{"name": "Stub", "fields": [{"k": "var", "name": "a"}]}],
|
||
"param-in": {"a": ["Int", "Float"]}},
|
||
{"kind": "fn", "name": "bad",
|
||
"type": {"k": "fn", "params": [],
|
||
"ret": {"k": "con", "name": "StubT",
|
||
"args": [{"k": "con", "name": "Str"}]},
|
||
"effects": []},
|
||
"params": [],
|
||
"body": {"t": "lit", "lit": {"kind": "unit"}}}
|
||
]
|
||
})).unwrap();
|
||
let mut modules = BTreeMap::new();
|
||
modules.insert("k".to_string(), m);
|
||
let ws = Workspace {
|
||
entry: "k".into(),
|
||
modules,
|
||
root_dir: std::path::PathBuf::from("."),
|
||
registry: ailang_core::workspace::Registry::default(),
|
||
};
|
||
let diags = check_workspace(&ws);
|
||
let d = diags
|
||
.iter()
|
||
.find(|d| d.code == "param-not-in-restricted-set")
|
||
.unwrap_or_else(|| panic!("expected param-not-in-restricted-set, got {diags:?}"));
|
||
assert!(
|
||
d.message.contains("Int") && d.message.contains("Float"),
|
||
"message must name the allowed set {{Int, Float}}, got: {:?}",
|
||
d.message
|
||
);
|
||
}
|
||
|
||
/// prep.3 (kernel-extension-mechanics): `param-in` enforcement
|
||
/// accepts a type-arg inside the allowed set. Same TypeDef as
|
||
/// the RED test, but `(con StubT (con Int))` — Int is in
|
||
/// {Int, Float}, so the check passes.
|
||
#[test]
|
||
fn param_in_accepts_allowed_type() {
|
||
let m: Module = serde_json::from_value(serde_json::json!({
|
||
"schema": SCHEMA,
|
||
"name": "k",
|
||
"defs": [
|
||
{"kind": "type", "name": "StubT", "vars": ["a"],
|
||
"ctors": [{"name": "Stub", "fields": [{"k": "var", "name": "a"}]}],
|
||
"param-in": {"a": ["Int", "Float"]}},
|
||
{"kind": "fn", "name": "good",
|
||
"type": {"k": "fn", "params": [],
|
||
"ret": {"k": "con", "name": "StubT",
|
||
"args": [{"k": "con", "name": "Int"}]},
|
||
"effects": []},
|
||
"params": [],
|
||
"body": {"t": "lit", "lit": {"kind": "unit"}}}
|
||
]
|
||
})).unwrap();
|
||
let mut modules = BTreeMap::new();
|
||
modules.insert("k".to_string(), m);
|
||
let ws = Workspace {
|
||
entry: "k".into(),
|
||
modules,
|
||
root_dir: std::path::PathBuf::from("."),
|
||
registry: ailang_core::workspace::Registry::default(),
|
||
};
|
||
let diags = check_workspace(&ws);
|
||
assert!(
|
||
!diags.iter().any(|d| d.code == "param-not-in-restricted-set"),
|
||
"expected NO param-not-in-restricted-set diagnostic, got {diags:?}"
|
||
);
|
||
}
|
||
|
||
/// Fieldtest F1 (kernel-extension-mechanics, 2026-05-28):
|
||
/// a type-scoped call `<Type>.<member>` made from inside `<Type>`'s
|
||
/// own home module must check cleanly. The receiver `value` is a
|
||
/// `Def::Fn` in the same module as `Counter`; calling it via
|
||
/// `(app Counter.value c)` is the canonical form per
|
||
/// `docs/specs/0048-kernel-extension-mechanics.md` § "Iteration
|
||
/// prep.1 — Canonical form decision", which does NOT carve out
|
||
/// same-module sites as forbidden. Today this rejects with
|
||
/// `[type-mismatch] expected kem.Counter, got Counter` because the
|
||
/// dot-qualified arm at `synth` qualifies the resolved fn's param
|
||
/// type to `<this_module>.Counter` via `qualify_local_types`, while
|
||
/// the local `Term::Ctor` synth (`Term::Ctor` arm in `synth_term`)
|
||
/// returns the bare `Counter` form for the same-module type — the
|
||
/// two halves of the resolver disagree on whether
|
||
/// `<this_module>.T` and bare `T` are the same type.
|
||
#[test]
|
||
fn type_scoped_call_in_same_module_resolves() {
|
||
// Exact shape of examples/fieldtest/kem_2b_min_repro.ail —
|
||
// local Counter ADT with a `value` projector, called from
|
||
// `main` via `(app Counter.value c)` where `c` is built by
|
||
// `(term-ctor Counter MkCounter 41)`.
|
||
let m: Module = serde_json::from_value(serde_json::json!({
|
||
"schema": SCHEMA,
|
||
"name": "kem",
|
||
"imports": [],
|
||
"defs": [
|
||
{"kind": "type", "name": "Counter", "ctors": [
|
||
{"name": "MkCounter", "fields": [{"k": "con", "name": "Int"}]}
|
||
]},
|
||
// `value` projects a Counter to Int. Body is a
|
||
// constant — the bug is at the call site, the body
|
||
// shape is irrelevant.
|
||
{"kind": "fn", "name": "value",
|
||
"type": {"k": "fn",
|
||
"params": [{"k": "con", "name": "Counter"}],
|
||
"ret": {"k": "con", "name": "Int"},
|
||
"effects": []},
|
||
"params": ["c"],
|
||
"body": {"t": "lit", "lit": {"kind": "int", "value": 0}}},
|
||
// `main` builds a Counter locally via term-ctor and
|
||
// calls `Counter.value` on it from the same module.
|
||
// Per spec § prep.1 "Canonical form decision" this
|
||
// is the canonical form and must check.
|
||
{"kind": "fn", "name": "main",
|
||
"type": {"k": "fn", "params": [],
|
||
"ret": {"k": "con", "name": "Int"},
|
||
"effects": []},
|
||
"params": [],
|
||
"body": {"t": "let", "name": "c",
|
||
"value": {"t": "ctor", "type": "Counter",
|
||
"ctor": "MkCounter",
|
||
"args": [{"t": "lit",
|
||
"lit": {"kind": "int", "value": 41}}]},
|
||
"body": {"t": "app",
|
||
"fn": {"t": "var", "name": "Counter.value"},
|
||
"args": [{"t": "var", "name": "c"}]}}}
|
||
]
|
||
})).unwrap();
|
||
let mut modules = BTreeMap::new();
|
||
modules.insert("kem".to_string(), m);
|
||
let ws = Workspace {
|
||
entry: "kem".into(),
|
||
modules,
|
||
root_dir: std::path::PathBuf::from("."),
|
||
registry: ailang_core::workspace::Registry::default(),
|
||
};
|
||
let diags = check_workspace(&ws);
|
||
assert!(
|
||
diags.is_empty(),
|
||
"same-module type-scoped call `Counter.value c` must check \
|
||
cleanly (the canonical-form rule does not carve out \
|
||
same-module sites); got {diags:?}"
|
||
);
|
||
}
|
||
|
||
/// Fieldtest F3 (kernel-extension-mechanics, 2026-05-28):
|
||
/// a `(con StubT (con Int))` reference in a consumer's type
|
||
/// signature, where `StubT` lives in a kernel-tier module
|
||
/// (`kernel: true`) and the consumer has NO explicit
|
||
/// `(import kernel_stub)`, must check cleanly. Spec §"Iteration
|
||
/// prep.3" promises kernel-tier types are accessible bare in
|
||
/// type position. The workspace pre-pass `qualify_workspace_types`
|
||
/// rewrites bare `StubT` to qualified `kernel_stub.StubT`; the
|
||
/// type-validity check at `synth_type` (line ~1968) then rejects
|
||
/// `kernel_stub` because only `prelude` is force-injected into
|
||
/// `env.imports` / `env.module_imports` (lines 1667–1671 and
|
||
/// 1797–1800) — kernel-tier modules OTHER than prelude are not
|
||
/// added to the per-module import map, so the qualifier they
|
||
/// receive from the pre-pass is unresolvable.
|
||
#[test]
|
||
fn kernel_tier_module_qualifier_resolves_without_explicit_import() {
|
||
let kernel_stub: Module = serde_json::from_value(serde_json::json!({
|
||
"schema": SCHEMA,
|
||
"name": "kernel_stub",
|
||
"kernel": true,
|
||
"imports": [],
|
||
"defs": [
|
||
{"kind": "type", "name": "StubT", "vars": ["a"],
|
||
"ctors": [{"name": "Stub", "fields": [{"k": "var", "name": "a"}]}]}
|
||
]
|
||
})).unwrap();
|
||
// Consumer references `StubT` bare in a type signature — no
|
||
// `(import kernel_stub)` declaration. Per spec § prep.3 the
|
||
// kernel-tier flag should make this resolve.
|
||
let consumer: Module = serde_json::from_value(serde_json::json!({
|
||
"schema": SCHEMA,
|
||
"name": "consumer",
|
||
"imports": [],
|
||
"defs": [
|
||
// The bug fires in the type-validity check on the
|
||
// declared param type `(con StubT (con Int))` — the
|
||
// body shape is irrelevant. A trivial Int-returning
|
||
// body keeps the test focused on the qualifier-
|
||
// resolution path.
|
||
{"kind": "fn", "name": "unwrap_int",
|
||
"type": {"k": "fn",
|
||
"params": [{"k": "con", "name": "StubT",
|
||
"args": [{"k": "con", "name": "Int"}]}],
|
||
"ret": {"k": "con", "name": "Int"},
|
||
"effects": []},
|
||
"params": ["s"],
|
||
"body": {"t": "lit", "lit": {"kind": "int", "value": 0}}}
|
||
]
|
||
})).unwrap();
|
||
let mut modules = BTreeMap::new();
|
||
modules.insert("kernel_stub".to_string(), kernel_stub);
|
||
modules.insert("consumer".to_string(), consumer);
|
||
let ws = Workspace {
|
||
entry: "consumer".into(),
|
||
modules,
|
||
root_dir: std::path::PathBuf::from("."),
|
||
registry: ailang_core::workspace::Registry::default(),
|
||
};
|
||
let diags = check_workspace(&ws);
|
||
assert!(
|
||
diags.is_empty(),
|
||
"kernel-tier `StubT` referenced bare in a consumer type \
|
||
signature must resolve without an explicit \
|
||
`(import kernel_stub)` declaration; got {diags:?}"
|
||
);
|
||
}
|
||
}
|