From 5af918c691c5d58f54f35a70065bf34d520c45c8 Mon Sep 17 00:00:00 2001 From: Assaf Sapir Date: Sat, 27 Jun 2026 16:36:54 +0300 Subject: [PATCH 1/5] M2: Explicit ad-hoc overloading (operators + Text comparison) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds explicit ad-hoc overloading as the language's only polymorphism (no generics): multiple same-named top-level definitions with full parameter type annotations form an overload set, resolved at each call site by EXACT static argument types (no implicit coercion); ambiguity / no-match are clear file:line:col diagnostics listing the candidates. Operators are user-overloadable (`+ - * / % == != < <= > >=`) because an operator is just a named overload set. The standard built-ins (`+` on Num/Text, the comparisons, `print`/`eprint` over Num/Text/Bool) are now VISIBLE overloads routed through the same mechanism, not compiler special-cases. Concrete deliverable: Text comparison overloads — `==`/`!=` (equality) and `<`/`<=`/`>`/`>=` (lexicographic) over Text, via a new `__text_cmp` runtime intrinsic. `<`/`>` are disambiguated from `< >` block delimiters by a lexer rule: a `>` is the block close only when it is the last token on its line; otherwise it is the greater-than operator (so `a > b` works everywhere). Pipeline: - Lexer: `>` line-final reclassification to a new `Gt` token. - Parser: operator-symbol-named definitions; `<`/`Gt` as comparison operators; binary-op loops stop at a top-level operator definition (`op =`). - Typechecker: overload-set registry keyed by name (functions AND operator symbols); built-in operator/print overloads; exact-type dispatch; new NoMatchingOverload / AmbiguousOverload / OverloadMissingAnnotation errors. - Codegen: each overload member mangled to a distinct symbol; calls/operators lowered to the resolved member; Text comparisons lowered via `__text_cmp`; records GC-allocated so a record returned from a function/operator survives; Bool `==`/`!=` lowered as integer compares; match payload bindings carry their declared type so an overloaded call on a concrete sum payload dispatches right. Ships `examples/overloading.ql` (wired into the examples gate, JIT + native AOT, exit 161), unit + integration + negative tests, and LANGUAGE.md updates (overloading + operator-overloading sections, feature matrix, `>` rule, the non-numeric-Result-payload-through-overload limitation cross-ref). Deferred (separate PR): concrete sum-payload typing — a non-numeric `Result` payload (`Ok("x")`) routed through an overload is part of the existing non-numeric-payload limitation. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 3 +- LANGUAGE.md | 91 +++++- examples/overloading.ql | 31 ++ quilon-rt/src/lib.rs | 27 ++ src/ast/nodes.rs | 40 ++- src/codegen/generator.rs | 605 ++++++++++++++++++++++++++++++++--- src/jit.rs | 1 + src/lexer/lexer.rs | 63 +++- src/lexer/token.rs | 10 + src/parser/ast_parser.rs | 114 +++++++ src/typechecker/checker.rs | 634 ++++++++++++++++++++++++++++++++----- tests/diagnostics_test.rs | 8 +- tests/examples_test.rs | 1 + tests/run_test.rs | 240 ++++++++++++++ 14 files changed, 1725 insertions(+), 143 deletions(-) create mode 100644 examples/overloading.ql diff --git a/CLAUDE.md b/CLAUDE.md index f55ab2a..6c08e9a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,7 +62,8 @@ Classic multi-pass pipeline; `src/driver.rs::front_end` wires the passes for the - Arrays and `Text` are both `{ ptr, i64 }` structs in LLVM (`Text` = `{ data, byte_len }`; arrays = `{ data, size }`). `Text` is a built-in type, no import. - Sum types (`Ok`/`NotOk`) are tagged unions (i8 tag + payload). **Numeric payloads work; non-numeric data in composites — `Text` as a payload (`Ok(text)`), or `Text` inside a record/array — doesn't type-check yet** — check `LANGUAGE.md` "Known limitations" and `tests/sum_*.rs` before assuming. - **No keywords** — symbol-based: `^` entry point, `<<` import, `>>` export, `|>` pipe (first-arg injection: `x |> f(a)` ≡ `f(x, a)`), `for n <- collection => body` loops, `?`/`|`/`_` pattern matching, `? :` ternary, `~` comments. Consult the symbol table in `LANGUAGE.md`. -- I/O lives in the `core.io` module (`<< core.io`): `print`/`eprint`/`write`/`stdout`/`stderr`. There is no `println`. `print`/`eprint` are compiler-lowered builtins (polymorphic over Num/Text/Bool). +- I/O lives in the `core.io` module (`<< core.io`): `print`/`eprint`/`write`/`stdout`/`stderr`. There is no `println`. `print`/`eprint` are built-in **overload sets** over Num/Text/Bool (lowered to runtime intrinsics); a user definition adds an overload member. +- **Overloading is ad-hoc and explicit** (the only polymorphism — no generics): 2+ same-named top-level defs with full param annotations, or an operator-symbol-named def, form an overload set; calls/operators resolve by **exact** static argument types (no coercion). Built-in operators (`+`, comparisons incl. `Text`) and `print` go through this same mechanism. Codegen mangles each member to a distinct symbol. `>` lexes as block-close only when line-final; otherwise it's the greater-than operator. ## Reference docs diff --git a/LANGUAGE.md b/LANGUAGE.md index 610478b..846a181 100644 --- a/LANGUAGE.md +++ b/LANGUAGE.md @@ -15,7 +15,7 @@ Quilon is a statically-typed, **symbol-based** language (no control-flow keyword | `::` | Type annotation | `x :: Num` | | `=>` | Function body / match arm | `f = x => x + 1` | | `->` | Return type | `f = x -> Num => x` | -| `< >` | Block delimiters | `< a b a + b >` | +| `< >` | Block delimiters · also `<`/`>` comparison ([rule](#expressions)) | `< a b a + b >` · `a < b` · `a > b` | | `^` | Entry point (main) | `^ = () -> Num => 0` | | `$` | Unit type **and** its sole value | `f = () -> $ => $` | | `<<` | Import a module | `<< core.io` | @@ -231,11 +231,75 @@ factorial = n -> Num => n == 0 ? 1 : n * factorial(n - 1) --- +## Overloading + +Quilon has **explicit ad-hoc overloading** — the *only* form of polymorphism (there +are no generics / type variables). Multiple top-level definitions that **share a name +and each carry full parameter type annotations** simply *are* an overload set — there is +no marker symbol or keyword: + +```quilon +score = (n :: Num) -> Num => n + 1 ~ the Num member +score = (s :: Text) -> Num => s.size ~ the Text member + +a = score(41) ~ 42 — picks the Num member +b = score("abcd") ~ 4 — picks the Text member +``` + +**Dispatch is by exact static argument type, with NO implicit coercion.** At each call +site the compiler picks the member whose parameter types match exactly. If none matches, +or (with exact matching) two members share a parameter-type list, it is a clear compile +error that lists the candidates: + +``` +error: No overload of 'score' matches argument types (Bool). Candidates: (Num), (Text) +``` + +- Every member of an overload set must annotate **all** its parameters (exact dispatch + can't choose between unannotated members). +- A single, ordinary `name = …` definition is **not** an overload set — it keeps full + type inference (unannotated params default to `Num`, the return type is inferred). +- Dispatch is resolved at **direct call sites** by static argument types. Passing an + overloaded name as a value (higher-order use) is not yet supported. + +### Operator overloading + +Operators are user-overloadable — `+ - * / %`, `== != < <= > >=` — because **an operator +is just a named overload set** under the hood. The standard operators are *visible* +overloads (e.g. `+` on `Num` and `+` on `Text`), not compiler magic, and a user +definition adds a member for a user type. Define one by naming it with the operator +symbol: + +```quilon +Vec = { x :: Num, y :: Num } ++ = (a :: Vec, b :: Vec) -> Vec => Vec { x = a.x + b.x, y = a.y + b.y } + +v = Vec { x = 1, y = 2 } + Vec { x = 3, y = 4 } ~ resolves to the user `+` +``` + +A user operator overload is resolved exactly like a function overload (by argument +types) and lowers to a direct call. `==` over `Text` (equality) and `<`/`>`/`<=`/`>=` +over `Text` (lexicographic order) are built-in overloads, so text comparisons work out +of the box: `"abc" < "abd"`, `"hi" == "hi"`. (Defining `<`/`>` is reserved — a top-level +`<`/`>` would read as a block; overload the others, or use `<=`/`>=`.) + +(See `examples/overloading.ql`.) + +--- + ## Expressions -- **Arithmetic:** `+ - * / %` (and `-x`). `+` is overloaded for `Text` concatenation. -- **Comparison:** `== != < <= > >=`. +- **Arithmetic:** `+ - * / %` (and `-x`). `+` is an [overload set](#overloading): `Num + Num` adds, `Text + Text` concatenates. +- **Comparison:** `== != < <= > >=`. Over `Num` and (lexicographically) `Text`; all return `Bool`. Each is a [user-overloadable operator](#operator-overloading). - **Logical:** `&& || !` (short-circuit). + +> **`<` and `>` vs. `< >` blocks.** `<` and `>` double as the block delimiters. A `<` +> after a complete operand is always less-than (a block can't start mid-expression). A +> `>` is the **block close** only when it is the **last token on its line** (`>` +> followed by only spaces/tabs then a newline or end-of-file); any other `>` — one with +> more on the same line, like `a > b` — is the greater-than operator. So `a > b` works +> everywhere; the only rule is *don't end a line with a comparison `>`* (write the right +> operand on the same line). `<=`/`>=`/`>>` are distinct tokens and unaffected. - **Ternary:** `cond ? then : else`. - **Blocks:** `< stmt… last >` are expressions that evaluate to their last expression — usable anywhere a value is, not just as a function body: ```quilon @@ -299,7 +363,7 @@ The type checker verifies matches are exhaustive (use `_` to cover the rest). (S | Function | Effect | |----------|--------| -| `print(x) -> $` | Write `x` to stdout, **with a trailing newline**. Polymorphic over `Num`/`Text`/`Bool` (`Bool` prints `true`/`false`). Returns `$` (Unit). | +| `print(x) -> $` | Write `x` to stdout, **with a trailing newline**. An [overload set](#overloading) over `Num`/`Text`/`Bool` (`Bool` prints `true`/`false`). Returns `$` (Unit). A user `print` definition *adds* an overload. | | `eprint(x) -> $` | Same, to stderr. Returns `$` (Unit). | | `write(content :: Text, fd :: Num) -> Num` | Write raw bytes (no newline) to a file descriptor; returns bytes written. | | `stdout`, `stderr` | The standard file descriptors. | @@ -365,10 +429,10 @@ is correct in the presence of multi-byte characters. For example, the program add = (a :: Num) -> Num => a + true ``` -reports: +reports (since `+` is an [overload set](#overloading), a `Num + Bool` matches no member): ``` -program.ql:1:28: error: Type mismatch: expected Num, got Bool +program.ql:1:28: error: No overload of '+' matches argument types (Num, Bool). Candidates: (Num, Num), (Text, Text) | 1 | add = (a :: Num) -> Num => a + true | ^^^^^^^^ @@ -389,6 +453,9 @@ message instead. Any compile error exits with status 1. | `^` entry point, native compile + JIT `run` | ✅ | | `Num`, arithmetic, comparison, logical, ternary | ✅ | | `Text` built-in: literals, `+`, `.size`, `.length` | ✅ | +| `Text` comparison: `==`/`!=` (equality), `<`/`<=`/`>`/`>=` (lexicographic) | ✅ | +| Ad-hoc overloading: same-named typed defs, exact-type dispatch | ✅ | +| Operator overloading (`+`, comparisons, … on user types); built-ins as overloads | ✅ | | `Bool` | ✅ | | `Unit` type / value (`$`) | ✅ | | Arrays: literals, `.size`, `[index]` | ✅ | @@ -407,7 +474,8 @@ message instead. Any compile error exits with status 1. | Conservative GC (Boehm) | ✅ | | `Text` in records/arrays, or as a sum-type payload (`Ok(text)`) | 🚧 | | Command-line `argv` (argc works; argv is a placeholder) | 🚧 | -| Generics, closures, `while` loops | ❌ | +| Generics / type variables (overloading is the only polymorphism), closures, `while` loops | ❌ | +| Overloaded name passed as a value (higher-order); only direct call sites resolve | ❌ | | Array methods (`map`/`filter`/`reduce`), string interpolation | ❌ | --- @@ -416,10 +484,13 @@ message instead. Any compile error exits with status 1. 0.9 is a stable **core**, not the whole language. Notably: -- **Non-numeric data in composites isn't sound yet.** `Text` inside a record or an array, and non-numeric sum-type payloads such as `Ok("x")` / `NotOk("error")`, do not type-check correctly in 0.9 — numeric payloads and numeric records/arrays work. Planned for a later release. +- **Non-numeric data in composites isn't sound yet.** `Text` inside a record or an array, and non-numeric sum-type payloads such as `Ok("x")` / `NotOk("error")`, do not type-check correctly in 0.9 — numeric payloads and numeric records/arrays work. Planned for a later release. A corollary for [overloading](#overloading): a `Result` payload is generic, so binding it (`Ok(x) => …`) and passing it to an [overload set](#overloading) resolves to the **`Num`** member (numeric payloads work end-to-end); a non-numeric `Result` payload (`Ok("x")`) routed through an overload is part of this same non-numeric-payload gap. A **user** sum type's payloads are concrete (`Circle(Num)`, `On(Bool)`), so they dispatch overloads correctly by their declared type. - **Array `.size` works only on a named receiver** (`xs.size`), not on a literal/expression (`[1,2,3].size`). -- A user-defined `print`/`eprint` is honored by the type checker but the code generator still lowers the built-in — overriding the runtime body is a follow-up. -- **No generics, closures, or `while` loops.** The module system is minimal (`core.io` built-in + file-path imports). +- **No generics, closures, or `while` loops.** Overloading (ad-hoc, exact-type + dispatch) is the only polymorphism; there are no type variables. The module system is + minimal (`core.io` built-in + file-path imports). +- **Overloads resolve at direct call sites only.** Passing an overloaded name as a value + (higher-order use) is not yet supported. - **Sum-type payloads mixing types across variants behind one value aren't unified yet.** Each variant's payload slots have a fixed representation sized to the widest variant; a single value carries one variant's payload. Distinct payload *types* per slot across variants (e.g. a position that is `Num` in one variant and `Text` in another) is a deferred follow-up — the built-in payload set (`Num`/`Text`/`Bool`, consistent per position) works. - `argv` is a placeholder (0); full `[]Text` conversion is planned. diff --git a/examples/overloading.ql b/examples/overloading.ql new file mode 100644 index 0000000..b666337 --- /dev/null +++ b/examples/overloading.ql @@ -0,0 +1,31 @@ +~ Ad-hoc overloading: multiple same-named typed definitions form an overload set, +~ resolved at each call site by the EXACT static argument types (no coercion). +~ Operators are user-overloadable too — an operator is just a named overload set. + +~ --- A user function overload set: same name, different parameter types. --- +~ The call site picks the member whose parameter type matches exactly. +score = (n :: Num) -> Num => n + 1 ~ Num overload +score = (s :: Text) -> Num => s.size ~ Text overload (byte length) + +~ --- A user operator overload on a record type. --- +~ `==` on Color compares the two components; it returns Bool, like any `==`. +Color = { r :: Num, g :: Num } +== = (a :: Color, b :: Color) -> Bool => a.r == b.r && a.g == b.g + +^ = () -> Num => < + ~ Overload dispatch by argument type: + fromNum = score(41) ~ Num overload -> 42 + fromText = score("abcd") ~ Text overload -> 4 + + ~ User operator overload (`==` on Color): + sameColor = Color { r = 1, g = 2 } == Color { r = 1, g = 2 } ? 100 : 0 ~ -> 100 + diffColor = Color { r = 1, g = 2 } == Color { r = 9, g = 2 } ? 1 : 0 ~ -> 0 + + ~ Built-in Text comparison overloads — equality and lexicographic ordering: + textEq = "quilon" == "quilon" ? 7 : 0 ~ -> 7 + textLt = "abc" < "abd" ? 3 : 0 ~ -> 3 (lexicographic: 'c' < 'd') + textGt = "b" > "a" ? 5 : 0 ~ -> 5 (bare `>` works on one line) + + ~ Deterministic total: 42 + 4 + 100 + 0 + 7 + 3 + 5 = 161 + fromNum + fromText + sameColor + diffColor + textEq + textLt + textGt +> diff --git a/quilon-rt/src/lib.rs b/quilon-rt/src/lib.rs index 9c98913..a378b6c 100644 --- a/quilon-rt/src/lib.rs +++ b/quilon-rt/src/lib.rs @@ -66,6 +66,33 @@ pub extern "C" fn __text_length(ptr: *const u8, len: i64) -> i64 { } } +/// Lexicographically compare two UTF-8 byte strings, returning -1, 0, or 1 (like +/// `memcmp`/Rust's `Ord` on byte slices: a common prefix orders by length). Backs the +/// `Text` comparison operators (`==`/`!=`/`<`/`<=`/`>`/`>=`). +/// +/// # Safety contract (upheld by the compiler) +/// `a`/`b` are null or point to at least `alen`/`blen` readable bytes. +#[allow(clippy::not_unsafe_ptr_arg_deref)] +#[unsafe(no_mangle)] +pub extern "C" fn __text_cmp(a: *const u8, alen: i64, b: *const u8, blen: i64) -> i32 { + let lhs = byte_slice(a, alen); + let rhs = byte_slice(b, blen); + match lhs.cmp(rhs) { + std::cmp::Ordering::Less => -1, + std::cmp::Ordering::Equal => 0, + std::cmp::Ordering::Greater => 1, + } +} + +/// View `len` bytes at `ptr` as a slice (empty for null/non-positive `len`). +fn byte_slice<'a>(ptr: *const u8, len: i64) -> &'a [u8] { + if ptr.is_null() || len <= 0 { + &[] + } else { + unsafe { std::slice::from_raw_parts(ptr, len as usize) } + } +} + /// Write `len` bytes from `ptr` to file descriptor `fd`, returning the number of /// bytes written (0 on null/empty/error). Backs the `write(content, fd)` builtin. /// diff --git a/src/ast/nodes.rs b/src/ast/nodes.rs index 5421508..c6b3f65 100644 --- a/src/ast/nodes.rs +++ b/src/ast/nodes.rs @@ -345,18 +345,50 @@ pub enum BinOp { Mod, Eq, Ne, - // `<` and `>` are block delimiters in Quilon, so bare less/greater-than are not - // parsed as operators yet; reserved for when the grammar disambiguates them. - #[allow(dead_code)] + // `<` and `>` double as block delimiters; the parser disambiguates them as + // comparison operators in operand position (a bare `>` only outside a `< >` + // block — see `match_comparison`). Lt, Le, - #[allow(dead_code)] Gt, Ge, And, Or, } +impl BinOp { + /// The operator's source symbol, which doubles as its overload-set name (an + /// operator is just a named overload set under the hood). Shared by the type + /// checker and codegen so a user operator overload is keyed identically in both. + pub fn symbol(self) -> &'static str { + match self { + BinOp::Add => "+", + BinOp::Sub => "-", + BinOp::Mul => "*", + BinOp::Div => "/", + BinOp::Mod => "%", + BinOp::Eq => "==", + BinOp::Ne => "!=", + BinOp::Lt => "<", + BinOp::Le => "<=", + BinOp::Gt => ">", + BinOp::Ge => ">=", + BinOp::And => "&&", + BinOp::Or => "||", + } + } +} + +/// Whether `name` is an operator symbol — and thus always an overload set, never a +/// plain value binding. Shared by the type checker and the code generator so both +/// agree on exactly which names are operators (the binary operator symbols). +pub fn is_operator_symbol(name: &str) -> bool { + matches!( + name, + "+" | "-" | "*" | "/" | "%" | "==" | "!=" | "<" | "<=" | ">" | ">=" | "&&" | "||" + ) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum UnaryOp { Neg, diff --git a/src/codegen/generator.rs b/src/codegen/generator.rs index 4182e29..17ae209 100644 --- a/src/codegen/generator.rs +++ b/src/codegen/generator.rs @@ -2,7 +2,7 @@ use crate::ast::{ BinOp, Expr, FunctionDecl, Item, MatchArm, MethodDecl, Pattern, Program, Type, TypeDecl, - TypeDef, UnaryOp, VarDecl, + TypeDef, UnaryOp, VarDecl, is_operator_symbol, }; use inkwell::AddressSpace; use inkwell::builder::Builder; @@ -12,6 +12,81 @@ use inkwell::types::{BasicType, BasicTypeEnum}; use inkwell::values::{BasicValue, BasicValueEnum, FunctionValue, PointerValue}; use std::collections::HashMap; +/// Names that the compiler provides built-in overloads for (`print`/`eprint`, lowered +/// to runtime intrinsics). A user definition of one ADDS an overload member (and is +/// mangled), rather than shadowing the built-in single-arg Num/Text/Bool forms. +fn is_builtin_overload_name(name: &str) -> bool { + matches!(name, "print" | "eprint") +} + +/// The inert `core.io` `print`/`eprint` placeholder (single unannotated param). The +/// compiler fully provides these as intrinsics, so the placeholder is never emitted +/// or registered as an overload member — see the type checker's matching predicate. +fn is_inert_io_placeholder(decl: &FunctionDecl) -> bool { + (decl.name == "print" || decl.name == "eprint") + && decl.params.len() == 1 + && decl.params[0].type_annotation.is_none() +} + +/// A short, mangling-safe tag for a Quilon type used in overload name mangling. Must be +/// deterministic and identical at definition and call sites (built from the declared +/// parameter type and from the inferred argument type respectively). +fn type_mangle(ty: &Type) -> String { + match ty { + Type::Num => "N".to_string(), + Type::Text => "T".to_string(), + Type::Bool => "B".to_string(), + Type::Unit => "U".to_string(), + Type::Array(elem) => format!("A{}", type_mangle(elem)), + Type::Named { name, .. } | Type::Sum { name, .. } => format!("named${}", name), + // A not-yet-concrete sum payload (`Generic`) resolves as `Num` for overload + // dispatch (see the type checker's `types_match`), so it mangles to the Num tag + // — keeping codegen's chosen symbol in agreement with the checker. + Type::Generic { .. } => "N".to_string(), + // Any other shape (e.g. a function type) — a stable, mangling-safe fallback. + other => format!("X{:?}", other) + .chars() + .filter(|c| c.is_alphanumeric() || *c == '$') + .collect(), + } +} + +/// The distinct LLVM symbol for one overload member: its name plus a per-parameter +/// type tag. Operator symbols (which aren't valid LLVM identifiers) are spelled out so +/// e.g. `+` on `(Point, Point)` becomes `op.add$named$Point$named$Point`. +fn mangle_overload(name: &str, params: &[Type]) -> String { + let base = operator_word(name) + .map(|w| format!("op.{}", w)) + .unwrap_or_else(|| name.to_string()); + let mut s = base; + for p in params { + s.push('$'); + s.push_str(&type_mangle(p)); + } + s +} + +/// A pronounceable word for an operator symbol, for use in a mangled LLVM name (which +/// can't contain the raw symbol). Returns `None` for non-operator (ordinary) names. +fn operator_word(name: &str) -> Option<&'static str> { + Some(match name { + "+" => "add", + "-" => "sub", + "*" => "mul", + "/" => "div", + "%" => "mod", + "==" => "eq", + "!=" => "ne", + "<" => "lt", + "<=" => "le", + ">" => "gt", + ">=" => "ge", + "&&" => "and", + "||" => "or", + _ => return None, + }) +} + /// A zero/`undef`-free constant of any basic LLVM type, used to fill a payload slot that /// carries no information (a `$` Unit payload stored into a sized slot). fn zeroed(ty: BasicTypeEnum<'_>) -> BasicValueEnum<'_> { @@ -42,6 +117,12 @@ pub struct CodeGenerator<'ctx> { // the variant's declaration index. Drives constructor codegen and tag-based pattern // dispatch (generalizing the old hardcoded Ok=0/NotOk=1). sum_variants: HashMap, + // Declared payload Quilon types per variant (constructor name -> field types). + // Lets `bind_pattern` record a matched payload binding's type in `var_types`, so an + // overloaded call on that binding (e.g. `Circle(n) => area(n)`) mangles by the + // payload's concrete type. (Result's `Ok`/`NotOk` carry `Generic`, which resolves + // as Num for overloads — see the type checker's `types_match`.) + variant_payloads: HashMap>, // Per-sum-type canonical payload layout (one LLVM type per payload slot), sized to // the widest variant so EVERY value of the type has the same struct shape // `{ i8 tag, slot0, slot1, ... }`. This lets a match arm extract any variant's @@ -51,6 +132,24 @@ pub struct CodeGenerator<'ctx> { // payloads are sized per-value at construction — see `register_builtin_sum_types`). sum_layouts: HashMap>>, current_function: Option>, + // Overload sets, keyed by name (function names AND operator symbols like `"+"`). + // Each entry is the list of that name's overload parameter-type signatures. A name + // is present here iff it is an overload set (operator-named, or 2+ same-named + // top-level defs); calls/operators to these names dispatch to a NAME-MANGLED + // function (`mangle_overload`) chosen by exact argument types. Operator builtins + // (Num/Text `+`, comparisons) are NOT entered here — they keep their inline + // lowering; only USER operator overloads add an operator symbol to this map. + // Each member is its `(parameter types, return type)`. + overloads: HashMap, Type)>>, + // Quilon type of each in-scope local/param, for argument-type inference at + // overloaded call sites (codegen lacks the type checker's full inference, so it + // tracks just enough — locals, params, and constructor results — to mangle). + var_types: HashMap, + // Declared return type of each NON-overloaded top-level function, so `infer_type` + // can give a call's result its real type (not a `Num` default) when that result is + // an argument to an overloaded call/operator — keeping codegen dispatch in sync + // with the type checker. (Overloaded callees' returns come from `overloads`.) + fn_return_types: HashMap, } impl<'ctx> CodeGenerator<'ctx> { @@ -67,8 +166,12 @@ impl<'ctx> CodeGenerator<'ctx> { named_type_fields: HashMap::new(), var_named_types: HashMap::new(), sum_variants: HashMap::new(), + variant_payloads: HashMap::new(), sum_layouts: HashMap::new(), current_function: None, + overloads: HashMap::new(), + var_types: HashMap::new(), + fn_return_types: HashMap::new(), }; codegen.register_builtin_sum_types(); codegen @@ -87,6 +190,16 @@ impl<'ctx> CodeGenerator<'ctx> { .insert("Ok".to_string(), (0u8, "Result".to_string())); self.sum_variants .insert("NotOk".to_string(), (1u8, "Result".to_string())); + // Result's payloads are generic (`Ok(T)` / `NotOk(E)`); a `Generic` binding + // resolves as Num for overload dispatch (see the type checker's `types_match`). + let generic = |n: &str| Type::Generic { + name: n.to_string(), + args: vec![], + }; + self.variant_payloads + .insert("Ok".to_string(), vec![generic("T")]); + self.variant_payloads + .insert("NotOk".to_string(), vec![generic("E")]); } /// Access the underlying LLVM module after `generate` has populated it. @@ -109,6 +222,53 @@ impl<'ctx> CodeGenerator<'ctx> { } } + // Pre-pass: discover overload sets (operator-named, or 2+ same-named defs), + // mirroring the type checker. Their definitions are name-mangled by parameter + // type and dispatched by exact argument type at each call/operator site. + let mut fn_counts: HashMap<&str, usize> = HashMap::new(); + for item in &program.items { + if let Item::FunctionDecl(decl) = item + && !is_inert_io_placeholder(decl) + { + *fn_counts.entry(decl.name.as_str()).or_insert(0) += 1; + } + } + for item in &program.items { + if let Item::FunctionDecl(decl) = item + && !is_inert_io_placeholder(decl) + && (is_operator_symbol(&decl.name) + || fn_counts.get(decl.name.as_str()).copied().unwrap_or(0) > 1 + || is_builtin_overload_name(&decl.name)) + && decl.name != "^" + { + let params: Vec = decl + .params + .iter() + .map(|p| p.type_annotation.clone().unwrap_or(Type::Num)) + .collect(); + // The return type drives argument-type inference for a value bound to + // an overloaded call/operator (e.g. a user `+` returning a record). + let ret = decl.return_type.clone().unwrap_or(Type::Num); + self.overloads + .entry(decl.name.clone()) + .or_default() + .push((params, ret)); + } + } + + // Pre-pass: record each NON-overloaded top-level function's declared return + // type, so `infer_type` can give a call result its real type when it feeds an + // overloaded call/operator (keeps codegen dispatch in sync with the checker). + for item in &program.items { + if let Item::FunctionDecl(decl) = item + && !is_inert_io_placeholder(decl) + && !self.overloads.contains_key(&decl.name) + && let Some(ret) = &decl.return_type + { + self.fn_return_types.insert(decl.name.clone(), ret.clone()); + } + } + // Generate code for all top-level items for item in &program.items { self.generate_item(item)?; @@ -235,6 +395,10 @@ impl<'ctx> CodeGenerator<'ctx> { for (tag, variant) in variants.iter().enumerate() { self.sum_variants .insert(variant.name.clone(), (tag as u8, type_name.to_string())); + // Record the variant's declared (concrete) payload types so a match arm's + // payload binding gets its real type for overloaded-call mangling. + self.variant_payloads + .insert(variant.name.clone(), variant.fields.clone()); } let max_arity = variants.iter().map(|v| v.fields.len()).max().unwrap_or(0); @@ -388,6 +552,18 @@ impl<'ctx> CodeGenerator<'ctx> { .insert(decl.name.clone(), type_name.clone()); } + // Remember the binding's Quilon type for overloaded-call argument mangling. + let inferred_qty = self.infer_type(&decl.value); + // If the value is a named record (e.g. bound to a user operator overload's + // result), track its type/fields so later `name.field` / method calls resolve. + if let Type::Named { name, .. } = &inferred_qty + && let Some(fields) = self.named_type_fields.get(name).cloned() + { + self.record_types.insert(decl.name.clone(), fields); + self.var_named_types.insert(decl.name.clone(), name.clone()); + } + self.var_types.insert(decl.name.clone(), inferred_qty); + let value = self.generate_expr(&decl.value)?; if self.current_function.is_some() { @@ -410,6 +586,12 @@ impl<'ctx> CodeGenerator<'ctx> { } fn generate_function_decl(&mut self, decl: &FunctionDecl) -> Result<(), String> { + // The inert core.io print/eprint placeholder is never emitted (the compiler + // lowers print/eprint to runtime intrinsics). + if is_inert_io_placeholder(decl) { + return Ok(()); + } + // Convert parameter types to LLVM types let param_types: Vec = decl .params @@ -448,7 +630,21 @@ impl<'ctx> CodeGenerator<'ctx> { // into one native binary (AOT). For example core.io's `write` placeholder, or // a user function named `read`/`open`, would otherwise shadow libc and break // the runtime intrinsics. Only the generated `main` wrapper is exported. - let function = self.module.add_function(&decl.name, fn_type, None); + // + // An overloaded member (operator-named, or one of several same-named defs) is + // emitted under a per-signature MANGLED name so the members don't collide; each + // call site dispatches to the matching mangled symbol by exact argument type. + let symbol = if self.overloads.contains_key(&decl.name) { + let params: Vec = decl + .params + .iter() + .map(|p| p.type_annotation.clone().unwrap_or(Type::Num)) + .collect(); + mangle_overload(&decl.name, ¶ms) + } else { + decl.name.clone() + }; + let function = self.module.add_function(&symbol, fn_type, None); function.set_linkage(inkwell::module::Linkage::Internal); self.current_function = Some(function); @@ -458,6 +654,7 @@ impl<'ctx> CodeGenerator<'ctx> { // Store parameters in variables map self.variables.clear(); + self.var_types.clear(); for (i, param) in decl.params.iter().enumerate() { let llvm_param = function.get_nth_param(i as u32).unwrap(); llvm_param.set_name(¶m.name); @@ -471,6 +668,17 @@ impl<'ctx> CodeGenerator<'ctx> { self.variables .insert(param.name.clone(), (alloca, param_type)); + // Track the parameter's Quilon type for overloaded-call mangling, and so a + // record/sum parameter's methods/fields resolve. + let qty = param.type_annotation.clone().unwrap_or(Type::Num); + if let Type::Named { name, .. } | Type::Sum { name, .. } = &qty { + self.var_named_types + .insert(param.name.clone(), name.clone()); + if let Some(fields) = self.named_type_fields.get(name) { + self.record_types.insert(param.name.clone(), fields.clone()); + } + } + self.var_types.insert(param.name.clone(), qty); } // Generate function body @@ -646,9 +854,36 @@ impl<'ctx> CodeGenerator<'ctx> { op: BinOp, right: &Expr, ) -> Result, String> { + // A USER operator overload (e.g. `+`/`==` on a record type) lowers to a direct + // call to its mangled function — the operator is just a named overload set. + // Built-in operators (Num arithmetic/compare, Text `+`/comparison) keep their + // inline lowering below; they are not entered in `self.overloads`. + let sym = op.symbol(); + if self.overloads.contains_key(sym) { + let arg_types = [self.infer_type(left), self.infer_type(right)]; + if let Some(symbol) = self.resolve_overload_symbol(sym, &arg_types) { + let l = self.generate_expr(left)?; + let r = self.generate_expr(right)?; + return self.build_direct_call(&symbol, &[l, r]); + } + } + let lhs = self.generate_expr(left)?; let rhs = self.generate_expr(right)?; + // Text comparison: both operands are `Text` { ptr, i64 } structs. Lower + // equality and lexicographic ordering via the `__text_cmp` runtime intrinsic + // (returns -1/0/1), then compare its result against 0 with the matching + // integer predicate. (Num operands fall through to the float paths below.) + if matches!( + op, + BinOp::Eq | BinOp::Ne | BinOp::Lt | BinOp::Le | BinOp::Gt | BinOp::Ge + ) && matches!(lhs, BasicValueEnum::StructValue(_)) + && matches!(rhs, BasicValueEnum::StructValue(_)) + { + return self.generate_text_compare(op, lhs, rhs); + } + match op { BinOp::Add => match (lhs, rhs) { (BasicValueEnum::FloatValue(l), BasicValueEnum::FloatValue(r)) => Ok(self @@ -695,28 +930,33 @@ impl<'ctx> CodeGenerator<'ctx> { Err("Div operation requires float values".to_string()) } } - BinOp::Eq => { - if let (BasicValueEnum::FloatValue(l), BasicValueEnum::FloatValue(r)) = (lhs, rhs) { - Ok(self - .builder - .build_float_compare(inkwell::FloatPredicate::OEQ, l, r, "eqtmp") - .map_err(|e| format!("Failed to build compare: {:?}", e))? - .into()) - } else { - Err("Eq operation requires float values".to_string()) - } - } - BinOp::Ne => { - if let (BasicValueEnum::FloatValue(l), BasicValueEnum::FloatValue(r)) = (lhs, rhs) { - Ok(self - .builder - .build_float_compare(inkwell::FloatPredicate::ONE, l, r, "netmp") - .map_err(|e| format!("Failed to build compare: {:?}", e))? - .into()) - } else { - Err("Ne operation requires float values".to_string()) - } - } + BinOp::Eq => match (lhs, rhs) { + (BasicValueEnum::FloatValue(l), BasicValueEnum::FloatValue(r)) => Ok(self + .builder + .build_float_compare(inkwell::FloatPredicate::OEQ, l, r, "eqtmp") + .map_err(|e| format!("Failed to build compare: {:?}", e))? + .into()), + // Bool == Bool (both i1) compares the integer values. + (BasicValueEnum::IntValue(l), BasicValueEnum::IntValue(r)) => Ok(self + .builder + .build_int_compare(inkwell::IntPredicate::EQ, l, r, "eqtmp") + .map_err(|e| format!("Failed to build compare: {:?}", e))? + .into()), + _ => Err("Eq requires two Nums or two Bools".to_string()), + }, + BinOp::Ne => match (lhs, rhs) { + (BasicValueEnum::FloatValue(l), BasicValueEnum::FloatValue(r)) => Ok(self + .builder + .build_float_compare(inkwell::FloatPredicate::ONE, l, r, "netmp") + .map_err(|e| format!("Failed to build compare: {:?}", e))? + .into()), + (BasicValueEnum::IntValue(l), BasicValueEnum::IntValue(r)) => Ok(self + .builder + .build_int_compare(inkwell::IntPredicate::NE, l, r, "netmp") + .map_err(|e| format!("Failed to build compare: {:?}", e))? + .into()), + _ => Err("Ne requires two Nums or two Bools".to_string()), + }, BinOp::Lt => { if let (BasicValueEnum::FloatValue(l), BasicValueEnum::FloatValue(r)) = (lhs, rhs) { Ok(self @@ -883,6 +1123,11 @@ impl<'ctx> CodeGenerator<'ctx> { "memcpy" => ptr.fn_type(&[ptr.into(), ptr.into(), i64t.into()], false), // i64 __text_length(i8*, i64) — grapheme-cluster count. "__text_length" => i64t.fn_type(&[ptr.into(), i64t.into()], false), + // i32 __text_cmp(i8* a, i64 alen, i8* b, i64 blen) — lexicographic byte + // comparison, returning -1 / 0 / 1. Backs Text ==/!=//>=. + "__text_cmp" => ctx + .i32_type() + .fn_type(&[ptr.into(), i64t.into(), ptr.into(), i64t.into()], false), // i64 __write_bytes(i64 fd, i8* ptr, i64 len) — raw write, backs `write`. "__write_bytes" => i64t.fn_type(&[i64t.into(), ptr.into(), i64t.into()], false), // void __print_num_fd(i64 fd, double) — number + newline to fd. @@ -1036,8 +1281,22 @@ impl<'ctx> CodeGenerator<'ctx> { }; // Core IO builtins, lowered to runtime intrinsics (see runtime::intrinsics). + // `print`/`eprint` are the built-in single-arg Num/Text/Bool overloads; a + // *user* overload of the same name (a different signature) is dispatched as a + // mangled function below, so only use the intrinsic when no user overload + // matches the argument types. match func_name.as_str() { - "print" | "eprint" => return self.generate_print(func_name, args), + "print" | "eprint" => { + let arg_types: Vec = args.iter().map(|a| self.infer_type(a)).collect(); + let is_builtin_print = arg_types.len() == 1 + && matches!(arg_types[0], Type::Num | Type::Text | Type::Bool); + let has_user_match = self + .resolve_overload_symbol(func_name, &arg_types) + .is_some(); + if is_builtin_print && !has_user_match { + return self.generate_print(func_name, args); + } + } "write" => return self.generate_write(args), _ => {} } @@ -1049,19 +1308,34 @@ impl<'ctx> CodeGenerator<'ctx> { return self.generate_sum_constructor(tag, &type_name, args); } + // Overloaded function call: dispatch to the per-signature mangled symbol chosen + // by exact argument types (the type checker has already verified a unique match). + let overload_symbol = if self.overloads.contains_key(func_name.as_str()) { + let arg_types: Vec = args.iter().map(|a| self.infer_type(a)).collect(); + self.resolve_overload_symbol(func_name, &arg_types) + } else { + None + }; + // Get the function from the module. If there is no plain top-level function with this // name, it may be a method call: the parser desugars `recv.method(a, b)` to // `method(recv, a, b)`, so resolve `recv`'s named type and dispatch to `Type_method`. - let function = match self.module.get_function(func_name) { - Some(f) => f, - None => { - let mangled = args - .first() - .and_then(|recv| self.receiver_type_name(recv)) - .map(|type_name| format!("{}_{}", type_name, func_name)); - match mangled.and_then(|m| self.module.get_function(&m)) { - Some(f) => f, - None => return Err(format!("Function not found: {}", func_name)), + let function = if let Some(sym) = &overload_symbol { + self.module + .get_function(sym) + .ok_or_else(|| format!("Overload not found: {}", sym))? + } else { + match self.module.get_function(func_name) { + Some(f) => f, + None => { + let mangled = args + .first() + .and_then(|recv| self.receiver_type_name(recv)) + .map(|type_name| format!("{}_{}", type_name, func_name)); + match mangled.and_then(|m| self.module.get_function(&m)) { + Some(f) => f, + None => return Err(format!("Function not found: {}", func_name)), + } } } }; @@ -1426,24 +1700,34 @@ impl<'ctx> CodeGenerator<'ctx> { // Create the struct value if self.current_function.is_some() { - // Allocate space for the struct - let alloca = self + // GC-allocate the struct (not a stack alloca) so a record VALUE can outlive + // the frame that built it — e.g. a record returned from a function or a user + // operator overload (`+ = (a :: Vec, b :: Vec) -> Vec => Vec { ... }`). A + // stack alloca would dangle once the callee returned. + use inkwell::values::AnyValue; + let size = struct_type + .size_of() + .ok_or_else(|| "record struct type has no compile-time size".to_string())?; + let alloc_fn = self.get_intrinsic("__alloc")?; + let record_ptr = self .builder - .build_alloca(struct_type, "record") - .map_err(|e| format!("Failed to build alloca: {:?}", e))?; + .build_call(alloc_fn, &[size.into()], "record") + .map_err(|e| format!("Failed to call __alloc for record: {:?}", e))? + .as_any_value_enum() + .into_pointer_value(); // Store each field for (i, value) in field_values.iter().enumerate() { let gep = self .builder - .build_struct_gep(struct_type, alloca, i as u32, &format!("field_{}", i)) + .build_struct_gep(struct_type, record_ptr, i as u32, &format!("field_{}", i)) .map_err(|e| format!("Failed to build GEP: {:?}", e))?; self.builder .build_store(gep, *value) .map_err(|e| format!("Failed to build store: {:?}", e))?; } - Ok(alloca.into()) + Ok(record_ptr.into()) } else { // For globals, we need constant values Err("Global records not yet implemented".to_string()) @@ -1527,6 +1811,92 @@ impl<'ctx> CodeGenerator<'ctx> { Ok(text.into()) } + /// Build a direct call to an already-emitted function by symbol, given the + /// already-generated argument values. Used to lower a resolved operator/function + /// overload to its mangled target. + fn build_direct_call( + &mut self, + symbol: &str, + arg_values: &[BasicValueEnum<'ctx>], + ) -> Result, String> { + let function = self + .module + .get_function(symbol) + .ok_or_else(|| format!("Overload not found: {}", symbol))?; + let arg_metadata: Vec = + arg_values.iter().map(|v| (*v).into()).collect(); + use inkwell::values::AnyValue; + let call_site = self + .builder + .build_call(function, &arg_metadata, "calltmp") + .map_err(|e| format!("Failed to build call: {:?}", e))?; + match call_site.as_any_value_enum() { + inkwell::values::AnyValueEnum::IntValue(v) => Ok(v.into()), + inkwell::values::AnyValueEnum::FloatValue(v) => Ok(v.into()), + inkwell::values::AnyValueEnum::PointerValue(v) => Ok(v.into()), + inkwell::values::AnyValueEnum::ArrayValue(v) => Ok(v.into()), + inkwell::values::AnyValueEnum::StructValue(v) => Ok(v.into()), + inkwell::values::AnyValueEnum::VectorValue(v) => Ok(v.into()), + _ => Err("Overloaded function does not return a basic value".to_string()), + } + } + + /// Lower a `Text`-vs-`Text` comparison: call `__text_cmp(aptr, alen, bptr, blen)` + /// (returns -1/0/1, memcmp-style with the shorter string ordering first on a common + /// prefix), then compare that i32 result against 0 with the predicate matching `op`. + /// Backs `Text` equality and lexicographic ordering (`==`/`!=`/`<`/`<=`/`>`/`>=`). + fn generate_text_compare( + &mut self, + op: BinOp, + lhs: BasicValueEnum<'ctx>, + rhs: BasicValueEnum<'ctx>, + ) -> Result, String> { + let (BasicValueEnum::StructValue(l), BasicValueEnum::StructValue(r)) = (lhs, rhs) else { + return Err("Text comparison requires two Text values".to_string()); + }; + let extract = |s: inkwell::values::StructValue<'ctx>, + idx: u32, + name: &str| + -> Result, String> { + self.builder + .build_extract_value(s, idx, name) + .map_err(|e| format!("Failed to extract text field: {:?}", e)) + }; + let l_ptr = extract(l, 0, "lcmp_ptr")?.into_pointer_value(); + let l_len = extract(l, 1, "lcmp_len")?.into_int_value(); + let r_ptr = extract(r, 0, "rcmp_ptr")?.into_pointer_value(); + let r_len = extract(r, 1, "rcmp_len")?.into_int_value(); + + let cmp_fn = self.get_intrinsic("__text_cmp")?; + use inkwell::values::AnyValue; + let cmp = self + .builder + .build_call( + cmp_fn, + &[l_ptr.into(), l_len.into(), r_ptr.into(), r_len.into()], + "text_cmp", + ) + .map_err(|e| format!("Failed to call __text_cmp: {:?}", e))? + .as_any_value_enum() + .into_int_value(); + + let pred = match op { + BinOp::Eq => inkwell::IntPredicate::EQ, + BinOp::Ne => inkwell::IntPredicate::NE, + BinOp::Lt => inkwell::IntPredicate::SLT, + BinOp::Le => inkwell::IntPredicate::SLE, + BinOp::Gt => inkwell::IntPredicate::SGT, + BinOp::Ge => inkwell::IntPredicate::SGE, + _ => return Err("non-comparison operator in text compare".to_string()), + }; + let zero = cmp.get_type().const_zero(); + Ok(self + .builder + .build_int_compare(pred, cmp, zero, "text_cmp_res") + .map_err(|e| format!("Failed to build text compare: {:?}", e))? + .into()) + } + fn generate_field_access( &mut self, expr: &Expr, @@ -1956,11 +2326,12 @@ impl<'ctx> CodeGenerator<'ctx> { Ok(()) } - Pattern::Constructor { name: _, args, .. } => { + Pattern::Constructor { name, args, .. } => { // Extract each payload field and bind it to the corresponding sub-pattern. // The value is `{ i8 tag, payload0, payload1, ... }`, so payload `i` is // struct field `i + 1`. Only identifier sub-patterns bind a name; others // (wildcards, nested constructors) are matched structurally elsewhere. + let payload_types = self.variant_payloads.get(name).cloned(); if let BasicValueEnum::StructValue(struct_val) = value { for (i, arg) in args.iter().enumerate() { if let Pattern::Ident { name: arg_name, .. } = arg { @@ -1975,6 +2346,12 @@ impl<'ctx> CodeGenerator<'ctx> { .map_err(|e| format!("Failed to store constructor arg: {:?}", e))?; self.variables .insert(arg_name.clone(), (alloca, payload.get_type())); + // Track the payload binding's declared Quilon type so an + // overloaded call on it (`Circle(n) => area(n)`) mangles by + // the concrete payload type, agreeing with the type checker. + if let Some(ty) = payload_types.as_ref().and_then(|t| t.get(i)) { + self.var_types.insert(arg_name.clone(), ty.clone()); + } } } } @@ -2108,6 +2485,9 @@ impl<'ctx> CodeGenerator<'ctx> { .map_err(|e| format!("Failed to store item: {:?}", e))?; self.variables .insert(name.clone(), (item_alloca, elem_val.get_type())); + // Loop elements are loaded as Num (codegen supports numeric arrays); + // track it so an overloaded call on the loop var mangles correctly. + self.var_types.insert(name.clone(), Type::Num); } ForPattern::ItemIndex { item, index, .. } => { // Bind item @@ -2117,6 +2497,8 @@ impl<'ctx> CodeGenerator<'ctx> { .map_err(|e| format!("Failed to store item: {:?}", e))?; self.variables .insert(item.clone(), (item_alloca, elem_val.get_type())); + self.var_types.insert(item.clone(), Type::Num); + self.var_types.insert(index.clone(), Type::Num); // Bind index (convert i64 to f64 for Num type) let index_f64 = self @@ -2215,6 +2597,141 @@ impl<'ctx> CodeGenerator<'ctx> { } } + /// Best-effort Quilon type of `expr`, sufficient to mangle overloaded call sites. + /// Codegen lacks the type checker's full inference, so this covers exactly the + /// shapes that can be an overloaded argument: literals, locals/params (tracked in + /// `var_types`), constructor results, field access on a known record, and the + /// result types of the supported operators. Falls back to `Num` (the historical + /// default) when it can't tell — overloaded dispatch then simply won't match and a + /// clear "function not found" surfaces, never a silent miscompile. + fn infer_type(&self, expr: &Expr) -> Type { + match expr { + Expr::Number { .. } => Type::Num, + Expr::String { .. } => Type::Text, + Expr::Bool { .. } => Type::Bool, + Expr::Unit { .. } => Type::Unit, + Expr::Ident { name, .. } => { + // A bare nullary sum-type constructor (not a bound variable) is a value + // of its sum type. + if let Some((_, type_name)) = self.sum_variants.get(name) + && !self.var_types.contains_key(name) + { + return self.sum_or_named(type_name); + } + self.var_types.get(name).cloned().unwrap_or(Type::Num) + } + Expr::Constructor { type_name, .. } => self.sum_or_named(type_name), + Expr::Call { func, args, .. } => { + if let Expr::Ident { name, .. } = func.as_ref() { + // A constructor call yields its sum type. + if let Some((_, type_name)) = self.sum_variants.get(name) { + return self.sum_or_named(type_name); + } + // An overloaded function call yields its resolved member's return. + let arg_types: Vec = args.iter().map(|a| self.infer_type(a)).collect(); + if let Some((_, ret)) = self.matching_overload(name, &arg_types) { + return ret.clone(); + } + // A non-overloaded top-level function yields its declared return + // type — so a call result that feeds an overloaded call/operator + // mangles to the right member (codegen agrees with the checker). + if let Some(ret) = self.fn_return_types.get(name) { + return self.resolve_named(ret); + } + } + // Unknown callee (no declared return, e.g. an unannotated function): + // default to Num, the historical inference default. + Type::Num + } + Expr::BinOp { + left, op, right, .. + } => { + // A user operator overload yields its resolved member's return type. + let sym = op.symbol(); + if self.overloads.contains_key(sym) { + let arg_types = [self.infer_type(left), self.infer_type(right)]; + if let Some((_, ret)) = self.matching_overload(sym, &arg_types) { + return ret.clone(); + } + } + // Built-ins: comparisons/logicals yield Bool; `+` on Text yields Text; + // arithmetic yields Num. Matches the type checker's operator results + // closely enough to mangle a nested overloaded argument. + match op { + BinOp::Eq + | BinOp::Ne + | BinOp::Lt + | BinOp::Le + | BinOp::Gt + | BinOp::Ge + | BinOp::And + | BinOp::Or => Type::Bool, + BinOp::Add + if self.infer_type(left) == Type::Text + || self.infer_type(right) == Type::Text => + { + Type::Text + } + _ => Type::Num, + } + } + Expr::If { then, .. } => self.infer_type(then), + Expr::Block { stmts, .. } => match stmts.last() { + Some(crate::ast::Statement::Expr(tail)) => self.infer_type(tail), + _ => Type::Num, + }, + Expr::FieldAccess { field, .. } if field == "size" || field == "length" => Type::Num, + _ => Type::Num, + } + } + + /// Normalize a declared type annotation for `infer_type`: a bare `Named { name }` + /// (the parser's form for a `:: SomeType` reference) becomes the canonical sum/named + /// tag so it mangles identically to an inferred value of that type. Built-ins pass + /// through unchanged. + fn resolve_named(&self, ty: &Type) -> Type { + match ty { + Type::Named { name, .. } | Type::Sum { name, .. } => self.sum_or_named(name), + other => other.clone(), + } + } + + /// The `Type` for a registered type name: a sum type if known, else a `Named`. + fn sum_or_named(&self, name: &str) -> Type { + if self.sum_layouts.contains_key(name) || name == "Result" { + Type::Sum { + name: name.to_string(), + variants: vec![], + } + } else { + Type::Named { + name: name.to_string(), + fields: vec![], + methods: vec![], + } + } + } + + /// If `name` is an overload set, pick the member matching `arg_types` exactly and + /// return its mangled LLVM symbol. `None` if `name` isn't overloaded or nothing + /// matches (the caller then falls back to its non-overloaded path). + fn resolve_overload_symbol(&self, name: &str, arg_types: &[Type]) -> Option { + let (params, _) = self.matching_overload(name, arg_types)?; + Some(mangle_overload(name, params)) + } + + /// The overload member of `name` whose parameter types match `arg_types` exactly + /// (by type tag), if any. Shared by symbol resolution and return-type inference. + fn matching_overload(&self, name: &str, arg_types: &[Type]) -> Option<&(Vec, Type)> { + self.overloads.get(name)?.iter().find(|(params, _)| { + params.len() == arg_types.len() + && params + .iter() + .zip(arg_types) + .all(|(p, a)| type_mangle(p) == type_mangle(a)) + }) + } + fn type_to_llvm(&self, ty: &Type) -> Result, String> { match ty { Type::Num => Ok(self.context.f64_type().into()), @@ -2245,6 +2762,10 @@ impl<'ctx> CodeGenerator<'ctx> { { Ok(self.sum_struct_type(name).into()) } + // Any other named RECORD type (a `:: SomeRecord` parameter/return, e.g. on a + // user operator overload) is passed by pointer — record instances are + // represented as a pointer to their struct alloca (see `generate_record`). + Type::Named { .. } => Ok(self.context.ptr_type(AddressSpace::default()).into()), _ => Err(format!("Unsupported type: {:?}", ty)), } } diff --git a/src/jit.rs b/src/jit.rs index da28a1f..709a9b5 100644 --- a/src/jit.rs +++ b/src/jit.rs @@ -55,6 +55,7 @@ pub fn run_program(program: &Program) -> Result { "__text_length", intrinsics::__text_length as *const () as usize, ), + ("__text_cmp", intrinsics::__text_cmp as *const () as usize), ( "__write_bytes", intrinsics::__write_bytes as *const () as usize, diff --git a/src/lexer/lexer.rs b/src/lexer/lexer.rs index dbb84e0..754933d 100644 --- a/src/lexer/lexer.rs +++ b/src/lexer/lexer.rs @@ -23,6 +23,17 @@ impl Lexer { Some(Ok(kind)) => { let span = lexer.span(); let text = source[span.clone()].to_string(); + // `>` reclassification: a `>` is the block-close delimiter only when + // it is the last token on its line — `>` followed by optional + // horizontal whitespace and then a newline or end-of-file. Any other + // `>` (something non-blank follows on the same line) is the + // greater-than operator `Gt`, so `a > b` works everywhere. + let kind = if kind == TokenKind::BlockClose && !is_line_final(source, span.end) + { + TokenKind::Gt + } else { + kind + }; tokens.push(Token::new(kind, Span::new(span.start, span.end), text)); } Some(Err(_)) => { @@ -49,6 +60,24 @@ impl Lexer { } } +/// Whether the position `at` in `source` is at the end of its line: only horizontal +/// whitespace (spaces/tabs) remains before a newline or the end of file. Used to tell a +/// block-closing `>` (line-final) from the greater-than operator `>` (followed by more +/// on the same line). A trailing `~` comment does NOT count as line-final — content +/// follows on the line — so a `>` immediately before a comment reads as `Gt`. +fn is_line_final(source: &str, at: usize) -> bool { + for b in source.as_bytes()[at..].iter() { + match b { + b' ' | b'\t' => continue, + b'\n' | b'\r' => return true, + _ => return false, + } + } + // Reached end of file with only horizontal whitespace: treat EOF as a line end so a + // file whose final token is a block-closing `>` (no trailing newline) still parses. + true +} + #[derive(Debug, Clone, PartialEq)] pub struct LexerError { pub message: String, @@ -144,15 +173,42 @@ mod tests { #[test] fn test_delimiters() { + // A `>` followed by more on the same line lexes as the greater-than operator + // (`Gt`), not the block-close delimiter — the block close is the line-final form. let tokens = Lexer::tokenize("< > { } ( ) [ ]").unwrap(); assert_eq!(tokens[0].kind, TokenKind::BlockOpen); - assert_eq!(tokens[1].kind, TokenKind::BlockClose); + assert_eq!(tokens[1].kind, TokenKind::Gt); assert_eq!(tokens[2].kind, TokenKind::BraceOpen); assert_eq!(tokens[3].kind, TokenKind::BraceClose); assert_eq!(tokens[4].kind, TokenKind::ParenOpen); assert_eq!(tokens[5].kind, TokenKind::ParenClose); } + #[test] + fn test_block_close_is_line_final_gt() { + // `>` at end of a line (only whitespace/newline after) closes a block. + let nl = Lexer::tokenize("<\n x\n>").unwrap(); + assert_eq!(nl[0].kind, TokenKind::BlockOpen); + assert_eq!(nl.last().unwrap().kind, TokenKind::Eof); + assert!(nl.iter().any(|t| t.kind == TokenKind::BlockClose)); + assert!(!nl.iter().any(|t| t.kind == TokenKind::Gt)); + + // A `>` at end of file (no trailing newline) still closes the block. + let eof = Lexer::tokenize("< x >").unwrap(); + // `>` is line-final (EOF after the trailing space) -> BlockClose. + assert!(eof.iter().any(|t| t.kind == TokenKind::BlockClose)); + + // A `>` with an operand after it on the same line is the greater-than operator. + let gt = Lexer::tokenize("a > b").unwrap(); + assert_eq!(gt[1].kind, TokenKind::Gt); + + // `>=` and `>>` are independent tokens, unaffected by the `>` rule. + let ge = Lexer::tokenize("a >= b").unwrap(); + assert_eq!(ge[1].kind, TokenKind::Ge); + let export = Lexer::tokenize(">> x = 1").unwrap(); + assert_eq!(export[0].kind, TokenKind::Export); + } + #[test] fn test_arithmetic() { let tokens = Lexer::tokenize("+ - * / %").unwrap(); @@ -168,8 +224,9 @@ mod tests { let tokens = Lexer::tokenize("== != < > <= >=").unwrap(); assert_eq!(tokens[0].kind, TokenKind::Eq); assert_eq!(tokens[1].kind, TokenKind::Ne); - assert_eq!(tokens[2].kind, TokenKind::BlockOpen); // < is block open - assert_eq!(tokens[3].kind, TokenKind::BlockClose); // > is block close + assert_eq!(tokens[2].kind, TokenKind::BlockOpen); // `<` is always block-open + // `>` here is followed by ` <= >=` on the same line, so it's the operator. + assert_eq!(tokens[3].kind, TokenKind::Gt); assert_eq!(tokens[4].kind, TokenKind::Le); assert_eq!(tokens[5].kind, TokenKind::Ge); } diff --git a/src/lexer/token.rs b/src/lexer/token.rs index 5b3a748..2c18906 100644 --- a/src/lexer/token.rs +++ b/src/lexer/token.rs @@ -144,9 +144,18 @@ pub enum TokenKind { #[token("<")] BlockOpen, + // `>` is reclassified after lexing (see `Lexer::tokenize`): it stays `BlockClose` + // when it is the last token on its line (`>` + optional `[ \t]*` + newline/EOF), + // and becomes the greater-than operator `Gt` otherwise. This lets a bare `a > b` + // work everywhere while a block still closes on a line-final `>`. #[token(">")] BlockClose, + /// The greater-than comparison operator. Produced from a `>` that is NOT the last + /// token on its line (see `BlockClose`). `<` is always `BlockOpen`; less-than is + /// recovered in the parser, where a `<` after a complete operand can only be `Lt`. + Gt, + #[token("{")] BraceOpen, @@ -272,6 +281,7 @@ impl fmt::Display for TokenKind { TokenKind::Pipe => write!(f, "|"), TokenKind::BlockOpen => write!(f, "<"), TokenKind::BlockClose => write!(f, ">"), + TokenKind::Gt => write!(f, ">"), TokenKind::BraceOpen => write!(f, "{{"), TokenKind::BraceClose => write!(f, "}}"), TokenKind::ParenOpen => write!(f, "("), diff --git a/src/parser/ast_parser.rs b/src/parser/ast_parser.rs index 2756066..a401607 100644 --- a/src/parser/ast_parser.rs +++ b/src/parser/ast_parser.rs @@ -92,6 +92,15 @@ impl<'a> Parser<'a> { false }; + // A top-level definition may be named by an operator symbol — this is how a + // user declares an operator overload, e.g. `+ = (a :: Point, b :: Point) ...`. + // An operator name is always a function definition (operators take operands). + if let Some(op_name) = self.operator_def_name() { + self.advance(); + self.expect(&TokenKind::Assign)?; + return self.parse_function_decl(op_name, start, None, exported); + } + let name = self.expect_ident()?; // Check for type annotation @@ -1058,7 +1067,32 @@ impl<'a> Parser<'a> { } } + /// Whether the current operator token actually begins a top-level operator + /// DEFINITION (`op = ...`) rather than continuing the current expression. The + /// grammar is newline-insensitive, so without this an expression-bodied item + /// followed by an operator overload — `x = 5` then `+ = (a, b) => …` — would let + /// the additive parser swallow the `+` as `5 + …`. An operator immediately + /// followed by `=` (Assign) is never a binary use (its right operand would be + /// `=`), so we stop and let `parse_item` pick up the operator definition. + fn at_operator_definition(&self) -> bool { + matches!( + self.peek().kind, + TokenKind::Plus + | TokenKind::Minus + | TokenKind::Star + | TokenKind::Slash + | TokenKind::Percent + | TokenKind::Eq + | TokenKind::Ne + | TokenKind::Le + | TokenKind::Ge + ) && self.peek_ahead(1).kind == TokenKind::Assign + } + fn match_comparison(&mut self) -> Option { + if self.at_operator_definition() { + return None; + } match &self.peek().kind { TokenKind::Le => { self.advance(); @@ -1068,11 +1102,28 @@ impl<'a> Parser<'a> { self.advance(); Some(BinOp::Ge) } + // `<` doubles as the block-open delimiter, but in comparison position + // (after a complete left operand) a block can never start, so a bare + // `<` here is unambiguously the less-than operator. + TokenKind::BlockOpen => { + self.advance(); + Some(BinOp::Lt) + } + // The lexer already distinguished a greater-than `>` (token `Gt`) from a + // block-closing `>` (token `BlockClose`, only when line-final), so a `Gt` + // here is unambiguously the operator. + TokenKind::Gt => { + self.advance(); + Some(BinOp::Gt) + } _ => None, } } fn match_additive(&mut self) -> Option { + if self.at_operator_definition() { + return None; + } if self.check(&TokenKind::Plus) { self.advance(); Some(BinOp::Add) @@ -1085,6 +1136,9 @@ impl<'a> Parser<'a> { } fn match_multiplicative(&mut self) -> Option { + if self.at_operator_definition() { + return None; + } match &self.peek().kind { TokenKind::Star => { self.advance(); @@ -1328,6 +1382,33 @@ impl<'a> Parser<'a> { // Helper methods + /// If the current token is an operator usable as an overload-set name AND it is + /// being *defined* (followed by `=`), return its symbol. This is how a user + /// declares an operator overload, e.g. `== = (a :: P, b :: P) -> Bool => ...`. + /// Requiring the following `=` keeps a stray leading operator from being mistaken + /// for a definition. `<`/`>` (block delimiters) are intentionally excluded here — + /// a top-level `< ... >` would be a block, never an operator name. + fn operator_def_name(&self) -> Option { + let sym = match self.peek().kind { + TokenKind::Plus => "+", + TokenKind::Minus => "-", + TokenKind::Star => "*", + TokenKind::Slash => "/", + TokenKind::Percent => "%", + TokenKind::Eq => "==", + TokenKind::Ne => "!=", + TokenKind::Le => "<=", + TokenKind::Ge => ">=", + _ => return None, + }; + // Only a definition (`op = ...`); otherwise leave it for expression parsing. + if self.peek_ahead(1).kind == TokenKind::Assign { + Some(sym.to_string()) + } else { + None + } + } + fn peek(&self) -> &Token { &self.tokens[self.pos] } @@ -1512,6 +1593,39 @@ mod tests { assert!(result.is_ok()); } + #[test] + fn test_parse_bare_less_and_greater_than() { + // `<` after a complete operand is `Lt`; a non-line-final `>` is `Gt`. + let lt = parse(&Lexer::tokenize("flag = a < b").unwrap()).unwrap(); + if let Item::VarDecl(d) = <.items[0] { + assert!(matches!(d.value, Expr::BinOp { op: BinOp::Lt, .. })); + } else { + panic!("expected a var decl"); + } + let gt = parse(&Lexer::tokenize("flag = a > b").unwrap()).unwrap(); + if let Item::VarDecl(d) = >.items[0] { + assert!(matches!(d.value, Expr::BinOp { op: BinOp::Gt, .. })); + } else { + panic!("expected a var decl"); + } + } + + #[test] + fn test_parse_operator_definition() { + // An operator symbol can name a top-level definition (an operator overload). + let tokens = + Lexer::tokenize("P = { x :: Num }\n== = (a :: P, b :: P) -> Bool => a.x == b.x") + .unwrap(); + let program = parse(&tokens).unwrap(); + // Items: the type decl and the `==` operator function. + let op = program.items.iter().find_map(|i| match i { + Item::FunctionDecl(f) if f.name == "==" => Some(f), + _ => None, + }); + let op = op.expect("expected an `==` operator definition"); + assert_eq!(op.params.len(), 2); + } + #[test] fn test_parse_logical() { let tokens = Lexer::tokenize("result = a && b || c").unwrap(); diff --git a/src/typechecker/checker.rs b/src/typechecker/checker.rs index 0f5f0d0..21fa8fe 100644 --- a/src/typechecker/checker.rs +++ b/src/typechecker/checker.rs @@ -52,6 +52,31 @@ pub enum TypeError { name: String, span: Span, }, + /// No overload of `name` accepts the given argument types (exact-match dispatch, + /// no implicit coercion). Lists the available candidate signatures. + NoMatchingOverload { + name: String, + arg_types: Vec, + candidates: Vec>, + span: Span, + }, + /// More than one overload of `name` matches the given argument types. (With + /// exact-match dispatch this means two overloads share a parameter-type list — + /// a duplicate definition.) Lists the colliding candidate signatures. + AmbiguousOverload { + name: String, + arg_types: Vec, + candidates: Vec>, + span: Span, + }, + /// An overloaded definition (operator-named, or one of several same-named defs) + /// left a parameter unannotated. Exact-type dispatch needs every member's + /// parameter types spelled out. + OverloadMissingAnnotation { + name: String, + param: String, + span: Span, + }, #[allow(dead_code)] PatternTypeMismatch { expected: Box, @@ -76,6 +101,9 @@ impl TypeError { | TypeError::ImmutableFieldWrite { span, .. } | TypeError::MutatingMethodOnImmutable { span, .. } | TypeError::DuplicateDefinition { span, .. } + | TypeError::NoMatchingOverload { span, .. } + | TypeError::AmbiguousOverload { span, .. } + | TypeError::OverloadMissingAnnotation { span, .. } | TypeError::PatternTypeMismatch { span, .. } | TypeError::NonExhaustiveMatch { span } => span, } @@ -126,6 +154,41 @@ impl std::fmt::Display for TypeError { TypeError::DuplicateDefinition { name, .. } => { write!(f, "Duplicate definition of '{}'", name) } + TypeError::NoMatchingOverload { + name, + arg_types, + candidates, + .. + } => { + write!( + f, + "No overload of '{}' matches argument types ({}). Candidates: {}", + name, + fmt_type_list(arg_types), + fmt_candidates(candidates), + ) + } + TypeError::AmbiguousOverload { + name, + arg_types, + candidates, + .. + } => { + write!( + f, + "Ambiguous call to '{}' with argument types ({}); multiple overloads match: {}", + name, + fmt_type_list(arg_types), + fmt_candidates(candidates), + ) + } + TypeError::OverloadMissingAnnotation { name, param, .. } => { + write!( + f, + "Overloaded definition '{}' must annotate every parameter; '{}' has no type annotation", + name, param + ) + } TypeError::PatternTypeMismatch { expected, got, .. } => { write!( f, @@ -142,6 +205,81 @@ impl std::fmt::Display for TypeError { impl std::error::Error for TypeError {} +/// A short, user-facing label for a type in overload diagnostics (`Num`, `Text`, +/// `Bool`, `$`, a user type's name, etc.). +fn type_label(ty: &Type) -> String { + match ty { + Type::Num => "Num".to_string(), + Type::Text => "Text".to_string(), + Type::Bool => "Bool".to_string(), + Type::Unit => "$".to_string(), + Type::Array(elem) => format!("[]{}", type_label(elem)), + Type::Named { name, .. } | Type::Sum { name, .. } => name.clone(), + // A not-yet-concrete type (an unresolved sum payload such as the `T` in `Ok(T)`). + Type::Generic { .. } => "".to_string(), + other => format!("{:?}", other), + } +} + +/// Render a comma-separated parameter/argument type list (`Num, Text`). +fn fmt_type_list(types: &[Type]) -> String { + types.iter().map(type_label).collect::>().join(", ") +} + +/// Whether `decl` is the inert `core.io` `print`/`eprint` placeholder (a single +/// UNannotated param with an inert body). The compiler fully provides these as +/// built-in overloads, so the placeholder is ignored — neither registered as a +/// user overload nor type-checked/emitted. A genuine user `print`/`eprint` overload +/// (fully annotated params) is NOT a placeholder and is handled normally. +fn is_inert_io_placeholder(decl: &FunctionDecl) -> bool { + (decl.name == "print" || decl.name == "eprint") + && decl.params.len() == 1 + && decl.params[0].type_annotation.is_none() +} + +/// Exact-type match for overload dispatch (no implicit coercion). Built-in scalars +/// match by identity; a user type matches by NAME (so a `Named`/`Sum` annotation and +/// the inferred instance line up regardless of carried fields); a `Generic` payload +/// slot (only Result's `Ok(T)`/`NotOk(E)`) matches anything, preserving the existing +/// generic-Result behavior. +fn types_match(param: &Type, arg: &Type) -> bool { + match (param, arg) { + // `Generic` is a not-yet-concrete type — only a sum payload binding (the `T` in + // `Ok(T)`) produces one, since concrete sum-payload typing is a deferred 0.9 + // feature. For overload dispatch a `Generic` resolves as `Num`, the canonical + // working payload (numeric payloads are sound end-to-end), so `Ok(x) => x * 2` + // dispatches `*` to its `(Num, Num)` member. This means an overloaded call on a + // generic value resolves DETERMINISTICALLY to the Num member rather than + // matching every member (a spurious ambiguity) — and it never wildcard-matches a + // user record/sum type. (A Text/Bool payload routed through an overload thus + // picks the Num member — the documented non-numeric-payload limitation, not a + // new behavior; true concrete-payload dispatch awaits that feature.) + (Type::Generic { .. }, other) | (other, Type::Generic { .. }) => { + matches!(other, Type::Num | Type::Generic { .. }) + } + (Type::Num, Type::Num) + | (Type::Text, Type::Text) + | (Type::Bool, Type::Bool) + | (Type::Unit, Type::Unit) => true, + (Type::Array(a), Type::Array(b)) => types_match(a, b), + // User record / sum types are identified by name. + ( + Type::Named { name: a, .. } | Type::Sum { name: a, .. }, + Type::Named { name: b, .. } | Type::Sum { name: b, .. }, + ) => a == b, + _ => false, + } +} + +/// Render candidate signatures for an overload diagnostic (`(Num, Num), (Text, Text)`). +fn fmt_candidates(candidates: &[Vec]) -> String { + candidates + .iter() + .map(|params| format!("({})", fmt_type_list(params))) + .collect::>() + .join(", ") +} + #[derive(Debug, Clone)] // `pub` so it doesn't leak through the public `Environment::lookup` signature. // `span` is recorded for diagnostics not yet emitted (source spans in errors). @@ -237,6 +375,17 @@ impl Environment { /// A method's signature and body: (params, return type, body expression). type MethodDef = (Vec, Option, Expr); +/// One member of an overload set: an exact parameter-type list and the result type. +/// Both named functions and operators (keyed by their symbol, e.g. `"+"`) live in the +/// same registry. `builtin` members are the compiler-lowered defaults (`+` on Num/Text, +/// the comparisons, `print`, …); user members come from top-level definitions. +#[derive(Debug, Clone)] +pub struct Overload { + pub params: Vec, + pub ret: Type, + pub builtin: bool, +} + pub struct TypeChecker { env: Environment, // Registry of methods: (TypeName, MethodName) -> method definition @@ -247,6 +396,16 @@ pub struct TypeChecker { // body containing `it.field := …` (or a call to another setter on `it`). // Calling such a method requires a `:=`-bound (mutable) receiver. setter_methods: std::collections::HashSet<(String, String)>, + // Ad-hoc overload sets, keyed by name (function names AND operator symbols like + // `"+"`/`"=="`). A name maps to all its candidate signatures; a call/operator use + // resolves to the one whose parameter types EXACTLY match the argument types (no + // implicit coercion). Built-in operator/`print` behavior lives here as `builtin` + // members, so the standard operators are visible overloads, not compiler magic. + overloads: std::collections::HashMap>, + // Top-level names that form a user overload set (operator-named, or 2+ defs). + // A call to one of these resolves by exact argument type via `overloads` rather + // than through a single `env` function binding. Computed in `check_program`. + overloaded_names: std::collections::HashSet, } impl Default for TypeChecker { @@ -262,10 +421,15 @@ impl TypeChecker { methods: std::collections::HashMap::new(), sum_types: std::collections::HashMap::new(), setter_methods: std::collections::HashSet::new(), + overloads: std::collections::HashMap::new(), + overloaded_names: std::collections::HashSet::new(), }; // Add built-in sum types to the environment checker.add_builtins(); + // Register the built-in operator/`print` overloads (the standard operators + // are visible overloads, not compiler magic). + checker.add_builtin_overloads(); checker } @@ -307,6 +471,148 @@ impl TypeChecker { ); } + /// Register the built-in operator overloads so the standard operators dispatch + /// through the SAME exact-match mechanism as user overloads — `+` on `Num` and + /// `+` on `Text` (concat) are just two members of the `+` overload set, etc. + /// `print`/`eprint` get a member per printable built-in (`Num`/`Text`/`Bool`). + fn add_builtin_overloads(&mut self) { + let arith = [BinOp::Add, BinOp::Sub, BinOp::Mul, BinOp::Div, BinOp::Mod]; + for op in arith { + // Num op Num -> Num. + self.add_overload( + op.symbol(), + Overload { + params: vec![Type::Num, Type::Num], + ret: Type::Num, + builtin: true, + }, + ); + } + // `+` also concatenates Text. + self.add_overload( + BinOp::Add.symbol(), + Overload { + params: vec![Type::Text, Type::Text], + ret: Type::Text, + builtin: true, + }, + ); + + // Comparisons. Equality (`==`/`!=`) over every built-in scalar; ordering + // (`<`/`<=`/`>`/`>=`) over Num and Text (Text is lexicographic — the + // concrete deliverable). All yield Bool. + let eq_ops = [BinOp::Eq, BinOp::Ne]; + let ord_ops = [BinOp::Lt, BinOp::Le, BinOp::Gt, BinOp::Ge]; + for op in eq_ops { + for ty in [Type::Num, Type::Text, Type::Bool] { + self.add_overload( + op.symbol(), + Overload { + params: vec![ty.clone(), ty], + ret: Type::Bool, + builtin: true, + }, + ); + } + } + for op in ord_ops { + for ty in [Type::Num, Type::Text] { + self.add_overload( + op.symbol(), + Overload { + params: vec![ty.clone(), ty], + ret: Type::Bool, + builtin: true, + }, + ); + } + } + + // Logical `&&`/`||`: Bool op Bool -> Bool. + for op in [BinOp::And, BinOp::Or] { + self.add_overload( + op.symbol(), + Overload { + params: vec![Type::Bool, Type::Bool], + ret: Type::Bool, + builtin: true, + }, + ); + } + + // `print`/`eprint`: one member per printable built-in; all return `$` (Unit). + for name in ["print", "eprint"] { + for ty in [Type::Num, Type::Text, Type::Bool] { + self.add_overload( + name, + Overload { + params: vec![ty], + ret: Type::Unit, + builtin: true, + }, + ); + } + } + } + + /// Add one member to the overload set `name`. + fn add_overload(&mut self, name: &str, overload: Overload) { + self.overloads + .entry(name.to_string()) + .or_default() + .push(overload); + } + + /// Resolve a call to overload set `name` by EXACT argument-type match (no implicit + /// coercion). Returns the matched overload's return type. Errors on no match or + /// (with exact matching, a duplicate-signature) ambiguity, listing the candidates. + fn resolve_overload( + &self, + name: &str, + arg_types: &[Type], + span: &Span, + ) -> Result { + let set = self.overloads.get(name); + let matches: Vec<&Overload> = set + .map(|s| { + s.iter() + .filter(|o| { + o.params.len() == arg_types.len() + && o.params + .iter() + .zip(arg_types.iter()) + .all(|(p, a)| types_match(p, a)) + }) + .collect() + }) + .unwrap_or_default(); + + // Candidate signatures are only needed to render an error, so build them lazily. + let candidates = || -> Vec> { + set.map(|s| s.iter().map(|o| o.params.clone()).collect()) + .unwrap_or_default() + }; + + match matches.as_slice() { + [] => Err(TypeError::NoMatchingOverload { + name: name.to_string(), + arg_types: arg_types.to_vec(), + candidates: candidates(), + span: span.clone(), + }), + // Re-resolve the result type: an overloaded member's return annotation may + // have been registered (pre-pass) before its named type existed, so a bare + // `Named{T, fields:[]}` is filled in to its full definition here. + [only] => Ok(self.resolve_type(&only.ret)), + _ => Err(TypeError::AmbiguousOverload { + name: name.to_string(), + arg_types: arg_types.to_vec(), + candidates: candidates(), + span: span.clone(), + }), + } + } + /// Type-check a constructor application `variant(args...)` against the registered /// sum types. Returns `Ok(Some(sum_type))` if `variant` is a known constructor (after /// validating arity and payload types), `Ok(None)` if no sum type has that variant @@ -368,22 +674,118 @@ impl TypeChecker { /// (`check_type_compatibility`) lines up with inferred constructor results. fn resolve_type(&self, ty: &Type) -> Type { match ty { - Type::Named { name, fields, .. } if fields.is_empty() => self - .sum_types - .get(name) - .cloned() - .unwrap_or_else(|| ty.clone()), + Type::Named { name, fields, .. } if fields.is_empty() => { + // A registered sum type wins; otherwise a registered named RECORD type + // (stored in `env` as its full `Named { fields, methods }`), so a + // function/operator parameter typed `:: SomeRecord` carries its fields + // and methods (field access / method dispatch in the body resolve). + if let Some(sum) = self.sum_types.get(name) { + sum.clone() + } else if let Some(named @ Type::Named { .. }) = self.env.get_type(name) { + named + } else { + ty.clone() + } + } _ => ty.clone(), } } pub fn check_program(&mut self, program: &Program) -> Result<(), TypeError> { + // Pre-pass: find the names that form an overload set — operator-named + // definitions, or any name with 2+ top-level function definitions. These + // dispatch by exact argument type instead of through a single `env` binding. + let mut fn_counts: std::collections::HashMap<&str, usize> = + std::collections::HashMap::new(); + for item in &program.items { + if let Item::FunctionDecl(decl) = item + && !is_inert_io_placeholder(decl) + { + *fn_counts.entry(decl.name.as_str()).or_insert(0) += 1; + } + } + // A name forms an overload set if it is operator-named, has 2+ definitions, OR + // already has a built-in overload set (e.g. `print`/`eprint` — a user + // definition of one ADDS an overload, it does not shadow the builtins). + // `^` (entry point) is never an overload set, even if (erroneously) repeated. + self.overloaded_names = fn_counts + .iter() + .filter(|(name, count)| { + (crate::ast::is_operator_symbol(name) + || **count > 1 + || self.overloads.contains_key(**name)) + && **name != "^" + }) + .map(|(name, _)| name.to_string()) + .collect(); + + // Register every overloaded definition's signature up front, so a call to any + // member resolves regardless of definition order (and recursion works). + for item in &program.items { + if let Item::FunctionDecl(decl) = item + && self.overloaded_names.contains(&decl.name) + && !is_inert_io_placeholder(decl) + { + self.register_overload_decl(decl)?; + } + } + for item in &program.items { self.check_item(item)?; } Ok(()) } + /// Register a top-level function definition as a member of its overload set. Each + /// overloaded member must annotate all its parameter types (exact-type dispatch + /// can't pick between unannotated members). The result type is the annotation, or + /// `Num` as the provisional default (refined when its body is checked). + fn register_overload_decl(&mut self, decl: &FunctionDecl) -> Result<(), TypeError> { + let mut params = Vec::with_capacity(decl.params.len()); + for p in &decl.params { + match &p.type_annotation { + Some(t) => params.push(self.resolve_type(t)), + // Exact-type dispatch needs every overloaded member's params annotated. + None => { + return Err(TypeError::OverloadMissingAnnotation { + name: decl.name.clone(), + param: p.name.clone(), + span: p.span.clone(), + }); + } + } + } + let ret = decl + .return_type + .as_ref() + .map(|t| self.resolve_type(t)) + .unwrap_or(Type::Num); + + // Reject an exact-duplicate signature (same parameter types) up front — it + // would make every call to it ambiguous. + if let Some(set) = self.overloads.get(&decl.name) + && set.iter().any(|o| { + o.params.len() == params.len() + && o.params.iter().zip(¶ms).all(|(a, b)| types_match(a, b)) + }) + { + return Err(TypeError::DuplicateDefinition { + name: decl.name.clone(), + span: decl.span.clone(), + }); + } + + self.add_overload( + &decl.name, + Overload { + params, + ret, + builtin: false, + }, + ); + Ok(()) + } + fn check_item(&mut self, item: &Item) -> Result<(), TypeError> { match item { Item::VarDecl(decl) => self.check_var_decl(decl), @@ -704,6 +1106,12 @@ impl TypeChecker { } fn check_function_decl(&mut self, decl: &FunctionDecl) -> Result<(), TypeError> { + // The inert core.io `print`/`eprint` placeholder is fully provided by the + // compiler as a built-in overload; ignore its declaration entirely. + if is_inert_io_placeholder(decl) { + return Ok(()); + } + // Build function type from parameters and return type let param_types: Vec = decl .params @@ -725,14 +1133,21 @@ impl TypeChecker { .map(|t| self.resolve_type(t)) .unwrap_or(Type::Num); - let func_type = Type::Function { - params: param_types.clone(), - return_type: Box::new(preliminary_return_type.clone()), - }; - - // Define the function in current scope BEFORE checking body (enables recursion) - self.env - .define(decl.name.clone(), func_type, false, decl.span.clone())?; + // An overloaded member (operator-named, or one of 2+ same-named defs) is NOT + // a single `env` binding — its signature already lives in the overload set + // (registered in the pre-pass). We only type-check its body here, then refine + // that member's return type from the inferred body when it wasn't annotated. + let is_overloaded = self.overloaded_names.contains(&decl.name); + + if !is_overloaded { + let func_type = Type::Function { + params: param_types.clone(), + return_type: Box::new(preliminary_return_type.clone()), + }; + // Define the function in current scope BEFORE checking body (enables recursion) + self.env + .define(decl.name.clone(), func_type, false, decl.span.clone())?; + } // Push scope for body type checking self.env.push_scope(); @@ -756,20 +1171,35 @@ impl TypeChecker { if let Some(ref annotated_type) = decl.return_type { let annotated_type = self.resolve_type(annotated_type); self.check_type_compatibility(&annotated_type, &body_type, &decl.span)?; - } else { + } else if is_overloaded { + // Refine this overload member's (provisional Num) return type to the body's. + self.update_overload_return(&decl.name, ¶m_types, body_type); + } else if body_type != preliminary_return_type { // Update the function type with the inferred return type - if body_type != preliminary_return_type { - let correct_func_type = Type::Function { - params: param_types, - return_type: Box::new(body_type.clone()), - }; - let _ = self.env.update_type(&decl.name, correct_func_type); - } + let correct_func_type = Type::Function { + params: param_types, + return_type: Box::new(body_type.clone()), + }; + let _ = self.env.update_type(&decl.name, correct_func_type); } Ok(()) } + /// Refine the return type of the overload member of `name` whose parameter types + /// are `params` (set during body inference for an unannotated overloaded def). + fn update_overload_return(&mut self, name: &str, params: &[Type], ret: Type) { + if let Some(set) = self.overloads.get_mut(name) + && let Some(member) = set.iter_mut().find(|o| { + !o.builtin + && o.params.len() == params.len() + && o.params.iter().zip(params).all(|(a, b)| types_match(a, b)) + }) + { + member.ret = ret; + } + } + fn infer_expr(&mut self, expr: &Expr) -> Result { match expr { Expr::Number { .. } => Ok(Type::Num), @@ -1157,28 +1587,11 @@ impl TypeChecker { let left_type = self.infer_expr(left)?; let right_type = self.infer_expr(right)?; - match op { - // `+` is overloaded: Text + Text concatenates, otherwise it is numeric. - BinOp::Add if left_type == Type::Text || right_type == Type::Text => { - self.check_type_compatibility(&Type::Text, &left_type, span)?; - self.check_type_compatibility(&Type::Text, &right_type, span)?; - Ok(Type::Text) - } - BinOp::Add | BinOp::Sub | BinOp::Mul | BinOp::Div | BinOp::Mod => { - self.check_type_compatibility(&Type::Num, &left_type, span)?; - self.check_type_compatibility(&Type::Num, &right_type, span)?; - Ok(Type::Num) - } - BinOp::Eq | BinOp::Ne | BinOp::Lt | BinOp::Le | BinOp::Gt | BinOp::Ge => { - self.check_type_compatibility(&left_type, &right_type, span)?; - Ok(Type::Bool) - } - BinOp::And | BinOp::Or => { - self.check_type_compatibility(&Type::Bool, &left_type, span)?; - self.check_type_compatibility(&Type::Bool, &right_type, span)?; - Ok(Type::Bool) - } - } + // An operator is just a named overload set. Resolve it by exact operand types + // against the operator's overload set, which holds the built-in members + // (Num/Text `+`, the comparisons, …) PLUS any user-defined operator overloads + // — so built-ins and user operators dispatch through the same mechanism. + self.resolve_overload(op.symbol(), &[left_type, right_type], span) } fn check_unary_op(&mut self, op: UnaryOp, expr: &Expr, span: &Span) -> Result { @@ -1272,20 +1685,23 @@ impl TypeChecker { } } + // Overload-set dispatch: if `func` names an overload set (a user overload set + // OR a built-in like `print`/`eprint`), resolve it by EXACT argument types. + // This is the general mechanism that replaces the old `print` special-casing — + // `print` is now just an overload set over Num/Text/Bool returning `$`. + if let Expr::Ident { name, .. } = func + && self.overloads.contains_key(name) + { + let mut arg_types = Vec::with_capacity(args.len()); + for arg in args { + arg_types.push(self.infer_expr(arg)?); + } + return self.resolve_overload(name, &arg_types, span); + } + // Fall back to regular function call let func_type = self.infer_expr(func)?; - // `print`/`eprint` are compiler-lowered (see CodeGenerator::generate_print) - // and polymorphic over Num / Text / Bool — which a single placeholder param - // can't express. They are applied as a FALLBACK: a user-defined/registered - // `print`/`eprint` whose signature accepts the args is resolved normally - // below and takes precedence; only when that resolution would reject the call - // (the core.io placeholder's untyped param defaulting to Num, given a Text) - // does the polymorphic builtin kick in. (`write` needs no fallback — its - // core.io placeholder is typed `(Text, Num) -> Num`.) - let is_print_builtin = matches!(func, Expr::Ident { name, .. } - if name == "print" || name == "eprint"); - match func_type { Type::Function { params, @@ -1299,34 +1715,12 @@ impl TypeChecker { }); } - // Type the arguments once. - let mut arg_types = Vec::with_capacity(args.len()); - for arg in args { - arg_types.push(self.infer_expr(arg)?); - } - - // Check the resolved signature, remembering the first mismatch. - let mut first_err = None; - for (param_type, arg_type) in params.iter().zip(arg_types.iter()) { - if let Err(e) = self.check_type_compatibility(param_type, arg_type, span) { - first_err = Some(e); - break; - } - } - match first_err { - None => Ok(*return_type), - // Builtin print/eprint fallback: accept a single Num/Text/Bool - // when the resolved signature (e.g. core.io's placeholder) rejects it. - // `print`/`eprint` yield Unit (`$`) — their result is meaningless. - Some(_) - if is_print_builtin - && arg_types.len() == 1 - && matches!(arg_types[0], Type::Num | Type::Text | Type::Bool) => - { - Ok(Type::Unit) - } - Some(e) => Err(e), + // Type the arguments once, then check against the resolved signature. + for (param_type, arg) in params.iter().zip(args.iter()) { + let arg_type = self.infer_expr(arg)?; + self.check_type_compatibility(param_type, &arg_type, span)?; } + Ok(*return_type) } _ => Err(TypeError::NotAFunction { got: func_type, @@ -1738,6 +2132,84 @@ result = val ? | OK(x, y) => x | NotOK => 0", assert!(checker.env.get_type("Result").is_some()); } + fn check_ok(src: &str) -> Result<(), TypeError> { + let tokens = Lexer::tokenize(src).unwrap(); + let program = parse(&tokens).unwrap(); + TypeChecker::new().check_program(&program) + } + + #[test] + fn test_overload_set_resolves_by_type() { + // Two `f` definitions; each call resolves by exact argument type. + assert!( + check_ok( + "f = (n :: Num) -> Num => n\nf = (s :: Text) -> Num => s.size\n^ = () -> Num => f(1) + f(\"x\")" + ) + .is_ok() + ); + } + + #[test] + fn test_overload_no_match_is_error() { + // No `f` overload accepts a Bool (no implicit coercion). + let err = check_ok( + "f = (n :: Num) -> Num => n\nf = (s :: Text) -> Num => s.size\n^ = () -> Num => f(true)", + ) + .unwrap_err(); + assert!(matches!(err, TypeError::NoMatchingOverload { .. })); + } + + #[test] + fn test_duplicate_overload_signature_is_error() { + let err = check_ok("f = (n :: Num) -> Num => n\nf = (m :: Num) -> Num => m").unwrap_err(); + assert!(matches!(err, TypeError::DuplicateDefinition { .. })); + } + + #[test] + fn test_user_operator_overload_typechecks() { + assert!( + check_ok( + "P = { x :: Num }\n== = (a :: P, b :: P) -> Bool => a.x == b.x\n^ = () -> Num => P { x = 1 } == P { x = 1 } ? 1 : 0" + ) + .is_ok() + ); + } + + #[test] + fn test_text_ordering_typechecks() { + assert!(check_ok("^ = () -> Num => \"a\" < \"b\" ? 1 : 0").is_ok()); + assert!(check_ok("^ = () -> Num => \"a\" == \"a\" ? 1 : 0").is_ok()); + } + + #[test] + fn test_operator_no_overload_for_operands_is_error() { + // `+` has no Num/Bool member. + assert!(check_ok("^ = () -> Num => 1 + true").is_err()); + } + + #[test] + fn test_generic_payload_resolves_as_num_for_operators() { + // A (generic) sum payload used with an operator resolves as Num — so + // `Ok(x) => x * 2` type-checks against `*`'s (Num, Num) member. This pins the + // documented Generic-as-Num overload behavior (concrete sum-payload typing is a + // separate deferred feature); a future change here would be a visible regression. + assert!( + check_ok("^ = () -> Num => <\n r = Ok(21)\n r ? | Ok(x) => x * 2 | NotOk(e) => 0\n>") + .is_ok() + ); + } + + #[test] + fn test_ok_dispatch_over_builtin_payloads() { + // Ok over Num/Text/Bool/$ all type-check. + assert!( + check_ok( + "^ = () -> Num => <\n a = Ok(1)\n b = Ok(\"s\")\n c = Ok(true)\n d = Ok($)\n 0\n>" + ) + .is_ok() + ); + } + #[test] fn test_for_loop_simple() { let tokens = Lexer::tokenize("test = => for n <- [1, 2, 3] => n").unwrap(); diff --git a/tests/diagnostics_test.rs b/tests/diagnostics_test.rs index d612a3c..17f1811 100644 --- a/tests/diagnostics_test.rs +++ b/tests/diagnostics_test.rs @@ -30,7 +30,7 @@ fn check(name: &str, source: &str) -> (bool, String) { #[test] fn type_error_reports_line_col_and_caret() { - // `a + true` is a Num/Bool mismatch on line 2. + // `a + true` has no `+` overload for (Num, Bool) — a clear no-match error on line 2. let src = "~ comment\nadd = (a :: Num) -> Num => a + true\n^ = () -> Num => add(1)\n"; let (ok, stderr) = check("type", src); @@ -40,7 +40,11 @@ fn type_error_reports_line_col_and_caret() { stderr.contains(":2:28: error:"), "no line:col header: {stderr}" ); - assert!(stderr.contains("Type mismatch"), "no message: {stderr}"); + // `+` is now a visible overload set; a Num/Bool mix matches no member. + assert!( + stderr.contains("No overload of '+'"), + "no message: {stderr}" + ); // The offending source line is echoed... assert!( stderr.contains("add = (a :: Num) -> Num => a + true"), diff --git a/tests/examples_test.rs b/tests/examples_test.rs index fec7662..c61650d 100644 --- a/tests/examples_test.rs +++ b/tests/examples_test.rs @@ -55,6 +55,7 @@ const EXPECTED_EXIT: &[(&str, i32)] = &[ ("sum_types.ql", 42), ("use_module.ql", 5), ("unit.ql", 0), + ("overloading.ql", 161), ]; fn ql_files() -> Vec { diff --git a/tests/run_test.rs b/tests/run_test.rs index bd12171..7f9931f 100644 --- a/tests/run_test.rs +++ b/tests/run_test.rs @@ -381,3 +381,243 @@ fn unit_is_incompatible_with_num() { "expected `$` (Unit) body for a `-> Num` function to be a type error" ); } + +/// Assert `src` is rejected by the type checker (front-end, no run). +fn assert_check_err(src: &str) { + let tokens = Lexer::tokenize(src).expect("lexing failed"); + let program = parser::parse(&tokens).expect("parsing failed"); + let mut checker = TypeChecker::new(); + assert!( + checker.check_program(&program).is_err(), + "expected a type error for source:\n{src}" + ); +} + +// --- Ad-hoc overloading: exact-type dispatch over an overload set. --- + +#[test] +fn run_overload_set_resolves_by_argument_type() { + // Two `pick` definitions form an overload set; each call resolves to the member + // whose parameter type matches exactly. The Num and Text members do different + // things, so the exit code proves the right one ran for each call. + assert_exit( + "pick = (n :: Num) -> Num => n + 1\npick = (s :: Text) -> Num => s.size\n^ = () -> Num => pick(40) + pick(\"ab\")", + 43, + ); +} + +#[test] +fn run_operator_overload_on_user_type() { + // A user `==` overload on a record type; resolved like any operator overload and + // lowered to a direct call. Returns Bool, used in a ternary. + assert_exit( + "P = { x :: Num, y :: Num }\n== = (a :: P, b :: P) -> Bool => a.x == b.x && a.y == b.y\n^ = () -> Num => P { x = 1, y = 2 } == P { x = 1, y = 2 } ? 42 : 0", + 42, + ); +} + +#[test] +fn run_operator_overload_returning_record_survives_frame() { + // A user `+` overload that RETURNS a record: the record is GC-allocated, so its + // fields are still readable after the operator call returns (would dangle if it + // were a stack alloca). Subsequent expressions must not corrupt it. + assert_exit( + "V = { x :: Num, y :: Num }\n+ = (a :: V, b :: V) -> V => V { x = a.x + b.x, y = a.y + b.y }\n^ = () -> Num => <\n v = V { x = 1, y = 2 } + V { x = 30, y = 9 }\n pad = 5 > 1 ? 0 : 99\n v.x + v.y + pad\n>", + 42, + ); +} + +#[test] +fn run_overloaded_operator_dispatch_uses_callee_return_type() { + // Regression (review BUG1): codegen must infer a call's result from the callee's + // declared return type, not default to Num — so `mkv(..) + mkv(..)` resolves the + // user `(V, V)` `+` overload (it would otherwise fall to the numeric `+` and fail). + assert_exit( + "V = { x :: Num, y :: Num }\nmkv = (n :: Num) -> V => V { x = n, y = n }\n+ = (a :: V, b :: V) -> V => V { x = a.x + b.x, y = a.y + b.y }\n^ = () -> Num => <\n w = mkv(1) + mkv(20)\n w.x + w.y\n>", + 42, + ); +} + +#[test] +fn run_operator_definition_after_expression_bodied_item_parses() { + // Regression (review BUG2): an operator definition on its own line must NOT be + // absorbed as a binary operator continuing the previous expression-bodied item. + // Here `k = 5` is followed by a top-level `+` overload; both must parse and run. + assert_exit( + "P = { x :: Num, y :: Num }\nk = 5\n+ = (a :: P, b :: P) -> P => P { x = a.x + b.x, y = a.y + b.y }\n^ = () -> Num => <\n p = P { x = 1, y = 2 } + P { x = 30, y = 9 }\n p.x + p.y + k\n>", + 47, + ); +} + +#[test] +fn run_overloaded_call_on_loop_variable_dispatches_by_element_type() { + // Regression (review BUG4): an overloaded call on a `for` loop variable must + // dispatch by the element type (Num here) — codegen tracks the loop var's type. + // The Text member would mis-handle a Num; resolving to the Num member yields + // inc(11) = 12 for the final element. + assert_exit( + "inc = (n :: Num) -> Num => n + 1\ninc = (t :: Text) -> Num => t.size\n^ = () -> Num => <\n last := 0\n for n <- [10, 20, 11] => <\n last := inc(n)\n >\n last\n>", + 12, + ); +} + +#[test] +fn run_numeric_sum_payload_through_operator_overload() { + // A numeric sum payload flows through an operator overload set: `Ok(x) => x * 2` + // — `x` is a (generic) Result payload that resolves as Num for `*`, so it picks the + // `(Num, Num)` member. (Guards the Generic-resolves-as-Num overload behavior.) + assert_exit( + "^ = () -> Num => <\n r = Ok(21)\n r ? | Ok(x) => x * 2 | NotOk(e) => 0\n>", + 42, + ); +} + +#[test] +fn run_user_sum_payload_dispatches_overload_by_concrete_type() { + // A user sum type's payloads carry CONCRETE types (unlike Result's generic ones), so + // a match arm's payload binding dispatches an overloaded call by that concrete type. + assert_exit( + "Shape = Circle(Num) / Rect(Num, Num)\narea = (n :: Num) -> Num => n * 3\n^ = () -> Num => <\n s = Circle(14)\n s ? | Circle(n) => area(n) | Rect(w, h) => w * h\n>", + 42, + ); +} + +#[test] +fn run_user_sum_bool_payload_dispatches_to_bool_overload_member() { + // Regression (review BUG3 family): a Bool payload binding must dispatch to the + // `(Bool)` overload member — codegen tracks the binding's concrete type — not the + // `(Num)` member (which previously produced an LLVM i1-into-f64 type mismatch). + assert_exit( + "Flag = On(Bool) / Off(Bool)\nclassify = (n :: Num) -> Num => n + 1\nclassify = (b :: Bool) -> Num => b ? 100 : 7\n^ = () -> Num => <\n s = On(true)\n s ? | On(b) => classify(b) | Off(b) => classify(b)\n>", + 100, + ); +} + +#[test] +fn run_text_equality_and_ordering_overloads() { + // Built-in Text comparison overloads: `==` (equality) and `<`/`>` (lexicographic). + assert_exit( + "^ = () -> Num => <\n eq = \"hi\" == \"hi\" ? 10 : 0\n lt = \"abc\" < \"abd\" ? 20 : 0\n gt = \"b\" > \"a\" ? 12 : 0\n eq + lt + gt\n>", + 42, + ); +} + +#[test] +fn run_text_inequality_is_false_when_equal() { + // `!=` on Text is the negation of `==`. + assert_exit("^ = () -> Num => \"x\" != \"x\" ? 1 : 42", 42); +} + +#[test] +fn run_bool_equality_compares_values() { + // `Bool == Bool` (i1 operands) is a built-in `==` overload; it must codegen to an + // integer compare, not error or miscompile. + assert_exit("^ = () -> Num => true == true ? 42 : 0", 42); + assert_exit("^ = () -> Num => true != false ? 42 : 0", 42); +} + +#[test] +fn run_ok_dispatch_over_every_builtin_payload() { + // `Ok` constructs over every built-in payload type, including `$` (zero-payload). + // All four construct; the matched `Ok(Num)` extracts its payload as the exit code. + assert_exit( + "^ = () -> Num => <\n n = Ok(42)\n t = Ok(\"hello\")\n b = Ok(true)\n u = Ok($)\n n ? | Ok(x) => x | NotOk(e) => 0\n>", + 42, + ); +} + +#[test] +fn run_ok_text_payload_constructs_and_dispatches() { + // `Ok(Text)` constructs and the match dispatches to the `Ok` arm by tag. (Using a + // bound Text payload's fields is the separate, documented non-numeric-payload + // limitation; here we only require construction + tag dispatch to work.) + assert_exit( + "^ = () -> Num => <\n r = Ok(\"abcd\")\n r ? | Ok(s) => 42 | NotOk(e) => 0\n>", + 42, + ); +} + +#[test] +fn print_remains_an_overload_over_builtins() { + // `print` is now a visible overload set over Num/Text/Bool (returning `$`), not a + // compiler special case. Each printable type type-checks and runs. + assert_exit_linked( + "<< core.io\n^ = () -> Num => <\n print(1)\n print(\"two\")\n print(true)\n 0\n>", + 0, + ); +} + +#[test] +fn user_print_overload_is_added_not_shadowed() { + // A user `print` overload with its own signature is ADDED to the overload set; the + // built-in single-arg print/eprint still work, and the user 2-arg form dispatches. + assert_exit_linked( + "<< core.io\nprint = (a :: Num, b :: Num) -> Num => a + b\n^ = () -> Num => <\n print(\"hi\")\n print(40, 2)\n>", + 42, + ); +} + +// --- Negative overload cases: ambiguous / no-match are clear compile errors. --- + +#[test] +fn no_matching_overload_is_a_compile_error() { + // No `pick` overload accepts a Bool (exact-match, no coercion). + assert_check_err( + "pick = (n :: Num) -> Num => n\npick = (s :: Text) -> Num => s.size\n^ = () -> Num => pick(true)", + ); +} + +#[test] +fn duplicate_overload_signature_is_a_compile_error() { + // Two definitions with the SAME parameter types make every call ambiguous. + assert_check_err( + "pick = (n :: Num) -> Num => n\npick = (m :: Num) -> Num => m + 1\n^ = () -> Num => pick(1)", + ); +} + +#[test] +fn operator_with_no_overload_for_operand_types_is_a_compile_error() { + // `+` has Num/Num and Text/Text overloads but none for Num + Bool. + assert_check_err("^ = () -> Num => 1 + true"); +} + +// --- The `>` lexing rule: line-final `>` closes a block; otherwise it is `Gt`. --- + +#[test] +fn run_bare_greater_than_works_on_one_line() { + // `a > b` on a single line is the greater-than operator everywhere (no parens). + assert_exit("^ = () -> Num => 5 > 3 ? 42 : 0", 42); +} + +#[test] +fn run_greater_than_inside_block_closing_line_final() { + // A `>` comparison used inside a `< >` block whose closing `>` is line-final. + assert_exit( + "^ = () -> Num => <\n ok = 10 > 2 ? 1 : 0\n ok == 1 ? 42 : 0\n>", + 42, + ); +} + +#[test] +fn dangling_comparison_at_line_end_is_an_error() { + // A `>` placed as the LAST token on its line is lexed as block-close, so using it + // as a comparison there must fail to parse (never silently miscompile). + let src = "^ = () -> Num => <\n x = 5\n x >\n 3\n>"; + let tokens = Lexer::tokenize(src).expect("lexing failed"); + assert!( + parser::parse(&tokens).is_err(), + "a line-final `>` used as comparison must be a parse error" + ); +} + +#[test] +fn unterminated_block_with_only_midline_gt_is_an_error() { + // A block whose `>` is mid-line (a `Gt`) never gets its line-final closing `>`, + // so the block is unterminated -> a clear parse error (unexpected EOF). + let src = "^ = () -> Num => < x > 5"; + let tokens = Lexer::tokenize(src).expect("lexing failed"); + assert!( + parser::parse(&tokens).is_err(), + "an unterminated block must be a parse error" + ); +} From b189a5f11785ab76230ecbd277a788780f462a26 Mon Sep 17 00:00:00 2001 From: Assaf Sapir Date: Sat, 27 Jun 2026 16:38:55 +0300 Subject: [PATCH 2/5] Dedup overloading predicates into the AST crate Share `is_operator_symbol` and `FunctionDecl::is_inert_io_placeholder` from `ast/nodes.rs` instead of duplicating them verbatim in the type checker and code generator, so the two passes can never disagree on which names are operators or which `print`/`eprint` placeholder to skip. Also build overload-resolution error `candidates` lazily (only when an error is actually raised). No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/ast/nodes.rs | 15 +++++++++++++++ src/codegen/generator.rs | 17 ++++------------- src/typechecker/checker.rs | 17 +++-------------- 3 files changed, 22 insertions(+), 27 deletions(-) diff --git a/src/ast/nodes.rs b/src/ast/nodes.rs index c6b3f65..0a9ca0d 100644 --- a/src/ast/nodes.rs +++ b/src/ast/nodes.rs @@ -93,6 +93,21 @@ pub struct FunctionDecl { pub span: Span, } +impl FunctionDecl { + /// Whether this is the inert `core.io` `print`/`eprint` placeholder: a single + /// UNannotated parameter with an inert body. The compiler fully provides + /// `print`/`eprint` as built-in overloads (lowered to runtime intrinsics), so the + /// placeholder is ignored everywhere — neither registered as a user overload nor + /// type-checked / emitted. A genuine user `print`/`eprint` overload has fully + /// annotated parameters and is therefore NOT a placeholder. Shared by the type + /// checker and codegen so the two never disagree on what to skip. + pub fn is_inert_io_placeholder(&self) -> bool { + (self.name == "print" || self.name == "eprint") + && self.params.len() == 1 + && self.params[0].type_annotation.is_none() + } +} + #[derive(Debug, Clone, PartialEq)] pub struct Param { pub name: String, diff --git a/src/codegen/generator.rs b/src/codegen/generator.rs index 17ae209..fc0320e 100644 --- a/src/codegen/generator.rs +++ b/src/codegen/generator.rs @@ -19,15 +19,6 @@ fn is_builtin_overload_name(name: &str) -> bool { matches!(name, "print" | "eprint") } -/// The inert `core.io` `print`/`eprint` placeholder (single unannotated param). The -/// compiler fully provides these as intrinsics, so the placeholder is never emitted -/// or registered as an overload member — see the type checker's matching predicate. -fn is_inert_io_placeholder(decl: &FunctionDecl) -> bool { - (decl.name == "print" || decl.name == "eprint") - && decl.params.len() == 1 - && decl.params[0].type_annotation.is_none() -} - /// A short, mangling-safe tag for a Quilon type used in overload name mangling. Must be /// deterministic and identical at definition and call sites (built from the declared /// parameter type and from the inferred argument type respectively). @@ -228,14 +219,14 @@ impl<'ctx> CodeGenerator<'ctx> { let mut fn_counts: HashMap<&str, usize> = HashMap::new(); for item in &program.items { if let Item::FunctionDecl(decl) = item - && !is_inert_io_placeholder(decl) + && !decl.is_inert_io_placeholder() { *fn_counts.entry(decl.name.as_str()).or_insert(0) += 1; } } for item in &program.items { if let Item::FunctionDecl(decl) = item - && !is_inert_io_placeholder(decl) + && !decl.is_inert_io_placeholder() && (is_operator_symbol(&decl.name) || fn_counts.get(decl.name.as_str()).copied().unwrap_or(0) > 1 || is_builtin_overload_name(&decl.name)) @@ -261,7 +252,7 @@ impl<'ctx> CodeGenerator<'ctx> { // overloaded call/operator (keeps codegen dispatch in sync with the checker). for item in &program.items { if let Item::FunctionDecl(decl) = item - && !is_inert_io_placeholder(decl) + && !decl.is_inert_io_placeholder() && !self.overloads.contains_key(&decl.name) && let Some(ret) = &decl.return_type { @@ -588,7 +579,7 @@ impl<'ctx> CodeGenerator<'ctx> { fn generate_function_decl(&mut self, decl: &FunctionDecl) -> Result<(), String> { // The inert core.io print/eprint placeholder is never emitted (the compiler // lowers print/eprint to runtime intrinsics). - if is_inert_io_placeholder(decl) { + if decl.is_inert_io_placeholder() { return Ok(()); } diff --git a/src/typechecker/checker.rs b/src/typechecker/checker.rs index 21fa8fe..efbbe14 100644 --- a/src/typechecker/checker.rs +++ b/src/typechecker/checker.rs @@ -226,17 +226,6 @@ fn fmt_type_list(types: &[Type]) -> String { types.iter().map(type_label).collect::>().join(", ") } -/// Whether `decl` is the inert `core.io` `print`/`eprint` placeholder (a single -/// UNannotated param with an inert body). The compiler fully provides these as -/// built-in overloads, so the placeholder is ignored — neither registered as a -/// user overload nor type-checked/emitted. A genuine user `print`/`eprint` overload -/// (fully annotated params) is NOT a placeholder and is handled normally. -fn is_inert_io_placeholder(decl: &FunctionDecl) -> bool { - (decl.name == "print" || decl.name == "eprint") - && decl.params.len() == 1 - && decl.params[0].type_annotation.is_none() -} - /// Exact-type match for overload dispatch (no implicit coercion). Built-in scalars /// match by identity; a user type matches by NAME (so a `Named`/`Sum` annotation and /// the inferred instance line up regardless of carried fields); a `Generic` payload @@ -699,7 +688,7 @@ impl TypeChecker { std::collections::HashMap::new(); for item in &program.items { if let Item::FunctionDecl(decl) = item - && !is_inert_io_placeholder(decl) + && !decl.is_inert_io_placeholder() { *fn_counts.entry(decl.name.as_str()).or_insert(0) += 1; } @@ -724,7 +713,7 @@ impl TypeChecker { for item in &program.items { if let Item::FunctionDecl(decl) = item && self.overloaded_names.contains(&decl.name) - && !is_inert_io_placeholder(decl) + && !decl.is_inert_io_placeholder() { self.register_overload_decl(decl)?; } @@ -1108,7 +1097,7 @@ impl TypeChecker { fn check_function_decl(&mut self, decl: &FunctionDecl) -> Result<(), TypeError> { // The inert core.io `print`/`eprint` placeholder is fully provided by the // compiler as a built-in overload; ignore its declaration entirely. - if is_inert_io_placeholder(decl) { + if decl.is_inert_io_placeholder() { return Ok(()); } From ab1d161589eb01c6b882bb5bdb5aa2142e0acdb0 Mon Sep 17 00:00:00 2001 From: Assaf Sapir Date: Sat, 27 Jun 2026 16:46:46 +0300 Subject: [PATCH 3/5] Fix AOT link: whole-archive libquilon_rt so all intrinsics are pulled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `quilon build` linked the runtime staticlib with a plain `-lquilon_rt`, which only pulls archive members that resolve an already-undefined symbol, in a single pass whose order depends on the archive layout. The Rust staticlib splits the `#[unsafe(no_mangle)]` intrinsics across codegen-unit objects, so depending on the (unspecified) CU split a referenced intrinsic could sit in a member the pass never pulls — surfacing as `undefined reference to __text_cmp` in CI's AOT link while the JIT (which maps symbols directly) worked. Local builds happened to co-locate the text intrinsics in one object, hiding it. Wrap `-lquilon_rt` in `-Wl,--whole-archive`/`--no-whole-archive` so every runtime object is included deterministically, regardless of CU split or archive order (GNU ld syntax, honored by both clang and gcc). Verified `examples/overloading.ql` builds and runs to 161 natively under BOTH linkers, including with a forced 256-codegen-unit split of quilon-rt. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/build.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/build.rs b/src/build.rs index 70c197a..70b9859 100644 --- a/src/build.rs +++ b/src/build.rs @@ -80,8 +80,22 @@ pub fn build_native(program: &Program, out: &Path, linker: &str) -> Result<(), S .arg(&obj) .arg("-L") .arg(&lib_dir) - // The Rust staticlib needs these system libs alongside Boehm GC. - .args(["-lquilon_rt", "-lgc", "-lpthread", "-ldl", "-lm"]) + // Pull EVERY object out of `libquilon_rt.a`, not just the members that resolve + // an already-undefined symbol. The Rust staticlib splits the `#[no_mangle]` + // runtime intrinsics across codegen-unit objects (and their order in the archive + // is unspecified), so a single linker pass over a plain `-lquilon_rt` can miss an + // intrinsic the program references (e.g. `__text_cmp`) when its defining object + // sits earlier than the object that first pulled the archive in — manifesting as + // an `undefined reference` only under whatever CU split CI happens to produce. + // `--whole-archive` makes inclusion deterministic; `--no-whole-archive` restores + // normal (on-demand) linking for the system libs that follow. + .args([ + "-Wl,--whole-archive", + "-lquilon_rt", + "-Wl,--no-whole-archive", + ]) + // System libs the Rust staticlib needs, alongside Boehm GC. + .args(["-lgc", "-lpthread", "-ldl", "-lm"]) .arg("-o") .arg(out) .status() From f90945b14f90c1062e8912ca2222188426147319 Mon Sep 17 00:00:00 2001 From: Assaf Sapir Date: Sat, 27 Jun 2026 16:55:39 +0300 Subject: [PATCH 4/5] Retain runtime intrinsics in the staticlib; require Bool from comparison overloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AOT link fix (cont.): pin every `#[no_mangle]` runtime intrinsic with a `#[used]` reachability table in quilon-rt, so the staticlib link can never dead-strip a symbol that is only ever called from generated LLVM IR (never from Rust) — the root of CI's `undefined reference to __text_cmp` during native linking. Combined with the `--whole-archive` wrap of `-lquilon_rt`, the intrinsics are guaranteed both present in the archive and pulled into the executable, regardless of codegen-unit layout, archive order, or linker GC. Also (user-confirmed language rule): a comparison/equality operator overload (`== != < <= > >=`) must return `Bool` — these are predicates feeding `?`/`|` matching and conditionals; a non-Bool return is a clear compile error (ComparisonOverloadNotBool). Arithmetic operators stay unconstrained (`Vec * Num -> Vec`, dot-product `Vec * Vec -> Num`, etc.). Tests (negative + positive + arithmetic-unconstrained) and a LANGUAGE.md note added. Co-Authored-By: Claude Opus 4.8 (1M context) --- LANGUAGE.md | 6 ++++ quilon-rt/src/lib.rs | 33 +++++++++++++++++++ src/typechecker/checker.rs | 67 ++++++++++++++++++++++++++++++++++++++ tests/run_test.rs | 7 ++++ 4 files changed, 113 insertions(+) diff --git a/LANGUAGE.md b/LANGUAGE.md index 846a181..b2b01ff 100644 --- a/LANGUAGE.md +++ b/LANGUAGE.md @@ -283,6 +283,12 @@ over `Text` (lexicographic order) are built-in overloads, so text comparisons wo of the box: `"abc" < "abd"`, `"hi" == "hi"`. (Defining `<`/`>` is reserved — a top-level `<`/`>` would read as a block; overload the others, or use `<=`/`>=`.) +A **comparison/equality** operator overload (`== != < <= > >=`) **must return `Bool`** — +these are predicates that feed `?`/`|` matching and conditionals; a non-`Bool` return is +a compile error. **Arithmetic** operators (`+ - * / %`) are unconstrained: an overload +returns whatever it declares (so `Vec + Vec -> Vec`, `Vec * Num -> Vec`, or a `Vec * Vec +-> Num` dot product are all legal). + (See `examples/overloading.ql`.) --- diff --git a/quilon-rt/src/lib.rs b/quilon-rt/src/lib.rs index a378b6c..8b9ebaa 100644 --- a/quilon-rt/src/lib.rs +++ b/quilon-rt/src/lib.rs @@ -183,6 +183,39 @@ fn format_num(x: f64) -> String { } } +/// Force every runtime intrinsic to be RETAINED in the `staticlib` archive, even +/// though nothing in this crate calls them (they are only ever called from the +/// LLVM IR the code generator emits, which rustc never sees). Without an in-crate +/// reference, the staticlib's link step can dead-strip an intrinsic — observed in +/// CI as `undefined reference to __text_cmp` during AOT linking while the JIT (which +/// maps symbols by address) was unaffected. The `#[used]` table is a reachability +/// root that pins all of them deterministically, independent of codegen-unit layout +/// or linker GC. (The AOT link also wraps the archive in `--whole-archive`; this +/// guarantees the symbols are present to be pulled in the first place.) +// Function pointers transmuted to a common fn-pointer type — `Sync`, +// const-constructible, and each entry pins its intrinsic. Kept as a `#[used]` +// reachability root so the staticlib link never dead-strips an intrinsic that is +// only ever called from generated LLVM IR (never from Rust). All entries are plain +// `extern "C"` fn items; the transmute only erases their (ABI-compatible) parameter +// lists for storage — the pointers are never called through this array. +type RtFn = unsafe extern "C" fn(); +// Each `transmute` only erases an (ABI-irrelevant) parameter list to a common +// fn-pointer type for storage; the entries are never called through this array. +#[allow(clippy::missing_transmute_annotations)] +#[used] +static QUILON_RT_INTRINSICS: [RtFn; 8] = unsafe { + [ + core::mem::transmute(__gc_init as extern "C" fn()), + core::mem::transmute(__alloc as extern "C" fn(i64) -> *mut c_void), + core::mem::transmute(__text_length as extern "C" fn(*const u8, i64) -> i64), + core::mem::transmute(__text_cmp as extern "C" fn(*const u8, i64, *const u8, i64) -> i32), + core::mem::transmute(__write_bytes as extern "C" fn(i64, *const u8, i64) -> i64), + core::mem::transmute(__print_num_fd as extern "C" fn(i64, f64)), + core::mem::transmute(__print_bool_fd as extern "C" fn(i64, i64)), + core::mem::transmute(__print_text_fd as extern "C" fn(i64, *const c_char)), + ] +}; + #[cfg(test)] mod tests { use super::*; diff --git a/src/typechecker/checker.rs b/src/typechecker/checker.rs index efbbe14..902cb2a 100644 --- a/src/typechecker/checker.rs +++ b/src/typechecker/checker.rs @@ -77,6 +77,13 @@ pub enum TypeError { param: String, span: Span, }, + /// A comparison/equality operator overload (`== != < <= > >=`) declared a non-`Bool` + /// return type. These operators are predicates and must yield `Bool`. + ComparisonOverloadNotBool { + operator: String, + got: Box, + span: Span, + }, #[allow(dead_code)] PatternTypeMismatch { expected: Box, @@ -104,6 +111,7 @@ impl TypeError { | TypeError::NoMatchingOverload { span, .. } | TypeError::AmbiguousOverload { span, .. } | TypeError::OverloadMissingAnnotation { span, .. } + | TypeError::ComparisonOverloadNotBool { span, .. } | TypeError::PatternTypeMismatch { span, .. } | TypeError::NonExhaustiveMatch { span } => span, } @@ -189,6 +197,14 @@ impl std::fmt::Display for TypeError { name, param ) } + TypeError::ComparisonOverloadNotBool { operator, got, .. } => { + write!( + f, + "comparison operator '{}' overload must return Bool, found {}", + operator, + type_label(got) + ) + } TypeError::PatternTypeMismatch { expected, got, .. } => { write!( f, @@ -226,6 +242,12 @@ fn fmt_type_list(types: &[Type]) -> String { types.iter().map(type_label).collect::>().join(", ") } +/// Whether `name` is a comparison/equality operator — these overloads are predicates +/// and are required to return `Bool` (arithmetic operators are unconstrained). +fn is_comparison_operator(name: &str) -> bool { + matches!(name, "==" | "!=" | "<" | "<=" | ">" | ">=") +} + /// Exact-type match for overload dispatch (no implicit coercion). Built-in scalars /// match by identity; a user type matches by NAME (so a `Named`/`Sum` annotation and /// the inferred instance line up regardless of carried fields); a `Generic` payload @@ -750,6 +772,17 @@ impl TypeChecker { .map(|t| self.resolve_type(t)) .unwrap_or(Type::Num); + // A comparison/equality operator overload (`== != < <= > >=`) must return `Bool`: + // these are predicates that feed `?`/`|` matching and conditionals. (Arithmetic + // operators are unconstrained — e.g. `Vec * Num -> Vec` is fine.) + if is_comparison_operator(&decl.name) && ret != Type::Bool { + return Err(TypeError::ComparisonOverloadNotBool { + operator: decl.name.clone(), + got: Box::new(ret), + span: decl.span.clone(), + }); + } + // Reject an exact-duplicate signature (same parameter types) up front — it // would make every call to it ambiguous. if let Some(set) = self.overloads.get(&decl.name) @@ -2154,6 +2187,40 @@ result = val ? | OK(x, y) => x | NotOK => 0", assert!(matches!(err, TypeError::DuplicateDefinition { .. })); } + #[test] + fn test_comparison_operator_overload_must_return_bool() { + // A `==` overload returning a non-Bool is rejected with a clear diagnostic. + let err = check_ok("V = { x :: Num }\n== = (a :: V, b :: V) -> V => a\n^ = () -> Num => 0") + .unwrap_err(); + assert!(matches!(err, TypeError::ComparisonOverloadNotBool { .. })); + // `<=` too (a definable comparison operator). + assert!( + check_ok("V = { x :: Num }\n<= = (a :: V, b :: V) -> Num => 1\n^ = () -> Num => 0") + .is_err() + ); + } + + #[test] + fn test_bool_returning_comparison_overload_is_accepted() { + assert!( + check_ok( + "V = { x :: Num }\n== = (a :: V, b :: V) -> Bool => a.x == b.x\n^ = () -> Num => V { x = 1 } == V { x = 1 } ? 1 : 0" + ) + .is_ok() + ); + } + + #[test] + fn test_arithmetic_operator_overload_return_type_is_unconstrained() { + // No homogeneity rule on arithmetic operators: `V * Num -> V` is fine. + assert!( + check_ok( + "V = { x :: Num }\n* = (a :: V, k :: Num) -> V => V { x = a.x }\n^ = () -> Num => <\n w = V { x = 2 } * 3\n w.x\n>" + ) + .is_ok() + ); + } + #[test] fn test_user_operator_overload_typechecks() { assert!( diff --git a/tests/run_test.rs b/tests/run_test.rs index 7f9931f..008faa2 100644 --- a/tests/run_test.rs +++ b/tests/run_test.rs @@ -416,6 +416,13 @@ fn run_operator_overload_on_user_type() { ); } +#[test] +fn comparison_operator_overload_must_return_bool() { + // A comparison/equality operator overload is a predicate — a non-Bool return type + // is a compile error. (Arithmetic operators have no such constraint.) + assert_check_err("V = { x :: Num }\n== = (a :: V, b :: V) -> V => a\n^ = () -> Num => 0"); +} + #[test] fn run_operator_overload_returning_record_survives_frame() { // A user `+` overload that RETURNS a record: the record is GC-allocated, so its From cecded35b55220211bbba6ff9dbcf755b69f5abc Mon Sep 17 00:00:00 2001 From: Assaf Sapir Date: Sat, 27 Jun 2026 17:01:11 +0300 Subject: [PATCH 5/5] Fix AOT examples gate: always rebuild a fresh libquilon_rt.a (cache-proof) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of CI's `undefined reference to __text_cmp` (JIT fine, AOT broken): neither `cargo test` nor `cargo build --all-targets` emits the `staticlib` artifact (nothing in those target sets consumes it as a staticlib), so the `libquilon_rt.a` in `target/` is whatever a prior build left — and CI's `actions/cache` restores a STALE copy from before `__text_cmp` existed. The program references the intrinsic; the cached archive doesn't define it. `ensure_runtime_lib` only rebuilt the `.a` when ABSENT, and a plain `cargo build -p quilon-rt` won't re-emit it when the crate fingerprint is already fresh (even if the on-disk `.a` is stale/missing). So build `quilon-rt` into a DEDICATED, cache-free `--target-dir` (forcing a fresh staticlib emit every run) and copy that `.a` next to the `quilon` binary. Verified by injecting a corrupted stale `.a` and watching the native-AOT parity gate regenerate it and pass under both linkers. (Keeps the prior belt-and-suspenders: `--whole-archive` around `-lquilon_rt` and the `#[used]` intrinsic-retention table — both correct, but the stale staticlib was the actual culprit.) Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/examples_test.rs | 35 ++++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/tests/examples_test.rs b/tests/examples_test.rs index c61650d..15ff876 100644 --- a/tests/examples_test.rs +++ b/tests/examples_test.rs @@ -114,18 +114,39 @@ fn tool_available(tool: &str) -> bool { .is_ok() } -/// Ensure `libquilon_rt.a` sits next to the `quilon` binary — `quilon build` links -/// it from there. `cargo build --all-targets` (CI) emits it; a bare `cargo test` -/// may not, so build the staticlib on demand. +/// Ensure a FRESH `libquilon_rt.a` sits next to the `quilon` binary — `quilon build` +/// links it from there. This is subtle: +/// +/// - Neither `cargo test` nor `cargo build --all-targets` emits the `staticlib` +/// artifact (nothing in those target sets *consumes* it as a staticlib), so the `.a` +/// in `target/` is whatever a previous build left — and in CI that is a STALE copy +/// restored from the build cache. That is exactly how a newly-added runtime intrinsic +/// (`__text_cmp`) links under the JIT yet fails AOT with `undefined reference`: the +/// program references it, but the cached `.a` predates it. +/// - Simply re-running `cargo build -p quilon-rt` does NOT help: if the crate's +/// fingerprint is already up to date (the rlib was compiled this run), cargo will not +/// re-emit the staticlib output, even if the `.a` on disk is stale/missing. +/// +/// So build `quilon-rt` into a DEDICATED, cache-free target dir (which forces a fresh +/// staticlib emit every time) and copy that `.a` next to the `quilon` binary. fn ensure_runtime_lib(bin_dir: &Path) { - if bin_dir.join("libquilon_rt.a").exists() { - return; - } let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()); - let _ = Command::new(cargo) + let rt_target = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target") + .join("rt-staticlib"); + let status = Command::new(&cargo) .args(["build", "-p", "quilon-rt"]) + .arg("--target-dir") + .arg(&rt_target) .current_dir(env!("CARGO_MANIFEST_DIR")) .status(); + assert!( + status.is_ok_and(|s| s.success()), + "failed to build libquilon_rt.a for the native-AOT gate" + ); + let fresh = rt_target.join("debug").join("libquilon_rt.a"); + std::fs::copy(&fresh, bin_dir.join("libquilon_rt.a")) + .expect("copy fresh libquilon_rt.a next to the quilon binary"); } /// Every runnable example must produce its documented exit code via the in-process