diff --git a/CMakeLists.txt b/CMakeLists.txt index 481ddc0..bb147a0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/src/codegen/cmake.cpp b/src/codegen/cmake.cpp index b5ff00a..a353a62 100644 --- a/src/codegen/cmake.cpp +++ b/src/codegen/cmake.cpp @@ -86,11 +86,33 @@ auto emit_find_packages(const std::vector& recipes, return {}; } std::string out = "\n# ----- dependencies -----\n"; + + 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(c)) + ? static_cast(std::toupper( + static_cast(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) { - out += std::format("find_package({})\n", r.find_package); + emit_one(r); } for (const auto& r : dev_recipes) { - out += std::format("find_package({})\n", r.find_package); + emit_one(r); } return out; } diff --git a/src/codegen/flake.cpp b/src/codegen/flake.cpp index 30d7e38..09ecd73 100644 --- a/src/codegen/flake.cpp +++ b/src/codegen/flake.cpp @@ -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" diff --git a/src/linkdb/database.cpp b/src/linkdb/database.cpp index 15c72c8..38e8e65 100644 --- a/src/linkdb/database.cpp +++ b/src/linkdb/database.cpp @@ -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{ diff --git a/src/linkdb/linkdb.cppm b/src/linkdb/linkdb.cppm index 5796fc0..3d5f185 100644 --- a/src/linkdb/linkdb.cppm +++ b/src/linkdb/linkdb.cppm @@ -15,6 +15,16 @@ struct Recipe { std::vector 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( REQUIRED IMPORTED_TARGET ) + // and the recipe's `targets` list holds the synthesized + // PkgConfig:: entries. `find_package` for these recipes is + // the literal string "PkgConfig REQUIRED" — kept consistent so + // overlay rows don't need a sentinel. + std::optional pkg_config_module; + bool operator==(const Recipe&) const = default; }; @@ -29,6 +39,7 @@ struct OverlayRow { std::vector targets; std::string source; std::int64_t verified_at = 0; + std::optional pkg_config_module; }; // RAII wrapper for an open sqlite3 connection used by the overlay database. diff --git a/src/linkdb/overlay.cpp b/src/linkdb/overlay.cpp index 36a5e3f..0f3482b 100644 --- a/src/linkdb/overlay.cpp +++ b/src/linkdb/overlay.cpp @@ -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> { 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); diff --git a/src/resolver/discover.cpp b/src/resolver/discover.cpp index 2a4279d..a630bd8 100644 --- a/src/resolver/discover.cpp +++ b/src/resolver/discover.cpp @@ -48,6 +48,36 @@ auto recipe_from_nix_scan(const NixCmakeCandidate& n, }; } +// PkgConfig-shaped recipe. The synthesized `PkgConfig::` target +// name must match what CMake's `pkg_check_modules(... IMPORTED_TARGET +// )` 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::`. +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(c))) { + out += static_cast(std::toupper(static_cast(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 scan_hit; + std::optional 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( diff --git a/src/resolver/pc_scan.cpp b/src/resolver/pc_scan.cpp new file mode 100644 index 0000000..a798866 --- /dev/null +++ b/src/resolver/pc_scan.cpp @@ -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(std::tolower(static_cast(c))); + } + if (out.size() > 3 && out.starts_with("lib")) { + out.erase(0, 3); + } + auto is_vchar = [](char c) { + return std::isdigit(static_cast(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(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(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(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 { + 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 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 diff --git a/src/resolver/resolver.cppm b/src/resolver/resolver.cppm index 8847780..c090e81 100644 --- a/src/resolver/resolver.cppm +++ b/src/resolver/resolver.cppm @@ -77,6 +77,22 @@ auto nix_cmake_scan(const std::filesystem::path& store_path, const std::string& package_name) -> util::Result; +// 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( REQUIRED IMPORTED_TARGET )`, +// linking against the generated `PkgConfig::` target. +struct PcCandidate { + std::string pc_module; // file stem, e.g. "sqlite3" + std::filesystem::path pc_file; // path to the .pc on disk +}; + +// Walks /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; + // Output of a conan-center-index recipe scrape. struct ConanRecipe { std::string find_package; // e.g. "fmt CONFIG REQUIRED"