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.
220 lines
8.0 KiB
C
220 lines
8.0 KiB
C
/* flan_dev — the part of the host ABI that only a dev build has.
|
|
*
|
|
* A redefinition module reaches the host's functions and globals through
|
|
* symbols the host already exports: a cell for each function, the storage for
|
|
* each global. That covers everything the program was *built* with. It does
|
|
* not cover a name the module introduces — a defn or a defvar typed into the
|
|
* REPL after the process started — because there is no symbol in the host to
|
|
* bind to and ELF cannot grow one.
|
|
*
|
|
* So a name that is new at run time is keyed by string instead. This file is
|
|
* the two lookups that make that work, and deliberately nothing else:
|
|
*
|
|
* 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_emit(...) where an evaluated expression's rendering
|
|
* goes, piece by piece, to be read back
|
|
*
|
|
* 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
|
|
* copy of a new function would each call their own, and redefining it would
|
|
* update one of them.
|
|
*
|
|
* The table never moves. A module holds the address of a cell for as long as
|
|
* it is loaded, so a growable table would leave those addresses pointing into
|
|
* a freed allocation. Fixed capacity and a loud failure instead.
|
|
*
|
|
* Never dlclose a module. A cell holds an address inside that module's text,
|
|
* and unloading it leaves every call site pointing at unmapped memory. There
|
|
* is no unload path here on purpose.
|
|
*/
|
|
|
|
#include <stdint.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
|
|
#define FLAN_DEV_MAX 4096
|
|
|
|
typedef struct {
|
|
const char *name; /* strdup'd: the module that passed it may go away */
|
|
void *cell; /* a function's cell, or a global's storage */
|
|
size_t size; /* a global's size; 0 for a function */
|
|
} entry;
|
|
|
|
static entry table[FLAN_DEV_MAX];
|
|
static size_t used;
|
|
|
|
static void die(const char *what, const char *name) {
|
|
fprintf(stderr, "flan_dev: %s: %s\n", what, name);
|
|
fflush(stderr);
|
|
abort();
|
|
}
|
|
|
|
static entry *find(const char *name) {
|
|
for (size_t i = 0; i < used; i++)
|
|
if (strcmp(table[i].name, name) == 0) return &table[i];
|
|
return NULL;
|
|
}
|
|
|
|
static entry *intern(const char *name) {
|
|
if (used == FLAN_DEV_MAX) die("out of dev name slots", name);
|
|
entry *e = &table[used++];
|
|
e->name = strdup(name);
|
|
if (e->name == NULL) die("out of memory", name);
|
|
e->cell = NULL;
|
|
e->size = 0;
|
|
return e;
|
|
}
|
|
|
|
/* The cell a run-time-introduced function is called through. One indirection
|
|
* more than a function the host was built with, whose cell is a symbol the
|
|
* module can name directly — the compiler picks per name, so the common case
|
|
* stays a single load. */
|
|
void **flan_dev_cell(const char *name) {
|
|
entry *e = find(name);
|
|
if (e == NULL) e = intern(name);
|
|
return &e->cell;
|
|
}
|
|
|
|
/* Storage for a run-time-introduced global, allocated once.
|
|
*
|
|
* [init] is its declared initial value, or NULL for all-zero. It is copied on
|
|
* the allocation and ignored on every call after it, which is where "a reload
|
|
* must not reset the program's state" lives: the second module to mention this
|
|
* name is a redefinition, and re-running an initialiser would throw away
|
|
* exactly what the reload exists to preserve. Doing it here rather than by a
|
|
* branch in the caller means the rule cannot be got wrong at one call site.
|
|
*
|
|
* A size mismatch is the layout-drift failure, caught at its first chance: the
|
|
* running process has already laid this memory out, and handing back the old
|
|
* allocation for a differently shaped type means the new body reads fields at
|
|
* the wrong offsets and nothing ever says so. Retyping a var needs a restart. */
|
|
void *flan_dev_global(const char *name, uint64_t size, const void *init) {
|
|
entry *e = find(name);
|
|
if (e == NULL) {
|
|
e = intern(name);
|
|
e->cell = calloc(1, size ? (size_t)size : 1);
|
|
if (e->cell == NULL) die("out of memory", name);
|
|
e->size = (size_t)size;
|
|
if (init != NULL && size > 0) memcpy(e->cell, init, (size_t)size);
|
|
return e->cell;
|
|
}
|
|
if (e->size != (size_t)size) die("size changed; restart to retype", name);
|
|
return e->cell;
|
|
}
|
|
|
|
/* ── The value of an evaluated expression ──────────────────────────── */
|
|
|
|
/* C-x C-e compiles a thunk that renders one expression and emits it here, a
|
|
* piece at a time. It is not written to stdout: stdout belongs to the program,
|
|
* it is in the hot path for anything that prints, and a dev-only feature must
|
|
* 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
|
|
* on the game thread at a frame boundary, whenever that happens to be; the
|
|
* daemon waits for the counter to move rather than guessing it has. */
|
|
|
|
#define RESULT_MAX 4096
|
|
static char result[RESULT_MAX];
|
|
static size_t result_len;
|
|
static int result_full;
|
|
static uint64_t generation;
|
|
|
|
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;
|
|
if (result_len + n > RESULT_MAX) {
|
|
n = RESULT_MAX - result_len;
|
|
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. */
|
|
__atomic_store_n(&generation, generation + 1, __ATOMIC_RELEASE);
|
|
}
|
|
|
|
const char *flan_dev_result_get(uint64_t *gen, uint64_t *len) {
|
|
*gen = __atomic_load_n(&generation, __ATOMIC_ACQUIRE);
|
|
*len = (uint64_t)result_len;
|
|
return result;
|
|
}
|