From a82bdf4734eec646c42074bab00b55ef478f8635 Mon Sep 17 00:00:00 2001 From: paulhorn Date: Thu, 17 Sep 2026 19:01:37 +0200 Subject: [PATCH] Add template overrides: canon templates and templates export Every generated source file, README.md and .gitignore now comes from a template that a file of the same name can replace: - /.canon/templates/ for that project, which wins over - ~/.config/canon/templates/ ($XDG_CONFIG_HOME respected) for all projects; canon new reads only these canon templates lists each template, where it comes from and the placeholders it can use. canon templates export [...] [--user] copies built-in texts as a starting point and never overwrites. Overrides are checked strictly: an unknown file name or a placeholder the template does not provide is an error naming the file; hidden files are ignored. Generators take a template_set, so they stay pure. Also: - executor: a symlink to a directory counts as a directory (macOS /var, linked config directories) - plan: add_directory stops at the filesystem root - project: find_project_root, shared by load_project and templates - tests: a templates scenario; scenarios run canon with their own empty XDG_CONFIG_HOME so personal templates cannot affect results Co-Authored-By: Claude Opus 5 --- CMakeLists.txt | 3 +- README.md | 42 ++- canon/add.cpp | 32 +- canon/add.hpp | 13 +- canon/canon.cpp | 128 ++++++- canon/cli.cpp | 37 +- canon/cli.hpp | 13 +- canon/cli.test.cpp | 17 + canon/executor.cpp | 3 +- canon/executor.test.cpp | 10 + canon/generate.cpp | 17 +- canon/generate.hpp | 3 +- canon/plan.cpp | 2 +- canon/project.cpp | 46 ++- canon/project.hpp | 8 +- canon/template.cpp | 19 ++ canon/template.hpp | 11 +- canon/template.test.cpp | 9 + canon/templates.cpp | 321 ++++++++++++++++++ canon/templates.hpp | 184 +++++----- canon/templates.test.cpp | 179 ++++++++++ tests/golden/templates/libhello/.canon.toml | 34 ++ .../libhello/.canon/templates/module.hpp | 6 + tests/golden/templates/libhello/.gitignore | 1 + .../golden/templates/libhello/CMakeLists.txt | 40 +++ tests/golden/templates/libhello/README.md | 12 + .../templates/libhello/hello/details/core.cpp | 5 + .../templates/libhello/hello/details/core.hpp | 6 + .../libhello/hello/details/core.test.cpp | 6 + .../golden/templates/libhello/hello/hello.cpp | 11 + .../golden/templates/libhello/hello/hello.hpp | 12 + .../templates/libhello/hello/hello.test.cpp | 13 + .../libhello/tests/basics/driver.cpp | 12 + tests/scenarios.cmake | 41 ++- 34 files changed, 1123 insertions(+), 173 deletions(-) create mode 100644 canon/templates.cpp create mode 100644 canon/templates.test.cpp create mode 100644 tests/golden/templates/libhello/.canon.toml create mode 100644 tests/golden/templates/libhello/.canon/templates/module.hpp create mode 100644 tests/golden/templates/libhello/.gitignore create mode 100644 tests/golden/templates/libhello/CMakeLists.txt create mode 100644 tests/golden/templates/libhello/README.md create mode 100644 tests/golden/templates/libhello/hello/details/core.cpp create mode 100644 tests/golden/templates/libhello/hello/details/core.hpp create mode 100644 tests/golden/templates/libhello/hello/details/core.test.cpp create mode 100644 tests/golden/templates/libhello/hello/hello.cpp create mode 100644 tests/golden/templates/libhello/hello/hello.hpp create mode 100644 tests/golden/templates/libhello/hello/hello.test.cpp create mode 100644 tests/golden/templates/libhello/tests/basics/driver.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index afa4f3d..260b59e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -23,6 +23,7 @@ add_library(canon.core STATIC canon/plan.cpp canon/project.cpp canon/template.cpp + canon/templates.cpp canon/toml.cpp ) target_include_directories(canon.core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) @@ -39,7 +40,7 @@ if(BUILD_TESTING) enable_testing() # Unit tests live next to the code they test: canon/.test.cpp. - foreach(unit add cli cmake doctor executor generate manifest name plan project template toml) + foreach(unit add cli cmake doctor executor generate manifest name plan project template templates 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 ec51699..6bcbb01 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # canon Scaffolds C++ projects that follow [P1204R0](https://wg21.link/p1204r0) -(Canonical Project Structure), keeps adding to them, and checks them. Written in +(Canonical Project Structure), keeps adding to them, and checks them. The files +it generates come from templates you can replace. Written in C++23, no third-party dependencies. ```bash @@ -34,10 +35,15 @@ canon add dep Boost::filesystem --package Boost # when the package name diffe # Checks canon doctor # compare the files with .canon.toml and P1204R0 +# Templates +canon templates # what each generated file is made from +canon templates export module.hpp # copy the built-in text to .canon/templates/ to edit +canon templates export --user # ... or all of them to ~/.config/canon/templates/ + canon add module core --dry-run # any add command: show the changes, write nothing ``` -`add` and `doctor` work from any directory inside the project. Every generated +`add`, `doctor` and `templates` work from any directory inside the project. Every generated project builds and passes its tests out of the box: ```bash @@ -98,6 +104,28 @@ explicitly with `canon add dep`. Hidden directories and top-level build output (`build*`, `cmake-build-*`) are skipped. +### Templates + +Every generated source file, `README.md` and `.gitignore` comes from a +template with `{{placeholders}}` such as `{{namespace}}`. To change one, put a +file with the template's name in a template directory. canon looks in, most +specific first: + +1. `.canon/templates/` in the project (commit it to share it with your team); +2. `~/.config/canon/templates/` (or `$XDG_CONFIG_HOME/canon/templates/`) for + all your projects. `canon new` only reads these, since the project does not + exist yet. + +`canon templates` lists every template, where it currently comes from and which +placeholders it can use. `canon templates export [...] [--user]` copies +the built-in text as a starting point and never overwrites an existing copy. + +Templates are checked strictly: a file whose name is not a template (a typo such +as `libary.hpp`) or a placeholder the template does not provide stops the +command with an error naming the file. Hidden files such as `.DS_Store` are +ignored. `CMakeLists.txt` and `.canon.toml` are not templates: canon renders +them from the manifest. + | File | Role | |---|---| | `canon/canon.cpp` | `main`: wires the command line to the generators, executor and doctor | @@ -106,21 +134,23 @@ skipped. | `canon/generate.*` | the manifest and plan for `canon new` | | `canon/add.*` | the plans for every `canon add` command | | `canon/doctor.*` | the checks behind `canon doctor` | -| `canon/templates.hpp` | the contents of generated files | +| `canon/templates.*` | built-in templates, overrides from template directories, export | | `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 | +| `canon/template.*` | `{{key}}` substitution and placeholder checks | ## Tests - **Unit tests**: `canon/.test.cpp`, next to the code they cover. - **Scenarios**: `tests/scenarios.cmake` runs command sequences (for example `new lib` followed by several `add` commands, including ones that must fail) - and ends each with `canon doctor`. Both of the following use them. + and ends each with `canon doctor`. canon runs with its own empty + `XDG_CONFIG_HOME`, so your personal templates never affect the results. Both + of the following use them. - **Golden tests**: `tests/golden//` holds the exact expected result of each scenario. After an intentional template change, regenerate the copies and review the diff: @@ -133,7 +163,7 @@ skipped. 1. ✅ `new lib`, `new exe`, plan/apply, `--dry-run`, manifest 2. ✅ `add module`, `add unit-test`, reading `.canon.toml`, managed-block updates 3. ✅ `add target`, `add test`, `add dep`, `doctor` -4. User template overrides +4. ✅ Template overrides: `templates`, `templates export`, project and user templates ## License diff --git a/canon/add.cpp b/canon/add.cpp index 2b01d6a..7468aed 100644 --- a/canon/add.cpp +++ b/canon/add.cpp @@ -1,7 +1,6 @@ #include #include -#include #include #include @@ -144,7 +143,8 @@ std::expected finish(plan result, const project& p, const man } // namespace -std::expected plan_add_module(const project& p, const module_request& request) { +std::expected plan_add_module(const project& p, const module_request& request, + const template_set& templates) { manifest updated = p.declared; const auto owner = find_target(updated, request.target); if (!owner) { @@ -166,15 +166,16 @@ std::expected plan_add_module(const project& p, const module_ const template_vars vars = module_vars(*names); plan result; - result.add_file(names->include, render(templates::module_header, vars)); - result.add_file(source, render(templates::module_source, vars)); + result.add_file(names->include, templates.render(template_id::module_header, vars)); + result.add_file(source, templates.render(template_id::module_source, vars)); if (request.unit_test) { - result.add_file(names->path + ".test.cpp", render(templates::module_unit_test, vars)); + result.add_file(names->path + ".test.cpp", templates.render(template_id::module_unit_test, vars)); } return finish(std::move(result), p, updated); } -std::expected plan_add_unit_test(const project& p, const module_request& request) { +std::expected plan_add_unit_test(const project& p, const module_request& request, + const template_set& templates) { manifest updated = p.declared; const auto owner = find_target(updated, request.target); if (!owner) { @@ -196,11 +197,12 @@ std::expected plan_add_unit_test(const project& p, const modu mod->unit_test = true; plan result; - result.add_file(names->path + ".test.cpp", render(templates::module_unit_test, module_vars(*names))); + result.add_file(names->path + ".test.cpp", templates.render(template_id::module_unit_test, module_vars(*names))); return finish(std::move(result), p, updated); } -std::expected plan_add_target(const project& p, const target_request& request) { +std::expected plan_add_target(const project& p, const target_request& request, + const template_set& templates) { const auto names = parse_project_name(request.kind, request.name); if (!names) { return std::unexpected(names.error()); @@ -228,15 +230,15 @@ std::expected plan_add_target(const project& p, const target_ if (request.kind == project_kind::library) { added.type = target_type::library; - result.add_file(main_path + ".hpp", render(templates::library_header, vars)); - result.add_file(main_path + ".cpp", render(templates::library_source, vars)); + result.add_file(main_path + ".hpp", templates.render(template_id::library_header, vars)); + result.add_file(main_path + ".cpp", templates.render(template_id::library_source, vars)); if (request.with_test) { - result.add_file(main_path + ".test.cpp", render(templates::library_unit_test, vars)); + result.add_file(main_path + ".test.cpp", templates.render(template_id::library_unit_test, vars)); } updated.modules.push_back({.path = main_path, .target = names->name, .unit_test = request.with_test}); } else { added.type = target_type::executable; - result.add_file(main_path + ".cpp", render(templates::executable_source, vars)); + result.add_file(main_path + ".cpp", templates.render(template_id::executable_source, vars)); const auto own = std::ranges::find(updated.targets, updated.project.name, &target::name); if (own != updated.targets.end() && own->type == target_type::library) { @@ -254,7 +256,8 @@ std::expected plan_add_target(const project& p, const target_ return finish(std::move(result), p, updated); } -std::expected plan_add_test(const project& p, const test_request& request) { +std::expected plan_add_test(const project& p, const test_request& request, + const template_set& templates) { if (const std::string problem = name_problem(request.name); !problem.empty()) { return fail(std::format("invalid test name '{}': {}", request.name, problem)); } @@ -288,8 +291,7 @@ std::expected plan_add_test(const project& p, const test_requ const bool has_main_header = std::ranges::contains(updated.modules, main_path, &source_module::path); const template_vars vars{{"target", (*subject)->name}, {"include", main_path + ".hpp"}}; result.add_file(added.path + "/driver.cpp", - render(has_main_header ? templates::test_driver : templates::test_driver_without_include, - vars)); + templates.render(has_main_header ? template_id::test_driver : template_id::test_driver_without_include, vars)); } else { added.kind = test_kind::run; } diff --git a/canon/add.hpp b/canon/add.hpp index e1c782f..2bdebfc 100644 --- a/canon/add.hpp +++ b/canon/add.hpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -41,18 +42,22 @@ struct dep_request { // /.hpp and .cpp (and .test.cpp); the source joins the target. // Directories in path become nested namespaces. -[[nodiscard]] std::expected plan_add_module(const project&, const module_request&); +[[nodiscard]] std::expected plan_add_module(const project&, const module_request&, + const template_set& = builtin_templates()); // /.test.cpp for a declared module without one. unit_test is ignored. -[[nodiscard]] std::expected plan_add_unit_test(const project&, const module_request&); +[[nodiscard]] std::expected plan_add_unit_test(const project&, const module_request&, + const template_set& = builtin_templates()); // Another library or executable with its own source directory, laid out like // `canon new`. A new executable links the project's library, if it has one. -[[nodiscard]] std::expected plan_add_target(const project&, const target_request&); +[[nodiscard]] std::expected plan_add_target(const project&, const target_request&, + const template_set& = builtin_templates()); // A functional test: tests//driver.cpp linked against a library, or a // run of an executable. Both take args and expect. -[[nodiscard]] std::expected plan_add_test(const project&, const test_request&); +[[nodiscard]] std::expected plan_add_test(const project&, const test_request&, + const template_set& = builtin_templates()); // Links a library of this project, or an imported target of an installed // package (adding its find_package()). Refuses cycles and executables. diff --git a/canon/canon.cpp b/canon/canon.cpp index d05b26d..ffba06b 100644 --- a/canon/canon.cpp +++ b/canon/canon.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include @@ -14,7 +15,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -77,6 +80,24 @@ bool apply_and_report(const canon::plan& plan, const fs::path& root, bool dry_ru return true; } +std::optional user_template_directory() { + const char* xdg = std::getenv("XDG_CONFIG_HOME"); + const char* home = std::getenv("HOME"); + return canon::user_template_directory(xdg != nullptr ? xdg : "", home != nullptr ? home : ""); +} + +// Where templates are read from, most specific first: the project's, then the user's. +std::vector template_directories(const std::optional& project_root) { + std::vector directories; + if (project_root) { + directories.push_back(*project_root / canon::project_template_directory); + } + if (const auto user = user_template_directory()) { + directories.push_back(*user); + } + return directories; +} + int run_new(const canon::new_command& cmd) { const auto name = canon::parse_project_name(cmd.kind, cmd.name); if (!name) { @@ -97,7 +118,12 @@ int run_new(const canon::new_command& cmd) { return EXIT_FAILURE; } - if (!apply_and_report(canon::generate_new_project(*name), directory, cmd.options.dry_run)) { + const auto templates = canon::load_templates(template_directories(std::nullopt)); + if (!templates) { + std::println(stderr, "canon: {}", templates.error()); + return EXIT_FAILURE; + } + if (!apply_and_report(canon::generate_new_project(*name, *templates), directory, cmd.options.dry_run)) { return EXIT_FAILURE; } if (cmd.options.dry_run) { @@ -118,7 +144,12 @@ int run_add(const canon::project_options& options, Planner make_plan, std::strin std::println(stderr, "canon: {}", project.error()); return EXIT_FAILURE; } - const std::expected plan = make_plan(*project); + const auto templates = canon::load_templates(template_directories(project->root)); + if (!templates) { + std::println(stderr, "canon: {}", templates.error()); + return EXIT_FAILURE; + } + const std::expected plan = make_plan(*project, *templates); if (!plan) { std::println(stderr, "canon: {}", plan.error()); return EXIT_FAILURE; @@ -159,6 +190,87 @@ int run_doctor(const canon::doctor_command& cmd) { return errors > 0 ? EXIT_FAILURE : EXIT_SUCCESS; } +int run_templates(const canon::templates_command& cmd) { + const auto root = canon::find_project_root(cmd.directory); + const auto directories = template_directories(root); + const auto templates = canon::load_templates(directories); + if (!templates) { + std::println(stderr, "canon: {}", templates.error()); + return EXIT_FAILURE; + } + + const std::optional project_directory = + root ? std::optional(*root / canon::project_template_directory) : std::nullopt; + const auto user_directory = user_template_directory(); + + std::println("Template directories, most specific first:"); + std::error_code ignored; + for (const auto& [label, directory] : {std::pair{"project", project_directory}, std::pair{"user", user_directory}}) { + if (directory) { + std::println(" {:<8} {}{}", label, display_path(*directory), + fs::is_directory(*directory, ignored) ? "" : " (does not exist)"); + } + } + if (!root) { + std::println(" (not inside a canon project, so no project templates)"); + } + + std::println("\nTemplates:"); + for (const canon::template_info& t : canon::template_catalog()) { + const fs::path& source = templates->source(t.id); + const std::string_view from = source.empty() ? "built-in" + : project_directory && source.parent_path() == *project_directory ? "project" + : "user"; + std::string keys; + for (const std::string_view key : t.placeholders) { + keys += std::format(" {{{{{}}}}}", key); + } + std::println(" {:<28}{:<10}{}\n {:<38}{}", t.file, from, t.used_for, "", keys.substr(1)); + } + std::println("\nTo change one, export it and edit the copy:\n" + " canon templates export for this project\n" + " canon templates export --user for all your projects"); + return EXIT_SUCCESS; +} + +int run_export_templates(const canon::export_templates_command& cmd) { + fs::path root; // what the plan's paths are relative to + fs::path directory; // where the templates go + if (cmd.user) { + const auto user = user_template_directory(); + if (!user) { + std::println(stderr, "canon: cannot find your config directory; set HOME or XDG_CONFIG_HOME"); + return EXIT_FAILURE; + } + directory = *user; // absolute, so the root stays empty + } else { + const auto project_root = canon::find_project_root(cmd.options.directory); + if (!project_root) { + std::println(stderr, "canon: not inside a canon project; use --user to export templates for all " + "your projects"); + return EXIT_FAILURE; + } + root = *project_root; + directory = canon::project_template_directory; + } + + const auto plan = canon::plan_export_templates(cmd.names, directory); + if (!plan) { + std::println(stderr, "canon: {}", plan.error()); + return EXIT_FAILURE; + } + if (!root.empty()) { + std::println("{}:", display_path(root)); + } + if (!apply_and_report(*plan, root, cmd.options.dry_run)) { + return EXIT_FAILURE; + } + if (!cmd.options.dry_run) { + std::println("\nEdit the copies; canon uses them from now on. 'canon templates' shows what is in effect."); + } + return EXIT_SUCCESS; +} + constexpr std::string_view rebuild = "Rebuild with: cmake --build build"; std::string target_suffix(const std::string& target) { @@ -189,19 +301,19 @@ int main(int argc, char* argv[]) try { [](const new_command& cmd) { return run_new(cmd); }, [](const add_module_command& cmd) { return run_add( - cmd.options, [&](const project& p) { return plan_add_module(p, cmd.request); }, + cmd.options, [&](const project& p, const template_set& t) { return plan_add_module(p, cmd.request, t); }, std::format("Added module {}{}. {}", cmd.request.path, target_suffix(cmd.request.target), rebuild)); }, [](const add_unit_test_command& cmd) { return run_add( - cmd.options, [&](const project& p) { return plan_add_unit_test(p, cmd.request); }, + cmd.options, [&](const project& p, const template_set& t) { return plan_add_unit_test(p, cmd.request, t); }, std::format("Added a unit test for module {}. {}", cmd.request.path, rebuild)); }, [](const add_target_command& cmd) { const bool library = cmd.request.kind == project_kind::library; return run_add( - cmd.options, [&](const project& p) { return plan_add_target(p, cmd.request); }, + cmd.options, [&](const project& p, const template_set& t) { return plan_add_target(p, cmd.request, t); }, library ? std::format("Added library {}. Link it into another target with:\n" " canon add dep {} --target ", cmd.request.name, cmd.request.name) @@ -209,18 +321,20 @@ int main(int argc, char* argv[]) try { }, [](const add_test_command& cmd) { return run_add( - cmd.options, [&](const project& p) { return plan_add_test(p, cmd.request); }, + cmd.options, [&](const project& p, const template_set& t) { return plan_add_test(p, cmd.request, t); }, std::format("Added test {}. Build and run it with:\n" " cmake --build build && ctest --test-dir build -R '^{}$'", cmd.request.name, cmd.request.name)); }, [](const add_dep_command& cmd) { return run_add( - cmd.options, [&](const project& p) { return plan_add_dep(p, cmd.request); }, + cmd.options, [&](const project& p, const template_set&) { return plan_add_dep(p, cmd.request); }, std::format("Added dependency {}{}. {}", cmd.request.dependency, target_suffix(cmd.request.target), rebuild)); }, [](const doctor_command& cmd) { return run_doctor(cmd); }, + [](const templates_command& cmd) { return run_templates(cmd); }, + [](const export_templates_command& cmd) { return run_export_templates(cmd); }, }, *command); } catch (const std::exception& e) { diff --git a/canon/cli.cpp b/canon/cli.cpp index f592509..0042ec4 100644 --- a/canon/cli.cpp +++ b/canon/cli.cpp @@ -23,6 +23,7 @@ struct options { std::vector args; bool dry_run = false; bool no_test = false; + bool user = false; std::vector used; // every option given, e.g. "--dir" }; @@ -202,6 +203,26 @@ std::expected parse_doctor(positionals positional, const o return doctor_command{.directory = project_options_of(opts).directory}; } +std::expected parse_templates(positionals positional, const options& opts) { + if (positional.size() == 1) { + if (auto ok = check_options(opts, "templates", {"--dir"}); !ok) { + return std::unexpected(ok.error()); + } + return templates_command{.directory = project_options_of(opts).directory}; + } + if (positional[1] != "export") { + return fail(std::format("unknown templates command '{}' (expected export)", positional[1])); + } + if (auto ok = check_options(opts, "templates export", {"--dir", "--dry-run", "--user"}); !ok) { + return std::unexpected(ok.error()); + } + return export_templates_command{ + .names = std::vector(positional.begin() + 2, positional.end()), + .user = opts.user, + .options = project_options_of(opts), + }; +} + } // namespace std::expected parse_command_line(std::span args) { @@ -220,8 +241,8 @@ std::expected parse_command_line(std::span(i) + 1, args.end()); break; } - if (arg == "--dry-run" || arg == "--no-test") { - (arg == "--dry-run" ? opts.dry_run : opts.no_test) = true; + if (arg == "--dry-run" || arg == "--no-test" || arg == "--user") { + (arg == "--dry-run" ? opts.dry_run : arg == "--no-test" ? opts.no_test : opts.user) = true; opts.used.push_back(arg); continue; } @@ -262,6 +283,9 @@ std::expected parse_command_line(std::span...] + Copy built-in templates to .canon/templates/ of the + project you are in (with --user, to your + ~/.config/canon/templates/) to edit them there + Options: --dir new: create the project inside others: look for the project from @@ -296,6 +328,7 @@ Options: --expect add test: the output must match --package add dep: the find_package() name, if it is not the part before '::' + --user templates export: export for all your projects -h, --help Show this help --version Show canon's version diff --git a/canon/cli.hpp b/canon/cli.hpp index c689a3e..fd4586d 100644 --- a/canon/cli.hpp +++ b/canon/cli.hpp @@ -9,6 +9,7 @@ #include #include #include +#include namespace canon { @@ -56,9 +57,19 @@ struct doctor_command { std::filesystem::path directory = "."; }; +struct templates_command { + std::filesystem::path directory = "."; // where to look for a project's templates +}; + +struct export_templates_command { + std::vector names{}; // template file names; empty exports all + bool user = false; // to the user template directory instead of the project's + project_options options{}; +}; + using command = std::variant; + doctor_command, templates_command, export_templates_command>; // Parses the arguments after the program name. Syntax is checked here, // including options a command does not take; names and paths are validated by diff --git a/canon/cli.test.cpp b/canon/cli.test.cpp index 37a1a4d..8ed81bc 100644 --- a/canon/cli.test.cpp +++ b/canon/cli.test.cpp @@ -103,6 +103,23 @@ int main() { CANON_CHECK_EQ(d->directory.generic_string(), "proj"); } + if (const auto t = as(parse({"templates", "--dir", "proj"}))) { + CANON_CHECK_EQ(t->directory.generic_string(), "proj"); + } + if (const auto e = as(parse({"templates", "export"}))) { + CANON_CHECK(e->names.empty()); + CANON_CHECK(!e->user); + } + if (const auto e = as( + parse({"templates", "export", "module.hpp", "gitignore", "--user", "--dry-run"}))) { + CANON_CHECK(e->names == std::vector({"module.hpp", "gitignore"})); + CANON_CHECK(e->user); + CANON_CHECK(e->options.dry_run); + } + CANON_CHECK(fails_with(parse({"templates", "import"}), "unknown templates command 'import'")); + CANON_CHECK(fails_with(parse({"templates", "--user"}), "--user does not apply to 'canon templates'")); + CANON_CHECK(fails_with(parse({"add", "module", "core", "--user"}), "--user does not apply to 'canon add module'")); + 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/executor.cpp b/canon/executor.cpp index 6ddae11..62281a1 100644 --- a/canon/executor.cpp +++ b/canon/executor.cpp @@ -20,7 +20,8 @@ std::string display(const fs::path& root, const fs::path& path) { checked check(const make_directory& op, const fs::path& root) { std::error_code ignored; - const auto status = fs::symlink_status(root / op.path, ignored); + // Follows symlinks: a link to a directory (macOS's /var, a linked config dir) is a directory. + const auto status = fs::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))); diff --git a/canon/executor.test.cpp b/canon/executor.test.cpp index f1cb824..aec980b 100644 --- a/canon/executor.test.cpp +++ b/canon/executor.test.cpp @@ -114,6 +114,16 @@ int main() { CANON_CHECK(!apply(missing, tmp.path())); } + { + // A symlink to a directory counts as that directory. + const details::temp_directory tmp; + fs::create_directories(tmp.path() / "real"); + fs::create_directory_symlink(tmp.path() / "real", tmp.path() / "proj"); + const auto report = apply(p, tmp.path()); + CANON_CHECK(report); + CANON_CHECK(fs::exists(tmp.path() / "real/src/main.cpp")); + } + { // 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 5bd2799..41b331b 100644 --- a/canon/generate.cpp +++ b/canon/generate.cpp @@ -1,7 +1,6 @@ #include #include -#include #include namespace canon { @@ -39,7 +38,7 @@ manifest new_project_manifest(const project_name& n) { return m; } -plan generate_new_project(const project_name& n) { +plan generate_new_project(const project_name& n, const template_set& templates) { 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; @@ -47,17 +46,17 @@ plan generate_new_project(const project_name& n) { plan p; p.add_file(root / manifest_file, to_toml(m)); - p.add_file(root / ".gitignore", std::string(templates::gitignore)); + p.add_file(root / ".gitignore", templates.render(template_id::gitignore, vars)); p.add_file(root / "CMakeLists.txt", render_cmakelists(m)); - p.add_file(root / "README.md", render(templates::readme, vars)); + p.add_file(root / "README.md", templates.render(template_id::readme, vars)); if (n.kind == project_kind::library) { - p.add_file(sources / (n.stem + ".hpp"), render(templates::library_header, vars)); - p.add_file(sources / (n.stem + ".cpp"), render(templates::library_source, vars)); - p.add_file(sources / (n.stem + ".test.cpp"), render(templates::library_unit_test, vars)); - p.add_file(root / "tests" / "basics" / "driver.cpp", render(templates::library_driver, vars)); + p.add_file(sources / (n.stem + ".hpp"), templates.render(template_id::library_header, vars)); + p.add_file(sources / (n.stem + ".cpp"), templates.render(template_id::library_source, vars)); + p.add_file(sources / (n.stem + ".test.cpp"), templates.render(template_id::library_unit_test, vars)); + p.add_file(root / "tests" / "basics" / "driver.cpp", templates.render(template_id::library_driver, vars)); } else { - p.add_file(sources / (n.stem + ".cpp"), render(templates::executable_source, vars)); + p.add_file(sources / (n.stem + ".cpp"), templates.render(template_id::executable_source, vars)); } return p; } diff --git a/canon/generate.hpp b/canon/generate.hpp index 467a880..751e675 100644 --- a/canon/generate.hpp +++ b/canon/generate.hpp @@ -3,6 +3,7 @@ #include #include #include +#include namespace canon { @@ -23,6 +24,6 @@ namespace canon { // │ ├── hello.cpp // │ └── hello.test.cpp // └── tests/basics/driver.cpp -[[nodiscard]] plan generate_new_project(const project_name&); +[[nodiscard]] plan generate_new_project(const project_name&, const template_set& = builtin_templates()); } // namespace canon diff --git a/canon/plan.cpp b/canon/plan.cpp index f20204f..382f182 100644 --- a/canon/plan.cpp +++ b/canon/plan.cpp @@ -8,7 +8,7 @@ namespace canon { void plan::add_directory(const std::filesystem::path& dir) { - if (dir.empty()) { + if (dir.empty() || dir == dir.parent_path()) { // nothing, or a filesystem root return; } const bool planned = std::ranges::any_of(operations, [&](const operation& op) { diff --git a/canon/project.cpp b/canon/project.cpp index 3910326..faf76c4 100644 --- a/canon/project.cpp +++ b/canon/project.cpp @@ -8,28 +8,54 @@ namespace canon { namespace fs = std::filesystem; -std::expected load_project(const fs::path& start) { +namespace { + +// start as an absolute directory path without a trailing separator. +std::optional absolute_directory(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())); + return std::nullopt; } if (!directory.has_filename()) { - directory = directory.parent_path(); // drop a trailing separator + directory = directory.parent_path(); } + return directory; +} - project p; - for (fs::path current = directory;; current = current.parent_path()) { +} // namespace + +std::optional find_project_root(const fs::path& start) { + const auto directory = absolute_directory(start); + if (!directory) { + return std::nullopt; + } + std::error_code error; + for (fs::path current = *directory;; current = current.parent_path()) { if (fs::is_regular_file(current / manifest_file, error)) { - p.root = current; - break; + return current; } 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())); + return std::nullopt; } } +} + +std::expected load_project(const fs::path& start) { + const auto directory = absolute_directory(start); + if (!directory) { + return std::unexpected(std::format("{} is not a directory", start.string())); + } + const auto root = find_project_root(*directory); + if (!root) { + return std::unexpected(std::format( + "no {} in {} or any parent directory; run this inside a project created by 'canon new'", + manifest_file, directory->string())); + } + + std::error_code error; + project p; + p.root = *root; const fs::path manifest_path = p.root / manifest_file; auto manifest_text = details::read_file(manifest_path); diff --git a/canon/project.hpp b/canon/project.hpp index ef8dab7..1eaeaf4 100644 --- a/canon/project.hpp +++ b/canon/project.hpp @@ -4,6 +4,7 @@ #include #include +#include #include namespace canon { @@ -17,8 +18,11 @@ struct project { 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. +// The nearest directory at or above start that contains .canon.toml, as an +// absolute path, so commands work from any subdirectory. +[[nodiscard]] std::optional find_project_root(const std::filesystem::path& start); + +// Loads the project at find_project_root(start). [[nodiscard]] std::expected load_project(const std::filesystem::path& start); } // namespace canon diff --git a/canon/template.cpp b/canon/template.cpp index 1b5de2c..a204acf 100644 --- a/canon/template.cpp +++ b/canon/template.cpp @@ -1,5 +1,6 @@ #include +#include #include #include @@ -33,4 +34,22 @@ std::string render(std::string_view text, const template_vars& vars) { } } +std::expected, std::string> placeholders(std::string_view text) { + std::vector keys; + for (std::size_t line = 1;;) { + const auto open = text.find("{{"); + if (open == std::string_view::npos) { + return keys; + } + line += static_cast(std::ranges::count(text.substr(0, open), '\n')); + const auto close = text.find("}}", open + 2); + if (close == std::string_view::npos) { + return std::unexpected(std::format("line {}: '{{{{' is never closed with '}}}}'", line)); + } + keys.emplace_back(text.substr(open + 2, close - open - 2)); + line += static_cast(std::ranges::count(text.substr(open, close - open), '\n')); + text.remove_prefix(close + 2); + } +} + } // namespace canon diff --git a/canon/template.hpp b/canon/template.hpp index c309fb3..e55262e 100644 --- a/canon/template.hpp +++ b/canon/template.hpp @@ -1,17 +1,24 @@ #pragma once +#include #include #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. +// Throws std::invalid_argument for an unknown key or an unterminated placeholder. +// Check user-supplied text with placeholders() first; for built-in templates +// either failure is a bug that tests should catch. [[nodiscard]] std::string render(std::string_view text, const template_vars& vars); +// The key of every {{key}} in text, in order of appearance, or why the text +// cannot be rendered (an unclosed placeholder). +[[nodiscard]] std::expected, std::string> placeholders(std::string_view text); + } // namespace canon diff --git a/canon/template.test.cpp b/canon/template.test.cpp index cdee3ca..9705e90 100644 --- a/canon/template.test.cpp +++ b/canon/template.test.cpp @@ -31,5 +31,14 @@ int main() { } CANON_CHECK(threw); + // placeholders() lists keys in order and reports unclosed ones with a line number. + { + const auto keys = placeholders("a {{name}} b\n{{stem}}{{name}} }} {"); + CANON_CHECK(keys && *keys == std::vector({"name", "stem", "name"})); + CANON_CHECK(placeholders("no placeholders").value_or(std::vector{"x"}).empty()); + const auto unclosed = placeholders("line 1\n{{name}}\nline 3 {{stem\n"); + CANON_CHECK(!unclosed && unclosed.error() == "line 3: '{{' is never closed with '}}'"); + } + return details::exit_status(); } diff --git a/canon/templates.cpp b/canon/templates.cpp new file mode 100644 index 0000000..241dc70 --- /dev/null +++ b/canon/templates.cpp @@ -0,0 +1,321 @@ +#include + +#include + +#include +#include +#include +#include +#include + +namespace canon { + +namespace fs = std::filesystem; + +namespace { + +// Built-in texts. Placeholders: +// {{name}} project or target name libhello +// {{stem}} source directory hello +// {{ident}} top-level namespace hello +// {{include}} header to include hello/details/utility.hpp +// {{namespace}} a module's namespace hello::details +// {{target}} target under test libhello + +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"; + +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"; + +constexpr std::string_view test_driver = R"cpp(// Functional test for {{target}}: use it only through its public headers, as a +// consumer would. Return non-zero from main() to fail. + +#include <{{include}}> + +int main() { +} +)cpp"; + +// For a library whose main header has been removed from .canon.toml. +constexpr std::string_view test_driver_without_include = R"cpp(// Functional test for {{target}}: use it only through its public headers, as a +// consumer would. Return non-zero from main() to fail. + +int main() { +} +)cpp"; + +constexpr auto project_keys = std::to_array({"name", "stem", "ident"}); +constexpr auto module_keys = std::to_array({"include", "namespace"}); +constexpr auto driver_keys = std::to_array({"target", "include"}); +constexpr auto bare_driver_keys = std::to_array({"target"}); + +constexpr std::array catalog{{ + {template_id::readme, "README.md", "README.md of a new project", project_keys, readme}, + {template_id::gitignore, "gitignore", ".gitignore of a new project", project_keys, gitignore}, + {template_id::library_header, "library.hpp", "main header of a new library", project_keys, library_header}, + {template_id::library_source, "library.cpp", "main source of a new library", project_keys, library_source}, + {template_id::library_unit_test, "library.test.cpp", "unit test of a new library", project_keys, + library_unit_test}, + {template_id::library_driver, "library-driver.cpp", "tests/basics/driver.cpp of a new library project", + project_keys, library_driver}, + {template_id::executable_source, "executable.cpp", "main() of a new executable", project_keys, + executable_source}, + {template_id::module_header, "module.hpp", "header of a new module", module_keys, module_header}, + {template_id::module_source, "module.cpp", "source of a new module", module_keys, module_source}, + {template_id::module_unit_test, "module.test.cpp", "unit test of a new module", module_keys, + module_unit_test}, + {template_id::test_driver, "driver.cpp", "driver of a new functional test", driver_keys, test_driver}, + {template_id::test_driver_without_include, "driver-without-include.cpp", + "driver of a new functional test for a library without a main header", bare_driver_keys, + test_driver_without_include}, +}}; + +std::unexpected fail(std::string message) { + return std::unexpected(std::move(message)); +} + +std::size_t index_of(template_id id) { + return static_cast(std::to_underlying(id)); +} + +std::string file_names() { + std::string out; + for (bool first = true; const template_info& t : catalog) { + out += first ? "" : ", "; + out += t.file; + first = false; + } + return out; +} + +} // namespace + +std::span template_catalog() { + return catalog; +} + +const template_info& info_of(template_id id) { + return catalog[index_of(id)]; +} + +std::optional user_template_directory(std::string_view xdg_config_home, std::string_view home) { + // The XDG base directory spec says relative paths are invalid and must be ignored. + if (!xdg_config_home.empty() && fs::path(xdg_config_home).is_absolute()) { + return fs::path(xdg_config_home) / "canon" / "templates"; + } + if (!home.empty()) { + return fs::path(home) / ".config" / "canon" / "templates"; + } + return std::nullopt; +} + +template_set::template_set() { + for (const template_info& t : catalog) { + texts_[index_of(t.id)] = std::string(t.builtin); + } +} + +std::string_view template_set::text(template_id id) const { + return texts_[index_of(id)]; +} + +const fs::path& template_set::source(template_id id) const { + return sources_[index_of(id)]; +} + +std::expected template_set::replace(template_id id, std::string text, fs::path source) { + const template_info& t = info_of(id); + const auto keys = placeholders(text); + if (!keys) { + return fail(std::format("{}: {}", source.string(), keys.error())); + } + for (const std::string& key : *keys) { + if (!std::ranges::contains(t.placeholders, key)) { + std::string allowed; + for (bool first = true; const std::string_view p : t.placeholders) { + allowed += std::format("{}{{{{{}}}}}", first ? "" : " ", p); + first = false; + } + return fail(std::format("{}: unknown placeholder {{{{{}}}}}; {} can use {}", source.string(), key, + t.file, allowed)); + } + } + texts_[index_of(id)] = std::move(text); + sources_[index_of(id)] = std::move(source); + return {}; +} + +std::string template_set::render(template_id id, const template_vars& vars) const { + return canon::render(texts_[index_of(id)], vars); +} + +const template_set& builtin_templates() { + static const template_set builtin; + return builtin; +} + +std::expected load_templates(std::span directories) { + template_set set; + std::array found{}; + + for (const fs::path& directory : directories) { + std::error_code error; + if (!fs::is_directory(directory, error)) { + continue; + } + std::vector entries; + for (fs::directory_iterator it(directory, error), end; !error && it != end; it.increment(error)) { + entries.push_back(it->path()); + } + if (error) { + return fail(std::format("cannot read {}: {}", directory.string(), error.message())); + } + std::ranges::sort(entries); + + for (const fs::path& entry : entries) { + const std::string name = entry.filename().string(); + if (name.starts_with('.')) { + continue; + } + const auto t = std::ranges::find(catalog, name, &template_info::file); + if (t == catalog.end()) { + return fail(std::format("{}: not a canon template (templates: {})", entry.string(), file_names())); + } + if (!fs::is_regular_file(entry, error)) { + return fail(std::format("{}: templates must be regular files", entry.string())); + } + if (found[index_of(t->id)]) { + continue; // a more specific directory already provided it + } + auto text = details::read_file(entry); + if (!text) { + return fail(std::format("cannot read {}", entry.string())); + } + if (auto ok = set.replace(t->id, std::move(*text), entry); !ok) { + return std::unexpected(ok.error()); + } + found[index_of(t->id)] = true; + } + } + return set; +} + +std::expected plan_export_templates(std::span names, + const fs::path& directory) { + plan result; + if (names.empty()) { + for (const template_info& t : catalog) { + result.add_file(directory / t.file, std::string(t.builtin)); + } + return result; + } + + std::set> seen; + for (const std::string& name : names) { + const auto t = std::ranges::find(catalog, name, &template_info::file); + if (t == catalog.end()) { + return fail(std::format("'{}' is not a canon template (templates: {})", name, file_names())); + } + if (seen.insert(name).second) { + result.add_file(directory / t->file, std::string(t->builtin)); + } + } + return result; +} + +} // namespace canon diff --git a/canon/templates.hpp b/canon/templates.hpp index 4ce3140..05d712c 100644 --- a/canon/templates.hpp +++ b/canon/templates.hpp @@ -1,132 +1,100 @@ #pragma once -#include - -// File templates for the generators, rendered with canon::render. Placeholders: -// {{name}} project or target name libhello -// {{stem}} source directory hello -// {{ident}} top-level namespace hello -// {{include}} header to include hello/details/utility.hpp -// {{namespace}} a module's namespace hello::details -// {{target}} target under test libhello - -namespace canon::templates { - -inline constexpr std::string_view gitignore = "/build/\n"; - -inline 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"; - -inline constexpr std::string_view library_header = R"cpp(#pragma once +#include +#include +#include +#include +#include +#include +#include +#include #include #include -namespace {{ident}} { +namespace canon { -// Returns a greeting for name, e.g. "Hello, World!". -[[nodiscard]] std::string greeting(std::string_view name); +// The contents of generated files. Every template has a built-in text, which a +// file of the same name in a template directory replaces: +// +// /.canon/templates/ for commands run inside that project +// ~/.config/canon/templates/ for every project ($XDG_CONFIG_HOME/canon/templates if set) +// +// A project template wins over a user template. `canon new` reads only user +// templates, since the project does not exist yet. -} // namespace {{ident}} -)cpp"; +enum class template_id { + readme, + gitignore, + library_header, + library_source, + library_unit_test, + library_driver, + executable_source, + module_header, + module_source, + module_unit_test, + test_driver, + test_driver_without_include, +}; -inline constexpr std::string_view library_source = R"cpp(#include <{{stem}}/{{stem}}.hpp> +inline constexpr std::size_t template_count = 12; -#include +inline constexpr std::string_view project_template_directory = ".canon/templates"; -namespace {{ident}} { +struct template_info { + template_id id; + std::string_view file; // override file name, e.g. "library.hpp" + std::string_view used_for; // e.g. "main header of a new library" + std::span placeholders; // the {{keys}} its text may use + std::string_view builtin; +}; -std::string greeting(std::string_view name) { - return std::format("Hello, {}!", name); -} +// Every template, in template_id order. +[[nodiscard]] std::span template_catalog(); -} // namespace {{ident}} -)cpp"; +[[nodiscard]] const template_info& info_of(template_id); -inline constexpr std::string_view library_unit_test = R"cpp(#include <{{stem}}/{{stem}}.hpp> +// $XDG_CONFIG_HOME/canon/templates if that is an absolute path, otherwise +// $HOME/.config/canon/templates; nothing if neither is usable. Pass the +// environment values, or empty strings for unset variables. +[[nodiscard]] std::optional user_template_directory(std::string_view xdg_config_home, + std::string_view home); -#include -#include -#include +// The template texts the generators use. +class template_set { +public: + template_set(); // all built-in -int main() { - const auto actual = {{ident}}::greeting("World"); - if (actual != "Hello, World!") { - std::println(stderr, "greeting(\"World\") returned \"{}\"", actual); - return EXIT_FAILURE; - } -} -)cpp"; + [[nodiscard]] std::string_view text(template_id) const; -inline constexpr std::string_view library_driver = R"cpp(// Functional test: uses {{name}} only through its public headers, as a consumer would. + // The file that replaced the built-in text, or an empty path. + [[nodiscard]] const std::filesystem::path& source(template_id) const; -#include <{{stem}}/{{stem}}.hpp> + // Fails, naming source, if text has an unclosed placeholder or uses one + // this template does not provide. + std::expected replace(template_id, std::string text, std::filesystem::path source); -#include -#include + [[nodiscard]] std::string render(template_id, const template_vars&) const; -int main() { - const auto greeting = {{ident}}::greeting("{{name}}"); - std::println("{}", greeting); - return greeting.starts_with("Hello") ? EXIT_SUCCESS : EXIT_FAILURE; -} -)cpp"; +private: + std::array texts_; + std::array sources_; +}; -inline constexpr std::string_view executable_source = R"cpp(#include +[[nodiscard]] const template_set& builtin_templates(); -int main(int argc, char* argv[]) { - std::println("Hello, {}!", argc > 1 ? argv[1] : "World"); -} -)cpp"; +// The built-in templates, replaced by the files in directories, most specific +// directory first: a template found in one directory is not read from later +// ones. Missing directories are skipped and hidden files (.DS_Store) ignored; +// every other entry must be a file named after a template. +[[nodiscard]] std::expected +load_templates(std::span directories); -inline constexpr std::string_view module_header = R"cpp(#pragma once +// Plans `canon templates export`: the built-in text of each named template, or +// of all of them if names is empty, as a file in directory. Existing files are +// never overwritten, so exporting cannot lose edits. +[[nodiscard]] std::expected plan_export_templates(std::span names, + const std::filesystem::path& directory); -namespace {{namespace}} { - -} // namespace {{namespace}} -)cpp"; - -inline constexpr std::string_view module_source = R"cpp(#include <{{include}}> - -namespace {{namespace}} { - -} // namespace {{namespace}} -)cpp"; - -inline 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"; - -inline constexpr std::string_view test_driver = R"cpp(// Functional test for {{target}}: use it only through its public headers, as a -// consumer would. Return non-zero from main() to fail. - -#include <{{include}}> - -int main() { -} -)cpp"; - -// For a library whose main header has been removed from .canon.toml. -inline constexpr std::string_view test_driver_without_include = R"cpp(// Functional test for {{target}}: use it only through its public headers, as a -// consumer would. Return non-zero from main() to fail. - -int main() { -} -)cpp"; - -} // namespace canon::templates +} // namespace canon diff --git a/canon/templates.test.cpp b/canon/templates.test.cpp new file mode 100644 index 0000000..709e957 --- /dev/null +++ b/canon/templates.test.cpp @@ -0,0 +1,179 @@ +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +using namespace canon; +namespace fs = std::filesystem; + +namespace { + +void write(const fs::path& file, std::string_view content) { + fs::create_directories(file.parent_path()); + std::ofstream(file, std::ios::binary) << content; +} + +bool fails_with(const std::expected& result, std::string_view message) { + if (result) { + std::println(stderr, " loaded successfully, expected: {}", message); + return false; + } + const bool matches = result.error().contains(message); + if (!matches) { + std::println(stderr, " error was: {}", result.error()); + } + return matches; +} + +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 ""; +} + +} // namespace + +int main() { + // The catalog: in id order, unique file names, and every built-in text only + // uses placeholders its template provides. + { + std::set files; + for (std::size_t i = 0; const template_info& t : template_catalog()) { + CANON_CHECK_EQ(static_cast(std::to_underlying(t.id)), i++); + CANON_CHECK(files.insert(t.file).second); + CANON_CHECK(!t.file.starts_with('.')); // hidden files are ignored when loading + const auto keys = placeholders(t.builtin); + CANON_CHECK(keys); + for (const std::string& key : keys.value_or(std::vector{})) { + if (!std::ranges::contains(t.placeholders, key)) { + std::println(stderr, " {} uses {{{{{}}}}}, which it does not provide", t.file, key); + CANON_CHECK(false); + } + } + } + CANON_CHECK_EQ(files.size(), template_count); + CANON_CHECK_EQ(info_of(template_id::module_header).file, "module.hpp"); + } + + CANON_CHECK(user_template_directory("/xdg", "/home/me") == fs::path("/xdg/canon/templates")); + CANON_CHECK(user_template_directory("relative/xdg", "/home/me") == fs::path("/home/me/.config/canon/templates")); + CANON_CHECK(user_template_directory("", "/home/me") == fs::path("/home/me/.config/canon/templates")); + CANON_CHECK(!user_template_directory("", "").has_value()); + + // Replacing a template checks its placeholders. + { + template_set set; + CANON_CHECK_EQ(set.text(template_id::module_header), info_of(template_id::module_header).builtin); + CANON_CHECK(set.source(template_id::module_header).empty()); + + CANON_CHECK(set.replace(template_id::module_header, "// {{include}}\nnamespace {{namespace}} {}\n", "/t/module.hpp")); + CANON_CHECK_EQ(set.source(template_id::module_header).string(), "/t/module.hpp"); + CANON_CHECK_EQ(set.render(template_id::module_header, {{"include", "a/b.hpp"}, {"namespace", "a"}}), + "// a/b.hpp\nnamespace a {}\n"); + + const auto unknown = set.replace(template_id::module_header, "{{name}}", "/t/module.hpp"); + CANON_CHECK(!unknown && unknown.error() == "/t/module.hpp: unknown placeholder {{name}}; module.hpp can use " + "{{include}} {{namespace}}"); + const auto unclosed = set.replace(template_id::readme, "# {{name}}\n\n{{stem", "/t/README.md"); + CANON_CHECK(!unclosed && unclosed.error() == "/t/README.md: line 3: '{{' is never closed with '}}'"); + // A failed replacement keeps the previous text. + CANON_CHECK(set.text(template_id::module_header).starts_with("// {{include}}")); + CANON_CHECK(set.source(template_id::readme).empty()); + } + + // Loading: the project directory wins, missing directories are fine, hidden files are ignored. + { + const details::temp_directory tmp; + const fs::path project_dir = tmp.path() / "project"; + const fs::path user = tmp.path() / "user"; + write(project_dir / "module.hpp", "// project {{namespace}}\n"); + write(user / "module.hpp", "// user\n"); + write(user / "gitignore", "/build/\n/.cache/\n"); + write(user / ".DS_Store", "binary junk {{"); + + const std::vector directories{project_dir, tmp.path() / "missing", user}; + const auto set = load_templates(directories); + CANON_CHECK(set); + if (set) { + CANON_CHECK_EQ(set->text(template_id::module_header), "// project {{namespace}}\n"); + CANON_CHECK(set->source(template_id::module_header) == project_dir / "module.hpp"); + CANON_CHECK_EQ(set->text(template_id::gitignore), "/build/\n/.cache/\n"); + CANON_CHECK(set->source(template_id::gitignore) == user / "gitignore"); + CANON_CHECK(set->source(template_id::readme).empty()); + + // Generators use what was loaded. + const auto name = parse_project_name(project_kind::library, "libhello"); + const plan p = generate_new_project(*name, *set); + CANON_CHECK_EQ(created(p, "libhello/.gitignore"), "/build/\n/.cache/\n"); + + const manifest m = new_project_manifest(*name); + const project loaded{.declared = m, .manifest_text = to_toml(m), .cmakelists_text = render_cmakelists(m)}; + const auto added = plan_add_module(loaded, {.path = "details/core"}, *set); + CANON_CHECK(added && created(*added, "hello/details/core.hpp") == "// project hello::details\n"); + } + + CANON_CHECK(load_templates(std::vector{}).has_value()); + + write(user / "libary.hpp", "typo"); + CANON_CHECK(fails_with(load_templates(directories), "libary.hpp: not a canon template (templates: README.md, ")); + fs::remove(user / "libary.hpp"); + + fs::create_directories(user / "module.cpp"); + CANON_CHECK(fails_with(load_templates(directories), "module.cpp: templates must be regular files")); + fs::remove(user / "module.cpp"); + + write(user / "driver.cpp", "#include <{{include}}>\n{{namespace}}\n"); + CANON_CHECK(fails_with(load_templates(directories), + "driver.cpp: unknown placeholder {{namespace}}; driver.cpp can use {{target}} {{include}}")); + fs::remove(user / "driver.cpp"); + + // An invalid template in a less specific directory is not read if a more specific one provides it. + write(user / "module.hpp", "{{broken"); + CANON_CHECK(load_templates(directories).has_value()); + } + + // Exporting writes the built-in texts, and never overwrites. + { + const auto all = plan_export_templates({}, ".canon/templates"); + CANON_CHECK(all); + if (all) { + CANON_CHECK(describe(*all).starts_with(" mkdir .canon/\n mkdir .canon/templates/\n create .canon/templates/README.md\n")); + CANON_CHECK_EQ(all->operations.size(), template_count + 2); + CANON_CHECK_EQ(created(*all, ".canon/templates/library.hpp"), info_of(template_id::library_header).builtin); + } + + const std::vector names{"module.hpp", "gitignore", "module.hpp"}; + const auto some = plan_export_templates(names, "t"); + CANON_CHECK(some && describe(*some) == " mkdir t/\n create t/module.hpp\n create t/gitignore\n"); + + const std::vector typo{"modul.hpp"}; + const auto bad = plan_export_templates(typo, "t"); + CANON_CHECK(!bad && bad.error().starts_with("'modul.hpp' is not a canon template (templates: README.md, gitignore, ")); + + // Absolute directories work too (the user directory), without planning the filesystem root. + const details::temp_directory tmp; + const auto absolute = plan_export_templates(names, tmp.path() / "config" / "canon" / "templates"); + CANON_CHECK(absolute); + if (absolute) { + const auto report = apply(*absolute, {}); + CANON_CHECK(report && report->size() == 5); // config/, canon/, templates/ and two files + CANON_CHECK(fs::exists(tmp.path() / "config/canon/templates/gitignore")); + CANON_CHECK(!apply(*absolute, {}).has_value()); // the files exist now + } + } + + return details::exit_status(); +} diff --git a/tests/golden/templates/libhello/.canon.toml b/tests/golden/templates/libhello/.canon.toml new file mode 100644 index 0000000..eff5898 --- /dev/null +++ b/tests/golden/templates/libhello/.canon.toml @@ -0,0 +1,34 @@ +# 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/details/core.cpp"] + +[[modules]] +path = "hello/hello" +target = "libhello" +unit-test = true + +[[modules]] +path = "hello/details/core" +target = "libhello" +unit-test = true + +[[tests]] +name = "basics" +kind = "driver" +path = "tests/basics" +target = "libhello" diff --git a/tests/golden/templates/libhello/.canon/templates/module.hpp b/tests/golden/templates/libhello/.canon/templates/module.hpp new file mode 100644 index 0000000..648a9ca --- /dev/null +++ b/tests/golden/templates/libhello/.canon/templates/module.hpp @@ -0,0 +1,6 @@ +// Part of {{namespace}}. +#pragma once + +namespace {{namespace}} { + +} // namespace {{namespace}} diff --git a/tests/golden/templates/libhello/.gitignore b/tests/golden/templates/libhello/.gitignore new file mode 100644 index 0000000..84c048a --- /dev/null +++ b/tests/golden/templates/libhello/.gitignore @@ -0,0 +1 @@ +/build/ diff --git a/tests/golden/templates/libhello/CMakeLists.txt b/tests/golden/templates/libhello/CMakeLists.txt new file mode 100644 index 0000000..d1fa4cc --- /dev/null +++ b/tests/golden/templates/libhello/CMakeLists.txt @@ -0,0 +1,40 @@ +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/details/core.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.details.core.test hello/details/core.test.cpp) + target_link_libraries(hello.details.core.test PRIVATE libhello) + add_test(NAME hello/details/core.test COMMAND hello.details.core.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/templates/libhello/README.md b/tests/golden/templates/libhello/README.md new file mode 100644 index 0000000..a844dca --- /dev/null +++ b/tests/golden/templates/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/templates/libhello/hello/details/core.cpp b/tests/golden/templates/libhello/hello/details/core.cpp new file mode 100644 index 0000000..a362d6c --- /dev/null +++ b/tests/golden/templates/libhello/hello/details/core.cpp @@ -0,0 +1,5 @@ +#include + +namespace hello::details { + +} // namespace hello::details diff --git a/tests/golden/templates/libhello/hello/details/core.hpp b/tests/golden/templates/libhello/hello/details/core.hpp new file mode 100644 index 0000000..b9ac1c9 --- /dev/null +++ b/tests/golden/templates/libhello/hello/details/core.hpp @@ -0,0 +1,6 @@ +// Part of hello::details. +#pragma once + +namespace hello::details { + +} // namespace hello::details diff --git a/tests/golden/templates/libhello/hello/details/core.test.cpp b/tests/golden/templates/libhello/hello/details/core.test.cpp new file mode 100644 index 0000000..02bcd8d --- /dev/null +++ b/tests/golden/templates/libhello/hello/details/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/templates/libhello/hello/hello.cpp b/tests/golden/templates/libhello/hello/hello.cpp new file mode 100644 index 0000000..e7b92ce --- /dev/null +++ b/tests/golden/templates/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/templates/libhello/hello/hello.hpp b/tests/golden/templates/libhello/hello/hello.hpp new file mode 100644 index 0000000..e38b635 --- /dev/null +++ b/tests/golden/templates/libhello/hello/hello.hpp @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +#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/templates/libhello/hello/hello.test.cpp b/tests/golden/templates/libhello/hello/hello.test.cpp new file mode 100644 index 0000000..5cafec8 --- /dev/null +++ b/tests/golden/templates/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/templates/libhello/tests/basics/driver.cpp b/tests/golden/templates/libhello/tests/basics/driver.cpp new file mode 100644 index 0000000..757d317 --- /dev/null +++ b/tests/golden/templates/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 index d6f48fd..376fc65 100644 --- a/tests/scenarios.cmake +++ b/tests/scenarios.cmake @@ -1,7 +1,10 @@ # canon command sequences shared by the golden and end-to-end tests. Each # scenario builds one project inside //. Requires CANON. +# +# canon runs with XDG_CONFIG_HOME set to /-config, so user +# templates in your own ~/.config never leak into the results. -set(CANON_SCENARIOS new-lib new-exe add-lib add-exe multi-target exe-tests) +set(CANON_SCENARIOS new-lib new-exe add-lib add-exe multi-target exe-tests templates) # Scenarios run canon from other directories, so a relative path would break. get_filename_component(CANON "${CANON}" ABSOLUTE) @@ -9,7 +12,7 @@ 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} + COMMAND ${CMAKE_COMMAND} -E env XDG_CONFIG_HOME=${config_home} ${CANON} ${ARGN} WORKING_DIRECTORY ${working_dir} RESULT_VARIABLE status OUTPUT_VARIABLE output @@ -23,7 +26,7 @@ endfunction() # Runs canon in working_dir and fails the script if it succeeds. function(canon_run_fails working_dir) execute_process( - COMMAND ${CANON} ${ARGN} + COMMAND ${CMAKE_COMMAND} -E env XDG_CONFIG_HOME=${config_home} ${CANON} ${ARGN} WORKING_DIRECTORY ${working_dir} RESULT_VARIABLE status OUTPUT_QUIET ERROR_QUIET) @@ -38,6 +41,9 @@ function(canon_scenario scenario work_dir out_var) set(dir ${work_dir}/${scenario}) file(REMOVE_RECURSE ${dir}) file(MAKE_DIRECTORY ${dir}) + set(config_home ${work_dir}/${scenario}-config) # seen by canon_run + file(REMOVE_RECURSE ${config_home}) + file(MAKE_DIRECTORY ${config_home}) if(scenario STREQUAL "new-lib") canon_run(${dir} new lib libhello) @@ -85,6 +91,35 @@ function(canon_scenario scenario work_dir out_var) canon_run(${project} add test spaces --arg "two words" --expect "Hello, two words!") canon_run_fails(${project} add test greets-paul) + elseif(scenario STREQUAL "templates") + # User templates apply to every new project: a license line on library and module headers. + canon_run(${dir} templates export library.hpp module.hpp --user) + foreach(template library.hpp module.hpp) + set(file ${config_home}/canon/templates/${template}) + file(READ ${file} text) + file(WRITE ${file} "// SPDX-License-Identifier: MIT\n${text}") + endforeach() + canon_run(${dir} new lib libhello) + set(project ${dir}/libhello) + + # A project template wins over a user template. + canon_run(${project} templates export module.hpp --dry-run) # must write nothing + canon_run(${project} templates export module.hpp) + file(READ ${project}/.canon/templates/module.hpp text) + file(WRITE ${project}/.canon/templates/module.hpp "// Part of {{namespace}}.\n${text}") + canon_run(${project} add module details/core) + canon_run(${project} templates) + canon_run_fails(${project} templates export module.hpp) # never overwrites + + # Broken templates are errors, not silently skipped. + file(WRITE ${project}/.canon/templates/module.cpp "{{nmae}}\n") + canon_run_fails(${project} add module broken) + canon_run_fails(${project} templates) + file(REMOVE ${project}/.canon/templates/module.cpp) + file(WRITE ${config_home}/canon/templates/modul.hpp "") + canon_run_fails(${project} add module broken) + file(REMOVE ${config_home}/canon/templates/modul.hpp) + else() message(FATAL_ERROR "unknown scenario '${scenario}'") endif()