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
sourceLanguagebranching. Switch only onnode->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'sObject+ type tags). - B3 — Map canonical operator names.
BinaryOpNode::opis already the IR contract; don't fork it. - B4 — Single error model. Throw the same
mpl_js_error/mpl_valueshape; let each language's idiom translate it. - B5 — Don't assume JS-style scope. Closures are explicit
LambdaNodewithcapturesLexicalThis— your target may have other idioms (e.g. Rust'smove). - B6 — Use the standard library path.
StandardLibraryCallNodecarries 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'sshared_ptrmodel (Rust'sRc<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 mutin Rust requiresunsafeblocks everywhere — every reference to a hoisted callable becomes anunsafe { NAME.call() }. Mitigation: wrap each in a smallpub fn name() -> MplValue { unsafe { NAME.clone() } }helper.- Closures: Rust's
movecapture is closer to[=]than[&]. The C++ backend'sclosureCaptureModeForrules must be re-implemented for Rust's borrow checker. MplObjectinterior mutability requiresRefCell<MplObject>(orRc<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
ModuleNode → LLVMBackend::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
.llfiles are 5-10× larger than equivalent C++. - Optimizer passes may aggressively reorder
__host_log__calls — for debug builds, must usevolatileloads. - 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 ofConsole.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 doubleprecision requires software emulation. - Reference types (Wasm GC proposal) are not yet standardized across all embedders; may need fallback to manual table management.
Console.WriteLinemaps tofd_writeto fd 1; embedder must providefd_readfor 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_objectmutation.
Backend 5 — Python
Why Python?
- Native source-to-source transpilation enables interactive use of compiled modules.
- Python's
dictmaps cleanly ontompl_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
nonlocalis required for closures that mutate outer vars — must be emitted correctly per closure. mpl_truthymatches Pythonbool(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.
withstatement, legacyarguments.callee).
Backend 7 — C# (.NET 8)
Why C#?
- Strong typing +
dynamiccovers 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.
dynamicdispatch 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:
- Reuse the C++ runtime by emitting a small C++ shim around the target-language code (LLVM/Wasm backends often do this), or
- 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:
- Read
./cpp/index.mdand./cpp/emitter.mdto understand the lowering contract. - Skim
../../empl/node-kind.mdto see the full set of IR nodes the backend must handle. - Pick a small, well-defined target — Rust is a good first choice (similar type system, no GC).
- Start with a minimal subset: integers, strings, function calls,
if/else, basic closures. - Iterate by running the existing JS test suite against the new backend.
Cross-references
- IR node kinds to support:
../../empl/node-kind.md - Standard library to lower:
../../standard-library/index.md - Frontend layer that produces IR for these backends:
../../frontends/index.md - Existing C++ backend as reference:
./cpp/index.md - Interface contract:
../../../MPL_Compiler/MPL_Compiler/core/plugin/ILanguageBackend.h