diff --git a/.agents/docs/2026-07-08-descriptor-index-evolution-roadmap.md b/.agents/docs/2026-07-08-descriptor-index-evolution-roadmap.md new file mode 100644 index 0000000..5f00945 --- /dev/null +++ b/.agents/docs/2026-07-08-descriptor-index-evolution-roadmap.md @@ -0,0 +1,106 @@ +# Descriptor & index evolution — 0.0.85 release train roadmap (cross-repo) + +Master plan executing, in one pass, the full necessity-ordered set from the two +sister designs: + +- `2026-07-08-index-version-semantics-and-descriptor-grammar-design.md` + (D1 long brackets, D2 single-source lint, D3 index floor) +- `2026-07-08-scanner-backend-abstraction-design.md` + (scan_overrides, L3 plan-vs-ddi reconciliation; p1689 backend deferred) + +Scope decision: user base is small — the one-time compatibility break is +absorbed NOW, all five items ship in **mcpp 0.0.85**, and mcpp-index adopts +immediately after. The p1689 per-package backend is explicitly OUT of this +train (seam documented, deferred until demanded). + +--- + +## 0. PROGRESS (live, updated as the train executes) + +| Item | Status | Evidence | +|---|---|---| +| W1 long brackets | ✅ done (b6c5793) | 4 unit tests; fmt descriptor in `[==[ ]==]` form parses + member test green | +| W2 xpkg parse | ✅ done (0b2b9b9) | strict IS default (unknown key → exit 1; `--allow-unknown` downgrades); e2e 93; `--json` parity vs lua5.4 = byte-identical | +| (extra) manifest split | ✅ done (bc24f8e) | :types/:toml/:xpkg partitions, API unchanged, suite green | +| W3 scan_overrides | ✅ done (ea7ea43) | fmt WITHOUT generated_files builds + tests green end-to-end | +| W4 ddi reconciliation | ✅ done (9211fd4) | negative test: omitted imports={std} → DYNDEP fails with planned-vs-compiler delta | +| W5 index floor | ✅ done (94e4c6b) | E0006 + upgrade hint + once-per-index; ignore hatch; 9.9.9/0.0.84 e2e. Deviation: staged-unpack lives in vendored xlings → follow-up there; open-time check is the mcpp-side enforcement | +| W6 release 0.0.85 | 🔄 PR #200 open, CI running | version bumped; docs/05 scan_overrides section; key e2e green | + +Findings during W6 hardening (both fixed in PR #200): +- **Corpus dry-run of strict lint over all 42 mcpp-index descriptors** caught: + single-quoted version keys invisible to list_xpkg_versions (real bug — + tensorvia had NO visible versions); platform sub-tables misread as unknown + keys; Form A descriptors failing on the missing segment. 42/42 now pass. +- **GCC 15 partition bug**: module partitions drop implicit template + instantiations of module-attached types at the aarch64 cross link; the + manifest split became three separate modules + umbrella (same API). + +| P1..P3 prepared | see mcpp-index adoption plan progress table | branches feat/index-floor-0.0.85 (2523dd9), feat/long-bracket-migrations (7f1e624) | +| P0 merge PR #63 | pending maintainer | tested locally 2026-07-08, green on 0.0.81 | +| P1 index floor PR | pending (after 0.0.85 release) | | +| P2 fmt → scan_overrides | ✅ validated locally only (per instruction: user's PR untouched) | override descriptor kept out of tree | +| P3/P4 migrations + docs | pending (after P1) | | + +Decision taken during execution (user-directed): `--strict` semantics are the +DEFAULT of `xpkg parse`; the escape flag is `--allow-unknown`. + +## 1. Work breakdown — repo `mcpp` (release 0.0.85) + +| # | Item | Design ref | Depends on | +|---|------|-----------|------------| +| W1 | **D1 long brackets**: `LuaCursor::read_long_bracket()` (`[[`/`[=[…]=]`, first-newline strip), `read_string()` branch, `strip_lua_comments_and_strings()` long strings + `--[[ ]]` block comments; lua5.4-parity unit fixtures (level ≥ 1, embedded `]]`, CRLF, comment containing `mcpp = {`) | grammar design D1 | — | +| W2 | **D2 CLI**: `mcpp xpkg parse [--json] [--strict]` wrapping `synthesize_from_xpkg_lua` + identity gate; `--strict` warns on unknown mcpp-segment keys (parser collects skipped keys instead of dropping them silently); e2e good/malformed fixtures | grammar design D2 | W1 (validates new grammar) | +| W3 | **scan_overrides**: parse in mcpp segment + project `mcpp.toml`; override-matched files bypass the text scan, declared `(provides, imports)` enter the graph; shape validation (glob must match ≥1 source, module names well-formed) | scanner design §3-pre | — | +| W4 | **L3 reconciliation**: `dyndep.cppm` diffs planned `(provides, requires)` vs compiler `.ddi` per TU; **mandatory for override units**, `MCPP_VERIFY_MODGRAPH=1`-gated for the rest (default-on in a later release once quiet); E-code with file + delta; watch logical-name sanitization parity (`bmi_basename`) | scanner design §3d | W3 (override units are the must-verify set) | +| W5 | **D3 index floor**: `index.toml` reader; check at the index-open choke point (`package_fetcher` index-dir layer); refresh becomes staged-unpack → floor check → atomic swap (keep-old + warn on incompatible); `MCPP_INDEX_FLOOR=ignore`; E-code + `mcpp explain`; version compare via `version_req.cppm` | grammar design D3 | — | +| W6 | **Release**: docs 04 (grammar: long brackets, scan_overrides key, unknown-key strictness) + 05 (`[build]`/override surface, `index.toml`); release notes carry the one-time-break notice for pre-0.0.85 clients | — | W1–W5 | + +Parallelism: W1 ∥ W3 ∥ W5 are independent tracks; W2 follows W1; W4 follows W3. + +Known implementation caveats: +- **Two refresh paths**: staged+atomic swap applies to the artifact-download + path; the xlings-git path can't stage inside mcpp — there the open-time + floor check is the guard (acceptable: git path is maintainer/dev-facing). +- Reconciliation false-positive risk (ddi logical-name formatting vs planned + names) is why non-override units start env-gated. + +## 2. Work breakdown — repo `mcpp-index` (after 0.0.85 is released + mirrored) + +Ordered PRs; details in mcpp-index +`.agents/docs/2026-07-08-index-side-adoption-plan.md`: + +| # | PR | Content | Gate | +|---|----|---------|------| +| P0 | (pre-train) merge PR #63 as-is | fmt via `generated_files`, works on 0.0.81 today; don't block it on the train | tested 2026-07-08 | +| P1 | **floor PR (atomic)** | `index.toml` (`min_mcpp = "0.0.85"`); CI `MCPP_VERSION` 0.0.81 → 0.0.85; lint job gains pinned-mcpp download + `xpkg parse --strict` loop; `publish_mcpp_index.sh` packs `index.toml` (one `cp`) | 0.0.85 released | +| P2 | fmt → `scan_overrides` | descriptor drops the 3.4 KB `generated_files` copy; upstream `src/fmt.cc` verbatim + `cxxflags -DFMT_IMPORT_STD` + declared `(provides={fmt}, imports={std})` | P1 merged | +| P3 | long-bracket migrations | `nlohmann.json`, `compat.eigen` `generated_files` → `[==[…]==]`; mechanical, parity-oracle-checked, one PR each | P1 merged | +| P4 | docs | `repository-and-schema.md`: replace "不支持 `[[…]]`" with "requires mcpp ≥ 0.0.85 (lint enforces)"; document `scan_overrides` as the pattern for guarded-import module units; add floor explanation + case-index rows | P1–P3 | + +Invariant enforced mechanically, not by policy: **no descriptor may use new +grammar/keys before P1** — while lint pins 0.0.81 the `xpkg parse` step doesn't +exist and old-lint passes would be meaningless; after P1, lint parses with +0.0.85, so pre-P1 usage simply cannot merge. + +## 3. Verification plan + +- mcpp unit: lua5.4-parity grammar fixtures; override shape validation. +- mcpp e2e: fixture index with `min_mcpp = "9.9.9"` → build fails with upgrade + message, `MCPP_INDEX_FLOOR=ignore` passes; staged refresh against an + incompatible snapshot keeps the old one; override with a deliberately wrong + `imports` → reconciliation error names file + delta; `xpkg parse --strict` + flags an unknown key. +- Integration bed: mcpp-index workspace CI (`mcpp test --workspace`, 3 OS) — + P2's fmt-via-override member is the live proof; note it is also the first + `import_std = true` package build on macOS/Windows in the matrix. + +## 4. Compatibility ledger (explicit) + +- Pre-0.0.85 clients + post-P2/P3 index: descriptor parse errors with no + guidance — **accepted one-time break**, announced in 0.0.85 release notes; + floor protects every client ≥ 0.0.85 from all future breaks (staged refresh + keeps last compatible snapshot). +- `format_version` of artifact/pointer: unchanged. Pointer stays dumb. +- xim/xlings side keeps executing descriptors as real Lua — long brackets and + new keys are valid Lua; no impact. diff --git a/.agents/docs/2026-07-08-index-version-semantics-and-descriptor-grammar-design.md b/.agents/docs/2026-07-08-index-version-semantics-and-descriptor-grammar-design.md new file mode 100644 index 0000000..b94fb45 --- /dev/null +++ b/.agents/docs/2026-07-08-index-version-semantics-and-descriptor-grammar-design.md @@ -0,0 +1,252 @@ +# Index version semantics + descriptor grammar v2 (long brackets) + single-source-of-truth lint (Design) + +Three coupled deliverables toward "描述符可读、lint 与客户端文法一致、index 对客户端 +有版本契约", targeting the **0.0.85** release window while the user base is small +enough to absorb a one-time grammar extension: + +- **Phase 1 (repo `mcpp`)** — (D1) teach the descriptor mini-reader Lua long-bracket + strings `[[…]]` / `[=[…]=]` and block comments; (D2) add `mcpp xpkg parse ` + so index CI validates with the *same* parser users build with; (D3) read an + index-level `index.toml` version contract (`min_mcpp` / `latest_mcpp`) and + error/hint accordingly. +- **Phase 2 (repo `mcpp-index`)** — add `index.toml`, switch the lint job to + `mcpp xpkg parse`, then migrate `generated_files` descriptors to long-bracket + form (fmt PR #63 first candidate). + +Ordering rule that makes the rollout safe: **floor first, migration after** — +no descriptor may use the new grammar until the index declares +`min_mcpp = "0.0.85"` and CI's pinned `MCPP_VERSION` is ≥ 0.0.85. + +--- + +## 1. Problem (grounded in code + experiments, 2026-07-08) + +Three defects observed while testing mcpp-index PR #63 (`fmtlib.fmt`): + +1. **Validator and consumer speak different grammars.** Index lint validates + descriptors with real Lua (`lua5.4 loadfile`), but mcpp parses the `mcpp = {}` + segment with a hand-written subset reader (`LuaCursor` / + `synthesize_from_xpkg_lua`, `src/manifest.cppm:1458+`) whose `read_string()` + accepts only `"`/`'` quotes. Experiment: rewriting the fmt descriptor's + `generated_files` value as `[==[…]==]` is byte-identical at the Lua level + (loadfile OK, 3425 bytes equal) yet mcpp 0.0.81 fails at *user build time* + with `malformed mcpp segment near key 'Formatting'` (the reader falls out of + sync at `[` and reads string content as keys). Lint green, user red — the + worst failure geometry. + +2. **Escaped one-line `generated_files` are unreviewable.** fmt's module wrapper + is a 3.4 KB single-line `\n`-escaped string; verifying "only 2 edits vs + upstream fmt.cc" required scripted extraction. nlohmann.json and compat.eigen + have the same shape. This is a direct consequence of the grammar gap above — + long brackets are the natural fix and are already valid Lua for the xim side. + +3. **No index→client version contract.** Index repos (`[indices]`, + `src/pm/index_spec.cppm`; default `mcpplibs` fetched/refreshed via xlings, + `src/xlings.cppm:271-295`) carry no "minimum mcpp that can read me". Any + grammar/schema evolution therefore breaks old clients *at descriptor parse + time* with a confusing error and no upgrade guidance. There is also no + channel to tell users a newer mcpp exists (`mcpp self` has + version/init/config only — no update machinery, `src/cli/cmd_self.cppm`). + +--- + +## 2. Design + +### D1 — descriptor grammar v2: long brackets + block comments (repo `mcpp`) + +Extend the `LuaCursor` data-literal subset in `src/manifest.cppm`: + +- `read_string()`: on `[` followed by `=`* then `[`, read a long-bracket literal + of that exact level; content is verbatim (no escape processing); strip one + leading newline immediately after the opening bracket (Lua semantics). +- `strip_lua_comments_and_strings()`: recognize `--[[…]]` / `--[=[…]=]` block + comments and long-bracket strings; blank content (preserve newlines) while + keeping the bracket structure, mirroring today's quote handling. +- `skip_table()` / `read_table_body()`: they dispatch through the same + string-aware paths — verify they inherit the new branch; add cases if not. + +Explicitly **out of scope** (grammar stays a data-literal subset, documented in +`docs/04-schema-xpkg-extension.md`): `..` concatenation, function calls, +variables, computed keys. The reader remains "typed fields out of a literal +table", not a Lua VM. (Rejected alternative: embed real Lua — loses the +"mcpp never executes descriptors" security property, breaks static auditability +of urls/mirrors, adds a C dependency; the schema is ~12 closed keys and does not +justify it.) + +Parity oracle for tests: for every fixture, content extracted via +`lua5.4 loadfile` must equal content extracted via `LuaCursor` — this pins the +subtle rules (level matching, first-newline strip, `]]` inside content requiring +a higher level, CRLF passthrough). + +### D2 — single source of truth for lint: `mcpp xpkg parse` (repo `mcpp`) + +New CLI: `mcpp xpkg parse [--json]` + +- Runs `synthesize_from_xpkg_lua` + `canonical_xpkg_identity_from_lua` (identity + gate) on the file, for each platform present in `xpm`. +- Success: prints a summary of the synthesized manifest (name, namespace, + versions, modules, generated_files paths+sizes, targets, features); `--json` + emits machine-readable form. Failure: the exact error users would see at + build time, non-zero exit. +- ~30 lines of routing; the parsing functions are already exported. + +mcpp-index lint then runs **both** validators, because the descriptor has two +real consumers with two real grammars: + +- `lua5.4 loadfile` — guards the xim/xlings side (which executes real Lua); +- `mcpp xpkg parse` (pinned `MCPP_VERSION` binary) — guards the mcpp side. + +Lint's verdict and the user's build verdict become identical *by construction*, +for this and every future grammar divergence — this retires the whole bug +class, not just `[[]]`. It is also the enforcement mechanism for the rollout +rule: while CI pins 0.0.84, a long-bracket descriptor simply fails lint. + +### D3 — index version contract: `index.toml` (both repos) + +Principle: **the contract describes the index content, so it lives in the index +tree and travels with it** (same pattern as Cargo's registry `config.json`). +One file, one check site, one flow change; the distribution chain +(`tools/publish_mcpp_index.sh`: git repo → content-hash artifact → rolling +pointer) is otherwise untouched — in particular the **pointer stays a dumb +pointer** (name/sha/size, no new fields, `format_version` unchanged). + +**One file** — `index.toml` at the index tree root (sibling of `pkgs/`), +authored and reviewed in the index repo, packed into the artifact by +`publish_mcpp_index.sh` (today it packs only `pkgs/` + README; one `cp` line). +Every place an index tree exists — git checkout, unpacked artifact snapshot, +CI-restored cache, `[indices] path =` local dir, third-party clone — therefore +carries its own contract, with no derivation or sync logic: + +```toml +[index] +spec = "1" # index layout spec (pkgs//.lua) +min_mcpp = "0.0.85" # oldest mcpp able to parse every descriptor herein +latest_mcpp = "0.0.85" # optional: newest known-good mcpp (upgrade hints) +``` + +**One check site** — the code has a single choke point where an index tree is +opened to read descriptors (the index-dir layer in +`src/pm/package_fetcher.cppm`: `sorted_index_dirs` / `read_xpkg_lua_from_path`). +The floor check lives there and only there: if `own_version < min_mcpp` → hard +error with an E-code and explicit upgrade instructions (`mcpp explain ` +for the long form). All transports converge on "a directory containing +`pkgs/`", so one check covers them all — including offline use and mcpp-index's +own CI workspace members, which exercise it for free. Escape hatch: +`MCPP_INDEX_FLOOR=ignore`. Version comparison reuses `src/version_req.cppm`. + +**One flow change** — index refresh becomes *staged + atomic*: download → +unpack to a staging dir → read the staged tree's `index.toml` → if compatible, +atomically swap into place; if not, **discard staging, keep the current +snapshot, warn once** with the upgrade hint. This yields graceful degradation +on future floor bumps (a 0.0.85 client facing `min_mcpp = "0.0.90"` keeps its +last compatible snapshot instead of bricking itself) using the same file and +the same check code — no pre-download sensing channel needed. The cost is one +wasted tarball download (the index tarball is a few hundred KB of .lua files, +at most once per freshness-TTL window, only while the client is behind the +floor); staged-unpack + atomic swap is good hygiene against partial downloads +anyway. + +**Soft hint** — if `own_version < latest_mcpp`, print one rate-limited stderr +line (piggyback the freshness TTL: at most once per refresh window, not per +build). + +**Missing `index.toml`** → no constraint (back-compat with existing snapshots +and third-party indices). + +#### Architecture rationale: authority / projection / channels + +Two contracts with different owners and lifecycles must not be conflated: +the **transport contract** (how to fetch/unpack: pointer JSON structure, +tarball layout — owned by the publishing pipeline, versioned by the existing +`format_version`) and the **content contract** (what capabilities consuming +the tree requires: descriptor grammar/schema — owned by index maintainers, +versioned by `index.toml` `spec`/`min_mcpp`). Putting the content floor *only* +in the pointer would hand content authority to the transport layer — the +wrong owner, and blind for every path that bypasses the pointer (git clone, +`path =`, restored caches). + +Three layers, each subsuming the previous one's failure mode; invest per +ecosystem scale: + +- **L1 — authority (this design, mandatory)**: contract travels inside the + tree; single check at the index-open choke point; staged refresh keeps the + last compatible snapshot. Parse failure → graceful keep-old + warn. +- **L2 — projection (optional, add anytime)**: publish script mechanically + derives `min_mcpp` into the pointer entry as *catalog metadata*, enabling + pre-download sensing. Legitimate exactly because L1 exists: projections may + be redundant, authority may not be ambiguous — on conflict the in-tree file + wins. Deferred today (saves only a few hundred KB per TTL window), never + architecturally wrong. +- **L3 — routing (evolution path, not now)**: versioned channels + (`mcpp-index-v1-latest`, `-v2-latest`, …; Debian dists / Rust channels + pattern). Incompatibility becomes a *routing decision* instead of an error: + clients select the channel matching their capability, old channels freeze, + `min_mcpp` degrades to a backstop assertion. Operationally premature at the + current user base; the hooks already exist for free (`index.toml` `spec` as + the future channel key; pointer `format_version` + the `indexes:{mcpp:{…}}` + shape extends to channel maps; the L1 check survives unchanged as the final + assertion). + +Rejected alternative: per-descriptor `min_mcpp`. Finer-grained, but N places to +maintain, and the failure it prevents (client parses *this* package) is already +covered by one index-wide dial that CI keeps honest. The per-descriptor +`spec = "1"` field stays as-is — the grammar change is representation-level, +not schema-level. + +Known limitation, accepted deliberately: the floor only protects clients +≥ 0.0.85 (older binaries don't know to look for `index.toml`). With today's +user base the one-time break is acceptable — that is exactly why this ships +now. Release notes for 0.0.85 carry the notice. + +### D4 — rollout order + +1. **mcpp 0.0.85**: D1 + D2 + D3 land together (D2 without D1 would lint a + grammar nobody can use; D1 without D3 would let migrated descriptors brick + old clients silently). +2. **mcpp-index**, in one PR: add `index.toml` (`min_mcpp = "0.0.85"`), bump CI + `MCPP_VERSION` to 0.0.85, add the `mcpp xpkg parse` lint step. +3. **mcpp-index**, follow-up PRs: migrate `generated_files` descriptors to + `[==[…]==]` (fmt → nlohmann.json → compat.eigen), update + `docs/repository-and-schema.md` (replace "不支持 `[[…]]`" with "requires + mcpp ≥ 0.0.85; lint enforces"), and require long-bracket form for *new* + generated_files descriptors going forward. + +--- + +## 3. Implementation sketch + +Phase 1 (`mcpp`), roughly in commit order: + +1. `manifest.cppm`: `read_long_bracket()` helper + `read_string()` branch + + `strip_lua_comments_and_strings()` support; unit tests incl. the + lua5.4-parity fixtures (level ≥ 1, embedded `]]`, first-newline, block + comment containing the text `mcpp = {`, CRLF). +2. `cli.cppm` + new `cli/cmd_xpkg.cppm`: `xpkg parse` routing; e2e test feeding + a good and a malformed descriptor. +3. `pm`: `index.toml` reader (tiny TOML via existing manifest TOML machinery); + floor check at the index-open choke point; refresh flow → staged unpack + + `index.toml` check + atomic swap (keep-old + warn on incompatible); E-code + + `explain` entry; e2e: fixture index with `min_mcpp = "9.9.9"` → build fails + with upgrade message; `MCPP_INDEX_FLOOR=ignore` passes; refresh against an + incompatible staged snapshot keeps the old one. + +Phase 2 (`mcpp-index`): `index.toml`; `tools/publish_mcpp_index.sh` packs it +into the artifact tree (one `cp`; pointer untouched); lint job +gains a pinned-mcpp download step (same snippet the workspace job uses) + +`xpkg parse` loop over changed descriptors; descriptor migrations as separate +reviewable PRs (the diff of escaped-string → long-bracket is mechanical and +the parity oracle makes it safe). + +## 4. Risks / open questions + +- **`]]` inside generated C++**: writer picks a sufficient level (`[==[`); + lint (via D2) catches a wrong choice. No auto-selection needed. +- **Parity drift**: the lua5.4-parity test is the guard; any future LuaCursor + change must keep it green. +- **`latest_mcpp` staleness**: it's advisory only; index CI bumps it alongside + `MCPP_VERSION`. Do we want the hint at all in v1? (cut-line candidate) +- **Third-party indices** never gaining `index.toml`: fine — absence means no + contract, same as today. +- **Interaction with `mcpp self update`** (doesn't exist yet): the floor error + prints manual instructions now; if/when self-update lands, the E-code message + upgrades to "run `mcpp self update`". diff --git a/.agents/docs/2026-07-08-scanner-backend-abstraction-design.md b/.agents/docs/2026-07-08-scanner-backend-abstraction-design.md new file mode 100644 index 0000000..36ea9e8 --- /dev/null +++ b/.agents/docs/2026-07-08-scanner-backend-abstraction-design.md @@ -0,0 +1,221 @@ +# Scanner backend abstraction: per-package opt-in, P1689 as lingua franca, plan-vs-ddi reconciliation (Design) + +Sister doc to `2026-07-08-index-version-semantics-and-descriptor-grammar-design.md` +(D1–D4 there; this doc is the scanner track). Goal: packages whose module units +legitimately need preprocessing to scan (upstream `src/fmt.cc` with +`#ifdef FMT_IMPORT_STD import std;`) can declare it, instead of the index +maintaining `generated_files` rewrites — while the default build keeps the +zero-cost deterministic text scan, and scan/build consistency becomes +machine-verified rather than rule-trusted. + +--- + +## 1. Current state (grounded, 2026-07-08) + +- Two scanners exist and already share one result shape: + `scan_packages` (M1 regex/text) and `scan_packages_p1689` + (GCC `-fdeps-format=p1689r5` / clang-scan-deps) — "Same ScanResult shape", + `src/modgraph/scanner.cppm:81-84`. +- Selection is **global only**: `MCPP_SCANNER=p1689` env var, one lambda in + `src/build/prepare.cppm:2544-2554`. No manifest/descriptor surface. +- Verified empirically (0.0.81): the same `#ifdef`-guarded `import std;` that + M1 rejects builds fine under `MCPP_SCANNER=p1689` with `-D` set, and + correctly drops the import edge without `-D` — the P1689 path evaluates + macros truthfully. +- `dyndep.cppm` is translation-only (.ddi → ninja dyndep file); the planned + graph is never compared against what the compiler actually saw. +- **Hazard**: the descriptor mcpp-segment parser silently skips unknown + top-level keys (`manifest.cppm` final `else` of the key loop — this is why + `schema = "0.1"` "works"). A `scanner = "p1689"` key on an old client is + ignored, then the text scanner rejects the source with a confusing error. + Schema evolution here is invisible, not loud. + +## 2. Why the M1 rule is sound at all (context) + +C++20 was deliberately shaped for text scanning (P1857R3): `import`/`module` +directives must be literal, line-initial, and **cannot be produced by macro +expansion**. The preprocessor's only levers over the import set are +conditional blocks (M1 bans them textually) and `#include`-carried text +(caught by §5). So "scan OK + any `-D` combination" yields the same phase-4 +import set the text scan saw — M1 consumes a language property, it does not +gamble. The rule, restated: **the module graph may not depend on information +the planner cannot evaluate.** Conditions must live in a layer the planner +understands: + +| condition kind | belongs in | resolution | +|---|---|---| +| platform/config (`#ifdef _WIN32 import w;`) | manifest data | lift to `cfg()` / features — planner evaluates natively | +| toolchain capability (fmt's `FMT_IMPORT_STD`) | preprocessor | declared per-package preprocess scan (this doc) | +| dead branches / compat shims | text | rewrite (`generated_files`) or upstream convergence | + +## 3. Design + +### 3-pre. The simplest mechanism first: declared scan overrides + +For index packages the sources are **sha256-pinned — their import set is a +constant**. Recomputing a constant with compiler processes at every user's +build is the wrong cost placement; the scan belongs to *packaging time*, +declared as data: + +```lua +mcpp = { + sources = { "*/src/fmt.cc" }, -- upstream file, verbatim + cxxflags = { "-DFMT_IMPORT_STD" }, -- author configures the macros + scan_overrides = { -- author asserts the scan result + ["*/src/fmt.cc"] = { provides = { "fmt" }, imports = { "std" } }, + }, +} +``` + +Files matching an override are not text-scanned; the declared unit enters the +graph directly. No preprocessing, no toolchain capability requirement, no +flag-keyed cache, identical behavior on every platform. **Assertion + +verification replaces computation** — sound because every lying-declaration +failure mode is loud at build (missing BMI / wrong BMI path / unresolvable +extra edge; no silent path), and three auditors stand behind it: + +1. index CI builds every package — a wrong declaration cannot merge; +2. plan-vs-ddi reconciliation (§3d) is **mandatory for override files**: the + compiler's own P1689 output verifies the assertion every build, catching + even upstream-version drift precisely; +3. `mcpp xpkg parse` (sister doc D2) validates the declaration's shape. + +`scan_overrides` is the recommended fix for the fmt case and its class +(pinned third-party module units with guarded imports). It also works in a +project's own `mcpp.toml` for stable files. The p1689 backend below remains +the right tool when the source is *mutable* (a project's own conditional +imports under active development) or when maintaining declarations stops +being worth it — it is the evolution path, not the first move. + +Necessity ordering that follows: index floor (sister doc D3, prerequisite for +adding ANY new descriptor key — old clients silently skip unknown keys) → +single-source lint (D2) → `scan_overrides` + reconciliation (this doc) → +long brackets (D1, ergonomics, deferrable) → per-package p1689 backend +(deferred until demanded; the seam below documents where it plugs in). + +### 3a. Backend model — P1689 is the wire format, not a backend + +``` +ScannerBackend + name : "text" | "p1689" | (future "exec:") + preconds : text → M1 rules (unconditional imports, no header units) + p1689 → toolchain has a P1689 producer (same capability check + dyndep uses, ninja_backend.cppm:258-263) + scan(batch of PackageRoot, ScanContext) -> ScanResult // shared shape today +``` + +- **"text"** — the built-in zero-cost producer with strict preconditions; the + default forever. +- **"p1689"** — one backend, multiple drivers picked per toolchain + (GCC built-in `-fdeps-*`, clang-scan-deps, MSVC `/scanDependencies` later); + all emit P1689 JSON. +- **future "exec:"** — external producer protocol: given file list + + flags, emit P1689 JSON. P1689 as lingua franca means third-party / + self-developed scanners plug in without touching the internal model; + `ScanResult`/modgraph stay the only internal truth. + +### 3b. Selection — resolution chain, most specific wins + +1. `MCPP_SCANNER` env (whole-build debug override, unchanged semantics) +2. package descriptor `mcpp = { scanner = "p1689" }` / package `mcpp.toml` + `[build] scanner` +3. root project `[build] scanner` +4. global `~/.mcpp/config.toml` +5. default `"text"` + +Granularity is **per-package** (the owner of conditional imports is the +package; it declares its own need). Per-source/per-target is deliberate +non-scope — the partition below just gets finer if ever needed. + +### 3c. Execution — partition + merge at the existing seam + +The `prepare.cppm` scan lambda becomes: resolve backend per package → +partition → run each backend's batch → concatenate `ScanResult`s → one +`modgraph::validate` over the merged graph. Graphs are logical-name-keyed; +merge is trivial; a text-scanned consumer importing a module provided by a +p1689-scanned package needs nothing special. + +Cache keys differ per backend and must not cross-pollute: +- text units → content hash only (graph independent of flags — the whole + point of the default); +- p1689 units → content hash + canonical flag fingerprint (reuse the plan's + `canonical_package_build_metadata` encoding — single source, scan-time flags + provably equal compile-time flags) + toolchain fingerprint + (`toolchain/fingerprint.cppm`). + +Precondition failure is a first-class error: "package X declares +`scanner = "p1689"`; toolchain has no P1689 producer (clang needs +clang-scan-deps)" — not a silent fallback to text. + +### 3d. Consistency — plan-vs-ddi reconciliation (backend-agnostic L3) + +Dyndep already makes the compiler emit per-TU P1689 `.ddi` files at build +time. Upgrade `dyndep.cppm` from pure translation to **assertion**: per TU, +diff planned `(provides, requires)` against the `.ddi`'s. Equal → the build is +certified consistent; different → hard error naming the file and the delta +("scanner assumed imports {std}; compiler saw {std, fmt} — conditional or +include-carried import?"). Zero extra compiler invocations; catches every +divergence class (include-carried imports, compiler extensions, future +language drift) without enumerating them. This closes the loop for BOTH +backends: text's construction-based guarantee and p1689's flags-keyed scan +both get observation-based verification. + +### 3e. Ecosystem rollout — couples to D2/D3 + +- The silent-unknown-key skip makes `scanner =` invisible to old clients → + index packages may only use it once the index floor (`index.toml + min_mcpp`) is ≥ the version shipping this feature. Same "floor first" rule + as the long-bracket grammar. +- `mcpp xpkg parse` (D2) learns the field, and gains `--strict`: **warn on + unknown mcpp-segment keys**, turning the silent-skip hazard into a loud + lint signal for all future schema evolution. +- Trust/cost: a descriptor opting into p1689 causes compiler-driven scans of + its own sources on the consumer's machine — no new trust boundary (building + compiles those sources anyway); cost scales with the opt-in set only. + +### 3f. Payoff example + +`pkgs/f/fmtlib.fmt.lua` drops its 3.4 KB `generated_files` copy entirely: + +```lua +mcpp = { + language = "c++23", + import_std = true, + modules = { "fmt" }, + scanner = "p1689", + include_dirs = { "*/include", "*/src" }, + sources = { "*/src/fmt.cc" }, -- upstream file, verbatim + cxxflags = { "-DFMT_IMPORT_STD" }, + targets = { ["fmt"] = { kind = "lib" } }, +} +``` + +(D1's long brackets make `generated_files` readable where still needed; +this makes it unnecessary where the only edit was un-guarding an import.) + +## 4. Non-goals / rejected + +- **Global default flip to p1689** — costs a compiler-ish pass per TU for + everyone and makes every graph flags-dependent; the text scan's determinism + and cache-friendliness stay the default. +- **Partial macro evaluation in the text scanner** — a guessing evaluator + fails silently; worse than refusing loudly (see sister doc's D1 rationale + and the M1 verification experiments). +- **Per-source scanner overrides** — speculative; partition granularity can + be refined later without model change. + +## 5. Sketch / order + +1. `manifest.cppm`: parse `scan_overrides` (mcpp segment + mcpp.toml); + validate shape. +2. `scanner.cppm` / `prepare.cppm`: override-matched files bypass the text + scan, declared units enter the graph. +3. `dyndep.cppm`: plan-vs-ddi reconciliation — mandatory for override files, + flag-gated elsewhere at first (`MCPP_VERIFY_MODGRAPH=1`), default-on once + stable. +4. D2 `xpkg parse --strict` unknown-key warning + override shape check. +5. Index side (after floor covers the release): migrate `fmtlib.fmt` off + `generated_files` onto `scan_overrides`; e2e over a workspace mixing + scanned and override packages. +6. Deferred (on demand): `scanner = "p1689"` per-package backend via the + partition + merge seam (§3a–3c). diff --git a/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml index 07a2de1..bb9b286 100644 --- a/.github/workflows/ci-macos.yml +++ b/.github/workflows/ci-macos.yml @@ -58,7 +58,10 @@ jobs: - name: Install LLVM via xlings run: | - xlings install llvm -y || xlings install llvm@20.1.7 -y + # Pin 20.1.7: llvm 22.1.8's libc++ headers reference __hash_memory, + # absent from the libc++ the link resolves on macOS (ld64.lld + # undefined symbol; hermetic-link follow-up tracks the real fix). + xlings install llvm@20.1.7 -y || xlings install llvm -y # Verify clang++ is available LLVM_ROOT=$(find "$HOME/.xlings" -path "*/xpkgs/xim-x-llvm/*/bin/clang++" | head -1 | xargs dirname | xargs dirname) echo "LLVM_ROOT=$LLVM_ROOT" @@ -283,9 +286,15 @@ jobs: - name: Configure dev mcpp sandbox to reuse xlings LLVM run: | - LLVM_PKG="$HOME/.xlings/data/xpkgs/xim-x-llvm/20.1.7" - MCPP_LLVM_LINK="$HOME/.mcpp/registry/data/xpkgs/xim-x-llvm/20.1.7" + # Discover whatever LLVM version `xlings install llvm` provided — + # hardcoding it (20.1.7) broke the job the day xlings bumped its + # default to 22.1.8. + LLVM_PKG=$(ls -d "$HOME/.xlings/data/xpkgs/xim-x-llvm"/*/ 2>/dev/null | head -1) + LLVM_PKG="${LLVM_PKG%/}" test -d "$LLVM_PKG" + LLVM_VER=$(basename "$LLVM_PKG") + echo "MCPP_LLVM_VER=$LLVM_VER" >> "$GITHUB_ENV" + MCPP_LLVM_LINK="$HOME/.mcpp/registry/data/xpkgs/xim-x-llvm/$LLVM_VER" printf '1\n' > "$LLVM_PKG/.mcpp_ok" mkdir -p "$HOME/.mcpp/registry/data/xpkgs/xim-x-llvm" @@ -345,7 +354,7 @@ jobs: "$MCPP" self config --mirror "$MCPP_E2E_TOOLCHAIN_MIRROR" "$MCPP" self config # macOS default toolchain is LLVM - "$MCPP" toolchain default llvm@20.1.7 + "$MCPP" toolchain default "llvm@${MCPP_LLVM_VER}" bash tests/e2e/run_all.sh - name: "Toolchain: LLVM — build mcpp (self-host)" @@ -355,7 +364,7 @@ jobs: test -x "$MCPP" cp "$MCPP" /tmp/mcpp-fresh MCPP=/tmp/mcpp-fresh - "$MCPP" toolchain default llvm@20.1.7 + "$MCPP" toolchain default "llvm@${MCPP_LLVM_VER}" "$MCPP" clean "$MCPP" build "$MCPP" --version diff --git a/.gitignore b/.gitignore index 21c6385..dbf24f6 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ doctor.log *.ifc *.ddi compile_commands.json +.cache/ diff --git a/docs/05-mcpp-toml.md b/docs/05-mcpp-toml.md index 58ce224..3af1af2 100644 --- a/docs/05-mcpp-toml.md +++ b/docs/05-mcpp-toml.md @@ -439,6 +439,45 @@ features = { } ``` +### 2.8.3 `[scan_overrides.""]` — Author-Asserted Scan Results + +The default module scanner is a text-level pass that (deliberately) rejects +`import` statements inside conditional preprocessor blocks. Some legitimate +module units carry them — e.g. fmt's official `src/fmt.cc` guards +`import std;` behind `#ifdef FMT_IMPORT_STD`. When the file's import set is +known and stable, declare it instead of scanning: + +```toml +[modules] +sources = ["src/**/*.cppm", "vendor/fmt.cc"] + +[scan_overrides."vendor/fmt.cc"] +provides = ["fmt"] # at most one provided module per unit +imports = ["std"] +``` + +Files matched by the glob skip the text scan; the declared unit enters the +module graph directly. The declaration is **audited every build**: the +compiler's own P1689 scan of the file (the `.ddi` dyndep input) is compared +against it, and any divergence fails that compile edge with both sides +printed — a stale declaration cannot silently corrupt the graph. An override +glob that matches no source file is an error. + +The same key exists in xpkg descriptors (index packages): + +```lua +mcpp = { + sources = { "*/src/fmt.cc" }, + cxxflags = { "-DFMT_IMPORT_STD" }, + scan_overrides = { + ["*/src/fmt.cc"] = { provides = { "fmt" }, imports = { "std" } }, + }, +} +``` + +To extend the plan-vs-ddi audit to *every* module unit (not just overrides), +set `MCPP_VERIFY_MODGRAPH=1` when generating the build. + ### 2.9 `[profile.]` — Build Profiles ```toml diff --git a/mcpp.toml b/mcpp.toml index a21282b..a38bd0d 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "0.0.84" +version = "0.0.85" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index b206bd7..2559a47 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -314,7 +314,7 @@ std::string emit_ninja_string(const BuildPlan& plan) { // P1: per-file dyndep rule. Converts one .ddi → .dd independently. append(std::format( "rule cxx_dyndep\n" - " command = $mcpp dyndep --single --bmi-dir {} --bmi-ext {} --output $out $in\n" + " command = $mcpp dyndep --single --bmi-dir {} --bmi-ext {} $expect --output $out $in\n" " description = DYNDEP $out\n" " restat = 1\n\n", traits.bmiDir, traits.bmiExt)); @@ -502,10 +502,42 @@ std::string emit_ninja_string(const BuildPlan& plan) { // invalidates that file's .dd and its compile edge, not all edges. // Map ddi path → dd path for Phase 3 reference. std::map ddi_to_dd; + // Plan-vs-ddi reconciliation (design 2026-07-08 scanner doc §3d): + // scan_overrides units ALWAYS carry their planned (provides, imports) + // on the dyndep edge — the compiler's own P1689 scan audits the + // author's assertion, per TU, failing the edge on divergence. + // MCPP_VERIFY_MODGRAPH=1 (read at generation time) extends the + // check to every module unit. + const bool verifyAll = [] { + const char* v = std::getenv("MCPP_VERIFY_MODGRAPH"); + return v && std::string_view(v) == "1"; + }(); + std::map ddi_expect; + for (auto& cu : plan.compileUnits) { + if (is_c_source(cu.source)) continue; + if (!cu.scanOverridden && !verifyAll) continue; + auto ddi = (cu.object.parent_path() / cu.source.filename()).string() + ".ddi"; + std::string exp; + if (cu.providesModule) + exp += std::format("--expect-provides {}", *cu.providesModule); + if (!cu.imports.empty()) { + std::string csv; + for (auto& m : cu.imports) { + if (!csv.empty()) csv += ","; + csv += m; + } + if (!exp.empty()) exp += " "; + exp += std::format("--expect-imports {}", csv); + } + if (exp.empty()) exp = "--expect-none"; + ddi_expect[ddi] = std::move(exp); + } for (auto& ddi : ddi_paths) { auto dd = ddi + ".dd"; // e.g. obj/cli.cppm.ddi.dd ddi_to_dd[ddi] = dd; append(std::format("build {} : cxx_dyndep {}\n", dd, ddi)); + if (auto it = ddi_expect.find(ddi); it != ddi_expect.end()) + append(std::format(" expect = {}\n", it->second)); } append("\n"); diff --git a/src/build/plan.cppm b/src/build/plan.cppm index 13b361b..5a1506a 100644 --- a/src/build/plan.cppm +++ b/src/build/plan.cppm @@ -24,6 +24,9 @@ struct CompileUnit { std::vector packageCxxflags; std::optional providesModule; // logical name, if .cppm export std::vector imports; // logical names imported + // Unit came from a scan_overrides declaration — plan-vs-ddi + // verification is mandatory for it (ninja_backend emits --expect-*). + bool scanOverridden = false; }; struct LinkUnit { @@ -420,6 +423,7 @@ BuildPlan make_plan(const mcpp::manifest::Manifest& manifest, cu.providesModule = u.provides->logicalName; } for (auto& req : u.requires_) cu.imports.push_back(req.logicalName); + cu.scanOverridden = u.scanOverridden; plan.compileUnits.push_back(std::move(cu)); } diff --git a/src/cli.cppm b/src/cli.cppm index ac7b655..a7375e6 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -22,6 +22,7 @@ import mcpp.cli.cmd_build; import mcpp.cli.cmd_cache; import mcpp.cli.cmd_new; import mcpp.cli.cmd_publish; +import mcpp.cli.cmd_xpkg; import mcpp.cli.cmd_registry; import mcpp.cli.cmd_self; import mcpp.cli.cmd_toolchain; @@ -61,6 +62,7 @@ void print_usage() { std::println(" mcpp publish [--dry-run] Publish package to default registry"); std::println(" mcpp pack [--mode ] Build + bundle a tarball (m: system|vendored|self-contained|static)"); std::println(" mcpp emit xpkg [-V VER] [-o FILE] Generate xpkg Lua entry"); + std::println(" mcpp xpkg parse [--json] Validate an xpkg descriptor (resolver grammar)"); std::println(""); std::println("Resource management:"); std::println(" mcpp toolchain install|list|default Manage mcpp's private toolchains"); @@ -320,6 +322,18 @@ int run(int argc, char** argv) { return dispatch_sub("emit", p, {{"xpkg", cmd_emit_xpkg}}); }))) + // ─── xpkg (descriptor tooling: parse) ────────────────────────── + .subcommand(cl::App("xpkg") + .description("Inspect / validate xpkg descriptors") + .subcommand(cl::App("parse") + .description("Parse a descriptor's mcpp segment exactly as the resolver would (strict: unknown keys are errors)") + .option(cl::Option("json").help("Emit machine-readable JSON")) + .option(cl::Option("allow-unknown") + .help("Downgrade unknown mcpp-segment keys from error to warning"))) + .action(wrap_rc([&dispatch_sub](const cl::ParsedArgs& p) { + return dispatch_sub("xpkg", p, {{"parse", cmd_xpkg_parse}}); + }))) + // ─── resource management ─────────────────────────────────────── .subcommand(cl::App("toolchain") .description("Install / list / select / remove C++ toolchains") @@ -455,6 +469,12 @@ int run(int argc, char** argv) { .help("BMI cache directory name (default: gcm.cache)")) .option(cl::Option("bmi-ext").takes_value().value_name("EXT") .help("BMI file extension (default: .gcm)")) + .option(cl::Option("expect-provides").takes_value().value_name("NAME") + .help("(verification) planned provided module for this TU")) + .option(cl::Option("expect-imports").takes_value().value_name("CSV") + .help("(verification) planned imports for this TU, comma-separated")) + .option(cl::Option("expect-none") + .help("(verification) planner assumed no provides/imports")) .action(wrap_rc(cmd_dyndep))) ; @@ -491,9 +511,9 @@ int run(int argc, char** argv) { { std::string_view first = argv[1]; if (!first.starts_with('-')) { - static constexpr std::array known = { + static constexpr std::array known = { "new", "build", "run", "test", "clean", "add", "remove", - "update", "search", "publish", "pack", "emit", + "update", "search", "publish", "pack", "emit", "xpkg", "toolchain", "cache", "index", "self", "explain", "version", "dyndep", "why", "resolve", }; diff --git a/src/cli/cmd_build.cppm b/src/cli/cmd_build.cppm index 7589308..198f368 100644 --- a/src/cli/cmd_build.cppm +++ b/src/cli/cmd_build.cppm @@ -182,6 +182,35 @@ export int cmd_dyndep(const mcpplibs::cmdline::ParsedArgs& parsed) { std::println(stderr, "error: --single requires exactly one .ddi input"); return 2; } + // Plan-vs-ddi reconciliation: when the generator declared what the + // planner assumed for this TU, compare against the compiler's own + // scan and fail the edge on divergence (mandatory for + // scan_overrides units; opt-in elsewhere via MCPP_VERIFY_MODGRAPH). + std::string expProvides = parsed.option_or_empty("expect-provides").value(); + std::string expImports = parsed.option_or_empty("expect-imports").value(); + if (!expProvides.empty() || !expImports.empty() || + parsed.is_flag_set("expect-none")) { + std::ifstream is{std::filesystem::path{parsed.positional(0)}}; + std::string ddiBody{std::istreambuf_iterator(is), {}}; + auto unit = mcpp::dyndep::parse_ddi(ddiBody); + if (!unit) { + std::println(stderr, "error: {}: {}", parsed.positional(0), unit.error()); + return 1; + } + std::optional ep; + if (!expProvides.empty()) ep = expProvides; + std::vector ei; + for (std::size_t b = 0; b < expImports.size();) { + auto e = expImports.find(',', b); + if (e == std::string::npos) e = expImports.size(); + if (e > b) ei.emplace_back(expImports.substr(b, e - b)); + b = e + 1; + } + if (auto err = mcpp::dyndep::verify_unit_expectations(*unit, ep, ei)) { + std::println(stderr, "error: {}", *err); + return 1; + } + } body = mcpp::dyndep::emit_dyndep_single(parsed.positional(0), opts); } else { std::vector ddis; diff --git a/src/cli/cmd_xpkg.cppm b/src/cli/cmd_xpkg.cppm new file mode 100644 index 0000000..ff1f24f --- /dev/null +++ b/src/cli/cmd_xpkg.cppm @@ -0,0 +1,216 @@ +// mcpp.cli.cmd_xpkg — `mcpp xpkg parse `: parse an xpkg +// descriptor's mcpp segment with EXACTLY the code the resolver uses at +// build time (synthesize_from_xpkg_lua + identity), so index lint and the +// user's build can never disagree on the grammar (single source of truth — +// design: .agents/docs/2026-07-08-index-version-semantics-and-descriptor- +// grammar-design.md D2). +// +// Strict by default: unknown mcpp-segment keys are an ERROR — a key this +// binary doesn't know would be silently ignored at build time, which under +// the index-floor discipline means either a typo or a descriptor that needs +// a newer floor. `--allow-unknown` downgrades them to warnings. + +module; +#include + +export module mcpp.cli.cmd_xpkg; + +import std; +import mcpplibs.cmdline; +import mcpp.manifest; +import mcpp.ui; + +namespace mcpp::cli { + +namespace { + +std::string json_escape(std::string_view s) { + std::string out; + out.reserve(s.size() + 8); + for (char c : s) { + switch (c) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: + if (static_cast(c) < 0x20) + out += std::format("\\u{:04x}", static_cast(c)); + else + out += c; + } + } + return out; +} + +std::string json_array(const std::vector& v) { + std::string out = "["; + for (std::size_t i = 0; i < v.size(); ++i) { + if (i) out += ","; + out += "\"" + json_escape(v[i]) + "\""; + } + return out + "]"; +} + +} // namespace + +export int cmd_xpkg_parse(const mcpplibs::cmdline::ParsedArgs& parsed) { + auto file = parsed.positional(0); + if (file.empty()) { + mcpp::ui::error("usage: mcpp xpkg parse [--json] [--allow-unknown]"); + return 2; + } + std::ifstream is{std::filesystem::path{file}}; + if (!is) { + mcpp::ui::error(std::format("cannot open '{}'", file)); + return 1; + } + std::string lua{std::istreambuf_iterator(is), {}}; + + const bool asJson = parsed.is_flag_set("json"); + const bool allowUnknown = parsed.is_flag_set("allow-unknown"); + + // Identity — same normalization the filename-lookup gate uses. + auto id = mcpp::manifest::canonical_xpkg_identity_from_lua(lua); + if (id.name.empty()) { + mcpp::ui::error(std::format( + "{}: no package identity (missing `package.name`)", file)); + return 1; + } + std::string fqn = id.ns.empty() ? id.name : id.ns + "." + id.name; + + // Versions per platform (xpm table). + static constexpr std::string_view kPlatforms[] = {"linux", "macosx", "windows"}; + std::map> versions; + std::string anyVersion; + for (auto plat : kPlatforms) { + auto v = mcpp::manifest::list_xpkg_versions(lua, plat); + if (!v.empty() && anyVersion.empty()) anyVersion = v.front(); + versions.emplace(std::string(plat), std::move(v)); + } + if (anyVersion.empty()) { + mcpp::ui::error(std::format( + "{}: xpm table declares no versions for any platform", file)); + return 1; + } + + // Form A descriptors carry no `mcpp = {}` table — build info comes + // from the fetched source's own mcpp.toml. Nothing further to parse. + auto field = mcpp::manifest::extract_mcpp_field(lua); + if (field.kind != mcpp::manifest::McppField::TableBody) { + if (asJson) { + std::println("{{\"namespace\":\"{}\",\"name\":\"{}\",\"form\":\"A\"}}", + json_escape(id.ns), json_escape(id.name)); + } else { + std::println("package {} (namespace '{}')", fqn, id.ns); + std::println("form A — no mcpp segment (build info from the " + "source's mcpp.toml)"); + std::println("parse OK"); + } + return 0; + } + + // The parse users get at build time — same function, same grammar. + auto m = mcpp::manifest::synthesize_from_xpkg_lua(lua, fqn, anyVersion); + if (!m) { + mcpp::ui::error(std::format("{}: {}", file, m.error().format())); + return 1; + } + + int rc = 0; + if (!m->xpkgUnknownKeys.empty()) { + for (auto& k : m->xpkgUnknownKeys) { + auto msg = std::format( + "{}: unknown mcpp-segment key '{}' — silently ignored at " + "build time by this mcpp version", file, k); + if (allowUnknown) std::println(stderr, "warning: {}", msg); + else mcpp::ui::error(msg); + } + if (!allowUnknown) rc = 1; + } + + if (asJson) { + std::string genFiles = "["; + bool first = true; + for (auto& [path, content] : m->buildConfig.generatedFiles) { + if (!first) genFiles += ","; + first = false; + genFiles += std::format("{{\"path\":\"{}\",\"bytes\":{}}}", + json_escape(path.generic_string()), content.size()); + } + genFiles += "]"; + std::string targets = "["; + for (std::size_t i = 0; i < m->targets.size(); ++i) { + if (i) targets += ","; + targets += "\"" + json_escape(m->targets[i].name) + "\""; + } + targets += "]"; + std::string vers = "{"; + { + bool f2 = true; + for (auto& [plat, v] : versions) { + if (!f2) vers += ","; + f2 = false; + vers += "\"" + plat + "\":" + json_array(v); + } + } + vers += "}"; + std::string genContents = "{"; + { + bool f3 = true; + for (auto& [path, content] : m->buildConfig.generatedFiles) { + if (!f3) genContents += ","; + f3 = false; + genContents += "\"" + json_escape(path.generic_string()) + "\":\"" + + json_escape(content) + "\""; + } + } + genContents += "}"; + std::println("{{\"namespace\":\"{}\",\"name\":\"{}\",\"versions\":{}," + "\"standard\":\"{}\",\"import_std\":{},\"sources\":{}," + "\"include_dirs\":{},\"generated_files\":{}," + "\"generated_contents\":{},\"targets\":{}," + "\"unknown_keys\":{}}}", + json_escape(id.ns), json_escape(id.name), vers, + json_escape(m->package.standard), + m->language.importStd ? "true" : "false", + json_array(m->modules.sources), + json_array([&] { + std::vector dirs; + for (auto& d : m->buildConfig.includeDirs) + dirs.push_back(d.generic_string()); + return dirs; + }()), + genFiles, genContents, targets, + json_array(m->xpkgUnknownKeys)); + return rc; + } + + std::println("package {} (namespace '{}')", fqn, id.ns); + for (auto& [plat, v] : versions) { + if (v.empty()) continue; + std::string joined; + for (std::size_t i = 0; i < v.size(); ++i) { + if (i) joined += ", "; + joined += v[i]; + } + std::println("versions {:<8} {}", plat, joined); + } + std::println("standard {} import_std={}", m->package.standard, + m->language.importStd); + if (!m->modules.sources.empty()) + std::println("sources {}", m->modules.sources.size()); + if (!m->buildConfig.includeDirs.empty()) + std::println("includes {}", m->buildConfig.includeDirs.size()); + for (auto& [path, content] : m->buildConfig.generatedFiles) + std::println("generated {} ({} bytes)", path.generic_string(), content.size()); + for (auto& t : m->targets) + std::println("target {}", t.name); + if (!m->featuresMap.empty()) + std::println("features {}", m->featuresMap.size()); + if (rc == 0) std::println("parse OK"); + return rc; +} + +} // namespace mcpp::cli diff --git a/src/doctor.cppm b/src/doctor.cppm index c47b296..1c67e8e 100644 --- a/src/doctor.cppm +++ b/src/doctor.cppm @@ -377,6 +377,12 @@ export int explain_code(std::string_view code) { "A cached BMI file referenced by manifest.txt is missing on disk. Run\n" "`mcpp cache prune --older-than 0d` to drop stale entries; the next build\n" "will repopulate."}, + {"E0006", "index requires a newer mcpp", + "The package index declares (index.toml [index].min_mcpp) that its\n" + "descriptors need a newer mcpp than this binary — parsing them would\n" + "silently misbehave, so resolution stops instead. Upgrade mcpp:\n" + " curl -fsSL https://github.com/mcpp-community/mcpp/releases/latest/download/install.sh | bash\n" + "To bypass for debugging only: MCPP_INDEX_FLOOR=ignore mcpp build"}, }; for (auto& e : table) { if (e.code == code) { @@ -387,7 +393,7 @@ export int explain_code(std::string_view code) { } } std::println(stderr, "error: unknown error code '{}'", code); - std::println(stderr, " known codes: E0001..E0005"); + std::println(stderr, " known codes: E0001..E0006"); return 2; } diff --git a/src/dyndep.cppm b/src/dyndep.cppm index dfbb070..02d2220 100644 --- a/src/dyndep.cppm +++ b/src/dyndep.cppm @@ -66,6 +66,18 @@ std::expected emit_dyndep_single(const std::filesystem::path& ddiPath, const DyndepOptions& opts = {}); +// Plan-vs-ddi reconciliation: compare the compiler's OWN scan of a TU +// (the .ddi — ground truth of what phase 4 saw under real flags) against +// the planner's assumption. Returns an error message on divergence. +// Mandatory for scan_overrides units (an assertion needs its auditor); +// opt-in for the rest via MCPP_VERIFY_MODGRAPH=1 (ninja_backend decides +// at generation time). Design: .agents/docs/2026-07-08-scanner-backend- +// abstraction-design.md §3d. +std::optional +verify_unit_expectations(const UnitInfo& actual, + const std::optional& expectProvides, + const std::vector& expectImports); + } // namespace mcpp::dyndep namespace mcpp::dyndep { @@ -327,4 +339,34 @@ emit_dyndep_single(const std::filesystem::path& ddiPath, return out; } + +std::optional +verify_unit_expectations(const UnitInfo& actual, + const std::optional& expectProvides, + const std::vector& expectImports) +{ + std::set act_p(actual.provides.begin(), actual.provides.end()); + std::set exp_p; + if (expectProvides && !expectProvides->empty()) exp_p.insert(*expectProvides); + std::set act_r(actual.requires_.begin(), actual.requires_.end()); + std::set exp_r(expectImports.begin(), expectImports.end()); + + if (act_p == exp_p && act_r == exp_r) return std::nullopt; + + auto join = [](const std::set& s) { + std::string out; + for (auto& v : s) { if (!out.empty()) out += ", "; out += v; } + return out.empty() ? std::string("") : out; + }; + return std::format( + "module-graph divergence in {}:\n" + " planned : provides [{}] imports [{}]\n" + " compiler: provides [{}] imports [{}]\n" + " The compiler's P1689 scan disagrees with the planner's assumption\n" + " (stale scan_overrides declaration, or a conditional/include-carried\n" + " import). Fix the declaration or the source.", + actual.primaryOutput.string(), + join(exp_p), join(exp_r), join(act_p), join(act_r)); +} + } // namespace mcpp::dyndep diff --git a/src/manifest.cppm b/src/manifest.cppm deleted file mode 100644 index 968fce8..0000000 --- a/src/manifest.cppm +++ /dev/null @@ -1,2442 +0,0 @@ -// mcpp.manifest — load and validate mcpp.toml - -export module mcpp.manifest; - -import std; -import mcpp.libs.toml; -import mcpp.pm.dep_spec; // M5.x pm/ subsystem refactor: DependencySpec lives here -import mcpp.pm.compat; // Legacy dependency-key compatibility helpers -import mcpp.pm.dependency_selector; -import mcpp.pm.index_spec; // IndexSpec for [indices] section -import mcpp.platform; - -export namespace mcpp::manifest { - -// PR-R1 transitional: the dependency data model has moved into -// `mcpp.pm.dep_spec`. The aliases below keep `mcpp::manifest::DependencySpec` -// and `mcpp::manifest::kDefaultNamespace` available as before so existing -// callers (`cli.cppm`, `fetcher.cppm`, ...) compile unchanged. A later -// refactor PR will migrate call sites to reference `mcpp::pm::` directly -// and these aliases can disappear. -using DependencySpec = mcpp::pm::DependencySpec; -inline constexpr auto kDefaultNamespace = mcpp::pm::kDefaultNamespace; -inline constexpr auto kCompatNamespace = mcpp::pm::kCompatNamespace; - -struct CppStandardConfig { - std::string canonical = "c++23"; - std::string flag = "-std=c++23"; - int level = 23; - bool gnuDialect = false; -}; - -struct Package { - std::string name; - std::string namespace_; // xpkg V1 namespace field (0.0.6+); empty = infer from name - std::string version; - std::string standard = "c++23"; // C++ standard (M5.0: moved from [language]) - std::string description; - std::string license; - std::vector authors; - std::string repo; - std::vector platforms; // declared supported platforms (CI matrix hint) -}; - -struct Language { - std::string standard = "c++23"; - bool modules = true; - bool importStd = true; -}; - -struct Modules { - std::vector sources; // glob patterns - std::vector exports_; // declared module names (optional) - bool strict = false; -}; - -struct Target { - std::string name; - enum Kind { Library, Binary, SharedLibrary, TestBinary } kind; - std::string main; // for binary / test - std::string soname; // ABI name for shared libraries, e.g. libfoo.so.1 - // Per-target compile flags. SCOPE: applied ONLY to this target's exclusive - // entry source (its `main`) — never to shared module/impl objects, which are - // compiled once and linked into every target (the build's compile-once model; - // see src/build/plan.cppm). `defines` are sugar desugared to `-D` at plan - // time and applied to both the C and C++ entry compile. Use these for flags - // that are private to a binary's own entry (e.g. `-DBUILD_SERVER=1`, - // `-Wno-deprecated`); for divergence that must reach shared code, use a - // workspace member or a [features] knob instead. - std::vector cflags; - std::vector cxxflags; - std::vector defines; - // Build gate: this target is emitted ONLY when every listed feature is - // active in the current build (otherwise it is silently skipped). Gate - // only — it does not activate features (use --features / [features].default). - std::vector requiredFeatures; -}; - -// `DependencySpec` and `kDefaultNamespace` have moved to mcpp.pm.dep_spec. -// Aliases at the top of this file keep `mcpp::manifest::DependencySpec` -// resolvable for unchanged call sites. - -// `[toolchain]` section per docs/21-toolchain-and-tools.md -// linux = "gcc@15.1.0" -// macos = "llvm@20" -// windows = "msvc@system" -// default = "gcc@15.1.0" (used when current platform isn't listed) -struct Toolchain { - std::map byPlatform; // platform -> "pkg@ver" - - // Returns the toolchain spec for a platform, falling back to "default". - std::optional for_platform(std::string_view platform) const { - if (auto it = byPlatform.find(std::string(platform)); it != byPlatform.end()) { - return it->second; - } - if (auto it = byPlatform.find("default"); it != byPlatform.end()) { - return it->second; - } - return std::nullopt; - } -}; - -// `[build]` section — tunables for the build backend. -// -// M5.0: now also carries `sources` (moved from [modules]) and `include_dirs` -// (new). Defaults are injected by load() after parse if these are empty. -struct BuildConfig { - std::vector sources; // glob patterns - // feature name → extra source globs gated by that feature. A glob listed - // here is EXCLUDED from the default build and only compiled/linked when the - // feature is active for this package (resolved in prepare_build). Lets a - // dependency expose an optional component (e.g. gtest's gtest_main.cc behind - // the "main" feature) without it being linked by default — see - // .agents/docs/2026-06-25-gtest-main-feature-and-add-dev-design.md. - std::map> featureSources; - // feature name → package-owned preprocessor defines (e.g. "-DEIGEN_USE_BLAS"). - // Feature System v2 Stage 1: when the feature is active these are appended to - // the package's compile flags alongside the automatic -DMCPP_FEATURE_ - // (resolved in prepare_build). Restricted by convention to the package's own - // namespaced macros — features do NOT inject free-form cflags/ldflags, which - // would break feature-union composition. See - // .agents/docs/2026-06-29-feature-capability-model-design.md. - std::map> featureDefines; - std::vector includeDirs; // relative to package root - std::map generatedFiles; // Form B package-owned support files - bool staticStdlib = true; - // "" (default = dynamic), "static", "dynamic" — chosen at resolve - // time from --static / --target / [target.].linkage. Wired - // through to ninja backend as the `-static` link flag. - std::string linkage; - // M5.x C-language support. `cflags` / `cxxflags` are appended verbatim - // to the per-rule baseline (see `ninja_backend` cflags / cxxflags). - // `cStandard` controls -std= for the C compile rule (.c files). - // Empty cStandard → backend default ("c11" today). - std::vector cflags; - std::vector cxxflags; - std::vector ldflags; - std::string cStandard; - // Escape hatch for the hermetic link check: a sandbox toolchain whose - // CRT/loader resolve OUTSIDE the sandbox is a hard error by default - // (silent host contamination, or issue #195's bare-CRT link failure); - // set true to deliberately link against host libraries. - // MCPP_ALLOW_HOST_LIBS=1 is the per-invocation equivalent. - bool allowHostLibs = false; - // macOS minimum supported OS version for produced binaries - // (LC_BUILD_VERSION minos), e.g. "14.0". Mirrors the ecosystem - // conventions around deployment targets (the MACOSX_DEPLOYMENT_TARGET - // env var that cargo/rustc/cc honor; SwiftPM's `platforms:` manifest - // field; CMAKE_OSX_DEPLOYMENT_TARGET). Precedence: the env var (an - // explicit per-invocation override) wins over this manifest default; - // empty + no env = toolchain/SDK default. No effect off macOS. - std::string macosDeploymentTarget; - // Resolved build-profile knobs (from [profile.] + built-in defaults). - std::string optLevel = "2"; // -O level - bool debug = false; // -g - bool lto = false; // -flto - bool strip = false; // link -s - // `[build].default-profile` (alias: `profile`) — the project's DEFAULT - // profile when no --profile/--dev/--release is passed. The global convention - // default stays "release"; this lets a project opt its plain `mcpp build` - // into e.g. "dev" without typing --profile. Precedence: --profile/--dev/ - // --release flag > [build].default-profile > "release". NOTE (distribution - // footgun): a project that defaults to dev should pass `--profile release` - // when producing a distributable (a pack-time release guard is a follow-up). - std::string defaultProfile; -}; - -// `[runtime]` — requirements needed when launching built binaries. -struct RuntimeConfig { - std::vector libraryDirs; // relative to package root - std::vector dlopenLibs; // runtime-loaded sonames - std::vector capabilities; // host/system capabilities REQUIRED - // Capabilities this package explicitly FULFILS (strong provider claim). - // Packages that merely list a capability in `capabilities` are weak - // providers (back-compat); provides-declarers win provider selection. - std::vector provides; - // [runtime.] provider = "" — explicit provider selection - // (the three-tier knob: default/auto → explicit override). - std::map providerOverrides; -}; - -// `[xlings]` — the project's build ENVIRONMENT (L-1). The subsection names mirror -// xlings' own `.xlings.json` schema 1:1, so mcpp materializes them verbatim into -// `/.mcpp/.xlings.json` (no translation layer): `deps` (host build-tools -// installed by xlings), `[xlings.workspace]` (tool→version pins, the general form -// of `[toolchain]`), `subos` (a named per-project sandbox), `[xlings.envs]` -// (env vars applied by xvm shims). See -// .agents/docs/2026-06-29-manifest-environment-and-platform-design.md (L-1). -struct XlingsConfig { - std::vector deps; // → .xlings.json "deps" - std::map workspace; // → "workspace" (tool → version) - std::string subos; // → "subos" (named project sandbox) - std::map envs; // → "envs" (env var → value) - - bool empty() const { - return deps.empty() && workspace.empty() && subos.empty() && envs.empty(); - } -}; - -// `[target.]` — per-target overrides. -// Picked up when caller passes --target to build/run/test. -struct TargetEntry { - std::string toolchain; // e.g. "gcc@15.1.0-musl"; empty = inherit [toolchain] - std::string linkage; // "static" | "dynamic" | "" (= auto by libc) -}; - -// `[target.'cfg(...)'.build]` — platform-conditional build flags (L1). The -// predicate is the raw `[target.]` key (e.g. `cfg(windows)`, -// `cfg(all(linux, not(arch="aarch64")))`, or a bare triple). It is stored -// DEFERRED here because manifest parsing is target-agnostic; prepare_build -// evaluates it against the RESOLVED target (host triple for a native build, -// the --target triple for a cross build) and merges matching flags into -// buildConfig. See .agents/docs/2026-06-29-manifest-environment-and-platform-design.md. -struct ConditionalConfig { - std::string predicate; // the [target.] key - std::vector cflags; - std::vector cxxflags; - std::vector ldflags; - // Conditional dependencies (Phase 1b): merged into the corresponding - // manifest maps in prepare_build when the predicate matches the resolved - // target — before dependency resolution, so they resolve like any dep. - std::map dependencies; - std::map devDependencies; - std::map buildDependencies; -}; - -// `[lib]` — library "root" interface convention. -// -// Convention-over-configuration: a library package's primary module -// interface lives at `src/.cppm`, where `` is -// the last dotted segment of `[package].name` (e.g. `mcpplibs.tinyhttps` -// → `src/tinyhttps.cppm`). That file declares `export module -// ;` and re-exports the public partitions. The lib -// root then drives: -// * `[modules].exports` default (the lib root's module = the only -// externally-visible base module), -// * `mcpp publish` xpkg generation (consumer just `import ;`), -// * downstream tooling (docs / explain) entry point. -// -// Override the convention with `[lib].path = "src/foo.cppm"` (cargo-style) -// — the file must still `export module ;` (no partition). -// -// Lib-root is only meaningful for projects that ship a `kind = "lib"` -// target. Pure-binary projects (mcpp itself, scaffolded `mcpp new`) -// don't trigger any lib-root checks. -struct LibConfig { - std::filesystem::path path; // explicit override; empty = use convention -}; - -// `[pack]` — `mcpp pack` configuration. See docs/35-pack-design.md. -// -// `default_mode` picks the bundling strategy when the user runs bare -// `mcpp pack` (no `--mode` flag): -// "static" — full musl static, no PT_INTERP / RUNPATH -// "bundle-project" — bundle only project's third-party .so (default) -// "bundle-all" — bundle every dynamic dep including libc / libstdc++ -struct PackConfig { - std::string defaultMode; // empty → "bundle-project" - std::vector include; // extra files/globs to ship - std::vector exclude; // patterns to drop from include - // Mode C overrides — let the user expand or contract the PEP 600 - // skip list when their target distros differ from the default - // assumption ("modern desktop Linux"). - std::vector alsoSkip; // libs to ALSO skip on top of PEP 600 - std::vector forceBundle; // libs to bundle even if PEP 600 says skip -}; - -// `[workspace]` — multi-package workspace support (0.0.11+). -// -// A workspace root mcpp.toml declares member packages. Members share -// a unified lock file, target directory, and can inherit dependency -// versions via `.workspace = true`. -// -// Virtual workspace (no [package]): pure management node. -// Rooted workspace ([package] + [workspace]): root is also a package. -struct WorkspaceConfig { - std::vector members; // relative paths to member dirs - std::vector exclude; // paths to exclude - std::map dependencies; // [workspace.dependencies] - bool present = false; -}; - -// [profile.] — bundled build settings (opt level, debug, lto, strip). -struct Profile { - std::string optLevel = "2"; - bool debug = false; - bool lto = false; - bool strip = false; - // Passthrough escape hatch (fixed keys, open values — I6 completeness): - std::vector cflags; - std::vector cxxflags; - std::vector ldflags; -}; - -struct Manifest { - std::filesystem::path sourcePath; // mcpp.toml's filesystem path - - Package package; - Language language; - Modules modules; - std::vector targets; - - // version-string keyed dependencies (M2 short form only). - std::map dependencies; - std::map devDependencies; - std::map buildDependencies; // host-side tools (M5+ behavior) - - Toolchain toolchain; // optional; empty == fallback - BuildConfig buildConfig; - RuntimeConfig runtimeConfig; - XlingsConfig xlings; // [xlings] build environment (L-1) - std::vector conditionalConfigs; // [target.'cfg(...)'.build], deferred - std::map profiles; // [profile.] - // [features] — feature name → implied features ("default" = default set). - std::map> featuresMap; - - // Feature System v2 Stage 3 — capabilities (provides/requires). A capability - // is just a shared string. A package satisfies one via package-level - // `provides` or via a feature's `provides`; a feature `requires` an abstract - // capability instead of a concrete package, and the resolver binds exactly - // one provider from the graph. See - // .agents/docs/2026-06-29-feature-capability-model-design.md. - std::vector provides; // package-level - std::map> featureProvides; // feature → caps - std::map> featureRequires; // feature → caps - // Feature System v2 Stage 2a — dependencies activated by a feature. A dep - // declared ONLY here is optional: pulled into the resolution worklist only - // when its feature is active (root --features or a dep spec's features=[...]). - // Each value is a full DependencySpec, so a feature-dep may itself request - // features. See .agents/docs/2026-06-29-feature-optional-dependencies-s2-design.md. - std::map> featureDeps; - // Root-only: [capabilities] cap = "provider" pins (also fed by --cap). - std::map capabilityPins; - - // [target.] tables — empty if user didn't declare any. - std::map targetOverrides; - - // [pack] — `mcpp pack` config (see docs/35-pack-design.md). - PackConfig packConfig; - - // [lib] — library root interface convention (M5.x+). - LibConfig lib; - - // [workspace] — multi-package workspace. - WorkspaceConfig workspace; - - // [indices] — custom package index repositories (index-name → IndexSpec). - std::map indices; - - // M5.0: post-parse computed/inferred state - CppStandardConfig cppStandard; - bool usesModules = true; // refined by scanner - bool usesImportStd = true; // refined by scanner - std::vector inferredNotes; // for `Inferred ...` banner - - // Non-fatal schema warnings collected during parse (e.g. unsupported keys - // under [targets.]). The caller (prepare_build) prints these and, under - // --strict, escalates them to errors — mirroring the feature/platform path. - std::vector schemaWarnings; -}; - -struct ManifestError { - std::string message; - std::filesystem::path file; - std::size_t line = 0; - std::size_t column = 0; - - std::string format() const { - if (line) - return std::format("{}:{}:{}: error: {}", file.string(), line, column, message); - return std::format("{}: error: {}", file.string(), message); - } -}; - -std::expected parse_string(std::string_view content, - const std::filesystem::path& origin = "mcpp.toml"); -std::expected load(const std::filesystem::path& path); -std::expected normalize_cpp_standard(std::string_view raw); - -// For `mcpp new` scaffolding. -std::string default_template(std::string_view packageName); - -// M6.x: `mcpp` field in xpkg.lua may be either: -// - a string (path to mcpp.toml inside the extracted tarball, glob-able) -// - a table (inline Form B descriptor) -// extract_mcpp_field discriminates and returns the right kind. -struct McppField { - enum Kind { Absent, StringPath, TableBody } kind = Absent; - std::string value; // glob path (StringPath) or table body (TableBody) -}; -McppField extract_mcpp_field(std::string_view luaContent); - -// Extract the list of available versions for `platform` (e.g. "linux", "macosx", -// "windows") from an xpkg .lua's xpm. = { ["X.Y.Z"] = {...}, ... }. -// Returns an empty vector if the platform table is missing or has no entries. -std::vector -list_xpkg_versions(std::string_view luaContent, std::string_view platform); - -// Extract the `namespace` field from an xpkg .lua's `package = { ... }` block. -// Returns empty string if the field is absent (legacy descriptors). -std::string extract_xpkg_namespace(std::string_view luaContent); - -// Extract the `name` field from an xpkg .lua's `package = { ... }` block. -// Returns empty string if the field is absent. -std::string extract_xpkg_name(std::string_view luaContent); - -// Canonical package identity — the unified (ns, name) model (design doc §4.2). -// -// A package's identity is a 2-tuple: `ns` is a hierarchical namespace path -// (sub-namespaces, dotted: `compat`, `a.b.c`), `name` is a single atomic -// segment. Surface spellings (dotted name, embedded prefix, missing namespace) -// all normalize to this tuple. Normalization: -// 1. If `declaredNs` is empty, inherit `indexDefaultNs` (owning-index ns). -// 2. Fully-qualified name: if `declaredName` already starts with `ns.`, it is -// the FQN; otherwise FQN = `ns.declaredName` (or just `declaredName` when -// there is no namespace at all). -// 3. Split the FQN on its LAST dot: prefix → `ns`, final segment → `name`. -struct XpkgIdentity { - std::string ns; - std::string name; - bool operator==(const XpkgIdentity&) const = default; -}; -XpkgIdentity canonical_xpkg_identity(std::string_view declaredNs, - std::string_view declaredName, - std::string_view indexDefaultNs = {}); - -// Convenience: the canonical identity of an xpkg .lua, read from its declared -// `package.{namespace,name}`. Empty `name` field → empty identity. -XpkgIdentity canonical_xpkg_identity_from_lua(std::string_view luaContent, - std::string_view indexDefaultNs = {}); - -// Identity gate: does this xpkg .lua actually DECLARE the package the caller -// asked for? Compares the descriptor's declared `package.{name,namespace}` -// against the requested (ns, shortName) coordinate. This is the invariant that -// makes filename-based lookup safe — a file found by a candidate filename is -// only accepted when it *is* the requested package, so a bare `zlib.lua` from a -// foreign index never satisfies a request for `compat.zlib`. -// -// A descriptor that declares no `name` is accepted (cannot verify → lenient). -// `allowLegacyBareDefault` governs only the default-namespace case: whether a -// no-namespace descriptor whose bare name matches counts as the default-ns -// package (preserves legacy bare-named mcpplibs descriptors). -// -// `indexDefaultNs` is the namespace OWNED BY the index the descriptor was found -// in. When the read is scoped to a single known index (e.g. a `[indices]` local -// path index owns namespace `local-dev`), a descriptor that declares no -// namespace inherits the index's — so `tinycfg.lua` (name only) in the -// `local-dev` index matches a request for `(local-dev, tinycfg)`. Empty for -// multi-index scans where the owning index isn't known per-file (the builtin -// global scan); see the design doc §4.1. -bool xpkg_lua_identity_matches(std::string_view luaContent, - std::string_view ns, - std::string_view shortName, - bool allowLegacyBareDefault = true, - std::string_view indexDefaultNs = {}); - -// Resolve the lib-root path for a manifest: -// 1. `[lib].path` if explicitly set (cargo-style override), -// 2. otherwise the convention `src/.cppm`, where -// `` is the last `.`-segment of [package].name -// (e.g. `mcpplibs.tinyhttps` → `src/tinyhttps.cppm`). -// The returned path is relative to the package root unless the user -// passed an absolute path in `[lib].path`. -std::filesystem::path resolve_lib_root_path(const Manifest& manifest); - -// True if the manifest declares at least one `kind = "lib"` target. -// Lib-root convention only applies when this returns true. -bool has_lib_target(const Manifest& manifest); - -// Synthesize a Manifest from an xpkg .lua file's `mcpp = {}` segment. -// Used when a fetched dep has no source/mcpp.toml — the index entry's -// `mcpp = {}` workaround block carries the missing build info. -// -// The resulting Manifest is in-memory only; sourcePath is set to the -// supplied package name + version so error messages can refer to it. -std::expected -synthesize_from_xpkg_lua(std::string_view luaContent, - std::string_view packageName, - std::string_view packageVersion); - -} // namespace mcpp::manifest - -// ===================================================================== -// Implementation -// ===================================================================== - -namespace mcpp::manifest { - -namespace t = mcpp::libs::toml; - -namespace { - -bool starts_with_std_flag(std::string_view flag) { - return flag == "-std" || flag.starts_with("-std="); -} - -ManifestError error(const std::filesystem::path& origin, - const std::string& msg, - t::Position pos = {0, 0}) { - return ManifestError{msg, origin, pos.line, pos.column}; -} - -bool is_basename(std::string_view value) { - return !value.empty() - && value.find('/') == std::string_view::npos - && value.find('\\') == std::string_view::npos; -} - -std::optional validate_target_soname(const Target& t, - std::string_view targetPath) { - if (t.soname.empty()) return std::nullopt; - if (t.kind != Target::SharedLibrary) { - return std::format("{}soname is only valid for shared targets", targetPath); - } - if (!is_basename(t.soname)) { - return std::format("{}soname must be a library basename, got '{}'", - targetPath, t.soname); - } - return std::nullopt; -} - -} // namespace - -std::expected normalize_cpp_standard(std::string_view raw) { - auto trim_copy = [](std::string_view input) { - std::size_t begin = 0; - while (begin < input.size() - && std::isspace(static_cast(input[begin]))) { - ++begin; - } - std::size_t end = input.size(); - while (end > begin - && std::isspace(static_cast(input[end - 1]))) { - --end; - } - return std::string(input.substr(begin, end - begin)); - }; - - std::string s = trim_copy(raw); - for (auto& c : s) c = static_cast(std::tolower(static_cast(c))); - - CppStandardConfig out; - if (s.empty() || s == "c++23" || s == "c++2b") { - out.canonical = "c++23"; - out.flag = "-std=c++23"; - out.level = 23; - out.gnuDialect = false; - return out; - } - if (s == "gnu++23" || s == "gnu++2b") { - out.canonical = "gnu++23"; - out.flag = "-std=gnu++23"; - out.level = 23; - out.gnuDialect = true; - return out; - } - if (s == "c++26" || s == "c++2c") { - out.canonical = "c++26"; - out.flag = "-std=c++26"; - out.level = 26; - out.gnuDialect = false; - return out; - } - if (s == "gnu++26" || s == "gnu++2c") { - out.canonical = "gnu++26"; - out.flag = "-std=gnu++26"; - out.level = 26; - out.gnuDialect = true; - return out; - } - if (s == "c++latest") { - out.canonical = "c++latest"; - out.flag = "-std=c++26"; - out.level = 999; - out.gnuDialect = false; - return out; - } - - return std::unexpected(std::format( - "unsupported C++ standard '{}'; expected c++23, c++26, c++2c, gnu++23, gnu++26, or c++latest", - raw)); -} - -std::expected parse_string(std::string_view content, - const std::filesystem::path& origin) { - auto doc = t::parse(content); - if (!doc) { - return std::unexpected(error(origin, doc.error().message, doc.error().where)); - } - - Manifest m; - m.sourcePath = origin; - - // [package] — required unless [workspace] is present (virtual workspace). - auto* pkg_t = doc->get_table("package"); - bool has_workspace = (doc->get_table("workspace") != nullptr); - if (!pkg_t && !has_workspace) - return std::unexpected(error(origin, "missing required [package] section")); - - auto name = doc->get_string("package.name"); - if (!name && !has_workspace) - return std::unexpected(error(origin, "missing required field 'package.name'")); - if (name) m.package.name = *name; - - // 0.0.6+: explicit namespace field (xpkg V1 style). - // If present, [package].name is the short name. - // If absent, compat.cppm::resolve_package_name infers from dotted name. - if (auto v = doc->get_string("package.namespace")) m.package.namespace_ = *v; - - auto version = doc->get_string("package.version"); - if (!version && !has_workspace) - return std::unexpected(error(origin, "missing required field 'package.version'")); - if (version) m.package.version = *version; - - if (auto v = doc->get_string("package.description")) m.package.description = *v; - if (auto v = doc->get_string("package.license")) m.package.license = *v; - if (auto v = doc->get_string("package.repo")) m.package.repo = *v; - if (auto v = doc->get_string_array("package.authors")) m.package.authors = *v; - if (auto v = doc->get_string_array("package.platforms")) m.package.platforms = *v; - - // [package].standard (M5.0 new home) - if (auto v = doc->get_string("package.standard")) m.package.standard = *v; - - // [language] (M5.0: deprecated, kept for backward compat — drop in M6) - // Reads to old fields AND mirrors to new package.standard if [package].standard not set. - bool had_language_section = (doc->get_table("language") != nullptr); - if (auto v = doc->get_string("language.standard")) { - m.language.standard = *v; - // mirror to new home only if [package].standard wasn't explicitly set - if (!doc->get_string("package.standard")) m.package.standard = *v; - } else { - m.language.standard = m.package.standard; // keep old field consistent with new - } - if (auto v = doc->get_bool("language.modules")) m.language.modules = *v; - if (auto v = doc->get_bool("language.import_std")) m.language.importStd = *v; - - // Validation on the unified standard. Store the canonical spelling so all - // downstream build surfaces consume one active value. - auto stdCfg = normalize_cpp_standard(m.package.standard); - if (!stdCfg) return std::unexpected(error(origin, stdCfg.error())); - m.cppStandard = *stdCfg; - m.package.standard = m.cppStandard.canonical; - m.language.standard = m.cppStandard.canonical; - if (had_language_section && !m.language.modules) { - return std::unexpected(error(origin, - "language.modules must be true (mcpp is modules-only)")); - } - - // [build].sources (M5.0 new home) + [modules].sources (deprecated, compat) - if (auto v = doc->get_string_array("build.sources")) m.buildConfig.sources = *v; - if (auto v = doc->get_string_array("modules.sources")) { - m.modules.sources = *v; - // If [build].sources wasn't set, mirror legacy field into new field. - if (m.buildConfig.sources.empty()) m.buildConfig.sources = *v; - } - // Mirror new → legacy so existing code reading manifest.modules.sources keeps working. - if (m.modules.sources.empty()) m.modules.sources = m.buildConfig.sources; - - if (auto v = doc->get_string_array("modules.exports")) m.modules.exports_ = *v; - if (auto v = doc->get_bool("modules.strict")) m.modules.strict = *v; - - // [build].include_dirs (M5.0 new field) - if (auto v = doc->get_string_array("build.include_dirs")) { - for (auto& s : *v) m.buildConfig.includeDirs.emplace_back(s); - } - - // [targets.*] — M5.0: now optional. If absent, defer to auto-inference (in load()). - // [profile.] — bundled build settings. - if (auto* profile_table = doc->get_table("profile"); - profile_table && !profile_table->empty()) { - for (auto& [pname, pval] : *profile_table) { - if (!pval.is_table()) continue; - auto& tt = pval.as_table(); - Profile pr; - if (auto it = tt.find("opt"); it != tt.end()) { - if (it->second.is_string()) pr.optLevel = it->second.as_string(); - else if (it->second.is_int()) pr.optLevel = std::to_string(it->second.as_int()); - } - if (auto it = tt.find("debug"); it != tt.end() && it->second.is_bool()) pr.debug = it->second.as_bool(); - if (auto it = tt.find("lto"); it != tt.end() && it->second.is_bool()) pr.lto = it->second.as_bool(); - if (auto it = tt.find("strip"); it != tt.end() && it->second.is_bool()) pr.strip = it->second.as_bool(); - auto read_list = [&](const char* key, std::vector& out) { - if (auto it = tt.find(key); it != tt.end() && it->second.is_array()) - for (auto& v : it->second.as_array()) - if (v.is_string()) out.push_back(v.as_string()); - }; - read_list("cflags", pr.cflags); - read_list("cxxflags", pr.cxxflags); - read_list("ldflags", pr.ldflags); - m.profiles[pname] = pr; - } - } - - // [features] — feature name → implied features. "default" lists the - // default-active set. Two accepted shapes (Feature System v2): - // array form (shorthand): name = ["implied", ...] - // table form (full): name = { implies = [...], defines = [...] } - // The table form lets a feature contribute package-owned defines (Stage 1); - // `requires`/`provides`/`deps` keys are reserved for later stages. - if (auto* features_table = doc->get_table("features"); - features_table && !features_table->empty()) { - auto read_str_array = [](const auto& tbl, std::string_view key, - std::vector& out) { - if (auto it = tbl.find(std::string(key)); - it != tbl.end() && it->second.is_array()) - for (auto& v : it->second.as_array()) - if (v.is_string()) out.push_back(v.as_string()); - }; - for (auto& [fname, fval] : *features_table) { - std::vector implied; - if (fval.is_array()) { - for (auto& v : fval.as_array()) - if (v.is_string()) implied.push_back(v.as_string()); - } else if (fval.is_table()) { - auto& ft = fval.as_table(); - read_str_array(ft, "implies", implied); - std::vector defs; - read_str_array(ft, "defines", defs); - if (!defs.empty()) m.buildConfig.featureDefines[fname] = std::move(defs); - std::vector reqs, provs; - read_str_array(ft, "requires", reqs); - read_str_array(ft, "provides", provs); - if (!reqs.empty()) m.featureRequires[fname] = std::move(reqs); - if (!provs.empty()) m.featureProvides[fname] = std::move(provs); - } - m.featuresMap[fname] = std::move(implied); - } - } - - // [package] provides — package-level capabilities (Feature System v2 S3). - if (auto v = doc->get_string_array("package.provides")) m.provides = *v; - - // [capabilities] cap = "provider" — root-only provider pins. - if (auto* caps = doc->get_table("capabilities"); caps && !caps->empty()) { - for (auto& [cap, cval] : *caps) - if (cval.is_string()) m.capabilityPins[cap] = cval.as_string(); - } - - auto* targets_table = doc->get_table("targets"); - if (targets_table && !targets_table->empty()) { - for (auto& [tname, tval] : *targets_table) { - if (!tval.is_table()) { - return std::unexpected(error(origin, - std::format("[targets.{}] must be a table", tname))); - } - Target t; - t.name = tname; - auto& tt = tval.as_table(); - - auto kit = tt.find("kind"); - if (kit == tt.end() || !kit->second.is_string()) { - return std::unexpected(error(origin, - std::format("targets.{}.kind missing or not a string", tname))); - } - const auto& kind_s = kit->second.as_string(); - if (kind_s == "lib" || kind_s == "library") t.kind = Target::Library; - else if (kind_s == "bin" || kind_s == "binary") t.kind = Target::Binary; - else if (kind_s == "shared" || kind_s == "dylib" - || kind_s == "so" || kind_s == "shlib") t.kind = Target::SharedLibrary; - else return std::unexpected(error(origin, - std::format("targets.{}.kind must be 'bin', 'lib' or 'shared'; got '{}'", tname, kind_s))); - - if (t.kind == Target::Binary) { - auto mit = tt.find("main"); - if (mit == tt.end() || !mit->second.is_string()) { - return std::unexpected(error(origin, - std::format("targets.{} (kind=bin) requires 'main' field", tname))); - } - t.main = mit->second.as_string(); - } - if (auto sit = tt.find("soname"); sit != tt.end()) { - if (!sit->second.is_string()) { - return std::unexpected(error(origin, - std::format("targets.{}.soname must be a string", tname))); - } - t.soname = sit->second.as_string(); - } - if (auto msg = validate_target_soname(t, std::format("targets.{}.", tname))) { - return std::unexpected(error(origin, *msg)); - } - - // Per-target flags (entry-scoped) + required-features gate. - auto read_list = [&](const char* key, std::vector& out) { - if (auto it = tt.find(key); it != tt.end() && it->second.is_array()) - for (auto& v : it->second.as_array()) - if (v.is_string()) out.push_back(v.as_string()); - }; - read_list("cflags", t.cflags); - read_list("cxxflags", t.cxxflags); - read_list("defines", t.defines); - read_list("required_features", t.requiredFeatures); - // Guard: -std=... belongs to [package].standard, not per-target flags - // (same rule as [build].cxxflags). Reject early with a clear message. - for (auto const& flag : t.cxxflags) { - if (starts_with_std_flag(flag)) { - return std::unexpected(error(origin, std::format( - "targets.{}.cxxflags contains '{}'; use [package].standard to " - "configure the C++ language standard", tname, flag))); - } - } - - // Surface unsupported keys instead of silently dropping them — the - // historic footgun behind issue #131 (a `[targets.x] cxxflags` typo on - // an older mcpp just vanished). Per-target arbitrary build config that - // must reach SHARED code is intentionally not a target key; point users - // at the right axis (workspace / features / profile). - static constexpr std::string_view kKnownTargetKeys[] = { - "kind", "main", "soname", - "cflags", "cxxflags", "defines", "required_features", - }; - for (auto& [key, _] : tt) { - bool known = false; - for (auto k : kKnownTargetKeys) if (key == k) { known = true; break; } - if (!known) { - m.schemaWarnings.push_back(std::format( - "[targets.{}] has unsupported key '{}' (ignored). Per-target keys: " - "kind, main, soname, cflags, cxxflags, defines, required_features. " - "For config that must affect shared code, split into a workspace " - "member or use [features]; for a whole-build mode use [profile.*].", - tname, key)); - } - } - m.targets.push_back(std::move(t)); - } - } // close `if (targets_table && !targets_table->empty())` - - // [dependencies] / [dev-dependencies] - // - // Three accepted forms (M5.x): - // - // (1) flat / default-ns - // [dependencies] - // gtest = "1.15.2" ⇒ (mcpp, gtest) - // frob = { path = "..." } ⇒ (mcpp, frob) inline spec - // - // (2) namespaced subtable (TOML-native, no quotes) - // [dependencies.mcpplibs] - // cmdline = "0.0.2" ⇒ (mcpplibs, cmdline) - // tmpl = { version = "0.0.1", features = [...] } - // - // (3) legacy quoted dotted form (deprecated, still parsed) - // [dependencies] - // "mcpplibs.cmdline" = "0.0.2" ⇒ (mcpplibs, cmdline) + warning - // - // The map key remains the fully-qualified `.` for non-default - // namespaces (so existing fetcher / lockfile lookups by composite name - // keep working) and the bare `` for the default namespace (so the - // common case stays unchanged). - auto is_dep_spec_key = [](std::string_view k) { - return k == "path" || k == "version" || k == "git" - || k == "rev" || k == "tag" || k == "branch" - || k == "features" || k == "workspace" || k == "visibility" - || k == "backend"; - }; - auto looks_like_inline_dep_spec = [&](const t::Table& sub) { - if (sub.empty()) return false; - for (auto& [sk, sv] : sub) { - if (!is_dep_spec_key(sk)) return false; - } - return true; - }; - - auto fill_inline_spec = [&](DependencySpec& spec, - std::string_view section, - std::string_view fqName, - const t::Table& sub) -> std::expected - { - if (auto it = sub.find("path"); it != sub.end() && it->second.is_string()) spec.path = it->second.as_string(); - if (auto it = sub.find("version"); it != sub.end() && it->second.is_string()) spec.version = it->second.as_string(); - if (auto it = sub.find("git"); it != sub.end() && it->second.is_string()) spec.git = it->second.as_string(); - if (auto it = sub.find("visibility"); it != sub.end() && it->second.is_string()) { - spec.visibility = it->second.as_string(); - if (spec.visibility != "public" - && spec.visibility != "private" - && spec.visibility != "interface") { - return std::unexpected(error(origin, std::format( - "[{}.\"{}\"] visibility must be 'public', 'private', or 'interface'", - section, fqName))); - } - } - if (auto it = sub.find("features"); it != sub.end() && it->second.is_array()) { - for (auto& fv : it->second.as_array()) - if (fv.is_string()) spec.features.push_back(fv.as_string()); - } - // `backend = ""` — sugar for requesting the dependency's - // `backend-` feature (library-level backend selection knob). - if (auto it = sub.find("backend"); it != sub.end() && it->second.is_string()) { - spec.features.push_back("backend-" + it->second.as_string()); - } - if (auto it = sub.find("rev"); it != sub.end() && it->second.is_string()) { - spec.gitRev = it->second.as_string(); - spec.gitRefKind = "rev"; - } else if (auto it = sub.find("tag"); it != sub.end() && it->second.is_string()) { - spec.gitRev = it->second.as_string(); - spec.gitRefKind = "tag"; - } else if (auto it = sub.find("branch"); it != sub.end() && it->second.is_string()) { - spec.gitRev = it->second.as_string(); - spec.gitRefKind = "branch"; - } - if (auto it = sub.find("workspace"); it != sub.end() && it->second.is_bool() && it->second.as_bool()) { - spec.inheritWorkspace = true; - return {}; // version will be filled in by workspace merge - } - if (spec.path.empty() && spec.version.empty() && spec.git.empty()) { - return std::unexpected(error(origin, std::format( - "[{}.\"{}\"] must specify 'path', 'version', or 'git'", section, fqName))); - } - if (!spec.git.empty() && spec.gitRev.empty()) { - return std::unexpected(error(origin, std::format( - "[{}.\"{}\"] git dep requires one of: rev / tag / branch", section, fqName))); - } - return {}; - }; - - auto assign_dep = [&](std::string_view section, - std::map& out, - const mcpp::pm::DependencySelector& selector, - const t::Value& value, - bool legacyDottedKey) - -> std::expected - { - if (selector.candidates.empty()) { - return std::unexpected(error(origin, std::format( - "[{}] dependency selector '{}' has no candidates", - section, selector.stableMapKey))); - } - - DependencySpec spec; - spec.namespace_ = selector.candidates.front().namespace_; - spec.shortName = selector.candidates.front().shortName; - spec.candidates = selector.candidates; - spec.legacyDottedKey = legacyDottedKey; - - auto key = selector.stableMapKey; - if (value.is_string()) { - spec.version = value.as_string(); - } else if (value.is_table()) { - auto& sub = value.as_table(); - if (!looks_like_inline_dep_spec(sub)) { - return std::unexpected(error(origin, std::format( - "[{}.{}] must be a version string or table of " - "(path/version/git/rev/tag/branch/features/visibility)", - section, key))); - } - if (auto r = fill_inline_spec(spec, section, key, sub); !r) return r; - } else { - return std::unexpected(error(origin, std::format( - "[{}].{} must be a string (version) or table (path/version/...)", - section, key))); - } - - out[key] = std::move(spec); - return {}; - }; - - auto is_namespace_table = [&](std::string_view section, - std::string_view key) { - auto path = std::format("{}.{}", section, key); - return doc->has_explicit_table(path) - || key == kDefaultNamespace; - }; - - std::function( - std::string_view, - std::map&, - std::string, - std::string, - const t::Table&)> load_nested_dep_table; - - load_nested_dep_table = - [&](std::string_view section, - std::map& out, - std::string ns, - std::string mapPrefix, - const t::Table& table) -> std::expected - { - for (auto& [k, v] : table) { - if (v.is_string() || - (v.is_table() && looks_like_inline_dep_spec(v.as_table()))) { - auto mapKey = mapPrefix.empty() - ? k - : std::format("{}.{}", mapPrefix, k); - auto selector = mcpp::pm::make_direct_dependency_selector( - ns, k, mapKey); - if (auto r = assign_dep(section, out, selector, v, false); !r) - return r; - continue; - } - if (!v.is_table()) { - return std::unexpected(error(origin, std::format( - "[{}].{}.{} must be a string, inline dep table, or nested table", - section, ns, k))); - } - auto childNs = std::format("{}.{}", ns, k); - auto childMapPrefix = mapPrefix.empty() - ? k - : std::format("{}.{}", mapPrefix, k); - if (auto r = load_nested_dep_table( - section, out, childNs, childMapPrefix, v.as_table()); !r) - return r; - } - return {}; - }; - - std::function( - std::string_view, - std::map&, - std::string, - const t::Table&)> load_selector_dep_table; - - load_selector_dep_table = - [&](std::string_view section, - std::map& out, - std::string selectorPrefix, - const t::Table& table) -> std::expected - { - for (auto& [k, v] : table) { - auto selectorText = selectorPrefix.empty() - ? k - : std::format("{}.{}", selectorPrefix, k); - if (v.is_string() || - (v.is_table() && looks_like_inline_dep_spec(v.as_table()))) { - auto selector = mcpp::pm::resolve_dependency_selector( - selectorText, - mcpp::pm::DependencySelectorMode::OmittedMcpplibsPriority); - if (auto r = assign_dep(section, out, selector, v, false); !r) - return r; - continue; - } - if (!v.is_table()) { - return std::unexpected(error(origin, std::format( - "[{}].{} must be a string, inline dep table, or nested table", - section, selectorText))); - } - if (auto r = load_selector_dep_table( - section, out, selectorText, v.as_table()); !r) - return r; - } - return {}; - }; - - // Parse a dependency table (already obtained) into `out`. Factored out of - // load_deps so the same logic serves both [dependencies] (via doc->get_table) - // and [target.'cfg(...)'.dependencies] (a nested table the dotted getter - // can't address). `section` is the logical section name, used for error - // messages and namespace/selector resolution. - auto load_deps_table = [&](std::string_view section, auto& tt, - std::map& out) - -> std::expected - { - for (auto& [k, v] : tt) { - // (1) string value → flat default-ns short version, or - // (3) legacy "ns.name" = "ver" (dotted key). - if (v.is_string()) { - if (k.find('.') != std::string::npos) { - auto legacyKey = mcpp::pm::compat::split_legacy_dependency_key(k); - auto selector = mcpp::pm::make_direct_dependency_selector( - legacyKey.namespace_, legacyKey.shortName, k); - if (auto r = assign_dep(section, out, selector, v, - legacyKey.legacyDottedKey); !r) - return r; - continue; - } - auto selector = mcpp::pm::resolve_dependency_selector( - k, mcpp::pm::DependencySelectorMode::OmittedMcpplibsPriority); - if (auto r = assign_dep(section, out, selector, v, false); !r) - return r; - continue; - } - - if (!v.is_table()) { - return std::unexpected(error(origin, std::format( - "[{}].{} must be a string (version) or table (path/version/...)", section, k))); - } - - auto& sub = v.as_table(); - - // (1') inline dep spec under the default namespace, e.g. - // frob = { path = "..." } or - // "mcpplibs.cmdline" = { version = "0.0.2" } - // The latter is the legacy dotted-key form; same treatment as (3). - if (looks_like_inline_dep_spec(sub)) { - if (k.find('.') != std::string::npos) { - auto legacyKey = mcpp::pm::compat::split_legacy_dependency_key(k); - auto selector = mcpp::pm::make_direct_dependency_selector( - legacyKey.namespace_, legacyKey.shortName, k); - if (auto r = assign_dep(section, out, selector, v, - legacyKey.legacyDottedKey); !r) - return r; - continue; - } - auto selector = mcpp::pm::resolve_dependency_selector( - k, mcpp::pm::DependencySelectorMode::OmittedMcpplibsPriority); - if (auto r = assign_dep(section, out, selector, v, false); !r) - return r; - continue; - } - - // (2) namespaced or nested subtable. - // - // Explicit tables such as `[dependencies.acme]` are namespace - // roots. Dotted keys written inside the single dependency table, - // such as `[dependencies] capi.lua = "0.0.3"`, are ordered - // selectors: mcpplibs.capi/lua first, then capi/lua. - if (is_namespace_table(section, k)) { - if (auto r = load_nested_dep_table(section, out, k, k, sub); !r) - return r; - } else if (auto r = load_selector_dep_table(section, out, k, sub); !r) { - return r; - } - } - return {}; - }; - auto load_deps = [&](std::string_view section, std::map& out) - -> std::expected - { - auto* tt = doc->get_table(section); - if (!tt) return {}; - return load_deps_table(section, *tt, out); - }; - if (auto r = load_deps("dependencies", m.dependencies); !r) return std::unexpected(r.error()); - if (auto r = load_deps("dev-dependencies", m.devDependencies); !r) return std::unexpected(r.error()); - if (auto r = load_deps("build-dependencies", m.buildDependencies); !r) return std::unexpected(r.error()); - - // [feature-deps.] — optional dependencies activated by a feature - // (Stage 2a). Each sub-table is loaded with the same dependency loader as - // [dependencies], keyed by the feature name. - if (auto* fdeps = doc->get_table("feature-deps")) { - for (auto& [fname, fval] : *fdeps) { - if (!fval.is_table()) continue; - if (auto r = load_deps("feature-deps." + std::string(fname), - m.featureDeps[fname]); !r) - return std::unexpected(r.error()); - m.featuresMap.try_emplace(fname, std::vector{}); // register - } - } - - // [toolchain] — platform → "pkg@version" map (docs/21) - if (auto* tt = doc->get_table("toolchain")) { - for (auto& [platform, val] : *tt) { - if (!val.is_string()) { - return std::unexpected(error(origin, - std::format("[toolchain].{} must be a string like \"gcc@15.1.0\"", platform))); - } - m.toolchain.byPlatform[platform] = val.as_string(); - } - } - - // [build] — backend tunables - if (auto v = doc->get_bool("build.static_stdlib")) m.buildConfig.staticStdlib = *v; - if (auto v = doc->get_bool("build.allow_host_libs")) m.buildConfig.allowHostLibs = *v; - if (auto v = doc->get_string_array("build.cflags")) m.buildConfig.cflags = *v; - if (auto v = doc->get_string_array("build.cxxflags")) m.buildConfig.cxxflags = *v; - if (auto v = doc->get_string_array("build.ldflags")) m.buildConfig.ldflags = *v; - if (auto v = doc->get_string("build.c_standard")) m.buildConfig.cStandard = *v; - if (auto v = doc->get_string("build.default-profile")) m.buildConfig.defaultProfile = *v; - else if (auto v = doc->get_string("build.profile")) m.buildConfig.defaultProfile = *v; // accepted alias - - // [xlings] — build environment (L-1). Subsections mirror .xlings.json 1:1. - if (auto v = doc->get_string_array("xlings.deps")) m.xlings.deps = *v; - if (auto v = doc->get_string("xlings.subos")) m.xlings.subos = *v; - if (auto* wt = doc->get_table("xlings.workspace")) - for (auto& [k, val] : *wt) - if (val.is_string()) m.xlings.workspace[k] = val.as_string(); - if (auto* et = doc->get_table("xlings.envs")) - for (auto& [k, val] : *et) - if (val.is_string()) m.xlings.envs[k] = val.as_string(); - if (auto v = doc->get_string("build.macos_deployment_target")) - m.buildConfig.macosDeploymentTarget = *v; - for (auto const& flag : m.buildConfig.cxxflags) { - if (starts_with_std_flag(flag)) { - return std::unexpected(error(origin, - std::format("build.cxxflags contains '{}'; use [package].standard to configure the C++ language standard", - flag))); - } - } - - // [runtime] — launch-time requirements. - if (auto v = doc->get_string_array("runtime.library_dirs")) { - for (auto& s : *v) m.runtimeConfig.libraryDirs.emplace_back(s); - } - if (auto v = doc->get_string_array("runtime.dlopen_libs")) - m.runtimeConfig.dlopenLibs = *v; - if (auto v = doc->get_string_array("runtime.capabilities")) - m.runtimeConfig.capabilities = *v; - if (auto v = doc->get_string_array("runtime.provides")) - m.runtimeConfig.provides = *v; - // [runtime.] provider = "" — explicit provider override. - if (auto* rt = doc->get_table("runtime"); rt && !rt->empty()) { - for (auto& [rk, rv] : *rt) { - if (!rv.is_table()) continue; // flat keys handled above - auto& tt = rv.as_table(); - if (auto it = tt.find("provider"); it != tt.end() && it->second.is_string()) - m.runtimeConfig.providerOverrides[rk] = it->second.as_string(); - } - } - - // [lib] — library root convention (cargo-style). - if (auto v = doc->get_string("lib.path")) { - m.lib.path = *v; - } - - // [pack] — `mcpp pack` configuration. See docs/35-pack-design.md. - if (auto v = doc->get_string("pack.default_mode")) { - const auto& s = *v; - if (s != "static" && s != "bundle-project" && s != "bundle-all") { - return std::unexpected(error(origin, std::format( - "[pack].default_mode = '{}' invalid; expected " - "'static' | 'bundle-project' | 'bundle-all'", s))); - } - m.packConfig.defaultMode = s; - } - if (auto v = doc->get_string_array("pack.include")) - m.packConfig.include = *v; - if (auto v = doc->get_string_array("pack.exclude")) - m.packConfig.exclude = *v; - // [pack.bundle-project] sub-table for fine-grained PEP 600 overrides. - if (auto v = doc->get_string_array("pack.bundle-project.also_skip")) - m.packConfig.alsoSkip = *v; - if (auto v = doc->get_string_array("pack.bundle-project.force_bundle")) - m.packConfig.forceBundle = *v; - - // [target.] — per-target overrides. We accept both GCC - // (x86_64-linux-musl) and Rust-style (x86_64-unknown-linux-musl) - // triple forms; the latter is canonicalised by stripping the - // `-unknown-` segment so both keys map to the same entry. - auto canon_triple = [](std::string s) { - constexpr std::string_view kUnknown = "-unknown-"; - if (auto p = s.find(kUnknown); p != std::string::npos) - s.replace(p, kUnknown.size(), "-"); - return s; - }; - if (auto* tt = doc->get_table("target")) { - for (auto& [triple, val] : *tt) { - if (!val.is_table()) continue; - auto& body = val.as_table(); - TargetEntry e; - if (auto it = body.find("toolchain"); it != body.end() && it->second.is_string()) - e.toolchain = it->second.as_string(); - if (auto it = body.find("linkage"); it != body.end() && it->second.is_string()) { - e.linkage = it->second.as_string(); - if (e.linkage != "static" && e.linkage != "dynamic") { - return std::unexpected(error(origin, std::format( - "[target.{}].linkage = '{}' is invalid; expected 'static' or 'dynamic'", - triple, e.linkage))); - } - } - m.targetOverrides[canon_triple(triple)] = std::move(e); - - // [target..{build,dependencies,...}] — platform-conditional - // config (L1). `triple` is the predicate key (cfg(...) or a bare - // triple); stored deferred, evaluated against the resolved target in - // prepare_build. - ConditionalConfig cc; - cc.predicate = triple; - if (auto bit = body.find("build"); bit != body.end() && bit->second.is_table()) { - auto& bt = bit->second.as_table(); - auto read_list = [&](const char* key, std::vector& out) { - if (auto f = bt.find(key); f != bt.end() && f->second.is_array()) - for (auto& v : f->second.as_array()) - if (v.is_string()) out.push_back(v.as_string()); - }; - read_list("cflags", cc.cflags); - read_list("cxxflags", cc.cxxflags); - read_list("ldflags", cc.ldflags); - } - // [target..{dependencies,dev-dependencies,build-dependencies}] - // parsed via the shared table-based loader (same selectors/namespaces - // as the global [dependencies]) into the deferred config. - auto read_deps = [&](const char* key, std::map& out) - -> std::expected - { - if (auto f = body.find(key); f != body.end() && f->second.is_table()) - return load_deps_table(key, f->second.as_table(), out); - return {}; - }; - if (auto r = read_deps("dependencies", cc.dependencies); !r) return std::unexpected(r.error()); - if (auto r = read_deps("dev-dependencies", cc.devDependencies); !r) return std::unexpected(r.error()); - if (auto r = read_deps("build-dependencies", cc.buildDependencies); !r) return std::unexpected(r.error()); - if (!cc.cflags.empty() || !cc.cxxflags.empty() || !cc.ldflags.empty() - || !cc.dependencies.empty() || !cc.devDependencies.empty() - || !cc.buildDependencies.empty()) - m.conditionalConfigs.push_back(std::move(cc)); - } - } - - // [workspace] — multi-package workspace support (0.0.11+). - if (doc->get_table("workspace")) { - m.workspace.present = true; - if (auto v = doc->get_string_array("workspace.members")) - m.workspace.members = *v; - if (auto v = doc->get_string_array("workspace.exclude")) - m.workspace.exclude = *v; - - // [workspace.dependencies] — versions that members inherit via .workspace = true. - if (auto* wdeps = doc->get_table("workspace.dependencies")) { - for (auto& [k, v] : *wdeps) { - if (v.is_string()) { - if (k.find('.') != std::string::npos) { - auto depKey = mcpp::pm::compat::split_legacy_dependency_key(k); - auto selector = mcpp::pm::make_direct_dependency_selector( - depKey.namespace_, depKey.shortName, k); - if (auto r = assign_dep("workspace.dependencies", - m.workspace.dependencies, - selector, v, - depKey.legacyDottedKey); !r) { - return std::unexpected(r.error()); - } - continue; - } - auto selector = mcpp::pm::resolve_dependency_selector( - k, mcpp::pm::DependencySelectorMode::OmittedMcpplibsPriority); - if (auto r = assign_dep("workspace.dependencies", - m.workspace.dependencies, - selector, v, false); !r) { - return std::unexpected(r.error()); - } - continue; - } - if (!v.is_table()) continue; - if (is_namespace_table("workspace.dependencies", k)) { - if (auto r = load_nested_dep_table("workspace.dependencies", - m.workspace.dependencies, - k, k, v.as_table()); !r) { - return std::unexpected(r.error()); - } - } else { - if (auto r = load_selector_dep_table("workspace.dependencies", - m.workspace.dependencies, - k, v.as_table()); !r) { - return std::unexpected(r.error()); - } - } - } - } - } - - // [indices] — custom package index repositories. - // - // Accepted forms: - // acme = "git@gitlab.example.com:platform/mcpp-index.git" # short: value = url - // acme-stable = { url = "git@...", tag = "v2.0" } # long: inline table - // local-dev = { path = "/my-packages" } # local path - // mcpplibs = { url = "https://...", rev = "abc123" } # pin built-in - if (auto* indices_t = doc->get_table("indices")) { - for (auto& [k, v] : *indices_t) { - mcpp::pm::IndexSpec spec; - spec.name = k; - - if (v.is_string()) { - // Short form: key = "url" - spec.url = v.as_string(); - } else if (v.is_table()) { - auto& sub = v.as_table(); - if (auto it = sub.find("url"); it != sub.end() && it->second.is_string()) spec.url = it->second.as_string(); - if (auto it = sub.find("rev"); it != sub.end() && it->second.is_string()) spec.rev = it->second.as_string(); - if (auto it = sub.find("tag"); it != sub.end() && it->second.is_string()) spec.tag = it->second.as_string(); - if (auto it = sub.find("branch"); it != sub.end() && it->second.is_string()) spec.branch = it->second.as_string(); - if (auto it = sub.find("path"); it != sub.end() && it->second.is_string()) spec.path = it->second.as_string(); - if (spec.url.empty() && spec.path.empty()) { - return std::unexpected(error(origin, std::format( - "[indices].{} must specify 'url' or 'path'", k))); - } - } else { - return std::unexpected(error(origin, std::format( - "[indices].{} must be a string (url) or inline table", k))); - } - - m.indices[k] = std::move(spec); - } - } - - return m; -} - -// M5.0: inject defaults and auto-infer targets when fields are absent. -// Mutates manifest in-place; called from load() with the project root. -namespace { - -void apply_defaults_and_infer(Manifest& m, const std::filesystem::path& root) { - // Default sources glob (covers .cppm/.cpp/.cc/.c under src/). - if (m.buildConfig.sources.empty()) { - m.buildConfig.sources = { - "src/**/*.cppm", - "src/**/*.cpp", - "src/**/*.cc", - "src/**/*.c", - }; - m.modules.sources = m.buildConfig.sources; // legacy mirror - m.inferredNotes.push_back("sources [src/**/*.{cppm,cpp,cc,c}]"); - } - - // Default include_dirs: ["include"] iff /include/ exists. - if (m.buildConfig.includeDirs.empty()) { - std::error_code ec; - if (std::filesystem::is_directory(root / "include", ec)) { - m.buildConfig.includeDirs.push_back("include"); - m.inferredNotes.push_back("include_dirs [include]"); - } - } - - // Auto-target inference (only when no [targets] declared). - if (m.targets.empty()) { - std::error_code ec; - auto mainCpp = root / "src" / "main.cpp"; - bool hasMain = std::filesystem::exists(mainCpp, ec); - - bool hasCppm = false; - if (std::filesystem::is_directory(root / "src", ec)) { - for (auto& e : std::filesystem::recursive_directory_iterator(root / "src", ec)) { - if (ec) break; - if (e.is_regular_file(ec) && !ec - && e.path().extension() == ".cppm") { - hasCppm = true; break; - } - } - } - - if (hasMain) { - Target t; - t.name = m.package.name; - t.kind = Target::Binary; - t.main = "src/main.cpp"; - m.targets.push_back(std::move(t)); - m.inferredNotes.push_back( - std::format("target {} (bin from src/main.cpp)", m.package.name)); - } else if (hasCppm) { - Target t; - t.name = m.package.name; - t.kind = Target::Library; - m.targets.push_back(std::move(t)); - m.inferredNotes.push_back( - std::format("target {} (lib from .cppm in src/)", m.package.name)); - } - // If neither, no auto-target — caller will error if it needs one. - } -} - -} // namespace - -std::expected load(const std::filesystem::path& path) { - std::ifstream is(path); - if (!is) { - return std::unexpected(ManifestError{ - std::format("cannot open '{}'", path.string()), - path, 0, 0}); - } - std::stringstream ss; - ss << is.rdbuf(); - auto m = parse_string(ss.str(), path); - if (!m) return m; - - // M5.0: defaults + target inference (uses filesystem context relative to mcpp.toml). - apply_defaults_and_infer(*m, path.parent_path()); - return m; -} - -// ===================================================================== -// synthesize_from_xpkg_lua — parse mcpp = {} segment from an xpkg .lua -// ===================================================================== -// -// Scope: tiny Lua-subset reader specialised for our `mcpp = { ... }` -// workaround block. We don't run real Lua; we just locate the mcpp -// table and read a short list of typed fields out of it. - -namespace { - -struct LuaCursor { - std::string_view text; - std::size_t pos = 0; - - bool eof() const { return pos >= text.size(); } - char peek() const { return pos < text.size() ? text[pos] : '\0'; } - - void skip_ws_and_comments() { - while (!eof()) { - char c = peek(); - if (c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == ',' || c == ';') { - ++pos; - } else if (c == '-' && pos + 1 < text.size() && text[pos + 1] == '-') { - while (!eof() && peek() != '\n') ++pos; - } else { - break; - } - } - } - - bool consume(char c) { - skip_ws_and_comments(); - if (peek() == c) { ++pos; return true; } - return false; - } - - std::string read_string() { - skip_ws_and_comments(); - if (peek() != '"' && peek() != '\'') return {}; - char q = text[pos++]; - std::string out; - while (!eof() && peek() != q) { - if (peek() == '\\' && pos + 1 < text.size()) { - ++pos; - char e = text[pos++]; - switch (e) { - case 'n': out.push_back('\n'); break; - case 't': out.push_back('\t'); break; - case 'r': out.push_back('\r'); break; - case '"': out.push_back('"'); break; - case '\'': out.push_back('\''); break; - case '\\': out.push_back('\\'); break; - default: out.push_back(e); - } - } else { - out.push_back(text[pos++]); - } - } - if (!eof()) ++pos; // closing quote - return out; - } - - std::string read_ident() { - skip_ws_and_comments(); - std::string out; - while (!eof() && (std::isalnum(static_cast(peek())) || - peek() == '_')) - { - out.push_back(text[pos++]); - } - return out; - } - - // Read either a bare ident or `["string"]`. - std::string read_key() { - skip_ws_and_comments(); - if (peek() == '[') { - ++pos; - auto s = read_string(); - skip_ws_and_comments(); - if (peek() == ']') ++pos; - return s; - } - return read_ident(); - } - - // Read a Lua barewordy value: number/true/false/nil up to delimiter. - std::string read_bareword() { - skip_ws_and_comments(); - std::string out; - while (!eof() && !std::isspace(static_cast(peek())) && - peek() != ',' && peek() != '}' && peek() != ';') - { - out.push_back(text[pos++]); - } - return out; - } - - // Skip an entire balanced { ... } block, string-aware. - void skip_table() { - if (!consume('{')) return; - int depth = 1; - while (!eof() && depth > 0) { - char c = peek(); - if (c == '"' || c == '\'') { - read_string(); - continue; - } else if (c == '-' && pos + 1 < text.size() && text[pos+1] == '-') { - while (!eof() && peek() != '\n') ++pos; - continue; - } else if (c == '{') { ++depth; ++pos; } - else if (c == '}') { --depth; ++pos; } - else { ++pos; } - } - } - - // Read and consume a balanced { ... } block, returning the inner text. - std::string read_table_body() { - if (!consume('{')) return {}; - auto start = pos; - int depth = 1; - while (!eof() && depth > 0) { - char c = peek(); - if (c == '"' || c == '\'') { - read_string(); - continue; - } - if (c == '-' && pos + 1 < text.size() && text[pos + 1] == '-') { - while (!eof() && peek() != '\n') ++pos; - continue; - } - if (c == '{') { - ++depth; - ++pos; - continue; - } - if (c == '}') { - --depth; - if (depth == 0) { - auto end = pos; - ++pos; - return std::string(text.substr(start, end - start)); - } - ++pos; - continue; - } - ++pos; - } - return {}; - } -}; - -std::string top_level_table_body_for_key(std::string_view body, std::string_view wantedKey) { - LuaCursor cur { body }; - cur.skip_ws_and_comments(); - while (!cur.eof()) { - auto key = cur.read_key(); - if (key.empty()) { - cur.skip_ws_and_comments(); - if (cur.eof()) break; - ++cur.pos; - continue; - } - cur.skip_ws_and_comments(); - if (!cur.consume('=')) { - cur.skip_ws_and_comments(); - continue; - } - cur.skip_ws_and_comments(); - if (key == wantedKey) { - return cur.read_table_body(); - } - if (cur.peek() == '{') cur.skip_table(); - else if (cur.peek() == '"' || cur.peek() == '\'') (void)cur.read_string(); - else (void)cur.read_bareword(); - cur.skip_ws_and_comments(); - } - return {}; -} - -std::string top_level_string_value_for_key(std::string_view body, std::string_view wantedKey) { - LuaCursor cur { body }; - cur.skip_ws_and_comments(); - while (!cur.eof()) { - auto key = cur.read_key(); - if (key.empty()) { - cur.skip_ws_and_comments(); - if (cur.eof()) break; - ++cur.pos; - continue; - } - cur.skip_ws_and_comments(); - if (!cur.consume('=')) { - cur.skip_ws_and_comments(); - continue; - } - cur.skip_ws_and_comments(); - if (key == wantedKey && (cur.peek() == '"' || cur.peek() == '\'')) { - return cur.read_string(); - } - if (cur.peek() == '{') cur.skip_table(); - else if (cur.peek() == '"' || cur.peek() == '\'') (void)cur.read_string(); - else (void)cur.read_bareword(); - cur.skip_ws_and_comments(); - } - return {}; -} - -// Strip Lua line comments (`-- ...\n`) and string contents from text, -// replacing them with spaces of the same length so positions are -// preserved. This is a simple-but-correct way to make the scanner -// in extract_mcpp_segment_body() ignore comments and strings without -// re-implementing a full Lua tokenizer. -std::string strip_lua_comments_and_strings(std::string_view text) { - std::string out(text.size(), ' '); - std::size_t i = 0; - while (i < text.size()) { - char c = text[i]; - // Line comment - if (c == '-' && i + 1 < text.size() && text[i+1] == '-') { - while (i < text.size() && text[i] != '\n') { - // keep newlines for line-number fidelity - out[i] = (text[i] == '\n') ? '\n' : ' '; - ++i; - } - continue; - } - // String literal - if (c == '"' || c == '\'') { - char q = c; - out[i] = c; // keep opening quote so structure-aware search still sees it - ++i; - while (i < text.size() && text[i] != q) { - if (text[i] == '\\' && i + 1 < text.size()) { - out[i] = ' '; - out[i+1] = ' '; - i += 2; - continue; - } - out[i] = (text[i] == '\n') ? '\n' : ' '; - ++i; - } - if (i < text.size()) { - out[i] = q; // closing quote - ++i; - } - continue; - } - out[i] = c; - ++i; - } - return out; -} - -// Locate the body of `mcpp = { ... }` and return the inner content (no -// surrounding braces). Returns empty string if not found. -// M6.x: locate the `mcpp = ...` field at top level of an xpkg.lua and -// classify it as either a table body or a string path. Operates on a -// comment-/string-stripped copy so literal "mcpp = ..." inside Lua -// comments doesn't false-match. -McppField extract_mcpp_field_impl(std::string_view raw_text) { - auto sanitized = strip_lua_comments_and_strings(raw_text); - std::string_view text { sanitized }; - - std::size_t p = 0; - while ((p = text.find("mcpp", p)) != std::string_view::npos) { - bool word_start = (p == 0 || (!std::isalnum(static_cast(text[p-1])) - && text[p-1] != '_')); - if (!word_start) { ++p; continue; } - std::size_t q = p + 4; - if (q < text.size() && (std::isalnum(static_cast(text[q])) || - text[q] == '_')) { ++p; continue; } - while (q < text.size() && (text[q] == ' ' || text[q] == '\t')) ++q; - if (q >= text.size() || text[q] != '=') { ++p; continue; } - ++q; - while (q < text.size() && (text[q] == ' ' || text[q] == '\t' || - text[q] == '\n' || text[q] == '\r')) ++q; - if (q >= text.size()) { ++p; continue; } - - // Discriminate: { → table body, " → string path - if (text[q] == '{') { - ++q; - std::size_t body_start = q; - int depth = 1; - while (q < text.size() && depth > 0) { - char c = text[q]; - if (c == '{') ++depth; - else if (c == '}') { - --depth; - if (depth == 0) { - return McppField{ - McppField::TableBody, - std::string(raw_text.substr(body_start, q - body_start))}; - } - } - ++q; - } - return {}; - } - if (text[q] == '"') { - // string literal — but the sanitizer blanks string contents, so - // re-locate the same `"..."` in raw_text and take its body. - // Find the opening `"` at offset q in raw_text (offsets align - // because sanitizer keeps positions). - std::size_t s = q; - if (s >= raw_text.size() || raw_text[s] != '"') { ++p; continue; } - ++s; - std::string val; - while (s < raw_text.size() && raw_text[s] != '"') { - if (raw_text[s] == '\\' && s + 1 < raw_text.size()) { - char nc = raw_text[s + 1]; - switch (nc) { - case 'n': val.push_back('\n'); break; - case 't': val.push_back('\t'); break; - case '"': val.push_back('"'); break; - case '\\': val.push_back('\\'); break; - default: val.push_back(nc); - } - s += 2; - } else { - val.push_back(raw_text[s++]); - } - } - return McppField{ McppField::StringPath, std::move(val) }; - } - ++p; - } - return {}; -} - -// Backward-compat: old API; prefer extract_mcpp_field for new callers. -std::string extract_mcpp_segment_body(std::string_view raw_text) { - auto f = extract_mcpp_field_impl(raw_text); - return f.kind == McppField::TableBody ? std::move(f.value) : std::string{}; -} - -} // namespace - -McppField extract_mcpp_field(std::string_view luaContent) { - return extract_mcpp_field_impl(luaContent); -} - -std::string extract_xpkg_namespace(std::string_view luaContent) { - auto packageBody = top_level_table_body_for_key(luaContent, "package"); - if (packageBody.empty()) return {}; - return top_level_string_value_for_key(packageBody, "namespace"); -} - -std::string extract_xpkg_name(std::string_view luaContent) { - auto packageBody = top_level_table_body_for_key(luaContent, "package"); - if (packageBody.empty()) return {}; - return top_level_string_value_for_key(packageBody, "name"); -} - -XpkgIdentity canonical_xpkg_identity(std::string_view declaredNs, - std::string_view declaredName, - std::string_view indexDefaultNs) { - // Step 1 — owning-index namespace: a descriptor that declares no namespace - // inherits the namespace of the index it lives in. - std::string ns(declaredNs.empty() ? indexDefaultNs : declaredNs); - std::string name(declaredName); - - // Step 2 — fully-qualified name. A declared name already prefixed with the - // namespace IS the FQN; otherwise the namespace is prepended. With no - // namespace at all, the declared name stands alone. - std::string fqn; - if (ns.empty()) { - fqn = name; - } else { - std::string prefix = ns + "."; - fqn = name.starts_with(prefix) ? name : ns + "." + name; - } - - // Step 3 — split the FQN on its LAST dot: prefix → ns, final segment → name. - auto pos = fqn.rfind('.'); - if (pos == std::string::npos) return XpkgIdentity{ /*ns=*/{}, /*name=*/fqn }; - return XpkgIdentity{ fqn.substr(0, pos), fqn.substr(pos + 1) }; -} - -XpkgIdentity canonical_xpkg_identity_from_lua(std::string_view luaContent, - std::string_view indexDefaultNs) { - auto name = extract_xpkg_name(luaContent); - if (name.empty()) return {}; - return canonical_xpkg_identity(extract_xpkg_namespace(luaContent), name, - indexDefaultNs); -} - -bool xpkg_lua_identity_matches(std::string_view luaContent, - std::string_view ns, - std::string_view shortName, - bool allowLegacyBareDefault, - std::string_view indexDefaultNs) { - auto luaName = extract_xpkg_name(luaContent); - if (luaName.empty()) return true; // no declared name → cannot verify, accept - - // Reduce the descriptor to its canonical (ns, name) tuple (design doc §4.2), - // then match per the unified model. - auto id = canonical_xpkg_identity(extract_xpkg_namespace(luaContent), - luaName, indexDefaultNs); - - // The single atomic name must equal the requested short name in every mode. - if (id.name != shortName) return false; - - // Discovery (empty request ns, e.g. `mcpp new --template X`): the caller - // derives the namespace from the descriptor, so a name match is enough. - if (ns.empty()) return true; - - // Unqualified / default-namespace request: resolve the name against the - // default namespace search path — the default namespace itself, then the - // `compat` wrapper namespace (`kCompatNamespace`, shared with the candidate - // generator). A legacy no-namespace descriptor is admitted under the flag. - if (ns == kDefaultNamespace) { - return id.ns == kDefaultNamespace - || id.ns == kCompatNamespace - || (allowLegacyBareDefault && id.ns.empty()); - } - - // Qualified request (concrete namespace: compat, xim, a custom/nested ns): - // exact namespace equality. Cross-namespace collisions are structurally - // impossible — a foreign `(xim, zlib)` never equals `(compat, zlib)`. - return id.ns == ns; -} - -std::vector -list_xpkg_versions(std::string_view luaContent, std::string_view platform) { - // Locate `xpm = { ... = { ["X.Y.Z"] = {...}, ... } ... }`. - // We work on a sanitized copy so quoted version keys remain locatable - // by their offsets in the original text. - auto sanitized = strip_lua_comments_and_strings(luaContent); - std::string_view text { sanitized }; - std::vector versions; - - auto find_word_at_lhs = [&](std::string_view name, std::size_t from) - -> std::size_t - { - std::size_t p = from; - while ((p = text.find(name, p)) != std::string_view::npos) { - bool word_start = (p == 0 || - (!std::isalnum(static_cast(text[p-1])) && text[p-1] != '_')); - std::size_t after = p + name.size(); - bool word_end = (after >= text.size() || - (!std::isalnum(static_cast(text[after])) && text[after] != '_')); - if (!word_start || !word_end) { ++p; continue; } - std::size_t q = after; - while (q < text.size() && (text[q] == ' ' || text[q] == '\t' || - text[q] == '\n' || text[q] == '\r')) ++q; - if (q < text.size() && text[q] == '=') return p; - ++p; - } - return std::string_view::npos; - }; - - auto skip_to_open_brace = [&](std::size_t from) -> std::size_t { - std::size_t q = from; - while (q < text.size() && text[q] != '{') ++q; - return q < text.size() ? q : std::string_view::npos; - }; - - // Match braces to find table extent. - auto find_table_end = [&](std::size_t open) -> std::size_t { - int depth = 1; - std::size_t q = open + 1; - while (q < text.size() && depth > 0) { - char c = text[q]; - if (c == '{') ++depth; - else if (c == '}') { - --depth; - if (depth == 0) return q; - } - ++q; - } - return std::string_view::npos; - }; - - auto xpm_pos = find_word_at_lhs("xpm", 0); - if (xpm_pos == std::string_view::npos) return versions; - auto xpm_open = skip_to_open_brace(xpm_pos); - if (xpm_open == std::string_view::npos) return versions; - auto xpm_end = find_table_end(xpm_open); - if (xpm_end == std::string_view::npos) return versions; - - auto plat_pos = find_word_at_lhs(platform, xpm_open + 1); - if (plat_pos == std::string_view::npos || plat_pos >= xpm_end) return versions; - auto plat_open = skip_to_open_brace(plat_pos); - if (plat_open == std::string_view::npos || plat_open >= xpm_end) return versions; - auto plat_end = find_table_end(plat_open); - if (plat_end == std::string_view::npos) return versions; - - // Inside platform table: scan for ["X.Y.Z"] = { ... } - std::size_t q = plat_open + 1; - while (q < plat_end) { - if (text[q] == '[') { - std::size_t r = q + 1; - while (r < plat_end && (text[r] == ' ' || text[r] == '\t')) ++r; - if (r < plat_end && text[r] == '"') { - ++r; - std::size_t key_start = r; - while (r < plat_end && text[r] != '"' && text[r] != '\n') ++r; - if (r < plat_end && text[r] == '"') { - versions.emplace_back(luaContent.substr(key_start, r - key_start)); - } - } - } - ++q; - } - return versions; -} - -std::expected -synthesize_from_xpkg_lua(std::string_view luaContent, - std::string_view packageName, - std::string_view packageVersion) -{ - auto body = extract_mcpp_segment_body(luaContent); - if (body.empty()) { - return std::unexpected(ManifestError{ - std::format( - "package '{}' has no `mcpp = {{}}` segment in its index entry " - "and the source has no mcpp.toml — cannot derive a manifest.", - packageName), - std::format("xpkg-lua of {}@{}", packageName, packageVersion), - 0, 0}); - } - if (auto platformBody = top_level_table_body_for_key(body, mcpp::platform::xpkg_platform); - !platformBody.empty()) { - body += "\n"; - body += platformBody; - } - - Manifest m; - m.sourcePath = std::format("xpkg-lua://{}@{}", packageName, packageVersion); - m.package.name = std::string(packageName); - m.package.version = std::string(packageVersion); - m.package.standard = "c++23"; - m.language.standard = "c++23"; - m.language.modules = true; - m.language.importStd = true; - - LuaCursor cur { body }; - cur.skip_ws_and_comments(); - - while (!cur.eof()) { - cur.skip_ws_and_comments(); - if (cur.eof()) break; - auto key = cur.read_key(); - if (key.empty()) { - cur.skip_ws_and_comments(); - if (cur.eof()) break; - ++cur.pos; // unknown char — advance and retry - continue; - } - cur.skip_ws_and_comments(); - if (!cur.consume('=')) { - return std::unexpected(ManifestError{ - std::format("malformed mcpp segment near key '{}'", key), - m.sourcePath, 0, 0}); - } - cur.skip_ws_and_comments(); - - if (key == "language") { - auto v = cur.read_string(); - if (!v.empty()) { - m.language.standard = v; - m.package.standard = v; - } - } - else if (key == "import_std") { - auto v = cur.read_bareword(); - m.language.importStd = (v == "true"); - } - else if (key == "modules") { - // `{ "a", "b", ... }` - if (!cur.consume('{')) { - return std::unexpected(ManifestError{ - "expected '{' after `modules =`", m.sourcePath, 0, 0}); - } - cur.skip_ws_and_comments(); - while (!cur.eof() && cur.peek() != '}') { - auto s = cur.read_string(); - if (!s.empty()) m.modules.exports_.push_back(std::move(s)); - cur.skip_ws_and_comments(); - } - cur.consume('}'); - } - else if (key == "sources") { - if (!cur.consume('{')) { - return std::unexpected(ManifestError{ - "expected '{' after `sources =`", m.sourcePath, 0, 0}); - } - cur.skip_ws_and_comments(); - while (!cur.eof() && cur.peek() != '}') { - auto s = cur.read_string(); - if (!s.empty()) { - m.modules.sources.push_back(s); - m.buildConfig.sources.push_back(std::move(s)); // M5.0 mirror - } - cur.skip_ws_and_comments(); - } - cur.consume('}'); - } - else if (key == "include_dirs") { - // M5.0: shipped headers exposed to dependents AND used by this - // package's own compile (mcpp's symmetric include_dirs semantics). - if (!cur.consume('{')) { - return std::unexpected(ManifestError{ - "expected '{' after `include_dirs =`", m.sourcePath, 0, 0}); - } - cur.skip_ws_and_comments(); - while (!cur.eof() && cur.peek() != '}') { - auto s = cur.read_string(); - if (!s.empty()) m.buildConfig.includeDirs.emplace_back(s); - cur.skip_ws_and_comments(); - } - cur.consume('}'); - } - else if (key == "provides") { - // Package-level capabilities (Feature System v2 S3): this package - // satisfies the listed abstract capability names for any dependent - // that `requires` them. `{ "blas", "lapack", ... }`. - if (!cur.consume('{')) { - return std::unexpected(ManifestError{ - "expected '{' after `provides =`", m.sourcePath, 0, 0}); - } - cur.skip_ws_and_comments(); - while (!cur.eof() && cur.peek() != '}') { - auto s = cur.read_string(); - if (!s.empty()) m.provides.push_back(std::move(s)); - cur.skip_ws_and_comments(); - } - cur.consume('}'); - } - else if (key == "generated_files") { - // `{ ["relative/path"] = "contents", ... }` - if (!cur.consume('{')) { - return std::unexpected(ManifestError{ - "expected '{' after `generated_files =`", m.sourcePath, 0, 0}); - } - cur.skip_ws_and_comments(); - while (!cur.eof() && cur.peek() != '}') { - auto path = cur.read_key(); - if (path.empty()) break; - cur.skip_ws_and_comments(); - if (!cur.consume('=')) { - return std::unexpected(ManifestError{ - "expected '=' in `generated_files` entry", m.sourcePath, 0, 0}); - } - auto content = cur.read_string(); - m.buildConfig.generatedFiles.emplace(path, std::move(content)); - cur.skip_ws_and_comments(); - } - cur.consume('}'); - } - else if (key == "targets") { - // `{ ["name"] = { kind = "lib" }, ... }` - if (!cur.consume('{')) { - return std::unexpected(ManifestError{ - "expected '{' after `targets =`", m.sourcePath, 0, 0}); - } - cur.skip_ws_and_comments(); - while (!cur.eof() && cur.peek() != '}') { - auto tname = cur.read_key(); - if (tname.empty()) { cur.skip_ws_and_comments(); break; } - cur.skip_ws_and_comments(); - if (!cur.consume('=')) break; - cur.skip_ws_and_comments(); - if (!cur.consume('{')) break; - - Target t; - t.name = tname; - t.kind = Target::Library; // default - cur.skip_ws_and_comments(); - while (!cur.eof() && cur.peek() != '}') { - auto sub = cur.read_key(); - cur.skip_ws_and_comments(); - if (!cur.consume('=')) break; - cur.skip_ws_and_comments(); - if (sub == "kind") { - auto k = cur.read_string(); - if (k == "lib" || k == "library") t.kind = Target::Library; - else if (k == "bin" || k == "binary") t.kind = Target::Binary; - else if (k == "shared" || k == "dylib" - || k == "so" || k == "shlib") t.kind = Target::SharedLibrary; - } else if (sub == "main") { - t.main = cur.read_string(); - } else if (sub == "soname") { - t.soname = cur.read_string(); - } else { - // unknown subfield — skip its value - cur.skip_ws_and_comments(); - if (cur.peek() == '{') cur.skip_table(); - else (void)cur.read_bareword(); - } - cur.skip_ws_and_comments(); - } - cur.consume('}'); - if (auto msg = validate_target_soname(t, std::format("targets.{}.", tname))) { - return std::unexpected(ManifestError{*msg, m.sourcePath, 0, 0}); - } - m.targets.push_back(std::move(t)); - cur.skip_ws_and_comments(); - } - cur.consume('}'); - } - else if (key == "features") { - // `{ ["main"] = { sources = { "*/gtest_main.cc" } }, ... }` - // Registers the feature (so it's a known feature) and, when it - // carries `sources`, records them as feature-gated source globs - // (excluded by default; included only when the feature is active — - // resolved in prepare_build). A feature with no `sources` is still - // registered (empty implied set) so it can be requested/validated. - if (!cur.consume('{')) { - return std::unexpected(ManifestError{ - "expected '{' after `features =`", m.sourcePath, 0, 0}); - } - cur.skip_ws_and_comments(); - while (!cur.eof() && cur.peek() != '}') { - auto fname = cur.read_key(); - if (fname.empty()) { cur.skip_ws_and_comments(); break; } - cur.skip_ws_and_comments(); - if (!cur.consume('=')) break; - cur.skip_ws_and_comments(); - if (!cur.consume('{')) break; - // register the feature (no implied features for now) - m.featuresMap.try_emplace(fname, std::vector{}); - cur.skip_ws_and_comments(); - while (!cur.eof() && cur.peek() != '}') { - auto sub = cur.read_key(); - cur.skip_ws_and_comments(); - if (!cur.consume('=')) break; - cur.skip_ws_and_comments(); - if (sub == "deps" && cur.peek() == '{') { - // Feature-activated optional deps (Stage 2a): - // deps = { ["compat.openblas"] = "0.3.x", ... } - // Same flat/dotted form as the top-level `deps` table. - cur.consume('{'); - cur.skip_ws_and_comments(); - while (!cur.eof() && cur.peek() != '}') { - auto dname = cur.read_key(); - if (dname.empty()) break; - cur.skip_ws_and_comments(); - if (!cur.consume('=')) break; - cur.skip_ws_and_comments(); - auto dver = cur.read_string(); - DependencySpec spec; - spec.version = dver; - auto selector = mcpp::pm::resolve_dependency_selector( - dname, - mcpp::pm::DependencySelectorMode::OmittedMcpplibsPriority); - if (!selector.candidates.empty()) { - spec.namespace_ = selector.candidates.front().namespace_; - spec.shortName = selector.candidates.front().shortName; - spec.candidates = std::move(selector.candidates); - m.featureDeps[fname][selector.stableMapKey] = std::move(spec); - } - cur.skip_ws_and_comments(); - } - cur.consume('}'); - } else { - // Feature subfields that carry a string array. `sources` - // gates source globs; `defines` carries package-owned macros - // (Stage 1); `requires`/`provides` declare capabilities - // (Stage 3). All share the `{ "...", ... }` shape. - std::vector* arr = - sub == "implies" ? &m.featuresMap[fname] - : sub == "sources" ? &m.buildConfig.featureSources[fname] - : sub == "defines" ? &m.buildConfig.featureDefines[fname] - : sub == "requires" ? &m.featureRequires[fname] - : sub == "provides" ? &m.featureProvides[fname] - : nullptr; - if (arr && cur.peek() == '{') { - cur.consume('{'); - cur.skip_ws_and_comments(); - while (!cur.eof() && cur.peek() != '}') { - auto s = cur.read_string(); - if (!s.empty()) arr->push_back(std::move(s)); - cur.skip_ws_and_comments(); - } - cur.consume('}'); - } else { - // unknown subfield — skip its value - if (cur.peek() == '{') cur.skip_table(); - else (void)cur.read_bareword(); - } - } - cur.skip_ws_and_comments(); - } - cur.consume('}'); - cur.skip_ws_and_comments(); - } - cur.consume('}'); - } - else if (key == "deps") { - // `{ ["name"] = "version", ["ns.name"] = "version", ... }` - // The mcpp segment uses the flat / dotted form only — namespaced - // subtables would require a richer Lua parser than we have here, - // and the same expressivity is reachable by writing - // ["mcpplibs.cmdline"] = "0.0.2" - // which the consumer side accepts identically. - if (!cur.consume('{')) { - return std::unexpected(ManifestError{ - "expected '{' after `deps =`", m.sourcePath, 0, 0}); - } - cur.skip_ws_and_comments(); - while (!cur.eof() && cur.peek() != '}') { - auto dname = cur.read_key(); - if (dname.empty()) break; - cur.skip_ws_and_comments(); - if (!cur.consume('=')) break; - cur.skip_ws_and_comments(); - auto dver = cur.read_string(); - if (!dname.empty()) { - DependencySpec spec; - spec.version = dver; - auto selector = mcpp::pm::resolve_dependency_selector( - dname, - mcpp::pm::DependencySelectorMode::OmittedMcpplibsPriority); - if (!selector.candidates.empty()) { - spec.namespace_ = selector.candidates.front().namespace_; - spec.shortName = selector.candidates.front().shortName; - spec.candidates = std::move(selector.candidates); - m.dependencies[selector.stableMapKey] = std::move(spec); - } - } - cur.skip_ws_and_comments(); - } - cur.consume('}'); - } - else if (key == "cflags" || key == "cxxflags" || key == "ldflags") { - // `{ "-Dfoo", "-Wall", ... }` — appended to the per-rule baseline - // by ninja_backend. cflags goes to the C rule (.c files), cxxflags - // to C++ rule (.cpp/.cc/.cxx/.cppm), ldflags to link commands. - if (!cur.consume('{')) { - return std::unexpected(ManifestError{ - std::format("expected '{{' after `{} =`", key), - m.sourcePath, 0, 0}); - } - cur.skip_ws_and_comments(); - auto& target = (key == "cflags") - ? m.buildConfig.cflags - : (key == "cxxflags" ? m.buildConfig.cxxflags : m.buildConfig.ldflags); - while (!cur.eof() && cur.peek() != '}') { - auto s = cur.read_string(); - if (key == "cxxflags" && starts_with_std_flag(s)) { - return std::unexpected(ManifestError{ - std::format("cxxflags contains '{}'; use language/package standard to configure the C++ language standard", - s), - m.sourcePath, 0, 0}); - } - if (!s.empty()) target.push_back(std::move(s)); - cur.skip_ws_and_comments(); - } - cur.consume('}'); - } - else if (key == "c_standard") { - auto v = cur.read_string(); - if (!v.empty()) m.buildConfig.cStandard = v; - } - else if (key == "runtime") { - auto runtimeBody = cur.read_table_body(); - LuaCursor rc { runtimeBody }; - rc.skip_ws_and_comments(); - while (!rc.eof()) { - auto sub = rc.read_key(); - if (sub.empty()) { - rc.skip_ws_and_comments(); - if (rc.eof()) break; - ++rc.pos; - continue; - } - rc.skip_ws_and_comments(); - if (!rc.consume('=')) { - return std::unexpected(ManifestError{ - std::format("malformed runtime segment near key '{}'", sub), - m.sourcePath, 0, 0}); - } - rc.skip_ws_and_comments(); - auto read_string_list = [&](std::vector& out) - -> std::expected - { - if (!rc.consume('{')) { - return std::unexpected(ManifestError{ - std::format("expected '{{' after `runtime.{} =`", sub), - m.sourcePath, 0, 0}); - } - rc.skip_ws_and_comments(); - while (!rc.eof() && rc.peek() != '}') { - auto s = rc.read_string(); - if (!s.empty()) out.push_back(std::move(s)); - rc.skip_ws_and_comments(); - } - rc.consume('}'); - return {}; - }; - if (sub == "library_dirs") { - std::vector dirs; - if (auto r = read_string_list(dirs); !r) return std::unexpected(r.error()); - for (auto& d : dirs) m.runtimeConfig.libraryDirs.emplace_back(std::move(d)); - } else if (sub == "dlopen_libs") { - if (auto r = read_string_list(m.runtimeConfig.dlopenLibs); !r) - return std::unexpected(r.error()); - } else if (sub == "capabilities") { - if (auto r = read_string_list(m.runtimeConfig.capabilities); !r) - return std::unexpected(r.error()); - } else if (sub == "provides") { - if (auto r = read_string_list(m.runtimeConfig.provides); !r) - return std::unexpected(r.error()); - } else { - rc.skip_ws_and_comments(); - if (rc.peek() == '"' || rc.peek() == '\'') (void)rc.read_string(); - else if (rc.peek() == '{') rc.skip_table(); - else (void)rc.read_bareword(); - } - rc.skip_ws_and_comments(); - } - } - else { - // Unknown key — skip the value (string / bareword / table). - cur.skip_ws_and_comments(); - if (cur.peek() == '"' || cur.peek() == '\'') (void)cur.read_string(); - else if (cur.peek() == '{') cur.skip_table(); - else (void)cur.read_bareword(); - } - } - - // Validate minimum - if (m.modules.sources.empty()) { - return std::unexpected(ManifestError{ - "synthesised manifest missing sources (mcpp segment must declare `sources = { ... }`)", - m.sourcePath, 0, 0}); - } - if (m.targets.empty()) { - // Default to a library target with the same name as the package. - Target t; - t.name = m.package.name; - // For dotted names like mcpplibs.cmdline, take the last segment. - auto dot = t.name.find_last_of('.'); - if (dot != std::string::npos) t.name = t.name.substr(dot + 1); - t.kind = Target::Library; - m.targets.push_back(std::move(t)); - } - - auto stdCfg = normalize_cpp_standard(m.package.standard); - if (!stdCfg) { - return std::unexpected(ManifestError{stdCfg.error(), m.sourcePath, 0, 0}); - } - m.cppStandard = *stdCfg; - m.package.standard = m.cppStandard.canonical; - m.language.standard = m.cppStandard.canonical; - - return m; -} - -std::string default_template(std::string_view packageName) { - // M5.0: minimal mcpp.toml — convention over configuration. - // sources / target / standard are all auto-inferred. Users add fields as - // they grow out of the defaults. - return std::format(R"([package] -name = "{}" -version = "0.1.0" -description = "A modular C++23 package" -license = "Apache-2.0" -)", packageName); -} - -bool has_lib_target(const Manifest& manifest) { - for (auto& t : manifest.targets) { - if (t.kind == Target::Library || t.kind == Target::SharedLibrary) { - return true; - } - } - return false; -} - -std::filesystem::path resolve_lib_root_path(const Manifest& manifest) { - if (!manifest.lib.path.empty()) { - return manifest.lib.path; - } - // Convention: src/.cppm - std::string tail = manifest.package.name; - if (auto p = tail.rfind('.'); p != std::string::npos) { - tail = tail.substr(p + 1); - } - return std::filesystem::path("src") / (tail + ".cppm"); -} - -} // namespace mcpp::manifest diff --git a/src/manifest/manifest.cppm b/src/manifest/manifest.cppm new file mode 100644 index 0000000..2960f3e --- /dev/null +++ b/src/manifest/manifest.cppm @@ -0,0 +1,18 @@ +// mcpp.manifest — manifest data model + the two descriptor formats. +// +// Umbrella module: one data model, two independent surface grammars. +// Split as SEPARATE modules (not partitions): GCC 15's partition handling +// drops implicit template instantiations of module-attached types +// (std::map<..., ScanOverride> etc.) from partition objects, breaking the +// aarch64 cross link; plain modules re-exported here behave identically +// for importers and link everywhere. +// +// mcpp.manifest.types shared data model (Manifest, Target, errors) +// mcpp.manifest.toml mcpp.toml parsing (projects / packages on disk) +// mcpp.manifest.xpkg xpkg .lua `mcpp = {}` segment (index descriptors) + +export module mcpp.manifest; + +export import mcpp.manifest.types; +export import mcpp.manifest.toml; +export import mcpp.manifest.xpkg; diff --git a/src/manifest/toml.cppm b/src/manifest/toml.cppm new file mode 100644 index 0000000..747bdc7 --- /dev/null +++ b/src/manifest/toml.cppm @@ -0,0 +1,961 @@ +// mcpp.manifest:toml — load and validate mcpp.toml. + +export module mcpp.manifest.toml; + +import mcpp.manifest.types; +import std; +import mcpp.libs.toml; +import mcpp.pm.dep_spec; +import mcpp.pm.compat; +import mcpp.pm.dependency_selector; +import mcpp.pm.index_spec; +import mcpp.platform; + +export namespace mcpp::manifest { + +std::expected parse_string(std::string_view content, + const std::filesystem::path& origin = "mcpp.toml"); +std::expected load(const std::filesystem::path& path); + +// For `mcpp new` scaffolding. +std::string default_template(std::string_view packageName); + +} // namespace mcpp::manifest + +namespace mcpp::manifest { + +namespace t = mcpp::libs::toml; + +namespace { + +ManifestError error(const std::filesystem::path& origin, + const std::string& msg, + t::Position pos = {0, 0}) { + return ManifestError{msg, origin, pos.line, pos.column}; +} + +} // namespace + +std::expected parse_string(std::string_view content, + const std::filesystem::path& origin) { + auto doc = t::parse(content); + if (!doc) { + return std::unexpected(error(origin, doc.error().message, doc.error().where)); + } + + Manifest m; + m.sourcePath = origin; + + // [package] — required unless [workspace] is present (virtual workspace). + auto* pkg_t = doc->get_table("package"); + bool has_workspace = (doc->get_table("workspace") != nullptr); + if (!pkg_t && !has_workspace) + return std::unexpected(error(origin, "missing required [package] section")); + + auto name = doc->get_string("package.name"); + if (!name && !has_workspace) + return std::unexpected(error(origin, "missing required field 'package.name'")); + if (name) m.package.name = *name; + + // 0.0.6+: explicit namespace field (xpkg V1 style). + // If present, [package].name is the short name. + // If absent, compat.cppm::resolve_package_name infers from dotted name. + if (auto v = doc->get_string("package.namespace")) m.package.namespace_ = *v; + + auto version = doc->get_string("package.version"); + if (!version && !has_workspace) + return std::unexpected(error(origin, "missing required field 'package.version'")); + if (version) m.package.version = *version; + + if (auto v = doc->get_string("package.description")) m.package.description = *v; + if (auto v = doc->get_string("package.license")) m.package.license = *v; + if (auto v = doc->get_string("package.repo")) m.package.repo = *v; + if (auto v = doc->get_string_array("package.authors")) m.package.authors = *v; + if (auto v = doc->get_string_array("package.platforms")) m.package.platforms = *v; + + // [package].standard (M5.0 new home) + if (auto v = doc->get_string("package.standard")) m.package.standard = *v; + + // [language] (M5.0: deprecated, kept for backward compat — drop in M6) + // Reads to old fields AND mirrors to new package.standard if [package].standard not set. + bool had_language_section = (doc->get_table("language") != nullptr); + if (auto v = doc->get_string("language.standard")) { + m.language.standard = *v; + // mirror to new home only if [package].standard wasn't explicitly set + if (!doc->get_string("package.standard")) m.package.standard = *v; + } else { + m.language.standard = m.package.standard; // keep old field consistent with new + } + if (auto v = doc->get_bool("language.modules")) m.language.modules = *v; + if (auto v = doc->get_bool("language.import_std")) m.language.importStd = *v; + + // Validation on the unified standard. Store the canonical spelling so all + // downstream build surfaces consume one active value. + auto stdCfg = normalize_cpp_standard(m.package.standard); + if (!stdCfg) return std::unexpected(error(origin, stdCfg.error())); + m.cppStandard = *stdCfg; + m.package.standard = m.cppStandard.canonical; + m.language.standard = m.cppStandard.canonical; + if (had_language_section && !m.language.modules) { + return std::unexpected(error(origin, + "language.modules must be true (mcpp is modules-only)")); + } + + // [build].sources (M5.0 new home) + [modules].sources (deprecated, compat) + if (auto v = doc->get_string_array("build.sources")) m.buildConfig.sources = *v; + if (auto v = doc->get_string_array("modules.sources")) { + m.modules.sources = *v; + // If [build].sources wasn't set, mirror legacy field into new field. + if (m.buildConfig.sources.empty()) m.buildConfig.sources = *v; + } + // Mirror new → legacy so existing code reading manifest.modules.sources keeps working. + if (m.modules.sources.empty()) m.modules.sources = m.buildConfig.sources; + + if (auto v = doc->get_string_array("modules.exports")) m.modules.exports_ = *v; + if (auto v = doc->get_bool("modules.strict")) m.modules.strict = *v; + + // [build].include_dirs (M5.0 new field) + if (auto v = doc->get_string_array("build.include_dirs")) { + for (auto& s : *v) m.buildConfig.includeDirs.emplace_back(s); + } + + // [targets.*] — M5.0: now optional. If absent, defer to auto-inference (in load()). + // [profile.] — bundled build settings. + if (auto* profile_table = doc->get_table("profile"); + profile_table && !profile_table->empty()) { + for (auto& [pname, pval] : *profile_table) { + if (!pval.is_table()) continue; + auto& tt = pval.as_table(); + Profile pr; + if (auto it = tt.find("opt"); it != tt.end()) { + if (it->second.is_string()) pr.optLevel = it->second.as_string(); + else if (it->second.is_int()) pr.optLevel = std::to_string(it->second.as_int()); + } + if (auto it = tt.find("debug"); it != tt.end() && it->second.is_bool()) pr.debug = it->second.as_bool(); + if (auto it = tt.find("lto"); it != tt.end() && it->second.is_bool()) pr.lto = it->second.as_bool(); + if (auto it = tt.find("strip"); it != tt.end() && it->second.is_bool()) pr.strip = it->second.as_bool(); + auto read_list = [&](const char* key, std::vector& out) { + if (auto it = tt.find(key); it != tt.end() && it->second.is_array()) + for (auto& v : it->second.as_array()) + if (v.is_string()) out.push_back(v.as_string()); + }; + read_list("cflags", pr.cflags); + read_list("cxxflags", pr.cxxflags); + read_list("ldflags", pr.ldflags); + m.profiles[pname] = pr; + } + } + + // [features] — feature name → implied features. "default" lists the + // default-active set. Two accepted shapes (Feature System v2): + // array form (shorthand): name = ["implied", ...] + // table form (full): name = { implies = [...], defines = [...] } + // The table form lets a feature contribute package-owned defines (Stage 1); + // `requires`/`provides`/`deps` keys are reserved for later stages. + if (auto* features_table = doc->get_table("features"); + features_table && !features_table->empty()) { + auto read_str_array = [](const auto& tbl, std::string_view key, + std::vector& out) { + if (auto it = tbl.find(std::string(key)); + it != tbl.end() && it->second.is_array()) + for (auto& v : it->second.as_array()) + if (v.is_string()) out.push_back(v.as_string()); + }; + for (auto& [fname, fval] : *features_table) { + std::vector implied; + if (fval.is_array()) { + for (auto& v : fval.as_array()) + if (v.is_string()) implied.push_back(v.as_string()); + } else if (fval.is_table()) { + auto& ft = fval.as_table(); + read_str_array(ft, "implies", implied); + std::vector defs; + read_str_array(ft, "defines", defs); + if (!defs.empty()) m.buildConfig.featureDefines[fname] = std::move(defs); + std::vector reqs, provs; + read_str_array(ft, "requires", reqs); + read_str_array(ft, "provides", provs); + if (!reqs.empty()) m.featureRequires[fname] = std::move(reqs); + if (!provs.empty()) m.featureProvides[fname] = std::move(provs); + } + m.featuresMap[fname] = std::move(implied); + } + } + + // [package] provides — package-level capabilities (Feature System v2 S3). + if (auto v = doc->get_string_array("package.provides")) m.provides = *v; + + // [capabilities] cap = "provider" — root-only provider pins. + if (auto* caps = doc->get_table("capabilities"); caps && !caps->empty()) { + for (auto& [cap, cval] : *caps) + if (cval.is_string()) m.capabilityPins[cap] = cval.as_string(); + } + + // [scan_overrides.""] — author-asserted scan results (see + // manifest:types ScanOverride). provides/imports are string arrays. + if (auto* so_table = doc->get_table("scan_overrides"); + so_table && !so_table->empty()) { + for (auto& [glob, val] : *so_table) { + if (!val.is_table()) { + return std::unexpected(error(origin, + std::format("[scan_overrides.\"{}\"] must be a table", glob))); + } + manifest::ScanOverride ov; + auto& st = val.as_table(); + auto read_names = [&](const char* key, std::vector& out) + -> std::optional { + auto it = st.find(key); + if (it == st.end()) return std::nullopt; + if (!it->second.is_array()) + return std::format("scan_overrides.\"{}\".{} must be an array", glob, key); + for (auto& v : it->second.as_array()) { + if (!v.is_string() || v.as_string().empty()) + return std::format("scan_overrides.\"{}\".{} entries must be non-empty strings", glob, key); + out.push_back(v.as_string()); + } + return std::nullopt; + }; + if (auto msg = read_names("provides", ov.provides)) + return std::unexpected(error(origin, *msg)); + if (auto msg = read_names("imports", ov.imports)) + return std::unexpected(error(origin, *msg)); + if (ov.provides.empty() && ov.imports.empty()) { + return std::unexpected(error(origin, std::format( + "scan_overrides.\"{}\" declares neither provides nor imports", glob))); + } + m.modules.scanOverrides.emplace(glob, std::move(ov)); + } + } + + auto* targets_table = doc->get_table("targets"); + if (targets_table && !targets_table->empty()) { + for (auto& [tname, tval] : *targets_table) { + if (!tval.is_table()) { + return std::unexpected(error(origin, + std::format("[targets.{}] must be a table", tname))); + } + Target t; + t.name = tname; + auto& tt = tval.as_table(); + + auto kit = tt.find("kind"); + if (kit == tt.end() || !kit->second.is_string()) { + return std::unexpected(error(origin, + std::format("targets.{}.kind missing or not a string", tname))); + } + const auto& kind_s = kit->second.as_string(); + if (kind_s == "lib" || kind_s == "library") t.kind = Target::Library; + else if (kind_s == "bin" || kind_s == "binary") t.kind = Target::Binary; + else if (kind_s == "shared" || kind_s == "dylib" + || kind_s == "so" || kind_s == "shlib") t.kind = Target::SharedLibrary; + else return std::unexpected(error(origin, + std::format("targets.{}.kind must be 'bin', 'lib' or 'shared'; got '{}'", tname, kind_s))); + + if (t.kind == Target::Binary) { + auto mit = tt.find("main"); + if (mit == tt.end() || !mit->second.is_string()) { + return std::unexpected(error(origin, + std::format("targets.{} (kind=bin) requires 'main' field", tname))); + } + t.main = mit->second.as_string(); + } + if (auto sit = tt.find("soname"); sit != tt.end()) { + if (!sit->second.is_string()) { + return std::unexpected(error(origin, + std::format("targets.{}.soname must be a string", tname))); + } + t.soname = sit->second.as_string(); + } + if (auto msg = validate_target_soname(t, std::format("targets.{}.", tname))) { + return std::unexpected(error(origin, *msg)); + } + + // Per-target flags (entry-scoped) + required-features gate. + auto read_list = [&](const char* key, std::vector& out) { + if (auto it = tt.find(key); it != tt.end() && it->second.is_array()) + for (auto& v : it->second.as_array()) + if (v.is_string()) out.push_back(v.as_string()); + }; + read_list("cflags", t.cflags); + read_list("cxxflags", t.cxxflags); + read_list("defines", t.defines); + read_list("required_features", t.requiredFeatures); + // Guard: -std=... belongs to [package].standard, not per-target flags + // (same rule as [build].cxxflags). Reject early with a clear message. + for (auto const& flag : t.cxxflags) { + if (starts_with_std_flag(flag)) { + return std::unexpected(error(origin, std::format( + "targets.{}.cxxflags contains '{}'; use [package].standard to " + "configure the C++ language standard", tname, flag))); + } + } + + // Surface unsupported keys instead of silently dropping them — the + // historic footgun behind issue #131 (a `[targets.x] cxxflags` typo on + // an older mcpp just vanished). Per-target arbitrary build config that + // must reach SHARED code is intentionally not a target key; point users + // at the right axis (workspace / features / profile). + static constexpr std::string_view kKnownTargetKeys[] = { + "kind", "main", "soname", + "cflags", "cxxflags", "defines", "required_features", + }; + for (auto& [key, _] : tt) { + bool known = false; + for (auto k : kKnownTargetKeys) if (key == k) { known = true; break; } + if (!known) { + m.schemaWarnings.push_back(std::format( + "[targets.{}] has unsupported key '{}' (ignored). Per-target keys: " + "kind, main, soname, cflags, cxxflags, defines, required_features. " + "For config that must affect shared code, split into a workspace " + "member or use [features]; for a whole-build mode use [profile.*].", + tname, key)); + } + } + m.targets.push_back(std::move(t)); + } + } // close `if (targets_table && !targets_table->empty())` + + // [dependencies] / [dev-dependencies] + // + // Three accepted forms (M5.x): + // + // (1) flat / default-ns + // [dependencies] + // gtest = "1.15.2" ⇒ (mcpp, gtest) + // frob = { path = "..." } ⇒ (mcpp, frob) inline spec + // + // (2) namespaced subtable (TOML-native, no quotes) + // [dependencies.mcpplibs] + // cmdline = "0.0.2" ⇒ (mcpplibs, cmdline) + // tmpl = { version = "0.0.1", features = [...] } + // + // (3) legacy quoted dotted form (deprecated, still parsed) + // [dependencies] + // "mcpplibs.cmdline" = "0.0.2" ⇒ (mcpplibs, cmdline) + warning + // + // The map key remains the fully-qualified `.` for non-default + // namespaces (so existing fetcher / lockfile lookups by composite name + // keep working) and the bare `` for the default namespace (so the + // common case stays unchanged). + auto is_dep_spec_key = [](std::string_view k) { + return k == "path" || k == "version" || k == "git" + || k == "rev" || k == "tag" || k == "branch" + || k == "features" || k == "workspace" || k == "visibility" + || k == "backend"; + }; + auto looks_like_inline_dep_spec = [&](const t::Table& sub) { + if (sub.empty()) return false; + for (auto& [sk, sv] : sub) { + if (!is_dep_spec_key(sk)) return false; + } + return true; + }; + + auto fill_inline_spec = [&](DependencySpec& spec, + std::string_view section, + std::string_view fqName, + const t::Table& sub) -> std::expected + { + if (auto it = sub.find("path"); it != sub.end() && it->second.is_string()) spec.path = it->second.as_string(); + if (auto it = sub.find("version"); it != sub.end() && it->second.is_string()) spec.version = it->second.as_string(); + if (auto it = sub.find("git"); it != sub.end() && it->second.is_string()) spec.git = it->second.as_string(); + if (auto it = sub.find("visibility"); it != sub.end() && it->second.is_string()) { + spec.visibility = it->second.as_string(); + if (spec.visibility != "public" + && spec.visibility != "private" + && spec.visibility != "interface") { + return std::unexpected(error(origin, std::format( + "[{}.\"{}\"] visibility must be 'public', 'private', or 'interface'", + section, fqName))); + } + } + if (auto it = sub.find("features"); it != sub.end() && it->second.is_array()) { + for (auto& fv : it->second.as_array()) + if (fv.is_string()) spec.features.push_back(fv.as_string()); + } + // `backend = ""` — sugar for requesting the dependency's + // `backend-` feature (library-level backend selection knob). + if (auto it = sub.find("backend"); it != sub.end() && it->second.is_string()) { + spec.features.push_back("backend-" + it->second.as_string()); + } + if (auto it = sub.find("rev"); it != sub.end() && it->second.is_string()) { + spec.gitRev = it->second.as_string(); + spec.gitRefKind = "rev"; + } else if (auto it = sub.find("tag"); it != sub.end() && it->second.is_string()) { + spec.gitRev = it->second.as_string(); + spec.gitRefKind = "tag"; + } else if (auto it = sub.find("branch"); it != sub.end() && it->second.is_string()) { + spec.gitRev = it->second.as_string(); + spec.gitRefKind = "branch"; + } + if (auto it = sub.find("workspace"); it != sub.end() && it->second.is_bool() && it->second.as_bool()) { + spec.inheritWorkspace = true; + return {}; // version will be filled in by workspace merge + } + if (spec.path.empty() && spec.version.empty() && spec.git.empty()) { + return std::unexpected(error(origin, std::format( + "[{}.\"{}\"] must specify 'path', 'version', or 'git'", section, fqName))); + } + if (!spec.git.empty() && spec.gitRev.empty()) { + return std::unexpected(error(origin, std::format( + "[{}.\"{}\"] git dep requires one of: rev / tag / branch", section, fqName))); + } + return {}; + }; + + auto assign_dep = [&](std::string_view section, + std::map& out, + const mcpp::pm::DependencySelector& selector, + const t::Value& value, + bool legacyDottedKey) + -> std::expected + { + if (selector.candidates.empty()) { + return std::unexpected(error(origin, std::format( + "[{}] dependency selector '{}' has no candidates", + section, selector.stableMapKey))); + } + + DependencySpec spec; + spec.namespace_ = selector.candidates.front().namespace_; + spec.shortName = selector.candidates.front().shortName; + spec.candidates = selector.candidates; + spec.legacyDottedKey = legacyDottedKey; + + auto key = selector.stableMapKey; + if (value.is_string()) { + spec.version = value.as_string(); + } else if (value.is_table()) { + auto& sub = value.as_table(); + if (!looks_like_inline_dep_spec(sub)) { + return std::unexpected(error(origin, std::format( + "[{}.{}] must be a version string or table of " + "(path/version/git/rev/tag/branch/features/visibility)", + section, key))); + } + if (auto r = fill_inline_spec(spec, section, key, sub); !r) return r; + } else { + return std::unexpected(error(origin, std::format( + "[{}].{} must be a string (version) or table (path/version/...)", + section, key))); + } + + out[key] = std::move(spec); + return {}; + }; + + auto is_namespace_table = [&](std::string_view section, + std::string_view key) { + auto path = std::format("{}.{}", section, key); + return doc->has_explicit_table(path) + || key == kDefaultNamespace; + }; + + std::function( + std::string_view, + std::map&, + std::string, + std::string, + const t::Table&)> load_nested_dep_table; + + load_nested_dep_table = + [&](std::string_view section, + std::map& out, + std::string ns, + std::string mapPrefix, + const t::Table& table) -> std::expected + { + for (auto& [k, v] : table) { + if (v.is_string() || + (v.is_table() && looks_like_inline_dep_spec(v.as_table()))) { + auto mapKey = mapPrefix.empty() + ? k + : std::format("{}.{}", mapPrefix, k); + auto selector = mcpp::pm::make_direct_dependency_selector( + ns, k, mapKey); + if (auto r = assign_dep(section, out, selector, v, false); !r) + return r; + continue; + } + if (!v.is_table()) { + return std::unexpected(error(origin, std::format( + "[{}].{}.{} must be a string, inline dep table, or nested table", + section, ns, k))); + } + auto childNs = std::format("{}.{}", ns, k); + auto childMapPrefix = mapPrefix.empty() + ? k + : std::format("{}.{}", mapPrefix, k); + if (auto r = load_nested_dep_table( + section, out, childNs, childMapPrefix, v.as_table()); !r) + return r; + } + return {}; + }; + + std::function( + std::string_view, + std::map&, + std::string, + const t::Table&)> load_selector_dep_table; + + load_selector_dep_table = + [&](std::string_view section, + std::map& out, + std::string selectorPrefix, + const t::Table& table) -> std::expected + { + for (auto& [k, v] : table) { + auto selectorText = selectorPrefix.empty() + ? k + : std::format("{}.{}", selectorPrefix, k); + if (v.is_string() || + (v.is_table() && looks_like_inline_dep_spec(v.as_table()))) { + auto selector = mcpp::pm::resolve_dependency_selector( + selectorText, + mcpp::pm::DependencySelectorMode::OmittedMcpplibsPriority); + if (auto r = assign_dep(section, out, selector, v, false); !r) + return r; + continue; + } + if (!v.is_table()) { + return std::unexpected(error(origin, std::format( + "[{}].{} must be a string, inline dep table, or nested table", + section, selectorText))); + } + if (auto r = load_selector_dep_table( + section, out, selectorText, v.as_table()); !r) + return r; + } + return {}; + }; + + // Parse a dependency table (already obtained) into `out`. Factored out of + // load_deps so the same logic serves both [dependencies] (via doc->get_table) + // and [target.'cfg(...)'.dependencies] (a nested table the dotted getter + // can't address). `section` is the logical section name, used for error + // messages and namespace/selector resolution. + auto load_deps_table = [&](std::string_view section, auto& tt, + std::map& out) + -> std::expected + { + for (auto& [k, v] : tt) { + // (1) string value → flat default-ns short version, or + // (3) legacy "ns.name" = "ver" (dotted key). + if (v.is_string()) { + if (k.find('.') != std::string::npos) { + auto legacyKey = mcpp::pm::compat::split_legacy_dependency_key(k); + auto selector = mcpp::pm::make_direct_dependency_selector( + legacyKey.namespace_, legacyKey.shortName, k); + if (auto r = assign_dep(section, out, selector, v, + legacyKey.legacyDottedKey); !r) + return r; + continue; + } + auto selector = mcpp::pm::resolve_dependency_selector( + k, mcpp::pm::DependencySelectorMode::OmittedMcpplibsPriority); + if (auto r = assign_dep(section, out, selector, v, false); !r) + return r; + continue; + } + + if (!v.is_table()) { + return std::unexpected(error(origin, std::format( + "[{}].{} must be a string (version) or table (path/version/...)", section, k))); + } + + auto& sub = v.as_table(); + + // (1') inline dep spec under the default namespace, e.g. + // frob = { path = "..." } or + // "mcpplibs.cmdline" = { version = "0.0.2" } + // The latter is the legacy dotted-key form; same treatment as (3). + if (looks_like_inline_dep_spec(sub)) { + if (k.find('.') != std::string::npos) { + auto legacyKey = mcpp::pm::compat::split_legacy_dependency_key(k); + auto selector = mcpp::pm::make_direct_dependency_selector( + legacyKey.namespace_, legacyKey.shortName, k); + if (auto r = assign_dep(section, out, selector, v, + legacyKey.legacyDottedKey); !r) + return r; + continue; + } + auto selector = mcpp::pm::resolve_dependency_selector( + k, mcpp::pm::DependencySelectorMode::OmittedMcpplibsPriority); + if (auto r = assign_dep(section, out, selector, v, false); !r) + return r; + continue; + } + + // (2) namespaced or nested subtable. + // + // Explicit tables such as `[dependencies.acme]` are namespace + // roots. Dotted keys written inside the single dependency table, + // such as `[dependencies] capi.lua = "0.0.3"`, are ordered + // selectors: mcpplibs.capi/lua first, then capi/lua. + if (is_namespace_table(section, k)) { + if (auto r = load_nested_dep_table(section, out, k, k, sub); !r) + return r; + } else if (auto r = load_selector_dep_table(section, out, k, sub); !r) { + return r; + } + } + return {}; + }; + auto load_deps = [&](std::string_view section, std::map& out) + -> std::expected + { + auto* tt = doc->get_table(section); + if (!tt) return {}; + return load_deps_table(section, *tt, out); + }; + if (auto r = load_deps("dependencies", m.dependencies); !r) return std::unexpected(r.error()); + if (auto r = load_deps("dev-dependencies", m.devDependencies); !r) return std::unexpected(r.error()); + if (auto r = load_deps("build-dependencies", m.buildDependencies); !r) return std::unexpected(r.error()); + + // [feature-deps.] — optional dependencies activated by a feature + // (Stage 2a). Each sub-table is loaded with the same dependency loader as + // [dependencies], keyed by the feature name. + if (auto* fdeps = doc->get_table("feature-deps")) { + for (auto& [fname, fval] : *fdeps) { + if (!fval.is_table()) continue; + if (auto r = load_deps("feature-deps." + std::string(fname), + m.featureDeps[fname]); !r) + return std::unexpected(r.error()); + m.featuresMap.try_emplace(fname, std::vector{}); // register + } + } + + // [toolchain] — platform → "pkg@version" map (docs/21) + if (auto* tt = doc->get_table("toolchain")) { + for (auto& [platform, val] : *tt) { + if (!val.is_string()) { + return std::unexpected(error(origin, + std::format("[toolchain].{} must be a string like \"gcc@15.1.0\"", platform))); + } + m.toolchain.byPlatform[platform] = val.as_string(); + } + } + + // [build] — backend tunables + if (auto v = doc->get_bool("build.static_stdlib")) m.buildConfig.staticStdlib = *v; + if (auto v = doc->get_bool("build.allow_host_libs")) m.buildConfig.allowHostLibs = *v; + if (auto v = doc->get_string_array("build.cflags")) m.buildConfig.cflags = *v; + if (auto v = doc->get_string_array("build.cxxflags")) m.buildConfig.cxxflags = *v; + if (auto v = doc->get_string_array("build.ldflags")) m.buildConfig.ldflags = *v; + if (auto v = doc->get_string("build.c_standard")) m.buildConfig.cStandard = *v; + if (auto v = doc->get_string("build.default-profile")) m.buildConfig.defaultProfile = *v; + else if (auto v = doc->get_string("build.profile")) m.buildConfig.defaultProfile = *v; // accepted alias + + // [xlings] — build environment (L-1). Subsections mirror .xlings.json 1:1. + if (auto v = doc->get_string_array("xlings.deps")) m.xlings.deps = *v; + if (auto v = doc->get_string("xlings.subos")) m.xlings.subos = *v; + if (auto* wt = doc->get_table("xlings.workspace")) + for (auto& [k, val] : *wt) + if (val.is_string()) m.xlings.workspace[k] = val.as_string(); + if (auto* et = doc->get_table("xlings.envs")) + for (auto& [k, val] : *et) + if (val.is_string()) m.xlings.envs[k] = val.as_string(); + if (auto v = doc->get_string("build.macos_deployment_target")) + m.buildConfig.macosDeploymentTarget = *v; + for (auto const& flag : m.buildConfig.cxxflags) { + if (starts_with_std_flag(flag)) { + return std::unexpected(error(origin, + std::format("build.cxxflags contains '{}'; use [package].standard to configure the C++ language standard", + flag))); + } + } + + // [runtime] — launch-time requirements. + if (auto v = doc->get_string_array("runtime.library_dirs")) { + for (auto& s : *v) m.runtimeConfig.libraryDirs.emplace_back(s); + } + if (auto v = doc->get_string_array("runtime.dlopen_libs")) + m.runtimeConfig.dlopenLibs = *v; + if (auto v = doc->get_string_array("runtime.capabilities")) + m.runtimeConfig.capabilities = *v; + if (auto v = doc->get_string_array("runtime.provides")) + m.runtimeConfig.provides = *v; + // [runtime.] provider = "" — explicit provider override. + if (auto* rt = doc->get_table("runtime"); rt && !rt->empty()) { + for (auto& [rk, rv] : *rt) { + if (!rv.is_table()) continue; // flat keys handled above + auto& tt = rv.as_table(); + if (auto it = tt.find("provider"); it != tt.end() && it->second.is_string()) + m.runtimeConfig.providerOverrides[rk] = it->second.as_string(); + } + } + + // [lib] — library root convention (cargo-style). + if (auto v = doc->get_string("lib.path")) { + m.lib.path = *v; + } + + // [pack] — `mcpp pack` configuration. See docs/35-pack-design.md. + if (auto v = doc->get_string("pack.default_mode")) { + const auto& s = *v; + if (s != "static" && s != "bundle-project" && s != "bundle-all") { + return std::unexpected(error(origin, std::format( + "[pack].default_mode = '{}' invalid; expected " + "'static' | 'bundle-project' | 'bundle-all'", s))); + } + m.packConfig.defaultMode = s; + } + if (auto v = doc->get_string_array("pack.include")) + m.packConfig.include = *v; + if (auto v = doc->get_string_array("pack.exclude")) + m.packConfig.exclude = *v; + // [pack.bundle-project] sub-table for fine-grained PEP 600 overrides. + if (auto v = doc->get_string_array("pack.bundle-project.also_skip")) + m.packConfig.alsoSkip = *v; + if (auto v = doc->get_string_array("pack.bundle-project.force_bundle")) + m.packConfig.forceBundle = *v; + + // [target.] — per-target overrides. We accept both GCC + // (x86_64-linux-musl) and Rust-style (x86_64-unknown-linux-musl) + // triple forms; the latter is canonicalised by stripping the + // `-unknown-` segment so both keys map to the same entry. + auto canon_triple = [](std::string s) { + constexpr std::string_view kUnknown = "-unknown-"; + if (auto p = s.find(kUnknown); p != std::string::npos) + s.replace(p, kUnknown.size(), "-"); + return s; + }; + if (auto* tt = doc->get_table("target")) { + for (auto& [triple, val] : *tt) { + if (!val.is_table()) continue; + auto& body = val.as_table(); + TargetEntry e; + if (auto it = body.find("toolchain"); it != body.end() && it->second.is_string()) + e.toolchain = it->second.as_string(); + if (auto it = body.find("linkage"); it != body.end() && it->second.is_string()) { + e.linkage = it->second.as_string(); + if (e.linkage != "static" && e.linkage != "dynamic") { + return std::unexpected(error(origin, std::format( + "[target.{}].linkage = '{}' is invalid; expected 'static' or 'dynamic'", + triple, e.linkage))); + } + } + m.targetOverrides[canon_triple(triple)] = std::move(e); + + // [target..{build,dependencies,...}] — platform-conditional + // config (L1). `triple` is the predicate key (cfg(...) or a bare + // triple); stored deferred, evaluated against the resolved target in + // prepare_build. + ConditionalConfig cc; + cc.predicate = triple; + if (auto bit = body.find("build"); bit != body.end() && bit->second.is_table()) { + auto& bt = bit->second.as_table(); + auto read_list = [&](const char* key, std::vector& out) { + if (auto f = bt.find(key); f != bt.end() && f->second.is_array()) + for (auto& v : f->second.as_array()) + if (v.is_string()) out.push_back(v.as_string()); + }; + read_list("cflags", cc.cflags); + read_list("cxxflags", cc.cxxflags); + read_list("ldflags", cc.ldflags); + } + // [target..{dependencies,dev-dependencies,build-dependencies}] + // parsed via the shared table-based loader (same selectors/namespaces + // as the global [dependencies]) into the deferred config. + auto read_deps = [&](const char* key, std::map& out) + -> std::expected + { + if (auto f = body.find(key); f != body.end() && f->second.is_table()) + return load_deps_table(key, f->second.as_table(), out); + return {}; + }; + if (auto r = read_deps("dependencies", cc.dependencies); !r) return std::unexpected(r.error()); + if (auto r = read_deps("dev-dependencies", cc.devDependencies); !r) return std::unexpected(r.error()); + if (auto r = read_deps("build-dependencies", cc.buildDependencies); !r) return std::unexpected(r.error()); + if (!cc.cflags.empty() || !cc.cxxflags.empty() || !cc.ldflags.empty() + || !cc.dependencies.empty() || !cc.devDependencies.empty() + || !cc.buildDependencies.empty()) + m.conditionalConfigs.push_back(std::move(cc)); + } + } + + // [workspace] — multi-package workspace support (0.0.11+). + if (doc->get_table("workspace")) { + m.workspace.present = true; + if (auto v = doc->get_string_array("workspace.members")) + m.workspace.members = *v; + if (auto v = doc->get_string_array("workspace.exclude")) + m.workspace.exclude = *v; + + // [workspace.dependencies] — versions that members inherit via .workspace = true. + if (auto* wdeps = doc->get_table("workspace.dependencies")) { + for (auto& [k, v] : *wdeps) { + if (v.is_string()) { + if (k.find('.') != std::string::npos) { + auto depKey = mcpp::pm::compat::split_legacy_dependency_key(k); + auto selector = mcpp::pm::make_direct_dependency_selector( + depKey.namespace_, depKey.shortName, k); + if (auto r = assign_dep("workspace.dependencies", + m.workspace.dependencies, + selector, v, + depKey.legacyDottedKey); !r) { + return std::unexpected(r.error()); + } + continue; + } + auto selector = mcpp::pm::resolve_dependency_selector( + k, mcpp::pm::DependencySelectorMode::OmittedMcpplibsPriority); + if (auto r = assign_dep("workspace.dependencies", + m.workspace.dependencies, + selector, v, false); !r) { + return std::unexpected(r.error()); + } + continue; + } + if (!v.is_table()) continue; + if (is_namespace_table("workspace.dependencies", k)) { + if (auto r = load_nested_dep_table("workspace.dependencies", + m.workspace.dependencies, + k, k, v.as_table()); !r) { + return std::unexpected(r.error()); + } + } else { + if (auto r = load_selector_dep_table("workspace.dependencies", + m.workspace.dependencies, + k, v.as_table()); !r) { + return std::unexpected(r.error()); + } + } + } + } + } + + // [indices] — custom package index repositories. + // + // Accepted forms: + // acme = "git@gitlab.example.com:platform/mcpp-index.git" # short: value = url + // acme-stable = { url = "git@...", tag = "v2.0" } # long: inline table + // local-dev = { path = "/my-packages" } # local path + // mcpplibs = { url = "https://...", rev = "abc123" } # pin built-in + if (auto* indices_t = doc->get_table("indices")) { + for (auto& [k, v] : *indices_t) { + mcpp::pm::IndexSpec spec; + spec.name = k; + + if (v.is_string()) { + // Short form: key = "url" + spec.url = v.as_string(); + } else if (v.is_table()) { + auto& sub = v.as_table(); + if (auto it = sub.find("url"); it != sub.end() && it->second.is_string()) spec.url = it->second.as_string(); + if (auto it = sub.find("rev"); it != sub.end() && it->second.is_string()) spec.rev = it->second.as_string(); + if (auto it = sub.find("tag"); it != sub.end() && it->second.is_string()) spec.tag = it->second.as_string(); + if (auto it = sub.find("branch"); it != sub.end() && it->second.is_string()) spec.branch = it->second.as_string(); + if (auto it = sub.find("path"); it != sub.end() && it->second.is_string()) spec.path = it->second.as_string(); + if (spec.url.empty() && spec.path.empty()) { + return std::unexpected(error(origin, std::format( + "[indices].{} must specify 'url' or 'path'", k))); + } + } else { + return std::unexpected(error(origin, std::format( + "[indices].{} must be a string (url) or inline table", k))); + } + + m.indices[k] = std::move(spec); + } + } + + return m; +} + +// M5.0: inject defaults and auto-infer targets when fields are absent. +// Mutates manifest in-place; called from load() with the project root. +namespace { + +void apply_defaults_and_infer(Manifest& m, const std::filesystem::path& root) { + // Default sources glob (covers .cppm/.cpp/.cc/.c under src/). + if (m.buildConfig.sources.empty()) { + m.buildConfig.sources = { + "src/**/*.cppm", + "src/**/*.cpp", + "src/**/*.cc", + "src/**/*.c", + }; + m.modules.sources = m.buildConfig.sources; // legacy mirror + m.inferredNotes.push_back("sources [src/**/*.{cppm,cpp,cc,c}]"); + } + + // Default include_dirs: ["include"] iff /include/ exists. + if (m.buildConfig.includeDirs.empty()) { + std::error_code ec; + if (std::filesystem::is_directory(root / "include", ec)) { + m.buildConfig.includeDirs.push_back("include"); + m.inferredNotes.push_back("include_dirs [include]"); + } + } + + // Auto-target inference (only when no [targets] declared). + if (m.targets.empty()) { + std::error_code ec; + auto mainCpp = root / "src" / "main.cpp"; + bool hasMain = std::filesystem::exists(mainCpp, ec); + + bool hasCppm = false; + if (std::filesystem::is_directory(root / "src", ec)) { + for (auto& e : std::filesystem::recursive_directory_iterator(root / "src", ec)) { + if (ec) break; + if (e.is_regular_file(ec) && !ec + && e.path().extension() == ".cppm") { + hasCppm = true; break; + } + } + } + + if (hasMain) { + Target t; + t.name = m.package.name; + t.kind = Target::Binary; + t.main = "src/main.cpp"; + m.targets.push_back(std::move(t)); + m.inferredNotes.push_back( + std::format("target {} (bin from src/main.cpp)", m.package.name)); + } else if (hasCppm) { + Target t; + t.name = m.package.name; + t.kind = Target::Library; + m.targets.push_back(std::move(t)); + m.inferredNotes.push_back( + std::format("target {} (lib from .cppm in src/)", m.package.name)); + } + // If neither, no auto-target — caller will error if it needs one. + } +} + +} // namespace + +std::expected load(const std::filesystem::path& path) { + std::ifstream is(path); + if (!is) { + return std::unexpected(ManifestError{ + std::format("cannot open '{}'", path.string()), + path, 0, 0}); + } + std::stringstream ss; + ss << is.rdbuf(); + auto m = parse_string(ss.str(), path); + if (!m) return m; + + // M5.0: defaults + target inference (uses filesystem context relative to mcpp.toml). + apply_defaults_and_infer(*m, path.parent_path()); + return m; +} + +std::string default_template(std::string_view packageName) { + // M5.0: minimal mcpp.toml — convention over configuration. + // sources / target / standard are all auto-inferred. Users add fields as + // they grow out of the defaults. + return std::format(R"([package] +name = "{}" +version = "0.1.0" +description = "A modular C++23 package" +license = "Apache-2.0" +)", packageName); +} + +} // namespace mcpp::manifest diff --git a/src/manifest/types.cppm b/src/manifest/types.cppm new file mode 100644 index 0000000..d939542 --- /dev/null +++ b/src/manifest/types.cppm @@ -0,0 +1,544 @@ +// mcpp.manifest:types — shared manifest data model. +// +// Everything both descriptor formats (mcpp.toml, xpkg .lua) synthesize +// into, plus format-agnostic helpers. No parsing lives here. + +export module mcpp.manifest.types; + +import std; +import mcpp.pm.dep_spec; // M5.x pm/ subsystem refactor: DependencySpec lives here +import mcpp.pm.compat; // Legacy dependency-key compatibility helpers +import mcpp.pm.index_spec; // IndexSpec for [indices] section +import mcpp.platform; + +export namespace mcpp::manifest { + + +// PR-R1 transitional: the dependency data model has moved into +// `mcpp.pm.dep_spec`. The aliases below keep `mcpp::manifest::DependencySpec` +// and `mcpp::manifest::kDefaultNamespace` available as before so existing +// callers (`cli.cppm`, `fetcher.cppm`, ...) compile unchanged. A later +// refactor PR will migrate call sites to reference `mcpp::pm::` directly +// and these aliases can disappear. +using DependencySpec = mcpp::pm::DependencySpec; +inline constexpr auto kDefaultNamespace = mcpp::pm::kDefaultNamespace; +inline constexpr auto kCompatNamespace = mcpp::pm::kCompatNamespace; + +struct CppStandardConfig { + std::string canonical = "c++23"; + std::string flag = "-std=c++23"; + int level = 23; + bool gnuDialect = false; +}; + +struct Package { + std::string name; + std::string namespace_; // xpkg V1 namespace field (0.0.6+); empty = infer from name + std::string version; + std::string standard = "c++23"; // C++ standard (M5.0: moved from [language]) + std::string description; + std::string license; + std::vector authors; + std::string repo; + std::vector platforms; // declared supported platforms (CI matrix hint) +}; + +struct Language { + std::string standard = "c++23"; + bool modules = true; + bool importStd = true; +}; + +// Author-asserted scan result for one source glob (scan_overrides). +// Files matched by the glob bypass the M1 text scan entirely; the declared +// (provides, imports) enter the module graph directly. Sound because the +// declaration is verified against the compiler's own P1689 (.ddi) output +// at build time — assertion + verification instead of computation. +// Design: .agents/docs/2026-07-08-scanner-backend-abstraction-design.md §3-pre. +struct ScanOverride { + std::vector provides; // module logical names the file exports + std::vector imports; // module logical names the file imports +}; + +struct Modules { + std::vector sources; // glob patterns + std::vector exports_; // declared module names (optional) + bool strict = false; + // glob → declared scan result; every glob must match ≥1 source file. + std::map scanOverrides; +}; + +struct Target { + std::string name; + enum Kind { Library, Binary, SharedLibrary, TestBinary } kind; + std::string main; // for binary / test + std::string soname; // ABI name for shared libraries, e.g. libfoo.so.1 + // Per-target compile flags. SCOPE: applied ONLY to this target's exclusive + // entry source (its `main`) — never to shared module/impl objects, which are + // compiled once and linked into every target (the build's compile-once model; + // see src/build/plan.cppm). `defines` are sugar desugared to `-D` at plan + // time and applied to both the C and C++ entry compile. Use these for flags + // that are private to a binary's own entry (e.g. `-DBUILD_SERVER=1`, + // `-Wno-deprecated`); for divergence that must reach shared code, use a + // workspace member or a [features] knob instead. + std::vector cflags; + std::vector cxxflags; + std::vector defines; + // Build gate: this target is emitted ONLY when every listed feature is + // active in the current build (otherwise it is silently skipped). Gate + // only — it does not activate features (use --features / [features].default). + std::vector requiredFeatures; +}; + +// `DependencySpec` and `kDefaultNamespace` have moved to mcpp.pm.dep_spec. +// Aliases at the top of this file keep `mcpp::manifest::DependencySpec` +// resolvable for unchanged call sites. + +// `[toolchain]` section per docs/21-toolchain-and-tools.md +// linux = "gcc@15.1.0" +// macos = "llvm@20" +// windows = "msvc@system" +// default = "gcc@15.1.0" (used when current platform isn't listed) +struct Toolchain { + std::map byPlatform; // platform -> "pkg@ver" + + // Returns the toolchain spec for a platform, falling back to "default". + std::optional for_platform(std::string_view platform) const { + if (auto it = byPlatform.find(std::string(platform)); it != byPlatform.end()) { + return it->second; + } + if (auto it = byPlatform.find("default"); it != byPlatform.end()) { + return it->second; + } + return std::nullopt; + } +}; + +// `[build]` section — tunables for the build backend. +// +// M5.0: now also carries `sources` (moved from [modules]) and `include_dirs` +// (new). Defaults are injected by load() after parse if these are empty. +struct BuildConfig { + std::vector sources; // glob patterns + // feature name → extra source globs gated by that feature. A glob listed + // here is EXCLUDED from the default build and only compiled/linked when the + // feature is active for this package (resolved in prepare_build). Lets a + // dependency expose an optional component (e.g. gtest's gtest_main.cc behind + // the "main" feature) without it being linked by default — see + // .agents/docs/2026-06-25-gtest-main-feature-and-add-dev-design.md. + std::map> featureSources; + // feature name → package-owned preprocessor defines (e.g. "-DEIGEN_USE_BLAS"). + // Feature System v2 Stage 1: when the feature is active these are appended to + // the package's compile flags alongside the automatic -DMCPP_FEATURE_ + // (resolved in prepare_build). Restricted by convention to the package's own + // namespaced macros — features do NOT inject free-form cflags/ldflags, which + // would break feature-union composition. See + // .agents/docs/2026-06-29-feature-capability-model-design.md. + std::map> featureDefines; + std::vector includeDirs; // relative to package root + std::map generatedFiles; // Form B package-owned support files + bool staticStdlib = true; + // "" (default = dynamic), "static", "dynamic" — chosen at resolve + // time from --static / --target / [target.].linkage. Wired + // through to ninja backend as the `-static` link flag. + std::string linkage; + // M5.x C-language support. `cflags` / `cxxflags` are appended verbatim + // to the per-rule baseline (see `ninja_backend` cflags / cxxflags). + // `cStandard` controls -std= for the C compile rule (.c files). + // Empty cStandard → backend default ("c11" today). + std::vector cflags; + std::vector cxxflags; + std::vector ldflags; + std::string cStandard; + // Escape hatch for the hermetic link check: a sandbox toolchain whose + // CRT/loader resolve OUTSIDE the sandbox is a hard error by default + // (silent host contamination, or issue #195's bare-CRT link failure); + // set true to deliberately link against host libraries. + // MCPP_ALLOW_HOST_LIBS=1 is the per-invocation equivalent. + bool allowHostLibs = false; + // macOS minimum supported OS version for produced binaries + // (LC_BUILD_VERSION minos), e.g. "14.0". Mirrors the ecosystem + // conventions around deployment targets (the MACOSX_DEPLOYMENT_TARGET + // env var that cargo/rustc/cc honor; SwiftPM's `platforms:` manifest + // field; CMAKE_OSX_DEPLOYMENT_TARGET). Precedence: the env var (an + // explicit per-invocation override) wins over this manifest default; + // empty + no env = toolchain/SDK default. No effect off macOS. + std::string macosDeploymentTarget; + // Resolved build-profile knobs (from [profile.] + built-in defaults). + std::string optLevel = "2"; // -O level + bool debug = false; // -g + bool lto = false; // -flto + bool strip = false; // link -s + // `[build].default-profile` (alias: `profile`) — the project's DEFAULT + // profile when no --profile/--dev/--release is passed. The global convention + // default stays "release"; this lets a project opt its plain `mcpp build` + // into e.g. "dev" without typing --profile. Precedence: --profile/--dev/ + // --release flag > [build].default-profile > "release". NOTE (distribution + // footgun): a project that defaults to dev should pass `--profile release` + // when producing a distributable (a pack-time release guard is a follow-up). + std::string defaultProfile; +}; + +// `[runtime]` — requirements needed when launching built binaries. +struct RuntimeConfig { + std::vector libraryDirs; // relative to package root + std::vector dlopenLibs; // runtime-loaded sonames + std::vector capabilities; // host/system capabilities REQUIRED + // Capabilities this package explicitly FULFILS (strong provider claim). + // Packages that merely list a capability in `capabilities` are weak + // providers (back-compat); provides-declarers win provider selection. + std::vector provides; + // [runtime.] provider = "" — explicit provider selection + // (the three-tier knob: default/auto → explicit override). + std::map providerOverrides; +}; + +// `[xlings]` — the project's build ENVIRONMENT (L-1). The subsection names mirror +// xlings' own `.xlings.json` schema 1:1, so mcpp materializes them verbatim into +// `/.mcpp/.xlings.json` (no translation layer): `deps` (host build-tools +// installed by xlings), `[xlings.workspace]` (tool→version pins, the general form +// of `[toolchain]`), `subos` (a named per-project sandbox), `[xlings.envs]` +// (env vars applied by xvm shims). See +// .agents/docs/2026-06-29-manifest-environment-and-platform-design.md (L-1). +struct XlingsConfig { + std::vector deps; // → .xlings.json "deps" + std::map workspace; // → "workspace" (tool → version) + std::string subos; // → "subos" (named project sandbox) + std::map envs; // → "envs" (env var → value) + + bool empty() const { + return deps.empty() && workspace.empty() && subos.empty() && envs.empty(); + } +}; + +// `[target.]` — per-target overrides. +// Picked up when caller passes --target to build/run/test. +struct TargetEntry { + std::string toolchain; // e.g. "gcc@15.1.0-musl"; empty = inherit [toolchain] + std::string linkage; // "static" | "dynamic" | "" (= auto by libc) +}; + +// `[target.'cfg(...)'.build]` — platform-conditional build flags (L1). The +// predicate is the raw `[target.]` key (e.g. `cfg(windows)`, +// `cfg(all(linux, not(arch="aarch64")))`, or a bare triple). It is stored +// DEFERRED here because manifest parsing is target-agnostic; prepare_build +// evaluates it against the RESOLVED target (host triple for a native build, +// the --target triple for a cross build) and merges matching flags into +// buildConfig. See .agents/docs/2026-06-29-manifest-environment-and-platform-design.md. +struct ConditionalConfig { + std::string predicate; // the [target.] key + std::vector cflags; + std::vector cxxflags; + std::vector ldflags; + // Conditional dependencies (Phase 1b): merged into the corresponding + // manifest maps in prepare_build when the predicate matches the resolved + // target — before dependency resolution, so they resolve like any dep. + std::map dependencies; + std::map devDependencies; + std::map buildDependencies; +}; + +// `[lib]` — library "root" interface convention. +// +// Convention-over-configuration: a library package's primary module +// interface lives at `src/.cppm`, where `` is +// the last dotted segment of `[package].name` (e.g. `mcpplibs.tinyhttps` +// → `src/tinyhttps.cppm`). That file declares `export module +// ;` and re-exports the public partitions. The lib +// root then drives: +// * `[modules].exports` default (the lib root's module = the only +// externally-visible base module), +// * `mcpp publish` xpkg generation (consumer just `import ;`), +// * downstream tooling (docs / explain) entry point. +// +// Override the convention with `[lib].path = "src/foo.cppm"` (cargo-style) +// — the file must still `export module ;` (no partition). +// +// Lib-root is only meaningful for projects that ship a `kind = "lib"` +// target. Pure-binary projects (mcpp itself, scaffolded `mcpp new`) +// don't trigger any lib-root checks. +struct LibConfig { + std::filesystem::path path; // explicit override; empty = use convention +}; + +// `[pack]` — `mcpp pack` configuration. See docs/35-pack-design.md. +// +// `default_mode` picks the bundling strategy when the user runs bare +// `mcpp pack` (no `--mode` flag): +// "static" — full musl static, no PT_INTERP / RUNPATH +// "bundle-project" — bundle only project's third-party .so (default) +// "bundle-all" — bundle every dynamic dep including libc / libstdc++ +struct PackConfig { + std::string defaultMode; // empty → "bundle-project" + std::vector include; // extra files/globs to ship + std::vector exclude; // patterns to drop from include + // Mode C overrides — let the user expand or contract the PEP 600 + // skip list when their target distros differ from the default + // assumption ("modern desktop Linux"). + std::vector alsoSkip; // libs to ALSO skip on top of PEP 600 + std::vector forceBundle; // libs to bundle even if PEP 600 says skip +}; + +// `[workspace]` — multi-package workspace support (0.0.11+). +// +// A workspace root mcpp.toml declares member packages. Members share +// a unified lock file, target directory, and can inherit dependency +// versions via `.workspace = true`. +// +// Virtual workspace (no [package]): pure management node. +// Rooted workspace ([package] + [workspace]): root is also a package. +struct WorkspaceConfig { + std::vector members; // relative paths to member dirs + std::vector exclude; // paths to exclude + std::map dependencies; // [workspace.dependencies] + bool present = false; +}; + +// [profile.] — bundled build settings (opt level, debug, lto, strip). +struct Profile { + std::string optLevel = "2"; + bool debug = false; + bool lto = false; + bool strip = false; + // Passthrough escape hatch (fixed keys, open values — I6 completeness): + std::vector cflags; + std::vector cxxflags; + std::vector ldflags; +}; + +struct Manifest { + std::filesystem::path sourcePath; // mcpp.toml's filesystem path + + // Unknown top-level keys silently skipped while synthesizing from an + // xpkg mcpp segment — surfaced as warnings by `mcpp xpkg parse` so + // schema evolution is loud in lint instead of invisible. + std::vector xpkgUnknownKeys; + + Package package; + Language language; + Modules modules; + std::vector targets; + + // version-string keyed dependencies (M2 short form only). + std::map dependencies; + std::map devDependencies; + std::map buildDependencies; // host-side tools (M5+ behavior) + + Toolchain toolchain; // optional; empty == fallback + BuildConfig buildConfig; + RuntimeConfig runtimeConfig; + XlingsConfig xlings; // [xlings] build environment (L-1) + std::vector conditionalConfigs; // [target.'cfg(...)'.build], deferred + std::map profiles; // [profile.] + // [features] — feature name → implied features ("default" = default set). + std::map> featuresMap; + + // Feature System v2 Stage 3 — capabilities (provides/requires). A capability + // is just a shared string. A package satisfies one via package-level + // `provides` or via a feature's `provides`; a feature `requires` an abstract + // capability instead of a concrete package, and the resolver binds exactly + // one provider from the graph. See + // .agents/docs/2026-06-29-feature-capability-model-design.md. + std::vector provides; // package-level + std::map> featureProvides; // feature → caps + std::map> featureRequires; // feature → caps + // Feature System v2 Stage 2a — dependencies activated by a feature. A dep + // declared ONLY here is optional: pulled into the resolution worklist only + // when its feature is active (root --features or a dep spec's features=[...]). + // Each value is a full DependencySpec, so a feature-dep may itself request + // features. See .agents/docs/2026-06-29-feature-optional-dependencies-s2-design.md. + std::map> featureDeps; + // Root-only: [capabilities] cap = "provider" pins (also fed by --cap). + std::map capabilityPins; + + // [target.] tables — empty if user didn't declare any. + std::map targetOverrides; + + // [pack] — `mcpp pack` config (see docs/35-pack-design.md). + PackConfig packConfig; + + // [lib] — library root interface convention (M5.x+). + LibConfig lib; + + // [workspace] — multi-package workspace. + WorkspaceConfig workspace; + + // [indices] — custom package index repositories (index-name → IndexSpec). + std::map indices; + + // M5.0: post-parse computed/inferred state + CppStandardConfig cppStandard; + bool usesModules = true; // refined by scanner + bool usesImportStd = true; // refined by scanner + std::vector inferredNotes; // for `Inferred ...` banner + + // Non-fatal schema warnings collected during parse (e.g. unsupported keys + // under [targets.]). The caller (prepare_build) prints these and, under + // --strict, escalates them to errors — mirroring the feature/platform path. + std::vector schemaWarnings; +}; + +struct ManifestError { + std::string message; + std::filesystem::path file; + std::size_t line = 0; + std::size_t column = 0; + + std::string format() const { + if (line) + return std::format("{}:{}:{}: error: {}", file.string(), line, column, message); + return std::format("{}: error: {}", file.string(), message); + } +}; + +std::expected normalize_cpp_standard(std::string_view raw); + +std::filesystem::path resolve_lib_root_path(const Manifest& manifest); + +// True if the manifest declares at least one `kind = "lib"` target. +// Lib-root convention only applies when this returns true. +bool has_lib_target(const Manifest& manifest); + + +// GCC 15 cross-link workaround anchor — see definition below. +void force_template_instantiations(); + +} // namespace mcpp::manifest + +// ── helpers shared by the toml and xpkg parsers (exported: separate +// modules need them reachable; they live in the manifest namespace) ── +export namespace mcpp::manifest { + +bool starts_with_std_flag(std::string_view flag) { + return flag == "-std" || flag.starts_with("-std="); +} + +bool is_basename(std::string_view value) { + return !value.empty() + && value.find('/') == std::string_view::npos + && value.find('\\') == std::string_view::npos; +} + +std::optional validate_target_soname(const Target& t, + std::string_view targetPath) { + if (t.soname.empty()) return std::nullopt; + if (t.kind != Target::SharedLibrary) { + return std::format("{}soname is only valid for shared targets", targetPath); + } + if (!is_basename(t.soname)) { + return std::format("{}soname must be a library basename, got '{}'", + targetPath, t.soname); + } + return std::nullopt; +} + + +std::expected normalize_cpp_standard(std::string_view raw) { + auto trim_copy = [](std::string_view input) { + std::size_t begin = 0; + while (begin < input.size() + && std::isspace(static_cast(input[begin]))) { + ++begin; + } + std::size_t end = input.size(); + while (end > begin + && std::isspace(static_cast(input[end - 1]))) { + --end; + } + return std::string(input.substr(begin, end - begin)); + }; + + std::string s = trim_copy(raw); + for (auto& c : s) c = static_cast(std::tolower(static_cast(c))); + + CppStandardConfig out; + if (s.empty() || s == "c++23" || s == "c++2b") { + out.canonical = "c++23"; + out.flag = "-std=c++23"; + out.level = 23; + out.gnuDialect = false; + return out; + } + if (s == "gnu++23" || s == "gnu++2b") { + out.canonical = "gnu++23"; + out.flag = "-std=gnu++23"; + out.level = 23; + out.gnuDialect = true; + return out; + } + if (s == "c++26" || s == "c++2c") { + out.canonical = "c++26"; + out.flag = "-std=c++26"; + out.level = 26; + out.gnuDialect = false; + return out; + } + if (s == "gnu++26" || s == "gnu++2c") { + out.canonical = "gnu++26"; + out.flag = "-std=gnu++26"; + out.level = 26; + out.gnuDialect = true; + return out; + } + if (s == "c++latest") { + out.canonical = "c++latest"; + out.flag = "-std=c++26"; + out.level = 999; + out.gnuDialect = false; + return out; + } + + return std::unexpected(std::format( + "unsupported C++ standard '{}'; expected c++23, c++26, c++2c, gnu++23, gnu++26, or c++latest", + raw)); +} + +bool has_lib_target(const Manifest& manifest) { + for (auto& t : manifest.targets) { + if (t.kind == Target::Library || t.kind == Target::SharedLibrary) { + return true; + } + } + return false; +} + +std::filesystem::path resolve_lib_root_path(const Manifest& manifest) { + if (!manifest.lib.path.empty()) { + return manifest.lib.path; + } + // Convention: src/.cppm + std::string tail = manifest.package.name; + if (auto p = tail.rfind('.'); p != std::string::npos) { + tail = tail.substr(p + 1); + } + return std::filesystem::path("src") / (tail + ".cppm"); +} + + + +// ── GCC 15 cross-link workaround ──────────────────────────────────── +// GCC 15 (aarch64-linux-musl cross, xim gcc 15.1.0) does not emit +// implicit template instantiations for std::map/... members of +// module-attached structs in IMPORTER object files — it expects the +// owning module to provide them. The old single-file mcpp.manifest +// emitted them by accident (its parser code constructed every struct); +// this non-inline, exported definition recreates that guarantee +// deliberately. Remove once the cross toolchain floor is GCC 16. +void force_template_instantiations() { + Manifest m; + WorkspaceConfig w; + XlingsConfig x; + Modules mo; + BuildConfig bc; + RuntimeConfig rc; + TargetEntry te; + ConditionalConfig cc; + LibConfig lc; + PackConfig pc; + Profile pr; + Toolchain tc; + (void)m; (void)w; (void)x; (void)mo; (void)bc; (void)rc; + (void)te; (void)cc; (void)lc; (void)pc; (void)pr; (void)tc; +} + +} // namespace mcpp::manifest diff --git a/src/manifest/xpkg.cppm b/src/manifest/xpkg.cppm new file mode 100644 index 0000000..bdd0eb9 --- /dev/null +++ b/src/manifest/xpkg.cppm @@ -0,0 +1,1237 @@ +// mcpp.manifest:xpkg — parse the `mcpp = {}` segment of xpkg .lua +// descriptors (tiny Lua-subset reader; we never execute Lua). + +export module mcpp.manifest.xpkg; + +import mcpp.manifest.types; +import std; +import mcpp.pm.dep_spec; +import mcpp.pm.dependency_selector; +import mcpp.platform; + +export namespace mcpp::manifest { + +// M6.x: `mcpp` field in xpkg.lua may be either: +// - a string (path to mcpp.toml inside the extracted tarball, glob-able) +// - a table (inline Form B descriptor) +// extract_mcpp_field discriminates and returns the right kind. +struct McppField { + enum Kind { Absent, StringPath, TableBody } kind = Absent; + std::string value; // glob path (StringPath) or table body (TableBody) +}; +McppField extract_mcpp_field(std::string_view luaContent); +// Extract the list of available versions for `platform` (e.g. "linux", "macosx", +// "windows") from an xpkg .lua's xpm. = { ["X.Y.Z"] = {...}, ... }. +// Returns an empty vector if the platform table is missing or has no entries. +std::vector +list_xpkg_versions(std::string_view luaContent, std::string_view platform); +// Extract the `namespace` field from an xpkg .lua's `package = { ... }` block. +// Returns empty string if the field is absent (legacy descriptors). +std::string extract_xpkg_namespace(std::string_view luaContent); +// Extract the `name` field from an xpkg .lua's `package = { ... }` block. +// Returns empty string if the field is absent. +std::string extract_xpkg_name(std::string_view luaContent); +// Canonical package identity — the unified (ns, name) model (design doc §4.2). +// +// A package's identity is a 2-tuple: `ns` is a hierarchical namespace path +// (sub-namespaces, dotted: `compat`, `a.b.c`), `name` is a single atomic +// segment. Surface spellings (dotted name, embedded prefix, missing namespace) +// all normalize to this tuple. Normalization: +// 1. If `declaredNs` is empty, inherit `indexDefaultNs` (owning-index ns). +// 2. Fully-qualified name: if `declaredName` already starts with `ns.`, it is +// the FQN; otherwise FQN = `ns.declaredName` (or just `declaredName` when +// there is no namespace at all). +// 3. Split the FQN on its LAST dot: prefix → `ns`, final segment → `name`. +struct XpkgIdentity { + std::string ns; + std::string name; + bool operator==(const XpkgIdentity&) const = default; +}; +XpkgIdentity canonical_xpkg_identity(std::string_view declaredNs, + std::string_view declaredName, + std::string_view indexDefaultNs = {}); +// Convenience: the canonical identity of an xpkg .lua, read from its declared +// `package.{namespace,name}`. Empty `name` field → empty identity. +XpkgIdentity canonical_xpkg_identity_from_lua(std::string_view luaContent, + std::string_view indexDefaultNs = {}); +// Identity gate: does this xpkg .lua actually DECLARE the package the caller +// asked for? Compares the descriptor's declared `package.{name,namespace}` +// against the requested (ns, shortName) coordinate. This is the invariant that +// makes filename-based lookup safe — a file found by a candidate filename is +// only accepted when it *is* the requested package, so a bare `zlib.lua` from a +// foreign index never satisfies a request for `compat.zlib`. +// +// A descriptor that declares no `name` is accepted (cannot verify → lenient). +// `allowLegacyBareDefault` governs only the default-namespace case: whether a +// no-namespace descriptor whose bare name matches counts as the default-ns +// package (preserves legacy bare-named mcpplibs descriptors). +// +// `indexDefaultNs` is the namespace OWNED BY the index the descriptor was found +// in. When the read is scoped to a single known index (e.g. a `[indices]` local +// path index owns namespace `local-dev`), a descriptor that declares no +// namespace inherits the index's — so `tinycfg.lua` (name only) in the +// `local-dev` index matches a request for `(local-dev, tinycfg)`. Empty for +// multi-index scans where the owning index isn't known per-file (the builtin +// global scan); see the design doc §4.1. +bool xpkg_lua_identity_matches(std::string_view luaContent, + std::string_view ns, + std::string_view shortName, + bool allowLegacyBareDefault = true, + std::string_view indexDefaultNs = {}); +// Resolve the lib-root path for a manifest: +// 1. `[lib].path` if explicitly set (cargo-style override), +// 2. otherwise the convention `src/.cppm`, where +// `` is the last `.`-segment of [package].name +// (e.g. `mcpplibs.tinyhttps` → `src/tinyhttps.cppm`). +// The returned path is relative to the package root unless the user +// passed an absolute path in `[lib].path`. +// Synthesize a Manifest from an xpkg .lua file's `mcpp = {}` segment. +// Used when a fetched dep has no source/mcpp.toml — the index entry's +// `mcpp = {}` workaround block carries the missing build info. +// +// The resulting Manifest is in-memory only; sourcePath is set to the +// supplied package name + version so error messages can refer to it. +std::expected +synthesize_from_xpkg_lua(std::string_view luaContent, + std::string_view packageName, + std::string_view packageVersion); +} // namespace mcpp::manifest + +namespace mcpp::manifest { + + +// ===================================================================== +// synthesize_from_xpkg_lua — parse mcpp = {} segment from an xpkg .lua +// ===================================================================== +// +// Scope: tiny Lua-subset reader specialised for our `mcpp = { ... }` +// workaround block. We don't run real Lua; we just locate the mcpp +// table and read a short list of typed fields out of it. + +namespace { + +struct LuaCursor { + std::string_view text; + std::size_t pos = 0; + + bool eof() const { return pos >= text.size(); } + char peek() const { return pos < text.size() ? text[pos] : '\0'; } + + void skip_ws_and_comments() { + while (!eof()) { + char c = peek(); + if (c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == ',' || c == ';') { + ++pos; + } else if (c == '-' && pos + 1 < text.size() && text[pos + 1] == '-') { + // Block comment --[[…]] / --[=[…]=] spans lines; otherwise + // line comment to end of line. + if (long_bracket_level_at(pos + 2) >= 0) { + pos += 2; + (void)read_long_bracket(); + } else { + while (!eof() && peek() != '\n') ++pos; + } + } else { + break; + } + } + } + + bool consume(char c) { + skip_ws_and_comments(); + if (peek() == c) { ++pos; return true; } + return false; + } + + // Lua long-bracket support ([[…]], [=[…]=], …). Returns the level + // (number of '=' between the brackets) if `at` starts a long-bracket + // opening, -1 otherwise. + int long_bracket_level_at(std::size_t at) const { + if (at >= text.size() || text[at] != '[') return -1; + std::size_t i = at + 1; + int level = 0; + while (i < text.size() && text[i] == '=') { ++level; ++i; } + return (i < text.size() && text[i] == '[') ? level : -1; + } + + // True when the next value token is a string in either spelling + // (quoted or long-bracket). Skips leading ws/comments like read_string. + bool at_string_start() { + skip_ws_and_comments(); + return peek() == '"' || peek() == '\'' || + long_bracket_level_at(pos) >= 0; + } + + // Read a long-bracket string; caller ensured `pos` is at an opening. + // Content is verbatim (no escape processing); per Lua semantics a + // newline immediately after the opening bracket is skipped. + std::string read_long_bracket() { + int level = long_bracket_level_at(pos); + if (level < 0) return {}; + pos += 2 + static_cast(level); // consume [=*[ + if (pos < text.size() && (text[pos] == '\n' || text[pos] == '\r')) { + char first = text[pos++]; + if (pos < text.size() && (text[pos] == '\n' || text[pos] == '\r') && + text[pos] != first) + ++pos; // \r\n / \n\r pair + } + std::string close = "]" + std::string(static_cast(level), '=') + "]"; + auto end = text.find(close, pos); + std::string out; + if (end == std::string_view::npos) { // unterminated + out.assign(text.substr(pos)); + pos = text.size(); + } else { + out.assign(text.substr(pos, end - pos)); + pos = end + close.size(); + } + return out; + } + + std::string read_string() { + skip_ws_and_comments(); + if (long_bracket_level_at(pos) >= 0) return read_long_bracket(); + if (peek() != '"' && peek() != '\'') return {}; + char q = text[pos++]; + std::string out; + while (!eof() && peek() != q) { + if (peek() == '\\' && pos + 1 < text.size()) { + ++pos; + char e = text[pos++]; + switch (e) { + case 'n': out.push_back('\n'); break; + case 't': out.push_back('\t'); break; + case 'r': out.push_back('\r'); break; + case '"': out.push_back('"'); break; + case '\'': out.push_back('\''); break; + case '\\': out.push_back('\\'); break; + default: out.push_back(e); + } + } else { + out.push_back(text[pos++]); + } + } + if (!eof()) ++pos; // closing quote + return out; + } + + std::string read_ident() { + skip_ws_and_comments(); + std::string out; + while (!eof() && (std::isalnum(static_cast(peek())) || + peek() == '_')) + { + out.push_back(text[pos++]); + } + return out; + } + + // Read either a bare ident or `["string"]`. + std::string read_key() { + skip_ws_and_comments(); + if (peek() == '[') { + ++pos; + auto s = read_string(); + skip_ws_and_comments(); + if (peek() == ']') ++pos; + return s; + } + return read_ident(); + } + + // Read a Lua barewordy value: number/true/false/nil up to delimiter. + std::string read_bareword() { + skip_ws_and_comments(); + std::string out; + while (!eof() && !std::isspace(static_cast(peek())) && + peek() != ',' && peek() != '}' && peek() != ';') + { + out.push_back(text[pos++]); + } + return out; + } + + // Skip an entire balanced { ... } block, string-aware. + void skip_table() { + if (!consume('{')) return; + int depth = 1; + while (!eof() && depth > 0) { + char c = peek(); + if (c == '"' || c == '\'') { + read_string(); + continue; + } else if (c == '[' && long_bracket_level_at(pos) >= 0) { + (void)read_long_bracket(); + continue; + } else if (c == '-' && pos + 1 < text.size() && text[pos+1] == '-') { + if (long_bracket_level_at(pos + 2) >= 0) { + pos += 2; + (void)read_long_bracket(); + } else { + while (!eof() && peek() != '\n') ++pos; + } + continue; + } else if (c == '{') { ++depth; ++pos; } + else if (c == '}') { --depth; ++pos; } + else { ++pos; } + } + } + + // Read and consume a balanced { ... } block, returning the inner text. + std::string read_table_body() { + if (!consume('{')) return {}; + auto start = pos; + int depth = 1; + while (!eof() && depth > 0) { + char c = peek(); + if (c == '"' || c == '\'') { + read_string(); + continue; + } + if (c == '[' && long_bracket_level_at(pos) >= 0) { + (void)read_long_bracket(); + continue; + } + if (c == '-' && pos + 1 < text.size() && text[pos + 1] == '-') { + if (long_bracket_level_at(pos + 2) >= 0) { + pos += 2; + (void)read_long_bracket(); + } else { + while (!eof() && peek() != '\n') ++pos; + } + continue; + } + if (c == '{') { + ++depth; + ++pos; + continue; + } + if (c == '}') { + --depth; + if (depth == 0) { + auto end = pos; + ++pos; + return std::string(text.substr(start, end - start)); + } + ++pos; + continue; + } + ++pos; + } + return {}; + } +}; + +std::string top_level_table_body_for_key(std::string_view body, std::string_view wantedKey) { + LuaCursor cur { body }; + cur.skip_ws_and_comments(); + while (!cur.eof()) { + auto key = cur.read_key(); + if (key.empty()) { + cur.skip_ws_and_comments(); + if (cur.eof()) break; + ++cur.pos; + continue; + } + cur.skip_ws_and_comments(); + if (!cur.consume('=')) { + cur.skip_ws_and_comments(); + continue; + } + cur.skip_ws_and_comments(); + if (key == wantedKey) { + return cur.read_table_body(); + } + if (cur.peek() == '{') cur.skip_table(); + else if (cur.at_string_start()) (void)cur.read_string(); + else (void)cur.read_bareword(); + cur.skip_ws_and_comments(); + } + return {}; +} + +std::string top_level_string_value_for_key(std::string_view body, std::string_view wantedKey) { + LuaCursor cur { body }; + cur.skip_ws_and_comments(); + while (!cur.eof()) { + auto key = cur.read_key(); + if (key.empty()) { + cur.skip_ws_and_comments(); + if (cur.eof()) break; + ++cur.pos; + continue; + } + cur.skip_ws_and_comments(); + if (!cur.consume('=')) { + cur.skip_ws_and_comments(); + continue; + } + cur.skip_ws_and_comments(); + if (key == wantedKey && cur.at_string_start()) { + return cur.read_string(); + } + if (cur.peek() == '{') cur.skip_table(); + else if (cur.at_string_start()) (void)cur.read_string(); + else (void)cur.read_bareword(); + cur.skip_ws_and_comments(); + } + return {}; +} + +// Strip Lua line comments (`-- ...\n`) and string contents from text, +// replacing them with spaces of the same length so positions are +// preserved. This is a simple-but-correct way to make the scanner +// in extract_mcpp_segment_body() ignore comments and strings without +// re-implementing a full Lua tokenizer. +std::string strip_lua_comments_and_strings(std::string_view text) { + std::string out(text.size(), ' '); + std::size_t i = 0; + // Long-bracket opening at `at`? Returns level (count of '='), else -1. + auto lb_level = [&](std::size_t at) -> int { + if (at >= text.size() || text[at] != '[') return -1; + std::size_t k = at + 1; + int level = 0; + while (k < text.size() && text[k] == '=') { ++level; ++k; } + return (k < text.size() && text[k] == '[') ? level : -1; + }; + // Blank [begin,end) preserving newlines for line-number fidelity. + auto blank = [&](std::size_t begin, std::size_t end) { + for (std::size_t d = begin; d < end && d < text.size(); ++d) + out[d] = (text[d] == '\n') ? '\n' : ' '; + }; + while (i < text.size()) { + char c = text[i]; + // Comment: block (--[[…]] / --[=[…]=]) spans lines; else line comment. + if (c == '-' && i + 1 < text.size() && text[i+1] == '-') { + if (int lvl = lb_level(i + 2); lvl >= 0) { + std::string close = "]" + std::string(static_cast(lvl), '=') + "]"; + auto end = text.find(close, i + 2 + 2 + static_cast(lvl)); + std::size_t stop = (end == std::string_view::npos) + ? text.size() : end + close.size(); + blank(i, stop); + i = stop; + continue; + } + while (i < text.size() && text[i] != '\n') { + out[i] = ' '; + ++i; + } + continue; + } + // Long-bracket string: keep delimiters, blank content (it may + // contain braces that would corrupt the structural depth count). + if (int lvl = lb_level(i); lvl >= 0) { + std::size_t openLen = 2 + static_cast(lvl); + for (std::size_t d = 0; d < openLen; ++d) out[i + d] = text[i + d]; + std::string close = "]" + std::string(static_cast(lvl), '=') + "]"; + auto end = text.find(close, i + openLen); + if (end == std::string_view::npos) { + blank(i + openLen, text.size()); + i = text.size(); + } else { + blank(i + openLen, end); + for (std::size_t d = 0; d < close.size(); ++d) out[end + d] = text[end + d]; + i = end + close.size(); + } + continue; + } + // String literal + if (c == '"' || c == '\'') { + char q = c; + out[i] = c; // keep opening quote so structure-aware search still sees it + ++i; + while (i < text.size() && text[i] != q) { + if (text[i] == '\\' && i + 1 < text.size()) { + out[i] = ' '; + out[i+1] = ' '; + i += 2; + continue; + } + out[i] = (text[i] == '\n') ? '\n' : ' '; + ++i; + } + if (i < text.size()) { + out[i] = q; // closing quote + ++i; + } + continue; + } + out[i] = c; + ++i; + } + return out; +} + +// Locate the body of `mcpp = { ... }` and return the inner content (no +// surrounding braces). Returns empty string if not found. +// M6.x: locate the `mcpp = ...` field at top level of an xpkg.lua and +// classify it as either a table body or a string path. Operates on a +// comment-/string-stripped copy so literal "mcpp = ..." inside Lua +// comments doesn't false-match. +McppField extract_mcpp_field_impl(std::string_view raw_text) { + auto sanitized = strip_lua_comments_and_strings(raw_text); + std::string_view text { sanitized }; + + std::size_t p = 0; + while ((p = text.find("mcpp", p)) != std::string_view::npos) { + bool word_start = (p == 0 || (!std::isalnum(static_cast(text[p-1])) + && text[p-1] != '_')); + if (!word_start) { ++p; continue; } + std::size_t q = p + 4; + if (q < text.size() && (std::isalnum(static_cast(text[q])) || + text[q] == '_')) { ++p; continue; } + while (q < text.size() && (text[q] == ' ' || text[q] == '\t')) ++q; + if (q >= text.size() || text[q] != '=') { ++p; continue; } + ++q; + while (q < text.size() && (text[q] == ' ' || text[q] == '\t' || + text[q] == '\n' || text[q] == '\r')) ++q; + if (q >= text.size()) { ++p; continue; } + + // Discriminate: { → table body, " → string path + if (text[q] == '{') { + ++q; + std::size_t body_start = q; + int depth = 1; + while (q < text.size() && depth > 0) { + char c = text[q]; + if (c == '{') ++depth; + else if (c == '}') { + --depth; + if (depth == 0) { + return McppField{ + McppField::TableBody, + std::string(raw_text.substr(body_start, q - body_start))}; + } + } + ++q; + } + return {}; + } + if (text[q] == '"') { + // string literal — but the sanitizer blanks string contents, so + // re-locate the same `"..."` in raw_text and take its body. + // Find the opening `"` at offset q in raw_text (offsets align + // because sanitizer keeps positions). + std::size_t s = q; + if (s >= raw_text.size() || raw_text[s] != '"') { ++p; continue; } + ++s; + std::string val; + while (s < raw_text.size() && raw_text[s] != '"') { + if (raw_text[s] == '\\' && s + 1 < raw_text.size()) { + char nc = raw_text[s + 1]; + switch (nc) { + case 'n': val.push_back('\n'); break; + case 't': val.push_back('\t'); break; + case '"': val.push_back('"'); break; + case '\\': val.push_back('\\'); break; + default: val.push_back(nc); + } + s += 2; + } else { + val.push_back(raw_text[s++]); + } + } + return McppField{ McppField::StringPath, std::move(val) }; + } + ++p; + } + return {}; +} + +// Backward-compat: old API; prefer extract_mcpp_field for new callers. +std::string extract_mcpp_segment_body(std::string_view raw_text) { + auto f = extract_mcpp_field_impl(raw_text); + return f.kind == McppField::TableBody ? std::move(f.value) : std::string{}; +} + +} // namespace + +McppField extract_mcpp_field(std::string_view luaContent) { + return extract_mcpp_field_impl(luaContent); +} + +std::string extract_xpkg_namespace(std::string_view luaContent) { + auto packageBody = top_level_table_body_for_key(luaContent, "package"); + if (packageBody.empty()) return {}; + return top_level_string_value_for_key(packageBody, "namespace"); +} + +std::string extract_xpkg_name(std::string_view luaContent) { + auto packageBody = top_level_table_body_for_key(luaContent, "package"); + if (packageBody.empty()) return {}; + return top_level_string_value_for_key(packageBody, "name"); +} + +XpkgIdentity canonical_xpkg_identity(std::string_view declaredNs, + std::string_view declaredName, + std::string_view indexDefaultNs) { + // Step 1 — owning-index namespace: a descriptor that declares no namespace + // inherits the namespace of the index it lives in. + std::string ns(declaredNs.empty() ? indexDefaultNs : declaredNs); + std::string name(declaredName); + + // Step 2 — fully-qualified name. A declared name already prefixed with the + // namespace IS the FQN; otherwise the namespace is prepended. With no + // namespace at all, the declared name stands alone. + std::string fqn; + if (ns.empty()) { + fqn = name; + } else { + std::string prefix = ns + "."; + fqn = name.starts_with(prefix) ? name : ns + "." + name; + } + + // Step 3 — split the FQN on its LAST dot: prefix → ns, final segment → name. + auto pos = fqn.rfind('.'); + if (pos == std::string::npos) return XpkgIdentity{ /*ns=*/{}, /*name=*/fqn }; + return XpkgIdentity{ fqn.substr(0, pos), fqn.substr(pos + 1) }; +} + +XpkgIdentity canonical_xpkg_identity_from_lua(std::string_view luaContent, + std::string_view indexDefaultNs) { + auto name = extract_xpkg_name(luaContent); + if (name.empty()) return {}; + return canonical_xpkg_identity(extract_xpkg_namespace(luaContent), name, + indexDefaultNs); +} + +bool xpkg_lua_identity_matches(std::string_view luaContent, + std::string_view ns, + std::string_view shortName, + bool allowLegacyBareDefault, + std::string_view indexDefaultNs) { + auto luaName = extract_xpkg_name(luaContent); + if (luaName.empty()) return true; // no declared name → cannot verify, accept + + // Reduce the descriptor to its canonical (ns, name) tuple (design doc §4.2), + // then match per the unified model. + auto id = canonical_xpkg_identity(extract_xpkg_namespace(luaContent), + luaName, indexDefaultNs); + + // The single atomic name must equal the requested short name in every mode. + if (id.name != shortName) return false; + + // Discovery (empty request ns, e.g. `mcpp new --template X`): the caller + // derives the namespace from the descriptor, so a name match is enough. + if (ns.empty()) return true; + + // Unqualified / default-namespace request: resolve the name against the + // default namespace search path — the default namespace itself, then the + // `compat` wrapper namespace (`kCompatNamespace`, shared with the candidate + // generator). A legacy no-namespace descriptor is admitted under the flag. + if (ns == kDefaultNamespace) { + return id.ns == kDefaultNamespace + || id.ns == kCompatNamespace + || (allowLegacyBareDefault && id.ns.empty()); + } + + // Qualified request (concrete namespace: compat, xim, a custom/nested ns): + // exact namespace equality. Cross-namespace collisions are structurally + // impossible — a foreign `(xim, zlib)` never equals `(compat, zlib)`. + return id.ns == ns; +} + +std::vector +list_xpkg_versions(std::string_view luaContent, std::string_view platform) { + // Locate `xpm = { ... = { ["X.Y.Z"] = {...}, ... } ... }`. + // We work on a sanitized copy so quoted version keys remain locatable + // by their offsets in the original text. + auto sanitized = strip_lua_comments_and_strings(luaContent); + std::string_view text { sanitized }; + std::vector versions; + + auto find_word_at_lhs = [&](std::string_view name, std::size_t from) + -> std::size_t + { + std::size_t p = from; + while ((p = text.find(name, p)) != std::string_view::npos) { + bool word_start = (p == 0 || + (!std::isalnum(static_cast(text[p-1])) && text[p-1] != '_')); + std::size_t after = p + name.size(); + bool word_end = (after >= text.size() || + (!std::isalnum(static_cast(text[after])) && text[after] != '_')); + if (!word_start || !word_end) { ++p; continue; } + std::size_t q = after; + while (q < text.size() && (text[q] == ' ' || text[q] == '\t' || + text[q] == '\n' || text[q] == '\r')) ++q; + if (q < text.size() && text[q] == '=') return p; + ++p; + } + return std::string_view::npos; + }; + + auto skip_to_open_brace = [&](std::size_t from) -> std::size_t { + std::size_t q = from; + while (q < text.size() && text[q] != '{') ++q; + return q < text.size() ? q : std::string_view::npos; + }; + + // Match braces to find table extent. + auto find_table_end = [&](std::size_t open) -> std::size_t { + int depth = 1; + std::size_t q = open + 1; + while (q < text.size() && depth > 0) { + char c = text[q]; + if (c == '{') ++depth; + else if (c == '}') { + --depth; + if (depth == 0) return q; + } + ++q; + } + return std::string_view::npos; + }; + + auto xpm_pos = find_word_at_lhs("xpm", 0); + if (xpm_pos == std::string_view::npos) return versions; + auto xpm_open = skip_to_open_brace(xpm_pos); + if (xpm_open == std::string_view::npos) return versions; + auto xpm_end = find_table_end(xpm_open); + if (xpm_end == std::string_view::npos) return versions; + + auto plat_pos = find_word_at_lhs(platform, xpm_open + 1); + if (plat_pos == std::string_view::npos || plat_pos >= xpm_end) return versions; + auto plat_open = skip_to_open_brace(plat_pos); + if (plat_open == std::string_view::npos || plat_open >= xpm_end) return versions; + auto plat_end = find_table_end(plat_open); + if (plat_end == std::string_view::npos) return versions; + + // Inside platform table: scan for ["X.Y.Z"] = { ... } + std::size_t q = plat_open + 1; + while (q < plat_end) { + if (text[q] == '[') { + std::size_t r = q + 1; + while (r < plat_end && (text[r] == ' ' || text[r] == '\t')) ++r; + if (r < plat_end && (text[r] == '"' || text[r] == '\'')) { + const char quote = text[r]; + ++r; + std::size_t key_start = r; + while (r < plat_end && text[r] != quote && text[r] != '\n') ++r; + if (r < plat_end && text[r] == quote) { + versions.emplace_back(luaContent.substr(key_start, r - key_start)); + } + } + } + ++q; + } + return versions; +} + +std::expected +synthesize_from_xpkg_lua(std::string_view luaContent, + std::string_view packageName, + std::string_view packageVersion) +{ + auto body = extract_mcpp_segment_body(luaContent); + if (body.empty()) { + return std::unexpected(ManifestError{ + std::format( + "package '{}' has no `mcpp = {{}}` segment in its index entry " + "and the source has no mcpp.toml — cannot derive a manifest.", + packageName), + std::format("xpkg-lua of {}@{}", packageName, packageVersion), + 0, 0}); + } + if (auto platformBody = top_level_table_body_for_key(body, mcpp::platform::xpkg_platform); + !platformBody.empty()) { + body += "\n"; + body += platformBody; + } + + Manifest m; + m.sourcePath = std::format("xpkg-lua://{}@{}", packageName, packageVersion); + m.package.name = std::string(packageName); + m.package.version = std::string(packageVersion); + m.package.standard = "c++23"; + m.language.standard = "c++23"; + m.language.modules = true; + m.language.importStd = true; + + LuaCursor cur { body }; + cur.skip_ws_and_comments(); + + while (!cur.eof()) { + cur.skip_ws_and_comments(); + if (cur.eof()) break; + auto key = cur.read_key(); + if (key.empty()) { + cur.skip_ws_and_comments(); + if (cur.eof()) break; + ++cur.pos; // unknown char — advance and retry + continue; + } + cur.skip_ws_and_comments(); + if (!cur.consume('=')) { + return std::unexpected(ManifestError{ + std::format("malformed mcpp segment near key '{}'", key), + m.sourcePath, 0, 0}); + } + cur.skip_ws_and_comments(); + + if (key == "language") { + auto v = cur.read_string(); + if (!v.empty()) { + m.language.standard = v; + m.package.standard = v; + } + } + else if (key == "import_std") { + auto v = cur.read_bareword(); + m.language.importStd = (v == "true"); + } + else if (key == "modules") { + // `{ "a", "b", ... }` + if (!cur.consume('{')) { + return std::unexpected(ManifestError{ + "expected '{' after `modules =`", m.sourcePath, 0, 0}); + } + cur.skip_ws_and_comments(); + while (!cur.eof() && cur.peek() != '}') { + auto s = cur.read_string(); + if (!s.empty()) m.modules.exports_.push_back(std::move(s)); + cur.skip_ws_and_comments(); + } + cur.consume('}'); + } + else if (key == "sources") { + if (!cur.consume('{')) { + return std::unexpected(ManifestError{ + "expected '{' after `sources =`", m.sourcePath, 0, 0}); + } + cur.skip_ws_and_comments(); + while (!cur.eof() && cur.peek() != '}') { + auto s = cur.read_string(); + if (!s.empty()) { + m.modules.sources.push_back(s); + m.buildConfig.sources.push_back(std::move(s)); // M5.0 mirror + } + cur.skip_ws_and_comments(); + } + cur.consume('}'); + } + else if (key == "include_dirs") { + // M5.0: shipped headers exposed to dependents AND used by this + // package's own compile (mcpp's symmetric include_dirs semantics). + if (!cur.consume('{')) { + return std::unexpected(ManifestError{ + "expected '{' after `include_dirs =`", m.sourcePath, 0, 0}); + } + cur.skip_ws_and_comments(); + while (!cur.eof() && cur.peek() != '}') { + auto s = cur.read_string(); + if (!s.empty()) m.buildConfig.includeDirs.emplace_back(s); + cur.skip_ws_and_comments(); + } + cur.consume('}'); + } + else if (key == "provides") { + // Package-level capabilities (Feature System v2 S3): this package + // satisfies the listed abstract capability names for any dependent + // that `requires` them. `{ "blas", "lapack", ... }`. + if (!cur.consume('{')) { + return std::unexpected(ManifestError{ + "expected '{' after `provides =`", m.sourcePath, 0, 0}); + } + cur.skip_ws_and_comments(); + while (!cur.eof() && cur.peek() != '}') { + auto s = cur.read_string(); + if (!s.empty()) m.provides.push_back(std::move(s)); + cur.skip_ws_and_comments(); + } + cur.consume('}'); + } + else if (key == "generated_files") { + // `{ ["relative/path"] = "contents", ... }` + if (!cur.consume('{')) { + return std::unexpected(ManifestError{ + "expected '{' after `generated_files =`", m.sourcePath, 0, 0}); + } + cur.skip_ws_and_comments(); + while (!cur.eof() && cur.peek() != '}') { + auto path = cur.read_key(); + if (path.empty()) break; + cur.skip_ws_and_comments(); + if (!cur.consume('=')) { + return std::unexpected(ManifestError{ + "expected '=' in `generated_files` entry", m.sourcePath, 0, 0}); + } + auto content = cur.read_string(); + m.buildConfig.generatedFiles.emplace(path, std::move(content)); + cur.skip_ws_and_comments(); + } + cur.consume('}'); + } + else if (key == "targets") { + // `{ ["name"] = { kind = "lib" }, ... }` + if (!cur.consume('{')) { + return std::unexpected(ManifestError{ + "expected '{' after `targets =`", m.sourcePath, 0, 0}); + } + cur.skip_ws_and_comments(); + while (!cur.eof() && cur.peek() != '}') { + auto tname = cur.read_key(); + if (tname.empty()) { cur.skip_ws_and_comments(); break; } + cur.skip_ws_and_comments(); + if (!cur.consume('=')) break; + cur.skip_ws_and_comments(); + if (!cur.consume('{')) break; + + Target t; + t.name = tname; + t.kind = Target::Library; // default + cur.skip_ws_and_comments(); + while (!cur.eof() && cur.peek() != '}') { + auto sub = cur.read_key(); + cur.skip_ws_and_comments(); + if (!cur.consume('=')) break; + cur.skip_ws_and_comments(); + if (sub == "kind") { + auto k = cur.read_string(); + if (k == "lib" || k == "library") t.kind = Target::Library; + else if (k == "bin" || k == "binary") t.kind = Target::Binary; + else if (k == "shared" || k == "dylib" + || k == "so" || k == "shlib") t.kind = Target::SharedLibrary; + } else if (sub == "main") { + t.main = cur.read_string(); + } else if (sub == "soname") { + t.soname = cur.read_string(); + } else { + // unknown subfield — skip its value + cur.skip_ws_and_comments(); + if (cur.peek() == '{') cur.skip_table(); + else (void)cur.read_bareword(); + } + cur.skip_ws_and_comments(); + } + cur.consume('}'); + if (auto msg = validate_target_soname(t, std::format("targets.{}.", tname))) { + return std::unexpected(ManifestError{*msg, m.sourcePath, 0, 0}); + } + m.targets.push_back(std::move(t)); + cur.skip_ws_and_comments(); + } + cur.consume('}'); + } + else if (key == "features") { + // `{ ["main"] = { sources = { "*/gtest_main.cc" } }, ... }` + // Registers the feature (so it's a known feature) and, when it + // carries `sources`, records them as feature-gated source globs + // (excluded by default; included only when the feature is active — + // resolved in prepare_build). A feature with no `sources` is still + // registered (empty implied set) so it can be requested/validated. + if (!cur.consume('{')) { + return std::unexpected(ManifestError{ + "expected '{' after `features =`", m.sourcePath, 0, 0}); + } + cur.skip_ws_and_comments(); + while (!cur.eof() && cur.peek() != '}') { + auto fname = cur.read_key(); + if (fname.empty()) { cur.skip_ws_and_comments(); break; } + cur.skip_ws_and_comments(); + if (!cur.consume('=')) break; + cur.skip_ws_and_comments(); + if (!cur.consume('{')) break; + // register the feature (no implied features for now) + m.featuresMap.try_emplace(fname, std::vector{}); + cur.skip_ws_and_comments(); + while (!cur.eof() && cur.peek() != '}') { + auto sub = cur.read_key(); + cur.skip_ws_and_comments(); + if (!cur.consume('=')) break; + cur.skip_ws_and_comments(); + if (sub == "deps" && cur.peek() == '{') { + // Feature-activated optional deps (Stage 2a): + // deps = { ["compat.openblas"] = "0.3.x", ... } + // Same flat/dotted form as the top-level `deps` table. + cur.consume('{'); + cur.skip_ws_and_comments(); + while (!cur.eof() && cur.peek() != '}') { + auto dname = cur.read_key(); + if (dname.empty()) break; + cur.skip_ws_and_comments(); + if (!cur.consume('=')) break; + cur.skip_ws_and_comments(); + auto dver = cur.read_string(); + DependencySpec spec; + spec.version = dver; + auto selector = mcpp::pm::resolve_dependency_selector( + dname, + mcpp::pm::DependencySelectorMode::OmittedMcpplibsPriority); + if (!selector.candidates.empty()) { + spec.namespace_ = selector.candidates.front().namespace_; + spec.shortName = selector.candidates.front().shortName; + spec.candidates = std::move(selector.candidates); + m.featureDeps[fname][selector.stableMapKey] = std::move(spec); + } + cur.skip_ws_and_comments(); + } + cur.consume('}'); + } else { + // Feature subfields that carry a string array. `sources` + // gates source globs; `defines` carries package-owned macros + // (Stage 1); `requires`/`provides` declare capabilities + // (Stage 3). All share the `{ "...", ... }` shape. + std::vector* arr = + sub == "implies" ? &m.featuresMap[fname] + : sub == "sources" ? &m.buildConfig.featureSources[fname] + : sub == "defines" ? &m.buildConfig.featureDefines[fname] + : sub == "requires" ? &m.featureRequires[fname] + : sub == "provides" ? &m.featureProvides[fname] + : nullptr; + if (arr && cur.peek() == '{') { + cur.consume('{'); + cur.skip_ws_and_comments(); + while (!cur.eof() && cur.peek() != '}') { + auto s = cur.read_string(); + if (!s.empty()) arr->push_back(std::move(s)); + cur.skip_ws_and_comments(); + } + cur.consume('}'); + } else { + // unknown subfield — skip its value + if (cur.peek() == '{') cur.skip_table(); + else (void)cur.read_bareword(); + } + } + cur.skip_ws_and_comments(); + } + cur.consume('}'); + cur.skip_ws_and_comments(); + } + cur.consume('}'); + } + else if (key == "deps") { + // `{ ["name"] = "version", ["ns.name"] = "version", ... }` + // The mcpp segment uses the flat / dotted form only — namespaced + // subtables would require a richer Lua parser than we have here, + // and the same expressivity is reachable by writing + // ["mcpplibs.cmdline"] = "0.0.2" + // which the consumer side accepts identically. + if (!cur.consume('{')) { + return std::unexpected(ManifestError{ + "expected '{' after `deps =`", m.sourcePath, 0, 0}); + } + cur.skip_ws_and_comments(); + while (!cur.eof() && cur.peek() != '}') { + auto dname = cur.read_key(); + if (dname.empty()) break; + cur.skip_ws_and_comments(); + if (!cur.consume('=')) break; + cur.skip_ws_and_comments(); + auto dver = cur.read_string(); + if (!dname.empty()) { + DependencySpec spec; + spec.version = dver; + auto selector = mcpp::pm::resolve_dependency_selector( + dname, + mcpp::pm::DependencySelectorMode::OmittedMcpplibsPriority); + if (!selector.candidates.empty()) { + spec.namespace_ = selector.candidates.front().namespace_; + spec.shortName = selector.candidates.front().shortName; + spec.candidates = std::move(selector.candidates); + m.dependencies[selector.stableMapKey] = std::move(spec); + } + } + cur.skip_ws_and_comments(); + } + cur.consume('}'); + } + else if (key == "cflags" || key == "cxxflags" || key == "ldflags") { + // `{ "-Dfoo", "-Wall", ... }` — appended to the per-rule baseline + // by ninja_backend. cflags goes to the C rule (.c files), cxxflags + // to C++ rule (.cpp/.cc/.cxx/.cppm), ldflags to link commands. + if (!cur.consume('{')) { + return std::unexpected(ManifestError{ + std::format("expected '{{' after `{} =`", key), + m.sourcePath, 0, 0}); + } + cur.skip_ws_and_comments(); + auto& target = (key == "cflags") + ? m.buildConfig.cflags + : (key == "cxxflags" ? m.buildConfig.cxxflags : m.buildConfig.ldflags); + while (!cur.eof() && cur.peek() != '}') { + auto s = cur.read_string(); + if (key == "cxxflags" && starts_with_std_flag(s)) { + return std::unexpected(ManifestError{ + std::format("cxxflags contains '{}'; use language/package standard to configure the C++ language standard", + s), + m.sourcePath, 0, 0}); + } + if (!s.empty()) target.push_back(std::move(s)); + cur.skip_ws_and_comments(); + } + cur.consume('}'); + } + else if (key == "c_standard") { + auto v = cur.read_string(); + if (!v.empty()) m.buildConfig.cStandard = v; + } + else if (key == "runtime") { + auto runtimeBody = cur.read_table_body(); + LuaCursor rc { runtimeBody }; + rc.skip_ws_and_comments(); + while (!rc.eof()) { + auto sub = rc.read_key(); + if (sub.empty()) { + rc.skip_ws_and_comments(); + if (rc.eof()) break; + ++rc.pos; + continue; + } + rc.skip_ws_and_comments(); + if (!rc.consume('=')) { + return std::unexpected(ManifestError{ + std::format("malformed runtime segment near key '{}'", sub), + m.sourcePath, 0, 0}); + } + rc.skip_ws_and_comments(); + auto read_string_list = [&](std::vector& out) + -> std::expected + { + if (!rc.consume('{')) { + return std::unexpected(ManifestError{ + std::format("expected '{{' after `runtime.{} =`", sub), + m.sourcePath, 0, 0}); + } + rc.skip_ws_and_comments(); + while (!rc.eof() && rc.peek() != '}') { + auto s = rc.read_string(); + if (!s.empty()) out.push_back(std::move(s)); + rc.skip_ws_and_comments(); + } + rc.consume('}'); + return {}; + }; + if (sub == "library_dirs") { + std::vector dirs; + if (auto r = read_string_list(dirs); !r) return std::unexpected(r.error()); + for (auto& d : dirs) m.runtimeConfig.libraryDirs.emplace_back(std::move(d)); + } else if (sub == "dlopen_libs") { + if (auto r = read_string_list(m.runtimeConfig.dlopenLibs); !r) + return std::unexpected(r.error()); + } else if (sub == "capabilities") { + if (auto r = read_string_list(m.runtimeConfig.capabilities); !r) + return std::unexpected(r.error()); + } else if (sub == "provides") { + if (auto r = read_string_list(m.runtimeConfig.provides); !r) + return std::unexpected(r.error()); + } else { + rc.skip_ws_and_comments(); + if (rc.at_string_start()) (void)rc.read_string(); + else if (rc.peek() == '{') rc.skip_table(); + else (void)rc.read_bareword(); + } + rc.skip_ws_and_comments(); + } + } + else if (key == "scan_overrides") { + // `{ ["glob"] = { provides = { "m" }, imports = { "std" } }, ... }` + // Author-asserted scan results (manifest:types ScanOverride): + // matched files bypass the M1 text scan; verified against the + // compiler's P1689 output at build time. + if (!cur.consume('{')) { + return std::unexpected(ManifestError{ + "expected '{' after `scan_overrides =`", m.sourcePath, 0, 0}); + } + cur.skip_ws_and_comments(); + while (!cur.eof() && cur.peek() != '}') { + auto glob = cur.read_key(); + if (glob.empty()) break; + cur.skip_ws_and_comments(); + if (!cur.consume('=') || !cur.consume('{')) { + return std::unexpected(ManifestError{ + std::format("malformed scan_overrides entry '{}'", glob), + m.sourcePath, 0, 0}); + } + ScanOverride ov; + cur.skip_ws_and_comments(); + while (!cur.eof() && cur.peek() != '}') { + auto sub = cur.read_key(); + cur.skip_ws_and_comments(); + if (sub.empty() || !cur.consume('=')) break; + cur.skip_ws_and_comments(); + std::vector* arr = + sub == "provides" ? &ov.provides + : sub == "imports" ? &ov.imports + : nullptr; + if (arr && cur.peek() == '{') { + cur.consume('{'); + cur.skip_ws_and_comments(); + while (!cur.eof() && cur.peek() != '}') { + auto s = cur.read_string(); + if (!s.empty()) arr->push_back(std::move(s)); + cur.skip_ws_and_comments(); + } + cur.consume('}'); + } else { + return std::unexpected(ManifestError{ + std::format("scan_overrides '{}': unknown subfield '{}' " + "(expected provides / imports)", glob, sub), + m.sourcePath, 0, 0}); + } + cur.skip_ws_and_comments(); + } + cur.consume('}'); + if (ov.provides.empty() && ov.imports.empty()) { + return std::unexpected(ManifestError{ + std::format("scan_overrides '{}' declares neither provides " + "nor imports", glob), + m.sourcePath, 0, 0}); + } + m.modules.scanOverrides.emplace(std::move(glob), std::move(ov)); + cur.skip_ws_and_comments(); + } + cur.consume('}'); + } + else if (key == "linux" || key == "macosx" || key == "windows") { + // Per-platform sub-table. The CURRENT platform's body was already + // appended to the segment before this loop (see above), so every + // spelling here is a known key: skip the table itself. + if (cur.peek() == '{') cur.skip_table(); + } + else if (key == "schema") { + // Descriptor schema tag (e.g. "0.1") — accepted, currently + // informational only. + (void)cur.read_string(); + } + else { + // Unknown key — skip the value (string / bareword / table), but + // record the key so `mcpp xpkg parse` can warn (schema evolution + // must be loud, not silently ignored). + m.xpkgUnknownKeys.push_back(key); + cur.skip_ws_and_comments(); + if (cur.at_string_start()) (void)cur.read_string(); + else if (cur.peek() == '{') cur.skip_table(); + else (void)cur.read_bareword(); + } + } + + // Validate minimum + if (m.modules.sources.empty()) { + return std::unexpected(ManifestError{ + "synthesised manifest missing sources (mcpp segment must declare `sources = { ... }`)", + m.sourcePath, 0, 0}); + } + if (m.targets.empty()) { + // Default to a library target with the same name as the package. + Target t; + t.name = m.package.name; + // For dotted names like mcpplibs.cmdline, take the last segment. + auto dot = t.name.find_last_of('.'); + if (dot != std::string::npos) t.name = t.name.substr(dot + 1); + t.kind = Target::Library; + m.targets.push_back(std::move(t)); + } + + auto stdCfg = normalize_cpp_standard(m.package.standard); + if (!stdCfg) { + return std::unexpected(ManifestError{stdCfg.error(), m.sourcePath, 0, 0}); + } + m.cppStandard = *stdCfg; + m.package.standard = m.cppStandard.canonical; + m.language.standard = m.cppStandard.canonical; + + return m; +} + +} // namespace mcpp::manifest diff --git a/src/modgraph/graph.cppm b/src/modgraph/graph.cppm index 84f7dda..9f9fefa 100644 --- a/src/modgraph/graph.cppm +++ b/src/modgraph/graph.cppm @@ -22,6 +22,9 @@ struct SourceUnit { std::vector requires_; bool isModuleInterface = false; // .cppm with export module bool isImplementation = false; // .cpp without export + // Unit built from a manifest scan_overrides declaration instead of a + // real scan — plan-vs-ddi verification is mandatory for these. + bool scanOverridden = false; }; struct Graph { diff --git a/src/modgraph/scanner.cppm b/src/modgraph/scanner.cppm index 83e0819..2032a80 100644 --- a/src/modgraph/scanner.cppm +++ b/src/modgraph/scanner.cppm @@ -439,7 +439,45 @@ void scan_one_into(ScanResult& result, manifest.package.namespace_.empty() ? manifest.package.name : manifest.package.namespace_ + "." + manifest.package.name; + // scan_overrides: author-asserted units bypass the text scan entirely. + // Every override glob must match at least one collected source file — + // an unmatched glob is a typo or a stale declaration, not a no-op. + std::map overrideHits; + for (auto const& [glob, ov] : manifest.modules.scanOverrides) + overrideHits[glob] = 0; + for (auto const& f : all_files) { + const mcpp::manifest::ScanOverride* ov = nullptr; + for (auto const& [glob, o] : manifest.modules.scanOverrides) { + if (path_matches_glob(f, root, glob)) { + ov = &o; + ++overrideHits[glob]; + break; + } + } + if (ov) { + SourceUnit u; + u.path = f; + u.packageName = qualifiedName; + u.scanOverridden = true; + if (!ov->provides.empty()) { + u.provides = ModuleId{ov->provides.front()}; + u.isModuleInterface = true; + if (ov->provides.size() > 1) { + result.errors.push_back(ScanError{f, 0, + "scan_overrides: a unit may declare at most one " + "provided module"}); + continue; + } + } + for (auto const& name : ov->imports) + u.requires_.push_back(ModuleId{name}); + u.localIncludeDirs = localIncludeDirs; + u.packageCflags = packageCflags; + u.packageCxxflags = packageCxxflags; + result.graph.units.push_back(std::move(u)); + continue; + } auto r = scan_file(f, qualifiedName); if (!r) { result.errors.push_back(r.error()); @@ -450,6 +488,14 @@ void scan_one_into(ScanResult& result, r->packageCxxflags = packageCxxflags; result.graph.units.push_back(std::move(*r)); } + + for (auto const& [glob, hits] : overrideHits) { + if (hits == 0) { + result.errors.push_back(ScanError{root, 0, std::format( + "scan_overrides glob '{}' matched no source file " + "(typo, or the glob is not covered by `sources`)", glob)}); + } + } } // Phase 2: producerOf + edges over already-collected units. diff --git a/src/pm/index_contract.cppm b/src/pm/index_contract.cppm new file mode 100644 index 0000000..1ee3d4a --- /dev/null +++ b/src/pm/index_contract.cppm @@ -0,0 +1,114 @@ +// mcpp.pm.index_contract — the index→client version contract. +// +// An index tree (a directory containing pkgs/) may carry an `index.toml` +// at its root: +// +// [index] +// spec = "1" # index layout spec +// min_mcpp = "0.0.85" # oldest mcpp able to parse every descriptor +// latest_mcpp = "0.0.85" # optional: newest known-good mcpp (hint) +// +// The contract travels WITH the tree (git checkout, unpacked artifact, +// CI-restored cache, `[indices] path =` local dir), so one check at the +// index-open choke point covers every transport, offline included. +// Missing index.toml → no constraint (back-compat, third-party indices). +// +// Escape hatch: MCPP_INDEX_FLOOR=ignore (debugging). +// Design: .agents/docs/2026-07-08-index-version-semantics-and-descriptor- +// grammar-design.md (D3). + +export module mcpp.pm.index_contract; + +import std; +import mcpp.libs.toml; +import mcpp.version_req; +import mcpp.toolchain.fingerprint; // MCPP_VERSION + +export namespace mcpp::pm { + +struct IndexContract { + std::string spec; // index layout spec ("1") + std::string minMcpp; // floor: oldest client able to parse the tree + std::string latestMcpp; // optional upgrade hint +}; + +// Read /index.toml. nullopt when absent or unreadable +// (absence is not an error — it simply means "no contract"). +std::optional +read_index_contract(const std::filesystem::path& indexRoot); + +// Pure floor predicate: does `ownVersion` satisfy `minMcpp`? +// Returns the violation message when it does not; nullopt when fine +// (including unparsable versions — the contract must never brick a +// client by being malformed). +std::optional +floor_violation(std::string_view minMcpp, std::string_view ownVersion); + +// Open-time check for an index tree. Combines read + floor + escape +// hatch + once-per-root deduplication of the (expensive to spam) error. +// Returns the violation message the FIRST time a too-new tree is opened; +// nullopt otherwise. +std::optional +check_index_floor(const std::filesystem::path& indexRoot); + +} // namespace mcpp::pm + +namespace mcpp::pm { + +std::optional +read_index_contract(const std::filesystem::path& indexRoot) +{ + std::error_code ec; + auto file = indexRoot / "index.toml"; + if (!std::filesystem::exists(file, ec)) return std::nullopt; + + std::ifstream is{file}; + if (!is) return std::nullopt; + std::string body{std::istreambuf_iterator(is), {}}; + + auto doc = mcpp::libs::toml::parse(body); + if (!doc) return std::nullopt; + + IndexContract c; + if (auto v = doc->get_string("index.spec")) c.spec = *v; + if (auto v = doc->get_string("index.min_mcpp")) c.minMcpp = *v; + if (auto v = doc->get_string("index.latest_mcpp")) c.latestMcpp = *v; + return c; +} + +std::optional +floor_violation(std::string_view minMcpp, std::string_view ownVersion) +{ + if (minMcpp.empty()) return std::nullopt; + auto need = mcpp::version_req::parse_version(minMcpp); + auto have = mcpp::version_req::parse_version(ownVersion); + if (!need || !have) return std::nullopt; // malformed contract never bricks + if (*have >= *need) return std::nullopt; + return std::format( + "index requires mcpp >= {} but this is mcpp {} [E0006]\n" + " Upgrade: curl -fsSL https://github.com/mcpp-community/mcpp/" + "releases/latest/download/install.sh | bash\n" + " Details: mcpp explain E0006 " + "(override for debugging: MCPP_INDEX_FLOOR=ignore)", + minMcpp, ownVersion); +} + +std::optional +check_index_floor(const std::filesystem::path& indexRoot) +{ + if (const char* v = std::getenv("MCPP_INDEX_FLOOR"); + v && std::string_view(v) == "ignore") + return std::nullopt; + + // Once per root per process: the same index is opened many times in a + // single resolve; report the violation once, stay quiet after. + static std::set reported; + auto c = read_index_contract(indexRoot); + if (!c) return std::nullopt; + auto violation = floor_violation(c->minMcpp, mcpp::toolchain::MCPP_VERSION); + if (!violation) return std::nullopt; + if (!reported.insert(indexRoot).second) return std::nullopt; + return violation; +} + +} // namespace mcpp::pm diff --git a/src/pm/package_fetcher.cppm b/src/pm/package_fetcher.cppm index e917e72..fedbce3 100644 --- a/src/pm/package_fetcher.cppm +++ b/src/pm/package_fetcher.cppm @@ -18,6 +18,8 @@ import mcpp.log; import mcpp.manifest; // xpkg_lua_identity_matches — descriptor identity gate import mcpp.pm.compat; import mcpp.pm.dep_spec; +import mcpp.pm.index_contract; // index.toml floor check (E0006) +import mcpp.ui; import mcpp.pm.index_spec; import mcpp.xlings; import mcpp.libs.toml; // re-used for tiny JSON-ish parsing? no — stick with manual @@ -431,6 +433,16 @@ read_identity_verified_xpkg_lua(const std::filesystem::path& pkgsDir, const std::vector& filenames, std::string_view indexDefaultNs = {}) { + // Index→client version contract: the tree carries its own floor + // (/index.toml min_mcpp). Checked here — the single choke + // point every transport converges on (artifact snapshot, git clone, + // [indices] path, CI-restored cache). Loud once per index; the + // resolve then fails as not-found with the cause already printed. + if (auto violation = mcpp::pm::check_index_floor(pkgsDir.parent_path())) { + mcpp::ui::error(*violation); + return std::nullopt; + } + std::error_code ec; if (!std::filesystem::exists(pkgsDir, ec)) return std::nullopt; for (auto& fname : filenames) { diff --git a/src/toolchain/fingerprint.cppm b/src/toolchain/fingerprint.cppm index 78bcadd..3ff167e 100644 --- a/src/toolchain/fingerprint.cppm +++ b/src/toolchain/fingerprint.cppm @@ -18,7 +18,7 @@ import mcpp.toolchain.detect; export namespace mcpp::toolchain { -inline constexpr std::string_view MCPP_VERSION = "0.0.84"; +inline constexpr std::string_view MCPP_VERSION = "0.0.85"; struct FingerprintInputs { Toolchain toolchain; diff --git a/tests/e2e/93_xpkg_parse.sh b/tests/e2e/93_xpkg_parse.sh new file mode 100755 index 0000000..5aed930 --- /dev/null +++ b/tests/e2e/93_xpkg_parse.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# requires: +# 93_xpkg_parse.sh — `mcpp xpkg parse`: descriptor grammar v2 (long +# brackets) + strict-by-default unknown-key detection + --json. +# +# Single-source-of-truth lint: this command parses with EXACTLY the +# resolver's grammar, so what passes here is what builds for users. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" + +# ── 1. valid descriptor using BOTH string spellings + block comment ── +cat > good.lua <<'EOF' +--[==[ decoy block comment: mcpp = { sources = { "WRONG" } } }}}} ]==] +package = { + spec = "1", + namespace = "demo", + name = "demo.gen", + xpm = { linux = { ["1.0.0"] = { url = "u", sha256 = "h" } } }, + mcpp = { + schema = "0.1", + sources = { [[mcpp_generated/gen.cc]] }, + generated_files = { + ["mcpp_generated/gen.cc"] = [==[ +export module gen; +int f() { return "x]]y"[0]; } +]==], + }, + targets = { ["gen"] = { kind = "lib" } }, + }, +} +EOF + +"$MCPP" xpkg parse good.lua | tee parse.out +grep -q "parse OK" parse.out +grep -q "demo.gen" parse.out +grep -q "34 bytes\|generated" parse.out + +# ── 2. --json round-trips the generated content verbatim ── +"$MCPP" xpkg parse --json good.lua > parse.json +python3 - <<'PY' +import json +j = json.load(open("parse.json")) +assert j["namespace"] == "demo" and j["name"] == "gen", j +c = j["generated_contents"]["mcpp_generated/gen.cc"] +assert c == 'export module gen;\nint f() { return "x]]y"[0]; }\n', repr(c) +assert j["unknown_keys"] == [], j +print("json ok") +PY + +# ── 3. unknown key: error by default, warning with --allow-unknown ── +sed 's/schema = "0.1",/schema = "0.1", bogus_key = "x",/' good.lua > bad.lua +if "$MCPP" xpkg parse bad.lua > /dev/null 2>&1; then + echo "FAIL: unknown key should be an error by default"; exit 1 +fi +"$MCPP" xpkg parse bad.lua 2>&1 | grep -q "unknown mcpp-segment key 'bogus_key'" +"$MCPP" xpkg parse --allow-unknown bad.lua > /dev/null + +# ── 4. malformed segment: exact resolver error surfaces ── +cat > broken.lua <<'EOF' +package = { + spec = "1", + name = "broken", + xpm = { linux = { ["1.0.0"] = { url = "u", sha256 = "h" } } }, + mcpp = { sources = { }, +} +EOF +if "$MCPP" xpkg parse broken.lua > /dev/null 2>&1; then + echo "FAIL: broken descriptor should fail"; exit 1 +fi + +echo "PASS 93_xpkg_parse" diff --git a/tests/unit/test_dyndep.cpp b/tests/unit/test_dyndep.cpp index 4409218..c477413 100644 --- a/tests/unit/test_dyndep.cpp +++ b/tests/unit/test_dyndep.cpp @@ -104,3 +104,33 @@ TEST(Dyndep, EmitDyndepFromFiles) { std::filesystem::remove_all(tmp); } + +// ── plan-vs-ddi reconciliation (scan_overrides auditor) ── + +TEST(VerifyUnitExpectations, MatchPasses) { + mcpp::dyndep::UnitInfo u; + u.primaryOutput = "obj/fmt.o"; + u.provides = {"fmt"}; + u.requires_ = {"std"}; + auto err = mcpp::dyndep::verify_unit_expectations(u, "fmt", {"std"}); + EXPECT_FALSE(err.has_value()) << *err; +} + +TEST(VerifyUnitExpectations, DivergenceReportsBothSides) { + mcpp::dyndep::UnitInfo u; + u.primaryOutput = "obj/fmt.o"; + u.provides = {"fmt"}; + u.requires_ = {"std"}; + auto err = mcpp::dyndep::verify_unit_expectations(u, "fmt", {}); + ASSERT_TRUE(err.has_value()); + EXPECT_NE(err->find("divergence"), std::string::npos); + EXPECT_NE(err->find("planned"), std::string::npos); + EXPECT_NE(err->find("std"), std::string::npos); +} + +TEST(VerifyUnitExpectations, ExpectNoneMatchesEmptyUnit) { + mcpp::dyndep::UnitInfo u; + u.primaryOutput = "obj/plain.o"; + auto err = mcpp::dyndep::verify_unit_expectations(u, std::nullopt, {}); + EXPECT_FALSE(err.has_value()); +} diff --git a/tests/unit/test_index_contract.cpp b/tests/unit/test_index_contract.cpp new file mode 100644 index 0000000..5590c5a --- /dev/null +++ b/tests/unit/test_index_contract.cpp @@ -0,0 +1,37 @@ +#include + +import std; +import mcpp.pm.index_contract; + +TEST(IndexContract, FloorViolationOrdering) { + using mcpp::pm::floor_violation; + EXPECT_FALSE(floor_violation("0.0.85", "0.0.85").has_value()); + EXPECT_FALSE(floor_violation("0.0.85", "0.0.90").has_value()); + EXPECT_FALSE(floor_violation("0.0.85", "1.0.0").has_value()); + auto v = floor_violation("0.0.85", "0.0.84"); + ASSERT_TRUE(v.has_value()); + EXPECT_NE(v->find("E0006"), std::string::npos); + EXPECT_NE(v->find("0.0.85"), std::string::npos); +} + +TEST(IndexContract, EmptyOrMalformedNeverBricks) { + using mcpp::pm::floor_violation; + EXPECT_FALSE(floor_violation("", "0.0.84").has_value()); + EXPECT_FALSE(floor_violation("not-a-version", "0.0.84").has_value()); +} + +TEST(IndexContract, ReadContractRoundTrip) { + auto dir = std::filesystem::temp_directory_path() / "mcpp_ic_test"; + std::filesystem::create_directories(dir); + { + std::ofstream os(dir / "index.toml"); + os << "[index]\nspec = \"1\"\nmin_mcpp = \"0.0.85\"\nlatest_mcpp = \"0.0.86\"\n"; + } + auto c = mcpp::pm::read_index_contract(dir); + ASSERT_TRUE(c.has_value()); + EXPECT_EQ(c->spec, "1"); + EXPECT_EQ(c->minMcpp, "0.0.85"); + EXPECT_EQ(c->latestMcpp, "0.0.86"); + std::filesystem::remove_all(dir); + EXPECT_FALSE(mcpp::pm::read_index_contract(dir).has_value()); +} diff --git a/tests/unit/test_manifest.cpp b/tests/unit/test_manifest.cpp index efb7e56..2ab9851 100644 --- a/tests/unit/test_manifest.cpp +++ b/tests/unit/test_manifest.cpp @@ -1343,3 +1343,174 @@ TEST(CanonicalIdentity, FromLuaReadsDeclaredFields) { EXPECT_EQ(mcpp::manifest::canonical_xpkg_identity_from_lua(noName), want("", "")); } + +// ── Descriptor grammar v2: Lua long-bracket strings + block comments ── +// Design: .agents/docs/2026-07-08-index-version-semantics-and-descriptor-grammar-design.md (D1) + +TEST(XpkgLongBracket, GeneratedFilesMultiline) { + // Content carries braces, quotes, an embedded `]]` (level-2 brackets), + // and a leading newline that Lua semantics strip. + constexpr auto lua = R"( +package = { + spec = "1", + name = "lbtest", + xpm = { linux = { ["1.0.0"] = { url = "u", sha256 = "h" } } }, + mcpp = { + sources = { "mcpp_generated/gen.cc" }, + generated_files = { + ["mcpp_generated/gen.cc"] = [==[ +export module gen; +int f() { return "x]]y"[0]; } +]==], + }, + targets = { ["gen"] = { kind = "lib" } }, + }, +} +)"; + auto m = mcpp::manifest::synthesize_from_xpkg_lua(lua, "lbtest", "1.0.0"); + ASSERT_TRUE(m.has_value()) << m.error().format(); + ASSERT_TRUE(m->buildConfig.generatedFiles.contains("mcpp_generated/gen.cc")); + EXPECT_EQ(m->buildConfig.generatedFiles["mcpp_generated/gen.cc"], + "export module gen;\nint f() { return \"x]]y\"[0]; }\n"); +} + +TEST(XpkgLongBracket, BlockCommentWithBracesAndFakeMcpp) { + // A block comment containing braces and the literal text `mcpp = {` + // must neither corrupt the structural depth count nor false-match + // the segment locator. + constexpr auto lua = R"( +--[==[ +decoy: mcpp = { sources = { "WRONG" } } }}}} +]==] +package = { + spec = "1", + name = "cmt", + xpm = { linux = { ["1.0.0"] = { url = "u", sha256 = "h" } } }, + mcpp = { + --[[ inline block comment { with braces } ]] + sources = { [[src/*.cpp]] }, + targets = { ["cmt"] = { kind = "lib" } }, + }, +} +)"; + auto m = mcpp::manifest::synthesize_from_xpkg_lua(lua, "cmt", "1.0.0"); + ASSERT_TRUE(m.has_value()) << m.error().format(); + ASSERT_EQ(m->modules.sources.size(), 1u); + EXPECT_EQ(m->modules.sources[0], "src/*.cpp"); +} + +TEST(XpkgLongBracket, NoLeadingNewlineKeptVerbatim) { + // Without a newline after the opening bracket, content starts + // immediately; CRLF right after the bracket is stripped as one break. + constexpr auto lua = + "package = {\n" + " spec = \"1\",\n" + " name = \"nl\",\n" + " xpm = { linux = { [\"1.0.0\"] = { url = \"u\", sha256 = \"h\" } } },\n" + " mcpp = {\n" + " sources = { \"g.cc\" },\n" + " generated_files = { [\"g.cc\"] = [[inline]], [\"h.cc\"] = [[\r\ncrlf]] },\n" + " targets = { [\"nl\"] = { kind = \"lib\" } },\n" + " },\n" + "}\n"; + auto m = mcpp::manifest::synthesize_from_xpkg_lua(lua, "nl", "1.0.0"); + ASSERT_TRUE(m.has_value()) << m.error().format(); + EXPECT_EQ(m->buildConfig.generatedFiles["g.cc"], "inline"); + EXPECT_EQ(m->buildConfig.generatedFiles["h.cc"], "crlf"); +} + +TEST(XpkgLongBracket, UnknownKeysRecordedSchemaAccepted) { + constexpr auto lua = R"( +package = { + spec = "1", + name = "uk", + xpm = { linux = { ["1.0.0"] = { url = "u", sha256 = "h" } } }, + mcpp = { + schema = "0.1", + sources = { "a.c" }, + bogus_key = "x", + future_table = { nested = { 1, 2 } }, + targets = { ["uk"] = { kind = "lib" } }, + }, +} +)"; + auto m = mcpp::manifest::synthesize_from_xpkg_lua(lua, "uk", "1.0.0"); + ASSERT_TRUE(m.has_value()) << m.error().format(); + ASSERT_EQ(m->xpkgUnknownKeys.size(), 2u); + EXPECT_EQ(m->xpkgUnknownKeys[0], "bogus_key"); + EXPECT_EQ(m->xpkgUnknownKeys[1], "future_table"); +} + +// ── scan_overrides: author-asserted scan results ── +// Design: .agents/docs/2026-07-08-scanner-backend-abstraction-design.md §3-pre + +TEST(ScanOverrides, XpkgSegmentParses) { + constexpr auto lua = R"( +package = { + spec = "1", + namespace = "fmtlib", + name = "fmtlib.fmt", + xpm = { linux = { ["12.2.0"] = { url = "u", sha256 = "h" } } }, + mcpp = { + sources = { "*/src/fmt.cc" }, + cxxflags = { "-DFMT_IMPORT_STD" }, + scan_overrides = { + ["*/src/fmt.cc"] = { provides = { "fmt" }, imports = { "std" } }, + }, + targets = { ["fmt"] = { kind = "lib" } }, + }, +} +)"; + auto m = mcpp::manifest::synthesize_from_xpkg_lua(lua, "fmtlib.fmt", "12.2.0"); + ASSERT_TRUE(m.has_value()) << m.error().format(); + ASSERT_EQ(m->modules.scanOverrides.size(), 1u); + auto& ov = m->modules.scanOverrides.at("*/src/fmt.cc"); + ASSERT_EQ(ov.provides.size(), 1u); + EXPECT_EQ(ov.provides[0], "fmt"); + ASSERT_EQ(ov.imports.size(), 1u); + EXPECT_EQ(ov.imports[0], "std"); + EXPECT_TRUE(m->xpkgUnknownKeys.empty()); +} + +TEST(ScanOverrides, XpkgRejectsEmptyDeclaration) { + constexpr auto lua = R"( +package = { + spec = "1", + name = "so", + xpm = { linux = { ["1.0.0"] = { url = "u", sha256 = "h" } } }, + mcpp = { + sources = { "a.cc" }, + scan_overrides = { ["a.cc"] = { } }, + targets = { ["so"] = { kind = "lib" } }, + }, +} +)"; + auto m = mcpp::manifest::synthesize_from_xpkg_lua(lua, "so", "1.0.0"); + ASSERT_FALSE(m.has_value()); + EXPECT_NE(m.error().message.find("neither provides nor imports"), + std::string::npos) << m.error().format(); +} + +TEST(ScanOverrides, TomlParses) { + constexpr auto src = R"( +[package] +name = "app" +version = "0.1.0" +[language] +standard = "c++23" +[modules] +sources = ["src/**/*.cppm", "vendor/fmt.cc"] +["scan_overrides"."vendor/fmt.cc"] +provides = ["fmt"] +imports = ["std"] +[targets.app] +kind = "bin" +main = "src/main.cpp" +)"; + auto m = mcpp::manifest::parse_string(src); + ASSERT_TRUE(m.has_value()) << m.error().format(); + ASSERT_EQ(m->modules.scanOverrides.size(), 1u); + auto& ov = m->modules.scanOverrides.at("vendor/fmt.cc"); + EXPECT_EQ(ov.provides, std::vector{"fmt"}); + EXPECT_EQ(ov.imports, std::vector{"std"}); +}