Joseph Ferano c2d378957e The x86 backend and dyn finally meet, which is where the dev loop is
x86 is what flan dev takes by default and dyn is the iteration feature, so
a backend that refused dyn meant the two halves of the dev loop could not
be in the same program. The refusal was one arm of is_agg, and it said the
true thing: it was never the representation that was missing. A dyn is
uint64_t, a scalar in both calling conventions, classified by every rule
this file already had; every operation on one is a Tast.Rt primitive and
call_rt has always known how to make one of those. What the lane actually
cost was the collector's root discipline.

Which is emit.ml's, reused rather than rewritten: Emit.dyn_roots counts the
roots for both backends now, so the pushes and the pops balance because one
counter decides both ends, and the two backends root the same nodes because
there is one counter and not two. A zeroed frame slot per dyn slot and per
dyn-producing call, minted beside the channel and outside every scoped --
the bump allocator reclaims at the end of a statement and a slot minted in
the body would be handed out again while the collector still held its
address. Pushed from the body buffer, not the prologue's, because a call
clobbers the registers the prologue is still spilling from. And one pop in
the epilogue, which is the whole of why this backend needed no landing-pad
work for it: there is exactly one epilogue, and the return, the fall-through
and the transfer exit all arrive at it. emit.ml needs the same pop at five
separate rets.

The ABI point the dyn handoff left open for the integrator is settled by
reading the other side rather than by agreeing: flan_dyn.c's mark follows a
value only when the quiet-NaN prefix is set, and the zero word does not have
it, so a zeroed root decodes as the double 0.0 and is never an address
anything dereferences. Zero is safe for a reason. The header says so now.

And one line in dev.ml that was never x86's: the merged dev host resets the
condition stacks and the frame chain between runs, because main is
re-entered by longjmp and pops no frame -- and it never reset the root
stack, so every root a finished run pushed still named stack the next run
was about to write over. That gap was an LLVM dev build's too.

Verification, and one of the numbers is new. @x86: MATCH 129 -> 135, DIFFER
0, REFUSED 0 -- the five dyn programs off survey.sh's llvmonly list, which
is gone rather than empty, plus p13. dune test --force green, with --x86
acceptance rows beside the LLVM ones for all five dyn programs, dyn-boundary
asserted on the same exit 134 and the same sentence on both.

p13-dyn-collect.flan is the one that is not a formality. Nothing else in
this repository allocates past flan_dyn.c's one-megabyte floor, so nothing
else collects even once, so a program whose roots are entirely wrong passes
every output test there is -- the handoff wrote that about the stub and it
outlived the stub. p13 allocates several megabytes of garbage while holding
live values across it: at forty times the corpus size it peaks at 4MB of
RSS, which is the collector running many times over, and both backends
still print the same four lines.
2026-09-19 14:22:55 +07:00
2024-07-09 21:01:55 +10:00
2026-09-10 14:56:35 +07:00
2026-09-10 14:40:34 +07:00

Flan

Flan

A statically typed Lisp for native games and interactive development.

Flan is an experimental, ahead-of-time compiled Lisp for programs that need predictable memory use and a fast editrun loop. It combines S-expressions, static types, explicit ownership, and a development session that can replace a function in a running program without resetting its state.

It is being built around games, but the interesting part is broader: a compiled language where the running program remains available for inspection, experimentation, and small changes.

In practical terms: you get parentheses, a debugger that would like to have a conversation, and no garbage collector quietly choosing the dramatic moment to join your frame loop.

What it has

  • Native compilation through LLVM, plus an in-progress direct x86-64 backend.
  • C-like data layout: structs, fixed arrays, pointers, slices, and explicit allocation. There is no garbage collector.
  • Owned Vec and Map containers, plus checked moves and borrowing-oriented slice operations.
  • Generics, algebraic unions, enums, macros, packages, defer, and a C FFI.
  • Conditions and restarts for recoverable failures and interactive debugging.
  • A raylib package and a collection of ported raylib examples.
  • Native, WASI, and web build targets. The cross targets are useful but less complete than the native development workflow.

The project is exploratory software, not a stable language release. Some features are deliberately refused while their semantics are still undecided; the compiler aims to say why rather than quietly accepting a partial version. It has opinions, but at least they arrive as error messages.

Quick start

Building requires a current OCaml/Dune toolchain, LLVM/Clang, and the native C toolchain. Raylib is only needed for programs that use the bundled graphics package.

dune build
dune exec ./bin/main.exe -- run web/examples/hello.flan

To build a standalone native executable:

dune exec ./bin/main.exe -- build web/examples/hello.flan -o hello
./hello

The falling-sand demo uses raylib:

dune exec ./bin/main.exe -- run sand.flan

Once you are iterating regularly, put the built executable on your PATH if you want to use the shorter flan commands shown below.

The live development loop

Start a long-lived development session:

flan dev sand.flan

The program runs normally and publishes a local socket beside the source file. The bundled Emacs mode can attach to it, evaluate expressions in the live process, inspect a stopped program, and recompile a top-level function from the buffer. A body change takes effect on the next call; changing a function's signature is intentionally rejected. The program keeps its state, which is especially nice when you have finally arranged the sand into something almost worth saving.

To set up the mode:

(add-to-list 'load-path "~/path/to/flan/emacs")
(require 'flan-mode)

Then use M-x flan to start and attach, or C-c C-z to attach to a session started in a terminal. The editor workflow is documented in emacs/MANUAL.md.

A small example

(defstruct AssetMissing [id i32])

(defn load-asset [id i32] i32
  (signal (AssetMissing {.id id}))
  100)

(defn asset-or-placeholder [id i32] i32
  (restart-case (load-asset id)
    (use-placeholder [] -1)))

(defn main [] ()
  (handler-bind [(AssetMissing [_] (invoke-restart 'use-placeholder))]
    (println (asset-or-placeholder 7))))

Here a missing asset signals a typed condition. The handler chooses a restart, so execution continues with a placeholder instead of requiring error values to be threaded through every caller. See web/examples/restart.flan for a runnable version.

Commands

Eleven of them, and the four anyone starts with:

flan check <file.flan>                         type-check a program
flan run   <file.flan> [flags] [-- args...]    build and run it
flan build <file.flan> [-o out] [flags]        build a native executable
flan dev   <file.flan> [-s socket] [--llvm]    start a live development session

Useful build options include -O0-O3, --debug, --sanitize, --no-bounds-checks, --x86, and --target=wasm32-wasi|web. run takes the same build flags and keeps them: everything after -- goes to the program, and a dash-argument before it that run does not offer is refused rather than guessed at. run is native-only; cross-built output should be run with an appropriate WASI runtime or browser. A .wasm file is not a tiny native executable in a trench coat.

--x86 picks the hand-written backend (lib/x86.ml) instead of LLVM, and flan dev is the one command that takes it unasked: a dev session is what it was written for, it halves the C-c C-c round trip, and nothing it builds outlives the session. --llvm is how to ask for the other one there — for a program this backend refuses by name, for --debug, and for the inspector, which walks a shadow stack it does not push. Every other command here is LLVM by default and stays that way; emacs/MANUAL.md lists what the dev backend does not do.

The other seven. Four print a stage of the pipeline, which is how you find out what the compiler thinks it was given:

flan read   <file.flan>...   the forms the reader produced
flan parse  <file.flan>...   one line per declaration
flan shim   <file.flan>...   the C a (declare-c ...) generated
flan emit   <file.flan> [--x86] [--dev] [--debug] [--no-bounds-checks]
                             LLVM IR, or x86-64 assembly under --x86

One builds a redefinition module the way flan dev does, for scripting the loop without an editor:

flan reload <program.flan> <forms.flan> [-o out.so] [--debug] [--x86]

And two are the C binding tools, which are the most useful thing here that nothing else documents.

flan import-c — read a header, print the bindings it implies

It builds nothing and writes nothing. It reads a C header (through clang's own parser), prints the declare-c forms it would generate, and then prints every function it refused and the reason — which is the half that earns its keep, because "this binding is missing" and "this binding cannot exist" are different problems:

$ flan import-c test/headers/sample.h
(declare-c set-seed [seed u32] "set_seed")
(declare-c add-ints [a i32  b i32] i32 "add_ints")
(declare-c name-length [text string] i32 "name_length")
...
;; 8 imported, 12 refused, of 21 functions in test/headers/sample.h
;; refused name-of: name_of returns char *, and a string only crosses as a
;;   parameter — a C function that returns one returns something Flan has no
;;   owner for
;; refused printf-like: printf_like is variadic, and a wrapper cannot forward
;;   an argument list it does not know the shape of
;; refused file-time: file_time long has a width that differs between this
;;   project's own targets (64 bits on x86-64, 32 on wasm32), so no single
;;   Flan type is right for it

Hand the package's .flan files along with the header and it diffs against them too: a defstruct whose layout no longer matches the header's record is named, which is the failure that otherwise shows up as a wrong pixel.

$ flan import-c vendor/raylib/raylib-5.5.h vendor/raylib/*.flan

Anything after the header that is not a .flan file is passed to clang, so -I and -D work as they do anywhere else.

flan generate-c — write those bindings into the package

The same machinery, committing its answer. It takes a package directory, not a header: the header comes from the package's own headers file, at the version its link file names, because which version may be read is a property of the package and not of the command line. It reads every .flan in the directory except the one it writes, so hand-written declarations keep winning, and it writes generated.flan.

$ flan generate-c vendor/raylib
;; refused get-clipboard-text: GetClipboardText returns char *, and a string
;;   only crosses as a parameter — ...
;; refused load-shader: LoadShader Shader is a struct the package does not
;;   describe — add a defstruct for it, or keep a hand-written declare-c

It exits non-zero and writes nothing if the package and the header disagree. That is the point of it: the committed file is the one thing in the package with no second opinion, so the moment of writing it is the last moment at which the installed library can contradict it.

Environment

Thirteen variables the toolchain reads, none of which has to be set on a machine with clang, LLVM and binutils on PATH. Each exists for a machine where the thing is somewhere else, or is the wrong one.

Variable What it replaces Read at
FLAN_CLANG clang, which compiles the IR and the runtime's C lib/build.ml:14
FLAN_LLC llc, used only by the live loop lib/build.ml:886
FLAN_LD ld, used only by the live loop lib/build.ml:887
FLAN_AS as, used only by --x86 lib/build.ml:978
FLAN_OBJDUMP objdump, used only by the disassembly verb lib/dev.ml:2018
FLAN_OCAMLFIND ocamlfind, which a merged flan dev needs at run time lib/dev.ml:2898
FLAN_EMCC emcc, for --target=web lib/build.ml:23
FLAN_LIBDIR where flan.cmxa and flan.a are, if not beside the binary lib/dev.ml:3187
FLAN_CACHE_DIR the object cache, default $XDG_CACHE_HOME/flan/objcache lib/build.ml:84
FLAN_WASM_SYSROOT the wasi-libc sysroot, default /usr/wasm32-wasi lib/build.ml:239
FLAN_WASM_BUILTINS libclang_rt.builtins-wasm32.a, which is not in clang's resource directory on Fedora lib/build.ml:272
FLAN_WEB_SHELL the HTML shell a --target=web build wraps the module in lib/build.ml:458
FLAN_DEV_LEAKS set (to anything) to have a --dev build print what it still held at exit runtime/flan_dev.c:1084

${FLAN_RAYLIB_WEB} is not read by the compiler: it is expanded inside vendor/raylib/link, which is where a package writes a linker argument that has to differ per target. Any ${NAME} in a link file expands from the environment and an unset one is refused by name.

llc and ld have to match clang. The live loop does not call the clang driver at all — it goes llc + ld -shared + dlopen, which is what makes C-c C-c cost milliseconds. So an llc from a different LLVM release than clang breaks the dev loop while flan build keeps working perfectly, which is a confusing shape of failure to meet without warning.

Variables beginning FLAN_DEV_ other than FLAN_DEV_LEAKS, plus FLAN_AGENT_SOCKET and FLAN_COMPILER_STAMP, are internal: flan dev sets them across its own exec to hand the merged binary what it needs. Setting them by hand is not supported.

Checking it

dune test                    the suite. Seconds. Run it constantly.
dune build @checks           everything else that can fail. A couple of minutes.
dune build @sanitize         the corpus under ASan and UBSan.
dune build @valgrind         the corpus under memcheck. Tens of minutes.

dune test means "the language still works". @checks — which is @page, @x86 and @cells — means "and everything written down about it is still true": the reference page's examples still print what the page says, the hand-written x86 backend still agrees with LLVM, and a --dev build still calls through its indirection cells.

The two are separate on purpose. A suite that goes red because prose drifted teaches you to skim past red. Both of this repository's silent failures — two backend refusals that sat for a month, a page of examples that stopped compiling for two days — were found by accident, and neither would have survived one person typing one command.

.github/workflows/checks.yml is now that person: it runs dune build, dune test --force and dune build @checks on every push. What it cannot run is written down in the workflow itself rather than left to be discovered — raylib, emscripten, wasm32-wasi and lldb are all absent from an Ubuntu runner, and every one of them was already a self-skip, so the tick is green over less than it is on a machine with those installed. The habit that covers the rest is still worth keeping: a lane's handoff quotes @checks, the way the x86 handoffs already quote the survey's counts.

Project map

License

Flan is released under the MIT License. Third-party material under vendor/ is distributed under its own licenses.

Description
No description provided
Readme MIT 7.5 MiB
Languages
OCaml 67.2%
Emacs Lisp 15.2%
C 10.4%
HTML 2.9%
Standard ML 2.8%
Other 1.5%