Elvelt

EMPL IR

EMPL IR (Intermediate Representation)

EMPL IR (Intermediate Representation)

The EMPL IR (Extended Multi-Platform Language Intermediate Representation) is the language-neutral intermediate representation used by the MPL_Compiler as the canonical pivot between all frontends and all backends. Every source language (JavaScript, Python, C#, Go, Rust, PHP, Ruby, Java, EMPLS) is parsed into an EMPL IR tree, and every backend consumes EMPL IR to emit target code (currently C++20, with other backends planned).

The IR lives entirely under the mpl::ir C++ namespace, declared across the following headers in MPL_Compiler/core/ir/:

Header Purpose
EMPLTypes.h Type system (TypeKind, UIRType, Visibility, SourceLocation, Parameter)
EMPLNode.h Node hierarchy: 38+ node classes plus NodeKind enum and EMPLNode base
UIRNode.h Backwards-compatible alias header (#include "EMPLNode.h")
EMPLVisitor.h Visitor pattern base class used by all node accept() methods
EMPLPrinter.h Pretty-printer that renders IR trees as human-readable text
EMPLValidator.h Static validation: enforces structural rules, reports ValidationResult
EMPLCanonicalizer.h Normalization: strips frontend-specific variants, extracts closure bindings
EMPLModule.h Top-level convenience module aggregator

Design philosophy

The IR is deliberately minimal but expressive. The goal is a small set of node kinds — each capturing exactly one syntactic / semantic concept shared by the majority of source languages — that is still rich enough to round-trip all modern language features (classes, async/await, generators, destructuring, ESM/CJS modules, BigInt, decorators).

The design follows three guiding principles:

  1. Language neutrality. No node, field, or enum value references a specific source language. The pre-existing JS-only metadata flag js.arrow was retired in favour of structural boolean fields (capturesLexicalThis, isConstructible, hasOwnArguments) on LambdaNode. See LambdaNode in nodes/lambda-async.md.
  2. Tree-based, not SSA. The IR is a concrete syntax tree with explicit control-flow nodes (IfNode, ForNode, SwitchNode, TryCatchNode). This keeps the IR easy to construct from hand-written recursive-descent parsers and easy to lower in a single bottom-up pass.
  3. Smart-pointer ownership. Every node is heap-allocated and owned via std::shared_ptr<EMPLNode> (alias EMPLNodePtr). Parent nodes own their children; sibling lists are stored in std::vector<EMPLNodePtr>.

Neutrality guarantees

The IR is contractually neutral on the following axes. Adding a new frontend or backend MUST NOT require modifications to any core/ir/EMPLNode.h definition (although the canonicalizer may be extended to produce cleaner cores).

  • C1 — No language name leakage. ModuleNode::sourceLanguage is a free-form string, but the IR never branches on it. Backends consume only structural fields.
  • C2 — No source-text preservation. Nodes store semantic fields, not raw source text. originalText exists only on UnsupportedNode (line 624) and is consumed solely to render fallback diagnostics.
  • C3 — No syntax sugar. Syntactic shortcuts from a single language (JS ??, Python **, Ruby ..) are normalized to BinaryOpNode::op strings; backends decide how to lower.
  • C4 — No implicit type coercion. BinaryOpNode::op strings are the IR contract. Backends MAY widen but MUST NOT silently coerce.
  • C5 — No scope chain. Closures are represented as LambdaNode with explicit capturesLexicalThis and a ClosureBindings analysis produced by EMPLCanonicalizer::extractClosureBindings (EMPLCanonicalizer.h:21).
  • C6 — Single error model. All exceptions are mpl_js_error / mpl_error instances; the IR has no per-language error type.
  • C7 — Visitor pattern, no RTTI abuse. All node traversal goes through EMPLVisitor; backends do not dynamic_cast on EMPLNode to discriminate kind (they switch on node->kind).
  • C8 — No parallel arrays. Every list inside a node lives in a std::vector<EMPLNodePtr> named after its semantic role (statements, cases, arguments, declarations).

C1–C8 constraints in detail

ID Constraint Enforced by Reference
C1 No if (sourceLanguage == "javascript") branching in backends Code review; no static check
C2 Only UnsupportedNode::originalText carries source text EMPLValidator EMPLNode.h:624
C3 BinaryOpNode::op is the canonical operator name Canonicalizer rewrites language-specific forms EMPLNode.h:472
C4 No implicit numeric promotion in IR EMPLValidator warns on == between mismatched types EMPLValidator.h:23
C5 Closures are explicit LambdaNode + ClosureBindings EMPLCanonicalizer::extractClosureBindings EMPLCanonicalizer.h:21
C6 mpl_js_error / mpl_error everywhere Library convention empl_core.h:82, empl.h:73
C7 All visitors derive from EMPLVisitor Compile-time EMPLVisitor.h
C8 Each list field has a semantic name Naming convention EMPLNode.h (all node classes)

High-level structure

ProgramNodetop-level (MPL_Compiler.cpp)
  └─ ModuleEntry[]
       └─ ModuleNodeone per source file / module
            ├─ imports: ImportNode[]other modules this depends on
            ├─ declarations:                ← top-level class/function/var declsFunctionDeclNodeClassDeclNodeInterfaceDeclNodeEnumDeclNodeTypeAliasDeclNodeVariableDeclNode
            └─ statements:                  ← top-level executable code
                  BlockNode
                    └─ statements: EMPLNodePtr[]
                          IfNode, ForNode, ForEachNode, …
                          ExpressionStmt (via ExpressionNode)
                          ReturnNode, ThrowNode, …
                          BinaryOpNode, UnaryOpNode, FunctionCallNode, …

Node taxonomy

The 38+ NodeKind enum values are grouped into six semantic families. See node-kind.md for the exhaustive table.

Family Members Typical source constructs
Module / program Program, Module, Import, Export ESM, CJS, Python imports, Go packages
Declarations FunctionDecl, VariableDecl, ClassDecl, InterfaceDecl, EnumDecl, TypeAliasDecl, DestructuringPattern function, let, class, interface, enum
Blocks & control flow Block, If, For, ForEach, While, DoWhile, Switch, TryCatch { }, if, for, switch, try
Jumps Return, Throw, Break, Continue return, throw, break, continue
Expressions BinaryOp, UnaryOp, Ternary, FunctionCall, MethodCall, MemberAccess, IndexAccess, Cast, Lambda, Await, Yield, Assignment a + b, f(x), obj.field, arr[i], x => y
Literals / values Literal, Identifier, ArrayLiteral, ObjectLiteral, RecordLiteral, TupleLiteral, StandardLibraryCall 42, "x", [1,2], console.log
Legacy PrintCall, FuncDecl, Unsupported Pre-v3 aliases (deprecated)

Type system overview

Types are described by UIRType (EMPLTypes.h:50) — a tagged-struct (kind: TypeKind) carrying generic / array / function / union / nullable payloads. The TypeKind enum (EMPLTypes.h:13) has 32 values ranging from Void to UserDefined and Never. See types.md for the full table.

Tooling pipeline

Source ─┐
        ├─► Frontend ─► EMPL IR ─► EMPLCanonicalizer ─► EMPL IR (core) ─► Backend ─► C++
        │                       └► EMPLValidator (optional; can run before canonicalize)
        └─► EMPLPrinter (debug dump at any stage)
  1. Parse: a frontend (frontends/<lang>/) consumes source text and emits a ProgramNode whose modules each hold a ModuleNode populated with EMPLNodePtr children.
  2. Print: EMPLPrinter::print(module) or EMPLPrinter::print(program) produces a stable, indented textual dump for debugging.
  3. Validate: EMPLValidator::validate(module|program) returns a ValidationResult { ok, errors[], warnings[] }. The validator can be run in strict mode (fail on UnsupportedNode) or permissive mode (only warn).
  4. Canonicalize: EMPLCanonicalizer::canonicalize(module|program) rewrites language-specific variants into a backend-friendly core. It also exposes extractClosureBindings for backends that need closure-capture metadata.
  5. Lower: the C++ backend (backends/cpp/CppBackend.h) walks the IR and emits C++20 source.

Build configuration

The IR headers compile under C++20 with -std=c++20. They have no third-party dependencies — only the C++ standard library (<memory>, <string>, <vector>, <variant>, <unordered_map>, <unordered_set>, <functional>, <utility>). The IR is consumed by the backend via direct header inclusion (#include "core/ir/EMPLNode.h").

The MPL_Compiler CMake build aggregates the IR sources via Unity Build (25-file batches). This is transparent to client code but means that editing one IR header can trigger a large recompile — see CMakeLists.txt for the unity-batch boundaries.

Versioning policy

The IR evolves conservatively. Changes follow this policy:

  • Adding a node kind. Requires a new NodeKind enum value, a new class deriving from EMPLNode, and updates to EMPLVisitor (for the visitor overload), EMPLPrinter (for the dump format), and EMPLValidator (for any structural rules). Old IRs (without the new kind) MUST still validate cleanly — the validator emits a warning, not an error, for unknown kinds from older sources.
  • Adding a field to an existing node. Allowed without a major version bump. Frontends that don't populate the field get the field's default value. Backends that read the field MUST handle the default case.
  • Renaming a field, node, or enum value. Forbidden without a deprecation cycle. The deprecated alias is retained for at least one minor version with a compiler warning ([[deprecated]] on the type alias or static method).
  • Changing a field's semantic meaning. Treated as a breaking change. Requires major version bump, written migration notes, and dual-version support in the C++ backend.

The current IR is v3.x. The retirement of js.arrow metadata in favour of LambdaNode::capturesLexicalThis / isConstructible / hasOwnArguments (see EMPLNode.h:572-574) is the most recent example of the deprecation policy in action.

See also

Quick-reference appendix

Smart-pointer alias

namespace mpl { namespace ir {
    class EMPLNode;
    using EMPLNodePtr = std::shared_ptr<EMPLNode>;     // canonical alias
    using UIRNode = EMPLNode;                          // legacy alias
    using UIRNodePtr = EMPLNodePtr;                    // legacy alias
}}

Node construction idiom

auto fn = std::make_shared<mpl::ir::FunctionDeclNode>();
fn->name = "add";
fn->parameters = { {"a", "a"}, {"b", "b"} };
fn->body = std::make_shared<mpl::ir::BlockNode>();

auto lit = mpl::ir::LiteralNode::makeInt(42);
fn->body->statements.push_back(
    std::make_shared<mpl::ir::ReturnNode>(...)); // returns ReturnNode holding lit

Visitor pattern

class MyVisitor : public mpl::ir::EMPLVisitor {
public:
    void visit(mpl::ir::FunctionDeclNode& node) override { /* … */ }
    void visit(mpl::ir::ClassDeclNode& node) override { /* … */ }
    // … one overload per concrete node class …
};

program->accept(visitor); // dispatches to the right visit overload

Walking a tree without the visitor

For simple traversals (e.g. collecting all IdentifierNodes), a manual recursive walk is often clearer than a full visitor:

void collectIdentifiers(const mpl::ir::EMPLNodePtr& node,
                        std::vector<std::string>& out) {
    if (!node) return;
    if (auto* id = dynamic_cast<mpl::ir::IdentifierNode*>(node.get())) {
        out.push_back(id->name);
    }
    // Recurse into known child slots …
}

The downside of this approach is that it does NOT scale — every new node kind requires updating every manual walker. The visitor pattern scales linearly because the visitor's dispatch table updates automatically when a new node kind is added (compiler enforces the visit overload).

Glossary

Term Definition
Frontend A parser for one source language (JS, Python, C#, …) that produces EMPL IR.
Backend A code generator that consumes EMPL IR and produces one target language (currently C++).
IR Intermediate Representation. The EMPL IR is the canonical form passed between frontend and backend.
Node A single EMPLNode-derived class instance. The IR is a tree of nodes.
Kind The NodeKind enum value that discriminates the concrete node class.
Module A single compilation unit (one source file). Multiple modules form a ProgramNode.
Statement A node that produces an effect (decl, assignment, call, control-flow).
Expression A node that produces a value. May appear inside another expression or as a statement.
Declaration A node that introduces a binding (function, class, variable, type).
Canonicalization The pass that rewrites language-specific variants into a backend-friendly core.