[M5+] add resolver::conan_probe
This commit is contained in:
13
CHANGELOG.md
13
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/<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
|
||||
`<store_path>/lib/cmake/**` for `*Config.cmake` / `*-config.cmake`
|
||||
files, scans them and their sibling `.cmake` files (e.g. the
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 | — |
|
||||
|
||||
192
src/resolver/conan_probe.cpp
Normal file
192
src/resolver/conan_probe.cpp
Normal 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
|
||||
@@ -51,4 +51,24 @@ auto nix_cmake_scan(const std::filesystem::path& store_path,
|
||||
const std::string& package_name)
|
||||
-> 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
|
||||
|
||||
@@ -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)
|
||||
|
||||
41
tests/conan_probe_live.cpp
Normal file
41
tests/conan_probe_live.cpp
Normal 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);
|
||||
}
|
||||
81
tests/conan_probe_parse.cpp
Normal file
81
tests/conan_probe_parse.cpp
Normal 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"});
|
||||
}
|
||||
Reference in New Issue
Block a user