[M6] resolver: pkg-config probe + codegen for PkgConfig
This commit is contained in:
@@ -50,6 +50,7 @@ target_sources(cargoxx
|
||||
src/exec/subprocess.cpp
|
||||
src/resolver/nixpkgs_probe.cpp
|
||||
src/resolver/nix_cmake_scan.cpp
|
||||
src/resolver/pc_scan.cpp
|
||||
src/resolver/conan_probe.cpp
|
||||
src/resolver/vcpkg_probe.cpp
|
||||
src/resolver/verify_link.cpp
|
||||
|
||||
@@ -86,11 +86,33 @@ auto emit_find_packages(const std::vector<linkdb::Recipe>& recipes,
|
||||
return {};
|
||||
}
|
||||
std::string out = "\n# ----- dependencies -----\n";
|
||||
for (const auto& r : recipes) {
|
||||
|
||||
bool pkgconfig_emitted = false;
|
||||
auto emit_one = [&](const linkdb::Recipe& r) {
|
||||
if (r.pkg_config_module && !r.pkg_config_module->empty()) {
|
||||
if (!pkgconfig_emitted) {
|
||||
out += "find_package(PkgConfig REQUIRED)\n";
|
||||
pkgconfig_emitted = true;
|
||||
}
|
||||
std::string upper;
|
||||
upper.reserve(r.pkg_config_module->size());
|
||||
for (char c : *r.pkg_config_module) {
|
||||
upper += std::isalnum(static_cast<unsigned char>(c))
|
||||
? static_cast<char>(std::toupper(
|
||||
static_cast<unsigned char>(c)))
|
||||
: '_';
|
||||
}
|
||||
out += std::format("pkg_check_modules({} REQUIRED IMPORTED_TARGET {})\n",
|
||||
upper, *r.pkg_config_module);
|
||||
} else {
|
||||
out += std::format("find_package({})\n", r.find_package);
|
||||
}
|
||||
};
|
||||
for (const auto& r : recipes) {
|
||||
emit_one(r);
|
||||
}
|
||||
for (const auto& r : dev_recipes) {
|
||||
out += std::format("find_package({})\n", r.find_package);
|
||||
emit_one(r);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -167,6 +167,18 @@ auto flake_nix(const GenerateInputs& in) -> std::string {
|
||||
|
||||
out += emit_inputs_block(pinned);
|
||||
|
||||
const bool any_pkg_config =
|
||||
std::ranges::any_of(in.recipes,
|
||||
[](const linkdb::Recipe& r) {
|
||||
return r.pkg_config_module &&
|
||||
!r.pkg_config_module->empty();
|
||||
}) ||
|
||||
std::ranges::any_of(in.dev_recipes,
|
||||
[](const linkdb::Recipe& r) {
|
||||
return r.pkg_config_module &&
|
||||
!r.pkg_config_module->empty();
|
||||
});
|
||||
|
||||
out += "\n";
|
||||
out += " outputs = ";
|
||||
out += emit_outputs_params(pinned);
|
||||
@@ -181,8 +193,11 @@ auto flake_nix(const GenerateInputs& in) -> std::string {
|
||||
" version = \"1.0\";\n"
|
||||
" nativeBuildInputs = [\n"
|
||||
" pkgs.ninja\n"
|
||||
" pkgs.cmake\n"
|
||||
" ];\n"
|
||||
" pkgs.cmake\n";
|
||||
if (any_pkg_config) {
|
||||
out += " pkgs.pkg-config\n";
|
||||
}
|
||||
out += " ];\n"
|
||||
" buildInputs = [\n";
|
||||
out += emit_build_inputs(bindings);
|
||||
out += " ];\n"
|
||||
|
||||
@@ -64,6 +64,7 @@ auto Database::resolve(const std::string& package, const std::string& version,
|
||||
.find_package = row.find_package,
|
||||
.targets = row.targets,
|
||||
.source = row.source,
|
||||
.pkg_config_module = row.pkg_config_module,
|
||||
};
|
||||
}
|
||||
return std::unexpected(util::Error{
|
||||
|
||||
@@ -15,6 +15,16 @@ struct Recipe {
|
||||
std::vector<std::string> targets; // post-substitution
|
||||
std::string source; // 'curated' | 'manual' | etc.
|
||||
|
||||
// When set, the dep is consumed via PkgConfig instead of a normal
|
||||
// find_package. Codegen emits
|
||||
// find_package(PkgConfig REQUIRED)
|
||||
// pkg_check_modules(<UPPER> REQUIRED IMPORTED_TARGET <pkg_config_module>)
|
||||
// and the recipe's `targets` list holds the synthesized
|
||||
// PkgConfig::<UPPER> entries. `find_package` for these recipes is
|
||||
// the literal string "PkgConfig REQUIRED" — kept consistent so
|
||||
// overlay rows don't need a sentinel.
|
||||
std::optional<std::string> pkg_config_module;
|
||||
|
||||
bool operator==(const Recipe&) const = default;
|
||||
};
|
||||
|
||||
@@ -29,6 +39,7 @@ struct OverlayRow {
|
||||
std::vector<std::string> targets;
|
||||
std::string source;
|
||||
std::int64_t verified_at = 0;
|
||||
std::optional<std::string> pkg_config_module;
|
||||
};
|
||||
|
||||
// RAII wrapper for an open sqlite3 connection used by the overlay database.
|
||||
|
||||
@@ -90,6 +90,26 @@ auto overlay_open(const std::filesystem::path& path)
|
||||
});
|
||||
}
|
||||
|
||||
// Schema migration: legacy overlays predate pkg_config_module.
|
||||
// SQLite ADD COLUMN errors when the column already exists; treat
|
||||
// "duplicate column" as success.
|
||||
constexpr const char* MIGRATE_PC =
|
||||
"ALTER TABLE recipes ADD COLUMN pkg_config_module TEXT";
|
||||
char* mig_err = nullptr;
|
||||
if (sqlite3_exec(state->handle(), MIGRATE_PC, nullptr, nullptr, &mig_err) !=
|
||||
SQLITE_OK) {
|
||||
if (mig_err && std::string_view{mig_err}.find("duplicate column") ==
|
||||
std::string_view::npos) {
|
||||
std::string msg = std::format("cannot migrate overlay schema: {}",
|
||||
mig_err ? mig_err : "?");
|
||||
sqlite3_free(mig_err);
|
||||
return std::unexpected(util::Error{
|
||||
util::ErrorCode::LinkdbCorrupt, std::move(msg), "", path, std::nullopt,
|
||||
});
|
||||
}
|
||||
sqlite3_free(mig_err);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -99,8 +119,8 @@ auto overlay_insert_manual(OverlayState& state, const std::string& package,
|
||||
constexpr const char* SQL =
|
||||
"INSERT OR REPLACE INTO recipes "
|
||||
"(package, version_range, nixpkgs_attr, find_package, targets, components, source, "
|
||||
" verified_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, NULL, 'manual', ?)";
|
||||
" verified_at, pkg_config_module) "
|
||||
"VALUES (?, ?, ?, ?, ?, NULL, 'manual', ?, ?)";
|
||||
|
||||
sqlite3* db = state.handle();
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
@@ -119,6 +139,11 @@ auto overlay_insert_manual(OverlayState& state, const std::string& package,
|
||||
sqlite3_bind_text(stmt, 4, r.find_package.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 5, targets_str.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int64(stmt, 6, now);
|
||||
if (r.pkg_config_module) {
|
||||
sqlite3_bind_text(stmt, 7, r.pkg_config_module->c_str(), -1, SQLITE_TRANSIENT);
|
||||
} else {
|
||||
sqlite3_bind_null(stmt, 7);
|
||||
}
|
||||
|
||||
auto rc = sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
@@ -137,8 +162,8 @@ auto overlay_insert(OverlayState& state, const std::string& package,
|
||||
constexpr const char* SQL =
|
||||
"INSERT OR REPLACE INTO recipes "
|
||||
"(package, version_range, nixpkgs_attr, find_package, targets, components, source, "
|
||||
" verified_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, NULL, ?, ?)";
|
||||
" verified_at, pkg_config_module) "
|
||||
"VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?)";
|
||||
|
||||
sqlite3* db = state.handle();
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
@@ -155,6 +180,11 @@ auto overlay_insert(OverlayState& state, const std::string& package,
|
||||
sqlite3_bind_text(stmt, 5, targets_str.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 6, source.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int64(stmt, 7, verified_at);
|
||||
if (r.pkg_config_module) {
|
||||
sqlite3_bind_text(stmt, 8, r.pkg_config_module->c_str(), -1, SQLITE_TRANSIENT);
|
||||
} else {
|
||||
sqlite3_bind_null(stmt, 8);
|
||||
}
|
||||
|
||||
auto rc = sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
@@ -245,7 +275,8 @@ auto overlay_evict_auto(OverlayState& state, const std::string& package)
|
||||
auto overlay_query(OverlayState& state, const std::string& package)
|
||||
-> util::Result<std::vector<OverlayRow>> {
|
||||
constexpr const char* SQL =
|
||||
"SELECT version_range, nixpkgs_attr, find_package, targets, source, verified_at "
|
||||
"SELECT version_range, nixpkgs_attr, find_package, targets, source, verified_at, "
|
||||
" pkg_config_module "
|
||||
"FROM recipes WHERE package = ?";
|
||||
|
||||
sqlite3* db = state.handle();
|
||||
@@ -276,6 +307,9 @@ auto overlay_query(OverlayState& state, const std::string& package)
|
||||
}
|
||||
row.source = column_text(stmt, 4);
|
||||
row.verified_at = sqlite3_column_int64(stmt, 5);
|
||||
if (sqlite3_column_type(stmt, 6) != SQLITE_NULL) {
|
||||
row.pkg_config_module = column_text(stmt, 6);
|
||||
}
|
||||
out.push_back(std::move(row));
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
@@ -48,6 +48,36 @@ auto recipe_from_nix_scan(const NixCmakeCandidate& n,
|
||||
};
|
||||
}
|
||||
|
||||
// PkgConfig-shaped recipe. The synthesized `PkgConfig::<NAME>` target
|
||||
// name must match what CMake's `pkg_check_modules(... IMPORTED_TARGET
|
||||
// <module>)` produces: the third arg of pkg_check_modules becomes the
|
||||
// prefix variable AND the imported-target suffix. We pass the
|
||||
// uppercased module stem so the target is `PkgConfig::<UPPER>`.
|
||||
auto upper_pc_name(std::string_view module_name) -> std::string {
|
||||
std::string out;
|
||||
out.reserve(module_name.size());
|
||||
for (char c : module_name) {
|
||||
if (std::isalnum(static_cast<unsigned char>(c))) {
|
||||
out += static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
|
||||
} else {
|
||||
out += '_';
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
auto recipe_from_pc(const PcCandidate& p, const std::string& nixpkgs_attr,
|
||||
const std::string& source) -> linkdb::Recipe {
|
||||
auto upper = upper_pc_name(p.pc_module);
|
||||
return linkdb::Recipe{
|
||||
.nixpkgs_attr = nixpkgs_attr,
|
||||
.find_package = "PkgConfig REQUIRED",
|
||||
.targets = {std::format("PkgConfig::{}", upper)},
|
||||
.source = source,
|
||||
.pkg_config_module = p.pc_module,
|
||||
};
|
||||
}
|
||||
|
||||
struct Candidate {
|
||||
std::string source;
|
||||
linkdb::Recipe recipe;
|
||||
@@ -109,16 +139,35 @@ auto discover(const std::string& name, const std::string& version_spec,
|
||||
return std::nullopt;
|
||||
};
|
||||
std::optional<NixCmakeCandidate> scan_hit;
|
||||
std::optional<PcCandidate> pc_hit;
|
||||
std::string realized_dev_path;
|
||||
if (!info->dev_path.empty()) {
|
||||
scan_hit = realize_and_scan("dev");
|
||||
if (auto realized = realize_path(std::format("{}.dev", name)); realized) {
|
||||
realized_dev_path = *realized;
|
||||
}
|
||||
}
|
||||
if (!scan_hit) {
|
||||
scan_hit = realize_and_scan("");
|
||||
}
|
||||
if (realized_dev_path.empty()) {
|
||||
if (auto realized = realize_path(name); realized) {
|
||||
realized_dev_path = *realized;
|
||||
}
|
||||
}
|
||||
if (scan_hit) {
|
||||
candidates.push_back(
|
||||
{"nix-probe", recipe_from_nix_scan(*scan_hit, name, "nix-probe")});
|
||||
}
|
||||
if (!realized_dev_path.empty()) {
|
||||
if (auto p = pc_scan(fs::path{realized_dev_path}, name); p) {
|
||||
pc_hit = std::move(*p);
|
||||
}
|
||||
}
|
||||
if (pc_hit) {
|
||||
candidates.push_back(
|
||||
{"pkg-config", recipe_from_pc(*pc_hit, name, "pkg-config")});
|
||||
}
|
||||
|
||||
if (candidates.empty()) {
|
||||
return std::unexpected(error(
|
||||
|
||||
161
src/resolver/pc_scan.cpp
Normal file
161
src/resolver/pc_scan.cpp
Normal file
@@ -0,0 +1,161 @@
|
||||
module cargoxx.resolver;
|
||||
|
||||
import std;
|
||||
import cargoxx.util;
|
||||
|
||||
namespace cargoxx::resolver {
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace {
|
||||
|
||||
// Mirrors `normalize` in nix_cmake_scan.cpp. Kept local rather than
|
||||
// extracted so each scanner is self-contained; a future Phase A
|
||||
// refactor will hoist this into a shared module.
|
||||
auto normalize(std::string_view s) -> std::string {
|
||||
std::string out;
|
||||
out.reserve(s.size());
|
||||
for (char c : s) {
|
||||
out += static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||
}
|
||||
if (out.size() > 3 && out.starts_with("lib")) {
|
||||
out.erase(0, 3);
|
||||
}
|
||||
auto is_vchar = [](char c) {
|
||||
return std::isdigit(static_cast<unsigned char>(c)) || c == '.'
|
||||
|| c == '-' || c == '_';
|
||||
};
|
||||
std::size_t end = out.size();
|
||||
while (end > 0 && is_vchar(out[end - 1])) {
|
||||
--end;
|
||||
}
|
||||
bool has_digit = false;
|
||||
for (auto i = end; i < out.size(); ++i) {
|
||||
if (std::isdigit(static_cast<unsigned char>(out[i]))) {
|
||||
has_digit = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (has_digit) {
|
||||
out.erase(end);
|
||||
if (!out.empty() && (out.back() == '-' || out.back() == '_')) {
|
||||
out.pop_back();
|
||||
}
|
||||
}
|
||||
std::string compact;
|
||||
compact.reserve(out.size());
|
||||
for (char c : out) {
|
||||
if (std::isalnum(static_cast<unsigned char>(c))) {
|
||||
compact += c;
|
||||
}
|
||||
}
|
||||
return compact;
|
||||
}
|
||||
|
||||
// Same scoring as nix_cmake_scan: 0 exact, 1 prefix-either, 2 fallback.
|
||||
auto match_score(std::string_view stem, std::string_view pkg) -> int {
|
||||
auto s = normalize(stem);
|
||||
auto q = normalize(pkg);
|
||||
if (q.empty()) {
|
||||
return 2;
|
||||
}
|
||||
if (s == q) {
|
||||
return 0;
|
||||
}
|
||||
if (!s.empty() && (s.starts_with(q) || q.starts_with(s))) {
|
||||
return 1;
|
||||
}
|
||||
return 2;
|
||||
}
|
||||
|
||||
// Defensive: read a few KB to verify the .pc has at least one Name: or
|
||||
// Libs: line. We don't parse the file — `pkg_check_modules` does that
|
||||
// at CMake time — but a sanity check rejects empty/junk files.
|
||||
auto looks_like_pc(const fs::path& path) -> bool {
|
||||
std::ifstream in{path};
|
||||
if (!in) {
|
||||
return false;
|
||||
}
|
||||
char buf[4096];
|
||||
in.read(buf, sizeof(buf));
|
||||
std::string_view chunk{buf, static_cast<std::size_t>(in.gcount())};
|
||||
return chunk.find("\nName:") != std::string_view::npos ||
|
||||
chunk.starts_with("Name:") ||
|
||||
chunk.find("\nLibs:") != std::string_view::npos ||
|
||||
chunk.starts_with("Libs:");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
auto pc_scan(const fs::path& store_path, const std::string& package_name)
|
||||
-> util::Result<PcCandidate> {
|
||||
const auto pc_dir = store_path / "lib" / "pkgconfig";
|
||||
std::error_code ec;
|
||||
if (!fs::exists(pc_dir, ec) || ec) {
|
||||
return std::unexpected(util::Error{
|
||||
util::ErrorCode::ResolutionUnknownPackage,
|
||||
std::format("no pkgconfig directory under '{}'", pc_dir.string()),
|
||||
"", store_path, std::nullopt,
|
||||
});
|
||||
}
|
||||
|
||||
struct Match {
|
||||
int score;
|
||||
std::string stem;
|
||||
fs::path path;
|
||||
};
|
||||
std::vector<Match> matches;
|
||||
|
||||
for (const auto& entry : fs::directory_iterator{
|
||||
pc_dir, fs::directory_options::skip_permission_denied, ec}) {
|
||||
if (!entry.is_regular_file()) {
|
||||
continue;
|
||||
}
|
||||
if (entry.path().extension() != ".pc") {
|
||||
continue;
|
||||
}
|
||||
if (!looks_like_pc(entry.path())) {
|
||||
continue;
|
||||
}
|
||||
auto stem = entry.path().stem().string();
|
||||
matches.push_back(Match{
|
||||
.score = match_score(stem, package_name),
|
||||
.stem = stem,
|
||||
.path = entry.path(),
|
||||
});
|
||||
}
|
||||
|
||||
if (matches.empty()) {
|
||||
return std::unexpected(util::Error{
|
||||
util::ErrorCode::ResolutionUnknownPackage,
|
||||
std::format("no usable .pc file under '{}'", pc_dir.string()),
|
||||
"", store_path, std::nullopt,
|
||||
});
|
||||
}
|
||||
|
||||
std::ranges::stable_sort(matches, [](const Match& a, const Match& b) {
|
||||
if (a.score != b.score) {
|
||||
return a.score < b.score;
|
||||
}
|
||||
if (a.stem.size() != b.stem.size()) {
|
||||
return a.stem.size() < b.stem.size();
|
||||
}
|
||||
return a.stem < b.stem;
|
||||
});
|
||||
|
||||
if (matches.front().score >= 2) {
|
||||
return std::unexpected(util::Error{
|
||||
util::ErrorCode::ResolutionUnknownPackage,
|
||||
std::format("no .pc file under '{}' matches package name '{}'",
|
||||
pc_dir.string(), package_name),
|
||||
"", store_path, std::nullopt,
|
||||
});
|
||||
}
|
||||
|
||||
return PcCandidate{
|
||||
.pc_module = std::move(matches.front().stem),
|
||||
.pc_file = std::move(matches.front().path),
|
||||
};
|
||||
}
|
||||
|
||||
} // namespace cargoxx::resolver
|
||||
@@ -77,6 +77,22 @@ auto nix_cmake_scan(const std::filesystem::path& store_path,
|
||||
const std::string& package_name)
|
||||
-> util::Result<NixCmakeCandidate>;
|
||||
|
||||
// A pkg-config-shaped recipe: the package ships a `.pc` file rather
|
||||
// than a CMake config. Consumed via `find_package(PkgConfig REQUIRED)`
|
||||
// + `pkg_check_modules(<NAME> REQUIRED IMPORTED_TARGET <pc_module>)`,
|
||||
// linking against the generated `PkgConfig::<NAME>` target.
|
||||
struct PcCandidate {
|
||||
std::string pc_module; // file stem, e.g. "sqlite3"
|
||||
std::filesystem::path pc_file; // path to the .pc on disk
|
||||
};
|
||||
|
||||
// Walks <store_path>/lib/pkgconfig/*.pc and picks the best match for
|
||||
// `package_name`. Returns ResolutionUnknownPackage when no `.pc` file
|
||||
// is present or none scores acceptably.
|
||||
auto pc_scan(const std::filesystem::path& store_path,
|
||||
const std::string& package_name)
|
||||
-> util::Result<PcCandidate>;
|
||||
|
||||
// Output of a conan-center-index recipe scrape.
|
||||
struct ConanRecipe {
|
||||
std::string find_package; // e.g. "fmt CONFIG REQUIRED"
|
||||
|
||||
Reference in New Issue
Block a user