[M5+] add resolver::vcpkg_probe

This commit is contained in:
2026-05-10 10:23:57 +00:00
parent e5c173b466
commit 941d5b3284
8 changed files with 319 additions and 2 deletions

View File

@@ -78,6 +78,15 @@ 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::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
`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)

View File

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

View File

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

View File

@@ -71,4 +71,22 @@ auto parse_conanfile(std::string_view conanfile_text, const std::string& fallbac
// ResolutionNetworkError.
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

View 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

View File

@@ -30,3 +30,5 @@ 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)
cargoxx_add_test(vcpkg_probe_parse)
cargoxx_add_test(vcpkg_probe_live)

View 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);
}

View 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"});
}