diff --git a/README.zh-CN.md b/README.zh-CN.md index 5c0fdb1f..e44c3ae7 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -8,11 +8,11 @@ [![C++23](https://img.shields.io/badge/C%2B%2B-23-blue.svg)](https://en.cppreference.com/w/cpp/23) [![Module](https://img.shields.io/badge/module-ok-green.svg)](https://en.cppreference.com/w/cpp/language/modules) [![License](https://img.shields.io/badge/license-Apache_2.0-blue.svg)](LICENSE) +[![ci](https://github.com/mcpp-community/mcpp/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/mcpp-community/mcpp/actions/workflows/ci.yml) | [文档](docs/zh/) · [快速开始](docs/zh/01-getting-started.md) · [mcpp.toml 指南](docs/zh/04-mcpp-toml.md) · [示例项目](docs/zh/03-examples.md) · [工具链管理](docs/zh/20-toolchains.md) | |:---:| | [包索引 mcpp-index](https://mcpplibs.github.io/mcpp-index/) · [模块化库 mcpplibs](https://github.com/mcpplibs) · [社区论坛](https://forum.d2learn.org/category/20) · [Issues](https://github.com/mcpp-community/mcpp/issues) · [Releases](https://github.com/mcpp-community/mcpp/releases) | -| [![ci](https://github.com/mcpp-community/mcpp/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/mcpp-community/mcpp/actions/workflows/ci.yml) | | 支持的插件 · [mcpp-language-server(mcppls)](https://github.com/Sunrisepeak/mcpp-language-server) —— C++20/23 模块语言服务器,面向 VS Code、Zed、CLion、Neovim、AI Agent(MCP)与 CI |

diff --git a/docs/04-mcpp-toml.md b/docs/04-mcpp-toml.md index 48be22ce..8cfe6a14 100644 --- a/docs/04-mcpp-toml.md +++ b/docs/04-mcpp-toml.md @@ -263,9 +263,10 @@ soname = "libmydriver.so.1" exports = "abi/mydriver.exports" # or inline: exports = ["vk_icd*"] ``` -**Omitting the key publishes everything, which is what both platforms already -do** — ELF gives symbols default visibility, and PE gets an auto-generated -`.def` listing every symbol. `exports` narrows that. +Omitting `exports` leaves ELF and Mach-O's native visibility rules in effect. +On the MSVC ABI, mcpp discovers exportable external definitions unless an input +already declares exports or `auto_export = false` disables discovery. `exports` +narrows the linker's published set. Two projects need the narrowing. A **runtime with a stable ABI** publishes a reviewed set and nothing else, so that what is not in the set stays free to @@ -300,6 +301,31 @@ A `soname` is meaningful on `kind = "lib"` too — see [`dependency_linkage`](#dependency_linkage--static-or-shared-is-the-consumers-decision) below, where the form a library takes becomes the consumer's decision. +#### `auto_export` — native export control on the MSVC ABI (unreleased) + +The key and LLVM bitcode discovery require an unreleased source build; they are +not available in mcpp 2026.10.3.1. A target that supplies its own export control +can omit the automatic export-discovery step: + +```toml +[targets.plugin] +kind = "shared" +auto_export = false +``` + +The boolean defaults to `true` and applies only to PE shared libraries on the +MSVC ABI, including clang and clang-cl. It applies when a library target is +built as a dependency too. It has no effect on static libraries, executables, +ELF, Mach-O or MinGW. Native `__declspec(dllexport)`, linker flags and explicit +`exports` lists remain effective when discovery is disabled. + +With discovery enabled, any input's explicit export intent suppresses automatic +exports for the whole DLL. COFF directives are checked first. LLVM bitcode is +inspected with the selected LLVM compiler, including `dllexport` declarations +and linker-option metadata. Only an unannotated DLL needs candidate enumeration; +bitcode candidates come from `llvm-nm` beside that compiler. Both FullLTO and +ThinLTO inputs can be mixed with ordinary COFF objects. + #### `windows_subsystem` and `windows_entry` — a Windows GUI executable (mcpp 2026.9.12.2+) ```toml diff --git a/docs/12-binary-distribution.md b/docs/12-binary-distribution.md index 4375609e..cd01f31c 100644 --- a/docs/12-binary-distribution.md +++ b/docs/12-binary-distribution.md @@ -372,9 +372,9 @@ exports at 65535. mcpp generates the `.def` from the objects, which is what CMake's `WINDOWS_EXPORT_ALL_SYMBOLS` has done since 3.4. It is a build-graph node whose inputs are the same objects the link consumes, so the exported surface cannot -drift from what was compiled, and it reads COFF directly rather than shelling out -to `dumpbin` — that tool lives in a Visual Studio developer environment, and -mcpp's default Windows toolchain is clang. +drift from what was compiled. Ordinary COFF objects are read directly. LLVM +bitcode inspection (unreleased source builds) uses the selected LLVM compiler +and the `llvm-nm` beside it; both FullLTO and ThinLTO are accepted. **Two limits survive that no tool can remove**, and they are the same two CMake documents for the same mechanism: @@ -386,10 +386,12 @@ documents for the same mechanism: Both are answered by annotation, and **annotation wins**: an object that already carries `/EXPORT:` directives — which is what `__declspec(dllexport)` emits — -makes mcpp stand down and generate nothing. Adding a list on top would export the +makes mcpp write an empty `EXPORTS` section. Adding a list on top would export the same names twice (`LNK4197`) and export everything else besides, replacing a -chosen public surface with all of it. Nothing is configured for this; the objects -say it. +chosen public surface with all of it. Bitcode's `dllexport` storage class and +linker-option metadata express the same intent. Export intent is checked before +candidate enumeration. The per-target [`auto_export`](04-mcpp-toml.md#auto_export--native-export-control-on-the-msvc-abi-unreleased) +key disables discovery entirely (unreleased source builds). Past 65535 exportable symbols mcpp refuses rather than truncating. A truncated export table links cleanly and then fails at whichever consumer needed the symbol diff --git a/docs/README.md b/docs/README.md index 4eea17b3..10ba8fd3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -118,6 +118,7 @@ token in front of a reader to the chapter that owns it. | | chapter | | chapter | |---|---|---|---| | `[package]`, `[targets.]`, `[build]`, `[lib]` | [04](04-mcpp-toml.md) | `[profile.]`, `[resources]`, `[runtime]` | [04](04-mcpp-toml.md) | +| `[targets.] auto_export` | [04](04-mcpp-toml.md) | `[targets.] exports` | [04](04-mcpp-toml.md) | | `[dependencies]`, `[dev-dependencies]`, `[build-dependencies]` | [05](05-dependencies.md) | `scan_overrides`, `module_extensions` | [04](04-mcpp-toml.md) | | `[features]`, `[feature-deps.]`, `provides` / `requires` | [06](06-features-and-capabilities.md) | `[workspace]` | [07](07-workspace.md) | | `[toolchain]`, `cxx_runtime` | [20](20-toolchains.md) | `[target.]`, `cfg(…)` | [22](22-target-side.md) | diff --git a/docs/zh/04-mcpp-toml.md b/docs/zh/04-mcpp-toml.md index d3b4048d..4c6ec44a 100644 --- a/docs/zh/04-mcpp-toml.md +++ b/docs/zh/04-mcpp-toml.md @@ -265,9 +265,9 @@ soname = "libmydriver.so.1" exports = "abi/mydriver.exports" # or inline: exports = ["vk_icd*"] ``` -**省略这个键会发布一切,而这恰好是两个平台本来就在做的事**——ELF 给 -符号默认可见性,PE 自动生成一份列出每个符号的 `.def`。`exports` 收窄 -这个范围。 +省略 `exports` 会保留 ELF 和 Mach-O 的原生可见性规则。在 MSVC ABI 上, +mcpp 会发现可导出的外部定义;任一输入已经声明导出,或设置了 +`auto_export = false` 时不进行这种发现。`exports` 收窄链接器发布的集合。 两类工程需要这种收窄。**带稳定 ABI 的运行时**只发布一份经过审查的 集合,其余一概不发布,让不在集合里的东西保留自由变化的空间。**与同类 @@ -300,6 +300,28 @@ script 并通过 `[build] ldflags` 传入,或者自行计算并发出 [`dependency_linkage`](#dependency_linkage--静态还是动态由消费者决定), 在那里,一个库采取的形式变成消费者的决定。 +#### `auto_export` —— MSVC ABI 上的原生导出控制(尚未发布) + +这个键和 LLVM bitcode 导出发现需要尚未发布的源码构建,mcpp 2026.10.3.1 +不提供这些能力。自行控制导出的 target 可以省略自动导出发现步骤: + +```toml +[targets.plugin] +kind = "shared" +auto_export = false +``` + +这个布尔值默认为 `true`,只作用于 MSVC ABI 上的 PE 共享库,包括 clang 和 +clang-cl;库作为依赖构建时也生效。它不影响静态库、可执行文件、ELF、Mach-O +或 MinGW。关闭发现后,原生 `__declspec(dllexport)`、链接旗标和显式 `exports` +列表仍然生效。 + +开启发现时,任一输入的显式导出意图都会禁止整个 DLL 的自动导出。首先检查 +COFF 指令,再使用所选 LLVM 编译器检查 bitcode 中的 `dllexport` 声明和 +linker-option 元数据。只有没有标注的 DLL 才需要枚举候选符号,bitcode 的 +候选符号由该编译器旁的 `llvm-nm` 提供。FullLTO 和 ThinLTO 输入都可以与 +普通 COFF 对象混用。 + #### `windows_subsystem` 与 `windows_entry` —— Windows GUI 可执行文件(mcpp 2026.9.12.2+) ```toml diff --git a/docs/zh/12-binary-distribution.md b/docs/zh/12-binary-distribution.md index efc70d32..415b62f4 100644 --- a/docs/zh/12-binary-distribution.md +++ b/docs/zh/12-binary-distribution.md @@ -343,9 +343,9 @@ error while loading shared libraries: libstdc++.so.6: cannot open shared object mcpp 从对象文件生成 `.def`——这正是 CMake 的 `WINDOWS_EXPORT_ALL_SYMBOLS` 自 3.4 起在做的事。它是构建图里的一个节点,输入就是链接所消费的那批对象, -因此导出面不会与「实际编译了什么」发生漂移;而且它直接读 COFF,不会 shell 出去 -调用 `dumpbin`——那个工具存在于 Visual Studio 的开发者环境里,而 mcpp 在 -Windows 上的默认工具链是 clang。 +因此导出面不会与「实际编译了什么」发生漂移。普通 COFF 对象直接读取;LLVM +bitcode 检查(尚未发布的源码构建)使用所选 LLVM 编译器和它旁边的 `llvm-nm`, +支持 FullLTO 和 ThinLTO。 **有两条限制是任何工具都消不掉的**,与 CMake 为同一机制记录的正是同样两条: @@ -355,10 +355,12 @@ Windows 上的默认工具链是 clang。 | **vtable** 被引用的类 | 整个类都要被标注,例如一个带虚函数的类的委托构造函数 | 两者都靠标注来回答,而且**标注优先**:一个已经带 `/EXPORT:` 指令的对象——那 -正是 `__declspec(dllexport)` 产生的——会让 mcpp 让开,什么都不生成。在其上再 +正是 `__declspec(dllexport)` 产生的——会让 mcpp 写入空的 `EXPORTS` 节。在其上再 叠加一份列表,会把同一批符号导出两次(`LNK4197`),还会把其余所有符号也一并 -导出,用「全部」取代作者选定的那个公开面。这件事没有任何东西需要配置:对象 -文件自己说了算。 +导出,用「全部」取代作者选定的那个公开面。bitcode 的 `dllexport` 存储类别和 +linker-option 元数据表达同样的意图。导出意图在枚举候选符号前检查。每个 target +可以通过 [`auto_export`](04-mcpp-toml.md#auto_export--msvc-abi-上的原生导出控制尚未发布) +完全关闭导出发现(尚未发布的源码构建)。 超过 65535 个可导出符号时,mcpp 拒绝而不是截断。一个被截断的导出表能干净地 链接完成,随后在恰好需要那个掉出去的符号的消费方那里失败。 diff --git a/docs/zh/README.md b/docs/zh/README.md index 9af4ddca..6b9e3897 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -114,6 +114,7 @@ | | 章节 | | 章节 | |---|---|---|---| | `[package]`、`[targets.]`、`[build]`、`[lib]` | [04](04-mcpp-toml.md) | `[profile.]`、`[resources]`、`[runtime]` | [04](04-mcpp-toml.md) | +| `[targets.] auto_export` | [04](04-mcpp-toml.md) | `[targets.] exports` | [04](04-mcpp-toml.md) | | `[dependencies]`、`[dev-dependencies]`、`[build-dependencies]` | [05](05-dependencies.md) | `scan_overrides`、`module_extensions` | [04](04-mcpp-toml.md) | | `[features]`、`[feature-deps.]`、`provides` / `requires` | [06](06-features-and-capabilities.md) | `[workspace]` | [07](07-workspace.md) | | `[toolchain]`、`cxx_runtime` | [20](20-toolchains.md) | `[target.]`、`cfg(…)` | [22](22-target-side.md) | diff --git a/modules/manifest/src/toml.cppm b/modules/manifest/src/toml.cppm index 9af08529..d6773181 100644 --- a/modules/manifest/src/toml.cppm +++ b/modules/manifest/src/toml.cppm @@ -1849,6 +1849,12 @@ std::expected parse_string(std::string_view content, if (auto msg = validate_target_soname(t, std::format("targets.{}.", tname))) { return std::unexpected(error(origin, *msg)); } + if (auto it = tt.find("auto_export"); it != tt.end()) { + if (!it->second.is_bool()) + return std::unexpected(error(origin, std::format( + "targets.{}.auto_export must be a boolean", tname))); + t.autoExport = it->second.as_bool(); + } // `exports` -- a file of symbol patterns, or the patterns inline. // // BOTH FORMS, because the two are used at different scales and the ABI @@ -1983,7 +1989,7 @@ std::expected parse_string(std::string_view content, // 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", "linkage", "main", "soname", "exports", + "kind", "linkage", "main", "soname", "exports", "auto_export", "cflags", "cxxflags", "defines", "required_features", "windows_entry", "windows_subsystem", "windows_code_page", }; diff --git a/modules/manifest/src/types.cppm b/modules/manifest/src/types.cppm index 13340830..be3a6662 100644 --- a/modules/manifest/src/types.cppm +++ b/modules/manifest/src/types.cppm @@ -178,6 +178,9 @@ struct Target { // stand in for it, adds `Application` guarded by the row's actual form. bool is_program() const { return kind == Binary || kind == Application; } std::string soname; // ABI name for shared libraries, e.g. libfoo.so.1 + // PE / MSVC ABI only: discover exports when the objects do not declare + // any. False leaves export control entirely to the native linker inputs. + bool autoExport = true; // WHICH SYMBOLS THIS ARTIFACT PUBLISHES. Empty = every symbol, which is // what both platforms do today (ELF default visibility; PE gets an // auto-generated .def listing everything, mcpp.build.coff_exports). diff --git a/modules/manifest/src/xpkg.cppm b/modules/manifest/src/xpkg.cppm index d099d4d8..228e16d0 100644 --- a/modules/manifest/src/xpkg.cppm +++ b/modules/manifest/src/xpkg.cppm @@ -1626,6 +1626,13 @@ synthesize_from_xpkg_lua(std::string_view luaContent, t.main = cur.read_string(); } else if (sub == "soname") { t.soname = cur.read_string(); + } else if (sub == "auto_export") { + auto raw = cur.read_bareword(); + if (raw != "true" && raw != "false") + return std::unexpected(ManifestError{ + std::format("targets.{}.auto_export must be a boolean", tname), + m.sourcePath, 0, 0}); + t.autoExport = raw == "true"; } else if (sub == "required_features") { // #355: without this, a Form B descriptor could not // express the cost gate that makes an optional host diff --git a/src/build/coff_exports.cppm b/src/build/coff_exports.cppm index 43d1aea8..02b5cc20 100644 --- a/src/build/coff_exports.cppm +++ b/src/build/coff_exports.cppm @@ -74,6 +74,10 @@ bool is_supported_machine(std::uint16_t machine); // place to say something the objects already say, and the two could disagree. bool declares_exports(std::span bytes); +// Shared by the COFF and LLVM symbol readers so LTO does not change the +// filtering policy or i386 calling-convention spelling. +std::optional export_name(std::string_view name, bool i386); + // Read one object file's exportable symbols. `bytes` is a whole `.obj`. // // Returns an error string for input this reader cannot honestly interpret — @@ -151,6 +155,15 @@ bool is_skipped_name(std::string_view n) { } // namespace +std::optional export_name(std::string_view name, bool i386) { + if (name.empty() || is_skipped_name(name)) return std::nullopt; + std::string out(name); + if (i386 && out.starts_with('_') && out.find('@') == std::string::npos) + out.erase(0, 1); + if (out.empty()) return std::nullopt; + return out; +} + bool declares_exports(std::span bytes) { if (bytes.size() < kFileHeaderSize) return false; const auto numSections = rd16(bytes, 2); @@ -293,13 +306,8 @@ read_exports(std::span bytes) auto nm = name_at(rec); if (!nm) return std::unexpected(nm.error()); - if (nm->empty() || is_skipped_name(*nm)) continue; - - // i386 (and `__cdecl` on it) carries a leading underscore that is part - // of the calling convention rather than of the name. - std::string name = *nm; - if (machine == kMachineI386 && name.starts_with('_') && name.find('@') == std::string::npos) - name.erase(0, 1); + auto name = export_name(*nm, machine == kMachineI386); + if (!name) continue; const auto flags = std::size_t(sectionNum) < sectionFlags.size() ? sectionFlags[sectionNum] : 0u; @@ -308,7 +316,7 @@ read_exports(std::span bytes) // section (a `const`) is still data. const bool isData = type != kSymTypeFunction && (flags & kScnMemExecute) == 0; - out.push_back(Export{ std::move(name), isData }); + out.push_back(Export{ std::move(*name), isData }); } return out; diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index bc15b36b..b6a268c5 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -38,6 +38,8 @@ import mcpp.diag; import mcpp.build.advice; import mcpp.dyndep; import mcpp.toolchain.detect; +import mcpp.toolchain.model; +import mcpp.modgraph.glob; import mcpp.toolchain.dialect; import mcpp.toolchain.provider; import mcpp.toolchain.registry; @@ -2152,7 +2154,7 @@ std::string emit_ninja_string(const BuildPlan& plan, std::string* placements, // POSIX-shell command is skipped entirely on Windows, the only platform this // edge exists for. append("rule coff_def\n"); - append(" command = $mcpp coff-def --output $out --name $def_name $in\n"); + append(" command = $mcpp coff-def --output $out --name $def_name $coff_tools $in\n"); append(" description = DEF $out\n\n"); // A WINDOWS PROGRAM'S RUNTIME DLLS, PLACED AFTER ITS LINK (SPEC-007 R4.3). @@ -3195,8 +3197,21 @@ std::string emit_ninja_string(const BuildPlan& plan, std::string* placements, for (auto const& o : lu.objects) defIns += " " + escape_ninja_path(o); append(std::format("build {} : coff_def{}\n", escape_ninja_path(lu.defFile), defIns)); - append(std::format(" def_name = {}\n\n", + append(std::format(" def_name = {}\n", lu.output.filename().string())); + if (mcpp::toolchain::is_clang(plan.toolchain)) { + const auto& compiler = plan.toolchain.binaryPath; + const auto nm = compiler.parent_path() + / (mcpp::platform::is_windows ? "llvm-nm.exe" : "llvm-nm"); + auto cxxArg = mcpp::modgraph::try_narrow(compiler); + auto nmArg = mcpp::modgraph::try_narrow(nm); + if (!cxxArg || !nmArg) + throw std::runtime_error("the selected LLVM tools have no UTF-8 spelling"); + append(" coff_tools = --llvm-cxx " + ninja_command_word(*cxxArg) + + " --llvm-nm " + ninja_command_word(*nmArg) + + " --llvm-target " + ninja_command_word(plan.toolchain.targetTriple) + "\n"); + } + append("\n"); } if (!lu.defFile.empty()) implicit += " " + escape_ninja_path(lu.defFile); diff --git a/src/build/pe_exports.cppm b/src/build/pe_exports.cppm new file mode 100644 index 00000000..21fbf1c3 --- /dev/null +++ b/src/build/pe_exports.cppm @@ -0,0 +1,250 @@ +// mcpp.build.pe_exports — export intent precedes candidate discovery, whether +// the MSVC-ABI linker inputs are COFF, LLVM bitcode, or a mixture of both. +export module mcpp.build.pe_exports; + +import std; +import mcpp.build.coff_exports; +import mcpp.modgraph.glob; +import mcpp.platform.fs; +import mcpp.platform.process; + +export namespace mcpp::build::pe { + +struct LLVMTools { + std::filesystem::path compiler; + std::filesystem::path nm; + std::string target; +}; + +bool is_bitcode(std::span bytes); +bool ir_declares_exports(std::string_view ir); +std::expected, std::string> +read_nm_exports(std::string_view text, bool i386); + +// Inputs that already declare their exported surface produce an empty result. +// No candidate reader is entered on that path; in particular, annotated COFF +// does not require LLVM tools merely because another input happens to be LTO. +std::expected, std::string> +read_exports(std::span objects, const LLVMTools& tools); + +} // namespace mcpp::build::pe + +namespace mcpp::build::pe { +namespace { + +// LLVM's printer quotes names and strings and uses hexadecimal escapes inside +// them. Keep those tokens whole: neither a name nor string data is DLL storage +// intent. Semicolon comments are also outside the token stream. +std::vector tokens(std::string_view line) { + std::vector out; + for (std::size_t i = 0; i < line.size();) { + if (std::isspace(static_cast(line[i]))) { ++i; continue; } + if (line[i] == ';') break; + auto begin = i++; + if (line[begin] == '"') { + while (i < line.size()) { + if (line[i] == '\\') { i = std::min(i + 3, line.size()); continue; } + if (line[i++] == '"') break; + } + } else if (std::string_view("{}()[]=,").find(line[begin]) == std::string_view::npos) { + while (i < line.size() + && !std::isspace(static_cast(line[i])) + && std::string_view("{}()[]=,;\"").find(line[i]) == std::string_view::npos) + ++i; + } + out.push_back(line.substr(begin, i - begin)); + } + return out; +} + +std::string unquote(std::string_view token) { + std::string out; + if (token.size() < 2 || token.front() != '"' || token.back() != '"') return out; + auto hex = [](char c) -> int { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; + }; + for (std::size_t i = 1; i + 1 < token.size(); ++i) { + if (token[i] == '\\' && i + 3 < token.size()) { + int hi = hex(token[i + 1]), lo = hex(token[i + 2]); + if (hi >= 0 && lo >= 0) { + out.push_back(static_cast((hi << 4) | lo)); + i += 2; + continue; + } + } + out.push_back(token[i]); + } + return out; +} + +bool export_directive(std::string text) { + for (auto& c : text) c = static_cast(std::tolower(static_cast(c))); + for (auto spelling : {"/export:", "-export:"}) { + auto pos = text.find(spelling); + while (pos != std::string::npos) { + if (pos == 0 || text[pos - 1] == '"' + || std::isspace(static_cast(text[pos - 1]))) return true; + pos = text.find(spelling, pos + 1); + } + } + return false; +} + +std::expected, std::string> +read_object(const std::filesystem::path& path) { + std::ifstream in(mcpp::platform::fs::extended_length(path), std::ios::binary); + if (!in) return std::unexpected("cannot read object"); + std::string data((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + if (in.bad()) return std::unexpected("cannot finish reading object"); + auto bytes = std::as_bytes(std::span(data)); + return std::vector(bytes.begin(), bytes.end()); +} + +std::expected +run_tool(const std::filesystem::path& tool, std::vector args) { + if (tool.empty()) + return std::unexpected("LLVM bitcode requires tools from the selected LLVM installation"); + auto name = mcpp::modgraph::try_narrow(tool); + if (!name) return std::unexpected("the selected LLVM tool has no UTF-8 spelling"); + args.insert(args.begin(), *name); + auto result = mcpp::platform::process::capture_exec(args); + if (result.exit_code != 0) + return std::unexpected(std::format("{} exited with status {}:\n{}", + *name, result.exit_code, result.output)); + return std::move(result.output); +} + +} // namespace + +bool is_bitcode(std::span bytes) { + if (bytes.size() < 4) return false; + const std::array raw{std::byte{0x42}, std::byte{0x43}, std::byte{0xc0}, std::byte{0xde}}; + const std::array wrapped{std::byte{0xde}, std::byte{0xc0}, std::byte{0x17}, std::byte{0x0b}}; + return std::ranges::equal(bytes.first(4), raw) + || std::ranges::equal(bytes.first(4), wrapped); +} + +bool ir_declares_exports(std::string_view ir) { + std::map> metadata; + for (auto line : ir | std::views::split('\n')) { + auto text = std::string_view(line); + auto begin = text.find_first_not_of(" \t\r"); + if (begin == std::string_view::npos) continue; + text.remove_prefix(begin); + // Bodies, debug comments and attribute lists cannot carry export + // intent. Do not tokenize every instruction of a large LTO input. + if (text.front() != '@' && text.front() != '!' + && !text.starts_with("define ") && !text.starts_with("declare ") + && !text.starts_with("module asm ")) continue; + auto words = tokens(text); + if (words.empty()) continue; + if (words.front() == "define" || words.front() == "declare" + || words.front().starts_with('@')) { + // The declaration header ends at the argument list / initializer. + // A local identifier or data literal named dllexport is not a flag. + for (auto word : words) { + if (word == "dllexport") return true; + if (word == "(" || word == "global" || word == "constant" + || word == "alias" || word == "ifunc") break; + } + } + if (words.front() == "module" && words.size() >= 3 && words[1] == "asm" + && export_directive(unquote(words.back()))) return true; + if (words.size() >= 3 && words.front().starts_with('!') && words[1] == "=") + metadata.emplace(words.front(), std::move(words)); + } + + // A pragma's linker options survive LTO as metadata. Follow only this root; + // debug metadata and unrelated string constants are not linker directives. + std::vector pending{"!llvm.linker.options"}; + std::set visited; + while (!pending.empty()) { + auto key = pending.back(); + pending.pop_back(); + if (!visited.insert(key).second) continue; + auto it = metadata.find(key); + if (it == metadata.end()) continue; + for (std::size_t i = 2; i < it->second.size(); ++i) { + auto word = it->second[i]; + if (word.starts_with('"') && export_directive(unquote(word))) return true; + if (word.size() > 1 && word.front() == '!' + && std::isdigit(static_cast(word[1]))) pending.push_back(word); + } + } + return false; +} + +std::expected, std::string> +read_nm_exports(std::string_view text, bool i386) { + std::vector out; + for (auto line : text | std::views::split('\n')) { + std::istringstream in{std::string(std::string_view(line))}; + std::string name, type, value; + if (!(in >> name)) continue; + if (!(in >> type >> value) || type.size() != 1) + return std::unexpected("unexpected llvm-nm POSIX output: " + std::string(std::string_view(line))); + // COFF COMDAT definitions are external symbols too: weak definitions + // must not disappear merely because the compiler used LTO. + const bool code = type == "T" || type == "W"; + const bool data = type == "D" || type == "B" || type == "R" || type == "V"; + if (!code && !data) continue; + auto exported = coff::export_name(name, i386); + if (exported) out.push_back({std::move(*exported), data}); + } + return out; +} + +std::expected, std::string> +read_exports(std::span objects, const LLVMTools& tools) { + auto error = [](const std::filesystem::path& obj, std::string message) { + return std::unexpected(mcpp::modgraph::escaped_spelling(obj) + ": " + message); + }; + std::vector bitcode; + for (auto const& obj : objects) { + auto bytes = read_object(obj); + if (!bytes) return error(obj, bytes.error()); + bitcode.push_back(is_bitcode(*bytes)); + if (!bitcode.back() && coff::declares_exports(*bytes)) return std::vector{}; + } + std::vector arguments; + for (auto const& obj : objects) { + auto arg = mcpp::modgraph::try_narrow(obj); + if (!arg) return error(obj, "object has no UTF-8 spelling"); + arguments.push_back(std::move(*arg)); + } + const bool i386 = tools.target.starts_with("i386-") || tools.target.starts_with("i686-") + || tools.target.starts_with("x86-"); + for (std::size_t i = 0; i < objects.size(); ++i) { + if (!bitcode[i]) continue; + if (tools.target.empty()) return error(objects[i], "LLVM bitcode inspection requires the selected target triple"); + std::vector args{"--driver-mode=g++", "-S", "-emit-llvm", "-x", "ir", + "--target=" + tools.target}; + args.insert(args.end(), {arguments[i], "-o", "-"}); + auto ir = run_tool(tools.compiler, std::move(args)); + if (!ir) return error(objects[i], ir.error()); + if (ir_declares_exports(*ir)) return std::vector{}; + } + std::vector all; + for (std::size_t i = 0; i < objects.size(); ++i) { + std::expected, std::string> symbols; + if (bitcode[i]) { + auto text = run_tool(tools.nm, {"--quiet", "--format=posix", "--extern-only", "--defined-only", + "--no-demangle", arguments[i]}); + if (!text) return error(objects[i], text.error()); + symbols = read_nm_exports(*text, i386); + } else { + auto bytes = read_object(objects[i]); + if (!bytes) return error(objects[i], bytes.error()); + symbols = coff::read_exports(*bytes); + } + if (!symbols) return error(objects[i], symbols.error()); + all.insert(all.end(), std::make_move_iterator(symbols->begin()), + std::make_move_iterator(symbols->end())); + } + return all; +} + +} // namespace mcpp::build::pe diff --git a/src/build/plan.cppm b/src/build/plan.cppm index 1e068831..61356069 100644 --- a/src/build/plan.cppm +++ b/src/build/plan.cppm @@ -2508,7 +2508,7 @@ make_plan(const mcpp::manifest::Manifest& manifest, lu.dependencyOwned = true; lu.output = dep.output; lu.importLibrary = import_library_for(dep.target, naming); - if (msvcTarget && !lu.importLibrary.empty()) + if (msvcTarget && dep.target.autoExport && !lu.importLibrary.empty()) lu.defFile = std::filesystem::path("bin") / (dep.target.name + ".def"); lu.soname = dep.target.soname; lu.exportPatterns = dep.target.exportPatterns; @@ -2640,7 +2640,7 @@ make_plan(const mcpp::manifest::Manifest& manifest, lu.importLibrary = import_library_for(t, naming); // MSVC only: MinGW's linker auto-exports, and generating a second // source of truth for what a DLL exports is how the two disagree. - if (msvcTarget && !lu.importLibrary.empty()) + if (msvcTarget && t.autoExport && !lu.importLibrary.empty()) lu.defFile = std::filesystem::path("bin") / (t.name + ".def"); lu.soname = t.soname; lu.exportPatterns = t.exportPatterns; diff --git a/src/cli.cppm b/src/cli.cppm index cf80a836..ffab28db 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -983,9 +983,12 @@ int run(int argc, char** argv) { .help("the selected toolset's C++ runtime directory")) .action(wrap_rc(cmd_place_dlls))) .subcommand(cl::App("coff-def") - .description("(internal: invoked by ninja) Write a .def of every exportable symbol in the given COFF objects") + .description("(internal: invoked by ninja) Discover exports in COFF and LLVM bitcode objects") .option(cl::Option("output").takes_value().value_name("PATH").help("the .def to write")) .option(cl::Option("name").takes_value().value_name("DLL").help("LIBRARY name recorded in the .def")) + .option(cl::Option("llvm-cxx").takes_value().value_name("PATH").help("selected LLVM compiler for bitcode inspection")) + .option(cl::Option("llvm-nm").takes_value().value_name("PATH").help("llvm-nm from the selected LLVM installation")) + .option(cl::Option("llvm-target").takes_value().value_name("TRIPLE").help("selected target triple")) .action(wrap_rc(cmd_coff_def))) .subcommand(cl::App("bmi-equal") .description("(internal: invoked by ninja) Compare two BMIs ignoring the compiler's embedded timestamp") diff --git a/src/cli/cmd_build.cppm b/src/cli/cmd_build.cppm index 3609b613..0e9ef994 100644 --- a/src/cli/cmd_build.cppm +++ b/src/cli/cmd_build.cppm @@ -17,6 +17,7 @@ import mcpp.bmi_cache.maintenance; // parse_duration, for `clean --stale --old import mcpp.build.directives; // the device-slot table import mcpp.build.configure; import mcpp.build.coff_exports; +import mcpp.build.pe_exports; import mcpp.build.stage; import mcpp.build.schedule.detach_codegen; import mcpp.build.test_targets; @@ -1449,38 +1450,23 @@ export int cmd_coff_def(const mcpplibs::cmdline::ParsedArgs& parsed) { std::string libName; if (auto v = parsed.value("name")) libName = *v; - std::vector all; - // When the AUTHOR has annotated the surface, the annotation wins and this - // edge writes an empty EXPORTS section — the linker then takes its export - // set from the objects' own `/EXPORT:` directives, exactly as if mcpp were - // not here. Adding a generated list on top would export the same names twice - // (LNK4197) and, worse, would export everything else as well, replacing a - // chosen public surface with all of it. - bool annotated = false; - for (std::size_t i = 0; i < parsed.positional_count(); ++i) { - const std::filesystem::path obj{ parsed.positional(i) }; - std::ifstream in(mcpp::platform::fs::extended_length(obj), std::ios::binary); - if (!in) { - std::println(stderr, "error: cannot read object '{}'", obj.string()); - return 1; - } - std::vector bytes; - for (char c; in.get(c); ) bytes.push_back(static_cast(c)); - if (mcpp::build::coff::declares_exports(bytes)) annotated = true; - auto syms = mcpp::build::coff::read_exports(bytes); - if (!syms) { - // Named with the object, because "which one" is the whole question - // when one file out of two hundred is the problem. - std::println(stderr, "error: {}: {}", obj.string(), syms.error()); - return 1; - } - all.insert(all.end(), syms->begin(), syms->end()); + mcpp::build::pe::LLVMTools tools; + if (auto v = parsed.value("llvm-cxx")) tools.compiler = *v; + if (auto v = parsed.value("llvm-nm")) tools.nm = *v; + if (auto v = parsed.value("llvm-target")) tools.target = *v; + std::vector objects; + for (std::size_t i = 0; i < parsed.positional_count(); ++i) + objects.emplace_back(parsed.positional(i)); + auto exports = mcpp::build::pe::read_exports(objects, tools); + if (!exports) { + std::println(stderr, "error: {}", exports.error()); + return 1; } + auto all = std::move(*exports); // Refused, not truncated. A `.def` cut at the ceiling links cleanly and // then fails at whichever consumer happens to need a symbol that fell off // the end — a diagnostic with no path back to this decision. - if (annotated) all.clear(); std::ranges::sort(all); all.erase(std::ranges::unique(all).begin(), all.end()); if (all.size() > mcpp::build::coff::kMaxExports) { diff --git a/tests/e2e/00_fixture_path_hygiene.sh b/tests/e2e/00_fixture_path_hygiene.sh index 1795f2f5..d1351b08 100755 --- a/tests/e2e/00_fixture_path_hygiene.sh +++ b/tests/e2e/00_fixture_path_hygiene.sh @@ -25,6 +25,20 @@ set -euo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" +source "$HERE/_host_path.sh" + +# CI passes MCPP_HOME in native Windows spelling, not just Git Bash spelling. +# Both must produce paths safe to interpolate into TOML basic strings. +case "$(uname -s)" in + MINGW* | MSYS* | CYGWIN*) + [[ "$(host_path 'C:\Users\runner admin\.mcpp/registry')" == 'C:/Users/runner admin/.mcpp/registry' ]] || { + echo "FAIL: host_path did not normalize a native Windows absolute path" + exit 1 + } + [[ "$(host_path 'C:/Users/runner admin/.mcpp/registry')" == 'C:/Users/runner admin/.mcpp/registry' ]] || exit 1 + ;; +esac +[[ "$(host_path '../relative directory')" == '../relative directory' ]] || exit 1 # A TOML `path` key: either at the start of a line, or inside an inline table # (`{ path = "..." }`). Anchoring this way is what keeps shell assignments such diff --git a/tests/e2e/881_pe_auto_exports_accept_llvm_bitcode.sh b/tests/e2e/881_pe_auto_exports_accept_llvm_bitcode.sh new file mode 100644 index 00000000..f28fbdde --- /dev/null +++ b/tests/e2e/881_pe_auto_exports_accept_llvm_bitcode.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +# requires: msvc python3 +# 881 -- mcpp#762: PE auto-export accepts LLVM LTO objects while preserving an +# explicitly annotated surface. Opting out removes the scanner from the graph. +set -e +source "$(dirname "$0")/_host_path.sh" +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } +MCPP="${MCPP:-mcpp}" +REGISTRY_HOST=$(host_path "${MCPP_HOME:-$HOME/.mcpp}/registry") +export MCPP_HOME="$TMP/home" +mkdir -p "$MCPP_HOME" +cat > "$MCPP_HOME/config.toml" < automatic/mcpp.toml <<'EOF' +[package] +name = "automatic" +version = "0.1.0" +[toolchain] +windows = "llvm@20.1.7" +[profile.release] +lto = true +[targets.automatic] +kind = "shared" +EOF +printf 'export module t881_lto;\nexport int answer();\n' > automatic/src/api.cppm +printf 'module t881_lto;\nint answer() { return 42; }\n' > automatic/src/impl.cpp +printf 'extern "C" int exported_data = 9;\n' > automatic/src/data.cpp +cat > app/mcpp.toml <<'EOF' +[package] +name = "app" +version = "0.1.0" +[dependencies] +automatic = { path = "../automatic" } +[toolchain] +windows = "llvm@20.1.7" +EOF +printf 'import t881_lto;\nint main() { return answer() == 42 ? 0 : 1; }\n' > app/src/main.cpp +(cd automatic && "$MCPP" build --profile release > build.log 2>&1) || fail "A: the LTO DLL did not build" automatic/build.log +(cd app && "$MCPP" run --profile release > run.log 2>&1) || fail "A: the module consumer did not run" app/run.log +automatic=$(find automatic/target -name automatic.dll | head -1) +python3 - "$automatic" <<'PY' +import ctypes, os, sys +lib = ctypes.CDLL(os.path.abspath(sys.argv[1])) +assert ctypes.c_int.in_dll(lib, "exported_data").value == 9 +PY + +# Find the selected LLVM through its actual compile command, not another LLVM +# on PATH or a version that happens to be installed beside it. +db=$(find automatic/target -name compile_commands.json | head -1) +compiler=$(python3 - "$db" <<'PY' +import json, sys +entries = json.load(open(sys.argv[1], encoding="utf-8")) +print(next(e["arguments"][0] for e in entries if e["file"].endswith("data.cpp"))) +PY +) +nm="$(dirname "$compiler")/llvm-nm.exe" + +# B: annotations in bitcode define the whole surface, including DATA. A +# literal containing export-like text must not be mistaken for an annotation. +mkdir -p annotated/src +cat > annotated/mcpp.toml <<'EOF' +[package] +name = "annotated" +version = "0.1.0" +[toolchain] +windows = "llvm@20.1.7" +[profile.release] +lto = true +[targets.annotated] +kind = "shared" +EOF +cat > annotated/src/api.cpp <<'EOF' +extern "C" __declspec(dllexport) int chosen() { return 7; } +extern "C" __declspec(dllexport) int chosen_data = 11; +extern "C" int hidden() { return 3; } +EOF +(cd annotated && "$MCPP" build --profile release > build.log 2>&1) || fail "B: annotated bitcode did not build" annotated/build.log +annotated=$(find annotated/target -name annotated.dll | head -1) +python3 - "$annotated" <<'PY' +import ctypes, os, sys +lib = ctypes.CDLL(os.path.abspath(sys.argv[1])) +assert lib.chosen() == 7 +assert ctypes.c_int.in_dll(lib, "chosen_data").value == 11 +try: + lib.hidden +except AttributeError: + pass +else: + raise AssertionError("the scanner widened the annotated surface") +PY +def=$(find annotated/target -name annotated.def | head -1) +[ "$(grep -cvE '^(LIBRARY|EXPORTS|[[:space:]]*$)' "$def" || true)" -eq 0 ] || fail "B: an annotated library received automatic exports" "$def" + +# C: native-only export control never depends on coff-def. +printf '\n' >> annotated/mcpp.toml +sed 's/kind = "shared"/kind = "shared"\nauto_export = false/' annotated/mcpp.toml > native.toml +mv native.toml annotated/mcpp.toml +(cd annotated && "$MCPP" build --profile release > native.log 2>&1) || fail "C: opt-out did not build" annotated/native.log +nj=$(find annotated/target -name build.ninja | head -1) +if grep -qE '^build .* : coff_def' "$nj"; then fail "C: the opt-out still schedules export discovery" "$nj"; fi + +# The same target is also read through a path dependency, not just as the root. +mkdir -p native-consumer/src +cat > native-consumer/mcpp.toml <<'EOF' +[package] +name = "native-consumer" +version = "0.1.0" +[dependencies] +annotated = { path = "../annotated" } +[toolchain] +windows = "llvm@20.1.7" +EOF +printf 'extern "C" __declspec(dllimport) int chosen();\nint main() { return chosen() == 7 ? 0 : 1; }\n' > native-consumer/src/main.cpp +(cd native-consumer && "$MCPP" run --profile release > run.log 2>&1) || fail "C: dependency opt-out did not run" native-consumer/run.log +nj=$(find native-consumer/target -name build.ninja | head -1) +if grep -qE '^build .* : coff_def' "$nj"; then fail "C: the dependency opt-out still schedules export discovery" "$nj"; fi + +# D: an ordinary annotated COFF input takes precedence in either input order. +# Deliberately unavailable LLVM tools prove that the bitcode inspection and +# symbol enumeration paths are not entered once COFF already states the intent. +"$compiler" --driver-mode=g++ --target=x86_64-pc-windows-msvc -c annotated/src/api.cpp -o annotated.obj +"$compiler" --driver-mode=g++ --target=x86_64-pc-windows-msvc -flto=thin -c automatic/src/data.cpp -o thin.obj +for order in 'annotated.obj thin.obj' 'thin.obj annotated.obj'; do + "$MCPP" coff-def --output mixed.def --name mixed --llvm-cxx absent-clang --llvm-nm absent-nm $order > mixed.log 2>&1 || fail "D: annotation precedence depends on input order" mixed.log + [ "$(grep -cvE '^(LIBRARY|EXPORTS|[[:space:]]*$)' mixed.def || true)" -eq 0 ] || fail "D: mixed objects widened the surface" mixed.def +done + +# E: ThinLTO and textual export-like data. Only real IR declarations or linker +# option metadata express intent; a string literal does not disable auto-export. +cat > literal.cpp <<'EOF' +extern "C" int literal_value = 13; +extern "C" const char* message = "dllexport /EXPORT:pretend"; +EOF +"$compiler" --driver-mode=g++ --target=x86_64-pc-windows-msvc -flto=thin -c literal.cpp -o literal.obj +"$MCPP" coff-def --output literal.def --name literal --llvm-cxx "$compiler" --llvm-nm "$nm" --llvm-target x86_64-pc-windows-msvc literal.obj > literal.log 2>&1 || fail "E: ThinLTO candidates were not read" literal.log +grep -q 'literal_value DATA' literal.def || fail "E: literal text suppressed automatic exports" literal.def + +# F: pragma linker options in ThinLTO suppress discovery without llvm-nm. +cat > pragma.cpp <<'EOF' +#pragma comment(linker, "/EXPORT:pragma_api") +extern "C" int pragma_api() { return 19; } +extern "C" int unrelated() { return 1; } +EOF +"$compiler" --driver-mode=g++ --target=x86_64-pc-windows-msvc -flto=thin -c pragma.cpp -o pragma.obj +"$MCPP" coff-def --output pragma.def --name pragma --llvm-cxx "$compiler" --llvm-nm absent-nm --llvm-target x86_64-pc-windows-msvc pragma.obj > pragma.log 2>&1 || fail "F: linker metadata did not suppress candidate discovery" pragma.log +[ "$(grep -cvE '^(LIBRARY|EXPORTS|[[:space:]]*$)' pragma.def || true)" -eq 0 ] || fail "F: linker metadata widened the surface" pragma.def + +# G: ordinary COFF and bitcode contribute to the same unannotated surface. +printf 'extern "C" int native_api() { return 23; }\n' > native.cpp +"$compiler" --driver-mode=g++ --target=x86_64-pc-windows-msvc -c native.cpp -o native.obj +"$MCPP" coff-def --output union.def --name union --llvm-cxx "$compiler" --llvm-nm "$nm" --llvm-target x86_64-pc-windows-msvc native.obj literal.obj > union.log 2>&1 || fail "G: mixed candidate discovery failed" union.log +grep -q 'native_api$' union.def || fail "G: COFF function was lost" union.def +grep -q 'literal_value DATA' union.def || fail "G: bitcode data was lost" union.def + +# H: the selected target controls x86 decoration, not the host's architecture. +"$compiler" --driver-mode=g++ --target=i686-pc-windows-msvc -flto=thin -c literal.cpp -o x86.obj +"$MCPP" coff-def --output x86.def --name x86 --llvm-cxx "$compiler" --llvm-nm "$nm" --llvm-target i686-pc-windows-msvc x86.obj > x86.log 2>&1 || fail "H: x86 bitcode discovery failed" x86.log +grep -q 'literal_value DATA' x86.def || fail "H: x86 cdecl decoration was not normalized" x86.def +echo "PASS: 881_pe_auto_exports_accept_llvm_bitcode" diff --git a/tests/e2e/_host_path.sh b/tests/e2e/_host_path.sh index 165cb865..aff28632 100644 --- a/tests/e2e/_host_path.sh +++ b/tests/e2e/_host_path.sh @@ -36,8 +36,9 @@ host_path() { # agree and Windows accepts `/` as a separator. Passing one to `cygpath -m` # would silently ANCHOR it at the current directory — which is not where the # manifest being written lives — so relative input is returned untouched. + # MCPP_HOME can arrive from CI as C:\Users\...; it is absolute too. case "$1" in - /*) ;; + /* | [A-Za-z]:[\\/]*) ;; *) printf '%s' "$1"; return 0 ;; esac case "$(uname -s)" in diff --git a/tests/unit/test_manifest.cpp b/tests/unit/test_manifest.cpp index e3664b76..2ef6e772 100644 --- a/tests/unit/test_manifest.cpp +++ b/tests/unit/test_manifest.cpp @@ -9,6 +9,47 @@ import mcpp.platform.axis; import mcpp.platform; import mcpp.build.prepare; // merge_conditional_config: a row's statement replaces +TEST(Manifest, PeAutoExportDefaultsOnAndAcceptsAnExplicitOptOut) { + for (auto const& declaration : {"", "auto_export = true", "auto_export = false"}) { + auto m = mcpp::manifest::parse_string(std::format( + "[package]\nname = \"dll\"\nversion = \"0.1.0\"\n" + "[targets.dll]\nkind = \"shared\"\n{}\n", declaration)); + ASSERT_TRUE(m.has_value()) << m.error().format(); + ASSERT_EQ(m->targets.size(), 1u); + EXPECT_EQ(m->targets.front().autoExport, std::string_view(declaration) != "auto_export = false"); + EXPECT_TRUE(m->schemaWarnings.empty()); + } + auto bad = mcpp::manifest::parse_string( + "[package]\nname = \"dll\"\nversion = \"0.1.0\"\n" + "[targets.dll]\nkind = \"shared\"\nauto_export = \"false\"\n"); + ASSERT_FALSE(bad.has_value()); + EXPECT_NE(bad.error().format().find("auto_export must be a boolean"), std::string::npos); +} + +TEST(SynthesizeFromXpkgLua, PeAutoExportHasTheSameDefaultAndBooleanContract) { + for (auto const& declaration : {"", "auto_export = true,", "auto_export = false,"}) { + auto m = mcpp::manifest::synthesize_from_xpkg_lua(std::format(R"( +package = {{ + spec = "1", name = "dll", + xpm = {{ windows = {{ ["0.1.0"] = {{ url = "u", sha256 = "h" }} }} }}, + mcpp = {{ sources = {{ "*/api.cpp" }}, targets = {{ ["dll"] = {{ kind = "shared", {} }} }} }}, +}} +)", declaration), "dll", "0.1.0", mcpp::platform::HostPlatform::current()); + ASSERT_TRUE(m.has_value()) << m.error().format(); + ASSERT_EQ(m->targets.size(), 1u); + EXPECT_EQ(m->targets.front().autoExport, std::string_view(declaration) != "auto_export = false,"); + } + auto bad = mcpp::manifest::synthesize_from_xpkg_lua(R"( +package = { + spec = "1", name = "dll", + xpm = { windows = { ["0.1.0"] = { url = "u", sha256 = "h" } } }, + mcpp = { sources = { "*/api.cpp" }, targets = { ["dll"] = { kind = "shared", auto_export = "false" } } }, +} +)", "dll", "0.1.0", mcpp::platform::HostPlatform::current()); + ASSERT_FALSE(bad.has_value()); + EXPECT_NE(bad.error().format().find("auto_export must be a boolean"), std::string::npos); +} + TEST(Manifest, CppFlyStandard) { // standard = "c++fly": latest level + all experimental gates (design // 2026-07-14-std-features-experimental-gate-design.md §5.1). diff --git a/tests/unit/test_ninja_backend.cpp b/tests/unit/test_ninja_backend.cpp index 20d0e28c..59756d3d 100644 --- a/tests/unit/test_ninja_backend.cpp +++ b/tests/unit/test_ninja_backend.cpp @@ -75,6 +75,31 @@ BuildPlan minimal_plan() { } // namespace +TEST(NinjaBackend, BitcodeExportToolsComeFromTheSelectedLlvmAndAreQuotedAsWords) { + auto plan = minimal_plan(); + plan.toolchain.compiler = mcpp::toolchain::CompilerId::Clang; + plan.toolchain.targetTriple = "x86_64-pc-windows-msvc"; + plan.toolchain.binaryPath = "/selected llvm/$installation/bin/clang++"; + LinkUnit dll; + dll.kind = LinkUnit::SharedLibrary; + dll.targetName = "probe"; + dll.output = "bin/probe.dll"; + dll.importLibrary = "bin/probe.lib"; + dll.defFile = "bin/probe.def"; + dll.objects = {"obj/probe.o"}; + plan.linkUnits.push_back(dll); + auto text = emit_ninja_string(plan); + const auto nm = plan.toolchain.binaryPath.parent_path() + / (mcpp::platform::is_windows ? "llvm-nm.exe" : "llvm-nm"); + EXPECT_NE(text.find("--llvm-cxx " + ninja_command_word(plan.toolchain.binaryPath.generic_string())), std::string::npos); + EXPECT_NE(text.find("--llvm-nm " + ninja_command_word(nm.generic_string())), std::string::npos); + EXPECT_NE(text.find("--llvm-target x86_64-pc-windows-msvc"), std::string::npos); + EXPECT_NE(text.find("build bin/probe.def : coff_def obj/probe.o"), std::string::npos); + plan.toolchain.compiler = mcpp::toolchain::CompilerId::MSVC; + plan.toolchain.binaryPath = "/native/bin/cl.exe"; + EXPECT_EQ(emit_ninja_string(plan).find("coff_tools ="), std::string::npos); +} + TEST(NinjaBackend, ObjectiveCSourceUsesCObjectRuleAndCFlags) { auto plan = minimal_plan(); plan.compileUnits.push_back({ diff --git a/tests/unit/test_pe_exports.cpp b/tests/unit/test_pe_exports.cpp new file mode 100644 index 00000000..1eb49691 --- /dev/null +++ b/tests/unit/test_pe_exports.cpp @@ -0,0 +1,121 @@ +#include + +import std; +import mcpp.build.pe_exports; +import mcpp.build.coff_exports; +import mcpp.build.plan; +import mcpp.manifest; +import mcpp.modgraph.graph; +import mcpp.modgraph.scanner; +import mcpp.toolchain.model; + +using mcpp::build::pe::ir_declares_exports; +using mcpp::build::pe::read_nm_exports; + +TEST(PeExports, RecognizesBothBitcodeContainersByContents) { + const std::array raw{std::byte{0x42}, std::byte{0x43}, std::byte{0xc0}, std::byte{0xde}}; + const std::array wrapped{std::byte{0xde}, std::byte{0xc0}, std::byte{0x17}, std::byte{0x0b}}; + const std::array coff{std::byte{0x64}, std::byte{0x86}, std::byte{1}, std::byte{0}}; + EXPECT_TRUE(mcpp::build::pe::is_bitcode(raw)); + EXPECT_TRUE(mcpp::build::pe::is_bitcode(wrapped)); + EXPECT_FALSE(mcpp::build::pe::is_bitcode(coff)); + EXPECT_FALSE(mcpp::build::pe::is_bitcode(std::span(raw).first(3))); +} + +TEST(PeExports, DllStorageIntentCoversFunctionsDataAndAliases) { + EXPECT_TRUE(ir_declares_exports("define dso_local dllexport i32 @api() {\nret i32 7\n}")); + EXPECT_TRUE(ir_declares_exports("@api = dllexport global i32 7")); + EXPECT_TRUE(ir_declares_exports("@api = dllexport alias i32, ptr @other")); + EXPECT_TRUE(ir_declares_exports("@\"name with spaces\" = dso_local dllexport constant i32 7")); +} + +TEST(PeExports, NamesCommentsAttributesAndDataDoNotDeclareExports) { + EXPECT_FALSE(ir_declares_exports(R"( +; define dllexport i32 @comment() +@dllexport = global i32 1 +@"dllexport" = global i32 2 +@message = constant [30 x i8] c"dllexport /EXPORT:pretend\00" +define i32 @dllexport() { + %dllexport = add i32 1, 2 + ret i32 %dllexport +} +attributes #0 = { "dllexport"="/EXPORT:pretend" } +!llvm.ident = !{!1} +!1 = !{!"/EXPORT:not_a_linker_option"} +)")); +} + +TEST(PeExports, LinkerMetadataIntentFollowsOnlyItsNamedRoot) { + EXPECT_TRUE(ir_declares_exports(R"( +!llvm.linker.options = !{!7, !8} +!7 = !{!"/DEFAULTLIB:libcmt"} +!8 = !{!"\2DEXPORT:chosen"} +)")); + EXPECT_TRUE(ir_declares_exports("!llvm.linker.options = !{!\"/ExPoRt:chosen,DATA\"}")); + EXPECT_TRUE(ir_declares_exports("module asm \".section .drectve\\0A.ascii \\22 /EXPORT:chosen\\22\"")); + EXPECT_FALSE(ir_declares_exports("!llvm.linker.options = !{!1}\n!1 = !{!\"/DEFAULTLIB:x\"}\n!2 = !{!\"/EXPORT:unused\"}")); + EXPECT_FALSE(ir_declares_exports("!llvm.linker.options = !{!1}\n!1 = !{!1}")); +} + +TEST(PeExports, NmCandidatesKeepDataAndComdatButExcludeNonDefinitions) { + auto symbols = read_nm_exports(R"( +function T ---------------- 0 +data D ---------------- 0 +zero B ---------------- 0 +constant R ---------------- 0 +comdat W ---------------- 0 +vtable V ---------------- 0 +external U ---------------- 0 +local t ---------------- 0 +absolute A ---------------- 0 +??_Gwidget@@ T ---------------- 0 +managed.name T ---------------- 0 +)", false); + ASSERT_TRUE(symbols.has_value()) << symbols.error(); + const std::vector expected{ + {"function", false}, {"data", true}, {"zero", true}, + {"constant", true}, {"comdat", false}, {"vtable", true}}; + EXPECT_EQ(*symbols, expected); +} + +TEST(PeExports, I386KeepsStdcallDecorationButNormalizesCdecl) { + auto symbols = read_nm_exports("_cdecl T ---------------- 0\n_stdcall@4 T ---------------- 0\n_variable D ---------------- 0\n", true); + ASSERT_TRUE(symbols.has_value()); + const std::vector expected{ + {"cdecl", false}, {"_stdcall@4", false}, {"variable", true}}; + EXPECT_EQ(*symbols, expected); + auto amd64 = read_nm_exports("_cdecl T ---------------- 0", false); + ASSERT_TRUE(amd64.has_value()); + EXPECT_EQ(amd64->front().name, "_cdecl"); +} + +TEST(PeExports, UnexpectedNmOutputIsAnErrorRatherThanAnEmptySurface) { + EXPECT_FALSE(read_nm_exports("tool emitted an unexpected diagnostic", false)); + EXPECT_FALSE(read_nm_exports("candidate T", false)); +} + +TEST(PeExports, OptOutAffectsOnlyTheMsvcAbiSharedLinkForm) { + for (auto triple : {"x86_64-pc-windows-msvc", "i686-pc-windows-msvc", + "x86_64-w64-mingw32", "x86_64-linux-gnu", "aarch64-apple-darwin"}) { + for (bool enabled : {false, true}) { + mcpp::manifest::Manifest manifest; + manifest.package.name = "probe"; + manifest.package.version = "0.1.0"; + manifest.package.standard = "c++23"; + manifest.targets.push_back({.name = "probe", + .kind = mcpp::manifest::Target::SharedLibrary, .autoExport = enabled}); + mcpp::toolchain::Toolchain tc; + tc.compiler = mcpp::toolchain::CompilerId::Clang; + tc.targetTriple = triple; + mcpp::modgraph::PackageRoot root; + root.root = std::filesystem::temp_directory_path() / "mcpp-pe-plan"; + root.manifest = manifest; + const auto plan = mcpp::build::make_plan(manifest, tc, {}, {}, {}, {root}, + root.root, root.root / "target", {}, {}); + ASSERT_TRUE(plan.has_value()) << plan.error(); + ASSERT_EQ(plan->linkUnits.size(), 1u); + EXPECT_EQ(!plan->linkUnits.front().defFile.empty(), + enabled && std::string_view(triple).ends_with("windows-msvc")) << triple; + } + } +}