[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 {
|
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`
|
||||||
|
// 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)));
|
||||||
}
|
}
|
||||||
for (std::size_t i = 0; i < a.size(); ++i) {
|
if (out.size() > 3 && out.starts_with("lib")) {
|
||||||
auto al = std::tolower(static_cast<unsigned char>(a[i]));
|
out.erase(0, 3);
|
||||||
auto bl = std::tolower(static_cast<unsigned char>(b[i]));
|
|
||||||
if (al != bl) {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
}
|
// Trim trailing version suffix. Walk back over [0-9._-] then peel
|
||||||
return true;
|
// 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user