Status: Draft / architecture, from verified experiments.
Target compiler: Clang (verified against Clang 22.1.8 + clang-scan-deps
22.1.8, Fedora). GCC and MSVC are covered by the companion documents.
Backend: Ninja only.
Companion to: MODULES_REQUIREMENTS.md (the design and its rules) and
HEADER_UNIT_REQUIREMENTS.md (the declared-units addition). That first
document scoped Clang out (its §6, §14) on the claim that Clang "will not
self-name BMIs into a directory the way GCC does." This document records the
experimental audit of Clang against every rule in that design, states
precisely which rule breaks, and specifies the one added mechanism — a
build-time BMI-naming ("harvest") edge — that ports named modules and
import std to Clang with every invariant intact. Header units on Clang are
out of scope here (§6).
Allow a Meson project built with Clang to use C++20 named modules (including
module partitions) and import std; within a single build tree, with the same
user-facing behavior as the GCC and MSVC ports and the same properties:
- static, a-priori-known compile command lines (no module name, no BMI path,
no
-fmodule-file=/-fmodule-output=<path>on any compile command); - no per-module flags synthesized from scan results;
- no reconfiguration triggered by editing source contents;
- a single shared module cache, imports resolved by name lookup in a directory;
- ordering carried by Ninja
dyndepfrom a cold-capable P1689 scan.
Every rule of MODULES_REQUIREMENTS.md was tested against Clang 22.1.8
(experiment log: Appendix A). The result is narrower than the original
exclusion assumed:
Exactly one axiom breaks: §2.3, "the compiler owns naming and placement of BMIs" — and only on the producer side.
- With bare
-fmodule-output(Clang's only self-naming mode), the BMI is written next to the object, named after the source:mismatch.cppmprovidingexport module oddname;yieldsobj/mismatch.pcm, not<cache>/oddname.pcm. Clang has no "write into this directory keyed by module name" mode like GCC'sgcm.cacheor MSVC's/ifcOutput <dir>\. - The only way to make the compiler itself produce
<name>.pcmis-fmodule-output=<cache>/<name>.pcm— a per-module output flag whose value is scan output. That single fact violates, as written, NFR1(a) (command varies with scan results), NFR1(b) (a BMI path on a command line), and FR4 ("MUST NOT invent per-module output flags"). One root cause, three clause hits. This is CMake's fork in the road — CMake accepted collator-generated per-module flags; this design cannot.
Everything else passes, several rules verbatim:
| Rule | Clang result |
|---|---|
| §2.2 / FR5 — imports resolved by name in a directory, no per-module flags | Passes exactly. -fprebuilt-module-path=<dir> resolves imports — including the full transitive closure and partitions — by <name>.pcm lookup, with the same : → - partition filename convention as GCC/MSVC (pkg:part → pkg-part.pcm). Verified: a consumer compiled with only that directory flag resolved pkg, transitively pkg:part, and a second module; linked; ran. |
| FR2 — cold-capable scan | Passes. clang-scan-deps -format=p1689 emits accurate provides/requires with no BMI in existence, including requires: std cold. Scan rebuild tracking works: -MD -MF in the wrapped command passes through and writes a depfile. |
| FR3 — collator name→path mapping | Computable (<cache>/<name>.pcm, : → - verified); the break is only that no compile edge natively writes the mapped path (§2.3 above; repaired by the harvest edge, §5). |
FR11 — import std via dependency('std') |
Passes with both stdlibs. libc++ ships libc++.modules.json in the same is-std-library schema as libstdc++'s (plus a local-arguments.system-include-directories field, §7). And Clang compiles libstdc++'s own bits/std.cc (with -x c++-module), links, runs — so the default clang-with-libstdc++ configuration is covered, not just libc++. Verified at c++20 and c++23. |
| FR1 — extension + explicit opt-in, never content | Same shape as MSVC. Clang decides interface-unit-ness by extension (.cppm) or explicit -x c++-module, never content — -fmodule-output is silently unused on a plain .cc. Clang therefore lands exactly on the declaration contract documented for the K7 decision: .cppm/.ixx auto-detected; an export module in a plain .cpp/.cc is not supported. GCC remains the lenient compiler; Clang joins MSVC. |
| A1 / §7.1 — uniform BMI-affecting flags | Enforced, not just contractual. A cpp_std mismatch between a BMI and its consumer is a hard error ("configuration mismatch"); macro divergence is tolerated. Meson's existing cross-target cpp_std divergence warning corresponds to a Clang error — stricter than GCC, in the direction the contract already points. |
| FR6–FR10, FR8, FR9, NFR2, NFR4 | Unaffected — mechanism-independent; they ride the same dyndep/collator machinery (with the harvest edge slotted in, §5/§10). |
Secondary deviations that are new machinery but not rule breaks:
- The scanner is a separate binary (
clang-scan-deps), not the compiler with scan flags: it must be detected, should be version-matched to the clang in use, wraps the full compile command after--, and writes to stdout (or-o). New tool-detection and rule shape; same architecture. -fprebuilt-module-pathis required — Clang has no implicit default cache (a bareimport pkg;is a fatal "module 'pkg' not found"). Same permitted category as MSVC's/ifcSearchDir: a directory flag, static.- Clang does not create the module output directory (
-fmodule-output=into a missing directory is an error) — sameos.makedirstreatment the MSVC path already has forifc.cache.
Binding here unchanged, as in the header-units document: the guiding
principles (§2 there), the axioms A1–A4, the user contract (§7 there), and
FR1–FR11. A1's consequence carries over: one uniform flag set → a single
shared module cache is sound for .pcm files too, with no per-target or
per-consumer keying. Clang's enforcement of A1 (§2 table) makes violations
louder here than on GCC, not different in kind.
Extends MODULES_REQUIREMENTS.md §4.
- BMI — for Clang: a
.pcmfile. - Module cache — the shared directory consumers name via
-fprebuilt-module-pathand harvest edges populate;pcm.cache/by analogy withgcm.cache/ifc.cache. - Source-keyed BMI — the
.pcmbare-fmodule-outputwrites next to the object: the object path with.o→.pcm(verified:obj/pkgpart.cppm.o→obj/pkgpart.cppm.pcm). Its path is knowable at generation time; its name has no relation to the module name. - Harvest edge — the added per-MIU build edge that copies the source-keyed
BMI to
<cache>/<module>.pcm, reading the module name from the MIU's already-built.ddiat build time. The command line is static; the module name never appears on it.
This is the "build-time BMI-naming step" MODULES_REQUIREMENTS.md §14
anticipated. Every link in the chain is verified (Appendix A):
- MIU compile — static. Compile with bare
-fmodule-output(plus-fprebuilt-module-path=<cache>so the MIU's own imports resolve). The BMI lands at the source-keyed path — knowable at generation time, so the compile edge can declare it as an ordinary implicit output. No module name, no BMI path, nothing scan-derived on the command line. - Harvest — static command, build-time knowledge. A Meson-internal
helper takes
(source-keyed .pcm, the MIU's .ddi, cache dir)— all three generation-time-knowable — reads theprovideslogical-name from the.ddi, and copies the BMI to<cache>/<name>.pcm(partition:→-). Declared output: a stamp. The real cache path is carried by the dyndep (next item), exactly as.gcmpaths are today. - Dyndep. The collator (which already knows every module name at collate
time) emits, for the harvest edge,
<cache>/<name>.pcmas an implicit output, and for each consumer object, the cache paths it requires as order-only inputs — FR3 unchanged except that the provider of the cache path is the harvest edge rather than the compile edge. - Consumer accepts the copy. Clang resolves a copied/renamed
.pcmvia-fprebuilt-module-pathlookup without complaint — validation does not key on the original path. Verified end-to-end (compile, link, run, exit 0), including a module whose source filename differs from its name.
Cost, honestly stated: one extra (trivial) process per module-interface unit, and each BMI existing twice (source-keyed + cache copy). NFR3's "at most one process per source" gains a small asterisk: one scan per source plus one copy per MIU; no IPC, no response files, no per-consumer duplication.
Rejected alternatives.
- Per-module
-fmodule-output=<cache>/<name>.pcmfrom the collator — the CMake model. Rejected: breaks NFR1(a)+(b) and FR4; the whole point of the jussi path is not doing this. - Mandate "MIU filename == module name" so the output path is knowable from
the filename alone. Rejected: it still puts a BMI path on the command line
(NFR1(b) as written), imposes a naming convention GCC/MSVC users don't
need, and is ambiguous for partitions (
pkg-part.cppm: modulepkg:partor modulepkg-part?). - Wait for an upstream
-fmodule-output-dir(module-name-keyed directory output). The right long-term fix; tracked as an open question (§13). The harvest edge is deletable line-for-line the day it exists.
Inherits every exclusion of MODULES_REQUIREMENTS.md §6. Additionally:
| Item | Why excluded |
|---|---|
| Header units on Clang | Not audited here. Clang has distinct machinery (-fmodule-header, -fmodule-file=<path> consumption) whose fit with HEADER_UNIT_REQUIREMENTS.md needs its own experiment pass. Named modules and import std only. |
Module interfaces in plain .cpp/.cc sources |
Clang cannot be told "infer from content" (FR1 row in §2): interface treatment needs the extension or a per-source -x c++-module. Same documented limitation as MSVC (K7 decision); .cppm/.ixx or nothing. A future per-source opt-in kwarg would lift this on Clang and MSVC together. |
-fmodule-file=<name>=<path> consumption |
Per-module consumer flags; forbidden by FR5/NFR1. The directory mechanism (-fprebuilt-module-path) is sufficient and verified. |
Clang-in-MSVC-mode (clang-cl) |
Untested; separate flag surface. |
All verified on Clang 22.1.8 / clang-scan-deps 22.1.8; Appendix A is the log.
Enable modules: -std=c++20 (or later). No extra enabling flag for named
modules.
Scan — cold-capable P1689, separate tool:
clang-scan-deps -format=p1689 -o <out.ddi> -- \
clang++ <target-compile-args> -c <source> -o <obj> -MD -MF <depfile>
- Emits P1689
provides(withis-interface) /requires; accurate with no BMI existing, includingrequires: std. - The
-MD -MFinside the wrapped command passes through: the scan writes a make-format depfile for its own rebuild tracking (add-MT <out.ddi>so the depfile targets the scan output, as the GCC scan does via-fdeps-target). clang-scan-depsmust be found alongside the compiler and version-matched to it (a scanner older than the compiler may not understand new flags).
MIU compile — self-named but source-keyed:
clang++ -std=c++20 -fprebuilt-module-path=<cache> -fmodule-output -c <miu> -o <obj>
writes the object and the BMI at <obj-path with .o → .pcm>. The BMI
filename derives from the object path, never the module name. .cppm is
recognized as an interface unit; other extensions need -x c++-module
(silently not treated as a module otherwise — the flag is reported unused).
Import resolution — directory + transitive, no per-module flags:
clang++ -std=c++20 -fprebuilt-module-path=<cache> -c <consumer> -o <obj>
resolves every import — and the transitive closure, verified through a
re-exported partition — by <name>.pcm lookup in the directory. Partition
pkg:part → pkg-part.pcm (same scheme as .gcm/.ifc). A copied/renamed
.pcm is accepted. Without the flag there is no fallback lookup: fatal
"module not found".
MIU object must be linked — as on GCC/MSVC; the experiments linked every MIU's object.
Flag mismatch: consuming a BMI under a different -std is a hard error
(-Wmodule-file-config-mismatch, promoted to error); differing -D macros
are accepted.
import std sources:
- libc++:
clang++ -print-file-name=libc++.modules.json(with-stdlib=libc++) → manifest in the libstdc++-compatible schema (is-std-library: true,source-pathrelative to the manifest), pluslocal-arguments.system-include-directoriesthat the std compile must add as-isystem(also manifest-relative). Sources:std.cppm,std.compat.cppm. - libstdc++ (Meson's default with Clang on Linux):
clang++ -print-file-name=libstdc++.modules.jsonresolves to the selected GCC's manifest, and Clang compilesbits/std.ccwith-x c++-module -Wno-reserved-module-identifier; the result links and runs. - Both stdlibs verified at
-std=c++20and-std=c++23.-Wno-reserved-module-identifieris required on the std MIU compile for both (the sources declare the reserved namestd).
FR1–FR11 stand. Clang adds:
FR-C1 — Scanner detection.
Meson MUST locate clang-scan-deps when the C++ compiler is Clang and the
project uses modules, prefer the copy shipped with that Clang, and error
clearly when it is missing or older than the compiler.
supports_cpp_modules_p1689() for Clang is "a usable, version-matched
clang-scan-deps exists" plus the minimum Clang this port supports.
FR-C2 — Scan edge shape.
The scan edge runs clang-scan-deps -format=p1689 -o <ddi> -- <full compile command> -MD -MF <depfile> -MT <ddi>. The wrapped command MUST be the
target's real compile command (same args the compile edge uses, §7), so the
scan sees the same dialect. One scan per source, as FR2.
FR-C3 — MIU compile edge.
Module-interface sources compile with bare -fmodule-output and the shared
-fprebuilt-module-path=<cache>; the source-keyed .pcm is declared as an
implicit output of the compile edge (its path is generation-time-knowable).
The command MUST NOT contain -fmodule-output=<path>.
FR-C4 — Harvest edge.
Each MIU gets one harvest edge (§5): inputs = the source-keyed .pcm and the
MIU's .ddi; static command; declared output = a stamp; the collator's
dyndep declares <cache>/<name>.pcm as its implicit output. The helper MUST
apply the : → - mapping and MUST fail loudly if the .ddi lists no
provides (an MIU that scanned as a non-interface indicates a broken scan).
FR-C5 — Consumer edges.
Every C++ compile of a module-enabled target carries exactly one
module-related flag: -fprebuilt-module-path=<cache>. No -fmodule-file=,
no per-module anything. (This is get_module_compile_args() for Clang.)
FR-C6 — Collation.
FR3 unchanged, except the provider of <cache>/<name>.pcm in the dyndep is
the module's harvest edge, and consumers' order-only inputs point at the
cache paths (never at source-keyed .pcms). FR10 diagnostics unchanged.
FR-C7 — Cache directory creation.
Meson MUST create the shared cache directory up front (as the MSVC path does
for ifc.cache); Clang does not create output directories.
FR-C8 — Interface-unit detection.
By extension exactly as GCC/MSVC (.cppm/.ixx, _has_cpp_module_source);
non-.cppm extensions get -x c++-module added the way MSVC gets
/interface /TP — keyed by extension, never content. Plain-.cpp interfaces
are unsupported (documented, as on MSVC).
FR-C9 — import std synthesis.
FR11 unchanged in shape. The synthesizer MUST key on the selected stdlib:
libc++'s manifest when -stdlib=libc++ is in effect, libstdc++'s otherwise;
MUST honor the manifest's local-arguments.system-include-directories
(manifest-relative) as -isystem; MUST add
-Wno-reserved-module-identifier and (for non-.cppm sources such as
libstdc++'s bits/std.cc) -x c++-module to the std MIU compiles only.
The std provider rides FR-C3/C4 like any MIU: harvested into the shared
cache, its objects linked as an ordinary dependency library.
FR-C10 — A1 divergence.
The existing cross-target cpp_std divergence warning stays; on Clang the
build-time consequence is a hard configuration-mismatch error rather than
IFNDR, which is acceptable (louder, same contract).
Per module-enabled target (delta from MODULES_REQUIREMENTS.md §10 marked):
each source ──► [scan edge: clang-scan-deps -format=p1689 -- <compile cmd>]
──► src.ddi (FR-C2)
│
dep targets' provided-module maps
▼
[collate edge] ──► target.dyndep + target.provided-modules
│
▼
each MIU ──► [compile: -fmodule-output -fprebuilt-module-path=cache]
──► obj.o + obj.pcm (source-keyed, implicit out) (FR-C3)
│
▼
[harvest edge: obj.pcm + src.ddi ──► stamp] (FR-C4)
│ dyndep: implicit out = cache/<name>.pcm
▼
each source ──► [compile edge, dyndep=…, -fprebuilt-module-path=cache]
──► obj.o (FR-C5)
▼
[link edge] (MIU objects linked, as GCC/MSVC)
- Scan, compile, and harvest command lines are all static; only the dyndep (built at build time by the collator, which has the scan results) carries module names — as file paths in the graph, never as flags.
Both sub-clauses hold for every compile and scan command:
(a) no command varies with scan results — the MIU/consumer/scan commands
differ only in input/output paths fixed at generation time; (b) no BMI path
and no module name appears on any compile or scan command line — the only
module-related flags are -fmodule-output (bare) and
-fprebuilt-module-path=<dir> (a directory). The harvest edge's command
names the source-keyed .pcm — a generation-time artifact path derived from
the object name, carrying no module knowledge; the module-named cache path
exists only inside the dyndep, exactly where .gcm paths live today. The
spirit and letter of "static command lines + dyndep-for-ordering" survive.
- Two copies of every BMI (source-keyed + cache). Disk, not time; a
future upstream
-fmodule-output-dirdeletes the harvest edge (§13). - Plain-
.cppmodule interfaces unsupported (FR-C8), as MSVC. - Header units on Clang unaudited (§6).
std.compatuntested.stdis verified end-to-end on both stdlibs;std.compatis listed in both manifests and expected to ride identically (it additionallyimport std;, ordered by the ordinary scan like GCC's), but it has not been run here yet.- One toolchain sample. Clang 22 on one machine; the minimum supported
Clang (P1689 in clang-scan-deps landed in 16;
-fmodule-outputin 17; manifest shipping varies by distro) needs pinning against CI, not this box.
Extends MODULES_REQUIREMENTS.md §12.
| Failure | Required behavior |
|---|---|
clang-scan-deps missing / version-mismatched |
Configure-time error naming the tool and the Clang it must match (FR-C1) |
MIU in a plain .cpp |
Not detected; documented limitation (FR-C8), as MSVC |
Harvest finds no provides in the .ddi |
Hard error naming the source (FR-C4) |
| Import with cache entry absent | Ordinary FR10 missing-provider error at collate; never a raw "module not found" for in-tree providers |
BMI/consumer cpp_std divergence |
Clang hard error (config mismatch); Meson warns at generate as today (FR-C10) |
| Cache dir missing | Never happens: created up front (FR-C7) |
import std; without dependency('std') |
FR10 missing-provider error with the FR11 hint |
- Upstream
-fmodule-output-dir. A module-name-keyed output directory in Clang would make it a straight GCC/MSVC peer and delete FR-C4 wholesale. Worth pursuing/tracking upstream; the harvest edge is designed to be removable. - Reduced BMIs. Newer Clang can emit reduced BMIs (interface-only,
-fmodules-reduced-bmi), which shrink rebuild cascades (better restat behavior for FR6). Not exercised here; adopt once the minimum Clang allows. clang-cland Clang targeting the MSVC ABI: separate flag surface, untested.- Header units on Clang against
HEADER_UNIT_REQUIREMENTS.md: needs its own experiment pass (-fmodule-headersemantics, cold-scan behavior ofclang-scan-depson header-unit imports). - Minimum version pinning (§11.5) once CI has a Clang matrix.
Experiment artifacts: scanbench-style scratch tree (clangexp/), sources
pkg.cppm (export module pkg; export import :part;), pkgpart.cppm
(export module pkg:part;), mismatch.cppm (export module oddname; — name
≠ filename), main.cpp (import pkg; import oddname;), stdmain.cpp
(import std; + std::println).
- Cold scan:
clang-scan-deps -format=p1689 -- clang++ -std=c++20 -c pkg.cppm -o pkg.owith no.pcmanywhere → correctprovides: pkg (is-interface: true),requires: pkg:part; consumer scan →requires: pkg, oddname;import std;scans cold torequires: std. - Depfile passthrough:
-MD -MF scan.dinside the wrapped command →scan.dwritten by the scan invocation. - Source-keyed BMI:
-fmodule-output -o obj/pkgpart.o→obj/pkgpart.pcm;-o obj/pkgpart.cppm.o→obj/pkgpart.cppm.pcm(object path,.o→.pcm);mismatch.cppm(moduleoddname) →obj/mismatch.pcm— never the module name. - Directory lookup: consumer with only
-fprebuilt-module-path=cacheresolvespkg, transitivelypkg:part(ascache/pkg-part.pcm—:→-), andoddname; links; runs (exit 0). - Copied BMIs accepted: all cache entries above were
cp'd from bare-fmodule-outputoutputs, including one renamed (mismatch.pcm→oddname.pcm). - No implicit cache: same consumer without the flag → fatal
module 'pkg' not found. - Config mismatch: consume a c++20 BMI at
-std=c++23→ hard error (config mismatch); differing-D→ accepted. - Extension-keyed interface detection:
-fmodule-outputon a.ccMIU → "argument unused" warning, no BMI;-x c++-module→ BMI produced. - No output-dir creation:
-fmodule-output=missing-dir/std.pcm→ errorunable to open output file. import std/ libc++: manifest at…/lib64/libc++.modules.json(is-std-library, manifest-relativesource-path,local-arguments.system-include-directories);std.cppmcompiled with-std=c++23 -stdlib=libc++ -Wno-reserved-module-identifier -isystem <manifest-relative dir> -fmodule-output; consumer with-fprebuilt-module-pathcompiled, linked withstd's object, ran (std::printlnoutput). Also builds at-std=c++20.import std/ libstdc++:clang++ -print-file-name=libstdc++.modules.jsonresolves (GCC 16's manifest);bits/std.cccompiled by Clang with-x c++-module -Wno-reserved-module-identifier; consumer compiled against the resultingstd.pcmwith the default stdlib, linked, ran.