From 941d5b3284e014da6fe78bec0b26071570c2271b Mon Sep 17 00:00:00 2001 From: Amadey Vorontsov Date: Sun, 10 May 2026 10:23:57 +0000 Subject: [PATCH] [M5+] add resolver::vcpkg_probe --- CHANGELOG.md | 9 ++ CMakeLists.txt | 1 + docs/auto-resolution.md | 4 +- src/resolver/resolver.cppm | 18 ++++ src/resolver/vcpkg_probe.cpp | 173 +++++++++++++++++++++++++++++++++++ tests/CMakeLists.txt | 2 + tests/vcpkg_probe_live.cpp | 43 +++++++++ tests/vcpkg_probe_parse.cpp | 71 ++++++++++++++ 8 files changed, 319 insertions(+), 2 deletions(-) create mode 100644 src/resolver/vcpkg_probe.cpp create mode 100644 tests/vcpkg_probe_live.cpp create mode 100644 tests/vcpkg_probe_parse.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 0339610..9f9abaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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//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//all/conanfile.py` via `curl` (text-only — never executes Python, per `SPEC.md` §14) diff --git a/CMakeLists.txt b/CMakeLists.txt index b5f03c2..3c26fe2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/docs/auto-resolution.md b/docs/auto-resolution.md index 25837d0..cabefcd 100644 --- a/docs/auto-resolution.md +++ b/docs/auto-resolution.md @@ -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 | — | diff --git a/src/resolver/resolver.cppm b/src/resolver/resolver.cppm index 6f833f6..6c1a6ee 100644 --- a/src/resolver/resolver.cppm +++ b/src/resolver/resolver.cppm @@ -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; +// Output of a microsoft/vcpkg port usage-file scrape. +struct VcpkgRecipe { + std::string find_package; // e.g. "fmt CONFIG REQUIRED" + std::vector 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; + +// Fetches https://raw.githubusercontent.com/microsoft/vcpkg/master/ports//usage +// via `curl` and feeds it through parse_vcpkg_usage. 404 → +// ResolutionUnknownPackage; transport errors → ResolutionNetworkError. +auto vcpkg_probe(const std::string& name) -> util::Result; + } // namespace cargoxx::resolver diff --git a/src/resolver/vcpkg_probe.cpp b/src/resolver/vcpkg_probe.cpp new file mode 100644 index 0000000..f867080 --- /dev/null +++ b/src/resolver/vcpkg_probe.cpp @@ -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::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(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::vector out; + std::size_t i = 0; + while (i < s.size()) { + while (i < s.size() && std::isspace(static_cast(s[i]))) { + ++i; + } + if (i >= s.size()) { + break; + } + std::size_t start = i; + while (i < s.size() && !std::isspace(static_cast(s[i]))) { + ++i; + } + out.emplace_back(s.substr(start, i - start)); + } + return out; +} + +// In `target_link_libraries( [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 { + auto toks = tokenize(args); + std::vector 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 { + 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 targets; + if (auto tll_args = extract_call_args(text, "target_link_libraries")) { + targets = extract_link_targets(*tll_args); + } + + // Stable-dedup. + std::vector 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 { + 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 diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 7e30aae..422634a 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -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) diff --git a/tests/vcpkg_probe_live.cpp b/tests/vcpkg_probe_live.cpp new file mode 100644 index 0000000..adafe5e --- /dev/null +++ b/tests/vcpkg_probe_live.cpp @@ -0,0 +1,43 @@ +// Network-gated integration test for resolver::vcpkg_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("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); +} diff --git a/tests/vcpkg_probe_parse.cpp b/tests/vcpkg_probe_parse.cpp new file mode 100644 index 0000000..e313ff9 --- /dev/null +++ b/tests/vcpkg_probe_parse.cpp @@ -0,0 +1,71 @@ +#include + +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{"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{"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 $ mylib) +)"; + auto r = parse_vcpkg_usage(text); + REQUIRE(r.has_value()); + // mylib lacks "::", $<...> contains '$' — both excluded. + REQUIRE(r->targets == std::vector{"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{"Eigen3::Eigen"}); +}