From 3d092dd78e06d8c2d1292e868566cbdc5182796c Mon Sep 17 00:00:00 2001 From: giles Date: Thu, 14 May 2026 20:07:35 +0000 Subject: [PATCH] erlang: er-to-sx / er-of-sx term marshalling (+23 runtime tests) --- lib/erlang/runtime.sx | 64 +++++++++++++++++++++++++++++++++++++ lib/erlang/scoreboard.json | 6 ++-- lib/erlang/scoreboard.md | 4 +-- lib/erlang/tests/runtime.sx | 55 +++++++++++++++++++++++++++++++ plans/erlang-on-sx.md | 4 ++- 5 files changed, 127 insertions(+), 6 deletions(-) diff --git a/lib/erlang/runtime.sx b/lib/erlang/runtime.sx index 6b5cc518..653a726f 100644 --- a/lib/erlang/runtime.sx +++ b/lib/erlang/runtime.sx @@ -893,6 +893,70 @@ (define er-list-bifs (fn () (keys (er-bif-registry-get)))) +;; ── term marshalling (Phase 8) ─────────────────────────────────── +;; Bridge Erlang term values (tagged dicts) and SX-native values for +;; FFI BIFs to call out into platform primitives. Conversions: +;; +;; Erlang SX-native +;; ───────────────────────── ──────────────── +;; atom {:tag "atom" :name S} ↔ symbol (make-symbol S) +;; nil {:tag "nil"} ↔ '() +;; cons {:tag "cons" :head :tail} → list of marshalled elements +;; tuple {:tag "tuple" :elements} → list of marshalled elements +;; binary {:tag "binary" :bytes} ↔ SX string +;; integer / float / boolean ↔ passthrough +;; SX string on the way back → binary +;; +;; Pids, refs, funs pass through unchanged — they have no SX-native +;; equivalent and are opaque to FFI primitives. + +(define er-cons-to-sx-list + (fn (v) + (cond + (er-nil? v) (list) + (er-cons? v) + (let ((tail (er-cons-to-sx-list (get v :tail))) + (head (er-to-sx (get v :head)))) + (let ((out (list head))) + (for-each + (fn (i) (append! out (nth tail i))) + (range 0 (len tail))) + out)) + :else (list v)))) + +(define er-to-sx + (fn (v) + (cond + (er-atom? v) (make-symbol (get v :name)) + (er-nil? v) (list) + (er-cons? v) (er-cons-to-sx-list v) + (er-tuple? v) + (let ((out (list)) (es (get v :elements))) + (for-each + (fn (i) (append! out (er-to-sx (nth es i)))) + (range 0 (len es))) + out) + (er-binary? v) (list->string (map integer->char (get v :bytes))) + :else v))) + +(define er-of-sx + (fn (v) + (let ((ty (type-of v))) + (cond + (= ty "symbol") (er-mk-atom (str v)) + (= ty "string") (er-mk-binary (map char->integer (string->list v))) + (= ty "list") + (let ((out (er-mk-nil))) + (for-each + (fn (i) + (set! out + (er-mk-cons (er-of-sx (nth v (- (- (len v) 1) i))) out))) + (range 0 (len v))) + out) + (= ty "nil") (er-mk-nil) + :else v)))) + + ;; Load an Erlang module declaration. Source must start with diff --git a/lib/erlang/scoreboard.json b/lib/erlang/scoreboard.json index 1462fc58..a36308cd 100644 --- a/lib/erlang/scoreboard.json +++ b/lib/erlang/scoreboard.json @@ -1,12 +1,12 @@ { "language": "erlang", - "total_pass": 600, - "total": 600, + "total_pass": 623, + "total": 623, "suites": [ {"name":"tokenize","pass":62,"total":62,"status":"ok"}, {"name":"parse","pass":52,"total":52,"status":"ok"}, {"name":"eval","pass":385,"total":385,"status":"ok"}, - {"name":"runtime","pass":70,"total":70,"status":"ok"}, + {"name":"runtime","pass":93,"total":93,"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"}, diff --git a/lib/erlang/scoreboard.md b/lib/erlang/scoreboard.md index 185cd415..70446c82 100644 --- a/lib/erlang/scoreboard.md +++ b/lib/erlang/scoreboard.md @@ -1,13 +1,13 @@ # Erlang-on-SX Scoreboard -**Total: 600 / 600 tests passing** +**Total: 623 / 623 tests passing** | | Suite | Pass | Total | |---|---|---|---| | ✅ | tokenize | 62 | 62 | | ✅ | parse | 52 | 52 | | ✅ | eval | 385 | 385 | -| ✅ | runtime | 70 | 70 | +| ✅ | runtime | 93 | 93 | | ✅ | ring | 4 | 4 | | ✅ | ping-pong | 4 | 4 | | ✅ | bank | 8 | 8 | diff --git a/lib/erlang/tests/runtime.sx b/lib/erlang/tests/runtime.sx index 0ca525c8..b0b7fbe5 100644 --- a/lib/erlang/tests/runtime.sx +++ b/lib/erlang/tests/runtime.sx @@ -211,6 +211,61 @@ (er-rt-test "reset clears" (len (er-list-bifs)) 0) (er-rt-test "reset lookup nil" (er-lookup-bif "fake" "echo" 1) nil) + + +;; ── Phase 8: term marshalling (er-to-sx / er-of-sx) ───────────── + +;; er-to-sx: Erlang → SX +(er-rt-test "to-sx atom" (er-to-sx (er-mk-atom "foo")) (make-symbol "foo")) +(er-rt-test "to-sx atom is symbol" (type-of (er-to-sx (er-mk-atom "x"))) "symbol") +(er-rt-test "to-sx nil" (er-to-sx (er-mk-nil)) (list)) +(er-rt-test "to-sx integer passthrough" (er-to-sx 42) 42) +(er-rt-test "to-sx float passthrough" (er-to-sx 3.14) 3.14) +(er-rt-test "to-sx boolean passthrough" (er-to-sx true) true) +(er-rt-test "to-sx binary → string" + (er-to-sx (er-mk-binary (list 104 105 33))) "hi!") +(er-rt-test "to-sx cons → list" + (er-to-sx (er-mk-cons 1 (er-mk-cons 2 (er-mk-cons 3 (er-mk-nil))))) (list 1 2 3)) +(er-rt-test "to-sx tuple → list" + (er-to-sx (er-mk-tuple (list 1 2 3))) (list 1 2 3)) +(er-rt-test "to-sx nested cons" + (er-to-sx (er-mk-cons (er-mk-atom "a") (er-mk-cons 7 (er-mk-nil)))) + (list (make-symbol "a") 7)) + +;; er-of-sx: SX → Erlang +(er-rt-test "of-sx symbol" + (get (er-of-sx (make-symbol "ok")) :name) "ok") +(er-rt-test "of-sx symbol is atom" + (er-atom? (er-of-sx (make-symbol "x"))) true) +(er-rt-test "of-sx string is binary" + (er-binary? (er-of-sx "hi")) true) +(er-rt-test "of-sx string bytes" + (get (er-of-sx "hi") :bytes) (list 104 105)) +(er-rt-test "of-sx integer passthrough" + (er-of-sx 42) 42) +(er-rt-test "of-sx empty list → nil" + (er-nil? (er-of-sx (list))) true) +(er-rt-test "of-sx list → cons chain length" + (er-list-length (er-of-sx (list 1 2 3 4))) 4) +(er-rt-test "of-sx list head/tail" + (get (er-of-sx (list 10 20)) :head) 10) + +;; Round-trips +(er-rt-test "rtrip integer" (er-to-sx (er-of-sx 99)) 99) +(er-rt-test "rtrip atom" + (get (er-of-sx (er-to-sx (er-mk-atom "abc"))) :name) "abc") +(er-rt-test "rtrip binary bytes" + (get (er-of-sx (er-to-sx (er-mk-binary (list 1 2 3)))) :bytes) (list 1 2 3)) +(er-rt-test "rtrip cons-of-ints length" + (er-list-length (er-of-sx (er-to-sx + (er-mk-cons 1 (er-mk-cons 2 (er-mk-cons 3 (er-mk-nil))))))) 3) + +;; Tuples don't round-trip exactly (er-to-sx flattens tuples to lists); +;; documented one-way conversion. +(er-rt-test "to-sx of tuple loses tag" + (er-cons? (er-of-sx (er-to-sx (er-mk-tuple (list 1 2 3))))) true) + + ;; Re-populate built-in BIFs so subsequent test files (ring, ping-pong, etc.) ;; can call length/spawn/etc. The migration onto the registry means a reset ;; here would otherwise break the rest of the conformance suite. diff --git a/plans/erlang-on-sx.md b/plans/erlang-on-sx.md index efda6173..13dbb8ed 100644 --- a/plans/erlang-on-sx.md +++ b/plans/erlang-on-sx.md @@ -114,7 +114,7 @@ Replace today's hardcoded BIF dispatch (`er-apply-bif`/`er-apply-remote-bif` in - [x] BIF registry: `er-bif-registry` global dict keyed by `"Module/Name/Arity"`, with `er-register-bif!`/`er-register-pure-bif!`/`er-lookup-bif`/`er-list-bifs`/`er-bif-registry-reset!` helpers — **+18 runtime tests** (600/600 total). Entries are `{:module :name :arity :fn :pure?}`. Arity is part of the key so `m:f/1` and `m:f/2` are independent. Re-registering the same key replaces the previous entry; reset clears. - [x] Migrate existing local + remote BIFs (length/hd/tl/lists:*/io:format/ets:*/etc.) onto the registry; delete the giant `cond` dispatch in `er-apply-bif`/`er-apply-remote-bif`. Conformance held at **600/600** after migration (baseline was 600, not the plan-text's 530 — the text was authored before Phase 7 work added rows). 67 builtin registrations across `erlang`/`lists`/`io`/`ets`/`code` modules; multi-arity BIFs (`is_function`, `spawn`, `exit`, `io:format`, `lists:seq`, `ets:delete`) register once per arity, all pointing at the same impl which dispatches on `(len vs)` internally. The four per-module cond dispatchers (`er-apply-lists-bif`, `er-apply-io-bif`, `er-apply-ets-bif`, `er-apply-code-bif`) are deleted. `er-apply-bif` and `er-apply-remote-bif` are now ~5-line registry lookups; user modules still win precedence over the registry. -- [ ] Term-marshalling helpers: `er-of-sx` and `er-to-sx` (atom ↔ symbol, tuple ↔ list-of-elements, Erlang list ↔ SX list, binary ↔ string, dict ↔ map). Round-trip tests +- [x] Term-marshalling helpers: `er-of-sx` (SX → Erlang) and `er-to-sx` (Erlang → SX). atom ↔ symbol, nil ↔ `()`, cons → list, tuple → list (one-way; tuples flatten), binary ↔ SX string, integer / float / boolean passthrough. **+23 runtime tests** (623/623 total). Erlang maps (`dict ↔ map`) deferred — Erlang map term not implemented in this port; will land when `#{}` syntax does. Pids, refs, funs pass through unchanged. SX strings on the way back become Erlang binaries (most useful FFI return shape). - [ ] `crypto:hash/2` — `sha256`, `sha512`, `blake3`; takes a binary, returns a binary. Uses the SX-host hash primitive - [ ] `cid:from_bytes/1`, `cid:to_string/1` — content-address an arbitrary binary - [ ] `file:read_file/1`, `file:write_file/2`, `file:list_dir/1`, `file:delete/1` — sync filesystem ops returning `{ok, Bin}` / `{error, Reason}` @@ -126,6 +126,8 @@ Replace today's hardcoded BIF dispatch (`er-apply-bif`/`er-apply-remote-bif` in _Newest first._ +- **2026-05-14 term-marshalling helpers landed** — `er-to-sx` (Erlang term → SX-native) and `er-of-sx` (SX-native → Erlang term) plus internal helper `er-cons-to-sx-list` (recursive cons-chain walker). All three live in `runtime.sx` next to the BIF registry. Conversion table: atom ↔ symbol via `make-symbol`/`er-mk-atom`; nil ↔ `()`; cons-chain → SX list (recursive marshal of each head); tuple → SX list (one-way — tuples flatten and can't be reconstructed without a tag); binary ↔ SX string (bytes ↔ char codes via `char->integer`/`integer->char`); integer / float / boolean passthrough; opaque types (pid, ref, fun) passthrough. SX strings on the way back become Erlang binaries — the natural FFI return shape. Empty SX list (`type-of` `"nil"`) marshals back to `er-mk-nil`. Edit gotchas during implementation: SX has no `while`, `string-ref`, or `string-length` primitive — used `(map char->integer (string->list s))` for byte extraction and a recursive helper for cons-walking. 23 new runtime tests in `tests/runtime.sx`: 10 covering `er-to-sx` (atom/atom-is-symbol, nil, int / float / bool passthrough, binary→string, cons→list, tuple→list, nested), 8 covering `er-of-sx` (symbol→atom, atom-tag, string→binary, byte content, int passthrough, empty-list→nil, list→cons length, head field), 4 round-trips (int, atom, binary bytes, list length), 1 negative documenting that tuple round-trip flattens to cons. Total **623/623** (+23 runtime). + - **2026-05-14 BIF registry migration complete — cond chains gone** — `er-register-builtin-bifs!` at the end of `runtime.sx` populates the registry with all 67 built-in BIFs in five module namespaces. Pure ops (`length`, `hd`, `tl`, `element`, predicates, arithmetic, list/atom/integer conversions, all of `lists`) registered via `er-register-pure-bif!`; side-effecting ops (`spawn`, `self`, `exit`, `link`/`monitor`/`register`, `process_flag`, `make_ref`, `throw`/`error`, `io:format`, all of `ets`, all of `code`) via `er-register-bif!`. Multi-arity entries: `is_function/1`/`/2`, `spawn/1`/`/3`, `exit/1`/`/2`, `io:format/1`/`/2`, `lists:seq/2`/`/3`, `ets:delete/1`/`/2` — six pairs, twelve registrations, all pointing at the existing arity-dispatching impl. `throw` and `error` are registered with a tiny inline `(fn (vs) (raise ...))` lambda because the original code chained directly through `raise` inside the cond instead of an `er-bif-*` helper. `er-apply-bif` shrinks from a 44-line cond chain to a 5-line registry lookup. `er-apply-remote-bif` becomes a 7-line dispatcher (user-modules-first → registry → error). All four per-module dispatchers (`er-apply-lists-bif`, `er-apply-io-bif`, `er-apply-ets-bif`, `er-apply-code-bif`) deleted — net reduction ~110 lines of cond machinery. One subtle wrinkle: `tests/runtime.sx` calls `er-bif-registry-reset!` near the end of its BIF-registry tests, which would have left subsequent test files (ring, ping-pong, etc.) unable to call `length`/`spawn`/etc. Fix: re-call `er-register-builtin-bifs!` at the bottom of `tests/runtime.sx` to repopulate. Total **600/600** unchanged. - **2026-05-14 Phase 8 BIF registry foundation** — `lib/erlang/runtime.sx` gains `er-bif-registry` (a `(list {})` mutable cell, same shape as `er-modules`) and five helpers: `er-bif-registry-get`/`er-bif-registry-reset!` (access + reset), `er-bif-key` (format `"Module/Name/Arity"`), `er-register-bif!` and `er-register-pure-bif!` (both upsert; differ only in the `:pure?` flag — pure ones are safe to inline, side-effecting ones go through normal IO), `er-lookup-bif` (returns the entry dict or nil), `er-list-bifs` (registered keys). Entries are `{:module :name :arity :fn :pure?}`. Lookup miss → nil; arity is part of the key so `m:f/1` and `m:f/2` are distinct; re-registering the same key replaces in-place (count stays the same); reset clears. Registry sits alongside `er-modules` in runtime.sx so any other piece of the system can register BIFs without touching the dispatcher — the migration onto this registry (the next checkbox) will rip out the giant cond chains in `er-apply-bif`/`er-apply-remote-bif`. 18 new runtime tests in `tests/runtime.sx`: empty-state, lookup-miss, register-grows-count, lookup-hit-fields (module/name/arity/pure?), fn-invocable, re-register-replaces, pure-flag-true, arity-disambiguation (3 entries for `fake:echo/1`, `fake:echo/2`, `fake:pure/2`), reset-clears, reset-lookup-nil. Total **600/600** (+18 runtime).