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