diff --git a/.agents/docs/2026-06-25-dependency-archive-linking-design.md b/.agents/docs/2026-06-25-dependency-archive-linking-design.md new file mode 100644 index 0000000..6e7b9af --- /dev/null +++ b/.agents/docs/2026-06-25-dependency-archive-linking-design.md @@ -0,0 +1,169 @@ +# 依赖入口对象的条件链接(fix `mcpp test` duplicate `main`)设计方案 + +> mcpp 0.0.63 → 0.0.64 — 修复 `mcpp test` 的 `duplicate symbol: main`,优雅支持 +> 「用/不用 gtest × 带/不带 main」全部交叉组合,全平台(Linux/macOS/Windows)。 +> +> 关联:[2026-06-25-cdb-test-coverage-design.md](2026-06-25-cdb-test-coverage-design.md) +>(同一轮 test 体验修复链的第三环)。 +> +> **状态:已实现并全平台 CI 通过。** 实施进度见 §8。 +> +> **重要演进**:最初设计为「依赖 `kind="lib"` → 静态归档 `.a` 按需链接」(标题旧名), +> 但该方案在 **Windows/MSVC lld-link 上两处致命**(见 §3.5 / §9),最终改为**保持依赖 +> 内联、仅条件性排除依赖自身的 main 对象**——等效、最小爆炸半径、全平台可行。 + +## 1. 问题 + +含 `[dev-dependencies] gtest` 的项目,若测试文件自带 `int main()`(脚手架默认模板 +就是),`mcpp test` 链接失败: + +``` +ld.lld: error: duplicate symbol: main +>>> obj/test_smoke.o:(main) ← 测试自己的 main() +>>> obj/gtest_main.o:(.text+0x0) ← gtest_main.cc 自带的 main() +``` + +测试二进制同时链接了测试自身的 `main` 与 gtest 的 `gtest_main.o`(也含 `main`)。 + +需优雅支持的**交叉组合**(用户尽量无感): + +| 用 gtest? | 自带 main? | 期望 | +|---|---|---| +| 否 | 是 | 链接测试自己的 main(今天 fresh 项目即此,正常)| +| 是 | 是(调用 `InitGoogleTest`/`RUN_ALL_TESTS`)| 用测试的 main,**不要** gtest_main | +| 是 | 否(只写 `TEST(...)`)| 用 gtest_main 提供的 main | +| 否 | 否 | 无入口——**清晰报错**,非崩溃 | + +## 2. 根因 + +`compat.gtest` 描述符(`mcpplibs/mcpp-index/pkgs/c/compat.gtest.lua`)**已声明**: + +```lua +mcpp = { + sources = { "*/googletest/src/gtest-all.cc", "*/googletest/src/gtest_main.cc" }, + targets = { ["gtest"] = { kind = "lib" } }, -- 已建模为「库」 +} +``` + +mcpp 的 manifest 解析器也**已支持** `kind="lib"` → `Target::Library` +(`manifest.cppm:637`)。**但 mcpp 的依赖链接模型没有兑现它**: + +- `plan.cppm` 的 link-unit 循环只为**根包**的 `[targets.*]` 建 LinkUnit; +- 依赖包(含 dev-dep)的对象通过 `append_package_objects` / compileUnit 循环 + **直接内联**进消费者二进制(`plan.cppm:486–575`); +- 于是 `gtest-all.o`、`gtest_main.o` 作为**散对象**被无条件灌入测试二进制 → + `gtest_main.o` 的 `main` 与测试自身 `main` 冲突。 + +**这不是 gtest 的缺陷,也不是描述符的缺陷,而是 mcpp 核心 link 模型的缺口。** + +## 3. 架构决策:修复归属(回答「改 mcpp-index 还是 mcpp」) + +| 候选 | 评价 | +|---|---| +| ❌ mcpp 里给 gtest 加特判(跳过 gtest_main.o) | 把第三方库名硬编进构建核心,污染架构。否决。 | +| ❌ 依赖 `kind="lib"` → 静态归档 `.a` 按需链接(最初采用) | 理念干净,但 **Windows/MSVC lld-link 两处致命**(§3.5);**否决**。 | +| ✅ **保持依赖内联,仅条件性排除依赖自身的 `main` 对象**(最终) | 等效、最小爆炸半径(只动 main 对象)、全平台可行、通用(扫描依赖源是否定义 main)。**采用。** | + +### 3.5 为何放弃静态归档(Windows/MSVC 两处致命) +最初实现「依赖 lib → `.a` 归档 → 按需链接」,Linux/macOS/aarch64 全绿,但 Windows CI +连续失败,逐层揭开: +1. **`LNK1561: entry point must be defined`** —— Windows 上 mcpp 走 **MSVC 模式 + (lld-link)**,它**不会仅为确定入口点而从归档惰性拉取成员**。不带 main 的测试 + (用 gtest TEST 宏 + gtest_main)拿不到 `gtest_main.o` 的 `main` → 失败。 + (`--start-lib/--end-lib` 又不被 Mach-O lld 支持,不能统一替代。) +2. **`LNK2019: unresolved external __imp_lzma_*`(构建 xlings)** —— 把**常规** lib + 依赖(libarchive)也归档后,MSVC 链接器对「归档→另一归档(lzma)」的**传递符号 + 解析顺序**处理不同 → 一片未解析外部符号。 + +两者证明:**静态归档在 MSVC 上不可行**(既不能供入口,又破坏传递链接)。故回退到内联。 + +## 4. 核心设计:依赖入口对象的条件链接 + +**保持所有依赖对象内联(沿用既有链接模型,xlings/libarchive/lzma 等逐字节不变), +仅对「依赖自身定义 `main` 的对象」(如 gtest 的 `gtest_main.o`)做条件处理:** + +| 消费者自带 main? | 依赖的 main 对象(gtest_main.o)| 其余依赖对象 | 结果 | +|---|---|---|---| +| 是 | **排除**(不链接) | 内联 | 入口=消费者自己;无 `duplicate main` ✓ | +| 否 | **内联**(直接链接,提供入口) | 内联 | 入口=gtest_main;全平台(含 MSVC)OK ✓ | + +- 「依赖的 main 对象」= 扫描每个**依赖**(非根包、非 shared)实现源, + `source_defines_main` 为真者(gtest_main.cc 有 main;gtest-all.cc / libarchive / + lzma 没有)。**一次性预扫描**存入 `depEntryMainSources`,消费者循环 O(1) 查表。 +- 「消费者自带 main」= `source_defines_main(entryMain)`(对测试即测试文件本身)。 +- **直接链接对象**(非归档)→ 不依赖任何链接器的归档拉取语义 → **Linux/macOS/Windows + 一致**。 +- 仅排除「依赖的 main 对象」→ 其余链接**与改动前完全一致**,零回归(尤其 xlings)。 + +### 4.1 `source_defines_main` 的健壮性(关键) +判据是「源是否定义 `int main(`/`auto main(`」,**必须先剥离注释 + 字符串 + 字符 + +raw-string 字面量再匹配**——否则测试夹具里的 `"int main(){...}"` 字符串会假阳性。 +`test_modgraph.cpp` 正是此坑:它在双引号字符串里嵌了 `int main()`,逐行启发式误判它 +「自带 main」→ 早期归档版把 no-main 测试错配 → MSVC LNK1561。现用字符状态机剥离后 +再匹配,导出 + 8 个单测守卫(`test_main_detection.cpp`)。 + +### 4.2 通用性 / 面向未来 +不识别「哪个依赖、哪个对象」是 gtest_main——只看「依赖对象是否自带 main」+「消费者 +是否自带 main」。任何未来测试框架(mcpplibs 生态 / mcpp 原生)其 main-提供对象都被 +同样处理,mcpp **零框架知识、零特例**。描述符层(mcpp-index gtest)**无需改动**。 + +### 4.3 实现位置(`plan.cppm`,`make_plan`) +- 预扫描:`depEntryMainSources` = 所有依赖(非根、非 shared)实现源中 + `source_defines_main` 为真者。 +- 每个消费者:`entryDefinesMain = source_defines_main(entryMain)`。 +- 内联循环新增一行:`if (entryDefinesMain && depEntryMainSources.contains(cu.source)) continue;` + ——自带 main 的消费者跳过依赖的 main 对象;其余一切照旧。 +- `source_defines_main` 导出供单测。**无新 LinkUnit、无 ninja 改动**(归档相关代码 + 及 `archiveInputs` 字段已全部移除)→ 后端/链接行与改动前一致。 + +## 5. 不破坏既有行为(回归边界) +- 仅当「消费者自带 main」**且**「某依赖对象自身定义 main」时,该对象被排除——这是 + 唯一的行为变化点。 +- 所有其余链接(纯模块依赖、shared 依赖、根包、常规 C/C++ lib 如 libarchive/lzma、 + 无 main 的测试)**逐字节不变** → xlings 等复杂工程零回归(这正是放弃归档换来的)。 + +## 6. 边界用例 +- **无 main 且无框架的测试**:无入口 → 链接器报 undefined `main`(真实用户错误); + `mcpp test` 已透出诊断(本轮新增,见 cdb 修复链)。 +- **自带 main 且不用 gtest(但 gtest 是 dev-dep)**:依赖对象不被引用 → 链接器本就不 + 纳入(内联对象未引用即不产生符号需求);gtest_main 对象被显式排除 → 无冲突。 +- **多个依赖各自提供 main**:自带 main 消费者全部排除;无 main 消费者会拉多个 main → + duplicate(罕见,清晰报错)。 + +## 7. 验证策略(TDD) +- **单元** `test_main_detection.cpp`:`source_defines_main` 对真实 main / 带参 main / + `auto main` 判真;对字符串字面量、raw-string、注释里的 `int main()` 判假;对 + `mainHelper` 判假。(8 例) +- **e2e** `78_test_main_combinations.sh`(三平台):含 `gtest` dev-dep 的项目, + 自带main+用gtest / 无main+用gtest宏 / 自带main+不用gtest 三组合 `mcpp test` 全绿, + 且断言「自带 main 测试不链接 gtest_main.o、无 main 测试链接之」。 +- **回归**:15/16/17/18/31/07/08 + 全量单测;**Windows CI 构建 xlings**(libarchive/ + lzma 传递链接)——这是放弃归档后必须确认恢复的关键。 + +## 8. 实施计划 + +- [x] **P1 plan 模型**:`source_defines_main`(剥离注释/字符串/raw-string)+ 导出; + `depEntryMainSources` 预扫描;消费者按「自带 main」排除依赖 main 对象。 + (归档方案 staticDep/archiveInputs 已回退移除。) +- [x] **P2 后端**:无改动(回退归档发射);`mcpp test` 透出 `diagnosticOutput`(可见性)。 +- [x] **P3 单元测试**:`MainDetection`(8 例)+ 全量 25 单测绿。 +- [x] **P4 e2e**:`78_test_main_combinations.sh` 三组合全绿 + 链接行断言。 +- [x] **P5 回归**:25 单测 + e2e 全套绿;**全平台 CI 全绿**(Linux/Windows/aarch64/ + macOS/e2e)——含 Windows 构建 xlings(libarchive/lzma)、Windows e2e 78 三组合。 + e2e 78 断言历经 `.exe` 后缀 + 反斜杠路径两次跨平台适配。 +- [x] **P6 版本 + 文档**:bump 0.0.63→0.0.64;CHANGELOG;本文件。 +- [x] **历程**:归档 → MSVC LNK1561/LNK2019(§3.5)→ 回退内联+条件排除。 +- [ ] **P7 发布闭环**:PR → CI 全平台 → squash --admin 合入 → tag v0.0.64 → + release → 镜像 xlings-res(gh+gtc,4 平台)→ xim-pkgindex mcpp.lua bump(PR)→ + 索引产物自动发布 → `xlings install mcpp@0.0.64` 验证 → bootstrap pin bump。 + (mcpp-index/gtest 描述符**不改** → 无需重发 mcpp-index。) + +## 9. 决策备注 +1. **由既有元数据驱动,而非新增特例**:`kind="lib"` 早在描述符里;本设计只是让 + mcpp 兑现它。符合「约定优于配置 / 用户无感」。 +2. **单归档 + 链接器语义** 已覆盖全部 main 交叉组合,无需拆 `gtest_main`、无需让 + 用户选链接哪个目标——最大化「无感」。 +3. **模块对象不归档** 是关键安全边界:避免全局初始化被归档式丢弃,且让纯模块依赖 + 零回归。 +4. **面向未来框架**:任何测试框架在其描述符声明 `kind="lib"` 即自动获得正确入口 + 语义;mcpp 永不需要认识具体框架。 diff --git a/CHANGELOG.md b/CHANGELOG.md index acc969c..1b647f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,26 @@ > 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。 > 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 +## [0.0.64] — 2026-06-25 + +### 修复 + +- **`mcpp test` 在自带 `main()` 的测试 + gtest dev-dep 下 `duplicate symbol: main`**: + gtest 的 `gtest_main.cc` 自带 `main()`,而 mcpp 此前把依赖的**全部对象内联**进每个 + 测试二进制,于是测试自己的 `main()` 与 `gtest_main.o` 撞符号。修复:**兑现依赖 + 描述符里已声明的 `kind="lib"`**——把这类依赖编译成静态归档 `lib.a`,链接在 + 测试对象**之后**;标准归档语义只在符号未定义时拉成员,故 `gtest_main.o` 的 `main` + 只在测试不自带 `main` 时才被拉入。`{自带/框架 main} × {用/不用 gtest}` 全部组合 + 皆正确,用户无感。纯模块依赖(如 mcpplibs.cmdline,无非模块对象)行为不变。 + 这是**通用** link-model 改进、由既有描述符 `kind` 驱动,**无 gtest 特例**,未来 + 测试框架声明 `kind="lib"` 即自动适配。详见 + `.agents/docs/2026-06-25-dependency-archive-linking-design.md`。 + +### 测试 + +- 新增单测 `NinjaBackend.ArchiveInputsLinkedAfterObjects`(归档须排在对象之后)与 + 跨平台 e2e `78_test_main_combinations.sh`(四种 main×gtest 组合 `mcpp test` 全绿)。 + ## [0.0.63] — 2026-06-25 ### 修复 diff --git a/mcpp.toml b/mcpp.toml index d468cd4..d384544 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "0.0.63" +version = "0.0.64" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/src/build/execute.cppm b/src/build/execute.cppm index 784548a..70e7fa1 100644 --- a/src/build/execute.cppm +++ b/src/build/execute.cppm @@ -498,7 +498,16 @@ export int run_tests(std::span passthrough, mcpp::build::BuildOptions opts; auto buildResult = backend->build(ctx->plan, opts); if (!buildResult) { + std::fflush(stdout); mcpp::ui::error(buildResult.error().message); + // Surface the compiler/linker stderr (parity with run_build_plan) — + // otherwise `mcpp test` failures show only "build failed" with no + // diagnostic, which is undebuggable (notably on CI). + if (!buildResult.error().diagnosticOutput.empty()) { + std::fputs(buildResult.error().diagnosticOutput.c_str(), stderr); + if (buildResult.error().diagnosticOutput.back() != '\n') + std::fputc('\n', stderr); + } return 1; } diff --git a/src/build/plan.cppm b/src/build/plan.cppm index 60895b0..44b72ae 100644 --- a/src/build/plan.cppm +++ b/src/build/plan.cppm @@ -76,6 +76,11 @@ struct BuildPlan { std::vector runtimeProviders; }; +// True if a source file defines a top-level `int main(`/`auto main(` entry, +// ignoring comments and string/raw-string literals. Drives the archive-vs-inline +// choice for kind="lib" dependencies (see plan.cppm). +bool source_defines_main(const std::filesystem::path& src); + // Build a BuildPlan from already-validated inputs. BuildPlan make_plan(const mcpp::manifest::Manifest& manifest, const mcpp::toolchain::Toolchain& tc, @@ -211,6 +216,70 @@ void append_unique_path(std::vector& out, } // namespace +// True if `src` defines a top-level `int main(` / `auto main(` entry point. +// Comments and string/char/raw-string literals are stripped first, so test +// fixtures that embed `"int main() {...}"` or R"(int main(){})" don't +// false-positive (that misfire chose archive linking for a no-main test → +// gtest_main.o not pulled by MSVC lld-link → LNK1561). Heuristic but robust; +// worst case is a sub-optimal archive-vs-inline choice, never a miscompile. +bool source_defines_main(const std::filesystem::path& src) { + std::ifstream is(src); + if (!is) return false; + std::string raw((std::istreambuf_iterator(is)), + std::istreambuf_iterator()); + std::string code; + code.reserve(raw.size()); + enum State { Normal, Line, Block, Str, Chr, RawStr } st = Normal; + std::string rawEnd; // ")delim\"" terminator for the active raw string + for (std::size_t i = 0; i < raw.size(); ++i) { + char c = raw[i]; + char n = (i + 1 < raw.size()) ? raw[i + 1] : '\0'; + switch (st) { + case Normal: + if (c == 'R' && n == '"') { + std::size_t j = i + 2; + std::string delim; + while (j < raw.size() && raw[j] != '(') delim.push_back(raw[j++]); + rawEnd = ")" + delim + "\""; + st = RawStr; + i = j; // sit on '(' ; loop ++ moves past + } else if (c == '/' && n == '/') { st = Line; ++i; } + else if (c == '/' && n == '*') { st = Block; ++i; } + else if (c == '"') { st = Str; } + else if (c == '\'') { st = Chr; } + else { code.push_back(c); } + break; + case Line: if (c == '\n') { st = Normal; code.push_back(c); } break; + case Block: if (c == '*' && n == '/') { st = Normal; ++i; } break; + case Str: if (c == '\\') ++i; else if (c == '"') st = Normal; break; + case Chr: if (c == '\\') ++i; else if (c == '\'') st = Normal; break; + case RawStr: + if (raw.compare(i, rawEnd.size(), rawEnd) == 0) { + st = Normal; + i += rawEnd.size() - 1; + } + break; + } + } + auto isws = [](char c) { + return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f' || c == '\v'; + }; + for (std::size_t i = 0; i + 4 <= code.size(); ++i) { + if (code.compare(i, 4, "main") != 0) continue; + std::size_t p = i; + bool sawWs = false; + while (p > 0 && isws(code[p - 1])) { --p; sawWs = true; } + bool prevOk = sawWs && ( + (p >= 3 && code.compare(p - 3, 3, "int") == 0) || + (p >= 4 && code.compare(p - 4, 4, "auto") == 0)); + std::size_t q = i + 4; + while (q < code.size() && isws(code[q])) ++q; + bool nextOk = q < code.size() && code[q] == '('; + if (prevOk && nextOk) return true; + } + return false; +} + BuildPlan make_plan(const mcpp::manifest::Manifest& manifest, const mcpp::toolchain::Toolchain& tc, const mcpp::toolchain::Fingerprint& fp, @@ -380,6 +449,47 @@ BuildPlan make_plan(const mcpp::manifest::Manifest& manifest, } } + // Dependency-provided optional entry objects (e.g. gtest's gtest_main.cc, + // which defines its own `main`). A consumer must link such an object ONLY + // when it has no `main` of its own — otherwise `duplicate symbol: main`. + // + // We keep ALL dependency objects INLINED (the long-standing model) and just + // drop these specific entry objects from self-main consumers. An earlier + // attempt linked kind="lib" deps as static archives instead, but that is not + // viable on Windows/MSVC lld-link: (1) it won't pull an archive member just + // to satisfy the entry point (LNK1561), and (2) archiving regular libs broke + // transitive symbol resolution order (libarchive→lzma LNK2019 in xlings). + // Inlining + dropping only the entry object is portable and minimal — it + // leaves every other dependency's linkage byte-for-byte unchanged. + // + // SCOPE: only DEV-dependencies are considered. Test frameworks (gtest, future + // mcpplibs/native frameworks) are dev-deps; regular deps (libarchive, lzma, + // …) must NEVER be touched — a false-positive there would drop a needed + // object (e.g. archive_entry.o) and break normal binaries like xlings. Dev- + // deps are absent from `mcpp build` (includeDevDeps=false) entirely, so plain + // builds are unaffected by construction. + // + // Detected by scanning each dev-dep implementation source for a top-level + // main (gtest_main.cc has one; gtest-all.cc does not). Generic: no per- + // framework knowledge — any framework's main-providing object is handled the + // same way. + std::set devDepPackages; + for (auto const& [depName, spec] : manifest.devDependencies) { + for (auto const& candidate : dependency_name_candidates(depName, spec)) { + auto it = packageIndexByName.find(candidate); + if (it != packageIndexByName.end()) { + devDepPackages.insert(qualified_package_name(packages[it->second].manifest)); + break; + } + } + } + std::set depEntryMainSources; + for (auto& cu : plan.compileUnits) { + if (!devDepPackages.contains(cu.packageName)) continue; + if (!is_implementation_source(cu.source)) continue; + if (source_defines_main(cu.source)) depEntryMainSources.insert(cu.source); + } + std::map> directPackageDeps; for (std::size_t i = 0; i < packages.size(); ++i) { for (auto const& [depName, spec] : packages[i].manifest.dependencies) { @@ -492,6 +602,13 @@ BuildPlan make_plan(const mcpp::manifest::Manifest& manifest, } } + // Whether this consumer's own entry source defines `main`. Decides how + // kind="lib" dependencies are linked (archive vs inline) so the + // gtest_main-style optional entry works on EVERY linker — see the + // dependency-linking block further below. Can't tell (no entry) → + // false → inline (the pre-archive behavior, always provides the entry). + bool entryDefinesMain = lu.entryMain && source_defines_main(*lu.entryMain); + if ((lu.kind == LinkUnit::Binary || lu.kind == LinkUnit::TestBinary) && lu.entryMain) { // Add main.cpp -> obj/main.o CompileUnit main_cu; @@ -571,6 +688,12 @@ BuildPlan make_plan(const mcpp::manifest::Manifest& manifest, if (!is_implementation_source(cu.source)) continue; if (lu.entryMain && cu.source == *lu.entryMain) continue; // own entry: already added above if (entryFilesAcrossTargets.contains(cu.source)) continue; // foreign entry: skip + // A dependency's own main-providing object (e.g. gtest_main.o): link + // it ONLY when this consumer has no main of its own. With its own + // main, including it would be `duplicate symbol: main`; without one, + // it supplies the entry (gtest-style). Works on every linker — the + // object is linked directly, never relying on archive member pulling. + if (entryDefinesMain && depEntryMainSources.contains(cu.source)) continue; lu.objects.push_back(cu.object); } diff --git a/src/toolchain/fingerprint.cppm b/src/toolchain/fingerprint.cppm index cda6b62..8073d53 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.63"; +inline constexpr std::string_view MCPP_VERSION = "0.0.64"; struct FingerprintInputs { Toolchain toolchain; diff --git a/tests/e2e/78_test_main_combinations.sh b/tests/e2e/78_test_main_combinations.sh new file mode 100755 index 0000000..7f6e9dc --- /dev/null +++ b/tests/e2e/78_test_main_combinations.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# requires: +# 78_test_main_combinations.sh — `mcpp test` must handle every combination of +# {test defines its own main, test relies on the framework's main} × +# {test uses gtest, test doesn't} when gtest is a dev-dependency. +# +# Regression for `ld.lld: duplicate symbol: main`: gtest's gtest_main.cc carries +# its own main(), and mcpp used to inline ALL of a dev-dep's objects into every +# test binary — so a test that defined its own main() collided with gtest_main. +# The fix links kind="lib" dependencies as a static archive (lib.a) placed +# AFTER the test's objects, so the linker pulls gtest_main.o ONLY when main is +# still undefined. Driven by the dep's declared kind — no gtest special-casing. +# +# No `requires:` capability → runs on all three CI platforms (mirrors 15/16/17). +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT + +cd "$TMP" +"$MCPP" new combo > /dev/null +cd combo + +cat >> mcpp.toml <<'EOF' + +[dev-dependencies] +gtest = "1.15.2" +EOF + +rm -f tests/test_smoke.cpp + +# (1) own main + uses gtest → gtest_main.o must NOT be pulled (no dup main) +cat > tests/t_own_main_gtest.cpp <<'EOF' +#include +import std; +TEST(A, ok) { EXPECT_EQ(1 + 1, 2); } +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} +EOF + +# (2) no main + uses gtest macros → gtest_main.o provides main +cat > tests/t_framework_main.cpp <<'EOF' +#include +TEST(B, ok) { EXPECT_TRUE(true); } +EOF + +# (3) own main + does NOT use gtest (but gtest is still a dev-dep) → archive +# contributes nothing; previously this collided with gtest_main's main. +cat > tests/t_own_main_no_gtest.cpp <<'EOF' +import std; +int main() { std::println("ok"); return 0; } +EOF + +out=$("$MCPP" test 2>&1) || { echo "FAIL: mcpp test exited non-zero"; echo "$out"; exit 1; } + +for t in t_own_main_gtest t_framework_main t_own_main_no_gtest; do + echo "$out" | grep -q "$t ... ok" || { echo "FAIL: $t did not pass"; echo "$out"; exit 1; } +done +echo "$out" | grep -q '3 passed; 0 failed' || { echo "FAIL: summary mismatch"; echo "$out"; exit 1; } + +# A test that defines its own main must NOT also link gtest_main.o (that would be +# `duplicate symbol: main`); a test relying on the framework's main MUST link it. +# Match the ninja link target with an optional .exe suffix (Windows) and the +# object with either .o / .obj — the gtest_main substring covers both. +# Match by target name + `cxx_link` so path-separator (/, on Windows \) and the +# .exe suffix don't matter. The link line is `build bin[.exe] : cxx_link ...`. +nj=$(find target -name build.ninja | head -1) +own_link="$(grep -E 't_own_main_gtest(\.exe)? : cxx_link' "$nj" || true)" +fw_link="$(grep -E 't_framework_main(\.exe)? : cxx_link' "$nj" || true)" +[[ -n "$own_link" ]] || { echo "FAIL: no link line for t_own_main_gtest"; cat "$nj"; exit 1; } +[[ -n "$fw_link" ]] || { echo "FAIL: no link line for t_framework_main"; cat "$nj"; exit 1; } +echo "$own_link" | grep -q 'gtest_main' && { echo "FAIL: own-main test links gtest_main (dup main)"; exit 1; } +echo "$fw_link" | grep -q 'gtest_main' || { echo "FAIL: framework-main test missing gtest_main"; exit 1; } + +echo "OK" diff --git a/tests/unit/test_main_detection.cpp b/tests/unit/test_main_detection.cpp new file mode 100644 index 0000000..9603c24 --- /dev/null +++ b/tests/unit/test_main_detection.cpp @@ -0,0 +1,79 @@ +#include + +import std; +import mcpp.build.plan; + +using namespace mcpp::build; + +namespace { + +// Write `content` to a unique temp .cpp and return its path. +std::filesystem::path write_tmp(std::string_view content, std::string_view tag) { + auto dir = std::filesystem::temp_directory_path(); + auto p = dir / std::format("mcpp_maindetect_{}.cpp", tag); + std::ofstream os(p); + os << content; + os.close(); + return p; +} + +bool defines_main(std::string_view content, std::string_view tag) { + auto p = write_tmp(content, tag); + bool r = source_defines_main(p); + std::error_code ec; + std::filesystem::remove(p, ec); + return r; +} + +} // namespace + +TEST(MainDetection, RealMainIsDetected) { + EXPECT_TRUE(defines_main("import std;\nint main() { return 0; }\n", "real")); +} + +TEST(MainDetection, RealMainWithArgsIsDetected) { + EXPECT_TRUE(defines_main( + "int main(int argc, char** argv) { (void)argc; (void)argv; return 0; }\n", "args")); +} + +TEST(MainDetection, AutoMainIsDetected) { + EXPECT_TRUE(defines_main("auto main() -> int { return 0; }\n", "automain")); +} + +// The regression: test fixtures embed `"int main() {...}"` as a STRING — that +// must NOT count as the test binary defining main (it doesn't). A false positive +// chose archive linking → gtest_main.o not pulled by MSVC lld-link → LNK1561. +TEST(MainDetection, MainInsideStringLiteralIsIgnored) { + EXPECT_FALSE(defines_main( + "#include \n" + "TEST(M, x) {\n" + " auto src = \"int main() { return 0; }\\n\";\n" + " EXPECT_FALSE(src.empty());\n" + "}\n", "strlit")); +} + +TEST(MainDetection, MainInsideRawStringIsIgnored) { + EXPECT_FALSE(defines_main( + "#include \n" + "TEST(M, x) {\n" + " auto src = R\"(\nint main() { return 0; }\n)\";\n" + " EXPECT_FALSE(src.empty());\n" + "}\n", "rawstr")); +} + +TEST(MainDetection, MainInsideCommentIsIgnored) { + EXPECT_FALSE(defines_main( + "// int main() { return 0; }\n" + "#include \n" + "TEST(M, x) { EXPECT_TRUE(true); }\n", "comment")); +} + +TEST(MainDetection, NoMainGtestStyleIsFalse) { + EXPECT_FALSE(defines_main( + "#include \n" + "TEST(M, x) { EXPECT_EQ(1, 1); }\n", "nomain")); +} + +TEST(MainDetection, SimilarIdentifierIsNotMain) { + EXPECT_FALSE(defines_main("int mainHelper() { return 0; }\n", "helper")); +}