feat(toolchain): resolve the compiler in one place

Every clang++, clang and llvm-ar the tool spawned was a bare name on
PATH, the std module source was /usr/share/libc++/v1/std.cppm, the WASI
sysroot /usr/share/wasi-sysroot and the mingw headers
/usr/x86_64-w64-mingw32, spelled out at eighteen call sites. A
toolchain installed anywhere else, which is what the Flatpak ships
under /app, had no way in.

Crafter.Build:Toolchain resolves all of it once per process:
CRAFTER_BUILD_LLVM_DIR, else a clang++ installed beside crafter-build
(<prefix>/bin, found through the same walk GetCrafterBuildHome does),
else PATH. What sits beside the compiler is used when present: its
libclang.so, its clang-c headers, its std module source, its WASI
sysroot. An explicit prefix without a clang++ is an error rather than a
fallback, for the same reason CRAFTER_BUILD_LIBCLANG was already
exclusive. CRAFTER_BUILD_LIBCXX_DIR points at a libc++ install other
than the compiler's own, and CRAFTER_MINGW_DIR now does what its help
line always claimed: it is passed as --sysroot for the mingw target.
Binaries linked by a toolchain outside /usr get an rpath to its lib
dir, since its libc++.so.1 is not on the loader's default path.

HostTarget() is the resolved clang's -print-target-triple. A clang built
without a vendor reports x86_64-unknown-linux-gnu where Arch's reports
x86_64-pc-linux-gnu, so target equality that decides whether a test runs
locally and which targets a bare `test` sweeps goes through SameTarget,
which ignores the vendor field. The strings themselves are never
rewritten: clang rejects a PCM whose triple differs from the importing
unit's. The examples and README stop hardcoding the host triple; the
default is already the host.

`crafter-build --version` prints the version baked in at build time
(CRAFTER_BUILD_VERSION, else `git describe`, else 0.0.0-dev), the
compiler and the host triple, and is answered before any argument is
forwarded to project.cpp. The version is a define on the library, so
each version is its own library variant and relinks the executable and
tests once per commit.

build.sh gains a shebang and `set -eu`, honours CRAFTER_BUILD_LLVM_DIR
and CRAFTER_BUILD_VERSION, skips the glslang clone when it is already
there, and writes a fresh archive instead of appending to one that may
hold members from an older layout.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-10-02 22:26:31 +02:00
commit 046609ff6f
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
18 changed files with 767 additions and 190 deletions

View file

@ -9,6 +9,7 @@ export module Crafter.Build:Clang_impl;
import std;
import :Clang;
import :Platform;
import :Toolchain;
import :Test;
import :Lint;
import :Progress;
@ -404,6 +405,11 @@ CompileCommand Crafter::GetCompileCommand(const Configuration& config) {
}
BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, std::shared_future<BuildResult>>& depResults, std::mutex& depMutex) {
// Before anything is spawned: one message naming the missing compiler
// beats a clang++-not-found per translation unit.
if (const Toolchain& tc = GetToolchain(); !tc.error.empty()) {
return {tc.error, false, {}};
}
// Reset per-build cached state on every Module/ModulePartition so that
// successive Build() calls on the same Configuration re-evaluate mtimes
// (incremental-rebuild test scenarios).
@ -449,7 +455,7 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
// triples (wasi, wasip1, wasip2, ...); the sysroot's per-triple subdirs
// handle the differences.
if (config.sysroot.empty() && config.target.starts_with("wasm32")) {
config.sysroot = "/usr/share/wasi-sysroot";
config.sysroot = GetToolchain().wasiSysroot.string();
}
fs::path buildDir = config.BuildDir();
@ -780,7 +786,7 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
Progress::Task task(std::format("Compiling {}.c", cFile.filename().string()));
if (buildCancelled.load(std::memory_order_relaxed)) return;
std::string result = RunBuildCommand(std::format("clang {0}.c --target={1}{2} -O3{3} -c{4}{5}{6} -MD -MF {7}_source.o.d -o {7}_source.o", cFile.string(), config.target, cArchFlags, ltoCompileFlags, includeFlags, defineFlags, userFlags, (buildDir / cFile.filename()).string()));
std::string result = RunBuildCommand(std::format("{8} {0}.c --target={1}{2} -O3{3} -c{4}{5}{6} -MD -MF {7}_source.o.d -o {7}_source.o", cFile.string(), config.target, cArchFlags, ltoCompileFlags, includeFlags, defineFlags, userFlags, (buildDir / cFile.filename()).string(), GetToolchain().clang.string()));
if (result.empty()) return;
bool expected = false;
@ -1005,6 +1011,13 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
// link below ends with linkExtras, so this reaches them all; the static-lib
// archive path uses ar instead and is handled separately.
linkExtras += ltoLinkFlags;
// Host links use the toolchain's own libc++. When the toolchain lives
// outside /usr (a bundled one) the binary needs an rpath to find
// libc++.so.1 at run time. mingw links libstdc++ and every sysroot build
// (wasm included) brings its own libc++.
if (config.target != "x86_64-w64-mingw32" && config.sysroot.empty()) {
linkExtras += GetToolchain().LibcxxLinkFlags();
}
// mingw uses libstdc++; C++26 std::print/format extras live in libstdc++exp.
// libstdc++ on mingw uses winpthreads for std::atomic_wait /
// counting_semaphore / stop_token, so -lpthread is required as soon as
@ -1142,7 +1155,7 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
// pull the needed members. llvm-ar writes a bitcode-aware index
// and handles plain ELF and COFF objects just as well, so every
// build uses it.
buildResult.result = RunBuildCommand(std::format("llvm-ar rcs {}.a {}", (outputDir/fs::path(std::string("lib")+config.outputName)).string(), files));
buildResult.result = RunBuildCommand(std::format("{} rcs {}.a {}", GetToolchain().llvmAr.string(), (outputDir/fs::path(std::string("lib")+config.outputName)).string(), files));
} else {
// LibraryDynamic. Output names follow each target's convention so
// consumers can link via the standard linker search:
@ -1420,14 +1433,32 @@ void Crafter::EnableWasiRelaxedSimdVariant(Configuration& cfg) {
}
std::string Crafter::HostTarget() {
static const std::string Cached = []() -> std::string {
CommandResult r = RunCommandChecked("clang++ -print-target-triple");
if (r.exitCode != 0) return {};
std::string out = std::move(r.output);
while (!out.empty() && (out.back() == '\n' || out.back() == '\r')) out.pop_back();
return out;
}();
return Cached;
return GetToolchain().hostTriple;
}
std::string Crafter::NormalizeTarget(std::string_view triple) {
// <arch>-<vendor>-<os>-<env>: drop the vendor. A three-part triple only
// carries one when its middle part is a vendor name rather than an OS
// (x86_64-unknown-wasip1 yes, x86_64-linux-gnu no).
std::vector<std::string_view> parts;
for (std::size_t begin = 0;;) {
std::size_t dash = triple.find('-', begin);
parts.push_back(dash == std::string_view::npos ? triple.substr(begin) : triple.substr(begin, dash - begin));
if (dash == std::string_view::npos) break;
begin = dash + 1;
}
const bool hasVendor = parts.size() == 4 || (parts.size() == 3 && (parts[1] == "unknown" || parts[1] == "pc"));
if (hasVendor) parts.erase(parts.begin() + 1);
std::string out;
for (std::string_view part : parts) {
if (!out.empty()) out += '-';
out += part;
}
return out;
}
bool Crafter::SameTarget(std::string_view a, std::string_view b) {
return a == b || NormalizeTarget(a) == NormalizeTarget(b);
}
bool Crafter::ArgQuery::Has(std::string_view flag) const {
@ -1475,7 +1506,7 @@ ArgQuery Crafter::ApplyStandardArgs(Configuration& cfg, std::span<const std::str
// ApplyStandardArgs, but doing it here makes dep PcmDirs consistent
// between the consumer's command-construction and the dep's own build.)
if (cfg.sysroot.empty() && cfg.target.starts_with("wasm32")) {
cfg.sysroot = "/usr/share/wasi-sysroot";
cfg.sysroot = GetToolchain().wasiSysroot.string();
}
return ArgQuery{args};
}
@ -1495,6 +1526,17 @@ std::vector<fs::path> Crafter::CleanProject(const fs::path& projectFile) {
return removed;
}
static void PrintVersion() {
std::println("crafter-build {}", CrafterBuildVersion());
const Toolchain& tc = GetToolchain();
if (!tc.error.empty()) {
std::println("compiler: {}", tc.error);
return;
}
std::println("compiler {} ({})", tc.clangVersion, tc.clangxx.string());
std::println("host {}", tc.hostTriple);
}
static void PrintHelp(std::string_view argv0) {
std::println(
R"(Usage:
@ -1504,6 +1546,7 @@ R"(Usage:
{0} format [format-options] [globs...] Apply the project's transform rules (rewrites files)
{0} clean Delete the project's bin/ and build/ trees
{0} help | -h | --help Show this help
{0} --version Print the version and the compiler in use
Loads ./project.cpp (override with --project=<path>), compiles it to a shared
object, and invokes its CrafterBuildProject() to obtain a Configuration that
@ -1571,7 +1614,14 @@ Environment:
CRAFTER_BUILD_RUNNER_<TARGET> Default test runner for a target triple.
Replace '-' and '.' with '_' in the
triple. CLI --runner= overrides this.
CRAFTER_MINGW_DIR Override mingw-w64 sysroot auto-detect.
CRAFTER_BUILD_LLVM_DIR LLVM prefix to use instead of a toolchain
installed beside crafter-build or PATH.
CRAFTER_BUILD_LIBCXX_DIR libc++ install to compile and link against
instead of the compiler's own.
CRAFTER_BUILD_LIBCLANG Exact libclang to load for lint.
CRAFTER_BUILD_WASI_SYSROOT WASI sysroot for wasm32 targets.
CRAFTER_MINGW_DIR mingw-w64 sysroot, passed as --sysroot.
CRAFTER_BUILD_HOME Directory holding the module sources.
Exit status:
0 success / all non-skipped tests passed / lint clean / format applied
@ -1599,7 +1649,13 @@ std::int32_t Crafter::Run(std::int32_t argc, char** argv) {
for (std::int32_t i = 1; i < argc; ++i) {
std::string_view arg = argv[i];
if (arg == "-h" || arg == "--help" || (!runTests && !runLint && !runFormat && arg == "help")) {
if (arg == "--version") {
// Answered here, before the project file is looked for, so
// it works in any directory and is never forwarded to
// project.cpp as an unknown argument.
PrintVersion();
return 0;
} else if (arg == "-h" || arg == "--help" || (!runTests && !runLint && !runFormat && arg == "help")) {
PrintHelp(argv0);
return 0;
} else if (!runLint && !runFormat && !runClean && arg == "test") {

View file

@ -4,6 +4,7 @@
module Crafter.Build:External_impl;
import std;
import :External;
import :Toolchain;
import :Platform;
import :Progress;
import :Clang;
@ -118,15 +119,16 @@ std::string BuildInjectedCMakeFlags(const fs::path& cmakeBuildDir, std::string_v
// suffix to library names assuming a debug build, breaking -l<name>
// linking. Pin to Release.
out += " -DCMAKE_BUILD_TYPE=Release";
out += " -DCMAKE_C_COMPILER=clang";
out += " -DCMAKE_CXX_COMPILER=clang++";
const Toolchain& tc = GetToolchain();
out += std::format(" -DCMAKE_C_COMPILER={}", ShellQuote(tc.clang.string()));
out += std::format(" -DCMAKE_CXX_COMPILER={}", ShellQuote(tc.clangxx.string()));
// Stdlib choice: libc++ for Linux targets (matches how crafter-build
// itself is built with -stdlib=libc++); the mingw cross-compile uses the
// libstdc++ shipped by the mingw-w64 toolchain, so it keeps the default.
if (target.ends_with("-linux-gnu") || target.ends_with("-linux-musl")) {
out += " -DCMAKE_CXX_FLAGS=-stdlib=libc++";
out += " -DCMAKE_EXE_LINKER_FLAGS=-stdlib=libc++";
out += " -DCMAKE_SHARED_LINKER_FLAGS=-stdlib=libc++";
out += std::format(" -DCMAKE_CXX_FLAGS={}", ShellQuote(std::format("-stdlib=libc++{}", tc.LibcxxCompileFlags())));
out += std::format(" -DCMAKE_EXE_LINKER_FLAGS={}", ShellQuote(std::format("-stdlib=libc++{}", tc.LibcxxLinkFlags())));
out += std::format(" -DCMAKE_SHARED_LINKER_FLAGS={}", ShellQuote(std::format("-stdlib=libc++{}", tc.LibcxxLinkFlags())));
}
if (target == "x86_64-w64-mingw32") {
out += " -DCMAKE_SYSTEM_NAME=Windows";
@ -142,6 +144,10 @@ std::string BuildInjectedCMakeFlags(const fs::path& cmakeBuildDir, std::string_v
std::string ConfigureCMake(const fs::path& cloneDir, const fs::path& cmakeBuildDir, std::span<const std::string> options, std::string_view target) {
fs::path optionsFile = cmakeBuildDir / ".crafter-options";
std::string optionsKey = JoinOptions(options);
// The injected flags are inputs too: a different compiler or libc++
// must reconfigure rather than reuse a CMakeCache made with the old one.
const std::string injected = BuildInjectedCMakeFlags(cmakeBuildDir, target);
const std::string configureKey = injected + '\n' + optionsKey;
bool needsConfigure = !fs::exists(cmakeBuildDir / "CMakeCache.txt");
if (!needsConfigure) {
@ -151,7 +157,7 @@ std::string ConfigureCMake(const fs::path& cloneDir, const fs::path& cmakeBuildD
} else {
std::stringstream buf;
buf << existing.rdbuf();
if (buf.str() != optionsKey) needsConfigure = true;
if (buf.str() != configureKey) needsConfigure = true;
}
}
@ -159,7 +165,7 @@ std::string ConfigureCMake(const fs::path& cloneDir, const fs::path& cmakeBuildD
if (!fs::exists(cmakeBuildDir)) fs::create_directories(cmakeBuildDir);
std::string cmd = std::format("cmake -S {} -B {}{}", ShellQuote(fs::absolute(cloneDir).string()), ShellQuote(fs::absolute(cmakeBuildDir).string()), BuildInjectedCMakeFlags(cmakeBuildDir, target));
std::string cmd = std::format("cmake -S {} -B {}{}", ShellQuote(fs::absolute(cloneDir).string()), ShellQuote(fs::absolute(cmakeBuildDir).string()), injected);
if (!options.empty()) {
cmd += " ";
cmd += optionsKey;
@ -170,7 +176,7 @@ std::string ConfigureCMake(const fs::path& cloneDir, const fs::path& cmakeBuildD
return std::format("cmake configure failed (exit {}): {}", r.exitCode, r.output);
}
std::ofstream(optionsFile) << optionsKey;
std::ofstream(optionsFile) << configureKey;
return "";
}

View file

@ -9,6 +9,7 @@ import std;
import :Lint;
import :Clang;
import :Platform;
import :Toolchain;
import :Progress;
namespace fs = std::filesystem;
using namespace Crafter;
@ -92,14 +93,14 @@ namespace {
LibClang LoadLibClang() {
LibClang lib;
std::vector<std::string> tried;
// CRAFTER_BUILD_LIBCLANG pins an exact path, mirroring the
// CRAFTER_MINGW_DIR override used elsewhere. It is exclusive: pointing
// it at a broken path must fail loudly rather than quietly succeed with
// some other libclang, or the override is useless for diagnosing which
// library is actually in play.
// The toolchain pins libclang when it knows where it is: the
// CRAFTER_BUILD_LIBCLANG override, or the libclang.so beside the
// compiler. A pin is exclusive: pointing the override at a broken
// path must fail loudly rather than quietly succeed with some other
// libclang, or it is useless for diagnosing which library is in play.
std::vector<std::string> candidates;
if (const char* pinned = std::getenv("CRAFTER_BUILD_LIBCLANG"); pinned && *pinned) {
candidates.emplace_back(pinned);
if (const std::optional<fs::path>& pinned = GetToolchain().libclang) {
candidates.emplace_back(pinned->string());
} else {
for (std::string_view name : LibClangNames) candidates.emplace_back(name);
}
@ -119,7 +120,7 @@ namespace {
tried.push_back(name);
}
if (!lib.handle) {
lib.error = std::format("could not load libclang (tried {}); install clang, or point CRAFTER_BUILD_LIBCLANG at it", join(tried));
lib.error = std::format("could not load libclang (tried {}); install clang, set CRAFTER_BUILD_LLVM_DIR, or point CRAFTER_BUILD_LIBCLANG at it", join(tried));
return lib;
}
@ -710,7 +711,7 @@ namespace {
// explicitly. Cached like HostTarget, and for the same reason.
const std::string& ClangResourceDir() {
static const std::string Cached = []() -> std::string {
CommandResult r = RunCommandChecked("clang++ -print-resource-dir");
CommandResult r = RunCommandChecked(std::format("{} -print-resource-dir", GetToolchain().clangxx.string()));
if (r.exitCode != 0) return {};
std::string out = std::move(r.output);
while (!out.empty() && (out.back() == '\n' || out.back() == '\r')) out.pop_back();

View file

@ -14,6 +14,7 @@ module;
module Crafter.Build:Platform_impl;
import std;
import :Platform;
import :Toolchain;
import :Clang;
import :Interface;
import :External;
@ -595,7 +596,7 @@ std::string Crafter::BuildStdPcm(const Configuration& config, fs::path stdPcm) {
std::vector<std::string> folders;
// Iterate through the directory and collect all subdirectories
for (const auto& entry : fs::directory_iterator("/usr/x86_64-w64-mingw32/include/c++")) {
for (const auto& entry : fs::directory_iterator(GetToolchain().mingwSysroot / "include" / "c++")) {
if (entry.is_directory()) {
folders.push_back(entry.path().filename().string());
}
@ -608,7 +609,7 @@ std::string Crafter::BuildStdPcm(const Configuration& config, fs::path stdPcm) {
std::string mingWversion = folders.front();
fs::path stdCc = fs::path(std::format("/usr/x86_64-w64-mingw32/include/c++/{}/bits/std.cc", mingWversion));
fs::path stdCc = GetToolchain().mingwSysroot / "include" / "c++" / mingWversion / "bits" / "std.cc";
if(!fs::exists(stdPcm) || fs::last_write_time(stdPcm) < fs::last_write_time(stdCc)) {
// -femulated-tls keeps PCM TLS access matching libstdc++'s emutls
@ -621,7 +622,7 @@ std::string Crafter::BuildStdPcm(const Configuration& config, fs::path stdPcm) {
if (copyEc) {
return std::format("copy {} -> {}: {}", stdCc.string(), stdCppm.string(), copyEc.message());
}
return RunBuildCommand(std::format("clang++ --target={} -march={} -mtune={} -femulated-tls -O3 -std=c++26 -Wno-reserved-identifier -Wno-reserved-module-identifier --precompile {} -o {}", config.target, config.march, config.mtune, stdCppm.string(), stdPcm.string()));
return RunBuildCommand(std::format("{} --target={} -march={} -mtune={} -femulated-tls -O3 -std=c++26 -Wno-reserved-identifier -Wno-reserved-module-identifier --precompile {} -o {}", GetBaseCommand(config), config.target, config.march, config.mtune, stdCppm.string(), stdPcm.string()));
} else {
return "";
}
@ -634,15 +635,15 @@ std::string Crafter::BuildStdPcm(const Configuration& config, fs::path stdPcm) {
if (isWasm) {
stdCppm = std::format("{}/share/libc++/v1/std.cppm", config.sysroot);
} else if (config.sysroot.empty()) {
stdCppm = "/usr/share/libc++/v1/std.cppm";
stdCppm = GetToolchain().stdCppm.string();
} else {
stdCppm = std::format("{}/usr/share/libc++/v1/std.cppm", config.sysroot);
// not every distro ships the std module sources (Alpine's
// libc++-dev doesn't); the host copy is interchangeable as long
// as the libc++ versions line up, which the compile of the PCM
// against the sysroot's headers verifies anyway
if (!fs::exists(stdCppm) && fs::exists("/usr/share/libc++/v1/std.cppm")) {
stdCppm = "/usr/share/libc++/v1/std.cppm";
if (!fs::exists(stdCppm) && fs::exists(GetToolchain().stdCppm)) {
stdCppm = GetToolchain().stdCppm.string();
}
}
std::string sysrootFlag = config.sysroot.empty()
@ -669,7 +670,7 @@ std::string Crafter::BuildStdPcm(const Configuration& config, fs::path stdPcm) {
archFlags += std::format(" -nostdinc++ -isystem {}/usr/include/c++/v1", config.sysroot);
}
if(!fs::exists(stdPcm) || fs::last_write_time(stdPcm) < fs::last_write_time(stdCppm)) {
return RunBuildCommand(std::format("clang++ --target={} -std=c++26 -stdlib=libc++{}{} -O3 -Wno-reserved-identifier -Wno-reserved-module-identifier --precompile {} -o {}", config.target, sysrootFlag, archFlags, stdCppm, stdPcm.string()));
return RunBuildCommand(std::format("{} --target={} -std=c++26{}{} -O3 -Wno-reserved-identifier -Wno-reserved-module-identifier --precompile {} -o {}", GetBaseCommand(config), config.target, sysrootFlag, archFlags, stdCppm, stdPcm.string()));
} else {
return "";
}
@ -689,17 +690,24 @@ fs::path Crafter::GetCacheDir() {
}
std::string Crafter::GetBaseCommand(const Configuration& config) {
std::string stdlib;
if(config.target == "x86_64-w64-mingw32") {
stdlib = "";
} else {
stdlib = "-stdlib=libc++";
const Toolchain& tc = GetToolchain();
std::string command = tc.clangxx.string();
if (config.target == "x86_64-w64-mingw32") {
// libstdc++ from the mingw toolchain, found by clang's own mingw
// discovery unless the sysroot was given explicitly.
if (tc.mingwSysrootExplicit) command += std::format(" --sysroot={}", tc.mingwSysroot.string());
return command;
}
return std::format("clang++ {}", stdlib);
command += " -stdlib=libc++";
// A sysroot build (wasm, a cross rootfs) takes libc++ from the sysroot;
// only host builds can be pointed at an explicit libc++ install.
if (config.sysroot.empty()) command += tc.LibcxxCompileFlags();
return command;
}
namespace {
constexpr std::array<std::string_view, 11> CrafterBuildModules = {
constexpr std::array<std::string_view, 12> CrafterBuildModules = {
"Crafter.Build-Toolchain",
"Crafter.Build-Shader",
"Crafter.Build-Platform",
"Crafter.Build-Interface",
@ -713,7 +721,7 @@ namespace {
"Crafter.Build",
};
void EnsureCrafterBuildPcms(const fs::path& sourceDir, const fs::path& cacheDir) {
void EnsureCrafterBuildPcms(const fs::path& sourceDir, const fs::path& cacheDir, const Configuration& host) {
CacheLock lock(cacheDir);
// Re-derived under the lock: another builder may have refreshed the
// cache from its own sources while we waited.
@ -729,12 +737,12 @@ namespace {
continue;
}
std::string cmd = std::format(
"clang++ --target={} -march=native -mtune=native "
"-std=c++26 -stdlib=libc++ -O3 "
"{} --target={} -march=native -mtune=native "
"-std=c++26 -O3 "
"-Wno-reserved-identifier -Wno-reserved-module-identifier "
"-fprebuilt-module-path={} "
"--precompile {} -o {}",
HostTarget(), cacheDir.string(), cppmPath.string(), pcmPath.string());
GetBaseCommand(host), host.target, cacheDir.string(), cppmPath.string(), pcmPath.string());
CommandResult r = Crafter::RunCommandChecked(cmd);
if (r.exitCode != 0) {
throw std::runtime_error(std::format("Failed to precompile {} (exit {}): {}", name, r.exitCode, r.output));
@ -748,6 +756,7 @@ namespace {
}
Configuration Crafter::LoadProject(const fs::path& projectFile, std::span<const std::string_view> args) {
RequireToolchain();
fs::path absProject = fs::canonical(projectFile);
ProjectFileGuard guard(absProject);
fs::path buildDir = absProject.parent_path() / "build";
@ -775,7 +784,7 @@ Configuration Crafter::LoadProject(const fs::path& projectFile, std::span<const
throw std::runtime_error(std::format("Failed to build std.pcm: {}", stdResult));
}
EnsureCrafterBuildPcms(sourceDir, cacheDir);
EnsureCrafterBuildPcms(sourceDir, cacheDir, hostConfig);
// After the host PCMs exist (a dependency's build imports Crafter.Build
// from that cache) and before the staleness check (its artifacts are
@ -792,13 +801,13 @@ Configuration Crafter::LoadProject(const fs::path& projectFile, std::span<const
// and a dependency archive built in release holds ThinLTO bitcode,
// which GNU ld can't read.
std::string compileCmd = std::format(
"clang++ --target={} -march=native -mtune=native "
"-std=c++26 -stdlib=libc++ -shared -fPIC -O3 -fuse-ld=lld "
"{} --target={} -march=native -mtune=native "
"-std=c++26 -shared -fPIC -O3 -fuse-ld=lld "
"-Wno-return-type-c-linkage "
"-fprebuilt-module-path={}{} "
"{} -o {}{}",
hostConfig.target, cacheDir.string(), preDeps.compileFlags,
absProject.string(), soPath.string(), preDeps.linkFlags);
"{} -o {}{}{}",
GetBaseCommand(hostConfig), hostConfig.target, cacheDir.string(), preDeps.compileFlags,
absProject.string(), soPath.string(), preDeps.linkFlags, GetToolchain().LibcxxLinkFlags());
std::string result = RunBuildCommand(compileCmd);
if (!result.empty()) {

View file

@ -181,7 +181,7 @@ TestRunner TestRunner::ForTarget(const Configuration& cfg) {
// Same triple as the host → run the binary directly. Covers the common
// case (cfg.target defaulted to HostTarget()) without any wrapper.
if (target == HostTarget()) return Local();
if (SameTarget(target, HostTarget())) return Local();
// Windows targets run under Wine; there is no Windows host.
if (TargetIsWindows(target)) return Wine();
@ -466,13 +466,16 @@ TestSummary Crafter::RunTests(Configuration& projectCfg, const RunTestsOptions&
// user having to know which targets exist. An explicit --target=X
// bypasses the sweep and runs that target only.
if (opts.targetFilter.empty()) {
std::set<std::string> sweep;
sweep.insert(HostTarget());
// Keyed by the normalized triple so x86_64-pc-linux-gnu and
// x86_64-unknown-linux-gnu make one pass; the first spelling seen is
// the one shown and filtered on.
std::map<std::string, std::string> sweep;
sweep.emplace(NormalizeTarget(HostTarget()), HostTarget());
for (const Test& t : projectCfg.tests) {
if (!t.config.target.empty()) sweep.insert(t.config.target);
if (!t.config.target.empty()) sweep.emplace(NormalizeTarget(t.config.target), t.config.target);
}
TestSummary aggregate;
for (const auto& target : sweep) {
for (const auto& [normalized, target] : sweep) {
RunTestsOptions perTarget = opts;
perTarget.targetFilter = target;
if (sweep.size() > 1) {
@ -499,7 +502,7 @@ TestSummary Crafter::RunTests(Configuration& projectCfg, const RunTestsOptions&
std::vector<Test*> filtered;
filtered.reserve(projectCfg.tests.size());
for (auto& test : projectCfg.tests) {
if (test.config.target != opts.targetFilter) continue;
if (!SameTarget(test.config.target, opts.targetFilter)) continue;
if (!MatchAny(opts.globs, test.config.name)) continue;
test.runner = TestRunner::FromEnv(test.config.target, TestRunner::ForTarget(test.config));
if (opts.runnerOverride) {

View file

@ -0,0 +1,182 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module;
// Supplied on the command line by build.sh and project.cpp (from the
// CRAFTER_BUILD_VERSION environment variable or `git describe`); a build
// that passes nothing is a development build.
#ifndef CRAFTER_BUILD_VERSION
#define CRAFTER_BUILD_VERSION "0.0.0-dev"
#endif
module Crafter.Build:Toolchain_impl;
import std;
import :Toolchain;
import :Platform;
namespace fs = std::filesystem;
using namespace Crafter;
namespace {
constexpr std::string_view EnvVars[] = {
"CRAFTER_BUILD_LLVM_DIR",
"CRAFTER_BUILD_LIBCXX_DIR",
"CRAFTER_BUILD_LIBCLANG",
"CRAFTER_BUILD_WASI_SYSROOT",
"CRAFTER_MINGW_DIR",
};
std::string_view Env(const ToolchainInputs& in, std::string_view name) {
auto it = in.env.find(name);
return it == in.env.end() ? std::string_view{} : std::string_view(it->second);
}
// "Arch Linux clang version 22.1.8" / "clang version 22.1.8" -> "22.1.8".
std::string ShortVersion(std::string_view versionLine) {
constexpr std::string_view Marker = "version ";
std::size_t at = versionLine.find(Marker);
if (at == std::string_view::npos) return "unknown";
std::string_view rest = versionLine.substr(at + Marker.size());
std::size_t end = rest.find_first_of(" \t(");
return std::string(rest.substr(0, end));
}
std::string Trimmed(std::string s) {
while (!s.empty() && (s.back() == '\n' || s.back() == '\r' || s.back() == ' ')) s.pop_back();
return s;
}
}
std::string Toolchain::LibcxxCompileFlags() const {
if (libcxxDir.empty()) return {};
return std::format(" -nostdinc++ -isystem {}", (libcxxDir / "include" / "c++" / "v1").string());
}
std::string Toolchain::LibcxxLinkFlags() const {
std::string flags;
if (!libcxxDir.empty()) {
fs::path lib = libcxxDir / "lib";
flags += std::format(" -L{} -Wl,-rpath,{}", lib.string(), lib.string());
} else if (!prefix.empty() && prefix != "/usr") {
flags += std::format(" -Wl,-rpath,{}", (prefix / "lib").string());
}
return flags;
}
std::string Toolchain::Key() const {
std::string identity;
for (const std::string& part : {clangxx.string(), clangVersion, libcxxDir.string(), stdCppm.string()}) {
identity += part;
identity += '\0';
}
return std::format("{}-{:08x}", ShortVersion(clangVersion), static_cast<std::uint32_t>(std::hash<std::string>{}(identity)));
}
Toolchain Crafter::ResolveToolchain(const ToolchainInputs& in) {
Toolchain tc;
// 1. The compiler: an explicit prefix, else one installed beside
// crafter-build, else PATH. An explicit prefix without a clang++ is an
// error rather than a fallback, for the same reason CRAFTER_BUILD_LIBCLANG
// is exclusive: an override that silently yields to something else
// cannot be used to diagnose which compiler is in play.
if (std::string_view dir = Env(in, "CRAFTER_BUILD_LLVM_DIR"); !dir.empty()) {
tc.prefix = fs::path(dir);
if (!fs::exists(tc.prefix / "bin" / "clang++")) {
tc.error = std::format("clang++ not found at {} (CRAFTER_BUILD_LLVM_DIR)", (tc.prefix / "bin" / "clang++").string());
}
} else if (!in.crafterPrefix.empty() && fs::exists(in.crafterPrefix / "bin" / "clang++")) {
tc.prefix = in.crafterPrefix;
}
if (tc.prefix.empty()) {
tc.clangxx = "clang++";
tc.clang = "clang";
tc.llvmAr = "llvm-ar";
} else {
tc.clangxx = tc.prefix / "bin" / "clang++";
tc.clang = tc.prefix / "bin" / "clang";
tc.llvmAr = tc.prefix / "bin" / "llvm-ar";
if (tc.prefix != "/usr" && fs::exists(tc.prefix / "include" / "clang-c" / "Index.h")) {
tc.llvmIncludeDir = tc.prefix / "include";
}
}
// 2. libclang. The override is exclusive and unchecked on purpose: a
// broken path must fail loudly when dlopened, not be papered over.
if (std::string_view pinned = Env(in, "CRAFTER_BUILD_LIBCLANG"); !pinned.empty()) {
tc.libclang = fs::path(pinned);
} else if (!tc.prefix.empty() && fs::exists(tc.prefix / "lib" / "libclang.so")) {
tc.libclang = tc.prefix / "lib" / "libclang.so";
}
// 3. libc++ and its std module source. The compiler's own copy lives at
// <prefix>/share/libc++/v1 when the prefix carries libc++; the system
// copy is the last resort either way.
const fs::path systemStdCppm = "/usr/share/libc++/v1/std.cppm";
if (std::string_view dir = Env(in, "CRAFTER_BUILD_LIBCXX_DIR"); !dir.empty()) {
tc.libcxxDir = fs::path(dir);
tc.stdCppm = tc.libcxxDir / "share" / "libc++" / "v1" / "std.cppm";
} else if (!tc.prefix.empty() && fs::exists(tc.prefix / "share" / "libc++" / "v1" / "std.cppm")) {
tc.stdCppm = tc.prefix / "share" / "libc++" / "v1" / "std.cppm";
} else {
tc.stdCppm = systemStdCppm;
}
// 4. Sysroots for the cross targets that have a conventional location.
if (std::string_view dir = Env(in, "CRAFTER_BUILD_WASI_SYSROOT"); !dir.empty()) {
tc.wasiSysroot = fs::path(dir);
} else if (!tc.prefix.empty() && fs::exists(tc.prefix / "share" / "wasi-sysroot")) {
tc.wasiSysroot = tc.prefix / "share" / "wasi-sysroot";
} else {
tc.wasiSysroot = "/usr/share/wasi-sysroot";
}
if (std::string_view dir = Env(in, "CRAFTER_MINGW_DIR"); !dir.empty()) {
tc.mingwSysroot = fs::path(dir);
tc.mingwSysrootExplicit = true;
} else {
tc.mingwSysroot = "/usr/x86_64-w64-mingw32";
}
return tc;
}
const Toolchain& Crafter::GetToolchain() {
static const Toolchain Resolved = []() {
ToolchainInputs in;
for (std::string_view name : EnvVars) {
if (const char* value = std::getenv(std::string(name).c_str()); value && *value) {
in.env.emplace(std::string(name), value);
}
}
// GetCrafterBuildHome throws when no share/crafter-build is found
// near the executable; a toolchain can still come from PATH then.
try {
in.crafterPrefix = GetCrafterBuildHome().parent_path().parent_path();
} catch (const std::exception&) {}
Toolchain tc = ResolveToolchain(in);
if (!tc.error.empty()) return tc;
CommandResult triple = RunCommandChecked(std::format("{} -print-target-triple", tc.clangxx.string()));
if (triple.exitCode != 0) {
tc.error = std::format("could not run {} (exit {}): {}", tc.clangxx.string(), triple.exitCode, Trimmed(triple.output));
if (tc.prefix.empty()) tc.error += "; install clang or set CRAFTER_BUILD_LLVM_DIR";
return tc;
}
tc.hostTriple = Trimmed(std::move(triple.output));
CommandResult version = RunCommandChecked(std::format("{} --version", tc.clangxx.string()));
if (version.exitCode == 0) {
std::string first = version.output.substr(0, version.output.find('\n'));
tc.clangVersion = Trimmed(std::move(first));
}
return tc;
}();
return Resolved;
}
const Toolchain& Crafter::RequireToolchain() {
const Toolchain& tc = GetToolchain();
if (!tc.error.empty()) throw std::runtime_error(tc.error);
return tc;
}
std::string_view Crafter::CrafterBuildVersion() {
return CRAFTER_BUILD_VERSION;
}