The dev runtime rendered a value twice over: [flan_dev_emit_{u64,i64,f64,str}]
into the result buffer an evaluation is read back from, and
[flan_dev_watch_emit_*] into the current watch slot, with the same four bodies
either side and the sink as the only difference. [flan_dev_result_end] and
[flan_dev_watch_end] were the same ellipsis-and-generation close, comment for
comment, over two buffers.
So the rendering takes the sink as a parameter and the eight entry points are
eight one-line calls into four statics. The exports stay eight: the compiler
emits four of them by name (Session.externs) and a program reaches the watch
four through declare-c. ABI does not collapse because the bodies did.
The escape table is now [flan_escape_char] in flan_rt.c, once: what one byte
reads as inside a quoted string, into a caller's four bytes. A table and not a
printer, because the framings are genuinely different — [flan_escape_bytes]
builds a capped slice to hand back and the dev pair streams into a buffer it
does not own the end of — and the framing is the part that is each caller's.
The dyn printer keeps its own copy, which is docs/SPIKE-DUPLICITY.md §9's one
defended repeat: it is inside the runtime that owns the storage it walks. Its
comment, and flan_rt.c's, no longer tell a reader to change the other two.
Same for the NaN rule, which was spelled four times: [flan_f64_format] is
flan_rt.c's [flan_f64_to_bytes] without the slice, and the REPL emitter and the
watch table call it rather than restating "%g, and nan unsigned".
Byte-identical, checked two ways. A session driven over the daemon's socket
before and after — every arm of the emit family, the escapes, and a string long
enough to reach the truncating close — diffs empty. And a harness linking both
trees' flan_rt.c + flan_dev.c compares 8173 renderings: every byte 0..255
through both string emitters and through flan_escape_bytes, every length across
both caps and the ellipsis either side of them, both NaN signs, both infinities,
i64 and u64 at their extremes. Identical.
Dead code, each verified by its own grep before removal. These are exported C
symbols, so a program could reach one through declare-c; the evidence is that
nothing in the tree does, including the docs that write the surface down.
flan_dev_watch_u64 (flan_dev.c) — one occurrence repo-wide, its own
definition. The i64/f64/str siblings are declare-c'd in
test/programs/dev-watch.flan and written down in emacs/MANUAL.md; this one
appears in neither, and in no other file.
flan_break_resume (flan_rt.c) — the only non-prose reference was a stale
extern in vendor/agent/flan_agent.c with no call under it. Both gone.
[flan_name_id] stays: the bounds and arithmetic conditions still hash through
it. [flan_restart_take]'s comment no longer points at a function that is not
there.
flan_dev_watch_enabled (flan_dev.c) — prototyped in flan_agent.c, never
called. [watch_on] is still read directly by the three sites that gate on it.
clang_stamp (lib/build.ml) — a [lazy] never forced; one occurrence.
marshal (lib/expand.ml) — no reference anywhere. [write], which it wrapped,
is called twice in [call], so the [let rec] group is demoted to keep it.
is_bytes (lib/js.ml) — dead within js.ml. Nothing else in that file is
touched: the JS backend is parked, not dead.
Kept on purpose: [Loc.forget_sources], documented in docs/BUILT.md as
deliberately retained, and [flan_dev_watch_num_f64], which emacs/MANUAL.md
declares as public surface.
Two comments in lib/dev.ml argued the orphan grace in terms of elisp symbols
from before the rename — [flan-dev--open], [flan-dev--connection],
[flan-dev-poll-interval], and a file called emacs/flan-dev.el. None of those
exist. Re-spelled as [flan--open], [flan--connection], [flan-poll-interval] and
emacs/flan.el, which is where they are; the reasoning is load-bearing and is
unchanged.
dune test: exit 0. 59 lines of code out, 24 lines net of the prose that says
why.
Flan is an experimental, ahead-of-time compiled Lisp for programs that need predictable memory use and a fast edit–run 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
VecandMapcontainers, 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
- web/index.html — language reference and fuller examples.
- spec-memory.md — ownership, containers, and generics.
- spec-conditions.md — conditions, handlers, and restarts.
- emacs/MANUAL.md — the interactive editor workflow.
- docs/BUILT.md — implementation rationale.
- NEXT.md — current work and known limits.
License
Flan is released under the MIT License. Third-party material under
vendor/ is distributed under its own licenses.