From ab5c468ace0fc5283d0b5375c6cbd96f5fba6b8a Mon Sep 17 00:00:00 2001 From: paulhorn Date: Thu, 17 Sep 2026 18:11:23 +0200 Subject: [PATCH] Add canon: P1204R0 project scaffolding in C++23 canon new lib|exe creates a project following P1204R0 (Canonical Project Structure) with a .canon.toml manifest and a CMakeLists.txt whose managed block is rendered from that manifest. Generators return a plan; the executor checks every operation before writing, so conflicts write nothing and --dry-run writes nothing. Tests: unit tests next to each source file, golden copies of generated projects, and an end-to-end build of both project kinds. Co-Authored-By: Claude Opus 5 --- .gitignore | 1 + CMakeLists.txt | 59 +++++++ README.md | 71 ++++++++ canon/canon.cpp | 121 +++++++++++++ canon/cli.cpp | 101 +++++++++++ canon/cli.hpp | 34 ++++ canon/cli.test.cpp | 70 ++++++++ canon/cmake.cpp | 163 ++++++++++++++++++ canon/cmake.hpp | 24 +++ canon/cmake.test.cpp | 98 +++++++++++ canon/details/overloaded.hpp | 11 ++ canon/details/testing.hpp | 68 ++++++++ canon/executor.cpp | 79 +++++++++ canon/executor.hpp | 20 +++ canon/executor.test.cpp | 76 ++++++++ canon/generate.cpp | 148 ++++++++++++++++ canon/generate.hpp | 28 +++ canon/generate.test.cpp | 117 +++++++++++++ canon/manifest.cpp | 106 ++++++++++++ canon/manifest.hpp | 59 +++++++ canon/manifest.test.cpp | 35 ++++ canon/name.cpp | 101 +++++++++++ canon/name.hpp | 27 +++ canon/name.test.cpp | 53 ++++++ canon/plan.cpp | 46 +++++ canon/plan.hpp | 38 ++++ canon/plan.test.cpp | 32 ++++ canon/template.cpp | 36 ++++ canon/template.hpp | 17 ++ canon/template.test.cpp | 35 ++++ tests/e2e/check.cmake | 41 +++++ tests/golden/check.cmake | 83 +++++++++ tests/golden/hello/.canon.toml | 24 +++ tests/golden/hello/.gitignore | 1 + tests/golden/hello/CMakeLists.txt | 28 +++ tests/golden/hello/README.md | 12 ++ tests/golden/hello/hello/hello.cpp | 5 + tests/golden/libhello/.canon.toml | 29 ++++ tests/golden/libhello/.gitignore | 1 + tests/golden/libhello/CMakeLists.txt | 35 ++++ tests/golden/libhello/README.md | 12 ++ tests/golden/libhello/hello/hello.cpp | 11 ++ tests/golden/libhello/hello/hello.hpp | 11 ++ tests/golden/libhello/hello/hello.test.cpp | 13 ++ tests/golden/libhello/tests/basics/driver.cpp | 12 ++ 45 files changed, 2192 insertions(+) create mode 100644 .gitignore create mode 100644 CMakeLists.txt create mode 100644 README.md create mode 100644 canon/canon.cpp create mode 100644 canon/cli.cpp create mode 100644 canon/cli.hpp create mode 100644 canon/cli.test.cpp create mode 100644 canon/cmake.cpp create mode 100644 canon/cmake.hpp create mode 100644 canon/cmake.test.cpp create mode 100644 canon/details/overloaded.hpp create mode 100644 canon/details/testing.hpp create mode 100644 canon/executor.cpp create mode 100644 canon/executor.hpp create mode 100644 canon/executor.test.cpp create mode 100644 canon/generate.cpp create mode 100644 canon/generate.hpp create mode 100644 canon/generate.test.cpp create mode 100644 canon/manifest.cpp create mode 100644 canon/manifest.hpp create mode 100644 canon/manifest.test.cpp create mode 100644 canon/name.cpp create mode 100644 canon/name.hpp create mode 100644 canon/name.test.cpp create mode 100644 canon/plan.cpp create mode 100644 canon/plan.hpp create mode 100644 canon/plan.test.cpp create mode 100644 canon/template.cpp create mode 100644 canon/template.hpp create mode 100644 canon/template.test.cpp create mode 100644 tests/e2e/check.cmake create mode 100644 tests/golden/check.cmake create mode 100644 tests/golden/hello/.canon.toml create mode 100644 tests/golden/hello/.gitignore create mode 100644 tests/golden/hello/CMakeLists.txt create mode 100644 tests/golden/hello/README.md create mode 100644 tests/golden/hello/hello/hello.cpp create mode 100644 tests/golden/libhello/.canon.toml create mode 100644 tests/golden/libhello/.gitignore create mode 100644 tests/golden/libhello/CMakeLists.txt create mode 100644 tests/golden/libhello/README.md create mode 100644 tests/golden/libhello/hello/hello.cpp create mode 100644 tests/golden/libhello/hello/hello.hpp create mode 100644 tests/golden/libhello/hello/hello.test.cpp create mode 100644 tests/golden/libhello/tests/basics/driver.cpp diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..84c048a --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/build/ diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..8902d00 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,59 @@ +cmake_minimum_required(VERSION 3.20) +project(canon + VERSION 0.1.0 + DESCRIPTION "Scaffolds C++ projects that follow P1204R0 (Canonical Project Structure)" + LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 23) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +option(BUILD_TESTING "Build canon's tests" ON) + +# Everything except main(), so unit tests can link against it. +add_library(canon.core STATIC + canon/cli.cpp + canon/cmake.cpp + canon/executor.cpp + canon/generate.cpp + canon/manifest.cpp + canon/name.cpp + canon/plan.cpp + canon/template.cpp +) +target_include_directories(canon.core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) +if(NOT MSVC) + target_compile_options(canon.core PUBLIC -Wall -Wextra -Wpedantic) +endif() + +add_executable(canon canon/canon.cpp) +target_link_libraries(canon PRIVATE canon.core) +target_compile_definitions(canon PRIVATE CANON_VERSION="${PROJECT_VERSION}") +install(TARGETS canon) + +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) + 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) + endforeach() + + # Byte-for-byte comparison of scaffolded projects against tests/golden/. + add_test(NAME tests/golden + COMMAND ${CMAKE_COMMAND} + -DCANON=$ + -DWORK_DIR=${CMAKE_CURRENT_BINARY_DIR}/tests/golden + -P ${CMAKE_CURRENT_SOURCE_DIR}/tests/golden/check.cmake) + + # Scaffolds each project kind, then configures, builds and tests it. + add_test(NAME tests/e2e + COMMAND ${CMAKE_COMMAND} + -DCANON=$ + -DWORK_DIR=${CMAKE_CURRENT_BINARY_DIR}/tests/e2e + -DCXX=${CMAKE_CXX_COMPILER} + -P ${CMAKE_CURRENT_SOURCE_DIR}/tests/e2e/check.cmake) + set_tests_properties(tests/e2e PROPERTIES LABELS e2e TIMEOUT 300) +endif() diff --git a/README.md b/README.md new file mode 100644 index 0000000..c31e46d --- /dev/null +++ b/README.md @@ -0,0 +1,71 @@ +# canon + +Scaffolds C++ projects that follow [P1204R0](https://wg21.link/p1204r0) +(Canonical Project Structure). 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 +``` + +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 +``` + +## Build canon + +```bash +cmake -S . -B build +cmake --build build +ctest --test-dir build --output-on-failure +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. + +Each project gets 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. + +| 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/plan.*` | operations and plans (pure data) | +| `canon/executor.*` | checks and applies a plan | +| `canon/template.*` | `{{key}}` substitution | + +## 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: + `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`. + +## 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 +3. `add target`, `add test`, `add dep`, `doctor` (check a project against P1204R0) +4. User template overrides diff --git a/canon/canon.cpp b/canon/canon.cpp new file mode 100644 index 0000000..4e0c7c9 --- /dev/null +++ b/canon/canon.cpp @@ -0,0 +1,121 @@ +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace { + +constexpr int usage_error = 2; + +// Quotes a path for pasting into a shell, only when it needs it. +std::string shell_quote(const std::string& s) { + const bool safe = !s.empty() && s.find_first_not_of("abcdefghijklmnopqrstuvwxyz" + "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "0123456789/._-+") == std::string::npos; + if (safe) { + return s; + } + std::string out = "'"; + for (const char c : s) { + out += c == '\'' ? std::string("'\\''") : std::string(1, c); + } + return out + "'"; +} + +// Absolute path for messages, with the home directory shown as ~. +std::string display_path(const fs::path& path) { + std::error_code error; + const std::string absolute = fs::absolute(path, error).lexically_normal().string(); + if (error) { + return path.string(); + } + const char* home_env = std::getenv("HOME"); + if (home_env == nullptr || *home_env == '\0') { + return absolute; + } + std::string home = fs::path(home_env).lexically_normal().string(); + if (home.size() > 1 && home.ends_with('/')) { + home.pop_back(); + } + if (absolute == home || absolute.starts_with(home + "/")) { + return "~" + absolute.substr(home.size()); + } + return absolute; +} + +int run_new(const canon::new_command& cmd) { + const auto name = canon::parse_project_name(cmd.kind, cmd.name); + if (!name) { + std::println(stderr, "canon: {}", name.error()); + return usage_error; + } + + std::error_code ignored; + if (!fs::is_directory(cmd.directory, ignored)) { + std::println(stderr, "canon: directory {} does not exist", cmd.directory.string()); + return EXIT_FAILURE; + } + const fs::path destination = (cmd.directory / name->name).lexically_normal(); + if (fs::exists(destination, ignored) && + !(fs::is_directory(destination, ignored) && fs::is_empty(destination, ignored))) { + std::println(stderr, "canon: {} already exists and is not an empty directory", + destination.string()); + 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()); + 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; + } + 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; +} + +} // namespace + +int main(int argc, char* argv[]) try { + const std::vector args(argv + 1, argv + argc); + const auto command = canon::parse_command_line(args); + if (!command) { + std::println(stderr, "canon: {}\nRun 'canon --help' for usage.", command.error()); + return usage_error; + } + return std::visit(canon::details::overloaded{ + [](const canon::help_command&) { + std::print("{}", canon::usage()); + return EXIT_SUCCESS; + }, + [](const canon::version_command&) { + std::println("canon {}", CANON_VERSION); + return EXIT_SUCCESS; + }, + [](const canon::new_command& cmd) { return run_new(cmd); }, + }, + *command); +} catch (const std::exception& e) { + std::println(stderr, "canon: internal error: {}", e.what()); + return EXIT_FAILURE; +} diff --git a/canon/cli.cpp b/canon/cli.cpp new file mode 100644 index 0000000..1833e18 --- /dev/null +++ b/canon/cli.cpp @@ -0,0 +1,101 @@ +#include + +#include +#include +#include + +namespace canon { + +namespace { + +std::unexpected fail(std::string message) { + return std::unexpected(std::move(message)); +} + +} // namespace + +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])); + } + if (positional.size() < 2) { + return fail("'new' needs a kind: lib or exe"); + } + + new_command cmd{.dry_run = dry_run}; + if (positional[1] == "lib") { + cmd.kind = project_kind::library; + } else if (positional[1] == "exe") { + cmd.kind = project_kind::executable; + } else { + return fail(std::format("unknown project kind '{}' (expected lib or exe)", positional[1])); + } + if (positional.size() < 3) { + return fail(std::format("'new {}' needs a project name", positional[1])); + } + if (positional.size() > 3) { + return fail(std::format("unexpected argument '{}'", positional[3])); + } + cmd.name = positional[2]; + if (directory) { + cmd.directory = *directory; + } + return cmd; +} + +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 + +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 + +Projects follow P1204R0 (Canonical Project Structure): https://wg21.link/p1204r0 +)"; +} + +} // namespace canon diff --git a/canon/cli.hpp b/canon/cli.hpp new file mode 100644 index 0000000..b9148c4 --- /dev/null +++ b/canon/cli.hpp @@ -0,0 +1,34 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include + +namespace canon { + +struct help_command {}; + +struct version_command {}; + +struct new_command { + project_kind kind{}; + std::string name{}; + std::filesystem::path directory = "."; // where the project directory is created + 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. +[[nodiscard]] std::expected +parse_command_line(std::span args); + +[[nodiscard]] std::string_view usage(); + +} // namespace canon diff --git a/canon/cli.test.cpp b/canon/cli.test.cpp new file mode 100644 index 0000000..a019eb8 --- /dev/null +++ b/canon/cli.test.cpp @@ -0,0 +1,70 @@ +#include + +#include + +#include +#include + +using namespace canon; + +namespace { + +auto parse(std::initializer_list args) { + const std::vector v(args); + return parse_command_line(v); +} + +bool is_help(const std::expected& c) { + return c && std::holds_alternative(*c); +} + +bool fails_with(const std::expected& c, std::string_view message) { + return !c && c.error().contains(message); +} + +} // namespace + +int main() { + CANON_CHECK(is_help(parse({}))); + CANON_CHECK(is_help(parse({"--help"}))); + CANON_CHECK(is_help(parse({"new", "lib", "-h"}))); + CANON_CHECK(parse({"--version"}) && std::holds_alternative(*parse({"--version"}))); + + if (const auto c = parse({"new", "lib", "libhello"}); CANON_CHECK(c), c) { + const auto* n = std::get_if(&*c); + CANON_CHECK(n != nullptr); + if (n != nullptr) { + CANON_CHECK(n->kind == project_kind::library); + CANON_CHECK_EQ(n->name, "libhello"); + CANON_CHECK_EQ(n->directory.generic_string(), "."); + CANON_CHECK(!n->dry_run); + } + } + + if (const auto c = parse({"--dry-run", "new", "exe", "hello", "--dir", "some/where"}); CANON_CHECK(c), c) { + const auto& n = std::get(*c); + CANON_CHECK(n.kind == project_kind::executable); + CANON_CHECK(n.dry_run); + CANON_CHECK_EQ(n.directory.generic_string(), "some/where"); + } + + if (const auto c = parse({"new", "exe", "--dir=out", "hello"}); CANON_CHECK(c), c) { + CANON_CHECK_EQ(std::get(*c).directory.generic_string(), "out"); + } + + // Everything after "--" is positional. + if (const auto c = parse({"new", "exe", "--", "-weird"}); CANON_CHECK(c), c) { + CANON_CHECK_EQ(std::get(*c).name, "-weird"); + } + + 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'")); + CANON_CHECK(fails_with(parse({"new", "lib"}), "needs a project name")); + CANON_CHECK(fails_with(parse({"new", "lib", "liba", "libb"}), "unexpected argument 'libb'")); + CANON_CHECK(fails_with(parse({"new", "lib", "liba", "--force"}), "unknown option '--force'")); + CANON_CHECK(fails_with(parse({"new", "lib", "liba", "--dir"}), "--dir needs a path")); + CANON_CHECK(fails_with(parse({"new", "lib", "liba", "--dir="}), "--dir needs a path")); + + return details::exit_status(); +} diff --git a/canon/cmake.cpp b/canon/cmake.cpp new file mode 100644 index 0000000..3e400fc --- /dev/null +++ b/canon/cmake.cpp @@ -0,0 +1,163 @@ +#include + +#include +#include +#include + +namespace canon { + +namespace { + +const target& find_target(const manifest& m, std::string_view name, std::string_view user) { + const auto found = std::ranges::find(m.targets, name, &target::name); + if (found == m.targets.end()) { + throw std::invalid_argument(std::format("{} refers to unknown target '{}'", user, name)); + } + return *found; +} + +// hello/hello -> hello.hello: CMake target names cannot contain '/'. +std::string dotted(std::string_view path) { + std::string out(path); + std::ranges::replace(out, '/', '.'); + return out; +} + +std::string cmake_quote(std::string_view s) { + std::string out = "\""; + for (const char c : s) { + if (c == '"' || c == '\\' || c == '$' || c == ';') { + out += '\\'; + } + out += c; + } + out += '"'; + return out; +} + +std::string join(const std::vector& items, std::string_view separator) { + std::string out; + for (bool first = true; const auto& item : items) { + if (!first) { + out += separator; + } + out += item; + first = false; + } + return out; +} + +void render_target(std::string& out, const manifest& m, const target& t) { + const bool library = t.type == target_type::library; + const std::string_view scope = library ? "PUBLIC" : "PRIVATE"; + + out += std::format("\n{}({}\n", library ? "add_library" : "add_executable", t.name); + for (const auto& source : t.sources) { + out += std::format(" {}\n", source); + } + out += ")\n"; + if (library) { + // The target name already carries P1204R0's "lib" prefix; stop CMake adding another. + out += std::format("set_target_properties({} PROPERTIES PREFIX \"\")\n", t.name); + } + out += std::format("target_include_directories({} {} ${{CMAKE_CURRENT_SOURCE_DIR}})\n", t.name, + scope); + if (library) { + out += std::format("target_compile_features({} PUBLIC cxx_std_{})\n", t.name, m.cxx_standard); + } + if (!t.depends.empty()) { + out += std::format("target_link_libraries({} {} {})\n", t.name, scope, join(t.depends, " ")); + } +} + +void render_tests(std::string& out, const manifest& m) { + const bool any_unit_tests = std::ranges::any_of(m.modules, &source_module::unit_test); + if (!any_unit_tests && m.tests.empty()) { + return; + } + + out += "\nif(BUILD_TESTING)\n"; + bool first = true; + const auto group = [&] { + if (!first) { + out += '\n'; + } + first = false; + }; + + for (const auto& mod : m.modules) { + if (!mod.unit_test) { + continue; + } + const target& owner = find_target(m, mod.target, std::format("module '{}'", mod.path)); + const std::string exe = dotted(mod.path) + ".test"; + + group(); + if (owner.type == target_type::library) { + out += std::format(" add_executable({} {}.test.cpp)\n", exe, mod.path); + out += std::format(" target_link_libraries({} PRIVATE {})\n", exe, owner.name); + } else { + // An executable cannot be linked against, so compile the module into the test. + out += std::format(" add_executable({} {}.test.cpp {}.cpp)\n", exe, mod.path, mod.path); + out += std::format(" target_include_directories({} PRIVATE ${{CMAKE_CURRENT_SOURCE_DIR}})\n", + exe); + } + out += std::format(" add_test(NAME {}.test COMMAND {})\n", mod.path, exe); + } + + for (const auto& t : m.tests) { + const target& subject = find_target(m, t.target, std::format("test '{}'", t.name)); + + group(); + if (t.kind == test_kind::driver) { + const std::string exe = dotted(t.path); + out += std::format(" add_executable({} {}/driver.cpp)\n", exe, t.path); + out += std::format(" target_link_libraries({} PRIVATE {})\n", exe, subject.name); + out += std::format(" add_test(NAME {} COMMAND {})\n", t.name, exe); + } else { + out += std::format(" add_test(NAME {} COMMAND {})\n", t.name, subject.name); + } + if (!t.expect.empty()) { + out += std::format(" set_tests_properties({} PROPERTIES PASS_REGULAR_EXPRESSION {})\n", + t.name, cmake_quote(t.expect)); + } + } + out += "endif()\n"; +} + +} // namespace + +std::string render_managed_block(const manifest& m) { + std::string out; + out += managed_begin; + out += "\n# Rendered from .canon.toml; canon rewrites everything between these markers.\n"; + for (const auto& t : m.targets) { + render_target(out, m, t); + } + render_tests(out, m); + out += '\n'; + out += managed_end; + out += '\n'; + return out; +} + +std::string render_cmakelists(const manifest& m) { + return std::format(R"cmake(cmake_minimum_required(VERSION {0}) +project({1} VERSION 0.1.0 LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD {2}) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +option(BUILD_TESTING "Build {1}'s tests" ON) +if(BUILD_TESTING) + enable_testing() +endif() + +# Add your own CMake outside the managed block below. + +{3})cmake", + m.cmake_minimum, m.project.name, m.cxx_standard, render_managed_block(m)); +} + +} // namespace canon diff --git a/canon/cmake.hpp b/canon/cmake.hpp new file mode 100644 index 0000000..a82072b --- /dev/null +++ b/canon/cmake.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include + +#include +#include + +namespace canon { + +// canon owns everything between these markers and re-renders it from the +// manifest as a whole, so rewriting it is idempotent. Everything outside +// belongs to the user. +inline constexpr std::string_view managed_begin = "# >>> canon:managed >>>"; +inline constexpr std::string_view managed_end = "# <<< canon:managed <<<"; + +// The managed block, markers included, rendered purely from the manifest. +// Throws std::invalid_argument if a module or test names an unknown target. +[[nodiscard]] std::string render_managed_block(const manifest&); + +// A complete CMakeLists.txt for a new project: a user-owned preamble followed +// by the managed block. +[[nodiscard]] std::string render_cmakelists(const manifest&); + +} // namespace canon diff --git a/canon/cmake.test.cpp b/canon/cmake.test.cpp new file mode 100644 index 0000000..590eda9 --- /dev/null +++ b/canon/cmake.test.cpp @@ -0,0 +1,98 @@ +#include + +#include + +#include +#include +#include + +using namespace canon; + +namespace { + +std::size_t count(std::string_view haystack, std::string_view needle) { + std::size_t n = 0; + for (auto at = haystack.find(needle); at != std::string_view::npos; + at = haystack.find(needle, at + needle.size())) { + ++n; + } + return n; +} + +manifest library() { + manifest m{.project = {project_kind::library, "libhello", "hello", "hello"}}; + m.targets.push_back( + {.name = "libhello", .type = target_type::library, .sources = {"hello/hello.cpp"}}); + 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; +} + +} // namespace + +int main() { + { + const std::string block = render_managed_block(library()); + CANON_CHECK(block.starts_with(managed_begin)); + CANON_CHECK(block.ends_with(std::string(managed_end) + "\n")); + CANON_CHECK_EQ(count(block, managed_begin), 1u); + CANON_CHECK_EQ(count(block, managed_end), 1u); + CANON_CHECK(block.contains("add_library(libhello\n hello/hello.cpp\n)\n")); + CANON_CHECK(block.contains("set_target_properties(libhello PROPERTIES PREFIX \"\")\n")); + CANON_CHECK(block.contains( + "target_include_directories(libhello PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})\n")); + CANON_CHECK(block.contains("target_compile_features(libhello PUBLIC cxx_std_23)\n")); + CANON_CHECK(block.contains(" add_executable(hello.hello.test hello/hello.test.cpp)\n" + " target_link_libraries(hello.hello.test PRIVATE libhello)\n" + " add_test(NAME hello/hello.test COMMAND hello.hello.test)\n")); + CANON_CHECK(block.contains(" add_executable(tests.basics tests/basics/driver.cpp)\n" + " target_link_libraries(tests.basics PRIVATE libhello)\n" + " add_test(NAME basics COMMAND tests.basics)\n")); + + // Rendering is a pure function of the manifest. + CANON_CHECK_EQ(render_managed_block(library()), block); + } + + { + manifest m{.project = {project_kind::executable, "tool", "tool", "tool"}}; + m.targets.push_back({.name = "tool", + .type = target_type::executable, + .sources = {"tool/tool.cpp", "tool/greet.cpp"}}); + m.modules.push_back({.path = "tool/greet", .target = "tool", .unit_test = true}); + m.tests.push_back( + {.name = "run", .kind = test_kind::run, .target = "tool", .expect = "Hi \"$x\";"}); + + const std::string block = render_managed_block(m); + CANON_CHECK(block.contains("add_executable(tool\n")); + CANON_CHECK(!block.contains("PREFIX")); + CANON_CHECK(block.contains("target_include_directories(tool PRIVATE")); + CANON_CHECK(block.contains(" add_executable(tool.greet.test tool/greet.test.cpp tool/greet.cpp)\n")); + CANON_CHECK(block.contains(" add_test(NAME run COMMAND tool)\n")); + CANON_CHECK(block.contains( + R"( set_tests_properties(run PROPERTIES PASS_REGULAR_EXPRESSION "Hi \"\$x\"\;"))")); + } + + { + manifest m = library(); + m.targets.clear(); + m.targets.push_back({.name = "libother", .type = target_type::library}); + bool threw = false; + try { + (void)render_managed_block(m); + } catch (const std::invalid_argument& e) { + threw = std::string_view(e.what()).contains("libhello"); + } + CANON_CHECK(threw); + } + + { + const std::string file = render_cmakelists(library()); + CANON_CHECK(file.starts_with("cmake_minimum_required(VERSION 3.20)\n" + "project(libhello VERSION 0.1.0 LANGUAGES CXX)\n")); + CANON_CHECK(file.contains("set(CMAKE_CXX_STANDARD 23)\n")); + CANON_CHECK(file.ends_with(render_managed_block(library()))); + } + + return details::exit_status(); +} diff --git a/canon/details/overloaded.hpp b/canon/details/overloaded.hpp new file mode 100644 index 0000000..56d8359 --- /dev/null +++ b/canon/details/overloaded.hpp @@ -0,0 +1,11 @@ +#pragma once + +namespace canon::details { + +// Builds a std::visit visitor out of lambdas. +template +struct overloaded : Fs... { + using Fs::operator()...; +}; + +} // namespace canon::details diff --git a/canon/details/testing.hpp b/canon/details/testing.hpp new file mode 100644 index 0000000..83dee81 --- /dev/null +++ b/canon/details/testing.hpp @@ -0,0 +1,68 @@ +#pragma once + +// Minimal unit-test support: CANON_CHECK / CANON_CHECK_EQ record failures and +// keep going; main() returns canon::details::exit_status(). + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace canon::details { + +inline int failures = 0; + +inline void check(bool ok, std::string_view expr, + std::source_location where = std::source_location::current()) { + if (ok) { + return; + } + ++failures; + std::println(stderr, "{}:{}: check failed: {}", where.file_name(), where.line(), expr); +} + +template +void check_equal(const Actual& actual, const Expected& expected, std::string_view expr, + std::source_location where = std::source_location::current()) { + if (actual == expected) { + return; + } + ++failures; + std::println(stderr, "{}:{}: check failed: {}\n actual: {}\n expected: {}", + where.file_name(), where.line(), expr, actual, expected); +} + +[[nodiscard]] inline int exit_status() { + return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; +} + +// A fresh directory under the system temp dir, removed on destruction. +class temp_directory { +public: + temp_directory() { + std::random_device random; + path_ = std::filesystem::temp_directory_path() / std::format("canon-test-{:08x}", random()); + std::filesystem::create_directories(path_); + } + ~temp_directory() { + std::error_code ignored; + std::filesystem::remove_all(path_, ignored); + } + temp_directory(const temp_directory&) = delete; + temp_directory& operator=(const temp_directory&) = delete; + + [[nodiscard]] const std::filesystem::path& path() const { return path_; } + +private: + std::filesystem::path path_; +}; + +} // namespace canon::details + +#define CANON_CHECK(expr) ::canon::details::check(static_cast(expr), #expr) +#define CANON_CHECK_EQ(actual, expected) \ + ::canon::details::check_equal((actual), (expected), #actual " == " #expected) diff --git a/canon/executor.cpp b/canon/executor.cpp new file mode 100644 index 0000000..8993f2a --- /dev/null +++ b/canon/executor.cpp @@ -0,0 +1,79 @@ +#include + +#include +#include + +namespace canon { + +namespace fs = std::filesystem; + +namespace { + +using result = 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) { + 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 {}; +} + +result 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 {}; +} + +result write(const make_directory& op, const fs::path& root) { + std::error_code error; + fs::create_directories(root / op.path, error); + if (error) { + return std::unexpected( + std::format("cannot create {}: {}", display(root, op.path), error.message())); + } + 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))); + } + 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))); + } + return {}; +} + +} // namespace + +result apply(const plan& p, const fs::path& root, apply_options options) { + for (const auto& op : p.operations) { + if (auto ok = std::visit([&](const auto& o) { return check(o, root); }, op); !ok) { + return ok; + } + } + if (options.dry_run) { + return {}; + } + for (const auto& op : p.operations) { + if (auto ok = std::visit([&](const auto& o) { return write(o, root); }, op); !ok) { + return ok; + } + } + return {}; +} + +} // namespace canon diff --git a/canon/executor.hpp b/canon/executor.hpp new file mode 100644 index 0000000..530752b --- /dev/null +++ b/canon/executor.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include + +#include +#include +#include + +namespace canon { + +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 = {}); + +} // namespace canon diff --git a/canon/executor.test.cpp b/canon/executor.test.cpp new file mode 100644 index 0000000..3c57802 --- /dev/null +++ b/canon/executor.test.cpp @@ -0,0 +1,76 @@ +#include + +#include + +#include +#include +#include + +using namespace canon; +namespace fs = std::filesystem; + +namespace { + +std::string read(const fs::path& file) { + std::ifstream in(file, std::ios::binary); + std::ostringstream content; + content << in.rdbuf(); + return content.str(); +} + +} // namespace + +int main() { + plan p; + p.add_file("proj/src/main.cpp", "int main() {}\n"); + p.add_file("proj/README.md", "# proj\n"); + + { + const details::temp_directory tmp; + const auto checked = apply(p, tmp.path(), {.dry_run = true}); + CANON_CHECK(checked.has_value()); + CANON_CHECK(fs::is_empty(tmp.path())); // a dry run writes nothing + } + + { + const details::temp_directory tmp; + CANON_CHECK(apply(p, tmp.path()).has_value()); + CANON_CHECK_EQ(read(tmp.path() / "proj/src/main.cpp"), "int main() {}\n"); + CANON_CHECK_EQ(read(tmp.path() / "proj/README.md"), "# proj\n"); + + // Applying again conflicts: existing files are never overwritten. + const auto again = apply(p, tmp.path()); + CANON_CHECK(!again); + if (!again) { + CANON_CHECK(again.error().contains("proj/src/main.cpp already exists")); + } + } + + { + // A conflict late in the plan stops the whole plan before anything is written. + const details::temp_directory tmp; + fs::create_directories(tmp.path() / "proj"); + std::ofstream(tmp.path() / "proj/README.md") << "mine\n"; + + const auto result = apply(p, tmp.path()); + CANON_CHECK(!result); + CANON_CHECK(!fs::exists(tmp.path() / "proj/src")); + CANON_CHECK_EQ(read(tmp.path() / "proj/README.md"), "mine\n"); + + // The dry run reports the same conflict. + CANON_CHECK(!apply(p, tmp.path(), {.dry_run = true})); + } + + { + // A file where the plan needs a directory is a conflict too. + const details::temp_directory tmp; + std::ofstream(tmp.path() / "proj") << "not a directory\n"; + const auto result = apply(p, tmp.path()); + CANON_CHECK(!result); + if (!result) { + CANON_CHECK(result.error().contains("is not a directory")); + } + } + + return details::exit_status(); +} diff --git a/canon/generate.cpp b/canon/generate.cpp new file mode 100644 index 0000000..3f81023 --- /dev/null +++ b/canon/generate.cpp @@ -0,0 +1,148 @@ +#include + +#include +#include + +#include + +namespace canon { + +namespace { + +constexpr std::string_view gitignore = "/build/\n"; + +constexpr std::string_view readme = R"md(# {{name}} + +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 +``` +)md"; + +constexpr std::string_view library_header = R"cpp(#pragma once + +#include +#include + +namespace {{ident}} { + +// Returns a greeting for name, e.g. "Hello, World!". +[[nodiscard]] std::string greeting(std::string_view name); + +} // namespace {{ident}} +)cpp"; + +constexpr std::string_view library_source = R"cpp(#include <{{stem}}/{{stem}}.hpp> + +#include + +namespace {{ident}} { + +std::string greeting(std::string_view name) { + return std::format("Hello, {}!", name); +} + +} // namespace {{ident}} +)cpp"; + +constexpr std::string_view library_unit_test = R"cpp(#include <{{stem}}/{{stem}}.hpp> + +#include +#include +#include + +int main() { + const auto actual = {{ident}}::greeting("World"); + if (actual != "Hello, World!") { + std::println(stderr, "greeting(\"World\") returned \"{}\"", actual); + return EXIT_FAILURE; + } +} +)cpp"; + +constexpr std::string_view library_driver = R"cpp(// Functional test: uses {{name}} only through its public headers, as a consumer would. + +#include <{{stem}}/{{stem}}.hpp> + +#include +#include + +int main() { + const auto greeting = {{ident}}::greeting("{{name}}"); + std::println("{}", greeting); + return greeting.starts_with("Hello") ? EXIT_SUCCESS : EXIT_FAILURE; +} +)cpp"; + +constexpr std::string_view executable_source = R"cpp(#include + +int main(int argc, char* argv[]) { + std::println("Hello, {}!", argc > 1 ? argv[1] : "World"); +} +)cpp"; + +} // namespace + +manifest new_project_manifest(const project_name& n) { + manifest m{.project = n}; + const std::string main_path = n.stem + "/" + n.stem; + + if (n.kind == project_kind::library) { + m.targets.push_back({ + .name = n.name, + .type = target_type::library, + .sources = {main_path + ".cpp"}, + }); + m.modules.push_back({.path = main_path, .target = n.name, .unit_test = true}); + m.tests.push_back({ + .name = "basics", + .kind = test_kind::driver, + .path = "tests/basics", + .target = n.name, + }); + } else { + m.targets.push_back({ + .name = n.name, + .type = target_type::executable, + .sources = {main_path + ".cpp"}, + }); + m.tests.push_back({ + .name = "basics", + .kind = test_kind::run, + .target = n.name, + .expect = "Hello, World!", + }); + } + return m; +} + +plan generate_new_project(const project_name& n) { + const manifest m = new_project_manifest(n); + const template_vars vars{{"name", n.name}, {"stem", n.stem}, {"ident", n.ident}}; + const std::filesystem::path root = n.name; + const std::filesystem::path sources = root / n.stem; + + plan p; + p.add_file(root / ".canon.toml", 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)); + + if (n.kind == project_kind::library) { + p.add_file(sources / (n.stem + ".hpp"), render(library_header, vars)); + p.add_file(sources / (n.stem + ".cpp"), render(library_source, vars)); + p.add_file(sources / (n.stem + ".test.cpp"), render(library_unit_test, vars)); + p.add_file(root / "tests" / "basics" / "driver.cpp", render(library_driver, vars)); + } else { + p.add_file(sources / (n.stem + ".cpp"), render(executable_source, vars)); + } + return p; +} + +} // namespace canon diff --git a/canon/generate.hpp b/canon/generate.hpp new file mode 100644 index 0000000..467a880 --- /dev/null +++ b/canon/generate.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include +#include +#include + +namespace canon { + +// What canon declares for a brand-new project of this name and kind. +[[nodiscard]] manifest new_project_manifest(const project_name&); + +// The plan for `canon new`. Paths start with the project directory (e.g. +// libhello/...), so the plan is applied in the directory that will contain it. +// +// Library libhello: Executable hello: +// libhello/ hello/ +// ├── .canon.toml ├── .canon.toml +// ├── .gitignore ├── .gitignore +// ├── CMakeLists.txt ├── CMakeLists.txt +// ├── README.md ├── README.md +// ├── hello/ └── hello/ +// │ ├── hello.hpp └── hello.cpp +// │ ├── hello.cpp +// │ └── hello.test.cpp +// └── tests/basics/driver.cpp +[[nodiscard]] plan generate_new_project(const project_name&); + +} // namespace canon diff --git a/canon/generate.test.cpp b/canon/generate.test.cpp new file mode 100644 index 0000000..7b04dc1 --- /dev/null +++ b/canon/generate.test.cpp @@ -0,0 +1,117 @@ +#include + +#include + +#include +#include +#include +#include + +using namespace canon; +namespace fs = std::filesystem; + +namespace { + +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; +} + +const create_file* find_file(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; +} + +// Invariants every plan must satisfy, whatever it generates. +void check_well_formed(const plan& p, std::string_view project) { + std::set directories; + for (const auto& op : p.operations) { + const fs::path& path = std::visit([](const auto& o) -> const fs::path& { return o.path; }, op); + CANON_CHECK(path.is_relative()); + CANON_CHECK(std::ranges::find(path, "..") == path.end()); + CANON_CHECK_EQ(path.begin()->generic_string(), project); + // Every parent directory is created before anything inside it. + if (path.has_parent_path()) { + CANON_CHECK(directories.contains(path.parent_path())); + } + if (std::holds_alternative(op)) { + CANON_CHECK(directories.insert(path).second); // no duplicates + } + } +} + +} // namespace + +int main() { + { + const auto name = parse_project_name(project_kind::library, "libhello"); + CANON_CHECK(name); + const plan p = generate_new_project(*name); + check_well_formed(p, "libhello"); + + CANON_CHECK(created_files(p) == std::vector({ + "libhello/.canon.toml", + "libhello/.gitignore", + "libhello/CMakeLists.txt", + "libhello/README.md", + "libhello/hello/hello.hpp", + "libhello/hello/hello.cpp", + "libhello/hello/hello.test.cpp", + "libhello/tests/basics/driver.cpp", + })); + + if (const auto* toml = find_file(p, "libhello/.canon.toml"); CANON_CHECK(toml), toml) { + CANON_CHECK(toml->content.contains("kind = \"lib\"")); + } + if (const auto* source = find_file(p, "libhello/hello/hello.cpp"); CANON_CHECK(source), source) { + CANON_CHECK(source->content.starts_with("#include \n")); + } + // Everything generated from a template was fully rendered. + for (const auto& op : p.operations) { + if (const auto* file = std::get_if(&op)) { + CANON_CHECK(!file->content.contains("{{")); + } + } + } + + { + const auto name = parse_project_name(project_kind::library, "libfoo-bar"); + CANON_CHECK(name); + const plan p = generate_new_project(*name); + const auto* header = find_file(p, "libfoo-bar/foo-bar/foo-bar.hpp"); + CANON_CHECK(header != nullptr); + if (header != nullptr) { + CANON_CHECK(header->content.contains("namespace foo_bar {")); + } + } + + { + const auto name = parse_project_name(project_kind::executable, "hello"); + CANON_CHECK(name); + const plan p = generate_new_project(*name); + check_well_formed(p, "hello"); + + CANON_CHECK(created_files(p) == std::vector({ + "hello/.canon.toml", + "hello/.gitignore", + "hello/CMakeLists.txt", + "hello/README.md", + "hello/hello/hello.cpp", + })); + const manifest m = new_project_manifest(*name); + CANON_CHECK_EQ(m.targets.size(), 1u); + CANON_CHECK(m.modules.empty()); + CANON_CHECK_EQ(m.tests.size(), 1u); + } + + return details::exit_status(); +} diff --git a/canon/manifest.cpp b/canon/manifest.cpp new file mode 100644 index 0000000..0e5108d --- /dev/null +++ b/canon/manifest.cpp @@ -0,0 +1,106 @@ +#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; + } + } + } + out += '"'; + return out; +} + +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; + } + out += ']'; + return out; +} + +} // namespace + +std::string_view to_string(target_type type) { + return type == target_type::library ? "library" : "executable"; +} + +std::string_view to_string(test_kind kind) { + return kind == test_kind::driver ? "driver" : "run"; +} + +std::string to_toml(const manifest& m) { + std::string out; + const auto line = [&out](std::string_view key, std::string_view value) { + out += std::format("{} = {}\n", key, value); + }; + + out += "# canon project manifest: what canon declared for this project.\n" + "# 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)); + + out += "\n[build]\n"; + line("backend", quote(m.backend)); + line("cmake-minimum", 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)); + if (!t.depends.empty()) { + line("depends", array(t.depends)); + } + } + + for (const auto& mod : m.modules) { + out += "\n[[modules]]\n"; + line("path", quote(mod.path)); + line("target", 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))); + if (!t.path.empty()) { + line("path", quote(t.path)); + } + line("target", quote(t.target)); + if (!t.expect.empty()) { + line("expect", quote(t.expect)); + } + } + + return out; +} + +} // namespace canon diff --git a/canon/manifest.hpp b/canon/manifest.hpp new file mode 100644 index 0000000..99d56dd --- /dev/null +++ b/canon/manifest.hpp @@ -0,0 +1,59 @@ +#pragma once + +#include + +#include +#include +#include + +namespace canon { + +enum class target_type { library, executable }; + +enum class test_kind { + driver, // tests//driver.cpp, linked against a library + run, // runs an executable target directly +}; + +[[nodiscard]] std::string_view to_string(target_type); +[[nodiscard]] std::string_view to_string(test_kind); + +struct target { + std::string name{}; + target_type type{}; + std::vector sources{}; + std::vector depends{}; +}; + +// A related file set: .hpp, .cpp and optionally .test.cpp. +struct source_module { + std::string path{}; // hello/hello + std::string target{}; // the target whose sources include .cpp + bool unit_test = false; +}; + +struct test_entry { + std::string name{}; + test_kind kind{}; + 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 +}; + +// What canon declared for a project. It records intent, not the state of the +// filesystem: users edit files by hand, and later commands compare the two. +struct manifest { + project_name project{}; + std::string spec = "P1204R0"; + std::string backend = "cmake"; + std::string cmake_minimum = "3.20"; + int cxx_standard = 23; + std::vector targets{}; + std::vector modules{}; + std::vector tests{}; +}; + +// The contents of .canon.toml. +[[nodiscard]] std::string to_toml(const manifest&); + +} // namespace canon diff --git a/canon/manifest.test.cpp b/canon/manifest.test.cpp new file mode 100644 index 0000000..146c1c7 --- /dev/null +++ b/canon/manifest.test.cpp @@ -0,0 +1,35 @@ +#include + +#include + +using namespace canon; + +int main() { + manifest m{.project = {project_kind::library, "libhello", "hello", "hello"}}; + m.targets.push_back({.name = "libhello", + .type = target_type::library, + .sources = {"hello/hello.cpp", "hello/extra.cpp"}}); + 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"}); + + 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")); + + // 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\"")); + + return details::exit_status(); +} diff --git a/canon/name.cpp b/canon/name.cpp new file mode 100644 index 0000000..21a50e4 --- /dev/null +++ b/canon/name.cpp @@ -0,0 +1,101 @@ +#include + +#include +#include +#include + +namespace canon { + +namespace { + +constexpr auto cxx_keywords = std::to_array({ + "alignas", "alignof", "and", "and_eq", "asm", "auto", + "bitand", "bitor", "bool", "break", "case", "catch", + "char", "char8_t", "char16_t", "char32_t", "class", "compl", + "concept", "const", "const_cast", "constexpr", "constinit", "continue", + "co_await", "co_return", "co_yield", "decltype", "default", "delete", + "do", "double", "dynamic_cast", "else", "enum", "explicit", + "export", "extern", "false", "float", "for", "friend", + "goto", "if", "inline", "int", "long", "mutable", + "namespace", "new", "noexcept", "not", "not_eq", "nullptr", + "operator", "or", "or_eq", "private", "protected", "public", + "register", "reinterpret_cast", "requires", "return", "short", "signed", + "sizeof", "static", "static_assert", "static_cast", "struct", "switch", + "template", "this", "thread_local", "throw", "true", "try", + "typedef", "typeid", "typename", "union", "unsigned", "using", + "virtual", "void", "volatile", "wchar_t", "while", "xor", + "xor_eq", +}); + +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()) { + return "the name is empty"; + } + if (stem.front() < 'a' || stem.front() > 'z') { + return "it must start with a lowercase letter"; + } + char previous = '\0'; + for (char c : stem) { + 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); + } + if (is_separator(c) && is_separator(previous)) { + return "'-' and '_' cannot appear next to each other"; + } + previous = c; + } + if (is_separator(previous)) { + return "it cannot end with '-' or '_'"; + } + 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"; + + std::string_view stem = name; + if (kind == project_kind::library) { + if (!name.starts_with(prefix)) { + return std::unexpected(std::format( + "library name '{}' must start with 'lib' (P1204R0), e.g. 'lib{}'", name, name)); + } + stem.remove_prefix(prefix.size()); + } else if (name.starts_with(prefix)) { + return std::unexpected(std::format( + "executable name '{}' must not start with 'lib' (P1204R0 reserves it for libraries)", + name)); + } + + if (const std::string problem = stem_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))) { + return std::unexpected(std::format( + "invalid name '{}': '{}' is a C++ keyword and cannot be a namespace", name, ident)); + } + + return project_name{ + .kind = kind, + .name = std::string(name), + .stem = std::string(stem), + .ident = std::move(ident), + }; +} + +} // namespace canon diff --git a/canon/name.hpp b/canon/name.hpp new file mode 100644 index 0000000..6d23819 --- /dev/null +++ b/canon/name.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include +#include +#include + +namespace canon { + +enum class project_kind { library, executable }; + +// "lib" or "exe": the word used on the command line and in .canon.toml. +[[nodiscard]] std::string_view to_string(project_kind); + +// A validated project name and the names P1204R0 derives from it. +struct project_name { + project_kind kind; + std::string name; // libhello, hello + std::string stem; // hello: the source directory and include prefix, + std::string ident; // hello: C++ namespace ('-' becomes '_') +}; + +// 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. +[[nodiscard]] std::expected parse_project_name(project_kind, + std::string_view name); + +} // namespace canon diff --git a/canon/name.test.cpp b/canon/name.test.cpp new file mode 100644 index 0000000..57e3ce8 --- /dev/null +++ b/canon/name.test.cpp @@ -0,0 +1,53 @@ +#include + +#include + +#include + +using namespace canon; + +int main() { + if (const auto n = parse_project_name(project_kind::library, "libhello"); CANON_CHECK(n), n) { + CANON_CHECK_EQ(n->name, "libhello"); + CANON_CHECK_EQ(n->stem, "hello"); + CANON_CHECK_EQ(n->ident, "hello"); + } + + if (const auto n = parse_project_name(project_kind::library, "libfoo-bar"); CANON_CHECK(n), n) { + CANON_CHECK_EQ(n->stem, "foo-bar"); + CANON_CHECK_EQ(n->ident, "foo_bar"); + } + + if (const auto n = parse_project_name(project_kind::executable, "hello2"); CANON_CHECK(n), n) { + CANON_CHECK_EQ(n->name, "hello2"); + CANON_CHECK_EQ(n->stem, "hello2"); + } + + { + const auto n = parse_project_name(project_kind::library, "hello"); + CANON_CHECK(!n); + CANON_CHECK(n.error().contains("must start with 'lib'")); + } + + { + const auto n = parse_project_name(project_kind::executable, "libhello"); + CANON_CHECK(!n); + CANON_CHECK(n.error().contains("must not start with 'lib'")); + } + + { + const auto n = parse_project_name(project_kind::library, "libnew"); + CANON_CHECK(!n); + CANON_CHECK(n.error().contains("C++ keyword")); + } + + for (const std::string_view bad : {"lib", "libHello", "lib2d", "libfoo-", "libfoo--bar", + "libfoo_-bar", "libfoo.bar", "lib-foo", "libfoo bar"}) { + CANON_CHECK_EQ(parse_project_name(project_kind::library, bad).has_value(), false); + } + for (const std::string_view bad : {"", "Hello", "2hello", "hello!", "new", "hello-"}) { + CANON_CHECK_EQ(parse_project_name(project_kind::executable, bad).has_value(), false); + } + + return details::exit_status(); +} diff --git a/canon/plan.cpp b/canon/plan.cpp new file mode 100644 index 0000000..6e57366 --- /dev/null +++ b/canon/plan.cpp @@ -0,0 +1,46 @@ +#include + +#include + +#include +#include + +namespace canon { + +void plan::add_directory(const std::filesystem::path& dir) { + if (dir.empty()) { + return; + } + const bool planned = std::ranges::any_of(operations, [&](const operation& op) { + const auto* existing = std::get_if(&op); + return existing != nullptr && existing->path == dir; + }); + if (planned) { + return; + } + add_directory(dir.parent_path()); + operations.emplace_back(make_directory{dir}); +} + +void plan::add_file(std::filesystem::path file, std::string content) { + add_directory(file.parent_path()); + operations.emplace_back(create_file{std::move(file), std::move(content)}); +} + +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); + } + return out; +} + +} // namespace canon diff --git a/canon/plan.hpp b/canon/plan.hpp new file mode 100644 index 0000000..155e41c --- /dev/null +++ b/canon/plan.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include +#include +#include +#include + +namespace canon { + +struct make_directory { + std::filesystem::path path; +}; + +// Never overwrites: applying fails if the file already exists. +struct create_file { + std::filesystem::path path; + 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). +// Paths are relative to the directory the plan is applied in. +struct plan { + std::vector operations; + + // Adds the directory, preceded by any parents not already in the plan. + void add_directory(const std::filesystem::path&); + + // Adds the file, preceded by any parent directories not already in the plan. + void add_file(std::filesystem::path, std::string content); +}; + +// One line per operation, e.g. " create libhello/hello/hello.hpp". +[[nodiscard]] std::string describe(const plan&); + +} // namespace canon diff --git a/canon/plan.test.cpp b/canon/plan.test.cpp new file mode 100644 index 0000000..1fa4b1f --- /dev/null +++ b/canon/plan.test.cpp @@ -0,0 +1,32 @@ +#include + +#include + +using namespace canon; + +int main() { + plan p; + p.add_file("proj/a/b/file.txt", "one"); + p.add_file("proj/a/other.txt", "two"); + p.add_directory("proj/a/b"); + + // 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"); + + const auto* file = std::get_if(&p.operations.at(3)); + CANON_CHECK(file != nullptr); + if (file != nullptr) { + CANON_CHECK_EQ(file->content, "one"); + } + + plan empty; + empty.add_directory(""); + CANON_CHECK(empty.operations.empty()); + CANON_CHECK_EQ(describe(empty), ""); + + return details::exit_status(); +} diff --git a/canon/template.cpp b/canon/template.cpp new file mode 100644 index 0000000..1b5de2c --- /dev/null +++ b/canon/template.cpp @@ -0,0 +1,36 @@ +#include + +#include +#include + +namespace canon { + +std::string render(std::string_view text, const template_vars& vars) { + std::string out; + out.reserve(text.size()); + + for (;;) { + const auto open = text.find("{{"); + if (open == std::string_view::npos) { + out += text; + return out; + } + out += text.substr(0, open); + + const auto close = text.find("}}", open + 2); + if (close == std::string_view::npos) { + throw std::invalid_argument( + std::format("unterminated placeholder in template near '{}'", text.substr(open, 20))); + } + + const std::string_view key = text.substr(open + 2, close - open - 2); + const auto var = vars.find(key); + if (var == vars.end()) { + throw std::invalid_argument(std::format("template uses unknown variable '{}'", key)); + } + out += var->second; + text.remove_prefix(close + 2); + } +} + +} // namespace canon diff --git a/canon/template.hpp b/canon/template.hpp new file mode 100644 index 0000000..c309fb3 --- /dev/null +++ b/canon/template.hpp @@ -0,0 +1,17 @@ +#pragma once + +#include +#include +#include +#include + +namespace canon { + +using template_vars = std::map>; + +// Replaces every {{key}} in text with vars[key]. +// Throws std::invalid_argument for an unknown key or an unterminated placeholder: +// templates are built into canon, so either one is a bug that tests should catch. +[[nodiscard]] std::string render(std::string_view text, const template_vars& vars); + +} // namespace canon diff --git a/canon/template.test.cpp b/canon/template.test.cpp new file mode 100644 index 0000000..cdee3ca --- /dev/null +++ b/canon/template.test.cpp @@ -0,0 +1,35 @@ +#include + +#include + +#include + +using namespace canon; + +int main() { + const template_vars vars{{"name", "libhello"}, {"stem", "hello"}}; + + CANON_CHECK_EQ(render("", vars), ""); + CANON_CHECK_EQ(render("no placeholders { } }}", vars), "no placeholders { } }}"); + CANON_CHECK_EQ(render("{{name}}", vars), "libhello"); + CANON_CHECK_EQ(render("#include <{{stem}}/{{stem}}.hpp>", vars), "#include "); + CANON_CHECK_EQ(render("CMake ${VAR} is left alone", vars), "CMake ${VAR} is left alone"); + + bool threw = false; + try { + (void)render("{{nmae}}", vars); + } catch (const std::invalid_argument& e) { + threw = std::string_view(e.what()).contains("nmae"); + } + CANON_CHECK(threw); + + threw = false; + try { + (void)render("oops {{name", vars); + } catch (const std::invalid_argument&) { + threw = true; + } + CANON_CHECK(threw); + + return details::exit_status(); +} diff --git a/tests/e2e/check.cmake b/tests/e2e/check.cmake new file mode 100644 index 0000000..67ce54d --- /dev/null +++ b/tests/e2e/check.cmake @@ -0,0 +1,41 @@ +# 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. +# +# cmake -DCANON= -DWORK_DIR= -DCXX= -P check.cmake + +cmake_minimum_required(VERSION 3.20) + +foreach(var CANON WORK_DIR CXX) + if(NOT DEFINED ${var}) + message(FATAL_ERROR "${var} is not set") + endif() +endforeach() + +file(REMOVE_RECURSE ${WORK_DIR}) +file(MAKE_DIRECTORY ${WORK_DIR}) + +function(run) + execute_process(COMMAND ${ARGN} + RESULT_VARIABLE status + OUTPUT_VARIABLE output + ERROR_VARIABLE errors) + if(NOT status EQUAL 0) + string(JOIN " " command ${ARGN}) + message(FATAL_ERROR "Command failed (${status}): ${command}\n${output}${errors}") + 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}) + run(${CMAKE_COMMAND} -S ${project} -B ${project}/build + -DCMAKE_CXX_COMPILER=${CXX} -DCMAKE_BUILD_TYPE=Debug) + run(${CMAKE_COMMAND} --build ${project}/build) + run(${CMAKE_CTEST_COMMAND} --test-dir ${project}/build --output-on-failure) +endforeach() diff --git a/tests/golden/check.cmake b/tests/golden/check.cmake new file mode 100644 index 0000000..3365374 --- /dev/null +++ b/tests/golden/check.cmake @@ -0,0 +1,83 @@ +# Scaffolds each project kind and compares the result byte for byte with the +# expected trees next to this script. +# +# cmake -DCANON= -DWORK_DIR= [-DUPDATE=ON] -P check.cmake +# +# UPDATE=ON replaces the expected trees with canon's current output; review the +# diff before committing it. + +cmake_minimum_required(VERSION 3.20) + +foreach(var CANON WORK_DIR) + if(NOT DEFINED ${var}) + message(FATAL_ERROR "${var} is not set") + endif() +endforeach() + +set(golden_dir ${CMAKE_CURRENT_LIST_DIR}) +file(REMOVE_RECURSE ${WORK_DIR}) +file(MAKE_DIRECTORY ${WORK_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}) + + if(UPDATE) + file(REMOVE_RECURSE ${expected}) + file(COPY ${actual} DESTINATION ${golden_dir}) + message(STATUS "Updated ${expected}") + continue() + endif() + + file(GLOB_RECURSE actual_files RELATIVE ${actual} ${actual}/*) + file(GLOB_RECURSE expected_files RELATIVE ${expected} ${expected}/*) + list(SORT actual_files) + list(SORT expected_files) + + if(NOT actual_files STREQUAL expected_files) + message(SEND_ERROR "${name}: files differ from golden\n" + " actual: ${actual_files}\n" + " expected: ${expected_files}") + set(failed TRUE) + continue() + endif() + + foreach(file ${expected_files}) + execute_process( + 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})") + set(failed TRUE) + endif() + endforeach() +endforeach() + +if(failed) + message(FATAL_ERROR "Golden check failed") +endif() diff --git a/tests/golden/hello/.canon.toml b/tests/golden/hello/.canon.toml new file mode 100644 index 0000000..0ec1ecd --- /dev/null +++ b/tests/golden/hello/.canon.toml @@ -0,0 +1,24 @@ +# 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"] + +[[tests]] +name = "basics" +kind = "run" +target = "hello" +expect = "Hello, World!" diff --git a/tests/golden/hello/.gitignore b/tests/golden/hello/.gitignore new file mode 100644 index 0000000..84c048a --- /dev/null +++ b/tests/golden/hello/.gitignore @@ -0,0 +1 @@ +/build/ diff --git a/tests/golden/hello/CMakeLists.txt b/tests/golden/hello/CMakeLists.txt new file mode 100644 index 0000000..d464c36 --- /dev/null +++ b/tests/golden/hello/CMakeLists.txt @@ -0,0 +1,28 @@ +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 +) +target_include_directories(hello PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) + +if(BUILD_TESTING) + 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/hello/README.md new file mode 100644 index 0000000..267b7c3 --- /dev/null +++ b/tests/golden/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/hello/hello/hello.cpp b/tests/golden/hello/hello/hello.cpp new file mode 100644 index 0000000..d1aadd6 --- /dev/null +++ b/tests/golden/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/libhello/.canon.toml new file mode 100644 index 0000000..983f030 --- /dev/null +++ b/tests/golden/libhello/.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 = "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"] + +[[modules]] +path = "hello/hello" +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/libhello/.gitignore new file mode 100644 index 0000000..84c048a --- /dev/null +++ b/tests/golden/libhello/.gitignore @@ -0,0 +1 @@ +/build/ diff --git a/tests/golden/libhello/CMakeLists.txt b/tests/golden/libhello/CMakeLists.txt new file mode 100644 index 0000000..67e6400 --- /dev/null +++ b/tests/golden/libhello/CMakeLists.txt @@ -0,0 +1,35 @@ +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 +) +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(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/libhello/README.md new file mode 100644 index 0000000..a844dca --- /dev/null +++ b/tests/golden/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/libhello/hello/hello.cpp b/tests/golden/libhello/hello/hello.cpp new file mode 100644 index 0000000..e7b92ce --- /dev/null +++ b/tests/golden/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/libhello/hello/hello.hpp b/tests/golden/libhello/hello/hello.hpp new file mode 100644 index 0000000..0d8d5ba --- /dev/null +++ b/tests/golden/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/libhello/hello/hello.test.cpp b/tests/golden/libhello/hello/hello.test.cpp new file mode 100644 index 0000000..5cafec8 --- /dev/null +++ b/tests/golden/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/libhello/tests/basics/driver.cpp b/tests/golden/libhello/tests/basics/driver.cpp new file mode 100644 index 0000000..757d317 --- /dev/null +++ b/tests/golden/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; +}