Embedding candela in Rust¶
candela runs inside a Rust program as a library. You register Rust functions the script can call, load a script once, and then call its functions repeatedly while the interpreter keeps its state between calls.
There are two ways to do it, and they differ in one thing: whether the compiler is in your process.
- Link
candelaand useEngine/Programto compile source at run time. Scripts can be edited and reloaded while the program runs. - Link
candela-vmalone and useHostRegistry/RuntimeProgramto load a.cdlbartifact built beforehand. The compiler is absent, the binary is smaller, and no source is shipped.
Everything else is shared: the same host functions, the same Value type, the
same call-by-name. Start with the first if you are unsure; jump to
running a precompiled artifact for the second.
Add the crate as a dependency and enable the embed feature:
The crate publishes as candela-lang, because the name candela on crates.io
belongs to an unrelated project. The package key renames it back, so your code
keeps writing use candela::... as everything below does.
The feature changes what a fatal error does: instead of ending the process, it
unwinds so the host survives and receives the error as a value. It needs a
profile that unwinds, which the crate provides as embed:
A whole program¶
use candela::{Engine, Value};
fn main() -> Result<(), candela::Diagnostic> {
let mut engine = Engine::new();
engine.register_host_fn("app", "width", |name: &str| name.len() as i64);
let mut program = engine.compile(
r#"
host "app" {
int width(string);
}
fn banner(label: string) -> int {
return app::width(label) + 2;
}
fn main() {}
"#,
"banner.cdl",
)?;
let cells = program.call("banner", &["title".into()])?;
assert_eq!(cells, Value::Int(7));
Ok(())
}
Engine¶
Engine holds the table of registered host functions and compiles scripts.
register_host_fn¶
Binds a Rust closure to the name a script reaches through a host block. The
closure may take up to five arguments of any of these types, and return one of
them or ():
| Rust | candela |
|---|---|
i64, i32 |
int |
f64 |
float |
bool |
bool |
String, or a single &str argument |
string |
Vec<T> |
T[] |
BTreeMap<String, T>, HashMap<String, T> |
{string: T} |
() (return only) |
null |
The closure's signature is derived from its Rust types and checked against the
script's host declaration when you compile. A disagreement in arity, argument
type or return type is returned as a Diagnostic, never a panic.
A namespace is a namespace, so a host function may take a name a built-in
already has: gpio::read is the int its block declares, not the read that
returns a string.
A script calls a host function as app::rows(id) or as app.rows(id); both
spellings name the same function. A variable takes the name back: where app
holds a value, app.rows(id) is that value's own method and the block is
reachable only through app::.
A closure that can fail returns Result<T, HostError> in place of T:
use candela::HostError;
engine.register_host_fn("fs", "read", |path: &str| {
std::fs::read_to_string(path).map_err(HostError::new)
});
HostError::new takes anything that renders, so an error from the work the
closure was doing carries through with map_err. What the script sees is a
runtime error at the call, naming the function and repeating the message; it
can be caught with catch "host_fn_error", and it reaches the host as a
Diagnostic with that code when it is not. The type checked against the
declaration is the T inside the Result, so the two spellings bind to the
same host signature.
register_host_fn_variadic¶
engine.register_host_fn_variadic("app", "log", |args: &[Value]| {
for arg in args {
println!("{arg:?}");
}
Ok(Value::Null)
});
Binds a closure that receives every argument as a slice and returns one Value,
or a HostError to raise in the script, so mixed and dynamically shaped
arguments cross the boundary without a fixed Rust signature. The script must
declare the function with ...:
No arity or type checking happens at the call site; the closure interprets what
it is handed. A variadic declaration bound to a fixed closure, or the reverse,
is a Diagnostic at compile time.
register_host_fn_typed¶
use candela::{HostType, Value};
engine.register_host_fn_typed(
"gpio",
"read",
vec![HostType::Int],
HostType::Int,
|args: &[Value]| Ok(Value::Int(args[0].as_i64().unwrap_or(0))),
);
Binds a closure that takes a slice, like the variadic form, but with the
signature handed over as data instead of read off a Rust closure. The
declaration is checked against those types the same way, so it must not use
..., and a script calling gpio::read gets an int.
Use it when the signature is only known at run time: a plugin table, a generated binding, anything a fixed Rust closure cannot spell.
pub enum HostType {
Int,
Float,
Bool,
String,
Unit,
Array(Box<HostType>),
Map(Box<HostType>),
Struct(Vec<(String, HostType)>),
}
These are the same types the table above lists, in the order arguments are
passed. Unit is candela's null, which is what a function that returns
nothing declares. Map is always string-keyed, so only the value type is
carried. Struct is a struct the host block declares, as its fields in
declaration order; see structs in a host block.
Structs in a host block¶
A function that takes a set of options takes them as a struct the host block
declares next to it. Each field gives its type and, if it has one, the value it
starts with:
host "process" {
struct StartOptions {
cwd: string = ".",
env: {string: string},
end_at_exit: bool = true,
}
bool start(string, string[], string, StartOptions);
}
Inside the block the struct is named bare. A script names it behind the namespace, the way it calls the block's functions, and builds it like any struct, defaults included:
fn launch() -> bool {
return process::start("java", ["-jar", "game.jar"], "game",
process::StartOptions { cwd: "instances/a", ..Default::default() });
}
process::StartOptions::default() and a bare Default::default() in the
struct's parameter work as well; see default values.
The closure is registered with register_host_fn_typed, naming the struct's
fields in the order the block declares them, and receives the value as a
Value::Map keyed by field name:
use candela::{HostType, Value};
engine.register_host_fn_typed(
"process",
"start",
vec![
HostType::String,
HostType::Array(Box::new(HostType::String)),
HostType::String,
HostType::Struct(vec![
("cwd".into(), HostType::String),
("env".into(), HostType::Map(Box::new(HostType::String))),
("end_at_exit".into(), HostType::Bool),
]),
],
HostType::Bool,
|args: &[Value]| {
let opts = args[3].as_map().expect("a record");
let cwd = opts["cwd"].as_str().unwrap_or(".");
Ok(Value::Bool(!cwd.is_empty()))
},
);
The binding checks every field name and type against the declaration, so a
registration written for another version of the struct is a
host_fn_signature_mismatch, not a missing key at run time. The defaults live
in the declaration: the script builds the whole value before the call, and the
closure always receives every field.
A struct crosses into the host only. A host function cannot return one, since
the value would come back as a map; a declaration that does is refused when it
binds. A .cdlb built from the script records the struct with the program's
own, so RuntimeProgram binds and passes it the same way.
register_macro¶
use candela::macros::MacroError;
engine.register_macro("lmn", |body: &str| {
Ok::<String, MacroError>(format!("\"{}\"", body.trim()))
});
Gives lmn!( ... ) a meaning in the scripts this engine compiles. The closure
receives the raw text between the parentheses, which candela does not interpret,
and returns candela source for one expression, which is parsed where the macro
stands. This is how a host puts its own syntax into a script; see
macros for what a script author sees. An expansion may
use a macro itself, up to 32 levels deep, so an expander that emits its own
macro fails the compile instead of running out of stack.
Returning MacroError instead fails the compile at the macro:
offset is a byte offset into the region body. Set it (MacroError::at) and
the diagnostic points at that position in the file the macro was written in;
leave it out (MacroError::new) and it covers the whole invocation. The region
ends at the parenthesis balancing the one that opened it, ignoring parentheses
inside candela string literals and after //.
allow_unknown_macros¶
A macro with no registered expander fails the compile by default, naming it.
Turning this on compiles it as null instead. It is for tools that read scripts
written for a host they are not part of, and would otherwise report every one of
that host's macros as an error; candela's own language server does this.
with_cfg¶
use candela::Cfg;
let mut cfg = Cfg::new();
cfg.enable("web");
cfg.enable_value("target", "wasm32");
let engine = candela::Engine::new().with_cfg(cfg);
Sets the flags @cfg(...) tests in
the scripts this engine compiles. enable turns on a flag, so @cfg(web)
holds; enable_value turns on a pair, so @cfg(target = "wasm32") holds. A
flag the configuration does not enable is false. Cfg also collects from an
iterator of flag names: Cfg::from_iter(["web", "debug"]).
candela gives no flag a meaning. Pick the names your host sets, document them for the people writing scripts against it, and pass the same set to every compile of one target.
A compile that does not go through an Engine, such as build_bytecode or
compile_checked, reads the configuration of the Cfg::scope it runs in:
The configuration covers every file the compile imports. Outside a scope, nothing is enabled.
compile¶
Parses and type-checks the source, binds every host function it declares to a
registered closure, and runs main once so top-level setup is done before the
host makes its first call. The filename is what error reports name.
It also compiles the body of every function in the file you pass whose
parameters are all annotated, at those declared types, whether or not anything
calls it. An error in a function main never calls comes back from compile
rather than from the first call. A function an import brought in is left for
the call. The check runs before main, so a broken body is reported before any
top-level setup has run.
A function with a bare parameter that nothing in the script calls is one the
host calls, so compile warns about each bare parameter and checks the body
with those parameters typed any. A body that does not compile that way is
another warning, not an error; the function is still compiled by the first
call that reaches it. The warnings come back on the program:
Each is a Diagnostic like an error, with a code, a plain-text message and a
span. See errors for the codes.
candela::compile_checked is that check on its own, the check candela build
runs before its release passes. It prints its warnings to stderr; run it inside
candela::collect_warnings to get them back as Diagnostics instead. To check
without opening libraries, use check.
A main is required: an embedded program runs one the way a run from the CLI
does. candela check is the step that compiles a file without one.
Returns a Diagnostic when the script does not compile, when the body of an
annotated function does not compile at its declared parameter types, when the
script declares no main, when a declared host function has no registered
closure, when a registered closure disagrees with its declaration, or when
running main raises a runtime error.
check¶
Type-checks the source the way compile does and stops there: no Program
comes back and nothing runs. It returns the warnings the compile raised, or the
first error as a Diagnostic.
A check needs less than a compile. A dylib block binds the signatures it
declares without opening its library, so a script whose C library a build step
has not produced yet still checks; the call sites are type-checked against the
declared signatures, and a signature naming a type C cannot represent is still
an error. host blocks are not matched against registered closures, and main
is not required. A source without main is checked as a library, so it raises
no warning about bare parameters. candela check and the language server check this way, and
candela::check_only is the same check for a frontend that wants the compile
tables it produces.
Program¶
Program is a compiled script with live interpreter state. Registers and heap
stay resident, so anything one call establishes is visible to the next. It is
single-threaded and neither Send nor Sync, matching the VM.
call¶
Invokes a script function by name and returns its value, or Value::Null for a
function that returns nothing. Arguments are Values; .into() covers the
scalars.
The first call with a given set of argument types compiles an entry for it, and later calls with the same types reuse that entry, so a function a host calls every frame is compiled once.
The call is type-checked against the function's signature, so a wrong argument
type comes back as a Diagnostic rather than corrupting the run. Annotate the
parameters of a function a host calls. A function called only from the script
takes its parameter types from the call site, but a host-called function has no
such call site, and the annotation is what the arguments are checked against:
Leaving the parameters bare still works, and the types are then taken from
each host call. compile warns about such a parameter and checks the body with
it typed any, which catches an error that does not depend on the argument.
Annotate when you want a mismatch reported against the declaration rather than
accepted as a new specialisation, and when you want the body checked at the
types the host passes.
warnings¶
The warnings compile raised, in the order it raised them. A warning does not
stop the compile.
Returns a Diagnostic when the function is unknown, when the arguments do not
type-check, or when the call raises a runtime error.
A Program that returns a Diagnostic stays usable. Some of that error
handling happens while call compiles the specialization it needs (an
undeclared name at the call site, an argument that fails to type-check, a
nested call that needed compiling first); whatever that compile step wrote to
the program's resident tables before hitting the error is undone, so the next
call runs as if the failed one had never been made. A runtime error, once the
call has started running, leaves the program's globals and any host-side state
exactly as the call left them; nothing after the failing point ran.
Finding macro regions¶
A build tool often needs to know where a macro is used before anything is
compiled: to collect the markup in a project, to hash it, to generate assets
from it. scan_regions runs the scanner the lexer runs, over plain source:
use candela::macros::scan_regions;
for region in scan_regions(&source, "lmn") {
println!("{} at {:?}", region.body, region.span);
}
body is the raw text between the parentheses, body_start its byte offset in
the source, and span the byte range of the whole invocation. The results match
what compiling the same source expands, including which name!( occurrences do
not count: one written inside a string literal or after // is not an
invocation. Scanning stops at a region the source never closes, so every region
returned is complete.
Running a precompiled artifact¶
Build the script to a .cdlb first, with candela build or build_bytecode,
then link only candela-vm in the program that runs it. Both compile in the
release profile, which runs the passes that make the program faster; see
the CLI reference. build_artifact takes a
BuildOptions to choose the profile and the machine
code to include; machine code needs the
native feature of candela-lang, and without it an artifact carries none.
candela-vm runs an artifact's machine code where it fits the machine, and
RuntimeProgram::native_functions says how many functions run that way. The
program that runs the artifact depends on the runtime alone:
use candela_vm::{HostRegistry, Value, load_program};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut hosts = HostRegistry::new();
hosts.register_host_fn("app", "width", |name: &str| name.len() as i64);
let bytes = std::fs::read("banner.cdlb")?;
let mut program = load_program(&bytes, &hosts)?;
program.run();
let cells = program.call("banner", &["title".into()])?;
assert_eq!(cells, Value::Int(7));
Ok(())
}
The library leaves your process's allocator as you set it. The candela-vm
binary sets glibc's for itself, which makes programs that build and drop big
lists much faster (see running an artifact).
A host on Linux with glibc gets the same by starting with
GLIBC_TUNABLES=glibc.malloc.top_pad=67108864:glibc.malloc.mmap_threshold=33554432,
or by calling mallopt with those two values before it loads a program.
HostRegistry¶
The table of closures a script's host blocks bind to. register_host_fn,
register_host_fn_typed and register_host_fn_variadic take the same arguments
and derive the same signatures as their Engine counterparts above.
Binding happens in load_program, and it checks what compiling a script checks:
every declared function must be registered, and each closure's arity, argument
types and return type must match the declaration. A LoadError::HostBinding
comes back naming what is missing or what disagrees, before any instruction runs.
Engine holds one of these registries internally, which is why the two paths
accept the same closures.
run¶
Runs main, the same way candela-vm does: a runtime error prints its report
and ends the process. Call it once, before the first call, so top-level setup
is done. To keep a failing script from taking the process with it, run it inside
collect_diagnostic, which turns the error into a Diagnostic you can handle.
call¶
Invokes a function by name against the resident state, returning its value or
Value::Null for a function that returns nothing. Arguments are checked against
the declared parameter types first.
Only functions the artifact exports are callable, and program.exports() lists
them. A function is exported when it is defined in the file that was built, is
reachable by its bare name, is not main, and has a type a host value can be on
every parameter. A bare parameter on a function nothing in the script calls is
exported as any, with a build warning; see
artifacts for the full rule. This is the difference
that matters when moving a script from Engine to an artifact: bare parameters
take their types from each host call there, but an artifact has no compiler to
specialise them later, so the body runs with them typed any. Annotate them to
have the body compiled at the types the host passes.
Errors come back as a CallError: the name is not exported, the argument count
or an argument type disagrees with the declaration, or the call raised a runtime
error, which arrives as the Diagnostic it produced.
Collecting garbage between frames¶
Program and RuntimeProgram both have collect. The collector frees heap
values the program can no longer reach. Left to itself, it works while the
program allocates: once a collection is under way, each allocation does a small
share of it, so no allocation stops the program for long, but the frame that
allocates pays for the work. A collection begins when the heap holds enough
more objects than the last one left, or when the program has allocated enough
more bytes than the last one found in use, so a program that builds a few large
lists is collected as well as one that builds many small values. collect lets
the host do that work when nothing is waiting on the program, such as between
frames.
collect(budget) carries on a collection already under way, or begins one when
the heap has grown enough since the last, and stops after about budget units
of work. It returns true when no collection work is left, and false when a
collection is still under way; the next call carries on from where this one
stopped, and so do the allocations of any call in between. On a heap that has
not grown, it does nothing and returns true, so calling it every frame costs
next to nothing when there is nothing to collect.
The budget counts work, not time: a unit is about one object traced or one slot
freed. The same calls do the same work on every machine, in a browser as well,
where the runtime has no clock to go by. collect(u32::MAX) runs a whole
collection in one call. The call that begins a collection reads every register
at once whatever the budget, and a call can run past its budget by up to one
batch of slots, so treat the budget as a target rather than a hard limit.
To pick a budget, time collect on the slowest machine you support and raise
the budget while the call still fits the idle time a frame leaves. A budget too
small to finish a collection between frames is safe: the allocations of the next
frame pick up what it leaves.
Once a host has called collect, allocations wait for the heap to grow further
before they begin a collection of their own, which leaves the work to the host.
A program no host collects for keeps the default thresholds.
gc_stats¶
What the collector has done over the program's heap, and how large the heap is:
| Field | Meaning |
|---|---|
cycles |
Collections begun |
units |
All collection work done, in budget units |
largest_slice |
The most units one step did, whether an allocation or collect asked for it |
arrays, maps, strings |
Each pool's len in slots and how many of them are free |
The arrays pool holds lists, structs, enum values and functions; strings
holds strings too long to fit inside a value. There are no time fields. To see
how long collecting takes, time your own collect calls.
Where an import looks¶
A script's imports resolve against two things the host can name: the standard library, and the packages it should be able to reach.
let engine = candela::Engine::new()
.with_lib_dir(app_root.join("libs"))
.with_import_root("shapes", cache.join("shapes/1.2.3"));
with_lib_dir names the directory holding std/, so import "std/string";
reads libs/std/string.cdl. It is what an application that ships the standard
library inside its own installation uses instead of setting
CANDELA_LIB_PATH. Without it, the default applies: CANDELA_LIB_PATH when
set, and libs/ beside the running executable otherwise.
with_import_root makes a directory the root of a package. The package is
entered through the file its candela.toml names as entry, src/main.cdl
when it names none or there is no manifest, so import "shapes"; reads that
file and import "shapes/circle"; reads circle.cdl beside it. This is how a
host offers a script code it did not ship with, and it is the same mechanism
the candela command uses for the packages a project depends on.
Both are builders, so they chain, and both take effect on every script the
engine compiles. Engine::compile is unchanged.
Where a dylib import looks¶
A script's dylib import is looked for beside the script itself, which is the
wrong place for an application that keeps its sources in one directory and its
native libraries in another. Name those directories and they are searched
first, in order, by a compile and by an artifact load alike, so dylib "md" in
src/main.cdl finds lib/libmd.so.
A library the standard library owns is looked for under the toolchain's libs
directory as well, after the directories named here, so an artifact that imports
std/math loads wherever it runs.
The call returns the list that was in effect, so a host that changes it for one
script can put the previous one back, and an empty list goes back to looking
beside the script alone. It is a per-thread setting, read while a script
compiles and while an artifact loads, so set it before either.
candela_vm::set_dylib_dirs is the same function, for a program that links
only the runtime.
Every directory named with with_import_root is searched for native libraries
too, after the ones set here, so a package that ships a shared library at its
root binds without further setup.
See C libraries for what a dylib import resolves to before
any directory is searched.
Values¶
Value is the type that crosses the boundary in both directions:
pub enum Value {
Null,
Int(i64),
Float(f64),
Bool(bool),
String(String),
Array(Vec<Value>),
Map(BTreeMap<String, Value>),
Enum { variant: String, payload: Vec<Value> },
}
From is implemented for i64, i32, f64, bool, String, &str and
(), so 5i64.into() and "title".into() build one. Going the other way,
as_i64, as_f64, as_str, into_string, into_array and into_map unwrap
one when it holds what you ask for.
Value::Int is an i64, the width a candela int holds, so an integer
crosses either way unchanged. Arrays are homogeneous and maps are string-keyed,
matching how candela types them. A struct read back from a script arrives as a
Map of its fields.
A list or map argument is built in the program's heap the way one the script builds is, reusing the space of values the collector has freed. Once the call returns, it is garbage unless the script kept it, so a host that passes a fresh list every frame does not grow the heap.
An enum arrives as Value::Enum, naming the variant it
holds and carrying that variant's payload in declaration order; a nullary
variant has an empty payload. The enum's own type name is not carried, because
the variant is what you match on:
match program.call("pick", &[Value::Int(1)])? {
Value::Enum { variant, payload } if variant == "Line" => {
println!("line {:?} to {:?}", payload[0], payload[1]);
}
other => println!("{other:?}"),
}
Enums travel outward only. A Value::Enum is refused as an argument in any
position, an any parameter and a list element included, and the call comes
back as an argument_type_mismatch naming the variant it was handed. A
parameter typed as an enum is not host-callable at all: an artifact gives such
a function no export. Build the value inside the script and return it.
A generic type is instantiated at compile time, so
nothing generic reaches the host: Cell<int> crosses the boundary as the
ordinary struct it compiles to.
Errors¶
Every fallible Engine/Program call returns Diagnostic:
pub struct Diagnostic {
pub filename: String,
pub span: Range<usize>,
pub message: String,
pub code: String,
}
code is a stable snake_case identifier you can match on, such as
unknown_variable, argument_type_mismatch, index_out_of_bounds,
unregistered_host_fn, host_fn_signature_mismatch or host_fn_error (one of
your own closures returned a HostError). message is plain text
with the terminal colouring removed, and span is a byte range into the file
filename names, which is enough to underline the offending source yourself.
See errors.
Only one diagnostic comes back per call, because compilation stops at the first error.
On the artifact path the errors are LoadError and CallError instead, both of
which print themselves. A runtime error inside a call still arrives as the
Diagnostic above, wrapped in CallError::Runtime.
The VM hands a runtime error back to call as a value, on both paths, so a call
does not unwind and leaves the process panic hook alone. Compiling is different:
the compiler recovers from an error by unwinding, and while it compiles, a hook
of candela's silences that unwind and passes every other panic on to yours.
That is why Engine::compile needs a profile that unwinds.
Evaluating source at run time¶
Compiling is opt-in and explicit: a host that wants to evaluate new source calls
Engine::compile again. There is no eval inside the language, so a script
cannot compile new code by itself, and a host that never calls compile after
start-up cannot be made to.
This is also the difference between the two ways of shipping candela in a host.
Linking the candela crate brings the compiler, so scripts can be compiled at
run time and reloaded. Shipping precompiled .cdlb artifacts and linking only
candela-vm leaves the compiler out of the process entirely, and with it any
way to evaluate source at all; see artifacts.
One-shot execution¶
For running a script and collecting what it printed, without keeping any state,
the crate exports a C entry point: candela_run takes source and returns the
captured output, and candela_free_output releases it. Use Engine and
Program for anything that calls back and forth.