diff --git a/README.md b/README.md index 590251b..921a814 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ TestRunner::Cmd("wine") // local exec via prefix co For elaborate setups (rsync, ssh ControlMaster, env vars on the spawn, `QEMU_LD_PREFIX` for sysroots), construct `TestRunner` directly — `copy`/`exec`/`cleanup`/`probe` are public string fields with `{bundle}`, `{remote_bundle}`, `{bin}`, `{bin_name}`, `{args}` placeholders. -For cross-arch builds, set `cfg.sysroot` on the `Configuration` to point at a sysroot for the target. `BuildStdPcm` and the compile/link commands both consume it: `--sysroot=` is passed to clang, and the std module is precompiled against the sysroot's libc++ headers. The module source has to come from the same libc++ release as those headers, so it is taken from `/usr/share/libc++/v1/std.cppm` when the sysroot ships it, else from the toolchain when both are the same major release, else from the sources crafter-build carries for other releases (`share/crafter-build/libc++-modules//`, `crafter-build --version` lists them). Alpine is the common case for the last two: its `libc++-dev` has the headers and no module source. When nothing matches, the build stops with one line naming both releases, installing that release's `share/libc++/v1` directory into the sysroot resolves it. The cheapest aarch64 sysroot is an extracted [Arch Linux ARM rootfs](http://os.archlinuxarm.org/) plus `libc++` + `libc++abi` + `gcc` installed into it via `pacman --root --config --dbpath /var/lib/pacman -Sy …` (point pacman at ALARM's mirrors via explicit config to avoid pulling host-arch packages). +For cross-arch builds, set `cfg.sysroot` on the `Configuration` to point at a sysroot for the target. `BuildStdPcm` and the compile/link commands both consume it: `--sysroot=` is passed to clang, and the std module is precompiled against the sysroot's libc++ headers. Ordinary compiles are pointed at the same headers (`-nostdinc++ -isystem /usr/include/c++/v1`), so a unit that `#include`s a standard header gets the sysroot's libc++ and not the host toolchain's. The module source has to come from the same libc++ release as those headers, so it is taken from `/usr/share/libc++/v1/std.cppm` when the sysroot ships it, else from the toolchain when both are the same major release, else from the sources crafter-build carries for other releases (`share/crafter-build/libc++-modules//`, `crafter-build --version` lists them). Alpine is the common case for the last two: its `libc++-dev` has the headers and no module source. When nothing matches, the build stops with one line naming both releases, installing that release's `share/libc++/v1` directory into the sysroot resolves it. The cheapest aarch64 sysroot is an extracted [Arch Linux ARM rootfs](http://os.archlinuxarm.org/) plus `libc++` + `libc++abi` + `gcc` installed into it via `pacman --root --config --dbpath /var/lib/pacman -Sy …` (point pacman at ALARM's mirrors via explicit config to avoid pulling host-arch packages). For `--target=x86_64-w64-mingw32` (Windows from Linux), the pipeline cross-compiles as long as `mingw-w64` is installed. Pair it with `--runner=cmd:wine` to run the result. Only the MinGW ABI is supported, there is no MSVC target and no Windows host. `-fuse-ld=lld` is auto-injected on every link, and `-lstdc++exp` is auto-linked for the mingw target so user projects don't need to declare those flags. diff --git a/implementations/Crafter.Build-Clang.cpp b/implementations/Crafter.Build-Clang.cpp index d2aee22..d10b158 100644 --- a/implementations/Crafter.Build-Clang.cpp +++ b/implementations/Crafter.Build-Clang.cpp @@ -296,6 +296,21 @@ CompileCommand Crafter::GetCompileCommand(const Configuration& config) { if (!config.sysroot.empty()) { out.command += std::format(" --sysroot={}", config.sysroot); + // Same reason as in BuildStdPcm: the driver ranks the host + // toolchain's own libc++ headers (/../include/c++/v1) above + // --sysroot. A unit that only does `import std;` never notices, but + // one that includes a standard header textually (vendored C++ such as + // Crafter.Asset's gdeflate) would get host libc++ configured for the + // host libc, which fails on a musl sysroot with "unknown rune table + // for this platform". Force the sysroot's copy. -nostdinc++ leaves + // -stdlib=libc++ unused on every compile ("argument unused during + // compilation"), hence the silencer; the price is that it also hides + // that diagnostic for a user's own misplaced flag (a -l or -Wl, + // option in compileFlags) on sysroot builds. Not for mingw, which + // uses libstdc++ and has no libc++ in its sysroot. + if (!isWasm && config.target != "x86_64-w64-mingw32") { + out.command += std::format(" -nostdinc++ -isystem {}/usr/include/c++/v1 -Wno-unused-command-line-argument", config.sysroot); + } } if (isWasm) { // -mllvm is consumed by codegen but not the link driver, which is the @@ -502,10 +517,28 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map buildCancelled{false}; + // Two entries with the same output name would compile in parallel into + // one file: whichever finished last won, the build reported success, and + // Check() then called both up to date. The usual way in is one source + // registered twice with different defines and no variant; a.vert next to + // a.frag is another. + { + std::unordered_map outputs; + for (const Shader& shader : config.shaders) { + const auto [existing, inserted] = outputs.emplace(shader.OutputName().string(), &shader); + if (inserted) continue; + buildError = std::format("shaders {} and {} both compile to {}: give one of them a distinct Shader::variant", existing->second->path.string(), shader.path.string(), existing->first); + buildCancelled.store(true); + break; + } + } for (const Shader& shader : config.shaders) { + if (buildCancelled.load(std::memory_order_relaxed)) break; if (shader.Check(outputDir)) continue; threads.emplace_back([&shader, &outputDir, &shaderIncludeDirs, &buildError, &buildCancelled]() { - Progress::Task task(std::format("Compiling shader {}", shader.path.filename().string())); + // A source registered more than once shows up once per variant; + // name the variant so the lines can be told apart. + Progress::Task task(shader.variant.empty() ? std::format("Compiling shader {}", shader.path.filename().string()) : std::format("Compiling shader {} ({})", shader.path.filename().string(), shader.variant)); if (buildCancelled.load(std::memory_order_relaxed)) return; std::string result = shader.Compile(outputDir, shaderIncludeDirs); @@ -878,7 +911,7 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_mapBinDir(); for (const Shader& shader : dep->shaders) { - fs::path src = depBinDir / shader.path.filename().replace_extension("spv"); + fs::path src = depBinDir / shader.OutputName(); if (!fs::exists(src)) continue; copyTree(src, outputDir / src.filename()); } diff --git a/implementations/Crafter.Build-Platform.cpp b/implementations/Crafter.Build-Platform.cpp index f8a7694..03cdba2 100644 --- a/implementations/Crafter.Build-Platform.cpp +++ b/implementations/Crafter.Build-Platform.cpp @@ -845,7 +845,18 @@ Configuration Crafter::LoadProject(const fs::path& projectFile, std::span includeDirs) const { @@ -52,6 +60,9 @@ namespace Crafter { auto fail = [&](std::string_view stage, std::string log) { glslang::FinalizeProcess(); std::string out = path.string(); + // Several variants of one source compile in parallel; without the + // variant the message would not say which of them failed. + if (!variant.empty()) out += std::format(" ({})", variant); out += ": "; out += stage; if (!log.empty()) { @@ -75,8 +86,34 @@ namespace Crafter { const char* shaderSource = src.data(); const std::int32_t shaderSourceLen = static_cast(src.size()); + // A variant is one component of a file name. Caught here, because a + // separator would otherwise surface as "failed to open SPIR-V output" + // after the whole compile. + if (variant.find_first_of("/\\") != std::string::npos) { + return fail(std::format("shader variant '{}' must not contain a path separator", variant), {}); + } + + // One #define line per entry, split like the compiler's -D: "NAME" + // defines it to 1, "NAME=VALUE" to VALUE. glslang places the preamble + // ahead of the whole source (it finds #version by scanning first), so + // line numbers in the user's file are unaffected. It keeps the + // pointer it is handed instead of copying, so the string is declared + // here to outlive parse() below. + std::string preamble; + for (const std::string& define : defines) { + const std::size_t eq = define.find('='); + const std::string name = define.substr(0, eq); + // An error inside the preamble is reported as "-1:" with no + // file and no macro name, so reject the malformed ones by name. + if (name.empty() || define.find_first_of("\r\n") != std::string::npos) { + return fail(std::format("invalid shader define '{}': expected NAME or NAME=VALUE on one line", define), {}); + } + preamble += std::format("#define {} {}\n", name, eq == std::string::npos ? std::string("1") : define.substr(eq + 1)); + } + glslang::TShader shader(glslangType); shader.setStringsWithLengthsAndNames(&shaderSource, &shaderSourceLen, fileNameList, 1); + if (!preamble.empty()) shader.setPreamble(preamble.c_str()); shader.setEntryPoint(entrypoint.c_str()); shader.setSourceEntryPoint(entrypoint.c_str()); shader.setEnvTarget(glslang::EShTargetSpv, glslang::EShTargetSpv_1_4); @@ -110,7 +147,7 @@ namespace Crafter { glslang::GlslangToSpv(*intermediate, spirv, &logger); std::string spvLog = logger.getAllMessages(); - fs::path filename = path.filename().replace_extension("spv"); + fs::path filename = OutputName(); std::ofstream file(outputDir/filename, std::ios::binary); if (!file) { return fail("failed to open SPIR-V output", (outputDir/filename).string()); diff --git a/interfaces/Crafter.Build-Shader.cppm b/interfaces/Crafter.Build-Shader.cppm index 4b4d841..b059cfb 100644 --- a/interfaces/Crafter.Build-Shader.cppm +++ b/interfaces/Crafter.Build-Shader.cppm @@ -28,7 +28,25 @@ namespace Crafter { fs::path path; std::string entrypoint; ShaderType type; + // Preprocessor macros for this compile, spelled like the compiler's -D: + // "NAME" (defined to 1) or "NAME=VALUE". They are placed ahead of the + // source, so one file can be registered more than once and take a + // different #ifdef branch each time. Note the spelling differs from + // Configuration::defines, which is a list of {name, value} pairs. + std::vector defines; + // Names the output when one source is compiled more than once: empty + // gives ".spv", "sets" gives ".sets.spv". One file-name + // component: no path separators. Every entry of a configuration must + // end up with its own OutputName(); Build() fails if two collide, so + // each extra compile of a source needs its own variant. Check() + // compares only the source's mtime: changing defines without touching + // the source leaves the old output in place. + std::string variant; Shader(fs::path&& path, std::string&& entrypoint, ShaderType type); + // File name of the compiled SPIR-V, relative to the output directory. + // The single source of truth for it: Check, Compile and the artifact + // forwarding in Build() all go through here. + fs::path OutputName() const; bool Check(const fs::path& outputDir) const; std::string Compile(const fs::path& outputDir, std::span includeDirs) const; }; diff --git a/project.cpp b/project.cpp index a7c3272..43584c8 100644 --- a/project.cpp +++ b/project.cpp @@ -125,11 +125,13 @@ extern "C" Configuration CrafterBuildProject(std::span a cfg.AddTest("CompilerWarning").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("CleanProject").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("ShaderCompile").Dependencies({ CrafterBuildLib.get() }); + cfg.AddTest("ShaderVariantBuild").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("StandardArgs").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("TestRunnerSpec").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("VariantId").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("ToolchainResolve").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("SysrootStdModule").Dependencies({ CrafterBuildLib.get() }); + cfg.AddTest("SysrootCompileFlags").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("VersionFlag").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("WasiBrowserRuntime").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("WasmVariants").Dependencies({ CrafterBuildLib.get() }); diff --git a/tests/HostCacheSourceStamp/main.cpp b/tests/HostCacheSourceStamp/main.cpp index b6904e8..4110c82 100644 --- a/tests/HostCacheSourceStamp/main.cpp +++ b/tests/HostCacheSourceStamp/main.cpp @@ -119,6 +119,17 @@ int main() { Check(fs::last_write_time(probePcm) == afterCold, "unchanged sources do not rebuild the cache"); Check(ReadStamp(moduleCacheDir) == firstStamp, "unchanged sources keep the same stamp"); + // The compiled project file is the other half of the same hazard: it is + // loaded into this process and must have been compiled against the + // declarations this process was built from. + fs::path projectSo; + for (const fs::directory_entry& entry : fs::recursive_directory_iterator(projectDir)) { + if (entry.path().filename() == "project.so") projectSo = entry.path(); + } + Check(!projectSo.empty(), "the project file was compiled to a project.so"); + if (projectSo.empty()) return 1; + const fs::file_time_type soBeforeEdit = fs::last_write_time(projectSo); + // Now the case the mtime rule cannot see. The edit lands in a module the // probe does not name, because the cached PCMs import each other: a change // anywhere in the set invalidates all of them. @@ -139,6 +150,11 @@ int main() { Check(fs::last_write_time(probePcm) > afterCold, "a content change rebuilds the cache even when the source's mtime is older"); Check(ReadStamp(moduleCacheDir) != firstStamp, "the recorded stamp follows the sources"); + // project.cpp itself is untouched, so only the stamp can force this. It + // once covered the version string alone (the rest sat behind a NUL in a + // format string): a project.so built against a class of another size was + // reused and corrupted the heap when it filled one in. + Check(fs::last_write_time(projectSo) > soBeforeEdit, "changed declarations recompile project.so even though project.cpp is unchanged"); fs::remove_all(scratch, ec); diff --git a/tests/ShaderCompile/fixture/variant.frag b/tests/ShaderCompile/fixture/variant.frag new file mode 100644 index 0000000..7d0178c --- /dev/null +++ b/tests/ShaderCompile/fixture/variant.frag @@ -0,0 +1,15 @@ +#version 450 + +// Compiled twice by the ShaderCompile test: once as is, once with VARIANT_RED +// and VARIANT_ALPHA=0.5 defined. The red branch does not compile unless the +// NAME=VALUE define arrives, and the two outputs only differ if the bare one +// does. +layout(location = 0) out vec4 outColor; + +void main() { +#ifdef VARIANT_RED + outColor = vec4(1.0, 0.0, 0.0, VARIANT_ALPHA); +#else + outColor = vec4(0.0, 1.0, 0.0, 1.0); +#endif +} diff --git a/tests/ShaderCompile/main.cpp b/tests/ShaderCompile/main.cpp index 20221d2..1ead9ca 100644 --- a/tests/ShaderCompile/main.cpp +++ b/tests/ShaderCompile/main.cpp @@ -8,7 +8,10 @@ using namespace Crafter; // Compile a tiny GLSL fragment shader directly via the Shader API. Verifies // the SPIR-V output lands in the target dir and starts with the SPIR-V magic -// number, without dragging in the full Build() pipeline. +// number, without dragging in the full Build() pipeline. The second half +// registers one source twice, with and without defines, and checks that the +// variant gets its own output, its own staleness and the other #ifdef branch +// (tests/ShaderVariantBuild drives the same thing through Build()). int main() { fs::path src = fs::current_path() / "tests" / "ShaderCompile" / "fixture" / "triangle.frag"; if (!fs::exists(src)) { @@ -52,5 +55,75 @@ int main() { std::println(std::cerr, "Shader::Check returned false on an up-to-date spv"); return 1; } + + // One source registered twice: the second entry carries defines and a + // variant name. It must land in its own file instead of overwriting the + // first, and it must take the other #ifdef branch. Crafter.Graphics ships + // each UI shader in a descriptor-heap and a descriptor-set flavour this + // way; if the variant were dropped from the output name, whichever compile + // finished last would silently win. + fs::path variantSrc = fs::current_path() / "tests" / "ShaderCompile" / "fixture" / "variant.frag"; + Shader plain(fs::path(variantSrc), "main", ShaderType::Fragment); + Shader red(fs::path(variantSrc), "main", ShaderType::Fragment); + red.defines = {"VARIANT_RED", "VARIANT_ALPHA=0.5"}; + red.variant = "red"; + + if (plain.OutputName() != "variant.spv" || red.OutputName() != "variant.red.spv") { + std::println(std::cerr, "unexpected output names: {} and {}", plain.OutputName().string(), red.OutputName().string()); + return 1; + } + for (const Shader* each : {&plain, &red}) { + // Staleness is per output, not per source: while only variant.spv + // exists, the variant is still due. A Check() that ignored the + // variant would call it up to date here, and Build() would then + // never compile a variant added to an already built project. + if (each == &red && red.Check(outDir)) { + std::println(std::cerr, "Shader::Check called the variant up to date before it was ever compiled"); + return 1; + } + std::string variantErr = each->Compile(outDir, includeDirs); + if (!variantErr.empty()) { + std::println(std::cerr, "variant shader compile failed: {}", variantErr); + return 1; + } + if (!each->Check(outDir)) { + std::println(std::cerr, "Shader::Check returned false for {}", each->OutputName().string()); + return 1; + } + } + + // Malformed defines and variants are refused by name. Left to glslang, an + // empty macro name surfaces as "-1:" with no file, and a variant + // with a separator as a failed open after the whole compile. + for (const char* bad : {"", "=1", "A\nB"}) { + Shader malformed(fs::path(variantSrc), "main", ShaderType::Fragment); + malformed.defines = {bad}; + malformed.variant = "malformed"; + if (!malformed.Compile(outDir, includeDirs).contains("invalid shader define")) { + std::println(std::cerr, "a malformed define was not rejected by name"); + return 1; + } + } + Shader nested(fs::path(variantSrc), "main", ShaderType::Fragment); + nested.variant = "sub/red"; + if (!nested.Compile(outDir, includeDirs).contains("path separator")) { + std::println(std::cerr, "a variant with a path separator was not rejected by name"); + return 1; + } + + auto readAll = [](const fs::path& file) { + std::ifstream stream(file, std::ios::binary); + return std::string(std::istreambuf_iterator(stream), std::istreambuf_iterator()); + }; + std::string plainSpv = readAll(outDir / "variant.spv"); + std::string redSpv = readAll(outDir / "variant.red.spv"); + if (plainSpv.empty() || redSpv.empty()) { + std::println(std::cerr, "a variant output is missing or empty"); + return 1; + } + if (plainSpv == redSpv) { + std::println(std::cerr, "both variants compiled to the same SPIR-V: the defines did not reach the preprocessor"); + return 1; + } return 0; } diff --git a/tests/ShaderVariantBuild/fixture/main.cpp b/tests/ShaderVariantBuild/fixture/main.cpp new file mode 100644 index 0000000..97eb9af --- /dev/null +++ b/tests/ShaderVariantBuild/fixture/main.cpp @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +int main() { + return 0; +} diff --git a/tests/ShaderVariantBuild/fixture/tint.frag b/tests/ShaderVariantBuild/fixture/tint.frag new file mode 100644 index 0000000..c227813 --- /dev/null +++ b/tests/ShaderVariantBuild/fixture/tint.frag @@ -0,0 +1,12 @@ +#version 450 + +// Built twice by the ShaderVariantBuild test: as is, and with TINT_RED defined. +layout(location = 0) out vec4 outColor; + +void main() { +#if TINT_RED + outColor = vec4(1.0, 0.0, 0.0, 1.0); +#else + outColor = vec4(0.0, 1.0, 0.0, 1.0); +#endif +} diff --git a/tests/ShaderVariantBuild/main.cpp b/tests/ShaderVariantBuild/main.cpp new file mode 100644 index 0000000..517b99c --- /dev/null +++ b/tests/ShaderVariantBuild/main.cpp @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +// One shader source can be registered more than once, each entry with its own +// defines and a variant that names its output ("tint.spv", "tint.red.spv"). +// Build() compiles the entries in parallel into one directory, which is where +// the feature can go wrong in ways the Shader API alone does not show: +// +// - both entries must land in the bin dir, with different contents; +// - an entry added later must still be compiled when the other one's output +// is already there and up to date; +// - two entries that would write the same file (defines set, variant +// forgotten) used to race silently: the file held whichever compile +// finished last, the build reported success, and Check() called both up +// to date from then on. That has to be a build error naming the cause. +// +// The fixture's `#if TINT_RED` also pins that a bare define is defined to 1, +// like the compiler's -D: an empty definition would not compile. +namespace { + std::int32_t Failures = 0; + + void Check(bool cond, std::string_view msg) { + if (!cond) { + std::println(std::cerr, "FAIL: {}", msg); + ++Failures; + } + } + + // Shader outputs are compared by mtime against the source, and the test + // adds an entry between passes, so work on a fresh copy outside the repo. + fs::path StageFixture() { + fs::path source = fs::current_path() / "tests" / "ShaderVariantBuild" / "fixture"; + fs::path staged = fs::temp_directory_path() / "crafter-build-shader-variant-build"; + fs::remove_all(staged); + fs::copy(source, staged, fs::copy_options::recursive); + return staged; + } + + Configuration MakeApp(const fs::path& staged) { + Configuration app; + app.path = staged; + app.name = "tinted"; + app.outputName = "tinted"; + app.target = HostTarget(); + app.type = ConfigurationType::Executable; + std::array ifaces = {}; + std::array impls = { "main" }; + app.GetInterfacesAndImplementations(ifaces, impls); + return app; + } + + Shader& AddTint(Configuration& app, const fs::path& staged) { + return app.shaders.emplace_back(staged / "tint.frag", std::string("main"), ShaderType::Fragment); + } + + BuildResult BuildOnce(Configuration& app) { + // A fresh depResults per pass: the map memoizes a Configuration's + // build for the duration of one pass. + std::unordered_map> depResults; + std::mutex depMutex; + return Build(app, depResults, depMutex); + } + + std::string ReadAll(const fs::path& file) { + std::ifstream stream(file, std::ios::binary); + return std::string(std::istreambuf_iterator(stream), std::istreambuf_iterator()); + } +} + +int main() { + const fs::path staged = StageFixture(); + + // Pass 1: the plain entry alone. + Configuration app = MakeApp(staged); + AddTint(app, staged); + BuildResult first = BuildOnce(app); + Check(first.result.empty(), std::format("the plain entry builds: {}", first.result)); + const fs::path plainSpv = app.BinDir() / "tint.spv"; + const fs::path redSpv = app.BinDir() / "tint.red.spv"; + Check(fs::exists(plainSpv), "tint.spv is in the bin dir"); + Check(!fs::exists(redSpv), "no variant output before the variant is registered"); + + // Pass 2: the variant is added while tint.spv is already up to date. It + // has its own staleness check and must be compiled, into its own file. + Shader& red = AddTint(app, staged); + red.defines = {"TINT_RED"}; + red.variant = "red"; + BuildResult second = BuildOnce(app); + Check(second.result.empty(), std::format("plain + variant builds: {}", second.result)); + Check(fs::exists(redSpv), "tint.red.spv is in the bin dir"); + const std::string plainBytes = ReadAll(plainSpv); + const std::string redBytes = ReadAll(redSpv); + Check(!plainBytes.empty() && !redBytes.empty(), "both outputs have content"); + Check(plainBytes != redBytes, "the variant took the other #if branch"); + + // Pass 3: the same define without a variant. Both entries would write + // tint.spv, so the build has to stop and say so, and must leave the file + // from pass 2 alone. + Configuration clash = MakeApp(staged); + AddTint(clash, staged); + Shader& unnamed = AddTint(clash, staged); + unnamed.defines = {"TINT_RED"}; + BuildResult third = BuildOnce(clash); + Check(!third.result.empty(), "two entries with one output name fail the build"); + Check(third.result.contains("tint.spv") && third.result.contains("variant"), std::format("the error names the file and the fix: {}", third.result)); + Check(ReadAll(plainSpv) == plainBytes, "the existing output is not overwritten by the failed build"); + + std::error_code ec; + fs::remove_all(staged, ec); + + if (Failures > 0) { + std::println(std::cerr, "{} assertions failed", Failures); + return 1; + } + return 0; +} diff --git a/tests/SysrootCompileFlags/main.cpp b/tests/SysrootCompileFlags/main.cpp new file mode 100644 index 0000000..bfaf95b --- /dev/null +++ b/tests/SysrootCompileFlags/main.cpp @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +// clang's driver ranks the host toolchain's own libc++ headers +// (/../include/c++/v1) above --sysroot. A unit that only does +// `import std;` never notices, because the std module is precompiled with the +// sysroot's headers forced. A unit that includes a standard header textually +// (vendored C++ in a dependency, say) got the host's libc++ configured for the +// host's libc: against a musl sysroot every such unit died with "unknown rune +// table for this platform", and against a glibc sysroot it compiled against +// the wrong release without a word. +// +// GetCompileCommand therefore has to point every sysroot compile at the +// sysroot's copy. It must not do so where there is no sysroot, for wasm (which +// has its own sysroot handling) or for mingw (libstdc++, no libc++ at all). +// The command is checked as text: no compiler runs. +namespace { + std::int32_t Failures = 0; + + void Check(bool cond, std::string_view msg) { + if (!cond) { + std::println(std::cerr, "FAIL: {}", msg); + ++Failures; + } + } + + std::string CommandFor(std::string_view target, std::string_view sysroot) { + Configuration cfg; + cfg.path = "/tmp/crafter-build-sysroot-compile-flags"; + cfg.name = "flags"; + cfg.outputName = "flags"; + cfg.target = std::string(target); + cfg.march = "generic"; + cfg.mtune = "generic"; + cfg.type = ConfigurationType::Executable; + cfg.sysroot = std::string(sysroot); + return GetCompileCommand(cfg).command; + } +} + +int main() { + const std::string sysroot = "/opt/crafter-build-test-sysroot"; + const std::string forced = std::format("-nostdinc++ -isystem {}/usr/include/c++/v1", sysroot); + + for (std::string_view target : {"aarch64-alpine-linux-musl", "aarch64-unknown-linux-gnu", "x86_64-pc-linux-gnu"}) { + const std::string command = CommandFor(target, sysroot); + Check(command.contains(std::format("--sysroot={}", sysroot)), std::format("{}: the sysroot is passed", target)); + Check(command.contains(forced), std::format("{}: the sysroot's libc++ headers are forced", target)); + } + + Check(!CommandFor("x86_64-pc-linux-gnu", "").contains("/usr/include/c++/v1 -Wno-unused-command-line-argument"), "no sysroot: nothing is forced"); + Check(!CommandFor("wasm32-wasip1", sysroot).contains(forced), "wasm: left to its own sysroot handling"); + Check(!CommandFor("x86_64-w64-mingw32", sysroot).contains(forced), "mingw: libstdc++ target, libc++ headers are not forced"); + + if (Failures > 0) { + std::println(std::cerr, "{} assertions failed", Failures); + return 1; + } + return 0; +}