on master help sign in
compile: base.compile builtin scaffold + knot-compile-rt staticlib

Wire `compile : Text -> Maybe a` end to end except for actually linking the JIT archive into programs (next step). Dependency inversion: the JIT compiler can't live in knot-runtime (knot-compiler's build.rs builds knot-runtime — a dep would recurse the build infinitely), so a separate knot-compile-rt staticlib bridges knot-jit (→ knot-compiler) and knot-runtime. - knot-runtime: a COMPILE_IMPL fn-pointer slot + knot_register_compile_impl (called by the JIT archive at startup) + knot_builtin_compile, which opens the host db and calls through the pointer; Nothing when no JIT is linked. - knot-compile-rt (new, staticlib+rlib): knot_compile_impl (drives knot_jit::compile_and_run, hands back the forced Value + inferred type) and knot_compile_rt_init (registers the impl). Builds standalone — no recursion. - compiler: `compile` added to BASE_STDLIB_FNS + codegen stdlib_names + declare_rt/define_stdlib_fn_1 (knot_builtin_compile); infer binds compile : ∀a. Text -> Maybe a. Verified: knot-compile-rt builds (no rt-build recursion), base.compile is typed and correctly inert — `base.compile "1 + 1"` → Nothing when no JIT archive is linked, no crash; plain AOT programs unchanged; suite 113/113.

commit7c16644204d8b7f1d79799e2b3d430df04eb3cdb authoriko <mail@iko.soy> dateWed 5 Aug 2026, 06:57 (2 months ago) parent237f9df
8 files changed+206
Cargo.lock+9 Cargo.toml+1 crates/knot-compile-rt/Cargo.toml+14 crates/knot-compile-rt/src/lib.rs+73 crates/knot-compiler/src/base.rs+4 crates/knot-compiler/src/codegen.rs+5 crates/knot-compiler/src/infer.rs+16 crates/knot-runtime/src/lib.rs+84
Cargo.lock+9
@@ -1468,6 +1468,15 @@ dependencies = [
14681468 "ariadne",
14691469]
14701470
1471+[[package]]
1472+name = "knot-compile-rt"
1473+version = "0.1.0"
1474+dependencies = [
1475+ "knot-jit",
1476+ "knot-runtime",
1477+ "libc",
1478+]
1479+
14711480[[package]]
14721481name = "knot-compiler"
14731482version = "0.1.0"
Cargo.toml+1
@@ -20,6 +20,7 @@ knot = { path = "crates/knot" }
2020knot-compiler = { path = "crates/knot-compiler" }
2121knot-runtime = { path = "crates/knot-runtime" }
2222knot-jit = { path = "crates/knot-jit" }
23+knot-compile-rt = { path = "crates/knot-compile-rt" }
2324cranelift-codegen = "0.131"
2425cranelift-frontend = "0.131"
2526cranelift-module = "0.131"
crates/knot-compile-rt/Cargo.tomlnew file+14
@@ -0,0 +1,14 @@
1+[package]
2+name = "knot-compile-rt"
3+version.workspace = true
4+edition.workspace = true
5+
6+[lib]
7+# Staticlib so the linker can embed this into compiled knot programs alongside
8+# libknot_runtime.a; rlib so the knot binary / tests can use it as a normal dep.
9+crate-type = ["staticlib", "rlib"]
10+
11+[dependencies]
12+knot-jit = { workspace = true }
13+knot-runtime = { workspace = true }
14+libc = { workspace = true }
crates/knot-compile-rt/src/lib.rsnew file+73
@@ -0,0 +1,73 @@
1+//! The `compile` builtin's JIT glue, linked into compiled knot programs as a
2+//! separate staticlib archive (alongside libknot_runtime.a). knot-runtime
3+//! cannot depend on the compiler directly (knot-compiler's build.rs builds
4+//! knot-runtime — a dep here would recurse), so this crate bridges: it depends
5+//! on knot-jit (→ knot-compiler) and knot-runtime, exposes the compile
6+//! implementation as a `#[no_mangle]` extern, and registers it into
7+//! knot-runtime's `COMPILE_IMPL` slot at program startup.
8+
9+use std::ffi::c_void;
10+
11+/// The compile implementation registered into knot-runtime. Matches the
12+/// `CompileImpl` signature in knot-runtime: takes the program source and a db
13+/// handle, writes the inferred type into the out-params, returns the forced
14+/// value (or null on any compile error).
15+///
16+/// `out_ty` is a freshly `malloc`'d, NUL-free buffer of length `*out_ty_len`
17+/// (caller frees with `libc::free`), or null when the type is unknown.
18+#[unsafe(no_mangle)]
19+pub unsafe extern "C" fn knot_compile_impl(
20+ src_ptr: *const u8,
21+ src_len: usize,
22+ db: *mut c_void,
23+ out_ty_ptr: *mut *mut u8,
24+ out_ty_len: *mut usize,
25+) -> *mut knot_runtime::Value {
26+ // Null out-params up front so a compile error leaves them well-defined.
27+ unsafe {
28+ if !out_ty_ptr.is_null() {
29+ *out_ty_ptr = std::ptr::null_mut();
30+ }
31+ if !out_ty_len.is_null() {
32+ *out_ty_len = 0;
33+ }
34+ }
35+
36+ let src_bytes = unsafe { std::slice::from_raw_parts(src_ptr, src_len) };
37+ let Ok(source) = std::str::from_utf8(src_bytes) else {
38+ return std::ptr::null_mut();
39+ };
40+
41+ match knot_jit::compile_and_run(source, db) {
42+ Ok(cv) => {
43+ // Hand the inferred type back as a malloc'd buffer (C ABI — the
44+ // runtime frees it). Only when we actually have one.
45+ if let Some(ty) = cv.ty
46+ && !out_ty_ptr.is_null()
47+ && !out_ty_len.is_null()
48+ {
49+ let buf = unsafe { libc::malloc(ty.len()) as *mut u8 };
50+ if !buf.is_null() {
51+ unsafe {
52+ std::ptr::copy_nonoverlapping(ty.as_ptr(), buf, ty.len());
53+ *out_ty_ptr = buf;
54+ *out_ty_len = ty.len();
55+ }
56+ } else {
57+ unsafe { libc::free(buf as *mut c_void) };
58+ }
59+ }
60+ // knot-jit treats the value as opaque *mut c_void; here it's a
61+ // knot-runtime Value again.
62+ cv.value as *mut knot_runtime::Value
63+ }
64+ Err(_) => std::ptr::null_mut(),
65+ }
66+}
67+
68+/// Install `knot_compile_impl` into knot-runtime's `COMPILE_IMPL` slot. Called
69+/// once from the generated program's `main` before user code runs.
70+#[unsafe(no_mangle)]
71+pub extern "C-unwind" fn knot_compile_rt_init() {
72+ knot_runtime::knot_register_compile_impl(knot_compile_impl as *mut c_void);
73+}
crates/knot-compiler/src/base.rs+4
@@ -138,6 +138,10 @@ pub(crate) const BASE_STDLIB_FNS: &[&str] = &[
138138 // Value → evaluable Knot source (dependency-collecting). Registered as a
139139 // stdlib function value in codegen.
140140 "extract",
141+ // In-process JIT compile+eval: `compile : Text -> Maybe a`. Registered as
142+ // a stdlib function value in codegen (calls knot_builtin_compile, which
143+ // dispatches through the runtime's registered compile implementation).
144+ "compile",
141145 // 0-arg IO builtins. Each is a re-runnable IO thunk (`Value::IO(thunk, _)`)
142146 // produced fresh by the bare-`Var` dispatch, so holding the action as a
143147 // record field is safe — forcing `base.now` twice runs `knot_now` twice.
crates/knot-compiler/src/codegen.rs+5
@@ -1371,6 +1371,7 @@ impl<M: cranelift_module::Module> Codegen<M> {
13711371 self.declare_rt("knot_println", &[p], &[p]);
13721372 self.declare_rt("knot_value_show", &[p], &[p]);
13731373 self.declare_rt("knot_value_extract", &[p], &[p]);
1374+ self.declare_rt("knot_builtin_compile", &[p], &[p]);
13741375 self.declare_rt("knot_value_show_unit", &[p, p, p], &[p]);
13751376 self.declare_rt("knot_guard_failed", &[], &[]);
13761377
@@ -2232,6 +2233,9 @@ impl<M: cranelift_module::Module> Codegen<M> {
22322233 // `extract` renders a value as evaluable Knot source (runtime
22332234 // `knot_value_extract`); first-class so `base.extract` works.
22342235 "extract",
2236+ // `compile` JIT-compiles+evals a source string (runtime
2237+ // `knot_builtin_compile`); first-class so `base.compile` works.
2238+ "compile",
22352239 // List ADT builtins (`base.list.*`), exposed nested under the
22362240 // `list` namespace in the prelude record. Runtime-implemented
22372241 // (knot_list_*) since the prelude record can't self-reference.
@@ -3105,6 +3109,7 @@ impl<M: cranelift_module::Module> Codegen<M> {
31053109 // Extract: 1-param. Renders any value as evaluable Knot source
31063110 // (collecting closure/IO dependencies into a `with` block).
31073111 self.define_stdlib_fn_1("extract", "knot_value_extract");
3112+ self.define_stdlib_fn_1("compile", "knot_builtin_compile");
31083113
31093114 // Sleep: 1-param (IO-returning)
31103115 self.define_stdlib_fn_1("sleep", "knot_sleep_io");
crates/knot-compiler/src/infer.rs+16
@@ -9553,6 +9553,22 @@ impl Infer {
95539553 Scheme::poly(vec![a], Ty::Fun(Box::new(Ty::Var(a)), Box::new(Ty::Text))),
95549554 );
95559555
9556+ // compile : ∀a. Text -> Maybe a — JIT-compile+eval a knot source string
9557+ // in-process against the host runtime. `Just {value}` on success (the
9558+ // value forced at compile time, typed as `a`), `Nothing` on any compile
9559+ // error or when the program's type doesn't match `a`.
9560+ let a = self.fresh_var();
9561+ self.bind_top(
9562+ "compile",
9563+ Scheme::poly(
9564+ vec![a],
9565+ Ty::Fun(
9566+ Box::new(Ty::Text),
9567+ Box::new(Ty::Con("Maybe".into(), vec![Ty::Var(a)])),
9568+ ),
9569+ ),
9570+ );
9571+
95569572 // union : ∀a. [a] -> [a] -> [a]
95579573 let a = self.fresh_var();
95589574 self.bind_top(
crates/knot-runtime/src/lib.rs+84
@@ -10687,6 +10687,90 @@ fn maybe_nothing() -> *mut Value {
1068710687 alloc(Value::Constructor(intern_str("Nothing"), encode_unit()))
1068810688}
1068910689
10690+// ── `compile : Text -> Maybe a` ────────────────────────────────────
10691+//
10692+// The JIT compiler is not part of knot-runtime (that would create a Cargo
10693+// cycle — knot-compiler's build.rs builds knot-runtime). Instead the program
10694+// links a separate `knot-compile-rt` archive whose code registers a compile
10695+// implementation here at startup; `base.compile` calls through the pointer.
10696+// A program built without the JIT archive leaves the slot empty and
10697+// `base.compile` returns `Nothing` for everything.
10698+
10699+/// Signature of the registered compile implementation: takes the program
10700+/// source and the host db handle, returns the forced value plus the inferred
10701+/// type string (out-params), or null on any compile error. Implemented by
10702+/// knot-compile-rt (`knot_compile_impl`).
10703+type CompileImpl = unsafe extern "C" fn(
10704+ src_ptr: *const u8,
10705+ src_len: usize,
10706+ db: *mut c_void,
10707+ out_ty_ptr: *mut *mut u8,
10708+ out_ty_len: *mut usize,
10709+) -> *mut Value;
10710+
10711+static COMPILE_IMPL: std::sync::atomic::AtomicPtr<c_void> =
10712+ std::sync::atomic::AtomicPtr::new(std::ptr::null_mut());
10713+
10714+/// Registered by knot-compile-rt at program startup to install the compile
10715+/// implementation. Called from the generated program's `main`.
10716+#[unsafe(no_mangle)]
10717+pub extern "C-unwind" fn knot_register_compile_impl(f: *mut c_void) {
10718+ COMPILE_IMPL.store(f, std::sync::atomic::Ordering::Release);
10719+}
10720+
10721+/// Look up the registered compile implementation, if any.
10722+fn compile_impl() -> Option<CompileImpl> {
10723+ let p = COMPILE_IMPL.load(std::sync::atomic::Ordering::Acquire);
10724+ if p.is_null() {
10725+ None
10726+ } else {
10727+ Some(unsafe { std::mem::transmute::<*mut c_void, CompileImpl>(p) })
10728+ }
10729+}
10730+
10731+/// `base.compile : Text -> Maybe a`. Compile+run the knot program in `src`
10732+/// in-process against the host runtime, returning `Just {value}` on success
10733+/// and `Nothing` on any compile error or when no JIT is linked.
10734+#[unsafe(no_mangle)]
10735+pub extern "C-unwind" fn knot_builtin_compile(src: *mut Value) -> *mut Value {
10736+ let Some(f) = compile_impl() else {
10737+ return maybe_nothing();
10738+ };
10739+ let Value::Text(source) = (unsafe { as_ref(src) }) else {
10740+ return maybe_nothing();
10741+ };
10742+ // Open a db handle for the snippet. knot_db_open records the path on first
10743+ // open (DB_PATH), so reuse it; a pure program (no store) gets :memory:.
10744+ // A snippet reading the host's persisted relations thus sees real data.
10745+ let db_path = DB_PATH.lock().unwrap_or_else(|e| e.into_inner()).clone();
10746+ let (pstr, plen) = if db_path.is_empty() {
10747+ (":memory:".as_bytes().as_ptr(), ":memory:".len())
10748+ } else {
10749+ (db_path.as_ptr(), db_path.len())
10750+ };
10751+ let db = knot_db_open(pstr, plen);
10752+ let mut ty_ptr: *mut u8 = std::ptr::null_mut();
10753+ let mut ty_len: usize = 0;
10754+ let value = unsafe {
10755+ f(
10756+ source.as_ptr(),
10757+ source.len(),
10758+ db,
10759+ &mut ty_ptr,
10760+ &mut ty_len,
10761+ )
10762+ };
10763+ if value.is_null() {
10764+ return maybe_nothing();
10765+ }
10766+ // TODO: unify the compiled program's type (ty_ptr/ty_len) against the
10767+ // caller's expected `a`; Nothing on mismatch. For now accept any type.
10768+ if !ty_ptr.is_null() {
10769+ unsafe { libc::free(ty_ptr as *mut c_void) };
10770+ }
10771+ maybe_just(value)
10772+}
10773+
1069010774/// nil — the empty List (1-arg builtin that ignores its argument, so the
1069110775/// bare `base.list.nil` function value has the standard 1-param shape).
1069210776#[unsafe(no_mangle)]