Adds named stand-ins for the three opaque Obj-C runtime types
and Apple's signed-char boolean to library/modules/std/objc.sx:
id :: *void; // any Obj-C instance pointer
Class :: *void; // a class object pointer
SEL :: *void; // a registered selector
BOOL :: s8; // Apple's signed-char boolean (NOT sx's bool)
All resolve to their underlying type at the LLVM layer — no
runtime cost — but make foreign-class declarations read closer
to Objective-C source. The header's old caveat about lacking
type aliases is gone.
141-objc-type-aliases.sx exercises the aliases against the real
macOS Obj-C runtime: alloc/init an NSObject, fetch its class
via objc_getClass, sel_registerName a SEL, then call
'isKindOfClass:' returning BOOL=1. Non-macOS paths print the
same line to keep the snapshot stable.
DEFERRED (M1.1.b, follow-up): 'Class(T)' parameterization with
#extends-aware covariance, and 'instancetype' per-decl
substitution. Both require compiler-level type-check support
beyond plain stdlib aliases.
170 examples pass (+1).
Per CLAUDE.md IMPASSIBLE RULES. Attempted Phase 3.2 C4 migration of
the UIKit chrome cluster in `library/modules/platform/uikit.sx`
(UIScreen / UIWindow / UIViewController / UITextField / UIView)
surfaced a real compiler bug: when a function body contains
`recv.method(...)` calls against an `#objc_class` receiver AND that
body is reached via `lazyLowerFunction` invoked from another
`inline if OS == ...` branch, the method dispatch fails with
"unresolved 'methodName'".
Specifically: `uikit_scene_will_connect_ios` (the iOS-sim crashing
case) contains `UIWindow.alloc().initWithWindowScene(scene)` etc.
The same calls compile cleanly in isolated probes — only the lazy-
lower-via-inline-if entry chain reproduces the bug. macOS target
builds fine throughout; ios-sim trips it.
C1/C2/C3 (commits 1ea9cda / 17775b2 / 2a7c8e0) happen to land cleanly
because the methods they migrate are reached eagerly (or are niladic
so the dispatch path doesn't hit the failing branch). C4 + C5 stay
blocked pending issue-0043's fix in a separate session.
Issue filed at `issues/0043-lazy-lower-loses-foreign-class-method-dispatch.md`
with the reproduction, stack trace, and investigation prompt
pointing at `lower.zig:1057` (`lazyLowerFunction`) and
`lower.zig:5290` (the field-access foreign-class dispatch chain).
FFI checkpoint updated to mark C4+C5 as BLOCKED on 0043.
The in-progress C4 working-tree changes were reverted; tree is at
the C3 commit `2a7c8e0` and chess on macOS/iOS-sim/Android builds
cleanly.
Third cluster: NSRunLoop and CADisplayLink move to declarative
`#objc_class` blocks.
Classes declared:
- NSRunLoop → currentRunLoop (class)
- CADisplayLink → displayLinkWithTarget_selector (class),
addToRunLoop_forMode (instance),
targetTimestamp (instance), duration (instance)
The display-link instance is created with the new typed call shape:
link := CADisplayLink.displayLinkWithTarget_selector(plat.gl_view, sel_tick);
plat.display_link = xx link; // keep the *void slot in the
// platform struct for ABI parity
runloop := NSRunLoop.currentRunLoop();
link.addToRunLoop_forMode(runloop, mode_ns);
The `sxTick:` callback's `link: *void` param is cast to
`*CADisplayLink` at function entry so the body's `link.duration()` /
`link.targetTimestamp()` calls type-check.
Two now-redundant `objc_getClass(...)` lookups for CADisplayLink /
NSRunLoop are gone — the class slots come from the declarative
declarations via `__sx_objc_class_init`.
166/166 tests; chess builds clean on macOS / iOS / Android.
Second cluster: NSNotification, NSBundle, NSNotificationCenter move
from `#objc_call(T)(recv, "sel:", args)` to declarative
`#foreign #objc_class("Cls") { ... }` blocks.
Classes declared (alongside the C1 Foundation utility group):
- NSNotification → userInfo (instance, returns *NSDictionary)
- NSBundle → mainBundle (class), resourcePath (instance)
- NSNotificationCenter → defaultCenter (class),
addObserver_selector_name_object (instance)
The 4-keyword `addObserver:selector:name:object:` selector derives
from the underscore-separated sx name via the default mangling rule
— no `#selector` override needed.
Cleanup wins:
- `objc_getClass("NSBundle")` and `objc_getClass("NSNotificationCenter")`
call sites gone — class slots now populated by emit_llvm's
`__sx_objc_class_init` constructor.
- `userInfo`'s return type is `*NSDictionary` directly, so the
previous `*void → *NSDictionary` cast at the keyboard-frame
callsite collapses.
166/166 tests; chess builds clean on macOS + iOS + Android via
`sx build main.sx`.
First of five Phase-3.2 migration clusters. Foundation utility
classes (NSValue, NSNumber, NSDictionary, NSMutableDictionary, NSSet)
in `library/modules/platform/uikit.sx` move from the explicit
`#objc_call(T)(recv, "selector:", args)` form to declarative
`#foreign #objc_class("Cls") { ... }` blocks with `recv.method(args)`
dispatch.
Classes declared (all near the top of uikit.sx, after the CGRect
struct):
- NSValue → CGRectValue (instance)
- NSNumber → numberWithBool (class), doubleValue +
unsignedLongValue (instance)
- NSDictionary → objectForKey (instance)
- NSMutableDictionary → dictionary (class), setObject_forKey (instance)
- NSSet → anyObject (instance)
Each call site casts the `*void` receiver to the typed foreign-class
pointer before dispatch — the existing `*void` shape is preserved
in the struct fields and outer parameter types; only the dispatch-
local copy is typed. This keeps the diff scoped to call-site
rewrites without rippling type changes through every consumer.
The four `objc_getClass(...)` calls that previously resolved
NSMutableDictionary / NSNumber at runtime are gone — Phase 3.1's
`__sx_objc_class_init` constructor populates the cached class slot
for each declared `#objc_class` at module load via
`OBJC_CLASSLIST_REFERENCES_<Cls>`.
166/166 example tests; chess clean on macOS + Android via
`tools/verify-step.sh` (iOS sim skipped — no booted simulator in
this run; previous full run was green at HEAD~6).
`examples/ffi-objc-dsl-07-mangling-table.sx` exercises every common
mangling shape in one fixture and pins the resolved selectors via
both `.txt` and `.ir` snapshots:
| sx method | derived selector |
|-----------------------------------|----------------------------|
| `length` | `length` |
| `addObject(o)` | `addObject:` |
| `combine_and(a, b)` | `combine:and:` |
| `insert_after_index(a, b, c)` | `insert:after:index:` |
| `add_observer_for_event(a, b, c, d)` | `add:observer:for:event:` |
| `initWithFrame_options(f, o)` | `initWithFrame:options:` |
| `custom_name #selector("actualSelectorName")` | `actualSelectorName` |
The class is synthesized at runtime via `objc_allocateClassPair` +
`class_addMethod` per selector (mirrors the pattern in
`ffi-objc-dsl-{01..05}.sx`), so the test actually dispatches through
the real Obj-C runtime on macOS.
Single commit because the implementation already shipped in 3.0/3.2;
this is a new regression that locks in current behavior, not a
test-then-make-green pair.
The `.ir` snapshot opts in via the existing run_examples.sh mechanism
(presence of a `.ir` file for the same name triggers capture). The
captured `OBJC_METH_VAR_NAME_*` constants surface every selector
string change at a glance.
166/166 tests.
Make-green half of the cadence step started in A1. Wires the
`#selector` directive end-to-end:
- Lexer token `hash_selector` at src/token.zig + lookup row in
src/lexer.zig.
- AST field `selector_override: ?[]const u8 = null` on
`ForeignMethodDecl` (src/ast.zig).
- Parser block in src/parser.zig that mirrors
`#jni_method_descriptor` — both occupy the same slot after the
optional `-> ReturnType` and before the body/terminator. Not
mutually exclusive at parse time.
- LSP semantic-token list (src/lsp/server.zig) updated.
- Lowering: `deriveObjcSelector` returns
`{ sel, keyword_count, is_override }`. When `is_override` is true,
the selector string is the user's literal and `keyword_count` is
the colon count in that literal. Both `lowerObjcMethodCall` and
`lowerObjcStaticCall` use the result.
Diagnostic policy when override colon-count ≠ call arity:
- Default mangling path: stays an error (`.err`). The user can fix
the sx-side name to produce the right keyword count.
- Override path: downgrades to a warning (`.warn`). Rationale:
Obj-C's `objc_msgSend` doesn't validate colon-vs-arg the way JNI's
`GetMethodID` validates the descriptor — the runtime dispatches
regardless and the wrong-arity case becomes silent calling-
convention corruption. The compiler is the last line of defense
for this typo class, but the warning preserves the override's
escape-hatch character (deliberate mismatches still proceed).
Snapshot for `examples/ffi-objc-dsl-06-selector-override.sx` flips
from the pre-3.2 parser-error to working output:
static override non-null: true
The mismatch diagnostic text in
`examples/ffi-objc-dsl-04-mismatch.sx`'s snapshot is updated to
drop the "once that lands (3.2)" phrasing now that 3.2 is here.
165/165 example tests.
Phase 3.2 xfail half. `#selector("explicit:string")` is the escape
hatch for cases where the sx-side method name doesn't conveniently
produce the target selector under the default mangling rule
(Phase 3.0 — split on `_`, each piece becomes a keyword with a
trailing `:`).
Surface form mirrors `#jni_method_descriptor("(Sig)Ret")` — sits
after the optional `-> ReturnType` and before the method body /
terminator.
Test fixture covers both lowering paths:
- Static method override: `NSObject.gimme()` with override
"description" — exercises lowerObjcStaticCall (Phase 3.1).
- Instance method override: `NSDictionary.lookup(self, key)` with
override "objectForKey:" — declared (parse + AST + lowering
wiring) but not invoked at runtime (no real NSDictionary in
scope). The declaration alone locks in the multi-arg-override path.
Pre-3.2: parser doesn't know `#selector`; snapshot captures
"expected ';'" at the override site, exit=1. Next commit (A2) wires
the lexer token, AST field, parser block, and lowering integration;
snapshot flips to working output.
165/165 example tests. Plan at
`~/.claude/plans/lets-see-options-for-merry-dijkstra.md`.
`static name :: ...` was redundant — instance methods always declare
`self: *Self` as their first param by convention. The parser now derives
`is_static` from the first param's TYPE: if it's `*Self` the method is
an instance method; anything else (including no params at all) is a
class method. Removes a token from the surface, keeps the dispatch
behavior identical.
The receiver param's NAME doesn't matter — only its type. Calling the
first param `this`, `me`, `receiver`, etc. is fine as long as the type
is `*Self`. This mirrors how the rest of sx handles receiver dispatch.
Migration of every site that used the keyword:
- `library/modules/platform/android.sx` — `SurfaceView.new(ctx)`.
- `examples/ffi-jni-class-03-static.sx` — `Math.abs(n)`.
- `examples/ffi-jni-main-03-ctor.sx` — `SurfaceView.new(ctx)` in the
`#jni_main` body.
- `examples/ffi-objc-dsl-05-static.sx` — NSObject's `.class()` /
`.description()`.
164/164 example tests; chess clean on macOS / iOS sim / Android via
`tools/verify-step.sh`.
Implementation half of the Phase 3.1 cadence step.
`lowerForeignStaticCall` for `#objc_class` / `#objc_protocol` runtimes
no longer bails; it routes through a new `lowerObjcStaticCall` helper
that loads the class object from a module-scoped cached slot (populated
once per module via `objc_getClass`) and dispatches `objc_msg_send`
with the same selector-mangling as Phase 3.0's instance dispatch.
Three pieces:
1. `Module.objc_class_cache` — parallel to `objc_selector_cache`,
insertion-ordered list of (class_name, slot_GlobalId) so the
constructor that calls `objc_getClass` per slot at module load
is deterministic. `lookupObjcClass` / `appendObjcClass` accessors.
2. `internObjcClassObject` in lower.zig — get-or-create a
`OBJC_CLASSLIST_REFERENCES_<Cls>` global pointer; matches clang's
naming convention. `lowerObjcStaticCall` reuses
`deriveObjcSelector` from 3.0 for the selector, loads the class
slot, and emits `objc_msg_send(class_obj, sel, args)`.
3. `emitObjcClassInit` in emit_llvm.zig — companion to
`emitObjcSelectorInit`. Walks `objc_class_cache`, synthesizes a
constructor `__sx_objc_class_init` that calls `objc_getClass(name)`
per slot, registers in `@llvm.global_ctors` for AOT (extending the
existing array if the selector init already created it), and
injects a direct call into main's prelude after any prior init
calls so the ORC JIT path runs it too.
Surface form is `.` (`NSObject.class()`) matching JNI's `Alias.new(...)`
convention rather than the plan's notional `::` — avoids extending the
parser for a new postfix operator with no other use case.
Test `examples/ffi-objc-dsl-05-static.sx` exercises NSObject's
`+class` and `+description` class methods via the new syntax, asserts
both return non-null. NSObject is always available at module-load,
unlike runtime-created test classes that wouldn't exist yet when
the class-init constructor runs.
164/164 tests; chess builds + runs clean on all three platforms.
Implementation half of the cadence step started in the previous commit.
`lowerForeignMethodCall` for `#objc_class` / `#objc_protocol` runtimes
no longer bails; it routes through a new `lowerObjcMethodCall` helper
that derives the Obj-C selector from the sx method name and lowers to
`objc_msg_send` against the cached SEL slot (same intern path as
explicit `#objc_call`).
Default selector mangling (matches clang's keyword-method convention):
- Niladic (arity 0 excluding self): name verbatim. `length()` → "length".
- Arity ≥ 1: split the sx method name on `_`; each piece becomes a
keyword with a trailing `:`. `addObject(o)` → "addObject:";
`combine_and(a, b)` → "combine:and:";
`initWithFrame_options(f, o)` → "initWithFrame:options:".
Arity validation: keyword count (pieces from the `_`-split) must equal
call-site arity excluding self. Mismatch diagnoses at the call site
with a hint pointing at the forthcoming `#selector("...")` override
(Phase 3.2) for selectors that don't fit the underscore-split rule.
Mangling helper `deriveObjcSelector` and dispatch helper
`lowerObjcMethodCall` sit alongside `lowerForeignMethodCall`. The
existing fall-through diagnostic for non-JNI/non-Obj-C runtimes
remains for Swift (Phase 4 territory).
Tests `examples/ffi-objc-dsl-{01-niladic,02-one-arg,03-multi-keyword,
04-mismatch}.sx` snapshots flip from the pre-3.0 bail diagnostic
(exit=1) to working output (exit=0 for cases 01-03) and the specific
keyword-count mismatch diagnostic for case 04. Each test follows the
established pattern from `ffi-objc-call-08-multi-keyword.sx`:
synthesize a class at runtime via `objc_allocateClassPair` /
`class_addMethod`, declare a matching `#objc_class`, invoke the DSL
form. 163/163 tests; chess unaffected (JNI dispatch path untouched).
The previous FFI checkpoint claimed Phase 3 step 3.0 ("`inst.method(args)`
on #objc_class receivers") had landed. It hadn't — `lowerForeignMethodCall`
in lower.zig:4353 still bails for any non-JNI runtime with the generic
"method calls on '{runtime}' runtime not yet supported (Phase 3/4)"
diagnostic, no commit introduced an Obj-C DSL dispatch path, and the
planned regression files weren't on disk.
This commit is the xfail half of the proper cadence (test-add then
make-green in separate commits):
- examples/ffi-objc-dsl-01-niladic.sx — `length()` → selector "length".
- examples/ffi-objc-dsl-02-one-arg.sx — `addObject(o)` → "addObject:".
- examples/ffi-objc-dsl-03-multi-keyword.sx — `combine_and(a, b)` →
"combine:and:" (sx name split on `_`, each piece becomes a keyword
with a trailing `:`).
- examples/ffi-objc-dsl-04-mismatch.sx — `something_extra(x)` —
keyword count (2) ≠ arity (1); must diagnose at the call site.
Each test follows the same pattern as `ffi-objc-call-08-multi-keyword.sx`:
synthesize a class at runtime via `objc_allocateClassPair` /
`class_addMethod`, declare the sx-side `#objc_class` against the same
name, then invoke the DSL form. Skips with a "(not macos)" line on
non-macOS hosts. Snapshots currently lock in the bail diagnostic with
exit=1; the next commit implements the dispatch and the snapshots
flip to the working output (and exit=0).
Checkpoint corrected to flag the prior false claim and reposition 3.0
back at the top of the open list.
CLAUDE.md REJECTED PATTERNS forbids silent default returns where the
"reasonable-looking" value happens to match one common case (s64 = 8
bytes = pointer-sized on the host) and is silently wrong everywhere
else. `resolveType(null) → .s64` was exactly this shape: a top-level
`g_pi := 3.14;` was silently typed as `s64`, producing a wrong-typed
slot and the wrong runtime value.
`resolveType` now takes a non-optional `*const Node`. Twelve callers
were classified:
- Six were already guarded by `if (x.type_annotation != null)` blocks
— the null branch was unreachable. Cleaned up to optional-payload
syntax (`if (cd.type_annotation) |ta|`) so the always-non-null path
is obvious from the type.
- Two (`#objc_call` / `#jni_call` return types) pass `FfiIntrinsicCall.
return_type`, which is `*Node` (not optional) in the AST — the
silent fallback couldn't be reached there either.
- One (top-level `var_decl` at lower.zig:630) DID legitimately receive
null when the user omitted both annotation and initializer typing.
Now mirrors `lowerVarDecl`'s local-scope behavior: explicit
annotation → resolveType; no annotation → `inferExprType` from the
initializer; neither → diagnose with a real error message.
- One (`lowerComptimeGlobal`, fixed in commit 82e7b04 alongside
Phase 1.4) already infers from the comptime expression.
- Two (JNI super-call / JNI method return type) were already
hand-rolled with `if (rt) |t| resolveType(t) else .void`.
Regression at `examples/137-toplevel-var-type-inference.sx`: `g_count
:= 42;` / `g_pi := 3.14;` / `g_flag := true;` at module scope. Pre-fix
`g_pi` got silently typed as `s64` and printed `0` or garbage; now it
prints `3.140000`. 159/159 example tests + chess clean.
The Phase 1.4 serializer left a silent malformed-const case: when the
interp evaluated a `#run` returning a string (or anything with a fat
pointer inside), the data field came in as a `.int` holding a libc
host address. `LLVMConstInt(ptr_type, addr, 1)` happily emitted `i0 0`
in the static const, and the runtime segfaulted on the first read.
Phase 1.4a closes this for string and slice destinations. The signature
of `valueToLLVMConst` now takes the IR `TypeId` (instead of just the
LLVM type) and a borrowed `*Interpreter`. A new helper
`serializeAggregateValue` splits on the IR type:
- `string` / `slice` (fat pointer `{data, len}`): extract `len`, read
that many bytes from the data field's address (via `interp.heapSlice`
for `heap_ptr`, via a new `readHostBytes` for `byte_ptr` / `.int`,
via slice indexing for string literals). Emit the bytes as a private
global byte array using the existing `emitConstStringGlobal`. The
fat-pointer aggregate's data ptr resolves to the byte array's address.
- `struct`: walk the IR field types in lockstep with the value's
fields; recurse with each declared field TypeId. This replaces the
old LLVM-type-walk via `LLVMStructGetTypeAtIndex` which couldn't tell
string-typed fields from generic ptr fields.
- `array`: walk with the element TypeId.
The remaining `.int → ptr` trap (a host address landing in a bare ptr
field outside a fat pointer) now bails loudly with a named diagnostic
identifying it as Phase 1.4a heap-walk follow-up territory. No
practical trigger in-tree, so deferred.
`Interpreter.heapSlice` promoted from package-private to `pub` so
the serializer can read interp-managed heap data.
Regression: `examples/136-comptime-string-global.sx` —
`GREETING :: #run build_greeting();` where `build_greeting` returns
`concat("hello", " world")`. Runtime prints `greeting = 'hello world'`
and `greeting.len = 11`. Pre-1.4a this segfaulted on the first read.
158/158 example tests; chess clean on macOS / iOS sim / Android via
`tools/verify-step.sh`.
Building on the Option 3 lvalue-borrow rule, the long-lived allocators
in `library/modules/allocators.sx` (GPA, Arena, TrackingAllocator) now
return their state by value instead of via a heap-allocated `*T`. The
caller binds the result to a local; the local IS the allocator state.
`xx local` borrows that storage under Option 3, so the `Allocator`
protocol value's `ctx` points at the local — no heap allocation for
the state struct, no `free` of the state needed.
```sx
gpa := GPA.init(); // GPA (value)
arena := Arena.init(xx gpa, 4096); // Arena (value)
tracker := TrackingAllocator.init(xx gpa); // TrackingAllocator (value)
push Context.{ allocator = xx tracker, data = null } { ... }
```
Why by-value:
- One fewer `libc_malloc` per allocator instance.
- No state-struct leak. The local is reclaimed at scope exit; `deinit`
only handles downstream resources (chunks, etc.) — not its own struct.
- Owning structs can embed allocators as value fields directly.
Callsite changes:
- `library/modules/ui/pipeline.sx`: `arena_a: Arena;` / `arena_b:
Arena;` (was `*Arena;`). The `build_arena: *Arena` local takes
`@self.arena_a` / `@self.arena_b`.
- `examples/126-xx-recover-then-dispatch.sx`: `recovered == @gpa`
instead of `recovered == gpa` (gpa is a value now).
- `examples/135-xx-lvalue-borrows.sx`: drop the `tracker_ptr.*`
deref — `init` already returns the value.
- `examples/50-smoke.sx`: Arena alloc counts dropped by 1 (no
state-struct allocation). Comments + snapshot updated.
`Arena.deinit` drops the trailing `parent.dealloc(xx a)` — the
caller's local owns the storage.
FFI IR snapshots regenerated to reflect the new signatures:
`@GPA.init` returns `i64` (was `ptr`); `@Arena.init` and
`@TrackingAllocator.init` use sret returns (was `ptr`).
CLAUDE.md "Allocator construction" rule rewritten around the
by-value convention. The forbidden caller-provides-storage and
redundant-pointer-rename patterns are still forbidden but for the
right reasons now (verbose, fragile) rather than as a workaround
for the old `init() -> *T` shape.
157/157 example tests pass; chess clean on macOS, iOS sim, and
Android via `tools/verify-step.sh`.
`xx <struct-typed local>` used to heap-copy the value through context.allocator.
The protocol value's `ctx` pointed at the heap copy; the original local was
left behind, untouched. Mutations through the protocol never reached the
original, and direct reads of the original never saw protocol mutations.
Two-fork bug, silent, easy to write by mistake.
New rule (Option 3 in the discussion):
- `xx <lvalue>` — identifier, field access, index expression, deref —
borrows the operand's storage. No heap copy, no `free` needed.
- `xx <rvalue>` — struct literal, function-call result, arithmetic, etc. —
heap-copies through context.allocator. Unchanged from today.
- `xx @ptr` and `xx <pointer-typed value>` — borrows the pointee. Unchanged.
Single switch in `buildProtocolErasure` ([lower.zig:10334](src/ir/lower.zig#L10334))
gated by a new `isLvalueExpr` helper ([lower.zig:10322](src/ir/lower.zig#L10322)).
Struct-typed operand: if the AST shape is identifier/field/index/deref,
emit `lowerExprAsPtr(operand_node)` and skip the heap-copy; otherwise
keep the alloca-store-heap_copy path.
specs.md §3 ownership table extended to three rows (rvalue, lvalue,
pointer) with examples and rationale per row.
Regressions:
- `examples/130-xx-value-routes-through-context-allocator.sx` — the
Phase 1.1 witness for heap-copy-via-context-allocator. Previous shape
(`xx <local-value>`) is now a borrow under Option 3 and no longer
exercises the heap-copy path. Rewritten to use a struct literal
(`xx ByValue.{...}`) which still heap-copies through context.allocator
— Tracer.count = 1 as before.
- `examples/135-xx-lvalue-borrows.sx` — new test. Dereferences a
TrackingAllocator into a stack value, does `xx tracker` inside a
push Context, and asserts alloc_count/dealloc_count on the LOCAL go
up. Under old semantics this would have stayed at 0 (heap copy got
the increments, local stayed stale).
157/157 example tests pass; chess clean on macOS / iOS sim / Android
(`tools/verify-step.sh` ran green immediately before this work).
`valueToLLVMConst` in emit_llvm previously handled int / float / boolean
and collapsed everything else into `LLVMConstNull(ty)`. A `#run` returning
a struct, string, function pointer, or anything aggregate produced a
zero-initialized global silently — the comptime result was computed by
the interp, then thrown away when emit_llvm couldn't represent it.
Replaced with a real walk:
- int / float / boolean — as before.
- null_val — `LLVMConstNull`.
- void_val / undef — `LLVMGetUndef`.
- func_ref — `func_map` lookup (already populated for the implicit-Context
static initializer of `__sx_default_context`).
- string — `emitConstStringGlobal`, returns a pointer to the byte array.
- aggregate — recurse field-by-field. Struct: walk
`LLVMStructGetTypeAtIndex` and emit `LLVMConstNamedStruct`. Array:
walk `LLVMGetElementType` and emit `LLVMConstArray2`.
The remaining variants (heap_ptr, byte_ptr, slot_ptr, closure, type_tag)
bail loudly with a `std.debug.print` carrying the global name — per
CLAUDE.md REJECTED PATTERNS, no more silent unimplemented arms. heap_ptr
serialization requires threading the IR `TypeId` so the heap content can
be walked recursively; deferred to Phase 1.4a alongside cycle detection.
The call site at emit_llvm.zig:676 now passes `global.name` so the
diagnostic locates the offending `#run` binding.
Type-inference fix at the binding site: `NAME :: #run expr;` with no
annotation used to default to `s64` via `resolveType(null) -> .s64`,
so even a successful Phase 1.4 serialization would emit `{0, 0}` —
the global's destination type was wrong. `lowerComptimeGlobal` now
calls `inferExprType(expr)` when no annotation is given, so the
inferred type matches the comptime function's return type. The
broader `resolveType(null)` fallback is left in place for other
callers — flagged in the MEM checkpoint as a follow-up audit.
Regression: `examples/134-comptime-aggregate-global.sx` exercises
`POINT :: #run make_point()` returning a `Point { x: s32, y: s32 }`.
Both interp (`sx run`) and codegen (`sx build`) now print
`POINT.x = 7 / POINT.y = 13` instead of `0 / 0`. 156/156 example
tests pass; chess unchanged.
The chess panel-text regression (text vanished after the first move on
macOS) had a single root cause: GlyphCache's entries List, hash table,
and shaped_buf grew through `context.allocator` — which during render
is the per-frame arena. On the next arena reset the backing died, and
subsequent glyph lookups read garbage / wrote into freshly-allocated
view-tree memory.
Fix is shaped as the user proposed: `List(T)`'s mutations take an
optional trailing `alloc: Allocator = context.allocator` argument. No
allocator stored on the container, no init ceremony, every existing
`list.append(item)` callsite keeps working unchanged. Long-lived
owners now write `list.append(item, self.parent_allocator)` and the
arena-leak bug becomes impossible to write accidentally.
Default-arg substitution previously only fired for identifier callees
(`expandCallDefaults` at lower.zig:7978). Extended to the generic
struct-method dispatch path (`list.append(...)` lands here) via a new
`appendDefaultArgs` helper that lowers fd.params[i].default_expr in
the caller's scope and appends to the lowered args slice.
Long-lived owners updated to capture `parent_allocator: Allocator` at
init and use it for every internal growth:
- GlyphCache (the chess bug) — entries, shaped_buf, hash_keys,
hash_vals, atlas bitmap.
- DockInteraction — drops the existing `push Context` workaround in
`ensure_capacity` for the explicit-arg form.
- StateStore — entries list + per-entry data buffer.
- Gles3Gpu, MetalGPU — shaders, buffers, textures (atlas-grow during
render would otherwise leak resources into the frame arena).
Also kept: an operator-precedence fix in pipeline.sx
(`(self.frame_index & 1) == 0` instead of
`self.frame_index & 1 == 0`, which parses as
`self.frame_index & (1 == 0)` = always 0). That was a stealth
single-arena-only bug that masked the GlyphCache one for a long time.
Docs:
- specs.md §11 documents `param: T = expr` default parameter values.
The parser already supported it — formalised in the spec now.
- current/CHECKPOINT-MEM.md logs the change.
- CLAUDE.md REJECTED PATTERNS gains a "Long-lived containers growing
through context.allocator" section with the `parent_allocator`
capture template and the list of existing examples to mirror.
155/155 example tests pass — zero-diff against snapshots since every
existing callsite still resolves to `context.allocator`.
CHECKPOINT-MEM.md "Next step" still pointed at Phase 1.2 from the
old MEM plan — but four commits have landed since: matchContextAllocCall
drop, typed raw-pointer stores, call-conv mismatch detection, and the
silent-arms sweep. The "Current state" section also still listed
matchContextAllocCall as preserved and tied test counts to 152.
Updates:
- "Last completed step" now points at the silent-arm sweep + typed
Store work.
- "Current state" rewritten: matchContextAllocCall is GONE, interp
raw-pointer paths enumerated, val_ty threading mentioned,
call-conv check called out.
- "Phase 0.3 audit findings" rewritten as historical context — chess
no longer touches any pattern-match bypass; protocol dispatch runs.
- "Next step" recommends Phase 1.3 (closure env through context),
notes Phase 1.2 was considered and skipped.
- Three new log entries for the four post-Step-9 commits.
Update CHECKPOINT-MEM.md to reflect that Steps 1-9 of
lets-see-options-for-merry-dijkstra.md are shipped. Notes that
ISSUE-MEM-002 is closed in the user-call path (matchContextAllocCall
remains as a documented comptime escape hatch), Phase 1.2/1.3/1.4
are unblocked, and points future sessions at the MEM plan
(tidy-doodling-cray.md) for the next phase.