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:

- <project>/.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 [<file>...] [--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 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 19:01:37 +02:00
parent 651fa4d89b
commit a82bdf4734
34 changed files with 1123 additions and 173 deletions
+2 -1
View File
@@ -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/<unit>.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)
+36 -6
View File
@@ -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 [<file>...] [--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/<unit>.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/<scenario>/` 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
+17 -15
View File
@@ -1,7 +1,6 @@
#include <canon/add.hpp>
#include <canon/cmake.hpp>
#include <canon/template.hpp>
#include <canon/templates.hpp>
#include <algorithm>
@@ -144,7 +143,8 @@ std::expected<plan, std::string> finish(plan result, const project& p, const man
} // namespace
std::expected<plan, std::string> plan_add_module(const project& p, const module_request& request) {
std::expected<plan, std::string> 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, std::string> 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, std::string> plan_add_unit_test(const project& p, const module_request& request) {
std::expected<plan, std::string> 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, std::string> 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, std::string> plan_add_target(const project& p, const target_request& request) {
std::expected<plan, std::string> 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, std::string> 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, std::string> plan_add_target(const project& p, const target_
return finish(std::move(result), p, updated);
}
std::expected<plan, std::string> plan_add_test(const project& p, const test_request& request) {
std::expected<plan, std::string> 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, std::string> 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;
}
+9 -4
View File
@@ -3,6 +3,7 @@
#include <canon/name.hpp>
#include <canon/plan.hpp>
#include <canon/project.hpp>
#include <canon/templates.hpp>
#include <expected>
#include <string>
@@ -41,18 +42,22 @@ struct dep_request {
// <stem>/<path>.hpp and .cpp (and .test.cpp); the source joins the target.
// Directories in path become nested namespaces.
[[nodiscard]] std::expected<plan, std::string> plan_add_module(const project&, const module_request&);
[[nodiscard]] std::expected<plan, std::string> plan_add_module(const project&, const module_request&,
const template_set& = builtin_templates());
// <stem>/<path>.test.cpp for a declared module without one. unit_test is ignored.
[[nodiscard]] std::expected<plan, std::string> plan_add_unit_test(const project&, const module_request&);
[[nodiscard]] std::expected<plan, std::string> 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, std::string> plan_add_target(const project&, const target_request&);
[[nodiscard]] std::expected<plan, std::string> plan_add_target(const project&, const target_request&,
const template_set& = builtin_templates());
// A functional test: tests/<name>/driver.cpp linked against a library, or a
// run of an executable. Both take args and expect.
[[nodiscard]] std::expected<plan, std::string> plan_add_test(const project&, const test_request&);
[[nodiscard]] std::expected<plan, std::string> 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.
+121 -7
View File
@@ -7,6 +7,7 @@
#include <canon/name.hpp>
#include <canon/plan.hpp>
#include <canon/project.hpp>
#include <canon/templates.hpp>
#include <cstdio>
#include <cstdlib>
@@ -14,7 +15,9 @@
#include <expected>
#include <filesystem>
#include <format>
#include <optional>
#include <print>
#include <utility>
#include <string>
#include <string_view>
#include <vector>
@@ -77,6 +80,24 @@ bool apply_and_report(const canon::plan& plan, const fs::path& root, bool dry_ru
return true;
}
std::optional<fs::path> 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<fs::path> template_directories(const std::optional<fs::path>& project_root) {
std::vector<fs::path> 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<canon::plan, std::string> 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<canon::plan, std::string> 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<fs::path> 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 <file> for this project\n"
" canon templates export <file> --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 <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) {
+35 -2
View File
@@ -23,6 +23,7 @@ struct options {
std::vector<std::string> args;
bool dry_run = false;
bool no_test = false;
bool user = false;
std::vector<std::string_view> used; // every option given, e.g. "--dir"
};
@@ -202,6 +203,26 @@ std::expected<command, std::string> parse_doctor(positionals positional, const o
return doctor_command{.directory = project_options_of(opts).directory};
}
std::expected<command, std::string> 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<std::string>(positional.begin() + 2, positional.end()),
.user = opts.user,
.options = project_options_of(opts),
};
}
} // namespace
std::expected<command, std::string> parse_command_line(std::span<const std::string_view> args) {
@@ -220,8 +241,8 @@ std::expected<command, std::string> parse_command_line(std::span<const std::stri
positional.insert(positional.end(), args.begin() + static_cast<std::ptrdiff_t>(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<command, std::string> parse_command_line(std::span<const std::stri
if (verb == "doctor") {
return parse_doctor(positional, opts);
}
if (verb == "templates") {
return parse_templates(positional, opts);
}
return fail(std::format("unknown command '{}'", verb));
}
@@ -286,6 +310,14 @@ Change the project you are in (run anywhere inside it):
Check the project you are in:
doctor Compare the files with .canon.toml and P1204R0
Customize generated files:
templates List the templates, where each one comes from and
the {{placeholders}} it can use
templates export [<file>...]
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 <path> new: create the project inside <path>
others: look for the project from <path>
@@ -296,6 +328,7 @@ Options:
--expect <regex> add test: the output must match
--package <name> 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
+12 -1
View File
@@ -9,6 +9,7 @@
#include <string>
#include <string_view>
#include <variant>
#include <vector>
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<std::string> 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<help_command, version_command, new_command, add_module_command,
add_unit_test_command, add_target_command, add_test_command, add_dep_command,
doctor_command>;
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
+17
View File
@@ -103,6 +103,23 @@ int main() {
CANON_CHECK_EQ(d->directory.generic_string(), "proj");
}
if (const auto t = as<templates_command>(parse({"templates", "--dir", "proj"}))) {
CANON_CHECK_EQ(t->directory.generic_string(), "proj");
}
if (const auto e = as<export_templates_command>(parse({"templates", "export"}))) {
CANON_CHECK(e->names.empty());
CANON_CHECK(!e->user);
}
if (const auto e = as<export_templates_command>(
parse({"templates", "export", "module.hpp", "gitignore", "--user", "--dry-run"}))) {
CANON_CHECK(e->names == std::vector<std::string>({"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'"));
+2 -1
View File
@@ -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)));
+10
View File
@@ -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;
+8 -9
View File
@@ -1,7 +1,6 @@
#include <canon/generate.hpp>
#include <canon/cmake.hpp>
#include <canon/template.hpp>
#include <canon/templates.hpp>
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;
}
+2 -1
View File
@@ -3,6 +3,7 @@
#include <canon/manifest.hpp>
#include <canon/name.hpp>
#include <canon/plan.hpp>
#include <canon/templates.hpp>
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
+1 -1
View File
@@ -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) {
+36 -10
View File
@@ -8,28 +8,54 @@ namespace canon {
namespace fs = std::filesystem;
std::expected<project, std::string> load_project(const fs::path& start) {
namespace {
// start as an absolute directory path without a trailing separator.
std::optional<fs::path> 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<fs::path> 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<project, std::string> 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);
+6 -2
View File
@@ -4,6 +4,7 @@
#include <expected>
#include <filesystem>
#include <optional>
#include <string>
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<std::filesystem::path> find_project_root(const std::filesystem::path& start);
// Loads the project at find_project_root(start).
[[nodiscard]] std::expected<project, std::string> load_project(const std::filesystem::path& start);
} // namespace canon
+19
View File
@@ -1,5 +1,6 @@
#include <canon/template.hpp>
#include <algorithm>
#include <format>
#include <stdexcept>
@@ -33,4 +34,22 @@ std::string render(std::string_view text, const template_vars& vars) {
}
}
std::expected<std::vector<std::string>, std::string> placeholders(std::string_view text) {
std::vector<std::string> keys;
for (std::size_t line = 1;;) {
const auto open = text.find("{{");
if (open == std::string_view::npos) {
return keys;
}
line += static_cast<std::size_t>(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::size_t>(std::ranges::count(text.substr(open, close - open), '\n'));
text.remove_prefix(close + 2);
}
}
} // namespace canon
+9 -2
View File
@@ -1,17 +1,24 @@
#pragma once
#include <expected>
#include <functional>
#include <map>
#include <string>
#include <string_view>
#include <vector>
namespace canon {
using template_vars = std::map<std::string, std::string, std::less<>>;
// 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::vector<std::string>, std::string> placeholders(std::string_view text);
} // namespace canon
+9
View File
@@ -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<std::string>({"name", "stem", "name"}));
CANON_CHECK(placeholders("no placeholders").value_or(std::vector<std::string>{"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();
}
+321
View File
@@ -0,0 +1,321 @@
#include <canon/templates.hpp>
#include <canon/details/files.hpp>
#include <algorithm>
#include <format>
#include <set>
#include <utility>
#include <vector>
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 <string>
#include <string_view>
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 <format>
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 <cstdio>
#include <cstdlib>
#include <print>
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 <cstdlib>
#include <print>
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 <print>
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<std::string_view>({"name", "stem", "ident"});
constexpr auto module_keys = std::to_array<std::string_view>({"include", "namespace"});
constexpr auto driver_keys = std::to_array<std::string_view>({"target", "include"});
constexpr auto bare_driver_keys = std::to_array<std::string_view>({"target"});
constexpr std::array<template_info, template_count> 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<std::string> fail(std::string message) {
return std::unexpected(std::move(message));
}
std::size_t index_of(template_id id) {
return static_cast<std::size_t>(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<const template_info> template_catalog() {
return catalog;
}
const template_info& info_of(template_id id) {
return catalog[index_of(id)];
}
std::optional<fs::path> 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<void, std::string> 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<template_set, std::string> load_templates(std::span<const fs::path> directories) {
template_set set;
std::array<bool, template_count> found{};
for (const fs::path& directory : directories) {
std::error_code error;
if (!fs::is_directory(directory, error)) {
continue;
}
std::vector<fs::path> 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, std::string> plan_export_templates(std::span<const std::string> 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<std::string, std::less<>> 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
+76 -108
View File
@@ -1,132 +1,100 @@
#pragma once
#include <string_view>
// 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 <canon/plan.hpp>
#include <canon/template.hpp>
#include <array>
#include <cstddef>
#include <expected>
#include <filesystem>
#include <optional>
#include <span>
#include <string>
#include <string_view>
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:
//
// <project>/.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 <format>
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<const std::string_view> 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<const template_info> 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<std::filesystem::path> user_template_directory(std::string_view xdg_config_home,
std::string_view home);
#include <cstdio>
#include <cstdlib>
#include <print>
// 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<void, std::string> replace(template_id, std::string text, std::filesystem::path source);
#include <cstdlib>
#include <print>
[[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<std::string, template_count> texts_;
std::array<std::filesystem::path, template_count> sources_;
};
inline constexpr std::string_view executable_source = R"cpp(#include <print>
[[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<template_set, std::string>
load_templates(std::span<const std::filesystem::path> 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, std::string> plan_export_templates(std::span<const std::string> 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
+179
View File
@@ -0,0 +1,179 @@
#include <canon/templates.hpp>
#include <canon/add.hpp>
#include <canon/cmake.hpp>
#include <canon/details/testing.hpp>
#include <canon/executor.hpp>
#include <canon/generate.hpp>
#include <algorithm>
#include <filesystem>
#include <fstream>
#include <set>
#include <string>
#include <vector>
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<template_set, std::string>& 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<create_file>(&op); file && file->path == path) {
return file->content;
}
}
return "<not created>";
}
} // namespace
int main() {
// The catalog: in id order, unique file names, and every built-in text only
// uses placeholders its template provides.
{
std::set<std::string_view> files;
for (std::size_t i = 0; const template_info& t : template_catalog()) {
CANON_CHECK_EQ(static_cast<std::size_t>(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<std::string>{})) {
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<fs::path> 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<fs::path>{}).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<std::string> 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<std::string> 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();
}
@@ -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"
@@ -0,0 +1,6 @@
// Part of {{namespace}}.
#pragma once
namespace {{namespace}} {
} // namespace {{namespace}}
@@ -0,0 +1 @@
/build/
@@ -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 <<<
+12
View File
@@ -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
```
@@ -0,0 +1,5 @@
#include <hello/details/core.hpp>
namespace hello::details {
} // namespace hello::details
@@ -0,0 +1,6 @@
// Part of hello::details.
#pragma once
namespace hello::details {
} // namespace hello::details
@@ -0,0 +1,6 @@
// Unit tests for <hello/details/core.hpp>. Return non-zero from main() to fail.
#include <hello/details/core.hpp>
int main() {
}
@@ -0,0 +1,11 @@
#include <hello/hello.hpp>
#include <format>
namespace hello {
std::string greeting(std::string_view name) {
return std::format("Hello, {}!", name);
}
} // namespace hello
@@ -0,0 +1,12 @@
// SPDX-License-Identifier: MIT
#pragma once
#include <string>
#include <string_view>
namespace hello {
// Returns a greeting for name, e.g. "Hello, World!".
[[nodiscard]] std::string greeting(std::string_view name);
} // namespace hello
@@ -0,0 +1,13 @@
#include <hello/hello.hpp>
#include <cstdio>
#include <cstdlib>
#include <print>
int main() {
const auto actual = hello::greeting("World");
if (actual != "Hello, World!") {
std::println(stderr, "greeting(\"World\") returned \"{}\"", actual);
return EXIT_FAILURE;
}
}
@@ -0,0 +1,12 @@
// Functional test: uses libhello only through its public headers, as a consumer would.
#include <hello/hello.hpp>
#include <cstdlib>
#include <print>
int main() {
const auto greeting = hello::greeting("libhello");
std::println("{}", greeting);
return greeting.starts_with("Hello") ? EXIT_SUCCESS : EXIT_FAILURE;
}
+38 -3
View File
@@ -1,7 +1,10 @@
# canon command sequences shared by the golden and end-to-end tests. Each
# scenario builds one project inside <work_dir>/<scenario>/. Requires CANON.
#
# canon runs with XDG_CONFIG_HOME set to <work_dir>/<scenario>-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()