Assets are baked in at compile time, one file or one whole directory

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.
This commit is contained in:
Joseph Ferano 2026-09-12 11:36:01 +07:00
parent ce59f90707
commit 1d7f5e1c85
9 changed files with 586 additions and 1 deletions

View File

@ -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

View File

@ -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

View File

@ -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 = "<prelude>"

View File

@ -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 <errno.h>
#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;
}

View File

@ -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)))

View File

@ -0,0 +1 @@
hello from a

View File

@ -0,0 +1 @@
BBB

Binary file not shown.

57
test/programs/embed.flan Normal file
View File

@ -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)