Foreign Function Interface

Silt can be embedded in Rust applications. The FFI lets you register Rust functions that are callable from silt code with the same syntax as builtins.

Quick Start

use silt::{Vm, Value, VmError};
use silt::compiler::Compiler;
use silt::lexer::Lexer;
use silt::parser::Parser;

let mut vm = Vm::new();

// Register a typed function (auto-marshalling)
vm.register_fn1("double", |x: i64| -> i64 { x * 2 });

// Compile and run silt code
let tokens = Lexer::new("fn main() { double(21) }").tokenize().unwrap();
let program = Parser::new(tokens).parse_program().unwrap();
let mut compiler = Compiler::new();
let functions = compiler.compile_program(&program).unwrap();
let script = std::sync::Arc::new(functions.into_iter().next().unwrap());

let result = vm.run(script).unwrap();
assert_eq!(result, Value::Int(42));

Registration API

Raw registration

Full control over arguments and return values:

vm.register_fn("my_func", |args: &[Value]| -> Result<Value, VmError> {
    let Value::Int(n) = &args[0] else {
        return Err(VmError::new("expected Int".into()));
    };
    Ok(Value::Int(n * 2))
});

Typed registration (auto-marshalling)

The register_fn0 through register_fn3 methods handle argument extraction and type checking automatically:

vm.register_fn0("answer", || -> i64 { 42 });
vm.register_fn1("double", |x: i64| -> i64 { x * 2 });
vm.register_fn2("add", |a: i64, b: i64| -> i64 { a + b });
vm.register_fn3("clamp", |x: i64, lo: i64, hi: i64| -> i64 {
    x.max(lo).min(hi)
});

Type mismatches produce clear errors:

double: expected Int, got String

Supported Types

The FromValue and IntoValue traits handle conversion between Rust and silt types:

Rust typeSilt typeNotes
i64Int
f64FloatAlso accepts Int (coerces)
boolBool
StringString
()Unit
ValueanyPassthrough, no conversion
Vec<Value>List
Option<T>Some(v) / NoneReturn only
Result<T, String>Ok(v) / Err(msg)Return only

Return Values

Returning Option

vm.register_fn1("find_user", |id: i64| -> Option<String> {
    if id == 1 { Some("alice".into()) } else { None }
});

From silt:

match find_user(1) {
  Some(name) -> println("found: {name}")
  None -> println("not found")
}

Returning Result

vm.register_fn1("parse_int", |s: String| -> Result<i64, String> {
    s.parse::<i64>().map_err(|e| e.to_string())
});

From silt:

let n = parse_int("42")?  -- propagates Err with ?

Higher-Order Functions

Foreign functions work as first-class values. They can be passed to list.map, list.filter, piped with |>, and stored in data structures:

vm.register_fn1("square", |x: i64| -> i64 { x * x });
[1, 2, 3] |> list.map(square)   -- [1, 4, 9]

Thread Safety

All registered functions must be Send + Sync since they may be called from spawned task threads. This is enforced by the type system:

// This works:
vm.register_fn1("pure", |x: i64| -> i64 { x * 2 });

// This won't compile (captures non-Send state):
// let cell = std::cell::RefCell::new(0);
// vm.register_fn0("bad", move || { *cell.borrow() });

Use Arc<Mutex<T>> if you need shared mutable state in a foreign function.