651fa4d89b
- add target lib|exe <name>: another library or executable in a source directory named after it (P1204R0's rule applied per target); a new executable links the project's library - add test <name>: tests/<name>/driver.cpp for a library, or a run of an executable, with --arg and --expect - add dep <dependency>: link a library of this project (no cycles, no executables) or an installed package's imported target such as fmt::fmt, adding its find_package() - doctor: read-only check of the files against .canon.toml and the P1204R0 layout; errors for missing declared files or broken markers, warnings for unbuilt sources, undeclared tests, a hand-edited managed block, .h/.cc extensions and include/ or src/ directories - every add command takes --target; the CLI rejects options a command does not take - manifest: packages, test args, and validation of target names, shared source directories and dependencies - templates moved to canon/templates.hpp - tests: multi-target and exe-tests scenarios; every scenario ends with canon doctor; CANON_CHECK accepts expressions containing commas; the CLI test no longer reads a destroyed temporary Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
230 lines
8.7 KiB
C++
230 lines
8.7 KiB
C++
#include <canon/add.hpp>
|
|
#include <canon/cli.hpp>
|
|
#include <canon/details/overloaded.hpp>
|
|
#include <canon/doctor.hpp>
|
|
#include <canon/executor.hpp>
|
|
#include <canon/generate.hpp>
|
|
#include <canon/name.hpp>
|
|
#include <canon/plan.hpp>
|
|
#include <canon/project.hpp>
|
|
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <exception>
|
|
#include <expected>
|
|
#include <filesystem>
|
|
#include <format>
|
|
#include <print>
|
|
#include <string>
|
|
#include <string_view>
|
|
#include <vector>
|
|
|
|
namespace fs = std::filesystem;
|
|
|
|
namespace {
|
|
|
|
constexpr int usage_error = 2;
|
|
|
|
// Quotes a path for pasting into a shell, only when it needs it.
|
|
std::string shell_quote(const std::string& s) {
|
|
const bool safe = !s.empty() && s.find_first_not_of("abcdefghijklmnopqrstuvwxyz"
|
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
|
"0123456789/._-+") == std::string::npos;
|
|
if (safe) {
|
|
return s;
|
|
}
|
|
std::string out = "'";
|
|
for (const char c : s) {
|
|
out += c == '\'' ? std::string("'\\''") : std::string(1, c);
|
|
}
|
|
return out + "'";
|
|
}
|
|
|
|
// Absolute path for messages, with the home directory shown as ~.
|
|
std::string display_path(const fs::path& path) {
|
|
std::error_code error;
|
|
const std::string absolute = fs::absolute(path, error).lexically_normal().string();
|
|
if (error) {
|
|
return path.string();
|
|
}
|
|
const char* home_env = std::getenv("HOME");
|
|
if (home_env == nullptr || *home_env == '\0') {
|
|
return absolute;
|
|
}
|
|
std::string home = fs::path(home_env).lexically_normal().string();
|
|
if (home.size() > 1 && home.ends_with('/')) {
|
|
home.pop_back();
|
|
}
|
|
if (absolute == home || absolute.starts_with(home + "/")) {
|
|
return "~" + absolute.substr(home.size());
|
|
}
|
|
return absolute;
|
|
}
|
|
|
|
// Applies (or dry-runs) the plan and prints what changed.
|
|
bool apply_and_report(const canon::plan& plan, const fs::path& root, bool dry_run) {
|
|
const auto report = canon::apply(plan, root, {.dry_run = dry_run});
|
|
if (!report) {
|
|
std::println(stderr, "canon: {}", report.error());
|
|
return false;
|
|
}
|
|
for (const auto& line : *report) {
|
|
std::println("{}", line);
|
|
}
|
|
if (dry_run) {
|
|
std::println("\nDry run: nothing was written.");
|
|
}
|
|
return true;
|
|
}
|
|
|
|
int run_new(const canon::new_command& cmd) {
|
|
const auto name = canon::parse_project_name(cmd.kind, cmd.name);
|
|
if (!name) {
|
|
std::println(stderr, "canon: {}", name.error());
|
|
return usage_error;
|
|
}
|
|
|
|
const fs::path& directory = cmd.options.directory;
|
|
std::error_code ignored;
|
|
if (!fs::is_directory(directory, ignored)) {
|
|
std::println(stderr, "canon: directory {} does not exist", directory.string());
|
|
return EXIT_FAILURE;
|
|
}
|
|
const fs::path destination = (directory / name->name).lexically_normal();
|
|
if (fs::exists(destination, ignored) &&
|
|
!(fs::is_directory(destination, ignored) && fs::is_empty(destination, ignored))) {
|
|
std::println(stderr, "canon: {} already exists and is not an empty directory", destination.string());
|
|
return EXIT_FAILURE;
|
|
}
|
|
|
|
if (!apply_and_report(canon::generate_new_project(*name), directory, cmd.options.dry_run)) {
|
|
return EXIT_FAILURE;
|
|
}
|
|
if (cmd.options.dry_run) {
|
|
return EXIT_SUCCESS;
|
|
}
|
|
const std::string_view kind = cmd.kind == canon::project_kind::library ? "library" : "executable";
|
|
std::println("\nCreated {} {} in {}.\n\nNext:\n cd {}\n cmake -S . -B build\n"
|
|
" cmake --build build\n ctest --test-dir build --output-on-failure",
|
|
kind, name->name, display_path(destination), shell_quote(destination.string()));
|
|
return EXIT_SUCCESS;
|
|
}
|
|
|
|
// Shared by the add commands: load the project, plan, apply, report.
|
|
template <class Planner>
|
|
int run_add(const canon::project_options& options, Planner make_plan, std::string_view done) {
|
|
const auto project = canon::load_project(options.directory);
|
|
if (!project) {
|
|
std::println(stderr, "canon: {}", project.error());
|
|
return EXIT_FAILURE;
|
|
}
|
|
const std::expected<canon::plan, std::string> plan = make_plan(*project);
|
|
if (!plan) {
|
|
std::println(stderr, "canon: {}", plan.error());
|
|
return EXIT_FAILURE;
|
|
}
|
|
|
|
std::println("{} ({}):", project->declared.project.name, display_path(project->root));
|
|
if (!apply_and_report(*plan, project->root, options.dry_run)) {
|
|
return EXIT_FAILURE;
|
|
}
|
|
if (!options.dry_run) {
|
|
std::println("\n{}", done);
|
|
}
|
|
return EXIT_SUCCESS;
|
|
}
|
|
|
|
int run_doctor(const canon::doctor_command& cmd) {
|
|
const auto project = canon::load_project(cmd.directory);
|
|
if (!project) {
|
|
std::println(stderr, "canon: {}", project.error());
|
|
return EXIT_FAILURE;
|
|
}
|
|
const auto findings = canon::diagnose(*project, canon::list_project_files(project->root));
|
|
|
|
std::println("{} ({}):", project->declared.project.name, display_path(project->root));
|
|
if (findings.empty()) {
|
|
std::println(" No problems found.");
|
|
return EXIT_SUCCESS;
|
|
}
|
|
|
|
int errors = 0;
|
|
for (const canon::finding& f : findings) {
|
|
errors += f.level == canon::severity::error ? 1 : 0;
|
|
std::println(" {:<8} {}: {}", canon::to_string(f.level), f.path, f.message);
|
|
}
|
|
const auto warnings = static_cast<int>(findings.size()) - errors;
|
|
std::println("\n{} {}, {} {}.", errors, errors == 1 ? "error" : "errors", warnings,
|
|
warnings == 1 ? "warning" : "warnings");
|
|
return errors > 0 ? EXIT_FAILURE : EXIT_SUCCESS;
|
|
}
|
|
|
|
constexpr std::string_view rebuild = "Rebuild with: cmake --build build";
|
|
|
|
std::string target_suffix(const std::string& target) {
|
|
return target.empty() ? "" : std::format(" to {}", target);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main(int argc, char* argv[]) try {
|
|
const std::vector<std::string_view> args(argv + 1, argv + argc);
|
|
const auto command = canon::parse_command_line(args);
|
|
if (!command) {
|
|
std::println(stderr, "canon: {}\nRun 'canon --help' for usage.", command.error());
|
|
return usage_error;
|
|
}
|
|
|
|
using namespace canon;
|
|
return std::visit(
|
|
details::overloaded{
|
|
[](const help_command&) {
|
|
std::print("{}", usage());
|
|
return EXIT_SUCCESS;
|
|
},
|
|
[](const version_command&) {
|
|
std::println("canon {}", CANON_VERSION);
|
|
return EXIT_SUCCESS;
|
|
},
|
|
[](const new_command& cmd) { return run_new(cmd); },
|
|
[](const add_module_command& cmd) {
|
|
return run_add(
|
|
cmd.options, [&](const project& p) { return plan_add_module(p, cmd.request); },
|
|
std::format("Added module {}{}. {}", cmd.request.path, target_suffix(cmd.request.target),
|
|
rebuild));
|
|
},
|
|
[](const add_unit_test_command& cmd) {
|
|
return run_add(
|
|
cmd.options, [&](const project& p) { return plan_add_unit_test(p, cmd.request); },
|
|
std::format("Added a unit test for module {}. {}", cmd.request.path, rebuild));
|
|
},
|
|
[](const add_target_command& cmd) {
|
|
const bool library = cmd.request.kind == project_kind::library;
|
|
return run_add(
|
|
cmd.options, [&](const project& p) { return plan_add_target(p, cmd.request); },
|
|
library ? std::format("Added library {}. Link it into another target with:\n"
|
|
" canon add dep {} --target <target>",
|
|
cmd.request.name, cmd.request.name)
|
|
: std::format("Added executable {}. {}", cmd.request.name, rebuild));
|
|
},
|
|
[](const add_test_command& cmd) {
|
|
return run_add(
|
|
cmd.options, [&](const project& p) { return plan_add_test(p, cmd.request); },
|
|
std::format("Added test {}. Build and run it with:\n"
|
|
" cmake --build build && ctest --test-dir build -R '^{}$'",
|
|
cmd.request.name, cmd.request.name));
|
|
},
|
|
[](const add_dep_command& cmd) {
|
|
return run_add(
|
|
cmd.options, [&](const project& p) { return plan_add_dep(p, cmd.request); },
|
|
std::format("Added dependency {}{}. {}", cmd.request.dependency,
|
|
target_suffix(cmd.request.target), rebuild));
|
|
},
|
|
[](const doctor_command& cmd) { return run_doctor(cmd); },
|
|
},
|
|
*command);
|
|
} catch (const std::exception& e) {
|
|
std::println(stderr, "canon: internal error: {}", e.what());
|
|
return EXIT_FAILURE;
|
|
}
|