Add canon add target, add test, add dep and doctor

- add target lib|exe <name>: another library or executable in a source
  directory named after it (P1204R0's rule applied per target); a new
  executable links the project's library
- add test <name>: tests/<name>/driver.cpp for a library, or a run of an
  executable, with --arg and --expect
- add dep <dependency>: link a library of this project (no cycles, no
  executables) or an installed package's imported target such as
  fmt::fmt, adding its find_package()
- doctor: read-only check of the files against .canon.toml and the
  P1204R0 layout; errors for missing declared files or broken markers,
  warnings for unbuilt sources, undeclared tests, a hand-edited managed
  block, .h/.cc extensions and include/ or src/ directories
- every add command takes --target; the CLI rejects options a command
  does not take
- manifest: packages, test args, and validation of target names, shared
  source directories and dependencies
- templates moved to canon/templates.hpp
- tests: multi-target and exe-tests scenarios; every scenario ends with
  canon doctor; CANON_CHECK accepts expressions containing commas; the
  CLI test no longer reads a destroyed temporary

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 18:38:59 +02:00
parent d38e1466da
commit 651fa4d89b
42 changed files with 2075 additions and 428 deletions
+2 -1
View File
@@ -15,6 +15,7 @@ add_library(canon.core STATIC
canon/add.cpp
canon/cli.cpp
canon/cmake.cpp
canon/doctor.cpp
canon/executor.cpp
canon/generate.cpp
canon/manifest.cpp
@@ -38,7 +39,7 @@ if(BUILD_TESTING)
enable_testing()
# Unit tests live next to the code they test: canon/<unit>.test.cpp.
foreach(unit add cli cmake executor generate manifest name plan project template toml)
foreach(unit add cli cmake doctor 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)
+59 -14
View File
@@ -1,24 +1,43 @@
# canon
Scaffolds C++ projects that follow [P1204R0](https://wg21.link/p1204r0)
(Canonical Project Structure), then keeps adding to them. Written in C++23, no
third-party dependencies.
(Canonical Project Structure), keeps adding to them, and checks 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 # libhello/hello/hello.{hpp,cpp,test.cpp} + tests/basics
canon new exe hello # hello/hello/hello.cpp
canon new exe hello --dir ~/src # create the project somewhere else
cd libhello
# Modules: related .hpp/.cpp/.test.cpp files
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
# Targets: more libraries or executables, each in its own source directory
canon add target exe hello-cli # hello-cli/hello-cli.cpp, links libhello
canon add module args --target hello-cli
canon add target lib libextra # extra/extra.{hpp,cpp,test.cpp}
# Functional tests
canon add test edge-cases # tests/edge-cases/driver.cpp, linked with libhello
canon add test greets --target hello-cli --arg Paul --expect "Hello, Paul!"
# Dependencies
canon add dep libextra # libhello links libextra
canon add dep fmt::fmt --target hello-cli # find_package(fmt) + link
canon add dep Boost::filesystem --package Boost # when the package name differs
# Checks
canon doctor # compare the files with .canon.toml and P1204R0
canon add module core --dry-run # any add command: show the changes, write nothing
```
`add` commands work from any directory inside the project. Every generated
`add` and `doctor` work from any directory inside the project. Every generated
project builds and passes its tests out of the box:
```bash
@@ -50,19 +69,44 @@ The block between `# >>> canon:managed >>>` and `# <<< canon:managed <<<` in
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`;
- anything you edit **inside** the block is replaced on the next `add`
(`canon doctor` warns when the block has been edited);
- `.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
canon reads `.canon.toml` strictly: unknown keys (usually typos), invalid
names, targets sharing a source directory and links to executables are errors
with a line number.
### Targets and P1204R0
P1204R0 describes a project as one library or one executable with a source
directory named after it. `canon add target` applies the same rule to each extra
target: `libextra` lives in `extra/`, `hello-cli` in `hello-cli/`. A new
executable links the project's library automatically; a new library is linked
explicitly with `canon add dep`.
### doctor
`canon doctor` never changes anything. It reports:
- **errors** (exit status 1): files `.canon.toml` declares that are missing, and
a `CMakeLists.txt` without valid managed-block markers;
- **warnings**: `.cpp` files in a source directory that no target builds, unit
or functional tests that are not declared, a hand-edited managed block,
`.h`/`.cc`-style extensions, and `include/` or `src/` directories.
Hidden directories and top-level build output (`build*`, `cmake-build-*`) are
skipped.
| File | Role |
|---|---|
| `canon/canon.cpp` | `main`: wires the command line to the generators and executor |
| `canon/cli.*` | argument parsing |
| `canon/canon.cpp` | `main`: wires the command line to the generators, executor and doctor |
| `canon/cli.*` | argument parsing, including which options each command takes |
| `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/generate.*` | the manifest and plan for `canon new` |
| `canon/add.*` | the plans for every `canon add` command |
| `canon/doctor.*` | the checks behind `canon doctor` |
| `canon/templates.hpp` | the contents of generated files |
| `canon/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 |
@@ -75,7 +119,8 @@ with a line number.
- **Unit tests**: `canon/<unit>.test.cpp`, next to the code they cover.
- **Scenarios**: `tests/scenarios.cmake` runs command sequences (for example
`new lib` followed by several `add` commands) that both of the following use.
`new lib` followed by several `add` commands, including ones that must fail)
and ends each with `canon doctor`. Both of the following use them.
- **Golden tests**: `tests/golden/<scenario>/` holds the exact expected result of
each scenario. After an intentional template change, regenerate the copies and
review the diff:
@@ -87,7 +132,7 @@ with a line number.
1. ✅ `new lib`, `new exe`, plan/apply, `--dry-run`, manifest
2. ✅ `add module`, `add unit-test`, reading `.canon.toml`, managed-block updates
3. `add target`, `add test`, `add dep`, `doctor` (check a project against P1204R0)
3. ✅ `add target`, `add test`, `add dep`, `doctor`
4. User template overrides
## License
+244 -62
View File
@@ -2,50 +2,61 @@
#include <canon/cmake.hpp>
#include <canon/template.hpp>
#include <canon/templates.hpp>
#include <algorithm>
#include <format>
#include <ranges>
#include <vector>
#include <set>
#include <string_view>
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));
}
std::string target_list(const manifest& m) {
std::string out;
for (bool first = true; const target& t : m.targets) {
out += first ? "" : ", ";
out += t.name;
first = false;
}
return out.empty() ? "none" : out;
}
// The target a command acts on: the requested one, or the project's own.
std::expected<target*, std::string> find_target(manifest& m, std::string_view requested) {
const std::string_view name = requested.empty() ? std::string_view(m.project.name) : requested;
const auto found = std::ranges::find(m.targets, name, &target::name);
if (found != m.targets.end()) {
return &*found;
}
if (requested.empty()) {
return fail(std::format("{} declares no target named '{}'; choose one with --target ({})",
manifest_file, name, target_list(m)));
}
return fail(std::format("no target '{}' in {} (targets: {})", name, manifest_file, target_list(m)));
}
std::expected<project_name, std::string> names_of(const target& t) {
auto names = target_names(t);
if (!names) {
return fail(std::format("target '{}' in {}: {}", t.name, manifest_file, names.error()));
}
return names;
}
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) {
std::expected<module_names, std::string> resolve_module(const target& owner, std::string_view path) {
if (path.empty()) {
return fail("invalid module path: it is empty");
}
@@ -53,13 +64,17 @@ std::expected<module_names, std::string> resolve_module(const project_name& proj
return fail(std::format("give the module path without an extension, e.g. '{}'",
path.substr(0, path.size() - 4)));
}
const auto names = names_of(owner);
if (!names) {
return std::unexpected(names.error());
}
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};
module_names result{.path = names->stem, .include = {}, .ns = names->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();
@@ -77,78 +92,103 @@ std::expected<module_names, std::string> resolve_module(const project_name& proj
"invalid module path '{}': directory '{}' must be a valid namespace name", path,
segment));
}
names.ns += std::format("::{}", segment);
result.ns += std::format("::{}", segment);
}
names.path += std::format("/{}", segment);
result.path += std::format("/{}", segment);
}
names.include = names.path + ".hpp";
return names;
result.include = result.path + ".hpp";
return result;
}
template_vars module_vars(const module_names& names) {
return {{"include", names.include}, {"namespace", names.ns}};
}
// Whether `to` is `from` or one of the libraries it links, directly or not.
bool reaches(const manifest& m, std::string_view from, std::string_view to) {
std::set<std::string, std::less<>> seen;
std::vector<std::string> pending{std::string(from)};
while (!pending.empty()) {
const std::string current = std::move(pending.back());
pending.pop_back();
if (current == to) {
return true;
}
if (!seen.insert(current).second) {
continue;
}
if (const auto found = std::ranges::find(m.targets, current, &target::name); found != m.targets.end()) {
pending.insert(pending.end(), found->depends.begin(), found->depends.end());
}
}
return false;
}
bool is_package_name(std::string_view name) {
return !name.empty() && std::ranges::all_of(name, [](char c) {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' ||
c == '-' || c == '.' || c == '+';
});
}
// 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) {
std::expected<plan, std::string> finish(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 {};
return result;
}
} // 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);
std::expected<plan, std::string> plan_add_module(const project& p, const module_request& request) {
manifest updated = p.declared;
const auto owner = find_target(updated, request.target);
if (!owner) {
return std::unexpected(owner.error());
}
const auto names = resolve_module(**owner, request.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);
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});
updated.modules.push_back({.path = names->path, .target = (*owner)->name, .unit_test = request.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));
result.add_file(names->include, render(templates::module_header, vars));
result.add_file(source, render(templates::module_source, vars));
if (request.unit_test) {
result.add_file(names->path + ".test.cpp", render(templates::module_unit_test, vars));
}
if (auto ok = add_updates(result, p, updated); !ok) {
return std::unexpected(ok.error());
}
return result;
return finish(std::move(result), p, updated);
}
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);
std::expected<plan, std::string> plan_add_unit_test(const project& p, const module_request& request) {
manifest updated = p.declared;
const auto owner = find_target(updated, request.target);
if (!owner) {
return std::unexpected(owner.error());
}
const auto names = resolve_module(**owner, request.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));
return fail(std::format("no module '{}' in {}; create it with 'canon add module {}'", names->path,
manifest_file, request.path));
}
if (mod->unit_test) {
return fail(std::format("module '{}' already has a unit test", names->path));
@@ -156,11 +196,153 @@ std::expected<plan, std::string> plan_add_unit_test(const project& p, std::strin
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());
result.add_file(names->path + ".test.cpp", render(templates::module_unit_test, module_vars(*names)));
return finish(std::move(result), p, updated);
}
std::expected<plan, std::string> plan_add_target(const project& p, const target_request& request) {
const auto names = parse_project_name(request.kind, request.name);
if (!names) {
return std::unexpected(names.error());
}
return result;
manifest updated = p.declared;
if (std::ranges::contains(updated.targets, names->name, &target::name)) {
return fail(std::format("target '{}' already exists in {}", names->name, manifest_file));
}
if (names->stem == "tests") {
return fail(std::format("target '{}' would use 'tests/', which holds functional tests", names->name));
}
for (const target& existing : updated.targets) {
const auto existing_names = target_names(existing);
if (existing_names && existing_names->stem == names->stem) {
return fail(std::format("target '{}' would use the source directory '{}/', which belongs to '{}'",
names->name, names->stem, existing.name));
}
}
const std::string main_path = names->stem + "/" + names->stem;
const template_vars vars{{"name", names->name}, {"stem", names->stem}, {"ident", names->ident}};
target added{.name = names->name, .sources = {main_path + ".cpp"}};
plan result;
if (request.kind == project_kind::library) {
added.type = target_type::library;
result.add_file(main_path + ".hpp", render(templates::library_header, vars));
result.add_file(main_path + ".cpp", render(templates::library_source, vars));
if (request.with_test) {
result.add_file(main_path + ".test.cpp", render(templates::library_unit_test, vars));
}
updated.modules.push_back({.path = main_path, .target = names->name, .unit_test = request.with_test});
} else {
added.type = target_type::executable;
result.add_file(main_path + ".cpp", render(templates::executable_source, vars));
const auto own = std::ranges::find(updated.targets, updated.project.name, &target::name);
if (own != updated.targets.end() && own->type == target_type::library) {
added.depends.push_back(own->name);
}
if (request.with_test) {
if (std::ranges::contains(updated.tests, names->name, &test_entry::name)) {
return fail(std::format("a test named '{}' already exists; add the target with --no-test",
names->name));
}
updated.tests.push_back({.name = names->name, .kind = test_kind::run, .target = names->name});
}
}
updated.targets.push_back(std::move(added));
return finish(std::move(result), p, updated);
}
std::expected<plan, std::string> plan_add_test(const project& p, const test_request& request) {
if (const std::string problem = name_problem(request.name); !problem.empty()) {
return fail(std::format("invalid test name '{}': {}", request.name, problem));
}
manifest updated = p.declared;
const auto subject = find_target(updated, request.target);
if (!subject) {
return std::unexpected(subject.error());
}
if (std::ranges::contains(updated.tests, request.name, &test_entry::name)) {
return fail(std::format("test '{}' already exists in {}", request.name, manifest_file));
}
test_entry added{
.name = request.name,
.target = (*subject)->name,
.args = request.args,
.expect = request.expect,
};
plan result;
if ((*subject)->type == target_type::library) {
added.kind = test_kind::driver;
added.path = "tests/" + request.name;
const auto names = names_of(**subject);
if (!names) {
return std::unexpected(names.error());
}
const std::string main_path = names->stem + "/" + names->stem;
const bool has_main_header = std::ranges::contains(updated.modules, main_path, &source_module::path);
const template_vars vars{{"target", (*subject)->name}, {"include", main_path + ".hpp"}};
result.add_file(added.path + "/driver.cpp",
render(has_main_header ? templates::test_driver : templates::test_driver_without_include,
vars));
} else {
added.kind = test_kind::run;
}
updated.tests.push_back(std::move(added));
return finish(std::move(result), p, updated);
}
std::expected<plan, std::string> plan_add_dep(const project& p, const dep_request& request) {
manifest updated = p.declared;
const auto owner = find_target(updated, request.target);
if (!owner) {
return std::unexpected(owner.error());
}
target& t = **owner;
const std::string& dependency = request.dependency;
if (std::ranges::contains(t.depends, dependency)) {
return fail(std::format("'{}' already depends on '{}'", t.name, dependency));
}
const auto internal = std::ranges::find(updated.targets, dependency, &target::name);
if (internal != updated.targets.end()) {
if (!request.package.empty()) {
return fail("--package is for installed packages, not for targets of this project");
}
if (internal->name == t.name) {
return fail(std::format("'{}' cannot depend on itself", t.name));
}
if (internal->type != target_type::library) {
return fail(std::format("'{}' is an executable; only libraries can be linked", dependency));
}
if (reaches(updated, dependency, t.name)) {
return fail(std::format("'{}' already depends on '{}', so this would create a cycle", dependency,
t.name));
}
t.depends.push_back(dependency);
} else if (const auto separator = dependency.find("::"); separator != std::string::npos) {
const std::string package =
request.package.empty() ? dependency.substr(0, separator) : request.package;
if (!is_package_name(package) || separator == 0 || separator + 2 == dependency.size()) {
return fail(std::format("'{}' does not look like a CMake target such as fmt::fmt", dependency));
}
t.depends.push_back(dependency);
if (!std::ranges::contains(updated.packages, package)) {
updated.packages.push_back(package);
}
} else {
return fail(std::format("'{}' is not a target in {} (targets: {}); for an installed package, give "
"its CMake target, e.g. fmt::fmt",
dependency, manifest_file, target_list(updated)));
}
return finish(plan{}, p, updated);
}
} // namespace canon
+48 -12
View File
@@ -1,25 +1,61 @@
#pragma once
#include <canon/name.hpp>
#include <canon/plan.hpp>
#include <canon/project.hpp>
#include <expected>
#include <string>
#include <string_view>
#include <vector>
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);
// Every add plan records its change in .canon.toml and re-renders the managed
// block of CMakeLists.txt. Plan paths are relative to the project root. An
// empty `target` means the project's own target (the one named like the project).
// 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);
struct module_request {
std::string path{}; // relative to the target's source directory: "core", "details/utility"
std::string target{};
bool unit_test = true;
};
struct target_request {
project_kind kind{};
std::string name{};
bool with_test = true; // library: a unit test; executable: a test that runs it
};
struct test_request {
std::string name{};
std::string target{};
std::vector<std::string> args{};
std::string expect{}; // regex the output must match
};
struct dep_request {
std::string dependency{}; // a library target of this project, or an imported target like fmt::fmt
std::string target{};
std::string package{}; // find_package() name; defaults to the part before "::"
};
// <stem>/<path>.hpp and .cpp (and .test.cpp); the source joins the target.
// Directories in path become nested namespaces.
[[nodiscard]] std::expected<plan, std::string> plan_add_module(const project&, const module_request&);
// <stem>/<path>.test.cpp for a declared module without one. unit_test is ignored.
[[nodiscard]] std::expected<plan, std::string> plan_add_unit_test(const project&, const module_request&);
// Another library or executable with its own source directory, laid out like
// `canon new`. A new executable links the project's library, if it has one.
[[nodiscard]] std::expected<plan, std::string> plan_add_target(const project&, const target_request&);
// A functional test: tests/<name>/driver.cpp linked against a library, or a
// run of an executable. Both take args and expect.
[[nodiscard]] std::expected<plan, std::string> plan_add_test(const project&, const test_request&);
// Links a library of this project, or an imported target of an installed
// package (adding its find_package()). Refuses cycles and executables.
[[nodiscard]] std::expected<plan, std::string> plan_add_dep(const project&, const dep_request&);
} // namespace canon
+258 -60
View File
@@ -50,7 +50,7 @@ const update_file* updated(const plan& p, std::string_view path) {
return nullptr;
}
// The manifest the plan would write, read back.
// The manifest the plan would write, read back (which also validates it).
manifest planned_manifest(const plan& p) {
const update_file* file = updated(p, manifest_file);
CANON_CHECK(file != nullptr);
@@ -58,6 +58,9 @@ manifest planned_manifest(const plan& p) {
return {};
}
auto parsed = parse_manifest(file->content);
if (!parsed) {
std::println(stderr, " planned manifest does not parse: {}", parsed.error());
}
CANON_CHECK(parsed);
return parsed ? *parsed : manifest{};
}
@@ -68,6 +71,16 @@ std::string planned_cmakelists(const plan& p) {
return file != nullptr ? file->content : std::string{};
}
// The project as it would be after applying the plan.
project after(const project& before, const plan& p) {
return project{
.root = before.root,
.declared = planned_manifest(p),
.manifest_text = updated(p, manifest_file)->content,
.cmakelists_text = planned_cmakelists(p),
};
}
bool fails_with(const std::expected<plan, std::string>& result, std::string_view message) {
if (result) {
std::println(stderr, " planned successfully, expected: {}", message);
@@ -80,70 +93,64 @@ bool fails_with(const std::expected<plan, std::string>& result, std::string_view
return matches;
}
} // namespace
int main() {
const project lib = make_project(project_kind::library, "libhello");
// add module core
void test_add_module(const project& lib) {
{
const auto result = plan_add_module(lib, "core", true);
const auto result = plan_add_module(lib, {.path = "core"});
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)"));
if (!result) {
return;
}
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)->expected == lib.manifest_text);
CANON_CHECK(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.
// Nested paths become nested namespaces; unit_test = false skips the unit test.
{
const auto result = plan_add_module(lib, "details/utility", false);
const auto result = plan_add_module(lib, {.path = "details/utility", .unit_test = 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"));
CANON_CHECK(plan_add_module(lib, {.path = "string-utils"}).has_value());
CANON_CHECK(fails_with(plan_add_module(lib, {.path = "my-dir/x"}), "directory 'my-dir' must be a valid namespace"));
CANON_CHECK(fails_with(plan_add_module(lib, {.path = "new/x"}), "directory 'new' must be a valid namespace"));
CANON_CHECK(fails_with(plan_add_module(lib, {.path = "core.hpp"}), "without an extension, e.g. 'core'"));
CANON_CHECK(fails_with(plan_add_module(lib, {.path = "hello"}), "module 'hello/hello' already exists"));
CANON_CHECK(fails_with(plan_add_module(lib, {.path = "core", .target = "nope"}),
"no target 'nope' in .canon.toml (targets: libhello)"));
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"));
CANON_CHECK(fails_with(plan_add_module(lib, {.path = std::string(bad)}), "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);
const auto result = plan_add_module(custom, {.path = "core"});
CANON_CHECK(result);
if (result) {
const std::string cmake = planned_cmakelists(*result);
@@ -152,34 +159,225 @@ int main() {
}
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 >>>'"));
CANON_CHECK(fails_with(plan_add_module(custom, {.path = "core"}),
"CMakeLists.txt: no '# >>> canon:managed >>>'"));
}
}
void test_add_unit_test(const project& lib) {
const auto without_test = plan_add_module(lib, {.path = "core", .unit_test = false});
CANON_CHECK(without_test);
if (!without_test) {
return;
}
const project next = after(lib, *without_test);
const auto result = plan_add_unit_test(next, {.path = "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"));
}
// 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);
CANON_CHECK(fails_with(plan_add_unit_test(lib, {.path = "hello"}), "module 'hello/hello' already has a unit test"));
CANON_CHECK(fails_with(plan_add_unit_test(lib, {.path = "nope"}), "create it with 'canon add module nope'"));
}
const auto result = plan_add_unit_test(after, "core");
void test_add_target(const project& lib) {
// An executable in a library project gets its own directory and links the library.
const auto exe = plan_add_target(lib, {.kind = project_kind::executable, .name = "hello-cli"});
CANON_CHECK(exe);
if (!exe) {
return;
}
CANON_CHECK(created_files(*exe) == std::vector<std::string>({"hello-cli/hello-cli.cpp"}));
CANON_CHECK(created(*exe, "hello-cli/hello-cli.cpp").contains("int main("));
{
const manifest m = planned_manifest(*exe);
CANON_CHECK(m.targets.back() == target{.name = "hello-cli",
.type = target_type::executable,
.sources = {"hello-cli/hello-cli.cpp"},
.depends = {"libhello"}});
CANON_CHECK(m.tests.back() == test_entry{.name = "hello-cli", .kind = test_kind::run, .target = "hello-cli"});
const std::string cmake = planned_cmakelists(*exe);
CANON_CHECK(cmake.contains("add_executable(hello-cli\n hello-cli/hello-cli.cpp\n)\n"));
CANON_CHECK(cmake.contains("target_link_libraries(hello-cli PRIVATE libhello)\n"));
CANON_CHECK(cmake.contains("add_test(NAME hello-cli COMMAND hello-cli)\n"));
}
// Modules go into the requested target's directory and namespace.
const project with_exe = after(lib, *exe);
{
const auto result = plan_add_module(with_exe, {.path = "args", .target = "hello-cli"});
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(created(*result, "hello-cli/args.hpp").contains("namespace hello_cli {"));
CANON_CHECK(planned_manifest(*result).targets.back().sources ==
std::vector<std::string>({"hello-cli/hello-cli.cpp", "hello-cli/args.cpp"}));
// The unit test of an executable's module links what the executable links.
CANON_CHECK(planned_cmakelists(*result).contains(
" add_executable(hello-cli.args.test hello-cli/args.test.cpp hello-cli/args.cpp)\n"
" target_include_directories(hello-cli.args.test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})\n"
" target_link_libraries(hello-cli.args.test PRIVATE libhello)\n"));
}
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.
// A second library is laid out like `canon new lib`, without links.
{
const auto result = plan_add_target(with_exe, {.kind = project_kind::library, .name = "libextra"});
CANON_CHECK(result);
if (result) {
CANON_CHECK(created_files(*result) ==
std::vector<std::string>({"extra/extra.hpp", "extra/extra.cpp", "extra/extra.test.cpp"}));
CANON_CHECK(created(*result, "extra/extra.hpp").contains("namespace extra {"));
const manifest m = planned_manifest(*result);
CANON_CHECK(m.targets.back().depends.empty());
CANON_CHECK(m.modules.back() == source_module{.path = "extra/extra", .target = "libextra", .unit_test = true});
}
const auto untested =
plan_add_target(with_exe, {.kind = project_kind::library, .name = "libextra", .with_test = false});
CANON_CHECK(untested && created_files(*untested).size() == 2);
}
// An executable in an executable project links nothing; --no-test adds no test.
{
const project tool = make_project(project_kind::executable, "tool");
const auto result =
plan_add_target(tool, {.kind = project_kind::executable, .name = "helper", .with_test = false});
CANON_CHECK(result);
if (result) {
const manifest m = planned_manifest(*result);
CANON_CHECK(m.targets.back().depends.empty());
CANON_CHECK_EQ(m.tests.size(), 1u);
}
}
CANON_CHECK(fails_with(plan_add_target(lib, {.kind = project_kind::library, .name = "libhello"}),
"target 'libhello' already exists"));
CANON_CHECK(fails_with(plan_add_target(lib, {.kind = project_kind::executable, .name = "hello"}),
"would use the source directory 'hello/', which belongs to 'libhello'"));
CANON_CHECK(fails_with(plan_add_target(lib, {.kind = project_kind::executable, .name = "tests"}),
"'tests/', which holds functional tests"));
CANON_CHECK(fails_with(plan_add_target(lib, {.kind = project_kind::library, .name = "extra"}),
"must start with 'lib'"));
CANON_CHECK(fails_with(plan_add_target(lib, {.kind = project_kind::executable, .name = "basics"}),
"a test named 'basics' already exists"));
}
void test_add_test(const project& lib) {
{
const auto result = plan_add_test(lib, {.name = "edge-cases", .args = {"a b", ""}, .expect = "ok"});
CANON_CHECK(result);
if (result) {
CANON_CHECK(created_files(*result) == std::vector<std::string>({"tests/edge-cases/driver.cpp"}));
CANON_CHECK(created(*result, "tests/edge-cases/driver.cpp").contains("#include <hello/hello.hpp>\n"));
CANON_CHECK(planned_manifest(*result).tests.back() == test_entry{.name = "edge-cases",
.kind = test_kind::driver,
.path = "tests/edge-cases",
.target = "libhello",
.args = {"a b", ""},
.expect = "ok"});
const std::string cmake = planned_cmakelists(*result);
CANON_CHECK(cmake.contains(" add_executable(tests.edge-cases tests/edge-cases/driver.cpp)\n"));
CANON_CHECK(cmake.contains(" add_test(NAME edge-cases COMMAND tests.edge-cases \"a b\" \"\")\n"));
CANON_CHECK(cmake.contains(" set_tests_properties(edge-cases PROPERTIES PASS_REGULAR_EXPRESSION \"ok\")\n"));
}
}
// A library without its main module gets a driver without an include.
{
project bare = lib;
bare.declared.modules.clear();
const auto result = plan_add_test(bare, {.name = "smoke"});
CANON_CHECK(result && !created(*result, "tests/smoke/driver.cpp").contains("#include"));
}
// Executables are tested by running them.
{
const project exe = make_project(project_kind::executable, "hello");
const auto result = plan_add_module(exe, "greeting", true);
const auto result = plan_add_test(exe, {.name = "greets-paul", .args = {"Paul"}, .expect = "Hello, Paul!"});
CANON_CHECK(result);
if (result) {
CANON_CHECK(created_files(*result).empty());
CANON_CHECK(planned_cmakelists(*result).contains(" add_test(NAME greets-paul COMMAND hello \"Paul\")\n"));
}
}
CANON_CHECK(fails_with(plan_add_test(lib, {.name = "basics"}), "test 'basics' already exists"));
CANON_CHECK(fails_with(plan_add_test(lib, {.name = "Bad Name"}), "invalid test name 'Bad Name'"));
CANON_CHECK(fails_with(plan_add_test(lib, {.name = "x", .target = "nope"}), "no target 'nope'"));
}
void test_add_dep(const project& lib) {
// An installed package: the imported target is linked and its package found.
{
const auto result = plan_add_dep(lib, {.dependency = "Threads::Threads"});
CANON_CHECK(result);
if (result) {
CANON_CHECK(created_files(*result).empty());
const manifest m = planned_manifest(*result);
CANON_CHECK(m.packages == std::vector<std::string>({"Threads"}));
CANON_CHECK(m.targets.at(0).depends == std::vector<std::string>({"Threads::Threads"}));
const std::string cmake = planned_cmakelists(*result);
CANON_CHECK(cmake.contains("\n\nfind_package(Threads REQUIRED)\n\nadd_library(libhello\n"));
CANON_CHECK(cmake.contains("target_link_libraries(libhello PUBLIC Threads::Threads)\n"));
const project next = after(lib, *result);
CANON_CHECK(fails_with(plan_add_dep(next, {.dependency = "Threads::Threads"}),
"'libhello' already depends on 'Threads::Threads'"));
// A second target from the same package does not repeat find_package().
const auto more = plan_add_dep(next, {.dependency = "Threads::Other"});
CANON_CHECK(more && planned_manifest(*more).packages.size() == 1);
}
}
{
const auto result = plan_add_dep(lib, {.dependency = "Boost::filesystem", .package = "Boost"});
CANON_CHECK(result && planned_manifest(*result).packages == std::vector<std::string>({"Boost"}));
const auto renamed = plan_add_dep(lib, {.dependency = "unofficial::sqlite3::sqlite3", .package = "unofficial-sqlite3"});
CANON_CHECK(renamed && planned_manifest(*renamed).packages == std::vector<std::string>({"unofficial-sqlite3"}));
}
// Targets of the project: libraries only, no cycles.
const auto exe = plan_add_target(lib, {.kind = project_kind::executable, .name = "hello-cli"});
const auto extra = plan_add_target(after(lib, *exe), {.kind = project_kind::library, .name = "libextra"});
const project three = after(after(lib, *exe), *extra);
{
const auto result = plan_add_dep(three, {.dependency = "libextra"});
CANON_CHECK(result);
if (result) {
CANON_CHECK(planned_manifest(*result).targets.at(0).depends == std::vector<std::string>({"libextra"}));
const project linked = after(three, *result);
CANON_CHECK(fails_with(plan_add_dep(linked, {.dependency = "libhello", .target = "libextra"}),
"'libhello' already depends on 'libextra', so this would create a cycle"));
}
}
CANON_CHECK(fails_with(plan_add_dep(three, {.dependency = "hello-cli", .target = "libextra"}),
"'hello-cli' is an executable"));
CANON_CHECK(fails_with(plan_add_dep(three, {.dependency = "libhello"}), "cannot depend on itself"));
CANON_CHECK(fails_with(plan_add_dep(three, {.dependency = "libextra", .package = "x"}),
"--package is for installed packages"));
CANON_CHECK(fails_with(plan_add_dep(three, {.dependency = "fmt"}),
"'fmt' is not a target in .canon.toml (targets: libhello, hello-cli, libextra)"));
CANON_CHECK(fails_with(plan_add_dep(three, {.dependency = "::fmt"}), "does not look like a CMake target"));
CANON_CHECK(fails_with(plan_add_dep(three, {.dependency = "fmt::"}), "does not look like a CMake target"));
}
} // namespace
int main() {
const project lib = make_project(project_kind::library, "libhello");
test_add_module(lib);
test_add_unit_test(lib);
test_add_target(lib);
test_add_test(lib);
test_add_dep(lib);
// Executables compile a 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, {.path = "greeting"});
CANON_CHECK(result);
if (result) {
CANON_CHECK(planned_manifest(*result).targets.at(0).sources ==
+90 -36
View File
@@ -1,6 +1,7 @@
#include <canon/add.hpp>
#include <canon/cli.hpp>
#include <canon/details/overloaded.hpp>
#include <canon/doctor.hpp>
#include <canon/executor.hpp>
#include <canon/generate.hpp>
#include <canon/name.hpp>
@@ -12,6 +13,7 @@
#include <exception>
#include <expected>
#include <filesystem>
#include <format>
#include <print>
#include <string>
#include <string_view>
@@ -82,23 +84,23 @@ int run_new(const canon::new_command& cmd) {
return usage_error;
}
const fs::path& directory = cmd.options.directory;
std::error_code ignored;
if (!fs::is_directory(cmd.directory, ignored)) {
std::println(stderr, "canon: directory {} does not exist", cmd.directory.string());
if (!fs::is_directory(directory, ignored)) {
std::println(stderr, "canon: directory {} does not exist", directory.string());
return EXIT_FAILURE;
}
const fs::path destination = (cmd.directory / name->name).lexically_normal();
const fs::path destination = (directory / name->name).lexically_normal();
if (fs::exists(destination, ignored) &&
!(fs::is_directory(destination, ignored) && fs::is_empty(destination, ignored))) {
std::println(stderr, "canon: {} already exists and is not an empty directory",
destination.string());
std::println(stderr, "canon: {} already exists and is not an empty directory", destination.string());
return EXIT_FAILURE;
}
if (!apply_and_report(canon::generate_new_project(*name), cmd.directory, cmd.dry_run)) {
if (!apply_and_report(canon::generate_new_project(*name), directory, cmd.options.dry_run)) {
return EXIT_FAILURE;
}
if (cmd.dry_run) {
if (cmd.options.dry_run) {
return EXIT_SUCCESS;
}
const std::string_view kind = cmd.kind == canon::project_kind::library ? "library" : "executable";
@@ -110,8 +112,8 @@ int run_new(const canon::new_command& cmd) {
// 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);
int run_add(const canon::project_options& options, Planner make_plan, std::string_view done) {
const auto project = canon::load_project(options.directory);
if (!project) {
std::println(stderr, "canon: {}", project.error());
return EXIT_FAILURE;
@@ -123,27 +125,44 @@ int run_add(const fs::path& directory, bool dry_run, Planner make_plan, std::str
}
std::println("{} ({}):", project->declared.project.name, display_path(project->root));
if (!apply_and_report(*plan, project->root, dry_run)) {
if (!apply_and_report(*plan, project->root, options.dry_run)) {
return EXIT_FAILURE;
}
if (!dry_run) {
std::println("\n{} Rebuild with: cmake --build build", done);
if (!options.dry_run) {
std::println("\n{}", 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_doctor(const canon::doctor_command& cmd) {
const auto project = canon::load_project(cmd.directory);
if (!project) {
std::println(stderr, "canon: {}", project.error());
return EXIT_FAILURE;
}
const auto findings = canon::diagnose(*project, canon::list_project_files(project->root));
std::println("{} ({}):", project->declared.project.name, display_path(project->root));
if (findings.empty()) {
std::println(" No problems found.");
return EXIT_SUCCESS;
}
int errors = 0;
for (const canon::finding& f : findings) {
errors += f.level == canon::severity::error ? 1 : 0;
std::println(" {:<8} {}: {}", canon::to_string(f.level), f.path, f.message);
}
const auto warnings = static_cast<int>(findings.size()) - errors;
std::println("\n{} {}, {} {}.", errors, errors == 1 ? "error" : "errors", warnings,
warnings == 1 ? "warning" : "warnings");
return errors > 0 ? EXIT_FAILURE : EXIT_SUCCESS;
}
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));
constexpr std::string_view rebuild = "Rebuild with: cmake --build build";
std::string target_suffix(const std::string& target) {
return target.empty() ? "" : std::format(" to {}", target);
}
} // namespace
@@ -155,20 +174,55 @@ int main(int argc, char* argv[]) try {
std::println(stderr, "canon: {}\nRun 'canon --help' for usage.", command.error());
return usage_error;
}
return std::visit(canon::details::overloaded{
[](const canon::help_command&) {
std::print("{}", canon::usage());
return EXIT_SUCCESS;
},
[](const canon::version_command&) {
std::println("canon {}", CANON_VERSION);
return EXIT_SUCCESS;
},
[](const canon::new_command& cmd) { return run_new(cmd); },
[](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);
using namespace canon;
return std::visit(
details::overloaded{
[](const help_command&) {
std::print("{}", usage());
return EXIT_SUCCESS;
},
[](const version_command&) {
std::println("canon {}", CANON_VERSION);
return EXIT_SUCCESS;
},
[](const new_command& cmd) { return run_new(cmd); },
[](const add_module_command& cmd) {
return run_add(
cmd.options, [&](const project& p) { return plan_add_module(p, cmd.request); },
std::format("Added module {}{}. {}", cmd.request.path, target_suffix(cmd.request.target),
rebuild));
},
[](const add_unit_test_command& cmd) {
return run_add(
cmd.options, [&](const project& p) { return plan_add_unit_test(p, cmd.request); },
std::format("Added a unit test for module {}. {}", cmd.request.path, rebuild));
},
[](const add_target_command& cmd) {
const bool library = cmd.request.kind == project_kind::library;
return run_add(
cmd.options, [&](const project& p) { return plan_add_target(p, cmd.request); },
library ? std::format("Added library {}. Link it into another target with:\n"
" canon add dep {} --target <target>",
cmd.request.name, cmd.request.name)
: std::format("Added executable {}. {}", cmd.request.name, rebuild));
},
[](const add_test_command& cmd) {
return run_add(
cmd.options, [&](const project& p) { return plan_add_test(p, cmd.request); },
std::format("Added test {}. Build and run it with:\n"
" cmake --build build && ctest --test-dir build -R '^{}$'",
cmd.request.name, cmd.request.name));
},
[](const add_dep_command& cmd) {
return run_add(
cmd.options, [&](const project& p) { return plan_add_dep(p, cmd.request); },
std::format("Added dependency {}{}. {}", cmd.request.dependency,
target_suffix(cmd.request.target), rebuild));
},
[](const doctor_command& cmd) { return run_doctor(cmd); },
},
*command);
} catch (const std::exception& e) {
std::println(stderr, "canon: internal error: {}", e.what());
return EXIT_FAILURE;
+223 -77
View File
@@ -1,6 +1,9 @@
#include <canon/cli.hpp>
#include <algorithm>
#include <array>
#include <format>
#include <initializer_list>
#include <optional>
#include <vector>
@@ -8,71 +11,195 @@ namespace canon {
namespace {
using positionals = std::span<const std::string_view>;
constexpr auto value_options = std::to_array<std::string_view>({"--dir", "--target", "--arg", "--expect", "--package"});
struct options {
std::optional<std::string_view> directory;
std::optional<std::string_view> target;
std::optional<std::string_view> expect;
std::optional<std::string_view> package;
std::vector<std::string> args;
bool dry_run = false;
bool no_test = false;
std::vector<std::string_view> used; // every option given, e.g. "--dir"
};
std::unexpected<std::string> fail(std::string message) {
return std::unexpected(std::move(message));
}
std::filesystem::path directory_or_current(const options& opts) {
return opts.directory ? std::filesystem::path(*opts.directory) : std::filesystem::path(".");
std::expected<void, std::string> set_value(options& opts, std::string_view option, std::string_view value) {
if (value.empty() && option != "--arg") { // an empty program argument is legitimate
return fail(std::format("{} needs a value", option));
}
if (option == "--dir") {
opts.directory = value;
} else if (option == "--target") {
opts.target = value;
} else if (option == "--expect") {
opts.expect = value;
} else if (option == "--package") {
opts.package = value;
} else {
opts.args.emplace_back(value);
}
return {};
}
std::expected<command, std::string> parse_new(std::span<const std::string_view> positional,
const options& opts) {
std::expected<void, std::string> check_options(const options& opts, std::string_view command,
std::initializer_list<std::string_view> allowed) {
for (const std::string_view option : opts.used) {
if (!std::ranges::contains(allowed, option)) {
return fail(std::format("{} does not apply to 'canon {}'", option, command));
}
}
return {};
}
std::expected<void, std::string> check_count(positionals positional, std::size_t count,
std::string_view missing) {
if (positional.size() < count) {
return fail(std::string(missing));
}
if (positional.size() > count) {
return fail(std::format("unexpected argument '{}'", positional[count]));
}
return {};
}
std::expected<project_kind, std::string> parse_kind(std::string_view word) {
if (word == "lib") {
return project_kind::library;
}
if (word == "exe") {
return project_kind::executable;
}
return fail(std::format("unknown project kind '{}' (expected lib or exe)", word));
}
project_options project_options_of(const options& opts) {
return {
.directory = opts.directory ? std::filesystem::path(*opts.directory) : std::filesystem::path("."),
.dry_run = opts.dry_run,
};
}
std::string target_of(const options& opts) {
return std::string(opts.target.value_or(""));
}
std::expected<command, std::string> parse_new(positionals positional, const options& opts) {
if (positional.size() < 2) {
return fail("'new' needs a kind: lib or exe");
}
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") {
cmd.kind = project_kind::executable;
} else {
return fail(std::format("unknown project kind '{}' (expected lib or exe)", positional[1]));
const auto kind = parse_kind(positional[1]);
if (!kind) {
return std::unexpected(kind.error());
}
if (positional.size() < 3) {
return fail(std::format("'new {}' needs a project name", positional[1]));
if (auto ok = check_count(positional, 3, std::format("'new {}' needs a project name", positional[1])); !ok) {
return std::unexpected(ok.error());
}
if (positional.size() > 3) {
return fail(std::format("unexpected argument '{}'", positional[3]));
if (auto ok = check_options(opts, "new", {"--dir", "--dry-run"}); !ok) {
return std::unexpected(ok.error());
}
cmd.name = positional[2];
return cmd;
return new_command{.kind = *kind, .name = std::string(positional[2]), .options = project_options_of(opts)};
}
std::expected<command, std::string> parse_add(std::span<const std::string_view> positional,
const options& opts) {
std::expected<command, std::string> parse_add(positionals positional, const options& opts) {
if (positional.size() < 2) {
return fail("'add' needs what to add: module or unit-test");
return fail("'add' needs what to add: module, unit-test, target, test or dep");
}
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{
const std::string name = std::format("add {}", what);
const auto check = [&](std::size_t count, std::string_view missing,
std::initializer_list<std::string_view> allowed) -> std::expected<void, std::string> {
if (auto ok = check_count(positional, count, missing); !ok) {
return ok;
}
return check_options(opts, name, allowed);
};
if (what == "module" || what == "unit-test") {
const auto ok = what == "module"
? check(3, "'add module' needs a module path, e.g. 'core'",
{"--dir", "--dry-run", "--target", "--no-test"})
: check(3, "'add unit-test' needs a module path, e.g. 'core'",
{"--dir", "--dry-run", "--target"});
if (!ok) {
return std::unexpected(ok.error());
}
const module_request request{
.path = std::string(positional[2]),
.target = target_of(opts),
.unit_test = !opts.no_test,
.directory = directory_or_current(opts),
.dry_run = opts.dry_run,
};
if (what == "module") {
return add_module_command{.request = request, .options = project_options_of(opts)};
}
return add_unit_test_command{.request = request, .options = project_options_of(opts)};
}
if (what == "target") {
if (positional.size() < 3) {
return fail("'add target' needs a kind: lib or exe");
}
const auto kind = parse_kind(positional[2]);
if (!kind) {
return std::unexpected(kind.error());
}
if (auto ok = check(4, std::format("'add target {}' needs a target name", positional[2]),
{"--dir", "--dry-run", "--no-test"});
!ok) {
return std::unexpected(ok.error());
}
return add_target_command{
.request = {.kind = *kind, .name = std::string(positional[3]), .with_test = !opts.no_test},
.options = project_options_of(opts),
};
}
return add_unit_test_command{
.path = std::string(positional[2]),
.directory = directory_or_current(opts),
.dry_run = opts.dry_run,
};
if (what == "test") {
if (auto ok = check(3, "'add test' needs a test name",
{"--dir", "--dry-run", "--target", "--arg", "--expect"});
!ok) {
return std::unexpected(ok.error());
}
return add_test_command{
.request = {.name = std::string(positional[2]),
.target = target_of(opts),
.args = opts.args,
.expect = std::string(opts.expect.value_or(""))},
.options = project_options_of(opts),
};
}
if (what == "dep") {
if (auto ok = check(3, "'add dep' needs a dependency: a library of this project or a CMake target such as fmt::fmt",
{"--dir", "--dry-run", "--target", "--package"});
!ok) {
return std::unexpected(ok.error());
}
return add_dep_command{
.request = {.dependency = std::string(positional[2]),
.target = target_of(opts),
.package = std::string(opts.package.value_or(""))},
.options = project_options_of(opts),
};
}
return fail(std::format("cannot add '{}' (expected module, unit-test, target, test or dep)", what));
}
std::expected<command, std::string> parse_doctor(positionals positional, const options& opts) {
if (auto ok = check_count(positional, 1, ""); !ok) {
return std::unexpected(ok.error());
}
if (auto ok = check_options(opts, "doctor", {"--dir"}); !ok) {
return std::unexpected(ok.error());
}
return doctor_command{.directory = project_options_of(opts).directory};
}
} // namespace
@@ -89,46 +216,52 @@ std::expected<command, std::string> parse_command_line(std::span<const std::stri
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());
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 (arg == "--dry-run" || arg == "--no-test") {
(arg == "--dry-run" ? opts.dry_run : opts.no_test) = true;
opts.used.push_back(arg);
continue;
}
const std::string_view option = arg.substr(0, arg.find('='));
if (std::ranges::contains(value_options, option)) {
std::string_view value;
if (option.size() < arg.size()) {
value = arg.substr(option.size() + 1);
} else if (i + 1 < args.size()) {
value = args[++i];
} else {
return fail(std::format("{} needs a value", option));
}
if (auto ok = set_value(opts, option, value); !ok) {
return std::unexpected(ok.error());
}
opts.used.push_back(option);
continue;
}
if (arg.size() > 1 && arg.starts_with('-')) {
return fail(std::format("unknown option '{}'", arg));
}
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);
}
if (verb == "doctor") {
return parse_doctor(positional, opts);
}
return fail(std::format("unknown command '{}'", verb));
}
@@ -136,22 +269,35 @@ std::string_view usage() {
return R"(usage: canon <command> [options]
Create a project:
new lib <name> Library project (the name must start with 'lib')
new exe <name> Executable 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
Change the project you are in (run anywhere inside it):
add module <path> <stem>/<path>.hpp, .cpp and .test.cpp, e.g. 'core' or
'details/utility' (directories become namespaces)
add unit-test <path> The .test.cpp for a module created with --no-test
add target lib|exe <name> Another library or executable, in a source directory
of its own
add test <name> A functional test: tests/<name>/driver.cpp for a
library, a run of the program for an executable
add dep <dependency> Link a library of this project, or an installed
package's CMake target such as fmt::fmt
Check the project you are in:
doctor Compare the files with .canon.toml and P1204R0
Options:
--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
--dir <path> new: create the project inside <path>
others: look for the project from <path>
--dry-run Show what would change without writing anything
--target <name> The target to change (default: the project's own)
--no-test add module / add target: skip the test
--arg <value> add test: pass an argument (repeat for more)
--expect <regex> add test: the output must match
--package <name> add dep: the find_package() name, if it is not the
part before '::'
-h, --help Show this help
--version Show canon's version
Projects follow P1204R0 (Canonical Project Structure): https://wg21.link/p1204r0
)";
+36 -13
View File
@@ -1,5 +1,6 @@
#pragma once
#include <canon/add.hpp>
#include <canon/name.hpp>
#include <expected>
@@ -11,6 +12,11 @@
namespace canon {
struct project_options {
std::filesystem::path directory = "."; // new: where to create the project; add: where to look for it
bool dry_run = false;
};
struct help_command {};
struct version_command {};
@@ -18,28 +24,45 @@ struct version_command {};
struct new_command {
project_kind kind{};
std::string name{};
std::filesystem::path directory = "."; // where the project directory is created
bool dry_run = false;
project_options options{};
};
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;
module_request request{};
project_options options{};
};
struct add_unit_test_command {
std::string path{};
std::filesystem::path directory = ".";
bool dry_run = false;
module_request request{};
project_options options{};
};
using command =
std::variant<help_command, version_command, new_command, add_module_command, add_unit_test_command>;
struct add_target_command {
target_request request{};
project_options options{};
};
// Parses the arguments after the program name. Only syntax is checked here;
// names and module paths are validated by the generators.
struct add_test_command {
test_request request{};
project_options options{};
};
struct add_dep_command {
dep_request request{};
project_options options{};
};
struct doctor_command {
std::filesystem::path directory = ".";
};
using command = std::variant<help_command, version_command, new_command, add_module_command,
add_unit_test_command, add_target_command, add_test_command, add_dep_command,
doctor_command>;
// Parses the arguments after the program name. Syntax is checked here,
// including options a command does not take; names and paths are validated by
// the generators.
[[nodiscard]] std::expected<command, std::string>
parse_command_line(std::span<const std::string_view> args);
+85 -51
View File
@@ -3,6 +3,7 @@
#include <canon/details/testing.hpp>
#include <initializer_list>
#include <optional>
#include <vector>
using namespace canon;
@@ -19,7 +20,23 @@ bool is_help(const std::expected<command, std::string>& c) {
}
bool fails_with(const std::expected<command, std::string>& c, std::string_view message) {
return !c && c.error().contains(message);
if (c) {
std::println(stderr, " parsed successfully, expected: {}", message);
return false;
}
const bool matches = c.error().contains(message);
if (!matches) {
std::println(stderr, " error was: {}", c.error());
}
return matches;
}
// A copy of the parsed command if it has the expected type.
template <class Command>
std::optional<Command> as(const std::expected<command, std::string>& c) {
const Command* result = c ? std::get_if<Command>(&*c) : nullptr;
CANON_CHECK(result != nullptr);
return result != nullptr ? std::optional<Command>(*result) : std::nullopt;
}
} // namespace
@@ -30,63 +47,61 @@ int main() {
CANON_CHECK(is_help(parse({"new", "lib", "-h"})));
CANON_CHECK(parse({"--version"}) && std::holds_alternative<version_command>(*parse({"--version"})));
if (const auto c = parse({"new", "lib", "libhello"}); CANON_CHECK(c), c) {
const auto* n = std::get_if<new_command>(&*c);
CANON_CHECK(n != nullptr);
if (n != nullptr) {
CANON_CHECK(n->kind == project_kind::library);
CANON_CHECK_EQ(n->name, "libhello");
CANON_CHECK_EQ(n->directory.generic_string(), ".");
CANON_CHECK(!n->dry_run);
}
if (const auto n = as<new_command>(parse({"new", "lib", "libhello"}))) {
CANON_CHECK(n->kind == project_kind::library);
CANON_CHECK_EQ(n->name, "libhello");
CANON_CHECK_EQ(n->options.directory.generic_string(), ".");
CANON_CHECK(!n->options.dry_run);
}
if (const auto c = parse({"--dry-run", "new", "exe", "hello", "--dir", "some/where"}); CANON_CHECK(c), c) {
const auto& n = std::get<new_command>(*c);
CANON_CHECK(n.kind == project_kind::executable);
CANON_CHECK(n.dry_run);
CANON_CHECK_EQ(n.directory.generic_string(), "some/where");
if (const auto n = as<new_command>(parse({"--dry-run", "new", "exe", "hello", "--dir", "some/where"}))) {
CANON_CHECK(n->kind == project_kind::executable);
CANON_CHECK(n->options.dry_run);
CANON_CHECK_EQ(n->options.directory.generic_string(), "some/where");
}
if (const auto c = parse({"new", "exe", "--dir=out", "hello"}); CANON_CHECK(c), c) {
CANON_CHECK_EQ(std::get<new_command>(*c).directory.generic_string(), "out");
if (const auto n = as<new_command>(parse({"new", "exe", "--dir=out", "hello"}))) {
CANON_CHECK_EQ(n->options.directory.generic_string(), "out");
}
// Everything after "--" is positional.
if (const auto c = parse({"new", "exe", "--", "-weird"}); CANON_CHECK(c), c) {
CANON_CHECK_EQ(std::get<new_command>(*c).name, "-weird");
if (const auto n = as<new_command>(parse({"new", "exe", "--", "-weird"}))) {
CANON_CHECK_EQ(n->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 a = as<add_module_command>(parse({"add", "module", "details/utility"}))) {
CANON_CHECK_EQ(a->request.path, "details/utility");
CANON_CHECK_EQ(a->request.target, "");
CANON_CHECK(a->request.unit_test);
CANON_CHECK(!a->options.dry_run);
}
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 a = as<add_module_command>(
parse({"add", "module", "core", "--no-test", "--dry-run", "--dir", "proj", "--target=hello-cli"}))) {
CANON_CHECK(!a->request.unit_test);
CANON_CHECK_EQ(a->request.target, "hello-cli");
CANON_CHECK(a->options.dry_run);
CANON_CHECK_EQ(a->options.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");
if (const auto a = as<add_unit_test_command>(parse({"add", "unit-test", "core", "--target", "x"}))) {
CANON_CHECK_EQ(a->request.path, "core");
CANON_CHECK_EQ(a->request.target, "x");
}
if (const auto a = as<add_target_command>(parse({"add", "target", "exe", "hello-cli", "--no-test"}))) {
CANON_CHECK(a->request.kind == project_kind::executable);
CANON_CHECK_EQ(a->request.name, "hello-cli");
CANON_CHECK(!a->request.with_test);
}
if (const auto a = as<add_test_command>(
parse({"add", "test", "greets", "--arg", "Paul", "--arg=", "--arg", "--weird", "--expect", "Hello, .*"}))) {
CANON_CHECK_EQ(a->request.name, "greets");
CANON_CHECK(a->request.args == std::vector<std::string>({"Paul", "", "--weird"}));
CANON_CHECK_EQ(a->request.expect, "Hello, .*");
}
if (const auto a = as<add_dep_command>(parse({"add", "dep", "Boost::filesystem", "--package", "Boost", "--target", "t"}))) {
CANON_CHECK_EQ(a->request.dependency, "Boost::filesystem");
CANON_CHECK_EQ(a->request.package, "Boost");
CANON_CHECK_EQ(a->request.target, "t");
}
if (const auto d = as<doctor_command>(parse({"doctor", "--dir", "proj"}))) {
CANON_CHECK_EQ(d->directory.generic_string(), "proj");
}
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"));
@@ -94,8 +109,27 @@ int main() {
CANON_CHECK(fails_with(parse({"new", "lib"}), "needs a project name"));
CANON_CHECK(fails_with(parse({"new", "lib", "liba", "libb"}), "unexpected argument 'libb'"));
CANON_CHECK(fails_with(parse({"new", "lib", "liba", "--force"}), "unknown option '--force'"));
CANON_CHECK(fails_with(parse({"new", "lib", "liba", "--dir"}), "--dir needs a path"));
CANON_CHECK(fails_with(parse({"new", "lib", "liba", "--dir="}), "--dir needs a path"));
CANON_CHECK(fails_with(parse({"new", "lib", "liba", "--dir"}), "--dir needs a value"));
CANON_CHECK(fails_with(parse({"new", "lib", "liba", "--dir="}), "--dir needs a value"));
CANON_CHECK(fails_with(parse({"new", "lib", "liba", "--no-test"}), "--no-test does not apply to 'canon new'"));
CANON_CHECK(fails_with(parse({"new", "lib", "liba", "--target", "x"}), "--target does not apply to 'canon new'"));
CANON_CHECK(fails_with(parse({"add"}), "needs what to add"));
CANON_CHECK(fails_with(parse({"add", "package", "x"}), "cannot add 'package'"));
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 does not apply to 'canon add unit-test'"));
CANON_CHECK(fails_with(parse({"add", "target", "lib"}), "'add target lib' needs a target name"));
CANON_CHECK(fails_with(parse({"add", "target"}), "'add target' needs a kind"));
CANON_CHECK(fails_with(parse({"add", "target", "app", "x"}), "unknown project kind 'app'"));
CANON_CHECK(fails_with(parse({"add", "target", "exe", "x", "--target", "y"}), "--target does not apply to 'canon add target'"));
CANON_CHECK(fails_with(parse({"add", "test"}), "'add test' needs a test name"));
CANON_CHECK(fails_with(parse({"add", "test", "t", "--expect="}), "--expect needs a value"));
CANON_CHECK(fails_with(parse({"add", "test", "t", "--package", "p"}), "--package does not apply to 'canon add test'"));
CANON_CHECK(fails_with(parse({"add", "dep"}), "'add dep' needs a dependency"));
CANON_CHECK(fails_with(parse({"add", "dep", "x", "--arg", "a"}), "--arg does not apply to 'canon add dep'"));
CANON_CHECK(fails_with(parse({"doctor", "extra"}), "unexpected argument 'extra'"));
CANON_CHECK(fails_with(parse({"doctor", "--dry-run"}), "--dry-run does not apply to 'canon doctor'"));
return details::exit_status();
}
+17 -2
View File
@@ -102,6 +102,9 @@ void render_tests(std::string& out, const manifest& m) {
out += std::format(" add_executable({} {}.test.cpp {}.cpp)\n", exe, mod.path, mod.path);
out += std::format(" target_include_directories({} PRIVATE ${{CMAKE_CURRENT_SOURCE_DIR}})\n",
exe);
if (!owner.depends.empty()) {
out += std::format(" target_link_libraries({} PRIVATE {})\n", exe, join(owner.depends, " "));
}
}
out += std::format(" add_test(NAME {}.test COMMAND {})\n", mod.path, exe);
}
@@ -109,14 +112,20 @@ void render_tests(std::string& out, const manifest& m) {
for (const auto& t : m.tests) {
const target& subject = find_target(m, t.target, std::format("test '{}'", t.name));
std::string args;
for (const auto& arg : t.args) {
args += ' ';
args += cmake_quote(arg);
}
group();
if (t.kind == test_kind::driver) {
const std::string exe = dotted(t.path);
out += std::format(" add_executable({} {}/driver.cpp)\n", exe, t.path);
out += std::format(" target_link_libraries({} PRIVATE {})\n", exe, subject.name);
out += std::format(" add_test(NAME {} COMMAND {})\n", t.name, exe);
out += std::format(" add_test(NAME {} COMMAND {}{})\n", t.name, exe, args);
} else {
out += std::format(" add_test(NAME {} COMMAND {})\n", t.name, subject.name);
out += std::format(" add_test(NAME {} COMMAND {}{})\n", t.name, subject.name, args);
}
if (!t.expect.empty()) {
out += std::format(" set_tests_properties({} PROPERTIES PASS_REGULAR_EXPRESSION {})\n",
@@ -132,6 +141,12 @@ std::string render_managed_block(const manifest& m) {
std::string out;
out += managed_begin;
out += "\n# Rendered from .canon.toml; canon rewrites everything between these markers.\n";
if (!m.packages.empty()) {
out += '\n';
for (const auto& package : m.packages) {
out += std::format("find_package({} REQUIRED)\n", package);
}
}
for (const auto& t : m.targets) {
render_target(out, m, t);
}
+23
View File
@@ -86,6 +86,29 @@ int main() {
CANON_CHECK(threw);
}
// Packages, dependencies and test arguments.
{
manifest m = library();
m.packages = {"Threads", "fmt"};
m.targets[0].depends = {"Threads::Threads"};
m.targets.push_back({.name = "cli",
.type = target_type::executable,
.sources = {"cli/cli.cpp", "cli/args.cpp"},
.depends = {"libhello", "fmt::fmt"}});
m.modules.push_back({.path = "cli/args", .target = "cli", .unit_test = true});
m.tests.push_back({.name = "cli-runs", .kind = test_kind::run, .target = "cli", .args = {"a b", "$HOME;x"}});
const std::string block = render_managed_block(m);
CANON_CHECK(block.contains("markers.\n\nfind_package(Threads REQUIRED)\nfind_package(fmt REQUIRED)\n\nadd_library("));
CANON_CHECK(block.contains("target_link_libraries(libhello PUBLIC Threads::Threads)\n"));
CANON_CHECK(block.contains("target_link_libraries(cli PRIVATE libhello fmt::fmt)\n"));
CANON_CHECK(block.contains(" add_executable(cli.args.test cli/args.test.cpp cli/args.cpp)\n"
" target_include_directories(cli.args.test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})\n"
" target_link_libraries(cli.args.test PRIVATE libhello fmt::fmt)\n"));
CANON_CHECK(block.contains(R"( add_test(NAME cli-runs COMMAND cli "a b" "\$HOME\;x"))"));
CANON_CHECK(!render_managed_block(library()).contains("find_package"));
}
// Replacing the managed block keeps everything around it and is idempotent.
{
const std::string original = render_cmakelists(library());
+1 -1
View File
@@ -63,6 +63,6 @@ private:
} // namespace canon::details
#define CANON_CHECK(expr) ::canon::details::check(static_cast<bool>(expr), #expr)
#define CANON_CHECK(...) ::canon::details::check(static_cast<bool>(__VA_ARGS__), #__VA_ARGS__)
#define CANON_CHECK_EQ(actual, expected) \
::canon::details::check_equal((actual), (expected), #actual " == " #expected)
+193
View File
@@ -0,0 +1,193 @@
#include <canon/doctor.hpp>
#include <canon/cmake.hpp>
#include <algorithm>
#include <array>
#include <format>
#include <set>
namespace canon {
namespace fs = std::filesystem;
namespace {
constexpr auto other_header_extensions = std::to_array<std::string_view>({".h", ".hh", ".hxx", ".h++", ".H"});
constexpr auto other_source_extensions = std::to_array<std::string_view>({".cc", ".cxx", ".c++", ".C"});
using path_set = std::set<std::string, std::less<>>;
class diagnosis {
public:
void error(std::string path, std::string message) {
findings_.push_back({severity::error, std::move(path), std::move(message)});
}
void warning(std::string path, std::string message) {
findings_.push_back({severity::warning, std::move(path), std::move(message)});
}
std::vector<finding> take() {
std::ranges::stable_sort(findings_, [](const finding& a, const finding& b) {
return std::tie(a.level, a.path) < std::tie(b.level, b.path);
});
return std::move(findings_);
}
private:
std::vector<finding> findings_;
};
// "canon add unit-test core" or "... --target hello-cli" for another target's module.
std::string add_unit_test_hint(const manifest& m, const source_module& mod) {
std::string relative = mod.path.substr(mod.path.find('/') + 1);
if (mod.target == m.project.name) {
return std::format("canon add unit-test {}", relative);
}
return std::format("canon add unit-test {} --target {}", relative, mod.target);
}
void check_managed_block(diagnosis& d, const project& p) {
const auto rendered = replace_managed_block(p.cmakelists_text, p.declared);
if (!rendered) {
d.error("CMakeLists.txt", rendered.error());
} else if (*rendered != p.cmakelists_text) {
d.warning("CMakeLists.txt", "the managed block differs from .canon.toml (edited by hand?); the next "
"'canon add' will replace it");
}
}
// Files .canon.toml declares that are not there.
void check_declared_files(diagnosis& d, const manifest& m, const path_set& present) {
for (const target& t : m.targets) {
for (const std::string& source : t.sources) {
if (!present.contains(source)) {
d.error(source, std::format("source of {} is missing", t.name));
}
}
}
for (const source_module& mod : m.modules) {
const std::string header = mod.path + ".hpp";
const std::string unit_test = mod.path + ".test.cpp";
if (!present.contains(header)) {
d.error(header, std::format("header of module {} is missing", mod.path));
}
if (mod.unit_test && !present.contains(unit_test)) {
d.error(unit_test, "unit test is declared in .canon.toml but missing");
}
if (!mod.unit_test && present.contains(unit_test)) {
d.warning(unit_test, std::format("not built: the module is declared without a unit test ({})",
add_unit_test_hint(m, mod)));
}
}
for (const test_entry& t : m.tests) {
if (t.kind == test_kind::driver && !present.contains(t.path + "/driver.cpp")) {
d.error(t.path + "/driver.cpp", std::format("driver of test {} is missing", t.name));
}
}
}
// Files that are there but .canon.toml does not know about, or that break the layout.
void check_present_files(diagnosis& d, const manifest& m, const std::vector<std::string>& files) {
path_set sources;
for (const target& t : m.targets) {
sources.insert(t.sources.begin(), t.sources.end());
}
path_set module_paths;
for (const source_module& mod : m.modules) {
module_paths.insert(mod.path);
}
path_set driver_dirs;
for (const test_entry& t : m.tests) {
if (t.kind == test_kind::driver) {
driver_dirs.insert(t.path);
}
}
std::vector<std::string> source_dirs;
for (const target& t : m.targets) {
if (const auto names = target_names(t)) {
source_dirs.push_back(names->stem + "/");
}
}
path_set reported_split_dirs;
for (const std::string& file : files) {
if (file.starts_with("include/") || file.starts_with("src/")) {
const std::string dir = file.substr(0, file.find('/') + 1);
if (reported_split_dirs.insert(dir).second) {
d.warning(dir, "P1204R0 keeps headers and sources together in the source directory "
"instead of splitting them into include/ and src/");
}
continue;
}
if (file.starts_with("tests/")) {
constexpr std::string_view driver = "/driver.cpp";
if (file.ends_with(driver) &&
!driver_dirs.contains(std::string_view(file).substr(0, file.size() - driver.size()))) {
d.warning(file, "functional test is not in .canon.toml, so it is not built");
}
continue;
}
const bool in_source_dir = std::ranges::any_of(
source_dirs, [&](const std::string& dir) { return file.starts_with(dir); });
if (!in_source_dir) {
continue;
}
const std::string extension = fs::path(file).extension().string();
if (file.ends_with(".test.cpp")) {
constexpr std::string_view suffix = ".test.cpp";
if (!module_paths.contains(std::string_view(file).substr(0, file.size() - suffix.size()))) {
d.warning(file, "unit test of a module that is not in .canon.toml, so it is not built");
}
} else if (extension == ".cpp") {
if (!sources.contains(file)) {
d.warning(file, "not a source of any target, so it is not built");
}
} else if (std::ranges::contains(other_header_extensions, extension)) {
d.warning(file, "headers use .hpp in canon's P1204R0 layout");
} else if (std::ranges::contains(other_source_extensions, extension)) {
d.warning(file, "sources use .cpp in canon's P1204R0 layout; this file is not built");
}
}
}
} // namespace
std::string_view to_string(severity level) {
return level == severity::error ? "error" : "warning";
}
std::vector<std::string> list_project_files(const fs::path& root) {
std::vector<std::string> files;
std::error_code error;
fs::recursive_directory_iterator it(root, fs::directory_options::skip_permission_denied, error);
for (; !error && it != fs::recursive_directory_iterator(); it.increment(error)) {
const std::string name = it->path().filename().string();
std::error_code ignored;
if (it->is_directory(ignored)) {
const bool hidden = name.starts_with('.');
const bool build_output =
it.depth() == 0 && (name.starts_with("build") || name.starts_with("cmake-build-"));
if (hidden || build_output) {
it.disable_recursion_pending();
}
} else if (it->is_regular_file(ignored)) {
files.push_back(it->path().lexically_relative(root).generic_string());
}
}
std::ranges::sort(files);
return files;
}
std::vector<finding> diagnose(const project& p, const std::vector<std::string>& files) {
const path_set present(files.begin(), files.end());
diagnosis d;
check_managed_block(d, p);
check_declared_files(d, p.declared, present);
check_present_files(d, p.declared, files);
return d.take();
}
} // namespace canon
+36
View File
@@ -0,0 +1,36 @@
#pragma once
#include <canon/project.hpp>
#include <filesystem>
#include <string>
#include <string_view>
#include <vector>
namespace canon {
enum class severity {
error, // the build is broken or does not match .canon.toml
warning, // something is not built, or departs from the P1204R0 layout
};
[[nodiscard]] std::string_view to_string(severity);
struct finding {
severity level;
std::string path; // relative to the project root
std::string message;
bool operator==(const finding&) const = default;
};
// Every regular file in the project, relative to root with '/' separators and
// sorted. Hidden directories (.git, .idea, ...) and top-level build output
// (build*, cmake-build-*) are skipped.
[[nodiscard]] std::vector<std::string> list_project_files(const std::filesystem::path& root);
// Compares the project with its manifest and the P1204R0 layout, given the
// files it contains. Errors come first, then warnings, each sorted by path.
[[nodiscard]] std::vector<finding> diagnose(const project&, const std::vector<std::string>& files);
} // namespace canon
+151
View File
@@ -0,0 +1,151 @@
#include <canon/doctor.hpp>
#include <canon/cmake.hpp>
#include <canon/details/testing.hpp>
#include <canon/executor.hpp>
#include <canon/generate.hpp>
#include <algorithm>
#include <filesystem>
#include <fstream>
#include <string>
#include <vector>
using namespace canon;
namespace fs = std::filesystem;
namespace {
project make_project() {
const manifest m = new_project_manifest(*parse_project_name(project_kind::library, "libhello"));
return project{
.root = "/unused",
.declared = m,
.manifest_text = to_toml(m),
.cmakelists_text = render_cmakelists(m),
};
}
const std::vector<std::string> fresh_files = {
".canon.toml", ".gitignore", "CMakeLists.txt", "README.md",
"hello/hello.cpp", "hello/hello.hpp", "hello/hello.test.cpp", "tests/basics/driver.cpp",
};
std::vector<std::string> with(std::vector<std::string> files, std::initializer_list<std::string> extra) {
files.insert(files.end(), extra);
return files;
}
std::vector<std::string> without(std::vector<std::string> files, const std::string& gone) {
std::erase(files, gone);
return files;
}
bool reports(const std::vector<finding>& findings, severity level, std::string_view path, std::string_view message) {
const bool found = std::ranges::any_of(findings, [&](const finding& f) {
return f.level == level && f.path == path && f.message.contains(message);
});
if (!found) {
std::println(stderr, " missing {} {}: {}", to_string(level), path, message);
for (const finding& f : findings) {
std::println(stderr, " got {} {}: {}", to_string(f.level), f.path, f.message);
}
}
return found;
}
} // namespace
int main() {
const project p = make_project();
CANON_CHECK(diagnose(p, fresh_files).empty());
// Declared but missing.
{
const auto findings = diagnose(p, without(without(without(fresh_files, "hello/hello.cpp"), "hello/hello.hpp"),
"tests/basics/driver.cpp"));
CANON_CHECK_EQ(findings.size(), 3u);
CANON_CHECK(reports(findings, severity::error, "hello/hello.cpp", "source of libhello is missing"));
CANON_CHECK(reports(findings, severity::error, "hello/hello.hpp", "header of module hello/hello is missing"));
CANON_CHECK(reports(findings, severity::error, "tests/basics/driver.cpp", "driver of test basics is missing"));
}
CANON_CHECK(reports(diagnose(p, without(fresh_files, "hello/hello.test.cpp")), severity::error,
"hello/hello.test.cpp", "declared in .canon.toml but missing"));
// Present but unknown to .canon.toml.
{
const auto findings = diagnose(p, with(fresh_files, {"hello/extra.cpp", "hello/orphan.test.cpp",
"tests/manual/driver.cpp", "hello/old.h",
"hello/impl.cc", "hello/notes.txt", "docs/x.cpp",
"include/hello/hello.hpp", "include/hello/x.hpp",
"src/hello.cpp"}));
CANON_CHECK(reports(findings, severity::warning, "hello/extra.cpp", "not a source of any target"));
CANON_CHECK(reports(findings, severity::warning, "hello/orphan.test.cpp", "module that is not in .canon.toml"));
CANON_CHECK(reports(findings, severity::warning, "tests/manual/driver.cpp", "functional test is not in .canon.toml"));
CANON_CHECK(reports(findings, severity::warning, "hello/old.h", "headers use .hpp"));
CANON_CHECK(reports(findings, severity::warning, "hello/impl.cc", "sources use .cpp"));
CANON_CHECK(reports(findings, severity::warning, "include/", "instead of splitting them into include/ and src/"));
CANON_CHECK(reports(findings, severity::warning, "src/", "include/ and src/"));
CANON_CHECK_EQ(findings.size(), 7u); // notes.txt and docs/ are none of canon's business
}
// A unit test on disk for a module declared without one.
{
project untested = p;
untested.declared.modules.at(0).unit_test = false;
untested.cmakelists_text = render_cmakelists(untested.declared);
const auto findings = diagnose(untested, fresh_files);
CANON_CHECK_EQ(findings.size(), 1u);
CANON_CHECK(reports(findings, severity::warning, "hello/hello.test.cpp", "(canon add unit-test hello)"));
untested.declared.modules.at(0).target = "other";
CANON_CHECK(reports(diagnose(untested, fresh_files), severity::warning, "hello/hello.test.cpp",
"(canon add unit-test hello --target other)"));
}
// The managed block.
{
project edited = p;
edited.cmakelists_text.replace(edited.cmakelists_text.find("hello/hello.cpp"), 15, "hello/other.cpp");
const auto findings = diagnose(edited, fresh_files);
CANON_CHECK_EQ(findings.size(), 1u);
CANON_CHECK(reports(findings, severity::warning, "CMakeLists.txt", "managed block differs from .canon.toml"));
edited.cmakelists_text = "project(x)\n";
CANON_CHECK(reports(diagnose(edited, fresh_files), severity::error, "CMakeLists.txt", "no '# >>> canon:managed >>>'"));
}
// Errors come before warnings, each sorted by path.
{
const auto findings = diagnose(p, with(without(fresh_files, "hello/hello.hpp"), {"hello/a.cpp", "hello/b.h"}));
CANON_CHECK_EQ(findings.size(), 3u);
if (findings.size() == 3) {
CANON_CHECK(findings[0].level == severity::error);
CANON_CHECK_EQ(findings[1].path, "hello/a.cpp");
CANON_CHECK_EQ(findings[2].path, "hello/b.h");
}
}
// Listing a real project skips build output and hidden directories.
{
const details::temp_directory tmp;
CANON_CHECK(apply(generate_new_project(*parse_project_name(project_kind::library, "libhello")), tmp.path()));
const fs::path root = tmp.path() / "libhello";
for (const char* dir : {"build", "build-release", "cmake-build-debug", ".git", ".idea", "hello/build"}) {
fs::create_directories(root / dir);
std::ofstream(root / dir / "junk.cpp") << "\n";
}
const auto files = list_project_files(root);
CANON_CHECK(files == with(fresh_files, {"hello/build/junk.cpp"}) ||
files == [&] {
auto sorted = with(fresh_files, {"hello/build/junk.cpp"});
std::ranges::sort(sorted);
return sorted;
}());
CANON_CHECK(std::ranges::is_sorted(files));
}
return details::exit_status();
}
+8 -91
View File
@@ -2,93 +2,10 @@
#include <canon/cmake.hpp>
#include <canon/template.hpp>
#include <string_view>
#include <canon/templates.hpp>
namespace canon {
namespace {
constexpr std::string_view gitignore = "/build/\n";
constexpr std::string_view readme = R"md(# {{name}}
Created by canon. The layout follows [P1204R0](https://wg21.link/p1204r0)
(Canonical Project Structure).
## Build and test
```bash
cmake -S . -B build
cmake --build build
ctest --test-dir build --output-on-failure
```
)md";
constexpr std::string_view library_header = R"cpp(#pragma once
#include <string>
#include <string_view>
namespace {{ident}} {
// Returns a greeting for name, e.g. "Hello, World!".
[[nodiscard]] std::string greeting(std::string_view name);
} // namespace {{ident}}
)cpp";
constexpr std::string_view library_source = R"cpp(#include <{{stem}}/{{stem}}.hpp>
#include <format>
namespace {{ident}} {
std::string greeting(std::string_view name) {
return std::format("Hello, {}!", name);
}
} // namespace {{ident}}
)cpp";
constexpr std::string_view library_unit_test = R"cpp(#include <{{stem}}/{{stem}}.hpp>
#include <cstdio>
#include <cstdlib>
#include <print>
int main() {
const auto actual = {{ident}}::greeting("World");
if (actual != "Hello, World!") {
std::println(stderr, "greeting(\"World\") returned \"{}\"", actual);
return EXIT_FAILURE;
}
}
)cpp";
constexpr std::string_view library_driver = R"cpp(// Functional test: uses {{name}} only through its public headers, as a consumer would.
#include <{{stem}}/{{stem}}.hpp>
#include <cstdlib>
#include <print>
int main() {
const auto greeting = {{ident}}::greeting("{{name}}");
std::println("{}", greeting);
return greeting.starts_with("Hello") ? EXIT_SUCCESS : EXIT_FAILURE;
}
)cpp";
constexpr std::string_view executable_source = R"cpp(#include <print>
int main(int argc, char* argv[]) {
std::println("Hello, {}!", argc > 1 ? argv[1] : "World");
}
)cpp";
} // namespace
manifest new_project_manifest(const project_name& n) {
manifest m{.project = n};
const std::string main_path = n.stem + "/" + n.stem;
@@ -130,17 +47,17 @@ plan generate_new_project(const project_name& n) {
plan p;
p.add_file(root / manifest_file, to_toml(m));
p.add_file(root / ".gitignore", std::string(gitignore));
p.add_file(root / ".gitignore", std::string(templates::gitignore));
p.add_file(root / "CMakeLists.txt", render_cmakelists(m));
p.add_file(root / "README.md", render(readme, vars));
p.add_file(root / "README.md", render(templates::readme, vars));
if (n.kind == project_kind::library) {
p.add_file(sources / (n.stem + ".hpp"), render(library_header, vars));
p.add_file(sources / (n.stem + ".cpp"), render(library_source, vars));
p.add_file(sources / (n.stem + ".test.cpp"), render(library_unit_test, vars));
p.add_file(root / "tests" / "basics" / "driver.cpp", render(library_driver, vars));
p.add_file(sources / (n.stem + ".hpp"), render(templates::library_header, vars));
p.add_file(sources / (n.stem + ".cpp"), render(templates::library_source, vars));
p.add_file(sources / (n.stem + ".test.cpp"), render(templates::library_unit_test, vars));
p.add_file(root / "tests" / "basics" / "driver.cpp", render(templates::library_driver, vars));
} else {
p.add_file(sources / (n.stem + ".cpp"), render(executable_source, vars));
p.add_file(sources / (n.stem + ".cpp"), render(templates::executable_source, vars));
}
return p;
}
+62
View File
@@ -136,6 +136,12 @@ void check_unique(const std::vector<T>& items, Key T::*key, const std::vector<in
} // namespace
std::expected<project_name, std::string> target_names(const target& t) {
return parse_project_name(t.type == target_type::library ? project_kind::library
: project_kind::executable,
t.name);
}
std::string_view to_string(target_type type) {
return type == target_type::library ? "library" : "executable";
}
@@ -163,6 +169,9 @@ std::string to_toml(const manifest& m) {
line("backend", toml_quote(m.backend));
line("cmake-minimum", toml_quote(m.cmake_minimum));
line("cxx-standard", std::to_string(m.cxx_standard));
if (!m.packages.empty()) {
line("packages", toml_array(m.packages));
}
for (const auto& t : m.targets) {
out += "\n[[targets]]\n";
@@ -189,6 +198,9 @@ std::string to_toml(const manifest& m) {
line("path", toml_quote(t.path));
}
line("target", toml_quote(t.target));
if (!t.args.empty()) {
line("args", toml_array(t.args));
}
if (!t.expect.empty()) {
line("expect", toml_quote(t.expect));
}
@@ -259,6 +271,12 @@ std::expected<manifest, std::string> parse_manifest(std::string_view text) {
}
m.cxx_standard = static_cast<int>(*standard);
}
if (auto packages = r.optional<std::vector<std::string>>("packages")) {
if (std::ranges::contains(*packages, std::string{})) {
throw manifest_error{r.line_of("packages"), "'packages' cannot contain an empty name"};
}
m.packages = std::move(*packages);
}
r.finish();
} else if (table.array_element && table.name == "targets") {
target t{
@@ -287,6 +305,7 @@ std::expected<manifest, std::string> parse_manifest(std::string_view text) {
.kind = read_test_kind(r),
.path = r.optional<std::string>("path").value_or(""),
.target = r.required<std::string>("target"),
.args = r.optional<std::vector<std::string>>("args").value_or(std::vector<std::string>{}),
.expect = r.optional<std::string>("expect").value_or(""),
};
r.finish();
@@ -309,6 +328,49 @@ std::expected<manifest, std::string> parse_manifest(std::string_view text) {
check_unique(m.modules, &source_module::path, module_lines, "module");
check_unique(m.tests, &test_entry::name, test_lines, "test");
std::vector<std::string> stems;
for (std::size_t i = 0; i < m.targets.size(); ++i) {
const target& t = m.targets[i];
const int line = target_lines[i];
const auto names = target_names(t);
if (!names) {
throw manifest_error{line, names.error()};
}
if (names->stem == "tests") {
throw manifest_error{line, std::format("target '{}' cannot use the source directory "
"'tests/', which holds functional tests",
t.name)};
}
if (const auto clash = std::ranges::find(stems, names->stem); clash != stems.end()) {
throw manifest_error{
line, std::format("targets '{}' and '{}' would share the source directory '{}/'",
m.targets[static_cast<std::size_t>(clash - stems.begin())].name,
t.name, names->stem)};
}
stems.push_back(names->stem);
for (const std::string& dependency : t.depends) {
if (dependency.contains("::")) {
continue; // an imported target from an installed package
}
const auto found = std::ranges::find(m.targets, dependency, &target::name);
if (found == m.targets.end()) {
throw manifest_error{line, std::format("target '{}' depends on '{}', which is neither a "
"target here nor a CMake target like fmt::fmt",
t.name, dependency)};
}
if (found->name == t.name) {
throw manifest_error{line, std::format("target '{}' depends on itself", t.name)};
}
if (found->type != target_type::library) {
throw manifest_error{line, std::format("target '{}' depends on executable '{}'; only "
"libraries can be linked",
t.name, dependency)};
}
}
}
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,
+10 -3
View File
@@ -25,11 +25,15 @@ struct target {
std::string name{};
target_type type{};
std::vector<std::string> sources{};
std::vector<std::string> depends{};
std::vector<std::string> depends{}; // libraries of this project, or imported targets like fmt::fmt
bool operator==(const target&) const = default;
};
// The names P1204R0 derives from a target's name, as for a project of the same
// kind: target libfoo uses foo/ and namespace foo, target foo-cli uses foo-cli/.
[[nodiscard]] std::expected<project_name, std::string> target_names(const target&);
// A related file set: <path>.hpp, <path>.cpp and optionally <path>.test.cpp.
struct source_module {
std::string path{}; // hello/hello
@@ -44,6 +48,7 @@ struct test_entry {
test_kind kind{};
std::string path{}; // driver: directory holding driver.cpp
std::string target{}; // driver: library to link; run: executable to run
std::vector<std::string> args{}; // command-line arguments for the test program
std::string expect{}; // regex the output must match; empty checks the exit status only
bool operator==(const test_entry&) const = default;
@@ -57,6 +62,7 @@ struct manifest {
std::string backend = "cmake";
std::string cmake_minimum = "3.20";
int cxx_standard = 23;
std::vector<std::string> packages{}; // find_package() names, in order
std::vector<target> targets{};
std::vector<source_module> modules{};
std::vector<test_entry> tests{};
@@ -67,8 +73,9 @@ struct manifest {
// 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: ...".
// Reads .canon.toml. Unknown tables or keys, wrong value types, invalid names,
// targets sharing a source directory 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
+31 -4
View File
@@ -68,7 +68,13 @@ int main() {
.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.targets.back().depends.push_back("Threads::Threads");
m.packages = {"Threads", "unofficial-sqlite3"};
m.tests.push_back({.name = "run",
.kind = test_kind::run,
.target = "tool",
.args = {"--name", "a b", ""},
.expect = "Hi \"there\""});
m.cxx_standard = 26;
const auto parsed = parse_manifest(to_toml(m));
CANON_CHECK(parsed);
@@ -112,9 +118,9 @@ int main() {
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 + "[[targets]]\nname = \"liba\"\ntype = \"library\"\n"
"[[targets]]\nname = \"liba\"\ntype = \"library\"\n",
"line 7: target 'liba' 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",
@@ -123,5 +129,26 @@ int main() {
"test 'b' has kind \"driver\" but no 'path'"));
CANON_CHECK(fails_with("[project]\nname = \"libhello\n", "line 2: unterminated string"));
// Targets: valid names, separate source directories, linkable dependencies.
const std::string lib_target = "[[targets]]\nname = \"libhello\"\ntype = \"library\"\n";
CANON_CHECK(fails_with(valid_header + "[[targets]]\nname = \"hello\"\ntype = \"library\"\n",
"line 4: library name 'hello' must start with 'lib'"));
CANON_CHECK(fails_with(valid_header + "[[targets]]\nname = \"libtool\"\ntype = \"executable\"\n",
"line 4: executable name 'libtool' must not start with 'lib'"));
CANON_CHECK(fails_with(valid_header + lib_target + "[[targets]]\nname = \"hello\"\ntype = \"executable\"\n",
"line 7: targets 'libhello' and 'hello' would share the source directory 'hello/'"));
CANON_CHECK(fails_with(valid_header + "[[targets]]\nname = \"tests\"\ntype = \"executable\"\n",
"cannot use the source directory 'tests/'"));
CANON_CHECK(fails_with(valid_header + lib_target + "depends = [\"libnope\"]\n",
"line 4: target 'libhello' depends on 'libnope', which is neither"));
CANON_CHECK(fails_with(valid_header + lib_target + "depends = [\"libhello\"]\n", "depends on itself"));
CANON_CHECK(fails_with(valid_header + lib_target + "depends = [\"tool\"]\n"
"[[targets]]\nname = \"tool\"\ntype = \"executable\"\n",
"depends on executable 'tool'"));
CANON_CHECK(parse_manifest(valid_header + lib_target + "depends = [\"fmt::fmt\"]\n").has_value());
CANON_CHECK(fails_with(valid_header + "[build]\npackages = [\"\"]\n", "'packages' cannot contain an empty name"));
CANON_CHECK(fails_with(valid_header + lib_target + "[[tests]]\nname = \"t\"\nkind = \"run\"\ntarget = \"libhello\"\nargs = \"x\"\n",
"'args' in [[tests]] must be an array of strings"));
return details::exit_status();
}
+132
View File
@@ -0,0 +1,132 @@
#pragma once
#include <string_view>
// File templates for the generators, rendered with canon::render. Placeholders:
// {{name}} project or target name libhello
// {{stem}} source directory hello
// {{ident}} top-level namespace hello
// {{include}} header to include hello/details/utility.hpp
// {{namespace}} a module's namespace hello::details
// {{target}} target under test libhello
namespace canon::templates {
inline constexpr std::string_view gitignore = "/build/\n";
inline constexpr std::string_view readme = R"md(# {{name}}
Created by canon. The layout follows [P1204R0](https://wg21.link/p1204r0)
(Canonical Project Structure).
## Build and test
```bash
cmake -S . -B build
cmake --build build
ctest --test-dir build --output-on-failure
```
)md";
inline constexpr std::string_view library_header = R"cpp(#pragma once
#include <string>
#include <string_view>
namespace {{ident}} {
// Returns a greeting for name, e.g. "Hello, World!".
[[nodiscard]] std::string greeting(std::string_view name);
} // namespace {{ident}}
)cpp";
inline constexpr std::string_view library_source = R"cpp(#include <{{stem}}/{{stem}}.hpp>
#include <format>
namespace {{ident}} {
std::string greeting(std::string_view name) {
return std::format("Hello, {}!", name);
}
} // namespace {{ident}}
)cpp";
inline constexpr std::string_view library_unit_test = R"cpp(#include <{{stem}}/{{stem}}.hpp>
#include <cstdio>
#include <cstdlib>
#include <print>
int main() {
const auto actual = {{ident}}::greeting("World");
if (actual != "Hello, World!") {
std::println(stderr, "greeting(\"World\") returned \"{}\"", actual);
return EXIT_FAILURE;
}
}
)cpp";
inline constexpr std::string_view library_driver = R"cpp(// Functional test: uses {{name}} only through its public headers, as a consumer would.
#include <{{stem}}/{{stem}}.hpp>
#include <cstdlib>
#include <print>
int main() {
const auto greeting = {{ident}}::greeting("{{name}}");
std::println("{}", greeting);
return greeting.starts_with("Hello") ? EXIT_SUCCESS : EXIT_FAILURE;
}
)cpp";
inline constexpr std::string_view executable_source = R"cpp(#include <print>
int main(int argc, char* argv[]) {
std::println("Hello, {}!", argc > 1 ? argv[1] : "World");
}
)cpp";
inline constexpr std::string_view module_header = R"cpp(#pragma once
namespace {{namespace}} {
} // namespace {{namespace}}
)cpp";
inline constexpr std::string_view module_source = R"cpp(#include <{{include}}>
namespace {{namespace}} {
} // namespace {{namespace}}
)cpp";
inline constexpr std::string_view module_unit_test = R"cpp(// Unit tests for <{{include}}>. Return non-zero from main() to fail.
#include <{{include}}>
int main() {
}
)cpp";
inline constexpr std::string_view test_driver = R"cpp(// Functional test for {{target}}: use it only through its public headers, as a
// consumer would. Return non-zero from main() to fail.
#include <{{include}}>
int main() {
}
)cpp";
// For a library whose main header has been removed from .canon.toml.
inline constexpr std::string_view test_driver_without_include = R"cpp(// Functional test for {{target}}: use it only through its public headers, as a
// consumer would. Return non-zero from main() to fail.
int main() {
}
)cpp";
} // namespace canon::templates
+38
View File
@@ -0,0 +1,38 @@
# canon project manifest: what canon declared for this project.
# The managed block in CMakeLists.txt is rendered from this file.
[project]
name = "hello"
kind = "exe"
stem = "hello"
spec = "P1204R0"
[build]
backend = "cmake"
cmake-minimum = "3.20"
cxx-standard = 23
[[targets]]
name = "hello"
type = "executable"
sources = ["hello/hello.cpp"]
[[tests]]
name = "basics"
kind = "run"
target = "hello"
expect = "Hello, World!"
[[tests]]
name = "greets-paul"
kind = "run"
target = "hello"
args = ["Paul"]
expect = "^Hello, Paul!"
[[tests]]
name = "spaces"
kind = "run"
target = "hello"
args = ["two words"]
expect = "Hello, two words!"
+1
View File
@@ -0,0 +1 @@
/build/
@@ -0,0 +1,34 @@
cmake_minimum_required(VERSION 3.20)
project(hello VERSION 0.1.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
option(BUILD_TESTING "Build hello's tests" ON)
if(BUILD_TESTING)
enable_testing()
endif()
# Add your own CMake outside the managed block below.
# >>> canon:managed >>>
# Rendered from .canon.toml; canon rewrites everything between these markers.
add_executable(hello
hello/hello.cpp
)
target_include_directories(hello PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
if(BUILD_TESTING)
add_test(NAME basics COMMAND hello)
set_tests_properties(basics PROPERTIES PASS_REGULAR_EXPRESSION "Hello, World!")
add_test(NAME greets-paul COMMAND hello "Paul")
set_tests_properties(greets-paul PROPERTIES PASS_REGULAR_EXPRESSION "^Hello, Paul!")
add_test(NAME spaces COMMAND hello "two words")
set_tests_properties(spaces PROPERTIES PASS_REGULAR_EXPRESSION "Hello, two words!")
endif()
# <<< canon:managed <<<
+12
View File
@@ -0,0 +1,12 @@
# hello
Created by canon. The layout follows [P1204R0](https://wg21.link/p1204r0)
(Canonical Project Structure).
## Build and test
```bash
cmake -S . -B build
cmake --build build
ctest --test-dir build --output-on-failure
```
@@ -0,0 +1,5 @@
#include <print>
int main(int argc, char* argv[]) {
std::println("Hello, {}!", argc > 1 ? argv[1] : "World");
}
@@ -0,0 +1,63 @@
# 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
packages = ["Threads"]
[[targets]]
name = "libhello"
type = "library"
sources = ["hello/hello.cpp"]
depends = ["libextra"]
[[targets]]
name = "hello-cli"
type = "executable"
sources = ["hello-cli/hello-cli.cpp", "hello-cli/args.cpp"]
depends = ["libhello", "Threads::Threads"]
[[targets]]
name = "libextra"
type = "library"
sources = ["extra/extra.cpp"]
[[modules]]
path = "hello/hello"
target = "libhello"
unit-test = true
[[modules]]
path = "hello-cli/args"
target = "hello-cli"
unit-test = true
[[modules]]
path = "extra/extra"
target = "libextra"
unit-test = true
[[tests]]
name = "basics"
kind = "driver"
path = "tests/basics"
target = "libhello"
[[tests]]
name = "hello-cli"
kind = "run"
target = "hello-cli"
[[tests]]
name = "edge-cases"
kind = "driver"
path = "tests/edge-cases"
target = "libhello"
@@ -0,0 +1 @@
/build/
@@ -0,0 +1,67 @@
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.
find_package(Threads REQUIRED)
add_library(libhello
hello/hello.cpp
)
set_target_properties(libhello PROPERTIES PREFIX "")
target_include_directories(libhello PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_compile_features(libhello PUBLIC cxx_std_23)
target_link_libraries(libhello PUBLIC libextra)
add_executable(hello-cli
hello-cli/hello-cli.cpp
hello-cli/args.cpp
)
target_include_directories(hello-cli PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(hello-cli PRIVATE libhello Threads::Threads)
add_library(libextra
extra/extra.cpp
)
set_target_properties(libextra PROPERTIES PREFIX "")
target_include_directories(libextra PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_compile_features(libextra 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-cli.args.test hello-cli/args.test.cpp hello-cli/args.cpp)
target_include_directories(hello-cli.args.test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(hello-cli.args.test PRIVATE libhello Threads::Threads)
add_test(NAME hello-cli/args.test COMMAND hello-cli.args.test)
add_executable(extra.extra.test extra/extra.test.cpp)
target_link_libraries(extra.extra.test PRIVATE libextra)
add_test(NAME extra/extra.test COMMAND extra.extra.test)
add_executable(tests.basics tests/basics/driver.cpp)
target_link_libraries(tests.basics PRIVATE libhello)
add_test(NAME basics COMMAND tests.basics)
add_test(NAME hello-cli COMMAND hello-cli)
add_executable(tests.edge-cases tests/edge-cases/driver.cpp)
target_link_libraries(tests.edge-cases PRIVATE libhello)
add_test(NAME edge-cases COMMAND tests.edge-cases)
endif()
# <<< canon:managed <<<
@@ -0,0 +1,12 @@
# libhello
Created by canon. The layout follows [P1204R0](https://wg21.link/p1204r0)
(Canonical Project Structure).
## Build and test
```bash
cmake -S . -B build
cmake --build build
ctest --test-dir build --output-on-failure
```
@@ -0,0 +1,11 @@
#include <extra/extra.hpp>
#include <format>
namespace extra {
std::string greeting(std::string_view name) {
return std::format("Hello, {}!", name);
}
} // namespace extra
@@ -0,0 +1,11 @@
#pragma once
#include <string>
#include <string_view>
namespace extra {
// Returns a greeting for name, e.g. "Hello, World!".
[[nodiscard]] std::string greeting(std::string_view name);
} // namespace extra
@@ -0,0 +1,13 @@
#include <extra/extra.hpp>
#include <cstdio>
#include <cstdlib>
#include <print>
int main() {
const auto actual = extra::greeting("World");
if (actual != "Hello, World!") {
std::println(stderr, "greeting(\"World\") returned \"{}\"", actual);
return EXIT_FAILURE;
}
}
@@ -0,0 +1,5 @@
#include <hello-cli/args.hpp>
namespace hello_cli {
} // namespace hello_cli
@@ -0,0 +1,5 @@
#pragma once
namespace hello_cli {
} // namespace hello_cli
@@ -0,0 +1,6 @@
// Unit tests for <hello-cli/args.hpp>. Return non-zero from main() to fail.
#include <hello-cli/args.hpp>
int main() {
}
@@ -0,0 +1,5 @@
#include <print>
int main(int argc, char* argv[]) {
std::println("Hello, {}!", argc > 1 ? argv[1] : "World");
}
@@ -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;
}
@@ -0,0 +1,7 @@
// Functional test for libhello: use it only through its public headers, as a
// consumer would. Return non-zero from main() to fail.
#include <hello/hello.hpp>
int main() {
}
+23 -1
View File
@@ -1,7 +1,7 @@
# 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)
set(CANON_SCENARIOS new-lib new-exe add-lib add-exe multi-target exe-tests)
# Scenarios run canon from other directories, so a relative path would break.
get_filename_component(CANON "${CANON}" ABSOLUTE)
@@ -64,9 +64,31 @@ function(canon_scenario scenario work_dir out_var)
set(project ${dir}/hello)
canon_run(${project} add module greeting)
elseif(scenario STREQUAL "multi-target")
canon_run(${dir} new lib libhello)
set(project ${dir}/libhello)
canon_run(${project} add target exe hello-cli) # links libhello
canon_run(${project} add module args --target hello-cli)
canon_run(${project} add target lib libextra)
canon_run(${project} add dep libextra) # libhello -> libextra
canon_run(${project} add dep Threads::Threads --target hello-cli)
canon_run(${project} add test edge-cases)
canon_run_fails(${project} add dep libhello --target libextra) # cycle
canon_run_fails(${project} add dep hello-cli) # executable
canon_run_fails(${project} add dep fmt) # neither
canon_run_fails(${project} add target exe hello) # hello/ is taken
elseif(scenario STREQUAL "exe-tests")
canon_run(${dir} new exe hello)
set(project ${dir}/hello)
canon_run(${project} add test greets-paul --arg Paul --expect "^Hello, Paul!")
canon_run(${project} add test spaces --arg "two words" --expect "Hello, two words!")
canon_run_fails(${project} add test greets-paul)
else()
message(FATAL_ERROR "unknown scenario '${scenario}'")
endif()
canon_run(${project} doctor) # every scenario ends healthy
set(${out_var} ${project} PARENT_SCOPE)
endfunction()