Replace CMake template readme by CLIForge's readme.

This commit is contained in:
alejo committed 2026-08-20 15:02:24 -03:00
1 parent fe6a77de83
commit 0766b0b990
1 file changed
+266 -380
+266 -380
View File
@@ -1,386 +1,272 @@
# CMake module-system demo
# CLIForge
A minimal C++20 project showing a per-module CMake setup: each module owns
a tiny, self-contained `CMakeLists.txt`, the root `CMakeLists.txt`
discovers modules automatically, three ways to bring in third-party code
(vendored, installed, or fetched) stay out of your warnings and your
compile database, every module gets its own browsable documentation
automatically the moment Doxygen is installed, and every output path is
configured in one place.
A header-only C++20 engine for building CLIs whose commands are ordinary
free functions. Parameters and options are explicitly typed where
they're declared; `.action()` validates (never invents) that the bound
function's arguments are consistent with what was declared, and accepts
them by value, by reference, or by const reference.
```cpp
#include <cliforge/cliforge.hpp>
using cliforge::Engine;
void greet(const std::string& name, bool loud) {
std::cout << (loud ? "HELLO, " : "Hello, ") << name << (loud ? "!!" : ".") << "\n";
}
int main(int argc, char** argv) {
cliforge::Engine cli("greeter");
cli.command()
.keyword("greet")
.parameter<std::string>("name", "Who to greet")
.flag("loud", 'l', "SHOUT the greeting")
.describe("Greets someone by name")
.action(&greet);
return cli.run(argc, argv);
}
```
```
$ greeter greet Ada --loud
HELLO, Ada!!
```
## Try it
```sh
g++ -std=c++20 -Iinclude -O2 -o packctl examples/main.cpp
./packctl # global help
./packctl project myapp create --template cpp --git
./packctl build 42 2 1 -a arm64 x64 -e vim # variadic params + variadic option
./packctl build 7 -ndi -e nano # combined short flags
./packctl deploy web1 --env prod web2 -n web3 # loose parameters, order-agnostic
./packctl project new -h # ambiguous prefix -> shows all 3 matches
./packctl projct myapp delete # typo -> "did you mean"
```
Or with CMake: `cmake -B build && cmake --build build`, which produces
`packctl` (the demo) plus `type_test` / `registration_errors_test`.
## The model
A command has exactly two parts, matching the spec:
```
packctl deploy web1 --env prod web2 -n web3
\____/ \___________ unstructured ___________/
structured flags + options + loose parameters, any order
```
* **Structured part** -- an ordered, *fixed-position* sequence of literal
**keywords** (`project`, `delete`) and typed positional **parameters**
(`<name>`). The user's tokens must line up with this sequence exactly,
in order. Only the *last* parameter here may be variadic. Keywords
carry no description of their own -- `.describe()` on the command is
what shows up in help.
* **Unstructured part** -- **flags** (boolean switches), **options**
(named, typed values), and additional **loose parameters**, which the
user may give in any order relative to each other:
* Flags/options are found by name (`--foo`/`-f`) wherever they appear.
Short flags combine (`-ndi` == `-n -d -i`); options can't be combined
since they need a value.
* Whatever bare (non-dash) tokens are left over are the loose
parameters, matched *positionally among themselves, in declaration
order* -- regardless of where they fall relative to the flags/
options. `deploy web1 --env prod web2 -n web3` and
`deploy -n web1 web2 --env prod web3` both give `hosts = [web1, web2,
web3]`.
A command must declare at least one keyword. `.keyword()` can only be
called before any `.flag()`/`.option()`. `.parameter<T>()` can be called
on *either side* of that line: before the first `.flag()`/`.option()`
it's a structured, fixed-position parameter; after, it's a loose one.
Either way, only the last parameter declared in its half may be variadic.
**Parameters are always required** (unless they're the trailing variadic
one, which accepts zero or more). **Options are always optional** --
`.option<T>()` must be bound to `std::optional<T>` or (if variadic)
`std::vector<T>` in `action()`; binding it to a bare, required `T` is a
`RegistrationError`. This mirrors ordinary CLI convention: positional
arguments are the required ones, named flags/options never are.
## Explicit types, validated (not invented) at action()
Every `.parameter<T>()` / `.option<T>()` call requires `T` up front --
types are never inferred from the bound function. `.action(&fn)` deduces
`fn`'s argument types via `function_traits` and *checks* each one against
what was already declared, matched in order:
```
[ parameters, in the order you called .parameter<T>() before any flag/option ]
++
[ flags/options/loose-parameters, in the order you called them after ]
```
| Declared as | `action()`'s argument must be |
|---|---|
| `flag(...)` | `bool` |
| `parameter<T>(...)` | `T` -- by value, `const T&`, or `T&` |
| `parameter<T>(..., variadic=true)` | `std::vector<T>` |
| `option<T>(...)` | `std::optional<T>` (never a bare `T` -- options are always optional) |
| `option<T>(..., variadic=true)` | `std::vector<T>` |
| an `enum class` (either kind) | needs `.choices<T>({{"name", T::Value}, ...})` called immediately after; `T` is deduced from the initializer list |
A mismatch anywhere in this table (wrong arity, a flag bound to
non-`bool`, an option bound to a bare/required type, a declared type
that doesn't match the corresponding argument type, a variadic slot
bound to a non-vector, ...) is a `RegistrationError` at startup -- see
`tests/registration_errors_test.cpp`. An unsupported scalar type (say,
`std::map<int,int>`) fails immediately at the `parameter<T>()`/
`option<T>()` call site via `static_assert`, before `action()` is even
reached.
Arguments can be taken **by value, by reference, or by const
reference** -- whichever avoids an unnecessary copy for the type in
question (`const std::string&`, `const std::vector<T>&`, etc. all work).
## Types
Fixed-width signed/unsigned integers (`int8_t` ... `uint64_t`), `float`,
`double`, `std::string`, `char` (exactly one byte), `bool`
(`true/false/1/0/yes/no/on/off`, case-insensitive), and custom
`enum class` types via `choices<T>()`. Parsing uses `std::from_chars`
for numbers, so `"5abc"` or `"3.14"` handed to an `int32_t` slot is
rejected rather than silently truncated.
## Errors are categorized, not just "something went wrong"
Every runtime parse failure is a `CliError` carrying both a specific,
actionable message and a programmatic `ErrorKind` (`.kind()`):
| Situation | `ErrorKind` | Example message |
|---|---|---|
| A `--flag`/`-x` isn't recognized | `UnknownOption` | `unknown flag/option '--arch' for this command -- did you mean '--architectures'?` |
| More positional values than declared slots can absorb | `TooManyArguments` | `too many arguments: unexpected 'extra' (and 2 more)` |
| A required parameter (or loose parameter) was never given | `MissingArgument` | `missing required argument '<name>': expected a value of type string` |
| An option was named but its value token is missing | `MissingValue` | `option '--editor' requires a value` |
| A token doesn't fit its declared type | `TypeMismatch` | `type mismatch for 'threshold': got 'abc', expected int32` |
## `--help` resolves ambiguity instead of hiding it
`--help`/`-h` (anywhere in the command line) and the `help <words...>`
top-level command both search for *every* command consistent with what
was typed so far -- including when a positional value has already been
filled in. `packctl project new -h` matches `project <name> create`,
`project <name> delete`, and `project <name>` (info) all at once, since
`new` is a perfectly plausible `<name>` for any of them; it prints all
three, clearly separated, rather than silently picking one. A
fully-specified prefix (`project myapp delete --help`) narrows to the
one actual match, exactly as before.
The same treatment applies to an *incomplete* command that wasn't
explicitly asking for help: `packctl project` alone is missing
`<name>`, and that's equally true of all three `project` commands (they
report the identical error). Rather than arbitrarily surfacing whichever
one happened to be registered first, every command tied for "most
specific incomplete match" is listed:
```
$ packctl project
Error: missing required argument '<name>': expected a value of type string
'project' matches 3 commands:
packctl project <name> create [OPTIONS] Scaffold a new project from a template
packctl project <name> delete [OPTIONS] Permanently delete a project by name
packctl project <name> [OPTIONS] Show information about a project
```
A single most-specific match (e.g. `packctl project x create --template`,
missing only `--template`'s value) still reports just the one relevant
`Usage:` line, unchanged.
## Keeping allocations down
* `TypeInfo`'s parser is a **plain stateless function pointer**, not
`std::function` -- built-in scalar types (the overwhelming majority of
slots) cost nothing to store or copy. Only `choices<T>()`-restricted
slots pay for an allocation, once, at registration (a `shared_ptr` to
a small name table), not per parse.
* **Short flag/option names are `char`**, not `std::string` -- no
allocation to declare one, and combined short flags (`-ndi`) are
matched by direct `char` comparison instead of constructing a
temporary single-character string per letter.
* The unstructured parser (flags/options/loose-parameters) works in
terms of `std::string_view` internally -- splitting `--name=value` or
stripping the leading dashes off a token no longer copies; the token's
bytes are only ever copied once, into the final `Scalar`, when a value
actually needs to be owned.
* `choices<T>()` no longer goes through `std::any` -- the declared C++
type is tracked with a `std::type_index` (a couple of pointer-sized
comparisons, no allocation) and validated directly against `action()`'s
argument types.
Parsing a single command line still does a handful of small, one-shot
`std::vector` allocations (there's no way around owning the parsed
values), so if you're embedding cliforge in a long-running REPL parsing
many commands per process, reusing buffers across calls would be the
next lever -- not implemented here, since it's not needed for the
"parse argv once and dispatch" shape most CLIs have.
## Design decisions worth knowing about
These are the places where the spec was open to interpretation; here's
what this implementation does and why.
* **Keywords never start with `-`.** The spec allows keywords to start
with digits, but a leading dash would be indistinguishable from a
flag. Internal dashes/dots/underscores are fine (`self-update`,
`2fa`, `v2.load` are all valid keywords).
* **Negative numbers vs. flags.** A token is only treated as "flag/
option syntax" if it starts with `-` and the next character *isn't a
digit* -- so `-5`/`-3` parse as negative numeric values (structured
variadic parameters *or* loose parameters), while `-n` still means the
flag `n`. Applied consistently in both the structured matcher and the
unstructured parser.
* **Disambiguating overlapping commands.** `project <name>` and
`project <name> delete` can both structurally match
`project myapp delete`. Candidates are ranked by how many *literal*
keywords matched (more specific wins), with total structured-slot
count as a tiebreaker, and are tried in that order -- so `delete`
correctly wins over swallowing `"delete"` into `<name>`.
* **Error attribution when several candidates fail.** If the
best-ranked candidate's *value* fails to parse or is missing, that's
almost certainly the user's intended command, so its error is what
gets reported -- not a generic "no command matches", and not a
less-relevant error from a lower-ranked candidate that also happened
to fail.
* **"Did you mean?" is structural, not string-blob.** Comparing the raw
input against a usage string like `project <name> delete` would
score badly once you substitute a real value for `<name>`. Instead,
suggestions walk the same keyword/parameter structure as real
matching, treating parameter slots as wildcards and only scoring
edit-distance on the literal keyword text -- normalized to a ratio so
a variadic parameter silently absorbing unrelated tokens can't make
garbage input look like a close match. Flag/option suggestions
additionally treat a prefix relationship (`--arch` for
`--architectures`) as a strong match even though its raw edit
distance is large.
* **`--name=value` is supported** alongside the spec's space-separated
form, as a low-cost, expected CLI convention.
* **Repeated non-variadic options: last write wins** (`--reason a
--reason b` yields `"b"`). Variadic options/loose-parameters
accumulate instead.
* **Not implemented:** a `--` "end of options" separator. Given the
negative-number heuristic already resolves the main practical
ambiguity, this was left as a documented extension point rather than
added speculatively.
## Layout
```
CMakeLists.txt project setup, options, install(), discovery -- edit by hand
Configuration.cmake all output-path configuration -- edit to relocate anything
cmake/BuildHelpers.cmake module macros + discovery mechanism -- edit rarely
cmake/Dependencies.cmake vendoring / installed-library / FetchContent helpers -- edit rarely
cmake/Documentation.cmake automatic per-module Doxygen integration -- edit rarely
source/Core/CMakeLists.txt library module "core_lib" (SHARED), depends on fmt
source/HelloApp/CMakeLists.txt executable module "hello_exe", depends on vendored tiny_ansi
source/FarewellApp/CMakeLists.txt executable module "farewell_exe", depends on vendored tiny_case
vendor/tiny_ansi/ header-only vendored dependency (no build of its own)
vendor/tiny_case/ vendored dependency with its own CMakeLists.txt
scripts/build.sh, build.bat configure + build
scripts/run.sh, run.bat run a module by its CMake target name
scripts/install.sh, install.bat install this project's own artifacts only
scripts/docs.sh, docs.bat build documentation for every module (or one)
.clang-format, .clang-tidy C++ style guide tooling (see "Code style & tooling" below)
.editorconfig, .gitattributes -- same
c-api/.clang-tidy naming override template for a C API subtree, if you add one
.githooks/pre-commit formatting/lint pre-commit hook
cpp-style-guide.md, TOOLING.md the style guide itself + tooling docs
include/cliforge/
value.hpp Scalar/Value/TypeInfo/ErrorKind -- the type & error system
slot.hpp Keyword/Parameter/Flag/Option descriptor
function_traits.hpp Return/argument-type deduction for action()
levenshtein.hpp Edit distance + prefix-aware closest-match helper
command.hpp Builder, matcher, unstructured parser, help text
engine.hpp Registration, ranking/dispatch, help resolution
cliforge.hpp Aggregator -- the only header you include
examples/main.cpp "packctl", a demo touching every feature
tests/
type_test.cpp every scalar type, valid + invalid input
registration_errors_test.cpp every builder-misuse guard (+ positive cases)
```
Everything generated -- the build tree, install output, docs, and reserved
packaging output -- lives under one `out/` directory (see "Output paths"
below); nothing under `out/` is source-controlled.
Each module's `CMakeLists.txt` is (conventionally) a couple of lines:
```cmake
# source/Core/CMakeLists.txt
add_dependency(fmt
GIT_REPOSITORY https://github.com/fmtlib/fmt.git
GIT_TAG 11.0.2
FIND_PACKAGE_ARGS NAMES fmt
)
add_lib_module(core_lib TYPE SHARED OUTPUT_NAME "demo_core" DEPENDS fmt::fmt)
```
A module declares its own dependencies in its own `CMakeLists.txt` -- same
"minimal tool intervention" principle as module registration: nothing to
add in the root file.
## Output paths (`Configuration.cmake`)
Every generated path is a cache variable, overridable with `-D<NAME>=...`
at configure time instead of editing any file:
| Variable | Default | What it controls |
|---|---|---|
| `OUT_DIR` | `out/` | Root of everything generated; override this to relocate all of the below at once |
| `INSTALL_OUTPUT_DIR` | `out/install` | Root of the `cmake --install` tree, one subdirectory per `DIST_TARGET` |
| `DIST_OUTPUT_DIR` | `out/dist` | Reserved for packaged/archived output (e.g. a future CPack integration) -- not populated by this project yet, but created and ready |
| `DOCS_OUTPUT_DIR` | `out/docs` | Root of generated Doxygen documentation, one subdirectory per module |
| `TOOLCACHE_DIR` | `.cache/tools` | Where downloaded, pinned build tools (currently just Doxygen) are cached -- kept outside `OUT_DIR` so `rm -rf out/` doesn't force a re-download |
| `RUNTIME_OUTPUT_SUBDIR` | `bin` | Subdirectory (inside whatever the build dir is) for executables/`.dll`/`.so` |
| `LIBRARY_OUTPUT_SUBDIR` | `bin` | Subdirectory for shared-library artifacts (kept alongside executables so `$ORIGIN` RPATH resolves them) |
| `ARCHIVE_OUTPUT_SUBDIR` | `lib` | Subdirectory for static/import-library artifacts |
The build directory itself (`CMAKE_BINARY_DIR`, where `CMakeCache.txt`
lives) isn't a `Configuration.cmake` variable -- it's fixed by whatever you
pass to `-B` when configuring. `scripts/build.sh`/`build.bat` default to
`out/build`, so the resulting layout is:
```
out/
build/ CMAKE_BINARY_DIR -- out/build/bin, out/build/lib during normal builds
install/<DIST_TARGET>/{bin,lib} what `cmake --install` / scripts/install.sh populates
dist/ reserved for future packaging output
docs/<module>/html/, docs/index.html generated documentation
```
Example -- build into a different location entirely:
```bash
cmake -G Ninja -S . -B /tmp/my-build -DOUT_DIR=/tmp/my-out
cmake --build /tmp/my-build
```
## Three ways to bring in a dependency (`cmake/Dependencies.cmake`)
| Helper | For | Example |
|---|---|---|
| `add_vendor_header_only(NAME [INCLUDE_DIR <dir>])` | A buildless header-only drop under `vendor/<NAME>/include` | `vendor/tiny_ansi/` |
| `add_vendor_subdirectory(SUBDIR)` | A vendored dependency with its own `CMakeLists.txt`, under `vendor/<SUBDIR>` | `vendor/tiny_case/` |
| `add_dependency(NAME <FetchContent_Declare args>)` | An installed package (`FIND_PACKAGE_ARGS ...`) with a fetch-from-source fallback | `fmt` in `source/Core/` |
All three are safe to call more than once for the same name (e.g. two
modules that need the same dependency) and mark the dependency's headers
`SYSTEM`, so `-Wall -Wextra -Wpedantic`/`/W4` -- which we only ever apply
per-target inside our own module macros, never globally -- doesn't fire on
code you don't control. Their targets are also dropped from
`compile_commands.json` (see below).
`add_dependency` is CMake's native find-then-fetch integration: it tries
`find_package()` first (an already-installed apt/vcpkg/conan copy) via
`FIND_PACKAGE_ARGS`, and only downloads + builds from source if nothing is
found. Omit `FIND_PACKAGE_ARGS` to always fetch. Requires network access
the first time a dependency isn't already installed.
A module `DEPENDS` on whatever target the chosen path defines (`fmt::fmt`,
`tiny_case`, `tiny_ansi`, ...) exactly like depending on another module --
`add_lib_module`/`add_exe_module` don't need to know or care which of the
three supplied it.
## Documentation (automatic, per module, pinned Doxygen)
Every module registered via `add_lib_module`/`add_exe_module`/
`add_test_module`/`add_example_module` gets its own
[Doxygen](https://www.doxygen.nl/) documentation target automatically --
nothing to add to a module's own `CMakeLists.txt`, and no Doxygen install
required on your machine. This project downloads and manages its own
pinned Doxygen (currently **1.16.1**, see `DOXYGEN_PINNED_VERSION` in
`cmake/Documentation.cmake`) the first time you configure with
`ENABLE_DOXYGEN` on (the default) -- it deliberately never looks for or
uses a system-installed `doxygen`, so every machine building this project
generates identical output regardless of what (if anything) is on `PATH`.
The download is cached under `.cache/tools/` (`TOOLCACHE_DIR`, see
"Output paths" above), so it only happens once per machine -- a clean
`rm -rf out/` does not force a re-download.
```bash
scripts/docs.sh # build docs for every module, prints where to open it
scripts/docs.sh core_lib # build docs for just one module (docs_<target>)
```
```bat
scripts\docs.bat
scripts\docs.bat core_lib
```
Output goes to `out/docs/<module>/html/index.html` (a sibling of
`out/build/`, not nested inside it -- see "Output paths" above), plus a
landing page at `out/docs/index.html` linking to every module.
Documentation is **not** built as part of a normal `scripts/build.sh` --
it's opt-in, the same way tests and examples are, since generating it on
every compile would be wasteful. If the pinned Doxygen can't be downloaded
(no network access, an unsupported host platform, or you passed
`-DENABLE_DOXYGEN=OFF`), `scripts/docs.sh` prints a clear message instead
of failing -- everything else keeps building normally either way:
```
Documentation is disabled (ENABLE_DOXYGEN=OFF, or Doxygen isn't installed). Install Doxygen and reconfigure to enable it.
```
**Writing docs for a module** is just normal Doxygen comments in that
module's headers -- e.g. `source/Core/include/core/Greeting.hpp`:
```cpp
/// Builds a greeting for @p name.
///
/// @param name the person (or thing) being greeted.
/// @return a formatted greeting string, e.g. "Hello, Ale! (from demo_core)".
std::string makeGreeting(const std::string& name);
```
Even with zero comments, a module's docs are still generated and useful --
file lists, function/class signatures, and (if [Graphviz](https://graphviz.org/)
is installed on your system -- this one enhancement, unlike Doxygen
itself, is still detected rather than pinned, since it's optional) call
graphs are produced regardless, since `DOXYGEN_EXTRACT_ALL` is on by
default. Comments just make it richer. Drop a `README.md` in a module's
own directory and it's automatically used as that module's Doxygen main
page.
**Configuring it** -- nothing is required, but everything is overridable:
```bash
# Disable entirely, even though Doxygen would otherwise download fine:
cmake -S . -B out/build -DENABLE_DOXYGEN=OFF
# Pin a different Doxygen version (also update the two SHA256 hashes in
# cmake/Documentation.cmake to match that release's assets -- a stale
# hash fails the configure loudly, on purpose, rather than silently
# accepting an unexpected file):
cmake -S . -B out/build -DDOXYGEN_PINNED_VERSION=1.17.0
# Any Doxyfile tag can be set as a CMake variable, e.g. stricter output:
cmake -S . -B out/build -DDOXYGEN_WARN_IF_UNDOCUMENTED=YES -DDOXYGEN_QUIET=NO
```
A single module can opt out with `NO_DOCS`:
```cmake
# some/internal/module/CMakeLists.txt
add_lib_module(internal_only TYPE STATIC NO_DOCS)
```
Vendored and fetched dependencies (`cmake/Dependencies.cmake`) are never
auto-documented -- same reasoning as their `compile_commands.json`
exclusion, it isn't your code.
## compile_commands.json / clang tooling
`CMAKE_EXPORT_COMPILE_COMMANDS` is on by default, and every build copies
`compile_commands.json` from the build directory to the project root so
clangd/clang-tidy/IWYU find it without extra configuration (CMake only
ever writes it into the build directory; most editors look for it at the
repo root). Vendored and fetched dependencies are excluded from it, so it
only reflects code this project actually owns. The project also builds
cleanly with clang directly:
```bash
cmake -G Ninja -S . -B out/build -DCMAKE_CXX_COMPILER=clang++ -DDIST_TARGET=linux-x64-clang
cmake --build out/build
```
## Code style & tooling
This project follows [`cpp-style-guide.md`](cpp-style-guide.md) (tabs,
Allman braces, `PascalCase` file names, `camelCase` functions/variables,
`m_`/`s_`/`g_` member/static/global prefixes -- see the file itself for
the full rules and rationale), enforced by the tooling described in
[`TOOLING.md`](TOOLING.md):
| File | Enforces |
|---|---|
| `.clang-format` | All formatting: tabs, Allman braces, pointer alignment, include order, line length |
| `.clang-tidy` | Naming conventions and a subset of language-feature rules, for this project's own C++ code |
| `c-api/.clang-tidy` | A template naming override (`snake_case`) for a C API subtree, if/when this project has one -- see below |
| `.editorconfig` | Baseline tabs/whitespace for editors that don't run clang-format live |
| `.gitattributes` | Forces LF line endings for source files on every OS |
| `.githooks/pre-commit` | Runs clang-format + clang-tidy on staged files before each commit; warns and skips (never blocks) if a tool isn't installed |
**One-time setup per clone** to activate the hook:
```bash
chmod +x .githooks/pre-commit
git config core.hooksPath .githooks
```
**clang-tidy needs `compile_commands.json`** to resolve `#include`s --
this project already generates and copies it to the repo root on every
build (see above), so as long as you've built at least once, the hook's
clang-tidy check works with no extra setup.
Two adjustments made when integrating the toolkit into this specific
project (not part of the toolkit as provided -- see `TOOLING.md` in this
repo for the toolkit's own documentation of everything else):
- **`vendor/` is excluded** from the pre-commit hook's staged-file check
(`git diff ... ':!vendor/*'`). Vendored code (`cmake/Dependencies.cmake`)
isn't this project's code, so it isn't held to this style guide -- same
reasoning as its `compile_commands.json` and warning-flag exclusions
elsewhere in this project.
- **`.clang-tidy` gained one option**, `LocalConstantCase: camelBack`. The
style guide's own text says only *global/static* `const`/`constexpr`
values are "constants" for naming purposes -- everything else stays
under the `camelCase` Local Variables rule. Without this option, a plain
local `const` (e.g. `const std::string name = ...;` inside a function)
silently fell through to the general `PascalCase` constant rule instead,
which would have flagged completely ordinary code. Verified against a
small isolated test file: with the fix, a local `const` is accepted in
`camelCase` while a `static const`/`constexpr` still correctly requires
`PascalCase`.
`c-api/.clang-tidy` is included as a template, positioned exactly as the
toolkit ships it (`c-api/.clang-tidy`) -- this project doesn't currently
have a C API (Part II) surface. If you add one, move this file to that
subtree's root (`c-api/`, `public-include/`, wherever your `extern "C"`
headers live); `clang-tidy` always uses the closest `.clang-tidy` file up
the directory tree, so files under that directory automatically pick up
the C API's `snake_case` naming instead of this project's `camelCase`.
This project's own code was reformatted and renamed to comply when the
toolkit was integrated -- e.g. `Greeting.hpp`/`.cpp` (was `greeting.h`/
`.cpp`) and `makeGreeting`/`makeFarewell` (was `make_greeting`/
`make_farewell`) in `source/Core/`. `main.cpp` in each app module became
`Main.cpp` for the same reason (`PascalCase` file names) -- the style
guide doesn't call out an entry-point exception, so none was assumed;
rename back to lowercase if you'd rather treat that specific convention
as an exception.
## How module discovery works
The root `CMakeLists.txt` calls `_discover_module_subdirectories()` on
three roots, in this order:
| Root | What it holds |
|---|---|
| `source/*` | library AND app/executable modules, side by side |
| `source/tests/*` | test modules |
| `source/examples/*` | example modules |
(`source/tests` and `source/examples` aren't picked up by the first call
-- they have no `CMakeLists.txt` directly inside them, only their own
children do.)
Any immediate child directory that contains its own `CMakeLists.txt` gets
`add_subdirectory()`'d automatically (`vendor/` is not one of these roots
-- vendored dependencies are opted into explicitly by the modules that
need them, not auto-built by default). This uses `CONFIGURE_DEPENDS`, so
the *next build* reconfigures on its own after you add or remove a module
directory -- no manual `cmake` re-run needed. A module may `DEPENDS` on a
module discovered later in this list; order doesn't affect linking (CMake
resolves target names across the whole project at generate time --
verified, not assumed).
## Requirements
CMake >= 3.25, Ninja, a C++20 compiler (GCC/Clang/MSVC), `git` (for
FetchContent's `GIT_REPOSITORY`-based dependencies like `fmt`), and
network access the first time you configure (also needed for the pinned
Doxygen download -- both are cached afterward, see "Output paths" and
"Documentation" above). Nothing else to install: Doxygen is downloaded
and pinned automatically, not a system dependency.
[Graphviz](https://graphviz.org/) is optional for Doxygen call graphs, and
[clang-format](https://clang.llvm.org/docs/ClangFormat.html)/
[clang-tidy](https://clang.llvm.org/extra/clang-tidy/) are optional for
the pre-commit hook (see "Code style & tooling" above) -- both are
detected, not pinned, since they're editor/workflow tools rather than
part of the build itself.
## Build
```bash
scripts/build.sh # Release (default)
scripts/build.sh Debug # or Debug / RelWithDebInfo / MinSizeRel
```
```bat
scripts\build.bat
scripts\build.bat Debug
```
## Run
```bash
scripts/run.sh hello_exe
scripts/run.sh farewell_exe -- Ale
scripts/run.sh # no args: lists available targets
```
```bat
scripts\run.bat hello_exe
scripts\run.bat farewell_exe -- Ale
```
`run.sh`/`run.bat` resolve the CMake **target id** (not the binary's
`OUTPUT_NAME`) to the actual binary via a manifest CMake generates at
configure time, so these scripts never need editing when a module is
added, removed, or renamed.
## Install
```bash
scripts/install.sh
```
```bat
scripts\install.bat
```
Always use the install script (or pass `--component demo` yourself if
calling `cmake --install` directly). A vendored/fetched dependency's own
`CMakeLists.txt` usually has `install()` rules of its own with no
component tag; installing without `--component` would also run those,
typically dumping files under `CMAKE_INSTALL_PREFIX` (e.g. `/usr/local`).
Tagging this project's own `install(TARGETS ...)` calls with `COMPONENT
demo` and always filtering on it is what keeps `cmake --install` scoped to
just this project's own artifacts.
## Adding a module
1. Create a directory under the right root (see the discovery table above).
2. Add a `CMakeLists.txt` inside it with one `add_lib_module` /
`add_exe_module` / `add_test_module` / `add_example_module` call, plus
any `add_vendor_*`/`add_dependency` calls it needs.
3. Add your `.cpp`/`.h` files inside that same directory.
4. Rebuild (`scripts/build.sh`) -- no other file needs touching.
```cmake
# source/MyTool/CMakeLists.txt
add_exe_module(my_tool_exe OUTPUT_NAME "my-tool" DEPENDS core_lib)
```
`scripts/run.sh my_tool_exe` picks it up automatically once it's built,
and `scripts/docs.sh my_tool_exe` documents it automatically too -- both
with zero edits anywhere outside `source/MyTool/`.