diff --git a/src/cli/cmd_build.cpp b/src/cli/cmd_build.cpp index 07cb1cf..e85871e 100644 --- a/src/cli/cmd_build.cpp +++ b/src/cli/cmd_build.cpp @@ -168,7 +168,14 @@ auto cmd_build(const fs::path& project_root, bool no_build, bool release, } auto lock = merge_lockfile(*m, recipes, prior); - codegen::GenerateInputs in{*m, *layout_result, lock, recipes, project_root}; + codegen::GenerateInputs in{ + .manifest = *m, + .layout = *layout_result, + .lock = lock, + .recipes = recipes, + .dev_recipes = {}, + .project_root = project_root, + }; auto flake_text = codegen::flake_nix(in); auto cmake_text = codegen::cmake_lists(in); diff --git a/src/codegen/cmake.cpp b/src/codegen/cmake.cpp index ccfc111..1712c9f 100644 --- a/src/codegen/cmake.cpp +++ b/src/codegen/cmake.cpp @@ -60,8 +60,6 @@ auto emit_header(const manifest::Manifest& m) -> std::string { return std::format( "cmake_minimum_required(VERSION 3.30)\n" "\n" - "# Opt into experimental C++ modules dyndep + `import std;` support.\n" - "# Required until CMake declares these stable.\n" "set(CMAKE_EXPERIMENTAL_CXX_MODULE_DYNDEP 1)\n" "set(CMAKE_EXPERIMENTAL_CXX_IMPORT_STD \"d0edc3af-4c50-42ea-a356-e2862fe7a444\")\n" "set(CMAKE_CXX_MODULE_STD ON)\n" @@ -71,29 +69,43 @@ auto emit_header(const manifest::Manifest& m) -> std::string { "# 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" - "# EXTENSIONS=ON for libc++ std-module compatibility (clang 21).\n" "set(CMAKE_CXX_EXTENSIONS ON)\n" "set(CMAKE_CXX_SCAN_FOR_MODULES ON)\n" - "set(CMAKE_EXPORT_COMPILE_COMMANDS ON)\n", + "set(CMAKE_EXPORT_COMPILE_COMMANDS ON)\n" + "\n" + "add_compile_options(-Wall -Wextra -Wpedantic -Wconversion)\n", m.package.name, edition_to_int(m.package.edition)); } -auto emit_find_packages(const std::vector& recipes) -> std::string { - if (recipes.empty()) { +auto emit_find_packages(const std::vector& recipes, + const std::vector& dev_recipes) + -> std::string { + if (recipes.empty() && dev_recipes.empty()) { return {}; } std::string out = "\n# ----- dependencies -----\n"; for (const auto& r : recipes) { out += std::format("find_package({})\n", r.find_package); } + for (const auto& r : dev_recipes) { + out += std::format("find_package({})\n", r.find_package); + } return out; } +auto recipe_is_catch2(const linkdb::Recipe& r) -> bool { + return r.find_package.starts_with("Catch2 "); +} + +auto any_recipe_is_catch2(const std::vector& dev_recipes) -> bool { + return std::ranges::any_of(dev_recipes, recipe_is_catch2); +} + auto emit_library(const layout::Target& lib, const std::string& package_name, const std::vector& recipes, + const std::vector& include_dirs, const fs::path& project_root) -> std::string { std::string out = "\n# ----- library target -----\n"; out += std::format("add_library({} STATIC)\n", package_name); @@ -110,6 +122,13 @@ auto emit_library(const layout::Target& lib, const std::string& package_name, } } out += ")\n"; + if (!include_dirs.empty()) { + out += std::format("target_include_directories({} SYSTEM PRIVATE", package_name); + for (const auto& d : include_dirs) { + out += std::format(" ../{}", d); + } + out += ")\n"; + } out += link_block(package_name, "PUBLIC", false, package_name, collect_dep_targets(recipes)); return out; @@ -140,14 +159,22 @@ auto emit_extra_binary(const layout::Target& bin, const std::string& package_nam } 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 { + const std::vector& recipes, + const std::vector& dev_recipes, bool has_lib, + bool use_catch_discover, 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); + std::vector link_targets = collect_dep_targets(recipes); + for (const auto& dt : collect_dep_targets(dev_recipes)) { + link_targets.push_back(dt); + } + out += link_block(target, "PRIVATE", has_lib, package_name, link_targets); + if (use_catch_discover) { + out += std::format("catch_discover_tests({})\n", target); + } else { + out += std::format("add_test(NAME {} COMMAND {})\n", t.name, target); + } return out; } @@ -250,13 +277,15 @@ auto emit_build_flags(const manifest::BuildSettings& build, 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(); + const bool use_catch_discover = any_recipe_is_catch2(in.dev_recipes); std::string out; out += emit_header(in.manifest); - out += emit_find_packages(in.recipes); + out += emit_find_packages(in.recipes, in.dev_recipes); if (in.layout.library) { - out += emit_library(*in.layout.library, pkg_name, in.recipes, in.project_root); + out += emit_library(*in.layout.library, pkg_name, in.recipes, + in.manifest.build.include_dirs, in.project_root); } const auto* primary = find_primary_bin(in.layout); @@ -277,8 +306,12 @@ auto cmake_lists(const GenerateInputs& in) -> std::string { if (!in.layout.tests.empty()) { out += "\n# ----- tests -----\nenable_testing()\n"; + if (use_catch_discover) { + out += "include(Catch)\n"; + } for (const auto& t : in.layout.tests) { - out += emit_test(t, pkg_name, in.recipes, has_lib, in.project_root); + out += emit_test(t, pkg_name, in.recipes, in.dev_recipes, has_lib, + use_catch_discover, in.project_root); } } diff --git a/src/codegen/codegen.cppm b/src/codegen/codegen.cppm index bba2239..23e6783 100644 --- a/src/codegen/codegen.cppm +++ b/src/codegen/codegen.cppm @@ -16,6 +16,7 @@ struct GenerateInputs { const layout::DiscoveredLayout& layout; const lockfile::Lockfile& lock; std::vector recipes; // one per manifest dep, same order + std::vector dev_recipes; // one per dev_dependency, same order std::filesystem::path project_root; }; diff --git a/tests/codegen_cmake.cpp b/tests/codegen_cmake.cpp index b64992f..73c3f73 100644 --- a/tests/codegen_cmake.cpp +++ b/tests/codegen_cmake.cpp @@ -69,7 +69,7 @@ TEST_CASE("cmake_lists for a binary-only project", "[codegen][cmake]") { .examples = {}, }; Lockfile lock = lock_minimal(); - GenerateInputs in{m, layout, lock, {}, ROOT}; + GenerateInputs in{m, layout, lock, {}, {}, ROOT}; auto out = cmake_lists(in); REQUIRE(out.find("project(hello LANGUAGES CXX)") != std::string::npos); @@ -91,7 +91,7 @@ TEST_CASE("cmake_lists for a library-only project", "[codegen][cmake]") { .examples = {}, }; Lockfile lock = lock_minimal(); - GenerateInputs in{m, layout, lock, {}, ROOT}; + GenerateInputs in{m, layout, lock, {}, {}, ROOT}; auto out = cmake_lists(in); REQUIRE(out.find("add_library(widget STATIC)") != std::string::npos); @@ -110,7 +110,7 @@ TEST_CASE("cmake_lists wires up library + primary binary", "[codegen][cmake]") { .examples = {}, }; Lockfile lock = lock_minimal(); - GenerateInputs in{m, layout, lock, {}, ROOT}; + GenerateInputs in{m, layout, lock, {}, {}, ROOT}; auto out = cmake_lists(in); REQUIRE(out.find("add_library(app STATIC)") != std::string::npos); @@ -133,7 +133,7 @@ TEST_CASE("cmake_lists emits extra binaries from src/bin/", "[codegen][cmake]") .examples = {}, }; Lockfile lock = lock_minimal(); - GenerateInputs in{m, layout, lock, {}, ROOT}; + GenerateInputs in{m, layout, lock, {}, {}, ROOT}; auto out = cmake_lists(in); REQUIRE(out.find("add_executable(app_bin ../src/main.cpp)") != std::string::npos); @@ -150,7 +150,7 @@ TEST_CASE("cmake_lists emits tests with add_test", "[codegen][cmake]") { .examples = {}, }; Lockfile lock = lock_minimal(); - GenerateInputs in{m, layout, lock, {}, ROOT}; + GenerateInputs in{m, layout, lock, {}, {}, ROOT}; auto out = cmake_lists(in); REQUIRE(out.find("enable_testing()") != std::string::npos); @@ -167,7 +167,7 @@ TEST_CASE("cmake_lists emits examples", "[codegen][cmake]") { .examples = {src_target(TargetKind::Example, "demo", "examples/demo.cpp")}, }; Lockfile lock = lock_minimal(); - GenerateInputs in{m, layout, lock, {}, ROOT}; + GenerateInputs in{m, layout, lock, {}, {}, ROOT}; auto out = cmake_lists(in); REQUIRE(out.find("add_executable(example_demo ../examples/demo.cpp)") != @@ -188,7 +188,7 @@ TEST_CASE("cmake_lists emits find_package per dep", "[codegen][cmake]") { recipe("Boost REQUIRED COMPONENTS filesystem system", {"Boost::filesystem", "Boost::system"}), }; - GenerateInputs in{m, layout, lock, recipes, ROOT}; + GenerateInputs in{m, layout, lock, recipes, {}, ROOT}; auto out = cmake_lists(in); REQUIRE(out.find("find_package(fmt CONFIG REQUIRED)") != std::string::npos); @@ -216,7 +216,7 @@ TEST_CASE("cmake_lists honors warnings_as_errors", "[codegen][cmake]") { .examples = {}, }; Lockfile lock = lock_minimal(); - GenerateInputs in{m, layout, lock, {}, ROOT}; + GenerateInputs in{m, layout, lock, {}, {}, ROOT}; auto out = cmake_lists(in); REQUIRE(out.find("foreach(target_name IN ITEMS app_bin)") != std::string::npos); @@ -236,7 +236,7 @@ TEST_CASE("cmake_lists honors sanitizers", "[codegen][cmake]") { .examples = {}, }; Lockfile lock = lock_minimal(); - GenerateInputs in{m, layout, lock, {}, ROOT}; + GenerateInputs in{m, layout, lock, {}, {}, ROOT}; auto out = cmake_lists(in); REQUIRE(out.find("-fsanitize=address,undefined") != std::string::npos); @@ -254,11 +254,11 @@ TEST_CASE("cmake_lists maps editions to standard numbers", "[codegen][cmake]") { Lockfile lock = lock_minimal(); Manifest m20{pkg("app", Edition::Cpp20), {}, {}}; - REQUIRE(cmake_lists(GenerateInputs{m20, layout, lock, {}, ROOT}) + 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}) + REQUIRE(cmake_lists(GenerateInputs{m26, layout, lock, {}, {}, ROOT}) .find("set(CMAKE_CXX_STANDARD 26)") != std::string::npos); } @@ -273,7 +273,72 @@ TEST_CASE("cmake_lists is deterministic", "[codegen][cmake]") { }; Lockfile lock = lock_minimal(); std::vector recipes = {recipe("fmt CONFIG REQUIRED", {"fmt::fmt"})}; - GenerateInputs in{m, layout, lock, recipes, ROOT}; + GenerateInputs in{m, layout, lock, recipes, {}, ROOT}; REQUIRE(cmake_lists(in) == cmake_lists(in)); } + +TEST_CASE("cmake_lists emits baseline warnings", "[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(); + GenerateInputs in{m, layout, lock, {}, {}, ROOT}; + + auto out = cmake_lists(in); + REQUIRE(out.find("add_compile_options(-Wall -Wextra -Wpedantic -Wconversion)") != + std::string::npos); +} + +TEST_CASE("cmake_lists emits target_include_directories for [build].include_dirs", + "[codegen][cmake]") { + Manifest m{ + .package = pkg("widget"), + .build = BuildSettings{.include_dirs = {"third_party", "vendor/json"}}, + }; + 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("target_include_directories(widget SYSTEM PRIVATE " + "../third_party ../vendor/json)") != std::string::npos); +} + +TEST_CASE("cmake_lists threads dev_recipes through find_package and tests", + "[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(); + std::vector dev_recipes = { + recipe("Catch2 CONFIG REQUIRED", {"Catch2::Catch2WithMain"}), + }; + GenerateInputs in{m, layout, lock, {}, dev_recipes, ROOT}; + + auto out = cmake_lists(in); + REQUIRE(out.find("find_package(Catch2 CONFIG REQUIRED)") != std::string::npos); + REQUIRE(out.find("include(Catch)") != std::string::npos); + REQUIRE(out.find("catch_discover_tests(test_basic)") != std::string::npos); + REQUIRE(out.find("add_test(NAME basic") == std::string::npos); + auto link = out.find("target_link_libraries(test_basic PRIVATE"); + REQUIRE(link != std::string::npos); + auto end = out.find(')', link); + auto block = out.substr(link, end - link); + REQUIRE(block.find("Catch2::Catch2WithMain") != std::string::npos); +} diff --git a/tests/codegen_flake.cpp b/tests/codegen_flake.cpp index 0ad357a..766841b 100644 --- a/tests/codegen_flake.cpp +++ b/tests/codegen_flake.cpp @@ -78,7 +78,7 @@ TEST_CASE("flake_nix always emits the shared nixos-unstable nixpkgs input", Manifest m{pkg("hello"), {}, {}}; DiscoveredLayout layout{}; Lockfile lock{1, {root_pkg("hello", "0.1.0")}}; - GenerateInputs in{m, layout, lock, {}, "/tmp/hello"}; + GenerateInputs in{m, layout, lock, {}, {}, "/tmp/hello"}; auto out = flake_nix(in); REQUIRE(out.find("description = \"hello\";") != std::string::npos); @@ -96,7 +96,7 @@ TEST_CASE("flake_nix emits a per-pinned-dep nixpkgs input", "[codegen][flake]") dep_pkg("fmt", "10.2.1", "abc123def456"), }}; std::vector recipes = {recipe("fmt_10")}; - GenerateInputs in{m, layout, lock, recipes, "/tmp/app"}; + GenerateInputs in{m, layout, lock, recipes, {}, "/tmp/app"}; auto out = flake_nix(in); // Per-dep input attribute @@ -117,7 +117,7 @@ TEST_CASE("flake_nix uses shared `pkgs` for unpinned deps", DiscoveredLayout layout{}; Lockfile lock{1, {root_pkg("app", "0.1.0"), dep_pkg("fmt", "*", std::nullopt)}}; std::vector recipes = {recipe("fmt_10")}; - GenerateInputs in{m, layout, lock, recipes, "/tmp/app"}; + GenerateInputs in{m, layout, lock, recipes, {}, "/tmp/app"}; auto out = flake_nix(in); REQUIRE(out.find("pkgs.fmt_10") != std::string::npos); @@ -133,7 +133,7 @@ TEST_CASE("flake_nix mixes pinned and unpinned deps", "[codegen][flake]") { dep_pkg("zlib", "*", std::nullopt), }}; std::vector recipes = {recipe("fmt_10"), recipe("zlib")}; - GenerateInputs in{m, layout, lock, recipes, "/tmp/app"}; + GenerateInputs in{m, layout, lock, recipes, {}, "/tmp/app"}; auto out = flake_nix(in); REQUIRE(out.find("pkgs_nixpkgs_fmt_10_2_1.fmt_10") != std::string::npos); @@ -145,7 +145,7 @@ TEST_CASE("flake_nix emits an empty buildInputs list when there are no deps", Manifest m{pkg("hello"), {}, {}}; DiscoveredLayout layout{}; Lockfile lock{1, {root_pkg("hello", "0.1.0")}}; - GenerateInputs in{m, layout, lock, {}, "/tmp/hello"}; + GenerateInputs in{m, layout, lock, {}, {}, "/tmp/hello"}; auto out = flake_nix(in); REQUIRE(out.find("buildInputs = [\n ];") != std::string::npos); @@ -162,7 +162,7 @@ TEST_CASE("flake_nix dedupes deps that share input + attr", dep_pkg("boost", "1.84.0", "rev42"), }}; std::vector recipes = {recipe("boost"), recipe("boost")}; - GenerateInputs in{m, layout, lock, recipes, "/tmp/app"}; + GenerateInputs in{m, layout, lock, recipes, {}, "/tmp/app"}; auto out = flake_nix(in); auto first = out.find("pkgs_nixpkgs_boost_1_84_0.boost"); @@ -180,7 +180,7 @@ TEST_CASE("flake_nix produces deterministic output", "[codegen][flake]") { dep_pkg("spdlog", "*", std::nullopt), }}; std::vector recipes = {recipe("fmt_10"), recipe("spdlog")}; - GenerateInputs in{m, layout, lock, recipes, "/tmp/app"}; + GenerateInputs in{m, layout, lock, recipes, {}, "/tmp/app"}; REQUIRE(flake_nix(in) == flake_nix(in)); } @@ -189,7 +189,7 @@ TEST_CASE("flake_nix output ends with a newline", "[codegen][flake]") { Manifest m{pkg("hello"), {}, {}}; DiscoveredLayout layout{}; Lockfile lock{1, {root_pkg("hello", "0.1.0")}}; - GenerateInputs in{m, layout, lock, {}, "/tmp/hello"}; + GenerateInputs in{m, layout, lock, {}, {}, "/tmp/hello"}; auto out = flake_nix(in); REQUIRE_FALSE(out.empty()); @@ -205,7 +205,7 @@ TEST_CASE("flake_nix sanitizes hyphens and dots in dep names", dep_pkg("range-v3", "0.12.0", "rev123"), }}; std::vector recipes = {recipe("range-v3")}; - GenerateInputs in{m, layout, lock, recipes, "/tmp/app"}; + GenerateInputs in{m, layout, lock, recipes, {}, "/tmp/app"}; auto out = flake_nix(in); REQUIRE(out.find("nixpkgs_range_v3_0_12_0.url = "