Skip to content

build.mcpp cannot import an ordinary module library: host-module and [dependencies] disagree on export surface and compile context #797

Closed

Description

@Sunrisepeak

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:

  1. Export surface. The host-module path requires a lib root ([lib] path or src/<name>.cppm).
    The ordinary path does not: it exports every export module unit among sources, checked against
    [modules].exports (Form B: modules = { … }), and a missing lib root is only a mcpp pack warning.
    The xpkg Form B vocabulary has no lib key, so a Form B package cannot state a lib root at all.
  2. Compile context. The host-module path compiles each interface unit alone with the build
    program's flags. The provider's own include_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 42

Build 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 from build.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 at src/<name>.cppm. Both are deviations from upstream module units
    that the index otherwise keeps verbatim.

Design questions

  1. What is a build dependency that is imported? host-module = true currently 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's build-dependencies) is a different thing. Should these
    be one concept with one pipeline, or two named concepts?
  2. One definition of the export surface. Which declaration governs what a dependency exposes:
    [lib] path, [modules].exports, or the scanned sources? Today the answer depends on how the
    package is consumed. If [lib] path is the export-surface control (no inference), then:
    • Should the ordinary path enforce it the same way?
    • Should Form B gain an explicit lib key that maps 1:1 to [lib] path?
    • How do lib and modules relate when both are present?
  3. 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 its include_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.
  4. 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?
  5. 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"?
  6. 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-module rule-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
    import mcpp".
  • C. Tactical. Add a Form B lib key 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

  1. Sunrisepeak commented on Oct 10, 2026

    @Sunrisepeak
    MemberAuthor

    Related: one package in both [dependencies] and [build-dependencies] (host-module) loses its target-side BMI

    Same 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 in prepare/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:

    [dependencies.marzer]
    tomlplusplus = "3.4.0"
    
    [build-dependencies.marzer]
    tomlplusplus = { version = "3.4.0", host-module = true }
    • build.mcpp (import tomlplusplus;) compiles and runs ✅
    • the project's own tests/parse.cpp (import tomlplusplus;) fails:
    tomlplusplus: error: failed to read compiled module: No such file or directory
    tomlplusplus: note: compiled module file is 'gcm.cache/tomlplusplus.gcm'
    tomlplusplus: note: imports must be built before being imported
    

    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 with e.hostModule || e.buildOnly, so the package looks unreachable from the target, buildTimeOnly is set, and its sources / modules.sources are cleared. On a merged edge, hostModule marks 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions