[M6] resolver: CMake builtin FindModule scan probe

This commit is contained in:
2026-05-15 13:56:17 +00:00
parent 8b396bcd0f
commit 8bbfcf7657
4 changed files with 238 additions and 0 deletions

View File

@@ -59,6 +59,7 @@ target_sources(cargoxx
../src/manifest/writer.cpp
../src/resolver/conan_probe.cpp
../src/resolver/discover.cpp
../src/resolver/findmodule_scan.cpp
../src/resolver/nix_cmake_scan.cpp
../src/resolver/nixpkgs_git.cpp
../src/resolver/nixpkgs_probe.cpp

View File

@@ -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);

View 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

View File

@@ -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>)`,