From d38e1466dad034f21d3ece5fe31e2b3c1b624033 Mon Sep 17 00:00:00 2001 From: paulhorn Date: Thu, 17 Sep 2026 18:24:53 +0200 Subject: [PATCH] Add canon add module and canon add unit-test canon add module creates /.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// Co-Authored-By: Claude Opus 5 --- CMakeLists.txt | 5 +- README.md | 70 ++-- canon/add.cpp | 166 ++++++++ canon/add.hpp | 25 ++ canon/add.test.cpp | 193 +++++++++ canon/canon.cpp | 68 +++- canon/cli.cpp | 169 +++++--- canon/cli.hpp | 18 +- canon/cli.test.cpp | 31 ++ canon/cmake.cpp | 48 +++ canon/cmake.hpp | 7 + canon/cmake.test.cpp | 40 ++ canon/details/files.hpp | 22 + canon/executor.cpp | 94 ++++- canon/executor.hpp | 11 +- canon/executor.test.cpp | 53 +++ canon/generate.cpp | 2 +- canon/manifest.cpp | 312 +++++++++++++-- canon/manifest.hpp | 15 + canon/manifest.test.cpp | 124 +++++- canon/name.cpp | 29 +- canon/name.hpp | 10 +- canon/plan.cpp | 30 +- canon/plan.hpp | 18 +- canon/plan.test.cpp | 4 +- canon/project.cpp | 56 +++ canon/project.hpp | 24 ++ canon/project.test.cpp | 64 +++ canon/toml.cpp | 376 ++++++++++++++++++ canon/toml.hpp | 41 ++ canon/toml.test.cpp | 116 ++++++ tests/e2e/check.cmake | 21 +- tests/golden/add-exe/hello/.canon.toml | 29 ++ tests/golden/{ => add-exe}/hello/.gitignore | 0 tests/golden/add-exe/hello/CMakeLists.txt | 33 ++ tests/golden/{ => add-exe}/hello/README.md | 0 tests/golden/add-exe/hello/hello/greeting.cpp | 5 + tests/golden/add-exe/hello/hello/greeting.hpp | 5 + .../add-exe/hello/hello/greeting.test.cpp | 6 + .../{ => add-exe}/hello/hello/hello.cpp | 0 tests/golden/add-lib/libhello/.canon.toml | 39 ++ .../golden/{ => add-lib}/libhello/.gitignore | 0 tests/golden/add-lib/libhello/CMakeLists.txt | 45 +++ tests/golden/{ => add-lib}/libhello/README.md | 0 tests/golden/add-lib/libhello/hello/core.cpp | 5 + tests/golden/add-lib/libhello/hello/core.hpp | 5 + .../add-lib/libhello/hello/core.test.cpp | 6 + .../libhello/hello/details/utility.cpp | 5 + .../libhello/hello/details/utility.hpp | 5 + .../libhello/hello/details/utility.test.cpp | 6 + .../{ => add-lib}/libhello/hello/hello.cpp | 0 .../{ => add-lib}/libhello/hello/hello.hpp | 0 .../libhello/hello/hello.test.cpp | 0 .../libhello/tests/basics/driver.cpp | 0 tests/golden/check.cmake | 49 +-- tests/golden/{ => new-exe}/hello/.canon.toml | 0 tests/golden/new-exe/hello/.gitignore | 1 + .../golden/{ => new-exe}/hello/CMakeLists.txt | 0 tests/golden/new-exe/hello/README.md | 12 + tests/golden/new-exe/hello/hello/hello.cpp | 5 + .../golden/{ => new-lib}/libhello/.canon.toml | 0 tests/golden/new-lib/libhello/.gitignore | 1 + .../{ => new-lib}/libhello/CMakeLists.txt | 0 tests/golden/new-lib/libhello/README.md | 12 + tests/golden/new-lib/libhello/hello/hello.cpp | 11 + tests/golden/new-lib/libhello/hello/hello.hpp | 11 + .../new-lib/libhello/hello/hello.test.cpp | 13 + .../new-lib/libhello/tests/basics/driver.cpp | 12 + tests/scenarios.cmake | 72 ++++ 69 files changed, 2406 insertions(+), 249 deletions(-) create mode 100644 canon/add.cpp create mode 100644 canon/add.hpp create mode 100644 canon/add.test.cpp create mode 100644 canon/details/files.hpp create mode 100644 canon/project.cpp create mode 100644 canon/project.hpp create mode 100644 canon/project.test.cpp create mode 100644 canon/toml.cpp create mode 100644 canon/toml.hpp create mode 100644 canon/toml.test.cpp create mode 100644 tests/golden/add-exe/hello/.canon.toml rename tests/golden/{ => add-exe}/hello/.gitignore (100%) create mode 100644 tests/golden/add-exe/hello/CMakeLists.txt rename tests/golden/{ => add-exe}/hello/README.md (100%) create mode 100644 tests/golden/add-exe/hello/hello/greeting.cpp create mode 100644 tests/golden/add-exe/hello/hello/greeting.hpp create mode 100644 tests/golden/add-exe/hello/hello/greeting.test.cpp rename tests/golden/{ => add-exe}/hello/hello/hello.cpp (100%) create mode 100644 tests/golden/add-lib/libhello/.canon.toml rename tests/golden/{ => add-lib}/libhello/.gitignore (100%) create mode 100644 tests/golden/add-lib/libhello/CMakeLists.txt rename tests/golden/{ => add-lib}/libhello/README.md (100%) create mode 100644 tests/golden/add-lib/libhello/hello/core.cpp create mode 100644 tests/golden/add-lib/libhello/hello/core.hpp create mode 100644 tests/golden/add-lib/libhello/hello/core.test.cpp create mode 100644 tests/golden/add-lib/libhello/hello/details/utility.cpp create mode 100644 tests/golden/add-lib/libhello/hello/details/utility.hpp create mode 100644 tests/golden/add-lib/libhello/hello/details/utility.test.cpp rename tests/golden/{ => add-lib}/libhello/hello/hello.cpp (100%) rename tests/golden/{ => add-lib}/libhello/hello/hello.hpp (100%) rename tests/golden/{ => add-lib}/libhello/hello/hello.test.cpp (100%) rename tests/golden/{ => add-lib}/libhello/tests/basics/driver.cpp (100%) rename tests/golden/{ => new-exe}/hello/.canon.toml (100%) create mode 100644 tests/golden/new-exe/hello/.gitignore rename tests/golden/{ => new-exe}/hello/CMakeLists.txt (100%) create mode 100644 tests/golden/new-exe/hello/README.md create mode 100644 tests/golden/new-exe/hello/hello/hello.cpp rename tests/golden/{ => new-lib}/libhello/.canon.toml (100%) create mode 100644 tests/golden/new-lib/libhello/.gitignore rename tests/golden/{ => new-lib}/libhello/CMakeLists.txt (100%) create mode 100644 tests/golden/new-lib/libhello/README.md create mode 100644 tests/golden/new-lib/libhello/hello/hello.cpp create mode 100644 tests/golden/new-lib/libhello/hello/hello.hpp create mode 100644 tests/golden/new-lib/libhello/hello/hello.test.cpp create mode 100644 tests/golden/new-lib/libhello/tests/basics/driver.cpp create mode 100644 tests/scenarios.cmake diff --git a/CMakeLists.txt b/CMakeLists.txt index 8902d00..218397e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -12,6 +12,7 @@ option(BUILD_TESTING "Build canon's tests" ON) # Everything except main(), so unit tests can link against it. add_library(canon.core STATIC + canon/add.cpp canon/cli.cpp canon/cmake.cpp canon/executor.cpp @@ -19,7 +20,9 @@ add_library(canon.core STATIC canon/manifest.cpp canon/name.cpp canon/plan.cpp + canon/project.cpp canon/template.cpp + canon/toml.cpp ) target_include_directories(canon.core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) if(NOT MSVC) @@ -35,7 +38,7 @@ if(BUILD_TESTING) enable_testing() # Unit tests live next to the code they test: canon/.test.cpp. - foreach(unit cli cmake executor generate manifest name plan template) + foreach(unit add cli cmake executor generate manifest name plan project template toml) add_executable(canon.${unit}.test canon/${unit}.test.cpp) target_link_libraries(canon.${unit}.test PRIVATE canon.core) add_test(NAME canon/${unit}.test COMMAND canon.${unit}.test) diff --git a/README.md b/README.md index 7df73e8..0432196 100644 --- a/README.md +++ b/README.md @@ -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/.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//` 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 diff --git a/canon/add.cpp b/canon/add.cpp new file mode 100644 index 0000000..a1fd4cc --- /dev/null +++ b/canon/add.cpp @@ -0,0 +1,166 @@ +#include + +#include +#include + +#include +#include +#include +#include + +namespace canon { + +namespace { + +constexpr std::string_view module_header = R"cpp(#pragma once + +namespace {{namespace}} { + +} // namespace {{namespace}} +)cpp"; + +constexpr std::string_view module_source = R"cpp(#include <{{include}}> + +namespace {{namespace}} { + +} // namespace {{namespace}} +)cpp"; + +constexpr std::string_view module_unit_test = R"cpp(// Unit tests for <{{include}}>. Return non-zero from main() to fail. + +#include <{{include}}> + +int main() { +} +)cpp"; + +std::unexpected fail(std::string message) { + return std::unexpected(std::move(message)); +} + +struct module_names { + std::string path; // hello/details/utility: manifest entry, files without extension + std::string include; // hello/details/utility.hpp + std::string ns; // hello::details +}; + +std::expected resolve_module(const project_name& project, + std::string_view path) { + if (path.empty()) { + return fail("invalid module path: it is empty"); + } + if (path.ends_with(".hpp") || path.ends_with(".cpp")) { + return fail(std::format("give the module path without an extension, e.g. '{}'", + path.substr(0, path.size() - 4))); + } + + std::vector segments; + for (const auto segment : std::views::split(path, '/')) { + segments.emplace_back(segment.begin(), segment.end()); + } + + module_names names{.path = project.stem, .include = {}, .ns = project.ident}; + for (std::size_t i = 0; i < segments.size(); ++i) { + const std::string_view segment = segments[i]; + const bool directory = i + 1 < segments.size(); + + if (segment.empty()) { + return fail(std::format("invalid module path '{}': empty path segment", path)); + } + if (const std::string problem = name_problem(segment); !problem.empty()) { + return fail(std::format("invalid module path '{}': '{}': {}", path, segment, problem)); + } + if (directory) { + // Directories become namespaces: /details/x.hpp declares ::details. + if (segment.contains('-') || is_cxx_keyword(segment)) { + return fail(std::format( + "invalid module path '{}': directory '{}' must be a valid namespace name", path, + segment)); + } + names.ns += std::format("::{}", segment); + } + names.path += std::format("/{}", segment); + } + names.include = names.path + ".hpp"; + return names; +} + +template_vars module_vars(const module_names& names) { + return {{"include", names.include}, {"namespace", names.ns}}; +} + +// Records the updated manifest and the re-rendered managed block in the plan. +std::expected add_updates(plan& result, const project& p, const manifest& updated) { + auto cmakelists = replace_managed_block(p.cmakelists_text, updated); + if (!cmakelists) { + return fail(std::format("CMakeLists.txt: {}", cmakelists.error())); + } + result.update(std::string(manifest_file), p.manifest_text, to_toml(updated)); + result.update("CMakeLists.txt", p.cmakelists_text, std::move(*cmakelists)); + return {}; +} + +} // namespace + +std::expected plan_add_module(const project& p, std::string_view path, + bool unit_test) { + const auto names = resolve_module(p.declared.project, path); + if (!names) { + return std::unexpected(names.error()); + } + + manifest updated = p.declared; + if (std::ranges::contains(updated.modules, names->path, &source_module::path)) { + return fail(std::format("module '{}' already exists in {}", names->path, manifest_file)); + } + const auto owner = std::ranges::find(updated.targets, updated.project.name, &target::name); + if (owner == updated.targets.end()) { + return fail(std::format("{} declares no target named '{}' to add the module to", manifest_file, + updated.project.name)); + } + + const std::string source = names->path + ".cpp"; + if (!std::ranges::contains(owner->sources, source)) { + owner->sources.push_back(source); + } + updated.modules.push_back({.path = names->path, .target = owner->name, .unit_test = unit_test}); + + const template_vars vars = module_vars(*names); + plan result; + result.add_file(names->include, render(module_header, vars)); + result.add_file(source, render(module_source, vars)); + if (unit_test) { + result.add_file(names->path + ".test.cpp", render(module_unit_test, vars)); + } + if (auto ok = add_updates(result, p, updated); !ok) { + return std::unexpected(ok.error()); + } + return result; +} + +std::expected plan_add_unit_test(const project& p, std::string_view path) { + const auto names = resolve_module(p.declared.project, path); + if (!names) { + return std::unexpected(names.error()); + } + + manifest updated = p.declared; + const auto mod = std::ranges::find(updated.modules, names->path, &source_module::path); + if (mod == updated.modules.end()) { + return fail(std::format("no module '{}' in {}; create it with 'canon add module {}'", + names->path, manifest_file, path)); + } + if (mod->unit_test) { + return fail(std::format("module '{}' already has a unit test", names->path)); + } + mod->unit_test = true; + + plan result; + result.add_file(names->path + ".test.cpp", render(module_unit_test, module_vars(*names))); + if (auto ok = add_updates(result, p, updated); !ok) { + return std::unexpected(ok.error()); + } + return result; +} + +} // namespace canon diff --git a/canon/add.hpp b/canon/add.hpp new file mode 100644 index 0000000..46109ab --- /dev/null +++ b/canon/add.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include +#include + +#include +#include +#include + +namespace canon { + +// Plans `canon add module `. path is relative to the source directory, +// e.g. "core" or "details/utility" (directories become nested namespaces). The +// plan creates /.hpp and .cpp (and .test.cpp when unit_test is set), +// adds the source to the project's target, records the module in .canon.toml +// and re-renders the managed block of CMakeLists.txt. Plan paths are relative +// to the project root. +[[nodiscard]] std::expected plan_add_module(const project&, std::string_view path, + bool unit_test); + +// Plans `canon add unit-test ` for a declared module without a unit test. +[[nodiscard]] std::expected plan_add_unit_test(const project&, + std::string_view path); + +} // namespace canon diff --git a/canon/add.test.cpp b/canon/add.test.cpp new file mode 100644 index 0000000..a6d9a91 --- /dev/null +++ b/canon/add.test.cpp @@ -0,0 +1,193 @@ +#include + +#include +#include +#include + +#include +#include +#include + +using namespace canon; + +namespace { + +project make_project(project_kind kind, std::string_view name) { + const manifest m = new_project_manifest(*parse_project_name(kind, name)); + return project{ + .root = "/unused", + .declared = m, + .manifest_text = to_toml(m), + .cmakelists_text = render_cmakelists(m), + }; +} + +std::vector created_files(const plan& p) { + std::vector files; + for (const auto& op : p.operations) { + if (const auto* file = std::get_if(&op)) { + files.push_back(file->path.generic_string()); + } + } + return files; +} + +std::string created(const plan& p, std::string_view path) { + for (const auto& op : p.operations) { + if (const auto* file = std::get_if(&op); file && file->path == path) { + return file->content; + } + } + return ""; +} + +const update_file* updated(const plan& p, std::string_view path) { + for (const auto& op : p.operations) { + if (const auto* file = std::get_if(&op); file && file->path == path) { + return file; + } + } + return nullptr; +} + +// The manifest the plan would write, read back. +manifest planned_manifest(const plan& p) { + const update_file* file = updated(p, manifest_file); + CANON_CHECK(file != nullptr); + if (file == nullptr) { + return {}; + } + auto parsed = parse_manifest(file->content); + CANON_CHECK(parsed); + return parsed ? *parsed : manifest{}; +} + +std::string planned_cmakelists(const plan& p) { + const update_file* file = updated(p, "CMakeLists.txt"); + CANON_CHECK(file != nullptr); + return file != nullptr ? file->content : std::string{}; +} + +bool fails_with(const std::expected& result, std::string_view message) { + if (result) { + std::println(stderr, " planned successfully, expected: {}", message); + return false; + } + const bool matches = result.error().contains(message); + if (!matches) { + std::println(stderr, " error was: {}", result.error()); + } + return matches; +} + +} // namespace + +int main() { + const project lib = make_project(project_kind::library, "libhello"); + + // add module core + { + const auto result = plan_add_module(lib, "core", true); + CANON_CHECK(result); + if (result) { + CANON_CHECK(created_files(*result) == + std::vector({"hello/core.hpp", "hello/core.cpp", "hello/core.test.cpp"})); + CANON_CHECK_EQ(created(*result, "hello/core.hpp"), + "#pragma once\n\nnamespace hello {\n\n} // namespace hello\n"); + CANON_CHECK(created(*result, "hello/core.cpp").starts_with("#include \n")); + CANON_CHECK(created(*result, "hello/core.test.cpp").contains("#include \n")); + + // Updates are guarded by the texts the project was loaded with. + CANON_CHECK(updated(*result, manifest_file) && updated(*result, manifest_file)->expected == lib.manifest_text); + CANON_CHECK(updated(*result, "CMakeLists.txt") && + updated(*result, "CMakeLists.txt")->expected == lib.cmakelists_text); + + manifest expected = lib.declared; + expected.targets.at(0).sources.push_back("hello/core.cpp"); + expected.modules.push_back({.path = "hello/core", .target = "libhello", .unit_test = true}); + CANON_CHECK(planned_manifest(*result) == expected); + + const std::string cmake = planned_cmakelists(*result); + CANON_CHECK_EQ(cmake, render_cmakelists(expected)); + CANON_CHECK(cmake.contains("add_library(libhello\n hello/hello.cpp\n hello/core.cpp\n)\n")); + CANON_CHECK(cmake.contains("add_test(NAME hello/core.test COMMAND hello.core.test)")); + } + } + + // Nested paths become nested namespaces; --no-test skips the unit test. + { + const auto result = plan_add_module(lib, "details/utility", false); + CANON_CHECK(result); + if (result) { + CANON_CHECK(created_files(*result) == + std::vector({"hello/details/utility.hpp", "hello/details/utility.cpp"})); + CANON_CHECK(created(*result, "hello/details/utility.hpp").contains("namespace hello::details {")); + CANON_CHECK(created(*result, "hello/details/utility.cpp") + .starts_with("#include \n")); + CANON_CHECK(!planned_manifest(*result).modules.back().unit_test); + CANON_CHECK(!planned_cmakelists(*result).contains("utility.test")); + } + } + + // Hyphens are fine in file names, not in directory (namespace) names. + CANON_CHECK(plan_add_module(lib, "string-utils", true).has_value()); + CANON_CHECK(fails_with(plan_add_module(lib, "my-dir/x", true), "directory 'my-dir' must be a valid namespace")); + CANON_CHECK(fails_with(plan_add_module(lib, "new/x", true), "directory 'new' must be a valid namespace")); + CANON_CHECK(fails_with(plan_add_module(lib, "core.hpp", true), "without an extension, e.g. 'core'")); + CANON_CHECK(fails_with(plan_add_module(lib, "hello", true), "module 'hello/hello' already exists")); + for (const std::string_view bad : {"", "/core", "core/", "a//b", "../core", "Core", "2d", "a.b"}) { + CANON_CHECK(fails_with(plan_add_module(lib, bad, true), "invalid module path")); + } + + // User content around the managed block survives. + { + project custom = lib; + custom.cmakelists_text = "# mine, before\n" + lib.cmakelists_text + "\ninstall(TARGETS libhello)\n"; + const auto result = plan_add_module(custom, "core", true); + CANON_CHECK(result); + if (result) { + const std::string cmake = planned_cmakelists(*result); + CANON_CHECK(cmake.starts_with("# mine, before\n")); + CANON_CHECK(cmake.ends_with("\ninstall(TARGETS libhello)\n")); + } + + custom.cmakelists_text = "cmake_minimum_required(VERSION 3.20)\n"; + CANON_CHECK(fails_with(plan_add_module(custom, "core", true), "CMakeLists.txt: no '# >>> canon:managed >>>'")); + } + + // add unit-test + { + const auto without_test = plan_add_module(lib, "core", false); + CANON_CHECK(without_test); + project after = lib; + after.declared = planned_manifest(*without_test); + after.manifest_text = updated(*without_test, manifest_file)->content; + after.cmakelists_text = planned_cmakelists(*without_test); + + const auto result = plan_add_unit_test(after, "core"); + CANON_CHECK(result); + if (result) { + CANON_CHECK(created_files(*result) == std::vector({"hello/core.test.cpp"})); + CANON_CHECK(planned_manifest(*result).modules.back().unit_test); + CANON_CHECK(planned_cmakelists(*result).contains("add_test(NAME hello/core.test")); + } + + CANON_CHECK(fails_with(plan_add_unit_test(lib, "hello"), "module 'hello/hello' already has a unit test")); + CANON_CHECK(fails_with(plan_add_unit_test(lib, "nope"), "create it with 'canon add module nope'")); + } + + // Executables compile the module into the executable and into its unit test. + { + const project exe = make_project(project_kind::executable, "hello"); + const auto result = plan_add_module(exe, "greeting", true); + CANON_CHECK(result); + if (result) { + CANON_CHECK(planned_manifest(*result).targets.at(0).sources == + std::vector({"hello/hello.cpp", "hello/greeting.cpp"})); + CANON_CHECK(planned_cmakelists(*result).contains( + "add_executable(hello.greeting.test hello/greeting.test.cpp hello/greeting.cpp)")); + } + } + + return details::exit_status(); +} diff --git a/canon/canon.cpp b/canon/canon.cpp index 4e0c7c9..820888d 100644 --- a/canon/canon.cpp +++ b/canon/canon.cpp @@ -1,13 +1,16 @@ +#include #include #include #include #include #include #include +#include #include #include #include +#include #include #include #include @@ -56,6 +59,22 @@ std::string display_path(const fs::path& path) { return absolute; } +// Applies (or dry-runs) the plan and prints what changed. +bool apply_and_report(const canon::plan& plan, const fs::path& root, bool dry_run) { + const auto report = canon::apply(plan, root, {.dry_run = dry_run}); + if (!report) { + std::println(stderr, "canon: {}", report.error()); + return false; + } + for (const auto& line : *report) { + std::println("{}", line); + } + if (dry_run) { + std::println("\nDry run: nothing was written."); + } + return true; +} + int run_new(const canon::new_command& cmd) { const auto name = canon::parse_project_name(cmd.kind, cmd.name); if (!name) { @@ -76,24 +95,57 @@ int run_new(const canon::new_command& cmd) { return EXIT_FAILURE; } - const canon::plan plan = canon::generate_new_project(*name); - if (const auto applied = canon::apply(plan, cmd.directory, {.dry_run = cmd.dry_run}); !applied) { - std::println(stderr, "canon: {}", applied.error()); + if (!apply_and_report(canon::generate_new_project(*name), cmd.directory, cmd.dry_run)) { return EXIT_FAILURE; } - - const std::string_view kind = cmd.kind == canon::project_kind::library ? "library" : "executable"; - std::print("{}", canon::describe(plan)); if (cmd.dry_run) { - std::println("\nDry run: nothing was written."); return EXIT_SUCCESS; } + const std::string_view kind = cmd.kind == canon::project_kind::library ? "library" : "executable"; std::println("\nCreated {} {} in {}.\n\nNext:\n cd {}\n cmake -S . -B build\n" " cmake --build build\n ctest --test-dir build --output-on-failure", kind, name->name, display_path(destination), shell_quote(destination.string())); return EXIT_SUCCESS; } +// Shared by the add commands: load the project, plan, apply, report. +template +int run_add(const fs::path& directory, bool dry_run, Planner make_plan, std::string_view done) { + const auto project = canon::load_project(directory); + if (!project) { + std::println(stderr, "canon: {}", project.error()); + return EXIT_FAILURE; + } + const std::expected plan = make_plan(*project); + if (!plan) { + std::println(stderr, "canon: {}", plan.error()); + return EXIT_FAILURE; + } + + std::println("{} ({}):", project->declared.project.name, display_path(project->root)); + if (!apply_and_report(*plan, project->root, dry_run)) { + return EXIT_FAILURE; + } + if (!dry_run) { + std::println("\n{} Rebuild with: cmake --build build", done); + } + return EXIT_SUCCESS; +} + +int run_add_module(const canon::add_module_command& cmd) { + return run_add( + cmd.directory, cmd.dry_run, + [&](const canon::project& p) { return canon::plan_add_module(p, cmd.path, cmd.unit_test); }, + std::format("Added module {}.", cmd.path)); +} + +int run_add_unit_test(const canon::add_unit_test_command& cmd) { + return run_add( + cmd.directory, cmd.dry_run, + [&](const canon::project& p) { return canon::plan_add_unit_test(p, cmd.path); }, + std::format("Added a unit test for module {}.", cmd.path)); +} + } // namespace int main(int argc, char* argv[]) try { @@ -113,6 +165,8 @@ int main(int argc, char* argv[]) try { return EXIT_SUCCESS; }, [](const canon::new_command& cmd) { return run_new(cmd); }, + [](const canon::add_module_command& cmd) { return run_add_module(cmd); }, + [](const canon::add_unit_test_command& cmd) { return run_add_unit_test(cmd); }, }, *command); } catch (const std::exception& e) { diff --git a/canon/cli.cpp b/canon/cli.cpp index 1833e18..8c4782e 100644 --- a/canon/cli.cpp +++ b/canon/cli.cpp @@ -8,59 +8,26 @@ namespace canon { namespace { +struct options { + std::optional directory; + bool dry_run = false; + bool no_test = false; +}; + std::unexpected fail(std::string message) { return std::unexpected(std::move(message)); } -} // namespace +std::filesystem::path directory_or_current(const options& opts) { + return opts.directory ? std::filesystem::path(*opts.directory) : std::filesystem::path("."); +} -std::expected parse_command_line(std::span args) { - std::vector positional; - std::optional directory; - bool dry_run = false; - - for (std::size_t i = 0; i < args.size(); ++i) { - const std::string_view arg = args[i]; - if (arg == "-h" || arg == "--help") { - return help_command{}; - } - if (arg == "--version") { - return version_command{}; - } - if (arg == "--dry-run") { - dry_run = true; - } else if (arg == "--dir") { - if (i + 1 == args.size()) { - return fail("--dir needs a path"); - } - directory = args[++i]; - } else if (arg.starts_with("--dir=")) { - directory = arg.substr(std::string_view("--dir=").size()); - } else if (arg == "--") { - positional.insert(positional.end(), args.begin() + static_cast(i) + 1, - args.end()); - break; - } else if (arg.size() > 1 && arg.starts_with('-')) { - return fail(std::format("unknown option '{}'", arg)); - } else { - positional.push_back(arg); - } - } - - if (directory && directory->empty()) { - return fail("--dir needs a path"); - } - if (positional.empty()) { - return help_command{}; - } - if (positional[0] != "new") { - return fail(std::format("unknown command '{}'", positional[0])); - } +std::expected parse_new(std::span positional, + const options& opts) { if (positional.size() < 2) { return fail("'new' needs a kind: lib or exe"); } - - new_command cmd{.dry_run = dry_run}; + new_command cmd{.directory = directory_or_current(opts), .dry_run = opts.dry_run}; if (positional[1] == "lib") { cmd.kind = project_kind::library; } else if (positional[1] == "exe") { @@ -75,24 +42,116 @@ std::expected parse_command_line(std::span parse_add(std::span positional, + const options& opts) { + if (positional.size() < 2) { + return fail("'add' needs what to add: module or unit-test"); + } + const std::string_view what = positional[1]; + if (what != "module" && what != "unit-test") { + return fail(std::format("cannot add '{}' (expected module or unit-test)", what)); + } + if (positional.size() < 3) { + return fail(std::format("'add {}' needs a module path, e.g. 'core'", what)); + } + if (positional.size() > 3) { + return fail(std::format("unexpected argument '{}'", positional[3])); + } + if (what == "module") { + return add_module_command{ + .path = std::string(positional[2]), + .unit_test = !opts.no_test, + .directory = directory_or_current(opts), + .dry_run = opts.dry_run, + }; + } + return add_unit_test_command{ + .path = std::string(positional[2]), + .directory = directory_or_current(opts), + .dry_run = opts.dry_run, + }; +} + +} // namespace + +std::expected parse_command_line(std::span args) { + std::vector positional; + options opts; + + for (std::size_t i = 0; i < args.size(); ++i) { + const std::string_view arg = args[i]; + if (arg == "-h" || arg == "--help") { + return help_command{}; + } + if (arg == "--version") { + return version_command{}; + } + if (arg == "--dry-run") { + opts.dry_run = true; + } else if (arg == "--no-test") { + opts.no_test = true; + } else if (arg == "--dir") { + if (i + 1 == args.size()) { + return fail("--dir needs a path"); + } + opts.directory = args[++i]; + } else if (arg.starts_with("--dir=")) { + opts.directory = arg.substr(std::string_view("--dir=").size()); + } else if (arg == "--") { + positional.insert(positional.end(), args.begin() + static_cast(i) + 1, + args.end()); + break; + } else if (arg.size() > 1 && arg.starts_with('-')) { + return fail(std::format("unknown option '{}'", arg)); + } else { + positional.push_back(arg); + } + } + + if (opts.directory && opts.directory->empty()) { + return fail("--dir needs a path"); + } + if (positional.empty()) { + return help_command{}; + } + + const std::string_view verb = positional[0]; + const bool adding_module = verb == "add" && positional.size() > 1 && positional[1] == "module"; + if (opts.no_test && !adding_module) { + return fail("--no-test only applies to 'add module'"); + } + if (verb == "new") { + return parse_new(positional, opts); + } + if (verb == "add") { + return parse_add(positional, opts); + } + return fail(std::format("unknown command '{}'", verb)); +} + std::string_view usage() { return R"(usage: canon [options] -Commands: - new lib Create a library project (the name must start with 'lib') - new exe Create an executable project +Create a project: + new lib Library project (the name must start with 'lib') + new exe Executable project + +Change the project you are in (any directory inside it works): + add module Add /.hpp, .cpp and .test.cpp, e.g. 'core' + or 'details/utility' (directories become namespaces) + add unit-test Add the .test.cpp for a module created with --no-test Options: - --dir Create the project inside (default: current directory) - --dry-run Show what would be created without writing anything - -h, --help Show this help - --version Show canon's version + --dir new: create the project inside + add: look for the project from + (default: current directory) + --dry-run Show what would change without writing anything + --no-test add module: skip the unit test + -h, --help Show this help + --version Show canon's version Projects follow P1204R0 (Canonical Project Structure): https://wg21.link/p1204r0 )"; diff --git a/canon/cli.hpp b/canon/cli.hpp index b9148c4..c9fef89 100644 --- a/canon/cli.hpp +++ b/canon/cli.hpp @@ -22,10 +22,24 @@ struct new_command { bool dry_run = false; }; -using command = std::variant; +struct add_module_command { + std::string path{}; // relative to the source directory, e.g. "core" + bool unit_test = true; + std::filesystem::path directory = "."; // where to start looking for .canon.toml + bool dry_run = false; +}; + +struct add_unit_test_command { + std::string path{}; + std::filesystem::path directory = "."; + bool dry_run = false; +}; + +using command = + std::variant; // Parses the arguments after the program name. Only syntax is checked here; -// project names are validated by parse_project_name. +// names and module paths are validated by the generators. [[nodiscard]] std::expected parse_command_line(std::span args); diff --git a/canon/cli.test.cpp b/canon/cli.test.cpp index a019eb8..27447ed 100644 --- a/canon/cli.test.cpp +++ b/canon/cli.test.cpp @@ -57,6 +57,37 @@ int main() { CANON_CHECK_EQ(std::get(*c).name, "-weird"); } + if (const auto c = parse({"add", "module", "details/utility"}); CANON_CHECK(c), c) { + const auto* a = std::get_if(&*c); + CANON_CHECK(a != nullptr); + if (a != nullptr) { + CANON_CHECK_EQ(a->path, "details/utility"); + CANON_CHECK(a->unit_test); + CANON_CHECK(!a->dry_run); + CANON_CHECK_EQ(a->directory.generic_string(), "."); + } + } + + if (const auto c = parse({"add", "module", "core", "--no-test", "--dry-run", "--dir", "proj"}); + CANON_CHECK(c), c) { + const auto& a = std::get(*c); + CANON_CHECK(!a.unit_test); + CANON_CHECK(a.dry_run); + CANON_CHECK_EQ(a.directory.generic_string(), "proj"); + } + + if (const auto c = parse({"add", "unit-test", "core"}); CANON_CHECK(c), c) { + const auto* a = std::get_if(&*c); + CANON_CHECK(a != nullptr && a->path == "core"); + } + + CANON_CHECK(fails_with(parse({"add"}), "needs what to add")); + CANON_CHECK(fails_with(parse({"add", "target", "x"}), "cannot add 'target'")); + CANON_CHECK(fails_with(parse({"add", "module"}), "'add module' needs a module path")); + CANON_CHECK(fails_with(parse({"add", "module", "a", "b"}), "unexpected argument 'b'")); + CANON_CHECK(fails_with(parse({"add", "unit-test", "core", "--no-test"}), "--no-test only applies")); + CANON_CHECK(fails_with(parse({"new", "lib", "libhello", "--no-test"}), "--no-test only applies")); + CANON_CHECK(fails_with(parse({"build"}), "unknown command 'build'")); CANON_CHECK(fails_with(parse({"new"}), "needs a kind")); CANON_CHECK(fails_with(parse({"new", "app", "x"}), "unknown project kind 'app'")); diff --git a/canon/cmake.cpp b/canon/cmake.cpp index 3e400fc..28cce54 100644 --- a/canon/cmake.cpp +++ b/canon/cmake.cpp @@ -2,6 +2,7 @@ #include #include +#include #include namespace canon { @@ -160,4 +161,51 @@ endif() m.cmake_minimum, m.project.name, m.cxx_standard, render_managed_block(m)); } +std::expected replace_managed_block(std::string_view text, + const manifest& m) { + std::optional begin; // offset where the begin-marker line starts + std::optional end; // offset just past the end-marker line + + for (std::size_t line_start = 0; line_start < text.size();) { + const std::size_t newline = text.find('\n', line_start); + const std::size_t line_end = newline == std::string_view::npos ? text.size() : newline; + const std::size_t next = newline == std::string_view::npos ? text.size() : newline + 1; + + std::string_view line = text.substr(line_start, line_end - line_start); + const auto first = line.find_first_not_of(" \t"); + const auto last = line.find_last_not_of(" \t\r"); + line = first == std::string_view::npos ? std::string_view{} : line.substr(first, last - first + 1); + + if (line == managed_begin) { + if (begin) { + return std::unexpected(std::format("'{}' appears more than once", managed_begin)); + } + begin = line_start; + } else if (line == managed_end) { + if (end) { + return std::unexpected(std::format("'{}' appears more than once", managed_end)); + } + if (!begin) { + return std::unexpected( + std::format("'{}' appears before '{}'", managed_end, managed_begin)); + } + end = next; + } + line_start = next; + } + + if (!begin) { + return std::unexpected(std::format( + "no '{}' line found; canon only edits the block between its markers", managed_begin)); + } + if (!end) { + return std::unexpected(std::format("no '{}' line found after '{}'", managed_end, managed_begin)); + } + + std::string out(text.substr(0, *begin)); + out += render_managed_block(m); + out += text.substr(*end); + return out; +} + } // namespace canon diff --git a/canon/cmake.hpp b/canon/cmake.hpp index a82072b..81415ae 100644 --- a/canon/cmake.hpp +++ b/canon/cmake.hpp @@ -2,6 +2,7 @@ #include +#include #include #include @@ -21,4 +22,10 @@ inline constexpr std::string_view managed_end = "# <<< canon:managed <<<"; // by the managed block. [[nodiscard]] std::string render_cmakelists(const manifest&); +// An existing CMakeLists.txt with its managed block replaced by one rendered +// from the manifest; everything outside the markers is kept byte for byte. +// Fails if the markers are missing, repeated or out of order. +[[nodiscard]] std::expected replace_managed_block(std::string_view cmakelists, + const manifest&); + } // namespace canon diff --git a/canon/cmake.test.cpp b/canon/cmake.test.cpp index 590eda9..82362de 100644 --- a/canon/cmake.test.cpp +++ b/canon/cmake.test.cpp @@ -86,6 +86,46 @@ int main() { CANON_CHECK(threw); } + // Replacing the managed block keeps everything around it and is idempotent. + { + const std::string original = render_cmakelists(library()); + const auto same = replace_managed_block(original, library()); + CANON_CHECK(same && *same == original); + + manifest more = library(); + more.targets[0].sources.push_back("hello/core.cpp"); + const std::string user_before = "# before\r\n"; + const std::string user_after = "\n# after, no trailing newline"; + std::string edited = user_before + original + user_after; + const auto replaced = replace_managed_block(edited, more); + CANON_CHECK(replaced); + if (replaced) { + CANON_CHECK(replaced->starts_with(user_before)); + CANON_CHECK(replaced->ends_with(render_managed_block(more) + user_after)); + CANON_CHECK(replaced->contains(" hello/core.cpp\n")); + const auto again = replace_managed_block(*replaced, more); + CANON_CHECK(again && *again == *replaced); + } + + // Hand edits inside the block are replaced; indented markers are still found. + const std::string block = " # >>> canon:managed >>> \nadd_library(junk)\n\t# <<< canon:managed <<<\r\n"; + const auto rebuilt = replace_managed_block("x\n" + block + "y\n", library()); + CANON_CHECK(rebuilt && *rebuilt == "x\n" + render_managed_block(library()) + "y\n"); + + const auto error_of = [](std::string_view text) { + const auto result = replace_managed_block(text, library()); + return result ? std::string("") : result.error(); + }; + const std::string begin = std::string(managed_begin) + "\n"; + const std::string end = std::string(managed_end) + "\n"; + CANON_CHECK(error_of("project(x)\n").contains("no '# >>> canon:managed >>>' line found")); + CANON_CHECK(error_of(begin).contains("no '# <<< canon:managed <<<' line found")); + CANON_CHECK(error_of(end + begin).contains("appears before")); + CANON_CHECK(error_of(begin + begin + end).contains("appears more than once")); + CANON_CHECK(error_of(begin + end + end).contains("appears more than once")); + CANON_CHECK(error_of("# >>> canon:managed >>> extra\n" + end).contains("appears before")); + } + { const std::string file = render_cmakelists(library()); CANON_CHECK(file.starts_with("cmake_minimum_required(VERSION 3.20)\n" diff --git a/canon/details/files.hpp b/canon/details/files.hpp new file mode 100644 index 0000000..8c650d6 --- /dev/null +++ b/canon/details/files.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace canon::details { + +// The whole file, or nullopt if it cannot be opened. +[[nodiscard]] inline std::optional read_file(const std::filesystem::path& path) { + std::ifstream in(path, std::ios::binary); + if (!in) { + return std::nullopt; + } + std::ostringstream content; + content << in.rdbuf(); + return std::move(content).str(); +} + +} // namespace canon::details diff --git a/canon/executor.cpp b/canon/executor.cpp index 8993f2a..6ddae11 100644 --- a/canon/executor.cpp +++ b/canon/executor.cpp @@ -1,5 +1,7 @@ #include +#include + #include #include @@ -9,32 +11,58 @@ namespace fs = std::filesystem; namespace { -using result = std::expected; +using checked = std::expected; // true if the operation changes something +using written = std::expected; std::string display(const fs::path& root, const fs::path& path) { return (root / path).lexically_normal().generic_string(); } -result check(const make_directory& op, const fs::path& root) { +checked check(const make_directory& op, const fs::path& root) { std::error_code ignored; const auto status = fs::symlink_status(root / op.path, ignored); if (fs::exists(status) && !fs::is_directory(status)) { return std::unexpected( std::format("{} already exists and is not a directory", display(root, op.path))); } - return {}; + return !fs::exists(status); } -result check(const create_file& op, const fs::path& root) { +checked check(const create_file& op, const fs::path& root) { std::error_code ignored; if (fs::exists(fs::symlink_status(root / op.path, ignored))) { return std::unexpected( std::format("{} already exists; canon never overwrites files", display(root, op.path))); } + return true; +} + +checked check(const update_file& op, const fs::path& root) { + const auto current = details::read_file(root / op.path); + if (!current) { + return std::unexpected(std::format("cannot read {}", display(root, op.path))); + } + if (*current != op.expected) { + return std::unexpected(std::format( + "{} changed after canon read it; nothing was written", display(root, op.path))); + } + return op.content != op.expected; +} + +written write_bytes(const fs::path& file, const std::string& content, const std::string& name) { + std::ofstream out(file, std::ios::out | std::ios::binary | std::ios::trunc); + if (!out) { + return std::unexpected(std::format("cannot open {} for writing", name)); + } + out.write(content.data(), static_cast(content.size())); + out.close(); + if (!out) { + return std::unexpected(std::format("cannot write {}", name)); + } return {}; } -result write(const make_directory& op, const fs::path& root) { +written write(const make_directory& op, const fs::path& root) { std::error_code error; fs::create_directories(root / op.path, error); if (error) { @@ -44,36 +72,60 @@ result write(const make_directory& op, const fs::path& root) { return {}; } -result write(const create_file& op, const fs::path& root) { - std::ofstream out(root / op.path, std::ios::out | std::ios::binary); - if (!out) { - return std::unexpected(std::format("cannot open {} for writing", display(root, op.path))); +written write(const create_file& op, const fs::path& root) { + return write_bytes(root / op.path, op.content, display(root, op.path)); +} + +// Writes a sibling temporary file and renames it over the original, so an +// interrupted write never leaves a half-written file behind. +written write(const update_file& op, const fs::path& root) { + const fs::path target = root / op.path; + fs::path temporary = target; + temporary += ".canon-tmp"; + + if (auto ok = write_bytes(temporary, op.content, display(root, op.path)); !ok) { + return ok; } - out.write(op.content.data(), static_cast(op.content.size())); - out.close(); - if (!out) { - return std::unexpected(std::format("cannot write {}", display(root, op.path))); + std::error_code error; + fs::rename(temporary, target, error); + if (error) { + std::error_code ignored; + fs::remove(temporary, ignored); + return std::unexpected( + std::format("cannot replace {}: {}", display(root, op.path), error.message())); } return {}; } } // namespace -result apply(const plan& p, const fs::path& root, apply_options options) { +std::expected, std::string> apply(const plan& p, const fs::path& root, + apply_options options) { + std::vector changes; for (const auto& op : p.operations) { - if (auto ok = std::visit([&](const auto& o) { return check(o, root); }, op); !ok) { - return ok; + const checked result = std::visit([&](const auto& o) { return check(o, root); }, op); + if (!result) { + return std::unexpected(result.error()); } + if (*result) { + changes.push_back(&op); + } + } + + std::vector report; + for (const operation* op : changes) { + report.push_back(describe(*op)); } if (options.dry_run) { - return {}; + return report; } - for (const auto& op : p.operations) { - if (auto ok = std::visit([&](const auto& o) { return write(o, root); }, op); !ok) { - return ok; + + for (const operation* op : changes) { + if (const written ok = std::visit([&](const auto& o) { return write(o, root); }, *op); !ok) { + return std::unexpected(ok.error()); } } - return {}; + return report; } } // namespace canon diff --git a/canon/executor.hpp b/canon/executor.hpp index 530752b..4ced7b0 100644 --- a/canon/executor.hpp +++ b/canon/executor.hpp @@ -5,6 +5,7 @@ #include #include #include +#include namespace canon { @@ -12,9 +13,11 @@ struct apply_options { bool dry_run = false; // check the plan against the filesystem, write nothing }; -// Applies the plan inside root. Every operation is checked before anything is -// written, so a conflict (such as an existing file) leaves the filesystem untouched. -[[nodiscard]] std::expected apply(const plan&, const std::filesystem::path& root, - apply_options = {}); +// Applies the plan inside root and returns one describe() line per operation +// that changes something; directories that already exist and updates that +// change nothing are left out. Every operation is checked before anything is +// written, so a conflict leaves the filesystem untouched. +[[nodiscard]] std::expected, std::string> +apply(const plan&, const std::filesystem::path& root, apply_options = {}); } // namespace canon diff --git a/canon/executor.test.cpp b/canon/executor.test.cpp index 3c57802..f1cb824 100644 --- a/canon/executor.test.cpp +++ b/canon/executor.test.cpp @@ -5,6 +5,8 @@ #include #include #include +#include +#include using namespace canon; namespace fs = std::filesystem; @@ -61,6 +63,57 @@ int main() { CANON_CHECK(!apply(p, tmp.path(), {.dry_run = true})); } + { + // The report lists what changes: existing directories are left out. + const details::temp_directory tmp; + fs::create_directories(tmp.path() / "proj"); + const auto report = apply(p, tmp.path(), {.dry_run = true}); + CANON_CHECK(report); + if (report) { + CANON_CHECK(*report == std::vector({" mkdir proj/src/", " create proj/src/main.cpp", + " create proj/README.md"})); + } + } + + { + const details::temp_directory tmp; + std::ofstream(tmp.path() / "CMakeLists.txt") << "old\n"; + + plan update; + update.update("CMakeLists.txt", "old\n", "new\n"); + update.add_file("added.txt", "x"); + + const auto dry = apply(update, tmp.path(), {.dry_run = true}); + CANON_CHECK(dry && dry->front() == " update CMakeLists.txt"); + CANON_CHECK_EQ(read(tmp.path() / "CMakeLists.txt"), "old\n"); + + CANON_CHECK(apply(update, tmp.path()).has_value()); + CANON_CHECK_EQ(read(tmp.path() / "CMakeLists.txt"), "new\n"); + CANON_CHECK(!fs::exists(tmp.path() / "CMakeLists.txt.canon-tmp")); + + // The file no longer holds what the plan expects: refuse, write nothing. + plan stale; + stale.update("CMakeLists.txt", "old\n", "newer\n"); + stale.add_file("other.txt", "x"); + const auto result = apply(stale, tmp.path()); + CANON_CHECK(!result); + if (!result) { + CANON_CHECK(result.error().contains("CMakeLists.txt changed after canon read it")); + } + CANON_CHECK_EQ(read(tmp.path() / "CMakeLists.txt"), "new\n"); + CANON_CHECK(!fs::exists(tmp.path() / "other.txt")); + + // An update that changes nothing is not reported. + plan unchanged; + unchanged.update("CMakeLists.txt", "new\n", "new\n"); + const auto quiet = apply(unchanged, tmp.path()); + CANON_CHECK(quiet && quiet->empty()); + + plan missing; + missing.update("gone.txt", "", "x"); + CANON_CHECK(!apply(missing, tmp.path())); + } + { // A file where the plan needs a directory is a conflict too. const details::temp_directory tmp; diff --git a/canon/generate.cpp b/canon/generate.cpp index 3f81023..84cfa4a 100644 --- a/canon/generate.cpp +++ b/canon/generate.cpp @@ -129,7 +129,7 @@ plan generate_new_project(const project_name& n) { const std::filesystem::path sources = root / n.stem; plan p; - p.add_file(root / ".canon.toml", to_toml(m)); + p.add_file(root / manifest_file, to_toml(m)); p.add_file(root / ".gitignore", std::string(gitignore)); p.add_file(root / "CMakeLists.txt", render_cmakelists(m)); p.add_file(root / "README.md", render(readme, vars)); diff --git a/canon/manifest.cpp b/canon/manifest.cpp index 0e5108d..5a04658 100644 --- a/canon/manifest.cpp +++ b/canon/manifest.cpp @@ -1,43 +1,137 @@ #include +#include + +#include #include +#include +#include namespace canon { namespace { -std::string quote(std::string_view s) { - std::string out = "\""; - for (const char c : s) { - switch (c) { - case '"': out += "\\\""; break; - case '\\': out += "\\\\"; break; - case '\n': out += "\\n"; break; - case '\r': out += "\\r"; break; - case '\t': out += "\\t"; break; - default: - if (const auto byte = static_cast(c); byte < 0x20 || byte == 0x7f) { - out += std::format("\\u{:04X}", static_cast(byte)); - } else { - out += c; +struct manifest_error { + int line; + std::string message; +}; + +constexpr std::string_view description(std::type_identity) { return "a string"; } +constexpr std::string_view description(std::type_identity) { return "an integer"; } +constexpr std::string_view description(std::type_identity) { return "true or false"; } +constexpr std::string_view description(std::type_identity>) { + return "an array of strings"; +} + +// Typed access to one table's keys that remembers which keys were read. +class table_reader { +public: + explicit table_reader(const toml_table& table) + : table_(table), used_(table.entries.size(), false) {} + + [[nodiscard]] std::string header() const { + return table_.array_element ? std::format("[[{}]]", table_.name) + : std::format("[{}]", table_.name); + } + + template + [[nodiscard]] std::optional optional(std::string_view key) { + const toml_entry* entry = find(key); + if (entry == nullptr) { + return std::nullopt; + } + if (const T* value = std::get_if(&entry->value)) { + return *value; + } + throw manifest_error{entry->line, std::format("'{}' in {} must be {}", key, header(), + description(std::type_identity{}))}; + } + + template + [[nodiscard]] T required(std::string_view key) { + if (auto value = optional(key)) { + return std::move(*value); + } + throw manifest_error{table_.line, std::format("{} is missing '{}'", header(), key)}; + } + + // The key's line, or the table header's line if the key is absent. + [[nodiscard]] int line_of(std::string_view key) const { + const auto found = std::ranges::find(table_.entries, key, &toml_entry::key); + return found == table_.entries.end() ? table_.line : found->line; + } + + // Keys canon does not know are almost always typos, so they are errors. + void finish() const { + for (std::size_t i = 0; i < used_.size(); ++i) { + if (!used_[i]) { + const toml_entry& entry = table_.entries[i]; + throw manifest_error{entry.line, + std::format("unknown key '{}' in {}", entry.key, header())}; } } } - out += '"'; - return out; + +private: + const toml_entry* find(std::string_view key) { + const auto found = std::ranges::find(table_.entries, key, &toml_entry::key); + if (found == table_.entries.end()) { + return nullptr; + } + used_[static_cast(found - table_.entries.begin())] = true; + return &*found; + } + + const toml_table& table_; + std::vector used_; +}; + +project_kind read_project_kind(table_reader& r) { + const auto word = r.required("kind"); + if (word == "lib") { + return project_kind::library; + } + if (word == "exe") { + return project_kind::executable; + } + throw manifest_error{r.line_of("kind"), "'kind' in [project] must be \"lib\" or \"exe\""}; } -std::string array(const std::vector& items) { - std::string out = "["; - for (bool first = true; const auto& item : items) { - if (!first) { - out += ", "; - } - out += quote(item); - first = false; +target_type read_target_type(table_reader& r) { + const auto word = r.required("type"); + if (word == "library") { + return target_type::library; + } + if (word == "executable") { + return target_type::executable; + } + throw manifest_error{r.line_of("type"), + "'type' in [[targets]] must be \"library\" or \"executable\""}; +} + +test_kind read_test_kind(table_reader& r) { + const auto word = r.required("kind"); + if (word == "driver") { + return test_kind::driver; + } + if (word == "run") { + return test_kind::run; + } + throw manifest_error{r.line_of("kind"), "'kind' in [[tests]] must be \"driver\" or \"run\""}; +} + +// Throws for the first element whose key repeats an earlier one. +template +void check_unique(const std::vector& items, Key T::*key, const std::vector& lines, + std::string_view what) { + for (std::size_t i = 0; i < items.size(); ++i) { + for (std::size_t j = 0; j < i; ++j) { + if (items[i].*key == items[j].*key) { + throw manifest_error{lines[i], + std::format("{} '{}' is declared twice", what, items[i].*key)}; + } + } } - out += ']'; - return out; } } // namespace @@ -60,47 +154,179 @@ std::string to_toml(const manifest& m) { "# The managed block in CMakeLists.txt is rendered from this file.\n"; out += "\n[project]\n"; - line("name", quote(m.project.name)); - line("kind", quote(to_string(m.project.kind))); - line("stem", quote(m.project.stem)); - line("spec", quote(m.spec)); + line("name", toml_quote(m.project.name)); + line("kind", toml_quote(to_string(m.project.kind))); + line("stem", toml_quote(m.project.stem)); + line("spec", toml_quote(m.spec)); out += "\n[build]\n"; - line("backend", quote(m.backend)); - line("cmake-minimum", quote(m.cmake_minimum)); + line("backend", toml_quote(m.backend)); + line("cmake-minimum", toml_quote(m.cmake_minimum)); line("cxx-standard", std::to_string(m.cxx_standard)); for (const auto& t : m.targets) { out += "\n[[targets]]\n"; - line("name", quote(t.name)); - line("type", quote(to_string(t.type))); - line("sources", array(t.sources)); + line("name", toml_quote(t.name)); + line("type", toml_quote(to_string(t.type))); + line("sources", toml_array(t.sources)); if (!t.depends.empty()) { - line("depends", array(t.depends)); + line("depends", toml_array(t.depends)); } } for (const auto& mod : m.modules) { out += "\n[[modules]]\n"; - line("path", quote(mod.path)); - line("target", quote(mod.target)); + line("path", toml_quote(mod.path)); + line("target", toml_quote(mod.target)); line("unit-test", mod.unit_test ? "true" : "false"); } for (const auto& t : m.tests) { out += "\n[[tests]]\n"; - line("name", quote(t.name)); - line("kind", quote(to_string(t.kind))); + line("name", toml_quote(t.name)); + line("kind", toml_quote(to_string(t.kind))); if (!t.path.empty()) { - line("path", quote(t.path)); + line("path", toml_quote(t.path)); } - line("target", quote(t.target)); + line("target", toml_quote(t.target)); if (!t.expect.empty()) { - line("expect", quote(t.expect)); + line("expect", toml_quote(t.expect)); } } return out; } +std::expected parse_manifest(std::string_view text) { + const auto tables = parse_toml(text); + if (!tables) { + return std::unexpected(tables.error()); + } + + try { + manifest m; + bool have_project = false; + std::vector target_lines; + std::vector module_lines; + std::vector test_lines; + + for (const toml_table& table : *tables) { + table_reader r(table); + + if (table.name.empty()) { + if (!table.entries.empty()) { + const toml_entry& first = table.entries.front(); + throw manifest_error{first.line, + std::format("'{}' must be inside a table such as [project]", + first.key)}; + } + } else if (!table.array_element && table.name == "project") { + const auto name = r.required("name"); + const project_kind kind = read_project_kind(r); + const auto stem = r.optional("stem"); + if (auto spec = r.optional("spec")) { + m.spec = std::move(*spec); + } + r.finish(); + + auto parsed = parse_project_name(kind, name); + if (!parsed) { + throw manifest_error{r.line_of("name"), parsed.error()}; + } + if (stem && *stem != parsed->stem) { + throw manifest_error{r.line_of("stem"), + std::format("stem '{}' does not match name '{}' (expected '{}')", + *stem, name, parsed->stem)}; + } + m.project = std::move(*parsed); + have_project = true; + } else if (!table.array_element && table.name == "build") { + if (auto backend = r.optional("backend")) { + if (*backend != "cmake") { + throw manifest_error{r.line_of("backend"), + std::format("backend '{}' is not supported (only \"cmake\")", + *backend)}; + } + m.backend = std::move(*backend); + } + if (auto minimum = r.optional("cmake-minimum")) { + m.cmake_minimum = std::move(*minimum); + } + if (auto standard = r.optional("cxx-standard")) { + if (*standard < 11 || *standard > 99) { + throw manifest_error{r.line_of("cxx-standard"), + "'cxx-standard' must be a C++ standard such as 23"}; + } + m.cxx_standard = static_cast(*standard); + } + r.finish(); + } else if (table.array_element && table.name == "targets") { + target t{ + .name = r.required("name"), + .type = read_target_type(r), + .sources = r.optional>("sources").value_or( + std::vector{}), + .depends = r.optional>("depends").value_or( + std::vector{}), + }; + r.finish(); + m.targets.push_back(std::move(t)); + target_lines.push_back(table.line); + } else if (table.array_element && table.name == "modules") { + source_module mod{ + .path = r.required("path"), + .target = r.required("target"), + .unit_test = r.optional("unit-test").value_or(false), + }; + r.finish(); + m.modules.push_back(std::move(mod)); + module_lines.push_back(table.line); + } else if (table.array_element && table.name == "tests") { + test_entry t{ + .name = r.required("name"), + .kind = read_test_kind(r), + .path = r.optional("path").value_or(""), + .target = r.required("target"), + .expect = r.optional("expect").value_or(""), + }; + r.finish(); + if (t.kind == test_kind::driver && t.path.empty()) { + throw manifest_error{table.line, + std::format("test '{}' has kind \"driver\" but no 'path'", t.name)}; + } + m.tests.push_back(std::move(t)); + test_lines.push_back(table.line); + } else { + throw manifest_error{table.line, std::format("unknown table {}", r.header())}; + } + } + + if (!have_project) { + throw manifest_error{1, "missing [project] table"}; + } + + check_unique(m.targets, &target::name, target_lines, "target"); + check_unique(m.modules, &source_module::path, module_lines, "module"); + check_unique(m.tests, &test_entry::name, test_lines, "test"); + + const auto check_target = [&](std::string_view name, int line, std::string_view user) { + if (!std::ranges::contains(m.targets, name, &target::name)) { + throw manifest_error{line, + std::format("{} refers to unknown target '{}'", user, name)}; + } + }; + for (std::size_t i = 0; i < m.modules.size(); ++i) { + check_target(m.modules[i].target, module_lines[i], + std::format("module '{}'", m.modules[i].path)); + } + for (std::size_t i = 0; i < m.tests.size(); ++i) { + check_target(m.tests[i].target, test_lines[i], std::format("test '{}'", m.tests[i].name)); + } + + return m; + } catch (const manifest_error& e) { + return std::unexpected(std::format("line {}: {}", e.line, e.message)); + } +} + } // namespace canon diff --git a/canon/manifest.hpp b/canon/manifest.hpp index 99d56dd..282158e 100644 --- a/canon/manifest.hpp +++ b/canon/manifest.hpp @@ -2,12 +2,15 @@ #include +#include #include #include #include namespace canon { +inline constexpr std::string_view manifest_file = ".canon.toml"; + enum class target_type { library, executable }; enum class test_kind { @@ -23,6 +26,8 @@ struct target { target_type type{}; std::vector sources{}; std::vector depends{}; + + bool operator==(const target&) const = default; }; // A related file set: .hpp, .cpp and optionally .test.cpp. @@ -30,6 +35,8 @@ struct source_module { std::string path{}; // hello/hello std::string target{}; // the target whose sources include .cpp bool unit_test = false; + + bool operator==(const source_module&) const = default; }; struct test_entry { @@ -38,6 +45,8 @@ struct test_entry { std::string path{}; // driver: directory holding driver.cpp std::string target{}; // driver: library to link; run: executable to run std::string expect{}; // regex the output must match; empty checks the exit status only + + bool operator==(const test_entry&) const = default; }; // What canon declared for a project. It records intent, not the state of the @@ -51,9 +60,15 @@ struct manifest { std::vector targets{}; std::vector modules{}; std::vector tests{}; + + bool operator==(const manifest&) const = default; }; // The contents of .canon.toml. [[nodiscard]] std::string to_toml(const manifest&); +// Reads .canon.toml. Unknown tables or keys, wrong value types, invalid names +// and references to undeclared targets are errors of the form "line N: ...". +[[nodiscard]] std::expected parse_manifest(std::string_view); + } // namespace canon diff --git a/canon/manifest.test.cpp b/canon/manifest.test.cpp index 146c1c7..c5dff3e 100644 --- a/canon/manifest.test.cpp +++ b/canon/manifest.test.cpp @@ -1,10 +1,16 @@ #include #include +#include + +#include +#include using namespace canon; -int main() { +namespace { + +manifest library() { manifest m{.project = {project_kind::library, "libhello", "hello", "hello"}}; m.targets.push_back({.name = "libhello", .type = target_type::library, @@ -12,24 +18,110 @@ int main() { m.modules.push_back({.path = "hello/hello", .target = "libhello", .unit_test = true}); m.tests.push_back( {.name = "basics", .kind = test_kind::driver, .path = "tests/basics", .target = "libhello"}); + return m; +} - const std::string toml = to_toml(m); - CANON_CHECK(toml.contains("[project]\nname = \"libhello\"\nkind = \"lib\"\nstem = \"hello\"\n" - "spec = \"P1204R0\"\n")); - CANON_CHECK(toml.contains("[build]\nbackend = \"cmake\"\ncmake-minimum = \"3.20\"\n" - "cxx-standard = 23\n")); - CANON_CHECK(toml.contains("[[targets]]\nname = \"libhello\"\ntype = \"library\"\n" - "sources = [\"hello/hello.cpp\", \"hello/extra.cpp\"]\n")); - CANON_CHECK(!toml.contains("depends")); // empty lists are omitted - CANON_CHECK(toml.contains("[[modules]]\npath = \"hello/hello\"\ntarget = \"libhello\"\n" - "unit-test = true\n")); - CANON_CHECK(toml.contains("[[tests]]\nname = \"basics\"\nkind = \"driver\"\n" - "path = \"tests/basics\"\ntarget = \"libhello\"\n")); - CANON_CHECK(!toml.contains("expect")); +const std::string valid_header = "[project]\nname = \"libhello\"\nkind = \"lib\"\n"; + +bool fails_with(std::string_view text, std::string_view message) { + const auto result = parse_manifest(text); + if (result) { + std::println(stderr, " parsed successfully, expected: {}", message); + return false; + } + const bool matches = result.error().contains(message); + if (!matches) { + std::println(stderr, " error was: {}", result.error()); + } + return matches; +} + +} // namespace + +int main() { + { + const std::string toml = to_toml(library()); + CANON_CHECK(toml.contains("[project]\nname = \"libhello\"\nkind = \"lib\"\nstem = \"hello\"\n" + "spec = \"P1204R0\"\n")); + CANON_CHECK(toml.contains("[build]\nbackend = \"cmake\"\ncmake-minimum = \"3.20\"\n" + "cxx-standard = 23\n")); + CANON_CHECK(toml.contains("[[targets]]\nname = \"libhello\"\ntype = \"library\"\n" + "sources = [\"hello/hello.cpp\", \"hello/extra.cpp\"]\n")); + CANON_CHECK(!toml.contains("depends")); // empty lists are omitted + CANON_CHECK(toml.contains("[[modules]]\npath = \"hello/hello\"\ntarget = \"libhello\"\n" + "unit-test = true\n")); + CANON_CHECK(toml.contains("[[tests]]\nname = \"basics\"\nkind = \"driver\"\n" + "path = \"tests/basics\"\ntarget = \"libhello\"\n")); + CANON_CHECK(!toml.contains("expect")); + } // Strings are escaped as TOML basic strings. - manifest odd{.project = {project_kind::executable, "a\"b\\c\n\x01", "x", "x"}}; - CANON_CHECK(to_toml(odd).contains("name = \"a\\\"b\\\\c\\n\\u0001\"")); + { + manifest odd{.project = {project_kind::executable, "a\"b\\c\n\x01", "x", "x"}}; + CANON_CHECK(to_toml(odd).contains("name = \"a\\\"b\\\\c\\n\\u0001\"")); + } + + // Everything canon writes, it reads back unchanged. + { + manifest m = library(); + m.targets.push_back({.name = "tool", + .type = target_type::executable, + .sources = {"hello/tool.cpp"}, + .depends = {"libhello"}}); + m.tests.push_back({.name = "run", .kind = test_kind::run, .target = "tool", .expect = "Hi \"there\""}); + m.cxx_standard = 26; + const auto parsed = parse_manifest(to_toml(m)); + CANON_CHECK(parsed); + CANON_CHECK(parsed && *parsed == m); + + for (const project_kind kind : {project_kind::library, project_kind::executable}) { + const auto name = parse_project_name(kind, kind == project_kind::library ? "libfoo-bar" : "foo"); + const manifest fresh = new_project_manifest(*name); + const auto reread = parse_manifest(to_toml(fresh)); + CANON_CHECK(reread && *reread == fresh); + } + } + + // Optional keys fall back to defaults; comments and blank lines are fine. + { + const auto parsed = parse_manifest("# hand-written\n" + valid_header); + CANON_CHECK(parsed); + if (parsed) { + CANON_CHECK_EQ(parsed->project.stem, "hello"); + CANON_CHECK_EQ(parsed->cxx_standard, 23); + CANON_CHECK(parsed->targets.empty()); + } + } + + CANON_CHECK(fails_with("", "line 1: missing [project] table")); + CANON_CHECK(fails_with("name = \"x\"\n", "line 1: 'name' must be inside a table")); + CANON_CHECK(fails_with("[project]\nkind = \"lib\"\n", "line 1: [project] is missing 'name'")); + CANON_CHECK(fails_with(valid_header + "nmae = \"typo\"\n", "line 4: unknown key 'nmae' in [project]")); + CANON_CHECK(fails_with("[project]\nname = 42\n", "line 2: 'name' in [project] must be a string")); + CANON_CHECK(fails_with("[project]\nname = \"libhello\"\nkind = \"app\"\n", + "line 3: 'kind' in [project] must be \"lib\" or \"exe\"")); + CANON_CHECK(fails_with("[project]\nname = \"hello\"\nkind = \"lib\"\n", "must start with 'lib'")); + CANON_CHECK(fails_with(valid_header + "stem = \"other\"\n", "line 4: stem 'other' does not match")); + CANON_CHECK(fails_with(valid_header + "[build]\nbackend = \"meson\"\n", "line 5: backend 'meson'")); + CANON_CHECK(fails_with(valid_header + "[build]\ncxx-standard = \"23\"\n", "must be an integer")); + CANON_CHECK(fails_with(valid_header + "[build]\ncxx-standard = 3\n", "must be a C++ standard")); + CANON_CHECK(fails_with(valid_header + "[extras]\n", "line 4: unknown table [extras]")); + CANON_CHECK(fails_with(valid_header + "[targets]\n", "unknown table [targets]")); + CANON_CHECK(fails_with(valid_header + "[[targets]]\nname = \"libhello\"\ntype = \"shared\"\n", + "line 6: 'type' in [[targets]] must be")); + CANON_CHECK(fails_with(valid_header + "[[targets]]\nname = \"libhello\"\ntype = \"library\"\n" + "sources = \"hello.cpp\"\n", + "line 7: 'sources' in [[targets]] must be an array of strings")); + CANON_CHECK(fails_with(valid_header + "[[targets]]\nname = \"a\"\ntype = \"library\"\n" + "[[targets]]\nname = \"a\"\ntype = \"library\"\n", + "line 7: target 'a' is declared twice")); + CANON_CHECK(fails_with(valid_header + "[[modules]]\npath = \"hello/x\"\ntarget = \"nope\"\n", + "line 4: module 'hello/x' refers to unknown target 'nope'")); + CANON_CHECK(fails_with(valid_header + "[[modules]]\npath = \"hello/x\"\ntarget = \"t\"\nunit-test = \"yes\"\n", + "'unit-test' in [[modules]] must be true or false")); + CANON_CHECK(fails_with(valid_header + "[[tests]]\nname = \"b\"\nkind = \"driver\"\ntarget = \"t\"\n", + "test 'b' has kind \"driver\" but no 'path'")); + CANON_CHECK(fails_with("[project]\nname = \"libhello\n", "line 2: unterminated string")); return details::exit_status(); } diff --git a/canon/name.cpp b/canon/name.cpp index 21a50e4..a61ca33 100644 --- a/canon/name.cpp +++ b/canon/name.cpp @@ -31,16 +31,25 @@ bool is_separator(char c) { return c == '-' || c == '_'; } -// Why stem is unusable, or an empty string if it is fine. -std::string stem_problem(std::string_view stem) { - if (stem.empty()) { +} // namespace + +std::string_view to_string(project_kind kind) { + return kind == project_kind::library ? "lib" : "exe"; +} + +bool is_cxx_keyword(std::string_view word) { + return std::ranges::contains(cxx_keywords, word); +} + +std::string name_problem(std::string_view s) { + if (s.empty()) { return "the name is empty"; } - if (stem.front() < 'a' || stem.front() > 'z') { + if (s.front() < 'a' || s.front() > 'z') { return "it must start with a lowercase letter"; } char previous = '\0'; - for (char c : stem) { + for (char c : s) { const bool alnum = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9'); if (!alnum && !is_separator(c)) { return std::format("'{}' is not allowed (use a-z, 0-9, '-' or '_')", c); @@ -56,12 +65,6 @@ std::string stem_problem(std::string_view stem) { return {}; } -} // namespace - -std::string_view to_string(project_kind kind) { - return kind == project_kind::library ? "lib" : "exe"; -} - std::expected parse_project_name(project_kind kind, std::string_view name) { constexpr std::string_view prefix = "lib"; @@ -79,13 +82,13 @@ std::expected parse_project_name(project_kind kind, name)); } - if (const std::string problem = stem_problem(stem); !problem.empty()) { + if (const std::string problem = name_problem(stem); !problem.empty()) { return std::unexpected(std::format("invalid name '{}': {}", name, problem)); } std::string ident(stem); std::ranges::replace(ident, '-', '_'); - if (std::ranges::contains(cxx_keywords, std::string_view(ident))) { + if (is_cxx_keyword(ident)) { return std::unexpected(std::format( "invalid name '{}': '{}' is a C++ keyword and cannot be a namespace", name, ident)); } diff --git a/canon/name.hpp b/canon/name.hpp index 6d23819..995c63a 100644 --- a/canon/name.hpp +++ b/canon/name.hpp @@ -17,11 +17,19 @@ struct project_name { std::string name; // libhello, hello std::string stem; // hello: the source directory and include prefix, std::string ident; // hello: C++ namespace ('-' becomes '_') + + bool operator==(const project_name&) const = default; }; // Libraries must be named lib; executables must not start with "lib". -// Stems use a-z, 0-9, '-' and '_', start with a letter, and must not be a C++ keyword. +// Stems follow name_problem's rules and must not be a C++ keyword. [[nodiscard]] std::expected parse_project_name(project_kind, std::string_view name); +// Why s cannot be used as a name: it must use a-z, 0-9, '-' and '_', start with +// a letter, and not double or end with a separator. Empty if s is fine. +[[nodiscard]] std::string name_problem(std::string_view s); + +[[nodiscard]] bool is_cxx_keyword(std::string_view); + } // namespace canon diff --git a/canon/plan.cpp b/canon/plan.cpp index 6e57366..f20204f 100644 --- a/canon/plan.cpp +++ b/canon/plan.cpp @@ -27,18 +27,30 @@ void plan::add_file(std::filesystem::path file, std::string content) { operations.emplace_back(create_file{std::move(file), std::move(content)}); } +void plan::update(std::filesystem::path file, std::string expected, std::string content) { + operations.emplace_back(update_file{std::move(file), std::move(expected), std::move(content)}); +} + +std::string describe(const operation& op) { + return std::visit(details::overloaded{ + [](const make_directory& d) { + return std::format(" mkdir {}/", d.path.generic_string()); + }, + [](const create_file& f) { + return std::format(" create {}", f.path.generic_string()); + }, + [](const update_file& f) { + return std::format(" update {}", f.path.generic_string()); + }, + }, + op); +} + std::string describe(const plan& p) { std::string out; for (const auto& op : p.operations) { - std::visit(details::overloaded{ - [&](const make_directory& d) { - out += std::format(" mkdir {}/\n", d.path.generic_string()); - }, - [&](const create_file& f) { - out += std::format(" create {}\n", f.path.generic_string()); - }, - }, - op); + out += describe(op); + out += '\n'; } return out; } diff --git a/canon/plan.hpp b/canon/plan.hpp index 155e41c..f23fb97 100644 --- a/canon/plan.hpp +++ b/canon/plan.hpp @@ -7,6 +7,7 @@ namespace canon { +// Ensures a directory exists. struct make_directory { std::filesystem::path path; }; @@ -17,7 +18,15 @@ struct create_file { std::string content; }; -using operation = std::variant; +// Replaces a file's content. Applying fails unless the file still holds +// `expected`, so edits made after canon read the file are never lost. +struct update_file { + std::filesystem::path path; + std::string expected; + std::string content; +}; + +using operation = std::variant; // What a generator wants done, as plain data. Generators never touch the // filesystem; the executor applies a plan (or only checks it, for --dry-run). @@ -30,9 +39,14 @@ struct plan { // Adds the file, preceded by any parent directories not already in the plan. void add_file(std::filesystem::path, std::string content); + + void update(std::filesystem::path, std::string expected, std::string content); }; -// One line per operation, e.g. " create libhello/hello/hello.hpp". +// e.g. " create libhello/hello/hello.hpp" +[[nodiscard]] std::string describe(const operation&); + +// One line per operation. [[nodiscard]] std::string describe(const plan&); } // namespace canon diff --git a/canon/plan.test.cpp b/canon/plan.test.cpp index 1fa4b1f..4901a2f 100644 --- a/canon/plan.test.cpp +++ b/canon/plan.test.cpp @@ -9,13 +9,15 @@ int main() { p.add_file("proj/a/b/file.txt", "one"); p.add_file("proj/a/other.txt", "two"); p.add_directory("proj/a/b"); + p.update("proj/CMakeLists.txt", "old", "new"); // Parents come first and each directory appears once. CANON_CHECK_EQ(describe(p), " mkdir proj/\n" " mkdir proj/a/\n" " mkdir proj/a/b/\n" " create proj/a/b/file.txt\n" - " create proj/a/other.txt\n"); + " create proj/a/other.txt\n" + " update proj/CMakeLists.txt\n"); const auto* file = std::get_if(&p.operations.at(3)); CANON_CHECK(file != nullptr); diff --git a/canon/project.cpp b/canon/project.cpp new file mode 100644 index 0000000..3910326 --- /dev/null +++ b/canon/project.cpp @@ -0,0 +1,56 @@ +#include + +#include + +#include + +namespace canon { + +namespace fs = std::filesystem; + +std::expected load_project(const fs::path& start) { + std::error_code error; + fs::path directory = fs::absolute(start, error).lexically_normal(); + if (error || !fs::is_directory(directory, error)) { + return std::unexpected(std::format("{} is not a directory", start.string())); + } + if (!directory.has_filename()) { + directory = directory.parent_path(); // drop a trailing separator + } + + project p; + for (fs::path current = directory;; current = current.parent_path()) { + if (fs::is_regular_file(current / manifest_file, error)) { + p.root = current; + break; + } + if (current == current.parent_path()) { + return std::unexpected(std::format( + "no {} in {} or any parent directory; run this inside a project created by 'canon new'", + manifest_file, directory.string())); + } + } + + const fs::path manifest_path = p.root / manifest_file; + auto manifest_text = details::read_file(manifest_path); + if (!manifest_text) { + return std::unexpected(std::format("cannot read {}", manifest_path.string())); + } + auto declared = parse_manifest(*manifest_text); + if (!declared) { + return std::unexpected(std::format("{}: {}", manifest_path.string(), declared.error())); + } + + const fs::path cmakelists_path = p.root / "CMakeLists.txt"; + auto cmakelists_text = details::read_file(cmakelists_path); + if (!cmakelists_text || !fs::is_regular_file(cmakelists_path, error)) { + return std::unexpected(std::format("cannot read {}", cmakelists_path.string())); + } + + p.declared = std::move(*declared); + p.manifest_text = std::move(*manifest_text); + p.cmakelists_text = std::move(*cmakelists_text); + return p; +} + +} // namespace canon diff --git a/canon/project.hpp b/canon/project.hpp new file mode 100644 index 0000000..ef8dab7 --- /dev/null +++ b/canon/project.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include + +#include +#include +#include + +namespace canon { + +// An existing project as read from disk. Generators work on this snapshot +// instead of the filesystem; the texts let update_file detect later edits. +struct project { + std::filesystem::path root{}; // the directory holding .canon.toml + manifest declared{}; + std::string manifest_text{}; + std::string cmakelists_text{}; +}; + +// Loads the project from the nearest directory at or above start that +// contains .canon.toml, so commands work from any subdirectory. +[[nodiscard]] std::expected load_project(const std::filesystem::path& start); + +} // namespace canon diff --git a/canon/project.test.cpp b/canon/project.test.cpp new file mode 100644 index 0000000..4812a1b --- /dev/null +++ b/canon/project.test.cpp @@ -0,0 +1,64 @@ +#include + +#include +#include +#include + +#include +#include + +using namespace canon; +namespace fs = std::filesystem; + +int main() { + const details::temp_directory tmp; + const auto name = parse_project_name(project_kind::library, "libhello"); + CANON_CHECK(apply(generate_new_project(*name), tmp.path()).has_value()); + const fs::path root = (tmp.path() / "libhello").lexically_normal(); + + // Found from the root and from any directory inside the project. + for (const fs::path& start : {root, root / "hello", root / "tests" / "basics"}) { + const auto p = load_project(start); + CANON_CHECK(p); + if (p) { + CANON_CHECK_EQ(p->root.string(), root.string()); + CANON_CHECK(p->declared == new_project_manifest(*name)); + CANON_CHECK(p->manifest_text.starts_with("# canon project manifest")); + CANON_CHECK(p->cmakelists_text.starts_with("cmake_minimum_required")); + } + } + + { + const auto p = load_project(tmp.path()); + CANON_CHECK(!p); + if (!p) { + CANON_CHECK(p.error().contains("no .canon.toml in")); + } + } + + CANON_CHECK(!load_project(tmp.path() / "does-not-exist")); + + { + std::ofstream(root / ".canon.toml", std::ios::app) << "typo = 1\n"; + const auto p = load_project(root); + CANON_CHECK(!p); + if (!p) { + CANON_CHECK(p.error().contains(".canon.toml: line")); + CANON_CHECK(p.error().contains("unknown key 'typo'")); + } + } + + { + const auto fresh = generate_new_project(*name); + const details::temp_directory other; + CANON_CHECK(apply(fresh, other.path()).has_value()); + fs::remove(other.path() / "libhello" / "CMakeLists.txt"); + const auto p = load_project(other.path() / "libhello"); + CANON_CHECK(!p); + if (!p) { + CANON_CHECK(p.error().contains("cannot read")); + } + } + + return details::exit_status(); +} diff --git a/canon/toml.cpp b/canon/toml.cpp new file mode 100644 index 0000000..d4dea22 --- /dev/null +++ b/canon/toml.cpp @@ -0,0 +1,376 @@ +#include + +#include +#include +#include + +namespace canon { + +namespace { + +struct parse_error { + int line; + std::string message; +}; + +bool is_bare_key_char(char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || + c == '-'; +} + +bool is_digit(char c) { + return c >= '0' && c <= '9'; +} + +void append_utf8(std::string& out, char32_t cp) { + if (cp < 0x80) { + out += static_cast(cp); + } else if (cp < 0x800) { + out += static_cast(0xC0 | (cp >> 6)); + out += static_cast(0x80 | (cp & 0x3F)); + } else if (cp < 0x10000) { + out += static_cast(0xE0 | (cp >> 12)); + out += static_cast(0x80 | ((cp >> 6) & 0x3F)); + out += static_cast(0x80 | (cp & 0x3F)); + } else { + out += static_cast(0xF0 | (cp >> 18)); + out += static_cast(0x80 | ((cp >> 12) & 0x3F)); + out += static_cast(0x80 | ((cp >> 6) & 0x3F)); + out += static_cast(0x80 | (cp & 0x3F)); + } +} + +class parser { +public: + explicit parser(std::string_view text) : text_(text) {} + + std::vector parse() { + std::vector tables(1); + tables.front().line = 1; + while (skip_trivia(), !at_end()) { + if (peek() == '[') { + tables.push_back(header(tables)); + } else { + entry(tables.back()); + } + } + return tables; + } + +private: + std::string_view text_; + std::size_t pos_ = 0; + int line_ = 1; + + [[nodiscard]] bool at_end() const { return pos_ >= text_.size(); } + [[nodiscard]] char peek() const { return text_[pos_]; } + [[nodiscard]] std::string_view rest() const { return text_.substr(pos_); } + + void advance() { + if (text_[pos_] == '\n') { + ++line_; + } + ++pos_; + } + + [[noreturn]] void fail(std::string message) const { throw parse_error{line_, std::move(message)}; } + + void expect(char c) { + if (at_end() || peek() != c) { + fail(std::format("expected '{}'", c)); + } + advance(); + } + + void skip_spaces() { + while (!at_end() && (peek() == ' ' || peek() == '\t')) { + advance(); + } + } + + void skip_comment() { + if (!at_end() && peek() == '#') { + while (!at_end() && peek() != '\n') { + advance(); + } + } + } + + // Whitespace, comments and blank lines. + void skip_trivia() { + for (;;) { + skip_spaces(); + skip_comment(); + if (at_end() || (peek() != '\n' && peek() != '\r')) { + return; + } + advance(); + } + } + + void end_of_line() { + skip_spaces(); + skip_comment(); + if (at_end()) { + return; + } + if (peek() == '\r') { + advance(); + } + if (at_end() || peek() != '\n') { + fail("expected the end of the line"); + } + advance(); + } + + std::string bare_key() { + const std::size_t start = pos_; + while (!at_end() && is_bare_key_char(peek())) { + advance(); + } + if (pos_ == start) { + fail("expected a key (letters, digits, '_' or '-')"); + } + return std::string(text_.substr(start, pos_ - start)); + } + + toml_table header(const std::vector& tables) { + const int line = line_; + advance(); // [ + const bool array = !at_end() && peek() == '['; + if (array) { + advance(); + } + skip_spaces(); + std::string name = bare_key(); + skip_spaces(); + expect(']'); + if (array) { + expect(']'); + } + end_of_line(); + + for (const toml_table& existing : tables) { + if (existing.name != name) { + continue; + } + if (array != existing.array_element) { + throw parse_error{line, std::format("[{0}] and [[{0}]] cannot both be used", name)}; + } + if (!array) { + throw parse_error{line, std::format("table [{}] is defined twice", name)}; + } + } + return toml_table{.name = std::move(name), .array_element = array, .line = line}; + } + + void entry(toml_table& table) { + const int line = line_; + std::string key = bare_key(); + skip_spaces(); + expect('='); + skip_spaces(); + toml_value value = parse_value(); + end_of_line(); + + if (std::ranges::contains(table.entries, key, &toml_entry::key)) { + throw parse_error{line, std::format("key '{}' is defined twice", key)}; + } + table.entries.push_back({.key = std::move(key), .value = std::move(value), .line = line}); + } + + toml_value parse_value() { + if (at_end() || peek() == '\n' || peek() == '\r') { + fail("expected a value"); + } + const char c = peek(); + if (c == '"') { + return basic_string(); + } + if (c == '[') { + return string_array(); + } + if (c == 't' || c == 'f') { + return boolean(); + } + if (c == '-' || c == '+' || is_digit(c)) { + return integer(); + } + if (c == '\'') { + fail("literal strings ('...') are not supported; use \"...\""); + } + fail("unsupported value (canon reads strings, integers, booleans and arrays of strings)"); + } + + std::string basic_string() { + advance(); // opening quote + if (rest().starts_with("\"\"")) { + fail("multi-line strings are not supported"); + } + std::string out; + for (;;) { + if (at_end() || peek() == '\n') { + fail("unterminated string"); + } + const char c = peek(); + advance(); + if (c == '"') { + return out; + } + if (c != '\\') { + if (static_cast(c) < 0x20 && c != '\t') { + fail("control characters in strings must be escaped"); + } + out += c; + continue; + } + if (at_end()) { + fail("unterminated string"); + } + const char escape = peek(); + advance(); + switch (escape) { + case '"': out += '"'; break; + case '\\': out += '\\'; break; + case 'b': out += '\b'; break; + case 'f': out += '\f'; break; + case 'n': out += '\n'; break; + case 'r': out += '\r'; break; + case 't': out += '\t'; break; + case 'u': append_utf8(out, code_point(4)); break; + case 'U': append_utf8(out, code_point(8)); break; + default: fail(std::format("unknown escape sequence '\\{}'", escape)); + } + } + } + + char32_t code_point(int digits) { + char32_t value = 0; + for (int i = 0; i < digits; ++i) { + if (at_end()) { + fail("incomplete unicode escape"); + } + const char c = peek(); + int digit = 0; + if (is_digit(c)) { + digit = c - '0'; + } else if (c >= 'a' && c <= 'f') { + digit = c - 'a' + 10; + } else if (c >= 'A' && c <= 'F') { + digit = c - 'A' + 10; + } else { + fail("incomplete unicode escape"); + } + advance(); + value = value * 16 + static_cast(digit); + } + if (value > 0x10FFFF || (value >= 0xD800 && value <= 0xDFFF)) { + fail("unicode escape is not a valid code point"); + } + return value; + } + + std::vector string_array() { + advance(); // [ + std::vector items; + for (;;) { + skip_trivia(); + if (at_end()) { + fail("unterminated array"); + } + if (peek() == ']') { + advance(); + return items; + } + if (peek() != '"') { + fail("arrays may only contain strings"); + } + items.push_back(basic_string()); + skip_trivia(); + if (at_end()) { + fail("unterminated array"); + } + if (peek() == ',') { + advance(); + } else if (peek() != ']') { + fail("expected ',' or ']' in array"); + } + } + } + + bool boolean() { + for (const std::string_view word : {"true", "false"}) { + if (rest().starts_with(word)) { + pos_ += word.size(); + return word == "true"; + } + } + fail("unsupported value (canon reads strings, integers, booleans and arrays of strings)"); + } + + std::int64_t integer() { + const std::size_t start = pos_; + if (peek() == '+' || peek() == '-') { + advance(); + } + while (!at_end() && is_digit(peek())) { + advance(); + } + std::string_view token = text_.substr(start, pos_ - start); + if (token.starts_with('+')) { + token.remove_prefix(1); + } + std::int64_t value = 0; + const auto [end, error] = std::from_chars(token.data(), token.data() + token.size(), value); + if (error != std::errc{} || end != token.data() + token.size()) { + fail("invalid integer"); + } + return value; + } +}; + +} // namespace + +std::expected, std::string> parse_toml(std::string_view text) { + try { + return parser(text).parse(); + } catch (const parse_error& e) { + return std::unexpected(std::format("line {}: {}", e.line, e.message)); + } +} + +std::string toml_quote(std::string_view s) { + std::string out = "\""; + for (const char c : s) { + switch (c) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: + if (const auto byte = static_cast(c); byte < 0x20 || byte == 0x7f) { + out += std::format("\\u{:04X}", static_cast(byte)); + } else { + out += c; + } + } + } + out += '"'; + return out; +} + +std::string toml_array(const std::vector& items) { + std::string out = "["; + for (bool first = true; const auto& item : items) { + if (!first) { + out += ", "; + } + out += toml_quote(item); + first = false; + } + out += ']'; + return out; +} + +} // namespace canon diff --git a/canon/toml.hpp b/canon/toml.hpp new file mode 100644 index 0000000..21ee09c --- /dev/null +++ b/canon/toml.hpp @@ -0,0 +1,41 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace canon { + +// The subset of TOML that canon reads and writes: [tables], [[arrays of +// tables]], bare keys, and values that are basic strings, integers, booleans +// or arrays of strings (which may span lines). Anything else is rejected with +// a line number rather than half-understood. + +using toml_value = std::variant>; + +struct toml_entry { + std::string key{}; + toml_value value{}; + int line = 0; +}; + +struct toml_table { + std::string name{}; // empty for keys before the first header + bool array_element = false; // declared with [[name]] + int line = 0; + std::vector entries{}; +}; + +// Tables in file order. The first is always the unnamed root table. +[[nodiscard]] std::expected, std::string> parse_toml(std::string_view); + +// A TOML basic string: "..." with escapes. +[[nodiscard]] std::string toml_quote(std::string_view); + +// ["a", "b"] +[[nodiscard]] std::string toml_array(const std::vector&); + +} // namespace canon diff --git a/canon/toml.test.cpp b/canon/toml.test.cpp new file mode 100644 index 0000000..e26e112 --- /dev/null +++ b/canon/toml.test.cpp @@ -0,0 +1,116 @@ +#include + +#include + +#include +#include +#include + +using namespace canon; + +namespace { + +bool fails_with(std::string_view text, std::string_view message) { + const auto result = parse_toml(text); + if (result) { + return false; + } + const bool matches = result.error().contains(message); + if (!matches) { + std::println(stderr, " error was: {}", result.error()); + } + return matches; +} + +} // namespace + +int main() { + { + const auto tables = parse_toml(R"(# comment + +[project] # trailing comment +name = "libhello" +count = -42 +enabled = true +off=false + +[[targets]] +sources = [ + "a.cpp", # first + "b.cpp", +] + +[[targets]] +sources = [] +)"); + CANON_CHECK(tables); + if (tables) { + CANON_CHECK_EQ(tables->size(), 4u); + CANON_CHECK(tables->at(0).name.empty()); + CANON_CHECK(tables->at(0).entries.empty()); + + const toml_table& project = tables->at(1); + CANON_CHECK_EQ(project.name, "project"); + CANON_CHECK(!project.array_element); + CANON_CHECK_EQ(project.line, 3); + CANON_CHECK_EQ(project.entries.size(), 4u); + CANON_CHECK(std::get(project.entries.at(0).value) == "libhello"); + CANON_CHECK_EQ(project.entries.at(0).line, 4); + CANON_CHECK_EQ(std::get(project.entries.at(1).value), -42); + CANON_CHECK_EQ(std::get(project.entries.at(2).value), true); + CANON_CHECK_EQ(project.entries.at(3).key, "off"); + CANON_CHECK_EQ(std::get(project.entries.at(3).value), false); + + CANON_CHECK(tables->at(2).array_element); + CANON_CHECK(std::get>(tables->at(2).entries.at(0).value) == + std::vector({"a.cpp", "b.cpp"})); + CANON_CHECK(std::get>(tables->at(3).entries.at(0).value).empty()); + } + } + + // Escapes, including unicode, and CRLF line endings. + { + const auto tables = + parse_toml("[t]\r\ns = \"q\\\" b\\\\ n\\n t\\t u\\u00e9 U\\U0001F600 # not a comment\"\r\n"); + CANON_CHECK(tables); + if (tables) { + CANON_CHECK_EQ(std::get(tables->at(1).entries.at(0).value), + "q\" b\\ n\n t\t u\xC3\xA9 U\xF0\x9F\x98\x80 # not a comment"); + } + } + + // What toml_quote writes, parse_toml reads back. + for (const std::string_view original : {"plain", "a\"b\\c", "tab\tnew\nline", "\x01\x1f\x7f", + "caf\xC3\xA9", ""}) { + const auto tables = parse_toml("[t]\nv = " + toml_quote(original) + "\n"); + CANON_CHECK(tables); + if (tables) { + CANON_CHECK(std::get(tables->at(1).entries.at(0).value) == original); + } + } + CANON_CHECK_EQ(toml_array({"a", "b\"c"}), R"(["a", "b\"c"])"); + CANON_CHECK_EQ(toml_array({}), "[]"); + + // Errors carry the line number. + CANON_CHECK(fails_with("[t]\nname = \"x\nnext = 1\n", "line 2: unterminated string")); + CANON_CHECK(fails_with("[t]\na = 1\na = 2\n", "line 3: key 'a' is defined twice")); + CANON_CHECK(fails_with("[t]\n[t]\n", "line 2: table [t] is defined twice")); + CANON_CHECK(fails_with("[t]\n[[t]]\n", "line 2: [t] and [[t]] cannot both be used")); + CANON_CHECK(fails_with("[t]\nname \"x\"\n", "line 2: expected '='")); + CANON_CHECK(fails_with("[t]\nname = \"x\" extra\n", "line 2: expected the end of the line")); + CANON_CHECK(fails_with("[t]\nname = \n", "line 2: expected a value")); + CANON_CHECK(fails_with("[t]\ns = \"\\q\"\n", "unknown escape sequence '\\q'")); + CANON_CHECK(fails_with("[t]\ns = \"\\u12\"\n", "incomplete unicode escape")); + CANON_CHECK(fails_with("[t]\ns = \"\\uD800\"\n", "not a valid code point")); + CANON_CHECK(fails_with("[t]\ns = 'x'\n", "literal strings")); + CANON_CHECK(fails_with("[t]\ns = \"\"\"x\"\"\"\n", "multi-line strings")); + CANON_CHECK(fails_with("[t]\ns = 1.5\n", "expected the end of the line")); + CANON_CHECK(fails_with("[t]\na = [\"x\", 1]\n", "arrays may only contain strings")); + CANON_CHECK(fails_with("[t]\na = [\"x\"\n", "unterminated array")); + CANON_CHECK(fails_with("[t]\na = [\"x\" \"y\"]\n", "expected ',' or ']'")); + CANON_CHECK(fails_with("[t]\nb = yes\n", "unsupported value")); + CANON_CHECK(fails_with("[t\n", "line 1: expected ']'")); + CANON_CHECK(fails_with("[t]\n\"quoted\" = 1\n", "expected a key")); + + return details::exit_status(); +} diff --git a/tests/e2e/check.cmake b/tests/e2e/check.cmake index 67ce54d..a8bb94e 100644 --- a/tests/e2e/check.cmake +++ b/tests/e2e/check.cmake @@ -1,6 +1,6 @@ -# Scaffolds each project kind, then configures, builds and tests the result -# with the given compiler. Proves generated projects work, not just that they -# look right. +# Runs each scenario from ../scenarios.cmake, then configures, builds and tests +# the resulting project with the given compiler. Proves generated projects +# work, not just that they look right. # # cmake -DCANON= -DWORK_DIR= -DCXX= -P check.cmake @@ -12,8 +12,8 @@ foreach(var CANON WORK_DIR CXX) endif() endforeach() -file(REMOVE_RECURSE ${WORK_DIR}) -file(MAKE_DIRECTORY ${WORK_DIR}) +get_filename_component(WORK_DIR "${WORK_DIR}" ABSOLUTE) +include(${CMAKE_CURRENT_LIST_DIR}/../scenarios.cmake) function(run) execute_process(COMMAND ${ARGN} @@ -26,14 +26,9 @@ function(run) endif() endfunction() -foreach(case lib:libhello exe:hello) - string(REPLACE ":" ";" parts ${case}) - list(GET parts 0 kind) - list(GET parts 1 name) - - set(project ${WORK_DIR}/${name}) - message(STATUS "e2e: canon new ${kind} ${name}") - run(${CANON} new ${kind} ${name} --dir ${WORK_DIR}) +foreach(scenario ${CANON_SCENARIOS}) + message(STATUS "e2e: ${scenario}") + canon_scenario(${scenario} ${WORK_DIR} project) run(${CMAKE_COMMAND} -S ${project} -B ${project}/build -DCMAKE_CXX_COMPILER=${CXX} -DCMAKE_BUILD_TYPE=Debug) run(${CMAKE_COMMAND} --build ${project}/build) diff --git a/tests/golden/add-exe/hello/.canon.toml b/tests/golden/add-exe/hello/.canon.toml new file mode 100644 index 0000000..f2a1f98 --- /dev/null +++ b/tests/golden/add-exe/hello/.canon.toml @@ -0,0 +1,29 @@ +# canon project manifest: what canon declared for this project. +# The managed block in CMakeLists.txt is rendered from this file. + +[project] +name = "hello" +kind = "exe" +stem = "hello" +spec = "P1204R0" + +[build] +backend = "cmake" +cmake-minimum = "3.20" +cxx-standard = 23 + +[[targets]] +name = "hello" +type = "executable" +sources = ["hello/hello.cpp", "hello/greeting.cpp"] + +[[modules]] +path = "hello/greeting" +target = "hello" +unit-test = true + +[[tests]] +name = "basics" +kind = "run" +target = "hello" +expect = "Hello, World!" diff --git a/tests/golden/hello/.gitignore b/tests/golden/add-exe/hello/.gitignore similarity index 100% rename from tests/golden/hello/.gitignore rename to tests/golden/add-exe/hello/.gitignore diff --git a/tests/golden/add-exe/hello/CMakeLists.txt b/tests/golden/add-exe/hello/CMakeLists.txt new file mode 100644 index 0000000..5f1a9ee --- /dev/null +++ b/tests/golden/add-exe/hello/CMakeLists.txt @@ -0,0 +1,33 @@ +cmake_minimum_required(VERSION 3.20) +project(hello VERSION 0.1.0 LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 23) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +option(BUILD_TESTING "Build hello's tests" ON) +if(BUILD_TESTING) + enable_testing() +endif() + +# Add your own CMake outside the managed block below. + +# >>> canon:managed >>> +# Rendered from .canon.toml; canon rewrites everything between these markers. + +add_executable(hello + hello/hello.cpp + hello/greeting.cpp +) +target_include_directories(hello PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) + +if(BUILD_TESTING) + add_executable(hello.greeting.test hello/greeting.test.cpp hello/greeting.cpp) + target_include_directories(hello.greeting.test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) + add_test(NAME hello/greeting.test COMMAND hello.greeting.test) + + add_test(NAME basics COMMAND hello) + set_tests_properties(basics PROPERTIES PASS_REGULAR_EXPRESSION "Hello, World!") +endif() + +# <<< canon:managed <<< diff --git a/tests/golden/hello/README.md b/tests/golden/add-exe/hello/README.md similarity index 100% rename from tests/golden/hello/README.md rename to tests/golden/add-exe/hello/README.md diff --git a/tests/golden/add-exe/hello/hello/greeting.cpp b/tests/golden/add-exe/hello/hello/greeting.cpp new file mode 100644 index 0000000..b5b3548 --- /dev/null +++ b/tests/golden/add-exe/hello/hello/greeting.cpp @@ -0,0 +1,5 @@ +#include + +namespace hello { + +} // namespace hello diff --git a/tests/golden/add-exe/hello/hello/greeting.hpp b/tests/golden/add-exe/hello/hello/greeting.hpp new file mode 100644 index 0000000..631426f --- /dev/null +++ b/tests/golden/add-exe/hello/hello/greeting.hpp @@ -0,0 +1,5 @@ +#pragma once + +namespace hello { + +} // namespace hello diff --git a/tests/golden/add-exe/hello/hello/greeting.test.cpp b/tests/golden/add-exe/hello/hello/greeting.test.cpp new file mode 100644 index 0000000..eddc8f1 --- /dev/null +++ b/tests/golden/add-exe/hello/hello/greeting.test.cpp @@ -0,0 +1,6 @@ +// Unit tests for . Return non-zero from main() to fail. + +#include + +int main() { +} diff --git a/tests/golden/hello/hello/hello.cpp b/tests/golden/add-exe/hello/hello/hello.cpp similarity index 100% rename from tests/golden/hello/hello/hello.cpp rename to tests/golden/add-exe/hello/hello/hello.cpp diff --git a/tests/golden/add-lib/libhello/.canon.toml b/tests/golden/add-lib/libhello/.canon.toml new file mode 100644 index 0000000..c707098 --- /dev/null +++ b/tests/golden/add-lib/libhello/.canon.toml @@ -0,0 +1,39 @@ +# canon project manifest: what canon declared for this project. +# The managed block in CMakeLists.txt is rendered from this file. + +[project] +name = "libhello" +kind = "lib" +stem = "hello" +spec = "P1204R0" + +[build] +backend = "cmake" +cmake-minimum = "3.20" +cxx-standard = 23 + +[[targets]] +name = "libhello" +type = "library" +sources = ["hello/hello.cpp", "hello/core.cpp", "hello/details/utility.cpp"] + +[[modules]] +path = "hello/hello" +target = "libhello" +unit-test = true + +[[modules]] +path = "hello/core" +target = "libhello" +unit-test = true + +[[modules]] +path = "hello/details/utility" +target = "libhello" +unit-test = true + +[[tests]] +name = "basics" +kind = "driver" +path = "tests/basics" +target = "libhello" diff --git a/tests/golden/libhello/.gitignore b/tests/golden/add-lib/libhello/.gitignore similarity index 100% rename from tests/golden/libhello/.gitignore rename to tests/golden/add-lib/libhello/.gitignore diff --git a/tests/golden/add-lib/libhello/CMakeLists.txt b/tests/golden/add-lib/libhello/CMakeLists.txt new file mode 100644 index 0000000..13ac0a7 --- /dev/null +++ b/tests/golden/add-lib/libhello/CMakeLists.txt @@ -0,0 +1,45 @@ +cmake_minimum_required(VERSION 3.20) +project(libhello VERSION 0.1.0 LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 23) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +option(BUILD_TESTING "Build libhello's tests" ON) +if(BUILD_TESTING) + enable_testing() +endif() + +# Add your own CMake outside the managed block below. + +# >>> canon:managed >>> +# Rendered from .canon.toml; canon rewrites everything between these markers. + +add_library(libhello + hello/hello.cpp + hello/core.cpp + hello/details/utility.cpp +) +set_target_properties(libhello PROPERTIES PREFIX "") +target_include_directories(libhello PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) +target_compile_features(libhello PUBLIC cxx_std_23) + +if(BUILD_TESTING) + add_executable(hello.hello.test hello/hello.test.cpp) + target_link_libraries(hello.hello.test PRIVATE libhello) + add_test(NAME hello/hello.test COMMAND hello.hello.test) + + add_executable(hello.core.test hello/core.test.cpp) + target_link_libraries(hello.core.test PRIVATE libhello) + add_test(NAME hello/core.test COMMAND hello.core.test) + + add_executable(hello.details.utility.test hello/details/utility.test.cpp) + target_link_libraries(hello.details.utility.test PRIVATE libhello) + add_test(NAME hello/details/utility.test COMMAND hello.details.utility.test) + + add_executable(tests.basics tests/basics/driver.cpp) + target_link_libraries(tests.basics PRIVATE libhello) + add_test(NAME basics COMMAND tests.basics) +endif() + +# <<< canon:managed <<< diff --git a/tests/golden/libhello/README.md b/tests/golden/add-lib/libhello/README.md similarity index 100% rename from tests/golden/libhello/README.md rename to tests/golden/add-lib/libhello/README.md diff --git a/tests/golden/add-lib/libhello/hello/core.cpp b/tests/golden/add-lib/libhello/hello/core.cpp new file mode 100644 index 0000000..d97f5d5 --- /dev/null +++ b/tests/golden/add-lib/libhello/hello/core.cpp @@ -0,0 +1,5 @@ +#include + +namespace hello { + +} // namespace hello diff --git a/tests/golden/add-lib/libhello/hello/core.hpp b/tests/golden/add-lib/libhello/hello/core.hpp new file mode 100644 index 0000000..631426f --- /dev/null +++ b/tests/golden/add-lib/libhello/hello/core.hpp @@ -0,0 +1,5 @@ +#pragma once + +namespace hello { + +} // namespace hello diff --git a/tests/golden/add-lib/libhello/hello/core.test.cpp b/tests/golden/add-lib/libhello/hello/core.test.cpp new file mode 100644 index 0000000..967e420 --- /dev/null +++ b/tests/golden/add-lib/libhello/hello/core.test.cpp @@ -0,0 +1,6 @@ +// Unit tests for . Return non-zero from main() to fail. + +#include + +int main() { +} diff --git a/tests/golden/add-lib/libhello/hello/details/utility.cpp b/tests/golden/add-lib/libhello/hello/details/utility.cpp new file mode 100644 index 0000000..8b86695 --- /dev/null +++ b/tests/golden/add-lib/libhello/hello/details/utility.cpp @@ -0,0 +1,5 @@ +#include + +namespace hello::details { + +} // namespace hello::details diff --git a/tests/golden/add-lib/libhello/hello/details/utility.hpp b/tests/golden/add-lib/libhello/hello/details/utility.hpp new file mode 100644 index 0000000..7075cf4 --- /dev/null +++ b/tests/golden/add-lib/libhello/hello/details/utility.hpp @@ -0,0 +1,5 @@ +#pragma once + +namespace hello::details { + +} // namespace hello::details diff --git a/tests/golden/add-lib/libhello/hello/details/utility.test.cpp b/tests/golden/add-lib/libhello/hello/details/utility.test.cpp new file mode 100644 index 0000000..fcb3f73 --- /dev/null +++ b/tests/golden/add-lib/libhello/hello/details/utility.test.cpp @@ -0,0 +1,6 @@ +// Unit tests for . Return non-zero from main() to fail. + +#include + +int main() { +} diff --git a/tests/golden/libhello/hello/hello.cpp b/tests/golden/add-lib/libhello/hello/hello.cpp similarity index 100% rename from tests/golden/libhello/hello/hello.cpp rename to tests/golden/add-lib/libhello/hello/hello.cpp diff --git a/tests/golden/libhello/hello/hello.hpp b/tests/golden/add-lib/libhello/hello/hello.hpp similarity index 100% rename from tests/golden/libhello/hello/hello.hpp rename to tests/golden/add-lib/libhello/hello/hello.hpp diff --git a/tests/golden/libhello/hello/hello.test.cpp b/tests/golden/add-lib/libhello/hello/hello.test.cpp similarity index 100% rename from tests/golden/libhello/hello/hello.test.cpp rename to tests/golden/add-lib/libhello/hello/hello.test.cpp diff --git a/tests/golden/libhello/tests/basics/driver.cpp b/tests/golden/add-lib/libhello/tests/basics/driver.cpp similarity index 100% rename from tests/golden/libhello/tests/basics/driver.cpp rename to tests/golden/add-lib/libhello/tests/basics/driver.cpp diff --git a/tests/golden/check.cmake b/tests/golden/check.cmake index 3365374..35437f2 100644 --- a/tests/golden/check.cmake +++ b/tests/golden/check.cmake @@ -1,5 +1,5 @@ -# Scaffolds each project kind and compares the result byte for byte with the -# expected trees next to this script. +# Runs each scenario from ../scenarios.cmake and compares the resulting project +# byte for byte with the expected tree in / next to this script. # # cmake -DCANON= -DWORK_DIR= [-DUPDATE=ON] -P check.cmake # @@ -14,42 +14,20 @@ foreach(var CANON WORK_DIR) endif() endforeach() -set(golden_dir ${CMAKE_CURRENT_LIST_DIR}) -file(REMOVE_RECURSE ${WORK_DIR}) -file(MAKE_DIRECTORY ${WORK_DIR}) +get_filename_component(WORK_DIR "${WORK_DIR}" ABSOLUTE) +include(${CMAKE_CURRENT_LIST_DIR}/../scenarios.cmake) +set(golden_dir ${CMAKE_CURRENT_LIST_DIR}) set(failed FALSE) -foreach(case lib:libhello exe:hello) - string(REPLACE ":" ";" parts ${case}) - list(GET parts 0 kind) - list(GET parts 1 name) - - execute_process( - COMMAND ${CANON} new ${kind} ${name} --dir ${WORK_DIR} - RESULT_VARIABLE status - OUTPUT_VARIABLE output - ERROR_VARIABLE errors) - if(NOT status EQUAL 0) - message(FATAL_ERROR "canon new ${kind} ${name} failed (${status}):\n${output}${errors}") - endif() - - # A second run must refuse to touch the existing project. - execute_process( - COMMAND ${CANON} new ${kind} ${name} --dir ${WORK_DIR} - RESULT_VARIABLE status - OUTPUT_QUIET ERROR_QUIET) - if(status EQUAL 0) - message(SEND_ERROR "canon new ${kind} ${name} succeeded over an existing project") - set(failed TRUE) - endif() - - set(actual ${WORK_DIR}/${name}) - set(expected ${golden_dir}/${name}) +foreach(scenario ${CANON_SCENARIOS}) + canon_scenario(${scenario} ${WORK_DIR} actual) + get_filename_component(name ${actual} NAME) + set(expected ${golden_dir}/${scenario}/${name}) if(UPDATE) - file(REMOVE_RECURSE ${expected}) - file(COPY ${actual} DESTINATION ${golden_dir}) + file(REMOVE_RECURSE ${golden_dir}/${scenario}) + file(COPY ${actual} DESTINATION ${golden_dir}/${scenario}) message(STATUS "Updated ${expected}") continue() endif() @@ -60,7 +38,7 @@ foreach(case lib:libhello exe:hello) list(SORT expected_files) if(NOT actual_files STREQUAL expected_files) - message(SEND_ERROR "${name}: files differ from golden\n" + message(SEND_ERROR "${scenario}: files differ from golden\n" " actual: ${actual_files}\n" " expected: ${expected_files}") set(failed TRUE) @@ -72,7 +50,8 @@ foreach(case lib:libhello exe:hello) COMMAND ${CMAKE_COMMAND} -E compare_files ${actual}/${file} ${expected}/${file} RESULT_VARIABLE different) if(different) - message(SEND_ERROR "${name}/${file} differs from golden (diff ${actual}/${file} ${expected}/${file})") + message(SEND_ERROR "${scenario}: ${name}/${file} differs from golden " + "(diff ${actual}/${file} ${expected}/${file})") set(failed TRUE) endif() endforeach() diff --git a/tests/golden/hello/.canon.toml b/tests/golden/new-exe/hello/.canon.toml similarity index 100% rename from tests/golden/hello/.canon.toml rename to tests/golden/new-exe/hello/.canon.toml diff --git a/tests/golden/new-exe/hello/.gitignore b/tests/golden/new-exe/hello/.gitignore new file mode 100644 index 0000000..84c048a --- /dev/null +++ b/tests/golden/new-exe/hello/.gitignore @@ -0,0 +1 @@ +/build/ diff --git a/tests/golden/hello/CMakeLists.txt b/tests/golden/new-exe/hello/CMakeLists.txt similarity index 100% rename from tests/golden/hello/CMakeLists.txt rename to tests/golden/new-exe/hello/CMakeLists.txt diff --git a/tests/golden/new-exe/hello/README.md b/tests/golden/new-exe/hello/README.md new file mode 100644 index 0000000..267b7c3 --- /dev/null +++ b/tests/golden/new-exe/hello/README.md @@ -0,0 +1,12 @@ +# hello + +Created by canon. The layout follows [P1204R0](https://wg21.link/p1204r0) +(Canonical Project Structure). + +## Build and test + +```bash +cmake -S . -B build +cmake --build build +ctest --test-dir build --output-on-failure +``` diff --git a/tests/golden/new-exe/hello/hello/hello.cpp b/tests/golden/new-exe/hello/hello/hello.cpp new file mode 100644 index 0000000..d1aadd6 --- /dev/null +++ b/tests/golden/new-exe/hello/hello/hello.cpp @@ -0,0 +1,5 @@ +#include + +int main(int argc, char* argv[]) { + std::println("Hello, {}!", argc > 1 ? argv[1] : "World"); +} diff --git a/tests/golden/libhello/.canon.toml b/tests/golden/new-lib/libhello/.canon.toml similarity index 100% rename from tests/golden/libhello/.canon.toml rename to tests/golden/new-lib/libhello/.canon.toml diff --git a/tests/golden/new-lib/libhello/.gitignore b/tests/golden/new-lib/libhello/.gitignore new file mode 100644 index 0000000..84c048a --- /dev/null +++ b/tests/golden/new-lib/libhello/.gitignore @@ -0,0 +1 @@ +/build/ diff --git a/tests/golden/libhello/CMakeLists.txt b/tests/golden/new-lib/libhello/CMakeLists.txt similarity index 100% rename from tests/golden/libhello/CMakeLists.txt rename to tests/golden/new-lib/libhello/CMakeLists.txt diff --git a/tests/golden/new-lib/libhello/README.md b/tests/golden/new-lib/libhello/README.md new file mode 100644 index 0000000..a844dca --- /dev/null +++ b/tests/golden/new-lib/libhello/README.md @@ -0,0 +1,12 @@ +# libhello + +Created by canon. The layout follows [P1204R0](https://wg21.link/p1204r0) +(Canonical Project Structure). + +## Build and test + +```bash +cmake -S . -B build +cmake --build build +ctest --test-dir build --output-on-failure +``` diff --git a/tests/golden/new-lib/libhello/hello/hello.cpp b/tests/golden/new-lib/libhello/hello/hello.cpp new file mode 100644 index 0000000..e7b92ce --- /dev/null +++ b/tests/golden/new-lib/libhello/hello/hello.cpp @@ -0,0 +1,11 @@ +#include + +#include + +namespace hello { + +std::string greeting(std::string_view name) { + return std::format("Hello, {}!", name); +} + +} // namespace hello diff --git a/tests/golden/new-lib/libhello/hello/hello.hpp b/tests/golden/new-lib/libhello/hello/hello.hpp new file mode 100644 index 0000000..0d8d5ba --- /dev/null +++ b/tests/golden/new-lib/libhello/hello/hello.hpp @@ -0,0 +1,11 @@ +#pragma once + +#include +#include + +namespace hello { + +// Returns a greeting for name, e.g. "Hello, World!". +[[nodiscard]] std::string greeting(std::string_view name); + +} // namespace hello diff --git a/tests/golden/new-lib/libhello/hello/hello.test.cpp b/tests/golden/new-lib/libhello/hello/hello.test.cpp new file mode 100644 index 0000000..5cafec8 --- /dev/null +++ b/tests/golden/new-lib/libhello/hello/hello.test.cpp @@ -0,0 +1,13 @@ +#include + +#include +#include +#include + +int main() { + const auto actual = hello::greeting("World"); + if (actual != "Hello, World!") { + std::println(stderr, "greeting(\"World\") returned \"{}\"", actual); + return EXIT_FAILURE; + } +} diff --git a/tests/golden/new-lib/libhello/tests/basics/driver.cpp b/tests/golden/new-lib/libhello/tests/basics/driver.cpp new file mode 100644 index 0000000..757d317 --- /dev/null +++ b/tests/golden/new-lib/libhello/tests/basics/driver.cpp @@ -0,0 +1,12 @@ +// Functional test: uses libhello only through its public headers, as a consumer would. + +#include + +#include +#include + +int main() { + const auto greeting = hello::greeting("libhello"); + std::println("{}", greeting); + return greeting.starts_with("Hello") ? EXIT_SUCCESS : EXIT_FAILURE; +} diff --git a/tests/scenarios.cmake b/tests/scenarios.cmake new file mode 100644 index 0000000..6202274 --- /dev/null +++ b/tests/scenarios.cmake @@ -0,0 +1,72 @@ +# canon command sequences shared by the golden and end-to-end tests. Each +# scenario builds one project inside //. Requires CANON. + +set(CANON_SCENARIOS new-lib new-exe add-lib add-exe) + +# Scenarios run canon from other directories, so a relative path would break. +get_filename_component(CANON "${CANON}" ABSOLUTE) + +# Runs canon in working_dir and fails the script if it fails. +function(canon_run working_dir) + execute_process( + COMMAND ${CANON} ${ARGN} + WORKING_DIRECTORY ${working_dir} + RESULT_VARIABLE status + OUTPUT_VARIABLE output + ERROR_VARIABLE errors) + if(NOT status EQUAL 0) + string(JOIN " " args ${ARGN}) + message(FATAL_ERROR "canon ${args} failed (${status}) in ${working_dir}:\n${output}${errors}") + endif() +endfunction() + +# Runs canon in working_dir and fails the script if it succeeds. +function(canon_run_fails working_dir) + execute_process( + COMMAND ${CANON} ${ARGN} + WORKING_DIRECTORY ${working_dir} + RESULT_VARIABLE status + OUTPUT_QUIET ERROR_QUIET) + if(status EQUAL 0) + string(JOIN " " args ${ARGN}) + message(FATAL_ERROR "canon ${args} succeeded in ${working_dir} but should have failed") + endif() +endfunction() + +# Runs the scenario and sets out_var to the project directory it produced. +function(canon_scenario scenario work_dir out_var) + set(dir ${work_dir}/${scenario}) + file(REMOVE_RECURSE ${dir}) + file(MAKE_DIRECTORY ${dir}) + + if(scenario STREQUAL "new-lib") + canon_run(${dir} new lib libhello) + canon_run_fails(${dir} new lib libhello) # never overwrites + set(project ${dir}/libhello) + + elseif(scenario STREQUAL "new-exe") + canon_run(${dir} new exe hello) + set(project ${dir}/hello) + + elseif(scenario STREQUAL "add-lib") + canon_run(${dir} new lib libhello) + set(project ${dir}/libhello) + canon_run(${project} add module core) + canon_run(${project}/hello add module details/utility --no-test) # from a subdirectory + canon_run(${project} add unit-test details/utility) + canon_run(${project} add module parser --dry-run) # must write nothing + canon_run_fails(${project} add module core) # already declared + canon_run_fails(${project} add unit-test core) # already has one + canon_run_fails(${project} add module Bad) # invalid path + + elseif(scenario STREQUAL "add-exe") + canon_run(${dir} new exe hello) + set(project ${dir}/hello) + canon_run(${project} add module greeting) + + else() + message(FATAL_ERROR "unknown scenario '${scenario}'") + endif() + + set(${out_var} ${project} PARENT_SCOPE) +endfunction()