diff --git a/CMakeLists.txt b/CMakeLists.txt index 218397e..afa4f3d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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/.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) diff --git a/README.md b/README.md index 0432196..ec51699 100644 --- a/README.md +++ b/README.md @@ -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/.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//` 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 diff --git a/canon/add.cpp b/canon/add.cpp index a1fd4cc..2b01d6a 100644 --- a/canon/add.cpp +++ b/canon/add.cpp @@ -2,50 +2,61 @@ #include #include +#include #include #include #include -#include +#include +#include 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 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 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 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 resolve_module(const project_name& project, - std::string_view path) { +std::expected 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 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 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 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> seen; + std::vector 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 add_updates(plan& result, const project& p, const manifest& updated) { +std::expected 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_add_module(const project& p, std::string_view path, - bool unit_test) { - const auto names = resolve_module(p.declared.project, path); +std::expected 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_add_unit_test(const project& p, std::string_view path) { - const auto names = resolve_module(p.declared.project, path); +std::expected 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_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_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_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_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 diff --git a/canon/add.hpp b/canon/add.hpp index 46109ab..e1c782f 100644 --- a/canon/add.hpp +++ b/canon/add.hpp @@ -1,25 +1,61 @@ #pragma once +#include #include #include #include #include -#include +#include namespace canon { -// Plans `canon add module `. path is relative to the source directory, -// e.g. "core" or "details/utility" (directories become nested namespaces). The -// plan creates /.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_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 ` for a declared module without a unit test. -[[nodiscard]] std::expected 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 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 "::" +}; + +// /.hpp and .cpp (and .test.cpp); the source joins the target. +// Directories in path become nested namespaces. +[[nodiscard]] std::expected plan_add_module(const project&, const module_request&); + +// /.test.cpp for a declared module without one. unit_test is ignored. +[[nodiscard]] std::expected 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_add_target(const project&, const target_request&); + +// A functional test: tests//driver.cpp linked against a library, or a +// run of an executable. Both take args and expect. +[[nodiscard]] std::expected 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_add_dep(const project&, const dep_request&); } // namespace canon diff --git a/canon/add.test.cpp b/canon/add.test.cpp index a6d9a91..e4ffcf8 100644 --- a/canon/add.test.cpp +++ b/canon/add.test.cpp @@ -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& result, std::string_view message) { if (result) { std::println(stderr, " planned successfully, expected: {}", message); @@ -80,70 +93,64 @@ bool fails_with(const std::expected& 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({"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 \n")); - CANON_CHECK(created(*result, "hello/core.test.cpp").contains("#include \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({"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 \n")); + CANON_CHECK(created(*result, "hello/core.test.cpp").contains("#include \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({"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 \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({"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({"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({"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({"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({"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({"tests/edge-cases/driver.cpp"})); + CANON_CHECK(created(*result, "tests/edge-cases/driver.cpp").contains("#include \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({"Threads"})); + CANON_CHECK(m.targets.at(0).depends == std::vector({"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({"Boost"})); + const auto renamed = plan_add_dep(lib, {.dependency = "unofficial::sqlite3::sqlite3", .package = "unofficial-sqlite3"}); + CANON_CHECK(renamed && planned_manifest(*renamed).packages == std::vector({"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({"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 == diff --git a/canon/canon.cpp b/canon/canon.cpp index 820888d..d05b26d 100644 --- a/canon/canon.cpp +++ b/canon/canon.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include #include #include @@ -12,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -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 -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(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 ", + 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; diff --git a/canon/cli.cpp b/canon/cli.cpp index 8c4782e..f592509 100644 --- a/canon/cli.cpp +++ b/canon/cli.cpp @@ -1,6 +1,9 @@ #include +#include +#include #include +#include #include #include @@ -8,71 +11,195 @@ namespace canon { namespace { +using positionals = std::span; + +constexpr auto value_options = std::to_array({"--dir", "--target", "--arg", "--expect", "--package"}); + struct options { std::optional directory; + std::optional target; + std::optional expect; + std::optional package; + std::vector args; bool dry_run = false; bool no_test = false; + std::vector used; // every option given, e.g. "--dir" }; std::unexpected 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 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 parse_new(std::span positional, - const options& opts) { +std::expected check_options(const options& opts, std::string_view command, + std::initializer_list 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 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 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 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 parse_add(std::span positional, - const options& opts) { +std::expected 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 allowed) -> std::expected { + 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 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 parse_command_line(std::span(i) + 1, - args.end()); + if (arg == "--") { + positional.insert(positional.end(), args.begin() + static_cast(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 [options] Create a project: - new lib Library project (the name must start with 'lib') - new exe Executable project + new lib Library project (the name must start with 'lib') + new exe Executable project -Change the project you are in (any directory inside it works): - add module Add /.hpp, .cpp and .test.cpp, e.g. 'core' - or 'details/utility' (directories become namespaces) - add unit-test Add the .test.cpp for a module created with --no-test +Change the project you are in (run anywhere inside it): + add module /.hpp, .cpp and .test.cpp, e.g. 'core' or + 'details/utility' (directories become namespaces) + add unit-test The .test.cpp for a module created with --no-test + add target lib|exe Another library or executable, in a source directory + of its own + add test A functional test: tests//driver.cpp for a + library, a run of the program for an executable + add dep 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 new: create the project inside - add: look for the project from - (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 new: create the project inside + others: look for the project from + --dry-run Show what would change without writing anything + --target The target to change (default: the project's own) + --no-test add module / add target: skip the test + --arg add test: pass an argument (repeat for more) + --expect add test: the output must match + --package 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 )"; diff --git a/canon/cli.hpp b/canon/cli.hpp index c9fef89..c689a3e 100644 --- a/canon/cli.hpp +++ b/canon/cli.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -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; +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; + +// 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 parse_command_line(std::span args); diff --git a/canon/cli.test.cpp b/canon/cli.test.cpp index 27447ed..37a1a4d 100644 --- a/canon/cli.test.cpp +++ b/canon/cli.test.cpp @@ -3,6 +3,7 @@ #include #include +#include #include using namespace canon; @@ -19,7 +20,23 @@ bool is_help(const std::expected& c) { } bool fails_with(const std::expected& 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 +std::optional as(const std::expected& c) { + const Command* result = c ? std::get_if(&*c) : nullptr; + CANON_CHECK(result != nullptr); + return result != nullptr ? std::optional(*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(*parse({"--version"}))); - if (const auto c = parse({"new", "lib", "libhello"}); CANON_CHECK(c), c) { - const auto* n = std::get_if(&*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(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(*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(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(*c).directory.generic_string(), "out"); + if (const auto n = as(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(*c).name, "-weird"); + if (const auto n = as(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(&*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(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(*c); - CANON_CHECK(!a.unit_test); - CANON_CHECK(a.dry_run); - CANON_CHECK_EQ(a.directory.generic_string(), "proj"); + if (const auto a = as( + 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(&*c); - CANON_CHECK(a != nullptr && a->path == "core"); + if (const auto a = as(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(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( + 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({"Paul", "", "--weird"})); + CANON_CHECK_EQ(a->request.expect, "Hello, .*"); + } + if (const auto a = as(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(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(); } diff --git a/canon/cmake.cpp b/canon/cmake.cpp index 28cce54..0dde50e 100644 --- a/canon/cmake.cpp +++ b/canon/cmake.cpp @@ -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); } diff --git a/canon/cmake.test.cpp b/canon/cmake.test.cpp index 82362de..907bf88 100644 --- a/canon/cmake.test.cpp +++ b/canon/cmake.test.cpp @@ -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()); diff --git a/canon/details/testing.hpp b/canon/details/testing.hpp index 83dee81..2df4d39 100644 --- a/canon/details/testing.hpp +++ b/canon/details/testing.hpp @@ -63,6 +63,6 @@ private: } // namespace canon::details -#define CANON_CHECK(expr) ::canon::details::check(static_cast(expr), #expr) +#define CANON_CHECK(...) ::canon::details::check(static_cast(__VA_ARGS__), #__VA_ARGS__) #define CANON_CHECK_EQ(actual, expected) \ ::canon::details::check_equal((actual), (expected), #actual " == " #expected) diff --git a/canon/doctor.cpp b/canon/doctor.cpp new file mode 100644 index 0000000..215af6b --- /dev/null +++ b/canon/doctor.cpp @@ -0,0 +1,193 @@ +#include + +#include + +#include +#include +#include +#include + +namespace canon { + +namespace fs = std::filesystem; + +namespace { + +constexpr auto other_header_extensions = std::to_array({".h", ".hh", ".hxx", ".h++", ".H"}); +constexpr auto other_source_extensions = std::to_array({".cc", ".cxx", ".c++", ".C"}); + +using path_set = std::set>; + +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 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 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& 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 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 list_project_files(const fs::path& root) { + std::vector 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 diagnose(const project& p, const std::vector& 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 diff --git a/canon/doctor.hpp b/canon/doctor.hpp new file mode 100644 index 0000000..f72982b --- /dev/null +++ b/canon/doctor.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include + +#include +#include +#include +#include + +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 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 diagnose(const project&, const std::vector& files); + +} // namespace canon diff --git a/canon/doctor.test.cpp b/canon/doctor.test.cpp new file mode 100644 index 0000000..c334048 --- /dev/null +++ b/canon/doctor.test.cpp @@ -0,0 +1,151 @@ +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +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 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 with(std::vector files, std::initializer_list extra) { + files.insert(files.end(), extra); + return files; +} + +std::vector without(std::vector files, const std::string& gone) { + std::erase(files, gone); + return files; +} + +bool reports(const std::vector& 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(); +} diff --git a/canon/generate.cpp b/canon/generate.cpp index 84cfa4a..5bd2799 100644 --- a/canon/generate.cpp +++ b/canon/generate.cpp @@ -2,93 +2,10 @@ #include #include - -#include +#include 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 -#include - -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 - -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 -#include -#include - -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 -#include - -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 - -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; } diff --git a/canon/manifest.cpp b/canon/manifest.cpp index 5a04658..b955271 100644 --- a/canon/manifest.cpp +++ b/canon/manifest.cpp @@ -136,6 +136,12 @@ void check_unique(const std::vector& items, Key T::*key, const std::vector 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 parse_manifest(std::string_view text) { } m.cxx_standard = static_cast(*standard); } + if (auto packages = r.optional>("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 parse_manifest(std::string_view text) { .kind = read_test_kind(r), .path = r.optional("path").value_or(""), .target = r.required("target"), + .args = r.optional>("args").value_or(std::vector{}), .expect = r.optional("expect").value_or(""), }; r.finish(); @@ -309,6 +328,49 @@ std::expected 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 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(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, diff --git a/canon/manifest.hpp b/canon/manifest.hpp index 282158e..ead3ebc 100644 --- a/canon/manifest.hpp +++ b/canon/manifest.hpp @@ -25,11 +25,15 @@ struct target { std::string name{}; target_type type{}; std::vector sources{}; - std::vector depends{}; + std::vector 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 target_names(const target&); + // A related file set: .hpp, .cpp and optionally .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 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 packages{}; // find_package() names, in order std::vector targets{}; std::vector modules{}; std::vector 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 parse_manifest(std::string_view); } // namespace canon diff --git a/canon/manifest.test.cpp b/canon/manifest.test.cpp index c5dff3e..7dbdb69 100644 --- a/canon/manifest.test.cpp +++ b/canon/manifest.test.cpp @@ -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(); } diff --git a/canon/templates.hpp b/canon/templates.hpp new file mode 100644 index 0000000..4ce3140 --- /dev/null +++ b/canon/templates.hpp @@ -0,0 +1,132 @@ +#pragma once + +#include + +// 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 +#include + +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 + +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 +#include +#include + +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 +#include + +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 + +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 diff --git a/tests/golden/exe-tests/hello/.canon.toml b/tests/golden/exe-tests/hello/.canon.toml new file mode 100644 index 0000000..9445d11 --- /dev/null +++ b/tests/golden/exe-tests/hello/.canon.toml @@ -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!" diff --git a/tests/golden/exe-tests/hello/.gitignore b/tests/golden/exe-tests/hello/.gitignore new file mode 100644 index 0000000..84c048a --- /dev/null +++ b/tests/golden/exe-tests/hello/.gitignore @@ -0,0 +1 @@ +/build/ diff --git a/tests/golden/exe-tests/hello/CMakeLists.txt b/tests/golden/exe-tests/hello/CMakeLists.txt new file mode 100644 index 0000000..e0889f9 --- /dev/null +++ b/tests/golden/exe-tests/hello/CMakeLists.txt @@ -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 <<< diff --git a/tests/golden/exe-tests/hello/README.md b/tests/golden/exe-tests/hello/README.md new file mode 100644 index 0000000..267b7c3 --- /dev/null +++ b/tests/golden/exe-tests/hello/README.md @@ -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 +``` diff --git a/tests/golden/exe-tests/hello/hello/hello.cpp b/tests/golden/exe-tests/hello/hello/hello.cpp new file mode 100644 index 0000000..d1aadd6 --- /dev/null +++ b/tests/golden/exe-tests/hello/hello/hello.cpp @@ -0,0 +1,5 @@ +#include + +int main(int argc, char* argv[]) { + std::println("Hello, {}!", argc > 1 ? argv[1] : "World"); +} diff --git a/tests/golden/multi-target/libhello/.canon.toml b/tests/golden/multi-target/libhello/.canon.toml new file mode 100644 index 0000000..4ffef24 --- /dev/null +++ b/tests/golden/multi-target/libhello/.canon.toml @@ -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" diff --git a/tests/golden/multi-target/libhello/.gitignore b/tests/golden/multi-target/libhello/.gitignore new file mode 100644 index 0000000..84c048a --- /dev/null +++ b/tests/golden/multi-target/libhello/.gitignore @@ -0,0 +1 @@ +/build/ diff --git a/tests/golden/multi-target/libhello/CMakeLists.txt b/tests/golden/multi-target/libhello/CMakeLists.txt new file mode 100644 index 0000000..6bd9c4c --- /dev/null +++ b/tests/golden/multi-target/libhello/CMakeLists.txt @@ -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 <<< diff --git a/tests/golden/multi-target/libhello/README.md b/tests/golden/multi-target/libhello/README.md new file mode 100644 index 0000000..a844dca --- /dev/null +++ b/tests/golden/multi-target/libhello/README.md @@ -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 +``` diff --git a/tests/golden/multi-target/libhello/extra/extra.cpp b/tests/golden/multi-target/libhello/extra/extra.cpp new file mode 100644 index 0000000..e8abb03 --- /dev/null +++ b/tests/golden/multi-target/libhello/extra/extra.cpp @@ -0,0 +1,11 @@ +#include + +#include + +namespace extra { + +std::string greeting(std::string_view name) { + return std::format("Hello, {}!", name); +} + +} // namespace extra diff --git a/tests/golden/multi-target/libhello/extra/extra.hpp b/tests/golden/multi-target/libhello/extra/extra.hpp new file mode 100644 index 0000000..9dfbde7 --- /dev/null +++ b/tests/golden/multi-target/libhello/extra/extra.hpp @@ -0,0 +1,11 @@ +#pragma once + +#include +#include + +namespace extra { + +// Returns a greeting for name, e.g. "Hello, World!". +[[nodiscard]] std::string greeting(std::string_view name); + +} // namespace extra diff --git a/tests/golden/multi-target/libhello/extra/extra.test.cpp b/tests/golden/multi-target/libhello/extra/extra.test.cpp new file mode 100644 index 0000000..6254243 --- /dev/null +++ b/tests/golden/multi-target/libhello/extra/extra.test.cpp @@ -0,0 +1,13 @@ +#include + +#include +#include +#include + +int main() { + const auto actual = extra::greeting("World"); + if (actual != "Hello, World!") { + std::println(stderr, "greeting(\"World\") returned \"{}\"", actual); + return EXIT_FAILURE; + } +} diff --git a/tests/golden/multi-target/libhello/hello-cli/args.cpp b/tests/golden/multi-target/libhello/hello-cli/args.cpp new file mode 100644 index 0000000..253bbab --- /dev/null +++ b/tests/golden/multi-target/libhello/hello-cli/args.cpp @@ -0,0 +1,5 @@ +#include + +namespace hello_cli { + +} // namespace hello_cli diff --git a/tests/golden/multi-target/libhello/hello-cli/args.hpp b/tests/golden/multi-target/libhello/hello-cli/args.hpp new file mode 100644 index 0000000..26f6f37 --- /dev/null +++ b/tests/golden/multi-target/libhello/hello-cli/args.hpp @@ -0,0 +1,5 @@ +#pragma once + +namespace hello_cli { + +} // namespace hello_cli diff --git a/tests/golden/multi-target/libhello/hello-cli/args.test.cpp b/tests/golden/multi-target/libhello/hello-cli/args.test.cpp new file mode 100644 index 0000000..f6da4e7 --- /dev/null +++ b/tests/golden/multi-target/libhello/hello-cli/args.test.cpp @@ -0,0 +1,6 @@ +// Unit tests for . Return non-zero from main() to fail. + +#include + +int main() { +} diff --git a/tests/golden/multi-target/libhello/hello-cli/hello-cli.cpp b/tests/golden/multi-target/libhello/hello-cli/hello-cli.cpp new file mode 100644 index 0000000..d1aadd6 --- /dev/null +++ b/tests/golden/multi-target/libhello/hello-cli/hello-cli.cpp @@ -0,0 +1,5 @@ +#include + +int main(int argc, char* argv[]) { + std::println("Hello, {}!", argc > 1 ? argv[1] : "World"); +} diff --git a/tests/golden/multi-target/libhello/hello/hello.cpp b/tests/golden/multi-target/libhello/hello/hello.cpp new file mode 100644 index 0000000..e7b92ce --- /dev/null +++ b/tests/golden/multi-target/libhello/hello/hello.cpp @@ -0,0 +1,11 @@ +#include + +#include + +namespace hello { + +std::string greeting(std::string_view name) { + return std::format("Hello, {}!", name); +} + +} // namespace hello diff --git a/tests/golden/multi-target/libhello/hello/hello.hpp b/tests/golden/multi-target/libhello/hello/hello.hpp new file mode 100644 index 0000000..0d8d5ba --- /dev/null +++ b/tests/golden/multi-target/libhello/hello/hello.hpp @@ -0,0 +1,11 @@ +#pragma once + +#include +#include + +namespace hello { + +// Returns a greeting for name, e.g. "Hello, World!". +[[nodiscard]] std::string greeting(std::string_view name); + +} // namespace hello diff --git a/tests/golden/multi-target/libhello/hello/hello.test.cpp b/tests/golden/multi-target/libhello/hello/hello.test.cpp new file mode 100644 index 0000000..5cafec8 --- /dev/null +++ b/tests/golden/multi-target/libhello/hello/hello.test.cpp @@ -0,0 +1,13 @@ +#include + +#include +#include +#include + +int main() { + const auto actual = hello::greeting("World"); + if (actual != "Hello, World!") { + std::println(stderr, "greeting(\"World\") returned \"{}\"", actual); + return EXIT_FAILURE; + } +} diff --git a/tests/golden/multi-target/libhello/tests/basics/driver.cpp b/tests/golden/multi-target/libhello/tests/basics/driver.cpp new file mode 100644 index 0000000..757d317 --- /dev/null +++ b/tests/golden/multi-target/libhello/tests/basics/driver.cpp @@ -0,0 +1,12 @@ +// Functional test: uses libhello only through its public headers, as a consumer would. + +#include + +#include +#include + +int main() { + const auto greeting = hello::greeting("libhello"); + std::println("{}", greeting); + return greeting.starts_with("Hello") ? EXIT_SUCCESS : EXIT_FAILURE; +} diff --git a/tests/golden/multi-target/libhello/tests/edge-cases/driver.cpp b/tests/golden/multi-target/libhello/tests/edge-cases/driver.cpp new file mode 100644 index 0000000..7240bd6 --- /dev/null +++ b/tests/golden/multi-target/libhello/tests/edge-cases/driver.cpp @@ -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 + +int main() { +} diff --git a/tests/scenarios.cmake b/tests/scenarios.cmake index 6202274..d6f48fd 100644 --- a/tests/scenarios.cmake +++ b/tests/scenarios.cmake @@ -1,7 +1,7 @@ # canon command sequences shared by the golden and end-to-end tests. Each # scenario builds one project inside //. 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()