diff --git a/lib/erlang/runtime.sx b/lib/erlang/runtime.sx index 653a726f..a7d7bc2a 100644 --- a/lib/erlang/runtime.sx +++ b/lib/erlang/runtime.sx @@ -1304,6 +1304,74 @@ :else (error "Erlang: ets:info: arity")))) + +;; ── file module (Phase 8 FFI) ──────────────────────────────────── +;; Synchronous file IO. Filenames must be SX strings (or Erlang +;; binaries/char-code lists coercible to strings via er-source-to-string). +;; Returns `{ok, Binary}` / `ok` on success, `{error, Reason}` on failure +;; where Reason is one of `enoent`, `eacces`, `enotdir`, `posix_error`. + +(define er-classify-file-error + (fn (msg) + (let ((s (str msg))) + (cond + (string-contains? s "No such") (er-mk-atom "enoent") + (string-contains? s "Permission denied") (er-mk-atom "eacces") + (string-contains? s "Not a directory") (er-mk-atom "enotdir") + (string-contains? s "Is a directory") (er-mk-atom "eisdir") + :else (er-mk-atom "posix_error"))))) + +(define er-bif-file-read-file + (fn (vs) + (let ((path (er-source-to-string (nth vs 0)))) + (cond + (= path nil) + (er-mk-tuple (list (er-mk-atom "error") (er-mk-atom "badarg"))) + :else + (let ((res (list nil)) (err (list nil))) + (guard (c (:else (set-nth! err 0 c))) + (set-nth! res 0 (file-read path))) + (cond + (not (= (nth err 0) nil)) + (er-mk-tuple (list (er-mk-atom "error") + (er-classify-file-error (nth err 0)))) + :else + (er-mk-tuple (list (er-mk-atom "ok") + (er-mk-binary (map char->integer (string->list (nth res 0)))))))))))) + +(define er-bif-file-write-file + (fn (vs) + (let ((path (er-source-to-string (nth vs 0))) + (data (er-source-to-string (nth vs 1)))) + (cond + (or (= path nil) (= data nil)) + (er-mk-tuple (list (er-mk-atom "error") (er-mk-atom "badarg"))) + :else + (let ((err (list nil))) + (guard (c (:else (set-nth! err 0 c))) + (file-write path data)) + (cond + (not (= (nth err 0) nil)) + (er-mk-tuple (list (er-mk-atom "error") + (er-classify-file-error (nth err 0)))) + :else (er-mk-atom "ok"))))))) + +(define er-bif-file-delete + (fn (vs) + (let ((path (er-source-to-string (nth vs 0)))) + (cond + (= path nil) + (er-mk-tuple (list (er-mk-atom "error") (er-mk-atom "badarg"))) + :else + (let ((err (list nil))) + (guard (c (:else (set-nth! err 0 c))) + (file-delete path)) + (cond + (not (= (nth err 0) nil)) + (er-mk-tuple (list (er-mk-atom "error") + (er-classify-file-error (nth err 0)))) + :else (er-mk-atom "ok"))))))) + ;; ── builtin BIF registrations (Phase 8 migration) ──────────────── ;; Populates `er-bif-registry` with every existing built-in BIF. Each ;; entry is keyed by "Module/Name/Arity"; multi-arity BIFs register @@ -1394,6 +1462,10 @@ (er-register-bif! "code" "which" 1 er-bif-code-which) (er-register-bif! "code" "is_loaded" 1 er-bif-code-is-loaded) (er-register-bif! "code" "all_loaded" 0 er-bif-code-all-loaded) + ;; file module + (er-register-bif! "file" "read_file" 1 er-bif-file-read-file) + (er-register-bif! "file" "write_file" 2 er-bif-file-write-file) + (er-register-bif! "file" "delete" 1 er-bif-file-delete) (er-mk-atom "ok"))) ;; Register everything at load time. diff --git a/lib/erlang/scoreboard.json b/lib/erlang/scoreboard.json index a36308cd..44604748 100644 --- a/lib/erlang/scoreboard.json +++ b/lib/erlang/scoreboard.json @@ -1,11 +1,11 @@ { "language": "erlang", - "total_pass": 623, - "total": 623, + "total_pass": 633, + "total": 633, "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":"eval","pass":395,"total":395,"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"}, diff --git a/lib/erlang/scoreboard.md b/lib/erlang/scoreboard.md index 70446c82..c25b6296 100644 --- a/lib/erlang/scoreboard.md +++ b/lib/erlang/scoreboard.md @@ -1,12 +1,12 @@ # Erlang-on-SX Scoreboard -**Total: 623 / 623 tests passing** +**Total: 633 / 633 tests passing** | | Suite | Pass | Total | |---|---|---|---| | ✅ | tokenize | 62 | 62 | | ✅ | parse | 52 | 52 | -| ✅ | eval | 385 | 385 | +| ✅ | eval | 395 | 395 | | ✅ | runtime | 93 | 93 | | ✅ | ring | 4 | 4 | | ✅ | ping-pong | 4 | 4 | diff --git a/lib/erlang/tests/eval.sx b/lib/erlang/tests/eval.sx index 4b643c95..de114767 100644 --- a/lib/erlang/tests/eval.sx +++ b/lib/erlang/tests/eval.sx @@ -1340,6 +1340,55 @@ (er-eval-test "capstone soft_purge clean after hard = true" (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/plans/erlang-on-sx.md b/plans/erlang-on-sx.md index 13dbb8ed..fb2e5808 100644 --- a/plans/erlang-on-sx.md +++ b/plans/erlang-on-sx.md @@ -115,17 +115,19 @@ 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. - [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}` -- [ ] `httpc:request/4` — synchronous HTTP GET/POST, returns `{ok, {Status, Headers, Body}}` / `{error, Reason}` -- [ ] `sqlite:open/1`, `sqlite:close/1`, `sqlite:exec/2`, `sqlite:query/2` — single-process SQLite handle pool keyed by filename +- [ ] `crypto:hash/2` — **BLOCKED** (no `sha256`/`sha512`/`blake3` primitive in this SX runtime). See Blockers. +- [ ] `cid:from_bytes/1`, `cid:to_string/1` — **BLOCKED** (needs `crypto:hash/2`). See Blockers. +- [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 ## Progress log _Newest first._ +- **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). - **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. @@ -178,4 +180,4 @@ _Newest first._ ## Blockers -- _(none yet)_ +- **SX runtime lacks platform primitives for crypto / dir-listing / HTTP / SQLite** (2026-05-14). Probed in `mcp_tree.exe`'s embedded `sx_server.exe`: `(sha256 "x")`, `(blake3 "x")`, `(hash "sha256" "x")`, `(file-list-dir "plans")`, `(http-get "url")`, `(fetch "url")` all return `Undefined symbol`. Only file-byte-level primitives exist: `file-read` ✓, `file-write` ✓, `file-delete` ✓, `file-exists?` ✓. Out-of-scope to add these (they live in `hosts/` per ground rules). Blocked Phase 8 BIFs: `crypto:hash/2`, `cid:from_bytes/1`, `cid:to_string/1`, `file:list_dir/1`, `httpc:request/4`, `sqlite:open/exec/query/close`. **Fix path:** a future iteration on the architecture branch can register host primitives (e.g. expose OCaml's `Digestif` for hashes, `Sys.readdir` for list_dir, `cohttp` for httpc); the BIF wrappers here will then become one-line registrations against `er-bif-registry`.