Compare commits

..
Author SHA1 Message Date
7eb837fd55
fix(ci): sign with a key kept on the runner host
Some checks failed
Flatpak / flatpak (push) Failing after 1h23m15s
The runner is our own machine, so the repo signing key does not need to
pass through the forge as a secret. It is generated once under
/etc/crafter-flatpak on the host and bind-mounted read-only into the job
container, where each run imports it into a throwaway keyring. One less
secret to manage, and the private key never leaves the box that uses it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 23:52:13 +02:00
2b7c8f7f2d
docs: install from the Catcrafts Flatpak repo
The README leads with the Flatpak install, keeps the native loop as the
way to build from source, and drops the tarball, musl and makepkg text
that no longer describes anything CI produces. The install layout shows
the /app tree and the per-app cache directory.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 23:29:44 +02:00
ccfd607870
feat(ci): build and publish the Flatpak
One job replaces the tarball pipeline. On a pull request it builds the
Flatpak with flatpak-builder, which runs the bootstrap, the self-host
build and the test suite inside the sandbox, and smoke-tests the result
with `crafter-build --version` and the hello-world example. On a push to
master it also bundles the build, promotes it into the live repo as the
`beta` branch and refreshes the rolling `latest` prerelease. On a tag
X.Y.Z it promotes as `stable`, refuses to proceed unless the metainfo's
newest release is that version, and publishes a release whose notes are
that entry's description.

The job runs on the `flatpak` runner label: the privileged forgejo-runner
on the Hetzner box, with the Sdk install, the flatpak-builder state and
the repo's web root as volumes. The state volume is what keeps a normal
run short: flatpak-builder rebuilds the LLVM and WASI modules only when
their sources change. The live repo is canonical and never built into;
builds are promoted with build-commit-from, then build-update-repo signs
and prunes it and generates static deltas.

The musl job, the per-march tarballs, the toolchain packages the runner
used to install and the PKGBUILD go: the Flatpak is the one artifact.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 23:29:43 +02:00
a902b32938
fix(packaging): assemble compiler-rt's wasm stubs for wasm32
wasi-sdk's sysroot build hands its compiler-rt sub-build
CMAKE_C_COMPILER_TARGET but nothing for the ASM language, so the two
wasm/*.S stubs in the builtins (the setjmp/longjmp and C++ exception
tags) were assembled for x86-64. wasm-ld warned on every wasm link that
the archive members were neither Wasm objects nor bitcode, and anything
that reaches those tags, which crafter-build's -wasm-enable-sjlj does,
would fail to link. wasi-sdk's own toolchain never sees it because its
clang defaults to wasm32-wasip1; the sysroot-only mode against a host
clang does. A one-line patch adds CMAKE_ASM_COMPILER_TARGET to that
sub-build; it belongs upstream.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 23:29:42 +02:00
ff2e8c03a9
fix(packaging): classic libc++ layout, no debug info, clean wasm flags
Three findings from building the manifest locally.

LLVM's runtimes build installs libc++ under lib/<triple>/ by default.
clang finds it there, but the rpath crafter-build adds for a toolchain
outside /usr points at <prefix>/lib, and so does every distro layout the
resolver was modelled on; the bootstrap failed to start with
libc++.so.1 missing. LLVM_ENABLE_PER_TARGET_RUNTIME_DIR=OFF puts the
runtimes in lib/ and include/c++/v1.

flatpak-builder's default CFLAGS carry -g. For all of LLVM that is a
30 GB build tree and a multi-gigabyte .Debug extension; the module now
builds without debug info and strips, keeping the hardening flags. The
no-debuginfo key also moves under build-options, where flatpak-builder
actually reads it.

The same default CFLAGS and LDFLAGS (-fcf-protection, -z relro) reach
wasi-sdk's nested wasm32 builds through the environment, and clang
rejects them for that target. The wasi-sysroot module clears them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 23:18:10 +02:00
add30b9a4f
feat(packaging): ship crafter-build as a Flatpak with its own toolchain
packaging/flatpak/ holds the manifest for net.catcrafts.CrafterBuild and
its side files. The runtime is the Freedesktop Sdk, not the Platform: a
build tool needs glibc's headers and crt objects, gcc's libgcc_s, cmake,
ninja, git and python at run time. Everything compiler-shaped is ours
under /app, built from one LLVM 22.1.8 source tree: clang, lld, libclang,
libc++ with its std module, compiler-rt for the host and for wasm32, the
WASI sysroot (wasi-sdk 34 in sysroot-only mode, driven by the clang just
built) and wasmtime for running the result. One tree means the modules
crafter-build builds and the compiler that consumes them cannot drift
apart, and the toolchain resolver finds all of it beside the executable
with no configuration.

The crafter-build module runs the bootstrap, the self-host build and the
test suite inside the sandbox, so a Flatpak that installs has passed the
suite with the toolchain it ships. CRAFTER_BUILD_MARCH is x86-64-v2 for
the shipped binary; user projects keep -march=native.

WasmHelloRuns compiles a fixture for wasm32-wasip1 and runs it under
wasmtime. The existing wasm tests only inspect generated files, so this
is the one that proves the sysroot, the std module for wasm and wasm-ld
work together; it skips where the sysroot or wasmtime is missing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 23:18:10 +02:00
c3485b7ab1
docs: name the toolchain in the cache directory layout
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 23:18:09 +02:00
59fc9501f1
fix: key PCM and project.so freshness on content, not installed mtimes
std.pcm was rebuilt when it was older than std.cppm, and project.so when
it was older than the crafter-build executable. Both inputs are installed
files, and an OSTree deployment (the Flatpak) gives every installed file
mtime 0: std.cppm and the executable always looked older than the cache,
so a clang bump would never rebuild std.pcm and a crafter-build update
would never recompile project.so. clang crashes on a PCM from another
compiler instead of reporting it, so the failure would have been a
backtrace on the user's first build after an update.

Each artifact now has a one-line stamp beside it. std.pcm's covers the
precompile command and the module source's bytes, which also catches a
libc++ update under the same clang. project.so's covers the crafter-build
version, the toolchain key, the module source stamp and the compile
command; project.cpp's and the pre-dependency artifacts' mtimes remain
inputs, since those live next to it with real timestamps. The module
source stamp stands in for the executable: project.so resolves Crafter::
symbols from the running binary, so only declarations matter, and an
implementation-only rebuild of crafter-build no longer recompiles it.
The std.pcm stamp folds into the module cache stamp, so a rebuilt
std.pcm rebuilds the PCMs that import it.

The cache directory carries the toolchain key
(<target>-<march>-<clang version>-<hash>), so a compiler bump starts
cold instead of handing the new clang PCMs it will crash on. LoadProject
and Build() now derive the directory from the same StdPcmDir. Old
<target>-<march> directories are left behind; `clean` never touched the
cache and still does not.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 23:18:09 +02:00
d22a8603df
fix(toolchain): pass -L as well as an rpath for a toolchain outside /usr
Inside the Flatpak the bundled toolchain compiled every bootstrap object,
then glslang's configure failed its test link with `unable to find
library -lc++`. clang searches its per-target runtime directory
(lib/<triple>) and the system library paths for libc++, not
<prefix>/lib, so with the classic layout a toolchain outside /usr needs
the search path spelled out, just as its loader needs the rpath.
LibcxxLinkFlags and build.sh now emit both, the same pair an explicit
CRAFTER_BUILD_LIBCXX_DIR already got.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 23:18:07 +02:00
a72d18eeab
docs: describe the toolchain resolver and --version
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 22:26:31 +02:00
046609ff6f
feat(toolchain): resolve the compiler in one place
Every clang++, clang and llvm-ar the tool spawned was a bare name on
PATH, the std module source was /usr/share/libc++/v1/std.cppm, the WASI
sysroot /usr/share/wasi-sysroot and the mingw headers
/usr/x86_64-w64-mingw32, spelled out at eighteen call sites. A
toolchain installed anywhere else, which is what the Flatpak ships
under /app, had no way in.

Crafter.Build:Toolchain resolves all of it once per process:
CRAFTER_BUILD_LLVM_DIR, else a clang++ installed beside crafter-build
(<prefix>/bin, found through the same walk GetCrafterBuildHome does),
else PATH. What sits beside the compiler is used when present: its
libclang.so, its clang-c headers, its std module source, its WASI
sysroot. An explicit prefix without a clang++ is an error rather than a
fallback, for the same reason CRAFTER_BUILD_LIBCLANG was already
exclusive. CRAFTER_BUILD_LIBCXX_DIR points at a libc++ install other
than the compiler's own, and CRAFTER_MINGW_DIR now does what its help
line always claimed: it is passed as --sysroot for the mingw target.
Binaries linked by a toolchain outside /usr get an rpath to its lib
dir, since its libc++.so.1 is not on the loader's default path.

HostTarget() is the resolved clang's -print-target-triple. A clang built
without a vendor reports x86_64-unknown-linux-gnu where Arch's reports
x86_64-pc-linux-gnu, so target equality that decides whether a test runs
locally and which targets a bare `test` sweeps goes through SameTarget,
which ignores the vendor field. The strings themselves are never
rewritten: clang rejects a PCM whose triple differs from the importing
unit's. The examples and README stop hardcoding the host triple; the
default is already the host.

`crafter-build --version` prints the version baked in at build time
(CRAFTER_BUILD_VERSION, else `git describe`, else 0.0.0-dev), the
compiler and the host triple, and is answered before any argument is
forwarded to project.cpp. The version is a define on the library, so
each version is its own library variant and relinks the executable and
tests once per commit.

build.sh gains a shebang and `set -eu`, honours CRAFTER_BUILD_LLVM_DIR
and CRAFTER_BUILD_VERSION, skips the glslang clone when it is already
there, and writes a fresh archive instead of appending to one that may
hold members from an older layout.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 22:26:31 +02:00
68e9b6bfa5
docs: describe the Linux-only host
README and AGENTS.md stop documenting build.cmd, LIBCXX_DIR, the
DLL-plus-launcher design rule and the cmd-over-SSH gotcha. The runner
section also drops Ssh, SshWin and Wsl, which were removed from the
code earlier but still had README entries, and the stale pointer to a
deleted cross-arch test. ci.yaml loses the commented-out Windows build,
staging and upload steps and the comments that counted them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 22:03:34 +02:00
46082a3117
refactor: drop Windows host support
crafter-build is moving to a single Flatpak distribution, and there is
no Windows host in that picture: no build.cmd, no MSVC or mingw host
build, no DLL plus launcher. Delete every host-Windows path instead of
leaving it to rot unverified: the cmd.exe command runner and quoting,
the LOCALAPPDATA cache, the MSVC and mingw-host LoadProject variants
with their LIBCXX_DIR and CRAFTER_MINGW_DIR plumbing, the winsock port
probe, the LoadLibrary libclang loader, the console-width and isatty
shims, and the preprocessor guards around the Linux bodies.

The x86_64-w64-mingw32 cross-compile target stays. It is target logic
(output names, -femulated-tls, the libstdc++ link extras, copying a
dependency DLL next to a mingw exe, wine as the runner) and did not
depend on any of the removed code. The msvc target goes with the host
that was the only thing able to use it.

Three target-path warts fixed on the way: ExpectedOutput named a mingw
static library <name>.lib while the archive step wrote lib<name>.a, so
those libraries relinked on every run; the import lib is no longer
copied next to a mingw exe, since the dependency's own bin dir is
already on the link line; and llvm-ar now writes every archive, not
only the LTO ones, so Debug and Release archives are made the same way.

CRAFTER_API and Crafter.Build-Api.h existed only for the DLL boundary.
Nothing downstream used them, so they are removed rather than kept as
an empty macro nobody can verify.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 22:03:34 +02:00
f60ba809b5
docs: describe IsTopLevelProject for test-only dependencies
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 22:02:40 +02:00
be88a194f4
fix: let a project file ask whether it is the top-level project
Crafter.LintRules declares a test that links crafter-build, fetched
with GitProject. crafter-build's own project.cpp loads Crafter.LintRules
as a `#pragma crafter dependency`, so the chain was: crafter-build loads
LintRules, LintRules' test wiring loads crafter-build's project.cpp from
the external cache, which loads LintRules again, and the cycle guard
threw. Every command that loads project.cpp in this repository has
failed since that test was added, including the last CI run on master.

A project file had no way to tell whether it was the one the user
pointed crafter-build at or a dependency being loaded for somebody
else, so it could not skip wiring that only its own build needs.
IsTopLevelProject() answers that from the loading-frame stack
ProjectDependency() already reads: one frame is the top-level project
file, a second is only ever pushed for a dependency. Outside any project
file it is true as well, which is what a test harness constructing
Configurations expects.

The ProjectDependency test records the answer from both the app and the
greeter project files and checks each.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 22:02:40 +02:00
49 changed files with 1767 additions and 1578 deletions

View file

@ -1,261 +1,192 @@
name: CI name: Flatpak
# One job, three outcomes:
# pull request -> build the Flatpak and run the suite inside it
# push master -> also publish the result as the `beta` branch of the
# Catcrafts repo and refresh the rolling `latest` release
# tag X.Y.Z -> publish as `stable` and create release X.Y.Z
#
# The `flatpak` runner label is the privileged runner on the Hetzner box
# (bubblewrap needs it) with four volumes allowed: the Sdk install, the
# flatpak-builder state (ccache and the LLVM module, so only crafter-build
# itself rebuilds on a normal run), the published repo's web root, and the
# repo signing key, which lives on the runner host and never in the forge.
on: on:
pull_request: pull_request:
branches: [master] branches: [master]
push: push:
branches: [master] branches: [master]
tags: ['[0-9]+.[0-9]+.[0-9]+']
workflow_dispatch: workflow_dispatch:
env:
APP_ID: net.catcrafts.CrafterBuild
MANIFEST: packaging/flatpak/net.catcrafts.CrafterBuild.yaml
CI_MANIFEST: packaging/flatpak/ci-manifest.yaml
RUNTIME: org.freedesktop.Sdk//26.08
REPO_DIR: /srv/flatpak/repo
WEB_DIR: /srv/flatpak
jobs: jobs:
build-test-release: flatpak:
runs-on: arch-latest runs-on: flatpak
env: container:
# x86-64-v2 = SSE4.2 baseline; runs on the CI SBC (Intel N5105 / Tremont, image: archlinux:latest
# no AVX) and on essentially every x86_64 CPU since ~2011. The bootstrap options: >-
# MUST stay v2 — anything higher would emit instructions the SBC can't -v crafter-flatpak-system:/var/lib/flatpak
# execute and the bootstrap binary itself would crash before it could -v crafter-flatpak-builder:/root/.cache/flatpak-builder
# rebuild for higher levels. v3/v4 variants are produced from this v2 -v /var/www/flatpak:/srv/flatpak
# bootstrap by re-running it with CRAFTER_BUILD_MARCH overridden. -v /etc/crafter-flatpak:/run/crafter-flatpak:ro
CRAFTER_BUILD_MARCH: x86-64-v2
CRAFTER_BUILD_MTUNE: generic
steps: steps:
- name: Install build dependencies - name: Install tools
run: | run: |
# The slim archlinux:latest image arrives without a populated
# pacman keyring AND without a local pacman master key, so:
# 1. --init generates the local signing key (needed when pacman
# itself rewrites the keyring during package upgrades)
# 2. --populate archlinux imports the upstream master keys so
# currently-shipping signatures verify
# After that we can refresh archlinux-keyring to pick up keys for
# packagers added after the image's snapshot, then -Syu the rest.
pacman-key --init pacman-key --init
pacman-key --populate archlinux pacman-key --populate archlinux
pacman -Sy --noconfirm --needed archlinux-keyring pacman -Sy --noconfirm --needed archlinux-keyring
pacman -Syu --noconfirm --needed \ pacman -Syu --noconfirm --needed flatpak flatpak-builder ccache git nodejs jq gnupg go-yq ostree
base-devel git zip tar jq \
clang llvm lld libc++ cmake \
mingw-w64-gcc \
wasi-libc wasi-libc++ wasi-libc++abi wasi-compiler-rt \
nodejs
# The container runs as root and the workspace may be owned by a
# different uid; tell git not to refuse operations on it.
git config --global --add safe.directory '*' git config --global --add safe.directory '*'
- name: Sdk
# A no-op once the volume is warm; `update` follows point releases.
run: |
flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
flatpak install -y --noninteractive flathub "$RUNTIME"
flatpak update -y --noninteractive
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
# Persist the auth token so the 'Update rolling latest tag' step fetch-depth: 0
# below can push the tag back via the implicit GITHUB_TOKEN.
persist-credentials: true persist-credentials: true
- name: Cache glslang clone+build - name: Version and channel
# Cache covers crafter-build's per-(target, march) external dep cache. id: meta
# First run builds glslang up to 6 times (3 marches × 2 targets); the run: |
# cache makes subsequent runs reuse those builds. set -eu
uses: actions/cache@v4 if [ "${GITHUB_REF_TYPE}" = tag ]; then
with: version="$GITHUB_REF_NAME"
path: | branch=stable
~/.cache/crafter.build/external # The metainfo's newest release is the release notes and must be
key: glslang-${{ runner.os }}-v2 # the version being tagged.
top=$(grep -m1 -oP '<release version="\K[^"]+' packaging/flatpak/net.catcrafts.CrafterBuild.metainfo.xml)
- name: Bootstrap (build.sh) if [ "$top" != "$version" ]; then
run: ./build.sh echo "metainfo lists $top as the newest release, but the tag is $version" >&2
exit 1
# Full style gate: Report() findings (naming, enum-class, char*, …) AND fi
# would-reformat findings from the transform rules. A dirty tree means else
# someone skipped `crafter-build format` — fail fast, before the version="0.0.0-dev+$(git rev-parse --short HEAD)"
# (much slower) test suite. branch=beta
# - name: Lint fi
# run: CRAFTER_BUILD_HOME=$PWD/share/crafter-build ./bin/crafter-build lint echo "version=$version" >> "$GITHUB_OUTPUT"
echo "branch=$branch" >> "$GITHUB_OUTPUT"
- name: Run tests echo "version $version, flatpak branch $branch"
run: CRAFTER_BUILD_HOME=$PWD/share/crafter-build ./bin/crafter-build test
- name: Build
# Everything below produces release artifacts and only runs on push to # The manifest is rewritten beside the original so its relative
# master. PRs stop after the test step above — the bootstrap binary is # source paths still resolve. The suite runs inside this build.
# enough to validate the change; the per-march variant builds and the env:
# archive uploads are wasted work on a PR. VERSION: ${{ steps.meta.outputs.version }}
- name: Build Linux x86-64-v2 (matches bootstrap) run: |
if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/master' set -eu
run: CRAFTER_BUILD_HOME=$PWD/share/crafter-build ./bin/crafter-build yq '."build-options".env.CRAFTER_BUILD_VERSION = strenv(VERSION)' "$MANIFEST" > "$CI_MANIFEST"
grep -n 'CRAFTER_BUILD_VERSION' "$CI_MANIFEST"
- name: Build Linux x86-64-v3 flatpak-builder --force-clean --ccache --disable-rofiles-fuse \
if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/master' --state-dir=/root/.cache/flatpak-builder \
run: CRAFTER_BUILD_HOME=$PWD/share/crafter-build CRAFTER_BUILD_MARCH=x86-64-v3 ./bin/crafter-build --repo=repo --default-branch="${{ steps.meta.outputs.branch }}" \
build-dir "$CI_MANIFEST"
- name: Build Linux x86-64-v4
if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/master' - name: Smoke test
run: CRAFTER_BUILD_HOME=$PWD/share/crafter-build CRAFTER_BUILD_MARCH=x86-64-v4 ./bin/crafter-build run: |
flatpak-builder --run build-dir "$CI_MANIFEST" crafter-build --version
# - name: Build Windows x86-64-v2 (mingw) flatpak-builder --run build-dir "$CI_MANIFEST" sh -c 'cd examples/hello-world && crafter-build && ls bin/*/hello*'
# if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/master'
# run: CRAFTER_BUILD_HOME=$PWD/share/crafter-build ./bin/crafter-build --target=x86_64-w64-mingw32 # Everything below publishes and only runs on master and on tags.
- name: Bundle
# - name: Build Windows x86-64-v3 (mingw) if: github.event_name != 'pull_request'
# if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/master'
# run: CRAFTER_BUILD_HOME=$PWD/share/crafter-build CRAFTER_BUILD_MARCH=x86-64-v3 ./bin/crafter-build --target=x86_64-w64-mingw32
# - name: Build Windows x86-64-v4 (mingw)
# if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/master'
# run: CRAFTER_BUILD_HOME=$PWD/share/crafter-build CRAFTER_BUILD_MARCH=x86-64-v4 ./bin/crafter-build --target=x86_64-w64-mingw32
- name: Package artifacts
if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/master'
run: | run: |
set -eux
mkdir -p dist mkdir -p dist
flatpak build-bundle repo "dist/crafter-build-${{ steps.meta.outputs.version }}.flatpak" "$APP_ID" "${{ steps.meta.outputs.branch }}"
cp packaging/flatpak/net.catcrafts.CrafterBuild.flatpakref dist/
(cd dist && sha256sum * > SHA256SUMS)
ls -la dist
# 6 archives — 3 marches × 2 OSes. Each is self-contained: the - name: Upload bundle
# Linux launcher is statically linked; the Windows launcher has if: github.event_name != 'pull_request'
# crafter-build.dll + libcrafter-build.dll.a alongside the exe.
for march in x86-64-v2 x86-64-v3 x86-64-v4; do
# Short suffix (v2/v3/v4) for archive filenames; the upload-artifact
# steps below reference these short names.
short=${march##*-}
# VariantId is "<name>-<target>-<march>-<mtune>-<hash>"; CI pins
# mtune=generic so each (target, march) resolves to a single subdir
# but the trailing hash isn't known to the workflow, hence the glob.
stage_lin=$(mktemp -d)
mkdir -p "$stage_lin/bin" "$stage_lin/lib"
cp bin/crafter.build-exe-x86_64-pc-linux-gnu-$march-*/crafter-build "$stage_lin/bin/"
cp bin/crafter.build-lib-x86_64-pc-linux-gnu-$march-*/libcrafter-build.a "$stage_lin/lib/"
cp -r share "$stage_lin/"
tar czf "dist/crafter-build-linux-x86_64-$short.tar.gz" -C "$stage_lin" .
# stage_win=$(mktemp -d)
# mkdir -p "$stage_win/bin"
# cp bin/crafter.build-exe-x86_64-w64-mingw32-$march-*/* "$stage_win/bin/"
# cp -r share "$stage_win/"
# (cd "$stage_win" && zip -r "$GITHUB_WORKSPACE/dist/crafter-build-windows-x86_64-$short.zip" .)
done
ls -la dist/
# Upload each archive as its own workflow artifact (one upload per file)
# so users browsing the PR / run page get six small, individually-named
# downloads instead of one wrapper zip containing the lot.
- name: Upload Linux v2 artifact
if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/master'
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: { name: crafter-build-linux-x86_64-v2, path: dist/crafter-build-linux-x86_64-v2.tar.gz, if-no-files-found: error } with:
- name: Upload Linux v3 artifact name: crafter-build-${{ steps.meta.outputs.version }}
if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/master' path: dist/*.flatpak
uses: actions/upload-artifact@v3 if-no-files-found: error
with: { name: crafter-build-linux-x86_64-v3, path: dist/crafter-build-linux-x86_64-v3.tar.gz, if-no-files-found: error }
- name: Upload Linux v4 artifact
if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/master'
uses: actions/upload-artifact@v3
with: { name: crafter-build-linux-x86_64-v4, path: dist/crafter-build-linux-x86_64-v4.tar.gz, if-no-files-found: error }
# - name: Upload Windows v2 artifact
# if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/master'
# uses: actions/upload-artifact@v3
# with: { name: crafter-build-windows-x86_64-v2, path: dist/crafter-build-windows-x86_64-v2.zip, if-no-files-found: error }
# - name: Upload Windows v3 artifact
# if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/master'
# uses: actions/upload-artifact@v3
# with: { name: crafter-build-windows-x86_64-v3, path: dist/crafter-build-windows-x86_64-v3.zip, if-no-files-found: error }
# - name: Upload Windows v4 artifact
# if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/master'
# uses: actions/upload-artifact@v3
# with: { name: crafter-build-windows-x86_64-v4, path: dist/crafter-build-windows-x86_64-v4.zip, if-no-files-found: error }
- name: Update rolling 'latest' tag - name: Publish to the Catcrafts repo
if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/master' if: github.event_name != 'pull_request'
run: |
set -eu
# The signing key is generated once on the runner host
# (/etc/crafter-flatpak: signing-key.asc and key-id) and mounted
# read-only; it is imported into a throwaway keyring per run.
if [ ! -s /run/crafter-flatpak/signing-key.asc ] || [ ! -s /run/crafter-flatpak/key-id ]; then
echo "no signing key under /etc/crafter-flatpak on the runner host; generate it as described in AGENTS.md (CI and publishing)" >&2
exit 1
fi
export GNUPGHOME=$(mktemp -d)
gpg --batch --quiet --import /run/crafter-flatpak/signing-key.asc
FLATPAK_GPG_KEY_ID=$(cat /run/crafter-flatpak/key-id)
# The live repo is the canonical copy; builds are promoted into it,
# never built into it.
[ -d "$REPO_DIR" ] || ostree init --repo="$REPO_DIR" --mode=archive
flatpak build-commit-from --src-repo=repo \
--gpg-sign="$FLATPAK_GPG_KEY_ID" --gpg-homedir="$GNUPGHOME" \
"$REPO_DIR" "app/$APP_ID/x86_64/${{ steps.meta.outputs.branch }}"
flatpak build-update-repo --gpg-sign="$FLATPAK_GPG_KEY_ID" --gpg-homedir="$GNUPGHOME" \
--generate-static-deltas --prune "$REPO_DIR"
# The repo description and the app ref carry the public key.
key=$(gpg --export "$FLATPAK_GPG_KEY_ID" | base64 --wrap=0)
sed "s|GPGKey=.*|GPGKey=$key|" packaging/flatpak/catcrafts.flatpakrepo > "$WEB_DIR/catcrafts.flatpakrepo"
sed "s|GPGKey=.*|GPGKey=$key|" packaging/flatpak/net.catcrafts.CrafterBuild.flatpakref > "$WEB_DIR/net.catcrafts.CrafterBuild.flatpakref"
- name: Rolling 'latest' release
if: github.event_name != 'pull_request' && github.ref_type != 'tag'
run: | run: |
git config user.email "ci@catcrafts.net" git config user.email "ci@catcrafts.net"
git config user.name "Crafter Build CI" git config user.name "Crafter Build CI"
git tag -f latest git tag -f latest
git push origin latest --force git push origin latest --force
- name: Publish rolling 'latest' release - name: Publish rolling 'latest' release
# forgejo-release uploads each file in release-dir as a separate if: github.event_name != 'pull_request' && github.ref_type != 'tag'
# release asset, so the Releases page shows six individually
# downloadable archives — pick the v-level matching your CPU.
if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/master'
uses: https://code.forgejo.org/actions/forgejo-release@v2 uses: https://code.forgejo.org/actions/forgejo-release@v2
with: with:
direction: upload direction: upload
url: ${{ github.server_url }} url: ${{ github.server_url }}
repo: ${{ github.repository }} repo: ${{ github.repository }}
tag: latest tag: latest
title: Latest main build title: Latest master build
prerelease: true prerelease: true
override: true override: true
release-dir: dist release-dir: dist
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
# musl build for Alpine / postmarketOS hosts: the glibc launcher above cannot - name: Release notes
# run there (musl has no glibc loader and gcompat lacks libgcc_s + the if: github.ref_type == 'tag'
# __isoc23_* symbols). Bootstrap natively on alpine:edge and ship the
# bootstrap output — the same thing the Arch PKGBUILD installs. Dynamic
# against musl libc++ (crafter-build dlopens project.so, so it cannot be
# fully static); consumers need `apk add clang lld libc++-dev llvm-runtimes`.
release-musl:
needs: build-test-release
if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/master'
runs-on: arch-latest
container:
image: alpine:edge
env:
CRAFTER_BUILD_MARCH: x86-64-v2
CRAFTER_BUILD_MTUNE: generic
steps:
- name: Install build dependencies
# nodejs: actions/checkout is a Node action. build-base: gcc's
# libgcc_s + crt objects, which clang's musl driver links against, plus
# make/binutils for the glslang cmake build (python3: its generators).
# clang-dev + llvm-dev: the libclang header Crafter.Build-Lint includes
# lives under llvm-config --includedir here, not /usr/include. llvm:
# llvm-ar for the LTO static archives the self-host/test builds write.
run: | run: |
apk add -q nodejs git curl jq tar clang clang-dev lld libc++-dev \ # The newest <release> block's description, as plain text.
llvm-libunwind-dev llvm-runtimes llvm llvm-dev cmake build-base python3 python3 - > release-notes.md <<'EOF'
git config --global --add safe.directory '*' import re, html
text = open("packaging/flatpak/net.catcrafts.CrafterBuild.metainfo.xml").read()
- name: Checkout block = re.search(r"<release [^>]*>(.*?)</release>", text, re.S).group(1)
uses: actions/checkout@v4 paras = re.findall(r"<p>(.*?)</p>", block, re.S)
print("\n\n".join(html.unescape(" ".join(p.split())) for p in paras))
- name: Bootstrap (build.sh) print("\nInstall:\n\n flatpak remote-add --if-not-exists catcrafts https://flatpak.catcrafts.net/catcrafts.flatpakrepo\n flatpak install catcrafts net.catcrafts.CrafterBuild\n")
run: ./build.sh EOF
- name: Publish release
- name: Run tests if: github.ref_type == 'tag'
run: CRAFTER_BUILD_HOME=$PWD/share/crafter-build ./bin/crafter-build test uses: https://code.forgejo.org/actions/forgejo-release@v2
with:
- name: Package artifact direction: upload
run: | url: ${{ github.server_url }}
set -eux repo: ${{ github.repository }}
file bin/crafter-build || true tag: ${{ github.ref_name }}
mkdir -p dist title: crafter-build ${{ github.ref_name }}
stage=$(mktemp -d) release-notes: release-notes.md
mkdir -p "$stage/bin" "$stage/lib" release-dir: dist
cp bin/crafter-build "$stage/bin/" token: ${{ secrets.GITHUB_TOKEN }}
cp lib/libcrafter-build.a "$stage/lib/"
cp -r share "$stage/"
tar czf dist/crafter-build-linux-x86_64-musl-v2.tar.gz -C "$stage" .
ls -la dist/
- name: Upload musl artifact
uses: actions/upload-artifact@v3
with: { name: crafter-build-linux-x86_64-musl-v2, path: dist/crafter-build-linux-x86_64-musl-v2.tar.gz, if-no-files-found: error }
# Add the asset to the rolling 'latest' release the first job just
# (re)published, via the API rather than forgejo-release: that action's
# override mode would drop the other job's assets.
- name: Attach to rolling 'latest' release
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
run: |
set -eu
asset=crafter-build-linux-x86_64-musl-v2.tar.gz
rel=$(curl -fsS -H "Authorization: token $TOKEN" "$API/releases/tags/latest")
id=$(printf '%s' "$rel" | jq -r .id)
for old in $(printf '%s' "$rel" | jq -r --arg n "$asset" '.assets[] | select(.name==$n) | .id'); do
curl -fsS -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$id/assets/$old"
done
curl -fsS -H "Authorization: token $TOKEN" -F "attachment=@dist/$asset" \
"$API/releases/$id/assets?name=$asset" > /dev/null
echo "attached $asset to release $id"

2
.gitignore vendored
View file

@ -3,3 +3,5 @@ bin/
lib/*.a lib/*.a
share/crafter-build/ share/crafter-build/
.claude .claude
# written by CI next to the manifest so relative source paths still resolve
packaging/flatpak/ci-manifest.yaml

View file

@ -8,19 +8,20 @@ crafter-build is a C++26 modules build system. A project is described by a `proj
| Path | What lives there | | Path | What lives there |
|---|---| |---|---|
| `interfaces/Crafter.Build-<Partition>.cppm` | Module interface partitions. `Crafter.Build.cppm` is the umbrella that re-exports them. `Crafter.Build-Api.h` defines `CRAFTER_API`. | | `interfaces/Crafter.Build-<Partition>.cppm` | Module interface partitions. `Crafter.Build.cppm` is the umbrella that re-exports them. |
| `implementations/Crafter.Build-<Partition>.cpp` | Implementation units, one per partition, named `Crafter.Build:<Partition>_impl`. `main.cpp` is a thin wrapper around `Crafter::Run`. | | `implementations/Crafter.Build-<Partition>.cpp` | Implementation units, one per partition, named `Crafter.Build:<Partition>_impl`. `main.cpp` is a thin wrapper around `Crafter::Run`. |
| `project.cpp` | The self-host build description: a `crafter.build-lib` library, a `crafter.build-exe` executable, the test declarations and the lint rules. | | `project.cpp` | The self-host build description: a `crafter.build-lib` library, a `crafter.build-exe` executable, the test declarations and the lint rules. |
| `tests/<Name>/main.cpp` | One self-test per directory; `tests/<Name>/fixture/` holds the sample project it builds. | | `tests/<Name>/main.cpp` | One self-test per directory; `tests/<Name>/fixture/` holds the sample project it builds. |
| `examples/` | Standalone user-facing projects, MIT licensed. | | `examples/` | Standalone user-facing projects, MIT licensed. |
| `build.sh`, `build.cmd` | Bootstrap scripts for Linux and Windows. They compile everything by hand, without crafter-build. | | `build.sh` | Bootstrap script. It compiles everything by hand, without crafter-build. |
| `wasi-runtime/` | `runtime.js` and `index.html.in`, shipped for browser targets. | | `wasi-runtime/` | `runtime.js` and `index.html.in`, shipped for browser targets. |
| `lib/` | Vendored third-party headers (nlohmann/json, glslang's DirStackFileIncluder). Do not edit. | | `lib/` | Vendored third-party headers (nlohmann/json, glslang's DirStackFileIncluder). Do not edit. |
| `share/crafter-build/`, `bin/`, `build/`, `lib/*.a` | Generated and gitignored. | | `share/crafter-build/`, `bin/`, `build/`, `lib/*.a` | Generated and gitignored. |
| `PKGBUILD`, `.forgejo/workflows/ci.yaml` | Arch package and the Forgejo Actions CI. | | `packaging/flatpak/`, `.forgejo/workflows/ci.yaml` | The Flatpak manifest with its metainfo, repo and ref files, and the Forgejo Actions CI that builds and publishes it. |
Partitions and what they own: Partitions and what they own:
- `:Toolchain` resolves the compiler once per process (`GetToolchain`): clang++, clang, llvm-ar, libclang, libc++'s std module source and the default sysroots, from `CRAFTER_BUILD_LLVM_DIR`, else a toolchain installed beside crafter-build, else PATH. It also holds the version baked in at build time.
- `:Clang` holds `Configuration`, `Build()`, `VariantId()`, `ApplyStandardArgs`, `HostTarget`, the CLI entry point `Crafter::Run`, and the lint and test data types. - `:Clang` holds `Configuration`, `Build()`, `VariantId()`, `ApplyStandardArgs`, `HostTarget`, the CLI entry point `Crafter::Run`, and the lint and test data types.
- `:Platform` runs commands, implements `LoadProject` (compile and load `project.cpp`), the host PCM cache, `#pragma crafter dependency`, and all OS-specific code. - `:Platform` runs commands, implements `LoadProject` (compile and load `project.cpp`), the host PCM cache, `#pragma crafter dependency`, and all OS-specific code.
- `:Interface` and `:Implementation` model module units, their staleness checks and their compiles. - `:Interface` and `:Implementation` model module units, their staleness checks and their compiles.
@ -31,7 +32,9 @@ Partitions and what they own:
## Dev environment ## Dev environment
Prerequisites on Linux: `clang` (with `libclang`, which the AST lint rules load at runtime), `lld`, `libc++` with `/usr/share/libc++/v1/std.cppm`, `cmake`, `git`. Network access to `forgejo.catcrafts.net` and `github.com` is needed: loading `project.cpp` fetches Crafter.Math, Crafter.Asset and Crafter.LintRules into `~/.cache/crafter.build/external/`, and the bootstrap clones glslang. Those sources are unpinned, so every invocation runs `git pull` on them. The shipped artifact is the Flatpak (see "CI and publishing"); the loop below is the native development loop.
Prerequisites on Linux: `clang` (with `libclang`, which the AST lint rules load at runtime), `lld`, `libc++` with its std module source (`share/libc++/v1/std.cppm`), `cmake`, `git`. `crafter-build --version` prints which compiler was resolved; `CRAFTER_BUILD_LLVM_DIR` points it at another LLVM prefix, and `build.sh` honours the same variable. Network access to `forgejo.catcrafts.net` and `github.com` is needed: loading `project.cpp` fetches Crafter.Math, Crafter.Asset and Crafter.LintRules into `~/.cache/crafter.build/external/`, and the bootstrap clones glslang. Those sources are unpinned, so every invocation runs `git pull` on them.
```bash ```bash
./build.sh # bootstrap: bin/crafter-build, lib/libcrafter-build.a, share/crafter-build/ ./build.sh # bootstrap: bin/crafter-build, lib/libcrafter-build.a, share/crafter-build/
@ -42,12 +45,10 @@ 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-<variant>/crafter-build` and does not replace it. - `bin/crafter-build` is the bootstrap binary and the harness for every command below. The self-host build writes to `bin/crafter.build-exe-<variant>/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 new `CRAFTER_API` 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. - 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`. Set `CRAFTER_BUILD_HOME` only when running a binary from outside this tree; `CRAFTER_BUILD_MARCH` and `CRAFTER_BUILD_MTUNE` default to `native`. `./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`. Set `CRAFTER_BUILD_HOME` only when running a binary from outside this tree; `CRAFTER_BUILD_MARCH` and `CRAFTER_BUILD_MTUNE` default to `native`.
Windows builds use `build.cmd` with `LIBCXX_DIR` pointing at a libc++ install; the Linux-to-mingw cross-compile is a separate artifact with a different ABI. See the README. Windows branches of recent features are wired but often unverified: say so in the PR when you touch one and cannot test it.
## Build, test and style commands ## Build, test and style commands
```bash ```bash
@ -104,10 +105,9 @@ Suppress one finding with `// lint-disable-next-line <rule>` or a whole file wit
Beyond the rules: Beyond the rules:
- `import std;` everywhere; standard headers are never `#include`d. C headers (`clang-c/Index.h`, `dlfcn.h`, `windows.h`) go in the global module fragment after `module;`. - `import std;` everywhere; standard headers are never `#include`d. C headers (`clang-c/Index.h`, `dlfcn.h`) go in the global module fragment after `module;`.
- Mark every function or method that `project.cpp` or a downstream consumer calls with `CRAFTER_API` in the interface. It is the Windows DLL boundary.
- Comments explain why: the failure mode, the constraint, the rejected alternative. The codebase carries long comments on purpose. Do not strip them, and write them for new non-obvious code. Commit bodies follow the same rule. - Comments explain why: the failure mode, the constraint, the rejected alternative. The codebase carries long comments on purpose. Do not strip them, and write them for new non-obvious code. Commit bodies follow the same rule.
- Platform splits live in `:Platform`, guarded by `CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc` and `_x86_64_w64_mingw32`. In `project.cpp` and tests, branch on `cfg.target` strings instead of preprocessor macros. - There is no host-platform preprocessor split: the host is Linux. Target differences (mingw, wasm) branch on `cfg.target` strings at runtime, in the library as well as in `project.cpp` and tests.
- A build step fails when its command exits nonzero, never because it printed. Use `RunBuildCommand` or `RunCommandChecked`; warnings go to `Progress::Diagnostic`. - A build step fails when its command exits nonzero, never because it printed. Use `RunBuildCommand` or `RunCommandChecked`; warnings go to `Progress::Diagnostic`.
- Every output path goes through `Configuration::BinDir()`, `BuildDir()` and `PcmDir()`. Hand-rolled `path / "bin" / ...` clobbered variants before; do not add new ones. - Every output path goes through `Configuration::BinDir()`, `BuildDir()` and `PcmDir()`. Hand-rolled `path / "bin" / ...` clobbered variants before; do not add new ones.
- Anything that changes what gets compiled or bundled belongs in `VariantId()`. It already hashes type, debug, sysroot, defines, compile flags, wasm variant flags and uninterpreted project args. - Anything that changes what gets compiled or bundled belongs in `VariantId()`. It already hashes type, debug, sysroot, defines, compile flags, wasm variant flags and uninterpreted project args.
@ -118,8 +118,8 @@ These were decided deliberately. Do not undo them without the maintainer's say-s
1. **Configuration is C++.** No JSON, YAML, TOML or DSL for project, runner or lint configuration. Lint rules are C++ callbacks; there is no clang-format or clang-tidy integration and no separate "format rule" type. A rule that calls `ctx.SetContent` is a transform, and that is the whole mechanism. 1. **Configuration is C++.** No JSON, YAML, TOML or DSL for project, runner or lint configuration. Lint rules are C++ callbacks; there is no clang-format or clang-tidy integration and no separate "format rule" type. A rule that calls `ctx.SetContent` is a transform, and that is the whole mechanism.
2. **Per-project artifacts go in `<project>/build` and `<project>/bin`. Only cross-project things go in `~/.cache/crafter.build/`:** the std PCM and Crafter.Build PCMs per target and march, and external clones keyed by their spec hash. 2. **Per-project artifacts go in `<project>/build` and `<project>/bin`. Only cross-project things go in `~/.cache/crafter.build/`:** the std PCM and Crafter.Build PCMs per target and march, and external clones keyed by their spec hash.
3. **Sources ship, PCMs build locally.** Packages install `*.cppm` under `share/crafter-build/`. Nothing target-baked ships except the executable itself, and that defaults to a portable `-march` in `PKGBUILD` and CI. 3. **Sources ship, PCMs build locally.** The Flatpak installs `*.cppm` under `/app/share/crafter-build/`. Nothing target-baked ships except the executable and the toolchain beside it, and the executable is built for a portable `-march`.
4. **Linux is a monolithic executable with `--export-dynamic`; Windows is a DLL plus a launcher.** The asymmetry is intentional. 4. **crafter-build is a monolithic executable with `--export-dynamic`.** `project.so` resolves `Crafter::` symbols from the running binary at dlopen time. There is no Windows host; `x86_64-w64-mingw32` is a cross-compile target for user projects only.
5. **Convention over configuration.** Lead with the zero-config path. Env var, then CLI flag, then full `project.cpp` are the escalation, not the entry point. 5. **Convention over configuration.** Lead with the zero-config path. Env var, then CLI flag, then full `project.cpp` are the escalation, not the entry point.
6. **One skip mechanism** (runner probe plus exit 77) and **one `TestRunner` shape** (template fields with `{bin}`, `{bundle}` and similar placeholders; `Cmd(...)` covers any wrapper). Do not add a special-case runner class or a hardcoded "skip on this OS" branch. 6. **One skip mechanism** (runner probe plus exit 77) and **one `TestRunner` shape** (template fields with `{bin}`, `{bundle}` and similar placeholders; `Cmd(...)` covers any wrapper). Do not add a special-case runner class or a hardcoded "skip on this OS" branch.
7. **The house-style rules stay in their own repo.** They are not library API of crafter-build and are not forced on downstream projects. 7. **The house-style rules stay in their own repo.** They are not library API of crafter-build and are not forced on downstream projects.
@ -133,9 +133,9 @@ These were decided deliberately. Do not undo them without the maintainer's say-s
- Subject line: `type(scope): imperative summary`, with `feat`, `fix`, `refactor`, `docs` or `test`; the scope (`lint`, `incremental`, ...) is optional. Body: what was wrong, why, and why the fix has this shape, wrapped at about 75 columns. Read `git log` for the voice. - Subject line: `type(scope): imperative summary`, with `feat`, `fix`, `refactor`, `docs` or `test`; the scope (`lint`, `incremental`, ...) is optional. Body: what was wrong, why, and why the fix has this shape, wrapped at about 75 columns. Read `git log` for the voice.
- Separate commits for the fix, its test and the README change when each stands on its own. - Separate commits for the fix, its test and the README change when each stands on its own.
- Update the README in the same PR when user-visible behaviour changes. - Update the README in the same PR when user-visible behaviour changes.
- `format`, `lint` and `test` green before pushing. CI runs the bootstrap and the test suite on PRs and publishes release archives on `master`. - `format`, `lint` and `test` green before pushing. CI builds the Flatpak and runs the suite inside it on PRs, publishes the `beta` branch of the Catcrafts repo on every push to `master`, and the `stable` branch plus a release on an `X.Y.Z` tag.
- Never commit `bin/`, `build/`, `share/crafter-build/`, `lib/*.a` or fixture build outputs (all gitignored), and never edit the vendored files in `lib/`. - Never commit `bin/`, `build/`, `share/crafter-build/`, `lib/*.a` or fixture build outputs (all gitignored), and never edit the vendored files in `lib/`.
- Keep CI's `CRAFTER_BUILD_MARCH=x86-64-v2` for the bootstrap: the runner's CPU cannot execute v3 code, and the v3 and v4 artifacts are produced from the v2 binary. - Keep `CRAFTER_BUILD_MARCH=x86-64-v2` in the manifest for the shipped binary: it has to run on any x86_64 machine. User projects still default to `native`.
## Security considerations ## Security considerations
@ -143,15 +143,30 @@ These were decided deliberately. Do not undo them without the maintainer's say-s
- Unpinned dependencies `git pull` on every invocation, so builds and tests need network. A missing or private repo surfaces as a clone failure before anything is built. - Unpinned dependencies `git pull` on every invocation, so builds and tests need network. A missing or private repo surfaces as a clone failure before anything is built.
- `--runner=cmd:<command>` executes the named wrapper around every test binary. Wrappers come from `--runner=` or `CRAFTER_BUILD_RUNNER_*`; do not hardcode hosts, wrappers or credentials in `project.cpp` or tests. - `--runner=cmd:<command>` executes the named wrapper around every test binary. Wrappers come from `--runner=` or `CRAFTER_BUILD_RUNNER_*`; do not hardcode hosts, wrappers or credentials in `project.cpp` or tests.
- The linter loads `libclang` at runtime by name. `CRAFTER_BUILD_LIBCLANG` pins an exact path and must fail loudly when wrong, never fall back to another library. - The linter loads `libclang` at runtime by name. `CRAFTER_BUILD_LIBCLANG` pins an exact path and must fail loudly when wrong, never fall back to another library.
- CI force-pushes the rolling `latest` tag and publishes a prerelease on every push to `master` with the workflow's own token. Do not add secrets to the workflow or echo the environment in it. - CI force-pushes the rolling `latest` tag and publishes a prerelease on every push to `master` with the workflow's own token, and signs the Flatpak repo with the key mounted from the runner host. Do not add secrets to the workflow or echo the environment in it.
- Tests write only under `fs::temp_directory_path()` and the fixture's own `bin/` and `build/`. Keep it that way. - Tests write only under `fs::temp_directory_path()` and the fixture's own `bin/` and `build/`. Keep it that way.
## CI and publishing
`.forgejo/workflows/ci.yaml` runs on the `flatpak` runner label: the privileged forgejo-runner on the Hetzner box (bubblewrap inside the job container needs it). Its `config.yml` allows three volumes, which the workflow mounts: `crafter-flatpak-system` (the Sdk install at `/var/lib/flatpak`), `crafter-flatpak-builder` (flatpak-builder state and ccache, so the LLVM and WASI modules are rebuilt only when their sources change) and `/var/www/flatpak`, the web root Caddy serves as `https://flatpak.catcrafts.net/`, and `/etc/crafter-flatpak`, which holds the repo signing key (`signing-key.asc`, no passphrase, and `key-id`), generated once on the host and mounted read-only into the job; nothing is stored in the forge. The live repo at `/var/www/flatpak/repo` is canonical; builds are promoted into it with `flatpak build-commit-from`, never built into it.
The key is made once, as root on the runner host, and the step fails with a message pointing here when it is missing:
```bash
install -d -m 700 /etc/crafter-flatpak/gnupg && export GNUPGHOME=/etc/crafter-flatpak/gnupg
gpg --batch --pinentry-mode loopback --passphrase '' --quick-generate-key 'Catcrafts Flatpak Repository <flatpak@catcrafts.net>' ed25519 sign never
KEYID=$(gpg --list-secret-keys --with-colons | awk -F: '/^sec/{print $5; exit}')
gpg --batch --pinentry-mode loopback --passphrase '' --export-secret-keys --armor "$KEYID" > /etc/crafter-flatpak/signing-key.asc
printf '%s\n' "$KEYID" > /etc/crafter-flatpak/key-id && chmod 600 /etc/crafter-flatpak/signing-key.asc /etc/crafter-flatpak/key-id
```
To release: add a `<release>` to `packaging/flatpak/net.catcrafts.CrafterBuild.metainfo.xml` (its description becomes the release notes), merge, and push the tag `X.Y.Z`. CI refuses a tag that does not match the newest entry. Local builds: `flatpak-builder --user --force-clean --ccache --repo=repo build-dir packaging/flatpak/net.catcrafts.CrafterBuild.yaml`, then `flatpak-builder --run build-dir <manifest> crafter-build --version`.
## Gotchas ## Gotchas
- A `#pragma crafter dependency` target is rebuilt on every load on purpose: clang crashes rather than erroring when it imports a BMI whose own imported PCM was rebuilt underneath it, and the depfile check is what catches a regenerated host cache. Do not optimise the rebuild away. - A `#pragma crafter dependency` target is rebuilt on every load on purpose: clang crashes rather than erroring when it imports a BMI whose own imported PCM was rebuilt underneath it, and the depfile check is what catches a regenerated host cache. Do not optimise the rebuild away.
- A host-tooling library such as Crafter.LintRules may `import Crafter.Build;` without declaring it as a dependency, because `LoadProject` fills the cache it resolves from. Listing Crafter.Build in its `cfg.dependencies` would clone and build all of crafter-build for every consumer. - A host-tooling library such as Crafter.LintRules may `import Crafter.Build;` without declaring it as a dependency, because `LoadProject` fills the cache it resolves from. Listing Crafter.Build in its `cfg.dependencies` would clone and build all of crafter-build for every consumer. Its test does link crafter-build, so that test is wired behind `IsTopLevelProject()`: without the guard, crafter-build loading the library as a dependency would load its own `project.cpp` again through the test's `GitProject` and hit the cycle guard.
- `crafter-build clean` leaves a pragma dependency's build alone; it lives with the clone in the cache. - `crafter-build clean` leaves a pragma dependency's build alone; it lives with the clone in the cache.
- The bootstrap binary is compiled without `CRAFTER_BUILD_HAS_ASSET`, so `cfg.assets` only works in the self-hosted binary. Changing asset extensions means updating two maps in `Crafter.Build-Clang.cpp`: `assetOutput` and the transitive copy loop. - The bootstrap binary is compiled without `CRAFTER_BUILD_HAS_ASSET`, so `cfg.assets` only works in the self-hosted binary. Changing asset extensions means updating two maps in `Crafter.Build-Clang.cpp`: `assetOutput` and the transitive copy loop.
- A warning is printed only when its unit actually recompiles. A build with nothing to do is silent, which is not evidence that a warning is gone. - A warning is printed only when its unit actually recompiles. A build with nothing to do is silent, which is not evidence that a warning is gone.
- `VariantId()` names the output directory, so CI globs `bin/crafter.build-exe-<target>-<march>-*/` rather than predicting the hash. - `VariantId()` names the output directory, so CI globs `bin/crafter.build-exe-<target>-<march>-*/` rather than predicting the hash.
- When driving `build.cmd` from a Windows `cmd` shell over SSH: write `set VAR=value&&next` without a space before `&&`, and `if exist X rmdir /S /Q X` instead of chaining `rmdir` with `&&`.

View file

@ -1,36 +0,0 @@
#SPDX-License-Identifier: LGPL-3.0-only
#SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
pkgname=crafter-build
pkgver=0.1.0
pkgrel=1
pkgdesc='C++26 modules build system'
arch=('x86_64')
url='https://forgejo.catcrafts.net/Catcrafts/Crafter.Build'
license=('LGPL-3.0-only')
depends=('clang' 'libc++' 'lld')
optdepends=('wasi-libc: WASI/WebAssembly target support'
'wasi-libc++: C++ standard library for WASI'
'wasi-libc++abi: C++ ABI support for WASI')
makedepends=('cmake' 'git')
source=()
sha256sums=()
options=('!strip' '!debug' '!lto')
build() {
cd "$startdir"
rm -rf bin build share/crafter-build
CRAFTER_BUILD_MARCH=x86-64-v3 CRAFTER_BUILD_MTUNE=generic ./build.sh
}
package() {
cd "$startdir"
install -Dm755 bin/crafter-build "$pkgdir/usr/bin/crafter-build"
install -Dm644 lib/libcrafter-build.a "$pkgdir/usr/lib/libcrafter-build.a"
install -dm755 "$pkgdir/usr/share/crafter-build"
install -m644 share/crafter-build/*.cppm "$pkgdir/usr/share/crafter-build/"
install -m644 share/crafter-build/*.h "$pkgdir/usr/share/crafter-build/"
install -dm755 "$pkgdir/usr/share/crafter-build/wasi-runtime"
install -m644 share/crafter-build/wasi-runtime/runtime.js "$pkgdir/usr/share/crafter-build/wasi-runtime/"
install -m644 share/crafter-build/wasi-runtime/index.html.in "$pkgdir/usr/share/crafter-build/wasi-runtime/"
}

View file

@ -2,22 +2,30 @@
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. 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) ## Install
Bootstrap requires `clang`, `cmake`, `git`, `lld`, and `libc++`. crafter-build ships as a Flatpak that carries its own clang, lld, libc++ and WASI sysroot, so every machine builds with the same compiler:
```bash
flatpak remote-add --if-not-exists catcrafts https://flatpak.catcrafts.net/catcrafts.flatpakrepo
flatpak install catcrafts net.catcrafts.CrafterBuild
alias crafter-build='flatpak run net.catcrafts.CrafterBuild'
```
Pre-release builds of `master` are the `beta` branch (`flatpak install catcrafts net.catcrafts.CrafterBuild//beta`), and every release also attaches a single-file bundle on the Forgejo releases page (`flatpak install --user crafter-build-<version>.flatpak`). The sandbox sees the whole host filesystem, so projects can live anywhere. Binaries built for the host link the bundled `libc++.so.1` through an rpath into the Flatpak: they run inside it (`-r`, the test runner), and are not meant to be copied out onto the host.
## Building from source (Linux)
The development loop is native. It needs `clang`, `cmake`, `git`, `lld` and `libc++` with its std module source:
```bash ```bash
./build.sh # produces bin/crafter-build ./build.sh # produces bin/crafter-build
./bin/crafter-build # rebuild via project.cpp ./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). The compiler is resolved once per run: `CRAFTER_BUILD_LLVM_DIR` if set, else a `clang++` installed beside crafter-build (`<prefix>/bin`), else PATH. `crafter-build --version` prints the version and the compiler in use; `crafter-build --help` lists the other overrides (`CRAFTER_BUILD_LIBCXX_DIR`, `CRAFTER_BUILD_LIBCLANG`, `CRAFTER_BUILD_WASI_SYSROOT`, `CRAFTER_MINGW_DIR`). A `Configuration` targets the host triple unless `cfg.target` says otherwise.
To build the system as a distro package on Arch: The bootstrap targets the toolchain's own triple (`clang++ -print-target-triple`); override with `CRAFTER_BUILD_TARGET=`. The Flatpak is built from `packaging/flatpak/net.catcrafts.CrafterBuild.yaml`, which runs this same bootstrap, the self-host build and the test suite inside the sandbox against the bundled toolchain (`CRAFTER_BUILD_LLVM_DIR=/app`).
```bash
makepkg -si # uses CRAFTER_BUILD_MARCH=x86-64-v3 by default for portability
```
## Writing a project.cpp ## Writing a project.cpp
@ -34,7 +42,6 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
cfg.path = "./"; cfg.path = "./";
cfg.name = "myapp"; cfg.name = "myapp";
cfg.outputName = "myapp"; cfg.outputName = "myapp";
cfg.target = "x86_64-pc-linux-gnu";
cfg.type = ConfigurationType::Executable; cfg.type = ConfigurationType::Executable;
std::array<fs::path, 1> ifaces = { "interfaces/Hello" }; std::array<fs::path, 1> ifaces = { "interfaces/Hello" };
@ -83,6 +90,8 @@ cfg.dependencies = { math };
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. 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.
### Dependencies of project.cpp itself ### 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: `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:
@ -100,13 +109,20 @@ import Crafter.LintRules;
## Install layout ## Install layout
Inside the Flatpak:
``` ```
<prefix>/bin/crafter-build # the executable /app/bin/crafter-build # the executable
<prefix>/lib/libcrafter-build.a # static archive for downstream consumers /app/bin/clang++ lld llvm-ar wasmtime # the bundled toolchain the executable resolves first
<prefix>/share/crafter-build/*.cppm # module sources (rebuilt on user machine on first run) /app/lib/libcrafter-build.a # static archive for downstream consumers
~/.cache/crafter.build/<target>-<march>/ # std.pcm + Crafter.Build*.pcm, built locally /app/share/crafter-build/*.cppm # module sources (rebuilt on the user's machine on first run)
/app/share/wasi-sysroot/ # WASI sysroot for wasm32 targets
~/.var/app/net.catcrafts.CrafterBuild/cache/crafter.build/<target>-<march>-<clang>-<hash>/
# std.pcm + Crafter.Build*.pcm, built locally per toolchain
``` ```
A source build uses the same shape under the checkout (`bin/`, `lib/`, `share/crafter-build/`) with the system compiler and `~/.cache/crafter.build/`.
## Tests ## Tests
Tests live under `tests/<Name>/`. The simplest case is a single C++ file: Tests live under `tests/<Name>/`. The simplest case is a single C++ file:
@ -152,18 +168,15 @@ Outcomes: ✅ pass, ❌ fail, 💥 crash (with signal name), ⏱ timeout, ⏭ sk
```cpp ```cpp
TestRunner::Local() // run on this machine (default) TestRunner::Local() // run on this machine (default)
TestRunner::Cmd("wine") // local exec via prefix command 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. `Cmd(cmd)` is a generic `<cmd> <bin> <args>` prefix runner — covers wine, qemu-user, valgrind, gdbserver, anything with that shape. Probes via `which <cmd>`.
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 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](http://os.archlinuxarm.org/) 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 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](http://os.archlinuxarm.org/) 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).
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. For `--target=x86_64-w64-mingw32` (Windows from Linux), the pipeline cross-compiles as long as `mingw-w64` is installed. Pair it with `--runner=cmd:wine` to run the result. Only the MinGW ABI is supported; there is no MSVC target and no Windows host. `-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 ### Configuring runners
@ -190,12 +203,9 @@ Spec grammar:
``` ```
local local
cmd:<command> # cmd:wine, cmd:qemu-aarch64, cmd:valgrind, ... 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). Each runner carries a `probe` command (e.g. `which wine`); 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 ### Linking the parent project's library
@ -208,7 +218,6 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>)
cfg.path = "tests/UnitFoo/"; cfg.path = "tests/UnitFoo/";
cfg.name = "UnitFoo"; cfg.name = "UnitFoo";
cfg.outputName = "UnitFoo"; cfg.outputName = "UnitFoo";
cfg.target = "x86_64-pc-linux-gnu";
cfg.type = ConfigurationType::Executable; cfg.type = ConfigurationType::Executable;
cfg.dependencies = { ParentLib("MyLib") }; // <-- the magic line cfg.dependencies = { ParentLib("MyLib") }; // <-- the magic line

137
build.cmd
View file

@ -1,137 +0,0 @@
@REM SPDX-License-Identifier: LGPL-3.0-only
@REM SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
@echo OFF
mkdir build 2>nul
mkdir bin 2>nul
mkdir lib 2>nul
mkdir share\crafter-build 2>nul
mkdir share\crafter-build\wasi-runtime 2>nul
copy /Y wasi-runtime\runtime.js share\crafter-build\wasi-runtime\
copy /Y wasi-runtime\index.html.in share\crafter-build\wasi-runtime\
copy /Y interfaces\Crafter.Build.cppm share\crafter-build\
copy /Y interfaces\Crafter.Build-Shader.cppm share\crafter-build\
copy /Y interfaces\Crafter.Build-Platform.cppm share\crafter-build\
copy /Y interfaces\Crafter.Build-Interface.cppm share\crafter-build\
copy /Y interfaces\Crafter.Build-Implementation.cppm share\crafter-build\
copy /Y interfaces\Crafter.Build-External.cppm share\crafter-build\
copy /Y interfaces\Crafter.Build-Clang.cppm share\crafter-build\
copy /Y interfaces\Crafter.Build-Test.cppm share\crafter-build\
copy /Y interfaces\Crafter.Build-Lint.cppm share\crafter-build\
copy /Y interfaces\Crafter.Build-Progress.cppm share\crafter-build\
copy /Y interfaces\Crafter.Build-Asset.cppm share\crafter-build\
copy /Y interfaces\Crafter.Build-Api.h share\crafter-build\
if not exist .\build\glslang\NUL git clone https://github.com/KhronosGroup/glslang.git .\build\glslang
set useLibcCommon=-nostdinc++ -nostdlib++
set useLibcLinker=-L %LIBCXX_DIR%\lib -lc++ %useLibcCommon%
set useLibcSource=-isystem %LIBCXX_DIR%\include\c++\v1 %useLibcCommon%
set buildDir=%CD%\build
if "%CRAFTER_BUILD_MARCH%"=="" set CRAFTER_BUILD_MARCH=native
if "%CRAFTER_BUILD_MTUNE%"=="" set CRAFTER_BUILD_MTUNE=native
cd .\build\glslang
cmake -G Ninja -B build ^
-DCMAKE_C_COMPILER=clang ^
-DCMAKE_CXX_COMPILER=clang++ ^
-DCMAKE_CXX_FLAGS="%useLibcSource%" ^
-DCMAKE_EXE_LINKER_FLAGS="%useLibcLinker%" ^
-DCMAKE_SHARED_LINKER_FLAGS="%useLibcLinker%" ^
-DCMAKE_BUILD_TYPE=Release ^
-DCMAKE_ARCHIVE_OUTPUT_DIRECTORY="%buildDir%" ^
-DCMAKE_LIBRARY_OUTPUT_DIRECTORY="%buildDir%" ^
-DENABLE_OPT=OFF
cmake --build build --config Release
cd ..\..\
set common_options=-I.\build\glslang -std=c++26 -O3 -march=%CRAFTER_BUILD_MARCH% -mtune=%CRAFTER_BUILD_MTUNE% -fprebuilt-module-path=.\build -D CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc -D CRAFTER_BUILD_CONFIGURATION_TYPE_EXECUTABLE -D _CRT_SECURE_NO_WARNINGS %useLibcSource% -c
clang++ %useLibcSource% -std=c++26 -march=%CRAFTER_BUILD_MARCH% -mtune=%CRAFTER_BUILD_MTUNE% -O3 -Wno-reserved-identifier -Wno-reserved-module-identifier --precompile %LIBCXX_DIR%\modules\c++\v1\std.cppm -o .\build\std.pcm
clang++ %common_options% -fmodule-output interfaces\Crafter.Build-Shader.cppm -o .\build\Crafter.Build-Shader.o
clang++ %common_options% -fmodule-output interfaces\Crafter.Build-Platform.cppm -o .\build\Crafter.Build-Platform.o
clang++ %common_options% -fmodule-output interfaces\Crafter.Build-Interface.cppm -o .\build\Crafter.Build-Interface.o
clang++ %common_options% -fmodule-output interfaces\Crafter.Build-Implementation.cppm -o .\build\Crafter.Build-Implementation.o
clang++ %common_options% -fmodule-output interfaces\Crafter.Build-External.cppm -o .\build\Crafter.Build-External.o
clang++ %common_options% -fmodule-output interfaces\Crafter.Build-Clang.cppm -o .\build\Crafter.Build-Clang.o
clang++ %common_options% -fmodule-output interfaces\Crafter.Build-Test.cppm -o .\build\Crafter.Build-Test.o
clang++ %common_options% -fmodule-output interfaces\Crafter.Build-Lint.cppm -o .\build\Crafter.Build-Lint.o
clang++ %common_options% -fmodule-output interfaces\Crafter.Build-Progress.cppm -o .\build\Crafter.Build-Progress.o
REM Asset partition: bootstrap compiles it WITHOUT CRAFTER_BUILD_HAS_ASSET so
REM CompressAsset takes the stub branch (no Crafter.Asset PCM exists yet).
clang++ %common_options% -fmodule-output interfaces\Crafter.Build-Asset.cppm -o .\build\Crafter.Build-Asset.o
clang++ %common_options% -fmodule-output interfaces\Crafter.Build.cppm -o .\build\Crafter.Build.o
clang++ %common_options% .\implementations\Crafter.Build-Shader.cpp -o .\build\Crafter.Build-Shader_impl.o
clang++ %common_options% .\implementations\Crafter.Build-Platform.cpp -o .\build\Crafter.Build-Platform_impl.o
clang++ %common_options% .\implementations\Crafter.Build-Interface.cpp -o .\build\Crafter.Build-Interface_impl.o
clang++ %common_options% .\implementations\Crafter.Build-Implementation.cpp -o .\build\Crafter.Build-Implementation_impl.o
clang++ %common_options% .\implementations\Crafter.Build-External.cpp -o .\build\Crafter.Build-External_impl.o
clang++ %common_options% .\implementations\Crafter.Build-Clang.cpp -o .\build\Crafter.Build-Clang_impl.o
clang++ %common_options% .\implementations\Crafter.Build-Test.cpp -o .\build\Crafter.Build-Test_impl.o
clang++ %common_options% .\implementations\Crafter.Build-Lint.cpp -o .\build\Crafter.Build-Lint_impl.o
clang++ %common_options% .\implementations\Crafter.Build-Progress.cpp -o .\build\Crafter.Build-Progress_impl.o
clang++ %common_options% .\implementations\Crafter.Build-Asset.cpp -o .\build\Crafter.Build-Asset_impl.o
clang++ %common_options% .\implementations\main.cpp -o .\build\main.o
REM Step 1: link all impl .o files into crafter-build.dll, generating crafter-build.lib import lib
clang++ %useLibcLinker% -shared -std=c++26 -O3 -march=%CRAFTER_BUILD_MARCH% -mtune=%CRAFTER_BUILD_MTUNE% -L.\build -fuse-ld=lld ^
-Wl,/IMPLIB:.\bin\crafter-build.lib ^
-lSPIRV -lGenericCodeGen -lglslang -lOSDependent -lMachineIndependent -lglslang-default-resource-limits ^
.\build\Crafter.Build-Shader.o ^
.\build\Crafter.Build-Platform.o ^
.\build\Crafter.Build-Interface.o ^
.\build\Crafter.Build-Implementation.o ^
.\build\Crafter.Build-External.o ^
.\build\Crafter.Build-Clang.o ^
.\build\Crafter.Build-Test.o ^
.\build\Crafter.Build-Lint.o ^
.\build\Crafter.Build-Progress.o ^
.\build\Crafter.Build-Asset.o ^
.\build\Crafter.Build.o ^
.\build\Crafter.Build-Shader_impl.o ^
.\build\Crafter.Build-Platform_impl.o ^
.\build\Crafter.Build-Interface_impl.o ^
.\build\Crafter.Build-Implementation_impl.o ^
.\build\Crafter.Build-External_impl.o ^
.\build\Crafter.Build-Clang_impl.o ^
.\build\Crafter.Build-Test_impl.o ^
.\build\Crafter.Build-Lint_impl.o ^
.\build\Crafter.Build-Progress_impl.o ^
.\build\Crafter.Build-Asset_impl.o ^
-o .\bin\crafter-build.dll
REM Step 2: link the launcher exe against crafter-build.lib
clang++ %useLibcLinker% -std=c++26 -O3 -march=%CRAFTER_BUILD_MARCH% -mtune=%CRAFTER_BUILD_MTUNE% -L.\bin -fuse-ld=lld ^
.\build\main.o ^
.\bin\crafter-build.lib ^
-o .\bin\crafter-build.exe
REM Step 3: bundle the same .o set as a static archive for downstream consumers
llvm-lib.exe /OUT:.\lib\crafter-build-static.lib ^
.\build\Crafter.Build-Shader.o ^
.\build\Crafter.Build-Platform.o ^
.\build\Crafter.Build-Interface.o ^
.\build\Crafter.Build-Implementation.o ^
.\build\Crafter.Build-External.o ^
.\build\Crafter.Build-Clang.o ^
.\build\Crafter.Build-Test.o ^
.\build\Crafter.Build-Lint.o ^
.\build\Crafter.Build-Progress.o ^
.\build\Crafter.Build-Asset.o ^
.\build\Crafter.Build.o ^
.\build\Crafter.Build-Shader_impl.o ^
.\build\Crafter.Build-Platform_impl.o ^
.\build\Crafter.Build-Interface_impl.o ^
.\build\Crafter.Build-Implementation_impl.o ^
.\build\Crafter.Build-External_impl.o ^
.\build\Crafter.Build-Clang_impl.o ^
.\build\Crafter.Build-Test_impl.o ^
.\build\Crafter.Build-Lint_impl.o ^
.\build\Crafter.Build-Progress_impl.o ^
.\build\Crafter.Build-Asset_impl.o
copy /Y "%LIBCXX_DIR%\lib\c++.dll" ".\bin\c++.dll"

190
build.sh
View file

@ -1,142 +1,126 @@
#!/bin/sh
#SPDX-License-Identifier: LGPL-3.0-only #SPDX-License-Identifier: LGPL-3.0-only
#SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® #SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
mkdir -p build # Bootstrap: compile crafter-build by hand, without crafter-build. The
mkdir -p bin # self-host build through project.cpp follows (`./bin/crafter-build`).
mkdir -p lib set -eu
mkdir -p share/crafter-build
mkdir -p share/crafter-build/wasi-runtime # The toolchain. CRAFTER_BUILD_LLVM_DIR names an LLVM prefix (the Flatpak
# sets /app); otherwise the tools come from PATH. The same rule the built
# binary applies at run time (Crafter.Build:Toolchain).
if [ -n "${CRAFTER_BUILD_LLVM_DIR:-}" ]; then
CXX="$CRAFTER_BUILD_LLVM_DIR/bin/clang++"
CC="$CRAFTER_BUILD_LLVM_DIR/bin/clang"
AR="$CRAFTER_BUILD_LLVM_DIR/bin/llvm-ar"
LLVM_CONFIG="$CRAFTER_BUILD_LLVM_DIR/bin/llvm-config"
# A prefix other than the system one keeps its libc++ off both the
# linker's search path (clang looks in lib/<triple> and the system dirs,
# not <prefix>/lib) and the loader's; -L and an rpath cover the two.
if [ "$CRAFTER_BUILD_LLVM_DIR" != /usr ]; then
LIBCXX_LINK_FLAGS="-L$CRAFTER_BUILD_LLVM_DIR/lib -Wl,-rpath,$CRAFTER_BUILD_LLVM_DIR/lib"
else
LIBCXX_LINK_FLAGS=""
fi
if [ -f "$CRAFTER_BUILD_LLVM_DIR/share/libc++/v1/std.cppm" ]; then
STD_CPPM="$CRAFTER_BUILD_LLVM_DIR/share/libc++/v1/std.cppm"
else
STD_CPPM=/usr/share/libc++/v1/std.cppm
fi
else
CXX=clang++
CC=clang
AR=llvm-ar
LLVM_CONFIG=llvm-config
LIBCXX_LINK_FLAGS=""
STD_CPPM=/usr/share/libc++/v1/std.cppm
fi
# The version baked into the binary: the environment (CI sets it from the
# tag), else the nearest tag, else a development marker. `latest` is the
# rolling CI tag and never a version.
VERSION="${CRAFTER_BUILD_VERSION:-$(git describe --tags --always --dirty --exclude=latest 2>/dev/null || echo 0.0.0-dev)}"
mkdir -p build bin lib share/crafter-build share/crafter-build/wasi-runtime
cp wasi-runtime/runtime.js share/crafter-build/wasi-runtime/ cp wasi-runtime/runtime.js share/crafter-build/wasi-runtime/
cp wasi-runtime/index.html.in share/crafter-build/wasi-runtime/ cp wasi-runtime/index.html.in share/crafter-build/wasi-runtime/
cp interfaces/Crafter.Build.cppm share/crafter-build/ # Partition order is the import order: every unit is listed before the
cp interfaces/Crafter.Build-Shader.cppm share/crafter-build/ # units that import it, and the umbrella last.
cp interfaces/Crafter.Build-Platform.cppm share/crafter-build/ PARTITIONS="Toolchain Shader Platform Interface Implementation External Clang Test Lint Progress Asset"
cp interfaces/Crafter.Build-Interface.cppm share/crafter-build/
cp interfaces/Crafter.Build-Implementation.cppm share/crafter-build/
cp interfaces/Crafter.Build-External.cppm share/crafter-build/
cp interfaces/Crafter.Build-Clang.cppm share/crafter-build/
cp interfaces/Crafter.Build-Test.cppm share/crafter-build/
cp interfaces/Crafter.Build-Lint.cppm share/crafter-build/
cp interfaces/Crafter.Build-Progress.cppm share/crafter-build/
cp interfaces/Crafter.Build-Asset.cppm share/crafter-build/
cp interfaces/Crafter.Build-Api.h share/crafter-build/
git clone https://github.com/KhronosGroup/glslang.git ./build/glslang cp interfaces/Crafter.Build.cppm share/crafter-build/
for p in $PARTITIONS; do
cp "interfaces/Crafter.Build-$p.cppm" share/crafter-build/
done
if [ ! -d build/glslang/.git ]; then
git clone --depth 1 https://github.com/KhronosGroup/glslang.git build/glslang
fi
current_dir=$(pwd) current_dir=$(pwd)
cd ./build/glslang cd build/glslang
cmake -B build \ cmake -B build \
-DCMAKE_C_COMPILER=clang \ -DCMAKE_C_COMPILER="$CC" \
-DCMAKE_CXX_COMPILER=clang++ \ -DCMAKE_CXX_COMPILER="$CXX" \
-DCMAKE_CXX_FLAGS="-stdlib=libc++" \ -DCMAKE_CXX_FLAGS="-stdlib=libc++" \
-DCMAKE_EXE_LINKER_FLAGS="-stdlib=libc++" \ -DCMAKE_EXE_LINKER_FLAGS="-stdlib=libc++ $LIBCXX_LINK_FLAGS" \
-DCMAKE_SHARED_LINKER_FLAGS="-stdlib=libc++" \ -DCMAKE_SHARED_LINKER_FLAGS="-stdlib=libc++ $LIBCXX_LINK_FLAGS" \
-DCMAKE_BUILD_TYPE=Release \ -DCMAKE_BUILD_TYPE=Release \
-DCMAKE_ARCHIVE_OUTPUT_DIRECTORY=$current_dir/build \ -DCMAKE_ARCHIVE_OUTPUT_DIRECTORY="$current_dir/build" \
-DCMAKE_LIBRARY_OUTPUT_DIRECTORY=$current_dir/build \ -DCMAKE_LIBRARY_OUTPUT_DIRECTORY="$current_dir/build" \
-DENABLE_OPT=OFF -DENABLE_OPT=OFF
cmake --build build --config Release cmake --build build --config Release
cd ../../ cd ../../
MARCH="${CRAFTER_BUILD_MARCH:-native}" MARCH="${CRAFTER_BUILD_MARCH:-native}"
MTUNE="${CRAFTER_BUILD_MTUNE:-native}" MTUNE="${CRAFTER_BUILD_MTUNE:-native}"
# The bootstrap targets the host toolchain's own triple: x86_64-pc-linux-gnu # The bootstrap targets the toolchain's own triple: x86_64-pc-linux-gnu on
# on glibc distros, x86_64-alpine-linux-musl on Alpine. Override with # glibc distros, x86_64-unknown-linux-gnu for the bundled LLVM,
# CRAFTER_BUILD_TARGET. The per-target define (triple with '-' -> '_') is the # x86_64-alpine-linux-musl on Alpine. Override with CRAFTER_BUILD_TARGET.
# same one the self-hosted build emits, so the host-gated code paths match. TARGET="${CRAFTER_BUILD_TARGET:-$("$CXX" -print-target-triple)}"
TARGET="${CRAFTER_BUILD_TARGET:-$(clang++ -print-target-triple)}"
TARGET_DEFINE="CRAFTER_BUILD_CONFIGURATION_TARGET_$(printf '%s' "$TARGET" | tr - _)" TARGET_DEFINE="CRAFTER_BUILD_CONFIGURATION_TARGET_$(printf '%s' "$TARGET" | tr - _)"
# Crafter.Build-Lint includes libclang's clang-c/Index.h. glibc distros keep # Crafter.Build-Lint includes libclang's clang-c/Index.h. glibc distros keep
# it in /usr/include; Alpine keeps LLVM under /usr/lib/llvmNN, so ask # it in /usr/include; other prefixes keep it under their own include dir, so
# llvm-config and add the dir when it is not already on the default path # ask llvm-config and add the dir when it is not already on the default path
# (never add /usr/include itself: that breaks libc++'s #include_next). # (never add /usr/include itself: that breaks libc++'s #include_next).
LLVM_INCDIR="$(llvm-config --includedir 2>/dev/null || true)" LLVM_INCDIR="$("$LLVM_CONFIG" --includedir 2>/dev/null || true)"
case "$LLVM_INCDIR" in ""|/usr/include) LLVM_INCFLAG="" ;; *) LLVM_INCFLAG="-I$LLVM_INCDIR" ;; esac case "$LLVM_INCDIR" in ""|/usr/include) LLVM_INCFLAG="" ;; *) LLVM_INCFLAG="-I$LLVM_INCDIR" ;; esac
common_options="--target=$TARGET -stdlib=libc++ -I./build/glslang $LLVM_INCFLAG -std=c++26 -O3 -march=$MARCH -mtune=$MTUNE -fprebuilt-module-path=./build -D $TARGET_DEFINE -D CRAFTER_BUILD_CONFIGURATION_TYPE_EXECUTABLE -c" common_options="--target=$TARGET -stdlib=libc++ -I./build/glslang $LLVM_INCFLAG -std=c++26 -O3 -march=$MARCH -mtune=$MTUNE -fprebuilt-module-path=./build -D $TARGET_DEFINE -D CRAFTER_BUILD_CONFIGURATION_TYPE_EXECUTABLE -D CRAFTER_BUILD_VERSION=\"$VERSION\" -c"
clang++ --target=$TARGET -std=c++26 -stdlib=libc++ -O3 -march=$MARCH -mtune=$MTUNE -Wno-reserved-identifier -Wno-reserved-module-identifier --precompile /usr/share/libc++/v1/std.cppm -o ./build/std.pcm "$CXX" --target="$TARGET" -std=c++26 -stdlib=libc++ -O3 -march="$MARCH" -mtune="$MTUNE" -Wno-reserved-identifier -Wno-reserved-module-identifier --precompile "$STD_CPPM" -o ./build/std.pcm
clang++ $common_options -fmodule-output interfaces/Crafter.Build-Shader.cppm -o ./build/Crafter.Build-Shader.o
clang++ $common_options -fmodule-output interfaces/Crafter.Build-Platform.cppm -o ./build/Crafter.Build-Platform.o
clang++ $common_options -fmodule-output interfaces/Crafter.Build-Interface.cppm -o ./build/Crafter.Build-Interface.o
clang++ $common_options -fmodule-output interfaces/Crafter.Build-Implementation.cppm -o ./build/Crafter.Build-Implementation.o
clang++ $common_options -fmodule-output interfaces/Crafter.Build-External.cppm -o ./build/Crafter.Build-External.o
clang++ $common_options -fmodule-output interfaces/Crafter.Build-Clang.cppm -o ./build/Crafter.Build-Clang.o
clang++ $common_options -fmodule-output interfaces/Crafter.Build-Test.cppm -o ./build/Crafter.Build-Test.o
clang++ $common_options -fmodule-output interfaces/Crafter.Build-Lint.cppm -o ./build/Crafter.Build-Lint.o
clang++ $common_options -fmodule-output interfaces/Crafter.Build-Progress.cppm -o ./build/Crafter.Build-Progress.o
# Asset partition: bootstrap compiles it WITHOUT CRAFTER_BUILD_HAS_ASSET so # Asset partition: bootstrap compiles it WITHOUT CRAFTER_BUILD_HAS_ASSET so
# CompressAsset takes the stub branch and the impl unit doesn't try to # CompressAsset takes the stub branch and the impl unit doesn't try to
# `import Crafter.Asset` (no Crafter.Asset PCM exists at this stage — the # `import Crafter.Asset` (no Crafter.Asset PCM exists at this stage — the
# self-host pass that follows wires in the real dep). # self-host pass that follows wires in the real dep).
clang++ $common_options -fmodule-output interfaces/Crafter.Build-Asset.cppm -o ./build/Crafter.Build-Asset.o OBJECTS=""
clang++ $common_options -fmodule-output interfaces/Crafter.Build.cppm -o ./build/Crafter.Build.o for p in $PARTITIONS; do
"$CXX" $common_options -fmodule-output "interfaces/Crafter.Build-$p.cppm" -o "./build/Crafter.Build-$p.o"
OBJECTS="$OBJECTS ./build/Crafter.Build-$p.o"
done
"$CXX" $common_options -fmodule-output interfaces/Crafter.Build.cppm -o ./build/Crafter.Build.o
OBJECTS="$OBJECTS ./build/Crafter.Build.o"
clang++ $common_options ./implementations/Crafter.Build-Shader.cpp -o ./build/Crafter.Build-Shader_impl.o for p in $PARTITIONS; do
clang++ $common_options ./implementations/Crafter.Build-Platform.cpp -o ./build/Crafter.Build-Platform_impl.o "$CXX" $common_options "./implementations/Crafter.Build-$p.cpp" -o "./build/Crafter.Build-${p}_impl.o"
clang++ $common_options ./implementations/Crafter.Build-Interface.cpp -o ./build/Crafter.Build-Interface_impl.o OBJECTS="$OBJECTS ./build/Crafter.Build-${p}_impl.o"
clang++ $common_options ./implementations/Crafter.Build-Implementation.cpp -o ./build/Crafter.Build-Implementation_impl.o done
clang++ $common_options ./implementations/Crafter.Build-External.cpp -o ./build/Crafter.Build-External_impl.o "$CXX" $common_options ./implementations/main.cpp -o ./build/main.o
clang++ $common_options ./implementations/Crafter.Build-Clang.cpp -o ./build/Crafter.Build-Clang_impl.o
clang++ $common_options ./implementations/Crafter.Build-Test.cpp -o ./build/Crafter.Build-Test_impl.o
clang++ $common_options ./implementations/Crafter.Build-Lint.cpp -o ./build/Crafter.Build-Lint_impl.o
clang++ $common_options ./implementations/Crafter.Build-Progress.cpp -o ./build/Crafter.Build-Progress_impl.o
clang++ $common_options ./implementations/Crafter.Build-Asset.cpp -o ./build/Crafter.Build-Asset_impl.o
clang++ $common_options ./implementations/main.cpp -o ./build/main.o
ar rcs ./lib/libcrafter-build.a \ # A fresh archive each time: `ar rcs` into an existing one keeps members a
./build/Crafter.Build-Shader.o \ # previous layout left behind.
./build/Crafter.Build-Platform.o \ rm -f ./lib/libcrafter-build.a
./build/Crafter.Build-Interface.o \ "$AR" rcs ./lib/libcrafter-build.a $OBJECTS
./build/Crafter.Build-Implementation.o \
./build/Crafter.Build-External.o \
./build/Crafter.Build-Clang.o \
./build/Crafter.Build-Test.o \
./build/Crafter.Build-Lint.o \
./build/Crafter.Build-Progress.o \
./build/Crafter.Build-Asset.o \
./build/Crafter.Build.o \
./build/Crafter.Build-Shader_impl.o \
./build/Crafter.Build-Platform_impl.o \
./build/Crafter.Build-Interface_impl.o \
./build/Crafter.Build-Implementation_impl.o \
./build/Crafter.Build-External_impl.o \
./build/Crafter.Build-Clang_impl.o \
./build/Crafter.Build-Test_impl.o \
./build/Crafter.Build-Lint_impl.o \
./build/Crafter.Build-Progress_impl.o \
./build/Crafter.Build-Asset_impl.o
clang++ --target=$TARGET -std=c++26 -stdlib=libc++ -O3 -march=$MARCH -mtune=$MTUNE -fuse-ld=lld \ "$CXX" --target="$TARGET" -std=c++26 -stdlib=libc++ -O3 -march="$MARCH" -mtune="$MTUNE" -fuse-ld=lld \
-Wl,--export-dynamic \ -Wl,--export-dynamic $LIBCXX_LINK_FLAGS \
-L./build \ -L./build \
./build/Crafter.Build-Shader.o \ $OBJECTS \
./build/Crafter.Build-Platform.o \
./build/Crafter.Build-Interface.o \
./build/Crafter.Build-Implementation.o \
./build/Crafter.Build-External.o \
./build/Crafter.Build-Clang.o \
./build/Crafter.Build-Test.o \
./build/Crafter.Build-Lint.o \
./build/Crafter.Build-Progress.o \
./build/Crafter.Build-Asset.o \
./build/Crafter.Build.o \
./build/Crafter.Build-Shader_impl.o \
./build/Crafter.Build-Platform_impl.o \
./build/Crafter.Build-Interface_impl.o \
./build/Crafter.Build-Implementation_impl.o \
./build/Crafter.Build-External_impl.o \
./build/Crafter.Build-Clang_impl.o \
./build/Crafter.Build-Test_impl.o \
./build/Crafter.Build-Lint_impl.o \
./build/Crafter.Build-Progress_impl.o \
./build/Crafter.Build-Asset_impl.o \
./build/main.o \ ./build/main.o \
-lSPIRV -lGenericCodeGen -lglslang -lOSDependent -lMachineIndependent -lglslang-default-resource-limits \ -lSPIRV -lGenericCodeGen -lglslang -lOSDependent -lMachineIndependent -lglslang-default-resource-limits \
-ldl \ -ldl \

View file

@ -11,7 +11,6 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>)
cfg.path = "./"; cfg.path = "./";
cfg.name = "hello"; cfg.name = "hello";
cfg.outputName = "hello"; cfg.outputName = "hello";
cfg.target = "x86_64-pc-linux-gnu";
cfg.type = ConfigurationType::Executable; cfg.type = ConfigurationType::Executable;
std::array<fs::path, 0> ifaces = {}; std::array<fs::path, 0> ifaces = {};

View file

@ -15,7 +15,6 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>)
lib->path = "./mylib/"; lib->path = "./mylib/";
lib->name = "MyMath"; lib->name = "MyMath";
lib->outputName = "MyMath"; lib->outputName = "MyMath";
lib->target = "x86_64-pc-linux-gnu";
lib->type = ConfigurationType::LibraryStatic; lib->type = ConfigurationType::LibraryStatic;
{ {
std::array<fs::path, 1> ifaces = { "MyMath" }; std::array<fs::path, 1> ifaces = { "MyMath" };
@ -29,7 +28,6 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>)
cfg.path = "./"; cfg.path = "./";
cfg.name = "math-app"; cfg.name = "math-app";
cfg.outputName = "math-app"; cfg.outputName = "math-app";
cfg.target = "x86_64-pc-linux-gnu";
cfg.type = ConfigurationType::Executable; cfg.type = ConfigurationType::Executable;
cfg.dependencies = { lib.get() }; cfg.dependencies = { lib.get() };

View file

@ -14,7 +14,6 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>)
cfg.path = "./mylib/"; cfg.path = "./mylib/";
cfg.name = "MyMath"; cfg.name = "MyMath";
cfg.outputName = "MyMath"; cfg.outputName = "MyMath";
cfg.target = "x86_64-pc-linux-gnu";
cfg.type = ConfigurationType::LibraryStatic; cfg.type = ConfigurationType::LibraryStatic;
std::array<fs::path, 1> ifaces = { "MyMath" }; std::array<fs::path, 1> ifaces = { "MyMath" };

View file

@ -11,7 +11,6 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>)
cfg.path = "./"; cfg.path = "./";
cfg.name = "greeter-app"; cfg.name = "greeter-app";
cfg.outputName = "greeter-app"; cfg.outputName = "greeter-app";
cfg.target = "x86_64-pc-linux-gnu";
cfg.type = ConfigurationType::Executable; cfg.type = ConfigurationType::Executable;
std::array<fs::path, 1> ifaces = { "interfaces/Greeter" }; std::array<fs::path, 1> ifaces = { "interfaces/Greeter" };

View file

@ -2,21 +2,14 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module; module;
#if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32)
#include <winsock2.h>
#include <ws2tcpip.h>
// <rpc.h> (pulled in transitively) defines `interface` as a macro for `struct`,
// which collides with local variables named `interface` in this TU.
#undef interface
#else
#include <sys/socket.h> #include <sys/socket.h>
#include <netinet/in.h> #include <netinet/in.h>
#include <unistd.h> #include <unistd.h>
#endif
export module Crafter.Build:Clang_impl; export module Crafter.Build:Clang_impl;
import std; import std;
import :Clang; import :Clang;
import :Platform; import :Platform;
import :Toolchain;
import :Test; import :Test;
import :Lint; import :Lint;
import :Progress; import :Progress;
@ -253,11 +246,14 @@ void Configuration::GetInterfacesAndImplementations(std::span<fs::path> interfac
} }
fs::path Crafter::StdPcmDir(const Configuration& config) { fs::path Crafter::StdPcmDir(const Configuration& config) {
// The std PCM is cached per target+march, but a wasm variant pass compiles // The std PCM is cached per target+march+toolchain: a PCM is only valid
// it with extra codegen flags (e.g. -mrelaxed-simd) — its BMI must not be // for the exact clang that wrote it, so a compiler bump starts a cold
// shared with the baseline's, or the consuming TUs see a target-feature // directory instead of handing the new clang a PCM it will crash on. A
// mismatch. Suffix the cache dir with the variant flags when present. // wasm variant pass compiles it with extra codegen flags (e.g.
std::string stdPcmKey = std::format("{}-{}", config.target, config.march); // -mrelaxed-simd) — its BMI must not be shared with the baseline's, or
// the consuming TUs see a target-feature mismatch. Suffix the cache dir
// with the variant flags when present.
std::string stdPcmKey = std::format("{}-{}-{}", config.target, config.march, GetToolchain().Key());
for (const std::string& f : config.wasmVariantFlags) { for (const std::string& f : config.wasmVariantFlags) {
stdPcmKey += "+"; stdPcmKey += "+";
for (char c : f) stdPcmKey += (c == '/' || c == '\\') ? '_' : c; for (char c : f) stdPcmKey += (c == '/' || c == '\\') ? '_' : c;
@ -267,17 +263,19 @@ fs::path Crafter::StdPcmDir(const Configuration& config) {
fs::path Crafter::ExpectedOutput(const Configuration& config) { fs::path Crafter::ExpectedOutput(const Configuration& config) {
const fs::path dir = config.BinDir(); const fs::path dir = config.BinDir();
const bool windowsTarget = config.target == "x86_64-w64-mingw32" || config.target == "x86_64-pc-windows-msvc"; const bool mingwTarget = config.target == "x86_64-w64-mingw32";
if (config.type == ConfigurationType::Executable) { if (config.type == ConfigurationType::Executable) {
if (config.target.starts_with("wasm32")) return dir / std::format("{}.wasm", config.outputName); if (config.target.starts_with("wasm32")) return dir / std::format("{}.wasm", config.outputName);
return config.target == "x86_64-w64-mingw32" ? dir / std::format("{}.exe", config.outputName) : dir / config.outputName; return mingwTarget ? dir / std::format("{}.exe", config.outputName) : dir / config.outputName;
} }
if (config.type == ConfigurationType::LibraryStatic) { if (config.type == ConfigurationType::LibraryStatic) {
return windowsTarget ? dir / std::format("{}.lib", config.outputName) : dir / std::format("lib{}.a", config.outputName); // The archive step writes lib<name>.a for every target, mingw included:
// llvm-ar does not care about the object format.
return dir / std::format("lib{}.a", config.outputName);
} }
// LibraryDynamic — the .dll on Windows targets so an mtime check sees the // LibraryDynamic — the .dll on the mingw target so an mtime check sees the
// freshly linked library; on Unix the .so is that file. // freshly linked library; on Unix the .so is that file.
return windowsTarget ? dir / std::format("{}.dll", config.outputName) : dir / std::format("lib{}.so", config.outputName); return mingwTarget ? dir / std::format("{}.dll", config.outputName) : dir / std::format("lib{}.so", config.outputName);
} }
CompileCommand Crafter::GetCompileCommand(const Configuration& config) { CompileCommand Crafter::GetCompileCommand(const Configuration& config) {
@ -322,22 +320,9 @@ CompileCommand Crafter::GetCompileCommand(const Configuration& config) {
} }
if(config.type == ConfigurationType::LibraryDynamic) { if(config.type == ConfigurationType::LibraryDynamic) {
#ifdef __linux__
out.command += " -fPIC -D CRAFTER_BUILD_CONFIGURATION_TYPE_SHARED_LIBRARY"; out.command += " -fPIC -D CRAFTER_BUILD_CONFIGURATION_TYPE_SHARED_LIBRARY";
#endif
#if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32)
out.command += " -D CRAFTER_BUILD_CONFIGURATION_TYPE_SHARED_LIBRARY";
#endif
} else if(config.type == ConfigurationType::Executable) { } else if(config.type == ConfigurationType::Executable) {
out.command += " -D CRAFTER_BUILD_CONFIGURATION_TYPE_EXECUTABLE"; out.command += " -D CRAFTER_BUILD_CONFIGURATION_TYPE_EXECUTABLE";
// On Windows targets the API uses __declspec(dllimport) when consuming
// a DLL. Set the macro for executables so CRAFTER_API resolves to
// dllimport in their PCM cache (separate from the lib's PCM cache,
// which gets dllexport). Harmless if the exe doesn't actually link a
// crafter DLL — CRAFTER_API only matters at API call sites.
if (config.target == "x86_64-w64-mingw32" || config.target == "x86_64-pc-windows-msvc") {
out.command += " -D CRAFTER_BUILD_DLL_IMPORT";
}
} else { } else {
out.command += " -D CRAFTER_BUILD_CONFIGURATION_TYPE_LIBRARY"; out.command += " -D CRAFTER_BUILD_CONFIGURATION_TYPE_LIBRARY";
} }
@ -423,6 +408,11 @@ CompileCommand Crafter::GetCompileCommand(const Configuration& config) {
} }
BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, std::shared_future<BuildResult>>& depResults, std::mutex& depMutex) { BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, std::shared_future<BuildResult>>& depResults, std::mutex& depMutex) {
// Before anything is spawned: one message naming the missing compiler
// beats a clang++-not-found per translation unit.
if (const Toolchain& tc = GetToolchain(); !tc.error.empty()) {
return {tc.error, false, {}};
}
// Reset per-build cached state on every Module/ModulePartition so that // Reset per-build cached state on every Module/ModulePartition so that
// successive Build() calls on the same Configuration re-evaluate mtimes // successive Build() calls on the same Configuration re-evaluate mtimes
// (incremental-rebuild test scenarios). // (incremental-rebuild test scenarios).
@ -464,15 +454,12 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
config.ResolvePendingImports(); config.ResolvePendingImports();
// Auto-detect the WASI sysroot before any compile step runs so BuildStdPcm // Auto-detect the WASI sysroot before any compile step runs so BuildStdPcm
// and the main compile command see the same value. Linux-only — Windows // and the main compile command see the same value. Covers all wasm32-*
// users supply cfg.sysroot pointing at their wasi-sdk install. Covers all // triples (wasi, wasip1, wasip2, ...); the sysroot's per-triple subdirs
// wasm32-* triples (wasi, wasip1, wasip2, ...); the sysroot's per-triple // handle the differences.
// subdirs handle the differences.
#ifdef __linux__
if (config.sysroot.empty() && config.target.starts_with("wasm32")) { if (config.sysroot.empty() && config.target.starts_with("wasm32")) {
config.sysroot = "/usr/share/wasi-sysroot"; config.sysroot = GetToolchain().wasiSysroot.string();
} }
#endif
fs::path buildDir = config.BuildDir(); fs::path buildDir = config.BuildDir();
fs::path outputDir = config.BinDir(); fs::path outputDir = config.BinDir();
@ -705,7 +692,6 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
const std::string& ltoCompileFlags = compile.ltoCompileFlags; const std::string& ltoCompileFlags = compile.ltoCompileFlags;
const std::string& ltoLinkFlags = compile.ltoLinkFlags; const std::string& ltoLinkFlags = compile.ltoLinkFlags;
const bool isWasm = config.target.starts_with("wasm32"); const bool isWasm = config.target.starts_with("wasm32");
const bool useLto = compile.useLto;
std::string files; std::string files;
std::unordered_set<std::string> libSet; std::unordered_set<std::string> libSet;
@ -803,7 +789,7 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
Progress::Task task(std::format("Compiling {}.c", cFile.filename().string())); Progress::Task task(std::format("Compiling {}.c", cFile.filename().string()));
if (buildCancelled.load(std::memory_order_relaxed)) return; if (buildCancelled.load(std::memory_order_relaxed)) return;
std::string result = RunBuildCommand(std::format("clang {0}.c --target={1}{2} -O3{3} -c{4}{5}{6} -MD -MF {7}_source.o.d -o {7}_source.o", cFile.string(), config.target, cArchFlags, ltoCompileFlags, includeFlags, defineFlags, userFlags, (buildDir / cFile.filename()).string())); std::string result = RunBuildCommand(std::format("{8} {0}.c --target={1}{2} -O3{3} -c{4}{5}{6} -MD -MF {7}_source.o.d -o {7}_source.o", cFile.string(), config.target, cArchFlags, ltoCompileFlags, includeFlags, defineFlags, userFlags, (buildDir / cFile.filename()).string(), GetToolchain().clang.string()));
if (result.empty()) return; if (result.empty()) return;
bool expected = false; bool expected = false;
@ -1028,6 +1014,13 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
// link below ends with linkExtras, so this reaches them all; the static-lib // link below ends with linkExtras, so this reaches them all; the static-lib
// archive path uses ar instead and is handled separately. // archive path uses ar instead and is handled separately.
linkExtras += ltoLinkFlags; linkExtras += ltoLinkFlags;
// Host links use the toolchain's own libc++. When the toolchain lives
// outside /usr (a bundled one) the binary needs an rpath to find
// libc++.so.1 at run time. mingw links libstdc++ and every sysroot build
// (wasm included) brings its own libc++.
if (config.target != "x86_64-w64-mingw32" && config.sysroot.empty()) {
linkExtras += GetToolchain().LibcxxLinkFlags();
}
// mingw uses libstdc++; C++26 std::print/format extras live in libstdc++exp. // mingw uses libstdc++; C++26 std::print/format extras live in libstdc++exp.
// libstdc++ on mingw uses winpthreads for std::atomic_wait / // libstdc++ on mingw uses winpthreads for std::atomic_wait /
// counting_semaphore / stop_token, so -lpthread is required as soon as // counting_semaphore / stop_token, so -lpthread is required as soon as
@ -1125,7 +1118,6 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
if(buildResult.repack) { if(buildResult.repack) {
Progress::Task task(std::format("Linking {}", config.outputName)); Progress::Task task(std::format("Linking {}", config.outputName));
if(config.type == ConfigurationType::Executable) { if(config.type == ConfigurationType::Executable) {
#ifdef __linux__
if(config.target == "x86_64-w64-mingw32") { if(config.target == "x86_64-w64-mingw32") {
try { try {
// Copy any LibraryDynamic dependency DLLs alongside // Copy any LibraryDynamic dependency DLLs alongside
@ -1136,15 +1128,12 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
std::function<void(Configuration*)> copyDepDlls = [&](Configuration* dep) { std::function<void(Configuration*)> copyDepDlls = [&](Configuration* dep) {
if (!dllSeen.insert(dep).second) return; if (!dllSeen.insert(dep).second) return;
if (dep->type == ConfigurationType::LibraryDynamic && dep->target == "x86_64-w64-mingw32") { if (dep->type == ConfigurationType::LibraryDynamic && dep->target == "x86_64-w64-mingw32") {
fs::path depDir = dep->BinDir(); // Only the DLL: Windows resolves it from the exe's
// The DLL itself (Windows resolves it from the // directory at load time. The import lib stays in
// exe's directory at load time) and the mingw // the dependency's own bin dir, which is already
// import lib (so a downstream `crafter-build.exe` // on the link line through buildResult.libs.
// can link a fresh project.dll against it without fs::path src = dep->BinDir() / std::format("{}.dll", dep->outputName);
// hunting through sibling output dirs). if (fs::exists(src)) {
for (auto fname : {std::format("{}.dll", dep->outputName), std::format("lib{}.dll.a", dep->outputName)}) {
fs::path src = depDir / fname;
if (!fs::exists(src)) continue;
fs::path dest = outputDir / src.filename(); fs::path dest = outputDir / src.filename();
if (!fs::exists(dest) || fs::last_write_time(src) > fs::last_write_time(dest)) { if (!fs::exists(dest) || fs::last_write_time(src) > fs::last_write_time(dest)) {
fs::copy(src, dest, fs::copy_options::overwrite_existing); fs::copy(src, dest, fs::copy_options::overwrite_existing);
@ -1163,65 +1152,22 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
} else { } else {
buildResult.result = RunBuildCommand(std::format("{}{} -o {} -fuse-ld=lld{}", command, files, (outputDir/config.outputName).string(), linkExtras)); buildResult.result = RunBuildCommand(std::format("{}{} -o {} -fuse-ld=lld{}", command, files, (outputDir/config.outputName).string(), linkExtras));
} }
#endif
#if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32)
if (config.target == "x86_64-w64-mingw32") {
// Windows host, mingw target: same shape as the Linux→mingw
// path (no LIBCXX_DIR / -lc++ / -nostdlib++ — those are MSVC
// libc++ flags). Copy LibraryDynamic dep DLLs + import libs
// alongside the launcher exe so Windows resolves them from
// the exe's own directory at load time. Runtime DLLs (libstdc++,
// libgcc, libwinpthread) come from msys2 on PATH.
std::unordered_set<Configuration*> dllSeen;
std::function<void(Configuration*)> copyDepDlls = [&](Configuration* dep) {
if (!dllSeen.insert(dep).second) return;
if (dep->type == ConfigurationType::LibraryDynamic && dep->target == "x86_64-w64-mingw32") {
fs::path depDir = dep->BinDir();
for (auto fname : {std::format("{}.dll", dep->outputName), std::format("lib{}.dll.a", dep->outputName)}) {
fs::path src = depDir / fname;
if (!fs::exists(src)) continue;
fs::path dest = outputDir / src.filename();
if (!fs::exists(dest) || fs::last_write_time(src) > fs::last_write_time(dest)) {
fs::copy(src, dest, fs::copy_options::overwrite_existing);
}
}
}
for (Configuration* sub : dep->dependencies) copyDepDlls(sub);
};
for (Configuration* dep : config.dependencies) copyDepDlls(dep);
buildResult.result = RunBuildCommand(std::format("{}{} -o {} -fuse-ld=lld{}", command, files, (outputDir/config.outputName).string(), linkExtras));
} else {
std::system(std::format("copy \"%LIBCXX_DIR%\\lib\\c++.dll\" \"{}\\c++.dll\"", outputDir.string()).c_str());
buildResult.result = RunBuildCommand(std::format("{}{} -o {}.exe -fuse-ld=lld -L %LIBCXX_DIR%\\lib -lc++ -nostdinc++ -nostdlib++{}", command, files, (outputDir/config.outputName).string(), linkExtras));
}
#endif
} else if(config.type == ConfigurationType::LibraryStatic) { } else if(config.type == ConfigurationType::LibraryStatic) {
#ifdef __linux__
// ThinLTO emits LLVM bitcode objects; plain `ar` writes an archive // ThinLTO emits LLVM bitcode objects; plain `ar` writes an archive
// index that omits their symbols, so a consumer's lld link can't // index that omits their symbols, so a consumer's lld link can't
// pull the needed members. llvm-ar writes a bitcode-aware index. // pull the needed members. llvm-ar writes a bitcode-aware index
buildResult.result = RunBuildCommand(std::format("{} rcs {}.a {}", useLto ? "llvm-ar" : "ar", (outputDir/fs::path(std::string("lib")+config.outputName)).string(), files)); // and handles plain ELF and COFF objects just as well, so every
#endif // build uses it.
buildResult.result = RunBuildCommand(std::format("{} rcs {}.a {}", GetToolchain().llvmAr.string(), (outputDir/fs::path(std::string("lib")+config.outputName)).string(), files));
#if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32)
buildResult.result = RunBuildCommand(std::format("llvm-lib.exe {} /OUT:{}.lib", files, (outputDir/fs::path(config.outputName)).string()));
#endif
} else { } else {
// LibraryDynamic. Output names follow each target's convention so // LibraryDynamic. Output names follow each target's convention so
// consumers can link via the standard linker search: // consumers can link via the standard linker search:
// mingw: <name>.dll + lib<name>.dll.a (lld --out-implib) // mingw: <name>.dll + lib<name>.dll.a (lld --out-implib)
// msvc: <name>.dll + <name>.lib (lld /IMPLIB)
// unix: lib<name>.so (rpath $ORIGIN) // unix: lib<name>.so (rpath $ORIGIN)
if (config.target == "x86_64-w64-mingw32") { if (config.target == "x86_64-w64-mingw32") {
fs::path dll = outputDir / std::format("{}.dll", config.outputName); fs::path dll = outputDir / std::format("{}.dll", config.outputName);
fs::path implib = outputDir / std::format("lib{}.dll.a", config.outputName); fs::path implib = outputDir / std::format("lib{}.dll.a", config.outputName);
buildResult.result = RunBuildCommand(std::format("{}{} -shared -o {} -Wl,--out-implib,{} -fuse-ld=lld{}", command, files, dll.string(), implib.string(), linkExtras)); buildResult.result = RunBuildCommand(std::format("{}{} -shared -o {} -Wl,--out-implib,{} -fuse-ld=lld{}", command, files, dll.string(), implib.string(), linkExtras));
} else if (config.target == "x86_64-pc-windows-msvc") {
fs::path dll = outputDir / std::format("{}.dll", config.outputName);
fs::path implib = outputDir / std::format("{}.lib", config.outputName);
buildResult.result = RunBuildCommand(std::format("{}{} -shared -o {} -Wl,/IMPLIB:{} -fuse-ld=lld{}", command, files, dll.string(), implib.string(), linkExtras));
} else { } else {
buildResult.result = RunBuildCommand(std::format("{}{} -shared -o {}.so -Wl,-rpath,'$ORIGIN' -fuse-ld=lld{}", command, files, (outputDir/(std::string("lib")+config.outputName)).string(), linkExtras)); buildResult.result = RunBuildCommand(std::format("{}{} -shared -o {}.so -Wl,-rpath,'$ORIGIN' -fuse-ld=lld{}", command, files, (outputDir/(std::string("lib")+config.outputName)).string(), linkExtras));
} }
@ -1490,14 +1436,32 @@ void Crafter::EnableWasiRelaxedSimdVariant(Configuration& cfg) {
} }
std::string Crafter::HostTarget() { std::string Crafter::HostTarget() {
static const std::string Cached = []() -> std::string { return GetToolchain().hostTriple;
CommandResult r = RunCommandChecked("clang++ -print-target-triple"); }
if (r.exitCode != 0) return {};
std::string out = std::move(r.output); std::string Crafter::NormalizeTarget(std::string_view triple) {
while (!out.empty() && (out.back() == '\n' || out.back() == '\r')) out.pop_back(); // <arch>-<vendor>-<os>-<env>: drop the vendor. A three-part triple only
return out; // carries one when its middle part is a vendor name rather than an OS
}(); // (x86_64-unknown-wasip1 yes, x86_64-linux-gnu no).
return Cached; std::vector<std::string_view> parts;
for (std::size_t begin = 0;;) {
std::size_t dash = triple.find('-', begin);
parts.push_back(dash == std::string_view::npos ? triple.substr(begin) : triple.substr(begin, dash - begin));
if (dash == std::string_view::npos) break;
begin = dash + 1;
}
const bool hasVendor = parts.size() == 4 || (parts.size() == 3 && (parts[1] == "unknown" || parts[1] == "pc"));
if (hasVendor) parts.erase(parts.begin() + 1);
std::string out;
for (std::string_view part : parts) {
if (!out.empty()) out += '-';
out += part;
}
return out;
}
bool Crafter::SameTarget(std::string_view a, std::string_view b) {
return a == b || NormalizeTarget(a) == NormalizeTarget(b);
} }
bool Crafter::ArgQuery::Has(std::string_view flag) const { bool Crafter::ArgQuery::Has(std::string_view flag) const {
@ -1544,11 +1508,9 @@ ArgQuery Crafter::ApplyStandardArgs(Configuration& cfg, std::span<const std::str
// includes it. (Build() also runs this once more for callers that bypassed // includes it. (Build() also runs this once more for callers that bypassed
// ApplyStandardArgs, but doing it here makes dep PcmDirs consistent // ApplyStandardArgs, but doing it here makes dep PcmDirs consistent
// between the consumer's command-construction and the dep's own build.) // between the consumer's command-construction and the dep's own build.)
#ifdef __linux__
if (cfg.sysroot.empty() && cfg.target.starts_with("wasm32")) { if (cfg.sysroot.empty() && cfg.target.starts_with("wasm32")) {
cfg.sysroot = "/usr/share/wasi-sysroot"; cfg.sysroot = GetToolchain().wasiSysroot.string();
} }
#endif
return ArgQuery{args}; return ArgQuery{args};
} }
@ -1567,6 +1529,17 @@ std::vector<fs::path> Crafter::CleanProject(const fs::path& projectFile) {
return removed; return removed;
} }
static void PrintVersion() {
std::println("crafter-build {}", CrafterBuildVersion());
const Toolchain& tc = GetToolchain();
if (!tc.error.empty()) {
std::println("compiler: {}", tc.error);
return;
}
std::println("compiler {} ({})", tc.clangVersion, tc.clangxx.string());
std::println("host {}", tc.hostTriple);
}
static void PrintHelp(std::string_view argv0) { static void PrintHelp(std::string_view argv0) {
std::println( std::println(
R"(Usage: R"(Usage:
@ -1576,6 +1549,7 @@ R"(Usage:
{0} format [format-options] [globs...] Apply the project's transform rules (rewrites files) {0} format [format-options] [globs...] Apply the project's transform rules (rewrites files)
{0} clean Delete the project's bin/ and build/ trees {0} clean Delete the project's bin/ and build/ trees
{0} help | -h | --help Show this help {0} help | -h | --help Show this help
{0} --version Print the version and the compiler in use
Loads ./project.cpp (override with --project=<path>), compiles it to a shared Loads ./project.cpp (override with --project=<path>), compiles it to a shared
object, and invokes its CrafterBuildProject() to obtain a Configuration that object, and invokes its CrafterBuildProject() to obtain a Configuration that
@ -1643,8 +1617,14 @@ Environment:
CRAFTER_BUILD_RUNNER_<TARGET> Default test runner for a target triple. CRAFTER_BUILD_RUNNER_<TARGET> Default test runner for a target triple.
Replace '-' and '.' with '_' in the Replace '-' and '.' with '_' in the
triple. CLI --runner= overrides this. triple. CLI --runner= overrides this.
CRAFTER_MINGW_DIR Override mingw-w64 sysroot auto-detect. CRAFTER_BUILD_LLVM_DIR LLVM prefix to use instead of a toolchain
LIBCXX_DIR Windows libc++ install (MSVC ABI builds). installed beside crafter-build or PATH.
CRAFTER_BUILD_LIBCXX_DIR libc++ install to compile and link against
instead of the compiler's own.
CRAFTER_BUILD_LIBCLANG Exact libclang to load for lint.
CRAFTER_BUILD_WASI_SYSROOT WASI sysroot for wasm32 targets.
CRAFTER_MINGW_DIR mingw-w64 sysroot, passed as --sysroot.
CRAFTER_BUILD_HOME Directory holding the module sources.
Exit status: Exit status:
0 success / all non-skipped tests passed / lint clean / format applied 0 success / all non-skipped tests passed / lint clean / format applied
@ -1672,7 +1652,13 @@ std::int32_t Crafter::Run(std::int32_t argc, char** argv) {
for (std::int32_t i = 1; i < argc; ++i) { for (std::int32_t i = 1; i < argc; ++i) {
std::string_view arg = argv[i]; std::string_view arg = argv[i];
if (arg == "-h" || arg == "--help" || (!runTests && !runLint && !runFormat && arg == "help")) { if (arg == "--version") {
// Answered here, before the project file is looked for, so
// it works in any directory and is never forwarded to
// project.cpp as an unknown argument.
PrintVersion();
return 0;
} else if (arg == "-h" || arg == "--help" || (!runTests && !runLint && !runFormat && arg == "help")) {
PrintHelp(argv0); PrintHelp(argv0);
return 0; return 0;
} else if (!runLint && !runFormat && !runClean && arg == "test") { } else if (!runLint && !runFormat && !runClean && arg == "test") {
@ -1789,11 +1775,7 @@ std::int32_t Crafter::Run(std::int32_t argc, char** argv) {
if (config.target.starts_with("wasm32")) { if (config.target.starts_with("wasm32")) {
const bool browserBuild = fs::exists(absDir / "index.html"); const bool browserBuild = fs::exists(absDir / "index.html");
auto have = [](std::string_view exe) { auto have = [](std::string_view exe) {
#ifdef _WIN32
std::string probe = std::format("where {} > NUL 2>&1", exe);
#else
std::string probe = std::format("command -v {} > /dev/null 2>&1", exe); std::string probe = std::format("command -v {} > /dev/null 2>&1", exe);
#endif
return std::system(probe.c_str()) == 0; return std::system(probe.c_str()) == 0;
}; };
if (browserBuild) { if (browserBuild) {
@ -1801,17 +1783,9 @@ std::int32_t Crafter::Run(std::int32_t argc, char** argv) {
// user has another dev server running we shift up rather // user has another dev server running we shift up rather
// than letting caddy/python exit with EADDRINUSE. // than letting caddy/python exit with EADDRINUSE.
auto findFreePort = [](std::int32_t basePort, std::int32_t span) -> std::int32_t { auto findFreePort = [](std::int32_t basePort, std::int32_t span) -> std::int32_t {
#if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32)
static const bool WsaInit = []{ WSADATA d; return WSAStartup(MAKEWORD(2, 2), &d) == 0; }();
if (!WsaInit) return basePort;
using Sock = SOCKET;
const Sock invalid = INVALID_SOCKET;
auto closesock = [](Sock s){ closesocket(s); };
#else
using Sock = std::int32_t; using Sock = std::int32_t;
const Sock invalid = -1; const Sock invalid = -1;
auto closesock = [](Sock s){ ::close(s); }; auto closesock = [](Sock s){ ::close(s); };
#endif
for (std::int32_t p = basePort; p < basePort + span; ++p) { for (std::int32_t p = basePort; p < basePort + span; ++p) {
Sock s = ::socket(AF_INET, SOCK_STREAM, 0); Sock s = ::socket(AF_INET, SOCK_STREAM, 0);
if (s == invalid) return basePort; if (s == invalid) return basePort;
@ -1934,11 +1908,7 @@ std::int32_t Crafter::Run(std::int32_t argc, char** argv) {
// Silence the backend's own banner/request logs so the // Silence the backend's own banner/request logs so the
// experience is identical regardless of which server is // experience is identical regardless of which server is
// picked — the line above is the only output. // picked — the line above is the only output.
#ifdef _WIN32
cmd += " > NUL 2>&1";
#else
cmd += " > /dev/null 2>&1"; cmd += " > /dev/null 2>&1";
#endif
return std::system(cmd.c_str()) == 0 ? 0 : 1; return std::system(cmd.c_str()) == 0 ? 0 : 1;
} }
// wasi-cli wasm — needs a standalone runtime. // wasi-cli wasm — needs a standalone runtime.
@ -1952,10 +1922,6 @@ std::int32_t Crafter::Run(std::int32_t argc, char** argv) {
return 1; return 1;
} }
// Resolve to absolute — cmd.exe on Windows mishandles a leading
// "./" by trying to interpret it as a command. system() invokes
// through cmd /c, so the relative-prefixed path makes cmd error
// with "'.' is not recognized as an internal or external command".
// Run from the artifact's own directory so relative file opens // Run from the artifact's own directory so relative file opens
// (shaders, assets copied alongside the exe via cfg.files) resolve // (shaders, assets copied alongside the exe via cfg.files) resolve
// against the bin dir rather than the user's cwd. We exit the // against the bin dir rather than the user's cwd. We exit the

View file

@ -4,6 +4,7 @@
module Crafter.Build:External_impl; module Crafter.Build:External_impl;
import std; import std;
import :External; import :External;
import :Toolchain;
import :Platform; import :Platform;
import :Progress; import :Progress;
import :Clang; import :Clang;
@ -21,18 +22,6 @@ std::string DeriveName(const GitSource& source) {
} }
std::string ShellQuote(std::string_view s) { std::string ShellQuote(std::string_view s) {
// Windows cmd treats single quotes as literal characters — only double
// quotes work. Paths don't contain " in practice, so a simple wrap
// suffices; backslashes inside the path stay backslashes.
#ifdef _WIN32
std::string out = "\"";
for (char c : s) {
if (c == '"') out += "\\\"";
else out += c;
}
out += "\"";
return out;
#else
std::string out = "'"; std::string out = "'";
for (char c : s) { for (char c : s) {
if (c == '\'') out += "'\\''"; if (c == '\'') out += "'\\''";
@ -40,7 +29,6 @@ std::string ShellQuote(std::string_view s) {
} }
out += "'"; out += "'";
return out; return out;
#endif
} }
std::string JoinOptions(std::span<const std::string> options) { std::string JoinOptions(std::span<const std::string> options) {
@ -131,19 +119,17 @@ std::string BuildInjectedCMakeFlags(const fs::path& cmakeBuildDir, std::string_v
// suffix to library names assuming a debug build, breaking -l<name> // suffix to library names assuming a debug build, breaking -l<name>
// linking. Pin to Release. // linking. Pin to Release.
out += " -DCMAKE_BUILD_TYPE=Release"; out += " -DCMAKE_BUILD_TYPE=Release";
out += " -DCMAKE_C_COMPILER=clang"; const Toolchain& tc = GetToolchain();
out += " -DCMAKE_CXX_COMPILER=clang++"; out += std::format(" -DCMAKE_C_COMPILER={}", ShellQuote(tc.clang.string()));
// Stdlib choice: libc++ for the Linux host build (matches how crafter-build out += std::format(" -DCMAKE_CXX_COMPILER={}", ShellQuote(tc.clangxx.string()));
// itself is built with -stdlib=libc++); mingw cross-compile uses libstdc++ // Stdlib choice: libc++ for Linux targets (matches how crafter-build
// shipped by the mingw-w64 toolchain so we leave the default; Windows // itself is built with -stdlib=libc++); the mingw cross-compile uses the
// native (msvc target) similarly uses libc++ via LIBCXX_DIR. // libstdc++ shipped by the mingw-w64 toolchain, so it keeps the default.
#ifdef __linux__
if (target.ends_with("-linux-gnu") || target.ends_with("-linux-musl")) { if (target.ends_with("-linux-gnu") || target.ends_with("-linux-musl")) {
out += " -DCMAKE_CXX_FLAGS=-stdlib=libc++"; out += std::format(" -DCMAKE_CXX_FLAGS={}", ShellQuote(std::format("-stdlib=libc++{}", tc.LibcxxCompileFlags())));
out += " -DCMAKE_EXE_LINKER_FLAGS=-stdlib=libc++"; out += std::format(" -DCMAKE_EXE_LINKER_FLAGS={}", ShellQuote(std::format("-stdlib=libc++{}", tc.LibcxxLinkFlags())));
out += " -DCMAKE_SHARED_LINKER_FLAGS=-stdlib=libc++"; out += std::format(" -DCMAKE_SHARED_LINKER_FLAGS={}", ShellQuote(std::format("-stdlib=libc++{}", tc.LibcxxLinkFlags())));
} }
#endif
if (target == "x86_64-w64-mingw32") { if (target == "x86_64-w64-mingw32") {
out += " -DCMAKE_SYSTEM_NAME=Windows"; out += " -DCMAKE_SYSTEM_NAME=Windows";
out += std::format(" -DCMAKE_C_COMPILER_TARGET={}", target); out += std::format(" -DCMAKE_C_COMPILER_TARGET={}", target);
@ -158,6 +144,10 @@ std::string BuildInjectedCMakeFlags(const fs::path& cmakeBuildDir, std::string_v
std::string ConfigureCMake(const fs::path& cloneDir, const fs::path& cmakeBuildDir, std::span<const std::string> options, std::string_view target) { std::string ConfigureCMake(const fs::path& cloneDir, const fs::path& cmakeBuildDir, std::span<const std::string> options, std::string_view target) {
fs::path optionsFile = cmakeBuildDir / ".crafter-options"; fs::path optionsFile = cmakeBuildDir / ".crafter-options";
std::string optionsKey = JoinOptions(options); std::string optionsKey = JoinOptions(options);
// The injected flags are inputs too: a different compiler or libc++
// must reconfigure rather than reuse a CMakeCache made with the old one.
const std::string injected = BuildInjectedCMakeFlags(cmakeBuildDir, target);
const std::string configureKey = injected + '\n' + optionsKey;
bool needsConfigure = !fs::exists(cmakeBuildDir / "CMakeCache.txt"); bool needsConfigure = !fs::exists(cmakeBuildDir / "CMakeCache.txt");
if (!needsConfigure) { if (!needsConfigure) {
@ -167,7 +157,7 @@ std::string ConfigureCMake(const fs::path& cloneDir, const fs::path& cmakeBuildD
} else { } else {
std::stringstream buf; std::stringstream buf;
buf << existing.rdbuf(); buf << existing.rdbuf();
if (buf.str() != optionsKey) needsConfigure = true; if (buf.str() != configureKey) needsConfigure = true;
} }
} }
@ -175,7 +165,7 @@ std::string ConfigureCMake(const fs::path& cloneDir, const fs::path& cmakeBuildD
if (!fs::exists(cmakeBuildDir)) fs::create_directories(cmakeBuildDir); if (!fs::exists(cmakeBuildDir)) fs::create_directories(cmakeBuildDir);
std::string cmd = std::format("cmake -S {} -B {}{}", ShellQuote(fs::absolute(cloneDir).string()), ShellQuote(fs::absolute(cmakeBuildDir).string()), BuildInjectedCMakeFlags(cmakeBuildDir, target)); std::string cmd = std::format("cmake -S {} -B {}{}", ShellQuote(fs::absolute(cloneDir).string()), ShellQuote(fs::absolute(cmakeBuildDir).string()), injected);
if (!options.empty()) { if (!options.empty()) {
cmd += " "; cmd += " ";
cmd += optionsKey; cmd += optionsKey;
@ -186,7 +176,7 @@ std::string ConfigureCMake(const fs::path& cloneDir, const fs::path& cmakeBuildD
return std::format("cmake configure failed (exit {}): {}", r.exitCode, r.output); return std::format("cmake configure failed (exit {}): {}", r.exitCode, r.output);
} }
std::ofstream(optionsFile) << optionsKey; std::ofstream(optionsFile) << configureKey;
return ""; return "";
} }
@ -277,9 +267,9 @@ ExternalBuildResult Crafter::BuildExternal(const ExternalDependency& dep, std::s
// (runtime-loader). The rpath stays embedded in the produced // (runtime-loader). The rpath stays embedded in the produced
// binary so it picks up shared deps from the cache without any // binary so it picks up shared deps from the cache without any
// LD_LIBRARY_PATH gymnastics. Static deps (.a) ignore the rpath // LD_LIBRARY_PATH gymnastics. Static deps (.a) ignore the rpath
// harmlessly. PE/COFF targets (mingw, msvc) resolve DLLs via PATH // harmlessly. The PE/COFF target (mingw) resolves DLLs via PATH or
// or adjacency rather than rpath, so lld warns if we pass it. // adjacency rather than rpath, so lld warns if we pass it.
const bool isPe = target == "x86_64-w64-mingw32" || target == "x86_64-pc-windows-msvc"; const bool isPe = target == "x86_64-w64-mingw32";
std::string buildDirAbs = fs::absolute(cmakeBuildDir).string(); std::string buildDirAbs = fs::absolute(cmakeBuildDir).string();
result.linkFlags.push_back(std::format("-L{}", buildDirAbs)); result.linkFlags.push_back(std::format("-L{}", buildDirAbs));
if (!isPe) result.linkFlags.push_back(std::format("-Wl,-rpath,{}", buildDirAbs)); if (!isPe) result.linkFlags.push_back(std::format("-Wl,-rpath,{}", buildDirAbs));

View file

@ -3,16 +3,13 @@
module; module;
#include <clang-c/Index.h> #include <clang-c/Index.h>
#if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32)
#include <windows.h>
#else
#include <dlfcn.h> #include <dlfcn.h>
#endif
export module Crafter.Build:Lint_impl; export module Crafter.Build:Lint_impl;
import std; import std;
import :Lint; import :Lint;
import :Clang; import :Clang;
import :Platform; import :Platform;
import :Toolchain;
import :Progress; import :Progress;
namespace fs = std::filesystem; namespace fs = std::filesystem;
using namespace Crafter; using namespace Crafter;
@ -20,20 +17,13 @@ using namespace Crafter;
namespace { namespace {
// ---------------- libclang ---------------- // ---------------- libclang ----------------
// //
// libclang is loaded at runtime rather than linked. Linking -lclang would // libclang is loaded at runtime rather than linked, so a plain build never
// break the mingw and MSVC cross-builds at link time and would put a // needs it and the binary carries no libclang.so.NN loader dependency; the
// libclang.so.NN runtime dependency into the otherwise self-contained // clang-c header is used for its declarations only, and every call goes
// release tarballs; the clang-c header is used for its declarations only, // through a pointer resolved here. Failure to load is
// and every call goes through a pointer resolved here. Failure to load is
// a hard error surfaced once by RunLint — there is deliberately no second, // a hard error surfaced once by RunLint — there is deliberately no second,
// weaker lexer to fall back to, because two engines disagreeing about what // weaker lexer to fall back to, because two engines disagreeing about what
// is a comment is a worse failure than not running. // is a comment is a worse failure than not running.
#if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32)
using LibHandle = HMODULE;
LibHandle OpenLibrary(const std::string& name) { return LoadLibraryA(name.c_str()); }
void* LibrarySymbol(LibHandle handle, const std::string& name) { return reinterpret_cast<void*>(GetProcAddress(handle, name.c_str())); }
constexpr std::string_view LibClangNames[] = {"libclang.dll", "clang.dll"};
#else
using LibHandle = void*; using LibHandle = void*;
LibHandle OpenLibrary(const std::string& name) { return dlopen(name.c_str(), RTLD_NOW | RTLD_LOCAL); } LibHandle OpenLibrary(const std::string& name) { return dlopen(name.c_str(), RTLD_NOW | RTLD_LOCAL); }
void* LibrarySymbol(LibHandle handle, const std::string& name) { return dlsym(handle, name.c_str()); } void* LibrarySymbol(LibHandle handle, const std::string& name) { return dlsym(handle, name.c_str()); }
@ -41,7 +31,6 @@ namespace {
"libclang.so", "libclang.so.22.1", "libclang.so.21.1", "libclang.so.20.1", "libclang.so", "libclang.so.22.1", "libclang.so.21.1", "libclang.so.20.1",
"libclang.so.1", "libclang.dylib", "libclang.so.1", "libclang.dylib",
}; };
#endif
// Signatures come from decltype on the header's declarations, so they can // Signatures come from decltype on the header's declarations, so they can
// never drift from the real API. decltype is unevaluated, so naming the // never drift from the real API. decltype is unevaluated, so naming the
@ -104,14 +93,14 @@ namespace {
LibClang LoadLibClang() { LibClang LoadLibClang() {
LibClang lib; LibClang lib;
std::vector<std::string> tried; std::vector<std::string> tried;
// CRAFTER_BUILD_LIBCLANG pins an exact path, mirroring the LIBCXX_DIR / // The toolchain pins libclang when it knows where it is: the
// CRAFTER_MINGW_DIR overrides used elsewhere. It is exclusive: pointing // CRAFTER_BUILD_LIBCLANG override, or the libclang.so beside the
// it at a broken path must fail loudly rather than quietly succeed with // compiler. A pin is exclusive: pointing the override at a broken
// some other libclang, or the override is useless for diagnosing which // path must fail loudly rather than quietly succeed with some other
// library is actually in play. // libclang, or it is useless for diagnosing which library is in play.
std::vector<std::string> candidates; std::vector<std::string> candidates;
if (const char* pinned = std::getenv("CRAFTER_BUILD_LIBCLANG"); pinned && *pinned) { if (const std::optional<fs::path>& pinned = GetToolchain().libclang) {
candidates.emplace_back(pinned); candidates.emplace_back(pinned->string());
} else { } else {
for (std::string_view name : LibClangNames) candidates.emplace_back(name); for (std::string_view name : LibClangNames) candidates.emplace_back(name);
} }
@ -131,7 +120,7 @@ namespace {
tried.push_back(name); tried.push_back(name);
} }
if (!lib.handle) { if (!lib.handle) {
lib.error = std::format("could not load libclang (tried {}); install clang, or point CRAFTER_BUILD_LIBCLANG at it", join(tried)); lib.error = std::format("could not load libclang (tried {}); install clang, set CRAFTER_BUILD_LLVM_DIR, or point CRAFTER_BUILD_LIBCLANG at it", join(tried));
return lib; return lib;
} }
@ -377,8 +366,8 @@ namespace {
} }
if (!args.empty()) args.erase(args.begin()); // argv[0] is the compiler if (!args.empty()) args.erase(args.begin()); // argv[0] is the compiler
for (std::string& arg : args) { for (std::string& arg : args) {
// Only \" unescapes. Erasing every backslash would destroy the // Only \" unescapes; a backslash anywhere else is part of the
// Windows include paths GetBaseCommand puts on the command line. // argument.
std::string unescaped; std::string unescaped;
unescaped.reserve(arg.size()); unescaped.reserve(arg.size());
for (std::size_t i = 0; i < arg.size(); ++i) { for (std::size_t i = 0; i < arg.size(); ++i) {
@ -722,7 +711,7 @@ namespace {
// explicitly. Cached like HostTarget, and for the same reason. // explicitly. Cached like HostTarget, and for the same reason.
const std::string& ClangResourceDir() { const std::string& ClangResourceDir() {
static const std::string Cached = []() -> std::string { static const std::string Cached = []() -> std::string {
CommandResult r = RunCommandChecked("clang++ -print-resource-dir"); CommandResult r = RunCommandChecked(std::format("{} -print-resource-dir", GetToolchain().clangxx.string()));
if (r.exitCode != 0) return {}; if (r.exitCode != 0) return {};
std::string out = std::move(r.output); std::string out = std::move(r.output);
while (!out.empty() && (out.back() == '\n' || out.back() == '\r')) out.pop_back(); while (!out.empty() && (out.back() == '\n' || out.back() == '\r')) out.pop_back();

View file

@ -2,23 +2,19 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module; module;
#if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32)
#include <windows.h>
#endif
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include <limits.h> #include <limits.h>
#ifdef __linux__
#include <unistd.h> #include <unistd.h>
#include <dlfcn.h> #include <dlfcn.h>
#include <sys/wait.h> #include <sys/wait.h>
#include <sys/file.h> #include <sys/file.h>
#include <fcntl.h> #include <fcntl.h>
#include <errno.h> #include <errno.h>
#endif
module Crafter.Build:Platform_impl; module Crafter.Build:Platform_impl;
import std; import std;
import :Platform; import :Platform;
import :Toolchain;
import :Clang; import :Clang;
import :Interface; import :Interface;
import :External; import :External;
@ -43,7 +39,6 @@ namespace {
// descriptor is close-on-exec / non-inheritable so spawned clang processes // descriptor is close-on-exec / non-inheritable so spawned clang processes
// don't keep the lock alive past our own release. // don't keep the lock alive past our own release.
class CacheLock { class CacheLock {
#ifdef __linux__
std::int32_t fd_ = -1; std::int32_t fd_ = -1;
public: public:
explicit CacheLock(const fs::path& cacheDir) { explicit CacheLock(const fs::path& cacheDir) {
@ -57,22 +52,6 @@ namespace {
::close(fd_); // closing the descriptor drops the flock ::close(fd_); // closing the descriptor drops the flock
} }
} }
#else
HANDLE handle_ = INVALID_HANDLE_VALUE;
public:
explicit CacheLock(const fs::path& cacheDir) {
handle_ = CreateFileW((cacheDir / ".lock").wstring().c_str(), GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
if (handle_ != INVALID_HANDLE_VALUE) {
OVERLAPPED ov{};
LockFileEx(handle_, LOCKFILE_EXCLUSIVE_LOCK, 0, MAXDWORD, MAXDWORD, &ov);
}
}
~CacheLock() {
if (handle_ != INVALID_HANDLE_VALUE) {
CloseHandle(handle_); // closing the handle drops the lock
}
}
#endif
CacheLock(const CacheLock&) = delete; CacheLock(const CacheLock&) = delete;
CacheLock& operator=(const CacheLock&) = delete; CacheLock& operator=(const CacheLock&) = delete;
}; };
@ -104,9 +83,6 @@ namespace {
for (std::string_view name : moduleNames) { for (std::string_view name : moduleNames) {
append(fs::path(std::format("{}.cppm", name))); append(fs::path(std::format("{}.cppm", name)));
} }
// Exported through `module;` preambles, so its contents land in the PCMs
// too.
append("Crafter.Build-Api.h");
return std::format("{:016x}", std::hash<std::string>{}(all)); return std::format("{:016x}", std::hash<std::string>{}(all));
} }
@ -114,15 +90,35 @@ namespace {
return cacheDir / "crafter-build-sources.stamp"; return cacheDir / "crafter-build-sources.stamp";
} }
std::string ReadCacheStamp(const fs::path& cacheDir) { // A stamp is a one-line content hash kept next to the artifact it
std::ifstream in(CacheStampPath(cacheDir), std::ios::binary); // describes. Written last, after the artifact, so an interrupted build
// leaves a stamp that disagrees and the next run starts over.
std::string ReadStamp(const fs::path& file) {
std::ifstream in(file, std::ios::binary);
std::string stamp; std::string stamp;
std::getline(in, stamp); std::getline(in, stamp);
return stamp; return stamp;
} }
void WriteCacheStamp(const fs::path& cacheDir, const std::string& stamp) { void WriteStamp(const fs::path& file, std::string_view stamp) {
std::ofstream(CacheStampPath(cacheDir), std::ios::binary | std::ios::trunc) << stamp << '\n'; std::ofstream(file, std::ios::binary | std::ios::trunc) << stamp << '\n';
}
fs::path StampPath(const fs::path& artifact) {
fs::path stamp = artifact;
stamp += ".stamp";
return stamp;
}
std::string HashStamp(std::string_view content) {
return std::format("{:016x}", std::hash<std::string_view>{}(content));
}
std::string FileBytes(const fs::path& file) {
std::ifstream in(file, std::ios::binary);
std::ostringstream buffer;
buffer << in.rdbuf();
return buffer.str();
} }
} }
@ -130,16 +126,7 @@ fs::path Crafter::GetCrafterBuildHome() {
if (const char* envHome = std::getenv("CRAFTER_BUILD_HOME")) { if (const char* envHome = std::getenv("CRAFTER_BUILD_HOME")) {
return fs::path(envHome); return fs::path(envHome);
} }
#if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32)
char buf[MAX_PATH];
DWORD len = GetModuleFileNameA(nullptr, buf, MAX_PATH);
if (len == 0 || len == MAX_PATH) {
throw std::runtime_error("GetModuleFileName failed");
}
fs::path hostExe(std::string(buf, len));
#else
fs::path hostExe = fs::read_symlink("/proc/self/exe"); fs::path hostExe = fs::read_symlink("/proc/self/exe");
#endif
// Walk up from the exe's directory looking for share/crafter-build with // Walk up from the exe's directory looking for share/crafter-build with
// a known module source present as a sentinel. Handles: // a known module source present as a sentinel. Handles:
@ -528,12 +515,17 @@ namespace {
return out; return out;
} }
// project.so predates its source, the crafter-build that will load it, or // project.so is stale when its stamp (crafter-build version, toolchain,
// a pre-dependency artifact — any of them means recompiling. // module sources and compile command) disagrees, or when it predates its
bool ProjectFileStale(const fs::path& artifact, const fs::path& project, const fs::path& hostExe, const ProjectFileDependencies& deps) { // source or a pre-dependency artifact. The executable's mtime is not an
// input any more: an OSTree deployment dates every installed file at the
// epoch, so a comparison against it never fired after an update and the
// old project.so was loaded into a binary it did not match.
bool ProjectFileStale(const fs::path& artifact, const fs::path& project, std::string_view stamp, const ProjectFileDependencies& deps) {
if (!fs::exists(artifact)) return true; if (!fs::exists(artifact)) return true;
if (ReadStamp(StampPath(artifact)) != stamp) return true;
const fs::file_time_type built = fs::last_write_time(artifact); const fs::file_time_type built = fs::last_write_time(artifact);
return built < fs::last_write_time(project) || built < fs::last_write_time(hostExe) || built < deps.newestArtifact; return built < fs::last_write_time(project) || built < deps.newestArtifact;
} }
} }
@ -554,499 +546,12 @@ Configuration* Crafter::ProjectDependency(std::string_view name) {
throw std::runtime_error(std::format("ProjectDependency('{}'): not declared by {} (declared: {})", name, frame.project.string(), declared.empty() ? "none" : declared)); throw std::runtime_error(std::format("ProjectDependency('{}'): not declared by {} (declared: {})", name, frame.project.string(), declared.empty() ? "none" : declared));
} }
#if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32) bool Crafter::IsTopLevelProject() {
CommandResult Crafter::RunCommandChecked(std::string_view cmd) { // One frame is the project file crafter-build was pointed at; a second
std::array<char, 128> buffer; // is only ever pushed for a dependency's project file.
CommandResult result{}; return LoadingFrames.size() <= 1;
std::string with = std::format("cmd /C \"{} 2>&1\"", std::string(cmd));
FILE* pipe = _popen(with.c_str(), "r");
if (!pipe) {
throw std::runtime_error("_popen() failed!");
}
while (fgets(buffer.data(), static_cast<std::int32_t>(buffer.size()), pipe) != nullptr) {
result.output += buffer.data();
}
result.exitCode = _pclose(pipe);
return result;
} }
CommandResult Crafter::RunCommandWithTimeout(std::string_view cmd, std::chrono::seconds timeout) {
CommandResult result{};
SECURITY_ATTRIBUTES sa{ sizeof(sa), nullptr, TRUE };
HANDLE readEnd = nullptr;
HANDLE writeEnd = nullptr;
if (!CreatePipe(&readEnd, &writeEnd, &sa, 0)) {
throw std::runtime_error("CreatePipe failed");
}
SetHandleInformation(readEnd, HANDLE_FLAG_INHERIT, 0);
// KILL_ON_JOB_CLOSE so the cmd.exe wrapper plus whatever it spawned
// (the test binary, ssh, etc.) all die when the job handle goes away —
// that's how we enforce the timeout reliably across the whole tree.
HANDLE job = CreateJobObjectA(nullptr, nullptr);
if (!job) {
CloseHandle(readEnd);
CloseHandle(writeEnd);
throw std::runtime_error("CreateJobObject failed");
}
JOBOBJECT_EXTENDED_LIMIT_INFORMATION jeli{};
jeli.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
SetInformationJobObject(job, JobObjectExtendedLimitInformation, &jeli, sizeof(jeli));
STARTUPINFOA si{};
si.cb = sizeof(si);
si.dwFlags = STARTF_USESTDHANDLES;
si.hStdOutput = writeEnd;
si.hStdError = writeEnd;
si.hStdInput = GetStdHandle(STD_INPUT_HANDLE);
PROCESS_INFORMATION pi{};
// cmd /C "...": gives users the same shell-syntax surface (redirects,
// &&, ||) as popen("/bin/sh -c …") does on Linux. cmd's special-case
// /C parsing strips the outer quote pair when the inner text contains
// additional quotes, which is the common case here.
std::string wrapped = std::format("cmd /C \"{}\"", std::string(cmd));
std::vector<char> cmdBuf(wrapped.begin(), wrapped.end());
cmdBuf.push_back('\0');
BOOL ok = CreateProcessA(nullptr, cmdBuf.data(), nullptr, nullptr, TRUE, CREATE_NO_WINDOW | CREATE_SUSPENDED, nullptr, nullptr, &si, &pi);
if (!ok) {
CloseHandle(readEnd);
CloseHandle(writeEnd);
CloseHandle(job);
throw std::runtime_error("CreateProcess failed");
}
AssignProcessToJobObject(job, pi.hProcess);
ResumeThread(pi.hThread);
// Drop our writer ref so the read end sees EOF once the child (its
// own dup of writeEnd) exits.
CloseHandle(writeEnd);
std::string output;
std::jthread reader([&output, readEnd]() {
char buf[4096];
DWORD n = 0;
while (ReadFile(readEnd, buf, sizeof(buf), &n, nullptr) && n > 0) {
output.append(buf, n);
}
});
DWORD waitMs = static_cast<DWORD>(std::min<std::int64_t>(static_cast<std::int64_t>(timeout.count()) * 1000LL, static_cast<std::int64_t>(INFINITE) - 1));
DWORD waitResult = WaitForSingleObject(pi.hProcess, waitMs);
if (waitResult == WAIT_TIMEOUT) {
TerminateJobObject(job, 1);
WaitForSingleObject(pi.hProcess, INFINITE);
result.timedOut = true;
result.exitCode = 124;
} else {
DWORD exit = 0;
GetExitCodeProcess(pi.hProcess, &exit);
// NTSTATUS error severity (top two bits set) means the process
// terminated by exception — STATUS_ACCESS_VIOLATION 0xC0000005,
// STATUS_STACK_OVERFLOW 0xC00000FD, etc. Surface those as crashes
// so the runner shows 💥 instead of a numeric exit code.
if ((exit & 0xC0000000) == 0xC0000000) {
result.crashed = true;
result.signal = static_cast<std::int32_t>(exit);
}
result.exitCode = static_cast<std::int32_t>(exit);
}
reader.join();
CloseHandle(readEnd);
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
CloseHandle(job);
result.output = std::move(output);
return result;
}
fs::path Crafter::GetCacheDir() {
if (const char* local = std::getenv("LOCALAPPDATA")) {
return fs::path(local) / "crafter.build";
}
throw std::runtime_error("LOCALAPPDATA not set");
}
namespace {
constexpr std::array<std::string_view, 11> CrafterBuildModules = {
"Crafter.Build-Shader",
"Crafter.Build-Platform",
"Crafter.Build-Interface",
"Crafter.Build-Implementation",
"Crafter.Build-External",
"Crafter.Build-Clang",
"Crafter.Build-Test",
"Crafter.Build-Lint",
"Crafter.Build-Progress",
"Crafter.Build-Asset",
"Crafter.Build",
};
}
#endif
#ifdef CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc
// Native Windows build via build.cmd: MSVC ABI + libc++ from %LIBCXX_DIR%.
std::string Crafter::BuildStdPcm(const Configuration& config, fs::path stdPcm) {
std::string libcxx = std::getenv("LIBCXX_DIR");
std::string stdcppm = std::format("{}\\modules\\c++\\v1\\std.cppm", libcxx);
CacheLock lock(stdPcm.parent_path());
if(!fs::exists(stdPcm) || fs::last_write_time(stdPcm) < fs::last_write_time(stdcppm)) {
return RunBuildCommand(std::format("clang++ --target={} -march={} -mtune={} -isystem %LIBCXX_DIR%\\include\\c++\\v1 -nostdinc++ -nostdlib++ -std=c++26 -Wno-reserved-identifier -Wno-reserved-module-identifier --precompile %LIBCXX_DIR%\\modules\\c++\\v1\\std.cppm -o {}", config.target, config.march, config.mtune, stdPcm.string()));
}
return "";
}
std::string Crafter::GetBaseCommand(const Configuration& config) {
return std::format("clang++ -nostdinc++ -nostdlib++ -isystem %LIBCXX_DIR%\\include\\c++\\v1");
}
namespace {
void EnsureCrafterBuildPcms(const fs::path& sourceDir, const fs::path& cacheDir) {
CacheLock lock(cacheDir);
// Re-derived under the lock: another builder may have refreshed the
// cache from its own sources while we waited.
std::string stamp = CrafterBuildSourceStamp(sourceDir, CrafterBuildModules);
bool upToDate = ReadCacheStamp(cacheDir) == stamp;
for (std::string_view name : CrafterBuildModules) {
fs::path cppmPath = sourceDir / std::format("{}.cppm", name);
fs::path pcmPath = cacheDir / std::format("{}.pcm", name);
if (!fs::exists(cppmPath)) {
throw std::runtime_error(std::format("module source {} not found in {} (set CRAFTER_BUILD_HOME)", name, sourceDir.string()));
}
if (upToDate && fs::exists(pcmPath)) {
continue;
}
std::string cmd = std::format(
"clang++ --target=x86_64-pc-windows-msvc -march=native -mtune=native "
"-std=c++26 -O3 -D CRAFTER_BUILD_DLL_IMPORT "
"-nostdinc++ -nostdlib++ -isystem %LIBCXX_DIR%\\include\\c++\\v1 "
"-Wno-reserved-identifier -Wno-reserved-module-identifier "
"-fprebuilt-module-path={} "
"--precompile {} -o {}",
cacheDir.string(), cppmPath.string(), pcmPath.string());
CommandResult r = Crafter::RunCommandChecked(cmd);
if (r.exitCode != 0) {
throw std::runtime_error(std::format("Failed to precompile {} (exit {}): {}", name, r.exitCode, r.output));
}
}
// Last, so an interrupted rebuild leaves the stamp disagreeing with the
// sources and the next run starts over rather than trusting a half-built
// set of PCMs.
if (!upToDate) WriteCacheStamp(cacheDir, stamp);
}
}
Configuration Crafter::LoadProject(const fs::path& projectFile, std::span<const std::string_view> args) {
fs::path absProject = fs::canonical(projectFile);
ProjectFileGuard guard(absProject);
fs::path buildDir = absProject.parent_path() / "build";
if (!fs::exists(buildDir)) {
fs::create_directories(buildDir);
}
fs::path dllPath = buildDir / std::format("{}.dll", absProject.stem().string());
char hostExeBuf[MAX_PATH];
DWORD hostExeLen = GetModuleFileNameA(nullptr, hostExeBuf, MAX_PATH);
if (hostExeLen == 0 || hostExeLen == MAX_PATH) {
throw std::runtime_error("GetModuleFileName failed");
}
fs::path hostExe(std::string(hostExeBuf, hostExeLen));
fs::path sourceDir = Crafter::GetCrafterBuildHome();
Configuration hostConfig;
hostConfig.target = "x86_64-pc-windows-msvc";
hostConfig.march = "native";
hostConfig.mtune = "native";
fs::path cacheDir = GetCacheDir() / std::format("{}-{}", hostConfig.target, hostConfig.march);
if (!fs::exists(cacheDir)) fs::create_directories(cacheDir);
std::string stdResult = BuildStdPcm(hostConfig, cacheDir / "std.pcm");
if (!stdResult.empty()) {
throw std::runtime_error(std::format("Failed to build std.pcm: {}", stdResult));
}
EnsureCrafterBuildPcms(sourceDir, cacheDir);
// See the Linux LoadProject for why this sits here and runs every time.
ProjectFileDependencies preDeps = ResolveProjectFileDependencies(absProject, hostConfig);
bool stale = ProjectFileStale(dllPath, absProject, hostExe, preDeps);
if (stale) {
fs::path crafterBuildLib = hostExe.parent_path() / "crafter-build.lib";
std::string compileCmd = std::format(
"clang++ --target=x86_64-pc-windows-msvc -march=native -mtune=native "
"-std=c++26 -shared -O3 -Wno-return-type-c-linkage -fuse-ld=lld "
"-D CRAFTER_BUILD_DLL_IMPORT "
"-nostdinc++ -nostdlib++ -isystem %LIBCXX_DIR%\\include\\c++\\v1 "
"-fprebuilt-module-path={}{} "
"-Wl,/EXPORT:CrafterBuildProject "
"{} {}{} -o {} -L %LIBCXX_DIR%\\lib -lc++",
cacheDir.string(), preDeps.compileFlags,
absProject.string(), crafterBuildLib.string(), preDeps.linkFlags, dllPath.string());
std::string result = RunBuildCommand(compileCmd);
if (!result.empty()) {
throw std::runtime_error(std::format("Failed to compile project {}: {}", absProject.string(), result));
}
}
// project.dll links libc++ dynamically (-lc++) and the dll typically lives
// under %LIBCXX_DIR%\lib. Add it to the loader's search path so the user
// doesn't have to put it on PATH manually.
if (const char* libcxx = std::getenv("LIBCXX_DIR"); libcxx && *libcxx) {
fs::path libcxxLib = fs::path(libcxx) / "lib";
SetDllDirectoryA(libcxxLib.string().c_str());
}
HMODULE handle = LoadLibraryA(dllPath.string().c_str());
if (!handle) {
throw std::runtime_error(std::format("Failed to load project {}: error {}", dllPath.string(), GetLastError()));
}
using ProjectFn = Configuration (*)(std::span<const std::string_view>);
auto fn = reinterpret_cast<ProjectFn>(GetProcAddress(handle, "CrafterBuildProject"));
if (!fn) {
throw std::runtime_error(std::format("CrafterBuildProject not found in {}: error {}", dllPath.string(), GetLastError()));
}
guard.Frame().dependencies = std::move(preDeps.configs);
return fn(args);
}
#endif
#ifdef CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32
// Linux→mingw cross-compile run on Windows: mingw ABI + libstdc++ from msys2.
// Resolves the mingw sysroot at C:\msys64\ucrt64 by default (UCRT runtime,
// matching the Arch cross-compile that produced this binary). User can
// override with CRAFTER_MINGW_DIR if they have mingw-w64 installed elsewhere
// or want the legacy MSVCRT mingw at C:\msys64\mingw64. clang itself is the
// user's existing standalone clang on PATH.
namespace {
fs::path MingwPrefix() {
if (const char* env = std::getenv("CRAFTER_MINGW_DIR"); env && *env) {
return fs::path(env);
}
return "C:\\msys64\\ucrt64";
}
std::string MingwGccVersion() {
fs::path includeRoot = MingwPrefix() / "include" / "c++";
if (!fs::exists(includeRoot)) {
throw std::runtime_error(std::format("mingw-w64 not found at {} (install msys2 mingw-w64-x86_64-toolchain or set CRAFTER_MINGW_DIR)", MingwPrefix().string()));
}
std::vector<std::string> versions;
for (const auto& entry : fs::directory_iterator(includeRoot)) {
if (entry.is_directory()) versions.push_back(entry.path().filename().string());
}
if (versions.empty()) {
throw std::runtime_error(std::format("no C++ versions under {}", includeRoot.string()));
}
std::sort(versions.begin(), versions.end(), std::greater<>());
return versions.front();
}
}
std::string Crafter::BuildStdPcm(const Configuration& config, fs::path stdPcm) {
CacheLock lock(stdPcm.parent_path());
if (config.target == "x86_64-pc-windows-msvc") {
// MSVC target on mingw host: same MSVC libc++ logic as the
// native-MSVC LoadProject path. User must have LIBCXX_DIR pointing
// at a Windows libc++ install.
const char* libcxxEnv = std::getenv("LIBCXX_DIR");
if (!libcxxEnv || !*libcxxEnv) {
return "MSVC target requires LIBCXX_DIR pointing at a Windows libc++ install";
}
std::string stdcppm = std::format("{}\\modules\\c++\\v1\\std.cppm", libcxxEnv);
if (fs::exists(stdPcm) && fs::last_write_time(stdPcm) >= fs::last_write_time(stdcppm)) {
return "";
}
return RunBuildCommand(std::format(
"clang++ --target={} -march={} -mtune={} -isystem %LIBCXX_DIR%\\include\\c++\\v1 "
"-nostdinc++ -nostdlib++ -std=c++26 -Wno-reserved-identifier -Wno-reserved-module-identifier "
"--precompile %LIBCXX_DIR%\\modules\\c++\\v1\\std.cppm -o {}",
config.target, config.march, config.mtune, stdPcm.string()));
}
// Default: mingw target. Look up mingw-w64 libstdc++ via the msys2 prefix.
fs::path prefix = MingwPrefix();
fs::path stdCc = prefix / "include" / "c++" / MingwGccVersion() / "bits" / "std.cc";
if (!fs::exists(stdCc)) {
return std::format("std.cc not found at {}", stdCc.string());
}
if (fs::exists(stdPcm) && fs::last_write_time(stdPcm) >= fs::last_write_time(stdCc)) {
return "";
}
// Copy std.cc → std.cppm in C++ rather than via cmd's `copy /Y`: keeping
// it in-process means one less shell round-trip and a std::error_code we
// can report directly. Held under the cache lock, so a plain filename is
// safe — no other builder writes this path concurrently.
fs::path stdCppm = stdPcm.parent_path() / "std.cppm";
std::error_code ec;
fs::copy_file(stdCc, stdCppm, fs::copy_options::overwrite_existing, ec);
if (ec) {
return std::format("copy {} -> {}: {}", stdCc.string(), stdCppm.string(), ec.message());
}
return RunBuildCommand(std::format(
"clang++ --target={} -march={} -mtune={} "
"--sysroot=\"{}\" -femulated-tls "
"-O3 -std=c++26 -Wno-reserved-identifier -Wno-reserved-module-identifier "
"--precompile \"{}\" -o {}",
config.target, config.march, config.mtune,
prefix.string(),
stdCppm.string(),
stdPcm.string()));
}
std::string Crafter::GetBaseCommand(const Configuration& config) {
if (config.target == "x86_64-pc-windows-msvc") {
return std::format("clang++ -nostdinc++ -nostdlib++ -isystem %LIBCXX_DIR%\\include\\c++\\v1");
}
return std::format("clang++ --sysroot=\"{}\" -femulated-tls", MingwPrefix().string());
}
namespace {
void EnsureCrafterBuildPcms(const fs::path& sourceDir, const fs::path& cacheDir) {
CacheLock lock(cacheDir);
// Re-derived under the lock: another builder may have refreshed the
// cache from its own sources while we waited.
std::string stamp = CrafterBuildSourceStamp(sourceDir, CrafterBuildModules);
bool upToDate = ReadCacheStamp(cacheDir) == stamp;
fs::path prefix = MingwPrefix();
for (std::string_view name : CrafterBuildModules) {
fs::path cppmPath = sourceDir / std::format("{}.cppm", name);
fs::path pcmPath = cacheDir / std::format("{}.pcm", name);
if (!fs::exists(cppmPath)) {
throw std::runtime_error(std::format("module source {} not found in {} (set CRAFTER_BUILD_HOME)", name, sourceDir.string()));
}
if (upToDate && fs::exists(pcmPath)) {
continue;
}
std::string cmd = std::format(
"clang++ --target=x86_64-w64-mingw32 -march=native -mtune=native "
"--sysroot=\"{}\" -femulated-tls "
"-std=c++26 -O3 -D CRAFTER_BUILD_DLL_IMPORT "
"-Wno-reserved-identifier -Wno-reserved-module-identifier "
"-fprebuilt-module-path={} "
"--precompile {} -o {}",
prefix.string(), cacheDir.string(), cppmPath.string(), pcmPath.string());
CommandResult r = Crafter::RunCommandChecked(cmd);
if (r.exitCode != 0) {
throw std::runtime_error(std::format("Failed to precompile {} (exit {}): {}", name, r.exitCode, r.output));
}
}
// Last, so an interrupted rebuild leaves the stamp disagreeing with the
// sources and the next run starts over rather than trusting a half-built
// set of PCMs.
if (!upToDate) WriteCacheStamp(cacheDir, stamp);
}
}
Configuration Crafter::LoadProject(const fs::path& projectFile, std::span<const std::string_view> args) {
fs::path absProject = fs::canonical(projectFile);
ProjectFileGuard guard(absProject);
fs::path buildDir = absProject.parent_path() / "build";
if (!fs::exists(buildDir)) fs::create_directories(buildDir);
fs::path dllPath = buildDir / std::format("{}.dll", absProject.stem().string());
char hostExeBuf[MAX_PATH];
DWORD hostExeLen = GetModuleFileNameA(nullptr, hostExeBuf, MAX_PATH);
if (hostExeLen == 0 || hostExeLen == MAX_PATH) {
throw std::runtime_error("GetModuleFileName failed");
}
fs::path hostExe(std::string(hostExeBuf, hostExeLen));
fs::path sourceDir = Crafter::GetCrafterBuildHome();
Configuration hostConfig;
hostConfig.target = "x86_64-w64-mingw32";
hostConfig.march = "native";
hostConfig.mtune = "native";
fs::path cacheDir = GetCacheDir() / std::format("{}-{}", hostConfig.target, hostConfig.march);
if (!fs::exists(cacheDir)) fs::create_directories(cacheDir);
std::string stdResult = BuildStdPcm(hostConfig, cacheDir / "std.pcm");
if (!stdResult.empty()) {
throw std::runtime_error(std::format("Failed to build std.pcm: {}", stdResult));
}
EnsureCrafterBuildPcms(sourceDir, cacheDir);
// See the Linux LoadProject for why this sits here and runs every time.
ProjectFileDependencies preDeps = ResolveProjectFileDependencies(absProject, hostConfig);
bool stale = ProjectFileStale(dllPath, absProject, hostExe, preDeps);
if (stale) {
// The import lib lives next to the launcher exe (Build()'s mingw
// copy step puts libcrafter-build.dll.a there) so -L<exe-dir>
// -lcrafter-build resolves it.
fs::path prefix = MingwPrefix();
std::string compileCmd = std::format(
"clang++ --target=x86_64-w64-mingw32 -march=native -mtune=native "
"--sysroot=\"{}\" -femulated-tls "
"-std=c++26 -shared -O3 -Wno-return-type-c-linkage -fuse-ld=lld "
"-D CRAFTER_BUILD_DLL_IMPORT "
"-fprebuilt-module-path={}{} "
// mingw-lld doesn't accept /EXPORT:NAME — use the catch-all so
// CrafterBuildProject is reachable via GetProcAddress without
// forcing project.cpp templates to add __declspec(dllexport).
"-Wl,--export-all-symbols "
// Dependency archives go before -lcrafter-build: they reference
// Crafter.Build symbols, and ld.lld resolves archives in order.
"{} -o {}{} -L\"{}\" -lcrafter-build -lstdc++exp -lpthread",
prefix.string(),
cacheDir.string(), preDeps.compileFlags,
absProject.string(), dllPath.string(), preDeps.linkFlags,
hostExe.parent_path().string());
std::string result = RunBuildCommand(compileCmd);
if (!result.empty()) {
throw std::runtime_error(std::format("Failed to compile project {}: {}", absProject.string(), result));
}
}
// project.dll is a mingw DLL linking libstdc++-6.dll / libgcc_s_seh-1.dll /
// libwinpthread-1.dll dynamically (LoadProject's compile uses -lstdc++exp
// -lpthread, not -static). We already know where they live from
// MingwPrefix(), so add bin/ to the loader's search path here instead of
// requiring the user to prepend it to PATH manually.
fs::path mingwBin = MingwPrefix() / "bin";
SetDllDirectoryA(mingwBin.string().c_str());
HMODULE handle = LoadLibraryA(dllPath.string().c_str());
if (!handle) {
throw std::runtime_error(std::format("Failed to load project {}: error {}", dllPath.string(), GetLastError()));
}
using ProjectFn = Configuration (*)(std::span<const std::string_view>);
auto fn = reinterpret_cast<ProjectFn>(GetProcAddress(handle, "CrafterBuildProject"));
if (!fn) {
throw std::runtime_error(std::format("CrafterBuildProject not found in {}: error {}", dllPath.string(), GetLastError()));
}
guard.Frame().dependencies = std::move(preDeps.configs);
return fn(args);
}
#endif
#ifdef __linux__
CommandResult Crafter::RunCommandChecked(std::string_view cmd) { CommandResult Crafter::RunCommandChecked(std::string_view cmd) {
std::array<char, 128> buffer; std::array<char, 128> buffer;
CommandResult result{}; CommandResult result{};
@ -1116,7 +621,7 @@ std::string Crafter::BuildStdPcm(const Configuration& config, fs::path stdPcm) {
std::vector<std::string> folders; std::vector<std::string> folders;
// Iterate through the directory and collect all subdirectories // Iterate through the directory and collect all subdirectories
for (const auto& entry : fs::directory_iterator("/usr/x86_64-w64-mingw32/include/c++")) { for (const auto& entry : fs::directory_iterator(GetToolchain().mingwSysroot / "include" / "c++")) {
if (entry.is_directory()) { if (entry.is_directory()) {
folders.push_back(entry.path().filename().string()); folders.push_back(entry.path().filename().string());
} }
@ -1129,23 +634,24 @@ std::string Crafter::BuildStdPcm(const Configuration& config, fs::path stdPcm) {
std::string mingWversion = folders.front(); std::string mingWversion = folders.front();
fs::path stdCc = fs::path(std::format("/usr/x86_64-w64-mingw32/include/c++/{}/bits/std.cc", mingWversion)); fs::path stdCc = GetToolchain().mingwSysroot / "include" / "c++" / mingWversion / "bits" / "std.cc";
if(!fs::exists(stdPcm) || fs::last_write_time(stdPcm) < fs::last_write_time(stdCc)) { // -femulated-tls keeps PCM TLS access matching libstdc++'s emutls
// -femulated-tls keeps PCM TLS access matching libstdc++'s emutls // definitions; mismatch surfaces as undefined std::__once_callable.
// definitions; mismatch surfaces as undefined std::__once_callable. // The std.cc → std.cppm copy uses a plain filename: held under the
// The std.cc → std.cppm copy uses a plain filename: held under the // cache lock, no other builder writes this path concurrently.
// cache lock, no other builder writes this path concurrently. fs::path stdCppm = stdPcm.parent_path() / "std.cppm";
fs::path stdCppm = stdPcm.parent_path() / "std.cppm"; std::string command = std::format("{} --target={} -march={} -mtune={} -femulated-tls -O3 -std=c++26 -Wno-reserved-identifier -Wno-reserved-module-identifier --precompile {} -o {}", GetBaseCommand(config), config.target, config.march, config.mtune, stdCppm.string(), stdPcm.string());
std::error_code copyEc; std::string stamp = HashStamp(command + '\0' + FileBytes(stdCc));
fs::copy_file(stdCc, stdCppm, fs::copy_options::overwrite_existing, copyEc); if (fs::exists(stdPcm) && ReadStamp(StampPath(stdPcm)) == stamp) return "";
if (copyEc) { std::error_code copyEc;
return std::format("copy {} -> {}: {}", stdCc.string(), stdCppm.string(), copyEc.message()); fs::copy_file(stdCc, stdCppm, fs::copy_options::overwrite_existing, copyEc);
} if (copyEc) {
return RunBuildCommand(std::format("clang++ --target={} -march={} -mtune={} -femulated-tls -O3 -std=c++26 -Wno-reserved-identifier -Wno-reserved-module-identifier --precompile {} -o {}", config.target, config.march, config.mtune, stdCppm.string(), stdPcm.string())); return std::format("copy {} -> {}: {}", stdCc.string(), stdCppm.string(), copyEc.message());
} else {
return "";
} }
std::string result = RunBuildCommand(command);
if (result.empty()) WriteStamp(StampPath(stdPcm), stamp);
return result;
} else { } else {
const bool isWasm = config.target.starts_with("wasm32"); const bool isWasm = config.target.starts_with("wasm32");
// wasi-sdk drops std.cppm at <sysroot>/share/libc++/v1/, the rest of // wasi-sdk drops std.cppm at <sysroot>/share/libc++/v1/, the rest of
@ -1155,15 +661,15 @@ std::string Crafter::BuildStdPcm(const Configuration& config, fs::path stdPcm) {
if (isWasm) { if (isWasm) {
stdCppm = std::format("{}/share/libc++/v1/std.cppm", config.sysroot); stdCppm = std::format("{}/share/libc++/v1/std.cppm", config.sysroot);
} else if (config.sysroot.empty()) { } else if (config.sysroot.empty()) {
stdCppm = "/usr/share/libc++/v1/std.cppm"; stdCppm = GetToolchain().stdCppm.string();
} else { } else {
stdCppm = std::format("{}/usr/share/libc++/v1/std.cppm", config.sysroot); stdCppm = std::format("{}/usr/share/libc++/v1/std.cppm", config.sysroot);
// not every distro ships the std module sources (Alpine's // not every distro ships the std module sources (Alpine's
// libc++-dev doesn't); the host copy is interchangeable as long // libc++-dev doesn't); the host copy is interchangeable as long
// as the libc++ versions line up, which the compile of the PCM // as the libc++ versions line up, which the compile of the PCM
// against the sysroot's headers verifies anyway // against the sysroot's headers verifies anyway
if (!fs::exists(stdCppm) && fs::exists("/usr/share/libc++/v1/std.cppm")) { if (!fs::exists(stdCppm) && fs::exists(GetToolchain().stdCppm)) {
stdCppm = "/usr/share/libc++/v1/std.cppm"; stdCppm = GetToolchain().stdCppm.string();
} }
} }
std::string sysrootFlag = config.sysroot.empty() std::string sysrootFlag = config.sysroot.empty()
@ -1185,15 +691,20 @@ std::string Crafter::BuildStdPcm(const Configuration& config, fs::path stdPcm) {
// non-wasm cross builds: the driver ranks the host toolchain's own // non-wasm cross builds: the driver ranks the host toolchain's own
// libc++ headers (<install>/../include/c++/v1) above --sysroot, so // libc++ headers (<install>/../include/c++/v1) above --sysroot, so
// the PCM would mix host libc++ with the foreign libc — force the // the PCM would mix host libc++ with the foreign libc — force the
// sysroot's copy (same trick as the Windows LIBCXX_DIR path) // sysroot's copy.
if (!isWasm && !config.sysroot.empty()) { if (!isWasm && !config.sysroot.empty()) {
archFlags += std::format(" -nostdinc++ -isystem {}/usr/include/c++/v1", config.sysroot); archFlags += std::format(" -nostdinc++ -isystem {}/usr/include/c++/v1", config.sysroot);
} }
if(!fs::exists(stdPcm) || fs::last_write_time(stdPcm) < fs::last_write_time(stdCppm)) { // Keyed on the command and the module source's bytes, never on
return RunBuildCommand(std::format("clang++ --target={} -std=c++26 -stdlib=libc++{}{} -O3 -Wno-reserved-identifier -Wno-reserved-module-identifier --precompile {} -o {}", config.target, sysrootFlag, archFlags, stdCppm, stdPcm.string())); // mtimes: an installed std.cppm can be dated at the epoch (OSTree),
} else { // and a libc++ update under the same clang changes the bytes but
return ""; // not the path.
} std::string command = std::format("{} --target={} -std=c++26{}{} -O3 -Wno-reserved-identifier -Wno-reserved-module-identifier --precompile {} -o {}", GetBaseCommand(config), config.target, sysrootFlag, archFlags, stdCppm, stdPcm.string());
std::string stamp = HashStamp(command + '\0' + FileBytes(stdCppm));
if (fs::exists(stdPcm) && ReadStamp(StampPath(stdPcm)) == stamp) return "";
std::string result = RunBuildCommand(command);
if (result.empty()) WriteStamp(StampPath(stdPcm), stamp);
return result;
} }
} }
@ -1210,17 +721,24 @@ fs::path Crafter::GetCacheDir() {
} }
std::string Crafter::GetBaseCommand(const Configuration& config) { std::string Crafter::GetBaseCommand(const Configuration& config) {
std::string stdlib; const Toolchain& tc = GetToolchain();
if(config.target == "x86_64-w64-mingw32") { std::string command = tc.clangxx.string();
stdlib = ""; if (config.target == "x86_64-w64-mingw32") {
} else { // libstdc++ from the mingw toolchain, found by clang's own mingw
stdlib = "-stdlib=libc++"; // discovery unless the sysroot was given explicitly.
if (tc.mingwSysrootExplicit) command += std::format(" --sysroot={}", tc.mingwSysroot.string());
return command;
} }
return std::format("clang++ {}", stdlib); command += " -stdlib=libc++";
// A sysroot build (wasm, a cross rootfs) takes libc++ from the sysroot;
// only host builds can be pointed at an explicit libc++ install.
if (config.sysroot.empty()) command += tc.LibcxxCompileFlags();
return command;
} }
namespace { namespace {
constexpr std::array<std::string_view, 11> CrafterBuildModules = { constexpr std::array<std::string_view, 12> CrafterBuildModules = {
"Crafter.Build-Toolchain",
"Crafter.Build-Shader", "Crafter.Build-Shader",
"Crafter.Build-Platform", "Crafter.Build-Platform",
"Crafter.Build-Interface", "Crafter.Build-Interface",
@ -1234,12 +752,17 @@ namespace {
"Crafter.Build", "Crafter.Build",
}; };
void EnsureCrafterBuildPcms(const fs::path& sourceDir, const fs::path& cacheDir) { // Returns the stamp of the module sources the cache was built from; it is
// one of project.so's inputs.
std::string EnsureCrafterBuildPcms(const fs::path& sourceDir, const fs::path& cacheDir, const Configuration& host) {
CacheLock lock(cacheDir); CacheLock lock(cacheDir);
// Re-derived under the lock: another builder may have refreshed the // Re-derived under the lock: another builder may have refreshed the
// cache from its own sources while we waited. // cache from its own sources while we waited.
std::string stamp = CrafterBuildSourceStamp(sourceDir, CrafterBuildModules); // The std PCM is an input to every module PCM: its stamp is folded in
const bool upToDate = ReadCacheStamp(cacheDir) == stamp; // so a rebuilt std.pcm (a libc++ update under the same clang) rebuilds
// them too, instead of leaving PCMs that import a std.pcm that is gone.
std::string stamp = HashStamp(CrafterBuildSourceStamp(sourceDir, CrafterBuildModules) + '\0' + ReadStamp(StampPath(cacheDir / "std.pcm")));
const bool upToDate = ReadStamp(CacheStampPath(cacheDir)) == stamp;
for (std::string_view name : CrafterBuildModules) { for (std::string_view name : CrafterBuildModules) {
fs::path cppmPath = sourceDir / std::format("{}.cppm", name); fs::path cppmPath = sourceDir / std::format("{}.cppm", name);
fs::path pcmPath = cacheDir / std::format("{}.pcm", name); fs::path pcmPath = cacheDir / std::format("{}.pcm", name);
@ -1250,12 +773,12 @@ namespace {
continue; continue;
} }
std::string cmd = std::format( std::string cmd = std::format(
"clang++ --target={} -march=native -mtune=native " "{} --target={} -march=native -mtune=native "
"-std=c++26 -stdlib=libc++ -O3 " "-std=c++26 -O3 "
"-Wno-reserved-identifier -Wno-reserved-module-identifier " "-Wno-reserved-identifier -Wno-reserved-module-identifier "
"-fprebuilt-module-path={} " "-fprebuilt-module-path={} "
"--precompile {} -o {}", "--precompile {} -o {}",
HostTarget(), cacheDir.string(), cppmPath.string(), pcmPath.string()); GetBaseCommand(host), host.target, cacheDir.string(), cppmPath.string(), pcmPath.string());
CommandResult r = Crafter::RunCommandChecked(cmd); CommandResult r = Crafter::RunCommandChecked(cmd);
if (r.exitCode != 0) { if (r.exitCode != 0) {
throw std::runtime_error(std::format("Failed to precompile {} (exit {}): {}", name, r.exitCode, r.output)); throw std::runtime_error(std::format("Failed to precompile {} (exit {}): {}", name, r.exitCode, r.output));
@ -1264,11 +787,13 @@ namespace {
// Last, so an interrupted rebuild leaves the stamp disagreeing with the // Last, so an interrupted rebuild leaves the stamp disagreeing with the
// sources and the next run starts over rather than trusting a half-built // sources and the next run starts over rather than trusting a half-built
// set of PCMs. // set of PCMs.
if (!upToDate) WriteCacheStamp(cacheDir, stamp); if (!upToDate) WriteStamp(CacheStampPath(cacheDir), stamp);
return stamp;
} }
} }
Configuration Crafter::LoadProject(const fs::path& projectFile, std::span<const std::string_view> args) { Configuration Crafter::LoadProject(const fs::path& projectFile, std::span<const std::string_view> args) {
RequireToolchain();
fs::path absProject = fs::canonical(projectFile); fs::path absProject = fs::canonical(projectFile);
ProjectFileGuard guard(absProject); ProjectFileGuard guard(absProject);
fs::path buildDir = absProject.parent_path() / "build"; fs::path buildDir = absProject.parent_path() / "build";
@ -1277,8 +802,6 @@ Configuration Crafter::LoadProject(const fs::path& projectFile, std::span<const
} }
fs::path soPath = buildDir / std::format("{}.so", absProject.stem().string()); fs::path soPath = buildDir / std::format("{}.so", absProject.stem().string());
fs::path hostExe = fs::read_symlink("/proc/self/exe");
fs::path sourceDir = Crafter::GetCrafterBuildHome(); fs::path sourceDir = Crafter::GetCrafterBuildHome();
// The host toolchain's own triple (x86_64-pc-linux-gnu on glibc distros, // The host toolchain's own triple (x86_64-pc-linux-gnu on glibc distros,
@ -1288,7 +811,9 @@ Configuration Crafter::LoadProject(const fs::path& projectFile, std::span<const
hostConfig.target = HostTarget(); hostConfig.target = HostTarget();
hostConfig.march = "native"; hostConfig.march = "native";
hostConfig.mtune = "native"; hostConfig.mtune = "native";
fs::path cacheDir = GetCacheDir() / std::format("{}-{}", hostConfig.target, hostConfig.march); // The same directory Build() resolves the std PCM from, toolchain key
// included (see StdPcmDir).
fs::path cacheDir = StdPcmDir(hostConfig);
if (!fs::exists(cacheDir)) fs::create_directories(cacheDir); if (!fs::exists(cacheDir)) fs::create_directories(cacheDir);
std::string stdResult = BuildStdPcm(hostConfig, cacheDir / "std.pcm"); std::string stdResult = BuildStdPcm(hostConfig, cacheDir / "std.pcm");
@ -1296,7 +821,7 @@ Configuration Crafter::LoadProject(const fs::path& projectFile, std::span<const
throw std::runtime_error(std::format("Failed to build std.pcm: {}", stdResult)); throw std::runtime_error(std::format("Failed to build std.pcm: {}", stdResult));
} }
EnsureCrafterBuildPcms(sourceDir, cacheDir); const std::string sourcesStamp = EnsureCrafterBuildPcms(sourceDir, cacheDir, hostConfig);
// After the host PCMs exist (a dependency's build imports Crafter.Build // After the host PCMs exist (a dependency's build imports Crafter.Build
// from that cache) and before the staleness check (its artifacts are // from that cache) and before the staleness check (its artifacts are
@ -1305,26 +830,30 @@ Configuration Crafter::LoadProject(const fs::path& projectFile, std::span<const
// clang crashes on it. // clang crashes on it.
ProjectFileDependencies preDeps = ResolveProjectFileDependencies(absProject, hostConfig); ProjectFileDependencies preDeps = ResolveProjectFileDependencies(absProject, hostConfig);
const bool stale = ProjectFileStale(soPath, absProject, hostExe, preDeps); // -fuse-ld=lld like every other link the tool performs: lld is a hard
// dependency, binutils' ld is not (Alpine's clang would default to it),
if (stale) { // and a dependency archive built in release holds ThinLTO bitcode,
// -fuse-ld=lld like every other link the tool performs: lld is a hard // which GNU ld can't read.
// dependency, binutils' ld is not (Alpine's clang would default to it), std::string compileCmd = std::format(
// and a dependency archive built in release holds ThinLTO bitcode, "{} --target={} -march=native -mtune=native "
// which GNU ld can't read. "-std=c++26 -shared -fPIC -O3 -fuse-ld=lld "
std::string compileCmd = std::format( "-Wno-return-type-c-linkage "
"clang++ --target={} -march=native -mtune=native " "-fprebuilt-module-path={}{} "
"-std=c++26 -stdlib=libc++ -shared -fPIC -O3 -fuse-ld=lld " "{} -o {}{}{}",
"-Wno-return-type-c-linkage " GetBaseCommand(hostConfig), hostConfig.target, cacheDir.string(), preDeps.compileFlags,
"-fprebuilt-module-path={}{} " absProject.string(), soPath.string(), preDeps.linkFlags, GetToolchain().LibcxxLinkFlags());
"{} -o {}{}", // What project.so was compiled against. The module source stamp stands
hostConfig.target, cacheDir.string(), preDeps.compileFlags, // in for the executable: project.so resolves Crafter:: symbols from the
absProject.string(), soPath.string(), preDeps.linkFlags); // running binary, so only the declarations (the .cppm files) matter,
// and an implementation-only rebuild of crafter-build leaves it valid.
const std::string projectStamp = HashStamp(std::format("{}\0{}\0{}\0{}", CrafterBuildVersion(), GetToolchain().Key(), sourcesStamp, compileCmd));
if (ProjectFileStale(soPath, absProject, projectStamp, preDeps)) {
std::string result = RunBuildCommand(compileCmd); std::string result = RunBuildCommand(compileCmd);
if (!result.empty()) { if (!result.empty()) {
throw std::runtime_error(std::format("Failed to compile project {}: {}", absProject.string(), result)); throw std::runtime_error(std::format("Failed to compile project {}: {}", absProject.string(), result));
} }
WriteStamp(StampPath(soPath), projectStamp);
} }
void* handle = dlopen(soPath.c_str(), RTLD_NOW | RTLD_GLOBAL); void* handle = dlopen(soPath.c_str(), RTLD_NOW | RTLD_GLOBAL);
@ -1342,5 +871,3 @@ Configuration Crafter::LoadProject(const fs::path& projectFile, std::span<const
guard.Frame().dependencies = std::move(preDeps.configs); guard.Frame().dependencies = std::move(preDeps.configs);
return fn(args); return fn(args);
} }
#endif

View file

@ -4,18 +4,8 @@
module; module;
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#if defined(_WIN32) #include <unistd.h>
#include <io.h> #include <sys/ioctl.h>
#include <windows.h>
#ifndef STDOUT_FILENO
#define STDOUT_FILENO _fileno(stdout)
#endif
#define CRAFTER_PROGRESS_ISATTY _isatty
#else
#include <unistd.h>
#include <sys/ioctl.h>
#define CRAFTER_PROGRESS_ISATTY isatty
#endif
export module Crafter.Build:Progress_impl; export module Crafter.Build:Progress_impl;
import std; import std;
@ -32,19 +22,10 @@ namespace {
std::chrono::steady_clock::time_point StartTime = std::chrono::steady_clock::now(); std::chrono::steady_clock::time_point StartTime = std::chrono::steady_clock::now();
std::int32_t TerminalWidth() { std::int32_t TerminalWidth() {
#if defined(_WIN32)
HANDLE h = GetStdHandle(STD_OUTPUT_HANDLE);
CONSOLE_SCREEN_BUFFER_INFO info;
if (h != INVALID_HANDLE_VALUE && GetConsoleScreenBufferInfo(h, &info)) {
std::int32_t w = info.srWindow.Right - info.srWindow.Left + 1;
if (w > 0) return w;
}
#else
winsize ws{}; winsize ws{};
if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0 && ws.ws_col > 0) { if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0 && ws.ws_col > 0) {
return ws.ws_col; return ws.ws_col;
} }
#endif
if (const char* col = std::getenv("COLUMNS")) { if (const char* col = std::getenv("COLUMNS")) {
try { std::int32_t c = std::stoi(col); if (c > 0) return c; } catch (...) {} try { std::int32_t c = std::stoi(col); if (c > 0) return c; } catch (...) {}
} }
@ -86,7 +67,7 @@ namespace Crafter::Progress {
void SetVerbosity(Verbosity v) { void SetVerbosity(Verbosity v) {
std::lock_guard lock(StateMutex); std::lock_guard lock(StateMutex);
ActiveVerbosity = v; ActiveVerbosity = v;
IsTty = CRAFTER_PROGRESS_ISATTY(STDOUT_FILENO) != 0; IsTty = isatty(STDOUT_FILENO) != 0;
StartTime = std::chrono::steady_clock::now(); StartTime = std::chrono::steady_clock::now();
Total.store(0); Total.store(0);
Done.store(0); Done.store(0);

View file

@ -35,38 +35,11 @@ namespace {
return out; return out;
} }
// cmd.exe doesn't recognize '...' as quoting (it would pass the single
// quotes through to the executable). Wrap in "..." for cmd; embedded "
// is rare in paths but escape it to be safe. Backslash sequences before
// " don't need the MS-CRT doubling rules because we go through
// `cmd /C "..."`, which uses cmd's parser, not the CRT's argv splitter.
std::string ShellQuoteCmd(std::string_view s) {
std::string out;
out.reserve(s.size() + 2);
out.push_back('"');
for (char c : s) {
if (c == '"') out += "\\\"";
else out.push_back(c);
}
out.push_back('"');
return out;
}
// Host-shell quoting: sh on Linux, cmd on Windows. For args/paths that
// hit the local shell (Local runner exec, Cmd-prefix runner exec).
std::string ShellQuoteHost(std::string_view s) {
#ifdef _WIN32
return ShellQuoteCmd(s);
#else
return ShellQuoteSh(s);
#endif
}
std::string JoinAndQuoteArgs(std::span<const std::string> args) { std::string JoinAndQuoteArgs(std::span<const std::string> args) {
std::string out; std::string out;
for (const auto& a : args) { for (const auto& a : args) {
if (!out.empty()) out.push_back(' '); if (!out.empty()) out.push_back(' ');
out += ShellQuoteHost(a); out += ShellQuoteSh(a);
} }
return out; return out;
} }
@ -191,11 +164,7 @@ TestRunner TestRunner::Cmd(std::string command) {
TestRunner r; TestRunner r;
r.name = std::format("cmd:{}", command); r.name = std::format("cmd:{}", command);
r.exec = std::format("{} {{bin}} {{args}}", command); r.exec = std::format("{} {{bin}} {{args}}", command);
#ifdef _WIN32
r.probe = std::format("where {}", command);
#else
r.probe = std::format("which {}", command); r.probe = std::format("which {}", command);
#endif
return r; return r;
} }
@ -203,11 +172,7 @@ TestRunner TestRunner::Wine() {
TestRunner r; TestRunner r;
r.name = "wine"; r.name = "wine";
r.exec = "wine {bin} {args}"; r.exec = "wine {bin} {args}";
#ifdef _WIN32
r.probe = "where wine";
#else
r.probe = "which wine"; r.probe = "which wine";
#endif
return r; return r;
} }
@ -216,13 +181,10 @@ TestRunner TestRunner::ForTarget(const Configuration& cfg) {
// Same triple as the host → run the binary directly. Covers the common // Same triple as the host → run the binary directly. Covers the common
// case (cfg.target defaulted to HostTarget()) without any wrapper. // case (cfg.target defaulted to HostTarget()) without any wrapper.
if (target == HostTarget()) return Local(); if (SameTarget(target, HostTarget())) return Local();
// Windows targets: native on a Windows host, Wine on Linux. We don't // Windows targets run under Wine; there is no Windows host.
// distinguish mingw vs msvc here — the produced .exe runs the same way. if (TargetIsWindows(target)) return Wine();
if (TargetIsWindows(target)) {
return TargetIsWindows(HostTarget()) ? Local() : Wine();
}
// WASI: a .wasm file isn't directly executable; wasmtime is the canonical // WASI: a .wasm file isn't directly executable; wasmtime is the canonical
// runtime. wasi-cli also works but the upstream Bytecode Alliance name is // runtime. wasi-cli also works but the upstream Bytecode Alliance name is
@ -311,7 +273,7 @@ TestResult Crafter::RunSingleTest(const Test& test, const fs::path& binary, std:
if (test.runner.exec.empty()) { if (test.runner.exec.empty()) {
// Local runner: spawn the binary directly through the host shell. // Local runner: spawn the binary directly through the host shell.
std::string cmd = std::format("{} {}", ShellQuoteHost(binary.string()), ph["{args}"]); std::string cmd = std::format("{} {}", ShellQuoteSh(binary.string()), ph["{args}"]);
r = RunCommandWithTimeout(cmd, timeout); r = RunCommandWithTimeout(cmd, timeout);
} else { } else {
// Prefix runner (qemu-user, wasmtime, wine, ...): templated exec // Prefix runner (qemu-user, wasmtime, wine, ...): templated exec
@ -337,11 +299,7 @@ TestResult Crafter::RunSingleTest(const Test& test, const fs::path& binary, std:
namespace { namespace {
bool ToolOnPath(std::string_view name) { bool ToolOnPath(std::string_view name) {
#ifdef _WIN32
std::string cmd = std::format("where {} > nul 2>&1", name);
#else
std::string cmd = std::format("which {} > /dev/null 2>&1", name); std::string cmd = std::format("which {} > /dev/null 2>&1", name);
#endif
return std::system(cmd.c_str()) == 0; return std::system(cmd.c_str()) == 0;
} }
@ -508,13 +466,16 @@ TestSummary Crafter::RunTests(Configuration& projectCfg, const RunTestsOptions&
// user having to know which targets exist. An explicit --target=X // user having to know which targets exist. An explicit --target=X
// bypasses the sweep and runs that target only. // bypasses the sweep and runs that target only.
if (opts.targetFilter.empty()) { if (opts.targetFilter.empty()) {
std::set<std::string> sweep; // Keyed by the normalized triple so x86_64-pc-linux-gnu and
sweep.insert(HostTarget()); // x86_64-unknown-linux-gnu make one pass; the first spelling seen is
// the one shown and filtered on.
std::map<std::string, std::string> sweep;
sweep.emplace(NormalizeTarget(HostTarget()), HostTarget());
for (const Test& t : projectCfg.tests) { for (const Test& t : projectCfg.tests) {
if (!t.config.target.empty()) sweep.insert(t.config.target); if (!t.config.target.empty()) sweep.emplace(NormalizeTarget(t.config.target), t.config.target);
} }
TestSummary aggregate; TestSummary aggregate;
for (const auto& target : sweep) { for (const auto& [normalized, target] : sweep) {
RunTestsOptions perTarget = opts; RunTestsOptions perTarget = opts;
perTarget.targetFilter = target; perTarget.targetFilter = target;
if (sweep.size() > 1) { if (sweep.size() > 1) {
@ -541,7 +502,7 @@ TestSummary Crafter::RunTests(Configuration& projectCfg, const RunTestsOptions&
std::vector<Test*> filtered; std::vector<Test*> filtered;
filtered.reserve(projectCfg.tests.size()); filtered.reserve(projectCfg.tests.size());
for (auto& test : projectCfg.tests) { for (auto& test : projectCfg.tests) {
if (test.config.target != opts.targetFilter) continue; if (!SameTarget(test.config.target, opts.targetFilter)) continue;
if (!MatchAny(opts.globs, test.config.name)) continue; if (!MatchAny(opts.globs, test.config.name)) continue;
test.runner = TestRunner::FromEnv(test.config.target, TestRunner::ForTarget(test.config)); test.runner = TestRunner::FromEnv(test.config.target, TestRunner::ForTarget(test.config));
if (opts.runnerOverride) { if (opts.runnerOverride) {

View file

@ -0,0 +1,185 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module;
// Supplied on the command line by build.sh and project.cpp (from the
// CRAFTER_BUILD_VERSION environment variable or `git describe`); a build
// that passes nothing is a development build.
#ifndef CRAFTER_BUILD_VERSION
#define CRAFTER_BUILD_VERSION "0.0.0-dev"
#endif
module Crafter.Build:Toolchain_impl;
import std;
import :Toolchain;
import :Platform;
namespace fs = std::filesystem;
using namespace Crafter;
namespace {
constexpr std::string_view EnvVars[] = {
"CRAFTER_BUILD_LLVM_DIR",
"CRAFTER_BUILD_LIBCXX_DIR",
"CRAFTER_BUILD_LIBCLANG",
"CRAFTER_BUILD_WASI_SYSROOT",
"CRAFTER_MINGW_DIR",
};
std::string_view Env(const ToolchainInputs& in, std::string_view name) {
auto it = in.env.find(name);
return it == in.env.end() ? std::string_view{} : std::string_view(it->second);
}
// "Arch Linux clang version 22.1.8" / "clang version 22.1.8" -> "22.1.8".
std::string ShortVersion(std::string_view versionLine) {
constexpr std::string_view Marker = "version ";
std::size_t at = versionLine.find(Marker);
if (at == std::string_view::npos) return "unknown";
std::string_view rest = versionLine.substr(at + Marker.size());
std::size_t end = rest.find_first_of(" \t(");
return std::string(rest.substr(0, end));
}
std::string Trimmed(std::string s) {
while (!s.empty() && (s.back() == '\n' || s.back() == '\r' || s.back() == ' ')) s.pop_back();
return s;
}
}
std::string Toolchain::LibcxxCompileFlags() const {
if (libcxxDir.empty()) return {};
return std::format(" -nostdinc++ -isystem {}", (libcxxDir / "include" / "c++" / "v1").string());
}
std::string Toolchain::LibcxxLinkFlags() const {
// clang searches its per-target runtime directory and the system library
// paths for -lc++, not <prefix>/lib, so a toolchain outside /usr with the
// classic layout needs the search path as well as the run-time path.
fs::path lib;
if (!libcxxDir.empty()) {
lib = libcxxDir / "lib";
} else if (!prefix.empty() && prefix != "/usr") {
lib = prefix / "lib";
}
if (lib.empty()) return {};
return std::format(" -L{} -Wl,-rpath,{}", lib.string(), lib.string());
}
std::string Toolchain::Key() const {
std::string identity;
for (const std::string& part : {clangxx.string(), clangVersion, libcxxDir.string(), stdCppm.string()}) {
identity += part;
identity += '\0';
}
return std::format("{}-{:08x}", ShortVersion(clangVersion), static_cast<std::uint32_t>(std::hash<std::string>{}(identity)));
}
Toolchain Crafter::ResolveToolchain(const ToolchainInputs& in) {
Toolchain tc;
// 1. The compiler: an explicit prefix, else one installed beside
// crafter-build, else PATH. An explicit prefix without a clang++ is an
// error rather than a fallback, for the same reason CRAFTER_BUILD_LIBCLANG
// is exclusive: an override that silently yields to something else
// cannot be used to diagnose which compiler is in play.
if (std::string_view dir = Env(in, "CRAFTER_BUILD_LLVM_DIR"); !dir.empty()) {
tc.prefix = fs::path(dir);
if (!fs::exists(tc.prefix / "bin" / "clang++")) {
tc.error = std::format("clang++ not found at {} (CRAFTER_BUILD_LLVM_DIR)", (tc.prefix / "bin" / "clang++").string());
}
} else if (!in.crafterPrefix.empty() && fs::exists(in.crafterPrefix / "bin" / "clang++")) {
tc.prefix = in.crafterPrefix;
}
if (tc.prefix.empty()) {
tc.clangxx = "clang++";
tc.clang = "clang";
tc.llvmAr = "llvm-ar";
} else {
tc.clangxx = tc.prefix / "bin" / "clang++";
tc.clang = tc.prefix / "bin" / "clang";
tc.llvmAr = tc.prefix / "bin" / "llvm-ar";
if (tc.prefix != "/usr" && fs::exists(tc.prefix / "include" / "clang-c" / "Index.h")) {
tc.llvmIncludeDir = tc.prefix / "include";
}
}
// 2. libclang. The override is exclusive and unchecked on purpose: a
// broken path must fail loudly when dlopened, not be papered over.
if (std::string_view pinned = Env(in, "CRAFTER_BUILD_LIBCLANG"); !pinned.empty()) {
tc.libclang = fs::path(pinned);
} else if (!tc.prefix.empty() && fs::exists(tc.prefix / "lib" / "libclang.so")) {
tc.libclang = tc.prefix / "lib" / "libclang.so";
}
// 3. libc++ and its std module source. The compiler's own copy lives at
// <prefix>/share/libc++/v1 when the prefix carries libc++; the system
// copy is the last resort either way.
const fs::path systemStdCppm = "/usr/share/libc++/v1/std.cppm";
if (std::string_view dir = Env(in, "CRAFTER_BUILD_LIBCXX_DIR"); !dir.empty()) {
tc.libcxxDir = fs::path(dir);
tc.stdCppm = tc.libcxxDir / "share" / "libc++" / "v1" / "std.cppm";
} else if (!tc.prefix.empty() && fs::exists(tc.prefix / "share" / "libc++" / "v1" / "std.cppm")) {
tc.stdCppm = tc.prefix / "share" / "libc++" / "v1" / "std.cppm";
} else {
tc.stdCppm = systemStdCppm;
}
// 4. Sysroots for the cross targets that have a conventional location.
if (std::string_view dir = Env(in, "CRAFTER_BUILD_WASI_SYSROOT"); !dir.empty()) {
tc.wasiSysroot = fs::path(dir);
} else if (!tc.prefix.empty() && fs::exists(tc.prefix / "share" / "wasi-sysroot")) {
tc.wasiSysroot = tc.prefix / "share" / "wasi-sysroot";
} else {
tc.wasiSysroot = "/usr/share/wasi-sysroot";
}
if (std::string_view dir = Env(in, "CRAFTER_MINGW_DIR"); !dir.empty()) {
tc.mingwSysroot = fs::path(dir);
tc.mingwSysrootExplicit = true;
} else {
tc.mingwSysroot = "/usr/x86_64-w64-mingw32";
}
return tc;
}
const Toolchain& Crafter::GetToolchain() {
static const Toolchain Resolved = []() {
ToolchainInputs in;
for (std::string_view name : EnvVars) {
if (const char* value = std::getenv(std::string(name).c_str()); value && *value) {
in.env.emplace(std::string(name), value);
}
}
// GetCrafterBuildHome throws when no share/crafter-build is found
// near the executable; a toolchain can still come from PATH then.
try {
in.crafterPrefix = GetCrafterBuildHome().parent_path().parent_path();
} catch (const std::exception&) {}
Toolchain tc = ResolveToolchain(in);
if (!tc.error.empty()) return tc;
CommandResult triple = RunCommandChecked(std::format("{} -print-target-triple", tc.clangxx.string()));
if (triple.exitCode != 0) {
tc.error = std::format("could not run {} (exit {}): {}", tc.clangxx.string(), triple.exitCode, Trimmed(triple.output));
if (tc.prefix.empty()) tc.error += "; install clang or set CRAFTER_BUILD_LLVM_DIR";
return tc;
}
tc.hostTriple = Trimmed(std::move(triple.output));
CommandResult version = RunCommandChecked(std::format("{} --version", tc.clangxx.string()));
if (version.exitCode == 0) {
std::string first = version.output.substr(0, version.output.find('\n'));
tc.clangVersion = Trimmed(std::move(first));
}
return tc;
}();
return Resolved;
}
const Toolchain& Crafter::RequireToolchain() {
const Toolchain& tc = GetToolchain();
if (!tc.error.empty()) throw std::runtime_error(tc.error);
return tc;
}
std::string_view Crafter::CrafterBuildVersion() {
return CRAFTER_BUILD_VERSION;
}

View file

@ -2,16 +2,7 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
#include <cstdio> #include <cstdio>
#if defined(_WIN32) #include <unistd.h>
#include <io.h>
#ifndef STDOUT_FILENO
#define STDOUT_FILENO _fileno(stdout)
#endif
#define CRAFTER_MAIN_ISATTY _isatty
#else
#include <unistd.h>
#define CRAFTER_MAIN_ISATTY isatty
#endif
import std; import std;
import Crafter.Build; import Crafter.Build;
@ -26,7 +17,7 @@ int main(int argc, char** argv) {
// process blocks in its serve loop and never returns to flush. A TTY is // process blocks in its serve loop and never returns to flush. A TTY is
// already line-buffered and its in-place redraw path flushes explicitly, so // already line-buffered and its in-place redraw path flushes explicitly, so
// only adjust when redirected. // only adjust when redirected.
if (CRAFTER_MAIN_ISATTY(STDOUT_FILENO) == 0) { if (isatty(STDOUT_FILENO) == 0) {
std::setvbuf(stdout, nullptr, _IOLBF, 0); std::setvbuf(stdout, nullptr, _IOLBF, 0);
} }
return Crafter::Run(argc, argv); return Crafter::Run(argc, argv);

View file

@ -1,14 +0,0 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
#pragma once
#if defined(_WIN32)
#if defined(CRAFTER_BUILD_DLL_IMPORT)
#define CRAFTER_API __declspec(dllimport)
#else
#define CRAFTER_API __declspec(dllexport)
#endif
#else
#define CRAFTER_API
#endif

View file

@ -1,8 +1,6 @@
// SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module;
#include "Crafter.Build-Api.h"
export module Crafter.Build:Asset; export module Crafter.Build:Asset;
import std; import std;
namespace fs = std::filesystem; namespace fs = std::filesystem;
@ -19,7 +17,7 @@ namespace Crafter {
// that errors out at runtime if cfg.assets is non-empty — full // that errors out at runtime if cfg.assets is non-empty — full
// support requires a self-host rebuild via crafter-build itself, // support requires a self-host rebuild via crafter-build itself,
// which resolves the Crafter.Asset dep through project.cpp. // which resolves the Crafter.Asset dep through project.cpp.
export CRAFTER_API std::string CompressAsset(const fs::path& input, const fs::path& output); export std::string CompressAsset(const fs::path& input, const fs::path& output);
// Split a single .obj + .mtl file by material group into per-material // Split a single .obj + .mtl file by material group into per-material
// .cmesh files, per-unique-albedo .ctex files, and a scene.txt manifest // .cmesh files, per-unique-albedo .ctex files, and a scene.txt manifest
@ -36,7 +34,7 @@ namespace Crafter {
// Idempotent: returns "" immediately if outDir/scene.txt already exists, // Idempotent: returns "" immediately if outDir/scene.txt already exists,
// so repeated builds skip the expensive split+resize step. // so repeated builds skip the expensive split+resize step.
// Returns "" on success, an error string on failure. // Returns "" on success, an error string on failure.
export CRAFTER_API std::string BuildOBJBundle( export std::string BuildOBJBundle(
const fs::path& objPath, const fs::path& objPath,
const fs::path& mtlPath, const fs::path& mtlPath,
const fs::path& outDir, const fs::path& outDir,

View file

@ -1,8 +1,6 @@
// SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module;
#include "Crafter.Build-Api.h"
export module Crafter.Build:Clang; export module Crafter.Build:Clang;
import std; import std;
import :Shader; import :Shader;
@ -65,30 +63,28 @@ export namespace Crafter {
bool IsLocal() const { return exec.empty(); } bool IsLocal() const { return exec.empty(); }
static CRAFTER_API TestRunner Local(); static TestRunner Local();
// Prefix runner: wraps the local binary in `<command> {bin} {args}`. // Prefix runner: wraps the local binary in `<command> {bin} {args}`.
// Used for qemu-user, wasmtime, and similar single-binary wrappers. // Used for qemu-user, wasmtime, and similar single-binary wrappers.
static CRAFTER_API TestRunner Cmd(std::string command); static TestRunner Cmd(std::string command);
// Run a Windows .exe through Wine. Probes `wine` on PATH; on a Windows // Run a Windows .exe through Wine. Probes `wine` on PATH.
// host the wine wrapper is pointless, so callers should route to Local static TestRunner Wine();
// before reaching here.
static CRAFTER_API TestRunner Wine();
// Parse a `<kind>[:<arg>]` spec used by CRAFTER_BUILD_RUNNER_<target> // Parse a `<kind>[:<arg>]` spec used by CRAFTER_BUILD_RUNNER_<target>
// and --runner=. Supported: "local", "cmd:<binary>". Returns nullopt // and --runner=. Supported: "local", "cmd:<binary>". Returns nullopt
// for an empty string; throws on a non-empty unrecognized spec. // for an empty string; throws on a non-empty unrecognized spec.
static CRAFTER_API std::optional<TestRunner> FromSpec(std::string_view spec); static std::optional<TestRunner> FromSpec(std::string_view spec);
// Honor CRAFTER_BUILD_RUNNER_<target> (power-user override). Triple // Honor CRAFTER_BUILD_RUNNER_<target> (power-user override). Triple
// dashes/dots become underscores so they're valid in env-var names. // dashes/dots become underscores so they're valid in env-var names.
// Returns `fallback` when the env var is unset. // Returns `fallback` when the env var is unset.
static CRAFTER_API TestRunner FromEnv(std::string_view target, TestRunner fallback = Local()); static TestRunner FromEnv(std::string_view target, TestRunner fallback = Local());
// Derive a runner from a Configuration's target triple + sysroot. // Derive a runner from a Configuration's target triple + sysroot.
// Returns Local() when target equals the host, Wine() for Windows // Returns Local() when target equals the host, Wine() for Windows
// targets on a non-Windows host, `qemu-<arch>` (with QEMU_LD_PREFIX // targets, `qemu-<arch>` (with QEMU_LD_PREFIX
// set when cfg.sysroot is non-empty) for non-host -linux- triples, // set when cfg.sysroot is non-empty) for non-host -linux- triples,
// `wasmtime` for wasm32-wasi/wasm64-wasi, and Local() as a last // `wasmtime` for wasm32-wasi/wasm64-wasi, and Local() as a last
// resort. CRAFTER_BUILD_RUNNER_<target> still wins as an override // resort. CRAFTER_BUILD_RUNNER_<target> still wins as an override
// upstream of this — see FromEnv. // upstream of this — see FromEnv.
static CRAFTER_API TestRunner ForTarget(const struct Configuration& cfg); static TestRunner ForTarget(const struct Configuration& cfg);
}; };
enum class TestOutcome { Pass, Fail, Crash, Timeout, Skipped }; enum class TestOutcome { Pass, Fail, Crash, Timeout, Skipped };
@ -233,17 +229,18 @@ export namespace Crafter {
}; };
// Per-file view handed to each LintRule's check callback. Every member // Per-file view handed to each LintRule's check callback. Every member
// function is out-of-line and CRAFTER_API (defined in Crafter.Build:Lint's // function is out-of-line (defined in Crafter.Build:Lint's implementation
// implementation unit) because rule lambdas execute from the user's // unit) because rule lambdas execute from the user's project.so and
// project DLL on Windows — clang does not emit module-attached in-class // resolve against the running executable: clang does not emit
// inline bodies into consumers (see ArgQuery below). // module-attached in-class inline bodies into consumers (see ArgQuery
// below).
struct LintContext { struct LintContext {
fs::path file; // absolute path of the file under lint fs::path file; // absolute path of the file under lint
std::string content; // whole file as read from disk std::string content; // whole file as read from disk
std::vector<std::string_view> lines; // views into `content`, one per line, no '\n' std::vector<std::string_view> lines; // views into `content`, one per line, no '\n'
CRAFTER_API std::string Extension() const; // ".cppm", ".cpp", ".h", ... std::string Extension() const; // ".cppm", ".cpp", ".h", ...
CRAFTER_API std::string_view Line(std::size_t n) const; // 1-based; empty if out of range std::string_view Line(std::size_t n) const; // 1-based; empty if out of range
// `content` with comments and string/char literal bodies blanked to // `content` with comments and string/char literal bodies blanked to
// spaces, newlines preserved — offsets and line numbers stay valid. // spaces, newlines preserved — offsets and line numbers stay valid.
// Built from Tokens() on first call, cached per file, so raw strings, // Built from Tokens() on first call, cached per file, so raw strings,
@ -254,9 +251,9 @@ export namespace Crafter {
// Convenient for a quick scan, but Tokens() is the better tool for // Convenient for a quick scan, but Tokens() is the better tool for
// anything structural: this view cannot tell an identifier from a // anything structural: this view cannot tell an identifier from a
// keyword, and it has already thrown away where the literals were. // keyword, and it has already thrown away where the literals were.
CRAFTER_API const std::string& CommentStripped(); const std::string& CommentStripped();
// Record a finding at `line` (1-based; pass 0 for a whole-file finding). // Record a finding at `line` (1-based; pass 0 for a whole-file finding).
CRAFTER_API void Report(std::size_t line, std::string message); void Report(std::size_t line, std::string message);
// Replace the file's content. Makes this rule a *transform*: // Replace the file's content. Makes this rule a *transform*:
// `crafter-build format` writes the result back to disk; `lint` // `crafter-build format` writes the result back to disk; `lint`
// derives would-reformat findings from it (dry — never writes). // derives would-reformat findings from it (dry — never writes).
@ -265,7 +262,7 @@ export namespace Crafter {
// pattern: build the new string, call SetContent once at the end. Do // pattern: build the new string, call SetContent once at the end. Do
// not assign `content` directly — that bypasses the re-split. May be // not assign `content` directly — that bypasses the re-split. May be
// combined with Report() in the same rule. // combined with Report() in the same rule.
CRAFTER_API void SetContent(std::string newContent); void SetContent(std::string newContent);
// True when `rule` is suppressed at `line` (1-based; 0 = whole-file, // True when `rule` is suppressed at `line` (1-based; 0 = whole-file,
// which only file-level directives cover) by a suppression comment: // which only file-level directives cover) by a suppression comment:
// // lint-disable-next-line [rules...] applies to the following line // // lint-disable-next-line [rules...] applies to the following line
@ -277,7 +274,7 @@ export namespace Crafter {
// itself for every line its edit touches (the driver cannot map lines // itself for every line its edit touches (the driver cannot map lines
// across a count-changing rewrite). Parsed lazily from the raw lines; // across a count-changing rewrite). Parsed lazily from the raw lines;
// re-parsed after SetContent. // re-parsed after SetContent.
CRAFTER_API bool Suppressed(std::string_view rule, std::size_t line); bool Suppressed(std::string_view rule, std::size_t line);
// The file lexed by clang, in source order. Built on first call, cached // The file lexed by clang, in source order. Built on first call, cached
// per file, re-lexed after SetContent. Empty for extensions that are // per file, re-lexed after SetContent. Empty for extensions that are
@ -287,16 +284,16 @@ export namespace Crafter {
// raw strings, line splices, digraphs and nested quoting right, and // raw strings, line splices, digraphs and nested quoting right, and
// offsets index straight into `content`, so a transform can find in // offsets index straight into `content`, so a transform can find in
// the token stream and edit in place. // the token stream and edit in place.
CRAFTER_API std::span<const LintToken> Tokens(); std::span<const LintToken> Tokens();
// The token's own bytes: content.substr(tok.offset, tok.length). // The token's own bytes: content.substr(tok.offset, tok.length).
CRAFTER_API std::string_view TokenText(const LintToken& token) const; std::string_view TokenText(const LintToken& token) const;
// Tokens whose FIRST byte is on `line` (1-based). A token that starts // Tokens whose FIRST byte is on `line` (1-based). A token that starts
// earlier and spans into `line` — a raw string, a block comment — is // earlier and spans into `line` — a raw string, a block comment — is
// not included; ask Tokens() directly when that matters. // not included; ask Tokens() directly when that matters.
CRAFTER_API std::span<const LintToken> TokensOnLine(std::size_t line); std::span<const LintToken> TokensOnLine(std::size_t line);
// True when a comment token starts on `line`. Replaces `Line(n).contains("//")`, // True when a comment token starts on `line`. Replaces `Line(n).contains("//")`,
// which false-positives on a `//` inside a string literal. // which false-positives on a `//` inside a string literal.
CRAFTER_API bool LineHasComment(std::size_t line); bool LineHasComment(std::size_t line);
// True when `line` (1-based) is touched by a token that spans more // True when `line` (1-based) is touched by a token that spans more
// than one line — a raw string or a block comment. A transform that // than one line — a raw string or a block comment. A transform that
// joins, splits or rewrites such a line changes what is inside that // joins, splits or rewrites such a line changes what is inside that
@ -304,7 +301,7 @@ export namespace Crafter {
// Replaces `Line(n).contains("R\"")`, which both misses raw strings // Replaces `Line(n).contains("R\"")`, which both misses raw strings
// opened on an earlier line and fires on the characters R" appearing // opened on an earlier line and fires on the characters R" appearing
// inside an ordinary literal. // inside an ordinary literal.
CRAFTER_API bool LineHasMultiLineToken(std::size_t line); bool LineHasMultiLineToken(std::size_t line);
// Declarations written in this file, flattened depth-first with // Declarations written in this file, flattened depth-first with
// parents before children (see LintDecl). Empty when the AST is not // parents before children (see LintDecl). Empty when the AST is not
@ -321,11 +318,11 @@ export namespace Crafter {
// slice. Declarations inside a preprocessor branch that is inactive // slice. Declarations inside a preprocessor branch that is inactive
// for the host are absent here, though Tokens() still sees them. Rules // for the host are absent here, though Tokens() still sees them. Rules
// that must cover every platform belong on tokens. // that must cover every platform belong on tokens.
CRAFTER_API std::span<const LintDecl> Decls(); std::span<const LintDecl> Decls();
CRAFTER_API bool AstAvailable(); bool AstAvailable();
// Why AstAvailable() is false — a missing PCM, a parse error, a file // Why AstAvailable() is false — a missing PCM, a parse error, a file
// that is not a translation unit. Empty when the AST is available. // that is not a translation unit. Empty when the AST is available.
CRAFTER_API std::string_view AstUnavailableReason(); std::string_view AstUnavailableReason();
// Driver wiring — set by RunLint before each check call. Not for rules. // Driver wiring — set by RunLint before each check call. Not for rules.
std::string activeRule; std::string activeRule;
@ -358,12 +355,21 @@ export namespace Crafter {
bool needsAst = false; bool needsAst = false;
}; };
// The host target triple, detected once per process by running // The host target triple: what the resolved toolchain's clang++ prints
// `clang++ -print-target-triple` and cached. Used as the default for // for -print-target-triple (see GetToolchain). Used as the default for
// Configuration::target so projects don't have to hardcode it for the // Configuration::target so projects don't have to hardcode it for the
// no-cross-compile case. Returns "" if clang isn't on PATH or doesn't // no-cross-compile case. Returns "" when no compiler could be resolved;
// print a triple — those projects still need an explicit cfg.target. // those projects still need an explicit cfg.target, and the first build
CRAFTER_API std::string HostTarget(); // step reports the missing compiler.
std::string HostTarget();
// Target equality that ignores the vendor field: x86_64-pc-linux-gnu,
// x86_64-unknown-linux-gnu and x86_64-linux-gnu are one target as far as
// "does this test run on this host" goes, and which spelling a clang
// reports depends on how it was built. The strings themselves are never
// rewritten: clang rejects a PCM whose triple string differs from the
// importing unit's, so what the user wrote is what the compiler sees.
std::string NormalizeTarget(std::string_view triple);
bool SameTarget(std::string_view a, std::string_view b);
struct Configuration { struct Configuration {
fs::path path; fs::path path;
@ -439,7 +445,7 @@ export namespace Crafter {
std::vector<Test> tests; std::vector<Test> tests;
// Lint rules for `crafter-build lint`. Populate via AddLintRule. // Lint rules for `crafter-build lint`. Populate via AddLintRule.
std::vector<LintRule> lintRules; std::vector<LintRule> lintRules;
CRAFTER_API void GetInterfacesAndImplementations(std::span<fs::path> interfaces, std::span<fs::path> implementations); void GetInterfacesAndImplementations(std::span<fs::path> interfaces, std::span<fs::path> implementations);
// Retry the `import X;` names GetInterfacesAndImplementations could not // Retry the `import X;` names GetInterfacesAndImplementations could not
// place, against the dependency DAG as it stands now. Sources are // place, against the dependency DAG as it stands now. Sources are
// scanned when they're declared, but `dependencies` is often assigned // scanned when they're declared, but `dependencies` is often assigned
@ -448,30 +454,30 @@ export namespace Crafter {
// change and this Configuration's objects would be silently reused // change and this Configuration's objects would be silently reused
// against the new layout. Build() calls this before checking mtimes; // against the new layout. Build() calls this before checking mtimes;
// calling it again is harmless. // calling it again is harmless.
CRAFTER_API void ResolvePendingImports(); void ResolvePendingImports();
// Declare a test. Sources default to `tests/<name>/main.cpp` resolved // Declare a test. Sources default to `tests/<name>/main.cpp` resolved
// against this Configuration's path; target/march/mtune/sysroot/debug // against this Configuration's path; target/march/mtune/sysroot/debug
// are inherited from this Configuration so cross-arch projects don't // are inherited from this Configuration so cross-arch projects don't
// have to re-specify. Returns a builder for chaining defines, deps, // have to re-specify. Returns a builder for chaining defines, deps,
// etc. Defined in Crafter.Build:Test. // etc. Defined in Crafter.Build:Test.
CRAFTER_API struct TestBuilder AddTest(std::string_view name); struct TestBuilder AddTest(std::string_view name);
// Same as AddTest, but compiles the parent's `interfaces` directly // Same as AddTest, but compiles the parent's `interfaces` directly
// into this test's Configuration (rather than going through a dep). // into this test's Configuration (rather than going through a dep).
// Use when the test must rebuild those interfaces with its own // Use when the test must rebuild those interfaces with its own
// compile flags — typically per-march SIMD codegen. // compile flags — typically per-march SIMD codegen.
CRAFTER_API struct TestBuilder AddTest(std::string_view name, std::span<fs::path> interfaces); struct TestBuilder AddTest(std::string_view name, std::span<fs::path> interfaces);
// Math-style fan-out: one Test per MarchTier, all sharing the same // Math-style fan-out: one Test per MarchTier, all sharing the same
// `tests/<name>/main.cpp` source and the same interface set, each // `tests/<name>/main.cpp` source and the same interface set, each
// compiled with the tier's `-march`/`-mtune`. Test names are // compiled with the tier's `-march`/`-mtune`. Test names are
// `<name>-<march>`. // `<name>-<march>`.
CRAFTER_API void AddMarchVariants(std::string_view name, std::span<fs::path> interfaces, std::span<const struct MarchTier> tiers); void AddMarchVariants(std::string_view name, std::span<fs::path> interfaces, std::span<const struct MarchTier> tiers);
// Register a lint rule for `crafter-build lint` / `format`. Rules // Register a lint rule for `crafter-build lint` / `format`. Rules
// registered on any Configuration whose path lies inside the project // registered on any Configuration whose path lies inside the project
// root are collected (deduplicated by name, root-first) — attach them // root are collected (deduplicated by name, root-first) — attach them
// to the lib or the exe config, either works. Rules that call // to the lib or the exe config, either works. Rules that call
// ctx.SetContent are transforms (see LintContext::SetContent). // ctx.SetContent are transforms (see LintContext::SetContent).
// Defined in Crafter.Build:Lint. // Defined in Crafter.Build:Lint.
CRAFTER_API void AddLintRule(std::string name, std::function<void(LintContext&)> check); void AddLintRule(std::string name, std::function<void(LintContext&)> check);
// Same, for a rule that reads LintContext::Decls(). Declared // Same, for a rule that reads LintContext::Decls(). Declared
// separately rather than as a flag on AddLintRule so existing // separately rather than as a flag on AddLintRule so existing
// registrations keep compiling. Such a rule makes the run build the // registrations keep compiling. Such a rule makes the run build the
@ -479,7 +485,7 @@ export namespace Crafter {
// produced becomes an error instead of a silent pass. Prefer // produced becomes an error instead of a silent pass. Prefer
// report-only: a transform running after the first one forces a // report-only: a transform running after the first one forces a
// re-parse of everything it changed. // re-parse of everything it changed.
CRAFTER_API void AddAstLintRule(std::string name, std::function<void(LintContext&)> check); void AddAstLintRule(std::string name, std::function<void(LintContext&)> check);
// Suffix that uniquely identifies this Configuration's compile state. // Suffix that uniquely identifies this Configuration's compile state.
// target+march+mtune are spelled out for readability; the rest // target+march+mtune are spelled out for readability; the rest
// (type, debug, sysroot, defines, compileFlags) collapse into a short // (type, debug, sysroot, defines, compileFlags) collapse into a short
@ -544,14 +550,14 @@ export namespace Crafter {
// Directory holding the std module PCM for this Configuration. Keyed on // Directory holding the std module PCM for this Configuration. Keyed on
// target+march because a BMI is only loadable by a TU with matching target // target+march because a BMI is only loadable by a TU with matching target
// features, and suffixed with the wasm variant flags for the same reason. // features, and suffixed with the wasm variant flags for the same reason.
CRAFTER_API fs::path StdPcmDir(const Configuration& config); fs::path StdPcmDir(const Configuration& config);
// Where Build leaves this Configuration's linked artifact: the executable // Where Build leaves this Configuration's linked artifact: the executable
// (with the target's suffix), `lib<name>.a` / `<name>.lib`, or the shared // (with the target's suffix), `lib<name>.a` / `<name>.lib`, or the shared
// library / DLL. The one rule every "is the consumer older than its // library / DLL. The one rule every "is the consumer older than its
// dependency" check reads, so a staleness test and the link step can't // dependency" check reads, so a staleness test and the link step can't
// disagree about the file name. // disagree about the file name.
CRAFTER_API fs::path ExpectedOutput(const Configuration& config); fs::path ExpectedOutput(const Configuration& config);
// The clang invocation every C++ translation unit in this Configuration is // The clang invocation every C++ translation unit in this Configuration is
// compiled with, before anything that depends on work having happened // compiled with, before anything that depends on work having happened
@ -586,14 +592,14 @@ export namespace Crafter {
fs::path stdPcmDir; fs::path stdPcmDir;
fs::path pcmDir; fs::path pcmDir;
}; };
CRAFTER_API CompileCommand GetCompileCommand(const Configuration& config); CompileCommand GetCompileCommand(const Configuration& config);
CRAFTER_API BuildResult Build(Configuration& config, std::unordered_map<fs::path, std::shared_future<BuildResult>>& depResults, std::mutex& depMutex); BuildResult Build(Configuration& config, std::unordered_map<fs::path, std::shared_future<BuildResult>>& depResults, std::mutex& depMutex);
// Takes main's argv verbatim so the CLI entry point is a one-liner; // Takes main's argv verbatim so the CLI entry point is a one-liner;
// the shape is inherited from the language, not chosen here. // the shape is inherited from the language, not chosen here.
// lint-disable-next-line no-char-pointer // lint-disable-next-line no-char-pointer
CRAFTER_API std::int32_t Run(std::int32_t argc, char** argv); std::int32_t Run(std::int32_t argc, char** argv);
// Delete the bin/ and build/ trees beside `projectFile`, returning the paths // Delete the bin/ and build/ trees beside `projectFile`, returning the paths
// that existed and were removed. Backs `crafter-build clean`. // that existed and were removed. Backs `crafter-build clean`.
@ -603,7 +609,7 @@ export namespace Crafter {
// to compile (and its git dependencies to be present) is useless exactly // to compile (and its git dependencies to be present) is useless exactly
// then. That means variant directories are not enumerated — the whole tree // then. That means variant directories are not enumerated — the whole tree
// goes, which is what the by-hand `rm -rf bin build` did anyway. // goes, which is what the by-hand `rm -rf bin build` did anyway.
CRAFTER_API std::vector<fs::path> CleanProject(const fs::path& projectFile); std::vector<fs::path> CleanProject(const fs::path& projectFile);
// Add a small index.html + runtime.js pair next to the .wasm output so the // Add a small index.html + runtime.js pair next to the .wasm output so the
// build can be loaded directly in a browser (just `serve` the bin dir and // build can be loaded directly in a browser (just `serve` the bin dir and
@ -611,7 +617,7 @@ export namespace Crafter {
// this and shouldn't carry the extra files. Call from project.cpp after // this and shouldn't carry the extra files. Call from project.cpp after
// outputName is set; index.html is generated against the current // outputName is set; index.html is generated against the current
// outputName so renaming the binary later requires another call. // outputName so renaming the binary later requires another call.
CRAFTER_API void EnableWasiBrowserRuntime(Configuration& cfg); void EnableWasiBrowserRuntime(Configuration& cfg);
// Register the relaxed-SIMD codegen variant on cfg. Builds an additional // Register the relaxed-SIMD codegen variant on cfg. Builds an additional
// outputName.relaxed-simd.wasm compiled with -mrelaxed-simd (FMA, // outputName.relaxed-simd.wasm compiled with -mrelaxed-simd (FMA,
@ -622,17 +628,17 @@ export namespace Crafter {
// 2026). Idempotent. Call before EnableWasiBrowserRuntime so the emitted // 2026). Idempotent. Call before EnableWasiBrowserRuntime so the emitted
// variants.json manifest includes it. A no-op for non-wasm targets at build // variants.json manifest includes it. A no-op for non-wasm targets at build
// time (no wasm is produced), but harmless to declare unconditionally. // time (no wasm is produced), but harmless to declare unconditionally.
CRAFTER_API void EnableWasiRelaxedSimdVariant(Configuration& cfg); void EnableWasiRelaxedSimdVariant(Configuration& cfg);
// View over the project's args with simple query helpers. Has(flag) for // View over the project's args with simple query helpers. Has(flag) for
// boolean switches; Get(prefix) for valued options (e.g. Get("--prefix=") // boolean switches; Get(prefix) for valued options (e.g. Get("--prefix=")
// returns the substring after the equals). Definitions are out-of-line // returns the substring after the equals). Definitions are out-of-line so
// and CRAFTER_API so they cross the Windows DLL boundary cleanly — clang // project.so resolves them from the executable: clang does not emit
// does not emit module-attached in-class inline bodies into consumers. // module-attached in-class inline bodies into consumers.
struct ArgQuery { struct ArgQuery {
std::span<const std::string_view> args; std::span<const std::string_view> args;
CRAFTER_API bool Has(std::string_view flag) const; bool Has(std::string_view flag) const;
CRAFTER_API std::optional<std::string> Get(std::string_view prefix) const; std::optional<std::string> Get(std::string_view prefix) const;
}; };
// Apply the framework's standard CLI args + env vars onto cfg: // Apply the framework's standard CLI args + env vars onto cfg:
@ -651,5 +657,5 @@ export namespace Crafter {
// pre-set defaults. Returns an ArgQuery over the same span so projects // pre-set defaults. Returns an ArgQuery over the same span so projects
// can query their own flags (`--timing`, ...) without re-rolling the // can query their own flags (`--timing`, ...) without re-rolling the
// for-arg-in-args loop. // for-arg-in-args loop.
CRAFTER_API ArgQuery ApplyStandardArgs(Configuration& cfg, std::span<const std::string_view> args); ArgQuery ApplyStandardArgs(Configuration& cfg, std::span<const std::string_view> args);
} }

View file

@ -1,8 +1,6 @@
// SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module;
#include "Crafter.Build-Api.h"
export module Crafter.Build:External; export module Crafter.Build:External;
import std; import std;
namespace fs = std::filesystem; namespace fs = std::filesystem;
@ -48,13 +46,13 @@ export namespace Crafter {
// declaration and target — BuildExternal derives its own working directory // declaration and target — BuildExternal derives its own working directory
// through this, so a caller that only needs to know where the headers ended // through this, so a caller that only needs to know where the headers ended
// up cannot drift from where they actually are. // up cannot drift from where they actually are.
CRAFTER_API fs::path ExternalCloneDir(const ExternalDependency& dep, std::string_view target); fs::path ExternalCloneDir(const ExternalDependency& dep, std::string_view target);
// The -I flags this dependency contributes, from its declared includeDirs. // The -I flags this dependency contributes, from its declared includeDirs.
// Also pure, which is what lets the linter's AST layer parse a source that // Also pure, which is what lets the linter's AST layer parse a source that
// includes an external's headers without running a build to find out where // includes an external's headers without running a build to find out where
// they are. The paths only resolve once something has fetched the clone. // they are. The paths only resolve once something has fetched the clone.
CRAFTER_API std::vector<std::string> ExternalIncludeFlags(const ExternalDependency& dep, std::string_view target); std::vector<std::string> ExternalIncludeFlags(const ExternalDependency& dep, std::string_view target);
CRAFTER_API ExternalBuildResult BuildExternal(const ExternalDependency& dep, std::string_view target, std::atomic<bool>& cancelled); ExternalBuildResult BuildExternal(const ExternalDependency& dep, std::string_view target, std::atomic<bool>& cancelled);
// Specification for a sibling crafter-build project to fetch and depend on. // Specification for a sibling crafter-build project to fetch and depend on.
// GitSource picks the revision: leave branch + commit empty for the // GitSource picks the revision: leave branch + commit empty for the
@ -73,7 +71,7 @@ export namespace Crafter {
// remote's project.cpp, and returns a stable Configuration* you can drop // remote's project.cpp, and returns a stable Configuration* you can drop
// into cfg.dependencies. The Configuration is owned by an internal cache // into cfg.dependencies. The Configuration is owned by an internal cache
// for the lifetime of the build; identical specs share one Configuration. // for the lifetime of the build; identical specs share one Configuration.
CRAFTER_API Configuration* GitProject(const GitProjectSpec& spec); Configuration* GitProject(const GitProjectSpec& spec);
// Specification for a local sibling crafter-build project (e.g. an // Specification for a local sibling crafter-build project (e.g. an
// example folder depending on its parent project, or a workspace where // example folder depending on its parent project, or a workspace where
@ -87,7 +85,7 @@ export namespace Crafter {
// cache copy. Resolves projectFile to its canonical absolute path and // cache copy. Resolves projectFile to its canonical absolute path and
// recursively LoadProject's it. Returns a stable Configuration* owned // recursively LoadProject's it. Returns a stable Configuration* owned
// by the same internal cache as GitProject. // by the same internal cache as GitProject.
CRAFTER_API Configuration* LocalProject(const LocalProjectSpec& spec); Configuration* LocalProject(const LocalProjectSpec& spec);
// Clone (or update) the given git source into the shared external // Clone (or update) the given git source into the shared external
// cache and return the path to the cloned working tree. Intended for // cache and return the path to the cloned working tree. Intended for
@ -97,5 +95,5 @@ export namespace Crafter {
// ExternalDependency / GitProject. Caching key = url + branch + commit; // ExternalDependency / GitProject. Caching key = url + branch + commit;
// identical calls reuse the existing clone. Pin `source.commit` to a // identical calls reuse the existing clone. Pin `source.commit` to a
// SHA for reproducible, offline-after-first-fetch builds. // SHA for reproducible, offline-after-first-fetch builds.
CRAFTER_API fs::path GitFetch(const GitSource& source); fs::path GitFetch(const GitSource& source);
} }

View file

@ -1,8 +1,6 @@
// SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module;
#include "Crafter.Build-Api.h"
export module Crafter.Build:Implementation; export module Crafter.Build:Implementation;
import std; import std;
namespace fs = std::filesystem; namespace fs = std::filesystem;
@ -19,8 +17,8 @@ namespace Crafter {
// no reachable Configuration provided when the source was scanned. // no reachable Configuration provided when the source was scanned.
std::vector<std::string> pendingImports; std::vector<std::string> pendingImports;
fs::path path; fs::path path;
CRAFTER_API Implementation(fs::path&& path); Implementation(fs::path&& path);
CRAFTER_API bool Check(const fs::path& buildDir, const fs::path& pcmDir, fs::file_time_type sourceFloor = fs::file_time_type::min()) const; bool Check(const fs::path& buildDir, const fs::path& pcmDir, fs::file_time_type sourceFloor = fs::file_time_type::min()) const;
CRAFTER_API void Compile(const std::string_view clang, const fs::path& buildDir, std::atomic<bool>& buildCancelled, std::string& buildError) const; void Compile(const std::string_view clang, const fs::path& buildDir, std::atomic<bool>& buildCancelled, std::string& buildError) const;
}; };
} }

View file

@ -1,8 +1,6 @@
// SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module;
#include "Crafter.Build-Api.h"
export module Crafter.Build:Interface; export module Crafter.Build:Interface;
import std; import std;
namespace fs = std::filesystem; namespace fs = std::filesystem;
@ -26,9 +24,9 @@ namespace Crafter {
bool checked = false; bool checked = false;
std::string name; std::string name;
fs::path path; fs::path path;
CRAFTER_API ModulePartition(std::string&& name, fs::path&& path); ModulePartition(std::string&& name, fs::path&& path);
CRAFTER_API bool Check(const fs::path& pcmDir, fs::file_time_type sourceFloor = fs::file_time_type::min()); bool Check(const fs::path& pcmDir, fs::file_time_type sourceFloor = fs::file_time_type::min());
CRAFTER_API void Compile(const std::string_view clang, const fs::path& pcmDir, const fs::path& buildDir, std::atomic<bool>& buildCancelled, std::string& buildError); void Compile(const std::string_view clang, const fs::path& pcmDir, const fs::path& buildDir, std::atomic<bool>& buildCancelled, std::string& buildError);
}; };
export class Module { export class Module {
@ -48,8 +46,8 @@ namespace Crafter {
std::vector<std::unique_ptr<ModulePartition>> partitions; std::vector<std::unique_ptr<ModulePartition>> partitions;
std::string name; std::string name;
fs::path path; fs::path path;
CRAFTER_API Module(std::string&& name, fs::path&& path); Module(std::string&& name, fs::path&& path);
CRAFTER_API bool Check(const fs::path& pcmDir, fs::file_time_type sourceFloor = fs::file_time_type::min()); bool Check(const fs::path& pcmDir, fs::file_time_type sourceFloor = fs::file_time_type::min());
CRAFTER_API void Compile(const std::string_view clang, const fs::path& pcmDir, const fs::path& buildDir, std::atomic<bool>& buildCancelled, std::string& buildError); void Compile(const std::string_view clang, const fs::path& pcmDir, const fs::path& buildDir, std::atomic<bool>& buildCancelled, std::string& buildError);
}; };
} }

View file

@ -1,8 +1,6 @@
// SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module;
#include "Crafter.Build-Api.h"
export module Crafter.Build:Lint; export module Crafter.Build:Lint;
import std; import std;
import :Clang; import :Clang;
@ -63,5 +61,5 @@ export namespace Crafter {
// final content differs from the original. Prints per-mode output: // final content differs from the original. Prints per-mode output:
// findings compiler-style (Report), would-change paths (Check), or // findings compiler-style (Report), would-change paths (Check), or
// formatted paths (Apply), plus a summary line. // formatted paths (Apply), plus a summary line.
CRAFTER_API LintSummary RunLint(Configuration& projectCfg, const RunLintOptions& opts); LintSummary RunLint(Configuration& projectCfg, const RunLintOptions& opts);
} }

View file

@ -1,8 +1,6 @@
// SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module;
#include "Crafter.Build-Api.h"
export module Crafter.Build:Platform; export module Crafter.Build:Platform;
import std; import std;
namespace fs = std::filesystem; namespace fs = std::filesystem;
@ -30,9 +28,9 @@ namespace Crafter {
// Progress::Diagnostic), otherwise the compiler's diagnostics. Callers // Progress::Diagnostic), otherwise the compiler's diagnostics. Callers
// therefore keep the `if (!result.empty())` shape they already had. // therefore keep the `if (!result.empty())` shape they already had.
std::string RunBuildCommand(std::string_view command); std::string RunBuildCommand(std::string_view command);
export CRAFTER_API CommandResult RunCommandWithTimeout(std::string_view command, std::chrono::seconds timeout); export CommandResult RunCommandWithTimeout(std::string_view command, std::chrono::seconds timeout);
std::string GetBaseCommand(const Configuration& config); std::string GetBaseCommand(const Configuration& config);
export CRAFTER_API Configuration LoadProject(const fs::path& projectFile, std::span<const std::string_view> args); export Configuration LoadProject(const fs::path& projectFile, std::span<const std::string_view> args);
// A dependency the project file currently running its CrafterBuildProject // A dependency the project file currently running its CrafterBuildProject
// declared with `#pragma crafter dependency`, so it can also be wired // declared with `#pragma crafter dependency`, so it can also be wired
// into the build graph (a test that links the same module, say) without // into the build graph (a test that links the same module, say) without
@ -40,11 +38,20 @@ namespace Crafter {
// name or any module it exports — whatever the project file `import`s. // name or any module it exports — whatever the project file `import`s.
// Throws when no such dependency was declared, or when called from // Throws when no such dependency was declared, or when called from
// anywhere other than inside LoadProject. // anywhere other than inside LoadProject.
export CRAFTER_API Configuration* ProjectDependency(std::string_view name); export Configuration* ProjectDependency(std::string_view name);
// True while the project file crafter-build was pointed at runs its
// CrafterBuildProject, and when no project file is running at all; false
// inside a dependency's project file (a `#pragma crafter dependency`,
// GitProject or LocalProject target). Wire anything only the project's
// own build or test run needs behind it. The case that forced it: a
// library's test links crafter-build itself, so when crafter-build loads
// 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();
// Resolves the directory holding distributed runtime assets (Crafter.Build // Resolves the directory holding distributed runtime assets (Crafter.Build
// module sources, wasi-runtime/, etc). Honors CRAFTER_BUILD_HOME; otherwise // module sources, wasi-runtime/, etc). Honors CRAFTER_BUILD_HOME; otherwise
// derives <prefix>/share/crafter-build from the running executable's path. // derives <prefix>/share/crafter-build from the running executable's path.
export CRAFTER_API fs::path GetCrafterBuildHome(); export fs::path GetCrafterBuildHome();
// Newest mtime among the prerequisites a compiler recorded in `depFile` // Newest mtime among the prerequisites a compiler recorded in `depFile`
// (clang's `-MD -MF <artifact>.d`). This is how a build step learns which // (clang's `-MD -MF <artifact>.d`). This is how a build step learns which
// headers its source pulled in: the module scanner only reads `import` // headers its source pulled in: the module scanner only reads `import`

View file

@ -1,22 +1,20 @@
// SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module;
#include "Crafter.Build-Api.h"
export module Crafter.Build:Progress; export module Crafter.Build:Progress;
import std; import std;
export namespace Crafter::Progress { export namespace Crafter::Progress {
enum class Verbosity { Quiet, Default, Verbose }; enum class Verbosity { Quiet, Default, Verbose };
CRAFTER_API void SetVerbosity(Verbosity v); void SetVerbosity(Verbosity v);
CRAFTER_API Verbosity GetVerbosity(); Verbosity GetVerbosity();
// RAII work-unit marker. Construction inflates the running total; destruction // RAII work-unit marker. Construction inflates the running total; destruction
// increments completed and (on Default + TTY) redraws the status line. Move // increments completed and (on Default + TTY) redraws the status line. Move
// semantics are disabled — keep these as plain stack locals inside the worker // semantics are disabled — keep these as plain stack locals inside the worker
// lambda so the destructor fires when that thread's work ends. // lambda so the destructor fires when that thread's work ends.
class CRAFTER_API Task { class Task {
public: public:
explicit Task(std::string label); explicit Task(std::string label);
~Task(); ~Task();
@ -29,18 +27,18 @@ export namespace Crafter::Progress {
}; };
// Verbose-mode command echo. No-op outside Verbose. // Verbose-mode command echo. No-op outside Verbose.
CRAFTER_API void EchoCommand(std::string_view command); void EchoCommand(std::string_view command);
// Non-fatal output from a build step that succeeded — compiler and linker // Non-fatal output from a build step that succeeded — compiler and linker
// warnings. Erases the status line first so the text doesn't land on top of // warnings. Erases the status line first so the text doesn't land on top of
// it, then writes to stderr. No-op on Quiet. // it, then writes to stderr. No-op on Quiet.
CRAFTER_API void Diagnostic(std::string_view text); void Diagnostic(std::string_view text);
// Erase the in-place status line so subsequent stderr writes (errors, // Erase the in-place status line so subsequent stderr writes (errors,
// banners) don't collide with it. No-op when not in TTY-redraw mode. // banners) don't collide with it. No-op when not in TTY-redraw mode.
CRAFTER_API void Clear(); void Clear();
// Print "Built N targets in Xms" (or equivalent) and clear in-place state. // Print "Built N targets in Xms" (or equivalent) and clear in-place state.
// Safe to call multiple times; subsequent calls are no-ops. // Safe to call multiple times; subsequent calls are no-ops.
CRAFTER_API void Finalize(); void Finalize();
} }

View file

@ -1,8 +1,6 @@
// SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module;
#include "Crafter.Build-Api.h"
export module Crafter.Build:Shader; export module Crafter.Build:Shader;
import std; import std;
namespace fs = std::filesystem; namespace fs = std::filesystem;
@ -30,8 +28,8 @@ namespace Crafter {
fs::path path; fs::path path;
std::string entrypoint; std::string entrypoint;
ShaderType type; ShaderType type;
CRAFTER_API Shader(fs::path&& path, std::string&& entrypoint, ShaderType type); Shader(fs::path&& path, std::string&& entrypoint, ShaderType type);
CRAFTER_API bool Check(const fs::path& outputDir) const; bool Check(const fs::path& outputDir) const;
CRAFTER_API std::string Compile(const fs::path& outputDir, std::span<const fs::path> includeDirs) const; std::string Compile(const fs::path& outputDir, std::span<const fs::path> includeDirs) const;
}; };
} }

View file

@ -1,8 +1,6 @@
// SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module;
#include "Crafter.Build-Api.h"
export module Crafter.Build:Test; export module Crafter.Build:Test;
import std; import std;
import :Clang; import :Clang;
@ -28,7 +26,7 @@ export namespace Crafter {
// avoid the double-`GetInterfacesAndImplementations` footgun (calling // avoid the double-`GetInterfacesAndImplementations` footgun (calling
// it twice would re-parse the same .cppm files into duplicate Module // it twice would re-parse the same .cppm files into duplicate Module
// entries). // entries).
struct CRAFTER_API TestBuilder { struct TestBuilder {
Configuration* parent; Configuration* parent;
std::size_t index; std::size_t index;
@ -85,8 +83,8 @@ export namespace Crafter {
bool AllPassed() const { return failed == 0 && crashed == 0 && timedOut == 0; } bool AllPassed() const { return failed == 0 && crashed == 0 && timedOut == 0; }
}; };
CRAFTER_API TestSummary RunTests(Configuration& projectCfg, const RunTestsOptions& opts, std::span<const std::string_view> projectArgs = {}); TestSummary RunTests(Configuration& projectCfg, const RunTestsOptions& opts, std::span<const std::string_view> projectArgs = {});
CRAFTER_API TestResult RunSingleTest(const Test& test, const std::filesystem::path& binary, std::chrono::seconds timeout); TestResult RunSingleTest(const Test& test, const std::filesystem::path& binary, std::chrono::seconds timeout);
// Parent-project access for test fixtures. Run() sets the pointer to the // Parent-project access for test fixtures. Run() sets the pointer to the
// root project's Configuration before discovery; fixtures call ParentLib() // root project's Configuration before discovery; fixtures call ParentLib()
@ -94,6 +92,6 @@ export namespace Crafter {
// parent itself) so they can declare it as a dependency without rebuilding // parent itself) so they can declare it as a dependency without rebuilding
// the lib config from scratch. Lookup is by Configuration::name and // the lib config from scratch. Lookup is by Configuration::name and
// recurses through the parent's dependency graph. // recurses through the parent's dependency graph.
CRAFTER_API void SetParentProject(Configuration* parent); void SetParentProject(Configuration* parent);
CRAFTER_API Configuration* ParentLib(std::string_view name); Configuration* ParentLib(std::string_view name);
} }

View file

@ -0,0 +1,80 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
export module Crafter.Build:Toolchain;
import std;
namespace fs = std::filesystem;
export namespace Crafter {
// The compiler crafter-build drives and what sits next to it, resolved
// once per process by GetToolchain(). Every clang++, clang and llvm-ar
// the tool spawns, the libclang the linter dlopens, the std module
// source and the default sysroots come from here, so a toolchain
// installed anywhere other than /usr (the Flatpak carries its own under
// /app) needs no per-call-site knowledge.
struct Toolchain {
// LLVM install prefix. Empty when the tools are bare names on PATH.
fs::path prefix;
fs::path clangxx;
fs::path clang;
fs::path llvmAr;
// Where clang-c/Index.h lives when it is not on the default include
// path; empty otherwise.
fs::path llvmIncludeDir;
// The exact libclang to dlopen; nullopt means search the usual names.
std::optional<fs::path> libclang;
// A libc++ install other than the compiler's own
// (CRAFTER_BUILD_LIBCXX_DIR); empty means clang finds its own.
fs::path libcxxDir;
// libc++'s std module source for host builds.
fs::path stdCppm;
fs::path wasiSysroot;
// Holds include/c++/<ver>/bits/std.cc for the mingw target. Passed to
// clang as --sysroot only when it was set explicitly: clang's own
// mingw discovery handles the distro layout, and a --sysroot pointing
// at the triple directory would lose crtbegin.o and libgcc.
fs::path mingwSysroot;
bool mingwSysrootExplicit = false;
// `clang++ -print-target-triple` and the first line of
// `clang++ --version`; both empty when clang++ could not be run.
std::string hostTriple;
std::string clangVersion;
// Why clang++ could not be resolved or run; empty when it could.
std::string error;
// Flags for compiling against libcxxDir; empty without one.
std::string LibcxxCompileFlags() const;
// Flags for linking against the libc++ the compiler will use: a -L
// and an rpath for <prefix>/lib when the prefix is not the system one
// (clang searches only its per-target runtime directory and the
// system paths, and a bundled toolchain's libc++.so.1 is on neither
// the linker's nor the loader's default path), or for an explicit
// libcxxDir.
std::string LibcxxLinkFlags() const;
// Names this toolchain for cache directory names: the clang version
// followed by a hash of what decides which headers and libraries a
// PCM is built against. Two toolchains with different keys never
// share a PCM.
std::string Key() const;
};
// What ResolveToolchain reads, so a test can feed it a staged layout.
struct ToolchainInputs {
// The CRAFTER_* variables, present only when set and non-empty.
std::map<std::string, std::string, std::less<>> env;
// The <prefix> of <prefix>/share/crafter-build; empty when unknown.
fs::path crafterPrefix;
};
// Pure path resolution: an env override wins, then a toolchain installed
// beside crafter-build (<crafterPrefix>/bin/clang++), then bare names on
// PATH. Never runs a process, so hostTriple and clangVersion stay empty.
Toolchain ResolveToolchain(const ToolchainInputs& inputs);
// ResolveToolchain over the real environment plus the two clang probes,
// computed once and cached. Never throws.
const Toolchain& GetToolchain();
// GetToolchain(), or a std::runtime_error carrying its error.
const Toolchain& RequireToolchain();
// The version baked in when crafter-build was built; "0.0.0-dev" for a
// build that was given none.
std::string_view CrafterBuildVersion();
}

View file

@ -2,6 +2,7 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
export module Crafter.Build; export module Crafter.Build;
export import :Toolchain;
export import :Clang; export import :Clang;
export import :Platform; export import :Platform;
export import :Implementation; export import :Implementation;

View file

@ -0,0 +1,8 @@
[Flatpak Repo]
Title=Catcrafts
Url=https://flatpak.catcrafts.net/repo/
Homepage=https://forgejo.catcrafts.net/Catcrafts
Comment=Catcrafts tools and applications
Description=crafter-build and other Catcrafts software, built and signed by Catcrafts CI
DefaultBranch=stable
GPGKey=GPG_KEY_BASE64_PLACEHOLDER

View file

@ -0,0 +1,13 @@
[Flatpak Ref]
Title=Crafter Build
Name=net.catcrafts.CrafterBuild
Branch=stable
Url=https://flatpak.catcrafts.net/repo/
SuggestRemoteName=catcrafts
Homepage=https://forgejo.catcrafts.net/Catcrafts/Crafter.Build
Comment=C++26 modules build system
Description=crafter-build with its own LLVM toolchain and WASI sysroot
Icon=
RuntimeRepo=https://dl.flathub.org/repo/flathub.flatpakrepo
IsRuntime=false
GPGKey=GPG_KEY_BASE64_PLACEHOLDER

View file

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-License-Identifier: CC0-1.0 -->
<component type="console-application">
<id>net.catcrafts.CrafterBuild</id>
<name>Crafter Build</name>
<summary>C++26 modules build system</summary>
<metadata_license>CC0-1.0</metadata_license>
<project_license>LGPL-3.0-only</project_license>
<developer id="net.catcrafts">
<name>Catcrafts</name>
</developer>
<description>
<p>
A build system for C++26 modules. Project descriptions are C++: a
project.cpp constructs a Configuration and returns it, and crafter-build
compiles, loads and executes it. The Flatpak carries its own LLVM, so
every machine builds with the same clang, lld, libc++ and WASI sysroot.
</p>
</description>
<url type="homepage">https://forgejo.catcrafts.net/Catcrafts/Crafter.Build</url>
<url type="bugtracker">https://forgejo.catcrafts.net/Catcrafts/Crafter.Build/issues</url>
<url type="vcs-browser">https://forgejo.catcrafts.net/Catcrafts/Crafter.Build</url>
<provides>
<binary>crafter-build</binary>
</provides>
<content_rating type="oars-1.1"/>
<releases>
<release version="2.0.0" date="2026-10-02">
<description>
<p>First release distributed as a Flatpak, with a bundled LLVM 22 toolchain and WASI sysroot. Windows host support is removed; crafter-build gains --version and resolves its compiler from one place.</p>
</description>
</release>
</releases>
</component>

View file

@ -0,0 +1,210 @@
# SPDX-License-Identifier: LGPL-3.0-only
# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
#
# crafter-build as a Flatpak. The runtime is the Freedesktop *Sdk*, not the
# Platform: a build tool needs glibc's headers and crt objects, gcc's
# libgcc_s, cmake, ninja, make, git and python at run time. Everything
# compiler-shaped is ours and lives under /app: clang, lld, libclang, libc++,
# compiler-rt (native and wasm32) and the WASI sysroot, all built from one
# LLVM source tree so the modules crafter-build builds and the compiler that
# consumes them can never drift apart.
app-id: net.catcrafts.CrafterBuild
runtime: org.freedesktop.Sdk
runtime-version: '26.08'
sdk: org.freedesktop.Sdk
command: crafter-build
finish-args:
# git dependencies, and the local web server `-r` starts for browser builds
- --share=network
- --socket=ssh-auth
# projects live anywhere; user sysroots too
- --filesystem=host
# so `-r` can launch what it built, games included
- --share=ipc
- --socket=wayland
- --socket=fallback-x11
- --socket=pulseaudio
- --device=all
# gdb and friends from the Sdk
- --allow=devel
- --env=PATH=/app/bin:/usr/bin
build-options:
env:
CRAFTER_BUILD_LLVM_DIR: /app
# The shipped binary runs on any x86_64 from the last decade; user
# projects still build with -march=native by default.
CRAFTER_BUILD_MARCH: x86-64-v2
CRAFTER_BUILD_MTUNE: generic
# CI replaces this with the tag; a local build is a development build.
CRAFTER_BUILD_VERSION: 0.0.0-dev
cleanup:
- /share/man
- /share/doc
modules:
- name: llvm
buildsystem: cmake-ninja
builddir: true
subdir: llvm
build-options:
# flatpak-builder's default CFLAGS carry -g. For all of LLVM that is a
# 30 GB build tree and a multi-gigabyte .Debug extension, so build
# without debug info, strip, and skip the split. The hardening flags
# from the defaults stay.
no-debuginfo: true
strip: true
cflags: -O2 -pipe -fexceptions -fasynchronous-unwind-tables -fstack-protector-strong -fstack-clash-protection -fcf-protection
cflags-override: true
cxxflags: -O2 -pipe -fexceptions -fasynchronous-unwind-tables -fstack-protector-strong -fstack-clash-protection -fcf-protection
cxxflags-override: true
config-opts:
- -DCMAKE_BUILD_TYPE=Release
- -DLLVM_ENABLE_ASSERTIONS=OFF
- -DLLVM_ENABLE_PROJECTS=clang;lld
- -DLLVM_ENABLE_RUNTIMES=compiler-rt;libcxx;libcxxabi;libunwind
- -DLLVM_TARGETS_TO_BUILD=X86;AArch64;WebAssembly
# Classic layout: libc++ in lib/ and include/c++/v1, not under a
# per-target subdirectory, the same shape as a distro install.
# crafter-build adds -L and an rpath for <prefix>/lib itself, since
# clang searches only its per-target directory and the system paths.
- -DLLVM_ENABLE_PER_TARGET_RUNTIME_DIR=OFF
# One shared LLVM and one shared clang library instead of a statically
# linked copy per tool: a fraction of the install size.
- -DLLVM_BUILD_LLVM_DYLIB=ON
- -DLLVM_LINK_LLVM_DYLIB=ON
- -DCLANG_LINK_CLANG_DYLIB=ON
- -DCLANG_DEFAULT_CXX_STDLIB=libc++
- -DCLANG_DEFAULT_LINKER=lld
- -DLIBCXX_INSTALL_MODULES=ON
- -DLLVM_INCLUDE_TESTS=OFF
- -DLLVM_INCLUDE_EXAMPLES=OFF
- -DLLVM_INCLUDE_DOCS=OFF
- -DLLVM_INCLUDE_BENCHMARKS=OFF
- -DLLVM_INSTALL_UTILS=OFF
- -DCLANG_INCLUDE_TESTS=OFF
- -DCOMPILER_RT_INCLUDE_TESTS=OFF
# The Sdk links with binutils' ld; two libLLVM-sized links at a time
# fit the CX53's memory with room to spare.
- -DLLVM_PARALLEL_LINK_JOBS=2
cleanup:
- /include/llvm
- /include/llvm-c
- /include/clang
- /include/clang-tidy
- /include/lld
- /lib/cmake
- /lib/*.a
- /share/opt-viewer
- /share/scan-build
- /share/scan-view
- /bin/clang-check
- /bin/clang-extdef-mapping
- /bin/clang-import-test
- /bin/clang-linker-wrapper
- /bin/clang-nvlink-wrapper
- /bin/clang-offload-bundler
- /bin/clang-offload-packager
- /bin/clang-refactor
- /bin/clang-rename
- /bin/clang-repl
- /bin/clang-sycl-linker
- /bin/diagtool
- /bin/git-clang-format
- /bin/hmaptool
- /bin/llvm-exegesis
- /bin/scan-build*
- /bin/scan-view
sources:
- type: archive
url: https://github.com/llvm/llvm-project/releases/download/llvmorg-22.1.8/llvm-project-22.1.8.src.tar.xz
sha256: 922f1817a0df7b1489272d18134ee0087a8b068828f87ac63b9861b1a9965888
- name: wasi-sysroot
# wasi-sdk's sysroot-only mode, driven by the clang built above:
# wasi-libc, compiler-rt builtins (installed into our clang's resource
# dir, where the wasm32 driver looks for them) and libc++ for wasip1 and
# wasip1-threads. The llvm-project tree is the same tarball as above so
# the wasm libc++ matches the native one.
buildsystem: cmake-ninja
builddir: true
build-options:
no-debuginfo: true
# flatpak-builder's default CFLAGS/LDFLAGS are x86-64 hardening flags
# (-fcf-protection, -z relro) that the nested wasm32 builds inherit
# through the environment and clang rejects for that target.
cflags: ""
cflags-override: true
cxxflags: ""
cxxflags-override: true
ldflags: ""
ldflags-override: true
strip: true
config-opts:
- -DCMAKE_TOOLCHAIN_FILE=/run/build/wasi-sysroot/wasi-host-toolchain.cmake
- -DWASI_SDK_BUILD_TOOLCHAIN=OFF
- -DWASI_SDK_INSTALL_TO_CLANG_RESOURCE_DIR=ON
- -DWASI_SDK_TARGETS=wasm32-wasip1;wasm32-wasip1-threads
- -DWASI_SDK_INCLUDE_TESTS=OFF
- -DCMAKE_INSTALL_PREFIX=/app
post-install:
# wasi-sdk installs into <prefix>/share/wasi-sysroot; crafter-build
# looks there (Crafter.Build:Toolchain), so nothing to move.
- test -f /app/share/wasi-sysroot/share/libc++/v1/std.cppm
sources:
- type: git
url: https://github.com/WebAssembly/wasi-sdk.git
tag: wasi-sdk-34
disable-submodules: true
# The compiler-rt sub-build gets the C target but not the ASM target, so
# its two wasm/*.S stubs come out as host objects when the driving clang
# does not default to wasm32. Reported upstream; drop once wasi-sdk has it.
- type: patch
path: wasi-sdk-asm-target.patch
- type: git
url: https://github.com/WebAssembly/wasi-libc.git
commit: 2e6fb9d8ee0cdf9e431fbcabe8af3115de000a13
dest: src/wasi-libc
- type: archive
url: https://github.com/llvm/llvm-project/releases/download/llvmorg-22.1.8/llvm-project-22.1.8.src.tar.xz
sha256: 922f1817a0df7b1489272d18134ee0087a8b068828f87ac63b9861b1a9965888
dest: src/llvm-project
- type: file
path: wasi-host-toolchain.cmake
- name: wasmtime
# The runner for wasm32-wasi tests and `-r` on a non-browser wasm build.
buildsystem: simple
build-commands:
- install -Dm755 wasmtime /app/bin/wasmtime
sources:
- type: archive
url: https://github.com/bytecodealliance/wasmtime/releases/download/v49.0.2/wasmtime-v49.0.2-x86_64-linux.tar.xz
sha256: a4d6e9e3a5a60f527cf7793d674c48930c80c2e8977995b8a275cad3254b9322
- name: crafter-build
buildsystem: simple
build-options:
# The self-host build fetches Crafter.Math, Crafter.Asset,
# Crafter.LintRules and glslang from git; they are unpinned on purpose
# (sibling repos tracking their default branch, see AGENTS.md).
build-args:
- --share=network
build-commands:
# Bootstrap by hand, then let the result build itself: only the
# self-hosted binary has asset support and ThinLTO.
- ./build.sh
- ./bin/crafter-build
- CRAFTER_BUILD_HOME=$PWD/share/crafter-build ./bin/crafter-build test
- install -Dm755 bin/crafter.build-exe-*/crafter-build /app/bin/crafter-build
- install -Dm644 bin/crafter.build-lib-*/libcrafter-build.a /app/lib/libcrafter-build.a
- mkdir -p /app/share && cp -r share/crafter-build /app/share/
- install -Dm644 LICENSE LICENSE.GPL -t /app/share/licenses/net.catcrafts.CrafterBuild/
- install -Dm644 packaging/flatpak/net.catcrafts.CrafterBuild.metainfo.xml -t /app/share/metainfo/
sources:
- type: dir
path: ../..
skip:
- .git
- bin
- build
- lib/libcrafter-build.a
- share/crafter-build

View file

@ -0,0 +1,23 @@
# SPDX-License-Identifier: LGPL-3.0-only
# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
#
# Points wasi-sdk's sysroot build at the clang the Flatpak built, instead of
# the one wasi-sdk would build itself. Mirrors the wasi-sdk.cmake toolchain
# file an installed wasi-sdk ships, with our prefix.
set(WASI_SDK_PREFIX /app)
set(CMAKE_SYSTEM_NAME WASI)
set(CMAKE_SYSTEM_VERSION 1)
set(CMAKE_SYSTEM_PROCESSOR wasm32)
set(triple wasm32-wasip1)
set(CMAKE_C_COMPILER ${WASI_SDK_PREFIX}/bin/clang)
set(CMAKE_CXX_COMPILER ${WASI_SDK_PREFIX}/bin/clang++)
set(CMAKE_ASM_COMPILER ${WASI_SDK_PREFIX}/bin/clang)
set(CMAKE_AR ${WASI_SDK_PREFIX}/bin/llvm-ar)
set(CMAKE_RANLIB ${WASI_SDK_PREFIX}/bin/llvm-ranlib)
set(CMAKE_NM ${WASI_SDK_PREFIX}/bin/llvm-nm)
set(CMAKE_C_COMPILER_TARGET ${triple})
set(CMAKE_CXX_COMPILER_TARGET ${triple})
set(CMAKE_ASM_COMPILER_TARGET ${triple})
# The sysroot does not exist yet when cmake probes the compiler.
set(CMAKE_C_COMPILER_WORKS ON)
set(CMAKE_CXX_COMPILER_WORKS ON)

View file

@ -0,0 +1,20 @@
wasi-sdk-sysroot.cmake: tell the compiler-rt sub-build the ASM target too
The sub-build gets CMAKE_C_COMPILER_TARGET but nothing for the ASM
language, so compiler-rt's wasm/*.S stubs (the setjmp/longjmp and C++
exception tags) are assembled for the host when the driving clang is not
a wasm-default build. wasm-ld then warns that the archive members are
neither Wasm objects nor bitcode, and code that needs the tags fails to
link. wasi-sdk's own toolchain hides this because its clang defaults to
wasm32-wasip1; the sysroot-only mode against a host clang does not.
--- a/cmake/wasi-sdk-sysroot.cmake
+++ b/cmake/wasi-sdk-sysroot.cmake
@@ -107,6 +107,7 @@
-DCOMPILER_RT_BUILD_ORC=OFF
-DCOMPILER_RT_BUILD_GWP_ASAN=OFF
-DCMAKE_C_COMPILER_TARGET=${target}
+ -DCMAKE_ASM_COMPILER_TARGET=${target}
-DCMAKE_C_FLAGS=${WASI_SDK_CPU_CFLAGS}
-DCMAKE_CXX_FLAGS=${WASI_SDK_CPU_CFLAGS}
-DCMAKE_ASM_FLAGS=${WASI_SDK_CPU_CFLAGS}

View file

@ -24,30 +24,36 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
CrafterBuildLib->name = "crafter.build-lib"; CrafterBuildLib->name = "crafter.build-lib";
CrafterBuildLib->outputName = "crafter-build"; CrafterBuildLib->outputName = "crafter-build";
ApplyStandardArgs(*CrafterBuildLib, args); ApplyStandardArgs(*CrafterBuildLib, args);
// Windows builds (native msvc via build.cmd or cross-compiled mingw from // Monolithic: project.so resolves Crafter:: symbols from the running
// Linux) need a DLL + import lib + launcher exe so LoadProject can // executable, so the library is only ever linked statically into it.
// compile project.cpp against a stable ABI boundary. Linux is monolithic. CrafterBuildLib->type = ConfigurationType::LibraryStatic;
CrafterBuildLib->type = (CrafterBuildLib->target == "x86_64-w64-mingw32" || CrafterBuildLib->target == "x86_64-pc-windows-msvc")
? ConfigurationType::LibraryDynamic
: ConfigurationType::LibraryStatic;
CrafterBuildLib->dependencies = { math, asset }; CrafterBuildLib->dependencies = { math, asset };
CrafterBuildLib->defines.push_back({"CRAFTER_BUILD_HAS_ASSET", ""}); CrafterBuildLib->defines.push_back({"CRAFTER_BUILD_HAS_ASSET", ""});
// Crafter.Build-Lint includes libclang's clang-c/Index.h. glibc distros // Crafter.Build-Lint includes libclang's clang-c/Index.h, which is only
// keep it in /usr/include; Alpine keeps LLVM under /usr/lib/llvmNN, so ask // on the default include path for a toolchain installed in /usr.
// llvm-config and add the dir when it is not already on the default path if (const fs::path& llvmInclude = GetToolchain().llvmIncludeDir; !llvmInclude.empty()) {
// (never /usr/include itself: that breaks libc++'s #include_next). CrafterBuildLib->compileFlags.push_back(std::format("-I{}", llvmInclude.string()));
if (CrafterBuildLib->target.ends_with("-linux-gnu") || CrafterBuildLib->target.ends_with("-linux-musl")) {
auto llvmInc = RunCommandWithTimeout("llvm-config --includedir 2>/dev/null", std::chrono::seconds(10));
std::string incDir = llvmInc.output;
while (!incDir.empty() && (incDir.back() == '\n' || incDir.back() == '\r')) incDir.pop_back();
if (llvmInc.exitCode == 0 && !incDir.empty() && incDir != "/usr/include") {
CrafterBuildLib->compileFlags.push_back(std::format("-I{}", incDir));
}
} }
// The version the binary reports: CI sets it from the tag, a checkout
// describes itself, anything else is a development build. A define on
// the library, so each version is its own library variant.
std::string version;
if (const char* env = std::getenv("CRAFTER_BUILD_VERSION"); env && *env) {
version = env;
} else {
auto described = RunCommandWithTimeout("git describe --tags --always --dirty --exclude=latest 2>/dev/null", std::chrono::seconds(10));
version = described.output;
while (!version.empty() && (version.back() == '\n' || version.back() == '\r')) version.pop_back();
if (described.exitCode != 0 || version.empty()) version = "0.0.0-dev";
}
// Backslash-escaped quotes: the define travels through the shell before
// it reaches clang, which must still see a string literal.
CrafterBuildLib->defines.push_back({"CRAFTER_BUILD_VERSION", std::format("\\\"{}\\\"", version)});
{ {
std::array<fs::path, 11> interfaces = { std::array<fs::path, 12> interfaces = {
"interfaces/Crafter.Build", "interfaces/Crafter.Build",
"interfaces/Crafter.Build-Toolchain",
"interfaces/Crafter.Build-Shader", "interfaces/Crafter.Build-Shader",
"interfaces/Crafter.Build-Platform", "interfaces/Crafter.Build-Platform",
"interfaces/Crafter.Build-Interface", "interfaces/Crafter.Build-Interface",
@ -59,7 +65,8 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
"interfaces/Crafter.Build-Progress", "interfaces/Crafter.Build-Progress",
"interfaces/Crafter.Build-Asset", "interfaces/Crafter.Build-Asset",
}; };
std::array<fs::path, 10> implementations = { std::array<fs::path, 11> implementations = {
"implementations/Crafter.Build-Toolchain",
"implementations/Crafter.Build-Shader", "implementations/Crafter.Build-Shader",
"implementations/Crafter.Build-Platform", "implementations/Crafter.Build-Platform",
"implementations/Crafter.Build-Interface", "implementations/Crafter.Build-Interface",
@ -79,12 +86,6 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
glslang.source.branch = "main"; glslang.source.branch = "main";
glslang.builder = ExternalBuilder::CMake; glslang.builder = ExternalBuilder::CMake;
glslang.options = { "-DENABLE_OPT=OFF" }; glslang.options = { "-DENABLE_OPT=OFF" };
// mingw cross-build: skip the standalone executable. We only consume the
// libraries, and glslang.exe pulls in libgcc_eh which needs pthread that
// mingw-w64 doesn't link by default.
if (CrafterBuildLib->target == "x86_64-w64-mingw32") {
glslang.options.push_back("-DENABLE_GLSLANG_BINARIES=OFF");
}
glslang.includeDirs = { "" }; glslang.includeDirs = { "" };
glslang.libs = { "SPIRV", "GenericCodeGen", "glslang", "OSDependent", "MachineIndependent", "glslang-default-resource-limits" }; glslang.libs = { "SPIRV", "GenericCodeGen", "glslang", "OSDependent", "MachineIndependent", "glslang-default-resource-limits" };
@ -106,10 +107,6 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
cfg.linkFlags.push_back("-Wl,--export-dynamic"); cfg.linkFlags.push_back("-Wl,--export-dynamic");
cfg.linkFlags.push_back("-ldl"); cfg.linkFlags.push_back("-ldl");
} }
if (cfg.target == "x86_64-w64-mingw32" || cfg.target == "x86_64-pc-windows-msvc") {
// winsock for the -r wasm port probe (bind/WSAStartup).
CrafterBuildLib->linkFlags.push_back("-lws2_32");
}
// Self-tests link the local crafter-build library and exercise it in // Self-tests link the local crafter-build library and exercise it in
// process. The harness (whichever crafter-build invokes `test`) compiles // process. The harness (whichever crafter-build invokes `test`) compiles
@ -131,8 +128,11 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
cfg.AddTest("StandardArgs").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("StandardArgs").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("TestRunnerSpec").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("TestRunnerSpec").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("VariantId").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("VariantId").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("ToolchainResolve").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("VersionFlag").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("WasiBrowserRuntime").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("WasiBrowserRuntime").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("WasmVariants").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("WasmVariants").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("WasmHelloRuns").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("RunSingleTestExit").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("RunSingleTestExit").Dependencies({ CrafterBuildLib.get() });
// LoadProject dlopens the synthesized project.so, which references // LoadProject dlopens the synthesized project.so, which references
// Crafter:: symbols (HostTarget, Configuration ctors) that have to be // Crafter:: symbols (HostTarget, Configuration ctors) that have to be
@ -143,6 +143,8 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
// Same LoadProject wiring as ConcurrentCacheRace above. // Same LoadProject wiring as ConcurrentCacheRace above.
cfg.AddTest("HostCacheSourceStamp").Dependencies({ CrafterBuildLib.get() }) cfg.AddTest("HostCacheSourceStamp").Dependencies({ CrafterBuildLib.get() })
.LinkFlag("-Wl,--export-dynamic").LinkFlag("-ldl"); .LinkFlag("-Wl,--export-dynamic").LinkFlag("-ldl");
cfg.AddTest("HostCacheToolchainKey").Dependencies({ CrafterBuildLib.get() })
.LinkFlag("-Wl,--export-dynamic").LinkFlag("-ldl");
cfg.AddTest("ProjectDependency").Dependencies({ CrafterBuildLib.get() }) cfg.AddTest("ProjectDependency").Dependencies({ CrafterBuildLib.get() })
.LinkFlag("-Wl,--export-dynamic").LinkFlag("-ldl"); .LinkFlag("-Wl,--export-dynamic").LinkFlag("-ldl");
cfg.AddTest("ConcurrentDependencyReset").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("ConcurrentDependencyReset").Dependencies({ CrafterBuildLib.get() });

View file

@ -71,11 +71,7 @@ namespace {
app.dependencies = { lib }; app.dependencies = { lib };
// The link step reads a command's output the same way a compile does, // The link step reads a command's output the same way a compile does,
// so cover it too: an unknown -z value makes ld.lld warn and exit 0. // so cover it too: an unknown -z value makes ld.lld warn and exit 0.
// GNU-style only — lld-link spells its options differently — so the app.linkFlags = { "-Wl,-z,crafter-build-nonexistent-z-value" };
// link case rides along on hosts that use it and is skipped elsewhere.
if (!app.target.contains("windows")) {
app.linkFlags = { "-Wl,-z,crafter-build-nonexistent-z-value" };
}
return app; return app;
} }

View file

@ -0,0 +1,154 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
#include <stdlib.h>
import std;
import Crafter.Build;
namespace fs = std::filesystem;
using namespace Crafter;
// Freshness of the std PCM and of project.so used to be an mtime comparison
// against files that come with the install: std.cppm and the crafter-build
// executable. A Flatpak (any OSTree deployment) gives every installed file
// mtime 0, so those inputs always looked older than the cache: a clang bump
// would never rebuild std.pcm, a crafter-build update would never recompile
// project.so, and clang crashes on the stale PCM instead of saying so.
//
// Both are now keyed on content stamps (the compile command, the std module
// source, the crafter-build version, the toolchain key and the module source
// stamp) and the cache directory carries the toolchain key in its name. This
// test drives the shapes an mtime rule gets wrong: a std.pcm dated before its
// source must still be reused, a warm load must leave project.so alone, and a
// stamp that disagrees must rebuild even when the artifact is dated after
// every input.
namespace {
std::int32_t Failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++Failures;
}
}
fs::path FindModuleCacheDir(const fs::path& cacheRoot) {
for (const fs::directory_entry& entry : fs::directory_iterator(cacheRoot / "crafter.build")) {
if (entry.is_directory()) return entry.path();
}
return {};
}
bool Load(const fs::path& projectFile, std::string_view what) {
std::array<std::string_view, 0> noArgs = {};
try {
(void)LoadProject(projectFile, noArgs);
return true;
} catch (const std::exception& e) {
std::println(std::cerr, "FAIL: LoadProject ({}) threw: {}", what, e.what());
++Failures;
return false;
}
}
fs::path StampOf(const fs::path& artifact) {
fs::path stamp = artifact;
stamp += ".stamp";
return stamp;
}
}
int main() {
fs::path installedHome = GetCrafterBuildHome();
if (!fs::exists(installedHome / "Crafter.Build.cppm")) {
std::println(std::cerr, "SKIP: no module sources at {}", installedHome.string());
return 77;
}
fs::path scratch = fs::temp_directory_path() / "crafter-build-host-cache-toolchain-key";
std::error_code ec;
fs::remove_all(scratch, ec);
fs::create_directories(scratch);
// A private copy of the module sources and a cold private cache, as in
// HostCacheSourceStamp.
fs::path home = scratch / "share" / "crafter-build";
fs::create_directories(home.parent_path());
fs::copy(installedHome, home, fs::copy_options::recursive);
setenv("CRAFTER_BUILD_HOME", home.string().c_str(), 1);
fs::path cacheRoot = scratch / "cache";
fs::create_directories(cacheRoot);
setenv("XDG_CACHE_HOME", cacheRoot.string().c_str(), 1);
fs::path projectDir = scratch / "project";
fs::create_directories(projectDir);
fs::path projectFile = projectDir / "project.cpp";
std::ofstream(projectFile) <<
"import std;\n"
"import Crafter.Build;\n"
"using namespace Crafter;\n"
"extern \"C\" Configuration CrafterBuildProject(std::span<const std::string_view>) {\n"
" Configuration cfg;\n"
" cfg.path = \"./\";\n"
" cfg.name = \"keyed\";\n"
" cfg.outputName = \"keyed\";\n"
" cfg.type = ConfigurationType::Executable;\n"
" return cfg;\n"
"}\n";
if (!Load(projectFile, "cold")) return 1;
const fs::path cacheDir = FindModuleCacheDir(cacheRoot);
Check(!cacheDir.empty(), "a module cache directory was created");
if (cacheDir.empty()) return 1;
Check(cacheDir.filename().string().contains(GetToolchain().Key()), std::format("the cache directory is named for the toolchain (got '{}', key '{}')", cacheDir.filename().string(), GetToolchain().Key()));
const fs::path stdPcm = cacheDir / "std.pcm";
const fs::path projectSo = projectDir / "build" / "project.so";
Check(fs::exists(stdPcm) && fs::exists(StampOf(stdPcm)), "std.pcm and its stamp exist after a cold load");
Check(fs::exists(projectSo) && fs::exists(StampOf(projectSo)), "project.so and its stamp exist after a cold load");
if (!fs::exists(StampOf(stdPcm)) || !fs::exists(StampOf(projectSo))) return 1;
// The OSTree shape, inverted: std.pcm dated before its source. A stamp
// rule reuses it; an mtime rule would rebuild it on every run. project.so
// keeps two mtime inputs that live next to it (project.cpp and dependency
// artifacts) but the executable is no longer one of them, so a warm load
// with nothing changed leaves it alone.
const fs::file_time_type epoch{};
fs::last_write_time(stdPcm, epoch);
const fs::file_time_type afterCold = fs::last_write_time(projectSo);
if (!Load(projectFile, "std.pcm dated at the epoch")) return 1;
Check(fs::last_write_time(stdPcm) == epoch, "std.pcm dated before its source is still reused");
Check(fs::last_write_time(projectSo) == afterCold, "project.so is left alone when nothing changed");
// The other half: artifacts dated after every input, with a stamp that
// disagrees. An mtime rule keeps them; the stamp rule rebuilds.
const fs::file_time_type future = fs::file_time_type::clock::now() + std::chrono::hours(24);
std::ofstream(StampOf(stdPcm)) << "stale";
fs::last_write_time(stdPcm, future);
if (!Load(projectFile, "stale std.pcm stamp")) return 1;
Check(fs::last_write_time(stdPcm) != future, "a disagreeing stamp rebuilds std.pcm even when it is dated after its source");
{
std::ifstream in(StampOf(stdPcm));
std::string stamp;
std::getline(in, stamp);
Check(stamp != "stale" && !stamp.empty(), "the rebuilt std.pcm gets its stamp back");
}
std::ofstream(StampOf(projectSo)) << "stale";
fs::last_write_time(projectSo, future);
if (!Load(projectFile, "stale project.so stamp")) return 1;
Check(fs::last_write_time(projectSo) != future, "a disagreeing stamp recompiles project.so even when it is dated after its inputs");
// The one mtime input that legitimately remains: the project file itself.
const fs::file_time_type beforeEdit = fs::last_write_time(projectSo);
{
std::ofstream append(projectFile, std::ios::app);
append << "// edited\n";
}
fs::last_write_time(projectFile, fs::file_time_type::clock::now() + std::chrono::seconds(2));
if (!Load(projectFile, "edited project.cpp")) return 1;
Check(fs::last_write_time(projectSo) != beforeEdit, "an edited project.cpp recompiles project.so");
fs::remove_all(scratch, ec);
return Failures == 0 ? 0 : 1;
}

View file

@ -16,5 +16,8 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>)
// Once by the module it exports, once by its configuration name; the // Once by the module it exports, once by its configuration name; the
// test expects the same object back for both. // test expects the same object back for both.
cfg.dependencies = { ProjectDependency("Greeter"), ProjectDependency("greeter") }; cfg.dependencies = { ProjectDependency("Greeter"), ProjectDependency("greeter") };
// Reported through a flag the test can read back: this file is the one
// LoadProject was pointed at, the greeter's is not.
cfg.compileFlags.push_back(IsTopLevelProject() ? "-DTOP_LEVEL=1" : "-DTOP_LEVEL=0");
return cfg; return cfg;
} }

View file

@ -19,5 +19,7 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
std::array<fs::path, 1> ifaces = { "interfaces/Greeter" }; std::array<fs::path, 1> ifaces = { "interfaces/Greeter" };
std::array<fs::path, 0> impls = {}; std::array<fs::path, 0> impls = {};
cfg.GetInterfacesAndImplementations(ifaces, impls); cfg.GetInterfacesAndImplementations(ifaces, impls);
// Loaded as a dependency, so this must come out false (see app/).
cfg.compileFlags.push_back(IsTopLevelProject() ? "-DTOP_LEVEL=1" : "-DTOP_LEVEL=0");
return cfg; return cfg;
} }

View file

@ -92,11 +92,14 @@ int main(int argc, char** argv) {
const fs::path appSo = scratch / "app" / "build" / "project.so"; const fs::path appSo = scratch / "app" / "build" / "project.so";
std::array<std::string_view, 0> noArgs = {}; std::array<std::string_view, 0> noArgs = {};
Check(IsTopLevelProject(), "outside any project file counts as top level");
// The dependency is fetched, built and linked before project.cpp runs, // 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.
try { try {
Configuration cfg = LoadProject(app, noArgs); Configuration cfg = LoadProject(app, noArgs);
Check(cfg.name == "hello from greeter", std::format("project.cpp imported and called the dependency's module (got '{}')", cfg.name)); 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"); Check(cfg.dependencies.size() == 2, "ProjectDependency resolved by module name and by configuration name");
if (cfg.dependencies.size() == 2) { if (cfg.dependencies.size() == 2) {
Check(cfg.dependencies[0] == cfg.dependencies[1], "both names resolve to the same dependency"); Check(cfg.dependencies[0] == cfg.dependencies[1], "both names resolve to the same dependency");
@ -105,6 +108,7 @@ int main(int argc, char** argv) {
Check(dep.target == HostTarget(), "the dependency was built for the host triple LoadProject uses"); Check(dep.target == HostTarget(), "the dependency was built for the host triple LoadProject uses");
Check(dep.march == "native", "the environment's march was overridden for host tooling"); 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, "-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(fs::exists(ExpectedOutput(dep)), "the dependency archive exists"); Check(fs::exists(ExpectedOutput(dep)), "the dependency archive exists");
} }
} catch (const std::exception& e) { } catch (const std::exception& e) {

View file

@ -6,9 +6,7 @@ import Crafter.Build;
using namespace Crafter; using namespace Crafter;
// POSIX env helpers — libc++ exports only std::getenv via `import std`, so // POSIX env helpers — libc++ exports only std::getenv via `import std`, so
// forward-declare the setters we need against the C library directly. The // forward-declare the setters we need against the C library directly.
// test only runs on POSIX hosts (the project.cpp gates new tests by
// x86_64-pc-linux-gnu) so we don't need a Windows alternative here.
extern "C" int setenv(const char* name, const char* value, int overwrite); extern "C" int setenv(const char* name, const char* value, int overwrite);
extern "C" int unsetenv(const char* name); extern "C" int unsetenv(const char* name);
@ -87,12 +85,36 @@ int main() {
Check(r.exec.find("QEMU_LD_PREFIX=/opt/alarm-sysroot") != std::string::npos, "ForTarget propagates sysroot to QEMU_LD_PREFIX"); Check(r.exec.find("QEMU_LD_PREFIX=/opt/alarm-sysroot") != std::string::npos, "ForTarget propagates sysroot to QEMU_LD_PREFIX");
} }
{ {
// From a Linux host the only sensible way to run x86_64-w64-mingw32 is wine. // The only way to run x86_64-w64-mingw32 output is wine.
Configuration cfg; Configuration cfg;
cfg.target = "x86_64-w64-mingw32"; cfg.target = "x86_64-w64-mingw32";
TestRunner r = TestRunner::ForTarget(cfg); TestRunner r = TestRunner::ForTarget(cfg);
if (HostTarget().find("linux") != std::string::npos) { Check(r.name == "wine", "ForTarget(mingw) uses wine");
Check(r.name == "wine", "ForTarget(mingw) on Linux host uses wine"); }
// SameTarget: the vendor field is spelling, not identity. A clang built
// without a vendor reports x86_64-unknown-linux-gnu where Arch's reports
// x86_64-pc-linux-gnu; both are the host, and a project that wrote the
// other spelling must still run its tests locally.
{
Check(SameTarget("x86_64-pc-linux-gnu", "x86_64-unknown-linux-gnu"), "pc and unknown vendors compare equal");
Check(SameTarget("x86_64-pc-linux-gnu", "x86_64-linux-gnu"), "a vendorless triple compares equal");
Check(SameTarget("x86_64-alpine-linux-musl", "x86_64-linux-musl"), "a distro vendor compares equal");
Check(SameTarget("aarch64-unknown-linux-gnu", "aarch64-linux-gnu"), "the vendor is dropped on other arches too");
Check(SameTarget("wasm32-unknown-wasip1", "wasm32-wasip1"), "a three-part triple with a vendor compares equal");
Check(!SameTarget("x86_64-pc-linux-gnu", "x86_64-linux-musl"), "a different environment stays different");
Check(!SameTarget("x86_64-pc-linux-gnu", "aarch64-unknown-linux-gnu"), "a different arch stays different");
Check(!SameTarget("x86_64-w64-mingw32", "x86_64-linux-gnu"), "mingw's w64 is not a vendor to drop");
Check(NormalizeTarget("x86_64-w64-mingw32") == "x86_64-w64-mingw32", "a mingw triple is left alone");
Check(NormalizeTarget("wasm32-wasip1") == "wasm32-wasip1", "a two-part triple is left alone");
Configuration pc;
pc.target = "x86_64-pc-linux-gnu";
Configuration unknown;
unknown.target = "x86_64-unknown-linux-gnu";
if (SameTarget(HostTarget(), "x86_64-linux-gnu")) {
Check(TestRunner::ForTarget(pc).IsLocal(), "ForTarget runs the pc spelling locally on an x86_64 glibc host");
Check(TestRunner::ForTarget(unknown).IsLocal(), "ForTarget runs the unknown spelling locally on an x86_64 glibc host");
} }
} }

View file

@ -0,0 +1,169 @@
// 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;
// ResolveToolchain is pure path resolution, so this test needs no compiler:
// it stages fake prefixes under the temp dir and checks which one wins,
// what is picked up beside it, and what the defaults are when nothing is.
// The last block runs the real GetToolchain() once, which is the only part
// that spawns clang.
namespace {
std::int32_t Failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++Failures;
}
}
void Touch(const fs::path& file) {
fs::create_directories(file.parent_path());
std::ofstream(file) << "";
}
bool Contains(std::string_view haystack, std::string_view needle) {
return haystack.find(needle) != std::string_view::npos;
}
}
int main() {
const fs::path scratch = fs::temp_directory_path() / "crafter-build-toolchain-resolve";
std::error_code ec;
fs::remove_all(scratch, ec);
fs::create_directories(scratch);
// A prefix with everything crafter-build looks for beside the compiler,
// and one with the compiler alone.
const fs::path full = scratch / "full";
for (const char* rel : {"bin/clang++", "bin/clang", "bin/llvm-ar", "lib/libclang.so", "include/clang-c/Index.h", "share/libc++/v1/std.cppm", "share/wasi-sysroot/include/.keep"}) {
Touch(full / rel);
}
const fs::path bare = scratch / "bare";
Touch(bare / "bin/clang++");
{
// Nothing set and nothing beside crafter-build: bare names on PATH
// and the system locations.
Toolchain tc = ResolveToolchain({});
Check(tc.error.empty(), "the PATH fallback is not an error");
Check(tc.prefix.empty(), "the PATH fallback has no prefix");
Check(tc.clangxx == "clang++" && tc.clang == "clang" && tc.llvmAr == "llvm-ar", "the PATH fallback uses bare tool names");
Check(!tc.libclang.has_value(), "the PATH fallback searches libclang by name");
Check(tc.llvmIncludeDir.empty(), "the PATH fallback adds no include dir");
Check(tc.stdCppm == "/usr/share/libc++/v1/std.cppm", "the system std module source is the default");
Check(tc.wasiSysroot == "/usr/share/wasi-sysroot", "the system wasi sysroot is the default");
Check(tc.mingwSysroot == "/usr/x86_64-w64-mingw32" && !tc.mingwSysrootExplicit, "the default mingw dir is not passed as --sysroot");
Check(tc.LibcxxCompileFlags().empty() && tc.LibcxxLinkFlags().empty(), "the PATH fallback adds no libc++ flags");
}
{
// A toolchain installed beside crafter-build wins over PATH, and
// everything found beside it is used.
ToolchainInputs in;
in.crafterPrefix = full;
Toolchain tc = ResolveToolchain(in);
Check(tc.prefix == full, "the prefix beside crafter-build is picked");
Check(tc.clangxx == full / "bin/clang++" && tc.clang == full / "bin/clang" && tc.llvmAr == full / "bin/llvm-ar", "the tools come from that prefix");
Check(tc.libclang == full / "lib/libclang.so", "libclang beside the compiler is pinned");
Check(tc.llvmIncludeDir == full / "include", "clang-c headers beside the compiler are added to the include path");
Check(tc.stdCppm == full / "share/libc++/v1/std.cppm", "the prefix's std module source is used");
Check(tc.wasiSysroot == full / "share/wasi-sysroot", "the prefix's wasi sysroot is used");
Check(tc.LibcxxCompileFlags().empty(), "the compiler finds its own libc++ headers without flags");
Check(Contains(tc.LibcxxLinkFlags(), std::format("-L{}", (full / "lib").string())) && Contains(tc.LibcxxLinkFlags(), std::format("-Wl,-rpath,{}", (full / "lib").string())), "binaries get -L and an rpath for the prefix's lib dir");
}
{
ToolchainInputs in;
in.crafterPrefix = scratch / "nothing-here";
Toolchain tc = ResolveToolchain(in);
Check(tc.prefix.empty() && tc.clangxx == "clang++", "a crafter-build prefix without clang++ falls back to PATH");
}
{
// The environment wins over the prefix beside crafter-build. A
// prefix with nothing but the compiler keeps the system defaults for
// everything else.
ToolchainInputs in;
in.crafterPrefix = full;
in.env["CRAFTER_BUILD_LLVM_DIR"] = bare.string();
Toolchain tc = ResolveToolchain(in);
Check(tc.error.empty(), "an explicit prefix with a clang++ is accepted");
Check(tc.prefix == bare, "CRAFTER_BUILD_LLVM_DIR wins over the prefix beside crafter-build");
Check(!tc.libclang.has_value(), "no libclang.so in the prefix: search by name");
Check(tc.llvmIncludeDir.empty(), "no clang-c headers in the prefix: nothing added");
Check(tc.stdCppm == "/usr/share/libc++/v1/std.cppm", "no std module in the prefix: the system copy");
Check(tc.wasiSysroot == "/usr/share/wasi-sysroot", "no wasi sysroot in the prefix: the system one");
Check(Contains(tc.LibcxxLinkFlags(), std::format("-L{}", (bare / "lib").string())) && Contains(tc.LibcxxLinkFlags(), std::format("-Wl,-rpath,{}", (bare / "lib").string())), "the link flags follow the explicit prefix");
}
{
ToolchainInputs in;
in.env["CRAFTER_BUILD_LLVM_DIR"] = (scratch / "missing").string();
Toolchain tc = ResolveToolchain(in);
Check(!tc.error.empty() && Contains(tc.error, "CRAFTER_BUILD_LLVM_DIR"), "an explicit prefix without clang++ is an error, not a fallback");
}
{
ToolchainInputs in;
in.env["CRAFTER_BUILD_LLVM_DIR"] = "/usr";
Toolchain tc = ResolveToolchain(in);
Check(tc.LibcxxLinkFlags().empty(), "the system prefix needs no rpath");
Check(tc.llvmIncludeDir.empty(), "the system prefix needs no include dir");
}
{
ToolchainInputs in;
in.crafterPrefix = full;
in.env["CRAFTER_BUILD_LIBCLANG"] = "/nonexistent/libclang.so";
Check(ResolveToolchain(in).libclang == fs::path("/nonexistent/libclang.so"), "CRAFTER_BUILD_LIBCLANG is taken verbatim, even when it does not exist");
}
{
// An explicit libc++ install: headers, library and std module all
// come from it, whatever the compiler would have used.
const fs::path libcxx = scratch / "libcxx";
ToolchainInputs in;
in.crafterPrefix = full;
in.env["CRAFTER_BUILD_LIBCXX_DIR"] = libcxx.string();
Toolchain tc = ResolveToolchain(in);
Check(tc.libcxxDir == libcxx, "CRAFTER_BUILD_LIBCXX_DIR is recorded");
Check(tc.stdCppm == libcxx / "share/libc++/v1/std.cppm", "the std module source follows the explicit libc++");
Check(Contains(tc.LibcxxCompileFlags(), std::format("-nostdinc++ -isystem {}", (libcxx / "include/c++/v1").string())), "compiles use the explicit libc++ headers");
Check(Contains(tc.LibcxxLinkFlags(), std::format("-L{}", (libcxx / "lib").string())) && Contains(tc.LibcxxLinkFlags(), std::format("-Wl,-rpath,{}", (libcxx / "lib").string())), "links use the explicit libc++ library dir, with an rpath");
}
{
ToolchainInputs in;
in.env["CRAFTER_BUILD_WASI_SYSROOT"] = "/opt/wasi";
in.env["CRAFTER_MINGW_DIR"] = "/opt/mingw";
Toolchain tc = ResolveToolchain(in);
Check(tc.wasiSysroot == "/opt/wasi", "CRAFTER_BUILD_WASI_SYSROOT overrides the wasi sysroot");
Check(tc.mingwSysroot == "/opt/mingw" && tc.mingwSysrootExplicit, "CRAFTER_MINGW_DIR overrides the mingw dir and is passed as --sysroot");
}
{
// The cache key separates toolchains that would build different
// PCMs and nothing else.
Toolchain a = ResolveToolchain({});
a.clangVersion = "clang version 22.1.8";
Toolchain b = a;
b.clangVersion = "Arch Linux clang version 22.1.9";
Toolchain c = a;
c.libcxxDir = "/opt/libcxx";
Toolchain d = ResolveToolchain({});
d.clangVersion = a.clangVersion;
Check(a.Key().starts_with("22.1.8-"), std::format("the key starts with the short clang version (got '{}')", a.Key()));
Check(b.Key().starts_with("22.1.9-"), "the short version is parsed past a distro prefix");
Check(a.Key() != b.Key(), "a different clang version changes the key");
Check(a.Key() != c.Key(), "a different libc++ location changes the key");
Check(a.Key() == d.Key(), "the key is deterministic");
}
{
// The real toolchain on this machine resolves and the probes work;
// HostTarget is the probe's answer.
const Toolchain& real = GetToolchain();
Check(real.error.empty(), std::format("the real toolchain resolves (error: '{}')", real.error));
Check(!real.hostTriple.empty() && real.hostTriple == HostTarget(), "HostTarget comes from the toolchain probe");
Check(!real.clangVersion.empty(), "the clang version was probed");
Check(!real.Key().starts_with("unknown-"), std::format("the real clang version parses (got '{}')", real.Key()));
}
fs::remove_all(scratch, ec);
return Failures == 0 ? 0 : 1;
}

View file

@ -0,0 +1,46 @@
// 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;
// `--version` has to work from any directory, before a project file is
// looked for, and must be answered rather than forwarded to project.cpp as
// one more unknown argument.
namespace {
std::int32_t Failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++Failures;
}
}
std::int32_t RunWith(std::vector<std::string> args) {
std::vector<char*> argv;
for (std::string& arg : args) argv.push_back(arg.data());
return Run(static_cast<std::int32_t>(argv.size()), argv.data());
}
}
int main() {
Check(!CrafterBuildVersion().empty(), "a version string is always baked in");
// An empty directory: no project.cpp to fall back to.
const fs::path scratch = fs::temp_directory_path() / "crafter-build-version-flag";
std::error_code ec;
fs::remove_all(scratch, ec);
fs::create_directories(scratch);
fs::current_path(scratch);
Check(RunWith({"crafter-build", "--version"}) == 0, "--version exits 0 without a project file");
Check(RunWith({"crafter-build", "--version", "--some-project-flag"}) == 0, "--version is answered before unknown arguments are forwarded");
Check(RunWith({"crafter-build", "--some-project-flag", "--version"}) == 0, "--version is answered wherever it appears");
fs::current_path(fs::temp_directory_path());
fs::remove_all(scratch, ec);
return Failures == 0 ? 0 : 1;
}

View file

@ -0,0 +1,9 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
import std;
int main() {
std::println("hello from wasm");
return 0;
}

View file

@ -0,0 +1,57 @@
// 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 wasm build end to end: the fixture is compiled for wasm32-wasip1 against
// the resolved toolchain's WASI sysroot and the result is run under
// wasmtime. The other wasm tests only inspect generated files; this is the
// one that proves the sysroot, the std module for wasm and wasm-ld all work
// together. It skips when the sysroot or wasmtime is missing, so a
// development box without them is not a failure. Inside the Flatpak both are
// bundled, and there the skip would be the failure to look for.
int main() {
const Toolchain& tc = GetToolchain();
if (!fs::exists(tc.wasiSysroot / "include")) {
std::println(std::cerr, "SKIP: no WASI sysroot at {}", tc.wasiSysroot.string());
return 77;
}
if (std::system("which wasmtime > /dev/null 2>&1") != 0) {
std::println(std::cerr, "SKIP: wasmtime not on PATH");
return 77;
}
Configuration cfg;
cfg.path = fs::current_path() / "tests" / "WasmHelloRuns" / "fixture";
cfg.name = "wasm-hello";
cfg.outputName = "wasm-hello";
cfg.target = "wasm32-wasip1";
cfg.type = ConfigurationType::Executable;
std::array<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = { "main" };
cfg.GetInterfacesAndImplementations(ifaces, impls);
std::unordered_map<fs::path, std::shared_future<BuildResult>> depResults;
std::mutex depMutex;
BuildResult r = Build(cfg, depResults, depMutex);
if (!r.result.empty()) {
std::println(std::cerr, "build failed: {}", r.result);
return 1;
}
const fs::path wasm = ExpectedOutput(cfg);
if (!fs::exists(wasm)) {
std::println(std::cerr, "no .wasm produced at {}", wasm.string());
return 1;
}
const auto run = RunCommandWithTimeout(std::format("wasmtime {}", wasm.string()), std::chrono::seconds(60));
if (run.exitCode != 0 || !run.output.contains("hello from wasm")) {
std::println(std::cerr, "wasmtime run failed (exit {}): {}", run.exitCode, run.output);
return 1;
}
return 0;
}