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:
+2
-1
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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)));
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 <<<
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user