Skip to content

Commit 86607e3

Browse files
committed
feat(build.mcpp): support import std;
mcpp asks projects to import std everywhere and then made their build script fall back to #include — there was no std BMI channel in the build.mcpp compile at all, and the bundled mcpp module says so in its own header comment. The std module the main build already uses is reusable verbatim: stdmod::ensure_built caches on (toolchain x standard x dialect), so a native build is a cache HIT on the very artifact the project's own TUs import — zero extra work. Only a cross build pays for a second one, which is unavoidable because build.mcpp runs on the host. That host/target split is the load-bearing part: prepare.cppm already resolves a host toolchain for build.mcpp (deliberately without the --target axis) and passes it in, so ensure_built gets the right one. Feeding it the target's std would produce a helper that cannot execute, and silently so until exec time. e2e 112 now proves the helper RAN by the file it was asked to write, not by the compiler's exit code. Detection matches the whole module name up to its ';' — 'import std' is a prefix of 'import std.compat', so the naive substring test builds a BMI nobody asked for. e2e 181 covers import std alone, import std with import mcpp (they share the staged-BMI cwd, which an implementation treating them as two independent conditions gets wrong), and an #include-only program that merely mentions import std in a comment.
1 parent c29bec2 commit 86607e3

3 files changed

Lines changed: 276 additions & 12 deletions

File tree

src/build/build_program.cppm

Lines changed: 136 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import mcpp.toolchain.fingerprint; // hash_file / hash_string (FNV-1a, 16 hex)
2121
import mcpp.toolchain.linkmodel; // shared C-library / clang-cfg-bypass model
2222
import mcpp.toolchain.model; // Toolchain, PayloadPaths, is_clang/is_musl_target/is_mingw_target
2323
import mcpp.toolchain.registry; // archive_tool
24+
import mcpp.toolchain.stdmod; // ensure_built — the SAME std BMI the main build uses
2425
import mcpp.toolchain.triple; // host_triple (MCPP_HOST contract value)
2526
import mcpp.ui;
2627

@@ -243,10 +244,13 @@ std::vector<std::string> host_base_flags(const mcpp::toolchain::Toolchain& tc) {
243244
return f;
244245
}
245246

246-
// The bundled `mcpp` build module — a typed API over the stdout wire protocol so
247-
// build.mcpp can `import mcpp;` (no `#include`, no `import std;`). I/O uses
248-
// C-level primitives in the global module fragment, so the module needs no std
249-
// module BMI. The functions mirror the directive set 1:1; they just print the
247+
// The bundled `mcpp` build module — a typed API over the stdout wire protocol
248+
// so build.mcpp can `import mcpp;` instead of `#include`. Its own I/O uses
249+
// C-level primitives in the global module fragment, so the module itself
250+
// needs no std BMI and stays buildable before one exists. (That was once also
251+
// a limit on build.mcpp; it no longer is — a build.mcpp may `import std;` and
252+
// the engine stages the same std module the main build uses.)
253+
// The functions mirror the directive set 1:1; they just print the
250254
// `mcpp:` lines the engine already parses. Embedded in the binary (not shipped as
251255
// a file) so it always matches this mcpp's protocol.
252256
// NOTE: the module declaration line uses a `@MODULE@` placeholder (substituted
@@ -311,6 +315,34 @@ inline const char* dep_dir(const char* name) {
311315
// GCC : -fmodules → gcm.cache/mcpp.gcm + mcpp.o; build.mcpp compiles from
312316
// `bdir` (cwd) so GCC finds gcm.cache/mcpp.gcm.
313317
// Clang : --precompile → mcpp.pcm, then -c → mcpp.o; pass -fmodule-file=mcpp=<pcm>.
318+
// Does the source contain `import <name>;`?
319+
//
320+
// A plain substring search is not enough here: "import std" is a prefix of
321+
// "import std.compat", so the naive test reports both for a program that
322+
// only imports the latter, and mcpp would build a std BMI nobody asked for.
323+
// Match the whole module name and require the terminating `;`, tolerating
324+
// the whitespace the grammar allows. Occurrences inside comments or string
325+
// literals still match — over-detection costs one cached BMI lookup, never
326+
// a wrong build, and that is the same trade the `import mcpp` check has
327+
// always made.
328+
bool imports_module(std::string_view src, std::string_view name) {
329+
constexpr std::string_view kImport = "import";
330+
std::size_t pos = 0;
331+
while ((pos = src.find(kImport, pos)) != std::string_view::npos) {
332+
std::size_t i = pos + kImport.size();
333+
// `importfoo` is not an import.
334+
if (i >= src.size() || (src[i] != ' ' && src[i] != '\t')) { ++pos; continue; }
335+
while (i < src.size() && (src[i] == ' ' || src[i] == '\t')) ++i;
336+
if (src.compare(i, name.size(), name) == 0) {
337+
std::size_t j = i + name.size();
338+
while (j < src.size() && (src[j] == ' ' || src[j] == '\t')) ++j;
339+
if (j < src.size() && src[j] == ';') return true;
340+
}
341+
++pos;
342+
}
343+
return false;
344+
}
345+
314346
std::expected<std::vector<std::string>, std::string>
315347
build_mcpp_module(const fs::path& bdir, const fs::path& compiler,
316348
const std::vector<std::string>& base, const std::string& stdFlag,
@@ -663,7 +695,9 @@ std::expected<void, std::string> run_build_program(
663695
// finds gcm.cache/mcpp.gcm.
664696
std::string srcText;
665697
{ std::ifstream is(src); std::ostringstream ss; ss << is.rdbuf(); srcText = ss.str(); }
666-
bool usesModule = srcText.find("import mcpp") != std::string::npos;
698+
bool usesModule = srcText.find("import mcpp") != std::string::npos;
699+
bool usesStdCompat = imports_module(srcText, "std.compat");
700+
bool usesStd = usesStdCompat || imports_module(srcText, "std");
667701

668702
std::vector<std::string> moduleFlags;
669703
if (usesModule) {
@@ -673,18 +707,104 @@ std::expected<void, std::string> run_build_program(
673707
moduleFlags = std::move(*mf);
674708
}
675709

710+
// ── `import std;` in build.mcpp ─────────────────────────────────────────
711+
//
712+
// mcpp asks projects to `import std;` everywhere and then made their build
713+
// script fall back to `#include` — the bundled `mcpp` module even says so
714+
// in its own header comment. The std module the main build already uses is
715+
// reusable verbatim: stdmod::ensure_built caches on
716+
// (toolchain × standard × dialect), so for a native build this is a cache
717+
// HIT on the very artifact the project's own TUs import. Only a cross
718+
// build pays for a second one, which is unavoidable — see below.
719+
//
720+
// `tc` here is the HOST toolchain: prepare.cppm's
721+
// host_tc_for_build_program() resolves the spec WITHOUT the --target axis
722+
// and hands it in. That is load-bearing. build.mcpp is compiled AND run on
723+
// the machine doing the build, so a std BMI built for the target would
724+
// produce a helper that cannot execute — the same host≠target mistake the
725+
// mingw-cross work had to fix in four separate places.
726+
std::vector<std::string> stdFlags;
727+
std::vector<std::string> stdObjects;
728+
// GCC finds staged BMIs by cwd; Clang/MSVC get an explicit path flag.
729+
bool stdStagedInBdir = false;
730+
if (usesStd) {
731+
if (!tc.hasImportStd) {
732+
return std::unexpected(std::format(
733+
"build.mcpp uses `import std;` but the host toolchain ({}) "
734+
"ships no std module.\n"
735+
" Use #include in build.mcpp, or switch to a toolchain "
736+
"that provides one.", tc.label()));
737+
}
738+
auto sm = mcpp::toolchain::ensure_built(
739+
tc, cppStandard.canonical, std_flag,
740+
mcpp::platform::macos::deployment_target(
741+
m.buildConfig.macosDeploymentTarget));
742+
if (!sm) {
743+
return std::unexpected(std::format(
744+
"build.mcpp uses `import std;` but the std module could not be "
745+
"built for the host toolchain: {}", sm.error().message));
746+
}
747+
748+
auto traits = mcpp::toolchain::bmi_traits(tc);
749+
if (traits.stdBmiUsePrefix.empty()) {
750+
// GCC: BMIs are found implicitly under <cwd>/gcm.cache, so stage
751+
// the cached ones where the compile will look. Copy rather than
752+
// symlink — this mirrors the main build's staging edge, and a
753+
// stale copy is caught by ensure_built's own cache key.
754+
std::error_code ec;
755+
fs::path gcmDir = bdir / traits.bmiDir;
756+
fs::create_directories(gcmDir, ec);
757+
auto stage = [&](const fs::path& from, std::string_view name)
758+
-> std::expected<void, std::string> {
759+
if (from.empty() || !fs::exists(from)) return {};
760+
fs::path to = gcmDir / std::format("{}{}", name, traits.bmiExt);
761+
fs::copy_file(from, to, fs::copy_options::overwrite_existing, ec);
762+
if (ec) return std::unexpected(std::format(
763+
"staging {} for build.mcpp failed: {}", name, ec.message()));
764+
return {};
765+
};
766+
if (auto r = stage(sm->bmiPath, "std"); !r)
767+
return std::unexpected(r.error());
768+
if (usesStdCompat) {
769+
if (auto r = stage(sm->compatBmiPath, "std.compat"); !r)
770+
return std::unexpected(r.error());
771+
}
772+
// -fmodules may already be present from the `mcpp` module path;
773+
// GCC tolerates the repeat, but keep the argv honest.
774+
if (!usesModule) stdFlags.push_back("-fmodules");
775+
stdStagedInBdir = true;
776+
} else {
777+
stdFlags.push_back(std::string(traits.stdBmiUsePrefix)
778+
+ sm->bmiPath.string());
779+
if (usesStdCompat && !sm->compatBmiPath.empty())
780+
stdFlags.push_back(std::string(traits.stdCompatBmiUsePrefix)
781+
+ sm->compatBmiPath.string());
782+
// The prefixes carry a leading space for the ninja string channel;
783+
// an argv element must not.
784+
for (auto& f : stdFlags)
785+
if (!f.empty() && f.front() == ' ') f.erase(0, 1);
786+
}
787+
if (!sm->objectPath.empty() && fs::exists(sm->objectPath))
788+
stdObjects.push_back(sm->objectPath.string());
789+
if (usesStdCompat && !sm->compatObjectPath.empty()
790+
&& fs::exists(sm->compatObjectPath))
791+
stdObjects.push_back(sm->compatObjectPath.string());
792+
}
793+
676794
// `-x c++` is required: the `.mcpp` extension is unknown to the compiler, so
677795
// without it the driver hands build.mcpp to the linker as a linker script.
678796
std::vector<std::string> compileArgv = { hostCompiler.string(), std_flag, "-O0" };
679797
for (auto& bf : base) compileArgv.push_back(bf);
680798
for (auto& mf : moduleFlags) compileArgv.push_back(mf);
799+
for (auto& sf : stdFlags) compileArgv.push_back(sf);
681800
compileArgv.push_back("-x"); compileArgv.push_back("c++");
682801
compileArgv.push_back(src.string());
683-
if (usesModule) {
684-
// Link the module object (reset the input language first so the .o isn't
685-
// treated as C++ source).
802+
if (usesModule || !stdObjects.empty()) {
803+
// Link the module objects (reset the input language first so the .o
804+
// isn't treated as C++ source).
686805
compileArgv.push_back("-x"); compileArgv.push_back("none");
687-
compileArgv.push_back((bdir / "mcpp.o").string());
806+
if (usesModule) compileArgv.push_back((bdir / "mcpp.o").string());
807+
for (auto& so : stdObjects) compileArgv.push_back(so);
688808
}
689809
// Self-contained helper link — see the staticHostHelper doctrine above.
690810
// Deliberately NOT in `base`: that also feeds the bundled module's
@@ -693,9 +813,13 @@ std::expected<void, std::string> run_build_program(
693813
if (staticHostHelper) compileArgv.push_back("-static");
694814
compileArgv.push_back("-o"); compileArgv.push_back(bin.string());
695815
mcpp::ui::info("build.mcpp", "compiling");
696-
// GCC resolves `import mcpp;` via gcm.cache/ relative to the compile cwd, so
697-
// run the module-using compile from bdir; otherwise the project root is fine.
698-
std::string compileCwd = usesModule ? bdir.string() : root.string();
816+
// GCC resolves imported BMIs via gcm.cache/ relative to the compile cwd, so
817+
// any compile that imports a module — `mcpp`, `std`, or both — has to run
818+
// from bdir, where they were staged. One condition, not two: a build.mcpp
819+
// that imports only std needs exactly the same cwd as one that imports
820+
// only mcpp. Otherwise the project root is fine.
821+
const bool needsBmiCwd = usesModule || stdStagedInBdir;
822+
std::string compileCwd = needsBmiCwd ? bdir.string() : root.string();
699823
auto cres = mcpp::platform::process::capture_exec(compileArgv, {}, compileCwd);
700824
if (cres.exit_code != 0) {
701825
return std::unexpected(std::format(

tests/e2e/112_build_mcpp_cross.sh

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,34 @@ if command -v wine &>/dev/null; then
6060
echo "unexpected wine output: $out"; exit 1; }
6161
fi
6262

63+
# ── host≠target for `import std;` in build.mcpp ────────────────────────────
64+
# The std module staged for a build.mcpp must be the HOST one. Feeding it the
65+
# target's would produce a helper that cannot execute here, and the failure is
66+
# silent until exec time — the same class of mistake the mingw-cross work had
67+
# to fix in four separate places. A cross build is the only configuration
68+
# where host and target BMIs differ, so this is the one place it can be
69+
# caught.
70+
cat > build.mcpp <<'EOF'
71+
import std;
72+
int main() {
73+
std::ofstream f("src/cross_gen.cpp");
74+
f << "extern \"C\" const char* bp_target() { return \""
75+
<< (std::getenv("MCPP_TARGET") ? std::getenv("MCPP_TARGET") : "<unset>")
76+
<< "\"; }\n";
77+
if (!f) return 1;
78+
std::println("mcpp:generated=src/cross_gen.cpp");
79+
return 0;
80+
}
81+
EOF
82+
83+
rm -f src/cross_gen.cpp
84+
"$MCPP" build --target x86_64-windows-gnu > build-std.log 2>&1 || {
85+
cat build-std.log; echo "cross build with import std in build.mcpp failed"; exit 1; }
86+
# The helper actually RAN on the host — proven by the file it was asked to
87+
# write, not by the compiler's exit code.
88+
[[ -f src/cross_gen.cpp ]] || {
89+
cat build-std.log; echo "import-std build.mcpp did not run on the host"; exit 1; }
90+
grep -q 'x86_64-windows-gnu' src/cross_gen.cpp || {
91+
cat src/cross_gen.cpp; echo "MCPP_TARGET wrong under import std"; exit 1; }
92+
6393
echo "OK"
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
#!/usr/bin/env bash
2+
# requires: unix-shell
3+
# 181_build_mcpp_import_std.sh — build.mcpp can `import std;`
4+
#
5+
# mcpp asks projects to `import std;` everywhere, then made their build script
6+
# fall back to `#include` — there was no std BMI channel in the build.mcpp
7+
# compile at all. This locks the gap shut. Cross-platform on purpose:
8+
# `import std;` is not a Windows-specific concern, and running it on Linux
9+
# gives the fastest feedback.
10+
set -e
11+
12+
TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
13+
cd "$TMP"
14+
"$MCPP" new imp_std >/dev/null 2>&1
15+
cd imp_std
16+
17+
# 1) `import std;` alone — the container/algorithm/format surface a real build
18+
# script reaches for, none of which is available without the std module.
19+
cat > build.mcpp <<'EOF'
20+
import std;
21+
int main() {
22+
std::vector<std::string> defines{"MCPP_FROM_IMPORT_STD", "MCPP_STD_COUNT_2"};
23+
std::ranges::sort(defines);
24+
for (auto const& d : defines) std::println("mcpp:cfg={}", d);
25+
std::println("mcpp:rerun-if-changed=build.mcpp");
26+
return 0;
27+
}
28+
EOF
29+
30+
cat > src/main.cpp <<'EOF'
31+
import std;
32+
int main() {
33+
#if defined(MCPP_FROM_IMPORT_STD) && defined(MCPP_STD_COUNT_2)
34+
std::println("import-std-ok");
35+
return 0;
36+
#else
37+
std::println("defines missing");
38+
return 1;
39+
#endif
40+
}
41+
EOF
42+
43+
out=$("$MCPP" build 2>&1) || { echo "FAIL: build with import std: $out"; exit 1; }
44+
run_out=$("$MCPP" run 2>&1) || { echo "FAIL: run: $run_out"; exit 1; }
45+
[[ "$run_out" == *"import-std-ok"* ]] \
46+
|| { echo "FAIL: run output: $run_out"; exit 1; }
47+
48+
# 2) `import std;` together with `import mcpp;` — both module channels active
49+
# at once. These share the staged-BMI cwd, and an implementation that
50+
# handles them as two independent conditions gets the cwd wrong for one.
51+
cat > build.mcpp <<'EOF'
52+
import std;
53+
import mcpp;
54+
int main() {
55+
std::string tag = std::format("MCPP_BOTH_{}", 1 + 1);
56+
mcpp::define(tag.c_str());
57+
mcpp::rerun_if_changed("build.mcpp");
58+
return 0;
59+
}
60+
EOF
61+
62+
cat > src/main.cpp <<'EOF'
63+
import std;
64+
int main() {
65+
#ifdef MCPP_BOTH_2
66+
std::println("both-modules-ok");
67+
return 0;
68+
#else
69+
std::println("define missing");
70+
return 1;
71+
#endif
72+
}
73+
EOF
74+
75+
out=$("$MCPP" build 2>&1) || { echo "FAIL: build with import std + mcpp: $out"; exit 1; }
76+
run_out=$("$MCPP" run 2>&1) || { echo "FAIL: run (both): $run_out"; exit 1; }
77+
[[ "$run_out" == *"both-modules-ok"* ]] \
78+
|| { echo "FAIL: run output (both): $run_out"; exit 1; }
79+
80+
# 3) An `#include`-only build.mcpp must still take the plain path — no std BMI
81+
# staged, no -fmodules, cwd = project root. Regression guard: the naive
82+
# detector ("does the text contain 'import std'") would fire on a comment.
83+
cat > build.mcpp <<'EOF'
84+
#include <cstdio>
85+
// This program deliberately mentions import std; in a comment.
86+
int main() {
87+
std::puts("mcpp:cfg=MCPP_PLAIN_PATH");
88+
std::puts("mcpp:rerun-if-changed=build.mcpp");
89+
return 0;
90+
}
91+
EOF
92+
93+
cat > src/main.cpp <<'EOF'
94+
import std;
95+
int main() {
96+
#ifdef MCPP_PLAIN_PATH
97+
std::println("plain-path-ok");
98+
return 0;
99+
#else
100+
return 1;
101+
#endif
102+
}
103+
EOF
104+
105+
out=$("$MCPP" build 2>&1) || { echo "FAIL: build plain build.mcpp: $out"; exit 1; }
106+
run_out=$("$MCPP" run 2>&1) || { echo "FAIL: run (plain): $run_out"; exit 1; }
107+
[[ "$run_out" == *"plain-path-ok"* ]] \
108+
|| { echo "FAIL: run output (plain): $run_out"; exit 1; }
109+
110+
echo "PASS: build.mcpp import std (alone, with import mcpp, and the plain path)"

0 commit comments

Comments
 (0)