Add canon: P1204R0 project scaffolding in C++23

canon new lib|exe creates a project following P1204R0 (Canonical Project
Structure) with a .canon.toml manifest and a CMakeLists.txt whose managed
block is rendered from that manifest.

Generators return a plan; the executor checks every operation before
writing, so conflicts write nothing and --dry-run writes nothing.

Tests: unit tests next to each source file, golden copies of generated
projects, and an end-to-end build of both project kinds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 18:11:23 +02:00
commit ab5c468ace
45 changed files with 2192 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/build/
+59
View File
@@ -0,0 +1,59 @@
cmake_minimum_required(VERSION 3.20)
project(canon
VERSION 0.1.0
DESCRIPTION "Scaffolds C++ projects that follow P1204R0 (Canonical Project Structure)"
LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
option(BUILD_TESTING "Build canon's tests" ON)
# Everything except main(), so unit tests can link against it.
add_library(canon.core STATIC
canon/cli.cpp
canon/cmake.cpp
canon/executor.cpp
canon/generate.cpp
canon/manifest.cpp
canon/name.cpp
canon/plan.cpp
canon/template.cpp
)
target_include_directories(canon.core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
if(NOT MSVC)
target_compile_options(canon.core PUBLIC -Wall -Wextra -Wpedantic)
endif()
add_executable(canon canon/canon.cpp)
target_link_libraries(canon PRIVATE canon.core)
target_compile_definitions(canon PRIVATE CANON_VERSION="${PROJECT_VERSION}")
install(TARGETS canon)
if(BUILD_TESTING)
enable_testing()
# Unit tests live next to the code they test: canon/<unit>.test.cpp.
foreach(unit cli cmake executor generate manifest name plan template)
add_executable(canon.${unit}.test canon/${unit}.test.cpp)
target_link_libraries(canon.${unit}.test PRIVATE canon.core)
add_test(NAME canon/${unit}.test COMMAND canon.${unit}.test)
endforeach()
# Byte-for-byte comparison of scaffolded projects against tests/golden/.
add_test(NAME tests/golden
COMMAND ${CMAKE_COMMAND}
-DCANON=$<TARGET_FILE:canon>
-DWORK_DIR=${CMAKE_CURRENT_BINARY_DIR}/tests/golden
-P ${CMAKE_CURRENT_SOURCE_DIR}/tests/golden/check.cmake)
# Scaffolds each project kind, then configures, builds and tests it.
add_test(NAME tests/e2e
COMMAND ${CMAKE_COMMAND}
-DCANON=$<TARGET_FILE:canon>
-DWORK_DIR=${CMAKE_CURRENT_BINARY_DIR}/tests/e2e
-DCXX=${CMAKE_CXX_COMPILER}
-P ${CMAKE_CURRENT_SOURCE_DIR}/tests/e2e/check.cmake)
set_tests_properties(tests/e2e PROPERTIES LABELS e2e TIMEOUT 300)
endif()
+71
View File
@@ -0,0 +1,71 @@
# canon
Scaffolds C++ projects that follow [P1204R0](https://wg21.link/p1204r0)
(Canonical Project Structure). Written in C++23, no third-party dependencies.
```bash
canon new lib libhello # library: libhello/hello/hello.{hpp,cpp,test.cpp} + tests/basics
canon new exe hello # executable: hello/hello/hello.cpp
canon new lib libhello --dry-run # show the plan, write nothing
canon new exe hello --dir ~/src # create the project somewhere else
```
Every generated project builds and passes its tests out of the box:
```bash
cd libhello
cmake -S . -B build
cmake --build build
ctest --test-dir build --output-on-failure
```
## Build canon
```bash
cmake -S . -B build
cmake --build build
ctest --test-dir build --output-on-failure
cmake --install build --prefix ~/.local # puts canon in ~/.local/bin
```
## How it works
Generators never touch the filesystem. They return a **plan** (a list of
`make_directory` / `create_file` operations) and the **executor** applies it.
The executor checks every operation first, so a conflict writes nothing, and
`--dry-run` is just "check, don't write". canon never overwrites files.
Each project gets a **`.canon.toml` manifest** recording what canon declared.
The block between `# >>> canon:managed >>>` and `# <<< canon:managed <<<` in
`CMakeLists.txt` is rendered entirely from that manifest. Upcoming `canon add`
commands will update the manifest and re-render the block, so running a command
twice gives the same result. Anything outside the block belongs to you.
| File | Role |
|---|---|
| `canon/canon.cpp` | `main`: wires the command line to the generators and executor |
| `canon/cli.*` | argument parsing |
| `canon/name.*` | project name rules (`lib` prefix, stem, namespace) |
| `canon/generate.*` | builds the manifest and plan for `canon new`; file templates |
| `canon/manifest.*` | the manifest model and its TOML output |
| `canon/cmake.*` | renders `CMakeLists.txt` and the managed block from a manifest |
| `canon/plan.*` | operations and plans (pure data) |
| `canon/executor.*` | checks and applies a plan |
| `canon/template.*` | `{{key}}` substitution |
## Tests
- **Unit tests**: `canon/<unit>.test.cpp`, next to the code they cover.
- **Golden tests**: `tests/golden/` holds the exact expected output for
`libhello` and `hello`. After an intentional template change, regenerate the
copies and review the diff:
`cmake -DCANON=build/canon -DWORK_DIR=build/tests/golden -DUPDATE=ON -P tests/golden/check.cmake`
- **End to end**: `tests/e2e/` scaffolds both kinds, then configures, builds and
runs their tests. Skip it with `ctest -LE e2e`.
## Roadmap
1. ✅ `new lib`, `new exe`, plan/apply, `--dry-run`, manifest
2. `add module`, `add unit-test`: read `.canon.toml` and re-render the managed block
3. `add target`, `add test`, `add dep`, `doctor` (check a project against P1204R0)
4. User template overrides
+121
View File
@@ -0,0 +1,121 @@
#include <canon/cli.hpp>
#include <canon/details/overloaded.hpp>
#include <canon/executor.hpp>
#include <canon/generate.hpp>
#include <canon/name.hpp>
#include <canon/plan.hpp>
#include <cstdio>
#include <cstdlib>
#include <exception>
#include <filesystem>
#include <print>
#include <string>
#include <string_view>
#include <vector>
namespace fs = std::filesystem;
namespace {
constexpr int usage_error = 2;
// Quotes a path for pasting into a shell, only when it needs it.
std::string shell_quote(const std::string& s) {
const bool safe = !s.empty() && s.find_first_not_of("abcdefghijklmnopqrstuvwxyz"
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
"0123456789/._-+") == std::string::npos;
if (safe) {
return s;
}
std::string out = "'";
for (const char c : s) {
out += c == '\'' ? std::string("'\\''") : std::string(1, c);
}
return out + "'";
}
// Absolute path for messages, with the home directory shown as ~.
std::string display_path(const fs::path& path) {
std::error_code error;
const std::string absolute = fs::absolute(path, error).lexically_normal().string();
if (error) {
return path.string();
}
const char* home_env = std::getenv("HOME");
if (home_env == nullptr || *home_env == '\0') {
return absolute;
}
std::string home = fs::path(home_env).lexically_normal().string();
if (home.size() > 1 && home.ends_with('/')) {
home.pop_back();
}
if (absolute == home || absolute.starts_with(home + "/")) {
return "~" + absolute.substr(home.size());
}
return absolute;
}
int run_new(const canon::new_command& cmd) {
const auto name = canon::parse_project_name(cmd.kind, cmd.name);
if (!name) {
std::println(stderr, "canon: {}", name.error());
return usage_error;
}
std::error_code ignored;
if (!fs::is_directory(cmd.directory, ignored)) {
std::println(stderr, "canon: directory {} does not exist", cmd.directory.string());
return EXIT_FAILURE;
}
const fs::path destination = (cmd.directory / name->name).lexically_normal();
if (fs::exists(destination, ignored) &&
!(fs::is_directory(destination, ignored) && fs::is_empty(destination, ignored))) {
std::println(stderr, "canon: {} already exists and is not an empty directory",
destination.string());
return EXIT_FAILURE;
}
const canon::plan plan = canon::generate_new_project(*name);
if (const auto applied = canon::apply(plan, cmd.directory, {.dry_run = cmd.dry_run}); !applied) {
std::println(stderr, "canon: {}", applied.error());
return EXIT_FAILURE;
}
const std::string_view kind = cmd.kind == canon::project_kind::library ? "library" : "executable";
std::print("{}", canon::describe(plan));
if (cmd.dry_run) {
std::println("\nDry run: nothing was written.");
return EXIT_SUCCESS;
}
std::println("\nCreated {} {} in {}.\n\nNext:\n cd {}\n cmake -S . -B build\n"
" cmake --build build\n ctest --test-dir build --output-on-failure",
kind, name->name, display_path(destination), shell_quote(destination.string()));
return EXIT_SUCCESS;
}
} // namespace
int main(int argc, char* argv[]) try {
const std::vector<std::string_view> args(argv + 1, argv + argc);
const auto command = canon::parse_command_line(args);
if (!command) {
std::println(stderr, "canon: {}\nRun 'canon --help' for usage.", command.error());
return usage_error;
}
return std::visit(canon::details::overloaded{
[](const canon::help_command&) {
std::print("{}", canon::usage());
return EXIT_SUCCESS;
},
[](const canon::version_command&) {
std::println("canon {}", CANON_VERSION);
return EXIT_SUCCESS;
},
[](const canon::new_command& cmd) { return run_new(cmd); },
},
*command);
} catch (const std::exception& e) {
std::println(stderr, "canon: internal error: {}", e.what());
return EXIT_FAILURE;
}
+101
View File
@@ -0,0 +1,101 @@
#include <canon/cli.hpp>
#include <format>
#include <optional>
#include <vector>
namespace canon {
namespace {
std::unexpected<std::string> fail(std::string message) {
return std::unexpected(std::move(message));
}
} // namespace
std::expected<command, std::string> parse_command_line(std::span<const std::string_view> args) {
std::vector<std::string_view> positional;
std::optional<std::string_view> directory;
bool dry_run = false;
for (std::size_t i = 0; i < args.size(); ++i) {
const std::string_view arg = args[i];
if (arg == "-h" || arg == "--help") {
return help_command{};
}
if (arg == "--version") {
return version_command{};
}
if (arg == "--dry-run") {
dry_run = true;
} else if (arg == "--dir") {
if (i + 1 == args.size()) {
return fail("--dir needs a path");
}
directory = args[++i];
} else if (arg.starts_with("--dir=")) {
directory = arg.substr(std::string_view("--dir=").size());
} else if (arg == "--") {
positional.insert(positional.end(), args.begin() + static_cast<std::ptrdiff_t>(i) + 1,
args.end());
break;
} else if (arg.size() > 1 && arg.starts_with('-')) {
return fail(std::format("unknown option '{}'", arg));
} else {
positional.push_back(arg);
}
}
if (directory && directory->empty()) {
return fail("--dir needs a path");
}
if (positional.empty()) {
return help_command{};
}
if (positional[0] != "new") {
return fail(std::format("unknown command '{}'", positional[0]));
}
if (positional.size() < 2) {
return fail("'new' needs a kind: lib or exe");
}
new_command cmd{.dry_run = dry_run};
if (positional[1] == "lib") {
cmd.kind = project_kind::library;
} else if (positional[1] == "exe") {
cmd.kind = project_kind::executable;
} else {
return fail(std::format("unknown project kind '{}' (expected lib or exe)", positional[1]));
}
if (positional.size() < 3) {
return fail(std::format("'new {}' needs a project name", positional[1]));
}
if (positional.size() > 3) {
return fail(std::format("unexpected argument '{}'", positional[3]));
}
cmd.name = positional[2];
if (directory) {
cmd.directory = *directory;
}
return cmd;
}
std::string_view usage() {
return R"(usage: canon <command> [options]
Commands:
new lib <name> Create a library project (the name must start with 'lib')
new exe <name> Create an executable project
Options:
--dir <path> Create the project inside <path> (default: current directory)
--dry-run Show what would be created without writing anything
-h, --help Show this help
--version Show canon's version
Projects follow P1204R0 (Canonical Project Structure): https://wg21.link/p1204r0
)";
}
} // namespace canon
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include <canon/name.hpp>
#include <expected>
#include <filesystem>
#include <span>
#include <string>
#include <string_view>
#include <variant>
namespace canon {
struct help_command {};
struct version_command {};
struct new_command {
project_kind kind{};
std::string name{};
std::filesystem::path directory = "."; // where the project directory is created
bool dry_run = false;
};
using command = std::variant<help_command, version_command, new_command>;
// Parses the arguments after the program name. Only syntax is checked here;
// project names are validated by parse_project_name.
[[nodiscard]] std::expected<command, std::string>
parse_command_line(std::span<const std::string_view> args);
[[nodiscard]] std::string_view usage();
} // namespace canon
+70
View File
@@ -0,0 +1,70 @@
#include <canon/cli.hpp>
#include <canon/details/testing.hpp>
#include <initializer_list>
#include <vector>
using namespace canon;
namespace {
auto parse(std::initializer_list<std::string_view> args) {
const std::vector<std::string_view> v(args);
return parse_command_line(v);
}
bool is_help(const std::expected<command, std::string>& c) {
return c && std::holds_alternative<help_command>(*c);
}
bool fails_with(const std::expected<command, std::string>& c, std::string_view message) {
return !c && c.error().contains(message);
}
} // namespace
int main() {
CANON_CHECK(is_help(parse({})));
CANON_CHECK(is_help(parse({"--help"})));
CANON_CHECK(is_help(parse({"new", "lib", "-h"})));
CANON_CHECK(parse({"--version"}) && std::holds_alternative<version_command>(*parse({"--version"})));
if (const auto c = parse({"new", "lib", "libhello"}); CANON_CHECK(c), c) {
const auto* n = std::get_if<new_command>(&*c);
CANON_CHECK(n != nullptr);
if (n != nullptr) {
CANON_CHECK(n->kind == project_kind::library);
CANON_CHECK_EQ(n->name, "libhello");
CANON_CHECK_EQ(n->directory.generic_string(), ".");
CANON_CHECK(!n->dry_run);
}
}
if (const auto c = parse({"--dry-run", "new", "exe", "hello", "--dir", "some/where"}); CANON_CHECK(c), c) {
const auto& n = std::get<new_command>(*c);
CANON_CHECK(n.kind == project_kind::executable);
CANON_CHECK(n.dry_run);
CANON_CHECK_EQ(n.directory.generic_string(), "some/where");
}
if (const auto c = parse({"new", "exe", "--dir=out", "hello"}); CANON_CHECK(c), c) {
CANON_CHECK_EQ(std::get<new_command>(*c).directory.generic_string(), "out");
}
// Everything after "--" is positional.
if (const auto c = parse({"new", "exe", "--", "-weird"}); CANON_CHECK(c), c) {
CANON_CHECK_EQ(std::get<new_command>(*c).name, "-weird");
}
CANON_CHECK(fails_with(parse({"build"}), "unknown command 'build'"));
CANON_CHECK(fails_with(parse({"new"}), "needs a kind"));
CANON_CHECK(fails_with(parse({"new", "app", "x"}), "unknown project kind 'app'"));
CANON_CHECK(fails_with(parse({"new", "lib"}), "needs a project name"));
CANON_CHECK(fails_with(parse({"new", "lib", "liba", "libb"}), "unexpected argument 'libb'"));
CANON_CHECK(fails_with(parse({"new", "lib", "liba", "--force"}), "unknown option '--force'"));
CANON_CHECK(fails_with(parse({"new", "lib", "liba", "--dir"}), "--dir needs a path"));
CANON_CHECK(fails_with(parse({"new", "lib", "liba", "--dir="}), "--dir needs a path"));
return details::exit_status();
}
+163
View File
@@ -0,0 +1,163 @@
#include <canon/cmake.hpp>
#include <algorithm>
#include <format>
#include <stdexcept>
namespace canon {
namespace {
const target& find_target(const manifest& m, std::string_view name, std::string_view user) {
const auto found = std::ranges::find(m.targets, name, &target::name);
if (found == m.targets.end()) {
throw std::invalid_argument(std::format("{} refers to unknown target '{}'", user, name));
}
return *found;
}
// hello/hello -> hello.hello: CMake target names cannot contain '/'.
std::string dotted(std::string_view path) {
std::string out(path);
std::ranges::replace(out, '/', '.');
return out;
}
std::string cmake_quote(std::string_view s) {
std::string out = "\"";
for (const char c : s) {
if (c == '"' || c == '\\' || c == '$' || c == ';') {
out += '\\';
}
out += c;
}
out += '"';
return out;
}
std::string join(const std::vector<std::string>& items, std::string_view separator) {
std::string out;
for (bool first = true; const auto& item : items) {
if (!first) {
out += separator;
}
out += item;
first = false;
}
return out;
}
void render_target(std::string& out, const manifest& m, const target& t) {
const bool library = t.type == target_type::library;
const std::string_view scope = library ? "PUBLIC" : "PRIVATE";
out += std::format("\n{}({}\n", library ? "add_library" : "add_executable", t.name);
for (const auto& source : t.sources) {
out += std::format(" {}\n", source);
}
out += ")\n";
if (library) {
// The target name already carries P1204R0's "lib" prefix; stop CMake adding another.
out += std::format("set_target_properties({} PROPERTIES PREFIX \"\")\n", t.name);
}
out += std::format("target_include_directories({} {} ${{CMAKE_CURRENT_SOURCE_DIR}})\n", t.name,
scope);
if (library) {
out += std::format("target_compile_features({} PUBLIC cxx_std_{})\n", t.name, m.cxx_standard);
}
if (!t.depends.empty()) {
out += std::format("target_link_libraries({} {} {})\n", t.name, scope, join(t.depends, " "));
}
}
void render_tests(std::string& out, const manifest& m) {
const bool any_unit_tests = std::ranges::any_of(m.modules, &source_module::unit_test);
if (!any_unit_tests && m.tests.empty()) {
return;
}
out += "\nif(BUILD_TESTING)\n";
bool first = true;
const auto group = [&] {
if (!first) {
out += '\n';
}
first = false;
};
for (const auto& mod : m.modules) {
if (!mod.unit_test) {
continue;
}
const target& owner = find_target(m, mod.target, std::format("module '{}'", mod.path));
const std::string exe = dotted(mod.path) + ".test";
group();
if (owner.type == target_type::library) {
out += std::format(" add_executable({} {}.test.cpp)\n", exe, mod.path);
out += std::format(" target_link_libraries({} PRIVATE {})\n", exe, owner.name);
} else {
// An executable cannot be linked against, so compile the module into the test.
out += std::format(" add_executable({} {}.test.cpp {}.cpp)\n", exe, mod.path, mod.path);
out += std::format(" target_include_directories({} PRIVATE ${{CMAKE_CURRENT_SOURCE_DIR}})\n",
exe);
}
out += std::format(" add_test(NAME {}.test COMMAND {})\n", mod.path, exe);
}
for (const auto& t : m.tests) {
const target& subject = find_target(m, t.target, std::format("test '{}'", t.name));
group();
if (t.kind == test_kind::driver) {
const std::string exe = dotted(t.path);
out += std::format(" add_executable({} {}/driver.cpp)\n", exe, t.path);
out += std::format(" target_link_libraries({} PRIVATE {})\n", exe, subject.name);
out += std::format(" add_test(NAME {} COMMAND {})\n", t.name, exe);
} else {
out += std::format(" add_test(NAME {} COMMAND {})\n", t.name, subject.name);
}
if (!t.expect.empty()) {
out += std::format(" set_tests_properties({} PROPERTIES PASS_REGULAR_EXPRESSION {})\n",
t.name, cmake_quote(t.expect));
}
}
out += "endif()\n";
}
} // namespace
std::string render_managed_block(const manifest& m) {
std::string out;
out += managed_begin;
out += "\n# Rendered from .canon.toml; canon rewrites everything between these markers.\n";
for (const auto& t : m.targets) {
render_target(out, m, t);
}
render_tests(out, m);
out += '\n';
out += managed_end;
out += '\n';
return out;
}
std::string render_cmakelists(const manifest& m) {
return std::format(R"cmake(cmake_minimum_required(VERSION {0})
project({1} VERSION 0.1.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD {2})
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
option(BUILD_TESTING "Build {1}'s tests" ON)
if(BUILD_TESTING)
enable_testing()
endif()
# Add your own CMake outside the managed block below.
{3})cmake",
m.cmake_minimum, m.project.name, m.cxx_standard, render_managed_block(m));
}
} // namespace canon
+24
View File
@@ -0,0 +1,24 @@
#pragma once
#include <canon/manifest.hpp>
#include <string>
#include <string_view>
namespace canon {
// canon owns everything between these markers and re-renders it from the
// manifest as a whole, so rewriting it is idempotent. Everything outside
// belongs to the user.
inline constexpr std::string_view managed_begin = "# >>> canon:managed >>>";
inline constexpr std::string_view managed_end = "# <<< canon:managed <<<";
// The managed block, markers included, rendered purely from the manifest.
// Throws std::invalid_argument if a module or test names an unknown target.
[[nodiscard]] std::string render_managed_block(const manifest&);
// A complete CMakeLists.txt for a new project: a user-owned preamble followed
// by the managed block.
[[nodiscard]] std::string render_cmakelists(const manifest&);
} // namespace canon
+98
View File
@@ -0,0 +1,98 @@
#include <canon/cmake.hpp>
#include <canon/details/testing.hpp>
#include <stdexcept>
#include <string>
#include <string_view>
using namespace canon;
namespace {
std::size_t count(std::string_view haystack, std::string_view needle) {
std::size_t n = 0;
for (auto at = haystack.find(needle); at != std::string_view::npos;
at = haystack.find(needle, at + needle.size())) {
++n;
}
return n;
}
manifest library() {
manifest m{.project = {project_kind::library, "libhello", "hello", "hello"}};
m.targets.push_back(
{.name = "libhello", .type = target_type::library, .sources = {"hello/hello.cpp"}});
m.modules.push_back({.path = "hello/hello", .target = "libhello", .unit_test = true});
m.tests.push_back(
{.name = "basics", .kind = test_kind::driver, .path = "tests/basics", .target = "libhello"});
return m;
}
} // namespace
int main() {
{
const std::string block = render_managed_block(library());
CANON_CHECK(block.starts_with(managed_begin));
CANON_CHECK(block.ends_with(std::string(managed_end) + "\n"));
CANON_CHECK_EQ(count(block, managed_begin), 1u);
CANON_CHECK_EQ(count(block, managed_end), 1u);
CANON_CHECK(block.contains("add_library(libhello\n hello/hello.cpp\n)\n"));
CANON_CHECK(block.contains("set_target_properties(libhello PROPERTIES PREFIX \"\")\n"));
CANON_CHECK(block.contains(
"target_include_directories(libhello PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})\n"));
CANON_CHECK(block.contains("target_compile_features(libhello PUBLIC cxx_std_23)\n"));
CANON_CHECK(block.contains(" add_executable(hello.hello.test hello/hello.test.cpp)\n"
" target_link_libraries(hello.hello.test PRIVATE libhello)\n"
" add_test(NAME hello/hello.test COMMAND hello.hello.test)\n"));
CANON_CHECK(block.contains(" add_executable(tests.basics tests/basics/driver.cpp)\n"
" target_link_libraries(tests.basics PRIVATE libhello)\n"
" add_test(NAME basics COMMAND tests.basics)\n"));
// Rendering is a pure function of the manifest.
CANON_CHECK_EQ(render_managed_block(library()), block);
}
{
manifest m{.project = {project_kind::executable, "tool", "tool", "tool"}};
m.targets.push_back({.name = "tool",
.type = target_type::executable,
.sources = {"tool/tool.cpp", "tool/greet.cpp"}});
m.modules.push_back({.path = "tool/greet", .target = "tool", .unit_test = true});
m.tests.push_back(
{.name = "run", .kind = test_kind::run, .target = "tool", .expect = "Hi \"$x\";"});
const std::string block = render_managed_block(m);
CANON_CHECK(block.contains("add_executable(tool\n"));
CANON_CHECK(!block.contains("PREFIX"));
CANON_CHECK(block.contains("target_include_directories(tool PRIVATE"));
CANON_CHECK(block.contains(" add_executable(tool.greet.test tool/greet.test.cpp tool/greet.cpp)\n"));
CANON_CHECK(block.contains(" add_test(NAME run COMMAND tool)\n"));
CANON_CHECK(block.contains(
R"( set_tests_properties(run PROPERTIES PASS_REGULAR_EXPRESSION "Hi \"\$x\"\;"))"));
}
{
manifest m = library();
m.targets.clear();
m.targets.push_back({.name = "libother", .type = target_type::library});
bool threw = false;
try {
(void)render_managed_block(m);
} catch (const std::invalid_argument& e) {
threw = std::string_view(e.what()).contains("libhello");
}
CANON_CHECK(threw);
}
{
const std::string file = render_cmakelists(library());
CANON_CHECK(file.starts_with("cmake_minimum_required(VERSION 3.20)\n"
"project(libhello VERSION 0.1.0 LANGUAGES CXX)\n"));
CANON_CHECK(file.contains("set(CMAKE_CXX_STANDARD 23)\n"));
CANON_CHECK(file.ends_with(render_managed_block(library())));
}
return details::exit_status();
}
+11
View File
@@ -0,0 +1,11 @@
#pragma once
namespace canon::details {
// Builds a std::visit visitor out of lambdas.
template <class... Fs>
struct overloaded : Fs... {
using Fs::operator()...;
};
} // namespace canon::details
+68
View File
@@ -0,0 +1,68 @@
#pragma once
// Minimal unit-test support: CANON_CHECK / CANON_CHECK_EQ record failures and
// keep going; main() returns canon::details::exit_status().
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <format>
#include <print>
#include <random>
#include <source_location>
#include <string_view>
namespace canon::details {
inline int failures = 0;
inline void check(bool ok, std::string_view expr,
std::source_location where = std::source_location::current()) {
if (ok) {
return;
}
++failures;
std::println(stderr, "{}:{}: check failed: {}", where.file_name(), where.line(), expr);
}
template <class Actual, class Expected>
void check_equal(const Actual& actual, const Expected& expected, std::string_view expr,
std::source_location where = std::source_location::current()) {
if (actual == expected) {
return;
}
++failures;
std::println(stderr, "{}:{}: check failed: {}\n actual: {}\n expected: {}",
where.file_name(), where.line(), expr, actual, expected);
}
[[nodiscard]] inline int exit_status() {
return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE;
}
// A fresh directory under the system temp dir, removed on destruction.
class temp_directory {
public:
temp_directory() {
std::random_device random;
path_ = std::filesystem::temp_directory_path() / std::format("canon-test-{:08x}", random());
std::filesystem::create_directories(path_);
}
~temp_directory() {
std::error_code ignored;
std::filesystem::remove_all(path_, ignored);
}
temp_directory(const temp_directory&) = delete;
temp_directory& operator=(const temp_directory&) = delete;
[[nodiscard]] const std::filesystem::path& path() const { return path_; }
private:
std::filesystem::path path_;
};
} // namespace canon::details
#define CANON_CHECK(expr) ::canon::details::check(static_cast<bool>(expr), #expr)
#define CANON_CHECK_EQ(actual, expected) \
::canon::details::check_equal((actual), (expected), #actual " == " #expected)
+79
View File
@@ -0,0 +1,79 @@
#include <canon/executor.hpp>
#include <format>
#include <fstream>
namespace canon {
namespace fs = std::filesystem;
namespace {
using result = std::expected<void, std::string>;
std::string display(const fs::path& root, const fs::path& path) {
return (root / path).lexically_normal().generic_string();
}
result check(const make_directory& op, const fs::path& root) {
std::error_code ignored;
const auto status = fs::symlink_status(root / op.path, ignored);
if (fs::exists(status) && !fs::is_directory(status)) {
return std::unexpected(
std::format("{} already exists and is not a directory", display(root, op.path)));
}
return {};
}
result check(const create_file& op, const fs::path& root) {
std::error_code ignored;
if (fs::exists(fs::symlink_status(root / op.path, ignored))) {
return std::unexpected(
std::format("{} already exists; canon never overwrites files", display(root, op.path)));
}
return {};
}
result write(const make_directory& op, const fs::path& root) {
std::error_code error;
fs::create_directories(root / op.path, error);
if (error) {
return std::unexpected(
std::format("cannot create {}: {}", display(root, op.path), error.message()));
}
return {};
}
result write(const create_file& op, const fs::path& root) {
std::ofstream out(root / op.path, std::ios::out | std::ios::binary);
if (!out) {
return std::unexpected(std::format("cannot open {} for writing", display(root, op.path)));
}
out.write(op.content.data(), static_cast<std::streamsize>(op.content.size()));
out.close();
if (!out) {
return std::unexpected(std::format("cannot write {}", display(root, op.path)));
}
return {};
}
} // namespace
result apply(const plan& p, const fs::path& root, apply_options options) {
for (const auto& op : p.operations) {
if (auto ok = std::visit([&](const auto& o) { return check(o, root); }, op); !ok) {
return ok;
}
}
if (options.dry_run) {
return {};
}
for (const auto& op : p.operations) {
if (auto ok = std::visit([&](const auto& o) { return write(o, root); }, op); !ok) {
return ok;
}
}
return {};
}
} // namespace canon
+20
View File
@@ -0,0 +1,20 @@
#pragma once
#include <canon/plan.hpp>
#include <expected>
#include <filesystem>
#include <string>
namespace canon {
struct apply_options {
bool dry_run = false; // check the plan against the filesystem, write nothing
};
// Applies the plan inside root. Every operation is checked before anything is
// written, so a conflict (such as an existing file) leaves the filesystem untouched.
[[nodiscard]] std::expected<void, std::string> apply(const plan&, const std::filesystem::path& root,
apply_options = {});
} // namespace canon
+76
View File
@@ -0,0 +1,76 @@
#include <canon/executor.hpp>
#include <canon/details/testing.hpp>
#include <filesystem>
#include <fstream>
#include <sstream>
using namespace canon;
namespace fs = std::filesystem;
namespace {
std::string read(const fs::path& file) {
std::ifstream in(file, std::ios::binary);
std::ostringstream content;
content << in.rdbuf();
return content.str();
}
} // namespace
int main() {
plan p;
p.add_file("proj/src/main.cpp", "int main() {}\n");
p.add_file("proj/README.md", "# proj\n");
{
const details::temp_directory tmp;
const auto checked = apply(p, tmp.path(), {.dry_run = true});
CANON_CHECK(checked.has_value());
CANON_CHECK(fs::is_empty(tmp.path())); // a dry run writes nothing
}
{
const details::temp_directory tmp;
CANON_CHECK(apply(p, tmp.path()).has_value());
CANON_CHECK_EQ(read(tmp.path() / "proj/src/main.cpp"), "int main() {}\n");
CANON_CHECK_EQ(read(tmp.path() / "proj/README.md"), "# proj\n");
// Applying again conflicts: existing files are never overwritten.
const auto again = apply(p, tmp.path());
CANON_CHECK(!again);
if (!again) {
CANON_CHECK(again.error().contains("proj/src/main.cpp already exists"));
}
}
{
// A conflict late in the plan stops the whole plan before anything is written.
const details::temp_directory tmp;
fs::create_directories(tmp.path() / "proj");
std::ofstream(tmp.path() / "proj/README.md") << "mine\n";
const auto result = apply(p, tmp.path());
CANON_CHECK(!result);
CANON_CHECK(!fs::exists(tmp.path() / "proj/src"));
CANON_CHECK_EQ(read(tmp.path() / "proj/README.md"), "mine\n");
// The dry run reports the same conflict.
CANON_CHECK(!apply(p, tmp.path(), {.dry_run = true}));
}
{
// A file where the plan needs a directory is a conflict too.
const details::temp_directory tmp;
std::ofstream(tmp.path() / "proj") << "not a directory\n";
const auto result = apply(p, tmp.path());
CANON_CHECK(!result);
if (!result) {
CANON_CHECK(result.error().contains("is not a directory"));
}
}
return details::exit_status();
}
+148
View File
@@ -0,0 +1,148 @@
#include <canon/generate.hpp>
#include <canon/cmake.hpp>
#include <canon/template.hpp>
#include <string_view>
namespace canon {
namespace {
constexpr std::string_view gitignore = "/build/\n";
constexpr std::string_view readme = R"md(# {{name}}
Created by canon. The layout follows [P1204R0](https://wg21.link/p1204r0)
(Canonical Project Structure).
## Build and test
```bash
cmake -S . -B build
cmake --build build
ctest --test-dir build --output-on-failure
```
)md";
constexpr std::string_view library_header = R"cpp(#pragma once
#include <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";
} // namespace
manifest new_project_manifest(const project_name& n) {
manifest m{.project = n};
const std::string main_path = n.stem + "/" + n.stem;
if (n.kind == project_kind::library) {
m.targets.push_back({
.name = n.name,
.type = target_type::library,
.sources = {main_path + ".cpp"},
});
m.modules.push_back({.path = main_path, .target = n.name, .unit_test = true});
m.tests.push_back({
.name = "basics",
.kind = test_kind::driver,
.path = "tests/basics",
.target = n.name,
});
} else {
m.targets.push_back({
.name = n.name,
.type = target_type::executable,
.sources = {main_path + ".cpp"},
});
m.tests.push_back({
.name = "basics",
.kind = test_kind::run,
.target = n.name,
.expect = "Hello, World!",
});
}
return m;
}
plan generate_new_project(const project_name& n) {
const manifest m = new_project_manifest(n);
const template_vars vars{{"name", n.name}, {"stem", n.stem}, {"ident", n.ident}};
const std::filesystem::path root = n.name;
const std::filesystem::path sources = root / n.stem;
plan p;
p.add_file(root / ".canon.toml", to_toml(m));
p.add_file(root / ".gitignore", std::string(gitignore));
p.add_file(root / "CMakeLists.txt", render_cmakelists(m));
p.add_file(root / "README.md", render(readme, vars));
if (n.kind == project_kind::library) {
p.add_file(sources / (n.stem + ".hpp"), render(library_header, vars));
p.add_file(sources / (n.stem + ".cpp"), render(library_source, vars));
p.add_file(sources / (n.stem + ".test.cpp"), render(library_unit_test, vars));
p.add_file(root / "tests" / "basics" / "driver.cpp", render(library_driver, vars));
} else {
p.add_file(sources / (n.stem + ".cpp"), render(executable_source, vars));
}
return p;
}
} // namespace canon
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include <canon/manifest.hpp>
#include <canon/name.hpp>
#include <canon/plan.hpp>
namespace canon {
// What canon declares for a brand-new project of this name and kind.
[[nodiscard]] manifest new_project_manifest(const project_name&);
// The plan for `canon new`. Paths start with the project directory (e.g.
// libhello/...), so the plan is applied in the directory that will contain it.
//
// Library libhello: Executable hello:
// libhello/ hello/
// ├── .canon.toml ├── .canon.toml
// ├── .gitignore ├── .gitignore
// ├── CMakeLists.txt ├── CMakeLists.txt
// ├── README.md ├── README.md
// ├── hello/ └── hello/
// │ ├── hello.hpp └── hello.cpp
// │ ├── hello.cpp
// │ └── hello.test.cpp
// └── tests/basics/driver.cpp
[[nodiscard]] plan generate_new_project(const project_name&);
} // namespace canon
+117
View File
@@ -0,0 +1,117 @@
#include <canon/generate.hpp>
#include <canon/details/testing.hpp>
#include <algorithm>
#include <set>
#include <string>
#include <vector>
using namespace canon;
namespace fs = std::filesystem;
namespace {
std::vector<std::string> created_files(const plan& p) {
std::vector<std::string> files;
for (const auto& op : p.operations) {
if (const auto* file = std::get_if<create_file>(&op)) {
files.push_back(file->path.generic_string());
}
}
return files;
}
const create_file* find_file(const plan& p, std::string_view path) {
for (const auto& op : p.operations) {
if (const auto* file = std::get_if<create_file>(&op); file && file->path == path) {
return file;
}
}
return nullptr;
}
// Invariants every plan must satisfy, whatever it generates.
void check_well_formed(const plan& p, std::string_view project) {
std::set<fs::path> directories;
for (const auto& op : p.operations) {
const fs::path& path = std::visit([](const auto& o) -> const fs::path& { return o.path; }, op);
CANON_CHECK(path.is_relative());
CANON_CHECK(std::ranges::find(path, "..") == path.end());
CANON_CHECK_EQ(path.begin()->generic_string(), project);
// Every parent directory is created before anything inside it.
if (path.has_parent_path()) {
CANON_CHECK(directories.contains(path.parent_path()));
}
if (std::holds_alternative<make_directory>(op)) {
CANON_CHECK(directories.insert(path).second); // no duplicates
}
}
}
} // namespace
int main() {
{
const auto name = parse_project_name(project_kind::library, "libhello");
CANON_CHECK(name);
const plan p = generate_new_project(*name);
check_well_formed(p, "libhello");
CANON_CHECK(created_files(p) == std::vector<std::string>({
"libhello/.canon.toml",
"libhello/.gitignore",
"libhello/CMakeLists.txt",
"libhello/README.md",
"libhello/hello/hello.hpp",
"libhello/hello/hello.cpp",
"libhello/hello/hello.test.cpp",
"libhello/tests/basics/driver.cpp",
}));
if (const auto* toml = find_file(p, "libhello/.canon.toml"); CANON_CHECK(toml), toml) {
CANON_CHECK(toml->content.contains("kind = \"lib\""));
}
if (const auto* source = find_file(p, "libhello/hello/hello.cpp"); CANON_CHECK(source), source) {
CANON_CHECK(source->content.starts_with("#include <hello/hello.hpp>\n"));
}
// Everything generated from a template was fully rendered.
for (const auto& op : p.operations) {
if (const auto* file = std::get_if<create_file>(&op)) {
CANON_CHECK(!file->content.contains("{{"));
}
}
}
{
const auto name = parse_project_name(project_kind::library, "libfoo-bar");
CANON_CHECK(name);
const plan p = generate_new_project(*name);
const auto* header = find_file(p, "libfoo-bar/foo-bar/foo-bar.hpp");
CANON_CHECK(header != nullptr);
if (header != nullptr) {
CANON_CHECK(header->content.contains("namespace foo_bar {"));
}
}
{
const auto name = parse_project_name(project_kind::executable, "hello");
CANON_CHECK(name);
const plan p = generate_new_project(*name);
check_well_formed(p, "hello");
CANON_CHECK(created_files(p) == std::vector<std::string>({
"hello/.canon.toml",
"hello/.gitignore",
"hello/CMakeLists.txt",
"hello/README.md",
"hello/hello/hello.cpp",
}));
const manifest m = new_project_manifest(*name);
CANON_CHECK_EQ(m.targets.size(), 1u);
CANON_CHECK(m.modules.empty());
CANON_CHECK_EQ(m.tests.size(), 1u);
}
return details::exit_status();
}
+106
View File
@@ -0,0 +1,106 @@
#include <canon/manifest.hpp>
#include <format>
namespace canon {
namespace {
std::string quote(std::string_view s) {
std::string out = "\"";
for (const char c : s) {
switch (c) {
case '"': out += "\\\""; break;
case '\\': out += "\\\\"; break;
case '\n': out += "\\n"; break;
case '\r': out += "\\r"; break;
case '\t': out += "\\t"; break;
default:
if (const auto byte = static_cast<unsigned char>(c); byte < 0x20 || byte == 0x7f) {
out += std::format("\\u{:04X}", static_cast<unsigned>(byte));
} else {
out += c;
}
}
}
out += '"';
return out;
}
std::string array(const std::vector<std::string>& items) {
std::string out = "[";
for (bool first = true; const auto& item : items) {
if (!first) {
out += ", ";
}
out += quote(item);
first = false;
}
out += ']';
return out;
}
} // namespace
std::string_view to_string(target_type type) {
return type == target_type::library ? "library" : "executable";
}
std::string_view to_string(test_kind kind) {
return kind == test_kind::driver ? "driver" : "run";
}
std::string to_toml(const manifest& m) {
std::string out;
const auto line = [&out](std::string_view key, std::string_view value) {
out += std::format("{} = {}\n", key, value);
};
out += "# canon project manifest: what canon declared for this project.\n"
"# The managed block in CMakeLists.txt is rendered from this file.\n";
out += "\n[project]\n";
line("name", quote(m.project.name));
line("kind", quote(to_string(m.project.kind)));
line("stem", quote(m.project.stem));
line("spec", quote(m.spec));
out += "\n[build]\n";
line("backend", quote(m.backend));
line("cmake-minimum", quote(m.cmake_minimum));
line("cxx-standard", std::to_string(m.cxx_standard));
for (const auto& t : m.targets) {
out += "\n[[targets]]\n";
line("name", quote(t.name));
line("type", quote(to_string(t.type)));
line("sources", array(t.sources));
if (!t.depends.empty()) {
line("depends", array(t.depends));
}
}
for (const auto& mod : m.modules) {
out += "\n[[modules]]\n";
line("path", quote(mod.path));
line("target", quote(mod.target));
line("unit-test", mod.unit_test ? "true" : "false");
}
for (const auto& t : m.tests) {
out += "\n[[tests]]\n";
line("name", quote(t.name));
line("kind", quote(to_string(t.kind)));
if (!t.path.empty()) {
line("path", quote(t.path));
}
line("target", quote(t.target));
if (!t.expect.empty()) {
line("expect", quote(t.expect));
}
}
return out;
}
} // namespace canon
+59
View File
@@ -0,0 +1,59 @@
#pragma once
#include <canon/name.hpp>
#include <string>
#include <string_view>
#include <vector>
namespace canon {
enum class target_type { library, executable };
enum class test_kind {
driver, // tests/<name>/driver.cpp, linked against a library
run, // runs an executable target directly
};
[[nodiscard]] std::string_view to_string(target_type);
[[nodiscard]] std::string_view to_string(test_kind);
struct target {
std::string name{};
target_type type{};
std::vector<std::string> sources{};
std::vector<std::string> depends{};
};
// A related file set: <path>.hpp, <path>.cpp and optionally <path>.test.cpp.
struct source_module {
std::string path{}; // hello/hello
std::string target{}; // the target whose sources include <path>.cpp
bool unit_test = false;
};
struct test_entry {
std::string name{};
test_kind kind{};
std::string path{}; // driver: directory holding driver.cpp
std::string target{}; // driver: library to link; run: executable to run
std::string expect{}; // regex the output must match; empty checks the exit status only
};
// What canon declared for a project. It records intent, not the state of the
// filesystem: users edit files by hand, and later commands compare the two.
struct manifest {
project_name project{};
std::string spec = "P1204R0";
std::string backend = "cmake";
std::string cmake_minimum = "3.20";
int cxx_standard = 23;
std::vector<target> targets{};
std::vector<source_module> modules{};
std::vector<test_entry> tests{};
};
// The contents of .canon.toml.
[[nodiscard]] std::string to_toml(const manifest&);
} // namespace canon
+35
View File
@@ -0,0 +1,35 @@
#include <canon/manifest.hpp>
#include <canon/details/testing.hpp>
using namespace canon;
int main() {
manifest m{.project = {project_kind::library, "libhello", "hello", "hello"}};
m.targets.push_back({.name = "libhello",
.type = target_type::library,
.sources = {"hello/hello.cpp", "hello/extra.cpp"}});
m.modules.push_back({.path = "hello/hello", .target = "libhello", .unit_test = true});
m.tests.push_back(
{.name = "basics", .kind = test_kind::driver, .path = "tests/basics", .target = "libhello"});
const std::string toml = to_toml(m);
CANON_CHECK(toml.contains("[project]\nname = \"libhello\"\nkind = \"lib\"\nstem = \"hello\"\n"
"spec = \"P1204R0\"\n"));
CANON_CHECK(toml.contains("[build]\nbackend = \"cmake\"\ncmake-minimum = \"3.20\"\n"
"cxx-standard = 23\n"));
CANON_CHECK(toml.contains("[[targets]]\nname = \"libhello\"\ntype = \"library\"\n"
"sources = [\"hello/hello.cpp\", \"hello/extra.cpp\"]\n"));
CANON_CHECK(!toml.contains("depends")); // empty lists are omitted
CANON_CHECK(toml.contains("[[modules]]\npath = \"hello/hello\"\ntarget = \"libhello\"\n"
"unit-test = true\n"));
CANON_CHECK(toml.contains("[[tests]]\nname = \"basics\"\nkind = \"driver\"\n"
"path = \"tests/basics\"\ntarget = \"libhello\"\n"));
CANON_CHECK(!toml.contains("expect"));
// Strings are escaped as TOML basic strings.
manifest odd{.project = {project_kind::executable, "a\"b\\c\n\x01", "x", "x"}};
CANON_CHECK(to_toml(odd).contains("name = \"a\\\"b\\\\c\\n\\u0001\""));
return details::exit_status();
}
+101
View File
@@ -0,0 +1,101 @@
#include <canon/name.hpp>
#include <algorithm>
#include <array>
#include <format>
namespace canon {
namespace {
constexpr auto cxx_keywords = std::to_array<std::string_view>({
"alignas", "alignof", "and", "and_eq", "asm", "auto",
"bitand", "bitor", "bool", "break", "case", "catch",
"char", "char8_t", "char16_t", "char32_t", "class", "compl",
"concept", "const", "const_cast", "constexpr", "constinit", "continue",
"co_await", "co_return", "co_yield", "decltype", "default", "delete",
"do", "double", "dynamic_cast", "else", "enum", "explicit",
"export", "extern", "false", "float", "for", "friend",
"goto", "if", "inline", "int", "long", "mutable",
"namespace", "new", "noexcept", "not", "not_eq", "nullptr",
"operator", "or", "or_eq", "private", "protected", "public",
"register", "reinterpret_cast", "requires", "return", "short", "signed",
"sizeof", "static", "static_assert", "static_cast", "struct", "switch",
"template", "this", "thread_local", "throw", "true", "try",
"typedef", "typeid", "typename", "union", "unsigned", "using",
"virtual", "void", "volatile", "wchar_t", "while", "xor",
"xor_eq",
});
bool is_separator(char c) {
return c == '-' || c == '_';
}
// Why stem is unusable, or an empty string if it is fine.
std::string stem_problem(std::string_view stem) {
if (stem.empty()) {
return "the name is empty";
}
if (stem.front() < 'a' || stem.front() > 'z') {
return "it must start with a lowercase letter";
}
char previous = '\0';
for (char c : stem) {
const bool alnum = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9');
if (!alnum && !is_separator(c)) {
return std::format("'{}' is not allowed (use a-z, 0-9, '-' or '_')", c);
}
if (is_separator(c) && is_separator(previous)) {
return "'-' and '_' cannot appear next to each other";
}
previous = c;
}
if (is_separator(previous)) {
return "it cannot end with '-' or '_'";
}
return {};
}
} // namespace
std::string_view to_string(project_kind kind) {
return kind == project_kind::library ? "lib" : "exe";
}
std::expected<project_name, std::string> parse_project_name(project_kind kind,
std::string_view name) {
constexpr std::string_view prefix = "lib";
std::string_view stem = name;
if (kind == project_kind::library) {
if (!name.starts_with(prefix)) {
return std::unexpected(std::format(
"library name '{}' must start with 'lib' (P1204R0), e.g. 'lib{}'", name, name));
}
stem.remove_prefix(prefix.size());
} else if (name.starts_with(prefix)) {
return std::unexpected(std::format(
"executable name '{}' must not start with 'lib' (P1204R0 reserves it for libraries)",
name));
}
if (const std::string problem = stem_problem(stem); !problem.empty()) {
return std::unexpected(std::format("invalid name '{}': {}", name, problem));
}
std::string ident(stem);
std::ranges::replace(ident, '-', '_');
if (std::ranges::contains(cxx_keywords, std::string_view(ident))) {
return std::unexpected(std::format(
"invalid name '{}': '{}' is a C++ keyword and cannot be a namespace", name, ident));
}
return project_name{
.kind = kind,
.name = std::string(name),
.stem = std::string(stem),
.ident = std::move(ident),
};
}
} // namespace canon
+27
View File
@@ -0,0 +1,27 @@
#pragma once
#include <expected>
#include <string>
#include <string_view>
namespace canon {
enum class project_kind { library, executable };
// "lib" or "exe": the word used on the command line and in .canon.toml.
[[nodiscard]] std::string_view to_string(project_kind);
// A validated project name and the names P1204R0 derives from it.
struct project_name {
project_kind kind;
std::string name; // libhello, hello
std::string stem; // hello: the source directory and include prefix, <hello/...>
std::string ident; // hello: C++ namespace ('-' becomes '_')
};
// Libraries must be named lib<stem>; executables must not start with "lib".
// Stems use a-z, 0-9, '-' and '_', start with a letter, and must not be a C++ keyword.
[[nodiscard]] std::expected<project_name, std::string> parse_project_name(project_kind,
std::string_view name);
} // namespace canon
+53
View File
@@ -0,0 +1,53 @@
#include <canon/name.hpp>
#include <canon/details/testing.hpp>
#include <string_view>
using namespace canon;
int main() {
if (const auto n = parse_project_name(project_kind::library, "libhello"); CANON_CHECK(n), n) {
CANON_CHECK_EQ(n->name, "libhello");
CANON_CHECK_EQ(n->stem, "hello");
CANON_CHECK_EQ(n->ident, "hello");
}
if (const auto n = parse_project_name(project_kind::library, "libfoo-bar"); CANON_CHECK(n), n) {
CANON_CHECK_EQ(n->stem, "foo-bar");
CANON_CHECK_EQ(n->ident, "foo_bar");
}
if (const auto n = parse_project_name(project_kind::executable, "hello2"); CANON_CHECK(n), n) {
CANON_CHECK_EQ(n->name, "hello2");
CANON_CHECK_EQ(n->stem, "hello2");
}
{
const auto n = parse_project_name(project_kind::library, "hello");
CANON_CHECK(!n);
CANON_CHECK(n.error().contains("must start with 'lib'"));
}
{
const auto n = parse_project_name(project_kind::executable, "libhello");
CANON_CHECK(!n);
CANON_CHECK(n.error().contains("must not start with 'lib'"));
}
{
const auto n = parse_project_name(project_kind::library, "libnew");
CANON_CHECK(!n);
CANON_CHECK(n.error().contains("C++ keyword"));
}
for (const std::string_view bad : {"lib", "libHello", "lib2d", "libfoo-", "libfoo--bar",
"libfoo_-bar", "libfoo.bar", "lib-foo", "libfoo bar"}) {
CANON_CHECK_EQ(parse_project_name(project_kind::library, bad).has_value(), false);
}
for (const std::string_view bad : {"", "Hello", "2hello", "hello!", "new", "hello-"}) {
CANON_CHECK_EQ(parse_project_name(project_kind::executable, bad).has_value(), false);
}
return details::exit_status();
}
+46
View File
@@ -0,0 +1,46 @@
#include <canon/plan.hpp>
#include <canon/details/overloaded.hpp>
#include <algorithm>
#include <format>
namespace canon {
void plan::add_directory(const std::filesystem::path& dir) {
if (dir.empty()) {
return;
}
const bool planned = std::ranges::any_of(operations, [&](const operation& op) {
const auto* existing = std::get_if<make_directory>(&op);
return existing != nullptr && existing->path == dir;
});
if (planned) {
return;
}
add_directory(dir.parent_path());
operations.emplace_back(make_directory{dir});
}
void plan::add_file(std::filesystem::path file, std::string content) {
add_directory(file.parent_path());
operations.emplace_back(create_file{std::move(file), std::move(content)});
}
std::string describe(const plan& p) {
std::string out;
for (const auto& op : p.operations) {
std::visit(details::overloaded{
[&](const make_directory& d) {
out += std::format(" mkdir {}/\n", d.path.generic_string());
},
[&](const create_file& f) {
out += std::format(" create {}\n", f.path.generic_string());
},
},
op);
}
return out;
}
} // namespace canon
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include <filesystem>
#include <string>
#include <variant>
#include <vector>
namespace canon {
struct make_directory {
std::filesystem::path path;
};
// Never overwrites: applying fails if the file already exists.
struct create_file {
std::filesystem::path path;
std::string content;
};
using operation = std::variant<make_directory, create_file>;
// What a generator wants done, as plain data. Generators never touch the
// filesystem; the executor applies a plan (or only checks it, for --dry-run).
// Paths are relative to the directory the plan is applied in.
struct plan {
std::vector<operation> operations;
// Adds the directory, preceded by any parents not already in the plan.
void add_directory(const std::filesystem::path&);
// Adds the file, preceded by any parent directories not already in the plan.
void add_file(std::filesystem::path, std::string content);
};
// One line per operation, e.g. " create libhello/hello/hello.hpp".
[[nodiscard]] std::string describe(const plan&);
} // namespace canon
+32
View File
@@ -0,0 +1,32 @@
#include <canon/plan.hpp>
#include <canon/details/testing.hpp>
using namespace canon;
int main() {
plan p;
p.add_file("proj/a/b/file.txt", "one");
p.add_file("proj/a/other.txt", "two");
p.add_directory("proj/a/b");
// Parents come first and each directory appears once.
CANON_CHECK_EQ(describe(p), " mkdir proj/\n"
" mkdir proj/a/\n"
" mkdir proj/a/b/\n"
" create proj/a/b/file.txt\n"
" create proj/a/other.txt\n");
const auto* file = std::get_if<create_file>(&p.operations.at(3));
CANON_CHECK(file != nullptr);
if (file != nullptr) {
CANON_CHECK_EQ(file->content, "one");
}
plan empty;
empty.add_directory("");
CANON_CHECK(empty.operations.empty());
CANON_CHECK_EQ(describe(empty), "");
return details::exit_status();
}
+36
View File
@@ -0,0 +1,36 @@
#include <canon/template.hpp>
#include <format>
#include <stdexcept>
namespace canon {
std::string render(std::string_view text, const template_vars& vars) {
std::string out;
out.reserve(text.size());
for (;;) {
const auto open = text.find("{{");
if (open == std::string_view::npos) {
out += text;
return out;
}
out += text.substr(0, open);
const auto close = text.find("}}", open + 2);
if (close == std::string_view::npos) {
throw std::invalid_argument(
std::format("unterminated placeholder in template near '{}'", text.substr(open, 20)));
}
const std::string_view key = text.substr(open + 2, close - open - 2);
const auto var = vars.find(key);
if (var == vars.end()) {
throw std::invalid_argument(std::format("template uses unknown variable '{}'", key));
}
out += var->second;
text.remove_prefix(close + 2);
}
}
} // namespace canon
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#include <functional>
#include <map>
#include <string>
#include <string_view>
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.
[[nodiscard]] std::string render(std::string_view text, const template_vars& vars);
} // namespace canon
+35
View File
@@ -0,0 +1,35 @@
#include <canon/template.hpp>
#include <canon/details/testing.hpp>
#include <stdexcept>
using namespace canon;
int main() {
const template_vars vars{{"name", "libhello"}, {"stem", "hello"}};
CANON_CHECK_EQ(render("", vars), "");
CANON_CHECK_EQ(render("no placeholders { } }}", vars), "no placeholders { } }}");
CANON_CHECK_EQ(render("{{name}}", vars), "libhello");
CANON_CHECK_EQ(render("#include <{{stem}}/{{stem}}.hpp>", vars), "#include <hello/hello.hpp>");
CANON_CHECK_EQ(render("CMake ${VAR} is left alone", vars), "CMake ${VAR} is left alone");
bool threw = false;
try {
(void)render("{{nmae}}", vars);
} catch (const std::invalid_argument& e) {
threw = std::string_view(e.what()).contains("nmae");
}
CANON_CHECK(threw);
threw = false;
try {
(void)render("oops {{name", vars);
} catch (const std::invalid_argument&) {
threw = true;
}
CANON_CHECK(threw);
return details::exit_status();
}
+41
View File
@@ -0,0 +1,41 @@
# Scaffolds each project kind, then configures, builds and tests the result
# with the given compiler. Proves generated projects work, not just that they
# look right.
#
# cmake -DCANON=<canon executable> -DWORK_DIR=<scratch dir> -DCXX=<compiler> -P check.cmake
cmake_minimum_required(VERSION 3.20)
foreach(var CANON WORK_DIR CXX)
if(NOT DEFINED ${var})
message(FATAL_ERROR "${var} is not set")
endif()
endforeach()
file(REMOVE_RECURSE ${WORK_DIR})
file(MAKE_DIRECTORY ${WORK_DIR})
function(run)
execute_process(COMMAND ${ARGN}
RESULT_VARIABLE status
OUTPUT_VARIABLE output
ERROR_VARIABLE errors)
if(NOT status EQUAL 0)
string(JOIN " " command ${ARGN})
message(FATAL_ERROR "Command failed (${status}): ${command}\n${output}${errors}")
endif()
endfunction()
foreach(case lib:libhello exe:hello)
string(REPLACE ":" ";" parts ${case})
list(GET parts 0 kind)
list(GET parts 1 name)
set(project ${WORK_DIR}/${name})
message(STATUS "e2e: canon new ${kind} ${name}")
run(${CANON} new ${kind} ${name} --dir ${WORK_DIR})
run(${CMAKE_COMMAND} -S ${project} -B ${project}/build
-DCMAKE_CXX_COMPILER=${CXX} -DCMAKE_BUILD_TYPE=Debug)
run(${CMAKE_COMMAND} --build ${project}/build)
run(${CMAKE_CTEST_COMMAND} --test-dir ${project}/build --output-on-failure)
endforeach()
+83
View File
@@ -0,0 +1,83 @@
# Scaffolds each project kind and compares the result byte for byte with the
# expected trees next to this script.
#
# cmake -DCANON=<canon executable> -DWORK_DIR=<scratch dir> [-DUPDATE=ON] -P check.cmake
#
# UPDATE=ON replaces the expected trees with canon's current output; review the
# diff before committing it.
cmake_minimum_required(VERSION 3.20)
foreach(var CANON WORK_DIR)
if(NOT DEFINED ${var})
message(FATAL_ERROR "${var} is not set")
endif()
endforeach()
set(golden_dir ${CMAKE_CURRENT_LIST_DIR})
file(REMOVE_RECURSE ${WORK_DIR})
file(MAKE_DIRECTORY ${WORK_DIR})
set(failed FALSE)
foreach(case lib:libhello exe:hello)
string(REPLACE ":" ";" parts ${case})
list(GET parts 0 kind)
list(GET parts 1 name)
execute_process(
COMMAND ${CANON} new ${kind} ${name} --dir ${WORK_DIR}
RESULT_VARIABLE status
OUTPUT_VARIABLE output
ERROR_VARIABLE errors)
if(NOT status EQUAL 0)
message(FATAL_ERROR "canon new ${kind} ${name} failed (${status}):\n${output}${errors}")
endif()
# A second run must refuse to touch the existing project.
execute_process(
COMMAND ${CANON} new ${kind} ${name} --dir ${WORK_DIR}
RESULT_VARIABLE status
OUTPUT_QUIET ERROR_QUIET)
if(status EQUAL 0)
message(SEND_ERROR "canon new ${kind} ${name} succeeded over an existing project")
set(failed TRUE)
endif()
set(actual ${WORK_DIR}/${name})
set(expected ${golden_dir}/${name})
if(UPDATE)
file(REMOVE_RECURSE ${expected})
file(COPY ${actual} DESTINATION ${golden_dir})
message(STATUS "Updated ${expected}")
continue()
endif()
file(GLOB_RECURSE actual_files RELATIVE ${actual} ${actual}/*)
file(GLOB_RECURSE expected_files RELATIVE ${expected} ${expected}/*)
list(SORT actual_files)
list(SORT expected_files)
if(NOT actual_files STREQUAL expected_files)
message(SEND_ERROR "${name}: files differ from golden\n"
" actual: ${actual_files}\n"
" expected: ${expected_files}")
set(failed TRUE)
continue()
endif()
foreach(file ${expected_files})
execute_process(
COMMAND ${CMAKE_COMMAND} -E compare_files ${actual}/${file} ${expected}/${file}
RESULT_VARIABLE different)
if(different)
message(SEND_ERROR "${name}/${file} differs from golden (diff ${actual}/${file} ${expected}/${file})")
set(failed TRUE)
endif()
endforeach()
endforeach()
if(failed)
message(FATAL_ERROR "Golden check failed")
endif()
+24
View File
@@ -0,0 +1,24 @@
# canon project manifest: what canon declared for this project.
# The managed block in CMakeLists.txt is rendered from this file.
[project]
name = "hello"
kind = "exe"
stem = "hello"
spec = "P1204R0"
[build]
backend = "cmake"
cmake-minimum = "3.20"
cxx-standard = 23
[[targets]]
name = "hello"
type = "executable"
sources = ["hello/hello.cpp"]
[[tests]]
name = "basics"
kind = "run"
target = "hello"
expect = "Hello, World!"
+1
View File
@@ -0,0 +1 @@
/build/
+28
View File
@@ -0,0 +1,28 @@
cmake_minimum_required(VERSION 3.20)
project(hello VERSION 0.1.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
option(BUILD_TESTING "Build hello's tests" ON)
if(BUILD_TESTING)
enable_testing()
endif()
# Add your own CMake outside the managed block below.
# >>> canon:managed >>>
# Rendered from .canon.toml; canon rewrites everything between these markers.
add_executable(hello
hello/hello.cpp
)
target_include_directories(hello PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
if(BUILD_TESTING)
add_test(NAME basics COMMAND hello)
set_tests_properties(basics PROPERTIES PASS_REGULAR_EXPRESSION "Hello, World!")
endif()
# <<< canon:managed <<<
+12
View File
@@ -0,0 +1,12 @@
# hello
Created by canon. The layout follows [P1204R0](https://wg21.link/p1204r0)
(Canonical Project Structure).
## Build and test
```bash
cmake -S . -B build
cmake --build build
ctest --test-dir build --output-on-failure
```
+5
View File
@@ -0,0 +1,5 @@
#include <print>
int main(int argc, char* argv[]) {
std::println("Hello, {}!", argc > 1 ? argv[1] : "World");
}
+29
View File
@@ -0,0 +1,29 @@
# canon project manifest: what canon declared for this project.
# The managed block in CMakeLists.txt is rendered from this file.
[project]
name = "libhello"
kind = "lib"
stem = "hello"
spec = "P1204R0"
[build]
backend = "cmake"
cmake-minimum = "3.20"
cxx-standard = 23
[[targets]]
name = "libhello"
type = "library"
sources = ["hello/hello.cpp"]
[[modules]]
path = "hello/hello"
target = "libhello"
unit-test = true
[[tests]]
name = "basics"
kind = "driver"
path = "tests/basics"
target = "libhello"
+1
View File
@@ -0,0 +1 @@
/build/
+35
View File
@@ -0,0 +1,35 @@
cmake_minimum_required(VERSION 3.20)
project(libhello VERSION 0.1.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
option(BUILD_TESTING "Build libhello's tests" ON)
if(BUILD_TESTING)
enable_testing()
endif()
# Add your own CMake outside the managed block below.
# >>> canon:managed >>>
# Rendered from .canon.toml; canon rewrites everything between these markers.
add_library(libhello
hello/hello.cpp
)
set_target_properties(libhello PROPERTIES PREFIX "")
target_include_directories(libhello PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_compile_features(libhello PUBLIC cxx_std_23)
if(BUILD_TESTING)
add_executable(hello.hello.test hello/hello.test.cpp)
target_link_libraries(hello.hello.test PRIVATE libhello)
add_test(NAME hello/hello.test COMMAND hello.hello.test)
add_executable(tests.basics tests/basics/driver.cpp)
target_link_libraries(tests.basics PRIVATE libhello)
add_test(NAME basics COMMAND tests.basics)
endif()
# <<< canon:managed <<<
+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
```
+11
View File
@@ -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
+11
View File
@@ -0,0 +1,11 @@
#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;
}