[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
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 | — |
|
||||||
|
|||||||
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)
|
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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
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