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:
parent
ce59f90707
commit
1d7f5e1c85
290
lib/check.ml
290
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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>"
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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)))
|
||||
|
||||
1
test/programs/assets/a.txt
Normal file
1
test/programs/assets/a.txt
Normal file
@ -0,0 +1 @@
|
||||
hello from a
|
||||
1
test/programs/assets/b.bin
Normal file
1
test/programs/assets/b.bin
Normal file
@ -0,0 +1 @@
|
||||
BBB
|
||||
BIN
test/programs/assets/raw.bin
Normal file
BIN
test/programs/assets/raw.bin
Normal file
Binary file not shown.
57
test/programs/embed.flan
Normal file
57
test/programs/embed.flan
Normal 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)
|
||||
Loading…
x
Reference in New Issue
Block a user