Elvelt

Backends

Modular Mode

Modular Mode

When the source program spans multiple files / modules (typical of any non-trivial JS, C#, or Go project), the driver invokes generateProgram() instead of generate(). The output is one .cpp file per module, plus a shared __mpl_module_registry.h and a single __mpl_main.cpp entry point.

Driver flag Effect
--modular Route to generateProgram() (line 3846)
--modular-out <dir> Output directory (default tests/modules/<stem>/) (line 3848)
Backend function Location Output
generateProgram(program) CppBackend.h:2264-2385 ProgramGenerationResult with per-module .cpp + registry + entry
extractModuleBody_(module) CppBackend.h:9811-9880 Body extracted from generate()'s int main() wrapper
sanitizeModuleId_(id) CppBackend.h:9794-9805 Replace non-alphanumerics with _
generateModuleRegistryHeader_() CppBackend.h:9883-9980 The shared __mpl_module_registry.h
generateMainEntryFile_(program, order) CppBackend.h:9983-10033 The __mpl_main.cpp entry point
writeCppModularBuildScaffold MPL_Compiler.cpp:4111 Emits CMakeLists.txt + entry_generated target

Driver-level flow

File: MPL_Compiler.cpp:4079-4117

if (program && options.modular) {
    plugin::ProgramGenerationResult progResult;
    try {
        progResult = backend->generateProgram(program);
    } catch (const std::exception& ex) {
        std::cerr << "Error: Backend program generation failed: " << ex.what() << std::endl;
        return 1;
    }

    std::filesystem::path modOutDir = options.modularOutDir.empty()
        ? std::filesystem::path("tests") / "modules" / p.stem().string()
        : std::filesystem::path(options.modularOutDir);
    std::filesystem::create_directories(modOutDir);

    for (const auto& [filePath, content] : progResult.files) {
        std::filesystem::path dst = modOutDir / filePath;
        std::ofstream fout(dst.string());
        fout << content;
    }

    if (options.targetLanguage == "cpp") {
        std::vector<std::string> generatedFiles;
        for (const auto& [filePath, _] : progResult.files) generatedFiles.push_back(filePath);
        const fs::path runtimeDir = findRuntimeSystemDir(argv[0]);
        writeCppModularBuildScaffold(modOutDir, p.stem().string(), generatedFiles, runtimeDir);
    }
}

The output directory layout (default tests/modules/<stem>/):

tests/modules/<stem>/
├── __mpl_module_registry.h
├── __mpl_main.cpp
├── <module_id_1>.cpp
├── <module_id_2>.cpp
├── …
└── CMakeLists.txt            ← generated by writeCppModularBuildScaffold

generateProgram() walkthrough

File: CppBackend.h:2264-2385

The routine is a 4-step pipeline:

plugin::ProgramGenerationResult generateProgram(
    const std::shared_ptr<ir::ProgramNode>& program) override
{
    plugin::ProgramGenerationResult result;
    if (!program || program->modules.empty()) return result;

    auto order = program->topologicalOrder();

    // 1. Generate the module registry header
    result.files["__mpl_module_registry.h"] = generateModuleRegistryHeader_();

    // 2. Generate per-module .cpp files
    for (const auto& entry : program->modules) {
        if (!entry.module) continue;
        const std::string sanitizedId = sanitizeModuleId_(entry.id);
        const std::string fileName = sanitizedId + ".cpp";
        std::string body = extractModuleBody_(entry.module);
        // …wrap body in __mpl_module_init()…
        result.files[fileName] = …;
    }

    // 3. Generate main entry file
    result.files["__mpl_main.cpp"] = generateMainEntryFile_(program, order);
    result.entryFilePath = "__mpl_main.cpp";

    // 4. Build compilation order: dependencies first, then entry
    for (const auto& id : order) {
        result.compilationOrder.push_back(sanitizeModuleId_(id) + ".cpp");
    }
    result.compilationOrder.push_back("__mpl_main.cpp");
    return result;
}

Step 1 — shared __mpl_module_registry.h

File: CppBackend.h:9883-9980

Emitted content:

#pragma once
#include "empl.h"
#include <string>
#include <functional>
#include <unordered_map>
#include <vector>

using __mpl_module_init_fn = std::function<void()>;
using __mpl_module_exports_fn = std::function<mpl_value()>;

inline std::unordered_map<std::string, __mpl_module_entry>& __mpl_module_table() { … }
inline bool __mpl_register_module(string, init_fn, exports_fn) { … }
inline std::unordered_map<string, string>& __mpl_module_alias_table() { … }
inline bool __mpl_register_module_alias(alias, target) { … }
inline bool __mpl_has_module_impl(string id) { … }             // alias-aware lookup
inline mpl_value __mpl_require_module_impl(string id) { … }    // initializes + returns exports
inline bool __mpl_init_module_runtime_hooks() { … }            // wires runtime hooks
inline const bool __mpl_module_runtime_hooks_initialized =
    __mpl_init_module_runtime_hooks();

The crucial logic is in __mpl_require_module_impl (line 9924-9969):

  1. Look up id in __mpl_module_table(); if missing, try __mpl_module_alias_table() for an alias resolution.
  2. If the module is already initialized, return cached_exports.
  3. If the module is currently being initialized (circular require), return the live exports() before init completes — this is the cycle-handling trick.
  4. Otherwise, mark initializing = true, run init(), cache the result, mark initialized = true.
  5. Catch three exception types — mpl_js_error, mpl_value (generic JS throw), std::exception — and convert them to appropriate stderr messages without losing the module's state.

The circular-require handling is line 9937-9939 — returning the live exports() during init is what lets A→B→A resolution terminate.

Step 2 — per-module .cpp

For each entry in program->modules, the backend:

  1. Calls extractModuleBody_(entry.module) (line 9811-9880) — invokes generate() for the module, then strips the int main() wrapper and the closing mpl_run_event_loop(); _exit(0); tail to leave just the body.
  2. Patches the body's __import_meta__.url to use the full module ID (line 2289-2298) instead of the short name.
  3. Replaces __export_binding__(name, value) calls with __export_value_binding__(name, value) (line 2302-2310) so that actual mpl_value objects are stored, not stringified versions.
  4. Wraps the body in a per-module namespace with __mpl_module_init() and __mpl_module_exports() functions.

The resulting per-module file:

// Auto-generated by MPL Compiler (module: <id>)
#include "empl.h"
#include "__mpl_module_registry.h"

namespace __mpl_mod_<sanitized_id> {

// Live cell so circular requires observe live/reassigned exports
static auto __mpl_cell_exports = std::make_shared<mpl_value>(mpl_box_value(std::make_shared<mpl_object>()));
static auto& __mpl_module_exports__ = *__mpl_cell_exports;
static auto& exports = __mpl_module_exports__;

void __mpl_module_init() {
    // Reset the exports cell at the start of every init
    *__mpl_cell_exports = mpl_box_value(std::make_shared<mpl_object>());
    mpl_set(__mpl_js_module(), "exports", __mpl_module_exports__);
    <body>   // ← extracted module statements
    // ESM named exports → merge into CJS-style module.exports
    {
        auto& __mpl_rec = mpl_module_registry()[__import_meta__.url];
        auto* __mpl_obj_slot = std::get_if<std::shared_ptr<mpl_object>>(&__mpl_module_exports__);
        if (__mpl_obj_slot && *__mpl_obj_slot) {
            for (const auto& __mpl_kv : __mpl_rec.value_exports) {
                mpl_set(*__mpl_obj_slot, __mpl_kv.first, __mpl_kv.second);
            }
        }
    }
    // Default-export synthesis
    {
        auto& __mpl_rec_default = mpl_module_registry()[__import_meta__.url];
        if (!__mpl_rec_default.value_exports.count("default")) {
            if (auto* __mpl_mod_obj = std::get_if<std::shared_ptr<mpl_object>>(&__mpl_module_exports__)) {
                if (!(*__mpl_mod_obj)->properties.empty() || !(*__mpl_mod_obj)->order.empty()) {
                    __export_value_binding__("default", __mpl_module_exports__);
                }
            }
        }
    }
}

mpl_value __mpl_module_exports() {
    return *__mpl_cell_exports;
}

} // namespace __mpl_mod_<sanitized_id>

// Static registration (runs before main)
static bool __mpl_reg_<sanitized_id> = __mpl_register_module(
    "<original_id>",
    __mpl_mod_<sanitized_id>::__mpl_module_init,
    __mpl_mod_<sanitized_id>::__mpl_module_exports);

Key design choices:

Choice Why
Live cell (shared_ptr<mpl_value>) for exports Allows late assignment of module.exports = … from inside the module
__mpl_module_init resets the cell Each fresh require() call sees a clean exports object
mpl_set(__mpl_js_module(), "exports", …) Sets the module.exports reference at runtime for import.meta-style code
Named exports → CJS-style merge Both import { x } from "…" and const { x } = require("…") see the same values
Default-export synthesis If the module sets module.exports but no explicit export default, the whole exports object becomes default
Static __mpl_reg_<id> variable Initialization happens at static-init time before main()
Namespace __mpl_mod_<id> Prevents collisions between module-local names across modules

Step 3 — __mpl_main.cpp

File: CppBackend.h:9983-10033

// Auto-generated by MPL Compiler (multi-module entry point)
#include "empl.h"
#include "__mpl_module_registry.h"

// Emit bare-specifier alias registrations so runtime require()
// can find modules even when npm deduplication places them at a
// different node_modules depth than the compiler resolved.
auto aliasIt = program->metadata.find("module_aliases");
if (aliasIt != program->metadata.end()) {
    ss << "static bool __mpl_register_aliases() {\n";
    // …parse `alias=target;…` and emit __mpl_register_module_alias calls…
    ss << "}\n";
    ss << "static const bool __mpl_aliases_registered = __mpl_register_aliases();\n\n";
}

ss << "int main(int argc, char** argv) {\n";
ss << "    __mpl_js_set_process_argv(argc, argv);\n";
ss << "    // Initialize modules in topological order\n";
ss << "    const std::vector<std::string> __mpl_init_order = { … };\n";
ss << "    for (const auto& id : __mpl_init_order) {\n";
ss << "        __mpl_require_module(id);\n";
ss << "    }\n";
ss << "    mpl_run_event_loop();\n";
ss << "    return __mpl_js_process_exit_status();\n";
ss << "}\n";

The topological order comes from program->topologicalOrder() (computed by EMPLCanonicalizer). Each module is initialized exactly once; if one module requires another, the require triggers initialization of the dependency if not already done.

The module_aliases metadata key (line 9995-10013) is populated by the linker when bare-specifier imports resolve through different node_modules depths than the compiler saw — the entry-point file registers the aliases so require("react") finds the actual installed copy.


Compilation-order correctness

The compilationOrder field (line 2379-2382) is the build system's source of truth:

for (const auto& id : order) {
    result.compilationOrder.push_back(sanitizeModuleId_(id) + ".cpp");
}
result.compilationOrder.push_back("__mpl_main.cpp");

A module's .cpp is only placed before another module's .cpp if the latter depends on it. The entry file is always last because it sees every module's symbols.


CMake scaffold (entry_generated)

After writing all the .cpp files, the driver calls writeCppModularBuildScaffold (defined in MPL_Compiler.cpp):

const fs::path runtimeDir = findRuntimeSystemDir(argv[0]);
writeCppModularBuildScaffold(modOutDir, p.stem().string(), generatedFiles, runtimeDir);

The scaffold writes a CMakeLists.txt with two targets:

Target Type Sources
<project_name> (the entry target) executable __mpl_main.cpp
entry_generated INTERFACE all per-module .cpp files + __mpl_main.cpp

entry_generated is an INTERFACE target — it carries the file list as an INTERFACE property so other CMake projects can do target_link_libraries(myapp INTERFACE entry_generated) and pick up the generated sources.

The runtime (runtimeDir) is resolved by findRuntimeSystemDir — it walks upward from the compiler's location to find MPL_Standard/System/. The CMake target links against the runtime as an INTERFACE include directory.


Round-trip: what extractModuleBody_ actually does

File: CppBackend.h:9811-9880

std::string extractModuleBody_(const std::shared_ptr<ir::ModuleNode>& module) {
    std::string fullCode = generate(module);          // ← same as single-module mode
    const std::string mainMarker = "int main(int argc, char** argv) {\n";
    const size_t mainPos = fullCode.find(mainMarker);
    const size_t bodyStart = mainPos + mainMarker.size();
    const std::string tailMarker = "    mpl_run_event_loop();\n";
    const size_t tailPos = fullCode.find(tailMarker, bodyStart);
    const size_t bodyEnd = (tailPos != std::string::npos) ? tailPos : fullCode.rfind("}\n");
    std::string body = fullCode.substr(bodyStart, bodyEnd - bodyStart);
    // Strip __mpl_js_set_process_argv(argc, argv); — only valid in entry main()
    const std::string argvInit = "    __mpl_js_set_process_argv(argc, argv);\n";
    const size_t argvInitPos = body.find(argvInit);
    if (argvInitPos != std::string::npos) body.erase(argvInitPos, argvInit.size());
    return body;
}

The trick is that generate(module) already emits a fully-functional int main() for a single-module program. extractModuleBody_ reuses all of that — hoisting, statement dispatch, runtime preamble — but extracts just the body lines between the int main() opener and the closing boilerplate. The body is then embedded inside __mpl_module_init().

The argvInit strip is necessary because __mpl_js_set_process_argv is only valid in the program's entry main(), not inside __mpl_module_init().

Debug dumps

Lines 9817-9827 and 9863-9872: when the generated code exceeds 500 KB, the full code and extracted body are dumped to /tmp/mpl_extract_full_<n>_<name>.cpp and /tmp/mpl_extract_body_<n>_<name>.cpp. The numbering is process-global and name-sanitized.


Worked example: 2-module JS program

Source

a.js:

import { b } from "./b.js";
export const a = b + 1;
console.log("a:", a);

b.js:

export const b = 41;

IR (ProgramNode)

  • Module a.js (entry) imports b.js (ImportNode { from: "./b.js", binding: "b" })
  • Module b.js (no imports)
  • topologicalOrder()["b.js", "a.js"]

Output files

__mpl_module_registry.h — the shared header above.

b_js.cpp:

// Auto-generated by MPL Compiler (module: b.js)
#include "empl.h"
#include "__mpl_module_registry.h"

namespace __mpl_mod_b_js {
static auto __mpl_cell_exports = std::make_shared<mpl_value>(mpl_box_value(std::make_shared<mpl_object>()));
static auto& __mpl_module_exports__ = *__mpl_cell_exports;
static auto& exports = __mpl_module_exports__;

void __mpl_module_init() {
    *__mpl_cell_exports = mpl_box_value(std::make_shared<mpl_object>());
    mpl_set(__mpl_js_module(), "exports", __mpl_module_exports__);
    // === extracted body ===
    __export_value_binding__("b", mpl_box_value(static_cast<long double>(41)));
    // === end extracted body ===
    // named-export merge into CJS exports
    { … }
}

mpl_value __mpl_module_exports() { return *__mpl_cell_exports; }
} // namespace

static bool __mpl_reg_b_js = __mpl_register_module(
    "b.js", __mpl_mod_b_js::__mpl_module_init, __mpl_mod_b_js::__mpl_module_exports);

a_js.cpp:

namespace __mpl_mod_a_js {
…
void __mpl_module_init() {
    …
    // === extracted body ===
    // const b = mpl_call(__mpl_require_module, mpl_box_value(std::string("b.js")))["b"]
    auto __b_module = mpl_call(__mpl_require_module, mpl_box_value(std::string("b.js")));
    auto b = mpl_get_value(__b_module, "b");
    // const a = b + 1
    auto a = mpl_box_value(mpl_add(b, mpl_box_value(static_cast<long double>(1))));
    // export a
    __export_value_binding__("a", a);
    // console.log("a:", a);
    std::cout << mpl_to_string(mpl_box_value(std::string("a:"))) << " "
              << mpl_to_string(a) << std::endl;
    // === end extracted body ===
}
…
}

static bool __mpl_reg_a_js = __mpl_register_module(
    "a.js", __mpl_mod_a_js::__mpl_module_init, __mpl_mod_a_js::__mpl_module_exports);

__mpl_main.cpp:

#include "empl.h"
#include "__mpl_module_registry.h"

int main(int argc, char** argv) {
    __mpl_js_set_process_argv(argc, argv);
    const std::vector<std::string> __mpl_init_order = { "b.js", "a.js" };
    for (const auto& id : __mpl_init_order) {
        __mpl_require_module(id);
    }
    mpl_run_event_loop();
    return __mpl_js_process_exit_status();
}

CMakeLists.txt (sketched):

cmake_minimum_required(VERSION 3.8)
project(<stem> LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

add_library(entry_generated INTERFACE)
target_sources(entry_generated INTERFACE
    ${CMAKE_CURRENT_SOURCE_DIR}/b_js.cpp
    ${CMAKE_CURRENT_SOURCE_DIR}/a_js.cpp
    ${CMAKE_CURRENT_SOURCE_DIR}/__mpl_main.cpp
)
target_include_directories(entry_generated INTERFACE
    ${CMAKE_CURRENT_SOURCE_DIR}
    <runtime_dir>/System
)

add_executable(<stem> __mpl_main.cpp)
target_link_libraries(<stem> PRIVATE entry_generated)

Output when run: a: 42.


Edge cases

Case Handling
Circular requires (a imports b, b imports a) __mpl_require_module_impl returns live exports() during init to break the cycle (line 9937-9939)
Module aliases (npm dedup, bare specifiers) module_aliases metadata → __mpl_register_module_alias calls in __mpl_main.cpp (line 9995-10013)
Empty program (program->modules.empty()) generateProgram() returns an empty ProgramGenerationResult (line 2269)
Single-module program run with --modular One entry in program->modules → one .cpp file plus the registry + entry (works fine)
Module body that throws __mpl_require_module_impl catches mpl_js_error, mpl_value, std::exception and prints to stderr without aborting (line 9947-9964)
Late module.exports = X reassignment The live __mpl_cell_exports cell ensures the new value is visible to subsequent require()s

Cross-references