libturi -- C Embedding API Reference

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.


Quick start

#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

Headers

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.


Initialisation

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).


Environments

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.


Evaluation

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.


Values -- TuriValue

TuriValue 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;

Tags

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

Constructors

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, ...);

Predicates and accessors

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);

Printing

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.


Registering native C functions

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);
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();
}

Per-embed-env peripherals

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.

Default natives -- seed once, install everywhere

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 teardown

Resets 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 owns

typedef 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. not NDEBUG) each TuriClosure carries 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).


Async API

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.


Thread safety

TuriEnv is not thread-safe. Use one environment per thread, or protect access with an external lock.


Error handling pattern

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).


Interpreter value semantics: :: is value-preserving, not bit-preserving

The 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.


Not interpreted: carve-outs

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.


Full example -- sandboxed calculator

#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;
}