Skip to content

Commit e4a07e3

Browse files
committed
fix(link): robust main-detection (strip strings/comments) — fixes Windows LNK1561
The per-consumer archive-vs-inline decision scanned source text line-by-line for 'int main(', which false-positived on test fixtures embedding "int main(){}" as a STRING (test_modgraph.cpp). That wrongly picked archive linking for a no-main test → MSVC lld-link doesn't pull gtest_main.o for the entry → LNK1561. (On ELF/Mach-O the archive member IS pulled, so Linux/macOS masked the bug.) source_defines_main now strips comments + string/char/raw-string literals via a char state machine before matching int/auto main(. Exported + unit-tested (test_main_detection.cpp: string/raw-string/comment fixtures must NOT count).
1 parent 7d8ee52 commit e4a07e3

3 files changed

Lines changed: 156 additions & 17 deletions

File tree

.agents/docs/2026-06-25-dependency-archive-linking-design.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,11 @@ LINK : fatal error LNK1561: entry point must be defined
9292
|| **归档** `lib<pkg>.a`(排在对象后) | 链接器不拉 `gtest_main.o`(main 已定义)→ 无 `duplicate main`;入口由测试提供,MSVC 也 OK |
9393
|| **直接内联**依赖的非模块对象 | `gtest_main.o` 作为普通对象直接提供入口 → **任何**链接器(含 MSVC)都 OK;测试无 main 故无冲突 |
9494

95-
判据 = **扫描消费者入口源是否定义 `int main`/`auto main`**(空白不敏感、跳过注释行;
96-
启发式,最坏只是选错链接方式而非出错;探测不到时默认按「无 main」内联=改动前行为)。
95+
判据 = **扫描消费者入口源是否定义 `int main`/`auto main`**(`source_defines_main`:先
96+
用字符状态机**剥离注释、字符串、字符、raw-string 字面量**再匹配——否则测试夹具里的
97+
`"int main(){...}"` 字符串会假阳性,导致对 no-main 测试错选归档 → MSVC LNK1561,正是
98+
`test_modgraph.cpp` 踩中的坑;启发式,最坏只是选错链接方式而非出错;探测不到时默认按
99+
「无 main」内联=改动前行为)。
97100
**通用**:无需识别「哪个依赖对象提供 main」,只看消费者自己——对任何 `kind="lib"`
98101
依赖、任何未来测试框架都成立。
99102

src/build/plan.cppm

Lines changed: 72 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,11 @@ struct BuildPlan {
7777
std::vector<CapabilityProvider> runtimeProviders;
7878
};
7979

80+
// True if a source file defines a top-level `int main(`/`auto main(` entry,
81+
// ignoring comments and string/raw-string literals. Drives the archive-vs-inline
82+
// choice for kind="lib" dependencies (see plan.cppm).
83+
bool source_defines_main(const std::filesystem::path& src);
84+
8085
// Build a BuildPlan from already-validated inputs.
8186
BuildPlan make_plan(const mcpp::manifest::Manifest& manifest,
8287
const mcpp::toolchain::Toolchain& tc,
@@ -212,6 +217,70 @@ void append_unique_path(std::vector<std::filesystem::path>& out,
212217

213218
} // namespace
214219

220+
// True if `src` defines a top-level `int main(` / `auto main(` entry point.
221+
// Comments and string/char/raw-string literals are stripped first, so test
222+
// fixtures that embed `"int main() {...}"` or R"(int main(){})" don't
223+
// false-positive (that misfire chose archive linking for a no-main test →
224+
// gtest_main.o not pulled by MSVC lld-link → LNK1561). Heuristic but robust;
225+
// worst case is a sub-optimal archive-vs-inline choice, never a miscompile.
226+
bool source_defines_main(const std::filesystem::path& src) {
227+
std::ifstream is(src);
228+
if (!is) return false;
229+
std::string raw((std::istreambuf_iterator<char>(is)),
230+
std::istreambuf_iterator<char>());
231+
std::string code;
232+
code.reserve(raw.size());
233+
enum State { Normal, Line, Block, Str, Chr, RawStr } st = Normal;
234+
std::string rawEnd; // ")delim\"" terminator for the active raw string
235+
for (std::size_t i = 0; i < raw.size(); ++i) {
236+
char c = raw[i];
237+
char n = (i + 1 < raw.size()) ? raw[i + 1] : '\0';
238+
switch (st) {
239+
case Normal:
240+
if (c == 'R' && n == '"') {
241+
std::size_t j = i + 2;
242+
std::string delim;
243+
while (j < raw.size() && raw[j] != '(') delim.push_back(raw[j++]);
244+
rawEnd = ")" + delim + "\"";
245+
st = RawStr;
246+
i = j; // sit on '(' ; loop ++ moves past
247+
} else if (c == '/' && n == '/') { st = Line; ++i; }
248+
else if (c == '/' && n == '*') { st = Block; ++i; }
249+
else if (c == '"') { st = Str; }
250+
else if (c == '\'') { st = Chr; }
251+
else { code.push_back(c); }
252+
break;
253+
case Line: if (c == '\n') { st = Normal; code.push_back(c); } break;
254+
case Block: if (c == '*' && n == '/') { st = Normal; ++i; } break;
255+
case Str: if (c == '\\') ++i; else if (c == '"') st = Normal; break;
256+
case Chr: if (c == '\\') ++i; else if (c == '\'') st = Normal; break;
257+
case RawStr:
258+
if (raw.compare(i, rawEnd.size(), rawEnd) == 0) {
259+
st = Normal;
260+
i += rawEnd.size() - 1;
261+
}
262+
break;
263+
}
264+
}
265+
auto isws = [](char c) {
266+
return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f' || c == '\v';
267+
};
268+
for (std::size_t i = 0; i + 4 <= code.size(); ++i) {
269+
if (code.compare(i, 4, "main") != 0) continue;
270+
std::size_t p = i;
271+
bool sawWs = false;
272+
while (p > 0 && isws(code[p - 1])) { --p; sawWs = true; }
273+
bool prevOk = sawWs && (
274+
(p >= 3 && code.compare(p - 3, 3, "int") == 0) ||
275+
(p >= 4 && code.compare(p - 4, 4, "auto") == 0));
276+
std::size_t q = i + 4;
277+
while (q < code.size() && isws(code[q])) ++q;
278+
bool nextOk = q < code.size() && code[q] == '(';
279+
if (prevOk && nextOk) return true;
280+
}
281+
return false;
282+
}
283+
215284
BuildPlan make_plan(const mcpp::manifest::Manifest& manifest,
216285
const mcpp::toolchain::Toolchain& tc,
217286
const mcpp::toolchain::Fingerprint& fp,
@@ -546,9 +615,9 @@ BuildPlan make_plan(const mcpp::manifest::Manifest& manifest,
546615
// Whether this consumer's own entry source defines `main`. Decides how
547616
// kind="lib" dependencies are linked (archive vs inline) so the
548617
// gtest_main-style optional entry works on EVERY linker — see the
549-
// dependency-linking block further below. Default false → if we can't
550-
// tell, fall back to inlining (the pre-archive behavior).
551-
bool entryDefinesMain = false;
618+
// dependency-linking block further below. Can't tell (no entry) →
619+
// false → inline (the pre-archive behavior, always provides the entry).
620+
bool entryDefinesMain = lu.entryMain && source_defines_main(*lu.entryMain);
552621

553622
if ((lu.kind == LinkUnit::Binary || lu.kind == LinkUnit::TestBinary) && lu.entryMain) {
554623
// Add main.cpp -> obj/main.o
@@ -587,18 +656,6 @@ BuildPlan make_plan(const mcpp::manifest::Manifest& manifest,
587656
}
588657
if (!name.empty()) main_cu.imports.push_back(name);
589658
}
590-
// Detect a top-level `int main(`/`auto main(` definition
591-
// (space-insensitive; skip comment lines). Heuristic, but the
592-
// worst case is a wrong archive-vs-inline choice, not breakage.
593-
if (!entryDefinesMain && !line.starts_with("//") && !line.starts_with("*")) {
594-
std::string nospace;
595-
for (char c : line)
596-
if (!std::isspace(static_cast<unsigned char>(c))) nospace.push_back(c);
597-
if (nospace.find("intmain(") != std::string::npos
598-
|| nospace.find("automain(") != std::string::npos) {
599-
entryDefinesMain = true;
600-
}
601-
}
602659
}
603660

604661
// Avoid duplicate insert if main was already scanned

tests/unit/test_main_detection.cpp

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
#include <gtest/gtest.h>
2+
3+
import std;
4+
import mcpp.build.plan;
5+
6+
using namespace mcpp::build;
7+
8+
namespace {
9+
10+
// Write `content` to a unique temp .cpp and return its path.
11+
std::filesystem::path write_tmp(std::string_view content, std::string_view tag) {
12+
auto dir = std::filesystem::temp_directory_path();
13+
auto p = dir / std::format("mcpp_maindetect_{}.cpp", tag);
14+
std::ofstream os(p);
15+
os << content;
16+
os.close();
17+
return p;
18+
}
19+
20+
bool defines_main(std::string_view content, std::string_view tag) {
21+
auto p = write_tmp(content, tag);
22+
bool r = source_defines_main(p);
23+
std::error_code ec;
24+
std::filesystem::remove(p, ec);
25+
return r;
26+
}
27+
28+
} // namespace
29+
30+
TEST(MainDetection, RealMainIsDetected) {
31+
EXPECT_TRUE(defines_main("import std;\nint main() { return 0; }\n", "real"));
32+
}
33+
34+
TEST(MainDetection, RealMainWithArgsIsDetected) {
35+
EXPECT_TRUE(defines_main(
36+
"int main(int argc, char** argv) { (void)argc; (void)argv; return 0; }\n", "args"));
37+
}
38+
39+
TEST(MainDetection, AutoMainIsDetected) {
40+
EXPECT_TRUE(defines_main("auto main() -> int { return 0; }\n", "automain"));
41+
}
42+
43+
// The regression: test fixtures embed `"int main() {...}"` as a STRING — that
44+
// must NOT count as the test binary defining main (it doesn't). A false positive
45+
// chose archive linking → gtest_main.o not pulled by MSVC lld-link → LNK1561.
46+
TEST(MainDetection, MainInsideStringLiteralIsIgnored) {
47+
EXPECT_FALSE(defines_main(
48+
"#include <gtest/gtest.h>\n"
49+
"TEST(M, x) {\n"
50+
" auto src = \"int main() { return 0; }\\n\";\n"
51+
" EXPECT_FALSE(src.empty());\n"
52+
"}\n", "strlit"));
53+
}
54+
55+
TEST(MainDetection, MainInsideRawStringIsIgnored) {
56+
EXPECT_FALSE(defines_main(
57+
"#include <gtest/gtest.h>\n"
58+
"TEST(M, x) {\n"
59+
" auto src = R\"(\nint main() { return 0; }\n)\";\n"
60+
" EXPECT_FALSE(src.empty());\n"
61+
"}\n", "rawstr"));
62+
}
63+
64+
TEST(MainDetection, MainInsideCommentIsIgnored) {
65+
EXPECT_FALSE(defines_main(
66+
"// int main() { return 0; }\n"
67+
"#include <gtest/gtest.h>\n"
68+
"TEST(M, x) { EXPECT_TRUE(true); }\n", "comment"));
69+
}
70+
71+
TEST(MainDetection, NoMainGtestStyleIsFalse) {
72+
EXPECT_FALSE(defines_main(
73+
"#include <gtest/gtest.h>\n"
74+
"TEST(M, x) { EXPECT_EQ(1, 1); }\n", "nomain"));
75+
}
76+
77+
TEST(MainDetection, SimilarIdentifierIsNotMain) {
78+
EXPECT_FALSE(defines_main("int mainHelper() { return 0; }\n", "helper"));
79+
}

0 commit comments

Comments
 (0)