Merge branch 'wasm32' into dev-loop

This commit is contained in:
Joseph Ferano 2026-09-11 19:48:51 +07:00
commit 32e20f03da
6 changed files with 446 additions and 39 deletions

71
NEXT.md
View File

@ -161,6 +161,7 @@ reader ✅ → parse ✅ → load ✅ → check ✅ → emit ✅ → clang ✅
| `test/programs/restarts.flan` | **`restart-case` and `invoke-restart`: the transfer, across two frames** |
| `test/test_emacs.ml` | **the client, driven against a real daemon and a real program** |
| `test/reload_host.c` | the C host that loads and installs two rebuilds, in one process |
| `test/wasm-run.mjs` | **a WASI host in twenty lines of `node:wasi`, so the table can run a wasm32 build** |
```
$ flan run calc-me.flan "1 + 2 * (3 - 0.5) / 2"
@ -1496,19 +1497,63 @@ painful to retrofit:
Deferred until after the dev loop:
6. **wasm32.** The user installed `wasi-libc-devel` and `wasi-libc-static`; the
sysroot is `/usr/wasm32-wasi` and `wasm-ld` is present. `clang
--target=wasm32-wasi --sysroot=/usr/wasm32-wasi` gets past the headers and
then **fails to link**: it wants
`lib/clang/20/lib/wasm32-unknown-wasi/libclang_rt.builtins.a`, which no
Fedora package provides (`dnf provides '*libclang_rt.builtins*wasm*'` finds
nothing). It has to come from a wasi-sdk release, dropped into clang's
resource directory. After that: teach `build.ml` `--sysroot`, and run the
acceptance table — `sand-headless.flan` included, which is exactly why it
does not import raylib — on both targets in CI.
Note plan.org has the *web* build linking raylib via emscripten, which
brings its own sysroot: wasi-sdk is right for the headless table, not
necessarily for the eventual game build.
6. ~~**wasm32.**~~ **Done, with one glued joint.** `flan build
--target=wasm32-wasi` produces a module, and `test/programs/sand-headless.flan`
prints `2256461126764447066` under it — the same hash as native, byte for
byte, at `-O2` and at `-O0`. That is the milestone: the RNG is ours rather
than libc's precisely so that number can be compared across targets, and it
compares equal. `values.flan` and `machine.flan` run there too, which is
where a 32-bit pointer would have shown. The acceptance table runs all four,
and skips them by *probing* — it builds the smallest program and runs it —
rather than by looking for a binary on PATH.
Three things this cost that were not in the old note:
- **The entry point is not `main`.** wasi-libc's start code calls
`__main_argc_argv`; clang renames C's argc/argv `main` to that, and the
`.ll` `Emit` writes says `@main` literally. The link succeeds and the
program traps on a signature-mismatched weak stub. `Build.wasm_main_source`
is a two-line C shim that bridges it, and the `__asm__("main")` label in it
is load-bearing: spelling the callee `main` makes clang rename *that* too
and the shim becomes an infinite self-call.
- **The target has to reach the C compiles, not just the link.** `flan_rt.c`
includes `<stdio.h>`; without `--sysroot` it never gets that far.
`target_flags` is computed once and passed to both, and the whole flag
list — not just the triple — is in the object cache key, so repointing a
sysroot cannot serve a stale `.o`.
- **Fedora's sysroot is one level deeper** than wasi-sdk's:
`include/wasm32-wasi/stdio.h`, not `include/stdio.h`. Both shapes count.
**The glued joint, and the one thing this contradicts in the old note.** The
old note said the builtins archive has to come from a wasi-sdk release. It
does not have to: emscripten builds the same compiler-rt for wasm32 and
calls it `libcompiler_rt.a`, and dropping that in as
`libclang_rt.builtins.a` links and runs. It is a different triple
(`wasm32-unknown-emscripten`) built by a different clang (22 against
Fedora's 20), so it is *substituting*, and wasi-sdk is still the proper
article. `build.ml` looks for `FLAN_WASM_BUILTINS`, then
`/opt/wasi-sdk/...`, then emscripten's beside `emcc` on PATH, and refuses by
name listing every path it tried when none is there. clang's resource
directory is root-owned, so the archive is not dropped into it — a shadow
resource directory is built under the object cache, named by a digest of
clang's own resource dir plus the archive's path, size and mtime, with the
real `include` symlinked in.
**The runtime is Node.** No `wasmtime` and no `wasmer` on this machine;
`test/wasm-run.mjs` is twenty lines of `node:wasi` and the table prefers
`wasmtime` or `wasmer` if either appears. `--no-warnings`, because
`node:wasi` writes an `ExperimentalWarning` to stderr on every run and the
harness compares combined output.
**Refused by name, not half-supported:** `--dev` with a wasm target (the
reload path is `dlopen`), `Build.shared` with one (same reason), and `flan
run --target=` (a `.wasm` is not something this host execs — build it and
point a runtime at it).
Still open: raylib on wasm, which plan.org wants through emscripten and its
own sysroot. wasi-sdk is right for the headless table; it is not necessarily
right for the eventual game build.
7. **Loose ends from milestone 4**, none of them blocking: block-scoped
`defer`; package visibility, so `rl/get-color-raw` is not callable; a
package importing a package; imported unions.

View File

@ -46,6 +46,24 @@ let dev_flag = "--dev"
let flags = [ no_checks_flag; dev_flag ]
(* [--target=wasm32-wasi], the one cross target. Unlike the flags above it
carries a value, so it is matched by prefix and stripped from the residual
arguments by the same test otherwise [-o out --target=X] falls into the
usage error. *)
let target_prefix = "--target="
let is_flag a =
List.mem a flags || String.starts_with ~prefix:target_prefix a
let target_of args =
List.find_map
(fun a ->
if String.starts_with ~prefix:target_prefix a then
Some (String.sub a (String.length target_prefix)
(String.length a - String.length target_prefix))
else None)
args
let () =
match Array.to_list Sys.argv with
| _ :: "read" :: files when files <> [] ->
@ -82,10 +100,19 @@ let () =
(Flan.Types.to_string f.ret) (Array.length f.slots))
p.fns))
files
| _ :: "emit" :: args when List.exists (fun a -> not (List.mem a flags)) args ->
(* The IR is target-independent — [Emit] writes no triple and no datalayout,
which is what lets one .ll serve both targets so there is nothing for a
target to change here. Refused rather than accepted and ignored: silently
swallowing a flag is the shape the house rule exists to prevent. *)
| _ :: "emit" :: args when target_of args <> None ->
prerr_endline
"flan emit: --target is refused — the emitted IR carries no triple and \
no datalayout, and the target is chosen at build.";
exit 2
| _ :: "emit" :: args when List.exists (fun a -> not (is_flag a)) args ->
let checks = not (List.mem no_checks_flag args) in
let dev = List.mem dev_flag args in
let files = List.filter (fun a -> not (List.mem a flags)) args in
let files = List.filter (fun a -> not (is_flag a)) args in
List.iter
(fun path ->
with_errors path (fun () ->
@ -94,20 +121,28 @@ let () =
| _ :: "build" :: path :: rest ->
let checks = not (List.mem no_checks_flag rest) in
let dev = List.mem dev_flag rest in
let target = target_of rest in
let out =
match List.filter (fun a -> not (List.mem a flags)) rest with
match List.filter (fun a -> not (is_flag a)) rest with
| [ "-o"; o ] -> o
| [] -> Filename.remove_extension (Filename.basename path)
| [] ->
let base = Filename.remove_extension (Filename.basename path) in
(* A wasm module is not an executable and must not be named like one:
the extension is what tells a runtime, and a reader, what it is. *)
(match target with
| Some t when String.starts_with ~prefix:"wasm32" t -> base ^ ".wasm"
| _ -> base)
| _ ->
prerr_endline
"usage: flan build <file.flan> [-o out] [--no-bounds-checks] [--dev]";
"usage: flan build <file.flan> [-o out] [--no-bounds-checks] \
[--dev] [--target=wasm32-wasi]";
exit 2
in
with_errors path (fun () ->
let l = load path in
let p = Flan.Check.program l.decls in
ignore (Flan.Build.executable
~opts:{ Flan.Build.default with checks; dev }
~opts:{ Flan.Build.default with checks; dev; target }
~csrcs:l.csrcs ~lflags:l.lflags p ~out))
(* The daemon an editor talks to: one session, the program it belongs to
running beside it, and a socket. Unlike [flan reload] the session persists,
@ -146,6 +181,14 @@ let () =
Printf.eprintf "%s %s llc %.1fms ld %.1fms\n" out
(String.concat " " c.Flan.Session.fns) timing.Flan.Build.llc_ms
timing.Flan.Build.link_ms)
(* [run] builds and execs. A .wasm is not executable, and picking a runtime
for it is a decision this command has no business making, so a cross
target is refused here by name rather than half-supported. *)
| _ :: "run" :: _ :: args when target_of args <> None ->
prerr_endline
"flan run: --target is refused — a cross-built module is not something \
this host can exec. Use flan build --target=... and a wasm runtime.";
exit 2
| _ :: "run" :: path :: args ->
with_errors path (fun () ->
let exe =
@ -163,7 +206,8 @@ let () =
| _ ->
prerr_endline
"usage: flan (read|parse|check|emit) <file.flan>...\n\
\ flan build <file.flan> [-o out] [--no-bounds-checks] [--dev]\n\
\ flan build <file.flan> [-o out] [--no-bounds-checks] [--dev] \
[--target=wasm32-wasi]\n\
\ flan run <file.flan> [args...]\n\
\ flan reload <program.flan> <forms.flan> [-o out.so]\n\
\ flan dev <program.flan> [-s socket]";

View File

@ -15,6 +15,21 @@ let write path contents =
output_string ch contents;
close_out ch
let write_bin path contents =
let ch = open_out_bin path in
output_string ch contents;
close_out ch
let read_file path =
let ch = open_in_bin path in
let n = in_channel_length ch in
let s = really_input_string ch n in
close_in ch;
s
let read_file_opt path =
try Some (read_file path) with Sys_error _ -> None
(* One temporary directory per build, so the .ll is findable by name when
something is wrong with it. *)
let workdir () =
@ -53,6 +68,170 @@ type opts = {
let default =
{ target = None; opt = "-O2"; keep = false; checks = true; dev = false }
(* ── wasm32, which needs more than a triple ──────────────────────────
The native target is whatever clang was built for, so [--target=] alone is
the whole of it. wasm32-wasi is not: the headers come from a sysroot clang
does not know about, and the builtins archive is not in clang's resource
directory on Fedora at all. Both have to be found, and *both* have to reach
the C compiles as well as the link [flan_rt.c] includes <stdio.h>.
Anything missing is refused by name, with the path that is missing and the
package that would supply it. A build that reports success for a target it
cannot actually produce is the one outcome worth avoiding here. *)
let getenv name = try Some (Sys.getenv name) with Not_found -> None
let is_wasm t = String.starts_with ~prefix:"wasm32" t
let wasm_target opts =
match opts.target with Some t when is_wasm t -> true | _ -> false
(* Where on PATH a program is, or None. *)
let on_path prog =
let dirs = String.split_on_char ':' (try Sys.getenv "PATH" with Not_found -> "") in
List.find_map
(fun d ->
let p = Filename.concat d prog in
if Sys.file_exists p then Some p else None)
dirs
let wasm_sysroot () =
match getenv "FLAN_WASM_SYSROOT" with Some s -> s | None -> "/usr/wasm32-wasi"
(* The builtins archive — __muldi3, the float conversions, memcpy. Fedora's
clang ships no wasm copy of it (dnf provides '*libclang_rt.builtins*wasm*'
finds nothing) and the proper article comes from a wasi-sdk release.
Failing that, emscripten builds the same compiler-rt for wasm32 and calls it
libcompiler_rt.a; it is a different triple (wasm32-unknown-emscripten) built
by a different clang, and it is *substituting* here, not the real thing. It
links and runs, and the sand hash matches native byte for byte, but a
session reading this should know the joint is glued. *)
let wasm_builtins_candidates () =
(* wasi-sdk's own resource directory, whichever LLVM that release bundled —
the version is in the path and moves release to release, so it is read
rather than guessed. Same rule as [clang_resource_dir]. *)
(let root = "/opt/wasi-sdk/lib/clang" in
match Sys.readdir root with
| vs ->
Array.sort compare vs;
Array.to_list vs
|> List.map (fun v ->
Filename.concat root
(Filename.concat v "lib/wasm32-unknown-wasi/libclang_rt.builtins.a"))
| exception Sys_error _ -> [])
@ (match on_path "emcc" with
| None -> []
| Some e ->
[ Filename.concat (Filename.dirname e)
"cache/sysroot/lib/wasm32-emscripten/libcompiler_rt.a" ])
let wasm_builtins () =
(* An explicit FLAN_WASM_BUILTINS that does not exist is an error and not a
hint: falling back to a guess would build against something other than
what was asked for and say nothing. *)
match getenv "FLAN_WASM_BUILTINS" with
| Some s when Sys.file_exists s -> s
| Some s ->
failwith (Printf.sprintf "wasm32: FLAN_WASM_BUILTINS is %s, which does not exist" s)
| None ->
let cands = wasm_builtins_candidates () in
match List.find_opt Sys.file_exists cands with
| Some p -> p
| None ->
failwith
(Printf.sprintf
"wasm32: no builtins archive. clang wants \
<resource-dir>/lib/wasm32-unknown-wasi/libclang_rt.builtins.a, which \
no Fedora package provides; it comes from a wasi-sdk release, or \
emscripten's libcompiler_rt.a will substitute. Looked in: %s. Set \
FLAN_WASM_BUILTINS to the archive."
(String.concat ", " cands))
(* clang's own resource directory, asked for rather than guessed — the version
number is in the path and a Fedora clang bump changes it. *)
let clang_resource_dir =
lazy
(let tmp =
Filename.concat (Filename.get_temp_dir_name ())
(Printf.sprintf "flan-rd-%d" (Unix.getpid ()))
in
let code =
Sys.command
(Printf.sprintf "%s -print-resource-dir > %s 2>/dev/null"
(Filename.quote clang) (Filename.quote tmp))
in
let s = if code = 0 then read_file_opt tmp else None in
(try Sys.remove tmp with Sys_error _ -> ());
match s with
| Some s -> String.trim s
| None -> failwith "wasm32: clang -print-resource-dir failed")
(* A resource directory clang will accept for wasm32-wasi: its real include
directory, and the builtins archive under the name and triple clang looks
for. Built under the object cache and named by a digest of what went into
it, so repointing FLAN_WASM_BUILTINS or upgrading clang makes a new one
rather than reusing a stale one. *)
let wasm_resource_dir () =
let real = Lazy.force clang_resource_dir in
let builtins = wasm_builtins () in
let st = Unix.stat builtins in
let key =
Digest.to_hex
(Digest.string
(String.concat "\000"
[ real; builtins; string_of_int st.Unix.st_size;
string_of_float st.Unix.st_mtime ]))
in
let dir = Filename.concat (cachedir ()) ("wasm-rd-" ^ key) in
let lib = Filename.concat dir "lib" in
let triple = Filename.concat lib "wasm32-unknown-wasi" in
let archive = Filename.concat triple "libclang_rt.builtins.a" in
if not (Sys.file_exists archive) then begin
let mk d = try Unix.mkdir d 0o700 with Unix.Unix_error (Unix.EEXIST, _, _) -> () in
mk dir; mk lib; mk triple;
let inc = Filename.concat dir "include" in
if not (Sys.file_exists inc) then
(try Unix.symlink (Filename.concat real "include") inc
with Unix.Unix_error _ -> ());
let tmp = Printf.sprintf "%s.%d.tmp" archive (Unix.getpid ()) in
write_bin tmp (read_file builtins);
(try Unix.rename tmp archive with Unix.Unix_error _ -> ())
end;
dir
(* The flags a target adds, used by the C compiles and by the link alike. *)
let target_flags opts =
match opts.target with
| None -> []
| Some t when not (is_wasm t) -> [ "--target=" ^ t ]
| Some t ->
let sysroot = wasm_sysroot () in
(* Fedora's wasi-libc puts the headers one level deeper than wasi-sdk's
does include/wasm32-wasi/stdio.h against include/stdio.h so both
shapes count as a sysroot. clang finds either on its own. *)
if not (List.exists
(fun p -> Sys.file_exists (Filename.concat sysroot p))
[ "include/stdio.h"; "include/wasm32-wasi/stdio.h" ])
then
failwith
(Printf.sprintf
"wasm32: no sysroot at %s (wanted include/stdio.h). Install \
wasi-libc-devel and wasi-libc-static, or set FLAN_WASM_SYSROOT."
sysroot);
[ "--target=" ^ t; "--sysroot=" ^ sysroot;
"-resource-dir=" ^ wasm_resource_dir () ]
(* wasi-libc's start code calls __main_argc_argv, not main: clang *renames*
C's argc/argv [main] to that when it compiles C for wasm32, and the .ll
Emit writes says @main literally. Without this the link succeeds and the
program traps at its first instruction on a signature-mismatched weak stub.
The __asm__ label is load-bearing and must not be "simplified" away
spelling the callee [main] makes clang rename *that* too, and the shim
becomes an infinite self-call that hangs rather than failing. *)
let wasm_main_source =
"int flan_entry(int argc, char **argv) __asm__(\"main\");\n\
int __main_argc_argv(int argc, char **argv) { return flan_entry(argc, argv); }\n"
(* What the compiler itself is, cheaply: its path, size and mtime. A clang
upgrade changes one of those, so the key changes with it without paying a
[clang --version] subprocess on every build, which would cost most of what
@ -75,13 +254,17 @@ let clang_stamp =
the source text, the compiler and the flags are all unchanged. The key has
to carry [opt] and [target]: the acceptance table builds the same programs
at -O0 and -O2, and an -O2 object must not serve an -O0 build. *)
let compile_c ~opts ~src ~name =
let compile_c ~opts ?tflags ~src ~name () =
(* The whole flag list, not just the triple: on wasm32 the sysroot and the
resource directory decide which headers and which builtins an object was
built against, so repointing either must not serve a stale .o. *)
let tflags = match tflags with Some f -> f | None -> target_flags opts in
let key =
Digest.to_hex
(Digest.string
(String.concat "\000"
[ name; src; Lazy.force clang_stamp; opts.opt;
(match opts.target with None -> "" | Some t -> t) ]))
String.concat " " tflags ]))
in
let obj = Filename.concat (cachedir ()) (key ^ ".o") in
if not (Sys.file_exists obj) then begin
@ -93,8 +276,7 @@ let compile_c ~opts ~src ~name =
let tmp = Printf.sprintf "%s.%d.tmp" obj (Unix.getpid ()) in
let cmd =
String.concat " "
([ Filename.quote clang; opts.opt; "-c" ]
@ (match opts.target with None -> [] | Some t -> [ "--target=" ^ t ])
([ Filename.quote clang; opts.opt; "-c" ] @ tflags
@ [ Filename.quote c; "-o"; Filename.quote tmp ])
in
let code = Sys.command cmd in
@ -105,18 +287,19 @@ let compile_c ~opts ~src ~name =
end;
obj
let read_file path =
let ch = open_in_bin path in
let n = in_channel_length ch in
let s = really_input_string ch n in
close_in ch;
s
(* [csrcs] and [lflags] come from the imported packages (see [Load]): the C
shim a package binds through, and the arguments needed to link the library
it binds to. *)
let executable ?(opts = default) ?(csrcs = []) ?(lflags = [])
(p : Tast.program) ~out =
(* A dev build is the REPL's, and the REPL reaches a running process through
[-rdynamic] and [dlopen]. Neither exists on wasm32, so the combination is
refused rather than quietly producing a module nothing can attach to. *)
if wasm_target opts && opts.dev then
failwith
"wasm32: --dev is native only — the reload path is dlopen, which wasm32 \
has no equivalent of";
let tflags = target_flags opts in
let dir = workdir () in
let ll = Filename.concat dir (Filename.basename out ^ ".ll") in
write ll (Emit.program ~checks:opts.checks ~dev:opts.dev p);
@ -129,19 +312,19 @@ let executable ?(opts = default) ?(csrcs = []) ?(lflags = [])
rather than as a missing flag. The table is BSS, so this costs address
space and not binary size, and [-rdynamic] and the cells are still what
[--dev] means. *)
let cc src name = compile_c ~opts ~tflags ~src ~name () in
let objs =
compile_c ~opts ~src:Runtime_src.source ~name:"flan_rt.c"
:: [ compile_c ~opts ~src:Runtime_src.dev_source ~name:"flan_dev.c" ]
@ List.map
(fun c ->
compile_c ~opts ~src:(read_file c) ~name:(Filename.basename c))
csrcs
cc Runtime_src.source "flan_rt.c"
:: [ cc Runtime_src.dev_source "flan_dev.c" ]
(* wasi-libc's entry point, which is not [main]. See [wasm_main_source]. *)
@ (if wasm_target opts then [ cc wasm_main_source "flan_wasm_main.c" ] else [])
@ List.map (fun c -> cc (read_file c) (Filename.basename c)) csrcs
in
let cmd =
String.concat " "
([ Filename.quote clang; opts.opt; "-Wno-override-module" ]
@ (if opts.dev then [ "-rdynamic" ] else [])
@ (match opts.target with None -> [] | Some t -> [ "--target=" ^ t ])
@ tflags
@ [ Filename.quote ll ]
@ List.map Filename.quote objs
@ lflags
@ -195,6 +378,10 @@ let run what cmd =
if code <> 0 then failwith (Printf.sprintf "%s failed (exit %d)" what code)
let shared ?(opts = default) ~ir ~out () : timing =
if wasm_target opts then
failwith
"wasm32: the reload path is native only — it is llc + ld -shared + \
dlopen, and wasm32 has no dlopen";
let dir = workdir () in
let base = Filename.remove_extension (Filename.basename out) in
let ll = Filename.concat dir (base ^ ".ll") in

View File

@ -18,4 +18,7 @@
; test_dev runs the compiler itself: flan dev launches and owns a program.
(file %{workspace_root}/bin/main.exe)
; The Emacs client, which test_emacs drives against a real daemon.
(glob_files %{workspace_root}/emacs/*.el)))
(glob_files %{workspace_root}/emacs/*.el)
; The WASI host the wasm32 case runs its module under, when no wasmtime or
; wasmer is installed.
(file wasm-run.mjs)))

View File

@ -437,6 +437,111 @@ let () =
print_endline "FAIL --no-bounds-checks: a check survived"
end;
(* ── wasm32 (NEXT.md, deferred item 6) ──────────────────────────────
The second target, and the reason sand-headless imports no raylib. What
is asserted is not that a wasm module exists it is that it prints the
*same hash* as the native build, byte for byte. That is only possible
because rand-f32 is written in Flan rather than bound to libc, so the
case is the regression test for that decision as much as for the port.
Four independent things can be absent clang's wasm target, the
wasi-libc sysroot, a builtins archive, and a runtime that speaks WASI
so the skip is a *probe*: build the smallest program and run it. A
[which] would go red on the machine where Node is too old, with a
reason nobody could read. *)
let wasm_runner =
if Sys.command "command -v wasmtime > /dev/null 2>&1" = 0 then
Some "wasmtime"
else if Sys.command "command -v wasmer > /dev/null 2>&1" = 0 then
Some "wasmer run"
else if Sys.command "command -v node > /dev/null 2>&1" = 0 then
(* --no-warnings because node:wasi prints an ExperimentalWarning to
stderr on every run, and this harness compares combined output. *)
Some "node --no-warnings wasm-run.mjs"
else None
in
let wasm_build ?(opt = "-O2") path out =
let l = Load.program ~file:path (Parse.program (Reader.read_file path)) in
let p = Check.program l.Load.decls in
ignore
(Build.executable
~opts:{ Build.default with opt; target = Some "wasm32-wasi" }
~csrcs:l.Load.csrcs ~lflags:l.Load.lflags p ~out)
in
let wasm_run ?arg runner wasm =
let out = Filename.concat scratch "flan-acceptance-wasm.out" in
let code =
Sys.command
(Printf.sprintf "%s %s %s > %s 2>&1" runner (Filename.quote wasm)
(match arg with None -> "" | Some a -> Filename.quote a)
(Filename.quote out))
in
let text = In_channel.with_open_bin out In_channel.input_all in
(try Sys.remove out with Sys_error _ -> ());
(code, text)
in
(match wasm_runner with
| None ->
print_endline
"acceptance: skipping the wasm32 case (no wasmtime, wasmer or node)"
| Some runner ->
let probe = Filename.concat scratch "flan-wasm-probe.wasm" in
let outcome =
match wasm_build "programs/unit-main.flan" probe with
| () ->
let code, text = wasm_run runner probe in
if code = 0 && text = "ok\n" then Ok ()
else
Error
(Printf.sprintf "%s could not run it: %S (exit %d)" runner text
code)
| exception Failure m -> Error m
in
(try Sys.remove probe with Sys_error _ -> ());
(match outcome with
| Error why ->
Printf.printf "acceptance: skipping the wasm32 case (%s)\n" why
| Ok () ->
let wasm_case name ?opt ?arg path expected =
let wasm =
Filename.concat scratch
("flan-w-" ^ Filename.remove_extension (Filename.basename path)
^ ".wasm")
in
wasm_build ?opt path wasm;
let code, text = wasm_run ?arg runner wasm in
if text <> expected || code <> 0 then begin
incr failures;
Printf.printf
"FAIL %s\n got: %S (exit %d)\n wanted: %S (exit 0)\n"
name text code expected
end;
(try Sys.remove wasm with Sys_error _ -> ())
in
(* The hash, which must equal the native one above. At both levels:
agreement at -O2 alone could be a coincidence of how LLVM folded
the float arithmetic, and -O0 is the cheap way to say it is not. *)
wasm_case "sand, headless, wasm32" "programs/sand-headless.flan"
sand_out;
wasm_case "sand, headless, wasm32, -O0" ~opt:"-O0"
"programs/sand-headless.flan" sand_out;
(* And the two fixed-output programs, which between them cover the
milestone-2 surface: globals, 2-D arrays, places through a
pointer, casts and match. A 32-bit pointer is the thing most
likely to go wrong and these are where it would show. *)
wasm_case "value semantics, wasm32" "programs/values.flan" values_out;
wasm_case "machine surface, wasm32" "programs/machine.flan"
machine_out;
(* calc-me, for the one host-ABI path the three above do not touch:
[flan_argv] builds an array of flan_slice in C and Flan indexes it
as [string], so what is pinned here is the element *stride* of a
ptr+len pair, which is 16 bytes native and 12 on wasm32 not a
field offset, and nothing else in the table reaches it. This is
also the file header's own claim, that the table runs on wasm32
too, honoured for the first time. *)
wasm_case "calc-me, wasm32" "../calc-me.flan"
~arg:"1 + 2 * (3 - 0.5) / 2" "3.5\n"));
if !failures = 0 then print_endline "acceptance: all tests passed"
else begin
Printf.printf "\n%d failure(s)\n" !failures;

23
test/wasm-run.mjs Normal file
View File

@ -0,0 +1,23 @@
// A WASI host, so the acceptance table can run a wasm32 build without a
// wasmtime or wasmer being installed. Node has had node:wasi since 16; it is
// still flagged experimental, hence --no-warnings at the call site, and before
// Node 20 it needs --experimental-wasi-unstable-preview1 as well. The
// acceptance test probes rather than assumes: if this file cannot run the
// module, the wasm case is skipped with what went wrong.
//
// preopens is empty on purpose. sand-headless reads nothing and writes one
// line to stdout, which is the whole point of it being the cross-target case.
import { WASI } from 'node:wasi';
import { readFile } from 'node:fs/promises';
const [, , file, ...rest] = process.argv;
const wasi = new WASI({
version: 'preview1',
args: [file, ...rest],
env: {},
preopens: {},
returnOnExit: true,
});
const mod = await WebAssembly.compile(await readFile(file));
const inst = await WebAssembly.instantiate(mod, wasi.getImportObject());
process.exitCode = wasi.start(inst);