Embedding Rex in Rust
Rex is designed as a small pipeline you can embed at whatever stage you need:
rex-parser: source →CompilationUnit { decls, body }rex-typesystem: HM inference + type classes →TypedExpr(plus predicates/type)rex-engine: build host modules, compile typed code intoCompiledProgram, then run it →rex_engine::Handle
This document focuses on common embedding patterns.
Running Untrusted Rex Code (Production Checklist)
This repo provides language-level parsing limits and a pure evaluator suitable for embedding. Your production server is responsible for enforcing hard resource limits (process isolation, wall-clock timeouts, memory limits).
Recommended defaults for untrusted input:
- Parsing enforces a fixed AST-depth cap.
- Run evaluation in an isolation boundary you can hard-kill (separate process/container), with CPU/RSS/time limits.
Evaluation API:
- Evaluation is async via
Evaluator.
Compile Then Run
rex-engine now has an explicit preparation boundary:
Enginebuilds the host environment.Compilerprepares user code into aCompiledProgram.Evaluatorowns the runtime core and runs one prepared program with runtime inputs formain.
Evaluator is single-shot: Evaluator::run consumes the evaluator, the compiled program, and a
BTreeMap<String, Handle> of inputs. Programs are compiled with Rex’s singular external interface
semantics: an explicit fn main ... defines named runtime inputs, while a final expression without
main is treated as an implicit zero-input main.
use rex::{
engine::{CompileOptions, Engine},
parser::parse,
};
let engine = Engine::with_prelude(())?;
let mut compiler = engine.into_compiler();
let parsed = parse("let x = 1 + 2 in x * 3").map_err(|errs| format!("{errs:?}"))?;
let program = compiler
.compile_program(&parsed, CompileOptions::default())
.await?;
assert_eq!(program.result_type().to_string(), "i32");
let evaluator = compiler.into_evaluator();
let value = evaluator.run(program, Default::default()).await?;
What “compiled” means in the current design:
- parsing, import rewriting, declaration injection, and typechecking have already happened
CompiledProgramcarries a typed expression plus the environment snapshot needed to run itCompiledProgram::main_signature()reports input names/types and the external result typeEvaluatorowns the runtime core needed for executionEvaluator::runconsumes the evaluator, compiled program, and runtime input map; use a new engine/compiler/evaluator for another generated workflow
What is captured:
- Rex declarations that are part of the prepared program are captured into the compiled env snapshot
- host-provided exports registered through
export,export_async,export_native,export_native_async, orexport_valueare carried by the evaluator produced from the same compiler - typeclass method bindings are carried by that same evaluator runtime
That means a CompiledProgram is intended to be run by the evaluator created from the same
compiler. Rex does not currently expose a portable compiled artifact or cross-runtime linking
model.
Phase-specific errors:
CompilerAPIs returnEngineErrorEvaluator::runreturnsEngineError- APIs that parse, compile, and run in one call return
ExecutionErrorbecause they cross phase boundaries
Compile parsed Rex sources with Compiler::compile_program and pass the resulting
CompiledProgram to Evaluator::run.
Evaluate Rex Code Directly
use rex::{
engine::Engine,
parser::parse,
};
let program = parse("let x = 1 + 2 in x * 3").map_err(|errs| format!("{errs:?}"))?;
let engine = Engine::with_prelude(())?;
let mut compiler = engine.into_compiler();
let program = compiler.compile_program(&program, Default::default()).await?;
let evaluator = compiler.into_evaluator();
let value = evaluator.run(program, Default::default()).await?;
println!("{value}");
Module sources loaded via importers must be declaration-only. To run an
expression, use snippet or program entry points.
Qualified alias members used in type/class positions (annotations, where constraints, instance
headers, superclass clauses) are validated against module exports during module processing; missing
exports fail early with module errors.
Engine Initialization and Default Imports
Engine::with_prelude(state) is shorthand for Engine::with_options(state, EngineOptions::default()).
- Prelude is enabled by default.
Preludeis default-imported.- Default imports are weak: they fill missing names, but never override local declarations or explicit imports.
If you want full control:
use rex::engine::{Engine, EngineOptions, PreludeMode};
let mut engine = Engine::with_options(
(),
EngineOptions {
prelude: PreludeMode::Disabled,
default_imports: vec![],
},
)?;
Inject Modules (Embedder Patterns)
This is fully supported in rex-engine. You can compose module loading from:
- bundled stdlib imports (
std.*) - modules injected with
Engine::inject_module - custom async importers (for DB/object-store/in-memory modules)
1) Use an Explicit Importer
rex-engine does not read module files from disk by default. File-backed loading is a host
policy decision; the CLI installs its own filesystem importer, while embedded applications should
provide an importer that matches their trust boundary.
Use DenyImporter when you need an explicit importer implementation that rejects every module
request.
Notes:
- importers receive an
ImportRequestwith the requested module name and the importing module id. - snippets and parsed programs load declaration-only modules through the compiler’s import rewriting path; module source itself remains declaration-only.
- import clauses (
(*)/ item lists) import exported names into unqualified scope. - unqualified imports are context-sensitive: expression positions use values, type positions use types, and class/constraint positions use classes.
- module aliases (
import x as M) provide qualified access to exported values, types, and classes. - importing a name only brings in the facets that actually exist under that name.
2) Inject In-Memory Rex Modules
For host-managed modules, either call Engine::inject_module or add an importer that maps
module_name to source text.
use futures::future::BoxFuture;
use rex::{
engine::{
CompileOptions, Engine, ImportRequest, Importer, ModuleId, ResolvedModule,
ResolvedModuleContent,
},
parser::parse,
};
use std::collections::HashMap;
use std::sync::Arc;
let mut engine = Engine::with_prelude(())?;
let modules = Arc::new(HashMap::from([
(
"acme.math".to_string(),
"pub fn inc : i32 -> i32 = \\x -> x + 1;".to_string(),
),
(
"acme.main".to_string(),
"import acme.math (inc);\npub fn main : i32 = inc 41;".to_string(),
),
]));
struct MapImporter {
modules: Arc<HashMap<String, String>>,
}
impl Importer for MapImporter {
fn import<'a>(
&'a self,
req: ImportRequest,
) -> BoxFuture<'a, Result<Option<ResolvedModule>, rex::engine::EngineError>> {
Box::pin(async move {
let Some(source) = self.modules.get(&req.module_name) else {
return Ok(None);
};
Ok(Some(ResolvedModule {
id: ModuleId::Virtual(format!("host:{}", req.module_name)),
content: ResolvedModuleContent::Source(source.clone()),
}))
})
}
}
engine.add_importer("host-map", Arc::new(MapImporter { modules }));
let mut compiler = engine.into_compiler();
let parsed = parse("import acme.main (main);\nmain").map_err(|errs| format!("{errs:?}"))?;
let program = compiler
.compile_program(&parsed, CompileOptions::default())
.await?;
let value = compiler.into_evaluator().run(program, Default::default()).await?;
println!("{value}");
3) Host-Provided Rust Functions, Exposed as Modules
This is the common embedder case.
Use Module + Engine::inject_module(...):
- Create a
Module. - Add exports:
- typed exports with
export/export_async - runtime/native exports with
export_native/export_native_async - optional structured declarations with
add_rex_adt/add_adt_decl
- typed exports with
- Inject it into the engine.
Module::add_rex_adt::<T>() now stages the full acyclic ADT family reachable from T.
This is driven by RexType::collect_rex_family(...): ADT types contribute declarations there,
while leaf Rex types inherit a no-op default. For example, if Label contains a Side, staging
Label is enough; you do not need to stage Side separately. Cyclic ADT families are still
rejected.
Module also exposes its staged decls, adts, and exports vectors directly. That is useful
if you want to inspect, transform, or assemble a module in multiple passes before calling
Engine::inject_module.
export handlers are fallible and must return Result<T, EngineError>. If a handler returns
Err(...), evaluation fails with that engine error.
export_async handlers follow the same rule, but return
Future<Output = Result<T, EngineError>>.
use rex::{
engine::{CompileOptions, Engine, Module},
parser::parse,
};
let mut engine = Engine::with_prelude(())?;
let mut math = Module::new("acme.math");
math.export("inc", |_state: &(), x: i32| { Ok(x + 1) })?;
math.export_async("double_async", |_state: &(), x: i32| async move { Ok(x * 2) })?;
engine.inject_module(math)?;
let mut compiler = engine.into_compiler();
let parsed = parse("import acme.math (inc, double_async as d);\ninc (d 20)")
.map_err(|errs| format!("{errs:?}"))?;
let program = compiler
.compile_program(&parsed, CompileOptions::default())
.await?;
let value = compiler.into_evaluator().run(program, Default::default()).await?;
println!("{value}");
You can declare ADTs directly inside an injected host module:
use rex_ast::Symbol;
use rex_engine::{Engine, Module};
use rex_typesystem::types::{BuiltinTypeId, Type};
let mut engine = Engine::with_prelude(())?;
let mut m = Module::new("acme.status");
let mut status = engine.adt_decl("Status", &[]);
status.add_variant(Symbol::intern("Ready"), vec![]);
status.add_variant(
Symbol::intern("Failed"),
vec![Type::builtin(BuiltinTypeId::String)],
);
m.add_adt_decl(status)?;
engine.inject_module(m)?;
Then Rex code can import and use those names from the module:
import acme.status (Status, Failed);
let fail: string -> Status = \msg -> Failed msg in
match (fail "boom") with {
case Failed msg -> length msg;
case _ -> 0;
}
Status is used here in type position, while Failed is used in expression/pattern positions.
They are imported through the same name-based mechanism.
Internally this generates module declarations and injects host implementations under qualified module export symbols.
If you need to construct exports separately (for example to build a module from plugin metadata), you can use:
Export::from_handler/Export::from_async_handler(typed handlers)Export::from_native/Export::from_native_async(handle-based native handlers)
Then add them via Module::add_export, or push them into Module::exports directly if you are
assembling the module programmatically.
This example shows how to use Rust enums and structs as Rex-facing types with ADTs declared inside
the module itself. The host function accepts a Rust Label (containing a Rust Side enum), and
Rex code calls it through sample.render_label.
Example:
use rex::{
Rex,
engine::{CompileOptions, Engine, EngineError, Module},
parser::parse,
};
#[derive(Clone, Debug, PartialEq, Rex)]
enum Side {
Left,
Right,
}
#[derive(Clone, Debug, PartialEq, Rex)]
struct Label {
text: String,
side: Side,
}
fn render_label(label: Label) -> String {
match label.side {
Side::Left => format!("{:<12}", label.text),
Side::Right => format!("{:>12}", label.text),
}
}
let mut engine = Engine::with_prelude(())?;
let mut m = Module::new("sample");
m.add_rex_adt::<Label>()?;
m.export("render_label", |_state: &(), label: Label| {
Ok::<String, EngineError>(render_label(label))
})?;
engine.inject_module(m)?;
let mut compiler = engine.into_compiler();
let parsed = parse(
r#"
import sample (Label, Left, Right, render_label);
(
render_label (Label { text = "left", side = Left }),
render_label (Label { text = "right", side = Right })
)
"#,
)
.map_err(|errs| format!("{errs:?}"))?;
let program = compiler
.compile_program(&parsed, CompileOptions::default())
.await?;
let value = compiler.into_evaluator().run(program, Default::default()).await?;
println!("{value}"); // ("left ", " right")
In that example:
Labelis imported once and then used as both a type name and a constructor value.LeftandRightare imported as constructor values.render_labelis imported as a value.
3a) Runtime-Defined Signatures (Handle APIs)
If your host determines function signatures/behavior at runtime, use the native module export
APIs and provide an explicit Scheme + arity:
Module::export_nativeModule::export_native_async
These callbacks receive Context<State> (not just &State), so they can:
- read state via
engine.state() - allocate new values via
engine.heap() - inspect typed call information via the explicit
&Type/Typecallback parameter
Async native callbacks receive owned argument vectors and return Send + 'static futures so the
runtime can suspend them as explicit pending evaluation frames.
use futures::FutureExt;
use rex_engine::{Engine, Context, Handle, Module};
use rex::typesystem::{BuiltinTypeId, Scheme, Type};
let mut engine = Engine::with_prelude(())?;
let mut m = Module::new("acme.dynamic");
let scheme = Scheme::new(vec![], vec![], Type::fun(Type::builtin(BuiltinTypeId::I32), Type::builtin(BuiltinTypeId::I32)));
m.export_native("id_handle", scheme.clone(), 1, |_ctx: Context<()>, _typ: &Type, args: &[Handle]| {
Ok(args[0].clone())
})?;
m.export_native_async("answer_async", Scheme::new(vec![], vec![], Type::builtin(BuiltinTypeId::I32)), 0, |ctx: Context<()>, _typ: Type, _args: Vec<Handle>| {
async move { ctx.heap().alloc_i32(42) }.boxed()
})?;
engine.inject_module(m)?;
Scheme and arity must agree. Registration returns an error if the type does not accept the
provided number of arguments.
4) Custom Importer Contract (Advanced)
If you need dynamic/nonstandard module loading behavior, implement Importer.
Importer contract:
- return
Ok(Some(ResolvedModule { ... }))when you can satisfy the module. - return
Ok(None)to let the next importer try. - return
Err(...)for hard failures (invalid module payload, policy violations, etc.).
ResolvedModule can carry either ResolvedModuleContent::Source(...) for real Rex source or
ResolvedModuleContent::CompilationUnit(...) for preconstructed structured modules.
5) Snippets That Import Relative Modules
If you evaluate ad-hoc Rex snippets that contain imports, parse the snippet and
pass an importer path in CompileOptions for Compiler::compile_program. For
type-only checks through Compiler::infer_snippet, pass the same importer path
argument there:
use rex::{
engine::CompileOptions,
parser::parse,
};
let mut compiler = engine.into_compiler();
let parsed = parse("import foo.bar as Bar;\nBar.add 1 2")
.map_err(|errs| format!("{errs:?}"))?;
let program = compiler
.compile_program(
&parsed,
CompileOptions::default()
.with_importer_path(std::path::Path::new("/tmp/workflow/_snippet.rex")),
)
.await?;
let value = compiler.into_evaluator().run(program, Default::default()).await?;
Engine State
Engine is generic over host state: Engine<State>, where
State: Clone + Send + Sync + 'static.
The state is stored as engine.state: Arc<State> and is shared across all injected functions.
- Use
Engine::with_prelude(())?if you do not need host state. - If you do, pass your state struct into
Engine::new(state)orEngine::with_prelude(state). export/export_asynccallbacks receive&Stateas their first parameter.- Handle-based native APIs (
export_native*) receiveContext<State>so they can allocate public handles through the heap and readengine.state().
use rex_engine::Engine;
#[derive(Clone)]
struct HostState {
user_id: String,
roles: Vec<String>,
}
let mut engine: Engine<HostState> = Engine::with_prelude(HostState {
user_id: "u-123".into(),
roles: vec!["admin".into(), "editor".into()],
})?;
let mut globals = Module::global();
globals.export("have_role", |state, role: String| {
Ok(state.roles.iter().any(|r| r == &role))
})?;
engine.inject_module(globals)?;
Array/List Interop at Host Boundaries
Rex keeps both List a and Array a because they serve different goals:
List ais ergonomic for user-authored functional code and pattern matching.Array ais the host-facing contiguous representation (for exampleVec<u8>from filesystem reads).
At host function call sites, Rex performs a narrow implicit coercion from
List a to Array a in argument position. This means users can pass list
literals to host functions that accept Vec<T> without writing conversions.
accept_bytes [1, 2, 3]
where accept_bytes is exported from Rust with a Vec<u8> parameter.
For the opposite direction, Rex exposes explicit helpers:
to_list : Array a -> List ato_array : List a -> Array a
Why to_list Is Explicit (Not Implicit)
Array -> List conversion is intentionally explicit to keep runtime costs
predictable in user code. Converting an array into a list allocates a new
linked structure and changes performance characteristics for downstream
operations.
If this conversion were implicit everywhere, the compiler could silently insert it in places where users do not expect allocation or complexity changes (for example inside control-flow joins, nested expressions, or polymorphic code). That would make performance harder to reason about and make type errors less transparent.
By requiring to_list explicitly, we keep intent and cost visible at the exact
program point where representation changes. This preserves ergonomics while
avoiding hidden work:
match (to_list bytes) with {
case Cons head _ -> head;
case Empty -> 0;
}
Typecheck Without Evaluating
use rex::{
parser::parse,
typesystem::{infer, standard_type_system},
};
let program = parse("map (\\x -> x) [1, 2, 3]").map_err(|errs| format!("{errs:?}"))?;
let mut ts = standard_type_system()?;
for decl in &program.decls {
match decl {
rex_ast::Decl::Type(d) => ts.register_type_decl(d)?,
rex_ast::Decl::Class(d) => ts.register_class_decl(d)?,
rex_ast::Decl::Instance(d) => {
ts.register_instance_decl(d)?;
}
rex_ast::Decl::Fn(d) => ts.register_fn_decls(std::slice::from_ref(d))?,
}
}
let body = program
.body
.as_ref()
.expect("snippet must contain a final expression");
let (preds, ty) = infer(&mut ts, body.as_ref())?;
println!("type: {ty}");
if !preds.is_empty() {
println!(
"constraints: {}",
preds.iter()
.map(|p| format!("{} {}", p.class, p.typ))
.collect::<Vec<_>>()
.join(", ")
);
}
Type Classes and Instances
Users can declare new type classes and instances directly in Rex source. As the host, you:
- Parse Rex source into
CompilationUnit { decls, body }. - Inject
Decl::Class/Decl::Instanceinto the type system (if you’re typechecking without running). - Inject all decls into the engine (if you’re running), so instance method bodies are available at runtime.
Typecheck: Inject Class/Instance Decls into TypeSystem
use rex::{
parser::parse,
typesystem::{infer, standard_type_system},
};
let code = r#"
class Size a where {
size : a -> i32;
}
instance<t> Size (List t) where {
size = \xs ->
match xs {
case Empty -> 0;
case Cons _ rest -> 1 + size rest;
};
}
size [1, 2, 3]
"#;
let program = parse(code).map_err(|errs| format!("{errs:?}"))?;
let mut ts = standard_type_system()?;
for decl in &program.decls {
match decl {
rex_ast::Decl::Type(d) => ts.register_type_decl(d)?,
rex_ast::Decl::Class(d) => ts.register_class_decl(d)?,
rex_ast::Decl::Instance(d) => {
ts.register_instance_decl(d)?;
}
rex_ast::Decl::Fn(d) => ts.register_fn_decls(std::slice::from_ref(d))?,
}
}
let body = program
.body
.as_ref()
.expect("snippet must contain a final expression");
let (_preds, ty) = infer(&mut ts, body.as_ref())?;
assert_eq!(ty.to_string(), "i32");
Evaluate: Inject Decls into Engine
use rex_engine::{Engine, EngineError, Module};
use rex::parser::parse;
let code = r#"
class Size a where {
size : a -> i32;
}
instance<t> Size (List t) where {
size = \xs ->
match xs {
case Empty -> 0;
case Cons _ rest -> 1 + size rest;
};
}
(size [1, 2, 3], size [])
"#;
let program = parse(code).map_err(|errs| format!("{errs:?}"))?;
let engine = Engine::with_prelude(())?;
let mut compiler = engine.into_compiler();
let compiled = compiler.compile_program(&program, Default::default()).await?;
let _ty = compiled.result_type().clone();
let value = compiler.into_evaluator().run(compiled, Default::default()).await?;
println!("{value}");
Inject Native Values and Functions
rex-engine is the boundary where Rust provides implementations for Rex values.
For host-provided modules, prefer Module + inject_module (above). For root-scope values
or functions, use Module::global() and inject that staged module into the engine.
use rex_engine::{Engine, Module};
let mut engine = Engine::with_prelude(())?;
let mut globals = Module::global();
globals.export_value("answer", 42i32)?;
globals.export("inc", |_state, x: i32| { Ok(x + 1) })?;
engine.inject_module(globals)?;
Integer Literal Overloading with Host Natives
Integer literals are overloaded (Integral a) and can specialize at call sites. This works for
direct calls, let bindings, and lambda wrappers:
use rex::parser::parse;
use rex_engine::{Engine, Module};
for code in [
"num_u8 4",
"let x = 4 in num_u8 x",
"let f = \\x -> num_i64 x in f 4",
] {
let mut engine = Engine::with_prelude(())?;
let mut globals = Module::global();
globals.export("num_u8", |_state: &(), x: u8| Ok(format!("{x}:u8")))?;
globals.export("num_i64", |_state: &(), x: i64| Ok(format!("{x}:i64")))?;
engine.inject_module(globals)?;
let program = parse(code).map_err(|errs| format!("parse error: {errs:?}"))?;
let mut compiler = engine.into_compiler();
let compiled = compiler.compile_program(&program, Default::default()).await?;
let _ty = compiled.result_type().clone();
let value = compiler.into_evaluator().run(compiled, Default::default()).await?;
println!("{value}");
}
Negative literals specialize only to signed numeric types. For example, num_i32 (-3) is valid,
while num_u32 (-3) is a type error.
Float literals are similarly context-sensitive for primitive float widths. A literal such as 3.0
defaults to f32 when unconstrained, but specializes to f64 when passed to a native or Rex
function whose argument type is f64.
Async Natives
If your host functions are async, stage them in a module with export_async and run the compiled
program with Evaluator::run.
use rex::parser::parse;
use rex_engine::{Engine, Module};
let mut engine = Engine::with_prelude(())?;
let mut globals = Module::global();
globals.export_async("inc", |_state, x: i32| async move { Ok(x + 1) })?;
engine.inject_module(globals)?;
let program = parse("inc 1").map_err(|errs| format!("parse error: {errs:?}"))?;
let mut compiler = engine.into_compiler();
let compiled = compiler.compile_program(&program, Default::default()).await?;
let _ty = compiled.result_type().clone();
let v = compiler.into_evaluator().run(compiled, Default::default()).await?;
println!("{v}");
By default, admitted async host futures are polled inline by the evaluator. This keeps the engine portable and avoids assuming a particular runtime, which is important for wasm embedders. Inline polling is fine for futures that are naturally non-blocking, but CPU-heavy or blocking work should be moved onto an executor supplied by the embedding application.
Use set_parallelism_controller to decide when async host callbacks may be invoked. A
ParallelismController grants a NativeAsyncPermit for each admitted
async native call; the permit is held until that call completes. Controllers can therefore enforce
process-local limits, shared limits across several evaluators, or externally coordinated limits
backed by a cluster scheduler.
ExecutionBounds remains available as a fixed controller. Its max_ready_work value is only an
internal evaluator queue-pressure guard: it limits how many already-created Rex frames sit in the
active ready queue, but it does not reserve external compute capacity. Native async permits are the
backpressure mechanism for host jobs.
Use set_async_call_policy to wrap futures after they have been admitted. The policy decides where
an admitted future runs; the parallelism controller decides whether the host callback is allowed to
start yet.
use futures::FutureExt;
use rex_engine::{AsyncCallPolicy, Engine, EngineError, Module};
let mut engine = Engine::with_prelude(())?;
engine.set_async_call_policy(AsyncCallPolicy::executor_fn(|future| {
async move {
tokio::spawn(future)
.await
.map_err(|err| EngineError::Internal(format!("async host task failed: {err}")))?
}
.boxed()
}));
let mut globals = Module::global();
globals.export_async("inc", |_state, x: i32| async move { Ok(x + 1) })?;
engine.inject_module(globals)?;
The executor hook is intentionally generic rather than Tokio-specific. Native applications can use Tokio or any other Rust executor; wasm applications can keep the inline policy or adapt to browser task primitives in the host crate.
Parsing Limits
Parsing enforces a fixed AST-depth cap:
use rex::parser::parse;
let program = parse("(((1)))")
.map_err(|errs| format!("parse error: {errs:?}"))?;
Bridge Rust Types with #[derive(Rex)]
The derive:
- implements
RexType - implements
RexAdt - implements
IntoRex - implements
FromRex - adds inherent helper methods such as
inject_rex,rex_adt_decl, andrex_adt_family - declares an ADT in the Rex type system
- injects runtime constructors (so Rex can build values)
- discovers and registers the full acyclic ADT family needed by the root type
The derive does not implement RexDefault; inject_rex_with_default is available only when the
type already provides that trait.
Fields of type Vec<T> are exposed as Array T and convert to/from Rex
runtime arrays. When constructing or updating derived records from Rex code, use
to_array [...] for these fields.
That means MyType::inject_rex(&mut engine)? is enough for acyclic graphs of derived ADTs. You do
not need to manually register dependencies in topological order. Cyclic ADT families are still not
supported by this registration path.
If a field uses a Rust type that participates in Rex value conversion but is not itself a Rex ADT
(for example a leaf type with manual RexType / IntoRex / FromRex impls), no extra
field annotation is required. Such leaf types inherit the default no-op family collection from
RexType, so derived ADTs can contain them without trying to register them as ADTs.
use rex::{
Rex,
engine::{Engine, EngineError, FromRex, Handle, Heap, IntoRex},
typesystem::{RexType, Type},
};
#[derive(Debug, PartialEq)]
struct AtomRef(i32);
impl RexType for AtomRef {
fn rex_type() -> Type {
i32::rex_type()
}
}
impl IntoRex for AtomRef {
fn into_rex(self, heap: &Heap) -> Result<Handle, EngineError> {
self.0.into_rex(heap)
}
}
impl FromRex for AtomRef {
fn from_rex(handle: &Handle) -> Result<Self, EngineError> {
Ok(Self(i32::from_rex(handle)?))
}
}
#[derive(Rex, Debug, PartialEq)]
struct Fragment(Vec<AtomRef>);
let mut engine = Engine::with_prelude(())?;
Fragment::inject_rex(&mut engine)?;
use rex::{
Rex,
engine::{Engine, FromRex},
parser::parse,
};
#[derive(Rex, Debug, PartialEq)]
enum Maybe<T> {
Just(T),
Nothing,
}
let mut engine = Engine::with_prelude(())?;
Maybe::<i32>::inject_rex(&mut engine)?;
let program = parse("Just 1").map_err(|errs| format!("parse error: {errs:?}"))?;
let mut compiler = engine.into_compiler();
let compiled = compiler.compile_program(&program, Default::default()).await?;
let _ty = compiled.result_type().clone();
let v = compiler.into_evaluator().run(compiled, Default::default()).await?;
assert_eq!(Maybe::<i32>::from_rex(&v)?, Maybe::Just(1));
Register ADTs Without Derive
If your type metadata is data-driven (for example loaded from JSON), you can build ADTs
without #[derive(Rex)].
- Use
Engine::adt_decl_from_type(...)to seed an ADT declaration from a Rex type head. - Add variants with
AdtDecl::add_variant(...). - Stage it with
Module::add_adt_decl(...), then inject that module withEngine::inject_module(...).
Module::add_adt_decl(...) is the low-level single-ADT staging primitive. If you are building
several ADTs manually, prefer batching them in one module with add_adt_family(...).
use rex::{
ast::Symbol,
engine::{Engine, Module},
typesystem::{RexType, Type},
};
let mut engine = Engine::with_prelude(())?;
let mut globals = Module::global();
let mut adt = engine.adt_decl_from_type(&Type::con("PrimitiveEither", 0))?;
adt.add_variant(Symbol::intern("Flag"), vec![bool::rex_type()]);
adt.add_variant(Symbol::intern("Count"), vec![i32::rex_type()]);
globals.add_adt_decl(adt)?;
engine.inject_module(globals)?;
If you have a Rust type with manual RexType/IntoRex/FromRex impls, implement
RexAdt and provide rex_adt_decl(). Then Engine::inject_rex_adt::<T>() gives manual
types the same registration workflow that #[derive(Rex)] exposes as T::inject_rex(...).
If the manual Rust type is itself an ADT, override RexType::collect_rex_family(...) and add its
AdtDecl there. Leaf types can inherit the default no-op implementation.
use rex::{
ast::Symbol,
engine::Engine,
typesystem::{AdtDecl, RexAdt, RexType, Type, TypeError, TypeVarSupply},
};
struct PrimitiveEither;
impl RexType for PrimitiveEither {
fn rex_type() -> Type {
Type::con("PrimitiveEither", 0)
}
fn collect_rex_family(out: &mut Vec<AdtDecl>) -> Result<(), TypeError> {
out.push(<Self as RexAdt>::rex_adt_decl()?);
Ok(())
}
}
impl RexAdt for PrimitiveEither {
fn rex_adt_decl() -> Result<AdtDecl, TypeError> {
let mut supply = TypeVarSupply::new();
let mut adt = AdtDecl::new(&Symbol::intern("PrimitiveEither"), &[], &mut supply);
adt.add_variant(Symbol::intern("Flag"), vec![bool::rex_type()]);
adt.add_variant(Symbol::intern("Count"), vec![i32::rex_type()]);
Ok(adt)
}
}
let mut engine = Engine::with_prelude(())?;
engine.inject_rex_adt::<PrimitiveEither>()?;
Depth Limits
Some workloads (very deep nesting) can exhaust parser/typechecker recursion depth. Prefer bounded limits for untrusted code:
- parser AST depth
rex_typesystem::TypeSystemLimits::safe_defaults