Add canon add module and canon add unit-test

canon add module <path> creates <stem>/<path>.hpp, .cpp and .test.cpp
(--no-test skips the test), records the module in .canon.toml, adds the
source to the project's target and re-renders the managed block of
CMakeLists.txt. canon add unit-test adds the test later. Both work from
any directory inside the project.

- toml: a reader for the TOML subset canon writes, with line numbers in
  errors, so canon stays dependency-free
- manifest: strict parsing (unknown keys, wrong types, bad references)
- project: finds .canon.toml by walking up from the current directory
- plan/executor: update_file refuses to write if the file changed after
  it was read, and the report skips directories that already exist
- tests: shared scenarios drive both the golden and end-to-end tests;
  goldens now live in tests/golden/<scenario>/

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 18:24:53 +02:00
parent 48edb15d16
commit d38e1466da
69 changed files with 2406 additions and 249 deletions
+4 -1
View File
@@ -12,6 +12,7 @@ option(BUILD_TESTING "Build canon's tests" ON)
# Everything except main(), so unit tests can link against it.
add_library(canon.core STATIC
canon/add.cpp
canon/cli.cpp
canon/cmake.cpp
canon/executor.cpp
@@ -19,7 +20,9 @@ add_library(canon.core STATIC
canon/manifest.cpp
canon/name.cpp
canon/plan.cpp
canon/project.cpp
canon/template.cpp
canon/toml.cpp
)
target_include_directories(canon.core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
if(NOT MSVC)
@@ -35,7 +38,7 @@ 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)
foreach(unit add cli cmake executor generate manifest name plan project template 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)
+45 -25
View File
@@ -1,19 +1,27 @@
# canon
Scaffolds C++ projects that follow [P1204R0](https://wg21.link/p1204r0)
(Canonical Project Structure). Written in C++23, no third-party dependencies.
(Canonical Project Structure), then keeps adding to them. 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
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 exe hello --dir ~/src # create the project somewhere else
cd libhello
canon add module core # hello/core.hpp, core.cpp, core.test.cpp
canon add module details/utility # hello/details/utility.*, namespace hello::details
canon add module parser --no-test # no .test.cpp ...
canon add unit-test parser # ... until you want one
canon add module core --dry-run # any command: show the changes, write nothing
```
Every generated project builds and passes its tests out of the box:
`add` commands work from any directory inside the project. 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
@@ -30,25 +38,35 @@ 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.
Generators never touch the filesystem. They return a **plan**: a list of
`make_directory`, `create_file` and `update_file` operations. The **executor**
checks every operation before writing anything, so a conflict writes nothing and
`--dry-run` just skips the writing. canon never overwrites existing files, and
it refuses to update a file that changed after it was read.
Each project gets a **`.canon.toml` manifest** recording what canon declared.
Each project has 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.
`CMakeLists.txt` is rendered entirely from that manifest. An `add` command
updates the manifest and re-renders the whole block, so:
- anything **outside** the block is yours and is never touched;
- anything you edit **inside** the block is replaced on the next `add`;
- `.canon.toml` is rewritten by canon, so comments you add to it are dropped.
canon reads `.canon.toml` strictly: unknown keys (usually typos) are errors
with a line number.
| 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/name.*` | naming rules (`lib` prefix, stem, namespace, keywords) |
| `canon/generate.*` | the manifest and plan for `canon new`; project templates |
| `canon/add.*` | the plans for `canon add module` and `canon add unit-test` |
| `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 |
@@ -56,17 +74,19 @@ twice gives the same result. Anything outside the block belongs to you.
## 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:
- **Scenarios**: `tests/scenarios.cmake` runs command sequences (for example
`new lib` followed by several `add` commands) that both of the following use.
- **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:
`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`.
- **End to end**: `tests/e2e/` runs each scenario, then configures, builds and
tests the result. 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
2. ✅ `add module`, `add unit-test`, reading `.canon.toml`, managed-block updates
3. `add target`, `add test`, `add dep`, `doctor` (check a project against P1204R0)
4. User template overrides
+166
View File
@@ -0,0 +1,166 @@
#include <canon/add.hpp>
#include <canon/cmake.hpp>
#include <canon/template.hpp>
#include <algorithm>
#include <format>
#include <ranges>
#include <vector>
namespace canon {
namespace {
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";
std::unexpected<std::string> fail(std::string message) {
return std::unexpected(std::move(message));
}
struct module_names {
std::string path; // hello/details/utility: manifest entry, files without extension
std::string include; // hello/details/utility.hpp
std::string ns; // hello::details
};
std::expected<module_names, std::string> resolve_module(const project_name& project,
std::string_view path) {
if (path.empty()) {
return fail("invalid module path: it is empty");
}
if (path.ends_with(".hpp") || path.ends_with(".cpp")) {
return fail(std::format("give the module path without an extension, e.g. '{}'",
path.substr(0, path.size() - 4)));
}
std::vector<std::string_view> segments;
for (const auto segment : std::views::split(path, '/')) {
segments.emplace_back(segment.begin(), segment.end());
}
module_names names{.path = project.stem, .include = {}, .ns = project.ident};
for (std::size_t i = 0; i < segments.size(); ++i) {
const std::string_view segment = segments[i];
const bool directory = i + 1 < segments.size();
if (segment.empty()) {
return fail(std::format("invalid module path '{}': empty path segment", path));
}
if (const std::string problem = name_problem(segment); !problem.empty()) {
return fail(std::format("invalid module path '{}': '{}': {}", path, segment, problem));
}
if (directory) {
// Directories become namespaces: <stem>/details/x.hpp declares <ident>::details.
if (segment.contains('-') || is_cxx_keyword(segment)) {
return fail(std::format(
"invalid module path '{}': directory '{}' must be a valid namespace name", path,
segment));
}
names.ns += std::format("::{}", segment);
}
names.path += std::format("/{}", segment);
}
names.include = names.path + ".hpp";
return names;
}
template_vars module_vars(const module_names& names) {
return {{"include", names.include}, {"namespace", names.ns}};
}
// Records the updated manifest and the re-rendered managed block in the plan.
std::expected<void, std::string> add_updates(plan& result, const project& p, const manifest& updated) {
auto cmakelists = replace_managed_block(p.cmakelists_text, updated);
if (!cmakelists) {
return fail(std::format("CMakeLists.txt: {}", cmakelists.error()));
}
result.update(std::string(manifest_file), p.manifest_text, to_toml(updated));
result.update("CMakeLists.txt", p.cmakelists_text, std::move(*cmakelists));
return {};
}
} // namespace
std::expected<plan, std::string> plan_add_module(const project& p, std::string_view path,
bool unit_test) {
const auto names = resolve_module(p.declared.project, path);
if (!names) {
return std::unexpected(names.error());
}
manifest updated = p.declared;
if (std::ranges::contains(updated.modules, names->path, &source_module::path)) {
return fail(std::format("module '{}' already exists in {}", names->path, manifest_file));
}
const auto owner = std::ranges::find(updated.targets, updated.project.name, &target::name);
if (owner == updated.targets.end()) {
return fail(std::format("{} declares no target named '{}' to add the module to", manifest_file,
updated.project.name));
}
const std::string source = names->path + ".cpp";
if (!std::ranges::contains(owner->sources, source)) {
owner->sources.push_back(source);
}
updated.modules.push_back({.path = names->path, .target = owner->name, .unit_test = unit_test});
const template_vars vars = module_vars(*names);
plan result;
result.add_file(names->include, render(module_header, vars));
result.add_file(source, render(module_source, vars));
if (unit_test) {
result.add_file(names->path + ".test.cpp", render(module_unit_test, vars));
}
if (auto ok = add_updates(result, p, updated); !ok) {
return std::unexpected(ok.error());
}
return result;
}
std::expected<plan, std::string> plan_add_unit_test(const project& p, std::string_view path) {
const auto names = resolve_module(p.declared.project, path);
if (!names) {
return std::unexpected(names.error());
}
manifest updated = p.declared;
const auto mod = std::ranges::find(updated.modules, names->path, &source_module::path);
if (mod == updated.modules.end()) {
return fail(std::format("no module '{}' in {}; create it with 'canon add module {}'",
names->path, manifest_file, path));
}
if (mod->unit_test) {
return fail(std::format("module '{}' already has a unit test", names->path));
}
mod->unit_test = true;
plan result;
result.add_file(names->path + ".test.cpp", render(module_unit_test, module_vars(*names)));
if (auto ok = add_updates(result, p, updated); !ok) {
return std::unexpected(ok.error());
}
return result;
}
} // namespace canon
+25
View File
@@ -0,0 +1,25 @@
#pragma once
#include <canon/plan.hpp>
#include <canon/project.hpp>
#include <expected>
#include <string>
#include <string_view>
namespace canon {
// Plans `canon add module <path>`. path is relative to the source directory,
// e.g. "core" or "details/utility" (directories become nested namespaces). The
// plan creates <stem>/<path>.hpp and .cpp (and .test.cpp when unit_test is set),
// adds the source to the project's target, records the module in .canon.toml
// and re-renders the managed block of CMakeLists.txt. Plan paths are relative
// to the project root.
[[nodiscard]] std::expected<plan, std::string> plan_add_module(const project&, std::string_view path,
bool unit_test);
// Plans `canon add unit-test <path>` for a declared module without a unit test.
[[nodiscard]] std::expected<plan, std::string> plan_add_unit_test(const project&,
std::string_view path);
} // namespace canon
+193
View File
@@ -0,0 +1,193 @@
#include <canon/add.hpp>
#include <canon/cmake.hpp>
#include <canon/details/testing.hpp>
#include <canon/generate.hpp>
#include <string>
#include <string_view>
#include <vector>
using namespace canon;
namespace {
project make_project(project_kind kind, std::string_view name) {
const manifest m = new_project_manifest(*parse_project_name(kind, name));
return project{
.root = "/unused",
.declared = m,
.manifest_text = to_toml(m),
.cmakelists_text = render_cmakelists(m),
};
}
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;
}
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>";
}
const update_file* updated(const plan& p, std::string_view path) {
for (const auto& op : p.operations) {
if (const auto* file = std::get_if<update_file>(&op); file && file->path == path) {
return file;
}
}
return nullptr;
}
// The manifest the plan would write, read back.
manifest planned_manifest(const plan& p) {
const update_file* file = updated(p, manifest_file);
CANON_CHECK(file != nullptr);
if (file == nullptr) {
return {};
}
auto parsed = parse_manifest(file->content);
CANON_CHECK(parsed);
return parsed ? *parsed : manifest{};
}
std::string planned_cmakelists(const plan& p) {
const update_file* file = updated(p, "CMakeLists.txt");
CANON_CHECK(file != nullptr);
return file != nullptr ? file->content : std::string{};
}
bool fails_with(const std::expected<plan, std::string>& result, std::string_view message) {
if (result) {
std::println(stderr, " planned successfully, expected: {}", message);
return false;
}
const bool matches = result.error().contains(message);
if (!matches) {
std::println(stderr, " error was: {}", result.error());
}
return matches;
}
} // namespace
int main() {
const project lib = make_project(project_kind::library, "libhello");
// add module core
{
const auto result = plan_add_module(lib, "core", true);
CANON_CHECK(result);
if (result) {
CANON_CHECK(created_files(*result) ==
std::vector<std::string>({"hello/core.hpp", "hello/core.cpp", "hello/core.test.cpp"}));
CANON_CHECK_EQ(created(*result, "hello/core.hpp"),
"#pragma once\n\nnamespace hello {\n\n} // namespace hello\n");
CANON_CHECK(created(*result, "hello/core.cpp").starts_with("#include <hello/core.hpp>\n"));
CANON_CHECK(created(*result, "hello/core.test.cpp").contains("#include <hello/core.hpp>\n"));
// Updates are guarded by the texts the project was loaded with.
CANON_CHECK(updated(*result, manifest_file) && updated(*result, manifest_file)->expected == lib.manifest_text);
CANON_CHECK(updated(*result, "CMakeLists.txt") &&
updated(*result, "CMakeLists.txt")->expected == lib.cmakelists_text);
manifest expected = lib.declared;
expected.targets.at(0).sources.push_back("hello/core.cpp");
expected.modules.push_back({.path = "hello/core", .target = "libhello", .unit_test = true});
CANON_CHECK(planned_manifest(*result) == expected);
const std::string cmake = planned_cmakelists(*result);
CANON_CHECK_EQ(cmake, render_cmakelists(expected));
CANON_CHECK(cmake.contains("add_library(libhello\n hello/hello.cpp\n hello/core.cpp\n)\n"));
CANON_CHECK(cmake.contains("add_test(NAME hello/core.test COMMAND hello.core.test)"));
}
}
// Nested paths become nested namespaces; --no-test skips the unit test.
{
const auto result = plan_add_module(lib, "details/utility", false);
CANON_CHECK(result);
if (result) {
CANON_CHECK(created_files(*result) ==
std::vector<std::string>({"hello/details/utility.hpp", "hello/details/utility.cpp"}));
CANON_CHECK(created(*result, "hello/details/utility.hpp").contains("namespace hello::details {"));
CANON_CHECK(created(*result, "hello/details/utility.cpp")
.starts_with("#include <hello/details/utility.hpp>\n"));
CANON_CHECK(!planned_manifest(*result).modules.back().unit_test);
CANON_CHECK(!planned_cmakelists(*result).contains("utility.test"));
}
}
// Hyphens are fine in file names, not in directory (namespace) names.
CANON_CHECK(plan_add_module(lib, "string-utils", true).has_value());
CANON_CHECK(fails_with(plan_add_module(lib, "my-dir/x", true), "directory 'my-dir' must be a valid namespace"));
CANON_CHECK(fails_with(plan_add_module(lib, "new/x", true), "directory 'new' must be a valid namespace"));
CANON_CHECK(fails_with(plan_add_module(lib, "core.hpp", true), "without an extension, e.g. 'core'"));
CANON_CHECK(fails_with(plan_add_module(lib, "hello", true), "module 'hello/hello' already exists"));
for (const std::string_view bad : {"", "/core", "core/", "a//b", "../core", "Core", "2d", "a.b"}) {
CANON_CHECK(fails_with(plan_add_module(lib, bad, true), "invalid module path"));
}
// User content around the managed block survives.
{
project custom = lib;
custom.cmakelists_text = "# mine, before\n" + lib.cmakelists_text + "\ninstall(TARGETS libhello)\n";
const auto result = plan_add_module(custom, "core", true);
CANON_CHECK(result);
if (result) {
const std::string cmake = planned_cmakelists(*result);
CANON_CHECK(cmake.starts_with("# mine, before\n"));
CANON_CHECK(cmake.ends_with("\ninstall(TARGETS libhello)\n"));
}
custom.cmakelists_text = "cmake_minimum_required(VERSION 3.20)\n";
CANON_CHECK(fails_with(plan_add_module(custom, "core", true), "CMakeLists.txt: no '# >>> canon:managed >>>'"));
}
// add unit-test
{
const auto without_test = plan_add_module(lib, "core", false);
CANON_CHECK(without_test);
project after = lib;
after.declared = planned_manifest(*without_test);
after.manifest_text = updated(*without_test, manifest_file)->content;
after.cmakelists_text = planned_cmakelists(*without_test);
const auto result = plan_add_unit_test(after, "core");
CANON_CHECK(result);
if (result) {
CANON_CHECK(created_files(*result) == std::vector<std::string>({"hello/core.test.cpp"}));
CANON_CHECK(planned_manifest(*result).modules.back().unit_test);
CANON_CHECK(planned_cmakelists(*result).contains("add_test(NAME hello/core.test"));
}
CANON_CHECK(fails_with(plan_add_unit_test(lib, "hello"), "module 'hello/hello' already has a unit test"));
CANON_CHECK(fails_with(plan_add_unit_test(lib, "nope"), "create it with 'canon add module nope'"));
}
// Executables compile the module into the executable and into its unit test.
{
const project exe = make_project(project_kind::executable, "hello");
const auto result = plan_add_module(exe, "greeting", true);
CANON_CHECK(result);
if (result) {
CANON_CHECK(planned_manifest(*result).targets.at(0).sources ==
std::vector<std::string>({"hello/hello.cpp", "hello/greeting.cpp"}));
CANON_CHECK(planned_cmakelists(*result).contains(
"add_executable(hello.greeting.test hello/greeting.test.cpp hello/greeting.cpp)"));
}
}
return details::exit_status();
}
+61 -7
View File
@@ -1,13 +1,16 @@
#include <canon/add.hpp>
#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 <canon/project.hpp>
#include <cstdio>
#include <cstdlib>
#include <exception>
#include <expected>
#include <filesystem>
#include <print>
#include <string>
@@ -56,6 +59,22 @@ std::string display_path(const fs::path& path) {
return absolute;
}
// Applies (or dry-runs) the plan and prints what changed.
bool apply_and_report(const canon::plan& plan, const fs::path& root, bool dry_run) {
const auto report = canon::apply(plan, root, {.dry_run = dry_run});
if (!report) {
std::println(stderr, "canon: {}", report.error());
return false;
}
for (const auto& line : *report) {
std::println("{}", line);
}
if (dry_run) {
std::println("\nDry run: nothing was written.");
}
return true;
}
int run_new(const canon::new_command& cmd) {
const auto name = canon::parse_project_name(cmd.kind, cmd.name);
if (!name) {
@@ -76,24 +95,57 @@ int run_new(const canon::new_command& cmd) {
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());
if (!apply_and_report(canon::generate_new_project(*name), cmd.directory, cmd.dry_run)) {
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;
}
const std::string_view kind = cmd.kind == canon::project_kind::library ? "library" : "executable";
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;
}
// Shared by the add commands: load the project, plan, apply, report.
template <class Planner>
int run_add(const fs::path& directory, bool dry_run, Planner make_plan, std::string_view done) {
const auto project = canon::load_project(directory);
if (!project) {
std::println(stderr, "canon: {}", project.error());
return EXIT_FAILURE;
}
const std::expected<canon::plan, std::string> plan = make_plan(*project);
if (!plan) {
std::println(stderr, "canon: {}", plan.error());
return EXIT_FAILURE;
}
std::println("{} ({}):", project->declared.project.name, display_path(project->root));
if (!apply_and_report(*plan, project->root, dry_run)) {
return EXIT_FAILURE;
}
if (!dry_run) {
std::println("\n{} Rebuild with: cmake --build build", done);
}
return EXIT_SUCCESS;
}
int run_add_module(const canon::add_module_command& cmd) {
return run_add(
cmd.directory, cmd.dry_run,
[&](const canon::project& p) { return canon::plan_add_module(p, cmd.path, cmd.unit_test); },
std::format("Added module {}.", cmd.path));
}
int run_add_unit_test(const canon::add_unit_test_command& cmd) {
return run_add(
cmd.directory, cmd.dry_run,
[&](const canon::project& p) { return canon::plan_add_unit_test(p, cmd.path); },
std::format("Added a unit test for module {}.", cmd.path));
}
} // namespace
int main(int argc, char* argv[]) try {
@@ -113,6 +165,8 @@ int main(int argc, char* argv[]) try {
return EXIT_SUCCESS;
},
[](const canon::new_command& cmd) { return run_new(cmd); },
[](const canon::add_module_command& cmd) { return run_add_module(cmd); },
[](const canon::add_unit_test_command& cmd) { return run_add_unit_test(cmd); },
},
*command);
} catch (const std::exception& e) {
+114 -55
View File
@@ -8,59 +8,26 @@ namespace canon {
namespace {
struct options {
std::optional<std::string_view> directory;
bool dry_run = false;
bool no_test = false;
};
std::unexpected<std::string> fail(std::string message) {
return std::unexpected(std::move(message));
}
} // namespace
std::filesystem::path directory_or_current(const options& opts) {
return opts.directory ? std::filesystem::path(*opts.directory) : std::filesystem::path(".");
}
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]));
}
std::expected<command, std::string> parse_new(std::span<const std::string_view> positional,
const options& opts) {
if (positional.size() < 2) {
return fail("'new' needs a kind: lib or exe");
}
new_command cmd{.dry_run = dry_run};
new_command cmd{.directory = directory_or_current(opts), .dry_run = opts.dry_run};
if (positional[1] == "lib") {
cmd.kind = project_kind::library;
} else if (positional[1] == "exe") {
@@ -75,24 +42,116 @@ std::expected<command, std::string> parse_command_line(std::span<const std::stri
return fail(std::format("unexpected argument '{}'", positional[3]));
}
cmd.name = positional[2];
if (directory) {
cmd.directory = *directory;
}
return cmd;
}
std::expected<command, std::string> parse_add(std::span<const std::string_view> positional,
const options& opts) {
if (positional.size() < 2) {
return fail("'add' needs what to add: module or unit-test");
}
const std::string_view what = positional[1];
if (what != "module" && what != "unit-test") {
return fail(std::format("cannot add '{}' (expected module or unit-test)", what));
}
if (positional.size() < 3) {
return fail(std::format("'add {}' needs a module path, e.g. 'core'", what));
}
if (positional.size() > 3) {
return fail(std::format("unexpected argument '{}'", positional[3]));
}
if (what == "module") {
return add_module_command{
.path = std::string(positional[2]),
.unit_test = !opts.no_test,
.directory = directory_or_current(opts),
.dry_run = opts.dry_run,
};
}
return add_unit_test_command{
.path = std::string(positional[2]),
.directory = directory_or_current(opts),
.dry_run = opts.dry_run,
};
}
} // namespace
std::expected<command, std::string> parse_command_line(std::span<const std::string_view> args) {
std::vector<std::string_view> positional;
options opts;
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") {
opts.dry_run = true;
} else if (arg == "--no-test") {
opts.no_test = true;
} else if (arg == "--dir") {
if (i + 1 == args.size()) {
return fail("--dir needs a path");
}
opts.directory = args[++i];
} else if (arg.starts_with("--dir=")) {
opts.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 (opts.directory && opts.directory->empty()) {
return fail("--dir needs a path");
}
if (positional.empty()) {
return help_command{};
}
const std::string_view verb = positional[0];
const bool adding_module = verb == "add" && positional.size() > 1 && positional[1] == "module";
if (opts.no_test && !adding_module) {
return fail("--no-test only applies to 'add module'");
}
if (verb == "new") {
return parse_new(positional, opts);
}
if (verb == "add") {
return parse_add(positional, opts);
}
return fail(std::format("unknown command '{}'", verb));
}
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
Create a project:
new lib <name> Library project (the name must start with 'lib')
new exe <name> Executable project
Change the project you are in (any directory inside it works):
add module <path> Add <stem>/<path>.hpp, .cpp and .test.cpp, e.g. 'core'
or 'details/utility' (directories become namespaces)
add unit-test <path> Add the .test.cpp for a module created with --no-test
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
--dir <path> new: create the project inside <path>
add: look for the project from <path>
(default: current directory)
--dry-run Show what would change without writing anything
--no-test add module: skip the unit test
-h, --help Show this help
--version Show canon's version
Projects follow P1204R0 (Canonical Project Structure): https://wg21.link/p1204r0
)";
+16 -2
View File
@@ -22,10 +22,24 @@ struct new_command {
bool dry_run = false;
};
using command = std::variant<help_command, version_command, new_command>;
struct add_module_command {
std::string path{}; // relative to the source directory, e.g. "core"
bool unit_test = true;
std::filesystem::path directory = "."; // where to start looking for .canon.toml
bool dry_run = false;
};
struct add_unit_test_command {
std::string path{};
std::filesystem::path directory = ".";
bool dry_run = false;
};
using command =
std::variant<help_command, version_command, new_command, add_module_command, add_unit_test_command>;
// Parses the arguments after the program name. Only syntax is checked here;
// project names are validated by parse_project_name.
// names and module paths are validated by the generators.
[[nodiscard]] std::expected<command, std::string>
parse_command_line(std::span<const std::string_view> args);
+31
View File
@@ -57,6 +57,37 @@ int main() {
CANON_CHECK_EQ(std::get<new_command>(*c).name, "-weird");
}
if (const auto c = parse({"add", "module", "details/utility"}); CANON_CHECK(c), c) {
const auto* a = std::get_if<add_module_command>(&*c);
CANON_CHECK(a != nullptr);
if (a != nullptr) {
CANON_CHECK_EQ(a->path, "details/utility");
CANON_CHECK(a->unit_test);
CANON_CHECK(!a->dry_run);
CANON_CHECK_EQ(a->directory.generic_string(), ".");
}
}
if (const auto c = parse({"add", "module", "core", "--no-test", "--dry-run", "--dir", "proj"});
CANON_CHECK(c), c) {
const auto& a = std::get<add_module_command>(*c);
CANON_CHECK(!a.unit_test);
CANON_CHECK(a.dry_run);
CANON_CHECK_EQ(a.directory.generic_string(), "proj");
}
if (const auto c = parse({"add", "unit-test", "core"}); CANON_CHECK(c), c) {
const auto* a = std::get_if<add_unit_test_command>(&*c);
CANON_CHECK(a != nullptr && a->path == "core");
}
CANON_CHECK(fails_with(parse({"add"}), "needs what to add"));
CANON_CHECK(fails_with(parse({"add", "target", "x"}), "cannot add 'target'"));
CANON_CHECK(fails_with(parse({"add", "module"}), "'add module' needs a module path"));
CANON_CHECK(fails_with(parse({"add", "module", "a", "b"}), "unexpected argument 'b'"));
CANON_CHECK(fails_with(parse({"add", "unit-test", "core", "--no-test"}), "--no-test only applies"));
CANON_CHECK(fails_with(parse({"new", "lib", "libhello", "--no-test"}), "--no-test only applies"));
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'"));
+48
View File
@@ -2,6 +2,7 @@
#include <algorithm>
#include <format>
#include <optional>
#include <stdexcept>
namespace canon {
@@ -160,4 +161,51 @@ endif()
m.cmake_minimum, m.project.name, m.cxx_standard, render_managed_block(m));
}
std::expected<std::string, std::string> replace_managed_block(std::string_view text,
const manifest& m) {
std::optional<std::size_t> begin; // offset where the begin-marker line starts
std::optional<std::size_t> end; // offset just past the end-marker line
for (std::size_t line_start = 0; line_start < text.size();) {
const std::size_t newline = text.find('\n', line_start);
const std::size_t line_end = newline == std::string_view::npos ? text.size() : newline;
const std::size_t next = newline == std::string_view::npos ? text.size() : newline + 1;
std::string_view line = text.substr(line_start, line_end - line_start);
const auto first = line.find_first_not_of(" \t");
const auto last = line.find_last_not_of(" \t\r");
line = first == std::string_view::npos ? std::string_view{} : line.substr(first, last - first + 1);
if (line == managed_begin) {
if (begin) {
return std::unexpected(std::format("'{}' appears more than once", managed_begin));
}
begin = line_start;
} else if (line == managed_end) {
if (end) {
return std::unexpected(std::format("'{}' appears more than once", managed_end));
}
if (!begin) {
return std::unexpected(
std::format("'{}' appears before '{}'", managed_end, managed_begin));
}
end = next;
}
line_start = next;
}
if (!begin) {
return std::unexpected(std::format(
"no '{}' line found; canon only edits the block between its markers", managed_begin));
}
if (!end) {
return std::unexpected(std::format("no '{}' line found after '{}'", managed_end, managed_begin));
}
std::string out(text.substr(0, *begin));
out += render_managed_block(m);
out += text.substr(*end);
return out;
}
} // namespace canon
+7
View File
@@ -2,6 +2,7 @@
#include <canon/manifest.hpp>
#include <expected>
#include <string>
#include <string_view>
@@ -21,4 +22,10 @@ inline constexpr std::string_view managed_end = "# <<< canon:managed <<<";
// by the managed block.
[[nodiscard]] std::string render_cmakelists(const manifest&);
// An existing CMakeLists.txt with its managed block replaced by one rendered
// from the manifest; everything outside the markers is kept byte for byte.
// Fails if the markers are missing, repeated or out of order.
[[nodiscard]] std::expected<std::string, std::string> replace_managed_block(std::string_view cmakelists,
const manifest&);
} // namespace canon
+40
View File
@@ -86,6 +86,46 @@ int main() {
CANON_CHECK(threw);
}
// Replacing the managed block keeps everything around it and is idempotent.
{
const std::string original = render_cmakelists(library());
const auto same = replace_managed_block(original, library());
CANON_CHECK(same && *same == original);
manifest more = library();
more.targets[0].sources.push_back("hello/core.cpp");
const std::string user_before = "# before\r\n";
const std::string user_after = "\n# after, no trailing newline";
std::string edited = user_before + original + user_after;
const auto replaced = replace_managed_block(edited, more);
CANON_CHECK(replaced);
if (replaced) {
CANON_CHECK(replaced->starts_with(user_before));
CANON_CHECK(replaced->ends_with(render_managed_block(more) + user_after));
CANON_CHECK(replaced->contains(" hello/core.cpp\n"));
const auto again = replace_managed_block(*replaced, more);
CANON_CHECK(again && *again == *replaced);
}
// Hand edits inside the block are replaced; indented markers are still found.
const std::string block = " # >>> canon:managed >>> \nadd_library(junk)\n\t# <<< canon:managed <<<\r\n";
const auto rebuilt = replace_managed_block("x\n" + block + "y\n", library());
CANON_CHECK(rebuilt && *rebuilt == "x\n" + render_managed_block(library()) + "y\n");
const auto error_of = [](std::string_view text) {
const auto result = replace_managed_block(text, library());
return result ? std::string("<no error>") : result.error();
};
const std::string begin = std::string(managed_begin) + "\n";
const std::string end = std::string(managed_end) + "\n";
CANON_CHECK(error_of("project(x)\n").contains("no '# >>> canon:managed >>>' line found"));
CANON_CHECK(error_of(begin).contains("no '# <<< canon:managed <<<' line found"));
CANON_CHECK(error_of(end + begin).contains("appears before"));
CANON_CHECK(error_of(begin + begin + end).contains("appears more than once"));
CANON_CHECK(error_of(begin + end + end).contains("appears more than once"));
CANON_CHECK(error_of("# >>> canon:managed >>> extra\n" + end).contains("appears before"));
}
{
const std::string file = render_cmakelists(library());
CANON_CHECK(file.starts_with("cmake_minimum_required(VERSION 3.20)\n"
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include <filesystem>
#include <fstream>
#include <optional>
#include <sstream>
#include <string>
namespace canon::details {
// The whole file, or nullopt if it cannot be opened.
[[nodiscard]] inline std::optional<std::string> read_file(const std::filesystem::path& path) {
std::ifstream in(path, std::ios::binary);
if (!in) {
return std::nullopt;
}
std::ostringstream content;
content << in.rdbuf();
return std::move(content).str();
}
} // namespace canon::details
+73 -21
View File
@@ -1,5 +1,7 @@
#include <canon/executor.hpp>
#include <canon/details/files.hpp>
#include <format>
#include <fstream>
@@ -9,32 +11,58 @@ namespace fs = std::filesystem;
namespace {
using result = std::expected<void, std::string>;
using checked = std::expected<bool, std::string>; // true if the operation changes something
using written = 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) {
checked 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 {};
return !fs::exists(status);
}
result check(const create_file& op, const fs::path& root) {
checked 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 true;
}
checked check(const update_file& op, const fs::path& root) {
const auto current = details::read_file(root / op.path);
if (!current) {
return std::unexpected(std::format("cannot read {}", display(root, op.path)));
}
if (*current != op.expected) {
return std::unexpected(std::format(
"{} changed after canon read it; nothing was written", display(root, op.path)));
}
return op.content != op.expected;
}
written write_bytes(const fs::path& file, const std::string& content, const std::string& name) {
std::ofstream out(file, std::ios::out | std::ios::binary | std::ios::trunc);
if (!out) {
return std::unexpected(std::format("cannot open {} for writing", name));
}
out.write(content.data(), static_cast<std::streamsize>(content.size()));
out.close();
if (!out) {
return std::unexpected(std::format("cannot write {}", name));
}
return {};
}
result write(const make_directory& op, const fs::path& root) {
written write(const make_directory& op, const fs::path& root) {
std::error_code error;
fs::create_directories(root / op.path, error);
if (error) {
@@ -44,36 +72,60 @@ result write(const make_directory& op, const fs::path& root) {
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)));
written write(const create_file& op, const fs::path& root) {
return write_bytes(root / op.path, op.content, display(root, op.path));
}
// Writes a sibling temporary file and renames it over the original, so an
// interrupted write never leaves a half-written file behind.
written write(const update_file& op, const fs::path& root) {
const fs::path target = root / op.path;
fs::path temporary = target;
temporary += ".canon-tmp";
if (auto ok = write_bytes(temporary, op.content, display(root, op.path)); !ok) {
return ok;
}
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)));
std::error_code error;
fs::rename(temporary, target, error);
if (error) {
std::error_code ignored;
fs::remove(temporary, ignored);
return std::unexpected(
std::format("cannot replace {}: {}", display(root, op.path), error.message()));
}
return {};
}
} // namespace
result apply(const plan& p, const fs::path& root, apply_options options) {
std::expected<std::vector<std::string>, std::string> apply(const plan& p, const fs::path& root,
apply_options options) {
std::vector<const operation*> changes;
for (const auto& op : p.operations) {
if (auto ok = std::visit([&](const auto& o) { return check(o, root); }, op); !ok) {
return ok;
const checked result = std::visit([&](const auto& o) { return check(o, root); }, op);
if (!result) {
return std::unexpected(result.error());
}
if (*result) {
changes.push_back(&op);
}
}
std::vector<std::string> report;
for (const operation* op : changes) {
report.push_back(describe(*op));
}
if (options.dry_run) {
return {};
return report;
}
for (const auto& op : p.operations) {
if (auto ok = std::visit([&](const auto& o) { return write(o, root); }, op); !ok) {
return ok;
for (const operation* op : changes) {
if (const written ok = std::visit([&](const auto& o) { return write(o, root); }, *op); !ok) {
return std::unexpected(ok.error());
}
}
return {};
return report;
}
} // namespace canon
+7 -4
View File
@@ -5,6 +5,7 @@
#include <expected>
#include <filesystem>
#include <string>
#include <vector>
namespace canon {
@@ -12,9 +13,11 @@ 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 = {});
// Applies the plan inside root and returns one describe() line per operation
// that changes something; directories that already exist and updates that
// change nothing are left out. Every operation is checked before anything is
// written, so a conflict leaves the filesystem untouched.
[[nodiscard]] std::expected<std::vector<std::string>, std::string>
apply(const plan&, const std::filesystem::path& root, apply_options = {});
} // namespace canon
+53
View File
@@ -5,6 +5,8 @@
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
#include <vector>
using namespace canon;
namespace fs = std::filesystem;
@@ -61,6 +63,57 @@ int main() {
CANON_CHECK(!apply(p, tmp.path(), {.dry_run = true}));
}
{
// The report lists what changes: existing directories are left out.
const details::temp_directory tmp;
fs::create_directories(tmp.path() / "proj");
const auto report = apply(p, tmp.path(), {.dry_run = true});
CANON_CHECK(report);
if (report) {
CANON_CHECK(*report == std::vector<std::string>({" mkdir proj/src/", " create proj/src/main.cpp",
" create proj/README.md"}));
}
}
{
const details::temp_directory tmp;
std::ofstream(tmp.path() / "CMakeLists.txt") << "old\n";
plan update;
update.update("CMakeLists.txt", "old\n", "new\n");
update.add_file("added.txt", "x");
const auto dry = apply(update, tmp.path(), {.dry_run = true});
CANON_CHECK(dry && dry->front() == " update CMakeLists.txt");
CANON_CHECK_EQ(read(tmp.path() / "CMakeLists.txt"), "old\n");
CANON_CHECK(apply(update, tmp.path()).has_value());
CANON_CHECK_EQ(read(tmp.path() / "CMakeLists.txt"), "new\n");
CANON_CHECK(!fs::exists(tmp.path() / "CMakeLists.txt.canon-tmp"));
// The file no longer holds what the plan expects: refuse, write nothing.
plan stale;
stale.update("CMakeLists.txt", "old\n", "newer\n");
stale.add_file("other.txt", "x");
const auto result = apply(stale, tmp.path());
CANON_CHECK(!result);
if (!result) {
CANON_CHECK(result.error().contains("CMakeLists.txt changed after canon read it"));
}
CANON_CHECK_EQ(read(tmp.path() / "CMakeLists.txt"), "new\n");
CANON_CHECK(!fs::exists(tmp.path() / "other.txt"));
// An update that changes nothing is not reported.
plan unchanged;
unchanged.update("CMakeLists.txt", "new\n", "new\n");
const auto quiet = apply(unchanged, tmp.path());
CANON_CHECK(quiet && quiet->empty());
plan missing;
missing.update("gone.txt", "", "x");
CANON_CHECK(!apply(missing, tmp.path()));
}
{
// A file where the plan needs a directory is a conflict too.
const details::temp_directory tmp;
+1 -1
View File
@@ -129,7 +129,7 @@ plan generate_new_project(const project_name& n) {
const std::filesystem::path sources = root / n.stem;
plan p;
p.add_file(root / ".canon.toml", to_toml(m));
p.add_file(root / manifest_file, 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));
+269 -43
View File
@@ -1,43 +1,137 @@
#include <canon/manifest.hpp>
#include <canon/toml.hpp>
#include <algorithm>
#include <format>
#include <optional>
#include <type_traits>
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;
struct manifest_error {
int line;
std::string message;
};
constexpr std::string_view description(std::type_identity<std::string>) { return "a string"; }
constexpr std::string_view description(std::type_identity<std::int64_t>) { return "an integer"; }
constexpr std::string_view description(std::type_identity<bool>) { return "true or false"; }
constexpr std::string_view description(std::type_identity<std::vector<std::string>>) {
return "an array of strings";
}
// Typed access to one table's keys that remembers which keys were read.
class table_reader {
public:
explicit table_reader(const toml_table& table)
: table_(table), used_(table.entries.size(), false) {}
[[nodiscard]] std::string header() const {
return table_.array_element ? std::format("[[{}]]", table_.name)
: std::format("[{}]", table_.name);
}
template <class T>
[[nodiscard]] std::optional<T> optional(std::string_view key) {
const toml_entry* entry = find(key);
if (entry == nullptr) {
return std::nullopt;
}
if (const T* value = std::get_if<T>(&entry->value)) {
return *value;
}
throw manifest_error{entry->line, std::format("'{}' in {} must be {}", key, header(),
description(std::type_identity<T>{}))};
}
template <class T>
[[nodiscard]] T required(std::string_view key) {
if (auto value = optional<T>(key)) {
return std::move(*value);
}
throw manifest_error{table_.line, std::format("{} is missing '{}'", header(), key)};
}
// The key's line, or the table header's line if the key is absent.
[[nodiscard]] int line_of(std::string_view key) const {
const auto found = std::ranges::find(table_.entries, key, &toml_entry::key);
return found == table_.entries.end() ? table_.line : found->line;
}
// Keys canon does not know are almost always typos, so they are errors.
void finish() const {
for (std::size_t i = 0; i < used_.size(); ++i) {
if (!used_[i]) {
const toml_entry& entry = table_.entries[i];
throw manifest_error{entry.line,
std::format("unknown key '{}' in {}", entry.key, header())};
}
}
}
out += '"';
return out;
private:
const toml_entry* find(std::string_view key) {
const auto found = std::ranges::find(table_.entries, key, &toml_entry::key);
if (found == table_.entries.end()) {
return nullptr;
}
used_[static_cast<std::size_t>(found - table_.entries.begin())] = true;
return &*found;
}
const toml_table& table_;
std::vector<bool> used_;
};
project_kind read_project_kind(table_reader& r) {
const auto word = r.required<std::string>("kind");
if (word == "lib") {
return project_kind::library;
}
if (word == "exe") {
return project_kind::executable;
}
throw manifest_error{r.line_of("kind"), "'kind' in [project] must be \"lib\" or \"exe\""};
}
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;
target_type read_target_type(table_reader& r) {
const auto word = r.required<std::string>("type");
if (word == "library") {
return target_type::library;
}
if (word == "executable") {
return target_type::executable;
}
throw manifest_error{r.line_of("type"),
"'type' in [[targets]] must be \"library\" or \"executable\""};
}
test_kind read_test_kind(table_reader& r) {
const auto word = r.required<std::string>("kind");
if (word == "driver") {
return test_kind::driver;
}
if (word == "run") {
return test_kind::run;
}
throw manifest_error{r.line_of("kind"), "'kind' in [[tests]] must be \"driver\" or \"run\""};
}
// Throws for the first element whose key repeats an earlier one.
template <class T, class Key>
void check_unique(const std::vector<T>& items, Key T::*key, const std::vector<int>& lines,
std::string_view what) {
for (std::size_t i = 0; i < items.size(); ++i) {
for (std::size_t j = 0; j < i; ++j) {
if (items[i].*key == items[j].*key) {
throw manifest_error{lines[i],
std::format("{} '{}' is declared twice", what, items[i].*key)};
}
}
}
out += ']';
return out;
}
} // namespace
@@ -60,47 +154,179 @@ std::string to_toml(const manifest& m) {
"# 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));
line("name", toml_quote(m.project.name));
line("kind", toml_quote(to_string(m.project.kind)));
line("stem", toml_quote(m.project.stem));
line("spec", toml_quote(m.spec));
out += "\n[build]\n";
line("backend", quote(m.backend));
line("cmake-minimum", quote(m.cmake_minimum));
line("backend", toml_quote(m.backend));
line("cmake-minimum", toml_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));
line("name", toml_quote(t.name));
line("type", toml_quote(to_string(t.type)));
line("sources", toml_array(t.sources));
if (!t.depends.empty()) {
line("depends", array(t.depends));
line("depends", toml_array(t.depends));
}
}
for (const auto& mod : m.modules) {
out += "\n[[modules]]\n";
line("path", quote(mod.path));
line("target", quote(mod.target));
line("path", toml_quote(mod.path));
line("target", toml_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)));
line("name", toml_quote(t.name));
line("kind", toml_quote(to_string(t.kind)));
if (!t.path.empty()) {
line("path", quote(t.path));
line("path", toml_quote(t.path));
}
line("target", quote(t.target));
line("target", toml_quote(t.target));
if (!t.expect.empty()) {
line("expect", quote(t.expect));
line("expect", toml_quote(t.expect));
}
}
return out;
}
std::expected<manifest, std::string> parse_manifest(std::string_view text) {
const auto tables = parse_toml(text);
if (!tables) {
return std::unexpected(tables.error());
}
try {
manifest m;
bool have_project = false;
std::vector<int> target_lines;
std::vector<int> module_lines;
std::vector<int> test_lines;
for (const toml_table& table : *tables) {
table_reader r(table);
if (table.name.empty()) {
if (!table.entries.empty()) {
const toml_entry& first = table.entries.front();
throw manifest_error{first.line,
std::format("'{}' must be inside a table such as [project]",
first.key)};
}
} else if (!table.array_element && table.name == "project") {
const auto name = r.required<std::string>("name");
const project_kind kind = read_project_kind(r);
const auto stem = r.optional<std::string>("stem");
if (auto spec = r.optional<std::string>("spec")) {
m.spec = std::move(*spec);
}
r.finish();
auto parsed = parse_project_name(kind, name);
if (!parsed) {
throw manifest_error{r.line_of("name"), parsed.error()};
}
if (stem && *stem != parsed->stem) {
throw manifest_error{r.line_of("stem"),
std::format("stem '{}' does not match name '{}' (expected '{}')",
*stem, name, parsed->stem)};
}
m.project = std::move(*parsed);
have_project = true;
} else if (!table.array_element && table.name == "build") {
if (auto backend = r.optional<std::string>("backend")) {
if (*backend != "cmake") {
throw manifest_error{r.line_of("backend"),
std::format("backend '{}' is not supported (only \"cmake\")",
*backend)};
}
m.backend = std::move(*backend);
}
if (auto minimum = r.optional<std::string>("cmake-minimum")) {
m.cmake_minimum = std::move(*minimum);
}
if (auto standard = r.optional<std::int64_t>("cxx-standard")) {
if (*standard < 11 || *standard > 99) {
throw manifest_error{r.line_of("cxx-standard"),
"'cxx-standard' must be a C++ standard such as 23"};
}
m.cxx_standard = static_cast<int>(*standard);
}
r.finish();
} else if (table.array_element && table.name == "targets") {
target t{
.name = r.required<std::string>("name"),
.type = read_target_type(r),
.sources = r.optional<std::vector<std::string>>("sources").value_or(
std::vector<std::string>{}),
.depends = r.optional<std::vector<std::string>>("depends").value_or(
std::vector<std::string>{}),
};
r.finish();
m.targets.push_back(std::move(t));
target_lines.push_back(table.line);
} else if (table.array_element && table.name == "modules") {
source_module mod{
.path = r.required<std::string>("path"),
.target = r.required<std::string>("target"),
.unit_test = r.optional<bool>("unit-test").value_or(false),
};
r.finish();
m.modules.push_back(std::move(mod));
module_lines.push_back(table.line);
} else if (table.array_element && table.name == "tests") {
test_entry t{
.name = r.required<std::string>("name"),
.kind = read_test_kind(r),
.path = r.optional<std::string>("path").value_or(""),
.target = r.required<std::string>("target"),
.expect = r.optional<std::string>("expect").value_or(""),
};
r.finish();
if (t.kind == test_kind::driver && t.path.empty()) {
throw manifest_error{table.line,
std::format("test '{}' has kind \"driver\" but no 'path'", t.name)};
}
m.tests.push_back(std::move(t));
test_lines.push_back(table.line);
} else {
throw manifest_error{table.line, std::format("unknown table {}", r.header())};
}
}
if (!have_project) {
throw manifest_error{1, "missing [project] table"};
}
check_unique(m.targets, &target::name, target_lines, "target");
check_unique(m.modules, &source_module::path, module_lines, "module");
check_unique(m.tests, &test_entry::name, test_lines, "test");
const auto check_target = [&](std::string_view name, int line, std::string_view user) {
if (!std::ranges::contains(m.targets, name, &target::name)) {
throw manifest_error{line,
std::format("{} refers to unknown target '{}'", user, name)};
}
};
for (std::size_t i = 0; i < m.modules.size(); ++i) {
check_target(m.modules[i].target, module_lines[i],
std::format("module '{}'", m.modules[i].path));
}
for (std::size_t i = 0; i < m.tests.size(); ++i) {
check_target(m.tests[i].target, test_lines[i], std::format("test '{}'", m.tests[i].name));
}
return m;
} catch (const manifest_error& e) {
return std::unexpected(std::format("line {}: {}", e.line, e.message));
}
}
} // namespace canon
+15
View File
@@ -2,12 +2,15 @@
#include <canon/name.hpp>
#include <expected>
#include <string>
#include <string_view>
#include <vector>
namespace canon {
inline constexpr std::string_view manifest_file = ".canon.toml";
enum class target_type { library, executable };
enum class test_kind {
@@ -23,6 +26,8 @@ struct target {
target_type type{};
std::vector<std::string> sources{};
std::vector<std::string> depends{};
bool operator==(const target&) const = default;
};
// A related file set: <path>.hpp, <path>.cpp and optionally <path>.test.cpp.
@@ -30,6 +35,8 @@ struct source_module {
std::string path{}; // hello/hello
std::string target{}; // the target whose sources include <path>.cpp
bool unit_test = false;
bool operator==(const source_module&) const = default;
};
struct test_entry {
@@ -38,6 +45,8 @@ struct test_entry {
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
bool operator==(const test_entry&) const = default;
};
// What canon declared for a project. It records intent, not the state of the
@@ -51,9 +60,15 @@ struct manifest {
std::vector<target> targets{};
std::vector<source_module> modules{};
std::vector<test_entry> tests{};
bool operator==(const manifest&) const = default;
};
// The contents of .canon.toml.
[[nodiscard]] std::string to_toml(const manifest&);
// Reads .canon.toml. Unknown tables or keys, wrong value types, invalid names
// and references to undeclared targets are errors of the form "line N: ...".
[[nodiscard]] std::expected<manifest, std::string> parse_manifest(std::string_view);
} // namespace canon
+108 -16
View File
@@ -1,10 +1,16 @@
#include <canon/manifest.hpp>
#include <canon/details/testing.hpp>
#include <canon/generate.hpp>
#include <string>
#include <string_view>
using namespace canon;
int main() {
namespace {
manifest library() {
manifest m{.project = {project_kind::library, "libhello", "hello", "hello"}};
m.targets.push_back({.name = "libhello",
.type = target_type::library,
@@ -12,24 +18,110 @@ int main() {
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;
}
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"));
const std::string valid_header = "[project]\nname = \"libhello\"\nkind = \"lib\"\n";
bool fails_with(std::string_view text, std::string_view message) {
const auto result = parse_manifest(text);
if (result) {
std::println(stderr, " parsed successfully, expected: {}", message);
return false;
}
const bool matches = result.error().contains(message);
if (!matches) {
std::println(stderr, " error was: {}", result.error());
}
return matches;
}
} // namespace
int main() {
{
const std::string toml = to_toml(library());
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\""));
{
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\""));
}
// Everything canon writes, it reads back unchanged.
{
manifest m = library();
m.targets.push_back({.name = "tool",
.type = target_type::executable,
.sources = {"hello/tool.cpp"},
.depends = {"libhello"}});
m.tests.push_back({.name = "run", .kind = test_kind::run, .target = "tool", .expect = "Hi \"there\""});
m.cxx_standard = 26;
const auto parsed = parse_manifest(to_toml(m));
CANON_CHECK(parsed);
CANON_CHECK(parsed && *parsed == m);
for (const project_kind kind : {project_kind::library, project_kind::executable}) {
const auto name = parse_project_name(kind, kind == project_kind::library ? "libfoo-bar" : "foo");
const manifest fresh = new_project_manifest(*name);
const auto reread = parse_manifest(to_toml(fresh));
CANON_CHECK(reread && *reread == fresh);
}
}
// Optional keys fall back to defaults; comments and blank lines are fine.
{
const auto parsed = parse_manifest("# hand-written\n" + valid_header);
CANON_CHECK(parsed);
if (parsed) {
CANON_CHECK_EQ(parsed->project.stem, "hello");
CANON_CHECK_EQ(parsed->cxx_standard, 23);
CANON_CHECK(parsed->targets.empty());
}
}
CANON_CHECK(fails_with("", "line 1: missing [project] table"));
CANON_CHECK(fails_with("name = \"x\"\n", "line 1: 'name' must be inside a table"));
CANON_CHECK(fails_with("[project]\nkind = \"lib\"\n", "line 1: [project] is missing 'name'"));
CANON_CHECK(fails_with(valid_header + "nmae = \"typo\"\n", "line 4: unknown key 'nmae' in [project]"));
CANON_CHECK(fails_with("[project]\nname = 42\n", "line 2: 'name' in [project] must be a string"));
CANON_CHECK(fails_with("[project]\nname = \"libhello\"\nkind = \"app\"\n",
"line 3: 'kind' in [project] must be \"lib\" or \"exe\""));
CANON_CHECK(fails_with("[project]\nname = \"hello\"\nkind = \"lib\"\n", "must start with 'lib'"));
CANON_CHECK(fails_with(valid_header + "stem = \"other\"\n", "line 4: stem 'other' does not match"));
CANON_CHECK(fails_with(valid_header + "[build]\nbackend = \"meson\"\n", "line 5: backend 'meson'"));
CANON_CHECK(fails_with(valid_header + "[build]\ncxx-standard = \"23\"\n", "must be an integer"));
CANON_CHECK(fails_with(valid_header + "[build]\ncxx-standard = 3\n", "must be a C++ standard"));
CANON_CHECK(fails_with(valid_header + "[extras]\n", "line 4: unknown table [extras]"));
CANON_CHECK(fails_with(valid_header + "[targets]\n", "unknown table [targets]"));
CANON_CHECK(fails_with(valid_header + "[[targets]]\nname = \"libhello\"\ntype = \"shared\"\n",
"line 6: 'type' in [[targets]] must be"));
CANON_CHECK(fails_with(valid_header + "[[targets]]\nname = \"libhello\"\ntype = \"library\"\n"
"sources = \"hello.cpp\"\n",
"line 7: 'sources' in [[targets]] must be an array of strings"));
CANON_CHECK(fails_with(valid_header + "[[targets]]\nname = \"a\"\ntype = \"library\"\n"
"[[targets]]\nname = \"a\"\ntype = \"library\"\n",
"line 7: target 'a' is declared twice"));
CANON_CHECK(fails_with(valid_header + "[[modules]]\npath = \"hello/x\"\ntarget = \"nope\"\n",
"line 4: module 'hello/x' refers to unknown target 'nope'"));
CANON_CHECK(fails_with(valid_header + "[[modules]]\npath = \"hello/x\"\ntarget = \"t\"\nunit-test = \"yes\"\n",
"'unit-test' in [[modules]] must be true or false"));
CANON_CHECK(fails_with(valid_header + "[[tests]]\nname = \"b\"\nkind = \"driver\"\ntarget = \"t\"\n",
"test 'b' has kind \"driver\" but no 'path'"));
CANON_CHECK(fails_with("[project]\nname = \"libhello\n", "line 2: unterminated string"));
return details::exit_status();
}
+16 -13
View File
@@ -31,16 +31,25 @@ 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()) {
} // namespace
std::string_view to_string(project_kind kind) {
return kind == project_kind::library ? "lib" : "exe";
}
bool is_cxx_keyword(std::string_view word) {
return std::ranges::contains(cxx_keywords, word);
}
std::string name_problem(std::string_view s) {
if (s.empty()) {
return "the name is empty";
}
if (stem.front() < 'a' || stem.front() > 'z') {
if (s.front() < 'a' || s.front() > 'z') {
return "it must start with a lowercase letter";
}
char previous = '\0';
for (char c : stem) {
for (char c : s) {
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);
@@ -56,12 +65,6 @@ std::string stem_problem(std::string_view stem) {
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";
@@ -79,13 +82,13 @@ std::expected<project_name, std::string> parse_project_name(project_kind kind,
name));
}
if (const std::string problem = stem_problem(stem); !problem.empty()) {
if (const std::string problem = name_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))) {
if (is_cxx_keyword(ident)) {
return std::unexpected(std::format(
"invalid name '{}': '{}' is a C++ keyword and cannot be a namespace", name, ident));
}
+9 -1
View File
@@ -17,11 +17,19 @@ struct project_name {
std::string name; // libhello, hello
std::string stem; // hello: the source directory and include prefix, <hello/...>
std::string ident; // hello: C++ namespace ('-' becomes '_')
bool operator==(const project_name&) const = default;
};
// 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.
// Stems follow name_problem's rules and must not be a C++ keyword.
[[nodiscard]] std::expected<project_name, std::string> parse_project_name(project_kind,
std::string_view name);
// Why s cannot be used as a name: it must use a-z, 0-9, '-' and '_', start with
// a letter, and not double or end with a separator. Empty if s is fine.
[[nodiscard]] std::string name_problem(std::string_view s);
[[nodiscard]] bool is_cxx_keyword(std::string_view);
} // namespace canon
+21 -9
View File
@@ -27,18 +27,30 @@ void plan::add_file(std::filesystem::path file, std::string content) {
operations.emplace_back(create_file{std::move(file), std::move(content)});
}
void plan::update(std::filesystem::path file, std::string expected, std::string content) {
operations.emplace_back(update_file{std::move(file), std::move(expected), std::move(content)});
}
std::string describe(const operation& op) {
return std::visit(details::overloaded{
[](const make_directory& d) {
return std::format(" mkdir {}/", d.path.generic_string());
},
[](const create_file& f) {
return std::format(" create {}", f.path.generic_string());
},
[](const update_file& f) {
return std::format(" update {}", f.path.generic_string());
},
},
op);
}
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);
out += describe(op);
out += '\n';
}
return out;
}
+16 -2
View File
@@ -7,6 +7,7 @@
namespace canon {
// Ensures a directory exists.
struct make_directory {
std::filesystem::path path;
};
@@ -17,7 +18,15 @@ struct create_file {
std::string content;
};
using operation = std::variant<make_directory, create_file>;
// Replaces a file's content. Applying fails unless the file still holds
// `expected`, so edits made after canon read the file are never lost.
struct update_file {
std::filesystem::path path;
std::string expected;
std::string content;
};
using operation = std::variant<make_directory, create_file, update_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).
@@ -30,9 +39,14 @@ struct plan {
// Adds the file, preceded by any parent directories not already in the plan.
void add_file(std::filesystem::path, std::string content);
void update(std::filesystem::path, std::string expected, std::string content);
};
// One line per operation, e.g. " create libhello/hello/hello.hpp".
// e.g. " create libhello/hello/hello.hpp"
[[nodiscard]] std::string describe(const operation&);
// One line per operation.
[[nodiscard]] std::string describe(const plan&);
} // namespace canon
+3 -1
View File
@@ -9,13 +9,15 @@ int main() {
p.add_file("proj/a/b/file.txt", "one");
p.add_file("proj/a/other.txt", "two");
p.add_directory("proj/a/b");
p.update("proj/CMakeLists.txt", "old", "new");
// 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");
" create proj/a/other.txt\n"
" update proj/CMakeLists.txt\n");
const auto* file = std::get_if<create_file>(&p.operations.at(3));
CANON_CHECK(file != nullptr);
+56
View File
@@ -0,0 +1,56 @@
#include <canon/project.hpp>
#include <canon/details/files.hpp>
#include <format>
namespace canon {
namespace fs = std::filesystem;
std::expected<project, std::string> load_project(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()));
}
if (!directory.has_filename()) {
directory = directory.parent_path(); // drop a trailing separator
}
project p;
for (fs::path current = directory;; current = current.parent_path()) {
if (fs::is_regular_file(current / manifest_file, error)) {
p.root = current;
break;
}
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()));
}
}
const fs::path manifest_path = p.root / manifest_file;
auto manifest_text = details::read_file(manifest_path);
if (!manifest_text) {
return std::unexpected(std::format("cannot read {}", manifest_path.string()));
}
auto declared = parse_manifest(*manifest_text);
if (!declared) {
return std::unexpected(std::format("{}: {}", manifest_path.string(), declared.error()));
}
const fs::path cmakelists_path = p.root / "CMakeLists.txt";
auto cmakelists_text = details::read_file(cmakelists_path);
if (!cmakelists_text || !fs::is_regular_file(cmakelists_path, error)) {
return std::unexpected(std::format("cannot read {}", cmakelists_path.string()));
}
p.declared = std::move(*declared);
p.manifest_text = std::move(*manifest_text);
p.cmakelists_text = std::move(*cmakelists_text);
return p;
}
} // namespace canon
+24
View File
@@ -0,0 +1,24 @@
#pragma once
#include <canon/manifest.hpp>
#include <expected>
#include <filesystem>
#include <string>
namespace canon {
// An existing project as read from disk. Generators work on this snapshot
// instead of the filesystem; the texts let update_file detect later edits.
struct project {
std::filesystem::path root{}; // the directory holding .canon.toml
manifest declared{};
std::string manifest_text{};
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.
[[nodiscard]] std::expected<project, std::string> load_project(const std::filesystem::path& start);
} // namespace canon
+64
View File
@@ -0,0 +1,64 @@
#include <canon/project.hpp>
#include <canon/details/testing.hpp>
#include <canon/executor.hpp>
#include <canon/generate.hpp>
#include <filesystem>
#include <fstream>
using namespace canon;
namespace fs = std::filesystem;
int main() {
const details::temp_directory tmp;
const auto name = parse_project_name(project_kind::library, "libhello");
CANON_CHECK(apply(generate_new_project(*name), tmp.path()).has_value());
const fs::path root = (tmp.path() / "libhello").lexically_normal();
// Found from the root and from any directory inside the project.
for (const fs::path& start : {root, root / "hello", root / "tests" / "basics"}) {
const auto p = load_project(start);
CANON_CHECK(p);
if (p) {
CANON_CHECK_EQ(p->root.string(), root.string());
CANON_CHECK(p->declared == new_project_manifest(*name));
CANON_CHECK(p->manifest_text.starts_with("# canon project manifest"));
CANON_CHECK(p->cmakelists_text.starts_with("cmake_minimum_required"));
}
}
{
const auto p = load_project(tmp.path());
CANON_CHECK(!p);
if (!p) {
CANON_CHECK(p.error().contains("no .canon.toml in"));
}
}
CANON_CHECK(!load_project(tmp.path() / "does-not-exist"));
{
std::ofstream(root / ".canon.toml", std::ios::app) << "typo = 1\n";
const auto p = load_project(root);
CANON_CHECK(!p);
if (!p) {
CANON_CHECK(p.error().contains(".canon.toml: line"));
CANON_CHECK(p.error().contains("unknown key 'typo'"));
}
}
{
const auto fresh = generate_new_project(*name);
const details::temp_directory other;
CANON_CHECK(apply(fresh, other.path()).has_value());
fs::remove(other.path() / "libhello" / "CMakeLists.txt");
const auto p = load_project(other.path() / "libhello");
CANON_CHECK(!p);
if (!p) {
CANON_CHECK(p.error().contains("cannot read"));
}
}
return details::exit_status();
}
+376
View File
@@ -0,0 +1,376 @@
#include <canon/toml.hpp>
#include <algorithm>
#include <charconv>
#include <format>
namespace canon {
namespace {
struct parse_error {
int line;
std::string message;
};
bool is_bare_key_char(char c) {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' ||
c == '-';
}
bool is_digit(char c) {
return c >= '0' && c <= '9';
}
void append_utf8(std::string& out, char32_t cp) {
if (cp < 0x80) {
out += static_cast<char>(cp);
} else if (cp < 0x800) {
out += static_cast<char>(0xC0 | (cp >> 6));
out += static_cast<char>(0x80 | (cp & 0x3F));
} else if (cp < 0x10000) {
out += static_cast<char>(0xE0 | (cp >> 12));
out += static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
out += static_cast<char>(0x80 | (cp & 0x3F));
} else {
out += static_cast<char>(0xF0 | (cp >> 18));
out += static_cast<char>(0x80 | ((cp >> 12) & 0x3F));
out += static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
out += static_cast<char>(0x80 | (cp & 0x3F));
}
}
class parser {
public:
explicit parser(std::string_view text) : text_(text) {}
std::vector<toml_table> parse() {
std::vector<toml_table> tables(1);
tables.front().line = 1;
while (skip_trivia(), !at_end()) {
if (peek() == '[') {
tables.push_back(header(tables));
} else {
entry(tables.back());
}
}
return tables;
}
private:
std::string_view text_;
std::size_t pos_ = 0;
int line_ = 1;
[[nodiscard]] bool at_end() const { return pos_ >= text_.size(); }
[[nodiscard]] char peek() const { return text_[pos_]; }
[[nodiscard]] std::string_view rest() const { return text_.substr(pos_); }
void advance() {
if (text_[pos_] == '\n') {
++line_;
}
++pos_;
}
[[noreturn]] void fail(std::string message) const { throw parse_error{line_, std::move(message)}; }
void expect(char c) {
if (at_end() || peek() != c) {
fail(std::format("expected '{}'", c));
}
advance();
}
void skip_spaces() {
while (!at_end() && (peek() == ' ' || peek() == '\t')) {
advance();
}
}
void skip_comment() {
if (!at_end() && peek() == '#') {
while (!at_end() && peek() != '\n') {
advance();
}
}
}
// Whitespace, comments and blank lines.
void skip_trivia() {
for (;;) {
skip_spaces();
skip_comment();
if (at_end() || (peek() != '\n' && peek() != '\r')) {
return;
}
advance();
}
}
void end_of_line() {
skip_spaces();
skip_comment();
if (at_end()) {
return;
}
if (peek() == '\r') {
advance();
}
if (at_end() || peek() != '\n') {
fail("expected the end of the line");
}
advance();
}
std::string bare_key() {
const std::size_t start = pos_;
while (!at_end() && is_bare_key_char(peek())) {
advance();
}
if (pos_ == start) {
fail("expected a key (letters, digits, '_' or '-')");
}
return std::string(text_.substr(start, pos_ - start));
}
toml_table header(const std::vector<toml_table>& tables) {
const int line = line_;
advance(); // [
const bool array = !at_end() && peek() == '[';
if (array) {
advance();
}
skip_spaces();
std::string name = bare_key();
skip_spaces();
expect(']');
if (array) {
expect(']');
}
end_of_line();
for (const toml_table& existing : tables) {
if (existing.name != name) {
continue;
}
if (array != existing.array_element) {
throw parse_error{line, std::format("[{0}] and [[{0}]] cannot both be used", name)};
}
if (!array) {
throw parse_error{line, std::format("table [{}] is defined twice", name)};
}
}
return toml_table{.name = std::move(name), .array_element = array, .line = line};
}
void entry(toml_table& table) {
const int line = line_;
std::string key = bare_key();
skip_spaces();
expect('=');
skip_spaces();
toml_value value = parse_value();
end_of_line();
if (std::ranges::contains(table.entries, key, &toml_entry::key)) {
throw parse_error{line, std::format("key '{}' is defined twice", key)};
}
table.entries.push_back({.key = std::move(key), .value = std::move(value), .line = line});
}
toml_value parse_value() {
if (at_end() || peek() == '\n' || peek() == '\r') {
fail("expected a value");
}
const char c = peek();
if (c == '"') {
return basic_string();
}
if (c == '[') {
return string_array();
}
if (c == 't' || c == 'f') {
return boolean();
}
if (c == '-' || c == '+' || is_digit(c)) {
return integer();
}
if (c == '\'') {
fail("literal strings ('...') are not supported; use \"...\"");
}
fail("unsupported value (canon reads strings, integers, booleans and arrays of strings)");
}
std::string basic_string() {
advance(); // opening quote
if (rest().starts_with("\"\"")) {
fail("multi-line strings are not supported");
}
std::string out;
for (;;) {
if (at_end() || peek() == '\n') {
fail("unterminated string");
}
const char c = peek();
advance();
if (c == '"') {
return out;
}
if (c != '\\') {
if (static_cast<unsigned char>(c) < 0x20 && c != '\t') {
fail("control characters in strings must be escaped");
}
out += c;
continue;
}
if (at_end()) {
fail("unterminated string");
}
const char escape = peek();
advance();
switch (escape) {
case '"': out += '"'; break;
case '\\': out += '\\'; break;
case 'b': out += '\b'; break;
case 'f': out += '\f'; break;
case 'n': out += '\n'; break;
case 'r': out += '\r'; break;
case 't': out += '\t'; break;
case 'u': append_utf8(out, code_point(4)); break;
case 'U': append_utf8(out, code_point(8)); break;
default: fail(std::format("unknown escape sequence '\\{}'", escape));
}
}
}
char32_t code_point(int digits) {
char32_t value = 0;
for (int i = 0; i < digits; ++i) {
if (at_end()) {
fail("incomplete unicode escape");
}
const char c = peek();
int digit = 0;
if (is_digit(c)) {
digit = c - '0';
} else if (c >= 'a' && c <= 'f') {
digit = c - 'a' + 10;
} else if (c >= 'A' && c <= 'F') {
digit = c - 'A' + 10;
} else {
fail("incomplete unicode escape");
}
advance();
value = value * 16 + static_cast<char32_t>(digit);
}
if (value > 0x10FFFF || (value >= 0xD800 && value <= 0xDFFF)) {
fail("unicode escape is not a valid code point");
}
return value;
}
std::vector<std::string> string_array() {
advance(); // [
std::vector<std::string> items;
for (;;) {
skip_trivia();
if (at_end()) {
fail("unterminated array");
}
if (peek() == ']') {
advance();
return items;
}
if (peek() != '"') {
fail("arrays may only contain strings");
}
items.push_back(basic_string());
skip_trivia();
if (at_end()) {
fail("unterminated array");
}
if (peek() == ',') {
advance();
} else if (peek() != ']') {
fail("expected ',' or ']' in array");
}
}
}
bool boolean() {
for (const std::string_view word : {"true", "false"}) {
if (rest().starts_with(word)) {
pos_ += word.size();
return word == "true";
}
}
fail("unsupported value (canon reads strings, integers, booleans and arrays of strings)");
}
std::int64_t integer() {
const std::size_t start = pos_;
if (peek() == '+' || peek() == '-') {
advance();
}
while (!at_end() && is_digit(peek())) {
advance();
}
std::string_view token = text_.substr(start, pos_ - start);
if (token.starts_with('+')) {
token.remove_prefix(1);
}
std::int64_t value = 0;
const auto [end, error] = std::from_chars(token.data(), token.data() + token.size(), value);
if (error != std::errc{} || end != token.data() + token.size()) {
fail("invalid integer");
}
return value;
}
};
} // namespace
std::expected<std::vector<toml_table>, std::string> parse_toml(std::string_view text) {
try {
return parser(text).parse();
} catch (const parse_error& e) {
return std::unexpected(std::format("line {}: {}", e.line, e.message));
}
}
std::string toml_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 toml_array(const std::vector<std::string>& items) {
std::string out = "[";
for (bool first = true; const auto& item : items) {
if (!first) {
out += ", ";
}
out += toml_quote(item);
first = false;
}
out += ']';
return out;
}
} // namespace canon
+41
View File
@@ -0,0 +1,41 @@
#pragma once
#include <cstdint>
#include <expected>
#include <string>
#include <string_view>
#include <variant>
#include <vector>
namespace canon {
// The subset of TOML that canon reads and writes: [tables], [[arrays of
// tables]], bare keys, and values that are basic strings, integers, booleans
// or arrays of strings (which may span lines). Anything else is rejected with
// a line number rather than half-understood.
using toml_value = std::variant<std::string, std::int64_t, bool, std::vector<std::string>>;
struct toml_entry {
std::string key{};
toml_value value{};
int line = 0;
};
struct toml_table {
std::string name{}; // empty for keys before the first header
bool array_element = false; // declared with [[name]]
int line = 0;
std::vector<toml_entry> entries{};
};
// Tables in file order. The first is always the unnamed root table.
[[nodiscard]] std::expected<std::vector<toml_table>, std::string> parse_toml(std::string_view);
// A TOML basic string: "..." with escapes.
[[nodiscard]] std::string toml_quote(std::string_view);
// ["a", "b"]
[[nodiscard]] std::string toml_array(const std::vector<std::string>&);
} // namespace canon
+116
View File
@@ -0,0 +1,116 @@
#include <canon/toml.hpp>
#include <canon/details/testing.hpp>
#include <string>
#include <string_view>
#include <vector>
using namespace canon;
namespace {
bool fails_with(std::string_view text, std::string_view message) {
const auto result = parse_toml(text);
if (result) {
return false;
}
const bool matches = result.error().contains(message);
if (!matches) {
std::println(stderr, " error was: {}", result.error());
}
return matches;
}
} // namespace
int main() {
{
const auto tables = parse_toml(R"(# comment
[project] # trailing comment
name = "libhello"
count = -42
enabled = true
off=false
[[targets]]
sources = [
"a.cpp", # first
"b.cpp",
]
[[targets]]
sources = []
)");
CANON_CHECK(tables);
if (tables) {
CANON_CHECK_EQ(tables->size(), 4u);
CANON_CHECK(tables->at(0).name.empty());
CANON_CHECK(tables->at(0).entries.empty());
const toml_table& project = tables->at(1);
CANON_CHECK_EQ(project.name, "project");
CANON_CHECK(!project.array_element);
CANON_CHECK_EQ(project.line, 3);
CANON_CHECK_EQ(project.entries.size(), 4u);
CANON_CHECK(std::get<std::string>(project.entries.at(0).value) == "libhello");
CANON_CHECK_EQ(project.entries.at(0).line, 4);
CANON_CHECK_EQ(std::get<std::int64_t>(project.entries.at(1).value), -42);
CANON_CHECK_EQ(std::get<bool>(project.entries.at(2).value), true);
CANON_CHECK_EQ(project.entries.at(3).key, "off");
CANON_CHECK_EQ(std::get<bool>(project.entries.at(3).value), false);
CANON_CHECK(tables->at(2).array_element);
CANON_CHECK(std::get<std::vector<std::string>>(tables->at(2).entries.at(0).value) ==
std::vector<std::string>({"a.cpp", "b.cpp"}));
CANON_CHECK(std::get<std::vector<std::string>>(tables->at(3).entries.at(0).value).empty());
}
}
// Escapes, including unicode, and CRLF line endings.
{
const auto tables =
parse_toml("[t]\r\ns = \"q\\\" b\\\\ n\\n t\\t u\\u00e9 U\\U0001F600 # not a comment\"\r\n");
CANON_CHECK(tables);
if (tables) {
CANON_CHECK_EQ(std::get<std::string>(tables->at(1).entries.at(0).value),
"q\" b\\ n\n t\t u\xC3\xA9 U\xF0\x9F\x98\x80 # not a comment");
}
}
// What toml_quote writes, parse_toml reads back.
for (const std::string_view original : {"plain", "a\"b\\c", "tab\tnew\nline", "\x01\x1f\x7f",
"caf\xC3\xA9", ""}) {
const auto tables = parse_toml("[t]\nv = " + toml_quote(original) + "\n");
CANON_CHECK(tables);
if (tables) {
CANON_CHECK(std::get<std::string>(tables->at(1).entries.at(0).value) == original);
}
}
CANON_CHECK_EQ(toml_array({"a", "b\"c"}), R"(["a", "b\"c"])");
CANON_CHECK_EQ(toml_array({}), "[]");
// Errors carry the line number.
CANON_CHECK(fails_with("[t]\nname = \"x\nnext = 1\n", "line 2: unterminated string"));
CANON_CHECK(fails_with("[t]\na = 1\na = 2\n", "line 3: key 'a' is defined twice"));
CANON_CHECK(fails_with("[t]\n[t]\n", "line 2: table [t] is defined twice"));
CANON_CHECK(fails_with("[t]\n[[t]]\n", "line 2: [t] and [[t]] cannot both be used"));
CANON_CHECK(fails_with("[t]\nname \"x\"\n", "line 2: expected '='"));
CANON_CHECK(fails_with("[t]\nname = \"x\" extra\n", "line 2: expected the end of the line"));
CANON_CHECK(fails_with("[t]\nname = \n", "line 2: expected a value"));
CANON_CHECK(fails_with("[t]\ns = \"\\q\"\n", "unknown escape sequence '\\q'"));
CANON_CHECK(fails_with("[t]\ns = \"\\u12\"\n", "incomplete unicode escape"));
CANON_CHECK(fails_with("[t]\ns = \"\\uD800\"\n", "not a valid code point"));
CANON_CHECK(fails_with("[t]\ns = 'x'\n", "literal strings"));
CANON_CHECK(fails_with("[t]\ns = \"\"\"x\"\"\"\n", "multi-line strings"));
CANON_CHECK(fails_with("[t]\ns = 1.5\n", "expected the end of the line"));
CANON_CHECK(fails_with("[t]\na = [\"x\", 1]\n", "arrays may only contain strings"));
CANON_CHECK(fails_with("[t]\na = [\"x\"\n", "unterminated array"));
CANON_CHECK(fails_with("[t]\na = [\"x\" \"y\"]\n", "expected ',' or ']'"));
CANON_CHECK(fails_with("[t]\nb = yes\n", "unsupported value"));
CANON_CHECK(fails_with("[t\n", "line 1: expected ']'"));
CANON_CHECK(fails_with("[t]\n\"quoted\" = 1\n", "expected a key"));
return details::exit_status();
}
+8 -13
View File
@@ -1,6 +1,6 @@
# 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.
# Runs each scenario from ../scenarios.cmake, then configures, builds and tests
# the resulting project 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
@@ -12,8 +12,8 @@ foreach(var CANON WORK_DIR CXX)
endif()
endforeach()
file(REMOVE_RECURSE ${WORK_DIR})
file(MAKE_DIRECTORY ${WORK_DIR})
get_filename_component(WORK_DIR "${WORK_DIR}" ABSOLUTE)
include(${CMAKE_CURRENT_LIST_DIR}/../scenarios.cmake)
function(run)
execute_process(COMMAND ${ARGN}
@@ -26,14 +26,9 @@ function(run)
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})
foreach(scenario ${CANON_SCENARIOS})
message(STATUS "e2e: ${scenario}")
canon_scenario(${scenario} ${WORK_DIR} project)
run(${CMAKE_COMMAND} -S ${project} -B ${project}/build
-DCMAKE_CXX_COMPILER=${CXX} -DCMAKE_BUILD_TYPE=Debug)
run(${CMAKE_COMMAND} --build ${project}/build)
+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 = "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", "hello/greeting.cpp"]
[[modules]]
path = "hello/greeting"
target = "hello"
unit-test = true
[[tests]]
name = "basics"
kind = "run"
target = "hello"
expect = "Hello, World!"
+33
View File
@@ -0,0 +1,33 @@
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
hello/greeting.cpp
)
target_include_directories(hello PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
if(BUILD_TESTING)
add_executable(hello.greeting.test hello/greeting.test.cpp hello/greeting.cpp)
target_include_directories(hello.greeting.test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
add_test(NAME hello/greeting.test COMMAND hello.greeting.test)
add_test(NAME basics COMMAND hello)
set_tests_properties(basics PROPERTIES PASS_REGULAR_EXPRESSION "Hello, World!")
endif()
# <<< canon:managed <<<
@@ -0,0 +1,5 @@
#include <hello/greeting.hpp>
namespace hello {
} // namespace hello
@@ -0,0 +1,5 @@
#pragma once
namespace hello {
} // namespace hello
@@ -0,0 +1,6 @@
// Unit tests for <hello/greeting.hpp>. Return non-zero from main() to fail.
#include <hello/greeting.hpp>
int main() {
}
+39
View File
@@ -0,0 +1,39 @@
# 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/core.cpp", "hello/details/utility.cpp"]
[[modules]]
path = "hello/hello"
target = "libhello"
unit-test = true
[[modules]]
path = "hello/core"
target = "libhello"
unit-test = true
[[modules]]
path = "hello/details/utility"
target = "libhello"
unit-test = true
[[tests]]
name = "basics"
kind = "driver"
path = "tests/basics"
target = "libhello"
@@ -0,0 +1,45 @@
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/core.cpp
hello/details/utility.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.core.test hello/core.test.cpp)
target_link_libraries(hello.core.test PRIVATE libhello)
add_test(NAME hello/core.test COMMAND hello.core.test)
add_executable(hello.details.utility.test hello/details/utility.test.cpp)
target_link_libraries(hello.details.utility.test PRIVATE libhello)
add_test(NAME hello/details/utility.test COMMAND hello.details.utility.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,5 @@
#include <hello/core.hpp>
namespace hello {
} // namespace hello
@@ -0,0 +1,5 @@
#pragma once
namespace hello {
} // namespace hello
@@ -0,0 +1,6 @@
// Unit tests for <hello/core.hpp>. Return non-zero from main() to fail.
#include <hello/core.hpp>
int main() {
}
@@ -0,0 +1,5 @@
#include <hello/details/utility.hpp>
namespace hello::details {
} // namespace hello::details
@@ -0,0 +1,5 @@
#pragma once
namespace hello::details {
} // namespace hello::details
@@ -0,0 +1,6 @@
// Unit tests for <hello/details/utility.hpp>. Return non-zero from main() to fail.
#include <hello/details/utility.hpp>
int main() {
}
+14 -35
View File
@@ -1,5 +1,5 @@
# Scaffolds each project kind and compares the result byte for byte with the
# expected trees next to this script.
# Runs each scenario from ../scenarios.cmake and compares the resulting project
# byte for byte with the expected tree in <scenario>/ next to this script.
#
# cmake -DCANON=<canon executable> -DWORK_DIR=<scratch dir> [-DUPDATE=ON] -P check.cmake
#
@@ -14,42 +14,20 @@ foreach(var CANON WORK_DIR)
endif()
endforeach()
set(golden_dir ${CMAKE_CURRENT_LIST_DIR})
file(REMOVE_RECURSE ${WORK_DIR})
file(MAKE_DIRECTORY ${WORK_DIR})
get_filename_component(WORK_DIR "${WORK_DIR}" ABSOLUTE)
include(${CMAKE_CURRENT_LIST_DIR}/../scenarios.cmake)
set(golden_dir ${CMAKE_CURRENT_LIST_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})
foreach(scenario ${CANON_SCENARIOS})
canon_scenario(${scenario} ${WORK_DIR} actual)
get_filename_component(name ${actual} NAME)
set(expected ${golden_dir}/${scenario}/${name})
if(UPDATE)
file(REMOVE_RECURSE ${expected})
file(COPY ${actual} DESTINATION ${golden_dir})
file(REMOVE_RECURSE ${golden_dir}/${scenario})
file(COPY ${actual} DESTINATION ${golden_dir}/${scenario})
message(STATUS "Updated ${expected}")
continue()
endif()
@@ -60,7 +38,7 @@ foreach(case lib:libhello exe:hello)
list(SORT expected_files)
if(NOT actual_files STREQUAL expected_files)
message(SEND_ERROR "${name}: files differ from golden\n"
message(SEND_ERROR "${scenario}: files differ from golden\n"
" actual: ${actual_files}\n"
" expected: ${expected_files}")
set(failed TRUE)
@@ -72,7 +50,8 @@ foreach(case lib:libhello exe:hello)
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})")
message(SEND_ERROR "${scenario}: ${name}/${file} differs from golden "
"(diff ${actual}/${file} ${expected}/${file})")
set(failed TRUE)
endif()
endforeach()
+1
View File
@@ -0,0 +1 @@
/build/
+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
```
@@ -0,0 +1,5 @@
#include <print>
int main(int argc, char* argv[]) {
std::println("Hello, {}!", argc > 1 ? argv[1] : "World");
}
+1
View File
@@ -0,0 +1 @@
/build/
+12
View File
@@ -0,0 +1,12 @@
# libhello
Created by canon. The layout follows [P1204R0](https://wg21.link/p1204r0)
(Canonical Project Structure).
## Build and test
```bash
cmake -S . -B build
cmake --build build
ctest --test-dir build --output-on-failure
```
@@ -0,0 +1,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,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;
}
+72
View File
@@ -0,0 +1,72 @@
# canon command sequences shared by the golden and end-to-end tests. Each
# scenario builds one project inside <work_dir>/<scenario>/. Requires CANON.
set(CANON_SCENARIOS new-lib new-exe add-lib add-exe)
# Scenarios run canon from other directories, so a relative path would break.
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}
WORKING_DIRECTORY ${working_dir}
RESULT_VARIABLE status
OUTPUT_VARIABLE output
ERROR_VARIABLE errors)
if(NOT status EQUAL 0)
string(JOIN " " args ${ARGN})
message(FATAL_ERROR "canon ${args} failed (${status}) in ${working_dir}:\n${output}${errors}")
endif()
endfunction()
# Runs canon in working_dir and fails the script if it succeeds.
function(canon_run_fails working_dir)
execute_process(
COMMAND ${CANON} ${ARGN}
WORKING_DIRECTORY ${working_dir}
RESULT_VARIABLE status
OUTPUT_QUIET ERROR_QUIET)
if(status EQUAL 0)
string(JOIN " " args ${ARGN})
message(FATAL_ERROR "canon ${args} succeeded in ${working_dir} but should have failed")
endif()
endfunction()
# Runs the scenario and sets out_var to the project directory it produced.
function(canon_scenario scenario work_dir out_var)
set(dir ${work_dir}/${scenario})
file(REMOVE_RECURSE ${dir})
file(MAKE_DIRECTORY ${dir})
if(scenario STREQUAL "new-lib")
canon_run(${dir} new lib libhello)
canon_run_fails(${dir} new lib libhello) # never overwrites
set(project ${dir}/libhello)
elseif(scenario STREQUAL "new-exe")
canon_run(${dir} new exe hello)
set(project ${dir}/hello)
elseif(scenario STREQUAL "add-lib")
canon_run(${dir} new lib libhello)
set(project ${dir}/libhello)
canon_run(${project} add module core)
canon_run(${project}/hello add module details/utility --no-test) # from a subdirectory
canon_run(${project} add unit-test details/utility)
canon_run(${project} add module parser --dry-run) # must write nothing
canon_run_fails(${project} add module core) # already declared
canon_run_fails(${project} add unit-test core) # already has one
canon_run_fails(${project} add module Bad) # invalid path
elseif(scenario STREQUAL "add-exe")
canon_run(${dir} new exe hello)
set(project ${dir}/hello)
canon_run(${project} add module greeting)
else()
message(FATAL_ERROR "unknown scenario '${scenario}'")
endif()
set(${out_var} ${project} PARENT_SCOPE)
endfunction()