libturi is the Turmeric eval runtime packaged as a static library (libturi.a).
It lets C programs evaluate Turmeric expressions, call Turmeric functions, and
expose C functions to Turmeric code -- all without spawning a subprocess.
#include <turi/eval.h>
int main(void) {
turi_init(/*use_color=*/false);
TuriEnv *env = turi_env_new();
TuriValue v = turi_eval(env, "(+ 1 2)");
/* v.tag == TURI_INT, v.as_int == 3 */
turi_env_free(env);
return 0;
}
Compile and link:
cc -o demo demo.c -I/usr/local/include/turi \
-L/usr/local/lib -lturi -lpthread
| Header | Purpose |
|---|---|
turi/eval.h |
Main API -- eval, init, async helpers |
turi/env.h |
TuriEnv type and environment operations |
turi/value.h |
TuriValue tagged union and constructors |
Include only turi/eval.h; it pulls in the others transitively.
void turi_init(bool use_color)Initialises the diagnostics subsystem. Call once before the first
turi_eval. Pass use_color = true to enable ANSI colour in error messages
(suitable when stderr is a terminal).
An environment (TuriEnv) holds all accumulated definitions and per-call
arenas. Closures captured from an environment must not outlive it.
TuriEnv *turi_env_new(void)Creates an unrestricted environment. All builtins -- including I/O -- are available.
TuriEnv *turi_env_new_sandboxed(void)Creates a sandboxed environment. I/O builtins (println, read-async,
write-async, …) and inline-C expressions are disabled. Suitable for
evaluating untrusted code.
void turi_env_free(TuriEnv *env)Frees all resources owned by env. Any TuriValue containing a closure,
struct, or future that was produced by this environment becomes invalid after
this call.
TuriValue turi_env_get(TuriEnv *env, const char *name)Looks up a global binding by name. Returns a TURI_ERROR value if the name
is not bound.
void turi_env_set(TuriEnv *env, const char *name, TuriValue value)Creates or replaces a global binding. Useful for injecting values from C into Turmeric code before evaluating expressions that reference them.
TuriValue turi_eval(TuriEnv *env, const char *src)Evaluates a Turmeric source string in env. Prior definitions in env remain
visible. New top-level definitions (defn, def) are stored back into env
for subsequent calls.
On parse or elaboration error the diagnostic is emitted to stderr and the
returned value has tag TURI_ERROR.
The returned value is valid until turi_env_free(env).
/* Define a function, then call it in a later eval. */
turi_eval(env, "(defn double [x :int] :int (* x 2))");
TuriValue r = turi_eval(env, "(double 21)");
/* r.as_int == 42 */
TuriValue turi_eval_file(TuriEnv *env, const char *path)Reads path and evaluates its contents via turi_eval. Returns a TURI_ERROR
if the file cannot be opened.
TuriValueTuriValue is a tagged union:
typedef struct TuriValue {
TuriTag tag;
union {
bool as_bool;
int64_t as_int;
double as_float;
const char *as_cstr; /* NUL-terminated string */
TuriClosure *as_closure;
const char *as_error; /* error message */
TuriEffectCont *as_cont;
TuriStruct *as_struct;
TuriThrow *as_throw;
TuriFuture *as_future;
};
} TuriValue;
| Tag | Meaning |
|---|---|
TURI_NIL |
Unit / void -- (), nil |
TURI_BOOL |
Boolean -- true / false |
TURI_INT |
64-bit signed integer |
TURI_FLOAT |
64-bit float (double) |
TURI_CSTR |
NUL-terminated C string |
TURI_CLOSURE |
First-class function |
TURI_ERROR |
Runtime / parse error (not catchable by try/catch) |
TURI_EFFECT_CONT |
Live algebraic-effect continuation |
TURI_STRUCT |
Struct instance |
TURI_THROW |
In-flight exception (catchable by try/catch) |
TURI_FUTURE |
Async future handle |
TuriValue turi_nil(void);
TuriValue turi_bool(bool b);
TuriValue turi_int(int64_t i);
TuriValue turi_float(double f);
TuriValue turi_cstr(const char *s); /* borrows s -- caller keeps it alive */
TuriValue turi_error(const char *msg);
TuriValue turi_errorf(const char *fmt, ...);
bool turi_is_error(TuriValue v);
bool turi_is_truthy(TuriValue v);
bool turi_is_throw(TuriValue v);
bool turi_as_bool(TuriValue v);
int64_t turi_as_int(TuriValue v);
double turi_as_float(TuriValue v);
const char *turi_as_cstr(TuriValue v);
const char *turi_error_message(TuriValue v);
void turi_value_repr(char *buf, size_t cap, TuriValue v);
Writes a human-readable representation into buf (at most cap bytes,
NUL-terminated). Examples: "42", "\"hello\"", "#<fn double>",
"true".
void turi_print_value(FILE *out, TuriValue v);
Prints the same representation to out.
void turi_env_register_native(TuriEnv *env, const char *name, TuriNativeFn fn, void *ud)Registers a C function so that Turmeric code can call it by name.
typedef TuriValue (*TuriNativeFn)(TuriEnv *env, TuriValue *args,
uint32_t n, void *ud);
args -- array of evaluated arguments (length n).ud -- the void *ud passed to turi_env_register_native.turi_nil() for void functions; return an appropriate TuriValue
for functions that produce results.turi_native_throw then return
turi_nil().static TuriValue native_add(TuriEnv *env, TuriValue *args,
uint32_t n, void *ud) {
(void)env; (void)ud;
if (n != 2) return turi_error("add: expected 2 args");
return turi_int(args[0].as_int + args[1].as_int);
}
turi_env_register_native(env, "my-add", native_add, NULL);
TuriValue r = turi_eval(env, "(my-add 10 32)");
/* r.as_int == 42 */
void turi_native_throw(TuriEnv *env, const char *msg)Raises a catchable Turmeric exception from inside a TuriNativeFn. Sets
env->throwing and env->throw_value; the native must return turi_nil()
immediately afterwards.
static TuriValue native_fail(TuriEnv *env, TuriValue *args,
uint32_t n, void *ud) {
turi_native_throw(env, "something went wrong");
return turi_nil();
}
These helpers exist for embedders that create one TuriEnv per attached
script (so that two scripts cannot clobber each other's globals -- e.g. each
exporting _ready). They keep the embed surface from becoming N copies of the
same boilerplate. See the report libturi-per-embed-env-and-peripherals for
the motivating Godot use case.
void turi_register_default_native(const char *name, TuriNativeFn fn, void *ud);
void turi_clear_default_natives(void);
TuriEnv *turi_env_new_with_natives(const TuriNativeSpec *specs, size_t n);
turi_register_default_native records a native that every subsequent
turi_env_new / turi_env_new_sandboxed / turi_env_new_with_natives call
installs automatically. An embedder shipping ten natives registers them once at
startup instead of re-registering on every per-script env (and cannot forget
one). The name is copied; re-registering a name replaces the prior entry. It
does not retroactively affect already-created envs.
turi_env_new_with_natives is turi_env_new plus an explicit table installed
on top of the default-natives registry:
TuriNativeSpec specs[] = {
{ "godot-println", native_println, lang_singleton },
{ "godot-emit", native_emit, lang_singleton },
};
TuriEnv *env = turi_env_new_with_natives(specs, 2);
void turi_env_reset(TuriEnv *env) -- reload without teardownResets an env to a from-scratch interpreter state: clears all globals that
turi_eval installed (user defn/def), the accumulated source, and the
deferred / handler / return / throw / abort / catch control state -- while
keeping every registered native alive (the builtins and the embedder's
own natives). This is what a script _reload wants: re-run the new source over
a clean slate without turi_env_free + re-registering natives.
Notes: non-native definitions a prelude installed (interpreted stdlib defns
loaded via turi_eval_file) are dropped too -- re-eval the prelude after a
reset. Arena memory from prior evals is reclaimed at turi_env_free, not here.
Call between top-level eval cycles, not from inside an async/handler frame.
void turi_env_set_diag_sink(TuriEnv *env, TuriDiagSinkFn cb, void *ud)Routes this env's parse/elaboration diagnostics to cb instead of stderr for
the duration of each turi_eval call on the env. Lets an editor integration
attribute each script's compile errors to that script in its own Output panel.
static void on_diag(TuriEnv *env, int level, const char *code,
const char *file, uint32_t line, uint32_t col_start,
uint32_t col_end, const char *message, void *ud) {
/* level: 0=error 1=warning 2=note 3=help; code is "TUR-E0001" or "". */
editor_output_push((Script *)ud, level, file, line, message);
}
turi_env_set_diag_sink(env, on_diag, script);
Pass cb == NULL to clear. LSP-collection and JSON diagnostic modes take
precedence over the sink when active.
void turi_env_set_module_base_dir(TuriEnv *env, const char *path)Sets the base directory used to resolve (import ...) paths. Call this
before turi_eval on source that imports modules; otherwise imports resolve
relative to the process cwd (for a packaged app, wherever the binary was
launched -- almost never what the user means). path is copied; pass NULL to
restore the default (".").
turi_env_set_module_base_dir(env, "res://scripts");
turi_eval(env, "(import std/list)\n...");
void turi_env_register_native_ex(...) -- native ud that the env ownstypedef void (*TuriNativeFreeFn)(void *ud);
void turi_env_register_native_ex(TuriEnv *env, const char *name,
TuriNativeFn fn, void *ud,
TuriNativeFreeFn free_ud);
Same as turi_env_register_native, but free_ud (when non-NULL) is invoked
with ud from turi_env_free. This lets a native registered with
ud = a per-script object tie that object's lifetime to the env it serves --
the common embedder pattern where a native calls back into the script for
property routing or signal emission. With one env per script, env lifetime ==
script lifetime, so the ud is reclaimed exactly when the script goes away.
Finalizers fire in LIFO order at teardown and are not fired by
turi_env_reset (natives survive a reset, so their ud must too). A native
whose ud outlives the env -- a process-global, or anything registered via
turi_register_default_native (shared across every env) -- must not take a
finalizer; use plain turi_env_register_native for those. TuriNativeSpec also
gained a free_ud field, honoured by turi_env_new_with_natives.
turi_env_register_native_ex(env, "godot-emit", native_emit,
script, (TuriNativeFreeFn)script_release);
void turi_env_set_interpret_mode(TuriEnv *env, bool interpret)Sets a per-env bit that is snapshotted into the process-global elaborator mode
flag for the duration of each turi_eval on the env, then restored. Every
turi_env_new defaults it to true (interpret mode -- registered natives
resolve at runtime). An embedder driving compile-mode elaboration through the
same process flips it to false.
The point is co-residency: before this, the last turi_env_new to run set the
global for everyone, so two libturi embedders in one process (e.g. a Godot
binding plus an MCP worker) could clobber each other's mode. Now each env
re-installs its own bit on every eval. (The elaborator still reads a
process-global within a single eval; threading the mode all the way through
the elaboration context per-env remains future work, but the cross-eval
clobber is gone.)
void turi_env_set_shared_spice_image(TuriEnv *env, struct TurSpiceImage *image)Points an env at another (prototype) env's already-loaded spice image instead of
auto-discovering and owning its own copy. The borrowing env will not free the
image in turi_env_free -- the prototype owns it and must outlive every
borrower. Pass the prototype's spice_image field; pass NULL to detach.
TuriEnv *proto = turi_env_new(); /* auto-discovers the project image */
turi_eval(proto, "...");
TuriEnv *script = turi_env_new();
turi_env_set_shared_spice_image(script, proto->spice_image); /* borrow, no copy */
This is a measure-first hook: the per-env image cost is only worth
optimizing once a real project shows many envs duplicating a nontrivial image,
and it is safe only when no borrower triggers (reload) on the shared image (a
reload would swap the prototype's image out from under the borrowers). It
deliberately avoids copy-on-write arena machinery until measurement justifies
it.
Debug-only cross-env guard. In debug builds (
assert/ASan, i.e. notNDEBUG) eachTuriClosurecarries an origin-env tag claimed on first application and checked on every later one. Caching a closure value across per-script envs -- forbidden, because closures pin their originating env's arenas -- aborts with a clear message instead of a heap use-after-free. Release builds compile the field and check out entirely (zero cost).
The async scheduler is cooperative and single-threaded. (async ...) spawns
a fiber; (await ...) suspends the current fiber until a future resolves.
From C, call turi_run_event_loop after evaluating async code to drive
completion.
void turi_run_event_loop(TuriEnv *env)Runs the cooperative scheduler until all pending fibers, timers, and I/O
callbacks complete. Call after any turi_eval that starts async tasks.
turi_eval(env, "(defn work [] :int (await (async (fn [] :int 42))))");
TuriValue r = turi_eval(env, "(work)");
/* r.as_int == 42 -- (await ...) in main context drives the loop inline */
TuriValue turi_task_spawn(TuriEnv *env, const char *src)Evaluates src as a zero-argument closure and spawns it as an async task.
Returns a TURI_FUTURE value.
void turi_task_cancel(TuriEnv *env, TuriFuture *f)Cancels the fiber owning f. The future is rejected; any (await f) will
throw a cancellation exception.
TuriValue turi_future_poll_val(TuriFuture *f)Non-blocking poll. Returns the resolved value, a TURI_ERROR if rejected,
or TURI_NIL if still pending.
TuriValue turi_sleep_async(TuriEnv *env, uint64_t ms)Returns a TURI_FUTURE that resolves after ms milliseconds. The current
fiber (if any) should (await) this future to suspend.
TuriEnv is not thread-safe. Use one environment per thread, or protect
access with an external lock.
TuriValue v = turi_eval(env, user_input);
if (turi_is_error(v)) {
fprintf(stderr, "error: %s\n", turi_error_message(v));
}
Note: TURI_ERROR is a value-level error (parse failure, unbound variable).
TURI_THROW is a Turmeric exception thrown by (throw ...) or
turi_native_throw. Uncaught throws surface as TURI_THROW values returned
from turi_eval; check with turi_is_throw(v).
:: is value-preserving, not bit-preservingThe tree-walking interpreter (turi, behind tur interpret, tur repl,
sandbox turi_eval, and the WASM REPL) holds values as tagged TuriValues
(TURI_INT carries a 64-bit word, TURI_FLOAT carries a double), whereas the
compiled path carries every scalar as a raw int64_t. This difference is
invisible except in one spot: a :: ascription that crosses the float/int
representation boundary.
(:: expr T) between two same-size but distinct scalar kinds lowers to a
bit-level reinterpret (EX_REINTERPRET). Whether that reinterpret is
observable depends on the pair:
:: between kinds |
Bits change meaning? | turi vs compiled |
|---|---|---|
int<->cstr, int<->ptr<void>, int<->opaque newtype, int8<->uint8, sym<->ptr |
no | identical |
float<->int (8-byte), float32<->int32 (4-byte) |
yes | diverges |
For the first group -- which is the overwhelming majority of real :: use
(unwrapping defopaque/refined newtypes like Fd, Pid, EventSourceId,
NonEmpty; pointer and cstr carriers) -- the underlying bits are identical,
so the interpreter's tag-preserving passthrough produces exactly the compiled
result.
Only the float/int pair differs, and only when you use :: to observe the
reinterpreted bit pattern as a result:
(:: 7 :float) ; compiled: 3.45846e-323 (the int's bits as a double)
; turi: 7
(:: 7.1 :int) ; compiled: 4619679907765970534 (the IEEE-754 bits)
; turi: 7.1
A float carried through the int64 carrier and back -- the common case, e.g.
an ADT/cons/tyvar payload or (:: f int) then (:: thru float) -- round-trips
correctly under turi, because the float keeps its tag the whole way. The
divergence is strictly "type-pun a float to read or synthesize its IEEE bits."
Why it is this way. A TURI_INT cannot be distinguished as "a genuine
integer" from "a carrier holding float bits", so any partial bit-reinterpret
breaks the round-trips that rely on tag preservation (it would corrupt a
Cons[float]'s .head to its integer bit pattern). Fully value-preserving is
the only self-consistent choice for the tagged model; closing the gap would
require a typed-carrier value representation. See
docs/reported/turi-map-nonint-value-carrier-ascription.md.
Practical guidance. Code that uses :: for its bit pattern (bit-level float
hashing, representation-exact serialization, manual IEEE/NaN-boxing) must run on
the compiled path (tur build / tur run). Note the stdlib's own float
Hash/MapKey reinterprets go through inline-C unions that the interpreter
overrides with natives, not through ::, so float-keyed maps and sets hash
correctly under the interpreter.
A handful of language features run only on the compiled path (tur build /
tur run). Under the tree-walking interpreter (tur interpret, tur repl,
sandbox turi_eval, the WASM REPL) they raise a clean, value-level
TURI_ERROR -- never a wrong answer, and never the generic "unhandled
expression kind" default. Check for them with turi_is_error as usual.
| Feature | Interpreter behaviour | Why |
|---|---|---|
User inline-C (```c ... ``` bodies) |
inline-C not supported in interpreter mode |
The interpreter has no C compiler. Stdlib inline-C is covered by registered natives (see turi_env_register_native) and the simple-shape evaluator; arbitrary user inline-C is not. |
Channels / select |
select is not supported in interpreter mode (channels require native primitives; use the compiled path) |
Turmeric channels are inline-C pthread mutex/condvar ring buffers with no native representation in turi, so select has nothing to select over. Tracked in docs/archive/history/turi-select-needs-channel-primitives.md. |
| WASM async | scheduler-dependent error | The WASM build drives async through the host event loop; the standalone interpreter's cooperative scheduler does not cover the WASM async path. See src/turi/fiber.c. |
call/cc, context-capturing serial-shift / cloneable-shift |
clean error | Need genuine continuation capture; deferred to the CPS-transform work. See docs/turi-carve-out.txt. |
These are intentional and enforced: tools/check_turi_parity.py (wired into
tests/run.sh) checks every EX_* kind against docs/turi-carve-out.txt, so a
carve-out can neither silently regress to the generic default nor go stale.
#include <stdio.h>
#include <turi/eval.h>
int main(void) {
turi_init(false);
TuriEnv *env = turi_env_new_sandboxed();
const char *exprs[] = {
"(defn fact [n :int] :int (if (<= n 1) 1 (* n (fact (- n 1)))))",
"(fact 10)",
"(fact 20)",
};
for (int i = 0; i < 3; i++) {
TuriValue v = turi_eval(env, exprs[i]);
if (turi_is_error(v)) {
fprintf(stderr, "error: %s\n", turi_error_message(v));
} else {
char buf[64];
turi_value_repr(buf, sizeof(buf), v);
printf("%s => %s\n", exprs[i], buf);
}
}
turi_env_free(env);
return 0;
}