Compare commits

..
10 changed files with 32 additions and 245 deletions

View file

@ -95,17 +95,14 @@ Cross-target runs use `test --target=<triple>` with `--runner=local` or `--runne
## Code style
`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:
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:
- 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.
- 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 `(`.
- `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.
Suppress one finding with `// lint-disable-next-line <rule>` or a whole file with `// lint-disable-file <rule>`. Both are rare in this tree and need a reason.

View file

@ -69,13 +69,12 @@ Clones live at `~/.cache/crafter.build/external/<name>-<hash>/`, 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" });
Configuration* sibling = LocalProject({ .projectFile = "../Crafter.Math/project.cpp", .args = depArgs });
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.
@ -95,8 +94,6 @@ import Crafter.LintRules;
#pragma crafter dependency "<source>" [branch "<name>" | commit "<sha>"] [project "<file>"]
```
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/<Name>/`. The simplest case is a single C++ file:

View file

@ -413,10 +413,6 @@ 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<const std::string_view> args;
std::vector<Configuration*> dependencies;
};
@ -430,10 +426,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<const std::string_view> args) {
explicit ProjectFileGuard(const fs::path& absProject) {
for (const ProjectFileFrame& frame : LoadingFrames) {
if (frame.project != absProject) continue;
std::string chain;
@ -441,7 +434,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;
@ -458,9 +451,8 @@ 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 is loaded with no arguments and so passes
// none on, while its own deps still read CRAFTER_BUILD_MARCH from the
// environment.
// the dependency's project.cpp forwards no arguments but still reads
// CRAFTER_BUILD_MARCH from the environment for its own deps.
void ForceHostTooling(Configuration& cfg, const Configuration& host, std::unordered_set<Configuration*>& seen) {
if (!seen.insert(&cfg).second) return;
cfg.target = host.target;
@ -503,16 +495,10 @@ namespace {
std::set<std::string> 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{ .source = { pragma.source, pragma.branch, pragma.commit }, .args = {} };
GitProjectSpec spec;
spec.source = { pragma.source, pragma.branch, pragma.commit };
if (!pragma.project.empty()) spec.projectFile = pragma.project;
cfg = GitProject(spec);
} else {
@ -526,7 +512,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, .args = {} });
cfg = LocalProject({ .projectFile = local });
}
if (std::ranges::contains(out.configs, cfg)) continue;
@ -594,12 +580,6 @@ bool Crafter::IsTopLevelProject() {
return LoadingFrames.size() <= 1;
}
std::vector<std::string> Crafter::ProjectArgs() {
if (LoadingFrames.empty()) return {};
const std::span<const std::string_view> args = LoadingFrames.back().args;
return std::vector<std::string>(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
@ -902,7 +882,7 @@ namespace {
Configuration Crafter::LoadProject(const fs::path& projectFile, std::span<const std::string_view> 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);

View file

@ -54,31 +54,16 @@ export namespace Crafter {
std::vector<std::string> ExternalIncludeFlags(const ExternalDependency& dep, std::string_view target);
ExternalBuildResult BuildExternal(const ExternalDependency& dep, std::string_view target, std::atomic<bool>& 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<std::string> 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
// 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.
// forwarded verbatim to the dep's CrafterBuildProject — typically you
// pass through the parent's args so target/march/debug propagate.
struct GitProjectSpec {
GitSource source;
fs::path projectFile = "project.cpp";
std::vector<std::string> args = ProjectArgs();
std::vector<std::string> args;
};
// Clones the spec's git URL into the per-project external cache (keyed by
@ -93,7 +78,7 @@ export namespace Crafter {
// multiple projects live side-by-side without going through git).
struct LocalProjectSpec {
fs::path projectFile; // relative to cwd or absolute
std::vector<std::string> args = ProjectArgs(); // as GitProjectSpec::args
std::vector<std::string> args; // forwarded to dep's CrafterBuildProject
};
// Same as GitProject but for a local on-disk project — no fetch, no

View file

@ -137,8 +137,15 @@ Remove it (do this before `crafter-build clean`, which deletes the repo the remo
}
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) {
Configuration* math = GitProject({ .source = { "https://forgejo.catcrafts.net/Catcrafts/Crafter.Math.git" } });
Configuration* asset = GitProject({ .source = { "https://forgejo.catcrafts.net/Catcrafts/Crafter.Asset.git" } });
std::vector<std::string> 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,
});
static auto CrafterBuildLib = std::make_unique<Configuration>();
CrafterBuildLib->path = "./";
@ -279,22 +286,16 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> 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 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);
// 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);
// See the top of this file.
cfg.scripts.push_back({"flatpak", BuildFlatpak});

View file

@ -1,29 +0,0 @@
// 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<const std::string_view> 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<std::string>(args.begin(), args.end());
cfg.compileFlags.push_back(gitDefault ? "-DGIT_DEFAULT=1" : "-DGIT_DEFAULT=0");
return cfg;
}

View file

@ -1,17 +0,0 @@
// 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<const std::string_view> args) {
Configuration cfg;
cfg.path = "./";
cfg.name = "leaf";
cfg.outputName = "leaf";
cfg.type = ConfigurationType::LibraryStatic;
ApplyStandardArgs(cfg, args);
return cfg;
}

View file

@ -1,21 +0,0 @@
// 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<const std::string_view> 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;
}

View file

@ -1,91 +0,0 @@
// 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<std::string> 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<std::string_view, 2> args = { "--debug", "--flavor=x" };
const std::vector<std::string> flavor = { "--flavor=x" };
const std::vector<std::string> 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;
}

View file

@ -115,21 +115,6 @@ 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<std::string_view, 1> 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);