diff --git a/.forgejo/workflows/ci.yaml b/.forgejo/workflows/ci.yaml index 6d9d973..2feeb1d 100644 --- a/.forgejo/workflows/ci.yaml +++ b/.forgejo/workflows/ci.yaml @@ -51,8 +51,9 @@ jobs: persist-credentials: true - name: Cache glslang clone+build - # Cache covers crafter-build's external dep cache. The first run builds - # glslang; the cache makes subsequent runs reuse that build. + # Cache covers crafter-build's per-(target, march) external dep cache. + # First run builds glslang up to 6 times (3 marches × 2 targets); the + # cache makes subsequent runs reuse those builds. uses: actions/cache@v4 with: path: | @@ -88,14 +89,27 @@ jobs: 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 + # - name: Build Windows x86-64-v2 (mingw) + # 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 + + # - name: Build Windows x86-64-v3 (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-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: | set -eux mkdir -p dist - # 3 archives, one per march level. Each carries the executable, the - # static library and share/; libc++ comes from the host. + # 6 archives — 3 marches × 2 OSes. Each is self-contained: the + # Linux launcher is statically linked; the Windows launcher has + # 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. @@ -110,12 +124,18 @@ jobs: 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 small, individually-named + # 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' @@ -129,6 +149,18 @@ jobs: 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 if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/master' @@ -140,7 +172,7 @@ jobs: - name: Publish rolling 'latest' release # forgejo-release uploads each file in release-dir as a separate - # release asset, so the Releases page shows individually + # 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 diff --git a/AGENTS.md b/AGENTS.md index 33f5018..f0eff75 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,12 +8,12 @@ crafter-build is a C++26 modules build system. A project is described by a `proj | Path | What lives there | |---|---| -| `interfaces/Crafter.Build-.cppm` | Module interface partitions. `Crafter.Build.cppm` is the umbrella that re-exports them. | +| `interfaces/Crafter.Build-.cppm` | Module interface partitions. `Crafter.Build.cppm` is the umbrella that re-exports them. `Crafter.Build-Api.h` defines `CRAFTER_API`. | | `implementations/Crafter.Build-.cpp` | Implementation units, one per partition, named `Crafter.Build:_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. | | `tests//main.cpp` | One self-test per directory; `tests//fixture/` holds the sample project it builds. | | `examples/` | Standalone user-facing projects, MIT licensed. | -| `build.sh` | Bootstrap script. It compiles everything by hand, without crafter-build. | +| `build.sh`, `build.cmd` | Bootstrap scripts for Linux and Windows. They compile everything by hand, without crafter-build. | | `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. | | `share/crafter-build/`, `bin/`, `build/`, `lib/*.a` | Generated and gitignored. | @@ -42,10 +42,12 @@ Three things to know about this loop: - `bin/crafter-build` is the bootstrap binary and the harness for every command below. The self-host build writes to `bin/crafter.build-exe-/crafter-build` and does not replace it. - Changes under `implementations/` are picked up by the self-host build and by the tests, which link the local library. Changes under `interfaces/` are not: the harness compiles against the copies in `share/crafter-build/` and the PCM cache built from them, so a new or changed declaration is invisible until you run `./build.sh` again. The symptom is `use of undeclared identifier` for the symbol you just added. -- 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. +- 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. `./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 ```bash @@ -102,9 +104,10 @@ Suppress one finding with `// lint-disable-next-line ` or a whole file wit Beyond the rules: -- `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;`. +- `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;`. +- 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. -- 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. +- 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. - 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. - 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. @@ -116,7 +119,7 @@ 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. 2. **Per-project artifacts go in `/build` and `/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. -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. +4. **Linux is a monolithic executable with `--export-dynamic`; Windows is a DLL plus a launcher.** The asymmetry is intentional. 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. 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. @@ -151,3 +154,4 @@ These were decided deliberately. Do not undo them without the maintainer's say-s - 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. - `VariantId()` names the output directory, so CI globs `bin/crafter.build-exe---*/` 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 `&&`. diff --git a/PKGBUILD b/PKGBUILD index 38299d1..d7372ac 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -29,6 +29,7 @@ package() { 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/" diff --git a/README.md b/README.md index a5ebf0a..301f355 100644 --- a/README.md +++ b/README.md @@ -154,15 +154,18 @@ Outcomes: ✅ pass, ❌ fail, 💥 crash (with signal name), ⏱ timeout, ⏭ sk ```cpp TestRunner::Local() // run on this machine (default) TestRunner::Cmd("wine") // local exec via prefix command +TestRunner::Ssh("hostname") // remote linux via scp + ssh +TestRunner::SshWin("winhost") // remote windows via scp + ssh-cmd.exe +TestRunner::Wsl() // Linux binaries via WSL2 (Windows host) ``` -`Cmd(cmd)` is a generic ` ` prefix runner — covers wine, qemu-user, valgrind, gdbserver, anything with that shape. Probes via `which `. +`Cmd(cmd)` is a generic ` ` prefix runner — covers wine, qemu-user, valgrind, gdbserver, anything with that shape. Probes via `which ` (or `where` on Windows hosts). The `Ssh` variants `scp -r` the test's whole bin dir, run the binary remotely, then clean up; each invocation uses a unique remote subdir so parallel runs don't collide. `Wsl()` mirrors the SSH model but copies the bundle into WSL's native filesystem (faster than executing in-place from `/mnt/c`) — useful when a Windows host wants to exercise Linux test binaries. For elaborate setups (rsync, ssh ControlMaster, env vars on the spawn, `QEMU_LD_PREFIX` for sysroots), construct `TestRunner` directly — `copy`/`exec`/`cleanup`/`probe` are public string fields with `{bundle}`, `{remote_bundle}`, `{bin}`, `{bin_name}`, `{args}` placeholders. -For cross-arch builds, set `cfg.sysroot` on the `Configuration` to point at a sysroot for the target. `BuildStdPcm` and the compile/link commands both consume it: `--sysroot=` is passed to clang, and `/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 --config --dbpath /var/lib/pacman -Sy …` (point pacman at ALARM's mirrors via explicit config to avoid pulling host-arch packages). +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=` is passed to clang, and `/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 --config --dbpath /var/lib/pacman -Sy …` (point pacman at ALARM's mirrors via explicit config to avoid pulling host-arch packages). See `tests/CrossArchAarch64/inner/` for a working sysroot+qemu example. -For `--target=x86_64-w64-mingw32` (Windows from Linux), 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. +For `--target=x86_64-w64-mingw32` (Windows from Linux), V2's pipeline cross-compiles as long as `mingw-w64` is installed. Pair it with `--runner=cmd:wine` (no VM needed) or `--runner=sshwin:winhost` (real Windows). Full MSVC ABI (`x86_64-pc-windows-msvc`) cross-compile from Linux is not yet wired; use MinGW for now. `-fuse-ld=lld` is auto-injected on every link, and `-lstdc++exp` is auto-linked for the mingw target so user projects don't need to declare those flags. ### Configuring runners @@ -189,9 +192,12 @@ Spec grammar: ``` local cmd: # cmd:wine, cmd:qemu-aarch64, cmd:valgrind, ... +ssh:[:] +sshwin:[:] # cmd.exe-shell variant for Windows remotes +wsl[:] # Windows host -> Linux binaries via WSL2 ``` -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). +Each runner carries a `probe` command (e.g. `which wine`, `ssh -BatchMode=yes true`); if it fails, every test using that runner is reported as ⏭ skipped and the rest of the suite continues. Skipped tests count toward the summary but don't fail the suite (`AllPassed()` returns true on skip-only runs). ### Linking the parent project's library diff --git a/build.cmd b/build.cmd new file mode 100644 index 0000000..a2f2fc5 --- /dev/null +++ b/build.cmd @@ -0,0 +1,137 @@ +@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" diff --git a/build.sh b/build.sh index f0c3083..5ff6fb8 100755 --- a/build.sh +++ b/build.sh @@ -21,6 +21,7 @@ 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 diff --git a/implementations/Crafter.Build-Clang.cpp b/implementations/Crafter.Build-Clang.cpp index 6aa9307..8e34387 100644 --- a/implementations/Crafter.Build-Clang.cpp +++ b/implementations/Crafter.Build-Clang.cpp @@ -2,9 +2,17 @@ // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® module; +#if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32) +#include +#include +// (pulled in transitively) defines `interface` as a macro for `struct`, +// which collides with local variables named `interface` in this TU. +#undef interface +#else #include #include #include +#endif export module Crafter.Build:Clang_impl; import std; import :Clang; @@ -259,19 +267,17 @@ fs::path Crafter::StdPcmDir(const Configuration& config) { fs::path Crafter::ExpectedOutput(const Configuration& config) { const fs::path dir = config.BinDir(); - const bool mingwTarget = config.target == "x86_64-w64-mingw32"; + const bool windowsTarget = config.target == "x86_64-w64-mingw32" || config.target == "x86_64-pc-windows-msvc"; if (config.type == ConfigurationType::Executable) { if (config.target.starts_with("wasm32")) return dir / std::format("{}.wasm", config.outputName); - return mingwTarget ? dir / std::format("{}.exe", config.outputName) : dir / config.outputName; + return config.target == "x86_64-w64-mingw32" ? dir / std::format("{}.exe", config.outputName) : dir / config.outputName; } if (config.type == ConfigurationType::LibraryStatic) { - // The archive step writes lib.a for every target, mingw included: - // llvm-ar does not care about the object format. - return dir / std::format("lib{}.a", config.outputName); + return windowsTarget ? dir / std::format("{}.lib", config.outputName) : dir / std::format("lib{}.a", config.outputName); } - // LibraryDynamic — the .dll on the mingw target so an mtime check sees the + // LibraryDynamic — the .dll on Windows targets so an mtime check sees the // freshly linked library; on Unix the .so is that file. - return mingwTarget ? dir / std::format("{}.dll", config.outputName) : dir / std::format("lib{}.so", config.outputName); + return windowsTarget ? dir / std::format("{}.dll", config.outputName) : dir / std::format("lib{}.so", config.outputName); } CompileCommand Crafter::GetCompileCommand(const Configuration& config) { @@ -316,9 +322,22 @@ CompileCommand Crafter::GetCompileCommand(const Configuration& config) { } if(config.type == ConfigurationType::LibraryDynamic) { + #ifdef __linux__ 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) { 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 { out.command += " -D CRAFTER_BUILD_CONFIGURATION_TYPE_LIBRARY"; } @@ -445,12 +464,15 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map libSet; @@ -1102,6 +1125,7 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map copyDepDlls = [&](Configuration* dep) { if (!dllSeen.insert(dep).second) return; if (dep->type == ConfigurationType::LibraryDynamic && dep->target == "x86_64-w64-mingw32") { - // Only the DLL: Windows resolves it from the exe's - // directory at load time. The import lib stays in - // the dependency's own bin dir, which is already - // on the link line through buildResult.libs. - fs::path src = dep->BinDir() / std::format("{}.dll", dep->outputName); - if (fs::exists(src)) { + fs::path depDir = dep->BinDir(); + // The DLL itself (Windows resolves it from the + // exe's directory at load time) and the mingw + // import lib (so a downstream `crafter-build.exe` + // can link a fresh project.dll against it without + // hunting through sibling output dirs). + 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); @@ -1136,22 +1163,65 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map dllSeen; + std::function 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) { + #ifdef __linux__ // ThinLTO emits LLVM bitcode objects; plain `ar` writes an archive // index that omits their symbols, so a consumer's lld link can't - // pull the needed members. llvm-ar writes a bitcode-aware index - // and handles plain ELF and COFF objects just as well, so every - // build uses it. - buildResult.result = RunBuildCommand(std::format("llvm-ar rcs {}.a {}", (outputDir/fs::path(std::string("lib")+config.outputName)).string(), files)); + // 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)); + #endif + + #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 { // LibraryDynamic. Output names follow each target's convention so // consumers can link via the standard linker search: // mingw: .dll + lib.dll.a (lld --out-implib) + // msvc: .dll + .lib (lld /IMPLIB) // unix: lib.so (rpath $ORIGIN) if (config.target == "x86_64-w64-mingw32") { fs::path dll = outputDir / std::format("{}.dll", 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)); + } 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 { buildResult.result = RunBuildCommand(std::format("{}{} -shared -o {}.so -Wl,-rpath,'$ORIGIN' -fuse-ld=lld{}", command, files, (outputDir/(std::string("lib")+config.outputName)).string(), linkExtras)); } @@ -1474,9 +1544,11 @@ ArgQuery Crafter::ApplyStandardArgs(Configuration& cfg, std::span NUL 2>&1", exe); +#else std::string probe = std::format("command -v {} > /dev/null 2>&1", exe); +#endif return std::system(probe.c_str()) == 0; }; if (browserBuild) { @@ -1724,9 +1801,17 @@ std::int32_t Crafter::Run(std::int32_t argc, char** argv) { // user has another dev server running we shift up rather // than letting caddy/python exit with EADDRINUSE. 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; const Sock invalid = -1; auto closesock = [](Sock s){ ::close(s); }; +#endif for (std::int32_t p = basePort; p < basePort + span; ++p) { Sock s = ::socket(AF_INET, SOCK_STREAM, 0); if (s == invalid) return basePort; @@ -1849,7 +1934,11 @@ std::int32_t Crafter::Run(std::int32_t argc, char** argv) { // Silence the backend's own banner/request logs so the // experience is identical regardless of which server is // picked — the line above is the only output. +#ifdef _WIN32 + cmd += " > NUL 2>&1"; +#else cmd += " > /dev/null 2>&1"; +#endif return std::system(cmd.c_str()) == 0 ? 0 : 1; } // wasi-cli wasm — needs a standalone runtime. @@ -1863,6 +1952,10 @@ std::int32_t Crafter::Run(std::int32_t argc, char** argv) { 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 // (shaders, assets copied alongside the exe via cfg.files) resolve // against the bin dir rather than the user's cwd. We exit the diff --git a/implementations/Crafter.Build-External.cpp b/implementations/Crafter.Build-External.cpp index c758a67..ac97844 100644 --- a/implementations/Crafter.Build-External.cpp +++ b/implementations/Crafter.Build-External.cpp @@ -21,6 +21,18 @@ std::string DeriveName(const GitSource& source) { } 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 = "'"; for (char c : s) { if (c == '\'') out += "'\\''"; @@ -28,6 +40,7 @@ std::string ShellQuote(std::string_view s) { } out += "'"; return out; +#endif } std::string JoinOptions(std::span options) { @@ -120,14 +133,17 @@ std::string BuildInjectedCMakeFlags(const fs::path& cmakeBuildDir, std::string_v out += " -DCMAKE_BUILD_TYPE=Release"; out += " -DCMAKE_C_COMPILER=clang"; out += " -DCMAKE_CXX_COMPILER=clang++"; - // Stdlib choice: libc++ for Linux targets (matches how crafter-build - // itself is built with -stdlib=libc++); the mingw cross-compile uses the - // libstdc++ shipped by the mingw-w64 toolchain, so it keeps the default. + // Stdlib choice: libc++ for the Linux host build (matches how crafter-build + // itself is built with -stdlib=libc++); mingw cross-compile uses libstdc++ + // shipped by the mingw-w64 toolchain so we leave the default; Windows + // native (msvc target) similarly uses libc++ via LIBCXX_DIR. + #ifdef __linux__ if (target.ends_with("-linux-gnu") || target.ends_with("-linux-musl")) { out += " -DCMAKE_CXX_FLAGS=-stdlib=libc++"; out += " -DCMAKE_EXE_LINKER_FLAGS=-stdlib=libc++"; out += " -DCMAKE_SHARED_LINKER_FLAGS=-stdlib=libc++"; } + #endif if (target == "x86_64-w64-mingw32") { out += " -DCMAKE_SYSTEM_NAME=Windows"; out += std::format(" -DCMAKE_C_COMPILER_TARGET={}", target); @@ -261,9 +277,9 @@ ExternalBuildResult Crafter::BuildExternal(const ExternalDependency& dep, std::s // (runtime-loader). The rpath stays embedded in the produced // binary so it picks up shared deps from the cache without any // LD_LIBRARY_PATH gymnastics. Static deps (.a) ignore the rpath - // harmlessly. The PE/COFF target (mingw) resolves DLLs via PATH or - // adjacency rather than rpath, so lld warns if we pass it. - const bool isPe = target == "x86_64-w64-mingw32"; + // harmlessly. PE/COFF targets (mingw, msvc) resolve DLLs via PATH + // or 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"; std::string buildDirAbs = fs::absolute(cmakeBuildDir).string(); result.linkFlags.push_back(std::format("-L{}", buildDirAbs)); if (!isPe) result.linkFlags.push_back(std::format("-Wl,-rpath,{}", buildDirAbs)); diff --git a/implementations/Crafter.Build-Lint.cpp b/implementations/Crafter.Build-Lint.cpp index a2c5308..72cf5f3 100644 --- a/implementations/Crafter.Build-Lint.cpp +++ b/implementations/Crafter.Build-Lint.cpp @@ -3,7 +3,11 @@ module; #include +#if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32) +#include +#else #include +#endif export module Crafter.Build:Lint_impl; import std; import :Lint; @@ -16,13 +20,20 @@ using namespace Crafter; namespace { // ---------------- libclang ---------------- // - // libclang is loaded at runtime rather than linked, so a plain build never - // needs it and the binary carries no libclang.so.NN loader dependency; the - // clang-c header is used for its declarations only, and every call goes - // through a pointer resolved here. Failure to load is + // libclang is loaded at runtime rather than linked. Linking -lclang would + // break the mingw and MSVC cross-builds at link time and would put a + // libclang.so.NN runtime dependency into the otherwise self-contained + // release tarballs; the clang-c header is used for its declarations only, + // 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, // weaker lexer to fall back to, because two engines disagreeing about what // 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(GetProcAddress(handle, name.c_str())); } + constexpr std::string_view LibClangNames[] = {"libclang.dll", "clang.dll"}; +#else using LibHandle = void*; 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()); } @@ -30,6 +41,7 @@ namespace { "libclang.so", "libclang.so.22.1", "libclang.so.21.1", "libclang.so.20.1", "libclang.so.1", "libclang.dylib", }; +#endif // Signatures come from decltype on the header's declarations, so they can // never drift from the real API. decltype is unevaluated, so naming the @@ -92,8 +104,8 @@ namespace { LibClang LoadLibClang() { LibClang lib; std::vector tried; - // CRAFTER_BUILD_LIBCLANG pins an exact path, mirroring the - // CRAFTER_MINGW_DIR override used elsewhere. It is exclusive: pointing + // CRAFTER_BUILD_LIBCLANG pins an exact path, mirroring the LIBCXX_DIR / + // CRAFTER_MINGW_DIR overrides used elsewhere. It is exclusive: pointing // it at a broken path must fail loudly rather than quietly succeed with // some other libclang, or the override is useless for diagnosing which // library is actually in play. @@ -365,8 +377,8 @@ namespace { } if (!args.empty()) args.erase(args.begin()); // argv[0] is the compiler for (std::string& arg : args) { - // Only \" unescapes; a backslash anywhere else is part of the - // argument. + // Only \" unescapes. Erasing every backslash would destroy the + // Windows include paths GetBaseCommand puts on the command line. std::string unescaped; unescaped.reserve(arg.size()); for (std::size_t i = 0; i < arg.size(); ++i) { diff --git a/implementations/Crafter.Build-Platform.cpp b/implementations/Crafter.Build-Platform.cpp index 12f98a6..5ffaa17 100644 --- a/implementations/Crafter.Build-Platform.cpp +++ b/implementations/Crafter.Build-Platform.cpp @@ -2,15 +2,20 @@ // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® module; +#if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32) +#include +#endif #include #include #include +#ifdef __linux__ #include #include #include #include #include #include +#endif module Crafter.Build:Platform_impl; import std; import :Platform; @@ -38,6 +43,7 @@ namespace { // descriptor is close-on-exec / non-inheritable so spawned clang processes // don't keep the lock alive past our own release. class CacheLock { +#ifdef __linux__ std::int32_t fd_ = -1; public: explicit CacheLock(const fs::path& cacheDir) { @@ -51,6 +57,22 @@ namespace { ::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& operator=(const CacheLock&) = delete; }; @@ -82,6 +104,9 @@ namespace { for (std::string_view name : moduleNames) { 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{}(all)); } @@ -105,7 +130,16 @@ fs::path Crafter::GetCrafterBuildHome() { if (const char* envHome = std::getenv("CRAFTER_BUILD_HOME")) { 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"); +#endif // Walk up from the exe's directory looking for share/crafter-build with // a known module source present as a sentinel. Handles: @@ -526,6 +560,499 @@ bool Crafter::IsTopLevelProject() { return LoadingFrames.size() <= 1; } +#if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32) +CommandResult Crafter::RunCommandChecked(std::string_view cmd) { + std::array buffer; + CommandResult result{}; + + 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(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 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(std::min(static_cast(timeout.count()) * 1000LL, static_cast(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(exit); + } + result.exitCode = static_cast(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 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 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); + auto fn = reinterpret_cast(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 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 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 + // -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); + auto fn = reinterpret_cast(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) { std::array buffer; CommandResult result{}; @@ -664,7 +1191,7 @@ std::string Crafter::BuildStdPcm(const Configuration& config, fs::path stdPcm) { // non-wasm cross builds: the driver ranks the host toolchain's own // libc++ headers (/../include/c++/v1) above --sysroot, so // the PCM would mix host libc++ with the foreign libc — force the - // sysroot's copy. + // sysroot's copy (same trick as the Windows LIBCXX_DIR path) if (!isWasm && !config.sysroot.empty()) { archFlags += std::format(" -nostdinc++ -isystem {}/usr/include/c++/v1", config.sysroot); } @@ -821,3 +1348,5 @@ Configuration Crafter::LoadProject(const fs::path& projectFile, std::span #include -#include -#include +#if defined(_WIN32) + #include + #include + #ifndef STDOUT_FILENO + #define STDOUT_FILENO _fileno(stdout) + #endif + #define CRAFTER_PROGRESS_ISATTY _isatty +#else + #include + #include + #define CRAFTER_PROGRESS_ISATTY isatty +#endif export module Crafter.Build:Progress_impl; import std; @@ -22,10 +32,19 @@ namespace { std::chrono::steady_clock::time_point StartTime = std::chrono::steady_clock::now(); 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{}; if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0 && ws.ws_col > 0) { return ws.ws_col; } + #endif if (const char* col = std::getenv("COLUMNS")) { try { std::int32_t c = std::stoi(col); if (c > 0) return c; } catch (...) {} } @@ -67,7 +86,7 @@ namespace Crafter::Progress { void SetVerbosity(Verbosity v) { std::lock_guard lock(StateMutex); ActiveVerbosity = v; - IsTty = isatty(STDOUT_FILENO) != 0; + IsTty = CRAFTER_PROGRESS_ISATTY(STDOUT_FILENO) != 0; StartTime = std::chrono::steady_clock::now(); Total.store(0); Done.store(0); diff --git a/implementations/Crafter.Build-Test.cpp b/implementations/Crafter.Build-Test.cpp index e18b148..a6e3ec8 100644 --- a/implementations/Crafter.Build-Test.cpp +++ b/implementations/Crafter.Build-Test.cpp @@ -35,11 +35,38 @@ namespace { 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 args) { std::string out; for (const auto& a : args) { if (!out.empty()) out.push_back(' '); - out += ShellQuoteSh(a); + out += ShellQuoteHost(a); } return out; } @@ -164,7 +191,11 @@ TestRunner TestRunner::Cmd(std::string command) { TestRunner r; r.name = std::format("cmd:{}", command); r.exec = std::format("{} {{bin}} {{args}}", command); +#ifdef _WIN32 + r.probe = std::format("where {}", command); +#else r.probe = std::format("which {}", command); +#endif return r; } @@ -172,7 +203,11 @@ TestRunner TestRunner::Wine() { TestRunner r; r.name = "wine"; r.exec = "wine {bin} {args}"; +#ifdef _WIN32 + r.probe = "where wine"; +#else r.probe = "which wine"; +#endif return r; } @@ -183,8 +218,11 @@ TestRunner TestRunner::ForTarget(const Configuration& cfg) { // case (cfg.target defaulted to HostTarget()) without any wrapper. if (target == HostTarget()) return Local(); - // Windows targets run under Wine; there is no Windows host. - if (TargetIsWindows(target)) return Wine(); + // Windows targets: native on a Windows host, Wine on Linux. We don't + // distinguish mingw vs msvc here — the produced .exe runs the same way. + if (TargetIsWindows(target)) { + return TargetIsWindows(HostTarget()) ? Local() : Wine(); + } // WASI: a .wasm file isn't directly executable; wasmtime is the canonical // runtime. wasi-cli also works but the upstream Bytecode Alliance name is @@ -273,7 +311,7 @@ TestResult Crafter::RunSingleTest(const Test& test, const fs::path& binary, std: if (test.runner.exec.empty()) { // Local runner: spawn the binary directly through the host shell. - std::string cmd = std::format("{} {}", ShellQuoteSh(binary.string()), ph["{args}"]); + std::string cmd = std::format("{} {}", ShellQuoteHost(binary.string()), ph["{args}"]); r = RunCommandWithTimeout(cmd, timeout); } else { // Prefix runner (qemu-user, wasmtime, wine, ...): templated exec @@ -299,7 +337,11 @@ TestResult Crafter::RunSingleTest(const Test& test, const fs::path& binary, std: namespace { 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); +#endif return std::system(cmd.c_str()) == 0; } diff --git a/implementations/main.cpp b/implementations/main.cpp index d703cca..346f6b6 100644 --- a/implementations/main.cpp +++ b/implementations/main.cpp @@ -2,7 +2,16 @@ // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® #include -#include +#if defined(_WIN32) + #include + #ifndef STDOUT_FILENO + #define STDOUT_FILENO _fileno(stdout) + #endif + #define CRAFTER_MAIN_ISATTY _isatty +#else + #include + #define CRAFTER_MAIN_ISATTY isatty +#endif import std; import Crafter.Build; @@ -17,7 +26,7 @@ int main(int argc, char** argv) { // 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 // only adjust when redirected. - if (isatty(STDOUT_FILENO) == 0) { + if (CRAFTER_MAIN_ISATTY(STDOUT_FILENO) == 0) { std::setvbuf(stdout, nullptr, _IOLBF, 0); } return Crafter::Run(argc, argv); diff --git a/interfaces/Crafter.Build-Api.h b/interfaces/Crafter.Build-Api.h new file mode 100644 index 0000000..334a891 --- /dev/null +++ b/interfaces/Crafter.Build-Api.h @@ -0,0 +1,14 @@ +// 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 diff --git a/interfaces/Crafter.Build-Asset.cppm b/interfaces/Crafter.Build-Asset.cppm index 7025687..33e2bbd 100644 --- a/interfaces/Crafter.Build-Asset.cppm +++ b/interfaces/Crafter.Build-Asset.cppm @@ -1,6 +1,8 @@ // SPDX-License-Identifier: LGPL-3.0-only // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +module; +#include "Crafter.Build-Api.h" export module Crafter.Build:Asset; import std; namespace fs = std::filesystem; @@ -17,7 +19,7 @@ namespace Crafter { // that errors out at runtime if cfg.assets is non-empty — full // support requires a self-host rebuild via crafter-build itself, // which resolves the Crafter.Asset dep through project.cpp. - export std::string CompressAsset(const fs::path& input, const fs::path& output); + export CRAFTER_API std::string CompressAsset(const fs::path& input, const fs::path& output); // Split a single .obj + .mtl file by material group into per-material // .cmesh files, per-unique-albedo .ctex files, and a scene.txt manifest @@ -34,7 +36,7 @@ namespace Crafter { // Idempotent: returns "" immediately if outDir/scene.txt already exists, // so repeated builds skip the expensive split+resize step. // Returns "" on success, an error string on failure. - export std::string BuildOBJBundle( + export CRAFTER_API std::string BuildOBJBundle( const fs::path& objPath, const fs::path& mtlPath, const fs::path& outDir, diff --git a/interfaces/Crafter.Build-Clang.cppm b/interfaces/Crafter.Build-Clang.cppm index 2bb1b73..76b5117 100644 --- a/interfaces/Crafter.Build-Clang.cppm +++ b/interfaces/Crafter.Build-Clang.cppm @@ -1,6 +1,8 @@ // SPDX-License-Identifier: LGPL-3.0-only // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +module; +#include "Crafter.Build-Api.h" export module Crafter.Build:Clang; import std; import :Shader; @@ -63,28 +65,30 @@ export namespace Crafter { bool IsLocal() const { return exec.empty(); } - static TestRunner Local(); + static CRAFTER_API TestRunner Local(); // Prefix runner: wraps the local binary in ` {bin} {args}`. // Used for qemu-user, wasmtime, and similar single-binary wrappers. - static TestRunner Cmd(std::string command); - // Run a Windows .exe through Wine. Probes `wine` on PATH. - static TestRunner Wine(); + static CRAFTER_API TestRunner Cmd(std::string command); + // Run a Windows .exe through Wine. Probes `wine` on PATH; on a Windows + // host the wine wrapper is pointless, so callers should route to Local + // before reaching here. + static CRAFTER_API TestRunner Wine(); // Parse a `[:]` spec used by CRAFTER_BUILD_RUNNER_ // and --runner=. Supported: "local", "cmd:". Returns nullopt // for an empty string; throws on a non-empty unrecognized spec. - static std::optional FromSpec(std::string_view spec); + static CRAFTER_API std::optional FromSpec(std::string_view spec); // Honor CRAFTER_BUILD_RUNNER_ (power-user override). Triple // dashes/dots become underscores so they're valid in env-var names. // Returns `fallback` when the env var is unset. - static TestRunner FromEnv(std::string_view target, TestRunner fallback = Local()); + static CRAFTER_API TestRunner FromEnv(std::string_view target, TestRunner fallback = Local()); // Derive a runner from a Configuration's target triple + sysroot. // Returns Local() when target equals the host, Wine() for Windows - // targets, `qemu-` (with QEMU_LD_PREFIX + // targets on a non-Windows host, `qemu-` (with QEMU_LD_PREFIX // set when cfg.sysroot is non-empty) for non-host -linux- triples, // `wasmtime` for wasm32-wasi/wasm64-wasi, and Local() as a last // resort. CRAFTER_BUILD_RUNNER_ still wins as an override // upstream of this — see FromEnv. - static TestRunner ForTarget(const struct Configuration& cfg); + static CRAFTER_API TestRunner ForTarget(const struct Configuration& cfg); }; enum class TestOutcome { Pass, Fail, Crash, Timeout, Skipped }; @@ -229,18 +233,17 @@ export namespace Crafter { }; // Per-file view handed to each LintRule's check callback. Every member - // function is out-of-line (defined in Crafter.Build:Lint's implementation - // unit) because rule lambdas execute from the user's project.so and - // resolve against the running executable: clang does not emit - // module-attached in-class inline bodies into consumers (see ArgQuery - // below). + // function is out-of-line and CRAFTER_API (defined in Crafter.Build:Lint's + // implementation unit) because rule lambdas execute from the user's + // project DLL on Windows — clang does not emit module-attached in-class + // inline bodies into consumers (see ArgQuery below). struct LintContext { fs::path file; // absolute path of the file under lint std::string content; // whole file as read from disk std::vector lines; // views into `content`, one per line, no '\n' - std::string Extension() const; // ".cppm", ".cpp", ".h", ... - std::string_view Line(std::size_t n) const; // 1-based; empty if out of range + CRAFTER_API 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 // `content` with comments and string/char literal bodies blanked to // spaces, newlines preserved — offsets and line numbers stay valid. // Built from Tokens() on first call, cached per file, so raw strings, @@ -251,9 +254,9 @@ export namespace Crafter { // Convenient for a quick scan, but Tokens() is the better tool for // anything structural: this view cannot tell an identifier from a // keyword, and it has already thrown away where the literals were. - const std::string& CommentStripped(); + CRAFTER_API const std::string& CommentStripped(); // Record a finding at `line` (1-based; pass 0 for a whole-file finding). - void Report(std::size_t line, std::string message); + CRAFTER_API void Report(std::size_t line, std::string message); // Replace the file's content. Makes this rule a *transform*: // `crafter-build format` writes the result back to disk; `lint` // derives would-reformat findings from it (dry — never writes). @@ -262,7 +265,7 @@ export namespace Crafter { // pattern: build the new string, call SetContent once at the end. Do // not assign `content` directly — that bypasses the re-split. May be // combined with Report() in the same rule. - void SetContent(std::string newContent); + CRAFTER_API void SetContent(std::string newContent); // True when `rule` is suppressed at `line` (1-based; 0 = whole-file, // which only file-level directives cover) by a suppression comment: // // lint-disable-next-line [rules...] applies to the following line @@ -274,7 +277,7 @@ export namespace Crafter { // itself for every line its edit touches (the driver cannot map lines // across a count-changing rewrite). Parsed lazily from the raw lines; // re-parsed after SetContent. - bool Suppressed(std::string_view rule, std::size_t line); + CRAFTER_API bool Suppressed(std::string_view rule, std::size_t line); // The file lexed by clang, in source order. Built on first call, cached // per file, re-lexed after SetContent. Empty for extensions that are @@ -284,16 +287,16 @@ export namespace Crafter { // raw strings, line splices, digraphs and nested quoting right, and // offsets index straight into `content`, so a transform can find in // the token stream and edit in place. - std::span Tokens(); + CRAFTER_API std::span Tokens(); // The token's own bytes: content.substr(tok.offset, tok.length). - std::string_view TokenText(const LintToken& token) const; + CRAFTER_API std::string_view TokenText(const LintToken& token) const; // 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 // not included; ask Tokens() directly when that matters. - std::span TokensOnLine(std::size_t line); + CRAFTER_API std::span TokensOnLine(std::size_t line); // True when a comment token starts on `line`. Replaces `Line(n).contains("//")`, // which false-positives on a `//` inside a string literal. - bool LineHasComment(std::size_t line); + CRAFTER_API bool LineHasComment(std::size_t line); // 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 // joins, splits or rewrites such a line changes what is inside that @@ -301,7 +304,7 @@ export namespace Crafter { // Replaces `Line(n).contains("R\"")`, which both misses raw strings // opened on an earlier line and fires on the characters R" appearing // inside an ordinary literal. - bool LineHasMultiLineToken(std::size_t line); + CRAFTER_API bool LineHasMultiLineToken(std::size_t line); // Declarations written in this file, flattened depth-first with // parents before children (see LintDecl). Empty when the AST is not @@ -318,11 +321,11 @@ export namespace Crafter { // slice. Declarations inside a preprocessor branch that is inactive // for the host are absent here, though Tokens() still sees them. Rules // that must cover every platform belong on tokens. - std::span Decls(); - bool AstAvailable(); + CRAFTER_API std::span Decls(); + CRAFTER_API bool AstAvailable(); // Why AstAvailable() is false — a missing PCM, a parse error, a file // that is not a translation unit. Empty when the AST is available. - std::string_view AstUnavailableReason(); + CRAFTER_API std::string_view AstUnavailableReason(); // Driver wiring — set by RunLint before each check call. Not for rules. std::string activeRule; @@ -360,7 +363,7 @@ export namespace Crafter { // 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 // print a triple — those projects still need an explicit cfg.target. - std::string HostTarget(); + CRAFTER_API std::string HostTarget(); struct Configuration { fs::path path; @@ -436,7 +439,7 @@ export namespace Crafter { std::vector tests; // Lint rules for `crafter-build lint`. Populate via AddLintRule. std::vector lintRules; - void GetInterfacesAndImplementations(std::span interfaces, std::span implementations); + CRAFTER_API void GetInterfacesAndImplementations(std::span interfaces, std::span implementations); // Retry the `import X;` names GetInterfacesAndImplementations could not // place, against the dependency DAG as it stands now. Sources are // scanned when they're declared, but `dependencies` is often assigned @@ -445,30 +448,30 @@ export namespace Crafter { // change and this Configuration's objects would be silently reused // against the new layout. Build() calls this before checking mtimes; // calling it again is harmless. - void ResolvePendingImports(); + CRAFTER_API void ResolvePendingImports(); // Declare a test. Sources default to `tests//main.cpp` resolved // against this Configuration's path; target/march/mtune/sysroot/debug // are inherited from this Configuration so cross-arch projects don't // have to re-specify. Returns a builder for chaining defines, deps, // etc. Defined in Crafter.Build:Test. - struct TestBuilder AddTest(std::string_view name); + CRAFTER_API struct TestBuilder AddTest(std::string_view name); // Same as AddTest, but compiles the parent's `interfaces` directly // into this test's Configuration (rather than going through a dep). // Use when the test must rebuild those interfaces with its own // compile flags — typically per-march SIMD codegen. - struct TestBuilder AddTest(std::string_view name, std::span interfaces); + CRAFTER_API struct TestBuilder AddTest(std::string_view name, std::span interfaces); // Math-style fan-out: one Test per MarchTier, all sharing the same // `tests//main.cpp` source and the same interface set, each // compiled with the tier's `-march`/`-mtune`. Test names are // `-`. - void AddMarchVariants(std::string_view name, std::span interfaces, std::span tiers); + CRAFTER_API void AddMarchVariants(std::string_view name, std::span interfaces, std::span tiers); // Register a lint rule for `crafter-build lint` / `format`. Rules // registered on any Configuration whose path lies inside the project // root are collected (deduplicated by name, root-first) — attach them // to the lib or the exe config, either works. Rules that call // ctx.SetContent are transforms (see LintContext::SetContent). // Defined in Crafter.Build:Lint. - void AddLintRule(std::string name, std::function check); + CRAFTER_API void AddLintRule(std::string name, std::function check); // Same, for a rule that reads LintContext::Decls(). Declared // separately rather than as a flag on AddLintRule so existing // registrations keep compiling. Such a rule makes the run build the @@ -476,7 +479,7 @@ export namespace Crafter { // produced becomes an error instead of a silent pass. Prefer // report-only: a transform running after the first one forces a // re-parse of everything it changed. - void AddAstLintRule(std::string name, std::function check); + CRAFTER_API void AddAstLintRule(std::string name, std::function check); // Suffix that uniquely identifies this Configuration's compile state. // target+march+mtune are spelled out for readability; the rest // (type, debug, sysroot, defines, compileFlags) collapse into a short @@ -541,14 +544,14 @@ export namespace Crafter { // 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 // features, and suffixed with the wasm variant flags for the same reason. - fs::path StdPcmDir(const Configuration& config); + CRAFTER_API fs::path StdPcmDir(const Configuration& config); // Where Build leaves this Configuration's linked artifact: the executable // (with the target's suffix), `lib.a` / `.lib`, or the shared // 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 // disagree about the file name. - fs::path ExpectedOutput(const Configuration& config); + CRAFTER_API fs::path ExpectedOutput(const Configuration& config); // The clang invocation every C++ translation unit in this Configuration is // compiled with, before anything that depends on work having happened @@ -583,14 +586,14 @@ export namespace Crafter { fs::path stdPcmDir; fs::path pcmDir; }; - CompileCommand GetCompileCommand(const Configuration& config); + CRAFTER_API CompileCommand GetCompileCommand(const Configuration& config); - BuildResult Build(Configuration& config, std::unordered_map>& depResults, std::mutex& depMutex); + CRAFTER_API BuildResult Build(Configuration& config, std::unordered_map>& depResults, std::mutex& depMutex); // Takes main's argv verbatim so the CLI entry point is a one-liner; // the shape is inherited from the language, not chosen here. // lint-disable-next-line no-char-pointer - std::int32_t Run(std::int32_t argc, char** argv); + CRAFTER_API std::int32_t Run(std::int32_t argc, char** argv); // Delete the bin/ and build/ trees beside `projectFile`, returning the paths // that existed and were removed. Backs `crafter-build clean`. @@ -600,7 +603,7 @@ export namespace Crafter { // to compile (and its git dependencies to be present) is useless exactly // then. That means variant directories are not enumerated — the whole tree // goes, which is what the by-hand `rm -rf bin build` did anyway. - std::vector CleanProject(const fs::path& projectFile); + CRAFTER_API std::vector CleanProject(const fs::path& projectFile); // 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 @@ -608,7 +611,7 @@ export namespace Crafter { // this and shouldn't carry the extra files. Call from project.cpp after // outputName is set; index.html is generated against the current // outputName so renaming the binary later requires another call. - void EnableWasiBrowserRuntime(Configuration& cfg); + CRAFTER_API void EnableWasiBrowserRuntime(Configuration& cfg); // Register the relaxed-SIMD codegen variant on cfg. Builds an additional // outputName.relaxed-simd.wasm compiled with -mrelaxed-simd (FMA, @@ -619,17 +622,17 @@ export namespace Crafter { // 2026). Idempotent. Call before EnableWasiBrowserRuntime so the emitted // variants.json manifest includes it. A no-op for non-wasm targets at build // time (no wasm is produced), but harmless to declare unconditionally. - void EnableWasiRelaxedSimdVariant(Configuration& cfg); + CRAFTER_API void EnableWasiRelaxedSimdVariant(Configuration& cfg); // View over the project's args with simple query helpers. Has(flag) for // boolean switches; Get(prefix) for valued options (e.g. Get("--prefix=") - // returns the substring after the equals). Definitions are out-of-line so - // project.so resolves them from the executable: clang does not emit - // module-attached in-class inline bodies into consumers. + // returns the substring after the equals). Definitions are out-of-line + // and CRAFTER_API so they cross the Windows DLL boundary cleanly — clang + // does not emit module-attached in-class inline bodies into consumers. struct ArgQuery { std::span args; - bool Has(std::string_view flag) const; - std::optional Get(std::string_view prefix) const; + CRAFTER_API bool Has(std::string_view flag) const; + CRAFTER_API std::optional Get(std::string_view prefix) const; }; // Apply the framework's standard CLI args + env vars onto cfg: @@ -648,5 +651,5 @@ export namespace Crafter { // pre-set defaults. Returns an ArgQuery over the same span so projects // can query their own flags (`--timing`, ...) without re-rolling the // for-arg-in-args loop. - ArgQuery ApplyStandardArgs(Configuration& cfg, std::span args); + CRAFTER_API ArgQuery ApplyStandardArgs(Configuration& cfg, std::span args); } diff --git a/interfaces/Crafter.Build-External.cppm b/interfaces/Crafter.Build-External.cppm index fe8e229..df5b190 100644 --- a/interfaces/Crafter.Build-External.cppm +++ b/interfaces/Crafter.Build-External.cppm @@ -1,6 +1,8 @@ // SPDX-License-Identifier: LGPL-3.0-only // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +module; +#include "Crafter.Build-Api.h" export module Crafter.Build:External; import std; namespace fs = std::filesystem; @@ -46,13 +48,13 @@ export namespace Crafter { // declaration and target — BuildExternal derives its own working directory // through this, so a caller that only needs to know where the headers ended // up cannot drift from where they actually are. - fs::path ExternalCloneDir(const ExternalDependency& dep, std::string_view target); + CRAFTER_API fs::path ExternalCloneDir(const ExternalDependency& dep, std::string_view target); // 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 // 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. - std::vector ExternalIncludeFlags(const ExternalDependency& dep, std::string_view target); - ExternalBuildResult BuildExternal(const ExternalDependency& dep, std::string_view target, std::atomic& cancelled); + CRAFTER_API std::vector ExternalIncludeFlags(const ExternalDependency& dep, std::string_view target); + CRAFTER_API ExternalBuildResult BuildExternal(const ExternalDependency& dep, std::string_view target, std::atomic& cancelled); // Specification for a sibling crafter-build project to fetch and depend on. // GitSource picks the revision: leave branch + commit empty for the @@ -71,7 +73,7 @@ export namespace Crafter { // remote's project.cpp, and returns a stable Configuration* you can drop // into cfg.dependencies. The Configuration is owned by an internal cache // for the lifetime of the build; identical specs share one Configuration. - Configuration* GitProject(const GitProjectSpec& spec); + CRAFTER_API Configuration* GitProject(const GitProjectSpec& spec); // Specification for a local sibling crafter-build project (e.g. an // example folder depending on its parent project, or a workspace where @@ -85,7 +87,7 @@ export namespace Crafter { // cache copy. Resolves projectFile to its canonical absolute path and // recursively LoadProject's it. Returns a stable Configuration* owned // by the same internal cache as GitProject. - Configuration* LocalProject(const LocalProjectSpec& spec); + CRAFTER_API Configuration* LocalProject(const LocalProjectSpec& spec); // Clone (or update) the given git source into the shared external // cache and return the path to the cloned working tree. Intended for @@ -95,5 +97,5 @@ export namespace Crafter { // ExternalDependency / GitProject. Caching key = url + branch + commit; // identical calls reuse the existing clone. Pin `source.commit` to a // SHA for reproducible, offline-after-first-fetch builds. - fs::path GitFetch(const GitSource& source); + CRAFTER_API fs::path GitFetch(const GitSource& source); } diff --git a/interfaces/Crafter.Build-Implementation.cppm b/interfaces/Crafter.Build-Implementation.cppm index 7c9c631..95f816b 100644 --- a/interfaces/Crafter.Build-Implementation.cppm +++ b/interfaces/Crafter.Build-Implementation.cppm @@ -1,6 +1,8 @@ // SPDX-License-Identifier: LGPL-3.0-only // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +module; +#include "Crafter.Build-Api.h" export module Crafter.Build:Implementation; import std; namespace fs = std::filesystem; @@ -17,8 +19,8 @@ namespace Crafter { // no reachable Configuration provided when the source was scanned. std::vector pendingImports; fs::path path; - Implementation(fs::path&& path); - bool Check(const fs::path& buildDir, const fs::path& pcmDir, fs::file_time_type sourceFloor = fs::file_time_type::min()) const; - void Compile(const std::string_view clang, const fs::path& buildDir, std::atomic& buildCancelled, std::string& buildError) const; + CRAFTER_API 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; + CRAFTER_API void Compile(const std::string_view clang, const fs::path& buildDir, std::atomic& buildCancelled, std::string& buildError) const; }; } diff --git a/interfaces/Crafter.Build-Interface.cppm b/interfaces/Crafter.Build-Interface.cppm index 27eb298..3991c24 100644 --- a/interfaces/Crafter.Build-Interface.cppm +++ b/interfaces/Crafter.Build-Interface.cppm @@ -1,6 +1,8 @@ // SPDX-License-Identifier: LGPL-3.0-only // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +module; +#include "Crafter.Build-Api.h" export module Crafter.Build:Interface; import std; namespace fs = std::filesystem; @@ -24,9 +26,9 @@ namespace Crafter { bool checked = false; std::string name; fs::path path; - ModulePartition(std::string&& name, fs::path&& path); - bool Check(const fs::path& pcmDir, fs::file_time_type sourceFloor = fs::file_time_type::min()); - void Compile(const std::string_view clang, const fs::path& pcmDir, const fs::path& buildDir, std::atomic& buildCancelled, std::string& buildError); + CRAFTER_API 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()); + CRAFTER_API void Compile(const std::string_view clang, const fs::path& pcmDir, const fs::path& buildDir, std::atomic& buildCancelled, std::string& buildError); }; export class Module { @@ -46,8 +48,8 @@ namespace Crafter { std::vector> partitions; std::string name; fs::path path; - Module(std::string&& name, fs::path&& path); - bool Check(const fs::path& pcmDir, fs::file_time_type sourceFloor = fs::file_time_type::min()); - void Compile(const std::string_view clang, const fs::path& pcmDir, const fs::path& buildDir, std::atomic& buildCancelled, std::string& buildError); + CRAFTER_API 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()); + CRAFTER_API void Compile(const std::string_view clang, const fs::path& pcmDir, const fs::path& buildDir, std::atomic& buildCancelled, std::string& buildError); }; } diff --git a/interfaces/Crafter.Build-Lint.cppm b/interfaces/Crafter.Build-Lint.cppm index bbe9398..a25e350 100644 --- a/interfaces/Crafter.Build-Lint.cppm +++ b/interfaces/Crafter.Build-Lint.cppm @@ -1,6 +1,8 @@ // SPDX-License-Identifier: LGPL-3.0-only // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +module; +#include "Crafter.Build-Api.h" export module Crafter.Build:Lint; import std; import :Clang; @@ -61,5 +63,5 @@ export namespace Crafter { // final content differs from the original. Prints per-mode output: // findings compiler-style (Report), would-change paths (Check), or // formatted paths (Apply), plus a summary line. - LintSummary RunLint(Configuration& projectCfg, const RunLintOptions& opts); + CRAFTER_API LintSummary RunLint(Configuration& projectCfg, const RunLintOptions& opts); } diff --git a/interfaces/Crafter.Build-Platform.cppm b/interfaces/Crafter.Build-Platform.cppm index 597f12c..0b37177 100644 --- a/interfaces/Crafter.Build-Platform.cppm +++ b/interfaces/Crafter.Build-Platform.cppm @@ -1,6 +1,8 @@ // SPDX-License-Identifier: LGPL-3.0-only // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +module; +#include "Crafter.Build-Api.h" export module Crafter.Build:Platform; import std; namespace fs = std::filesystem; @@ -28,9 +30,9 @@ namespace Crafter { // Progress::Diagnostic), otherwise the compiler's diagnostics. Callers // therefore keep the `if (!result.empty())` shape they already had. std::string RunBuildCommand(std::string_view command); - export CommandResult RunCommandWithTimeout(std::string_view command, std::chrono::seconds timeout); + export CRAFTER_API CommandResult RunCommandWithTimeout(std::string_view command, std::chrono::seconds timeout); std::string GetBaseCommand(const Configuration& config); - export Configuration LoadProject(const fs::path& projectFile, std::span args); + export CRAFTER_API Configuration LoadProject(const fs::path& projectFile, std::span args); // A dependency the project file currently running its CrafterBuildProject // declared with `#pragma crafter dependency`, so it can also be wired // into the build graph (a test that links the same module, say) without @@ -38,7 +40,7 @@ namespace Crafter { // name or any module it exports — whatever the project file `import`s. // Throws when no such dependency was declared, or when called from // anywhere other than inside LoadProject. - export Configuration* ProjectDependency(std::string_view name); + export CRAFTER_API 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`, @@ -47,11 +49,11 @@ namespace Crafter { // 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(); + export CRAFTER_API bool IsTopLevelProject(); // Resolves the directory holding distributed runtime assets (Crafter.Build // module sources, wasi-runtime/, etc). Honors CRAFTER_BUILD_HOME; otherwise // derives /share/crafter-build from the running executable's path. - export fs::path GetCrafterBuildHome(); + export CRAFTER_API fs::path GetCrafterBuildHome(); // Newest mtime among the prerequisites a compiler recorded in `depFile` // (clang's `-MD -MF .d`). This is how a build step learns which // headers its source pulled in: the module scanner only reads `import` diff --git a/interfaces/Crafter.Build-Progress.cppm b/interfaces/Crafter.Build-Progress.cppm index 123e53b..1ed4bc7 100644 --- a/interfaces/Crafter.Build-Progress.cppm +++ b/interfaces/Crafter.Build-Progress.cppm @@ -1,20 +1,22 @@ // SPDX-License-Identifier: LGPL-3.0-only // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +module; +#include "Crafter.Build-Api.h" export module Crafter.Build:Progress; import std; export namespace Crafter::Progress { enum class Verbosity { Quiet, Default, Verbose }; - void SetVerbosity(Verbosity v); - Verbosity GetVerbosity(); + CRAFTER_API void SetVerbosity(Verbosity v); + CRAFTER_API Verbosity GetVerbosity(); // RAII work-unit marker. Construction inflates the running total; destruction // increments completed and (on Default + TTY) redraws the status line. Move // semantics are disabled — keep these as plain stack locals inside the worker // lambda so the destructor fires when that thread's work ends. - class Task { + class CRAFTER_API Task { public: explicit Task(std::string label); ~Task(); @@ -27,18 +29,18 @@ export namespace Crafter::Progress { }; // Verbose-mode command echo. No-op outside Verbose. - void EchoCommand(std::string_view command); + CRAFTER_API void EchoCommand(std::string_view command); // 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 // it, then writes to stderr. No-op on Quiet. - void Diagnostic(std::string_view text); + CRAFTER_API void Diagnostic(std::string_view text); // 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. - void Clear(); + CRAFTER_API void Clear(); // Print "Built N targets in Xms" (or equivalent) and clear in-place state. // Safe to call multiple times; subsequent calls are no-ops. - void Finalize(); + CRAFTER_API void Finalize(); } diff --git a/interfaces/Crafter.Build-Shader.cppm b/interfaces/Crafter.Build-Shader.cppm index 4b4d841..059d19a 100644 --- a/interfaces/Crafter.Build-Shader.cppm +++ b/interfaces/Crafter.Build-Shader.cppm @@ -1,6 +1,8 @@ // SPDX-License-Identifier: LGPL-3.0-only // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +module; +#include "Crafter.Build-Api.h" export module Crafter.Build:Shader; import std; namespace fs = std::filesystem; @@ -28,8 +30,8 @@ namespace Crafter { fs::path path; std::string entrypoint; ShaderType type; - Shader(fs::path&& path, std::string&& entrypoint, ShaderType type); - bool Check(const fs::path& outputDir) const; - std::string Compile(const fs::path& outputDir, std::span includeDirs) const; + CRAFTER_API Shader(fs::path&& path, std::string&& entrypoint, ShaderType type); + CRAFTER_API bool Check(const fs::path& outputDir) const; + CRAFTER_API std::string Compile(const fs::path& outputDir, std::span includeDirs) const; }; } diff --git a/interfaces/Crafter.Build-Test.cppm b/interfaces/Crafter.Build-Test.cppm index 0b69256..f26f99a 100644 --- a/interfaces/Crafter.Build-Test.cppm +++ b/interfaces/Crafter.Build-Test.cppm @@ -1,6 +1,8 @@ // SPDX-License-Identifier: LGPL-3.0-only // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +module; +#include "Crafter.Build-Api.h" export module Crafter.Build:Test; import std; import :Clang; @@ -26,7 +28,7 @@ export namespace Crafter { // avoid the double-`GetInterfacesAndImplementations` footgun (calling // it twice would re-parse the same .cppm files into duplicate Module // entries). - struct TestBuilder { + struct CRAFTER_API TestBuilder { Configuration* parent; std::size_t index; @@ -83,8 +85,8 @@ export namespace Crafter { bool AllPassed() const { return failed == 0 && crashed == 0 && timedOut == 0; } }; - TestSummary RunTests(Configuration& projectCfg, const RunTestsOptions& opts, std::span projectArgs = {}); - TestResult RunSingleTest(const Test& test, const std::filesystem::path& binary, std::chrono::seconds timeout); + CRAFTER_API TestSummary RunTests(Configuration& projectCfg, const RunTestsOptions& opts, std::span projectArgs = {}); + CRAFTER_API 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 // root project's Configuration before discovery; fixtures call ParentLib() @@ -92,6 +94,6 @@ export namespace Crafter { // parent itself) so they can declare it as a dependency without rebuilding // the lib config from scratch. Lookup is by Configuration::name and // recurses through the parent's dependency graph. - void SetParentProject(Configuration* parent); - Configuration* ParentLib(std::string_view name); + CRAFTER_API void SetParentProject(Configuration* parent); + CRAFTER_API Configuration* ParentLib(std::string_view name); } diff --git a/project.cpp b/project.cpp index 8ce6800..3d7f5a3 100644 --- a/project.cpp +++ b/project.cpp @@ -24,9 +24,12 @@ extern "C" Configuration CrafterBuildProject(std::span a CrafterBuildLib->name = "crafter.build-lib"; CrafterBuildLib->outputName = "crafter-build"; ApplyStandardArgs(*CrafterBuildLib, args); - // Monolithic: project.so resolves Crafter:: symbols from the running - // executable, so the library is only ever linked statically into it. - CrafterBuildLib->type = ConfigurationType::LibraryStatic; + // Windows builds (native msvc via build.cmd or cross-compiled mingw from + // Linux) need a DLL + import lib + launcher exe so LoadProject can + // compile project.cpp against a stable ABI boundary. Linux is monolithic. + CrafterBuildLib->type = (CrafterBuildLib->target == "x86_64-w64-mingw32" || CrafterBuildLib->target == "x86_64-pc-windows-msvc") + ? ConfigurationType::LibraryDynamic + : ConfigurationType::LibraryStatic; CrafterBuildLib->dependencies = { math, asset }; CrafterBuildLib->defines.push_back({"CRAFTER_BUILD_HAS_ASSET", ""}); @@ -76,6 +79,12 @@ extern "C" Configuration CrafterBuildProject(std::span a glslang.source.branch = "main"; glslang.builder = ExternalBuilder::CMake; 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.libs = { "SPIRV", "GenericCodeGen", "glslang", "OSDependent", "MachineIndependent", "glslang-default-resource-limits" }; @@ -97,6 +106,10 @@ extern "C" Configuration CrafterBuildProject(std::span a cfg.linkFlags.push_back("-Wl,--export-dynamic"); 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 // process. The harness (whichever crafter-build invokes `test`) compiles diff --git a/tests/CompilerWarning/main.cpp b/tests/CompilerWarning/main.cpp index 5691f6d..91bd2aa 100644 --- a/tests/CompilerWarning/main.cpp +++ b/tests/CompilerWarning/main.cpp @@ -71,7 +71,11 @@ namespace { app.dependencies = { lib }; // 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. - app.linkFlags = { "-Wl,-z,crafter-build-nonexistent-z-value" }; + // GNU-style only — lld-link spells its options differently — so the + // 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; } diff --git a/tests/TestRunnerSpec/main.cpp b/tests/TestRunnerSpec/main.cpp index 12371be..e2b8104 100644 --- a/tests/TestRunnerSpec/main.cpp +++ b/tests/TestRunnerSpec/main.cpp @@ -6,7 +6,9 @@ import Crafter.Build; using namespace Crafter; // POSIX env helpers — libc++ exports only std::getenv via `import std`, so -// forward-declare the setters we need against the C library directly. +// forward-declare the setters we need against the C library directly. The +// 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 unsetenv(const char* name); @@ -85,11 +87,13 @@ int main() { Check(r.exec.find("QEMU_LD_PREFIX=/opt/alarm-sysroot") != std::string::npos, "ForTarget propagates sysroot to QEMU_LD_PREFIX"); } { - // The only way to run x86_64-w64-mingw32 output is wine. + // From a Linux host the only sensible way to run x86_64-w64-mingw32 is wine. Configuration cfg; cfg.target = "x86_64-w64-mingw32"; TestRunner r = TestRunner::ForTarget(cfg); - Check(r.name == "wine", "ForTarget(mingw) uses wine"); + if (HostTarget().find("linux") != std::string::npos) { + Check(r.name == "wine", "ForTarget(mingw) on Linux host uses wine"); + } } // FromEnv: when the env var is set, it wins over the fallback.