[M5+] generalize nix_cmake_scan config picker

This commit is contained in:
2026-05-10 15:49:35 +00:00
parent 3c77431658
commit 5f233b9fa9
2 changed files with 158 additions and 31 deletions

View File

@@ -123,41 +123,82 @@ auto scan_imported_targets(std::string_view config_text) -> std::vector<std::str
namespace { namespace {
// Score a stem against the queried package name. Lower is better. // Normalize a name for cross-shape comparison:
// 0 — exact match (case-insensitive) // 1. lowercase
// 1 — prefix match (one is a case-insensitive prefix of the other) // 2. drop a leading "lib" if the remainder is at least 3 chars long
// 2 — fallback (any other non-empty target list) // (handles `libllvm` → `llvm`, `libclang` → `clang`)
auto match_score(std::string_view stem, std::string_view pkg) -> int { // 3. drop a trailing version-ish suffix `[-_]?[0-9][0-9._-]*`
auto eq_ci = [](std::string_view a, std::string_view b) { // (handles `Boost-1.89.0` → `boost`, `boost_atomic-1.89.0`
if (a.size() != b.size()) { // → `boost_atomic`); does nothing when no suffix is present
return false; // 4. drop every remaining non-alphanumeric character so spelling
} // variants like `gRPC`/`grpc` and `nlohmann_json`/`nlohmann-json`
for (std::size_t i = 0; i < a.size(); ++i) { // collapse together
auto al = std::tolower(static_cast<unsigned char>(a[i])); auto normalize(std::string_view s) -> std::string {
auto bl = std::tolower(static_cast<unsigned char>(b[i])); std::string out;
if (al != bl) { out.reserve(s.size());
return false; for (char c : s) {
} out += static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
} }
return true; 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<unsigned char>(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<unsigned char>(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<unsigned char>(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_<component> 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; return 0;
} }
auto starts_with_ci = [](std::string_view longer, std::string_view shorter) { auto prefix_either = [](const std::string& a, const std::string& b) {
if (shorter.size() > longer.size()) { if (a.empty() || b.empty()) {
return false; return false;
} }
for (std::size_t i = 0; i < shorter.size(); ++i) { return a.starts_with(b) || b.starts_with(a);
auto a = std::tolower(static_cast<unsigned char>(longer[i]));
auto b = std::tolower(static_cast<unsigned char>(shorter[i]));
if (a != b) {
return false;
}
}
return true;
}; };
if (starts_with_ci(stem, pkg) || starts_with_ci(pkg, stem)) { if (prefix_either(p, q) || prefix_either(s, q)) {
return 1; return 1;
} }
return 2; return 2;
@@ -187,6 +228,8 @@ auto nix_cmake_scan(const fs::path& store_path, const std::string& package_name)
struct Match { struct Match {
int score; int score;
std::string stem; // for tiebreak: prefer shorter stems
std::string parent_dir; // for tiebreak: then shorter parent dir
NixCmakeCandidate cand; NixCmakeCandidate cand;
}; };
std::vector<Match> matches; std::vector<Match> 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 stem = config_stem_to_package(entry.path().filename().string());
auto parent_dir_name = pkg_dir.filename().string();
NixCmakeCandidate cand{ NixCmakeCandidate cand{
.find_package = std::format("{} CONFIG REQUIRED", stem), .find_package = std::format("{} CONFIG REQUIRED", stem),
.targets = std::move(targets), .targets = std::move(targets),
.config_file = entry.path(), .config_file = entry.path(),
}; };
matches.push_back(Match{ 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), .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); return std::move(matches.front().cand);
} }

View File

@@ -136,6 +136,69 @@ TEST_CASE("nix_cmake_scan ignores Config files with no IMPORTED targets",
REQUIRE(r.error().code == ErrorCode::ResolutionUnknownPackage); 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", TEST_CASE("nix_cmake_scan scans sibling *-targets.cmake files",
"[resolver][nix_cmake_scan]") { "[resolver][nix_cmake_scan]") {
// Mirrors fmt's real on-disk layout: the *-config.cmake is empty of // Mirrors fmt's real on-disk layout: the *-config.cmake is empty of