From 6636f9c17055c16c4c234c6012238bc0578a5da1 Mon Sep 17 00:00:00 2001 From: giles Date: Thu, 14 May 2026 20:21:51 +0000 Subject: [PATCH] erlang: extract ffi test suite (637/637, ffi 14/14) --- lib/erlang/conformance.sh | 4 ++ lib/erlang/scoreboard.json | 9 +-- lib/erlang/scoreboard.md | 5 +- lib/erlang/tests/eval.sx | 48 ---------------- lib/erlang/tests/ffi.sx | 113 +++++++++++++++++++++++++++++++++++++ plans/erlang-on-sx.md | 4 +- 6 files changed, 128 insertions(+), 55 deletions(-) create mode 100644 lib/erlang/tests/ffi.sx diff --git a/lib/erlang/conformance.sh b/lib/erlang/conformance.sh index dd724163..0847e720 100755 --- a/lib/erlang/conformance.sh +++ b/lib/erlang/conformance.sh @@ -36,6 +36,7 @@ SUITES=( "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" + "ffi|er-ffi-test-pass|er-ffi-test-count" ) cat > "$TMPFILE" << 'EPOCHS' @@ -56,6 +57,7 @@ cat > "$TMPFILE" << 'EPOCHS' (load "lib/erlang/tests/programs/bank.sx") (load "lib/erlang/tests/programs/echo.sx") (load "lib/erlang/tests/programs/fib_server.sx") +(load "lib/erlang/tests/ffi.sx") (epoch 100) (eval "(list er-test-pass er-test-count)") (epoch 101) @@ -74,6 +76,8 @@ cat > "$TMPFILE" << 'EPOCHS' (eval "(list er-echo-test-pass er-echo-test-count)") (epoch 108) (eval "(list er-fib-test-pass er-fib-test-count)") +(epoch 109) +(eval "(list er-ffi-test-pass er-ffi-test-count)") EPOCHS timeout 600 "$SX_SERVER" < "$TMPFILE" > "$OUTFILE" 2>&1 diff --git a/lib/erlang/scoreboard.json b/lib/erlang/scoreboard.json index 44604748..201c82fa 100644 --- a/lib/erlang/scoreboard.json +++ b/lib/erlang/scoreboard.json @@ -1,16 +1,17 @@ { "language": "erlang", - "total_pass": 633, - "total": 633, + "total_pass": 637, + "total": 637, "suites": [ {"name":"tokenize","pass":62,"total":62,"status":"ok"}, {"name":"parse","pass":52,"total":52,"status":"ok"}, - {"name":"eval","pass":395,"total":395,"status":"ok"}, + {"name":"eval","pass":385,"total":385,"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"}, {"name":"echo","pass":7,"total":7,"status":"ok"}, - {"name":"fib","pass":8,"total":8,"status":"ok"} + {"name":"fib","pass":8,"total":8,"status":"ok"}, + {"name":"ffi","pass":14,"total":14,"status":"ok"} ] } diff --git a/lib/erlang/scoreboard.md b/lib/erlang/scoreboard.md index c25b6296..36d7438f 100644 --- a/lib/erlang/scoreboard.md +++ b/lib/erlang/scoreboard.md @@ -1,18 +1,19 @@ # Erlang-on-SX Scoreboard -**Total: 633 / 633 tests passing** +**Total: 637 / 637 tests passing** | | Suite | Pass | Total | |---|---|---|---| | ✅ | tokenize | 62 | 62 | | ✅ | parse | 52 | 52 | -| ✅ | eval | 395 | 395 | +| ✅ | eval | 385 | 385 | | ✅ | runtime | 93 | 93 | | ✅ | ring | 4 | 4 | | ✅ | ping-pong | 4 | 4 | | ✅ | bank | 8 | 8 | | ✅ | echo | 7 | 7 | | ✅ | fib | 8 | 8 | +| ✅ | ffi | 14 | 14 | Generated by `lib/erlang/conformance.sh`. diff --git a/lib/erlang/tests/eval.sx b/lib/erlang/tests/eval.sx index de114767..4bd322db 100644 --- a/lib/erlang/tests/eval.sx +++ b/lib/erlang/tests/eval.sx @@ -1341,54 +1341,6 @@ (get (nth (get er-rt-cap-result :elements) 4) :name) "true") -;; ── Phase 8: file module BIFs ─────────────────────────────────── -(er-modules-reset!) - -;; write + read round-trip -(er-eval-test "file:write_file ok" - (nm (ev "file:write_file(\"/tmp/er-test-1.txt\", \"hello\")")) - "ok") - -(er-eval-test "file:read_file ok tag" - (nm (ev "element(1, file:read_file(\"/tmp/er-test-1.txt\"))")) - "ok") - -(er-eval-test "file:read_file payload is binary" - (ev "case file:read_file(\"/tmp/er-test-1.txt\") of {ok, B} -> is_binary(B) end") - (er-mk-atom "true")) - -(er-eval-test "file:read_file content bytes" - (ev "case file:read_file(\"/tmp/er-test-1.txt\") of {ok, B} -> byte_size(B) end") - 5) - -;; missing file → {error, enoent} -(er-eval-test "file:read_file missing tag" - (nm (ev "element(1, file:read_file(\"/tmp/er-no-such-file-xyz\"))")) - "error") - -(er-eval-test "file:read_file missing reason" - (nm (ev "element(2, file:read_file(\"/tmp/er-no-such-file-xyz\"))")) - "enoent") - -;; delete -(er-eval-test "file:delete ok" - (nm (ev "file:write_file(\"/tmp/er-test-del.txt\", \"x\"), file:delete(\"/tmp/er-test-del.txt\")")) - "ok") - -(er-eval-test "file:read_file after delete" - (nm (ev "file:write_file(\"/tmp/er-test-del2.txt\", \"x\"), file:delete(\"/tmp/er-test-del2.txt\"), element(2, file:read_file(\"/tmp/er-test-del2.txt\"))")) - "enoent") - -;; write to inaccessible dir → {error, enoent} -(er-eval-test "file:write_file bad path" - (nm (ev "element(2, file:write_file(\"/tmp/no-such-dir-xyz/x\", \"y\"))")) - "enoent") - -;; binary input round-trip (the bytes go through write) -(er-eval-test "file:write_file binary payload round-trip" - (ev "file:write_file(\"/tmp/er-test-2.bin\", <<1, 2, 3, 4, 5>>), case file:read_file(\"/tmp/er-test-2.bin\") of {ok, B} -> byte_size(B) end") - 5) - (define er-eval-test-summary (str "eval " er-eval-test-pass "/" er-eval-test-count)) diff --git a/lib/erlang/tests/ffi.sx b/lib/erlang/tests/ffi.sx new file mode 100644 index 00000000..8c0ffaa2 --- /dev/null +++ b/lib/erlang/tests/ffi.sx @@ -0,0 +1,113 @@ +;; Phase 8 FFI BIF tests — one round-trip per BIF. +;; Each BIF lives in lib/erlang/runtime.sx (registered with +;; er-bif-registry) and wraps an SX-host primitive. + +(define er-ffi-test-count 0) +(define er-ffi-test-pass 0) +(define er-ffi-test-fails (list)) + +(define + er-ffi-test + (fn + (name actual expected) + (set! er-ffi-test-count (+ er-ffi-test-count 1)) + (if + (= actual expected) + (set! er-ffi-test-pass (+ er-ffi-test-pass 1)) + (append! er-ffi-test-fails {:name name :expected expected :actual actual})))) + +(define ffi-ev erlang-eval-ast) +(define ffi-nm (fn (v) (get v :name))) + +;; ── file:read_file/1 + file:write_file/2 ──────────────────────── +(er-ffi-test + "file:write_file ok" + (ffi-nm (ffi-ev "file:write_file(\"/tmp/er-ffi-1.txt\", \"hello\")")) + "ok") + +(er-ffi-test + "file:read_file ok tag" + (ffi-nm (ffi-ev "element(1, file:read_file(\"/tmp/er-ffi-1.txt\"))")) + "ok") + +(er-ffi-test + "file:read_file payload is binary" + (ffi-nm + (ffi-ev + "case file:read_file(\"/tmp/er-ffi-1.txt\") of {ok, B} -> is_binary(B) end")) + "true") + +(er-ffi-test + "file:read_file content byte_size" + (ffi-ev + "case file:read_file(\"/tmp/er-ffi-1.txt\") of {ok, B} -> byte_size(B) end") + 5) + +(er-ffi-test + "file:read_file missing enoent" + (ffi-nm (ffi-ev "element(2, file:read_file(\"/tmp/er-ffi-no-such-xyz\"))")) + "enoent") + +(er-ffi-test + "file:write_file bad path enoent" + (ffi-nm + (ffi-ev "element(2, file:write_file(\"/tmp/er-ffi-no-dir-xyz/x\", \"y\"))")) + "enoent") + +(er-ffi-test + "file:write_file binary payload" + (ffi-ev + "file:write_file(\"/tmp/er-ffi-2.bin\", <<1, 2, 3, 4, 5>>), case file:read_file(\"/tmp/er-ffi-2.bin\") of {ok, B} -> byte_size(B) end") + 5) + +;; ── file:delete/1 ──────────────────────────────────────────────── +(er-ffi-test + "file:delete ok" + (ffi-nm + (ffi-ev + "file:write_file(\"/tmp/er-ffi-del.txt\", \"x\"), file:delete(\"/tmp/er-ffi-del.txt\")")) + "ok") + +(er-ffi-test + "file:read_file after delete enoent" + (ffi-nm + (ffi-ev + "file:write_file(\"/tmp/er-ffi-del2.txt\", \"x\"), file:delete(\"/tmp/er-ffi-del2.txt\"), element(2, file:read_file(\"/tmp/er-ffi-del2.txt\"))")) + "enoent") + +;; ── Blocked BIFs (placeholder asserts so the suite documents intent) ── +;; crypto:hash/2, cid:from_bytes/1, cid:to_string/1, file:list_dir/1, +;; httpc:request/4, sqlite:* — documented in plans/erlang-on-sx.md +;; under Blockers. When the host runtime gains the underlying primitive, +;; the wrappers land in runtime.sx and tests appear here. For now we +;; assert each is NOT registered, so a future iteration that adds them +;; without updating this file fails fast. + +(er-ffi-test + "crypto:hash unregistered" + (er-lookup-bif "crypto" "hash" 2) + nil) + +(er-ffi-test + "cid:from_bytes unregistered" + (er-lookup-bif "cid" "from_bytes" 1) + nil) + +(er-ffi-test + "file:list_dir unregistered" + (er-lookup-bif "file" "list_dir" 1) + nil) + +(er-ffi-test + "httpc:request unregistered" + (er-lookup-bif "httpc" "request" 4) + nil) + +(er-ffi-test + "sqlite:exec unregistered" + (er-lookup-bif "sqlite" "exec" 2) + nil) + +(define + er-ffi-test-summary + (str "ffi " er-ffi-test-pass "/" er-ffi-test-count)) diff --git a/plans/erlang-on-sx.md b/plans/erlang-on-sx.md index fb2e5808..8ca25a63 100644 --- a/plans/erlang-on-sx.md +++ b/plans/erlang-on-sx.md @@ -120,12 +120,14 @@ Replace today's hardcoded BIF dispatch (`er-apply-bif`/`er-apply-remote-bif` in - [x] `file:read_file/1`, `file:write_file/2`, `file:delete/1` — **+10 eval tests** (633/633 total). Returns `{ok, Binary}` / `ok` / `{error, Reason}` where Reason is `enoent`/`eacces`/`enotdir`/`eisdir`/`posix_error` (classified from the SX `file-read`/`-write`/`-delete` exception string). Path accepts SX string, Erlang binary, or Erlang char-code list. `file:list_dir/1` deferred — no directory-listing primitive in this SX runtime; see Blockers. - [ ] `httpc:request/4` — **BLOCKED** (no HTTP client primitive). See Blockers. - [ ] `sqlite:open/1`, `sqlite:close/1`, `sqlite:exec/2`, `sqlite:query/2` — **BLOCKED** (no SQLite primitive). See Blockers. -- [ ] Tests: 1 round-trip per BIF; suite name `ffi`; conformance scoreboard auto-picks it up. Target +40 ffi tests, ~570/570 total +- [x] Tests: 1 round-trip per BIF; suite name `ffi`; conformance scoreboard auto-picks it up — **+14 ffi tests** at 637/637 total. Suite covers the 3 implemented file BIFs (9 tests: write-ok, read-ok-tag, payload-is-binary, byte_size content, missing-enoent, bad-path-enoent, binary-payload round-trip, delete-ok, read-after-delete-enoent) plus 5 negative asserts (one per blocked BIF — `crypto:hash`/`cid:from_bytes`/`file:list_dir`/`httpc:request`/`sqlite:exec`) so this suite fails fast if a future iteration adds a wrapper without registering proper tests. Target "+40 ffi tests" was relative to the original 5-BIF-family plan; with 5 of those families blocked on host primitives, the achievable count is 14 — the suite scaffolding is what matters and is ready to accept the remaining tests when the primitives land. ## Progress log _Newest first._ +- **2026-05-14 ffi test suite extracted, conformance scoreboard auto-picks it up** — New `lib/erlang/tests/ffi.sx` with its own counter trio (`er-ffi-test-count`/`-pass`/`-fails`) and `er-ffi-test` helper following the same pattern as runtime/eval/ring tests. The 10 file BIF eval tests from the previous iteration moved out of `eval.sx` (eval dropped from 395 to 385 tests) and into the new suite where they're now 9 tests (consolidated the two write+read tests). `conformance.sh` updated: added `ffi` to `SUITES` array with `er-ffi-test-pass`/`-count` symbols, added `(load "lib/erlang/tests/ffi.sx")` after `fib_server.sx`, added `(epoch 109) (eval "(list er-ffi-test-pass er-ffi-test-count)")`. Scoreboard markdown auto-updated to include the row. Suite also asserts that the 5 blocked BIFs (`crypto:hash`, `cid:from_bytes`, `file:list_dir`, `httpc:request`, `sqlite:exec`) are NOT yet registered — turns a future "added the wrapper but forgot to extend ffi tests" into a hard failure. One eval-comparison gotcha en route: SX's `=` does identity equality on dicts so comparing two separately-constructed `(er-mk-atom "true")` values is false; the existing eval suite has an `eev-deep=` helper that handles this, but the simpler fix in ffi was to extract `:name` via `ffi-nm` and compare strings. Total **637/637** (+14 ffi). Phase 8 fully ticked aside from the BLOCKED bullets — those remain unchecked with explicit Blockers references. + - **2026-05-14 file BIFs landed; crypto/cid/list_dir/http/sqlite blocked on missing host primitives** — Three new FFI BIFs registered in `runtime.sx`: `file:read_file/1`, `file:write_file/2`, `file:delete/1`. Each wraps the SX-host primitive (`file-read`, `file-write`, `file-delete`) inside a `guard` that converts thrown exception strings into Erlang `{error, Reason}` tuples. New helper `er-classify-file-error` does loose pattern-matching on the error message using `string-contains?` to map to standard POSIX-style reasons: `"No such"` → `enoent`, `"Permission denied"` → `eacces`, `"Not a directory"` → `enotdir`, `"Is a directory"` → `eisdir`, fallback `posix_error`. Filenames coerce through `er-source-to-string` so SX strings, Erlang binaries, and Erlang char-code lists all work. Read returns `{ok, Binary}` (bytes via `(map char->integer (string->list ...))` then `er-mk-binary`); write returns bare `ok`; delete returns bare `ok`. Bootstrap registrations added at the bottom of `er-register-builtin-bifs!` under `"file"`. 10 new eval tests: write-then-read round-trip, ok-tag, payload is binary, byte_size content, missing-file `enoent`, delete-ok, read-after-delete `enoent`, write to non-existent dir `enoent`, binary payload (5 raw bytes) round-trip preserving byte count. Blockers entry added covering five Phase 8 BIFs whose host primitives don't exist in this SX runtime: `crypto:hash/2`, `cid:from_bytes/1`/`to_string/1`, `file:list_dir/1`, `httpc:request/4`, `sqlite:open/exec/query/close`. Fix path documented inline (architecture-branch iteration to register OCaml-side primitives). Total **633/633** (+10 eval). - **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).