From 6981f55f28e1921b05ffa238dba8c6e47c997e15 Mon Sep 17 00:00:00 2001 From: paulhorn Date: Thu, 17 Sep 2026 19:09:35 +0200 Subject: [PATCH] Add canon sync canon sync rewrites the managed block of CMakeLists.txt from .canon.toml, for after either was edited by hand. It prints a unified diff of the change (also with --dry-run), writes only CMakeLists.txt so comments in .canon.toml survive, does nothing if the block already matches, and warns when the build will still fail because .canon.toml declares files that do not exist. doctor's hand-edited-block warning now points to it. - diff: a small line-based unified diff, no external tools - tests: a sync scenario (hand-edited manifest, --dry-run leaves the file untouched, sync from a subdirectory, no-op second run, hand edits in the block replaced); unit tests for plan_sync and unified_diff Co-Authored-By: Claude Opus 5 --- CMakeLists.txt | 4 +- README.md | 25 +++- canon/canon.cpp | 58 +++++++- canon/cli.cpp | 16 +++ canon/cli.hpp | 6 +- canon/cli.test.cpp | 7 + canon/diff.cpp | 126 ++++++++++++++++++ canon/diff.hpp | 14 ++ canon/diff.test.cpp | 40 ++++++ canon/doctor.cpp | 4 +- canon/sync.cpp | 21 +++ canon/sync.hpp | 17 +++ canon/sync.test.cpp | 77 +++++++++++ tests/golden/sync/libhello/.canon.toml | 30 +++++ tests/golden/sync/libhello/.gitignore | 1 + tests/golden/sync/libhello/CMakeLists.txt | 36 +++++ tests/golden/sync/libhello/README.md | 12 ++ tests/golden/sync/libhello/hello/extra.cpp | 3 + tests/golden/sync/libhello/hello/hello.cpp | 11 ++ tests/golden/sync/libhello/hello/hello.hpp | 11 ++ .../golden/sync/libhello/hello/hello.test.cpp | 13 ++ .../sync/libhello/tests/basics/driver.cpp | 12 ++ tests/scenarios.cmake | 27 +++- 23 files changed, 560 insertions(+), 11 deletions(-) create mode 100644 canon/diff.cpp create mode 100644 canon/diff.hpp create mode 100644 canon/diff.test.cpp create mode 100644 canon/sync.cpp create mode 100644 canon/sync.hpp create mode 100644 canon/sync.test.cpp create mode 100644 tests/golden/sync/libhello/.canon.toml create mode 100644 tests/golden/sync/libhello/.gitignore create mode 100644 tests/golden/sync/libhello/CMakeLists.txt create mode 100644 tests/golden/sync/libhello/README.md create mode 100644 tests/golden/sync/libhello/hello/extra.cpp create mode 100644 tests/golden/sync/libhello/hello/hello.cpp create mode 100644 tests/golden/sync/libhello/hello/hello.hpp create mode 100644 tests/golden/sync/libhello/hello/hello.test.cpp create mode 100644 tests/golden/sync/libhello/tests/basics/driver.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 260b59e..bad7cc6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -15,6 +15,7 @@ add_library(canon.core STATIC canon/add.cpp canon/cli.cpp canon/cmake.cpp + canon/diff.cpp canon/doctor.cpp canon/executor.cpp canon/generate.cpp @@ -22,6 +23,7 @@ add_library(canon.core STATIC canon/name.cpp canon/plan.cpp canon/project.cpp + canon/sync.cpp canon/template.cpp canon/templates.cpp canon/toml.cpp @@ -40,7 +42,7 @@ if(BUILD_TESTING) enable_testing() # Unit tests live next to the code they test: canon/.test.cpp. - foreach(unit add cli cmake doctor executor generate manifest name plan project template templates toml) + foreach(unit add cli cmake diff doctor executor generate manifest name plan project sync template templates toml) add_executable(canon.${unit}.test canon/${unit}.test.cpp) target_link_libraries(canon.${unit}.test PRIVATE canon.core) add_test(NAME canon/${unit}.test COMMAND canon.${unit}.test) diff --git a/README.md b/README.md index 6bcbb01..e62383c 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ canon add dep Boost::filesystem --package Boost # when the package name diffe # Checks canon doctor # compare the files with .canon.toml and P1204R0 +canon sync # rewrite CMakeLists.txt's managed block from .canon.toml # Templates canon templates # what each generated file is made from @@ -43,8 +44,8 @@ canon templates export --user # ... or all of them to ~/.config/canon/temp canon add module core --dry-run # any add command: show the changes, write nothing ``` -`add`, `doctor` and `templates` work from any directory inside the project. Every generated -project builds and passes its tests out of the box: +`add`, `doctor`, `sync` and `templates` work from any directory inside the +project. Every generated project builds and passes its tests out of the box: ```bash cmake -S . -B build @@ -75,14 +76,24 @@ The block between `# >>> canon:managed >>>` and `# <<< canon:managed <<<` in updates the manifest and re-renders the whole block, so: - anything **outside** the block is yours and is never touched; -- anything you edit **inside** the block is replaced on the next `add` - (`canon doctor` warns when the block has been edited); -- `.canon.toml` is rewritten by canon, so comments you add to it are dropped. +- anything you edit **inside** the block is replaced on the next `add` or + `sync` (`canon doctor` warns when the block has been edited); +- `add` commands rewrite `.canon.toml`, so comments you add to it are dropped. canon reads `.canon.toml` strictly: unknown keys (usually typos), invalid names, targets sharing a source directory and links to executables are errors with a line number. +### sync + +After editing `.canon.toml` by hand (say, to add a source or change +`cxx-standard`), run `canon sync` to bring the managed block in line. It shows a +diff of the block, with `--dry-run` or without, since hand edits inside the +block are replaced. It only writes `CMakeLists.txt`, so comments in +`.canon.toml` survive, and it does nothing if the block already matches. If +`.canon.toml` now declares files that do not exist, it says so, because CMake +would fail on them. + ### Targets and P1204R0 P1204R0 describes a project as one library or one executable with a source @@ -133,6 +144,8 @@ them from the manifest. | `canon/name.*` | naming rules (`lib` prefix, stem, namespace, keywords) | | `canon/generate.*` | the manifest and plan for `canon new` | | `canon/add.*` | the plans for every `canon add` command | +| `canon/sync.*` | the plan for `canon sync` | +| `canon/diff.*` | the unified diff `canon sync` shows | | `canon/doctor.*` | the checks behind `canon doctor` | | `canon/templates.*` | built-in templates, overrides from template directories, export | | `canon/project.*` | finds and loads an existing project | @@ -165,6 +178,8 @@ them from the manifest. 3. ✅ `add target`, `add test`, `add dep`, `doctor` 4. ✅ Template overrides: `templates`, `templates export`, project and user templates +Since then: ✅ `sync`. + ## License MIT, see [LICENSE](LICENSE). diff --git a/canon/canon.cpp b/canon/canon.cpp index ffba06b..942ecd1 100644 --- a/canon/canon.cpp +++ b/canon/canon.cpp @@ -1,14 +1,17 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include +#include #include #include #include @@ -17,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -65,7 +69,8 @@ std::string display_path(const fs::path& path) { } // 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) { +// details, if any, is printed after the report, e.g. a diff. +bool apply_and_report(const canon::plan& plan, const fs::path& root, bool dry_run, std::string_view details = {}) { const auto report = canon::apply(plan, root, {.dry_run = dry_run}); if (!report) { std::println(stderr, "canon: {}", report.error()); @@ -74,6 +79,9 @@ bool apply_and_report(const canon::plan& plan, const fs::path& root, bool dry_ru for (const auto& line : *report) { std::println("{}", line); } + if (!details.empty()) { + std::print("\n{}", details); + } if (dry_run) { std::println("\nDry run: nothing was written."); } @@ -190,6 +198,53 @@ int run_doctor(const canon::doctor_command& cmd) { return errors > 0 ? EXIT_FAILURE : EXIT_SUCCESS; } +int run_sync(const canon::sync_command& cmd) { + const auto project = canon::load_project(cmd.options.directory); + if (!project) { + std::println(stderr, "canon: {}", project.error()); + return EXIT_FAILURE; + } + const auto plan = canon::plan_sync(*project); + if (!plan) { + std::println(stderr, "canon: {}", plan.error()); + return EXIT_FAILURE; + } + + std::println("{} ({}):", project->declared.project.name, display_path(project->root)); + if (plan->operations.empty()) { + std::println(" CMakeLists.txt is already in sync with .canon.toml."); + return EXIT_SUCCESS; + } + + const auto& update = std::get(plan->operations.front()); + std::string diff; + for (const auto line : std::views::split(canon::unified_diff(update.expected, update.content), '\n')) { + if (!line.empty()) { + diff += std::format(" {}\n", std::string_view(line.begin(), line.end())); + } + } + if (!apply_and_report(*plan, project->root, cmd.options.dry_run, diff)) { + return EXIT_FAILURE; + } + if (cmd.options.dry_run) { + return EXIT_SUCCESS; + } + + // CMake will fail on declared files that do not exist, so point them out now. + canon::project synced = *project; + synced.cmakelists_text = update.content; + const auto findings = canon::diagnose(synced, canon::list_project_files(project->root)); + const auto errors = std::ranges::count(findings, canon::severity::error, &canon::finding::level); + if (errors == 0) { + std::println("\nCMakeLists.txt now matches .canon.toml. Rebuild with: cmake --build build"); + } else { + std::println("\nCMakeLists.txt now matches .canon.toml, but the build will fail until you fix what\n" + "'canon doctor' reports ({} {}), e.g.\n {}: {}", + errors, errors == 1 ? "error" : "errors", findings.front().path, findings.front().message); + } + return EXIT_SUCCESS; +} + int run_templates(const canon::templates_command& cmd) { const auto root = canon::find_project_root(cmd.directory); const auto directories = template_directories(root); @@ -333,6 +388,7 @@ int main(int argc, char* argv[]) try { target_suffix(cmd.request.target), rebuild)); }, [](const doctor_command& cmd) { return run_doctor(cmd); }, + [](const sync_command& cmd) { return run_sync(cmd); }, [](const templates_command& cmd) { return run_templates(cmd); }, [](const export_templates_command& cmd) { return run_export_templates(cmd); }, }, diff --git a/canon/cli.cpp b/canon/cli.cpp index 0042ec4..9db0a66 100644 --- a/canon/cli.cpp +++ b/canon/cli.cpp @@ -203,6 +203,16 @@ std::expected parse_doctor(positionals positional, const o return doctor_command{.directory = project_options_of(opts).directory}; } +std::expected parse_sync(positionals positional, const options& opts) { + if (auto ok = check_count(positional, 1, ""); !ok) { + return std::unexpected(ok.error()); + } + if (auto ok = check_options(opts, "sync", {"--dir", "--dry-run"}); !ok) { + return std::unexpected(ok.error()); + } + return sync_command{.options = project_options_of(opts)}; +} + std::expected parse_templates(positionals positional, const options& opts) { if (positional.size() == 1) { if (auto ok = check_options(opts, "templates", {"--dir"}); !ok) { @@ -283,6 +293,9 @@ std::expected parse_command_line(std::span; + doctor_command, sync_command, templates_command, export_templates_command>; // Parses the arguments after the program name. Syntax is checked here, // including options a command does not take; names and paths are validated by diff --git a/canon/cli.test.cpp b/canon/cli.test.cpp index 8ed81bc..031c45e 100644 --- a/canon/cli.test.cpp +++ b/canon/cli.test.cpp @@ -103,6 +103,13 @@ int main() { CANON_CHECK_EQ(d->directory.generic_string(), "proj"); } + if (const auto s = as(parse({"sync", "--dry-run", "--dir=proj"}))) { + CANON_CHECK(s->options.dry_run); + CANON_CHECK_EQ(s->options.directory.generic_string(), "proj"); + } + CANON_CHECK(fails_with(parse({"sync", "now"}), "unexpected argument 'now'")); + CANON_CHECK(fails_with(parse({"sync", "--target", "x"}), "--target does not apply to 'canon sync'")); + if (const auto t = as(parse({"templates", "--dir", "proj"}))) { CANON_CHECK_EQ(t->directory.generic_string(), "proj"); } diff --git a/canon/diff.cpp b/canon/diff.cpp new file mode 100644 index 0000000..75f18c1 --- /dev/null +++ b/canon/diff.cpp @@ -0,0 +1,126 @@ +#include + +#include +#include +#include +#include + +namespace canon { + +namespace { + +std::vector split_lines(std::string_view text) { + std::vector lines; + while (!text.empty()) { + const auto newline = text.find('\n'); + if (newline == std::string_view::npos) { + lines.push_back(text); + break; + } + lines.push_back(text.substr(0, newline)); + text.remove_prefix(newline + 1); + } + return lines; +} + +enum class edit : char { keep = ' ', remove = '-', add = '+' }; + +struct step { + edit kind; + std::size_t before; // index into the old lines (the next one, for an add) + std::size_t after; // index into the new lines (the next one, for a remove) +}; + +// The shortest edit script, from a longest-common-subsequence table. Files canon +// diffs are small, so the quadratic table is fine. Within a changed region, +// removals come before additions, as in `diff -u`. +std::vector edit_script(const std::vector& a, const std::vector& b) { + const std::size_t n = a.size(); + const std::size_t m = b.size(); + std::vector table((n + 1) * (m + 1), 0); // LCS of a[i..] and b[j..] + const auto lcs = [&](std::size_t i, std::size_t j) -> std::uint32_t& { return table[i * (m + 1) + j]; }; + for (std::size_t i = n; i-- > 0;) { + for (std::size_t j = m; j-- > 0;) { + lcs(i, j) = a[i] == b[j] ? lcs(i + 1, j + 1) + 1 : std::max(lcs(i + 1, j), lcs(i, j + 1)); + } + } + + std::vector steps; + std::size_t i = 0; + std::size_t j = 0; + while (i < n || j < m) { + if (i < n && j < m && a[i] == b[j]) { + steps.push_back({edit::keep, i++, j++}); + } else if (j == m || (i < n && lcs(i + 1, j) >= lcs(i, j + 1))) { + steps.push_back({edit::remove, i++, j}); + } else { + steps.push_back({edit::add, i, j++}); + } + } + return steps; +} + +// "12,5" for five lines from line 12; "12" for one line; "11,0" for none after line 11. +std::string range(std::size_t start, std::size_t count) { + if (count == 0) { + return std::format("{},0", start); + } + if (count == 1) { + return std::format("{}", start + 1); + } + return std::format("{},{}", start + 1, count); +} + +} // namespace + +std::string unified_diff(std::string_view before, std::string_view after, std::size_t context) { + const auto a = split_lines(before); + const auto b = split_lines(after); + const auto steps = edit_script(a, b); + + std::string out; + std::size_t next = 0; // first step not yet printed + while (next < steps.size()) { + std::size_t change = next; + while (change < steps.size() && steps[change].kind == edit::keep) { + ++change; + } + if (change == steps.size()) { + break; + } + + // Extend the hunk over later changes separated by at most 2 * context unchanged lines. + std::size_t last_change = change; + std::size_t unchanged = 0; + for (std::size_t s = change + 1; s < steps.size(); ++s) { + if (steps[s].kind != edit::keep) { + last_change = s; + unchanged = 0; + } else if (++unchanged > 2 * context) { + break; + } + } + const std::size_t first = std::max(next, change >= context ? change - context : 0); + const std::size_t end = std::min(last_change + 1 + context, steps.size()); + + const auto old_count = static_cast(std::ranges::count_if( + steps.begin() + static_cast(first), steps.begin() + static_cast(end), + [](const step& s) { return s.kind != edit::add; })); + const auto new_count = static_cast(std::ranges::count_if( + steps.begin() + static_cast(first), steps.begin() + static_cast(end), + [](const step& s) { return s.kind != edit::remove; })); + out += std::format("@@ -{} +{} @@\n", range(steps[first].before, old_count), + range(steps[first].after, new_count)); + + for (std::size_t s = first; s < end; ++s) { + const step& current = steps[s]; + out += static_cast(current.kind); + out += current.kind == edit::add ? b[current.after] : a[current.before]; + out += '\n'; + } + next = end; + } + return out; +} + +} // namespace canon diff --git a/canon/diff.hpp b/canon/diff.hpp new file mode 100644 index 0000000..aa70796 --- /dev/null +++ b/canon/diff.hpp @@ -0,0 +1,14 @@ +#pragma once + +#include +#include +#include + +namespace canon { + +// A unified diff of two texts, line by line: "@@ -12,5 +12,6 @@" hunks with +// `context` unchanged lines around each change, lines prefixed with ' ', '-' +// or '+'. Empty if both texts have the same lines. +[[nodiscard]] std::string unified_diff(std::string_view before, std::string_view after, std::size_t context = 2); + +} // namespace canon diff --git a/canon/diff.test.cpp b/canon/diff.test.cpp new file mode 100644 index 0000000..504a21e --- /dev/null +++ b/canon/diff.test.cpp @@ -0,0 +1,40 @@ +#include + +#include + +using namespace canon; + +int main() { + const std::string lines = "a\nb\nc\nd\ne\nf\ng\n"; + + CANON_CHECK_EQ(unified_diff(lines, lines), ""); + CANON_CHECK_EQ(unified_diff("", ""), ""); + CANON_CHECK_EQ(unified_diff("same\n", "same"), ""); // only the final newline differs + + // A replaced line, with two lines of context on each side. + CANON_CHECK_EQ(unified_diff(lines, "a\nb\nc\nX\ne\nf\ng\n"), "@@ -2,5 +2,5 @@\n" + " b\n" + " c\n" + "-d\n" + "+X\n" + " e\n" + " f\n"); + + // Additions at the end and removals at the start. + CANON_CHECK_EQ(unified_diff("a\nb\n", "a\nb\nc\n"), "@@ -1,2 +1,3 @@\n a\n b\n+c\n"); + CANON_CHECK_EQ(unified_diff("a\nb\nc\nd\n", "c\nd\n"), "@@ -1,4 +1,2 @@\n-a\n-b\n c\n d\n"); + CANON_CHECK_EQ(unified_diff("", "x\n"), "@@ -0,0 +1 @@\n+x\n"); + CANON_CHECK_EQ(unified_diff("x\n", ""), "@@ -1 +0,0 @@\n-x\n"); + + // Changes more than 2 * context lines apart get separate hunks; closer ones share one. + const std::string many = "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n"; + CANON_CHECK_EQ(unified_diff(many, "X\n2\n3\n4\n5\n6\n7\n8\n9\nY\n", 1), + "@@ -1,2 +1,2 @@\n-1\n+X\n 2\n@@ -9,2 +9,2 @@\n 9\n-10\n+Y\n"); + CANON_CHECK_EQ(unified_diff(many, "1\n2\nX\n4\nY\n6\n7\n8\n9\n10\n", 1), + "@@ -2,5 +2,5 @@\n 2\n-3\n+X\n 4\n-5\n+Y\n 6\n"); + + // Context 0 shows only the changed lines. + CANON_CHECK_EQ(unified_diff(lines, "a\nb\nc\nd\ne\nf\ng\nh\n", 0), "@@ -7,0 +8 @@\n+h\n"); + + return details::exit_status(); +} diff --git a/canon/doctor.cpp b/canon/doctor.cpp index 215af6b..852e97f 100644 --- a/canon/doctor.cpp +++ b/canon/doctor.cpp @@ -51,8 +51,8 @@ void check_managed_block(diagnosis& d, const project& p) { if (!rendered) { d.error("CMakeLists.txt", rendered.error()); } else if (*rendered != p.cmakelists_text) { - d.warning("CMakeLists.txt", "the managed block differs from .canon.toml (edited by hand?); the next " - "'canon add' will replace it"); + d.warning("CMakeLists.txt", "the managed block differs from .canon.toml (edited by hand?); 'canon sync' " + "rewrites it from .canon.toml (add --dry-run to see the changes)"); } } diff --git a/canon/sync.cpp b/canon/sync.cpp new file mode 100644 index 0000000..8ccc02e --- /dev/null +++ b/canon/sync.cpp @@ -0,0 +1,21 @@ +#include + +#include + +#include + +namespace canon { + +std::expected plan_sync(const project& p) { + auto cmakelists = replace_managed_block(p.cmakelists_text, p.declared); + if (!cmakelists) { + return std::unexpected(std::format("CMakeLists.txt: {}", cmakelists.error())); + } + plan result; + if (*cmakelists != p.cmakelists_text) { + result.update("CMakeLists.txt", p.cmakelists_text, std::move(*cmakelists)); + } + return result; +} + +} // namespace canon diff --git a/canon/sync.hpp b/canon/sync.hpp new file mode 100644 index 0000000..8a1c620 --- /dev/null +++ b/canon/sync.hpp @@ -0,0 +1,17 @@ +#pragma once + +#include +#include + +#include +#include + +namespace canon { + +// Plans `canon sync`: CMakeLists.txt with its managed block re-rendered from +// .canon.toml, for when either was edited by hand. Hand edits inside the block +// are replaced; everything outside it is kept. .canon.toml itself is not +// rewritten, so its comments survive. The plan is empty if nothing differs. +[[nodiscard]] std::expected plan_sync(const project&); + +} // namespace canon diff --git a/canon/sync.test.cpp b/canon/sync.test.cpp new file mode 100644 index 0000000..b7293a8 --- /dev/null +++ b/canon/sync.test.cpp @@ -0,0 +1,77 @@ +#include + +#include +#include +#include + +#include +#include + +using namespace canon; + +namespace { + +project make_project() { + const manifest m = new_project_manifest(*parse_project_name(project_kind::library, "libhello")); + return project{ + .root = "/unused", + .declared = m, + .manifest_text = to_toml(m), + .cmakelists_text = render_cmakelists(m), + }; +} + +// A copy of the plan's only operation, which must update CMakeLists.txt. +std::optional the_update(const std::expected& result) { + CANON_CHECK(result && result->operations.size() == 1); + if (!result || result->operations.size() != 1) { + return std::nullopt; + } + const auto* update = std::get_if(&result->operations.front()); + CANON_CHECK(update != nullptr && update->path == "CMakeLists.txt"); + return update != nullptr ? std::optional(*update) : std::nullopt; +} + +} // namespace + +int main() { + const project fresh = make_project(); + + // Already in sync: nothing to do. + { + const auto result = plan_sync(fresh); + CANON_CHECK(result && result->operations.empty()); + } + + // .canon.toml edited by hand: the block follows it, and .canon.toml is left alone. + { + project edited = fresh; + edited.declared.targets.at(0).sources.push_back("hello/extra.cpp"); + if (const auto update = the_update(plan_sync(edited))) { + CANON_CHECK_EQ(update->expected, fresh.cmakelists_text); + CANON_CHECK_EQ(update->content, render_cmakelists(edited.declared)); + CANON_CHECK(update->content.contains(" hello/hello.cpp\n hello/extra.cpp\n")); + } + } + + // The block edited by hand: it is re-rendered; content outside it is kept. + { + project edited = fresh; + const std::string mine = "\ninstall(TARGETS libhello)\n"; + edited.cmakelists_text = fresh.cmakelists_text + mine; + edited.cmakelists_text.insert(edited.cmakelists_text.find("add_library"), "add_compile_options(-Werror)\n"); + if (const auto update = the_update(plan_sync(edited))) { + CANON_CHECK_EQ(update->content, fresh.cmakelists_text + mine); + } + } + + // Without markers there is nothing canon may rewrite. + { + project unmarked = fresh; + unmarked.cmakelists_text = "cmake_minimum_required(VERSION 3.20)\n"; + const auto result = plan_sync(unmarked); + CANON_CHECK(!result && result.error().starts_with("CMakeLists.txt: no '# >>> canon:managed >>>' line")); + } + + return details::exit_status(); +} diff --git a/tests/golden/sync/libhello/.canon.toml b/tests/golden/sync/libhello/.canon.toml new file mode 100644 index 0000000..aa90fee --- /dev/null +++ b/tests/golden/sync/libhello/.canon.toml @@ -0,0 +1,30 @@ +# edited by hand +# 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/extra.cpp"] + +[[modules]] +path = "hello/hello" +target = "libhello" +unit-test = true + +[[tests]] +name = "basics" +kind = "driver" +path = "tests/basics" +target = "libhello" diff --git a/tests/golden/sync/libhello/.gitignore b/tests/golden/sync/libhello/.gitignore new file mode 100644 index 0000000..84c048a --- /dev/null +++ b/tests/golden/sync/libhello/.gitignore @@ -0,0 +1 @@ +/build/ diff --git a/tests/golden/sync/libhello/CMakeLists.txt b/tests/golden/sync/libhello/CMakeLists.txt new file mode 100644 index 0000000..78fb05e --- /dev/null +++ b/tests/golden/sync/libhello/CMakeLists.txt @@ -0,0 +1,36 @@ +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/extra.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(tests.basics tests/basics/driver.cpp) + target_link_libraries(tests.basics PRIVATE libhello) + add_test(NAME basics COMMAND tests.basics) +endif() + +# <<< canon:managed <<< diff --git a/tests/golden/sync/libhello/README.md b/tests/golden/sync/libhello/README.md new file mode 100644 index 0000000..a844dca --- /dev/null +++ b/tests/golden/sync/libhello/README.md @@ -0,0 +1,12 @@ +# libhello + +Created by canon. The layout follows [P1204R0](https://wg21.link/p1204r0) +(Canonical Project Structure). + +## Build and test + +```bash +cmake -S . -B build +cmake --build build +ctest --test-dir build --output-on-failure +``` diff --git a/tests/golden/sync/libhello/hello/extra.cpp b/tests/golden/sync/libhello/hello/extra.cpp new file mode 100644 index 0000000..dd02dde --- /dev/null +++ b/tests/golden/sync/libhello/hello/extra.cpp @@ -0,0 +1,3 @@ +namespace hello { +int extra() { return 42; } +} diff --git a/tests/golden/sync/libhello/hello/hello.cpp b/tests/golden/sync/libhello/hello/hello.cpp new file mode 100644 index 0000000..e7b92ce --- /dev/null +++ b/tests/golden/sync/libhello/hello/hello.cpp @@ -0,0 +1,11 @@ +#include + +#include + +namespace hello { + +std::string greeting(std::string_view name) { + return std::format("Hello, {}!", name); +} + +} // namespace hello diff --git a/tests/golden/sync/libhello/hello/hello.hpp b/tests/golden/sync/libhello/hello/hello.hpp new file mode 100644 index 0000000..0d8d5ba --- /dev/null +++ b/tests/golden/sync/libhello/hello/hello.hpp @@ -0,0 +1,11 @@ +#pragma once + +#include +#include + +namespace hello { + +// Returns a greeting for name, e.g. "Hello, World!". +[[nodiscard]] std::string greeting(std::string_view name); + +} // namespace hello diff --git a/tests/golden/sync/libhello/hello/hello.test.cpp b/tests/golden/sync/libhello/hello/hello.test.cpp new file mode 100644 index 0000000..5cafec8 --- /dev/null +++ b/tests/golden/sync/libhello/hello/hello.test.cpp @@ -0,0 +1,13 @@ +#include + +#include +#include +#include + +int main() { + const auto actual = hello::greeting("World"); + if (actual != "Hello, World!") { + std::println(stderr, "greeting(\"World\") returned \"{}\"", actual); + return EXIT_FAILURE; + } +} diff --git a/tests/golden/sync/libhello/tests/basics/driver.cpp b/tests/golden/sync/libhello/tests/basics/driver.cpp new file mode 100644 index 0000000..757d317 --- /dev/null +++ b/tests/golden/sync/libhello/tests/basics/driver.cpp @@ -0,0 +1,12 @@ +// Functional test: uses libhello only through its public headers, as a consumer would. + +#include + +#include +#include + +int main() { + const auto greeting = hello::greeting("libhello"); + std::println("{}", greeting); + return greeting.starts_with("Hello") ? EXIT_SUCCESS : EXIT_FAILURE; +} diff --git a/tests/scenarios.cmake b/tests/scenarios.cmake index 376fc65..6a05ac7 100644 --- a/tests/scenarios.cmake +++ b/tests/scenarios.cmake @@ -4,7 +4,7 @@ # canon runs with XDG_CONFIG_HOME set to /-config, so user # templates in your own ~/.config never leak into the results. -set(CANON_SCENARIOS new-lib new-exe add-lib add-exe multi-target exe-tests templates) +set(CANON_SCENARIOS new-lib new-exe add-lib add-exe multi-target exe-tests templates sync) # Scenarios run canon from other directories, so a relative path would break. get_filename_component(CANON "${CANON}" ABSOLUTE) @@ -120,6 +120,31 @@ function(canon_scenario scenario work_dir out_var) canon_run_fails(${project} add module broken) file(REMOVE ${config_home}/canon/templates/modul.hpp) + elseif(scenario STREQUAL "sync") + canon_run(${dir} new lib libhello) + set(project ${dir}/libhello) + + # Declare a source by editing .canon.toml by hand, then bring CMakeLists.txt along. + file(WRITE ${project}/hello/extra.cpp "namespace hello {\nint extra() { return 42; }\n}\n") + file(READ ${project}/.canon.toml manifest) + string(REPLACE "sources = [\"hello/hello.cpp\"]" "sources = [\"hello/hello.cpp\", \"hello/extra.cpp\"]" + manifest "${manifest}") + file(WRITE ${project}/.canon.toml "# edited by hand\n${manifest}") + file(READ ${project}/CMakeLists.txt before) + canon_run(${project} sync --dry-run) + file(READ ${project}/CMakeLists.txt after) + if(NOT before STREQUAL after) + message(FATAL_ERROR "canon sync --dry-run changed CMakeLists.txt") + endif() + canon_run(${project}/hello sync) # from a subdirectory + canon_run(${project} sync) # already in sync + + # Hand edits inside the managed block are replaced; the comment in .canon.toml survives. + file(READ ${project}/CMakeLists.txt cmake) + string(REPLACE "set_target_properties" "add_compile_options(-w)\nset_target_properties" cmake "${cmake}") + file(WRITE ${project}/CMakeLists.txt "${cmake}") + canon_run(${project} sync) + else() message(FATAL_ERROR "unknown scenario '${scenario}'") endif()