[M5+] generalize nix_cmake_scan config picker
This commit is contained in:
@@ -123,41 +123,82 @@ auto scan_imported_targets(std::string_view config_text) -> std::vector<std::str
|
||||
|
||||
namespace {
|
||||
|
||||
// Score a stem against the queried package name. Lower is better.
|
||||
// 0 — exact match (case-insensitive)
|
||||
// 1 — prefix match (one is a case-insensitive prefix of the other)
|
||||
// 2 — fallback (any other non-empty target list)
|
||||
auto match_score(std::string_view stem, std::string_view pkg) -> 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<unsigned char>(a[i]));
|
||||
auto bl = std::tolower(static_cast<unsigned char>(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<char>(std::tolower(static_cast<unsigned char>(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<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;
|
||||
}
|
||||
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<unsigned char>(longer[i]));
|
||||
auto b = std::tolower(static_cast<unsigned char>(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<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 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user