Bodied instance methods on a sx-defined '#objc_class("Cls") { ... }'
declaration are now registered in fn_ast_map under '<Cls>.<method>'
and declared in the IR with their *Self params substituted to
the hidden state-struct type (M1.2 A.2a).
registerObjcDefinedClassMethods walks the foreign_class_decl's
members, synthesizes an FnDecl from each ForeignMethodDecl (zipping
params + param_names), and feeds it through declareFunction with
current_foreign_class temporarily pinned so resolveTypeWithBindings
substitutes Self → __SxFooState.
resolveTypeWithBindings now treats type_expr 'Self' as a contextual
alias: when current_foreign_class points to a sx-defined Obj-C
class, the substitution returns objcDefinedStateStructType(fcd).
Other Self contexts (protocols, JNI super, foreign-class member
type resolution) are untouched — the check filters on (!is_foreign
and runtime == .objc_class).
lowerFunction also sets current_foreign_class for the duration of
the body lowering when the name is qualified <Cls>.<method> and
Cls is in objc_defined_class_cache. Save+restore via defer so
nested calls round-trip cleanly.
Verification (manual): 'sx ir' on an sx-defined class shows
'declare void @SxFoo.bump(ptr, ptr)' — two args = implicit
__sx_ctx + the state-struct pointer (correct *Self substitution).
Body emission happens lazily; A.2c will trigger it eagerly so
the IMP trampoline (A.4) can reference it.
170 example tests + zig build test green.
Builds (and interns) the hidden sx-state struct type for an
sx-defined '#objc_class'. Layout:
__<ClassName>State {
user_field_0,
user_field_1,
...
}
This struct is what the runtime's '__sx_state' ivar points at —
separate from the Obj-C object itself, which stays opaque. The
sx method bodies will operate on '*__SxFooState' (after '*Self'
substitution in A.2b) so 'self.field' resolves to a plain struct
field access — A.3's 'free if types align' premise.
M1.2 A.5 will prepend '__sx_allocator: Allocator' so dealloc can
free through the per-instance allocator. Field-by-name access
stays correct across the future repositioning.
Methods / '#extends' / '#implements' members are ignored — only
'.field' contributes. Three unit tests pin: typical-field case,
empty-class case, mixed-member case.
Dead code at this commit — helper isn't called yet. A.2b (body
lowering with '*Self' substitution) wires it in. 170 example
tests + zig build test green.
Derives Apple's runtime type-encoding string from an IR method
signature. Called by class_addMethod(cls, sel, imp, types) when
M1.2 A.4+ synthesise IMPs for sx-defined classes.
Layout: <ret> @ : <param0> <param1> ... — @ is the receiver,
: is _cmd. Caller passes user-declared params AFTER stripping
'self: *Self'.
Encoding table:
v=void B=bool c=s8/BOOL s=s16 i=s32 q=s64
C=u8 S=u16 I=u32 Q=u64 f=f32 d=f64
@=foreign Obj-C class ptr #=Class :=SEL
*=[*]u8 (C string) ^v=any other ptr
bool (sx i1) maps to 'B' (C99 _Bool); s8 to 'c' (Apple's BOOL).
Foreign-class pointers detected via foreign_class_map lookup on
the pointee struct name. Other pointers fall to ^v — encoding is
metadata, not ABI, so conservative is safe.
Struct / slice / closure / etc. BAIL via diagnostic
(ObjcEncodingUnsupported) rather than silently mis-encoding, per
CLAUDE.md rejected-patterns rule. Future passes will widen the
table as new shapes show up in real IMPs.
Dead code at this commit — helper isn't called yet. Three unit
tests in src/ir/lower.test.zig pin the primitive / pointer /
Obj-C-class-pointer encodings before A.2 wires the helper in.
170 example tests + zig build test green.
Adds an insertion-ordered cache on Module for sx-defined Obj-C
classes — every '#objc_class("Cls") { ... }' declaration WITHOUT
'#foreign'. registerForeignClassDecl appends the entry alongside
its existing foreign_class_map insert; lookup helper available
via Module.lookupObjcDefinedClass.
ObjcDefinedClassEntry { name, *const ast.ForeignClassDecl }
The pointer back into the AST lets later passes (M1.2 A.1+) walk
'members' for fields / methods / '#extends' / '#implements'
without duplicating that data on the entry. Insertion order
matters because class-pair init constructors (A.4) must register
parent classes before children — 'objc_allocateClassPair(super,
...)' resolves super by lookup.
Infrastructure only — no observable behavior change. The cache
is populated but not yet read; A.1+ start pulling from it. 170
example tests + zig build test green.
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).
Extends parseForeignClassDecl ([src/parser.zig:1262]) with an
arrow arm that mirrors the existing parseFnDecl shape — single-
expression body wrapped in a one-statement block so downstream
lowering sees the same AST as a brace-body method.
Closes the M1.0 surface: '=> expr;' is now valid for top-level
functions, struct methods, AND '#objc_class' member methods.
The sx-defined class lowering (A.7 dispatch gate) is still gated,
so 140-expression-bodied-objc-method.sx exercises parse-only —
the body is reachable but the method is never invoked. When M1.2
lights up sx-defined class instantiation, the arrow-body form
will flow through unchanged.
169 examples pass (+1 from 140 now green); zig build test green.
parseForeignClassDecl ([src/parser.zig:1262]) accepts ';'
(declaration) or '{ ... }' (block body) but not '=>' for member
methods. The arrow form, which parseFnDecl ([src/parser.zig:1647])
already handles for top-level/struct decls (M1.0 1/3), surfaces
'expected ;' at the arrow today.
Snapshot pins that error so the next commit (the parser
extension) shows up as a single diagnostic→runtime-output diff
in 140-expression-bodied-objc-method.{txt,exit}.
sx's '=>' body form (already used for lambdas) works today for
top-level function declarations and struct member methods. Pin
the surface with examples/139-expression-bodied-fn.sx so a
parser regression here surfaces immediately.
Coverage:
- module-top: double :: (x: s32) -> s32 => x * 2;
- niladic: answer :: () -> s32 => 42;
- struct method: total :: (self: *Point) -> s32 => self.x + self.y;
Next: extend the same form to '#objc_class' member methods (the
M2.1(a/b) class-constant + class-method overrides path).
`inferExprType` for a chained call `Cls.static().instance(...)` never
looked the inner call's foreign-class declaration up, so the outer
dispatch saw a `.s64` receiver, the `foreign_class_map.get(...)` lookup
missed, and lowering emitted `error: unresolved 'method'`. The macOS
target appeared to work because `inline if OS == .ios { ... }` strips
the gated body before lowering — eliding every call that would have
exercised the broken path.
The "lazy-lower" framing in the original issue file was a red herring.
Fix in `src/ir/lower.zig`:
1. `inferExprType` for `.call` with `.field_access` callee now checks
`foreign_class_map` for both shapes — `Cls.static_method(args)` (object
identifier matches a foreign-class alias, look up static members) and
`inst.instance_method(args)` (receiver is a pointer to a foreign-class
struct, look up non-static members).
2. New helpers `resolveForeignMethodReturnType` and
`resolveForeignClassMemberType` substitute `*Self` / `Self` to the
foreign-class struct so a `*Self` return doesn't synthesize a phantom
`Self`-named struct that future dispatches can't resolve.
3. The Obj-C lowering paths (`lowerObjcMethodCall`, `lowerObjcStaticCall`)
route through the same helper for `ret_ty` so the IR Ref's type matches
what `inferExprType` reports.
Regression test at `examples/138-foreign-class-chained-dispatch.sx`
exercises NSObject's `+alloc` / `-init` chain in both shapes —
`*NSObject` return then `*Self` return, and `*Self` then `*Self`. Runs
on the host (macOS) for live exercise; non-macOS hosts fall through to
a stub matching the expected output.
This unblocks Phase 3.2 C4/C5 — the `UIWindow.alloc().initWithWindowScene(scene)`
pattern that surfaced the bug is the cluster's bread-and-butter shape.
167/167 example tests; chess builds clean on macOS, iOS-sim, Android.
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.
xfail half of Phase 3.1: static calls on `#objc_class` aliases lower
to `objc_msg_send` against the class object (loaded once per module
via `objc_getClass`).
Test mirrors the Phase 3.0 pattern (`ffi-objc-dsl-01..04`): synthesize
a class at runtime via `objc_allocateClassPair`, add class methods on
the metaclass via `object_getClass(cls) + class_addMethod`, declare
the sx-side `#objc_class` with `static answer :: ...` / `static add ::
...`, then invoke `SxProbeStatic.answer()` / `.add(7, 35)`. Skips on
non-macOS.
Surface choice: the call site is `.` (`Cls.method(args)`), matching
JNI's existing static dispatch convention (`SurfaceView.new(ctx)`)
rather than the plan's notional `::` form. The lowering disambiguates
static vs instance by inspecting `method.is_static` on the foreign-
class member, same as JNI. Picking `.` avoids extending the parser
for a new postfix operator with no other use case.
Pre-3.1 snapshot pins the current bail diagnostic at
`lowerForeignStaticCall` (lower.zig:4475) — "static calls on
'objc_class' runtime not yet supported (Phase 3/4)" — fires twice
because both the niladic and the keyword-arg static call hit it.
exit=1.
164/164 tests; next commit implements the dispatch and flips the
snapshot to working output.
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.
Previous version leaned on chess-specific terminology (GlyphCache, render,
frame arena) and made the rule read like a project memo. Replaced with a
generic `LongLived` example, a two-question test for when to apply, and
no incident-specific narrative. The "field name is by convention" line
removes the implicit prescription of `parent_allocator` so projects can
follow their own naming.
Also drops the explicit cross-reference list of existing examples — those
already drift with the code; the principle is enough to recognise the
shape when it appears.
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`.
allocViaContext used to fall back to a direct `.heap_alloc` (libc
malloc) when `Context` wasn't registered — i.e. when the program
didn't import std.sx. That was a silent escape hatch: a program could
appear to allocate fine without a `Context`, sidestepping protocol
dispatch entirely. Same shape as the matchContextAllocCall trap we
removed, just in a different code path.
Now: every site that needs `Context` emits a clear diagnostic when
the type isn't in scope, pointing the user at the required import.
- `allocViaContext`: the three fallback branches (no implicit_ctx, no
Context type, malformed Context struct) all call the new
`diagnoseMissingContext("heap allocation")` and return a
placeholder. Codegen no longer emits libc malloc as the silent
no-import path.
- `lowerPush`: the no-Context branches used to silently drop the
push and just lower the body. Now diagnose first, then lower
(keeping the body's other diagnostics flowing).
- `lowerIdentifier` for "context": used to silently fall through to
`global_names.get("context")` (which would emit an unresolved
identifier with no actionable hint). Now diagnose with the
required-import message.
With every consumer gone, the `.heap_alloc` and `.heap_free` IR ops
are deleted entirely:
- `inst.zig`: drop the Op variants.
- `interp.zig`: drop the execInst arms.
- `emit_llvm.zig`: drop the arms (the `getOrDeclareMalloc/Free`
helpers stay — they're still used by the foreign-decl path for
user-level `malloc`/`free` foreign bindings).
- `print.zig`: drop the printers + the isVoidOp arm.
- `emit_llvm.test.zig`: drop the unit test (op no longer exists).
155/155 example tests pass. Unit tests green. Chess green on macOS /
iOS sim / Android. A program that doesn't import std.sx and tries to
use `context.allocator.alloc` or `push Context.{}` or the `context`
identifier now gets a real error:
error: heap allocation requires the Context type — add
`#import "modules/std.sx";` (or a module that imports it)
Closes the last silent allocation-protocol escape.
The closure trampoline's env-buffer heap-copy in `lowerLambda` used to
call `.heap_alloc` directly (libc malloc, no protocol). Now it routes
through `allocViaContext` like every other compiler-internal alloc,
so a closure created inside `push Context.{ allocator = ... }` honors
the installed allocator — trackers count the env, arenas absorb it,
custom allocators see it. Closes the last `.heap_alloc` shortcut for
sx-internal allocations.
One ordering subtlety fixed alongside: the deferred restore of
`current_ctx_ref` at lowerLambda exit fired AFTER the env-and-closure
build section, so `allocViaContext` was reading `Ref.fromIndex(0)`
(the lambda's own ctx param, only valid inside the lambda body) when
emitting the alloc in the CALLER's scope. Without the explicit
restore, the env_heap dispatch silently routed through the default
context — the captured tracker never saw it. Fixed by restoring
`current_ctx_ref` right after `self.builder.func = saved_func`, before
the env build.
Regression test: `examples/133-closure-env-routes-through-context-allocator.sx`
mirrors the 130-xx-value pattern — install a Tracer via `push Context`,
create a capturing closure inside, assert `Tracer.count = 1`. Without
the fix the count is 0 (env goes through default context). Verified
by stashing the lower.zig change and re-running.
Bonus: `examples/50-smoke.sx` "closure-gpa" output flips from
`allocs=-1` to `allocs=0`. The old `-1` was the bug's signature —
the test manually `dealloc`'d the env after the closure ran, but the
GPA had never seen the matching alloc, so its counter went negative.
With Phase 1.3 the alloc/dealloc balance at 0. Snapshot regen.
155/155 example tests pass (133 new + 50-smoke regen). Chess green on
macOS / iOS sim / Android.
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.
Apply the new CLAUDE.md "no silent unimplemented arms" rule to the
interp. Every `else => return error.CannotEvalComptime` and
`else => return val` (passthrough) gets a one-line `bailDetail` that
surfaces through `printInterpBailDiag` as
`op=X/X: <reason>` instead of a bare `CannotEvalComptime`.
Tightened sites:
- `.deref` else-arm used to return the operand unchanged for ANY
Value kind. Now: enumerated allow-list (`.aggregate`, `.string`
are legitimate pre-dereferenced values); scalars / handles / undef
/ null bail loudly. Previously, dereffing e.g. a `.boolean`
silently produced a bare `.boolean` and the caller treated it as
a successful deref.
- `.unbox_any` else-arm used to return the operand unchanged for any
non-aggregate. Now: enumerated bails for scalars / handles / void.
An unbox_any whose operand wasn't routed through `box_any` first
is a frontend bug and now shows up as one.
- `.compiler_call` for an unregistered hook silently returned
`CannotEvalComptime`. Now names the missing hook category in the
detail.
- `.length` / `.data_ptr` / `.subslice` / `.array_to_slice` /
`.global_addr` / `.call_indirect` / `struct_get` / `enum_tag` /
`enum_payload` / `unary -` / `field_name_get` / `field_value_get`
/ `objc_msg_send` / `jni_msg_send`: every `else` arm now carries
a specific reason.
- `evalArith` / `evalCmp` use `typeErrorDetail` so mismatched
operand pairs surface "neither both-int nor both-float-coercible"
instead of bare TypeError.
- `callForeign` distinguishes "dlsym error" vs "symbol not found"
vs "> 8 args" instead of returning the same error for all three.
- `execBuiltin` arms for ops the lowering shouldn't have emitted at
comptime (`.cast`, `.type_of`, `.alloc`, `.dealloc`) bail with a
reason instead of a bare error.
154/154 still passing. Behavioural change: the `.deref` /
`.unbox_any` arms used to silently produce a value for Value kinds
they shouldn't have accepted. Any consumer relying on that silent
fall-through now bails — which is the point.
Sibling to the silent-fallback-defaults rule. Catch-all `else`
branches that pass a value through unchanged, write a default width,
or swallow errors into a zero-init are the same class of bug — a
case the implementer didn't think of corrupts data silently.
Both bites this session:
- `storeAtRawPtr` writing 8 bytes regardless of IR type (fixed by
threading val_ty through inst.Store).
- `.deref` else-arm returning val unchanged (now errors loudly for
raw pointers).
- comptime init catch swallowing the error into `.void_val`.
Preferred order: implement the arm in the same step. If plumbing is
out of scope, bail loudly with `bailDetail(comptime msg)` and leave a
one-line comment about what's needed. Width/type/layout info that's
ambiguous from the Value tag belongs in the IR op struct, not in a
"this case is usually 8 bytes" shortcut.
The interp's `storeAtRawPtr` used to write 8 bytes from a `.int` /
`.float` Value regardless of the destination's declared width. The
Value tag flattens s8..s64/u*/pointer all to `.int`, so it can't
disambiguate widths on its own — every store risked clobbering up to
7 neighbor bytes if the actual IR type was sub-8.
Fix:
- `inst.Store` gains `val_ty: TypeId` (defaults to `.void` for
backward compat with the LLVM emitter, which doesn't read it).
- `builder.store` captures `getRefType(val)` at emit time.
- `storeAtRawPtr` now takes `val_ty`, looks up
`types.typeSizeBytes(val_ty)`, and writes exactly that many bytes:
`.int` → width bytes of the i64 representation (1..8),
`.float` → 4 (f32 round-trip via @floatCast) or 8,
`.boolean` → 1 (zeros higher width bytes when destination is wider),
`.null_val` → width bytes of zero. Width outside the expected band
bails with a clear diagnostic.
Regression test: `examples/132-comptime-typed-store-widths.sx`. For
every primitive type (u8/u16/u32/u64, s8/s16/s32/s64, bool, f32, f64),
the test:
1. Allocates a 32-byte libc buffer through `context.allocator`.
2. Fills with sentinel byte 0xAA.
3. Writes ONE typed value at offset 8.
4. Sums every byte back.
5. Compares the runtime checksum (LLVM-emitted store, already
correct) against a comptime checksum baked via `#run`.
Mismatch = neighbor clobber. The test exits non-zero with a per-width
"FAIL u8: comptime=X runtime=Y" line so future regressions surface
the offending width.
Also wired:
- Interp's `index_get` gains `.int` / `.byte_ptr` base arms — `buf[i]`
through a raw libc-malloc'd pointer reads one byte at offset i.
Used by the new test's `sum_bytes` loop; previously bailed at
`op=index_get`.
- `emit_llvm`'s comptime-init catch block prints a real diagnostic
instead of swallowing the error and filling the const with zero.
Stale bail state from a previous init is cleared before each call.
154/154 example tests pass (the new test + the existing 153). Chess
still green on macOS / iOS sim / Android.
Comptime fall-through paths used to surface as bare `CannotEvalComptime`
with no hint about the actual limitation. Now each raw-pointer Value
combination that isn't yet wired sets `Interpreter.last_bail_detail`
with a one-line explanation; `printInterpBailDiag` appends it after
the op tag:
error: post-link callback failed: CannotEvalComptime
(op=load/load: comptime load through raw host pointer not supported
(IR type width not threaded)) at .../bundle.sx:N:N
Sites covered: `.load` / `.store` / `.struct_gep` / `.deref` /
`.index_gep` arms for `.int`, `.byte_ptr`, `.heap_ptr` bases;
`storeAtRawPtr`'s catch-all (now exhaustively names every rejected
Value kind); foreign-arg marshalling of unsupported aggregate shapes.
Notable behaviour change: `.deref` through a raw pointer used to
silently return the pointer-as-int unchanged. That looked like a
successful deref to callers — now it errors loudly. Aggregate
passthrough (for `*string` / `*Closure` slot deref) is preserved.
The `storeAtRawPtr` `.int`/`.float` arms still assume 8-byte width —
the Store IR op doesn't carry val's TypeId. Documented inline at the
helper: real-world comptime stores hit 8-byte fields; smaller dests
would clobber. Threading val_ty into Store is left for when a
comptime path actually hits this.
153/153 still passing. The new diagnostics fire when a comptime path
goes through an unhandled shape — verified by reading the bail text
from a synthetic test (separate issue: `#run` silently drops the error
instead of surfacing the diagnostic to the user — out of scope here).
Comptime now runs the full Allocator-protocol dispatch chain — the
same IR codegen emits — instead of being short-circuited at lowering
by an AST pattern-match. `context.allocator.alloc(size)` flows
through the protocol thunk into `CAllocator.alloc → libc_malloc`,
returning a real host-libc pointer. The interp picks it up as a raw
`.int` Value and treats it as memory.
The pieces:
- `evalComptimeString` now uses the parent module instead of spinning
up a fresh ct_module. The parent already has every type, protocol,
impl, and thunk registered (Allocator, CAllocator, Context, the
GPA/Tracker thunks), so the dispatch chain runs without a separate
scan pass. The comptime function is appended to the parent module;
it's `is_comptime` so codegen skips it.
- Interp gains raw-pointer paths:
- `index_gep(.aggregate{.int data_ptr, .int len}, idx)` produces a
new `.byte_ptr` (a new Value variant) — byte-granular pointer that
`store` writes 1 byte through. Mirrors the existing heap_ptr
semantics for the same op shape.
- `index_gep(.int, idx)` returns `.int = p + idx` (byte-addressed).
- `store(.int_ptr, val)` writes val's bytes via `@ptrFromInt`.
Handles int (8B), float (8B), bool (1B), null_val (8B of zeros).
- `store(.byte_ptr, val)` writes a single byte.
- `marshalForeignArg` handles `.aggregate{.int data, .int len}` and
`.byte_ptr` — both copy bytes into a null-terminated tmp buffer
for the C-side call.
- `asString` reads `len` bytes from a `.int` data field via
`@ptrFromInt`.
- `resolveFieldLoad` / `resolveFieldStore` reject field-pointer
aggregates whose first field is a wide integer (would otherwise
mis-trigger on a struct stored on the stack with an int pointer
in field 0).
- `lowerFunction` / `lazyLowerFunction` / `synthesizeJniMainStub`
bind `current_ctx_ref = &__sx_default_context` for every
callconv(.c) sx entry — not just `isExportedEntryName`. The JNI
stubs need this so `context.X` in the body resolves through
current_ctx_ref now that the pattern-match is gone.
- `matchContextAllocCall` and its dispatch site are deleted.
11 JNI/ObjC `.ir` snapshots regen — the comptime function appended to
the parent module shifts string-pool indices. 153/153 example tests
pass, chess green on macOS / iOS sim / Android.
Audit of library + game found every C-side callback already follows
the callconv(.c) rule. The static check at the bare-fn-ref site catches
typed fn-pointer mismatches; the one remaining hole is `xx <sx_fn> : *void`
(used by e.g. `class_addMethod(_, _, xx my_imp, _)`). Tried to close
it by requiring callconv(.c) on any sx fn cast to *void, but
examples/50-smoke.sx legitimately stores a default-conv sx fn into a
*void slot when manually constructing a Closure value — that path
goes through the sx-side closure trampoline ABI, not C. The compiler
can't distinguish C-side vs sx-side from the cast alone.
Leaving the hole open and documenting why. The existing libraries
follow the convention manually; the typed-fn-ptr check covers
pthread_create / SDL callbacks / GL loader-style sites which is where
the real-world bugs landed.
Passing a default-conv sx function to a `callconv(.c)` fn-pointer slot
(e.g. pthread_create's start routine) used to silently mismatch ABIs:
the C-side caller didn't supply __sx_ctx, so the sx-side body read its
first user param as garbage. The bug surfaced as a SIGSEGV inside
ANativeWindow_setBuffersGeometry on Android during chess bringup.
Now the compiler rejects the coercion outright at the bare-fn name
lookup site:
error: call-convention mismatch: 'sx_handler' is declared with
default sx convention but the target type expects callconv(.c)
Also: `#foreign` declarations without an explicit `callconv` now default
to `.c` instead of `.default`. Every external C symbol is by definition
C-conv; the previous default silently typed `objc_msgSend` (et al.) as
default-conv, so the check would fire on the consumer side when the
user typed a fn-ptr as `callconv(.c)`. With the foreign-default fix,
the existing typed-msgSend casts in `std/objc.sx` and `gpu/metal.sx`
keep type-checking and the rule is "C-conv on both sides or neither."
Caught by the new check (fixed in the same commit):
- `ios_gl_proc` in `platform/uikit.sx` lacked callconv(.c) but was
passed to `load_gl` whose `get_proc` slot expects it.
- `ffi_apply_callback` / `ffi_apply_callback2` in
`examples/ffi-06-callback.sx` had default-conv fn-ptr params but
the C bodies (in the companion .c) are unambiguously C-conv.
Regression test: `examples/131-callconv-mismatch-diagnostic.sx`
locks in the diagnostic shape (sx-conv fn → callconv(.c) slot).
153/153 example tests pass. Chess green on macOS / iOS sim / Android.
Verify-step uncovered three categories of regressions where sx code
calls into the platform's C ABI through fn-pointer types or as a
registered callback. Every site now declares the right convention.
C-side calls INTO sx → callconv(.c) on the sx function:
- platform/android.sx: sx_android_render_thread_entry is the start
routine pthread_create invokes — pthread treats it as a C function.
Also annotate the pthread_create signature so the start-routine fn-
pointer field rejects mismatching sx fns at compile time.
sx code calling typed fn-pointers cast from C symbols → callconv(.c)
on the fn-pointer type:
- opengl.sx: 55 GL fn-ptr globals + load_gl's proc-loader param. GL
trampolines are macOS/iOS/Android system code.
- std/objc.sx: the two typed `objc_msgSend` casts.
- gpu/metal.sx: ~40 typed `objc_msgSend` casts across Metal command
encoder / device / pipeline construction.
The block invoke trampolines (objc_block.sx) call back INTO sx (the
closure trampoline). The typed fn-ptr there stays default-conv so
ctx prepends correctly. Compiler change: a callconv(.c) sx function
now binds `current_ctx_ref` to `&__sx_default_context` at entry (used
to be gated by `isExportedEntryName`). C-callable sx callbacks like
the block invokes don't get their own __sx_ctx param but their bodies
still need a real Context to forward to the closure they delegate to.
Tests: 152/152 example suite + chess green on all 3 platforms.
Screenshots at /tmp/sx-game-{macos,iossim,android}.png.
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.
The `context : Context = ---;` global in `library/modules/std.sx` had
no remaining readers — all `context.X` lookups in user code resolve
through `current_ctx_ref` (Step 5), `push Context.{...}` uses an alloca
slot (Step 6), and `allocViaContext` sources from the lowering's
current ref. `emitDefaultContextInit` (the only writer) was already
removed in Step 5.
`inferExprType` for the `context` identifier now returns the registered
`Context` type when implicit-ctx is enabled, mirroring the lowering's
identifier-handling fast path. Without this, `context.allocator` would
type as `s64` (the fallback) and the field access would fail.
11 JNI/ObjC IR snapshots regen — the `@context` LLVM global is gone
from each.
152/152 example tests pass.
Step 5 — `context` resolves through `current_ctx_ref`. The compile-time
emit of the default GPA into the `context` global is gone; entry points
already bind `current_ctx_ref` to `&__sx_default_context` and every
sx-to-sx call forwards it. `allocViaContext` sources from
`current_ctx_ref` too. `matchContextAllocCall` is kept as a comptime
escape hatch: the ct_module spun up by `evalComptimeString` doesn't get
the full Allocator/CAllocator/Context type registration so the protocol-
dispatch chain wouldn't run in the interp; codegen also wins from the
direct libc malloc/free.
Step 6 — `push Context.{...}` stack-discipline rewrite. Allocates a
fresh `Context` slot, binds `current_ctx_ref` to it for the body's
lexical scope, restores on exit. No global, no walk.
Step 7 — interp parity. `defaultContextValue()` builds the Context
aggregate (CAllocator thunks for alloc/dealloc, null data) on demand.
`interp.call` bootstraps slot_ptr(0) when an entry function with
implicit ctx is called sans args; `materializeCtxArg` dereferences the
caller's slot_ptr into the aggregate at every sx-to-sx call boundary so
the callee's `load(ref_0)` lands on the value; `load` of an aggregate
is a passthrough. `.global_addr` of `__sx_default_context` returns the
aggregate directly so exported entries' first-line `global_addr(...)`
runs cleanly in `#run`.
`ct_lowering` inherits `implicit_ctx_enabled` + `has_implicit_ctx` so
functions lowered into the ct module carry ctx like their main-module
twins.
152/152 example tests pass. Snapshots regen.
Continues the implicit-Context refactor. Bare-fn trampolines, lambda
trampolines, and protocol thunks now carry __sx_ctx at slot 0; call
sites for closures, fn-pointer variables, and method dispatch prepend
the caller's current ctx.
- emit_llvm.zig:1687 call_indirect treats `fp_ctx_slots` leading args
as opaque ptr (the implicit ctx) when the fn-pointer is default-conv
under has_implicit_ctx.
- lower.zig:fnPtrTypeWantsCtx predicate gates the prepend at both
scope-local and global fn-pointer call sites.
- lower.zig:fixupMethodReceiver skips __sx_ctx when probing the
receiver param's type.
- lower.zig:lowerLambda builds closure type from user-visible params
only (skip ctx + env).
- lower.zig:closure(bare_fn) builds closure type from user-visible
params only.
- module.zig: Module.has_implicit_ctx flag mirrors Lowering's switch
so emit_llvm can read it without a back-pointer.
Tests updated:
- 5 ObjC-block/runtime tests get `callconv(.c)` on fn-ptr types
cast from `objc_msgSend` / Block.invoke (C-side calls into sx).
- ffi-06-callback gets `callconv(.c)` on double_it/add_with_ctx —
the registered C-side callbacks.
- 08-types snapshot regen (undefined-init drift from layout shift).
- 11 JNI/ObjC .ir snapshots regen for the ctx-prepended thunk
signatures.
151/152 example tests pass. Remaining failure (05-run) is the
comptime/interp path that requires Step 7 (callWithDefaultContext).
The session-long set of changes that lay the groundwork for the
Jai-literal implicit-Context-parameter refactor. Lots of accumulated
work; the new arrival is the implicit-ctx foundation (steps 1+2 of
the plan in current/CHECKPOINT-MEM.md):
Step 1 — `CAllocator :: struct {}` stateless allocator in
library/modules/allocators.sx, delegating directly to
libc_malloc/libc_free. `ConstantValue` in src/ir/inst.zig gains a
`func_ref: FuncId` leaf so nested aggregates can carry function
pointers (the inline Allocator value's fn-ptr fields). Switch
sites updated in emit_llvm.zig, print.zig, interp.zig.
Step 2 — `emitDefaultContextGlobal` in src/ir/lower.zig synthesises
a static `__sx_default_context` global with a nested-aggregate
init_val pointing at the CAllocator → Allocator thunks. The
second-pass `initVtableGlobals` in emit_llvm.zig is generalised
to handle `.aggregate` init_vals (re-emits after func_map is
populated so func_ref leaves resolve to real symbols).
Also folded in from earlier work this session:
- Phase 1.1: `xx value` heap-copy in `buildProtocolValue` routes
through `context.allocator` via the new `allocViaContext` helper.
- interp.zig: `marshalForeignArg` double-offset bug fixed —
`heapSlice` already adds `hp.offset` to the slice ptr, so the
extra `+ hp.offset` was scribbling memcpy/memset into adjacent
heap state, corrupting `heap.items[0]`. Symptom: `build_format`
at comptime produced zero bytes, all `print` calls failed.
- Lazy lowering: `lazyLowerFunction` now declares foreign-body
functions as extern stubs in the local (comptime) module so
cross-module foreign calls resolve.
- Allocator API: all stdlib allocators on one-line `init() -> *T`
(CAllocator/GPA: libc-backed; Arena/TrackingAllocator: parent-
backed; BufAlloc: embeds state at head of user buffer).
- issues 0038 (transitive #import), 0039 (chess + stdlib migration
fallout), 0040 (generic struct method dot-dispatch), 0041
(pointer types as type-arg), 0042 (alias name resolution) — all
fixed; regression tests in examples/.
- Diagnostic: `emitError` now embeds the lowering's
`current_source_file` and enclosing function in the literal
message; SX_TRACE_UNRESOLVED=1 dumps a Zig stack trace at the
emit site so misattributed spans can't hide where the failure
is.
- tools/verify-step.sh (all-platforms gate) and tools/scratch.sh
(interp/codegen parity tester) added.
Test suite: 152 example tests pass; chess builds + screenshots on
macOS / iOS sim / Android.