[M6] resolver: CMake builtin FindModule scan probe
This commit is contained in:
@@ -78,6 +78,17 @@ auto recipe_from_pc(const PcCandidate& p, const std::string& nixpkgs_attr,
|
||||
};
|
||||
}
|
||||
|
||||
auto recipe_from_findmodule(const FindModuleCandidate& fm,
|
||||
const std::string& nixpkgs_attr,
|
||||
const std::string& source) -> linkdb::Recipe {
|
||||
return linkdb::Recipe{
|
||||
.nixpkgs_attr = nixpkgs_attr,
|
||||
.find_package = fm.find_package,
|
||||
.targets = fm.targets,
|
||||
.source = source,
|
||||
};
|
||||
}
|
||||
|
||||
struct Candidate {
|
||||
std::string source;
|
||||
linkdb::Recipe recipe;
|
||||
@@ -159,6 +170,10 @@ auto discover(const std::string& name, const std::string& version_spec,
|
||||
candidates.push_back(
|
||||
{"nix-probe", recipe_from_nix_scan(*scan_hit, name, "nix-probe")});
|
||||
}
|
||||
if (auto fm = findmodule_scan(name); fm) {
|
||||
candidates.push_back(
|
||||
{"cmake-findmodule", recipe_from_findmodule(*fm, name, "cmake-findmodule")});
|
||||
}
|
||||
if (!realized_dev_path.empty()) {
|
||||
if (auto p = pc_scan(fs::path{realized_dev_path}, name); p) {
|
||||
pc_hit = std::move(*p);
|
||||
|
||||
205
src/resolver/findmodule_scan.cpp
Normal file
205
src/resolver/findmodule_scan.cpp
Normal file
@@ -0,0 +1,205 @@
|
||||
module cargoxx.resolver;
|
||||
|
||||
import std;
|
||||
import cargoxx.exec;
|
||||
import cargoxx.util;
|
||||
|
||||
namespace cargoxx::resolver {
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace {
|
||||
|
||||
// Mirrors nix_cmake_scan / pc_scan. Kept local; Phase A refactor will
|
||||
// extract this to a shared `name_match` module.
|
||||
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);
|
||||
}
|
||||
auto is_vchar = [](char c) {
|
||||
return std::isdigit(static_cast<unsigned char>(c)) || c == '.'
|
||||
|| c == '-' || c == '_';
|
||||
};
|
||||
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;
|
||||
}
|
||||
|
||||
auto match_score(std::string_view stem, std::string_view pkg) -> int {
|
||||
auto s = normalize(stem);
|
||||
auto q = normalize(pkg);
|
||||
if (q.empty()) {
|
||||
return 2;
|
||||
}
|
||||
if (s == q) {
|
||||
return 0;
|
||||
}
|
||||
if (!s.empty() && (s.starts_with(q) || q.starts_with(s))) {
|
||||
return 1;
|
||||
}
|
||||
return 2;
|
||||
}
|
||||
|
||||
// Find CMake's bundled Modules dir. `cmake -E capabilities` emits JSON
|
||||
// with a `cmakeRoot` field; modules live at `${cmakeRoot}/Modules/`.
|
||||
// We parse the value with a tiny string search rather than dragging
|
||||
// nlohmann::json through this module — the field's value is always a
|
||||
// quoted string immediately after the literal `"cmakeRoot":`.
|
||||
auto find_modules_dir() -> std::optional<fs::path> {
|
||||
auto r = exec::run("cmake",
|
||||
{"-E", "capabilities"},
|
||||
exec::ExecOptions{
|
||||
.cwd = fs::current_path(),
|
||||
.env_overrides = {},
|
||||
.timeout = std::chrono::seconds{5},
|
||||
.inherit_stdio = false,
|
||||
});
|
||||
if (!r || r->exit_code != 0) {
|
||||
return std::nullopt;
|
||||
}
|
||||
std::string_view body = r->stdout_text;
|
||||
constexpr std::string_view key = "\"cmakeRoot\":\"";
|
||||
auto pos = body.find(key);
|
||||
if (pos == std::string_view::npos) {
|
||||
return std::nullopt;
|
||||
}
|
||||
pos += key.size();
|
||||
auto end = body.find('"', pos);
|
||||
if (end == std::string_view::npos) {
|
||||
return std::nullopt;
|
||||
}
|
||||
fs::path modules = fs::path{std::string{body.substr(pos, end - pos)}} / "Modules";
|
||||
std::error_code ec;
|
||||
if (!fs::exists(modules, ec) || ec) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return modules;
|
||||
}
|
||||
|
||||
// Strip a leading "Find" and trailing ".cmake" from a filename to get
|
||||
// the find_package stem.
|
||||
auto module_stem(const fs::path& path) -> std::string {
|
||||
auto s = path.stem().string(); // e.g. "FindSQLite3"
|
||||
if (s.starts_with("Find")) {
|
||||
s.erase(0, 4);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
auto findmodule_scan(const std::string& package_name)
|
||||
-> util::Result<FindModuleCandidate> {
|
||||
auto modules_dir = find_modules_dir();
|
||||
if (!modules_dir) {
|
||||
return std::unexpected(util::Error{
|
||||
util::ErrorCode::ResolutionUnknownPackage,
|
||||
"cannot locate CMake's bundled Modules directory",
|
||||
"", std::nullopt, std::nullopt,
|
||||
});
|
||||
}
|
||||
|
||||
struct Match {
|
||||
int score;
|
||||
std::string stem;
|
||||
fs::path path;
|
||||
};
|
||||
std::vector<Match> matches;
|
||||
|
||||
std::error_code ec;
|
||||
for (const auto& entry : fs::directory_iterator{
|
||||
*modules_dir, fs::directory_options::skip_permission_denied, ec}) {
|
||||
if (!entry.is_regular_file()) {
|
||||
continue;
|
||||
}
|
||||
auto name = entry.path().filename().string();
|
||||
if (!name.starts_with("Find") || !name.ends_with(".cmake")) {
|
||||
continue;
|
||||
}
|
||||
auto stem = module_stem(entry.path());
|
||||
matches.push_back(Match{
|
||||
.score = match_score(stem, package_name),
|
||||
.stem = std::move(stem),
|
||||
.path = entry.path(),
|
||||
});
|
||||
}
|
||||
|
||||
if (matches.empty()) {
|
||||
return std::unexpected(util::Error{
|
||||
util::ErrorCode::ResolutionUnknownPackage,
|
||||
std::format("no Find*.cmake under '{}'", modules_dir->string()),
|
||||
"", std::nullopt, std::nullopt,
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
return a.stem < b.stem;
|
||||
});
|
||||
|
||||
if (matches.front().score >= 2) {
|
||||
return std::unexpected(util::Error{
|
||||
util::ErrorCode::ResolutionUnknownPackage,
|
||||
std::format("no Find*.cmake matches package name '{}'", package_name),
|
||||
"", std::nullopt, std::nullopt,
|
||||
});
|
||||
}
|
||||
|
||||
const auto& winner = matches.front();
|
||||
|
||||
// FindModules rarely declare IMPORTED targets the same way Config
|
||||
// files do, so scan_imported_targets often comes back empty.
|
||||
// Default to CMake's modern convention `<X>::<X>` and let the
|
||||
// namespaced-from-the-module-body pick override when present.
|
||||
std::vector<std::string> targets;
|
||||
std::ifstream in{winner.path};
|
||||
if (in) {
|
||||
std::string text{std::istreambuf_iterator<char>{in}, {}};
|
||||
targets = scan_imported_targets(text);
|
||||
}
|
||||
if (targets.empty()) {
|
||||
targets.push_back(std::format("{}::{}", winner.stem, winner.stem));
|
||||
} else {
|
||||
targets = filter_public_targets(std::move(targets), winner.stem);
|
||||
}
|
||||
|
||||
return FindModuleCandidate{
|
||||
.find_package = std::format("{} REQUIRED", winner.stem),
|
||||
.targets = std::move(targets),
|
||||
.module_file = winner.path,
|
||||
};
|
||||
}
|
||||
|
||||
} // namespace cargoxx::resolver
|
||||
@@ -77,6 +77,23 @@ auto nix_cmake_scan(const std::filesystem::path& store_path,
|
||||
const std::string& package_name)
|
||||
-> util::Result<NixCmakeCandidate>;
|
||||
|
||||
// A CMake builtin FindModule-shaped recipe. CMake ships ~160 `Find*.cmake`
|
||||
// modules with the installation; for libraries that have no `Config.cmake`
|
||||
// but a corresponding builtin (sqlite has `FindSQLite3.cmake`, openssl has
|
||||
// `FindOpenSSL.cmake`, threads has `FindThreads.cmake`, …) the recipe
|
||||
// emits `find_package(<X> REQUIRED)` without the CONFIG keyword.
|
||||
struct FindModuleCandidate {
|
||||
std::string find_package; // e.g. "SQLite3 REQUIRED"
|
||||
std::vector<std::string> targets; // e.g. ["SQLite::SQLite3"]
|
||||
std::filesystem::path module_file; // the FindX.cmake we matched
|
||||
};
|
||||
|
||||
// Walks CMake's bundled `Modules/Find*.cmake` and picks the best match
|
||||
// for `package_name`. Returns ResolutionUnknownPackage when no module
|
||||
// scores acceptably.
|
||||
auto findmodule_scan(const std::string& package_name)
|
||||
-> util::Result<FindModuleCandidate>;
|
||||
|
||||
// A pkg-config-shaped recipe: the package ships a `.pc` file rather
|
||||
// than a CMake config. Consumed via `find_package(PkgConfig REQUIRED)`
|
||||
// + `pkg_check_modules(<NAME> REQUIRED IMPORTED_TARGET <pc_module>)`,
|
||||
|
||||
Reference in New Issue
Block a user