diff --git a/AGENTS.md b/AGENTS.md index ffd0057..d5227e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,7 +45,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`. diff --git a/README.md b/README.md index 89fc5de..2620f3f 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,9 @@ Configuration* sibling = LocalProject({ .projectFile = "../Crafter.Math/project. 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. Set `.args` to send something else: `{}` for nothing, or a list built from the project's args, say one without a flag only this project reads (it would otherwise give the sibling a build directory of its own) or with an extra `--target=`. Releases up to 2.2.0 forward nothing when `.args` is left out. +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. diff --git a/implementations/Crafter.Build-Clang.cpp b/implementations/Crafter.Build-Clang.cpp index 550023e..f93451a 100644 --- a/implementations/Crafter.Build-Clang.cpp +++ b/implementations/Crafter.Build-Clang.cpp @@ -1651,6 +1651,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. @@ -1762,6 +1766,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]; @@ -1773,11 +1780,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 6e12988..ff0c766 100644 --- a/implementations/Crafter.Build-External.cpp +++ b/implementations/Crafter.Build-External.cpp @@ -360,11 +360,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; @@ -449,7 +450,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 1d21c9c..b727cbb 100644 --- a/implementations/Crafter.Build-Platform.cpp +++ b/implementations/Crafter.Build-Platform.cpp @@ -413,9 +413,10 @@ namespace { struct ProjectFileFrame { fs::path project; - // What LoadProject was given, for ProjectArgs(). A view is enough: - // the caller's storage outlives LoadProject, and the frame is popped - // before LoadProject returns. + // 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; }; @@ -430,10 +431,7 @@ namespace { // already on the stack is a cycle, which used to recurse forever. class ProjectFileGuard { public: - // The args are recorded with the frame rather than just before - // CrafterBuildProject runs, so a spec built while dlopen runs the - // project file's static initialisers sees them too. - ProjectFileGuard(const fs::path& absProject, std::span args) { + explicit ProjectFileGuard(const fs::path& absProject) { for (const ProjectFileFrame& frame : LoadingFrames) { if (frame.project != absProject) continue; std::string chain; @@ -441,7 +439,7 @@ namespace { throw std::runtime_error(std::format("circular project dependency: {}{}", chain, absProject.string())); } index_ = LoadingFrames.size(); - LoadingFrames.push_back({ absProject, args, {} }); + LoadingFrames.push_back({ absProject, {}, {} }); } ~ProjectFileGuard() { LoadingFrames.pop_back(); } ProjectFileGuard(const ProjectFileGuard&) = delete; @@ -453,6 +451,21 @@ 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 @@ -503,30 +516,36 @@ namespace { std::set libs; for (const PragmaDependency& pragma : pragmas) { - // Host tooling is loaded with none of the consumer's args, so both - // specs pass `.args = {}` instead of defaulting to them. The tool - // is built for the host whatever the consumer targets: given the - // consumer's --target=, its project.cpp would pick sources and - // flags for the wrong triple before ForceHostTooling resets the - // fields, and the consumer's own flags would hash it apart from - // the build every other consumer of the tool shares. + // 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{ .source = { pragma.source, pragma.branch, pragma.commit }, .args = {} }; - 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, .args = {} }); } if (std::ranges::contains(out.configs, cfg)) continue; @@ -600,6 +619,10 @@ std::vector Crafter::ProjectArgs() { 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 @@ -902,7 +925,7 @@ namespace { Configuration Crafter::LoadProject(const fs::path& projectFile, std::span args) { RequireToolchain(); fs::path absProject = fs::canonical(projectFile); - ProjectFileGuard guard(absProject, args); + ProjectFileGuard guard(absProject); fs::path buildDir = absProject.parent_path() / "build"; if (!fs::exists(buildDir)) { fs::create_directories(buildDir); @@ -974,6 +997,9 @@ 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 f33cb3d..23ebe6e 100644 --- a/interfaces/Crafter.Build-External.cppm +++ b/interfaces/Crafter.Build-External.cppm @@ -71,16 +71,21 @@ export namespace Crafter { // clone can still differ in includeDirs, libDirs and libs. ExternalBuildResult ExternalOutputs(const ExternalDependency& dep, std::string_view target); - // The args LoadProject was given for the project file currently running - // its CrafterBuildProject: what that function receives, standard flags - // included (cfg.projectArgs is only the part ApplyStandardArgs left - // over). Empty when no project file is running, and on any thread but - // the one loading it, because it reads the stack LoadProject keeps per - // thread, as IsTopLevelProject does. 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. + // 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. @@ -88,21 +93,26 @@ export namespace Crafter { // 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 // what the dep's CrafterBuildProject receives. They default to this - // project's own, so --target=, --march=, --debug and flags of its own - // such as --local reach a whole chain of sibling projects without being - // passed. Set them to forward something else: a copy without one of this - // project's flags, one with an extra --target=, or {} for nothing. + // 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 = 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 diff --git a/interfaces/Crafter.Build-Platform.cppm b/interfaces/Crafter.Build-Platform.cppm index 2601275..eb9110c 100644 --- a/interfaces/Crafter.Build-Platform.cppm +++ b/interfaces/Crafter.Build-Platform.cppm @@ -75,6 +75,12 @@ namespace Crafter { // that library as a dependency, the test's GitProject loads crafter-build's // project file in turn and the two recurse until the cycle guard throws. export bool IsTopLevelProject(); + // 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 4ea9679..bfd3e9b 100644 --- a/project.cpp +++ b/project.cpp @@ -282,6 +282,8 @@ extern "C" Configuration CrafterBuildProject(std::span a .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("ConcurrentDependencyReset").Dependencies({ CrafterBuildLib.get() }); 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 index 353c592..7b51816 100644 --- a/tests/ProjectArgs/fixture/app/project.cpp +++ b/tests/ProjectArgs/fixture/app/project.cpp @@ -6,12 +6,14 @@ 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. The test reads what -// each copy received back out of cfg.dependencies. +// 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 = "./"; @@ -19,7 +21,7 @@ extern "C" Configuration CrafterBuildProject(std::span a cfg.outputName = "app"; cfg.type = ConfigurationType::Executable; ApplyStandardArgs(cfg, args); - cfg.dependencies = { inherited, none, replaced }; + 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" } }; 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 index 4b47938..6bf4005 100644 --- a/tests/ProjectArgs/main.cpp +++ b/tests/ProjectArgs/main.cpp @@ -15,7 +15,10 @@ using namespace Crafter; // 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 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; @@ -26,10 +29,21 @@ namespace { } } - // The single sibling lib and leaf declare, or nullptr. + // 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() { @@ -60,24 +74,25 @@ int main() { 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() == 3, std::format("the project declared its three siblings (got {})", cfg.dependencies.size())); - if (cfg.dependencies.size() == 3) { - const Configuration& inherited = *cfg.dependencies[0]; - const Configuration& none = *cfg.dependencies[1]; - const Configuration& replaced = *cfg.dependencies[2]; - Check(inherited.debug && inherited.projectArgs == flavor, std::format("a LocalProject without .args gets the project's args (debug {}, projectArgs {})", inherited.debug, inherited.projectArgs)); - Check(!none.debug && none.projectArgs.empty(), std::format("`.args = {{}}` forwards nothing (debug {}, projectArgs {})", none.debug, none.projectArgs)); - Check(!replaced.debug && replaced.projectArgs == only, std::format("an explicit list replaces the args rather than adding to them (debug {}, projectArgs {})", replaced.debug, replaced.projectArgs)); - Check(&inherited != &none && &inherited != &replaced && &none != &replaced, "each list loads its own configuration"); + 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. - const Configuration* inheritedLeaf = OnlyDependency(inherited); - Check(inheritedLeaf && inheritedLeaf->debug && inheritedLeaf->projectArgs == flavor, "the project's args reach its sibling's sibling"); - const Configuration* noneLeaf = OnlyDependency(none); - Check(noneLeaf && !noneLeaf->debug && noneLeaf->projectArgs.empty(), "a sibling loaded with nothing passes nothing on"); - const Configuration* replacedLeaf = OnlyDependency(replaced); - Check(replacedLeaf && !replacedLeaf->debug && replacedLeaf->projectArgs == only, "a sibling passes on the list it was given"); + 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); 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 de01130..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,6 +114,7 @@ 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) { @@ -116,20 +122,15 @@ int main(int argc, char** argv) { return 1; } - // 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 { - std::array flavored = { "--flavor=x" }; - Configuration cfg = LoadProject(app, flavored); - const bool found = !cfg.dependencies.empty(); - Check(found, "a consumer loaded with args still finds its pragma dependency"); - if (found) Check(cfg.dependencies[0]->projectArgs.empty(), std::format("a pragma dependency is loaded without the consumer's args (got {})", cfg.dependencies[0]->projectArgs)); - } catch (const std::exception& e) { - std::println(std::cerr, "FAIL: LoadProject with args 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");