diff --git a/lib/erlang/runtime.sx b/lib/erlang/runtime.sx index 88c26f85..4dc1a709 100644 --- a/lib/erlang/runtime.sx +++ b/lib/erlang/runtime.sx @@ -742,3 +742,75 @@ (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))))) diff --git a/lib/erlang/scoreboard.json b/lib/erlang/scoreboard.json index bd549191..9960afbe 100644 --- a/lib/erlang/scoreboard.json +++ b/lib/erlang/scoreboard.json @@ -1,11 +1,11 @@ { "language": "erlang", - "total_pass": 405, - "total": 405, + "total_pass": 415, + "total": 415, "suites": [ {"name":"tokenize","pass":62,"total":62,"status":"ok"}, {"name":"parse","pass":52,"total":52,"status":"ok"}, - {"name":"eval","pass":221,"total":221,"status":"ok"}, + {"name":"eval","pass":231,"total":231,"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"}, diff --git a/lib/erlang/scoreboard.md b/lib/erlang/scoreboard.md index 6c9db0f7..6f9e96f3 100644 --- a/lib/erlang/scoreboard.md +++ b/lib/erlang/scoreboard.md @@ -1,12 +1,12 @@ # Erlang-on-SX Scoreboard -**Total: 405 / 405 tests passing** +**Total: 415 / 415 tests passing** | | Suite | Pass | Total | |---|---|---|---| | ✅ | tokenize | 62 | 62 | | ✅ | parse | 52 | 52 | -| ✅ | eval | 221 | 221 | +| ✅ | eval | 231 | 231 | | ✅ | runtime | 39 | 39 | | ✅ | ring | 4 | 4 | | ✅ | ping-pong | 4 | 4 | diff --git a/lib/erlang/tests/eval.sx b/lib/erlang/tests/eval.sx index ce4ca612..530f8faf 100644 --- a/lib/erlang/tests/eval.sx +++ b/lib/erlang/tests/eval.sx @@ -634,6 +634,71 @@ (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")) + (define er-eval-test-summary (str "eval " er-eval-test-pass "/" er-eval-test-count)) diff --git a/lib/erlang/transpile.sx b/lib/erlang/transpile.sx index 48d1fb3b..00afed25 100644 --- a/lib/erlang/transpile.sx +++ b/lib/erlang/transpile.sx @@ -479,7 +479,12 @@ ((fun-node (get node :fun)) (args (get node :args))) (cond (= (get fun-node :type) "atom") - (er-apply-bif (get fun-node :value) (er-eval-args args env)) + (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") (er-apply-remote-bif (get (get fun-node :mod) :value) @@ -584,6 +589,8 @@ (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) diff --git a/plans/erlang-on-sx.md b/plans/erlang-on-sx.md index 2baff518..152c18ec 100644 --- a/plans/erlang-on-sx.md +++ b/plans/erlang-on-sx.md @@ -84,7 +84,7 @@ Core mapping: - [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 +- [x] `-module(M).` loading, `M:F(...)` calls across modules — **10 new eval tests**; multi-arity, sibling calls, cross-module dispatch via `er-modules` registry - [ ] `gen_server` behaviour (the big OTP win) - [ ] `supervisor` (simple one-for-one) - [ ] Registered processes: `register/2`, `whereis/1` @@ -99,6 +99,7 @@ Core mapping: _Newest first._ +- **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.