diff --git a/src/resolver/nix_cmake_scan.cpp b/src/resolver/nix_cmake_scan.cpp index f2db13a..592a037 100644 --- a/src/resolver/nix_cmake_scan.cpp +++ b/src/resolver/nix_cmake_scan.cpp @@ -123,41 +123,82 @@ auto scan_imported_targets(std::string_view config_text) -> std::vector int { - auto eq_ci = [](std::string_view a, std::string_view b) { - if (a.size() != b.size()) { - return false; - } - for (std::size_t i = 0; i < a.size(); ++i) { - auto al = std::tolower(static_cast(a[i])); - auto bl = std::tolower(static_cast(b[i])); - if (al != bl) { - return false; - } - } - return true; +// Normalize a name for cross-shape comparison: +// 1. lowercase +// 2. drop a leading "lib" if the remainder is at least 3 chars long +// (handles `libllvm` → `llvm`, `libclang` → `clang`) +// 3. drop a trailing version-ish suffix `[-_]?[0-9][0-9._-]*` +// (handles `Boost-1.89.0` → `boost`, `boost_atomic-1.89.0` +// → `boost_atomic`); does nothing when no suffix is present +// 4. drop every remaining non-alphanumeric character so spelling +// variants like `gRPC`/`grpc` and `nlohmann_json`/`nlohmann-json` +// collapse together +auto normalize(std::string_view s) -> std::string { + std::string out; + out.reserve(s.size()); + for (char c : s) { + out += static_cast(std::tolower(static_cast(c))); + } + if (out.size() > 3 && out.starts_with("lib")) { + out.erase(0, 3); + } + // Trim trailing version suffix. Walk back over [0-9._-] then peel + // a single optional separator if there's a digit run preceding it. + auto is_vchar = [](char c) { + return std::isdigit(static_cast(c)) || c == '.' + || c == '-' || c == '_'; }; - if (eq_ci(stem, pkg)) { + std::size_t end = out.size(); + while (end > 0 && is_vchar(out[end - 1])) { + --end; + } + bool has_digit = false; + for (auto i = end; i < out.size(); ++i) { + if (std::isdigit(static_cast(out[i]))) { + has_digit = true; + break; + } + } + if (has_digit) { + out.erase(end); + if (!out.empty() && (out.back() == '-' || out.back() == '_')) { + out.pop_back(); + } + } + std::string compact; + compact.reserve(out.size()); + for (char c : out) { + if (std::isalnum(static_cast(c))) { + compact += c; + } + } + return compact; +} + +// Score a candidate against the queried package name. Lower is better. +// 0 — parent_dir or stem == pkg after normalization +// 1 — parent_dir or stem and pkg share a prefix in either direction +// (covers boost_ losing to a top-level Boost dir, +// and glfw matching glfw3) +// 2 — fallback: candidate has IMPORTED targets but no name overlap +auto match_score(std::string_view parent_dir, std::string_view stem, + std::string_view pkg) -> int { + auto p = normalize(parent_dir); + auto s = normalize(stem); + auto q = normalize(pkg); + if (q.empty()) { + return 2; + } + if (p == q || s == q) { return 0; } - auto starts_with_ci = [](std::string_view longer, std::string_view shorter) { - if (shorter.size() > longer.size()) { + auto prefix_either = [](const std::string& a, const std::string& b) { + if (a.empty() || b.empty()) { return false; } - for (std::size_t i = 0; i < shorter.size(); ++i) { - auto a = std::tolower(static_cast(longer[i])); - auto b = std::tolower(static_cast(shorter[i])); - if (a != b) { - return false; - } - } - return true; + return a.starts_with(b) || b.starts_with(a); }; - if (starts_with_ci(stem, pkg) || starts_with_ci(pkg, stem)) { + if (prefix_either(p, q) || prefix_either(s, q)) { return 1; } return 2; @@ -187,6 +228,8 @@ auto nix_cmake_scan(const fs::path& store_path, const std::string& package_name) struct Match { int score; + std::string stem; // for tiebreak: prefer shorter stems + std::string parent_dir; // for tiebreak: then shorter parent dir NixCmakeCandidate cand; }; std::vector matches; @@ -226,13 +269,16 @@ auto nix_cmake_scan(const fs::path& store_path, const std::string& package_name) } auto stem = config_stem_to_package(entry.path().filename().string()); + auto parent_dir_name = pkg_dir.filename().string(); NixCmakeCandidate cand{ .find_package = std::format("{} CONFIG REQUIRED", stem), .targets = std::move(targets), .config_file = entry.path(), }; matches.push_back(Match{ - .score = match_score(stem, package_name), + .score = match_score(parent_dir_name, stem, package_name), + .stem = stem, + .parent_dir = std::move(parent_dir_name), .cand = std::move(cand), }); } @@ -245,7 +291,25 @@ auto nix_cmake_scan(const fs::path& store_path, const std::string& package_name) }); } - std::ranges::stable_sort(matches, {}, &Match::score); + // Deterministic tiebreak within the same score band: prefer the + // candidate with the shorter normalized stem (the "more canonical" + // name for find_package), then the shorter parent dir name, then + // alphabetical order for full reproducibility. + std::ranges::stable_sort(matches, [](const Match& a, const Match& b) { + if (a.score != b.score) { + return a.score < b.score; + } + if (a.stem.size() != b.stem.size()) { + return a.stem.size() < b.stem.size(); + } + if (a.parent_dir.size() != b.parent_dir.size()) { + return a.parent_dir.size() < b.parent_dir.size(); + } + if (a.parent_dir != b.parent_dir) { + return a.parent_dir < b.parent_dir; + } + return a.stem < b.stem; + }); return std::move(matches.front().cand); } diff --git a/tests/nix_cmake_scan_parse.cpp b/tests/nix_cmake_scan_parse.cpp index 7fddd32..d999bc9 100644 --- a/tests/nix_cmake_scan_parse.cpp +++ b/tests/nix_cmake_scan_parse.cpp @@ -136,6 +136,69 @@ TEST_CASE("nix_cmake_scan ignores Config files with no IMPORTED targets", REQUIRE(r.error().code == ErrorCode::ResolutionUnknownPackage); } +TEST_CASE("nix_cmake_scan strips a leading lib prefix when scoring", + "[resolver][nix_cmake_scan]") { + // Real layout for nixpkgs `libllvm.dev`. `libllvm` should match + // `LLVMConfig.cmake`'s parent dir (`llvm`) after lib-stripping + // and beat `polly/PollyConfig.cmake`. + auto store = fresh_store(); + touch_config(store, "llvm/LLVMConfig.cmake", + R"(add_library(LLVM SHARED IMPORTED))"); + touch_config(store, "polly/PollyConfig.cmake", + R"(add_library(Polly SHARED IMPORTED))"); + + auto r = nix_cmake_scan(store, "libllvm"); + REQUIRE(r.has_value()); + REQUIRE(r->find_package == "LLVM CONFIG REQUIRED"); + REQUIRE(r->config_file.filename() == "LLVMConfig.cmake"); +} + +TEST_CASE("nix_cmake_scan picks the canonical config from a versioned dir", + "[resolver][nix_cmake_scan]") { + // Boost ships a top-level `Boost-1.89.0/BoostConfig.cmake` plus + // 47 modular component configs at sibling versioned dirs. + // After version-suffix stripping the top-level Boost should win. + auto store = fresh_store(); + touch_config(store, "Boost-1.89.0/BoostConfig.cmake", + R"(add_library(Boost::headers INTERFACE IMPORTED))"); + touch_config(store, "boost_atomic-1.89.0/boost_atomic-config.cmake", + R"(add_library(Boost::atomic SHARED IMPORTED))"); + touch_config(store, "boost_filesystem-1.89.0/boost_filesystem-config.cmake", + R"(add_library(Boost::filesystem SHARED IMPORTED))"); + + auto r = nix_cmake_scan(store, "boost"); + REQUIRE(r.has_value()); + REQUIRE(r->find_package == "Boost CONFIG REQUIRED"); + REQUIRE(r->config_file.filename() == "BoostConfig.cmake"); +} + +TEST_CASE("nix_cmake_scan ignores satellite configs in multi-config stores", + "[resolver][nix_cmake_scan]") { + // Mirrors `protobuf` which ships protobuf-config.cmake plus an + // unrelated utf8_range-config.cmake under the same prefix tree. + auto store = fresh_store(); + touch_config(store, "protobuf/protobuf-config.cmake", + R"(add_library(protobuf::libprotobuf SHARED IMPORTED))"); + touch_config(store, "utf8_range/utf8_range-config.cmake", + R"(add_library(utf8_range::utf8_range SHARED IMPORTED))"); + + auto r = nix_cmake_scan(store, "protobuf"); + REQUIRE(r.has_value()); + REQUIRE(r->find_package == "protobuf CONFIG REQUIRED"); +} + +TEST_CASE("nix_cmake_scan normalizes case for spelling variants", + "[resolver][nix_cmake_scan]") { + // grpc ships `gRPCConfig.cmake` under `lib/cmake/grpc/`. + auto store = fresh_store(); + touch_config(store, "grpc/gRPCConfig.cmake", + R"(add_library(gRPC::grpc++ SHARED IMPORTED))"); + + auto r = nix_cmake_scan(store, "grpc"); + REQUIRE(r.has_value()); + REQUIRE(r->find_package == "gRPC CONFIG REQUIRED"); +} + TEST_CASE("nix_cmake_scan scans sibling *-targets.cmake files", "[resolver][nix_cmake_scan]") { // Mirrors fmt's real on-disk layout: the *-config.cmake is empty of