Files baked in at compile time, and read and written at run time

This commit is contained in:
Joseph Ferano 2026-09-12 11:51:16 +07:00
commit eb98c5859a
17 changed files with 1082 additions and 6 deletions

6
.gitignore vendored
View File

@ -50,3 +50,9 @@ probe.c
# came from. vendor/raylib/build-web.sh makes both, and the path is named to a
# build through FLAN_RAYLIB_WEB, not committed.
vendor/raylib/web/
# What programs/slurp.flan and programs/web-files.flan write when run by hand
# from the source tree rather than out of _build.
test/slurp-out.txt
test/slurp-made.txt
test/web-files-out.txt

145
BUILT.md
View File

@ -1489,6 +1489,151 @@ And the **accumulation pattern** — `(fn [c] (push errors c) ...)` over an encl
capture does not exist at all, and the spec's captured-`Vec`-by-pointer rule has never had to exist because every
capturable type today is a value type. It is its own item and should be planned as one.
## Assets are baked in, and the reason it is a compiler feature
NEXT.md decision 1. `(embed "brush.png")` is a `[u8]`, `(embed "brush.png" string)` is a `string`, and
`(embed-dir "assets")` is a `[n EmbedFile]` sorted by name. Odin's `#load` and `#load_directory` are the model
(`src/parser.cpp`, and `check_load_directive` / `check_load_directory_directive` in `src/check_builtin.cpp`); Odin's
`#` is not imported, because an s-expression language already has a head position for a name and these resolve as
ordinary named calls exactly the way `vec-new` and `heap-allocator` do.
**The reason for this shape rather than a build flag is the one that decided it.** It is a *compiler* feature, so it
needs no linker arguments and no per-target packaging, and it works identically on desktop and web. That matters more
here than it does for Odin, because `Load` hands out `lflags` only to a directory package and `main` is not exported,
so a program can never be a package: the single file doing `(rl/load-texture "brush.png")` is structurally the one file
with **no link channel at all**. The web lane found that hole and did not invent a flag for it. Embedding has no such
hole, because there is nothing to tell the linker.
**It costs nothing at run time.** The bytes reach the program as a `Tast.Str` node typed `[u8]`, which emit.ml turns
into the same `private unnamed_addr constant` every string literal already becomes, and its `escape` is byte-exact
across the whole 0255 range, so a PNG survives the round trip through the `.ll`. Bound with `defconst` at top level an
`embed-dir` is an LLVM constant outright, through emit.ml's `const`.
**A `Str` node typed `[u8]`, not a `Bytes` prim over a `string`.** This is the one non-obvious choice. `Bytes` is
identity — emit.ml lowers `Types.String` and `Types.Slice _` to the same `%slice` — but wrapping the literal in a prim
makes the node non-constant, and `const` then refuses an `embed-dir` in a `defconst` with *a global's value must be a
compile-time constant*. Both of emit.ml's string emitters take the bytes and ignore the node's type, so it is the same
constant either way and this one is a constant a global can hold.
**Two spellings, not one form that changes type with its context.** Odin threads a `type_hint` everywhere and can
afford `#load("p")` to mean a `string` here and a `[]u8` there. With structural equality, no implicit widening and no
coercion anywhere, the same text meaning two types would be a wart, so `string` is written down when it is wanted. The
site's expectation is a fallback only and nothing depends on it.
**The path is a literal and resolves relative to the file the form is written in.** Both are Odin's rules and for
Odin's reasons: the bytes must be in hand before any value exists, which is what makes the result free; and a path
relative to the compiler's working directory would make a package's assets depend on where `flan` was invoked from,
which cannot be right. A missing file is a compile error naming it, never an empty embed — an asset silently absent is
exactly the quiet wrongness this removes. An empty *directory* is not that case and embeds cleanly as `[0 EmbedFile]`.
**The directory lookup is a linear scan, and that is the chosen answer rather than the fallback one.** `embed-find` is
an ordinary prelude function over a `[EmbedFile]`. A directory embed is tens of entries whose names sit in cache-warm
`.rodata`; a compile-time perfect hash would be a build-time map with its own failure modes that nothing has asked for,
and sort-and-bisect is the next step if a program ever embeds thousands of files — it would not change the type. It
takes a **slice** rather than the array, because an array's length is part of its type and there are no generics, so
the call reads `(embed-find (slice assets 0 (len assets)) "brush.png")`. Entries are sorted by name because `readdir`
order is filesystem-dependent and an unsorted embed would make two builds of identical sources emit different `.ll`.
Non-recursive, files only — Odin again.
**The sharp edge, inherited and not widened.** The slice points into `.rodata`, so a store through it segfaults at
`-O0` and is deleted as undefined behaviour at `-O2` — the same trap the prelude's ASCII-case note measures for
`(bytes "Hi")`, and the same one NEXT.md tracks as "writing through a string literal". Nothing here makes it worse and
nothing here fixes it; provenance is what would. **To get a mutable copy, clone the bytes into a `Vec`.** It is worth
saying loudly because an embedded asset is precisely the thing someone will try to decode in place.
**What this does not do.** `sand.flan` still calls `(rl/load-texture "brush.png")`, which hands raylib a path for
raylib to open. Pointing raylib at embedded bytes needs `LoadImageFromMemory` and `LoadTextureFromImage` in place of
`LoadTexture` — a raylib binding question, not an embedding one — so the flagship program is not yet asset-free on the
web. The mechanism it needs is in.
## `slurp`, `barf`, and the two ways they fail
NEXT.md decisions 2 and 5. `(slurp path)` and `(slurp path allocator)` read a whole file into a `(Vec u8)`;
`(barf path bytes)` writes one.
**`slurp` waited for `Vec` because its result has no length until the file is read**, and it obeys spec-memory.md's
rule without an exception: *no allocating operation returns an error*. There is no `Result` here, no out-parameter and
no error code — `slurp`'s type is `(Vec u8)` and `barf`'s is `Unit`.
**Two failures, two conditions, and the guards nest rather than merge.** Allocation failure is `StorageExhausted` under
`retry`, unchanged and reused. File failure is `FileError {:path :op :reason}` under `retry` and `use-value`. They stay
apart because they ask two different answerable questions: the handler that grows an arena is not the handler that
supplies another path, and collapsing them would make one handler guess which it was looking at. `file_guard` in
check.ml is `alloc_guard`'s shape built from the same nodes — a `while`, a `restart-case` and an `error` — so the
backend learns nothing new.
**The restarts are Common Lisp's pair for a `file-error`.** `retry` for "the file may be there now"; `use-value [p
string]` for "try this other path". `use-value` is the first restart clause the **compiler itself** emits with a
parameter — typed restarts landed the same session — and its parameter *is* the path slot the attempt reads, so the
clause body is empty. emit.ml's `bind_params` stores the invoker's argument into the slot, the clause falls through,
and the loop re-attempts against the new path. Everything is inside that loop, so a `use-value` naming a different file
re-measures it and re-allocates for *its* size; the `Vec` is freed at the top of each turn, which is why a retry does
not leak, and freeing a `Vec` that never allocated is a no-op.
**The two forms resolve paths by opposite rules, and it is worth saying in one place.** An `embed` path is resolved at
*compile* time relative to the file the form is written in. A `slurp` or `barf` path is resolved at *run* time by the
host, against the process's working directory — these are ordinary values, and one can arrive from `argv` or from a
`use-value` restart. `test/programs/slurp.flan` reads `"programs/assets/a.txt"` only because the suite runs from
`_build/default/test`. Two forms in one section with opposite rules is exactly where someone gets bitten.
**The break loop can be offered this restart and cannot fill it in.** That is not new behaviour, only a new way to
reach it: a break loop chooses a restart by position and has nothing to supply a parameter with, and emit.ml already
emits a `flan_restart_unarmed` guard on every clause that takes one, so taking it refuses with the reason rather than
running the clause on a zeroed buffer. `slurp`'s `use-value` is simply the first such clause the *compiler* emits —
`alloc_guard`'s `retry` takes no parameters — and it rides the same path a hand-written one does. The acceptance suite
asserts the guard is on the emitted IR for both.
**On the web, `barf` signals — every time, with the path in the condition.** This is the decision worth restating,
because two more obvious answers are both wrong here. A **build-time refusal** is unusable: Flan has *no conditional
compilation*, nothing in `parse.ml` or `check.ml` reads the target, so "isolate this to desktop" is not expressible in
source and the refusal would have nowhere to be silenced. A **silent no-op** is worse than either, because that is how
a save file disappears with nothing said. So the program gets a condition and decides — which is the language having
something Odin does not. Odin stubs its whole file API on js/wasm to `.Unsupported` (`core/os/file_js.odin`) so that
importing `core:os` "panics cleanly", and a panic is not a decision. **The restriction was taken; the mechanism was
not.**
Nothing in the compiler reads the target to do this. The refusal is one `#ifdef __EMSCRIPTEN__` in `flan_rt.c`, which
is where the host ABI is *already* implemented twice. `slurp` keeps working on the web — it compiles, runs, and reports
a missing file honestly — and the bytes a web program actually wants come from an `embed`.
`test/programs/web-files.flan` is one source built for both targets, and `test/test_web.ml` **runs** it under node
rather than asserting the artifact's shape: an artifact-shape assertion would say nothing about the thing the decision
bought. The test checks that the refusal is printed with its path and reason, and that the desktop's success line is
*absent* — a silent no-op would have taken that branch.
### What the host ABI grew by, and why that much
plan.org names the filesystem as the #1 portability risk — "pack assets, one abstraction, never touch paths" — so the
widening is written down rather than assumed. It is **three calls and one reader**:
| Call | What it does |
|------|--------------|
| `flan_file_size(path, n, &out)` | how many bytes are there |
| `flan_file_read(path, n, buf, cap, &got)` | fill a buffer the caller owns |
| `flan_file_write(path, n, buf, len)` | write a whole file |
| `flan_file_fail_reason()` | which of the four reasons it was |
They are POSIX-shaped and **Vec-ignorant**: no handle crosses the boundary, nothing is held between calls, and each
takes a path and answers 1/0 the way every allocator entry point already does. `flan_slurp_into` — the part that knows
what a `Vec` is — is runtime *glue* on this side of the ABI, not a fourth host call, so a second target implements
three functions and inherits the rest. The reason is a global rather than an out-parameter for the same reason
`flan_alloc_fail_bytes` is: the condition is a value struct on the failing frame's stack with fixed numeric fields and
no rendered message.
**These do touch paths, which is the widening plan.org warned about**, and decision 2 took it knowingly. `embed` is the
half that does not: it needs no host ABI at all, so "pack assets" remains the answer for anything known at build time
and `slurp` is for bytes that genuinely are not.
`FileError` is one type with a `reason` field rather than a family, because conditions have no hierarchy today
(spec-conditions.md §1) and a family would need one handler clause per member to say "any file error". NEXT.md decision
4's parent link is the answer to that and is not built; when it is, these reasons can become types without any call
site changing.
**One thing `slurp` and `barf` reveal that is not theirs to fix.** A handler that wants "try to save, and carry on if
you cannot" has nowhere to go: `error` is diverging (spec-conditions.md §2), a handler returning normally has not
answered it, and neither `retry` nor `use-value` means *give up*. `web-files.flan` calls `exit` for that reason. A
`continue`-style restart — or `handler-case`, which unwinds — is what the case wants, and neither exists.
## Where build time goes
`flan build calc-me.flan` was ~160ms, and ~95% of it was clang. **The object cache is in**, and it is now ~110ms:

16
NEXT.md
View File

@ -229,7 +229,7 @@ $ flan run sand.flan # a window, 120 fps, hold space
Five questions were put and answered in one sitting. Each is a decision, not a preference — build against them, and
reopen one only with a reason rather than a taste.
**1. Assets are embedded at compile time, one file or one directory.** Odin's answer, and the reason it is the right
~~**1. Assets are embedded at compile time, one file or one directory.**~~ **Built**`(embed "p")`, `(embed "p" string)`, `(embed-dir "d")`. See BUILT.md, "Assets are baked in". Odin's answer, and the reason it is the right
one here: it is a *compiler* feature, so it needs no build flags, no linker arguments and no per-target packaging, and
it works identically on desktop and web. That matters more here than it does for Odin, because `Load` gives link flags
only to directory packages — the single file doing `(rl/load-texture "brush.png")` is structurally the one file with
@ -238,7 +238,7 @@ no link channel, which is what stopped the web lane from inventing a flag. Embed
`--preload-file` stays available later for assets that should load lazily rather than be baked in; the `@web` link
line already carries it if wanted.
**2. Reading a file works everywhere; writing is desktop-only and signals on web.** Odin stubs its whole file API on
~~**2. Reading a file works everywhere; writing is desktop-only and signals on web.**~~ **Built**`barf` on the web signals `FileError` with reason `file-unsupported`, and `test/test_web.ml` runs it under node rather than asserting the artifact's shape. See BUILT.md, "slurp, barf, and the two ways they fail". Odin stubs its whole file API on
js/wasm — every operation returns `.Unsupported`, and `core/os/file_js.odin`'s own comment says the stubs exist only
so importing `core:os` "panics cleanly". Take the restriction and not the mechanism. **Flan has no conditional
compilation** — nothing in `parse.ml` or `check.ml` reads the target — so "isolate this code to desktop" is not
@ -264,7 +264,7 @@ those costs and leaves the frozen model otherwise intact. Real inheritance stays
this closes nothing off. That section stays open for the record but is no longer the blocking question for
`handler-case`.
**5. File I/O — `slurp` and `barf` — is the next stdlib work**, after `Vec`, because `slurp` returns a string whose
~~**5. File I/O — `slurp` and `barf` — is the next stdlib work**~~ **Built.** It was the next stdlib work, after `Vec`, because `slurp` returns a string whose
length is not known until the file is read and therefore cannot exist before an allocator does.
## Blocked and unfinished
@ -375,8 +375,14 @@ equivalent — add the include and the agent compiles into a web build that can
`vendor:agent` by name on a web target the way `--dev` is refused. The second is the honest one. Neither was taken
here: `vendor/agent/` belonged to another lane this session.
**2. Assets are two questions and only one of them is about emscripten.** `sand.flan` does
`(rl/load-texture "brush.png")` against a bare relative path.
~~**2. Assets are two questions and only one of them is about emscripten.**~~ **Answered by the embed above, and the
answer was the third option neither half here considered: make it a compiler feature and neither question arises.** The
hard half below is exactly right about the problem — the file that needs the asset is structurally the one file that
cannot declare it — and the conclusion drawn from it, that the fix must be a link channel or a new declaration, was
the wrong one. `(embed "brush.png")` needs no channel, because there is nothing to tell the linker. What is *not* done
is `sand.flan` itself: `(rl/load-texture "brush.png")` takes a path and raylib opens it, so pointing raylib at embedded
bytes needs `LoadTextureFromImage` over `LoadImageFromMemory`, which is a raylib binding question and not this one.
The original text follows. `sand.flan` does `(rl/load-texture "brush.png")` against a bare relative path.
- The easy half: a bare relative path has no meaning on a target with no filesystem. emscripten's answer is
`--embed-file` or `--preload-file` into MEMFS, and both are *linker arguments*, so they are already expressible as an

View File

@ -366,6 +366,79 @@ 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"
(* The whole read is guarded, not only the open. On Linux [open_in_bin] on a
*directory* succeeds and [in_channel_length] answers a number; the read is
where EISDIR arrives. Guarding only the open therefore turned (embed "dir")
someone who meant embed-dir into an uncaught OCaml exception out of the
checker, which is the one way a user can make the compiler crash rather than
refuse. *)
let read_embed_file path loc =
match
let ch = open_in_bin path in
Fun.protect ~finally:(fun () -> close_in_noerr ch)
(fun () -> really_input_string ch (in_channel_length ch))
with
| s -> s
| exception Sys_error msg ->
if Sys.file_exists path && (try Sys.is_directory path with Sys_error _ -> false)
then
Loc.fail loc
"cannot embed %s: it is a directory — (embed-dir \"...\") embeds one \
of those, as a [n EmbedFile]"
path
else Loc.fail loc "cannot embed %s: %s" path msg
| exception End_of_file ->
Loc.fail loc "cannot embed %s: it changed size while being read" path
(* Non-recursive, files only, sorted by name — the three things Odin's
#load_directory settles, and the sort is the one that matters most here:
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
(* [Sys.is_directory] *raises* on a path that does not resolve, so the
existence test has to come first: a dangling symlink in an embedded
directory would otherwise crash the compiler before it was ever asked
about. Non-recursive and files only, which is Odin's rule too. *)
let files =
List.filter
(fun n ->
let full = Filename.concat path n in
Sys.file_exists full
&& not (try Sys.is_directory full with Sys_error _ -> true))
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 +1462,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 +2028,177 @@ 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; _ ] ->
(* The spelling is settled before the file is opened, so a program that
asks for a type embed cannot read a file as is told that, rather than
being told the file is missing and left to discover the other half
after fixing it. *)
(match args with
| [ _; { Ast.e = Ast.Var "string"; _ } ] | [ _ ] -> ()
| [ _; t ] ->
fail t.Ast.loc
"embed's second argument is the type to read the file as, and \
`string` is the only one (embed \"p\") is the [u8]"
| _ -> ());
let data = read_embed_file (embed_path loc p) p.Ast.loc in
let as_string () = mk loc Types.String (Tast.Str data) in
(* A [Str] node typed [u8] rather than a [Bytes] prim over one. [Bytes]
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 ())
| _ ->
(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. flan_file_read is not here: nothing Flan emits calls it
; only flan_slurp_into does, from C and flan_slurp_into is runtime glue
; rather than a fourth host call. See flan_rt.c for why the widening stops
; here. `embed` needs none of these: it is a compile-time constant.
declare i8 @flan_file_size(ptr, i64, ptr)
declare i8 @flan_file_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)

View File

@ -0,0 +1,10 @@
;;;; A missing file with nothing handling it. spec-conditions.md §2: `error` is
;;;; the diverging variant, so the program stops on the frame that erred rather
;;;; than carrying on with a Vec that was never filled. The restarts are
;;;; offered whether or not anyone takes them — a break loop lists both.
(defn main [] i32
(println "before")
(let [v (slurp "programs/assets/does-not-exist")]
(println "unreachable")
(free v))
0)

103
test/programs/slurp.flan Normal file
View File

@ -0,0 +1,103 @@
;;;; slurp and barf — NEXT.md decisions 2 and 5.
;;;;
;;;; slurp reads a whole file and answers a (Vec u8). It allocates, which is
;;;; why it waited for Vec, and it follows spec-memory.md's rule to the letter:
;;;; no allocating operation returns an error, so there is no Result here and
;;;; no out-parameter anywhere.
;;;;
;;;; Failure signals a condition under a restart, which is the pattern
;;;; StorageExhausted set this session. Two restarts, and they are the pair
;;;; Common Lisp establishes for a file-error:
;;;;
;;;; retry the file may be there now
;;;; use-value [p string] try this other path instead
;;;;
;;;; use-value is a *typed* restart — the second thing this session bought —
;;;; and it is the first one the compiler itself emits. Its parameter is the
;;;; path slot the attempt reads, so the clause body is empty: the invoker's
;;;; argument lands in the slot, the clause falls through, and the loop
;;;; re-attempts against the new path.
;; Handlers cannot see the locals of the function that established them, so the
;; observations are globals — the same shape exhausted.flan uses.
(defvar seen i64)
(defvar last-reason i32)
(defvar last-op i32)
(defvar last-path string)
(defn main [] i32
;; ── The happy path ────────────────────────────────────────────────
(let [v (slurp "programs/assets/a.txt")]
(println (len v)) ; 13
(print (string (as-slice v))) ; hello from a
(free v))
;; Byte-exact, the same as an embed: nothing here decodes anything.
(let [v (slurp "programs/assets/raw.bin")]
(println (len v)) ; 4
(println (at v 0)) ; 0
(println (at v 2)) ; 255
(free v))
;; ── use-value: a missing file, answered with another path ─────────
;; The textbook case. The handler does not know what slurp was going to do
;; with the bytes and does not have to: it names a file that is there and
;; the read resumes as if that had been asked for all along.
(handler-bind
[(FileError [c]
(set seen (+ seen 1))
(set last-reason (.reason c))
(set last-op (.op c))
(set last-path (.path c))
(invoke-restart 'use-value "programs/assets/b.bin"))]
(let [v (slurp "programs/assets/does-not-exist")]
(println (len v)) ; 3
(println (string (as-slice v))) ; BBB
(free v)))
(println seen) ; 1
(println (= last-reason file-missing)) ; true
(println (= last-op file-op-read)) ; true
;; The condition carries the path that actually failed, not the one that
;; eventually worked — the handler is told what it is answering about.
(println last-path) ; programs/assets/does-not-exist
;; ── barf, and reading back what it wrote ──────────────────────────
(barf "slurp-out.txt" (bytes "round trip\n"))
(let [v (slurp "slurp-out.txt")]
(println (len v)) ; 11
(print (string (as-slice v))) ; round trip
(free v))
;; barf's own failure signals the same condition with op = write. A directory
;; that does not exist is the reachable case on every platform.
(set seen 0)
(handler-bind
[(FileError [c]
(set seen (+ seen 1))
(set last-op (.op c))
(invoke-restart 'use-value "slurp-out.txt"))]
(barf "no-such-dir/x.txt" (bytes "second\n")))
(println seen) ; 1
(println (= last-op file-op-write)) ; true
(let [v (slurp "slurp-out.txt")]
(print (string (as-slice v))) ; second
(free v))
;; ── retry: the file was not there, so the handler makes it ────────
;; The other restart, and the one use-value cannot stand in for: here the
;; path is right and the world is wrong. The handler fixes the world and
;; re-attempts the *same* request, which is exactly what retry means for a
;; failed allocation too.
(set seen 0)
(handler-bind
[(FileError [c]
(set seen (+ seen 1))
(set last-reason (.reason c))
(barf "slurp-made.txt" (bytes "made by the handler\n"))
(invoke-restart 'retry))]
(let [v (slurp "slurp-made.txt")]
(print (string (as-slice v))) ; made by the handler
(free v)))
(println seen) ; 1
(println (= last-reason file-missing)) ; true
0)

View File

@ -0,0 +1,35 @@
;;;; The same source on both targets, which is the whole of decision 2.
;;;;
;;;; Flan has NO conditional compilation — nothing in parse.ml or check.ml
;;;; reads the target — so "isolate this to desktop" is not expressible here,
;;;; and a build-time refusal would therefore be unusable. A silent no-op is
;;;; worse than either, because that is how a save file disappears with nothing
;;;; said. So `barf` on the web signals a condition and the program decides,
;;;; which is the language having something Odin does not: Odin stubs its whole
;;;; file API on js/wasm to .Unsupported so that importing core:os "panics
;;;; cleanly", and a panic is not a decision.
;;;;
;;;; Built for the desktop this prints that the write worked. Built for the
;;;; browser it prints that it was refused, and says which file and why. One
;;;; source, two outcomes, no flag anywhere.
(defn main [] i32
;; Embedding needs no filesystem and no host ABI, so this line is identical
;; on both targets and is why decision 1 came first.
(print (embed "assets/a.txt" string))
(handler-bind
[(FileError [c]
(print "refused: ")
(print (.path c))
(print " reason ")
(println (.reason c))
(println (= (.reason c) file-unsupported))
;; There is no restart that means "give up and carry on" — spec-
;; conditions.md §2 makes `error` diverging, and a handler that returns
;; normally has not answered it. Leaving is the honest way out of a
;; save that cannot happen.
(exit 0))]
(barf "web-files-out.txt" (bytes "state\n")))
(println "wrote it")
0)

View File

@ -360,6 +360,33 @@ let () =
"(defn main [] i32 (restart-case 0 (use-value [v i32] v))\n\
\ (invoke-restart 'use-value (println \"\")) 0)"
"a restart argument must be a value";
(* An embed reads the bytes before any value exists, so the path has to be
a literal - Odin's rule and for Odin's reason (check_load_directive
refuses anything that is not Addressing_Constant). This is the refusal
that keeps the result genuinely free at run time. *)
refuses_src "an embedded path that is computed"
"(defn main [] i32 (let [p \"x\"] (len (embed p))))"
"must be a literal string";
(* A file that is not there is a compile error naming it, not an empty
embed: an asset silently missing is the class of quiet wrongness this
whole feature exists to remove. *)
refuses_src "an embedded file that does not exist"
"(defn main [] i32 (len (embed \"no-such-asset.bin\")))"
"cannot embed";
(* A directory where a file was meant. On Linux open_in_bin on a directory
succeeds and the *read* is where EISDIR arrives, so this was an uncaught
exception out of the checker until the whole read was guarded - the one
way a user could make the compiler crash rather than refuse. *)
refuses_src "embed given a directory"
"(defn main [] i32 (len (embed \"programs/assets\")))"
"it is a directory";
(* One extra argument, and `string` is the only thing it can be. Two
spellings, not one form that changes type with its context. *)
refuses_src "embed asked for a type it cannot read a file as"
"(defn main [] i32 (len (embed \"no-such-asset.bin\" i32)))"
"`string` is the only one";
(* Allocators, spec-memory.md. The tier on its own, with no container
above it, so that a failure here is not read as a Vec bug. What is
asserted is the capability set differing per allocator, the context
@ -453,6 +480,89 @@ let () =
end;
(try Sys.remove exe with Sys_error _ -> ());
(* -- Files, NEXT.md decisions 1, 2 and 5 --------------------------
Embedding first, because it is the one that costs nothing at run time
and needs no host ABI at all: a compiler feature, so no linker
arguments, no per-target packaging, and identical on desktop and web.
At -O0 and as a dev build too - a dev build emits a defconst as a
mutable global, so the (embed-dir) constant travels a different path
there and is worth seeing twice. *)
let embed_out =
"13\nhello from a\nBBB\n4\n0\n255\n254\n3\na.txt\nb.bin\nraw.bin\nBBB\n\
no nope.txt\n"
in
outputs "embed, a file and a directory" "programs/embed.flan" embed_out;
outputs ~opt:"-O0" "embed, -O0" "programs/embed.flan" embed_out;
outputs ~dev:true "embed, dev" "programs/embed.flan" embed_out;
(* slurp and barf, with all three restart paths taken: use-value on a read,
use-value on a write, and retry after the handler made the file. The
typed restart is the thing being exercised as much as the file I/O -
this is the first restart clause the *compiler* emits with a parameter,
and its parameter is the path slot the attempt reads. *)
let slurp_out =
"13\nhello from a\n4\n0\n255\n3\nBBB\n1\ntrue\ntrue\n\
programs/assets/does-not-exist\n11\nround trip\n1\ntrue\nsecond\n\
made by the handler\n1\ntrue\n"
in
let clean () =
List.iter (fun f -> try Sys.remove f with Sys_error _ -> ())
[ "slurp-out.txt"; "slurp-made.txt" ]
in
clean ();
outputs "slurp and barf, with restarts" "programs/slurp.flan" slurp_out;
clean ();
outputs ~opt:"-O0" "slurp and barf, -O0" "programs/slurp.flan" slurp_out;
clean ();
outputs ~dev:true "slurp and barf, dev" "programs/slurp.flan" slurp_out;
clean ();
(* slurp's use-value is the first restart clause the *compiler* emits with
a parameter - alloc_guard's retry takes none - so the guard against the
break loop taking it with nothing to fill the parameter in with is worth
asserting here too. It is the same emit.ml path a hand-written typed
clause goes through (the restarts.flan case above), and this says the
compiler-emitted one is on it rather than beside it. *)
let p =
Reader.read_file "programs/slurp.flan" |> Parse.program |> Check.program
in
if not (contains (Emit.program p) "call void @flan_restart_unarmed(") then begin
incr failures;
print_endline
"FAIL the compiler-emitted use-value has no guard against being taken \
without an argument"
end;
(* The desktop half of the one program whose behaviour differs by target.
test_web.ml builds this same text for the browser and asserts the other
outcome: there `barf` signals and the program says which file it could
not write, here it writes it. No conditional compilation is involved in
either - nothing in parse.ml or check.ml reads the target, and the whole
of the difference is one #ifdef in flan_rt.c. Seeing both halves is what
makes the claim a test rather than an assertion. *)
(try Sys.remove "web-files-out.txt" with Sys_error _ -> ());
outputs "files, the desktop half of the web case" "programs/web-files.flan"
"hello from a\nwrote it\n";
(try Sys.remove "web-files-out.txt" with Sys_error _ -> ());
(* A missing file with nothing handling it. The same rule StorageExhausted
follows: [error] is the diverging variant, so the program stops on the
frame that erred rather than carrying on with a Vec that was never
filled. Neither restart is taken and both were still offered. *)
let exe = compile "programs/slurp-unhandled.flan" in
let code, text = run exe None in
if code <> 134 || not (contains text "before")
|| not (contains text "unhandled FileError")
|| contains text "unreachable"
then begin
incr failures;
Printf.printf
"FAIL an unhandled FileError stops the program\n\
\ got: %S (exit %d)\n wanted: exit 134, naming the condition\n"
text code
end;
(try Sys.remove exe with Sys_error _ -> ());
(* The epoch trap: a container whose allocator has been released. This is
spec-memory.md's shipping answer to "Open: catching a use-after-release
statically" — detection, loud and immediate, rather than a static rule

View File

@ -131,6 +131,55 @@ let () =
end;
cleanup probe;
(* ── Files on the web, NEXT.md decision 2 ─────────────────────────
The one case here whose *behaviour* differs from the desktop's, and it
differs with no conditional compilation anywhere: programs/web-files.flan
is built for both targets from the same text, and nothing in parse.ml or
check.ml has read the target. On the desktop it writes the file and says
so; here `barf` signals a FileError the program handles, naming the file
and the reason. The refusal lives in one #ifdef in flan_rt.c, which is
where the host ABI is already implemented twice.
This is run rather than inspected. An artifact-shape assertion would say
nothing about the thing decision 2 actually bought that a program on
the web is *told* its write did not happen instead of quietly losing it.
`embed` is in the same program on purpose: it is the half that needs no
filesystem and no host ABI, so the same line works on both targets and
is the answer for assets a web build has to carry. *)
if not (have "node") then
print_endline "web: skipping the barf case (no node)"
else begin
let out = Filename.concat scratch "flan-web-files.html" in
(match web_build "programs/web-files.flan" out with
| exception Failure m -> fail "barf for the browser: %s" m
| () ->
let _, js, _ = parts out in
let log = Filename.concat scratch "flan-web-files.out" in
let code =
Sys.command
(Printf.sprintf "node %s > %s 2>&1" (Filename.quote js)
(Filename.quote log))
in
let text = In_channel.with_open_bin log In_channel.input_all in
(try Sys.remove log with Sys_error _ -> ());
(* The embed, byte for byte, out of the module's own data. *)
if not (contains text "hello from a") then
fail "the embedded file did not reach the web build: %S" text;
(* The refusal, naming the file and the reason, and reason 4 is
file-unsupported rather than a missing file or a denied one. *)
if not (contains text "refused: web-files-out.txt reason 4")
|| not (contains text "true") then
fail "barf did not signal on the web: %S" text;
(* And the desktop's line is absent: a silent no-op would have taken
this branch, which is the outcome decision 2 rules out by name. *)
if contains text "wrote it" then
fail "barf reported success on the web: %S" text;
if code <> 0 then
fail "the web barf case exited %d: %S" code text;
cleanup out)
end;
(* ── raylib in the browser ────────────────────────────────────────
The claim BUILT.md left open. core-basic-window.flan is built for the
web unchanged no edit to its `until` loop, which is the whole point