[M5+] add resolver::nix_cmake_scan
This commit is contained in:
252
src/resolver/nix_cmake_scan.cpp
Normal file
252
src/resolver/nix_cmake_scan.cpp
Normal 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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user