Chapter 33. Registering Native Functions
Goal
By the end of this chapter you will be able to register host functions that scripts call, by both the ergonomic and the lower-level routes.
What a native function is
A native function is a Rust function the host registers with the VM under
a name. A script calls it by that name, after a use declaration. Native
functions are the bridge: the script decides what should happen, the
native does it. The piano roll’s host::play is a native; the script
calls it, and the Rust side updates the audio voice state.
Native functions are registered after Vm::new and before the script
runs.
The ergonomic route: register_fn
The recommended route is register_fn. It accepts any Rust function or
closure of arity zero through four whose argument and return types
implement the KeleusmaType marshalling trait, which the primitive types
already do:
#![allow(unused)]
fn main() {
vm.register_fn("math::add", |a: i64, b: i64| -> i64 { a + b });
vm.register_fn("math::sin", |x: f64| -> f64 { libm::sin(x) });
}
Argument extraction, arity checking, and return-value wrapping are
handled automatically. For a function that may fail, register_fn_fallible
accepts a Result<R, VmError> return:
#![allow(unused)]
fn main() {
vm.register_fn_fallible("io::read_setting", |key: &str| -> Result<String, VmError> {
fetch(key).map_err(|e| VmError::NativeError(format!("{}", e)))
});
}
Strings: take a borrowed view
A native that takes a script’s Text should declare its argument as
&str, not String:
#![allow(unused)]
fn main() {
vm.register_fn("host::name_length", |s: &str| -> i64 { s.len() as i64 });
}
The borrow is valid for the call and no longer. That is not a
restriction the marshalling layer invented; a script’s dynamic string
lives in the arena, and the arena is reclaimed at the next resume. The
lifetime simply makes the rule one the compiler checks. Copy what you
need to keep before returning.
Two properties of the view matter and are easy to get wrong:
- The length is authoritative. A Keleusma string is a byte string, so it may contain a NUL byte in the middle, and that byte is content. Do not treat the view as NUL-terminated.
- The length counts bytes, not characters. A three-character string may be six bytes long.
An owned String argument still works and behaves as it always has:
#![allow(unused)]
fn main() {
vm.register_fn("host::name_length", |s: String| -> i64 { s.len() as i64 });
}
It is a convenience for the VM embedding only. The ahead-of-time native
backend cannot hand a native an owned String without copying, so a
native written against String does not carry across to that embedding,
while one written against &str does. If you may ever compile your
scripts ahead of time, take the borrowed view. The full contract is in
docs/spec/NATIVE_STRING_ABI.md.
A borrowed string may appear in any argument position, mixed freely with owned arguments:
#![allow(unused)]
fn main() {
vm.register_fn("host::pad", |s: &str, width: i64| -> i64 {
core::cmp::max(s.len() as i64, width)
});
}
A host struct or enum crosses the boundary by deriving KeleusmaType:
#![allow(unused)]
fn main() {
#[derive(KeleusmaType, Debug, Clone)]
struct Point { x: f64, y: f64 }
}
The lower-level route: register_native_closure
register_fn cannot capture host state, because its argument is a plain
function shape. When a native must read or write state the host owns, use
register_native_closure. It takes a closure that receives the raw
&[Value] arguments and returns Result<Value, VmError>:
#![allow(unused)]
fn main() {
let voices = shared_voices.clone();
vm.register_native_closure("host::silence", move |args: &[Value]| {
let channel = match args[0] {
Value::Int(n) => n as usize,
ref other => return Err(VmError::TypeError(
format!("expected Int, got {:?}", other))),
};
voices.lock().unwrap()[channel].gate = false;
Ok(Value::Unit)
});
}
This is the route the piano roll uses for every one of its natives,
because each one captures the shared Arc<Mutex<[Voice; 8]>> voice
table. The closure owns its captured clone, and inspecting the raw
Value lets the native validate its arguments explicitly. A plain
function pointer with no captured state can instead use register_native.
Bundled libraries
The keleusma::stddsl module ships three bundles of natives, registered
in one call each:
#![allow(unused)]
fn main() {
vm.register_library(stddsl::Math); // math::sqrt, math::pow, ...
vm.register_library(stddsl::Audio); // audio::midi_to_freq, ...
vm.register_library(stddsl::Shell); // shell::getenv, shell::run, shell::sleep_ms,
// shell::read_file, shell::write_file, ...
}
The Shell bundle in V0.2.1 covers environment access (getenv,
has_env, setenv), subprocess execution (run, run_full,
run_checked, run_timeout), process termination (exit), timing (sleep_ms,
now_unix_ms), file I/O (read_file, write_file, append_file,
file_exists), stderr output (write_err, writeln_err), and host
metadata (pid, hostname, arg_count, arg, pwd, cd). See
STANDARD_LIBRARY.md for the full list.
Each bundle exposes a public SIGNATURES constant containing
source-form use declarations. Hosts that want compile-time type and
arity validation prepend the constant to the script source before
parsing; the bundled keleusma-cli does this for all three bundles. A
custom bundle can implement the Library trait and expose a parallel
SIGNATURES constant to participate in the same validation flow.
What you now know
- A native function is a host Rust function a script calls by name.
register_fnandregister_fn_fallibleare the ergonomic route, for functions whose types implementKeleusmaType.register_native_closureis the route for natives that capture host state, as every piano roll native does.register_libraryinstalls a bundled or custom set of natives.- A fallible native’s failure is handled on the script side with the
native-error construct
native(args) { ok(v) => ..., error(code) => ... }. The host reports theWorderror code by returning an error built with theKeleusmaErrorderive, which maps a fieldless enum’s variants to their discriminants. See Chapter 23.
The next chapter drives a script that yields.