Architecture
Rex is implemented as a small set of focused crates that form a pipeline:
- Parsing (
rex-parser): converts source text into arex_ast::CompilationUnit { decls, body }. - Typing (
rex-typesystem): Hindley–Milner inference + ADTs + type classes; produces arex_typesystem::TypedExpr. - Execution (
rex-engine): builds the host environment, prepares typed code into aCompiledProgram, and evaluates it to an ownedrex_engine::Value.
The crates are designed so you can use them independently (e.g. parser-only tooling, typechecking-only checks, or embedding the full evaluator).
Crates
rex-ast: shared AST types (Expr,Pattern,Decl,TypeExpr,CompilationUnit, symbols, spans).rex-parser: source parser. Entry point:rex_parser::parse.- Parsing enforces a fixed cap on AST nesting.
rex-typesystem: type system. Entry points:TypeSystem::new()to create an explicit typing environment.infer_typed(&mut ts, expr)/infer(&mut ts, expr)for type inference.- The inference implementation itself lives in
rex-typesystem/src/inference.rs;typesystem.rsnow holds the shared core types, environments, and registration logic. - For untrusted code, set
rex_typesystem::TypeSystemLimits::safe_defaults()before inference. RegisteredValuecarries a scheme, Rex-visible parameter names, and optional Markdown docs.TypeBundleis the JSON-facing persistence format for those registrations and their referenced, documented ADTs.
rex-engine: host environment builder, compiler, and runtime evaluator. Entry points:Builder::with_prelude(state)?to inject runtime constructors and builtin implementations (statecan be()).standard_type_system()?to create a typing environment with therex-enginestandard prelude.Builder::build_compiler()to consume the prepared builder into a compilation view.Compiler::compile_programto consume the compiler and prepare a parsed program entry point into(CompiledProgram, Evaluator).Compiler::infer_*consumes the compiler for type-only checks.Evaluator::run(compiled, inputs).awaitto execute one prepared program.inputsis aBTreeMap<String, Value>for the program’s externalmaininterface;runconsumes the evaluator, compiled program, and input map and returns aValue.Buildercarries host state asBuilder<State>(State: Clone + Send + Sync + 'static); typedexportcallbacks receive an owned clone ofStateand returnResult<T, EngineError>, typedexport_asynccallbacks receive an owned clone ofStateand returnFuture<Output = Result<T, EngineError>>, while dynamic native APIs (export_native*) receiveContext<State>, an instantiated type, and ownedValues.- compile and evaluation APIs return
EngineError; convenience entry points that cross phases returnExecutionError. - Host module injection API:
Module+Export+Builder::inject_modulefor eager registration, orImporter<State>returningResolvedModuleContent::module(...)for lazy Rust-backed modules. Named modules accept optional Markdown docs inModule::new.
rex-proc-macro:#[derive(Rex)],#[rex::export], and#[rex::module]bridge documented Rust types, functions, and modules into Rex registrations.rex: top-level facade for embedding the pipeline in Rust applications.rex-cli:rex_clicommand-line front-end around the pipeline.rex-lsp/rex-vscode: editor tooling.
rex-engine is organized internally around the same phases:
builder/owns builder-facing host/module registration.compiler/owns program preparation, import rewriting, typechecking, module loading state, andCompiledProgramconstruction.evaluator/owns execution, scheduling, native dispatch,Context, and the runtime core.modules/,memory/, andconfig.rshold shared module identities, heap values/GC roots, and runtime options.
Runtime Ownership and GC Boundaries
rex-engine uses a moving copying collector. Heap has one owner and moves exactly once
through Builder -> Compiler -> Evaluator; there is no heap mutex or public heap capability.
Private heap cells contain moving edges, while evaluator frames, environments, scheduler work, and
compiler state contain stable runtime-root tokens. The evaluator traverses and relocates those
roots only at actual collection safepoints, not around every work item.
The public boundary is an owned Value tree. Before a host call starts, the evaluator performs a
type-directed copy from internal roots to Value; the host future contains only those values and
host state. On completion, the evaluator validates and imports the result while it again has
exclusive heap access. Internal prelude intrinsics are deliberately separate: they operate
synchronously through the private runtime/root scope and never use the host Value ABI.
This separation also leaves the internal representation free for a future LLVM backend and custom
binary heap. Generated code can eventually use private tagged values and participate in the same
GC safepoint/root protocol without changing host handlers or treating Value as a JIT ABI.
See Memory Management for the reference categories, allowed conversions, and collector invariants.
Design Notes
- Typed preparation:
rex-engineprepares code into a typed form before execution. The currentCompiledProgramstores a typed AST plus the environment snapshot needed to run it. - Single-shot execution: evaluation is intentionally one-shot.
CompiledProgramis moved intoEvaluator::runwith its runtime input map, consuming the evaluator as well. Prepare all required declarations/modules before constructing or consuming the evaluator. - Single-use preparation:
Builder::build_compiler(),Compiler::compile_program, and theCompiler::infer_*APIs consume their receivers. Each program run should create a fresh builder/compiler/evaluator lineage. - Same-lineage runtime model: a
CompiledProgramis intended to run on the evaluator produced from the same compiler. Rex programs are supplied as source and compiled per run, so Rex does not expose a portable compiled-artifact or cross-runtime linking model. - Phase ownership:
Builderowns embedder configuration only untilbuild_compiler().Compilerthen owns preparation state: the type environment, runtime declaration environment, runtime registries, heap, module loader caches, and execution policy snapshot.Evaluatoris built only by consuming that compiler, so runtime code cannot mutate modules or compile new declarations. - Prelude ownership:
rex-engineowns the standard prelude source, standard typing environment, and runtime contract. The split is:- typeclass and instance declarations written in Rex at
rex-engine/src/prelude/typeclasses.rex rex-engine/src/prelude/type_system.rsbuilds the prelude-enabledTypeSystem, including ADTs, parsed declarations, and primop schemesrex-engine/src/prelude/mod.rsparses the Rex source and injects runtime method bodies/native implementations forBuilder::with_prelude(state)?rex-typesystemexposes generic registration/inference APIs and does not own the standard prelude
- typeclass and instance declarations written in Rex at
- Depth bounding: Some parts of the pipeline are naturally recursive (parsing deeply nested parentheses, matching deeply nested terms). The parser enforces a fixed AST-depth cap, and the typechecker-limit API provides bounded recursion for production/untrusted workloads.
- Import-use rewrite/validation: module processing resolves import aliases across expression vars, constructor patterns, type references, and class references; unresolved qualified alias members are rejected as module errors before runtime.
- Abstract module namespace: a
ModuleIdis a validated qualified Rex name such asstd.preludeorffmpeg.formats.av1. It does not encode a filesystem path, origin kind, URL, or content hash. Importers decide whether a module name maps to a file, database row, in-memory string, open editor buffer, generated AST, or Rust host module. - Importer payloads and caching: importers are generic over host state and can return Rex
source, a prebuilt
CompilationPackage, or a Rust-backedModule<State>. Source imports are parsed intoCompilationUnitvalues, then converted intoCompilationPackagefor compiler module processing. Source-derived and prebuilt packages are loaded through the SCC module graph path. Rust-backed modules are installed lazily through the same internal module installer used by eagerinject_module, and are self-contained host modules rather than Rex source modules with nested import loading. Importer results are cached for one compile so the same request is not resolved repeatedly.
Intentional String Boundaries
Rex now prefers structured internal representations (for example NameRef, BuiltinTypeId,
CanonicalSymbol, and module/type/class maps) across parser, type system, evaluator, and LSP
rewrite paths. Remaining string usage is intentional in these boundary layers:
- Source text and parsing: the parser accepts source strings by definition.
- Human-facing diagnostics and display: error messages, hover text, CLI rendering, and debug output stringify symbols/types for readability.
- Protocol/serialization boundaries: JSON/LSP payloads are string-based and convert structured internal symbols/types at the edge.
- Module specifiers: parsed import names are textual before being resolved into validated
ModuleIdvalues and handed to importer policy.
Non-goal for this pass:
- Eliminating all
.to_string()calls globally. The design target is to avoid stringly-typed core semantics, not to remove string conversion at UI/protocol boundaries.