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
2 changes: 1 addition & 1 deletion .cursor/skills/finding-ion-bugs/references/bug-hotspots.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
- **Reference escape**: `&` stored in struct, returned, sent on channel, captured by `spawn`. Locals and params must also reject off-stack stores (`Box<&T>`, `Vec<&T>`); `is_reference_containing` on decls/returns is not enough (`test_box_ref_let_error.ion`). `Option<&T>` stack temporaries from `get_ref` stay legal.
- **Send**: non-Send types on channels or in spawn closures; `Box` and channel element variance
- **Recursive types**: `is_reference_containing` / `is_send` / `is_eq_type` / `type_needs_drop` need a visiting set; without one, `Box`/`Vec`/`Option<Box<…>>` self-reference stack-overflows at decl time. Representability (`InfiniteSize`) treats only `Box`/`Vec`/`RawPtr` as size boundaries — `Option<Node>` is still infinite size; `Option<Box<Node>>` is not. Do not “stop at Box” inside the no-escape walker or `Box<&T>` silently passes.
- **Generic enum `None`**: `Option::None` has no payload, so `T` is inferred from `expr_expected` / return type (let annotation, struct field, call argument, or return). Unannotated `let empty = Option::None` must error with cannot-infer, not a later `Option<T>` vs `Option<Box<Node>>` mismatch (`test_option_none_unannotated_error.ion`). Same-expression `Node { next: Option::None }` is fine (`test_option_none_struct_field.ion`). Direct call arguments `take(Option::None)` are fine (`test_option_none_call_arg.ion`).
- **Generic enum `None`**: `Option::None` has no payload, so `T` is inferred from `expr_expected` / return type (let annotation, struct field, call argument, built-in value parameter, or return). Unannotated `let empty = Option::None` must error with cannot-infer, not a later `Option<T>` vs `Option<Box<Node>>` mismatch (`test_option_none_unannotated_error.ion`). Same-expression `Node { next: Option::None }` is fine (`test_option_none_struct_field.ion`). Direct call arguments `take(Option::None)` are fine (`test_option_none_call_arg.ion`). `send(&tx, Option::None)` infers from `Sender<T>` (`test_send_option_none.ion`); `Box::new(Option::None)` infers from an expected `Box<Option<...>>` (`test_box_new_option_none.ion`). `send` is `Expr::Send`, not a user `Call`, so call-arg expected-type plumbing does not cover it.
- **Match-arm result types**: `infer_block_result_type` reads recorded `TypeInfo` expr types plus control-flow shape (diverge vs value). It must not call `check_expr` again after `check_stmt` (`test_vec_get_putback_named.ion`).
- **Match on `&GenericEnum`**: peel `Ref` before building the type-param subst map in `add_pattern_bindings` (see `test_match_ref_generic_enum_arith.ion`); bare `if let Type::Generic` misses `Ref { Generic { … } }` and leaves bindings as `&T`
- **`resolve_type_name` and `&Enum` params**: must recurse into `Ref` so `&Flag` becomes `Ref { Enum }` (parser stores enum names as `Struct`); otherwise calls get `expected &Flag, got &Flag` from Struct vs Enum mismatch
Expand Down
2 changes: 1 addition & 1 deletion .cursor/skills/ion-lang/references/language-constraints.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ APIs that would return `&T` in Rust must use owned values, indices, or the patte

- `spawn { ... }` creates an OS thread
- `channel<T>()` → `(Sender<T>, Receiver<T>)` - bounded MPSC
- `send(&tx, v)` moves `v` into channel; `recv(&mut rx)` receives by move
- `send(&tx, v)` moves `v` into channel; the value is checked against `T`. `recv(&mut rx)` receives by move
- Only `Send` types cross thread boundaries

## Memory
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ let p: Point = Point { x: 1, y: 2 };

## Enum variants

Tuple: `Option::Some(42)`, `Option::None`. `take(Option::None)` infers `T` from the parameter type ([tests/test_option_none_call_arg.ion](../../../../tests/test_option_none_call_arg.ion)); unannotated `let empty = Option::None` still needs an annotation.
Tuple: `Option::Some(42)`, `Option::None`. `take(Option::None)` infers `T` from the parameter type ([tests/test_option_none_call_arg.ion](../../../../tests/test_option_none_call_arg.ion)); `send(&tx, Option::None)` infers from `Sender<T>` ([tests/test_send_option_none.ion](../../../../tests/test_send_option_none.ion)); `Box::new(Option::None)` infers from an expected `Box<Option<...>>` ([tests/test_box_new_option_none.ion](../../../../tests/test_box_new_option_none.ion)); unannotated `let empty = Option::None` still needs an annotation.

Struct: `Status::Ok { value: 10 }`.

Expand Down Expand Up @@ -383,7 +383,7 @@ Multi-file mode prefixes each module's C symbols (`io_print_int`, `fmt_print_int

## Channel send expressions

`send(&tx, make())` is valid ([tests/test_channel_send_call_expr.ion](../../../../tests/test_channel_send_call_expr.ion)). Use `send(&tx, value)` and `recv(&mut rx)` (see [examples/spawn_channel/spawn_channel.ion](../../../../examples/spawn_channel/spawn_channel.ion)).
`send(&tx, make())` is valid ([tests/test_channel_send_call_expr.ion](../../../../tests/test_channel_send_call_expr.ion)). `send(&tx, Option::None)` infers `T` from `Sender<T>` ([tests/test_send_option_none.ion](../../../../tests/test_send_option_none.ion)). Use `send(&tx, value)` and `recv(&mut rx)` (see [examples/spawn_channel/spawn_channel.ion](../../../../examples/spawn_channel/spawn_channel.ion)).

## if / ownership merge

Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## 0.1.20 - 2026-08-14

- **Type checker**: `send(&tx, Option::None)` infers `T` from `Sender<T>`, and `Box::new(Option::None)` infers from an expected `Box<Option<...>>`. This impacts passing unannotated no-payload generic variants into `send` or `Box::new` (user-function call arguments already inferred in 0.1.19). Unannotated `let empty = Option::None` still requires an annotation.
- **Tests**: `test_send_option_none.ion`, `test_send_result_err.ion`, `test_box_new_option_none.ion`.
- **Docs**: ION_SPEC §4.4 / §7.2, bug hotspots, verified patterns.

## 0.1.19 - 2026-08-13

- **Codegen**: `Vec<T>` scope-exit drop now drops remaining elements when `T` needs destruction, then `ion_vec_free`. This impacts any `Vec<String>`, `Vec<Vec<U>>`, or `Vec` of structs/enums with owned fields (previously the backing array was freed and elements leaked). `Vec::get` of such `T` hollows the slot (already specified as move-out). `Vec::set` drops the previous element. `Vec<int>` and other Copy elements are unchanged. `Box<T>` drops `T` before `ion_box_free` when `T` needs destruction; `Box::unwrap` still does not drop `T`.
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "ion-compiler"
version = "0.1.19"
version = "0.1.20"
edition = "2024"

[[bin]]
Expand Down
4 changes: 2 additions & 2 deletions ION_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -671,7 +671,7 @@ Ion supports a **local, Hindley–Milner-inspired inference**:
The inference engine is intentionally limited:

- No higher-rank polymorphism.
- Generic enum variants with no payload (`Option::None`) infer type arguments only from an adjacent expected type (a `let` annotation, a struct field, a function parameter / call argument, or a return type). They do not take `T` from a later statement; without that context the compiler requires an annotation.
- Generic enum variants with no payload (`Option::None`) infer type arguments only from an adjacent expected type (a `let` annotation, a struct field, a function parameter / call argument including built-in value parameters such as `send` and `Box::new`, or a return type). They do not take `T` from a later statement; without that context the compiler requires an annotation.
- Generic type parameters may declare optional **trait bounds** (`Copy`, `Eq`, `Send`). Bounds are checked at monomorphization: each concrete instantiation must satisfy every bound on the corresponding parameter. There are no user-defined traits; bounds name structural capabilities checked by the compiler (see Section 4.8).
- Structural `Send` still applies per instantiation even without an explicit bound: for a generic type `Wrapper<T>`, each monomorphized `Wrapper<U>` is `Send` if and only if all of its fields (with `T` replaced by `U`) are `Send`.

Expand Down Expand Up @@ -999,7 +999,7 @@ Semantics:

- `channel<T>()` is a built-in that returns `(Sender<T>, Receiver<T>)`. It takes no arguments. Element type `T` must be `Send`. The runtime buffer capacity is fixed at **1** slot per channel in the current compiler.
- `Sender<T>` and `Receiver<T>` are move-only value types (not pointers).
- `send(&tx, value)` moves a value into the channel. Requires `&Sender<T>`.
- `send(&tx, value)` moves a value into the channel. Requires `&Sender<T>`. The value is checked against `T`, so `send(&tx, Option::None)` infers from the sender.
- `recv(&mut rx)` moves a value out of the channel. Requires `&mut Receiver<T>`. Blocks until a value is available.
- `send` blocks when the buffer is full; `recv` blocks when empty.
- Tuple destructuring is supported: `let (tx, rx) = channel<int>();`
Expand Down
2 changes: 1 addition & 1 deletion src/cgen/builtins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ impl Codegen {
// Generate the argument expression
let mut arg_code = String::new();
let old_output = std::mem::replace(&mut self.output, arg_code);
self.generate_expr(&args[0]);
self.generate_expr_with_type(&args[0], Some(inner_type));
arg_code = std::mem::replace(&mut self.output, old_output);
code.push_str(&arg_code);
code.push_str("; } ptr; })");
Expand Down
4 changes: 2 additions & 2 deletions src/cgen/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2590,7 +2590,7 @@ impl Codegen {
let needs_temp = !is_send_value_lvalue(value);
if needs_temp {
self.write(&format!("{} _send_val = ", self.type_to_c(value_type)));
self.generate_expr(value);
self.generate_expr_with_type(value, Some(value_type));
self.write("; ");
}
self.write("ion_channel_send(");
Expand Down Expand Up @@ -2830,7 +2830,7 @@ impl Codegen {
let needs_temp = !is_send_value_lvalue(value);
if needs_temp {
self.write(&format!("{} _send_val = ", self.type_to_c(value_type)));
self.generate_expr(value);
self.generate_expr_with_type(value, Some(value_type));
self.write("; ");
}
self.write("ion_channel_send(");
Expand Down
13 changes: 12 additions & 1 deletion src/tc/builtins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,18 @@ impl TypeChecker {
span: call_expr.span,
});
}
let value_ty = self.check_expr(&call_expr.args[0])?;
let expected_inner = match &self.expr_expected {
Some(Type::Box { inner }) => Some(inner.as_ref().clone()),
_ => match &self.current_return_type {
Some(Type::Box { inner }) => Some(inner.as_ref().clone()),
_ => None,
},
};
let value_ty = if let Some(inner) = &expected_inner {
self.check_expr_with_expected(&call_expr.args[0], inner)?
} else {
self.check_expr(&call_expr.args[0])?
};
let box_ty = Type::Box {
inner: Box::new(value_ty),
};
Expand Down
2 changes: 1 addition & 1 deletion src/tc/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3120,7 +3120,7 @@ impl TypeChecker {
}
};

let value_type = self.check_expr(&send_expr.value)?;
let value_type = self.check_expr_with_expected(&send_expr.value, &elem_type)?;
if !types_equal(&value_type, &elem_type) {
return Err(TypeCheckError::TypeMismatch {
expected: type_to_string(&elem_type),
Expand Down
4 changes: 4 additions & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@ The test runner prints pass/fail counts when it finishes. Do not rely on hardcod
- `test_option_none_call_arg.ion` - `take(Option::None)` infers `T` from the parameter (exit 3)
- `test_option_none_call_arg_middle.ion` - `take2(5, Option::None)` infers `T` in a non-final argument (exit 5)
- `test_result_err_call_arg.ion` - `take(Result::Err(7))` infers `T` from the parameter (exit 7)
- `test_send_option_none.ion` - `send(&tx, Option::None)` infers `T` from `Sender<Option<int>>` (exit 0)
- `test_send_result_err.ion` - `send(&tx, Result::Err(4))` infers `T` from `Sender<Result<int, int>>` (exit 4)
- `test_box_new_option_none.ion` - `Box::new(Option::None)` infers from expected `Box<Option<int>>` (exit 0)
- `test_unannotated_let_non_int.ion` - unannotated `let q = p` / `let n = w.p` / `let v = origin()` keep struct types, not default int (exit 5); cgen asserts `Point q =` / `Point n =` / `Point v =`
- `test_enum_generic.ion` - Generic enum types
- `test_result_custom_enum.ion` - `Result<int, MyError>` via `stdlib/result.ion` (Ok and Err, exit 0)
Expand Down Expand Up @@ -258,6 +261,7 @@ The test runner prints pass/fail counts when it finishes. Do not rely on hardcod
- `test_channel_string.ion` - `channel<String>` send/recv; IR recv uses `String` element type (exit 3)
- `test_channel_send_call_expr.ion` - `send(&tx, make())` with non-lvalue operand codegen (exit 7)
- `test_channel_send_field_call_expr.ion` - `send(&tx, make_pair().x)` temps field of call result (exit 11)
- `test_send_option_none.ion` - `send(&tx, Option::None)` infers from `Sender<T>` (exit 0); also listed under Enums
- `test_enum_struct_variant.ion` - Struct-style enum variants with named fields
- `test_for_loop.ion` - `for...in` loop syntax with Vec iteration

Expand Down
17 changes: 17 additions & 0 deletions tests/test_box_new_option_none.ion
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Box::new(Option::None) infers T from the expected Box<Option<int>>.
enum Option<T> {
Some(T);
None;
}

fn main() -> int {
let b: Box<Option<int>> = Box::new(Option::None);
match Box::unwrap(b) {
Option::Some(v) => {
return v;
}
Option::None => {
return 0;
}
}
}
5 changes: 5 additions & 0 deletions tests/test_expectations.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ test_option_none_call_arg.ion run 3
test_option_none_call_arg.ion cgen take((Option_int)
test_option_none_call_arg_middle.ion run 5
test_result_err_call_arg.ion run 7
test_send_option_none.ion run 0
test_send_option_none.ion cgen Option_int _send_val
test_send_result_err.ion run 4
test_box_new_option_none.ion run 0
test_box_new_option_none.ion cgen (Option_int)
test_vec_string_scope_drop.ion run 0
test_vec_string_scope_drop.ion cgen ion_string_free(((ion_string_t**)((v)->data))
test_vec_string_scope_drop.ion cgen ion_vec_free((ion_vec_t*)(v))
Expand Down
18 changes: 18 additions & 0 deletions tests/test_send_option_none.ion
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// send(&tx, Option::None) infers T from Sender<Option<int>>.
enum Option<T> {
Some(T);
None;
}

fn main() -> int {
let (tx, rx): (Sender<Option<int>>, Receiver<Option<int>>) = channel<Option<int>>();
send(&tx, Option::None);
match recv(&mut rx) {
Option::Some(v) => {
return v;
}
Option::None => {
return 0;
}
}
}
15 changes: 15 additions & 0 deletions tests/test_send_result_err.ion
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// send(&tx, Result::Err(...)) infers T from Sender<Result<T, E>>.
import "stdlib/result.ion" as result;

fn main() -> int {
let (tx, rx): (Sender<Result<int, int>>, Receiver<Result<int, int>>) = channel<Result<int, int>>();
send(&tx, Result::Err(4));
match recv(&mut rx) {
Result::Ok(v) => {
return v;
}
Result::Err(e) => {
return e;
}
}
}