Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Embedding Rex in Rust

Rex is designed as a small pipeline you can embed at whatever stage you need:

  1. rex-parser: source → CompilationUnit { decls, body }
  2. rex-typesystem: HM inference + type classes → TypedExpr (plus predicates/type)
  3. rex-engine: build host modules, compile typed code into CompiledProgram, 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:

  • Engine builds the host environment.
  • Compiler prepares user code into a CompiledProgram.
  • Evaluator owns the runtime core and runs one prepared program with runtime inputs for main.

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
  • CompiledProgram carries a typed expression plus the environment snapshot needed to run it
  • CompiledProgram::main_signature() reports input names/types and the external result type
  • Evaluator owns the runtime core needed for execution
  • Evaluator::run consumes 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, or export_value are 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:

  • Compiler APIs return EngineError
  • Evaluator::run returns EngineError
  • APIs that parse, compile, and run in one call return ExecutionError because 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.
  • Prelude is 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 ImportRequest with 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(...):

  1. Create a Module.
  2. 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
  3. 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:

  • Label is imported once and then used as both a type name and a constructor value.
  • Left and Right are imported as constructor values.
  • render_label is 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_native
  • Module::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 / Type callback 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) or Engine::with_prelude(state).
  • export / export_async callbacks receive &State as their first parameter.
  • Handle-based native APIs (export_native*) receive Context<State> so they can allocate public handles through the heap and read engine.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 a is ergonomic for user-authored functional code and pattern matching.
  • Array a is the host-facing contiguous representation (for example Vec<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 a
  • to_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:

  1. Parse Rex source into CompilationUnit { decls, body }.
  2. Inject Decl::Class / Decl::Instance into the type system (if you’re typechecking without running).
  3. 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, and rex_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 with Engine::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