Elvelt

Backends

Future Backends — Roadmap

Future Backends — Roadmap

CppBackend is the only backend currently shipped, but the architecture is intentionally backend-agnostic. This document enumerates the planned additional backends, the work each one requires, and the architectural considerations that apply to all of them.

Backend Target Status Estimated effort
Rust Rust 2021 (no_std-capable subset) Planned ~6 months
LLVM IR LLVM 17+ textual IR Planned ~12 months
WebAssembly Wasm 2.0 + WASI Preview 2 Planned ~9 months
Go Go 1.22 Exploratory ~4 months
Python Python 3.12 Exploratory ~3 months
JavaScript ES2025 source (round-trip) Exploratory ~2 months
C# C# 12 / .NET 8 Exploratory ~3 months
Native executable Static ELF/PE/ Mach-O via LLVM Long-term ~18 months

General architecture for new backends

Every new backend must implement ILanguageBackend (core/plugin/ILanguageBackend.h:29):

class MyBackend : public plugin::ILanguageBackend {
public:
    std::string languageName() const override { return "my_lang"; }
    std::string fileExtension() const override { return ".my_lang"; }
    std::string generate(const std::shared_ptr<ir::ModuleNode>& module) override;
    // generateProgram() is optional; default flatten-all is fine for most cases
};

And register it in MPL_Compiler.cpp:46:

reg.registerBackend(std::make_unique<backends::MyBackend>());

Language-neutrality contract (B1-B6)

Backends must respect the same neutrality guarantees the C++ backend follows (see ./cpp/index.md § Language-neutrality guarantees). Concretely:

  • B1 — No sourceLanguage branching. Switch only on node->kind.
  • B2 — Use language-neutral IR for runtime. If your target has a variant, use it. If not, use your language's equivalent (e.g. Java's Object + type tags).
  • B3 — Map canonical operator names. BinaryOpNode::op is already the IR contract; don't fork it.
  • B4 — Single error model. Throw the same mpl_js_error / mpl_value shape; let each language's idiom translate it.
  • B5 — Don't assume JS-style scope. Closures are explicit LambdaNode with capturesLexicalThis — your target may have other idioms (e.g. Rust's move).
  • B6 — Use the standard library path. StandardLibraryCallNode carries the canonical (module, fn, args) triple; lower it to your language's equivalent (println!, Math.sqrt, etc.).

Backend 1 — Rust

Why Rust?

  • Memory safety without GC aligns with mpl_value's shared_ptr model (Rust's Rc<RefCell<Value>>).
  • Strong type system makes the variant ergonomics explicit.
  • Native compilation produces single-file binaries without the C++ runtime dependency.

Architecture

ModuleNode  →  RustBackend::generate()
              │
              ├─ generate Cargo.toml + src/lib.rs preamble
              ├─ emit a runtime crate (mpl_runtime) into a sibling directory
              ├─ lower each statement/expression to Rust 2021 syntax
              └─ map mpl_value → enum MplValue { Undef, Null, Bool(bool), Num(f64), … }

Key design choices

IR construct Rust lowering
mpl_value pub enum MplValue { Undef, Null, Bool(bool), Num(f64), Str(String), BigInt(BigInt), Symbol(u64), Object(Rc<RefCell<MplObject>>) }
mpl_object pub struct MplObject { id: u64, properties: HashMap<String, MplValue>, order: Vec<String>, prototype: Option<Rc<RefCell<MplObject>>> }
mpl_call mpl_runtime::call(callee, args)
mpl_get_value / mpl_set MplValue::get_prop(obj, key) / MplValue::set_prop(obj, key, val) (methods on the enum)
Hoisted static mpl_value static mut NAME: MplValue = MplValue::Undef; (with unsafe — needed for the equivalent of C++'s static mutable)
Closures Rust closures with move capture (closest equivalent to C++ [=])
Cell-wrapped vars Rc<RefCell<MplValue>> (Rust's shared_ptr<mpl_value> analog)
BigInt num_bigint::BigInt (replaces boost::multiprecision::cpp_int)

Cargo.toml snippet:

[package]
name = "mpl_compiled"
version = "0.1.0"
edition = "2021"

[dependencies]
mpl_runtime = { path = "../mpl_runtime" }
num-bigint = "0.4"
once_cell = "1.19"

Risks & unknowns

  • static mut in Rust requires unsafe blocks everywhere — every reference to a hoisted callable becomes an unsafe { NAME.call() }. Mitigation: wrap each in a small pub fn name() -> MplValue { unsafe { NAME.clone() } } helper.
  • Closures: Rust's move capture is closer to [=] than [&]. The C++ backend's closureCaptureModeFor rules must be re-implemented for Rust's borrow checker.
  • MplObject interior mutability requires RefCell<MplObject> (or Rc<RefCell<…>>); the borrow checker may reject patterns that compile in C++.

Backend 2 — LLVM IR

Why LLVM IR?

  • Single IR can target x86_64, AArch64, RISC-V, WebAssembly (via the WASM backend), GPUs, and more.
  • Strong optimizer (mem2reg, GVN, inlining) often produces better code than hand-written C++.
  • Foundation for the "native executable" backend (link LLVM IR to native object files).

Architecture

ModuleNodeLLVMBackend::generate()
              │
              ├─ emit LLVM module text (.ll)
              │    ├─ declare runtime functions (@mpl_call, @mpl_get_value, …)
              │    ├─ declare struct types (%mpl_value, %mpl_object)
              │    └─ translate each IR node into LLVM instructions
              └─ driver layer calls llc + lld to produce executable

Key design choices

IR construct LLVM IR lowering
mpl_value { i8, [24 x i8] } — 1-byte discriminator + 24 bytes of payload (fits pointers/doubles inline)
mpl_object %mpl_object = type { i64, %"class.std::unordered_map", %"class.std::vector", %mpl_object* }
mpl_call call @mpl_call(%value %callee, …)
mpl_get_value / mpl_set call @mpl_get_value(%value %obj, %value %key) / void @mpl_set(…)
Hoisted static mpl_value @name = global %mpl_value { i8 0, [24 x i8] zeroinitializer }
Closures Define a struct per closure capture set; pass as first arg
Cell-wrapped vars @__cell_x = global %"class.std::shared_ptr" zeroinitializer
BigInt External function call into __gmpz_* or similar

Driver integration (separate tool, not part of MPL_Compiler proper):

llc -filetype=obj -relocation-model=pic module.ll -o module.o
lld module.o runtime.o -o program

Risks & unknowns

  • LLVM IR text format is verbose — generated .ll files are 5-10× larger than equivalent C++.
  • Optimizer passes may aggressively reorder __host_log__ calls — for debug builds, must use volatile loads.
  • Linking requires shipping the MPL runtime compiled to LLVM bitcode alongside.

Backend 3 — WebAssembly (Wasm 2.0 + WASI Preview 2)

Why Wasm?

  • Sandboxed execution model maps cleanly onto mpl_object's prototype chain (no shared mutable globals by default).
  • WASI provides fd_read, fd_write, clock_time_get — direct equivalents of Console.ReadLine, Console.WriteLine, DateTime.NowUnixMs.
  • Browser deployment via <script type="module"> makes any compiled language into a browser-runnable artifact.

Architecture

ModuleNode  →  WasmBackend::generate()
              │
              ├─ emit Wasm text format (.wat) or binary (.wasm)
              │    ├─ declare imports for runtime helpers (or inline them)
              │    ├─ lower each statement/expression to Wasm instructions
              │    └─ for --modular mode, emit one .wasm per module
              └─ driver invokes wasmtime or wasmer to produce executable

Key design choices

IR construct Wasm lowering
mpl_value (array (mut i32) (mut i64) (mut f64)) — 3 × 8 bytes, indexed by discriminator byte
mpl_object Reference type (ref $mpl_object) with a struct $mpl_object = (field $id i64) (field $props (ref $map)) …
mpl_call call $mpl_call — runtime function provided by the embedder
Closures Wasm struct types with the captured fields embedded
Cell-wrapped vars global $__cell_x = (mut (ref $mpl_value))
BigInt Multiple i64 slots with carry arithmetic (no native support in Wasm 1.0/2.0)

Risks & unknowns

  • Wasm has no native 64-bit float (only 32-bit); long double precision requires software emulation.
  • Reference types (Wasm GC proposal) are not yet standardized across all embedders; may need fallback to manual table management.
  • Console.WriteLine maps to fd_write to fd 1; embedder must provide fd_read for stdin.

Backend 4 — Go

Why Go?

  • First-class concurrency (goroutines + channels) matches MPL's microtask queue.
  • Strong standard library covers most of MPL_Standard.
  • Fast compilation, single static binary.

Key design choices

IR construct Go lowering
mpl_value interface{} (empty interface) + runtime type switch
mpl_object *MplObject struct with sync.Mutex for thread-safety
mpl_call func MplCall(callee interface{}, args []interface{}) interface{}
Closures Go closures (no move keyword; capture is implicit by reference for outer-scope vars)
Cell-wrapped vars *interface{} (pointer to interface)
BigInt math/big.Int

Risks & unknowns

  • Go's interface{} boxing is implicit — the backend must not double-wrap.
  • Goroutine boundaries require careful handling of shared mpl_object mutation.

Backend 5 — Python

Why Python?

  • Native source-to-source transpilation enables interactive use of compiled modules.
  • Python's dict maps cleanly onto mpl_object.
  • The Python frontend consumes Python; the Python backend produces Python — useful for cross-compilation (e.g. Python source → C++ binary for distribution).

Key design choices

IR construct Python lowering
mpl_value Native Python: None, bool, float, str, int, custom MplObject class
mpl_object class MplObject: def __init__(self): self.properties = OrderedDict(); self.prototype = None
mpl_call def mpl_call(callee, *args): …
Closures Python closures (lexical capture)
Cell-wrapped vars nonlocal declarations + cell objects
BigInt Native int (arbitrary precision in Python 3)

Risks & unknowns

  • Python's nonlocal is required for closures that mutate outer vars — must be emitted correctly per closure.
  • mpl_truthy matches Python bool(x) closely but differs for __bool__ overrides.

Backend 6 — JavaScript (round-trip)

Why JavaScript?

  • Some users want a "sugar-stripped" JavaScript output (no language-specific syntax, just modern ES2025).
  • Useful for shipping compiled artifacts to Node.js / Deno / Bun directly.

Key design choices

IR construct JavaScript lowering
mpl_value Native JS primitives + Object for mpl_object
mpl_object Object.create(null) with insertion-order Map for properties
mpl_call Native Reflect.apply(callee, this, args)
Closures Native JS closures
Cell-wrapped vars Mutable captured variable in outer scope (JS auto-cell)

Risks & unknowns

  • Must be careful not to emit features the target doesn't support (e.g. legacy browsers without BigInt).
  • Some JS features are tricky to lower into JS (e.g. with statement, legacy arguments.callee).

Backend 7 — C# (.NET 8)

Why C#?

  • Strong typing + dynamic covers both worlds.
  • Roslyn source generators could enable compile-time diagnostics.
  • Direct IL generation is possible for AOT scenarios.

Key design choices

IR construct C# lowering
mpl_value object + dynamic dispatch
mpl_object Dictionary<string, object> with insertion-order wrapper
mpl_call MplRuntime.Call(callee, args)
Closures C# closures with captured locals
Cell-wrapped vars Class wrapping the variable (ref-type cells)
BigInt System.Numerics.BigInteger

Risks & unknowns

  • C# closures don't auto-capture by reference; explicit wrapper classes needed.
  • dynamic dispatch is slower than direct calls; benchmarks needed.

Backend 8 — Native executable (via LLVM)

Why?

  • Produces a single-file ELF/PE/Mach-O executable with no external dependencies.
  • Optimal startup time (no JIT, no runtime initialization beyond the runtime static init).
  • Enables shipping compiled programs to systems without a C++ toolchain.

Architecture

LLVMBackend → IR.ll
       │
       ├─ llc → module.o
       ├─ mpl_runtime.a (precompiled LLVM bitcode linked in)
       └─ lld → program.exe

This is essentially the LLVM backend plus a toolchain driver. See Backend 2.


Cross-cutting work for all backends

Work item Required for Difficulty
Translate the runtime preamble (mpl_value + helpers) into the target language All Medium — boilerplate but mostly mechanical
Implement cell-wrap semantics for closures All High — borrow/ownership rules differ per language
Implement standard library lowering (StandardLibraryCallNode) All Low — (module, fn) table is the same shape; just the RHS changes
Implement hoisting for module-level callables All Medium — every language has different hoisting rules
Ship the runtime as a library All Medium
Add CMake / Cargo / go.mod / etc. integration All Low
Add CI matrix builds All Low

Design constraints that affect every backend

No if (frontend is X) branches

Adding a backend that emits if (module->sourceLanguage == "javascript") { … } is a contract violation. If a language construct cannot be lowered 1:1, the backend must use __host_log__ (or its equivalent in the target language) to surface the gap.

Shared MPL_Standard/System/empl.h runtime

The C++ runtime is the canonical reference implementation. A new backend should either:

  1. Reuse the C++ runtime by emitting a small C++ shim around the target-language code (LLVM/Wasm backends often do this), or
  2. Reimplement the runtime in the target language (Rust, Go, Python backends).

MPL_Standard/Specification.json is the single source of truth — it lists every standard-library call with its parameter and return types. Each backend should be able to load this JSON and generate its own standard-library lowering table.

Backward compatibility

Adding a new backend must not break the C++ backend's behavior. The C++ tests (ctest) must continue to pass at 100% before a new backend lands.

Performance parity

Each backend should aim for at most 2× the runtime overhead of the C++ backend for the same workload. Backends that exceed this are marked as "exploratory" until optimizations land.


Where to start

If you want to implement a new backend:

  1. Read ./cpp/index.md and ./cpp/emitter.md to understand the lowering contract.
  2. Skim ../../empl/node-kind.md to see the full set of IR nodes the backend must handle.
  3. Pick a small, well-defined target — Rust is a good first choice (similar type system, no GC).
  4. Start with a minimal subset: integers, strings, function calls, if/else, basic closures.
  5. Iterate by running the existing JS test suite against the new backend.

Cross-references