diff --git a/AGENTS.md b/AGENTS.md index a97508b..f55da0d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,14 +95,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 b7e0d46..2029038 100644 --- a/README.md +++ b/README.md @@ -69,12 +69,13 @@ 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. 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. + 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 +95,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. + ## Tests Tests live under `tests//`. The simplest case is a single C++ file: diff --git a/implementations/Crafter.Build-Platform.cpp b/implementations/Crafter.Build-Platform.cpp index e4ead32..1d21c9c 100644 --- a/implementations/Crafter.Build-Platform.cpp +++ b/implementations/Crafter.Build-Platform.cpp @@ -413,6 +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. + std::span args; std::vector dependencies; }; @@ -426,7 +430,10 @@ namespace { // already on the stack is a cycle, which used to recurse forever. class ProjectFileGuard { public: - explicit ProjectFileGuard(const fs::path& absProject) { + // 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) { for (const ProjectFileFrame& frame : LoadingFrames) { if (frame.project != absProject) continue; std::string chain; @@ -434,7 +441,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, args, {} }); } ~ProjectFileGuard() { LoadingFrames.pop_back(); } ProjectFileGuard(const ProjectFileGuard&) = delete; @@ -451,8 +458,9 @@ namespace { // 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; @@ -495,10 +503,16 @@ 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. Configuration* cfg = nullptr; if (IsGitSource(pragma.source)) { - GitProjectSpec spec; - spec.source = { pragma.source, pragma.branch, pragma.commit }; + GitProjectSpec spec{ .source = { pragma.source, pragma.branch, pragma.commit }, .args = {} }; if (!pragma.project.empty()) spec.projectFile = pragma.project; cfg = GitProject(spec); } else { @@ -512,7 +526,7 @@ namespace { 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 }); + cfg = LocalProject({ .projectFile = local, .args = {} }); } if (std::ranges::contains(out.configs, cfg)) continue; @@ -580,6 +594,12 @@ bool Crafter::IsTopLevelProject() { return LoadingFrames.size() <= 1; } +std::vector Crafter::ProjectArgs() { + if (LoadingFrames.empty()) return {}; + const std::span args = LoadingFrames.back().args; + return std::vector(args.begin(), args.end()); +} + 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 @@ -882,7 +902,7 @@ namespace { Configuration Crafter::LoadProject(const fs::path& projectFile, std::span args) { RequireToolchain(); fs::path absProject = fs::canonical(projectFile); - ProjectFileGuard guard(absProject); + ProjectFileGuard guard(absProject, args); fs::path buildDir = absProject.parent_path() / "build"; if (!fs::exists(buildDir)) { fs::create_directories(buildDir); diff --git a/interfaces/Crafter.Build-External.cppm b/interfaces/Crafter.Build-External.cppm index fe8e229..2ab2760 100644 --- a/interfaces/Crafter.Build-External.cppm +++ b/interfaces/Crafter.Build-External.cppm @@ -54,16 +54,31 @@ export namespace Crafter { std::vector ExternalIncludeFlags(const ExternalDependency& dep, std::string_view target); ExternalBuildResult BuildExternal(const ExternalDependency& dep, std::string_view target, std::atomic& cancelled); + // 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. + 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, 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. 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 @@ -77,8 +92,8 @@ export namespace Crafter { // 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/project.cpp b/project.cpp index 8ba59fe..ce191d6 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 = "./"; @@ -286,16 +279,22 @@ 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("Script").Dependencies({ CrafterBuildLib.get() }) .LinkFlag("-Wl,--export-dynamic").LinkFlag("-ldl"); cfg.AddTest("ConcurrentDependencyReset").Dependencies({ CrafterBuildLib.get() }); 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/ProjectArgs/fixture/app/project.cpp b/tests/ProjectArgs/fixture/app/project.cpp new file mode 100644 index 0000000..353c592 --- /dev/null +++ b/tests/ProjectArgs/fixture/app/project.cpp @@ -0,0 +1,29 @@ +// 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. 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 cfg; + cfg.path = "./"; + cfg.name = "app"; + cfg.outputName = "app"; + cfg.type = ConfigurationType::Executable; + ApplyStandardArgs(cfg, args); + cfg.dependencies = { inherited, none, replaced }; + // 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/main.cpp b/tests/ProjectArgs/main.cpp new file mode 100644 index 0000000..4b47938 --- /dev/null +++ b/tests/ProjectArgs/main.cpp @@ -0,0 +1,91 @@ +// 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. +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 and leaf declare, or nullptr. + const Configuration* OnlyDependency(const Configuration& cfg) { + return cfg.dependencies.size() == 1 ? cfg.dependencies[0] : nullptr; + } +} + +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() == 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"); + + // 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"); + } + } 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/main.cpp b/tests/ProjectDependency/main.cpp index fcf1c57..de01130 100644 --- a/tests/ProjectDependency/main.cpp +++ b/tests/ProjectDependency/main.cpp @@ -115,6 +115,21 @@ int main(int argc, char** argv) { std::println(std::cerr, "FAIL: LoadProject threw: {}", e.what()); 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; + } 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);