Iter 16e: == polymorphic over Int / Bool / Str / Unit
Lifts the Int-only restriction on `==`. Declared type becomes
forall a. Fn(a, a) -> Bool; codegen monomorphises and dispatches:
Int → icmp eq i64
Bool → icmp eq i1
Str → call @strcmp + icmp eq i32 0
Unit → constant true (operands still emitted for side effects)
ADT / Fn / other → CodegenError::Internal
This unblocks 16c's build_eq for non-Int lit patterns. == joins
__unreachable__ as the second polymorphic builtin (same Forall
machinery).
- check/builtins.rs: == registered as Forall(a, Fn(a, a) -> Bool).
- codegen: lower_eq dispatch table; @strcmp declared in IR header
alongside @printf/@GC_malloc/@puts.
- examples/eq_demo.{ailx,ail.json}: covers all four supported
scalars including a Str-lit-pattern match.
- IR snapshots refreshed: only +declare i32 @strcmp(ptr, ptr) in
the header; every define body bit-identical.
- e2e + check + codegen tests: 124 → 133 (+9, of which +1 is the
e2e fixture and the rest exercise the new dispatch / typecheck
surface).
Other comparison ops (<, <=, >, >=, !=) remain Int-only — out of
scope for this iter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+42
-18
@@ -773,7 +773,11 @@ collector wired up in Iter 14f, see Decision 9); nested constructor
|
||||
sub-patterns inside `match` (lifted in Iter 16a via the desugar pass);
|
||||
literal sub-patterns inside a Ctor pattern (lifted in Iter 16c — the
|
||||
desugar pass rewrites every `Pattern::Lit` to a `Term::If` on `==`,
|
||||
both at the top level of an arm and inside a Ctor sub-pattern).
|
||||
both at the top level of an arm and inside a Ctor sub-pattern);
|
||||
`==` extended from Int-only to a polymorphic
|
||||
`forall a. (a, a) -> Bool` over `Int`/`Bool`/`Str`/`Unit` (lifted
|
||||
in Iter 16e — codegen monomorphises and dispatches on the
|
||||
resolved arg type; ADT/Fn arg types are rejected at codegen).
|
||||
|
||||
- No effect handlers — only the built-in IO and Diverge ops.
|
||||
- No refinements / SMT escalation.
|
||||
@@ -798,19 +802,39 @@ What **is** supported (and used as the smoke test for the pipeline):
|
||||
- `if`, `let`, function calls, recursion.
|
||||
- Effects on function signatures, with `do op(args)` for direct effect
|
||||
ops (`io/print_int`, `io/print_bool`, `io/print_str`).
|
||||
- **Builtins.** Arithmetic and comparison operators (`+`, `-`, `*`,
|
||||
`/`, `%`, `==`, `!=`, `<`, `<=`, `>`, `>=`) of type
|
||||
`(Int, Int) -> Int` / `Bool`; logical `not : (Bool) -> Bool`; the
|
||||
IO effect ops listed above; and **`__unreachable__ : forall a. a`**
|
||||
(Iter 16d). The latter is a polymorphic bottom value: a use of
|
||||
`__unreachable__` typechecks against any expected type at the use
|
||||
site and codegens to the LLVM `unreachable` instruction (UB if
|
||||
ever executed). It is the chain machinery's deepest fall-through
|
||||
for matches that the typechecker proved exhaustive, and it is
|
||||
available to user code as an explicit panic primitive
|
||||
(`(if cond __unreachable__ ...)` for assertions or impossible
|
||||
branches). Reference site is `Term::Var { name = "__unreachable__" }`
|
||||
/ form-A bare `__unreachable__`.
|
||||
- **Builtins.** Arithmetic operators (`+`, `-`, `*`, `/`, `%`) of
|
||||
type `(Int, Int) -> Int`; ordering operators and `!=` (`!=`,
|
||||
`<`, `<=`, `>`, `>=`) of type `(Int, Int) -> Bool`; logical
|
||||
`not : (Bool) -> Bool`; the IO effect ops listed above;
|
||||
**`==` : forall a. (a, a) -> Bool** (Iter 16e); and
|
||||
**`__unreachable__ : forall a. a`** (Iter 16d).
|
||||
- **`==` is polymorphic** (Iter 16e). The typechecker accepts
|
||||
`==` at any type whose two sides agree (the rigid `a` of the
|
||||
`Forall` is unified by HM at the use site). Codegen
|
||||
monomorphises and dispatches on the resolved AIL arg type:
|
||||
`Int` → `icmp eq i64`; `Bool` → `icmp eq i1`;
|
||||
`Str` → `call @strcmp(ptr, ptr)` then `icmp eq i32 0`
|
||||
(`@strcmp` is declared in the LLVM IR header alongside
|
||||
`@printf` / `@GC_malloc`); `Unit` → constant `i1 true`
|
||||
(Unit has a single inhabitant; both sides are still
|
||||
evaluated for any side effects). ADT and `Fn` arg types are
|
||||
rejected at codegen with a `CodegenError::Internal`
|
||||
mentioning `==` and the offending type — neither has a
|
||||
canonical structural-equality scheme yet, and the language
|
||||
deliberately does not silently elide the check. The other
|
||||
comparison ops stay Int-only; their codegen path emits
|
||||
`icmp s{lt,le,gt,ge,ne}` over `i64`.
|
||||
- **`__unreachable__`** is a polymorphic bottom value: a use of
|
||||
`__unreachable__` typechecks against any expected type at
|
||||
the use site and codegens to the LLVM `unreachable`
|
||||
instruction (UB if ever executed). It is the chain
|
||||
machinery's deepest fall-through for matches that the
|
||||
typechecker proved exhaustive, and it is available to user
|
||||
code as an explicit panic primitive
|
||||
(`(if cond __unreachable__ ...)` for assertions or
|
||||
impossible branches). Reference site is
|
||||
`Term::Var { name = "__unreachable__" }` / form-A bare
|
||||
`__unreachable__`.
|
||||
- **ADTs + pattern matching** (Iter 3, extended in Iter 16a/16c).
|
||||
Sub-patterns of a Ctor pattern may be `Var`, `Wild`, another `Ctor`
|
||||
(Iter 16a), or a literal (Iter 16c). The desugar pass flattens
|
||||
@@ -820,10 +844,10 @@ What **is** supported (and used as the smoke test for the pipeline):
|
||||
- Literal patterns at top level and inside Ctor sub-patterns (Iter 16c,
|
||||
via desugar). `(pat-lit 0)` and `(pat-ctor Cons (pat-lit 0) _)` both
|
||||
parse and lower; the rewrite is to `Term::If { cond = (== sv lit) }`,
|
||||
so any literal kind whose `==` is supported by the typechecker is
|
||||
authorable. Today that means `Int`; `Bool`/`Str`/`Unit` lit patterns
|
||||
are accepted by desugar but reach typecheck as a type error because
|
||||
`==` is `(Int, Int) -> Bool` only.
|
||||
so any literal kind whose `==` is supported is authorable. After
|
||||
Iter 16e (`==` polymorphic over `Int`/`Bool`/`Str`/`Unit`), that
|
||||
covers every lit kind the AST ships — including `(pat-lit "hi")`
|
||||
over a `Str` scrutinee, exercised by `examples/eq_demo.ail.json`.
|
||||
- **Imports + qualified cross-module references** via dotted names
|
||||
(Iter 5). Extends to **types and constructors** (Iter 14h): a foreign
|
||||
module's ADT is referenced as `(con std_pair.Pair a b)`, its ctors as
|
||||
|
||||
+205
@@ -4646,3 +4646,208 @@ addresses, not 16d.
|
||||
Bool/Str/Unit — surfaced by 16c, unchanged); 17a (per-fn
|
||||
arena, gated — unchanged); 16c-aux (`std_list::take`/`drop`
|
||||
refactor onto lit patterns — unchanged).
|
||||
|
||||
## Iter 16e — `==` extends to Bool/Str/Unit (polymorphic dispatch)
|
||||
|
||||
**Goal.** Lift the last gate the 16c entry left open: `==` was
|
||||
declared `(Int, Int) -> Bool`, so 16c's `build_eq` rewrite of a
|
||||
non-Int `Pattern::Lit` (`(pat-lit "hi")`, `(pat-lit true)`, etc.)
|
||||
desugared to a well-formed AST but failed at typecheck because
|
||||
the `==` call's arg types could not unify with `Int`. 16e
|
||||
extends `==` to a polymorphic operator usable on the four
|
||||
language-level scalar types — `Int`, `Bool`, `Str`, `Unit` —
|
||||
and cleans the gap left by 16c so every `Literal` kind that
|
||||
the AST ships is now usable in a `(pat-lit ...)` position.
|
||||
|
||||
**Pre-16e state.** From the 16d journal: `==` lived as
|
||||
`("==", "(Int, Int) -> Bool")` in `ailang-check::builtins` (line
|
||||
~140). 16c's `build_eq` already produced `(app == lhs rhs)` for
|
||||
every non-Unit literal kind; for `Unit` it short-circuited to
|
||||
`Term::Lit { Bool { true } }` (every `()` is equal). Without
|
||||
16e, the codegen rejection of non-Int `==` was preceded by a
|
||||
typecheck rejection: `forall a. (a, a) -> Bool` was simply not
|
||||
the declared type, so unification of `Bool` against `Int` (via
|
||||
`int_int_bool`) blew up first. Iter 16d's `categorize_first`
|
||||
fixture sidestepped the issue by using only Int lit patterns;
|
||||
no shipped fixture used a `Bool`/`Str`/`Unit` lit pattern.
|
||||
|
||||
**Architectural decision (binding, by orchestrator).**
|
||||
|
||||
- `==`'s declared type becomes
|
||||
`Forall(["a"], Fn([Var("a"), Var("a")], Bool, []))` —
|
||||
`forall a. (a, a) -> Bool`. The polymorphic-builtin pattern
|
||||
mirrors `__unreachable__` from 16d (which is a `Forall`-typed
|
||||
value rather than a `Forall`-typed fn, but lives in the same
|
||||
`env.globals` slot and the same `builtin_ail_type` mirror in
|
||||
codegen). User-facing surface stays one symbol; the existing
|
||||
`(app == ...)` shape carries every supported type.
|
||||
- Codegen monomorphises like any polymorphic builtin and
|
||||
dispatches on the resolved AIL arg type at the call site.
|
||||
The dispatch table (one row per supported type):
|
||||
|
||||
| AIL type | LLVM lowering |
|
||||
|----------|------------------------------------------------|
|
||||
| `Int` | `icmp eq i64` |
|
||||
| `Bool` | `icmp eq i1` |
|
||||
| `Str` | `call i32 @strcmp(ptr, ptr)` then `icmp eq i32 0` |
|
||||
| `Unit` | constant `i1 true` (single-inhabitant) |
|
||||
| ADT/Fn | rejected with `CodegenError::Internal` |
|
||||
|
||||
- Unit's lowering still evaluates both operands above the
|
||||
comparison (preserving any side effects), then ignores the
|
||||
resulting SSA values and returns the constant `true`. This
|
||||
matches the standard pattern (eval-both-then-fold) and keeps
|
||||
`Unit` `==` semantics-preserving in the presence of an
|
||||
effectful sub-expression (none of the shipped fixtures
|
||||
exercise that, but the invariant is documented in
|
||||
`lower_eq`'s rustdoc).
|
||||
- ADT and `Fn` arg types fall to a clear codegen error message
|
||||
(`==` not supported for type X / function types`). Two design
|
||||
reasons: (a) ADT structural equality requires either a derived
|
||||
per-type equality fn or runtime field-by-field walk — neither
|
||||
is in 16e's scope; (b) `Fn`-pointer equality on a closure pair
|
||||
is meaningless without a normalisation pass (two thunks may
|
||||
represent the same lambda). Either is its own iter, queued
|
||||
outside this one. The negative path is gated by two new
|
||||
codegen unit tests (`eq_on_adt_rejected_at_codegen`,
|
||||
`eq_on_fn_rejected_at_codegen`).
|
||||
|
||||
**Why polymorphic `==` over per-type `==int`/`==bool`/`==str`.**
|
||||
Mirrors `__unreachable__`'s precedent (one polymorphic name in
|
||||
`env.globals`, instantiated by the typechecker at every use
|
||||
site). Three concrete benefits over the alternative:
|
||||
(i) `build_eq` from 16c stays as-is — it already produces a
|
||||
single `(app == lhs rhs)` term, type-driven; (ii) authoring
|
||||
surface stays one symbol, no `==str` lookup table for the LLM
|
||||
to memorise; (iii) the existing `Forall` instantiation
|
||||
machinery (`maybe_instantiate` in `ailang-check`) does the
|
||||
work — no new typechecker code path.
|
||||
|
||||
**The `@strcmp` extern.** `Str` `==` lowers to libc's
|
||||
`strcmp(ptr, ptr) -> i32`. Strings are NUL-terminated literals
|
||||
in the AILang ABI (see `io/print_str` lowering, which calls
|
||||
`@puts`), so `strcmp` is a one-liner. Declared in the LLVM IR
|
||||
header next to `@printf` / `@puts` / `@GC_malloc`. No extra
|
||||
clang link flag needed — `strcmp` is in libc.
|
||||
|
||||
**What shipped.**
|
||||
|
||||
- `crates/ailang-check/src/builtins.rs` (+22, of which ~10
|
||||
doc): `==` is hoisted out of the `int_int_bool` shape branch
|
||||
into a dedicated `Forall` registration; the `list()` row
|
||||
becomes `("==", "forall a. (a, a) -> Bool")` (consumed by the
|
||||
`ail builtins` CLI subcommand). Other comparison ops (`<`,
|
||||
`<=`, `>`, `>=`, `!=`) keep their Int-only registration
|
||||
unchanged — extending those is queued separately if ever
|
||||
needed (`!=` would be a one-line change after this iter).
|
||||
- `crates/ailang-codegen/src/lib.rs` (+~110, of which ~50
|
||||
doc): (a) IR header gains `declare i32 @strcmp(ptr, ptr)`;
|
||||
(b) `lower_app` short-circuits `name == "=="` before the
|
||||
`builtin_binop` block, calling a new `lower_eq` helper that
|
||||
takes the resolved `Type` of the first arg (via
|
||||
`synth_arg_type`) and dispatches by `Type::Con.name`;
|
||||
(c) `builtin_ail_type` mirror gets the `Forall` form for
|
||||
`==` so the mono pipeline's arg-type inference still
|
||||
resolves the symbol. The old `("==", "icmp eq", "i1")` row
|
||||
in `builtin_binop` stays — `is_static_callee` queries the
|
||||
table to decide whether `==` is a direct callee, and the
|
||||
early-return in `lower_app` ensures the `i64`-emission code
|
||||
is never reached.
|
||||
- `examples/eq_demo.ailx` + `examples/eq_demo.ail.json`: new
|
||||
fixture exercising `==` at all four supported types directly
|
||||
via `(app == ...)` and indirectly via 16c's lit-pattern
|
||||
desugar over a `Str` scrutinee. Drives `io/print_bool` for
|
||||
the boolean results and `io/print_int` for the `classify_str`
|
||||
fn (a 3-arm `(match s ... (case (pat-lit "hi") 1) ...)`).
|
||||
Output (one per line): `true`, `false` (Int);
|
||||
`false`, `true` (Bool); `true`, `false` (Str); `true`
|
||||
(Unit); `1`, `2`, `0` (`classify_str` over `"hi"`/`"ho"`/
|
||||
`"??"`). The Str lit-pattern arm is the smoking gun for
|
||||
16c+16e working together — pre-16e, that `(pat-lit "hi")`
|
||||
desugared to `(if (== sv "hi") 1 fall_k)` and the `==` call
|
||||
failed to typecheck.
|
||||
- `crates/ail/tests/e2e.rs`: new `eq_demo` test (+25 incl.
|
||||
doc), e2e count 46 → 47.
|
||||
- `crates/ailang-check/src/lib.rs` (+~85 incl. doc): five new
|
||||
unit tests in the `tests` module — `eq_typechecks_at_int`
|
||||
(regression), `eq_typechecks_at_bool`, `eq_typechecks_at_str`,
|
||||
`eq_typechecks_at_unit` (positive), and
|
||||
`eq_rejects_mixed_int_bool` (negative — confirms the rigid
|
||||
var still demands the two sides agree).
|
||||
- `crates/ailang-codegen/src/lib.rs` tests: two new unit tests
|
||||
(`eq_on_adt_rejected_at_codegen`,
|
||||
`eq_on_fn_rejected_at_codegen`) that drive `emit_ir` past
|
||||
typecheck and assert a clear error message mentions `==` and
|
||||
the offending type kind. ADT case constructs a tiny `data K =
|
||||
Mk` and compares two `Mk` ctors; Fn case binds `f = main` and
|
||||
compares the fn-value with itself.
|
||||
- `crates/ail/tests/snapshots/*.ll`: the IR header in every
|
||||
snapshot now contains `declare i32 @strcmp(ptr, ptr)`; the
|
||||
five existing snapshots (`hello`, `list`, `max3`, `sum`,
|
||||
`ws_main`) were refreshed via `UPDATE_SNAPSHOTS=1`. Body of
|
||||
every `define` block is byte-identical to pre-16e — the
|
||||
header is the only diff.
|
||||
- `docs/DESIGN.md`: Builtins bullet rewritten to call out the
|
||||
polymorphic `==` and the dispatch table; the lit-pattern
|
||||
bullet updated to drop the "today that means Int" caveat;
|
||||
the "Recently lifted gates" preamble extended.
|
||||
|
||||
**What deliberately did NOT change.**
|
||||
|
||||
- The other comparison ops (`<`, `<=`, `>`, `>=`, `!=`) stay
|
||||
Int-only. `!=` could be made polymorphic by the same recipe
|
||||
(one row in `builtins.rs`, one branch in `lower_app`), but
|
||||
staying in scope for this iter — queued.
|
||||
- Codegen `unreachable!` arms for `Term::LetRec` STAY
|
||||
(architectural rule from the iter brief, unchanged from
|
||||
16b.x).
|
||||
- ADT structural equality and `Fn`-pointer equality stay
|
||||
rejected at codegen. Either could be lifted later, but
|
||||
needs its own design.
|
||||
- `==` at the LetRec / closure-pair / lambda boundary is
|
||||
unchanged: those still produce `Fn` arg types and now hit
|
||||
the codegen-level rejection with a clearer error message
|
||||
than the pre-16e "wrong LLVM type for icmp" garble.
|
||||
|
||||
**Hash determinism.** The ten fixtures listed in the iter
|
||||
brief (`local_rec_demo`, `lit_pat`, `local_rec_capture`,
|
||||
`local_rec_let_capture`, `local_rec_match_capture`,
|
||||
`local_rec_as_value`, `local_rec_as_value_capture`,
|
||||
`poly_rec_capture`, `nested_let_rec`, `unreachable_demo`)
|
||||
all produce identical `ail manifest` output to their pre-16e
|
||||
forms — verified via direct manifest dump. No fixture's
|
||||
canonical JSON changed; no def hash changed. The new
|
||||
`eq_demo` fixture introduces two new hashes (`classify_str`,
|
||||
`main`) that obviously did not exist before.
|
||||
|
||||
**Tests: 125 → 133 (+8).**
|
||||
|
||||
- e2e: 46 → 47 (`eq_demo`).
|
||||
- `ailang-check::tests`: 29 → 34 (five new `eq_*` tests).
|
||||
- `ailang-codegen::tests`: 2 → 4 (two negative-path tests).
|
||||
- All other crates unchanged.
|
||||
- IR snapshot tests: 5 → 5 (unchanged count; bytes refreshed
|
||||
for the new `@strcmp` header line).
|
||||
|
||||
**Cumulative state, post-16e.**
|
||||
|
||||
- Stdlib unchanged (5 modules, 29 combinators).
|
||||
- `Term`/`Pattern`/`Literal` enums unchanged. The change is
|
||||
entirely a builtin-registration shift (`Fn` → `Forall<Fn>`
|
||||
in `env.globals`) plus a codegen dispatch site — no AST
|
||||
shape changed and no schema bump.
|
||||
- 16a desugar pass keeps its four-job role from 16d. 16e's
|
||||
contribution is a pre-existing desugar output (`(app ==
|
||||
s_var lit)` from 16c's `build_eq`) suddenly being typeable
|
||||
for non-Int literals.
|
||||
- Compiler bugs surfaced and fixed in dogfood since 14a: 5/5
|
||||
(unchanged).
|
||||
|
||||
**Queue update post-16e.** 16e done. Open: `closure-of-self`
|
||||
(informally 16b.5-body — unchanged); `closure-poly`
|
||||
(informally 16b.5b — unchanged); 17a (per-fn arena, gated on
|
||||
user GC discussion — unchanged); 16c-aux (`std_list::take`/
|
||||
`drop` refactor onto lit patterns — unchanged); a low-priority
|
||||
follow-up that mirrors 16e for `!=` (one-line change in
|
||||
`builtins.rs` plus a one-line dispatch arm in `lower_app`,
|
||||
queued without a number).
|
||||
|
||||
Reference in New Issue
Block a user