Files baked in at compile time, and read and written at run time
This commit is contained in:
commit
eb98c5859a
6
.gitignore
vendored
6
.gitignore
vendored
@ -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
145
BUILT.md
@ -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 0–255 range, so a PNG survives the round trip through the `.ll`. Bound with `defconst` at top level an
|
||||
`embed-dir` is an LLVM constant outright, through emit.ml's `const`.
|
||||
|
||||
**A `Str` node typed `[u8]`, not a `Bytes` prim over a `string`.** This is the one non-obvious choice. `Bytes` is
|
||||
identity — emit.ml lowers `Types.String` and `Types.Slice _` to the same `%slice` — but wrapping the literal in a prim
|
||||
makes the node non-constant, and `const` then refuses an `embed-dir` in a `defconst` with *a global's value must be a
|
||||
compile-time constant*. Both of emit.ml's string emitters take the bytes and ignore the node's type, so it is the same
|
||||
constant either way and this one is a constant a global can hold.
|
||||
|
||||
**Two spellings, not one form that changes type with its context.** Odin threads a `type_hint` everywhere and can
|
||||
afford `#load("p")` to mean a `string` here and a `[]u8` there. With structural equality, no implicit widening and no
|
||||
coercion anywhere, the same text meaning two types would be a wart, so `string` is written down when it is wanted. The
|
||||
site's expectation is a fallback only and nothing depends on it.
|
||||
|
||||
**The path is a literal and resolves relative to the file the form is written in.** Both are Odin's rules and for
|
||||
Odin's reasons: the bytes must be in hand before any value exists, which is what makes the result free; and a path
|
||||
relative to the compiler's working directory would make a package's assets depend on where `flan` was invoked from,
|
||||
which cannot be right. A missing file is a compile error naming it, never an empty embed — an asset silently absent is
|
||||
exactly the quiet wrongness this removes. An empty *directory* is not that case and embeds cleanly as `[0 EmbedFile]`.
|
||||
|
||||
**The directory lookup is a linear scan, and that is the chosen answer rather than the fallback one.** `embed-find` is
|
||||
an ordinary prelude function over a `[EmbedFile]`. A directory embed is tens of entries whose names sit in cache-warm
|
||||
`.rodata`; a compile-time perfect hash would be a build-time map with its own failure modes that nothing has asked for,
|
||||
and sort-and-bisect is the next step if a program ever embeds thousands of files — it would not change the type. It
|
||||
takes a **slice** rather than the array, because an array's length is part of its type and there are no generics, so
|
||||
the call reads `(embed-find (slice assets 0 (len assets)) "brush.png")`. Entries are sorted by name because `readdir`
|
||||
order is filesystem-dependent and an unsorted embed would make two builds of identical sources emit different `.ll`.
|
||||
Non-recursive, files only — Odin again.
|
||||
|
||||
**The sharp edge, inherited and not widened.** The slice points into `.rodata`, so a store through it segfaults at
|
||||
`-O0` and is deleted as undefined behaviour at `-O2` — the same trap the prelude's ASCII-case note measures for
|
||||
`(bytes "Hi")`, and the same one NEXT.md tracks as "writing through a string literal". Nothing here makes it worse and
|
||||
nothing here fixes it; provenance is what would. **To get a mutable copy, clone the bytes into a `Vec`.** It is worth
|
||||
saying loudly because an embedded asset is precisely the thing someone will try to decode in place.
|
||||
|
||||
**What this does not do.** `sand.flan` still calls `(rl/load-texture "brush.png")`, which hands raylib a path for
|
||||
raylib to open. Pointing raylib at embedded bytes needs `LoadImageFromMemory` and `LoadTextureFromImage` in place of
|
||||
`LoadTexture` — a raylib binding question, not an embedding one — so the flagship program is not yet asset-free on the
|
||||
web. The mechanism it needs is in.
|
||||
|
||||
## `slurp`, `barf`, and the two ways they fail
|
||||
|
||||
NEXT.md decisions 2 and 5. `(slurp path)` and `(slurp path allocator)` read a whole file into a `(Vec u8)`;
|
||||
`(barf path bytes)` writes one.
|
||||
|
||||
**`slurp` waited for `Vec` because its result has no length until the file is read**, and it obeys spec-memory.md's
|
||||
rule without an exception: *no allocating operation returns an error*. There is no `Result` here, no out-parameter and
|
||||
no error code — `slurp`'s type is `(Vec u8)` and `barf`'s is `Unit`.
|
||||
|
||||
**Two failures, two conditions, and the guards nest rather than merge.** Allocation failure is `StorageExhausted` under
|
||||
`retry`, unchanged and reused. File failure is `FileError {:path :op :reason}` under `retry` and `use-value`. They stay
|
||||
apart because they ask two different answerable questions: the handler that grows an arena is not the handler that
|
||||
supplies another path, and collapsing them would make one handler guess which it was looking at. `file_guard` in
|
||||
check.ml is `alloc_guard`'s shape built from the same nodes — a `while`, a `restart-case` and an `error` — so the
|
||||
backend learns nothing new.
|
||||
|
||||
**The restarts are Common Lisp's pair for a `file-error`.** `retry` for "the file may be there now"; `use-value [p
|
||||
string]` for "try this other path". `use-value` is the first restart clause the **compiler itself** emits with a
|
||||
parameter — typed restarts landed the same session — and its parameter *is* the path slot the attempt reads, so the
|
||||
clause body is empty. emit.ml's `bind_params` stores the invoker's argument into the slot, the clause falls through,
|
||||
and the loop re-attempts against the new path. Everything is inside that loop, so a `use-value` naming a different file
|
||||
re-measures it and re-allocates for *its* size; the `Vec` is freed at the top of each turn, which is why a retry does
|
||||
not leak, and freeing a `Vec` that never allocated is a no-op.
|
||||
|
||||
**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
16
NEXT.md
@ -229,7 +229,7 @@ $ flan run sand.flan # a window, 120 fps, hold space
|
||||
Five questions were put and answered in one sitting. Each is a decision, not a preference — build against them, and
|
||||
reopen one only with a reason rather than a taste.
|
||||
|
||||
**1. Assets are embedded at compile time, one file or one directory.** Odin's answer, and the reason it is the right
|
||||
~~**1. Assets are embedded at compile time, one file or one directory.**~~ **Built** — `(embed "p")`, `(embed "p" string)`, `(embed-dir "d")`. See BUILT.md, "Assets are baked in". Odin's answer, and the reason it is the right
|
||||
one here: it is a *compiler* feature, so it needs no build flags, no linker arguments and no per-target packaging, and
|
||||
it works identically on desktop and web. That matters more here than it does for Odin, because `Load` gives link flags
|
||||
only to directory packages — the single file doing `(rl/load-texture "brush.png")` is structurally the one file with
|
||||
@ -238,7 +238,7 @@ no link channel, which is what stopped the web lane from inventing a flag. Embed
|
||||
`--preload-file` stays available later for assets that should load lazily rather than be baked in; the `@web` link
|
||||
line already carries it if wanted.
|
||||
|
||||
**2. Reading a file works everywhere; writing is desktop-only and signals on web.** Odin stubs its whole file API on
|
||||
~~**2. Reading a file works everywhere; writing is desktop-only and signals on web.**~~ **Built** — `barf` on the web signals `FileError` with reason `file-unsupported`, and `test/test_web.ml` runs it under node rather than asserting the artifact's shape. See BUILT.md, "slurp, barf, and the two ways they fail". Odin stubs its whole file API on
|
||||
js/wasm — every operation returns `.Unsupported`, and `core/os/file_js.odin`'s own comment says the stubs exist only
|
||||
so importing `core:os` "panics cleanly". Take the restriction and not the mechanism. **Flan has no conditional
|
||||
compilation** — nothing in `parse.ml` or `check.ml` reads the target — so "isolate this code to desktop" is not
|
||||
@ -264,7 +264,7 @@ those costs and leaves the frozen model otherwise intact. Real inheritance stays
|
||||
this closes nothing off. That section stays open for the record but is no longer the blocking question for
|
||||
`handler-case`.
|
||||
|
||||
**5. File I/O — `slurp` and `barf` — is the next stdlib work**, after `Vec`, because `slurp` returns a string whose
|
||||
~~**5. File I/O — `slurp` and `barf` — is the next stdlib work**~~ **Built.** It was the next stdlib work, after `Vec`, because `slurp` returns a string whose
|
||||
length is not known until the file is read and therefore cannot exist before an allocator does.
|
||||
|
||||
## Blocked and unfinished
|
||||
@ -375,8 +375,14 @@ equivalent — add the include and the agent compiles into a web build that can
|
||||
`vendor:agent` by name on a web target the way `--dev` is refused. The second is the honest one. Neither was taken
|
||||
here: `vendor/agent/` belonged to another lane this session.
|
||||
|
||||
**2. Assets are two questions and only one of them is about emscripten.** `sand.flan` does
|
||||
`(rl/load-texture "brush.png")` against a bare relative path.
|
||||
~~**2. Assets are two questions and only one of them is about emscripten.**~~ **Answered by the embed above, and the
|
||||
answer was the third option neither half here considered: make it a compiler feature and neither question arises.** The
|
||||
hard half below is exactly right about the problem — the file that needs the asset is structurally the one file that
|
||||
cannot declare it — and the conclusion drawn from it, that the fix must be a link channel or a new declaration, was
|
||||
the wrong one. `(embed "brush.png")` needs no channel, because there is nothing to tell the linker. What is *not* done
|
||||
is `sand.flan` itself: `(rl/load-texture "brush.png")` takes a path and raylib opens it, so pointing raylib at embedded
|
||||
bytes needs `LoadTextureFromImage` over `LoadImageFromMemory`, which is a raylib binding question and not this one.
|
||||
The original text follows. `sand.flan` does `(rl/load-texture "brush.png")` against a bare relative path.
|
||||
|
||||
- The easy half: a bare relative path has no meaning on a target with no filesystem. emscripten's answer is
|
||||
`--embed-file` or `--preload-file` into MEMFS, and both are *linker arguments*, so they are already expressible as an
|
||||
|
||||
317
lib/check.ml
317
lib/check.ml
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
10
test/programs/slurp-unhandled.flan
Normal file
10
test/programs/slurp-unhandled.flan
Normal 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
103
test/programs/slurp.flan
Normal 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)
|
||||
35
test/programs/web-files.flan
Normal file
35
test/programs/web-files.flan
Normal 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)
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user