[M5+] add resolver::vcpkg_probe
This commit is contained in:
@@ -78,6 +78,15 @@ 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::vcpkg_probe(name)` — fetches
|
||||||
|
`https://raw.githubusercontent.com/microsoft/vcpkg/master/ports/<name>/usage`
|
||||||
|
and feeds it through `parse_vcpkg_usage`. The pure parser extracts
|
||||||
|
the first `find_package(...)` arg block, adds `REQUIRED` if absent,
|
||||||
|
and gathers `target_link_libraries` targets that contain `::` (skips
|
||||||
|
generator expressions and bare names). `tests/vcpkg_probe_parse.cpp`
|
||||||
|
covers 6 cases; `tests/vcpkg_probe_live.cpp` (gated by
|
||||||
|
`CARGOXX_NETWORK_TESTS=1`) verifies fmt + a 404 path against real
|
||||||
|
microsoft/vcpkg ports.
|
||||||
- `cargoxx.resolver::conan_probe(name)` — fetches
|
- `cargoxx.resolver::conan_probe(name)` — fetches
|
||||||
`https://raw.githubusercontent.com/conan-io/conan-center-index/master/recipes/<name>/all/conanfile.py`
|
`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)
|
via `curl` (text-only — never executes Python, per `SPEC.md` §14)
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ target_sources(cargoxx
|
|||||||
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/resolver/conan_probe.cpp
|
||||||
|
src/resolver/vcpkg_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
|
||||||
|
|||||||
@@ -194,8 +194,8 @@ auto verify_link(const Recipe& candidate,
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 1. nixpkgs_probe + JSON parser | ✅ | `1c7ff39` |
|
| 1. nixpkgs_probe + JSON parser | ✅ | `1c7ff39` |
|
||||||
| 2. nix_cmake_scan | ✅ | `e63ac69` |
|
| 2. nix_cmake_scan | ✅ | `e63ac69` |
|
||||||
| 3. conan_probe + parse_conanfile | ✅ | (this commit) |
|
| 3. conan_probe + parse_conanfile | ✅ | `e5c173b` |
|
||||||
| 4. vcpkg_probe + parse_vcpkg_usage | pending | — |
|
| 4. vcpkg_probe + parse_vcpkg_usage | ✅ | (this commit) |
|
||||||
| 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 | — |
|
||||||
|
|
||||||
|
|||||||
@@ -71,4 +71,22 @@ auto parse_conanfile(std::string_view conanfile_text, const std::string& fallbac
|
|||||||
// ResolutionNetworkError.
|
// ResolutionNetworkError.
|
||||||
auto conan_probe(const std::string& name) -> util::Result<ConanRecipe>;
|
auto conan_probe(const std::string& name) -> util::Result<ConanRecipe>;
|
||||||
|
|
||||||
|
// Output of a microsoft/vcpkg port usage-file scrape.
|
||||||
|
struct VcpkgRecipe {
|
||||||
|
std::string find_package; // e.g. "fmt CONFIG REQUIRED"
|
||||||
|
std::vector<std::string> targets; // e.g. ["fmt::fmt"]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pure: scrape a vcpkg port `usage` file (plain CMake) for the first
|
||||||
|
// find_package(...) arguments and the targets named in the corresponding
|
||||||
|
// target_link_libraries(...) call. Returns ResolutionUnknownPackage when
|
||||||
|
// no find_package directive appears.
|
||||||
|
auto parse_vcpkg_usage(std::string_view usage_text)
|
||||||
|
-> util::Result<VcpkgRecipe>;
|
||||||
|
|
||||||
|
// Fetches https://raw.githubusercontent.com/microsoft/vcpkg/master/ports/<name>/usage
|
||||||
|
// via `curl` and feeds it through parse_vcpkg_usage. 404 →
|
||||||
|
// ResolutionUnknownPackage; transport errors → ResolutionNetworkError.
|
||||||
|
auto vcpkg_probe(const std::string& name) -> util::Result<VcpkgRecipe>;
|
||||||
|
|
||||||
} // namespace cargoxx::resolver
|
} // namespace cargoxx::resolver
|
||||||
|
|||||||
173
src/resolver/vcpkg_probe.cpp
Normal file
173
src/resolver/vcpkg_probe.cpp
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
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 first balanced `(...)` block following `keyword` (with no
|
||||||
|
// alphanumeric / underscore character immediately preceding `keyword` to
|
||||||
|
// avoid matching e.g. `_find_package`).
|
||||||
|
auto extract_call_args(std::string_view text, std::string_view keyword)
|
||||||
|
-> std::optional<std::string_view> {
|
||||||
|
std::size_t pos = 0;
|
||||||
|
while (pos < text.size()) {
|
||||||
|
auto next = text.find(keyword, pos);
|
||||||
|
if (next == std::string_view::npos) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
if (next > 0) {
|
||||||
|
char prev = text[next - 1];
|
||||||
|
if (std::isalnum(static_cast<unsigned char>(prev)) || prev == '_') {
|
||||||
|
pos = next + keyword.size();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
auto open = text.find('(', next + keyword.size());
|
||||||
|
if (open == std::string_view::npos) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
auto close = text.find(')', open + 1);
|
||||||
|
if (close == std::string_view::npos) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
return text.substr(open + 1, close - open - 1);
|
||||||
|
}
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto tokenize(std::string_view s) -> std::vector<std::string> {
|
||||||
|
std::vector<std::string> out;
|
||||||
|
std::size_t i = 0;
|
||||||
|
while (i < s.size()) {
|
||||||
|
while (i < s.size() && std::isspace(static_cast<unsigned char>(s[i]))) {
|
||||||
|
++i;
|
||||||
|
}
|
||||||
|
if (i >= s.size()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::size_t start = i;
|
||||||
|
while (i < s.size() && !std::isspace(static_cast<unsigned char>(s[i]))) {
|
||||||
|
++i;
|
||||||
|
}
|
||||||
|
out.emplace_back(s.substr(start, i - start));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In `target_link_libraries(<target> [PRIVATE|PUBLIC|INTERFACE] dep dep ...)`,
|
||||||
|
// every token after a visibility keyword that contains "::" is treated as
|
||||||
|
// an external link target. Tokens that look like CMake variables
|
||||||
|
// (${...}) or contain commas (e.g. "$<...>" generator-expressions) are
|
||||||
|
// dropped.
|
||||||
|
auto extract_link_targets(std::string_view args) -> std::vector<std::string> {
|
||||||
|
auto toks = tokenize(args);
|
||||||
|
std::vector<std::string> out;
|
||||||
|
bool past_visibility = false;
|
||||||
|
for (auto& t : toks) {
|
||||||
|
if (t == "PRIVATE" || t == "PUBLIC" || t == "INTERFACE") {
|
||||||
|
past_visibility = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!past_visibility) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (t.find("::") == std::string::npos) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (t.find('$') != std::string::npos) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push_back(std::move(t));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
auto parse_vcpkg_usage(std::string_view text) -> util::Result<VcpkgRecipe> {
|
||||||
|
auto fp_args = extract_call_args(text, "find_package");
|
||||||
|
if (!fp_args) {
|
||||||
|
return std::unexpected(error(
|
||||||
|
util::ErrorCode::ResolutionUnknownPackage,
|
||||||
|
"vcpkg usage file has no find_package(...) directive"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The args block is "fmt CONFIG REQUIRED" or similar. Strip leading/
|
||||||
|
// trailing whitespace + collapse runs to single spaces.
|
||||||
|
std::string find_package_args;
|
||||||
|
for (auto& tok : tokenize(*fp_args)) {
|
||||||
|
if (!find_package_args.empty()) {
|
||||||
|
find_package_args += ' ';
|
||||||
|
}
|
||||||
|
find_package_args += tok;
|
||||||
|
}
|
||||||
|
if (find_package_args.empty()) {
|
||||||
|
return std::unexpected(error(
|
||||||
|
util::ErrorCode::ResolutionUnknownPackage,
|
||||||
|
"vcpkg usage file has empty find_package() args"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// If REQUIRED isn't present, add it — generated CMake always wants it.
|
||||||
|
if (find_package_args.find("REQUIRED") == std::string::npos) {
|
||||||
|
find_package_args += " REQUIRED";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Targets: scrape the first target_link_libraries(...) call.
|
||||||
|
std::vector<std::string> targets;
|
||||||
|
if (auto tll_args = extract_call_args(text, "target_link_libraries")) {
|
||||||
|
targets = extract_link_targets(*tll_args);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stable-dedup.
|
||||||
|
std::vector<std::string> deduped;
|
||||||
|
for (auto& t : targets) {
|
||||||
|
if (std::ranges::find(deduped, t) == deduped.end()) {
|
||||||
|
deduped.push_back(std::move(t));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return VcpkgRecipe{
|
||||||
|
.find_package = std::move(find_package_args),
|
||||||
|
.targets = std::move(deduped),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
auto vcpkg_probe(const std::string& name) -> util::Result<VcpkgRecipe> {
|
||||||
|
if (name.empty()) {
|
||||||
|
return std::unexpected(error(util::ErrorCode::ResolutionUnknownPackage,
|
||||||
|
"package name is empty"));
|
||||||
|
}
|
||||||
|
auto url = std::format(
|
||||||
|
"https://raw.githubusercontent.com/microsoft/vcpkg/master/ports/{}/usage",
|
||||||
|
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) {
|
||||||
|
return std::unexpected(error(util::ErrorCode::ResolutionUnknownPackage,
|
||||||
|
std::format("vcpkg has no port '{}'", 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_vcpkg_usage(r->stdout_text);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace cargoxx::resolver
|
||||||
@@ -30,3 +30,5 @@ 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_parse)
|
||||||
cargoxx_add_test(conan_probe_live)
|
cargoxx_add_test(conan_probe_live)
|
||||||
|
cargoxx_add_test(vcpkg_probe_parse)
|
||||||
|
cargoxx_add_test(vcpkg_probe_live)
|
||||||
|
|||||||
43
tests/vcpkg_probe_live.cpp
Normal file
43
tests/vcpkg_probe_live.cpp
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// Network-gated integration test for resolver::vcpkg_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("vcpkg_probe finds 'fmt' in microsoft/vcpkg",
|
||||||
|
"[resolver][network]") {
|
||||||
|
if (!network_tests_enabled()) {
|
||||||
|
SKIP("CARGOXX_NETWORK_TESTS != 1");
|
||||||
|
}
|
||||||
|
auto r = cargoxx::resolver::vcpkg_probe("fmt");
|
||||||
|
REQUIRE(r.has_value());
|
||||||
|
REQUIRE(r->find_package.starts_with("fmt"));
|
||||||
|
REQUIRE_FALSE(r->targets.empty());
|
||||||
|
// fmt's vcpkg port should expose at least one fmt:: target.
|
||||||
|
REQUIRE(std::ranges::any_of(r->targets, [](const std::string& t) {
|
||||||
|
return t.starts_with("fmt::");
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("vcpkg_probe returns ResolutionUnknownPackage for a 404",
|
||||||
|
"[resolver][network]") {
|
||||||
|
if (!network_tests_enabled()) {
|
||||||
|
SKIP("CARGOXX_NETWORK_TESTS != 1");
|
||||||
|
}
|
||||||
|
auto r = cargoxx::resolver::vcpkg_probe(
|
||||||
|
"definitely_not_a_real_pkg_cargoxx_xyzzy");
|
||||||
|
REQUIRE_FALSE(r.has_value());
|
||||||
|
REQUIRE(r.error().code ==
|
||||||
|
cargoxx::util::ErrorCode::ResolutionUnknownPackage);
|
||||||
|
}
|
||||||
71
tests/vcpkg_probe_parse.cpp
Normal file
71
tests/vcpkg_probe_parse.cpp
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
#include <catch2/catch_test_macros.hpp>
|
||||||
|
|
||||||
|
import cargoxx.resolver;
|
||||||
|
import cargoxx.util;
|
||||||
|
import std;
|
||||||
|
|
||||||
|
using cargoxx::resolver::parse_vcpkg_usage;
|
||||||
|
using cargoxx::util::ErrorCode;
|
||||||
|
|
||||||
|
TEST_CASE("parse_vcpkg_usage extracts find_package and a target",
|
||||||
|
"[resolver][vcpkg]") {
|
||||||
|
constexpr std::string_view text = R"(fmt provides CMake targets:
|
||||||
|
|
||||||
|
find_package(fmt CONFIG REQUIRED)
|
||||||
|
target_link_libraries(main PRIVATE fmt::fmt)
|
||||||
|
)";
|
||||||
|
auto r = parse_vcpkg_usage(text);
|
||||||
|
REQUIRE(r.has_value());
|
||||||
|
REQUIRE(r->find_package == "fmt CONFIG REQUIRED");
|
||||||
|
REQUIRE(r->targets == std::vector<std::string>{"fmt::fmt"});
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("parse_vcpkg_usage adds REQUIRED when missing", "[resolver][vcpkg]") {
|
||||||
|
constexpr std::string_view text = R"(
|
||||||
|
find_package(spdlog CONFIG)
|
||||||
|
target_link_libraries(main PRIVATE spdlog::spdlog)
|
||||||
|
)";
|
||||||
|
auto r = parse_vcpkg_usage(text);
|
||||||
|
REQUIRE(r.has_value());
|
||||||
|
REQUIRE(r->find_package == "spdlog CONFIG REQUIRED");
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("parse_vcpkg_usage dedupes targets", "[resolver][vcpkg]") {
|
||||||
|
constexpr std::string_view text = R"(
|
||||||
|
find_package(boost CONFIG REQUIRED)
|
||||||
|
target_link_libraries(main PRIVATE Boost::filesystem Boost::system Boost::filesystem)
|
||||||
|
)";
|
||||||
|
auto r = parse_vcpkg_usage(text);
|
||||||
|
REQUIRE(r.has_value());
|
||||||
|
REQUIRE(r->targets == std::vector<std::string>{"Boost::filesystem", "Boost::system"});
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("parse_vcpkg_usage skips generator-expression and bare-name tokens",
|
||||||
|
"[resolver][vcpkg]") {
|
||||||
|
constexpr std::string_view text = R"(
|
||||||
|
find_package(qt6 CONFIG REQUIRED)
|
||||||
|
target_link_libraries(main PRIVATE Qt6::Core $<TARGET_NAME:Qt6::Gui> mylib)
|
||||||
|
)";
|
||||||
|
auto r = parse_vcpkg_usage(text);
|
||||||
|
REQUIRE(r.has_value());
|
||||||
|
// mylib lacks "::", $<...> contains '$' — both excluded.
|
||||||
|
REQUIRE(r->targets == std::vector<std::string>{"Qt6::Core"});
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("parse_vcpkg_usage errors when no find_package directive present",
|
||||||
|
"[resolver][vcpkg]") {
|
||||||
|
auto r = parse_vcpkg_usage("nothing useful here");
|
||||||
|
REQUIRE_FALSE(r.has_value());
|
||||||
|
REQUIRE(r.error().code == ErrorCode::ResolutionUnknownPackage);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("parse_vcpkg_usage handles target_link_libraries with PUBLIC visibility",
|
||||||
|
"[resolver][vcpkg]") {
|
||||||
|
constexpr std::string_view text = R"(
|
||||||
|
find_package(eigen3 CONFIG REQUIRED)
|
||||||
|
target_link_libraries(main PUBLIC Eigen3::Eigen)
|
||||||
|
)";
|
||||||
|
auto r = parse_vcpkg_usage(text);
|
||||||
|
REQUIRE(r.has_value());
|
||||||
|
REQUIRE(r->targets == std::vector<std::string>{"Eigen3::Eigen"});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user