diff --git a/lib/erlang/bench_ring.sh b/lib/erlang/bench_ring.sh new file mode 100755 index 00000000..c7b9625c --- /dev/null +++ b/lib/erlang/bench_ring.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# Erlang-on-SX ring benchmark. +# +# Spawns N processes in a ring, passes a token N hops (one full round), +# and reports wall-clock time + throughput. Aspirational target from +# the plan is 1M processes; current sync-scheduler architecture caps out +# orders of magnitude lower — this script measures honestly across a +# range of N so the result/scaling is recorded. +# +# Usage: +# bash lib/erlang/bench_ring.sh # default ladder +# bash lib/erlang/bench_ring.sh 100 1000 5000 # custom Ns + +set -uo pipefail +cd "$(git rev-parse --show-toplevel)" + +SX_SERVER="${SX_SERVER:-hosts/ocaml/_build/default/bin/sx_server.exe}" +if [ ! -x "$SX_SERVER" ]; then + SX_SERVER="/root/rose-ash/hosts/ocaml/_build/default/bin/sx_server.exe" +fi +if [ ! -x "$SX_SERVER" ]; then + echo "ERROR: sx_server.exe not found." >&2 + exit 1 +fi + +if [ "$#" -gt 0 ]; then + NS=("$@") +else + NS=(10 100 500 1000) +fi + +TMPFILE=$(mktemp) +trap "rm -f $TMPFILE" EXIT + +# One-line Erlang program. Replaces __N__ with the size for each run. +PROGRAM='Me = self(), N = __N__, Spawner = fun () -> receive {setup, Next} -> Loop = fun () -> receive {token, 0, Parent} -> Parent ! done; {token, K, Parent} -> Next ! {token, K-1, Parent}, Loop() end end, Loop() end end, BuildRing = fun (K, Acc) -> if K =:= 0 -> Acc; true -> BuildRing(K-1, [spawn(Spawner) | Acc]) end end, Pids = BuildRing(N, []), Wire = fun (Ps) -> case Ps of [P, Q | _] -> P ! {setup, Q}, Wire(tl(Ps)); [Last] -> Last ! {setup, hd(Pids)} end end, Wire(Pids), hd(Pids) ! {token, N, Me}, receive done -> done end' + +run_n() { + local n="$1" + local prog="${PROGRAM//__N__/$n}" + cat > "$TMPFILE" <&1) + end_s=$(date +%s) + end_ns=$(date +%N) + + local ok="false" + if echo "$out" | grep -q ':name "done"'; then ok="true"; fi + + # ms = (end_s - start_s)*1000 + (end_ns - start_ns)/1e6 + elapsed_ms=$(awk -v s1="$start_s" -v n1="$start_ns" -v s2="$end_s" -v n2="$end_ns" \ + 'BEGIN { printf "%d", (s2 - s1) * 1000 + (n2 - n1) / 1000000 }') + + if [ "$ok" = "true" ]; then + local hops_per_s + hops_per_s=$(awk -v n="$n" -v ms="$elapsed_ms" \ + 'BEGIN { if (ms == 0) ms = 1; printf "%.0f", n * 1000 / ms }') + printf " N=%-8s hops=%-8s %sms (%s hops/s)\n" "$n" "$n" "$elapsed_ms" "$hops_per_s" + else + printf " N=%-8s FAILED %sms\n" "$n" "$elapsed_ms" + fi +} + +echo "Ring benchmark — sx_server.exe (synchronous scheduler)" +echo +for n in "${NS[@]}"; do + run_n "$n" +done +echo +echo "Note: 1M-process target from the plan is aspirational; the synchronous" +echo "scheduler with shift-based suspension and dict-based env copies is not" +echo "engineered for that scale. Numbers above are honest baselines." diff --git a/lib/erlang/bench_ring_results.md b/lib/erlang/bench_ring_results.md new file mode 100644 index 00000000..96883b8f --- /dev/null +++ b/lib/erlang/bench_ring_results.md @@ -0,0 +1,35 @@ +# Ring Benchmark Results + +Generated by `lib/erlang/bench_ring.sh` against `sx_server.exe` on the +synchronous Erlang-on-SX scheduler. + +| N (processes) | Hops | Wall-clock | Throughput | +|---|---|---|---| +| 10 | 10 | 907ms | 11 hops/s | +| 50 | 50 | 2107ms | 24 hops/s | +| 100 | 100 | 3827ms | 26 hops/s | +| 500 | 500 | 17004ms | 29 hops/s | +| 1000 | 1000 | 29832ms | 34 hops/s | + +(Each `Nm` row spawns N processes connected in a ring and passes a +single token N hops total — i.e. the token completes one full lap.) + +## Status of the 1M-process target + +Phase 3's stretch goal in `plans/erlang-on-sx.md` is a million-process +ring benchmark. **That target is not met** in the current synchronous +scheduler; extrapolating from the table above, 1M hops would take +~30 000 s. Correctness is fine — the program runs at every measured +size — but throughput is bound by per-hop overhead. + +Per-hop cost is dominated by: +- `er-env-copy` per fun clause attempt (whole-dict copy each time) +- `call/cc` capture + `raise`/`guard` unwind on every `receive` +- `er-q-delete-at!` rebuilds the mailbox backing list on every match +- `dict-set!`/`dict-has?` lookups in the global processes table + +To reach 1M-process throughput in this architecture would need at +least: persistent (path-copying) envs, an inline scheduler that +doesn't call/cc on the common path (msg-already-in-mailbox), and a +linked-list mailbox. None of those are in scope for the Phase 3 +checkbox — captured here as the floor we're starting from. diff --git a/lib/erlang/conformance.sh b/lib/erlang/conformance.sh new file mode 100755 index 00000000..7b0d7121 --- /dev/null +++ b/lib/erlang/conformance.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +# Erlang-on-SX conformance runner. +# +# Loads every erlang test suite via the epoch protocol, collects +# pass/fail counts, and writes lib/erlang/scoreboard.json + .md. +# +# Usage: +# bash lib/erlang/conformance.sh # run all suites +# bash lib/erlang/conformance.sh -v # verbose per-suite + +set -uo pipefail +cd "$(git rev-parse --show-toplevel)" + +SX_SERVER="${SX_SERVER:-hosts/ocaml/_build/default/bin/sx_server.exe}" +if [ ! -x "$SX_SERVER" ]; then + SX_SERVER="/root/rose-ash/hosts/ocaml/_build/default/bin/sx_server.exe" +fi +if [ ! -x "$SX_SERVER" ]; then + echo "ERROR: sx_server.exe not found." >&2 + exit 1 +fi + +VERBOSE="${1:-}" +TMPFILE=$(mktemp) +OUTFILE=$(mktemp) +trap "rm -f $TMPFILE $OUTFILE" EXIT + +# Each suite: name | counter pass | counter total +SUITES=( + "tokenize|er-test-pass|er-test-count" + "parse|er-parse-test-pass|er-parse-test-count" + "eval|er-eval-test-pass|er-eval-test-count" + "runtime|er-rt-test-pass|er-rt-test-count" + "ring|er-ring-test-pass|er-ring-test-count" + "ping-pong|er-pp-test-pass|er-pp-test-count" + "bank|er-bank-test-pass|er-bank-test-count" + "echo|er-echo-test-pass|er-echo-test-count" + "fib|er-fib-test-pass|er-fib-test-count" +) + +cat > "$TMPFILE" << 'EPOCHS' +(epoch 1) +(load "lib/erlang/tokenizer.sx") +(load "lib/erlang/parser.sx") +(load "lib/erlang/parser-core.sx") +(load "lib/erlang/parser-expr.sx") +(load "lib/erlang/parser-module.sx") +(load "lib/erlang/transpile.sx") +(load "lib/erlang/runtime.sx") +(load "lib/erlang/tests/tokenize.sx") +(load "lib/erlang/tests/parse.sx") +(load "lib/erlang/tests/eval.sx") +(load "lib/erlang/tests/runtime.sx") +(load "lib/erlang/tests/programs/ring.sx") +(load "lib/erlang/tests/programs/ping_pong.sx") +(load "lib/erlang/tests/programs/bank.sx") +(load "lib/erlang/tests/programs/echo.sx") +(load "lib/erlang/tests/programs/fib_server.sx") +(epoch 100) +(eval "(list er-test-pass er-test-count)") +(epoch 101) +(eval "(list er-parse-test-pass er-parse-test-count)") +(epoch 102) +(eval "(list er-eval-test-pass er-eval-test-count)") +(epoch 103) +(eval "(list er-rt-test-pass er-rt-test-count)") +(epoch 104) +(eval "(list er-ring-test-pass er-ring-test-count)") +(epoch 105) +(eval "(list er-pp-test-pass er-pp-test-count)") +(epoch 106) +(eval "(list er-bank-test-pass er-bank-test-count)") +(epoch 107) +(eval "(list er-echo-test-pass er-echo-test-count)") +(epoch 108) +(eval "(list er-fib-test-pass er-fib-test-count)") +EPOCHS + +timeout 120 "$SX_SERVER" < "$TMPFILE" > "$OUTFILE" 2>&1 + +# Parse "(N M)" from the line after each "(ok-len ...)" marker. +parse_pair() { + local epoch="$1" + local line + line=$(grep -A1 "^(ok-len $epoch " "$OUTFILE" | tail -1) + echo "$line" | sed -E 's/[()]//g' +} + +TOTAL_PASS=0 +TOTAL_COUNT=0 +JSON_SUITES="" +MD_ROWS="" + +idx=0 +for entry in "${SUITES[@]}"; do + name="${entry%%|*}" + epoch=$((100 + idx)) + pair=$(parse_pair "$epoch") + pass=$(echo "$pair" | awk '{print $1}') + count=$(echo "$pair" | awk '{print $2}') + if [ -z "$pass" ] || [ -z "$count" ]; then + pass=0 + count=0 + fi + TOTAL_PASS=$((TOTAL_PASS + pass)) + TOTAL_COUNT=$((TOTAL_COUNT + count)) + status="ok" + marker="✅" + if [ "$pass" != "$count" ]; then + status="fail" + marker="❌" + fi + if [ "$VERBOSE" = "-v" ]; then + printf " %-12s %s/%s\n" "$name" "$pass" "$count" + fi + if [ -n "$JSON_SUITES" ]; then JSON_SUITES+=","; fi + JSON_SUITES+=$'\n ' + JSON_SUITES+="{\"name\":\"$name\",\"pass\":$pass,\"total\":$count,\"status\":\"$status\"}" + MD_ROWS+="| $marker | $name | $pass | $count |"$'\n' + idx=$((idx + 1)) +done + +printf '\nErlang-on-SX conformance: %d / %d\n' "$TOTAL_PASS" "$TOTAL_COUNT" + +# scoreboard.json +cat > lib/erlang/scoreboard.json < lib/erlang/scoreboard.md <") (let ((body (er-parse-body st))) {:pattern pat :body body :class klass :guards guards})))))) + +;; ── binary literals / patterns ──────────────────────────────── +;; `<< [Seg {, Seg}] >>` where Seg = Value [: Size] [/ Spec]. Size is +;; a literal integer (multiple of 8 supported); Spec is `integer` +;; (default) or `binary` (rest-of-binary tail). Sufficient for the +;; common `<>` patterns. +(define + er-parse-binary + (fn + (st) + (er-expect! st "punct" "<<") + (cond + (er-is? st "punct" ">>") + (do (er-advance! st) {:segments (list) :type "binary"}) + :else (let + ((segs (list (er-parse-binary-segment st)))) + (er-parse-binary-tail st segs))))) + +(define + er-parse-binary-tail + (fn + (st segs) + (cond + (er-is? st "punct" ",") + (do + (er-advance! st) + (append! segs (er-parse-binary-segment st)) + (er-parse-binary-tail st segs)) + (er-is? st "punct" ">>") + (do (er-advance! st) {:segments segs :type "binary"}) + :else (error + (str + "Erlang parse: expected ',' or '>>' in binary, got '" + (er-cur-value st) + "'"))))) + +(define + er-parse-binary-segment + (fn + (st) + ;; Use `er-parse-primary` for the value so a leading `:` falls + ;; through to the segment's size suffix instead of being eaten + ;; by `er-parse-postfix-loop` as a `Mod:Fun` remote call. + (let + ((v (er-parse-primary st))) + (let + ((size (cond + (er-is? st "punct" ":") + (do (er-advance! st) (er-parse-primary st)) + :else nil)) + (spec (cond + (er-is? st "op" "/") + (do + (er-advance! st) + (let + ((tok (er-cur st))) + (er-advance! st) + (get tok :value))) + :else "integer"))) + {:size size :spec spec :value v})))) diff --git a/lib/erlang/runtime.sx b/lib/erlang/runtime.sx index a7d81938..03aaad5d 100644 --- a/lib/erlang/runtime.sx +++ b/lib/erlang/runtime.sx @@ -1,230 +1,1204 @@ -;; lib/erlang/runtime.sx — Erlang BIFs and stdlib wrappers on SX primitives +;; Erlang runtime — scheduler, process records, mailbox queue. +;; Phase 3 foundation. spawn/send/receive build on these primitives. ;; -;; Provides Erlang-idiomatic wrappers. Thin where spec primitives match; -;; inline where Erlang semantics differ (e.g. rem sign, integer division). +;; Scheduler is a single global dict in `er-scheduler` holding: +;; :next-pid INT — counter for fresh pid allocation +;; :processes DICT — pid-key (string) -> process record +;; :runnable QUEUE — FIFO of pids ready to run +;; :current PID — pid currently executing, or nil ;; -;; Primitives used from spec: -;; integer?/float? (Phase 2) -;; remainder/quotient (Phase 2 / Phase 15) -;; bitwise-and/or/xor/not (Phase 7) -;; arithmetic-shift (Phase 7) -;; make-set/set-add!/etc (Phase 18) -;; make-regexp/regexp-match/etc (Phase 20) -;; gcd (Phase 15) +;; A pid value is tagged: {:tag "pid" :id INT}. Pids compare by id. +;; +;; Process record fields: +;; :pid — this process's pid +;; :mailbox — queue of received messages (arrival order) +;; :state — "runnable" | "running" | "waiting" | "exiting" | "dead" +;; :continuation — saved k (for receive suspension); nil otherwise +;; :receive-pats — patterns the process is blocked on; nil otherwise +;; :trap-exit — bool +;; :links — list of pids +;; :monitors — list of {:ref :pid} +;; :env — Erlang env at the last yield +;; :exit-reason — nil until the process exits +;; +;; Queue — amortised-O(1) FIFO with head-pointer + slab-compact: +;; {:items (list...) :head-idx INT} -;; --------------------------------------------------------------------------- -;; 1. Numeric tower — type predicates + conversions -;; --------------------------------------------------------------------------- +;; ── queue ──────────────────────────────────────────────────────── +(define er-q-new (fn () {:head-idx 0 :items (list)})) -(define er-is-integer? integer?) -(define er-is-float? float?) -(define (er-is-number? x) (or (integer? x) (float? x))) -(define (er-is-atom? x) (= (type-of x) "symbol")) -(define er-is-list? list?) -(define er-is-binary? bytevector?) +(define er-q-push! (fn (q x) (append! (get q :items) x))) -;; Erlang float/1 coerces an integer to float -(define (er-float x) (* 1 x)) - -;; Erlang trunc/1 — truncate toward zero -(define er-trunc truncate) - -;; Erlang round/1 — round to nearest integer -(define er-round round) - -;; Erlang abs/1 -(define er-abs abs) - -;; Erlang max/min (BIFs in OTP 26) -(define (er-max a b) (if (>= a b) a b)) -(define (er-min a b) (if (<= a b) a b)) - -;; --------------------------------------------------------------------------- -;; 2. Integer arithmetic — div + rem (Erlang semantics) -;; --------------------------------------------------------------------------- - -;; Erlang div: integer division truncating toward zero -(define er-div quotient) - -;; Erlang rem: remainder with sign of dividend (matches remainder primitive) -(define er-rem remainder) - -;; Erlang gcd (non-standard BIF but useful) -(define er-gcd gcd) - -;; --------------------------------------------------------------------------- -;; 3. Bitwise ops — band / bor / bxor / bnot / bsl / bsr -;; --------------------------------------------------------------------------- - -(define er-band bitwise-and) -(define er-bor bitwise-or) -(define er-bxor bitwise-xor) -(define er-bnot bitwise-not) - -;; bsl: bit shift left by N positions -(define (er-bsl x n) (arithmetic-shift x n)) - -;; bsr: bit shift right by N positions -(define (er-bsr x n) (arithmetic-shift x (- 0 n))) - -;; --------------------------------------------------------------------------- -;; 4. Sets module — thin wrappers matching Erlang sets API -;; --------------------------------------------------------------------------- - -(define er-sets-new make-set) -(define er-sets-add-element set-add!) -(define er-sets-is-element set-member?) -(define er-sets-del-element set-remove!) -(define er-sets-union set-union) -(define er-sets-intersection set-intersection) -(define er-sets-subtract set-difference) -(define er-sets-to-list set->list) -(define er-sets-from-list list->set) -(define (er-sets-size s) (len (set->list s))) -(define (er-sets-is-set? x) (set? x)) - -;; --------------------------------------------------------------------------- -;; 5. Regexp — re module wrappers -;; --------------------------------------------------------------------------- - -;; er-re-run: returns match dict or nil (no match) (define - (er-re-run subject pattern) - (regexp-match (make-regexp pattern) subject)) + er-q-pop! + (fn + (q) + (let + ((h (get q :head-idx)) (items (get q :items))) + (if + (>= h (len items)) + nil + (let + ((x (nth items h))) + (dict-set! q :head-idx (+ h 1)) + (er-q-compact! q) + x))))) -;; er-re-replace: replace first match (define - (er-re-replace subject pattern replacement) - (regexp-replace (make-regexp pattern) subject replacement)) + er-q-peek + (fn + (q) + (let + ((h (get q :head-idx)) (items (get q :items))) + (if (>= h (len items)) nil (nth items h))))) -;; er-re-replace-all: global replace (define - (er-re-replace-all subject pattern replacement) - (regexp-replace-all (make-regexp pattern) subject replacement)) + er-q-len + (fn (q) (- (len (get q :items)) (get q :head-idx)))) -;; er-re-match-groups: extract capture groups from a match result -(define (er-re-match-groups m) (if (= m nil) nil (get m :groups))) +(define er-q-empty? (fn (q) (= (er-q-len q) 0))) -;; er-re-split: split string on regexp delimiter +;; Compact the backing list when the head pointer gets large so the +;; queue doesn't grow without bound. Threshold chosen to amortise the +;; O(n) copy — pops are still amortised O(1). (define - (er-re-split subject pattern) - (let - ((re (make-regexp pattern)) - (ms (regexp-match-all (make-regexp pattern) subject))) + er-q-compact! + (fn + (q) + (let + ((h (get q :head-idx)) (items (get q :items))) + (when + (> h 128) + (let + ((new (list))) + (for-each + (fn (i) (append! new (nth items i))) + (range h (len items))) + (dict-set! q :items new) + (dict-set! q :head-idx 0)))))) + +(define + er-q-to-list + (fn + (q) + (let + ((h (get q :head-idx)) (items (get q :items)) (out (list))) + (for-each + (fn (i) (append! out (nth items i))) + (range h (len items))) + out))) + +;; Read the i'th entry (relative to head) without popping. +(define + er-q-nth + (fn (q i) (nth (get q :items) (+ (get q :head-idx) i)))) + +;; Remove entry at logical index i, shift tail in. +(define + er-q-delete-at! + (fn + (q i) + (let + ((h (get q :head-idx)) (items (get q :items)) (new (list))) + (for-each + (fn + (j) + (when (not (= j (+ h i))) (append! new (nth items j)))) + (range h (len items))) + (dict-set! q :items new) + (dict-set! q :head-idx 0)))) + +;; ── pids ───────────────────────────────────────────────────────── +(define er-mk-pid (fn (id) {:id id :tag "pid"})) +(define er-pid? (fn (v) (er-is-tagged? v "pid"))) +(define er-pid-id (fn (pid) (get pid :id))) +(define er-pid-key (fn (pid) (str "p" (er-pid-id pid)))) +(define + er-pid-equal? + (fn (a b) (and (er-pid? a) (er-pid? b) (= (er-pid-id a) (er-pid-id b))))) + +;; ── refs ───────────────────────────────────────────────────────── +(define er-mk-ref (fn (id) {:id id :tag "ref"})) +(define er-ref? (fn (v) (er-is-tagged? v "ref"))) +(define + er-ref-equal? + (fn (a b) (and (er-ref? a) (er-ref? b) (= (get a :id) (get b :id))))) + +(define + er-ref-new! + (fn + () + (let + ((s (er-sched))) + (let + ((n (get s :next-ref))) + (dict-set! s :next-ref (+ n 1)) + (er-mk-ref n))))) + +;; ── scheduler state ────────────────────────────────────────────── +(define er-scheduler (list nil)) + +(define + er-sched-init! + (fn + () + (set-nth! + er-scheduler + 0 + {:next-pid 0 + :next-ref 0 + :current nil + :processes {} + :registered {} + :ets {} + :runnable (er-q-new)}))) + +(define er-sched (fn () (nth er-scheduler 0))) + +(define + er-pid-new! + (fn + () + (let + ((s (er-sched))) + (let + ((n (get s :next-pid))) + (dict-set! s :next-pid (+ n 1)) + (er-mk-pid n))))) + +(define + er-sched-runnable + (fn () (get (er-sched) :runnable))) + +(define + er-sched-processes + (fn () (get (er-sched) :processes))) + +(define + er-sched-enqueue! + (fn (pid) (er-q-push! (er-sched-runnable) pid))) + +(define + er-sched-next-runnable! + (fn () (er-q-pop! (er-sched-runnable)))) + +(define + er-sched-runnable-count + (fn () (er-q-len (er-sched-runnable)))) + +(define + er-sched-set-current! + (fn (pid) (dict-set! (er-sched) :current pid))) + +(define er-sched-current-pid (fn () (get (er-sched) :current))) + +(define + er-sched-process-count + (fn () (len (keys (er-sched-processes))))) + +;; ── process records ────────────────────────────────────────────── +(define + er-proc-new! + (fn + (env) + (let + ((pid (er-pid-new!))) + (let + ((proc + {:pid pid + :env env + :links (list) + :mailbox (er-q-new) + :state "runnable" + :monitors (list) + :monitored-by (list) + :continuation nil + :receive-pats nil + :trap-exit false + :has-timeout false + :timed-out false + :exit-reason nil})) + (dict-set! (er-sched-processes) (er-pid-key pid) proc) + (er-sched-enqueue! pid) + proc)))) + +(define + er-proc-get + (fn (pid) (get (er-sched-processes) (er-pid-key pid)))) + +(define + er-proc-exists? + (fn (pid) (dict-has? (er-sched-processes) (er-pid-key pid)))) + +(define + er-proc-field + (fn (pid field) (get (er-proc-get pid) field))) + +(define + er-proc-set! + (fn + (pid field val) + (let + ((p (er-proc-get pid))) + (if + (= p nil) + (error (str "Erlang: no such process " (er-pid-key pid))) + (dict-set! p field val))))) + +(define + er-proc-mailbox-push! + (fn (pid msg) (er-q-push! (er-proc-field pid :mailbox) msg))) + +(define + er-proc-mailbox-size + (fn (pid) (er-q-len (er-proc-field pid :mailbox)))) + +;; Main process is always pid 0 (scheduler starts with next-pid 0 and +;; erlang-eval-ast calls er-proc-new! first). Returns nil if no eval +;; has run. +(define + er-main-pid + (fn () (er-mk-pid 0))) + +(define + er-last-main-exit-reason + (fn + () (if - (= (len ms) 0) - (list subject) - (letrec - ((go (fn (matches pos acc) (if (= (len matches) 0) (append acc (list (substring subject pos (len subject)))) (let ((m (first matches)) (start (get (first matches) :start)) (end (get (first matches) :end))) (go (rest matches) end (append acc (list (substring subject pos start))))))))) - (go ms 0 (list)))))) + (er-proc-exists? (er-main-pid)) + (er-proc-field (er-main-pid) :exit-reason) + nil))) -;; --------------------------------------------------------------------------- -;; 6. List BIFs — hd/tl/length + lists module -;; --------------------------------------------------------------------------- - -(define (er-hd lst) (first lst)) -(define (er-tl lst) (rest lst)) -(define (er-length lst) (len lst)) - -;; lists:member/2 +;; ── process BIFs ──────────────────────────────────────────────── (define - (er-lists-member elem lst) - (cond - ((= (len lst) 0) false) - ((= elem (first lst)) true) - (else (er-lists-member elem (rest lst))))) + er-bif-is-pid + (fn (vs) (er-bool (er-pid? (er-bif-arg1 vs "is_pid"))))) -;; lists:reverse/1 -(define er-lists-reverse reverse) - -;; lists:append/2 -(define er-lists-append append) - -;; lists:flatten/1 (define - (er-lists-flatten lst) - (cond - ((= (len lst) 0) (list)) - ((list? (first lst)) - (append (er-lists-flatten (first lst)) (er-lists-flatten (rest lst)))) - (else (cons (first lst) (er-lists-flatten (rest lst)))))) + er-bif-self + (fn + (vs) + (if + (not (= (len vs) 0)) + (error "Erlang: self/0: arity") + (let + ((pid (er-sched-current-pid))) + (if + (= pid nil) + (error "Erlang: self/0: no current process") + pid))))) -;; lists:nth/2 — 1-indexed -(define (er-lists-nth n lst) (nth lst (- n 1))) - -;; lists:map/2 -(define er-lists-map map) - -;; lists:filter/2 -(define er-lists-filter filter) - -;; lists:foldl/3 — (Fun, Acc0, List) (define - (er-lists-foldl f acc lst) - (if - (= (len lst) 0) - acc - (er-lists-foldl f (f (first lst) acc) (rest lst)))) + er-bif-spawn + (fn + (vs) + (cond + (= (len vs) 1) (er-spawn-fun (nth vs 0)) + (= (len vs) 3) (error + "Erlang: spawn/3: module-based spawn deferred to Phase 5 (modules)") + :else (error "Erlang: spawn: wrong arity")))) -;; lists:foldr/3 (define - (er-lists-foldr f acc lst) - (if - (= (len lst) 0) - acc - (f (first lst) (er-lists-foldr f acc (rest lst))))) + er-spawn-fun + (fn + (fv) + (if + (not (er-fun? fv)) + (error "Erlang: spawn/1: not a fun") + (let + ((proc (er-proc-new! (er-env-new)))) + (dict-set! proc :initial-fun fv) + (get proc :pid))))) -;; lists:zip/2 (define - (er-lists-zip a b) - (if - (or (= (len a) 0) (= (len b) 0)) - (list) - (cons (list (first a) (first b)) (er-lists-zip (rest a) (rest b))))) + er-bif-exit + (fn + (vs) + (cond + (= (len vs) 1) (raise (er-mk-exit-marker (nth vs 0))) + (= (len vs) 2) + (error + "Erlang: exit/2 (signal another process) deferred to next Phase 4 step (signal propagation)") + :else (error "Erlang: exit: wrong arity")))) -;; lists:seq/2 — generate integer range (1-indexed like Erlang) +;; ── links / monitors / refs ───────────────────────────────────── (define - (er-lists-seq from to) - (if - (> from to) - (list) - (cons from (er-lists-seq (+ from 1) to)))) + er-bif-is-reference + (fn (vs) (er-bool (er-ref? (er-bif-arg1 vs "is_reference"))))) -;; --------------------------------------------------------------------------- -;; 7. Type conversion BIFs -;; --------------------------------------------------------------------------- +;; ── name registry ───────────────────────────────────────────── +(define er-registered (fn () (get (er-sched) :registered))) -;; atom_to_list/1 — convert atom (symbol) to its name string -(define (er-atom-to-list a) (symbol->string a)) - -;; list_to_atom/1 — convert string to atom (symbol) -(define (er-list-to-atom s) (make-symbol s)) - -;; integer_to_list/1 -(define (er-integer-to-list n) (str n)) - -;; list_to_integer/1 -(define (er-list-to-integer s) (truncate (parse-number s))) - -;; float_to_list/1 -(define (er-float-to-list f) (str f)) - -;; list_to_float/1 -(define (er-list-to-float s) (* 1 (parse-number s))) - -;; integer_to_list/2 — with radix (e.g. 16 for hex) -(define (er-integer-to-list-radix n radix) (number->string n radix)) - -;; --------------------------------------------------------------------------- -;; 8. ok/error tuple helpers — Erlang idiom {ok, Val} / {error, Reason} -;; --------------------------------------------------------------------------- - -(define (er-ok val) (list "ok" val)) -(define (er-error reason) (list "error" reason)) (define - (er-is-ok? t) - (and (list? t) (= (len t) 2) (= (first t) "ok"))) + er-bif-register + (fn + (vs) + (if + (not (= (len vs) 2)) + (error "Erlang: register/2: arity") + (let + ((name (nth vs 0)) (pid (nth vs 1))) + (cond + (not (er-atom? name)) + (raise (er-mk-error-marker (er-mk-atom "badarg"))) + (not (er-pid? pid)) + (raise (er-mk-error-marker (er-mk-atom "badarg"))) + (not (er-proc-exists? pid)) + (raise (er-mk-error-marker (er-mk-atom "badarg"))) + (dict-has? (er-registered) (get name :name)) + (raise (er-mk-error-marker (er-mk-atom "badarg"))) + :else (do + (dict-set! (er-registered) (get name :name) pid) + (er-mk-atom "true"))))))) + (define - (er-is-error? t) - (and (list? t) (= (len t) 2) (= (first t) "error"))) -(define (er-unwrap t) (nth t 1)) + er-bif-unregister + (fn + (vs) + (let + ((name (er-bif-arg1 vs "unregister"))) + (cond + (not (er-atom? name)) + (raise (er-mk-error-marker (er-mk-atom "badarg"))) + (not (dict-has? (er-registered) (get name :name))) + (raise (er-mk-error-marker (er-mk-atom "badarg"))) + :else (do + (dict-delete! (er-registered) (get name :name)) + (er-mk-atom "true")))))) + +(define + er-bif-whereis + (fn + (vs) + (let + ((name (er-bif-arg1 vs "whereis"))) + (cond + (not (er-atom? name)) + (raise (er-mk-error-marker (er-mk-atom "badarg"))) + (dict-has? (er-registered) (get name :name)) + (get (er-registered) (get name :name)) + :else (er-mk-atom "undefined"))))) + +(define + er-bif-registered + (fn + (vs) + (if + (not (= (len vs) 0)) + (error "Erlang: registered/0: arity") + (let + ((ks (keys (er-registered))) (out (er-mk-nil))) + (for-each + (fn + (i) + (let + ((k (nth ks (- (- (len ks) 1) i)))) + (set! out (er-mk-cons (er-mk-atom k) out)))) + (range 0 (len ks))) + out)))) + +;; Find the registered name for a pid, if any. Returns string or nil. +(define + er-find-registration + (fn + (pid) + (let + ((reg (er-registered)) (ks (keys reg)) (found (list nil))) + (for-each + (fn + (i) + (when + (= (nth found 0) nil) + (let + ((k (nth ks i))) + (when (er-pid-equal? (get reg k) pid) (set-nth! found 0 k))))) + (range 0 (len ks))) + (nth found 0)))) + +;; Drop pid from the registry (called on process death). +(define + er-unregister-pid! + (fn + (pid) + (let + ((name (er-find-registration pid))) + (when (not (= name nil)) (dict-delete! (er-registered) name))))) + +(define + er-bif-process-flag + (fn + (vs) + (if + (not (= (len vs) 2)) + (error "Erlang: process_flag/2: arity") + (let + ((flag (nth vs 0)) + (val (nth vs 1)) + (me (er-sched-current-pid))) + (cond + (and (er-atom? flag) (= (get flag :name) "trap_exit")) + (let + ((old (er-proc-field me :trap-exit))) + (er-proc-set! me :trap-exit (er-truthy? val)) + (er-bool old)) + :else (error + (str + "Erlang: process_flag: unsupported flag '" + (er-format-value flag) + "'"))))))) + +(define + er-bif-make-ref + (fn + (vs) + (if + (not (= (len vs) 0)) + (error "Erlang: make_ref/0: arity") + (er-ref-new!)))) + +;; Add `target` to `pid`'s :links list if not already there. +(define + er-link-add-one! + (fn + (pid target) + (let + ((links (er-proc-field pid :links))) + (when + (not (er-link-has? links target)) + (append! links target))))) + +(define + er-link-has? + (fn + (links target) + (cond + (= (len links) 0) false + (er-pid-equal? (nth links 0) target) true + :else (er-link-has? (er-slice-list links 1) target)))) + +(define + er-link-remove-one! + (fn + (pid target) + (let + ((old (er-proc-field pid :links)) (out (list))) + (for-each + (fn + (i) + (let + ((p (nth old i))) + (when (not (er-pid-equal? p target)) (append! out p)))) + (range 0 (len old))) + (er-proc-set! pid :links out)))) + +(define + er-bif-link + (fn + (vs) + (let + ((target (er-bif-arg1 vs "link")) (me (er-sched-current-pid))) + (cond + (not (er-pid? target)) (error "Erlang: link: not a pid") + (er-pid-equal? target me) (er-mk-atom "true") + (not (er-proc-exists? target)) + (raise (er-mk-exit-marker (er-mk-atom "noproc"))) + :else (do + (er-link-add-one! me target) + (er-link-add-one! target me) + (er-mk-atom "true")))))) + +(define + er-bif-unlink + (fn + (vs) + (let + ((target (er-bif-arg1 vs "unlink")) (me (er-sched-current-pid))) + (cond + (not (er-pid? target)) (error "Erlang: unlink: not a pid") + :else (do + (er-link-remove-one! me target) + (when + (er-proc-exists? target) + (er-link-remove-one! target me)) + (er-mk-atom "true")))))) + +(define + er-bif-monitor + (fn + (vs) + (if + (not (= (len vs) 2)) + (error "Erlang: monitor/2: arity") + (let + ((kind (nth vs 0)) + (target (nth vs 1)) + (me (er-sched-current-pid))) + (cond + (not (and (er-atom? kind) (= (get kind :name) "process"))) + (error "Erlang: monitor: only 'process' supported") + (not (er-pid? target)) (error "Erlang: monitor: not a pid") + :else (let + ((ref (er-ref-new!))) + (append! + (er-proc-field me :monitors) + {:ref ref :pid target}) + (when + (er-proc-exists? target) + (append! + (er-proc-field target :monitored-by) + {:from me :ref ref})) + ref)))))) + +(define + er-bif-demonitor + (fn + (vs) + (let + ((ref (er-bif-arg1 vs "demonitor")) (me (er-sched-current-pid))) + (if + (not (er-ref? ref)) + (error "Erlang: demonitor: not a reference") + (do + (er-demonitor-purge! me ref) + (er-mk-atom "true")))))) + +(define + er-demonitor-purge! + (fn + (me ref) + (let + ((old (er-proc-field me :monitors)) (out (list)) (target-ref (list nil))) + (for-each + (fn + (i) + (let + ((m (nth old i))) + (if + (er-ref-equal? (get m :ref) ref) + (set-nth! target-ref 0 (get m :pid)) + (append! out m)))) + (range 0 (len old))) + (er-proc-set! me :monitors out) + (when + (and + (not (= (nth target-ref 0) nil)) + (er-proc-exists? (nth target-ref 0))) + (let + ((target (nth target-ref 0)) + (oldby (er-proc-field (nth target-ref 0) :monitored-by)) + (out2 (list))) + (for-each + (fn + (i) + (let + ((m (nth oldby i))) + (when + (not (er-ref-equal? (get m :ref) ref)) + (append! out2 m)))) + (range 0 (len oldby))) + (er-proc-set! target :monitored-by out2)))))) + +;; ── scheduler loop ────────────────────────────────────────────── +;; Each scheduler step wraps the process body in `guard`. `receive` +;; with no match captures a `call/cc` continuation onto the proc +;; record and then `raise`s `er-suspend-marker`; the guard catches +;; the raise and the scheduler moves on. `exit/1` raises an exit +;; marker the same way. Resumption from a saved continuation also +;; runs under a fresh `guard` so a resumed receive that needs to +;; suspend again has a handler to unwind to. `shift`/`reset` aren't +;; usable here because SX's captured delimited continuations don't +;; re-establish their own reset boundary when invoked — a second +;; suspension during replay raises "shift without enclosing reset". +(define er-suspend-marker {:tag "er-suspend-marker"}) + +(define + er-suspended? + (fn + (v) + (and + (= (type-of v) "dict") + (= (get v :tag) "er-suspend-marker")))) + +(define + er-exited? + (fn + (v) + (and + (= (type-of v) "dict") + (= (get v :tag) "er-exit-marker")))) + +(define + er-mk-exit-marker + (fn (reason) {:tag "er-exit-marker" :reason reason})) + +(define + er-mk-throw-marker + (fn (reason) {:tag "er-throw-marker" :reason reason})) + +(define + er-mk-error-marker + (fn (reason) {:tag "er-error-marker" :reason reason})) + +(define + er-thrown? + (fn + (v) + (and + (= (type-of v) "dict") + (= (get v :tag) "er-throw-marker")))) + +(define + er-errored? + (fn + (v) + (and + (= (type-of v) "dict") + (= (get v :tag) "er-error-marker")))) + +(define + er-sched-run-all! + (fn + () + (let + ((pid (er-sched-next-runnable!))) + (cond + (not (= pid nil)) + (do (er-sched-step! pid) (er-sched-run-all!)) + ;; Queue empty — fire one pending receive-with-timeout and go again. + (er-sched-fire-one-timeout!) (er-sched-run-all!) + :else nil)))) + +;; Wake one waiting process whose receive had an `after Ms` clause. +;; Returns true if one fired. In our synchronous model "time passes" +;; once the runnable queue drains — timeouts only fire then. +(define + er-sched-fire-one-timeout! + (fn + () + (let + ((ks (keys (er-sched-processes))) (fired (list false))) + (for-each + (fn + (k) + (when + (not (nth fired 0)) + (let + ((p (get (er-sched-processes) k))) + (when + (and + (= (get p :state) "waiting") + (get p :has-timeout)) + (dict-set! p :timed-out true) + (dict-set! p :has-timeout false) + (dict-set! p :state "runnable") + (er-sched-enqueue! (get p :pid)) + (set-nth! fired 0 true))))) + ks) + (nth fired 0)))) + +(define + er-sched-step! + (fn + (pid) + (cond + (= (er-proc-field pid :state) "dead") nil + :else (er-sched-step-alive! pid)))) + +(define + er-sched-step-alive! + (fn + (pid) + (er-sched-set-current! pid) + (er-proc-set! pid :state "running") + (let + ((prev-k (er-proc-field pid :continuation)) + (result-ref (list nil))) + (guard + (c + ((er-suspended? c) (set-nth! result-ref 0 c)) + ((er-exited? c) (set-nth! result-ref 0 c)) + ((er-thrown? c) + (set-nth! + result-ref + 0 + (er-mk-exit-marker + (er-mk-tuple + (list (er-mk-atom "nocatch") (get c :reason)))))) + ((er-errored? c) + (set-nth! result-ref 0 (er-mk-exit-marker (get c :reason))))) + (set-nth! + result-ref + 0 + (if + (= prev-k nil) + (er-apply-fun (er-proc-field pid :initial-fun) (list)) + (do (er-proc-set! pid :continuation nil) (prev-k nil))))) + (let + ((r (nth result-ref 0))) + (cond + (er-suspended? r) nil + (er-exited? r) + (do + (er-proc-set! pid :state "dead") + (er-proc-set! pid :exit-reason (get r :reason)) + (er-proc-set! pid :exit-result nil) + (er-proc-set! pid :continuation nil) + (er-unregister-pid! pid) + (er-propagate-exit! pid (get r :reason))) + :else (do + (er-proc-set! pid :state "dead") + (er-proc-set! pid :exit-reason (er-mk-atom "normal")) + (er-proc-set! pid :exit-result r) + (er-proc-set! pid :continuation nil) + (er-unregister-pid! pid) + (er-propagate-exit! pid (er-mk-atom "normal")))))) + (er-sched-set-current! nil))) + +;; ── exit-signal propagation ───────────────────────────────────── +;; Called when `pid` finishes (normally or via exit). Walks the +;; process's `:monitored-by` and `:links` lists to deliver `{'DOWN'}` +;; messages and exit signals respectively. Linked processes without +;; `trap_exit` cascade-die with the same reason; those with +;; `trap_exit` true receive an `{'EXIT', From, Reason}` message. +(define + er-propagate-exit! + (fn + (pid reason) + (er-fire-monitors! pid reason) + (er-fire-links! pid reason))) + +(define + er-fire-monitors! + (fn + (pid reason) + (let + ((mons (er-proc-field pid :monitored-by))) + (for-each + (fn + (i) + (let + ((m (nth mons i))) + (let + ((from (get m :from)) (ref (get m :ref))) + (when + (and (er-proc-exists? from) + (not (= (er-proc-field from :state) "dead"))) + (let + ((msg + (er-mk-tuple + (list + (er-mk-atom "DOWN") + ref + (er-mk-atom "process") + pid + reason)))) + (er-proc-mailbox-push! from msg) + (when + (= (er-proc-field from :state) "waiting") + (er-proc-set! from :state "runnable") + (er-sched-enqueue! from))))))) + (range 0 (len mons)))))) + +(define + er-fire-links! + (fn + (pid reason) + (let + ((links (er-proc-field pid :links)) + (is-normal (er-is-atom-named? reason "normal"))) + (for-each + (fn + (i) + (let + ((target (nth links i))) + (when + (and (er-proc-exists? target) + (not (= (er-proc-field target :state) "dead"))) + (let + ((trap (er-proc-field target :trap-exit))) + (cond + trap (er-deliver-exit-msg! target pid reason) + is-normal nil + :else (er-cascade-exit! target reason)))))) + (range 0 (len links)))))) + +(define + er-deliver-exit-msg! + (fn + (target from reason) + (let + ((msg + (er-mk-tuple (list (er-mk-atom "EXIT") from reason)))) + (er-proc-mailbox-push! target msg) + (when + (= (er-proc-field target :state) "waiting") + (er-proc-set! target :state "runnable") + (er-sched-enqueue! target))))) + +(define + er-cascade-exit! + (fn + (target reason) + (er-proc-set! target :state "dead") + (er-proc-set! target :exit-reason reason) + (er-proc-set! target :exit-result nil) + (er-proc-set! target :continuation nil) + (er-propagate-exit! target reason))) + +;; ── module registry ───────────────────────────────────────────── +;; Global mutable dict from module name -> module env (which itself +;; binds each function name to a fun value capturing the same env, so +;; sibling functions can call each other recursively). +(define er-modules (list {})) +(define er-modules-get (fn () (nth er-modules 0))) +(define er-modules-reset! (fn () (set-nth! er-modules 0 {}))) + +;; Load an Erlang module declaration. Source must start with +;; `-module(Name).` and contain function definitions. Functions +;; sharing a name (different arities) get their clauses concatenated +;; into a single fun value — `er-apply-fun-clauses` already filters +;; by arity, so multi-arity dispatch falls out for free. +(define + erlang-load-module + (fn + (src) + (let + ((module-ast (er-parse-module src))) + (let + ((mod-name (get module-ast :name)) + (functions (get module-ast :functions)) + (mod-env (er-env-new)) + (by-name {})) + (for-each + (fn + (i) + (let + ((f (nth functions i))) + (let + ((name (get f :name)) (clauses (get f :clauses))) + (if + (dict-has? by-name name) + (let + ((existing (get by-name name))) + (for-each + (fn (j) (append! existing (nth clauses j))) + (range 0 (len clauses)))) + (let + ((init (list))) + (for-each + (fn (j) (append! init (nth clauses j))) + (range 0 (len clauses))) + (dict-set! by-name name init)))))) + (range 0 (len functions))) + (for-each + (fn + (k) + (let + ((all-clauses (get by-name k))) + (er-env-bind! mod-env k (er-mk-fun all-clauses mod-env)))) + (keys by-name)) + (dict-set! (er-modules-get) mod-name mod-env) + (er-mk-atom mod-name))))) + +(define + er-apply-user-module + (fn + (mod name vs) + (let + ((mod-env (get (er-modules-get) mod))) + (if + (not (dict-has? mod-env name)) + (raise + (er-mk-error-marker + (er-mk-tuple + (list + (er-mk-atom "undef") + (er-mk-atom mod) + (er-mk-atom name))))) + (er-apply-fun (get mod-env name) vs))))) + +;; ── gen_server (OTP-lite) ─────────────────────────────────────── +;; A minimal gen_server behaviour — `start_link/2`, `call/2`, `cast/2`, +;; `stop/1`, plus the receive loop dispatching `Mod:handle_call/3`, +;; `Mod:handle_cast/2`, `Mod:handle_info/2`. Loaded into the user +;; module registry on demand via `(er-load-gen-server!)`. +(define + er-gen-server-source + "-module(gen_server). + start_link(Mod, Args) -> + spawn(fun () -> + case Mod:init(Args) of + {ok, State} -> gen_server:loop(Mod, State); + {stop, Reason} -> exit(Reason) + end + end). + call(Pid, Req) -> + Ref = make_ref(), + Pid ! {'$gen_call', {self(), Ref}, Req}, + receive {Ref, Reply} -> Reply end. + cast(Pid, Msg) -> + Pid ! {'$gen_cast', Msg}, + ok. + stop(Pid) -> + gen_server:call(Pid, '$gen_stop'). + loop(Mod, State) -> + receive + {'$gen_call', {From, Ref}, '$gen_stop'} -> + From ! {Ref, ok}; + {'$gen_call', {From, Ref}, Req} -> + case Mod:handle_call(Req, From, State) of + {reply, Reply, NewState} -> + From ! {Ref, Reply}, + gen_server:loop(Mod, NewState); + {noreply, NewState} -> + gen_server:loop(Mod, NewState); + {stop, Reason, Reply, NewState} -> + From ! {Ref, Reply}, + exit(Reason) + end; + {'$gen_cast', Msg} -> + case Mod:handle_cast(Msg, State) of + {noreply, NewState} -> gen_server:loop(Mod, NewState); + {stop, Reason, NewState} -> exit(Reason) + end; + Other -> + case Mod:handle_info(Other, State) of + {noreply, NewState} -> gen_server:loop(Mod, NewState); + {stop, Reason, NewState} -> exit(Reason) + end + end.") + +(define + er-load-gen-server! + (fn () (erlang-load-module er-gen-server-source))) + +;; ── supervisor (OTP-lite, one-for-one) ────────────────────────── +;; Each child spec is `{Id, StartFn}` — `StartFn/0` returns the +;; child's pid. The supervisor `process_flag(trap_exit, true)`, +;; links to every child, and on `{'EXIT', DeadPid, _}` calls the +;; matching `StartFn` to bring up a fresh replacement. Strategy is +;; one-for-one: only the dead child restarts; siblings keep running. +(define + er-supervisor-source + "-module(supervisor). + start_link(Mod, Args) -> + spawn(fun () -> + process_flag(trap_exit, true), + case Mod:init(Args) of + {ok, ChildSpecs} -> + Children = lists:map( + fun (Spec) -> supervisor:start_child(Spec) end, + ChildSpecs), + supervisor:loop(Children) + end + end). + start_child({Id, StartFn}) -> + P = StartFn(), + link(P), + {Id, StartFn, P}. + which_children(Sup) -> + Sup ! {'$sup_which', self()}, + receive {'$sup_children', Cs} -> Cs end. + stop(Sup) -> + Sup ! '$sup_stop', + ok. + loop(Children) -> + receive + {'EXIT', Dead, _Reason} -> + supervisor:loop(supervisor:restart(Children, Dead)); + {'$sup_which', From} -> + From ! {'$sup_children', Children}, + supervisor:loop(Children); + '$sup_stop' -> + ok + end. + restart([], _) -> []; + restart([{Id, SF, P} | T], Dead) -> + case P =:= Dead of + true -> + NewP = SF(), + link(NewP), + [{Id, SF, NewP} | T]; + false -> + [{Id, SF, P} | supervisor:restart(T, Dead)] + end.") + +(define + er-load-supervisor! + (fn () (erlang-load-module er-supervisor-source))) + +;; ── ETS-lite ──────────────────────────────────────────────────── +;; Each table is a mutable list of tuples; key is the tuple's first +;; element (keypos=1, the default). Tables live on the scheduler +;; under `:ets` keyed by the registering atom name. Set semantics: +;; `insert/2` replaces an existing entry with the same key. +(define er-ets-tables (fn () (get (er-sched) :ets))) + +(define + er-bif-ets-new + (fn + (vs) + (cond + (not (= (len vs) 2)) (error "Erlang: ets:new/2: arity") + :else (let + ((name (nth vs 0))) + (cond + (not (er-atom? name)) + (raise (er-mk-error-marker (er-mk-atom "badarg"))) + (dict-has? (er-ets-tables) (get name :name)) + (raise + (er-mk-error-marker + (er-mk-tuple (list (er-mk-atom "badarg") name)))) + :else (do + (dict-set! (er-ets-tables) (get name :name) (list)) + name)))))) + +(define + er-ets-resolve + (fn + (id) + (cond + (not (er-atom? id)) + (raise (er-mk-error-marker (er-mk-atom "badarg"))) + (not (dict-has? (er-ets-tables) (get id :name))) + (raise (er-mk-error-marker (er-mk-atom "badarg"))) + :else (get (er-ets-tables) (get id :name))))) + +(define + er-bif-ets-insert + (fn + (vs) + (cond + (not (= (len vs) 2)) (error "Erlang: ets:insert/2: arity") + :else (let + ((tab (er-ets-resolve (nth vs 0))) + (entry (nth vs 1))) + (cond + (not (er-tuple? entry)) + (raise (er-mk-error-marker (er-mk-atom "badarg"))) + (= (len (get entry :elements)) 0) + (raise (er-mk-error-marker (er-mk-atom "badarg"))) + :else (do + (er-ets-replace-or-append! tab entry) + (er-mk-atom "true"))))))) + +(define + er-ets-replace-or-append! + (fn + (tab entry) + (let + ((key (nth (get entry :elements) 0)) + (replaced (list false))) + (for-each + (fn + (i) + (when + (er-equal? (nth (get (nth tab i) :elements) 0) key) + (set-nth! tab i entry) + (set-nth! replaced 0 true))) + (range 0 (len tab))) + (when (not (nth replaced 0)) (append! tab entry))))) + +(define + er-bif-ets-lookup + (fn + (vs) + (cond + (not (= (len vs) 2)) (error "Erlang: ets:lookup/2: arity") + :else (let + ((tab (er-ets-resolve (nth vs 0))) + (key (nth vs 1)) + (out (er-mk-nil))) + (for-each + (fn + (i) + (let + ((j (- (- (len tab) 1) i)) + (entry (nth tab (- (- (len tab) 1) i)))) + (when + (er-equal? (nth (get entry :elements) 0) key) + (set! out (er-mk-cons entry out))))) + (range 0 (len tab))) + out)))) + +(define + er-bif-ets-delete + (fn + (vs) + (cond + (= (len vs) 1) (er-ets-delete-table! (nth vs 0)) + (= (len vs) 2) (er-ets-delete-key! (nth vs 0) (nth vs 1)) + :else (error "Erlang: ets:delete: arity")))) + +(define + er-ets-delete-table! + (fn + (id) + (cond + (not (er-atom? id)) + (raise (er-mk-error-marker (er-mk-atom "badarg"))) + (not (dict-has? (er-ets-tables) (get id :name))) + (raise (er-mk-error-marker (er-mk-atom "badarg"))) + :else (do + (dict-delete! (er-ets-tables) (get id :name)) + (er-mk-atom "true"))))) + +(define + er-ets-delete-key! + (fn + (id key) + (let + ((tab (er-ets-resolve id)) (out (list))) + (for-each + (fn + (i) + (let + ((entry (nth tab i))) + (when + (not (er-equal? (nth (get entry :elements) 0) key)) + (append! out entry)))) + (range 0 (len tab))) + (dict-set! (er-ets-tables) (get id :name) out) + (er-mk-atom "true")))) + +(define + er-bif-ets-tab2list + (fn + (vs) + (let + ((tab (er-ets-resolve (er-bif-arg1 vs "ets:tab2list"))) (out (er-mk-nil))) + (for-each + (fn + (i) + (let + ((j (- (- (len tab) 1) i))) + (set! out (er-mk-cons (nth tab j) out)))) + (range 0 (len tab))) + out))) + +(define + er-bif-ets-info + (fn + (vs) + (cond + (= (len vs) 2) + (let + ((tab (er-ets-resolve (nth vs 0))) (key (nth vs 1))) + (cond + (and (er-atom? key) (= (get key :name) "size")) (len tab) + :else (er-mk-atom "undefined"))) + :else (error "Erlang: ets:info: arity")))) + +(define + er-apply-ets-bif + (fn + (name vs) + (cond + (= name "new") (er-bif-ets-new vs) + (= name "insert") (er-bif-ets-insert vs) + (= name "lookup") (er-bif-ets-lookup vs) + (= name "delete") (er-bif-ets-delete vs) + (= name "tab2list") (er-bif-ets-tab2list vs) + (= name "info") (er-bif-ets-info vs) + :else (error + (str "Erlang: undefined 'ets:" name "/" (len vs) "'"))))) diff --git a/lib/erlang/scoreboard.json b/lib/erlang/scoreboard.json new file mode 100644 index 00000000..b2db94e0 --- /dev/null +++ b/lib/erlang/scoreboard.json @@ -0,0 +1,16 @@ +{ + "language": "erlang", + "total_pass": 530, + "total": 530, + "suites": [ + {"name":"tokenize","pass":62,"total":62,"status":"ok"}, + {"name":"parse","pass":52,"total":52,"status":"ok"}, + {"name":"eval","pass":346,"total":346,"status":"ok"}, + {"name":"runtime","pass":39,"total":39,"status":"ok"}, + {"name":"ring","pass":4,"total":4,"status":"ok"}, + {"name":"ping-pong","pass":4,"total":4,"status":"ok"}, + {"name":"bank","pass":8,"total":8,"status":"ok"}, + {"name":"echo","pass":7,"total":7,"status":"ok"}, + {"name":"fib","pass":8,"total":8,"status":"ok"} + ] +} diff --git a/lib/erlang/scoreboard.md b/lib/erlang/scoreboard.md new file mode 100644 index 00000000..bf9592fa --- /dev/null +++ b/lib/erlang/scoreboard.md @@ -0,0 +1,18 @@ +# Erlang-on-SX Scoreboard + +**Total: 530 / 530 tests passing** + +| | Suite | Pass | Total | +|---|---|---|---| +| ✅ | tokenize | 62 | 62 | +| ✅ | parse | 52 | 52 | +| ✅ | eval | 346 | 346 | +| ✅ | runtime | 39 | 39 | +| ✅ | ring | 4 | 4 | +| ✅ | ping-pong | 4 | 4 | +| ✅ | bank | 8 | 8 | +| ✅ | echo | 7 | 7 | +| ✅ | fib | 8 | 8 | + + +Generated by `lib/erlang/conformance.sh`. diff --git a/lib/erlang/tests/eval.sx b/lib/erlang/tests/eval.sx new file mode 100644 index 00000000..a3056000 --- /dev/null +++ b/lib/erlang/tests/eval.sx @@ -0,0 +1,1130 @@ +;; Erlang evaluator tests — sequential expressions. + +(define er-eval-test-count 0) +(define er-eval-test-pass 0) +(define er-eval-test-fails (list)) + +(define + eev-deep= + (fn + (a b) + (cond + (and (= (type-of a) "dict") (= (type-of b) "dict")) + (let + ((ka (sort (keys a))) (kb (sort (keys b)))) + (and (= ka kb) (every? (fn (k) (eev-deep= (get a k) (get b k))) ka))) + (and (= (type-of a) "list") (= (type-of b) "list")) + (and + (= (len a) (len b)) + (every? (fn (i) (eev-deep= (nth a i) (nth b i))) (range 0 (len a)))) + :else (= a b)))) + +(define + er-eval-test + (fn + (name actual expected) + (set! er-eval-test-count (+ er-eval-test-count 1)) + (if + (eev-deep= actual expected) + (set! er-eval-test-pass (+ er-eval-test-pass 1)) + (append! er-eval-test-fails {:actual actual :expected expected :name name})))) + +(define ev erlang-eval-ast) +(define nm (fn (v) (get v :name))) + +;; ── literals ────────────────────────────────────────────────────── +(er-eval-test "int" (ev "42") 42) +(er-eval-test "zero" (ev "0") 0) +(er-eval-test "float" (ev "3.14") 3.14) +(er-eval-test "string" (ev "\"hi\"") "hi") +(er-eval-test "atom" (nm (ev "ok")) "ok") +(er-eval-test "atom true" (nm (ev "true")) "true") +(er-eval-test "atom false" (nm (ev "false")) "false") + +;; ── arithmetic ──────────────────────────────────────────────────── +(er-eval-test "add" (ev "1 + 2") 3) +(er-eval-test "sub" (ev "5 - 3") 2) +(er-eval-test "mul" (ev "4 * 3") 12) +(er-eval-test "div-real" (ev "10 / 4") 2.5) +(er-eval-test "div-int" (ev "10 div 3") 3) +(er-eval-test "rem" (ev "10 rem 3") 1) +(er-eval-test "div-neg" (ev "-10 div 3") -3) +(er-eval-test "precedence" (ev "1 + 2 * 3") 7) +(er-eval-test "parens" (ev "(1 + 2) * 3") 9) +(er-eval-test "unary-neg" (ev "-(1 + 2)") -3) +(er-eval-test "unary-neg int" (ev "-7") -7) + +;; ── comparison ──────────────────────────────────────────────────── +(er-eval-test "lt true" (nm (ev "1 < 2")) "true") +(er-eval-test "gt false" (nm (ev "1 > 2")) "false") +(er-eval-test "le equal" (nm (ev "2 =< 2")) "true") +(er-eval-test "ge equal" (nm (ev "2 >= 2")) "true") +(er-eval-test "eq" (nm (ev "2 == 2")) "true") +(er-eval-test "neq" (nm (ev "1 /= 2")) "true") +(er-eval-test "exact-eq same" (nm (ev "1 =:= 1")) "true") +(er-eval-test "exact-neq int" (nm (ev "1 =:= 2")) "false") +(er-eval-test "=/= true" (nm (ev "1 =/= 2")) "true") +(er-eval-test "atom-eq" (nm (ev "ok == ok")) "true") +(er-eval-test "atom-neq" (nm (ev "ok == error")) "false") + +;; ── logical ─────────────────────────────────────────────────────── +(er-eval-test "and tt" (nm (ev "true and true")) "true") +(er-eval-test "and tf" (nm (ev "true and false")) "false") +(er-eval-test "or tf" (nm (ev "true or false")) "true") +(er-eval-test + "andalso short" + (nm (ev "false andalso Neverref")) + "false") +(er-eval-test + "orelse short" + (nm (ev "true orelse Neverref")) + "true") +(er-eval-test "not true" (nm (ev "not true")) "false") +(er-eval-test "not false" (nm (ev "not false")) "true") + +;; ── tuples & lists ──────────────────────────────────────────────── +(er-eval-test "tuple tag" (get (ev "{1, 2, 3}") :tag) "tuple") +(er-eval-test "tuple len" (len (get (ev "{1, 2, 3}") :elements)) 3) +(er-eval-test "tuple elem" (nth (get (ev "{10, 20}") :elements) 1) 20) +(er-eval-test "empty tuple" (len (get (ev "{}") :elements)) 0) +(er-eval-test "nested tuple" + (nm (nth (get (ev "{ok, error}") :elements) 0)) "ok") +(er-eval-test "nil list" (get (ev "[]") :tag) "nil") +(er-eval-test "list head" (get (ev "[1, 2, 3]") :head) 1) +(er-eval-test + "list tail tail head" + (get (get (get (ev "[1, 2, 3]") :tail) :tail) :head) + 3) + +;; ── list ops ────────────────────────────────────────────────────── +(er-eval-test "++ head" (get (ev "[1, 2] ++ [3]") :head) 1) +(er-eval-test "++ last" + (get (get (get (ev "[1, 2] ++ [3]") :tail) :tail) :head) 3) + +;; ── block ───────────────────────────────────────────────────────── +(er-eval-test "block last wins" (ev "begin 1, 2, 3 end") 3) +(er-eval-test "bare body" (ev "1, 2, 99") 99) + +;; ── match + var ─────────────────────────────────────────────────── +(er-eval-test "match bind-and-use" (ev "X = 5, X + 1") 6) +(er-eval-test "match sequential" (ev "X = 1, Y = 2, X + Y") 3) +(er-eval-test + "rebind equal ok" + (ev "X = 5, X = 5, X") 5) + +;; ── if ──────────────────────────────────────────────────────────── +(er-eval-test "if picks first" (ev "if true -> 1; true -> 2 end") 1) +(er-eval-test + "if picks second" + (nm (ev "if 1 > 2 -> bad; true -> good end")) + "good") +(er-eval-test + "if with guard" + (ev "X = 5, if X > 0 -> 1; true -> 0 end") + 1) + +;; ── pattern matching ───────────────────────────────────────────── +(er-eval-test "match atom literal" (nm (ev "ok = ok, done")) "done") +(er-eval-test "match int literal" (ev "5 = 5, 42") 42) +(er-eval-test "match tuple bind" + (ev "{ok, V} = {ok, 99}, V") 99) +(er-eval-test "match tuple nested" + (ev "{A, {B, C}} = {1, {2, 3}}, A + B + C") 6) +(er-eval-test "match cons head" + (ev "[H|T] = [1, 2, 3], H") 1) +(er-eval-test "match cons tail head" + (ev "[_, H|_] = [1, 2, 3], H") 2) +(er-eval-test "match nil" + (ev "[] = [], 7") 7) +(er-eval-test "match wildcard always" + (ev "_ = 42, 7") 7) +(er-eval-test "match var reuse equal" + (ev "X = 5, X = 5, X") 5) + +;; ── case ───────────────────────────────────────────────────────── +(er-eval-test "case bind" (ev "case 5 of N -> N end") 5) +(er-eval-test "case tuple" + (ev "case {ok, 42} of {ok, V} -> V end") 42) +(er-eval-test "case cons" + (ev "case [1, 2, 3] of [H|_] -> H end") 1) +(er-eval-test "case fallthrough" + (ev "case error of ok -> 1; error -> 2 end") 2) +(er-eval-test "case wildcard" + (nm (ev "case x of ok -> ok; _ -> err end")) + "err") +(er-eval-test "case guard" + (ev "case 5 of N when N > 0 -> pos; _ -> neg end") + (er-mk-atom "pos")) +(er-eval-test "case guard fallthrough" + (ev "case -3 of N when N > 0 -> pos; _ -> neg end") + (er-mk-atom "neg")) +(er-eval-test "case bound re-match" + (ev "X = 5, case 5 of X -> same; _ -> diff end") + (er-mk-atom "same")) +(er-eval-test "case bound re-match fail" + (ev "X = 5, case 6 of X -> same; _ -> diff end") + (er-mk-atom "diff")) +(er-eval-test "case nested tuple" + (ev "case {ok, {value, 42}} of {ok, {value, V}} -> V end") + 42) +(er-eval-test "case multi-clause" + (ev "case 2 of 1 -> one; 2 -> two; _ -> other end") + (er-mk-atom "two")) +(er-eval-test "case leak binding" + (ev "case {ok, 7} of {ok, X} -> X end + 1") + 8) + +;; ── guard BIFs (is_*) ──────────────────────────────────────────── +(er-eval-test "is_integer 42" (nm (ev "is_integer(42)")) "true") +(er-eval-test "is_integer ok" (nm (ev "is_integer(ok)")) "false") +(er-eval-test "is_atom ok" (nm (ev "is_atom(ok)")) "true") +(er-eval-test "is_atom int" (nm (ev "is_atom(42)")) "false") +(er-eval-test "is_list cons" (nm (ev "is_list([1,2])")) "true") +(er-eval-test "is_list nil" (nm (ev "is_list([])")) "true") +(er-eval-test "is_list tuple" (nm (ev "is_list({1,2})")) "false") +(er-eval-test "is_tuple tuple" (nm (ev "is_tuple({ok,1})")) "true") +(er-eval-test "is_tuple list" (nm (ev "is_tuple([1])")) "false") +(er-eval-test "is_number int" (nm (ev "is_number(42)")) "true") +(er-eval-test "is_number atom" (nm (ev "is_number(foo)")) "false") +(er-eval-test "is_boolean true" (nm (ev "is_boolean(true)")) "true") +(er-eval-test "is_boolean false" (nm (ev "is_boolean(false)")) "true") +(er-eval-test "is_boolean atom" (nm (ev "is_boolean(foo)")) "false") + +;; ── guard BIFs wired into case / if ───────────────────────────── +(er-eval-test "guard is_integer pick" + (nm (ev "case 5 of N when is_integer(N) -> int; _ -> other end")) + "int") +(er-eval-test "guard is_integer reject" + (nm (ev "case foo of N when is_integer(N) -> int; _ -> other end")) + "other") +(er-eval-test "guard is_atom" + (nm (ev "case foo of X when is_atom(X) -> atom_yes; _ -> no end")) + "atom_yes") +(er-eval-test "guard conjunction" + (nm (ev "case 5 of N when is_integer(N), N > 0 -> pos; _ -> np end")) + "pos") +(er-eval-test "guard disjunction (if)" + (nm (ev "X = foo, if is_integer(X); is_atom(X) -> yes; true -> no end")) + "yes") +(er-eval-test "guard arith" + (nm (ev "case 3 of N when N * 2 > 5 -> big; _ -> small end")) + "big") + +;; ── BIFs: list + tuple ────────────────────────────────────────── +(er-eval-test "length empty" (ev "length([])") 0) +(er-eval-test "length 3" (ev "length([a, b, c])") 3) +(er-eval-test "length cons chain" (ev "length([1 | [2 | [3 | []]]])") 3) +(er-eval-test "hd" (ev "hd([10, 20, 30])") 10) +(er-eval-test "hd atom" + (nm (ev "hd([ok, err])")) "ok") +(er-eval-test "tl head" + (get (ev "tl([1, 2, 3])") :head) 2) +(er-eval-test "tl of single" (get (ev "tl([1])") :tag) "nil") +(er-eval-test "element 1" (nm (ev "element(1, {ok, value})")) "ok") +(er-eval-test "element 2" (ev "element(2, {ok, 42})") 42) +(er-eval-test "element 3" + (nm (ev "element(3, {a, b, c, d})")) "c") +(er-eval-test "tuple_size 2" (ev "tuple_size({a, b})") 2) +(er-eval-test "tuple_size 0" (ev "tuple_size({})") 0) + +;; ── BIFs: atom / list conversions ─────────────────────────────── +(er-eval-test "atom_to_list" (ev "atom_to_list(hello)") "hello") +(er-eval-test "list_to_atom roundtrip" + (nm (ev "list_to_atom(atom_to_list(foo))")) "foo") +(er-eval-test "list_to_atom fresh" + (nm (ev "list_to_atom(\"bar\")")) "bar") + +;; ── lists module ──────────────────────────────────────────────── +(er-eval-test "lists:reverse empty" + (get (ev "lists:reverse([])") :tag) "nil") +(er-eval-test "lists:reverse 3" + (ev "hd(lists:reverse([1, 2, 3]))") 3) +(er-eval-test "lists:reverse full" + (ev "lists:foldl(fun (X, Acc) -> Acc + X end, 0, lists:reverse([1, 2, 3]))") 6) + +;; ── funs + lists:map / lists:foldl ────────────────────────────── +(er-eval-test "fun call" (ev "F = fun (X) -> X + 1 end, F(10)") 11) +(er-eval-test "fun two-arg" + (ev "F = fun (X, Y) -> X * Y end, F(3, 4)") 12) +(er-eval-test "fun closure" + (ev "N = 100, F = fun (X) -> X + N end, F(5)") 105) +(er-eval-test "fun clauses" + (ev "F = fun (0) -> zero; (N) -> N end, element(1, {F(0), F(7)})") + (er-mk-atom "zero")) +(er-eval-test "fun multi-clause second" + (ev "F = fun (0) -> 0; (N) -> N * 2 end, F(5)") 10) +(er-eval-test "lists:map empty" + (get (ev "lists:map(fun (X) -> X end, [])") :tag) "nil") +(er-eval-test "lists:map double" + (ev "hd(lists:map(fun (X) -> X * 2 end, [1, 2, 3]))") 2) +(er-eval-test "lists:map sum-length" + (ev "length(lists:map(fun (X) -> X end, [a, b, c, d]))") 4) +(er-eval-test "lists:foldl sum" + (ev "lists:foldl(fun (X, Acc) -> X + Acc end, 0, [1, 2, 3, 4, 5])") 15) +(er-eval-test "lists:foldl product" + (ev "lists:foldl(fun (X, Acc) -> X * Acc end, 1, [1, 2, 3, 4])") 24) +(er-eval-test "lists:foldl as reverse" + (ev "hd(lists:foldl(fun (X, Acc) -> [X | Acc] end, [], [1, 2, 3]))") 3) + +;; ── io:format (via capture buffer) ────────────────────────────── +(er-eval-test "io:format plain" + (do (er-io-flush!) (ev "io:format(\"hello~n\")") (er-io-buffer-content)) + "hello\n") +(er-eval-test "io:format args" + (do (er-io-flush!) (ev "io:format(\"x=~p y=~p~n\", [42, hello])") (er-io-buffer-content)) + "x=42 y=hello\n") +(er-eval-test "io:format returns ok" + (nm (do (er-io-flush!) (ev "io:format(\"~n\")"))) "ok") +(er-eval-test "io:format tuple" + (do (er-io-flush!) (ev "io:format(\"~p\", [{ok, 1}])") (er-io-buffer-content)) + "{ok,1}") +(er-eval-test "io:format list" + (do (er-io-flush!) (ev "io:format(\"~p\", [[1,2,3]])") (er-io-buffer-content)) + "[1,2,3]") +(er-eval-test "io:format escape" + (do (er-io-flush!) (ev "io:format(\"50~~\")") (er-io-buffer-content)) + "50~") + +;; ── processes: self/0, spawn/1, is_pid ────────────────────────── +(er-eval-test "self tag" + (get (ev "self()") :tag) "pid") +(er-eval-test "is_pid self" + (nm (ev "is_pid(self())")) "true") +(er-eval-test "is_pid number" + (nm (ev "is_pid(42)")) "false") +(er-eval-test "is_pid atom" + (nm (ev "is_pid(ok)")) "false") +(er-eval-test "self equals self" + (nm (ev "Pid = self(), Pid =:= Pid")) "true") +(er-eval-test "self =:= self expr" + (nm (ev "self() == self()")) "true") +(er-eval-test "spawn returns pid" + (get (ev "spawn(fun () -> ok end)") :tag) "pid") +(er-eval-test "is_pid spawn" + (nm (ev "is_pid(spawn(fun () -> ok end))")) "true") +(er-eval-test "spawn new pid distinct" + (nm (ev "P1 = self(), P2 = spawn(fun () -> ok end), P1 =:= P2")) + "false") +(er-eval-test "two spawns distinct" + (nm (ev "P1 = spawn(fun () -> ok end), P2 = spawn(fun () -> ok end), P1 =:= P2")) + "false") +(er-eval-test "spawn then drain io" + (do + (er-io-flush!) + (ev "spawn(fun () -> io:format(\"child~n\") end), io:format(\"parent~n\")") + (er-io-buffer-content)) + "parent\nchild\n") +(er-eval-test "multiple spawn ordering" + (do + (er-io-flush!) + (ev "spawn(fun () -> io:format(\"a~n\") end), spawn(fun () -> io:format(\"b~n\") end), io:format(\"main~n\")") + (er-io-buffer-content)) + "main\na\nb\n") +(er-eval-test "child self is its own pid" + (do + (er-io-flush!) + (ev "P = spawn(fun () -> io:format(\"~p\", [is_pid(self())]) end), io:format(\"~p;\", [is_pid(P)])") + (er-io-buffer-content)) + "true;true") + +;; ── ! (send) + receive ────────────────────────────────────────── +(er-eval-test "self-send + receive" + (nm (ev "Me = self(), Me ! hello, receive Msg -> Msg end")) "hello") +(er-eval-test "send returns msg" + (nm (ev "Me = self(), Msg = Me ! ok, Me ! x, receive _ -> Msg end")) "ok") +(er-eval-test "receive int" + (ev "Me = self(), Me ! 42, receive N -> N + 1 end") 43) +(er-eval-test "receive with pattern" + (ev "Me = self(), Me ! {ok, 7}, receive {ok, V} -> V * 2 end") 14) +(er-eval-test "receive with guard" + (ev "Me = self(), Me ! 5, receive N when N > 0 -> positive end") + (er-mk-atom "positive")) +(er-eval-test "receive skips non-match" + (nm (ev "Me = self(), Me ! wrong, Me ! right, receive right -> ok end")) + "ok") +(er-eval-test "receive selective leaves others" + (nm (ev "Me = self(), Me ! a, Me ! b, receive b -> got_b end")) + "got_b") +(er-eval-test "two receives consume both" + (ev "Me = self(), Me ! 1, Me ! 2, X = receive A -> A end, Y = receive B -> B end, X + Y") 3) + +;; ── spawn + send + receive (real process communication) ───────── +(er-eval-test "spawn sends back" + (nm + (ev "Me = self(), spawn(fun () -> Me ! pong end), receive pong -> got_pong end")) + "got_pong") +(er-eval-test "ping-pong" + (do + (er-io-flush!) + (ev "Me = self(), Child = spawn(fun () -> receive {ping, From} -> From ! pong end end), Child ! {ping, Me}, receive pong -> io:format(\"pong~n\") end") + (er-io-buffer-content)) + "pong\n") +(er-eval-test "echo server" + (ev "Me = self(), Echo = spawn(fun () -> receive {From, Msg} -> From ! Msg end end), Echo ! {Me, 99}, receive R -> R end") 99) + +;; ── receive with multiple clauses ──────────────────────────────── +(er-eval-test "receive multi-clause" + (nm (ev "Me = self(), Me ! foo, receive ok -> a; foo -> b; bar -> c end")) + "b") +(er-eval-test "receive nested tuple" + (ev "Me = self(), Me ! {result, {ok, 42}}, receive {result, {ok, V}} -> V end") 42) + +;; ── receive ... after ... ─────────────────────────────────────── +(er-eval-test "after 0 empty mailbox" + (nm (ev "receive _ -> got after 0 -> timeout end")) + "timeout") +(er-eval-test "after 0 match wins" + (nm (ev "Me = self(), Me ! ok, receive ok -> got after 0 -> timeout end")) + "got") +(er-eval-test "after 0 non-match fires timeout" + (nm (ev "Me = self(), Me ! wrong, receive right -> got after 0 -> timeout end")) + "timeout") +(er-eval-test "after 0 leaves non-match" + (ev "Me = self(), Me ! wrong, receive right -> got after 0 -> to end, receive X -> X end") + (er-mk-atom "wrong")) +(er-eval-test "after Ms no sender — timeout fires" + (nm (ev "receive _ -> got after 100 -> timed_out end")) + "timed_out") +(er-eval-test "after Ms with sender — match wins" + (nm (ev "Me = self(), spawn(fun () -> Me ! hi end), receive hi -> got after 100 -> to end")) + "got") +(er-eval-test "after Ms computed" + (nm (ev "Ms = 50, receive _ -> got after Ms -> done end")) + "done") +(er-eval-test "after 0 body side effect" + (do (er-io-flush!) + (ev "receive _ -> ok after 0 -> io:format(\"to~n\") end") + (er-io-buffer-content)) + "to\n") +(er-eval-test "after zero poll selective" + (ev "Me = self(), Me ! first, Me ! second, X = receive second -> got_second after 0 -> to end, Y = receive first -> got_first after 0 -> to end, {X, Y}") + (er-mk-tuple (list (er-mk-atom "got_second") (er-mk-atom "got_first")))) + +;; ── exit/1 + process termination ───────────────────────────────── +(er-eval-test "exit normal returns nil" (ev "exit(normal)") nil) +(er-eval-test "exit normal reason" + (do (ev "exit(normal)") (nm (er-last-main-exit-reason))) "normal") +(er-eval-test "exit bye reason" + (do (ev "exit(bye)") (nm (er-last-main-exit-reason))) "bye") +(er-eval-test "exit tuple reason" + (do (ev "exit({shutdown, crash})") + (get (er-last-main-exit-reason) :tag)) + "tuple") +(er-eval-test "normal completion reason" + (do (ev "42") (nm (er-last-main-exit-reason))) "normal") +(er-eval-test "exit aborts subsequent" + (do (er-io-flush!) (ev "io:format(\"a~n\"), exit(bye), io:format(\"b~n\")") (er-io-buffer-content)) + "a\n") +(er-eval-test "child exit doesn't kill parent" + (do + (er-io-flush!) + (ev "spawn(fun () -> io:format(\"before~n\"), exit(quit), io:format(\"after~n\") end), io:format(\"main~n\")") + (er-io-buffer-content)) + "main\nbefore\n") +(er-eval-test "child exit reason recorded on child" + (do + (er-io-flush!) + (ev "P = spawn(fun () -> exit(child_bye) end), io:format(\"~p\", [is_pid(P)])") + (er-io-buffer-content)) + "true") +(er-eval-test "exit inside fn chain" + (do (ev "F = fun () -> exit(from_fn) end, F()") + (nm (er-last-main-exit-reason))) + "from_fn") + +;; ── refs / link / monitor ────────────────────────────────────── +(er-eval-test "make_ref tag" + (get (ev "make_ref()") :tag) "ref") +(er-eval-test "is_reference fresh" + (nm (ev "R = make_ref(), is_reference(R)")) "true") +(er-eval-test "is_reference pid" + (nm (ev "is_reference(self())")) "false") +(er-eval-test "is_reference number" + (nm (ev "is_reference(42)")) "false") +(er-eval-test "make_ref distinct" + (nm (ev "R1 = make_ref(), R2 = make_ref(), R1 =:= R2")) "false") +(er-eval-test "make_ref same id eq" + (nm (ev "R = make_ref(), R =:= R")) "true") + +(er-eval-test "link returns true" + (nm (ev "P = spawn(fun () -> ok end), link(P)")) "true") +(er-eval-test "self link returns true" + (nm (ev "link(self())")) "true") +(er-eval-test "unlink returns true" + (nm (ev "P = spawn(fun () -> ok end), link(P), unlink(P)")) "true") +(er-eval-test "unlink without link" + (nm (ev "P = spawn(fun () -> ok end), unlink(P)")) "true") + +(er-eval-test "monitor returns ref" + (get (ev "P = spawn(fun () -> ok end), monitor(process, P)") :tag) + "ref") +(er-eval-test "monitor refs distinct" + (nm (ev "P = spawn(fun () -> ok end), R1 = monitor(process, P), R2 = monitor(process, P), R1 =:= R2")) + "false") +(er-eval-test "demonitor returns true" + (nm (ev "P = spawn(fun () -> ok end), R = monitor(process, P), demonitor(R)")) + "true") + +;; Bidirectional link recorded on both sides. +(er-eval-test "link bidirectional" + (do + (ev "P = spawn(fun () -> receive forever -> ok end end), link(P)") + ;; After eval, check links on main + child via accessors. + (and + (= (len (er-proc-field (er-mk-pid 0) :links)) 1) + (= (len (er-proc-field (er-mk-pid 1) :links)) 1))) + true) + +;; unlink clears both sides. +(er-eval-test "unlink clears both" + (do + (ev "P = spawn(fun () -> receive forever -> ok end end), link(P), unlink(P)") + (and + (= (len (er-proc-field (er-mk-pid 0) :links)) 0) + (= (len (er-proc-field (er-mk-pid 1) :links)) 0))) + true) + +;; monitor adds entries to both lists. +(er-eval-test "monitor records both sides" + (do + (ev "P = spawn(fun () -> receive forever -> ok end end), monitor(process, P)") + (and + (= (len (er-proc-field (er-mk-pid 0) :monitors)) 1) + (= (len (er-proc-field (er-mk-pid 1) :monitored-by)) 1))) + true) + +;; demonitor clears both lists. +(er-eval-test "demonitor clears both" + (do + (ev "P = spawn(fun () -> receive forever -> ok end end), R = monitor(process, P), demonitor(R)") + (and + (= (len (er-proc-field (er-mk-pid 0) :monitors)) 0) + (= (len (er-proc-field (er-mk-pid 1) :monitored-by)) 0))) + true) + +;; ── exit-signal propagation + trap_exit ──────────────────────── +(er-eval-test "process_flag default false" + (nm (ev "process_flag(trap_exit, true)")) "false") +(er-eval-test "process_flag returns prev" + (nm (ev "process_flag(trap_exit, true), process_flag(trap_exit, false)")) + "true") + +;; Monitor fires on normal exit. +(er-eval-test "monitor DOWN normal" + (nm (ev "P = spawn(fun () -> ok end), monitor(process, P), receive {'DOWN', _, process, _, R} -> R end")) + "normal") + +;; Monitor fires on abnormal exit. +(er-eval-test "monitor DOWN abnormal" + (nm (ev "P = spawn(fun () -> exit(boom) end), monitor(process, P), receive {'DOWN', _, process, _, R} -> R end")) + "boom") + +;; Monitor's ref appears in DOWN message. +(er-eval-test "monitor DOWN ref matches" + (nm (ev "P = spawn(fun () -> exit(bye) end), Ref = monitor(process, P), receive {'DOWN', Ref, process, _, _} -> ok_match end")) + "ok_match") + +;; Two monitors -> both fire. +(er-eval-test "two monitors both fire" + (ev "P = spawn(fun () -> exit(crash) end), monitor(process, P), monitor(process, P), receive {'DOWN', _, _, _, _} -> ok end, receive {'DOWN', _, _, _, _} -> 2 end") + 2) + +;; trap_exit + link + abnormal exit -> {'EXIT', From, Reason} message. +(er-eval-test "trap_exit catches abnormal" + (nm (ev "process_flag(trap_exit, true), P = spawn(fun () -> exit(boom) end), link(P), receive {'EXIT', _, R} -> R end")) + "boom") + +;; trap_exit + link + normal exit -> {'EXIT', From, normal}. +(er-eval-test "trap_exit catches normal" + (nm (ev "process_flag(trap_exit, true), P = spawn(fun () -> ok end), link(P), receive {'EXIT', _, R} -> R end")) + "normal") + +;; Cascade exit: A links B, B dies abnormally, A dies with same reason. +(er-eval-test "cascade reason" + (do + (ev "A = spawn(fun () -> B = spawn(fun () -> exit(crash) end), link(B), receive forever -> ok end end), receive after 0 -> ok end") + (nm (er-proc-field (er-mk-pid 1) :exit-reason))) + "crash") + +;; Normal exit doesn't cascade (without trap_exit) — A's body returns +;; "survived" via the `after` clause and A dies normally. +(er-eval-test "normal exit no cascade" + (do + (ev "A = spawn(fun () -> B = spawn(fun () -> ok end), link(B), receive {'EXIT', _, _} -> got_exit after 50 -> survived end end), receive after 0 -> ok end") + (list + (nm (er-proc-field (er-mk-pid 1) :exit-reason)) + (nm (er-proc-field (er-mk-pid 1) :exit-result)))) + (list "normal" "survived")) + +;; Monitor without trap_exit: monitored proc abnormal doesn't kill the monitor. +(er-eval-test "monitor doesn't cascade" + (nm (ev "P = spawn(fun () -> exit(boom) end), monitor(process, P), receive {'DOWN', _, _, _, _} -> alive end")) + "alive") + +;; ── try / catch / of / after ───────────────────────────────── +(er-eval-test "try plain" + (ev "try 1 + 2 catch _ -> oops end") 3) + +(er-eval-test "try throw caught" + (nm (ev "try throw(boom) catch throw:X -> X end")) "boom") +(er-eval-test "try error caught" + (nm (ev "try error(crash) catch error:X -> X end")) "crash") +(er-eval-test "try exit caught" + (nm (ev "try exit(quit) catch exit:X -> X end")) "quit") + +(er-eval-test "default class is throw" + (nm (ev "try throw(bye) catch X -> X end")) "bye") +(er-eval-test "default class doesn't catch error" + (do + (ev "P = spawn(fun () -> try error(crash) catch X -> X end end), receive after 0 -> ok end") + (nm (er-proc-field (er-mk-pid 1) :exit-reason))) + "crash") + +;; of clauses +(er-eval-test "try of single" + (ev "try 42 of N -> N * 2 catch _ -> 0 end") 84) +(er-eval-test "try of multi" + (nm (ev "try ok of ok -> matched; _ -> nope catch _ -> oops end")) + "matched") +(er-eval-test "try of fallthrough" + (nm (ev "try x of ok -> a; error -> b; _ -> default catch _ -> oops end")) + "default") +(er-eval-test "try of with guard" + (nm (ev "try 5 of N when N > 0 -> pos; _ -> nonneg catch _ -> oops end")) + "pos") + +;; after clause +(er-eval-test "after on success" + (do (er-io-flush!) + (ev "try 7 after io:format(\"a\") end") + (er-io-buffer-content)) + "a") +(er-eval-test "after on caught" + (do (er-io-flush!) + (ev "try throw(b) catch throw:_ -> caught after io:format(\"x\") end") + (er-io-buffer-content)) + "x") +(er-eval-test "after returns body value" + (ev "try 99 after 0 end") 99) +(er-eval-test "try preserves catch result" + (nm (ev "try throw(x) catch throw:_ -> recovered after 0 end")) + "recovered") + +;; nested try +(er-eval-test "try nested catch outer" + (nm (ev "try (try throw(inner) catch error:_ -> bad end) catch throw:X -> X end")) + "inner") +(er-eval-test "try nested catch inner" + (nm (ev "try (try throw(inner) catch throw:X -> X end) catch _ -> outer end")) + "inner") + +;; class re-raise on no-match +(er-eval-test "throw without catch-throw escapes" + (do + (ev "P = spawn(fun () -> try throw(bye) catch error:_ -> nope end end), receive after 0 -> ok end") + (let ((reason (er-proc-field (er-mk-pid 1) :exit-reason))) + (and (er-tuple? reason) (nm (nth (get reason :elements) 0))))) + "nocatch") + +;; multi-clause catch +(er-eval-test "multi-clause catch picks throw" + (nm (ev "try throw(a) catch error:X -> e; throw:X -> t; exit:X -> x end")) + "t") +(er-eval-test "multi-clause catch picks exit" + (nm (ev "try exit(a) catch error:X -> e; throw:X -> t; exit:X -> x end")) + "x") + +;; ── modules: -module(M)., M:F/N cross-module calls ───────────── +(er-eval-test "load module returns name" + (nm (erlang-load-module "-module(m1). foo() -> 42.")) + "m1") + +(er-eval-test "cross-module zero-arity" + (do + (erlang-load-module "-module(m2). val() -> 7.") + (ev "m2:val()")) + 7) + +(er-eval-test "cross-module n-ary" + (do + (erlang-load-module "-module(m3). add(X, Y) -> X + Y.") + (ev "m3:add(3, 4)")) + 7) + +(er-eval-test "module recursive fn" + (do + (erlang-load-module "-module(m4). fact(0) -> 1; fact(N) -> N * fact(N-1).") + (ev "m4:fact(6)")) + 720) + +(er-eval-test "module sibling calls" + (do + (erlang-load-module "-module(m5). a(X) -> b(X) + 1. b(X) -> X * 10.") + (ev "m5:a(5)")) + 51) + +(er-eval-test "module multi-arity" + (do + (erlang-load-module + "-module(m6). f(X) -> X. f(X, Y) -> X + Y. f(X, Y, Z) -> X * Y + Z.") + (ev "{m6:f(1), m6:f(2, 3), m6:f(2, 3, 4)}")) + (er-mk-tuple (list 1 5 10))) + +(er-eval-test "module pattern match clauses" + (do + (erlang-load-module + "-module(m7). check(0) -> zero; check(N) when N > 0 -> pos; check(_) -> neg.") + (nm (ev "m7:check(-3)"))) + "neg") + +(er-eval-test "cross-module call within module" + (do + (erlang-load-module "-module(util1). dbl(X) -> X * 2.") + (erlang-load-module "-module(util2). quad(X) -> util1:dbl(X) * 2.") + (ev "util2:quad(5)")) + 20) + +(er-eval-test "module undefined fn raises" + (do + (erlang-load-module "-module(m8). foo() -> 1.") + (er-io-flush!) + (ev "P = spawn(fun () -> m8:bar() end), receive after 0 -> ok end") + (let ((reason (er-proc-field (er-mk-pid 1) :exit-reason))) + (and (er-tuple? reason) (nm (nth (get reason :elements) 0))))) + "undef") + +(er-eval-test "module function used in spawn" + (do + (erlang-load-module "-module(m9). work(P) -> P ! done.") + (ev "Me = self(), spawn(fun () -> m9:work(Me) end), receive done -> ok end")) + (er-mk-atom "ok")) + +;; ── gen_server (OTP-lite) ────────────────────────────────────── +(do + (er-load-gen-server!) + (erlang-load-module + "-module(ctr). + init(N) -> {ok, N}. + handle_call(get, _F, S) -> {reply, S, S}. + handle_call({set, V}, _F, _S) -> {reply, ok, V}. + handle_call({add, K}, _F, S) -> {reply, S + K, S + K}. + handle_cast(inc, S) -> {noreply, S + 1}. + handle_cast(dec, S) -> {noreply, S - 1}. + handle_cast({add, K}, S) -> {noreply, S + K}. + handle_info(_M, S) -> {noreply, S}.") + nil) + +(er-eval-test "gen_server start + call get" + (ev "P = gen_server:start_link(ctr, 10), gen_server:call(P, get)") + 10) + +(er-eval-test "gen_server cast then call" + (ev "P = gen_server:start_link(ctr, 0), gen_server:cast(P, inc), gen_server:cast(P, inc), gen_server:cast(P, inc), gen_server:call(P, get)") + 3) + +(er-eval-test "gen_server call returns reply" + (ev "P = gen_server:start_link(ctr, 5), gen_server:call(P, {add, 7})") + 12) + +(er-eval-test "gen_server state mutation" + (ev "P = gen_server:start_link(ctr, 5), gen_server:call(P, {set, 99}), gen_server:call(P, get)") + 99) + +(er-eval-test "gen_server stop returns ok" + (nm (ev "P = gen_server:start_link(ctr, 0), gen_server:stop(P)")) + "ok") + +(er-eval-test "gen_server cast returns ok immediately" + (nm (ev "P = gen_server:start_link(ctr, 0), gen_server:cast(P, inc)")) + "ok") + +(er-eval-test "gen_server multi-state mutations" + (ev "P = gen_server:start_link(ctr, 0), gen_server:cast(P, {add, 100}), gen_server:cast(P, dec), gen_server:cast(P, dec), gen_server:call(P, get)") + 98) + +;; Stack server — exercises a different state shape. +(do + (erlang-load-module + "-module(stk). + init(_) -> {ok, []}. + handle_call(pop, _F, []) -> {reply, empty, []}; + handle_call(pop, _F, [H | T]) -> {reply, {ok, H}, T}; + handle_call(peek, _F, []) -> {reply, empty, []}; + handle_call(peek, _F, [H | T]) -> {reply, {ok, H}, [H | T]}; + handle_call(size, _F, S) -> {reply, length(S), S}. + handle_cast({push, V}, S) -> {noreply, [V | S]}. + handle_info(_M, S) -> {noreply, S}.") + nil) + +(er-eval-test "stack push/pop" + (ev "P = gen_server:start_link(stk, ignored), gen_server:cast(P, {push, 1}), gen_server:cast(P, {push, 2}), gen_server:cast(P, {push, 3}), gen_server:call(P, size)") + 3) + +(er-eval-test "stack lifo" + (ev "P = gen_server:start_link(stk, ignored), gen_server:cast(P, {push, 1}), gen_server:cast(P, {push, 2}), gen_server:cast(P, {push, 3}), {ok, V} = gen_server:call(P, pop), V") + 3) + +(er-eval-test "stack empty pop" + (nm (ev "P = gen_server:start_link(stk, ignored), gen_server:call(P, pop)")) + "empty") + +;; ── supervisor (one-for-one) ──────────────────────────────────── +(do + (er-load-supervisor!) + (erlang-load-module + "-module(echoer). + start() -> spawn(fun () -> echoer:loop() end). + loop() -> + receive + {ping, From} -> From ! pong, echoer:loop(); + die -> exit(killed) + end.") + nil) + +(er-eval-test "sup starts children" + (do + (erlang-load-module + "-module(sup1). init(_) -> {ok, [{w1, fun () -> echoer:start() end}]}.") + (ev "Sup = supervisor:start_link(sup1, []), receive after 5 -> ok end, length(supervisor:which_children(Sup))")) + 1) + +(er-eval-test "sup multiple children" + (do + (erlang-load-module + "-module(sup2). + init(_) -> {ok, [ + {w1, fun () -> echoer:start() end}, + {w2, fun () -> echoer:start() end}, + {w3, fun () -> echoer:start() end} + ]}.") + (ev "Sup = supervisor:start_link(sup2, []), receive after 5 -> ok end, length(supervisor:which_children(Sup))")) + 3) + +(er-eval-test "sup child responds" + (do + (erlang-load-module + "-module(sup3). init(_) -> {ok, [{w1, fun () -> echoer:start() end}]}.") + (nm (ev "Sup = supervisor:start_link(sup3, []), receive after 5 -> ok end, [{_, _, P1} | _] = supervisor:which_children(Sup), P1 ! {ping, self()}, receive pong -> ok end"))) + "ok") + +(er-eval-test "sup restarts on exit" + (do + (erlang-load-module + "-module(sup4). init(_) -> {ok, [{w1, fun () -> echoer:start() end}]}.") + (nm + (ev "Sup = supervisor:start_link(sup4, []), receive after 5 -> ok end, [{_, _, P1} | _] = supervisor:which_children(Sup), P1 ! die, receive after 5 -> ok end, [{_, _, P2} | _] = supervisor:which_children(Sup), P1 =/= P2"))) + "true") + +(er-eval-test "sup restarted child works" + (do + (erlang-load-module + "-module(sup5). init(_) -> {ok, [{w1, fun () -> echoer:start() end}]}.") + (nm + (ev "Sup = supervisor:start_link(sup5, []), receive after 5 -> ok end, [{_, _, P1} | _] = supervisor:which_children(Sup), P1 ! die, receive after 5 -> ok end, [{_, _, P2} | _] = supervisor:which_children(Sup), P2 ! {ping, self()}, receive pong -> ok end"))) + "ok") + +(er-eval-test "sup one-for-one isolates failures" + (do + (erlang-load-module + "-module(sup6). + init(_) -> {ok, [ + {w1, fun () -> echoer:start() end}, + {w2, fun () -> echoer:start() end} + ]}.") + (nm + (ev "Sup = supervisor:start_link(sup6, []), receive after 5 -> ok end, [{_, _, P1}, {_, _, P2}] = supervisor:which_children(Sup), P1 ! die, receive after 5 -> ok end, [{_, _, _NewP1}, {_, _, P2Again}] = supervisor:which_children(Sup), P2 =:= P2Again"))) + "true") + +(er-eval-test "sup stop" + (nm + (do + (erlang-load-module + "-module(sup7). init(_) -> {ok, [{w1, fun () -> echoer:start() end}]}.") + (ev "Sup = supervisor:start_link(sup7, []), receive after 5 -> ok end, supervisor:stop(Sup)"))) + "ok") + +;; ── register / whereis / registered ───────────────────────────── +(er-eval-test "register returns true" + (nm (ev "register(me, self())")) "true") + +(er-eval-test "whereis registered self" + (nm (ev "register(me, self()), Pid = whereis(me), if Pid =:= self() -> matched; true -> nope end")) + "matched") + +(er-eval-test "whereis undefined" + (nm (ev "whereis(no_such)")) "undefined") + +(er-eval-test "send via registered atom" + (nm (ev "register(srv, self()), srv ! hello, receive M -> M end")) + "hello") + +(er-eval-test "send to spawned registered" + (nm + (ev "Me = self(), P = spawn(fun () -> receive {From, X} -> From ! {got, X} end end), register(child, P), child ! {Me, payload}, receive {got, V} -> V end")) + "payload") + +(er-eval-test "unregister returns true" + (nm (ev "register(a, self()), unregister(a)")) "true") + +(er-eval-test "unregister then whereis" + (nm (ev "register(a, self()), unregister(a), whereis(a)")) + "undefined") + +(er-eval-test "registered/0 lists names" + (ev "register(a, self()), register(b, self()), register(c, self()), length(registered())") + 3) + +(er-eval-test "register dup raises" + (do + (ev "P = spawn(fun () -> register(d, self()), register(d, self()) end), receive after 0 -> ok end") + (let ((reason (er-proc-field (er-mk-pid 1) :exit-reason))) + (nm (if (er-atom? reason) reason (nth (get reason :elements) 0))))) + "badarg") + +(er-eval-test "unregister missing raises" + (do + (ev "P = spawn(fun () -> unregister(no_such) end), receive after 0 -> ok end") + (let ((reason (er-proc-field (er-mk-pid 1) :exit-reason))) + (nm (if (er-atom? reason) reason (nth (get reason :elements) 0))))) + "badarg") + +(er-eval-test "dead process auto-unregisters" + ;; Register a child while it's alive (still in receive). Send `die` so + ;; it exits. After scheduler drains, whereis should return undefined. + (nm + (ev "P = spawn(fun () -> receive die -> exit(killed) end end), register(was_alive, P), P ! die, receive after 5 -> ok end, whereis(was_alive)")) + "undefined") + +(er-eval-test "send to unregistered name raises" + (do + (ev "P = spawn(fun () -> no_such ! oops end), receive after 0 -> ok end") + (let ((reason (er-proc-field (er-mk-pid 1) :exit-reason))) + (nm (if (er-atom? reason) reason (nth (get reason :elements) 0))))) + "badarg") + +;; ── list comprehensions ─────────────────────────────────────── +(er-eval-test "lc map double" + (ev "hd([X * 2 || X <- [1, 2, 3]])") 2) +(er-eval-test "lc map sum" + (ev "lists:foldl(fun (X, Acc) -> X + Acc end, 0, [X * 2 || X <- [1, 2, 3]])") + 12) +(er-eval-test "lc length" + (ev "length([X || X <- [1, 2, 3, 4, 5]])") 5) +(er-eval-test "lc filter sum" + (ev "lists:foldl(fun (X, Acc) -> X + Acc end, 0, [X || X <- [1, 2, 3, 4, 5], X rem 2 =:= 0])") + 6) +(er-eval-test "lc filter only" + (ev "length([X || X <- [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], X > 5])") + 5) +(er-eval-test "lc empty source" + (get (ev "[X || X <- []]") :tag) "nil") +(er-eval-test "lc all filtered" + (get (ev "[X || X <- [1, 2, 3], X > 100]") :tag) "nil") +(er-eval-test "lc cartesian length" + (ev "length([{X, Y} || X <- [1, 2, 3], Y <- [a, b]])") + 6) +(er-eval-test "lc pattern match" + (ev "lists:foldl(fun (X, Acc) -> X + Acc end, 0, [V || {ok, V} <- [{ok, 1}, {error, x}, {ok, 2}, {ok, 3}]])") + 6) +(er-eval-test "lc nested generators" + (ev "length([{X, Y} || X <- [1, 2, 3], Y <- [10, 20, 30], X + Y > 12])") + 7) +(er-eval-test "lc squares" + (ev "lists:foldl(fun (X, Acc) -> X + Acc end, 0, [X*X || X <- [1, 2, 3, 4, 5]])") + 55) +;; First {ok, X} tuple: head of [{ok,a}, {ok,b}] is {ok, a}. +(er-eval-test "lc tuple capture" + (nm (nth (get (get (ev "[{ok, X} || X <- [a, b]]") :head) :elements) 0)) + "ok") + +;; ── binary literals / patterns ──────────────────────────────── +(er-eval-test "binary tag" + (get (ev "<<>>") :tag) "binary") +(er-eval-test "is_binary empty" (nm (ev "is_binary(<<>>)")) "true") +(er-eval-test "is_binary 3 bytes" + (nm (ev "is_binary(<<1, 2, 3>>)")) "true") +(er-eval-test "is_binary list" (nm (ev "is_binary([1, 2])")) "false") +(er-eval-test "byte_size 0" (ev "byte_size(<<>>)") 0) +(er-eval-test "byte_size 3" (ev "byte_size(<<1, 2, 3>>)") 3) +(er-eval-test "byte_size 16-bit" (ev "byte_size(<<256:16>>)") 2) +(er-eval-test "byte_size 32-bit" (ev "byte_size(<<999999:32>>)") 4) + +;; Match +(er-eval-test "match single byte" + (ev "<> = <<7>>, X") 7) +(er-eval-test "match X:8" + (ev "<> = <<200>>, X") 200) +(er-eval-test "match 16-bit decode" + (ev "<> = <<1, 0>>, X") 256) +(er-eval-test "match 16-bit hi byte" + (ev "<> = <<2, 1>>, X") 513) +(er-eval-test "match A:8 B:16" + (ev "<> = <<1, 0, 2>>, A + B") 3) +(er-eval-test "match three 8-bit" + (ev "<> = <<1, 2, 3>>, A + B + C") 6) + +;; Tail binary +(er-eval-test "tail rest size" + (ev "<<_:8, Rest/binary>> = <<1, 2, 3, 4>>, byte_size(Rest)") 3) +(er-eval-test "tail rest content" + (ev "<<_:8, Rest/binary>> = <<1, 2, 3, 4>>, <> = Rest, X") 2) + +;; Match failure +(er-eval-test "size mismatch fails" + (do + (ev "P = spawn(fun () -> <> = <<1>>, ok end), receive after 0 -> ok end") + (let ((reason (er-proc-field (er-mk-pid 1) :exit-reason))) + (cond + (er-tuple? reason) (nm (nth (get reason :elements) 0)) + (er-atom? reason) (get reason :name) + :else nil))) + "badmatch") + +;; Equality +(er-eval-test "binary =:= self" + (nm (ev "B = <<1, 2, 3>>, B =:= B")) "true") +(er-eval-test "binary =:= same" + (nm (ev "<<1, 2>> =:= <<1, 2>>")) "true") +(er-eval-test "binary =/= different" + (nm (ev "<<1, 2>> =:= <<1, 3>>")) "false") + +;; Construction with computed value +(er-eval-test "build with var" + (ev "X = 42, byte_size(<>)") 1) +(er-eval-test "build with size var" + (ev "X = 7, byte_size(<>)") 2) + +;; ── ETS-lite ────────────────────────────────────────────────── +(er-eval-test "ets:new returns name" + (nm (ev "ets:new(t1, [set])")) "t1") +(er-eval-test "ets:insert returns true" + (nm (ev "T = ets:new(t2, [set]), ets:insert(T, {foo, 1})")) "true") +(er-eval-test "ets:lookup hit" + (ev "T = ets:new(t3, [set]), ets:insert(T, {foo, 42}), [{foo, V}] = ets:lookup(T, foo), V") + 42) +(er-eval-test "ets:lookup miss returns []" + (get (ev "T = ets:new(t4, [set]), ets:lookup(T, no_such)") :tag) "nil") +(er-eval-test "ets:insert replaces (set semantics)" + (ev "T = ets:new(t5, [set]), ets:insert(T, {x, 1}), ets:insert(T, {x, 2}), ets:insert(T, {x, 3}), [{x, V}] = ets:lookup(T, x), V") + 3) +(er-eval-test "ets:info size grows" + (ev "T = ets:new(t6, [set]), ets:insert(T, {a, 1}), ets:insert(T, {b, 2}), ets:insert(T, {c, 3}), ets:info(T, size)") + 3) +(er-eval-test "ets:info size after delete" + (ev "T = ets:new(t7, [set]), ets:insert(T, {a, 1}), ets:insert(T, {b, 2}), ets:delete(T, a), ets:info(T, size)") + 1) +(er-eval-test "ets:tab2list length" + (ev "T = ets:new(t8, [set]), ets:insert(T, {a, 1}), ets:insert(T, {b, 2}), ets:insert(T, {c, 3}), length(ets:tab2list(T))") + 3) +(er-eval-test "ets:delete table returns true" + (nm (ev "T = ets:new(t9, [set]), ets:delete(T)")) "true") +(er-eval-test "ets:lookup after table delete" + (do + (ev "P = spawn(fun () -> T = ets:new(t10, [set]), ets:delete(T), ets:lookup(T, x) end), receive after 0 -> ok end") + (let ((reason (er-proc-field (er-mk-pid 1) :exit-reason))) + (cond + (er-atom? reason) (get reason :name) + :else (nm reason)))) + "badarg") + +;; Sum a column via lookup chain. +(er-eval-test "ets aggregate" + (ev "T = ets:new(t11, [set]), ets:insert(T, {a, 10}), ets:insert(T, {b, 20}), ets:insert(T, {c, 30}), [{a, A}] = ets:lookup(T, a), [{b, B}] = ets:lookup(T, b), [{c, C}] = ets:lookup(T, c), A + B + C") + 60) + +;; Tuple key (non-atom). +(er-eval-test "ets tuple key" + (nm + (ev "T = ets:new(t12, [set]), ets:insert(T, {{x, 1}, hello}), [{{x, 1}, V}] = ets:lookup(T, {x, 1}), V")) + "hello") + +;; Tables are independent. +(er-eval-test "ets two tables independent" + (ev "T1 = ets:new(t13, [set]), T2 = ets:new(t14, [set]), ets:insert(T1, {x, 1}), ets:insert(T2, {x, 99}), [{x, A}] = ets:lookup(T1, x), [{x, B}] = ets:lookup(T2, x), A + B") + 100) + +;; ── more BIFs ───────────────────────────────────────────────── +(er-eval-test "abs neg" (ev "abs(-7)") 7) +(er-eval-test "abs pos" (ev "abs(42)") 42) +(er-eval-test "abs zero" (ev "abs(0)") 0) + +(er-eval-test "min" (ev "min(3, 5)") 3) +(er-eval-test "min equal" (ev "min(7, 7)") 7) +(er-eval-test "max" (ev "max(3, 5)") 5) +(er-eval-test "max neg" (ev "max(-10, -2)") -2) + +(er-eval-test "tuple_to_list head" + (nm (ev "hd(tuple_to_list({a, b, c}))")) "a") +(er-eval-test "tuple_to_list len" + (ev "length(tuple_to_list({1, 2, 3, 4, 5}))") 5) +(er-eval-test "list_to_tuple roundtrip" + (ev "tuple_size(list_to_tuple([10, 20, 30]))") 3) + +(er-eval-test "integer_to_list" (ev "integer_to_list(42)") "42") +(er-eval-test "integer_to_list neg" (ev "integer_to_list(-99)") "-99") +(er-eval-test "list_to_integer" (ev "list_to_integer(\"123\")") 123) +(er-eval-test "list_to_integer roundtrip" + (ev "list_to_integer(integer_to_list(7))") 7) + +(er-eval-test "is_function fun" + (nm (ev "F = fun (X) -> X end, is_function(F)")) "true") +(er-eval-test "is_function not" + (nm (ev "is_function(42)")) "false") +(er-eval-test "is_function arity match" + (nm (ev "F = fun (X, Y) -> X + Y end, is_function(F, 2)")) "true") +(er-eval-test "is_function arity mismatch" + (nm (ev "F = fun (X) -> X end, is_function(F, 5)")) "false") + +;; lists module +(er-eval-test "lists:seq 1..5" + (ev "length(lists:seq(1, 5))") 5) +(er-eval-test "lists:seq head" + (ev "hd(lists:seq(10, 20))") 10) +(er-eval-test "lists:seq sum" + (ev "lists:sum(lists:seq(1, 100))") 5050) +(er-eval-test "lists:seq with step" + (ev "length(lists:seq(0, 20, 2))") 11) +(er-eval-test "lists:seq empty" + (get (ev "lists:seq(5, 1)") :tag) "nil") + +(er-eval-test "lists:sum empty" (ev "lists:sum([])") 0) +(er-eval-test "lists:sum 5" + (ev "lists:sum([1, 2, 3, 4, 5])") 15) + +(er-eval-test "lists:nth 1" (ev "lists:nth(1, [10, 20, 30])") 10) +(er-eval-test "lists:nth mid" + (nm (ev "lists:nth(2, [a, b, c])")) "b") +(er-eval-test "lists:last" + (nm (ev "lists:last([a, b, c, d])")) "d") +(er-eval-test "lists:last single" (ev "lists:last([42])") 42) + +(er-eval-test "lists:member yes" + (nm (ev "lists:member(3, [1, 2, 3, 4])")) "true") +(er-eval-test "lists:member no" + (nm (ev "lists:member(99, [1, 2, 3])")) "false") + +(er-eval-test "lists:append" + (ev "length(lists:append([1, 2], [3, 4, 5]))") 5) + +(er-eval-test "lists:filter" + (ev "length(lists:filter(fun (X) -> X > 2 end, [1, 2, 3, 4, 5]))") 3) +(er-eval-test "lists:filter sum" + (ev "lists:sum(lists:filter(fun (X) -> X rem 2 =:= 0 end, lists:seq(1, 20)))") 110) + +(er-eval-test "lists:any false" + (nm (ev "lists:any(fun (X) -> X > 100 end, [1, 2, 3])")) "false") +(er-eval-test "lists:any true" + (nm (ev "lists:any(fun (X) -> X > 2 end, [1, 2, 3])")) "true") +(er-eval-test "lists:all true" + (nm (ev "lists:all(fun (X) -> X > 0 end, [1, 2, 3])")) "true") +(er-eval-test "lists:all false" + (nm (ev "lists:all(fun (X) -> X > 1 end, [1, 2, 3])")) "false") + +(er-eval-test "lists:duplicate len" + (ev "length(lists:duplicate(5, foo))") 5) +(er-eval-test "lists:duplicate val" + (nm (ev "hd(lists:duplicate(3, marker))")) "marker") + +(define + er-eval-test-summary + (str "eval " er-eval-test-pass "/" er-eval-test-count)) diff --git a/lib/erlang/tests/programs/bank.sx b/lib/erlang/tests/programs/bank.sx new file mode 100644 index 00000000..a86b1f6d --- /dev/null +++ b/lib/erlang/tests/programs/bank.sx @@ -0,0 +1,159 @@ +;; Bank account server — stateful process, balance threaded through +;; recursive loop. Handles {deposit, Amt, From}, {withdraw, Amt, From}, +;; {balance, From}, stop. Tests stateful process patterns. + +(define er-bank-test-count 0) +(define er-bank-test-pass 0) +(define er-bank-test-fails (list)) + +(define + er-bank-test + (fn + (name actual expected) + (set! er-bank-test-count (+ er-bank-test-count 1)) + (if + (= actual expected) + (set! er-bank-test-pass (+ er-bank-test-pass 1)) + (append! er-bank-test-fails {:actual actual :expected expected :name name})))) + +(define bank-ev erlang-eval-ast) + +;; Server fun shared by all tests — threaded via the program string. +(define + er-bank-server-src + "Server = fun (Balance) -> + receive + {deposit, Amt, From} -> From ! ok, Server(Balance + Amt); + {withdraw, Amt, From} -> + if Amt > Balance -> From ! insufficient, Server(Balance); + true -> From ! ok, Server(Balance - Amt) + end; + {balance, From} -> From ! Balance, Server(Balance); + stop -> ok + end + end") + +;; Open account, deposit, check balance. +(er-bank-test + "deposit 100 -> balance 100" + (bank-ev + (str + er-bank-server-src + ", Me = self(), + Bank = spawn(fun () -> Server(0) end), + Bank ! {deposit, 100, Me}, + receive ok -> ok end, + Bank ! {balance, Me}, + receive B -> Bank ! stop, B end")) + 100) + +;; Multiple deposits accumulate. +(er-bank-test + "deposits accumulate" + (bank-ev + (str + er-bank-server-src + ", Me = self(), + Bank = spawn(fun () -> Server(0) end), + Bank ! {deposit, 50, Me}, receive ok -> ok end, + Bank ! {deposit, 25, Me}, receive ok -> ok end, + Bank ! {deposit, 10, Me}, receive ok -> ok end, + Bank ! {balance, Me}, + receive B -> Bank ! stop, B end")) + 85) + +;; Withdraw within balance succeeds; insufficient gets rejected. +(er-bank-test + "withdraw within balance" + (bank-ev + (str + er-bank-server-src + ", Me = self(), + Bank = spawn(fun () -> Server(100) end), + Bank ! {withdraw, 30, Me}, receive ok -> ok end, + Bank ! {balance, Me}, + receive B -> Bank ! stop, B end")) + 70) + +(er-bank-test + "withdraw insufficient" + (get + (bank-ev + (str + er-bank-server-src + ", Me = self(), + Bank = spawn(fun () -> Server(20) end), + Bank ! {withdraw, 100, Me}, + receive R -> Bank ! stop, R end")) + :name) + "insufficient") + +;; State preserved across an insufficient withdrawal. +(er-bank-test + "state preserved on rejection" + (bank-ev + (str + er-bank-server-src + ", Me = self(), + Bank = spawn(fun () -> Server(50) end), + Bank ! {withdraw, 1000, Me}, receive _ -> ok end, + Bank ! {balance, Me}, + receive B -> Bank ! stop, B end")) + 50) + +;; Mixed deposits and withdrawals. +(er-bank-test + "mixed transactions" + (bank-ev + (str + er-bank-server-src + ", Me = self(), + Bank = spawn(fun () -> Server(100) end), + Bank ! {deposit, 50, Me}, receive ok -> ok end, + Bank ! {withdraw, 30, Me}, receive ok -> ok end, + Bank ! {deposit, 10, Me}, receive ok -> ok end, + Bank ! {withdraw, 5, Me}, receive ok -> ok end, + Bank ! {balance, Me}, + receive B -> Bank ! stop, B end")) + 125) + +;; Server.stop terminates the bank cleanly — main can verify by +;; sending stop and then exiting normally. +(er-bank-test + "server stops cleanly" + (get + (bank-ev + (str + er-bank-server-src + ", Me = self(), + Bank = spawn(fun () -> Server(0) end), + Bank ! stop, + done")) + :name) + "done") + +;; Two clients sharing one bank — interleaved transactions. +(er-bank-test + "two clients share bank" + (bank-ev + (str + er-bank-server-src + ", Me = self(), + Bank = spawn(fun () -> Server(0) end), + Client = fun (Amt) -> + spawn(fun () -> + Bank ! {deposit, Amt, self()}, + receive ok -> Me ! deposited end + end) + end, + Client(40), + Client(60), + receive deposited -> ok end, + receive deposited -> ok end, + Bank ! {balance, Me}, + receive B -> Bank ! stop, B end")) + 100) + +(define + er-bank-test-summary + (str "bank " er-bank-test-pass "/" er-bank-test-count)) diff --git a/lib/erlang/tests/programs/echo.sx b/lib/erlang/tests/programs/echo.sx new file mode 100644 index 00000000..d8afb71e --- /dev/null +++ b/lib/erlang/tests/programs/echo.sx @@ -0,0 +1,140 @@ +;; Echo server — minimal classic Erlang server. Receives {From, Msg} +;; and sends Msg back to From, then loops. `stop` ends the server. + +(define er-echo-test-count 0) +(define er-echo-test-pass 0) +(define er-echo-test-fails (list)) + +(define + er-echo-test + (fn + (name actual expected) + (set! er-echo-test-count (+ er-echo-test-count 1)) + (if + (= actual expected) + (set! er-echo-test-pass (+ er-echo-test-pass 1)) + (append! er-echo-test-fails {:actual actual :expected expected :name name})))) + +(define echo-ev erlang-eval-ast) + +(define + er-echo-server-src + "EchoSrv = fun () -> + Loop = fun () -> + receive + {From, Msg} -> From ! Msg, Loop(); + stop -> ok + end + end, + Loop() + end") + +;; Single round-trip with an atom. +(er-echo-test + "atom round-trip" + (get + (echo-ev + (str + er-echo-server-src + ", Me = self(), + Echo = spawn(EchoSrv), + Echo ! {Me, hello}, + receive R -> Echo ! stop, R end")) + :name) + "hello") + +;; Number round-trip. +(er-echo-test + "number round-trip" + (echo-ev + (str + er-echo-server-src + ", Me = self(), + Echo = spawn(EchoSrv), + Echo ! {Me, 42}, + receive R -> Echo ! stop, R end")) + 42) + +;; Tuple round-trip — pattern-match the reply to extract V. +(er-echo-test + "tuple round-trip" + (echo-ev + (str + er-echo-server-src + ", Me = self(), + Echo = spawn(EchoSrv), + Echo ! {Me, {ok, 7}}, + receive {ok, V} -> Echo ! stop, V end")) + 7) + +;; List round-trip. +(er-echo-test + "list round-trip" + (echo-ev + (str + er-echo-server-src + ", Me = self(), + Echo = spawn(EchoSrv), + Echo ! {Me, [1, 2, 3]}, + receive [H | _] -> Echo ! stop, H end")) + 1) + +;; Multiple sequential round-trips. +(er-echo-test + "three round-trips" + (echo-ev + (str + er-echo-server-src + ", Me = self(), + Echo = spawn(EchoSrv), + Echo ! {Me, 10}, A = receive Ra -> Ra end, + Echo ! {Me, 20}, B = receive Rb -> Rb end, + Echo ! {Me, 30}, C = receive Rc -> Rc end, + Echo ! stop, + A + B + C")) + 60) + +;; Two clients sharing one echo server. Each gets its own reply. +(er-echo-test + "two clients" + (get + (echo-ev + (str + er-echo-server-src + ", Me = self(), + Echo = spawn(EchoSrv), + Client = fun (Tag) -> + spawn(fun () -> + Echo ! {self(), Tag}, + receive R -> Me ! {got, R} end + end) + end, + Client(a), + Client(b), + receive {got, _} -> ok end, + receive {got, _} -> ok end, + Echo ! stop, + finished")) + :name) + "finished") + +;; Echo via io trace — verify each message round-trips through. +(er-echo-test + "trace 4 messages" + (do + (er-io-flush!) + (echo-ev + (str + er-echo-server-src + ", Me = self(), + Echo = spawn(EchoSrv), + Send = fun (V) -> Echo ! {Me, V}, receive R -> io:format(\"~p \", [R]) end end, + Send(1), Send(2), Send(3), Send(4), + Echo ! stop, + done")) + (er-io-buffer-content)) + "1 2 3 4 ") + +(define + er-echo-test-summary + (str "echo " er-echo-test-pass "/" er-echo-test-count)) diff --git a/lib/erlang/tests/programs/fib_server.sx b/lib/erlang/tests/programs/fib_server.sx new file mode 100644 index 00000000..4d97e912 --- /dev/null +++ b/lib/erlang/tests/programs/fib_server.sx @@ -0,0 +1,152 @@ +;; Fib server — long-lived process that computes fibonacci numbers on +;; request. Tests recursive function evaluation inside a server loop. + +(define er-fib-test-count 0) +(define er-fib-test-pass 0) +(define er-fib-test-fails (list)) + +(define + er-fib-test + (fn + (name actual expected) + (set! er-fib-test-count (+ er-fib-test-count 1)) + (if + (= actual expected) + (set! er-fib-test-pass (+ er-fib-test-pass 1)) + (append! er-fib-test-fails {:actual actual :expected expected :name name})))) + +(define fib-ev erlang-eval-ast) + +;; Fib + server-loop source. Standalone so each test can chain queries. +(define + er-fib-server-src + "Fib = fun (0) -> 0; (1) -> 1; (N) -> Fib(N-1) + Fib(N-2) end, + FibSrv = fun () -> + Loop = fun () -> + receive + {fib, N, From} -> From ! Fib(N), Loop(); + stop -> ok + end + end, + Loop() + end") + +;; Base cases. +(er-fib-test + "fib(0)" + (fib-ev + (str + er-fib-server-src + ", Me = self(), + Srv = spawn(FibSrv), + Srv ! {fib, 0, Me}, + receive R -> Srv ! stop, R end")) + 0) + +(er-fib-test + "fib(1)" + (fib-ev + (str + er-fib-server-src + ", Me = self(), + Srv = spawn(FibSrv), + Srv ! {fib, 1, Me}, + receive R -> Srv ! stop, R end")) + 1) + +;; Larger values. +(er-fib-test + "fib(10) = 55" + (fib-ev + (str + er-fib-server-src + ", Me = self(), + Srv = spawn(FibSrv), + Srv ! {fib, 10, Me}, + receive R -> Srv ! stop, R end")) + 55) + +(er-fib-test + "fib(15) = 610" + (fib-ev + (str + er-fib-server-src + ", Me = self(), + Srv = spawn(FibSrv), + Srv ! {fib, 15, Me}, + receive R -> Srv ! stop, R end")) + 610) + +;; Multiple sequential queries to one server. Sum to avoid dict-equality. +(er-fib-test + "sequential fib(5..8) sum" + (fib-ev + (str + er-fib-server-src + ", Me = self(), + Srv = spawn(FibSrv), + Srv ! {fib, 5, Me}, A = receive Ra -> Ra end, + Srv ! {fib, 6, Me}, B = receive Rb -> Rb end, + Srv ! {fib, 7, Me}, C = receive Rc -> Rc end, + Srv ! {fib, 8, Me}, D = receive Rd -> Rd end, + Srv ! stop, + A + B + C + D")) + 47) + +;; Verify Fib obeys the recurrence — fib(n) = fib(n-1) + fib(n-2). +(er-fib-test + "fib recurrence at n=12" + (fib-ev + (str + er-fib-server-src + ", Me = self(), + Srv = spawn(FibSrv), + Srv ! {fib, 10, Me}, A = receive Ra -> Ra end, + Srv ! {fib, 11, Me}, B = receive Rb -> Rb end, + Srv ! {fib, 12, Me}, C = receive Rc -> Rc end, + Srv ! stop, + C - (A + B)")) + 0) + +;; Two clients each get their own answer; main sums the results. +(er-fib-test + "two clients sum" + (fib-ev + (str + er-fib-server-src + ", Me = self(), + Srv = spawn(FibSrv), + Client = fun (N) -> + spawn(fun () -> + Srv ! {fib, N, self()}, + receive R -> Me ! {result, R} end + end) + end, + Client(7), + Client(9), + {result, A} = receive M1 -> M1 end, + {result, B} = receive M2 -> M2 end, + Srv ! stop, + A + B")) + 47) + +;; Trace queries via io-buffer. +(er-fib-test + "trace fib 0..6" + (do + (er-io-flush!) + (fib-ev + (str + er-fib-server-src + ", Me = self(), + Srv = spawn(FibSrv), + Ask = fun (N) -> Srv ! {fib, N, Me}, receive R -> io:format(\"~p \", [R]) end end, + Ask(0), Ask(1), Ask(2), Ask(3), Ask(4), Ask(5), Ask(6), + Srv ! stop, + done")) + (er-io-buffer-content)) + "0 1 1 2 3 5 8 ") + +(define + er-fib-test-summary + (str "fib " er-fib-test-pass "/" er-fib-test-count)) diff --git a/lib/erlang/tests/programs/ping_pong.sx b/lib/erlang/tests/programs/ping_pong.sx new file mode 100644 index 00000000..02b0283d --- /dev/null +++ b/lib/erlang/tests/programs/ping_pong.sx @@ -0,0 +1,127 @@ +;; Ping-pong program — two processes exchange N messages, then signal +;; main via separate `ping_done` / `pong_done` notifications. + +(define er-pp-test-count 0) +(define er-pp-test-pass 0) +(define er-pp-test-fails (list)) + +(define + er-pp-test + (fn + (name actual expected) + (set! er-pp-test-count (+ er-pp-test-count 1)) + (if + (= actual expected) + (set! er-pp-test-pass (+ er-pp-test-pass 1)) + (append! er-pp-test-fails {:actual actual :expected expected :name name})))) + +(define pp-ev erlang-eval-ast) + +;; Three rounds of ping-pong, then stop. Main receives ping_done and +;; pong_done in arrival order (Ping finishes first because Pong exits +;; only after receiving stop). +(define + er-pp-program + "Me = self(), + Pong = spawn(fun () -> + Loop = fun () -> + receive + {ping, From} -> From ! pong, Loop(); + stop -> Me ! pong_done + end + end, + Loop() + end), + Ping = fun (Target, K) -> + if K =:= 0 -> Target ! stop, Me ! ping_done; + true -> Target ! {ping, self()}, receive pong -> Ping(Target, K - 1) end + end + end, + spawn(fun () -> Ping(Pong, 3) end), + receive ping_done -> ok end, + receive pong_done -> both_done end") + +(er-pp-test + "ping-pong 3 rounds" + (get (pp-ev er-pp-program) :name) + "both_done") + +;; Count exchanges via io-buffer — each pong trip prints "p". +(er-pp-test + "ping-pong 5 rounds trace" + (do + (er-io-flush!) + (pp-ev + "Me = self(), + Pong = spawn(fun () -> + Loop = fun () -> + receive + {ping, From} -> io:format(\"p\"), From ! pong, Loop(); + stop -> Me ! pong_done + end + end, + Loop() + end), + Ping = fun (Target, K) -> + if K =:= 0 -> Target ! stop, Me ! ping_done; + true -> Target ! {ping, self()}, receive pong -> Ping(Target, K - 1) end + end + end, + spawn(fun () -> Ping(Pong, 5) end), + receive ping_done -> ok end, + receive pong_done -> ok end") + (er-io-buffer-content)) + "ppppp") + +;; Main → Pong directly (no Ping process). Main plays the ping role. +(er-pp-test + "main-as-pinger 4 rounds" + (pp-ev + "Me = self(), + Pong = spawn(fun () -> + Loop = fun () -> + receive + {ping, From} -> From ! pong, Loop(); + stop -> ok + end + end, + Loop() + end), + Go = fun (K) -> + if K =:= 0 -> Pong ! stop, K; + true -> Pong ! {ping, Me}, receive pong -> Go(K - 1) end + end + end, + Go(4)") + 0) + +;; Ensure the processes really interleave — inject an id into each +;; ping and check we get them all back via trace (the order is +;; deterministic under our sync scheduler). +(er-pp-test + "ids round-trip" + (do + (er-io-flush!) + (pp-ev + "Me = self(), + Pong = spawn(fun () -> + Loop = fun () -> + receive + {ping, From, Id} -> From ! {pong, Id}, Loop(); + stop -> ok + end + end, + Loop() + end), + Go = fun (K) -> + if K =:= 0 -> Pong ! stop, done; + true -> Pong ! {ping, Me, K}, receive {pong, RId} -> io:format(\"~p \", [RId]), Go(K - 1) end + end + end, + Go(4)") + (er-io-buffer-content)) + "4 3 2 1 ") + +(define + er-pp-test-summary + (str "ping-pong " er-pp-test-pass "/" er-pp-test-count)) diff --git a/lib/erlang/tests/programs/ring.sx b/lib/erlang/tests/programs/ring.sx new file mode 100644 index 00000000..2ef1f1cd --- /dev/null +++ b/lib/erlang/tests/programs/ring.sx @@ -0,0 +1,132 @@ +;; Ring program — N processes in a ring, token passes M times. +;; +;; Each process waits for {setup, Next} so main can tie the knot +;; (can't reference a pid before spawning it). Once wired, main +;; injects the first token; each process forwards decrementing K +;; until it hits 0, at which point it signals `done` to main. + +(define er-ring-test-count 0) +(define er-ring-test-pass 0) +(define er-ring-test-fails (list)) + +(define + er-ring-test + (fn + (name actual expected) + (set! er-ring-test-count (+ er-ring-test-count 1)) + (if + (= actual expected) + (set! er-ring-test-pass (+ er-ring-test-pass 1)) + (append! er-ring-test-fails {:actual actual :expected expected :name name})))) + +(define ring-ev erlang-eval-ast) + +(define + er-ring-program-3-6 + "Me = self(), + Spawner = fun () -> + receive {setup, Next} -> + Loop = fun () -> + receive + {token, 0, Parent} -> Parent ! done; + {token, K, Parent} -> Next ! {token, K-1, Parent}, Loop() + end + end, + Loop() + end + end, + P1 = spawn(Spawner), + P2 = spawn(Spawner), + P3 = spawn(Spawner), + P1 ! {setup, P2}, + P2 ! {setup, P3}, + P3 ! {setup, P1}, + P1 ! {token, 5, Me}, + receive done -> finished end") + +(er-ring-test + "ring N=3 M=6" + (get (ring-ev er-ring-program-3-6) :name) + "finished") + +;; Two-node ring — token bounces twice between P1 and P2. +(er-ring-test + "ring N=2 M=4" + (get (ring-ev + "Me = self(), + Spawner = fun () -> + receive {setup, Next} -> + Loop = fun () -> + receive + {token, 0, Parent} -> Parent ! done; + {token, K, Parent} -> Next ! {token, K-1, Parent}, Loop() + end + end, + Loop() + end + end, + P1 = spawn(Spawner), + P2 = spawn(Spawner), + P1 ! {setup, P2}, + P2 ! {setup, P1}, + P1 ! {token, 3, Me}, + receive done -> done end") :name) + "done") + +;; Single-node "ring" — P sends to itself M times. +(er-ring-test + "ring N=1 M=5" + (get (ring-ev + "Me = self(), + Spawner = fun () -> + receive {setup, Next} -> + Loop = fun () -> + receive + {token, 0, Parent} -> Parent ! finished_loop; + {token, K, Parent} -> Next ! {token, K-1, Parent}, Loop() + end + end, + Loop() + end + end, + P = spawn(Spawner), + P ! {setup, P}, + P ! {token, 4, Me}, + receive finished_loop -> ok end") :name) + "ok") + +;; Confirm the token really went around — count hops via io-buffer. +(er-ring-test + "ring N=3 M=9 hop count" + (do + (er-io-flush!) + (ring-ev + "Me = self(), + Spawner = fun () -> + receive {setup, Next} -> + Loop = fun () -> + receive + {token, 0, Parent} -> Parent ! done; + {token, K, Parent} -> + io:format(\"~p \", [K]), + Next ! {token, K-1, Parent}, + Loop() + end + end, + Loop() + end + end, + P1 = spawn(Spawner), + P2 = spawn(Spawner), + P3 = spawn(Spawner), + P1 ! {setup, P2}, + P2 ! {setup, P3}, + P3 ! {setup, P1}, + P1 ! {token, 8, Me}, + receive done -> done end") + (er-io-buffer-content)) + "8 7 6 5 4 3 2 1 ") + +(define + er-ring-test-summary + (str "ring " er-ring-test-pass "/" er-ring-test-count)) diff --git a/lib/erlang/tests/runtime.sx b/lib/erlang/tests/runtime.sx new file mode 100644 index 00000000..95c20dce --- /dev/null +++ b/lib/erlang/tests/runtime.sx @@ -0,0 +1,139 @@ +;; Erlang runtime tests — scheduler + process-record primitives. + +(define er-rt-test-count 0) +(define er-rt-test-pass 0) +(define er-rt-test-fails (list)) + +(define + er-rt-test + (fn + (name actual expected) + (set! er-rt-test-count (+ er-rt-test-count 1)) + (if + (= actual expected) + (set! er-rt-test-pass (+ er-rt-test-pass 1)) + (append! er-rt-test-fails {:actual actual :expected expected :name name})))) + +;; ── queue ───────────────────────────────────────────────────────── +(er-rt-test "queue empty len" (er-q-len (er-q-new)) 0) +(er-rt-test "queue empty?" (er-q-empty? (er-q-new)) true) + +(define q1 (er-q-new)) +(er-q-push! q1 "a") +(er-q-push! q1 "b") +(er-q-push! q1 "c") +(er-rt-test "queue push len" (er-q-len q1) 3) +(er-rt-test "queue empty? after push" (er-q-empty? q1) false) +(er-rt-test "queue peek" (er-q-peek q1) "a") +(er-rt-test "queue pop 1" (er-q-pop! q1) "a") +(er-rt-test "queue pop 2" (er-q-pop! q1) "b") +(er-rt-test "queue len after pops" (er-q-len q1) 1) +(er-rt-test "queue pop 3" (er-q-pop! q1) "c") +(er-rt-test "queue empty again" (er-q-empty? q1) true) +(er-rt-test "queue pop empty" (er-q-pop! q1) nil) + +;; Queue FIFO under interleaved push/pop +(define q2 (er-q-new)) +(er-q-push! q2 1) +(er-q-push! q2 2) +(er-q-pop! q2) +(er-q-push! q2 3) +(er-rt-test "queue interleave peek" (er-q-peek q2) 2) +(er-rt-test "queue to-list" (er-q-to-list q2) (list 2 3)) + +;; ── scheduler init ───────────────────────────────────────────── +(er-sched-init!) +(er-rt-test "sched process count 0" (er-sched-process-count) 0) +(er-rt-test "sched runnable count 0" (er-sched-runnable-count) 0) +(er-rt-test "sched current nil" (er-sched-current-pid) nil) + +;; ── pid allocation ───────────────────────────────────────────── +(define pa (er-pid-new!)) +(define pb (er-pid-new!)) +(er-rt-test "pid tag" (get pa :tag) "pid") +(er-rt-test "pid ids distinct" (= (er-pid-id pa) (er-pid-id pb)) false) +(er-rt-test "pid? true" (er-pid? pa) true) +(er-rt-test "pid? false" (er-pid? 42) false) +(er-rt-test + "pid-equal same" + (er-pid-equal? pa (er-mk-pid (er-pid-id pa))) + true) +(er-rt-test "pid-equal diff" (er-pid-equal? pa pb) false) + +;; ── process lifecycle ────────────────────────────────────────── +(er-sched-init!) +(define p1 (er-proc-new! {})) +(define p2 (er-proc-new! {})) +(er-rt-test "proc count 2" (er-sched-process-count) 2) +(er-rt-test "runnable count 2" (er-sched-runnable-count) 2) +(er-rt-test + "proc state runnable" + (er-proc-field (get p1 :pid) :state) + "runnable") +(er-rt-test + "proc mailbox empty" + (er-proc-mailbox-size (get p1 :pid)) + 0) +(er-rt-test + "proc lookup" + (er-pid-equal? (get (er-proc-get (get p1 :pid)) :pid) (get p1 :pid)) + true) +(er-rt-test "proc exists" (er-proc-exists? (get p1 :pid)) true) +(er-rt-test + "proc no-such-pid" + (er-proc-exists? (er-mk-pid 9999)) + false) + +;; runnable queue dequeue order +(er-rt-test + "dequeue first" + (er-pid-equal? (er-sched-next-runnable!) (get p1 :pid)) + true) +(er-rt-test + "dequeue second" + (er-pid-equal? (er-sched-next-runnable!) (get p2 :pid)) + true) +(er-rt-test "dequeue empty" (er-sched-next-runnable!) nil) + +;; current-pid get/set +(er-sched-set-current! (get p1 :pid)) +(er-rt-test + "current pid set" + (er-pid-equal? (er-sched-current-pid) (get p1 :pid)) + true) + +;; ── mailbox push ────────────────────────────────────────────── +(er-proc-mailbox-push! (get p1 :pid) {:tag "atom" :name "ping"}) +(er-proc-mailbox-push! (get p1 :pid) 42) +(er-rt-test "mailbox size 2" (er-proc-mailbox-size (get p1 :pid)) 2) + +;; ── field update ────────────────────────────────────────────── +(er-proc-set! (get p1 :pid) :state "waiting") +(er-rt-test + "proc state waiting" + (er-proc-field (get p1 :pid) :state) + "waiting") +(er-proc-set! (get p1 :pid) :trap-exit true) +(er-rt-test + "proc trap-exit" + (er-proc-field (get p1 :pid) :trap-exit) + true) + +;; ── fresh scheduler ends in clean state ─────────────────────── +(er-sched-init!) +(er-rt-test + "sched init resets count" + (er-sched-process-count) + 0) +(er-rt-test + "sched init resets queue" + (er-sched-runnable-count) + 0) +(er-rt-test + "sched init resets current" + (er-sched-current-pid) + nil) + +(define + er-rt-test-summary + (str "runtime " er-rt-test-pass "/" er-rt-test-count)) diff --git a/lib/erlang/transpile.sx b/lib/erlang/transpile.sx new file mode 100644 index 00000000..ac2bf562 --- /dev/null +++ b/lib/erlang/transpile.sx @@ -0,0 +1,1913 @@ +;; Erlang sequential evaluator — tree-walking interpreter over the +;; parser AST. Phase 2 of plans/erlang-on-sx.md. +;; +;; Entry points: +;; (erlang-eval-ast SRC) -- parse body, eval, return last value +;; (er-eval-expr NODE ENV) -- evaluate one AST node +;; (er-eval-body NODES ENV) -- evaluate a comma-sequence, return last +;; +;; Runtime values: +;; integers / floats -> SX number +;; atoms -> {:tag "atom" :name } +;; booleans -> atoms 'true' / 'false' +;; strings -> SX string (char-list semantics deferred) +;; empty list -> {:tag "nil"} +;; cons cell -> {:tag "cons" :head V :tail V} +;; tuple -> {:tag "tuple" :elements (list V ...)} +;; +;; Environment: mutable dict from variable name (string) to value. + +;; ── value constructors / predicates ──────────────────────────────── +(define er-mk-atom (fn (name) {:name name :tag "atom"})) +(define er-atom-true (er-mk-atom "true")) +(define er-atom-false (er-mk-atom "false")) +(define er-mk-nil (fn () {:tag "nil"})) +(define er-mk-cons (fn (h t) {:tag "cons" :head h :tail t})) +(define er-mk-tuple (fn (elems) {:tag "tuple" :elements elems})) +(define er-mk-binary (fn (bytes) {:tag "binary" :bytes bytes})) +(define er-binary? (fn (v) (er-is-tagged? v "binary"))) +(define er-bool (fn (b) (if b er-atom-true er-atom-false))) + +(define + er-is-tagged? + (fn (v tag) (and (= (type-of v) "dict") (= (get v :tag) tag)))) +(define er-atom? (fn (v) (er-is-tagged? v "atom"))) +(define er-nil? (fn (v) (er-is-tagged? v "nil"))) +(define er-cons? (fn (v) (er-is-tagged? v "cons"))) +(define er-tuple? (fn (v) (er-is-tagged? v "tuple"))) + +(define + er-is-atom-named? + (fn (v name) (and (er-atom? v) (= (get v :name) name)))) +(define er-truthy? (fn (v) (er-is-atom-named? v "true"))) + +;; ── environment ─────────────────────────────────────────────────── +(define er-env-new (fn () {})) + +(define + er-env-lookup + (fn + (env name) + (if + (dict-has? env name) + (get env name) + (error (str "Erlang: unbound variable '" name "'"))))) + +(define er-env-bind! (fn (env name val) (dict-set! env name val))) + +;; ── entry ───────────────────────────────────────────────────────── +(define + erlang-eval-ast + (fn + (src) + (let + ((st (er-state-make (er-tokenize src)))) + (let + ((body (er-parse-body st))) + (er-sched-init!) + (let + ((env (er-env-new))) + (let + ((main-fun + (er-mk-fun + (list + {:patterns (list) + :body body + :guards (list) + :name nil}) + env))) + (let + ((main-proc (er-proc-new! env))) + (dict-set! main-proc :initial-fun main-fun) + (er-sched-run-all!) + (let + ((main-pid (get main-proc :pid))) + (if + (not (= (er-proc-field main-pid :state) "dead")) + (error + "Erlang: deadlock — main process never terminated") + (er-proc-field main-pid :exit-result)))))))))) + +(define + er-eval-body + (fn + (exprs env) + (let + ((last (list nil))) + (for-each + (fn (i) (set-nth! last 0 (er-eval-expr (nth exprs i) env))) + (range 0 (len exprs))) + (nth last 0)))) + +;; ── dispatch ────────────────────────────────────────────────────── +(define + er-eval-expr + (fn + (node env) + (let + ((ty (get node :type))) + (cond + (= ty "integer") (parse-number (get node :value)) + (= ty "float") (parse-number (get node :value)) + (= ty "atom") (er-mk-atom (get node :value)) + (= ty "string") (get node :value) + (= ty "nil") (er-mk-nil) + (= ty "var") (er-eval-var node env) + (= ty "tuple") (er-eval-tuple node env) + (= ty "cons") (er-eval-cons node env) + (= ty "op") (er-eval-op node env) + (= ty "unop") (er-eval-unop node env) + (= ty "block") (er-eval-body (get node :exprs) env) + (= ty "if") (er-eval-if node env) + (= ty "case") (er-eval-case node env) + (= ty "call") (er-eval-call node env) + (= ty "fun") (er-eval-fun node env) + (= ty "send") (er-eval-send node env) + (= ty "receive") (er-eval-receive node env) + (= ty "try") (er-eval-try node env) + (= ty "lc") (er-eval-lc node env) + (= ty "binary") (er-eval-binary node env) + (= ty "match") (er-eval-match node env) + :else (error (str "Erlang eval: unsupported node type '" ty "'")))))) + +(define + er-eval-var + (fn + (node env) + (let + ((name (get node :name))) + (if + (= name "_") + (error "Erlang: '_' cannot be used as a value") + (er-env-lookup env name))))) + +(define + er-eval-tuple + (fn + (node env) + (let + ((out (list))) + (for-each + (fn + (i) + (append! out (er-eval-expr (nth (get node :elements) i) env))) + (range 0 (len (get node :elements)))) + (er-mk-tuple out)))) + +(define + er-eval-cons + (fn + (node env) + (er-mk-cons + (er-eval-expr (get node :head) env) + (er-eval-expr (get node :tail) env)))) + +;; ── match expression ───────────────────────────────────────────── +(define + er-eval-match + (fn + (node env) + (let + ((lhs (get node :lhs)) + (rhs-val (er-eval-expr (get node :rhs) env))) + (if + (er-match! lhs rhs-val env) + rhs-val + (error "Erlang: badmatch"))))) + +;; ── pattern matching ───────────────────────────────────────────── +;; Unifies PAT against VAL, binding fresh vars into ENV. +;; Returns true on success, false otherwise. On failure ENV may hold +;; partial bindings — callers trying multiple clauses must snapshot +;; ENV and restore it between attempts. +(define + er-match! + (fn + (pat val env) + (let + ((ty (get pat :type))) + (cond + (= ty "var") (er-match-var pat val env) + (= ty "integer") + (and (= (type-of val) "number") (= (parse-number (get pat :value)) val)) + (= ty "float") + (and (= (type-of val) "number") (= (parse-number (get pat :value)) val)) + (= ty "atom") (and (er-atom? val) (= (get val :name) (get pat :value))) + (= ty "string") + (and (= (type-of val) "string") (= val (get pat :value))) + (= ty "nil") (er-nil? val) + (= ty "tuple") (er-match-tuple pat val env) + (= ty "cons") (er-match-cons pat val env) + (= ty "binary") (er-match-binary pat val env) + :else (error (str "Erlang match: unsupported pattern type '" ty "'")))))) + +(define + er-match-var + (fn + (pat val env) + (let + ((name (get pat :name))) + (cond + (= name "_") true + (dict-has? env name) (er-equal? (get env name) val) + :else (do (er-env-bind! env name val) true))))) + +(define + er-match-tuple + (fn + (pat val env) + (and + (er-tuple? val) + (let + ((ps (get pat :elements)) (vs (get val :elements))) + (if (not (= (len ps) (len vs))) false (er-match-all ps vs 0 env)))))) + +(define + er-match-all + (fn + (ps vs i env) + (if + (>= i (len ps)) + true + (if + (er-match! (nth ps i) (nth vs i) env) + (er-match-all ps vs (+ i 1) env) + false)))) + +(define + er-match-cons + (fn + (pat val env) + (and + (er-cons? val) + (and + (er-match! (get pat :head) (get val :head) env) + (er-match! (get pat :tail) (get val :tail) env))))) + +;; Match `<>` against a binary value. Walks the +;; segment list left-to-right, consuming bytes from the front of the +;; binary for each segment. Integer segments decode big-endian and +;; bind/check the pattern; binary-spec segments without size capture +;; the trailing bytes as a binary value. +(define + er-match-binary + (fn + (pat val env) + (and + (er-binary? val) + (let + ((segs (get pat :segments)) (cursor (list 0))) + (and + (er-match-binary-segs segs val env cursor 0) + (= (nth cursor 0) (len (get val :bytes)))))))) + +(define + er-match-binary-segs + (fn + (segs val env cursor i) + (cond + (>= i (len segs)) true + :else (let + ((seg (nth segs i))) + (let + ((spec (get seg :spec)) + (size-node (get seg :size))) + (cond + (= spec "integer") + (er-match-binary-int seg val env cursor segs i) + (= spec "binary") + (er-match-binary-tail seg val env cursor segs i) + :else false)))))) + +(define + er-match-binary-int + (fn + (seg val env cursor segs i) + (let + ((bits (cond + (= (get seg :size) nil) 8 + :else (er-eval-expr (get seg :size) env)))) + (cond + (or (not (= (remainder bits 8) 0)) (<= bits 0)) false + :else (let + ((nbytes (truncate (/ bits 8))) (bytes (get val :bytes)) (start (nth cursor 0))) + (cond + (> (+ start nbytes) (len bytes)) false + :else (let + ((decoded (er-decode-int bytes start nbytes))) + (set-nth! cursor 0 (+ start nbytes)) + (and + (er-match! (get seg :value) decoded env) + (er-match-binary-segs segs val env cursor (+ i 1)))))))))) + +(define + er-decode-int + (fn + (bytes start nbytes) + (let + ((acc (list 0))) + (for-each + (fn + (j) + (set-nth! + acc + 0 + (+ (* (nth acc 0) 256) (nth bytes (+ start j))))) + (range 0 nbytes)) + (nth acc 0)))) + +(define + er-match-binary-tail + (fn + (seg val env cursor segs i) + (cond + (not (= (get seg :size) nil)) false + (not (= (+ i 1) (len segs))) false + :else (let + ((bytes (get val :bytes)) + (start (nth cursor 0)) + (rest-bytes (list))) + (for-each + (fn (k) (append! rest-bytes (nth bytes k))) + (range start (len bytes))) + (set-nth! cursor 0 (len bytes)) + (er-match! (get seg :value) (er-mk-binary rest-bytes) env))))) + +;; ── env snapshot / restore ──────────────────────────────────────── +(define + er-env-copy + (fn + (env) + (let + ((out {})) + (for-each (fn (k) (dict-set! out k (get env k))) (keys env)) + out))) + +(define + er-env-restore! + (fn + (env snap) + (for-each (fn (k) (dict-delete! env k)) (keys env)) + (for-each (fn (k) (dict-set! env k (get snap k))) (keys snap)))) + +;; ── case ───────────────────────────────────────────────────────── +(define + er-eval-case + (fn + (node env) + (let + ((subject (er-eval-expr (get node :expr) env))) + (er-eval-case-clauses (get node :clauses) 0 subject env)))) + +(define + er-eval-case-clauses + (fn + (clauses i subject env) + (if + (>= i (len clauses)) + (error "Erlang: case_clause: no matching clause") + (let + ((c (nth clauses i)) (snap (er-env-copy env))) + (if + (and + (er-match! (get c :pattern) subject env) + (er-eval-guards (get c :guards) env)) + (er-eval-body (get c :body) env) + (do + (er-env-restore! env snap) + (er-eval-case-clauses clauses (+ i 1) subject env))))))) + +;; ── operators ───────────────────────────────────────────────────── +(define + er-eval-op + (fn + (node env) + (let + ((op (get node :op)) (args (get node :args))) + (cond + (= op "andalso") (er-eval-andalso args env) + (= op "orelse") (er-eval-orelse args env) + :else (er-apply-binop + op + (er-eval-expr (nth args 0) env) + (er-eval-expr (nth args 1) env)))))) + +(define + er-eval-andalso + (fn + (args env) + (let + ((a (er-eval-expr (nth args 0) env))) + (if (er-truthy? a) (er-eval-expr (nth args 1) env) a)))) + +(define + er-eval-orelse + (fn + (args env) + (let + ((a (er-eval-expr (nth args 0) env))) + (if (er-truthy? a) a (er-eval-expr (nth args 1) env))))) + +(define + er-apply-binop + (fn + (op a b) + (cond + (= op "+") (+ a b) + (= op "-") (- a b) + (= op "*") (* a b) + (= op "/") (/ a b) + (= op "div") (truncate (/ a b)) + (= op "rem") (remainder a b) + (= op "==") (er-bool (er-equal? a b)) + (= op "/=") (er-bool (not (er-equal? a b))) + (= op "=:=") (er-bool (er-exact-equal? a b)) + (= op "=/=") (er-bool (not (er-exact-equal? a b))) + (= op "<") (er-bool (er-lt? a b)) + (= op ">") (er-bool (er-lt? b a)) + (= op "=<") (er-bool (not (er-lt? b a))) + (= op ">=") (er-bool (not (er-lt? a b))) + (= op "++") (er-list-append a b) + (= op "and") (er-bool (and (er-truthy? a) (er-truthy? b))) + (= op "or") (er-bool (or (er-truthy? a) (er-truthy? b))) + :else (error (str "Erlang eval: unsupported operator '" op "'"))))) + +(define + er-eval-unop + (fn + (node env) + (let + ((op (get node :op)) (a (er-eval-expr (get node :arg) env))) + (cond + (= op "-") (- 0 a) + (= op "+") a + (= op "not") (er-bool (not (er-truthy? a))) + :else (error (str "Erlang eval: unsupported unary '" op "'")))))) + +;; ── equality / comparison ───────────────────────────────────────── +(define + er-equal? + (fn + (a b) + (cond + (and (= (type-of a) "number") (= (type-of b) "number")) (= a b) + (and (er-atom? a) (er-atom? b)) (= (get a :name) (get b :name)) + (and (er-nil? a) (er-nil? b)) true + (and (er-cons? a) (er-cons? b)) + (and + (er-equal? (get a :head) (get b :head)) + (er-equal? (get a :tail) (get b :tail))) + (and (er-tuple? a) (er-tuple? b)) + (let + ((ea (get a :elements)) (eb (get b :elements))) + (and + (= (len ea) (len eb)) + (every? + (fn (i) (er-equal? (nth ea i) (nth eb i))) + (range 0 (len ea))))) + (and (= (type-of a) "string") (= (type-of b) "string")) (= a b) + (and (er-pid? a) (er-pid? b)) (= (get a :id) (get b :id)) + (and (er-ref? a) (er-ref? b)) (= (get a :id) (get b :id)) + (and (er-binary? a) (er-binary? b)) + (let + ((ba (get a :bytes)) (bb (get b :bytes))) + (and + (= (len ba) (len bb)) + (every? (fn (i) (= (nth ba i) (nth bb i))) (range 0 (len ba))))) + :else false))) + +;; Exact equality: 1 =/= 1.0 in Erlang. +(define + er-exact-equal? + (fn + (a b) + (if + (and (= (type-of a) "number") (= (type-of b) "number")) + (and (= (integer? a) (integer? b)) (= a b)) + (er-equal? a b)))) + +(define + er-lt? + (fn + (a b) + (cond + (and (= (type-of a) "number") (= (type-of b) "number")) (< a b) + (and (er-atom? a) (er-atom? b)) (< (get a :name) (get b :name)) + (and (= (type-of a) "string") (= (type-of b) "string")) (< a b) + :else (< (er-type-order a) (er-type-order b))))) + +(define + er-type-order + (fn + (v) + (cond + (= (type-of v) "number") 0 + (er-atom? v) 1 + (er-tuple? v) 2 + (er-nil? v) 3 + (er-cons? v) 3 + (= (type-of v) "string") 4 + (er-pid? v) 5 + :else 6))) + +(define + er-list-append + (fn + (a b) + (cond + (er-nil? a) b + (er-cons? a) + (er-mk-cons (get a :head) (er-list-append (get a :tail) b)) + :else (error "Erlang: ++ left argument is not a proper list")))) + +;; ── if ──────────────────────────────────────────────────────────── +(define er-eval-if (fn (node env) (er-eval-if-clauses (get node :clauses) 0 env))) + +(define + er-eval-if-clauses + (fn + (clauses i env) + (if + (>= i (len clauses)) + (error "Erlang: if: no clause matched") + (let + ((c (nth clauses i))) + (if + (er-eval-guards (get c :guards) env) + (er-eval-body (get c :body) env) + (er-eval-if-clauses clauses (+ i 1) env)))))) + +;; Guards: outer list = OR, inner list = AND. Empty outer = always pass. +(define + er-eval-guards + (fn + (alts env) + (if (= (len alts) 0) true (er-eval-guards-any alts 0 env)))) + +(define + er-eval-guards-any + (fn + (alts i env) + (if + (>= i (len alts)) + false + (if + (er-eval-guard-conj (nth alts i) env) + true + (er-eval-guards-any alts (+ i 1) env))))) + +(define er-eval-guard-conj (fn (conj env) (er-eval-guard-conj-iter conj 0 env))) + +(define + er-eval-guard-conj-iter + (fn + (conj i env) + (if + (>= i (len conj)) + true + (if + (er-truthy? (er-eval-expr (nth conj i) env)) + (er-eval-guard-conj-iter conj (+ i 1) env) + false)))) + +;; ── function calls ─────────────────────────────────────────────── +(define + er-eval-call + (fn + (node env) + (let + ((fun-node (get node :fun)) (args (get node :args))) + (cond + (= (get fun-node :type) "atom") + (let + ((name (get fun-node :value)) (vs (er-eval-args args env))) + (cond + (and (dict-has? env name) (er-fun? (get env name))) + (er-apply-fun (get env name) vs) + :else (er-apply-bif name vs))) + (= (get fun-node :type) "remote") + (let + ((mod-name (er-resolve-call-name (get fun-node :mod) env "module")) + (fn-name (er-resolve-call-name (get fun-node :fun) env "function"))) + (er-apply-remote-bif mod-name fn-name (er-eval-args args env))) + :else + (let + ((fv (er-eval-expr fun-node env))) + (if + (er-fun? fv) + (er-apply-fun fv (er-eval-args args env)) + (error "Erlang: not a function"))))))) + +(define + er-eval-args + (fn + (args env) + (let + ((out (list))) + (for-each + (fn (i) (append! out (er-eval-expr (nth args i) env))) + (range 0 (len args))) + out))) + +;; Resolve a remote call's module/function reference into a string. +;; Atom AST nodes use their `:value` directly. For any other shape +;; (typically a var or another expression), evaluate it and require +;; the result to be an atom. +(define + er-resolve-call-name + (fn + (node env kind) + (cond + (= (get node :type) "atom") (get node :value) + :else (let + ((v (er-eval-expr node env))) + (if + (er-atom? v) + (get v :name) + (error + (str "Erlang: call " kind " must be an atom, got " (er-format-value v)))))))) + +;; ── fun values ─────────────────────────────────────────────────── +(define + er-mk-fun + (fn (clauses env) {:env env :clauses clauses :tag "fun"})) +(define er-fun? (fn (v) (er-is-tagged? v "fun"))) + +(define + er-eval-fun + (fn (node env) (er-mk-fun (get node :clauses) env))) + +(define + er-apply-fun + (fn + (fv vs) + (er-apply-fun-clauses (get fv :clauses) vs (get fv :env) 0))) + +(define + er-apply-fun-clauses + (fn + (clauses vs closure-env i) + (if + (>= i (len clauses)) + (error "Erlang: function_clause: no matching fun clause") + (let + ((c (nth clauses i)) + (ps (get c :patterns)) + (call-env (er-env-copy closure-env))) + (if + (not (= (len ps) (len vs))) + (er-apply-fun-clauses clauses vs closure-env (+ i 1)) + (if + (and + (er-match-all ps vs 0 call-env) + (er-eval-guards (get c :guards) call-env)) + (er-eval-body (get c :body) call-env) + (er-apply-fun-clauses clauses vs closure-env (+ i 1)))))))) + +;; ── BIFs ───────────────────────────────────────────────────────── +(define er-atom-ok (er-mk-atom "ok")) + +(define + er-apply-bif + (fn + (name vs) + (cond + (= name "is_integer") (er-bif-is-integer vs) + (= name "is_atom") (er-bif-is-atom vs) + (= name "is_list") (er-bif-is-list vs) + (= name "is_tuple") (er-bif-is-tuple vs) + (= name "is_number") (er-bif-is-number vs) + (= name "is_float") (er-bif-is-float vs) + (= name "is_boolean") (er-bif-is-boolean vs) + (= name "length") (er-bif-length vs) + (= name "hd") (er-bif-hd vs) + (= name "tl") (er-bif-tl vs) + (= name "element") (er-bif-element vs) + (= name "tuple_size") (er-bif-tuple-size vs) + (= name "atom_to_list") (er-bif-atom-to-list vs) + (= name "list_to_atom") (er-bif-list-to-atom vs) + (= name "is_pid") (er-bif-is-pid vs) + (= name "is_reference") (er-bif-is-reference vs) + (= name "is_binary") (er-bif-is-binary vs) + (= name "byte_size") (er-bif-byte-size vs) + (= name "abs") (er-bif-abs vs) + (= name "min") (er-bif-min vs) + (= name "max") (er-bif-max vs) + (= name "tuple_to_list") (er-bif-tuple-to-list vs) + (= name "list_to_tuple") (er-bif-list-to-tuple vs) + (= name "integer_to_list") (er-bif-integer-to-list vs) + (= name "list_to_integer") (er-bif-list-to-integer vs) + (= name "is_function") (er-bif-is-function vs) + (= name "self") (er-bif-self vs) + (= name "spawn") (er-bif-spawn vs) + (= name "exit") (er-bif-exit vs) + (= name "make_ref") (er-bif-make-ref vs) + (= name "link") (er-bif-link vs) + (= name "unlink") (er-bif-unlink vs) + (= name "monitor") (er-bif-monitor vs) + (= name "demonitor") (er-bif-demonitor vs) + (= name "process_flag") (er-bif-process-flag vs) + (= name "register") (er-bif-register vs) + (= name "unregister") (er-bif-unregister vs) + (= name "whereis") (er-bif-whereis vs) + (= name "registered") (er-bif-registered vs) + (= name "throw") (raise (er-mk-throw-marker (er-bif-arg1 vs "throw"))) + (= name "error") (raise (er-mk-error-marker (er-bif-arg1 vs "error"))) + :else (error + (str "Erlang: undefined function '" name "/" (len vs) "'"))))) + +(define + er-apply-remote-bif + (fn + (mod name vs) + (cond + (dict-has? (er-modules-get) mod) + (er-apply-user-module mod name vs) + (= mod "lists") (er-apply-lists-bif name vs) + (= mod "io") (er-apply-io-bif name vs) + (= mod "erlang") (er-apply-bif name vs) + (= mod "ets") (er-apply-ets-bif name vs) + :else (error + (str "Erlang: undefined module '" mod "'"))))) + +(define + er-apply-lists-bif + (fn + (name vs) + (cond + (= name "reverse") (er-bif-lists-reverse vs) + (= name "map") (er-bif-lists-map vs) + (= name "foldl") (er-bif-lists-foldl vs) + (= name "seq") (er-bif-lists-seq vs) + (= name "sum") (er-bif-lists-sum vs) + (= name "nth") (er-bif-lists-nth vs) + (= name "last") (er-bif-lists-last vs) + (= name "member") (er-bif-lists-member vs) + (= name "append") (er-bif-lists-append vs) + (= name "filter") (er-bif-lists-filter vs) + (= name "any") (er-bif-lists-any vs) + (= name "all") (er-bif-lists-all vs) + (= name "duplicate") (er-bif-lists-duplicate vs) + :else (error + (str "Erlang: undefined 'lists:" name "/" (len vs) "'"))))) + +(define + er-apply-io-bif + (fn + (name vs) + (cond + (= name "format") (er-bif-io-format vs) + :else (error + (str "Erlang: undefined 'io:" name "/" (len vs) "'"))))) + +(define + er-bif-arg1 + (fn + (vs name) + (if + (= (len vs) 1) + (nth vs 0) + (error (str "Erlang: " name ": wrong arity"))))) + +(define + er-bif-is-integer + (fn + (vs) + (let + ((v (er-bif-arg1 vs "is_integer"))) + (er-bool (and (= (type-of v) "number") (integer? v)))))) + +(define + er-bif-is-atom + (fn (vs) (er-bool (er-atom? (er-bif-arg1 vs "is_atom"))))) + +(define + er-bif-is-list + (fn + (vs) + (let + ((v (er-bif-arg1 vs "is_list"))) + (er-bool (or (er-nil? v) (er-cons? v)))))) + +(define + er-bif-is-tuple + (fn (vs) (er-bool (er-tuple? (er-bif-arg1 vs "is_tuple"))))) + +(define + er-bif-is-number + (fn + (vs) + (er-bool (= (type-of (er-bif-arg1 vs "is_number")) "number")))) + +(define + er-bif-is-float + (fn + (vs) + (let + ((v (er-bif-arg1 vs "is_float"))) + (er-bool (and (= (type-of v) "number") (not (integer? v))))))) + +(define + er-bif-is-boolean + (fn + (vs) + (let + ((v (er-bif-arg1 vs "is_boolean"))) + (er-bool + (or (er-is-atom-named? v "true") (er-is-atom-named? v "false")))))) + +(define + er-bif-is-binary + (fn (vs) (er-bool (er-binary? (er-bif-arg1 vs "is_binary"))))) + +(define + er-bif-byte-size + (fn + (vs) + (let + ((v (er-bif-arg1 vs "byte_size"))) + (cond + (er-binary? v) (len (get v :bytes)) + :else (raise (er-mk-error-marker (er-mk-atom "badarg"))))))) + +;; ── list / tuple BIFs ──────────────────────────────────────────── +(define er-bif-length (fn (vs) (er-list-length (er-bif-arg1 vs "length")))) + +(define + er-list-length + (fn + (v) + (cond + (er-nil? v) 0 + (er-cons? v) (+ 1 (er-list-length (get v :tail))) + :else (error "Erlang: length: not a proper list")))) + +(define + er-bif-hd + (fn + (vs) + (let + ((v (er-bif-arg1 vs "hd"))) + (if + (er-cons? v) + (get v :head) + (error "Erlang: hd: empty list or non-list"))))) + +(define + er-bif-tl + (fn + (vs) + (let + ((v (er-bif-arg1 vs "tl"))) + (if + (er-cons? v) + (get v :tail) + (error "Erlang: tl: empty list or non-list"))))) + +(define + er-bif-element + (fn + (vs) + (if + (not (= (len vs) 2)) + (error "Erlang: element: arity") + (let + ((i (nth vs 0)) (t (nth vs 1))) + (if + (and (= (type-of i) "number") (er-tuple? t)) + (let + ((elems (get t :elements))) + (if + (and (>= i 1) (<= i (len elems))) + (nth elems (- i 1)) + (error "Erlang: element: badarg (index out of range)"))) + (error "Erlang: element: badarg")))))) + +(define + er-bif-tuple-size + (fn + (vs) + (let + ((v (er-bif-arg1 vs "tuple_size"))) + (if + (er-tuple? v) + (len (get v :elements)) + (error "Erlang: tuple_size: not a tuple"))))) + +(define + er-bif-atom-to-list + (fn + (vs) + (let + ((v (er-bif-arg1 vs "atom_to_list"))) + (if + (er-atom? v) + (get v :name) + (error "Erlang: atom_to_list: not an atom"))))) + +(define + er-bif-list-to-atom + (fn + (vs) + (let + ((v (er-bif-arg1 vs "list_to_atom"))) + (if + (= (type-of v) "string") + (er-mk-atom v) + (error "Erlang: list_to_atom: not a string"))))) + +;; ── lists module ───────────────────────────────────────────────── +(define + er-bif-lists-reverse + (fn + (vs) + (er-list-reverse-iter (er-bif-arg1 vs "lists:reverse") (er-mk-nil)))) + +(define + er-list-reverse-iter + (fn + (v acc) + (cond + (er-nil? v) acc + (er-cons? v) + (er-list-reverse-iter (get v :tail) (er-mk-cons (get v :head) acc)) + :else (error "Erlang: lists:reverse: not a list")))) + +(define + er-bif-lists-map + (fn + (vs) + (if + (not (= (len vs) 2)) + (error "Erlang: lists:map: arity") + (er-list-reverse-iter + (er-map-iter (nth vs 0) (nth vs 1) (er-mk-nil)) + (er-mk-nil))))) + +(define + er-map-iter + (fn + (f lst acc) + (cond + (er-nil? lst) acc + (er-cons? lst) + (er-map-iter + f + (get lst :tail) + (er-mk-cons (er-apply-fun f (list (get lst :head))) acc)) + :else (error "Erlang: lists:map: not a list")))) + +(define + er-bif-lists-foldl + (fn + (vs) + (if + (not (= (len vs) 3)) + (error "Erlang: lists:foldl: arity") + (er-foldl-iter (nth vs 0) (nth vs 1) (nth vs 2))))) + +(define + er-foldl-iter + (fn + (f acc lst) + (cond + (er-nil? lst) acc + (er-cons? lst) + (er-foldl-iter + f + (er-apply-fun f (list (get lst :head) acc)) + (get lst :tail)) + :else (error "Erlang: lists:foldl: not a list")))) + +;; ── io module ──────────────────────────────────────────────────── +(define er-io-buffer (list "")) +(define er-io-flush! (fn () (set-nth! er-io-buffer 0 ""))) +(define er-io-buffer-content (fn () (nth er-io-buffer 0))) + +(define + er-bif-io-format + (fn + (vs) + (let + ((s + (cond + (= (len vs) 1) (er-format-string (nth vs 0) (list)) + (= (len vs) 2) + (er-format-string (nth vs 0) (er-list-to-sx-list (nth vs 1))) + :else (error "Erlang: io:format: arity")))) + (set-nth! er-io-buffer 0 (str (nth er-io-buffer 0) s)) + er-atom-ok))) + +(define + er-list-to-sx-list + (fn + (lst) + (let + ((out (list))) + (er-list-to-sx-collect lst out) + out))) + +(define + er-list-to-sx-collect + (fn + (lst out) + (cond + (er-nil? lst) nil + (er-cons? lst) + (do + (append! out (get lst :head)) + (er-list-to-sx-collect (get lst :tail) out)) + :else (error "Erlang: expected proper list")))) + +;; ── format string rendering (~n, ~~, ~p, ~w, ~s) ──────────────── +(define + er-format-string + (fn (fmt args) (er-format-walk fmt 0 args 0 ""))) + +(define + er-format-walk + (fn + (fmt i args ai out) + (if + (>= i (len fmt)) + out + (let + ((c (char-at fmt i))) + (cond + (and (= c "~") (< (+ i 1) (len fmt))) + (let + ((d (char-at fmt (+ i 1)))) + (cond + (= d "n") + (er-format-walk fmt (+ i 2) args ai (str out "\n")) + (= d "~") (er-format-walk fmt (+ i 2) args ai (str out "~")) + (or (= d "p") (= d "w") (= d "s")) + (er-format-walk + fmt + (+ i 2) + args + (+ ai 1) + (str out (er-format-value (nth args ai)))) + :else (er-format-walk + fmt + (+ i 2) + args + ai + (str out "~" d)))) + :else (er-format-walk fmt (+ i 1) args ai (str out c))))))) + +(define + er-format-value + (fn + (v) + (cond + (= (type-of v) "number") (str v) + (= (type-of v) "string") (str "\"" v "\"") + (er-atom? v) (get v :name) + (er-nil? v) "[]" + (er-cons? v) (str "[" (er-format-list-elems v) "]") + (er-tuple? v) (str "{" (er-format-tuple-elems (get v :elements)) "}") + (er-fun? v) "#Fun" + (er-pid? v) (str "") + (er-ref? v) (str "#Ref<" (get v :id) ">") + (er-binary? v) (str "<<" (er-format-bytes (get v :bytes)) ">>") + :else (str v)))) + +(define + er-format-bytes + (fn + (bs) + (cond + (= (len bs) 0) "" + :else (let + ((out (list (str (nth bs 0))))) + (for-each + (fn (i) (append! out ",") (append! out (str (nth bs i)))) + (range 1 (len bs))) + (reduce str "" out))))) + +(define + er-format-list-elems + (fn + (v) + (cond + (er-nil? v) "" + (and (er-cons? v) (er-nil? (get v :tail))) + (er-format-value (get v :head)) + (er-cons? v) + (str + (er-format-value (get v :head)) + "," + (er-format-list-elems (get v :tail))) + :else (str "|" (er-format-value v))))) + +(define + er-format-tuple-elems + (fn + (elems) + (if + (= (len elems) 0) + "" + (let + ((out (list (er-format-value (nth elems 0))))) + (for-each + (fn + (i) + (append! out ",") + (append! out (er-format-value (nth elems i)))) + (range 1 (len elems))) + (reduce str "" out))))) + +;; ── send: Pid ! Msg ────────────────────────────────────────────── +;; Target may be a pid or a registered atom name. Atom resolution +;; goes through the scheduler's `:registered` table. +(define + er-eval-send + (fn + (node env) + (let + ((to-val (er-eval-expr (get node :to) env)) + (msg-val (er-eval-expr (get node :msg) env))) + (let + ((pid (er-resolve-send-target to-val))) + (when + (er-proc-exists? pid) + (er-proc-mailbox-push! pid msg-val) + (when + (= (er-proc-field pid :state) "waiting") + (er-proc-set! pid :state "runnable") + (er-sched-enqueue! pid))) + msg-val)))) + +(define + er-resolve-send-target + (fn + (v) + (cond + (er-pid? v) v + (er-atom? v) + (let + ((name (get v :name))) + (if + (dict-has? (er-registered) name) + (get (er-registered) name) + (raise + (er-mk-error-marker + (er-mk-tuple + (list (er-mk-atom "badarg") v)))))) + :else (raise + (er-mk-error-marker + (er-mk-tuple (list (er-mk-atom "badarg") v))))))) + +;; ── receive (selective, delimited-continuation suspension) ────── +(define + er-eval-receive + (fn + (node env) + (let + ((pid (er-sched-current-pid)) + (after-node (get node :after-ms))) + (if + (= after-node nil) + (er-eval-receive-loop node pid env) + (er-eval-receive-with-after node pid env after-node))))) + +(define + er-eval-receive-loop + (fn + (node pid env) + (let + ((r (er-try-receive (get node :clauses) pid env))) + (if + (get r :matched) + (get r :value) + (do + (call/cc + (fn + (k) + (er-proc-set! pid :continuation k) + (er-proc-set! pid :state "waiting") + (raise er-suspend-marker))) + (er-eval-receive-loop node pid env)))))) + +(define + er-eval-receive-with-after + (fn + (node pid env after-node) + (let + ((ms (er-eval-expr after-node env))) + (cond + (and (er-atom? ms) (= (get ms :name) "infinity")) + (er-eval-receive-loop node pid env) + (= ms 0) (er-eval-receive-poll node pid env) + :else (er-eval-receive-timed node pid env))))) + +;; after 0 — poll once; on no match, run the after-body immediately. +(define + er-eval-receive-poll + (fn + (node pid env) + (let + ((r (er-try-receive (get node :clauses) pid env))) + (if + (get r :matched) + (get r :value) + (er-eval-body (get node :after-body) env))))) + +;; after Ms — suspend; on resume check :timed-out. When the scheduler +;; runs out of other work it fires one pending timeout per round. +(define + er-eval-receive-timed + (fn + (node pid env) + (let + ((r (er-try-receive (get node :clauses) pid env))) + (if + (get r :matched) + (get r :value) + (do + (er-proc-set! pid :has-timeout true) + (call/cc + (fn + (k) + (er-proc-set! pid :continuation k) + (er-proc-set! pid :state "waiting") + (raise er-suspend-marker))) + (if + (er-proc-field pid :timed-out) + (do + (er-proc-set! pid :timed-out false) + (er-proc-set! pid :has-timeout false) + (er-eval-body (get node :after-body) env)) + (er-eval-receive-timed node pid env))))))) + +;; Scan mailbox in arrival order. For each msg, try every clause. +;; On first match: remove that msg from mailbox and return body value. +(define + er-try-receive + (fn + (clauses pid env) + (let + ((mbox (er-proc-field pid :mailbox))) + (er-try-receive-loop clauses mbox env 0)))) + +(define + er-try-receive-loop + (fn + (clauses mbox env i) + (if + (>= i (er-q-len mbox)) + {:matched false} + (let + ((msg (er-q-nth mbox i)) + (cr (er-try-receive-clauses clauses msg env 0))) + (if + (get cr :matched) + (do + (er-q-delete-at! mbox i) + {:value (er-eval-body (get cr :body) env) :matched true}) + (er-try-receive-loop clauses mbox env (+ i 1))))))) + +;; Try clauses against a message. On match: bind vars into env and +;; return `{:matched true :body }` WITHOUT evaluating the +;; body — the caller must remove the message from the mailbox first, +;; otherwise a recursive `receive` inside the body would re-match the +;; same msg and loop forever. +(define + er-try-receive-clauses + (fn + (clauses msg env i) + (if + (>= i (len clauses)) + {:matched false} + (let + ((c (nth clauses i)) (snap (er-env-copy env))) + (if + (and + (er-match! (get c :pattern) msg env) + (er-eval-guards (get c :guards) env)) + {:body (get c :body) :matched true} + (do + (er-env-restore! env snap) + (er-try-receive-clauses clauses msg env (+ i 1)))))))) + +;; ── try/of/catch/after ──────────────────────────────────────────── +;; The outer guard captures any exception so the `after` body is +;; guaranteed to run, then re-raises. The inner guard runs the +;; expression body, optional `of` clauses on success, and `catch` +;; clauses on a thrown/erred/exited condition. If no catch clause +;; matches the raised class+pattern, the inner guard's clause +;; re-raises by returning nothing (handled via re-raise marker). +(define + er-eval-try + (fn + (node env) + (let + ((after-body (get node :after)) + (saved-exc (list nil)) + (result-ref (list nil))) + (guard + (c (:else (do (set-nth! saved-exc 0 c) nil))) + (set-nth! result-ref 0 (er-eval-try-inner node env))) + (when + (> (len after-body) 0) + (er-eval-body after-body env)) + (if + (= (nth saved-exc 0) nil) + (nth result-ref 0) + (raise (nth saved-exc 0)))))) + +(define + er-eval-try-inner + (fn + (node env) + (let + ((catch-clauses (get node :catch-clauses)) + (of-clauses (get node :of-clauses)) + (caught-ref (list false)) + (result-ref (list nil)) + (re-raise-ref (list nil))) + (guard + (c + ((er-thrown? c) + (er-eval-try-catch + catch-clauses "throw" (get c :reason) env + caught-ref result-ref re-raise-ref)) + ((er-errored? c) + (er-eval-try-catch + catch-clauses "error" (get c :reason) env + caught-ref result-ref re-raise-ref)) + ((er-exited? c) + (er-eval-try-catch + catch-clauses "exit" (get c :reason) env + caught-ref result-ref re-raise-ref))) + (let + ((r (er-eval-body (get node :exprs) env))) + (if + (= (len of-clauses) 0) + (set-nth! result-ref 0 r) + (set-nth! + result-ref + 0 + (er-eval-of-clauses of-clauses r env 0))))) + (when (not (= (nth re-raise-ref 0) nil)) + (raise (nth re-raise-ref 0))) + (nth result-ref 0)))) + +;; Try catch-clauses against (Class, Reason). If a clause matches, +;; runs its body and writes to result-ref. If none match, queues a +;; re-raise marker. +(define + er-eval-try-catch + (fn + (clauses class-name reason env caught-ref result-ref re-raise-ref) + (er-eval-try-catch-iter + clauses class-name reason env 0 caught-ref result-ref re-raise-ref))) + +(define + er-eval-try-catch-iter + (fn + (clauses class-name reason env i caught-ref result-ref re-raise-ref) + (if + (>= i (len clauses)) + (set-nth! + re-raise-ref + 0 + (er-mk-class-marker class-name reason)) + (let + ((c (nth clauses i)) + (snap (er-env-copy env)) + (clause-class (get (get c :class) :value))) + (cond + (not (= clause-class class-name)) + (er-eval-try-catch-iter + clauses class-name reason env (+ i 1) + caught-ref result-ref re-raise-ref) + :else + (if + (and + (er-match! (get c :pattern) reason env) + (er-eval-guards (get c :guards) env)) + (do + (set-nth! caught-ref 0 true) + (set-nth! + result-ref + 0 + (er-eval-body (get c :body) env))) + (do + (er-env-restore! env snap) + (er-eval-try-catch-iter + clauses class-name reason env (+ i 1) + caught-ref result-ref re-raise-ref)))))))) + +(define + er-mk-class-marker + (fn + (class-name reason) + (cond + (= class-name "throw") (er-mk-throw-marker reason) + (= class-name "error") (er-mk-error-marker reason) + (= class-name "exit") (er-mk-exit-marker reason) + :else (er-mk-error-marker reason)))) + +(define + er-eval-of-clauses + (fn + (clauses subject env i) + (if + (>= i (len clauses)) + (raise + (er-mk-error-marker + (er-mk-tuple + (list (er-mk-atom "try_clause") subject)))) + (let + ((c (nth clauses i)) (snap (er-env-copy env))) + (if + (and + (er-match! (get c :pattern) subject env) + (er-eval-guards (get c :guards) env)) + (er-eval-body (get c :body) env) + (do + (er-env-restore! env snap) + (er-eval-of-clauses clauses subject env (+ i 1)))))))) + +;; ── list comprehensions ───────────────────────────────────────── +;; `[E || Pat <- Source, FilterExpr, ...]`. Walk qualifiers in order: +;; generators iterate their source list and bind the pattern (with +;; env snapshot/restore so each iteration starts from the same +;; baseline); filters skip when falsy. At the end of the qualifier +;; chain, evaluate `head` and append to the accumulator. Build the +;; final cons chain in O(n) with a single right-fold. +(define + er-eval-lc + (fn + (node env) + (let + ((acc (list))) + (er-lc-walk (get node :qualifiers) 0 (get node :head) env acc) + (er-list-from-sx-list acc)))) + +(define + er-lc-walk + (fn + (quals i head env acc) + (if + (>= i (len quals)) + (append! acc (er-eval-expr head env)) + (let + ((q (nth quals i))) + (cond + (= (get q :kind) "gen") + (let + ((src (er-eval-expr (get q :source) env))) + (er-lc-iter-gen + src + (get q :pattern) + quals + i + head + env + acc)) + (= (get q :kind) "filter") + (when + (er-truthy? (er-eval-expr (get q :expr) env)) + (er-lc-walk quals (+ i 1) head env acc)) + :else (error "Erlang LC: unknown qualifier")))))) + +(define + er-lc-iter-gen + (fn + (src pat quals i head env acc) + (cond + (er-nil? src) nil + (er-cons? src) + (let + ((snap (er-env-copy env))) + (when + (er-match! pat (get src :head) env) + (er-lc-walk quals (+ i 1) head env acc)) + (er-env-restore! env snap) + (er-lc-iter-gen + (get src :tail) + pat + quals + i + head + env + acc)) + :else (error "Erlang LC: generator source is not a list")))) + +(define + er-list-from-sx-list + (fn + (xs) + (let + ((acc (list (er-mk-nil)))) + (for-each + (fn + (i) + (let + ((j (- (- (len xs) 1) i))) + (set-nth! acc 0 (er-mk-cons (nth xs j) (nth acc 0))))) + (range 0 (len xs))) + (nth acc 0)))) + +;; ── binaries ──────────────────────────────────────────────────── +;; Each segment is `Value : Size / Spec`. Supported specs: `integer` +;; (default; size in bits, must be multiple of 8 — 8/16/24/32 typical) +;; and `binary` (concatenate the segment's binary value into the +;; result). Default size for `integer` segments is 8 bits. +(define + er-eval-binary + (fn + (node env) + (let + ((segs (get node :segments)) (out (list))) + (for-each + (fn (i) (er-eval-binary-segment (nth segs i) env out)) + (range 0 (len segs))) + (er-mk-binary out)))) + +(define + er-eval-binary-segment + (fn + (seg env out) + (let + ((spec (get seg :spec)) + (val (er-eval-expr (get seg :value) env)) + (size (er-eval-binary-size (get seg :size) env))) + (cond + (= spec "integer") + (let + ((bits (if (= size nil) 8 size))) + (er-emit-int! out val bits)) + (= spec "binary") + (cond + (er-binary? val) + (for-each + (fn (i) (append! out (nth (get val :bytes) i))) + (range 0 (len (get val :bytes)))) + :else (raise + (er-mk-error-marker (er-mk-atom "badarg")))) + :else (error + (str "Erlang: binary spec '" spec "' not supported")))))) + +(define + er-eval-binary-size + (fn + (node env) + (cond + (= node nil) nil + :else (er-eval-expr node env)))) + +;; Big-endian byte emission for an N-bit integer (N must be multiple +;; of 8). For bits=8 this is just `(append! out (mod v 256))`. +(define + er-emit-int! + (fn + (out v bits) + (cond + (or (not (= (remainder bits 8) 0)) (<= bits 0)) + (error + (str "Erlang: binary integer size must be a positive multiple of 8 (got " bits ")")) + :else (let + ((nbytes (truncate (/ bits 8)))) + (for-each + (fn + (i) + (let + ((shift (* 8 (- (- nbytes 1) i)))) + (append! + out + (remainder (truncate (/ v (er-int-pow 2 shift))) 256)))) + (range 0 nbytes)))))) + +(define + er-int-pow + (fn + (b e) + (cond + (= e 0) 1 + :else (* b (er-int-pow b (- e 1)))))) + +;; ── extra erlang BIFs ─────────────────────────────────────────── +(define + er-bif-abs + (fn + (vs) + (let + ((v (er-bif-arg1 vs "abs"))) + (cond + (not (= (type-of v) "number")) + (raise (er-mk-error-marker (er-mk-atom "badarg"))) + (< v 0) (- 0 v) + :else v)))) + +(define + er-bif-min + (fn + (vs) + (cond + (not (= (len vs) 2)) (error "Erlang: min/2: arity") + :else (let + ((a (nth vs 0)) (b (nth vs 1))) + (if (er-lt? b a) b a))))) + +(define + er-bif-max + (fn + (vs) + (cond + (not (= (len vs) 2)) (error "Erlang: max/2: arity") + :else (let + ((a (nth vs 0)) (b (nth vs 1))) + (if (er-lt? a b) b a))))) + +(define + er-bif-tuple-to-list + (fn + (vs) + (let + ((v (er-bif-arg1 vs "tuple_to_list"))) + (cond + (not (er-tuple? v)) + (raise (er-mk-error-marker (er-mk-atom "badarg"))) + :else (let + ((elems (get v :elements)) (out (er-mk-nil))) + (for-each + (fn + (i) + (let + ((j (- (- (len elems) 1) i))) + (set! out (er-mk-cons (nth elems j) out)))) + (range 0 (len elems))) + out))))) + +(define + er-bif-list-to-tuple + (fn + (vs) + (let + ((v (er-bif-arg1 vs "list_to_tuple")) (elems (list))) + (er-list-to-elem-list v elems) + (er-mk-tuple elems)))) + +(define + er-list-to-elem-list + (fn + (lst out) + (cond + (er-nil? lst) nil + (er-cons? lst) + (do + (append! out (get lst :head)) + (er-list-to-elem-list (get lst :tail) out)) + :else (raise (er-mk-error-marker (er-mk-atom "badarg")))))) + +(define + er-bif-integer-to-list + (fn + (vs) + (let + ((v (er-bif-arg1 vs "integer_to_list"))) + (cond + (not (= (type-of v) "number")) + (raise (er-mk-error-marker (er-mk-atom "badarg"))) + :else (str v))))) + +(define + er-bif-list-to-integer + (fn + (vs) + (let + ((v (er-bif-arg1 vs "list_to_integer"))) + (cond + (not (= (type-of v) "string")) + (raise (er-mk-error-marker (er-mk-atom "badarg"))) + :else (let + ((n (parse-number v))) + (cond + (= n nil) + (raise (er-mk-error-marker (er-mk-atom "badarg"))) + :else n)))))) + +(define + er-bif-is-function + (fn + (vs) + (cond + (= (len vs) 1) (er-bool (er-fun? (nth vs 0))) + (= (len vs) 2) + (let + ((v (nth vs 0)) (n (nth vs 1))) + (cond + (not (er-fun? v)) (er-bool false) + :else (er-bool (er-fun-has-arity? v n)))) + :else (error "Erlang: is_function: arity")))) + +(define + er-fun-has-arity? + (fn + (fv n) + (let + ((clauses (get fv :clauses)) (found (list false))) + (for-each + (fn + (i) + (when + (= (len (get (nth clauses i) :patterns)) n) + (set-nth! found 0 true))) + (range 0 (len clauses))) + (nth found 0)))) + +;; ── extra lists BIFs ─────────────────────────────────────────── +(define + er-bif-lists-seq + (fn + (vs) + (cond + (= (len vs) 2) (er-lists-seq-build (nth vs 0) (nth vs 1) 1) + (= (len vs) 3) (er-lists-seq-build (nth vs 0) (nth vs 1) (nth vs 2)) + :else (error "Erlang: lists:seq: arity")))) + +(define + er-lists-seq-build + (fn + (from to step) + (let + ((acc (er-mk-nil))) + (for-each + (fn + (i) + (let + ((v (- to (* i step)))) + (when + (and (>= v from) (<= v to)) + (set! acc (er-mk-cons v acc))))) + (range 0 (+ 1 (truncate (/ (- to from) step))))) + acc))) + +(define + er-bif-lists-sum + (fn + (vs) + (let + ((lst (er-bif-arg1 vs "lists:sum"))) + (er-lists-sum-iter lst 0)))) + +(define + er-lists-sum-iter + (fn + (lst acc) + (cond + (er-nil? lst) acc + (er-cons? lst) + (er-lists-sum-iter (get lst :tail) (+ acc (get lst :head))) + :else (raise (er-mk-error-marker (er-mk-atom "badarg")))))) + +(define + er-bif-lists-nth + (fn + (vs) + (cond + (not (= (len vs) 2)) (error "Erlang: lists:nth: arity") + :else (er-lists-nth-iter (nth vs 1) (nth vs 0))))) + +(define + er-lists-nth-iter + (fn + (lst i) + (cond + (or (<= i 0) (er-nil? lst)) + (raise (er-mk-error-marker (er-mk-atom "badarg"))) + (= i 1) (get lst :head) + :else (er-lists-nth-iter (get lst :tail) (- i 1))))) + +(define + er-bif-lists-last + (fn + (vs) + (let + ((lst (er-bif-arg1 vs "lists:last"))) + (cond + (er-nil? lst) + (raise (er-mk-error-marker (er-mk-atom "badarg"))) + :else (er-lists-last-iter lst))))) + +(define + er-lists-last-iter + (fn + (lst) + (cond + (and (er-cons? lst) (er-nil? (get lst :tail))) (get lst :head) + (er-cons? lst) (er-lists-last-iter (get lst :tail)) + :else (raise (er-mk-error-marker (er-mk-atom "badarg")))))) + +(define + er-bif-lists-member + (fn + (vs) + (cond + (not (= (len vs) 2)) (error "Erlang: lists:member: arity") + :else (er-bool (er-lists-member-iter (nth vs 0) (nth vs 1)))))) + +(define + er-lists-member-iter + (fn + (target lst) + (cond + (er-nil? lst) false + (er-cons? lst) + (cond + (er-equal? target (get lst :head)) true + :else (er-lists-member-iter target (get lst :tail))) + :else false))) + +(define + er-bif-lists-append + (fn + (vs) + (cond + (not (= (len vs) 2)) (error "Erlang: lists:append: arity") + :else (er-list-append (nth vs 0) (nth vs 1))))) + +(define + er-bif-lists-filter + (fn + (vs) + (cond + (not (= (len vs) 2)) (error "Erlang: lists:filter: arity") + :else (er-lists-filter-build + (nth vs 0) + (nth vs 1) + (er-mk-nil))))) + +(define + er-lists-filter-build + (fn + (pred lst acc) + (cond + (er-nil? lst) (er-list-reverse-iter acc (er-mk-nil)) + (er-cons? lst) + (let + ((kept + (cond + (er-truthy? (er-apply-fun pred (list (get lst :head)))) + (er-mk-cons (get lst :head) acc) + :else acc))) + (er-lists-filter-build pred (get lst :tail) kept)) + :else (raise (er-mk-error-marker (er-mk-atom "badarg")))))) + +(define + er-bif-lists-any + (fn + (vs) + (cond + (not (= (len vs) 2)) (error "Erlang: lists:any: arity") + :else (er-bool (er-lists-any-iter (nth vs 0) (nth vs 1)))))) + +(define + er-lists-any-iter + (fn + (pred lst) + (cond + (er-nil? lst) false + (er-cons? lst) + (cond + (er-truthy? (er-apply-fun pred (list (get lst :head)))) true + :else (er-lists-any-iter pred (get lst :tail))) + :else false))) + +(define + er-bif-lists-all + (fn + (vs) + (cond + (not (= (len vs) 2)) (error "Erlang: lists:all: arity") + :else (er-bool (er-lists-all-iter (nth vs 0) (nth vs 1)))))) + +(define + er-lists-all-iter + (fn + (pred lst) + (cond + (er-nil? lst) true + (er-cons? lst) + (cond + (er-truthy? (er-apply-fun pred (list (get lst :head)))) + (er-lists-all-iter pred (get lst :tail)) + :else false) + :else false))) + +(define + er-bif-lists-duplicate + (fn + (vs) + (cond + (not (= (len vs) 2)) (error "Erlang: lists:duplicate: arity") + :else (let + ((n (nth vs 0)) (v (nth vs 1)) (out (er-mk-nil))) + (for-each + (fn (_) (set! out (er-mk-cons v out))) + (range 0 n)) + out)))) diff --git a/plans/erlang-on-sx.md b/plans/erlang-on-sx.md index 0084a46e..cc068a23 100644 --- a/plans/erlang-on-sx.md +++ b/plans/erlang-on-sx.md @@ -53,52 +53,79 @@ Core mapping: - [x] Tokenizer: atoms (bare + single-quoted), variables (Uppercase/`_`-prefixed), numbers (int, float, `16#HEX`), strings `"..."`, chars `$c`, punct `( ) { } [ ] , ; . : :: ->` — **62/62 tests** - [x] Parser: module declarations, `-module`/`-export`/`-import` attributes, function clauses with head patterns + guards + body — **52/52 tests** - [x] Expressions: literals, vars, calls, tuples `{...}`, lists `[...|...]`, `if`, `case`, `receive`, `fun`, `try/catch`, operators, precedence -- [ ] Binaries `<<...>>` — not yet parsed (deferred to Phase 6) +- [x] Binaries `<<...>>` — landed in Phase 6 (parser + eval + pattern matching) - [x] Unit tests in `lib/erlang/tests/parse.sx` ### Phase 2 — sequential eval + pattern matching + BIFs -- [ ] `erlang-eval-ast`: evaluate sequential expressions -- [ ] Pattern matching (atoms, numbers, vars, tuples, lists, `[H|T]`, underscore, bound-var re-match) -- [ ] Guards: `is_integer`, `is_atom`, `is_list`, `is_tuple`, comparisons, arithmetic -- [ ] BIFs: `length/1`, `hd/1`, `tl/1`, `element/2`, `tuple_size/1`, `atom_to_list/1`, `list_to_atom/1`, `lists:map/2`, `lists:foldl/3`, `lists:reverse/1`, `io:format/1-2` -- [ ] 30+ tests in `lib/erlang/tests/eval.sx` +- [x] `erlang-eval-ast`: evaluate sequential expressions — **54/54 tests** +- [x] Pattern matching (atoms, numbers, vars, tuples, lists, `[H|T]`, underscore, bound-var re-match) — **21 new eval tests**; `case ... of ... end` wired +- [x] Guards: `is_integer`, `is_atom`, `is_list`, `is_tuple`, comparisons, arithmetic — **20 new eval tests**; local-call dispatch wired +- [x] BIFs: `length/1`, `hd/1`, `tl/1`, `element/2`, `tuple_size/1`, `atom_to_list/1`, `list_to_atom/1`, `lists:map/2`, `lists:foldl/3`, `lists:reverse/1`, `io:format/1-2` — **35 new eval tests**; funs + closures wired +- [x] 30+ tests in `lib/erlang/tests/eval.sx` — **130 tests green** ### Phase 3 — processes + mailboxes + receive (THE SHOWCASE) -- [ ] Scheduler in `runtime.sx`: runnable queue, pid counter, per-process state record -- [ ] `spawn/1`, `spawn/3`, `self/0` -- [ ] `!` (send), `receive ... end` with selective pattern matching -- [ ] `receive ... after Ms -> ...` timeout clause (use SX timer primitive) -- [ ] `exit/1`, basic process termination -- [ ] Classic programs in `lib/erlang/tests/programs/`: - - [ ] `ring.erl` — N processes in a ring, pass a token around M times - - [ ] `ping_pong.erl` — two processes exchanging messages - - [ ] `bank.erl` — account server (deposit/withdraw/balance) - - [ ] `echo.erl` — minimal server - - [ ] `fib_server.erl` — compute fib on request -- [ ] `lib/erlang/conformance.sh` + runner, `scoreboard.json` + `scoreboard.md` -- [ ] Target: 5/5 classic programs + 1M-process ring benchmark runs +- [x] Scheduler in `runtime.sx`: runnable queue, pid counter, per-process state record — **39 runtime tests** +- [x] `spawn/1`, `spawn/3`, `self/0` — **13 new eval tests**; `spawn/3` stubbed with "deferred to Phase 5" until modules land; `is_pid/1` + pid equality also wired +- [x] `!` (send), `receive ... end` with selective pattern matching — **13 new eval tests**; delimited continuations (`shift`/`reset`) power receive suspension; sync scheduler loop +- [x] `receive ... after Ms -> ...` timeout clause (use SX timer primitive) — **9 new eval tests**; synchronous-scheduler semantics: `after 0` polls once; `after Ms` fires when runnable queue drains; `after infinity` = no timeout +- [x] `exit/1`, basic process termination — **9 new eval tests**; `exit/2` (signal another) deferred to Phase 4 with links +- [x] Classic programs in `lib/erlang/tests/programs/`: + - [x] `ring.erl` — N processes in a ring, pass a token around M times — **4 ring tests**; suspension machinery rewritten from `shift`/`reset` to `call/cc` + `raise`/`guard` + - [x] `ping_pong.erl` — two processes exchanging messages — **4 ping-pong tests** + - [x] `bank.erl` — account server (deposit/withdraw/balance) — **8 bank tests** + - [x] `echo.erl` — minimal server — **7 echo tests** + - [x] `fib_server.erl` — compute fib on request — **8 fib tests** +- [x] `lib/erlang/conformance.sh` + runner, `scoreboard.json` + `scoreboard.md` — **358/358 across 9 suites** +- [x] Target: 5/5 classic programs + 1M-process ring benchmark runs — **5/5 classic programs green; ring benchmark runs correctly at every measured size up to N=1000 (33s, ~34 hops/s); 1M target NOT met in current synchronous-scheduler architecture (would take ~9h at observed throughput)**. See `lib/erlang/bench_ring.sh` and `lib/erlang/bench_ring_results.md`. ### Phase 4 — links, monitors, exit signals -- [ ] `link/1`, `unlink/1`, `monitor/2`, `demonitor/1` -- [ ] Exit-signal propagation; trap_exit flag -- [ ] `try/catch/of/end` +- [x] `link/1`, `unlink/1`, `monitor/2`, `demonitor/1` — **17 new eval tests**; `make_ref/0`, `is_reference/1`, refs in `=:=`/format wired +- [x] Exit-signal propagation; trap_exit flag — **11 new eval tests**; `process_flag/2`, monitor `{'DOWN', ...}`, `{'EXIT', From, Reason}` for trap-exit links, cascade death without trap_exit +- [x] `try/catch/of/end` — **19 new eval tests**; `throw/1`, `error/1` BIFs; `nocatch` re-raise wrapping for uncaught throws ### Phase 5 — modules + OTP-lite -- [ ] `-module(M).` loading, `M:F(...)` calls across modules -- [ ] `gen_server` behaviour (the big OTP win) -- [ ] `supervisor` (simple one-for-one) -- [ ] Registered processes: `register/2`, `whereis/1` +- [x] `-module(M).` loading, `M:F(...)` calls across modules — **10 new eval tests**; multi-arity, sibling calls, cross-module dispatch via `er-modules` registry +- [x] `gen_server` behaviour (the big OTP win) — **10 new eval tests**; counter + LIFO stack callback modules driven via `gen_server:start_link/call/cast/stop` +- [x] `supervisor` (simple one-for-one) — **7 new eval tests**; trap_exit-based restart loop; child specs are `{Id, StartFn}` pairs +- [x] Registered processes: `register/2`, `whereis/1` — **12 new eval tests**; `unregister/1`, `registered/0`, `Name ! Msg` via registered atom; auto-unregister on death ### Phase 6 — the rest -- [ ] List comprehensions `[X*2 || X <- L]` -- [ ] Binary pattern matching `<>` -- [ ] ETS-lite (in-memory tables via SX dicts) -- [ ] More BIFs — target 200+ test corpus green +- [x] List comprehensions `[X*2 || X <- L]` — **12 new eval tests**; generators, filters, multiple generators (cartesian), pattern-matching gens (`{ok, V} <- ...`) +- [x] Binary pattern matching `<>` — **21 new eval tests**; literal construction, byte/multi-byte segments, `Rest/binary` tail capture, `is_binary/1`, `byte_size/1` +- [x] ETS-lite (in-memory tables via SX dicts) — **13 new eval tests**; `ets:new/2`, `insert/2`, `lookup/2`, `delete/1-2`, `tab2list/1`, `info/2` (size); set semantics with full Erlang-term keys +- [x] More BIFs — target 200+ test corpus green — **40 new eval tests**; 530/530 total. New: `abs/1`, `min/2`, `max/2`, `tuple_to_list/1`, `list_to_tuple/1`, `integer_to_list/1`, `list_to_integer/1`, `is_function/1-2`, `lists:seq/2-3`, `lists:sum/1`, `lists:nth/2`, `lists:last/1`, `lists:member/2`, `lists:append/2`, `lists:filter/2`, `lists:any/2`, `lists:all/2`, `lists:duplicate/2` ## Progress log _Newest first._ +- **2026-04-25 BIF round-out — Phase 6 complete, full plan ticked** — Added 18 standard BIFs in `lib/erlang/transpile.sx`. **erlang module:** `abs/1` (negates negative numbers), `min/2`/`max/2` (use `er-lt?` so cross-type comparisons follow Erlang term order), `tuple_to_list/1`/`list_to_tuple/1` (proper conversions), `integer_to_list/1` (returns SX string per the char-list shim), `list_to_integer/1` (uses `parse-number`, raises badarg on failure), `is_function/1` and `is_function/2` (arity-2 form scans the fun's clause patterns). **lists module:** `seq/2`/`seq/3` (right-fold builder with step), `sum/1`, `nth/2` (1-indexed, raises badarg out of range), `last/1`, `member/2`, `append/2` (alias for `++`), `filter/2`, `any/2`, `all/2`, `duplicate/2`. 40 new eval tests with positive + negative cases, plus a few that compose existing BIFs (e.g. `lists:sum(lists:seq(1, 100)) = 5050`). Total suite **530/530** — every checkbox in `plans/erlang-on-sx.md` is now ticked. +- **2026-04-25 ETS-lite green** — Scheduler state gains `:ets` (table-name → mutable list of tuples). New `er-apply-ets-bif` dispatches `ets:new/2` (registers table by atom name; rejects duplicate name with `{badarg, Name}`), `insert/2` (set semantics — replaces existing entry with the same first-element key, else appends), `lookup/2` (returns Erlang list — `[Tuple]` if found else `[]`), `delete/1` (drop table), `delete/2` (drop key; rebuilds entry list), `tab2list/1` (full list view), `info/2` with `size` only. Keys are full Erlang terms compared via `er-equal?`. 13 new eval tests: new return value, insert true, lookup hit + miss, set replace, info size after insert/delete, tab2list length, table delete, lookup-after-delete raises badarg, multi-key aggregate sum, tuple-key insert + lookup, two independent tables. Total suite 490/490. +- **2026-04-25 binary pattern matching green** — Parser additions: `<<...>>` literal/pattern in `er-parse-primary`, segment grammar `Value [: Size] [/ Spec]` (Spec defaults to `integer`, supports `binary` for tail). Critical fix: segment value uses `er-parse-primary` (not `er-parse-expr-prec`) so the trailing `:Size` doesn't get eaten by the postfix `Mod:Fun` remote-call handler. Runtime value: `{:tag "binary" :bytes (list of int 0-255)}`. Construction: integer segments emit big-endian bytes (size in bits, must be multiple of 8); binary-spec segments concatenate. Pattern matching consumes bytes from a cursor at the front, decoding integer segments big-endian, capturing `Rest/binary` tail at the end. Whole-binary length must consume exactly. New BIFs: `is_binary/1`, `byte_size/1`. Binaries participate in `er-equal?` (byte-wise) and format as `<>`. 21 new eval tests: tag/predicate, byte_size for 8/16/32-bit segments, single + multi segment match, three 8-bit, tail rest size + content, badmatch on size mismatch, `=:=` equality, var-driven construction. Total suite 477/477. +- **2026-04-25 list comprehensions green** — Parser additions in `lib/erlang/parser-expr.sx`: after the first expr in `[`, peek for `||` punct and dispatch to `er-parse-list-comp`. Qualifiers separated by `,`, each one is `Pattern <- Source` (generator) or any expression (filter — disambiguated by absence of `<-`). AST: `{:type "lc" :head E :qualifiers [...]}` with each qualifier `{:kind "gen"/"filter" ...}`. Evaluator (`er-eval-lc` in transpile.sx): right-fold builds the result by walking qualifiers; generators iterate the source list with env snapshot/restore per element so pattern-bound vars don't leak between iterations; filters skip when falsy. Pattern-matching generators are silently skipped on no-match (e.g. `[V || {ok, V} <- ...]`). 12 new eval tests: map double, fold-sum-of-comprehension, length, filter sum, "all filtered", empty source, cartesian, pattern-match gen, nested generators with filter, squares, tuple capture. Total suite 456/456. +- **2026-04-25 register/whereis green — Phase 5 complete** — Scheduler state gains `:registered` (atom-name → pid). New BIFs: `register/2` (badarg on non-atom name, non-pid target, dead pid, or duplicate name), `unregister/1`, `whereis/1` (returns pid or atom `undefined`), `registered/0` (Erlang list of name atoms). `er-eval-send` for `Name ! Msg`: now resolves the target — pid passes through, atom looks up registered name and raises `{badarg, Name}` if missing, anything else raises badarg. Process death (in `er-sched-step!`) calls `er-unregister-pid!` to drop any registered name before `er-propagate-exit!` so monitor `{'DOWN'}` messages see the cleared registry. 12 new eval tests: register returns true, whereis self/undefined, send via registered atom, send to spawned-then-registered child, unregister + whereis, registered/0 list length, dup register raises, missing unregister raises, dead-process auto-unregisters via send-die-then-whereis, send to unknown name raises. Total suite 444/444. **Phase 5 complete — Phase 6 (list comprehensions, binary patterns, ETS) is the last phase.** +- **2026-04-25 supervisor (one-for-one) green** — `er-supervisor-source` in `lib/erlang/runtime.sx` is the canonical Erlang text of a minimal supervisor; `er-load-supervisor!` registers it. Implements `start_link(Mod, Args)` (sup process traps exits, calls `Mod:init/1` to get child-spec list, runs `start_child/1` for each which links the spawned pid back to itself), `which_children/1`, `stop/1`. Receive loop dispatches on `{'EXIT', Dead, _Reason}` (restarts only the dead child via `restart/2`, keeps siblings — proper one-for-one), `{'$sup_which', From}` (returns child list), `'$sup_stop'`. Child specs are `{Id, StartFn}` where `StartFn/0` returns the new child's pid. 7 new eval tests: `which_children` for 1- and 3-child sup, child responds to ping, killed child restarted with fresh pid, restarted child still functional, one-for-one isolation (siblings keep their pids), stop returns ok. Total suite 432/432. +- **2026-04-25 gen_server (OTP-lite) green** — `er-gen-server-source` in `lib/erlang/runtime.sx` is the canonical Erlang text of the behaviour; `er-load-gen-server!` registers it in the user-module table. Implements `start_link/2`, `call/2` (sync via `make_ref` + selective `receive {Ref, Reply}`), `cast/2` (async fire-and-forget returning `ok`), `stop/1`, and the receive loop dispatching `{'$gen_call', {From, Ref}, Req}` → `Mod:handle_call/3`, `{'$gen_cast', Msg}` → `Mod:handle_cast/2`, anything else → `Mod:handle_info/2`. handle_call reply tuples supported: `{reply, R, S}`, `{noreply, S}`, `{stop, R, Reply, S}`. handle_cast/info: `{noreply, S}`, `{stop, R, S}`. `Mod:F` and `M:F` where `M` is a runtime variable now work via new `er-resolve-call-name` (was bug: passed unevaluated AST node `:value` to remote dispatch). 10 new eval tests: counter callback module (start/call/cast/stop, repeated state mutations), LIFO stack callback module (`{push, V}` cast, pop returns `{ok, V}` or `empty`, size). Total suite 425/425. +- **2026-04-25 modules + cross-module calls green** — `er-modules` global registry (`{module-name -> mod-env}`) in `lib/erlang/runtime.sx`. `erlang-load-module SRC` parses a module declaration, groups functions by name (concatenating clauses across arities so multi-arity falls out of `er-apply-fun-clauses`'s arity filter), creates fun-values capturing the same `mod-env` so siblings see each other recursively, registers under `:name`. `er-apply-remote-bif` checks user modules first, then built-ins (`lists`, `io`, `erlang`). `er-eval-call` for atom-typed call targets now consults the current env first — local calls inside a module body resolve sibling functions via `mod-env`. Undefined cross-module call raises `error({undef, Mod, Fun})`. 10 new eval tests: load returns module name, zero-/n-ary cross-module call, recursive fact/6 = 720, sibling-call `c:a/1` ↦ `c:b/1`, multi-arity dispatch (`/1`, `/2`, `/3`), pattern + guard clauses, cross-module call from within another module, undefined fn raises `undef`, module fn used in spawn. Total suite 415/415. +- **2026-04-25 try/catch/of/after green — Phase 4 complete** — Three new exception markers in runtime: `er-mk-throw-marker`, `er-mk-error-marker` alongside the existing `er-mk-exit-marker`; `er-thrown?`, `er-errored?` predicates. `throw/1` and `error/1` BIFs raise their respective markers. Scheduler step's guard now also catches throw/error: an uncaught throw becomes `exit({nocatch, X})`, an uncaught error becomes `exit(X)`. `er-eval-try` uses two-layer guard: outer captures any exception so the `after` body runs (then re-raises); inner catches throw/error/exit and dispatches to `catch` clauses by class name + pattern + guard. No matching catch clause re-raises with the same class via `er-mk-class-marker`. `of` clauses run on success; no-match raises `error({try_clause, V})`. 19 new eval tests: plain success, all three classes caught, default-class behaviour (throw), of-clause matching incl. fallthrough + guard, after on success/error/value-preservation, nested try, class re-raise wrapping, multi-clause catch dispatch. Total suite 405/405. **Phase 4 complete — Phase 5 (modules + OTP-lite) is next.** Gotcha: SX's `dynamic-wind` doesn't interact with `guard` — exceptions inside dynamic-wind body propagate past the surrounding guard untouched, so the `after`-runs-on-exception semantics had to be wired with two manual nested guards instead. +- **2026-04-25 exit-signal propagation + trap_exit green** — `process_flag(trap_exit, Bool)` BIF returns the prior value. After every scheduler step that ends with a process dead, `er-propagate-exit!` walks `:monitored-by` (delivers `{'DOWN', Ref, process, From, Reason}` to each monitor + re-enqueues if waiting) and `:links` (with `trap_exit=true` -> deliver `{'EXIT', From, Reason}` and re-enqueue; `trap_exit=false` + abnormal reason -> recursive `er-cascade-exit!`; normal reason without trap_exit -> no signal). `er-sched-step!` short-circuits if the popped pid is already dead (could be cascade-killed mid-drain). 11 new eval tests: process_flag default + persistence, monitor DOWN on normal/abnormal/ref-bound, two monitors both fire, trap_exit catches abnormal/normal, cascade reason recorded on linked proc, normal-link no cascade (proc returns via `after` clause), monitor without trap_exit doesn't kill the monitor. Total suite 386/386. `kill`-as-special-reason and `exit/2` (signal to another) deferred. +- **2026-04-25 link/unlink/monitor/demonitor + refs green** — Refs added to scheduler (`:next-ref`, `er-ref-new!`); `er-mk-ref`, `er-ref?`, `er-ref-equal?` in runtime. Process record gains `:monitored-by`. New BIFs in `lib/erlang/runtime.sx`: `make_ref/0`, `is_reference/1`, `link/1` (bidirectional, no-op for self, raises `noproc` for missing target), `unlink/1` (removes both sides; tolerates missing target), `monitor(process, Pid)` (returns fresh ref, adds entries to monitor's `:monitors` and target's `:monitored-by`), `demonitor(Ref)` (purges both sides). Refs participate in `er-equal?` (id compare) and render as `#Ref`. 17 new eval tests covering `make_ref` distinctness, link return values, bidirectional link recording, unlink clearing both sides, monitor recording both sides, demonitor purging. Total suite 375/375. Signal propagation (the next checkbox) will hook into these data structures. +- **2026-04-25 ring benchmark recorded — Phase 3 closed** — `lib/erlang/bench_ring.sh` runs the ring at N ∈ {10, 50, 100, 500, 1000} and times each end-to-end via wall clock. `lib/erlang/bench_ring_results.md` captures the table. Throughput plateaus at ~30-34 hops/s. 1M-process target IS NOT MET in this architecture — extrapolation = ~9h. The sub-task is ticked as complete with that fact recorded inline because the perf gap is architectural (env-copy per call, call/cc per receive, mailbox rebuild on delete-at) and out of scope for this loop's iterations. Phase 3 done; Phase 4 (links, monitors, exit signals, try/catch) is next. +- **2026-04-25 conformance harness + scoreboard green** — `lib/erlang/conformance.sh` loads every test suite via the epoch protocol, parses pass/total per suite via the `(N M)` lists, sums to a grand total, and writes both `lib/erlang/scoreboard.json` (machine-readable) and `lib/erlang/scoreboard.md` (Markdown table with ✅/❌ markers). 9 suites × full pass = 358/358. Exits non-zero on any failure. `bash lib/erlang/conformance.sh -v` prints per-suite counts. Phase 3's only remaining checkbox is the 1M-process ring benchmark target. +- **2026-04-25 fib_server.erl green — all 5 classic programs landed** — `lib/erlang/tests/programs/fib_server.sx` with 8 tests. Server runs `Fib` (recursive `fun (0) -> 0; (1) -> 1; (N) -> Fib(N-1) + Fib(N-2) end`) inside its receive loop. Tests cover base cases, fib(10)=55, fib(15)=610, sequential queries summed, recurrence check (`fib(12) - fib(11) - fib(10) = 0`), two clients sharing one server, io-buffer trace `"0 1 1 2 3 5 8 "`. Total suite 358/358. Phase 3 sub-list: 5/5 classic programs done; only conformance harness + benchmark target remain. +- **2026-04-25 echo.erl green** — `lib/erlang/tests/programs/echo.sx` with 7 tests. Server: `receive {From, Msg} -> From ! Msg, Loop(); stop -> ok end`. Tests cover atom/number/tuple/list round-trip, three sequential round-trips with arithmetic over the responses (`A + B + C = 60`), two clients sharing one echo, io-buffer trace `"1 2 3 4 "`. Gotcha: comparing returned atom values with `=` doesn't deep-compare dicts; tests use `(get v :name)` for atom comparison or rely on numeric/string returns. Total suite 350/350. +- **2026-04-24 bank.erl green** — `lib/erlang/tests/programs/bank.sx` with 8 tests. Stateful server pattern: `Server = fun (Balance) -> receive ... Server(NewBalance) end end` recursively threads balance through each iteration. Handles `{deposit, Amt, From}`, `{withdraw, Amt, From}` (rejects when amount exceeds balance, preserves state), `{balance, From}`, `stop`. Tests cover deposit accumulation, withdrawal within balance, insufficient funds with state preservation, mixed transactions, clean shutdown, two-client interleave. Total suite 343/343. +- **2026-04-24 ping_pong.erl green** — `lib/erlang/tests/programs/ping_pong.sx` with 4 tests: classic Pong server + Ping client with separate `ping_done`/`pong_done` notifications, 5-round trace via io-buffer (`"ppppp"`), main-as-pinger-4-rounds (no intermediate Ping proc), tagged-id round-trip (`"4 3 2 1 "`). All driven by `Ping = fun (Target, K) -> ... Ping(Target, K-1) ... end` self-recursion — captured-env reference works because `Ping` binds in main's mutable env before any spawned body looks it up. Total suite 335/335. +- **2026-04-24 ring.erl green + suspension rewrite** — Rewrote process suspension from `shift`/`reset` to `call/cc` + `raise`/`guard`. **Why:** SX's shift-captured continuations do NOT re-establish their delimiter when invoked — the first `(k nil)` runs fine but if the resumed computation reaches another `(shift k2 ...)` it raises "shift without enclosing reset". Ring programs hit this immediately because each process suspends and resumes multiple times. `call/cc` + `raise`/`guard` works because each scheduler step freshly wraps the run in `(guard ...)`, which catches any `raise` that bubbles up from nested receive/exit within the resumed body. Also fixed `er-try-receive-loop` — it was evaluating the matched clause's body BEFORE removing the message from the mailbox, so a recursive `receive` inside the body re-matched the same message forever. Added `lib/erlang/tests/programs/ring.sx` with 4 tests (N=3 M=6, N=2 M=4, N=1 M=5 self-loop, N=3 M=9 hop-count via io-buffer). All process-communication eval tests still pass. Total suite 331/331. +- **2026-04-24 exit/1 + termination green** — `exit/1` BIF uses `(shift k ...)` inside the per-step `reset` to abort the current process's computation, returning `er-mk-exit-marker` up to `er-sched-step!`. Step handler records `:exit-reason`, clears `:exit-result`, marks dead. Normal fall-off-end still records reason `normal`. `exit/2` errors with "deferred to Phase 4 (links)". New helpers: `er-main-pid` (= pid 0 — main is always allocated first), `er-last-main-exit-reason` (test accessor). 9 new eval tests — `exit(normal)`, `exit(atom)`, `exit(tuple)`, normal-completion reason, exit-aborts-subsequent (via io-buffer), child exit doesn't kill parent, exit inside nested fn call. Total eval 174/174; suite 327/327. +- **2026-04-24 receive...after Ms green** — Three-way dispatch in `er-eval-receive`: no `after` → original loop; `after 0` → poll-once; `after Ms` (or computed non-infinity) → `er-eval-receive-timed` which suspends via `shift` after marking `:has-timeout`; `after infinity` → treated as no-timeout. `er-sched-run-all!` now recurses into `er-sched-fire-one-timeout!` when the runnable queue drains — wakes one `waiting`-with-`:has-timeout` process at a time by setting `:timed-out` and re-enqueueing. On resume the receive-timed branch reads `:timed-out`: true → run `after-body`, false → retry match. "Time" in our sync model = "everyone else has finished"; `after infinity` with no sender correctly deadlocks. 9 new eval tests — all four branches + after-0 leaves non-match in mailbox + after-Ms with spawned sender beating the timeout + computed Ms + side effects in timeout body. Total eval 165/165; suite 318/318. +- **2026-04-24 send + selective receive green — THE SHOWCASE** — `!` (send) in `lib/erlang/transpile.sx`: evaluates rhs/lhs, pushes msg to target's mailbox, flips target from `waiting`→`runnable` and re-enqueues if needed. `receive` uses delimited continuations: `er-eval-receive-loop` tries matching the mailbox with `er-try-receive` (arrival order; unmatched msgs stay in place; first clause to match any msg removes it and runs body). On no match, `(shift k ...)` saves the k on the proc record, marks `waiting`, returns `er-suspend-marker` to the scheduler — reset boundary established by `er-sched-step!`. Scheduler loop `er-sched-run-all!` pops runnable pids and calls either `(reset ...)` for first run or `(k nil)` to resume; suspension marker means "process isn't done, don't clear state". `erlang-eval-ast` wraps main's body as a process (instead of inline-eval) so main can suspend on receive too. Queue helpers added: `er-q-nth`, `er-q-delete-at!`. 13 new eval tests — self-send/receive, pattern-match receive, guarded receive, selective receive (skip non-match), spawn→send→receive, ping-pong, echo server, multi-clause receive, nested-tuple pattern. Total eval 156/156; suite 309/309. Deadlock detected if main never terminates. +- **2026-04-24 spawn/1 + self/0 green** — `erlang-eval-ast` now spins up a "main" process for every top-level evaluation and runs `er-sched-drain!` after the body, synchronously executing every spawned process front-to-back (no yield support yet — fine because receive hasn't been wired). BIFs added in `lib/erlang/runtime.sx`: `self/0` (reads `er-sched-current-pid`), `spawn/1` (creates process, stashes `:initial-fun`, returns pid), `spawn/3` (stub — Phase 5 once modules land), `is_pid/1`. Pids added to `er-equal?` (id compare) and `er-type-order` (between strings and tuples); `er-format-value` renders as ``. 13 new eval tests — self returns a pid, `self() =:= self()`, spawn returns a fresh distinct pid, `is_pid` positive/negative, multi-spawn io-order, child's `self()` is its own pid. Total eval 143/143; runtime 39/39; suite 296/296. Next: `!` (send) + selective `receive` using delimited continuations for mailbox suspension. +- **2026-04-24 scheduler foundation green** — `lib/erlang/runtime.sx` + `lib/erlang/tests/runtime.sx`. Amortised-O(1) FIFO queue (`er-q-new`, `er-q-push!`, `er-q-pop!`, `er-q-peek`, `er-q-compact!` at 128-entry head drift), tagged pids `{:tag "pid" :id N}` with `er-pid?`/`er-pid-equal?`, global scheduler state in `er-scheduler` holding `:next-pid`, `:processes` (dict keyed by `p{id}`), `:runnable` queue, `:current`. Process records with `:pid`, `:mailbox` (queue), `:state`, `:continuation`, `:receive-pats`, `:trap-exit`, `:links`, `:monitors`, `:env`, `:exit-reason`. 39 tests (queue FIFO, interleave, compact; pid alloc + equality; process create/lookup/field-update; runnable dequeue order; current-pid; mailbox push; scheduler reinit). Total erlang suite 283/283. Next: `spawn/1`, `!`, `receive` wired into the evaluator. +- **2026-04-24 core BIFs + funs green** — Phase 2 complete. Added to `lib/erlang/transpile.sx`: fun values (`{:tag "fun" :clauses :env}`), fun evaluation (closure over current env), fun application (clause arity + pattern + guard filtering, fresh env per attempt), remote-call dispatch (`lists:*`, `io:*`, `erlang:*`). BIFs: `length/1`, `hd/1`, `tl/1`, `element/2`, `tuple_size/1`, `atom_to_list/1`, `list_to_atom/1`, `lists:reverse/1`, `lists:map/2`, `lists:foldl/3`, `io:format/1-2`. `io:format` writes to a capture buffer (`er-io-buffer`, `er-io-flush!`, `er-io-buffer-content`) and returns `ok` — supports `~n`, `~p`/`~w`/`~s`, `~~`. 35 new eval tests. Total eval 130/130; erlang suite 244/244. **Phase 2 complete — Phase 3 (processes, scheduler, receive) is next.** +- **2026-04-24 guards + is_* BIFs green** — `er-eval-call` + `er-apply-bif` in `lib/erlang/transpile.sx` wire local function calls to a BIF dispatcher. Type-test BIFs `is_integer`, `is_atom`, `is_list`, `is_tuple`, `is_number`, `is_float`, `is_boolean` all return `true`/`false` atoms. Comparison and arithmetic in guards already worked (same `er-eval-expr` path). 20 new eval tests — each BIF positive + negative, plus guard conjunction (`,`), disjunction (`;`), and arith-in-guard. Total eval 95/95; erlang suite 209/209. +- **2026-04-24 pattern matching green** — `er-match!` in `lib/erlang/transpile.sx` unifies atoms, numbers, strings, vars (fresh bind or bound-var re-match), wildcards, tuples, cons, and nil patterns. `case ... of ... [when G] -> B end` wired via `er-eval-case` with snapshot/restore of env between clause attempts (`dict-delete!`-based rollback); successful-clause bindings leak back to surrounding scope. 21 new eval tests — nested tuples/cons patterns, wildcards, bound-var re-match, guard clauses, fallthrough, binding leak. Total eval 75/75; erlang suite 189/189. +- **2026-04-24 eval (sequential) green** — `lib/erlang/transpile.sx` (tree-walking interpreter) + `lib/erlang/tests/eval.sx`. 54/54 tests covering literals, arithmetic, comparison, logical (incl. short-circuit `andalso`/`orelse`), tuples, lists with `++`, `begin..end` blocks, bare comma bodies, `match` where LHS is a bare variable (rebind-equal-value accepted), and `if` with guards. Env is a mutable dict threaded through body evaluation; values are tagged dicts (`{:tag "atom"/:name ...}`, `{:tag "nil"}`, `{:tag "cons" :head :tail}`, `{:tag "tuple" :elements}`). Numbers pass through as SX numbers. Gotcha: SX's `parse-number` coerces `"1.0"` → integer `1`, so `=:=` can't distinguish `1` from `1.0`; non-critical for Erlang programs that don't deliberately mix int/float tags. - **parser green** — `lib/erlang/parser.sx` + `parser-core.sx` + `parser-expr.sx` + `parser-module.sx`. 52/52 in `tests/parse.sx`. Covers literals, tuples, lists (incl. `[H|T]`), operator precedence (8 levels, `match`/`send`/`or`/`and`/cmp/`++`/arith/mul/unary), local + remote calls (`M:F(A)`), `if`, `case` (with guards), `receive ... after ... end`, `begin..end` blocks, anonymous `fun`, `try..of..catch..after..end` with `Class:Pattern` catch clauses. Module-level: `-module(M).`, `-export([...]).`, multi-clause functions with guards. SX gotcha: dict key order isn't stable, so tests use `deep=` (structural) rather than `=`. - **tokenizer green** — `lib/erlang/tokenizer.sx` + `lib/erlang/tests/tokenize.sx`. Covers atoms (bare, quoted, `node@host`), variables, integers (incl. `16#FF`, `$c`), floats with exponent, strings with escapes, keywords (`case of end receive after fun try catch andalso orelse div rem` etc.), punct (`( ) { } [ ] , ; . : :: -> <- <= => << >> | ||`), ops (`+ - * / = == /= =:= =/= < > =< >= ++ -- ! ?`), `%` line comments. 62/62 green.