From 1d7f5e1c851b318c9f8dfdecb22ed6cd1febf279 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sat, 12 Sep 2026 11:36:01 +0700 Subject: [PATCH] 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)