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
56 changes: 49 additions & 7 deletions LANGUAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,47 @@ factorial = n -> Num => n == 0 ? 1 : n * factorial(n - 1)
```
(See `examples/factorial.ql`, `examples/fibonacci.ql`.)

### Closures — capture by `=` (value) vs `:=` (reference)

A function written **inside** another function's body is a **closure**: it can read the
enclosing locals it refers to. How each captured name is captured is decided **by the
operator that bound it** — there is no capture list and no marker, mirroring the
mutability rule for [variables](#variables) and [records](#mutation-in-place-field-writes--setters):

- a name bound with **`=`** is captured **by value** — a frozen, read-only snapshot taken
when the closure is created;
- a name bound with **`:=`** is captured **by reference** — a single shared, mutable cell.
Writes through it (from inside the closure or from the enclosing code) are visible to
everyone sharing it, and the cell survives even if the closure outlives the frame that
created it.

```quilon
^ = () -> Num => <
total := 0 ~ `:=` -> captured BY REFERENCE
bump = n => <
total := total + n ~ writes the SHARED cell; the effect persists across calls
total
>
bump(10) ~ total -> 10
bump(20) ~ total -> 30 (same cell)

base = 7 ~ `=` -> captured BY VALUE (a frozen copy)
addBase = x => x + base

total + addBase(5) ~ 30 + 12 = 42
>
```

A non-capturing nested function may **recurse** (`fact = n => … fact(n-1) …`); nested
closures may capture from any enclosing frame (the shared `:=` cell is threaded through
every level), and a closure value may itself be captured by another closure and called.

Closures are **monomorphic** in this milestone: parameters and captured values are
concrete-typed (the capture rule needs no type variables). Capturing a polymorphic value,
generic closures, passing a closure as a function **parameter**, and returning a closure
from a function (higher-order across frames) are deferred — see
[Known limitations](#known-limitations). (See `examples/closures.ql`.)

---

## Overloading
Expand Down Expand Up @@ -495,6 +536,7 @@ message instead. Any compile error exits with status 1.
| Named record types + methods (`it`) | ✅ |
| In-place mutation of `:=` records: field writes (`obj.f := v`) + setter methods | ✅ |
| Functions, recursion, blocks, type inference | ✅ |
| Closures: lexical capture (`=` by value / `:=` by reference), monomorphic | ✅ |
| Pipe `\|>` (first-arg injection) | ✅ |
| `for n <- collection => body` loops | ✅ |
| Ranges: infix `lo <- hi` → inclusive `[]Num` (descends when `lo > hi`) | ✅ |
Expand All @@ -507,8 +549,9 @@ message instead. Any compile error exits with status 1.
| Conservative GC (Boehm) | ✅ |
| `Text` (and nested arrays) in records/arrays, or as a sum-type payload (`Ok(text)`) | ✅ |
| Command-line `argv` (argc works; argv is a placeholder) | 🚧 |
| Generics / type variables (overloading is the only polymorphism), closures, `while` loops | ❌ |
| Overloaded name passed as a value (higher-order); only direct call sites resolve | ❌ |
| Generics / type variables (overloading is the only polymorphism), `while` loops | ❌ |
| Overloaded name passed as a value, or a closure as a param / return (higher-order) | ❌ |
| Generic / polymorphic-capturing closures | ❌ |
| Array methods (`map`/`filter`/`reduce`), string interpolation | ❌ |

---
Expand All @@ -519,11 +562,10 @@ message instead. Any compile error exits with status 1.

- **A generic `Result` payload routed through an overload set resolves to the `Num` member.** `Text`/array fields and `Ok("x")`/`NotOk("e")` payloads now type-check and round-trip end-to-end (see [records](#records), [`Result`](#result-is-a-normal-sum-type)). But a `Result` payload is *generic*, so binding it (`Ok(x) => …`) and passing `x` to an [overload set](#overloading) still resolves to the **`Num`** member; 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`).
- **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.
- 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 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).
- **Closures are monomorphic.** Lexical capture works end-to-end (`=` by value / `:=` by reference; see [Closures](#closures--capture-by--value-vs--reference)), including recursion of non-capturing nested functions, capture across multiple nesting levels, and capturing-then-calling another closure. Deferred to a later milestone (they need the closure's type threaded through inference / defunctionalization): capturing a *polymorphic* value, *generic* closures, passing a closure **as a function parameter**, and **returning a closure from a function**. A closure used in an unsupported position is rejected at compile time (e.g. an unannotated function parameter that is called reports `Not a function`), never miscompiled.
- **Overloads (and closures) 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
27 changes: 27 additions & 0 deletions examples/closures.ql
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
~ Closures: lexical capture, with the capture mode decided by the binding operator.
~ `=` bindings are captured BY VALUE — a frozen, read-only snapshot.
~ `:=` bindings are captured BY REFERENCE — a shared, mutable cell whose writes
~ escape the closure and persist across calls.
~ There is no capture list and no marker: the operator that bound the name is the
~ signal. (Closures are monomorphic in M3 — concrete-typed params and captures.)

^ = () -> Num => <
~ `total` is `:=` -> captured by reference. `bump` mutates the shared cell, so
~ its writes accumulate across separate calls (they escape the closure).
total := 0
bump = n => <
total := total + n
total
>

bump(10) ~ total -> 10
bump(20) ~ total -> 30 (the same cell, written again)

~ `base` is `=` -> captured by value. `addBase` sees a frozen copy; rebinding
~ `base` afterwards does NOT change what the closure already captured.
base = 7
addBase = x => x + base

~ 30 (accumulated via the :=-captured cell) + 12 (5 + the =-captured 7) = 42.
total + addBase(5)
>
194 changes: 194 additions & 0 deletions src/ast/captures.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
//! Free-variable / capture analysis for closures (M3).
//!
//! A lambda *captures* an identifier when its body references a name bound in an
//! enclosing scope (and not shadowed by one of the lambda's own bindings). Capture is
//! purely lexical; how each captured name is captured (by value vs by reference) is
//! decided by the binding operator at codegen time, not here.
//!
//! The one subtlety is that Quilon's `:=` is BOTH "mutable bind" and "reassign": inside a
//! closure, `x := v` *reassigns the captured cell* when `x` names an enclosing binding,
//! but *declares a fresh local* when it does not. So this analysis is parameterized by
//! the set of enclosing names (`outer`): a `:=` to an outer name is a use (capture), a
//! `:=` to a new name is a local. It never needs to resolve types.

use super::nodes::{Expr, ForPattern, Item, Pattern, Statement};
use std::collections::HashSet;

/// The ordered, de-duplicated names a lambda captures: references in its body to names in
/// `outer` (the enclosing scope) that the lambda has not shadowed with its own parameter
/// or local binding. `params` are the lambda's parameter names (which shadow `outer`).
/// Order follows first textual appearance, giving the closure environment a stable field
/// layout.
pub fn lambda_free_idents(params: &[String], body: &Expr, outer: &HashSet<String>) -> Vec<String> {
// `local` accumulates names bound INSIDE the lambda (params first); a read or write of
// a `local` name is never a capture. A name that is neither local nor outer is a
// top-level/global reference, also not captured.
let mut local: HashSet<String> = params.iter().cloned().collect();
let mut seen = HashSet::new();
let mut ordered = Vec::new();
collect(body, &mut local, outer, &mut seen, &mut ordered);
ordered
}

/// Record a reference to `name` as a capture if it resolves to an enclosing binding
/// (`outer`) and is not locally shadowed.
fn note(
name: &str,
local: &HashSet<String>,
outer: &HashSet<String>,
seen: &mut HashSet<String>,
out: &mut Vec<String>,
) {
if !local.contains(name) && outer.contains(name) && seen.insert(name.to_string()) {
out.push(name.to_string());
}
}

fn collect(
expr: &Expr,
local: &mut HashSet<String>,
outer: &HashSet<String>,
seen: &mut HashSet<String>,
out: &mut Vec<String>,
) {
match expr {
Expr::Ident { name, .. } => note(name, local, outer, seen, out),
Expr::Number { .. } | Expr::String { .. } | Expr::Bool { .. } | Expr::Unit { .. } => {}
Expr::BinOp { left, right, .. }
| Expr::Pipeline { left, right, .. }
| Expr::Range {
start: left,
end: right,
..
} => {
collect(left, local, outer, seen, out);
collect(right, local, outer, seen, out);
}
Expr::UnaryOp { expr, .. } | Expr::FieldAccess { expr, .. } => {
collect(expr, local, outer, seen, out)
}
Expr::Call { func, args, .. } => {
collect(func, local, outer, seen, out);
for a in args {
collect(a, local, outer, seen, out);
}
}
Expr::Lambda { params, body, .. } => {
// A nested lambda's parameters shadow within its own body; names it reads from
// OUR scope are transitively free in us too. Its locals are its own — clone so
// they don't leak back into ours.
let mut inner = local.clone();
for p in params {
inner.insert(p.name.clone());
}
collect(body, &mut inner, outer, seen, out);
}
Expr::Block { stmts, .. } => {
// A block opens a nested scope; thread a forward-growing local set through it.
let mut block_local = local.clone();
for stmt in stmts {
match stmt {
Statement::Expr(e) => collect(e, &mut block_local, outer, seen, out),
Statement::Item(Item::VarDecl(decl)) => {
// The initializer runs BEFORE the name binds.
collect(&decl.value, &mut block_local, outer, seen, out);
// `x := v` where `x` is an outer binding not yet shadowed locally
// is a REASSIGNMENT of the captured cell — a use, so capture `x`
// and do NOT shadow it. Any other binding introduces a local.
let is_outer_reassign = decl.mutable
&& !block_local.contains(&decl.name)
&& outer.contains(&decl.name);
if is_outer_reassign {
note(&decl.name, &block_local, outer, seen, out);
} else {
block_local.insert(decl.name.clone());
}
}
Statement::Item(Item::FunctionDecl(decl)) => {
// A nested function is itself a closure: names it reads from OUR
// scope are transitively free in us too. Analyze its body with its
// parameters shadowing (a cloned local set), then bind its name.
let mut inner = block_local.clone();
for p in &decl.params {
inner.insert(p.name.clone());
}
collect(&decl.body, &mut inner, outer, seen, out);
block_local.insert(decl.name.clone());
}
Statement::Item(Item::TypeDecl(_)) => {}
}
}
}
Expr::If {
cond, then, else_, ..
} => {
collect(cond, local, outer, seen, out);
collect(then, local, outer, seen, out);
collect(else_, local, outer, seen, out);
}
Expr::Match { expr, arms, .. } => {
collect(expr, local, outer, seen, out);
for arm in arms {
let mut arm_local = local.clone();
bind_pattern(&arm.pattern, &mut arm_local);
collect(&arm.body, &mut arm_local, outer, seen, out);
}
}
Expr::FieldAssign { target, value, .. } => {
collect(target, local, outer, seen, out);
collect(value, local, outer, seen, out);
}
Expr::Index { expr, index, .. } => {
collect(expr, local, outer, seen, out);
collect(index, local, outer, seen, out);
}
Expr::Array { elements, .. } => {
for e in elements {
collect(e, local, outer, seen, out);
}
}
Expr::Record { fields, .. } | Expr::Constructor { fields, .. } => {
for (_, e) in fields {
collect(e, local, outer, seen, out);
}
}
Expr::SumConstructor { args, .. } => {
for a in args {
collect(a, local, outer, seen, out);
}
}
Expr::ForLoop {
collection,
pattern,
body,
..
} => {
collect(collection, local, outer, seen, out);
let mut inner = local.clone();
match pattern {
ForPattern::Item { name, .. } => {
inner.insert(name.clone());
}
ForPattern::ItemIndex { item, index, .. } => {
inner.insert(item.clone());
inner.insert(index.clone());
}
}
collect(body, &mut inner, outer, seen, out);
}
}
}

fn bind_pattern(pattern: &Pattern, bound: &mut HashSet<String>) {
match pattern {
Pattern::Ident { name, .. } => {
bound.insert(name.clone());
}
Pattern::Constructor { args, .. } => {
for a in args {
bind_pattern(a, bound);
}
}
Pattern::Number { .. } | Pattern::Wildcard { .. } => {}
}
}
1 change: 1 addition & 0 deletions src/ast/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// AST (Abstract Syntax Tree) definitions for Quilon

pub mod captures;
pub mod nodes;
pub mod types;

Expand Down
15 changes: 15 additions & 0 deletions src/ast/nodes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,20 @@ pub enum Expr {
span: Span,
},

// Function literal (lambda / closure): `x => x + 1`, `(a, b) => a + b`, `() => 0`.
// A first-class value, distinct from a top-level `FunctionDecl`. When its body
// references names bound in an enclosing scope, those are *captured*: a name bound
// with `=` is captured by value (read-only copy), one bound with `:=` is captured
// by reference (a shared, mutable GC cell). Capture is inferred entirely from the
// binding operator — there is no capture list. Closures are monomorphic in M3:
// params/captures are concrete-typed; generic closures are deferred to M4.
Lambda {
params: Vec<Param>,
return_type: Option<Type>,
body: Box<Expr>,
span: Span,
},

// Pipeline
Pipeline {
left: Box<Expr>,
Expand Down Expand Up @@ -277,6 +291,7 @@ impl Expr {
Expr::BinOp { span, .. } => span,
Expr::UnaryOp { span, .. } => span,
Expr::Call { span, .. } => span,
Expr::Lambda { span, .. } => span,
Expr::Pipeline { span, .. } => span,
Expr::Block { span, .. } => span,
Expr::If { span, .. } => span,
Expand Down
Loading