diff --git a/CHANGELOG.md b/CHANGELOG.md index a2d2311..503a5c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,14 @@ All notable changes to cargoxx will be documented in this file. not show `buildInputs`; we add one between `nativeBuildInputs` and the `env.NIX_CFLAGS_COMPILE` block as the natural slot for the deps that TECH_SPEC §10 says we splice in. +- `codegen::cmake_lists(in) -> std::string` per `SPEC.md` §8 and + `TECH_SPEC.md` §9: `find_package` per dep, optional library target + with module units + private impl sources, primary binary + `_bin` linked against the library, additional binaries from + `src/bin/`, tests with `enable_testing()` + `add_test`, examples, + and `[build]` honoring `warnings_as_errors` and `sanitizers`. Source + paths emitted relative to `build/` (i.e. prefixed with `../`). + Output is deterministic. `tests/codegen_cmake.cpp` covers 11 cases. - SQLite overlay: `Database::open(overlay_path)` now opens (and creates, if missing) a per-user `linkdb.sqlite` cache, applying the schema from `SPEC.md` §9 idempotently. Default path is diff --git a/CMakeLists.txt b/CMakeLists.txt index c130a21..364446f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -49,6 +49,7 @@ target_sources(cargoxx src/linkdb/curated.cpp src/linkdb/overlay.cpp src/codegen/flake.cpp + src/codegen/cmake.cpp src/cli/cmd_new.cpp src/cli/run.cpp PUBLIC diff --git a/src/codegen/cmake.cpp b/src/codegen/cmake.cpp new file mode 100644 index 0000000..05e3daa --- /dev/null +++ b/src/codegen/cmake.cpp @@ -0,0 +1,289 @@ +module cargoxx.codegen; + +import std; +import cargoxx.manifest; +import cargoxx.layout; +import cargoxx.linkdb; +import cargoxx.lockfile; + +namespace cargoxx::codegen { + +namespace { + +namespace fs = std::filesystem; + +auto edition_to_int(manifest::Edition e) -> int { + switch (e) { + case manifest::Edition::Cpp20: + return 20; + case manifest::Edition::Cpp23: + return 23; + case manifest::Edition::Cpp26: + return 26; + } + return 23; +} + +auto rel_to_build(const fs::path& p, const fs::path& project_root) -> std::string { + return (fs::path{".."} / p.lexically_relative(project_root)).generic_string(); +} + +auto collect_dep_targets(const std::vector& recipes) + -> std::vector { + std::vector out; + for (const auto& r : recipes) { + for (const auto& t : r.targets) { + out.push_back(t); + } + } + return out; +} + +auto link_block(std::string_view target_name, std::string_view visibility, + bool include_lib, std::string_view package_name, + const std::vector& dep_targets) -> std::string { + if (!include_lib && dep_targets.empty()) { + return ""; + } + std::string out = std::format("target_link_libraries({} {}\n", target_name, visibility); + if (include_lib) { + out += std::format(" {}\n", package_name); + } + for (const auto& t : dep_targets) { + out += std::format(" {}\n", t); + } + out += ")\n"; + return out; +} + +auto emit_header(const manifest::Manifest& m) -> std::string { + return std::format( + "cmake_minimum_required(VERSION 3.30)\n" + "project({} LANGUAGES CXX)\n" + "\n" + "# Generated by cargoxx — do not edit.\n" + "# Source of truth: ../Cargoxx.toml\n" + "\n" + "# ----- toolchain configuration -----\n" + "set(CMAKE_CXX_STANDARD {})\n" + "set(CMAKE_CXX_STANDARD_REQUIRED ON)\n" + "set(CMAKE_CXX_EXTENSIONS OFF)\n" + "set(CMAKE_CXX_SCAN_FOR_MODULES ON)\n" + "set(CMAKE_EXPORT_COMPILE_COMMANDS ON)\n", + m.package.name, edition_to_int(m.package.edition)); +} + +auto emit_find_packages(const std::vector& recipes) -> std::string { + if (recipes.empty()) { + return {}; + } + std::string out = "\n# ----- dependencies -----\n"; + for (const auto& r : recipes) { + out += std::format("find_package({})\n", r.find_package); + } + return out; +} + +auto emit_library(const layout::Target& lib, const std::string& package_name, + const std::vector& recipes, + const fs::path& project_root) -> std::string { + std::string out = "\n# ----- library target -----\n"; + out += std::format("add_library({} STATIC)\n", package_name); + out += std::format("target_sources({}\n", package_name); + out += " PUBLIC\n"; + out += " FILE_SET CXX_MODULES FILES\n"; + for (const auto& m : lib.module_units) { + out += std::format(" {}\n", rel_to_build(m, project_root)); + } + if (!lib.additional_sources.empty()) { + out += " PRIVATE\n"; + for (const auto& s : lib.additional_sources) { + out += std::format(" {}\n", rel_to_build(s, project_root)); + } + } + out += ")\n"; + out += link_block(package_name, "PUBLIC", false, package_name, + collect_dep_targets(recipes)); + return out; +} + +auto emit_primary_binary(const layout::Target& bin, const std::string& package_name, + const std::vector& recipes, bool has_lib, + const fs::path& project_root) -> std::string { + std::string out = "\n# ----- binary target -----\n"; + out += std::format("add_executable({}_bin {})\n", package_name, + rel_to_build(bin.entry, project_root)); + out += std::format("set_target_properties({}_bin PROPERTIES OUTPUT_NAME {})\n", + package_name, package_name); + out += link_block(std::format("{}_bin", package_name), "PRIVATE", has_lib, package_name, + collect_dep_targets(recipes)); + return out; +} + +auto emit_extra_binary(const layout::Target& bin, const std::string& package_name, + const std::vector& recipes, bool has_lib, + const fs::path& project_root) -> std::string { + std::string out = std::format("\n# ----- binary target: {} -----\n", bin.name); + out += std::format("add_executable({} {})\n", bin.name, + rel_to_build(bin.entry, project_root)); + out += link_block(bin.name, "PRIVATE", has_lib, package_name, + collect_dep_targets(recipes)); + return out; +} + +auto emit_test(const layout::Target& t, const std::string& package_name, + const std::vector& recipes, bool has_lib, + const fs::path& project_root) -> std::string { + auto target = std::format("test_{}", t.name); + std::string out = std::format("add_executable({} {})\n", target, + rel_to_build(t.entry, project_root)); + out += link_block(target, "PRIVATE", has_lib, package_name, + collect_dep_targets(recipes)); + out += std::format("add_test(NAME {} COMMAND {})\n", t.name, target); + return out; +} + +auto emit_example(const layout::Target& e, const std::string& package_name, + const std::vector& recipes, bool has_lib, + const fs::path& project_root) -> std::string { + auto target = std::format("example_{}", e.name); + std::string out = std::format("add_executable({} {})\n", target, + rel_to_build(e.entry, project_root)); + out += link_block(target, "PRIVATE", has_lib, package_name, + collect_dep_targets(recipes)); + return out; +} + +auto find_primary_bin(const layout::DiscoveredLayout& layout) -> const layout::Target* { + for (const auto& b : layout.binaries) { + if (b.entry.filename() == "main.cpp" && + b.entry.parent_path().filename() == "src") { + return &b; + } + } + return nullptr; +} + +auto collect_all_target_names(const layout::DiscoveredLayout& layout, + const std::string& package_name) -> std::vector { + std::vector out; + if (layout.library) { + out.push_back(package_name); + } + if (find_primary_bin(layout) != nullptr) { + out.push_back(std::format("{}_bin", package_name)); + } + for (const auto& b : layout.binaries) { + if (b.entry.filename() == "main.cpp" && + b.entry.parent_path().filename() == "src") { + continue; + } + out.push_back(b.name); + } + for (const auto& t : layout.tests) { + out.push_back(std::format("test_{}", t.name)); + } + for (const auto& e : layout.examples) { + out.push_back(std::format("example_{}", e.name)); + } + return out; +} + +auto emit_build_flags(const manifest::BuildSettings& build, + const layout::DiscoveredLayout& layout, + const std::string& package_name) -> std::string { + if (!build.warnings_as_errors && build.sanitizers.empty()) { + return {}; + } + auto targets = collect_all_target_names(layout, package_name); + if (targets.empty()) { + return {}; + } + + std::string targets_list; + for (std::size_t i = 0; i < targets.size(); ++i) { + if (i > 0) { + targets_list += ' '; + } + targets_list += targets[i]; + } + + std::string out = "\n# ----- build flags from [build] section -----\n"; + + if (build.warnings_as_errors) { + out += std::format("foreach(target_name IN ITEMS {})\n", targets_list); + out += " target_compile_options(${target_name} PRIVATE -Wall -Wextra -Wpedantic " + "-Werror)\n"; + out += "endforeach()\n"; + } + + if (!build.sanitizers.empty()) { + std::string san_list; + for (std::size_t i = 0; i < build.sanitizers.size(); ++i) { + if (i > 0) { + san_list += ','; + } + san_list += build.sanitizers[i]; + } + out += std::format("foreach(target_name IN ITEMS {})\n", targets_list); + out += std::format(" target_compile_options(${{target_name}} PRIVATE " + "-fsanitize={})\n", + san_list); + out += std::format(" target_link_options(${{target_name}} PRIVATE -fsanitize={})\n", + san_list); + out += "endforeach()\n"; + } + + return out; +} + +} // namespace + +auto cmake_lists(const GenerateInputs& in) -> std::string { + const auto& pkg_name = in.manifest.package.name; + const bool has_lib = in.layout.library.has_value(); + + std::string out; + out += emit_header(in.manifest); + out += emit_find_packages(in.recipes); + + if (in.layout.library) { + out += emit_library(*in.layout.library, pkg_name, in.recipes, in.project_root); + } + + const auto* primary = find_primary_bin(in.layout); + if (primary) { + out += emit_primary_binary(*primary, pkg_name, in.recipes, has_lib, in.project_root); + } + + bool has_extra_bin = false; + for (const auto& b : in.layout.binaries) { + if (&b == primary) { + continue; + } + if (!has_extra_bin) { + has_extra_bin = true; + } + out += emit_extra_binary(b, pkg_name, in.recipes, has_lib, in.project_root); + } + + if (!in.layout.tests.empty()) { + out += "\n# ----- tests -----\nenable_testing()\n"; + for (const auto& t : in.layout.tests) { + out += emit_test(t, pkg_name, in.recipes, has_lib, in.project_root); + } + } + + if (!in.layout.examples.empty()) { + out += "\n# ----- examples -----\n"; + for (const auto& e : in.layout.examples) { + out += emit_example(e, pkg_name, in.recipes, has_lib, in.project_root); + } + } + + out += emit_build_flags(in.manifest.build, in.layout, pkg_name); + + return out; +} + +} // namespace cargoxx::codegen diff --git a/src/codegen/codegen.cppm b/src/codegen/codegen.cppm index 4932f06..bba2239 100644 --- a/src/codegen/codegen.cppm +++ b/src/codegen/codegen.cppm @@ -20,5 +20,6 @@ struct GenerateInputs { }; auto flake_nix(const GenerateInputs& in) -> std::string; +auto cmake_lists(const GenerateInputs& in) -> std::string; } // namespace cargoxx::codegen diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 9cdc10a..3e6ab28 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -16,4 +16,5 @@ cargoxx_add_test(lockfile_round_trip) cargoxx_add_test(linkdb_lookup) cargoxx_add_test(linkdb_overlay) cargoxx_add_test(codegen_flake) +cargoxx_add_test(codegen_cmake) cargoxx_add_test(cmd_new) diff --git a/tests/codegen_cmake.cpp b/tests/codegen_cmake.cpp new file mode 100644 index 0000000..64a840f --- /dev/null +++ b/tests/codegen_cmake.cpp @@ -0,0 +1,281 @@ +#include + +import cargoxx.codegen; +import cargoxx.manifest; +import cargoxx.layout; +import cargoxx.lockfile; +import cargoxx.linkdb; +import std; + +using cargoxx::codegen::cmake_lists; +using cargoxx::codegen::GenerateInputs; +using cargoxx::layout::DiscoveredLayout; +using cargoxx::layout::Target; +using cargoxx::layout::TargetKind; +using cargoxx::linkdb::Recipe; +using cargoxx::lockfile::Lockfile; +using cargoxx::lockfile::LockfilePackage; +using cargoxx::manifest::BuildSettings; +using cargoxx::manifest::Edition; +using cargoxx::manifest::Manifest; +using cargoxx::manifest::Package; + +namespace { + +constexpr std::string_view ROOT = "/proj"; + +auto pkg(std::string name, Edition ed = Edition::Cpp23) -> Package { + return Package{.name = std::move(name), + .version = "0.1.0", + .edition = ed, + .authors = {}, + .license = std::nullopt}; +} + +auto lock_minimal() -> Lockfile { return Lockfile{1, {}}; } + +auto src_target(TargetKind kind, std::string name, std::string rel_entry, + std::vector module_units = {}) -> Target { + Target t{ + .kind = kind, + .name = std::move(name), + .entry = std::filesystem::path{ROOT} / rel_entry, + .additional_sources = {}, + .module_units = {}, + }; + for (auto& u : module_units) { + t.module_units.push_back(std::filesystem::path{ROOT} / u); + } + return t; +} + +auto recipe(std::string find_pkg, std::vector targets) -> Recipe { + return Recipe{ + .nixpkgs_attr = "ignored", + .find_package = std::move(find_pkg), + .targets = std::move(targets), + .source = "curated", + }; +} + +} // namespace + +TEST_CASE("cmake_lists for a binary-only project", "[codegen][cmake]") { + Manifest m{pkg("hello"), {}, {}}; + DiscoveredLayout layout{ + .library = std::nullopt, + .binaries = {src_target(TargetKind::Binary, "hello", "src/main.cpp")}, + .tests = {}, + .examples = {}, + }; + Lockfile lock = lock_minimal(); + GenerateInputs in{m, layout, lock, {}, ROOT}; + + auto out = cmake_lists(in); + REQUIRE(out.find("project(hello LANGUAGES CXX)") != std::string::npos); + REQUIRE(out.find("set(CMAKE_CXX_STANDARD 23)") != std::string::npos); + REQUIRE(out.find("add_executable(hello_bin ../src/main.cpp)") != std::string::npos); + REQUIRE(out.find("set_target_properties(hello_bin PROPERTIES OUTPUT_NAME hello)") != + std::string::npos); + REQUIRE(out.find("add_library") == std::string::npos); + REQUIRE(out.find("enable_testing") == std::string::npos); +} + +TEST_CASE("cmake_lists for a library-only project", "[codegen][cmake]") { + Manifest m{pkg("widget"), {}, {}}; + DiscoveredLayout layout{ + .library = src_target(TargetKind::Library, "widget", "src/lib.cppm", + {"src/lib.cppm"}), + .binaries = {}, + .tests = {}, + .examples = {}, + }; + Lockfile lock = lock_minimal(); + GenerateInputs in{m, layout, lock, {}, ROOT}; + + auto out = cmake_lists(in); + REQUIRE(out.find("add_library(widget STATIC)") != std::string::npos); + REQUIRE(out.find("FILE_SET CXX_MODULES FILES") != std::string::npos); + REQUIRE(out.find("../src/lib.cppm") != std::string::npos); + REQUIRE(out.find("add_executable") == std::string::npos); +} + +TEST_CASE("cmake_lists wires up library + primary binary", "[codegen][cmake]") { + Manifest m{pkg("app"), {}, {}}; + DiscoveredLayout layout{ + .library = src_target(TargetKind::Library, "app", "src/lib.cppm", + {"src/lib.cppm"}), + .binaries = {src_target(TargetKind::Binary, "app", "src/main.cpp")}, + .tests = {}, + .examples = {}, + }; + Lockfile lock = lock_minimal(); + GenerateInputs in{m, layout, lock, {}, ROOT}; + + auto out = cmake_lists(in); + REQUIRE(out.find("add_library(app STATIC)") != std::string::npos); + REQUIRE(out.find("add_executable(app_bin ../src/main.cpp)") != std::string::npos); + // primary binary must link the library + auto bin_link = out.find("target_link_libraries(app_bin PRIVATE"); + REQUIRE(bin_link != std::string::npos); + auto end = out.find(')', bin_link); + auto block = out.substr(bin_link, end - bin_link); + REQUIRE(block.find("\n app\n") != std::string::npos); +} + +TEST_CASE("cmake_lists emits extra binaries from src/bin/", "[codegen][cmake]") { + Manifest m{pkg("app"), {}, {}}; + DiscoveredLayout layout{ + .library = std::nullopt, + .binaries = {src_target(TargetKind::Binary, "tool", "src/bin/tool.cpp"), + src_target(TargetKind::Binary, "app", "src/main.cpp")}, + .tests = {}, + .examples = {}, + }; + Lockfile lock = lock_minimal(); + GenerateInputs in{m, layout, lock, {}, ROOT}; + + auto out = cmake_lists(in); + REQUIRE(out.find("add_executable(app_bin ../src/main.cpp)") != std::string::npos); + REQUIRE(out.find("add_executable(tool ../src/bin/tool.cpp)") != std::string::npos); +} + +TEST_CASE("cmake_lists emits tests with add_test", "[codegen][cmake]") { + Manifest m{pkg("app"), {}, {}}; + DiscoveredLayout layout{ + .library = src_target(TargetKind::Library, "app", "src/lib.cppm", + {"src/lib.cppm"}), + .binaries = {}, + .tests = {src_target(TargetKind::Test, "basic", "tests/basic.cpp")}, + .examples = {}, + }; + Lockfile lock = lock_minimal(); + GenerateInputs in{m, layout, lock, {}, ROOT}; + + auto out = cmake_lists(in); + REQUIRE(out.find("enable_testing()") != std::string::npos); + REQUIRE(out.find("add_executable(test_basic ../tests/basic.cpp)") != std::string::npos); + REQUIRE(out.find("add_test(NAME basic COMMAND test_basic)") != std::string::npos); +} + +TEST_CASE("cmake_lists emits examples", "[codegen][cmake]") { + Manifest m{pkg("app"), {}, {}}; + DiscoveredLayout layout{ + .library = std::nullopt, + .binaries = {src_target(TargetKind::Binary, "app", "src/main.cpp")}, + .tests = {}, + .examples = {src_target(TargetKind::Example, "demo", "examples/demo.cpp")}, + }; + Lockfile lock = lock_minimal(); + GenerateInputs in{m, layout, lock, {}, ROOT}; + + auto out = cmake_lists(in); + REQUIRE(out.find("add_executable(example_demo ../examples/demo.cpp)") != + std::string::npos); +} + +TEST_CASE("cmake_lists emits find_package per dep", "[codegen][cmake]") { + Manifest m{pkg("app"), {}, {}}; + DiscoveredLayout layout{ + .library = std::nullopt, + .binaries = {src_target(TargetKind::Binary, "app", "src/main.cpp")}, + .tests = {}, + .examples = {}, + }; + Lockfile lock = lock_minimal(); + std::vector recipes = { + recipe("fmt CONFIG REQUIRED", {"fmt::fmt"}), + recipe("Boost REQUIRED COMPONENTS filesystem system", + {"Boost::filesystem", "Boost::system"}), + }; + GenerateInputs in{m, layout, lock, recipes, ROOT}; + + auto out = cmake_lists(in); + REQUIRE(out.find("find_package(fmt CONFIG REQUIRED)") != std::string::npos); + REQUIRE(out.find("find_package(Boost REQUIRED COMPONENTS filesystem system)") != + std::string::npos); + + auto link_block = out.find("target_link_libraries(app_bin PRIVATE"); + REQUIRE(link_block != std::string::npos); + auto end = out.find(')', link_block); + auto block = out.substr(link_block, end - link_block); + REQUIRE(block.find("fmt::fmt") != std::string::npos); + REQUIRE(block.find("Boost::filesystem") != std::string::npos); + REQUIRE(block.find("Boost::system") != std::string::npos); +} + +TEST_CASE("cmake_lists honors warnings_as_errors", "[codegen][cmake]") { + Manifest m{ + pkg("app"), + {}, + BuildSettings{.warnings_as_errors = true, .sanitizers = {}}, + }; + DiscoveredLayout layout{ + .library = std::nullopt, + .binaries = {src_target(TargetKind::Binary, "app", "src/main.cpp")}, + .tests = {}, + .examples = {}, + }; + Lockfile lock = lock_minimal(); + GenerateInputs in{m, layout, lock, {}, ROOT}; + + auto out = cmake_lists(in); + REQUIRE(out.find("foreach(target_name IN ITEMS app_bin)") != std::string::npos); + REQUIRE(out.find("-Wall -Wextra -Wpedantic -Werror") != std::string::npos); +} + +TEST_CASE("cmake_lists honors sanitizers", "[codegen][cmake]") { + Manifest m{ + pkg("app"), + {}, + BuildSettings{.warnings_as_errors = false, + .sanitizers = {"address", "undefined"}}, + }; + DiscoveredLayout layout{ + .library = std::nullopt, + .binaries = {src_target(TargetKind::Binary, "app", "src/main.cpp")}, + .tests = {}, + .examples = {}, + }; + Lockfile lock = lock_minimal(); + GenerateInputs in{m, layout, lock, {}, ROOT}; + + auto out = cmake_lists(in); + REQUIRE(out.find("-fsanitize=address,undefined") != std::string::npos); + REQUIRE(out.find("target_link_options(${target_name} PRIVATE -fsanitize=") != + std::string::npos); +} + +TEST_CASE("cmake_lists maps editions to standard numbers", "[codegen][cmake]") { + DiscoveredLayout layout{ + .library = std::nullopt, + .binaries = {src_target(TargetKind::Binary, "app", "src/main.cpp")}, + .tests = {}, + .examples = {}, + }; + Lockfile lock = lock_minimal(); + + Manifest m20{pkg("app", Edition::Cpp20), {}, {}}; + REQUIRE(cmake_lists(GenerateInputs{m20, layout, lock, {}, ROOT}) + .find("set(CMAKE_CXX_STANDARD 20)") != std::string::npos); + + Manifest m26{pkg("app", Edition::Cpp26), {}, {}}; + REQUIRE(cmake_lists(GenerateInputs{m26, layout, lock, {}, ROOT}) + .find("set(CMAKE_CXX_STANDARD 26)") != std::string::npos); +} + +TEST_CASE("cmake_lists is deterministic", "[codegen][cmake]") { + Manifest m{pkg("app"), {}, {}}; + DiscoveredLayout layout{ + .library = src_target(TargetKind::Library, "app", "src/lib.cppm", + {"src/lib.cppm"}), + .binaries = {src_target(TargetKind::Binary, "app", "src/main.cpp")}, + .tests = {src_target(TargetKind::Test, "basic", "tests/basic.cpp")}, + .examples = {}, + }; + Lockfile lock = lock_minimal(); + std::vector recipes = {recipe("fmt CONFIG REQUIRED", {"fmt::fmt"})}; + GenerateInputs in{m, layout, lock, recipes, ROOT}; + + REQUIRE(cmake_lists(in) == cmake_lists(in)); +}