Add canon add module and canon add unit-test

canon add module <path> creates <stem>/<path>.hpp, .cpp and .test.cpp
(--no-test skips the test), records the module in .canon.toml, adds the
source to the project's target and re-renders the managed block of
CMakeLists.txt. canon add unit-test adds the test later. Both work from
any directory inside the project.

- toml: a reader for the TOML subset canon writes, with line numbers in
  errors, so canon stays dependency-free
- manifest: strict parsing (unknown keys, wrong types, bad references)
- project: finds .canon.toml by walking up from the current directory
- plan/executor: update_file refuses to write if the file changed after
  it was read, and the report skips directories that already exist
- tests: shared scenarios drive both the golden and end-to-end tests;
  goldens now live in tests/golden/<scenario>/

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 18:24:53 +02:00
parent 48edb15d16
commit d38e1466da
69 changed files with 2406 additions and 249 deletions
+45 -25
View File
@@ -1,19 +1,27 @@
# canon
Scaffolds C++ projects that follow [P1204R0](https://wg21.link/p1204r0)
(Canonical Project Structure). Written in C++23, no third-party dependencies.
(Canonical Project Structure), then keeps adding to them. Written in C++23, no
third-party dependencies.
```bash
canon new lib libhello # library: libhello/hello/hello.{hpp,cpp,test.cpp} + tests/basics
canon new exe hello # executable: hello/hello/hello.cpp
canon new lib libhello --dry-run # show the plan, write nothing
canon new exe hello --dir ~/src # create the project somewhere else
canon new lib libhello # library: libhello/hello/hello.{hpp,cpp,test.cpp} + tests/basics
canon new exe hello # executable: hello/hello/hello.cpp
canon new exe hello --dir ~/src # create the project somewhere else
cd libhello
canon add module core # hello/core.hpp, core.cpp, core.test.cpp
canon add module details/utility # hello/details/utility.*, namespace hello::details
canon add module parser --no-test # no .test.cpp ...
canon add unit-test parser # ... until you want one
canon add module core --dry-run # any command: show the changes, write nothing
```
Every generated project builds and passes its tests out of the box:
`add` commands work from any directory inside the project. Every generated
project builds and passes its tests out of the box:
```bash
cd libhello
cmake -S . -B build
cmake --build build
ctest --test-dir build --output-on-failure
@@ -30,25 +38,35 @@ cmake --install build --prefix ~/.local # puts canon in ~/.local/bin
## How it works
Generators never touch the filesystem. They return a **plan** (a list of
`make_directory` / `create_file` operations) and the **executor** applies it.
The executor checks every operation first, so a conflict writes nothing, and
`--dry-run` is just "check, don't write". canon never overwrites files.
Generators never touch the filesystem. They return a **plan**: a list of
`make_directory`, `create_file` and `update_file` operations. The **executor**
checks every operation before writing anything, so a conflict writes nothing and
`--dry-run` just skips the writing. canon never overwrites existing files, and
it refuses to update a file that changed after it was read.
Each project gets a **`.canon.toml` manifest** recording what canon declared.
Each project has a **`.canon.toml` manifest** recording what canon declared.
The block between `# >>> canon:managed >>>` and `# <<< canon:managed <<<` in
`CMakeLists.txt` is rendered entirely from that manifest. Upcoming `canon add`
commands will update the manifest and re-render the block, so running a command
twice gives the same result. Anything outside the block belongs to you.
`CMakeLists.txt` is rendered entirely from that manifest. An `add` command
updates the manifest and re-renders the whole block, so:
- anything **outside** the block is yours and is never touched;
- anything you edit **inside** the block is replaced on the next `add`;
- `.canon.toml` is rewritten by canon, so comments you add to it are dropped.
canon reads `.canon.toml` strictly: unknown keys (usually typos) are errors
with a line number.
| File | Role |
|---|---|
| `canon/canon.cpp` | `main`: wires the command line to the generators and executor |
| `canon/cli.*` | argument parsing |
| `canon/name.*` | project name rules (`lib` prefix, stem, namespace) |
| `canon/generate.*` | builds the manifest and plan for `canon new`; file templates |
| `canon/manifest.*` | the manifest model and its TOML output |
| `canon/cmake.*` | renders `CMakeLists.txt` and the managed block from a manifest |
| `canon/name.*` | naming rules (`lib` prefix, stem, namespace, keywords) |
| `canon/generate.*` | the manifest and plan for `canon new`; project templates |
| `canon/add.*` | the plans for `canon add module` and `canon add unit-test` |
| `canon/project.*` | finds and loads an existing project |
| `canon/manifest.*` | the manifest model, written to and read from TOML |
| `canon/toml.*` | the small TOML subset canon reads and writes |
| `canon/cmake.*` | renders `CMakeLists.txt` and replaces its managed block |
| `canon/plan.*` | operations and plans (pure data) |
| `canon/executor.*` | checks and applies a plan |
| `canon/template.*` | `{{key}}` substitution |
@@ -56,17 +74,19 @@ twice gives the same result. Anything outside the block belongs to you.
## Tests
- **Unit tests**: `canon/<unit>.test.cpp`, next to the code they cover.
- **Golden tests**: `tests/golden/` holds the exact expected output for
`libhello` and `hello`. After an intentional template change, regenerate the
copies and review the diff:
- **Scenarios**: `tests/scenarios.cmake` runs command sequences (for example
`new lib` followed by several `add` commands) that both of the following use.
- **Golden tests**: `tests/golden/<scenario>/` holds the exact expected result of
each scenario. After an intentional template change, regenerate the copies and
review the diff:
`cmake -DCANON=build/canon -DWORK_DIR=build/tests/golden -DUPDATE=ON -P tests/golden/check.cmake`
- **End to end**: `tests/e2e/` scaffolds both kinds, then configures, builds and
runs their tests. Skip it with `ctest -LE e2e`.
- **End to end**: `tests/e2e/` runs each scenario, then configures, builds and
tests the result. Skip it with `ctest -LE e2e`.
## Roadmap
1. ✅ `new lib`, `new exe`, plan/apply, `--dry-run`, manifest
2. `add module`, `add unit-test`: read `.canon.toml` and re-render the managed block
2. ✅ `add module`, `add unit-test`, reading `.canon.toml`, managed-block updates
3. `add target`, `add test`, `add dep`, `doctor` (check a project against P1204R0)
4. User template overrides