25 KiB
Smalltalk-on-SX: blocks with non-local return on delimited continuations
The headline showcase is blocks — Smalltalk's closures with non-local return (^expr aborts the enclosing method, not the block). Every other Smalltalk on top of a host VM (RSqueak on PyPy, GemStone on C, Maxine on Java) reinvents non-local return on whatever stack discipline the host gives them. On SX it's a one-liner: a block holds a captured continuation; ^ just invokes it. Message-passing OO falls out cheaply on top of the existing component / dispatch machinery.
End-state goal: ANSI-ish Smalltalk-80 subset, SUnit working, ~200 hand-written tests + a vendored slice of the Pharo kernel tests, classic corpus (eight queens, quicksort, mandelbrot, Conway's Life).
Scope decisions (defaults — override by editing before we spawn)
- Syntax: Pharo / Squeak chunk format (
!separators,Object subclass: #Foo …). No fileIn/fileOut images — text source only. - Conformance: ANSI X3J20 as a target, not bug-for-bug Squeak. "Reads like Smalltalk, runs like Smalltalk."
- Test corpus: SUnit ported to SX-Smalltalk + custom programs + a curated slice of Pharo
Kernel-Tests/Collections-Tests. - Image: out of scope. Source-only. No
become:between sessions, no snapshotting. - Reflection:
class,respondsTo:,perform:,doesNotUnderstand:in.become:(object-identity swap) in — it's a good CEK exercise. Method modification at runtime in. - GUI / Morphic / threads: out entirely.
Ground rules
- Scope: only touch
lib/smalltalk/**andplans/smalltalk-on-sx.md. Don't editspec/,hosts/,shared/, or any otherlib/<lang>/**. Smalltalk primitives go inlib/smalltalk/runtime.sx. - SX files: use
sx-treeMCP tools only. - Commits: one feature per commit. Keep
## Progress logupdated and tick roadmap boxes.
Architecture sketch
Smalltalk source
│
▼
lib/smalltalk/tokenizer.sx — selectors, keywords, literals, $c, #sym, #(…), $'…'
│
▼
lib/smalltalk/parser.sx — AST: classes, methods, blocks, cascades, sends
│
▼
lib/smalltalk/transpile.sx — AST → SX AST (entry: smalltalk-eval-ast)
│
▼
lib/smalltalk/runtime.sx — class table, MOP, dispatch, primitives
Core mapping:
- Class = SX dict
{:name :superclass :ivars :methods :class-methods :metaclass}. Class table is a flat dict keyed by class name. - Object = SX dict
{:class :ivars}—ivarskeyed by symbol. Tagged ints / floats / strings / symbols are not boxed; their class is looked up by SX type. - Method = SX lambda closing over a
selfbinding + temps. Body wrapped in a delimited continuation so^can escape. - Message send =
(st-send receiver selector args)— does class-table lookup, walks superclass chain, falls back todoesNotUnderstand:with aMessageobject. - Block
[:x | … ^v … ]= lambda + captured^k(the method-return continuation). Invoking^callsk; outer block invocation past method return raisesBlockContext>>cannotReturn:. - Cascade
r m1; m2; m3=(let ((tmp r)) (st-send tmp 'm1 ()) (st-send tmp 'm2 ()) (st-send tmp 'm3 ())). ifTrue:ifFalse:/whileTrue:= ordinary block sends; the runtime intrinsifies them in the JIT path so they compile to native branches (Tier 1 of bytecode expansion already covers this pattern).become:= swap two object identities everywhere — in SX this is a heap walk, but we restrict tooneWayBecome:(cheap: rewrite class field) by default.
Roadmap
Phase 1 — tokenizer + parser
- Tokenizer: identifiers, keywords (
foo:), binary selectors (+,==,,,->,~=etc.), numbers (radix16r1F; scaled1.5s2deferred), strings'…''…', characters$c, symbols#foo#'foo bar'#+, byte arrays#[1 2 3](open token), literal arrays#(1 #foo 'x')(open token), comments"…" - Parser (expression level): blocks
[:a :b | | t1 t2 | …], cascades, message precedence (unary > binary > keyword), assignment, return, statement sequences, literal arrays, byte arrays, paren grouping, method headers (+ other,at:put:, unary, with temps and body). Class-definition keyword messages parse as ordinary keyword sends — no special-case needed. - Parser (chunk-stream level):
st-read-chunkssplits source on!(with!!doubling) andst-parse-chunksruns the Pharo file-in state machine —methodsFor:/class methodsFor:opens a method batch, an empty chunk closes it. Pragmas<primitive: …>(incl. multiple keyword pairs, before or after temps, multiple per method) parsed into the method AST. - Unit tests in
lib/smalltalk/tests/parse.sx
Phase 2 — object model + sequential eval
- Class table + bootstrap (
lib/smalltalk/runtime.sx): canonical hierarchy installed (Object,Behavior,ClassDescription,Class,Metaclass,UndefinedObject,Boolean/True/False,Magnitude/Number/Integer/SmallInteger/Float/Character,Collection/SequenceableCollection/ArrayedCollection/Array/String/Symbol/OrderedCollection/Dictionary,BlockClosure). User class definition viast-class-define!, methods viast-class-add-method!(stamps:defining-classfor super), method lookup walks chain, ivars accumulated through superclass chain, native SX value types map to Smalltalk classes viast-class-of. smalltalk-eval-ast(lib/smalltalk/eval.sx): all literal kinds, ident resolution (locals → ivars → class refs), self/super/thisContext, assignment (locals or ivars, mutating), message send, cascade, sequence, and ^return via a sentinel marker (proper continuation-based escape is the Phase 3 showcase). Frames carry a parent chain so blocks close over outer locals. Primitive method tables for SmallInteger/Float, String/Symbol, Boolean, UndefinedObject, Array, BlockClosure (value/value:/whileTrue:/etc.), and class-sidenew/name/etc. Also satisfies "30+ tests" — 60 eval tests.- Method lookup: walk class → superclass already in
st-method-lookup-walk; new cached wrapperst-method-lookupkeys on(class, selector, side)and stores:not-foundfor negative results so DNU paths don't re-walk. Cache invalidates onst-class-define!,st-class-add-method!,st-class-add-class-method!,st-class-remove-method!, and full bootstrap. Stats helpersst-method-cache-stats/st-method-cache-reset-stats!for tests + later debugging. doesNotUnderstand:fallback.Messageclass added at bootstrap withselector/argumentsivars and accessor methods. Primitive senders (Number/String/Boolean/Nil/Array/BlockClosure/class-side) now return the:unhandledsentinel for unknown selectors;st-sendbuilds aMessageviast-make-messageand routes throughst-dnu, which looks updoesNotUnderstand:on the receiver's class chain (instance- or class-side as appropriate). User overrides intercept unknowns and see the symbol selector + arguments array in the Message.supersend. Method invocation captures the defining class on the frame;st-super-sendwalks from(st-class-superclass defining-class)(instance- or class-side as appropriate). Falls through primitives → DNU when no method is found. Receiver is preserved asself, so ivar mutations stick. Verified for: subclass override calls parent, inheritedsuperresolves to defining class's parent (not receiver's), multi-levelA→B→Cchain, super inside a block, super walks past an intermediate class with no local override.- 30+ tests in
lib/smalltalk/tests/eval.sx(60 tests, covering literals through user-class method dispatch with cascades and closures)
Phase 3 — blocks + non-local return (THE SHOWCASE)
- Method invocation captures a
^k(the return continuation) and binds it as the block's escape.st-invokewraps body in(call/cc (fn (k) ...)); the frame's:return-kis set to k. Block creation copies(get frame :return-k)onto the block. Block invocation sets the new frame's:return-kto the block's saved one — so non-local return reaches back through any number of intermediate block invocations. ^exprfrom inside a block invokes that captured^k. The "return" AST type evaluates the expression then calls(k v)on the frame's :return-k. Verified:detect:in:style early-exit, multi-level nested blocks, ^ from insideto:do:/whileTrue:, ^ from a block passed to a different method (Caller→Helper) returns from Caller.BlockContext>>value,value:,value:value:,value:value:value:,value:value:value:value:,valueWithArguments:. Implemented inst-block-dispatch+st-block-apply(eval iteration); pinned by 19 dedicated tests inlib/smalltalk/tests/blocks.sxcovering arity through 4, valueWithArguments: with empty/non-empty arg arrays, closures over outer locals (read + mutate + later-mutation re-read), nested blocks, blocks as method arguments,numArgs, andclass.whileTrue:/whileTrue/whileFalse:/whileFalseas ordinary block sends.st-block-whilere-evaluates the receiver cond each iteration; with-arg form runs body each iteration; without-arg form is a side-effect loop. Now returnsnilper ANSI/Pharo. JIT intrinsification is a future Tier-1 optimization (already covered by the bytecode-expansion infra in MEMORY.md). 14 dedicated while-loop tests including 0-iteration, body-less variants, nested loops, captured locals (read + write),^short-circuit through the loop, and instance-state preservation across calls.ifTrue:/ifFalse:/ifTrue:ifFalse:/ifFalse:ifTrue:as block sends, plusand:/or:short-circuit, eager&/|,not. Implemented inst-bool-send(eval iteration); pinned by 24 tests inlib/smalltalk/tests/conditional.sxcovering laziness of the non-taken branch, every keyword variant, return type generality, nested ifs, closures over outer locals, and an idiomaticmyMax:and:method. Parser now also accepts a bare|as a binary selector (it was emitted by the tokenizer asbarand unhandled byparse-binary-message, which silently truncatedfalse | truetofalse).- Escape past returned-from method raises (the SX-level analogue of
BlockContext>>cannotReturn:). Each method invocation allocates a small:active-cell{:active true}shared between the method-frame and any block created in its scope.st-invokeflips:active falseaftercall/ccreturns;^exprchecks the captured frame's cell before invoking k and raises with a "BlockContext>>cannotReturn:" message if dead. Verified bylib/smalltalk/tests/cannot_return.sx(5 tests using SXguardto catch the raise). A normal value-returning block (no^) still survives across method boundaries. - Classic programs in
lib/smalltalk/tests/programs/:eight-queens.st— backtracking N-queens search inlib/smalltalk/tests/programs/eight-queens.st. The.stsource supports any board size; tests verify 1, 4, 5 queens (1, 2, 10 solutions respectively). 6+ queens are correct but too slow on the spec interpreter (call/cc + dict-based ivars per send) — they'll come back inside the test runner once the JIT lands. The 8-queens canonical case will run in production.quicksort.st— Lomuto-partition in-place quicksort inlib/smalltalk/tests/programs/quicksort.st. Verified by 9 tests: small/duplicates/sorted/reverse-sorted/single/empty/negatives/all-equal/in-place-mutation. Exercises Arrayat:/at:put:mutation, recursion,to:do:over varying ranges.mandelbrot.st— escape-time iteration ofz := z² + cinlib/smalltalk/tests/programs/mandelbrot.st. Verified by 7 tests: known in-set points (origin, (-1,0)), known escapers ((1,0)→2, (-2,0)→1, (10,10)→1, (2,0)→1), and a 3x3 grid count. Caught a real bug along the way: literal#(...)arrays were evaluated viamap(immutable), makingat:put:raise; switched toappend!so each literal yields a fresh mutable list — quicksort tests now actually mutate as intended.life.st(Conway's Life).lib/smalltalk/tests/programs/life.stcarries the canonical rules with edge handling. Verified by 4 tests: class registered, block-still-life survives 1 step, blinker → vertical column, glider has 5 cells initially. Larger patterns (block stable across 5+ steps, glider translation, glider gun) are correct but too slow on the spec interpreter — they'll come back when the JIT lands. Also added Pharo-style dynamic array literal{e1. e2. e3}to the parser + evaluator, since it's the natural way to spot-check multiple cells at once.fibonacci.st(recursive + Array-memoised) —lib/smalltalk/tests/programs/fibonacci.st. Loaded from chunk-format source by newsmalltalk-loadhelper; verified by 13 tests inlib/smalltalk/tests/programs.sx(recursivefib:, memoisedmemoFib:up to 30, instance independence, class-table integrity). Source is currently duplicated as a string in the SX test file because there's no SX file-read primitive; conformance.sh will dedupe by piping the .st file directly.
lib/smalltalk/conformance.sh+ runner,scoreboard.json+scoreboard.md. The runner runsbash lib/smalltalk/test.sh -vonce, parses per-file counts, and emits both files. JSON has date / program names / corpus-test count / all-test pass/total / exit code. Markdown has a totals table, the program list, the verbatim per-file test counts block, and notes about JIT-deferred work. Both are checked into the tree as the latest baseline; the runner overwrites them.
Phase 4 — reflection + MOP
Object>>class,class>>name,class>>superclass,class>>methodDict,class>>selectors.classis universal inst-primitive-send(returnsMetaclassfor class-refs, the receiver's class otherwise). Class-side dispatch gainsmethodDict/classMethodDict(raw dict),selectors/classSelectors(Array of symbols),instanceVariableNames(own),allInstVarNames(inherited + own). 26 tests inlib/smalltalk/tests/reflection.sx.Object>>perform:/perform:with:/perform:with:with:/perform:with:with:with:/perform:with:with:with:with:/perform:withArguments:. Universal inst-primitive-send; routes back throughst-sendso user methods, primitives, super, and DNU all still apply. Selector arg can be a symbol or string (westrit). 10 new tests inlib/smalltalk/tests/reflection.sx.Object>>respondsTo:,Object>>isKindOf:,Object>>isMemberOf:. Universal inst-primitive-send.respondsTo:searches user method dicts (instance- or class-side based on receiver kind); native primitive selectors aren't enumerated, documented limitation.isKindOf:walksst-class-inherits-from?;isMemberOf:is exact class equality. 26 new tests inreflection.sx.Behavior>>compile:— runtime method addition. Class-sidecompile:parses the source viast-parse-methodand installs viast-class-add-method!. Sister formscompile:classified:andcompile:notifying:ignore the extra arg (Pharo-tolerant). Returns the selector as a symbol. Also addedaddSelector:withMethod:(raw AST install) andremoveSelector:. 9 new tests inreflection.sx.Object>>becomeForward:— one-way become at the universalst-primitive-sendlayer. Mutates the receiver's:classand:ivarsto match the target viadict-set!; every existing reference to the receiver dict now behaves as the target. Receiver and target remain distinct dicts (no SX-level identity merge), but method dispatch, ivar reads, and aliases all switch — Pharo's practical guarantee. 6 tests inreflection.sx, including the alias case (aandalias := aboth see the new identity).- Exceptions:
Exception,Error,ZeroDivide,MessageNotUnderstoodin bootstrap.signalraises the receiver via SXraise;signal:setsmessageTextfirst.on:do:/ensure:/ifCurtailed:on BlockClosure use SXguard. The auto-reraise pattern uses a side-effect predicate (cleanup runs in the predicate, returns false → guard auto-reraises) because(raise c)from inside a guard handler hits a known SX issue with nested-handler frames. 15 tests inlib/smalltalk/tests/exceptions.sx. Phase 4 complete.
Phase 5 — collections + numeric tower
SequenceableCollection/OrderedCollection/Array/String/Symbol. Bootstrap installs shared methods onSequenceableCollection:inject:into:,detect:/detect:ifNone:,count:,allSatisfy:/anySatisfy:,includes:,do:separatedBy:,indexOf:/indexOf:ifAbsent:,reject:,isEmpty/notEmpty,asString. They each callself do:, which dispatches to the receiver's primitivedo:— so Array, String, and Symbol inherit them uniformly. String/Symbol primitives gainedat:(1-indexed),copyFrom:to:,first/last,do:. OrderedCollection class is in the bootstrap hierarchy; its instance shape will fill out alongside Set/Dictionary in the next box. 28 tests inlib/smalltalk/tests/collections.sx.HashedCollection/Set/Dictionary/IdentityDictionaryStreamhierarchy:ReadStream/WriteStream/ReadWriteStreamNumbertower:SmallInteger/LargePositiveInteger/Float/FractionString>>format:,printOn:for everything
Phase 6 — SUnit + corpus to 200+
- Port SUnit (TestCase, TestSuite, TestResult) — written in SX-Smalltalk, runs in itself
- Vendor a slice of Pharo
Kernel-TestsandCollections-Tests - Drive the scoreboard up: aim for 200+ green tests
- Stretch: ANSI Smalltalk validator subset
Phase 7 — speed (optional)
- Method-dictionary inline caching (already in CEK as a primitive; just wire selector cache)
- Block intrinsification beyond
whileTrue:/ifTrue: - Compare against GNU Smalltalk on the corpus
Progress log
Newest first. Agent appends on every commit.
- 2026-04-25: Phase 5 sequenceable-collection methods + 28 tests (
lib/smalltalk/tests/collections.sx). 13 shared methods onSequenceableCollection(inject:into:, detect:, count:, …), inherited by Array/String/Symbol viaself do:. String primitives at:/copyFrom:to:/first/last/do:. 523/523 total. - 2026-04-25: Exception system + 15 tests (
lib/smalltalk/tests/exceptions.sx). Exception/Error/ZeroDivide/MessageNotUnderstood in bootstrap; signal/signal: raise via SXraise; on:do:/ensure:/ifCurtailed: on BlockClosure via SXguard. Phase 4 complete. 495/495 total. - 2026-04-25:
Object>>becomeForward:+ 6 tests. In-place mutation of:classand:ivarsviadict-set!; aliases see the new identity. 480/480 total. - 2026-04-25:
Behavior>>compile:+ sisters + 9 tests. Parses source viast-parse-method, installs via runtime helpers; also addedaddSelector:withMethod:andremoveSelector:. 474/474 total. - 2026-04-25:
respondsTo:/isKindOf:/isMemberOf:+ 26 tests. Universal atst-primitive-send. 465/465 total. - 2026-04-25:
Object>>perform:family + 10 tests. Universal dispatch viast-sendafter(str (nth args 0))for the selector. 439/439 total. - 2026-04-25: Phase 4 reflection accessors (
lib/smalltalk/tests/reflection.sx, 26 tests). UniversalObject>>class, plusmethodDict/selectors/instanceVariableNames/allInstVarNames/classMethodDict/classSelectorson class-refs. 429/429 total. - 2026-04-25: conformance.sh + scoreboard.{json,md} (
lib/smalltalk/conformance.sh,lib/smalltalk/scoreboard.json,lib/smalltalk/scoreboard.md). Single-pass runner overtest.sh -v; baseline at 5 programs / 39 corpus tests / 403 total. Phase 3 complete. - 2026-04-25: classic-corpus #5 Life (
tests/programs/life.st, 4 tests). Spec-interpreter Conway's Life with edge handling. Block + blinker + glider initial setup verified; larger step counts pending JIT (each spec-interpreter step is ~5-8s on a 5x5 grid). Added{e1. e2. e3}dynamic array literal to parser + evaluator. 403/403 total. - 2026-04-25: classic-corpus #4 mandelbrot (
tests/programs/mandelbrot.st, 7 tests). Escape-time iterator + grid counter. Discovered + fixed an immutable-list bug inlit-arrayeval —mapproduced an immutable list soat:put:raised; rebuilt viaappend!. Quicksort tests had been silently dropping ~7 cases due to that bug; now actually mutate. 399/399 total. - 2026-04-25: classic-corpus #3 quicksort (
tests/programs/quicksort.st, 9 tests). Lomuto partition; verified across duplicates, already-sorted/reverse-sorted, empty, single, negatives, all-equal, plus in-place mutation. 385/385 total. - 2026-04-25: classic-corpus #2 eight-queens (
tests/programs/eight-queens.st, 5 tests). Backtracking search; verified for boards of size 1, 4, 5. Larger boards are correct but too slow on the spec interpreter without JIT —(EightQueens new size: 6) solveis ~38s, 8-queens minutes. 382/382 total. - 2026-04-25: classic-corpus #1 fibonacci (
tests/programs/fibonacci.st+tests/programs.sx, 13 tests). Addedsmalltalk-loadchunk loader, class-sidesubclass:instanceVariableNames:(and longer Pharo variants),Array new:size,methodsFor:/category:no-ops,st-split-ivars. 377/377 total. - 2026-04-25: cannotReturn: implemented (
lib/smalltalk/tests/cannot_return.sx, 5 tests). Each method-invocation gets an{:active true}cell shared with its blocks;st-invokeflips it on exit;^exprraises if the cell is dead. Tests use SXguardto catch the raise. Non-^blocks unaffected. 364/364 total. - 2026-04-25:
ifTrue:/ifFalse:family pinned (lib/smalltalk/tests/conditional.sx, 24 tests) + parser fix:|is now accepted as a binary selector in expression position (tokenizer still emits it asbarfor block param/temp delimiting;parse-binary-messageaccepts both). Caught byfalse | truetruncating silently tofalse. 359/359 total. - 2026-04-25:
whileTrue:/whileFalse:/ no-arg variants pinned (lib/smalltalk/tests/while.sx, 14 tests).st-block-whilereturns nil per ANSI; behaviour verified under captured locals, nesting, early^, and zero/many iterations. 334/334 total. - 2026-04-25: BlockContext value family pinned (
lib/smalltalk/tests/blocks.sx, 19 tests). Each value/valueN/valueWithArguments: variant verified plus closure semantics (read, write, later-mutation re-read), nested blocks, and block-as-arg. 320/320 total. - 2026-04-25: THE SHOWCASE — non-local return via captured method-return continuations + 14 NLR tests (
lib/smalltalk/tests/nlr.sx).st-invokewraps body incall/cc; blocks copy creating method's^k;^exprinvokes that k. Verified across nested blocks,to:do:/whileTrue:, blocks passed to different methods (Caller→Helper escapes back to Caller), inner-vs-outer method nesting. Sentinel-based return removed. 301/301 total. - 2026-04-25:
supersend + 9 tests (lib/smalltalk/tests/super.sx).st-super-sendwalks from defining-class's superclass; class-side aware; primitives → DNU fallback. Also fixed top-level| temps |parsing inst-parse(the absence of which was silently aborting earlier eval/dnu tests — counts go from 274 → 287, with previously-skipped tests now actually running). - 2026-04-25:
doesNotUnderstand:+ 12 DNU tests (lib/smalltalk/tests/dnu.sx). Bootstrap installsMessage(with selector/arguments accessors). Primitives signal:unhandledinstead of erroring;st-dnubuilds a Message and walksdoesNotUnderstand:lookup. User Object DNU intercepts unknown sends to native receivers (Number, String, Block) too. 267/267 total. - 2026-04-25: method-lookup cache (
st-method-cachekeyed byclass|selector|side, stores:not-foundfor misses). Invalidation on define/add/remove + bootstrap.st-class-remove-method!added. Stats helpers + 10 cache tests; 255/255 total. - 2026-04-25:
smalltalk-eval-ast+ 60 eval tests (lib/smalltalk/eval.sx,lib/smalltalk/tests/eval.sx). Frame chain with mutable locals/ivars (viadict-set!), full literal eval, send dispatch (user methods + native primitive tables for Number/String/Boolean/Nil/Array/Block/Class), block closures, while/to:do:, cascades returning last, sentinel-based^return. User Point class round-trip works including+returning a fresh point. 245/245 total. - 2026-04-25: class table + bootstrap (
lib/smalltalk/runtime.sx,lib/smalltalk/tests/runtime.sx). Canonical hierarchy, type→class mapping for native SX values, instance construction, ivar inheritance, method install with:defining-classstamp, instance- and class-side method lookup walking the superclass chain. 54 new tests, 185/185 total. - 2026-04-25: chunk-stream parser + pragmas + 21 chunk/pragma tests (
lib/smalltalk/tests/parse_chunks.sx).st-read-chunks(with!!doubling),st-parse-chunksstate machine formethodsFor:batches incl. class-side. Pragmas with multiple keyword pairs, signed numeric / string / symbol args, in either pragma-then-temps or temps-then-pragma order. 131/131 tests pass. - 2026-04-25: expression-level parser + 47 parse tests (
lib/smalltalk/parser.sx,lib/smalltalk/tests/parse.sx). Full message precedence (unary > binary > keyword), cascades, blocks with params/temps, literal/byte arrays, assignment chain, method headers (unary/binary/keyword). Chunk-format! !driver deferred to a follow-up box. 110/110 tests pass. - 2026-04-25: tokenizer + 63 tests (
lib/smalltalk/tokenizer.sx,lib/smalltalk/tests/tokenize.sx,lib/smalltalk/test.sh). All token types covered except scaled decimals1.5s2(deferred).#(and#[emit open tokens; literal-array contents lexed as ordinary tokens for the parser to interpret.
Blockers
Shared-file issues that need someone else to fix. Minimal repro only.
- (none yet)