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()