Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
97 changes: 87 additions & 10 deletions LANGUAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -231,11 +231,81 @@ 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 `<=`/`>=`.)

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`.)

---

## 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
Expand Down Expand Up @@ -299,7 +369,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. |
Expand Down Expand Up @@ -365,10 +435,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
| ^^^^^^^^
Expand All @@ -389,6 +459,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]` | ✅ |
Expand All @@ -407,7 +480,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 | ❌ |

---
Expand All @@ -416,10 +490,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.

Expand Down
31 changes: 31 additions & 0 deletions examples/overloading.ql
Original file line number Diff line number Diff line change
@@ -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
>
60 changes: 60 additions & 0 deletions quilon-rt/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -156,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::*;
Expand Down
55 changes: 51 additions & 4 deletions src/ast/nodes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -345,18 +360,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,
Expand Down
Loading