[M5+] add resolver::nix_cmake_scan

This commit is contained in:
2026-05-10 10:08:55 +00:00
parent 1c7ff39f64
commit e63ac69239
8 changed files with 783 additions and 0 deletions

View File

@@ -0,0 +1,252 @@
module cargoxx.resolver;
import std;
import cargoxx.util;
namespace cargoxx::resolver {
namespace fs = std::filesystem;
auto config_stem_to_package(std::string_view filename) -> std::string {
std::string s{filename};
// Drop any directory prefix.
if (auto slash = s.find_last_of('/'); slash != std::string::npos) {
s.erase(0, slash + 1);
}
constexpr std::array suffixes = {".cmake"};
for (auto suf : suffixes) {
if (s.ends_with(suf)) {
s.erase(s.size() - std::string_view{suf}.size());
}
}
constexpr std::array stems = {std::string_view{"Config"}, std::string_view{"-config"}};
for (auto stem : stems) {
if (s.ends_with(stem)) {
s.erase(s.size() - stem.size());
break;
}
}
return s;
}
namespace {
// Walks `text` looking for `add_library(<name> ... IMPORTED ...)` and
// `add_library(<alias> ALIAS <real>)` forms. Returns the bare target names.
auto collect_targets(std::string_view text) -> std::vector<std::string> {
std::vector<std::string> out;
constexpr std::string_view marker = "add_library";
std::size_t pos = 0;
while (pos < text.size()) {
auto next = text.find(marker, pos);
if (next == std::string_view::npos) {
break;
}
// Must be at line start or preceded by whitespace/punct (avoid
// matching `_add_library` etc.).
if (next > 0) {
char prev = text[next - 1];
if (std::isalnum(static_cast<unsigned char>(prev)) || prev == '_') {
pos = next + marker.size();
continue;
}
}
// Find the opening '('.
auto open = text.find('(', next + marker.size());
if (open == std::string_view::npos) {
break;
}
auto close = text.find(')', open + 1);
if (close == std::string_view::npos) {
break;
}
auto args = text.substr(open + 1, close - open - 1);
// Tokenize by whitespace. CMake's add_library has the form
// add_library(<name> [STATIC|SHARED|...] [IMPORTED] ...)
// or
// add_library(<alias> ALIAS <real>)
std::vector<std::string_view> toks;
std::size_t tp = 0;
while (tp < args.size()) {
while (tp < args.size() &&
std::isspace(static_cast<unsigned char>(args[tp]))) {
++tp;
}
if (tp >= args.size()) {
break;
}
std::size_t start = tp;
while (tp < args.size() &&
!std::isspace(static_cast<unsigned char>(args[tp]))) {
++tp;
}
toks.push_back(args.substr(start, tp - start));
}
if (toks.size() >= 2) {
const auto& name = toks[0];
bool imported = false;
bool alias = false;
for (std::size_t i = 1; i < toks.size(); ++i) {
if (toks[i] == "IMPORTED") {
imported = true;
}
if (toks[i] == "ALIAS") {
alias = true;
}
}
if (imported || alias) {
out.emplace_back(name);
}
}
pos = close + 1;
}
return out;
}
} // namespace
auto scan_imported_targets(std::string_view config_text) -> std::vector<std::string> {
auto targets = collect_targets(config_text);
// Stable-dedup: preserve first-occurrence order, drop duplicates.
std::vector<std::string> out;
out.reserve(targets.size());
for (auto& t : targets) {
if (std::ranges::find(out, t) == out.end()) {
out.push_back(std::move(t));
}
}
return out;
}
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;
};
if (eq_ci(stem, pkg)) {
return 0;
}
auto starts_with_ci = [](std::string_view longer, std::string_view shorter) {
if (shorter.size() > longer.size()) {
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;
};
if (starts_with_ci(stem, pkg) || starts_with_ci(pkg, stem)) {
return 1;
}
return 2;
}
auto is_config_filename(const fs::path& p) -> bool {
if (p.extension() != ".cmake") {
return false;
}
auto stem = p.stem().string();
return stem.ends_with("Config") || stem.ends_with("-config");
}
} // namespace
auto nix_cmake_scan(const fs::path& store_path, const std::string& package_name)
-> util::Result<NixCmakeCandidate> {
const auto cmake_dir = store_path / "lib" / "cmake";
std::error_code ec;
if (!fs::exists(cmake_dir, ec) || ec) {
return std::unexpected(util::Error{
util::ErrorCode::ResolutionUnknownPackage,
std::format("no CMake configs under '{}'", cmake_dir.string()),
"", store_path, std::nullopt,
});
}
struct Match {
int score;
NixCmakeCandidate cand;
};
std::vector<Match> matches;
// Walk the tree once, find every *Config.cmake / *-config.cmake. The
// IMPORTED targets are usually in a sibling *-targets.cmake (via the
// standard `include(<self>-targets.cmake)` pattern), so for each
// config file we scan every .cmake file in its parent directory.
for (const auto& entry : fs::recursive_directory_iterator{
cmake_dir, fs::directory_options::skip_permission_denied}) {
if (!entry.is_regular_file()) {
continue;
}
if (!is_config_filename(entry.path())) {
continue;
}
std::vector<std::string> targets;
const auto pkg_dir = entry.path().parent_path();
for (const auto& sib : fs::directory_iterator{pkg_dir}) {
if (!sib.is_regular_file() || sib.path().extension() != ".cmake") {
continue;
}
std::ifstream in{sib.path()};
if (!in) {
continue;
}
std::string text{std::istreambuf_iterator<char>{in}, {}};
for (auto& t : scan_imported_targets(text)) {
if (std::ranges::find(targets, t) == targets.end()) {
targets.push_back(std::move(t));
}
}
}
if (targets.empty()) {
continue;
}
auto stem = config_stem_to_package(entry.path().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),
.cand = std::move(cand),
});
}
if (matches.empty()) {
return std::unexpected(util::Error{
util::ErrorCode::ResolutionUnknownPackage,
std::format("no IMPORTED targets found under '{}'", cmake_dir.string()),
"", store_path, std::nullopt,
});
}
std::ranges::stable_sort(matches, {}, &Match::score);
return std::move(matches.front().cand);
}
} // namespace cargoxx::resolver

View File

@@ -26,4 +26,29 @@ auto parse_nix_eval_json(std::string_view attr, std::string_view json)
// `ResolutionNetworkError` on timeout or evaluator errors.
auto nixpkgs_probe(const std::string& attr) -> util::Result<NixpkgsInfo>;
// One CMake config-file's IMPORTED targets together with the find_package
// expression derived from its filename stem.
struct NixCmakeCandidate {
std::string find_package; // e.g. "fmt CONFIG REQUIRED"
std::vector<std::string> targets; // e.g. ["fmt::fmt"]
std::filesystem::path config_file; // the *Config.cmake we scraped
};
// Pure: scan a single CMake config text for `add_library(... IMPORTED)`
// target names. ALIAS targets are also collected so canonical
// `<alias>::<member>` forms get picked up.
auto scan_imported_targets(std::string_view config_text) -> std::vector<std::string>;
// Pure: turn a CMake config filename into the find_package name.
// `fmtConfig.cmake` / `fmt-config.cmake` -> `fmt`.
auto config_stem_to_package(std::string_view filename) -> std::string;
// Walks <store_path>/lib/cmake/** for *Config.cmake / *-config.cmake files.
// Picks the candidate whose derived package name best matches `package_name`
// (exact case-insensitive equality > prefix > first non-empty target list).
// Returns ResolutionUnknownPackage when nothing usable is found.
auto nix_cmake_scan(const std::filesystem::path& store_path,
const std::string& package_name)
-> util::Result<NixCmakeCandidate>;
} // namespace cargoxx::resolver