A simple to use C++ build system wihout the headaches of cmake.
  • C++ 92.4%
  • JavaScript 6.2%
  • Shell 0.8%
  • C 0.3%
  • GLSL 0.2%
  • Other 0.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jorijn van der Graaf c3d97be59e
Some checks failed
CI / build-test-release (push) Failing after 4m7s
CI / release-musl (push) Has been skipped
Merge branch 'claude/project-dependencies': project.cpp dependencies, AGENTS.md
Brings in `#pragma crafter dependency`, the switch to Crafter.LintRules
for the house style, and the contributor guide in AGENTS.md.

The branch predates the musl host work on master, and both rewrote the
command that compiles project.so, so the merge reconciles them by hand:

- LoadProject compiles project.so for hostConfig.target (HostTarget(),
  from master) with the pre-dependency module path and archives from
  the branch. -fuse-ld=lld appears once, with both reasons in the
  comment.
- ForceHostTooling added -fPIC only for the literal x86_64-pc-linux-gnu
  triple. hostConfig now comes from HostTarget(), so on Alpine the host
  is x86_64-alpine-linux-musl and the dependency archive would not have
  been position-independent. It now matches any *-linux-gnu or
  *-linux-musl host, as project.cpp and the cmake flags do since the
  musl change, and its comment no longer argues against HostTarget().
- The ProjectDependency test compared the dependency's target against
  the glibc triple, which the musl CI job would fail; it compares
  against HostTarget().
- README keeps master's paragraph on the bootstrap triple. AGENTS.md
  describes the test block as `if (linuxTarget)`, which is what
  project.cpp has now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 20:47:23 +02:00
.forgejo/workflows musl host support: bootstrap and run on Alpine (x86_64-alpine-linux-musl) 2026-09-02 00:05:44 +02:00
examples linting 2026-07-23 01:24:42 +02:00
implementations Merge branch 'claude/project-dependencies': project.cpp dependencies, AGENTS.md 2026-10-02 20:47:23 +02:00
interfaces feat: project.cpp declares its own dependencies with #pragma crafter dependency 2026-10-02 20:03:22 +02:00
lib new tests 2026-05-27 19:45:05 +02:00
tests Merge branch 'claude/project-dependencies': project.cpp dependencies, AGENTS.md 2026-10-02 20:47:23 +02:00
wasi-runtime linting 2026-07-23 01:24:42 +02:00
.gitignore test runner, cross-target runners, lib/exe split 2026-04-27 22:32:19 +02:00
AGENTS.md Merge branch 'claude/project-dependencies': project.cpp dependencies, AGENTS.md 2026-10-02 20:47:23 +02:00
build.cmd linting 2026-07-23 01:24:42 +02:00
build.sh musl host support: bootstrap and run on Alpine (x86_64-alpine-linux-musl) 2026-09-02 00:05:44 +02:00
LICENSE SPDX license update 2026-07-22 18:19:17 +02:00
LICENSE.GPL SPDX license update 2026-07-22 18:19:17 +02:00
PKGBUILD SPDX license update 2026-07-22 18:19:17 +02:00
project.cpp Merge branch 'claude/project-dependencies': project.cpp dependencies, AGENTS.md 2026-10-02 20:47:23 +02:00
README.md Merge branch 'claude/project-dependencies': project.cpp dependencies, AGENTS.md 2026-10-02 20:47:23 +02:00

Crafter Build

A C++26 modules build system. Project descriptions are written in C++ with no JSON, no Lua, no embedded DSL. You write a project.cpp that constructs a Configuration and returns it; Crafter Build compiles, loads, and executes it.

Quick start (Linux)

Bootstrap requires clang, cmake, git, lld, and libc++.

./build.sh                                # produces bin/crafter-build
./bin/crafter-build                       # rebuild via project.cpp

The bootstrap targets the host toolchain's own triple (clang++ -print-target-triple): x86_64-pc-linux-gnu on glibc distros, x86_64-alpine-linux-musl on Alpine/postmarketOS (apk add clang clang-dev lld libc++-dev llvm-libunwind-dev llvm-runtimes llvm llvm-dev cmake build-base python3 git). Override with CRAFTER_BUILD_TARGET=. CI ships both: crafter-build-linux-x86_64-v{2,3,4}.tar.gz (glibc) and crafter-build-linux-x86_64-musl-v2.tar.gz (musl, needs libc++ at runtime — the launcher dlopens project.so, so it cannot be static).

To build the system as a distro package on Arch:

makepkg -si        # uses CRAFTER_BUILD_MARCH=x86-64-v3 by default for portability

Writing a project.cpp

Crafter Build loads a project.cpp from the current directory. The file exports one function that returns a populated Configuration:

import std;
import Crafter.Build;
namespace fs = std::filesystem;
using namespace Crafter;

extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) {
    Configuration cfg;
    cfg.path = "./";
    cfg.name = "myapp";
    cfg.outputName = "myapp";
    cfg.target = "x86_64-pc-linux-gnu";
    cfg.type = ConfigurationType::Executable;

    std::array<fs::path, 1> ifaces = { "interfaces/Hello" };
    std::array<fs::path, 2> impls  = { "implementations/Hello", "implementations/main" };
    cfg.GetInterfacesAndImplementations(ifaces, impls);

    return cfg;
}

Run crafter-build in that directory; outputs land at bin/myapp-<target>-<march>/myapp and intermediates at build/myapp-<target>-<march>/. Add -r to run the produced executable straight after a successful build.

Dependencies

Three kinds, all fetched/built incrementally:

Cross-project Crafter sub-projects — point cfg.dependencies at another Configuration*. Cross-project module imports (import OtherProject;) are tracked per-import: only the modules that actually import a changed dep rebuild.

External git+cmake deps — for things like glslang. Declare in cfg.externalDependencies:

ExternalDependency& glslang = cfg.externalDependencies.emplace_back();
glslang.name = "glslang";
glslang.source.url = "https://github.com/KhronosGroup/glslang.git";
glslang.source.branch = "main";
glslang.builder = ExternalBuilder::CMake;
glslang.options = { "-DENABLE_OPT=OFF" };
glslang.includeDirs = { "" };
glslang.libs = { "SPIRV", "glslang", "OSDependent" };

Clones live at ~/.cache/crafter.build/external/<name>-<hash>/, keyed by (url, branch, commit, options) so different projects sharing the same dep with the same spec reuse the clone and cmake build, while projects with diverging specs each get their own cache entry. The clone is resilient to partial-clone recovery and CMake reconfigure is triggered automatically on options changes.

Header-only git deps — same as cmake deps but builder = ExternalBuilder::None. Just clones and propagates -I flags.

Sibling crafter-build projects — GitProject / LocalProject load another project's project.cpp in-process and return a Configuration* for cfg.dependencies:

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 });
cfg.dependencies = { math };

Clones share the external cache and identical specs share one Configuration. An unpinned source is git pulled on every invocation; set .commit for reproducible, offline-after-first-fetch builds.

Dependencies of project.cpp itself

project.cpp can import modules from another crafter-build project. The file has to compile before it can say anything, so the dependency is a directive next to the import it enables:

import std;
import Crafter.Build;
#pragma crafter dependency "https://forgejo.catcrafts.net/Catcrafts/Crafter.LintRules.git"
import Crafter.LintRules;
#pragma crafter dependency "<source>" [branch "<name>" | commit "<sha>"] [project "<file>"]

Install layout

<prefix>/bin/crafter-build               # the executable
<prefix>/lib/libcrafter-build.a          # static archive for downstream consumers
<prefix>/share/crafter-build/*.cppm      # module sources (rebuilt on user machine on first run)
~/.cache/crafter.build/<target>-<march>/ # std.pcm + Crafter.Build*.pcm, built locally

Tests

Tests live under tests/<Name>/. The simplest case is a single C++ file:

tests/Smoke/main.cpp        # int main() — exit 0 = pass, nonzero = fail, 77 = skipped

Run them all:

crafter-build test

Test conventions:

  • Exit codes: 0 = ✅ pass, nonzero = ❌ fail, 77 = ⏭ skipped (autoconf convention; use it for runtime opt-outs like "tool not on PATH").
  • Each test runs in its own subprocess — a segfault doesn't take the runner down.
  • Default timeout is 60 s per test; runs in parallel up to hardware_concurrency().
  • Stdout+stderr is captured and printed atomically on completion. Failed/crashed/timed-out tests also write to build/test-logs/<name>.log.
  • A broken fixture project.cpp is reported as a single failed test, not a fatal error — other tests still run.

CLI:

crafter-build test                                              # run all
crafter-build test 'Unit*'                                      # glob-filter test names
crafter-build test --list                                       # enumerate without running
crafter-build test --target=x86_64-w64-mingw32                  # only tests for this target
crafter-build test --target=x86_64-w64-mingw32 --runner=cmd:wine
crafter-build test --jobs=1                                     # serial mode
crafter-build test --timeout=5                                  # override per-test timeout

Outcomes: ✅ pass, ❌ fail, 💥 crash (with signal name), ⏱ timeout, ⏭ skipped. The runner exits 0 only if every non-skipped test passed.

--target= filters the run to tests whose cfg.target matches; default is the host triple. Per-target tests parse --target=... from their own args span and build for it; tests with hardcoded targets (e.g. host-only meta-tests) are filtered out under non-matching --target=.

Cross-target test runners

Test::runner runs the test binary somewhere other than this machine. Four factories:

TestRunner::Local()                                  // run on this machine (default)
TestRunner::Cmd("wine")                              // local exec via prefix command
TestRunner::Ssh("hostname")                          // remote linux via scp + ssh
TestRunner::SshWin("winhost")                        // remote windows via scp + ssh-cmd.exe
TestRunner::Wsl()                                    // Linux binaries via WSL2 (Windows host)

Cmd(cmd) is a generic <cmd> <bin> <args> prefix runner — covers wine, qemu-user, valgrind, gdbserver, anything with that shape. Probes via which <cmd> (or where on Windows hosts). The Ssh variants scp -r the test's whole bin dir, run the binary remotely, then clean up; each invocation uses a unique remote subdir so parallel runs don't collide. Wsl() mirrors the SSH model but copies the bundle into WSL's native filesystem (faster than executing in-place from /mnt/c) — useful when a Windows host wants to exercise Linux test binaries.

For elaborate setups (rsync, ssh ControlMaster, env vars on the spawn, QEMU_LD_PREFIX for sysroots), construct TestRunner directly — copy/exec/cleanup/probe are public string fields with {bundle}, {remote_bundle}, {bin}, {bin_name}, {args} placeholders.

For cross-arch builds, set cfg.sysroot on the Configuration to point at a sysroot for the target. BuildStdPcm and the compile/link commands both consume it: --sysroot=<path> is passed to clang, and <sysroot>/usr/share/libc++/v1/std.cppm is precompiled instead of the host's. The cheapest aarch64 sysroot is an extracted Arch Linux ARM rootfs plus libc++ + libc++abi + gcc installed into it via pacman --root <rootfs> --config <alarm-pacman.conf> --dbpath <rootfs>/var/lib/pacman -Sy … (point pacman at ALARM's mirrors via explicit config to avoid pulling host-arch packages). See tests/CrossArchAarch64/inner/ for a working sysroot+qemu example.

For --target=x86_64-w64-mingw32 (Windows from Linux), V2's pipeline cross-compiles as long as mingw-w64 is installed. Pair it with --runner=cmd:wine (no VM needed) or --runner=sshwin:winhost (real Windows). Full MSVC ABI (x86_64-pc-windows-msvc) cross-compile from Linux is not yet wired; use MinGW for now. -fuse-ld=lld is auto-injected on every link, and -lstdc++exp is auto-linked for the mingw target so user projects don't need to declare those flags.

Configuring runners

Two layers, CLI wins over env, both override the per-test default of Local():

Env var (persistent, per shell or in CI):

export CRAFTER_BUILD_RUNNER_x86_64_w64_mingw32=cmd:wine
export CRAFTER_BUILD_RUNNER_aarch64_linux_gnu=cmd:qemu-aarch64
crafter-build test --target=x86_64-w64-mingw32           # uses cmd:wine

The variable name is CRAFTER_BUILD_RUNNER_<NORMALIZED_TARGET> (target triple with - and . replaced by _).

CLI (per invocation, scoped to --target=):

crafter-build test --target=x86_64-w64-mingw32 --runner=cmd:wine

Spec grammar:

local
cmd:<command>                  # cmd:wine, cmd:qemu-aarch64, cmd:valgrind, ...
ssh:<host>[:<remoteDir>]
sshwin:<host>[:<remoteDir>]    # cmd.exe-shell variant for Windows remotes
wsl[:<remoteDir>]              # Windows host -> Linux binaries via WSL2

Each runner carries a probe command (e.g. which wine, ssh -BatchMode=yes <host> true); if it fails, every test using that runner is reported as ⏭ skipped and the rest of the suite continues. Skipped tests count toward the summary but don't fail the suite (AllPassed() returns true on skip-only runs).

Linking the parent project's library

A test fixture can depend on the project's own library so it can import and exercise its API:

// tests/UnitFoo/project.cpp
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>) {
    Configuration cfg;
    cfg.path = "tests/UnitFoo/";
    cfg.name = "UnitFoo";
    cfg.outputName = "UnitFoo";
    cfg.target = "x86_64-pc-linux-gnu";
    cfg.type = ConfigurationType::Executable;
    cfg.dependencies = { ParentLib("MyLib") };       // <-- the magic line

    std::array<fs::path, 0> ifaces = {};
    std::array<fs::path, 1> impls = { "main" };
    cfg.GetInterfacesAndImplementations(ifaces, impls);
    return cfg;
}

ParentLib(name) walks the parent project's dependency graph (and the parent itself) to find a Configuration* by Configuration::name. The build links it into the test exe, so import MyLib; resolves naturally. See examples/tests/ for the complete worked example.

Linting & formatting

Lint rules are C++ in project.cpp with no .lintrc, no YAML, and no built-in rules. A rule is a named callback that runs once per source file; register it on the Configuration before returning:

cfg.AddLintRule("no-tabs", [](Crafter::LintContext& ctx) {
    if (ctx.Extension() != ".cpp" && ctx.Extension() != ".cppm") return;
    for (std::size_t n = 1; n <= ctx.lines.size(); ++n) {
        if (ctx.Line(n).contains('\t')) ctx.Report(n, "tab character (use spaces)");
    }
});

A rule that calls ctx.SetContent(newContent) is a transform which is defined once, it powers both verbs: lint reports where the transform would change the file (dry, never writes), and format applies it to disk:

cfg.AddLintRule("final-newline", [](Crafter::LintContext& ctx) {
    if (!ctx.content.empty() && !ctx.content.ends_with('\n')) {
        ctx.SetContent(ctx.content + '\n');
    }
});
crafter-build lint            # full dry gate: Report() findings + would-reformat, exit 1 on any
crafter-build lint 'spdx*'    # rule-name glob filter
crafter-build lint --list     # enumerate rules without running them
crafter-build format          # apply transforms, rewrite changed files in place (exit 0)
crafter-build format --check  # CI gate: list files that would change, exit 1 if any

Lint findings print compiler-style — file:line: warning: message [rule] — so editors and CI pick them up. format rewrites files in place (gofmt convention) and only touches files whose bytes actually change; report-only findings are lint's business and don't affect format's exit code. A project with no rules registered exits 1 with a message showing how to add one.

LintContext gives each rule:

Member Meaning
file absolute path of the file under lint
content the whole file
lines pre-split line views (report 1-based via Line(n))
Extension() file extension, e.g. ".cppm"
CommentStripped() content with comments and string/char literal bodies blanked to spaces — newlines kept, so line numbers stay valid (raw strings not recognized yet)
Report(line, msg) record a finding (line == 0 for whole-file)
SetContent(str) replace the file's content — makes the rule a transform; lines re-splits, CommentStripped() re-derives. Build the new string, call once at the end; don't assign content directly

Suppression is comment-based :

// lint-disable-next-line no-char-pointer      — one rule, the following line
// lint-disable-next-line naming, wrap-join    — several rules (space- or comma-separated)
// lint-disable-next-line                      — all rules, the following line
// lint-disable-file fixed-width-types         — one rule, whole file (any position)
// lint-disable-file                           — all rules, whole file

Examples

examples/ contains progressively larger self-contained projects:

Example Shows
hello-world Smallest possible project: project.cpp + main.cpp
with-module Adding a C++ module interface
library Library + executable, linked via cfg.dependencies
tests tests linking the parent library

Each builds standalone: cd examples/<name> && crafter-build.

License

This project is licensed under the GNU Lesser General Public License, version 3 only (LGPL-3.0-only). See LICENSE for the license text. The LGPL supplements the GNU General Public License version 3, a copy of which is included as LICENSE.GPL.

The example code under examples/ is licensed under the MIT license (see examples/LICENSE), so it can be freely copied into your own projects.

The lib/ directory contains vendored third-party code (nlohmann/json, glslang's DirStackFileIncluder) that remains under its own permissive licenses; see the headers of those files.

Copyright (C) 2026 Catcrafts® catcrafts.net