Ce document de référence est rédigé en anglais, la langue de travail du dépôt. Les pages du site — le tour, les guides « je viens de… » et la bibliothèque — existent intégralement dans les deux langues.
Keal compiles to a single C11 translation unit. That one fact is the foundation of this whole plan: the C ABI is the lingua franca of every runtime worth talking to. Each language below either speaks it natively (C, C++, Rust), can be told to (Go), or exposes a C doorway into its virtual machine (Java, Kotlin via JNI). So the path is not six bridges — it is one bridge, widened in stages, plus one gateway per foreign runtime.
This document is the plan: what exists, what each stage adds, what it costs, and in what order to build it.
The generated C is C11 and portable, with one exception that decides the toolchain: overflow is checked with __builtin_mul_overflow and its siblings, which are GCC and Clang builtins. MSVC cannot compile the runtime, and a default Rust install on Windows brings exactly MSVC — so the compiler will build there and keal build will not, until a second toolchain is installed.
Install MinGW-w64 in a POSIX-threads flavour (the win32 and mcf flavours ship no pthread.h, and the actor scheduler needs one), or LLVM clang targeting mingw32. keal build looks for CC, then cc, gcc, clang, and takes the first that answers; keal doctor names the one it found. Where the only compiler present is cl.exe, the error says that rather than reporting no compiler at all — it is the difference between a puzzle and an instruction.
Working today, tested by the suite (extern_programs_build_and_run):
native """
#include <math.h>
double hypot3(double a, double b, double c) { return sqrt(a*a + b*b + c*c); }
"""
extern func hypot3(a: Float, b: Float, c: Float): Float
println(hypot3(1.0, 2.0, 2.0)) // 3.0
native "..." — C pasted verbatim into the generated translation unit.extern func name(...): Ret [= "symbol"] — a C symbol made callable, with the checker holding callers to the declared signature.extern proc name(...) [= "symbol"] — the same for a C function that returns void. The boundary keeps the language's own distinction rather than making a void function claim a result nobody reads; keal bindgen emits it for every void prototype it finds.keal build prog.keal extra.c extra.cpp — extra C/C++ sources compiled and linked in; any C++ among them switches the linker to c++ so its runtime is present. This is already C++ interop for anything wrapped in an extern "C" function.Int, Float and Bool cross, because they carry no ownership (docs/memory.md §6).Everything below widens that boundary or drives it from the other side.
Every other tier stands on this one. Status: 1a, 1b and 1c are shipped; 1d is deferred, with the reason recorded below.
1a. Strings across the boundary — SHIPPED. The ownership rule the memory model already dictates, explicit in the signature:
extern func parse(source: borrow String): Int // C reads, does not keep
extern func render(doc: Int): own String // C hands us a malloc'd buffer
borrow String passes the NUL-terminated const char*; the callee must not retain it past the call.own String on a result adopts the buffer: Keal counts it and free()s it with the string (keal_str_adopt in the runtime; a NULL from C reads as the empty string). free(), so the buffer has to come from malloc — and that is worth saying because the hosts this boundary exists for each have an allocator of their own that their own code reaches for by habit: PostgreSQL's palloc, Python's PyMem_Malloc, a JVM's NewStringUTF. Handing one of those back as own String corrupts the heap rather than failing, which is the quietest way to be wrong. Copy into malloc'd memory at the boundary, or hand back borrow String and keep the buffer.String crossing, in both directions, and rejects a mode anywhere else — misuse is a checked error with a note saying what to write.The other direction: a companion .c calling into Keal. keal emit-header prints a k_ prototype for every function that crosses, and a String crosses as an opaque KealStr*. Opaque needs a way to say something, so the header also declares the whole of it:
typedef struct KealStr KealStr;
KealStr* keal_abi_str_new(const char* bytes, int64_t len);
const char* keal_abi_str_bytes(KealStr* s);
int64_t keal_abi_str_len(KealStr* s);
KealStr* keal_abi_str_retain(KealStr* s);
void keal_abi_str_release(KealStr* s);
Loading the program instead of running it. The header declares two more:
void keal_runtime_init(void); /* prepare the runtime: literals, and no more */
int keal_program_run(void); /* run the program's top level */
main calls both, in that order, so a program run the ordinary way never has to think about them. A host that never calls main — PostgreSQL loading a LANGUAGE C function, a JNI .so, a Python extension — has to call the first, or every string literal is still a null pointer and the first read of one ends the process. It is idempotent.
The second is the one to think about. Every top-level val and var is set by the top-level statements, so before they run a global is a null pointer, and a host that only initialised the runtime will find it so. There is no smaller thing to call: the initialisers are statements among the other statements, and their order relative to those is the program's own. Splitting them out would make that order observable and give the program two states where it has one. So the top level has a name, and a host that wants the globals runs it — which is the only thing that ever set them. Until then, a global written var x: String? = null is honest: C's zero is exactly what a nullable means.
Five names, and the layout is not one of them — everything else in the emitted runtime is static, which is why a prototype mentioning KealStr* would otherwise be a declaration the other side could not use. Ownership is the rule the rest of this page follows: a Keal function borrows its arguments and owns what it answers, so a companion releases what it made and what it got back, and nothing else.
1b. Structs across the boundary — SHIPPED. A record whose fields are all Int, Float or Bool crosses by value, no annotation needed: the generated C defines a headerless mirror typedef struct Keal_Name { ... } with unmangled field names, before any native block, and the boundary copies fields both ways. The C side just writes functions over Keal_Name:
record Vec2(val x: Float, val y: Float)
native """
static double vec2_dot(Keal_Vec2 a, Keal_Vec2 b) { return a.x*b.x + a.y*b.y; }
"""
extern func vec2_dot(a: Vec2, b: Vec2): Float
1c. Header generation — SHIPPED. keal emit-header prog.keal > prog.h prints the C face of the boundary: the Keal_Name mirror structs (same text as the generated C, so the two translation units agree) and a k_name prototype for every non-generic function whose signature crosses cleanly. A companion .c file compiled by keal build prog.keal helper.c includes it and calls straight back into Keal — the suite's boundary test does exactly that. This is the prerequisite the Go and JVM gateways needed.
1d. Closure callbacks — deferred, deliberately. Passing a Keal closure to C as a bare function pointer needs somewhere to put the environment, and C callback APIs differ on it: most take a void* userdata alongside the pointer, some take nothing. Picking a convention shapes the syntax (extern func onEach(f: (Int) -> Int) must say which argument is the userdata slot), so it waits for a real consumer instead of guessing. Meanwhile the shipped 1c covers the common need from the other side: C code can call named Keal functions (k_name) directly, today.
What shipped landed the way everything lands here: oracle first, the three self-hosted twins mirrored, byte-equality over the corpus, a build-and-run boundary test with leaks clean, and the bootstrap fixed point re-proven.
Rust exports the C ABI natively, and both tools it needed exist now:
keal build prog.keal libx.a -lm -L/opt/lib passes .a/.so/.dylib/.o files and -l/-L flags to the link step, and -I/-D to the compile steps.keal bindgen header.h — shipped. Reads a C header, writes the extern func declarations. It binds only what crosses exactly — int64_t/long long, double, bool, const char* as borrow String, a returned char* as own String, Keal_Name mirrors as records — and skips everything else with the reason printed: a 32-bit int, a borrowed const char* return, a variadic, a function-pointer parameter. A guessed binding is a crash with a delay; a skipped one is a wrapper the C author writes in five lines.The chain is four commands, demonstrated and verified in examples/interop/rust/:
cargo build --release # staticlib with extern "C" exports
cbindgen --lang c --output kealdemo.h # its header
keal bindgen kealdemo.h > bindings.keal # its Keal face
keal build main.keal target/release/libkealdemo.a -I.
Rust's ownership marries tier 1a exactly: &CStr ↔ borrow String, CString::into_raw ↔ own String (Keal frees it). No runtime is embedded. The same two tools serve plain C libraries — sqlite, curl — and headers from go build -buildmode=c-archive, which is why tier 3 needs no new machinery.
A convention worth knowing: a hand-written header that defines a mirror struct should guard it with #ifndef KEAL_MIRROR_Name, the same guard the generated C and keal emit-header emit, so the two definitions coexist.
Go compiles to a C-linkable archive: go build -buildmode=c-archive produces libx.a plus a generated header — which keal bindgen from tier 2 already consumes. So the mechanism is exactly the Rust path. What is different is the cost, and it should be stated honestly:
borrow/own conventions express already.//export.Verdict: supported by the same two tools (link inputs + bindgen); document the weight, add one worked example under examples/interop/go/.
Kotlin/JVM and Java are the same target: the JVM, reached through JNI — a C API (jni.h) that the generated C can call directly, because the generated program is C. No new backend capability is needed beyond tier 1; what is needed is a runtime module and a wrapper generator.
4a. The JVM host module — SHIPPED (lib/jvm.keal): one Keal module, no new compiler capability — a native block of helpers over jni.h plus extern func declarations riding tier 1's borrow/own strings. Verified end to end by the suite (jvm_gateway_works_end_to_end, skipped when no JDK is installed):
import "lib/jvm.keal"
jvmStart("") // or "-Djava.class.path=foo.jar"
val date = jvmClass("java/time/LocalDate")
jvmArgInt(2026)
jvmArgInt(1)
jvmArgInt(1)
val d = jvmStaticObj(date, "of", "(III)Ljava/time/LocalDate;")
jvmArgLong(58)
println(jvmToString(jvmCallObj(d, "plusDays", "(J)Ljava/time/LocalDate;")))
// 2026-02-28 — java.time, in a native Keal binary
Build with the JDK on the line (link inputs from tier 2):
JH=$(/usr/libexec/java_home)
keal build prog.keal -I$JH/include -I$JH/include/darwin \
-L$JH/lib/server -ljvm -Wl,-rpath,$JH/lib/server
The honest v1 shape: objects are opaque Int handles (JNI global refs, freed with jvmFree); arguments are pushed with jvmArg* matching the Java parameter type; calls take JNI signatures verbatim; a Java exception becomes a Keal panic carrying the throwable's toString. The worked example is examples/interop/java/.
4b. keal jbind — shipped. The wrapper generator, and the road to import java.time.LocalDate. keal jbind java.time.LocalDate java.time.DayOfWeek reads each class through javap -public and prints one typed Keal module over exactly the 4a calls:
// generated
class UUID(val handle: Int) : Ord {
func toString(): String { return jvmToString(this.handle) }
proc free() { jvmFree(this.handle) }
func compareTo(a0: UUID): Comp { ... }
}
func uuidRandomUUID(): UUID { ... }
so user code never touches JNI signatures. The bindgen rule holds: only members whose types cross exactly are bound — int, long, double, boolean, String, and any class bound in the same run (bind DayOfWeek alongside LocalDate and getDayOfWeek() comes typed) — everything else is skipped with the reason printed. Statics and constructors become free functions (localDateOf, uuidNew); a representable Java compareTo makes the wrapper Ord, so prelude compare and < reach across the JVM; free() releases the global ref (handle stays visible for calls jbind could not type). --jvm <path> sets the emitted import path, and an argument naming a file is read as saved javap output — which keeps the snapshot test (tests/jbind/) JDK-free; the end-to-end test builds a native binary against live-generated java.time wrappers. Kotlin classes are plain JVM classes: same generator, Kotlin's stdlib on the classpath.
The endpoint, in three steps that stack — all three shipped: (1) 4a — you write signatures; (2) jbind — a generated LocalDate.keal you import by path; (3) loader sugar —
import java.time.LocalDate, java.time.DayOfWeek
jvmStart("")
val d = localDateOf(2026, 1, 1).plusDays(58)
println(d.toString()) // 2026-02-28
println(d.getDayOfWeek().toString()) // SATURDAY
A non-path import desugars to .jbind/<classes>.keal next to the file; classes named in one import are bound together, so they see each other typed. keal run/check/build fill the cache through javap when it is missing (keal jbind --cache .jbind java.time.LocalDate does it by hand), and the directory carries its own copy of the gateway, so a committed .jbind/ builds anywhere — no JDK needed until the classes change. The compiler stages themselves never generate: keal tokens/ast/types/cgen and the self-hosted twins read only what is on disk, which keeps the byte-for-byte corpora meaningful.
4c. Later, if wanted: GraalVM native-image --shared turns a JVM library into a plain C shared library with its own header — then the JVM disappears entirely and the Rust path carries Java code too. Worth a worked example once 4a exists; not worth building first, because it constrains which libraries work (no dynamic class loading).
Cost to state plainly: the JVM gateway embeds a JVM (or Graal image); it is for programs that need Java libraries, not a default. The determinism story of the test suite stops at the boundary — JVM output is the JVM's.
.kealsql is a Keal moduleimport "./blog.kealsql"
for (p in byAuthor("ada")) { println(p.title) }
KealSql describes a PostgreSQL schema and its queries; its compiler answers a Keal module in which those queries are already typed. Importing the .kealsql itself is the point: a column renamed in the schema breaks the program where it is compiled, not the query where it runs.
The import desugars to .kealsql/<stem>.client.keal beside the file, and that is the same shape .jbind/ uses, on purpose:
keal run, check and build generate the module when it is missing or when the .kealsql is newer. That second half differs from .jbind/, which only generates what is absent, and it differs deliberately: a Java class changes when somebody upgrades a dependency, a schema changes while you are writing it.tokens, ast, types, cgen — never generate. They read what is on disk, or the self-hosted front end and the Rust one would be comparing different inputs. A missing module there says what to run.$KEALSQL when set, kealsql on the path otherwise, and a missing one says where to get it.keal build app.keal -lpq -I$(pg_config --includedir), the same way the JVM's flags are passed. Nothing in keal build runs pg_config for you — a build driver that knew about PostgreSQL would owe the next project the same favour.borrow/own at the boundary, exactly as docs/memory.md §6 demands.src/cbackend.rs, is mirrored in selfhost/cbackend.keal, and the byte-equality tests plus a worked example under examples/interop/ keep it honest. The bootstrapped dist/kealc gets each feature the same day the Rust oracle does, or the fixed-point test fails.keal.toml listing sources, libraries and flags replaces the growing command line.longjmp — which is how PostgreSQL's ereport, some parsers and a few test frameworks report errors — jumps over the Keal frames beneath it. Two things are skipped, and only the first is obvious. The releases the backend emitted at those scopes' ends never run, so the values they held leak and their deinits do not run. There is no double free: the skipped releases do not run at all, and the foreign setjmp returns into C, never back into the abandoned frame.
The one that bites is keal_try_depth. A try is emitted as ++ before its body and -- after; a longjmp out of the body skips the --, and the counter stays raised on that thread for good. From then on keal_panic believes a catch is waiting: instead of printing and ending the process it records the message and RETURNS, and the caller uses the harmless value that comes back. Measured, on a program with no try in it at all, so nothing checks the unwinding flag either:
index out of bounds -> prints 0, carries on, exits 0 the same, counter clean -> prints the error, exits 1
So a foreign longjmp does not crash the program: it converts every later failure into a wrong answer given quietly. That is worse than a leak, and it does not need a try anywhere in the Keal code, because the counter is consulted by the runtime rather than by emitted guards.
The rule, then: catch it in the innermost C shim. Wrap the call that can jump (PG_TRY/PG_CATCH, or the library's own equivalent) and turn the error into an ordinary return value, so no longjmp ever crosses a Keal frame. If that is impossible, save keal_try_depth and keal_unwinding before the call and restore them after the jump — both are file-scope in the emitted C, so a native block sees them.
| # | Item | Unlocks | Status |
|---|---|---|---|
| 1 | Tier 1a strings + 1b structs | everything | shipped |
| 2 | Tier 1c emit-header | Keal as a library; Go/JVM callbacks | shipped |
| 3 | Link inputs (.a, -l, -L, -I, -D) | Rust, Go, C libraries | shipped |
| 4 | keal bindgen for C headers | sqlite/curl/every C lib, Rust via cbindgen, Go via c-archive | shipped, with a verified Rust demo |
| 5 | Closure callbacks (1d) | C APIs that take function pointers | waits for a consumer |
| 6 | JVM host module (4a) | Java + Kotlin | shipped, with a verified java.time demo |
| 7 | keal jbind (4b) | typed Java/Kotlin imports, then import java.time.LocalDate as loader sugar | next |
Each row is independently shippable and independently testable; nothing in a later row forces rework of an earlier one.