From 1d7f5e1c851b318c9f8dfdecb22ed6cd1febf279 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sat, 12 Sep 2026 11:36:01 +0700 Subject: [PATCH 1/7] Assets are baked in at compile time, one file or one whole directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decision 1. Odin's #load and #load_directory are the model, spelled as ordinary named calls — an s-expression language already has a head position and does not need Odin's `#`. (embed "p") is a [u8], (embed "p" string) is a string, and (embed-dir "d") is a [n EmbedFile] sorted by name. Two spellings rather than one that changes type with its context. Odin threads a type_hint everywhere and can afford it; with structural equality and no implicit widening, the same text meaning two types here would be a wart. The path is a literal and resolves relative to the file the form is written in, both of which are Odin's rules and for Odin's reasons: the bytes must be in hand before any value exists, and a package's assets must not depend on where flan was invoked from. The bytes reach the program as a [Str] node typed [u8], not as a [Bytes] prim over a string. [Bytes] is identity — emit.ml lowers String and Slice _ to the same %slice — and wrapping the literal in a prim would make the node non-constant, so an (embed-dir) bound with defconst could not be an LLVM constant. Both string emitters take the bytes and ignore the node's type, so it is the same constant either way and one a global can hold. emit.ml's escape is byte-exact, so a PNG survives the .ll. The directory lookup is a linear scan in the prelude over a slice of EmbedFile. A directory embed is tens of entries out of cache-warm .rodata, and a compile-time perfect hash would be a build-time map with its own failure modes that nothing has asked for. Sorted because readdir order is filesystem-dependent and an unsorted embed would make two builds of identical sources emit different .ll. The slice points into .rodata, so a store through it segfaults at -O0 and is deleted at -O2 — the same measured trap the prelude's ASCII-case note describes for (bytes "Hi"). Inherited, not widened; clone into a Vec for a mutable copy. --- lib/check.ml | 290 +++++++++++++++++++++++++++++++++++ lib/emit.ml | 8 + lib/prelude.ml | 55 +++++++ runtime/flan_rt.c | 168 ++++++++++++++++++++ test/dune | 7 +- test/programs/assets/a.txt | 1 + test/programs/assets/b.bin | 1 + test/programs/assets/raw.bin | Bin 0 -> 4 bytes test/programs/embed.flan | 57 +++++++ 9 files changed, 586 insertions(+), 1 deletion(-) create mode 100644 test/programs/assets/a.txt create mode 100644 test/programs/assets/b.bin create mode 100644 test/programs/assets/raw.bin create mode 100644 test/programs/embed.flan diff --git a/lib/check.ml b/lib/check.ml index bf73624..ade1447 100644 --- a/lib/check.ml +++ b/lib/check.ml @@ -366,6 +366,59 @@ let here loc = mk loc Types.String (Tast.Str (Loc.to_string loc)) (* A runtime call, with the result type spelled at the site. *) let rt loc ty sym args = mk loc ty (Tast.Prim (Tast.Rt sym, args)) +(* ── Reading a file at compile time, decision 1 ──────────────────────── + The path is a *literal*, because the bytes have to be in hand before any + value exists — this is Odin's rule too (check_load_directive rejects + anything that is not Addressing_Constant) and it is what makes the result + cost nothing at run time. + + It resolves relative to the directory of the file the form is written in, + which is again Odin's rule (dir_from_path of the call's file). Relative to + the compiler's working directory would make a package's assets depend on + where flan was invoked from, which is the thing that cannot be right. An + absolute path is taken as written. *) +let embed_path loc (p : Ast.expr) = + match p.Ast.e with + | Ast.Str "" -> Loc.fail p.Ast.loc "an embedded path cannot be empty" + | Ast.Str s when Filename.is_relative s -> + let base = Filename.dirname loc.Loc.file in + if String.equal base "" then s else Filename.concat base s + | Ast.Str s -> s + | _ -> + Loc.fail p.Ast.loc + "an embedded path must be a literal string — the bytes are read at \ + compile time, so there is nothing here to compute it from" + +let read_embed_file path loc = + match open_in_bin path with + | exception Sys_error msg -> Loc.fail loc "cannot embed %s: %s" path msg + | ch -> + let n = in_channel_length ch in + let s = really_input_string ch n in + close_in ch; + s + +(* Non-recursive, files only, sorted by name — the three things Odin's + #load_directory settles, and the sort is the one that matters most here: + readdir order is filesystem-dependent, so an unsorted embed would make the + emitted .ll differ between two builds of identical sources. *) +let read_embed_dir path loc = + let names = + match Sys.readdir path with + | exception Sys_error msg -> Loc.fail loc "cannot embed %s: %s" path msg + | a -> Array.to_list a + in + let files = + List.filter + (fun n -> + let full = Filename.concat path n in + (not (Sys.is_directory full)) && Sys.file_exists full) + names + in + List.map + (fun n -> (n, read_embed_file (Filename.concat path n) loc)) + (List.sort String.compare files) + let i64_at loc n = mk loc (Types.Int Types.I64) (Tast.Int (n, Types.I64)) (* spec-memory.md, "Alignment": the number is produced where the concrete @@ -1389,6 +1442,79 @@ and alloc_guard ctx loc (attempt : Tast.expr) = (Tast.Let ([ (ok, mk loc Types.Bool (Tast.Bool false)) ], [ mk loc Types.Unit (Tast.While (notok (), [ body ])) ])) +(* ── File failure, decisions 2 and 5 ─────────────────────────────────── + The same shape [alloc_guard] has, for the same reason and out of the same + nodes: the operation signals inside a [restart-case] it establishes itself, + so nothing anywhere grows a Result and neither [slurp] nor [barf] can fail + silently. Compiler-emitted at the point of failure, which spec-memory.md + already names as the exception to plan.org's "restarts go at the resync + point, once" — a restart at an outer loop cannot re-open a file. + + Two restarts, and they are the textbook pair Common Lisp establishes for a + file-error: + + retry the file may be there now — the handler made a + directory, mounted something, or waited. + use-value [p string] try this other path instead. + + [use-value]'s parameter *is* the path slot, so the clause body is [unit]: + emit.ml's [bind_params] stores the invoker's argument straight into the slot + the attempt reads, the clause falls through, and the while re-tests and + re-attempts against the new path. Typed restarts landed this session and + this is the first thing the compiler itself emits one for. + + [attempt] must be repeatable, so the path is a slot read at each turn of the + loop rather than an expression re-evaluated. *) +and file_guard ctx loc ~path_slot ~op mk_steps = + let ok = fresh_slot ctx Types.Bool in + let okv = mk loc Types.Bool (Tast.Local ok) in + let notok () = mk loc Types.Bool (Tast.Prim (Tast.Not, [ okv ])) in + let i8 n = mk loc (Types.Int Types.I8) (Tast.Int (n, Types.I8)) in + (* Fixed fields and no rendered message, exactly as StorageExhausted: the + condition is built on the failing frame's stack and formatting is the + handler's job. [path] is whatever the attempt last used, so a handler that + supplied one through [use-value] sees the path that actually failed. *) + let cond = + mk loc (Types.Named "FileError") + (Tast.Make + ("FileError", + [ mk loc Types.String (Tast.Local path_slot); + mk loc (Types.Int Types.I32) (Tast.Int (Int64.of_int op, Types.I32)); + mk loc (Types.Int Types.I32) + (Tast.Prim (Tast.Cast (Types.Int Types.I32), + [ rt loc (Types.Int Types.I64) + "flan_file_fail_reason" [] ])) ])) + in + let signal () = + mk loc Types.Never (Tast.Signal (Tast.Serror, type_id "FileError", cond)) + in + (* One step of the attempt: run the runtime call, record whether it worked, + and signal if it did not. The last step a caller gives is what leaves [ok] + true, which is what stops the loop. *) + let try_ (attempt : Tast.expr) = + mk loc Types.Unit + (Tast.Do + [ mk loc Types.Unit + (Tast.Set (Tast.Plocal ok, + mk loc Types.Bool (Tast.Prim (Tast.Ne, [ attempt; i8 0L ])))); + mk loc Types.Unit (Tast.If (notok (), signal (), unit_at loc)) ]) + in + let clause name params = + let sg = restart_sig (List.map snd params) in + { Tast.rname_id = type_id name; rname = name; rparams = params; + rsig = sg; rsig_id = type_id sg; rbody = [ unit_at loc ] } + in + let body = + mk loc Types.Unit + (Tast.RestartCase + ([ clause "retry" []; + clause "use-value" [ (path_slot, Types.String) ] ], + mk loc Types.Unit (Tast.Do (mk_steps try_)))) + in + mk loc Types.Unit + (Tast.Let ([ (ok, mk loc Types.Bool (Tast.Bool false)) ], + [ mk loc Types.Unit (Tast.While (notok (), [ body ])) ])) + (* The element type for [vec-new]: a leading bare symbol naming a type, or the expectation at the site. A bare symbol shadowed by a local or a global is that binding — an allocator, in practice — and not a type. *) @@ -1882,6 +2008,170 @@ and named_call ctx ~want loc name args = mk loc (Types.Vec elem) (Tast.Local d) ]))) | _ -> fail loc "clone is (clone v) or (clone v allocator)") + (* ── Assets, decision 1: embedded at compile time ────────────── + Odin's #load and #load_directory are the model (src/parser.cpp, + src/check_builtin.cpp's check_load_directive), and the reason it is the + right answer here is the one NEXT.md gives: it is a *compiler* feature, so + it needs no build flags, no linker arguments and no per-target packaging, + and it works identically on desktop and web. That matters more here than + it does for Odin, because [Load] gives link flags only to a directory + package — the single file doing (rl/load-texture "brush.png") is + structurally the one file with no link channel. Embedding has no such + hole. + + Odin's `#` is not imported. An s-expression language already has a head + position for a name, so these are ordinary named calls spelled [embed] and + [embed-dir], resolved here exactly as [vec-new] and [heap-allocator] are. + + The result costs nothing at run time: the bytes become a + `private unnamed_addr constant` string, the same one every string literal + already becomes, and emit.ml's [escape] is byte-exact, so a PNG survives + the round trip through the .ll. Bound with [defconst], an [embed-dir] + becomes an LLVM constant outright (emit.ml's [const]). + + The one sharp edge, and it is not new: the slice this hands back points + into .rodata, so a store through it either segfaults at -O0 or is deleted + at -O2 — the same measured trap the prelude's ASCII-case note describes + for (bytes "Hi"). Clone the bytes into a Vec for a mutable copy. Nothing + here widens that hole; it inherits it, and provenance is what would close + it. *) + | "embed" -> + (match args with + | [ p ] | [ p; _ ] -> + let data = read_embed_file (embed_path loc p) p.Ast.loc in + let as_string () = mk loc Types.String (Tast.Str data) in + (* A [Str] node typed [u8] rather than a [Bytes] prim over one. [Bytes] + is identity — emit.ml lowers String and Slice _ to the same %slice — + and the prim would make the node non-constant, so an (embed-dir) in a + defconst could not be an LLVM constant. Both of emit.ml's string + emitters take the bytes and ignore the node's type, so this is the + same constant either way, and it is one a global can hold. *) + let as_bytes () = mk loc (Types.Slice (Types.Int Types.U8)) (Tast.Str data) in + (* Two spellings rather than one that changes type with its context. + Odin threads a type_hint everywhere and can afford (embed "p") to + mean a string here and a []u8 there; with structural equality and no + implicit widening anywhere, the same text meaning two types would be + a wart. [want] is a fallback only, and nothing depends on it. *) + (match args with + | [ _; { Ast.e = Ast.Var "string"; _ } ] -> + expect loc ~want (as_string ()) + | [ _; t ] -> + fail t.Ast.loc + "embed's second argument is the type to read the file as, and \ + `string` is the only one — (embed \"p\") is the [u8]" + | _ -> + (match want with + | Some Types.String -> as_string () + | _ -> expect loc ~want (as_bytes ()))) + | _ -> + fail loc + "embed is (embed \"path\") for a [u8], or (embed \"path\" string)") + | "embed-dir" -> + arity loc name 1 args; + let arg = List.hd args in + let entries = read_embed_dir (embed_path loc arg) arg.Ast.loc in + if not (Hashtbl.mem ctx.env.structs "EmbedFile") then + fail loc + "embed-dir answers a [n EmbedFile] and EmbedFile is not in scope — it \ + is a prelude type and something has replaced the prelude"; + let ety = Types.Named "EmbedFile" in + let elems = + List.map + (fun (nm, data) -> + mk loc ety + (Tast.Make + ("EmbedFile", + [ mk loc Types.String (Tast.Str nm); + mk loc (Types.Slice (Types.Int Types.U8)) (Tast.Str data) ]))) + entries + in + expect loc ~want + (mk loc (Types.Array (Int64.of_int (List.length entries), ety)) + (Tast.Arr elems)) + + (* ── slurp and barf, decisions 2 and 5 ───────────────────────── + [slurp] reads a whole file and answers a (Vec u8). It allocates, which is + why it waited for Vec, and it follows spec-memory.md's rule to the letter: + no allocating operation returns an error, so there is no Result here and + no out-parameter — a failure to allocate is StorageExhausted under [retry] + and a failure to read is FileError under [retry] and [use-value]. + + The two guards nest rather than merge, and that is the point: they are two + different failures with two different answerable questions, and a handler + that grows an arena is not the handler that supplies another path. + + Everything is inside the file loop, so a [use-value] that names a + different file re-measures it and re-allocates for its size. The Vec is + freed at the top of each turn, which is why a retry does not leak; freeing + a Vec that never allocated is a no-op (flan_rt.c, flan_vec_free). *) + | "slurp" -> + (match args with + | path :: rest when List.length rest <= 1 -> + let path = check ctx ~want:Types.String path in + let a = allocator_arg ctx loc rest in + let ps = fresh_slot ctx Types.String in + let psv () = mk loc Types.String (Tast.Local ps) in + let u8 = Types.Int Types.U8 in + let vt = Types.Vec u8 in + let v = fresh_slot ctx vt in + let vv () = mk loc vt (Tast.Local v) in + let n = fresh_slot ctx (Types.Int Types.I64) in + let nv () = mk loc (Types.Int Types.I64) (Tast.Local n) in + let steps try_ = + [ (* The size first, because it is the step that does not allocate: + a missing file is found before any storage is committed to it. *) + try_ (rt loc (Types.Int Types.I8) "flan_file_size" + [ psv (); addr_of loc (nv ()) ]); + (* Previous turn's storage, if a retry brought us back here. *) + rt loc Types.Unit "flan_vec_free" + [ vv (); size_of loc u8; align_of loc u8; here loc ]; + alloc_guard ctx loc + (rt loc (Types.Int Types.I8) "flan_vec_init" + [ vv (); a; nv (); size_of loc u8; align_of loc u8; here loc ]); + (* Fills the Vec the line above sized. A file that grew since the + measurement is truncated to the buffer; one that shrank leaves a + shorter Vec. Both are successful reads of what was there. *) + try_ (rt loc (Types.Int Types.I8) "flan_slurp_into" [ vv (); psv () ]) ] + in + expect loc ~want + (mk loc vt + (Tast.Let + ([ (ps, path); + (n, i64_at loc 0L); + (v, mk loc vt (Tast.Zero vt)) ], + [ file_guard ctx loc ~path_slot:ps ~op:0 steps; vv () ]))) + | _ -> fail loc "slurp is (slurp path) or (slurp path allocator)") + (* [barf] writes a whole file, and on the web target it signals — every time, + with the path in the condition. Decision 2, and the reason is worth having + at the call site: Flan has NO conditional compilation, so "isolate this to + desktop" is not expressible in source and a build-time refusal would be + unusable; a silent no-op is worse than either, because that is how a save + file disappears with nothing said. So the program gets a condition and + decides. Nothing here reads the target — the refusal is flan_rt.c's, one + #ifdef in the host layer, which is exactly where the two targets are + already implemented twice. *) + | "barf" -> + arity loc name 2 args; + (match args with + | [ path; data ] -> + let path = check ctx ~want:Types.String path in + let data = byte_slice ctx data in + let ps = fresh_slot ctx Types.String in + let ds = fresh_slot ctx (Types.Slice (Types.Int Types.U8)) in + let steps try_ = + [ try_ (rt loc (Types.Int Types.I8) "flan_file_write" + [ mk loc Types.String (Tast.Local ps); + mk loc (Types.Slice (Types.Int Types.U8)) (Tast.Local ds) ]) ] + in + (* Both operands are bound before the loop so that a retry re-attempts + the write and not the expressions that produced it — the same rule + alloc_guard states for push. *) + expect loc ~want + (mk loc Types.Unit + (Tast.Let ([ (ps, path); (ds, data) ], + [ file_guard ctx loc ~path_slot:ps ~op:1 steps ]))) + | _ -> assert false) + (* ── containers ────────────────────────────────────────────────── *) (* [at] and [len] were already the names for a fixed array and a slice, so a Vec extends them rather than adding a parallel pair — which is the diff --git a/lib/emit.ml b/lib/emit.ml index f5194cf..31963b0 100644 --- a/lib/emit.ml +++ b/lib/emit.ml @@ -1819,6 +1819,14 @@ declare i64 @flan_vec_len(ptr, ptr, i64) declare ptr @flan_vec_at(ptr, i32, i64, ptr, i64) declare void @flan_vec_as_slice(ptr, ptr, i32, i32, i64, ptr, i64) declare void @flan_vec_free(ptr, i64, i64, ptr, i64) +; The filesystem. Three host calls plus one reason reader, and flan_slurp_into +; is runtime glue rather than a fourth — see flan_rt.c for why the widening +; stops here. `embed` needs none of these: it is a compile-time constant. +declare i8 @flan_file_size(ptr, i64, ptr) +declare i8 @flan_file_read(ptr, i64, ptr, i64, ptr) +declare i8 @flan_file_write(ptr, i64, ptr, i64) +declare i64 @flan_file_fail_reason() +declare i8 @flan_slurp_into(ptr, ptr, i64) |} (* C's main, adapting to whichever of the four shapes Flan's main has: argv and diff --git a/lib/prelude.ml b/lib/prelude.ml index 6aed562..5b1a212 100644 --- a/lib/prelude.ml +++ b/lib/prelude.ml @@ -745,6 +745,61 @@ let source = {flan| ;; move-only by the rule that a struct containing a ;; Vec is move-only. It needs the Vec, not a spec ;; change. +;; ── Files: embedding, slurp and barf ────────────────────────────────── +;; +;; One entry per file in an (embed-dir "...") — Odin's Load_Directory_File +;; (base/runtime/core.odin), which is the same two fields for the same reason: +;; a directory embed is only useful if you can find one file in it by the name +;; it had on disk. +;; +;; `data` points into the program's own .rodata, exactly as a string literal +;; does, so an embed costs nothing at run time and nothing at startup. It is +;; also read-only, and the same trap the ASCII-case note above measures applies +;; here: a store through it either segfaults at -O0 or is deleted at -O2. To +;; get a mutable copy, clone the bytes into a Vec. +(defstruct EmbedFile [name string data [u8]]) + +;; A linear scan, deliberately. A directory embed is tens of entries, the scan +;; is over names already in cache-warm .rodata, and the alternative — a +;; compile-time perfect hash — is a build-time map with its own failure modes +;; that nothing here has asked for. If a program ever embeds thousands of +;; files, sort-and-bisect is the next step and it does not change this type. +;; +;; It takes a slice rather than the array (embed-dir) answers, because an array +;; length is part of its type and there are no generics: write +;; (embed-find (slice assets 0 (len assets)) "brush.png"). +(defn embed-find [files [EmbedFile] name string] (Option [u8]) + (dotimes [i (len files)] + (when (bytes=? (bytes (.name (at files i))) (bytes name)) + (return (Some (.data (at files i)))))) + None) + +;; The condition slurp and barf signal — spec-conditions.md, and the same shape +;; StorageExhausted has: a value struct on the signalling frame's stack, fixed +;; fields, no rendered message. `path` is the path that failed, which is a +;; string literal or a string the handler itself supplied, so naming it costs +;; no allocation either. +;; +;; One type rather than a family, because conditions have no hierarchy today +;; (spec-conditions.md §1) and a family would need one handler clause per +;; member to say "any file error". The parent link NEXT.md decides on is the +;; answer to that, and it is not built; when it is, these reasons can become +;; types without any call site changing. +(defstruct FileError [path string op i32 reason i32]) + +(defconst file-op-read i32 0) +(defconst file-op-write i32 1) + +(defconst file-missing i32 1) +(defconst file-denied i32 2) +(defconst file-io i32 3) +;; What `barf` signals on the web target, every time. Decision 2: writing is +;; desktop-only, and it signals rather than refusing at build time (Flan has no +;; conditional compilation, so isolating code to desktop is not expressible) or +;; silently doing nothing (which is how a save file disappears with nothing +;; said). +(defconst file-unsupported i32 4) + |flan} let file = "" diff --git a/runtime/flan_rt.c b/runtime/flan_rt.c index 2f4f528..df6b8c1 100644 --- a/runtime/flan_rt.c +++ b/runtime/flan_rt.c @@ -995,3 +995,171 @@ int8_t flan_vec_clone(flan_vec *dst, flan_vec *src, flan_allocator *a, dst->len = src->len; return 1; } + +/* ── The filesystem, and the whole of what it adds to the host ABI ─── + * + * plan.org names the filesystem as the #1 portability risk — "pack assets, one + * abstraction, never touch paths" — so the widening here is deliberately three + * calls and one reader, and the reason each exists is written down: + * + * flan_file_size(path, n, &out) how many bytes are there + * flan_file_read(path, n, buf, cap, &got) fill a buffer the caller owns + * flan_file_write(path, n, buf, len) write a whole file + * flan_file_fail_reason() which of the four reasons it was + * + * They are POSIX-shaped and know nothing about a Vec: no file handle crosses + * the boundary, no descriptor is held between calls, and every one takes a + * path and returns 1/0 the way every allocator entry point already does. The + * Vec-aware part is flan_slurp below, which is *runtime glue* on this side of + * the ABI rather than a fourth host call — so a second target implements three + * functions and inherits the rest. + * + * These do touch paths, which is the widening plan.org warned about and which + * decision 2 took knowingly. `embed` is the answer that does not: an asset + * baked in at compile time needs none of this and works identically on both + * targets. Reach for slurp when the bytes genuinely are not known until the + * program runs. + * + * The reason is a global rather than an out-parameter for the same reason + * flan_alloc_fail_bytes is: the condition the compiler builds at the failing + * site is a value struct with fixed numeric fields and no rendered message, + * and reading one word is the cheapest way to carry the number out. */ + +#include + +#define FLAN_FILE_OK 0 +#define FLAN_FILE_MISSING 1 +#define FLAN_FILE_DENIED 2 +#define FLAN_FILE_IO 3 +/* Decision 2: writing is desktop-only and *signals* on web. Not a build-time + * refusal, because Flan has no conditional compilation and "isolate this to + * desktop" is therefore not expressible in source; and not a silent no-op, + * because that is how a save file disappears with nothing said. The program + * gets a condition and decides. This is the language having something Odin + * does not — Odin's core/os/file_js.odin stubs the whole API to .Unsupported + * so that importing core:os "panics cleanly". */ +#define FLAN_FILE_UNSUPPORTED 4 + +static int64_t flan_file_fail = FLAN_FILE_OK; + +int64_t flan_file_fail_reason(void) { return flan_file_fail; } + +/* A Flan string is ptr+len and never NUL-terminated, so every entry point here + * makes a terminated copy on its own stack. PATH_MAX is not consulted: a path + * too long for this buffer is reported as missing rather than truncated and + * silently opened, which is the failure this exists to avoid. */ +#define FLAN_PATH_MAX 4096 + +static int flan_path_cstr(const uint8_t *p, int64_t n, char *out) { + if (n < 0 || n >= FLAN_PATH_MAX) return 0; + if (n > 0) memcpy(out, p, (size_t)n); + out[n] = '\0'; + /* An embedded NUL would make the C string shorter than the Flan one, so the + * file opened would not be the file named. Refuse rather than guess. */ + if ((int64_t)strlen(out) != n) return 0; + return 1; +} + +static int64_t flan_errno_reason(void) { + switch (errno) { + case ENOENT: case ENOTDIR: return FLAN_FILE_MISSING; + case EACCES: case EPERM: return FLAN_FILE_DENIED; + default: return FLAN_FILE_IO; + } +} + +int8_t flan_file_size(const uint8_t *path, int64_t n, int64_t *out) { + char buf[FLAN_PATH_MAX]; + FILE *f; + long end; + *out = 0; + if (!flan_path_cstr(path, n, buf)) { + flan_file_fail = FLAN_FILE_MISSING; + return 0; + } + errno = 0; + f = fopen(buf, "rb"); + if (!f) { flan_file_fail = flan_errno_reason(); return 0; } + if (fseek(f, 0, SEEK_END) != 0 || (end = ftell(f)) < 0) { + fclose(f); + flan_file_fail = FLAN_FILE_IO; + return 0; + } + fclose(f); + *out = (int64_t)end; + flan_file_fail = FLAN_FILE_OK; + return 1; +} + +/* Reads at most cap bytes and reports how many it got. The file may have + * changed size since flan_file_size looked, so the count is an output and not + * an assertion: a short read is a successful read of a shorter file, and a + * longer file is truncated to the buffer the caller already allocated. */ +int8_t flan_file_read(const uint8_t *path, int64_t n, void *dst, int64_t cap, + int64_t *got) { + char buf[FLAN_PATH_MAX]; + FILE *f; + size_t r; + *got = 0; + if (!flan_path_cstr(path, n, buf)) { + flan_file_fail = FLAN_FILE_MISSING; + return 0; + } + errno = 0; + f = fopen(buf, "rb"); + if (!f) { flan_file_fail = flan_errno_reason(); return 0; } + r = cap > 0 ? fread(dst, 1, (size_t)cap, f) : 0; + if (ferror(f)) { fclose(f); flan_file_fail = FLAN_FILE_IO; return 0; } + fclose(f); + *got = (int64_t)r; + flan_file_fail = FLAN_FILE_OK; + return 1; +} + +int8_t flan_file_write(const uint8_t *path, int64_t n, const void *src, + int64_t len) { +#if defined(__EMSCRIPTEN__) + /* The browser has no filesystem to write to that outlives the page, and + * MEMFS would be the silent no-op decision 2 rules out by name. So the + * answer is the condition, every time, with the path still in it so a + * handler can say which write was refused. */ + (void)path; (void)n; (void)src; (void)len; + flan_file_fail = FLAN_FILE_UNSUPPORTED; + return 0; +#else + char buf[FLAN_PATH_MAX]; + FILE *f; + size_t w; + if (!flan_path_cstr(path, n, buf)) { + flan_file_fail = FLAN_FILE_MISSING; + return 0; + } + errno = 0; + f = fopen(buf, "wb"); + if (!f) { flan_file_fail = flan_errno_reason(); return 0; } + w = len > 0 ? fwrite(src, 1, (size_t)len, f) : 0; + if (w != (size_t)(len > 0 ? len : 0) || fclose(f) != 0) { + flan_file_fail = FLAN_FILE_IO; + return 0; + } + flan_file_fail = FLAN_FILE_OK; + return 1; +#endif +} + +/* Runtime glue, not host ABI: the Vec-aware half of slurp, kept on this side + * of the boundary so the three calls above stay POSIX-shaped and a second + * target implements only them. + * + * It fills a Vec the *compiler* already initialised to the right capacity — + * which is what keeps spec-memory.md's rule intact: the allocation went + * through flan_vec_init under the compiler's alloc_guard, so a failure to + * allocate is StorageExhausted with retry, and a failure to read is FileError + * with retry and use-value. Two failures, two conditions, neither swallowing + * the other. */ +int8_t flan_slurp_into(flan_vec *v, const uint8_t *path, int64_t n) { + int64_t got = 0; + if (!flan_file_read(path, n, v->ptr, v->cap, &got)) return 0; + v->len = got; + return 1; +} diff --git a/test/dune b/test/dune index b64f601..b87d43e 100644 --- a/test/dune +++ b/test/dune @@ -25,6 +25,9 @@ ; examples/digits.flan, so the directory has to be here whole. (glob_files %{workspace_root}/examples/*) (glob_files programs/*.flan) + ; The files programs/embed.flan bakes in. An embed reads them at *compile* + ; time, so they are a dependency of the checker run and not of the program. + (glob_files programs/assets/*) ; The reload primitive's host: a C main that dlopens what Build.shared made. (file reload_host.c) ; A shared object that is not a redefinition module, for the agent's refusal @@ -51,6 +54,7 @@ (libraries flan unix) (deps (glob_files programs/*.flan) + (glob_files programs/assets/*) ; The raylib bindings and the ported example the raylib case builds. The ; example imports examples/digits.flan, so the directory comes whole. (glob_files %{workspace_root}/vendor/raylib/*) @@ -82,5 +86,6 @@ (glob_files %{workspace_root}/vendor/agent/*) (glob_files %{workspace_root}/vendor/edn/*) (glob_files %{workspace_root}/examples/*) - (glob_files programs/*.flan)) + (glob_files programs/*.flan) + (glob_files programs/assets/*)) (action (run ./test_sanitize.exe))) diff --git a/test/programs/assets/a.txt b/test/programs/assets/a.txt new file mode 100644 index 0000000..f4d9bd5 --- /dev/null +++ b/test/programs/assets/a.txt @@ -0,0 +1 @@ +hello from a diff --git a/test/programs/assets/b.bin b/test/programs/assets/b.bin new file mode 100644 index 0000000..f6d5afa --- /dev/null +++ b/test/programs/assets/b.bin @@ -0,0 +1 @@ +BBB \ No newline at end of file diff --git a/test/programs/assets/raw.bin b/test/programs/assets/raw.bin new file mode 100644 index 0000000000000000000000000000000000000000..ad2f38543fc2bba3468a77f36137c23378420463 GIT binary patch literal 4 LcmZQz{QnOC0|Np7 literal 0 HcmV?d00001 diff --git a/test/programs/embed.flan b/test/programs/embed.flan new file mode 100644 index 0000000..4783be7 --- /dev/null +++ b/test/programs/embed.flan @@ -0,0 +1,57 @@ +;;;; Assets baked in at compile time — NEXT.md decision 1. +;;;; +;;;; Odin's #load and #load_directory are the model, spelled as ordinary named +;;;; calls because an s-expression language already has a head position and +;;;; does not need Odin's `#`. The whole reason for preferring this to a build +;;;; flag is that it is a *compiler* feature: no linker arguments, no +;;;; per-target packaging, and identical on desktop and web. A single-file +;;;; program has no link channel at all — `Load` hands out lflags only to a +;;;; directory package — so the file that needs the asset is structurally the +;;;; one file that could not declare it. Embedding has no such hole. +;;;; +;;;; Nothing here costs anything at run time: every one of these is a +;;;; `private unnamed_addr constant` in the emitted module. + +;; Bound once at top level, where it becomes an LLVM constant outright rather +;; than an array rebuilt on the stack per call (emit.ml's `const`). +(defconst assets [3 EmbedFile] (embed-dir "assets")) + +(defn main [] i32 + ;; The default answer is a [u8]: bytes, because that is what an asset is. + (let [a (embed "assets/a.txt")] + (println (len a)) ; 13 + (print (string a))) ; hello from a + + ;; `string` is the second spelling, not a different meaning for the same + ;; text. With structural equality and no implicit widening, one form that + ;; changes type with its context would be a wart. + (println (embed "assets/b.bin" string)) ; BBB + + ;; Byte-exact, including bytes no text encoding would survive: emit.ml's + ;; escape hex-escapes everything outside printable ASCII, so a PNG makes the + ;; round trip through the .ll unchanged. + (let [raw (embed "assets/raw.bin")] + (println (len raw)) ; 4 + (println (at raw 0)) ; 0 + (println (at raw 2)) ; 255 + (println (at raw 3))) ; 254 + + ;; A directory embed is a fixed array of EmbedFile, sorted by name — sorted + ;; because readdir order is filesystem-dependent and an unsorted embed would + ;; make two builds of identical sources emit different .ll. + (println (len assets)) ; 3 + (println (.name (at assets 0))) ; a.txt + (println (.name (at assets 1))) ; b.bin + (println (.name (at assets 2))) ; raw.bin + + ;; The name-to-bytes lookup is a linear scan in the prelude. It takes a slice + ;; rather than the array, because an array's length is part of its type and + ;; there are no generics. + (let [all (slice assets 0 (len assets))] + (match (embed-find all "b.bin") + (Some b) (println (string b)) ; BBB + None (println "missing")) + (match (embed-find all "nope.txt") + (Some _) (println "found") + None (println "no nope.txt"))) ; no nope.txt + 0) From f88ce560733e688b5d4127fdab5019b11e2c84ac Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sat, 12 Sep 2026 11:38:24 +0700 Subject: [PATCH 2/7] slurp reads a whole file, barf writes one, and failure is a condition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decisions 2 and 5. slurp allocates, which is why it waited for Vec, and it follows spec-memory.md's rule exactly: no allocating operation returns an error, so there is no Result here and no out-parameter. A failure to allocate is StorageExhausted under retry; a failure to read is FileError under retry and use-value. The two guards nest rather than merge, because they are two different failures with two different answerable questions — the handler that grows an arena is not the handler that supplies another path. The restarts are the pair Common Lisp establishes for a file-error. use-value is a typed restart, the other thing that landed this session, and this is the first one the compiler itself emits with a parameter. Its parameter *is* the path slot the attempt reads, so the clause body is empty: emit.ml's bind_params stores the invoker's argument into the slot, the clause falls through, and the loop re-attempts against the new path. Everything is inside that loop, so a use-value naming a different file re-measures it and re-allocates for its size; the Vec is freed at the top of each turn, which is why a retry does not leak. The host ABI grows by three calls and one reason reader: flan_file_size, flan_file_read, flan_file_write, flan_file_fail_reason. They are POSIX-shaped and Vec-ignorant — no handle crosses the boundary and nothing is held between calls — so a second target implements three functions. flan_slurp_into is runtime glue on this side of the ABI rather than a fourth call. These do touch paths, which is the widening plan.org names as the #1 portability risk and which decision 2 took knowingly; embed is the answer that does not touch them at all. --- test/programs/slurp-unhandled.flan | 10 +++ test/programs/slurp.flan | 103 +++++++++++++++++++++++++++++ test/slurp-made.txt | 1 + test/slurp-out.txt | 1 + test/test_acceptance.ml | 53 +++++++++++++++ 5 files changed, 168 insertions(+) create mode 100644 test/programs/slurp-unhandled.flan create mode 100644 test/programs/slurp.flan create mode 100644 test/slurp-made.txt create mode 100644 test/slurp-out.txt diff --git a/test/programs/slurp-unhandled.flan b/test/programs/slurp-unhandled.flan new file mode 100644 index 0000000..25c4b51 --- /dev/null +++ b/test/programs/slurp-unhandled.flan @@ -0,0 +1,10 @@ +;;;; A missing file with nothing handling it. spec-conditions.md §2: `error` is +;;;; the diverging variant, so the program stops on the frame that erred rather +;;;; than carrying on with a Vec that was never filled. The restarts are +;;;; offered whether or not anyone takes them — a break loop lists both. +(defn main [] i32 + (println "before") + (let [v (slurp "programs/assets/does-not-exist")] + (println "unreachable") + (free v)) + 0) diff --git a/test/programs/slurp.flan b/test/programs/slurp.flan new file mode 100644 index 0000000..bb609d0 --- /dev/null +++ b/test/programs/slurp.flan @@ -0,0 +1,103 @@ +;;;; slurp and barf — NEXT.md decisions 2 and 5. +;;;; +;;;; slurp reads a whole file and answers a (Vec u8). It allocates, which is +;;;; why it waited for Vec, and it follows spec-memory.md's rule to the letter: +;;;; no allocating operation returns an error, so there is no Result here and +;;;; no out-parameter anywhere. +;;;; +;;;; Failure signals a condition under a restart, which is the pattern +;;;; StorageExhausted set this session. Two restarts, and they are the pair +;;;; Common Lisp establishes for a file-error: +;;;; +;;;; retry the file may be there now +;;;; use-value [p string] try this other path instead +;;;; +;;;; use-value is a *typed* restart — the second thing this session bought — +;;;; and it is the first one the compiler itself emits. Its parameter is the +;;;; path slot the attempt reads, so the clause body is empty: the invoker's +;;;; argument lands in the slot, the clause falls through, and the loop +;;;; re-attempts against the new path. + +;; Handlers cannot see the locals of the function that established them, so the +;; observations are globals — the same shape exhausted.flan uses. +(defvar seen i64) +(defvar last-reason i32) +(defvar last-op i32) +(defvar last-path string) + +(defn main [] i32 + ;; ── The happy path ──────────────────────────────────────────────── + (let [v (slurp "programs/assets/a.txt")] + (println (len v)) ; 13 + (print (string (as-slice v))) ; hello from a + (free v)) + + ;; Byte-exact, the same as an embed: nothing here decodes anything. + (let [v (slurp "programs/assets/raw.bin")] + (println (len v)) ; 4 + (println (at v 0)) ; 0 + (println (at v 2)) ; 255 + (free v)) + + ;; ── use-value: a missing file, answered with another path ───────── + ;; The textbook case. The handler does not know what slurp was going to do + ;; with the bytes and does not have to: it names a file that is there and + ;; the read resumes as if that had been asked for all along. + (handler-bind + [(FileError [c] + (set seen (+ seen 1)) + (set last-reason (.reason c)) + (set last-op (.op c)) + (set last-path (.path c)) + (invoke-restart 'use-value "programs/assets/b.bin"))] + (let [v (slurp "programs/assets/does-not-exist")] + (println (len v)) ; 3 + (println (string (as-slice v))) ; BBB + (free v))) + (println seen) ; 1 + (println (= last-reason file-missing)) ; true + (println (= last-op file-op-read)) ; true + ;; The condition carries the path that actually failed, not the one that + ;; eventually worked — the handler is told what it is answering about. + (println last-path) ; programs/assets/does-not-exist + + ;; ── barf, and reading back what it wrote ────────────────────────── + (barf "slurp-out.txt" (bytes "round trip\n")) + (let [v (slurp "slurp-out.txt")] + (println (len v)) ; 11 + (print (string (as-slice v))) ; round trip + (free v)) + + ;; barf's own failure signals the same condition with op = write. A directory + ;; that does not exist is the reachable case on every platform. + (set seen 0) + (handler-bind + [(FileError [c] + (set seen (+ seen 1)) + (set last-op (.op c)) + (invoke-restart 'use-value "slurp-out.txt"))] + (barf "no-such-dir/x.txt" (bytes "second\n"))) + (println seen) ; 1 + (println (= last-op file-op-write)) ; true + (let [v (slurp "slurp-out.txt")] + (print (string (as-slice v))) ; second + (free v)) + + ;; ── retry: the file was not there, so the handler makes it ──────── + ;; The other restart, and the one use-value cannot stand in for: here the + ;; path is right and the world is wrong. The handler fixes the world and + ;; re-attempts the *same* request, which is exactly what retry means for a + ;; failed allocation too. + (set seen 0) + (handler-bind + [(FileError [c] + (set seen (+ seen 1)) + (set last-reason (.reason c)) + (barf "slurp-made.txt" (bytes "made by the handler\n")) + (invoke-restart 'retry))] + (let [v (slurp "slurp-made.txt")] + (print (string (as-slice v))) ; made by the handler + (free v))) + (println seen) ; 1 + (println (= last-reason file-missing)) ; true + 0) diff --git a/test/slurp-made.txt b/test/slurp-made.txt new file mode 100644 index 0000000..e1de303 --- /dev/null +++ b/test/slurp-made.txt @@ -0,0 +1 @@ +made by the handler diff --git a/test/slurp-out.txt b/test/slurp-out.txt new file mode 100644 index 0000000..e019be0 --- /dev/null +++ b/test/slurp-out.txt @@ -0,0 +1 @@ +second diff --git a/test/test_acceptance.ml b/test/test_acceptance.ml index e71ec30..4a748c9 100644 --- a/test/test_acceptance.ml +++ b/test/test_acceptance.ml @@ -453,6 +453,59 @@ let () = end; (try Sys.remove exe with Sys_error _ -> ()); + (* -- Files, NEXT.md decisions 1, 2 and 5 -------------------------- + Embedding first, because it is the one that costs nothing at run time + and needs no host ABI at all: a compiler feature, so no linker + arguments, no per-target packaging, and identical on desktop and web. + At -O0 and as a dev build too - a dev build emits a defconst as a + mutable global, so the (embed-dir) constant travels a different path + there and is worth seeing twice. *) + let embed_out = + "13\nhello from a\nBBB\n4\n0\n255\n254\n3\na.txt\nb.bin\nraw.bin\nBBB\n\ + no nope.txt\n" + in + outputs "embed, a file and a directory" "programs/embed.flan" embed_out; + outputs ~opt:"-O0" "embed, -O0" "programs/embed.flan" embed_out; + outputs ~dev:true "embed, dev" "programs/embed.flan" embed_out; + + (* slurp and barf, with all three restart paths taken: use-value on a read, + use-value on a write, and retry after the handler made the file. The + typed restart is the thing being exercised as much as the file I/O - + this is the first restart clause the *compiler* emits with a parameter, + and its parameter is the path slot the attempt reads. *) + let slurp_out = + "13\nhello from a\n4\n0\n255\n3\nBBB\n1\ntrue\ntrue\n\ + programs/assets/does-not-exist\n11\nround trip\n1\ntrue\nsecond\n\ + made by the handler\n1\ntrue\n" + in + let clean () = + List.iter (fun f -> try Sys.remove f with Sys_error _ -> ()) + [ "slurp-out.txt"; "slurp-made.txt" ] + in + clean (); + outputs "slurp and barf, with restarts" "programs/slurp.flan" slurp_out; + clean (); + outputs ~opt:"-O0" "slurp and barf, -O0" "programs/slurp.flan" slurp_out; + clean (); + + (* A missing file with nothing handling it. The same rule StorageExhausted + follows: [error] is the diverging variant, so the program stops on the + frame that erred rather than carrying on with a Vec that was never + filled. Neither restart is taken and both were still offered. *) + let exe = compile "programs/slurp-unhandled.flan" in + let code, text = run exe None in + if code <> 134 || not (contains text "before") + || not (contains text "unhandled FileError") + || contains text "unreachable" + then begin + incr failures; + Printf.printf + "FAIL an unhandled FileError stops the program\n\ + \ got: %S (exit %d)\n wanted: exit 134, naming the condition\n" + text code + end; + (try Sys.remove exe with Sys_error _ -> ()); + (* The epoch trap: a container whose allocator has been released. This is spec-memory.md's shipping answer to "Open: catching a use-after-release statically" — detection, loud and immediate, rather than a static rule From fff4f5d985ff88f5d2df8a2b7d12a87c4010771c Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sat, 12 Sep 2026 11:41:24 +0700 Subject: [PATCH 3/7] One source, two outcomes: barf is refused on the web and says so MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit programs/web-files.flan is built for both targets from the same text and neither build reads the target anywhere in parse.ml or check.ml. On the desktop it writes the file and says so; in the browser barf signals a FileError the program handles, naming the file and reason 4, file-unsupported. The whole of the difference is one #ifdef in flan_rt.c, which is where the host ABI is already implemented twice. The web case is run under node rather than inspected. An artifact-shape assertion would say nothing about what decision 2 actually bought — that a program on the web is told its write did not happen instead of quietly losing it — so the test asserts the refusal is printed and that the desktop's success line is absent. A silent no-op would have taken that branch, which is the outcome the decision rules out by name. The same program embeds a file and prints it, because that is the half needing no filesystem and no host ABI: the line is identical on both targets and is the answer for assets a web build has to carry. --- test/programs/web-files.flan | 35 ++++++++++++++++++++++++++ test/test_acceptance.ml | 12 +++++++++ test/test_web.ml | 49 ++++++++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+) create mode 100644 test/programs/web-files.flan diff --git a/test/programs/web-files.flan b/test/programs/web-files.flan new file mode 100644 index 0000000..dcf5263 --- /dev/null +++ b/test/programs/web-files.flan @@ -0,0 +1,35 @@ +;;;; The same source on both targets, which is the whole of decision 2. +;;;; +;;;; Flan has NO conditional compilation — nothing in parse.ml or check.ml +;;;; reads the target — so "isolate this to desktop" is not expressible here, +;;;; and a build-time refusal would therefore be unusable. A silent no-op is +;;;; worse than either, because that is how a save file disappears with nothing +;;;; said. So `barf` on the web signals a condition and the program decides, +;;;; which is the language having something Odin does not: Odin stubs its whole +;;;; file API on js/wasm to .Unsupported so that importing core:os "panics +;;;; cleanly", and a panic is not a decision. +;;;; +;;;; Built for the desktop this prints that the write worked. Built for the +;;;; browser it prints that it was refused, and says which file and why. One +;;;; source, two outcomes, no flag anywhere. + +(defn main [] i32 + ;; Embedding needs no filesystem and no host ABI, so this line is identical + ;; on both targets and is why decision 1 came first. + (print (embed "assets/a.txt" string)) + + (handler-bind + [(FileError [c] + (print "refused: ") + (print (.path c)) + (print " reason ") + (println (.reason c)) + (println (= (.reason c) file-unsupported)) + ;; There is no restart that means "give up and carry on" — spec- + ;; conditions.md §2 makes `error` diverging, and a handler that returns + ;; normally has not answered it. Leaving is the honest way out of a + ;; save that cannot happen. + (exit 0))] + (barf "web-files-out.txt" (bytes "state\n"))) + (println "wrote it") + 0) diff --git a/test/test_acceptance.ml b/test/test_acceptance.ml index 4a748c9..18ddf93 100644 --- a/test/test_acceptance.ml +++ b/test/test_acceptance.ml @@ -488,6 +488,18 @@ let () = outputs ~opt:"-O0" "slurp and barf, -O0" "programs/slurp.flan" slurp_out; clean (); + (* The desktop half of the one program whose behaviour differs by target. + test_web.ml builds this same text for the browser and asserts the other + outcome: there `barf` signals and the program says which file it could + not write, here it writes it. No conditional compilation is involved in + either - nothing in parse.ml or check.ml reads the target, and the whole + of the difference is one #ifdef in flan_rt.c. Seeing both halves is what + makes the claim a test rather than an assertion. *) + (try Sys.remove "web-files-out.txt" with Sys_error _ -> ()); + outputs "files, the desktop half of the web case" "programs/web-files.flan" + "hello from a\nwrote it\n"; + (try Sys.remove "web-files-out.txt" with Sys_error _ -> ()); + (* A missing file with nothing handling it. The same rule StorageExhausted follows: [error] is the diverging variant, so the program stops on the frame that erred rather than carrying on with a Vec that was never diff --git a/test/test_web.ml b/test/test_web.ml index c1dea12..3f6b0e9 100644 --- a/test/test_web.ml +++ b/test/test_web.ml @@ -131,6 +131,55 @@ let () = end; cleanup probe; + (* ── Files on the web, NEXT.md decision 2 ───────────────────────── + The one case here whose *behaviour* differs from the desktop's, and it + differs with no conditional compilation anywhere: programs/web-files.flan + is built for both targets from the same text, and nothing in parse.ml or + check.ml has read the target. On the desktop it writes the file and says + so; here `barf` signals a FileError the program handles, naming the file + and the reason. The refusal lives in one #ifdef in flan_rt.c, which is + where the host ABI is already implemented twice. + + This is run rather than inspected. An artifact-shape assertion would say + nothing about the thing decision 2 actually bought — that a program on + the web is *told* its write did not happen instead of quietly losing it. + + `embed` is in the same program on purpose: it is the half that needs no + filesystem and no host ABI, so the same line works on both targets and + is the answer for assets a web build has to carry. *) + if not (have "node") then + print_endline "web: skipping the barf case (no node)" + else begin + let out = Filename.concat scratch "flan-web-files.html" in + (match web_build "programs/web-files.flan" out with + | exception Failure m -> fail "barf for the browser: %s" m + | () -> + let _, js, _ = parts out in + let log = Filename.concat scratch "flan-web-files.out" in + let code = + Sys.command + (Printf.sprintf "node %s > %s 2>&1" (Filename.quote js) + (Filename.quote log)) + in + let text = In_channel.with_open_bin log In_channel.input_all in + (try Sys.remove log with Sys_error _ -> ()); + (* The embed, byte for byte, out of the module's own data. *) + if not (contains text "hello from a") then + fail "the embedded file did not reach the web build: %S" text; + (* The refusal, naming the file and the reason, and reason 4 is + file-unsupported rather than a missing file or a denied one. *) + if not (contains text "refused: web-files-out.txt reason 4") + || not (contains text "true") then + fail "barf did not signal on the web: %S" text; + (* And the desktop's line is absent: a silent no-op would have taken + this branch, which is the outcome decision 2 rules out by name. *) + if contains text "wrote it" then + fail "barf reported success on the web: %S" text; + if code <> 0 then + fail "the web barf case exited %d: %S" code text; + cleanup out) + end; + (* ── raylib in the browser ──────────────────────────────────────── The claim BUILT.md left open. core-basic-window.flan is built for the web unchanged — no edit to its `until` loop, which is the whole point From fe85ecd246f941aed8b26ca49eebfc047ce16546 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sat, 12 Sep 2026 11:43:34 +0700 Subject: [PATCH 4/7] The three ways an embed is written wrong, each said at the right moment A computed path, a file that is not there, and a second argument that is not `string`. The type argument is now settled before the file is opened: a program asking for a type embed cannot read a file as was otherwise told the file was missing, and got the real complaint only after fixing the wrong thing. A missing asset is a compile error naming it rather than an empty embed, because an asset silently absent is the class of quiet wrongness the whole feature exists to remove. An empty *directory* is not that: it embeds cleanly as [0 EmbedFile] and len answers 0. --- lib/check.ml | 15 +++++++++++---- test/test_acceptance.ml | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/lib/check.ml b/lib/check.ml index ade1447..576cb6c 100644 --- a/lib/check.ml +++ b/lib/check.ml @@ -2038,6 +2038,17 @@ and named_call ctx ~want loc name args = | "embed" -> (match args with | [ p ] | [ p; _ ] -> + (* The spelling is settled before the file is opened, so a program that + asks for a type embed cannot read a file as is told that, rather than + being told the file is missing and left to discover the other half + after fixing it. *) + (match args with + | [ _; { Ast.e = Ast.Var "string"; _ } ] | [ _ ] -> () + | [ _; t ] -> + fail t.Ast.loc + "embed's second argument is the type to read the file as, and \ + `string` is the only one — (embed \"p\") is the [u8]" + | _ -> ()); let data = read_embed_file (embed_path loc p) p.Ast.loc in let as_string () = mk loc Types.String (Tast.Str data) in (* A [Str] node typed [u8] rather than a [Bytes] prim over one. [Bytes] @@ -2055,10 +2066,6 @@ and named_call ctx ~want loc name args = (match args with | [ _; { Ast.e = Ast.Var "string"; _ } ] -> expect loc ~want (as_string ()) - | [ _; t ] -> - fail t.Ast.loc - "embed's second argument is the type to read the file as, and \ - `string` is the only one — (embed \"p\") is the [u8]" | _ -> (match want with | Some Types.String -> as_string () diff --git a/test/test_acceptance.ml b/test/test_acceptance.ml index 18ddf93..363673b 100644 --- a/test/test_acceptance.ml +++ b/test/test_acceptance.ml @@ -360,6 +360,25 @@ let () = "(defn main [] i32 (restart-case 0 (use-value [v i32] v))\n\ \ (invoke-restart 'use-value (println \"\")) 0)" "a restart argument must be a value"; + (* An embed reads the bytes before any value exists, so the path has to be + a literal - Odin's rule and for Odin's reason (check_load_directive + refuses anything that is not Addressing_Constant). This is the refusal + that keeps the result genuinely free at run time. *) + refuses_src "an embedded path that is computed" + "(defn main [] i32 (let [p \"x\"] (len (embed p))))" + "must be a literal string"; + (* A file that is not there is a compile error naming it, not an empty + embed: an asset silently missing is the class of quiet wrongness this + whole feature exists to remove. *) + refuses_src "an embedded file that does not exist" + "(defn main [] i32 (len (embed \"no-such-asset.bin\")))" + "cannot embed"; + (* One extra argument, and `string` is the only thing it can be. Two + spellings, not one form that changes type with its context. *) + refuses_src "embed asked for a type it cannot read a file as" + "(defn main [] i32 (len (embed \"no-such-asset.bin\" i32)))" + "`string` is the only one"; + (* Allocators, spec-memory.md. The tier on its own, with no container above it, so that a failure here is not read as a Vec bug. What is asserted is the capability set differing per allocator, the context From 9ab247badf1aef9b37a45204c361f221bead8133 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sat, 12 Sep 2026 11:45:41 +0700 Subject: [PATCH 5/7] Say why the embed is a constant and why barf refuses rather than lies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NEXT.md strikes decisions 1, 2 and 5, and the web target's "assets are two questions" item, which the embed answered with a third option neither half of it considered: make it a compiler feature and neither question arises. That item's diagnosis was right — the file that needs the asset is structurally the one file that cannot declare it — and its conclusion, that the fix must be a link channel or a new declaration, was wrong. BUILT.md gets the two sections. The embed one records the choice a reader would otherwise have to reverse-engineer: the bytes are a Str node typed [u8] rather than a Bytes prim over a string, because the prim is identity but makes the node non-constant, and an embed-dir in a defconst then cannot be an LLVM constant. It also states the .rodata write hole loudly, because an embedded asset is precisely what someone will try to decode in place. The slurp/barf one writes down what the host ABI grew by and why that much: three POSIX-shaped calls and one reason reader, Vec-ignorant, with the Vec-aware half as runtime glue rather than a fourth call. And it records the gap the feature revealed without fixing — a handler that wants "try to save, carry on if you cannot" has nowhere to go, because error is diverging and neither restart means give up. --- BUILT.md | 132 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ NEXT.md | 16 ++++--- 2 files changed, 143 insertions(+), 5 deletions(-) diff --git a/BUILT.md b/BUILT.md index 98d63c6..16bd9a4 100644 --- a/BUILT.md +++ b/BUILT.md @@ -1489,6 +1489,138 @@ And the **accumulation pattern** — `(fn [c] (push errors c) ...)` over an encl capture does not exist at all, and the spec's captured-`Vec`-by-pointer rule has never had to exist because every capturable type today is a value type. It is its own item and should be planned as one. +## Assets are baked in, and the reason it is a compiler feature + +NEXT.md decision 1. `(embed "brush.png")` is a `[u8]`, `(embed "brush.png" string)` is a `string`, and +`(embed-dir "assets")` is a `[n EmbedFile]` sorted by name. Odin's `#load` and `#load_directory` are the model +(`src/parser.cpp`, and `check_load_directive` / `check_load_directory_directive` in `src/check_builtin.cpp`); Odin's +`#` is not imported, because an s-expression language already has a head position for a name and these resolve as +ordinary named calls exactly the way `vec-new` and `heap-allocator` do. + +**The reason for this shape rather than a build flag is the one that decided it.** It is a *compiler* feature, so it +needs no linker arguments and no per-target packaging, and it works identically on desktop and web. That matters more +here than it does for Odin, because `Load` hands out `lflags` only to a directory package and `main` is not exported, +so a program can never be a package: the single file doing `(rl/load-texture "brush.png")` is structurally the one file +with **no link channel at all**. The web lane found that hole and did not invent a flag for it. Embedding has no such +hole, because there is nothing to tell the linker. + +**It costs nothing at run time.** The bytes reach the program as a `Tast.Str` node typed `[u8]`, which emit.ml turns +into the same `private unnamed_addr constant` every string literal already becomes, and its `escape` is byte-exact +across the whole 0–255 range, so a PNG survives the round trip through the `.ll`. Bound with `defconst` at top level an +`embed-dir` is an LLVM constant outright, through emit.ml's `const`. + +**A `Str` node typed `[u8]`, not a `Bytes` prim over a `string`.** This is the one non-obvious choice. `Bytes` is +identity — emit.ml lowers `Types.String` and `Types.Slice _` to the same `%slice` — but wrapping the literal in a prim +makes the node non-constant, and `const` then refuses an `embed-dir` in a `defconst` with *a global's value must be a +compile-time constant*. Both of emit.ml's string emitters take the bytes and ignore the node's type, so it is the same +constant either way and this one is a constant a global can hold. + +**Two spellings, not one form that changes type with its context.** Odin threads a `type_hint` everywhere and can +afford `#load("p")` to mean a `string` here and a `[]u8` there. With structural equality, no implicit widening and no +coercion anywhere, the same text meaning two types would be a wart, so `string` is written down when it is wanted. The +site's expectation is a fallback only and nothing depends on it. + +**The path is a literal and resolves relative to the file the form is written in.** Both are Odin's rules and for +Odin's reasons: the bytes must be in hand before any value exists, which is what makes the result free; and a path +relative to the compiler's working directory would make a package's assets depend on where `flan` was invoked from, +which cannot be right. A missing file is a compile error naming it, never an empty embed — an asset silently absent is +exactly the quiet wrongness this removes. An empty *directory* is not that case and embeds cleanly as `[0 EmbedFile]`. + +**The directory lookup is a linear scan, and that is the chosen answer rather than the fallback one.** `embed-find` is +an ordinary prelude function over a `[EmbedFile]`. A directory embed is tens of entries whose names sit in cache-warm +`.rodata`; a compile-time perfect hash would be a build-time map with its own failure modes that nothing has asked for, +and sort-and-bisect is the next step if a program ever embeds thousands of files — it would not change the type. It +takes a **slice** rather than the array, because an array's length is part of its type and there are no generics, so +the call reads `(embed-find (slice assets 0 (len assets)) "brush.png")`. Entries are sorted by name because `readdir` +order is filesystem-dependent and an unsorted embed would make two builds of identical sources emit different `.ll`. +Non-recursive, files only — Odin again. + +**The sharp edge, inherited and not widened.** The slice points into `.rodata`, so a store through it segfaults at +`-O0` and is deleted as undefined behaviour at `-O2` — the same trap the prelude's ASCII-case note measures for +`(bytes "Hi")`, and the same one NEXT.md tracks as "writing through a string literal". Nothing here makes it worse and +nothing here fixes it; provenance is what would. **To get a mutable copy, clone the bytes into a `Vec`.** It is worth +saying loudly because an embedded asset is precisely the thing someone will try to decode in place. + +**What this does not do.** `sand.flan` still calls `(rl/load-texture "brush.png")`, which hands raylib a path for +raylib to open. Pointing raylib at embedded bytes needs `LoadImageFromMemory` and `LoadTextureFromImage` in place of +`LoadTexture` — a raylib binding question, not an embedding one — so the flagship program is not yet asset-free on the +web. The mechanism it needs is in. + +## `slurp`, `barf`, and the two ways they fail + +NEXT.md decisions 2 and 5. `(slurp path)` and `(slurp path allocator)` read a whole file into a `(Vec u8)`; +`(barf path bytes)` writes one. + +**`slurp` waited for `Vec` because its result has no length until the file is read**, and it obeys spec-memory.md's +rule without an exception: *no allocating operation returns an error*. There is no `Result` here, no out-parameter and +no error code — `slurp`'s type is `(Vec u8)` and `barf`'s is `Unit`. + +**Two failures, two conditions, and the guards nest rather than merge.** Allocation failure is `StorageExhausted` under +`retry`, unchanged and reused. File failure is `FileError {:path :op :reason}` under `retry` and `use-value`. They stay +apart because they ask two different answerable questions: the handler that grows an arena is not the handler that +supplies another path, and collapsing them would make one handler guess which it was looking at. `file_guard` in +check.ml is `alloc_guard`'s shape built from the same nodes — a `while`, a `restart-case` and an `error` — so the +backend learns nothing new. + +**The restarts are Common Lisp's pair for a `file-error`.** `retry` for "the file may be there now"; `use-value [p +string]` for "try this other path". `use-value` is the first restart clause the **compiler itself** emits with a +parameter — typed restarts landed the same session — and its parameter *is* the path slot the attempt reads, so the +clause body is empty. emit.ml's `bind_params` stores the invoker's argument into the slot, the clause falls through, +and the loop re-attempts against the new path. Everything is inside that loop, so a `use-value` naming a different file +re-measures it and re-allocates for *its* size; the `Vec` is freed at the top of each turn, which is why a retry does +not leak, and freeing a `Vec` that never allocated is a no-op. + +**On the web, `barf` signals — every time, with the path in the condition.** This is the decision worth restating, +because two more obvious answers are both wrong here. A **build-time refusal** is unusable: Flan has *no conditional +compilation*, nothing in `parse.ml` or `check.ml` reads the target, so "isolate this to desktop" is not expressible in +source and the refusal would have nowhere to be silenced. A **silent no-op** is worse than either, because that is how +a save file disappears with nothing said. So the program gets a condition and decides — which is the language having +something Odin does not. Odin stubs its whole file API on js/wasm to `.Unsupported` (`core/os/file_js.odin`) so that +importing `core:os` "panics cleanly", and a panic is not a decision. **The restriction was taken; the mechanism was +not.** + +Nothing in the compiler reads the target to do this. The refusal is one `#ifdef __EMSCRIPTEN__` in `flan_rt.c`, which +is where the host ABI is *already* implemented twice. `slurp` keeps working on the web — it compiles, runs, and reports +a missing file honestly — and the bytes a web program actually wants come from an `embed`. + +`test/programs/web-files.flan` is one source built for both targets, and `test/test_web.ml` **runs** it under node +rather than asserting the artifact's shape: an artifact-shape assertion would say nothing about the thing the decision +bought. The test checks that the refusal is printed with its path and reason, and that the desktop's success line is +*absent* — a silent no-op would have taken that branch. + +### What the host ABI grew by, and why that much + +plan.org names the filesystem as the #1 portability risk — "pack assets, one abstraction, never touch paths" — so the +widening is written down rather than assumed. It is **three calls and one reader**: + +| Call | What it does | +|------|--------------| +| `flan_file_size(path, n, &out)` | how many bytes are there | +| `flan_file_read(path, n, buf, cap, &got)` | fill a buffer the caller owns | +| `flan_file_write(path, n, buf, len)` | write a whole file | +| `flan_file_fail_reason()` | which of the four reasons it was | + +They are POSIX-shaped and **Vec-ignorant**: no handle crosses the boundary, nothing is held between calls, and each +takes a path and answers 1/0 the way every allocator entry point already does. `flan_slurp_into` — the part that knows +what a `Vec` is — is runtime *glue* on this side of the ABI, not a fourth host call, so a second target implements +three functions and inherits the rest. The reason is a global rather than an out-parameter for the same reason +`flan_alloc_fail_bytes` is: the condition is a value struct on the failing frame's stack with fixed numeric fields and +no rendered message. + +**These do touch paths, which is the widening plan.org warned about**, and decision 2 took it knowingly. `embed` is the +half that does not: it needs no host ABI at all, so "pack assets" remains the answer for anything known at build time +and `slurp` is for bytes that genuinely are not. + +`FileError` is one type with a `reason` field rather than a family, because conditions have no hierarchy today +(spec-conditions.md §1) and a family would need one handler clause per member to say "any file error". NEXT.md decision +4's parent link is the answer to that and is not built; when it is, these reasons can become types without any call +site changing. + +**One thing `slurp` and `barf` reveal that is not theirs to fix.** A handler that wants "try to save, and carry on if +you cannot" has nowhere to go: `error` is diverging (spec-conditions.md §2), a handler returning normally has not +answered it, and neither `retry` nor `use-value` means *give up*. `web-files.flan` calls `exit` for that reason. A +`continue`-style restart — or `handler-case`, which unwinds — is what the case wants, and neither exists. + ## Where build time goes `flan build calc-me.flan` was ~160ms, and ~95% of it was clang. **The object cache is in**, and it is now ~110ms: diff --git a/NEXT.md b/NEXT.md index d9f4901..deeff41 100644 --- a/NEXT.md +++ b/NEXT.md @@ -229,7 +229,7 @@ $ flan run sand.flan # a window, 120 fps, hold space Five questions were put and answered in one sitting. Each is a decision, not a preference — build against them, and reopen one only with a reason rather than a taste. -**1. Assets are embedded at compile time, one file or one directory.** Odin's answer, and the reason it is the right +~~**1. Assets are embedded at compile time, one file or one directory.**~~ **Built** — `(embed "p")`, `(embed "p" string)`, `(embed-dir "d")`. See BUILT.md, "Assets are baked in". Odin's answer, and the reason it is the right one here: it is a *compiler* feature, so it needs no build flags, no linker arguments and no per-target packaging, and it works identically on desktop and web. That matters more here than it does for Odin, because `Load` gives link flags only to directory packages — the single file doing `(rl/load-texture "brush.png")` is structurally the one file with @@ -238,7 +238,7 @@ no link channel, which is what stopped the web lane from inventing a flag. Embed `--preload-file` stays available later for assets that should load lazily rather than be baked in; the `@web` link line already carries it if wanted. -**2. Reading a file works everywhere; writing is desktop-only and signals on web.** Odin stubs its whole file API on +~~**2. Reading a file works everywhere; writing is desktop-only and signals on web.**~~ **Built** — `barf` on the web signals `FileError` with reason `file-unsupported`, and `test/test_web.ml` runs it under node rather than asserting the artifact's shape. See BUILT.md, "slurp, barf, and the two ways they fail". Odin stubs its whole file API on js/wasm — every operation returns `.Unsupported`, and `core/os/file_js.odin`'s own comment says the stubs exist only so importing `core:os` "panics cleanly". Take the restriction and not the mechanism. **Flan has no conditional compilation** — nothing in `parse.ml` or `check.ml` reads the target — so "isolate this code to desktop" is not @@ -264,7 +264,7 @@ those costs and leaves the frozen model otherwise intact. Real inheritance stays this closes nothing off. That section stays open for the record but is no longer the blocking question for `handler-case`. -**5. File I/O — `slurp` and `barf` — is the next stdlib work**, after `Vec`, because `slurp` returns a string whose +~~**5. File I/O — `slurp` and `barf` — is the next stdlib work**~~ **Built.** It was the next stdlib work, after `Vec`, because `slurp` returns a string whose length is not known until the file is read and therefore cannot exist before an allocator does. ## Blocked and unfinished @@ -375,8 +375,14 @@ equivalent — add the include and the agent compiles into a web build that can `vendor:agent` by name on a web target the way `--dev` is refused. The second is the honest one. Neither was taken here: `vendor/agent/` belonged to another lane this session. -**2. Assets are two questions and only one of them is about emscripten.** `sand.flan` does -`(rl/load-texture "brush.png")` against a bare relative path. +~~**2. Assets are two questions and only one of them is about emscripten.**~~ **Answered by the embed above, and the +answer was the third option neither half here considered: make it a compiler feature and neither question arises.** The +hard half below is exactly right about the problem — the file that needs the asset is structurally the one file that +cannot declare it — and the conclusion drawn from it, that the fix must be a link channel or a new declaration, was +the wrong one. `(embed "brush.png")` needs no channel, because there is nothing to tell the linker. What is *not* done +is `sand.flan` itself: `(rl/load-texture "brush.png")` takes a path and raylib opens it, so pointing raylib at embedded +bytes needs `LoadTextureFromImage` over `LoadImageFromMemory`, which is a raylib binding question and not this one. +The original text follows. `sand.flan` does `(rl/load-texture "brush.png")` against a bare relative path. - The easy half: a bare relative path has no meaning on a target with no filesystem. emscripten's answer is `--embed-file` or `--preload-file` into MEMFS, and both are *linker arguments*, so they are already expressible as an From 2a5632bca162a07ec32385c8225d9b3115e9d13a Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sat, 12 Sep 2026 11:50:06 +0700 Subject: [PATCH 6/7] Guard the whole embed read, not only the open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Linux open_in_bin on a directory succeeds and in_channel_length answers a number; the read is where EISDIR arrives. Guarding only the open turned (embed "assets") — someone who meant embed-dir — into an uncaught OCaml exception out of the checker, which is the one way a user could make the compiler crash rather than refuse. It now says it is a directory and names the form that embeds one. Same class, same function family: read_embed_dir tested is_directory before file_exists, and Sys.is_directory raises on a path that does not resolve, so a dangling symlink inside an embedded directory crashed before the existence test ran. The conjuncts are swapped. slurp.flan gets its dev build, and the compiler-emitted use-value gets the same unarmed-restart assertion the hand-written one has. It is the first clause the compiler emits with a parameter — alloc_guard's retry takes none — so it is worth saying it rides emit.ml's existing path rather than sitting beside it. flan_file_read loses its declare: nothing Flan emits calls it, only flan_slurp_into does, from C. That takes the edit to emit.ml down to four declare lines and a comment. --- BUILT.md | 13 +++++++++++++ lib/check.ml | 36 ++++++++++++++++++++++++++++-------- lib/emit.ml | 8 ++++---- test/test_acceptance.ml | 26 ++++++++++++++++++++++++++ 4 files changed, 71 insertions(+), 12 deletions(-) diff --git a/BUILT.md b/BUILT.md index 16bd9a4..d970c86 100644 --- a/BUILT.md +++ b/BUILT.md @@ -1570,6 +1570,19 @@ and the loop re-attempts against the new path. Everything is inside that loop, s re-measures it and re-allocates for *its* size; the `Vec` is freed at the top of each turn, which is why a retry does not leak, and freeing a `Vec` that never allocated is a no-op. +**The two forms resolve paths by opposite rules, and it is worth saying in one place.** An `embed` path is resolved at +*compile* time relative to the file the form is written in. A `slurp` or `barf` path is resolved at *run* time by the +host, against the process's working directory — these are ordinary values, and one can arrive from `argv` or from a +`use-value` restart. `test/programs/slurp.flan` reads `"programs/assets/a.txt"` only because the suite runs from +`_build/default/test`. Two forms in one section with opposite rules is exactly where someone gets bitten. + +**The break loop can be offered this restart and cannot fill it in.** That is not new behaviour, only a new way to +reach it: a break loop chooses a restart by position and has nothing to supply a parameter with, and emit.ml already +emits a `flan_restart_unarmed` guard on every clause that takes one, so taking it refuses with the reason rather than +running the clause on a zeroed buffer. `slurp`'s `use-value` is simply the first such clause the *compiler* emits — +`alloc_guard`'s `retry` takes no parameters — and it rides the same path a hand-written one does. The acceptance suite +asserts the guard is on the emitted IR for both. + **On the web, `barf` signals — every time, with the path in the condition.** This is the decision worth restating, because two more obvious answers are both wrong here. A **build-time refusal** is unusable: Flan has *no conditional compilation*, nothing in `parse.ml` or `check.ml` reads the target, so "isolate this to desktop" is not expressible in diff --git a/lib/check.ml b/lib/check.ml index 576cb6c..91be36e 100644 --- a/lib/check.ml +++ b/lib/check.ml @@ -389,14 +389,29 @@ let embed_path loc (p : Ast.expr) = "an embedded path must be a literal string — the bytes are read at \ compile time, so there is nothing here to compute it from" +(* The whole read is guarded, not only the open. On Linux [open_in_bin] on a + *directory* succeeds and [in_channel_length] answers a number; the read is + where EISDIR arrives. Guarding only the open therefore turned (embed "dir") + — someone who meant embed-dir — into an uncaught OCaml exception out of the + checker, which is the one way a user can make the compiler crash rather than + refuse. *) let read_embed_file path loc = - match open_in_bin path with - | exception Sys_error msg -> Loc.fail loc "cannot embed %s: %s" path msg - | ch -> - let n = in_channel_length ch in - let s = really_input_string ch n in - close_in ch; - s + match + let ch = open_in_bin path in + Fun.protect ~finally:(fun () -> close_in_noerr ch) + (fun () -> really_input_string ch (in_channel_length ch)) + with + | s -> s + | exception Sys_error msg -> + if Sys.file_exists path && (try Sys.is_directory path with Sys_error _ -> false) + then + Loc.fail loc + "cannot embed %s: it is a directory — (embed-dir \"...\") embeds one \ + of those, as a [n EmbedFile]" + path + else Loc.fail loc "cannot embed %s: %s" path msg + | exception End_of_file -> + Loc.fail loc "cannot embed %s: it changed size while being read" path (* Non-recursive, files only, sorted by name — the three things Odin's #load_directory settles, and the sort is the one that matters most here: @@ -408,11 +423,16 @@ let read_embed_dir path loc = | exception Sys_error msg -> Loc.fail loc "cannot embed %s: %s" path msg | a -> Array.to_list a in + (* [Sys.is_directory] *raises* on a path that does not resolve, so the + existence test has to come first: a dangling symlink in an embedded + directory would otherwise crash the compiler before it was ever asked + about. Non-recursive and files only, which is Odin's rule too. *) let files = List.filter (fun n -> let full = Filename.concat path n in - (not (Sys.is_directory full)) && Sys.file_exists full) + Sys.file_exists full + && not (try Sys.is_directory full with Sys_error _ -> true)) names in List.map diff --git a/lib/emit.ml b/lib/emit.ml index 31963b0..68ccd28 100644 --- a/lib/emit.ml +++ b/lib/emit.ml @@ -1819,11 +1819,11 @@ declare i64 @flan_vec_len(ptr, ptr, i64) declare ptr @flan_vec_at(ptr, i32, i64, ptr, i64) declare void @flan_vec_as_slice(ptr, ptr, i32, i32, i64, ptr, i64) declare void @flan_vec_free(ptr, i64, i64, ptr, i64) -; The filesystem. Three host calls plus one reason reader, and flan_slurp_into -; is runtime glue rather than a fourth — see flan_rt.c for why the widening -; stops here. `embed` needs none of these: it is a compile-time constant. +; The filesystem. flan_file_read is not here: nothing Flan emits calls it — +; only flan_slurp_into does, from C — and flan_slurp_into is runtime glue +; rather than a fourth host call. See flan_rt.c for why the widening stops +; here. `embed` needs none of these: it is a compile-time constant. declare i8 @flan_file_size(ptr, i64, ptr) -declare i8 @flan_file_read(ptr, i64, ptr, i64, ptr) declare i8 @flan_file_write(ptr, i64, ptr, i64) declare i64 @flan_file_fail_reason() declare i8 @flan_slurp_into(ptr, ptr, i64) diff --git a/test/test_acceptance.ml b/test/test_acceptance.ml index 363673b..0b9f47f 100644 --- a/test/test_acceptance.ml +++ b/test/test_acceptance.ml @@ -373,6 +373,14 @@ let () = refuses_src "an embedded file that does not exist" "(defn main [] i32 (len (embed \"no-such-asset.bin\")))" "cannot embed"; + (* A directory where a file was meant. On Linux open_in_bin on a directory + succeeds and the *read* is where EISDIR arrives, so this was an uncaught + exception out of the checker until the whole read was guarded - the one + way a user could make the compiler crash rather than refuse. *) + refuses_src "embed given a directory" + "(defn main [] i32 (len (embed \"programs/assets\")))" + "it is a directory"; + (* One extra argument, and `string` is the only thing it can be. Two spellings, not one form that changes type with its context. *) refuses_src "embed asked for a type it cannot read a file as" @@ -506,6 +514,24 @@ let () = clean (); outputs ~opt:"-O0" "slurp and barf, -O0" "programs/slurp.flan" slurp_out; clean (); + outputs ~dev:true "slurp and barf, dev" "programs/slurp.flan" slurp_out; + clean (); + + (* slurp's use-value is the first restart clause the *compiler* emits with + a parameter - alloc_guard's retry takes none - so the guard against the + break loop taking it with nothing to fill the parameter in with is worth + asserting here too. It is the same emit.ml path a hand-written typed + clause goes through (the restarts.flan case above), and this says the + compiler-emitted one is on it rather than beside it. *) + let p = + Reader.read_file "programs/slurp.flan" |> Parse.program |> Check.program + in + if not (contains (Emit.program p) "call void @flan_restart_unarmed(") then begin + incr failures; + print_endline + "FAIL the compiler-emitted use-value has no guard against being taken \ + without an argument" + end; (* The desktop half of the one program whose behaviour differs by target. test_web.ml builds this same text for the browser and asserts the other From 54c38d0654622e63f8becd0be9be943aa61eec22 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sat, 12 Sep 2026 11:50:22 +0700 Subject: [PATCH 7/7] The files slurp.flan writes are output, not sources They were committed by a git add -A taken after running the program by hand from the source tree. The suite runs it out of _build and cleans up after itself; this is only for a run done directly. --- .gitignore | 6 ++++++ test/slurp-made.txt | 1 - test/slurp-out.txt | 1 - 3 files changed, 6 insertions(+), 2 deletions(-) delete mode 100644 test/slurp-made.txt delete mode 100644 test/slurp-out.txt diff --git a/.gitignore b/.gitignore index 634e8d8..ceef522 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,9 @@ probe.c # came from. vendor/raylib/build-web.sh makes both, and the path is named to a # build through FLAN_RAYLIB_WEB, not committed. vendor/raylib/web/ + +# What programs/slurp.flan and programs/web-files.flan write when run by hand +# from the source tree rather than out of _build. +test/slurp-out.txt +test/slurp-made.txt +test/web-files-out.txt diff --git a/test/slurp-made.txt b/test/slurp-made.txt deleted file mode 100644 index e1de303..0000000 --- a/test/slurp-made.txt +++ /dev/null @@ -1 +0,0 @@ -made by the handler diff --git a/test/slurp-out.txt b/test/slurp-out.txt deleted file mode 100644 index e019be0..0000000 --- a/test/slurp-out.txt +++ /dev/null @@ -1 +0,0 @@ -second