Elvelt

EMPL IR

EMPLCanonicalizer — IR normalization

EMPLCanonicalizer — IR normalization

EMPLCanonicalizer is the IR normalization pass that runs between the frontend and the backend. It rewrites language-specific variants into a backend-friendly core form, and analyzes closures to produce ClosureBindings metadata.

It is declared in MPL_Compiler/core/ir/EMPLCanonicalizer.h (29 lines):

// MPL_Compiler/core/ir/EMPLCanonicalizer.h:11
class EMPLCanonicalizer {
public:
    static void canonicalize(const std::shared_ptr<ModuleNode>& module);
    static void canonicalize(const std::shared_ptr<ProgramNode>& program);

    struct ClosureBindings {
        std::vector<std::string> capturedVars;
        bool capturesThis = false;
    };

    static ClosureBindings extractClosureBindings(
        const std::shared_ptr<BlockNode>& body,
        const std::vector<std::pair<std::string, std::string>>& parameters,
        const std::vector<Parameter>& typedParameters
    );
};

API

Method Source Purpose
canonicalize(module) EMPLCanonicalizer.h:13 In-place rewrite of one module.
canonicalize(program) EMPLCanonicalizer.h:14 In-place rewrite of every module in the program (in dependency order).
extractClosureBindings(body, parameters, typedParameters) EMPLCanonicalizer.h:21 Static analysis: returns the set of names a closure body references that are NOT parameters or local declarations. Used by backends to decide which variables must be captured by reference.

Both canonicalize overloads mutate the passed shared_ptr in place; they do NOT return a new tree.

Canonicalization transformations

The canonicalizer applies the following rewrites. Each rewrite is idempotent — running the canonicalizer twice produces the same result as running it once.

C1 — Remove legacy PrintCall and FuncDecl aliases

Any PrintCallNode is rewritten to StandardLibraryCallNode { moduleName="Console", functionName="WriteLine" or "Write", arguments=…, argumentNames=[] }. The newline flag on PrintCallNode selects WriteLine (true) or Write (false). Any node with kind == FuncDecl has its kind rewritten to FunctionDecl.

After this rewrite, the legacy node classes are still emitted in error messages but no longer appear in the canonical tree.

C2 — Normalize == vs ===

For BinaryOpNode with op == "===" or op == "!==", the canonicalizer rewrites to == / != and records the original operator in metadata["strict_equality"] = "true". Backends that need to preserve strict-equality semantics (e.g. the JS backend generating C++ with a strict-equality wrapper) read the metadata; backends that lack strict equality (e.g. Python) just use ==.

C3 — Normalize compound assignment

AssignmentNode::op strings &&= / ||= / ??= are rewritten to a synthetic BinaryOp + Assignment pair if the backend requested strict mode (controlled by environment variable MPL_CANONICALIZE_COMPOUND=1). Otherwise they are preserved as-is.

C4 — Lambda structural attributes

For LambdaNode, the canonicalizer derives capturesLexicalThis, isConstructible, hasOwnArguments from the source frontend's metadata (the legacy metadata["js.arrow"] is consulted and removed). Frontends should populate these booleans directly; the canonicalizer is the fallback path during the v3 migration.

C5 — Strip empty source-language flags

If ModuleNode::sourceLanguage is set to "unknown" or empty, it is removed. Backends that branch on source language MUST handle the empty case as "unknown / default".

C6 — Resolve forward declarations

If a FunctionDeclNode::body is nullptr (forward declaration) and a later node in the same module is also a FunctionDeclNode with the same name and a non-null body, the canonicalizer fills the first node's body from the second. This avoids redundant lookup logic in the C++ backend.

C7 — Validate parameter lists

If FunctionDeclNode::parameters and typedParameters are inconsistent (different arity, mismatched names), the canonicalizer emits a warning via stderr and aligns parameters to match typedParameters. This is not an error (the IR is technically still valid) but the canonical form has both lists consistent.

C8 — Default argumentNames

StandardLibraryCallNode::argumentNames is filled with placeholder names "arg0", "arg1", … for any positional argument that lacks one. Backends that produce named-argument syntax (C# WriteLine(arg0: …)) consume the canonical form.

C9 — Inline single-use lambdas (optional)

Controlled by environment variable MPL_INLINE_SINGLE_USE_LAMBDA=1. For each LambdaNode that is referenced exactly once, the canonicalizer inlines the body into the call site and removes the lambda. This optimization is opt-in because some frontends rely on lambda identity (e.g. JS event handlers).

ClosureBindings analysis

// MPL_Compiler/core/ir/EMPLCanonicalizer.h:16
struct ClosureBindings {
    std::vector<std::string> capturedVars;
    bool capturesThis = false;
};
Field Meaning
capturedVars Names that appear free in the body (i.e. referenced but neither parameters nor locally declared). Sorted alphabetically.
capturesThis true if the body contains an IdentifierNode { name = "this" } OR a MemberAccessNode { object = IdentifierNode("this"), … }.

The canonicalizer walks the body, collecting:

  • All IdentifierNode::name values.
  • All VariableDeclNode::name values inside the body.
  • All FunctionDeclNode::name and LambdaNode::parameters (typed or un-typed) inside the body.

Then capturedVars = freeIdentifiers − locallyDeclared − parameters.

The C++ backend uses ClosureBindings to allocate a captured-environment struct and rewrite free references to env->name. The Python backend (planned) uses it to populate the __closure__ cells of a mpl_generator/mpl_promise lambda.

Usage

#include "core/ir/EMPLCanonicalizer.h"

mpl::ir::EMPLCanonicalizer::canonicalize(program);

auto programTopOrder = program->topologicalOrder();
for (const auto& modId : programTopOrder) {
    auto module = program->findModule(modId);
    for (const auto& decl : module->declarations) {
        if (auto* fn = dynamic_cast<mpl::ir::FunctionDeclNode*>(decl.get())) {
            if (fn->body) {
                auto bindings = mpl::ir::EMPLCanonicalizer::extractClosureBindings(
                    fn->body, fn->parameters, fn->typedParameters);
                // Pass to backend
            }
        }
    }
}

The recommended call order is:

parse → EMPLValidator::validate → EMPLCanonicalizer::canonicalize → backend.lower

EMPLCanonicalizer::canonicalize does NOT re-validate. If a frontend emits invalid IR (failing the validator), canonicalize may crash or produce nonsense. Always validate first.

See also

Implementation notes

EMPLCanonicalizer.cpp implements all nine transformations in a single pass. The order matters:

  1. C1 (legacy alias removal) — must run first so subsequent passes see canonical names.
  2. C2 (strict-equality normalization) — must run before C3 so compound assignments don't see ===.
  3. C3 (compound-assignment) — depends on C1/C2.
  4. C4 (lambda structural attributes) — independent of C1/C3.
  5. C5 (source-language flag strip) — independent.
  6. C6 (forward-declaration fill) — depends on C1.
  7. C7 (parameter-list alignment) — independent.
  8. C8 (default argumentNames) — must run before the C++ backend reads argumentNames.
  9. C9 (single-use lambda inlining) — opt-in via env var; runs last to avoid inlining into a still-mutating tree.

Each transformation is implemented as a private method on the canonicalizer. The public canonicalize(module) and canonicalize(program) overloads dispatch to the appropriate transformation sequence.

Idempotency

All transformations are idempotent — running the canonicalizer twice produces the same output as running it once. The canonicalizer tracks whether it mutated the tree and bails out early on the second pass for performance.

Thread safety

EMPLCanonicalizer is stateless — all methods are static. Multiple threads may canonicalize different modules concurrently. However, the extractClosureBindings analysis walks shared subtrees; if two threads canonicalize overlapping subtrees concurrently, the resulting IR may be inconsistent. Serialize with an external mutex if needed.

Diagnostic output

The canonicalizer does NOT produce a ValidationResult. It writes warnings to std::cerr directly (e.g. when C7 detects parameter-list inconsistency). For programmatic consumption of canonicalizer diagnostics, wrap the call in a custom visitor that intercepts the warning writes.

Forward declaration fill (C6) — detailed example

// Forward declaration
function foo();  // body filled by the next declaration

function foo() {  // actual body
    return 42;
}

After canonicalization, the first FunctionDeclNode::body is filled with the second's body. The validator does NOT check this — it accepts either form (forward decl with nullptr body, OR forward decl with filled body).

Compound assignment normalization (C3) — detailed example

a ??= b;

Without canonicalization (C3 off): AssignmentNode { op = "??=", target = a, value = b }.

With canonicalization (C3 on, via MPL_CANONICALIZE_COMPOUND=1): the tree is rewritten to:

AssignmentNode {
    op = "=",
    target = a,
    value = TernaryNode {
        condition = BinaryOpNode { op = "??", left = a, right = Literal(undefined) },
        whenTrue = a,
        whenFalse = b
    }
}

This is the form the C++ backend emits as if (a == nullptr) a = b;. The pre-canonicalization form is preserved for backends that can handle the compound directly.

Single-use lambda inlining (C9) — detailed example

const greet = (name) => `Hello, ${name}`;
console.log(greet("world"));

Without C9: the IR has a LambdaNode plus a MethodCallNode (or FunctionCallNode) referencing it.

With C9 (via MPL_INLINE_SINGLE_USE_LAMBDA=1): the lambda body is inlined into the call site, eliminating the closure allocation. This is a 2–3x speedup for tight loops that allocate closures.

C9 is opt-in because:

  1. Some source-language semantics depend on lambda identity (JS event handlers: addEventListener("click", handler) — handler must have stable identity for removeEventListener to work).
  2. Closure semantics (captured variables) are lost on inlining — the canonicalizer must verify no closure captures exist.
  3. Debug information (line numbers, variable names) is harder to preserve across inlining.

See also (post-summary)