From 8892154b28bbade29ad90695a69d544b3a87aca7 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Thu, 23 Jul 2026 01:24:42 +0200 Subject: [PATCH] linting --- .forgejo/workflows/ci.yaml | 7 + README.md | 63 +- build.cmd | 7 + build.sh | 7 + examples/hello-world/main.cpp | 4 +- examples/hello-world/project.cpp | 4 +- examples/library/main.cpp | 4 +- examples/library/mylib/MyMath.cppm | 4 +- examples/library/project.cpp | 4 +- examples/tests/mylib/MyMath.cppm | 4 +- examples/tests/project.cpp | 4 +- examples/tests/tests/Smoke/main.cpp | 4 +- examples/tests/tests/UnitMyMath/main.cpp | 4 +- examples/wasi/main.cpp | 4 +- examples/wasi/project.cpp | 4 +- examples/with-module/interfaces/Greeter.cppm | 4 +- examples/with-module/main.cpp | 4 +- examples/with-module/project.cpp | 4 +- implementations/Crafter.Build-Asset.cpp | 23 +- implementations/Crafter.Build-Clang.cpp | 213 ++--- implementations/Crafter.Build-External.cpp | 61 +- .../Crafter.Build-Implementation.cpp | 8 +- implementations/Crafter.Build-Interface.cpp | 14 +- implementations/Crafter.Build-Lint.cpp | 444 +++++++++ implementations/Crafter.Build-Platform.cpp | 152 ++-- implementations/Crafter.Build-Progress.cpp | 104 ++- implementations/Crafter.Build-Shader.cpp | 10 +- implementations/Crafter.Build-Test.cpp | 112 +-- implementations/main.cpp | 4 +- interfaces/Crafter.Build-Api.h | 4 +- interfaces/Crafter.Build-Asset.cppm | 4 +- interfaces/Crafter.Build-Clang.cppm | 104 ++- interfaces/Crafter.Build-External.cppm | 9 +- interfaces/Crafter.Build-Implementation.cppm | 4 +- interfaces/Crafter.Build-Interface.cppm | 4 +- interfaces/Crafter.Build-Lint.cppm | 59 ++ interfaces/Crafter.Build-Platform.cppm | 14 +- interfaces/Crafter.Build-Progress.cppm | 4 +- interfaces/Crafter.Build-Shader.cppm | 4 +- interfaces/Crafter.Build-Test.cppm | 18 +- interfaces/Crafter.Build.cppm | 7 +- lint-rules.h | 858 ++++++++++++++++++ project.cpp | 74 +- tests/ConcurrentCacheRace/main.cpp | 23 +- .../fixture/app/main.cpp | 4 +- .../fixture/deplib/DepMod-impl.cpp | 4 +- .../fixture/deplib/DepMod.cppm | 4 +- tests/ConcurrentDependencyReset/main.cpp | 12 +- tests/DependencyLink/fixture/main.cpp | 4 +- .../DependencyLink/fixture/mathlib/Calc.cppm | 4 +- tests/DependencyLink/main.cpp | 16 +- tests/HelloWorld/fixture/main.cpp | 4 +- tests/HelloWorld/main.cpp | 4 +- tests/HouseRules/main.cpp | 260 ++++++ tests/Lint/fixture/clean.cpp | 8 + tests/Lint/fixture/dirty.cpp | 13 + tests/Lint/main.cpp | 435 +++++++++ tests/ModuleInterface/fixture/Greeter.cppm | 4 +- tests/ModuleInterface/fixture/main.cpp | 4 +- tests/ModuleInterface/main.cpp | 15 +- tests/RunSingleTestExit/main.cpp | 20 +- tests/ShaderCompile/main.cpp | 4 +- tests/StandardArgs/main.cpp | 12 +- tests/StaticLib/fixture/Greet.cppm | 4 +- tests/StaticLib/main.cpp | 4 +- tests/TestRunnerSpec/main.cpp | 21 +- tests/VariantId/main.cpp | 12 +- tests/WasiBrowserRuntime/main.cpp | 8 +- tests/WasmVariants/main.cpp | 17 +- wasi-runtime/runtime.js | 4 +- 70 files changed, 2780 insertions(+), 596 deletions(-) create mode 100644 implementations/Crafter.Build-Lint.cpp create mode 100644 interfaces/Crafter.Build-Lint.cppm create mode 100644 lint-rules.h create mode 100644 tests/HouseRules/main.cpp create mode 100644 tests/Lint/fixture/clean.cpp create mode 100644 tests/Lint/fixture/dirty.cpp create mode 100644 tests/Lint/main.cpp diff --git a/.forgejo/workflows/ci.yaml b/.forgejo/workflows/ci.yaml index fa695af..0c86de3 100644 --- a/.forgejo/workflows/ci.yaml +++ b/.forgejo/workflows/ci.yaml @@ -63,6 +63,13 @@ jobs: - name: Bootstrap (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 run: CRAFTER_BUILD_HOME=$PWD/share/crafter-build ./bin/crafter-build test diff --git a/README.md b/README.md index af2936a..09bea8b 100644 --- a/README.md +++ b/README.md @@ -245,6 +245,67 @@ extern "C" Configuration CrafterBuildProject(std::span) `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/) contains progressively larger self-contained projects: @@ -260,7 +321,7 @@ Each builds standalone: `cd examples/ && crafter-build`. ## 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. - **`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. diff --git a/build.cmd b/build.cmd index 707d0ab..a2f2fc5 100644 --- a/build.cmd +++ b/build.cmd @@ -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-Clang.cppm share\crafter-build\ copy /Y interfaces\Crafter.Build-Test.cppm share\crafter-build\ +copy /Y interfaces\Crafter.Build-Lint.cppm share\crafter-build\ copy /Y interfaces\Crafter.Build-Progress.cppm share\crafter-build\ copy /Y interfaces\Crafter.Build-Asset.cppm share\crafter-build\ copy /Y interfaces\Crafter.Build-Api.h share\crafter-build\ @@ -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-Clang.cppm -o .\build\Crafter.Build-Clang.o clang++ %common_options% -fmodule-output interfaces\Crafter.Build-Test.cppm -o .\build\Crafter.Build-Test.o +clang++ %common_options% -fmodule-output interfaces\Crafter.Build-Lint.cppm -o .\build\Crafter.Build-Lint.o clang++ %common_options% -fmodule-output interfaces\Crafter.Build-Progress.cppm -o .\build\Crafter.Build-Progress.o REM Asset partition: bootstrap compiles it WITHOUT CRAFTER_BUILD_HAS_ASSET so REM CompressAsset takes the stub branch (no Crafter.Asset PCM exists yet). @@ -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-Clang.cpp -o .\build\Crafter.Build-Clang_impl.o clang++ %common_options% .\implementations\Crafter.Build-Test.cpp -o .\build\Crafter.Build-Test_impl.o +clang++ %common_options% .\implementations\Crafter.Build-Lint.cpp -o .\build\Crafter.Build-Lint_impl.o clang++ %common_options% .\implementations\Crafter.Build-Progress.cpp -o .\build\Crafter.Build-Progress_impl.o clang++ %common_options% .\implementations\Crafter.Build-Asset.cpp -o .\build\Crafter.Build-Asset_impl.o clang++ %common_options% .\implementations\main.cpp -o .\build\main.o @@ -85,6 +88,7 @@ clang++ %useLibcLinker% -shared -std=c++26 -O3 -march=%CRAFTER_BUILD_MARCH% -mtu .\build\Crafter.Build-External.o ^ .\build\Crafter.Build-Clang.o ^ .\build\Crafter.Build-Test.o ^ + .\build\Crafter.Build-Lint.o ^ .\build\Crafter.Build-Progress.o ^ .\build\Crafter.Build-Asset.o ^ .\build\Crafter.Build.o ^ @@ -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-Clang_impl.o ^ .\build\Crafter.Build-Test_impl.o ^ + .\build\Crafter.Build-Lint_impl.o ^ .\build\Crafter.Build-Progress_impl.o ^ .\build\Crafter.Build-Asset_impl.o ^ -o .\bin\crafter-build.dll @@ -114,6 +119,7 @@ llvm-lib.exe /OUT:.\lib\crafter-build-static.lib ^ .\build\Crafter.Build-External.o ^ .\build\Crafter.Build-Clang.o ^ .\build\Crafter.Build-Test.o ^ + .\build\Crafter.Build-Lint.o ^ .\build\Crafter.Build-Progress.o ^ .\build\Crafter.Build-Asset.o ^ .\build\Crafter.Build.o ^ @@ -124,6 +130,7 @@ llvm-lib.exe /OUT:.\lib\crafter-build-static.lib ^ .\build\Crafter.Build-External_impl.o ^ .\build\Crafter.Build-Clang_impl.o ^ .\build\Crafter.Build-Test_impl.o ^ + .\build\Crafter.Build-Lint_impl.o ^ .\build\Crafter.Build-Progress_impl.o ^ .\build\Crafter.Build-Asset_impl.o diff --git a/build.sh b/build.sh index 8cd0f53..c579760 100755 --- a/build.sh +++ b/build.sh @@ -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-Clang.cppm share/crafter-build/ cp interfaces/Crafter.Build-Test.cppm share/crafter-build/ +cp interfaces/Crafter.Build-Lint.cppm share/crafter-build/ cp interfaces/Crafter.Build-Progress.cppm share/crafter-build/ cp interfaces/Crafter.Build-Asset.cppm share/crafter-build/ cp interfaces/Crafter.Build-Api.h share/crafter-build/ @@ -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-Clang.cppm -o ./build/Crafter.Build-Clang.o clang++ $common_options -fmodule-output interfaces/Crafter.Build-Test.cppm -o ./build/Crafter.Build-Test.o +clang++ $common_options -fmodule-output interfaces/Crafter.Build-Lint.cppm -o ./build/Crafter.Build-Lint.o clang++ $common_options -fmodule-output interfaces/Crafter.Build-Progress.cppm -o ./build/Crafter.Build-Progress.o # Asset partition: bootstrap compiles it WITHOUT CRAFTER_BUILD_HAS_ASSET so # 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-Clang.cpp -o ./build/Crafter.Build-Clang_impl.o clang++ $common_options ./implementations/Crafter.Build-Test.cpp -o ./build/Crafter.Build-Test_impl.o +clang++ $common_options ./implementations/Crafter.Build-Lint.cpp -o ./build/Crafter.Build-Lint_impl.o clang++ $common_options ./implementations/Crafter.Build-Progress.cpp -o ./build/Crafter.Build-Progress_impl.o clang++ $common_options ./implementations/Crafter.Build-Asset.cpp -o ./build/Crafter.Build-Asset_impl.o clang++ $common_options ./implementations/main.cpp -o ./build/main.o @@ -82,6 +85,7 @@ ar rcs ./lib/libcrafter-build.a \ ./build/Crafter.Build-External.o \ ./build/Crafter.Build-Clang.o \ ./build/Crafter.Build-Test.o \ + ./build/Crafter.Build-Lint.o \ ./build/Crafter.Build-Progress.o \ ./build/Crafter.Build-Asset.o \ ./build/Crafter.Build.o \ @@ -92,6 +96,7 @@ ar rcs ./lib/libcrafter-build.a \ ./build/Crafter.Build-External_impl.o \ ./build/Crafter.Build-Clang_impl.o \ ./build/Crafter.Build-Test_impl.o \ + ./build/Crafter.Build-Lint_impl.o \ ./build/Crafter.Build-Progress_impl.o \ ./build/Crafter.Build-Asset_impl.o @@ -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-Clang.o \ ./build/Crafter.Build-Test.o \ + ./build/Crafter.Build-Lint.o \ ./build/Crafter.Build-Progress.o \ ./build/Crafter.Build-Asset.o \ ./build/Crafter.Build.o \ @@ -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-Clang_impl.o \ ./build/Crafter.Build-Test_impl.o \ + ./build/Crafter.Build-Lint_impl.o \ ./build/Crafter.Build-Progress_impl.o \ ./build/Crafter.Build-Asset_impl.o \ ./build/main.o \ diff --git a/examples/hello-world/main.cpp b/examples/hello-world/main.cpp index 4761dd7..bd19420 100644 --- a/examples/hello-world/main.cpp +++ b/examples/hello-world/main.cpp @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: MIT -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® import std; diff --git a/examples/hello-world/project.cpp b/examples/hello-world/project.cpp index ac76268..d9dfc51 100644 --- a/examples/hello-world/project.cpp +++ b/examples/hello-world/project.cpp @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: MIT -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® import std; import Crafter.Build; diff --git a/examples/library/main.cpp b/examples/library/main.cpp index a1e030a..b72e143 100644 --- a/examples/library/main.cpp +++ b/examples/library/main.cpp @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: MIT -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® import std; import MyMath; diff --git a/examples/library/mylib/MyMath.cppm b/examples/library/mylib/MyMath.cppm index 847101b..2bd0889 100644 --- a/examples/library/mylib/MyMath.cppm +++ b/examples/library/mylib/MyMath.cppm @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: MIT -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® export module MyMath; import std; diff --git a/examples/library/project.cpp b/examples/library/project.cpp index 8e65ebc..24f7a61 100644 --- a/examples/library/project.cpp +++ b/examples/library/project.cpp @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: MIT -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® import std; import Crafter.Build; diff --git a/examples/tests/mylib/MyMath.cppm b/examples/tests/mylib/MyMath.cppm index 847101b..2bd0889 100644 --- a/examples/tests/mylib/MyMath.cppm +++ b/examples/tests/mylib/MyMath.cppm @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: MIT -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® export module MyMath; import std; diff --git a/examples/tests/project.cpp b/examples/tests/project.cpp index 98c7f88..9a026cb 100644 --- a/examples/tests/project.cpp +++ b/examples/tests/project.cpp @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: MIT -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® import std; import Crafter.Build; diff --git a/examples/tests/tests/Smoke/main.cpp b/examples/tests/tests/Smoke/main.cpp index 4b1de7a..1e04213 100644 --- a/examples/tests/tests/Smoke/main.cpp +++ b/examples/tests/tests/Smoke/main.cpp @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: MIT -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // Single-file test: no project.cpp needed. The folder name "Smoke" becomes // the test name. Exit 0 = pass, anything else = fail, exit 77 = skipped. diff --git a/examples/tests/tests/UnitMyMath/main.cpp b/examples/tests/tests/UnitMyMath/main.cpp index 8faad1f..7474bdd 100644 --- a/examples/tests/tests/UnitMyMath/main.cpp +++ b/examples/tests/tests/UnitMyMath/main.cpp @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: MIT -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® import std; import MyMath; diff --git a/examples/wasi/main.cpp b/examples/wasi/main.cpp index 025d61b..f55e0f7 100644 --- a/examples/wasi/main.cpp +++ b/examples/wasi/main.cpp @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: MIT -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® import std; diff --git a/examples/wasi/project.cpp b/examples/wasi/project.cpp index 28e4e4b..f54c9df 100644 --- a/examples/wasi/project.cpp +++ b/examples/wasi/project.cpp @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: MIT -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® import std; import Crafter.Build; diff --git a/examples/with-module/interfaces/Greeter.cppm b/examples/with-module/interfaces/Greeter.cppm index 30d421c..89e4d7e 100644 --- a/examples/with-module/interfaces/Greeter.cppm +++ b/examples/with-module/interfaces/Greeter.cppm @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: MIT -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® export module Greeter; import std; diff --git a/examples/with-module/main.cpp b/examples/with-module/main.cpp index 634e786..5ca71a2 100644 --- a/examples/with-module/main.cpp +++ b/examples/with-module/main.cpp @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: MIT -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® import std; import Greeter; diff --git a/examples/with-module/project.cpp b/examples/with-module/project.cpp index c1ed2c1..f885fba 100644 --- a/examples/with-module/project.cpp +++ b/examples/with-module/project.cpp @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: MIT -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® import std; import Crafter.Build; diff --git a/implementations/Crafter.Build-Asset.cpp b/implementations/Crafter.Build-Asset.cpp index bb962d5..25998a1 100644 --- a/implementations/Crafter.Build-Asset.cpp +++ b/implementations/Crafter.Build-Asset.cpp @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: LGPL-3.0-only -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® module Crafter.Build:Asset_impl; import :Asset; @@ -15,7 +15,7 @@ namespace Crafter { std::string ext = input.extension().string(); // Case-insensitive extension match — Sponza's textures dir mixes // case (.tga and .TGA both appear in the wild). - for (char& c : ext) c = static_cast(std::tolower(static_cast(c))); + for (char& c : ext) c = static_cast(std::tolower(static_cast(c))); #ifdef CRAFTER_BUILD_HAS_ASSET try { // stb_image (the loader behind LoadPNG) handles all of these, @@ -35,20 +35,11 @@ namespace Crafter { } return {}; #else - 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()); + 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()); #endif } - std::string BuildOBJBundle( - const fs::path& objPath, - const fs::path& mtlPath, - const fs::path& outDir, - std::uint16_t albedoSize) - { + std::string BuildOBJBundle(const fs::path& objPath, const fs::path& mtlPath, const fs::path& outDir, std::uint16_t albedoSize) { #ifdef CRAFTER_BUILD_HAS_ASSET const fs::path manifest = outDir / "scene.txt"; if (fs::exists(manifest)) return {}; @@ -75,9 +66,7 @@ namespace Crafter { if (mesh.vertexes.empty() || mesh.indexes.empty()) continue; mesh.SaveCompressed(outDir / std::format("mesh_{}.cmesh", emitted)); std::int32_t a = -1; - if (auto it = materials.find(matName); - it != materials.end() && !it->second.mapKd.empty()) - a = static_cast(albedoIndex.at(it->second.mapKd)); + if (auto it = materials.find(matName); it != materials.end() && !it->second.mapKd.empty()) a = static_cast(albedoIndex.at(it->second.mapKd)); meshAlbedoIdx.push_back(a); ++emitted; } diff --git a/implementations/Crafter.Build-Clang.cpp b/implementations/Crafter.Build-Clang.cpp index 0648fbf..95a691d 100644 --- a/implementations/Crafter.Build-Clang.cpp +++ b/implementations/Crafter.Build-Clang.cpp @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: LGPL-3.0-only -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® module; #if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32) @@ -18,6 +18,7 @@ import std; import :Clang; import :Platform; import :Test; +import :Lint; import :Progress; import :Asset; namespace fs = std::filesystem; @@ -25,9 +26,7 @@ using namespace Crafter; void Configuration::GetInterfacesAndImplementations(std::span interfaces, std::span implementations) { - auto resolveImport = [this](const std::string& importName, - std::vector& localDeps, - std::vector>& externalDeps) -> bool { + auto resolveImport = [this](const std::string& importName, std::vector& localDeps, std::vector>& externalDeps) -> bool { for(const std::unique_ptr& interface : this->interfaces) { if(interface->name == importName) { localDeps.push_back(interface.get()); @@ -40,7 +39,7 @@ void Configuration::GetInterfacesAndImplementations(std::span interfac if (!seen.insert(depCfg).second) return false; for(const std::unique_ptr& depInterface : depCfg->interfaces) { 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)); return true; } @@ -98,9 +97,9 @@ void Configuration::GetInterfacesAndImplementations(std::span interfac goto next; } } - throw std::runtime_error(std::format("Module {} not found, referenced in {}", match[1].str(), std::get<0>(tempModulePaths[i]).string())); + throw std::runtime_error(std::format("Module {} not found, referenced in {}", match[1].str(), std::get<0>(tempModulePaths[i]).string())); } else { - throw std::runtime_error(std::format("No module declaration found in {}", std::get<0>(tempModulePaths[i]).string())); + throw std::runtime_error(std::format("No module declaration found in {}", std::get<0>(tempModulePaths[i]).string())); } next:; } @@ -305,7 +304,7 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map std::optional { std::string ext = src.extension().string(); - for (char& c : ext) c = static_cast(std::tolower(static_cast(c))); + for (char& c : ext) c = static_cast(std::tolower(static_cast(c))); // stb_image (used by CompressAsset → TextureAsset::LoadPNG) handles // png/tga/jpg/bmp; all map to .ctex. if (ext == ".png" || ext == ".tga" || ext == ".jpg" || ext == ".jpeg" || ext == ".bmp") { @@ -435,7 +434,7 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_mappath)) { 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()); @@ -629,7 +628,7 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map fs::last_write_time(objPath))) { threads.emplace_back([&cFile, &buildDir, &buildError, &buildCancelled, &config, &includeFlags, &defineFlags, &userFlags, &cArchFlags, <oCompileFlags]() { Progress::Task task(std::format("Compiling {}.c", cFile.filename().string())); @@ -696,8 +695,8 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map fs::last_write_time(objPath))) { threads.emplace_back([&cFile, &buildDir, &buildError, &buildCancelled]() { Progress::Task task(std::format("Compiling {}.cu", cFile.filename().string())); @@ -789,7 +788,7 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map(std::tolower(static_cast(c))); + for (char& c : ext) c = static_cast(std::tolower(static_cast(c))); fs::path srcName = asset.filename(); if (ext == ".png" || ext == ".tga" || ext == ".jpg" || ext == ".jpeg" || ext == ".bmp") { srcName.replace_extension(".ctex"); @@ -821,12 +820,12 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map fs::path { fs::path dir = c.BinDir(); if (c.type == ConfigurationType::Executable) { - if (c.target.starts_with("wasm32")) - return dir / (c.outputName + ".wasm"); + if (c.target.starts_with("wasm32")) return dir / (std::format("{}.wasm", c.outputName)); return c.target == "x86_64-w64-mingw32" - ? dir / (c.outputName + ".exe") + ? dir / (std::format("{}.exe", c.outputName)) : dir / c.outputName; } if (c.type == ConfigurationType::LibraryStatic) { @@ -971,13 +969,13 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map consumerMtime; }; for (const std::unique_ptr& iface : config.interfaces) { - if (objNewer(buildDir / (iface->path.filename().string() + ".o"))) { + if (objNewer(buildDir / std::format("{}.o", iface->path.filename().string()))) { buildResult.repack = true; break; } bool partHit = false; for (const std::unique_ptr& part : iface->partitions) { - if (objNewer(buildDir / (part->path.filename().string() + ".o"))) { + if (objNewer(buildDir / std::format("{}.o", part->path.filename().string()))) { partHit = true; break; } @@ -986,7 +984,7 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_mapoutputName), - std::format("lib{}.dll.a", dep->outputName)}) { + for (auto fname : {std::format("{}.dll", dep->outputName), std::format("lib{}.dll.a", dep->outputName)}) { fs::path src = depDir / fname; if (!fs::exists(src)) continue; fs::path dest = outputDir / src.filename(); @@ -1053,8 +1050,7 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_maptype == ConfigurationType::LibraryDynamic && dep->target == "x86_64-w64-mingw32") { fs::path depDir = dep->BinDir(); - for (auto fname : {std::format("{}.dll", dep->outputName), - std::format("lib{}.dll.a", dep->outputName)}) { + for (auto fname : {std::format("{}.dll", dep->outputName), std::format("lib{}.dll.a", dep->outputName)}) { fs::path src = depDir / fname; if (!fs::exists(src)) continue; fs::path dest = outputDir / src.filename(); @@ -1067,9 +1063,7 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_mapwasmVariantFlags = variant.flags; fs::path variantBin = config.BinDir(); @@ -1164,19 +1147,16 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_mapwasmVariantFlags.clear(); if (!vr.result.empty()) { - buildResult.result = std::format( - "wasm variant '{}' build failed: {}", variant.label, vr.result); + buildResult.result = std::format("wasm variant '{}' build failed: {}", variant.label, vr.result); break; } 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::copy_file(src, dst, fs::copy_options::overwrite_existing, ec); if (ec) { - buildResult.result = std::format( - "copy wasm variant '{}' {} -> {}: {}", - variant.label, src.string(), dst.string(), ec.message()); + buildResult.result = std::format("copy wasm variant '{}' {} -> {}: {}", variant.label, src.string(), dst.string(), ec.message()); break; } } @@ -1190,9 +1170,7 @@ void Crafter::EnableWasiBrowserRuntime(Configuration& cfg) { fs::path runtimeJs = runtimeDir / "runtime.js"; fs::path htmlTemplate = runtimeDir / "index.html.in"; if (!fs::exists(runtimeJs) || !fs::exists(htmlTemplate)) { - throw std::runtime_error(std::format( - "wasi-runtime assets missing under {} (set CRAFTER_BUILD_HOME or reinstall)", - runtimeDir.string())); + throw std::runtime_error(std::format("wasi-runtime assets missing under {} (set CRAFTER_BUILD_HOME or reinstall)", runtimeDir.string())); } 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 // headers. Using ms-since-epoch is enough to be unique per build // without invoking any version-control machinery. - const std::string buildId = std::to_string( - std::chrono::duration_cast( - std::chrono::system_clock::now().time_since_epoch()).count()); + const std::string buildId = std::to_string(std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count()); std::string envScriptTags; for (const std::string& name : envScripts) { - envScriptTags += std::format( - " \n", - name, buildId); + envScriptTags += std::format(" \n", name, buildId); } // 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. auto compressedExt = [](const fs::path& src) -> std::optional { std::string ext = src.extension().string(); - for (char& c : ext) c = static_cast(std::tolower(static_cast(c))); + for (char& c : ext) c = static_cast(std::tolower(static_cast(c))); if (ext == ".png" || ext == ".tga" || ext == ".jpg" || ext == ".jpeg" || ext == ".bmp") { return std::string(".ctex"); } @@ -1340,7 +1314,7 @@ void Crafter::EnableWasiBrowserRuntime(Configuration& cfg) { if (!first) v << ","; first = false; std::string url = label.empty() - ? cfg.outputName + ".wasm" + ? std::format("{}.wasm", cfg.outputName) : std::format("{}.{}.wasm", cfg.outputName, label); v << "{\"label\":\"" << jsonEscape(label) << "\"" << ",\"url\":\"" << jsonEscape(url) << "\"" @@ -1363,7 +1337,7 @@ void Crafter::EnableWasiBrowserRuntime(Configuration& cfg) { std::stringstream buf; buf << in.rdbuf(); 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"(\{\{BUILDID\}\})"), buildId); std::ofstream out(htmlPath); @@ -1388,14 +1362,14 @@ void Crafter::EnableWasiRelaxedSimdVariant(Configuration& cfg) { } 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"); if (r.exitCode != 0) return {}; std::string out = std::move(r.output); while (!out.empty() && (out.back() == '\n' || out.back() == '\r')) out.pop_back(); return out; }(); - return cached; + return Cached; } bool Crafter::ArgQuery::Has(std::string_view flag) const { @@ -1417,7 +1391,8 @@ ArgQuery Crafter::ApplyStandardArgs(Configuration& cfg, std::span), compiles it to a shared @@ -1472,6 +1449,22 @@ Test options (after the `test` subcommand): project's tests plus the host triple. 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. + 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. + 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: Any flag not consumed above is forwarded verbatim to CrafterBuildProject as 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). Exit status: - 0 success / all non-skipped tests passed - 1 build failure or one or more tests failed + 0 success / all non-skipped tests passed / lint clean / format applied + 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); } @@ -1500,17 +1494,27 @@ int Crafter::Run(int argc, char** argv) { projectArgs.reserve(argc); bool runTests = false; + bool runLint = false; + bool runFormat = false; bool runAfterBuild = false; RunTestsOptions testOpts; + RunLintOptions lintOpts; Progress::Verbosity verbosity = Progress::Verbosity::Default; for (int i = 1; i < argc; ++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); return 0; - } else if (arg == "test") { + } else if (!runLint && !runFormat && arg == "test") { 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") { runAfterBuild = true; } 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())); } else if (runTests && !arg.starts_with("-")) { 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 { projectArgs.push_back(arg); } @@ -1553,6 +1561,18 @@ int Crafter::Run(int argc, char** argv) { Configuration config = LoadProject(projectFile, projectArgs); 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) { TestSummary summary = RunTests(config, testOpts, projectArgs); 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 // user has another dev server running we shift up rather // 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) - static const bool wsaInit = []{ WSADATA d; return WSAStartup(MAKEWORD(2, 2), &d) == 0; }(); - if (!wsaInit) return basePort; - using sock_t = SOCKET; - const sock_t invalid = INVALID_SOCKET; - auto closesock = [](sock_t s){ closesocket(s); }; + static const bool WsaInit = []{ WSADATA d; return WSAStartup(MAKEWORD(2, 2), &d) == 0; }(); + if (!WsaInit) return basePort; + using Sock = SOCKET; + const Sock invalid = INVALID_SOCKET; + auto closesock = [](Sock s){ closesocket(s); }; #else - using sock_t = int; - const sock_t invalid = -1; - auto closesock = [](sock_t s){ ::close(s); }; + using Sock = std::int32_t; + const Sock invalid = -1; + auto closesock = [](Sock s){ ::close(s); }; #endif - for (int p = basePort; p < basePort + span; ++p) { - sock_t s = ::socket(AF_INET, SOCK_STREAM, 0); + for (std::int32_t p = basePort; p < basePort + span; ++p) { + Sock s = ::socket(AF_INET, SOCK_STREAM, 0); if (s == invalid) return basePort; sockaddr_in addr{}; addr.sin_family = AF_INET; @@ -1627,7 +1647,7 @@ int Crafter::Run(int argc, char** argv) { } return basePort; }; - const int port = findFreePort(8080, 16); + const std::int32_t port = findFreePort(8080, 16); // Cross-origin isolation: the browser coarsens // performance.now() (and chrono::steady_clock under wasi) // to ~0.1ms unless the response carries COOP/COEP, which @@ -1661,8 +1681,7 @@ int Crafter::Run(int argc, char** argv) { " file_server\n" "}}\n", port, absDir.string())); - cmd = std::format("caddy run --config {} --adapter caddyfile", - cf.string()); + cmd = std::format("caddy run --config {} --adapter caddyfile", cf.string()); } else if (have("python3") || have("python")) { std::string_view py = have("python3") ? "python3" : "python"; picked = py; @@ -1701,8 +1720,7 @@ int Crafter::Run(int argc, char** argv) { "header('Cache-Control: no-store');\n" "return false;\n"); isolated = true; - cmd = std::format("php -S 0.0.0.0:{} -t {} {}", - port, absDir.string(), rp.string()); + cmd = std::format("php -S 0.0.0.0:{} -t {} {}", port, absDir.string(), rp.string()); } else if (have("ruby")) { picked = "ruby"; 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"; cmd = std::format("npx --yes http-server {} -p {} --silent", absDir.string(), port); } else { - std::println(std::cerr, - "-r wasm: no HTTP server found in PATH. Install one of: " - "caddy, python3, python, php, ruby, busybox, npx (Node.js)."); + std::println(std::cerr, "-r wasm: no HTTP server found in PATH. Install one of: " "caddy, python3, python, php, ruby, busybox, npx (Node.js)."); return 1; } std::println("listening on port :{}", port); if (!isolated) { - 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); + 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); } // Silence the backend's own banner/request logs so the // experience is identical regardless of which server is @@ -1742,9 +1755,7 @@ int Crafter::Run(int argc, char** argv) { if (have("wasmer")) { return std::system(std::format("wasmer run {}", artifact.string()).c_str()) == 0 ? 0 : 1; } - 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."); + 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."); return 1; } diff --git a/implementations/Crafter.Build-External.cpp b/implementations/Crafter.Build-External.cpp index 6a7b853..fb2ed44 100644 --- a/implementations/Crafter.Build-External.cpp +++ b/implementations/Crafter.Build-External.cpp @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: LGPL-3.0-only -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® module Crafter.Build:External_impl; 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); - std::string cmd = std::format("cmake -S {} -B {}{}", - ShellQuote(fs::absolute(cloneDir).string()), - ShellQuote(fs::absolute(cmakeBuildDir).string()), - BuildInjectedCMakeFlags(cmakeBuildDir, target)); + std::string cmd = std::format("cmake -S {} -B {}{}", ShellQuote(fs::absolute(cloneDir).string()), ShellQuote(fs::absolute(cmakeBuildDir).string()), BuildInjectedCMakeFlags(cmakeBuildDir, target)); if (!options.empty()) { cmd += " "; 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 // 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. - unsigned jobs = std::max(1u, std::thread::hardware_concurrency()); - std::string cmd = std::format("cmake --build {} --parallel {}", - ShellQuote(fs::absolute(cmakeBuildDir).string()), jobs); + std::uint32_t jobs = std::max(1u, std::thread::hardware_concurrency()); + std::string cmd = std::format("cmake --build {} --parallel {}", ShellQuote(fs::absolute(cmakeBuildDir).string()), jobs); CommandResult r = RunCommandChecked(cmd); if (r.exitCode != 0) { return std::format("cmake --build failed (exit {}): {}", r.exitCode, r.output); @@ -210,10 +206,7 @@ std::string BuildCMake(const fs::path& cmakeBuildDir) { } // namespace -ExternalBuildResult Crafter::BuildExternal( - const ExternalDependency& dep, - std::string_view target, - std::atomic& cancelled) { +ExternalBuildResult Crafter::BuildExternal(const ExternalDependency& dep, std::string_view target, std::atomic& cancelled) { ExternalBuildResult result; if (cancelled.load(std::memory_order_relaxed)) return result; @@ -234,8 +227,7 @@ ExternalBuildResult Crafter::BuildExternal( } } - std::string keyMaterial = std::format("{}|{}|{}|{}|{}", - dep.source.url, dep.source.branch, dep.source.commit, JoinOptions(dep.options), target); + std::string keyMaterial = std::format("{}|{}|{}|{}|{}", dep.source.url, dep.source.branch, dep.source.commit, JoinOptions(dep.options), target); std::size_t key = std::hash{}(keyMaterial); fs::path cloneDir = externalRoot / std::format("{}-{:016x}", name, key); @@ -303,8 +295,8 @@ ExternalBuildResult Crafter::BuildExternal( } namespace { - std::mutex projectCacheMutex; - std::unordered_map> projectCache; + std::mutex ProjectCacheMutex; + std::unordered_map> ProjectCache; // Run the dep's project.cpp from its own directory so its relative paths // (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 // in-memory Configuration is keyed by the full spec so each (args) // permutation gets its own Configuration object. - std::string srcKey = std::format("git|{}|{}|{}|{}", - spec.source.url, spec.source.branch, spec.source.commit, spec.projectFile.string()); + std::string srcKey = std::format("git|{}|{}|{}|{}", spec.source.url, spec.source.branch, spec.source.commit, spec.projectFile.string()); std::string srcHash = std::format("{:016x}", std::hash{}(srcKey)); std::string fullKey = srcKey; @@ -354,8 +345,8 @@ Configuration* Crafter::GitProject(const GitProjectSpec& spec) { std::string fullHash = std::format("{:016x}", std::hash{}(fullKey)); { - std::lock_guard lock(projectCacheMutex); - if (auto it = projectCache.find(fullHash); it != projectCache.end()) { + std::lock_guard lock(ProjectCacheMutex); + if (auto it = ProjectCache.find(fullHash); it != ProjectCache.end()) { return it->second.get(); } } @@ -379,38 +370,34 @@ Configuration* Crafter::GitProject(const GitProjectSpec& spec) { fs::path projectPath = cloneDir / spec.projectFile; if (!fs::exists(projectPath)) { - throw std::runtime_error(std::format( - "GitProject({}): {} not found in clone", spec.source.url, spec.projectFile.string())); + throw std::runtime_error(std::format("GitProject({}): {} not found in clone", spec.source.url, spec.projectFile.string())); } std::vector argViews(spec.args.begin(), spec.args.end()); Configuration cfg = LoadProjectFromRoot(projectPath, cloneDir, argViews); - std::lock_guard lock(projectCacheMutex); - if (auto it = projectCache.find(fullHash); it != projectCache.end()) { + std::lock_guard lock(ProjectCacheMutex); + if (auto it = ProjectCache.find(fullHash); it != ProjectCache.end()) { return it->second.get(); } auto stored = std::make_unique(std::move(cfg)); Configuration* ptr = stored.get(); - projectCache.emplace(std::move(fullHash), std::move(stored)); + ProjectCache.emplace(std::move(fullHash), std::move(stored)); return ptr; } fs::path Crafter::GitFetch(const GitSource& source) { std::string name = DeriveName(source); if (name.empty()) { - throw std::runtime_error(std::format( - "GitFetch: could not derive name from URL '{}'", source.url)); + throw std::runtime_error(std::format("GitFetch: could not derive name from URL '{}'", source.url)); } fs::path externalRoot = GetCacheDir() / "external"; std::error_code ec; fs::create_directories(externalRoot, ec); if (ec) { - throw std::runtime_error(std::format( - "GitFetch: failed to create {}: {}", externalRoot.string(), ec.message())); + throw std::runtime_error(std::format("GitFetch: failed to create {}: {}", externalRoot.string(), ec.message())); } - std::string keyMaterial = std::format("git|{}|{}|{}", - source.url, source.branch, source.commit); + std::string keyMaterial = std::format("git|{}|{}|{}", source.url, source.branch, source.commit); std::size_t key = std::hash{}(keyMaterial); 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{}(fullKey)); { - std::lock_guard lock(projectCacheMutex); - if (auto it = projectCache.find(fullHash); it != projectCache.end()) { + std::lock_guard lock(ProjectCacheMutex); + if (auto it = ProjectCache.find(fullHash); it != ProjectCache.end()) { return it->second.get(); } } @@ -449,12 +436,12 @@ Configuration* Crafter::LocalProject(const LocalProjectSpec& spec) { std::vector argViews(spec.args.begin(), spec.args.end()); Configuration cfg = LoadProjectFromRoot(canonical, depRoot, argViews); - std::lock_guard lock(projectCacheMutex); - if (auto it = projectCache.find(fullHash); it != projectCache.end()) { + std::lock_guard lock(ProjectCacheMutex); + if (auto it = ProjectCache.find(fullHash); it != ProjectCache.end()) { return it->second.get(); } auto stored = std::make_unique(std::move(cfg)); Configuration* ptr = stored.get(); - projectCache.emplace(std::move(fullHash), std::move(stored)); + ProjectCache.emplace(std::move(fullHash), std::move(stored)); return ptr; } diff --git a/implementations/Crafter.Build-Implementation.cpp b/implementations/Crafter.Build-Implementation.cpp index 89147bf..579c09d 100644 --- a/implementations/Crafter.Build-Implementation.cpp +++ b/implementations/Crafter.Build-Implementation.cpp @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: LGPL-3.0-only -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® module Crafter.Build:Implementation_impl; 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 { - std::string objPath = (buildDir/path.filename()).string()+"_impl.o"; - std::string cppPath = path.string()+".cpp"; + std::string objPath = std::format("{}_impl.o", (buildDir/path.filename()).string()); + 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)) { return true; } diff --git a/implementations/Crafter.Build-Interface.cpp b/implementations/Crafter.Build-Interface.cpp index 73373ec..91400ca 100644 --- a/implementations/Crafter.Build-Interface.cpp +++ b/implementations/Crafter.Build-Interface.cpp @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: LGPL-3.0-only -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® module Crafter.Build:Interface_impl; import std; @@ -13,8 +13,8 @@ namespace Crafter { bool ModulePartition::Check(const fs::path& pcmDir, fs::file_time_type sourceFloor) { if(!checked) { checked = true; - std::string pcmPath = (pcmDir/path.filename()).generic_string()+".pcm"; - std::string cppmPath = path.generic_string()+".cppm"; + std::string pcmPath = std::format("{}.pcm", (pcmDir/path.filename()).generic_string()); + 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)) { fs::file_time_type pcmTime = fs::last_write_time(pcmPath); for(ModulePartition* dependency : partitionDependencies) { @@ -97,8 +97,8 @@ namespace Crafter { bool Module::Check(const fs::path& pcmDir, fs::file_time_type sourceFloor) { if(!checked) { checked = true; - std::string pcmPath = (pcmDir/path.filename()).generic_string()+".pcm"; - std::string cppmPath = path.generic_string()+".cppm"; + std::string pcmPath = std::format("{}.pcm", (pcmDir/path.filename()).generic_string()); + 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)) { bool depCheck = false; for(std::unique_ptr& partition : partitions) { @@ -126,7 +126,7 @@ namespace Crafter { } } - void Module::Compile(const std::string_view clang, const fs::path& pcmDir, const fs::path& buildDir, std::atomic& buildCancelled, std::string& buildError) { + void Module::Compile(const std::string_view clang, const fs::path& pcmDir, const fs::path& buildDir, std::atomic& buildCancelled, std::string& buildError) { std::vector threads; threads.reserve(partitions.size()); for(std::unique_ptr& part : partitions) { diff --git a/implementations/Crafter.Build-Lint.cpp b/implementations/Crafter.Build-Lint.cpp new file mode 100644 index 0000000..45e0d68 --- /dev/null +++ b/implementations/Crafter.Build-Lint.cpp @@ -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 SplitLines(std::string_view content) { + std::vector 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 CollectLocalConfigs(Configuration& root, const fs::path& projectRoot) { + std::vector local; + std::unordered_set seen; + std::function 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& files) { + for (const std::unique_ptr& mod : c.interfaces) { + files.insert(fs::path(std::format("{}.cppm", mod->path.string()))); + for (const std::unique_ptr& 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 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 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 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 localConfigs = CollectLocalConfigs(projectCfg, projectRoot); + + // Collect rules root-first, dedup by name (first registration wins). + std::vector rules; + std::unordered_set 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 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 beforeLines = SplitLines(before); + bool anyLineDiffers = false; + if (beforeLines.size() == ctx.lines.size()) { + std::vector 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(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 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; +} diff --git a/implementations/Crafter.Build-Platform.cpp b/implementations/Crafter.Build-Platform.cpp index 9e07e7b..c0b35a8 100644 --- a/implementations/Crafter.Build-Platform.cpp +++ b/implementations/Crafter.Build-Platform.cpp @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: LGPL-3.0-only -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® module; #if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32) @@ -42,7 +42,7 @@ namespace { // don't keep the lock alive past our own release. class CacheLock { #ifdef CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_linux_gnu - int fd_ = -1; + std::int32_t fd_ = -1; public: explicit CacheLock(const fs::path& cacheDir) { fd_ = ::open((cacheDir / ".lock").c_str(), O_RDWR | O_CREAT | O_CLOEXEC, 0644); @@ -59,9 +59,7 @@ namespace { HANDLE handle_ = INVALID_HANDLE_VALUE; public: explicit CacheLock(const fs::path& cacheDir) { - handle_ = CreateFileW((cacheDir / ".lock").wstring().c_str(), - GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, - nullptr, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); + handle_ = CreateFileW((cacheDir / ".lock").wstring().c_str(), GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); if (handle_ != INVALID_HANDLE_VALUE) { OVERLAPPED ov{}; LockFileEx(handle_, LOCKFILE_EXCLUSIVE_LOCK, 0, MAXDWORD, MAXDWORD, &ov); @@ -111,15 +109,43 @@ fs::path Crafter::GetCrafterBuildHome() { if (parent == dir) break; dir = parent; } - std::string msg = std::format( - "could not locate crafter-build runtime assets relative to {} (set CRAFTER_BUILD_HOME). Tried:", - hostExe.string()); + std::string msg = std::format("could not locate crafter-build runtime assets relative to {} (set CRAFTER_BUILD_HOME). Tried:", hostExe.string()); for (const auto& p : tried) { - msg += "\n " + p.string(); + msg += std::format("\n {}", p.string()); } 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 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) std::string Crafter::RunCommand(const std::string_view cmd) { Progress::EchoCommand(cmd); @@ -127,14 +153,14 @@ std::string Crafter::RunCommand(const std::string_view cmd) { std::string result; // 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"); if (!pipe) { throw std::runtime_error("_popen() failed!"); } - while (fgets(buffer.data(), static_cast(buffer.size()), pipe) != nullptr) { + while (fgets(buffer.data(), static_cast(buffer.size()), pipe) != nullptr) { result += buffer.data(); } @@ -146,14 +172,14 @@ CommandResult Crafter::RunCommandChecked(std::string_view cmd) { std::array buffer; 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"); if (!pipe) { throw std::runtime_error("_popen() failed!"); } - while (fgets(buffer.data(), static_cast(buffer.size()), pipe) != nullptr) { + while (fgets(buffer.data(), static_cast(buffer.size()), pipe) != nullptr) { result.output += buffer.data(); } @@ -165,7 +191,8 @@ CommandResult Crafter::RunCommandWithTimeout(std::string_view cmd, std::chrono:: CommandResult result{}; SECURITY_ATTRIBUTES sa{ sizeof(sa), nullptr, TRUE }; - HANDLE readEnd = nullptr, writeEnd = nullptr; + HANDLE readEnd = nullptr; + HANDLE writeEnd = nullptr; if (!CreatePipe(&readEnd, &writeEnd, &sa, 0)) { 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 // /C parsing strips the outer quote pair when the inner text contains // 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 cmdBuf(wrapped.begin(), wrapped.end()); cmdBuf.push_back('\0'); - BOOL ok = CreateProcessA( - nullptr, cmdBuf.data(), nullptr, nullptr, TRUE, - CREATE_NO_WINDOW | CREATE_SUSPENDED, - nullptr, nullptr, &si, &pi); + BOOL ok = CreateProcessA(nullptr, cmdBuf.data(), nullptr, nullptr, TRUE, CREATE_NO_WINDOW | CREATE_SUSPENDED, nullptr, nullptr, &si, &pi); if (!ok) { CloseHandle(readEnd); CloseHandle(writeEnd); @@ -226,9 +250,7 @@ CommandResult Crafter::RunCommandWithTimeout(std::string_view cmd, std::chrono:: } }); - DWORD waitMs = static_cast(std::min( - static_cast(timeout.count()) * 1000LL, - static_cast(INFINITE) - 1)); + DWORD waitMs = static_cast(std::min(static_cast(timeout.count()) * 1000LL, static_cast(INFINITE) - 1)); DWORD waitResult = WaitForSingleObject(pi.hProcess, waitMs); if (waitResult == WAIT_TIMEOUT) { @@ -245,9 +267,9 @@ CommandResult Crafter::RunCommandWithTimeout(std::string_view cmd, std::chrono:: // so the runner shows 💥 instead of a numeric exit code. if ((exit & 0xC0000000) == 0xC0000000) { result.crashed = true; - result.signal = static_cast(exit); + result.signal = static_cast(exit); } - result.exitCode = static_cast(exit); + result.exitCode = static_cast(exit); } reader.join(); @@ -269,7 +291,7 @@ fs::path Crafter::GetCacheDir() { } namespace { - constexpr std::array kCrafterBuildModules = { + constexpr std::array CrafterBuildModules = { "Crafter.Build-Shader", "Crafter.Build-Platform", "Crafter.Build-Interface", @@ -277,6 +299,7 @@ namespace { "Crafter.Build-External", "Crafter.Build-Clang", "Crafter.Build-Test", + "Crafter.Build-Lint", "Crafter.Build-Progress", "Crafter.Build-Asset", "Crafter.Build", @@ -305,9 +328,9 @@ std::string Crafter::GetBaseCommand(const Configuration& config) { namespace { void EnsureCrafterBuildPcms(const fs::path& sourceDir, const fs::path& cacheDir) { CacheLock lock(cacheDir); - for (std::string_view name : kCrafterBuildModules) { - fs::path cppmPath = sourceDir / (std::string(name) + ".cppm"); - fs::path pcmPath = cacheDir / (std::string(name) + ".pcm"); + for (std::string_view name : CrafterBuildModules) { + fs::path cppmPath = sourceDir / std::format("{}.cppm", name); + fs::path pcmPath = cacheDir / std::format("{}.pcm", name); if (!fs::exists(cppmPath)) { throw std::runtime_error(std::format("module source {} not found in {} (set CRAFTER_BUILD_HOME)", name, sourceDir.string())); } @@ -336,7 +359,7 @@ Configuration Crafter::LoadProject(const fs::path& projectFile, std::span versions; for (const auto& entry : fs::directory_iterator(includeRoot)) { @@ -505,13 +524,11 @@ namespace { void EnsureCrafterBuildPcms(const fs::path& sourceDir, const fs::path& cacheDir) { CacheLock lock(cacheDir); fs::path prefix = MingwPrefix(); - for (std::string_view name : kCrafterBuildModules) { - fs::path cppmPath = sourceDir / (std::string(name) + ".cppm"); - fs::path pcmPath = cacheDir / (std::string(name) + ".pcm"); + for (std::string_view name : CrafterBuildModules) { + fs::path cppmPath = sourceDir / std::format("{}.cppm", name); + fs::path pcmPath = cacheDir / std::format("{}.pcm", name); if (!fs::exists(cppmPath)) { - throw std::runtime_error(std::format( - "module source {} not found in {} (set CRAFTER_BUILD_HOME)", - name, sourceDir.string())); + throw std::runtime_error(std::format("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)) { continue; @@ -526,8 +543,7 @@ namespace { prefix.string(), cacheDir.string(), cppmPath.string(), pcmPath.string()); CommandResult r = Crafter::RunCommandChecked(cmd); if (r.exitCode != 0) { - throw std::runtime_error(std::format( - "Failed to precompile {} (exit {}): {}", name, r.exitCode, r.output)); + throw std::runtime_error(std::format("Failed to precompile {} (exit {}): {}", name, r.exitCode, r.output)); } } } @@ -537,7 +553,7 @@ Configuration Crafter::LoadProject(const fs::path& projectFile, std::span); auto fn = reinterpret_cast(GetProcAddress(handle, "CrafterBuildProject")); if (!fn) { - throw std::runtime_error(std::format( - "CrafterBuildProject not found in {}: error {}", dllPath.string(), GetLastError())); + throw std::runtime_error(std::format("CrafterBuildProject not found in {}: error {}", dllPath.string(), GetLastError())); } return fn(args); @@ -626,7 +637,7 @@ std::string Crafter::RunCommand(const std::string_view cmd) { std::array buffer; std::string result; - std::string with = std::string(cmd) + " 2>&1"; + std::string with = std::format("{} 2>&1", cmd); // Open pipe to file FILE* pipe = popen(with.c_str(), "r"); if (!pipe) throw std::runtime_error("popen() failed!"); @@ -645,7 +656,7 @@ CommandResult Crafter::RunCommandChecked(std::string_view cmd) { std::array buffer; 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"); if (!pipe) throw std::runtime_error("popen() failed!"); @@ -653,7 +664,7 @@ CommandResult Crafter::RunCommandChecked(std::string_view cmd) { result.output += buffer.data(); } - int status = pclose(pipe); + std::int32_t status = pclose(pipe); if (WIFEXITED(status)) { result.exitCode = WEXITSTATUS(status); } else if (WIFSIGNALED(status)) { @@ -670,9 +681,7 @@ CommandResult Crafter::RunCommandWithTimeout(std::string_view cmd, std::chrono:: std::array buffer; CommandResult result{}; - std::string wrapped = std::format( - "timeout --kill-after=2 {} {} 2>&1", - timeout.count(), cmd); + std::string wrapped = std::format("timeout --kill-after=2 {} {} 2>&1", timeout.count(), cmd); FILE* pipe = popen(wrapped.c_str(), "r"); 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(); } - int status = pclose(pipe); + std::int32_t status = pclose(pipe); if (WIFEXITED(status)) { - int code = WEXITSTATUS(status); + std::int32_t code = WEXITSTATUS(status); if (code == 124) { result.timedOut = true; 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; // wasmVariantFlags is empty on every other path. 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 // libc++ headers (/../include/c++/v1) above --sysroot, so @@ -814,7 +823,7 @@ std::string Crafter::GetBaseCommand(const Configuration& config) { } namespace { - constexpr std::array kCrafterBuildModules = { + constexpr std::array CrafterBuildModules = { "Crafter.Build-Shader", "Crafter.Build-Platform", "Crafter.Build-Interface", @@ -822,6 +831,7 @@ namespace { "Crafter.Build-External", "Crafter.Build-Clang", "Crafter.Build-Test", + "Crafter.Build-Lint", "Crafter.Build-Progress", "Crafter.Build-Asset", "Crafter.Build", @@ -829,9 +839,9 @@ namespace { void EnsureCrafterBuildPcms(const fs::path& sourceDir, const fs::path& cacheDir) { CacheLock lock(cacheDir); - for (std::string_view name : kCrafterBuildModules) { - fs::path cppmPath = sourceDir / (std::string(name) + ".cppm"); - fs::path pcmPath = cacheDir / (std::string(name) + ".pcm"); + for (std::string_view name : CrafterBuildModules) { + fs::path cppmPath = sourceDir / std::format("{}.cppm", name); + fs::path pcmPath = cacheDir / std::format("{}.pcm", name); if (!fs::exists(cppmPath)) { throw std::runtime_error(std::format("module source {} not found in {} (set CRAFTER_BUILD_HOME)", name, sourceDir.string())); } @@ -859,7 +869,7 @@ Configuration Crafter::LoadProject(const fs::path& projectFile, std::span @@ -22,21 +22,21 @@ import std; import :Progress; namespace { - std::mutex g_mutex; - std::atomic g_total{0}; - std::atomic g_done{0}; - Crafter::Progress::Verbosity g_verbosity = Crafter::Progress::Verbosity::Default; - bool g_isTty = false; - bool g_lineDirty = false; // status line has uncleared content - bool g_finalized = false; - std::chrono::steady_clock::time_point g_startTime = std::chrono::steady_clock::now(); + std::mutex StateMutex; + std::atomic Total{0}; + std::atomic Done{0}; + Crafter::Progress::Verbosity ActiveVerbosity = Crafter::Progress::Verbosity::Default; + bool IsTty = false; + bool LineDirty = false; // status line has uncleared content + bool Finalized = false; + std::chrono::steady_clock::time_point StartTime = std::chrono::steady_clock::now(); - int TerminalWidth() { + std::int32_t TerminalWidth() { #if defined(_WIN32) HANDLE h = GetStdHandle(STD_OUTPUT_HANDLE); CONSOLE_SCREEN_BUFFER_INFO info; if (h != INVALID_HANDLE_VALUE && GetConsoleScreenBufferInfo(h, &info)) { - 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; } #else @@ -46,21 +46,21 @@ namespace { } #endif 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; } - // Caller holds g_mutex. + // Caller holds StateMutex. void RenderStatus(std::string_view label) { - if (g_verbosity != Crafter::Progress::Verbosity::Default || !g_isTty) return; - int done = g_done.load(std::memory_order_relaxed); - int total = g_total.load(std::memory_order_relaxed); + if (ActiveVerbosity != Crafter::Progress::Verbosity::Default || !IsTty) return; + std::int32_t done = Done.load(std::memory_order_relaxed); + std::int32_t total = Total.load(std::memory_order_relaxed); std::string prefix = std::format("[{}/{}] ", done, total); - int width = TerminalWidth(); - int avail = width - static_cast(prefix.size()) - 1; + std::int32_t width = TerminalWidth(); + std::int32_t avail = width - static_cast(prefix.size()) - 1; std::string trimmed{label}; - if (avail > 0 && static_cast(trimmed.size()) > avail) { + if (avail > 0 && static_cast(trimmed.size()) > avail) { trimmed.resize(static_cast(avail)); } // \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(trimmed.c_str(), stdout); std::fflush(stdout); - g_lineDirty = true; + LineDirty = true; } - // Caller holds g_mutex. + // Caller holds StateMutex. void ClearLineLocked() { - if (g_lineDirty) { + if (LineDirty) { std::fputs("\r\033[2K", stdout); std::fflush(stdout); - g_lineDirty = false; + LineDirty = false; } } } @@ -84,66 +84,64 @@ namespace { namespace Crafter::Progress { void SetVerbosity(Verbosity v) { - std::lock_guard lock(g_mutex); - g_verbosity = v; - g_isTty = CRAFTER_PROGRESS_ISATTY(STDOUT_FILENO) != 0; - g_startTime = std::chrono::steady_clock::now(); - g_total.store(0); - g_done.store(0); - g_finalized = false; + std::lock_guard lock(StateMutex); + ActiveVerbosity = v; + IsTty = CRAFTER_PROGRESS_ISATTY(STDOUT_FILENO) != 0; + StartTime = std::chrono::steady_clock::now(); + Total.store(0); + Done.store(0); + Finalized = false; } Verbosity GetVerbosity() { - std::lock_guard lock(g_mutex); - return g_verbosity; + std::lock_guard lock(StateMutex); + return ActiveVerbosity; } Task::Task(std::string label) : label_(std::move(label)) { - g_total.fetch_add(1, std::memory_order_relaxed); - std::lock_guard lock(g_mutex); - if (g_verbosity == Verbosity::Default && g_isTty) { + Total.fetch_add(1, std::memory_order_relaxed); + std::lock_guard lock(StateMutex); + if (ActiveVerbosity == Verbosity::Default && IsTty) { RenderStatus(label_); } } Task::~Task() { - int done = g_done.fetch_add(1, std::memory_order_relaxed) + 1; - std::lock_guard lock(g_mutex); - if (g_verbosity == Verbosity::Default) { - if (g_isTty) { + std::int32_t done = Done.fetch_add(1, std::memory_order_relaxed) + 1; + std::lock_guard lock(StateMutex); + if (ActiveVerbosity == Verbosity::Default) { + if (IsTty) { RenderStatus(label_); } else { // 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_); } } } void EchoCommand(std::string_view command) { - std::lock_guard lock(g_mutex); - if (g_verbosity == Verbosity::Verbose) { + std::lock_guard lock(StateMutex); + if (ActiveVerbosity == Verbosity::Verbose) { std::println("$ {}", command); } } void Clear() { - std::lock_guard lock(g_mutex); + std::lock_guard lock(StateMutex); ClearLineLocked(); } void Finalize() { - std::lock_guard lock(g_mutex); - if (g_finalized) return; - g_finalized = true; + std::lock_guard lock(StateMutex); + if (Finalized) return; + Finalized = true; ClearLineLocked(); - if (g_verbosity == Verbosity::Quiet) return; - int done = g_done.load(); + if (ActiveVerbosity == Verbosity::Quiet) return; + std::int32_t done = Done.load(); if (done == 0) return; // Nothing happened (cached build); stay silent. - auto elapsed = std::chrono::duration_cast( - std::chrono::steady_clock::now() - g_startTime); - std::println("Built {} step{} in {}ms", - done, done == 1 ? "" : "s", elapsed.count()); + auto elapsed = std::chrono::duration_cast(std::chrono::steady_clock::now() - StartTime); + std::println("Built {} step{} in {}ms", done, done == 1 ? "" : "s", elapsed.count()); } } // namespace Crafter::Progress diff --git a/implementations/Crafter.Build-Shader.cpp b/implementations/Crafter.Build-Shader.cpp index 0ebea9b..5bc01b7 100644 --- a/implementations/Crafter.Build-Shader.cpp +++ b/implementations/Crafter.Build-Shader.cpp @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: LGPL-3.0-only -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® module; #include "SPIRV/GlslangToSpv.h" @@ -72,11 +72,11 @@ namespace Crafter { std::string pathStr = path.string(); const char* file_name_list[1] = { pathStr.c_str() }; - const char* shader_source = src.data(); - const int shader_source_len = static_cast(src.size()); + const char* shaderSource = src.data(); + const std::int32_t shaderSourceLen = static_cast(src.size()); 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.setSourceEntryPoint(entrypoint.c_str()); shader.setEnvTarget(glslang::EShTargetSpv, glslang::EShTargetSpv_1_4); diff --git a/implementations/Crafter.Build-Test.cpp b/implementations/Crafter.Build-Test.cpp index 5183af8..f686d26 100644 --- a/implementations/Crafter.Build-Test.cpp +++ b/implementations/Crafter.Build-Test.cpp @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: LGPL-3.0-only -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® module; export module Crafter.Build:Test_impl; @@ -13,41 +13,15 @@ using namespace Crafter; namespace { bool TargetIsWindows(std::string_view target) { - return target.find("windows") != std::string_view::npos - || target.find("mingw") != std::string_view::npos; + return target.find("windows") != std::string_view::npos || target.find("mingw") != std::string_view::npos; } fs::path TestBinaryPath(const Configuration& cfg) { 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) { - 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 globs, std::string_view name) { - if (globs.empty()) return true; - for (const auto& g : globs) { - if (MatchGlob(g, name)) return true; - } - return false; - } + // MatchGlob/MatchAny live in :Platform — shared with the lint verb. std::string ShellQuoteSh(std::string_view s) { std::string out; @@ -118,7 +92,7 @@ namespace { return out; } - std::string SignalName(int sig) { + std::string SignalName(std::int32_t sig) { switch (sig) { case 1: return "SIGHUP"; case 2: return "SIGINT"; @@ -139,7 +113,7 @@ namespace { std::error_code ec; fs::create_directories(logDir, ec); 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) { @@ -174,19 +148,16 @@ namespace { std::println("⏱ {}{} ({}ms) timeout", r.name, runnerSuffix, ms); break; case TestOutcome::Skipped: - std::println("⏭ {}{} skipped: {}", r.name, runnerSuffix, - r.output.empty() ? std::string("(no reason)") : r.output); + std::println("⏭ {}{} skipped: {}", r.name, runnerSuffix, r.output.empty() ? std::string("(no reason)") : r.output); break; } } } namespace { - std::atomic g_parentProject{nullptr}; + std::atomic ParentProject{nullptr}; - Configuration* FindLibInTree(Configuration* root, - std::string_view name, - std::unordered_set& seen) { + Configuration* FindLibInTree(Configuration* root, std::string_view name, std::unordered_set& seen) { if (!seen.insert(root).second) return nullptr; if (root->name == name) return root; for (Configuration* dep : root->dependencies) { @@ -197,20 +168,17 @@ namespace { } void Crafter::SetParentProject(Configuration* parent) { - g_parentProject.store(parent); + ParentProject.store(parent); } Configuration* Crafter::ParentLib(std::string_view name) { - Configuration* root = g_parentProject.load(); + Configuration* root = ParentProject.load(); if (!root) { - throw std::runtime_error(std::format( - "Crafter::ParentLib('{}'): no parent project set", name)); + throw std::runtime_error(std::format("Crafter::ParentLib('{}'): no parent project set", name)); } std::unordered_set seen; if (auto found = FindLibInTree(root, name, seen)) return found; - throw std::runtime_error(std::format( - "Crafter::ParentLib('{}'): not found in parent project '{}'", - name, root->name)); + throw std::runtime_error(std::format("Crafter::ParentLib('{}'): not found in parent project '{}'", name, root->name)); } TestRunner TestRunner::Local() { @@ -286,9 +254,7 @@ TestRunner TestRunner::ForTarget(const Configuration& cfg) { // emulated loader then searches ITS default paths against the // host filesystem, picking up host-arch libraries from /lib — // LD_LIBRARY_PATH steers it back into the sysroot. - r.exec = std::format( - "env QEMU_LD_PREFIX={0} LD_LIBRARY_PATH={0}/lib:{0}/usr/lib {1}", - cfg.sysroot, r.exec); + r.exec = std::format("env QEMU_LD_PREFIX={0} LD_LIBRARY_PATH={0}/lib:{0}/usr/lib {1}", cfg.sysroot, r.exec); } return r; } @@ -317,9 +283,7 @@ namespace { if (spec.starts_with("cmd:") && spec.size() > 4) { return TestRunner::Cmd(std::string(spec.substr(4))); } - throw std::runtime_error(std::format( - "TestRunner::FromSpec: unrecognized runner spec '{}' " - "(expected 'local' or 'cmd:')", spec)); + throw std::runtime_error(std::format("TestRunner::FromSpec: unrecognized runner spec '{}' " "(expected 'local' or 'cmd:')", spec)); } } @@ -411,8 +375,7 @@ namespace { return {false, std::format("env '{}' unset", arg)}; } } else { - return {false, std::format( - "unknown require kind '{}' (expected tool/file/env)", kind)}; + return {false, std::format("unknown require kind '{}' (expected tool/file/env)", kind)}; } } return {true, ""}; @@ -479,9 +442,7 @@ TestBuilder Configuration::AddTest(std::string_view name, std::span in return TestBuilder{this, tests.size() - 1}; } -void Configuration::AddMarchVariants(std::string_view name, - std::span interfaces, - std::span tiers) { +void Configuration::AddMarchVariants(std::string_view name, std::span interfaces, std::span tiers) { for (const auto& tier : tiers) { Test t; 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::Target(std::string t) { test().config.target = std::move(t); return *this; } -TestBuilder& TestBuilder::March(std::string m) { test().config.march = std::move(m); return *this; } -TestBuilder& TestBuilder::Mtune(std::string m) { test().config.mtune = std::move(m); return *this; } -TestBuilder& TestBuilder::Sysroot(fs::path s) { test().config.sysroot = s.string(); return *this; } -TestBuilder& TestBuilder::Debug(bool d) { test().config.debug = d; return *this; } +TestBuilder& TestBuilder::Path(fs::path p) { Ref().config.path = std::move(p); return *this; } +TestBuilder& TestBuilder::Target(std::string t) { Ref().config.target = std::move(t); return *this; } +TestBuilder& TestBuilder::March(std::string m) { Ref().config.march = 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) { Ref().config.sysroot = s.string(); return *this; } +TestBuilder& TestBuilder::Debug(bool d) { Ref().config.debug = d; return *this; } 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; } -TestBuilder& TestBuilder::Timeout(std::chrono::seconds s) { test().timeout = s; return *this; } -TestBuilder& TestBuilder::Args(std::vector a) { test().args = std::move(a); return *this; } -TestBuilder& TestBuilder::Requires(std::string r) { test().requires_.push_back(std::move(r)); return *this; } +TestBuilder& TestBuilder::Timeout(std::chrono::seconds s) { Ref().timeout = s; return *this; } +TestBuilder& TestBuilder::Args(std::vector a) { Ref().args = std::move(a); return *this; } +TestBuilder& TestBuilder::Requires(std::string r) { Ref().requires_.push_back(std::move(r)); return *this; } TestBuilder& TestBuilder::Dependencies(std::vector d) { - test().config.dependencies = std::move(d); + Ref().config.dependencies = std::move(d); return *this; } -TestBuilder& TestBuilder::LinkFlag(std::string f) { test().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::LinkFlag(std::string f) { Ref().config.linkFlags.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 projectArgs) { // 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; } - int jobs = opts.jobs > 0 + std::int32_t jobs = opts.jobs > 0 ? opts.jobs : std::max(1u, std::thread::hardware_concurrency()); - jobs = std::min(jobs, static_cast(filtered.size())); + jobs = std::min(jobs, static_cast(filtered.size())); std::unordered_map> depResults; std::mutex depMutex; @@ -651,10 +612,7 @@ TestSummary Crafter::RunTests(Configuration& projectCfg, const RunTestsOptions& if (!tool.empty() && !RequiresMentionsTool(t.requires_, tool)) { r.outcome = TestOutcome::Fail; r.exitCode = -1; - r.output = std::format( - "runner '{}' unavailable and not declared in requires " - "(add .Requires(\"tool:{}\") to permit skipping)", - t.runner.name, tool); + r.output = std::format("runner '{}' unavailable and not declared in requires " "(add .Requires(\"tool:{}\") to permit skipping)", t.runner.name, tool); } else { r.outcome = TestOutcome::Skipped; r.output = std::format("runner '{}' not available", t.runner.name); @@ -718,7 +676,7 @@ TestSummary Crafter::RunTests(Configuration& projectCfg, const RunTestsOptions& std::vector threads; threads.reserve(jobs); - for (int j = 0; j < jobs; ++j) { + for (std::int32_t j = 0; j < jobs; ++j) { threads.emplace_back(worker); } threads.clear(); // joins all jthreads diff --git a/implementations/main.cpp b/implementations/main.cpp index 2a149b3..346f6b6 100644 --- a/implementations/main.cpp +++ b/implementations/main.cpp @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: LGPL-3.0-only -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® #include #if defined(_WIN32) diff --git a/interfaces/Crafter.Build-Api.h b/interfaces/Crafter.Build-Api.h index 9016640..334a891 100644 --- a/interfaces/Crafter.Build-Api.h +++ b/interfaces/Crafter.Build-Api.h @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: LGPL-3.0-only -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® #pragma once diff --git a/interfaces/Crafter.Build-Asset.cppm b/interfaces/Crafter.Build-Asset.cppm index 0f112a9..33e2bbd 100644 --- a/interfaces/Crafter.Build-Asset.cppm +++ b/interfaces/Crafter.Build-Asset.cppm @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: LGPL-3.0-only -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® module; #include "Crafter.Build-Api.h" diff --git a/interfaces/Crafter.Build-Clang.cppm b/interfaces/Crafter.Build-Clang.cppm index 566508e..64c22f7 100644 --- a/interfaces/Crafter.Build-Clang.cppm +++ b/interfaces/Crafter.Build-Clang.cppm @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: LGPL-3.0-only -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® module; #include "Crafter.Build-Api.h" @@ -96,12 +96,91 @@ export namespace Crafter { struct TestResult { std::string name; TestOutcome outcome = TestOutcome::Pass; - int exitCode = 0; - int signal = 0; + std::int32_t exitCode = 0; + std::int32_t signal = 0; std::chrono::milliseconds duration{0}; 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): + // :: warning: [] + 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 fileRules; + std::unordered_set lineAll; + std::unordered_map> 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 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* sink = nullptr; + std::optional commentStrippedCache; + std::optional 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 check; + }; + // The host target triple, detected once per process by running // `clang++ -print-target-triple` and cached. Used as the default for // Configuration::target so projects don't have to hardcode it for the @@ -172,6 +251,8 @@ export namespace Crafter { // wasmVariants instead. std::vector wasmVariantFlags; std::vector tests; + // Lint rules for `crafter-build lint`. Populate via AddLintRule. + std::vector lintRules; CRAFTER_API void GetInterfacesAndImplementations(std::span interfaces, std::span implementations); // Declare a test. Sources default to `tests//main.cpp` resolved // against this Configuration's path; target/march/mtune/sysroot/debug @@ -188,9 +269,14 @@ export namespace Crafter { // `tests//main.cpp` source and the same interface set, each // compiled with the tier's `-march`/`-mtune`. Test names are // `-`. - CRAFTER_API void AddMarchVariants(std::string_view name, - std::span interfaces, - std::span tiers); + CRAFTER_API void AddMarchVariants(std::string_view name, std::span interfaces, std::span tiers); + // Register a lint rule for `crafter-build lint` / `format`. Rules + // registered on any Configuration whose path lies inside the project + // root are collected (deduplicated by name, root-first) — attach them + // to the lib or the exe config, either works. Rules that call + // ctx.SetContent are transforms (see LintContext::SetContent). + // Defined in Crafter.Build:Lint. + CRAFTER_API void AddLintRule(std::string name, std::function check); // Suffix that uniquely identifies this Configuration's compile state. // target+march+mtune are spelled out for readability; the rest // (type, debug, sysroot, defines, compileFlags) collapse into a short @@ -198,7 +284,7 @@ export namespace Crafter { // compile state can't clobber each other's outputs. std::string VariantId() const { std::string compileKey; - compileKey += std::to_string(static_cast(type)); + compileKey += std::to_string(static_cast(type)); compileKey += '|'; compileKey += debug ? '1' : '0'; compileKey += '|'; @@ -297,4 +383,4 @@ export namespace Crafter { // can query their own flags (`--timing`, ...) without re-rolling the // for-arg-in-args loop. CRAFTER_API ArgQuery ApplyStandardArgs(Configuration& cfg, std::span args); -} \ No newline at end of file +} diff --git a/interfaces/Crafter.Build-External.cppm b/interfaces/Crafter.Build-External.cppm index b640590..868b943 100644 --- a/interfaces/Crafter.Build-External.cppm +++ b/interfaces/Crafter.Build-External.cppm @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: LGPL-3.0-only -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® module; #include "Crafter.Build-Api.h" @@ -44,10 +44,7 @@ export namespace Crafter { fs::file_time_type latestArtifact = fs::file_time_type::min(); }; - CRAFTER_API ExternalBuildResult BuildExternal( - const ExternalDependency& dep, - std::string_view target, - std::atomic& cancelled); + CRAFTER_API ExternalBuildResult BuildExternal(const ExternalDependency& dep, std::string_view target, std::atomic& cancelled); // Specification for a sibling crafter-build project to fetch and depend on. // GitSource picks the revision: leave branch + commit empty for the diff --git a/interfaces/Crafter.Build-Implementation.cppm b/interfaces/Crafter.Build-Implementation.cppm index 3cc69a6..fb9842a 100644 --- a/interfaces/Crafter.Build-Implementation.cppm +++ b/interfaces/Crafter.Build-Implementation.cppm @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: LGPL-3.0-only -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® module; #include "Crafter.Build-Api.h" diff --git a/interfaces/Crafter.Build-Interface.cppm b/interfaces/Crafter.Build-Interface.cppm index 9fa22e5..e7274cb 100644 --- a/interfaces/Crafter.Build-Interface.cppm +++ b/interfaces/Crafter.Build-Interface.cppm @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: LGPL-3.0-only -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® module; #include "Crafter.Build-Api.h" diff --git a/interfaces/Crafter.Build-Lint.cppm b/interfaces/Crafter.Build-Lint.cppm new file mode 100644 index 0000000..d6c2a8a --- /dev/null +++ b/interfaces/Crafter.Build-Lint.cppm @@ -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 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 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 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); +} diff --git a/interfaces/Crafter.Build-Platform.cppm b/interfaces/Crafter.Build-Platform.cppm index 9aa31f0..eeb6fa9 100644 --- a/interfaces/Crafter.Build-Platform.cppm +++ b/interfaces/Crafter.Build-Platform.cppm @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: LGPL-3.0-only -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® module; #include "Crafter.Build-Api.h" @@ -10,11 +10,11 @@ namespace fs = std::filesystem; namespace Crafter { struct Configuration; struct CommandResult { - int exitCode = 0; + std::int32_t exitCode = 0; std::string output; bool crashed = false; bool timedOut = false; - int signal = 0; + std::int32_t signal = 0; }; std::string BuildStdPcm(const Configuration& config, fs::path stdPcm); fs::path GetCacheDir(); @@ -27,4 +27,8 @@ namespace Crafter { // module sources, wasi-runtime/, etc). Honors CRAFTER_BUILD_HOME; otherwise // derives /share/crafter-build from the running executable's path. export CRAFTER_API fs::path GetCrafterBuildHome(); -} \ No newline at end of file + // 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 globs, std::string_view name); +} diff --git a/interfaces/Crafter.Build-Progress.cppm b/interfaces/Crafter.Build-Progress.cppm index 16773ca..fe563c6 100644 --- a/interfaces/Crafter.Build-Progress.cppm +++ b/interfaces/Crafter.Build-Progress.cppm @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: LGPL-3.0-only -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® module; #include "Crafter.Build-Api.h" diff --git a/interfaces/Crafter.Build-Shader.cppm b/interfaces/Crafter.Build-Shader.cppm index cf18134..059d19a 100644 --- a/interfaces/Crafter.Build-Shader.cppm +++ b/interfaces/Crafter.Build-Shader.cppm @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: LGPL-3.0-only -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® module; #include "Crafter.Build-Api.h" diff --git a/interfaces/Crafter.Build-Test.cppm b/interfaces/Crafter.Build-Test.cppm index 4e800f9..f26f99a 100644 --- a/interfaces/Crafter.Build-Test.cppm +++ b/interfaces/Crafter.Build-Test.cppm @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: LGPL-3.0-only -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® module; #include "Crafter.Build-Api.h" @@ -32,7 +32,7 @@ export namespace Crafter { Configuration* parent; 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 // "./" (project root, where tests//main.cpp lives). Override @@ -59,7 +59,7 @@ export namespace Crafter { struct RunTestsOptions { std::vector globs; - int jobs = 0; + std::int32_t jobs = 0; std::optional timeoutOverride; bool listOnly = false; // Single-target run: only tests whose Configuration::target matches @@ -76,11 +76,11 @@ export namespace Crafter { }; struct TestSummary { - int passed = 0; - int failed = 0; - int crashed = 0; - int timedOut = 0; - int skipped = 0; + std::int32_t passed = 0; + std::int32_t failed = 0; + std::int32_t crashed = 0; + std::int32_t timedOut = 0; + std::int32_t skipped = 0; std::vector results; bool AllPassed() const { return failed == 0 && crashed == 0 && timedOut == 0; } }; diff --git a/interfaces/Crafter.Build.cppm b/interfaces/Crafter.Build.cppm index 04b055a..de659dc 100644 --- a/interfaces/Crafter.Build.cppm +++ b/interfaces/Crafter.Build.cppm @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: LGPL-3.0-only -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® export module Crafter.Build; export import :Clang; @@ -8,5 +8,6 @@ export import :Implementation; export import :Shader; export import :External; export import :Test; +export import :Lint; export import :Progress; -export import :Asset; \ No newline at end of file +export import :Asset; diff --git a/lint-rules.h b/lint-rules.h new file mode 100644 index 0000000..12461fd --- /dev/null +++ b/lint-rules.h @@ -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 Lines(std::string_view text) { + std::vector 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 lines = Lines(ctx.CommentStripped()); + + enum class Scope { Namespace, Type, Function, Other }; + std::vector 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 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(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(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 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 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 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 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 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 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 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 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 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 stripped = Lines(ctx.CommentStripped()); + std::vector 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 stripped = Lines(ctx.CommentStripped()); + static const std::regex ifHeader(R"(^\s*(?:\}?\s*else\s+)?if\s*\(.*\)\s*$)"); + std::vector 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 stripped = Lines(ctx.CommentStripped()); + std::vector 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> 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 diff --git a/project.cpp b/project.cpp index ce91d44..251a8af 100644 --- a/project.cpp +++ b/project.cpp @@ -1,8 +1,9 @@ -//SPDX-License-Identifier: LGPL-3.0-only -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// 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; @@ -17,22 +18,22 @@ extern "C" Configuration CrafterBuildProject(std::span a .args = depArgs, }); - static auto crafterBuildLib = std::make_unique(); - crafterBuildLib->path = "./"; - crafterBuildLib->name = "crafter.build-lib"; - crafterBuildLib->outputName = "crafter-build"; - ApplyStandardArgs(*crafterBuildLib, args); + static auto CrafterBuildLib = std::make_unique(); + CrafterBuildLib->path = "./"; + CrafterBuildLib->name = "crafter.build-lib"; + CrafterBuildLib->outputName = "crafter-build"; + ApplyStandardArgs(*CrafterBuildLib, args); // Windows builds (native msvc via build.cmd or cross-compiled mingw from // Linux) need a DLL + import lib + launcher exe so LoadProject can // compile project.cpp against a stable ABI boundary. Linux is monolithic. - crafterBuildLib->type = (crafterBuildLib->target == "x86_64-w64-mingw32" || crafterBuildLib->target == "x86_64-pc-windows-msvc") + CrafterBuildLib->type = (CrafterBuildLib->target == "x86_64-w64-mingw32" || CrafterBuildLib->target == "x86_64-pc-windows-msvc") ? ConfigurationType::LibraryDynamic : ConfigurationType::LibraryStatic; - - crafterBuildLib->dependencies = { math, asset }; - crafterBuildLib->defines.push_back({"CRAFTER_BUILD_HAS_ASSET", ""}); + + CrafterBuildLib->dependencies = { math, asset }; + CrafterBuildLib->defines.push_back({"CRAFTER_BUILD_HAS_ASSET", ""}); { - std::array interfaces = { + std::array interfaces = { "interfaces/Crafter.Build", "interfaces/Crafter.Build-Shader", "interfaces/Crafter.Build-Platform", @@ -41,10 +42,11 @@ extern "C" Configuration CrafterBuildProject(std::span a "interfaces/Crafter.Build-External", "interfaces/Crafter.Build-Clang", "interfaces/Crafter.Build-Test", + "interfaces/Crafter.Build-Lint", "interfaces/Crafter.Build-Progress", "interfaces/Crafter.Build-Asset", }; - std::array implementations = { + std::array implementations = { "implementations/Crafter.Build-Shader", "implementations/Crafter.Build-Platform", "implementations/Crafter.Build-Interface", @@ -52,12 +54,13 @@ extern "C" Configuration CrafterBuildProject(std::span a "implementations/Crafter.Build-External", "implementations/Crafter.Build-Clang", "implementations/Crafter.Build-Test", + "implementations/Crafter.Build-Lint", "implementations/Crafter.Build-Progress", "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.source.url = "https://github.com/KhronosGroup/glslang.git"; glslang.source.branch = "main"; @@ -66,7 +69,7 @@ extern "C" Configuration CrafterBuildProject(std::span a // mingw cross-build: skip the standalone executable. We only consume the // libraries, and glslang.exe pulls in libgcc_eh which needs pthread that // mingw-w64 doesn't link by default. - if (crafterBuildLib->target == "x86_64-w64-mingw32") { + if (CrafterBuildLib->target == "x86_64-w64-mingw32") { glslang.options.push_back("-DENABLE_GLSLANG_BINARIES=OFF"); } glslang.includeDirs = { "" }; @@ -78,7 +81,7 @@ extern "C" Configuration CrafterBuildProject(std::span a cfg.outputName = "crafter-build"; ApplyStandardArgs(cfg, args); cfg.type = ConfigurationType::Executable; - cfg.dependencies = { crafterBuildLib.get() }; + cfg.dependencies = { CrafterBuildLib.get() }; { std::array interfaces = {}; std::array implementations = { "implementations/main" }; @@ -90,35 +93,42 @@ extern "C" Configuration CrafterBuildProject(std::span a } if (cfg.target == "x86_64-w64-mingw32" || cfg.target == "x86_64-pc-windows-msvc") { // winsock for the -r wasm port probe (bind/WSAStartup). - crafterBuildLib->linkFlags.push_back("-lws2_32"); + CrafterBuildLib->linkFlags.push_back("-lws2_32"); } // Self-tests link the local crafter-build library and exercise it in // process. The harness (whichever crafter-build invokes `test`) compiles // 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. // Mirrors how downstream consumers link their own libraries into tests. if (cfg.target == "x86_64-pc-linux-gnu") { - cfg.AddTest("HelloWorld").Dependencies({ crafterBuildLib.get() }); - cfg.AddTest("StaticLib").Dependencies({ crafterBuildLib.get() }); - cfg.AddTest("ModuleInterface").Dependencies({ crafterBuildLib.get() }); - cfg.AddTest("DependencyLink").Dependencies({ crafterBuildLib.get() }); - cfg.AddTest("ShaderCompile").Dependencies({ crafterBuildLib.get() }); - cfg.AddTest("StandardArgs").Dependencies({ crafterBuildLib.get() }); - cfg.AddTest("TestRunnerSpec").Dependencies({ crafterBuildLib.get() }); - cfg.AddTest("VariantId").Dependencies({ crafterBuildLib.get() }); - cfg.AddTest("WasiBrowserRuntime").Dependencies({ crafterBuildLib.get() }); - cfg.AddTest("WasmVariants").Dependencies({ crafterBuildLib.get() }); - cfg.AddTest("RunSingleTestExit").Dependencies({ crafterBuildLib.get() }); + cfg.AddTest("HelloWorld").Dependencies({ CrafterBuildLib.get() }); + cfg.AddTest("StaticLib").Dependencies({ CrafterBuildLib.get() }); + cfg.AddTest("ModuleInterface").Dependencies({ CrafterBuildLib.get() }); + cfg.AddTest("DependencyLink").Dependencies({ CrafterBuildLib.get() }); + cfg.AddTest("ShaderCompile").Dependencies({ CrafterBuildLib.get() }); + cfg.AddTest("StandardArgs").Dependencies({ CrafterBuildLib.get() }); + cfg.AddTest("TestRunnerSpec").Dependencies({ CrafterBuildLib.get() }); + cfg.AddTest("VariantId").Dependencies({ CrafterBuildLib.get() }); + cfg.AddTest("WasiBrowserRuntime").Dependencies({ CrafterBuildLib.get() }); + cfg.AddTest("WasmVariants").Dependencies({ CrafterBuildLib.get() }); + cfg.AddTest("RunSingleTestExit").Dependencies({ CrafterBuildLib.get() }); // LoadProject dlopens the synthesized project.so, which references // Crafter:: symbols (HostTarget, Configuration ctors) that have to be // visible from the test exe — same wiring crafter-build itself uses // for project.so. - cfg.AddTest("ConcurrentCacheRace").Dependencies({ crafterBuildLib.get() }) + cfg.AddTest("ConcurrentCacheRace").Dependencies({ CrafterBuildLib.get() }) .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; } diff --git a/tests/ConcurrentCacheRace/main.cpp b/tests/ConcurrentCacheRace/main.cpp index 4d0c66a..eeb09c9 100644 --- a/tests/ConcurrentCacheRace/main.cpp +++ b/tests/ConcurrentCacheRace/main.cpp @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: LGPL-3.0-only -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® #include import std; @@ -27,7 +27,7 @@ namespace { if (const char* env = std::getenv("CRAFTER_BUILD_HOME"); env && *env) { return env; } - for (const char* candidate : { + for (std::string_view candidate : { "/usr/local/share/crafter-build", "/usr/share/crafter-build", }) { @@ -47,8 +47,7 @@ namespace { int main() { fs::path home = FindCrafterBuildHome(); if (home.empty()) { - std::println(std::cerr, - "SKIP: no installed share/crafter-build found and CRAFTER_BUILD_HOME unset"); + std::println(std::cerr, "SKIP: no installed share/crafter-build found and CRAFTER_BUILD_HOME unset"); return 77; } setenv("CRAFTER_BUILD_HOME", home.string().c_str(), 1); @@ -64,9 +63,9 @@ int main() { fs::create_directories(cacheDir); setenv("XDG_CACHE_HOME", cacheDir.string().c_str(), 1); - constexpr int N = 4; + constexpr std::int32_t N = 4; std::vector 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::create_directories(dir); WriteFile(dir / "project.cpp", std::format( @@ -89,7 +88,7 @@ int main() { std::array errors; std::vector threads; threads.reserve(N); - for (int i = 0; i < N; ++i) { + for (std::int32_t i = 0; i < N; ++i) { threads.emplace_back([&, i]() { try { std::array args = {}; @@ -101,8 +100,8 @@ int main() { } for (std::thread& t : threads) t.join(); - int failures = 0; - for (int i = 0; i < N; ++i) { + std::int32_t failures = 0; + for (std::int32_t i = 0; i < N; ++i) { if (!errors[i].empty()) { std::println(std::cerr, "FAIL p{}: {}", i, errors[i]); ++failures; @@ -112,9 +111,7 @@ int main() { fs::remove_all(scratch, ec); if (failures > 0) { - std::println(std::cerr, - "{}/{} concurrent LoadProject() invocations failed (host-cache race)", - failures, N); + std::println(std::cerr, "{}/{} concurrent LoadProject() invocations failed (host-cache race)", failures, N); return 1; } return 0; diff --git a/tests/ConcurrentDependencyReset/fixture/app/main.cpp b/tests/ConcurrentDependencyReset/fixture/app/main.cpp index b58ebb6..403ff2c 100644 --- a/tests/ConcurrentDependencyReset/fixture/app/main.cpp +++ b/tests/ConcurrentDependencyReset/fixture/app/main.cpp @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: LGPL-3.0-only -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® import std; import DepMod; diff --git a/tests/ConcurrentDependencyReset/fixture/deplib/DepMod-impl.cpp b/tests/ConcurrentDependencyReset/fixture/deplib/DepMod-impl.cpp index 0bb279d..465669d 100644 --- a/tests/ConcurrentDependencyReset/fixture/deplib/DepMod-impl.cpp +++ b/tests/ConcurrentDependencyReset/fixture/deplib/DepMod-impl.cpp @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: LGPL-3.0-only -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® module DepMod; diff --git a/tests/ConcurrentDependencyReset/fixture/deplib/DepMod.cppm b/tests/ConcurrentDependencyReset/fixture/deplib/DepMod.cppm index 8565a24..a13eaf0 100644 --- a/tests/ConcurrentDependencyReset/fixture/deplib/DepMod.cppm +++ b/tests/ConcurrentDependencyReset/fixture/deplib/DepMod.cppm @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: LGPL-3.0-only -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® export module DepMod; export int dep_value(); diff --git a/tests/ConcurrentDependencyReset/main.cpp b/tests/ConcurrentDependencyReset/main.cpp index 2383213..792da50 100644 --- a/tests/ConcurrentDependencyReset/main.cpp +++ b/tests/ConcurrentDependencyReset/main.cpp @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: LGPL-3.0-only -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® import std; import Crafter.Build; @@ -101,10 +101,7 @@ int main() { // reset the (cached, never-rebuilt) dependency's module flag. Before the // fix this was false and an intra-config waiter would have deadlocked. if (!dep.interfaces[0]->compiled.load()) { - 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"); + 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"); return 1; } @@ -115,8 +112,7 @@ int main() { } auto run = RunCommandWithTimeout(bin.string(), std::chrono::seconds(10)); if (run.exitCode != 0 || run.timedOut || run.crashed) { - std::println(std::cerr, "consumer exe did not exit cleanly: exit={} output={}", - run.exitCode, run.output); + std::println(std::cerr, "consumer exe did not exit cleanly: exit={} output={}", run.exitCode, run.output); return 1; } if (run.output != "42") { diff --git a/tests/DependencyLink/fixture/main.cpp b/tests/DependencyLink/fixture/main.cpp index c069ebf..c129434 100644 --- a/tests/DependencyLink/fixture/main.cpp +++ b/tests/DependencyLink/fixture/main.cpp @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: LGPL-3.0-only -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® import std; import Calc; diff --git a/tests/DependencyLink/fixture/mathlib/Calc.cppm b/tests/DependencyLink/fixture/mathlib/Calc.cppm index 8eeebcc..c5a2200 100644 --- a/tests/DependencyLink/fixture/mathlib/Calc.cppm +++ b/tests/DependencyLink/fixture/mathlib/Calc.cppm @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: LGPL-3.0-only -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® export module Calc; import std; diff --git a/tests/DependencyLink/main.cpp b/tests/DependencyLink/main.cpp index f2da7ff..d2c9461 100644 --- a/tests/DependencyLink/main.cpp +++ b/tests/DependencyLink/main.cpp @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: LGPL-3.0-only -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® import std; import Crafter.Build; @@ -44,18 +44,15 @@ int main() { } // The import resolves to the dependency's interface, not a local one. if (!app.implementations[0].moduleDependencies.empty()) { - std::println(std::cerr, "expected no local module deps, got {}", - app.implementations[0].moduleDependencies.size()); + std::println(std::cerr, "expected no local module deps, got {}", app.implementations[0].moduleDependencies.size()); return 1; } if (app.implementations[0].externalModuleDependencies.size() != 1) { - std::println(std::cerr, "expected 1 external module dep, got {}", - app.implementations[0].externalModuleDependencies.size()); + std::println(std::cerr, "expected 1 external module dep, got {}", app.implementations[0].externalModuleDependencies.size()); return 1; } if (app.implementations[0].externalModuleDependencies[0].first->name != "Calc") { - std::println(std::cerr, "expected external dep 'Calc', got '{}'", - app.implementations[0].externalModuleDependencies[0].first->name); + std::println(std::cerr, "expected external dep 'Calc', got '{}'", app.implementations[0].externalModuleDependencies[0].first->name); return 1; } @@ -81,8 +78,7 @@ int main() { auto run = RunCommandWithTimeout(bin.string(), std::chrono::seconds(10)); if (run.exitCode != 0 || run.timedOut || run.crashed) { - std::println(std::cerr, "exe did not exit cleanly: exit={} output={}", - run.exitCode, run.output); + std::println(std::cerr, "exe did not exit cleanly: exit={} output={}", run.exitCode, run.output); return 1; } if (run.output != "7") { diff --git a/tests/HelloWorld/fixture/main.cpp b/tests/HelloWorld/fixture/main.cpp index 3ab9bb5..04b6252 100644 --- a/tests/HelloWorld/fixture/main.cpp +++ b/tests/HelloWorld/fixture/main.cpp @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: LGPL-3.0-only -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® import std; int main() { return 0; } diff --git a/tests/HelloWorld/main.cpp b/tests/HelloWorld/main.cpp index b89e8f8..d592b0a 100644 --- a/tests/HelloWorld/main.cpp +++ b/tests/HelloWorld/main.cpp @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: LGPL-3.0-only -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® import std; import Crafter.Build; diff --git a/tests/HouseRules/main.cpp b/tests/HouseRules/main.cpp new file mode 100644 index 0000000..0b3593e --- /dev/null +++ b/tests/HouseRules/main.cpp @@ -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(content.size())); + } + Configuration cfg; + cfg.path = dir; + cfg.name = "house-fixture"; + cfg.outputName = "house-fixture"; + cfg.target = HostTarget(); + std::array ifaces = {}; + std::array 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(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(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& 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; +} diff --git a/tests/Lint/fixture/clean.cpp b/tests/Lint/fixture/clean.cpp new file mode 100644 index 0000000..5c81015 --- /dev/null +++ b/tests/Lint/fixture/clean.cpp @@ -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; +} diff --git a/tests/Lint/fixture/dirty.cpp b/tests/Lint/fixture/dirty.cpp new file mode 100644 index 0000000..1f8404a --- /dev/null +++ b/tests/Lint/fixture/dirty.cpp @@ -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) diff --git a/tests/Lint/main.cpp b/tests/Lint/main.cpp new file mode 100644 index 0000000..c900b27 --- /dev/null +++ b/tests/Lint/main.cpp @@ -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(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 impls) const { + Configuration cfg; + cfg.path = dir; + cfg.name = "fmt-fixture"; + cfg.outputName = "fmt-fixture"; + cfg.target = HostTarget(); + std::array 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 ifaces = {}; + std::array 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; +} diff --git a/tests/ModuleInterface/fixture/Greeter.cppm b/tests/ModuleInterface/fixture/Greeter.cppm index ac85ad1..b6a7f07 100644 --- a/tests/ModuleInterface/fixture/Greeter.cppm +++ b/tests/ModuleInterface/fixture/Greeter.cppm @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: LGPL-3.0-only -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® export module Greeter; import std; diff --git a/tests/ModuleInterface/fixture/main.cpp b/tests/ModuleInterface/fixture/main.cpp index 7d5abf3..c934b0a 100644 --- a/tests/ModuleInterface/fixture/main.cpp +++ b/tests/ModuleInterface/fixture/main.cpp @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: LGPL-3.0-only -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® import std; import Greeter; diff --git a/tests/ModuleInterface/main.cpp b/tests/ModuleInterface/main.cpp index c04c6b1..baa6e7a 100644 --- a/tests/ModuleInterface/main.cpp +++ b/tests/ModuleInterface/main.cpp @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: LGPL-3.0-only -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® import std; import Crafter.Build; @@ -28,15 +28,11 @@ int main() { return 1; } if (cfg.implementations[0].moduleDependencies.size() != 1) { - std::println(std::cerr, - "expected main.cpp to depend on 1 module, got {}", - cfg.implementations[0].moduleDependencies.size()); + std::println(std::cerr, "expected main.cpp to depend on 1 module, got {}", cfg.implementations[0].moduleDependencies.size()); return 1; } if (cfg.implementations[0].moduleDependencies[0]->name != "Greeter") { - std::println(std::cerr, - "expected dep 'Greeter', got '{}'", - cfg.implementations[0].moduleDependencies[0]->name); + std::println(std::cerr, "expected dep 'Greeter', got '{}'", cfg.implementations[0].moduleDependencies[0]->name); return 1; } @@ -56,8 +52,7 @@ int main() { auto run = RunCommandWithTimeout(bin.string(), std::chrono::seconds(10)); if (run.exitCode != 0 || run.timedOut || run.crashed) { - std::println(std::cerr, "binary did not exit cleanly: exit={} output={}", - run.exitCode, run.output); + std::println(std::cerr, "binary did not exit cleanly: exit={} output={}", run.exitCode, run.output); return 1; } if (run.output != "ok-from-module") { diff --git a/tests/RunSingleTestExit/main.cpp b/tests/RunSingleTestExit/main.cpp index 6b48a0a..34f429f 100644 --- a/tests/RunSingleTestExit/main.cpp +++ b/tests/RunSingleTestExit/main.cpp @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: LGPL-3.0-only -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® import std; import Crafter.Build; @@ -7,28 +7,24 @@ namespace fs = std::filesystem; using namespace Crafter; namespace { - int failures = 0; + std::int32_t Failures = 0; void Check(bool cond, std::string_view msg) { if (!cond) { std::println(std::cerr, "FAIL: {}", msg); - ++failures; + ++Failures; } } // Write a tiny POSIX shell script that exits with `code` and return its // path. The harness invokes ` ` through the host shell, so // 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; std::ofstream out(script); out << "#!/bin/sh\nexit " << code << "\n"; out.close(); - 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::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); return script; } } @@ -82,8 +78,8 @@ int main() { fs::remove_all(dir, ec); - if (failures > 0) { - std::println(std::cerr, "{} assertions failed", failures); + if (Failures > 0) { + std::println(std::cerr, "{} assertions failed", Failures); return 1; } return 0; diff --git a/tests/ShaderCompile/main.cpp b/tests/ShaderCompile/main.cpp index 9b19686..20221d2 100644 --- a/tests/ShaderCompile/main.cpp +++ b/tests/ShaderCompile/main.cpp @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: LGPL-3.0-only -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® import std; import Crafter.Build; diff --git a/tests/StandardArgs/main.cpp b/tests/StandardArgs/main.cpp index dfea9bd..b30ca46 100644 --- a/tests/StandardArgs/main.cpp +++ b/tests/StandardArgs/main.cpp @@ -1,17 +1,17 @@ -//SPDX-License-Identifier: LGPL-3.0-only -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® import std; import Crafter.Build; using namespace Crafter; namespace { - int failures = 0; + std::int32_t Failures = 0; void Check(bool cond, std::string_view msg) { if (!cond) { 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"); } - if (failures > 0) { - std::println(std::cerr, "{} assertions failed", failures); + if (Failures > 0) { + std::println(std::cerr, "{} assertions failed", Failures); return 1; } return 0; diff --git a/tests/StaticLib/fixture/Greet.cppm b/tests/StaticLib/fixture/Greet.cppm index 9766403..472d90d 100644 --- a/tests/StaticLib/fixture/Greet.cppm +++ b/tests/StaticLib/fixture/Greet.cppm @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: LGPL-3.0-only -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® export module Greet; import std; diff --git a/tests/StaticLib/main.cpp b/tests/StaticLib/main.cpp index 88d180d..3e0a37b 100644 --- a/tests/StaticLib/main.cpp +++ b/tests/StaticLib/main.cpp @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: LGPL-3.0-only -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® import std; import Crafter.Build; diff --git a/tests/TestRunnerSpec/main.cpp b/tests/TestRunnerSpec/main.cpp index 0e87b7f..e2b8104 100644 --- a/tests/TestRunnerSpec/main.cpp +++ b/tests/TestRunnerSpec/main.cpp @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: LGPL-3.0-only -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® import std; 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); namespace { - int failures = 0; + std::int32_t Failures = 0; void Check(bool cond, std::string_view msg) { if (!cond) { std::println(std::cerr, "FAIL: {}", msg); - ++failures; + ++Failures; } } } @@ -84,8 +84,7 @@ int main() { cfg.target = "aarch64-linux-gnu"; cfg.sysroot = "/opt/alarm-sysroot"; TestRunner r = TestRunner::ForTarget(cfg); - Check(r.exec.find("QEMU_LD_PREFIX=/opt/alarm-sysroot") != std::string::npos, - "ForTarget propagates sysroot to QEMU_LD_PREFIX"); + Check(r.exec.find("QEMU_LD_PREFIX=/opt/alarm-sysroot") != std::string::npos, "ForTarget propagates sysroot to QEMU_LD_PREFIX"); } { // From a Linux host the only sensible way to run x86_64-w64-mingw32 is wine. @@ -101,17 +100,17 @@ int main() { { // Use a unique fake triple so we don't stomp on a real env var the // CI/dev shell may have set. - const char* name = "CRAFTER_BUILD_RUNNER_fake_target_for_unit_test"; - setenv(name, "cmd:fake-tool", 1); + const std::string name = "CRAFTER_BUILD_RUNNER_fake_target_for_unit_test"; + setenv(name.c_str(), "cmd:fake-tool", 1); TestRunner r = TestRunner::FromEnv("fake-target-for-unit-test", TestRunner::Local()); Check(r.name == "cmd:fake-tool", "FromEnv reads CRAFTER_BUILD_RUNNER_"); - unsetenv(name); + unsetenv(name.c_str()); TestRunner fallback = TestRunner::FromEnv("fake-target-for-unit-test", TestRunner::Local()); Check(fallback.IsLocal(), "FromEnv falls back when env var is unset"); } - if (failures > 0) { - std::println(std::cerr, "{} assertions failed", failures); + if (Failures > 0) { + std::println(std::cerr, "{} assertions failed", Failures); return 1; } return 0; diff --git a/tests/VariantId/main.cpp b/tests/VariantId/main.cpp index 9115ba3..0f1b2e4 100644 --- a/tests/VariantId/main.cpp +++ b/tests/VariantId/main.cpp @@ -1,17 +1,17 @@ -//SPDX-License-Identifier: LGPL-3.0-only -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® import std; import Crafter.Build; using namespace Crafter; namespace { - int failures = 0; + std::int32_t Failures = 0; void Check(bool cond, std::string_view msg) { if (!cond) { std::println(std::cerr, "FAIL: {}", msg); - ++failures; + ++Failures; } } @@ -115,8 +115,8 @@ int main() { Check(a.BinDir() != b.BinDir(), "different VariantId yields different BinDir"); } - if (failures > 0) { - std::println(std::cerr, "{} assertions failed", failures); + if (Failures > 0) { + std::println(std::cerr, "{} assertions failed", Failures); return 1; } return 0; diff --git a/tests/WasiBrowserRuntime/main.cpp b/tests/WasiBrowserRuntime/main.cpp index 5007186..103de24 100644 --- a/tests/WasiBrowserRuntime/main.cpp +++ b/tests/WasiBrowserRuntime/main.cpp @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: LGPL-3.0-only -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® import std; import Crafter.Build; @@ -41,9 +41,7 @@ int main() { } else if (fs::exists(systemHome / "wasi-runtime" / "runtime.js")) { setenv("CRAFTER_BUILD_HOME", systemHome.c_str(), 1); } else { - std::println(std::cerr, - "wasi-runtime assets not found near {} or {}", - repoWasi.string(), systemHome.string()); + std::println(std::cerr, "wasi-runtime assets not found near {} or {}", repoWasi.string(), systemHome.string()); return 77; // skipped — environmental, not a code defect } diff --git a/tests/WasmVariants/main.cpp b/tests/WasmVariants/main.cpp index 4214790..06f291f 100644 --- a/tests/WasmVariants/main.cpp +++ b/tests/WasmVariants/main.cpp @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: LGPL-3.0-only -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® import std; 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 / // integration), not here — same boundary the WasiBrowserRuntime test draws. namespace { - int failures = 0; + std::int32_t Failures = 0; 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); } else { std::println(std::cerr, "wasi-runtime assets not found; skipping manifest check"); - return failures ? 1 : 77; + return Failures ? 1 : 77; } Configuration cfg; @@ -103,15 +103,14 @@ int main() { // prefers it when probes pass. auto relaxedPos = j.find("relaxed-simd"); auto baselinePos = j.find("\"wvhelper.wasm\""); - Check(relaxedPos != std::string::npos && baselinePos != std::string::npos && relaxedPos < baselinePos, - "relaxed-simd entry precedes baseline"); + Check(relaxedPos != std::string::npos && baselinePos != std::string::npos && relaxedPos < baselinePos, "relaxed-simd entry precedes baseline"); } fs::remove_all(cfg.path, ec); } - if (failures > 0) { - std::println(std::cerr, "{} assertions failed", failures); + if (Failures > 0) { + std::println(std::cerr, "{} assertions failed", Failures); return 1; } return 0; diff --git a/wasi-runtime/runtime.js b/wasi-runtime/runtime.js index 558f795..2f43876 100644 --- a/wasi-runtime/runtime.js +++ b/wasi-runtime/runtime.js @@ -1,5 +1,5 @@ -//SPDX-License-Identifier: LGPL-3.0-only -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // Minimal browser WASI shim. Loaded by index.html, which sets // window.CRAFTER_WASM_URL before this script runs so a single runtime.js