linting
Some checks failed
CI / build-test-release (push) Failing after 7m15s

This commit is contained in:
Jorijn van der Graaf 2026-07-23 01:24:42 +02:00
commit 8892154b28
70 changed files with 2780 additions and 596 deletions

View file

@ -63,6 +63,13 @@ jobs:
- name: Bootstrap (build.sh) - name: Bootstrap (build.sh)
run: ./build.sh run: ./build.sh
# Full style gate: Report() findings (naming, enum-class, char*, …) AND
# would-reformat findings from the transform rules. A dirty tree means
# someone skipped `crafter-build format` — fail fast, before the
# (much slower) test suite.
- name: Lint
run: CRAFTER_BUILD_HOME=$PWD/share/crafter-build ./bin/crafter-build lint
- name: Run tests - name: Run tests
run: CRAFTER_BUILD_HOME=$PWD/share/crafter-build ./bin/crafter-build test run: CRAFTER_BUILD_HOME=$PWD/share/crafter-build ./bin/crafter-build test

View file

@ -245,6 +245,67 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>)
`ParentLib(name)` walks the parent project's dependency graph (and the parent itself) to find a `Configuration*` by `Configuration::name`. The build links it into the test exe, so `import MyLib;` resolves naturally. See [examples/tests/](examples/tests/) for the complete worked example. `ParentLib(name)` walks the parent project's dependency graph (and the parent itself) to find a `Configuration*` by `Configuration::name`. The build links it into the test exe, so `import MyLib;` resolves naturally. See [examples/tests/](examples/tests/) for the complete worked example.
## Linting & formatting
Lint rules are C++ in `project.cpp` — no `.lintrc`, no YAML, and no built-in rules. A rule is a named callback that runs once per source file; register it on the `Configuration` before returning:
```cpp
cfg.AddLintRule("no-tabs", [](Crafter::LintContext& ctx) {
if (ctx.Extension() != ".cpp" && ctx.Extension() != ".cppm") return;
for (std::size_t n = 1; n <= ctx.lines.size(); ++n) {
if (ctx.Line(n).contains('\t')) ctx.Report(n, "tab character (use spaces)");
}
});
```
A rule that calls `ctx.SetContent(newContent)` is a **transform** — defined once, it powers both verbs: `lint` reports where the transform would change the file (dry, never writes), and `format` applies it to disk:
```cpp
cfg.AddLintRule("final-newline", [](Crafter::LintContext& ctx) {
if (!ctx.content.empty() && !ctx.content.ends_with('\n')) {
ctx.SetContent(ctx.content + '\n');
}
});
```
```bash
crafter-build lint # full dry gate: Report() findings + would-reformat, exit 1 on any
crafter-build lint 'spdx*' # rule-name glob filter
crafter-build lint --list # enumerate rules without running them
crafter-build format # apply transforms, rewrite changed files in place (exit 0)
crafter-build format --check # CI gate: list files that would change, exit 1 if any
```
Lint findings print compiler-style — `file:line: warning: message [rule]` — so editors and CI pick them up. `format` rewrites files in place (gofmt convention) and only touches files whose bytes actually change; report-only findings are `lint`'s business and don't affect `format`'s exit code. A project with no rules registered exits 1 with a message showing how to add one.
`LintContext` gives each rule:
| Member | Meaning |
|---|---|
| `file` | absolute path of the file under lint |
| `content` | the whole file |
| `lines` | pre-split line views (report 1-based via `Line(n)`) |
| `Extension()` | file extension, e.g. `".cppm"` |
| `CommentStripped()` | `content` with comments and string/char literal bodies blanked to spaces — newlines kept, so line numbers stay valid (raw strings not recognized yet) |
| `Report(line, msg)` | record a finding (`line == 0` for whole-file) |
| `SetContent(str)` | replace the file's content — makes the rule a transform; `lines` re-splits, `CommentStripped()` re-derives. Build the new string, call once at the end; don't assign `content` directly |
Suppression is comment-based :
```cpp
// lint-disable-next-line no-char-pointer — one rule, the following line
// lint-disable-next-line naming, wrap-join — several rules (space- or comma-separated)
// lint-disable-next-line — all rules, the following line
// lint-disable-file fixed-width-types — one rule, whole file (any position)
// lint-disable-file — all rules, whole file
```
Directives silence findings **and** stop `format` from rewriting the suppressed lines. The driver handles this automatically for reports and line-preserving transforms; a custom transform that merges or splits lines must ask `ctx.Suppressed(rule, line)` for the lines it touches (the built-in house rules do).
The linted file set is the project's own sources: module interfaces (+ partitions), implementations, `cFiles`, `cuda`, shader sources, declared tests' sources, and `project.cpp` itself — for the root `Configuration` plus every transitive dependency whose path lies inside the project root. `GitProject` / external dependencies are foreign code and are skipped, as are `files`/`buildFiles`/`assets` (data, not source). Rules registered on any in-root config are collected and deduplicated by name, so with a lib/exe split you can attach them to either. Rules run in registration order, each seeing the previous transform's output; a rule that throws is reverted and becomes a finding instead of aborting the run. Files are read and written as bytes — line views carry a trailing `\r` on CRLF files, so transforms that should be CRLF-safe must handle it (see the `trim-trailing-ws` dogfood rule).
This repo dogfoods the feature with its full house style — 15 rules in [lint-rules.h](lint-rules.h) (naming conventions, K&R braces, `enum class`, `std::println`, fixed-width integer types, `std::format` over concatenation, wrap-joining, whitespace hygiene), registered by [project.cpp](project.cpp) via one `ProjectLint::AddProjectLintRules(cfg)` call. The header is deliberately project-local, not part of the crafter-build library — copy it into a sibling repo to adopt the same style there; `tests/HouseRules/` validates every transform. Most rules are transforms: `crafter-build format` mechanically restyled this entire codebase (~150 fixes), with only expression rewrites the operand scanner can't prove safe (ternaries, raw-string lines) reported for hand-fixing.
## Examples ## Examples
[examples/](examples/) contains progressively larger self-contained projects: [examples/](examples/) contains progressively larger self-contained projects:
@ -260,7 +321,7 @@ Each builds standalone: `cd examples/<name> && crafter-build`.
## Architecture ## Architecture
- **Modules**: `Crafter.Build:Shader` / `:Platform` / `:Interface` / `:Implementation` / `:External` / `:Clang` / `:Test` partitions, re-exported by the `Crafter.Build` umbrella. - **Modules**: `Crafter.Build:Shader` / `:Platform` / `:Interface` / `:Implementation` / `:External` / `:Clang` / `:Test` / `:Lint` / `:Progress` / `:Asset` partitions, re-exported by the `Crafter.Build` umbrella.
- **Build process**: parallel — interface PCMs, implementation `.o` files, external dep clones, dep-config recursive builds, and shader compilations all spawn threads, sync at well-defined join points. - **Build process**: parallel — interface PCMs, implementation `.o` files, external dep clones, dep-config recursive builds, and shader compilations all spawn threads, sync at well-defined join points.
- **`LoadProject`** compiles `project.cpp` to a shared object (`.so` on Linux, `.dll` on Windows) and loads it. On Linux the host exe is linked with `-Wl,--export-dynamic` so the project's undefined symbols resolve back into the running binary. On Windows the host is split into `crafter-build.dll` (everything) + a thin `crafter-build.exe` launcher; the public `Crafter::*` API is annotated with a `CRAFTER_API` macro (`__declspec(dllexport)` when the host is built, `__declspec(dllimport)` when project.dll consumes it via the rebuilt user-cache PCMs), and the host passes `-Wl,/EXPORT:CrafterBuildProject` when linking project.dll so user `project.cpp` files don't need platform-gated annotations. - **`LoadProject`** compiles `project.cpp` to a shared object (`.so` on Linux, `.dll` on Windows) and loads it. On Linux the host exe is linked with `-Wl,--export-dynamic` so the project's undefined symbols resolve back into the running binary. On Windows the host is split into `crafter-build.dll` (everything) + a thin `crafter-build.exe` launcher; the public `Crafter::*` API is annotated with a `CRAFTER_API` macro (`__declspec(dllexport)` when the host is built, `__declspec(dllimport)` when project.dll consumes it via the rebuilt user-cache PCMs), and the host passes `-Wl,/EXPORT:CrafterBuildProject` when linking project.dll so user `project.cpp` files don't need platform-gated annotations.

View file

@ -18,6 +18,7 @@ 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-External.cppm share\crafter-build\
copy /Y interfaces\Crafter.Build-Clang.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-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-Progress.cppm share\crafter-build\
copy /Y interfaces\Crafter.Build-Asset.cppm share\crafter-build\ copy /Y interfaces\Crafter.Build-Asset.cppm share\crafter-build\
copy /Y interfaces\Crafter.Build-Api.h share\crafter-build\ copy /Y interfaces\Crafter.Build-Api.h share\crafter-build\
@ -57,6 +58,7 @@ clang++ %common_options% -fmodule-output interfaces\Crafter.Build-Implementation
clang++ %common_options% -fmodule-output interfaces\Crafter.Build-External.cppm -o .\build\Crafter.Build-External.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-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-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 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 Asset partition: bootstrap compiles it WITHOUT CRAFTER_BUILD_HAS_ASSET so
REM CompressAsset takes the stub branch (no Crafter.Asset PCM exists yet). REM CompressAsset takes the stub branch (no Crafter.Asset PCM exists yet).
@ -70,6 +72,7 @@ clang++ %common_options% .\implementations\Crafter.Build-Implementation.cpp -o .
clang++ %common_options% .\implementations\Crafter.Build-External.cpp -o .\build\Crafter.Build-External_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-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-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-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\Crafter.Build-Asset.cpp -o .\build\Crafter.Build-Asset_impl.o
clang++ %common_options% .\implementations\main.cpp -o .\build\main.o clang++ %common_options% .\implementations\main.cpp -o .\build\main.o
@ -85,6 +88,7 @@ clang++ %useLibcLinker% -shared -std=c++26 -O3 -march=%CRAFTER_BUILD_MARCH% -mtu
.\build\Crafter.Build-External.o ^ .\build\Crafter.Build-External.o ^
.\build\Crafter.Build-Clang.o ^ .\build\Crafter.Build-Clang.o ^
.\build\Crafter.Build-Test.o ^ .\build\Crafter.Build-Test.o ^
.\build\Crafter.Build-Lint.o ^
.\build\Crafter.Build-Progress.o ^ .\build\Crafter.Build-Progress.o ^
.\build\Crafter.Build-Asset.o ^ .\build\Crafter.Build-Asset.o ^
.\build\Crafter.Build.o ^ .\build\Crafter.Build.o ^
@ -95,6 +99,7 @@ clang++ %useLibcLinker% -shared -std=c++26 -O3 -march=%CRAFTER_BUILD_MARCH% -mtu
.\build\Crafter.Build-External_impl.o ^ .\build\Crafter.Build-External_impl.o ^
.\build\Crafter.Build-Clang_impl.o ^ .\build\Crafter.Build-Clang_impl.o ^
.\build\Crafter.Build-Test_impl.o ^ .\build\Crafter.Build-Test_impl.o ^
.\build\Crafter.Build-Lint_impl.o ^
.\build\Crafter.Build-Progress_impl.o ^ .\build\Crafter.Build-Progress_impl.o ^
.\build\Crafter.Build-Asset_impl.o ^ .\build\Crafter.Build-Asset_impl.o ^
-o .\bin\crafter-build.dll -o .\bin\crafter-build.dll
@ -114,6 +119,7 @@ llvm-lib.exe /OUT:.\lib\crafter-build-static.lib ^
.\build\Crafter.Build-External.o ^ .\build\Crafter.Build-External.o ^
.\build\Crafter.Build-Clang.o ^ .\build\Crafter.Build-Clang.o ^
.\build\Crafter.Build-Test.o ^ .\build\Crafter.Build-Test.o ^
.\build\Crafter.Build-Lint.o ^
.\build\Crafter.Build-Progress.o ^ .\build\Crafter.Build-Progress.o ^
.\build\Crafter.Build-Asset.o ^ .\build\Crafter.Build-Asset.o ^
.\build\Crafter.Build.o ^ .\build\Crafter.Build.o ^
@ -124,6 +130,7 @@ llvm-lib.exe /OUT:.\lib\crafter-build-static.lib ^
.\build\Crafter.Build-External_impl.o ^ .\build\Crafter.Build-External_impl.o ^
.\build\Crafter.Build-Clang_impl.o ^ .\build\Crafter.Build-Clang_impl.o ^
.\build\Crafter.Build-Test_impl.o ^ .\build\Crafter.Build-Test_impl.o ^
.\build\Crafter.Build-Lint_impl.o ^
.\build\Crafter.Build-Progress_impl.o ^ .\build\Crafter.Build-Progress_impl.o ^
.\build\Crafter.Build-Asset_impl.o .\build\Crafter.Build-Asset_impl.o

View file

@ -18,6 +18,7 @@ cp interfaces/Crafter.Build-Implementation.cppm share/crafter-build/
cp interfaces/Crafter.Build-External.cppm share/crafter-build/ cp interfaces/Crafter.Build-External.cppm share/crafter-build/
cp interfaces/Crafter.Build-Clang.cppm share/crafter-build/ cp interfaces/Crafter.Build-Clang.cppm share/crafter-build/
cp interfaces/Crafter.Build-Test.cppm share/crafter-build/ cp interfaces/Crafter.Build-Test.cppm share/crafter-build/
cp interfaces/Crafter.Build-Lint.cppm share/crafter-build/
cp interfaces/Crafter.Build-Progress.cppm share/crafter-build/ cp interfaces/Crafter.Build-Progress.cppm share/crafter-build/
cp interfaces/Crafter.Build-Asset.cppm share/crafter-build/ cp interfaces/Crafter.Build-Asset.cppm share/crafter-build/
cp interfaces/Crafter.Build-Api.h share/crafter-build/ cp interfaces/Crafter.Build-Api.h share/crafter-build/
@ -55,6 +56,7 @@ clang++ $common_options -fmodule-output interfaces/Crafter.Build-Implementation.
clang++ $common_options -fmodule-output interfaces/Crafter.Build-External.cppm -o ./build/Crafter.Build-External.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-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-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 clang++ $common_options -fmodule-output interfaces/Crafter.Build-Progress.cppm -o ./build/Crafter.Build-Progress.o
# Asset partition: bootstrap compiles it WITHOUT CRAFTER_BUILD_HAS_ASSET so # Asset partition: bootstrap compiles it WITHOUT CRAFTER_BUILD_HAS_ASSET so
# CompressAsset takes the stub branch and the impl unit doesn't try to # CompressAsset takes the stub branch and the impl unit doesn't try to
@ -70,6 +72,7 @@ clang++ $common_options ./implementations/Crafter.Build-Implementation.cpp -o ./
clang++ $common_options ./implementations/Crafter.Build-External.cpp -o ./build/Crafter.Build-External_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-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-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-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/Crafter.Build-Asset.cpp -o ./build/Crafter.Build-Asset_impl.o
clang++ $common_options ./implementations/main.cpp -o ./build/main.o clang++ $common_options ./implementations/main.cpp -o ./build/main.o
@ -82,6 +85,7 @@ ar rcs ./lib/libcrafter-build.a \
./build/Crafter.Build-External.o \ ./build/Crafter.Build-External.o \
./build/Crafter.Build-Clang.o \ ./build/Crafter.Build-Clang.o \
./build/Crafter.Build-Test.o \ ./build/Crafter.Build-Test.o \
./build/Crafter.Build-Lint.o \
./build/Crafter.Build-Progress.o \ ./build/Crafter.Build-Progress.o \
./build/Crafter.Build-Asset.o \ ./build/Crafter.Build-Asset.o \
./build/Crafter.Build.o \ ./build/Crafter.Build.o \
@ -92,6 +96,7 @@ ar rcs ./lib/libcrafter-build.a \
./build/Crafter.Build-External_impl.o \ ./build/Crafter.Build-External_impl.o \
./build/Crafter.Build-Clang_impl.o \ ./build/Crafter.Build-Clang_impl.o \
./build/Crafter.Build-Test_impl.o \ ./build/Crafter.Build-Test_impl.o \
./build/Crafter.Build-Lint_impl.o \
./build/Crafter.Build-Progress_impl.o \ ./build/Crafter.Build-Progress_impl.o \
./build/Crafter.Build-Asset_impl.o ./build/Crafter.Build-Asset_impl.o
@ -105,6 +110,7 @@ clang++ -std=c++26 -stdlib=libc++ -O3 -march=$MARCH -mtune=$MTUNE -fuse-ld=lld \
./build/Crafter.Build-External.o \ ./build/Crafter.Build-External.o \
./build/Crafter.Build-Clang.o \ ./build/Crafter.Build-Clang.o \
./build/Crafter.Build-Test.o \ ./build/Crafter.Build-Test.o \
./build/Crafter.Build-Lint.o \
./build/Crafter.Build-Progress.o \ ./build/Crafter.Build-Progress.o \
./build/Crafter.Build-Asset.o \ ./build/Crafter.Build-Asset.o \
./build/Crafter.Build.o \ ./build/Crafter.Build.o \
@ -115,6 +121,7 @@ clang++ -std=c++26 -stdlib=libc++ -O3 -march=$MARCH -mtune=$MTUNE -fuse-ld=lld \
./build/Crafter.Build-External_impl.o \ ./build/Crafter.Build-External_impl.o \
./build/Crafter.Build-Clang_impl.o \ ./build/Crafter.Build-Clang_impl.o \
./build/Crafter.Build-Test_impl.o \ ./build/Crafter.Build-Test_impl.o \
./build/Crafter.Build-Lint_impl.o \
./build/Crafter.Build-Progress_impl.o \ ./build/Crafter.Build-Progress_impl.o \
./build/Crafter.Build-Asset_impl.o \ ./build/Crafter.Build-Asset_impl.o \
./build/main.o \ ./build/main.o \

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
import std; import std;

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
import std; import std;
import Crafter.Build; import Crafter.Build;

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
import std; import std;
import MyMath; import MyMath;

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
export module MyMath; export module MyMath;
import std; import std;

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
import std; import std;
import Crafter.Build; import Crafter.Build;

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
export module MyMath; export module MyMath;
import std; import std;

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
import std; import std;
import Crafter.Build; import Crafter.Build;

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// Single-file test: no project.cpp needed. The folder name "Smoke" becomes // Single-file test: no project.cpp needed. The folder name "Smoke" becomes
// the test name. Exit 0 = pass, anything else = fail, exit 77 = skipped. // the test name. Exit 0 = pass, anything else = fail, exit 77 = skipped.

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
import std; import std;
import MyMath; import MyMath;

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
import std; import std;

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
import std; import std;
import Crafter.Build; import Crafter.Build;

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
export module Greeter; export module Greeter;
import std; import std;

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
import std; import std;
import Greeter; import Greeter;

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
import std; import std;
import Crafter.Build; import Crafter.Build;

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module Crafter.Build:Asset_impl; module Crafter.Build:Asset_impl;
import :Asset; import :Asset;
@ -15,7 +15,7 @@ namespace Crafter {
std::string ext = input.extension().string(); std::string ext = input.extension().string();
// Case-insensitive extension match — Sponza's textures dir mixes // Case-insensitive extension match — Sponza's textures dir mixes
// case (.tga and .TGA both appear in the wild). // case (.tga and .TGA both appear in the wild).
for (char& c : ext) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c))); for (char& c : ext) c = static_cast<char>(std::tolower(static_cast<std::uint8_t>(c)));
#ifdef CRAFTER_BUILD_HAS_ASSET #ifdef CRAFTER_BUILD_HAS_ASSET
try { try {
// stb_image (the loader behind LoadPNG) handles all of these, // stb_image (the loader behind LoadPNG) handles all of these,
@ -35,20 +35,11 @@ namespace Crafter {
} }
return {}; return {};
#else #else
return std::format( return std::format("{}: crafter-build was bootstrapped without Crafter.Asset linkage; " "rebuild via `./bin/crafter-build` so the self-host pass picks up the " "Crafter.Asset dep declared in project.cpp", input.string());
"{}: crafter-build was bootstrapped without Crafter.Asset linkage; "
"rebuild via `./bin/crafter-build` so the self-host pass picks up the "
"Crafter.Asset dep declared in project.cpp",
input.string());
#endif #endif
} }
std::string BuildOBJBundle( std::string BuildOBJBundle(const fs::path& objPath, const fs::path& mtlPath, const fs::path& outDir, std::uint16_t albedoSize) {
const fs::path& objPath,
const fs::path& mtlPath,
const fs::path& outDir,
std::uint16_t albedoSize)
{
#ifdef CRAFTER_BUILD_HAS_ASSET #ifdef CRAFTER_BUILD_HAS_ASSET
const fs::path manifest = outDir / "scene.txt"; const fs::path manifest = outDir / "scene.txt";
if (fs::exists(manifest)) return {}; if (fs::exists(manifest)) return {};
@ -75,9 +66,7 @@ namespace Crafter {
if (mesh.vertexes.empty() || mesh.indexes.empty()) continue; if (mesh.vertexes.empty() || mesh.indexes.empty()) continue;
mesh.SaveCompressed(outDir / std::format("mesh_{}.cmesh", emitted)); mesh.SaveCompressed(outDir / std::format("mesh_{}.cmesh", emitted));
std::int32_t a = -1; std::int32_t a = -1;
if (auto it = materials.find(matName); if (auto it = materials.find(matName); it != materials.end() && !it->second.mapKd.empty()) a = static_cast<std::int32_t>(albedoIndex.at(it->second.mapKd));
it != materials.end() && !it->second.mapKd.empty())
a = static_cast<std::int32_t>(albedoIndex.at(it->second.mapKd));
meshAlbedoIdx.push_back(a); meshAlbedoIdx.push_back(a);
++emitted; ++emitted;
} }

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module; module;
#if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32) #if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32)
@ -18,6 +18,7 @@ import std;
import :Clang; import :Clang;
import :Platform; import :Platform;
import :Test; import :Test;
import :Lint;
import :Progress; import :Progress;
import :Asset; import :Asset;
namespace fs = std::filesystem; namespace fs = std::filesystem;
@ -25,9 +26,7 @@ using namespace Crafter;
void Configuration::GetInterfacesAndImplementations(std::span<fs::path> interfaces, std::span<fs::path> implementations) { void Configuration::GetInterfacesAndImplementations(std::span<fs::path> interfaces, std::span<fs::path> implementations) {
auto resolveImport = [this](const std::string& importName, auto resolveImport = [this](const std::string& importName, std::vector<Module*>& localDeps, std::vector<std::pair<Module*, fs::path>>& externalDeps) -> bool {
std::vector<Module*>& localDeps,
std::vector<std::pair<Module*, fs::path>>& externalDeps) -> bool {
for(const std::unique_ptr<Module>& interface : this->interfaces) { for(const std::unique_ptr<Module>& interface : this->interfaces) {
if(interface->name == importName) { if(interface->name == importName) {
localDeps.push_back(interface.get()); localDeps.push_back(interface.get());
@ -40,7 +39,7 @@ void Configuration::GetInterfacesAndImplementations(std::span<fs::path> interfac
if (!seen.insert(depCfg).second) return false; if (!seen.insert(depCfg).second) return false;
for(const std::unique_ptr<Module>& depInterface : depCfg->interfaces) { for(const std::unique_ptr<Module>& depInterface : depCfg->interfaces) {
if(depInterface->name == importName) { if(depInterface->name == importName) {
fs::path depPcmPath = (depCfg->PcmDir() / depInterface->path.filename()).string() + ".pcm"; fs::path depPcmPath = std::format("{}.pcm", (depCfg->PcmDir() / depInterface->path.filename()).string());
externalDeps.emplace_back(depInterface.get(), std::move(depPcmPath)); externalDeps.emplace_back(depInterface.get(), std::move(depPcmPath));
return true; return true;
} }
@ -305,7 +304,7 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
// a single thread to match the cfg.files pattern. // a single thread to match the cfg.files pattern.
auto compressedName = [](const fs::path& src) -> std::optional<fs::path> { auto compressedName = [](const fs::path& src) -> std::optional<fs::path> {
std::string ext = src.extension().string(); std::string ext = src.extension().string();
for (char& c : ext) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c))); for (char& c : ext) c = static_cast<char>(std::tolower(static_cast<std::uint8_t>(c)));
// stb_image (used by CompressAsset → TextureAsset::LoadPNG) handles // stb_image (used by CompressAsset → TextureAsset::LoadPNG) handles
// png/tga/jpg/bmp; all map to .ctex. // png/tga/jpg/bmp; all map to .ctex.
if (ext == ".png" || ext == ".tga" || ext == ".jpg" || ext == ".jpeg" || ext == ".bmp") { if (ext == ".png" || ext == ".tga" || ext == ".jpg" || ext == ".jpeg" || ext == ".bmp") {
@ -435,7 +434,7 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
// it with extra codegen flags (e.g. -mrelaxed-simd) — its BMI must not be // it with extra codegen flags (e.g. -mrelaxed-simd) — its BMI must not be
// shared with the baseline's, or the consuming TUs see a target-feature // shared with the baseline's, or the consuming TUs see a target-feature
// mismatch. Suffix the cache dir with the variant flags when present. // mismatch. Suffix the cache dir with the variant flags when present.
std::string stdPcmKey = config.target + "-" + config.march; std::string stdPcmKey = std::format("{}-{}", config.target, config.march);
for (const std::string& f : config.wasmVariantFlags) { for (const std::string& f : config.wasmVariantFlags) {
stdPcmKey += "+"; stdPcmKey += "+";
for (char c : f) stdPcmKey += (c == '/' || c == '\\') ? '_' : c; for (char c : f) stdPcmKey += (c == '/' || c == '\\') ? '_' : c;
@ -492,7 +491,7 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
// for the baseline build. Part of VariantId, so these objects/PCMs // for the baseline build. Part of VariantId, so these objects/PCMs
// land in their own dir. // land in their own dir.
for (const std::string& f : config.wasmVariantFlags) { for (const std::string& f : config.wasmVariantFlags) {
command += " " + f; command += std::format(" {}", f);
} }
} }
if (config.target == "x86_64-w64-mingw32") { if (config.target == "x86_64-w64-mingw32") {
@ -542,7 +541,7 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
if (!seen.insert(dep).second) return; if (!seen.insert(dep).second) return;
for (const auto& entry : fs::recursive_directory_iterator(dep->path)) { for (const auto& entry : fs::recursive_directory_iterator(dep->path)) {
if (entry.is_directory() && entry.path().filename() == "include") { if (entry.is_directory() && entry.path().filename() == "include") {
includeFlags += " -I" + entry.path().string(); includeFlags += std::format(" -I{}", entry.path().string());
} }
} }
includeFlags += std::format(" -I{}", dep->path.string()); includeFlags += std::format(" -I{}", dep->path.string());
@ -629,7 +628,7 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
// pick them up too (vendored C deps usually need -I from this set). // pick them up too (vendored C deps usually need -I from this set).
std::string userFlags; std::string userFlags;
for(const std::string& flag : config.compileFlags) { for(const std::string& flag : config.compileFlags) {
userFlags += " " + flag; userFlags += std::format(" {}", flag);
} }
command += userFlags; command += userFlags;
@ -676,8 +675,8 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
} }
for (const fs::path& cFile : config.cFiles) { for (const fs::path& cFile : config.cFiles) {
files += std::format(" {}_source.o ", (buildDir / cFile.filename()).string()); files += std::format(" {}_source.o ", (buildDir / cFile.filename()).string());
const std::string objPath = (buildDir / cFile.filename()).string() + "_source.o"; const std::string objPath = std::format("{}_source.o", (buildDir / cFile.filename()).string());
const std::string srcPath = cFile.string() + ".c"; const std::string srcPath = std::format("{}.c", cFile.string());
if (!fs::exists(objPath) || (fs::exists(srcPath) && fs::last_write_time(srcPath) > fs::last_write_time(objPath))) { if (!fs::exists(objPath) || (fs::exists(srcPath) && fs::last_write_time(srcPath) > fs::last_write_time(objPath))) {
threads.emplace_back([&cFile, &buildDir, &buildError, &buildCancelled, &config, &includeFlags, &defineFlags, &userFlags, &cArchFlags, &ltoCompileFlags]() { threads.emplace_back([&cFile, &buildDir, &buildError, &buildCancelled, &config, &includeFlags, &defineFlags, &userFlags, &cArchFlags, &ltoCompileFlags]() {
Progress::Task task(std::format("Compiling {}.c", cFile.filename().string())); Progress::Task task(std::format("Compiling {}.c", cFile.filename().string()));
@ -696,8 +695,8 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
for (const fs::path& cFile : config.cuda) { for (const fs::path& cFile : config.cuda) {
files += std::format(" {}_source.o ", (buildDir / cFile.filename()).string()); files += std::format(" {}_source.o ", (buildDir / cFile.filename()).string());
const std::string objPath = (buildDir / cFile.filename()).string() + "_source.o"; const std::string objPath = std::format("{}_source.o", (buildDir / cFile.filename()).string());
const std::string srcPath = cFile.string() + ".cu"; const std::string srcPath = std::format("{}.cu", cFile.string());
if (!fs::exists(objPath) || (fs::exists(srcPath) && fs::last_write_time(srcPath) > fs::last_write_time(objPath))) { if (!fs::exists(objPath) || (fs::exists(srcPath) && fs::last_write_time(srcPath) > fs::last_write_time(objPath))) {
threads.emplace_back([&cFile, &buildDir, &buildError, &buildCancelled]() { threads.emplace_back([&cFile, &buildDir, &buildError, &buildCancelled]() {
Progress::Task task(std::format("Compiling {}.cu", cFile.filename().string())); Progress::Task task(std::format("Compiling {}.cu", cFile.filename().string()));
@ -789,7 +788,7 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
continue; continue;
} }
std::string ext = asset.extension().string(); std::string ext = asset.extension().string();
for (char& c : ext) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c))); for (char& c : ext) c = static_cast<char>(std::tolower(static_cast<std::uint8_t>(c)));
fs::path srcName = asset.filename(); fs::path srcName = asset.filename();
if (ext == ".png" || ext == ".tga" || ext == ".jpg" || ext == ".jpeg" || ext == ".bmp") { if (ext == ".png" || ext == ".tga" || ext == ".jpg" || ext == ".jpeg" || ext == ".bmp") {
srcName.replace_extension(".ctex"); srcName.replace_extension(".ctex");
@ -821,12 +820,12 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
// Public compile flags propagated from sub-deps. Add them to this build's // Public compile flags propagated from sub-deps. Add them to this build's
// command so config sees the headers its deps expose, and re-publish them // command so config sees the headers its deps expose, and re-publish them
// so config's own consumers see them transitively. // so config's own consumers see them transitively.
for (const std::string& flag : publicFlagSet) command += " " + flag; for (const std::string& flag : publicFlagSet) command += std::format(" {}", flag);
buildResult.publicCompileFlags = std::move(publicFlagSet); buildResult.publicCompileFlags = std::move(publicFlagSet);
fs::file_time_type externalFloor = fs::file_time_type::min(); fs::file_time_type externalFloor = fs::file_time_type::min();
for(const ExternalBuildResult& ext : externalResults) { for(const ExternalBuildResult& ext : externalResults) {
for(const std::string& flag : ext.compileFlags) { for(const std::string& flag : ext.compileFlags) {
command += " " + flag; command += std::format(" {}", flag);
// Headers a dep links via ExternalDependency are part of its // Headers a dep links via ExternalDependency are part of its
// public surface (its modules can include them in declarations // public surface (its modules can include them in declarations
// visible to consumers), so propagate the -I to consumers. // visible to consumers), so propagate the -I to consumers.
@ -889,7 +888,7 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
std::string linkExtras; std::string linkExtras;
for(const std::string& flag : buildResult.libs) { for(const std::string& flag : buildResult.libs) {
linkExtras += " " + flag; linkExtras += std::format(" {}", flag);
} }
// Link-only LTO/section flags (empty in Debug and for wasm). Every exe/lib // Link-only LTO/section flags (empty in Debug and for wasm). Every exe/lib
// link below ends with linkExtras, so this reaches them all; the static-lib // link below ends with linkExtras, so this reaches them all; the static-lib
@ -927,10 +926,9 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
auto expectedOutputFor = [](const Configuration& c) -> fs::path { auto expectedOutputFor = [](const Configuration& c) -> fs::path {
fs::path dir = c.BinDir(); fs::path dir = c.BinDir();
if (c.type == ConfigurationType::Executable) { if (c.type == ConfigurationType::Executable) {
if (c.target.starts_with("wasm32")) if (c.target.starts_with("wasm32")) return dir / (std::format("{}.wasm", c.outputName));
return dir / (c.outputName + ".wasm");
return c.target == "x86_64-w64-mingw32" return c.target == "x86_64-w64-mingw32"
? dir / (c.outputName + ".exe") ? dir / (std::format("{}.exe", c.outputName))
: dir / c.outputName; : dir / c.outputName;
} }
if (c.type == ConfigurationType::LibraryStatic) { if (c.type == ConfigurationType::LibraryStatic) {
@ -971,13 +969,13 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
return !ec && t > consumerMtime; return !ec && t > consumerMtime;
}; };
for (const std::unique_ptr<Module>& iface : config.interfaces) { for (const std::unique_ptr<Module>& iface : config.interfaces) {
if (objNewer(buildDir / (iface->path.filename().string() + ".o"))) { if (objNewer(buildDir / std::format("{}.o", iface->path.filename().string()))) {
buildResult.repack = true; buildResult.repack = true;
break; break;
} }
bool partHit = false; bool partHit = false;
for (const std::unique_ptr<ModulePartition>& part : iface->partitions) { for (const std::unique_ptr<ModulePartition>& part : iface->partitions) {
if (objNewer(buildDir / (part->path.filename().string() + ".o"))) { if (objNewer(buildDir / std::format("{}.o", part->path.filename().string()))) {
partHit = true; partHit = true;
break; break;
} }
@ -986,7 +984,7 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
} }
if (!buildResult.repack) { if (!buildResult.repack) {
for (const Implementation& impl : config.implementations) { for (const Implementation& impl : config.implementations) {
if (objNewer(buildDir / (impl.path.filename().string() + "_impl.o"))) { if (objNewer(buildDir / std::format("{}_impl.o", impl.path.filename().string()))) {
buildResult.repack = true; buildResult.repack = true;
break; break;
} }
@ -1016,8 +1014,7 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
// import lib (so a downstream `crafter-build.exe` // import lib (so a downstream `crafter-build.exe`
// can link a fresh project.dll against it without // can link a fresh project.dll against it without
// hunting through sibling output dirs). // hunting through sibling output dirs).
for (auto fname : {std::format("{}.dll", dep->outputName), for (auto fname : {std::format("{}.dll", dep->outputName), std::format("lib{}.dll.a", dep->outputName)}) {
std::format("lib{}.dll.a", dep->outputName)}) {
fs::path src = depDir / fname; fs::path src = depDir / fname;
if (!fs::exists(src)) continue; if (!fs::exists(src)) continue;
fs::path dest = outputDir / src.filename(); fs::path dest = outputDir / src.filename();
@ -1053,8 +1050,7 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
if (!dllSeen.insert(dep).second) return; if (!dllSeen.insert(dep).second) return;
if (dep->type == ConfigurationType::LibraryDynamic && dep->target == "x86_64-w64-mingw32") { if (dep->type == ConfigurationType::LibraryDynamic && dep->target == "x86_64-w64-mingw32") {
fs::path depDir = dep->BinDir(); fs::path depDir = dep->BinDir();
for (auto fname : {std::format("{}.dll", dep->outputName), for (auto fname : {std::format("{}.dll", dep->outputName), std::format("lib{}.dll.a", dep->outputName)}) {
std::format("lib{}.dll.a", dep->outputName)}) {
fs::path src = depDir / fname; fs::path src = depDir / fname;
if (!fs::exists(src)) continue; if (!fs::exists(src)) continue;
fs::path dest = outputDir / src.filename(); fs::path dest = outputDir / src.filename();
@ -1067,9 +1063,7 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
}; };
for (Configuration* dep : config.dependencies) copyDepDlls(dep); for (Configuration* dep : config.dependencies) copyDepDlls(dep);
buildResult.result = RunCommand(std::format( buildResult.result = RunCommand(std::format("{}{} -o {} -fuse-ld=lld{}", command, files, (outputDir/config.outputName).string(), linkExtras));
"{}{} -o {} -fuse-ld=lld{}",
command, files, (outputDir/config.outputName).string(), linkExtras));
} else { } else {
std::system(std::format("copy \"%LIBCXX_DIR%\\lib\\c++.dll\" \"{}\\c++.dll\"", outputDir.string()).c_str()); std::system(std::format("copy \"%LIBCXX_DIR%\\lib\\c++.dll\" \"{}\\c++.dll\"", outputDir.string()).c_str());
buildResult.result = RunCommand(std::format("{}{} -o {}.exe -fuse-ld=lld -L %LIBCXX_DIR%\\lib -lc++ -nostdinc++ -nostdlib++{}", command, files, (outputDir/config.outputName).string(), linkExtras)); buildResult.result = RunCommand(std::format("{}{} -o {}.exe -fuse-ld=lld -L %LIBCXX_DIR%\\lib -lc++ -nostdinc++ -nostdlib++{}", command, files, (outputDir/config.outputName).string(), linkExtras));
@ -1095,19 +1089,13 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
if (config.target == "x86_64-w64-mingw32") { if (config.target == "x86_64-w64-mingw32") {
fs::path dll = outputDir / std::format("{}.dll", config.outputName); fs::path dll = outputDir / std::format("{}.dll", config.outputName);
fs::path implib = outputDir / std::format("lib{}.dll.a", config.outputName); fs::path implib = outputDir / std::format("lib{}.dll.a", config.outputName);
buildResult.result = RunCommand(std::format( buildResult.result = RunCommand(std::format("{}{} -shared -o {} -Wl,--out-implib,{} -fuse-ld=lld{}", command, files, dll.string(), implib.string(), linkExtras));
"{}{} -shared -o {} -Wl,--out-implib,{} -fuse-ld=lld{}",
command, files, dll.string(), implib.string(), linkExtras));
} else if (config.target == "x86_64-pc-windows-msvc") { } else if (config.target == "x86_64-pc-windows-msvc") {
fs::path dll = outputDir / std::format("{}.dll", config.outputName); fs::path dll = outputDir / std::format("{}.dll", config.outputName);
fs::path implib = outputDir / std::format("{}.lib", config.outputName); fs::path implib = outputDir / std::format("{}.lib", config.outputName);
buildResult.result = RunCommand(std::format( buildResult.result = RunCommand(std::format("{}{} -shared -o {} -Wl,/IMPLIB:{} -fuse-ld=lld{}", command, files, dll.string(), implib.string(), linkExtras));
"{}{} -shared -o {} -Wl,/IMPLIB:{} -fuse-ld=lld{}",
command, files, dll.string(), implib.string(), linkExtras));
} else { } else {
buildResult.result = RunCommand(std::format( buildResult.result = RunCommand(std::format("{}{} -shared -o {}.so -Wl,-rpath,'$ORIGIN' -fuse-ld=lld{}", command, files, (outputDir/(std::string("lib")+config.outputName)).string(), linkExtras));
"{}{} -shared -o {}.so -Wl,-rpath,'$ORIGIN' -fuse-ld=lld{}",
command, files, (outputDir/(std::string("lib")+config.outputName)).string(), linkExtras));
} }
} }
} }
@ -1127,11 +1115,7 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
// Guard: only fires on the top-level config that *declared* wasmVariants // Guard: only fires on the top-level config that *declared* wasmVariants
// (deps leave it empty) and never on a variant pass itself (those carry // (deps leave it empty) and never on a variant pass itself (those carry
// wasmVariantFlags), so there's no recursion. Skipped on build error. // wasmVariantFlags), so there's no recursion. Skipped on build error.
if (buildResult.result.empty() if (buildResult.result.empty() && config.target.starts_with("wasm32") && config.type == ConfigurationType::Executable && !config.wasmVariants.empty() && config.wasmVariantFlags.empty()) {
&& config.target.starts_with("wasm32")
&& config.type == ConfigurationType::Executable
&& !config.wasmVariants.empty()
&& config.wasmVariantFlags.empty()) {
fs::path baselineBin = config.BinDir(); fs::path baselineBin = config.BinDir();
// The whole transitive graph, so the variant flags reach every TU. // The whole transitive graph, so the variant flags reach every TU.
@ -1151,8 +1135,7 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
// another name — nothing extra to compile. // another name — nothing extra to compile.
if (variant.label.empty() || variant.flags.empty()) continue; if (variant.label.empty() || variant.flags.empty()) continue;
Progress::Task task(std::format("Building wasm variant {} ({})", Progress::Task task(std::format("Building wasm variant {} ({})", variant.label, config.outputName));
variant.label, config.outputName));
for (Configuration* c : graph) c->wasmVariantFlags = variant.flags; for (Configuration* c : graph) c->wasmVariantFlags = variant.flags;
fs::path variantBin = config.BinDir(); fs::path variantBin = config.BinDir();
@ -1164,19 +1147,16 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
for (Configuration* c : graph) c->wasmVariantFlags.clear(); for (Configuration* c : graph) c->wasmVariantFlags.clear();
if (!vr.result.empty()) { if (!vr.result.empty()) {
buildResult.result = std::format( buildResult.result = std::format("wasm variant '{}' build failed: {}", variant.label, vr.result);
"wasm variant '{}' build failed: {}", variant.label, vr.result);
break; break;
} }
std::error_code ec; std::error_code ec;
fs::path src = variantBin / (config.outputName + ".wasm"); fs::path src = variantBin / (std::format("{}.wasm", config.outputName));
fs::path dst = baselineBin / std::format("{}.{}.wasm", config.outputName, variant.label); fs::path dst = baselineBin / std::format("{}.{}.wasm", config.outputName, variant.label);
fs::copy_file(src, dst, fs::copy_options::overwrite_existing, ec); fs::copy_file(src, dst, fs::copy_options::overwrite_existing, ec);
if (ec) { if (ec) {
buildResult.result = std::format( buildResult.result = std::format("copy wasm variant '{}' {} -> {}: {}", variant.label, src.string(), dst.string(), ec.message());
"copy wasm variant '{}' {} -> {}: {}",
variant.label, src.string(), dst.string(), ec.message());
break; break;
} }
} }
@ -1190,9 +1170,7 @@ void Crafter::EnableWasiBrowserRuntime(Configuration& cfg) {
fs::path runtimeJs = runtimeDir / "runtime.js"; fs::path runtimeJs = runtimeDir / "runtime.js";
fs::path htmlTemplate = runtimeDir / "index.html.in"; fs::path htmlTemplate = runtimeDir / "index.html.in";
if (!fs::exists(runtimeJs) || !fs::exists(htmlTemplate)) { if (!fs::exists(runtimeJs) || !fs::exists(htmlTemplate)) {
throw std::runtime_error(std::format( throw std::runtime_error(std::format("wasi-runtime assets missing under {} (set CRAFTER_BUILD_HOME or reinstall)", runtimeDir.string()));
"wasi-runtime assets missing under {} (set CRAFTER_BUILD_HOME or reinstall)",
runtimeDir.string()));
} }
fs::path htmlOutDir = cfg.path / "build" / "wasi-runtime" / cfg.name; fs::path htmlOutDir = cfg.path / "build" / "wasi-runtime" / cfg.name;
@ -1225,15 +1203,11 @@ void Crafter::EnableWasiBrowserRuntime(Configuration& cfg) {
// the dev server (python -m http.server) sends no Cache-Control // the dev server (python -m http.server) sends no Cache-Control
// headers. Using ms-since-epoch is enough to be unique per build // headers. Using ms-since-epoch is enough to be unique per build
// without invoking any version-control machinery. // without invoking any version-control machinery.
const std::string buildId = std::to_string( const std::string buildId = std::to_string(std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::system_clock::now().time_since_epoch()).count());
std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch()).count());
std::string envScriptTags; std::string envScriptTags;
for (const std::string& name : envScripts) { for (const std::string& name : envScripts) {
envScriptTags += std::format( envScriptTags += std::format(" <script src=\"{}?v={}\" type=\"module\"></script>\n", name, buildId);
" <script src=\"{}?v={}\" type=\"module\"></script>\n",
name, buildId);
} }
// Walk the dep graph again for non-JS assets — these get pre-loaded by // Walk the dep graph again for non-JS assets — these get pre-loaded by
@ -1250,7 +1224,7 @@ void Crafter::EnableWasiBrowserRuntime(Configuration& cfg) {
// one preloaded wins. Avoid collisions in the source tree. // one preloaded wins. Avoid collisions in the source tree.
auto compressedExt = [](const fs::path& src) -> std::optional<std::string> { auto compressedExt = [](const fs::path& src) -> std::optional<std::string> {
std::string ext = src.extension().string(); std::string ext = src.extension().string();
for (char& c : ext) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c))); for (char& c : ext) c = static_cast<char>(std::tolower(static_cast<std::uint8_t>(c)));
if (ext == ".png" || ext == ".tga" || ext == ".jpg" || ext == ".jpeg" || ext == ".bmp") { if (ext == ".png" || ext == ".tga" || ext == ".jpg" || ext == ".jpeg" || ext == ".bmp") {
return std::string(".ctex"); return std::string(".ctex");
} }
@ -1340,7 +1314,7 @@ void Crafter::EnableWasiBrowserRuntime(Configuration& cfg) {
if (!first) v << ","; if (!first) v << ",";
first = false; first = false;
std::string url = label.empty() std::string url = label.empty()
? cfg.outputName + ".wasm" ? std::format("{}.wasm", cfg.outputName)
: std::format("{}.{}.wasm", cfg.outputName, label); : std::format("{}.{}.wasm", cfg.outputName, label);
v << "{\"label\":\"" << jsonEscape(label) << "\"" v << "{\"label\":\"" << jsonEscape(label) << "\""
<< ",\"url\":\"" << jsonEscape(url) << "\"" << ",\"url\":\"" << jsonEscape(url) << "\""
@ -1363,7 +1337,7 @@ void Crafter::EnableWasiBrowserRuntime(Configuration& cfg) {
std::stringstream buf; std::stringstream buf;
buf << in.rdbuf(); buf << in.rdbuf();
std::string html = buf.str(); std::string html = buf.str();
html = std::regex_replace(html, std::regex(R"(\{\{WASM\}\})"), cfg.outputName + ".wasm"); html = std::regex_replace(html, std::regex(R"(\{\{WASM\}\})"), std::format("{}.wasm", cfg.outputName));
html = std::regex_replace(html, std::regex(R"(\{\{ENV_SCRIPTS\}\})"), envScriptTags); html = std::regex_replace(html, std::regex(R"(\{\{ENV_SCRIPTS\}\})"), envScriptTags);
html = std::regex_replace(html, std::regex(R"(\{\{BUILDID\}\})"), buildId); html = std::regex_replace(html, std::regex(R"(\{\{BUILDID\}\})"), buildId);
std::ofstream out(htmlPath); std::ofstream out(htmlPath);
@ -1388,14 +1362,14 @@ void Crafter::EnableWasiRelaxedSimdVariant(Configuration& cfg) {
} }
std::string Crafter::HostTarget() { std::string Crafter::HostTarget() {
static const std::string cached = []() -> std::string { static const std::string Cached = []() -> std::string {
CommandResult r = RunCommandChecked("clang++ -print-target-triple"); CommandResult r = RunCommandChecked("clang++ -print-target-triple");
if (r.exitCode != 0) return {}; if (r.exitCode != 0) return {};
std::string out = std::move(r.output); std::string out = std::move(r.output);
while (!out.empty() && (out.back() == '\n' || out.back() == '\r')) out.pop_back(); while (!out.empty() && (out.back() == '\n' || out.back() == '\r')) out.pop_back();
return out; return out;
}(); }();
return cached; return Cached;
} }
bool Crafter::ArgQuery::Has(std::string_view flag) const { bool Crafter::ArgQuery::Has(std::string_view flag) const {
@ -1417,7 +1391,8 @@ ArgQuery Crafter::ApplyStandardArgs(Configuration& cfg, std::span<const std::str
if (const char* envMtune = std::getenv("CRAFTER_BUILD_MTUNE"); envMtune && *envMtune) { if (const char* envMtune = std::getenv("CRAFTER_BUILD_MTUNE"); envMtune && *envMtune) {
cfg.mtune = envMtune; cfg.mtune = envMtune;
} }
bool sawLib = false, sawShared = false; bool sawLib = false;
bool sawShared = false;
for (std::string_view a : args) { for (std::string_view a : args) {
if (a == "--debug") cfg.debug = true; if (a == "--debug") cfg.debug = true;
else if (a == "--lib") sawLib = true; else if (a == "--lib") sawLib = true;
@ -1446,6 +1421,8 @@ static void PrintHelp(std::string_view argv0) {
R"(Usage: R"(Usage:
{0} [options] [-- project-args...] Build the project in the current directory {0} [options] [-- project-args...] Build the project in the current directory
{0} test [test-options] [globs...] Build and run the project's tests {0} test [test-options] [globs...] Build and run the project's tests
{0} lint [lint-options] [globs...] Run the project's lint rules over its sources
{0} format [format-options] [globs...] Apply the project's transform rules (rewrites files)
{0} help | -h | --help Show this help {0} help | -h | --help Show this help
Loads ./project.cpp (override with --project=<path>), compiles it to a shared Loads ./project.cpp (override with --project=<path>), compiles it to a shared
@ -1472,6 +1449,22 @@ Test options (after the `test` subcommand):
project's tests plus the host triple. project's tests plus the host triple.
<glob> One or more name globs to filter tests (e.g. 'Unit*'). <glob> One or more name globs to filter tests (e.g. 'Unit*').
Lint options (after the `lint` subcommand):
--list Enumerate matching lint rules without running them.
<glob> One or more name globs to filter rules (e.g. 'spdx*').
Lint rules are defined in project.cpp via cfg.AddLintRule(name, callback)
C++ callbacks run once per source file. No rules ship by default.
Format options (after the `format` subcommand):
--check Dry run: list files that would change, exit 1 if any.
--list Enumerate matching rules without running them.
<glob> One or more name globs to filter rules.
Rules are shared with lint a rule that calls ctx.SetContent is a
transform. `format` rewrites changed files in place; `lint` reports the
same transforms as would-reformat findings without writing.
Project args: Project args:
Any flag not consumed above is forwarded verbatim to CrafterBuildProject as Any flag not consumed above is forwarded verbatim to CrafterBuildProject as
part of its `args` span. Project-specific flags (e.g. --target=, custom part of its `args` span. Project-specific flags (e.g. --target=, custom
@ -1487,8 +1480,9 @@ Environment:
LIBCXX_DIR Windows libc++ install (MSVC ABI builds). LIBCXX_DIR Windows libc++ install (MSVC ABI builds).
Exit status: Exit status:
0 success / all non-skipped tests passed 0 success / all non-skipped tests passed / lint clean / format applied
1 build failure or one or more tests failed 1 build failure, one or more tests failed, lint findings, no rules defined,
format write errors, or `format --check` found files that would change
)", argv0); )", argv0);
} }
@ -1500,17 +1494,27 @@ int Crafter::Run(int argc, char** argv) {
projectArgs.reserve(argc); projectArgs.reserve(argc);
bool runTests = false; bool runTests = false;
bool runLint = false;
bool runFormat = false;
bool runAfterBuild = false; bool runAfterBuild = false;
RunTestsOptions testOpts; RunTestsOptions testOpts;
RunLintOptions lintOpts;
Progress::Verbosity verbosity = Progress::Verbosity::Default; Progress::Verbosity verbosity = Progress::Verbosity::Default;
for (int i = 1; i < argc; ++i) { for (int i = 1; i < argc; ++i) {
std::string_view arg = argv[i]; std::string_view arg = argv[i];
if (arg == "-h" || arg == "--help" || (!runTests && arg == "help")) { if (arg == "-h" || arg == "--help" || (!runTests && !runLint && !runFormat && arg == "help")) {
PrintHelp(argv0); PrintHelp(argv0);
return 0; return 0;
} else if (arg == "test") { } else if (!runLint && !runFormat && arg == "test") {
runTests = true; runTests = true;
} else if (!runTests && !runFormat && arg == "lint") {
runLint = true;
} else if (!runTests && !runLint && arg == "format") {
runFormat = true;
lintOpts.mode = LintMode::Apply;
} else if (runFormat && arg == "--check") {
lintOpts.mode = LintMode::Check;
} else if (arg == "-r") { } else if (arg == "-r") {
runAfterBuild = true; runAfterBuild = true;
} else if (arg == "-v" || arg == "--verbose") { } else if (arg == "-v" || arg == "--verbose") {
@ -1529,6 +1533,10 @@ int Crafter::Run(int argc, char** argv) {
testOpts.runnerOverride = std::string(arg.substr(std::string_view("--runner=").size())); testOpts.runnerOverride = std::string(arg.substr(std::string_view("--runner=").size()));
} else if (runTests && !arg.starts_with("-")) { } else if (runTests && !arg.starts_with("-")) {
testOpts.globs.emplace_back(arg); testOpts.globs.emplace_back(arg);
} else if ((runLint || runFormat) && arg == "--list") {
lintOpts.listOnly = true;
} else if ((runLint || runFormat) && !arg.starts_with("-")) {
lintOpts.globs.emplace_back(arg);
} else { } else {
projectArgs.push_back(arg); projectArgs.push_back(arg);
} }
@ -1553,6 +1561,18 @@ int Crafter::Run(int argc, char** argv) {
Configuration config = LoadProject(projectFile, projectArgs); Configuration config = LoadProject(projectFile, projectArgs);
SetParentProject(&config); SetParentProject(&config);
if (runLint || runFormat) {
// lexically_normal: fs::absolute keeps a "./" component, which
// would leak into displayed paths and the project-root derivation.
lintOpts.projectFile = fs::absolute(projectFile).lexically_normal();
LintSummary lintSummary = RunLint(config, lintOpts);
if (!runFormat) return lintSummary.Clean() ? 0 : 1;
if (lintSummary.noRulesDefined || lintSummary.errors > 0) return 1;
// Apply: formatting files is success (gofmt convention).
// Check: a would-change file is the CI failure signal.
return lintOpts.mode == LintMode::Check && !lintSummary.changedFiles.empty() ? 1 : 0;
}
if (runTests) { if (runTests) {
TestSummary summary = RunTests(config, testOpts, projectArgs); TestSummary summary = RunTests(config, testOpts, projectArgs);
return summary.AllPassed() ? 0 : 1; return summary.AllPassed() ? 0 : 1;
@ -1602,20 +1622,20 @@ int Crafter::Run(int argc, char** argv) {
// Probe-bind to find a free port starting at 8080 — if the // Probe-bind to find a free port starting at 8080 — if the
// user has another dev server running we shift up rather // user has another dev server running we shift up rather
// than letting caddy/python exit with EADDRINUSE. // than letting caddy/python exit with EADDRINUSE.
auto findFreePort = [](int basePort, int span) -> int { 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) #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; }(); static const bool WsaInit = []{ WSADATA d; return WSAStartup(MAKEWORD(2, 2), &d) == 0; }();
if (!wsaInit) return basePort; if (!WsaInit) return basePort;
using sock_t = SOCKET; using Sock = SOCKET;
const sock_t invalid = INVALID_SOCKET; const Sock invalid = INVALID_SOCKET;
auto closesock = [](sock_t s){ closesocket(s); }; auto closesock = [](Sock s){ closesocket(s); };
#else #else
using sock_t = int; using Sock = std::int32_t;
const sock_t invalid = -1; const Sock invalid = -1;
auto closesock = [](sock_t s){ ::close(s); }; auto closesock = [](Sock s){ ::close(s); };
#endif #endif
for (int p = basePort; p < basePort + span; ++p) { for (std::int32_t p = basePort; p < basePort + span; ++p) {
sock_t s = ::socket(AF_INET, SOCK_STREAM, 0); Sock s = ::socket(AF_INET, SOCK_STREAM, 0);
if (s == invalid) return basePort; if (s == invalid) return basePort;
sockaddr_in addr{}; sockaddr_in addr{};
addr.sin_family = AF_INET; addr.sin_family = AF_INET;
@ -1627,7 +1647,7 @@ int Crafter::Run(int argc, char** argv) {
} }
return basePort; return basePort;
}; };
const int port = findFreePort(8080, 16); const std::int32_t port = findFreePort(8080, 16);
// Cross-origin isolation: the browser coarsens // Cross-origin isolation: the browser coarsens
// performance.now() (and chrono::steady_clock under wasi) // performance.now() (and chrono::steady_clock under wasi)
// to ~0.1ms unless the response carries COOP/COEP, which // to ~0.1ms unless the response carries COOP/COEP, which
@ -1661,8 +1681,7 @@ int Crafter::Run(int argc, char** argv) {
" file_server\n" " file_server\n"
"}}\n", "}}\n",
port, absDir.string())); port, absDir.string()));
cmd = std::format("caddy run --config {} --adapter caddyfile", cmd = std::format("caddy run --config {} --adapter caddyfile", cf.string());
cf.string());
} else if (have("python3") || have("python")) { } else if (have("python3") || have("python")) {
std::string_view py = have("python3") ? "python3" : "python"; std::string_view py = have("python3") ? "python3" : "python";
picked = py; picked = py;
@ -1701,8 +1720,7 @@ int Crafter::Run(int argc, char** argv) {
"header('Cache-Control: no-store');\n" "header('Cache-Control: no-store');\n"
"return false;\n"); "return false;\n");
isolated = true; isolated = true;
cmd = std::format("php -S 0.0.0.0:{} -t {} {}", cmd = std::format("php -S 0.0.0.0:{} -t {} {}", port, absDir.string(), rp.string());
port, absDir.string(), rp.string());
} else if (have("ruby")) { } else if (have("ruby")) {
picked = "ruby"; picked = "ruby";
cmd = std::format("ruby -run -e httpd {} -p{}", absDir.string(), port); cmd = std::format("ruby -run -e httpd {} -p{}", absDir.string(), port);
@ -1713,17 +1731,12 @@ int Crafter::Run(int argc, char** argv) {
picked = "npx http-server"; picked = "npx http-server";
cmd = std::format("npx --yes http-server {} -p {} --silent", absDir.string(), port); cmd = std::format("npx --yes http-server {} -p {} --silent", absDir.string(), port);
} else { } else {
std::println(std::cerr, std::println(std::cerr, "-r wasm: no HTTP server found in PATH. Install one of: " "caddy, python3, python, php, ruby, busybox, npx (Node.js).");
"-r wasm: no HTTP server found in PATH. Install one of: "
"caddy, python3, python, php, ruby, busybox, npx (Node.js).");
return 1; return 1;
} }
std::println("listening on port :{}", port); std::println("listening on port :{}", port);
if (!isolated) { if (!isolated) {
std::println(std::cerr, std::println(std::cerr, "warning: {} does not emit COOP/COEP — performance.now() will be coarse " "(~0.1ms). Install caddy, python3, or php for cross-origin isolation.", picked);
"warning: {} does not emit COOP/COEP — performance.now() will be coarse "
"(~0.1ms). Install caddy, python3, or php for cross-origin isolation.",
picked);
} }
// Silence the backend's own banner/request logs so the // Silence the backend's own banner/request logs so the
// experience is identical regardless of which server is // experience is identical regardless of which server is
@ -1742,9 +1755,7 @@ int Crafter::Run(int argc, char** argv) {
if (have("wasmer")) { if (have("wasmer")) {
return std::system(std::format("wasmer run {}", artifact.string()).c_str()) == 0 ? 0 : 1; return std::system(std::format("wasmer run {}", artifact.string()).c_str()) == 0 ? 0 : 1;
} }
std::println(std::cerr, std::println(std::cerr, "-r wasm: no wasm runtime found in PATH. Install wasmtime or wasmer, " "or call EnableWasiBrowserRuntime(cfg) in project.cpp for a browser build.");
"-r wasm: no wasm runtime found in PATH. Install wasmtime or wasmer, "
"or call EnableWasiBrowserRuntime(cfg) in project.cpp for a browser build.");
return 1; return 1;
} }

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module Crafter.Build:External_impl; module Crafter.Build:External_impl;
import std; import std;
@ -175,10 +175,7 @@ std::string ConfigureCMake(const fs::path& cloneDir, const fs::path& cmakeBuildD
if (!fs::exists(cmakeBuildDir)) fs::create_directories(cmakeBuildDir); if (!fs::exists(cmakeBuildDir)) fs::create_directories(cmakeBuildDir);
std::string cmd = std::format("cmake -S {} -B {}{}", std::string cmd = std::format("cmake -S {} -B {}{}", ShellQuote(fs::absolute(cloneDir).string()), ShellQuote(fs::absolute(cmakeBuildDir).string()), BuildInjectedCMakeFlags(cmakeBuildDir, target));
ShellQuote(fs::absolute(cloneDir).string()),
ShellQuote(fs::absolute(cmakeBuildDir).string()),
BuildInjectedCMakeFlags(cmakeBuildDir, target));
if (!options.empty()) { if (!options.empty()) {
cmd += " "; cmd += " ";
cmd += optionsKey; cmd += optionsKey;
@ -198,9 +195,8 @@ std::string BuildCMake(const fs::path& cmakeBuildDir) {
// unit at a time, leaving every core but one idle. Pass an explicit job // unit at a time, leaving every core but one idle. Pass an explicit job
// count (a bare --parallel maps to an unbounded `make -j` fork bomb on the // count (a bare --parallel maps to an unbounded `make -j` fork bomb on the
// Makefiles generator) so deps like DPP/msquic/glslang compile ~N× faster. // Makefiles generator) so deps like DPP/msquic/glslang compile ~N× faster.
unsigned jobs = std::max(1u, std::thread::hardware_concurrency()); std::uint32_t jobs = std::max(1u, std::thread::hardware_concurrency());
std::string cmd = std::format("cmake --build {} --parallel {}", std::string cmd = std::format("cmake --build {} --parallel {}", ShellQuote(fs::absolute(cmakeBuildDir).string()), jobs);
ShellQuote(fs::absolute(cmakeBuildDir).string()), jobs);
CommandResult r = RunCommandChecked(cmd); CommandResult r = RunCommandChecked(cmd);
if (r.exitCode != 0) { if (r.exitCode != 0) {
return std::format("cmake --build failed (exit {}): {}", r.exitCode, r.output); return std::format("cmake --build failed (exit {}): {}", r.exitCode, r.output);
@ -210,10 +206,7 @@ std::string BuildCMake(const fs::path& cmakeBuildDir) {
} // namespace } // namespace
ExternalBuildResult Crafter::BuildExternal( ExternalBuildResult Crafter::BuildExternal(const ExternalDependency& dep, std::string_view target, std::atomic<bool>& cancelled) {
const ExternalDependency& dep,
std::string_view target,
std::atomic<bool>& cancelled) {
ExternalBuildResult result; ExternalBuildResult result;
if (cancelled.load(std::memory_order_relaxed)) return result; if (cancelled.load(std::memory_order_relaxed)) return result;
@ -234,8 +227,7 @@ ExternalBuildResult Crafter::BuildExternal(
} }
} }
std::string keyMaterial = std::format("{}|{}|{}|{}|{}", std::string keyMaterial = std::format("{}|{}|{}|{}|{}", dep.source.url, dep.source.branch, dep.source.commit, JoinOptions(dep.options), target);
dep.source.url, dep.source.branch, dep.source.commit, JoinOptions(dep.options), target);
std::size_t key = std::hash<std::string>{}(keyMaterial); std::size_t key = std::hash<std::string>{}(keyMaterial);
fs::path cloneDir = externalRoot / std::format("{}-{:016x}", name, key); fs::path cloneDir = externalRoot / std::format("{}-{:016x}", name, key);
@ -303,8 +295,8 @@ ExternalBuildResult Crafter::BuildExternal(
} }
namespace { namespace {
std::mutex projectCacheMutex; std::mutex ProjectCacheMutex;
std::unordered_map<std::string, std::unique_ptr<Configuration>> projectCache; std::unordered_map<std::string, std::unique_ptr<Configuration>> ProjectCache;
// Run the dep's project.cpp from its own directory so its relative paths // Run the dep's project.cpp from its own directory so its relative paths
// (cfg.path = "./", interfaces/, lib/, ...) resolve against the dep // (cfg.path = "./", interfaces/, lib/, ...) resolve against the dep
@ -342,8 +334,7 @@ Configuration* Crafter::GitProject(const GitProjectSpec& spec) {
// how the dep's project.cpp interprets them at runtime), while the // how the dep's project.cpp interprets them at runtime), while the
// in-memory Configuration is keyed by the full spec so each (args) // in-memory Configuration is keyed by the full spec so each (args)
// permutation gets its own Configuration object. // permutation gets its own Configuration object.
std::string srcKey = std::format("git|{}|{}|{}|{}", std::string srcKey = std::format("git|{}|{}|{}|{}", spec.source.url, spec.source.branch, spec.source.commit, spec.projectFile.string());
spec.source.url, spec.source.branch, spec.source.commit, spec.projectFile.string());
std::string srcHash = std::format("{:016x}", std::hash<std::string>{}(srcKey)); std::string srcHash = std::format("{:016x}", std::hash<std::string>{}(srcKey));
std::string fullKey = srcKey; std::string fullKey = srcKey;
@ -354,8 +345,8 @@ Configuration* Crafter::GitProject(const GitProjectSpec& spec) {
std::string fullHash = std::format("{:016x}", std::hash<std::string>{}(fullKey)); std::string fullHash = std::format("{:016x}", std::hash<std::string>{}(fullKey));
{ {
std::lock_guard lock(projectCacheMutex); std::lock_guard lock(ProjectCacheMutex);
if (auto it = projectCache.find(fullHash); it != projectCache.end()) { if (auto it = ProjectCache.find(fullHash); it != ProjectCache.end()) {
return it->second.get(); return it->second.get();
} }
} }
@ -379,38 +370,34 @@ Configuration* Crafter::GitProject(const GitProjectSpec& spec) {
fs::path projectPath = cloneDir / spec.projectFile; fs::path projectPath = cloneDir / spec.projectFile;
if (!fs::exists(projectPath)) { if (!fs::exists(projectPath)) {
throw std::runtime_error(std::format( throw std::runtime_error(std::format("GitProject({}): {} not found in clone", spec.source.url, spec.projectFile.string()));
"GitProject({}): {} not found in clone", spec.source.url, spec.projectFile.string()));
} }
std::vector<std::string_view> argViews(spec.args.begin(), spec.args.end()); std::vector<std::string_view> argViews(spec.args.begin(), spec.args.end());
Configuration cfg = LoadProjectFromRoot(projectPath, cloneDir, argViews); Configuration cfg = LoadProjectFromRoot(projectPath, cloneDir, argViews);
std::lock_guard lock(projectCacheMutex); std::lock_guard lock(ProjectCacheMutex);
if (auto it = projectCache.find(fullHash); it != projectCache.end()) { if (auto it = ProjectCache.find(fullHash); it != ProjectCache.end()) {
return it->second.get(); return it->second.get();
} }
auto stored = std::make_unique<Configuration>(std::move(cfg)); auto stored = std::make_unique<Configuration>(std::move(cfg));
Configuration* ptr = stored.get(); Configuration* ptr = stored.get();
projectCache.emplace(std::move(fullHash), std::move(stored)); ProjectCache.emplace(std::move(fullHash), std::move(stored));
return ptr; return ptr;
} }
fs::path Crafter::GitFetch(const GitSource& source) { fs::path Crafter::GitFetch(const GitSource& source) {
std::string name = DeriveName(source); std::string name = DeriveName(source);
if (name.empty()) { if (name.empty()) {
throw std::runtime_error(std::format( throw std::runtime_error(std::format("GitFetch: could not derive name from URL '{}'", source.url));
"GitFetch: could not derive name from URL '{}'", source.url));
} }
fs::path externalRoot = GetCacheDir() / "external"; fs::path externalRoot = GetCacheDir() / "external";
std::error_code ec; std::error_code ec;
fs::create_directories(externalRoot, ec); fs::create_directories(externalRoot, ec);
if (ec) { if (ec) {
throw std::runtime_error(std::format( throw std::runtime_error(std::format("GitFetch: failed to create {}: {}", externalRoot.string(), ec.message()));
"GitFetch: failed to create {}: {}", externalRoot.string(), ec.message()));
} }
std::string keyMaterial = std::format("git|{}|{}|{}", std::string keyMaterial = std::format("git|{}|{}|{}", source.url, source.branch, source.commit);
source.url, source.branch, source.commit);
std::size_t key = std::hash<std::string>{}(keyMaterial); std::size_t key = std::hash<std::string>{}(keyMaterial);
fs::path cloneDir = externalRoot / std::format("{}-{:016x}", name, key); fs::path cloneDir = externalRoot / std::format("{}-{:016x}", name, key);
@ -439,8 +426,8 @@ Configuration* Crafter::LocalProject(const LocalProjectSpec& spec) {
std::string fullHash = std::format("{:016x}", std::hash<std::string>{}(fullKey)); std::string fullHash = std::format("{:016x}", std::hash<std::string>{}(fullKey));
{ {
std::lock_guard lock(projectCacheMutex); std::lock_guard lock(ProjectCacheMutex);
if (auto it = projectCache.find(fullHash); it != projectCache.end()) { if (auto it = ProjectCache.find(fullHash); it != ProjectCache.end()) {
return it->second.get(); return it->second.get();
} }
} }
@ -449,12 +436,12 @@ Configuration* Crafter::LocalProject(const LocalProjectSpec& spec) {
std::vector<std::string_view> argViews(spec.args.begin(), spec.args.end()); std::vector<std::string_view> argViews(spec.args.begin(), spec.args.end());
Configuration cfg = LoadProjectFromRoot(canonical, depRoot, argViews); Configuration cfg = LoadProjectFromRoot(canonical, depRoot, argViews);
std::lock_guard lock(projectCacheMutex); std::lock_guard lock(ProjectCacheMutex);
if (auto it = projectCache.find(fullHash); it != projectCache.end()) { if (auto it = ProjectCache.find(fullHash); it != ProjectCache.end()) {
return it->second.get(); return it->second.get();
} }
auto stored = std::make_unique<Configuration>(std::move(cfg)); auto stored = std::make_unique<Configuration>(std::move(cfg));
Configuration* ptr = stored.get(); Configuration* ptr = stored.get();
projectCache.emplace(std::move(fullHash), std::move(stored)); ProjectCache.emplace(std::move(fullHash), std::move(stored));
return ptr; return ptr;
} }

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module Crafter.Build:Implementation_impl; module Crafter.Build:Implementation_impl;
import std; import std;
@ -13,8 +13,8 @@ namespace Crafter {
} }
bool Implementation::Check(const fs::path& buildDir, const fs::path& pcmDir, fs::file_time_type sourceFloor) const { bool Implementation::Check(const fs::path& buildDir, const fs::path& pcmDir, fs::file_time_type sourceFloor) const {
std::string objPath = (buildDir/path.filename()).string()+"_impl.o"; std::string objPath = std::format("{}_impl.o", (buildDir/path.filename()).string());
std::string cppPath = path.string()+".cpp"; std::string cppPath = std::format("{}.cpp", path.string());
if(!fs::exists(objPath) || std::max(fs::last_write_time(cppPath), sourceFloor) >= fs::last_write_time(objPath)) { if(!fs::exists(objPath) || std::max(fs::last_write_time(cppPath), sourceFloor) >= fs::last_write_time(objPath)) {
return true; return true;
} }

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module Crafter.Build:Interface_impl; module Crafter.Build:Interface_impl;
import std; import std;
@ -13,8 +13,8 @@ namespace Crafter {
bool ModulePartition::Check(const fs::path& pcmDir, fs::file_time_type sourceFloor) { bool ModulePartition::Check(const fs::path& pcmDir, fs::file_time_type sourceFloor) {
if(!checked) { if(!checked) {
checked = true; checked = true;
std::string pcmPath = (pcmDir/path.filename()).generic_string()+".pcm"; std::string pcmPath = std::format("{}.pcm", (pcmDir/path.filename()).generic_string());
std::string cppmPath = path.generic_string()+".cppm"; std::string cppmPath = std::format("{}.cppm", path.generic_string());
if(fs::exists(pcmPath) && std::max(fs::last_write_time(cppmPath), sourceFloor) < fs::last_write_time(pcmPath)) { if(fs::exists(pcmPath) && std::max(fs::last_write_time(cppmPath), sourceFloor) < fs::last_write_time(pcmPath)) {
fs::file_time_type pcmTime = fs::last_write_time(pcmPath); fs::file_time_type pcmTime = fs::last_write_time(pcmPath);
for(ModulePartition* dependency : partitionDependencies) { for(ModulePartition* dependency : partitionDependencies) {
@ -97,8 +97,8 @@ namespace Crafter {
bool Module::Check(const fs::path& pcmDir, fs::file_time_type sourceFloor) { bool Module::Check(const fs::path& pcmDir, fs::file_time_type sourceFloor) {
if(!checked) { if(!checked) {
checked = true; checked = true;
std::string pcmPath = (pcmDir/path.filename()).generic_string()+".pcm"; std::string pcmPath = std::format("{}.pcm", (pcmDir/path.filename()).generic_string());
std::string cppmPath = path.generic_string()+".cppm"; std::string cppmPath = std::format("{}.cppm", path.generic_string());
if(fs::exists(pcmPath) && std::max(fs::last_write_time(cppmPath), sourceFloor) < fs::last_write_time(pcmPath)) { if(fs::exists(pcmPath) && std::max(fs::last_write_time(cppmPath), sourceFloor) < fs::last_write_time(pcmPath)) {
bool depCheck = false; bool depCheck = false;
for(std::unique_ptr<ModulePartition>& partition : partitions) { for(std::unique_ptr<ModulePartition>& partition : partitions) {

View file

@ -0,0 +1,444 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module;
export module Crafter.Build:Lint_impl;
import std;
import :Lint;
import :Clang;
import :Platform;
import :Progress;
namespace fs = std::filesystem;
using namespace Crafter;
namespace {
// Blank //-comments, /*...*/ comments and string/char literal bodies to
// spaces while copying '\n' through, so byte offsets and line numbers in
// the result match the original text. Raw string literals are not
// recognized (documented v1 limitation) — their bodies pass through as
// ordinary string content until the first '"'.
std::string StripComments(std::string_view src) {
enum class State { Code, LineComment, BlockComment, String, Char };
std::string out;
out.reserve(src.size());
State state = State::Code;
for (std::size_t i = 0; i < src.size(); ++i) {
char c = src[i];
char next = i + 1 < src.size() ? src[i + 1] : '\0';
switch (state) {
case State::Code:
if (c == '/' && next == '/') {
state = State::LineComment;
out += " ";
++i;
} else if (c == '/' && next == '*') {
state = State::BlockComment;
out += " ";
++i;
} else if (c == '"') {
state = State::String;
out += c; // keep the delimiter so quoting stays visible
} else if (c == '\'') {
state = State::Char;
out += c;
} else {
out += c;
}
break;
case State::LineComment:
if (c == '\n') {
state = State::Code;
out += c;
} else {
out += ' ';
}
break;
case State::BlockComment:
if (c == '*' && next == '/') {
state = State::Code;
out += " ";
++i;
} else {
out += c == '\n' ? '\n' : ' ';
}
break;
case State::String:
case State::Char: {
char delim = state == State::String ? '"' : '\'';
if (c == '\\' && next != '\0') {
out += " ";
++i;
if (next == '\n') out.back() = '\n';
} else if (c == delim) {
state = State::Code;
out += c;
} else {
out += c == '\n' ? '\n' : ' ';
}
break;
}
}
}
return out;
}
std::vector<std::string_view> SplitLines(std::string_view content) {
std::vector<std::string_view> lines;
std::size_t start = 0;
while (start <= content.size()) {
std::size_t end = content.find('\n', start);
if (end == std::string_view::npos) {
// Skip a phantom empty final line after a trailing '\n'.
if (start < content.size()) lines.push_back(content.substr(start));
break;
}
lines.push_back(content.substr(start, end - start));
start = end + 1;
}
return lines;
}
bool PathInsideRoot(const fs::path& p, const fs::path& root) {
fs::path rel = fs::weakly_canonical(p).lexically_relative(fs::weakly_canonical(root));
return !rel.empty() && *rel.begin() != "..";
}
// Depth-first walk over the dependency graph keeping only Configurations
// whose path lies inside the project root — GitProject / external deps
// live under the global cache and are foreign code: they contribute
// neither rules nor files. Root-first order so the root's rules win the
// by-name dedup.
std::vector<Configuration*> CollectLocalConfigs(Configuration& root, const fs::path& projectRoot) {
std::vector<Configuration*> local;
std::unordered_set<Configuration*> seen;
std::function<void(Configuration*)> walk = [&](Configuration* c) {
if (!seen.insert(c).second) return;
if (PathInsideRoot(fs::absolute(c->path), projectRoot)) {
local.push_back(c);
}
for (Configuration* dep : c->dependencies) walk(dep);
};
walk(&root);
return local;
}
void CollectConfigSources(const Configuration& c, std::set<fs::path>& files) {
for (const std::unique_ptr<Module>& mod : c.interfaces) {
files.insert(fs::path(std::format("{}.cppm", mod->path.string())));
for (const std::unique_ptr<ModulePartition>& part : mod->partitions) {
files.insert(fs::path(std::format("{}.cppm", part->path.string())));
}
}
for (const Implementation& impl : c.implementations) {
files.insert(fs::path(std::format("{}.cpp", impl.path.string())));
}
// cFiles/cuda resolve against cwd at build time (see Build's compile
// loops); mirror that here.
for (const fs::path& cf : c.cFiles) {
files.insert(fs::absolute(fs::path(std::format("{}.c", cf.string()))).lexically_normal());
}
for (const fs::path& cu : c.cuda) {
files.insert(fs::absolute(fs::path(std::format("{}.cu", cu.string()))).lexically_normal());
}
for (const Shader& shader : c.shaders) {
files.insert(fs::absolute(shader.path).lexically_normal());
}
// files/buildFiles/assets are deliberately excluded: data shipped or
// referenced by the build, not source code.
}
}
std::string LintContext::Extension() const {
return file.extension().string();
}
std::string_view LintContext::Line(std::size_t n) const {
if (n == 0 || n > lines.size()) return {};
return lines[n - 1];
}
const std::string& LintContext::CommentStripped() {
if (!commentStrippedCache) {
commentStrippedCache = StripComments(content);
}
return *commentStrippedCache;
}
void LintContext::Report(std::size_t line, std::string message) {
sink->push_back({file, line, activeRule, std::move(message)});
}
namespace {
// Scan raw lines for suppression comments. Raw, not stripped: the
// directives ARE comments. A next-line directive on (0-based) line i
// targets 1-based line i + 2 — the line below it.
LintSuppressions ParseSuppressions(std::span<const std::string_view> lines) {
LintSuppressions s;
constexpr std::string_view NextLineMarker = "lint-disable-next-line";
constexpr std::string_view FileMarker = "lint-disable-file";
for (std::size_t i = 0; i < lines.size(); ++i) {
std::size_t slash = lines[i].find("//");
if (slash == std::string_view::npos) continue;
bool nextLine = true;
std::size_t marker = lines[i].find(NextLineMarker, slash);
std::size_t markerLen = NextLineMarker.size();
if (marker == std::string_view::npos) {
nextLine = false;
marker = lines[i].find(FileMarker, slash);
markerLen = FileMarker.size();
}
if (marker == std::string_view::npos) continue;
// Everything after the marker is rule names; none = all rules.
std::string_view rest = lines[i].substr(marker + markerLen);
std::vector<std::string> names;
std::size_t pos = 0;
while (pos < rest.size()) {
if (rest[pos] == ' ' || rest[pos] == '\t' || rest[pos] == ',' || rest[pos] == '\r') { ++pos; continue; }
std::size_t end = rest.find_first_of(" \t,\r", pos);
if (end == std::string_view::npos) end = rest.size();
names.emplace_back(rest.substr(pos, end - pos));
pos = end;
}
if (nextLine) {
if (names.empty()) s.lineAll.insert(i + 2);
else for (std::string& n : names) s.lineRules[i + 2].insert(std::move(n));
} else {
if (names.empty()) s.fileAll = true;
else for (std::string& n : names) s.fileRules.insert(std::move(n));
}
}
return s;
}
}
bool LintContext::Suppressed(std::string_view rule, std::size_t line) {
if (!suppressionsCache) suppressionsCache = ParseSuppressions(lines);
const LintSuppressions& s = *suppressionsCache;
if (s.fileAll || s.fileRules.contains(std::string(rule))) return true;
if (line == 0) return false;
if (s.lineAll.contains(line)) return true;
if (auto it = s.lineRules.find(line); it != s.lineRules.end()) return it->second.contains(std::string(rule));
return false;
}
void LintContext::SetContent(std::string newContent) {
content = std::move(newContent);
lines = SplitLines(content);
commentStrippedCache.reset();
suppressionsCache.reset(); // line numbers may have shifted — re-parse
}
void Configuration::AddLintRule(std::string name, std::function<void(LintContext&)> check) {
lintRules.push_back({std::move(name), std::move(check)});
}
LintSummary Crafter::RunLint(Configuration& projectCfg, const RunLintOptions& opts) {
LintSummary summary;
fs::path projectRoot = opts.projectFile.empty()
? fs::absolute(projectCfg.path)
: opts.projectFile.parent_path();
std::vector<Configuration*> localConfigs = CollectLocalConfigs(projectCfg, projectRoot);
// Collect rules root-first, dedup by name (first registration wins).
std::vector<const LintRule*> rules;
std::unordered_set<std::string_view> ruleNames;
for (Configuration* c : localConfigs) {
for (const LintRule& rule : c->lintRules) {
if (ruleNames.insert(rule.name).second) rules.push_back(&rule);
}
}
if (rules.empty()) {
summary.noRulesDefined = true;
std::println(std::cerr,
R"msg(No lint rules defined.
Register rules in project.cpp before returning the Configuration:
cfg.AddLintRule("no-tabs", [](Crafter::LintContext& ctx) {{
if (ctx.Extension() != ".cpp" && ctx.Extension() != ".cppm") return;
for (std::size_t n = 1; n <= ctx.lines.size(); ++n) {{
if (ctx.Line(n).contains('\t')) ctx.Report(n, "tab character (use spaces)");
}}
}});
A rule that calls ctx.SetContent(newContent) is a transform: `crafter-build
format` applies it to disk, and `crafter-build lint` reports where it would.
`crafter-build lint` runs every rule over the project's own sources.)msg");
return summary;
}
std::erase_if(rules, [&](const LintRule* r) { return !MatchAny(opts.globs, r->name); });
summary.rulesRun = rules.size();
if (opts.listOnly) {
for (const LintRule* rule : rules) std::println("{}", rule->name);
return summary;
}
if (rules.empty()) {
std::println("No lint rules matched.");
return summary;
}
std::set<fs::path> files;
for (Configuration* c : localConfigs) {
CollectConfigSources(*c, files);
for (const Test& t : c->tests) CollectConfigSources(t.config, files);
}
if (!opts.projectFile.empty()) files.insert(opts.projectFile);
fs::path cwd = fs::current_path();
auto shown = [&cwd](const fs::path& p) {
return PathInsideRoot(p, cwd) ? p.lexically_relative(cwd) : p;
};
for (const fs::path& file : files) {
std::ifstream in(file, std::ios::binary);
if (!in) continue; // config parse already read it; a vanished file fails the build first
std::stringstream buffer;
buffer << in.rdbuf();
LintContext ctx;
ctx.file = file;
ctx.content = std::move(buffer).str();
ctx.lines = SplitLines(ctx.content);
ctx.sink = &summary.findings;
++summary.filesLinted;
const std::string original = ctx.content;
for (const LintRule* rule : rules) {
ctx.activeRule = rule->name;
// Snapshot for transform diffing — and the revert point if the
// rule throws, so a half-applied transform never reaches disk
// and chained rules see clean input.
std::string before = ctx.content;
try {
rule->check(ctx);
} catch (const std::exception& e) {
// Never let a rule's exception unwind across the project
// DLL boundary — surface it as a finding instead.
ctx.SetContent(std::move(before));
ctx.Report(0, std::format("rule '{}' threw: {}", rule->name, e.what()));
++summary.errors;
if (opts.mode != LintMode::Report) {
// Report mode prints it with the findings; the other
// modes don't print findings, so surface it here.
std::println(std::cerr, "{}: rule '{}' threw: {}", shown(file).string(), rule->name, e.what());
}
continue;
}
// Transform detection is compare-by-value: a rule that SetContents
// identical bytes is not a change. The mutated content carries
// forward in every mode so chained rules compose identically
// whether or not this run writes.
if (ctx.content == before) continue;
// File-level suppression disables the transform outright — in
// every mode, so `format` never rewrites a suppressed file.
if (ctx.Suppressed(rule->name, 0)) {
ctx.SetContent(std::move(before));
continue;
}
// Diff before/after. Same line count → per-line handling:
// suppressed changed lines are REVERTED (all modes — suppression
// must also stop `format`), the rest yield would-reformat
// findings in the dry modes. Different count (or no differing
// line — SplitLines hides a trailing '\n', the final-newline
// case) → one whole-file finding; count-changing transforms
// handle per-line suppression themselves (see
// LintContext::Suppressed).
std::vector<std::string_view> beforeLines = SplitLines(before);
bool anyLineDiffers = false;
if (beforeLines.size() == ctx.lines.size()) {
std::vector<std::size_t> reverted;
for (std::size_t i = 0; i < beforeLines.size(); ++i) {
if (beforeLines[i] == ctx.lines[i]) continue;
anyLineDiffers = true;
if (ctx.Suppressed(rule->name, i + 1)) {
reverted.push_back(i);
} else if (opts.mode != LintMode::Apply) {
summary.findings.push_back({file, i + 1, rule->name, "would reformat"});
}
}
if (!reverted.empty()) {
std::string rebuilt;
rebuilt.reserve(ctx.content.size());
std::size_t next = 0;
for (std::size_t i = 0; i < ctx.lines.size(); ++i) {
rebuilt += (next < reverted.size() && reverted[next] == i) ? beforeLines[i] : ctx.lines[i];
if (next < reverted.size() && reverted[next] == i) ++next;
if (i + 1 < ctx.lines.size() || ctx.content.ends_with('\n')) rebuilt += '\n';
}
ctx.SetContent(std::move(rebuilt));
}
}
if (!anyLineDiffers && opts.mode != LintMode::Apply && ctx.content != before) {
summary.findings.push_back({file, 0, rule->name, "would reformat"});
}
}
// Drop findings the file's directives suppress — covers Report()
// calls from any rule (custom ones included) plus the derived
// would-reformat findings above. Line-0 findings only match
// file-level directives.
std::erase_if(summary.findings, [&](const LintFinding& f) {
return f.file == file && ctx.Suppressed(f.rule, f.line);
});
if (ctx.content != original) {
summary.changedFiles.push_back(file);
if (opts.mode == LintMode::Apply) {
std::ofstream out(file, std::ios::binary | std::ios::trunc);
out.write(ctx.content.data(), static_cast<std::streamsize>(ctx.content.size()));
out.close();
if (!out) {
std::println(std::cerr, "failed to write {}", shown(file).string());
++summary.errors;
}
}
}
}
std::sort(summary.findings.begin(), summary.findings.end(),
[](const LintFinding& a, const LintFinding& b) {
return std::tie(a.file, a.line) < std::tie(b.file, b.line);
});
Progress::Clear();
switch (opts.mode) {
case LintMode::Report: {
std::unordered_set<std::string> filesWithFindings;
for (const LintFinding& f : summary.findings) {
filesWithFindings.insert(f.file.string());
std::println("{}:{}: warning: {} [{}]", shown(f.file).string(), f.line, f.message, f.rule);
}
if (summary.findings.empty()) {
std::println("Lint clean: {} files, {} rules", summary.filesLinted, summary.rulesRun);
} else {
std::println("{} finding(s) in {} of {} files ({} rules)", summary.findings.size(), filesWithFindings.size(), summary.filesLinted, summary.rulesRun);
}
break;
}
case LintMode::Check: {
// gofmt -l style: the paths alone, then a one-line verdict.
// Report-only findings are lint's business, not printed here.
for (const fs::path& f : summary.changedFiles) {
std::println("{}", shown(f).string());
}
if (summary.changedFiles.empty()) {
std::println("Format check clean: {} files, {} rules", summary.filesLinted, summary.rulesRun);
} else {
std::println("{} file(s) would be reformatted", summary.changedFiles.size());
}
break;
}
case LintMode::Apply: {
for (const fs::path& f : summary.changedFiles) {
std::println("formatted: {}", shown(f).string());
}
if (summary.changedFiles.empty()) {
std::println("Nothing to format: {} files, {} rules", summary.filesLinted, summary.rulesRun);
} else {
std::println("Formatted {} of {} files ({} rules)", summary.changedFiles.size(), summary.filesLinted, summary.rulesRun);
}
break;
}
}
return summary;
}

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module; module;
#if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32) #if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32)
@ -42,7 +42,7 @@ namespace {
// don't keep the lock alive past our own release. // don't keep the lock alive past our own release.
class CacheLock { class CacheLock {
#ifdef CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_linux_gnu #ifdef CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_linux_gnu
int fd_ = -1; std::int32_t fd_ = -1;
public: public:
explicit CacheLock(const fs::path& cacheDir) { explicit CacheLock(const fs::path& cacheDir) {
fd_ = ::open((cacheDir / ".lock").c_str(), O_RDWR | O_CREAT | O_CLOEXEC, 0644); fd_ = ::open((cacheDir / ".lock").c_str(), O_RDWR | O_CREAT | O_CLOEXEC, 0644);
@ -59,9 +59,7 @@ namespace {
HANDLE handle_ = INVALID_HANDLE_VALUE; HANDLE handle_ = INVALID_HANDLE_VALUE;
public: public:
explicit CacheLock(const fs::path& cacheDir) { explicit CacheLock(const fs::path& cacheDir) {
handle_ = CreateFileW((cacheDir / ".lock").wstring().c_str(), handle_ = CreateFileW((cacheDir / ".lock").wstring().c_str(), GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE,
nullptr, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
if (handle_ != INVALID_HANDLE_VALUE) { if (handle_ != INVALID_HANDLE_VALUE) {
OVERLAPPED ov{}; OVERLAPPED ov{};
LockFileEx(handle_, LOCKFILE_EXCLUSIVE_LOCK, 0, MAXDWORD, MAXDWORD, &ov); LockFileEx(handle_, LOCKFILE_EXCLUSIVE_LOCK, 0, MAXDWORD, MAXDWORD, &ov);
@ -111,15 +109,43 @@ fs::path Crafter::GetCrafterBuildHome() {
if (parent == dir) break; if (parent == dir) break;
dir = parent; dir = parent;
} }
std::string msg = std::format( std::string msg = std::format("could not locate crafter-build runtime assets relative to {} (set CRAFTER_BUILD_HOME). Tried:", hostExe.string());
"could not locate crafter-build runtime assets relative to {} (set CRAFTER_BUILD_HOME). Tried:",
hostExe.string());
for (const auto& p : tried) { for (const auto& p : tried) {
msg += "\n " + p.string(); msg += std::format("\n {}", p.string());
} }
throw std::runtime_error(msg); throw std::runtime_error(msg);
} }
bool Crafter::MatchGlob(std::string_view glob, std::string_view name) {
std::size_t gi = 0;
std::size_t ni = 0;
std::size_t star = std::string_view::npos;
std::size_t mark = 0;
while (ni < name.size()) {
if (gi < glob.size() && (glob[gi] == '?' || glob[gi] == name[ni])) {
++gi; ++ni;
} else if (gi < glob.size() && glob[gi] == '*') {
star = gi++;
mark = ni;
} else if (star != std::string_view::npos) {
gi = star + 1;
ni = ++mark;
} else {
return false;
}
}
while (gi < glob.size() && glob[gi] == '*') ++gi;
return gi == glob.size();
}
bool Crafter::MatchAny(std::span<const std::string> globs, std::string_view name) {
if (globs.empty()) return true;
for (const auto& g : globs) {
if (MatchGlob(g, name)) return true;
}
return false;
}
#if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32) #if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32)
std::string Crafter::RunCommand(const std::string_view cmd) { std::string Crafter::RunCommand(const std::string_view cmd) {
Progress::EchoCommand(cmd); Progress::EchoCommand(cmd);
@ -127,14 +153,14 @@ std::string Crafter::RunCommand(const std::string_view cmd) {
std::string result; std::string result;
// Use cmd.exe to interpret redirection // Use cmd.exe to interpret redirection
std::string with = "cmd /C \"" + std::string(cmd) + " 2>&1\""; std::string with = std::format("cmd /C \"{} 2>&1\"", std::string(cmd));
FILE* pipe = _popen(with.c_str(), "r"); FILE* pipe = _popen(with.c_str(), "r");
if (!pipe) { if (!pipe) {
throw std::runtime_error("_popen() failed!"); throw std::runtime_error("_popen() failed!");
} }
while (fgets(buffer.data(), static_cast<int>(buffer.size()), pipe) != nullptr) { while (fgets(buffer.data(), static_cast<std::int32_t>(buffer.size()), pipe) != nullptr) {
result += buffer.data(); result += buffer.data();
} }
@ -146,14 +172,14 @@ CommandResult Crafter::RunCommandChecked(std::string_view cmd) {
std::array<char, 128> buffer; std::array<char, 128> buffer;
CommandResult result{}; CommandResult result{};
std::string with = "cmd /C \"" + std::string(cmd) + " 2>&1\""; std::string with = std::format("cmd /C \"{} 2>&1\"", std::string(cmd));
FILE* pipe = _popen(with.c_str(), "r"); FILE* pipe = _popen(with.c_str(), "r");
if (!pipe) { if (!pipe) {
throw std::runtime_error("_popen() failed!"); throw std::runtime_error("_popen() failed!");
} }
while (fgets(buffer.data(), static_cast<int>(buffer.size()), pipe) != nullptr) { while (fgets(buffer.data(), static_cast<std::int32_t>(buffer.size()), pipe) != nullptr) {
result.output += buffer.data(); result.output += buffer.data();
} }
@ -165,7 +191,8 @@ CommandResult Crafter::RunCommandWithTimeout(std::string_view cmd, std::chrono::
CommandResult result{}; CommandResult result{};
SECURITY_ATTRIBUTES sa{ sizeof(sa), nullptr, TRUE }; SECURITY_ATTRIBUTES sa{ sizeof(sa), nullptr, TRUE };
HANDLE readEnd = nullptr, writeEnd = nullptr; HANDLE readEnd = nullptr;
HANDLE writeEnd = nullptr;
if (!CreatePipe(&readEnd, &writeEnd, &sa, 0)) { if (!CreatePipe(&readEnd, &writeEnd, &sa, 0)) {
throw std::runtime_error("CreatePipe failed"); throw std::runtime_error("CreatePipe failed");
} }
@ -196,14 +223,11 @@ CommandResult Crafter::RunCommandWithTimeout(std::string_view cmd, std::chrono::
// &&, ||) as popen("/bin/sh -c …") does on Linux. cmd's special-case // &&, ||) as popen("/bin/sh -c …") does on Linux. cmd's special-case
// /C parsing strips the outer quote pair when the inner text contains // /C parsing strips the outer quote pair when the inner text contains
// additional quotes, which is the common case here. // additional quotes, which is the common case here.
std::string wrapped = "cmd /C \"" + std::string(cmd) + "\""; std::string wrapped = std::format("cmd /C \"{}\"", std::string(cmd));
std::vector<char> cmdBuf(wrapped.begin(), wrapped.end()); std::vector<char> cmdBuf(wrapped.begin(), wrapped.end());
cmdBuf.push_back('\0'); cmdBuf.push_back('\0');
BOOL ok = CreateProcessA( BOOL ok = CreateProcessA(nullptr, cmdBuf.data(), nullptr, nullptr, TRUE, CREATE_NO_WINDOW | CREATE_SUSPENDED, nullptr, nullptr, &si, &pi);
nullptr, cmdBuf.data(), nullptr, nullptr, TRUE,
CREATE_NO_WINDOW | CREATE_SUSPENDED,
nullptr, nullptr, &si, &pi);
if (!ok) { if (!ok) {
CloseHandle(readEnd); CloseHandle(readEnd);
CloseHandle(writeEnd); CloseHandle(writeEnd);
@ -226,9 +250,7 @@ CommandResult Crafter::RunCommandWithTimeout(std::string_view cmd, std::chrono::
} }
}); });
DWORD waitMs = static_cast<DWORD>(std::min<long long>( DWORD waitMs = static_cast<DWORD>(std::min<std::int64_t>(static_cast<std::int64_t>(timeout.count()) * 1000LL, static_cast<std::int64_t>(INFINITE) - 1));
static_cast<long long>(timeout.count()) * 1000LL,
static_cast<long long>(INFINITE) - 1));
DWORD waitResult = WaitForSingleObject(pi.hProcess, waitMs); DWORD waitResult = WaitForSingleObject(pi.hProcess, waitMs);
if (waitResult == WAIT_TIMEOUT) { if (waitResult == WAIT_TIMEOUT) {
@ -245,9 +267,9 @@ CommandResult Crafter::RunCommandWithTimeout(std::string_view cmd, std::chrono::
// so the runner shows 💥 instead of a numeric exit code. // so the runner shows 💥 instead of a numeric exit code.
if ((exit & 0xC0000000) == 0xC0000000) { if ((exit & 0xC0000000) == 0xC0000000) {
result.crashed = true; result.crashed = true;
result.signal = static_cast<int>(exit); result.signal = static_cast<std::int32_t>(exit);
} }
result.exitCode = static_cast<int>(exit); result.exitCode = static_cast<std::int32_t>(exit);
} }
reader.join(); reader.join();
@ -269,7 +291,7 @@ fs::path Crafter::GetCacheDir() {
} }
namespace { namespace {
constexpr std::array<std::string_view, 10> kCrafterBuildModules = { constexpr std::array<std::string_view, 11> CrafterBuildModules = {
"Crafter.Build-Shader", "Crafter.Build-Shader",
"Crafter.Build-Platform", "Crafter.Build-Platform",
"Crafter.Build-Interface", "Crafter.Build-Interface",
@ -277,6 +299,7 @@ namespace {
"Crafter.Build-External", "Crafter.Build-External",
"Crafter.Build-Clang", "Crafter.Build-Clang",
"Crafter.Build-Test", "Crafter.Build-Test",
"Crafter.Build-Lint",
"Crafter.Build-Progress", "Crafter.Build-Progress",
"Crafter.Build-Asset", "Crafter.Build-Asset",
"Crafter.Build", "Crafter.Build",
@ -305,9 +328,9 @@ std::string Crafter::GetBaseCommand(const Configuration& config) {
namespace { namespace {
void EnsureCrafterBuildPcms(const fs::path& sourceDir, const fs::path& cacheDir) { void EnsureCrafterBuildPcms(const fs::path& sourceDir, const fs::path& cacheDir) {
CacheLock lock(cacheDir); CacheLock lock(cacheDir);
for (std::string_view name : kCrafterBuildModules) { for (std::string_view name : CrafterBuildModules) {
fs::path cppmPath = sourceDir / (std::string(name) + ".cppm"); fs::path cppmPath = sourceDir / std::format("{}.cppm", name);
fs::path pcmPath = cacheDir / (std::string(name) + ".pcm"); fs::path pcmPath = cacheDir / std::format("{}.pcm", name);
if (!fs::exists(cppmPath)) { if (!fs::exists(cppmPath)) {
throw std::runtime_error(std::format("module source {} not found in {} (set CRAFTER_BUILD_HOME)", name, sourceDir.string())); throw std::runtime_error(std::format("module source {} not found in {} (set CRAFTER_BUILD_HOME)", name, sourceDir.string()));
} }
@ -336,7 +359,7 @@ Configuration Crafter::LoadProject(const fs::path& projectFile, std::span<const
if (!fs::exists(buildDir)) { if (!fs::exists(buildDir)) {
fs::create_directories(buildDir); fs::create_directories(buildDir);
} }
fs::path dllPath = buildDir / (absProject.stem().string() + ".dll"); fs::path dllPath = buildDir / std::format("{}.dll", absProject.stem().string());
char hostExeBuf[MAX_PATH]; char hostExeBuf[MAX_PATH];
DWORD hostExeLen = GetModuleFileNameA(nullptr, hostExeBuf, MAX_PATH); DWORD hostExeLen = GetModuleFileNameA(nullptr, hostExeBuf, MAX_PATH);
@ -361,9 +384,7 @@ Configuration Crafter::LoadProject(const fs::path& projectFile, std::span<const
EnsureCrafterBuildPcms(sourceDir, cacheDir); EnsureCrafterBuildPcms(sourceDir, cacheDir);
bool stale = !fs::exists(dllPath) bool stale = !fs::exists(dllPath) || fs::last_write_time(dllPath) < fs::last_write_time(absProject) || fs::last_write_time(dllPath) < fs::last_write_time(hostExe);
|| fs::last_write_time(dllPath) < fs::last_write_time(absProject)
|| fs::last_write_time(dllPath) < fs::last_write_time(hostExe);
if (stale) { if (stale) {
fs::path crafterBuildLib = hostExe.parent_path() / "crafter-build.lib"; fs::path crafterBuildLib = hostExe.parent_path() / "crafter-build.lib";
@ -427,9 +448,7 @@ namespace {
std::string MingwGccVersion() { std::string MingwGccVersion() {
fs::path includeRoot = MingwPrefix() / "include" / "c++"; fs::path includeRoot = MingwPrefix() / "include" / "c++";
if (!fs::exists(includeRoot)) { if (!fs::exists(includeRoot)) {
throw std::runtime_error(std::format( 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()));
"mingw-w64 not found at {} (install msys2 mingw-w64-x86_64-toolchain or set CRAFTER_MINGW_DIR)",
MingwPrefix().string()));
} }
std::vector<std::string> versions; std::vector<std::string> versions;
for (const auto& entry : fs::directory_iterator(includeRoot)) { for (const auto& entry : fs::directory_iterator(includeRoot)) {
@ -505,13 +524,11 @@ namespace {
void EnsureCrafterBuildPcms(const fs::path& sourceDir, const fs::path& cacheDir) { void EnsureCrafterBuildPcms(const fs::path& sourceDir, const fs::path& cacheDir) {
CacheLock lock(cacheDir); CacheLock lock(cacheDir);
fs::path prefix = MingwPrefix(); fs::path prefix = MingwPrefix();
for (std::string_view name : kCrafterBuildModules) { for (std::string_view name : CrafterBuildModules) {
fs::path cppmPath = sourceDir / (std::string(name) + ".cppm"); fs::path cppmPath = sourceDir / std::format("{}.cppm", name);
fs::path pcmPath = cacheDir / (std::string(name) + ".pcm"); fs::path pcmPath = cacheDir / std::format("{}.pcm", name);
if (!fs::exists(cppmPath)) { if (!fs::exists(cppmPath)) {
throw std::runtime_error(std::format( throw std::runtime_error(std::format("module source {} not found in {} (set CRAFTER_BUILD_HOME)", name, sourceDir.string()));
"module source {} not found in {} (set CRAFTER_BUILD_HOME)",
name, sourceDir.string()));
} }
if (fs::exists(pcmPath) && fs::last_write_time(cppmPath) < fs::last_write_time(pcmPath)) { if (fs::exists(pcmPath) && fs::last_write_time(cppmPath) < fs::last_write_time(pcmPath)) {
continue; continue;
@ -526,8 +543,7 @@ namespace {
prefix.string(), cacheDir.string(), cppmPath.string(), pcmPath.string()); prefix.string(), cacheDir.string(), cppmPath.string(), pcmPath.string());
CommandResult r = Crafter::RunCommandChecked(cmd); CommandResult r = Crafter::RunCommandChecked(cmd);
if (r.exitCode != 0) { if (r.exitCode != 0) {
throw std::runtime_error(std::format( throw std::runtime_error(std::format("Failed to precompile {} (exit {}): {}", name, r.exitCode, r.output));
"Failed to precompile {} (exit {}): {}", name, r.exitCode, r.output));
} }
} }
} }
@ -537,7 +553,7 @@ Configuration Crafter::LoadProject(const fs::path& projectFile, std::span<const
fs::path absProject = fs::canonical(projectFile); fs::path absProject = fs::canonical(projectFile);
fs::path buildDir = absProject.parent_path() / "build"; fs::path buildDir = absProject.parent_path() / "build";
if (!fs::exists(buildDir)) fs::create_directories(buildDir); if (!fs::exists(buildDir)) fs::create_directories(buildDir);
fs::path dllPath = buildDir / (absProject.stem().string() + ".dll"); fs::path dllPath = buildDir / std::format("{}.dll", absProject.stem().string());
char hostExeBuf[MAX_PATH]; char hostExeBuf[MAX_PATH];
DWORD hostExeLen = GetModuleFileNameA(nullptr, hostExeBuf, MAX_PATH); DWORD hostExeLen = GetModuleFileNameA(nullptr, hostExeBuf, MAX_PATH);
@ -562,9 +578,7 @@ Configuration Crafter::LoadProject(const fs::path& projectFile, std::span<const
EnsureCrafterBuildPcms(sourceDir, cacheDir); EnsureCrafterBuildPcms(sourceDir, cacheDir);
bool stale = !fs::exists(dllPath) bool stale = !fs::exists(dllPath) || fs::last_write_time(dllPath) < fs::last_write_time(absProject) || fs::last_write_time(dllPath) < fs::last_write_time(hostExe);
|| fs::last_write_time(dllPath) < fs::last_write_time(absProject)
|| fs::last_write_time(dllPath) < fs::last_write_time(hostExe);
if (stale) { if (stale) {
// The import lib lives next to the launcher exe (Build()'s mingw // The import lib lives next to the launcher exe (Build()'s mingw
@ -589,8 +603,7 @@ Configuration Crafter::LoadProject(const fs::path& projectFile, std::span<const
std::string result = RunCommand(compileCmd); std::string result = RunCommand(compileCmd);
if (!result.empty()) { if (!result.empty()) {
throw std::runtime_error(std::format( throw std::runtime_error(std::format("Failed to compile project {}: {}", absProject.string(), result));
"Failed to compile project {}: {}", absProject.string(), result));
} }
} }
@ -604,15 +617,13 @@ Configuration Crafter::LoadProject(const fs::path& projectFile, std::span<const
HMODULE handle = LoadLibraryA(dllPath.string().c_str()); HMODULE handle = LoadLibraryA(dllPath.string().c_str());
if (!handle) { if (!handle) {
throw std::runtime_error(std::format( throw std::runtime_error(std::format("Failed to load project {}: error {}", dllPath.string(), GetLastError()));
"Failed to load project {}: error {}", dllPath.string(), GetLastError()));
} }
using ProjectFn = Configuration (*)(std::span<const std::string_view>); using ProjectFn = Configuration (*)(std::span<const std::string_view>);
auto fn = reinterpret_cast<ProjectFn>(GetProcAddress(handle, "CrafterBuildProject")); auto fn = reinterpret_cast<ProjectFn>(GetProcAddress(handle, "CrafterBuildProject"));
if (!fn) { if (!fn) {
throw std::runtime_error(std::format( throw std::runtime_error(std::format("CrafterBuildProject not found in {}: error {}", dllPath.string(), GetLastError()));
"CrafterBuildProject not found in {}: error {}", dllPath.string(), GetLastError()));
} }
return fn(args); return fn(args);
@ -626,7 +637,7 @@ std::string Crafter::RunCommand(const std::string_view cmd) {
std::array<char, 128> buffer; std::array<char, 128> buffer;
std::string result; std::string result;
std::string with = std::string(cmd) + " 2>&1"; std::string with = std::format("{} 2>&1", cmd);
// Open pipe to file // Open pipe to file
FILE* pipe = popen(with.c_str(), "r"); FILE* pipe = popen(with.c_str(), "r");
if (!pipe) throw std::runtime_error("popen() failed!"); if (!pipe) throw std::runtime_error("popen() failed!");
@ -645,7 +656,7 @@ CommandResult Crafter::RunCommandChecked(std::string_view cmd) {
std::array<char, 128> buffer; std::array<char, 128> buffer;
CommandResult result{}; CommandResult result{};
std::string with = std::string(cmd) + " 2>&1"; std::string with = std::format("{} 2>&1", cmd);
FILE* pipe = popen(with.c_str(), "r"); FILE* pipe = popen(with.c_str(), "r");
if (!pipe) throw std::runtime_error("popen() failed!"); if (!pipe) throw std::runtime_error("popen() failed!");
@ -653,7 +664,7 @@ CommandResult Crafter::RunCommandChecked(std::string_view cmd) {
result.output += buffer.data(); result.output += buffer.data();
} }
int status = pclose(pipe); std::int32_t status = pclose(pipe);
if (WIFEXITED(status)) { if (WIFEXITED(status)) {
result.exitCode = WEXITSTATUS(status); result.exitCode = WEXITSTATUS(status);
} else if (WIFSIGNALED(status)) { } else if (WIFSIGNALED(status)) {
@ -670,9 +681,7 @@ CommandResult Crafter::RunCommandWithTimeout(std::string_view cmd, std::chrono::
std::array<char, 128> buffer; std::array<char, 128> buffer;
CommandResult result{}; CommandResult result{};
std::string wrapped = std::format( std::string wrapped = std::format("timeout --kill-after=2 {} {} 2>&1", timeout.count(), cmd);
"timeout --kill-after=2 {} {} 2>&1",
timeout.count(), cmd);
FILE* pipe = popen(wrapped.c_str(), "r"); FILE* pipe = popen(wrapped.c_str(), "r");
if (!pipe) throw std::runtime_error("popen() failed!"); if (!pipe) throw std::runtime_error("popen() failed!");
@ -681,9 +690,9 @@ CommandResult Crafter::RunCommandWithTimeout(std::string_view cmd, std::chrono::
result.output += buffer.data(); result.output += buffer.data();
} }
int status = pclose(pipe); std::int32_t status = pclose(pipe);
if (WIFEXITED(status)) { if (WIFEXITED(status)) {
int code = WEXITSTATUS(status); std::int32_t code = WEXITSTATUS(status);
if (code == 124) { if (code == 124) {
result.timedOut = true; result.timedOut = true;
result.exitCode = 124; result.exitCode = 124;
@ -774,7 +783,7 @@ std::string Crafter::BuildStdPcm(const Configuration& config, fs::path stdPcm) {
// (e.g. -mrelaxed-simd) so the std BMI is feature-compatible. wasm only; // (e.g. -mrelaxed-simd) so the std BMI is feature-compatible. wasm only;
// wasmVariantFlags is empty on every other path. // wasmVariantFlags is empty on every other path.
if (isWasm) { if (isWasm) {
for (const std::string& f : config.wasmVariantFlags) archFlags += " " + f; for (const std::string& f : config.wasmVariantFlags) archFlags += std::format(" {}", f);
} }
// non-wasm cross builds: the driver ranks the host toolchain's own // non-wasm cross builds: the driver ranks the host toolchain's own
// libc++ headers (<install>/../include/c++/v1) above --sysroot, so // libc++ headers (<install>/../include/c++/v1) above --sysroot, so
@ -814,7 +823,7 @@ std::string Crafter::GetBaseCommand(const Configuration& config) {
} }
namespace { namespace {
constexpr std::array<std::string_view, 10> kCrafterBuildModules = { constexpr std::array<std::string_view, 11> CrafterBuildModules = {
"Crafter.Build-Shader", "Crafter.Build-Shader",
"Crafter.Build-Platform", "Crafter.Build-Platform",
"Crafter.Build-Interface", "Crafter.Build-Interface",
@ -822,6 +831,7 @@ namespace {
"Crafter.Build-External", "Crafter.Build-External",
"Crafter.Build-Clang", "Crafter.Build-Clang",
"Crafter.Build-Test", "Crafter.Build-Test",
"Crafter.Build-Lint",
"Crafter.Build-Progress", "Crafter.Build-Progress",
"Crafter.Build-Asset", "Crafter.Build-Asset",
"Crafter.Build", "Crafter.Build",
@ -829,9 +839,9 @@ namespace {
void EnsureCrafterBuildPcms(const fs::path& sourceDir, const fs::path& cacheDir) { void EnsureCrafterBuildPcms(const fs::path& sourceDir, const fs::path& cacheDir) {
CacheLock lock(cacheDir); CacheLock lock(cacheDir);
for (std::string_view name : kCrafterBuildModules) { for (std::string_view name : CrafterBuildModules) {
fs::path cppmPath = sourceDir / (std::string(name) + ".cppm"); fs::path cppmPath = sourceDir / std::format("{}.cppm", name);
fs::path pcmPath = cacheDir / (std::string(name) + ".pcm"); fs::path pcmPath = cacheDir / std::format("{}.pcm", name);
if (!fs::exists(cppmPath)) { if (!fs::exists(cppmPath)) {
throw std::runtime_error(std::format("module source {} not found in {} (set CRAFTER_BUILD_HOME)", name, sourceDir.string())); throw std::runtime_error(std::format("module source {} not found in {} (set CRAFTER_BUILD_HOME)", name, sourceDir.string()));
} }
@ -859,7 +869,7 @@ Configuration Crafter::LoadProject(const fs::path& projectFile, std::span<const
if (!fs::exists(buildDir)) { if (!fs::exists(buildDir)) {
fs::create_directories(buildDir); fs::create_directories(buildDir);
} }
fs::path soPath = buildDir / (absProject.stem().string() + ".so"); fs::path soPath = buildDir / std::format("{}.so", absProject.stem().string());
fs::path hostExe = fs::read_symlink("/proc/self/exe"); fs::path hostExe = fs::read_symlink("/proc/self/exe");
@ -879,9 +889,7 @@ Configuration Crafter::LoadProject(const fs::path& projectFile, std::span<const
EnsureCrafterBuildPcms(sourceDir, cacheDir); EnsureCrafterBuildPcms(sourceDir, cacheDir);
bool stale = !fs::exists(soPath) bool stale = !fs::exists(soPath) || fs::last_write_time(soPath) < fs::last_write_time(absProject) || fs::last_write_time(soPath) < fs::last_write_time(hostExe);
|| fs::last_write_time(soPath) < fs::last_write_time(absProject)
|| fs::last_write_time(soPath) < fs::last_write_time(hostExe);
if (stale) { if (stale) {
std::string compileCmd = std::format( std::string compileCmd = std::format(

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module; module;
#include <stdio.h> #include <stdio.h>
@ -22,21 +22,21 @@ import std;
import :Progress; import :Progress;
namespace { namespace {
std::mutex g_mutex; std::mutex StateMutex;
std::atomic<int> g_total{0}; std::atomic<std::int32_t> Total{0};
std::atomic<int> g_done{0}; std::atomic<std::int32_t> Done{0};
Crafter::Progress::Verbosity g_verbosity = Crafter::Progress::Verbosity::Default; Crafter::Progress::Verbosity ActiveVerbosity = Crafter::Progress::Verbosity::Default;
bool g_isTty = false; bool IsTty = false;
bool g_lineDirty = false; // status line has uncleared content bool LineDirty = false; // status line has uncleared content
bool g_finalized = false; bool Finalized = false;
std::chrono::steady_clock::time_point g_startTime = std::chrono::steady_clock::now(); std::chrono::steady_clock::time_point StartTime = std::chrono::steady_clock::now();
int TerminalWidth() { std::int32_t TerminalWidth() {
#if defined(_WIN32) #if defined(_WIN32)
HANDLE h = GetStdHandle(STD_OUTPUT_HANDLE); HANDLE h = GetStdHandle(STD_OUTPUT_HANDLE);
CONSOLE_SCREEN_BUFFER_INFO info; CONSOLE_SCREEN_BUFFER_INFO info;
if (h != INVALID_HANDLE_VALUE && GetConsoleScreenBufferInfo(h, &info)) { if (h != INVALID_HANDLE_VALUE && GetConsoleScreenBufferInfo(h, &info)) {
int w = info.srWindow.Right - info.srWindow.Left + 1; std::int32_t w = info.srWindow.Right - info.srWindow.Left + 1;
if (w > 0) return w; if (w > 0) return w;
} }
#else #else
@ -46,21 +46,21 @@ namespace {
} }
#endif #endif
if (const char* col = std::getenv("COLUMNS")) { if (const char* col = std::getenv("COLUMNS")) {
try { int c = std::stoi(col); if (c > 0) return c; } catch (...) {} try { std::int32_t c = std::stoi(col); if (c > 0) return c; } catch (...) {}
} }
return 80; return 80;
} }
// Caller holds g_mutex. // Caller holds StateMutex.
void RenderStatus(std::string_view label) { void RenderStatus(std::string_view label) {
if (g_verbosity != Crafter::Progress::Verbosity::Default || !g_isTty) return; if (ActiveVerbosity != Crafter::Progress::Verbosity::Default || !IsTty) return;
int done = g_done.load(std::memory_order_relaxed); std::int32_t done = Done.load(std::memory_order_relaxed);
int total = g_total.load(std::memory_order_relaxed); std::int32_t total = Total.load(std::memory_order_relaxed);
std::string prefix = std::format("[{}/{}] ", done, total); std::string prefix = std::format("[{}/{}] ", done, total);
int width = TerminalWidth(); std::int32_t width = TerminalWidth();
int avail = width - static_cast<int>(prefix.size()) - 1; std::int32_t avail = width - static_cast<std::int32_t>(prefix.size()) - 1;
std::string trimmed{label}; std::string trimmed{label};
if (avail > 0 && static_cast<int>(trimmed.size()) > avail) { if (avail > 0 && static_cast<std::int32_t>(trimmed.size()) > avail) {
trimmed.resize(static_cast<std::size_t>(avail)); trimmed.resize(static_cast<std::size_t>(avail));
} }
// \r returns to col 0, \033[2K erases the whole line. No newline. // \r returns to col 0, \033[2K erases the whole line. No newline.
@ -68,15 +68,15 @@ namespace {
std::fputs(prefix.c_str(), stdout); std::fputs(prefix.c_str(), stdout);
std::fputs(trimmed.c_str(), stdout); std::fputs(trimmed.c_str(), stdout);
std::fflush(stdout); std::fflush(stdout);
g_lineDirty = true; LineDirty = true;
} }
// Caller holds g_mutex. // Caller holds StateMutex.
void ClearLineLocked() { void ClearLineLocked() {
if (g_lineDirty) { if (LineDirty) {
std::fputs("\r\033[2K", stdout); std::fputs("\r\033[2K", stdout);
std::fflush(stdout); std::fflush(stdout);
g_lineDirty = false; LineDirty = false;
} }
} }
} }
@ -84,66 +84,64 @@ namespace {
namespace Crafter::Progress { namespace Crafter::Progress {
void SetVerbosity(Verbosity v) { void SetVerbosity(Verbosity v) {
std::lock_guard lock(g_mutex); std::lock_guard lock(StateMutex);
g_verbosity = v; ActiveVerbosity = v;
g_isTty = CRAFTER_PROGRESS_ISATTY(STDOUT_FILENO) != 0; IsTty = CRAFTER_PROGRESS_ISATTY(STDOUT_FILENO) != 0;
g_startTime = std::chrono::steady_clock::now(); StartTime = std::chrono::steady_clock::now();
g_total.store(0); Total.store(0);
g_done.store(0); Done.store(0);
g_finalized = false; Finalized = false;
} }
Verbosity GetVerbosity() { Verbosity GetVerbosity() {
std::lock_guard lock(g_mutex); std::lock_guard lock(StateMutex);
return g_verbosity; return ActiveVerbosity;
} }
Task::Task(std::string label) : label_(std::move(label)) { Task::Task(std::string label) : label_(std::move(label)) {
g_total.fetch_add(1, std::memory_order_relaxed); Total.fetch_add(1, std::memory_order_relaxed);
std::lock_guard lock(g_mutex); std::lock_guard lock(StateMutex);
if (g_verbosity == Verbosity::Default && g_isTty) { if (ActiveVerbosity == Verbosity::Default && IsTty) {
RenderStatus(label_); RenderStatus(label_);
} }
} }
Task::~Task() { Task::~Task() {
int done = g_done.fetch_add(1, std::memory_order_relaxed) + 1; std::int32_t done = Done.fetch_add(1, std::memory_order_relaxed) + 1;
std::lock_guard lock(g_mutex); std::lock_guard lock(StateMutex);
if (g_verbosity == Verbosity::Default) { if (ActiveVerbosity == Verbosity::Default) {
if (g_isTty) { if (IsTty) {
RenderStatus(label_); RenderStatus(label_);
} else { } else {
// Non-TTY: one append-only line per completed task (ninja-style). // Non-TTY: one append-only line per completed task (ninja-style).
int total = g_total.load(std::memory_order_relaxed); std::int32_t total = Total.load(std::memory_order_relaxed);
std::println("[{}/{}] {}", done, total, label_); std::println("[{}/{}] {}", done, total, label_);
} }
} }
} }
void EchoCommand(std::string_view command) { void EchoCommand(std::string_view command) {
std::lock_guard lock(g_mutex); std::lock_guard lock(StateMutex);
if (g_verbosity == Verbosity::Verbose) { if (ActiveVerbosity == Verbosity::Verbose) {
std::println("$ {}", command); std::println("$ {}", command);
} }
} }
void Clear() { void Clear() {
std::lock_guard lock(g_mutex); std::lock_guard lock(StateMutex);
ClearLineLocked(); ClearLineLocked();
} }
void Finalize() { void Finalize() {
std::lock_guard lock(g_mutex); std::lock_guard lock(StateMutex);
if (g_finalized) return; if (Finalized) return;
g_finalized = true; Finalized = true;
ClearLineLocked(); ClearLineLocked();
if (g_verbosity == Verbosity::Quiet) return; if (ActiveVerbosity == Verbosity::Quiet) return;
int done = g_done.load(); std::int32_t done = Done.load();
if (done == 0) return; // Nothing happened (cached build); stay silent. if (done == 0) return; // Nothing happened (cached build); stay silent.
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>( auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now() - StartTime);
std::chrono::steady_clock::now() - g_startTime); std::println("Built {} step{} in {}ms", done, done == 1 ? "" : "s", elapsed.count());
std::println("Built {} step{} in {}ms",
done, done == 1 ? "" : "s", elapsed.count());
} }
} // namespace Crafter::Progress } // namespace Crafter::Progress

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module; module;
#include "SPIRV/GlslangToSpv.h" #include "SPIRV/GlslangToSpv.h"
@ -72,11 +72,11 @@ namespace Crafter {
std::string pathStr = path.string(); std::string pathStr = path.string();
const char* file_name_list[1] = { pathStr.c_str() }; const char* file_name_list[1] = { pathStr.c_str() };
const char* shader_source = src.data(); const char* shaderSource = src.data();
const int shader_source_len = static_cast<int>(src.size()); const std::int32_t shaderSourceLen = static_cast<std::int32_t>(src.size());
glslang::TShader shader(glslangType); glslang::TShader shader(glslangType);
shader.setStringsWithLengthsAndNames(&shader_source, &shader_source_len, file_name_list, 1); shader.setStringsWithLengthsAndNames(&shaderSource, &shaderSourceLen, file_name_list, 1);
shader.setEntryPoint(entrypoint.c_str()); shader.setEntryPoint(entrypoint.c_str());
shader.setSourceEntryPoint(entrypoint.c_str()); shader.setSourceEntryPoint(entrypoint.c_str());
shader.setEnvTarget(glslang::EShTargetSpv, glslang::EShTargetSpv_1_4); shader.setEnvTarget(glslang::EShTargetSpv, glslang::EShTargetSpv_1_4);

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module; module;
export module Crafter.Build:Test_impl; export module Crafter.Build:Test_impl;
@ -13,41 +13,15 @@ using namespace Crafter;
namespace { namespace {
bool TargetIsWindows(std::string_view target) { bool TargetIsWindows(std::string_view target) {
return target.find("windows") != std::string_view::npos return target.find("windows") != std::string_view::npos || target.find("mingw") != std::string_view::npos;
|| target.find("mingw") != std::string_view::npos;
} }
fs::path TestBinaryPath(const Configuration& cfg) { fs::path TestBinaryPath(const Configuration& cfg) {
fs::path outputDir = cfg.BinDir(); fs::path outputDir = cfg.BinDir();
return outputDir / (TargetIsWindows(cfg.target) ? cfg.outputName + ".exe" : cfg.outputName); return outputDir / (TargetIsWindows(cfg.target) ? std::format("{}.exe", cfg.outputName) : cfg.outputName);
} }
bool MatchGlob(std::string_view glob, std::string_view name) { // MatchGlob/MatchAny live in :Platform — shared with the lint verb.
std::size_t gi = 0, ni = 0, star = std::string_view::npos, mark = 0;
while (ni < name.size()) {
if (gi < glob.size() && (glob[gi] == '?' || glob[gi] == name[ni])) {
++gi; ++ni;
} else if (gi < glob.size() && glob[gi] == '*') {
star = gi++;
mark = ni;
} else if (star != std::string_view::npos) {
gi = star + 1;
ni = ++mark;
} else {
return false;
}
}
while (gi < glob.size() && glob[gi] == '*') ++gi;
return gi == glob.size();
}
bool MatchAny(std::span<const std::string> globs, std::string_view name) {
if (globs.empty()) return true;
for (const auto& g : globs) {
if (MatchGlob(g, name)) return true;
}
return false;
}
std::string ShellQuoteSh(std::string_view s) { std::string ShellQuoteSh(std::string_view s) {
std::string out; std::string out;
@ -118,7 +92,7 @@ namespace {
return out; return out;
} }
std::string SignalName(int sig) { std::string SignalName(std::int32_t sig) {
switch (sig) { switch (sig) {
case 1: return "SIGHUP"; case 1: return "SIGHUP";
case 2: return "SIGINT"; case 2: return "SIGINT";
@ -139,7 +113,7 @@ namespace {
std::error_code ec; std::error_code ec;
fs::create_directories(logDir, ec); fs::create_directories(logDir, ec);
if (ec) return; if (ec) return;
std::ofstream(logDir / (name + ".log")) << output; std::ofstream(logDir / (std::format("{}.log", name))) << output;
} }
void PrintResult(const TestResult& r, std::string_view runnerName) { void PrintResult(const TestResult& r, std::string_view runnerName) {
@ -174,19 +148,16 @@ namespace {
std::println("⏱ {}{} ({}ms) timeout", r.name, runnerSuffix, ms); std::println("⏱ {}{} ({}ms) timeout", r.name, runnerSuffix, ms);
break; break;
case TestOutcome::Skipped: case TestOutcome::Skipped:
std::println("⏭ {}{} skipped: {}", r.name, runnerSuffix, std::println("⏭ {}{} skipped: {}", r.name, runnerSuffix, r.output.empty() ? std::string("(no reason)") : r.output);
r.output.empty() ? std::string("(no reason)") : r.output);
break; break;
} }
} }
} }
namespace { namespace {
std::atomic<Configuration*> g_parentProject{nullptr}; std::atomic<Configuration*> ParentProject{nullptr};
Configuration* FindLibInTree(Configuration* root, Configuration* FindLibInTree(Configuration* root, std::string_view name, std::unordered_set<Configuration*>& seen) {
std::string_view name,
std::unordered_set<Configuration*>& seen) {
if (!seen.insert(root).second) return nullptr; if (!seen.insert(root).second) return nullptr;
if (root->name == name) return root; if (root->name == name) return root;
for (Configuration* dep : root->dependencies) { for (Configuration* dep : root->dependencies) {
@ -197,20 +168,17 @@ namespace {
} }
void Crafter::SetParentProject(Configuration* parent) { void Crafter::SetParentProject(Configuration* parent) {
g_parentProject.store(parent); ParentProject.store(parent);
} }
Configuration* Crafter::ParentLib(std::string_view name) { Configuration* Crafter::ParentLib(std::string_view name) {
Configuration* root = g_parentProject.load(); Configuration* root = ParentProject.load();
if (!root) { if (!root) {
throw std::runtime_error(std::format( throw std::runtime_error(std::format("Crafter::ParentLib('{}'): no parent project set", name));
"Crafter::ParentLib('{}'): no parent project set", name));
} }
std::unordered_set<Configuration*> seen; std::unordered_set<Configuration*> seen;
if (auto found = FindLibInTree(root, name, seen)) return found; if (auto found = FindLibInTree(root, name, seen)) return found;
throw std::runtime_error(std::format( throw std::runtime_error(std::format("Crafter::ParentLib('{}'): not found in parent project '{}'", name, root->name));
"Crafter::ParentLib('{}'): not found in parent project '{}'",
name, root->name));
} }
TestRunner TestRunner::Local() { TestRunner TestRunner::Local() {
@ -286,9 +254,7 @@ TestRunner TestRunner::ForTarget(const Configuration& cfg) {
// emulated loader then searches ITS default paths against the // emulated loader then searches ITS default paths against the
// host filesystem, picking up host-arch libraries from /lib — // host filesystem, picking up host-arch libraries from /lib —
// LD_LIBRARY_PATH steers it back into the sysroot. // LD_LIBRARY_PATH steers it back into the sysroot.
r.exec = std::format( r.exec = std::format("env QEMU_LD_PREFIX={0} LD_LIBRARY_PATH={0}/lib:{0}/usr/lib {1}", cfg.sysroot, r.exec);
"env QEMU_LD_PREFIX={0} LD_LIBRARY_PATH={0}/lib:{0}/usr/lib {1}",
cfg.sysroot, r.exec);
} }
return r; return r;
} }
@ -317,9 +283,7 @@ namespace {
if (spec.starts_with("cmd:") && spec.size() > 4) { if (spec.starts_with("cmd:") && spec.size() > 4) {
return TestRunner::Cmd(std::string(spec.substr(4))); return TestRunner::Cmd(std::string(spec.substr(4)));
} }
throw std::runtime_error(std::format( throw std::runtime_error(std::format("TestRunner::FromSpec: unrecognized runner spec '{}' " "(expected 'local' or 'cmd:<binary>')", spec));
"TestRunner::FromSpec: unrecognized runner spec '{}' "
"(expected 'local' or 'cmd:<binary>')", spec));
} }
} }
@ -411,8 +375,7 @@ namespace {
return {false, std::format("env '{}' unset", arg)}; return {false, std::format("env '{}' unset", arg)};
} }
} else { } else {
return {false, std::format( return {false, std::format("unknown require kind '{}' (expected tool/file/env)", kind)};
"unknown require kind '{}' (expected tool/file/env)", kind)};
} }
} }
return {true, ""}; return {true, ""};
@ -479,9 +442,7 @@ TestBuilder Configuration::AddTest(std::string_view name, std::span<fs::path> in
return TestBuilder{this, tests.size() - 1}; return TestBuilder{this, tests.size() - 1};
} }
void Configuration::AddMarchVariants(std::string_view name, void Configuration::AddMarchVariants(std::string_view name, std::span<fs::path> interfaces, std::span<const MarchTier> tiers) {
std::span<fs::path> interfaces,
std::span<const MarchTier> tiers) {
for (const auto& tier : tiers) { for (const auto& tier : tiers) {
Test t; Test t;
t.config.path = "./"; t.config.path = "./";
@ -507,25 +468,25 @@ void Configuration::AddMarchVariants(std::string_view name,
} }
} }
TestBuilder& TestBuilder::Path(fs::path p) { test().config.path = std::move(p); return *this; } TestBuilder& TestBuilder::Path(fs::path p) { Ref().config.path = std::move(p); return *this; }
TestBuilder& TestBuilder::Target(std::string t) { test().config.target = std::move(t); return *this; } TestBuilder& TestBuilder::Target(std::string t) { Ref().config.target = std::move(t); return *this; }
TestBuilder& TestBuilder::March(std::string m) { test().config.march = std::move(m); return *this; } TestBuilder& TestBuilder::March(std::string m) { Ref().config.march = std::move(m); return *this; }
TestBuilder& TestBuilder::Mtune(std::string m) { test().config.mtune = std::move(m); return *this; } TestBuilder& TestBuilder::Mtune(std::string m) { Ref().config.mtune = std::move(m); return *this; }
TestBuilder& TestBuilder::Sysroot(fs::path s) { test().config.sysroot = s.string(); return *this; } TestBuilder& TestBuilder::Sysroot(fs::path s) { Ref().config.sysroot = s.string(); return *this; }
TestBuilder& TestBuilder::Debug(bool d) { test().config.debug = d; return *this; } TestBuilder& TestBuilder::Debug(bool d) { Ref().config.debug = d; return *this; }
TestBuilder& TestBuilder::Define(std::string n, std::string v) { TestBuilder& TestBuilder::Define(std::string n, std::string v) {
test().config.defines.push_back({std::move(n), std::move(v)}); Ref().config.defines.push_back({std::move(n), std::move(v)});
return *this; return *this;
} }
TestBuilder& TestBuilder::Timeout(std::chrono::seconds s) { test().timeout = s; return *this; } TestBuilder& TestBuilder::Timeout(std::chrono::seconds s) { Ref().timeout = s; return *this; }
TestBuilder& TestBuilder::Args(std::vector<std::string> a) { test().args = std::move(a); return *this; } TestBuilder& TestBuilder::Args(std::vector<std::string> a) { Ref().args = std::move(a); return *this; }
TestBuilder& TestBuilder::Requires(std::string r) { test().requires_.push_back(std::move(r)); return *this; } TestBuilder& TestBuilder::Requires(std::string r) { Ref().requires_.push_back(std::move(r)); return *this; }
TestBuilder& TestBuilder::Dependencies(std::vector<Configuration*> d) { TestBuilder& TestBuilder::Dependencies(std::vector<Configuration*> d) {
test().config.dependencies = std::move(d); Ref().config.dependencies = std::move(d);
return *this; return *this;
} }
TestBuilder& TestBuilder::LinkFlag(std::string f) { test().config.linkFlags.push_back(std::move(f)); return *this; } TestBuilder& TestBuilder::LinkFlag(std::string f) { Ref().config.linkFlags.push_back(std::move(f)); return *this; }
TestBuilder& TestBuilder::CompileFlag(std::string f) { test().config.compileFlags.push_back(std::move(f)); return *this; } TestBuilder& TestBuilder::CompileFlag(std::string f) { Ref().config.compileFlags.push_back(std::move(f)); return *this; }
TestSummary Crafter::RunTests(Configuration& projectCfg, const RunTestsOptions& opts, std::span<const std::string_view> projectArgs) { TestSummary Crafter::RunTests(Configuration& projectCfg, const RunTestsOptions& opts, std::span<const std::string_view> projectArgs) {
// Multi-target sweep: when no --target= was given, the run covers every // Multi-target sweep: when no --target= was given, the run covers every
@ -590,10 +551,10 @@ TestSummary Crafter::RunTests(Configuration& projectCfg, const RunTestsOptions&
return summary; return summary;
} }
int jobs = opts.jobs > 0 std::int32_t jobs = opts.jobs > 0
? opts.jobs ? opts.jobs
: std::max(1u, std::thread::hardware_concurrency()); : std::max(1u, std::thread::hardware_concurrency());
jobs = std::min(jobs, static_cast<int>(filtered.size())); jobs = std::min(jobs, static_cast<std::int32_t>(filtered.size()));
std::unordered_map<fs::path, std::shared_future<BuildResult>> depResults; std::unordered_map<fs::path, std::shared_future<BuildResult>> depResults;
std::mutex depMutex; std::mutex depMutex;
@ -651,10 +612,7 @@ TestSummary Crafter::RunTests(Configuration& projectCfg, const RunTestsOptions&
if (!tool.empty() && !RequiresMentionsTool(t.requires_, tool)) { if (!tool.empty() && !RequiresMentionsTool(t.requires_, tool)) {
r.outcome = TestOutcome::Fail; r.outcome = TestOutcome::Fail;
r.exitCode = -1; r.exitCode = -1;
r.output = std::format( r.output = std::format("runner '{}' unavailable and not declared in requires " "(add .Requires(\"tool:{}\") to permit skipping)", t.runner.name, tool);
"runner '{}' unavailable and not declared in requires "
"(add .Requires(\"tool:{}\") to permit skipping)",
t.runner.name, tool);
} else { } else {
r.outcome = TestOutcome::Skipped; r.outcome = TestOutcome::Skipped;
r.output = std::format("runner '{}' not available", t.runner.name); r.output = std::format("runner '{}' not available", t.runner.name);
@ -718,7 +676,7 @@ TestSummary Crafter::RunTests(Configuration& projectCfg, const RunTestsOptions&
std::vector<std::jthread> threads; std::vector<std::jthread> threads;
threads.reserve(jobs); threads.reserve(jobs);
for (int j = 0; j < jobs; ++j) { for (std::int32_t j = 0; j < jobs; ++j) {
threads.emplace_back(worker); threads.emplace_back(worker);
} }
threads.clear(); // joins all jthreads threads.clear(); // joins all jthreads

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
#include <cstdio> #include <cstdio>
#if defined(_WIN32) #if defined(_WIN32)

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
#pragma once #pragma once

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module; module;
#include "Crafter.Build-Api.h" #include "Crafter.Build-Api.h"

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module; module;
#include "Crafter.Build-Api.h" #include "Crafter.Build-Api.h"
@ -96,12 +96,91 @@ export namespace Crafter {
struct TestResult { struct TestResult {
std::string name; std::string name;
TestOutcome outcome = TestOutcome::Pass; TestOutcome outcome = TestOutcome::Pass;
int exitCode = 0; std::int32_t exitCode = 0;
int signal = 0; std::int32_t signal = 0;
std::chrono::milliseconds duration{0}; std::chrono::milliseconds duration{0};
std::string output; std::string output;
}; };
// One lint diagnostic. Produced by LintContext::Report — or derived by
// the driver when a transform rule's SetContent output differs from the
// file on disk ("would reformat") — collected and printed compiler-style
// by RunLint (Crafter.Build:Lint):
// <file>:<line>: warning: <message> [<rule>]
struct LintFinding {
fs::path file;
std::size_t line = 0; // 1-based; 0 = whole-file finding
std::string rule; // name of the rule that produced it
std::string message;
};
// Parsed `// lint-disable-*` suppression directives for one file (see
// LintContext::Suppressed). Line keys are 1-based and refer to the line a
// next-line directive TARGETS (the line after the comment).
struct LintSuppressions {
bool fileAll = false;
std::unordered_set<std::string> fileRules;
std::unordered_set<std::size_t> lineAll;
std::unordered_map<std::size_t, std::unordered_set<std::string>> lineRules;
};
// Per-file view handed to each LintRule's check callback. Every member
// function is out-of-line and CRAFTER_API (defined in Crafter.Build:Lint's
// 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<std::string_view> lines; // views into `content`, one per line, no '\n'
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, /*...*/ comments and string/char literal
// bodies blanked to spaces, newlines preserved — offsets and line
// numbers stay valid. Built on first call, cached per file. Raw string
// literals are not recognized (v1 limitation).
CRAFTER_API const std::string& CommentStripped();
// Record a finding at `line` (1-based; pass 0 for a whole-file finding).
CRAFTER_API void Report(std::size_t line, std::string message);
// 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).
// `lines` is re-split and CommentStripped() re-derives on next call;
// string_views taken before this call are invalidated. Recommended
// 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.
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
// // lint-disable-file [rules...] applies to the whole file
// Rule names are space- or comma-separated; none = all rules. The
// driver already filters Report()
// findings and reverts line-preserving transform edits on suppressed
// lines; a transform that MERGES or SPLITS lines must consult this
// 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.
CRAFTER_API bool Suppressed(std::string_view rule, std::size_t line);
// Driver wiring — set by RunLint before each check call. Not for rules.
std::string activeRule;
std::vector<LintFinding>* sink = nullptr;
std::optional<std::string> commentStrippedCache;
std::optional<LintSuppressions> suppressionsCache;
};
// A named lint rule: `check` runs once per (rule, file) over the project's
// own sources. Rules self-filter by ctx.Extension() / ctx.file. A rule
// that calls ctx.SetContent is a transform — defined once, it both gates
// `crafter-build lint` and fixes under `crafter-build format`.
struct LintRule {
std::string name;
std::function<void(LintContext&)> check;
};
// The host target triple, detected once per process by running // The host target triple, detected once per process by running
// `clang++ -print-target-triple` and cached. Used as the default for // `clang++ -print-target-triple` and cached. Used as the default for
// Configuration::target so projects don't have to hardcode it for the // Configuration::target so projects don't have to hardcode it for the
@ -172,6 +251,8 @@ export namespace Crafter {
// wasmVariants instead. // wasmVariants instead.
std::vector<std::string> wasmVariantFlags; std::vector<std::string> wasmVariantFlags;
std::vector<Test> tests; std::vector<Test> tests;
// Lint rules for `crafter-build lint`. Populate via AddLintRule.
std::vector<LintRule> lintRules;
CRAFTER_API void GetInterfacesAndImplementations(std::span<fs::path> interfaces, std::span<fs::path> implementations); CRAFTER_API void GetInterfacesAndImplementations(std::span<fs::path> interfaces, std::span<fs::path> implementations);
// Declare a test. Sources default to `tests/<name>/main.cpp` resolved // Declare a test. Sources default to `tests/<name>/main.cpp` resolved
// against this Configuration's path; target/march/mtune/sysroot/debug // against this Configuration's path; target/march/mtune/sysroot/debug
@ -188,9 +269,14 @@ export namespace Crafter {
// `tests/<name>/main.cpp` source and the same interface set, each // `tests/<name>/main.cpp` source and the same interface set, each
// compiled with the tier's `-march`/`-mtune`. Test names are // compiled with the tier's `-march`/`-mtune`. Test names are
// `<name>-<march>`. // `<name>-<march>`.
CRAFTER_API void AddMarchVariants(std::string_view name, CRAFTER_API void AddMarchVariants(std::string_view name, std::span<fs::path> interfaces, std::span<const struct MarchTier> tiers);
std::span<fs::path> interfaces, // Register a lint rule for `crafter-build lint` / `format`. Rules
std::span<const struct MarchTier> tiers); // 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.
CRAFTER_API void AddLintRule(std::string name, std::function<void(LintContext&)> check);
// Suffix that uniquely identifies this Configuration's compile state. // Suffix that uniquely identifies this Configuration's compile state.
// target+march+mtune are spelled out for readability; the rest // target+march+mtune are spelled out for readability; the rest
// (type, debug, sysroot, defines, compileFlags) collapse into a short // (type, debug, sysroot, defines, compileFlags) collapse into a short
@ -198,7 +284,7 @@ export namespace Crafter {
// compile state can't clobber each other's outputs. // compile state can't clobber each other's outputs.
std::string VariantId() const { std::string VariantId() const {
std::string compileKey; std::string compileKey;
compileKey += std::to_string(static_cast<int>(type)); compileKey += std::to_string(static_cast<std::int32_t>(type));
compileKey += '|'; compileKey += '|';
compileKey += debug ? '1' : '0'; compileKey += debug ? '1' : '0';
compileKey += '|'; compileKey += '|';

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module; module;
#include "Crafter.Build-Api.h" #include "Crafter.Build-Api.h"
@ -44,10 +44,7 @@ export namespace Crafter {
fs::file_time_type latestArtifact = fs::file_time_type::min(); fs::file_time_type latestArtifact = fs::file_time_type::min();
}; };
CRAFTER_API ExternalBuildResult BuildExternal( CRAFTER_API ExternalBuildResult BuildExternal(const ExternalDependency& dep, std::string_view target, std::atomic<bool>& cancelled);
const ExternalDependency& dep,
std::string_view target,
std::atomic<bool>& cancelled);
// Specification for a sibling crafter-build project to fetch and depend on. // Specification for a sibling crafter-build project to fetch and depend on.
// GitSource picks the revision: leave branch + commit empty for the // GitSource picks the revision: leave branch + commit empty for the

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module; module;
#include "Crafter.Build-Api.h" #include "Crafter.Build-Api.h"

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module; module;
#include "Crafter.Build-Api.h" #include "Crafter.Build-Api.h"

View file

@ -0,0 +1,59 @@
// 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;
export namespace Crafter {
enum class LintMode {
// `lint`: diagnose only; transform output becomes would-reformat
// findings. Never writes. The default.
Report,
// `format --check`: dry run; record the files that would change.
Check,
// `format`: rewrite changed files in place.
Apply,
};
struct RunLintOptions {
// Rule-name globs ('*', '?'); empty = every registered rule.
std::vector<std::string> globs;
// Enumerate matching rule names without running them.
bool listOnly = false;
LintMode mode = LintMode::Report;
// Absolute path of the loaded project.cpp. It is linted too, and its
// parent directory is the project root that decides which dependency
// Configurations contribute rules/files (GitProject / cache-dir deps
// are foreign code and skipped). Empty => fall back to cfg.path.
std::filesystem::path projectFile;
};
struct LintSummary {
std::vector<LintFinding> findings; // sorted by (file, line); filled in every mode
// Apply: files rewritten on disk. Report/Check: files a transform
// would change. The `format` verb's exit code keys off this in Check
// mode; formatting files in Apply mode is success.
std::vector<std::filesystem::path> changedFiles;
std::size_t filesLinted = 0;
std::size_t rulesRun = 0; // rules remaining after glob filter
std::size_t errors = 0; // rule exceptions + write failures
bool noRulesDefined = false; // project registered no rules at all
// Host-side only (like TestSummary::AllPassed), safe as in-class inline.
bool Clean() const { return findings.empty() && !noRulesDefined; }
};
// Run the project's lint rules over its own sources: module interfaces
// (+ partitions), implementations, cFiles, cuda, shaders, declared tests'
// sources, and project.cpp itself — for the root Configuration plus every
// transitive dependency whose path lies inside the project root. Rules
// run in registration order per file, each seeing the previous rule's
// transform output; a rule that throws is reverted and surfaced as a
// finding + error. Only Apply mode writes to disk, and only files whose
// 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.
CRAFTER_API LintSummary RunLint(Configuration& projectCfg, const RunLintOptions& opts);
}

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module; module;
#include "Crafter.Build-Api.h" #include "Crafter.Build-Api.h"
@ -10,11 +10,11 @@ namespace fs = std::filesystem;
namespace Crafter { namespace Crafter {
struct Configuration; struct Configuration;
struct CommandResult { struct CommandResult {
int exitCode = 0; std::int32_t exitCode = 0;
std::string output; std::string output;
bool crashed = false; bool crashed = false;
bool timedOut = false; bool timedOut = false;
int signal = 0; std::int32_t signal = 0;
}; };
std::string BuildStdPcm(const Configuration& config, fs::path stdPcm); std::string BuildStdPcm(const Configuration& config, fs::path stdPcm);
fs::path GetCacheDir(); fs::path GetCacheDir();
@ -27,4 +27,8 @@ namespace Crafter {
// module sources, wasi-runtime/, etc). Honors CRAFTER_BUILD_HOME; otherwise // module sources, wasi-runtime/, etc). Honors CRAFTER_BUILD_HOME; otherwise
// derives <prefix>/share/crafter-build from the running executable's path. // derives <prefix>/share/crafter-build from the running executable's path.
export CRAFTER_API fs::path GetCrafterBuildHome(); export CRAFTER_API fs::path GetCrafterBuildHome();
// Wildcard name matching ('*', '?') shared by the test and lint verbs.
bool MatchGlob(std::string_view glob, std::string_view name);
// Empty `globs` matches everything.
bool MatchAny(std::span<const std::string> globs, std::string_view name);
} }

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module; module;
#include "Crafter.Build-Api.h" #include "Crafter.Build-Api.h"

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module; module;
#include "Crafter.Build-Api.h" #include "Crafter.Build-Api.h"

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module; module;
#include "Crafter.Build-Api.h" #include "Crafter.Build-Api.h"
@ -32,7 +32,7 @@ export namespace Crafter {
Configuration* parent; Configuration* parent;
std::size_t index; std::size_t index;
Test& test() const { return parent->tests[index]; } Test& Ref() const { return parent->tests[index]; }
// Override the path the test's sources resolve against. Defaults to // Override the path the test's sources resolve against. Defaults to
// "./" (project root, where tests/<name>/main.cpp lives). Override // "./" (project root, where tests/<name>/main.cpp lives). Override
@ -59,7 +59,7 @@ export namespace Crafter {
struct RunTestsOptions { struct RunTestsOptions {
std::vector<std::string> globs; std::vector<std::string> globs;
int jobs = 0; std::int32_t jobs = 0;
std::optional<std::chrono::seconds> timeoutOverride; std::optional<std::chrono::seconds> timeoutOverride;
bool listOnly = false; bool listOnly = false;
// Single-target run: only tests whose Configuration::target matches // Single-target run: only tests whose Configuration::target matches
@ -76,11 +76,11 @@ export namespace Crafter {
}; };
struct TestSummary { struct TestSummary {
int passed = 0; std::int32_t passed = 0;
int failed = 0; std::int32_t failed = 0;
int crashed = 0; std::int32_t crashed = 0;
int timedOut = 0; std::int32_t timedOut = 0;
int skipped = 0; std::int32_t skipped = 0;
std::vector<TestResult> results; std::vector<TestResult> results;
bool AllPassed() const { return failed == 0 && crashed == 0 && timedOut == 0; } bool AllPassed() const { return failed == 0 && crashed == 0 && timedOut == 0; }
}; };

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
export module Crafter.Build; export module Crafter.Build;
export import :Clang; export import :Clang;
@ -8,5 +8,6 @@ export import :Implementation;
export import :Shader; export import :Shader;
export import :External; export import :External;
export import :Test; export import :Test;
export import :Lint;
export import :Progress; export import :Progress;
export import :Asset; export import :Asset;

858
lint-rules.h Normal file
View file

@ -0,0 +1,858 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
#pragma once
// This repo's house-style lint rules. Deliberately project-local — the rule
// set is NOT part of the crafter-build library, so downstream consumers are
// never nudged toward one team's style. Include from project.cpp AFTER
// `import std;` and `import Crafter.Build;` (the header uses both and has no
// includes of its own), then call AddProjectLintRules(cfg).
//
// Report-only rules are registered first so their file:line numbers always
// match the on-disk file; transforms run last because each one sees the
// previous transform's output, which can shift line numbers.
//
// All detection runs on ctx.CommentStripped() — comments and string/char
// literal bodies are blanked to spaces with newlines preserved, so byte
// offsets and line numbers in the stripped text are valid in ctx.content.
// Transforms use that property directly: find in stripped, edit in content.
namespace ProjectLint {
inline bool IsCppFile(const Crafter::LintContext& ctx) {
std::string ext = ctx.file.extension().string();
return ext == ".cpp" || ext == ".cppm" || ext == ".h";
}
inline std::vector<std::string_view> Lines(std::string_view text) {
std::vector<std::string_view> lines;
std::size_t start = 0;
while (start <= text.size()) {
std::size_t end = text.find('\n', start);
if (end == std::string_view::npos) {
if (start < text.size()) lines.push_back(text.substr(start));
break;
}
lines.push_back(text.substr(start, end - start));
start = end + 1;
}
return lines;
}
inline std::string_view Trim(std::string_view s) {
while (!s.empty() && (s.front() == ' ' || s.front() == '\t' || s.front() == '\r')) s.remove_prefix(1);
while (!s.empty() && (s.back() == ' ' || s.back() == '\t' || s.back() == '\r')) s.remove_suffix(1);
return s;
}
inline bool IsWordChar(char c) {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_';
}
// Net '(' minus ')' on one stripped line.
inline std::int64_t ParenDelta(std::string_view s) {
std::int64_t d = 0;
for (char c : s) {
if (c == '(') ++d;
if (c == ')') --d;
}
return d;
}
inline bool IsPascalCase(std::string_view name) {
return !name.empty() && name[0] >= 'A' && name[0] <= 'Z' && !name.contains('_');
}
inline bool IsCamelCase(std::string_view name) {
// One trailing underscore is the member-shadowing-a-keyword convention
// (requires_, label_) — allowed.
if (name.ends_with('_')) name.remove_suffix(1);
return !name.empty() && ((name[0] >= 'a' && name[0] <= 'z') || name[0] == '_') && name.find('_', 1) == std::string_view::npos;
}
// The identifier ending right before position `pos` (exclusive), or empty.
inline std::string_view WordBefore(std::string_view s, std::size_t pos) {
std::size_t end = pos;
while (end > 0 && (s[end - 1] == ' ' || s[end - 1] == '\t')) --end;
std::size_t begin = end;
while (begin > 0 && (IsWordChar(s[begin - 1]) || s[begin - 1] == '~')) --begin;
return s.substr(begin, end - begin);
}
inline void AddProjectLintRules(Crafter::Configuration& cfg) {
using Crafter::LintContext;
// ---------------- report-only rules ----------------
// License header: SPDX identifier on line 1, copyright on line 2, blank
// line 3.
cfg.AddLintRule("spdx-header", [](LintContext& ctx) {
if (!IsCppFile(ctx)) return;
if (!ctx.Line(1).starts_with("// SPDX-License-Identifier:")) {
ctx.Report(1, "first line must be // SPDX-License-Identifier: ...");
}
if (!ctx.Line(2).starts_with("// SPDX-FileCopyrightText:")) {
ctx.Report(2, "second line must be // SPDX-FileCopyrightText: ...");
}
if (ctx.lines.size() >= 3 && !Trim(ctx.Line(3)).empty()) {
ctx.Report(3, "third line must be blank (separates the license header)");
}
});
cfg.AddLintRule("no-tabs", [](LintContext& ctx) {
if (!IsCppFile(ctx)) return;
for (std::size_t n = 1; n <= ctx.lines.size(); ++n) {
if (ctx.Line(n).contains('\t')) ctx.Report(n, "tab character (use spaces)");
}
});
// Naming: functions/types PascalCase, variables camelCase, statics and
// namespace-scope globals PascalCase. A line-based scope tracker decides
// whether a declaration sits at namespace scope (global) or inside a
// function/type (local/member). Heuristic by nature — report-only.
cfg.AddLintRule("naming", [](LintContext& ctx) {
if (!IsCppFile(ctx)) return;
std::vector<std::string_view> lines = Lines(ctx.CommentStripped());
enum class Scope { Namespace, Type, Function, Other };
std::vector<Scope> stack;
auto currentScope = [&]() { return stack.empty() ? Scope::Namespace : stack.back(); };
static const std::regex typeDecl(R"(\b(?:class|struct|union)\s+(?:CRAFTER_API\s+)?([A-Za-z_]\w*))");
static const std::regex enumDecl(R"(\benum\s+(?:class\s+|struct\s+)?([A-Za-z_]\w*))");
static const std::regex usingDecl(R"(^\s*using\s+([A-Za-z_]\w*)\s*=)");
static const std::regex varDecl(
R"(^\s*((?:static|constexpr|const|inline|mutable|thread_local|export|CRAFTER_API)\s+)*)"
R"((?:std::)?[A-Za-z_][\w:]*(?:<[^;={]*>)?(?:\s*[&*])*\s+([A-Za-z_]\w*)\s*(=|;|\{))");
static const std::unordered_set<std::string_view> keywords = {
"if", "for", "while", "switch", "catch", "return", "else", "do", "case", "goto",
"new", "delete", "throw", "using", "namespace", "template", "typedef", "friend",
"public", "private", "protected", "class", "struct", "enum", "union", "import",
"module", "export", "break", "continue", "co_return", "co_await", "co_yield",
"sizeof", "alignof", "decltype", "static_assert", "operator", "try", "requires",
};
// Cumulative paren depth at the start of each line: declaration and
// function checks only run at depth 0, so wrapped parameter lists and
// continuation lines (`) {` closers) never look like declarations.
std::int64_t parenDepth = 0;
for (std::size_t i = 0; i < lines.size(); ++i) {
std::string_view trimmed = Trim(lines[i]);
std::string lineStr(lines[i]);
std::smatch m;
bool atDepth0 = parenDepth == 0;
parenDepth = std::max<std::int64_t>(0, parenDepth + ParenDelta(lines[i]));
if (!trimmed.starts_with('#') && atDepth0) {
// Type / alias names must be PascalCase.
if (std::regex_search(lineStr, m, typeDecl) || std::regex_search(lineStr, m, enumDecl)) {
std::string name = m[1].str();
if (!keywords.contains(name) && !IsPascalCase(name)) {
ctx.Report(i + 1, std::format("type '{}' should be PascalCase", name));
}
}
if (std::regex_search(lineStr, m, usingDecl) && !IsPascalCase(m[1].str())) {
ctx.Report(i + 1, std::format("type alias '{}' should be PascalCase", m[1].str()));
}
// Function definitions: identifier before the first '(' on a
// line that ends where a body opens or closes — a multi-line
// def's trailing '{' or a one-liner's trailing '}'. Statement
// calls with inline lambda arguments look similar
// (`bool x = std::any_of(..., [](T v) { ... });`) but end in
// ';' and/or carry '=' before the name — both excluded.
// Lambdas ("](") and control keywords are skipped; ctors/
// dtors pass the Pascal check by construction; `main` and
// operators exempt.
std::size_t bodyBrace = trimmed.find('{');
bool functionShaped = trimmed.ends_with('{') || trimmed.ends_with('}');
if (functionShaped && currentScope() != Scope::Function && !trimmed.starts_with("return")) {
std::size_t paren = trimmed.find('(');
if (paren != std::string_view::npos && paren > 0 && paren < bodyBrace) {
std::string_view name = WordBefore(trimmed, paren);
char before = trimmed[paren - 1];
bool looksLikeDef = !name.empty() && IsWordChar(before)
&& !keywords.contains(name) && name != "main"
&& !name.starts_with('~');
// Require a type token before the name (or a
// qualified Class::Name), with no '=' in front —
// plain calls and initializations don't match.
if (looksLikeDef) {
std::size_t nameStart = trimmed.rfind(name, paren);
std::string_view prefix = Trim(trimmed.substr(0, nameStart));
looksLikeDef = !prefix.empty() && !prefix.contains('=')
&& (IsWordChar(prefix.back()) || prefix.back() == '>'
|| prefix.back() == '*' || prefix.back() == '&'
|| prefix.ends_with("::"));
}
if (looksLikeDef && !IsPascalCase(name)) {
ctx.Report(i + 1, std::format("function '{}' should be PascalCase", name));
}
}
}
// Variable declarations: camelCase locally, PascalCase for
// statics and namespace-scope globals.
if (std::regex_search(lineStr, m, varDecl)) {
std::string name = m[2].str();
std::string_view typeToken = Trim(std::string_view(lineStr).substr(0, static_cast<std::size_t>(m.position(2))));
std::string_view firstWord = typeToken.substr(0, typeToken.find_first_of(" \t<"));
if (!keywords.contains(firstWord) && !keywords.contains(name)) {
bool isStatic = lineStr.contains("static ");
// constexpr variables are compile-time constants —
// constant naming (PascalCase) like statics/globals.
bool isConstexpr = lineStr.contains("constexpr ");
bool global = currentScope() == Scope::Namespace;
if ((isStatic || global || isConstexpr) && !IsPascalCase(name)) {
ctx.Report(i + 1, std::format("{} '{}' should be PascalCase",
isStatic ? "static variable"
: isConstexpr ? "constexpr constant"
: "global variable", name));
} else if (!isStatic && !global && !isConstexpr && !IsCamelCase(name)) {
ctx.Report(i + 1, std::format("variable '{}' should be camelCase", name));
}
}
}
}
// Scope tracking: classify each '{' opened on this line; pop on '}'.
for (std::size_t c = 0; c < lines[i].size(); ++c) {
if (lines[i][c] == '{') {
Scope kind = Scope::Other;
std::string_view upTo = Trim(lines[i].substr(0, c));
if (trimmed.starts_with("namespace") || upTo.contains("namespace ")) {
kind = Scope::Namespace;
} else if (std::regex_search(lineStr, typeDecl) || std::regex_search(lineStr, enumDecl)) {
kind = Scope::Type;
} else if (upTo.ends_with(')') || upTo.ends_with("const") || upTo.ends_with("noexcept")
|| upTo.ends_with("->") || trimmed.starts_with("extern")) {
kind = Scope::Function; // function/lambda/control body — all non-global
}
stack.push_back(kind);
} else if (lines[i][c] == '}') {
if (!stack.empty()) stack.pop_back();
}
}
}
});
cfg.AddLintRule("enum-class", [](LintContext& ctx) {
if (!IsCppFile(ctx)) return;
std::vector<std::string_view> lines = Lines(ctx.CommentStripped());
static const std::regex plainEnum(R"(\benum\s+(?!class\b|struct\b)[A-Za-z_])");
for (std::size_t i = 0; i < lines.size(); ++i) {
std::string lineStr(lines[i]);
if (std::regex_search(lineStr, plainEnum)) {
ctx.Report(i + 1, "use enum class instead of plain enum");
}
}
});
cfg.AddLintRule("no-iostream-print", [](LintContext& ctx) {
if (!IsCppFile(ctx)) return;
std::vector<std::string_view> lines = Lines(ctx.CommentStripped());
for (std::size_t i = 0; i < lines.size(); ++i) {
if (lines[i].contains("std::cout")) {
ctx.Report(i + 1, "use std::println instead of std::cout");
} else if (lines[i].contains("std::cerr") && lines[i].contains("<<")) {
ctx.Report(i + 1, "use std::println(std::cerr, ...) instead of streaming to std::cerr");
}
}
});
// Prefer std::string / std::string_view over char*. OS interop stays:
// getenv returns char*, argv is char**, extern "C" prototypes mirror C.
cfg.AddLintRule("no-char-pointer", [](LintContext& ctx) {
if (!IsCppFile(ctx)) return;
std::vector<std::string_view> lines = Lines(ctx.CommentStripped());
static const std::regex charPtr(R"(\bchar\s*\*)");
for (std::size_t i = 0; i < lines.size(); ++i) {
std::string lineStr(lines[i]);
// C-interop stays char*: argv, getenv/setenv, dlerror, C APIs fed
// by c_str()/data(), binary IO reinterpret_casts, extern "C"
// prototypes. (extern " matches with the literal body blanked.)
if (lineStr.contains("argv") || lineStr.contains("getenv") || lineStr.contains("setenv")
|| lineStr.contains("dlerror") || lineStr.contains("c_str") || lineStr.contains(".data(")
|| lineStr.contains("reinterpret_cast") || lineStr.contains("extern \"")) continue;
if (std::regex_search(lineStr, charPtr)) {
ctx.Report(i + 1, "prefer std::string / std::string_view over char*");
}
}
});
// String building via `+` with a literal operand → std::format. Plain
// `a += b` accumulation (builder pattern) stays legal. Single-line chains
// of simple operands (identifier / member / call chains, parenthesized
// groups, literals) are REWRITTEN automatically; anything the operand
// scanner can't prove safe — raw-string lines, ternaries, mixed
// operators, multi-line expressions — is reported for a human instead.
cfg.AddLintRule("format-concat", [](LintContext& ctx) {
if (!IsCppFile(ctx)) return;
const std::string& code = ctx.CommentStripped();
std::vector<std::string_view> stripped = Lines(code);
// Walk one operand leftwards from `from` (exclusive). Returns the
// operand's begin, or npos when the shape isn't a simple postfix
// chain (then the whole chain bails to a report).
auto operandLeft = [](std::string_view s, std::size_t from) -> std::size_t {
std::size_t i = from;
while (i > 0 && (s[i - 1] == ' ' || s[i - 1] == '\t')) --i;
bool any = false;
for (;;) {
if (i == 0) break;
char c = s[i - 1];
if (c == ')' || c == ']') {
char open = c == ')' ? '(' : '[';
std::size_t depth = 0;
do {
--i;
if (s[i] == c) ++depth;
if (s[i] == open) --depth;
if (depth == 0) break;
} while (i > 0);
if (depth != 0) return std::string_view::npos;
any = true;
// A group directly preceded by an identifier (or another
// group) is a call/index postfix — keep consuming the
// callee: `path.string()` is ONE operand, not `()`.
if (i > 0 && (IsWordChar(s[i - 1]) || s[i - 1] == ')' || s[i - 1] == ']')) continue;
} else if (c == '"') {
--i; // closing quote; interior is blanked, find the opener
while (i > 0 && s[i - 1] != '"') --i;
if (i == 0) return std::string_view::npos;
--i;
any = true;
} else if (IsWordChar(c)) {
while (i > 0 && IsWordChar(s[i - 1])) --i;
any = true;
} else if (c == '.' && any) {
--i;
continue;
} else if (c == ':' && i > 1 && s[i - 2] == ':' && any) {
i -= 2;
continue;
} else if (c == '>' && i > 1 && s[i - 2] == '-' && any) {
i -= 2;
continue;
} else {
break;
}
// After a primary, only connectors continue the operand.
if (i > 0 && (s[i - 1] == '.' || (s[i - 1] == ':' && i > 1 && s[i - 2] == ':')
|| (s[i - 1] == '>' && i > 1 && s[i - 2] == '-'))) continue;
break;
}
if (!any) return std::string_view::npos;
// A unary operator, ternary, or other-precedence operator in
// front means expression structure we don't reason about — bail.
std::size_t b = i;
while (b > 0 && (s[b - 1] == ' ' || s[b - 1] == '\t')) --b;
if (b > 0 && std::string_view("!*&~-?:<>/%^|").contains(s[b - 1])) return std::string_view::npos;
return i;
};
// Walk one operand rightwards from `from` (inclusive). Returns one
// past the operand's end, or npos on bail.
auto operandRight = [](std::string_view s, std::size_t from) -> std::size_t {
std::size_t i = from;
while (i < s.size() && (s[i] == ' ' || s[i] == '\t')) ++i;
bool any = false;
for (;;) {
if (i >= s.size()) break;
char c = s[i];
if (c == '(' || c == '[') {
char close = c == '(' ? ')' : ']';
std::size_t depth = 0;
while (i < s.size()) {
if (s[i] == c) ++depth;
if (s[i] == close && --depth == 0) { ++i; break; }
++i;
}
if (depth != 0) return std::string_view::npos;
any = true;
} else if (c == '"') {
++i;
while (i < s.size() && s[i] != '"') ++i;
if (i >= s.size()) return std::string_view::npos;
++i;
any = true;
} else if (IsWordChar(c)) {
while (i < s.size() && IsWordChar(s[i])) ++i;
any = true;
} else if (any && c == '.') {
++i;
continue;
} else if (any && c == ':' && i + 1 < s.size() && s[i + 1] == ':') {
i += 2;
continue;
} else if (any && c == '-' && i + 1 < s.size() && s[i + 1] == '>') {
i += 2;
continue;
} else {
break;
}
if (i < s.size() && (s[i] == '(' || s[i] == '[' || s[i] == '.'
|| (s[i] == ':' && i + 1 < s.size() && s[i + 1] == ':')
|| (s[i] == '-' && i + 1 < s.size() && s[i + 1] == '>'))) continue;
break;
}
return any ? i : std::string_view::npos;
};
struct Edit { std::size_t begin; std::size_t end; std::string replacement; };
std::vector<Edit> edits; // offsets into ctx.content
std::size_t lineStart = 0;
for (std::size_t li = 0; li < stripped.size(); ++li) {
std::string_view line = stripped[li];
std::size_t lineOff = lineStart;
lineStart += line.size() + 1;
std::string_view trimmed = Trim(line);
if (trimmed.starts_with('#')) continue;
// Raw strings defeat the comment stripper's quote tracking; any
// literal-+ pattern on such a line is a manual fix.
bool hasRaw = std::string_view(ctx.content).substr(lineOff, line.size()).contains("R\"");
std::size_t searchFrom = 0;
while (searchFrom < line.size()) {
// A candidate `+` that isn't ++ / += and touches a literal.
std::size_t plus = line.find('+', searchFrom);
if (plus == std::string_view::npos) break;
searchFrom = plus + 1;
if (plus + 1 < line.size() && (line[plus + 1] == '+' || line[plus + 1] == '=')) { ++searchFrom; continue; }
if (plus > 0 && line[plus - 1] == '+') continue;
std::size_t leftEnd = plus;
while (leftEnd > 0 && (line[leftEnd - 1] == ' ' || line[leftEnd - 1] == '\t')) --leftEnd;
std::size_t rightBegin = plus + 1;
while (rightBegin < line.size() && (line[rightBegin] == ' ' || line[rightBegin] == '\t')) ++rightBegin;
bool literalAdjacent = (leftEnd > 0 && line[leftEnd - 1] == '"')
|| (rightBegin < line.size() && line[rightBegin] == '"');
if (!literalAdjacent) continue;
if (hasRaw) {
ctx.Report(li + 1, "use std::format instead of string concatenation with + (raw-string line, fix manually)");
break;
}
// Expand to the full chain: operands joined by `+`.
std::size_t chainBegin = operandLeft(line, plus);
std::size_t chainEnd = operandRight(line, plus + 1);
bool bail = chainBegin == std::string_view::npos || chainEnd == std::string_view::npos;
while (!bail) {
std::size_t b = chainBegin;
while (b > 0 && (line[b - 1] == ' ' || line[b - 1] == '\t')) --b;
if (b > 0 && line[b - 1] == '+' && !(b > 1 && line[b - 2] == '+')) {
std::size_t prev = operandLeft(line, b - 1);
if (prev == std::string_view::npos) { bail = true; break; }
chainBegin = prev;
} else break;
}
while (!bail) {
std::size_t e = chainEnd;
while (e < line.size() && (line[e] == ' ' || line[e] == '\t')) ++e;
if (e < line.size() && line[e] == '+' && !(e + 1 < line.size() && (line[e + 1] == '+' || line[e + 1] == '='))) {
std::size_t next = operandRight(line, e + 1);
if (next == std::string_view::npos) { bail = true; break; }
chainEnd = next;
} else break;
}
if (bail) {
ctx.Report(li + 1, "use std::format instead of string concatenation with + (not auto-fixable)");
break;
}
// Split the chain at depth-0 '+' into parts; literals feed the
// format string, everything else becomes an argument.
std::string_view chain = line.substr(chainBegin, chainEnd - chainBegin);
std::string_view rawChain = std::string_view(ctx.content).substr(lineOff + chainBegin, chainEnd - chainBegin);
std::string fmt;
std::vector<std::string_view> args;
std::size_t partBegin = 0;
std::int64_t depth = 0;
bool inLit = false;
for (std::size_t p = 0; p <= chain.size(); ++p) {
if (p < chain.size()) {
char c = chain[p];
if (c == '"') inLit = !inLit;
if (inLit) continue;
if (c == '(' || c == '[') ++depth;
if (c == ')' || c == ']') --depth;
if (!(c == '+' && depth == 0)) continue;
}
std::string_view part = Trim(chain.substr(partBegin, p - partBegin));
std::string_view rawPart = Trim(rawChain.substr(partBegin, p - partBegin));
if (part.starts_with('"') && part.ends_with('"') && part.size() >= 2
&& std::count(part.begin(), part.end(), '"') == 2) {
for (char c : rawPart.substr(1, rawPart.size() - 2)) {
fmt += c;
if (c == '{') fmt += '{';
if (c == '}') fmt += '}';
}
} else if (part.contains('"') && part.contains('+')) {
bail = true; // nested concat inside an operand — human territory
break;
} else {
fmt += "{}";
args.push_back(rawPart);
}
partBegin = p + 1;
}
if (bail || args.empty()) {
ctx.Report(li + 1, "use std::format instead of string concatenation with + (not auto-fixable)");
break;
}
std::string replacement = std::format("std::format(\"{}\"", fmt);
for (std::string_view a : args) replacement += std::format(", {}", a);
replacement += ")";
edits.push_back({lineOff + chainBegin, lineOff + chainEnd, std::move(replacement)});
searchFrom = chainEnd;
}
}
if (edits.empty()) return;
std::string out = ctx.content;
std::sort(edits.begin(), edits.end(), [](const Edit& a, const Edit& b) { return a.begin > b.begin; });
for (const Edit& e : edits) out.replace(e.begin, e.end - e.begin, e.replacement);
ctx.SetContent(std::move(out));
});
// ---------------- transforms (auto-fixed by `crafter-build format`) ----------------
// short/int/long → fixed-width types. Lines mentioning main/argc/argv or
// extern "C" keep their C-conventional ints.
cfg.AddLintRule("fixed-width-types", [](LintContext& ctx) {
if (!IsCppFile(ctx)) return;
const std::string& code = ctx.CommentStripped();
struct Rep { std::size_t pos; std::size_t len; std::string to; };
std::vector<Rep> reps;
// Builtin integer type specifiers combine in any order (`unsigned
// long`, `long unsigned int`, ...), so match whole RUNS of these
// keywords and classify the run, rather than the words one by one.
static const std::unordered_set<std::string_view> IntWords = {
"unsigned", "signed", "short", "long", "int", "char",
};
std::size_t lineStart = 0;
while (lineStart <= code.size()) {
std::size_t lineEnd = code.find('\n', lineStart);
if (lineEnd == std::string::npos) lineEnd = code.size();
std::string_view line(code.data() + lineStart, lineEnd - lineStart);
// `int main` / argc / argv keep their C-conventional type; so do
// extern "C" prototypes (the literal body is blanked in the
// stripped text, so match `extern "`).
bool exempt = line.contains("int main") || line.contains("argc") || line.contains("argv")
|| line.contains("extern \"");
std::size_t pos = 0;
while (!exempt && pos < line.size()) {
if (!IsWordChar(line[pos])) { ++pos; continue; }
std::size_t wordEnd = pos;
while (wordEnd < line.size() && IsWordChar(line[wordEnd])) ++wordEnd;
if (!IntWords.contains(line.substr(pos, wordEnd - pos))) { pos = wordEnd; continue; }
// Extend the run over consecutive specifier keywords.
std::size_t runBegin = pos;
std::size_t runEnd = wordEnd;
bool hasUnsigned = false;
bool hasSigned = false;
bool hasShort = false;
bool hasChar = false;
bool hasLong = false;
std::string_view nextWord;
for (;;) {
std::string_view word = line.substr(pos, wordEnd - pos);
if (word == "unsigned") hasUnsigned = true;
else if (word == "signed") hasSigned = true;
else if (word == "short") hasShort = true;
else if (word == "char") hasChar = true;
else if (word == "long") hasLong = true;
runEnd = wordEnd;
pos = wordEnd;
while (pos < line.size() && (line[pos] == ' ' || line[pos] == '\t')) ++pos;
wordEnd = pos;
while (wordEnd < line.size() && IsWordChar(line[wordEnd])) ++wordEnd;
nextWord = line.substr(pos, wordEnd - pos);
if (!IntWords.contains(nextWord)) break;
}
pos = runEnd;
// Bare `char` is text, not an integer — only signed/unsigned
// char is byte arithmetic. `long double` is a floating type.
if (hasChar && !hasUnsigned && !hasSigned) continue;
if (nextWord == "double") continue;
std::string_view width = hasChar ? "8" : hasShort ? "16" : hasLong ? "64" : "32";
reps.push_back({lineStart + runBegin, runEnd - runBegin,
std::format("std::{}int{}_t", hasUnsigned ? "u" : "", width)});
}
lineStart = lineEnd + 1;
}
if (reps.empty()) return;
std::string out = ctx.content;
std::sort(reps.begin(), reps.end(), [](const Rep& a, const Rep& b) { return a.pos > b.pos; });
for (const Rep& r : reps) out.replace(r.pos, r.len, r.to);
ctx.SetContent(std::move(out));
});
// One declaration per statement. Auto-splits the simple initialized form
// `Type a = x, b = y;`; anything with pointers/references, parens, or
// templates in the declarators is only reported (splitting `int* a, b;`
// would change b's type).
cfg.AddLintRule("single-declaration", [](LintContext& ctx) {
if (!IsCppFile(ctx)) return;
std::vector<std::string_view> stripped = Lines(ctx.CommentStripped());
// The declarator char class excludes quotes: string-literal bodies are
// blanked in the stripped text, so reconstructing them would corrupt
// the file — those lines are left alone.
static const std::regex simpleMulti(
R"(^(\s*)((?:std::)?[A-Za-z_][\w:]*)\s+([A-Za-z_]\w*\s*=\s*[^,;()<>*&"]+(?:,\s*[A-Za-z_]\w*\s*=\s*[^,;()<>*&"]+)+);\s*$)");
std::string out;
bool changed = false;
for (std::size_t i = 0; i < stripped.size(); ++i) {
std::string lineStr(stripped[i]);
std::smatch m;
std::string_view raw = i + 1 <= ctx.lines.size() ? ctx.Line(i + 1) : std::string_view{};
if (!raw.contains("//") && !ctx.Suppressed("single-declaration", i + 1)
&& std::regex_match(lineStr, m, simpleMulti)) {
std::string indent = m[1].str(), type = m[2].str(), decls = m[3].str();
std::size_t start = 0;
bool first = true;
while (start < decls.size()) {
std::size_t comma = decls.find(',', start);
std::string_view d = Trim(std::string_view(decls).substr(start, comma == std::string::npos ? std::string::npos : comma - start));
if (!first) out += '\n';
out += std::format("{}{} {};", indent, type, d);
first = false;
if (comma == std::string::npos) break;
start = comma + 1;
}
changed = true;
} else {
out += raw;
}
if (i + 1 < stripped.size() || ctx.content.ends_with('\n')) out += '\n';
}
if (changed) ctx.SetContent(std::move(out));
});
// K&R braces: `{` on its own line after a `)`/else/do/try header joins
// onto the header line. Standalone scope blocks (previous line ends with
// `;`, `{`, a comment, …) are intentional and stay.
cfg.AddLintRule("brace-style", [](LintContext& ctx) {
if (!IsCppFile(ctx)) return;
std::vector<std::string_view> stripped = Lines(ctx.CommentStripped());
std::vector<std::string> outLines;
outLines.reserve(ctx.lines.size());
bool changed = false;
for (std::size_t i = 0; i < ctx.lines.size(); ++i) {
std::string_view trimmed = Trim(stripped[i]);
if (trimmed == "{" && !outLines.empty()) {
std::string_view prevTrim = i > 0 ? Trim(stripped[i - 1]) : std::string_view{};
bool headerBefore = prevTrim.ends_with(')') || prevTrim == "else" || prevTrim == "do" || prevTrim == "try";
bool hasComment = ctx.Line(i + 1).contains("//") || (i > 0 && ctx.Line(i).contains("//"));
bool suppressed = ctx.Suppressed("brace-style", i) || ctx.Suppressed("brace-style", i + 1);
if (headerBefore && !hasComment && !suppressed) {
std::string& prev = outLines.back();
while (!prev.empty() && (prev.back() == ' ' || prev.back() == '\t')) prev.pop_back();
prev += " {";
changed = true;
continue;
}
}
outLines.emplace_back(ctx.Line(i + 1));
}
if (!changed) return;
std::string out;
for (std::size_t i = 0; i < outLines.size(); ++i) {
out += outLines[i];
if (i + 1 < outLines.size() || ctx.content.ends_with('\n')) out += '\n';
}
ctx.SetContent(std::move(out));
});
// Single-statement if bodies join onto the if line: `if (x)\n y;` →
// `if (x) y;` — when the condition's parens balance on one line, the body
// is one `;`-terminated statement, neither line carries a comment, and
// the joined line stays ≤ 250 columns.
cfg.AddLintRule("if-single-line", [](LintContext& ctx) {
if (!IsCppFile(ctx)) return;
std::vector<std::string_view> stripped = Lines(ctx.CommentStripped());
static const std::regex ifHeader(R"(^\s*(?:\}?\s*else\s+)?if\s*\(.*\)\s*$)");
std::vector<std::string> outLines;
bool changed = false;
for (std::size_t i = 0; i < ctx.lines.size(); ++i) {
std::string lineStr(stripped[i]);
if (i + 1 < ctx.lines.size() && std::regex_match(lineStr, ifHeader) && ParenDelta(stripped[i]) == 0) {
std::string_view body = Trim(stripped[i + 1]);
bool joinable = !body.empty() && body != "{" && !body.starts_with("if") && body.ends_with(';')
&& !ctx.Line(i + 1).contains("//") && !ctx.Line(i + 2).contains("//")
&& !ctx.Suppressed("if-single-line", i + 1) && !ctx.Suppressed("if-single-line", i + 2);
std::string joined = std::string(ctx.Line(i + 1));
while (!joined.empty() && (joined.back() == ' ' || joined.back() == '\t')) joined.pop_back();
joined += " ";
joined += Trim(ctx.Line(i + 2));
if (joinable && joined.size() <= 250) {
outLines.push_back(std::move(joined));
++i; // consume the body line
changed = true;
continue;
}
}
outLines.emplace_back(ctx.Line(i + 1));
}
if (!changed) return;
std::string out;
for (std::size_t i = 0; i < outLines.size(); ++i) {
out += outLines[i];
if (i + 1 < outLines.size() || ctx.content.ends_with('\n')) out += '\n';
}
ctx.SetContent(std::move(out));
});
// Wrapped call arguments join back onto one line when the whole
// expression fits in 250 columns. Lambda bodies (lines ending `{`),
// comments, raw strings, and preprocessor lines are left alone.
// Operator-style continuations (next line starts with && / || / |) join
// under the same length limit.
cfg.AddLintRule("wrap-join", [](LintContext& ctx) {
if (!IsCppFile(ctx)) return;
// Fixpoint loop: a join can enable another (joining an inner paren
// wrap balances the line an operator continuation hangs off), so one
// pass is not idempotent. Iterate until a pass changes nothing; the
// cap is a safety net — joins strictly reduce the line count, so
// termination is guaranteed anyway.
for (std::int32_t pass = 0; pass < 16; ++pass) {
std::vector<std::string_view> stripped = Lines(ctx.CommentStripped());
std::vector<std::string> outLines;
bool changed = false;
for (std::size_t i = 0; i < ctx.lines.size(); ++i) {
std::string_view trimmed = Trim(stripped[i]);
std::int64_t delta = ParenDelta(stripped[i]);
bool candidate = delta > 0 && !trimmed.empty() && !trimmed.starts_with('#')
&& !trimmed.ends_with('{') && !ctx.Line(i + 1).contains("//") && !ctx.Line(i + 1).contains("R\"");
if (candidate) {
std::string joined(ctx.Line(i + 1));
std::size_t j = i + 1;
bool ok = true;
while (delta > 0 && j < ctx.lines.size() && j - i <= 4) {
std::string_view next = Trim(stripped[j]);
// A `{`-ending line is fine when it closes the expression
// (a control header's `) {`); mid-expression it means a
// lambda body starts — leave those wrapped.
bool closes = delta + ParenDelta(stripped[j]) <= 0;
if (next.empty() || (next.ends_with('{') && !closes)
|| ctx.Line(j + 1).contains("//") || ctx.Line(j + 1).contains("R\"")) {
ok = false;
break;
}
while (!joined.empty() && (joined.back() == ' ' || joined.back() == '\t')) joined.pop_back();
std::string_view fragment = Trim(ctx.Line(j + 1));
// No separator right after an opening paren or before a
// closing one — joining must not manufacture `( x` / `x )`.
if (!joined.ends_with('(') && !fragment.starts_with(')') && !fragment.starts_with(',')) joined += " ";
joined += fragment;
delta += ParenDelta(stripped[j]);
++j;
}
for (std::size_t l = i + 1; ok && l <= j; ++l) ok = !ctx.Suppressed("wrap-join", l);
if (ok && delta <= 0 && joined.size() <= 250) {
outLines.push_back(std::move(joined));
i = j - 1; // consumed through line j-1 (0-based i)
changed = true;
continue;
}
} else if (delta == 0 && i + 1 < ctx.lines.size() && !trimmed.starts_with('#')
&& !ctx.Line(i + 1).contains("//") && !ctx.Line(i + 1).contains("R\"")) {
auto isOpStart = [](std::string_view s) {
return s.starts_with("&&") || s.starts_with("||") || s.starts_with("| ");
};
// Operator-style continuation chain (`a\n && b\n && c`).
// Joined when the WHOLE chain stays within 250 columns —
// longer conditions legitimately wrap. Same mechanics as the
// paren join: chain lines must be comment-free, raw-string
// free, and paren-balanced.
if (isOpStart(Trim(stripped[i + 1])) && !isOpStart(trimmed)) {
std::string joined(ctx.Line(i + 1));
std::size_t j = i + 1;
bool ok = true;
while (j < ctx.lines.size() && isOpStart(Trim(stripped[j]))) {
if (ParenDelta(stripped[j]) != 0 || ctx.Line(j + 1).contains("//") || ctx.Line(j + 1).contains("R\"")) {
ok = false;
break;
}
while (!joined.empty() && (joined.back() == ' ' || joined.back() == '\t')) joined.pop_back();
joined += " ";
joined += Trim(ctx.Line(j + 1));
++j;
}
for (std::size_t l = i + 1; ok && l <= j; ++l) ok = !ctx.Suppressed("wrap-join", l);
if (ok && joined.size() <= 250) {
outLines.push_back(std::move(joined));
i = j - 1; // consumed the chain
changed = true;
continue;
}
}
}
outLines.emplace_back(ctx.Line(i + 1));
}
if (!changed) return;
std::string out;
for (std::size_t i = 0; i < outLines.size(); ++i) {
out += outLines[i];
if (i + 1 < outLines.size() || ctx.content.ends_with('\n')) out += '\n';
}
ctx.SetContent(std::move(out));
}
});
// No space padding inside parens: `( x` / `x )` → `(x` / `x)`. Only
// intra-line (a line legitimately ends with '(' when a call wraps);
// string/comment interiors are excluded via the stripped text.
cfg.AddLintRule("paren-spacing", [](LintContext& ctx) {
if (!IsCppFile(ctx)) return;
const std::string& code = ctx.CommentStripped();
std::vector<std::pair<std::size_t, std::size_t>> cuts; // [begin, end) spans of spaces to delete
for (std::size_t i = 0; i < code.size(); ++i) {
if (code[i] == '(' ) {
std::size_t j = i + 1;
while (j < code.size() && code[j] == ' ') ++j;
if (j > i + 1 && j < code.size() && code[j] != '\n' && code[j] != '\r') cuts.push_back({i + 1, j});
} else if (code[i] == ')') {
std::size_t j = i;
while (j > 0 && code[j - 1] == ' ') --j;
// Only single-space padding: multi-space runs are usually
// deliberate alignment columns.
if (j == i - 1 && j > 0 && code[j - 1] != '\n' && code[j - 1] != ',') cuts.push_back({j, i});
}
}
if (cuts.empty()) return;
std::string out = ctx.content;
for (auto it = cuts.rbegin(); it != cuts.rend(); ++it) out.erase(it->first, it->second - it->first);
ctx.SetContent(std::move(out));
});
cfg.AddLintRule("trim-trailing-ws", [](LintContext& ctx) {
if (!IsCppFile(ctx)) return;
std::string out;
out.reserve(ctx.content.size());
for (std::size_t n = 1; n <= ctx.lines.size(); ++n) {
std::string_view line = ctx.Line(n);
// Byte fidelity on CRLF files: peel the \r, trim, put it back.
bool crlf = line.ends_with('\r');
if (crlf) line.remove_suffix(1);
while (line.ends_with(' ') || line.ends_with('\t')) line.remove_suffix(1);
out += line;
if (crlf) out += '\r';
// The last line only had a newline if the file ended with one —
// preserve that byte exactly so this rule doesn't shadow
// final-newline.
if (n < ctx.lines.size() || ctx.content.ends_with('\n')) out += '\n';
}
ctx.SetContent(std::move(out));
});
cfg.AddLintRule("final-newline", [](LintContext& ctx) {
if (!IsCppFile(ctx)) return;
if (!ctx.content.empty() && !ctx.content.ends_with('\n')) {
ctx.SetContent(ctx.content + '\n');
}
});
}
} // namespace ProjectLint

View file

@ -1,8 +1,9 @@
//SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
import std; import std;
import Crafter.Build; import Crafter.Build;
#include "lint-rules.h"
namespace fs = std::filesystem; namespace fs = std::filesystem;
using namespace Crafter; using namespace Crafter;
@ -17,22 +18,22 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
.args = depArgs, .args = depArgs,
}); });
static auto crafterBuildLib = std::make_unique<Configuration>(); static auto CrafterBuildLib = std::make_unique<Configuration>();
crafterBuildLib->path = "./"; CrafterBuildLib->path = "./";
crafterBuildLib->name = "crafter.build-lib"; CrafterBuildLib->name = "crafter.build-lib";
crafterBuildLib->outputName = "crafter-build"; CrafterBuildLib->outputName = "crafter-build";
ApplyStandardArgs(*crafterBuildLib, args); ApplyStandardArgs(*CrafterBuildLib, args);
// Windows builds (native msvc via build.cmd or cross-compiled mingw from // Windows builds (native msvc via build.cmd or cross-compiled mingw from
// Linux) need a DLL + import lib + launcher exe so LoadProject can // Linux) need a DLL + import lib + launcher exe so LoadProject can
// compile project.cpp against a stable ABI boundary. Linux is monolithic. // 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") CrafterBuildLib->type = (CrafterBuildLib->target == "x86_64-w64-mingw32" || CrafterBuildLib->target == "x86_64-pc-windows-msvc")
? ConfigurationType::LibraryDynamic ? ConfigurationType::LibraryDynamic
: ConfigurationType::LibraryStatic; : ConfigurationType::LibraryStatic;
crafterBuildLib->dependencies = { math, asset }; CrafterBuildLib->dependencies = { math, asset };
crafterBuildLib->defines.push_back({"CRAFTER_BUILD_HAS_ASSET", ""}); CrafterBuildLib->defines.push_back({"CRAFTER_BUILD_HAS_ASSET", ""});
{ {
std::array<fs::path, 10> interfaces = { std::array<fs::path, 11> interfaces = {
"interfaces/Crafter.Build", "interfaces/Crafter.Build",
"interfaces/Crafter.Build-Shader", "interfaces/Crafter.Build-Shader",
"interfaces/Crafter.Build-Platform", "interfaces/Crafter.Build-Platform",
@ -41,10 +42,11 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
"interfaces/Crafter.Build-External", "interfaces/Crafter.Build-External",
"interfaces/Crafter.Build-Clang", "interfaces/Crafter.Build-Clang",
"interfaces/Crafter.Build-Test", "interfaces/Crafter.Build-Test",
"interfaces/Crafter.Build-Lint",
"interfaces/Crafter.Build-Progress", "interfaces/Crafter.Build-Progress",
"interfaces/Crafter.Build-Asset", "interfaces/Crafter.Build-Asset",
}; };
std::array<fs::path, 9> implementations = { std::array<fs::path, 10> implementations = {
"implementations/Crafter.Build-Shader", "implementations/Crafter.Build-Shader",
"implementations/Crafter.Build-Platform", "implementations/Crafter.Build-Platform",
"implementations/Crafter.Build-Interface", "implementations/Crafter.Build-Interface",
@ -52,12 +54,13 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
"implementations/Crafter.Build-External", "implementations/Crafter.Build-External",
"implementations/Crafter.Build-Clang", "implementations/Crafter.Build-Clang",
"implementations/Crafter.Build-Test", "implementations/Crafter.Build-Test",
"implementations/Crafter.Build-Lint",
"implementations/Crafter.Build-Progress", "implementations/Crafter.Build-Progress",
"implementations/Crafter.Build-Asset", "implementations/Crafter.Build-Asset",
}; };
crafterBuildLib->GetInterfacesAndImplementations(interfaces, implementations); CrafterBuildLib->GetInterfacesAndImplementations(interfaces, implementations);
} }
ExternalDependency& glslang = crafterBuildLib->externalDependencies.emplace_back(); ExternalDependency& glslang = CrafterBuildLib->externalDependencies.emplace_back();
glslang.name = "glslang"; glslang.name = "glslang";
glslang.source.url = "https://github.com/KhronosGroup/glslang.git"; glslang.source.url = "https://github.com/KhronosGroup/glslang.git";
glslang.source.branch = "main"; glslang.source.branch = "main";
@ -66,7 +69,7 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
// mingw cross-build: skip the standalone executable. We only consume the // mingw cross-build: skip the standalone executable. We only consume the
// libraries, and glslang.exe pulls in libgcc_eh which needs pthread that // libraries, and glslang.exe pulls in libgcc_eh which needs pthread that
// mingw-w64 doesn't link by default. // mingw-w64 doesn't link by default.
if (crafterBuildLib->target == "x86_64-w64-mingw32") { if (CrafterBuildLib->target == "x86_64-w64-mingw32") {
glslang.options.push_back("-DENABLE_GLSLANG_BINARIES=OFF"); glslang.options.push_back("-DENABLE_GLSLANG_BINARIES=OFF");
} }
glslang.includeDirs = { "" }; glslang.includeDirs = { "" };
@ -78,7 +81,7 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
cfg.outputName = "crafter-build"; cfg.outputName = "crafter-build";
ApplyStandardArgs(cfg, args); ApplyStandardArgs(cfg, args);
cfg.type = ConfigurationType::Executable; cfg.type = ConfigurationType::Executable;
cfg.dependencies = { crafterBuildLib.get() }; cfg.dependencies = { CrafterBuildLib.get() };
{ {
std::array<fs::path, 0> interfaces = {}; std::array<fs::path, 0> interfaces = {};
std::array<fs::path, 1> implementations = { "implementations/main" }; std::array<fs::path, 1> implementations = { "implementations/main" };
@ -90,35 +93,42 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
} }
if (cfg.target == "x86_64-w64-mingw32" || cfg.target == "x86_64-pc-windows-msvc") { if (cfg.target == "x86_64-w64-mingw32" || cfg.target == "x86_64-pc-windows-msvc") {
// winsock for the -r wasm port probe (bind/WSAStartup). // winsock for the -r wasm port probe (bind/WSAStartup).
crafterBuildLib->linkFlags.push_back("-lws2_32"); CrafterBuildLib->linkFlags.push_back("-lws2_32");
} }
// Self-tests link the local crafter-build library and exercise it in // Self-tests link the local crafter-build library and exercise it in
// process. The harness (whichever crafter-build invokes `test`) compiles // process. The harness (whichever crafter-build invokes `test`) compiles
// these against the *installed* share/crafter-build .cppm files, then // these against the *installed* share/crafter-build .cppm files, then
// links each test exe against crafterBuildLib built from the local // links each test exe against CrafterBuildLib built from the local
// sources — so the code under test is whatever's in this checkout. // sources — so the code under test is whatever's in this checkout.
// Mirrors how downstream consumers link their own libraries into tests. // Mirrors how downstream consumers link their own libraries into tests.
if (cfg.target == "x86_64-pc-linux-gnu") { if (cfg.target == "x86_64-pc-linux-gnu") {
cfg.AddTest("HelloWorld").Dependencies({ crafterBuildLib.get() }); cfg.AddTest("HelloWorld").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("StaticLib").Dependencies({ crafterBuildLib.get() }); cfg.AddTest("StaticLib").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("ModuleInterface").Dependencies({ crafterBuildLib.get() }); cfg.AddTest("ModuleInterface").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("DependencyLink").Dependencies({ crafterBuildLib.get() }); cfg.AddTest("DependencyLink").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("ShaderCompile").Dependencies({ crafterBuildLib.get() }); cfg.AddTest("ShaderCompile").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("StandardArgs").Dependencies({ crafterBuildLib.get() }); cfg.AddTest("StandardArgs").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("TestRunnerSpec").Dependencies({ crafterBuildLib.get() }); cfg.AddTest("TestRunnerSpec").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("VariantId").Dependencies({ crafterBuildLib.get() }); cfg.AddTest("VariantId").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("WasiBrowserRuntime").Dependencies({ crafterBuildLib.get() }); cfg.AddTest("WasiBrowserRuntime").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("WasmVariants").Dependencies({ crafterBuildLib.get() }); cfg.AddTest("WasmVariants").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("RunSingleTestExit").Dependencies({ crafterBuildLib.get() }); cfg.AddTest("RunSingleTestExit").Dependencies({ CrafterBuildLib.get() });
// LoadProject dlopens the synthesized project.so, which references // LoadProject dlopens the synthesized project.so, which references
// Crafter:: symbols (HostTarget, Configuration ctors) that have to be // Crafter:: symbols (HostTarget, Configuration ctors) that have to be
// visible from the test exe — same wiring crafter-build itself uses // visible from the test exe — same wiring crafter-build itself uses
// for project.so. // for project.so.
cfg.AddTest("ConcurrentCacheRace").Dependencies({ crafterBuildLib.get() }) cfg.AddTest("ConcurrentCacheRace").Dependencies({ CrafterBuildLib.get() })
.LinkFlag("-Wl,--export-dynamic").LinkFlag("-ldl"); .LinkFlag("-Wl,--export-dynamic").LinkFlag("-ldl");
cfg.AddTest("ConcurrentDependencyReset").Dependencies({ crafterBuildLib.get() }); cfg.AddTest("ConcurrentDependencyReset").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("Lint").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("HouseRules").Dependencies({ CrafterBuildLib.get() });
} }
// Dogfood: this repo's house-style rules (see lint-rules.h). Report
// rules gate `crafter-build lint`; transform rules also auto-fix under
// `crafter-build format`.
ProjectLint::AddProjectLintRules(cfg);
return cfg; return cfg;
} }

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
#include <stdlib.h> #include <stdlib.h>
import std; import std;
@ -27,7 +27,7 @@ namespace {
if (const char* env = std::getenv("CRAFTER_BUILD_HOME"); env && *env) { if (const char* env = std::getenv("CRAFTER_BUILD_HOME"); env && *env) {
return env; return env;
} }
for (const char* candidate : { for (std::string_view candidate : {
"/usr/local/share/crafter-build", "/usr/local/share/crafter-build",
"/usr/share/crafter-build", "/usr/share/crafter-build",
}) { }) {
@ -47,8 +47,7 @@ namespace {
int main() { int main() {
fs::path home = FindCrafterBuildHome(); fs::path home = FindCrafterBuildHome();
if (home.empty()) { if (home.empty()) {
std::println(std::cerr, std::println(std::cerr, "SKIP: no installed share/crafter-build found and CRAFTER_BUILD_HOME unset");
"SKIP: no installed share/crafter-build found and CRAFTER_BUILD_HOME unset");
return 77; return 77;
} }
setenv("CRAFTER_BUILD_HOME", home.string().c_str(), 1); setenv("CRAFTER_BUILD_HOME", home.string().c_str(), 1);
@ -64,9 +63,9 @@ int main() {
fs::create_directories(cacheDir); fs::create_directories(cacheDir);
setenv("XDG_CACHE_HOME", cacheDir.string().c_str(), 1); setenv("XDG_CACHE_HOME", cacheDir.string().c_str(), 1);
constexpr int N = 4; constexpr std::int32_t N = 4;
std::vector<fs::path> projects; std::vector<fs::path> projects;
for (int i = 0; i < N; ++i) { for (std::int32_t i = 0; i < N; ++i) {
fs::path dir = scratch / std::format("p{}", i); fs::path dir = scratch / std::format("p{}", i);
fs::create_directories(dir); fs::create_directories(dir);
WriteFile(dir / "project.cpp", std::format( WriteFile(dir / "project.cpp", std::format(
@ -89,7 +88,7 @@ int main() {
std::array<std::string, N> errors; std::array<std::string, N> errors;
std::vector<std::thread> threads; std::vector<std::thread> threads;
threads.reserve(N); threads.reserve(N);
for (int i = 0; i < N; ++i) { for (std::int32_t i = 0; i < N; ++i) {
threads.emplace_back([&, i]() { threads.emplace_back([&, i]() {
try { try {
std::array<std::string_view, 0> args = {}; std::array<std::string_view, 0> args = {};
@ -101,8 +100,8 @@ int main() {
} }
for (std::thread& t : threads) t.join(); for (std::thread& t : threads) t.join();
int failures = 0; std::int32_t failures = 0;
for (int i = 0; i < N; ++i) { for (std::int32_t i = 0; i < N; ++i) {
if (!errors[i].empty()) { if (!errors[i].empty()) {
std::println(std::cerr, "FAIL p{}: {}", i, errors[i]); std::println(std::cerr, "FAIL p{}: {}", i, errors[i]);
++failures; ++failures;
@ -112,9 +111,7 @@ int main() {
fs::remove_all(scratch, ec); fs::remove_all(scratch, ec);
if (failures > 0) { if (failures > 0) {
std::println(std::cerr, std::println(std::cerr, "{}/{} concurrent LoadProject() invocations failed (host-cache race)", failures, N);
"{}/{} concurrent LoadProject() invocations failed (host-cache race)",
failures, N);
return 1; return 1;
} }
return 0; return 0;

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
import std; import std;
import DepMod; import DepMod;

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module DepMod; module DepMod;

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
export module DepMod; export module DepMod;
export int dep_value(); export int dep_value();

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
import std; import std;
import Crafter.Build; import Crafter.Build;
@ -101,10 +101,7 @@ int main() {
// reset the (cached, never-rebuilt) dependency's module flag. Before the // reset the (cached, never-rebuilt) dependency's module flag. Before the
// fix this was false and an intra-config waiter would have deadlocked. // fix this was false and an intra-config waiter would have deadlocked.
if (!dep.interfaces[0]->compiled.load()) { if (!dep.interfaces[0]->compiled.load()) {
std::println(std::cerr, std::println(std::cerr, "FAIL: consumer build reset a cached dependency's module 'compiled' " "flag (issue #16 regression) — this is the state that deadlocks a " "concurrent build");
"FAIL: consumer build reset a cached dependency's module 'compiled' "
"flag (issue #16 regression) — this is the state that deadlocks a "
"concurrent build");
return 1; return 1;
} }
@ -115,8 +112,7 @@ int main() {
} }
auto run = RunCommandWithTimeout(bin.string(), std::chrono::seconds(10)); auto run = RunCommandWithTimeout(bin.string(), std::chrono::seconds(10));
if (run.exitCode != 0 || run.timedOut || run.crashed) { if (run.exitCode != 0 || run.timedOut || run.crashed) {
std::println(std::cerr, "consumer exe did not exit cleanly: exit={} output={}", std::println(std::cerr, "consumer exe did not exit cleanly: exit={} output={}", run.exitCode, run.output);
run.exitCode, run.output);
return 1; return 1;
} }
if (run.output != "42") { if (run.output != "42") {

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
import std; import std;
import Calc; import Calc;

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
export module Calc; export module Calc;
import std; import std;

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
import std; import std;
import Crafter.Build; import Crafter.Build;
@ -44,18 +44,15 @@ int main() {
} }
// The import resolves to the dependency's interface, not a local one. // The import resolves to the dependency's interface, not a local one.
if (!app.implementations[0].moduleDependencies.empty()) { if (!app.implementations[0].moduleDependencies.empty()) {
std::println(std::cerr, "expected no local module deps, got {}", std::println(std::cerr, "expected no local module deps, got {}", app.implementations[0].moduleDependencies.size());
app.implementations[0].moduleDependencies.size());
return 1; return 1;
} }
if (app.implementations[0].externalModuleDependencies.size() != 1) { if (app.implementations[0].externalModuleDependencies.size() != 1) {
std::println(std::cerr, "expected 1 external module dep, got {}", std::println(std::cerr, "expected 1 external module dep, got {}", app.implementations[0].externalModuleDependencies.size());
app.implementations[0].externalModuleDependencies.size());
return 1; return 1;
} }
if (app.implementations[0].externalModuleDependencies[0].first->name != "Calc") { if (app.implementations[0].externalModuleDependencies[0].first->name != "Calc") {
std::println(std::cerr, "expected external dep 'Calc', got '{}'", std::println(std::cerr, "expected external dep 'Calc', got '{}'", app.implementations[0].externalModuleDependencies[0].first->name);
app.implementations[0].externalModuleDependencies[0].first->name);
return 1; return 1;
} }
@ -81,8 +78,7 @@ int main() {
auto run = RunCommandWithTimeout(bin.string(), std::chrono::seconds(10)); auto run = RunCommandWithTimeout(bin.string(), std::chrono::seconds(10));
if (run.exitCode != 0 || run.timedOut || run.crashed) { if (run.exitCode != 0 || run.timedOut || run.crashed) {
std::println(std::cerr, "exe did not exit cleanly: exit={} output={}", std::println(std::cerr, "exe did not exit cleanly: exit={} output={}", run.exitCode, run.output);
run.exitCode, run.output);
return 1; return 1;
} }
if (run.output != "7") { if (run.output != "7") {

View file

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

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
import std; import std;
import Crafter.Build; import Crafter.Build;

260
tests/HouseRules/main.cpp Normal file
View file

@ -0,0 +1,260 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
import std;
import Crafter.Build;
#include "../../lint-rules.h"
namespace fs = std::filesystem;
using namespace Crafter;
// Validates the house-style ruleset in lint-rules.h — the transforms and
// reports this repo (and sibling repos that copy the header) rely on. Each
// case writes a scratch file, runs ONE rule via the glob filter, and asserts
// the findings and/or rewritten bytes.
namespace {
std::int32_t Failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++Failures;
}
}
struct RuleRun {
LintSummary summary;
std::string text; // file content after the run
};
RuleRun RunRule(std::string_view content, std::string_view rule, LintMode mode) {
static std::int32_t Counter = 0;
fs::path dir = fs::temp_directory_path() / "crafter-build-house-rules" / std::format("case-{}", Counter++);
fs::remove_all(dir);
fs::create_directories(dir);
{
std::ofstream f(dir / "f.cpp", std::ios::binary | std::ios::trunc);
f.write(content.data(), static_cast<std::streamsize>(content.size()));
}
Configuration cfg;
cfg.path = dir;
cfg.name = "house-fixture";
cfg.outputName = "house-fixture";
cfg.target = HostTarget();
std::array<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = { "f" };
cfg.GetInterfacesAndImplementations(ifaces, impls);
ProjectLint::AddProjectLintRules(cfg);
RunLintOptions opts;
opts.mode = mode;
opts.globs = { std::string(rule) };
RuleRun run;
run.summary = RunLint(cfg, opts);
std::ifstream f(dir / "f.cpp", std::ios::binary);
std::stringstream buffer;
buffer << f.rdbuf();
run.text = std::move(buffer).str();
return run;
}
bool HasFinding(const LintSummary& s, std::string_view fragment) {
return std::any_of(s.findings.begin(), s.findings.end(), [&](const LintFinding& f) { return f.message.contains(fragment); });
}
}
int main() {
// fixed-width-types: int/long long convert; main/argc and extern "C" stay.
{
RuleRun r = RunRule("int Foo(long long v) { int x = 5; return x; }\n" "extern \"C\" int setenv(const char* n, const char* v, int o);\n" "int main(int argc, char** argv) { return 0; }\n", "fixed-width-types", LintMode::Apply);
Check(r.text.contains("std::int32_t Foo(std::int64_t v) { std::int32_t x = 5;"), "fixed-width converts int and long long");
Check(r.text.contains("extern \"C\" int setenv"), "extern \"C\" prototype keeps int");
Check(r.text.contains("int main(int argc"), "main/argc keep int");
}
// fixed-width-types is signed/unsigned aware, in any specifier order;
// bare char (text) and long double (floating) are not integers.
{
RuleRun r = RunRule("void F() {\n"
" unsigned a = 1;\n"
" unsigned int b = 2;\n"
" unsigned long long c = 3;\n"
" long unsigned int d = 4;\n"
" unsigned short e = 5;\n"
" signed f = 6;\n"
" signed char g = 7;\n"
" auto h = static_cast<unsigned char>(g);\n"
" char text = 'x';\n"
" long double pi = 3.14L;\n"
"}\n",
"fixed-width-types", LintMode::Apply);
Check(r.text.contains("std::uint32_t a = 1;"), "bare unsigned -> uint32");
Check(r.text.contains("std::uint32_t b = 2;"), "unsigned int -> uint32");
Check(r.text.contains("std::uint64_t c = 3;"), "unsigned long long -> uint64");
Check(r.text.contains("std::uint64_t d = 4;"), "long unsigned int (reordered) -> uint64");
Check(r.text.contains("std::uint16_t e = 5;"), "unsigned short -> uint16");
Check(r.text.contains("std::int32_t f = 6;"), "bare signed -> int32");
Check(r.text.contains("std::int8_t g = 7;"), "signed char -> int8");
Check(r.text.contains("static_cast<std::uint8_t>(g)"), "unsigned char -> uint8");
Check(r.text.contains("char text = 'x';"), "bare char stays (text, not an integer)");
Check(r.text.contains("long double pi = 3.14L;"), "long double stays (floating type)");
}
// brace-style: Allman brace joins; standalone scope block stays.
{
RuleRun r = RunRule("void Foo()\n{\n}\n\nvoid Bar() {\n Baz();\n {\n Qux();\n }\n}\n", "brace-style", LintMode::Apply);
Check(r.text.contains("void Foo() {"), "Allman brace joined onto header");
Check(r.text.contains("Baz();\n {"), "scope block brace untouched");
}
// if-single-line: short body joins; braced body stays.
{
RuleRun r = RunRule("void F(bool b) {\n if (b)\n Run();\n if (b) {\n Run();\n }\n}\n", "if-single-line", LintMode::Apply);
Check(r.text.contains("if (b) Run();"), "single-statement if body joined");
Check(r.text.contains("if (b) {"), "braced if body untouched");
}
// single-declaration: simple multi-decl splits; pointer decl only reports.
{
RuleRun r = RunRule("void F() {\n bool a = false, b = true;\n}\n", "single-declaration", LintMode::Apply);
Check(r.text.contains("bool a = false;\n bool b = true;"), "multi-declaration split");
}
// wrap-join: short wrapped call joins; operator chain joins; long stays.
{
RuleRun r = RunRule("void F() {\n G(alpha,\n beta);\n bool x = alpha\n && beta;\n}\n", "wrap-join", LintMode::Apply);
Check(r.text.contains("G(alpha, beta);"), "wrapped call arguments joined");
Check(r.text.contains("bool x = alpha && beta;"), "operator continuation joined");
}
// format-concat: literal-adjacent + rewrites; += RHS rewrites; args survive.
{
RuleRun r = RunRule("void F(std::string name, std::string cmd) {\n"
" std::string a = name + \".cpp\";\n"
" std::string b = \"pre-\" + name + \"-post\";\n"
" cmd += \" \" + name;\n"
" std::string keep = name + cmd;\n"
" fs::path p;\n"
" std::string c = p.stem().string() + \".pcm\";\n"
" fs::path d = std::string(cmd) + \".so\";\n"
"}\n",
"format-concat", LintMode::Apply);
Check(r.text.contains("std::string a = std::format(\"{}.cpp\", name);"), "trailing literal converts");
Check(!r.text.contains("namestd::format"), "replacement splices at the operand boundary");
Check(r.text.contains("std::string b = std::format(\"pre-{}-post\", name);"), "sandwich chain converts");
Check(r.text.contains("cmd += std::format(\" {}\", name);"), "+= RHS converts");
Check(r.text.contains("std::string keep = name + cmd;"), "literal-free + is left alone");
Check(r.text.contains("std::string c = std::format(\"{}.pcm\", p.stem().string());"), "call-chain left operand consumed whole (the stringstd regression)");
Check(r.text.contains("fs::path d = std::format(\"{}.so\", std::string(cmd));"), "constructor-call left operand consumed whole");
}
{
// Ternary and raw-string lines are reported, never rewritten.
RuleRun r = RunRule("std::string F(bool b, std::string n) { return b ? n + \".x\" : n; }\n", "format-concat", LintMode::Report);
Check(HasFinding(r.summary, "not auto-fixable"), "ternary concat reports instead of fixing");
Check(r.text.contains("n + \".x\""), "ternary concat untouched on disk");
}
// paren-spacing: single-space padding removed; wrapped call ends keep.
{
RuleRun r = RunRule("void F() {\n G( x );\n}\n", "paren-spacing", LintMode::Apply);
Check(r.text.contains("G(x);"), "paren padding removed");
}
// naming: wrong-case function/type/static/global flagged; camel local passes.
{
RuleRun r = RunRule("namespace {\n"
" std::int32_t bad_global = 0;\n"
" struct lint_thing {};\n"
"}\n"
"void lower_func() {\n"
" static bool lowerStatic = false;\n"
" std::int32_t fineLocal = 1;\n"
"}\n",
"naming", LintMode::Report);
Check(HasFinding(r.summary, "'bad_global' should be PascalCase"), "lowercase global flagged");
Check(HasFinding(r.summary, "'lint_thing' should be PascalCase"), "lowercase type flagged");
Check(HasFinding(r.summary, "'lower_func' should be PascalCase"), "lowercase function flagged");
Check(HasFinding(r.summary, "'lowerStatic' should be PascalCase"), "camel static flagged");
Check(!HasFinding(r.summary, "fineLocal"), "camelCase local passes");
}
{
// Statement calls with inline lambda arguments are NOT function
// definitions (the any_of regression); a genuine lowercase one-liner
// method still is.
RuleRun r = RunRule("struct Holder {\n"
" bool clean() const { return true; }\n"
"};\n"
"void T(std::vector<std::int32_t>& v) {\n"
" bool x = std::any_of(v.begin(), v.end(), [](std::int32_t n) { return n > 0; });\n"
" std::erase_if(v, [](std::int32_t n) { return n < 0; });\n"
"}\n",
"naming", LintMode::Report);
Check(!HasFinding(r.summary, "any_of"), "call with lambda argument is not a function definition");
Check(!HasFinding(r.summary, "erase_if"), "bare statement call is not a function definition");
Check(HasFinding(r.summary, "'clean' should be PascalCase"), "lowercase one-liner method still flagged");
}
// enum-class + no-iostream-print reports.
{
RuleRun r = RunRule("enum Color { Red };\n", "enum-class", LintMode::Report);
Check(HasFinding(r.summary, "enum class"), "plain enum flagged");
}
{
RuleRun r = RunRule("void F() { std::cout << 1; }\n", "no-iostream-print", LintMode::Report);
Check(HasFinding(r.summary, "std::println"), "std::cout flagged");
}
// The whole ruleset is idempotent: a second Apply changes nothing.
{
std::string_view source = "int Foo()\n{\n std::string s = std::string(\"a\") + \"b\";\n if (true)\n return 1;\n return 0;\n}\n";
static constexpr std::string_view AllRules = "*";
RuleRun first = RunRule(source, AllRules, LintMode::Apply);
RuleRun second = RunRule(first.text, AllRules, LintMode::Apply);
Check(second.summary.changedFiles.empty(), "second apply of the full ruleset is a no-op");
Check(first.text != source, "first apply actually changed the fixture");
}
// Suppression directives against house transforms: the driver reverts
// line-preserving edits (fixed-width) and the count-changing rules check
// Suppressed() themselves (wrap-join).
{
RuleRun r = RunRule("void F() {\n"
" // lint-disable-next-line fixed-width-types\n"
" int keep = 1;\n"
" int convert = 2;\n"
"}\n",
"fixed-width-types", LintMode::Apply);
Check(r.text.contains("int keep = 1;"), "next-line directive keeps the suppressed int");
Check(r.text.contains("std::int32_t convert = 2;"), "unsuppressed line still converts");
}
{
RuleRun r = RunRule("void F() {\n"
" // lint-disable-next-line wrap-join\n"
" G(alpha,\n"
" beta);\n"
" H(alpha,\n"
" beta);\n"
"}\n",
"wrap-join", LintMode::Apply);
Check(r.text.contains("G(alpha,\n"), "suppressed wrap stays wrapped");
Check(r.text.contains("H(alpha, beta);"), "unsuppressed wrap still joins");
}
// wrap-join converges in ONE run: joining the inner paren wrap balances
// the && line, which only then becomes an operator-joinable continuation.
{
RuleRun r = RunRule("void F() {\n"
" bool ok = alpha\n"
" && beta(gamma,\n"
" delta);\n"
"}\n",
"wrap-join", LintMode::Apply);
Check(r.text.contains("bool ok = alpha && beta(gamma, delta);"), "self-enabling joins reach the fixpoint in one apply");
RuleRun second = RunRule(r.text, "wrap-join", LintMode::Apply);
Check(second.summary.changedFiles.empty(), "wrap-join is idempotent after the fixpoint");
}
if (Failures > 0) {
std::println(std::cerr, "{} assertions failed", Failures);
return 1;
}
return 0;
}

View file

@ -0,0 +1,8 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// Fixture with no lint violations — rules asserting on dirty.cpp must not
// fire here.
int CleanAnswer() {
return 42;
}

View file

@ -0,0 +1,13 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// Fixture with deliberate violations at fixed line numbers —
// tests/Lint/main.cpp asserts on these exact lines. Do not reflow.
// MARKER inside a comment: must be invisible to CommentStripped() (line 7)
int DirtyTab() {
return 1; // deliberate tab indent (line 9)
}
const char* dirtyString = "MARKER inside a string literal (line 12)";
int MARKER_in_code = 13; // identifier survives CommentStripped() (line 13)

435
tests/Lint/main.cpp Normal file
View file

@ -0,0 +1,435 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
import std;
import Crafter.Build;
namespace fs = std::filesystem;
using namespace Crafter;
namespace {
std::int32_t Failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++Failures;
}
}
// Scratch-dir fixture for transform tests: Apply mode rewrites files, so
// these cases must never point at the checked-in fixture/. Each case
// writes its own sources into a fresh temp dir.
struct Scratch {
fs::path dir;
explicit Scratch(std::string_view name) {
dir = fs::temp_directory_path() / "crafter-build-lint-format" / name;
fs::remove_all(dir);
fs::create_directories(dir);
}
void Write(std::string_view stem, std::string_view content) const {
std::ofstream f(dir / std::format("{}.cpp", stem), std::ios::binary | std::ios::trunc);
f.write(content.data(), static_cast<std::streamsize>(content.size()));
}
std::string Read(std::string_view stem) const {
std::ifstream f(dir / std::format("{}.cpp", stem), std::ios::binary);
std::stringstream buffer;
buffer << f.rdbuf();
return std::move(buffer).str();
}
Configuration Config(std::vector<fs::path> impls) const {
Configuration cfg;
cfg.path = dir;
cfg.name = "fmt-fixture";
cfg.outputName = "fmt-fixture";
cfg.target = HostTarget();
std::array<fs::path, 0> ifaces = {};
cfg.GetInterfacesAndImplementations(ifaces, impls);
return cfg;
}
};
void AddTrimRule(Configuration& cfg) {
cfg.AddLintRule("trim", [](LintContext& ctx) {
std::string out;
out.reserve(ctx.content.size());
for (std::size_t n = 1; n <= ctx.lines.size(); ++n) {
std::string_view line = ctx.Line(n);
bool crlf = line.ends_with('\r');
if (crlf) line.remove_suffix(1);
while (line.ends_with(' ') || line.ends_with('\t')) line.remove_suffix(1);
out += line;
if (crlf) out += '\r';
if (n < ctx.lines.size() || ctx.content.ends_with('\n')) out += '\n';
}
ctx.SetContent(std::move(out));
});
}
RunLintOptions Mode(LintMode m) {
RunLintOptions opts;
opts.mode = m;
return opts;
}
// Fresh Configuration over the two fixture sources. Rebuilt per case so
// rule registrations don't leak between them.
Configuration FixtureConfig() {
Configuration cfg;
cfg.path = fs::current_path() / "tests" / "Lint" / "fixture";
cfg.name = "lint-fixture";
cfg.outputName = "lint-fixture";
cfg.target = HostTarget();
std::array<fs::path, 0> ifaces = {};
std::array<fs::path, 2> impls = { "clean", "dirty" };
cfg.GetInterfacesAndImplementations(ifaces, impls);
return cfg;
}
void AddTabRule(Configuration& cfg) {
cfg.AddLintRule("tab-rule", [](LintContext& ctx) {
for (std::size_t n = 1; n <= ctx.lines.size(); ++n) {
if (ctx.Line(n).contains('\t')) ctx.Report(n, "tab character");
}
});
}
}
// In-process tests for RunLint: rule registration, file collection over a
// Configuration, glob filtering, --list, comment stripping, and dedup. Lint
// only reads sources, so no Build() is needed.
int main() {
// No rules registered → noRulesDefined, not Clean.
{
Configuration cfg = FixtureConfig();
LintSummary s = RunLint(cfg, {});
Check(s.noRulesDefined, "no rules -> noRulesDefined");
Check(!s.Clean(), "no rules -> not Clean (exit 1)");
Check(s.findings.empty(), "no rules -> no findings");
}
// tab-rule fires on dirty.cpp line 9 only; never-fires stays silent.
{
Configuration cfg = FixtureConfig();
AddTabRule(cfg);
cfg.AddLintRule("never-fires", [](LintContext&) {});
LintSummary s = RunLint(cfg, {});
Check(s.rulesRun == 2, "both rules run");
Check(s.filesLinted == 2, "both fixture files linted");
Check(s.findings.size() == 1, "exactly one finding");
Check(!s.Clean(), "findings -> not Clean");
if (!s.findings.empty()) {
Check(s.findings[0].file.filename() == "dirty.cpp", "finding is in dirty.cpp");
Check(s.findings[0].line == 9, "tab reported at line 9");
Check(s.findings[0].rule == "tab-rule", "finding attributed to tab-rule");
}
}
// Glob filter drops tab-rule → clean run.
{
Configuration cfg = FixtureConfig();
AddTabRule(cfg);
cfg.AddLintRule("never-fires", [](LintContext&) {});
RunLintOptions opts;
opts.globs = { "never-*" };
LintSummary s = RunLint(cfg, opts);
Check(s.rulesRun == 1, "glob filters to one rule");
Check(s.findings.empty(), "filtered run has no findings");
Check(s.Clean(), "filtered run is Clean");
}
// listOnly enumerates without running any rule.
{
Configuration cfg = FixtureConfig();
AddTabRule(cfg);
RunLintOptions opts;
opts.listOnly = true;
LintSummary s = RunLint(cfg, opts);
Check(s.findings.empty(), "listOnly records no findings");
Check(s.filesLinted == 0, "listOnly reads no files");
Check(s.rulesRun == 1, "listOnly still counts matching rules");
}
// CommentStripped: MARKER in a comment (line 7) and in a string literal
// (line 12) are blanked; the identifier at line 13 survives, and line
// numbers computed from the stripped text match the real file.
{
Configuration cfg = FixtureConfig();
cfg.AddLintRule("marker", [](LintContext& ctx) {
const std::string& code = ctx.CommentStripped();
for (std::size_t pos = code.find("MARKER"); pos != std::string::npos;
pos = code.find("MARKER", pos + 1)) {
std::size_t line = 1 + std::count(code.begin(), code.begin() + pos, '\n');
ctx.Report(line, "MARKER in code");
}
});
LintSummary s = RunLint(cfg, {});
Check(s.findings.size() == 1, "only the code MARKER is found");
if (!s.findings.empty()) {
Check(s.findings[0].line == 13, "code MARKER reported at line 13");
Check(s.findings[0].file.filename() == "dirty.cpp", "MARKER finding is in dirty.cpp");
}
}
// Duplicate rule name: first registration wins, second never runs.
{
Configuration cfg = FixtureConfig();
AddTabRule(cfg);
cfg.AddLintRule("tab-rule", [](LintContext& ctx) {
ctx.Report(1, "duplicate ran");
});
LintSummary s = RunLint(cfg, {});
Check(s.rulesRun == 1, "duplicate name deduplicated");
Check(s.findings.size() == 1 && s.findings[0].line == 9, "only the first registration ran");
}
// A throwing rule surfaces as a finding, not an unwind.
{
Configuration cfg = FixtureConfig();
cfg.AddLintRule("throws", [](LintContext& ctx) {
if (ctx.file.filename() == "clean.cpp") throw std::runtime_error("boom");
});
LintSummary s = RunLint(cfg, {});
Check(s.findings.size() == 1, "exception becomes a finding");
if (!s.findings.empty()) {
Check(s.findings[0].message.contains("boom"), "finding carries the exception message");
Check(s.findings[0].line == 0, "exception finding is whole-file (line 0)");
}
}
// --- Transform (format) cases: scratch dir, never the checked-in fixture ---
// Report mode: transform diffs become would-reformat findings; disk untouched.
{
Scratch s("report");
s.Write("a", "hello \nworld\n");
Configuration cfg = s.Config({"a"});
AddTrimRule(cfg);
LintSummary sum = RunLint(cfg, Mode(LintMode::Report));
Check(sum.findings.size() == 1, "one would-reformat finding");
if (!sum.findings.empty()) {
Check(sum.findings[0].line == 1, "would-reformat at line 1");
Check(sum.findings[0].rule == "trim", "attributed to the transform rule");
Check(sum.findings[0].message == "would reformat", "derived message");
}
Check(sum.changedFiles.size() == 1, "changedFiles populated in Report mode");
Check(!sum.Clean(), "would-reformat gates lint");
Check(s.Read("a") == "hello \nworld\n", "Report mode never writes");
}
// Apply mode: disk rewritten; a sibling report-only rule's findings are
// still recorded (the verb, not the driver, ignores them).
{
Scratch s("apply");
s.Write("a", "hello \nworld\n");
Configuration cfg = s.Config({"a"});
AddTrimRule(cfg);
cfg.AddLintRule("note", [](LintContext& ctx) { ctx.Report(2, "note"); });
LintSummary sum = RunLint(cfg, Mode(LintMode::Apply));
Check(s.Read("a") == "hello\nworld\n", "Apply rewrites the file");
Check(sum.changedFiles.size() == 1, "one file formatted");
bool hasNote = std::any_of(sum.findings.begin(), sum.findings.end(), [](const LintFinding& f) { return f.rule == "note"; });
Check(hasNote, "report-only findings still recorded in Apply mode");
Check(sum.errors == 0, "clean apply has no errors");
}
// Check mode: reported, not written.
{
Scratch s("check");
s.Write("a", "hello \n");
Configuration cfg = s.Config({"a"});
AddTrimRule(cfg);
LintSummary sum = RunLint(cfg, Mode(LintMode::Check));
Check(sum.changedFiles.size() == 1, "Check records would-change file");
Check(!sum.findings.empty(), "Check records would-reformat findings");
Check(s.Read("a") == "hello \n", "Check mode never writes");
}
// Chaining: rule2 sees rule1's output; both attributed in Report mode.
{
Scratch s("chain");
s.Write("a", "AAA\n");
Configuration cfg = s.Config({"a"});
cfg.AddLintRule("one", [](LintContext& ctx) {
std::string c = ctx.content;
if (auto p = c.find("AAA"); p != std::string::npos) c.replace(p, 3, "BBB");
ctx.SetContent(std::move(c));
});
cfg.AddLintRule("two", [](LintContext& ctx) {
std::string c = ctx.content;
if (auto p = c.find("BBB"); p != std::string::npos) c.replace(p, 3, "CCC");
ctx.SetContent(std::move(c));
});
LintSummary rep = RunLint(cfg, Mode(LintMode::Report));
bool one = std::any_of(rep.findings.begin(), rep.findings.end(), [](const LintFinding& f) { return f.rule == "one"; });
bool two = std::any_of(rep.findings.begin(), rep.findings.end(), [](const LintFinding& f) { return f.rule == "two"; });
Check(one && two, "chained transforms both attributed");
Check(s.Read("a") == "AAA\n", "Report leaves chain input untouched");
LintSummary app = RunLint(cfg, Mode(LintMode::Apply));
Check(s.Read("a") == "CCC\n", "Apply composes chained transforms");
Check(app.changedFiles.size() == 1, "chain counts as one changed file");
}
// A throwing transform is reverted — half-applied content never lands.
{
Scratch s("throws");
s.Write("a", "keep\n");
Configuration cfg = s.Config({"a"});
cfg.AddLintRule("bad", [](LintContext& ctx) {
ctx.SetContent("garbage");
throw std::runtime_error("mid-transform");
});
LintSummary sum = RunLint(cfg, Mode(LintMode::Apply));
Check(s.Read("a") == "keep\n", "throwing transform reverted, disk untouched");
Check(sum.errors == 1, "exception counted as error");
Check(sum.changedFiles.empty(), "reverted transform is not a change");
bool threw = std::any_of(sum.findings.begin(), sum.findings.end(),
[](const LintFinding& f) {
return f.line == 0 && f.message.contains("mid-transform");
});
Check(threw, "exception surfaced as line-0 finding");
}
// CRLF byte fidelity + the final-newline whole-file (line 0) diff edge.
{
Scratch s("bytes");
s.Write("crlf", "x \r\ny\r\n");
s.Write("noeol", "a\nb");
Configuration cfg = s.Config({"crlf", "noeol"});
AddTrimRule(cfg);
cfg.AddLintRule("final-newline", [](LintContext& ctx) {
if (!ctx.content.empty() && !ctx.content.ends_with('\n')) {
ctx.SetContent(ctx.content + '\n');
}
});
LintSummary rep = RunLint(cfg, Mode(LintMode::Report));
bool wholeFile = std::any_of(rep.findings.begin(), rep.findings.end(),
[](const LintFinding& f) {
return f.rule == "final-newline" && f.line == 0;
});
Check(wholeFile, "missing final newline reports as whole-file finding");
RunLint(cfg, Mode(LintMode::Apply));
Check(s.Read("crlf") == "x\r\ny\r\n", "trim preserves CRLF endings");
Check(s.Read("noeol") == "a\nb\n", "final-newline appends exactly one newline");
}
// Mixed rule: Report() and SetContent() from the same rule.
{
Scratch s("mixed");
s.Write("a", "bad \n");
Configuration cfg = s.Config({"a"});
cfg.AddLintRule("mixed", [](LintContext& ctx) {
ctx.Report(1, "flagged");
std::string c = ctx.content;
if (auto p = c.find(' '); p != std::string::npos) c.erase(p, 1);
ctx.SetContent(std::move(c));
});
LintSummary rep = RunLint(cfg, Mode(LintMode::Report));
bool flagged = std::any_of(rep.findings.begin(), rep.findings.end(), [](const LintFinding& f) { return f.message == "flagged"; });
bool reformat = std::any_of(rep.findings.begin(), rep.findings.end(), [](const LintFinding& f) { return f.message == "would reformat"; });
Check(flagged && reformat, "mixed rule records both finding kinds");
RunLint(cfg, Mode(LintMode::Apply));
Check(s.Read("a") == "bad\n", "mixed rule's transform applied");
}
// Idempotency: a second Apply is a no-op; identical SetContent bytes are
// not a change (compare-by-value, not call-tracking).
{
Scratch s("idempotent");
s.Write("a", "hello \n");
Configuration cfg = s.Config({"a"});
AddTrimRule(cfg);
LintSummary first = RunLint(cfg, Mode(LintMode::Apply));
Check(first.changedFiles.size() == 1, "first apply changes the file");
LintSummary second = RunLint(cfg, Mode(LintMode::Apply));
Check(second.changedFiles.empty(), "second apply is a no-op");
Check(second.findings.empty(), "no-op apply has no findings");
}
// --- Suppression directives (engine-level) ---
// next-line directive with a rule name suppresses that finding only.
{
Scratch s("suppress-next-line");
s.Write("a", "// lint-disable-next-line flag\nbad\nbad\n");
Configuration cfg = s.Config({"a"});
cfg.AddLintRule("flag", [](LintContext& ctx) {
for (std::size_t n = 1; n <= ctx.lines.size(); ++n) {
if (ctx.Line(n).contains("bad")) ctx.Report(n, "bad");
}
});
LintSummary sum = RunLint(cfg, Mode(LintMode::Report));
Check(sum.findings.size() == 1, "next-line directive suppresses one finding");
if (!sum.findings.empty()) Check(sum.findings[0].line == 3, "the unsuppressed line still reports");
}
// next-line suppression also reverts a transform's edit on that line —
// `format` must not rewrite what lint is told to ignore.
{
Scratch s("suppress-transform");
s.Write("a", "// lint-disable-next-line trim\nkeep \ntrim \n");
Configuration cfg = s.Config({"a"});
AddTrimRule(cfg);
RunLint(cfg, Mode(LintMode::Apply));
Check(s.Read("a") == "// lint-disable-next-line trim\nkeep \ntrim\n",
"suppressed line keeps its bytes; the unsuppressed one is fixed");
}
// Multiple rule names on one directive (space- or comma-separated).
{
Scratch s("suppress-multi");
s.Write("a", "// lint-disable-next-line flagA, flagB\nbad \nbad \n");
Configuration cfg = s.Config({"a"});
cfg.AddLintRule("flagA", [](LintContext& ctx) {
for (std::size_t n = 1; n <= ctx.lines.size(); ++n) {
if (ctx.Line(n).contains("bad")) ctx.Report(n, "A");
}
});
cfg.AddLintRule("flagB", [](LintContext& ctx) {
for (std::size_t n = 1; n <= ctx.lines.size(); ++n) {
if (ctx.Line(n).contains("bad")) ctx.Report(n, "B");
}
});
AddTrimRule(cfg);
LintSummary sum = RunLint(cfg, Mode(LintMode::Report));
bool line2Silent = std::none_of(sum.findings.begin(), sum.findings.end(), [](const LintFinding& f) { return f.line == 2 && f.rule != "trim"; });
bool line3Loud = std::count_if(sum.findings.begin(), sum.findings.end(), [](const LintFinding& f) { return f.line == 3; }) >= 2;
Check(line2Silent, "both named rules suppressed on the target line");
bool trimStillFires = std::any_of(sum.findings.begin(), sum.findings.end(), [](const LintFinding& f) { return f.line == 2 && f.rule == "trim"; });
Check(trimStillFires, "unnamed rule still fires on the target line");
Check(line3Loud, "unsuppressed line reports from both rules");
}
// file-level all-rules directive silences findings and stops format.
{
Scratch s("suppress-file");
s.Write("a", "// lint-disable-file\nbad \n");
Configuration cfg = s.Config({"a"});
AddTrimRule(cfg);
cfg.AddLintRule("flag", [](LintContext& ctx) { ctx.Report(2, "bad"); });
LintSummary rep = RunLint(cfg, Mode(LintMode::Report));
Check(rep.findings.empty(), "file-level all directive suppresses every finding");
LintSummary app = RunLint(cfg, Mode(LintMode::Apply));
Check(app.changedFiles.empty(), "file-level all directive stops format");
Check(s.Read("a") == "// lint-disable-file\nbad \n", "file bytes untouched");
}
// file-level with a rule name: that rule is dead, others still act.
{
Scratch s("suppress-file-rule");
s.Write("a", "// lint-disable-file flag\nbad \n");
Configuration cfg = s.Config({"a"});
AddTrimRule(cfg);
cfg.AddLintRule("flag", [](LintContext& ctx) { ctx.Report(2, "bad"); });
LintSummary rep = RunLint(cfg, Mode(LintMode::Report));
bool onlyTrim = !rep.findings.empty() && std::all_of(rep.findings.begin(), rep.findings.end(), [](const LintFinding& f) { return f.rule == "trim"; });
Check(onlyTrim, "file-level rule directive kills that rule, trim still fires");
RunLint(cfg, Mode(LintMode::Apply));
Check(s.Read("a") == "// lint-disable-file flag\nbad\n", "other rules still format");
}
if (Failures > 0) {
std::println(std::cerr, "{} assertions failed", Failures);
return 1;
}
return 0;
}

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
export module Greeter; export module Greeter;
import std; import std;

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
import std; import std;
import Greeter; import Greeter;

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
import std; import std;
import Crafter.Build; import Crafter.Build;
@ -28,15 +28,11 @@ int main() {
return 1; return 1;
} }
if (cfg.implementations[0].moduleDependencies.size() != 1) { if (cfg.implementations[0].moduleDependencies.size() != 1) {
std::println(std::cerr, std::println(std::cerr, "expected main.cpp to depend on 1 module, got {}", cfg.implementations[0].moduleDependencies.size());
"expected main.cpp to depend on 1 module, got {}",
cfg.implementations[0].moduleDependencies.size());
return 1; return 1;
} }
if (cfg.implementations[0].moduleDependencies[0]->name != "Greeter") { if (cfg.implementations[0].moduleDependencies[0]->name != "Greeter") {
std::println(std::cerr, std::println(std::cerr, "expected dep 'Greeter', got '{}'", cfg.implementations[0].moduleDependencies[0]->name);
"expected dep 'Greeter', got '{}'",
cfg.implementations[0].moduleDependencies[0]->name);
return 1; return 1;
} }
@ -56,8 +52,7 @@ int main() {
auto run = RunCommandWithTimeout(bin.string(), std::chrono::seconds(10)); auto run = RunCommandWithTimeout(bin.string(), std::chrono::seconds(10));
if (run.exitCode != 0 || run.timedOut || run.crashed) { if (run.exitCode != 0 || run.timedOut || run.crashed) {
std::println(std::cerr, "binary did not exit cleanly: exit={} output={}", std::println(std::cerr, "binary did not exit cleanly: exit={} output={}", run.exitCode, run.output);
run.exitCode, run.output);
return 1; return 1;
} }
if (run.output != "ok-from-module") { if (run.output != "ok-from-module") {

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
import std; import std;
import Crafter.Build; import Crafter.Build;
@ -7,28 +7,24 @@ namespace fs = std::filesystem;
using namespace Crafter; using namespace Crafter;
namespace { namespace {
int failures = 0; std::int32_t Failures = 0;
void Check(bool cond, std::string_view msg) { void Check(bool cond, std::string_view msg) {
if (!cond) { if (!cond) {
std::println(std::cerr, "FAIL: {}", msg); std::println(std::cerr, "FAIL: {}", msg);
++failures; ++Failures;
} }
} }
// Write a tiny POSIX shell script that exits with `code` and return its // Write a tiny POSIX shell script that exits with `code` and return its
// path. The harness invokes `<binary> <args>` through the host shell, so // path. The harness invokes `<binary> <args>` through the host shell, so
// a #!/bin/sh script works as a stand-in for a real test binary. // a #!/bin/sh script works as a stand-in for a real test binary.
fs::path WriteExitScript(const fs::path& dir, std::string_view stem, int code) { fs::path WriteExitScript(const fs::path& dir, std::string_view stem, std::int32_t code) {
fs::path script = dir / stem; fs::path script = dir / stem;
std::ofstream out(script); std::ofstream out(script);
out << "#!/bin/sh\nexit " << code << "\n"; out << "#!/bin/sh\nexit " << code << "\n";
out.close(); out.close();
fs::permissions(script, fs::permissions(script, fs::perms::owner_read | fs::perms::owner_write | fs::perms::owner_exec | fs::perms::group_read | fs::perms::group_exec | fs::perms::others_read | fs::perms::others_exec, fs::perm_options::replace);
fs::perms::owner_read | fs::perms::owner_write | fs::perms::owner_exec
| fs::perms::group_read | fs::perms::group_exec
| fs::perms::others_read | fs::perms::others_exec,
fs::perm_options::replace);
return script; return script;
} }
} }
@ -82,8 +78,8 @@ int main() {
fs::remove_all(dir, ec); fs::remove_all(dir, ec);
if (failures > 0) { if (Failures > 0) {
std::println(std::cerr, "{} assertions failed", failures); std::println(std::cerr, "{} assertions failed", Failures);
return 1; return 1;
} }
return 0; return 0;

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
import std; import std;
import Crafter.Build; import Crafter.Build;

View file

@ -1,17 +1,17 @@
//SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
import std; import std;
import Crafter.Build; import Crafter.Build;
using namespace Crafter; using namespace Crafter;
namespace { namespace {
int failures = 0; std::int32_t Failures = 0;
void Check(bool cond, std::string_view msg) { void Check(bool cond, std::string_view msg) {
if (!cond) { if (!cond) {
std::println(std::cerr, "FAIL: {}", msg); std::println(std::cerr, "FAIL: {}", msg);
++failures; ++Failures;
} }
} }
} }
@ -83,8 +83,8 @@ int main() {
Check(!q.Get("--other=").has_value(), "ArgQuery::Get returns nullopt for absent prefix"); Check(!q.Get("--other=").has_value(), "ArgQuery::Get returns nullopt for absent prefix");
} }
if (failures > 0) { if (Failures > 0) {
std::println(std::cerr, "{} assertions failed", failures); std::println(std::cerr, "{} assertions failed", Failures);
return 1; return 1;
} }
return 0; return 0;

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
export module Greet; export module Greet;
import std; import std;

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
import std; import std;
import Crafter.Build; import Crafter.Build;

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
import std; import std;
import Crafter.Build; import Crafter.Build;
@ -13,12 +13,12 @@ extern "C" int setenv(const char* name, const char* value, int overwrite);
extern "C" int unsetenv(const char* name); extern "C" int unsetenv(const char* name);
namespace { namespace {
int failures = 0; std::int32_t Failures = 0;
void Check(bool cond, std::string_view msg) { void Check(bool cond, std::string_view msg) {
if (!cond) { if (!cond) {
std::println(std::cerr, "FAIL: {}", msg); std::println(std::cerr, "FAIL: {}", msg);
++failures; ++Failures;
} }
} }
} }
@ -84,8 +84,7 @@ int main() {
cfg.target = "aarch64-linux-gnu"; cfg.target = "aarch64-linux-gnu";
cfg.sysroot = "/opt/alarm-sysroot"; cfg.sysroot = "/opt/alarm-sysroot";
TestRunner r = TestRunner::ForTarget(cfg); TestRunner r = TestRunner::ForTarget(cfg);
Check(r.exec.find("QEMU_LD_PREFIX=/opt/alarm-sysroot") != std::string::npos, Check(r.exec.find("QEMU_LD_PREFIX=/opt/alarm-sysroot") != std::string::npos, "ForTarget propagates sysroot to QEMU_LD_PREFIX");
"ForTarget propagates sysroot to QEMU_LD_PREFIX");
} }
{ {
// From a Linux host the only sensible way to run x86_64-w64-mingw32 is wine. // From a Linux host the only sensible way to run x86_64-w64-mingw32 is wine.
@ -101,17 +100,17 @@ int main() {
{ {
// Use a unique fake triple so we don't stomp on a real env var the // Use a unique fake triple so we don't stomp on a real env var the
// CI/dev shell may have set. // CI/dev shell may have set.
const char* name = "CRAFTER_BUILD_RUNNER_fake_target_for_unit_test"; const std::string name = "CRAFTER_BUILD_RUNNER_fake_target_for_unit_test";
setenv(name, "cmd:fake-tool", 1); setenv(name.c_str(), "cmd:fake-tool", 1);
TestRunner r = TestRunner::FromEnv("fake-target-for-unit-test", TestRunner::Local()); TestRunner r = TestRunner::FromEnv("fake-target-for-unit-test", TestRunner::Local());
Check(r.name == "cmd:fake-tool", "FromEnv reads CRAFTER_BUILD_RUNNER_<target>"); Check(r.name == "cmd:fake-tool", "FromEnv reads CRAFTER_BUILD_RUNNER_<target>");
unsetenv(name); unsetenv(name.c_str());
TestRunner fallback = TestRunner::FromEnv("fake-target-for-unit-test", TestRunner::Local()); TestRunner fallback = TestRunner::FromEnv("fake-target-for-unit-test", TestRunner::Local());
Check(fallback.IsLocal(), "FromEnv falls back when env var is unset"); Check(fallback.IsLocal(), "FromEnv falls back when env var is unset");
} }
if (failures > 0) { if (Failures > 0) {
std::println(std::cerr, "{} assertions failed", failures); std::println(std::cerr, "{} assertions failed", Failures);
return 1; return 1;
} }
return 0; return 0;

View file

@ -1,17 +1,17 @@
//SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
import std; import std;
import Crafter.Build; import Crafter.Build;
using namespace Crafter; using namespace Crafter;
namespace { namespace {
int failures = 0; std::int32_t Failures = 0;
void Check(bool cond, std::string_view msg) { void Check(bool cond, std::string_view msg) {
if (!cond) { if (!cond) {
std::println(std::cerr, "FAIL: {}", msg); std::println(std::cerr, "FAIL: {}", msg);
++failures; ++Failures;
} }
} }
@ -115,8 +115,8 @@ int main() {
Check(a.BinDir() != b.BinDir(), "different VariantId yields different BinDir"); Check(a.BinDir() != b.BinDir(), "different VariantId yields different BinDir");
} }
if (failures > 0) { if (Failures > 0) {
std::println(std::cerr, "{} assertions failed", failures); std::println(std::cerr, "{} assertions failed", Failures);
return 1; return 1;
} }
return 0; return 0;

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
import std; import std;
import Crafter.Build; import Crafter.Build;
@ -41,9 +41,7 @@ int main() {
} else if (fs::exists(systemHome / "wasi-runtime" / "runtime.js")) { } else if (fs::exists(systemHome / "wasi-runtime" / "runtime.js")) {
setenv("CRAFTER_BUILD_HOME", systemHome.c_str(), 1); setenv("CRAFTER_BUILD_HOME", systemHome.c_str(), 1);
} else { } else {
std::println(std::cerr, std::println(std::cerr, "wasi-runtime assets not found near {} or {}", repoWasi.string(), systemHome.string());
"wasi-runtime assets not found near {} or {}",
repoWasi.string(), systemHome.string());
return 77; // skipped — environmental, not a code defect return 77; // skipped — environmental, not a code defect
} }

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
import std; import std;
import Crafter.Build; import Crafter.Build;
@ -15,9 +15,9 @@ extern "C" int setenv(const char* name, const char* value, int overwrite);
// actual multi-.wasm production is exercised by a real wasm build (manual / // actual multi-.wasm production is exercised by a real wasm build (manual /
// integration), not here — same boundary the WasiBrowserRuntime test draws. // integration), not here — same boundary the WasiBrowserRuntime test draws.
namespace { namespace {
int failures = 0; std::int32_t Failures = 0;
void Check(bool cond, std::string_view msg) { void Check(bool cond, std::string_view msg) {
if (!cond) { std::println(std::cerr, "FAIL: {}", msg); ++failures; } if (!cond) { std::println(std::cerr, "FAIL: {}", msg); ++Failures; }
} }
} }
@ -68,7 +68,7 @@ int main() {
setenv("CRAFTER_BUILD_HOME", systemHome.c_str(), 1); setenv("CRAFTER_BUILD_HOME", systemHome.c_str(), 1);
} else { } else {
std::println(std::cerr, "wasi-runtime assets not found; skipping manifest check"); std::println(std::cerr, "wasi-runtime assets not found; skipping manifest check");
return failures ? 1 : 77; return Failures ? 1 : 77;
} }
Configuration cfg; Configuration cfg;
@ -103,15 +103,14 @@ int main() {
// prefers it when probes pass. // prefers it when probes pass.
auto relaxedPos = j.find("relaxed-simd"); auto relaxedPos = j.find("relaxed-simd");
auto baselinePos = j.find("\"wvhelper.wasm\""); auto baselinePos = j.find("\"wvhelper.wasm\"");
Check(relaxedPos != std::string::npos && baselinePos != std::string::npos && relaxedPos < baselinePos, Check(relaxedPos != std::string::npos && baselinePos != std::string::npos && relaxedPos < baselinePos, "relaxed-simd entry precedes baseline");
"relaxed-simd entry precedes baseline");
} }
fs::remove_all(cfg.path, ec); fs::remove_all(cfg.path, ec);
} }
if (failures > 0) { if (Failures > 0) {
std::println(std::cerr, "{} assertions failed", failures); std::println(std::cerr, "{} assertions failed", Failures);
return 1; return 1;
} }
return 0; return 0;

View file

@ -1,5 +1,5 @@
//SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// Minimal browser WASI shim. Loaded by index.html, which sets // Minimal browser WASI shim. Loaded by index.html, which sets
// window.CRAFTER_WASM_URL before this script runs so a single runtime.js // window.CRAFTER_WASM_URL before this script runs so a single runtime.js