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 .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 only from `expr_expected` / return type. 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`).
- **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`).
- **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 All @@ -29,6 +29,7 @@ CLI errors use `TypeCheckError` Debug form (`UseAfterMove { ... }`). LSP reforma
- **IR must use `TypeInfo`**: after a successful type-check, every lowered expression id has a canonical type. Missing id is a compiler bug, never a syntactic `int` fallback. One-off patches for this class shipped in 0.1.10 / 0.1.13 / 0.1.16; 0.1.17 makes the checker the source of truth (`let x = y`, `Box::new(StructLit)`, `Box::unwrap`, `Enum::Variant`). Tests: `test_unannotated_let_non_int.ion`, `test_box_new_struct_let*.ion`, `test_box_unwrap_struct_let.ion`, `test_enum_unannotated_let.ion`.
- **Generic match subst**: `substitute_types_in_expr` must substitute `Match.scrutinee_type` (not only the inner expr / `enum_type`). Leaving `Slot<T>` emits `Slot_T` (`examples/handle_table`).
- **`Box::unwrap`**: copy `T` out, then `ion_box_free` the box pointer; do not drop `T` (nested heap in the payload would double-free). Move-mark the argument so scope-exit drop does not free again (`test_box_unwrap_same_scope.ion`). A `run` exit code cannot catch the leak; Linux CI leak-sanitizer step uses `detect_leaks=1` on unwrap tests.
- **`Vec<T>` element drop**: scope-exit drop must iterate remaining elements when `T` needs destruction, then `ion_vec_free`. `Vec::get` of such `T` hollows the slot; `Vec::set` drops the previous element. Copy `T` stays memcpy-only (`test_vec_string_scope_drop.ion`, `test_vec_get_set.ion`). Do not drop nested owned fields through a `get_ref` binding.
- **`String::len`**: null-check the `String*` value, not `&local` (`-Waddress` under CI `-Werror`)
- **`len` method routing**: `"len"` is a Vec, String, and Slice method. IR/cgen must classify the receiver first (`receiver_is_slice` / `receiver_is_string`) before the `vec_methods` table, or `s.len()` on `&[]T` becomes `Vec::len` (same class as the v0.1.1 `String::len` mis-route). `vec.len()` must still become `Vec::len` (`test_slice_len.ion`, `test_method_call_basic.ion`).
- **String literal call args**: parameters typed `String` need `ion_string_from_literal` at the call site, not only on `let s: String = "…"`. Pass compilation-wide `TypeInfo.function_params` into multi-file cgen (`println`, `io::println`, `io_println`) so imported callees see `String` (`test_string_call_arg_literal.ion`, `test_multi_fmt_io.ion`).
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 @@ -35,7 +35,7 @@ APIs that would return `&T` in Rust must use owned values, indices, or the patte

- Stack by default; `Box<T>` for explicit heap
- `defer` for deterministic cleanup at scope exit
- `Vec<T>`, `String` drop at scope end (runtime-assisted)
- `Vec<T>`, `String` drop at scope end (elements of a dropping `T`, then the backing array)

## Unsafe boundaries

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

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

Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ jobs:
test_box_new_struct_let 10
test_box_new_struct_let_annotated 10
test_box_unwrap_same_scope 3
test_box_string_scope_drop 0
test_vec_string_scope_drop 0
EOF

- name: Generated C thread sanitizer (Linux)
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## 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`.
- **Type checker**: unannotated `Option::None` / other no-payload generic variants infer `T` from a call argument's parameter type (any position), matching return and struct-field positions. Unannotated `let empty = Option::None` still requires an annotation. This impacts passing `Option::None` or `Result::Err(...)` directly into a function.
- **Tests**: `test_vec_string_scope_drop.ion`, `test_vec_struct_string_scope_drop.ion`, `test_box_string_scope_drop.ion`, `test_option_none_call_arg.ion`, `test_option_none_call_arg_middle.ion`, `test_result_err_call_arg.ion`. Linux CI leak-sanitizer covers the Vec/Box string scope-drop tests.
- **Docs**: ION_SPEC §4.4 / §5.5 / §8.2, ABI Vec/Box drop, bug hotspots, verified patterns.

## 0.1.18 - 2026-08-13

- **Type checker**: `Box<&T>` / `Vec<&T>` (and arrays/channels of references) are `ReferenceEscape` as locals and parameters, not only as struct fields. This impacts any program that boxed or vectored a reference (`let b: Box<&int> = Box::new(&x)` previously compiled). Stack-local `Option<&T>` from `Vec::get_ref` is unchanged.
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.18"
version = "0.1.19"
edition = "2024"

[[bin]]
Expand Down
10 changes: 5 additions & 5 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, 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, 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 @@ -896,8 +896,8 @@ fn process() {

When a binding goes out of scope (block exit, function return), owned values with heap resources are dropped automatically:

- `Box<T>`: `ion_box_free`
- `Vec<T>`: `ion_vec_free`
- `Box<T>`: drop `T` (when it needs destruction), then `ion_box_free`
- `Vec<T>`: drop remaining elements (when `T` needs destruction), then `ion_vec_free`
- `String`: `ion_string_free`
- `Sender<T>` / `Receiver<T>`: `ion_channel_handle_drop` (refcounted; freed when both ends are dropped)
- Struct fields with owned heap types (`Box`, `Vec`, `String`, channels, or nested structs/enums containing them) are dropped in declaration order when the struct goes out of scope.
Expand Down Expand Up @@ -1078,9 +1078,9 @@ Note that:

- `Vec<T>` is `Send` if `T: Send`.
- `Vec::new()` and `Vec::with_capacity()` infer `T` from a `let` type annotation when present (e.g. `let v: Vec<i32> = Vec::new()`).
- `Vec::get()` and `Vec::pop()` return `Option<T>` to handle out-of-bounds or empty cases. Both **move** the element out of the vector. To preserve vector length after a read-only scan, either use `Vec::get_ref()` (below) or copy fields and `Vec::set()` a rebuilt struct literal to put the value back (nested `Vec` fields still move on put-back).
- `Vec::get()` and `Vec::pop()` return `Option<T>` to handle out-of-bounds or empty cases. Both **move** the element out of the vector. For a `T` that needs destruction, `Vec::get` hollows the slot (zero-fills it) so later vector drop does not free the moved value again. Copy `T` is left in place (move and copy are indistinguishable). To preserve vector length after a read-only scan, either use `Vec::get_ref()` (below) or copy fields and `Vec::set()` a rebuilt struct literal to put the value back (nested `Vec` fields still move on put-back).
- `Vec::get_ref()` returns `Option<&T>`: a **local, stack-only borrow** of an in-place element. It does not move or hollow the slot. The result is only valid as a short-lived binding within the current function (for example in a `match` arm). Match arms bind the element as `&T`; for enum elements, an inner `match` on that binding dispatches variants directly (no unary `*` deref). Copy fields in struct or enum variant patterns bind as `T`; non-copy fields bind as `&T`. Codegen uses `T*` for types with owned fields and copies by value for copy types, so repeated scans over `Vec<struct-with-nested-Vec>` do not double-free nested fields. It cannot be returned, stored in structs or enums, sent on channels, or cross `spawn`. While an `&T` from `get_ref` is active, the root owner of the vector (the binding behind `&Vec<T>`) is shared-borrowed: `&mut Vec<T>` on that owner, `Vec::set`, `Vec::push`, and `Vec::pop` on the same vector are rejected until the borrow ends. Out-of-range or negative indices yield `Option::None`. Nested inspection (`order.lines` then `get_ref`) follows the same root-owner borrow rules as field paths (Section 5.3). Field paths through `&Struct` that are already references (for example `order.lines` when `order: &Order`) are passed to `Vec` methods without an extra `&`.
- `Vec::set()` requires a mutable reference and returns an error code (0 for success, non-zero for failure). After shared borrows from `get_ref` end, `Vec::set` on the same index is allowed.
- `Vec::set()` requires a mutable reference and returns an error code (0 for success, non-zero for failure). When `T` needs destruction, the previous element at that index is dropped before the new value is written. After shared borrows from `get_ref` end, `Vec::set` on the same index is allowed.

For cross-function or long-lived access, Ion still favors an **index/handle style**: helpers return indices or keys and callers re-index within their own function bodies. When slots in a growable table can be reused, prefer `Handle` / `Arena<T>` in Section 8.6 over a raw `int` index.

Expand Down
6 changes: 4 additions & 2 deletions docs/ABI.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ Stable beta expectations:

- `Vec::new`, `Vec::push`, `Vec::pop`, `Vec::get`, `Vec::get_ref`, `Vec::set`, `Vec::len`, and
`Vec::capacity` remain available through the stdlib/builtin surface.
- Dropping `Vec<T>` drops owned elements when `T` needs destruction.
- Dropping `Vec<T>` drops owned elements when `T` needs destruction (compiler drop glue over `0..len`, then `ion_vec_free`). The runtime helper does not take a destructor callback.
- `Vec::get` of a dropping `T` copies the element into `Option<T>` and hollows the slot. `Vec::set` of a dropping `T` drops the previous element before overwrite.
- Bounds-sensitive operations either return `Option<T>` where documented or
trigger the runtime panic path for checked indexing.
- `Vec::get` / `Vec::pop` return heap `Option` blobs from the runtime; generated
Expand Down Expand Up @@ -79,7 +80,8 @@ Stable beta expectations:
- `Box::new` allocates and owns a value.
- `Box::unwrap` consumes the box, copies the payload out by value, and
`ion_box_free`s the allocation. It does not drop `T`; the caller owns the copy.
- Dropping a `Box<T>` drops the payload and releases the allocation once.
- Dropping a `Box<T>` drops the payload when `T` needs destruction, then
releases the allocation once.

## Arrays and slices

Expand Down
111 changes: 103 additions & 8 deletions src/cgen/builtins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -236,13 +236,58 @@ impl Codegen {

let elem_c_type = self.resolve_vec_elem_c_type(&args[0], return_type);
let deref_vec = self.vec_ion_ptr_expr(&args[0], &vec_code);
code.push_str("ion_vec_get((ion_vec_t*)(");
code.push_str(&deref_vec);
code.push_str("), ");
code.push_str(&index_code);
code.push_str(", sizeof(");
code.push_str(&elem_c_type);
code.push_str("))");
let elem_ty = self.vec_elem_type_from_arg(&args[0]);
let hollow = elem_ty.as_ref().is_some_and(|t| self.type_needs_drop(t));
if hollow {
let n = self.temp_var_counter;
self.temp_var_counter += 1;
let gv = format!("_ion_gv{n}");
let gi = format!("_ion_gi{n}");
let gr = format!("_ion_gr{n}");
code.push_str("({ ion_vec_t* ");
code.push_str(&gv);
code.push_str(" = (ion_vec_t*)(");
code.push_str(&deref_vec);
code.push_str("); int ");
code.push_str(&gi);
code.push_str(" = ");
code.push_str(&index_code);
code.push_str("; void* ");
code.push_str(&gr);
code.push_str(" = ion_vec_get(");
code.push_str(&gv);
code.push_str(", ");
code.push_str(&gi);
code.push_str(", sizeof(");
code.push_str(&elem_c_type);
code.push_str(")); if (");
code.push_str(&gr);
code.push_str(" && *(int*)");
code.push_str(&gr);
code.push_str(" == 0 && ");
code.push_str(&gv);
code.push_str(" && ");
code.push_str(&gv);
code.push_str("->data) { memset((char*)");
code.push_str(&gv);
code.push_str("->data + (size_t)");
code.push_str(&gi);
code.push_str(" * sizeof(");
code.push_str(&elem_c_type);
code.push_str("), 0, sizeof(");
code.push_str(&elem_c_type);
code.push_str(")); } ");
code.push_str(&gr);
code.push_str("; })");
} else {
code.push_str("ion_vec_get((ion_vec_t*)(");
code.push_str(&deref_vec);
code.push_str("), ");
code.push_str(&index_code);
code.push_str(", sizeof(");
code.push_str(&elem_c_type);
code.push_str("))");
}
return Some(code);
}

Expand Down Expand Up @@ -348,6 +393,7 @@ impl Codegen {
value_code = std::mem::replace(&mut self.output, old_output);

let elem_c_type = self.resolve_vec_elem_c_type(&args[0], return_type);
let drop_old = elem_ty.as_ref().is_some_and(|t| self.type_needs_drop(t));

let value_is_lvalue = matches!(
args[2],
Expand All @@ -356,7 +402,56 @@ impl Codegen {
| IREexpr::Var(_)
| IREexpr::FieldAccess { .. }
);
if value_is_lvalue {
if drop_old {
let n = self.temp_var_counter;
self.temp_var_counter += 1;
let sv = format!("_ion_sv{n}");
let si = format!("_ion_si{n}");
let slot = format!("(({elem_c_type}*)(({sv})->data))[{si}]");
let drop_old_stmt = elem_ty
.as_ref()
.map(|t| self.capture_drop_at_path(&slot, t))
.unwrap_or_default();
code.push_str("({ ion_vec_t* ");
code.push_str(&sv);
code.push_str(" = (ion_vec_t*)(");
code.push_str(&deref_vec);
code.push_str("); int ");
code.push_str(&si);
code.push_str(" = ");
code.push_str(&index_code);
code.push_str("; ");
let value_ptr = if value_is_lvalue {
format!("&{value_code}")
} else if matches!(args[2], IREexpr::Call { .. }) {
code.push_str(&elem_c_type);
code.push_str(" _ion_set_val = ");
code.push_str(&value_code);
code.push_str("; ");
"&_ion_set_val".to_string()
} else {
format!("&(({elem_c_type}){{{value_code}}})")
};
code.push_str("if (");
code.push_str(&sv);
code.push_str(" && ");
code.push_str(&si);
code.push_str(" >= 0 && (size_t)");
code.push_str(&si);
code.push_str(" < ");
code.push_str(&sv);
code.push_str("->len) { ");
code.push_str(&drop_old_stmt);
code.push_str(" } ion_vec_set(");
code.push_str(&sv);
code.push_str(", ");
code.push_str(&si);
code.push_str(", ");
code.push_str(&value_ptr);
code.push_str(", sizeof(");
code.push_str(&elem_c_type);
code.push_str(")); })");
} else if value_is_lvalue {
code.push_str("ion_vec_set((ion_vec_t*)(");
code.push_str(&deref_vec);
code.push_str("), ");
Expand Down
Loading