5170b6abd1
Closes Gitea #1. Realises the "P2 follow-up" called out in examples/prelude.ail:9 — removes the surface comparator names `==` / `!=` / `<` / `<=` / `>` / `>=` from the language entirely, routes the LLM-author's `(app eq …)` / `(app compare …)` / `(app ne|lt|le|gt|ge …)` through prelude.Eq / prelude.Ord class- dispatch, ships six named Float-comparison fns (`float_eq`/`float_ne`/`float_lt`/`float_le`/`float_gt`/`float_ge`) so Float keeps comparability without an Eq/Ord instance, and emits primitive instance bodies with `alwaysinline` so the -O0 IR shape stays at one instruction per comparison. Plan-task journal (13 tasks, single atomic iter per Approach A): Task 1 — Bootstrap: 4 new fixtures (eq_user_adt_smoke.ail, eq_float_must_fail.ail, float_compare_smoke.ail, operator_unbound_check.ail) + 5 new E2E/pin tests as RED starting state. All 5 confirmed RED at start: north-star fixed `NoInstance Eq Unit` (preserved by Unit-eq opening line intentionally added in plan-self-review); float_compare unknown `float_eq`; operator-name typechecked (== still polymorphic); must-fail diagnostic lacked `float_eq`; alwaysinline absent from IR. Task 2 — Codegen alwaysinline + intercept arms: introduced `intercept_emit_wants_alwaysinline` allowlist + appended ` alwaysinline` between `)` and `{` of the `define` line in `emit_fn`; added 9 new intercept arms to `try_emit_primitive_instance_body` — `eq__Int`/`eq__Bool`/ `eq__Unit` + the six `float_*` arms. Task 3 — Prelude reshape: `instance Eq Unit` added; Eq Int/Bool bodies become placeholder-`false` (intercept overrides); six `float_*` free fns added; line-9 P2-follow-up comment removed. Lockstep hash re-pins: prelude_module_hash_pin (3abe0d3fa3c11c99 → new), mono_hash_stability six body-hash literals (eq__Int/Bool/Str + compare__Int/Bool/Str all shifted because placeholder body changes the canonical hash). IR-snapshot regen (hello/list/max3/sum/ws_main) rolled forward into this task at orchestrator's pragmatic call — prelude shift forces the snapshots immediately. Task 4 — Fixture migration: 58 .ail fixtures rewritten (`(app == …)` → `(app eq …)`, etc.; Float-typed operand sites to `(app float_eq …)` / `(app float_lt …)`). eq_ord_user_adt.ail:21 inner `==` → `eq` migration with lockstep eq_ord_e2e.rs:134 body-hash re-pin (3c4cf040cb4e8bb2 → new). Two prose snapshots accepted; deps test + 5 hash_pin literals updated as cascading consequences. Task 5 — Test-scaffold migration: all 8 in-source `#[cfg(test)] mod tests` AST-literal sites threaded (desugar.rs:2414, check/lib.rs:5656/6092/6220, codegen/lib.rs sites). 7 obsolete in-source tests deleted (5 eq_typechecks + 2 lower_eq ADT/Fn rejection — coverage moves to E2E eq_user_adt_smoke + eq_float_must_fail). 2 additional letrec tests deleted (single-module check env can't resolve eq/ge without prelude auto-import; covered by workspace E2E). Task 6 — Lit-pattern desugar: build_eq emits `Term::Var { name: "eq" }` instead of `"=="` at desugar.rs:1109; doc-comment rewritten to describe class-dispatch. Task 7 — Dead-machinery deletion sweep (compile-gated): typchecker builtins (install + list comparator entries deleted); codegen builtin_binop_typed (10 comparator arms deleted from synth.rs, table reduced to arithmetic core); lower_eq fn entirely deleted; `==` short-circuit in lower_app deleted; `is_static_callee` `==` clause deleted; `is_arithmetic_or_comparison_op` renamed to `is_arithmetic_op` + caller-update at codegen/lib.rs:2152 + :2529. Dead `poly_a_a_to_bool` helpers also removed. Workspace build green after compile gate — every caller of the deleted surface was migrated by Tasks 4-6. Task 8 — Float-aware NoInstance diagnostic: check/lib.rs:856-880 addendum extended to name `float_eq` / `float_lt` as the explicit alternative for Eq/Ord at Float; eq_float_noinstance.rs:32-44 assertion extended. Task 9 — IR snapshot regen: subsumed by Task 3 (prelude shift forced immediate snapshot regen; deferring to Task 9 would have left the workspace red between tasks). Task 10 — Prose-projection cleanup: 6 comparator arms deleted from binop_info; 3 in-source mod-tests updated/deleted (comparator-infix rendering would be dishonest now that the operators are no longer language identifiers). Task 11 — Contract updates: 5 design files rewritten to the class-dispatch present — float-semantics.md (arithmetic guarantees retained on +/-/*/; comparison guarantees transferred from ==/!=/< to float_eq/float_ne/float_lt/etc.); prelude-classes.md (Eq Unit added to instance list; new paragraph on six float_* fns; Float-no-Eq/Ord clause gains `→ use float_eq` cross-reference); str-abi.md (clause "REMAIN primitive operators" rewritten to describe class-method dispatch); scope-boundaries.md (multiple operator-name and Pattern::Lit-desugar clauses rewritten); authoring-surface.md (==, <= dropped from operator-example list). Task 12 — Initial acceptance gate: workspace 638/0 GREEN; bench/compile_check + cross_lang 0 regressed; bench/check.py flagged 4 regressions on bench_closure_chain (+29% bump_s, +47% rc_s, +29% bump_rss_kb, +48% rc_rss_kb). Orchestrator initially classified as DONE-with-concerns; Boss reclassified as PARTIAL-via-acceptance-#8-fail after independent re-run, extended iter scope to Task 13. Task 13 — Direct icmp intercept arms (Boss-extension): try_emit_primitive_instance_body gains direct-icmp arms for lt__Int / le__Int / gt__Int / ge__Int / ne__Int, bypassing the compare__Int → Ordering → match indirection that allocated one Ordering ctor per call in tight loops. Each new arm inherits `alwaysinline` via the existing allowlist (extended accordingly). New IR pin test `ord_int_intercept_ir_pin.rs` + smoke fixture `ord_int_intercept_smoke.ail` ratify the optimization — opt -O2 -S confirms zero `call @ail_prelude_lt__Int` in optimized IR; the icmp folds directly at every use site. Bool variants (lt__Bool/etc.) deliberately NOT added — no bench/example calls Ord at Bool; spec-extension permits skipping if unreachable at bench level. Family can be extended symmetrically when first Bool-ordered perf workload appears. Bench-gate post-Task-13: bench_closure_chain bump_s -6.05%, rc_s -2.67%, bump_rss_kb -0.77%, rc_rss_kb +0.46% — all four previously-regressed metrics back inside tolerance. Full bench corpus: 36 metrics, 0 regressed, 0 improved beyond tolerance, 36 stable. bench/check.py exit 0 ✓; bench/compile_check.py exit 0 ✓; bench/cross_lang.py exit 0 ✓. Workspace: 640 passed, 0 failed across all binaries (delta vs. milestone start: +5 new E2E/pin tests added in Task 1 + 1 IR pin added in Task 13 − 9 in-source mod tests deleted as obsolete net ≈ −3). The eq_user_adt_smoke fixture's Unit-opening line was the deliberate RED-first device added in planner self-review so the north-star wouldn't accidentally GREEN at start (user-ADT-Eq on Point with hand-written instance was already operable today; the milestone's actual delivery is operator-name death + Eq Unit + Float-named-fns + cleanup of the two-pathy primitive comparator machinery). Net delta: 96 files changed, 1760 insertions, 1101 deletions; 12 net-new files (6 new fixtures, 5 new E2E/pin tests, 1 stats file). main passes-test-count: 640 (was 633 pre-iter, accounting for the deletions). Spec-vs-acceptance addendum: spec §Testing strategy anticipated the bench-gate-regression case with two recovery paths (`alwaysinline` investigation or "spec needs revisiting toward α"). Task 13 is the third path — direct intercept arms for `lt`/etc. that bypass the compare→match path entirely. The spec's contingency clause was thus generous enough to absorb Task 13 without spec revision, but a future iter that hits a class-method primitive where the body shape introduces a similar codegen cost (Ordering allocation, RC tax, deferred-init) should expect a parallel extension. The pattern is "primitive instance whose canonical body indirects through other class methods that allocate" → add a direct-emit intercept arm + alwaysinline + IR-shape pin. Concerns absorbed: - Codegen lower_app / resolve_top_level_fn gained a prelude-fallback lookup so bare monomorphic prelude fns (`float_eq` etc.) resolve from non-prelude modules without explicit `prelude.float_eq` qualifier. Mirrors the typechecker's implicit prelude import — not in plan but necessary infra for the named-fn surface to be callable. - Recon's "≈10 fixtures" estimate was 6× too low — 58 actual. Mechanical migration; no design impact. - Recon caught 4 in-source AST-literal sites the spec missed (lib.rs:5656 `>=` site + three codegen/lib.rs sites); Task 5 covered all. - Recon caught 2 contract files outside the spec's update set that directly contradicted the milestone (str-abi.md + scope-boundaries.md "REMAIN primitive operators" / "Pattern::Lit desugar to ==" clauses); Task 11 covered all 5. Stats file: `bench/orchestrator-stats/2026-05-21-iter-operator-routing-eq-ord.1.json`. closes #1
570 lines
22 KiB
Rust
570 lines
22 KiB
Rust
//! Built-in operations known to the typechecker (and codegen).
|
|
//!
|
|
//! This module owns the **fixed** symbol set the language ships with — the
|
|
//! arithmetic / comparison / logical operators (`+`, `==`, `not`, ...) and
|
|
//! the IO effect ops (`io/print_str`). User code cannot define
|
|
//! anything in here; conversely the typechecker treats every entry as
|
|
//! always-in-scope without an explicit import.
|
|
//!
|
|
//! Builtins are kept in a **separate table** from user globals (see
|
|
//! [`crate::Env::globals`] vs [`crate::Env::effect_ops`]) only because the
|
|
//! two channels surface differently in the AST: value-level builtins are
|
|
//! reached through `Term::Var { name }` (they live alongside user defs in
|
|
//! `Env.globals`), while effect ops are reached through `Term::Do { op }`
|
|
//! and need to carry an extra effect label, hence the dedicated
|
|
//! [`struct@EffectOpSig`] payload in `Env.effect_ops`.
|
|
//!
|
|
//! The typechecker calls [`install()`] once per module-check, before any
|
|
//! user-supplied globals are added. The CLI's `ail builtins` subcommand
|
|
//! reflects the same data via [`list()`] / [`value_names()`].
|
|
|
|
use ailang_core::ast::Type;
|
|
|
|
/// Signature of a builtin effect operation: the effect label it raises,
|
|
/// its parameter types, and its return type.
|
|
///
|
|
/// The typechecker consults this when synthesising the type of a
|
|
/// `Term::Do { op, args }` — it unifies `args` against `params`, returns
|
|
/// `ret`, and inserts `effect` into the body's accumulated effect set so
|
|
/// the surrounding fn's declared effect row is checked against actual
|
|
/// usage.
|
|
#[derive(Debug, Clone)]
|
|
pub struct EffectOpSig {
|
|
/// Effect label this op raises (e.g. `"IO"`). Compared against the
|
|
/// declared effect row on the enclosing fn type.
|
|
pub effect: String,
|
|
/// Positional parameter types. Length and order are the contract for
|
|
/// `Term::Do { args }`.
|
|
pub params: Vec<Type>,
|
|
/// Return type of the op. Often [`Type::unit`] for sinks like
|
|
/// `io/print_str`.
|
|
pub ret: Type,
|
|
}
|
|
|
|
/// Populates `env` with every built-in operator and effect op.
|
|
///
|
|
/// Called once at the start of `check_in_workspace`, before user
|
|
/// type defs and globals are folded in. After this returns, every name in
|
|
/// [`list()`] is resolvable in `env`. Idempotent for a fresh `Env`; calling
|
|
/// it twice would shadow the same entries with identical types.
|
|
pub fn install(env: &mut crate::Env) {
|
|
// arithmetic and comparison ops are polymorphic.
|
|
// Same shape as `==` below — the {Int, Float}-restriction is
|
|
// enforced at codegen, not at typecheck. `%` stays Int-only
|
|
// (no fmod yet — `%` semantics for Float require an explicit
|
|
// decision on sign-of-result and ±0/±Inf edge cases that has
|
|
// not been made).
|
|
let poly_a_a_to_a = || 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::Var { name: "a".into() }),
|
|
effects: vec![],
|
|
param_modes: vec![],
|
|
ret_mode: ailang_core::ast::ParamMode::Implicit,
|
|
}),
|
|
};
|
|
// 2026-05-21 operator-routing-eq-ord: the `poly_a_a_to_bool`
|
|
// helper produced the `forall a. (a, a) -> Bool` shape used by
|
|
// the six comparator builtins (`==`/`!=`/`<`/`<=`/`>`/`>=`).
|
|
// Those builtins are gone (class-method dispatch via prelude.Eq
|
|
// / Ord replaces them); the helper is dead.
|
|
let int_int_int = Type::Fn {
|
|
params: vec![Type::int(), Type::int()],
|
|
ret: Box::new(Type::int()),
|
|
effects: vec![],
|
|
param_modes: vec![],
|
|
ret_mode: ailang_core::ast::ParamMode::Implicit,
|
|
};
|
|
for op in ["+", "-", "*", "/"] {
|
|
env.globals.insert(op.into(), poly_a_a_to_a());
|
|
}
|
|
env.globals.insert("%".into(), int_int_int);
|
|
// 2026-05-21 operator-routing-eq-ord: the six comparator names
|
|
// `==` / `!=` / `<` / `<=` / `>` / `>=` are no longer part of the
|
|
// language. Equality / ordering go through the class methods
|
|
// `eq` / `compare` (and the free helpers `ne` / `lt` / `le` /
|
|
// `gt` / `ge`) dispatched via prelude.Eq / prelude.Ord; Float
|
|
// comparison goes through the named fns `float_eq` / `float_ne`
|
|
// / `float_lt` / `float_le` / `float_gt` / `float_ge`.
|
|
env.globals.insert(
|
|
"not".into(),
|
|
Type::Fn {
|
|
params: vec![Type::bool_()],
|
|
ret: Box::new(Type::bool_()),
|
|
effects: vec![],
|
|
param_modes: vec![],
|
|
ret_mode: ailang_core::ast::ParamMode::Implicit,
|
|
},
|
|
);
|
|
|
|
// `__unreachable__` is the polymorphic bottom value.
|
|
// Type: `forall a. a`. Used by the desugar pass as the chain
|
|
// terminator of an exhaustive match, and available to user code as
|
|
// a primitive panic point. Codegen lowers it to LLVM `unreachable`.
|
|
// It is a value, not a fn — reference site is `(var __unreachable__)`,
|
|
// not `(app __unreachable__)`.
|
|
env.globals.insert(
|
|
"__unreachable__".into(),
|
|
Type::Forall {
|
|
vars: vec!["a".into()],
|
|
constraints: vec![],
|
|
body: Box::new(Type::Var { name: "a".into() }),
|
|
},
|
|
);
|
|
|
|
// Float-conversion and inspection builtins.
|
|
// Codegen lowering lands in iter 4; iter 3 only registers types.
|
|
// `neg` is polymorphic (`forall a. (a) -> a`) for the same reason
|
|
// the widened `+` is — Int and Float negation share one symbol;
|
|
// the spec section A3 also notes that `(- 0.0 x)` desugar is
|
|
// wrong for `-0.0` (returns `+0.0` per IEEE rounding), so Float
|
|
// negation needs its own builtin name.
|
|
env.globals.insert(
|
|
"neg".into(),
|
|
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: ailang_core::ast::ParamMode::Implicit,
|
|
}),
|
|
},
|
|
);
|
|
env.globals.insert(
|
|
"int_to_float".into(),
|
|
Type::Fn {
|
|
params: vec![Type::int()],
|
|
ret: Box::new(Type::float()),
|
|
effects: vec![],
|
|
param_modes: vec![],
|
|
ret_mode: ailang_core::ast::ParamMode::Implicit,
|
|
},
|
|
);
|
|
env.globals.insert(
|
|
"float_to_int_truncate".into(),
|
|
Type::Fn {
|
|
params: vec![Type::float()],
|
|
ret: Box::new(Type::int()),
|
|
effects: vec![],
|
|
param_modes: vec![],
|
|
ret_mode: ailang_core::ast::ParamMode::Implicit,
|
|
},
|
|
);
|
|
env.globals.insert(
|
|
"float_to_str".into(),
|
|
Type::Fn {
|
|
params: vec![Type::float()],
|
|
ret: Box::new(Type::str_()),
|
|
effects: vec![],
|
|
param_modes: vec![],
|
|
ret_mode: ailang_core::ast::ParamMode::Own,
|
|
},
|
|
);
|
|
env.globals.insert(
|
|
"int_to_str".into(),
|
|
Type::Fn {
|
|
params: vec![Type::int()],
|
|
ret: Box::new(Type::str_()),
|
|
effects: vec![],
|
|
param_modes: vec![],
|
|
ret_mode: ailang_core::ast::ParamMode::Own,
|
|
},
|
|
);
|
|
env.globals.insert(
|
|
"bool_to_str".into(),
|
|
Type::Fn {
|
|
params: vec![Type::bool_()],
|
|
ret: Box::new(Type::str_()),
|
|
effects: vec![],
|
|
param_modes: vec![],
|
|
ret_mode: ailang_core::ast::ParamMode::Own,
|
|
},
|
|
);
|
|
env.globals.insert(
|
|
"str_clone".into(),
|
|
Type::Fn {
|
|
params: vec![Type::str_()],
|
|
ret: Box::new(Type::str_()),
|
|
effects: vec![],
|
|
param_modes: vec![ailang_core::ast::ParamMode::Borrow],
|
|
ret_mode: ailang_core::ast::ParamMode::Own,
|
|
},
|
|
);
|
|
env.globals.insert(
|
|
"str_concat".into(),
|
|
Type::Fn {
|
|
params: vec![Type::str_(), Type::str_()],
|
|
ret: Box::new(Type::str_()),
|
|
effects: vec![],
|
|
param_modes: vec![
|
|
ailang_core::ast::ParamMode::Borrow,
|
|
ailang_core::ast::ParamMode::Borrow,
|
|
],
|
|
ret_mode: ailang_core::ast::ParamMode::Own,
|
|
},
|
|
);
|
|
env.globals.insert(
|
|
"is_nan".into(),
|
|
Type::Fn {
|
|
params: vec![Type::float()],
|
|
ret: Box::new(Type::bool_()),
|
|
effects: vec![],
|
|
param_modes: vec![],
|
|
ret_mode: ailang_core::ast::ParamMode::Implicit,
|
|
},
|
|
);
|
|
|
|
// Float bit-pattern constants. Bare values, not
|
|
// fns — reference site is `(var nan)`. Codegen emits
|
|
// `double 0x7FF8000000000000` (NaN), `double 0x7FF0000000000000`
|
|
// (+Inf), `double 0xFFF0000000000000` (-Inf) at the use site in
|
|
// iter 4. Parallel to `__unreachable__` but typed as concrete
|
|
// `Float` rather than the polymorphic bottom — these constants
|
|
// always denote a specific `f64` bit pattern, never a generic
|
|
// missing value.
|
|
env.globals.insert("nan".into(), Type::float());
|
|
env.globals.insert("inf".into(), Type::float());
|
|
env.globals.insert("neg_inf".into(), Type::float());
|
|
|
|
env.effect_ops.insert(
|
|
"io/print_str".into(),
|
|
EffectOpSig {
|
|
effect: "IO".into(),
|
|
params: vec![Type::str_()],
|
|
ret: Type::unit(),
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Names of value-level built-ins (operators, `not`) — the ones that
|
|
/// show up as `Term::Var { name }` references in user code. Effect ops
|
|
/// are excluded because they reach codegen via `Term::Do`, not `Var`.
|
|
///
|
|
/// Single source of truth: derived from `list()` by filtering out the
|
|
/// effect-op rows. Kept as a function (not a `const`) because `list()`
|
|
/// already allocates; this is only consulted by tooling (`ail deps`).
|
|
pub fn value_names() -> Vec<&'static str> {
|
|
list()
|
|
.into_iter()
|
|
.filter(|(_, sig)| !sig.contains("[effect op]"))
|
|
.map(|(n, _)| n)
|
|
.collect()
|
|
}
|
|
|
|
/// Returns the list of all registered built-ins. Useful for the CLI subcommand
|
|
/// `ail builtins`, when the LLM wants to check expected signatures.
|
|
pub fn list() -> Vec<(&'static str, &'static str)> {
|
|
vec![
|
|
("+", "forall a. (a, a) -> a"),
|
|
("-", "forall a. (a, a) -> a"),
|
|
("*", "forall a. (a, a) -> a"),
|
|
("/", "forall a. (a, a) -> a"),
|
|
("%", "(Int, Int) -> Int"),
|
|
("not", "(Bool) -> Bool"),
|
|
("__unreachable__", "forall a. a"),
|
|
("neg", "forall a. (a) -> a"),
|
|
("int_to_float", "(Int) -> Float"),
|
|
("float_to_int_truncate", "(Float) -> Int"),
|
|
("float_to_str", "(Float) -> Str"),
|
|
("int_to_str", "(Int) -> Str"),
|
|
("bool_to_str", "(Bool) -> Str"),
|
|
("str_clone", "(Str) -> Str"),
|
|
("str_concat", "(Str, Str) -> Str"),
|
|
("is_nan", "(Float) -> Bool"),
|
|
("nan", "Float"),
|
|
("inf", "Float"),
|
|
("neg_inf", "Float"),
|
|
("io/print_str", "(Str) -> Unit !IO [effect op]"),
|
|
]
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::{Env, Subst};
|
|
use ailang_core::ast::{Literal, Term};
|
|
use indexmap::IndexMap;
|
|
use std::collections::BTreeSet;
|
|
|
|
/// Synthesize the type of a small expression in a fresh Env that has
|
|
/// only the builtins installed (no user defs). Returns the fully-
|
|
/// applied (substitution-resolved) result type; effects ignored for
|
|
/// this helper. Wraps `crate::synth` with the boilerplate state
|
|
/// (locals, effects sink, subst, counter, residuals) the real
|
|
/// typechecker entry points (`check_module`) would otherwise own.
|
|
fn synth_in_builtins_env(t: &Term) -> Type {
|
|
let mut env = Env::default();
|
|
install(&mut env);
|
|
let mut locals: IndexMap<String, Type> = IndexMap::new();
|
|
let mut effects: BTreeSet<String> = BTreeSet::new();
|
|
let mut subst = Subst::default();
|
|
let mut counter: u32 = 0;
|
|
let mut residuals = Vec::new();
|
|
let mut free_fn_calls = Vec::new();
|
|
let mut warnings: Vec<crate::diagnostic::Diagnostic> = Vec::new();
|
|
// loop-recur iter 2: test helper synths one term from top-of-
|
|
// body — fresh empty loop-stack.
|
|
let mut loop_stack: Vec<Vec<(String, Type)>> = Vec::new();
|
|
let ty = crate::synth(
|
|
t,
|
|
&env,
|
|
&mut locals,
|
|
&mut loop_stack,
|
|
&mut effects,
|
|
"<test>",
|
|
&mut subst,
|
|
&mut counter,
|
|
&mut residuals,
|
|
&mut free_fn_calls,
|
|
&mut warnings,
|
|
)
|
|
.expect("synth");
|
|
subst.apply(&ty)
|
|
}
|
|
|
|
fn lit_int(v: i64) -> Term {
|
|
Term::Lit { lit: Literal::Int { value: v } }
|
|
}
|
|
fn lit_float(bits: u64) -> Term {
|
|
Term::Lit { lit: Literal::Float { bits } }
|
|
}
|
|
fn app(callee: &str, args: Vec<Term>) -> Term {
|
|
Term::App {
|
|
callee: Box::new(Term::Var { name: callee.into() }),
|
|
args,
|
|
tail: false,
|
|
}
|
|
}
|
|
|
|
/// regression — `(+ 1 2)` still resolves to `Int`
|
|
/// after the widening from `(Int, Int) -> Int` to
|
|
/// `forall a. (a, a) -> a`. Protects the no-regression invariant
|
|
/// for every existing Int-using fixture: the polymorphic `+`
|
|
/// instantiated at `(Int, Int)` must still return `Int`,
|
|
/// bit-identical to the pre-widening monomorphic shape.
|
|
#[test]
|
|
fn widen_plus_keeps_int_int_int() {
|
|
let ty = synth_in_builtins_env(&app("+", vec![lit_int(1), lit_int(2)]));
|
|
assert_eq!(ty, Type::int(), "(+ 1 2) must still type as Int");
|
|
}
|
|
|
|
/// new acceptance — `(+ 1.5 2.5)` types as `Float`.
|
|
/// Pre-widening this would have failed with `TypeMismatch` because
|
|
/// `+` was monomorphic `(Int, Int) -> Int`. The widening to
|
|
/// `forall a. (a, a) -> a` makes Float arithmetic a typecheck-clean
|
|
/// shape; codegen filtering of the {Int, Float} arg-type set
|
|
/// happens in iter 4.
|
|
#[test]
|
|
fn widen_plus_accepts_float_float_float() {
|
|
let bits_a = 1.5_f64.to_bits();
|
|
let bits_b = 2.5_f64.to_bits();
|
|
let ty = synth_in_builtins_env(&app("+", vec![lit_float(bits_a), lit_float(bits_b)]));
|
|
assert_eq!(ty, Type::float(), "(+ 1.5 2.5) must type as Float");
|
|
}
|
|
|
|
// 2026-05-21 operator-routing-eq-ord: the two `widen_lt_*` smoke
|
|
// tests (Int-Int-Bool + Float-Float-Bool) lived inside the builtin
|
|
// env and pinned `<` as a builtin. With `<` deleted from the
|
|
// builtin table, the tests are unreachable — comparison is now a
|
|
// class-method (`lt` via prelude.Ord) for Int/Bool/Str and a
|
|
// named fn (`float_lt`) for Float, neither of which is in the
|
|
// `synth_in_builtins_env`-visible namespace. Coverage of the new
|
|
// surface lives in `eq_ord_e2e.rs` (Ord class-dispatch) and in
|
|
// `float_compare_smoke_e2e.rs` (named-fn Float path).
|
|
|
|
/// `neg` is polymorphic — `forall a. (a) -> a`.
|
|
/// Both `(neg 5) : Int` and `(neg 1.5) : Float` typecheck. The
|
|
/// polymorphic shape is the same as the widened arithmetic ops, so
|
|
/// Int and Float negation share one symbol; codegen dispatches on
|
|
/// the resolved arg type at the call site (iter 4).
|
|
#[test]
|
|
fn install_neg_is_polymorphic() {
|
|
let ty_int = synth_in_builtins_env(&app("neg", vec![lit_int(5)]));
|
|
assert_eq!(ty_int, Type::int(), "(neg 5) must type as Int");
|
|
let bits = 1.5_f64.to_bits();
|
|
let ty_float = synth_in_builtins_env(&app("neg", vec![lit_float(bits)]));
|
|
assert_eq!(ty_float, Type::float(), "(neg 1.5) must type as Float");
|
|
}
|
|
|
|
/// `int_to_float : (Int) -> Float`. The
|
|
/// monomorphic conversion builtin — codegen lowers via `sitofp` in
|
|
/// iter 4. Typecheck only validates the signature here.
|
|
#[test]
|
|
fn install_int_to_float_signature() {
|
|
let ty = synth_in_builtins_env(&app("int_to_float", vec![lit_int(5)]));
|
|
assert_eq!(ty, Type::float(), "(int_to_float 5) must type as Float");
|
|
}
|
|
|
|
/// `float_to_int_truncate : (Float) -> Int`.
|
|
/// Saturating truncation toward zero per spec A4 — typecheck only
|
|
/// validates the signature; semantics is iter 4's codegen lowering
|
|
/// via `@llvm.fptosi.sat.i64.f64`.
|
|
#[test]
|
|
fn install_float_to_int_truncate_signature() {
|
|
let bits = 1.5_f64.to_bits();
|
|
let ty = synth_in_builtins_env(&app("float_to_int_truncate", vec![lit_float(bits)]));
|
|
assert_eq!(ty, Type::int(), "(float_to_int_truncate 1.5) must type as Int");
|
|
}
|
|
|
|
/// `float_to_str : (Float) -> Str`. Codegen lowers
|
|
/// via runtime C glue in iter 4; typecheck only validates the signature.
|
|
#[test]
|
|
fn install_float_to_str_signature() {
|
|
let bits = 1.5_f64.to_bits();
|
|
let ty = synth_in_builtins_env(&app("float_to_str", vec![lit_float(bits)]));
|
|
assert_eq!(ty, Type::str_(), "(float_to_str 1.5) must type as Str");
|
|
}
|
|
|
|
/// `int_to_str : (Int) -> Str`. Codegen lowers via the
|
|
/// runtime C glue `ailang_int_to_str` from `runtime/str.c`.
|
|
#[test]
|
|
fn install_int_to_str_signature() {
|
|
let ty = synth_in_builtins_env(&app("int_to_str", vec![lit_int(42)]));
|
|
assert_eq!(ty, Type::str_(), "(int_to_str 42) must type as Str");
|
|
}
|
|
|
|
/// `bool_to_str : (Bool) -> Str`. Codegen lowers via the
|
|
/// runtime C glue `ailang_bool_to_str` from `runtime/str.c`.
|
|
#[test]
|
|
fn install_bool_to_str_signature() {
|
|
let ty = synth_in_builtins_env(&app(
|
|
"bool_to_str",
|
|
vec![Term::Lit { lit: Literal::Bool { value: true } }],
|
|
));
|
|
assert_eq!(ty, Type::str_(), "(bool_to_str true) must type as Str");
|
|
}
|
|
|
|
/// `str_clone : (Str borrow) -> Str`. Codegen lowers via the
|
|
/// runtime C glue `ailang_str_clone` from `runtime/str.c`.
|
|
#[test]
|
|
fn install_str_clone_signature() {
|
|
let ty = synth_in_builtins_env(&app(
|
|
"str_clone",
|
|
vec![Term::Lit { lit: Literal::Str { value: "hi".into() } }],
|
|
));
|
|
assert_eq!(ty, Type::str_(), "(str_clone \"hi\") must type as Str");
|
|
}
|
|
|
|
#[test]
|
|
fn install_str_concat_signature() {
|
|
// `str_concat : (Str borrow, Str borrow) -> Str
|
|
// own`. Codegen lowers via the runtime C glue `ailang_str_concat`
|
|
// from `runtime/str.c`.
|
|
let mut env = Env::default();
|
|
install(&mut env);
|
|
let ty = env
|
|
.globals
|
|
.get("str_concat")
|
|
.expect("str_concat must be installed");
|
|
match ty {
|
|
Type::Fn {
|
|
params,
|
|
ret,
|
|
effects,
|
|
param_modes,
|
|
ret_mode,
|
|
} => {
|
|
assert_eq!(params.len(), 2);
|
|
assert!(matches!(params[0], Type::Con { ref name, .. } if name == "Str"));
|
|
assert!(matches!(params[1], Type::Con { ref name, .. } if name == "Str"));
|
|
assert!(matches!(**ret, Type::Con { ref name, .. } if name == "Str"));
|
|
assert!(effects.is_empty(), "str_concat must be effect-free");
|
|
assert_eq!(
|
|
param_modes,
|
|
&vec![
|
|
ailang_core::ast::ParamMode::Borrow,
|
|
ailang_core::ast::ParamMode::Borrow,
|
|
]
|
|
);
|
|
assert_eq!(*ret_mode, ailang_core::ast::ParamMode::Own);
|
|
}
|
|
other => panic!("expected Type::Fn; got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// `is_nan : (Float) -> Bool`. Codegen lowers to
|
|
/// `fcmp uno double %x, %x` in iter 4 — typecheck only validates
|
|
/// the signature here.
|
|
#[test]
|
|
fn install_is_nan_signature() {
|
|
let bits = 1.5_f64.to_bits();
|
|
let ty = synth_in_builtins_env(&app("is_nan", vec![lit_float(bits)]));
|
|
assert_eq!(ty, Type::bool_(), "(is_nan 1.5) must type as Bool");
|
|
}
|
|
|
|
/// `nan`, `inf`, `neg_inf` are bare-value constants
|
|
/// of type `Float`. They are NOT functions — reference site is
|
|
/// `(var nan)`, not `(app nan)`. Parallel to `__unreachable__` which
|
|
/// is `forall a. a`, but here the type is the concrete `Float`
|
|
/// instead of the polymorphic bottom — these constants always denote
|
|
/// a specific `f64` bit pattern.
|
|
#[test]
|
|
fn install_float_constants() {
|
|
let ty_nan = synth_in_builtins_env(&Term::Var { name: "nan".into() });
|
|
assert_eq!(ty_nan, Type::float(), "nan must type as Float");
|
|
let ty_inf = synth_in_builtins_env(&Term::Var { name: "inf".into() });
|
|
assert_eq!(ty_inf, Type::float(), "inf must type as Float");
|
|
let ty_neg_inf = synth_in_builtins_env(&Term::Var { name: "neg_inf".into() });
|
|
assert_eq!(ty_neg_inf, Type::float(), "neg_inf must type as Float");
|
|
}
|
|
|
|
/// pattern-matching on Float literals is hard-
|
|
/// rejected at typecheck per spec line 723-735 recommendation (a).
|
|
/// IEEE-`==` semantics make Float patterns semantically dubious
|
|
/// (NaN never matches; equality is bit-exact not approximate).
|
|
/// Surface lex / parser accept the syntax (iter 2); typecheck
|
|
/// surfaces the error here.
|
|
#[test]
|
|
fn reject_float_pattern_in_match() {
|
|
use ailang_core::ast::{Arm, Pattern};
|
|
let bits = 1.5_f64.to_bits();
|
|
let scrut = lit_float(bits);
|
|
let arm = Arm {
|
|
pat: Pattern::Lit { lit: Literal::Float { bits } },
|
|
body: lit_int(0),
|
|
};
|
|
let term = Term::Match {
|
|
scrutinee: Box::new(scrut),
|
|
arms: vec![arm],
|
|
};
|
|
let mut env = Env::default();
|
|
install(&mut env);
|
|
let mut locals: IndexMap<String, Type> = IndexMap::new();
|
|
let mut effects: BTreeSet<String> = BTreeSet::new();
|
|
let mut subst = Subst::default();
|
|
let mut counter: u32 = 0;
|
|
let mut residuals = Vec::new();
|
|
let mut free_fn_calls = Vec::new();
|
|
let mut warnings: Vec<crate::diagnostic::Diagnostic> = Vec::new();
|
|
// loop-recur iter 2: test helper synths one term from top-of-
|
|
// body — fresh empty loop-stack.
|
|
let mut loop_stack: Vec<Vec<(String, Type)>> = Vec::new();
|
|
let err = crate::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("must reject");
|
|
assert!(
|
|
matches!(err, crate::CheckError::FloatPatternNotAllowed),
|
|
"expected FloatPatternNotAllowed, got {err:?}"
|
|
);
|
|
}
|
|
}
|