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