Files
AILang/crates/ailang-codegen/src/subst.rs
T
Brummel b586999e81 iter prep.1-type-scoped-namespacing (DONE 5/5): TypeDef-first resolution + workspace pre-pass — closes #31
First iteration of the kernel-extension-mechanics milestone. Ships
the type-scoped `<TypeName>.<member>` resolution path as the
canonical form for type-associated operations, narrows the
`BareCrossModuleTypeRef` / `BadCrossModuleTypeRef` diagnostics from
"bare = strictly local" to "bare = in-scope by any path", migrates
12 std-library example fixtures, and introduces a workspace-wide
normalisation pre-pass `prepare_workspace_for_check` shared between
`check_workspace` and `monomorphise_workspace`.

Architectural discovery during implementation: the plan covered the
`Term::Var` dot-qualified resolver layer plus the workspace
validator's bare-name acceptance, but the migration of bare-form
fixtures exposed five sites where bare vs. qualified type-names
needed symmetric treatment — `Term::Ctor` resolution, `Type::Con`
well-formedness, mono's poly-free-fn name/constraint-count
enumeration, codegen's `lookup_ctor_by_type` bare-name path, and
the upstream desugar-then-qualify composition. Rather than
scattering TypeDef-first ladders across each site, the implementer
centralised the work into one pre-pass that walks every consumer
module's `Type::Con.name` and `Term::Ctor.type_name`, rewriting
bare cross-module references to their qualified `<home>.<Type>`
form. This is symmetric to the pre-existing `qualify_local_types`
(owner-side); the new pre-pass is the consumer-side mirror.
Downstream passes see qualified Types regardless of authoring form.
The TypeDef-first ladder still lives in `synth`'s `Term::Var` arm
because `<TypeName>.<member>` is term-position-only — `Maybe.from_maybe`
is a Var, not a Type expression, and the pre-pass does not rewrite
Var names.

Alternatives considered:

(a) Add TypeDef-first ladder at every resolution site separately
    (the plan's implicit assumption). Rejected: O(N) extension
    sites, each carrying the same workspace-walking logic; the
    pre-pass version is O(1) — one pass, every downstream consumer
    benefits.
(b) BLOCKED + spec re-brainstorm. Rejected: the architecture
    extension is consistent with prep.1's thesis (bare type-name
    resolves to the workspace-wide TypeDef) and forward-compatible
    with prep.2 (Term::New.type_name falls under the same rewrite)
    and prep.3 (kernel-tier TypeDefs enter the workspace map
    automatically). No design regression to bounce back over.

Spec updated to document the realisation mechanism honestly: the
"Realisation mechanism — workspace pre-pass" subsection clarifies
that the resolver-level semantics described in "Implementation
shape" are the user-facing contract, and the actual code path is
the pre-pass.

Verification:

- `cargo test --workspace`: ALL GREEN. 87 e2e + every crate's unit
  + integration tests pass with no regressions.
- Three NEW in-source tests pin Task 1's resolver paths:
  `type_scoped_member_resolves`, `type_scoped_member_not_found`,
  `type_scoped_receiver_not_a_type`.
- One NEW workspace test pins the narrowed validator:
  `ct1_validator_accepts_bare_with_explicit_import`.
- One renamed-and-flipped existing test:
  `ct1_validator_rejects_bare_xmod_with_import_candidate` →
  `ct1_validator_accepts_bare_xmod_with_import_candidate` (the
  bare-with-import path is now ACCEPTED).
- One NEW companion test for the workspace-wide ctor lookup:
  `ct2_term_ctor_bare_cross_module_via_workspace_resolves`.
- Two pre-existing tests' assertions updated for the new error
  wording: `ct1_check_cli::check_human_mode_emits_actionable_message_to_stderr`
  and `crates/ailang-check/tests/workspace.rs::unknown_module_prefix_is_reported`.
- 12 migrated `.ail` fixtures verified via the existing e2e
  suite (each fixture is the test runner's target for an existing
  `build_and_run` assertion).
- Negative fixture `ct_2_bare_cross_module.ail` semantically
  preserved: dropped its `(import std_maybe)` so bare `Maybe` is
  out-of-scope under the narrowed rule and still fires
  `BareCrossModuleTypeRef`.

Concerns:

- The pre-pass introduces a new architectural layer (consumer-side
  qualification) that the spec did not originally anticipate. Spec
  amendment in this commit documents the layer. Future iterations
  reference `prepare_workspace_for_check` as established
  infrastructure.
- `examples/test_ct1_bare_xmod_rejected.ail.json` switched its
  offending name from bare `Ordering` (which under the prep.1
  semantics may now resolve via implicit prelude) to a still-
  unresolvable `Mystery_Type`. The CLI test's intent (assert that
  a human-mode `ail check` exits non-zero on a still-RED case) is
  preserved.

Milestone status: kernel-extension-mechanics (Gitea #6) advances
1/3 iters. Next: prep.2 (`Term::New` construct) issue #32.
2026-05-28 14:43:03 +02:00

248 lines
11 KiB
Rust

//! Type substitution + unification helpers for monomorphisation.
//!
//! Free functions extracted from `lib.rs` during the 18g tidy split.
//! The four-step pipeline is: `derive_substitution` walks declared
//! params against actual arg types, calling `unify_for_subst` to bind
//! `Type::Var`s; `apply_subst_to_type` / `apply_subst_to_term`
//! specialise a polymorphic def under that binding;
//! `qualify_local_types_codegen` rewrites bare ADT names into
//! `module.Type` form when a sig crosses an import boundary;
//! `descriptor_for_subst` produces the stable mangling suffix used in
//! the specialised symbol's name.
use ailang_core::ast::*;
use std::collections::{BTreeMap, BTreeSet};
use super::{CodegenError, Result};
/// derive a name → concrete-type substitution from the
/// declared params of a `Forall` body and the actual arg types at a
/// call site. Walks both sides in parallel; whenever a `Type::Var`
/// (rigid name) appears on the params side, binds it to the
/// corresponding concrete type. Conflicts (same var bound to two
/// different types) surface as an internal error — the typechecker
/// would already have rejected such a call.
pub(crate) fn derive_substitution(
vars: &[String],
params: &[Type],
arg_tys: &[Type],
) -> Result<BTreeMap<String, Type>> {
if params.len() != arg_tys.len() {
return Err(CodegenError::Internal(format!(
"derive_substitution: arity mismatch ({} params vs {} args)",
params.len(),
arg_tys.len(),
)));
}
let var_set: BTreeSet<&str> = vars.iter().map(|s| s.as_str()).collect();
let mut subst: BTreeMap<String, Type> = BTreeMap::new();
for (p, a) in params.iter().zip(arg_tys.iter()) {
unify_for_subst(p, a, &var_set, &mut subst)?;
}
// Any forall var not pinned by the args is left unbound. For the
// MVP this is an error — we can't specialise without a concrete
// type. The typechecker's body should have constrained it already
// through return-type unification, but at the call site we only
// see args; if needed, callers can extend this with expected-ret
// info.
// a forall var that the args couldn't pin (e.g.
// `is_none(Nothing) : forall a. (Maybe a) -> Bool` — `a` is
// genuinely unobservable from the args alone) defaults to `Unit`.
// The specialised body must not actually read an `a`-typed value,
// or it would have failed type-checking; a dummy concrete type is
// sound and lets monomorphisation proceed deterministically. The
// descriptor uses the same default, so all such call sites
// converge on a single specialisation.
for v in vars {
if !subst.contains_key(v) {
subst.insert(v.clone(), Type::unit());
}
}
Ok(subst)
}
/// Walks `param` and `arg` in parallel, treating any `Type::Var { name }`
/// on the param side whose name is in `vars` as an unknown to be bound
/// in `subst`. Identical concrete shapes pass through; structural
/// mismatches yield an internal error.
pub(crate) fn unify_for_subst(
param: &Type,
arg: &Type,
vars: &BTreeSet<&str>,
subst: &mut BTreeMap<String, Type>,
) -> Result<()> {
// A `$u`-prefixed var is a
// synth-only wildcard produced by `synth_arg_type` for nullary
// ctors of a parameterised ADT (e.g. `Nil : List<$u>`). It
// carries no real constraint — accept without binding so a
// sibling arg can pin the type var instead. Without this,
// `Cons(Int, Nil)` synth would unify `a = Int` (from head) and
// then `a = $u` (from tail's recursive `List<a>` slot) and
// falsely error.
//
// 15g-aux: the early-return must accept `$u` on **either** side.
// `$u` enters in arg position from synth, but the prev-binding
// recursion below (`unify_for_subst(&prev, arg, ...)`) can swap
// a `$u` onto the param side when a previously-bound type is
// unified against a fresher arg whose roles differ. Reduced
// repro: `length [Left 1, Right 10]` — `a` first binds to
// `Either<Int, $u>` from `Left 1`, then a recursive unification
// against `Either<$u, Int>` from `Right 10` lands `$u` in the
// param-pos[1] slot. Symmetric early-return is correct because
// `$u` is a synth-only wildcard regardless of which side carries
// it after the prev-binding swap.
if let Type::Var { name } = arg {
if name.starts_with("$u") {
return Ok(());
}
}
if let Type::Var { name } = param {
if name.starts_with("$u") {
return Ok(());
}
}
match (param, arg) {
(Type::Var { name }, _) if vars.contains(name.as_str()) => {
if let Some(prev) = subst.get(name).cloned() {
// the previously-bound type may be more
// concrete than `arg` (e.g. `prev = List<Int>` from a
// sibling binding, `arg = List<$u>` from a synth-
// wildcard nullary ctor). Use recursive unification
// instead of strict equality so the inner `$u`
// wildcard matches `Int`. The previous strict-
// equality check rejected such overlaps as bogus
// duplicate bindings.
return unify_for_subst(&prev, arg, vars, subst);
}
subst.insert(name.clone(), arg.clone());
Ok(())
}
(
Type::Con { name: pn, args: pa },
Type::Con { name: an, args: aa },
) if pn == an && pa.len() == aa.len() => {
for (p, a) in pa.iter().zip(aa.iter()) {
unify_for_subst(p, a, vars, subst)?;
}
Ok(())
}
(
Type::Fn { params: pp, ret: pr, .. },
Type::Fn { params: ap, ret: ar, .. },
) => {
if pp.len() != ap.len() {
return Err(CodegenError::Internal(
"monomorphisation: fn arity mismatch in arg".into(),
));
}
for (p, a) in pp.iter().zip(ap.iter()) {
unify_for_subst(p, a, vars, subst)?;
}
unify_for_subst(pr, ar, vars, subst)
}
(Type::Var { name: pn }, Type::Var { name: an }) if pn == an => Ok(()),
_ => Err(CodegenError::Internal(format!(
"monomorphisation: cannot match param `{}` to arg `{}`",
ailang_core::pretty::type_to_string(param),
ailang_core::pretty::type_to_string(arg),
))),
}
}
/// rewrites bare `Type::Con` references that resolve against
/// `owner_local_types` into qualified `module.Type` form. Mirrors
/// `ailang_check::qualify_local_types` and complements prep.1's
/// `qualify_workspace_types` (which qualifies consumer-side bare
/// cross-module refs). Used when the codegen pulls a polymorphic
/// fn signature across the import boundary; without this the
/// substitution derived from the call site's qualified args
/// (e.g. `std_maybe.Maybe<Int>`) would fail to unify against the
/// owner-local signature (`Maybe<a>`).
pub(crate) fn qualify_local_types_codegen(
t: &Type,
owner_module: &str,
owner_local_types: &BTreeSet<String>,
) -> 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 = if name.contains('.') {
name.clone()
} else if ailang_core::primitives::is_primitive_name(name) {
name.clone()
} else if owner_local_types.contains(name) {
format!("{owner_module}.{name}")
} else {
name.clone()
};
Type::Con {
name: qualified,
args: args
.iter()
.map(|a| qualify_local_types_codegen(a, owner_module, owner_local_types))
.collect(),
}
}
Type::Fn { params, ret, effects, param_modes, ret_mode } => Type::Fn {
params: params
.iter()
.map(|p| qualify_local_types_codegen(p, owner_module, owner_local_types))
.collect(),
ret: Box::new(qualify_local_types_codegen(ret, owner_module, owner_local_types)),
effects: effects.clone(),
param_modes: param_modes.clone(),
ret_mode: *ret_mode,
},
Type::Forall { vars, constraints, body } => Type::Forall {
vars: vars.clone(),
constraints: constraints.clone(),
body: Box::new(qualify_local_types_codegen(body, owner_module, owner_local_types)),
},
Type::Var { .. } => t.clone(),
}
}
/// substitute rigid type vars in `t` according to `subst`.
/// Used to specialise the type of a polymorphic def for a given
/// instantiation.
pub(crate) fn apply_subst_to_type(t: &Type, subst: &BTreeMap<String, Type>) -> Type {
match t {
Type::Var { name } => subst.get(name).cloned().unwrap_or_else(|| t.clone()),
Type::Con { name, args } => Type::Con {
name: name.clone(),
args: args.iter().map(|a| apply_subst_to_type(a, subst)).collect(),
},
Type::Fn { params, ret, effects, param_modes, ret_mode } => Type::Fn {
params: params.iter().map(|p| apply_subst_to_type(p, subst)).collect(),
ret: Box::new(apply_subst_to_type(ret, subst)),
effects: effects.clone(),
param_modes: param_modes.clone(),
ret_mode: *ret_mode,
},
Type::Forall { vars, constraints, body } => {
// Inner forall shadows: don't substitute re-bound names.
let inner: BTreeMap<String, Type> = subst
.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(apply_subst_to_type(body, &inner)),
}
}
}
}
// iter 23.4: `apply_subst_to_term` and `descriptor_for_subst` were
// the codegen-side body-substitution and symbol-mangling helpers used
// by `lower_polymorphic_call` / `emit_specialised_fn`. Both are gone:
// the typecheck-time mono pass synthesises every monomorphic body
// (via `ailang_check::substitute_rigids_in_term`) and produces
// surface-named mono symbols (via `ailang_check::mono::mono_symbol_n`).