diff --git a/AGENTS.md b/AGENTS.md index 2fa2db0..d6be6d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,7 +46,7 @@ Prerequisites on Linux: `clang` (with `libclang`, which the AST lint rules load Three things to know about this loop: - `bin/crafter-build` is the bootstrap binary and the harness for every command below. The self-host build writes to `bin/crafter.build-exe-/crafter-build` and does not replace it. -- Changes under `implementations/` are picked up by the self-host build and by the tests, which link the local library. Changes under `interfaces/` are not: the harness compiles against the copies in `share/crafter-build/` and the PCM cache built from them, so a new or changed declaration is invisible until you run `./build.sh` again. The symptom is `use of undeclared identifier` for the symbol you just added. +- Changes under `implementations/` are picked up by the self-host build and by the tests, which link the local library. Changes under `interfaces/` are not: the harness compiles against the copies in `share/crafter-build/` and the PCM cache built from them, so a new or changed declaration is invisible until you run `./build.sh` again. The symptom is `use of undeclared identifier` for the symbol you just added. A changed default member initialiser has no symptom at all: `project.cpp` compiles against the old one and quietly gets the old default, as `project.cpp`'s sibling loads do with a harness that predates `GitProjectSpec::args = ProjectArgs()`. - A new exported symbol that a `project.cpp` reaches on its default path also needs `./build.sh`: the old binary does not export it, and `LoadProject` fails with an undefined symbol. `./build.sh` clones and builds glslang on first run and takes several minutes; later runs are faster. `./bin/crafter-build clean` deletes `bin/` and `build/` without loading `project.cpp`. That takes `bin/crafter-build` itself with it, and `build/flatpak`, the state of the local Flatpak build (see "CI and publishing"): about 2 GB, and the next `run flatpak` rebuilds LLVM. Set `CRAFTER_BUILD_HOME` only when running a binary from outside this tree; `CRAFTER_BUILD_MARCH` and `CRAFTER_BUILD_MTUNE` default to `native`. @@ -96,14 +96,17 @@ Cross-target runs use `test --target=` with `--runner=local` or `--runne ## Code style -Style is enforced by the 17 rules in [Crafter.LintRules](https://forgejo.catcrafts.net/Catcrafts/Crafter.LintRules), which `project.cpp` pulls in through `#pragma crafter dependency` and applies with `crafter-build format` and `lint`. Most rules are transforms, so run `format` and let it restyle rather than hand-formatting. In short: +`project.cpp` pulls in [Crafter.LintRules](https://forgejo.catcrafts.net/Catcrafts/Crafter.LintRules) through `#pragma crafter dependency` and applies its lite set, `LintRules::AddLiteRules`: the 8 rules that ask for different code, without the ones that only decide layout, whitespace and naming. `lint` reports all 8; `format` applies the two transforms among them, `format-concat` and `fixed-width-types`, and leaves everything else as written. Enforced: - The first three lines of every source: `// SPDX-License-Identifier: LGPL-3.0-only`, `// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®`, blank. Examples use MIT. +- `enum class` only. `std::println` instead of iostream. `std::format` instead of string concatenation. `std::int32_t` and friends instead of `int` or `long` (exempt: `main`, `argc`/`argv`, `extern "C"` and foreign API signatures). No `char*` outside C interop. +- `const` on scalar locals that are never written; `constexpr` on constants with constant initialisers. + +Not checked, so match the surrounding code: + - Four-space indentation, no tabs, no trailing whitespace, final newline. - `PascalCase` for types, functions, statics and constants; `camelCase` for variables; a trailing `_` is allowed. -- `enum class` only. `std::println` instead of iostream. `std::format` instead of string concatenation. `std::int32_t` and friends instead of `int` or `long` (exempt: `main`, `argc`/`argv`, `extern "C"` and foreign API signatures). No `char*` outside C interop. -- One declaration per statement. K&R braces. `if (x) return;` on one line when it fits. The formatter joins wrapped statements that fit on one line, so do not hand-wrap short ones. `if (` and `for (` with a space; no space before a call's `(`. -- `const` on scalar locals that are never written; `constexpr` on constants with constant initialisers. +- One declaration per statement. K&R braces. `if (x) return;` on one line when it fits, and no hand-wrapping of a statement that fits on one line. `if (` and `for (` with a space; no space before a call's `(`. Suppress one finding with `// lint-disable-next-line ` or a whole file with `// lint-disable-file `. Both are rare in this tree and need a reason. diff --git a/README.md b/README.md index 73c9d36..8edf06a 100644 --- a/README.md +++ b/README.md @@ -69,12 +69,15 @@ Clones live at `~/.cache/crafter.build/external/-/`, keyed by `(url, ```cpp Configuration* math = GitProject({ .source = { .url = "https://forgejo.catcrafts.net/Catcrafts/Crafter.Math.git" }, // .branch or .commit to pin - .args = depArgs, // forwarded to its CrafterBuildProject }); -Configuration* sibling = LocalProject({ .projectFile = "../Crafter.Math/project.cpp", .args = depArgs }); +Configuration* sibling = LocalProject({ .projectFile = "../Crafter.Math/project.cpp" }); cfg.dependencies = { math }; ``` +The sibling's `CrafterBuildProject` gets the args this `project.cpp` was loaded with (`ProjectArgs()`), so `--target=`, `--march=`, `--debug` and the project's own flags reach every sibling down the chain. That is all of them: `cfg.projectArgs` holds only what `ApplyStandardArgs` did not interpret, and passing it on would drop the target and the build type. Set `.args` to send something else: `{}` for nothing, or a list built from the project's args, such as one without a flag only this project reads. Build such a list once and give it to every sibling. A project that two paths load with different lists is built twice, and the program ends up with both builds. Releases up to 2.2.0 forward nothing when `.args` is left out. + +The default is read when the spec is made, so make it inside `CrafterBuildProject`, on the thread that calls it. A spec made at namespace scope, on a thread of your own or in a script forwards nothing. + Clones share the external cache and identical specs share one `Configuration`. An unpinned source is `git pull`ed on every invocation, set `.commit` for reproducible, offline-after-first-fetch builds. A project loaded this way runs its `CrafterBuildProject` as a dependency, and `IsTopLevelProject()` is `false` while it does (`true` in the project crafter-build was pointed at, and outside any project file). Wire test-only dependencies behind it: a library whose test links crafter-build itself would otherwise load crafter-build's `project.cpp` from inside crafter-build and recurse. @@ -94,6 +97,8 @@ import Crafter.LintRules; #pragma crafter dependency "" [branch "" | commit ""] [project ""] ``` +Such a dependency is built for the host, whatever the project targets, and its `project.cpp` gets none of the project's args. + ### Dependency notes for agents An agent working on a project reads its `AGENTS.md`, which says nothing about the libraries the project builds against unless someone copied that in by hand. A library ships it instead: an `AGENTS.consumer.md` beside its `project.cpp`, written for whoever uses the library rather than whoever works on it. When a consumer has an `AGENTS.md` beside its own `project.cpp`, `crafter-build` and `crafter-build test` keep its dependencies' notes in a section of it: diff --git a/implementations/Crafter.Build-Clang.cpp b/implementations/Crafter.Build-Clang.cpp index ff300bd..6041c8b 100644 --- a/implementations/Crafter.Build-Clang.cpp +++ b/implementations/Crafter.Build-Clang.cpp @@ -1652,6 +1652,10 @@ Build options: -v, --verbose Verbose progress output. -q, --quiet Suppress progress output. --project= Path to the project file (default: ./project.cpp). + -- Ends this tool's options. Every later word is a + project arg, even one that looks like an option or + a verb; after test, lint or format, a glob; after + run, the script's name and then its args. Test options (after the `test` subcommand): --list Enumerate matching tests without running them. @@ -1770,6 +1774,9 @@ std::int32_t Crafter::Run(std::int32_t argc, char** argv) { RunLintOptions lintOpts; RunScriptOptions scriptOpts; Progress::Verbosity verbosity = Progress::Verbosity::Default; + // Set by `--`: every later word is an operand of the verb, never an + // option or a verb of this tool. + bool operandsOnly = false; for (std::int32_t i = 1; i < argc; ++i) { std::string_view arg = argv[i]; @@ -1781,11 +1788,25 @@ std::int32_t Crafter::Run(std::int32_t argc, char** argv) { // variant hash and so the directory the project builds into. if (scriptOpts.named) { scriptOpts.args.emplace_back(arg); - } else if (runScript && !arg.starts_with("-")) { + } else if (runScript && (operandsOnly || !arg.starts_with("-"))) { // The first word after `run`, even one that is a verb // elsewhere. Options before it keep their usual meaning. scriptOpts.name = arg; scriptOpts.named = true; + } else if (operandsOnly) { + if (runTests) { + testOpts.globs.emplace_back(arg); + } else if (runLint || runFormat) { + lintOpts.globs.emplace_back(arg); + } else { + projectArgs.push_back(arg); + } + } else if (arg == "--") { + // Ends this tool's options and is never forwarded itself. It + // used to land in projectArgs as a word of its own, giving the + // project a variant hash of its own, and every sibling project + // that inherits the args one as well. + operandsOnly = true; } else if (arg == "--version") { // Answered here, before the project file is looked for, so // it works in any directory and is never forwarded to diff --git a/implementations/Crafter.Build-External.cpp b/implementations/Crafter.Build-External.cpp index 393ccde..9faf8dc 100644 --- a/implementations/Crafter.Build-External.cpp +++ b/implementations/Crafter.Build-External.cpp @@ -394,11 +394,12 @@ Configuration* Crafter::GitProject(const GitProjectSpec& spec) { // source revision (forwarded args don't change what gets cloned, just // how the dep's project.cpp interprets them at runtime), while the // in-memory Configuration is keyed by the full spec so each (args) - // permutation gets its own Configuration object. + // permutation gets its own Configuration object, and host tooling one + // apart from the target's even for the same args. std::string srcKey = std::format("git|{}|{}|{}|{}", spec.source.url, spec.source.branch, spec.source.commit, spec.projectFile.string()); std::string srcHash = std::format("{:016x}", std::hash{}(srcKey)); - std::string fullKey = srcKey; + std::string fullKey = std::format("{}{}", LoadingHostTooling() ? "host|" : "", srcKey); for (const std::string& a : spec.args) { fullKey += '|'; fullKey += a; @@ -482,7 +483,7 @@ Configuration* Crafter::LocalProject(const LocalProjectSpec& spec) { } fs::path canonical = fs::canonical(projectPath); - std::string fullKey = std::format("local|{}", canonical.string()); + std::string fullKey = std::format("{}local|{}", LoadingHostTooling() ? "host|" : "", canonical.string()); for (const std::string& a : spec.args) { fullKey += '|'; fullKey += a; diff --git a/implementations/Crafter.Build-Platform.cpp b/implementations/Crafter.Build-Platform.cpp index d54831a..5f7460c 100644 --- a/implementations/Crafter.Build-Platform.cpp +++ b/implementations/Crafter.Build-Platform.cpp @@ -413,6 +413,11 @@ namespace { struct ProjectFileFrame { fs::path project; + // What LoadProject was given, for ProjectArgs(), and only once + // CrafterBuildProject is about to be called (see LoadProject). A view + // is enough: the caller's storage outlives LoadProject, and the frame + // is popped before LoadProject returns. + std::span args; std::vector dependencies; }; @@ -441,7 +446,7 @@ namespace { throw std::runtime_error(std::format("circular project dependency: {}{}", chain, absProject.string())); } index_ = LoadingFrames.size(); - LoadingFrames.push_back({ absProject, {} }); + LoadingFrames.push_back({ absProject, {}, {} }); } ~ProjectFileGuard() { LoadingFrames.pop_back(); } ProjectFileGuard(const ProjectFileGuard&) = delete; @@ -453,13 +458,29 @@ namespace { std::size_t index_ = 0; }; + // How many `#pragma crafter dependency` loads this thread is inside. The + // tool's project file runs within its load, and so does every sibling it + // loads in turn, which is what LoadingHostTooling() reports to GitProject + // and LocalProject. Per thread like LoadingFrames: a sibling loaded on a + // thread of the tool's own goes uncounted, as it gets no ProjectArgs(). + thread_local std::int32_t HostToolingLoads = 0; + + class HostToolingScope { + public: + HostToolingScope() { ++HostToolingLoads; } + ~HostToolingScope() { --HostToolingLoads; } + HostToolingScope(const HostToolingScope&) = delete; + HostToolingScope& operator=(const HostToolingScope&) = delete; + }; + // A pre-dependency is host tooling: project.so is loaded into the running // crafter-build, so the dependency is built for the host and against the // same PCM cache LoadProject populates — the hostConfig LoadProject itself // compiles project.so with, so the dependency's std/Crafter.Build PCMs // land in the cache dir project.so is built against. Recursive, because - // the dependency's project.cpp forwards no arguments but still reads - // CRAFTER_BUILD_MARCH from the environment for its own deps. + // the dependency's project.cpp is loaded with no arguments and so passes + // none on, while its own deps still read CRAFTER_BUILD_MARCH from the + // environment. void ForceHostTooling(Configuration& cfg, const Configuration& host, std::unordered_set& seen) { if (!seen.insert(&cfg).second) return; cfg.target = host.target; @@ -502,24 +523,36 @@ namespace { std::set libs; for (const PragmaDependency& pragma : pragmas) { + // Neither spec sets args, and the tool still gets none of the + // consumer's: this pre-pass runs before LoadProject hands the + // frame its args, so ProjectArgs() is empty. Given the consumer's + // --target=, the tool's project.cpp would pick sources and flags + // for the wrong triple, and its flags would split the tool's + // variant from the one every other consumer shares. The scope + // keeps the tool and everything it loads in a project cache of + // their own: ForceHostTooling rewrites them in place, and a + // consumer loading the same sibling for itself got that host + // build back whenever the two cache keys matched. Configuration* cfg = nullptr; - if (IsGitSource(pragma.source)) { - GitProjectSpec spec; - spec.source = { pragma.source, pragma.branch, pragma.commit }; - if (!pragma.project.empty()) spec.projectFile = pragma.project; - cfg = GitProject(spec); - } else { - if (!pragma.branch.empty() || !pragma.commit.empty()) PragmaError(absProject, pragma.line, "'branch' and 'commit' only apply to a git source"); - // Relative to the project file, not the cwd: LoadProject runs - // from wherever the consumer was invoked. - fs::path local = (absProject.parent_path() / pragma.source).lexically_normal(); - if (fs::is_directory(local)) { - local /= pragma.project.empty() ? fs::path("project.cpp") : fs::path(pragma.project); - } else if (!pragma.project.empty()) { - PragmaError(absProject, pragma.line, "'project' only applies to a directory source"); + { + HostToolingScope hostTooling; + if (IsGitSource(pragma.source)) { + GitProjectSpec spec{ .source = { pragma.source, pragma.branch, pragma.commit } }; + if (!pragma.project.empty()) spec.projectFile = pragma.project; + cfg = GitProject(spec); + } else { + if (!pragma.branch.empty() || !pragma.commit.empty()) PragmaError(absProject, pragma.line, "'branch' and 'commit' only apply to a git source"); + // Relative to the project file, not the cwd: LoadProject runs + // from wherever the consumer was invoked. + fs::path local = (absProject.parent_path() / pragma.source).lexically_normal(); + if (fs::is_directory(local)) { + local /= pragma.project.empty() ? fs::path("project.cpp") : fs::path(pragma.project); + } else if (!pragma.project.empty()) { + PragmaError(absProject, pragma.line, "'project' only applies to a directory source"); + } + if (!fs::exists(local)) PragmaError(absProject, pragma.line, std::format("no such project file: {}", local.string())); + cfg = LocalProject({ .projectFile = local }); } - if (!fs::exists(local)) PragmaError(absProject, pragma.line, std::format("no such project file: {}", local.string())); - cfg = LocalProject({ .projectFile = local }); } if (std::ranges::contains(out.configs, cfg)) continue; @@ -596,6 +629,16 @@ std::vector Crafter::PragmaDependencies(const fs::path& projectF return {}; } +std::vector Crafter::ProjectArgs() { + if (LoadingFrames.empty()) return {}; + const std::span args = LoadingFrames.back().args; + return std::vector(args.begin(), args.end()); +} + +bool Crafter::LoadingHostTooling() { + return HostToolingLoads > 0; +} + namespace { // The one reading of a wait status (pclose, system) for every helper // that runs a command: a signal death is reported the way a shell @@ -971,6 +1014,13 @@ Configuration Crafter::LoadProject(const fs::path& projectFile, std::span projectArgs; std::vector tests; // Lint rules for `crafter-build lint`. Populate via AddLintRule. diff --git a/interfaces/Crafter.Build-External.cppm b/interfaces/Crafter.Build-External.cppm index c7eb495..4ba3014 100644 --- a/interfaces/Crafter.Build-External.cppm +++ b/interfaces/Crafter.Build-External.cppm @@ -71,31 +71,56 @@ export namespace Crafter { // clone can still differ in includeDirs, libDirs and libs. ExternalBuildResult ExternalOutputs(const ExternalDependency& dep, std::string_view target); + // The args the project file now running its CrafterBuildProject was + // loaded with: everything that function receives, --target=, --debug and + // the other standard flags included. Not cfg.projectArgs, which holds + // only what ApplyStandardArgs left uninterpreted: `.args = + // cfg.projectArgs` would drop the target and the build type. Empty + // everywhere else, because it reads the stack LoadProject keeps per + // thread, as IsTopLevelProject does: outside a project file, on any + // thread but the one running it, once CrafterBuildProject has returned + // (a script runs later), and before it is called, which covers the + // file's static initialisers and its `#pragma crafter dependency` + // pre-pass. The specs below default their args to it. Defined beside + // LoadProject, but declared here so those defaults can see it: importing + // :Platform into this partition would make the exported `struct + // Configuration;` above a redeclaration of :Platform's unexported one, + // which clang rejects. + std::vector ProjectArgs(); + // Specification for a sibling crafter-build project to fetch and depend on. // GitSource picks the revision: leave branch + commit empty for the // remote's default branch HEAD (sloppy but convenient), set branch to // track a branch tip, or set commit to pin to an immutable SHA. args are - // forwarded verbatim to the dep's CrafterBuildProject — typically you - // pass through the parent's args so target/march/debug propagate. + // what the dep's CrafterBuildProject receives. They default to this + // project's own, read when the spec is made, so --target=, --march=, + // --debug and the project's own flags reach every sibling down a chain + // without being passed. Set them to forward something else: {} for + // nothing, or a list built from this project's args, such as one without + // a flag only this project reads. Give such a list to every sibling: a + // project that two paths load with two different lists is built twice, + // and the program ends up with both builds. struct GitProjectSpec { GitSource source; fs::path projectFile = "project.cpp"; - std::vector args; + std::vector args = ProjectArgs(); }; - // Clones the spec's git URL into the per-project external cache (keyed by - // url+branch+commit+args+projectFile), recursively LoadProject's the - // remote's project.cpp, and returns a stable Configuration* you can drop - // into cfg.dependencies. The Configuration is owned by an internal cache - // for the lifetime of the build; identical specs share one Configuration. + // Clones the spec's git URL into the external cache, recursively + // LoadProject's the remote's project.cpp, and returns a stable + // Configuration* you can drop into cfg.dependencies. The clone is keyed by + // url+branch+commit+projectFile alone, so every arg list builds in the + // same working tree, each into variant directories of its own. The + // Configuration is owned by an internal cache for the lifetime of the + // build, keyed by the clone and the args: identical specs share one. Configuration* GitProject(const GitProjectSpec& spec); // Specification for a local sibling crafter-build project (e.g. an // example folder depending on its parent project, or a workspace where // multiple projects live side-by-side without going through git). struct LocalProjectSpec { - fs::path projectFile; // relative to cwd or absolute - std::vector args; // forwarded to dep's CrafterBuildProject + fs::path projectFile; // relative to cwd or absolute + std::vector args = ProjectArgs(); // as GitProjectSpec::args }; // Same as GitProject but for a local on-disk project — no fetch, no diff --git a/interfaces/Crafter.Build-Platform.cppm b/interfaces/Crafter.Build-Platform.cppm index 8710ac8..32b230b 100644 --- a/interfaces/Crafter.Build-Platform.cppm +++ b/interfaces/Crafter.Build-Platform.cppm @@ -82,6 +82,12 @@ namespace Crafter { // dependency notes of the project crafter-build was pointed at are // gathered (Crafter.Build:Agents). std::vector PragmaDependencies(const fs::path& projectFile); + // True while this thread loads a `#pragma crafter dependency`: the tool's + // project file and every sibling it loads in turn. GitProject and + // LocalProject key the project cache on it, so host tooling, which + // ForceHostTooling rewrites in place, never shares a Configuration with a + // load for the target. + bool LoadingHostTooling(); // Resolves the directory holding distributed runtime assets (Crafter.Build // module sources, wasi-runtime/, etc). Honors CRAFTER_BUILD_HOME; otherwise // derives /share/crafter-build from the running executable's path. diff --git a/project.cpp b/project.cpp index 7e69ee7..45f74a0 100644 --- a/project.cpp +++ b/project.cpp @@ -137,15 +137,8 @@ Remove it (do this before `crafter-build clean`, which deletes the repo the remo } extern "C" Configuration CrafterBuildProject(std::span args) { - std::vector depArgs(args.begin(), args.end()); - Configuration* math = GitProject({ - .source = { "https://forgejo.catcrafts.net/Catcrafts/Crafter.Math.git" }, - .args = depArgs, - }); - Configuration* asset = GitProject({ - .source = { "https://forgejo.catcrafts.net/Catcrafts/Crafter.Asset.git" }, - .args = depArgs, - }); + Configuration* math = GitProject({ .source = { "https://forgejo.catcrafts.net/Catcrafts/Crafter.Math.git" } }); + Configuration* asset = GitProject({ .source = { "https://forgejo.catcrafts.net/Catcrafts/Crafter.Asset.git" } }); static auto CrafterBuildLib = std::make_unique(); CrafterBuildLib->path = "./"; @@ -289,6 +282,10 @@ extern "C" Configuration CrafterBuildProject(std::span a .LinkFlag("-Wl,--export-dynamic").LinkFlag("-ldl"); cfg.AddTest("ProjectDependency").Dependencies({ CrafterBuildLib.get() }) .LinkFlag("-Wl,--export-dynamic").LinkFlag("-ldl"); + cfg.AddTest("ProjectArgs").Dependencies({ CrafterBuildLib.get() }) + .LinkFlag("-Wl,--export-dynamic").LinkFlag("-ldl"); + cfg.AddTest("HostToolingSibling").Dependencies({ CrafterBuildLib.get() }) + .LinkFlag("-Wl,--export-dynamic").LinkFlag("-ldl"); cfg.AddTest("Script").Dependencies({ CrafterBuildLib.get() }) .LinkFlag("-Wl,--export-dynamic").LinkFlag("-ldl"); cfg.AddTest("DependencyNotes").Dependencies({ CrafterBuildLib.get() }) @@ -297,10 +294,14 @@ extern "C" Configuration CrafterBuildProject(std::span a cfg.AddTest("Lint").Dependencies({ CrafterBuildLib.get() }); } - // Dogfood: the house-style rules, imported from their own repo through - // the pragma above. Report rules gate `crafter-build lint`; transform - // rules also auto-fix under `crafter-build format`. - LintRules::AddRules(cfg); + // Dogfood: the lite house rules, imported from their own repo through + // the pragma above. Lite is the house rules without the ones that only + // decide layout, whitespace and naming, which this tree keeps by hand: + // the full set's naming rule, for one, wants camelCase where this tree + // spells its statics and constants PascalCase. Report rules gate + // `crafter-build lint`; the two transforms also auto-fix under + // `crafter-build format`. + LintRules::AddLiteRules(cfg); // See the top of this file. cfg.scripts.push_back({"flatpak", BuildFlatpak}); diff --git a/tests/HostToolingSibling/fixture/app/project.cpp b/tests/HostToolingSibling/fixture/app/project.cpp new file mode 100644 index 0000000..d9f107a --- /dev/null +++ b/tests/HostToolingSibling/fixture/app/project.cpp @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +import std; +import Crafter.Build; +#pragma crafter dependency "../tool" +using namespace Crafter; + +// Loads for itself the sibling its tool loads, by the same path and, when +// the app gets no args, with the same args. +extern "C" Configuration CrafterBuildProject(std::span args) { + Configuration* shared = LocalProject({ .projectFile = "../shared/project.cpp" }); + + Configuration cfg; + cfg.path = "./"; + cfg.name = "app"; + cfg.outputName = "app"; + cfg.type = ConfigurationType::Executable; + ApplyStandardArgs(cfg, args); + cfg.dependencies = { shared, ProjectDependency("tool") }; + return cfg; +} diff --git a/tests/HostToolingSibling/fixture/shared/interfaces/Shared.cppm b/tests/HostToolingSibling/fixture/shared/interfaces/Shared.cppm new file mode 100644 index 0000000..af40390 --- /dev/null +++ b/tests/HostToolingSibling/fixture/shared/interfaces/Shared.cppm @@ -0,0 +1,5 @@ +export module Shared; +import std; +export std::int32_t SharedValue() { + return 7; +} diff --git a/tests/HostToolingSibling/fixture/shared/project.cpp b/tests/HostToolingSibling/fixture/shared/project.cpp new file mode 100644 index 0000000..3cd5bdc --- /dev/null +++ b/tests/HostToolingSibling/fixture/shared/project.cpp @@ -0,0 +1,23 @@ +// 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; + +// The sibling both the tool and the app load. ApplyStandardArgs on purpose: +// it reads CRAFTER_BUILD_MARCH from the environment, which is how the test +// tells the app's copy from the one built as host tooling. +extern "C" Configuration CrafterBuildProject(std::span args) { + Configuration cfg; + cfg.path = "./"; + cfg.name = "shared"; + cfg.outputName = "shared"; + cfg.type = ConfigurationType::LibraryStatic; + ApplyStandardArgs(cfg, args); + std::array ifaces = { "interfaces/Shared" }; + std::array impls = {}; + cfg.GetInterfacesAndImplementations(ifaces, impls); + return cfg; +} diff --git a/tests/HostToolingSibling/fixture/tool/interfaces/Tool.cppm b/tests/HostToolingSibling/fixture/tool/interfaces/Tool.cppm new file mode 100644 index 0000000..c6d5490 --- /dev/null +++ b/tests/HostToolingSibling/fixture/tool/interfaces/Tool.cppm @@ -0,0 +1,6 @@ +export module Tool; +import std; +import Shared; +export std::int32_t ToolValue() { + return SharedValue(); +} diff --git a/tests/HostToolingSibling/fixture/tool/project.cpp b/tests/HostToolingSibling/fixture/tool/project.cpp new file mode 100644 index 0000000..51ad781 --- /dev/null +++ b/tests/HostToolingSibling/fixture/tool/project.cpp @@ -0,0 +1,24 @@ +// 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; + +// The app's `#pragma crafter dependency`, with a sibling of its own. +extern "C" Configuration CrafterBuildProject(std::span args) { + Configuration* shared = LocalProject({ .projectFile = "../shared/project.cpp" }); + + Configuration cfg; + cfg.path = "./"; + cfg.name = "tool"; + cfg.outputName = "tool"; + cfg.type = ConfigurationType::LibraryStatic; + ApplyStandardArgs(cfg, args); + cfg.dependencies = { shared }; + std::array ifaces = { "interfaces/Tool" }; + std::array impls = {}; + cfg.GetInterfacesAndImplementations(ifaces, impls); + return cfg; +} diff --git a/tests/HostToolingSibling/main.cpp b/tests/HostToolingSibling/main.cpp new file mode 100644 index 0000000..f6ad160 --- /dev/null +++ b/tests/HostToolingSibling/main.cpp @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +#include +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +// A sibling that a project and its `#pragma crafter dependency` both load +// is two configurations: the tool's copy is host tooling, built for the +// host and position-independent, and the project's is built the way the +// project builds. ForceHostTooling rewrites the tool's graph in place, and +// the project cache used to hand the project that same rewritten +// Configuration whenever the two loads had the same key, as they do when +// the project gets no args. The project then built against a sibling +// compiled with -march=native while itself building for x86-64-v2, and +// clang refused the sibling's BMI. +namespace { + std::int32_t Failures = 0; + + void Check(bool cond, std::string_view msg) { + if (!cond) { + std::println(std::cerr, "FAIL: {}", msg); + ++Failures; + } + } +} + +int main() { + const fs::path installedHome = GetCrafterBuildHome(); + if (!fs::exists(installedHome / "Crafter.Build.cppm")) { + std::println(std::cerr, "SKIP: no module sources at {}", installedHome.string()); + return 77; + } + + // Staged because LoadProject drops a build/ tree next to every project + // file it compiles, and the pre-pass builds the tool and its sibling. + const fs::path fixture = fs::current_path() / "tests" / "HostToolingSibling" / "fixture"; + const fs::path scratch = fs::temp_directory_path() / "crafter-build-host-tooling-sibling"; + std::error_code ec; + fs::remove_all(scratch, ec); + fs::create_directories(scratch); + fs::copy(fixture, scratch, fs::copy_options::recursive); + + // The march the project builds for. Host tooling overrides it to + // native, so a sibling that comes back with native was rewritten. + setenv("CRAFTER_BUILD_MARCH", "x86-64-v2", 1); + + // From the app's own directory, as crafter-build runs it: the fixture's + // LocalProject paths are relative to the working directory. + const fs::path cwd = fs::current_path(); + fs::current_path(scratch / "app"); + std::array noArgs = {}; + try { + Configuration cfg = LoadProject("project.cpp", noArgs); + Check(cfg.dependencies.size() == 2, std::format("the app declared its sibling and its tool (got {})", cfg.dependencies.size())); + if (cfg.dependencies.size() == 2) { + const Configuration* shared = cfg.dependencies[0]; + const Configuration* tool = cfg.dependencies[1]; + const Configuration* toolShared = tool->dependencies.size() == 1 ? tool->dependencies[0] : nullptr; + Check(toolShared != nullptr, "the tool declared its sibling"); + Check(shared != toolShared, "the app's sibling is not the tool's copy of it"); + Check(shared->march == "x86-64-v2", std::format("the app's sibling keeps the app's march (got {})", shared->march)); + Check(!std::ranges::contains(shared->compileFlags, "-fPIC"), "the app's sibling is not built as host tooling"); + if (toolShared) { + Check(toolShared->march == "native", std::format("the tool's copy is built for the host (got {})", toolShared->march)); + Check(std::ranges::contains(toolShared->compileFlags, "-fPIC"), "the tool's copy is position-independent"); + } + } + } catch (const std::exception& e) { + fs::current_path(cwd); + std::println(std::cerr, "FAIL: LoadProject threw: {}", e.what()); + return 1; + } + fs::current_path(cwd); + + return Failures > 0 ? 1 : 0; +} diff --git a/tests/ProjectArgs/fixture/app/project.cpp b/tests/ProjectArgs/fixture/app/project.cpp new file mode 100644 index 0000000..7b51816 --- /dev/null +++ b/tests/ProjectArgs/fixture/app/project.cpp @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +import std; +import Crafter.Build; +using namespace Crafter; + +// Loads the same sibling three ways: with its args left at their default, +// with nothing forwarded, and with a list of its own. Then a sibling that +// makes its own sibling's spec at namespace scope. The test reads what each +// copy received back out of cfg.dependencies. +extern "C" Configuration CrafterBuildProject(std::span args) { + Configuration* inherited = LocalProject({ .projectFile = "../lib/project.cpp" }); + Configuration* none = LocalProject({ .projectFile = "../lib/project.cpp", .args = {} }); + Configuration* replaced = LocalProject({ .projectFile = "../lib/project.cpp", .args = { "--only" } }); + Configuration* fromStatic = LocalProject({ .projectFile = "../static/project.cpp" }); + + Configuration cfg; + cfg.path = "./"; + cfg.name = "app"; + cfg.outputName = "app"; + cfg.type = ConfigurationType::Executable; + ApplyStandardArgs(cfg, args); + cfg.dependencies = { inherited, none, replaced, fromStatic }; + // GitProjectSpec takes the same default. Checked on the spec itself, + // since a GitProject would need a repository to clone. + const GitProjectSpec git{ .source = { .url = "unused" } }; + const bool gitDefault = git.args == std::vector(args.begin(), args.end()); + cfg.compileFlags.push_back(gitDefault ? "-DGIT_DEFAULT=1" : "-DGIT_DEFAULT=0"); + return cfg; +} diff --git a/tests/ProjectArgs/fixture/leaf/project.cpp b/tests/ProjectArgs/fixture/leaf/project.cpp new file mode 100644 index 0000000..15b16d4 --- /dev/null +++ b/tests/ProjectArgs/fixture/leaf/project.cpp @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +import std; +import Crafter.Build; +using namespace Crafter; + +// The end of the chain: records what it was loaded with and nothing else. +extern "C" Configuration CrafterBuildProject(std::span args) { + Configuration cfg; + cfg.path = "./"; + cfg.name = "leaf"; + cfg.outputName = "leaf"; + cfg.type = ConfigurationType::LibraryStatic; + ApplyStandardArgs(cfg, args); + return cfg; +} diff --git a/tests/ProjectArgs/fixture/lib/project.cpp b/tests/ProjectArgs/fixture/lib/project.cpp new file mode 100644 index 0000000..f1702c9 --- /dev/null +++ b/tests/ProjectArgs/fixture/lib/project.cpp @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +import std; +import Crafter.Build; +using namespace Crafter; + +// The middle of the chain. Its own sibling takes the default too, so +// whatever this file was loaded with has to reach the leaf unchanged. +extern "C" Configuration CrafterBuildProject(std::span args) { + Configuration* leaf = LocalProject({ .projectFile = "../leaf/project.cpp" }); + + Configuration cfg; + cfg.path = "./"; + cfg.name = "lib"; + cfg.outputName = "lib"; + cfg.type = ConfigurationType::LibraryStatic; + ApplyStandardArgs(cfg, args); + cfg.dependencies = { leaf }; + return cfg; +} diff --git a/tests/ProjectArgs/fixture/static/project.cpp b/tests/ProjectArgs/fixture/static/project.cpp new file mode 100644 index 0000000..84f75bd --- /dev/null +++ b/tests/ProjectArgs/fixture/static/project.cpp @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +import std; +import Crafter.Build; +using namespace Crafter; + +// Makes its sibling's spec at namespace scope, so the spec's args default +// while LoadProject dlopens this file: before CrafterBuildProject is called, +// and on the first load of the file only. +namespace { + const LocalProjectSpec LeafSpec{ .projectFile = "../leaf/project.cpp" }; +} + +extern "C" Configuration CrafterBuildProject(std::span args) { + Configuration* leaf = LocalProject(LeafSpec); + + Configuration cfg; + cfg.path = "./"; + cfg.name = "static"; + cfg.outputName = "static"; + cfg.type = ConfigurationType::LibraryStatic; + ApplyStandardArgs(cfg, args); + cfg.dependencies = { leaf }; + return cfg; +} diff --git a/tests/ProjectArgs/main.cpp b/tests/ProjectArgs/main.cpp new file mode 100644 index 0000000..6bf4005 --- /dev/null +++ b/tests/ProjectArgs/main.cpp @@ -0,0 +1,106 @@ +// 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; + +// A GitProject or LocalProject without `.args` forwards the args of the +// project file that declares it: both specs default their args to +// ProjectArgs(). Before that default, every project.cpp with a sibling +// dependency copied its span into a std::vector for each spec, +// and a spec that missed the copy built its sibling with none of the +// project's flags: for the host while the project cross-compiled, and in +// release while the project built --debug. The fixture is a chain +// app -> lib -> leaf of LocalProjects; app also loads lib with an empty and +// with a replaced list, and checks the GitProjectSpec default itself, which +// a clone would need a repository for. A spec made at namespace scope gets +// no args: made while dlopen runs the file's static initialisers, which +// happens on its first load only, it would otherwise keep that load's args +// for every later one. +namespace { + std::int32_t Failures = 0; + + void Check(bool cond, std::string_view msg) { + if (!cond) { + std::println(std::cerr, "FAIL: {}", msg); + ++Failures; + } + } + + // The single sibling lib, static and leaf declare, or nullptr. + const Configuration* OnlyDependency(const Configuration& cfg) { + return cfg.dependencies.size() == 1 ? cfg.dependencies[0] : nullptr; + } + + // What a sibling was loaded with, as ApplyStandardArgs split it up: + // --debug into cfg.debug, the uninterpreted rest into cfg.projectArgs. + // Printed either way, so a failure says which args the sibling read. + void CheckLoaded(const Configuration* cfg, bool debug, const std::vector& rest, std::string_view what) { + if (!cfg) { + Check(false, std::format("{} (no such configuration)", what)); + return; + } + Check(cfg->debug == debug && cfg->projectArgs == rest, std::format("{} (debug {}, projectArgs {})", what, cfg->debug, cfg->projectArgs)); + } +} + +int main() { + const fs::path installedHome = GetCrafterBuildHome(); + if (!fs::exists(installedHome / "Crafter.Build.cppm")) { + std::println(std::cerr, "SKIP: no module sources at {}", installedHome.string()); + return 77; + } + + // Staged because LoadProject drops a build/ tree next to every project + // file it compiles. + const fs::path fixture = fs::current_path() / "tests" / "ProjectArgs" / "fixture"; + const fs::path scratch = fs::temp_directory_path() / "crafter-build-project-args"; + std::error_code ec; + fs::remove_all(scratch, ec); + fs::create_directories(scratch); + fs::copy(fixture, scratch, fs::copy_options::recursive); + + Check(ProjectArgs().empty(), "no project args outside a project file"); + + // Loaded from the project's own directory, as crafter-build runs it: the + // fixture's LocalProject paths are relative to the working directory. + const fs::path cwd = fs::current_path(); + fs::current_path(scratch / "app"); + std::array args = { "--debug", "--flavor=x" }; + const std::vector flavor = { "--flavor=x" }; + const std::vector only = { "--only" }; + try { + Configuration cfg = LoadProject("project.cpp", args); + Check(std::ranges::contains(cfg.compileFlags, "-DGIT_DEFAULT=1"), "a GitProjectSpec's args default to the project's own"); + Check(cfg.dependencies.size() == 4, std::format("the project declared its four siblings (got {})", cfg.dependencies.size())); + if (cfg.dependencies.size() == 4) { + const Configuration* inherited = cfg.dependencies[0]; + const Configuration* none = cfg.dependencies[1]; + const Configuration* replaced = cfg.dependencies[2]; + const Configuration* fromStatic = cfg.dependencies[3]; + CheckLoaded(inherited, true, flavor, "a LocalProject without .args gets the project's args"); + CheckLoaded(none, false, {}, "`.args = {}` forwards nothing"); + CheckLoaded(replaced, false, only, "an explicit list replaces the args rather than adding to them"); + Check(inherited != none && inherited != replaced && none != replaced, "each list loads its own configuration"); + + // One sibling further down, the same default passes on what the + // middle project was given, not what the top one was. + CheckLoaded(OnlyDependency(*inherited), true, flavor, "the project's args reach its sibling's sibling"); + CheckLoaded(OnlyDependency(*none), false, {}, "a sibling loaded with nothing passes nothing on"); + CheckLoaded(OnlyDependency(*replaced), false, only, "a sibling passes on the list it was given"); + + CheckLoaded(fromStatic, true, flavor, "the sibling with a namespace-scope spec is itself loaded with the args"); + CheckLoaded(OnlyDependency(*fromStatic), false, {}, "a spec made at namespace scope forwards nothing"); + } + } catch (const std::exception& e) { + fs::current_path(cwd); + std::println(std::cerr, "FAIL: LoadProject threw: {}", e.what()); + return 1; + } + fs::current_path(cwd); + + Check(ProjectArgs().empty(), "no project args once the project file has returned"); + return Failures > 0 ? 1 : 0; +} diff --git a/tests/ProjectDependency/fixture/static-dep/project.cpp b/tests/ProjectDependency/fixture/static-dep/project.cpp new file mode 100644 index 0000000..2090db5 --- /dev/null +++ b/tests/ProjectDependency/fixture/static-dep/project.cpp @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +import std; +import Crafter.Build; +#pragma crafter dependency "../greeter" +using namespace Crafter; + +// Asks for its dependency at namespace scope, so the call runs while +// LoadProject dlopens this file, before CrafterBuildProject is called. +namespace { + Configuration* const Greeter = ProjectDependency("greeter"); +} + +extern "C" Configuration CrafterBuildProject(std::span) { + Configuration cfg; + cfg.path = "./"; + cfg.name = std::format("static {}", Greeter->name); + cfg.outputName = "static-dep"; + cfg.type = ConfigurationType::Executable; + return cfg; +} diff --git a/tests/ProjectDependency/main.cpp b/tests/ProjectDependency/main.cpp index fcf1c57..595d17c 100644 --- a/tests/ProjectDependency/main.cpp +++ b/tests/ProjectDependency/main.cpp @@ -90,14 +90,19 @@ int main(int argc, char** argv) { const fs::path app = scratch / "app" / "project.cpp"; const fs::path appSo = scratch / "app" / "build" / "project.so"; - std::array noArgs = {}; Check(IsTopLevelProject(), "outside any project file counts as top level"); // The dependency is fetched, built and linked before project.cpp runs, - // and ProjectDependency() hands it back by either name. + // and ProjectDependency() hands it back by either name. The app ignores + // the arg it is loaded with, and the dependency must not get it: host + // tooling gets none of the consumer's args, although a LocalProject spec + // defaults to them. The consumer's flags would split the tool's variant + // from every other consumer's, and its --target= would have the tool's + // project.cpp choose for the wrong triple. try { - Configuration cfg = LoadProject(app, noArgs); + std::array flavored = { "--flavor=x" }; + Configuration cfg = LoadProject(app, flavored); Check(cfg.name == "hello from greeter", std::format("project.cpp imported and called the dependency's module (got '{}')", cfg.name)); Check(std::ranges::contains(cfg.compileFlags, "-DTOP_LEVEL=1"), "the project LoadProject was pointed at sees itself as top level"); Check(cfg.dependencies.size() == 2, "ProjectDependency resolved by module name and by configuration name"); @@ -109,12 +114,23 @@ int main(int argc, char** argv) { Check(dep.march == "native", "the environment's march was overridden for host tooling"); Check(std::ranges::contains(dep.compileFlags, "-fPIC"), "the dependency archive was built position-independent"); Check(std::ranges::contains(dep.compileFlags, "-DTOP_LEVEL=0"), "a dependency's project file does not see itself as top level"); + Check(dep.projectArgs.empty(), std::format("the dependency got none of the consumer's args (got {})", dep.projectArgs)); Check(fs::exists(ExpectedOutput(dep)), "the dependency archive exists"); } } catch (const std::exception& e) { std::println(std::cerr, "FAIL: LoadProject threw: {}", e.what()); return 1; } + + // A static initialiser of the project file runs while LoadProject + // dlopens it, before CrafterBuildProject is called, and can already ask + // for a dependency. In a child, because a static initialiser that + // throws terminates the process. + { + const std::string loaded = LoadInChild(scratch / "static-dep" / "project.cpp"); + Check(loaded == "static greeter", std::format("ProjectDependency() works in a static initialiser (got '{}')", loaded)); + } + Check(fs::exists(appSo), "project.so was produced"); if (!fs::exists(appSo)) return 1; const fs::file_time_type firstSo = fs::last_write_time(appSo); diff --git a/tests/Script/main.cpp b/tests/Script/main.cpp index 8e200a8..472682d 100644 --- a/tests/Script/main.cpp +++ b/tests/Script/main.cpp @@ -218,6 +218,29 @@ int main() { Check(fs::exists(binSentinel) && fs::exists(buildSentinel), "`clean` as a script argument deleted nothing"); } + // `--` ends this tool's options and goes nowhere itself. In the + // project's args it gave the project a variant of its own, and every + // sibling that inherits the args one too. The word after it is an + // operand even when it looks like an option, here the script's name; + // after the name, a `--` is the script's like any other word. + { + fs::remove(scratch / "record.txt"); + const std::int32_t code = RunWith({"crafter-build", project, "--flavour=x", "run", "--", "record", "--", "a"}); + Check(code == 42, std::format("the script after `--` ran (got {})", code)); + const std::vector expected = { + "arg:--", "arg:a", + "projectArg:--flavour=x", + "debug:false", + "project:script-fixture", + }; + const std::vector record = ReadLines(scratch / "record.txt"); + Check(record == expected, "`--` reached neither the project args nor the script"); + if (record != expected) { + for (const std::string& line : record) std::println(std::cerr, " recorded: {}", line); + } + Check(RunWith({"crafter-build", project, "run", "--", "-x"}) == 1, "after `--` a word that looks like an option names the script"); + } + // The first word after `run` is the name even when it is a verb. { fs::remove(scratch / "record.txt");