Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 177 additions & 0 deletions .agents/docs/2026-07-26-add-catch2-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
# Design doc: add Catch2 to mcpp-index

Date: 2026-07-26 (revised 2026-07-27 — single package, see §"Revision")

## Source

- Repo: [catchorg/Catch2](https://github.com/catchorg/Catch2)
- License: BSL-1.0 (Boost Software License 1.0)

Catch2 has two major versions with incompatible APIs and source layouts:

| Version | Source layout | Resulting shape |
|---------|--------------|-----------------|
| 2.13.10 | `single_include/catch2/catch.hpp` (single amalgamated header) | **header-only** |
| 3.15.2 | `src/catch2/**/*.{hpp,cpp}` (individual source files) | **static library** |

## Revision: one package, not two

The first draft of this design shipped **two** packages, `compat.catch2` (v3)
and `compat.catch2-v2` (v2), on the reasoning that "the v2→v3 jump changed the
repo layout completely, so the two versions cannot share a single `mcpp`
block."

The premise is right; the conclusion was not. The two layouts' globs are
**disjoint**, so a single `mcpp` block can carry the **union** of both and let
each version light up exactly one half:

| glob | 2.13.10 | 3.15.2 |
|---|---|---|
| `*/single_include` | exists | absent |
| `*/src/catch2/**/*.cpp` | **0 matches** — v2's only `src/` file is `src/catch_with_main.cpp`, *not* under `src/catch2/` | 107 |
| `catch2/catch_all.hpp` | absent | exists |

An `include_dirs` entry whose glob matches nothing is not an error, and a
`sources` glob that matches nothing simply contributes no TUs — which is what
makes the union safe. At 2.13.10 the package degenerates to header-only
(`single_include` + an anchor TU); at 3.15.2 it is a 106-TU static library.

The last row doubles as a compile-time major discriminator:
`__has_include(<catch2/catch_all.hpp>)`.

### Why one package is the better shape regardless

- **A version does not belong in `name`.** `catch2-v2` smuggles a major into
the atomic name segment that SPEC-001 identity reserves for the name alone —
the same anti-pattern the `compat.openssl` → `openssl` rename removed.
- **Semver stops working across the split.** With two packages a consumer
cannot write `catch2 = "^3"`, and version resolution cannot see v2 and v3 as
the same library.
- **Discovery.** One library should not answer to two package names.
- **It is a one-way door.** Once `compat.catch2-v2` ships in a published index
artifact, withdrawing it is a breaking change. The decision had to be made
before merge, not after.

### Relationship to mcpp#290

[mcpp-community/mcpp#290](https://github.com/mcpp-community/mcpp/issues/290)
asks for per-version `mcpp` build blocks. It is **not** a prerequisite here —
the merge works on mcpp 0.0.109 today.

Nor is Catch2 the case #290 argues from. That issue's motivating example
(llama.cpp b10069 vs b10107) shares ~95% of its build rules and differs in a
few source files. Catch2 v2/v3 share essentially *nothing* but boilerplate
(`language`, `import_std`, `c_standard`, `targets`, `deps`); they merge only
because their globs happen not to overlap.

That "happen not to" is the weak point, and it is exactly what #290 would fix:
the disjointness is a property of two upstream trees, not something this
descriptor can enforce. When #290 lands, this collapses into explicit
`["2.x"]` / `["3.x"]` blocks and the implicit assumption disappears. Until
then the premise is documented in the descriptor header and must be re-checked
whenever a version is added.

## Package shape

- **Sources**: anchor TU + `*/src/catch2/**/*.cpp` minus
`!*/src/catch2/internal/catch_main.cpp` (107 files, 106 compiled).
- **include_dirs**: `{ "*/single_include", "*/src", "mcpp_generated" }`.
- **Anchor** (`mcpp_generated/catch2_anchor.c`): required at v2, where the
sources glob matches nothing and a lib target still needs a TU. Same shape
as compat.eigen / compat.khrplatform. Inert at v3.
- **catch_user_config.hpp**: materialised via `generated_files` (upstream ships
only `catch_user_config.hpp.in` and lets CMake fill it). Only the two VALUE
defines are mandatory — `CATCH_CONFIG_DEFAULT_REPORTER` and
`CATCH_CONFIG_CONSOLE_WIDTH`; every other entry in the `.in` is a
`#cmakedefine`, i.e. absent means "use the compiler-detected default". Both
are `#ifndef`-guarded so a consumer can override via `-D`. v2 never includes
this file. **Re-read the upstream `.in` when bumping v3** — a newly added
mandatory value-define would break silently here.

## The `main` feature

`features = ["main"]` compiles a **generated** TU that supplies a default entry
point, branching on `__has_include(<catch2/catch_all.hpp>)` to pick the v3
(`Catch::Session`) or v2 (`CATCH_CONFIG_MAIN`) spelling.

It deliberately does **not** point at upstream's
`src/catch2/internal/catch_main.cpp`. That was the first draft's approach and
it does not work. Measured on mcpp 0.0.109:

| descriptor form | default path | `features = ["main"]` |
|---|---|---|
| glob + `!` negation, feature → the negated path | ok | **FAIL** `undefined reference to 'main'` |
| glob, no negation, feature → the globbed path | **FAIL** `multiple definition of 'main'` | ok |
| glob + `!` negation + explicit re-add, feature → that path | ok | **FAIL** `undefined reference to 'main'` |
| glob + `!` negation, feature → **generated TU** | ok | ok |

Two mechanics behind that table:

1. A `!` negation in `sources` is an **absolute** exclusion applied to the
final source set. A feature cannot add the path back — not even if the path
is also listed explicitly afterwards.
2. Feature gating matches **literal** `sources` entries. A file pulled in by a
glob is not gated at all, so it lands in the default build.

Together these mean the `compat.gtest` pattern does **not** transfer:
`gtest_main.cc` is a literal `sources` entry, so listing it under a feature
gates it. Under a glob the same idea silently breaks in one direction or the
other. A generated TU sidesteps the conflict.

Cost of not reusing upstream's file: no `LeakDetector` registration (a Windows
CRT-debug nicety) and no `wmain` variant (reachable only under `_UNICODE` on
Windows). Consumers needing either write their own `main()`.

## Why this needs four workspace members

Features are resolved **per consuming project**, so one project cannot hold
`catch2` both with and without `main` (same constraint that forces `asio-ssl`
to be separate from `asio-module`). Two majors × {default, `main` feature} = 4:

| member | version | requests | what it would catch |
|---|---|---|---|
| `catch2` | 3.15.2 | — (own `main()`) | negation stops working → `multiple definition of 'main'` |
| `catch2-main` | 3.15.2 | `features = ["main"]` | feature stops gating the TU in → `undefined reference to 'main'` |
| `catch2-v2` | 2.13.10 | — (own `CATCH_CONFIG_MAIN`) | union bleeds across versions; `single_include` unresolved |
| `catch2-v2-main` | 2.13.10 | `features = ["main"]` | the `__has_include` ELSE branch (v3 members only take THEN) |

The first draft declared a `main` feature on both packages and covered
**neither** — which is precisely why the broken feature passed CI. Every test
body keeps a real `REQUIRE`, and Catch2 prints its assertion count on exit, so
a TU that compiles to nothing cannot pass quietly.

## Download URLs and mirrors

GitHub tag archive tarballs (not the individual amalgamated release assets):
xpm takes one URL per version, and the tag archive is the only form that
carries both layouts. Neither archive contains symlinks, so the Windows
extraction path is safe.

| Version | GLOBAL | CN | SHA256 |
|---|---|---|---|
| 2.13.10 | `github.com/catchorg/Catch2/archive/refs/tags/v2.13.10.tar.gz` | `gitcode.com/mcpp-res/catch2/releases/download/2.13.10/catch2-2.13.10.tar.gz` | `d54a712b…9943` |
| 3.15.2 | `github.com/catchorg/Catch2/archive/refs/tags/v3.15.2.tar.gz` | `gitcode.com/mcpp-res/catch2/releases/download/3.15.2/catch2-3.15.2.tar.gz` | `acfae120…0420` |

CN assets were uploaded to the `mcpp-res` gitcode org and re-downloaded: both
are byte-identical to the upstream archives (same sha256, same size), so the
single recorded `sha256` covers either source.

## Verification

Local, mcpp 0.0.109 (matching CI `MCPP_VERSION`), gcc@16.1.0, linux-x86_64:

- `mcpp xpkg parse` on the descriptor: OK
- `tests/check_mirror_urls.lua`: OK
- All four members via `mcpp test -p`: ok
- **Negative controls** (not committed): dropping `features = ["main"]` from
each `-main` member fails with `undefined reference to 'main'`, confirming
the feature is what supplies the entry point rather than something else.
- Both tarballs re-downloaded from GLOBAL and CN and sha256-checked.

CI covers linux / macOS / windows.

## Notes

- Catch2 v3 requires C++14, v2 requires C++11; both build under
`language = "c++23"`.
- `c_standard = "c11"` is for the anchor TU.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ mcpp self config --mirror CN # 切换至国内镜像,默认使用 GLOBAL 上
| 原生模块库(Form A) | [`mcpplibs.xpkg`](pkgs/x/xpkg.lua) · [`mcpplibs.tinyhttps`](pkgs/t/tinyhttps.lua) · [`tensorvia-cpu`](pkgs/t/tensorvia-cpu.lua) · [`ffmpeg`](pkgs/f/ffmpeg.lua)(模块层,源码经 `compat.ffmpeg` 直编) · [`opencv`](pkgs/o/opencv.lua)(单仓库:模块层与 OpenCV 5 全源码构建同在包内,索引侧只留本描述符) |
| C 源码 compat(含 `features`) | [`compat.cjson`](pkgs/c/compat.cjson.lua) · [`compat.zlib`](pkgs/c/compat.zlib.lua) |
| header-only(含 `features`) | [`compat.eigen`](pkgs/c/compat.eigen.lua) |
| 单包多 major(形态随版本切换) | [`compat.catch2`](pkgs/c/compat.catch2.lua)(3.x 编 `src/catch2/` 出静态库;2.x 走 `single_include/` header-only) |
| 外部构建系统(`install()` 从源码构建) | [`compat.openblas`](pkgs/c/compat.openblas.lua)(Make) · [`compat.openssl`](pkgs/c/compat.openssl.lua)(Perl Configure + Make,静态 libssl/libcrypto) |
| 全源码直编(config 快照 + 源列表,零外部构建系统) | [`compat.ffmpeg`](pkgs/c/compat.ffmpeg.lua)(2281 TU 含 NASM 汇编,28 个目录 glob 声明) |
| C++23 module wrapper | [`nlohmann.json`](pkgs/n/nlohmann.json.lua) · [`marzer.tomlplusplus`](pkgs/m/marzer.tomlplusplus.lua) |
Expand Down
4 changes: 4 additions & 0 deletions mcpp.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ members = [
"tests/examples/asio-module",
"tests/examples/asio-ssl",
"tests/examples/build-mcpp",
"tests/examples/catch2",
"tests/examples/catch2-main",
"tests/examples/catch2-v2",
"tests/examples/catch2-v2-main",
"tests/examples/cjson",
"tests/examples/core",
"tests/examples/eigen",
Expand Down
180 changes: 180 additions & 0 deletions pkgs/c/compat.catch2.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
-- Form B inline descriptor for Catch2 — a modern C++-native test framework.
--
-- ONE package, TWO majors. v2 and v3 have completely different source layouts
-- (`single_include/catch2/catch.hpp` vs `src/catch2/**`), and this descriptor
-- covers both by taking the UNION of the two layouts' globs. That works only
-- because the two sets are DISJOINT:
--
-- | glob | 2.13.10 | 3.15.2 |
-- |-------------------------|---------------------------------|--------|
-- | */single_include | exists | absent |
-- | */src/catch2/**/*.cpp | 0 matches — v2's only src/ file | 107 |
-- | | is src/catch_with_main.cpp, | |
-- | | NOT under src/catch2/ | |
--
-- So each version lights up exactly one half and the other degenerates to
-- empty: v2 resolves to header-only (single_include + the anchor TU below),
-- v3 to a 106-TU static library. An include_dir whose glob matches nothing is
-- not an error, which is what makes the union safe.
--
-- THIS DISJOINTNESS IS THE LOAD-BEARING PREMISE, and it is a property of the
-- two upstream trees rather than something this descriptor can enforce. Re-
-- check the table above when adding a version. The right long-term shape is
-- per-version build blocks — mcpp-community/mcpp#290 — after which this
-- collapses into explicit ["2.x"] / ["3.x"] tables and the assumption goes
-- away entirely. Splitting into two packages was rejected: it encodes a
-- version into the `name` segment, which SPEC-001 identity reserves for an
-- atomic name, and it breaks semver across the major (a consumer could not
-- write `catch2 = "^3"`).
--
-- Features (sources-only gate):
-- `main` — compiles a GENERATED TU supplying a default entry point. It
-- branches on __has_include(<catch2/catch_all.hpp>), which exists only in
-- v3, to pick the v3 (Catch::Session) or v2 (CATCH_CONFIG_MAIN) spelling.
-- Excluded by default; request `features = ["main"]`.
--
-- It deliberately does NOT point at upstream's
-- src/catch2/internal/catch_main.cpp. That file is matched by the sources
-- glob and must be `!`-negated, else a consumer with its own main() gets
-- `multiple definition of 'main'`; and a `!` negation is an ABSOLUTE
-- exclusion that a feature cannot add back — pointing the feature at the
-- negated path yields a feature that silently does nothing and an
-- `undefined reference to 'main'` at link time. Both directions were
-- measured on mcpp 0.0.109. Note this is NOT `compat.gtest`'s shape:
-- gtest lists gtest_main.cc as a LITERAL `sources` entry, and feature
-- gating matches literal entries — a file pulled in by a glob is not gated
-- at all. A generated TU sidesteps the whole conflict.
--
-- Cost of not reusing upstream's file: no LeakDetector registration (a
-- Windows CRT-debug nicety) and no wmain variant (reachable only under
-- _UNICODE on Windows). Consumers needing either write their own main().
--
-- All `mcpp` paths are GLOBS relative to the verdir; the leading `*` absorbs
-- the GitHub tarball's `Catch2-<tag>/` wrap layer.
package = {
spec = "1",
namespace = "compat",
name = "catch2",
description = "A modern, C++-native test framework for unit-tests, TDD and BDD",
licenses = {"BSL-1.0"},
repo = "https://github.com/catchorg/Catch2",
type = "package",

xpm = {
linux = {
["2.13.10"] = {
url = {
GLOBAL = "https://github.com/catchorg/Catch2/archive/refs/tags/v2.13.10.tar.gz",
CN = "https://gitcode.com/mcpp-res/catch2/releases/download/2.13.10/catch2-2.13.10.tar.gz",
},
sha256 = "d54a712b7b1d7708bc7a819a8e6e47b2fde9536f487b89ccbca295072a7d9943",
},
["3.15.2"] = {
url = {
GLOBAL = "https://github.com/catchorg/Catch2/archive/refs/tags/v3.15.2.tar.gz",
CN = "https://gitcode.com/mcpp-res/catch2/releases/download/3.15.2/catch2-3.15.2.tar.gz",
},
sha256 = "acfae120892c2b67a74142d36d060c0caa96f1c3aaa8aabd96e19961163d0420",
},
},
macosx = {
["2.13.10"] = {
url = {
GLOBAL = "https://github.com/catchorg/Catch2/archive/refs/tags/v2.13.10.tar.gz",
CN = "https://gitcode.com/mcpp-res/catch2/releases/download/2.13.10/catch2-2.13.10.tar.gz",
},
sha256 = "d54a712b7b1d7708bc7a819a8e6e47b2fde9536f487b89ccbca295072a7d9943",
},
["3.15.2"] = {
url = {
GLOBAL = "https://github.com/catchorg/Catch2/archive/refs/tags/v3.15.2.tar.gz",
CN = "https://gitcode.com/mcpp-res/catch2/releases/download/3.15.2/catch2-3.15.2.tar.gz",
},
sha256 = "acfae120892c2b67a74142d36d060c0caa96f1c3aaa8aabd96e19961163d0420",
},
},
windows = {
["2.13.10"] = {
url = {
GLOBAL = "https://github.com/catchorg/Catch2/archive/refs/tags/v2.13.10.tar.gz",
CN = "https://gitcode.com/mcpp-res/catch2/releases/download/2.13.10/catch2-2.13.10.tar.gz",
},
sha256 = "d54a712b7b1d7708bc7a819a8e6e47b2fde9536f487b89ccbca295072a7d9943",
},
["3.15.2"] = {
url = {
GLOBAL = "https://github.com/catchorg/Catch2/archive/refs/tags/v3.15.2.tar.gz",
CN = "https://gitcode.com/mcpp-res/catch2/releases/download/3.15.2/catch2-3.15.2.tar.gz",
},
sha256 = "acfae120892c2b67a74142d36d060c0caa96f1c3aaa8aabd96e19961163d0420",
},
},
},

mcpp = {
language = "c++23",
import_std = false,
c_standard = "c11", -- for the anchor TU below

-- Union of both layouts. v2 resolves */single_include, v3 resolves
-- */src; the other one matches nothing and is skipped. mcpp_generated
-- carries catch2/catch_user_config.hpp for v3.
include_dirs = { "*/single_include", "*/src", "mcpp_generated" },

generated_files = {
-- v3 only. Upstream ships catch_user_config.hpp.in and lets CMake
-- materialise it. Only the two VALUE defines are mandatory —
-- everything else in the .in is a #cmakedefine, i.e. absent means
-- "use the compiler-detected default", which is what we want.
-- #ifndef-guarded so a consumer can override via -D.
-- Re-read the upstream .in when bumping v3: a newly added
-- mandatory value-define would otherwise break silently here.
-- v2 never includes this file.
["mcpp_generated/catch2/catch_user_config.hpp"] = [==[
#ifndef CATCH_USER_CONFIG_HPP_INCLUDED
#define CATCH_USER_CONFIG_HPP_INCLUDED

#ifndef CATCH_CONFIG_DEFAULT_REPORTER
#define CATCH_CONFIG_DEFAULT_REPORTER "console"
#endif
#ifndef CATCH_CONFIG_CONSOLE_WIDTH
#define CATCH_CONFIG_CONSOLE_WIDTH 80
#endif

#endif // CATCH_USER_CONFIG_HPP_INCLUDED
]==],
-- Required for v2, where the sources glob below matches nothing
-- and a lib target still needs at least one TU (same shape as
-- compat.eigen / compat.khrplatform). Inert extra object on v3.
["mcpp_generated/catch2_anchor.c"] = [==[
int mcpp_compat_catch2_anchor(void) { return 0; }
]==],
-- The `main` feature's TU. See the header comment for why this is
-- generated rather than upstream's catch_main.cpp.
["mcpp_generated/catch2_main.cpp"] = [==[
#if __has_include(<catch2/catch_all.hpp>)
// Catch2 v3: the library is compiled in; just drive a session.
# include <catch2/catch_session.hpp>
int main(int argc, char* argv[]) { return Catch::Session().run(argc, argv); }
#else
// Catch2 v2 is header-only: this TU is also where the implementation lands.
# define CATCH_CONFIG_MAIN
# include <catch2/catch.hpp>
#endif
]==],
},

sources = {
"mcpp_generated/catch2_anchor.c",
"*/src/catch2/**/*.cpp",
-- Upstream's default main(); would collide with a consumer's own.
"!*/src/catch2/internal/catch_main.cpp",
},

targets = { ["catch2"] = { kind = "lib" } },
features = {
["main"] = { sources = { "mcpp_generated/catch2_main.cpp" } },
},
deps = { },
},
}
16 changes: 16 additions & 0 deletions tests/examples/catch2-main/mcpp.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# compat.catch2 at v3 — `main` FEATURE path: the package supplies the entry
# point and this project deliberately defines no main(). If the feature stops
# gating the generated TU in, this member fails with
# `undefined reference to 'main'`.
#
# This cannot share a project with tests/examples/catch2: features are resolved
# per consuming project, so one project cannot hold catch2 both with and
# without `main` (same constraint as asio-ssl vs asio-module).
#
# The `compat` index redirect is inherited from the workspace root.
[package]
name = "catch2-main-tests"
version = "0.1.0"

[dependencies.compat]
catch2 = { version = "3.15.2", features = ["main"] }
Loading
Loading