Crafter.Build/interfaces/Crafter.Build-Clang.cppm
catbot 13697cd026 fix: re-resolve module imports before checking staleness
Adding a data member to a class in a module interface did not rebuild every
object compiled against the old layout. The build succeeded with no error or
warning and the resulting binary mixed both layouts, surfacing later as a
SIGSEGV in a destructor.

GetInterfacesAndImplementations scans a TU's `import X;` statements when the
source is declared. An import that matches neither a module in the
Configuration nor one reachable through `dependencies` was dropped on the
floor, leaving that TU with no staleness edge to the interface it consumes.
`dependencies` is frequently assigned *after* the scan — AddTest does exactly
that, resolving tests/<name>/main.cpp and only then returning a builder whose
.Dependencies() supplies the library — so consumers of a dependency's modules
routinely carried no edge at all. A layout change then rebuilt the library,
relinked the consumer, and kept the consumer's object as it was.

Unresolved names are now remembered on the partition/implementation as
pendingImports, and Configuration::ResolvePendingImports retries them against
the dependency DAG as it stands. Build() calls it immediately before comparing
mtimes, which closes the window for every caller rather than only the ones that
declare in the right order; TestBuilder::Dependencies also calls it so the
Configuration is coherent for anyone inspecting it before the build.

Resolves #27
2026-07-30 17:12:24 +00:00

395 lines
21 KiB
C++

// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module;
#include "Crafter.Build-Api.h"
export module Crafter.Build:Clang;
import std;
import :Shader;
import :Interface;
import :Implementation;
import :External;
namespace fs = std::filesystem;
export namespace Crafter {
struct BuildResult {
std::string result;
bool repack;
std::unordered_set<std::string> libs;
// Compile flags (typically -I include paths) the dep wants its
// consumers to see — sourced from its external dependencies' include
// dirs so headers a dep exposes in its public modules are reachable
// when a consumer #includes them directly. Propagates transitively.
std::unordered_set<std::string> publicCompileFlags;
};
struct Define {
std::string name;
std::string value;
};
// A browser-wasm codegen variant. The same wasm32 target is compiled more
// than once, each pass adding `flags` to every translation unit, so newer
// ISA features (relaxed SIMD today; threads, future SIMD revisions, … later)
// can be adopted without dropping engines that lack them. Each non-empty
// `label` emits an additional `outputName.<label>.wasm` alongside the
// baseline `outputName.wasm`; `probes` names the runtime feature-detect
// checks (see wasi-runtime/runtime.js's probe registry) that must all pass
// for a browser to be served this variant. The first variant whose probes
// pass wins; the baseline is the universal fallback.
struct WasmVariant {
std::string label;
std::vector<std::string> flags;
std::vector<std::string> probes;
};
enum class ConfigurationType {
Executable,
LibraryStatic,
LibraryDynamic,
};
struct Test;
struct TestRunner {
// Command template the harness executes to run the test binary.
// Local runners leave this empty; prefix runners (Cmd, Wine) set it to
// a template like "wine {bin} {args}" or "qemu-aarch64 {bin} {args}".
std::string exec;
// Display name; also the cache key for the availability probe.
std::string name;
// Runs once per RunTests invocation (cached by `name`). Exit 0 = runner
// is available; non-zero = skip every Test using this runner with a
// "runner unavailable" message. Empty = always available (e.g., Local).
std::string probe;
bool IsLocal() const { return exec.empty(); }
static CRAFTER_API TestRunner Local();
// Prefix runner: wraps the local binary in `<command> {bin} {args}`.
// Used for qemu-user, wasmtime, and similar single-binary wrappers.
static CRAFTER_API TestRunner Cmd(std::string command);
// Run a Windows .exe through Wine. Probes `wine` on PATH; on a Windows
// host the wine wrapper is pointless, so callers should route to Local
// before reaching here.
static CRAFTER_API TestRunner Wine();
// Parse a `<kind>[:<arg>]` spec used by CRAFTER_BUILD_RUNNER_<target>
// and --runner=. Supported: "local", "cmd:<binary>". Returns nullopt
// for an empty string; throws on a non-empty unrecognized spec.
static CRAFTER_API std::optional<TestRunner> FromSpec(std::string_view spec);
// Honor CRAFTER_BUILD_RUNNER_<target> (power-user override). Triple
// dashes/dots become underscores so they're valid in env-var names.
// Returns `fallback` when the env var is unset.
static CRAFTER_API TestRunner FromEnv(std::string_view target, TestRunner fallback = Local());
// Derive a runner from a Configuration's target triple + sysroot.
// Returns Local() when target equals the host, Wine() for Windows
// targets on a non-Windows host, `qemu-<arch>` (with QEMU_LD_PREFIX
// set when cfg.sysroot is non-empty) for non-host -linux- triples,
// `wasmtime` for wasm32-wasi/wasm64-wasi, and Local() as a last
// resort. CRAFTER_BUILD_RUNNER_<target> still wins as an override
// upstream of this — see FromEnv.
static CRAFTER_API TestRunner ForTarget(const struct Configuration& cfg);
};
enum class TestOutcome { Pass, Fail, Crash, Timeout, Skipped };
struct TestResult {
std::string name;
TestOutcome outcome = TestOutcome::Pass;
std::int32_t exitCode = 0;
std::int32_t signal = 0;
std::chrono::milliseconds duration{0};
std::string output;
};
// One lint diagnostic. Produced by LintContext::Report — or derived by
// the driver when a transform rule's SetContent output differs from the
// file on disk ("would reformat") — collected and printed compiler-style
// by RunLint (Crafter.Build:Lint):
// <file>:<line>: warning: <message> [<rule>]
struct LintFinding {
fs::path file;
std::size_t line = 0; // 1-based; 0 = whole-file finding
std::string rule; // name of the rule that produced it
std::string message;
};
// Parsed `// lint-disable-*` suppression directives for one file (see
// LintContext::Suppressed). Line keys are 1-based and refer to the line a
// next-line directive TARGETS (the line after the comment).
struct LintSuppressions {
bool fileAll = false;
std::unordered_set<std::string> fileRules;
std::unordered_set<std::size_t> lineAll;
std::unordered_map<std::size_t, std::unordered_set<std::string>> lineRules;
};
// Per-file view handed to each LintRule's check callback. Every member
// function is out-of-line and CRAFTER_API (defined in Crafter.Build:Lint's
// implementation unit) because rule lambdas execute from the user's
// project DLL on Windows — clang does not emit module-attached in-class
// inline bodies into consumers (see ArgQuery below).
struct LintContext {
fs::path file; // absolute path of the file under lint
std::string content; // whole file as read from disk
std::vector<std::string_view> lines; // views into `content`, one per line, no '\n'
CRAFTER_API std::string Extension() const; // ".cppm", ".cpp", ".h", ...
CRAFTER_API std::string_view Line(std::size_t n) const; // 1-based; empty if out of range
// `content` with //-comments, /*...*/ comments and string/char literal
// bodies blanked to spaces, newlines preserved — offsets and line
// numbers stay valid. Built on first call, cached per file. Raw string
// literals are not recognized (v1 limitation).
CRAFTER_API const std::string& CommentStripped();
// Record a finding at `line` (1-based; pass 0 for a whole-file finding).
CRAFTER_API void Report(std::size_t line, std::string message);
// Replace the file's content. Makes this rule a *transform*:
// `crafter-build format` writes the result back to disk; `lint`
// derives would-reformat findings from it (dry — never writes).
// `lines` is re-split and CommentStripped() re-derives on next call;
// string_views taken before this call are invalidated. Recommended
// pattern: build the new string, call SetContent once at the end. Do
// not assign `content` directly — that bypasses the re-split. May be
// combined with Report() in the same rule.
CRAFTER_API void SetContent(std::string newContent);
// True when `rule` is suppressed at `line` (1-based; 0 = whole-file,
// which only file-level directives cover) by a suppression comment:
// // lint-disable-next-line [rules...] applies to the following line
// // lint-disable-file [rules...] applies to the whole file
// Rule names are space- or comma-separated; none = all rules. The
// driver already filters Report()
// findings and reverts line-preserving transform edits on suppressed
// lines; a transform that MERGES or SPLITS lines must consult this
// itself for every line its edit touches (the driver cannot map lines
// across a count-changing rewrite). Parsed lazily from the raw lines;
// re-parsed after SetContent.
CRAFTER_API bool Suppressed(std::string_view rule, std::size_t line);
// Driver wiring — set by RunLint before each check call. Not for rules.
std::string activeRule;
std::vector<LintFinding>* sink = nullptr;
std::optional<std::string> commentStrippedCache;
std::optional<LintSuppressions> suppressionsCache;
};
// A named lint rule: `check` runs once per (rule, file) over the project's
// own sources. Rules self-filter by ctx.Extension() / ctx.file. A rule
// that calls ctx.SetContent is a transform — defined once, it both gates
// `crafter-build lint` and fixes under `crafter-build format`.
struct LintRule {
std::string name;
std::function<void(LintContext&)> check;
};
// The host target triple, detected once per process by running
// `clang++ -print-target-triple` and cached. Used as the default for
// Configuration::target so projects don't have to hardcode it for the
// no-cross-compile case. Returns "" if clang isn't on PATH or doesn't
// print a triple — those projects still need an explicit cfg.target.
CRAFTER_API std::string HostTarget();
struct Configuration {
fs::path path;
std::string outputName;
std::string name;
std::string march = "native";
std::string mtune = "native";
std::string target = HostTarget();
std::string sysroot;
bool debug = false;
ConfigurationType type = ConfigurationType::Executable;
std::vector<std::unique_ptr<Module>> interfaces;
std::vector<Implementation> implementations;
std::vector<fs::path> cFiles;
std::vector<fs::path> cuda;
std::vector<Configuration*> dependencies;
std::vector<fs::path> files;
// Build-time-only source files this configuration exposes to its own
// and its consumers' shader compiles as glslang #include search
// paths. Each entry's parent directory (or the entry itself, if it's
// a directory) is added to the includer for every shader compiled in
// this configuration and in any configuration that transitively
// depends on it. Files are NOT copied — they're read in place from
// the dep's source tree, like C++ -I include dirs.
std::vector<fs::path> buildFiles;
std::vector<Define> defines;
std::vector<Shader> shaders;
// Source assets compressed via Crafter.Asset's SaveCompressed →
// .ctex/.cmesh in this configuration's bin dir.
//
// Each entry is either:
// - A single .png/.obj file. Output lands flat in the bin dir:
// bin/<filename>.ctex or bin/<filename>.cmesh.
// - A directory. The build recurses; .png/.obj are compressed
// with the relative tree mirrored under bin/<dirname>/, and
// every other file in the tree is copied through unchanged.
// Lets mod/map trees (mod.json + cannon/base.obj +
// cannon/color.png) keep their nested layout so JSON paths
// like "cannon/base.cmesh" resolve at runtime.
//
// Crafter.Asset must be reachable through cfg.dependencies (the
// build engine links its library API into crafter-build via a
// self-host pass). Forwarded to a consuming executable's bin dir
// alongside .spv shaders and cfg.files entries.
std::vector<fs::path> assets;
std::vector<ExternalDependency> externalDependencies;
std::vector<std::string> compileFlags;
std::vector<std::string> linkFlags;
// Browser-wasm feature-detected variants. Empty (the default) builds a
// single outputName.wasm with the baseline wasm flag set. Non-empty
// builds the baseline plus one outputName.<label>.wasm per entry, each
// recompiling the whole build graph with that entry's `flags`;
// EnableWasiBrowserRuntime emits a variants.json manifest the shipped
// runtime.js uses to pick the right variant per browser. Populate via
// EnableWasiRelaxedSimdVariant for the common case.
std::vector<WasmVariant> wasmVariants;
// Extra wasm codegen flags injected into this configuration's compile +
// link command for the active variant pass. Set across the whole build
// graph by the variant driver inside Build(); part of VariantId so each
// variant's objects/PCMs land in their own build+bin dir and never
// clobber the baseline's. Not for direct project use — declare
// wasmVariants instead.
std::vector<std::string> wasmVariantFlags;
std::vector<Test> tests;
// Lint rules for `crafter-build lint`. Populate via AddLintRule.
std::vector<LintRule> lintRules;
CRAFTER_API void GetInterfacesAndImplementations(std::span<fs::path> interfaces, std::span<fs::path> implementations);
// Retry the `import X;` names GetInterfacesAndImplementations could not
// place, against the dependency DAG as it stands now. Sources are
// scanned when they're declared, but `dependencies` is often assigned
// afterwards (AddTest does exactly this), and an import that resolved to
// nothing leaves no staleness edge — so a dependency's interface could
// change and this Configuration's objects would be silently reused
// against the new layout. Build() calls this before checking mtimes;
// calling it again is harmless.
CRAFTER_API void ResolvePendingImports();
// Declare a test. Sources default to `tests/<name>/main.cpp` resolved
// against this Configuration's path; target/march/mtune/sysroot/debug
// are inherited from this Configuration so cross-arch projects don't
// have to re-specify. Returns a builder for chaining defines, deps,
// etc. Defined in Crafter.Build:Test.
CRAFTER_API struct TestBuilder AddTest(std::string_view name);
// Same as AddTest, but compiles the parent's `interfaces` directly
// into this test's Configuration (rather than going through a dep).
// Use when the test must rebuild those interfaces with its own
// compile flags — typically per-march SIMD codegen.
CRAFTER_API struct TestBuilder AddTest(std::string_view name, std::span<fs::path> interfaces);
// Math-style fan-out: one Test per MarchTier, all sharing the same
// `tests/<name>/main.cpp` source and the same interface set, each
// compiled with the tier's `-march`/`-mtune`. Test names are
// `<name>-<march>`.
CRAFTER_API void AddMarchVariants(std::string_view name, std::span<fs::path> interfaces, std::span<const struct MarchTier> tiers);
// Register a lint rule for `crafter-build lint` / `format`. Rules
// registered on any Configuration whose path lies inside the project
// root are collected (deduplicated by name, root-first) — attach them
// to the lib or the exe config, either works. Rules that call
// ctx.SetContent are transforms (see LintContext::SetContent).
// Defined in Crafter.Build:Lint.
CRAFTER_API void AddLintRule(std::string name, std::function<void(LintContext&)> check);
// Suffix that uniquely identifies this Configuration's compile state.
// target+march+mtune are spelled out for readability; the rest
// (type, debug, sysroot, defines, compileFlags) collapse into a short
// hash so two Configurations sharing a path but with different
// compile state can't clobber each other's outputs.
std::string VariantId() const {
std::string compileKey;
compileKey += std::to_string(static_cast<std::int32_t>(type));
compileKey += '|';
compileKey += debug ? '1' : '0';
compileKey += '|';
compileKey += sysroot;
for (const Define& d : defines) {
compileKey += "|D:";
compileKey += d.name;
compileKey += '=';
compileKey += d.value;
}
for (const std::string& f : compileFlags) {
compileKey += "|F:";
compileKey += f;
}
// Wasm variant codegen flags perturb every object/PCM, so they must
// key into a distinct build+bin dir from the baseline (and from
// each other) to avoid clobbering.
for (const std::string& f : wasmVariantFlags) {
compileKey += "|W:";
compileKey += f;
}
std::size_t configHash = std::hash<std::string>{}(compileKey);
return std::format("{}-{}-{}-{}-{:08x}", name, target, march, mtune, configHash);
}
fs::path BuildDir() const { return path / "build" / VariantId(); }
fs::path BinDir() const { return path / "bin" / VariantId(); }
fs::path PcmDir() const {
return type == ConfigurationType::Executable ? BuildDir() : BinDir();
}
};
struct Test {
Configuration config;
TestRunner runner;
std::chrono::seconds timeout{60};
std::vector<std::string> args;
// Declarative preconditions. Each entry is "tool:<name>",
// "file:<path>", or "env:<VAR>". Evaluated before the test runs; any
// unmet require turns the test into a Skip with a derived reason.
// Also doubles as the "I know this runner might not be here" opt-in:
// when the test's derived runner needs a tool (e.g. qemu-aarch64,
// wasmtime, wine) and the matching tool: entry isn't present, an
// unavailable runner becomes a Fail instead of a silent Skip — the
// dependency has to be declared to be allowed to be missing.
std::vector<std::string> requires_;
};
CRAFTER_API BuildResult Build(Configuration& config, std::unordered_map<fs::path, std::shared_future<BuildResult>>& depResults, std::mutex& depMutex);
CRAFTER_API int Run(int argc, char** argv);
// Add a small index.html + runtime.js pair next to the .wasm output so the
// build can be loaded directly in a browser (just `serve` the bin dir and
// open it). Opt-in: WASI builds destined for wasmtime/wasmer don't need
// this and shouldn't carry the extra files. Call from project.cpp after
// outputName is set; index.html is generated against the current
// outputName so renaming the binary later requires another call.
CRAFTER_API void EnableWasiBrowserRuntime(Configuration& cfg);
// Register the relaxed-SIMD codegen variant on cfg. Builds an additional
// outputName.relaxed-simd.wasm compiled with -mrelaxed-simd (FMA,
// dot-product, relaxed swizzle/laneselect, …); the shipped runtime serves
// it only to engines that report relaxed-SIMD support (Chrome 114+, Firefox
// 120+) and falls back to the baseline outputName.wasm everywhere else
// (e.g. Safari, which still gates relaxed SIMD behind a flag as of mid
// 2026). Idempotent. Call before EnableWasiBrowserRuntime so the emitted
// variants.json manifest includes it. A no-op for non-wasm targets at build
// time (no wasm is produced), but harmless to declare unconditionally.
CRAFTER_API void EnableWasiRelaxedSimdVariant(Configuration& cfg);
// View over the project's args with simple query helpers. Has(flag) for
// boolean switches; Get(prefix) for valued options (e.g. Get("--prefix=")
// returns the substring after the equals). Definitions are out-of-line
// and CRAFTER_API so they cross the Windows DLL boundary cleanly — clang
// does not emit module-attached in-class inline bodies into consumers.
struct ArgQuery {
std::span<const std::string_view> args;
CRAFTER_API bool Has(std::string_view flag) const;
CRAFTER_API std::optional<std::string> Get(std::string_view prefix) const;
};
// Apply the framework's standard CLI args + env vars onto cfg:
// --debug cfg.debug = true
// --target=<triple> cfg.target = <triple>
// --march=<value> cfg.march = <value>
// --mtune=<value> cfg.mtune = <value>
// --lib cfg.type promoted Executable → LibraryStatic
// --shared cfg.type promoted LibraryStatic → LibraryDynamic
// Promotions chain in priority order so `--lib --shared` lands on
// LibraryDynamic regardless of arg order; each is a no-op when the
// baseline doesn't match (e.g. --shared on an Executable, or --lib on a
// pre-set library).
// $CRAFTER_BUILD_MARCH / $CRAFTER_BUILD_MTUNE seed march/mtune.
// Env applies first, then args, so CLI wins over env wins over caller's
// pre-set defaults. Returns an ArgQuery over the same span so projects
// can query their own flags (`--timing`, ...) without re-rolling the
// for-arg-in-args loop.
CRAFTER_API ArgQuery ApplyStandardArgs(Configuration& cfg, std::span<const std::string_view> args);
}