Merge: a macro reads the file, and the struct is the file's shape
This commit is contained in:
commit
1d74a4f694
11
NEXT.md
11
NEXT.md
@ -1073,8 +1073,15 @@ rather than cumulative, which is a deliberate divergence from `watch.clj` argued
|
||||
five numbers, and the window is the editor's". **Item 6 landed the same day**:
|
||||
`test/programs/frame-rollback.flan` is the worked example — `snapshot` at the top of the frame, `restore` in the
|
||||
`continue` clause — and `bounds-condition.flan`'s half-written abandoned frame is the thought it finishes. That was
|
||||
the last Tier 1 item anyone was going to move. Items 7 (`drop`), 8 (generics) and 9 (`(read-edn T bytes)`) are still
|
||||
on that list and still deferred with reasons written beside each; none is a blocker for this game.
|
||||
the last Tier 1 item anyone was going to move. **Item 9 (`(read-edn T bytes)`) landed 2026-09-19**, and not as
|
||||
`read-edn`: `(edn/defedn Tileset "assets/tileset.edn")` derives the struct from the *file* at compile time and
|
||||
emits a reader with it, so the ~80 lines `docs/PORTING.md` prices for two schemas are not written by hand and not
|
||||
written by the compiler either. It is a macro in `vendor:edn`, and `defjson` is the same over `vendor:json`. The
|
||||
competing answer PORTING names — compile-time embedding — is not a competitor after all but the other half: the
|
||||
shape comes from the file at compile time and the bytes may come from an `embed` beside it. See
|
||||
[`docs/BUILT.md`](docs/BUILT.md), "A type provider: the struct a data file implies". Items 7 (`drop`) and 8
|
||||
(generics) are still on that list and still deferred with reasons written beside each; neither is a blocker for
|
||||
this game.
|
||||
|
||||
**What `docs/PORTING.md` says NOT to build, with evidence:** escaping closures (one capture site, fixed by one parameter),
|
||||
`Handle`/pools, `Result`/`try`, `handler-case`, `loop`/`recur` and tail calls, user allocators, structural typing —
|
||||
|
||||
175
docs/BUILT.md
175
docs/BUILT.md
@ -5804,3 +5804,178 @@ the same comparison the prelude uses. An infinity still prints signed: there the
|
||||
the value, and the backends were always agreed about it. Pinned in
|
||||
`test/programs/format.flan`, which is in the x86 survey corpus, so one program holds both
|
||||
the printed form under `dune test` and the agreement under the survey.
|
||||
|
||||
## A type provider: the struct a data file implies, derived while the program is compiled
|
||||
|
||||
`(edn/defedn Tileset "assets/tileset.edn")` reads the file at expansion time, works out
|
||||
what shape it is, and answers the struct that shape implies together with a reader over
|
||||
the tokenizer next door. `(.texture-path data)` is then a field load off a struct nobody
|
||||
declared: no `Value`, no `match`, no runtime tag, nothing looked up by name. F#'s type
|
||||
providers with the useful half and none of the plugin protocol, and `vendor/json`'s
|
||||
`defjson` is the same thing over a different grammar.
|
||||
|
||||
Both are macros in their packages, not compiler builtins. That was the requirement and it
|
||||
held, but only after four things the macro system did not have. Each is general and none
|
||||
of them mentions EDN.
|
||||
|
||||
### A macro may read a file, at the path the call site is written at
|
||||
|
||||
`(embed "assets/x.edn")` resolves against the directory of the *source file the form is
|
||||
written in* — check.ml's `embed_path`, and Odin's rule before it — because anything else
|
||||
makes a package's assets depend on where `flan` happened to be invoked from. A macro had
|
||||
no way to honour that rule: it runs inside the compiler and could always have called
|
||||
`slurp`, but a `Form` carries no location, deliberately, so a macro handed
|
||||
`(defedn T "assets/x.edn")` knows the path and not what it is relative to.
|
||||
|
||||
So the compiler tells it. `lib/macro.ml`'s `dir_of` pokes the call site's directory into
|
||||
two C symbols in the macro module's own `flan_rt.c` before every expansion, and the
|
||||
prelude's `(macro-slurp "...")` joins the two. C data and not a Flan global because
|
||||
`Build.macro_module` emits with hidden visibility and only the `flan.macro.*` thunks stay
|
||||
exported — the other half of that same comment is that the C goes on resolving the way it
|
||||
always did, which is what makes these findable.
|
||||
|
||||
It answers `None` rather than signalling, and that is why it is not `slurp`. A condition
|
||||
raised inside an expansion is raised in the compiler, through the module's own copy of the
|
||||
runtime, which is the failure `Build.macro_module`'s hidden-visibility note measured: it
|
||||
takes the process down instead of parking it. Absence arriving as an answer is what lets
|
||||
a provider refuse *about* a missing file, with a location, which is the sentence its
|
||||
author wanted anyway.
|
||||
|
||||
Re-expansion re-reads, and nothing had to be built for that: the cached macro module holds
|
||||
the macro's code, not the data, and `expand_form` calls the macro fresh. Editing the
|
||||
`.edn` and building again produces the struct that file now implies — measured by adding
|
||||
a key and finding it in the emitted IR.
|
||||
|
||||
### A package's macro may call the package's functions
|
||||
|
||||
`Load.qualify_macro` already renamed a package macro's body so a call to the package's own
|
||||
`next` reads `edn/next`; the intent was written down. The module was then compiled from
|
||||
the prelude and the `defmacro`s alone, so the call arrived at the checker as *the call
|
||||
edn/next into an imported package*. That was the machinery missing a piece, not a rule —
|
||||
and it is why a derivation this size can be ordinary Flan over the tokenizer next door
|
||||
instead of a second scanner inlined into a macro body.
|
||||
|
||||
`Parse.imported_decls` carries the package's declarations beside its macros, already
|
||||
qualified, and `Macro.compile` trims them to what the macro bodies reach. raylib's five
|
||||
`with-*` are pure quasiquote, so nothing of raylib is reachable and its module is the one
|
||||
it always was — which matters, because raylib's declarations are `declare`s against a
|
||||
library a macro module has no linker argument for. The trim is over names and happens
|
||||
before `Check`, since a surviving `Declare` would be emitted whether or not the checker
|
||||
was asked about it.
|
||||
|
||||
One thing this exposed. A quasiquote inside a package's *ordinary function* was never
|
||||
qualified — only a `defmacro` body goes through `qualify_macro` — so a
|
||||
`(defn ... [c (Ptr Cursor)] ...)` emitted from a derivation helper reached the importer
|
||||
naming a type it had never heard of. The name survives into a *string*, which is exactly
|
||||
the property the expander's walk depends on and exactly what puts it out of a rename's
|
||||
reach. `rename_expr` now qualifies a literal `(Form.Sym {.s "..."})` naming something the
|
||||
package owns. Until a package's macros could call its functions there was no helper that
|
||||
built code, so this could not have shown before.
|
||||
|
||||
### One call, several declarations
|
||||
|
||||
Expansion is form-for-form, and every macro written until now expanded to an *expression*.
|
||||
A provider produces the struct *and* the reader over it, and a struct per nesting level in
|
||||
the data: three declarations and more from one form, which no arrangement of one-for-one
|
||||
reaches. A top-level `(do ...)` is now its items, spliced in place, after expansion and
|
||||
before the declaration walk. Nobody writes one in a file, and the single-declaration entry
|
||||
point says so by name for anyone who tries.
|
||||
|
||||
### `compile-error`, because a name carries a name and not a sentence
|
||||
|
||||
This is the one piece that had to go in the compiler, and the prelude's `unless` had
|
||||
already written down why: *a macro has no error facility, so a malformed call answers a
|
||||
name nothing defines and the report is the right place with the wrong sentence.* A
|
||||
provider's refusals are all sentence — *the third element of this vector is a string where
|
||||
the first two were integers*, at line 3 column 9 of a file the compiler is not reading —
|
||||
and no symbol an expansion could invent holds that.
|
||||
|
||||
So a macro that has to refuse expands to `(compile-error "...")`, one arm in check.ml's
|
||||
builtin match. A builtin because it has to fail *while checking*: a declared function
|
||||
would compile, link and run, and the compile it was meant to stop would have succeeded.
|
||||
`Loc.from_macro` already stamps the call site onto every node of an expansion, so the
|
||||
location is the form the author wrote and the sentence is the macro's — the two halves the
|
||||
prelude's note says are never both right at once. It is wrapped in a `defn` with a
|
||||
gensym'd name, because a top-level position takes a declaration and the body is where an
|
||||
expression the checker walks can live.
|
||||
|
||||
### The rules it derives, and the one the game file decided
|
||||
|
||||
A map with keyword keys is a struct, one field per key. An integer is `i64`, a float
|
||||
`f64`, a boolean `bool`, a string `string` — *copied*, which is `read.flan`'s contract and
|
||||
not the tokenizer's: a `Token`'s text points into the buffer and a struct that outlives
|
||||
the buffer cannot hold one. A vector of one repeated shape is `(Vec T)`. A nested map is a
|
||||
struct named for the path that reaches it, `Tileset-selected-cells`, with a hyphen because
|
||||
`/` is package qualification and because every name in this language is hyphenated
|
||||
already, so no case conversion has to be written at macro time.
|
||||
|
||||
The set rule is the one the real file decided. A set is this repo's `(Map T bool)` —
|
||||
check.ml says exactly that where it refuses a `()` value — so its elements are map *keys*,
|
||||
and a vector inside a set is therefore a fixed array `[n T]` and not a `(Vec T)`: a Vec is
|
||||
not a map key and `[2 i64]` is. `game-data.edn` is a set of `[x y]` pairs, so that is the
|
||||
case rather than a corner of it. Every element must then be the same *length* as well as
|
||||
the same shape, which falls out of the type comparison already being made, since the
|
||||
length is in the type.
|
||||
|
||||
Everything else is refused while expanding, with the line and column in the **data** file:
|
||||
a heterogeneous collection names both positions, an empty one has no element to derive
|
||||
from, a `nil` has no type, and a map with a key that is not a keyword is not a struct. A
|
||||
file the compiler could not make sense of is one the program would have read wrongly.
|
||||
|
||||
`(vec-new)` and `(map-new)` have to be *told* what they build by naming a type, and
|
||||
`(Vec i64)` and `[2 i64]` have no name to be. Both fall back to what the context wants and
|
||||
a signature is a type position where anything can be written, so each collection gets a
|
||||
one-line constructor stating its type. The reader reads better for it: it says
|
||||
`(cells-new a)` where it would otherwise carry a type nobody wrote.
|
||||
|
||||
### Two conditions at read time, for the two ways a file stops matching
|
||||
|
||||
The struct was derived from the file as it was when the program was compiled.
|
||||
`SchemaDrift` names a key that has arrived or a field that has gone, once each, because a
|
||||
missing key otherwise leaves a field at zero — a texture path of `""` and a count of `0` —
|
||||
and the program draws nothing for a reason nothing reports. `ReadFailed` is the louder
|
||||
one: a generated reader accumulates errors on the cursor, and the cursor is made and
|
||||
dropped inside the entry point, so a file that does not parse would have handed back a
|
||||
zeroed struct with nothing said. `read-file` answers an `Option` precisely so a malformed
|
||||
document is distinguishable from one that is literally nil, and a derived reader is held
|
||||
to the same honesty. Neither is fatal: signalling a condition no handler takes carries on,
|
||||
so a program that would rather not care writes nothing.
|
||||
|
||||
### What `defjson` shares, which is the design and not the code
|
||||
|
||||
`vendor/json` imports `vendor/edn` for nothing, and borrowing a shape walk across that
|
||||
line would be a dependency for the sake of a resemblance. What carries over is the shape
|
||||
of the answer — one walk giving a type, the declarations it needs and the expression that
|
||||
reads one; a refusal carried in a field rather than raised; the typed constructor per
|
||||
collection; the gensym'd `compile-error` wrapper.
|
||||
|
||||
Four things are genuinely different. Strings go through `string-of` and never through
|
||||
`.text`, because `.text` is the raw interior with escapes undecoded and a field read off
|
||||
it would hold a backslash and an `n` where the file meant a newline. Commas and colons are
|
||||
tokens rather than whitespace. An object's keys are strings, so a key is refused when it
|
||||
is not a name a program could write, and refused again when it carries an escape — the
|
||||
generated reader compares against the bytes as written, which costs no allocation per key
|
||||
and is only the same question when the name is written plainly. And there are no sets, so
|
||||
there is no map-key path and no fixed array: every collection is a `(Vec T)`, and
|
||||
`defjson` is the smaller of the two by half. JSON has no integer type either; the
|
||||
tokenizer draws the line at whether a number has a fraction or an exponent, which is the
|
||||
only line there is, so `1` derives `i64` and `1.0` derives `f64`.
|
||||
|
||||
### What is checked
|
||||
|
||||
`test/programs/edn-provide.flan` reads the real `assets/edn/tileset.edn` through a derived
|
||||
struct, and its first five lines are `edn-read.flan`'s first five character for character.
|
||||
Two readers over one file agreeing is what says the derived one is right; either alone
|
||||
could be self-consistently wrong. The pair memberships are the derivation deciding in
|
||||
public — `[3 4]` is a key and `[9 9]` is not, where a version that made the set 108 loose
|
||||
integers would have compiled and answered differently on all four.
|
||||
|
||||
The refusals write their own data file, because the data file *is* the test, and each is
|
||||
asserted on the position it names rather than on the fact of failing. One checks a line
|
||||
and column into a file the compiler is not reading, which is the whole of what
|
||||
`compile-error` was added for. `test_session.ml` expands a `defedn` through a session at
|
||||
an origin the editor would have sent, which is the `C-c C-m` path and the live-tuning
|
||||
loop: it asserts both that the data file resolved against the buffer's directory and that
|
||||
what comes back is code a person can read. The generated readers survey under `@x86` — the
|
||||
fixed-array map key is a hash and equality pair nothing generated had asked the backend
|
||||
for before — and `@sanitize` is clean over both programs.
|
||||
|
||||
@ -214,10 +214,11 @@ tileset file itself.
|
||||
|
||||
So this is writable today, and the tileset needs no hand-written reader at all: it reads
|
||||
as a `Value.Table` whose `:selected-cells` is a `Value.Set` of pairs, against an arena,
|
||||
released by one `free-all`. A hand-written reader is still what a *struct* costs, because
|
||||
`(read-edn T bytes)` is not built — call it ~80 lines for the bitmask table if it wants
|
||||
to land in a struct rather than a `Value`. See §3 for whether it should be written at
|
||||
all.
|
||||
released by one `free-all`. A hand-written reader is no longer what a *struct* costs:
|
||||
`(edn/defedn Tileset "assets/tileset.edn")` derives the struct from the file while the
|
||||
program is compiled and emits the reader with it, so the ~80 lines this used to price are
|
||||
not written at all. See §3 item 9, and `docs/BUILT.md`, "A type provider: the struct a
|
||||
data file implies".
|
||||
|
||||
---
|
||||
|
||||
@ -629,10 +630,15 @@ not compete for the same slot.
|
||||
game rather than in the engine. **Do not sequence it ahead of items 4–6 on this game's
|
||||
account.**
|
||||
|
||||
9. **`(read-edn T bytes)`.** `vendor:edn` already makes the asset readers writable; this
|
||||
removes ~80 lines of hand-written cursor walking for two schemas. Convenience, and it
|
||||
competes with compile-time embedding, which may be the better answer for both files
|
||||
anyway.
|
||||
9. ~~**`(read-edn T bytes)`.**~~ **Built**, and not under that name: `(edn/defedn Tileset
|
||||
"assets/tileset.edn")` reads the *file* while the program is compiled, derives the
|
||||
struct its shape implies, and emits a reader with it — so the ~80 lines are neither
|
||||
hand-written nor written by the compiler from a type that was declared by hand. It is a
|
||||
macro in `vendor:edn`, not a builtin, and `defjson` is the same over `vendor:json`.
|
||||
The competition with compile-time embedding was the wrong reading of it: they are the
|
||||
two halves of one answer, the shape from the file at compile time and the bytes from an
|
||||
`embed` beside it. See `docs/BUILT.md`, "A type provider: the struct a data file
|
||||
implies".
|
||||
|
||||
**Not ranked, because this game does not need them:** escaping closures and capture (one
|
||||
site, fixed by one parameter), `Handle` and pools (nothing to pool), `Result`/`try`
|
||||
|
||||
39
lib/check.ml
39
lib/check.ml
@ -4683,6 +4683,41 @@ and named_call ctx ~want loc name args =
|
||||
| _ ->
|
||||
fail loc
|
||||
"embed is (embed \"path\") for a [u8], or (embed \"path\" string)")
|
||||
(* ── What a macro says when it has to refuse ───────────────────
|
||||
The one thing a macro could not do, written down in the prelude where
|
||||
[unless] settles for it: "a macro has no error facility: it runs inside
|
||||
the compiler and anything it signals aborts the compile with no location.
|
||||
So a malformed (unless) answers a name nothing defines, and the report is
|
||||
'unknown name unless-takes-a-test-and-a-body' at the call site, which is
|
||||
the right place and the wrong sentence."
|
||||
|
||||
A name nothing defines carries a name. It cannot carry a sentence, and a
|
||||
type provider's refusals are all sentence: the third element of this
|
||||
vector is a string where the first two were integers; there is no file at
|
||||
assets/x.edn; :size is a map with keys of two kinds. Those name a position
|
||||
in a *data* file, which no symbol the expansion could invent will hold.
|
||||
|
||||
So a macro that has to refuse expands to a call to this, and the string is
|
||||
the report. [Loc.from_macro] has already stamped the call site onto every
|
||||
node of the expansion, so the location is the [defedn] the author wrote
|
||||
and the sentence is the macro's — which is the two halves the prelude's
|
||||
note says are never both right at once.
|
||||
|
||||
A builtin and not a declaration, because it has to fail *here*: a declared
|
||||
function would compile, link and run, and the compile it was meant to stop
|
||||
would have succeeded. The whole of it is one arm, and the argument is a
|
||||
literal for the same reason [embed]'s path is one — there is nothing at
|
||||
this point in a compile to compute a string from. *)
|
||||
| "compile-error" ->
|
||||
arity loc name 1 args;
|
||||
(match (List.hd args).Ast.e with
|
||||
| Ast.Str s -> fail loc "%s" s
|
||||
| _ ->
|
||||
fail (List.hd args).Ast.loc
|
||||
"compile-error takes a literal string — it is reported while the \
|
||||
program is being checked, so there is nothing here to build one \
|
||||
from. A macro that has to refuse builds the sentence as it expands \
|
||||
and puts it in the form")
|
||||
| "embed-dir" ->
|
||||
arity loc name 1 args;
|
||||
let arg = List.hd args in
|
||||
@ -5780,6 +5815,10 @@ let builtins : (string * string * string) list =
|
||||
("embed-dir", "embed-dir [\"path\"] [n EmbedFile]",
|
||||
"Every file in the directory, read at compile time, as a fixed array of \
|
||||
EmbedFile. It does not descend.");
|
||||
("compile-error", "compile-error [\"message\"] ()",
|
||||
"Refuses the compile with that message, at the form it is written in. \
|
||||
What a macro expands to when it has to say why: a name nothing defines \
|
||||
carries a name, and this carries a sentence.");
|
||||
|
||||
(* files *)
|
||||
("slurp", "slurp [string Allocator?] (Vec u8)",
|
||||
|
||||
35
lib/load.ml
35
lib/load.ml
@ -238,6 +238,31 @@ let rec rename_expr owned alias bound (e : Ast.expr) : Ast.expr =
|
||||
in
|
||||
{ a with Ast.body = List.map (rename_expr owned alias bound)
|
||||
a.Ast.body }) arms)
|
||||
(* A quoted symbol naming something the package declares.
|
||||
[(Form.Sym {.s "Cursor"})] is what a quasiquote desugars to, and it is
|
||||
the one place a package's name survives into a *string* — which is
|
||||
exactly the property [Macro]'s walk depends on and exactly what puts the
|
||||
name out of an ordinary rename's reach.
|
||||
|
||||
[qualify_macro] handles that for a [defmacro] by renaming the body
|
||||
before the desugaring, over the text its author wrote. An ordinary
|
||||
[defn] never went through it, and until a package's macros could call
|
||||
the package's functions that was never visible: a helper that *builds*
|
||||
code was not a thing a package could have. It is now — the derivation a
|
||||
type provider does is far too large for one macro body — and a
|
||||
[(defn ... [c (Ptr Cursor)] ...)] emitted from one arrived at the
|
||||
importer naming a type the importer has never heard of.
|
||||
|
||||
Only a literal string, and only a name the package owns. A [.s] computed
|
||||
at run time is a name the macro made up (a generated struct, a field
|
||||
read out of the data file) and is nobody's to qualify; a literal naming
|
||||
[i64] or [let] is not the package's either. The case order matters: this
|
||||
has to be tried before the general [Struct] arm below, which would
|
||||
rewrite the constructor and walk past the field. *)
|
||||
| Ast.Struct (("Form.Sym" as n), [ ("s", ({ Ast.e = Ast.Str s; _ } as v)) ])
|
||||
when qualify_name owned alias bound s <> s ->
|
||||
Ast.Struct (name n,
|
||||
[ ("s", { v with Ast.e = Ast.Str (qualify_name owned alias bound s) }) ])
|
||||
| Ast.Struct (n, kvs) ->
|
||||
Ast.Struct (name n, List.map (fun (k, v) -> (k, go v)) kvs)
|
||||
| Ast.Arr items -> Ast.Arr (gos items)
|
||||
@ -965,12 +990,17 @@ let rec import ~seen ~open_ ~loc alias dir =
|
||||
let nested_macros =
|
||||
List.fold_left (fun acc r -> macro_union acc r.macros) [] nested
|
||||
in
|
||||
(* Beside the macros, what those macros may call — see the note over
|
||||
[Parse.imported_decls]. The same set the package's own files are checked
|
||||
against, so a macro of a package this one imports is compiled against
|
||||
exactly what its author could see. *)
|
||||
let nested_decls = List.concat_map (fun r -> r.decls) nested in
|
||||
(* The package's own files, parsed with what it imported in front of them
|
||||
and nothing else. A parent's macros are deliberately not here: this
|
||||
package did not import that parent, and a name it never asked for is not
|
||||
one it should be able to call. *)
|
||||
let ds =
|
||||
Parse.with_imported nested_macros
|
||||
Parse.with_imported ~decls:nested_decls nested_macros
|
||||
(fun () -> List.concat_map (fun (_, forms) -> Parse.program forms) sources)
|
||||
in
|
||||
(* [main] is the importer's, always. A package that called its own would
|
||||
@ -1319,7 +1349,8 @@ let program ?(parse = Parse.program) ~file (forms : Form.t list) : t =
|
||||
(imports_of forms)
|
||||
in
|
||||
let decls =
|
||||
Parse.with_imported (macro_union imported.macros !Parse.imported_macros)
|
||||
Parse.with_imported ~decls:(imported.decls @ !Parse.imported_decls)
|
||||
(macro_union imported.macros !Parse.imported_macros)
|
||||
(fun () -> parse forms)
|
||||
in
|
||||
let t =
|
||||
|
||||
165
lib/macro.ml
165
lib/macro.ml
@ -82,11 +82,23 @@ let self =
|
||||
| Some s when s <> "" -> s
|
||||
| _ -> Build.stamp_of Sys.executable_name)
|
||||
|
||||
let key (extra : Form.t list) =
|
||||
(* [support] is in the key for the same reason the prelude's text is: it is
|
||||
compiled into the module, so a package whose functions changed while its
|
||||
macros did not is a stale [.so] that the extras alone would not notice.
|
||||
[Marshal] and not a printer, because [Ast] has no printer and one written
|
||||
for a cache key would be a second rendering of the tree to keep in step with
|
||||
the first. The declarations are plain data — variants, strings, floats and
|
||||
locations, no closures and no abstract blocks — so the image is structural,
|
||||
and it moves when a location does. That direction is the safe one: a
|
||||
cosmetic edit above a package's functions costs a rebuild of the module, and
|
||||
nothing costs a stale one. *)
|
||||
let key ?(support = []) (extra : Form.t list) =
|
||||
Digest.to_hex
|
||||
(Digest.string
|
||||
(Lazy.force self ^ "\000" ^ Prelude.source ^ "\000"
|
||||
^ String.concat "\000" (List.map Form.to_string extra)))
|
||||
^ String.concat "\000" (List.map Form.to_string extra)
|
||||
^ "\000"
|
||||
^ (if support = [] then "" else Marshal.to_string support [])))
|
||||
|
||||
(* True while a macro module is being built. [Build.macro_module] goes through
|
||||
[Check.program], which parses the prelude, which calls back into
|
||||
@ -163,9 +175,99 @@ let reduce (forms : Form.t list) : Form.t list =
|
||||
| _ -> true)
|
||||
forms
|
||||
|
||||
(* ── What a package's macro may call ────────────────────────────────
|
||||
The header above says a macro body may call prelude functions and other
|
||||
macros and nothing else, and for a macro written in a *package* that was the
|
||||
machinery missing a piece rather than a rule. [Load.qualify_macro] renames
|
||||
the body so a call to the package's own [next] reads [edn/next] — it says
|
||||
the intent plainly — and the module was then compiled without anything of
|
||||
that name in it, so the call arrived at the checker as "the call edn/next
|
||||
into an imported package".
|
||||
|
||||
So [Parse.imported_decls] carries the package's declarations beside its
|
||||
macros, already qualified, and they go into the module. Trimmed to what the
|
||||
macros actually reach, for two reasons that are both about programs whose
|
||||
macros want none of this: raylib's five [with-*] are pure quasiquote, so
|
||||
nothing of raylib is reachable and the module is the one it always was — and
|
||||
raylib's declarations are [declare]s against a library this link has no
|
||||
argument for, so a module that took the whole package would fail to link
|
||||
for every program that draws anything.
|
||||
|
||||
Reachability over names and not over [Reach]'s checked program, because the
|
||||
trim has to happen *before* [Check]: an [Ast.Declare] that survived into the
|
||||
module would be emitted whether or not the checker was ever asked about it.
|
||||
A type is reached the same way a function is — [Load.uses] walks signatures
|
||||
and bodies alike — which is what keeps [edn/Cursor] in when [edn/next] is. *)
|
||||
|
||||
let support (roots : string list) (ds : Ast.decl list) : Ast.decl list =
|
||||
if ds = [] then []
|
||||
else begin
|
||||
let want = Hashtbl.create 64 in
|
||||
List.iter (fun n -> Hashtbl.replace want n ()) roots;
|
||||
(* Fixpoint over the declarations, since a kept one names more. Bounded by
|
||||
their number: the set only grows and a pass that adds nothing stops. *)
|
||||
let changed = ref true in
|
||||
while !changed do
|
||||
changed := false;
|
||||
List.iter
|
||||
(fun (d : Ast.decl) ->
|
||||
match Ast.declared_name d with
|
||||
| Some n when Hashtbl.mem want n ->
|
||||
List.iter
|
||||
(fun (u, _) ->
|
||||
if not (Hashtbl.mem want u) then begin
|
||||
Hashtbl.replace want u ();
|
||||
changed := true
|
||||
end)
|
||||
(Load.uses [ d ])
|
||||
| _ -> ())
|
||||
ds
|
||||
done;
|
||||
List.filter
|
||||
(fun (d : Ast.decl) ->
|
||||
match Ast.declared_name d with
|
||||
| Some n -> Hashtbl.mem want n
|
||||
| None -> false)
|
||||
ds
|
||||
end
|
||||
|
||||
(* The names a macro's text mentions, which is the root set above. Every symbol
|
||||
in the body, because a macro body reaches a package's names as calls, as
|
||||
types in a [let]'s initialiser and as data-type cases — and over-rooting only
|
||||
ever keeps a declaration that would have compiled anyway. *)
|
||||
let roots_of (extra : Form.t list) =
|
||||
List.fold_left (fun acc f -> Load.form_syms f acc) [] extra
|
||||
|
||||
let compile (names : string list) (extra : Form.t list) : loaded =
|
||||
(* The macros themselves are in [imported_decls] too — a [defmacro] is an
|
||||
[Ast.Defn] by the time [Parse] is finished with it, and [Load] qualifies
|
||||
and carries it like any other declaration. They arrive here a second time
|
||||
in [extra], which is where their *current* text is, so the copy in the
|
||||
support set is dropped rather than reaching the checker as a name defined
|
||||
twice. Current matters: a session that has just re-evaluated a macro holds
|
||||
the new body in [macros] and the old one in [decls]. *)
|
||||
(* Deduped by name as well as filtered, and the dedupe is not belt and
|
||||
braces. [Load.program] extends the ambient set rather than replacing it —
|
||||
a session has already set its own when it calls — so a package reached
|
||||
along two routes, or a file reloaded inside a session that already knows
|
||||
it, arrives twice. Two declarations of one name reach [Check.program] as a
|
||||
redefinition, refused with a sentence nobody would connect to this. It is
|
||||
the rule [Load.macro_union] already applies a level up, applied to the
|
||||
declarations that now travel with those macros. *)
|
||||
let support =
|
||||
let seen = Hashtbl.create 64 in
|
||||
List.filter
|
||||
(fun (d : Ast.decl) ->
|
||||
match Ast.declared_name d with
|
||||
| Some n when List.mem n names -> false
|
||||
| Some n -> if Hashtbl.mem seen n then false
|
||||
else (Hashtbl.add seen n (); true)
|
||||
| None -> true)
|
||||
(support (roots_of extra) !Parse.imported_decls)
|
||||
in
|
||||
let out =
|
||||
Filename.concat (Build.cachedir ()) ("flan-macros-" ^ key extra ^ ".so")
|
||||
Filename.concat (Build.cachedir ())
|
||||
("flan-macros-" ^ key ~support extra ^ ".so")
|
||||
in
|
||||
if not (Sys.file_exists out) then begin
|
||||
building := true;
|
||||
@ -177,8 +279,13 @@ let compile (names : string list) (extra : Form.t list) : loaded =
|
||||
(fun () ->
|
||||
(* [Check.program] prepends the prelude itself — reduced, for the one
|
||||
build that cannot have all of it — so only the file's own defmacros
|
||||
go in here. *)
|
||||
let p = Check.program (Parse.program extra) in
|
||||
and the package declarations they reach go in here.
|
||||
|
||||
The support comes first: it holds the types a macro's signature
|
||||
names, and a declaration order that mentioned [edn/Cursor] before
|
||||
declaring it would be refused for a reason that is this line's and
|
||||
not the author's. *)
|
||||
let p = Check.program (support @ Parse.program extra) in
|
||||
(* Written beside the final name and renamed, so a second process
|
||||
reading the cache never sees a half-written object. *)
|
||||
let tmp = out ^ "." ^ string_of_int (Unix.getpid ()) in
|
||||
@ -189,6 +296,48 @@ let compile (names : string list) (extra : Form.t list) : loaded =
|
||||
{ handle;
|
||||
fns = List.map (fun n -> (n, Dynload.dl_sym handle ("flan.macro." ^ n))) names }
|
||||
|
||||
(* ── Where the call site is ────────────────────────────────────────
|
||||
The one thing a macro cannot find out for itself and the one it needs to
|
||||
read a data file: a Form carries no location — deliberately, see
|
||||
[Expand.unmarshal] — so a macro handed [(defedn T "assets/x.edn")] knows the
|
||||
path and not what it is relative to. [(embed "assets/x.edn")] resolves
|
||||
against the directory of the source file the form is written in, and a macro
|
||||
reading a file has to resolve it the same way or a package's data would
|
||||
depend on where flan was invoked from.
|
||||
|
||||
So it is poked in before the call, into the two C symbols the module's own
|
||||
[flan_rt.c] declares for it. C data and not a Flan global because
|
||||
[Build.macro_module] emits with hidden visibility and only the
|
||||
[flan.macro.*] thunks stay exported — the same comment's other half is that
|
||||
the C goes on resolving the way it always did, which is what makes these two
|
||||
findable.
|
||||
|
||||
Set per call rather than once per module: one expansion walks the prelude's
|
||||
forms, the file's own and a package's, and a macro called from a package's
|
||||
source resolves against *that* file's directory. [Filename.dirname] is
|
||||
[embed_path]'s own move, and an empty answer — a bare filename with no
|
||||
directory in it — leaves the length at zero, which the runtime reads as "no
|
||||
better idea than the process's own directory". *)
|
||||
|
||||
let dir_of (l : loaded) (loc : Loc.t) =
|
||||
let dir = Filename.dirname loc.Loc.file in
|
||||
let dir = if String.equal dir "." then "" else dir in
|
||||
(* Not guarded. The symbol is in the module this just built, so its absence
|
||||
means [runtime/flan_rt.c] and this file have come apart — and the shape
|
||||
that failure would take if it were swallowed is a relative path resolving
|
||||
against the compiler's working directory, which reads some *other* file
|
||||
and says nothing. A missing symbol raises out of [dl_sym] instead. *)
|
||||
let buf = Dynload.dl_sym l.handle "flan_macro_dir" in
|
||||
let n = Dynload.dl_sym l.handle "flan_macro_dir_n" in
|
||||
(* 4096 is FLAN_PATH_MAX, and a path at or over it is left unset rather than
|
||||
truncated: half a directory is a path that resolves to the wrong file,
|
||||
where none at all resolves to none. *)
|
||||
if String.length dir > 0 && String.length dir < 4096 then begin
|
||||
Dynload.poke_bytes buf 0 dir;
|
||||
Dynload.poke_i64 n 0 (Int64.of_int (String.length dir))
|
||||
end
|
||||
else Dynload.poke_i64 n 0 0L
|
||||
|
||||
(* ── The walk ──────────────────────────────────────────────────────
|
||||
Bottom up: a macro's arguments are expanded before it is called, so nothing
|
||||
a macro is handed contains a call to another macro. Then what it answers is
|
||||
@ -212,6 +361,7 @@ let rec expand_form (l : loaded) (f : Form.t) : Form.t =
|
||||
every form it produced knows where it came from and an error on one of
|
||||
them can say so. *)
|
||||
let from = Loc.from_macro n loc in
|
||||
dir_of l loc;
|
||||
settle l n loc (Expand.call ~loc:from (List.assoc n l.fns) args) fuel
|
||||
| Form.List xs -> Form.make (Form.List (List.map (expand_form l) xs)) loc
|
||||
| Form.Vec xs -> Form.make (Form.Vec (List.map (expand_form l) xs)) loc
|
||||
@ -230,6 +380,7 @@ and settle l first loc (f : Form.t) left =
|
||||
else begin
|
||||
let args = List.map (expand_form l) args in
|
||||
let from = Loc.from_macro m loc in
|
||||
dir_of l loc;
|
||||
settle l first loc (Expand.call ~loc:from (List.assoc m l.fns) args)
|
||||
(left - 1)
|
||||
end
|
||||
@ -416,6 +567,10 @@ let expand_step (f : Form.t) : Form.t * string option =
|
||||
match f.Form.v with
|
||||
| Form.List ({ Form.v = Form.Sym n; _ } :: args)
|
||||
when List.mem_assoc n l.fns ->
|
||||
(* [C-c C-m] over a type provider reads the data file, which is the
|
||||
whole of what makes the live loop live: edit the .edn, expand
|
||||
again, see the struct that file now implies. *)
|
||||
dir_of l f.Form.loc;
|
||||
( Expand.call ~loc:(Loc.from_macro n f.Form.loc) (List.assoc n l.fns)
|
||||
args,
|
||||
Some n )
|
||||
|
||||
72
lib/parse.ml
72
lib/parse.ml
@ -1219,6 +1219,15 @@ let rec decl (f : Form.t) : Ast.decl =
|
||||
| _ ->
|
||||
fail f "defmacro is (defmacro name [param ...] body ...)")
|
||||
|
||||
(* Only reachable from the single-declaration entry point below: a file's
|
||||
forms go through [splice] first, and a [do] there is its items. Said by
|
||||
name because the two paths differ and the difference is not the author's
|
||||
fault to guess at. *)
|
||||
| List ({ v = Sym "do"; _ } :: _) ->
|
||||
fail f
|
||||
"a top-level (do ...) is several declarations spliced in place, and this \
|
||||
is a position that takes exactly one — a macro answering several is a \
|
||||
file's form, not an expression's"
|
||||
| List ({ v = Sym s; _ } :: _) -> fail f "unknown top-level form (%s ...)" s
|
||||
| _ -> fail f "expected a top-level declaration, found %s" (Form.to_string f)
|
||||
|
||||
@ -1255,10 +1264,40 @@ let expander : (Form.t list -> Form.t list) ref = ref (fun fs -> fs)
|
||||
whole of an evaluation. Empty is the ordinary case and costs nothing. *)
|
||||
let imported_macros : Form.t list ref = ref []
|
||||
|
||||
let with_imported (ms : Form.t list) (f : unit -> 'a) : 'a =
|
||||
(* What those macros are allowed to *call*, and it is the same list the
|
||||
importing program gets: the package's declarations, qualified under the
|
||||
alias, as [Load] already built them.
|
||||
|
||||
A macro module is compiled from the prelude plus the [defmacro]s, so until
|
||||
now the header's rule held without anything enforcing it — a macro body
|
||||
could call prelude functions and other macros and nothing else. A package
|
||||
macro that called one of its own package's functions was renamed to
|
||||
[alias/fn] by [Load.rename_form], reached the checker with nothing of that
|
||||
name declared, and was refused as a call into an imported package.
|
||||
|
||||
That refusal was the machinery missing a piece rather than a rule. The
|
||||
rename says the intent plainly: what a package's macro answers with, and
|
||||
what its body calls, is spelled the way the importer spells it. So the
|
||||
declarations travel beside the macros and go into the module with them.
|
||||
[Macro.compile] prunes them to what the macros actually reach, so a package
|
||||
whose macros are pure quasiquote — raylib's five [with-*] — pays nothing and
|
||||
links nothing new.
|
||||
|
||||
An [Ast.decl list] and not forms, because [Load] has already done the
|
||||
qualifying over the Ast and a second renamer over [Form] would be that work
|
||||
written twice, in the file where the two copies could disagree silently. *)
|
||||
let imported_decls : Ast.decl list ref = ref []
|
||||
|
||||
let with_imported ?(decls = []) (ms : Form.t list) (f : unit -> 'a) : 'a =
|
||||
let saved = !imported_macros in
|
||||
let saved_decls = !imported_decls in
|
||||
imported_macros := ms;
|
||||
Fun.protect ~finally:(fun () -> imported_macros := saved) f
|
||||
imported_decls := decls;
|
||||
Fun.protect
|
||||
~finally:(fun () ->
|
||||
imported_macros := saved;
|
||||
imported_decls := saved_decls)
|
||||
f
|
||||
|
||||
(* Two entry points and not one function with a flag, and the reason is the
|
||||
daemon. [Loc.Errors] is a second exception, and the handlers in the session
|
||||
@ -1273,12 +1312,41 @@ let with_imported (ms : Form.t list) (f : unit -> 'a) : 'a =
|
||||
here: the reader already found where each declaration ends, so skipping a
|
||||
bad one costs nothing and cannot lose its place. Inside a declaration there
|
||||
is no such landmark, so one bad [defn] is one error. *)
|
||||
(* ── One call, several declarations ────────────────────────────────
|
||||
Expansion is form-for-form: [Macro.expand_form] answers one [Form.t] per
|
||||
input and the loop below turns each into one [Ast.decl]. Every macro written
|
||||
until now expands to an *expression* — [unless], [into], raylib's [with-*] —
|
||||
so one-for-one was the whole of what was needed.
|
||||
|
||||
A type provider is the first thing that is not. [(defedn Tileset "t.edn")]
|
||||
has to produce the struct *and* the reader over it, and a nested map in the
|
||||
data means a struct per nesting level: three declarations and more from one
|
||||
form. There is no arrangement of one-for-one that reaches that.
|
||||
|
||||
So a [do] at the top level is its items, in place. It is the sequencing
|
||||
spelling the language already has, it is Clojure's answer to exactly this,
|
||||
and it is only ever reachable by a macro: nobody writes [(do (defn ...))] in
|
||||
a file, and the message below still says so for anyone who tries and wrote
|
||||
it wrong. Recursive, because a macro that splices what another macro
|
||||
answered has a [do] inside a [do] and the nesting is not the author's to
|
||||
flatten by hand.
|
||||
|
||||
It is spliced *after* expansion and before the declaration walk, so what is
|
||||
spliced is already fully expanded — a [do] holding a call to another macro
|
||||
settled before it got here. *)
|
||||
let rec splice (f : Form.t) : Form.t list =
|
||||
match f.Form.v with
|
||||
| Form.List ({ Form.v = Form.Sym "do"; _ } :: items) ->
|
||||
List.concat_map splice items
|
||||
| _ -> [ f ]
|
||||
|
||||
let parse_forms ~keep_going (forms : Form.t list) : Ast.decl list =
|
||||
(* Quasiquote first and always, because it is pure and needs nothing loaded:
|
||||
it is what turns a macro body into ordinary code, and the prelude's own
|
||||
macros have to parse in a process that has not built a macro module yet.
|
||||
Then expansion, which may need one. *)
|
||||
let forms = !expander (List.map Expand.quasiquote forms) in
|
||||
let forms = List.concat_map splice forms in
|
||||
temps := 0;
|
||||
let s = Loc.sink ~on:keep_going in
|
||||
let decls = List.filter_map (fun f -> Loc.caught s (fun () -> decl f)) forms in
|
||||
|
||||
@ -1794,6 +1794,41 @@ let source = {flan|
|
||||
(let [n (i64 0)]
|
||||
(if (= (file-stat-raw path (addr n)) 1) (Some n) None)))
|
||||
|
||||
;; ── Reading a file while a macro runs ─────────────────────────────────
|
||||
;;
|
||||
;; The one thing a macro needed that it could not write for itself. A macro is
|
||||
;; compiled and dlopened into the compiler, so `slurp` was always callable from
|
||||
;; one; what was missing is that a macro has no idea where its call site is,
|
||||
;; and so no way to resolve a path the way `(embed "assets/x.edn")` resolves
|
||||
;; one — relative to the directory of the source file the form is written in.
|
||||
;;
|
||||
;; This is that rule, and it is the *same* rule: the compiler pokes the call
|
||||
;; site's directory into the runtime before every expansion (runtime/flan_rt.c,
|
||||
;; "Reading a file while a macro runs", and lib/macro.ml's expand_form), and a
|
||||
;; relative path is joined to it. An absolute path is taken as written.
|
||||
;;
|
||||
;; **None rather than a condition**, which is the whole reason this is not
|
||||
;; `slurp`. A condition signalled inside an expansion is signalled *in the
|
||||
;; compiler*, through the macro module's own copy of the runtime, and that is
|
||||
;; the failure `Build.macro_module`'s hidden-visibility note measured: it takes
|
||||
;; the process down instead of parking it. Absence arriving as an answer is
|
||||
;; what lets a type provider say "there is no file at that path" as a refusal
|
||||
;; with a location, which is the sentence its author wanted anyway.
|
||||
;;
|
||||
;; **Outside a macro it is still a read**, with the path relative to the
|
||||
;; process rather than to any source file — nothing else knows better, and
|
||||
;; every program links this runtime. It is not a file API and `slurp` is; this
|
||||
;; exists so a macro can look at data at compile time.
|
||||
(declare macro-slurp-raw [path string out-len (Ptr i64)] (Ptr u8)
|
||||
"flan_macro_slurp")
|
||||
|
||||
(defn macro-slurp [path string] (Option [u8])
|
||||
(let [n (i64 0)
|
||||
p (macro-slurp-raw path (addr n))]
|
||||
(if (< n 0)
|
||||
None
|
||||
(Some (slice-from-ptr p (i32 n))))))
|
||||
|
||||
;; ── Form: what a macro takes and what it answers ──────────────────────
|
||||
;;
|
||||
;; The reader's output, mirrored on the Flan side, because a macro is a
|
||||
|
||||
@ -122,6 +122,32 @@ let create ?(debug = false) ?(x86 = false) ~file () =
|
||||
macros = Load.macro_union (own_macros forms) l.Load.macros;
|
||||
thunks = 0; debug; x86 }, l)
|
||||
|
||||
(* What a macro may call, for the same reason [macros] is held: an evaluation
|
||||
parses one form with no import in sight, and a package macro whose body
|
||||
calls its own package's functions has to find them. [Load.program] hands
|
||||
this to [Parse.with_imported] from the import it just read; a session has to
|
||||
answer it from what it already holds.
|
||||
|
||||
Filtered out of [decls] by ownership rather than kept as a second list,
|
||||
because [decls] is the one thing every redefinition already maintains and a
|
||||
parallel copy would be a second thing to remember to update. A package's
|
||||
names are qualified in there — that is what "post-Load: flat, one namespace"
|
||||
means — so the prefix is the whole test.
|
||||
|
||||
The buffer's own declarations are deliberately not here. A macro module is
|
||||
built from the prelude with no part of the file in it (see [Macro.reduce]'s
|
||||
header, and the cycle it is about), and a session's [decls] is the file. *)
|
||||
let package_decls t =
|
||||
List.filter
|
||||
(fun (d : Ast.decl) ->
|
||||
match Ast.declared_name d with
|
||||
| Some n ->
|
||||
List.exists
|
||||
(fun (p : Load.pkg) -> String.starts_with ~prefix:(p.Load.alias ^ "/") n)
|
||||
t.pkgs
|
||||
| None -> false)
|
||||
t.decls
|
||||
|
||||
(* Which package a file being edited belongs to, if any.
|
||||
|
||||
A form typed into vendor/agent/agent.flan declares [poll], but the running
|
||||
@ -480,7 +506,7 @@ let restore t h =
|
||||
|
||||
let eval ?(origin = "<eval>") ?pause t src : change =
|
||||
let forms = Reader.read_all ~file:origin src in
|
||||
Parse.with_imported t.macros @@ fun () ->
|
||||
Parse.with_imported ~decls:(package_decls t) t.macros @@ fun () ->
|
||||
(* Through [Load] like any other source, so an evaluated (import ...) means
|
||||
what it means in a file. Its expansion is what gets spliced, which is also
|
||||
why the accumulated list is the post-Load one: re-evaluating a file that
|
||||
@ -1296,7 +1322,7 @@ let eval_expr ?(origin = "<eval>") ?(pause = false) t src : change =
|
||||
untouched: a cold macro module costs its ~300ms before that clock starts,
|
||||
and the non-termination refusals raise [Loc.Error] out of this call, which
|
||||
the daemon already answers as an error rather than a silence. *)
|
||||
let parsed = Parse.with_imported t.macros (fun () -> Parse.expr form) in
|
||||
let parsed = Parse.with_imported ~decls:(package_decls t) t.macros (fun () -> Parse.expr form) in
|
||||
(* Wrapped before the checker, so the call is checked like any other and a
|
||||
prelude that stopped offering [pause] would be an ordinary unknown name
|
||||
rather than a thunk that silently did not stop. The [Do] takes the
|
||||
@ -1440,7 +1466,7 @@ let macroexpand ?(origin = "<eval>") ~(all : bool) t (src : string) : expansion
|
||||
let before = Expand.quasiquote form in
|
||||
(* And the session's macros in front of it, as [eval] and [eval_expr] both
|
||||
put them: [Macro.program] reads [Parse.imported_macros] directly. *)
|
||||
Parse.with_imported t.macros @@ fun () ->
|
||||
Parse.with_imported ~decls:(package_decls t) t.macros @@ fun () ->
|
||||
let after, name =
|
||||
if all then Macro.expand_all before else Macro.expand_step before
|
||||
in
|
||||
|
||||
@ -2975,6 +2975,88 @@ const uint8_t *flan_getenv(const uint8_t *name, int64_t n, int64_t *len) {
|
||||
return (const uint8_t *)v;
|
||||
}
|
||||
|
||||
/* ── Reading a file while a macro runs ─────────────────────────────────
|
||||
*
|
||||
* A macro is compiled and dlopened into the compiler, so it is ordinary native
|
||||
* code and could always have called `slurp`. What it could not do is resolve a
|
||||
* path the way the rest of the language resolves one. (embed "assets/x.edn")
|
||||
* is relative to the directory of the *source file the form is written in* —
|
||||
* lib/check.ml's embed_path, and Odin's rule before it — because anything else
|
||||
* makes a package's assets depend on where flan happened to be invoked from.
|
||||
* A macro has no idea where its call site is: a Form carries no location, on
|
||||
* purpose (see the prelude's Form, and Expand.unmarshal).
|
||||
*
|
||||
* So the compiler tells it, here. lib/macro.ml dlsym's the two symbols below
|
||||
* and pokes the call site's directory into them before every expansion; a
|
||||
* macro-time read joins that to a relative path and opens the result. The
|
||||
* channel is C data and not a Flan global because Build.macro_module emits the
|
||||
* module with hidden visibility — only the flan.macro.* thunks stay exported —
|
||||
* and "the C goes on resolving the way it always did" is the other half of
|
||||
* that same comment.
|
||||
*
|
||||
* It is empty in a process that is not expanding anything, which is every
|
||||
* process but the compiler: the module links this file, so a *program* holding
|
||||
* these symbols simply has a relative path mean what it means to the shell.
|
||||
*
|
||||
* `slurp` is deliberately not what the prelude wraps around this. slurp
|
||||
* signals a FileError, and a condition raised inside an expansion is raised in
|
||||
* the compiler, through the macro module's own copy of the runtime — which is
|
||||
* the failure Build.macro_module's ~hidden comment measured. Absence answers
|
||||
* here as a length of -1, on getenv's pattern, so a data file that is not
|
||||
* there becomes something the macro can refuse *about* rather than a trap. */
|
||||
|
||||
char flan_macro_dir[FLAN_PATH_MAX] = { 0 };
|
||||
int64_t flan_macro_dir_n = 0;
|
||||
|
||||
/* The bytes are the caller's to read and nobody's to free: an expansion is
|
||||
* bounded by the size of the program being compiled, which is exactly the
|
||||
* budget lib/dynload.ml's `owned` note already spends on a macro's own
|
||||
* allocations. Leaking is the same decision as there, for the same reason —
|
||||
* the returned slice is read after the call returns, and there is no `drop`. */
|
||||
const uint8_t *flan_macro_slurp(const uint8_t *path, int64_t n, int64_t *len) {
|
||||
static const char empty[1] = { 0 };
|
||||
char rel[FLAN_PATH_MAX];
|
||||
char full[FLAN_PATH_MAX];
|
||||
FILE *f;
|
||||
long size;
|
||||
uint8_t *buf;
|
||||
size_t got;
|
||||
*len = -1;
|
||||
if (!flan_path_cstr(path, n, rel)) return (const uint8_t *)empty;
|
||||
/* An absolute path is taken as written, and a relative one is joined to the
|
||||
* call site's directory — embed_path's two cases, in the same order. A dir
|
||||
* that was never poked leaves a relative path relative to the process, which
|
||||
* is the only thing it can mean when nothing knows better. */
|
||||
if (rel[0] == '/' || flan_macro_dir_n <= 0) {
|
||||
memcpy(full, rel, (size_t)n + 1);
|
||||
} else {
|
||||
if (flan_macro_dir_n + 1 + n >= FLAN_PATH_MAX) return (const uint8_t *)empty;
|
||||
memcpy(full, flan_macro_dir, (size_t)flan_macro_dir_n);
|
||||
full[flan_macro_dir_n] = '/';
|
||||
memcpy(full + flan_macro_dir_n + 1, rel, (size_t)n + 1);
|
||||
}
|
||||
f = fopen(full, "rb");
|
||||
if (!f) return (const uint8_t *)empty;
|
||||
/* A directory opens on Linux and fails at the read, which is the trap
|
||||
* check.ml's read_embed_file records: guarding only the open turns
|
||||
* (macro-slurp "somedir") into a crash rather than an answer. Both ends are
|
||||
* guarded here and both answer absent. */
|
||||
if (fseek(f, 0, SEEK_END) != 0) { fclose(f); return (const uint8_t *)empty; }
|
||||
size = ftell(f);
|
||||
if (size < 0 || fseek(f, 0, SEEK_SET) != 0) {
|
||||
fclose(f);
|
||||
return (const uint8_t *)empty;
|
||||
}
|
||||
buf = (uint8_t *)malloc((size_t)size + 1);
|
||||
if (!buf) { fclose(f); return (const uint8_t *)empty; }
|
||||
got = fread(buf, 1, (size_t)size, f);
|
||||
fclose(f);
|
||||
if (got != (size_t)size) { free(buf); return (const uint8_t *)empty; }
|
||||
buf[size] = 0;
|
||||
*len = (int64_t)size;
|
||||
return buf;
|
||||
}
|
||||
|
||||
/* ── The rest of the file surface ──────────────────────────────────────
|
||||
*
|
||||
* Four more POSIX-shaped calls under the same rules as flan_file_size,
|
||||
|
||||
10
test/dune
10
test/dune
@ -65,6 +65,9 @@
|
||||
; fourth file beside those three is a type error in an unrelated program.
|
||||
; embed-dir does not descend and neither does a glob, so this is its own line.
|
||||
(glob_files programs/assets/edn/*)
|
||||
; And the config programs/json-provide.flan derives a struct from, under
|
||||
; assets/ for the same reason and needing its own line for the same one.
|
||||
(glob_files programs/assets/json/*)
|
||||
; 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
|
||||
@ -103,6 +106,7 @@
|
||||
(glob_files programs/*.flan)
|
||||
(glob_files programs/assets/*)
|
||||
(glob_files programs/assets/edn/*)
|
||||
(glob_files programs/assets/json/*)
|
||||
; 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/*)
|
||||
@ -149,7 +153,8 @@
|
||||
; a Flan program: flan_dyn.c has no Flan spelling yet. It is also the one
|
||||
; translation unit here that frees anything, which is what makes it worth a
|
||||
; sanitized run at all. See [dyn_sweep].
|
||||
(file dyn_ops.c))
|
||||
(file dyn_ops.c)
|
||||
(glob_files programs/assets/json/*))
|
||||
(action (run ./test_sanitize.exe)))
|
||||
|
||||
; The corpus a third time, under Valgrind's memcheck. Its own alias for the
|
||||
@ -186,6 +191,7 @@
|
||||
(glob_files programs/*.flan)
|
||||
(glob_files programs/assets/*)
|
||||
(glob_files programs/assets/edn/*)
|
||||
(glob_files programs/assets/json/*)
|
||||
; The package tree the multi-level cases import, as in the test stanza
|
||||
; above: a glob per directory, because dune's glob does not descend.
|
||||
(glob_files programs/pkgs/shape/*)
|
||||
@ -244,6 +250,7 @@
|
||||
(glob_files programs/*.flan)
|
||||
(glob_files programs/assets/*)
|
||||
(glob_files programs/assets/edn/*)
|
||||
(glob_files programs/assets/json/*)
|
||||
; A glob per package directory, because dune's glob does not descend.
|
||||
(glob_files programs/pkgs/shape/*)
|
||||
(glob_files programs/pkgs/area/*)
|
||||
@ -407,6 +414,7 @@
|
||||
(glob_files programs/*.flan)
|
||||
(glob_files programs/assets/*)
|
||||
(glob_files programs/assets/edn/*)
|
||||
(glob_files programs/assets/json/*)
|
||||
(glob_files programs/pkgs/shape/*)
|
||||
(glob_files programs/pkgs/area/*)
|
||||
(glob_files programs/pkgs/draw/*)
|
||||
|
||||
11
test/programs/assets/edn/tuning.edn
Normal file
11
test/programs/assets/edn/tuning.edn
Normal file
@ -0,0 +1,11 @@
|
||||
;; A tuning file with one of each shape defedn derives, so that the struct it
|
||||
;; produces exercises every arm: a string, an integer, a float, a boolean, a
|
||||
;; homogeneous vector, a nested map, and a nested map inside that one.
|
||||
{:name "goblin"
|
||||
:hp 12
|
||||
:speed 1.5
|
||||
:boss? false
|
||||
:drops [3 1 4 1 5]
|
||||
:hitbox {:w 16
|
||||
:h 24
|
||||
:offset {:x -2 :y 0}}}
|
||||
12
test/programs/assets/json/config.json
Normal file
12
test/programs/assets/json/config.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "tileset\nrunner",
|
||||
"port": 8080,
|
||||
"scale": 1.5,
|
||||
"debug": true,
|
||||
"layers": [3, 1, 4, 1, 5],
|
||||
"window": {
|
||||
"w": 1280,
|
||||
"h": 720,
|
||||
"origin": { "x": -2, "y": 0 }
|
||||
}
|
||||
}
|
||||
120
test/programs/edn-provide.flan
Normal file
120
test/programs/edn-provide.flan
Normal file
@ -0,0 +1,120 @@
|
||||
;;;; defedn over two real files, and the condition a drifted one signals.
|
||||
;;;;
|
||||
;;;; The first half is the claim the feature is for: `(.texture-path t)` here
|
||||
;;;; is a field load off a struct nobody declared, and the same numbers
|
||||
;;;; edn-read.flan prints through the dynamic reader come out of it. Two
|
||||
;;;; readers over one file agreeing is what says the derived one is right —
|
||||
;;;; either alone could be self-consistently wrong.
|
||||
;;;;
|
||||
;;;; The second half is the shape matrix: a string, an integer, a float, a
|
||||
;;;; boolean, a vector, and a map inside a map, each read back.
|
||||
;;;;
|
||||
;;;; The third is drift. The struct was derived from the file as it was when
|
||||
;;;; this was compiled; the bytes read at run time are a later version of it,
|
||||
;;;; with one key gone and one arrived. Both are named by SchemaDrift, which is
|
||||
;;;; the whole reason the reader carries one: a missing key otherwise leaves a
|
||||
;;;; field at zero and the program draws nothing for a reason nothing reports.
|
||||
|
||||
(import edn "vendor:edn")
|
||||
|
||||
;; Both derived at compile time, from the files as they sit beside this one.
|
||||
;; The path is the (embed "...") path — relative to this file — and not the
|
||||
;; path the run-time reads below use, which is relative to the process.
|
||||
(edn/defedn Tileset "assets/edn/tileset.edn")
|
||||
(edn/defedn Tuning "assets/edn/tuning.edn")
|
||||
|
||||
;; The same file the dynamic reader in edn-read.flan walks. Embedded rather
|
||||
;; than read, so this half asserts the reader and not the filesystem.
|
||||
(defconst tileset (embed "assets/edn/tileset.edn"))
|
||||
(defconst tuning (embed "assets/edn/tuning.edn"))
|
||||
|
||||
(defn show-tileset [a Allocator] ()
|
||||
(let [t (Tileset-of-bytes tileset a)]
|
||||
;; The line edn-read.flan prints first, off a struct field this time.
|
||||
(println (.texture-path t))
|
||||
;; 54 pairs, and the same three memberships and one miss. A derivation
|
||||
;; that flattened the pairs into 108 integers would have a different count
|
||||
;; and would answer no to every one of these.
|
||||
(println (len (.selected-cells t)))
|
||||
(println (has-key? (.selected-cells t) [3 4]))
|
||||
(println (has-key? (.selected-cells t) [0 0]))
|
||||
(println (has-key? (.selected-cells t) [4 11]))
|
||||
(println (has-key? (.selected-cells t) [9 9]))))
|
||||
|
||||
(defn show-tuning [a Allocator] ()
|
||||
(let [t (Tuning-of-bytes tuning a)]
|
||||
(println (.name t))
|
||||
(println (.hp t))
|
||||
(println (.speed t))
|
||||
(println (if (.boss? t) "yes" "no"))
|
||||
(println (len (.drops t)))
|
||||
;; 3 + 1 + 4 + 1 + 5. A vector read that stopped at the first element would
|
||||
;; still have a plausible length from a zeroed Vec, so the sum is the claim.
|
||||
(let [total (i64 0)]
|
||||
(dotimes [i (len (.drops t))]
|
||||
(set total (+ total (at (.drops t) i))))
|
||||
(println total))
|
||||
;; The nested structs, by the names the paths give them: Tuning-hitbox and
|
||||
;; Tuning-hitbox-offset. Both are ordinary field loads, two deep.
|
||||
(println (.w (.hitbox t)))
|
||||
(println (.h (.hitbox t)))
|
||||
(println (.x (.offset (.hitbox t))))
|
||||
(println (.y (.offset (.hitbox t))))))
|
||||
|
||||
;; ── Drift ───────────────────────────────────────────────────────────
|
||||
;;
|
||||
;; The struct says :name :hp :speed :boss? :drops :hitbox. These bytes have no
|
||||
;; :speed and have a :level the struct has never heard of, which is what a
|
||||
;; tuning file looks like a month after the program was built.
|
||||
(defconst drifted string
|
||||
"{:name \"imp\" :hp 3 :level 7 :boss? true :drops [1] :hitbox {:w 1 :h 1 :offset {:x 0 :y 0}}}")
|
||||
|
||||
(defn show-drift [a Allocator] ()
|
||||
(handler-bind
|
||||
[(edn/SchemaDrift [d]
|
||||
(do (print (if (.extra? d) "extra " "missing "))
|
||||
(print (.field d))
|
||||
(print " in ")
|
||||
(println (.struct d))))]
|
||||
(let [t (Tuning-of-bytes (bytes drifted) a)]
|
||||
;; The fields that were there are read, which is the other half of the
|
||||
;; contract: a drifted file is reported, not refused. :speed is the one
|
||||
;; that was missing and is zero.
|
||||
(println (.name t))
|
||||
(println (.hp t))
|
||||
(println (.speed t)))))
|
||||
|
||||
;; ── A file that does not parse ──────────────────────────────────────
|
||||
;;
|
||||
;; The louder failure, and the one that had the quieter answer until ReadFailed
|
||||
;; existed: a generated reader accumulates errors on the cursor, and the cursor
|
||||
;; is made and dropped inside the entry point, so a stray brace gave back a
|
||||
;; zeroed struct with nothing said. The rest of this package refuses to do
|
||||
;; that — read-file answers an Option so that a malformed document is
|
||||
;; distinguishable from one that is literally nil — and a derived reader has to
|
||||
;; be at least as honest.
|
||||
(defconst broken string "{:name \"orc\" :hp }")
|
||||
|
||||
(defn show-broken [a Allocator] ()
|
||||
(handler-bind
|
||||
[(edn/ReadFailed [e]
|
||||
(do (print "unreadable ")
|
||||
(print (.struct e))
|
||||
(print ": ")
|
||||
(println (edn/error-message (.code e)))))]
|
||||
(let [t (Tuning-of-bytes (bytes broken) a)]
|
||||
;; Read anyway, and zeroed, which is the half a handler that carries on
|
||||
;; is choosing. Printed so that "it signalled" and "it gave back nothing
|
||||
;; usable" are two claims rather than one.
|
||||
(println (.hp t)))))
|
||||
|
||||
(defn main [] i32
|
||||
(let [a (heap-allocator)]
|
||||
(show-tileset a)
|
||||
(println "")
|
||||
(show-tuning a)
|
||||
(println "")
|
||||
(show-drift a)
|
||||
(println "")
|
||||
(show-broken a))
|
||||
0)
|
||||
53
test/programs/json-provide.flan
Normal file
53
test/programs/json-provide.flan
Normal file
@ -0,0 +1,53 @@
|
||||
;;;; defjson over a config file, and the escape that says it is not defedn.
|
||||
;;;;
|
||||
;;;; The same idea as edn-provide.flan and a different package: json depends on
|
||||
;;;; edn for nothing and this program imports only json.
|
||||
;;;;
|
||||
;;;; "name" is the line that matters. Its value in the file is written
|
||||
;;;; "tileset\nrunner", and JSON's `.text` is the RAW interior — escapes
|
||||
;;;; undecoded — so a reader built on `.text` prints one line with a backslash
|
||||
;;;; and an n in it. This prints two lines, which is `string-of` having been
|
||||
;;;; used where a field is filled.
|
||||
|
||||
(import json "vendor:json")
|
||||
|
||||
(json/defjson Config "assets/json/config.json")
|
||||
|
||||
(defconst config (embed "assets/json/config.json"))
|
||||
|
||||
(defn main [] i32
|
||||
(let [a (heap-allocator)
|
||||
cfg (Config-of-bytes config a)]
|
||||
;; Two lines, not one with a backslash in it.
|
||||
(println (.name cfg))
|
||||
(println (.port cfg))
|
||||
(println (.scale cfg))
|
||||
(println (if (.debug cfg) "yes" "no"))
|
||||
(println (len (.layers cfg)))
|
||||
;; 3 + 1 + 4 + 1 + 5. A length alone would pass on a Vec that was allocated
|
||||
;; and never filled, so the sum is the claim.
|
||||
(let [total (i64 0)]
|
||||
(dotimes [i (len (.layers cfg))]
|
||||
(set total (+ total (at (.layers cfg) i))))
|
||||
(println total))
|
||||
;; The nested structs, by the names the paths give them: Config-window and
|
||||
;; Config-window-origin. Ordinary field loads, two deep.
|
||||
(println (.w (.window cfg)))
|
||||
(println (.h (.window cfg)))
|
||||
(println (.x (.origin (.window cfg))))
|
||||
(println (.y (.origin (.window cfg))))
|
||||
(println "")
|
||||
;; Drift, the same contract defedn's reader carries: the struct says "port"
|
||||
;; and these bytes do not, and they carry a "host" it has never heard of.
|
||||
(handler-bind
|
||||
[(json/SchemaDrift [d]
|
||||
(do (print (if (.extra? d) "extra " "missing "))
|
||||
(print (.field d))
|
||||
(print " in ")
|
||||
(println (.struct d))))]
|
||||
(let [c2 (Config-of-bytes
|
||||
(bytes "{\"name\":\"x\",\"host\":\"h\",\"scale\":2.0,\"debug\":false,\"layers\":[1],\"window\":{\"w\":1,\"h\":1,\"origin\":{\"x\":0,\"y\":0}}}")
|
||||
a)]
|
||||
(println (.name c2))
|
||||
(println (.port c2)))))
|
||||
0)
|
||||
@ -643,6 +643,184 @@ let () =
|
||||
outputs ~opt:"-O0" "edn/read over the tileset, -O0"
|
||||
"programs/edn-read.flan" edn_read_out;
|
||||
|
||||
(* The same file again, through a struct derived from it at compile time.
|
||||
The first five lines are the first five above, character for character,
|
||||
and that is the claim: two readers over one file agreeing is what says
|
||||
the derived one is right, where either alone could be self-consistently
|
||||
wrong. Nothing in the program declares a type and nothing in it matches
|
||||
on a tag — (.texture-path t) is a field load.
|
||||
|
||||
The pair memberships are the derivation's own decision showing: the set
|
||||
became a (Map [2 i64] bool), so [3 4] is a key and [9 9] is not. A
|
||||
version that made it a (Vec i64) of 108 numbers would have compiled and
|
||||
would answer differently on every one of these four lines.
|
||||
|
||||
Then the shape matrix — a string, an integer, a float, a boolean, a
|
||||
vector summed, and a map inside a map read two field loads deep, by the
|
||||
names the paths give them. 14 is 3+1+4+1+5, and it is there because a
|
||||
length alone would pass on a Vec that was allocated and never filled.
|
||||
|
||||
Last, drift: the struct was derived from a file with :speed and without
|
||||
:level, and the bytes read carry the opposite. Both conditions are
|
||||
named, in the order the reader meets them — the unknown key as it
|
||||
arrives, the missing field when the map closes — and the read carries
|
||||
on, which is the other half of the contract. :speed reads 0.
|
||||
|
||||
Then the louder failure, which had the quieter answer until ReadFailed
|
||||
existed: a generated reader accumulates errors on the cursor, and the
|
||||
cursor is made and dropped inside the entry point, so a file that does
|
||||
not parse handed back a zeroed struct with nothing said. The rest of the
|
||||
package refuses to do that — read-file answers an Option so a malformed
|
||||
document is distinguishable from one that is literally nil — and this is
|
||||
the derived reader being as honest. Two lines and not one: it signalled,
|
||||
and what it gave back is nothing usable. *)
|
||||
let edn_provide_out =
|
||||
"./source-assets/Sprout Lands Premium/Objects/Mushrooms, Flowers, \
|
||||
Stones.png\n\
|
||||
54\ntrue\ntrue\ntrue\nfalse\n\n\
|
||||
goblin\n12\n1.5\nno\n5\n14\n16\n24\n-2\n0\n\n\
|
||||
extra level in Tuning\nmissing speed in Tuning\nimp\n3\n0\n\n\
|
||||
unreadable Tuning: unexpected token: not the kind the caller was \
|
||||
reading\n0\n"
|
||||
in
|
||||
outputs "defedn over the tileset and a tuning file"
|
||||
"programs/edn-provide.flan" edn_provide_out;
|
||||
outputs ~opt:"-O0" "defedn over the tileset and a tuning file, -O0"
|
||||
"programs/edn-provide.flan" edn_provide_out;
|
||||
|
||||
(* The same idea in the other package, over a config file. json depends on
|
||||
edn for nothing and this program imports only json: what the two share
|
||||
is the design, not a line of code.
|
||||
|
||||
The first two lines are the claim that separates them. "name" is written
|
||||
"tileset\nrunner" in the file, and json.flan's .text is the RAW interior
|
||||
with escapes undecoded — so a reader built on it prints one line with a
|
||||
backslash and an n in it. Two lines is string-of having been used where
|
||||
a field is filled, which is the one call in that package that allocates
|
||||
and the one that is correct.
|
||||
|
||||
Then the shape matrix — a number that is an integer because the file
|
||||
wrote it as one, a number that is a float because the file wrote a
|
||||
point, a boolean, an array summed rather than counted, and an object
|
||||
inside an object read two field loads deep — and drift, where the bytes
|
||||
have a "host" the struct never heard of and no "port" it expects. *)
|
||||
let json_provide_out =
|
||||
"tileset\nrunner\n8080\n1.5\nyes\n5\n14\n1280\n720\n-2\n0\n\n\
|
||||
extra host in Config\nmissing port in Config\nx\n0\n"
|
||||
in
|
||||
outputs "defjson over a config file" "programs/json-provide.flan"
|
||||
json_provide_out;
|
||||
outputs ~opt:"-O0" "defjson over a config file, -O0"
|
||||
"programs/json-provide.flan" json_provide_out;
|
||||
|
||||
(* What a provider refuses, and where it says the trouble is.
|
||||
A data file the compiler could not make sense of is one the program
|
||||
would have read wrongly, so each of these is a compile that stops rather
|
||||
than a struct with a field of some guessed type.
|
||||
|
||||
Both halves of the pair are written here rather than committed, because
|
||||
the data file *is* the test: a fixture .edn sitting in the corpus would
|
||||
be read by nothing else and would look like an asset. They go beside the
|
||||
other programs so that the two things a provider resolves — the vendor:
|
||||
collection, and the data path relative to the source file — resolve the
|
||||
way they do for a real one.
|
||||
|
||||
Each is asserted on the position it names and not only on the fact of
|
||||
failing. A version that refused everything with one sentence would pass
|
||||
a test that checked the refusal alone, and a line and column into a file
|
||||
the compiler is not reading is the whole of what the refusal had to be
|
||||
given a facility for. *)
|
||||
let refusal ~pkg ~mac ~ext name data needle =
|
||||
let base = "programs/refuse-" ^ name in
|
||||
let datap = base ^ "." ^ ext and flanp = base ^ ".flan" in
|
||||
Out_channel.with_open_bin datap (fun ch -> Out_channel.output_string ch data);
|
||||
Out_channel.with_open_bin flanp (fun ch ->
|
||||
Out_channel.output_string ch
|
||||
(Printf.sprintf
|
||||
"(import p \"vendor:%s\")\n(p/%s T \"refuse-%s.%s\")\n\
|
||||
(defn main [] i32 0)\n" pkg mac name ext));
|
||||
(match
|
||||
let l = Load.program ~file:flanp (Reader.read_file flanp) in
|
||||
Check.program l.Load.decls
|
||||
with
|
||||
| _ ->
|
||||
incr failures;
|
||||
Printf.printf "FAIL %s\n it was accepted\n" name
|
||||
| exception Loc.Error { Loc.dmsg = m; _ } ->
|
||||
if not (contains m needle) then begin
|
||||
incr failures;
|
||||
Printf.printf "FAIL %s\n said: %S\n wanted: %S in it\n"
|
||||
name m needle
|
||||
end);
|
||||
List.iter (fun f -> try Sys.remove f with Sys_error _ -> ()) [ datap; flanp ]
|
||||
in
|
||||
let provider_refusal = refusal ~pkg:"edn" ~mac:"defedn" ~ext:"edn" in
|
||||
let json_refusal = refusal ~pkg:"json" ~mac:"defjson" ~ext:"json" in
|
||||
(* The element positions are named, both of them, because "heterogeneous"
|
||||
on its own sends someone to read the whole file. *)
|
||||
provider_refusal "mixed-vector" "{:xs [1 2 \"three\"]}" "element 2 is string";
|
||||
(* A map whose keys are not all keywords is not a struct: a field is named,
|
||||
and "name" in quotes is a value. *)
|
||||
provider_refusal "mixed-keys" "{:a 1 \"b\" 2}" "that is not a keyword";
|
||||
(* An empty collection carries no element to derive an element type from,
|
||||
which is the one thing a shape read out of data cannot guess. *)
|
||||
provider_refusal "empty-vector" "{:xs []}"
|
||||
"has no element to derive an element type from";
|
||||
(* nil has no type. A field that is sometimes absent is not something a
|
||||
struct holds, and guessing would put a zero where a decision belongs. *)
|
||||
provider_refusal "nil-value" "{:a nil}" "has no type to derive";
|
||||
(* The line and column are into the *data* file and are the point of the
|
||||
whole error facility: this one is on the third line. *)
|
||||
provider_refusal "position" "{:a 1\n :b 2\n :c [1 \"x\"]}" "line 3 column";
|
||||
(* A set becomes a (Map T bool), so its elements are map keys. A set of
|
||||
maps is refused by name here rather than at the (Map ...) it would build,
|
||||
whose message names a type nobody wrote. *)
|
||||
provider_refusal "set-of-maps" "{:s #{{:a 1}}}"
|
||||
"is not something this builds";
|
||||
(* And the file that is not there, which is the case the path rule is for:
|
||||
it says where it looked. *)
|
||||
(let flanp = "programs/refuse-missing.flan" in
|
||||
Out_channel.with_open_bin flanp (fun ch ->
|
||||
Out_channel.output_string ch
|
||||
"(import edn \"vendor:edn\")\n\
|
||||
(edn/defedn T \"no-such-file.edn\")\n(defn main [] i32 0)\n");
|
||||
(match
|
||||
let l = Load.program ~file:flanp (Reader.read_file flanp) in
|
||||
Check.program l.Load.decls
|
||||
with
|
||||
| _ ->
|
||||
incr failures;
|
||||
Printf.printf "FAIL a defedn over a file that is not there\n\
|
||||
\ it was accepted\n"
|
||||
| exception Loc.Error { Loc.dmsg = m; _ } ->
|
||||
if not (contains m "there is no file at no-such-file.edn") then begin
|
||||
incr failures;
|
||||
Printf.printf "FAIL a defedn over a file that is not there\n\
|
||||
\ said: %S\n" m
|
||||
end);
|
||||
(try Sys.remove flanp with Sys_error _ -> ()));
|
||||
|
||||
(* And what defjson refuses, which is the same walk over a different
|
||||
grammar. The first three are the ones it shares; the last two are its
|
||||
own, and both are about a member name.
|
||||
|
||||
A struct's fields are names, and JSON's are arbitrary strings — so
|
||||
"a b" has no field it could become, and an escaped one is refused
|
||||
because the generated reader compares against the bytes as written. The
|
||||
comparison costs no allocation per key, which is the whole reason it is
|
||||
written that way, and it is only the same question as "is this the
|
||||
field" when the name has no escape in it. Refusing is what keeps those
|
||||
two facts from quietly disagreeing. *)
|
||||
json_refusal "json-mixed-array" "{\"xs\": [1, 2, \"three\"]}"
|
||||
"element 2 is string";
|
||||
json_refusal "json-null" "{\"a\": null}" "has no type to derive";
|
||||
json_refusal "json-empty-array" "{\"xs\": []}"
|
||||
"has no element to derive an element type from";
|
||||
json_refusal "json-spaced-key" "{\"a b\": 1}"
|
||||
"is not a name a program could write";
|
||||
json_refusal "json-escaped-key" "{\"a\\nb\": 1}"
|
||||
"has an escape in it";
|
||||
|
||||
(* And the branch that makes it safe, which needs a program that dies to
|
||||
say anything — the shape bounds.flan uses, and for the same reason.
|
||||
Run 0 is the control and must not trap: a (Vec (Vec i32)) in the region
|
||||
@ -2999,10 +3177,11 @@ level "1"
|
||||
outputs ~opt:"-O0" "dyn: an unannotated defn at two types, -O0"
|
||||
"programs/dyn-basic.flan" dyn_basic_out;
|
||||
(* The rendering is the typed printer's: a leading space after the open
|
||||
bracket and strings quoted inside a collection but bare alone —
|
||||
(println [\"a\"]) and (println \"a\") already disagree exactly this way,
|
||||
so the dyn printer disagreeing would have been the bug. The stub this
|
||||
expectation was first written against printed neither. *)
|
||||
bracket, and a string quoted inside a collection but bare alone —
|
||||
println of a one-string array and of the string itself already
|
||||
disagree exactly this way, so the dyn printer disagreeing would have
|
||||
been the bug. The stub this expectation was first written against
|
||||
printed neither. *)
|
||||
let dyn_vec_out = "4\n[ 1 2.5 \"three\" true]\n1 2.5 three true \n" in
|
||||
outputs "dyn: a heterogeneous vector"
|
||||
"programs/dyn-vec.flan" dyn_vec_out;
|
||||
|
||||
@ -737,6 +737,55 @@ let () =
|
||||
| exception Loc.Error { Loc.dmsg = m; _ } ->
|
||||
fail "a session that refused an expansion could not expand afterwards: %s" m);
|
||||
|
||||
(* ── A type provider, expanded ─────────────────────────────────────
|
||||
Two claims, and they are the two halves of the live-tuning loop.
|
||||
|
||||
The first is the path. A macro that reads a data file resolves it the way
|
||||
(embed "...") does — against the directory of the source file the *form*
|
||||
is written in — and a macro cannot know where that is, because a Form
|
||||
carries no location. The compiler pokes the call site's directory in
|
||||
before every expansion, and the call site here is an origin the editor
|
||||
sent, not a file on a command line. "assets/edn/tuning.edn" is beside
|
||||
programs/edn-provide.flan and nowhere near this process's directory, so an
|
||||
expansion that answered anything at all read the right file.
|
||||
|
||||
The second is that the answer is readable. `C-c C-m` over a provider is
|
||||
the only way to see what it decided, and a provider whose output nobody
|
||||
can look at is a plugin. So this asserts on text a person would recognise:
|
||||
the struct with its fields and derived types, the nested struct named for
|
||||
its path, and the reader's dispatch on a key. Asserted as substrings
|
||||
rather than in full — the expansion is some hundreds of characters and a
|
||||
golden copy of it would fail on every comment reflowed in the derivation.
|
||||
|
||||
It also re-reads on every expansion, which is what makes editing the .edn
|
||||
and hitting C-c C-m a loop: nothing is cached but the macro module, and
|
||||
that holds the macro's code, not the data. *)
|
||||
(let pt, _ = Session.create ~file:"programs/edn-provide.flan" () in
|
||||
match
|
||||
Session.macroexpand ~origin:"programs/edn-provide.flan" ~all:false pt
|
||||
"(edn/defedn Tuning \"assets/edn/tuning.edn\")"
|
||||
with
|
||||
| x ->
|
||||
let got = Form.to_source x.Session.xafter in
|
||||
List.iter
|
||||
(fun want ->
|
||||
if not (has got want) then
|
||||
fail "expanding a defedn: %S is not in\n%s" want got)
|
||||
[ (* The struct, with a type per field derived from the value. *)
|
||||
"(defstruct Tuning [name string hp i64 speed f64 boss? bool";
|
||||
(* The vector, and the constructor that states its type so that
|
||||
(vec-new) has something to take it from. *)
|
||||
"drops (Vec i64)";
|
||||
(* The nested map, named for the path that reaches it, and the one
|
||||
nested inside that. *)
|
||||
"(defstruct Tuning-hitbox-offset [x i64 y i64])";
|
||||
"hitbox Tuning-hitbox";
|
||||
(* And the reader, dispatching on a key onto a field. *)
|
||||
"(edn/keyword=? k \"speed\")";
|
||||
"(set (.speed out) (edn/need-float c))" ]
|
||||
| exception Loc.Error { Loc.dmsg = m; _ } ->
|
||||
fail "expanding a defedn through a session: %s" m);
|
||||
|
||||
(* A form typed into a file that is *imported as a package* has to be
|
||||
qualified the way the import qualified it, or it splices as a brand-new
|
||||
unrelated name: the evaluation reports success and the running program
|
||||
|
||||
584
vendor/edn/provide.flan
vendored
Normal file
584
vendor/edn/provide.flan
vendored
Normal file
@ -0,0 +1,584 @@
|
||||
;;;; defedn: a struct derived from a data file, at compile time.
|
||||
;;;;
|
||||
;;;; F#'s type providers, with the part that makes them worth having and none
|
||||
;;;; of the part that needs a plugin protocol. `(edn/defedn Tileset "t.edn")`
|
||||
;;;; reads t.edn while the program is being compiled, works out what shape it
|
||||
;;;; is, and emits the struct that shape implies together with a reader for it.
|
||||
;;;; From then on `(.texture-path data)` is a field load off a struct: no Value,
|
||||
;;;; no match, no runtime tag, nothing to look up by name.
|
||||
;;;;
|
||||
;;;; read.flan is the other half of the same choice, and both belong here. A
|
||||
;;;; dynamic Value is what you want when the shape is the program's *input* —
|
||||
;;;; an editor opening a file it has never seen. A provider is what you want
|
||||
;;;; when the shape is part of the program and only the numbers change, which
|
||||
;;;; is what a game's tuning file is. The typed world is not an afterthought:
|
||||
;;;; it is the same tokenizer, read by a macro instead of by a loop.
|
||||
;;;;
|
||||
;;;; ── What it needs, and what was built for it ─────────────────────────
|
||||
;;;;
|
||||
;;;; A macro is compiled and dlopened into the compiler, so it has always been
|
||||
;;;; able to run arbitrary code at expansion time. Three things it could not do
|
||||
;;;; are what this file rests on, and all three are general:
|
||||
;;;;
|
||||
;;;; - `(macro-slurp "t.edn")` reads a file at expansion time, resolved the
|
||||
;;;; way `(embed "t.edn")` resolves a path — against the directory of the
|
||||
;;;; source file the form is written in.
|
||||
;;;; - a package's macro may call the package's own functions, which is why
|
||||
;;;; the derivation below is ordinary Flan over the tokenizer next door
|
||||
;;;; rather than a second scanner inlined into a macro body.
|
||||
;;;; - a macro may answer several declarations, as a top-level `(do ...)`,
|
||||
;;;; and may refuse with a sentence through `(compile-error "...")`.
|
||||
;;;;
|
||||
;;;; ── The rules ────────────────────────────────────────────────────────
|
||||
;;;;
|
||||
;;;; A map with keyword keys is a struct, one field per key, named for the
|
||||
;;;; keyword. An integer is an i64, a float an f64, a boolean a bool, a string
|
||||
;;;; a `string` — copied, which is read.flan's contract and not the tokenizer's:
|
||||
;;;; a Token's text points into the buffer, and a struct that outlives the
|
||||
;;;; buffer cannot hold one.
|
||||
;;;;
|
||||
;;;; A vector of one repeated shape is a `(Vec T)`. A set is this repo's own
|
||||
;;;; spelling of one, `(Map T bool)` — check.ml says exactly that where it
|
||||
;;;; refuses a map with a `()` value — and its elements are therefore read as
|
||||
;;;; map *keys*. That is why a vector inside a set derives to a fixed array
|
||||
;;;; `[n T]` rather than to a Vec: a Vec is not a map key and `[2 i64]` is.
|
||||
;;;; The file this was built for is a set of pairs, so that is the case rather
|
||||
;;;; than a corner of it.
|
||||
;;;;
|
||||
;;;; A nested map is a struct of its own, named for the path that reaches it —
|
||||
;;;; `Tileset-selected-cells`. A hyphen because `/` is package qualification
|
||||
;;;; and cannot appear in a name a program declares, and because every name in
|
||||
;;;; this language is already hyphenated, so no case conversion has to be
|
||||
;;;; written to produce one. The path is unique, so the name is.
|
||||
;;;;
|
||||
;;;; Everything else is refused while expanding, with the line and column in
|
||||
;;;; the *data* file. A refusal is the point: a file the compiler could not
|
||||
;;;; make sense of is one the program would have read wrongly.
|
||||
|
||||
;; ── Small string work, for the refusals and the names ───────────────
|
||||
|
||||
(defn joined [a string b string] string
|
||||
(let [v (vec-new u8)]
|
||||
(append (addr v) (bytes a))
|
||||
(append (addr v) (bytes b))
|
||||
(string (as-slice v))))
|
||||
|
||||
(defn joined3 [a string b string c string] string
|
||||
(joined a (joined b c)))
|
||||
|
||||
;; Copied out, and not `(string (i64->bytes n))`. The prelude's note over
|
||||
;; append-i64 is the reason: i64->bytes renders into one shared static buffer
|
||||
;; in the runtime, so two of its results cannot be held at once — and `where`
|
||||
;; below holds a line and a column at the same time, which read as the same
|
||||
;; number until this copied.
|
||||
(defn i64->string [n i64] string
|
||||
(let [v (vec-new u8)]
|
||||
(append-i64 (addr v) n)
|
||||
(string (as-slice v))))
|
||||
|
||||
;; The tokenizer answers byte offsets, because that is what a slice into the
|
||||
;; buffer costs nothing to produce. A person reading a refusal wants a line and
|
||||
;; a column, so the newlines before the offset are counted here — once per
|
||||
;; refusal, which is as often as this is ever called.
|
||||
(defn where [src [u8] pos i32] string
|
||||
(let [line (i64 1)
|
||||
col (i64 1)
|
||||
i (i32 0)]
|
||||
(while (< i pos)
|
||||
(if (= (at src i) \newline)
|
||||
(do (set line (+ line 1)) (set col 1))
|
||||
(set col (+ col 1)))
|
||||
(set i (+ i 1)))
|
||||
(joined3 "line " (i64->string line) (joined " column " (i64->string col)))))
|
||||
|
||||
;; ── What a value came to ────────────────────────────────────────────
|
||||
;;
|
||||
;; One walk answers three things at once, which is why they travel together:
|
||||
;; the *type* the value implies, the struct declarations that type needs (a
|
||||
;; nested map contributes one, and everything nested inside it contributes
|
||||
;; more), and the *expression* that reads one — written against a cursor named
|
||||
;; `c` and an allocator named `a`, which is the shape every generated reader
|
||||
;; binds.
|
||||
;;
|
||||
;; `bad` is the refusal, carried rather than raised: there is no exception to
|
||||
;; throw out of a recursive walk, and a partial answer with a reason attached
|
||||
;; propagates to the top where the one `compile-error` is written. Empty means
|
||||
;; the walk succeeded.
|
||||
(defstruct Derived
|
||||
[ty Form
|
||||
decls [Form]
|
||||
reader Form
|
||||
bad string])
|
||||
|
||||
(defn derived-bad [msg string] Derived
|
||||
(Derived {.ty `i64 .decls (form-nil) .reader `0 .bad msg}))
|
||||
|
||||
(defn ok-derived [ty Form decls [Form] reader Form] Derived
|
||||
(Derived {.ty ty .decls decls .reader reader .bad ""}))
|
||||
|
||||
(defn bad? [d Derived] bool
|
||||
(> (len (bytes (.bad d))) 0))
|
||||
|
||||
;; ── The scalars a generated reader calls ────────────────────────────
|
||||
;;
|
||||
;; Functions and not inlined expansions, so that `C-c C-m` over a defedn shows
|
||||
;; a reader somebody can read. Each is `expect` plus the conversion, with the
|
||||
;; same "the cursor carries the error" contract the hand-written reader in
|
||||
;; test/programs/edn.flan is written against: a failure leaves the value at
|
||||
;; zero and the cursor not ok?, so a whole struct is a straight line of
|
||||
;; assignments with one test at the end.
|
||||
|
||||
(defn need-int [c (Ptr Cursor)] i64
|
||||
(match (int-of (expect c tok-int)) (Some v) v None 0))
|
||||
|
||||
;; Two kinds are acceptable, because 2 and 2.0 are the same number and a tuning
|
||||
;; file written by hand has both. `float-of` answers Some for either.
|
||||
(defn need-float [c (Ptr Cursor)] f64
|
||||
(let [t (next c)]
|
||||
(match (float-of t)
|
||||
(Some x) x
|
||||
None (do (fail c err-unexpected-token (.pos t)) 0.0))))
|
||||
|
||||
(defn need-bool [c (Ptr Cursor)] bool
|
||||
(match (bool-of (expect c tok-bool)) (Some v) v None false))
|
||||
|
||||
;; Copied into the allocator, which is the whole difference between a field of
|
||||
;; a struct and a Token's text. The lifetime contract at the top of edn.flan is
|
||||
;; the reason: `text` is a slice of the buffer, and a struct read out of a
|
||||
;; buffer that is later freed would hold a dangling one.
|
||||
(defn need-string [c (Ptr Cursor) a Allocator] string
|
||||
(let [t (expect c tok-string)
|
||||
b (vec-new u8 a)]
|
||||
(append (addr b) (.text t))
|
||||
(string (as-slice b))))
|
||||
|
||||
;; Whether the next thing, past trivia, is this byte. Enough of a peek for
|
||||
;; every loop below — "is the collection over" is the only lookahead a reader
|
||||
;; of a known shape ever needs — and it consumes nothing, so the closer is
|
||||
;; still there for `expect` to take.
|
||||
(defn at-byte? [c (Ptr Cursor) b u8] bool
|
||||
(skip-trivia c)
|
||||
(and (not (at-end? c)) (= (at (.src c) (.pos c)) b)))
|
||||
|
||||
;; ── The condition a reader signals when the file moved ──────────────
|
||||
;;
|
||||
;; The case the whole feature exists to catch. The struct was derived from the
|
||||
;; file as it was when the program was compiled; the file read at run time may
|
||||
;; be a later one, and a field that has gone or arrived is a program reading
|
||||
;; something other than what it was built for.
|
||||
;;
|
||||
;; Silence is the alternative and it is the bad one: a missing key leaves a
|
||||
;; field at zero, which is a texture path of "" and a count of 0, and the
|
||||
;; program draws nothing for a reason nothing reports. So it is a condition,
|
||||
;; with the field named. Nothing here is fatal — signalling a condition no
|
||||
;; handler takes carries on — so a program that would rather not care does not
|
||||
;; have to write anything, and one that would rather know binds a handler.
|
||||
;;
|
||||
;; `pos` is the byte offset in the buffer being read: of the offending key for
|
||||
;; an unknown one, and of the token that ended the map for a missing one, which
|
||||
;; is where a person would look to add it.
|
||||
(defstruct SchemaDrift
|
||||
[field string
|
||||
struct string
|
||||
extra? bool
|
||||
pos i32])
|
||||
|
||||
;; ── And the one a file that does not parse signals ──────────────────
|
||||
;;
|
||||
;; The louder failure had the quieter answer until this existed. A generated
|
||||
;; reader accumulates errors on the cursor rather than returning them — which
|
||||
;; is what lets it be a straight line of assignments — and the cursor is made
|
||||
;; and dropped inside the entry point, so a stray brace in a file read at run
|
||||
;; time gave the program a zeroed struct and said nothing at all.
|
||||
;;
|
||||
;; That is the one thing the rest of this package refuses to do. `read-file`
|
||||
;; answers an Option precisely so that a malformed document is distinguishable
|
||||
;; from a document that is literally nil, and the hand-written reader in
|
||||
;; test/programs/edn.flan tests ok? and prints the reason. A derived reader has
|
||||
;; to be at least as honest.
|
||||
;;
|
||||
;; A condition and not an Option, to match SchemaDrift beside it: both are "the
|
||||
;; file is not what this program was built for", and a handler that wants to
|
||||
;; carry on with a half-read struct may, while one that wants to stop has
|
||||
;; something to stop on. `code` is an err-* constant, which `error-message`
|
||||
;; turns into a sentence.
|
||||
(defstruct ReadFailed
|
||||
[struct string
|
||||
code i32
|
||||
pos i32])
|
||||
|
||||
;; ── Deriving ────────────────────────────────────────────────────────
|
||||
;;
|
||||
;; One value, from the cursor's current position, consumed. `name` is what a
|
||||
;; struct here would be called; `src` is the whole buffer, for the positions a
|
||||
;; refusal names.
|
||||
(defn derive [c (Ptr Cursor) name string src [u8]] Derived
|
||||
(let [t (next c)]
|
||||
(when (not (ok? c))
|
||||
(return (derived-bad
|
||||
(joined3 "the data file could not be read at " (where src (error-pos c))
|
||||
(joined ": " (error-message (.err c)))))))
|
||||
(cond
|
||||
(= (.kind t) tok-int) (ok-derived `i64 (form-nil) `(need-int c))
|
||||
(= (.kind t) tok-float) (ok-derived `f64 (form-nil) `(need-float c))
|
||||
(= (.kind t) tok-bool) (ok-derived `bool (form-nil) `(need-bool c))
|
||||
(= (.kind t) tok-string) (ok-derived `string (form-nil) `(need-string c a))
|
||||
|
||||
(= (.kind t) tok-map-open) (derive-map c name (.pos t) src)
|
||||
(= (.kind t) tok-vec-open) (derive-vec c name (.pos t) src)
|
||||
(= (.kind t) tok-set-open) (derive-set c name (.pos t) src)
|
||||
|
||||
(= (.kind t) tok-nil)
|
||||
(derived-bad
|
||||
(joined3 "the nil at " (where src (.pos t))
|
||||
" has no type to derive — a field that is sometimes absent is not something a struct can hold, so give it a value in the file or take the key out"))
|
||||
|
||||
:else
|
||||
(derived-bad
|
||||
(joined3 "the value at " (where src (.pos t))
|
||||
" is not one defedn derives a type from — a map, a vector, a set, an integer, a float, a boolean or a string")))))
|
||||
|
||||
;; A vector, whose elements must all come to the same type. The first element
|
||||
;; decides; every one after it is compared against that decision and both
|
||||
;; positions are named when they disagree, because "heterogeneous" without
|
||||
;; saying where sends someone to read the whole file.
|
||||
(defn derive-vec [c (Ptr Cursor) name string at-pos i32 src [u8]] Derived
|
||||
(when (at-byte? c \])
|
||||
(return (derived-bad
|
||||
(joined3 "the empty vector at " (where src at-pos)
|
||||
" has no element to derive an element type from — defedn reads the shape out of the data, and an empty collection carries none"))))
|
||||
(let [head (derive c (joined name "-item") src)]
|
||||
(when (bad? head)
|
||||
(return head))
|
||||
(let [n (i64 1)]
|
||||
(while (and (ok? c) (not (at-byte? c \])))
|
||||
(let [item (derive c (joined name "-item") src)]
|
||||
(when (bad? item)
|
||||
(return item))
|
||||
(when (not (same-type? (.ty head) (.ty item)))
|
||||
(return (derived-bad (disagreement "vector" src at-pos n
|
||||
(.ty head) (.ty item)))))
|
||||
(set n (+ n 1))))
|
||||
(expect c tok-vec-close)
|
||||
(let [elem (.ty head)
|
||||
read1 (.reader head)
|
||||
ty `(Vec ~elem)
|
||||
cn (Form.Sym {.s (joined name "-new")})]
|
||||
(ok-derived ty (with-decl (.decls head) `(defn ~cn [a Allocator] ~ty
|
||||
(vec-new a)))
|
||||
`(let [xs (~cn a)]
|
||||
(expect c tok-vec-open)
|
||||
(while (and (ok? c) (not (at-byte? c \])))
|
||||
(push xs ~read1))
|
||||
(expect c tok-vec-close)
|
||||
xs))))))
|
||||
|
||||
;; Why every collection gets a one-line constructor of its own.
|
||||
;;
|
||||
;; `(vec-new)` and `(map-new)` each need to be told what they build, and the
|
||||
;; way to tell them in argument position is to *name* a type: check.ml's
|
||||
;; vec_new_elem and map_new_types take an `Ast.Var` and nothing else. A type
|
||||
;; this derives may have no name — `(Vec i64)` has none, and `[2 i64]`, which
|
||||
;; is the key of the set in the file this was built for, has none either.
|
||||
;;
|
||||
;; Both fall back to what the context wants, and a function's return type is a
|
||||
;; type position where anything can be written. So the type is stated once, in
|
||||
;; a signature, and the bare call in the body gets it from `want`. It is also
|
||||
;; the more readable expansion: the reader says `(cells-new a)` where it would
|
||||
;; otherwise carry a type nobody wrote.
|
||||
(defn with-decl [decls [Form] d Form] [Form]
|
||||
(form-append decls (form-cons d (form-nil))))
|
||||
|
||||
;; A set becomes `(Map T bool)`, so its elements are map keys. `derive-key` is
|
||||
;; where that constraint is enforced and said.
|
||||
(defn derive-set [c (Ptr Cursor) name string at-pos i32 src [u8]] Derived
|
||||
(when (at-byte? c \})
|
||||
(return (derived-bad
|
||||
(joined3 "the empty set at " (where src at-pos)
|
||||
" has no element to derive an element type from"))))
|
||||
(let [head (derive-key c (joined name "-key") src)]
|
||||
(when (bad? head)
|
||||
(return head))
|
||||
(let [n (i64 1)]
|
||||
(while (and (ok? c) (not (at-byte? c \})))
|
||||
(let [item (derive-key c (joined name "-key") src)]
|
||||
(when (bad? item)
|
||||
(return item))
|
||||
(when (not (same-type? (.ty head) (.ty item)))
|
||||
(return (derived-bad (disagreement "set" src at-pos n
|
||||
(.ty head) (.ty item)))))
|
||||
(set n (+ n 1))))
|
||||
(expect c tok-map-close)
|
||||
(let [elem (.ty head)
|
||||
read1 (.reader head)
|
||||
ty `(Map ~elem bool)
|
||||
cn (Form.Sym {.s (joined name "-new")})]
|
||||
(ok-derived ty (with-decl (.decls head) `(defn ~cn [a Allocator] ~ty
|
||||
(map-new a)))
|
||||
`(let [tbl (~cn a)]
|
||||
(expect c tok-set-open)
|
||||
(while (and (ok? c) (not (at-byte? c \})))
|
||||
(put tbl ~read1 true))
|
||||
(expect c tok-map-close)
|
||||
tbl))))))
|
||||
|
||||
;; One element of a set. The scalars that are map keys pass; a vector becomes a
|
||||
;; fixed array, which is one where a Vec is not; anything else is refused here
|
||||
;; rather than at the `(Map ...)` the caller would build out of it, because a
|
||||
;; map-key refusal names a type nobody wrote.
|
||||
(defn derive-key [c (Ptr Cursor) name string src [u8]] Derived
|
||||
(when (at-byte? c \[)
|
||||
(return (derive-array c name src)))
|
||||
(let [d (derive c name src)]
|
||||
(when (bad? d)
|
||||
(return d))
|
||||
(when (not (key-type? (.ty d)))
|
||||
(return (derived-bad
|
||||
(joined3 "a set of " (render (.ty d))
|
||||
" is not something this builds: a set becomes a (Map T bool), so its elements are map keys. Integers, booleans, strings and vectors of those are"))))
|
||||
d))
|
||||
|
||||
;; A vector in key position. Its length is part of its type, so every element
|
||||
;; of the set has to be the same length as well as the same shape — which falls
|
||||
;; out of the type comparison the caller already makes, since the length is in
|
||||
;; the type it compares.
|
||||
(defn derive-array [c (Ptr Cursor) name string src [u8]] Derived
|
||||
(let [open (next c)]
|
||||
(when (at-byte? c \])
|
||||
(return (derived-bad
|
||||
(joined3 "the empty vector at " (where src (.pos open))
|
||||
" is inside a set, and an empty fixed array has no element type and no length"))))
|
||||
(let [head (derive c (joined name "-item") src)]
|
||||
(when (bad? head)
|
||||
(return head))
|
||||
(let [n (i64 1)]
|
||||
(while (and (ok? c) (not (at-byte? c \])))
|
||||
(let [item (derive c (joined name "-item") src)]
|
||||
(when (bad? item)
|
||||
(return item))
|
||||
(when (not (same-type? (.ty head) (.ty item)))
|
||||
(return (derived-bad (disagreement "vector inside a set" src
|
||||
(.pos open) n
|
||||
(.ty head) (.ty item)))))
|
||||
(set n (+ n 1))))
|
||||
(expect c tok-vec-close)
|
||||
(when (not (key-type? (.ty head)))
|
||||
(return (derived-bad
|
||||
(joined3 "a set of vectors of " (render (.ty head))
|
||||
" is not something this builds: the vector becomes a fixed array, which is a map key only when its elements are compared bytewise"))))
|
||||
(let [elem (.ty head)
|
||||
read1 (.reader head)
|
||||
count (Form.Int {.i n})]
|
||||
(ok-derived `[~count ~elem] (.decls head)
|
||||
`(let [arr (array ~count ~elem)
|
||||
i 0]
|
||||
(expect c tok-vec-open)
|
||||
(while (and (ok? c) (not (at-byte? c \])) (< i ~count))
|
||||
(set (at arr i) ~read1)
|
||||
(set i (+ i 1)))
|
||||
(expect c tok-vec-close)
|
||||
arr)))))))
|
||||
|
||||
(defn disagreement [what string src [u8] at-pos i32 n i64
|
||||
first Form second Form] string
|
||||
(joined3 (joined3 "the " what " at ")
|
||||
(where src at-pos)
|
||||
(joined3 (joined3 " holds more than one shape: its first element is "
|
||||
(render first) " and element ")
|
||||
(i64->string n)
|
||||
(joined3 " is " (render second)
|
||||
". Every element has to be the same shape, because the type this becomes has one element type"))))
|
||||
|
||||
;; ── A map, which is a struct ────────────────────────────────────────
|
||||
;;
|
||||
;; The declaration and the reader together, because the fields decide both and
|
||||
;; walking twice would mean tokenizing twice.
|
||||
;;
|
||||
;; The reader's shape is the hand-written one in test/programs/edn.flan, which
|
||||
;; was written to show what a generated one would look like: open the map, loop
|
||||
;; on the keys, dispatch each onto its field, and finish. What it does
|
||||
;; differently is the two arms a hand-written reader had no reason to have — a
|
||||
;; key that is not a field of the struct, and a field the file did not have.
|
||||
;; Both signal SchemaDrift. See the note over that type.
|
||||
(defn derive-map [c (Ptr Cursor) name string at-pos i32 src [u8]] Derived
|
||||
(when (at-byte? c \})
|
||||
(return (derived-bad
|
||||
(joined3 "the empty map at " (where src at-pos)
|
||||
" has no keys to derive fields from — a struct with no fields is not a shape anything can be read into"))))
|
||||
(let [fields (vec-new Form) ; the defstruct's [name type ...] vector
|
||||
clauses (vec-new Form) ; the reader's cond: test, body, test, body
|
||||
missing (vec-new Form) ; one per field, checked when the map closes
|
||||
decls (vec-new Form) ; nested structs, innermost first
|
||||
idx (i64 0)]
|
||||
(while (and (ok? c) (not (at-byte? c \})))
|
||||
(let [k (next c)]
|
||||
(when (not (ok? c))
|
||||
(return (derived-bad
|
||||
(joined3 "the data file could not be read at "
|
||||
(where src (error-pos c))
|
||||
(joined ": " (error-message (.err c)))))))
|
||||
(when (!= (.kind k) tok-keyword)
|
||||
(return (derived-bad
|
||||
(joined3 (joined3 "the map at " (where src at-pos) " has a key at ")
|
||||
(where src (.pos k))
|
||||
" that is not a keyword. A struct's fields are named, so every key of a map defedn reads has to be one — :name, not \"name\" and not 1"))))
|
||||
(let [fname (copy-text (.text k))
|
||||
d (derive c (joined3 name "-" fname) src)]
|
||||
(when (bad? d)
|
||||
(return d))
|
||||
(dotimes [i (len (.decls d))]
|
||||
(push decls (at (.decls d) i)))
|
||||
(push fields (Form.Sym {.s fname}))
|
||||
(push fields (.ty d))
|
||||
(let [dot (Form.Sym {.s (joined "." fname)})
|
||||
lit (Form.Str {.s fname})
|
||||
bit (Form.Int {.i (<< (i64 1) idx)})
|
||||
read1 (.reader d)]
|
||||
(push clauses `(keyword=? k ~lit))
|
||||
(push clauses `(do (set (~dot out) ~read1)
|
||||
(set seen (bit-or seen ~bit))))
|
||||
;; Checked at the closing brace rather than tracked by name: the
|
||||
;; bit is decided here, where the field is, so the two cannot fall
|
||||
;; out of step the way a parallel list of names would.
|
||||
(push missing
|
||||
`(when (= (bit-and seen ~bit) 0)
|
||||
(signal (SchemaDrift {.field ~lit
|
||||
.struct ~(Form.Str {.s name})
|
||||
.extra? false
|
||||
.pos (.pos k)})))))
|
||||
(set idx (+ idx 1)))))
|
||||
(expect c tok-map-close)
|
||||
;; An unknown key. The hand-written reader skips one, which is right when a
|
||||
;; person wrote the reader and knows what else is in the file. Here the
|
||||
;; struct *is* the file, so a key that is not a field is the file having
|
||||
;; moved: it is reported and then skipped, so a program that declines to
|
||||
;; handle the condition still reads the rest.
|
||||
(push clauses `:else)
|
||||
(push clauses
|
||||
`(do (signal (SchemaDrift {.field (copy-text (.text k))
|
||||
.struct ~(Form.Str {.s name})
|
||||
.extra? true
|
||||
.pos (.pos k)}))
|
||||
(when (not (skip-value c))
|
||||
(return out))))
|
||||
(let [sname (Form.Sym {.s name})
|
||||
rname (Form.Sym {.s (joined "read-" name)})
|
||||
struct `(defstruct ~sname ~(Form.Vec {.xs (as-slice fields)}))
|
||||
reader
|
||||
`(defn ~rname [c (Ptr Cursor) a Allocator] ~sname
|
||||
(let [out (~sname {})
|
||||
seen 0]
|
||||
(expect c tok-map-open)
|
||||
(while (ok? c)
|
||||
(let [k (next c)]
|
||||
(when (or (not (ok? c)) (= (.kind k) tok-map-close))
|
||||
~@(as-slice missing)
|
||||
(return out))
|
||||
(when (!= (.kind k) tok-keyword)
|
||||
(fail c err-unexpected-token (.pos k))
|
||||
(return out))
|
||||
(cond ~@(as-slice clauses))))
|
||||
out))]
|
||||
(push decls struct)
|
||||
(push decls reader)
|
||||
(ok-derived sname (as-slice decls) `(~rname c a)))))
|
||||
|
||||
;; ── Comparing and rendering a type form ─────────────────────────────
|
||||
|
||||
(defn same-type? [a Form b Form] bool
|
||||
(bytes=? (bytes (render a)) (bytes (render b))))
|
||||
|
||||
;; A type form as text, for the refusals. Only the shapes this file builds — a
|
||||
;; name, a number, `(Vec T)`, `(Map K V)` and `[n T]` — because nothing else
|
||||
;; ever reaches it.
|
||||
(defn render [f Form] string
|
||||
(match f
|
||||
(Form.Sym s) s
|
||||
(Form.Int i) (i64->string i)
|
||||
(Form.List xs) (joined3 "(" (render-items xs) ")")
|
||||
(Form.Vec xs) (joined3 "[" (render-items xs) "]")
|
||||
_ "?"))
|
||||
|
||||
(defn render-items [xs [Form]] string
|
||||
(let [out ""]
|
||||
(dotimes [i (len xs)]
|
||||
(set out (if (= i 0)
|
||||
(render (at xs i))
|
||||
(joined3 out " " (render (at xs i))))))
|
||||
out))
|
||||
|
||||
;; What check.ml takes as a map key, narrowed to what this file can produce.
|
||||
;; A float is deliberately absent and the checker says why: NaN is not equal to
|
||||
;; itself, so there is no equality for a map to hash.
|
||||
(defn key-type? [t Form] bool
|
||||
(let [s (bytes (render t))]
|
||||
(or (bytes=? s (bytes "i64"))
|
||||
(or (bytes=? s (bytes "bool"))
|
||||
(bytes=? s (bytes "string"))))))
|
||||
|
||||
;; ── The macro ───────────────────────────────────────────────────────
|
||||
;;
|
||||
;; `(edn/defedn Tileset "assets/tileset.edn")`. The path is relative to the
|
||||
;; file this is written in, exactly as `(embed "assets/tileset.edn")` is — see
|
||||
;; `macro-slurp` in the prelude, and check.ml's `embed_path`, which is the rule
|
||||
;; it copies.
|
||||
;;
|
||||
;; It answers a `do`, which the top level splices: the nested structs innermost
|
||||
;; first, then the struct named here, a reader per struct, and the two entry
|
||||
;; points over the whole thing. `C-c C-m` over the call shows all of it, which
|
||||
;; is the point of generating readable code rather than the smallest code — a
|
||||
;; provider whose output nobody can look at is a plugin.
|
||||
(defmacro defedn [args]
|
||||
(if (!= (len args) 2)
|
||||
(refuse "defedn is (defedn Name \"path.edn\") — a name for the struct, and a path to the file its shape is read out of")
|
||||
(match (at args 1)
|
||||
(Form.Str path)
|
||||
(match (at args 0)
|
||||
(Form.Sym name)
|
||||
(match (macro-slurp path)
|
||||
(Some src) (provide name path src)
|
||||
None (refuse
|
||||
(joined3 "there is no file at " path
|
||||
", read relative to the file this defedn is written in — the same place (embed \"...\") would look")))
|
||||
_ (refuse "defedn's first argument is the name of the struct to declare, written as a name"))
|
||||
_ (refuse "defedn's second argument is the path to the data file, written as a string literal — the file is read while this is being compiled, so there is nothing here to compute a path from"))))
|
||||
|
||||
(defn provide [name string path string src [u8]] Form
|
||||
(let [cur (cursor src)
|
||||
d (derive (addr cur) name src)]
|
||||
(if (bad? d)
|
||||
(refuse (.bad d))
|
||||
(if (not (= (.kind (next (addr cur))) tok-eof))
|
||||
(refuse (joined3 path " holds more than one value, and a defedn derives one struct from one" ""))
|
||||
(let [sname (Form.Sym {.s name})
|
||||
rname (Form.Sym {.s (joined "read-" name)})
|
||||
bname (Form.Sym {.s (joined name "-of-bytes")})
|
||||
fname (Form.Sym {.s (joined name "-read-file")})]
|
||||
`(do
|
||||
~@(.decls d)
|
||||
;; The two entry points. Both take the allocator the struct's own
|
||||
;; fields are built in, because a string field is a copy and a Vec
|
||||
;; field is an allocation — spec-memory's rule, and the reason the
|
||||
;; destination is never implicit.
|
||||
(defn ~bname [b [u8] a Allocator] ~sname
|
||||
(let [c (cursor b)
|
||||
out (~rname (addr c) a)]
|
||||
;; The cursor is made and dropped here, so this is the only
|
||||
;; place that can ask whether the read worked. See ReadFailed.
|
||||
(when (not (ok? (addr c)))
|
||||
(signal (ReadFailed {.struct ~(Form.Str {.s name})
|
||||
.code (.err c)
|
||||
.pos (.err-pos c)})))
|
||||
out))
|
||||
(defn ~fname [p string a Allocator] ~sname
|
||||
(let [b (slurp p a)]
|
||||
(~bname (as-slice b) a)))))))))
|
||||
|
||||
;; A refusal, as a declaration. `compile-error` is an expression and a
|
||||
;; top-level position takes a declaration, so it goes in the body of a function
|
||||
;; nothing calls: the checker walks it, the arm fires, and `Loc.from_macro` has
|
||||
;; already put the report on the `defedn` the author wrote. The name is a
|
||||
;; gensym, so two refusals in one file are two reports rather than a name
|
||||
;; defined twice.
|
||||
(defn refuse [msg string] Form
|
||||
`(defn ~(gensym) [] () (compile-error ~(Form.Str {.s msg}))))
|
||||
464
vendor/json/provide.flan
vendored
Normal file
464
vendor/json/provide.flan
vendored
Normal file
@ -0,0 +1,464 @@
|
||||
;;;; defjson: a struct derived from a JSON file, at compile time.
|
||||
;;;;
|
||||
;;;; `(json/defjson Config "config.json")` reads config.json while the program
|
||||
;;;; is being compiled, derives the struct its shape implies, and emits that
|
||||
;;;; struct with a reader over the tokenizer next door. `(.port cfg)` is then a
|
||||
;;;; field load: no Value, no match, no runtime tag, nothing looked up by name.
|
||||
;;;;
|
||||
;;;; It is the same idea as vendor/edn's defedn and deliberately not the same
|
||||
;;;; code. Neither package imports the other: json depends on edn for nothing,
|
||||
;;;; and borrowing a shape walk across that line would be a dependency for the
|
||||
;;;; sake of a resemblance. What carries over is the design — one walk that
|
||||
;;;; answers a type, the declarations that type needs and the expression that
|
||||
;;;; reads one; a refusal carried in a field rather than raised; a typed
|
||||
;;;; one-line constructor per collection; and a `compile-error` wrapped in a
|
||||
;;;; defn nothing calls.
|
||||
;;;;
|
||||
;;;; ── What is different, and why ───────────────────────────────────────
|
||||
;;;;
|
||||
;;;; **Strings go through `string-of` and never through `.text`.** json.flan's
|
||||
;;;; `.text` is the RAW interior of a string token, escapes undecoded, so a
|
||||
;;;; field read off it would hold a literal backslash-n where the file meant a
|
||||
;;;; newline. `string-of` is the one call in that package that allocates, and
|
||||
;;;; it is the one that is correct.
|
||||
;;;;
|
||||
;;;; **Commas and colons are tokens.** In EDN a comma is whitespace; here a
|
||||
;;;; member is a string, a colon, a value, and a comma if another follows.
|
||||
;;;;
|
||||
;;;; **An object's keys are strings, not keywords**, so the refusal a map of
|
||||
;;;; the wrong shape gets is about a string, and a key has to look like a name
|
||||
;;;; a program could write — `{"a b": 1}` is a legal object and `a b` is not a
|
||||
;;;; field.
|
||||
;;;;
|
||||
;;;; **There are no sets**, so there is no map-key path and no fixed array:
|
||||
;;;; every collection here is a `(Vec T)`. defjson is strictly the smaller of
|
||||
;;;; the two.
|
||||
;;;;
|
||||
;;;; JSON has no integer type of its own — the tokenizer draws the line at
|
||||
;;;; whether a number has a fraction or an exponent, which is the only line
|
||||
;;;; there is — so a number written 1 is an i64 here and one written 1.0 is an
|
||||
;;;; f64. That is the file's own distinction and the honest one to derive from;
|
||||
;;;; a tuning value that will sometimes be fractional should be written 1.0.
|
||||
|
||||
;; ── Small string work ───────────────────────────────────────────────
|
||||
|
||||
(defn joined [a string b string] string
|
||||
(let [v (vec-new u8)]
|
||||
(append (addr v) (bytes a))
|
||||
(append (addr v) (bytes b))
|
||||
(string (as-slice v))))
|
||||
|
||||
(defn joined3 [a string b string c string] string
|
||||
(joined a (joined b c)))
|
||||
|
||||
;; Copied out, and not `(string (i64->bytes n))`: the prelude's note over
|
||||
;; append-i64 is the reason — i64->bytes renders into one shared static buffer
|
||||
;; in the runtime, so two of its results cannot be held at once, and `where`
|
||||
;; holds a line and a column at the same time.
|
||||
(defn i64->string [n i64] string
|
||||
(let [v (vec-new u8)]
|
||||
(append-i64 (addr v) n)
|
||||
(string (as-slice v))))
|
||||
|
||||
;; The tokenizer answers byte offsets. A person reading a refusal wants a line
|
||||
;; and a column, so the newlines before the offset are counted here — once per
|
||||
;; refusal, which is as often as this is ever called.
|
||||
(defn where [src [u8] pos i32] string
|
||||
(let [line (i64 1)
|
||||
col (i64 1)
|
||||
i (i32 0)]
|
||||
(while (< i pos)
|
||||
(if (= (at src i) \newline)
|
||||
(do (set line (+ line 1)) (set col 1))
|
||||
(set col (+ col 1)))
|
||||
(set i (+ i 1)))
|
||||
(joined3 "line " (i64->string line) (joined " column " (i64->string col)))))
|
||||
|
||||
;; ── What a value came to ────────────────────────────────────────────
|
||||
;;
|
||||
;; One walk answers three things: the type the value implies, the struct
|
||||
;; declarations that type needs, and the expression that reads one — written
|
||||
;; against a cursor named `c` and an allocator named `a`, which is what every
|
||||
;; generated reader binds. `bad` is the refusal, carried rather than raised,
|
||||
;; because there is no exception to throw out of a recursive walk.
|
||||
(defstruct Derived
|
||||
[ty Form
|
||||
decls [Form]
|
||||
reader Form
|
||||
bad string])
|
||||
|
||||
(defn derived-bad [msg string] Derived
|
||||
(Derived {.ty `i64 .decls (form-nil) .reader `0 .bad msg}))
|
||||
|
||||
(defn ok-derived [ty Form decls [Form] reader Form] Derived
|
||||
(Derived {.ty ty .decls decls .reader reader .bad ""}))
|
||||
|
||||
(defn bad? [d Derived] bool
|
||||
(> (len (bytes (.bad d))) 0))
|
||||
|
||||
(defn with-decl [decls [Form] d Form] [Form]
|
||||
(form-append decls (form-cons d (form-nil))))
|
||||
|
||||
;; ── The scalars a generated reader calls ────────────────────────────
|
||||
;;
|
||||
;; Functions and not inlined expansions, so that `C-c C-m` over a defjson shows
|
||||
;; a reader somebody can read. Each is `expect` plus the conversion, and a
|
||||
;; failure leaves the value at zero with the cursor not ok? — so a whole struct
|
||||
;; is a straight line of assignments with one test at the end.
|
||||
|
||||
(defn need-int [c (Ptr Cursor)] i64
|
||||
(match (int-of (expect c tok-int)) (Some v) v None 0))
|
||||
|
||||
;; Both number kinds are acceptable, because 2 and 2.0 are the same number and
|
||||
;; a file written by hand has both. `float-of` answers Some for either.
|
||||
(defn need-float [c (Ptr Cursor)] f64
|
||||
(let [t (next c)]
|
||||
(match (float-of t)
|
||||
(Some x) x
|
||||
None (do (fail c err-unexpected-token (.pos t)) 0.0))))
|
||||
|
||||
(defn need-bool [c (Ptr Cursor)] bool
|
||||
(match (bool-of (expect c tok-bool)) (Some v) v None false))
|
||||
|
||||
;; `string-of` and not `.text`. The header says why: `.text` is the raw
|
||||
;; interior and an escape in it has not been decoded, so a field read off it
|
||||
;; would hold a backslash and an n where the file meant a newline.
|
||||
(defn need-string [c (Ptr Cursor)] string
|
||||
(match (string-of (expect c tok-string)) (Some s) s None ""))
|
||||
|
||||
;; Whether the next thing, past whitespace, is this byte. Enough of a peek for
|
||||
;; every loop below — "is this over" and "is there another member" are the only
|
||||
;; lookaheads a reader of a known shape needs — and it consumes nothing.
|
||||
(defn at-byte? [c (Ptr Cursor) b u8] bool
|
||||
(skip-trivia c)
|
||||
(and (not (at-end? c)) (= (at (.src c) (.pos c)) b)))
|
||||
|
||||
;; The comma between two members, taken when there is one. A trailing comma is
|
||||
;; json.flan's err-trailing-comma and is the tokenizer's to refuse, not this
|
||||
;; loop's: taking it here and then meeting the closer is exactly the shape that
|
||||
;; refusal is written against.
|
||||
(defn comma [c (Ptr Cursor)] ()
|
||||
(when (at-byte? c \,)
|
||||
(next c)))
|
||||
|
||||
;; ── The condition a reader signals when the file moved ──────────────
|
||||
;;
|
||||
;; The case the whole feature exists to catch, and the same one defedn carries:
|
||||
;; the struct was derived from the file as it was when the program was
|
||||
;; compiled, and a key that has gone or arrived since is a program reading
|
||||
;; something other than what it was built for. Silence is the alternative and
|
||||
;; it is the bad one — a missing key leaves a field at zero, which is a port of
|
||||
;; 0 and a path of "", and the program fails for a reason nothing reports.
|
||||
;;
|
||||
;; Its own type and not edn's, because these two packages do not depend on each
|
||||
;; other. A program reading both files binds two clauses, which is the honest
|
||||
;; shape: the two conditions carry different provenance.
|
||||
(defstruct SchemaDrift
|
||||
[field string
|
||||
struct string
|
||||
extra? bool
|
||||
pos i32])
|
||||
|
||||
;; And the one a file that does not parse signals. A generated reader
|
||||
;; accumulates errors on the cursor rather than returning them — which is what
|
||||
;; lets it be a straight line of assignments — and the cursor is made and
|
||||
;; dropped inside the entry point, so without this a stray brace in a file read
|
||||
;; at run time would give the program a zeroed struct and say nothing. The
|
||||
;; louder failure would have had the quieter answer. `code` is an err-*
|
||||
;; constant, which `error-message` turns into a sentence.
|
||||
(defstruct ReadFailed
|
||||
[struct string
|
||||
code i32
|
||||
pos i32])
|
||||
|
||||
;; ── Deriving ────────────────────────────────────────────────────────
|
||||
|
||||
(defn derive [c (Ptr Cursor) name string src [u8]] Derived
|
||||
(let [t (next c)]
|
||||
(when (not (ok? c))
|
||||
(return (derived-bad
|
||||
(joined3 "the data file could not be read at " (where src (error-pos c))
|
||||
(joined ": " (error-message (.err c)))))))
|
||||
(cond
|
||||
(= (.kind t) tok-int) (ok-derived `i64 (form-nil) `(need-int c))
|
||||
(= (.kind t) tok-float) (ok-derived `f64 (form-nil) `(need-float c))
|
||||
(= (.kind t) tok-bool) (ok-derived `bool (form-nil) `(need-bool c))
|
||||
(= (.kind t) tok-string) (ok-derived `string (form-nil) `(need-string c))
|
||||
|
||||
(= (.kind t) tok-object-open) (derive-object c name (.pos t) src)
|
||||
(= (.kind t) tok-array-open) (derive-array c name (.pos t) src)
|
||||
|
||||
(= (.kind t) tok-null)
|
||||
(derived-bad
|
||||
(joined3 "the null at " (where src (.pos t))
|
||||
" has no type to derive — a field that is sometimes absent is not something a struct can hold, so give it a value in the file or take the key out"))
|
||||
|
||||
:else
|
||||
(derived-bad
|
||||
(joined3 "the value at " (where src (.pos t))
|
||||
" is not one defjson derives a type from — an object, an array, a number, a boolean or a string")))))
|
||||
|
||||
;; An array, whose elements must all come to the same type. The first decides;
|
||||
;; every one after it is compared against that, and both positions are named
|
||||
;; when they disagree — "heterogeneous" on its own sends someone to read the
|
||||
;; whole file.
|
||||
(defn derive-array [c (Ptr Cursor) name string at-pos i32 src [u8]] Derived
|
||||
(when (at-byte? c \])
|
||||
(return (derived-bad
|
||||
(joined3 "the empty array at " (where src at-pos)
|
||||
" has no element to derive an element type from — defjson reads the shape out of the data, and an empty collection carries none"))))
|
||||
(let [head (derive c (joined name "-item") src)]
|
||||
(when (bad? head)
|
||||
(return head))
|
||||
(comma c)
|
||||
(let [n (i64 1)]
|
||||
(while (and (ok? c) (not (at-byte? c \])))
|
||||
(let [item (derive c (joined name "-item") src)]
|
||||
(when (bad? item)
|
||||
(return item))
|
||||
(when (not (same-type? (.ty head) (.ty item)))
|
||||
(return (derived-bad
|
||||
(joined3 (joined3 "the array at " (where src at-pos)
|
||||
" holds more than one shape: element 0 is ")
|
||||
(render (.ty head))
|
||||
(joined3 (joined3 " and element " (i64->string n) " is ")
|
||||
(render (.ty item))
|
||||
". Every element of an array has to be the same shape, because the (Vec T) it becomes has one element type")))))
|
||||
(comma c)
|
||||
(set n (+ n 1))))
|
||||
(expect c tok-array-close)
|
||||
(let [elem (.ty head)
|
||||
read1 (.reader head)
|
||||
ty `(Vec ~elem)
|
||||
cn (Form.Sym {.s (joined name "-new")})]
|
||||
;; The constructor states the type so the bare (vec-new a) in its body
|
||||
;; can take it from `want`. (vec-new) has to be *told* what it builds by
|
||||
;; naming a type, and `(Vec i64)` has no name to be — but a signature is
|
||||
;; a type position where anything can be written. The reader reads
|
||||
;; better for it too.
|
||||
(ok-derived ty (with-decl (.decls head) `(defn ~cn [a Allocator] ~ty
|
||||
(vec-new a)))
|
||||
`(let [xs (~cn a)]
|
||||
(expect c tok-array-open)
|
||||
(while (and (ok? c) (not (at-byte? c \])))
|
||||
(push xs ~read1)
|
||||
(comma c))
|
||||
(expect c tok-array-close)
|
||||
xs))))))
|
||||
|
||||
;; ── An object, which is a struct ────────────────────────────────────
|
||||
|
||||
(defn derive-object [c (Ptr Cursor) name string at-pos i32 src [u8]] Derived
|
||||
(when (at-byte? c \})
|
||||
(return (derived-bad
|
||||
(joined3 "the empty object at " (where src at-pos)
|
||||
" has no members to derive fields from — a struct with no fields is not a shape anything can be read into"))))
|
||||
(let [fields (vec-new Form) ; the defstruct's [name type ...] vector
|
||||
clauses (vec-new Form) ; the reader's cond: test, body, test, body
|
||||
missing (vec-new Form) ; one per field, checked when the object closes
|
||||
decls (vec-new Form) ; nested structs, innermost first
|
||||
idx (i64 0)]
|
||||
(while (and (ok? c) (not (at-byte? c \})))
|
||||
(let [k (next c)]
|
||||
(when (not (ok? c))
|
||||
(return (derived-bad
|
||||
(joined3 "the data file could not be read at " (where src (error-pos c))
|
||||
(joined ": " (error-message (.err c)))))))
|
||||
(when (!= (.kind k) tok-string)
|
||||
(return (derived-bad
|
||||
(joined3 "the object at " (joined3 (where src at-pos) " has a member at " (where src (.pos k)))
|
||||
" whose name is not a string, which JSON requires"))))
|
||||
;; The comparison in the generated reader is against the token's RAW
|
||||
;; text, which costs no allocation per key. That is only the same
|
||||
;; question as "is this the field" when the name has no escape in it —
|
||||
;; so a name that has one is refused here rather than silently compared
|
||||
;; wrongly. Nothing writes "port" for "port"; what this catches is
|
||||
;; the file where it would have mattered.
|
||||
(let [raw (.text k)]
|
||||
(when (has-escape? raw)
|
||||
(return (derived-bad
|
||||
(joined3 "the member name at " (where src (.pos k))
|
||||
" has an escape in it. A generated reader compares a key against the bytes as written, which costs nothing per key and is only the same question when the name is written plainly — so this one is refused rather than matched wrongly"))))
|
||||
(when (not (name-like? raw))
|
||||
(return (derived-bad
|
||||
(joined3 "the member name at " (where src (.pos k))
|
||||
" is not a name a program could write, so there is no field it can become. A struct's fields are named; letters, digits, - and ? are what a name is made of"))))
|
||||
(expect c tok-colon)
|
||||
(let [fname (copy-of raw)
|
||||
d (derive c (joined3 name "-" fname) src)]
|
||||
(when (bad? d)
|
||||
(return d))
|
||||
(dotimes [i (len (.decls d))]
|
||||
(push decls (at (.decls d) i)))
|
||||
(push fields (Form.Sym {.s fname}))
|
||||
(push fields (.ty d))
|
||||
(let [dot (Form.Sym {.s (joined "." fname)})
|
||||
lit (Form.Str {.s fname})
|
||||
bit (Form.Int {.i (<< (i64 1) idx)})
|
||||
read1 (.reader d)]
|
||||
(push clauses `(key=? k ~lit))
|
||||
(push clauses `(do (set (~dot out) ~read1)
|
||||
(set seen (bit-or seen ~bit))))
|
||||
;; Checked where the object closes. The bit is decided here,
|
||||
;; beside the field, so the two cannot fall out of step the way a
|
||||
;; parallel list of names would.
|
||||
(push missing
|
||||
`(when (= (bit-and seen ~bit) 0)
|
||||
(signal (SchemaDrift {.field ~lit
|
||||
.struct ~(Form.Str {.s name})
|
||||
.extra? false
|
||||
.pos (.pos close)})))))
|
||||
(comma c)
|
||||
(set idx (+ idx 1))))))
|
||||
(expect c tok-object-close)
|
||||
;; A member the struct has no field for: the struct *is* the file, so an
|
||||
;; unknown name is the file having moved. Reported and then skipped, so a
|
||||
;; program that declines to handle the condition still reads the rest.
|
||||
(push clauses `:else)
|
||||
(push clauses
|
||||
`(do (signal (SchemaDrift {.field (match (string-of k) (Some s) s None "")
|
||||
.struct ~(Form.Str {.s name})
|
||||
.extra? true
|
||||
.pos (.pos k)}))
|
||||
(when (not (skip-value c))
|
||||
(return out))))
|
||||
(let [sname (Form.Sym {.s name})
|
||||
rname (Form.Sym {.s (joined "read-" name)})
|
||||
struct `(defstruct ~sname ~(Form.Vec {.xs (as-slice fields)}))
|
||||
reader
|
||||
`(defn ~rname [c (Ptr Cursor) a Allocator] ~sname
|
||||
(let [out (~sname {})
|
||||
seen 0]
|
||||
(expect c tok-object-open)
|
||||
(while (and (ok? c) (not (at-byte? c \})))
|
||||
(let [k (expect c tok-string)]
|
||||
(expect c tok-colon)
|
||||
(cond ~@(as-slice clauses))
|
||||
(comma c)))
|
||||
(let [close (expect c tok-object-close)]
|
||||
~@(as-slice missing))
|
||||
out))]
|
||||
(push decls struct)
|
||||
(push decls reader)
|
||||
(ok-derived sname (as-slice decls) `(~rname c a)))))
|
||||
|
||||
;; ── The small predicates the refusals are written against ───────────
|
||||
|
||||
;; The generated reader's key test. Against the raw interior, which is the
|
||||
;; whole of why a name with an escape in it is refused above.
|
||||
(defn key=? [t Token s string] bool
|
||||
(bytes=? (.text t) (bytes s)))
|
||||
|
||||
(defn has-escape? [s [u8]] bool
|
||||
(dotimes [i (len s)]
|
||||
(when (= (at s i) \\)
|
||||
(return true)))
|
||||
false)
|
||||
|
||||
;; What a field name may be made of. Deliberately narrower than what the reader
|
||||
;; would accept: this is the set a *person* would recognise as a name, and a
|
||||
;; member called "a b" or "x.y" has no field it could become.
|
||||
(defn name-like? [s [u8]] bool
|
||||
(when (= (len s) 0)
|
||||
(return false))
|
||||
(dotimes [i (len s)]
|
||||
(let [b (at s i)]
|
||||
(when (not (or (alpha? b)
|
||||
(or (digit? b)
|
||||
(or (= b \-) (or (= b \?) (or (= b \_) (= b \!)))))))
|
||||
(return false))))
|
||||
true)
|
||||
|
||||
;; A copy of a token's raw text as a string. The Vec header is dropped here on
|
||||
;; purpose: this runs inside the compiler, where an expansion is bounded by the
|
||||
;; size of the program being compiled.
|
||||
(defn copy-of [s [u8]] string
|
||||
(let [b (vec-new u8)]
|
||||
(append (addr b) s)
|
||||
(string (as-slice b))))
|
||||
|
||||
;; ── Comparing and rendering a type form ─────────────────────────────
|
||||
|
||||
(defn same-type? [a Form b Form] bool
|
||||
(bytes=? (bytes (render a)) (bytes (render b))))
|
||||
|
||||
;; A type form as text, for the refusals. Only the shapes this file builds — a
|
||||
;; name and `(Vec T)` — because nothing else ever reaches it.
|
||||
(defn render [f Form] string
|
||||
(match f
|
||||
(Form.Sym s) s
|
||||
(Form.Int i) (i64->string i)
|
||||
(Form.List xs) (joined3 "(" (render-items xs) ")")
|
||||
(Form.Vec xs) (joined3 "[" (render-items xs) "]")
|
||||
_ "?"))
|
||||
|
||||
(defn render-items [xs [Form]] string
|
||||
(let [out ""]
|
||||
(dotimes [i (len xs)]
|
||||
(set out (if (= i 0)
|
||||
(render (at xs i))
|
||||
(joined3 out " " (render (at xs i))))))
|
||||
out))
|
||||
|
||||
;; ── The macro ───────────────────────────────────────────────────────
|
||||
;;
|
||||
;; `(json/defjson Config "config.json")`. The path is relative to the file this
|
||||
;; is written in, exactly as `(embed "config.json")` is — see `macro-slurp` in
|
||||
;; the prelude, and check.ml's `embed_path`, which is the rule it copies.
|
||||
;;
|
||||
;; It answers a `do`, which the top level splices: the nested structs innermost
|
||||
;; first, then the struct named here, a reader per struct, and the two entry
|
||||
;; points over the whole thing.
|
||||
(defmacro defjson [args]
|
||||
(if (!= (len args) 2)
|
||||
(refuse "defjson is (defjson Name \"path.json\") — a name for the struct, and a path to the file its shape is read out of")
|
||||
(match (at args 1)
|
||||
(Form.Str path)
|
||||
(match (at args 0)
|
||||
(Form.Sym name)
|
||||
(match (macro-slurp path)
|
||||
(Some src) (provide name path src)
|
||||
None (refuse
|
||||
(joined3 "there is no file at " path
|
||||
", read relative to the file this defjson is written in — the same place (embed \"...\") would look")))
|
||||
_ (refuse "defjson's first argument is the name of the struct to declare, written as a name"))
|
||||
_ (refuse "defjson's second argument is the path to the data file, written as a string literal — the file is read while this is being compiled, so there is nothing here to compute a path from"))))
|
||||
|
||||
(defn provide [name string path string src [u8]] Form
|
||||
(let [cur (cursor src)
|
||||
d (derive (addr cur) name src)]
|
||||
(if (bad? d)
|
||||
(refuse (.bad d))
|
||||
(if (not (= (.kind (next (addr cur))) tok-eof))
|
||||
(refuse (joined path " holds more than one value, and a defjson derives one struct from one"))
|
||||
(let [sname (Form.Sym {.s name})
|
||||
rname (Form.Sym {.s (joined "read-" name)})
|
||||
bname (Form.Sym {.s (joined name "-of-bytes")})
|
||||
fname (Form.Sym {.s (joined name "-read-file")})]
|
||||
`(do
|
||||
~@(.decls d)
|
||||
;; Both entry points take the allocator the struct's own fields are
|
||||
;; built in: a string field is a copy and a Vec field is an
|
||||
;; allocation, and spec-memory's rule is that the destination is
|
||||
;; never implicit.
|
||||
(defn ~bname [b [u8] a Allocator] ~sname
|
||||
(let [c (cursor b)
|
||||
out (~rname (addr c) a)]
|
||||
;; The cursor is made and dropped here, so this is the only
|
||||
;; place that can ask whether the read worked. See ReadFailed.
|
||||
(when (not (ok? (addr c)))
|
||||
(signal (ReadFailed {.struct ~(Form.Str {.s name})
|
||||
.code (.err c)
|
||||
.pos (.err-pos c)})))
|
||||
out))
|
||||
(defn ~fname [p string a Allocator] ~sname
|
||||
(let [b (slurp p a)]
|
||||
(~bname (as-slice b) a)))))))))
|
||||
|
||||
;; A refusal, as a declaration. `compile-error` is an expression and a top-level
|
||||
;; position takes a declaration, so it goes in the body of a function nothing
|
||||
;; calls: the checker walks it, the arm fires, and `Loc.from_macro` has already
|
||||
;; put the report on the `defjson` the author wrote. The name is a gensym, so
|
||||
;; two refusals in one file are two reports rather than a name defined twice.
|
||||
(defn refuse [msg string] Form
|
||||
`(defn ~(gensym) [] () (compile-error ~(Form.Str {.s msg}))))
|
||||
Loading…
x
Reference in New Issue
Block a user