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):
- Look up
idin__mpl_module_table(); if missing, try__mpl_module_alias_table()for an alias resolution. - If the module is already initialized, return
cached_exports. - If the module is currently being initialized (circular require), return the live
exports()before init completes — this is the cycle-handling trick. - Otherwise, mark
initializing = true, runinit(), cache the result, markinitialized = true. - 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:
- Calls
extractModuleBody_(entry.module)(line 9811-9880) — invokesgenerate()for the module, then strips theint main()wrapper and the closingmpl_run_event_loop(); _exit(0);tail to leave just the body. - Patches the body's
__import_meta__.urlto use the full module ID (line 2289-2298) instead of the short name. - Replaces
__export_binding__(name, value)calls with__export_value_binding__(name, value)(line 2302-2310) so that actualmpl_valueobjects are stored, not stringified versions. - 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) importsb.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
generateProgram()interface contract:../../empl/index.md- Module linking for JS projects:
../frontends/javascript.md EMPLCanonicalizer::topologicalOrder:../../empl/canonicalizer.md- Single-file
generate()path:./emitter.md mpl_valueboxing rules for the exports cell:./variant-handling.md