V2: WASI, -r flag, CI pipeline, examples & tests cleanup
Some checks failed
CI / build-test-release (pull_request) Failing after 44s

WASI / wasm32 target support
- Auto-detect /usr/share/wasi-sysroot on Linux when target starts_with("wasm32")
- Skip -march/-mtune for wasm (clang rejects them)
- Apply -fno-exceptions -fno-c++-static-destructors -mllvm -wasm-enable-sjlj
  -D_WASI_EMULATED_SIGNAL to wasm builds (compile + std PCM, kept in sync)
- .wasm output extension in expectedOutputFor and link command
- EnableWasiBrowserRuntime(cfg): opt-in helper that drops index.html +
  runtime.js next to the .wasm; runtime.js reads window.CRAFTER_WASM_URL
  set in the templated index.html so a single shim handles any output name

-r run flag in the CLI: build then exec the artifact (host targets only;
  rejects libraries; auto .exe/.wasm extension handling)

CI pipeline (.forgejo/workflows/ci.yaml)
- Triggers: PR/push to master + manual dispatch
- Single arch-latest container job: install deps, bootstrap, self-rebuild,
  run tests, cross-compile mingw, package both archives, upload artifacts
- Rolling 'latest' release published only on push/dispatch to master

mingw cross-compile from Linux now works end-to-end:
- ExternalDependency cache key includes target so per-target glslang builds
  don't collide; CMAKE_BUILD_TYPE=Release pinned (otherwise glslang appends
  'd' to lib names and breaks linking); cross-compile cmake flags
  (CMAKE_SYSTEM_NAME=Windows, CMAKE_*_COMPILER_TARGET=...)
- project.cpp accepts --target=<triple>; Linux-only -Wl,--export-dynamic
  and -ldl are gated; mingw glslang skips the standalone exe (its libgcc_eh
  link pulls pthread which mingw doesn't link by default)
- mingw compile uses -femulated-tls so std::__once_callable etc reference
  the same emutls symbols libstdc++ provides
- mingw link auto-adds -lstdc++exp -lpthread

GetCrafterBuildHome() exposed from the Platform module; LoadProject (Linux
+ Windows) now both use it instead of duplicating the resolution.

Examples reorg: hello-world, library, with-module, wasi, tests — each with
its own README. Tests reorg: per-test directory with inner/ fixture, no
shared tests/fixtures/ tree. New Wasi test verifies .wasm magic bytes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-04-28 23:24:46 +02:00
commit eaee502e8c
102 changed files with 2211 additions and 686 deletions

View file

@ -63,7 +63,7 @@ namespace {
return false;
}
std::string ShellQuote(std::string_view s) {
std::string ShellQuoteSh(std::string_view s) {
std::string out;
out.reserve(s.size() + 2);
out.push_back('\'');
@ -75,11 +75,42 @@ namespace {
return out;
}
std::string JoinAndQuoteArgs(std::span<const std::string> args) {
// cmd.exe doesn't recognize '...' as quoting (it would pass the single
// quotes through to the executable). Wrap in "..." for cmd; embedded "
// is rare in paths but escape it to be safe. Backslash sequences before
// " don't need the MS-CRT doubling rules because we go through
// `cmd /C "..."`, which uses cmd's parser, not the CRT's argv splitter.
std::string ShellQuoteCmd(std::string_view s) {
std::string out;
out.reserve(s.size() + 2);
out.push_back('"');
for (char c : s) {
if (c == '"') out += "\\\"";
else out.push_back(c);
}
out.push_back('"');
return out;
}
// Host-shell quoting: sh on Linux, cmd on Windows. For args/paths that
// hit the local shell (Local runner exec, Cmd-prefix runner exec).
std::string ShellQuoteHost(std::string_view s) {
#ifdef _WIN32
return ShellQuoteCmd(s);
#else
return ShellQuoteSh(s);
#endif
}
std::string JoinAndQuoteArgs(std::span<const std::string> args, TestRunner::Shell shell) {
std::string out;
for (const auto& a : args) {
if (!out.empty()) out.push_back(' ');
out += ShellQuote(a);
switch (shell) {
case TestRunner::Shell::Sh: out += ShellQuoteSh(a); break;
case TestRunner::Shell::Cmd: out += ShellQuoteCmd(a); break;
case TestRunner::Shell::Host: out += ShellQuoteHost(a); break;
}
}
return out;
}
@ -167,9 +198,42 @@ namespace {
}
}
namespace {
std::atomic<Configuration*> g_parentProject{nullptr};
Configuration* FindLibInTree(Configuration* root,
std::string_view name,
std::unordered_set<Configuration*>& seen) {
if (!seen.insert(root).second) return nullptr;
if (root->name == name) return root;
for (Configuration* dep : root->dependencies) {
if (auto found = FindLibInTree(dep, name, seen)) return found;
}
return nullptr;
}
}
void Crafter::SetParentProject(Configuration* parent) {
g_parentProject.store(parent);
}
Configuration* Crafter::ParentLib(std::string_view name) {
Configuration* root = g_parentProject.load();
if (!root) {
throw std::runtime_error(std::format(
"Crafter::ParentLib('{}'): no parent project set", name));
}
std::unordered_set<Configuration*> seen;
if (auto found = FindLibInTree(root, name, seen)) return found;
throw std::runtime_error(std::format(
"Crafter::ParentLib('{}'): not found in parent project '{}'",
name, root->name));
}
TestRunner TestRunner::Local() {
TestRunner r;
r.name = "local";
r.argsShell = Shell::Host;
return r;
}
@ -177,10 +241,18 @@ TestRunner TestRunner::Ssh(std::string host, std::string remoteDir) {
TestRunner r;
r.name = std::format("ssh:{}", host);
r.remoteDir = std::move(remoteDir);
r.copy = std::format("ssh -q {0} 'mkdir -p {{remote_bundle}}' && scp -r -q {{bundle}}/. {0}:{{remote_bundle}}/", host);
r.exec = std::format("ssh -q {} 'cd {{remote_bundle}} && ./{{bin_name}} {{args}}'", host);
r.cleanup = std::format("ssh -q {} 'rm -rf {{remote_bundle}}' || true", host);
r.probe = std::format("ssh -q -o BatchMode=yes -o ConnectTimeout=5 {} true > /dev/null 2>&1", host);
r.argsShell = Shell::Sh;
// Outer "..." (not '...') so the wrapper survives both sh (Linux host)
// and cmd (Windows host). cmd doesn't honor single quotes, which would
// cause it to split on the inner '&&'. Args inside use POSIX quoting
// because they reach a remote bash regardless of which host issued them.
r.copy = std::format("ssh -q {0} \"mkdir -p {{remote_bundle}}\" && scp -r -q {{bundle}}/. {0}:{{remote_bundle}}/", host);
r.exec = std::format("ssh -q {} \"cd {{remote_bundle}} && ./{{bin_name}} {{args}}\"", host);
r.cleanup = std::format("ssh -q {} \"rm -rf {{remote_bundle}}\"", host);
// RunCommandChecked captures stdout+stderr internally — no shell redirect
// needed in the probe spec, which means it works the same way under cmd
// (Windows host) and sh (Linux host) since `> /dev/null` doesn't translate.
r.probe = std::format("ssh -q -o BatchMode=yes -o ConnectTimeout=5 {} true", host);
return r;
}
@ -188,6 +260,7 @@ TestRunner TestRunner::SshWin(std::string host, std::string remoteDir) {
TestRunner r;
r.name = std::format("sshwin:{}", host);
r.remoteDir = std::move(remoteDir);
r.argsShell = Shell::Cmd;
// cmd.exe-friendly templates. Use backslash variants of remote paths because
// cmd's mkdir won't auto-create intermediate directories with forward
// slashes. scp tolerates either, so we keep forward slashes for the scp
@ -196,15 +269,39 @@ TestRunner TestRunner::SshWin(std::string host, std::string remoteDir) {
r.copy = std::format("ssh -q {0} \"mkdir {{remote_bundle_win}} 2>nul & rem ok\" && scp -r -q {{bundle}}/. {0}:{{remote_bundle}}/", host);
r.exec = std::format("ssh -q {} \"{{bin_win}} {{args}}\"", host);
r.cleanup = std::format("ssh -q {} \"rd /s /q {{remote_bundle_win}} 2>nul & rem ok\"", host);
r.probe = std::format("ssh -q -o BatchMode=yes -o ConnectTimeout=5 {} \"ver\" > /dev/null 2>&1", host);
// No shell redirect in the probe — see TestRunner::Ssh for rationale.
r.probe = std::format("ssh -q -o BatchMode=yes -o ConnectTimeout=5 {} \"ver\"", host);
return r;
}
TestRunner TestRunner::QemuUser(std::string qemuBin) {
TestRunner TestRunner::Wsl(std::string remoteDir) {
TestRunner r;
r.name = std::format("qemu:{}", qemuBin);
r.exec = std::format("{} {{bin}} {{args}}", qemuBin);
r.probe = std::format("which {} > /dev/null 2>&1", qemuBin);
r.name = "wsl";
r.argsShell = Shell::Sh;
r.remoteDir = std::move(remoteDir);
// Transport runner: stage the test bundle into WSL's native filesystem
// (faster than executing in-place from /mnt/c) then run via bash. {bundle_wsl}
// is the local Windows path translated to /mnt/<drive>/... form so wsl cp
// can read it. Args are POSIX-quoted because they reach a Linux shell.
r.copy = "wsl mkdir -p {remote_bundle} && wsl cp -r {bundle_wsl}/. {remote_bundle}/";
r.exec = "wsl bash -c \"cd {remote_bundle} && ./{bin_name} {args}\"";
r.cleanup = "wsl rm -rf {remote_bundle}";
// `where wsl` matches the host-shell convention; on a non-Windows host
// it fails (no `where` command), which correctly skips this runner.
r.probe = "where wsl";
return r;
}
TestRunner TestRunner::Cmd(std::string command) {
TestRunner r;
r.name = std::format("cmd:{}", command);
r.argsShell = Shell::Host;
r.exec = std::format("{} {{bin}} {{args}}", command);
#ifdef _WIN32
r.probe = std::format("where {}", command);
#else
r.probe = std::format("which {}", command);
#endif
return r;
}
@ -225,7 +322,7 @@ namespace {
}
if (parts.empty()) return std::nullopt;
if (parts[0] == "local") return TestRunner::Local();
if (parts[0] == "qemu" && parts.size() == 2) return TestRunner::QemuUser(parts[1]);
if (parts[0] == "cmd" && parts.size() == 2) return TestRunner::Cmd(parts[1]);
if (parts[0] == "ssh" && parts.size() == 2) return TestRunner::Ssh(parts[1]);
if (parts[0] == "ssh" && parts.size() >= 3) {
std::string remote;
@ -244,9 +341,34 @@ namespace {
}
return TestRunner::SshWin(parts[1], remote);
}
if (parts[0] == "wsl" && parts.size() == 1) return TestRunner::Wsl();
if (parts[0] == "wsl" && parts.size() >= 2) {
std::string remote;
for (std::size_t i = 1; i < parts.size(); ++i) {
if (i > 1) remote.push_back(':');
remote += parts[i];
}
return TestRunner::Wsl(remote);
}
throw std::runtime_error(std::format(
"TestRunner::FromEnv: unrecognized runner spec '{}'", spec));
"TestRunner::FromSpec: unrecognized runner spec '{}'", spec));
}
// C:\Users\jorij -> /mnt/c/Users/jorij. Idempotent on paths that don't
// start with a drive letter, so callers can compute it unconditionally.
std::string WindowsPathToWsl(std::string_view p) {
if (p.size() < 2 || p[1] != ':') return std::string(p);
char drive = static_cast<char>(std::tolower(static_cast<unsigned char>(p[0])));
std::string out = std::format("/mnt/{}", drive);
for (std::size_t i = 2; i < p.size(); ++i) {
out.push_back(p[i] == '\\' ? '/' : p[i]);
}
return out;
}
}
std::optional<TestRunner> TestRunner::FromSpec(std::string_view spec) {
return ParseRunnerSpec(spec);
}
TestRunner TestRunner::FromEnv(std::string_view target, TestRunner fallback) {
@ -263,7 +385,7 @@ TestResult Crafter::RunSingleTest(const Test& test, const fs::path& binary, std:
result.name = test.config.name;
std::map<std::string, std::string> ph;
ph["{args}"] = JoinAndQuoteArgs(test.args);
ph["{args}"] = JoinAndQuoteArgs(test.args, test.runner.argsShell);
ph["{bin_name}"] = binary.filename().string();
ph["{bundle}"] = binary.parent_path().string();
@ -271,8 +393,8 @@ TestResult Crafter::RunSingleTest(const Test& test, const fs::path& binary, std:
CommandResult r;
if (test.runner.exec.empty()) {
// Pure-local runner: spawn the binary directly.
std::string cmd = std::format("{} {}", ShellQuote(binary.string()), ph["{args}"]);
// Pure-local runner: spawn the binary directly through the host shell.
std::string cmd = std::format("{} {}", ShellQuoteHost(binary.string()), ph["{args}"]);
r = RunCommandWithTimeout(cmd, timeout);
} else if (test.runner.copy.empty()) {
// Prefix runner (qemu-user, wsl, ...): templated exec wraps a local binary.
@ -292,6 +414,10 @@ TestResult Crafter::RunSingleTest(const Test& test, const fs::path& binary, std:
std::replace(ph["{remote_bundle_win}"].begin(), ph["{remote_bundle_win}"].end(), '/', '\\');
ph["{bin_win}"] = ph["{bin}"];
std::replace(ph["{bin_win}"].begin(), ph["{bin_win}"].end(), '/', '\\');
// WSL form of the local bundle path: C:\foo -> /mnt/c/foo. Used by
// the Wsl runner's `wsl cp -r` step to read the test bundle out of
// the Windows host's filesystem.
ph["{bundle_wsl}"] = WindowsPathToWsl(ph["{bundle}"]);
CommandResult cp = RunCommandWithTimeout(Substitute(test.runner.copy, ph), 5min);
if (cp.exitCode != 0) {
@ -318,14 +444,133 @@ TestResult Crafter::RunSingleTest(const Test& test, const fs::path& binary, std:
if (r.timedOut) result.outcome = TestOutcome::Timeout;
else if (r.crashed) result.outcome = TestOutcome::Crash;
else if (r.exitCode == 77) result.outcome = TestOutcome::Skipped;
else if (r.exitCode != 0) result.outcome = TestOutcome::Fail;
else result.outcome = TestOutcome::Pass;
return result;
}
TestSummary Crafter::RunTests(Configuration& projectCfg, const RunTestsOptions& opts) {
namespace {
// Synthesize a Configuration for tests/<Name>/ folders that don't contain
// a project.cpp. Convention: cfg.path = the folder, cfg.name/outputName =
// folder basename, cfg.target = the run's targetFilter, cfg.type = exe.
// Sources: top-level *.cpp (excluding project.cpp) become implementations,
// interfaces/*.cppm become module interfaces (matching the layout used
// elsewhere in this codebase). Tests with deeper layouts, defines, or
// dependencies still need an explicit project.cpp.
Configuration SynthesizeTest(const fs::path& dir, std::string_view target) {
Configuration cfg;
cfg.path = dir;
cfg.name = dir.filename().string();
cfg.outputName = cfg.name;
cfg.target = std::string(target);
cfg.type = ConfigurationType::Executable;
std::vector<fs::path> impls;
for (auto& e : fs::directory_iterator(dir)) {
if (!e.is_regular_file()) continue;
auto p = e.path();
if (p.extension() != ".cpp") continue;
if (p.filename() == "project.cpp") continue;
impls.push_back(p.stem());
}
std::ranges::sort(impls);
std::vector<fs::path> ifaces;
fs::path interfacesDir = dir / "interfaces";
if (fs::exists(interfacesDir) && fs::is_directory(interfacesDir)) {
for (auto& e : fs::directory_iterator(interfacesDir)) {
if (!e.is_regular_file()) continue;
auto p = e.path();
if (p.extension() != ".cppm") continue;
ifaces.push_back(fs::path("interfaces") / p.stem());
}
std::ranges::sort(ifaces);
}
if (impls.empty() && ifaces.empty()) {
throw std::runtime_error(std::format(
"no .cpp or interfaces/*.cppm files found in {}", dir.string()));
}
cfg.GetInterfacesAndImplementations(ifaces, impls);
return cfg;
}
}
TestSummary Crafter::RunTests(Configuration& projectCfg, const RunTestsOptions& opts, std::span<const std::string_view> projectArgs) {
TestSummary summary;
std::vector<TestResult> discoveryFailures;
// Auto-discover tests one layer deep: each <project>/tests/<Name>/ folder
// is a test. If it contains project.cpp, that's loaded for full control;
// otherwise the Configuration is synthesized from the folder contents.
// Folders whose name starts with '_' or '.' are skipped (so tests/_shared/
// holds cross-test code without becoming a test). Each project.cpp receives
// the same args the root project did, so --target=... propagates through.
//
// Discovery is keyed off cwd (= the project root, since crafter-build loads
// ./project.cpp), not projectCfg.path: tests live at the project root even
// when projectCfg.path points at a subdirectory like "./src/" or "./lib/".
fs::path testsDir = fs::current_path() / "tests";
if (fs::exists(testsDir) && fs::is_directory(testsDir)) {
struct TestEntry { fs::path dir; fs::path pcpp; }; // pcpp empty = synth
std::vector<TestEntry> entries;
for (auto& entry : fs::directory_iterator(testsDir)) {
if (!entry.is_directory()) continue;
auto stem = entry.path().filename().string();
if (stem.empty() || stem[0] == '_' || stem[0] == '.') continue;
TestEntry te;
te.dir = entry.path();
auto pcpp = te.dir / "project.cpp";
if (fs::exists(pcpp)) te.pcpp = pcpp;
entries.push_back(std::move(te));
}
std::ranges::sort(entries, [](auto& a, auto& b) { return a.dir < b.dir; });
// Inject --target=<filter> into the args we hand each fixture so its
// CrafterBuildProject can default to the run's target. The CLI's own
// --target=... propagates through projectArgs already; this only
// appends when missing so an explicit user choice wins.
std::string targetArg = std::format("--target={}", opts.targetFilter);
std::vector<std::string_view> fixtureArgs(projectArgs.begin(), projectArgs.end());
bool hasTarget = std::ranges::any_of(fixtureArgs, [](std::string_view a) {
return a.starts_with("--target=");
});
if (!hasTarget) fixtureArgs.push_back(targetArg);
for (auto& te : entries) {
Test t;
try {
if (!te.pcpp.empty()) {
t.config = LoadProject(te.pcpp, fixtureArgs);
} else {
t.config = SynthesizeTest(te.dir, opts.targetFilter);
}
} catch (const std::exception& e) {
// A broken fixture shouldn't kill the whole run. Surface as a
// Fail and let other tests proceed.
TestResult r;
r.name = te.dir.filename().string();
r.outcome = TestOutcome::Fail;
r.exitCode = -1;
r.output = !te.pcpp.empty()
? std::format("project.cpp failed to load: {}", e.what())
: std::format("test discovery failed: {}", e.what());
discoveryFailures.push_back(std::move(r));
continue;
}
if (t.config.target != opts.targetFilter) continue;
t.runner = TestRunner::FromEnv(t.config.target, TestRunner::Local());
if (opts.runnerOverride) {
if (auto r = TestRunner::FromSpec(*opts.runnerOverride)) {
t.runner = std::move(*r);
}
}
projectCfg.tests.push_back(std::move(t));
}
}
std::vector<Test*> filtered;
filtered.reserve(projectCfg.tests.size());
@ -336,17 +581,28 @@ TestSummary Crafter::RunTests(Configuration& projectCfg, const RunTestsOptions&
}
if (opts.listOnly) {
for (auto& r : discoveryFailures) {
std::println("{} (project.cpp broken)", r.name);
}
for (auto* t : filtered) {
std::println("{}", t->config.name);
}
return summary;
}
if (filtered.empty()) {
if (filtered.empty() && discoveryFailures.empty()) {
std::println("No tests matched.");
return summary;
}
// Render discovery failures upfront so they appear before parallel test
// results. They're already-determined Fails — no need to put them through
// the worker pool.
for (auto& r : discoveryFailures) {
PrintResult(r, "");
WriteLog(projectCfg.path, r.name, r.output);
}
int jobs = opts.jobs > 0
? opts.jobs
: std::max(1u, std::thread::hardware_concurrency());
@ -366,7 +622,9 @@ TestSummary Crafter::RunTests(Configuration& projectCfg, const RunTestsOptions&
if (auto it = probeCache.find(runner.name); it != probeCache.end()) {
return it->second;
}
bool ok = (std::system(runner.probe.c_str()) == 0);
// RunCommandChecked captures and discards output internally, so probe
// specs don't need `> /dev/null 2>&1` (which doesn't translate to cmd).
bool ok = (RunCommandChecked(runner.probe).exitCode == 0);
probeCache[runner.name] = ok;
return ok;
};
@ -446,6 +704,10 @@ TestSummary Crafter::RunTests(Configuration& projectCfg, const RunTestsOptions&
}
threads.clear(); // joins all jthreads
for (auto& r : discoveryFailures) {
results.push_back(std::move(r));
}
for (auto& r : results) {
switch (r.outcome) {
case TestOutcome::Pass: summary.passed++; break;