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 <noreply@anthropic.com>
This commit is contained in:
+3
-1
@@ -15,6 +15,7 @@ add_library(canon.core STATIC
|
|||||||
canon/add.cpp
|
canon/add.cpp
|
||||||
canon/cli.cpp
|
canon/cli.cpp
|
||||||
canon/cmake.cpp
|
canon/cmake.cpp
|
||||||
|
canon/diff.cpp
|
||||||
canon/doctor.cpp
|
canon/doctor.cpp
|
||||||
canon/executor.cpp
|
canon/executor.cpp
|
||||||
canon/generate.cpp
|
canon/generate.cpp
|
||||||
@@ -22,6 +23,7 @@ add_library(canon.core STATIC
|
|||||||
canon/name.cpp
|
canon/name.cpp
|
||||||
canon/plan.cpp
|
canon/plan.cpp
|
||||||
canon/project.cpp
|
canon/project.cpp
|
||||||
|
canon/sync.cpp
|
||||||
canon/template.cpp
|
canon/template.cpp
|
||||||
canon/templates.cpp
|
canon/templates.cpp
|
||||||
canon/toml.cpp
|
canon/toml.cpp
|
||||||
@@ -40,7 +42,7 @@ if(BUILD_TESTING)
|
|||||||
enable_testing()
|
enable_testing()
|
||||||
|
|
||||||
# Unit tests live next to the code they test: canon/<unit>.test.cpp.
|
# Unit tests live next to the code they test: canon/<unit>.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)
|
add_executable(canon.${unit}.test canon/${unit}.test.cpp)
|
||||||
target_link_libraries(canon.${unit}.test PRIVATE canon.core)
|
target_link_libraries(canon.${unit}.test PRIVATE canon.core)
|
||||||
add_test(NAME canon/${unit}.test COMMAND canon.${unit}.test)
|
add_test(NAME canon/${unit}.test COMMAND canon.${unit}.test)
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ canon add dep Boost::filesystem --package Boost # when the package name diffe
|
|||||||
|
|
||||||
# Checks
|
# Checks
|
||||||
canon doctor # compare the files with .canon.toml and P1204R0
|
canon doctor # compare the files with .canon.toml and P1204R0
|
||||||
|
canon sync # rewrite CMakeLists.txt's managed block from .canon.toml
|
||||||
|
|
||||||
# Templates
|
# Templates
|
||||||
canon templates # what each generated file is made from
|
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
|
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
|
`add`, `doctor`, `sync` and `templates` work from any directory inside the
|
||||||
project builds and passes its tests out of the box:
|
project. Every generated project builds and passes its tests out of the box:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cmake -S . -B build
|
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:
|
updates the manifest and re-renders the whole block, so:
|
||||||
|
|
||||||
- anything **outside** the block is yours and is never touched;
|
- anything **outside** the block is yours and is never touched;
|
||||||
- anything you edit **inside** the block is replaced on the next `add`
|
- anything you edit **inside** the block is replaced on the next `add` or
|
||||||
(`canon doctor` warns when the block has been edited);
|
`sync` (`canon doctor` warns when the block has been edited);
|
||||||
- `.canon.toml` is rewritten by canon, so comments you add to it are dropped.
|
- `add` commands rewrite `.canon.toml`, so comments you add to it are dropped.
|
||||||
|
|
||||||
canon reads `.canon.toml` strictly: unknown keys (usually typos), invalid
|
canon reads `.canon.toml` strictly: unknown keys (usually typos), invalid
|
||||||
names, targets sharing a source directory and links to executables are errors
|
names, targets sharing a source directory and links to executables are errors
|
||||||
with a line number.
|
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
|
### Targets and P1204R0
|
||||||
|
|
||||||
P1204R0 describes a project as one library or one executable with a source
|
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/name.*` | naming rules (`lib` prefix, stem, namespace, keywords) |
|
||||||
| `canon/generate.*` | the manifest and plan for `canon new` |
|
| `canon/generate.*` | the manifest and plan for `canon new` |
|
||||||
| `canon/add.*` | the plans for every `canon add` command |
|
| `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/doctor.*` | the checks behind `canon doctor` |
|
||||||
| `canon/templates.*` | built-in templates, overrides from template directories, export |
|
| `canon/templates.*` | built-in templates, overrides from template directories, export |
|
||||||
| `canon/project.*` | finds and loads an existing project |
|
| `canon/project.*` | finds and loads an existing project |
|
||||||
@@ -165,6 +178,8 @@ them from the manifest.
|
|||||||
3. ✅ `add target`, `add test`, `add dep`, `doctor`
|
3. ✅ `add target`, `add test`, `add dep`, `doctor`
|
||||||
4. ✅ Template overrides: `templates`, `templates export`, project and user templates
|
4. ✅ Template overrides: `templates`, `templates export`, project and user templates
|
||||||
|
|
||||||
|
Since then: ✅ `sync`.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT, see [LICENSE](LICENSE).
|
MIT, see [LICENSE](LICENSE).
|
||||||
|
|||||||
+57
-1
@@ -1,14 +1,17 @@
|
|||||||
#include <canon/add.hpp>
|
#include <canon/add.hpp>
|
||||||
#include <canon/cli.hpp>
|
#include <canon/cli.hpp>
|
||||||
#include <canon/details/overloaded.hpp>
|
#include <canon/details/overloaded.hpp>
|
||||||
|
#include <canon/diff.hpp>
|
||||||
#include <canon/doctor.hpp>
|
#include <canon/doctor.hpp>
|
||||||
#include <canon/executor.hpp>
|
#include <canon/executor.hpp>
|
||||||
#include <canon/generate.hpp>
|
#include <canon/generate.hpp>
|
||||||
#include <canon/name.hpp>
|
#include <canon/name.hpp>
|
||||||
#include <canon/plan.hpp>
|
#include <canon/plan.hpp>
|
||||||
#include <canon/project.hpp>
|
#include <canon/project.hpp>
|
||||||
|
#include <canon/sync.hpp>
|
||||||
#include <canon/templates.hpp>
|
#include <canon/templates.hpp>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <exception>
|
#include <exception>
|
||||||
@@ -17,6 +20,7 @@
|
|||||||
#include <format>
|
#include <format>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <print>
|
#include <print>
|
||||||
|
#include <ranges>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <string_view>
|
#include <string_view>
|
||||||
@@ -65,7 +69,8 @@ std::string display_path(const fs::path& path) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Applies (or dry-runs) the plan and prints what changed.
|
// 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});
|
const auto report = canon::apply(plan, root, {.dry_run = dry_run});
|
||||||
if (!report) {
|
if (!report) {
|
||||||
std::println(stderr, "canon: {}", report.error());
|
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) {
|
for (const auto& line : *report) {
|
||||||
std::println("{}", line);
|
std::println("{}", line);
|
||||||
}
|
}
|
||||||
|
if (!details.empty()) {
|
||||||
|
std::print("\n{}", details);
|
||||||
|
}
|
||||||
if (dry_run) {
|
if (dry_run) {
|
||||||
std::println("\nDry run: nothing was written.");
|
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;
|
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<canon::update_file>(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) {
|
int run_templates(const canon::templates_command& cmd) {
|
||||||
const auto root = canon::find_project_root(cmd.directory);
|
const auto root = canon::find_project_root(cmd.directory);
|
||||||
const auto directories = template_directories(root);
|
const auto directories = template_directories(root);
|
||||||
@@ -333,6 +388,7 @@ int main(int argc, char* argv[]) try {
|
|||||||
target_suffix(cmd.request.target), rebuild));
|
target_suffix(cmd.request.target), rebuild));
|
||||||
},
|
},
|
||||||
[](const doctor_command& cmd) { return run_doctor(cmd); },
|
[](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 templates_command& cmd) { return run_templates(cmd); },
|
||||||
[](const export_templates_command& cmd) { return run_export_templates(cmd); },
|
[](const export_templates_command& cmd) { return run_export_templates(cmd); },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -203,6 +203,16 @@ std::expected<command, std::string> parse_doctor(positionals positional, const o
|
|||||||
return doctor_command{.directory = project_options_of(opts).directory};
|
return doctor_command{.directory = project_options_of(opts).directory};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::expected<command, std::string> 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<command, std::string> parse_templates(positionals positional, const options& opts) {
|
std::expected<command, std::string> parse_templates(positionals positional, const options& opts) {
|
||||||
if (positional.size() == 1) {
|
if (positional.size() == 1) {
|
||||||
if (auto ok = check_options(opts, "templates", {"--dir"}); !ok) {
|
if (auto ok = check_options(opts, "templates", {"--dir"}); !ok) {
|
||||||
@@ -283,6 +293,9 @@ std::expected<command, std::string> parse_command_line(std::span<const std::stri
|
|||||||
if (verb == "doctor") {
|
if (verb == "doctor") {
|
||||||
return parse_doctor(positional, opts);
|
return parse_doctor(positional, opts);
|
||||||
}
|
}
|
||||||
|
if (verb == "sync") {
|
||||||
|
return parse_sync(positional, opts);
|
||||||
|
}
|
||||||
if (verb == "templates") {
|
if (verb == "templates") {
|
||||||
return parse_templates(positional, opts);
|
return parse_templates(positional, opts);
|
||||||
}
|
}
|
||||||
@@ -309,6 +322,9 @@ Change the project you are in (run anywhere inside it):
|
|||||||
|
|
||||||
Check the project you are in:
|
Check the project you are in:
|
||||||
doctor Compare the files with .canon.toml and P1204R0
|
doctor Compare the files with .canon.toml and P1204R0
|
||||||
|
sync Rewrite the managed block of CMakeLists.txt from
|
||||||
|
.canon.toml after editing either by hand; shows
|
||||||
|
the changes (see them first with --dry-run)
|
||||||
|
|
||||||
Customize generated files:
|
Customize generated files:
|
||||||
templates List the templates, where each one comes from and
|
templates List the templates, where each one comes from and
|
||||||
|
|||||||
+5
-1
@@ -57,6 +57,10 @@ struct doctor_command {
|
|||||||
std::filesystem::path directory = ".";
|
std::filesystem::path directory = ".";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
struct sync_command {
|
||||||
|
project_options options{};
|
||||||
|
};
|
||||||
|
|
||||||
struct templates_command {
|
struct templates_command {
|
||||||
std::filesystem::path directory = "."; // where to look for a project's templates
|
std::filesystem::path directory = "."; // where to look for a project's templates
|
||||||
};
|
};
|
||||||
@@ -69,7 +73,7 @@ struct export_templates_command {
|
|||||||
|
|
||||||
using command = std::variant<help_command, version_command, new_command, add_module_command,
|
using command = std::variant<help_command, version_command, new_command, add_module_command,
|
||||||
add_unit_test_command, add_target_command, add_test_command, add_dep_command,
|
add_unit_test_command, add_target_command, add_test_command, add_dep_command,
|
||||||
doctor_command, templates_command, export_templates_command>;
|
doctor_command, sync_command, templates_command, export_templates_command>;
|
||||||
|
|
||||||
// Parses the arguments after the program name. Syntax is checked here,
|
// Parses the arguments after the program name. Syntax is checked here,
|
||||||
// including options a command does not take; names and paths are validated by
|
// including options a command does not take; names and paths are validated by
|
||||||
|
|||||||
@@ -103,6 +103,13 @@ int main() {
|
|||||||
CANON_CHECK_EQ(d->directory.generic_string(), "proj");
|
CANON_CHECK_EQ(d->directory.generic_string(), "proj");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (const auto s = as<sync_command>(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<templates_command>(parse({"templates", "--dir", "proj"}))) {
|
if (const auto t = as<templates_command>(parse({"templates", "--dir", "proj"}))) {
|
||||||
CANON_CHECK_EQ(t->directory.generic_string(), "proj");
|
CANON_CHECK_EQ(t->directory.generic_string(), "proj");
|
||||||
}
|
}
|
||||||
|
|||||||
+126
@@ -0,0 +1,126 @@
|
|||||||
|
#include <canon/diff.hpp>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <format>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace canon {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
std::vector<std::string_view> split_lines(std::string_view text) {
|
||||||
|
std::vector<std::string_view> 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<step> edit_script(const std::vector<std::string_view>& a, const std::vector<std::string_view>& b) {
|
||||||
|
const std::size_t n = a.size();
|
||||||
|
const std::size_t m = b.size();
|
||||||
|
std::vector<std::uint32_t> 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<step> 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::size_t>(std::ranges::count_if(
|
||||||
|
steps.begin() + static_cast<std::ptrdiff_t>(first), steps.begin() + static_cast<std::ptrdiff_t>(end),
|
||||||
|
[](const step& s) { return s.kind != edit::add; }));
|
||||||
|
const auto new_count = static_cast<std::size_t>(std::ranges::count_if(
|
||||||
|
steps.begin() + static_cast<std::ptrdiff_t>(first), steps.begin() + static_cast<std::ptrdiff_t>(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<char>(current.kind);
|
||||||
|
out += current.kind == edit::add ? b[current.after] : a[current.before];
|
||||||
|
out += '\n';
|
||||||
|
}
|
||||||
|
next = end;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace canon
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#include <canon/diff.hpp>
|
||||||
|
|
||||||
|
#include <canon/details/testing.hpp>
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
+2
-2
@@ -51,8 +51,8 @@ void check_managed_block(diagnosis& d, const project& p) {
|
|||||||
if (!rendered) {
|
if (!rendered) {
|
||||||
d.error("CMakeLists.txt", rendered.error());
|
d.error("CMakeLists.txt", rendered.error());
|
||||||
} else if (*rendered != p.cmakelists_text) {
|
} else if (*rendered != p.cmakelists_text) {
|
||||||
d.warning("CMakeLists.txt", "the managed block differs from .canon.toml (edited by hand?); the next "
|
d.warning("CMakeLists.txt", "the managed block differs from .canon.toml (edited by hand?); 'canon sync' "
|
||||||
"'canon add' will replace it");
|
"rewrites it from .canon.toml (add --dry-run to see the changes)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#include <canon/sync.hpp>
|
||||||
|
|
||||||
|
#include <canon/cmake.hpp>
|
||||||
|
|
||||||
|
#include <format>
|
||||||
|
|
||||||
|
namespace canon {
|
||||||
|
|
||||||
|
std::expected<plan, std::string> 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
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <canon/plan.hpp>
|
||||||
|
#include <canon/project.hpp>
|
||||||
|
|
||||||
|
#include <expected>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
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, std::string> plan_sync(const project&);
|
||||||
|
|
||||||
|
} // namespace canon
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
#include <canon/sync.hpp>
|
||||||
|
|
||||||
|
#include <canon/cmake.hpp>
|
||||||
|
#include <canon/details/testing.hpp>
|
||||||
|
#include <canon/generate.hpp>
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
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<update_file> the_update(const std::expected<plan, std::string>& result) {
|
||||||
|
CANON_CHECK(result && result->operations.size() == 1);
|
||||||
|
if (!result || result->operations.size() != 1) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
const auto* update = std::get_if<update_file>(&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();
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/build/
|
||||||
@@ -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 <<<
|
||||||
@@ -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,3 @@
|
|||||||
|
namespace hello {
|
||||||
|
int extra() { return 42; }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
+26
-1
@@ -4,7 +4,7 @@
|
|||||||
# canon runs with XDG_CONFIG_HOME set to <work_dir>/<scenario>-config, so user
|
# canon runs with XDG_CONFIG_HOME set to <work_dir>/<scenario>-config, so user
|
||||||
# templates in your own ~/.config never leak into the results.
|
# 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.
|
# Scenarios run canon from other directories, so a relative path would break.
|
||||||
get_filename_component(CANON "${CANON}" ABSOLUTE)
|
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)
|
canon_run_fails(${project} add module broken)
|
||||||
file(REMOVE ${config_home}/canon/templates/modul.hpp)
|
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()
|
else()
|
||||||
message(FATAL_ERROR "unknown scenario '${scenario}'")
|
message(FATAL_ERROR "unknown scenario '${scenario}'")
|
||||||
endif()
|
endif()
|
||||||
|
|||||||
Reference in New Issue
Block a user