[M5+] add resolver::conan_probe

This commit is contained in:
2026-05-10 10:14:38 +00:00
parent e63ac69239
commit e5c173b466
8 changed files with 352 additions and 2 deletions

View File

@@ -78,6 +78,19 @@ All notable changes to cargoxx will be documented in this file.
and `[build]` honoring `warnings_as_errors` and `sanitizers`. Source and `[build]` honoring `warnings_as_errors` and `sanitizers`. Source
paths emitted relative to `build/` (i.e. prefixed with `../`). paths emitted relative to `build/` (i.e. prefixed with `../`).
Output is deterministic. `tests/codegen_cmake.cpp` covers 11 cases. Output is deterministic. `tests/codegen_cmake.cpp` covers 11 cases.
- `cargoxx.resolver::conan_probe(name)` — fetches
`https://raw.githubusercontent.com/conan-io/conan-center-index/master/recipes/<name>/all/conanfile.py`
via `curl` (text-only — never executes Python, per `SPEC.md` §14)
and feeds it through `parse_conanfile`. Pure parser handles both the
modern `set_property("cmake_target_name", "...")` form and the legacy
`names["cmake_find_package"] = "..."` form, derives the missing field
from the other when only one is set, normalizes bare names into the
canonical `<file>::<target>` shape, and substitutes Python f-string
`{...}` placeholders with the cmake_file_name so recipes like fmt's
`f"fmt::{target}"` parse correctly. `tests/conan_probe_parse.cpp`
covers 6 cases; `tests/conan_probe_live.cpp` (gated by
`CARGOXX_NETWORK_TESTS=1`) verifies fmt and a 404 path against the
real conan-center-index.
- `cargoxx.resolver::nix_cmake_scan(store_path, package_name)` — walks - `cargoxx.resolver::nix_cmake_scan(store_path, package_name)` — walks
`<store_path>/lib/cmake/**` for `*Config.cmake` / `*-config.cmake` `<store_path>/lib/cmake/**` for `*Config.cmake` / `*-config.cmake`
files, scans them and their sibling `.cmake` files (e.g. the files, scans them and their sibling `.cmake` files (e.g. the

View File

@@ -54,6 +54,7 @@ target_sources(cargoxx
src/exec/subprocess.cpp src/exec/subprocess.cpp
src/resolver/nixpkgs_probe.cpp src/resolver/nixpkgs_probe.cpp
src/resolver/nix_cmake_scan.cpp src/resolver/nix_cmake_scan.cpp
src/resolver/conan_probe.cpp
src/cli/cmd_new.cpp src/cli/cmd_new.cpp
src/cli/cmd_build.cpp src/cli/cmd_build.cpp
src/cli/cmd_run.cpp src/cli/cmd_run.cpp

View File

@@ -193,8 +193,8 @@ auto verify_link(const Recipe& candidate,
| Phase | Status | Commit | | Phase | Status | Commit |
| --- | --- | --- | | --- | --- | --- |
| 1. nixpkgs_probe + JSON parser | ✅ | `1c7ff39` | | 1. nixpkgs_probe + JSON parser | ✅ | `1c7ff39` |
| 2. nix_cmake_scan | pending | — | | 2. nix_cmake_scan | ✅ | `e63ac69` |
| 3. conan_probe + parse_conanfile | pending | — | | 3. conan_probe + parse_conanfile | ✅ | (this commit) |
| 4. vcpkg_probe + parse_vcpkg_usage | pending | — | | 4. vcpkg_probe + parse_vcpkg_usage | pending | — |
| 5. verify_link (tmp project + cmd_build) | pending | — | | 5. verify_link (tmp project + cmd_build) | pending | — |
| 6. Database::discover + cmd_add wire-up + failure caching | pending | — | | 6. Database::discover + cmd_add wire-up + failure caching | pending | — |

View File

@@ -0,0 +1,192 @@
module cargoxx.resolver;
import std;
import cargoxx.util;
import cargoxx.exec;
namespace cargoxx::resolver {
namespace {
auto error(util::ErrorCode code, std::string msg) -> util::Error {
return util::Error{code, std::move(msg), "", std::nullopt, std::nullopt};
}
// Find the *value* (in quotes) of a key in patterns like
// set_property("cmake_target_name", "fmt::fmt")
// names["cmake_find_package"] = "fmt"
//
// `key_pattern` is a substring guaranteed to appear right before the value
// (with quotes/punctuation between). We then look for the next quoted
// string after `key_pattern`'s position.
auto extract_quoted_after(std::string_view text, std::string_view key_pattern)
-> std::optional<std::string> {
auto pos = text.find(key_pattern);
if (pos == std::string_view::npos) {
return std::nullopt;
}
pos += key_pattern.size();
// Scan forward looking for the *next* quoted string.
while (pos < text.size()) {
char c = text[pos];
if (c == '"' || c == '\'') {
char quote = c;
++pos;
std::string out;
while (pos < text.size() && text[pos] != quote) {
out += text[pos++];
}
if (pos < text.size()) {
return out;
}
return std::nullopt;
}
// newline → bail; the value is on this line per Conan convention
if (c == '\n') {
return std::nullopt;
}
++pos;
}
return std::nullopt;
}
auto extract_set_property(std::string_view text, std::string_view property_name)
-> std::optional<std::string> {
auto needle = std::format("set_property(\"{}\"", property_name);
auto v = extract_quoted_after(text, needle);
if (v) {
return v;
}
needle = std::format("set_property('{}'", property_name);
return extract_quoted_after(text, needle);
}
auto extract_names_legacy(std::string_view text, std::string_view legacy_key)
-> std::optional<std::string> {
auto needle = std::format("names[\"{}\"]", legacy_key);
auto v = extract_quoted_after(text, needle);
if (v) {
return v;
}
needle = std::format("names['{}']", legacy_key);
return extract_quoted_after(text, needle);
}
} // namespace
auto parse_conanfile(std::string_view text, const std::string& fallback_name)
-> util::Result<ConanRecipe> {
auto target = extract_set_property(text, "cmake_target_name");
auto file = extract_set_property(text, "cmake_file_name");
if (!target) {
target = extract_names_legacy(text, "cmake_find_package");
}
if (!file) {
file = extract_names_legacy(text, "cmake_find_package");
}
// If only one of the two surfaces, derive the other heuristically:
// cmake_target_name "fmt::fmt" → cmake_file_name "fmt"
// cmake_file_name "fmt" → cmake_target_name "fmt::fmt"
if (target && !file) {
auto t = *target;
if (auto cc = t.find("::"); cc != std::string::npos) {
file = t.substr(0, cc);
} else {
file = t;
}
}
if (file && !target) {
target = std::format("{}::{}", *file, *file);
}
if (!target || !file) {
// Last resort: fall back to the package name itself.
if (!fallback_name.empty()) {
file = fallback_name;
target = std::format("{}::{}", fallback_name, fallback_name);
} else {
return std::unexpected(error(
util::ErrorCode::ResolutionUnknownPackage,
"conanfile.py contained no cmake_target_name or cmake_file_name"));
}
}
// Conan recipes often parameterize the target via Python f-strings:
// f"fmt::{target}" or f"{name}::{component}"
// We can't evaluate Python — substitute every `{...}` placeholder with
// the cmake_file_name (or fallback) which is the canonical target stem
// for the vast majority of recipes.
auto substitute_braces = [&](std::string s) -> std::string {
const auto& sub = !file->empty() ? *file : fallback_name;
std::string out;
out.reserve(s.size());
std::size_t i = 0;
while (i < s.size()) {
if (s[i] == '{') {
auto close = s.find('}', i + 1);
if (close == std::string::npos) {
out += s.substr(i);
break;
}
out += sub;
i = close + 1;
} else {
out += s[i++];
}
}
return out;
};
*target = substitute_braces(*target);
*file = substitute_braces(*file);
// Normalize bare names to the conventional `<file>::<target>` form so
// `target_link_libraries` actually resolves. Legacy Conan recipes set
// `names["cmake_find_package"] = "spdlog"` for both fields, leaving us
// with a bare "spdlog" target that CMake won't accept as an imported
// target reference.
if (target->find("::") == std::string::npos) {
target = std::format("{}::{}", *file, *target);
}
return ConanRecipe{
.find_package = std::format("{} CONFIG REQUIRED", *file),
.targets = {*target},
};
}
auto conan_probe(const std::string& name) -> util::Result<ConanRecipe> {
if (name.empty()) {
return std::unexpected(error(util::ErrorCode::ResolutionUnknownPackage,
"package name is empty"));
}
auto url = std::format(
"https://raw.githubusercontent.com/conan-io/conan-center-index/"
"master/recipes/{}/all/conanfile.py",
name);
auto r = exec::run("curl", {"-fsSL", "--max-time", "30", url},
exec::ExecOptions{
.cwd = {},
.env_overrides = {},
.timeout = std::chrono::seconds{40},
.inherit_stdio = false,
});
if (!r) {
return std::unexpected(r.error());
}
if (r->exit_code == 22) {
// curl --fail returns 22 on HTTP 4xx — treat 404s as "not in conan".
return std::unexpected(error(util::ErrorCode::ResolutionUnknownPackage,
std::format("conan-center has no recipe for '{}'",
name)));
}
if (r->exit_code != 0) {
return std::unexpected(error(
util::ErrorCode::ResolutionNetworkError,
std::format("curl failed (exit {}): {}", r->exit_code, r->stderr_text)));
}
return parse_conanfile(r->stdout_text, name);
}
} // namespace cargoxx::resolver

View File

@@ -51,4 +51,24 @@ auto nix_cmake_scan(const std::filesystem::path& store_path,
const std::string& package_name) const std::string& package_name)
-> util::Result<NixCmakeCandidate>; -> util::Result<NixCmakeCandidate>;
// Output of a conan-center-index recipe scrape.
struct ConanRecipe {
std::string find_package; // e.g. "fmt CONFIG REQUIRED"
std::vector<std::string> targets; // e.g. ["fmt::fmt"]
};
// Pure: scrapes a conanfile.py text for `cmake_target_name` and
// `cmake_file_name`. Handles both the modern
// `cpp_info.set_property("cmake_target_name", "...")` form and the
// legacy `cpp_info.names["cmake_find_package"] = "..."` form. Returns
// ResolutionUnknownPackage when no recognizable recipe is found.
auto parse_conanfile(std::string_view conanfile_text, const std::string& fallback_name)
-> util::Result<ConanRecipe>;
// Fetches https://raw.githubusercontent.com/conan-io/conan-center-index/
// master/recipes/<name>/all/conanfile.py via `curl` and feeds it through
// parse_conanfile. 404 → ResolutionUnknownPackage; transport errors →
// ResolutionNetworkError.
auto conan_probe(const std::string& name) -> util::Result<ConanRecipe>;
} // namespace cargoxx::resolver } // namespace cargoxx::resolver

View File

@@ -28,3 +28,5 @@ cargoxx_add_test(nixpkgs_probe_parse)
cargoxx_add_test(nixpkgs_probe_live) cargoxx_add_test(nixpkgs_probe_live)
cargoxx_add_test(nix_cmake_scan_parse) cargoxx_add_test(nix_cmake_scan_parse)
cargoxx_add_test(nix_cmake_scan_live) cargoxx_add_test(nix_cmake_scan_live)
cargoxx_add_test(conan_probe_parse)
cargoxx_add_test(conan_probe_live)

View File

@@ -0,0 +1,41 @@
// Network-gated integration test for resolver::conan_probe.
#include <catch2/catch_test_macros.hpp>
import cargoxx.resolver;
import cargoxx.util;
import std;
namespace {
auto network_tests_enabled() -> bool {
auto* env = std::getenv("CARGOXX_NETWORK_TESTS");
return env != nullptr && std::string_view{env} == "1";
}
} // namespace
TEST_CASE("conan_probe finds 'fmt' in conan-center-index",
"[resolver][network]") {
if (!network_tests_enabled()) {
SKIP("CARGOXX_NETWORK_TESTS != 1");
}
auto r = cargoxx::resolver::conan_probe("fmt");
REQUIRE(r.has_value());
REQUIRE_FALSE(r->find_package.empty());
REQUIRE_FALSE(r->targets.empty());
// fmt's recipe should set cmake_target_name = "fmt::fmt".
REQUIRE(r->targets.front() == "fmt::fmt");
}
TEST_CASE("conan_probe returns ResolutionUnknownPackage for a 404",
"[resolver][network]") {
if (!network_tests_enabled()) {
SKIP("CARGOXX_NETWORK_TESTS != 1");
}
auto r = cargoxx::resolver::conan_probe(
"definitely_not_a_real_pkg_cargoxx_xyzzy");
REQUIRE_FALSE(r.has_value());
REQUIRE(r.error().code ==
cargoxx::util::ErrorCode::ResolutionUnknownPackage);
}

View File

@@ -0,0 +1,81 @@
#include <catch2/catch_test_macros.hpp>
import cargoxx.resolver;
import cargoxx.util;
import std;
using cargoxx::resolver::parse_conanfile;
using cargoxx::util::ErrorCode;
TEST_CASE("parse_conanfile picks up modern set_property form", "[resolver][conan]") {
constexpr std::string_view text = R"PY(
from conan import ConanFile
class FmtConan(ConanFile):
name = "fmt"
def package_info(self):
self.cpp_info.set_property("cmake_file_name", "fmt")
self.cpp_info.set_property("cmake_target_name", "fmt::fmt")
)PY";
auto r = parse_conanfile(text, "fmt");
REQUIRE(r.has_value());
REQUIRE(r->find_package == "fmt CONFIG REQUIRED");
REQUIRE(r->targets == std::vector<std::string>{"fmt::fmt"});
}
TEST_CASE("parse_conanfile picks up legacy names[] form", "[resolver][conan]") {
constexpr std::string_view text = R"PY(
class SpdlogConan(ConanFile):
def package_info(self):
self.cpp_info.names["cmake_find_package"] = "spdlog"
)PY";
auto r = parse_conanfile(text, "spdlog");
REQUIRE(r.has_value());
REQUIRE(r->find_package == "spdlog CONFIG REQUIRED");
// Derived target heuristic: spdlog::spdlog
REQUIRE(r->targets == std::vector<std::string>{"spdlog::spdlog"});
}
TEST_CASE("parse_conanfile derives cmake_file_name from a colon-namespaced target",
"[resolver][conan]") {
constexpr std::string_view text = R"PY(
self.cpp_info.set_property("cmake_target_name", "Boost::filesystem")
)PY";
auto r = parse_conanfile(text, "boost");
REQUIRE(r.has_value());
REQUIRE(r->find_package == "Boost CONFIG REQUIRED");
REQUIRE(r->targets == std::vector<std::string>{"Boost::filesystem"});
}
TEST_CASE("parse_conanfile falls back to the package name when nothing matches",
"[resolver][conan]") {
constexpr std::string_view text = R"PY(
# This recipe gives us nothing useful at the cpp_info level.
class OpaqueConan(ConanFile):
name = "opaque"
)PY";
auto r = parse_conanfile(text, "opaque");
REQUIRE(r.has_value());
REQUIRE(r->find_package == "opaque CONFIG REQUIRED");
REQUIRE(r->targets == std::vector<std::string>{"opaque::opaque"});
}
TEST_CASE("parse_conanfile errors when no information AND no fallback",
"[resolver][conan]") {
auto r = parse_conanfile("# nothing here", "");
REQUIRE_FALSE(r.has_value());
REQUIRE(r.error().code == ErrorCode::ResolutionUnknownPackage);
}
TEST_CASE("parse_conanfile accepts single-quoted strings", "[resolver][conan]") {
constexpr std::string_view text = R"PY(
self.cpp_info.set_property('cmake_target_name', 'absl::strings')
self.cpp_info.set_property('cmake_file_name', 'absl')
)PY";
auto r = parse_conanfile(text, "abseil");
REQUIRE(r.has_value());
REQUIRE(r->find_package == "absl CONFIG REQUIRED");
REQUIRE(r->targets == std::vector<std::string>{"absl::strings"});
}