Merge branch 'claude/project-dependencies': project.cpp dependencies, AGENTS.md
Brings in `#pragma crafter dependency`, the switch to Crafter.LintRules for the house style, and the contributor guide in AGENTS.md. The branch predates the musl host work on master, and both rewrote the command that compiles project.so, so the merge reconciles them by hand: - LoadProject compiles project.so for hostConfig.target (HostTarget(), from master) with the pre-dependency module path and archives from the branch. -fuse-ld=lld appears once, with both reasons in the comment. - ForceHostTooling added -fPIC only for the literal x86_64-pc-linux-gnu triple. hostConfig now comes from HostTarget(), so on Alpine the host is x86_64-alpine-linux-musl and the dependency archive would not have been position-independent. It now matches any *-linux-gnu or *-linux-musl host, as project.cpp and the cmake flags do since the musl change, and its comment no longer argues against HostTarget(). - The ProjectDependency test compared the dependency's target against the glibc triple, which the musl CI job would fail; it compares against HostTarget(). - README keeps master's paragraph on the bootstrap triple. AGENTS.md describes the test block as `if (linuxTarget)`, which is what project.cpp has now. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
commit
c3d97be59e
18 changed files with 784 additions and 1635 deletions
157
AGENTS.md
Normal file
157
AGENTS.md
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
Guidance for coding agents and contributors working on crafter-build itself. The [README](README.md) is the user-facing manual; this file holds what you need to change the build system. When the two disagree, the code wins: check the interface in `interfaces/` and the `--help` output of the built binary.
|
||||||
|
|
||||||
|
## Project overview
|
||||||
|
|
||||||
|
crafter-build is a C++26 modules build system. A project is described by a `project.cpp` that returns a `Crafter::Configuration`; the tool compiles that file to a shared object, loads it in-process and builds what it describes. This repository is self-hosting: its own `project.cpp` builds crafter-build with crafter-build, and its tests link the library and drive `Build()` in-process.
|
||||||
|
|
||||||
|
| Path | What lives there |
|
||||||
|
|---|---|
|
||||||
|
| `interfaces/Crafter.Build-<Partition>.cppm` | Module interface partitions. `Crafter.Build.cppm` is the umbrella that re-exports them. `Crafter.Build-Api.h` defines `CRAFTER_API`. |
|
||||||
|
| `implementations/Crafter.Build-<Partition>.cpp` | Implementation units, one per partition, named `Crafter.Build:<Partition>_impl`. `main.cpp` is a thin wrapper around `Crafter::Run`. |
|
||||||
|
| `project.cpp` | The self-host build description: a `crafter.build-lib` library, a `crafter.build-exe` executable, the test declarations and the lint rules. |
|
||||||
|
| `tests/<Name>/main.cpp` | One self-test per directory; `tests/<Name>/fixture/` holds the sample project it builds. |
|
||||||
|
| `examples/` | Standalone user-facing projects, MIT licensed. |
|
||||||
|
| `build.sh`, `build.cmd` | Bootstrap scripts for Linux and Windows. They compile everything by hand, without crafter-build. |
|
||||||
|
| `wasi-runtime/` | `runtime.js` and `index.html.in`, shipped for browser targets. |
|
||||||
|
| `lib/` | Vendored third-party headers (nlohmann/json, glslang's DirStackFileIncluder). Do not edit. |
|
||||||
|
| `share/crafter-build/`, `bin/`, `build/`, `lib/*.a` | Generated and gitignored. |
|
||||||
|
| `PKGBUILD`, `.forgejo/workflows/ci.yaml` | Arch package and the Forgejo Actions CI. |
|
||||||
|
|
||||||
|
Partitions and what they own:
|
||||||
|
|
||||||
|
- `:Clang` holds `Configuration`, `Build()`, `VariantId()`, `ApplyStandardArgs`, `HostTarget`, the CLI entry point `Crafter::Run`, and the lint and test data types.
|
||||||
|
- `:Platform` runs commands, implements `LoadProject` (compile and load `project.cpp`), the host PCM cache, `#pragma crafter dependency`, and all OS-specific code.
|
||||||
|
- `:Interface` and `:Implementation` model module units, their staleness checks and their compiles.
|
||||||
|
- `:External` handles git + cmake dependencies, `GitProject` and `LocalProject`.
|
||||||
|
- `:Test` holds `AddTest`, `RunTests` and `TestRunner`.
|
||||||
|
- `:Lint` is the lint/format driver plus the libclang token and AST layer.
|
||||||
|
- `:Progress` is console output. `:Shader` compiles shaders through glslang. `:Asset` is the hook into Crafter.Asset.
|
||||||
|
|
||||||
|
## Dev environment
|
||||||
|
|
||||||
|
Prerequisites on Linux: `clang` (with `libclang`, which the AST lint rules load at runtime), `lld`, `libc++` with `/usr/share/libc++/v1/std.cppm`, `cmake`, `git`. Network access to `forgejo.catcrafts.net` and `github.com` is needed: loading `project.cpp` fetches Crafter.Math, Crafter.Asset and Crafter.LintRules into `~/.cache/crafter.build/external/`, and the bootstrap clones glslang. Those sources are unpinned, so every invocation runs `git pull` on them.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./build.sh # bootstrap: bin/crafter-build, lib/libcrafter-build.a, share/crafter-build/
|
||||||
|
./bin/crafter-build # self-host build through project.cpp
|
||||||
|
```
|
||||||
|
|
||||||
|
Three things to know about this loop:
|
||||||
|
|
||||||
|
- `bin/crafter-build` is the bootstrap binary and the harness for every command below. The self-host build writes to `bin/crafter.build-exe-<variant>/crafter-build` and does not replace it.
|
||||||
|
- Changes under `implementations/` are picked up by the self-host build and by the tests, which link the local library. Changes under `interfaces/` are not: the harness compiles against the copies in `share/crafter-build/` and the PCM cache built from them, so a new or changed declaration is invisible until you run `./build.sh` again. The symptom is `use of undeclared identifier` for the symbol you just added.
|
||||||
|
- A new `CRAFTER_API` symbol that a `project.cpp` reaches on its default path also needs `./build.sh`: the old binary does not export it, and `LoadProject` fails with an undefined symbol.
|
||||||
|
|
||||||
|
`./build.sh` clones and builds glslang on first run and takes several minutes; later runs are faster. `./bin/crafter-build clean` deletes `bin/` and `build/` without loading `project.cpp`. Set `CRAFTER_BUILD_HOME` only when running a binary from outside this tree; `CRAFTER_BUILD_MARCH` and `CRAFTER_BUILD_MTUNE` default to `native`.
|
||||||
|
|
||||||
|
Windows builds use `build.cmd` with `LIBCXX_DIR` pointing at a libc++ install; the Linux-to-mingw cross-compile is a separate artifact with a different ABI. See the README. Windows branches of recent features are wired but often unverified: say so in the PR when you touch one and cannot test it.
|
||||||
|
|
||||||
|
## Build, test and style commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/crafter-build # build (self-host)
|
||||||
|
./bin/crafter-build test # build and run every self-test
|
||||||
|
./bin/crafter-build test 'Incremental*' # name glob
|
||||||
|
./bin/crafter-build test --list # enumerate without running
|
||||||
|
./bin/crafter-build test --jobs=1 --timeout=120
|
||||||
|
./bin/crafter-build lint # dry style gate, exit 1 on any finding
|
||||||
|
./bin/crafter-build format # apply the transform rules in place
|
||||||
|
./bin/crafter-build format --check # CI-style dry run
|
||||||
|
./bin/crafter-build clean
|
||||||
|
```
|
||||||
|
|
||||||
|
Before you call a change done, all three must exit 0: `format` (then re-read your diff), `lint`, `test`. CI runs `./build.sh` and `crafter-build test` on every PR; its lint step is currently commented out, so lint is enforced only by you. Failed, crashed and timed-out tests leave their output in `build/test-logs/<name>.log`.
|
||||||
|
|
||||||
|
## Testing instructions
|
||||||
|
|
||||||
|
Tests are declared, not discovered. Each one is a line in `project.cpp`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
cfg.AddTest("IncrementalHeaderChange").Dependencies({ CrafterBuildLib.get() });
|
||||||
|
```
|
||||||
|
|
||||||
|
The source is `tests/<Name>/main.cpp`. The test inherits target, march and the rest from the root configuration and links the local `crafter.build-lib`, so `import Crafter.Build;` resolves to the code in this checkout. A test that calls `LoadProject` also needs `.LinkFlag("-Wl,--export-dynamic").LinkFlag("-ldl")`, as the existing ones show. The declarations sit in an `if (linuxTarget)` block that matches any `*-linux-gnu` or `*-linux-musl` target: the suite is Linux-host only today.
|
||||||
|
|
||||||
|
A test is an ordinary `int main()`. Exit 0 passes, nonzero fails, 77 skips (for a missing tool, never for a failed assertion). Each runs in its own subprocess, in parallel, with a 60 s default timeout.
|
||||||
|
|
||||||
|
Conventions the existing tests follow:
|
||||||
|
|
||||||
|
- Open `main.cpp` with a comment that states the behaviour under test and the failure it guards against. `tests/CompilerWarning/main.cpp` shows the shape.
|
||||||
|
- Collect failures with a `Check(cond, msg)` helper that prints `FAIL: ...` to stderr and counts, then return nonzero at the end. Do not stop at the first failure unless later steps are meaningless without it.
|
||||||
|
- Drive the engine in-process: `Build(cfg, depResults, depMutex)`, `RunTests`, the lint driver. Do not shell out to `bin/crafter-build` and grep its stdout.
|
||||||
|
- Fixtures under `tests/<Name>/fixture/` are inputs. A test that edits or touches files first stages a copy under `fs::temp_directory_path() / "crafter-build-<name>"` and removes it on the next run. Variant sources are checked in as `*.in` files and copied over the original inside the staged copy.
|
||||||
|
- Use a fresh `depResults` map per build pass: the map memoises each configuration's result for one pass, so reusing it skips rebuilds you meant to observe. `Build()` itself is safe to call repeatedly on the same `Configuration`.
|
||||||
|
- Set `cfg.path` before calling `GetInterfacesAndImplementations`, and spell `cFiles` as absolute paths: both resolve against the working directory, not the configuration.
|
||||||
|
|
||||||
|
A bug fix ships with a test that fails before the fix and passes after it, in its own `test:` commit when it documents the behaviour on its own.
|
||||||
|
|
||||||
|
Cross-target runs use `test --target=<triple>` with `--runner=local` or `--runner=cmd:<wrapper>` (wine, qemu-user, valgrind), or `CRAFTER_BUILD_RUNNER_<TRIPLE>` as the persistent form. A failed runner probe skips its tests rather than failing the suite.
|
||||||
|
|
||||||
|
## Code style
|
||||||
|
|
||||||
|
Style is enforced by the 17 rules in [Crafter.LintRules](https://forgejo.catcrafts.net/Catcrafts/Crafter.LintRules), which `project.cpp` pulls in through `#pragma crafter dependency` and applies with `crafter-build format` and `lint`. Most rules are transforms, so run `format` and let it restyle rather than hand-formatting. In short:
|
||||||
|
|
||||||
|
- The first three lines of every source: `// SPDX-License-Identifier: LGPL-3.0-only`, `// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®`, blank. Examples use MIT.
|
||||||
|
- Four-space indentation, no tabs, no trailing whitespace, final newline.
|
||||||
|
- `PascalCase` for types, functions, statics and constants; `camelCase` for variables; a trailing `_` is allowed.
|
||||||
|
- `enum class` only. `std::println` instead of iostream. `std::format` instead of string concatenation. `std::int32_t` and friends instead of `int` or `long` (exempt: `main`, `argc`/`argv`, `extern "C"` and foreign API signatures). No `char*` outside C interop.
|
||||||
|
- One declaration per statement. K&R braces. `if (x) return;` on one line when it fits. The formatter joins wrapped statements that fit on one line, so do not hand-wrap short ones. `if (` and `for (` with a space; no space before a call's `(`.
|
||||||
|
- `const` on scalar locals that are never written; `constexpr` on constants with constant initialisers.
|
||||||
|
|
||||||
|
Suppress one finding with `// lint-disable-next-line <rule>` or a whole file with `// lint-disable-file <rule>`. Both are rare in this tree and need a reason.
|
||||||
|
|
||||||
|
Beyond the rules:
|
||||||
|
|
||||||
|
- `import std;` everywhere; standard headers are never `#include`d. C headers (`clang-c/Index.h`, `dlfcn.h`, `windows.h`) go in the global module fragment after `module;`.
|
||||||
|
- Mark every function or method that `project.cpp` or a downstream consumer calls with `CRAFTER_API` in the interface. It is the Windows DLL boundary.
|
||||||
|
- Comments explain why: the failure mode, the constraint, the rejected alternative. The codebase carries long comments on purpose. Do not strip them, and write them for new non-obvious code. Commit bodies follow the same rule.
|
||||||
|
- Platform splits live in `:Platform`, guarded by `CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc` and `_x86_64_w64_mingw32`. In `project.cpp` and tests, branch on `cfg.target` strings instead of preprocessor macros.
|
||||||
|
- A build step fails when its command exits nonzero, never because it printed. Use `RunBuildCommand` or `RunCommandChecked`; warnings go to `Progress::Diagnostic`.
|
||||||
|
- Every output path goes through `Configuration::BinDir()`, `BuildDir()` and `PcmDir()`. Hand-rolled `path / "bin" / ...` clobbered variants before; do not add new ones.
|
||||||
|
- Anything that changes what gets compiled or bundled belongs in `VariantId()`. It already hashes type, debug, sysroot, defines, compile flags, wasm variant flags and uninterpreted project args.
|
||||||
|
|
||||||
|
## Design rules
|
||||||
|
|
||||||
|
These were decided deliberately. Do not undo them without the maintainer's say-so.
|
||||||
|
|
||||||
|
1. **Configuration is C++.** No JSON, YAML, TOML or DSL for project, runner or lint configuration. Lint rules are C++ callbacks; there is no clang-format or clang-tidy integration and no separate "format rule" type. A rule that calls `ctx.SetContent` is a transform, and that is the whole mechanism.
|
||||||
|
2. **Per-project artifacts go in `<project>/build` and `<project>/bin`. Only cross-project things go in `~/.cache/crafter.build/`:** the std PCM and Crafter.Build PCMs per target and march, and external clones keyed by their spec hash.
|
||||||
|
3. **Sources ship, PCMs build locally.** Packages install `*.cppm` under `share/crafter-build/`. Nothing target-baked ships except the executable itself, and that defaults to a portable `-march` in `PKGBUILD` and CI.
|
||||||
|
4. **Linux is a monolithic executable with `--export-dynamic`; Windows is a DLL plus a launcher.** The asymmetry is intentional.
|
||||||
|
5. **Convention over configuration.** Lead with the zero-config path. Env var, then CLI flag, then full `project.cpp` are the escalation, not the entry point.
|
||||||
|
6. **One skip mechanism** (runner probe plus exit 77) and **one `TestRunner` shape** (template fields with `{bin}`, `{bundle}` and similar placeholders; `Cmd(...)` covers any wrapper). Do not add a special-case runner class or a hardcoded "skip on this OS" branch.
|
||||||
|
7. **The house-style rules stay in their own repo.** They are not library API of crafter-build and are not forced on downstream projects.
|
||||||
|
8. **Fix warts inline; do not stack workarounds.** The previous version of this tool died of accumulated hacks. If a change needs a shortcut, say so in the PR rather than hiding it.
|
||||||
|
9. **`project.cpp` keeps the library/executable split.** `crafter.build-lib` owns the sources, dependencies and glslang; `crafter.build-exe` is `main.cpp` plus link flags.
|
||||||
|
10. **Host-tooling builds** (the target of a `#pragma crafter dependency`) get `-fPIC` and the host triple scoped to that build. Keep it scoped.
|
||||||
|
|
||||||
|
## Commit and PR guidelines
|
||||||
|
|
||||||
|
- Branch from `master` and open the PR against `master` on the project's Forgejo at `forgejo.catcrafts.net/Catcrafts/Crafter.Build`. Issue work uses `claude/issue-<n>` branch names. Pushes go over SSH; HTTPS push is disabled on the server.
|
||||||
|
- Subject line: `type(scope): imperative summary`, with `feat`, `fix`, `refactor`, `docs` or `test`; the scope (`lint`, `incremental`, ...) is optional. Body: what was wrong, why, and why the fix has this shape, wrapped at about 75 columns. Read `git log` for the voice.
|
||||||
|
- Separate commits for the fix, its test and the README change when each stands on its own.
|
||||||
|
- Update the README in the same PR when user-visible behaviour changes.
|
||||||
|
- `format`, `lint` and `test` green before pushing. CI runs the bootstrap and the test suite on PRs and publishes release archives on `master`.
|
||||||
|
- Never commit `bin/`, `build/`, `share/crafter-build/`, `lib/*.a` or fixture build outputs (all gitignored), and never edit the vendored files in `lib/`.
|
||||||
|
- Keep CI's `CRAFTER_BUILD_MARCH=x86-64-v2` for the bootstrap: the runner's CPU cannot execute v3 code, and the v3 and v4 artifacts are produced from the v2 binary.
|
||||||
|
|
||||||
|
## Security considerations
|
||||||
|
|
||||||
|
- `project.cpp` is compiled and loaded into the crafter-build process, and every `GitProject`, `#pragma crafter dependency` and `ExternalDependency` clones a repository and runs its build on the host. Treat a dependency URL as code you are choosing to run. This repo's own dependencies are sibling Catcrafts repos tracking their default branch on purpose; pin with `commit` when adding anything else, and whenever reproducibility matters.
|
||||||
|
- Unpinned dependencies `git pull` on every invocation, so builds and tests need network. A missing or private repo surfaces as a clone failure before anything is built.
|
||||||
|
- `--runner=cmd:<command>` executes the named wrapper around every test binary. Wrappers come from `--runner=` or `CRAFTER_BUILD_RUNNER_*`; do not hardcode hosts, wrappers or credentials in `project.cpp` or tests.
|
||||||
|
- The linter loads `libclang` at runtime by name. `CRAFTER_BUILD_LIBCLANG` pins an exact path and must fail loudly when wrong, never fall back to another library.
|
||||||
|
- CI force-pushes the rolling `latest` tag and publishes a prerelease on every push to `master` with the workflow's own token. Do not add secrets to the workflow or echo the environment in it.
|
||||||
|
- Tests write only under `fs::temp_directory_path()` and the fixture's own `bin/` and `build/`. Keep it that way.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- A `#pragma crafter dependency` target is rebuilt on every load on purpose: clang crashes rather than erroring when it imports a BMI whose own imported PCM was rebuilt underneath it, and the depfile check is what catches a regenerated host cache. Do not optimise the rebuild away.
|
||||||
|
- A host-tooling library such as Crafter.LintRules may `import Crafter.Build;` without declaring it as a dependency, because `LoadProject` fills the cache it resolves from. Listing Crafter.Build in its `cfg.dependencies` would clone and build all of crafter-build for every consumer.
|
||||||
|
- `crafter-build clean` leaves a pragma dependency's build alone; it lives with the clone in the cache.
|
||||||
|
- The bootstrap binary is compiled without `CRAFTER_BUILD_HAS_ASSET`, so `cfg.assets` only works in the self-hosted binary. Changing asset extensions means updating two maps in `Crafter.Build-Clang.cpp`: `assetOutput` and the transitive copy loop.
|
||||||
|
- A warning is printed only when its unit actually recompiles. A build with nothing to do is silent, which is not evidence that a warning is gone.
|
||||||
|
- `VariantId()` names the output directory, so CI globs `bin/crafter.build-exe-<target>-<march>-*/` rather than predicting the hash.
|
||||||
|
- When driving `build.cmd` from a Windows `cmd` shell over SSH: write `set VAR=value&&next` without a space before `&&`, and `if exist X rmdir /S /Q X` instead of chaining `rmdir` with `&&`.
|
||||||
132
README.md
132
README.md
|
|
@ -1,11 +1,6 @@
|
||||||
# Crafter Build
|
# Crafter Build
|
||||||
|
|
||||||
A C++26 modules build system. Project descriptions are written in C++ — no JSON, no Lua, no embedded DSL. You write a `project.cpp` that constructs a `Configuration` and returns it; Crafter Build compiles, loads, and executes it.
|
A C++26 modules build system. Project descriptions are written in C++ with no JSON, no Lua, no embedded DSL. You write a `project.cpp` that constructs a `Configuration` and returns it; Crafter Build compiles, loads, and executes it.
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
- **Linux**: working end-to-end, packageable as a distro package. Verified on Arch Linux via [PKGBUILD](PKGBUILD) in a fresh container.
|
|
||||||
- **Windows**: working end-to-end, including the test runner. Bootstrap (`build.cmd`) produces `crafter-build.exe` + `crafter-build.dll` + `crafter-build.lib`; user `project.cpp` links against the import lib via the host-controlled DLL boundary. `crafter-build test` runs natively on Windows hosts; cross-OS runs back to Linux via WSL2 (`--runner=wsl`).
|
|
||||||
|
|
||||||
## Quick start (Linux)
|
## Quick start (Linux)
|
||||||
|
|
||||||
|
|
@ -16,8 +11,6 @@ Bootstrap requires `clang`, `cmake`, `git`, `lld`, and `libc++`.
|
||||||
./bin/crafter-build # rebuild via project.cpp
|
./bin/crafter-build # rebuild via project.cpp
|
||||||
```
|
```
|
||||||
|
|
||||||
For distro-packaged installs, `crafter-build` finds its modules at `<prefix>/share/crafter-build/` automatically — no env var required.
|
|
||||||
|
|
||||||
The bootstrap targets the host toolchain's own triple (`clang++ -print-target-triple`): `x86_64-pc-linux-gnu` on glibc distros, `x86_64-alpine-linux-musl` on Alpine/postmarketOS (`apk add clang clang-dev lld libc++-dev llvm-libunwind-dev llvm-runtimes llvm llvm-dev cmake build-base python3 git`). Override with `CRAFTER_BUILD_TARGET=`. CI ships both: `crafter-build-linux-x86_64-v{2,3,4}.tar.gz` (glibc) and `crafter-build-linux-x86_64-musl-v2.tar.gz` (musl, needs `libc++` at runtime — the launcher dlopens `project.so`, so it cannot be static).
|
The bootstrap targets the host toolchain's own triple (`clang++ -print-target-triple`): `x86_64-pc-linux-gnu` on glibc distros, `x86_64-alpine-linux-musl` on Alpine/postmarketOS (`apk add clang clang-dev lld libc++-dev llvm-libunwind-dev llvm-runtimes llvm llvm-dev cmake build-base python3 git`). Override with `CRAFTER_BUILD_TARGET=`. CI ships both: `crafter-build-linux-x86_64-v{2,3,4}.tar.gz` (glibc) and `crafter-build-linux-x86_64-musl-v2.tar.gz` (musl, needs `libc++` at runtime — the launcher dlopens `project.so`, so it cannot be static).
|
||||||
|
|
||||||
To build the system as a distro package on Arch:
|
To build the system as a distro package on Arch:
|
||||||
|
|
@ -26,33 +19,6 @@ To build the system as a distro package on Arch:
|
||||||
makepkg -si # uses CRAFTER_BUILD_MARCH=x86-64-v3 by default for portability
|
makepkg -si # uses CRAFTER_BUILD_MARCH=x86-64-v3 by default for portability
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick start (Windows)
|
|
||||||
|
|
||||||
Two ways to get a working `crafter-build` on Windows: native build via `build.cmd` (MSVC ABI), or download the cross-compiled mingw artifact from CI (mingw ABI). They produce different binaries with different ABI requirements; pick one and stick with it.
|
|
||||||
|
|
||||||
**Native MSVC build** — what `build.cmd` does, what CI doesn't ship.
|
|
||||||
|
|
||||||
Need clang+lld targeting `x86_64-pc-windows-msvc` and a Windows libc++ install pointed to by `LIBCXX_DIR`. Then `build.cmd` produces `bin/{crafter-build.exe, crafter-build.dll, crafter-build.lib}`.
|
|
||||||
|
|
||||||
**Cross-compiled mingw artifact** — what CI ships from the Linux runner.
|
|
||||||
|
|
||||||
The CI's Windows zip contains `crafter-build.exe` + `crafter-build.dll` + `libcrafter-build.dll.a`. The DLL is **statically linked against libstdc++/libgcc/libwinpthread**, so it doesn't depend on a particular runtime DLL being on the consumer's PATH. To use it for builds the consumer needs the mingw-w64 sysroot installed, since `crafter-build.exe` invokes the user's clang to compile their `project.cpp`:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# one-time setup
|
|
||||||
winget install MSYS2.MSYS2
|
|
||||||
C:\msys64\usr\bin\pacman.exe -S --noconfirm mingw-w64-ucrt-x86_64-toolchain
|
|
||||||
# add C:\msys64\ucrt64\bin to PATH (so user-built executables can find msys2 clang)
|
|
||||||
|
|
||||||
# then any project with a project.cpp:
|
|
||||||
cd <project-dir>
|
|
||||||
crafter-build.exe -r
|
|
||||||
```
|
|
||||||
|
|
||||||
Match the toolchain flavor to the artifact: **UCRT mingw** (`mingw-w64-ucrt-x86_64-toolchain`, sysroot at `C:\msys64\ucrt64`) — the artifact is cross-compiled with UCRT, the older MSVCRT mingw won't ABI-match. Override the auto-detected sysroot path with `CRAFTER_MINGW_DIR=...` if you have mingw-w64 installed somewhere else.
|
|
||||||
|
|
||||||
A project.cpp built on Windows by this artifact gets compiled with `--target=x86_64-w64-mingw32` by default, producing self-contained mingw exes (libstdc++/libgcc/libwinpthread statically linked, no runtime DLLs alongside). For MSVC-ABI output set `cfg.target = "x86_64-pc-windows-msvc"` in your `project.cpp` *and* point `LIBCXX_DIR` at a Windows libc++ install — same prerequisite as the native build.
|
|
||||||
|
|
||||||
## Writing a project.cpp
|
## Writing a project.cpp
|
||||||
|
|
||||||
Crafter Build loads a `project.cpp` from the current directory. The file exports one function that returns a populated `Configuration`:
|
Crafter Build loads a `project.cpp` from the current directory. The file exports one function that returns a populated `Configuration`:
|
||||||
|
|
@ -79,7 +45,7 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Run `crafter-build` in that directory; outputs land at `bin/myapp-<target>-<march>/myapp` and intermediates at `build/myapp-<target>-<march>/`. Add `-r` to run the produced executable straight after a successful build (host targets only — for cross targets use `crafter-build test --runner=...`).
|
Run `crafter-build` in that directory; outputs land at `bin/myapp-<target>-<march>/myapp` and intermediates at `build/myapp-<target>-<march>/`. Add `-r` to run the produced executable straight after a successful build.
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
|
|
@ -104,6 +70,34 @@ Clones live at `~/.cache/crafter.build/external/<name>-<hash>/`, keyed by `(url,
|
||||||
|
|
||||||
**Header-only git deps** — same as cmake deps but `builder = ExternalBuilder::None`. Just clones and propagates `-I` flags.
|
**Header-only git deps** — same as cmake deps but `builder = ExternalBuilder::None`. Just clones and propagates `-I` flags.
|
||||||
|
|
||||||
|
**Sibling crafter-build projects** — `GitProject` / `LocalProject` load another project's `project.cpp` in-process and return a `Configuration*` for `cfg.dependencies`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
Configuration* math = GitProject({
|
||||||
|
.source = { .url = "https://forgejo.catcrafts.net/Catcrafts/Crafter.Math.git" }, // .branch or .commit to pin
|
||||||
|
.args = depArgs, // forwarded to its CrafterBuildProject
|
||||||
|
});
|
||||||
|
Configuration* sibling = LocalProject({ .projectFile = "../Crafter.Math/project.cpp", .args = depArgs });
|
||||||
|
cfg.dependencies = { math };
|
||||||
|
```
|
||||||
|
|
||||||
|
Clones share the external cache and identical specs share one `Configuration`. An unpinned source is `git pull`ed on every invocation; set `.commit` for reproducible, offline-after-first-fetch builds.
|
||||||
|
|
||||||
|
### Dependencies of project.cpp itself
|
||||||
|
|
||||||
|
`project.cpp` can import modules from another crafter-build project. The file has to compile before it can say anything, so the dependency is a directive next to the `import` it enables:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
import std;
|
||||||
|
import Crafter.Build;
|
||||||
|
#pragma crafter dependency "https://forgejo.catcrafts.net/Catcrafts/Crafter.LintRules.git"
|
||||||
|
import Crafter.LintRules;
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
#pragma crafter dependency "<source>" [branch "<name>" | commit "<sha>"] [project "<file>"]
|
||||||
|
```
|
||||||
|
|
||||||
## Install layout
|
## Install layout
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
@ -113,31 +107,6 @@ Clones live at `~/.cache/crafter.build/external/<name>-<hash>/`, keyed by `(url,
|
||||||
~/.cache/crafter.build/<target>-<march>/ # std.pcm + Crafter.Build*.pcm, built locally
|
~/.cache/crafter.build/<target>-<march>/ # std.pcm + Crafter.Build*.pcm, built locally
|
||||||
```
|
```
|
||||||
|
|
||||||
Sources ship instead of PCMs because libc++ ABI varies between machines — the user's machine builds its own PCMs against its own libc++ on first run (~6-8s one-time cost), making the install resilient to libc++ version differences across distros.
|
|
||||||
|
|
||||||
The static archive is for downstream projects that want to use Crafter.Build *as a library* (construct `Configuration` objects, call `Build()`, etc., directly in C++) without invoking the CLI. Link with `-lcrafter-build` plus the project's external dep libs (`-lSPIRV -lglslang -lOSDependent -lMachineIndependent -lglslang-default-resource-limits -lGenericCodeGen`) and `-ldl` (for `LoadProject`'s dlopen path on Linux). On Windows the equivalent is `bin/crafter-build-static.lib`.
|
|
||||||
|
|
||||||
## Build incrementality
|
|
||||||
|
|
||||||
Per-import precise tracking for both within-project and cross-project module dependencies:
|
|
||||||
|
|
||||||
- Touch `lib/Hello.cppm` → only consumers of `Hello` rebuild.
|
|
||||||
- Touch `lib/Other.cppm` → only consumers of `Other` rebuild.
|
|
||||||
- Touch a header → only the units that `#include` it (directly or transitively) rebuild.
|
|
||||||
- External CMake dep produces fresh `.a` files → whole project rebuilds (deliberately coarse — cmake-dep changes are rare).
|
|
||||||
|
|
||||||
Diamond deps (`A → {B, C}; B → X; C → X`) build `X` exactly once via a `std::shared_future<BuildResult>` cache.
|
|
||||||
|
|
||||||
Tracking is derived from each translation unit's `import` statements, which are scanned when the sources are declared. Every kind of unit is scanned — primary module interfaces, partitions and implementation units alike — and an interface that imports a sibling module in the same `Configuration` also gets its compile ordered behind it. `cfg.dependencies` is often assigned *afterwards* — `AddTest` works that way — so `Build()` re-resolves any import that matched nothing at scan time before it compares mtimes. Without all of that, a consumer of a dependency's module carried no edge to it at all: adding a data member to that dependency's interface rebuilt the library, relinked the consumer, and left the consumer's object compiled against the old class layout. Nothing fails to link when a member is added, so the result was a working build and a crash later.
|
|
||||||
|
|
||||||
`#include`s are tracked separately, because scanning can't see them: a header pulls in more headers, and which ones are open depends on the preprocessor state at the point of inclusion. So every compile is asked to report what it actually opened — `clang -MD -MF <artifact>.d`, written next to the object or BMI it belongs to — and the next staleness check reads that file back and compares every prerequisite's mtime against the artifact. C sources are covered the same way, and there they are the *only* dependency record. An artifact whose depfile is missing, or that names a prerequisite which no longer exists, is rebuilt once: neither state is evidence of freshness. Before this, a header edit changed no `.cppm` or `.cpp` mtime at all, so a build reported nothing to do and left objects compiled against the old contents — the same mixed-layout failure as above, reached through `#include` instead of `import`.
|
|
||||||
|
|
||||||
Everything that changes what gets built belongs in the variant id, since it names the `bin/` and `build/` directory. That includes project args crafter-build itself doesn't interpret: `crafter-build` and `crafter-build -- --no-webgpu` get separate directories rather than interleaving their outputs in one. The cached host PCMs under `<cache>/crafter.build/<target>-<march>/` are shared by every crafter-build on the machine, so they are invalidated by a hash of the module sources rather than by mtime — an mtime can't tell a newer PCM from one built by a different install.
|
|
||||||
|
|
||||||
`crafter-build clean` removes the project's `bin/` and `build/` trees. It doesn't load `project.cpp`, so it still works when the project no longer compiles.
|
|
||||||
|
|
||||||
A build step fails when its command exits non-zero — never because it printed something. Warnings are printed to stderr and the build carries on; nothing here implies `-Werror`, so add it to `cfg.compileFlags` if you want warnings to be fatal, and they will then be fatal every time. Note that a warning only appears when the unit it belongs to is actually recompiled, so a build with nothing to do is silent.
|
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
Tests live under `tests/<Name>/`. The simplest case is a single C++ file:
|
Tests live under `tests/<Name>/`. The simplest case is a single C++ file:
|
||||||
|
|
@ -152,12 +121,6 @@ Run them all:
|
||||||
crafter-build test
|
crafter-build test
|
||||||
```
|
```
|
||||||
|
|
||||||
Auto-discovery walks `tests/` one level deep. Three escalation layers:
|
|
||||||
|
|
||||||
1. **`tests/<Name>/main.cpp`** with no `project.cpp` — a Configuration is synthesized: top-level `*.cpp` files become implementations, `interfaces/*.cppm` become module interfaces, target = the run's `--target=` (host triple by default). Most tests need nothing more.
|
|
||||||
2. **`tests/<Name>/project.cpp`** — full control. Use this for tests with defines, dependencies, or a non-default target.
|
|
||||||
3. Folders starting with `_` or `.` are skipped, so `tests/_shared/` can hold cross-test code without becoming a test itself.
|
|
||||||
|
|
||||||
Test conventions:
|
Test conventions:
|
||||||
|
|
||||||
- **Exit codes**: `0` = ✅ pass, nonzero = ❌ fail, **`77`** = ⏭ skipped (autoconf convention; use it for runtime opt-outs like "tool not on PATH").
|
- **Exit codes**: `0` = ✅ pass, nonzero = ❌ fail, **`77`** = ⏭ skipped (autoconf convention; use it for runtime opt-outs like "tool not on PATH").
|
||||||
|
|
@ -260,7 +223,7 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>)
|
||||||
|
|
||||||
## Linting & formatting
|
## 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:
|
Lint rules are C++ in `project.cpp` with no `.lintrc`, no YAML, and no built-in rules. A rule is a named callback that runs once per source file; register it on the `Configuration` before returning:
|
||||||
|
|
||||||
```cpp
|
```cpp
|
||||||
cfg.AddLintRule("no-tabs", [](Crafter::LintContext& ctx) {
|
cfg.AddLintRule("no-tabs", [](Crafter::LintContext& ctx) {
|
||||||
|
|
@ -271,7 +234,7 @@ cfg.AddLintRule("no-tabs", [](Crafter::LintContext& ctx) {
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
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:
|
A rule that calls `ctx.SetContent(newContent)` is a **transform** which is defined once, it powers both verbs: `lint` reports where the transform would change the file (dry, never writes), and `format` applies it to disk:
|
||||||
|
|
||||||
```cpp
|
```cpp
|
||||||
cfg.AddLintRule("final-newline", [](Crafter::LintContext& ctx) {
|
cfg.AddLintRule("final-newline", [](Crafter::LintContext& ctx) {
|
||||||
|
|
@ -313,12 +276,6 @@ Suppression is comment-based :
|
||||||
// lint-disable-file — all rules, whole file
|
// lint-disable-file — all rules, whole file
|
||||||
```
|
```
|
||||||
|
|
||||||
Directives silence findings **and** stop `format` from rewriting the suppressed lines. The driver handles this automatically for reports and line-preserving transforms; a custom transform that merges or splits lines must ask `ctx.Suppressed(rule, line)` for the lines it touches (the built-in house rules do).
|
|
||||||
|
|
||||||
The linted file set is the project's own sources: module interfaces (+ partitions), implementations, `cFiles`, `cuda`, shader sources, declared tests' sources, and `project.cpp` itself — for the root `Configuration` plus every transitive dependency whose path lies inside the project root. `GitProject` / external dependencies are foreign code and are skipped, as are `files`/`buildFiles`/`assets` (data, not source). Rules registered on any in-root config are collected and deduplicated by name, so with a lib/exe split you can attach them to either. Rules run in registration order, each seeing the previous transform's output; a rule that throws is reverted and becomes a finding instead of aborting the run. Files are read and written as bytes — line views carry a trailing `\r` on CRLF files, so transforms that should be CRLF-safe must handle it (see the `trim-trailing-ws` dogfood rule).
|
|
||||||
|
|
||||||
This repo dogfoods the feature with its full house style — 15 rules in [lint-rules.h](lint-rules.h) (naming conventions, K&R braces, `enum class`, `std::println`, fixed-width integer types, `std::format` over concatenation, wrap-joining, whitespace hygiene), registered by [project.cpp](project.cpp) via one `ProjectLint::AddProjectLintRules(cfg)` call. The header is deliberately project-local, not part of the crafter-build library — copy it into a sibling repo to adopt the same style there; `tests/HouseRules/` validates every transform. Most rules are transforms: `crafter-build format` mechanically restyled this entire codebase (~150 fixes), with only expression rewrites the operand scanner can't prove safe (ternaries, raw-string lines) reported for hand-fixing.
|
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
[examples/](examples/) contains progressively larger self-contained projects:
|
[examples/](examples/) contains progressively larger self-contained projects:
|
||||||
|
|
@ -328,33 +285,10 @@ This repo dogfoods the feature with its full house style — 15 rules in [lint-r
|
||||||
| [hello-world](examples/hello-world/) | Smallest possible project: `project.cpp` + `main.cpp` |
|
| [hello-world](examples/hello-world/) | Smallest possible project: `project.cpp` + `main.cpp` |
|
||||||
| [with-module](examples/with-module/) | Adding a C++ module interface |
|
| [with-module](examples/with-module/) | Adding a C++ module interface |
|
||||||
| [library](examples/library/) | Library + executable, linked via `cfg.dependencies` |
|
| [library](examples/library/) | Library + executable, linked via `cfg.dependencies` |
|
||||||
| [tests](examples/tests/) | Auto-discovered tests + tests linking the parent library |
|
| [tests](examples/tests/) | tests linking the parent library |
|
||||||
|
|
||||||
Each builds standalone: `cd examples/<name> && crafter-build`.
|
Each builds standalone: `cd examples/<name> && crafter-build`.
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
- **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.
|
|
||||||
|
|
||||||
## Compatibility / portability
|
|
||||||
|
|
||||||
- Linux: x86_64. Tested on Arch in a clean container.
|
|
||||||
- Windows: x86_64 MSVC ABI. `build.cmd` produces `bin/crafter-build.{exe,dll,lib}`; userprojs verified end-to-end (`project.cpp` → `project.dll` loaded by the host → `userapp.exe` built and run).
|
|
||||||
|
|
||||||
The `CRAFTER_BUILD_MARCH` and `CRAFTER_BUILD_MTUNE` env vars override the default `-march=native` / `-mtune=native` for distro CI builds:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
CRAFTER_BUILD_MARCH=x86-64-v3 CRAFTER_BUILD_MTUNE=generic ./build.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Roadmap
|
|
||||||
|
|
||||||
- [ ] Target-triple auto-detection (`clang -print-target-triple`)
|
|
||||||
- [ ] Prefer system glslang when available, fall back to git+cmake
|
|
||||||
- [ ] Order-preserving deduplication of `BuildResult.libs` (current `unordered_set` doesn't preserve link order — only matters for cyclic static libs)
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This project is licensed under the GNU Lesser General Public License, version 3 only (LGPL-3.0-only). See [LICENSE](LICENSE) for the license text. The LGPL supplements the GNU General Public License version 3, a copy of which is included as [LICENSE.GPL](LICENSE.GPL).
|
This project is licensed under the GNU Lesser General Public License, version 3 only (LGPL-3.0-only). See [LICENSE](LICENSE) for the license text. The LGPL supplements the GNU General Public License version 3, a copy of which is included as [LICENSE.GPL](LICENSE.GPL).
|
||||||
|
|
|
||||||
|
|
@ -265,6 +265,21 @@ fs::path Crafter::StdPcmDir(const Configuration& config) {
|
||||||
return GetCacheDir()/stdPcmKey;
|
return GetCacheDir()/stdPcmKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fs::path Crafter::ExpectedOutput(const Configuration& config) {
|
||||||
|
const fs::path dir = config.BinDir();
|
||||||
|
const bool windowsTarget = config.target == "x86_64-w64-mingw32" || config.target == "x86_64-pc-windows-msvc";
|
||||||
|
if (config.type == ConfigurationType::Executable) {
|
||||||
|
if (config.target.starts_with("wasm32")) return dir / std::format("{}.wasm", config.outputName);
|
||||||
|
return config.target == "x86_64-w64-mingw32" ? dir / std::format("{}.exe", config.outputName) : dir / config.outputName;
|
||||||
|
}
|
||||||
|
if (config.type == ConfigurationType::LibraryStatic) {
|
||||||
|
return windowsTarget ? dir / std::format("{}.lib", config.outputName) : dir / std::format("lib{}.a", config.outputName);
|
||||||
|
}
|
||||||
|
// LibraryDynamic — the .dll on Windows targets so an mtime check sees the
|
||||||
|
// freshly linked library; on Unix the .so is that file.
|
||||||
|
return windowsTarget ? dir / std::format("{}.dll", config.outputName) : dir / std::format("lib{}.so", config.outputName);
|
||||||
|
}
|
||||||
|
|
||||||
CompileCommand Crafter::GetCompileCommand(const Configuration& config) {
|
CompileCommand Crafter::GetCompileCommand(const Configuration& config) {
|
||||||
CompileCommand out;
|
CompileCommand out;
|
||||||
out.stdPcmDir = StdPcmDir(config);
|
out.stdPcmDir = StdPcmDir(config);
|
||||||
|
|
@ -1042,34 +1057,13 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
|
||||||
// was rebuilt by an earlier run (so dep.repack is false this time around)
|
// was rebuilt by an earlier run (so dep.repack is false this time around)
|
||||||
// but the consumer was never relinked against the new dep.
|
// but the consumer was never relinked against the new dep.
|
||||||
{
|
{
|
||||||
auto expectedOutputFor = [](const Configuration& c) -> fs::path {
|
fs::path expected = ExpectedOutput(config);
|
||||||
fs::path dir = c.BinDir();
|
|
||||||
if (c.type == ConfigurationType::Executable) {
|
|
||||||
if (c.target.starts_with("wasm32")) return dir / (std::format("{}.wasm", c.outputName));
|
|
||||||
return c.target == "x86_64-w64-mingw32"
|
|
||||||
? dir / (std::format("{}.exe", c.outputName))
|
|
||||||
: dir / c.outputName;
|
|
||||||
}
|
|
||||||
if (c.type == ConfigurationType::LibraryStatic) {
|
|
||||||
return c.target == "x86_64-w64-mingw32" || c.target == "x86_64-pc-windows-msvc"
|
|
||||||
? dir / std::format("{}.lib", c.outputName)
|
|
||||||
: dir / std::format("lib{}.a", c.outputName);
|
|
||||||
}
|
|
||||||
// LibraryDynamic — point at the .dll on Windows targets so the
|
|
||||||
// mtime check sees the newly-built DLL; on Unix the .so suffices.
|
|
||||||
if (c.target == "x86_64-w64-mingw32" || c.target == "x86_64-pc-windows-msvc") {
|
|
||||||
return dir / std::format("{}.dll", c.outputName);
|
|
||||||
}
|
|
||||||
return dir / std::format("lib{}.so", c.outputName);
|
|
||||||
};
|
|
||||||
|
|
||||||
fs::path expected = expectedOutputFor(config);
|
|
||||||
if (!fs::exists(expected)) {
|
if (!fs::exists(expected)) {
|
||||||
buildResult.repack = true;
|
buildResult.repack = true;
|
||||||
} else {
|
} else {
|
||||||
auto consumerMtime = fs::last_write_time(expected);
|
auto consumerMtime = fs::last_write_time(expected);
|
||||||
for (Configuration* dep : config.dependencies) {
|
for (Configuration* dep : config.dependencies) {
|
||||||
fs::path depArtifact = expectedOutputFor(*dep);
|
fs::path depArtifact = ExpectedOutput(*dep);
|
||||||
if (fs::exists(depArtifact) && fs::last_write_time(depArtifact) > consumerMtime) {
|
if (fs::exists(depArtifact) && fs::last_write_time(depArtifact) > consumerMtime) {
|
||||||
buildResult.repack = true;
|
buildResult.repack = true;
|
||||||
break;
|
break;
|
||||||
|
|
@ -1786,13 +1780,7 @@ std::int32_t Crafter::Run(std::int32_t argc, char** argv) {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
fs::path dir = config.BinDir();
|
fs::path dir = config.BinDir();
|
||||||
fs::path artifact = dir / config.outputName;
|
fs::path artifact = fs::absolute(ExpectedOutput(config));
|
||||||
if (config.target.starts_with("wasm32")) {
|
|
||||||
artifact += ".wasm";
|
|
||||||
} else if (config.target == "x86_64-w64-mingw32" || config.target == "x86_64-pc-windows-msvc") {
|
|
||||||
artifact += ".exe";
|
|
||||||
}
|
|
||||||
artifact = fs::absolute(artifact);
|
|
||||||
fs::path absDir = fs::absolute(dir);
|
fs::path absDir = fs::absolute(dir);
|
||||||
|
|
||||||
// wasm targets need either a wasm runtime (wasi-cli) or an HTTP
|
// wasm targets need either a wasm runtime (wasi-cli) or an HTTP
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ module Crafter.Build:Platform_impl;
|
||||||
import std;
|
import std;
|
||||||
import :Platform;
|
import :Platform;
|
||||||
import :Clang;
|
import :Clang;
|
||||||
|
import :Interface;
|
||||||
|
import :External;
|
||||||
import :Progress;
|
import :Progress;
|
||||||
namespace fs = std::filesystem;
|
namespace fs = std::filesystem;
|
||||||
using namespace Crafter;
|
using namespace Crafter;
|
||||||
|
|
@ -286,6 +288,272 @@ std::string Crafter::RunBuildCommand(std::string_view cmd) {
|
||||||
return r.output;
|
return r.output;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Dependencies of project.cpp itself -------------------------------------
|
||||||
|
//
|
||||||
|
// #pragma crafter dependency "<source>" [branch "<b>" | commit "<sha>"] [project "<file>"]
|
||||||
|
//
|
||||||
|
// names a sibling crafter-build project that project.cpp imports a module
|
||||||
|
// from. Anything a file needs before it can compile cannot be C++ inside that
|
||||||
|
// file, so this is a directive next to the `import` it enables — where
|
||||||
|
// `#include` lives. Each one is fetched, built for the host, and put on
|
||||||
|
// project.so's module path and link line before project.cpp is compiled. The
|
||||||
|
// dependency's own project.cpp goes through LoadProject, so its pragmas
|
||||||
|
// resolve recursively.
|
||||||
|
namespace {
|
||||||
|
struct PragmaDependency {
|
||||||
|
std::string source;
|
||||||
|
std::string branch;
|
||||||
|
std::string commit;
|
||||||
|
std::string project;
|
||||||
|
std::size_t line = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
[[noreturn]] void PragmaError(const fs::path& file, std::size_t line, std::string_view message) {
|
||||||
|
throw std::runtime_error(std::format("{}:{}: {}", file.string(), line, message));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The tokens of one directive: bare words and double-quoted strings (no
|
||||||
|
// escapes — a path or URL never needs one). Reading stops at `//`.
|
||||||
|
class PragmaCursor {
|
||||||
|
public:
|
||||||
|
PragmaCursor(std::string_view text, const fs::path& file, std::size_t line) : text_(text), file_(file), line_(line) {}
|
||||||
|
|
||||||
|
bool AtEnd() {
|
||||||
|
SkipSpace();
|
||||||
|
return pos_ >= text_.size() || text_.substr(pos_).starts_with("//");
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Take(char c) {
|
||||||
|
SkipSpace();
|
||||||
|
if (pos_ >= text_.size() || text_[pos_] != c) return false;
|
||||||
|
++pos_;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string_view Word() {
|
||||||
|
SkipSpace();
|
||||||
|
const std::size_t start = pos_;
|
||||||
|
while (pos_ < text_.size() && IsWordChar(text_[pos_])) ++pos_;
|
||||||
|
return text_.substr(start, pos_ - start);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<std::string_view> Quoted() {
|
||||||
|
SkipSpace();
|
||||||
|
if (pos_ >= text_.size() || text_[pos_] != '"') return std::nullopt;
|
||||||
|
const std::size_t close = text_.find('"', pos_ + 1);
|
||||||
|
if (close == std::string_view::npos) Fail("unterminated string");
|
||||||
|
const std::string_view inner = text_.substr(pos_ + 1, close - pos_ - 1);
|
||||||
|
pos_ = close + 1;
|
||||||
|
return inner;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[noreturn]] void Fail(std::string_view message) const { PragmaError(file_, line_, message); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
static bool IsWordChar(char c) { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'; }
|
||||||
|
|
||||||
|
void SkipSpace() {
|
||||||
|
while (pos_ < text_.size() && (text_[pos_] == ' ' || text_[pos_] == '\t' || text_[pos_] == '\r')) ++pos_;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string_view text_;
|
||||||
|
const fs::path& file_;
|
||||||
|
std::size_t line_;
|
||||||
|
std::size_t pos_ = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// One directive per line, anywhere in the file. Comments aren't parsed:
|
||||||
|
// a directive inside a block comment is honoured, and `//` lines fall out
|
||||||
|
// because they don't start with `#`.
|
||||||
|
std::vector<PragmaDependency> ScanProjectFilePragmas(const fs::path& absProject) {
|
||||||
|
std::vector<PragmaDependency> found;
|
||||||
|
std::ifstream in(absProject);
|
||||||
|
std::string raw;
|
||||||
|
for (std::size_t lineNo = 1; std::getline(in, raw); ++lineNo) {
|
||||||
|
PragmaCursor cur(raw, absProject, lineNo);
|
||||||
|
if (!cur.Take('#') || cur.Word() != "pragma" || cur.Word() != "crafter") continue;
|
||||||
|
const std::string_view verb = cur.Word();
|
||||||
|
if (verb != "dependency") cur.Fail(std::format("unknown crafter pragma '{}'", verb));
|
||||||
|
|
||||||
|
PragmaDependency dep;
|
||||||
|
dep.line = lineNo;
|
||||||
|
const std::optional<std::string_view> source = cur.Quoted();
|
||||||
|
if (!source) cur.Fail("expected a quoted source after 'dependency'");
|
||||||
|
dep.source = *source;
|
||||||
|
while (!cur.AtEnd()) {
|
||||||
|
const std::string_view key = cur.Word();
|
||||||
|
std::string* field = key == "branch" ? &dep.branch : key == "commit" ? &dep.commit : key == "project" ? &dep.project : nullptr;
|
||||||
|
if (!field) cur.Fail(key.empty() ? "unexpected text after the source" : std::format("unknown keyword '{}' (expected branch, commit or project)", key));
|
||||||
|
if (!field->empty()) cur.Fail(std::format("'{}' given twice", key));
|
||||||
|
const std::optional<std::string_view> value = cur.Quoted();
|
||||||
|
if (!value || value->empty()) cur.Fail(std::format("expected a quoted value after '{}'", key));
|
||||||
|
*field = *value;
|
||||||
|
}
|
||||||
|
if (dep.source.empty()) cur.Fail("the source is empty");
|
||||||
|
if (!dep.branch.empty() && !dep.commit.empty()) cur.Fail("'branch' and 'commit' are mutually exclusive");
|
||||||
|
found.push_back(std::move(dep));
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ProjectFileFrame {
|
||||||
|
fs::path project;
|
||||||
|
std::vector<Configuration*> dependencies;
|
||||||
|
};
|
||||||
|
|
||||||
|
// LoadProject nests: a dependency's project.cpp loads inside its
|
||||||
|
// consumer's pre-pass, and GitProject inside CrafterBuildProject nests
|
||||||
|
// again. Several threads may also load at once (ConcurrentCacheRace), so
|
||||||
|
// the stack is per thread. ProjectDependency() reads the top frame.
|
||||||
|
thread_local std::vector<ProjectFileFrame> LoadingFrames;
|
||||||
|
|
||||||
|
// Held for the whole of one LoadProject call. Pushing a file that is
|
||||||
|
// already on the stack is a cycle, which used to recurse forever.
|
||||||
|
class ProjectFileGuard {
|
||||||
|
public:
|
||||||
|
explicit ProjectFileGuard(const fs::path& absProject) {
|
||||||
|
for (const ProjectFileFrame& frame : LoadingFrames) {
|
||||||
|
if (frame.project != absProject) continue;
|
||||||
|
std::string chain;
|
||||||
|
for (const ProjectFileFrame& f : LoadingFrames) chain += std::format("{} -> ", f.project.string());
|
||||||
|
throw std::runtime_error(std::format("circular project dependency: {}{}", chain, absProject.string()));
|
||||||
|
}
|
||||||
|
index_ = LoadingFrames.size();
|
||||||
|
LoadingFrames.push_back({ absProject, {} });
|
||||||
|
}
|
||||||
|
~ProjectFileGuard() { LoadingFrames.pop_back(); }
|
||||||
|
ProjectFileGuard(const ProjectFileGuard&) = delete;
|
||||||
|
ProjectFileGuard& operator=(const ProjectFileGuard&) = delete;
|
||||||
|
|
||||||
|
ProjectFileFrame& Frame() { return LoadingFrames[index_]; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::size_t index_ = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// A pre-dependency is host tooling: project.so is loaded into the running
|
||||||
|
// crafter-build, so the dependency is built for the host and against the
|
||||||
|
// same PCM cache LoadProject populates — the hostConfig LoadProject itself
|
||||||
|
// compiles project.so with, so the dependency's std/Crafter.Build PCMs
|
||||||
|
// land in the cache dir project.so is built against. Recursive, because
|
||||||
|
// the dependency's project.cpp forwards no arguments but still reads
|
||||||
|
// CRAFTER_BUILD_MARCH from the environment for its own deps.
|
||||||
|
void ForceHostTooling(Configuration& cfg, const Configuration& host, std::unordered_set<Configuration*>& seen) {
|
||||||
|
if (!seen.insert(&cfg).second) return;
|
||||||
|
cfg.target = host.target;
|
||||||
|
cfg.march = host.march;
|
||||||
|
cfg.mtune = host.mtune;
|
||||||
|
cfg.debug = false;
|
||||||
|
// project.so is a shared object, so the archive it links has to be
|
||||||
|
// position-independent; static libraries aren't by default. Part of
|
||||||
|
// VariantId, so this build never shares a bin dir with a normal one.
|
||||||
|
// Checked before pushing: the same Configuration comes back from the
|
||||||
|
// project cache for every consumer in this process. Linux hosts only,
|
||||||
|
// glibc or musl: Windows targets have no use for the flag.
|
||||||
|
const bool linuxHost = host.target.ends_with("-linux-gnu") || host.target.ends_with("-linux-musl");
|
||||||
|
if (linuxHost && !std::ranges::contains(cfg.compileFlags, "-fPIC")) {
|
||||||
|
cfg.compileFlags.emplace_back("-fPIC");
|
||||||
|
}
|
||||||
|
for (Configuration* dep : cfg.dependencies) ForceHostTooling(*dep, host, seen);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ProjectFileDependencies {
|
||||||
|
std::vector<Configuration*> configs;
|
||||||
|
std::string compileFlags; // " -fprebuilt-module-path=…" for each dependency and its transitive deps
|
||||||
|
std::string linkFlags; // every dependency's BuildResult.libs, verbatim
|
||||||
|
fs::file_time_type newestArtifact = fs::file_time_type::min();
|
||||||
|
};
|
||||||
|
|
||||||
|
bool IsGitSource(std::string_view source) {
|
||||||
|
return source.contains("://") || source.starts_with("git@");
|
||||||
|
}
|
||||||
|
|
||||||
|
ProjectFileDependencies ResolveProjectFileDependencies(const fs::path& absProject, const Configuration& host) {
|
||||||
|
ProjectFileDependencies out;
|
||||||
|
const std::vector<PragmaDependency> pragmas = ScanProjectFilePragmas(absProject);
|
||||||
|
if (pragmas.empty()) return out;
|
||||||
|
|
||||||
|
std::unordered_map<fs::path, std::shared_future<BuildResult>> depResults;
|
||||||
|
std::mutex depMutex;
|
||||||
|
std::unordered_set<Configuration*> forced;
|
||||||
|
std::unordered_set<Configuration*> walked;
|
||||||
|
std::unordered_set<std::string> libs;
|
||||||
|
|
||||||
|
for (const PragmaDependency& pragma : pragmas) {
|
||||||
|
Configuration* cfg = nullptr;
|
||||||
|
if (IsGitSource(pragma.source)) {
|
||||||
|
GitProjectSpec spec;
|
||||||
|
spec.source = { pragma.source, pragma.branch, pragma.commit };
|
||||||
|
if (!pragma.project.empty()) spec.projectFile = pragma.project;
|
||||||
|
cfg = GitProject(spec);
|
||||||
|
} else {
|
||||||
|
if (!pragma.branch.empty() || !pragma.commit.empty()) PragmaError(absProject, pragma.line, "'branch' and 'commit' only apply to a git source");
|
||||||
|
// Relative to the project file, not the cwd: LoadProject runs
|
||||||
|
// from wherever the consumer was invoked.
|
||||||
|
fs::path local = (absProject.parent_path() / pragma.source).lexically_normal();
|
||||||
|
if (fs::is_directory(local)) {
|
||||||
|
local /= pragma.project.empty() ? fs::path("project.cpp") : fs::path(pragma.project);
|
||||||
|
} else if (!pragma.project.empty()) {
|
||||||
|
PragmaError(absProject, pragma.line, "'project' only applies to a directory source");
|
||||||
|
}
|
||||||
|
if (!fs::exists(local)) PragmaError(absProject, pragma.line, std::format("no such project file: {}", local.string()));
|
||||||
|
cfg = LocalProject({ .projectFile = local });
|
||||||
|
}
|
||||||
|
if (std::ranges::contains(out.configs, cfg)) continue;
|
||||||
|
|
||||||
|
ForceHostTooling(*cfg, host, forced);
|
||||||
|
BuildResult built;
|
||||||
|
{
|
||||||
|
Progress::Task task(std::format("Building project dependency {}", cfg->name));
|
||||||
|
built = Build(*cfg, depResults, depMutex);
|
||||||
|
}
|
||||||
|
if (!built.result.empty()) PragmaError(absProject, pragma.line, std::format("project dependency '{}' failed to build: {}", cfg->name, built.result));
|
||||||
|
out.configs.push_back(cfg);
|
||||||
|
libs.insert(built.libs.begin(), built.libs.end());
|
||||||
|
|
||||||
|
// The module path needs the dependency and everything it depends
|
||||||
|
// on, same as GetCompileCommand does for a consumer; the newest
|
||||||
|
// artifact over the same set is what project.so is stale against.
|
||||||
|
std::function<void(Configuration*)> walk = [&](Configuration* c) {
|
||||||
|
if (!walked.insert(c).second) return;
|
||||||
|
out.compileFlags += std::format(" -fprebuilt-module-path={}", c->PcmDir().string());
|
||||||
|
std::error_code ec;
|
||||||
|
const fs::file_time_type t = fs::last_write_time(ExpectedOutput(*c), ec);
|
||||||
|
if (!ec && t > out.newestArtifact) out.newestArtifact = t;
|
||||||
|
for (Configuration* sub : c->dependencies) walk(sub);
|
||||||
|
};
|
||||||
|
walk(cfg);
|
||||||
|
}
|
||||||
|
for (const std::string& lib : libs) out.linkFlags += std::format(" {}", lib);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// project.so predates its source, the crafter-build that will load it, or
|
||||||
|
// a pre-dependency artifact — any of them means recompiling.
|
||||||
|
bool ProjectFileStale(const fs::path& artifact, const fs::path& project, const fs::path& hostExe, const ProjectFileDependencies& deps) {
|
||||||
|
if (!fs::exists(artifact)) return true;
|
||||||
|
const fs::file_time_type built = fs::last_write_time(artifact);
|
||||||
|
return built < fs::last_write_time(project) || built < fs::last_write_time(hostExe) || built < deps.newestArtifact;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Configuration* Crafter::ProjectDependency(std::string_view name) {
|
||||||
|
if (LoadingFrames.empty()) throw std::runtime_error(std::format("ProjectDependency('{}'): called outside a project file", name));
|
||||||
|
const ProjectFileFrame& frame = LoadingFrames.back();
|
||||||
|
for (Configuration* cfg : frame.dependencies) {
|
||||||
|
if (cfg->name == name) return cfg;
|
||||||
|
for (const std::unique_ptr<Module>& iface : cfg->interfaces) {
|
||||||
|
if (iface->name == name) return cfg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::string declared;
|
||||||
|
for (Configuration* cfg : frame.dependencies) {
|
||||||
|
if (!declared.empty()) declared += ", ";
|
||||||
|
declared += cfg->name;
|
||||||
|
}
|
||||||
|
throw std::runtime_error(std::format("ProjectDependency('{}'): not declared by {} (declared: {})", name, frame.project.string(), declared.empty() ? "none" : declared));
|
||||||
|
}
|
||||||
|
|
||||||
#if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32)
|
#if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32)
|
||||||
CommandResult Crafter::RunCommandChecked(std::string_view cmd) {
|
CommandResult Crafter::RunCommandChecked(std::string_view cmd) {
|
||||||
std::array<char, 128> buffer;
|
std::array<char, 128> buffer;
|
||||||
|
|
@ -482,6 +750,7 @@ namespace {
|
||||||
|
|
||||||
Configuration Crafter::LoadProject(const fs::path& projectFile, std::span<const std::string_view> args) {
|
Configuration Crafter::LoadProject(const fs::path& projectFile, std::span<const std::string_view> args) {
|
||||||
fs::path absProject = fs::canonical(projectFile);
|
fs::path absProject = fs::canonical(projectFile);
|
||||||
|
ProjectFileGuard guard(absProject);
|
||||||
fs::path buildDir = absProject.parent_path() / "build";
|
fs::path buildDir = absProject.parent_path() / "build";
|
||||||
if (!fs::exists(buildDir)) {
|
if (!fs::exists(buildDir)) {
|
||||||
fs::create_directories(buildDir);
|
fs::create_directories(buildDir);
|
||||||
|
|
@ -511,7 +780,10 @@ Configuration Crafter::LoadProject(const fs::path& projectFile, std::span<const
|
||||||
|
|
||||||
EnsureCrafterBuildPcms(sourceDir, cacheDir);
|
EnsureCrafterBuildPcms(sourceDir, cacheDir);
|
||||||
|
|
||||||
bool stale = !fs::exists(dllPath) || fs::last_write_time(dllPath) < fs::last_write_time(absProject) || fs::last_write_time(dllPath) < fs::last_write_time(hostExe);
|
// See the Linux LoadProject for why this sits here and runs every time.
|
||||||
|
ProjectFileDependencies preDeps = ResolveProjectFileDependencies(absProject, hostConfig);
|
||||||
|
|
||||||
|
bool stale = ProjectFileStale(dllPath, absProject, hostExe, preDeps);
|
||||||
|
|
||||||
if (stale) {
|
if (stale) {
|
||||||
fs::path crafterBuildLib = hostExe.parent_path() / "crafter-build.lib";
|
fs::path crafterBuildLib = hostExe.parent_path() / "crafter-build.lib";
|
||||||
|
|
@ -521,11 +793,11 @@ Configuration Crafter::LoadProject(const fs::path& projectFile, std::span<const
|
||||||
"-std=c++26 -shared -O3 -Wno-return-type-c-linkage -fuse-ld=lld "
|
"-std=c++26 -shared -O3 -Wno-return-type-c-linkage -fuse-ld=lld "
|
||||||
"-D CRAFTER_BUILD_DLL_IMPORT "
|
"-D CRAFTER_BUILD_DLL_IMPORT "
|
||||||
"-nostdinc++ -nostdlib++ -isystem %LIBCXX_DIR%\\include\\c++\\v1 "
|
"-nostdinc++ -nostdlib++ -isystem %LIBCXX_DIR%\\include\\c++\\v1 "
|
||||||
"-fprebuilt-module-path={} "
|
"-fprebuilt-module-path={}{} "
|
||||||
"-Wl,/EXPORT:CrafterBuildProject "
|
"-Wl,/EXPORT:CrafterBuildProject "
|
||||||
"{} {} -o {} -L %LIBCXX_DIR%\\lib -lc++",
|
"{} {}{} -o {} -L %LIBCXX_DIR%\\lib -lc++",
|
||||||
cacheDir.string(),
|
cacheDir.string(), preDeps.compileFlags,
|
||||||
absProject.string(), crafterBuildLib.string(), dllPath.string());
|
absProject.string(), crafterBuildLib.string(), preDeps.linkFlags, dllPath.string());
|
||||||
|
|
||||||
std::string result = RunBuildCommand(compileCmd);
|
std::string result = RunBuildCommand(compileCmd);
|
||||||
if (!result.empty()) {
|
if (!result.empty()) {
|
||||||
|
|
@ -552,6 +824,7 @@ Configuration Crafter::LoadProject(const fs::path& projectFile, std::span<const
|
||||||
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()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
guard.Frame().dependencies = std::move(preDeps.configs);
|
||||||
return fn(args);
|
return fn(args);
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
@ -686,6 +959,7 @@ namespace {
|
||||||
|
|
||||||
Configuration Crafter::LoadProject(const fs::path& projectFile, std::span<const std::string_view> args) {
|
Configuration Crafter::LoadProject(const fs::path& projectFile, std::span<const std::string_view> args) {
|
||||||
fs::path absProject = fs::canonical(projectFile);
|
fs::path absProject = fs::canonical(projectFile);
|
||||||
|
ProjectFileGuard guard(absProject);
|
||||||
fs::path buildDir = absProject.parent_path() / "build";
|
fs::path buildDir = absProject.parent_path() / "build";
|
||||||
if (!fs::exists(buildDir)) fs::create_directories(buildDir);
|
if (!fs::exists(buildDir)) fs::create_directories(buildDir);
|
||||||
fs::path dllPath = buildDir / std::format("{}.dll", absProject.stem().string());
|
fs::path dllPath = buildDir / std::format("{}.dll", absProject.stem().string());
|
||||||
|
|
@ -713,7 +987,10 @@ Configuration Crafter::LoadProject(const fs::path& projectFile, std::span<const
|
||||||
|
|
||||||
EnsureCrafterBuildPcms(sourceDir, cacheDir);
|
EnsureCrafterBuildPcms(sourceDir, cacheDir);
|
||||||
|
|
||||||
bool stale = !fs::exists(dllPath) || fs::last_write_time(dllPath) < fs::last_write_time(absProject) || fs::last_write_time(dllPath) < fs::last_write_time(hostExe);
|
// See the Linux LoadProject for why this sits here and runs every time.
|
||||||
|
ProjectFileDependencies preDeps = ResolveProjectFileDependencies(absProject, hostConfig);
|
||||||
|
|
||||||
|
bool stale = ProjectFileStale(dllPath, absProject, hostExe, preDeps);
|
||||||
|
|
||||||
if (stale) {
|
if (stale) {
|
||||||
// The import lib lives next to the launcher exe (Build()'s mingw
|
// The import lib lives next to the launcher exe (Build()'s mingw
|
||||||
|
|
@ -725,15 +1002,17 @@ Configuration Crafter::LoadProject(const fs::path& projectFile, std::span<const
|
||||||
"--sysroot=\"{}\" -femulated-tls "
|
"--sysroot=\"{}\" -femulated-tls "
|
||||||
"-std=c++26 -shared -O3 -Wno-return-type-c-linkage -fuse-ld=lld "
|
"-std=c++26 -shared -O3 -Wno-return-type-c-linkage -fuse-ld=lld "
|
||||||
"-D CRAFTER_BUILD_DLL_IMPORT "
|
"-D CRAFTER_BUILD_DLL_IMPORT "
|
||||||
"-fprebuilt-module-path={} "
|
"-fprebuilt-module-path={}{} "
|
||||||
// mingw-lld doesn't accept /EXPORT:NAME — use the catch-all so
|
// mingw-lld doesn't accept /EXPORT:NAME — use the catch-all so
|
||||||
// CrafterBuildProject is reachable via GetProcAddress without
|
// CrafterBuildProject is reachable via GetProcAddress without
|
||||||
// forcing project.cpp templates to add __declspec(dllexport).
|
// forcing project.cpp templates to add __declspec(dllexport).
|
||||||
"-Wl,--export-all-symbols "
|
"-Wl,--export-all-symbols "
|
||||||
"{} -o {} -L\"{}\" -lcrafter-build -lstdc++exp -lpthread",
|
// Dependency archives go before -lcrafter-build: they reference
|
||||||
|
// Crafter.Build symbols, and ld.lld resolves archives in order.
|
||||||
|
"{} -o {}{} -L\"{}\" -lcrafter-build -lstdc++exp -lpthread",
|
||||||
prefix.string(),
|
prefix.string(),
|
||||||
cacheDir.string(),
|
cacheDir.string(), preDeps.compileFlags,
|
||||||
absProject.string(), dllPath.string(),
|
absProject.string(), dllPath.string(), preDeps.linkFlags,
|
||||||
hostExe.parent_path().string());
|
hostExe.parent_path().string());
|
||||||
|
|
||||||
std::string result = RunBuildCommand(compileCmd);
|
std::string result = RunBuildCommand(compileCmd);
|
||||||
|
|
@ -761,6 +1040,7 @@ Configuration Crafter::LoadProject(const fs::path& projectFile, std::span<const
|
||||||
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()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
guard.Frame().dependencies = std::move(preDeps.configs);
|
||||||
return fn(args);
|
return fn(args);
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
@ -990,6 +1270,7 @@ namespace {
|
||||||
|
|
||||||
Configuration Crafter::LoadProject(const fs::path& projectFile, std::span<const std::string_view> args) {
|
Configuration Crafter::LoadProject(const fs::path& projectFile, std::span<const std::string_view> args) {
|
||||||
fs::path absProject = fs::canonical(projectFile);
|
fs::path absProject = fs::canonical(projectFile);
|
||||||
|
ProjectFileGuard guard(absProject);
|
||||||
fs::path buildDir = absProject.parent_path() / "build";
|
fs::path buildDir = absProject.parent_path() / "build";
|
||||||
if (!fs::exists(buildDir)) {
|
if (!fs::exists(buildDir)) {
|
||||||
fs::create_directories(buildDir);
|
fs::create_directories(buildDir);
|
||||||
|
|
@ -1017,19 +1298,28 @@ Configuration Crafter::LoadProject(const fs::path& projectFile, std::span<const
|
||||||
|
|
||||||
EnsureCrafterBuildPcms(sourceDir, cacheDir);
|
EnsureCrafterBuildPcms(sourceDir, cacheDir);
|
||||||
|
|
||||||
const bool stale = !fs::exists(soPath) || fs::last_write_time(soPath) < fs::last_write_time(absProject) || fs::last_write_time(soPath) < fs::last_write_time(hostExe);
|
// After the host PCMs exist (a dependency's build imports Crafter.Build
|
||||||
|
// from that cache) and before the staleness check (its artifacts are
|
||||||
|
// inputs to project.so). Built on every load, fresh or not: a dependency
|
||||||
|
// BMI whose own imports were rebuilt underneath it doesn't fail cleanly,
|
||||||
|
// clang crashes on it.
|
||||||
|
ProjectFileDependencies preDeps = ResolveProjectFileDependencies(absProject, hostConfig);
|
||||||
|
|
||||||
|
const bool stale = ProjectFileStale(soPath, absProject, hostExe, preDeps);
|
||||||
|
|
||||||
if (stale) {
|
if (stale) {
|
||||||
// -fuse-ld=lld like every other link the tool performs: lld is a hard
|
// -fuse-ld=lld like every other link the tool performs: lld is a hard
|
||||||
// dependency, binutils' ld is not (Alpine's clang would default to it).
|
// dependency, binutils' ld is not (Alpine's clang would default to it),
|
||||||
|
// and a dependency archive built in release holds ThinLTO bitcode,
|
||||||
|
// which GNU ld can't read.
|
||||||
std::string compileCmd = std::format(
|
std::string compileCmd = std::format(
|
||||||
"clang++ --target={} -march=native -mtune=native "
|
"clang++ --target={} -march=native -mtune=native "
|
||||||
"-std=c++26 -stdlib=libc++ -shared -fPIC -O3 -fuse-ld=lld "
|
"-std=c++26 -stdlib=libc++ -shared -fPIC -O3 -fuse-ld=lld "
|
||||||
"-Wno-return-type-c-linkage "
|
"-Wno-return-type-c-linkage "
|
||||||
"-fprebuilt-module-path={} "
|
"-fprebuilt-module-path={}{} "
|
||||||
"{} -o {}",
|
"{} -o {}{}",
|
||||||
hostConfig.target, cacheDir.string(),
|
hostConfig.target, cacheDir.string(), preDeps.compileFlags,
|
||||||
absProject.string(), soPath.string());
|
absProject.string(), soPath.string(), preDeps.linkFlags);
|
||||||
|
|
||||||
std::string result = RunBuildCommand(compileCmd);
|
std::string result = RunBuildCommand(compileCmd);
|
||||||
if (!result.empty()) {
|
if (!result.empty()) {
|
||||||
|
|
@ -1049,6 +1339,7 @@ Configuration Crafter::LoadProject(const fs::path& projectFile, std::span<const
|
||||||
throw std::runtime_error(std::format("CrafterBuildProject not found in {}: {}", soPath.string(), err));
|
throw std::runtime_error(std::format("CrafterBuildProject not found in {}: {}", soPath.string(), err));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
guard.Frame().dependencies = std::move(preDeps.configs);
|
||||||
return fn(args);
|
return fn(args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -546,6 +546,13 @@ export namespace Crafter {
|
||||||
// features, and suffixed with the wasm variant flags for the same reason.
|
// features, and suffixed with the wasm variant flags for the same reason.
|
||||||
CRAFTER_API fs::path StdPcmDir(const Configuration& config);
|
CRAFTER_API fs::path StdPcmDir(const Configuration& config);
|
||||||
|
|
||||||
|
// Where Build leaves this Configuration's linked artifact: the executable
|
||||||
|
// (with the target's suffix), `lib<name>.a` / `<name>.lib`, or the shared
|
||||||
|
// library / DLL. The one rule every "is the consumer older than its
|
||||||
|
// dependency" check reads, so a staleness test and the link step can't
|
||||||
|
// disagree about the file name.
|
||||||
|
CRAFTER_API fs::path ExpectedOutput(const Configuration& config);
|
||||||
|
|
||||||
// The clang invocation every C++ translation unit in this Configuration is
|
// The clang invocation every C++ translation unit in this Configuration is
|
||||||
// compiled with, before anything that depends on work having happened
|
// compiled with, before anything that depends on work having happened
|
||||||
// (dependency public flags, external dependency flags — Build appends
|
// (dependency public flags, external dependency flags — Build appends
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,14 @@ namespace Crafter {
|
||||||
export CRAFTER_API CommandResult RunCommandWithTimeout(std::string_view command, std::chrono::seconds timeout);
|
export CRAFTER_API CommandResult RunCommandWithTimeout(std::string_view command, std::chrono::seconds timeout);
|
||||||
std::string GetBaseCommand(const Configuration& config);
|
std::string GetBaseCommand(const Configuration& config);
|
||||||
export CRAFTER_API Configuration LoadProject(const fs::path& projectFile, std::span<const std::string_view> args);
|
export CRAFTER_API Configuration LoadProject(const fs::path& projectFile, std::span<const std::string_view> args);
|
||||||
|
// A dependency the project file currently running its CrafterBuildProject
|
||||||
|
// declared with `#pragma crafter dependency`, so it can also be wired
|
||||||
|
// into the build graph (a test that links the same module, say) without
|
||||||
|
// spelling the source twice. `name` is the dependency's Configuration
|
||||||
|
// name or any module it exports — whatever the project file `import`s.
|
||||||
|
// Throws when no such dependency was declared, or when called from
|
||||||
|
// anywhere other than inside LoadProject.
|
||||||
|
export CRAFTER_API Configuration* ProjectDependency(std::string_view name);
|
||||||
// Resolves the directory holding distributed runtime assets (Crafter.Build
|
// Resolves the directory holding distributed runtime assets (Crafter.Build
|
||||||
// module sources, wasi-runtime/, etc). Honors CRAFTER_BUILD_HOME; otherwise
|
// module sources, wasi-runtime/, etc). Honors CRAFTER_BUILD_HOME; otherwise
|
||||||
// derives <prefix>/share/crafter-build from the running executable's path.
|
// derives <prefix>/share/crafter-build from the running executable's path.
|
||||||
|
|
|
||||||
971
lint-rules.h
971
lint-rules.h
|
|
@ -1,971 +0,0 @@
|
||||||
// SPDX-License-Identifier: LGPL-3.0-only
|
|
||||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
|
||||||
#pragma once
|
|
||||||
|
|
||||||
// This repo's house-style lint rules. Deliberately project-local — the rule
|
|
||||||
// set is NOT part of the crafter-build library, so downstream consumers are
|
|
||||||
// never nudged toward one team's style. Include from project.cpp AFTER
|
|
||||||
// `import std;` and `import Crafter.Build;` (the header uses both and has no
|
|
||||||
// includes of its own), then call AddProjectLintRules(cfg).
|
|
||||||
//
|
|
||||||
// Report-only rules are registered first so their file:line numbers always
|
|
||||||
// match the on-disk file; transforms run last because each one sees the
|
|
||||||
// previous transform's output, which can shift line numbers.
|
|
||||||
//
|
|
||||||
// All detection runs on ctx.CommentStripped() — comments and string/char
|
|
||||||
// literal bodies are blanked to spaces with newlines preserved, so byte
|
|
||||||
// offsets and line numbers in the stripped text are valid in ctx.content.
|
|
||||||
// Transforms use that property directly: find in stripped, edit in content.
|
|
||||||
|
|
||||||
namespace ProjectLint {
|
|
||||||
|
|
||||||
inline bool IsCppFile(const Crafter::LintContext& ctx) {
|
|
||||||
std::string ext = ctx.file.extension().string();
|
|
||||||
return ext == ".cpp" || ext == ".cppm" || ext == ".h";
|
|
||||||
}
|
|
||||||
|
|
||||||
inline std::vector<std::string_view> Lines(std::string_view text) {
|
|
||||||
std::vector<std::string_view> lines;
|
|
||||||
std::size_t start = 0;
|
|
||||||
while (start <= text.size()) {
|
|
||||||
std::size_t end = text.find('\n', start);
|
|
||||||
if (end == std::string_view::npos) {
|
|
||||||
if (start < text.size()) lines.push_back(text.substr(start));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
lines.push_back(text.substr(start, end - start));
|
|
||||||
start = end + 1;
|
|
||||||
}
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
inline std::string_view Trim(std::string_view s) {
|
|
||||||
while (!s.empty() && (s.front() == ' ' || s.front() == '\t' || s.front() == '\r')) s.remove_prefix(1);
|
|
||||||
while (!s.empty() && (s.back() == ' ' || s.back() == '\t' || s.back() == '\r')) s.remove_suffix(1);
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
inline bool IsWordChar(char c) {
|
|
||||||
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Net '(' minus ')' on one stripped line.
|
|
||||||
inline std::int64_t ParenDelta(std::string_view s) {
|
|
||||||
std::int64_t d = 0;
|
|
||||||
for (char c : s) {
|
|
||||||
if (c == '(') ++d;
|
|
||||||
if (c == ')') --d;
|
|
||||||
}
|
|
||||||
return d;
|
|
||||||
}
|
|
||||||
|
|
||||||
inline bool IsPascalCase(std::string_view name) {
|
|
||||||
return !name.empty() && name[0] >= 'A' && name[0] <= 'Z' && !name.contains('_');
|
|
||||||
}
|
|
||||||
|
|
||||||
inline bool IsCamelCase(std::string_view name) {
|
|
||||||
// One trailing underscore is the member-shadowing-a-keyword convention
|
|
||||||
// (requires_, label_) — allowed.
|
|
||||||
if (name.ends_with('_')) name.remove_suffix(1);
|
|
||||||
return !name.empty() && ((name[0] >= 'a' && name[0] <= 'z') || name[0] == '_') && name.find('_', 1) == std::string_view::npos;
|
|
||||||
}
|
|
||||||
|
|
||||||
// clang spells a pointer type "int *"; house style attaches the star to the
|
|
||||||
// type. Only affects spelling, never which type is meant.
|
|
||||||
inline std::string NormalisePointerSpelling(std::string_view type) {
|
|
||||||
std::string out;
|
|
||||||
out.reserve(type.size());
|
|
||||||
for (char c : type) {
|
|
||||||
if ((c == '*' || c == '&') && out.ends_with(' ')) out.pop_back();
|
|
||||||
out += c;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
// True for a pointer to char, as clang spells a type: "char *",
|
|
||||||
// "const char *", "char **". Deliberately not signed/unsigned char, which are
|
|
||||||
// byte buffers rather than text and were never the target.
|
|
||||||
inline bool IsCharPointer(std::string_view type) {
|
|
||||||
std::size_t star = type.find('*');
|
|
||||||
if (star == std::string_view::npos) return false;
|
|
||||||
std::string_view base = Trim(type.substr(0, star));
|
|
||||||
if (base.starts_with("const ")) base.remove_prefix(6);
|
|
||||||
if (base.starts_with("volatile ")) base.remove_prefix(9);
|
|
||||||
return base == "char";
|
|
||||||
}
|
|
||||||
|
|
||||||
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: types and functions PascalCase, variables camelCase, with
|
|
||||||
// statics, namespace-scope globals and constexpr constants PascalCase.
|
|
||||||
//
|
|
||||||
// Reads the AST. The previous version needed four std::regex, a hand-rolled
|
|
||||||
// {/} scope stack, a cumulative paren-depth counter to avoid mistaking a
|
|
||||||
// wrapped parameter list for a declaration, a keyword denylist, and a
|
|
||||||
// "function-shaped line" guess whose own comment conceded it was heuristic.
|
|
||||||
// All of it existed to answer two questions clang answers directly: what
|
|
||||||
// kind of declaration is this, and what encloses it.
|
|
||||||
cfg.AddAstLintRule("naming", [](LintContext& ctx) {
|
|
||||||
if (!IsCppFile(ctx)) return;
|
|
||||||
using Kind = Crafter::LintDeclKind;
|
|
||||||
std::span<const Crafter::LintDecl> decls = ctx.Decls();
|
|
||||||
for (const Crafter::LintDecl& decl : decls) {
|
|
||||||
if (decl.name.empty()) continue; // anonymous namespace, unnamed struct
|
|
||||||
// A declaration with C language linkage is named by the C library
|
|
||||||
// it mirrors — `extern "C" int setenv(...)` is not ours to rename.
|
|
||||||
if (decl.isExternC) continue;
|
|
||||||
// Namespace scope is now a lookup rather than a brace count, and it
|
|
||||||
// is exact for a local declared inside a function body.
|
|
||||||
bool atNamespaceScope = decl.parent == Crafter::LintNoParent
|
|
||||||
|| decls[decl.parent].kind == Kind::Namespace;
|
|
||||||
switch (decl.kind) {
|
|
||||||
case Kind::Class:
|
|
||||||
case Kind::Struct:
|
|
||||||
case Kind::Union:
|
|
||||||
case Kind::Enum:
|
|
||||||
if (!IsPascalCase(decl.name)) {
|
|
||||||
ctx.Report(decl.line, std::format("type '{}' should be PascalCase", decl.name));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case Kind::TypeAlias:
|
|
||||||
if (!IsPascalCase(decl.name)) {
|
|
||||||
ctx.Report(decl.line, std::format("type alias '{}' should be PascalCase", decl.name));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case Kind::Function:
|
|
||||||
case Kind::Method:
|
|
||||||
// main is spelled by the language; operators by their
|
|
||||||
// symbol. Constructors and destructors take their type's
|
|
||||||
// name and are separate kinds, so they never arrive here.
|
|
||||||
if (decl.name == "main" || decl.name.starts_with("operator")) break;
|
|
||||||
if (!IsPascalCase(decl.name)) {
|
|
||||||
ctx.Report(decl.line, std::format("function '{}' should be PascalCase", decl.name));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case Kind::Variable:
|
|
||||||
// constexpr variables are compile-time constants and take
|
|
||||||
// constant naming, like statics and globals.
|
|
||||||
if (decl.isStatic || decl.isConstexpr || atNamespaceScope) {
|
|
||||||
if (!IsPascalCase(decl.name)) {
|
|
||||||
ctx.Report(decl.line, std::format("{} '{}' should be PascalCase",
|
|
||||||
decl.isStatic ? "static variable"
|
|
||||||
: decl.isConstexpr ? "constexpr constant"
|
|
||||||
: "global variable", decl.name));
|
|
||||||
}
|
|
||||||
} else if (!IsCamelCase(decl.name)) {
|
|
||||||
ctx.Report(decl.line, std::format("variable '{}' should be camelCase", decl.name));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case Kind::Field:
|
|
||||||
if (decl.isStatic || decl.isConstexpr) {
|
|
||||||
if (!IsPascalCase(decl.name)) {
|
|
||||||
ctx.Report(decl.line, std::format("static member '{}' should be PascalCase", decl.name));
|
|
||||||
}
|
|
||||||
} else if (!IsCamelCase(decl.name)) {
|
|
||||||
ctx.Report(decl.line, std::format("member '{}' should be camelCase", decl.name));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Scoped enums only. Kept on tokens rather than moved to the AST, even
|
|
||||||
// though LintDecl carries an exact isScopedEnum: an AST is one
|
|
||||||
// configuration's slice, so a plain enum inside a preprocessor branch that
|
|
||||||
// is inactive for the host would stop being reported. Tokens are lexed
|
|
||||||
// without evaluating #if, so every platform's code stays covered.
|
|
||||||
cfg.AddLintRule("enum-class", [](LintContext& ctx) {
|
|
||||||
if (!IsCppFile(ctx)) return;
|
|
||||||
std::span<const Crafter::LintToken> tokens = ctx.Tokens();
|
|
||||||
for (std::size_t i = 0; i < tokens.size(); ++i) {
|
|
||||||
if (tokens[i].kind != Crafter::LintTokenKind::Keyword) continue;
|
|
||||||
if (ctx.TokenText(tokens[i]) != "enum") continue;
|
|
||||||
// Asking for the next TOKEN rather than the rest of the line means a
|
|
||||||
// declaration split across lines reads identically to one that is
|
|
||||||
// not — the regex this replaced required the name to follow `enum`
|
|
||||||
// on the same line, so `enum\n Color {` went unreported.
|
|
||||||
std::string_view next = i + 1 < tokens.size() ? ctx.TokenText(tokens[i + 1]) : std::string_view{};
|
|
||||||
if (next == "class" || next == "struct") continue;
|
|
||||||
ctx.Report(tokens[i].line, "use enum class instead of plain enum");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
cfg.AddLintRule("no-iostream-print", [](LintContext& ctx) {
|
|
||||||
if (!IsCppFile(ctx)) return;
|
|
||||||
std::vector<std::string_view> lines = Lines(ctx.CommentStripped());
|
|
||||||
for (std::size_t i = 0; i < lines.size(); ++i) {
|
|
||||||
if (lines[i].contains("std::cout")) {
|
|
||||||
ctx.Report(i + 1, "use std::println instead of std::cout");
|
|
||||||
} else if (lines[i].contains("std::cerr") && lines[i].contains("<<")) {
|
|
||||||
ctx.Report(i + 1, "use std::println(std::cerr, ...) instead of streaming to std::cerr");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Prefer std::string / std::string_view over char* in our own declarations.
|
|
||||||
//
|
|
||||||
// Reads the AST rather than the text, which retires the substring denylist
|
|
||||||
// this rule used to carry (argv, getenv, setenv, dlerror, c_str, .data(,
|
|
||||||
// reinterpret_cast, extern "). Every entry was a patch for one interop
|
|
||||||
// site, the list could only grow, and it disabled the rule for a whole LINE
|
|
||||||
// whenever one appeared. The question is now asked directly: whose header
|
|
||||||
// dictates this spelling? A declaration with C language linkage, or one
|
|
||||||
// that binds to an entity declared outside the project, is somebody else's
|
|
||||||
// API and keeps its spelling.
|
|
||||||
//
|
|
||||||
// Only declarations are considered. A char* inside a cast or an expression
|
|
||||||
// is not an interface, and reinterpret_cast<char*> for binary IO — the case
|
|
||||||
// the denylist existed to permit — is no longer a finding to suppress.
|
|
||||||
cfg.AddAstLintRule("no-char-pointer", [](LintContext& ctx) {
|
|
||||||
if (!IsCppFile(ctx)) return;
|
|
||||||
std::span<const Crafter::LintDecl> decls = ctx.Decls();
|
|
||||||
for (const Crafter::LintDecl& decl : decls) {
|
|
||||||
// main's signature is fixed by the language, so its argv is no more
|
|
||||||
// ours to modernise than a C API's is.
|
|
||||||
if (decl.parent != Crafter::LintNoParent && decls[decl.parent].name == "main") continue;
|
|
||||||
if (decl.kind == Crafter::LintDeclKind::Function && decl.name == "main") continue;
|
|
||||||
switch (decl.kind) {
|
|
||||||
case Crafter::LintDeclKind::Variable:
|
|
||||||
case Crafter::LintDeclKind::Parameter:
|
|
||||||
case Crafter::LintDeclKind::Field:
|
|
||||||
case Crafter::LintDeclKind::TypeAlias:
|
|
||||||
case Crafter::LintDeclKind::Function:
|
|
||||||
case Crafter::LintDeclKind::Method:
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (decl.isExternC || decl.isForeignApi) continue;
|
|
||||||
if (!IsCharPointer(decl.type)) continue;
|
|
||||||
ctx.Report(decl.line, std::format("prefer std::string / std::string_view over char* ('{}' is '{}')", decl.name, decl.type));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
// A local that is never written should say so. Restricted to SCALARS —
|
|
||||||
// integers, bools, enums, pointers, floating types — which is what makes
|
|
||||||
// the answer exact rather than a guess: a scalar has no member functions,
|
|
||||||
// so the only ways to write one are assignment, ++/--, having its address
|
|
||||||
// taken, or binding to a non-const reference, and the AST layer tracks all
|
|
||||||
// four. For a class type, a non-const method call could mutate it and
|
|
||||||
// deciding that needs the whole-program analysis clang-tidy does.
|
|
||||||
//
|
|
||||||
// Report-only. Adding const is a judgement about intent as much as
|
|
||||||
// mechanics, and a wrong suggestion should cost a glance, not a build.
|
|
||||||
cfg.AddAstLintRule("const-local", [](LintContext& ctx) {
|
|
||||||
if (!IsCppFile(ctx)) return;
|
|
||||||
std::span<const Crafter::LintDecl> decls = ctx.Decls();
|
|
||||||
for (const Crafter::LintDecl& decl : decls) {
|
|
||||||
if (decl.kind != Crafter::LintDeclKind::Variable) continue;
|
|
||||||
if (decl.parent == Crafter::LintNoParent) continue;
|
|
||||||
// Locals only: a namespace-scope or static variable may be written
|
|
||||||
// from a translation unit this parse cannot see.
|
|
||||||
Crafter::LintDeclKind enclosing = decls[decl.parent].kind;
|
|
||||||
bool isLocal = enclosing == Crafter::LintDeclKind::Function || enclosing == Crafter::LintDeclKind::Method
|
|
||||||
|| enclosing == Crafter::LintDeclKind::Constructor || enclosing == Crafter::LintDeclKind::Destructor;
|
|
||||||
if (!isLocal || decl.isStatic) continue;
|
|
||||||
if (decl.isConst || decl.isConstexpr) continue;
|
|
||||||
if (!decl.isScalar || decl.isMutated) continue;
|
|
||||||
if (decl.name.empty()) continue;
|
|
||||||
// A range-for binding is not what a reader pictures as an
|
|
||||||
// assignable variable, and `for (T* const x : …)` is not a spelling
|
|
||||||
// anybody writes.
|
|
||||||
if (decl.isLoopVariable) continue;
|
|
||||||
// Likewise `T* const p` — the useful constness for a pointer local
|
|
||||||
// is almost always on the pointee, which this rule cannot advise
|
|
||||||
// on. Restricting to value types keeps the advice actionable.
|
|
||||||
if (decl.type.contains('*')) continue;
|
|
||||||
ctx.Report(decl.line, std::format("'{}' is never modified — declare it const", decl.name));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// A constant whose value is already a constant expression can be constexpr,
|
|
||||||
// which moves it into the type system instead of leaving it to the
|
|
||||||
// optimiser. Constant-ness of the initialiser is decided by clang's own
|
|
||||||
// evaluator, so `sizeof(T)`, a fold over other constants and anything else
|
|
||||||
// that folds all qualify, while `Compute()` does not.
|
|
||||||
//
|
|
||||||
// Restricted to scalars on purpose: for a class type, constexpr may be
|
|
||||||
// unavailable even when the initialiser folds (a std::string constant
|
|
||||||
// cannot be constexpr at namespace scope), and the evaluator answering
|
|
||||||
// "this folds" is not the same question as "constexpr is legal here".
|
|
||||||
cfg.AddAstLintRule("constexpr-constant", [](LintContext& ctx) {
|
|
||||||
if (!IsCppFile(ctx)) return;
|
|
||||||
for (const Crafter::LintDecl& decl : ctx.Decls()) {
|
|
||||||
if (decl.kind != Crafter::LintDeclKind::Variable && decl.kind != Crafter::LintDeclKind::Field) continue;
|
|
||||||
if (!decl.isConst || decl.isConstexpr || !decl.isScalar) continue;
|
|
||||||
// A pointer's value is an address, which is rarely a constant
|
|
||||||
// expression and never an interesting one to promote.
|
|
||||||
if (decl.type.contains('*')) continue;
|
|
||||||
if (!decl.isConstantInitialised) continue;
|
|
||||||
if (decl.name.empty()) continue;
|
|
||||||
ctx.Report(decl.line, std::format("'{}' has a constant initialiser — declare it constexpr", decl.name));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
cfg.AddLintRule("format-concat", [](LintContext& ctx) {
|
|
||||||
if (!IsCppFile(ctx)) return;
|
|
||||||
const std::string& code = ctx.CommentStripped();
|
|
||||||
std::vector<std::string_view> stripped = Lines(code);
|
|
||||||
|
|
||||||
// Walk one operand leftwards from `from` (exclusive). Returns the
|
|
||||||
// operand's begin, or npos when the shape isn't a simple postfix
|
|
||||||
// chain (then the whole chain bails to a report).
|
|
||||||
auto operandLeft = [](std::string_view s, std::size_t from) -> std::size_t {
|
|
||||||
std::size_t i = from;
|
|
||||||
while (i > 0 && (s[i - 1] == ' ' || s[i - 1] == '\t')) --i;
|
|
||||||
bool any = false;
|
|
||||||
for (;;) {
|
|
||||||
if (i == 0) break;
|
|
||||||
char c = s[i - 1];
|
|
||||||
if (c == ')' || c == ']') {
|
|
||||||
char open = c == ')' ? '(' : '[';
|
|
||||||
std::size_t depth = 0;
|
|
||||||
do {
|
|
||||||
--i;
|
|
||||||
if (s[i] == c) ++depth;
|
|
||||||
if (s[i] == open) --depth;
|
|
||||||
if (depth == 0) break;
|
|
||||||
} while (i > 0);
|
|
||||||
if (depth != 0) return std::string_view::npos;
|
|
||||||
any = true;
|
|
||||||
// A group directly preceded by an identifier (or another
|
|
||||||
// group) is a call/index postfix — keep consuming the
|
|
||||||
// callee: `path.string()` is ONE operand, not `()`.
|
|
||||||
if (i > 0 && (IsWordChar(s[i - 1]) || s[i - 1] == ')' || s[i - 1] == ']')) continue;
|
|
||||||
} else if (c == '"') {
|
|
||||||
--i; // closing quote; interior is blanked, find the opener
|
|
||||||
while (i > 0 && s[i - 1] != '"') --i;
|
|
||||||
if (i == 0) return std::string_view::npos;
|
|
||||||
--i;
|
|
||||||
any = true;
|
|
||||||
} else if (IsWordChar(c)) {
|
|
||||||
while (i > 0 && IsWordChar(s[i - 1])) --i;
|
|
||||||
any = true;
|
|
||||||
} else if (c == '.' && any) {
|
|
||||||
--i;
|
|
||||||
continue;
|
|
||||||
} else if (c == ':' && i > 1 && s[i - 2] == ':' && any) {
|
|
||||||
i -= 2;
|
|
||||||
continue;
|
|
||||||
} else if (c == '>' && i > 1 && s[i - 2] == '-' && any) {
|
|
||||||
i -= 2;
|
|
||||||
continue;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// After a primary, only connectors continue the operand.
|
|
||||||
if (i > 0 && (s[i - 1] == '.' || (s[i - 1] == ':' && i > 1 && s[i - 2] == ':')
|
|
||||||
|| (s[i - 1] == '>' && i > 1 && s[i - 2] == '-'))) continue;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (!any) return std::string_view::npos;
|
|
||||||
// A unary operator, ternary, or other-precedence operator in
|
|
||||||
// front means expression structure we don't reason about — bail.
|
|
||||||
std::size_t b = i;
|
|
||||||
while (b > 0 && (s[b - 1] == ' ' || s[b - 1] == '\t')) --b;
|
|
||||||
if (b > 0 && std::string_view("!*&~-?:<>/%^|").contains(s[b - 1])) return std::string_view::npos;
|
|
||||||
return i;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Walk one operand rightwards from `from` (inclusive). Returns one
|
|
||||||
// past the operand's end, or npos on bail.
|
|
||||||
auto operandRight = [](std::string_view s, std::size_t from) -> std::size_t {
|
|
||||||
std::size_t i = from;
|
|
||||||
while (i < s.size() && (s[i] == ' ' || s[i] == '\t')) ++i;
|
|
||||||
bool any = false;
|
|
||||||
for (;;) {
|
|
||||||
if (i >= s.size()) break;
|
|
||||||
char c = s[i];
|
|
||||||
if (c == '(' || c == '[') {
|
|
||||||
char close = c == '(' ? ')' : ']';
|
|
||||||
std::size_t depth = 0;
|
|
||||||
while (i < s.size()) {
|
|
||||||
if (s[i] == c) ++depth;
|
|
||||||
if (s[i] == close && --depth == 0) { ++i; break; }
|
|
||||||
++i;
|
|
||||||
}
|
|
||||||
if (depth != 0) return std::string_view::npos;
|
|
||||||
any = true;
|
|
||||||
} else if (c == '"') {
|
|
||||||
++i;
|
|
||||||
while (i < s.size() && s[i] != '"') ++i;
|
|
||||||
if (i >= s.size()) return std::string_view::npos;
|
|
||||||
++i;
|
|
||||||
any = true;
|
|
||||||
} else if (IsWordChar(c)) {
|
|
||||||
while (i < s.size() && IsWordChar(s[i])) ++i;
|
|
||||||
any = true;
|
|
||||||
} else if (any && c == '.') {
|
|
||||||
++i;
|
|
||||||
continue;
|
|
||||||
} else if (any && c == ':' && i + 1 < s.size() && s[i + 1] == ':') {
|
|
||||||
i += 2;
|
|
||||||
continue;
|
|
||||||
} else if (any && c == '-' && i + 1 < s.size() && s[i + 1] == '>') {
|
|
||||||
i += 2;
|
|
||||||
continue;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (i < s.size() && (s[i] == '(' || s[i] == '[' || s[i] == '.'
|
|
||||||
|| (s[i] == ':' && i + 1 < s.size() && s[i + 1] == ':')
|
|
||||||
|| (s[i] == '-' && i + 1 < s.size() && s[i + 1] == '>'))) continue;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return any ? i : std::string_view::npos;
|
|
||||||
};
|
|
||||||
|
|
||||||
struct Edit { std::size_t begin; std::size_t end; std::string replacement; };
|
|
||||||
std::vector<Edit> edits; // offsets into ctx.content
|
|
||||||
std::size_t lineStart = 0;
|
|
||||||
for (std::size_t li = 0; li < stripped.size(); ++li) {
|
|
||||||
std::string_view line = stripped[li];
|
|
||||||
std::size_t lineOff = lineStart;
|
|
||||||
lineStart += line.size() + 1;
|
|
||||||
std::string_view trimmed = Trim(line);
|
|
||||||
if (trimmed.starts_with('#')) continue;
|
|
||||||
// A chain is read within one line, so a literal that spans lines
|
|
||||||
// would be sliced in half by chainEnd. Single-line raw strings are
|
|
||||||
// fine: they reduce to R"…" in the stripped view, so they fail the
|
|
||||||
// "is a plain literal" test below and travel through as an
|
|
||||||
// argument, spelling and all.
|
|
||||||
bool spansLines = ctx.LineHasMultiLineToken(li + 1);
|
|
||||||
|
|
||||||
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 (spansLines) {
|
|
||||||
ctx.Report(li + 1, "use std::format instead of string concatenation with + (multi-line literal, fix manually)");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expand to the full chain: operands joined by `+`.
|
|
||||||
std::size_t chainBegin = operandLeft(line, plus);
|
|
||||||
std::size_t chainEnd = operandRight(line, plus + 1);
|
|
||||||
bool bail = chainBegin == std::string_view::npos || chainEnd == std::string_view::npos;
|
|
||||||
while (!bail) {
|
|
||||||
std::size_t b = chainBegin;
|
|
||||||
while (b > 0 && (line[b - 1] == ' ' || line[b - 1] == '\t')) --b;
|
|
||||||
if (b > 0 && line[b - 1] == '+' && !(b > 1 && line[b - 2] == '+')) {
|
|
||||||
std::size_t prev = operandLeft(line, b - 1);
|
|
||||||
if (prev == std::string_view::npos) { bail = true; break; }
|
|
||||||
chainBegin = prev;
|
|
||||||
} else break;
|
|
||||||
}
|
|
||||||
while (!bail) {
|
|
||||||
std::size_t e = chainEnd;
|
|
||||||
while (e < line.size() && (line[e] == ' ' || line[e] == '\t')) ++e;
|
|
||||||
if (e < line.size() && line[e] == '+' && !(e + 1 < line.size() && (line[e + 1] == '+' || line[e + 1] == '='))) {
|
|
||||||
std::size_t next = operandRight(line, e + 1);
|
|
||||||
if (next == std::string_view::npos) { bail = true; break; }
|
|
||||||
chainEnd = next;
|
|
||||||
} else break;
|
|
||||||
}
|
|
||||||
if (bail) {
|
|
||||||
ctx.Report(li + 1, "use std::format instead of string concatenation with + (not auto-fixable)");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Split the chain at depth-0 '+' into parts; literals feed the
|
|
||||||
// format string, everything else becomes an argument.
|
|
||||||
std::string_view chain = line.substr(chainBegin, chainEnd - chainBegin);
|
|
||||||
std::string_view rawChain = std::string_view(ctx.content).substr(lineOff + chainBegin, chainEnd - chainBegin);
|
|
||||||
std::string fmt;
|
|
||||||
std::vector<std::string_view> args;
|
|
||||||
std::size_t partBegin = 0;
|
|
||||||
std::int64_t depth = 0;
|
|
||||||
bool inLit = false;
|
|
||||||
for (std::size_t p = 0; p <= chain.size(); ++p) {
|
|
||||||
if (p < chain.size()) {
|
|
||||||
char c = chain[p];
|
|
||||||
if (c == '"') inLit = !inLit;
|
|
||||||
if (inLit) continue;
|
|
||||||
if (c == '(' || c == '[') ++depth;
|
|
||||||
if (c == ')' || c == ']') --depth;
|
|
||||||
if (!(c == '+' && depth == 0)) continue;
|
|
||||||
}
|
|
||||||
std::string_view part = Trim(chain.substr(partBegin, p - partBegin));
|
|
||||||
std::string_view rawPart = Trim(rawChain.substr(partBegin, p - partBegin));
|
|
||||||
if (part.starts_with('"') && part.ends_with('"') && part.size() >= 2
|
|
||||||
&& std::count(part.begin(), part.end(), '"') == 2) {
|
|
||||||
for (char c : rawPart.substr(1, rawPart.size() - 2)) {
|
|
||||||
fmt += c;
|
|
||||||
if (c == '{') fmt += '{';
|
|
||||||
if (c == '}') fmt += '}';
|
|
||||||
}
|
|
||||||
} else if (part.contains('"') && part.contains('+')) {
|
|
||||||
bail = true; // nested concat inside an operand — human territory
|
|
||||||
break;
|
|
||||||
} else {
|
|
||||||
fmt += "{}";
|
|
||||||
args.push_back(rawPart);
|
|
||||||
}
|
|
||||||
partBegin = p + 1;
|
|
||||||
}
|
|
||||||
if (bail || args.empty()) {
|
|
||||||
ctx.Report(li + 1, "use std::format instead of string concatenation with + (not auto-fixable)");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
std::string replacement = std::format("std::format(\"{}\"", fmt);
|
|
||||||
for (std::string_view a : args) replacement += std::format(", {}", a);
|
|
||||||
replacement += ")";
|
|
||||||
edits.push_back({lineOff + chainBegin, lineOff + chainEnd, std::move(replacement)});
|
|
||||||
searchFrom = chainEnd;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (edits.empty()) return;
|
|
||||||
std::string out = ctx.content;
|
|
||||||
std::sort(edits.begin(), edits.end(), [](const Edit& a, const Edit& b) { return a.begin > b.begin; });
|
|
||||||
for (const Edit& e : edits) out.replace(e.begin, e.end - e.begin, e.replacement);
|
|
||||||
ctx.SetContent(std::move(out));
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------- transforms (auto-fixed by `crafter-build format`) ----------------
|
|
||||||
|
|
||||||
// short/int/long → fixed-width types. Lines mentioning main/argc/argv or
|
|
||||||
// extern "C" keep their C-conventional ints.
|
|
||||||
cfg.AddAstLintRule("fixed-width-types", [](LintContext& ctx) {
|
|
||||||
if (!IsCppFile(ctx)) return;
|
|
||||||
const std::string& code = ctx.CommentStripped();
|
|
||||||
struct Rep { std::size_t pos; std::size_t len; std::string to; };
|
|
||||||
std::vector<Rep> reps;
|
|
||||||
|
|
||||||
// Byte ranges whose integer spelling is not ours to change. A wrong
|
|
||||||
// rewrite here does not merely over-report — it produces code that no
|
|
||||||
// longer matches the API it is calling — so this is derived from the
|
|
||||||
// AST rather than from substrings on the line.
|
|
||||||
//
|
|
||||||
// Replaces the old per-LINE textual exemption (`int main`, `argc`,
|
|
||||||
// `argv`, `extern "`), which both missed cases and disabled the rule
|
|
||||||
// for everything else sharing a line with one of those words.
|
|
||||||
std::vector<std::pair<std::size_t, std::size_t>> protectedRanges;
|
|
||||||
for (const Crafter::LintDecl& decl : ctx.Decls()) {
|
|
||||||
if (decl.isExternC || decl.isForeignApi) {
|
|
||||||
protectedRanges.emplace_back(decl.begin, decl.end);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// main's signature is fixed by the language. Only the signature:
|
|
||||||
// its body is ordinary code, and the declaration's extent covers
|
|
||||||
// the whole function.
|
|
||||||
if (decl.kind == Crafter::LintDeclKind::Function && decl.name == "main") {
|
|
||||||
std::size_t bodyBrace = ctx.content.find('{', decl.begin);
|
|
||||||
protectedRanges.emplace_back(decl.begin, bodyBrace == std::string::npos ? decl.end : bodyBrace);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
auto isProtected = [&protectedRanges](std::size_t offset) {
|
|
||||||
return std::any_of(protectedRanges.begin(), protectedRanges.end(),
|
|
||||||
[offset](const std::pair<std::size_t, std::size_t>& range) {
|
|
||||||
return offset >= range.first && offset < range.second;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
// Builtin integer type specifiers combine in any order (`unsigned
|
|
||||||
// long`, `long unsigned int`, ...), so match whole RUNS of these
|
|
||||||
// keywords and classify the run, rather than the words one by one.
|
|
||||||
static const std::unordered_set<std::string_view> IntWords = {
|
|
||||||
"unsigned", "signed", "short", "long", "int", "char",
|
|
||||||
};
|
|
||||||
std::size_t lineStart = 0;
|
|
||||||
while (lineStart <= code.size()) {
|
|
||||||
std::size_t lineEnd = code.find('\n', lineStart);
|
|
||||||
if (lineEnd == std::string::npos) lineEnd = code.size();
|
|
||||||
std::string_view line(code.data() + lineStart, lineEnd - lineStart);
|
|
||||||
std::size_t pos = 0;
|
|
||||||
while (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;
|
|
||||||
|
|
||||||
if (isProtected(lineStart + runBegin)) 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.AddAstLintRule("single-declaration", [](LintContext& ctx) {
|
|
||||||
if (!IsCppFile(ctx)) return;
|
|
||||||
std::span<const Crafter::LintDecl> decls = ctx.Decls();
|
|
||||||
std::span<const Crafter::LintToken> tokens = ctx.Tokens();
|
|
||||||
|
|
||||||
struct Edit { std::size_t begin; std::size_t end; std::string text; };
|
|
||||||
std::vector<Edit> edits;
|
|
||||||
|
|
||||||
for (std::size_t i = 0; i < decls.size(); ++i) {
|
|
||||||
const Crafter::LintDecl& head = decls[i];
|
|
||||||
if (head.kind != Crafter::LintDeclKind::Variable && head.kind != Crafter::LintDeclKind::Field) continue;
|
|
||||||
// A group head owns the shared type, so its extent starts before
|
|
||||||
// its name. Continuation declarators start AT their name.
|
|
||||||
if (head.begin == head.nameOffset) continue;
|
|
||||||
|
|
||||||
std::size_t last = i;
|
|
||||||
while (last + 1 < decls.size()) {
|
|
||||||
const Crafter::LintDecl& next = decls[last + 1];
|
|
||||||
if (next.kind != head.kind || next.parent != head.parent) break;
|
|
||||||
if (next.begin != next.nameOffset) break; // starts a new statement
|
|
||||||
++last;
|
|
||||||
}
|
|
||||||
if (last == i) continue; // a single declarator: nothing to split
|
|
||||||
|
|
||||||
// The statement runs to the ';' after the final declarator.
|
|
||||||
auto semicolon = std::ranges::find_if(tokens, [&](const Crafter::LintToken& t) {
|
|
||||||
return t.offset >= decls[last].end && ctx.TokenText(t) == ";";
|
|
||||||
});
|
|
||||||
if (semicolon == tokens.end()) continue;
|
|
||||||
std::size_t stmtEnd = semicolon->offset + 1;
|
|
||||||
|
|
||||||
// A comment anywhere inside the statement would be swallowed by the
|
|
||||||
// rewrite. One AFTER the ';' is outside the replaced range and
|
|
||||||
// survives, which the line-based version could not manage — it
|
|
||||||
// refused the whole line.
|
|
||||||
bool hasInnerComment = std::ranges::any_of(tokens, [&](const Crafter::LintToken& t) {
|
|
||||||
return t.kind == Crafter::LintTokenKind::Comment && t.offset >= head.begin && t.offset < stmtEnd;
|
|
||||||
});
|
|
||||||
if (hasInnerComment) continue;
|
|
||||||
if (ctx.Suppressed("single-declaration", head.line)) continue;
|
|
||||||
|
|
||||||
// Indent from the head's own line, so a statement that starts
|
|
||||||
// mid-line is left alone rather than reflowed.
|
|
||||||
std::size_t lineBegin = ctx.content.rfind('\n', head.begin);
|
|
||||||
lineBegin = lineBegin == std::string::npos ? 0 : lineBegin + 1;
|
|
||||||
if (!Trim(std::string_view(ctx.content).substr(lineBegin, head.begin - lineBegin)).empty()) continue;
|
|
||||||
std::string indent(std::string_view(ctx.content).substr(lineBegin, head.begin - lineBegin));
|
|
||||||
|
|
||||||
std::string replacement;
|
|
||||||
for (std::size_t d = i; d <= last; ++d) {
|
|
||||||
// Each declarator gets its OWN type. This is the whole reason
|
|
||||||
// this rule needed the AST: copying the head's type prefix
|
|
||||||
// turns `int* a, b;` into `int* a; int* b;` and silently
|
|
||||||
// changes b's type. clang has already resolved b as plain int.
|
|
||||||
std::string type = NormalisePointerSpelling(decls[d].type);
|
|
||||||
std::string_view declarator = std::string_view(ctx.content).substr(decls[d].nameOffset, decls[d].end - decls[d].nameOffset);
|
|
||||||
if (d > i) replacement += std::format("\n{}", indent);
|
|
||||||
// NormalisePointerSpelling already attached the star to the
|
|
||||||
// type, so the separator is always a single space: `int* a`.
|
|
||||||
replacement += std::format("{} {};", type, Trim(declarator));
|
|
||||||
}
|
|
||||||
edits.push_back({head.begin, stmtEnd, std::move(replacement)});
|
|
||||||
i = last;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (edits.empty()) return;
|
|
||||||
std::string out = ctx.content;
|
|
||||||
std::ranges::sort(edits, [](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.text);
|
|
||||||
ctx.SetContent(std::move(out));
|
|
||||||
});
|
|
||||||
|
|
||||||
// K&R braces: `{` on its own line after a `)`/else/do/try header joins
|
|
||||||
// onto the header line. Standalone scope blocks (previous line ends with
|
|
||||||
// `;`, `{`, a comment, …) are intentional and stay.
|
|
||||||
cfg.AddLintRule("brace-style", [](LintContext& ctx) {
|
|
||||||
if (!IsCppFile(ctx)) return;
|
|
||||||
std::vector<std::string_view> stripped = Lines(ctx.CommentStripped());
|
|
||||||
std::vector<std::string> outLines;
|
|
||||||
outLines.reserve(ctx.lines.size());
|
|
||||||
bool changed = false;
|
|
||||||
for (std::size_t i = 0; i < ctx.lines.size(); ++i) {
|
|
||||||
std::string_view trimmed = Trim(stripped[i]);
|
|
||||||
if (trimmed == "{" && !outLines.empty()) {
|
|
||||||
std::string_view prevTrim = i > 0 ? Trim(stripped[i - 1]) : std::string_view{};
|
|
||||||
bool headerBefore = prevTrim.ends_with(')') || prevTrim == "else" || prevTrim == "do" || prevTrim == "try";
|
|
||||||
bool hasComment = ctx.LineHasComment(i + 1) || (i > 0 && ctx.LineHasComment(i));
|
|
||||||
bool suppressed = ctx.Suppressed("brace-style", i) || ctx.Suppressed("brace-style", i + 1);
|
|
||||||
if (headerBefore && !hasComment && !suppressed) {
|
|
||||||
std::string& prev = outLines.back();
|
|
||||||
while (!prev.empty() && (prev.back() == ' ' || prev.back() == '\t')) prev.pop_back();
|
|
||||||
prev += " {";
|
|
||||||
changed = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
outLines.emplace_back(ctx.Line(i + 1));
|
|
||||||
}
|
|
||||||
if (!changed) return;
|
|
||||||
std::string out;
|
|
||||||
for (std::size_t i = 0; i < outLines.size(); ++i) {
|
|
||||||
out += outLines[i];
|
|
||||||
if (i + 1 < outLines.size() || ctx.content.ends_with('\n')) out += '\n';
|
|
||||||
}
|
|
||||||
ctx.SetContent(std::move(out));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Single-statement if bodies join onto the if line: `if (x)\n y;` →
|
|
||||||
// `if (x) y;` — when the condition's parens balance on one line, the body
|
|
||||||
// is one `;`-terminated statement, neither line carries a comment, and
|
|
||||||
// the joined line stays ≤ 250 columns.
|
|
||||||
cfg.AddLintRule("if-single-line", [](LintContext& ctx) {
|
|
||||||
if (!IsCppFile(ctx)) return;
|
|
||||||
std::vector<std::string_view> stripped = Lines(ctx.CommentStripped());
|
|
||||||
static const std::regex ifHeader(R"(^\s*(?:\}?\s*else\s+)?if\s*\(.*\)\s*$)");
|
|
||||||
std::vector<std::string> outLines;
|
|
||||||
bool changed = false;
|
|
||||||
for (std::size_t i = 0; i < ctx.lines.size(); ++i) {
|
|
||||||
std::string lineStr(stripped[i]);
|
|
||||||
if (i + 1 < ctx.lines.size() && std::regex_match(lineStr, ifHeader) && ParenDelta(stripped[i]) == 0) {
|
|
||||||
std::string_view body = Trim(stripped[i + 1]);
|
|
||||||
bool joinable = !body.empty() && body != "{" && !body.starts_with("if") && body.ends_with(';')
|
|
||||||
&& !ctx.LineHasComment(i + 1) && !ctx.LineHasComment(i + 2)
|
|
||||||
&& !ctx.Suppressed("if-single-line", i + 1) && !ctx.Suppressed("if-single-line", i + 2);
|
|
||||||
std::string joined = std::string(ctx.Line(i + 1));
|
|
||||||
while (!joined.empty() && (joined.back() == ' ' || joined.back() == '\t')) joined.pop_back();
|
|
||||||
joined += " ";
|
|
||||||
joined += Trim(ctx.Line(i + 2));
|
|
||||||
if (joinable && joined.size() <= 250) {
|
|
||||||
outLines.push_back(std::move(joined));
|
|
||||||
++i; // consume the body line
|
|
||||||
changed = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
outLines.emplace_back(ctx.Line(i + 1));
|
|
||||||
}
|
|
||||||
if (!changed) return;
|
|
||||||
std::string out;
|
|
||||||
for (std::size_t i = 0; i < outLines.size(); ++i) {
|
|
||||||
out += outLines[i];
|
|
||||||
if (i + 1 < outLines.size() || ctx.content.ends_with('\n')) out += '\n';
|
|
||||||
}
|
|
||||||
ctx.SetContent(std::move(out));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wrapped call arguments join back onto one line when the whole
|
|
||||||
// expression fits in 250 columns. Lambda bodies (lines ending `{`),
|
|
||||||
// comments, raw strings, and preprocessor lines are left alone.
|
|
||||||
// Operator-style continuations (next line starts with && / || / |) join
|
|
||||||
// under the same length limit.
|
|
||||||
cfg.AddLintRule("wrap-join", [](LintContext& ctx) {
|
|
||||||
if (!IsCppFile(ctx)) return;
|
|
||||||
// Fixpoint loop: a join can enable another (joining an inner paren
|
|
||||||
// wrap balances the line an operator continuation hangs off), so one
|
|
||||||
// pass is not idempotent. Iterate until a pass changes nothing; the
|
|
||||||
// cap is a safety net — joins strictly reduce the line count, so
|
|
||||||
// termination is guaranteed anyway.
|
|
||||||
for (std::int32_t pass = 0; pass < 16; ++pass) {
|
|
||||||
std::vector<std::string_view> stripped = Lines(ctx.CommentStripped());
|
|
||||||
std::vector<std::string> outLines;
|
|
||||||
bool changed = false;
|
|
||||||
for (std::size_t i = 0; i < ctx.lines.size(); ++i) {
|
|
||||||
std::string_view trimmed = Trim(stripped[i]);
|
|
||||||
std::int64_t delta = ParenDelta(stripped[i]);
|
|
||||||
bool candidate = delta > 0 && !trimmed.empty() && !trimmed.starts_with('#')
|
|
||||||
&& !trimmed.ends_with('{') && !ctx.LineHasComment(i + 1) && !ctx.LineHasMultiLineToken(i + 1);
|
|
||||||
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.LineHasComment(j + 1) || ctx.LineHasMultiLineToken(j + 1)) {
|
|
||||||
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.LineHasComment(i + 1) && !ctx.LineHasMultiLineToken(i + 1)) {
|
|
||||||
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.LineHasComment(j + 1) || ctx.LineHasMultiLineToken(j + 1)) {
|
|
||||||
ok = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
while (!joined.empty() && (joined.back() == ' ' || joined.back() == '\t')) joined.pop_back();
|
|
||||||
joined += " ";
|
|
||||||
joined += Trim(ctx.Line(j + 1));
|
|
||||||
++j;
|
|
||||||
}
|
|
||||||
for (std::size_t l = i + 1; ok && l <= j; ++l) ok = !ctx.Suppressed("wrap-join", l);
|
|
||||||
if (ok && joined.size() <= 250) {
|
|
||||||
outLines.push_back(std::move(joined));
|
|
||||||
i = j - 1; // consumed the chain
|
|
||||||
changed = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
outLines.emplace_back(ctx.Line(i + 1));
|
|
||||||
}
|
|
||||||
if (!changed) return;
|
|
||||||
std::string out;
|
|
||||||
for (std::size_t i = 0; i < outLines.size(); ++i) {
|
|
||||||
out += outLines[i];
|
|
||||||
if (i + 1 < outLines.size() || ctx.content.ends_with('\n')) out += '\n';
|
|
||||||
}
|
|
||||||
ctx.SetContent(std::move(out));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// No space padding inside parens: `( x` / `x )` → `(x` / `x)`. Only
|
|
||||||
// intra-line (a line legitimately ends with '(' when a call wraps);
|
|
||||||
// string/comment interiors are excluded via the stripped text.
|
|
||||||
cfg.AddLintRule("paren-spacing", [](LintContext& ctx) {
|
|
||||||
if (!IsCppFile(ctx)) return;
|
|
||||||
const std::string& code = ctx.CommentStripped();
|
|
||||||
std::vector<std::pair<std::size_t, std::size_t>> cuts; // [begin, end) spans of spaces to delete
|
|
||||||
for (std::size_t i = 0; i < code.size(); ++i) {
|
|
||||||
if (code[i] == '(' ) {
|
|
||||||
std::size_t j = i + 1;
|
|
||||||
while (j < code.size() && code[j] == ' ') ++j;
|
|
||||||
if (j > i + 1 && j < code.size() && code[j] != '\n' && code[j] != '\r') cuts.push_back({i + 1, j});
|
|
||||||
} else if (code[i] == ')') {
|
|
||||||
std::size_t j = i;
|
|
||||||
while (j > 0 && code[j - 1] == ' ') --j;
|
|
||||||
// Only single-space padding: multi-space runs are usually
|
|
||||||
// deliberate alignment columns.
|
|
||||||
if (j == i - 1 && j > 0 && code[j - 1] != '\n' && code[j - 1] != ',') cuts.push_back({j, i});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (cuts.empty()) return;
|
|
||||||
std::string out = ctx.content;
|
|
||||||
for (auto it = cuts.rbegin(); it != cuts.rend(); ++it) out.erase(it->first, it->second - it->first);
|
|
||||||
ctx.SetContent(std::move(out));
|
|
||||||
});
|
|
||||||
|
|
||||||
cfg.AddLintRule("trim-trailing-ws", [](LintContext& ctx) {
|
|
||||||
if (!IsCppFile(ctx)) return;
|
|
||||||
std::string out;
|
|
||||||
out.reserve(ctx.content.size());
|
|
||||||
for (std::size_t n = 1; n <= ctx.lines.size(); ++n) {
|
|
||||||
std::string_view line = ctx.Line(n);
|
|
||||||
// Byte fidelity on CRLF files: peel the \r, trim, put it back.
|
|
||||||
bool crlf = line.ends_with('\r');
|
|
||||||
if (crlf) line.remove_suffix(1);
|
|
||||||
while (line.ends_with(' ') || line.ends_with('\t')) line.remove_suffix(1);
|
|
||||||
out += line;
|
|
||||||
if (crlf) out += '\r';
|
|
||||||
// The last line only had a newline if the file ended with one —
|
|
||||||
// preserve that byte exactly so this rule doesn't shadow
|
|
||||||
// final-newline.
|
|
||||||
if (n < ctx.lines.size() || ctx.content.ends_with('\n')) out += '\n';
|
|
||||||
}
|
|
||||||
ctx.SetContent(std::move(out));
|
|
||||||
});
|
|
||||||
|
|
||||||
cfg.AddLintRule("final-newline", [](LintContext& ctx) {
|
|
||||||
if (!IsCppFile(ctx)) return;
|
|
||||||
if (!ctx.content.empty() && !ctx.content.ends_with('\n')) {
|
|
||||||
ctx.SetContent(ctx.content + '\n');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace ProjectLint
|
|
||||||
14
project.cpp
14
project.cpp
|
|
@ -3,7 +3,8 @@
|
||||||
|
|
||||||
import std;
|
import std;
|
||||||
import Crafter.Build;
|
import Crafter.Build;
|
||||||
#include "lint-rules.h"
|
#pragma crafter dependency "https://forgejo.catcrafts.net/Catcrafts/Crafter.LintRules.git"
|
||||||
|
import Crafter.LintRules;
|
||||||
namespace fs = std::filesystem;
|
namespace fs = std::filesystem;
|
||||||
using namespace Crafter;
|
using namespace Crafter;
|
||||||
|
|
||||||
|
|
@ -142,15 +143,16 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
||||||
// Same LoadProject wiring as ConcurrentCacheRace above.
|
// Same LoadProject wiring as ConcurrentCacheRace above.
|
||||||
cfg.AddTest("HostCacheSourceStamp").Dependencies({ CrafterBuildLib.get() })
|
cfg.AddTest("HostCacheSourceStamp").Dependencies({ CrafterBuildLib.get() })
|
||||||
.LinkFlag("-Wl,--export-dynamic").LinkFlag("-ldl");
|
.LinkFlag("-Wl,--export-dynamic").LinkFlag("-ldl");
|
||||||
|
cfg.AddTest("ProjectDependency").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("Lint").Dependencies({ CrafterBuildLib.get() });
|
||||||
cfg.AddTest("HouseRules").Dependencies({ CrafterBuildLib.get() });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dogfood: this repo's house-style rules (see lint-rules.h). Report
|
// Dogfood: the house-style rules, imported from their own repo through
|
||||||
// rules gate `crafter-build lint`; transform rules also auto-fix under
|
// the pragma above. Report rules gate `crafter-build lint`; transform
|
||||||
// `crafter-build format`.
|
// rules also auto-fix under `crafter-build format`.
|
||||||
ProjectLint::AddProjectLintRules(cfg);
|
LintRules::AddRules(cfg);
|
||||||
|
|
||||||
return cfg;
|
return cfg;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,513 +0,0 @@
|
||||||
// SPDX-License-Identifier: LGPL-3.0-only
|
|
||||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
|
||||||
|
|
||||||
import std;
|
|
||||||
import Crafter.Build;
|
|
||||||
#include "../../lint-rules.h"
|
|
||||||
namespace fs = std::filesystem;
|
|
||||||
using namespace Crafter;
|
|
||||||
|
|
||||||
// Validates the house-style ruleset in lint-rules.h — the transforms and
|
|
||||||
// reports this repo (and sibling repos that copy the header) rely on. Each
|
|
||||||
// case writes a scratch file, runs ONE rule via the glob filter, and asserts
|
|
||||||
// the findings and/or rewritten bytes.
|
|
||||||
namespace {
|
|
||||||
std::int32_t Failures = 0;
|
|
||||||
|
|
||||||
void Check(bool cond, std::string_view msg) {
|
|
||||||
if (!cond) {
|
|
||||||
std::println(std::cerr, "FAIL: {}", msg);
|
|
||||||
++Failures;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct RuleRun {
|
|
||||||
LintSummary summary;
|
|
||||||
std::string text; // file content after the run
|
|
||||||
};
|
|
||||||
|
|
||||||
RuleRun RunRule(std::string_view content, std::string_view rule, LintMode mode) {
|
|
||||||
static std::int32_t Counter = 0;
|
|
||||||
fs::path dir = fs::temp_directory_path() / "crafter-build-house-rules" / std::format("case-{}", Counter++);
|
|
||||||
fs::remove_all(dir);
|
|
||||||
fs::create_directories(dir);
|
|
||||||
{
|
|
||||||
std::ofstream f(dir / "f.cpp", std::ios::binary | std::ios::trunc);
|
|
||||||
f.write(content.data(), static_cast<std::streamsize>(content.size()));
|
|
||||||
}
|
|
||||||
Configuration cfg;
|
|
||||||
cfg.path = dir;
|
|
||||||
cfg.name = "house-fixture";
|
|
||||||
cfg.outputName = "house-fixture";
|
|
||||||
cfg.target = HostTarget();
|
|
||||||
std::array<fs::path, 0> ifaces = {};
|
|
||||||
std::array<fs::path, 1> impls = { "f" };
|
|
||||||
cfg.GetInterfacesAndImplementations(ifaces, impls);
|
|
||||||
ProjectLint::AddProjectLintRules(cfg);
|
|
||||||
RunLintOptions opts;
|
|
||||||
opts.mode = mode;
|
|
||||||
opts.globs = { std::string(rule) };
|
|
||||||
RuleRun run;
|
|
||||||
run.summary = RunLint(cfg, opts);
|
|
||||||
std::ifstream f(dir / "f.cpp", std::ios::binary);
|
|
||||||
std::stringstream buffer;
|
|
||||||
buffer << f.rdbuf();
|
|
||||||
run.text = std::move(buffer).str();
|
|
||||||
return run;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool HasFinding(const LintSummary& s, std::string_view fragment) {
|
|
||||||
return std::any_of(s.findings.begin(), s.findings.end(), [&](const LintFinding& f) { return f.message.contains(fragment); });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int main() {
|
|
||||||
// fixed-width-types: int/long long convert; main/argc and extern "C" stay.
|
|
||||||
{
|
|
||||||
RuleRun r = RunRule("int Foo(long long v) { int x = 5; return x; }\n" "extern \"C\" int setenv(const char* n, const char* v, int o);\n" "int main(int argc, char** argv) { return 0; }\n", "fixed-width-types", LintMode::Apply);
|
|
||||||
Check(r.text.contains("std::int32_t Foo(std::int64_t v) { std::int32_t x = 5;"), "fixed-width converts int and long long");
|
|
||||||
Check(r.text.contains("extern \"C\" int setenv"), "extern \"C\" prototype keeps int");
|
|
||||||
Check(r.text.contains("int main(int argc"), "main/argc keep int");
|
|
||||||
}
|
|
||||||
|
|
||||||
// fixed-width-types is signed/unsigned aware, in any specifier order;
|
|
||||||
// bare char (text) and long double (floating) are not integers.
|
|
||||||
{
|
|
||||||
RuleRun r = RunRule("void F() {\n"
|
|
||||||
" unsigned a = 1;\n"
|
|
||||||
" unsigned int b = 2;\n"
|
|
||||||
" unsigned long long c = 3;\n"
|
|
||||||
" long unsigned int d = 4;\n"
|
|
||||||
" unsigned short e = 5;\n"
|
|
||||||
" signed f = 6;\n"
|
|
||||||
" signed char g = 7;\n"
|
|
||||||
" auto h = static_cast<unsigned char>(g);\n"
|
|
||||||
" char text = 'x';\n"
|
|
||||||
" long double pi = 3.14L;\n"
|
|
||||||
"}\n",
|
|
||||||
"fixed-width-types", LintMode::Apply);
|
|
||||||
Check(r.text.contains("std::uint32_t a = 1;"), "bare unsigned -> uint32");
|
|
||||||
Check(r.text.contains("std::uint32_t b = 2;"), "unsigned int -> uint32");
|
|
||||||
Check(r.text.contains("std::uint64_t c = 3;"), "unsigned long long -> uint64");
|
|
||||||
Check(r.text.contains("std::uint64_t d = 4;"), "long unsigned int (reordered) -> uint64");
|
|
||||||
Check(r.text.contains("std::uint16_t e = 5;"), "unsigned short -> uint16");
|
|
||||||
Check(r.text.contains("std::int32_t f = 6;"), "bare signed -> int32");
|
|
||||||
Check(r.text.contains("std::int8_t g = 7;"), "signed char -> int8");
|
|
||||||
Check(r.text.contains("static_cast<std::uint8_t>(g)"), "unsigned char -> uint8");
|
|
||||||
Check(r.text.contains("char text = 'x';"), "bare char stays (text, not an integer)");
|
|
||||||
Check(r.text.contains("long double pi = 3.14L;"), "long double stays (floating type)");
|
|
||||||
}
|
|
||||||
|
|
||||||
// brace-style: Allman brace joins; standalone scope block stays.
|
|
||||||
{
|
|
||||||
RuleRun r = RunRule("void Foo()\n{\n}\n\nvoid Bar() {\n Baz();\n {\n Qux();\n }\n}\n", "brace-style", LintMode::Apply);
|
|
||||||
Check(r.text.contains("void Foo() {"), "Allman brace joined onto header");
|
|
||||||
Check(r.text.contains("Baz();\n {"), "scope block brace untouched");
|
|
||||||
}
|
|
||||||
|
|
||||||
// if-single-line: short body joins; braced body stays.
|
|
||||||
{
|
|
||||||
RuleRun r = RunRule("void F(bool b) {\n if (b)\n Run();\n if (b) {\n Run();\n }\n}\n", "if-single-line", LintMode::Apply);
|
|
||||||
Check(r.text.contains("if (b) Run();"), "single-statement if body joined");
|
|
||||||
Check(r.text.contains("if (b) {"), "braced if body untouched");
|
|
||||||
}
|
|
||||||
|
|
||||||
// single-declaration splits on the AST, so each declarator carries its own
|
|
||||||
// resolved type. That is the whole reason it is not a token rule: copying
|
|
||||||
// the head's type prefix turns `int* a, b;` into `int* a; int* b;` and
|
|
||||||
// silently changes b from int to int*.
|
|
||||||
{
|
|
||||||
RuleRun r = RunRule("#include <vector>\n"
|
|
||||||
"void F() {\n"
|
|
||||||
" bool a = false, b = true;\n"
|
|
||||||
" int* ptrA = nullptr, *ptrB = nullptr;\n"
|
|
||||||
" int* mixed = nullptr, plain = 7;\n"
|
|
||||||
" std::vector<int> tmplA{}, tmplB{};\n"
|
|
||||||
" int callA = g(), callB = h();\n"
|
|
||||||
" int keep = 1, kept = 2; // trailing comment\n"
|
|
||||||
"}\n",
|
|
||||||
"single-declaration", LintMode::Apply);
|
|
||||||
Check(r.text.contains("bool a = false;\n bool b = true;"), "single-declaration: simple split");
|
|
||||||
Check(r.text.contains("int* ptrA = nullptr;\n int* ptrB = nullptr;"), "single-declaration: pointers split, star kept on the type");
|
|
||||||
// The case a token-based splitter gets wrong.
|
|
||||||
Check(r.text.contains("int* mixed = nullptr;\n int plain = 7;"), "single-declaration: only the starred declarator is a pointer");
|
|
||||||
Check(r.text.contains("std::vector<int> tmplA{};\n std::vector<int> tmplB{};"), "single-declaration: template arguments are not a bail-out");
|
|
||||||
Check(r.text.contains("int callA = g();\n int callB = h();"), "single-declaration: call initialisers are not a bail-out");
|
|
||||||
// A comment after the ';' is outside the replaced range, so it survives;
|
|
||||||
// the line-based version refused the whole line instead.
|
|
||||||
Check(r.text.contains("int keep = 1;\n int kept = 2; // trailing comment"), "single-declaration: trailing comment survives the split");
|
|
||||||
RuleRun again = RunRule(r.text, "single-declaration", LintMode::Apply);
|
|
||||||
Check(again.summary.changedFiles.empty(), "single-declaration: idempotent");
|
|
||||||
}
|
|
||||||
|
|
||||||
// wrap-join: short wrapped call joins; operator chain joins; long stays.
|
|
||||||
{
|
|
||||||
RuleRun r = RunRule("void F() {\n G(alpha,\n beta);\n bool x = alpha\n && beta;\n}\n", "wrap-join", LintMode::Apply);
|
|
||||||
Check(r.text.contains("G(alpha, beta);"), "wrapped call arguments joined");
|
|
||||||
Check(r.text.contains("bool x = alpha && beta;"), "operator continuation joined");
|
|
||||||
}
|
|
||||||
|
|
||||||
// format-concat: literal-adjacent + rewrites; += RHS rewrites; args survive.
|
|
||||||
{
|
|
||||||
RuleRun r = RunRule("void F(std::string name, std::string cmd) {\n"
|
|
||||||
" std::string a = name + \".cpp\";\n"
|
|
||||||
" std::string b = \"pre-\" + name + \"-post\";\n"
|
|
||||||
" cmd += \" \" + name;\n"
|
|
||||||
" std::string keep = name + cmd;\n"
|
|
||||||
" fs::path p;\n"
|
|
||||||
" std::string c = p.stem().string() + \".pcm\";\n"
|
|
||||||
" fs::path d = std::string(cmd) + \".so\";\n"
|
|
||||||
"}\n",
|
|
||||||
"format-concat", LintMode::Apply);
|
|
||||||
Check(r.text.contains("std::string a = std::format(\"{}.cpp\", name);"), "trailing literal converts");
|
|
||||||
Check(!r.text.contains("namestd::format"), "replacement splices at the operand boundary");
|
|
||||||
Check(r.text.contains("std::string b = std::format(\"pre-{}-post\", name);"), "sandwich chain converts");
|
|
||||||
Check(r.text.contains("cmd += std::format(\" {}\", name);"), "+= RHS converts");
|
|
||||||
Check(r.text.contains("std::string keep = name + cmd;"), "literal-free + is left alone");
|
|
||||||
Check(r.text.contains("std::string c = std::format(\"{}.pcm\", p.stem().string());"), "call-chain left operand consumed whole (the stringstd regression)");
|
|
||||||
Check(r.text.contains("fs::path d = std::format(\"{}.so\", std::string(cmd));"), "constructor-call left operand consumed whole");
|
|
||||||
}
|
|
||||||
{
|
|
||||||
// Ternary and raw-string lines are reported, never rewritten.
|
|
||||||
RuleRun r = RunRule("std::string F(bool b, std::string n) { return b ? n + \".x\" : n; }\n", "format-concat", LintMode::Report);
|
|
||||||
Check(HasFinding(r.summary, "not auto-fixable"), "ternary concat reports instead of fixing");
|
|
||||||
Check(r.text.contains("n + \".x\""), "ternary concat untouched on disk");
|
|
||||||
}
|
|
||||||
|
|
||||||
// paren-spacing: single-space padding removed; wrapped call ends keep.
|
|
||||||
{
|
|
||||||
RuleRun r = RunRule("void F() {\n G( x );\n}\n", "paren-spacing", LintMode::Apply);
|
|
||||||
Check(r.text.contains("G(x);"), "paren padding removed");
|
|
||||||
}
|
|
||||||
|
|
||||||
// naming: wrong-case function/type/static/global flagged; camel local passes.
|
|
||||||
{
|
|
||||||
RuleRun r = RunRule("namespace {\n"
|
|
||||||
" std::int32_t bad_global = 0;\n"
|
|
||||||
" struct lint_thing {};\n"
|
|
||||||
"}\n"
|
|
||||||
"void lower_func() {\n"
|
|
||||||
" static bool lowerStatic = false;\n"
|
|
||||||
" std::int32_t fineLocal = 1;\n"
|
|
||||||
"}\n",
|
|
||||||
"naming", LintMode::Report);
|
|
||||||
Check(HasFinding(r.summary, "'bad_global' should be PascalCase"), "lowercase global flagged");
|
|
||||||
Check(HasFinding(r.summary, "'lint_thing' should be PascalCase"), "lowercase type flagged");
|
|
||||||
Check(HasFinding(r.summary, "'lower_func' should be PascalCase"), "lowercase function flagged");
|
|
||||||
Check(HasFinding(r.summary, "'lowerStatic' should be PascalCase"), "camel static flagged");
|
|
||||||
Check(!HasFinding(r.summary, "fineLocal"), "camelCase local passes");
|
|
||||||
}
|
|
||||||
{
|
|
||||||
// Statement calls with inline lambda arguments are NOT function
|
|
||||||
// definitions (the any_of regression); a genuine lowercase one-liner
|
|
||||||
// method still is.
|
|
||||||
RuleRun r = RunRule("struct Holder {\n"
|
|
||||||
" bool clean() const { return true; }\n"
|
|
||||||
"};\n"
|
|
||||||
"void T(std::vector<std::int32_t>& v) {\n"
|
|
||||||
" bool x = std::any_of(v.begin(), v.end(), [](std::int32_t n) { return n > 0; });\n"
|
|
||||||
" std::erase_if(v, [](std::int32_t n) { return n < 0; });\n"
|
|
||||||
"}\n",
|
|
||||||
"naming", LintMode::Report);
|
|
||||||
Check(!HasFinding(r.summary, "any_of"), "call with lambda argument is not a function definition");
|
|
||||||
Check(!HasFinding(r.summary, "erase_if"), "bare statement call is not a function definition");
|
|
||||||
Check(HasFinding(r.summary, "'clean' should be PascalCase"), "lowercase one-liner method still flagged");
|
|
||||||
}
|
|
||||||
|
|
||||||
// enum-class + no-iostream-print reports.
|
|
||||||
{
|
|
||||||
RuleRun r = RunRule("enum Color { Red };\n", "enum-class", LintMode::Report);
|
|
||||||
Check(HasFinding(r.summary, "enum class"), "plain enum flagged");
|
|
||||||
}
|
|
||||||
{
|
|
||||||
RuleRun r = RunRule("void F() { std::cout << 1; }\n", "no-iostream-print", LintMode::Report);
|
|
||||||
Check(HasFinding(r.summary, "std::println"), "std::cout flagged");
|
|
||||||
}
|
|
||||||
|
|
||||||
// The whole ruleset is idempotent: a second Apply changes nothing.
|
|
||||||
{
|
|
||||||
std::string_view source = "int Foo()\n{\n std::string s = std::string(\"a\") + \"b\";\n if (true)\n return 1;\n return 0;\n}\n";
|
|
||||||
static constexpr std::string_view AllRules = "*";
|
|
||||||
RuleRun first = RunRule(source, AllRules, LintMode::Apply);
|
|
||||||
RuleRun second = RunRule(first.text, AllRules, LintMode::Apply);
|
|
||||||
Check(second.summary.changedFiles.empty(), "second apply of the full ruleset is a no-op");
|
|
||||||
Check(first.text != source, "first apply actually changed the fixture");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Suppression directives against house transforms: the driver reverts
|
|
||||||
// line-preserving edits (fixed-width) and the count-changing rules check
|
|
||||||
// Suppressed() themselves (wrap-join).
|
|
||||||
{
|
|
||||||
RuleRun r = RunRule("void F() {\n"
|
|
||||||
" // lint-disable-next-line fixed-width-types\n"
|
|
||||||
" int keep = 1;\n"
|
|
||||||
" int convert = 2;\n"
|
|
||||||
"}\n",
|
|
||||||
"fixed-width-types", LintMode::Apply);
|
|
||||||
Check(r.text.contains("int keep = 1;"), "next-line directive keeps the suppressed int");
|
|
||||||
Check(r.text.contains("std::int32_t convert = 2;"), "unsuppressed line still converts");
|
|
||||||
}
|
|
||||||
{
|
|
||||||
RuleRun r = RunRule("void F() {\n"
|
|
||||||
" // lint-disable-next-line wrap-join\n"
|
|
||||||
" G(alpha,\n"
|
|
||||||
" beta);\n"
|
|
||||||
" H(alpha,\n"
|
|
||||||
" beta);\n"
|
|
||||||
"}\n",
|
|
||||||
"wrap-join", LintMode::Apply);
|
|
||||||
Check(r.text.contains("G(alpha,\n"), "suppressed wrap stays wrapped");
|
|
||||||
Check(r.text.contains("H(alpha, beta);"), "unsuppressed wrap still joins");
|
|
||||||
}
|
|
||||||
|
|
||||||
// wrap-join converges in ONE run: joining the inner paren wrap balances
|
|
||||||
// the && line, which only then becomes an operator-joinable continuation.
|
|
||||||
{
|
|
||||||
RuleRun r = RunRule("void F() {\n"
|
|
||||||
" bool ok = alpha\n"
|
|
||||||
" && beta(gamma,\n"
|
|
||||||
" delta);\n"
|
|
||||||
"}\n",
|
|
||||||
"wrap-join", LintMode::Apply);
|
|
||||||
Check(r.text.contains("bool ok = alpha && beta(gamma, delta);"), "self-enabling joins reach the fixpoint in one apply");
|
|
||||||
RuleRun second = RunRule(r.text, "wrap-join", LintMode::Apply);
|
|
||||||
Check(second.summary.changedFiles.empty(), "wrap-join is idempotent after the fixpoint");
|
|
||||||
}
|
|
||||||
|
|
||||||
// The guards used to be substring probes over the raw line, so a literal
|
|
||||||
// whose TEXT contained `//` or `R"` looked like a comment or a raw string
|
|
||||||
// and silently disabled the rule. Both shapes occur in this repo's own
|
|
||||||
// sources — "MARKER" ends in the characters R", and any string mentioning
|
|
||||||
// a lint-disable directive contains //.
|
|
||||||
{
|
|
||||||
RuleRun r = RunRule("void F() {\n" " auto hit = text.find(\"MARKER\",\n" " start);\n" "}\n", "wrap-join", LintMode::Apply);
|
|
||||||
Check(r.text.contains("text.find(\"MARKER\", start);"), "R\" inside a literal no longer blocks wrap-join");
|
|
||||||
}
|
|
||||||
{
|
|
||||||
RuleRun r = RunRule("void F() {\n" " Check(read() == \"// lint-disable-next-line trim\\n\",\n" " \"message\");\n" "}\n", "wrap-join", LintMode::Apply);
|
|
||||||
Check(r.text.contains("\\n\", \"message\");"), "// inside a literal no longer blocks wrap-join");
|
|
||||||
}
|
|
||||||
|
|
||||||
// A real trailing comment still blocks the join: text pulled up past a
|
|
||||||
// `//` would be swallowed by it.
|
|
||||||
{
|
|
||||||
RuleRun r = RunRule("void F() {\n" " auto v = g(alpha, // why\n" " beta);\n" "}\n", "wrap-join", LintMode::Apply);
|
|
||||||
Check(r.text.contains("g(alpha, // why\n"), "a real comment still blocks wrap-join");
|
|
||||||
}
|
|
||||||
|
|
||||||
// A genuinely multi-line literal is never reflowed — joining its lines
|
|
||||||
// would change the string's contents.
|
|
||||||
{
|
|
||||||
std::string_view source = "void F() {\n"
|
|
||||||
" auto text = R\"sql(SELECT a,\n"
|
|
||||||
" b FROM t)sql\";\n"
|
|
||||||
"}\n";
|
|
||||||
RuleRun r = RunRule(source, "wrap-join", LintMode::Apply);
|
|
||||||
Check(r.text == source, "wrap-join leaves a multi-line raw string alone");
|
|
||||||
RuleRun paren = RunRule(source, "paren-spacing", LintMode::Apply);
|
|
||||||
Check(paren.text == source, "paren-spacing leaves a multi-line raw string alone");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Type keywords inside a literal are text, not declarations.
|
|
||||||
{
|
|
||||||
std::string_view source = "void F() {\n"
|
|
||||||
" auto sql = R\"q(int x; unsigned long y;)q\";\n"
|
|
||||||
"}\n";
|
|
||||||
RuleRun r = RunRule(source, "fixed-width-types", LintMode::Apply);
|
|
||||||
Check(r.text == source, "fixed-width-types leaves type names inside a raw string alone");
|
|
||||||
}
|
|
||||||
|
|
||||||
// no-char-pointer reads the AST, so it distinguishes OUR char* from one
|
|
||||||
// whose spelling belongs to somebody else's header. This is what replaced
|
|
||||||
// the substring denylist (argv, getenv, c_str, reinterpret_cast, …): each
|
|
||||||
// entry there disabled the rule for a whole line, and the list could only
|
|
||||||
// grow as new libraries arrived.
|
|
||||||
{
|
|
||||||
RuleRun r = RunRule("#include <cstdlib>\n"
|
|
||||||
"#include <string>\n"
|
|
||||||
"extern \"C\" const char* CApiEntry(const char* path);\n"
|
|
||||||
"char* OurBadApi(char* input) { return input; }\n"
|
|
||||||
"void F() {\n"
|
|
||||||
" char* fromLibc = std::getenv(\"HOME\");\n"
|
|
||||||
" std::string mine = \"ok\";\n"
|
|
||||||
" const char* toLibc = mine.c_str();\n"
|
|
||||||
" auto raw = reinterpret_cast<char*>(&mine);\n"
|
|
||||||
"}\n",
|
|
||||||
"no-char-pointer", LintMode::Report);
|
|
||||||
// Ours, so reported: the declaration and its parameter.
|
|
||||||
Check(HasFinding(r.summary, "'OurBadApi'"), "no-char-pointer: our own char* return is reported");
|
|
||||||
Check(HasFinding(r.summary, "'input'"), "no-char-pointer: our own char* parameter is reported");
|
|
||||||
// Foreign, so exempt — each for a reason, not by name.
|
|
||||||
Check(!HasFinding(r.summary, "'CApiEntry'"), "no-char-pointer: extern \"C\" declaration is exempt");
|
|
||||||
Check(!HasFinding(r.summary, "'path'"), "no-char-pointer: extern \"C\" parameter is exempt");
|
|
||||||
Check(!HasFinding(r.summary, "'fromLibc'"), "no-char-pointer: a value from libc is exempt");
|
|
||||||
Check(!HasFinding(r.summary, "'toLibc'"), "no-char-pointer: a value from c_str() is exempt");
|
|
||||||
// A local whose deduced type is char* is still our declaration, so it
|
|
||||||
// is reported. The old denylist exempted every line mentioning
|
|
||||||
// reinterpret_cast; a deliberate low-level cast now takes an explicit
|
|
||||||
// lint-disable comment, which is at least visible at the site.
|
|
||||||
Check(HasFinding(r.summary, "'raw'"), "no-char-pointer: a deduced char* local is still ours");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cases the line-based heuristic structurally could not see. It only looked
|
|
||||||
// at declarations starting at cumulative paren depth 0 and inferred scope
|
|
||||||
// from a running {/} count, so a wrapped signature, a specifier split over
|
|
||||||
// two lines, or a local shadowing a member all escaped it.
|
|
||||||
{
|
|
||||||
RuleRun r = RunRule("#include <string>\n"
|
|
||||||
"namespace Outer {\n"
|
|
||||||
" int wrapped_function(\n"
|
|
||||||
" int first,\n"
|
|
||||||
" int second) { return first + second; }\n"
|
|
||||||
" static\n"
|
|
||||||
" int splitStatic = 1;\n"
|
|
||||||
" struct Holder {\n"
|
|
||||||
" int Member = 0;\n"
|
|
||||||
" void Method() {\n"
|
|
||||||
" int Local = 1;\n"
|
|
||||||
" (void)Local;\n"
|
|
||||||
" }\n"
|
|
||||||
" };\n"
|
|
||||||
"}\n"
|
|
||||||
"extern \"C\" int c_api_entry(const char* name);\n",
|
|
||||||
"naming", LintMode::Report);
|
|
||||||
// A signature wrapped over lines is still a function declaration.
|
|
||||||
Check(HasFinding(r.summary, "'wrapped_function' should be PascalCase"), "naming: wrapped signature is seen");
|
|
||||||
// `static` on its own line still applies to the declaration below it.
|
|
||||||
Check(HasFinding(r.summary, "'splitStatic' should be PascalCase"), "naming: split specifier is seen");
|
|
||||||
// Members are camelCase; the enclosing kind decides, not a brace count.
|
|
||||||
Check(HasFinding(r.summary, "'Member' should be camelCase"), "naming: member is checked as a member");
|
|
||||||
// A local inside a method is a local, not a member and not a global.
|
|
||||||
Check(HasFinding(r.summary, "'Local' should be camelCase"), "naming: local inside a method is a local");
|
|
||||||
// Named by libc, not by us.
|
|
||||||
Check(!HasFinding(r.summary, "c_api_entry"), "naming: extern \"C\" declaration is exempt");
|
|
||||||
Check(!HasFinding(r.summary, "'Method'"), "naming: PascalCase method passes");
|
|
||||||
Check(!HasFinding(r.summary, "'Holder'"), "naming: PascalCase type passes");
|
|
||||||
}
|
|
||||||
|
|
||||||
// fixed-width-types must not rewrite an integer whose width somebody else's
|
|
||||||
// header chose — the rewrite would leave code that no longer matches the API
|
|
||||||
// it calls. Derived from the AST, so the exemption is per-declaration rather
|
|
||||||
// than the old per-line textual one, which both missed cases and disabled
|
|
||||||
// the rule for anything else sharing a line with `argc`/`extern "`.
|
|
||||||
{
|
|
||||||
RuleRun r = RunRule("#include <cstdlib>\n"
|
|
||||||
"extern \"C\" unsigned long CApiCall(unsigned int flags);\n"
|
|
||||||
"long OurOwnApi(int value) { return value; }\n"
|
|
||||||
"void F() {\n"
|
|
||||||
" unsigned long fromLibc = std::strtoul(\"1\", nullptr, 10);\n"
|
|
||||||
" unsigned ours = 1;\n"
|
|
||||||
" (void)fromLibc; (void)ours;\n"
|
|
||||||
"}\n"
|
|
||||||
"int main(int argc, char** argv) {\n"
|
|
||||||
" for (int i = 0; i < argc; ++i) { (void)argv[i]; }\n"
|
|
||||||
" return 0;\n"
|
|
||||||
"}\n",
|
|
||||||
"fixed-width-types", LintMode::Apply);
|
|
||||||
// Somebody else's widths, kept.
|
|
||||||
Check(r.text.contains("extern \"C\" unsigned long CApiCall(unsigned int flags);"), "fixed-width: extern \"C\" signature keeps its widths");
|
|
||||||
Check(r.text.contains("unsigned long fromLibc = std::strtoul"), "fixed-width: a value from a C library keeps its width");
|
|
||||||
Check(r.text.contains("int main(int argc, char** argv)"), "fixed-width: main's signature is untouched");
|
|
||||||
// Ours, converted.
|
|
||||||
Check(r.text.contains("std::int64_t OurOwnApi(std::int32_t value)"), "fixed-width: our own signature converts");
|
|
||||||
Check(r.text.contains("std::uint32_t ours = 1;"), "fixed-width: our own local converts");
|
|
||||||
// The loop counter inside main's BODY is ordinary code. The old rule
|
|
||||||
// skipped it because `argc` appeared on the same line; only the
|
|
||||||
// signature is exempt now, not everything near it.
|
|
||||||
Check(r.text.contains("for (std::int32_t i = 0;"), "fixed-width: main's body is not exempt, only its signature");
|
|
||||||
}
|
|
||||||
|
|
||||||
// enum-class asks for the next TOKEN after `enum`, so a declaration split
|
|
||||||
// over lines reads the same as one that is not. The regex it replaced
|
|
||||||
// required the name to follow `enum` on the same line.
|
|
||||||
{
|
|
||||||
RuleRun r = RunRule("enum Plain { A };\n"
|
|
||||||
"enum\n"
|
|
||||||
" Split { B };\n"
|
|
||||||
"enum class Scoped { C };\n"
|
|
||||||
"enum\n"
|
|
||||||
" class SplitScoped { D };\n"
|
|
||||||
"enum struct AlsoFine { E };\n",
|
|
||||||
"enum-class", LintMode::Report);
|
|
||||||
Check(r.summary.findings.size() == 2, std::format("enum-class: exactly the two plain enums ({} found)", r.summary.findings.size()));
|
|
||||||
const bool onFirst = std::any_of(r.summary.findings.begin(), r.summary.findings.end(), [](const LintFinding& f) { return f.line == 1; });
|
|
||||||
const bool onSplit = std::any_of(r.summary.findings.begin(), r.summary.findings.end(), [](const LintFinding& f) { return f.line == 2; });
|
|
||||||
Check(onFirst, "enum-class: single-line plain enum reported");
|
|
||||||
Check(onSplit, "enum-class: line-split plain enum reported at the keyword");
|
|
||||||
}
|
|
||||||
|
|
||||||
// const-local's mutation analysis is exact for scalars: assignment, ++/--,
|
|
||||||
// address-of and binding to a non-const reference are the only ways to
|
|
||||||
// write one, and all four are tracked. Both directions matter — a missed
|
|
||||||
// write means advising const on something that cannot be const.
|
|
||||||
{
|
|
||||||
RuleRun r = RunRule("void Mutate(int& out);\n"
|
|
||||||
"void ReadOnly(const int& in);\n"
|
|
||||||
"void ByValue(int v);\n"
|
|
||||||
"int Compute();\n"
|
|
||||||
"void F() {\n"
|
|
||||||
" int neverWritten = 1;\n"
|
|
||||||
" int assigned = 1; assigned = 2;\n"
|
|
||||||
" int incremented = 1; ++incremented;\n"
|
|
||||||
" int compound = 1; compound += 2;\n"
|
|
||||||
" int addressed = 1; int* taken = &addressed;\n"
|
|
||||||
" int toMutatingRef = 1; Mutate(toMutatingRef);\n"
|
|
||||||
" int toConstRef = 1; ReadOnly(toConstRef);\n"
|
|
||||||
" int toByValue = 1; ByValue(toByValue);\n"
|
|
||||||
" int boundToRef = 1; int& alias = boundToRef;\n"
|
|
||||||
" for (int loop = 0; loop < 1; ++loop) { (void)loop; }\n"
|
|
||||||
" (void)taken; (void)alias;\n"
|
|
||||||
"}\n",
|
|
||||||
"const-local", LintMode::Report);
|
|
||||||
Check(HasFinding(r.summary, "'neverWritten'"), "const-local: an unwritten local is reported");
|
|
||||||
Check(HasFinding(r.summary, "'toConstRef'"), "const-local: passing to a const& is not a write");
|
|
||||||
Check(HasFinding(r.summary, "'toByValue'"), "const-local: passing by value is not a write");
|
|
||||||
Check(!HasFinding(r.summary, "'assigned'"), "const-local: assignment is a write");
|
|
||||||
Check(!HasFinding(r.summary, "'incremented'"), "const-local: ++ is a write");
|
|
||||||
Check(!HasFinding(r.summary, "'compound'"), "const-local: += is a write");
|
|
||||||
Check(!HasFinding(r.summary, "'addressed'"), "const-local: taking an address counts as a write");
|
|
||||||
Check(!HasFinding(r.summary, "'toMutatingRef'"), "const-local: binding to a non-const& parameter is a write");
|
|
||||||
Check(!HasFinding(r.summary, "'boundToRef'"), "const-local: binding to a non-const& local is a write");
|
|
||||||
Check(!HasFinding(r.summary, "'loop'"), "const-local: a mutated loop counter is not reported");
|
|
||||||
// Pointers and range-for bindings are excluded: `T* const p` and
|
|
||||||
// `for (T* const x : …)` are not spellings anybody writes.
|
|
||||||
Check(!HasFinding(r.summary, "'taken'"), "const-local: pointer locals are out of scope");
|
|
||||||
}
|
|
||||||
{
|
|
||||||
RuleRun r = RunRule("void F() {\n" " for (int each : Range()) { (void)each; }\n" "}\n", "const-local", LintMode::Report);
|
|
||||||
Check(!HasFinding(r.summary, "'each'"), "const-local: a range-for binding is not reported");
|
|
||||||
}
|
|
||||||
|
|
||||||
// constexpr-constant asks clang's constant evaluator, not the tokens of the
|
|
||||||
// initialiser. sizeof and a fold over other constants are constant
|
|
||||||
// expressions that no token scan can recognise; a call result is not.
|
|
||||||
{
|
|
||||||
RuleRun r = RunRule("int Compute();\n"
|
|
||||||
"constexpr int Base = 4;\n"
|
|
||||||
"struct Big { int a, b; };\n"
|
|
||||||
"void F() {\n"
|
|
||||||
" const int literal = 4;\n"
|
|
||||||
" const int folded = 1 << 4;\n"
|
|
||||||
" const int fromSizeof = sizeof(Big);\n"
|
|
||||||
" const int fromOtherConstant = Base + 1;\n"
|
|
||||||
" const int fromCall = Compute();\n"
|
|
||||||
" constexpr int already = 8;\n"
|
|
||||||
" (void)literal; (void)folded; (void)fromSizeof;\n"
|
|
||||||
" (void)fromOtherConstant; (void)fromCall; (void)already;\n"
|
|
||||||
"}\n",
|
|
||||||
"constexpr-constant", LintMode::Report);
|
|
||||||
Check(HasFinding(r.summary, "'literal'"), "constexpr: a literal constant is reported");
|
|
||||||
Check(HasFinding(r.summary, "'folded'"), "constexpr: an operator fold over literals is reported");
|
|
||||||
// Neither of these is reachable from a token scan of the initialiser.
|
|
||||||
Check(HasFinding(r.summary, "'fromSizeof'"), "constexpr: sizeof folds");
|
|
||||||
Check(HasFinding(r.summary, "'fromOtherConstant'"), "constexpr: a fold over another constant folds");
|
|
||||||
Check(!HasFinding(r.summary, "'fromCall'"), "constexpr: a call result is not a constant expression");
|
|
||||||
Check(!HasFinding(r.summary, "'already'"), "constexpr: an existing constexpr is not re-reported");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Failures > 0) {
|
|
||||||
std::println(std::cerr, "{} assertions failed", Failures);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
20
tests/ProjectDependency/fixture/app/project.cpp
Normal file
20
tests/ProjectDependency/fixture/app/project.cpp
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
// SPDX-License-Identifier: LGPL-3.0-only
|
||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
import std;
|
||||||
|
import Crafter.Build;
|
||||||
|
#pragma crafter dependency "../greeter"
|
||||||
|
import Greeter;
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>) {
|
||||||
|
Configuration cfg;
|
||||||
|
cfg.path = "./";
|
||||||
|
cfg.name = Greet();
|
||||||
|
cfg.outputName = "app";
|
||||||
|
cfg.type = ConfigurationType::Executable;
|
||||||
|
// Once by the module it exports, once by its configuration name; the
|
||||||
|
// test expects the same object back for both.
|
||||||
|
cfg.dependencies = { ProjectDependency("Greeter"), ProjectDependency("greeter") };
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
8
tests/ProjectDependency/fixture/bad-both/project.cpp
Normal file
8
tests/ProjectDependency/fixture/bad-both/project.cpp
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import std;
|
||||||
|
import Crafter.Build;
|
||||||
|
#pragma crafter dependency "../greeter" branch "main" commit "0123abc"
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>) {
|
||||||
|
return Configuration{};
|
||||||
|
}
|
||||||
8
tests/ProjectDependency/fixture/bad-nosource/project.cpp
Normal file
8
tests/ProjectDependency/fixture/bad-nosource/project.cpp
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import std;
|
||||||
|
import Crafter.Build;
|
||||||
|
#pragma crafter dependency
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>) {
|
||||||
|
return Configuration{};
|
||||||
|
}
|
||||||
12
tests/ProjectDependency/fixture/cycle-a/project.cpp
Normal file
12
tests/ProjectDependency/fixture/cycle-a/project.cpp
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import std;
|
||||||
|
import Crafter.Build;
|
||||||
|
#pragma crafter dependency "../cycle-b"
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>) {
|
||||||
|
Configuration cfg;
|
||||||
|
cfg.path = "./";
|
||||||
|
cfg.name = "cycle-a";
|
||||||
|
cfg.outputName = "cycle-a";
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
12
tests/ProjectDependency/fixture/cycle-b/project.cpp
Normal file
12
tests/ProjectDependency/fixture/cycle-b/project.cpp
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import std;
|
||||||
|
import Crafter.Build;
|
||||||
|
#pragma crafter dependency "../cycle-a"
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>) {
|
||||||
|
Configuration cfg;
|
||||||
|
cfg.path = "./";
|
||||||
|
cfg.name = "cycle-b";
|
||||||
|
cfg.outputName = "cycle-b";
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
export module Greeter;
|
||||||
|
import std;
|
||||||
|
export std::string Greet() {
|
||||||
|
return "hello again";
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
export module Greeter;
|
||||||
|
import std;
|
||||||
|
// Not inline: the string has to come out of libgreeter.a, so a consumer that
|
||||||
|
// only found the BMI and never linked the archive fails to load.
|
||||||
|
export std::string Greet() {
|
||||||
|
return "hello from greeter";
|
||||||
|
}
|
||||||
23
tests/ProjectDependency/fixture/greeter/project.cpp
Normal file
23
tests/ProjectDependency/fixture/greeter/project.cpp
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
// The sibling project the consumer's project.cpp imports a module from.
|
||||||
|
// ApplyStandardArgs on purpose: it reads CRAFTER_BUILD_MARCH from the
|
||||||
|
// environment, and the pre-pass has to override whatever it picked.
|
||||||
|
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) {
|
||||||
|
Configuration cfg;
|
||||||
|
cfg.path = "./";
|
||||||
|
cfg.name = "greeter";
|
||||||
|
cfg.outputName = "greeter";
|
||||||
|
cfg.type = ConfigurationType::LibraryStatic;
|
||||||
|
ApplyStandardArgs(cfg, args);
|
||||||
|
std::array<fs::path, 1> ifaces = { "interfaces/Greeter" };
|
||||||
|
std::array<fs::path, 0> impls = {};
|
||||||
|
cfg.GetInterfacesAndImplementations(ifaces, impls);
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
151
tests/ProjectDependency/main.cpp
Normal file
151
tests/ProjectDependency/main.cpp
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
// SPDX-License-Identifier: LGPL-3.0-only
|
||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
#include <stdlib.h>
|
||||||
|
import std;
|
||||||
|
import Crafter.Build;
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
// `#pragma crafter dependency`: a project.cpp that imports a module from a
|
||||||
|
// sibling crafter-build project. LoadProject has to fetch and build that
|
||||||
|
// project before project.cpp compiles, put its BMI and archive on the
|
||||||
|
// project.so command line, and treat the archive as an input to the
|
||||||
|
// staleness check. The fixture is copied to a scratch dir because one case
|
||||||
|
// edits a source and every build drops bin/ and build/ trees next to it.
|
||||||
|
//
|
||||||
|
// A recompiled project.so can't be observed from the process that already
|
||||||
|
// dlopened the old one — the loader hands back the mapping it has for that
|
||||||
|
// path — so the "dependency changed" cases load through a child process:
|
||||||
|
// this binary re-run with `--load <project.cpp>` prints the name the project
|
||||||
|
// file produced.
|
||||||
|
namespace {
|
||||||
|
std::int32_t Failures = 0;
|
||||||
|
|
||||||
|
void Check(bool cond, std::string_view msg) {
|
||||||
|
if (!cond) {
|
||||||
|
std::println(std::cerr, "FAIL: {}", msg);
|
||||||
|
++Failures;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expect LoadProject to throw; the message, or "" when it didn't.
|
||||||
|
std::string LoadFails(const fs::path& projectFile) {
|
||||||
|
std::array<std::string_view, 0> noArgs = {};
|
||||||
|
try {
|
||||||
|
(void)LoadProject(projectFile, noArgs);
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
return e.what();
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadProject in a fresh process; returns what it printed, trimmed.
|
||||||
|
std::string LoadInChild(const fs::path& projectFile) {
|
||||||
|
const fs::path self = fs::read_symlink("/proc/self/exe");
|
||||||
|
const auto r = RunCommandWithTimeout(std::format("{} --load {}", self.string(), projectFile.string()), std::chrono::seconds(300));
|
||||||
|
std::string out = r.output;
|
||||||
|
while (!out.empty() && (out.back() == '\n' || out.back() == '\r')) out.pop_back();
|
||||||
|
if (r.exitCode != 0) return std::format("<exit {}> {}", r.exitCode, out);
|
||||||
|
// The dependency build's progress lines share stdout; the name is the
|
||||||
|
// last line.
|
||||||
|
const std::size_t lastBreak = out.rfind('\n');
|
||||||
|
return lastBreak == std::string::npos ? out : out.substr(lastBreak + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChangeGreeting(const fs::path& scratch) {
|
||||||
|
const fs::path iface = scratch / "greeter" / "interfaces" / "Greeter.cppm";
|
||||||
|
fs::copy_file(scratch / "greeter" / "interfaces" / "Greeter-changed.cppm.in", iface, fs::copy_options::overwrite_existing);
|
||||||
|
// copy_file carries the source's mtime across, which would leave the
|
||||||
|
// rewritten interface looking older than the BMI built from it.
|
||||||
|
fs::last_write_time(iface, fs::file_time_type::clock::now());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char** argv) {
|
||||||
|
if (argc == 3 && std::string_view(argv[1]) == "--load") {
|
||||||
|
std::array<std::string_view, 0> noArgs = {};
|
||||||
|
Configuration cfg = LoadProject(argv[2], noArgs);
|
||||||
|
std::println("{}", cfg.name);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fs::path installedHome = GetCrafterBuildHome();
|
||||||
|
if (!fs::exists(installedHome / "Crafter.Build.cppm")) {
|
||||||
|
std::println(std::cerr, "SKIP: no module sources at {}", installedHome.string());
|
||||||
|
return 77;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fs::path fixture = fs::current_path() / "tests" / "ProjectDependency" / "fixture";
|
||||||
|
const fs::path scratch = fs::temp_directory_path() / "crafter-build-project-dependency";
|
||||||
|
std::error_code ec;
|
||||||
|
fs::remove_all(scratch, ec);
|
||||||
|
fs::create_directories(scratch);
|
||||||
|
fs::copy(fixture, scratch, fs::copy_options::recursive);
|
||||||
|
|
||||||
|
// The dependency's project.cpp applies the standard args, which read the
|
||||||
|
// march from the environment. project.so is compiled for the host, so
|
||||||
|
// the pre-pass must override it or the dependency's BMIs won't load.
|
||||||
|
setenv("CRAFTER_BUILD_MARCH", "x86-64-v2", 1);
|
||||||
|
|
||||||
|
const fs::path app = scratch / "app" / "project.cpp";
|
||||||
|
const fs::path appSo = scratch / "app" / "build" / "project.so";
|
||||||
|
std::array<std::string_view, 0> noArgs = {};
|
||||||
|
|
||||||
|
// The dependency is fetched, built and linked before project.cpp runs,
|
||||||
|
// and ProjectDependency() hands it back by either name.
|
||||||
|
try {
|
||||||
|
Configuration cfg = LoadProject(app, noArgs);
|
||||||
|
Check(cfg.name == "hello from greeter", std::format("project.cpp imported and called the dependency's module (got '{}')", cfg.name));
|
||||||
|
Check(cfg.dependencies.size() == 2, "ProjectDependency resolved by module name and by configuration name");
|
||||||
|
if (cfg.dependencies.size() == 2) {
|
||||||
|
Check(cfg.dependencies[0] == cfg.dependencies[1], "both names resolve to the same dependency");
|
||||||
|
const Configuration& dep = *cfg.dependencies[0];
|
||||||
|
Check(dep.name == "greeter", "the dependency is the fixture's greeter project");
|
||||||
|
Check(dep.target == HostTarget(), "the dependency was built for the host triple LoadProject uses");
|
||||||
|
Check(dep.march == "native", "the environment's march was overridden for host tooling");
|
||||||
|
Check(std::ranges::contains(dep.compileFlags, "-fPIC"), "the dependency archive was built position-independent");
|
||||||
|
Check(fs::exists(ExpectedOutput(dep)), "the dependency archive exists");
|
||||||
|
}
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
std::println(std::cerr, "FAIL: LoadProject threw: {}", e.what());
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
Check(fs::exists(appSo), "project.so was produced");
|
||||||
|
if (!fs::exists(appSo)) return 1;
|
||||||
|
const fs::file_time_type firstSo = fs::last_write_time(appSo);
|
||||||
|
|
||||||
|
// Editing the dependency rebuilds it and recompiles project.so against
|
||||||
|
// it — without touching project.cpp.
|
||||||
|
ChangeGreeting(scratch);
|
||||||
|
const std::string changed = LoadInChild(app);
|
||||||
|
Check(changed == "hello again", std::format("a changed dependency reaches project.cpp (got '{}')", changed));
|
||||||
|
Check(fs::last_write_time(appSo) > firstSo, "project.so was recompiled after the dependency changed");
|
||||||
|
const fs::file_time_type secondSo = fs::last_write_time(appSo);
|
||||||
|
|
||||||
|
// And an unchanged dependency doesn't: "always stale" would be invisible
|
||||||
|
// to every other case here.
|
||||||
|
const std::string unchanged = LoadInChild(app);
|
||||||
|
Check(unchanged == "hello again", std::format("an unchanged dependency still loads (got '{}')", unchanged));
|
||||||
|
Check(fs::last_write_time(appSo) == secondSo, "project.so is left alone when nothing changed");
|
||||||
|
|
||||||
|
// Directive errors name the file and line.
|
||||||
|
const std::string noSource = LoadFails(scratch / "bad-nosource" / "project.cpp");
|
||||||
|
Check(noSource.contains("project.cpp:3:") && noSource.contains("expected a quoted source"), std::format("a directive without a source is rejected with its location (got '{}')", noSource));
|
||||||
|
const std::string both = LoadFails(scratch / "bad-both" / "project.cpp");
|
||||||
|
Check(both.contains("project.cpp:3:") && both.contains("mutually exclusive"), std::format("branch and commit together are rejected (got '{}')", both));
|
||||||
|
|
||||||
|
// Two project files that depend on each other stop instead of recursing.
|
||||||
|
const std::string cycle = LoadFails(scratch / "cycle-a" / "project.cpp");
|
||||||
|
Check(cycle.contains("circular project dependency"), std::format("a dependency cycle is reported (got '{}')", cycle));
|
||||||
|
|
||||||
|
// The accessor is only meaningful while a project file is running.
|
||||||
|
try {
|
||||||
|
(void)ProjectDependency("Greeter");
|
||||||
|
Check(false, "ProjectDependency outside a project file throws");
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
Check(std::string_view(e.what()).contains("outside a project file"), std::format("ProjectDependency outside a project file explains itself (got '{}')", e.what()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Failures > 0 ? 1 : 0;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue