Printers for every shape a value can have

C-x C-e rendered the scalars and refused the rest, which made it a calculator
rather than a REPL. The renderer is now a compile-time walk over the type,
emitting a piece at a time: structs, nested structs, fixed arrays, slices,
options, enums by name, and pointers as their shape. A raylib Color comes back
through the FFI as (rl/Color {:r 17 :g 34 :b 51 :a 68}).

Piecewise emission is what makes composites possible at all - a struct is its
fields with punctuation between them, and concatenating that in generated IR
would need an allocator the language does not have.

u64 now renders, in C, with %llu. It used to refuse because i64->bytes is
signed and it would otherwise come back as -1, but refusing a whole struct
because one field is a u64 is much worse than adding a runtime entry point.
Strings are quoted and escaped in C for the same reason: unescaped content does
not round-trip and reads as a framing bug rather than as the value it is.

An enum renders as :name, recovered from the checker's table as a chain of
comparisons, since members are erased to i32 before the backend sees them; a
value outside the declared members falls through to its number, which is what
you would want to see. A pointer is rendered and never followed - it is the
only thing that could make the walk cycle, and dereferencing one a REPL was
handed is not a safe thing to do on someone's behalf.

Three bounds, easy to conflate. depth and span bound the walk, so sand's
[100 [100 u32]] grid does not unroll into ten thousand render sites. The output
is bounded once in the runtime, since a slice renders through a loop the
compiler cannot bound, and one place enforcing it means no renderer carries a
budget.

emit.ml's cast now treats an enum as the i32 it is. Nothing in the surface
language produces that - a keyword resolves against its enum and never widens -
but the renderer needs an enum's number when it falls outside the members.
This commit is contained in:
Joseph Ferano 2026-09-11 07:17:46 +07:00
parent 44e199186e
commit 20fedd4ad8
7 changed files with 404 additions and 70 deletions

45
NEXT.md
View File

@ -737,11 +737,46 @@ socket. The read is safe without a handshake because `flan_dev_result` bumps a
generation counter last; the daemon waits for it to move rather than assuming generation counter last; the daemon waits for it to move rather than assuming
the program has reached a frame boundary. the program has reached a frame boundary.
What renders: the integers, the floats, `bool`, `string`, `[u8]`, `Unit`, and The renderer is a **compile-time walk over the type**, emitting a piece at a
an enum (as its number — enum members are erased to `i32` before the backend time through `flan_dev_emit`. Piecewise because a struct is its fields with
sees them). What refuses, by name: everything else, and **`u64` specifically**, punctuation between them, and concatenating that in generated IR would need an
because `i64->bytes` is signed and anything past 2⁶³ would come back negative. allocator the language does not have.
Refusing beats a number that is quietly wrong.
```
big 18446744073709551615
col :blue
(.pos b) (V {:x 1.5 :y 0})
b (Blob {:id 7 :name "sandy \"quoted\"" :pos (V {:x 1.5 :y 0}) :tags [ 0 42 0]})
(slice (.tags b) 0 3) [ 0 42 0]
(rl/get-color 0x11223344) (rl/Color {:r 17 :g 34 :b 51 :a 68})
sim/grid [ [ 0 0 0 0 0 0 0 0 ...] [ 0 ... ] ...]
```
Details that are decisions rather than formatting:
- **`u64` renders in C**, with `%llu`. The language's own `i64->bytes` is
signed, so it used to refuse rather than come back as `-1` — but refusing a
whole struct because one field is a `u64` is much worse, so the runtime got
an entry point instead.
- **Strings are quoted and escaped**, also in C. Unescaped content does not
round-trip and reads as a framing bug rather than as the value it is.
- **An enum renders as `:name`**, recovered from the checker's table as a chain
of comparisons, because members are erased to `i32` before the backend sees
them. A value outside the declared members falls through to its number, which
is exactly what you would want to see.
- **A pointer is never followed**`<ptr>`. It is the only thing that could
make the walk cycle, and dereferencing one a REPL was handed is not a safe
thing to do on someone's behalf.
- **Three separate bounds**, easy to conflate. `depth` (4) and `span` (8) bound
the *walk*, so `[100 [100 u32]]` does not become ten thousand render sites in
one module. The *output* is bounded once in the runtime — `emit` truncates at
4K and `end` appends `...` — because a slice renders through a loop the
compiler cannot bound, and one place enforcing it means no renderer carries a
budget.
- A slice is the one case needing a runtime loop, and the slice goes into a
slot first so the expression it came from is not evaluated once per element.
What still refuses by name: `Map`, `Fn`, a type variable.
A caveat inherited from the language, not introduced here: `3.0` renders as A caveat inherited from the language, not introduced here: `3.0` renders as
`3`, indistinguishable from the integer. `flan run calc-me.flan "1.5 * 2.0"` `3`, indistinguishable from the integer. `flan run calc-me.flan "1.5 * 2.0"`

View File

@ -742,7 +742,15 @@ and shim_out f name (x : Tast.expr) =
and cast f (x : Tast.expr) target = and cast f (x : Tast.expr) target =
let v = value f x in let v = value f x in
let src = x.Tast.ty in (* An enum is an i32 at run time and its own type only in the checker, so a
cast involving one is a cast on that i32. Nothing in the surface language
produces this a keyword resolves against the enum and never widens but
the REPL's renderer needs an enum's number when it falls outside the
declared members. *)
let concrete (t : Types.t) =
match t with Types.Enum _ -> Types.Int Types.I32 | t -> t
in
let src = concrete x.Tast.ty and target = concrete target in
if Types.equal src target then v if Types.equal src target then v
else else
let op = let op =

View File

@ -349,51 +349,204 @@ let eval ?(origin = "<eval>") t src : change =
expression is wrapped in a function that has nowhere to be called from, and expression is wrapped in a function that has nowhere to be called from, and
the module says "run this once". The agent does, at a frame boundary. the module says "run this once". The agent does, at a frame boundary.
Getting the value back does not marshal anything. The compiler knows the Getting the value back does not marshal anything. A Flan value carries no
expression's type, so the thunk renders it to bytes here, at compile time, header, so nothing at run time could say what it is; the compiler knows the
and hands them to the runtime which is the only thing that *can* work, type and renders it *there*, in the thunk. That is the layout decision's
since a Flan value carries no header and nothing at run time could tell what bill, paid here and it is why the renderer is a compile-time walk over the
it is. That is the layout decision's bill, paid here. type rather than a function in the runtime.
The rendering goes to [flan_dev_result], not to stdout: stdout belongs to The rendering goes to [flan_dev_emit], a piece at a time, not to stdout.
the program, it is in the hot path for anything that prints, and a dev-only Piecewise because a struct is its fields with punctuation between them and
feature must not put a branch in it. *) concatenating that in generated IR would need an allocator the language does
not have; not stdout because stdout belongs to the program, is in the hot
path for anything that prints, and a dev-only feature must not put a branch
in it. *)
let result_sym = "flan/dev-result" type emitter = { ename : string; ety : Types.t }
let result_extern : Tast.extern = let emit_bytes = { ename = "flan/dev-emit"; ety = Types.Slice (Types.Int Types.U8) }
{ Tast.ename = result_sym; esym = "flan_dev_result"; let emit_str = { ename = "flan/dev-emit-str"; ety = Types.Slice (Types.Int Types.U8) }
eparams = [ Types.Slice (Types.Int Types.U8) ]; eret = Types.Unit } let emit_i64 = { ename = "flan/dev-emit-i64"; ety = Types.Int Types.I64 }
let emit_u64 = { ename = "flan/dev-emit-u64"; ety = Types.Int Types.U64 }
let emit_f64 = { ename = "flan/dev-emit-f64"; ety = Types.Float Types.F64 }
(* The scalars, and nothing else yet. A struct, an (Option T) or a slice of let externs : Tast.extern list =
structs needs a printer derived per type, which is real work; refusing by let one e sym = { Tast.ename = e.ename; esym = sym; eparams = [ e.ety ];
name is the house rule, and a wrong rendering would be the silent kind. *) eret = Types.Unit } in
let render (e : Tast.expr) : Tast.expr = [ one emit_bytes "flan_dev_emit";
one emit_str "flan_dev_emit_str";
one emit_i64 "flan_dev_emit_i64";
one emit_u64 "flan_dev_emit_u64";
one emit_f64 "flan_dev_emit_f64";
{ Tast.ename = "flan/dev-begin"; esym = "flan_dev_result_begin";
eparams = []; eret = Types.Unit };
{ Tast.ename = "flan/dev-end"; esym = "flan_dev_result_end";
eparams = []; eret = Types.Unit } ]
(* Two separate limits, easily conflated. [depth] and [span] bound the *walk*,
so a big fixed array or a self-containing struct cannot turn one expression
into a module with ten thousand render sites in it. How much text actually
comes out is bounded in the runtime instead, once, for every renderer. *)
let max_depth = 4
let max_span = 8
type ctx = {
structs : Tast.structure list;
enums : (string * (string * int64) list) list;
(* Slots a rendered loop needs. The thunk's frame grows as the walk finds
slices in it. *)
mutable slots : Types.t list; (* reversed *)
mutable nslots : int;
}
let slot c ty =
let i = c.nslots in
c.nslots <- i + 1;
c.slots <- ty :: c.slots;
i
let rec render c depth (e : Tast.expr) : Tast.expr list =
let loc = e.Tast.loc in let loc = e.Tast.loc in
let bytes = Types.Slice (Types.Int Types.U8) in let unit_ e = { Tast.e; ty = Types.Unit; loc } in
let call em x = unit_ (Tast.Call (em.ename, [ x ])) in
let cast t x = { Tast.e = Tast.Prim (Tast.Cast t, [ x ]); ty = t; loc } in let cast t x = { Tast.e = Tast.Prim (Tast.Cast t, [ x ]); ty = t; loc } in
let prim p x = { Tast.e = Tast.Prim (p, [ x ]); ty = bytes; loc } in let bytes_of s =
let str s = { Tast.e = Tast.Str s; ty = Types.String; loc } in { Tast.e = Tast.Prim (Tast.Bytes, [ { Tast.e = Tast.Str s; ty = Types.String; loc } ]);
ty = Types.Slice (Types.Int Types.U8); loc }
in
let lit s = call emit_bytes (bytes_of s) in
let int64 n = { Tast.e = Tast.Int (n, Types.I64); ty = Types.Int Types.I64; loc } in
let i32 n =
{ Tast.e = Tast.Int (Int64.of_int n, Types.I32); ty = Types.Int Types.I32; loc }
in
let do_ xs = unit_ (Tast.Do xs) in
if depth > max_depth then [ lit "..." ]
else
match e.Tast.ty with match e.Tast.ty with
| Types.Int Types.U64 -> | Types.Int Types.U64 -> [ call emit_u64 e ]
(* i64->bytes is signed, so anything above 2^63 would render negative. | Types.Int _ -> [ call emit_i64 (cast (Types.Int Types.I64) e) ]
Refusing beats a number that is quietly wrong. *) | Types.Float _ -> [ call emit_f64 (cast (Types.Float Types.F64) e) ]
fail loc "no printer for u64 yet — its rendering would be signed"
| Types.Int _ -> prim Tast.I64ToBytes (cast (Types.Int Types.I64) e)
| Types.Enum _ ->
(* An enum is an i32 at run time and its members are not carried into the
backend, so this is the number and not the name. *)
prim Tast.I64ToBytes (cast (Types.Int Types.I64) e)
| Types.Float _ -> prim Tast.F64ToBytes (cast (Types.Float Types.F64) e)
| Types.Bool -> | Types.Bool ->
{ Tast.e = Tast.If (e, prim Tast.Bytes (str "true"), prim Tast.Bytes (str "false")); [ unit_ (Tast.If (e, lit "true", lit "false")) ]
ty = bytes; loc } | Types.Unit -> [ lit "()" ]
| Types.String -> prim Tast.Bytes e | Types.String ->
| Types.Slice (Types.Int Types.U8) -> e [ call emit_str
| Types.Unit -> prim Tast.Bytes (str "()") { Tast.e = Tast.Prim (Tast.Bytes, [ e ]);
ty = Types.Slice (Types.Int Types.U8); loc } ]
(* Bytes are almost always text, and escaping makes the case where they are
not readable rather than a mess. *)
| Types.Slice (Types.Int Types.U8) -> [ call emit_str e ]
(* An enum's members are erased to i32 before the backend sees them, so the
name has to be recovered here, from the checker's table, as a chain of
comparisons. Falling through to the number is not a failure: a value
outside the declared members is exactly what you would want to see. *)
| Types.Enum n ->
let members = try List.assoc n c.enums with Not_found -> [] in
let number = call emit_i64 (cast (Types.Int Types.I64) e) in
List.fold_left
(fun otherwise (name, v) ->
let is =
{ Tast.e =
Tast.Prim (Tast.Eq,
[ cast (Types.Int Types.I64) e; int64 v ]);
ty = Types.Bool; loc }
in
unit_ (Tast.If (is, lit (":" ^ name), otherwise)))
number members
|> fun x -> [ x ]
(* A pointer is rendered as its shape and never followed: it is the only
thing that could make this walk cycle, and dereferencing one a REPL was
handed is not a safe thing to do on someone's behalf. *)
| Types.Ptr _ -> [ lit "<ptr>" ]
| Types.Option t ->
let tag = { Tast.e = Tast.Field (e, 0); ty = Types.Int Types.I8; loc } in
let some = { Tast.e = Tast.Field (e, 1); ty = t; loc } in
let is_some =
{ Tast.e =
Tast.Prim (Tast.Ne,
[ tag; { Tast.e = Tast.Int (0L, Types.I8);
ty = Types.Int Types.I8; loc } ]);
ty = Types.Bool; loc }
in
[ unit_
(Tast.If (is_some,
do_ ((lit "(some " :: render c (depth + 1) some) @ [ lit ")" ]),
lit "none")) ]
| Types.Named n ->
(match
List.find_opt (fun (s : Tast.structure) -> String.equal s.Tast.sname n)
c.structs
with
| None -> [ lit ("<" ^ n ^ ">") ]
| Some st ->
let fields = st.Tast.fields in
let shown = List.filteri (fun i _ -> i < max_span) fields in
let parts =
List.concat
(List.mapi
(fun i (f : Tast.field) ->
let v = { Tast.e = Tast.Field (e, i); ty = f.Tast.fty; loc } in
(if i = 0 then [] else [ lit " " ])
@ [ lit (":" ^ f.Tast.fname ^ " ") ]
@ render c (depth + 1) v)
shown)
in
[ do_ ((lit ("(" ^ n ^ " {") :: parts)
@ (if List.length fields > max_span then [ lit " ..." ] else [])
@ [ lit "})" ]) ])
(* A fixed array's length is in its type, so it unrolls — capped, because
sand's grid is [100 [100 u32]] and unrolling that is ten thousand render
sites in one module. *)
| Types.Array (n, t) ->
let shown = min (Int64.to_int n) max_span in
let parts =
List.concat
(List.init shown (fun i ->
let v =
{ Tast.e = Tast.Prim (Tast.At, [ e; i32 i ]); ty = t; loc }
in
lit " " :: render c (depth + 1) v))
in
[ do_ ((lit "[" :: parts)
@ (if Int64.to_int n > shown then [ lit " ..." ] else [])
@ [ lit "]" ]) ]
(* A slice's length is not known until it runs, so this is the one case
that needs a loop. The slice goes into a slot first: the expression it
came from must not be evaluated once per element. *)
| Types.Slice t ->
let sv = slot c e.Tast.ty and iv = slot c (Types.Int Types.I32) in
let local i ty = { Tast.e = Tast.Local i; ty; loc } in
let len =
{ Tast.e = Tast.Prim (Tast.Len, [ local sv e.Tast.ty ]);
ty = Types.Int Types.I32; loc }
in
let cond =
{ Tast.e = Tast.Prim (Tast.Lt, [ local iv (Types.Int Types.I32); len ]);
ty = Types.Bool; loc }
in
let elem =
{ Tast.e =
Tast.Prim (Tast.At, [ local sv e.Tast.ty; local iv (Types.Int Types.I32) ]);
ty = t; loc }
in
let step =
unit_
(Tast.Set
(Tast.Plocal iv,
{ Tast.e =
Tast.Prim (Tast.Add, [ local iv (Types.Int Types.I32); i32 1 ]);
ty = Types.Int Types.I32; loc }))
in
[ unit_
(Tast.Let
([ (sv, e); (iv, i32 0) ],
[ lit "[";
unit_
(Tast.While
(cond, (lit " " :: render c (depth + 1) elem) @ [ step ]));
lit "]" ])) ]
| t -> | t ->
fail loc "no printer for %s yet — only the scalars, bool and strings render" fail loc "no printer for %s" (Types.to_string t)
(Types.to_string t)
let eval_expr ?(origin = "<eval>") t src : change = let eval_expr ?(origin = "<eval>") t src : change =
let form = let form =
@ -402,16 +555,22 @@ let eval_expr ?(origin = "<eval>") t src : change =
| [] -> fail Loc.unknown "nothing to evaluate" | [] -> fail Loc.unknown "nothing to evaluate"
| _ :: f :: _ -> fail f.Form.loc "one expression at a time" | _ :: f :: _ -> fail f.Form.loc "one expression at a time"
in in
let checked, slots = Check.expression t.env (Parse.expr form) in let checked, base = Check.expression t.env (Parse.expr form) in
let c =
{ structs = t.program.Tast.structs;
enums = Hashtbl.fold (fun k v acc -> (k, v) :: acc) t.env.Check.enums [];
slots = []; nslots = Array.length base }
in
let loc = checked.Tast.loc in
let nullary n = { Tast.e = Tast.Call (n, []); ty = Types.Unit; loc } in
let body = let body =
[ { Tast.e = Tast.Call (result_sym, [ render checked ]); (nullary "flan/dev-begin" :: render c 0 checked) @ [ nullary "flan/dev-end" ]
ty = Types.Unit; loc = checked.Tast.loc } ]
in in
t.thunks <- t.thunks + 1; t.thunks <- t.thunks + 1;
let name = Printf.sprintf "eval/%d" t.thunks in let name = Printf.sprintf "eval/%d" t.thunks in
let thunk : Tast.fn = let thunk : Tast.fn =
{ Tast.name; params = []; slots; ret = Types.Unit; body; { Tast.name; params = []; ret = Types.Unit; body; floc = loc;
floc = checked.Tast.loc } slots = Array.append base (Array.of_list (List.rev c.slots)) }
in in
(* Built against the program but never spliced into it: an evaluation is not (* Built against the program but never spliced into it: an evaluation is not
a declaration, and adding one would leave the session carrying an eval/N a declaration, and adding one would leave the session carrying an eval/N
@ -419,7 +578,7 @@ let eval_expr ?(origin = "<eval>") t src : change =
let program = let program =
{ t.program with { t.program with
Tast.fns = t.program.Tast.fns @ [ thunk ]; Tast.fns = t.program.Tast.fns @ [ thunk ];
externs = t.program.Tast.externs @ [ result_extern ] } externs = t.program.Tast.externs @ externs }
in in
let ir = let ir =
Emit.redefinition ~dev:true ~known:(known t) ~call:name program ~fns:[ name ] Emit.redefinition ~dev:true ~known:(known t) ~call:name program ~fns:[ name ]

View File

@ -12,8 +12,8 @@
* *
* flan_dev_cell(name) the cell a new function lives in * flan_dev_cell(name) the cell a new function lives in
* flan_dev_global(name, size, init) the storage a new global lives in * flan_dev_global(name, size, init) the storage a new global lives in
* flan_dev_result(bytes, len) where an evaluated expression's rendering * flan_dev_emit(...) where an evaluated expression's rendering
* goes, for the daemon to read back * goes, piece by piece, to be read back
* *
* Both are idempotent: the second module to mention a name gets what the first * Both are idempotent: the second module to mention a name gets what the first
* one got. That is the whole point. Two modules that each define their own * one got. That is the whole point. Two modules that each define their own
@ -106,10 +106,20 @@ void *flan_dev_global(const char *name, uint64_t size, const void *init) {
/* ── The value of an evaluated expression ──────────────────────────── */ /* ── The value of an evaluated expression ──────────────────────────── */
/* C-x C-e compiles a thunk that renders one expression and calls this with the /* C-x C-e compiles a thunk that renders one expression and emits it here, a
* text. It is not written to stdout: stdout belongs to the program, it is in * piece at a time. It is not written to stdout: stdout belongs to the program,
* the hot path for anything that prints, and a dev-only feature must not put a * it is in the hot path for anything that prints, and a dev-only feature must
* branch in it. The daemon reads this back over the agent's socket instead. * not put a branch in it. The daemon reads this back over the agent's socket.
*
* Emitting piece by piece rather than returning one string is what makes a
* composite renderer possible at all a struct is its fields with punctuation
* between them, and concatenating that in the generated IR would mean an
* allocator the language does not have.
*
* The output bound lives here and nowhere else. A slice of a million elements
* renders with a loop the compiler cannot bound, so [emit] truncates and
* [end] says so with an ellipsis. One place enforcing it means no renderer has
* to carry a budget.
* *
* [generation] is what makes the read safe without a handshake. The thunk runs * [generation] is what makes the read safe without a handshake. The thunk runs
* on the game thread at a frame boundary, whenever that happens to be; the * on the game thread at a frame boundary, whenever that happens to be; the
@ -118,13 +128,86 @@ void *flan_dev_global(const char *name, uint64_t size, const void *init) {
#define RESULT_MAX 4096 #define RESULT_MAX 4096
static char result[RESULT_MAX]; static char result[RESULT_MAX];
static size_t result_len; static size_t result_len;
static int result_full;
static uint64_t generation; static uint64_t generation;
void flan_dev_result(const uint8_t *bytes, int64_t len) { void flan_dev_result_begin(void) {
result_len = 0;
result_full = 0;
}
void flan_dev_emit(const uint8_t *bytes, int64_t len) {
size_t n = len < 0 ? 0 : (size_t)len; size_t n = len < 0 ? 0 : (size_t)len;
if (n > RESULT_MAX) n = RESULT_MAX; if (result_len + n > RESULT_MAX) {
memcpy(result, bytes, n); n = RESULT_MAX - result_len;
result_len = n; result_full = 1;
}
memcpy(result + result_len, bytes, n);
result_len += n;
}
static void emit_cstr(const char *s) {
flan_dev_emit((const uint8_t *)s, (int64_t)strlen(s));
}
/* Rendered in C so that u64 is not a lie: the language's own i64->bytes is
* signed, and anything past 2^63 would come back negative. */
void flan_dev_emit_u64(uint64_t x) {
char buf[32];
snprintf(buf, sizeof buf, "%llu", (unsigned long long)x);
emit_cstr(buf);
}
void flan_dev_emit_i64(int64_t x) {
char buf[32];
snprintf(buf, sizeof buf, "%lld", (long long)x);
emit_cstr(buf);
}
void flan_dev_emit_f64(double x) {
char buf[64];
snprintf(buf, sizeof buf, "%g", x);
emit_cstr(buf);
}
/* Quoted and escaped, in C, because doing it in the generated IR would be a
* loop per string and the language has no allocator to build the result in.
* A string whose content is not escaped does not round-trip and reads as a
* framing bug rather than as the value it is. */
void flan_dev_emit_str(const uint8_t *bytes, int64_t len) {
size_t n = len < 0 ? 0 : (size_t)len;
emit_cstr("\"");
for (size_t i = 0; i < n; i++) {
unsigned char c = bytes[i];
switch (c) {
case '"': emit_cstr("\\\""); break;
case '\\': emit_cstr("\\\\"); break;
case '\n': emit_cstr("\\n"); break;
case '\t': emit_cstr("\\t"); break;
case '\r': emit_cstr("\\r"); break;
default:
if (c < 0x20) {
char buf[8];
snprintf(buf, sizeof buf, "\\x%02x", c);
emit_cstr(buf);
} else {
flan_dev_emit(&c, 1);
}
}
}
emit_cstr("\"");
}
void flan_dev_result_end(void) {
if (result_full) {
/* Room is made for it rather than assumed: the buffer is full by
* definition when this fires. */
const char *ell = "...";
size_t k = strlen(ell);
if (result_len > RESULT_MAX - k) result_len = RESULT_MAX - k;
memcpy(result + result_len, ell, k);
result_len += k;
}
/* Last, so a reader that sees the new generation sees the whole value. */ /* Last, so a reader that sees the new generation sees the whole value. */
__atomic_store_n(&generation, generation + 1, __ATOMIC_RELEASE); __atomic_store_n(&generation, generation + 1, __ATOMIC_RELEASE);
} }

View File

@ -7,8 +7,6 @@
(import agent "vendor:agent") (import agent "vendor:agent")
(defvar ticks i64) (defvar ticks i64)
(defconst step-by i64 3)
(defn step [] i64 (defn step [] i64
(set ticks (+ ticks 1)) (set ticks (+ ticks 1))
ticks) ticks)

View File

@ -0,0 +1,38 @@
;;;; The fixture C-x C-e is tested against: one value of every shape the
;;;; renderer knows, held in globals so an expression has something real to
;;;; read out of a running process.
;;;;
;;;; It keeps running rather than counting reloads, because a thunk only runs
;;;; when the program next reaches a frame boundary. (agent/wait 5) is both the
;;;; poll and the pacing — 5ms of nothing, which is what a frame is when there
;;;; is no frame.
(import agent "vendor:agent")
(defstruct V [x f32 y f32])
(defstruct Blob [id i32 name string pos V tags [3 i32]])
(defenum Colour [red 0 green 1 blue 2])
(defvar ticks i64)
(defvar big u64)
(defvar b Blob)
(defvar arr [4 i32])
(defvar col Colour)
;;; Read by nothing on purpose: it is here to be *evaluated*, so that C-x C-e
;;; is tested against a constant living in the program's memory and not only
;;; against arithmetic the compiler could have done itself.
(defconst step-by i64 3)
(defn main [] i32
(agent/start "/tmp/flan-printers-fallback.sock")
(set big 0xFFFFFFFFFFFFFFFF)
(set (.id b) 7)
(set (.name b) "sandy \"quoted\"")
(set (.x (.pos b)) 1.5)
(set (at (.tags b) 1) 42)
(set (at arr 2) 9)
(set col :blue)
(dotimes [i 4000]
(agent/wait 5)
(set ticks (+ ticks 1)))
0)

View File

@ -60,7 +60,7 @@ let () =
let flan = "../bin/main.exe" in let flan = "../bin/main.exe" in
let pid = let pid =
Unix.create_process flan Unix.create_process flan
[| flan; "dev"; "programs/dev-repl.flan"; "-s"; sock |] [| flan; "dev"; "programs/printers.flan"; "-s"; sock |]
Unix.stdin fd Unix.stderr Unix.stdin fd Unix.stderr
in in
Unix.close fd; Unix.close fd;
@ -82,9 +82,26 @@ let () =
in in
value "arithmetic" "(+ 1 2)" "3"; value "arithmetic" "(+ 1 2)" "3";
value "a comparison" "(< 1 2)" "true"; value "a comparison" "(< 1 2)" "true";
value "a string" "\"hi\"" "hi"; (* Quoted and escaped, in the runtime: a string whose content is not
(* A defconst: its value is in the program's rodata and this reads it. *) escaped does not round-trip and reads as a framing bug. *)
value "a string" "\"hi\"" "\"hi\"";
value "an escaped string" "(.name b)" "\"sandy \\\"quoted\\\"\"";
(* A defconst: its value is in the program's memory and this reads it. *)
value "a constant" "step-by" "3"; value "a constant" "step-by" "3";
(* Rendered in C, because the language's own i64->bytes is signed and
this would otherwise come back as -1. *)
value "u64 at its maximum" "big" "18446744073709551615";
(* A struct, nested, with a fixed array inside it. *)
value "a struct" "(.pos b)" "(V {:x 1.5 :y 0})";
value "a nested struct" "b"
"(Blob {:id 7 :name \"sandy \\\"quoted\\\"\" :pos (V {:x 1.5 :y 0}) :tags [ 0 42 0]})";
value "a fixed array" "arr" "[ 0 0 9 0]";
(* A slice's length is not known until it runs, so this one renders
through a loop rather than by unrolling. *)
value "a slice" "(slice (.tags b) 0 3)" "[ 0 42 0]";
(* An enum's members are erased to i32 before the backend sees them, so
the name is recovered from the checker's table. *)
value "an enum" "col" ":blue";
(* The one that proves it ran inside the process: the program increments (* The one that proves it ran inside the process: the program increments
[ticks] every frame, so two evaluations of it must disagree. A copy [ticks] every frame, so two evaluations of it must disagree. A copy
@ -101,9 +118,6 @@ let () =
program advancing" a b program advancing" a b
| _ -> fail "ticks did not evaluate"); | _ -> fail "ticks did not evaluate");
(* Types with no printer derived yet refuse by name rather than render
something plausible and wrong. u64 is its own case: i64->bytes is
signed, so anything past 2^63 would come back negative. *)
let refuses name code reason = let refuses name code reason =
let r = evals code in let r = evals code in
match field r "message" with match field r "message" with
@ -114,7 +128,6 @@ let () =
| Some m -> fail "%s said %S, wanted it to mention %S" name m reason | Some m -> fail "%s said %S, wanted it to mention %S" name m reason
| None -> fail "%s was accepted" name | None -> fail "%s was accepted" name
in in
refuses "u64" "rand-state" "no printer for u64";
refuses "a declaration" "(defvar nope i64)" ""; refuses "a declaration" "(defvar nope i64)" "";
refuses "an unknown name" "no-such-name" "unknown name"; refuses "an unknown name" "no-such-name" "unknown name";