From 210a7794e1eaba8338083552050eb7b59eef12d6 Mon Sep 17 00:00:00 2001 From: Cod-e-Codes Date: Thu, 13 Aug 2026 22:03:23 -0400 Subject: [PATCH] Infer Option::None from send and Box::new expected types. Builtin value parameters were not checking-mode sites, so send(&tx, Option::None) still required a typed temporary after user-function calls already inferred T. --- .../references/bug-hotspots.md | 2 +- .../references/language-constraints.md | 2 +- .../references/verified-patterns.md | 4 ++-- CHANGELOG.md | 6 ++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- ION_SPEC.md | 4 ++-- src/cgen/builtins.rs | 2 +- src/cgen/mod.rs | 4 ++-- src/tc/builtins.rs | 13 ++++++++++++- src/tc/mod.rs | 2 +- tests/README.md | 4 ++++ tests/test_box_new_option_none.ion | 17 +++++++++++++++++ tests/test_expectations.tsv | 5 +++++ tests/test_send_option_none.ion | 18 ++++++++++++++++++ tests/test_send_result_err.ion | 15 +++++++++++++++ 16 files changed, 89 insertions(+), 13 deletions(-) create mode 100644 tests/test_box_new_option_none.ion create mode 100644 tests/test_send_option_none.ion create mode 100644 tests/test_send_result_err.ion diff --git a/.cursor/skills/finding-ion-bugs/references/bug-hotspots.md b/.cursor/skills/finding-ion-bugs/references/bug-hotspots.md index c974bbd..6d6ae6d 100644 --- a/.cursor/skills/finding-ion-bugs/references/bug-hotspots.md +++ b/.cursor/skills/finding-ion-bugs/references/bug-hotspots.md @@ -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>` self-reference stack-overflows at decl time. Representability (`InfiniteSize`) treats only `Box`/`Vec`/`RawPtr` as size boundaries — `Option` is still infinite size; `Option>` 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` vs `Option>` 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` vs `Option>` 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` (`test_send_option_none.ion`); `Box::new(Option::None)` infers from an expected `Box>` (`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 diff --git a/.cursor/skills/ion-lang/references/language-constraints.md b/.cursor/skills/ion-lang/references/language-constraints.md index 3c60ad5..c88b217 100644 --- a/.cursor/skills/ion-lang/references/language-constraints.md +++ b/.cursor/skills/ion-lang/references/language-constraints.md @@ -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()` → `(Sender, Receiver)` - 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 diff --git a/.cursor/skills/writing-ion-code/references/verified-patterns.md b/.cursor/skills/writing-ion-code/references/verified-patterns.md index 5d6956b..16b9b9d 100644 --- a/.cursor/skills/writing-ion-code/references/verified-patterns.md +++ b/.cursor/skills/writing-ion-code/references/verified-patterns.md @@ -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` ([tests/test_send_option_none.ion](../../../../tests/test_send_option_none.ion)); `Box::new(Option::None)` infers from an expected `Box>` ([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 }`. @@ -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` ([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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0da8d9a..3d30709 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.1.20 - 2026-08-14 + +- **Type checker**: `send(&tx, Option::None)` infers `T` from `Sender`, and `Box::new(Option::None)` infers from an expected `Box>`. 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` scope-exit drop now drops remaining elements when `T` needs destruction, then `ion_vec_free`. This impacts any `Vec`, `Vec>`, 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` and other Copy elements are unchanged. `Box` drops `T` before `ion_box_free` when `T` needs destruction; `Box::unwrap` still does not drop `T`. diff --git a/Cargo.lock b/Cargo.lock index 9c59bef..56fcfc6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -323,7 +323,7 @@ dependencies = [ [[package]] name = "ion-compiler" -version = "0.1.19" +version = "0.1.20" dependencies = [ "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index dd80f44..b326ec8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ion-compiler" -version = "0.1.19" +version = "0.1.20" edition = "2024" [[bin]] diff --git a/ION_SPEC.md b/ION_SPEC.md index af3ef7d..b8b6893 100644 --- a/ION_SPEC.md +++ b/ION_SPEC.md @@ -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`, each monomorphized `Wrapper` is `Send` if and only if all of its fields (with `T` replaced by `U`) are `Send`. @@ -999,7 +999,7 @@ Semantics: - `channel()` is a built-in that returns `(Sender, Receiver)`. 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` and `Receiver` are move-only value types (not pointers). -- `send(&tx, value)` moves a value into the channel. Requires `&Sender`. +- `send(&tx, value)` moves a value into the channel. Requires `&Sender`. 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`. 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();` diff --git a/src/cgen/builtins.rs b/src/cgen/builtins.rs index f27232c..99dd0d9 100644 --- a/src/cgen/builtins.rs +++ b/src/cgen/builtins.rs @@ -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; })"); diff --git a/src/cgen/mod.rs b/src/cgen/mod.rs index e2b59ef..d3b5546 100644 --- a/src/cgen/mod.rs +++ b/src/cgen/mod.rs @@ -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("); @@ -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("); diff --git a/src/tc/builtins.rs b/src/tc/builtins.rs index e344944..7f2cd30 100644 --- a/src/tc/builtins.rs +++ b/src/tc/builtins.rs @@ -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), }; diff --git a/src/tc/mod.rs b/src/tc/mod.rs index 2193351..cd404e4 100644 --- a/src/tc/mod.rs +++ b/src/tc/mod.rs @@ -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), diff --git a/tests/README.md b/tests/README.md index 12e9975..c000d6c 100644 --- a/tests/README.md +++ b/tests/README.md @@ -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>` (exit 0) +- `test_send_result_err.ion` - `send(&tx, Result::Err(4))` infers `T` from `Sender>` (exit 4) +- `test_box_new_option_none.ion` - `Box::new(Option::None)` infers from expected `Box>` (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` via `stdlib/result.ion` (Ok and Err, exit 0) @@ -258,6 +261,7 @@ The test runner prints pass/fail counts when it finishes. Do not rely on hardcod - `test_channel_string.ion` - `channel` 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` (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 diff --git a/tests/test_box_new_option_none.ion b/tests/test_box_new_option_none.ion new file mode 100644 index 0000000..6a8ddb4 --- /dev/null +++ b/tests/test_box_new_option_none.ion @@ -0,0 +1,17 @@ +// Box::new(Option::None) infers T from the expected Box>. +enum Option { + Some(T); + None; +} + +fn main() -> int { + let b: Box> = Box::new(Option::None); + match Box::unwrap(b) { + Option::Some(v) => { + return v; + } + Option::None => { + return 0; + } + } +} diff --git a/tests/test_expectations.tsv b/tests/test_expectations.tsv index 05f172f..6cf3fcf 100644 --- a/tests/test_expectations.tsv +++ b/tests/test_expectations.tsv @@ -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)) diff --git a/tests/test_send_option_none.ion b/tests/test_send_option_none.ion new file mode 100644 index 0000000..86f1cde --- /dev/null +++ b/tests/test_send_option_none.ion @@ -0,0 +1,18 @@ +// send(&tx, Option::None) infers T from Sender>. +enum Option { + Some(T); + None; +} + +fn main() -> int { + let (tx, rx): (Sender>, Receiver>) = channel>(); + send(&tx, Option::None); + match recv(&mut rx) { + Option::Some(v) => { + return v; + } + Option::None => { + return 0; + } + } +} diff --git a/tests/test_send_result_err.ion b/tests/test_send_result_err.ion new file mode 100644 index 0000000..2eba892 --- /dev/null +++ b/tests/test_send_result_err.ion @@ -0,0 +1,15 @@ +// send(&tx, Result::Err(...)) infers T from Sender>. +import "stdlib/result.ion" as result; + +fn main() -> int { + let (tx, rx): (Sender>, Receiver>) = channel>(); + send(&tx, Result::Err(4)); + match recv(&mut rx) { + Result::Ok(v) => { + return v; + } + Result::Err(e) => { + return e; + } + } +}