diff --git a/build/CMakeLists.txt b/build/CMakeLists.txt index 66b9160..30f5389 100644 --- a/build/CMakeLists.txt +++ b/build/CMakeLists.txt @@ -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 diff --git a/src/resolver/discover.cpp b/src/resolver/discover.cpp index b5ddd17..fcd563b 100644 --- a/src/resolver/discover.cpp +++ b/src/resolver/discover.cpp @@ -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); diff --git a/src/resolver/findmodule_scan.cpp b/src/resolver/findmodule_scan.cpp new file mode 100644 index 0000000..842c87a --- /dev/null +++ b/src/resolver/findmodule_scan.cpp @@ -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(std::tolower(static_cast(c))); + } + if (out.size() > 3 && out.starts_with("lib")) { + out.erase(0, 3); + } + auto is_vchar = [](char c) { + return std::isdigit(static_cast(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(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(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 { + 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 { + 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 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 `::` and let the + // namespaced-from-the-module-body pick override when present. + std::vector targets; + std::ifstream in{winner.path}; + if (in) { + std::string text{std::istreambuf_iterator{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 diff --git a/src/resolver/resolver.cppm b/src/resolver/resolver.cppm index f6b5a2c..12bc8ba 100644 --- a/src/resolver/resolver.cppm +++ b/src/resolver/resolver.cppm @@ -77,6 +77,23 @@ auto nix_cmake_scan(const std::filesystem::path& store_path, const std::string& package_name) -> util::Result; +// 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( REQUIRED)` without the CONFIG keyword. +struct FindModuleCandidate { + std::string find_package; // e.g. "SQLite3 REQUIRED" + std::vector 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; + // 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( REQUIRED IMPORTED_TARGET )`,