Repository navigation
build.mcpp cannot import an ordinary module library: host-module and [dependencies] disagree on export surface and compile context #797
Description
Summary
A module library that works as an ordinary dependency cannot be imported from build.mcpp.
Declaring it with host-module = true fails, and it fails for two independent reasons:
- Export surface. The host-module path requires a lib root (
[lib] pathorsrc/<name>.cppm).
The ordinary path does not: it exports everyexport moduleunit amongsources, checked against
[modules].exports(Form B:modules = { … }), and a missing lib root is only amcpp packwarning.
The xpkg Form B vocabulary has nolibkey, so a Form B package cannot state a lib root at all. - Compile context. The host-module path compiles each interface unit alone with the build
program's flags. The provider's owninclude_dirs,defines,cxxflags, and its dependencies'
public include paths are not passed. Any module unit whose global module fragment includes the
library's headers (the normal shape of a module wrapper over a header library) cannot compile.
The two paths are separate implementations, and they give different answers to the same question:
what does this package export, and in what context is it compiled? The host-module path was designed
for rule packages (mcpp.plugins, grpcgen: pure modules importing only std/mcpp), and
docs/30 says so: "units are otherwise compiled alone, so they import std, mcpp and nothing else".
Using an ordinary library in a build program is not modelled. This issue asks for a design
decision, not a patch.
Reproduction (mcpp 2026.10.5.2, gcc 16.1.0, x86_64-linux-gnu)
One package: toml++ 3.4.0 plus a module unit. The mcpp.toml is written by hand and states everything explicitly:
# pkg/mcpp.toml
[package]
namespace = "marzer"
name = "tomlplusplus"
version = "3.4.0"
standard = "c++23"
[lib]
path = "mcpp_generated/tomlplusplus.cppm" # export module tomlplusplus; GMF: #include <toml++/toml.hpp>
[build]
sources = ["mcpp_generated/tomlplusplus.cppm"]
include_dirs = ["tomlplusplus-3.4.0/include"]
[targets.tomlplusplus]
kind = "lib"Ordinary dependency: works.
[dependencies.marzer]
tomlplusplus = { path = "../pkg" }// src/main.cpp
import std; import tomlplusplus;
int main() { std::println("{}", toml::parse("x = 42\n")["x"].value_or(0)); } // prints 42Build dependency: fails.
[build-dependencies.marzer]
tomlplusplus = { path = "../pkg", host-module = true }// build.mcpp
import std; import tomlplusplus;
int main() {
auto t = toml::parse("x = 42\n");
std::println("mcpp:cxxflag=-DTOML_X={}", t["x"].value_or(0));
}error: host module 'tomlplusplus' compile failed (exit 1):
pkg/mcpp_generated/tomlplusplus.cppm:9:10: fatal error: toml++/toml.hpp: No such file or directory
The same package taken from an index as Form B (mcpp = { … } with generated_files, as
marzer.tomlplusplus in mcpp-index is) fails one step earlier:
error: host module 'tomlplusplus': no interface unit at <verdir>/src/tomlplusplus.cppm
A package offering build rules must have a lib root (src/<name>.cppm or [lib] path).
Where the two paths diverge
[dependencies] |
host-module = true |
|
|---|---|---|
| implementation | full graph: modgraph scan → per-package compile plan → ninja | prepare/features.cpp::host_module_units + build/host_module_compile.cppm::provide_host_module, called from build_program.cppm |
| export surface | every export module unit in sources; [modules].exports is the declaration |
lib root (mandatory), then the remaining interface units in sources; [modules].exports not consulted |
| missing lib root | warning (modgraph/validate.cppm §2.5, impact: mcpp pack) |
hard error |
| Form B | modules = {…} → modules.exports_; no lib-root key (xpkg.cppm kKnownXpkgKeys) |
unreachable unless the generated unit happens to sit at src/<tail>.cppm |
| compile argv | provider's include_dirs/_after, defines, cxxflags, deps' public includes |
compiler <std> -fmodules -c <iface> + build-program base + std/mcpp/host BMIs only |
| provider's non-interface sources | compiled and linked | not compiled; one object per interface unit |
| provider's own deps | resolved in the graph | not part of the host compile |
There is also an asymmetry inside [build-dependencies] itself. A tools = [...] request is built by
the tool sub-build, which resolves the package as its own root (docs/05). That is a complete build of
the package for the host, with its own context. A host-module = true request does not use that
mechanism.
Impact
- In mcpp-index, all 7 Form B module packages that synthesize their interface with
generated_files
(nlohmann.json,fmtlib.fmt,chriskohlhoff.asio,marzer.tomlplusplus,boost-ext.ut,
neargye.magic_enum,mpusz.mp-units) are unusable frombuild.mcpp. So is every Form B package
whose interface comes from an upstream path such as*/src/…. - The natural build-program use cases are blocked: parsing a TOML/JSON config, formatting generated
code with fmt, and similar. - The only workaround that works today is in the package, not the engine. It rewrites the interface
unit's#include <lib/header.hpp>into a path relative to the unit ("../../include/…"), and on
Form B it also places the unit atsrc/<name>.cppm. Both are deviations from upstream module units
that the index otherwise keeps verbatim.
Design questions
- What is a build dependency that is imported?
host-module = truecurrently means
"a rule: an extension of the build program's API." An ordinary library compiled for the host and
linked into the build program (Cargo'sbuild-dependencies) is a different thing. Should these
be one concept with one pipeline, or two named concepts? - One definition of the export surface. Which declaration governs what a dependency exposes:
[lib] path,[modules].exports, or the scannedsources? Today the answer depends on how the
package is consumed. If[lib] pathis the export-surface control (no inference), then:- Should the ordinary path enforce it the same way?
- Should Form B gain an explicit
libkey that maps 1:1 to[lib] path? - How do
libandmodulesrelate when both are present?
- Compile context of a host build. Should compiling a package for the build program mean
"the same package plan, built with the host toolchain"? That would include itsinclude_dirs,
defines,cxxflags, features, and its dependencies' public interface. If so, everything that
reaches the compile must enter the host-module store key (common_inputs). Today that key covers
only what the argv carries. - Transitive dependencies and feature resolution. A host library has its own dependencies. Is
that a separate host sub-graph (the tool sub-build model), or a shared graph? How are features
unified between the host and target uses of one package (cf. Cargo resolver v2 separating them)?
What goes into the lockfile? - Linking. For a library that is not header-only, implementation units, C sources, and prebuilt
archives must reach the build program's link. What is the boundary, given that a rule package must
stay "compiled once, keyed globally"? - Diagnostics. "A package offering build rules must have a lib root" is the wrong message for a
user who wanted a library, not a rule. Whatever the model becomes, the error should name the
concept the user actually reached for.
Possible directions (non-prescriptive)
- A. Separate concept. Keep
host-modulerule-only. Add a "host library" edge that goes through
the existing tool sub-build machinery (the package resolved as its own root, for the host), and
expose its BMIs and archives to the build program. - B. Unify. The host-module compile reuses the ordinary per-package compile plan and swaps only
toolchain, standard, and store. Rule packages become the special case "a host library whose units
importmcpp". - C. Tactical. Add a Form B
libkey and pass the provider's compile inputs to the host compile.
This unblocks header-backed module wrappers, but questions 1, 4, and 5 remain open.
Workaround in mcpp-index (for reference)
marzer.tomlplusplus is moving to Form A so that build.mcpp can import it today:
mcpp = "*/mcpp.toml", and an install() hook writes mcpp.toml
([lib] path = "src/modules/tomlplusplus.cppm", include_dirs = ["include"]) and the unit into the
unpacked tree. The unit includes "../../include/toml++/toml.hpp" instead of <toml++/toml.hpp>.
Verified: build.mcpp imports it and runs, and ordinary consumers are unchanged. With the upstream
<toml++/toml.hpp> restored, the host compile fails again. The relative include is the part that
carries the workaround; it should be reverted once the engine models this case.
Activity
Sunrisepeak commentedon Oct 10, 2026
Related: one package in both
[dependencies]and[build-dependencies](host-module) loses its target-side BMISame design area: the boundary between an ordinary edge and a host-module edge. Found while adding a test to mcpp-index.
docs/05 states: "Naming one package in both tables is not an error: the ordinary declaration wins … what each declaration requests (
tools,features,host-module,reexport) is requested of the one edge." The comment inprepare/features.cpp(the build-time-only pruning) says the same about the dual-role case: "A package the project depends on directly must stay in the target."With the Form A toml++ from the workaround above (its own
mcpp.toml,[lib] path,include_dirs), on mcpp 2026.10.5.2:build.mcpp(import tomlplusplus;) compiles and runs ✅tests/parse.cpp(import tomlplusplus;) fails:Each table on its own works. The likely cause: the two declarations merge into one edge that carries
hostModule = true.reachable(targetEdgesOnly = true)skips any edge withe.hostModule || e.buildOnly, so the package looks unreachable from the target,buildTimeOnlyis set, and itssources/modules.sourcesare cleared. On a merged edge,hostModulemarks a request, not "build-time only". The target-reachability test reads it as the latter.mcpp-index works around this by testing the two uses in separate members.
fix(site)+feat(tomlplusplus): 文档站点死链修复;build.mcpp 中可 import tomlplusp…
A host sub-build targets the host; one directory, every spelling; tes…
A synthesised program is written where a project takes it over; the r…
A build program's environment contract does not depend on the childre…
A package's compiled build program is placed beside its run record ag…
A planning pass places no program copy in the project (SPEC-005 R2.1, #…
A plugin hands a dependency on with reexport = true on its [dependenc…
docs: a build plugin names the libraries its modules import in [depen…
feat: build.mcpp, its libraries and mcpp's engine interface have the …
mcpp-language-server 0.0.12: the maintained mcppls-clangd engine, hea…