[M5+] add resolver::nix_cmake_scan
This commit is contained in:
14
CHANGELOG.md
14
CHANGELOG.md
@@ -78,6 +78,20 @@ 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::nix_cmake_scan(store_path, package_name)` — walks
|
||||
`<store_path>/lib/cmake/**` for `*Config.cmake` / `*-config.cmake`
|
||||
files, scans them and their sibling `.cmake` files (e.g. the
|
||||
`<pkg>-targets.cmake` that real packages like fmt put their
|
||||
`add_library(... IMPORTED)` calls in) for IMPORTED + ALIAS targets,
|
||||
and returns the `NixCmakeCandidate` whose derived stem best matches
|
||||
`package_name` (case-insensitive equality > prefix > first non-empty).
|
||||
Pure helpers `scan_imported_targets(text)` and
|
||||
`config_stem_to_package(filename)` are exported for unit testing.
|
||||
`tests/nix_cmake_scan_parse.cpp` covers 11 cases including a fixture
|
||||
that mirrors fmt's `<pkg>-config.cmake → <pkg>-targets.cmake`
|
||||
layout. `tests/nix_cmake_scan_live.cpp` (gated by
|
||||
`CARGOXX_NETWORK_TESTS=1`) realizes `nixpkgs#fmt.dev` via
|
||||
`nix build` and verifies `fmt::fmt` is discovered end-to-end.
|
||||
- `cargoxx.resolver::nixpkgs_probe(attr)` — runs
|
||||
`nix eval nixpkgs#<attr> --json --apply 'p: { version, path }'` via
|
||||
`exec::run` and returns a `NixpkgsInfo { attr, version, out_path }`.
|
||||
|
||||
@@ -53,6 +53,7 @@ target_sources(cargoxx
|
||||
src/codegen/cmake.cpp
|
||||
src/exec/subprocess.cpp
|
||||
src/resolver/nixpkgs_probe.cpp
|
||||
src/resolver/nix_cmake_scan.cpp
|
||||
src/cli/cmd_new.cpp
|
||||
src/cli/cmd_build.cpp
|
||||
src/cli/cmd_run.cpp
|
||||
|
||||
273
docs/auto-resolution.md
Normal file
273
docs/auto-resolution.md
Normal file
@@ -0,0 +1,273 @@
|
||||
# Auto-resolution for non-curated packages
|
||||
|
||||
Status: in progress. Tracks the implementation of `cargoxx add <pkg>` for
|
||||
packages that are not in `data/linkdb.json`. See `SPEC.md` §9 step 4–6 for
|
||||
the contract this implements.
|
||||
|
||||
## Goal
|
||||
|
||||
Today `cargoxx add` only succeeds for the 25 packages baked into
|
||||
`data/linkdb.json`. This work extends `cargoxx add <pkg>` to fall through
|
||||
to the user's local machine and, on success, persist the discovered
|
||||
recipe to the SQLite overlay so subsequent runs are instant.
|
||||
|
||||
The user-stated steps:
|
||||
|
||||
1. confirm the package exists in `nixpkgs` (`nixos-unstable`),
|
||||
2. discover its CMake `find_package` / target rules via Conan, then vcpkg,
|
||||
then by scanning `lib/cmake/**/*Config.cmake` under the package's nix
|
||||
store path,
|
||||
3. verify the candidate by building an empty program that links the dep,
|
||||
4. record the version (already in hand from step 1's `nix eval`),
|
||||
5. write the recipe to the overlay so it sticks.
|
||||
|
||||
## Design decisions
|
||||
|
||||
| Decision | Choice | Why |
|
||||
| --- | --- | --- |
|
||||
| Verify depth | full `cargoxx build` of a tmp project | catches link / ABI errors that configure-only would miss (e.g. abseil-cpp's libstdc++ vs libc++ mismatch already exposed by `verify-curated-db.sh`) |
|
||||
| Probe order | Conan → vcpkg → nix-cmake-scan; first that *passes verification* wins; failed candidates fall through | maximizes hit rate without polluting overlay |
|
||||
| Discovery side-effects | `Database::resolve()` stays pure (overlay+curated only); a separate `Database::discover()` does network + verify + persist | preserves the existing test surface; `cmd_add` orchestrates the chain |
|
||||
| Failure caching | populate `resolution_failures` (already in schema) when *all* probes fail; subsequent retries within 24 h short-circuit | prevents repeated minute-long retries |
|
||||
| Verification result handling | scaffold tmp project, write provisional overlay row with `verified_at = 0`, build; on success rewrite `verified_at = now`; on failure delete the row | overlay only ever holds verified recipes |
|
||||
|
||||
## Resolution chain
|
||||
|
||||
```
|
||||
db.resolve(name, version, components)
|
||||
├─ overlay rows (existing)
|
||||
├─ curated JSON (existing)
|
||||
└─ on LinkdbUnknownPackage → cmd_add calls db.discover(name, project_root)
|
||||
├─ nixpkgs probe: nix eval nixpkgs#<name> for { version, path }
|
||||
│ fail → resolution_failures, return error
|
||||
├─ Conan probe: GET conan-center-index/recipes/<name>/all/conanfile.py
|
||||
│ regex out cmake_target_name + cmake_file_name
|
||||
├─ vcpkg probe: GET microsoft/vcpkg/ports/<name>/usage
|
||||
│ parse the literal CMake snippet
|
||||
├─ nix-cmake-scan: walk <path>/lib/cmake/**/*Config.cmake
|
||||
│ regex add_library(<name> ... IMPORTED) for targets
|
||||
│ derive find_package name from the *Config.cmake filename stem
|
||||
│
|
||||
├─ for each candidate (in order above):
|
||||
│ verify_link(candidate, name, version, components, overlay_path)
|
||||
│ — scaffold tmp project (cmd_new),
|
||||
│ — provisional overlay row pointing at the candidate,
|
||||
│ — write empty src/main.cpp,
|
||||
│ — call cmd_build(no_build = false) to run nix develop -c
|
||||
│ cmake configure + build,
|
||||
│ — succeeds → rewrite overlay row with verified_at = now;
|
||||
│ return Recipe to caller
|
||||
│ — fails → delete provisional row, try next probe
|
||||
│
|
||||
└─ all candidates failed → record to resolution_failures;
|
||||
return ResolutionUnsatisfiable
|
||||
```
|
||||
|
||||
## File layout
|
||||
|
||||
```
|
||||
src/resolver/
|
||||
├── resolver.cppm # public API surface for all resolver helpers
|
||||
├── nixpkgs_probe.cpp # ✅ Phase 1 (committed: 1c7ff39)
|
||||
├── nix_cmake_scan.cpp # Phase 2
|
||||
├── conan_probe.cpp # Phase 3
|
||||
├── vcpkg_probe.cpp # Phase 4
|
||||
└── verify_link.cpp # Phase 5
|
||||
```
|
||||
|
||||
`Database::discover` and the `cmd_add` wire-up land in Phase 6 by editing
|
||||
`src/linkdb/curated.cpp`, `src/linkdb/overlay.cpp`, and
|
||||
`src/cli/cmd_add.cpp`.
|
||||
|
||||
The deferred files in `TECH_SPEC.md` §1 (`nixhub.cpp`, `lazamar.cpp`,
|
||||
`nixpkgs_git.cpp`) belong to a separate feature — the *version* resolver
|
||||
that picks a concrete version from a range. Out of scope here.
|
||||
|
||||
## Critical files (re-)used
|
||||
|
||||
| File | Why |
|
||||
| --- | --- |
|
||||
| `src/linkdb/linkdb.cppm` | extend with `Database::discover()` declaration |
|
||||
| `src/linkdb/curated.cpp:158` | `Database::resolve` already does overlay → curated; discovery is *not* folded in here, kept side-effect free |
|
||||
| `src/linkdb/overlay.cpp` | split `overlay_insert_manual` → `overlay_insert_recipe(row, source)` so non-`manual` sources are persistable; add `overlay_delete_recipe`; add `overlay_record_failure` for `resolution_failures` |
|
||||
| `src/cli/cmd_add.cpp:48` | after `db->resolve(...)` returns `LinkdbUnknownPackage`, call `db->discover(name, project_root)` and use the returned recipe |
|
||||
| `src/exec/exec.cppm`, `src/exec/subprocess.cpp` | reuse `exec::run` for `nix eval` and `curl` — no new tooling, just new call sites |
|
||||
| `src/util/util.cppm` | reuse `ResolutionUnknownPackage` (E40), `ResolutionNetworkError` (E41), `ResolutionUnsatisfiable` (E42); no new error codes |
|
||||
| `src/cli/cmd_build.cpp` | called by `verify_link.cpp`; takes `overlay_path` and `project_root`; no signature change needed |
|
||||
| `scripts/verify-curated-db.sh` | conceptual template for the `verify_link` flow — same pattern as that script, in code form |
|
||||
|
||||
## Probe specs
|
||||
|
||||
### A. nixpkgs_probe (✅ done — Phase 1, 1c7ff39)
|
||||
|
||||
```
|
||||
nix eval nixpkgs#<pkg> --json --apply 'p: { version = p.version or ""; path = p.outPath; }'
|
||||
```
|
||||
|
||||
- `--extra-experimental-features 'nix-command flakes'` baked into the call
|
||||
so it works without user-side `nix.conf` flags.
|
||||
- 60 s `ExecOptions.timeout`.
|
||||
- Failure modes: missing attribute (`stderr` has `does not provide attribute`)
|
||||
→ `ResolutionUnknownPackage`; otherwise `ResolutionNetworkError`.
|
||||
- Returned: `NixpkgsInfo { attr, version, out_path }`.
|
||||
- Field name **must** be `path`, not `outPath`. nix's `--json` mode coerces
|
||||
any attrset containing `outPath` to a bare-string derivation reference,
|
||||
which would lose the `version` field.
|
||||
|
||||
### B. nix_cmake_scan (Phase 2, next)
|
||||
|
||||
- Walk `<out_path>/lib/cmake/` recursively.
|
||||
- For each `<X>Config.cmake` or `<X>-config.cmake`:
|
||||
- `find_package` name = stem `<X>`.
|
||||
- Read file. Regex
|
||||
`add_library\(([^ ]+)\s+(STATIC|SHARED|INTERFACE|UNKNOWN)\s+IMPORTED\)`
|
||||
to extract IMPORTED targets.
|
||||
- Also pick up `add_library(<alias> ALIAS <real>)` so the canonical
|
||||
`<alias>::<sub>` form gets detected.
|
||||
- Pick best candidate:
|
||||
1. case-insensitive equality between stem and `package_name`,
|
||||
2. prefix match,
|
||||
3. first config with non-empty target list.
|
||||
- Returns `NixCmakeCandidate { find_package, targets, config_file }` or
|
||||
`ResolutionUnknownPackage`.
|
||||
|
||||
### C. Conan probe (Phase 3)
|
||||
|
||||
- Text-only — never executes Python. SPEC §14 mandates this.
|
||||
- `curl -fsSL https://raw.githubusercontent.com/conan-io/conan-center-index/master/recipes/<pkg>/all/conanfile.py`.
|
||||
- Regex `cmake_target_name\s*=\s*['"]([^'"]+)['"]` and same for
|
||||
`cmake_file_name`. Handle both `cpp_info.set_property("cmake_target_name", ...)`
|
||||
and the legacy `self.cpp_info.names["cmake"] = "..."` forms.
|
||||
- Pure parser exposed as `parse_conanfile(text)`; the network adapter
|
||||
wraps `curl` via `exec::run`.
|
||||
- 404 → `ResolutionUnknownPackage`; transport errors → `ResolutionNetworkError`.
|
||||
|
||||
### D. vcpkg probe (Phase 4)
|
||||
|
||||
- `curl -fsSL https://raw.githubusercontent.com/microsoft/vcpkg/master/ports/<pkg>/usage`.
|
||||
- The file is plain CMake. Extract first `find_package(<name> ...)` line and
|
||||
any `target_link_libraries(... <pkg>::...)` lines.
|
||||
- Pure parser exposed as `parse_vcpkg_usage(text)`.
|
||||
|
||||
### E. verify_link (Phase 5)
|
||||
|
||||
```cpp
|
||||
auto verify_link(const Recipe& candidate,
|
||||
const std::string& name,
|
||||
const std::string& version_spec,
|
||||
const std::vector<std::string>& components,
|
||||
const std::filesystem::path& cargoxx_overlay_path)
|
||||
-> util::Result<void>;
|
||||
```
|
||||
|
||||
- Create `<tmp>/cargoxx-verify-<name>` (mktemp).
|
||||
- `cmd_new(name, /*lib_only=*/false, tmp_parent)`.
|
||||
- Insert `candidate` into `cargoxx_overlay_path` with the right `source`
|
||||
and `verified_at = 0` (provisional).
|
||||
- Mutate the scaffolded manifest to declare `name` with `version_spec`
|
||||
and `components`.
|
||||
- Overwrite `src/main.cpp` with `int main() {}` — empty body. The point
|
||||
is to exercise find_package + target_link_libraries + linker, *not* to
|
||||
call any specific API (which would require per-package knowledge).
|
||||
- Call `cmd_build(tmp_proj, no_build=false, release=false,
|
||||
target=nullopt, overlay_path=cargoxx_overlay_path)`.
|
||||
- On success: rewrite the overlay row with `verified_at = now()`,
|
||||
return `{}`.
|
||||
- On failure: delete the provisional row, return the build error.
|
||||
- Always: `std::filesystem::remove_all(tmp_dir)` (RAII helper).
|
||||
|
||||
## Persistence semantics
|
||||
|
||||
| Probe path | `source` column | `verified_at` | TTL (existing `overlay_is_fresh`) |
|
||||
| --- | --- | --- | --- |
|
||||
| Conan probe verified | `conan` | now | 30 days |
|
||||
| vcpkg probe verified | `vcpkg` | now | 30 days |
|
||||
| nix-cmake-scan verified | `nix-probe` | now | 30 days |
|
||||
| Manual via `linkdb add` | `manual` | now | never expires |
|
||||
|
||||
`resolution_failures` populated only when **all** probes fail. Subsequent
|
||||
`cargoxx add` calls within 24 h skip probing and return the cached error.
|
||||
|
||||
## Phasing (one commit per phase)
|
||||
|
||||
| Phase | Status | Commit |
|
||||
| --- | --- | --- |
|
||||
| 1. nixpkgs_probe + JSON parser | ✅ | `1c7ff39` |
|
||||
| 2. nix_cmake_scan | pending | — |
|
||||
| 3. conan_probe + parse_conanfile | pending | — |
|
||||
| 4. vcpkg_probe + parse_vcpkg_usage | pending | — |
|
||||
| 5. verify_link (tmp project + cmd_build) | pending | — |
|
||||
| 6. Database::discover + cmd_add wire-up + failure caching | pending | — |
|
||||
|
||||
## Testing strategy
|
||||
|
||||
| Test | Mechanism |
|
||||
| --- | --- |
|
||||
| `parse_nix_eval_json(text)` | ✅ Catch2 unit (`tests/nixpkgs_probe_parse.cpp`) |
|
||||
| `nixpkgs_probe(name)` | ✅ network-gated (`tests/nixpkgs_probe_live.cpp`); requires `CARGOXX_NETWORK_TESTS=1` |
|
||||
| `scan_imported_targets(text)` | Catch2 unit |
|
||||
| `nix_cmake_scan(tmp)` | Catch2 unit using a fixture tree |
|
||||
| `parse_conanfile(text)` | Catch2 unit; embedded conanfile.py snippets covering both old and new forms |
|
||||
| `parse_vcpkg_usage(text)` | Catch2 unit |
|
||||
| `conan_probe(name)` | network-gated; against `fmt` |
|
||||
| `vcpkg_probe(name)` | network-gated; against `fmt` |
|
||||
| `verify_link` end-to-end | network-gated; uses `simdjson` (small, present in nixpkgs, not in our curated DB) |
|
||||
| `cmd_add` end-to-end on uncurated package | network-gated; full flow on `simdjson` |
|
||||
|
||||
Failure-mode coverage:
|
||||
- Conan/vcpkg 404 → `ResolutionUnknownPackage`
|
||||
- `nix eval` errors → `ResolutionUnknownPackage`
|
||||
- All probes return candidates that fail to verify-link → record failure,
|
||||
return `ResolutionUnsatisfiable`
|
||||
- `resolution_failures` cache hit → returns the recorded error without
|
||||
re-probing
|
||||
|
||||
## Definition of done
|
||||
|
||||
After Phase 6:
|
||||
|
||||
```sh
|
||||
nix develop -c cmake --build build && \
|
||||
ctest --test-dir build --output-on-failure # all unit tests green
|
||||
CARGOXX_NETWORK_TESTS=1 nix develop -c ctest --test-dir build # live tests too
|
||||
```
|
||||
|
||||
Manual smoke (matches the user's request 1–5):
|
||||
|
||||
```sh
|
||||
cd /tmp && rm -rf simd-smoke && mkdir simd-smoke && cd simd-smoke
|
||||
~/cargoxx/build/cargoxx new app && cd app
|
||||
~/cargoxx/build/cargoxx add simdjson # not in curated; triggers discover
|
||||
# Expected output:
|
||||
# probing nixpkgs#simdjson ... ok (3.x.y)
|
||||
# probing conan-center-index ... ok (cmake_target_name = simdjson::simdjson)
|
||||
# verifying ... ok
|
||||
# Added simdjson 3.x.y (linkdb: conan)
|
||||
~/cargoxx/build/cargoxx build # ordinary build path now
|
||||
# picks up the freshly cached
|
||||
# overlay row
|
||||
```
|
||||
|
||||
A second `cargoxx add simdjson` in another fresh project hits the overlay
|
||||
directly and returns instantly — proves persistence step (5).
|
||||
|
||||
## Risks / known limits
|
||||
|
||||
- **Network**: Conan + vcpkg probes need outbound HTTPS. The
|
||||
network-gated test layer covers this; the unit tests on pure parsers
|
||||
don't need network.
|
||||
- **Conan recipe shape variation**: ~10 % of recipes use Python
|
||||
conditionals to set `cmake_target_name` per option — text parsing
|
||||
will miss these. Falls through to vcpkg / nix-scan, which is the
|
||||
point of the chain.
|
||||
- **nix-cmake-scan heuristics**: packages without standard
|
||||
`lib/cmake/<X>/<X>Config.cmake` layout won't be picked up. Acceptable
|
||||
for v0.2; the manual escape hatch (`cargoxx linkdb add`) covers
|
||||
edge cases.
|
||||
- **Overlay growth**: long-tail packages will accumulate in the user's
|
||||
overlay sqlite. No cleanup in v0.2 — not a concern at human-scale
|
||||
package counts.
|
||||
- **Verify-link slowness**: full `cargoxx build` per candidate. First
|
||||
probe usually wins, so it's typically one build. Worst case: three
|
||||
builds (Conan fail, vcpkg fail, nix-scan ok). Document as expected
|
||||
behavior in the CLI output (`verifying...` progress message).
|
||||
252
src/resolver/nix_cmake_scan.cpp
Normal file
252
src/resolver/nix_cmake_scan.cpp
Normal file
@@ -0,0 +1,252 @@
|
||||
module cargoxx.resolver;
|
||||
|
||||
import std;
|
||||
import cargoxx.util;
|
||||
|
||||
namespace cargoxx::resolver {
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
auto config_stem_to_package(std::string_view filename) -> std::string {
|
||||
std::string s{filename};
|
||||
// Drop any directory prefix.
|
||||
if (auto slash = s.find_last_of('/'); slash != std::string::npos) {
|
||||
s.erase(0, slash + 1);
|
||||
}
|
||||
constexpr std::array suffixes = {".cmake"};
|
||||
for (auto suf : suffixes) {
|
||||
if (s.ends_with(suf)) {
|
||||
s.erase(s.size() - std::string_view{suf}.size());
|
||||
}
|
||||
}
|
||||
constexpr std::array stems = {std::string_view{"Config"}, std::string_view{"-config"}};
|
||||
for (auto stem : stems) {
|
||||
if (s.ends_with(stem)) {
|
||||
s.erase(s.size() - stem.size());
|
||||
break;
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// Walks `text` looking for `add_library(<name> ... IMPORTED ...)` and
|
||||
// `add_library(<alias> ALIAS <real>)` forms. Returns the bare target names.
|
||||
auto collect_targets(std::string_view text) -> std::vector<std::string> {
|
||||
std::vector<std::string> out;
|
||||
constexpr std::string_view marker = "add_library";
|
||||
std::size_t pos = 0;
|
||||
while (pos < text.size()) {
|
||||
auto next = text.find(marker, pos);
|
||||
if (next == std::string_view::npos) {
|
||||
break;
|
||||
}
|
||||
// Must be at line start or preceded by whitespace/punct (avoid
|
||||
// matching `_add_library` etc.).
|
||||
if (next > 0) {
|
||||
char prev = text[next - 1];
|
||||
if (std::isalnum(static_cast<unsigned char>(prev)) || prev == '_') {
|
||||
pos = next + marker.size();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Find the opening '('.
|
||||
auto open = text.find('(', next + marker.size());
|
||||
if (open == std::string_view::npos) {
|
||||
break;
|
||||
}
|
||||
auto close = text.find(')', open + 1);
|
||||
if (close == std::string_view::npos) {
|
||||
break;
|
||||
}
|
||||
auto args = text.substr(open + 1, close - open - 1);
|
||||
|
||||
// Tokenize by whitespace. CMake's add_library has the form
|
||||
// add_library(<name> [STATIC|SHARED|...] [IMPORTED] ...)
|
||||
// or
|
||||
// add_library(<alias> ALIAS <real>)
|
||||
std::vector<std::string_view> toks;
|
||||
std::size_t tp = 0;
|
||||
while (tp < args.size()) {
|
||||
while (tp < args.size() &&
|
||||
std::isspace(static_cast<unsigned char>(args[tp]))) {
|
||||
++tp;
|
||||
}
|
||||
if (tp >= args.size()) {
|
||||
break;
|
||||
}
|
||||
std::size_t start = tp;
|
||||
while (tp < args.size() &&
|
||||
!std::isspace(static_cast<unsigned char>(args[tp]))) {
|
||||
++tp;
|
||||
}
|
||||
toks.push_back(args.substr(start, tp - start));
|
||||
}
|
||||
|
||||
if (toks.size() >= 2) {
|
||||
const auto& name = toks[0];
|
||||
bool imported = false;
|
||||
bool alias = false;
|
||||
for (std::size_t i = 1; i < toks.size(); ++i) {
|
||||
if (toks[i] == "IMPORTED") {
|
||||
imported = true;
|
||||
}
|
||||
if (toks[i] == "ALIAS") {
|
||||
alias = true;
|
||||
}
|
||||
}
|
||||
if (imported || alias) {
|
||||
out.emplace_back(name);
|
||||
}
|
||||
}
|
||||
|
||||
pos = close + 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
auto scan_imported_targets(std::string_view config_text) -> std::vector<std::string> {
|
||||
auto targets = collect_targets(config_text);
|
||||
// Stable-dedup: preserve first-occurrence order, drop duplicates.
|
||||
std::vector<std::string> out;
|
||||
out.reserve(targets.size());
|
||||
for (auto& t : targets) {
|
||||
if (std::ranges::find(out, t) == out.end()) {
|
||||
out.push_back(std::move(t));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// Score a stem against the queried package name. Lower is better.
|
||||
// 0 — exact match (case-insensitive)
|
||||
// 1 — prefix match (one is a case-insensitive prefix of the other)
|
||||
// 2 — fallback (any other non-empty target list)
|
||||
auto match_score(std::string_view stem, std::string_view pkg) -> int {
|
||||
auto eq_ci = [](std::string_view a, std::string_view b) {
|
||||
if (a.size() != b.size()) {
|
||||
return false;
|
||||
}
|
||||
for (std::size_t i = 0; i < a.size(); ++i) {
|
||||
auto al = std::tolower(static_cast<unsigned char>(a[i]));
|
||||
auto bl = std::tolower(static_cast<unsigned char>(b[i]));
|
||||
if (al != bl) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
if (eq_ci(stem, pkg)) {
|
||||
return 0;
|
||||
}
|
||||
auto starts_with_ci = [](std::string_view longer, std::string_view shorter) {
|
||||
if (shorter.size() > longer.size()) {
|
||||
return false;
|
||||
}
|
||||
for (std::size_t i = 0; i < shorter.size(); ++i) {
|
||||
auto a = std::tolower(static_cast<unsigned char>(longer[i]));
|
||||
auto b = std::tolower(static_cast<unsigned char>(shorter[i]));
|
||||
if (a != b) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
if (starts_with_ci(stem, pkg) || starts_with_ci(pkg, stem)) {
|
||||
return 1;
|
||||
}
|
||||
return 2;
|
||||
}
|
||||
|
||||
auto is_config_filename(const fs::path& p) -> bool {
|
||||
if (p.extension() != ".cmake") {
|
||||
return false;
|
||||
}
|
||||
auto stem = p.stem().string();
|
||||
return stem.ends_with("Config") || stem.ends_with("-config");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
auto nix_cmake_scan(const fs::path& store_path, const std::string& package_name)
|
||||
-> util::Result<NixCmakeCandidate> {
|
||||
const auto cmake_dir = store_path / "lib" / "cmake";
|
||||
std::error_code ec;
|
||||
if (!fs::exists(cmake_dir, ec) || ec) {
|
||||
return std::unexpected(util::Error{
|
||||
util::ErrorCode::ResolutionUnknownPackage,
|
||||
std::format("no CMake configs under '{}'", cmake_dir.string()),
|
||||
"", store_path, std::nullopt,
|
||||
});
|
||||
}
|
||||
|
||||
struct Match {
|
||||
int score;
|
||||
NixCmakeCandidate cand;
|
||||
};
|
||||
std::vector<Match> matches;
|
||||
|
||||
// Walk the tree once, find every *Config.cmake / *-config.cmake. The
|
||||
// IMPORTED targets are usually in a sibling *-targets.cmake (via the
|
||||
// standard `include(<self>-targets.cmake)` pattern), so for each
|
||||
// config file we scan every .cmake file in its parent directory.
|
||||
for (const auto& entry : fs::recursive_directory_iterator{
|
||||
cmake_dir, fs::directory_options::skip_permission_denied}) {
|
||||
if (!entry.is_regular_file()) {
|
||||
continue;
|
||||
}
|
||||
if (!is_config_filename(entry.path())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
std::vector<std::string> targets;
|
||||
const auto pkg_dir = entry.path().parent_path();
|
||||
for (const auto& sib : fs::directory_iterator{pkg_dir}) {
|
||||
if (!sib.is_regular_file() || sib.path().extension() != ".cmake") {
|
||||
continue;
|
||||
}
|
||||
std::ifstream in{sib.path()};
|
||||
if (!in) {
|
||||
continue;
|
||||
}
|
||||
std::string text{std::istreambuf_iterator<char>{in}, {}};
|
||||
for (auto& t : scan_imported_targets(text)) {
|
||||
if (std::ranges::find(targets, t) == targets.end()) {
|
||||
targets.push_back(std::move(t));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (targets.empty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
auto stem = config_stem_to_package(entry.path().filename().string());
|
||||
NixCmakeCandidate cand{
|
||||
.find_package = std::format("{} CONFIG REQUIRED", stem),
|
||||
.targets = std::move(targets),
|
||||
.config_file = entry.path(),
|
||||
};
|
||||
matches.push_back(Match{
|
||||
.score = match_score(stem, package_name),
|
||||
.cand = std::move(cand),
|
||||
});
|
||||
}
|
||||
|
||||
if (matches.empty()) {
|
||||
return std::unexpected(util::Error{
|
||||
util::ErrorCode::ResolutionUnknownPackage,
|
||||
std::format("no IMPORTED targets found under '{}'", cmake_dir.string()),
|
||||
"", store_path, std::nullopt,
|
||||
});
|
||||
}
|
||||
|
||||
std::ranges::stable_sort(matches, {}, &Match::score);
|
||||
return std::move(matches.front().cand);
|
||||
}
|
||||
|
||||
} // namespace cargoxx::resolver
|
||||
@@ -26,4 +26,29 @@ auto parse_nix_eval_json(std::string_view attr, std::string_view json)
|
||||
// `ResolutionNetworkError` on timeout or evaluator errors.
|
||||
auto nixpkgs_probe(const std::string& attr) -> util::Result<NixpkgsInfo>;
|
||||
|
||||
// One CMake config-file's IMPORTED targets together with the find_package
|
||||
// expression derived from its filename stem.
|
||||
struct NixCmakeCandidate {
|
||||
std::string find_package; // e.g. "fmt CONFIG REQUIRED"
|
||||
std::vector<std::string> targets; // e.g. ["fmt::fmt"]
|
||||
std::filesystem::path config_file; // the *Config.cmake we scraped
|
||||
};
|
||||
|
||||
// Pure: scan a single CMake config text for `add_library(... IMPORTED)`
|
||||
// target names. ALIAS targets are also collected so canonical
|
||||
// `<alias>::<member>` forms get picked up.
|
||||
auto scan_imported_targets(std::string_view config_text) -> std::vector<std::string>;
|
||||
|
||||
// Pure: turn a CMake config filename into the find_package name.
|
||||
// `fmtConfig.cmake` / `fmt-config.cmake` -> `fmt`.
|
||||
auto config_stem_to_package(std::string_view filename) -> std::string;
|
||||
|
||||
// Walks <store_path>/lib/cmake/** for *Config.cmake / *-config.cmake files.
|
||||
// Picks the candidate whose derived package name best matches `package_name`
|
||||
// (exact case-insensitive equality > prefix > first non-empty target list).
|
||||
// Returns ResolutionUnknownPackage when nothing usable is found.
|
||||
auto nix_cmake_scan(const std::filesystem::path& store_path,
|
||||
const std::string& package_name)
|
||||
-> util::Result<NixCmakeCandidate>;
|
||||
|
||||
} // namespace cargoxx::resolver
|
||||
|
||||
@@ -26,3 +26,5 @@ cargoxx_add_test(cmd_add)
|
||||
cargoxx_add_test(cmd_remove)
|
||||
cargoxx_add_test(nixpkgs_probe_parse)
|
||||
cargoxx_add_test(nixpkgs_probe_live)
|
||||
cargoxx_add_test(nix_cmake_scan_parse)
|
||||
cargoxx_add_test(nix_cmake_scan_live)
|
||||
|
||||
57
tests/nix_cmake_scan_live.cpp
Normal file
57
tests/nix_cmake_scan_live.cpp
Normal file
@@ -0,0 +1,57 @@
|
||||
// nix-store-gated integration test for resolver::nix_cmake_scan.
|
||||
// Realizes nixpkgs#fmt.dev (cheap if already cached) and verifies that
|
||||
// nix_cmake_scan picks up its real *-targets.cmake IMPORTED targets.
|
||||
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
|
||||
import cargoxx.resolver;
|
||||
import cargoxx.exec;
|
||||
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";
|
||||
}
|
||||
|
||||
auto realize(std::string_view attr) -> std::optional<std::filesystem::path> {
|
||||
auto r = cargoxx::exec::run(
|
||||
"nix",
|
||||
{"--extra-experimental-features", "nix-command flakes",
|
||||
"build", std::format("nixpkgs#{}", attr),
|
||||
"--no-link", "--print-out-paths"},
|
||||
cargoxx::exec::ExecOptions{
|
||||
.cwd = {},
|
||||
.env_overrides = {},
|
||||
.timeout = std::chrono::seconds{180},
|
||||
.inherit_stdio = false,
|
||||
});
|
||||
if (!r || r->exit_code != 0) {
|
||||
return std::nullopt;
|
||||
}
|
||||
auto path = r->stdout_text;
|
||||
while (!path.empty() && (path.back() == '\n' || path.back() == ' ')) {
|
||||
path.pop_back();
|
||||
}
|
||||
return std::filesystem::path{path};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("nix_cmake_scan finds fmt's IMPORTED targets",
|
||||
"[resolver][network]") {
|
||||
if (!network_tests_enabled()) {
|
||||
SKIP("CARGOXX_NETWORK_TESTS != 1");
|
||||
}
|
||||
auto p = realize("fmt.dev");
|
||||
REQUIRE(p.has_value());
|
||||
|
||||
auto r = cargoxx::resolver::nix_cmake_scan(*p, "fmt");
|
||||
REQUIRE(r.has_value());
|
||||
REQUIRE(r->find_package == "fmt CONFIG REQUIRED");
|
||||
// fmt-targets.cmake declares fmt::fmt + fmt::fmt-header-only.
|
||||
REQUIRE(std::ranges::find(r->targets, std::string{"fmt::fmt"}) !=
|
||||
r->targets.end());
|
||||
}
|
||||
159
tests/nix_cmake_scan_parse.cpp
Normal file
159
tests/nix_cmake_scan_parse.cpp
Normal file
@@ -0,0 +1,159 @@
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
|
||||
import cargoxx.resolver;
|
||||
import cargoxx.util;
|
||||
import std;
|
||||
|
||||
using cargoxx::resolver::config_stem_to_package;
|
||||
using cargoxx::resolver::nix_cmake_scan;
|
||||
using cargoxx::resolver::scan_imported_targets;
|
||||
using cargoxx::util::ErrorCode;
|
||||
|
||||
TEST_CASE("config_stem_to_package strips Config / -config / .cmake",
|
||||
"[resolver][nix_cmake_scan]") {
|
||||
REQUIRE(config_stem_to_package("fmtConfig.cmake") == "fmt");
|
||||
REQUIRE(config_stem_to_package("fmt-config.cmake") == "fmt");
|
||||
REQUIRE(config_stem_to_package("Catch2Config.cmake") == "Catch2");
|
||||
REQUIRE(config_stem_to_package("range-v3-config.cmake") == "range-v3");
|
||||
REQUIRE(config_stem_to_package("/abs/path/sub/spdlogConfig.cmake") == "spdlog");
|
||||
}
|
||||
|
||||
TEST_CASE("scan_imported_targets picks up IMPORTED libraries",
|
||||
"[resolver][nix_cmake_scan]") {
|
||||
constexpr std::string_view text = R"(
|
||||
# noise here
|
||||
add_library(fmt::fmt SHARED IMPORTED)
|
||||
set_target_properties(fmt::fmt PROPERTIES IMPORTED_LOCATION "/x")
|
||||
)";
|
||||
auto out = scan_imported_targets(text);
|
||||
REQUIRE(out == std::vector<std::string>{"fmt::fmt"});
|
||||
}
|
||||
|
||||
TEST_CASE("scan_imported_targets picks up ALIAS targets",
|
||||
"[resolver][nix_cmake_scan]") {
|
||||
constexpr std::string_view text = R"(
|
||||
add_library(absl_strings_internal STATIC IMPORTED)
|
||||
add_library(absl::strings ALIAS absl_strings_internal)
|
||||
)";
|
||||
auto out = scan_imported_targets(text);
|
||||
// Both the imported impl and the alias should be reported, deduped,
|
||||
// in declaration order.
|
||||
REQUIRE(out.size() == 2);
|
||||
REQUIRE(out[0] == "absl_strings_internal");
|
||||
REQUIRE(out[1] == "absl::strings");
|
||||
}
|
||||
|
||||
TEST_CASE("scan_imported_targets ignores plain (non-IMPORTED) add_library",
|
||||
"[resolver][nix_cmake_scan]") {
|
||||
constexpr std::string_view text = R"(
|
||||
add_library(local STATIC src.cpp)
|
||||
add_library(other_lib SHARED other.cpp)
|
||||
)";
|
||||
auto out = scan_imported_targets(text);
|
||||
REQUIRE(out.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("scan_imported_targets does not match _add_library or similar",
|
||||
"[resolver][nix_cmake_scan]") {
|
||||
constexpr std::string_view text = R"(
|
||||
# function definition uses the marker as a substring
|
||||
function(_add_library_helper)
|
||||
endfunction()
|
||||
)";
|
||||
auto out = scan_imported_targets(text);
|
||||
REQUIRE(out.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("scan_imported_targets dedupes duplicate target names",
|
||||
"[resolver][nix_cmake_scan]") {
|
||||
constexpr std::string_view text = R"(
|
||||
add_library(fmt::fmt SHARED IMPORTED)
|
||||
# benign duplicate (e.g. config-version reuse)
|
||||
add_library(fmt::fmt SHARED IMPORTED)
|
||||
)";
|
||||
auto out = scan_imported_targets(text);
|
||||
REQUIRE(out == std::vector<std::string>{"fmt::fmt"});
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
auto fresh_store() -> std::filesystem::path {
|
||||
auto d = std::filesystem::temp_directory_path() /
|
||||
std::format("cargoxx-cmake-scan-{}", std::random_device{}());
|
||||
std::filesystem::create_directories(d / "lib" / "cmake");
|
||||
return d;
|
||||
}
|
||||
|
||||
void touch_config(const std::filesystem::path& store, std::string_view rel,
|
||||
std::string_view content) {
|
||||
auto p = store / "lib" / "cmake" / rel;
|
||||
std::filesystem::create_directories(p.parent_path());
|
||||
std::ofstream{p} << content;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("nix_cmake_scan finds the canonical config", "[resolver][nix_cmake_scan]") {
|
||||
auto store = fresh_store();
|
||||
touch_config(store, "fmt/fmtConfig.cmake",
|
||||
R"(add_library(fmt::fmt SHARED IMPORTED))");
|
||||
|
||||
auto r = nix_cmake_scan(store, "fmt");
|
||||
REQUIRE(r.has_value());
|
||||
REQUIRE(r->find_package == "fmt CONFIG REQUIRED");
|
||||
REQUIRE(r->targets == std::vector<std::string>{"fmt::fmt"});
|
||||
REQUIRE(r->config_file.filename() == "fmtConfig.cmake");
|
||||
}
|
||||
|
||||
TEST_CASE("nix_cmake_scan prefers exact case-insensitive match",
|
||||
"[resolver][nix_cmake_scan]") {
|
||||
auto store = fresh_store();
|
||||
touch_config(store, "absl/abslConfig.cmake",
|
||||
R"(add_library(absl::strings SHARED IMPORTED))");
|
||||
touch_config(store, "absl-extras/abslExtrasConfig.cmake",
|
||||
R"(add_library(absl::extras SHARED IMPORTED))");
|
||||
|
||||
auto r = nix_cmake_scan(store, "absl");
|
||||
REQUIRE(r.has_value());
|
||||
REQUIRE(r->find_package == "absl CONFIG REQUIRED");
|
||||
}
|
||||
|
||||
TEST_CASE("nix_cmake_scan returns ResolutionUnknownPackage when nothing found",
|
||||
"[resolver][nix_cmake_scan]") {
|
||||
auto store = fresh_store();
|
||||
auto r = nix_cmake_scan(store, "nothing");
|
||||
REQUIRE_FALSE(r.has_value());
|
||||
REQUIRE(r.error().code == ErrorCode::ResolutionUnknownPackage);
|
||||
}
|
||||
|
||||
TEST_CASE("nix_cmake_scan ignores Config files with no IMPORTED targets",
|
||||
"[resolver][nix_cmake_scan]") {
|
||||
auto store = fresh_store();
|
||||
touch_config(store, "junk/junkConfig.cmake",
|
||||
R"(message(STATUS "no targets here"))");
|
||||
auto r = nix_cmake_scan(store, "junk");
|
||||
REQUIRE_FALSE(r.has_value());
|
||||
REQUIRE(r.error().code == ErrorCode::ResolutionUnknownPackage);
|
||||
}
|
||||
|
||||
TEST_CASE("nix_cmake_scan scans sibling *-targets.cmake files",
|
||||
"[resolver][nix_cmake_scan]") {
|
||||
// Mirrors fmt's real on-disk layout: the *-config.cmake is empty of
|
||||
// IMPORTED targets but include()s a sibling *-targets.cmake that
|
||||
// carries them.
|
||||
auto store = fresh_store();
|
||||
touch_config(store, "fmt/fmt-config.cmake",
|
||||
R"(include("${CMAKE_CURRENT_LIST_DIR}/fmt-targets.cmake"))");
|
||||
touch_config(store, "fmt/fmt-targets.cmake",
|
||||
R"(add_library(fmt::fmt SHARED IMPORTED)
|
||||
add_library(fmt::fmt-header-only INTERFACE IMPORTED))");
|
||||
|
||||
auto r = nix_cmake_scan(store, "fmt");
|
||||
REQUIRE(r.has_value());
|
||||
REQUIRE(r->find_package == "fmt CONFIG REQUIRED");
|
||||
REQUIRE(r->targets.size() == 2);
|
||||
REQUIRE(std::ranges::find(r->targets, std::string{"fmt::fmt"}) !=
|
||||
r->targets.end());
|
||||
REQUIRE(std::ranges::find(r->targets, std::string{"fmt::fmt-header-only"}) !=
|
||||
r->targets.end());
|
||||
}
|
||||
Reference in New Issue
Block a user