From e5c173b466c6593ece73f55992d19c5a67a59d20 Mon Sep 17 00:00:00 2001 From: Amadey Vorontsov Date: Sun, 10 May 2026 10:14:38 +0000 Subject: [PATCH] [M5+] add resolver::conan_probe --- CHANGELOG.md | 13 +++ CMakeLists.txt | 1 + docs/auto-resolution.md | 4 +- src/resolver/conan_probe.cpp | 192 +++++++++++++++++++++++++++++++++++ src/resolver/resolver.cppm | 20 ++++ tests/CMakeLists.txt | 2 + tests/conan_probe_live.cpp | 41 ++++++++ tests/conan_probe_parse.cpp | 81 +++++++++++++++ 8 files changed, 352 insertions(+), 2 deletions(-) create mode 100644 src/resolver/conan_probe.cpp create mode 100644 tests/conan_probe_live.cpp create mode 100644 tests/conan_probe_parse.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 69f40ac..0339610 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,19 @@ All notable changes to cargoxx will be documented in this file. and `[build]` honoring `warnings_as_errors` and `sanitizers`. Source paths emitted relative to `build/` (i.e. prefixed with `../`). 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//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 `::` 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 `/lib/cmake/**` for `*Config.cmake` / `*-config.cmake` files, scans them and their sibling `.cmake` files (e.g. the diff --git a/CMakeLists.txt b/CMakeLists.txt index bbf32d6..b5f03c2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -54,6 +54,7 @@ target_sources(cargoxx src/exec/subprocess.cpp src/resolver/nixpkgs_probe.cpp src/resolver/nix_cmake_scan.cpp + src/resolver/conan_probe.cpp src/cli/cmd_new.cpp src/cli/cmd_build.cpp src/cli/cmd_run.cpp diff --git a/docs/auto-resolution.md b/docs/auto-resolution.md index 3102e95..25837d0 100644 --- a/docs/auto-resolution.md +++ b/docs/auto-resolution.md @@ -193,8 +193,8 @@ auto verify_link(const Recipe& candidate, | Phase | Status | Commit | | --- | --- | --- | | 1. nixpkgs_probe + JSON parser | ✅ | `1c7ff39` | -| 2. nix_cmake_scan | pending | — | -| 3. conan_probe + parse_conanfile | pending | — | +| 2. nix_cmake_scan | ✅ | `e63ac69` | +| 3. conan_probe + parse_conanfile | ✅ | (this commit) | | 4. vcpkg_probe + parse_vcpkg_usage | pending | — | | 5. verify_link (tmp project + cmd_build) | pending | — | | 6. Database::discover + cmd_add wire-up + failure caching | pending | — | diff --git a/src/resolver/conan_probe.cpp b/src/resolver/conan_probe.cpp new file mode 100644 index 0000000..e5cf4eb --- /dev/null +++ b/src/resolver/conan_probe.cpp @@ -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 { + 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 { + 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 { + 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 { + 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 `::` 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 { + 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 diff --git a/src/resolver/resolver.cppm b/src/resolver/resolver.cppm index 688b53c..6f833f6 100644 --- a/src/resolver/resolver.cppm +++ b/src/resolver/resolver.cppm @@ -51,4 +51,24 @@ auto nix_cmake_scan(const std::filesystem::path& store_path, const std::string& package_name) -> util::Result; +// Output of a conan-center-index recipe scrape. +struct ConanRecipe { + std::string find_package; // e.g. "fmt CONFIG REQUIRED" + std::vector 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; + +// Fetches https://raw.githubusercontent.com/conan-io/conan-center-index/ +// master/recipes//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; + } // namespace cargoxx::resolver diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 55bf98d..7e30aae 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -28,3 +28,5 @@ cargoxx_add_test(nixpkgs_probe_parse) cargoxx_add_test(nixpkgs_probe_live) cargoxx_add_test(nix_cmake_scan_parse) cargoxx_add_test(nix_cmake_scan_live) +cargoxx_add_test(conan_probe_parse) +cargoxx_add_test(conan_probe_live) diff --git a/tests/conan_probe_live.cpp b/tests/conan_probe_live.cpp new file mode 100644 index 0000000..fa15c15 --- /dev/null +++ b/tests/conan_probe_live.cpp @@ -0,0 +1,41 @@ +// Network-gated integration test for resolver::conan_probe. + +#include + +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); +} diff --git a/tests/conan_probe_parse.cpp b/tests/conan_probe_parse.cpp new file mode 100644 index 0000000..2a7e1b1 --- /dev/null +++ b/tests/conan_probe_parse.cpp @@ -0,0 +1,81 @@ +#include + +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{"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{"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{"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{"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{"absl::strings"}); +}