From e2f0aa582a7b2cacc04074521e33f42a7d3df731 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sun, 2 Aug 2026 01:25:40 +0800 Subject: [PATCH 01/14] chore: bump to 2026.8.2.1 + windows usability design/plan docs --- ...2026-08-01-issue331-windows-msvc-triage.md | 391 +++++++ .../2026-08-02-windows-usability-design.md | 648 +++++++++++ ...2-windows-usability-implementation-plan.md | 1019 +++++++++++++++++ mcpp.toml | 2 +- src/toolchain/fingerprint.cppm | 2 +- 5 files changed, 2060 insertions(+), 2 deletions(-) create mode 100644 .agents/docs/2026-08-01-issue331-windows-msvc-triage.md create mode 100644 .agents/docs/2026-08-02-windows-usability-design.md create mode 100644 .agents/docs/2026-08-02-windows-usability-implementation-plan.md diff --git a/.agents/docs/2026-08-01-issue331-windows-msvc-triage.md b/.agents/docs/2026-08-01-issue331-windows-msvc-triage.md new file mode 100644 index 00000000..864e7afc --- /dev/null +++ b/.agents/docs/2026-08-01-issue331-windows-msvc-triage.md @@ -0,0 +1,391 @@ +# issue #331 逐条核验 + Windows 无 MSVC 默认工具链分析 + +日期:2026-08-01 · 基线:main @ 7f1489d(mcpp 2026.8.1.1) +方法:全部结论对着 HEAD 源码核验,逐条给行号。**没有在 Windows 上实跑**——凡是只能由 Windows 侧行为决定的,下面明确标注"未实测"。 + +--- + +## 0. 结论速览 + +| # | 报告的问题 | 真实性 | 根因是否如报告所述 | 归类 | +|---|---|---|---|---| +| 1 | `build.mcpp` 在 Windows+MSVC 完全不可用 | ✅ 真实 | ⚠️ 只说对了第一层,**底下还有一层更硬的** | A:真实且通用 | +| 2 | cppwinrt 投影头目录不在 `INCLUDE` | ✅ 真实 | ✅ 完全准确 | A(加目录)+ B(生成投影头) | +| 3 | 绝对路径 `include_dirs` 跳过 glob;且被空格拆开 | ✅ 两半都真实 | ✅ 前半准确;**后半我定位到了** | A:真实且通用(非 Windows 专属) | +| 4 | `ldflags` 文件输入不能用工程相对路径 | ✅ 真实 | ✅ 准确 | A(补 typed 字段)+ C("像路径就重解析"的建议不该做) | +| 5 | 文档在 `[build]` 下举例 `linkage` | ✅ 真实 | ✅ 准确,且**还漏了第三处** | A:文档 bug;建议的"实现成简写"不该做 | +| 6 | path 索引注册落在沙盒 xlings 配置里 | ✅ 症状真实 | ❌ **代码不是这么走的** | D:真实症状 + 根因误判 | + +分类含义: +- **A** = 真实且通用,mcpp 该修 +- **B** = 真实,但不该由 mcpp 承担(属于 `build.mcpp` / 项目侧) +- **C** = 提议本身有害,应拒绝(问题真实,解法不对) +- **D** = 症状真实,但报告给出的机制与代码不符,得重新定位 + +**没有一条是伪问题。** 六条全部可在源码里指到行。分歧只在"根因"和"该怎么修"。 + +--- + +## 1. `build.mcpp` 在 Windows + MSVC 下完全不可用 — 真实,且比报告更严重 + +### 1a. 报告的那层:`argv[0]` 不加引号(确认) + +`src/platform/process.cppm:230-245`: + +```cpp +#if defined(_WIN32) + std::string cmd = argv[0]; // 刻意保留 RAW +#else + std::string cmd = mcpp::platform::shell::quote(argv[0]); +#endif +``` + +`src/build/build_program.cppm:699` 正是 `capture_exec(compileArgv, {}, compileCwd)`,Windows 上 `capture_exec` 走 `popen`/`cmd.exe`(`process.cppm:371-378` 明确写了 Windows 保留 `std::system` 路径)。`cl.exe` 路径必含空格 → `'C:\Program' is not recognized`。**完全属实。** + +**报告没提但同样成立**:这不是 `build.mcpp` 专属。`command_from_argv` 是 Windows 上**所有** `capture_exec`/`run_exec` 的公共入口,调用点包括 `execute.cppm:504/716/792/1270`(跑测试、跑 `mcpp run`)、`xlings.cppm:1356`(探 ninja 版本)。今天没炸只是因为 mcpp 自己装的 payload 路径(`C:\Users\\.mcpp\...`)不含空格。**用户名带空格的 Windows 账户,或把 MCPP_HOME 放在 `C:\Program Files\` 下,同样会炸。** + +### 1b. 报告没看到的那层:`build.mcpp` 的编译命令行是**写死的 GNU 方言** + +`build_program.cppm:678-695`: + +```cpp +std::vector compileArgv = { hostCompiler.string(), std_flag, "-O0" }; +... +compileArgv.push_back("-x"); compileArgv.push_back("c++"); +... +if (staticHostHelper) compileArgv.push_back("-static"); +compileArgv.push_back("-o"); compileArgv.push_back(bin.string()); +``` + +整个 `build_program.cppm` 里 `grep -i msvc` 只有三条**注释**命中,零条代码分支。`-O0` / `-x c++` / `-o` / `-static` 全是 GCC/Clang 驱动语法,`cl.exe` 一个都不认。`host_base_flags(tc)`(`:168-243`)也只有 Clang 和 GCC 两个分支,MSVC 走到最后会拼出一串 `-B` / `-L` / `-Wl,`。 + +**所以:把 #1a 的引号修好,Windows+MSVC 的 `build.mcpp` 依然编不过**,只是错误从 `'C:\Program' is not recognized` 变成一串 `cl : Command line warning D9002: ignoring unknown option '-O0'` + `LNK1181`。报告里"顺带"提的 `mcpp:link-lib` 被硬拼成 `"-l" + val`(`:132`)是同一个病的第三个症状,不是独立问题。 + +**真正的修法**是让 `build.mcpp` 通道走已有的 `mcpp.toolchain.dialect`(`CommandDialect`,0.0.89 就建好了,`flags.cppm` / `ninja_backend.cppm` 都在用),而不是在 `build_program.cppm` 里补 MSVC 特判: + +- 编译 argv:`std_flag` / 优化档 / 输入语言 / 输出路径 四个位置改由 dialect 拼; +- 指令面:`link-lib` / `link-search` 落到 dialect 的库/搜索路径拼写(`/link foo.lib` vs `-lfoo`); +- `-static` → MSVC 侧是 `/MT`,落在 `staticHostHelper` 那个单一决策点里。 + +### 1c. 为什么 CI 没抓到 + +`.github/workflows/ci-windows-e2e.yml:70` — 整个 Windows e2e 套件跑在 `llvm@20.1.7` 上。MSVC 只有一个专属脚本 `tests/e2e/99_msvc_native_build.sh`,而它里面 `build.mcpp` 出现 **0 次**。十个 `build.mcpp` e2e(89/92/110/111/125/144/145/164/168 + 97)全在 clang 下跑,clang 的 payload 路径不含空格。 + +> **覆盖缺口(本次三条 A 类问题的共同成因)**:`MSVC × {build.mcpp, 含空格路径, cppwinrt, /MT}` 这个笛卡尔积在 CI 里是空的。 + +**归类:A(真实且通用)。** 而且 1a 的影响面超出 MSVC,是全 Windows 的路径假设 bug。 + +--- + +## 2. C++/WinRT 投影头目录不在 `INCLUDE` — 真实,但要拆成两半 + +`src/toolchain/msvc.cppm:455-461` 一字不差: + +```cpp +env.push_back({"INCLUDE", join({ + tools / "include", + sdk.root / "Include" / sdk.version / "ucrt", + sdk.root / "Include" / sdk.version / "um", + sdk.root / "Include" / sdk.version / "shared", + sdk.root / "Include" / sdk.version / "winrt", +})}); +``` + +报告对 `winrt` vs `cppwinrt` 的区分是对的:前者是 ABI 头(`windows.foundation.h`,C 风格 IUnknown 接口),后者是 C++/WinRT 投影(`winrt/Windows.Foundation.h`)。 + +**两半要分开判:** + +- **`cppwinrt` 目录存在时加进 `INCLUDE`** —— 这是 A 类。一行的事,语义等价于 `vcvarsall` 本来就做的(MSBuild 的 C++/WinRT targets 会加这个目录)。mcpp 声称"合成 INCLUDE/LIB,不走 vcvarsall"(`docs/03-toolchains.md:183`),那合成就该完整。**建议按报告做,`exists()` 判断后追加。** + +- **目录不存在时现场跑 `cppwinrt.exe -in local -out ` 生成** —— 这是 **B 类,不该由 mcpp 做**。理由: + 1. 这是**代码生成**,和 `xcb` 的 `c_client.py`、`nasm` 汇编、`rc.exe` 资源编译同类,mcpp 对这类东西的既定答案就是 `build.mcpp`(`docs/07-build-mcpp.md`),不是往工具链探测里塞 SDK 工具调用; + 2. 生成参数(`-in local` vs `-in ` vs `-in 10.0.26100.0`、`-out`、`-optimize`、`-overwrite`)是项目决策,不是工具链属性; + 3. 一旦 mcpp 内置这个,等于把 `cppwinrt.exe` 的 CLI 契约焊进 mcpp 的发布周期。 + + 报告自己的绕行方式(prebuild 里探测+生成)就是正确形态 —— 它今天必须写成外部 `prebuild.sh`,**只是因为 #1**。#1 修好,这一半自动回到 `build.mcpp` 里,不需要 mcpp 做任何事。 + +**归类:A(加目录,一行)+ B(生成投影头,属 `build.mcpp`)。** + +--- + +## 3. 绝对路径 `include_dirs` — 两半都真实,**后半我定位到了** + +### 3a. 跳过 glob 展开(确认) + +`src/build/plan.cppm:261-270`: + +```cpp +if (inc.is_absolute()) return { inc }; // 绝对路径原样返回 +const auto glob = inc.generic_string(); +auto expanded = mcpp::modgraph::expand_dir_glob(root, glob); +``` + +`expand_dir_glob(root, glob)` 的签名是 root-relative 的,所以早退是**实现便利**留下的口子,不是设计。属实。 + +### 3b. "被拆成三个参数落到源文件位置" —— 报告说没定位到,**在这里** + +同一份数据走**两条通道**,一条加引号一条不加: + +| 通道 | 位置 | 处理 | +|---|---|---| +| 全局 `include_flags`(ninja 变量) | `src/build/flags.cppm:239-242` | `shell_quote_arg(escape_path(t))` ✅ 有引号 | +| per-TU `$local_includes` | `src/build/ninja_backend.cppm:108-132` | `escape_flag_path(inc)` ❌ **只做 ninja `$` 转义,无 shell 引号** | + +```cpp +// ninja_backend.cppm:108 +std::string local_include_flags(const CompileUnit& cu, bool msvcDialect) { + for (auto const& inc : cu.localIncludeDirs) { + flags += " -I"; + flags += escape_flag_path(inc); // ← 只转义 ' ' '$' ':' 给 ninja + } +``` + +`escape_flag_path`(`:85-99`)把空格转成 `$ ` 是**给 ninja 解析器看的**;ninja 反转义后交给 shell / rspfile 的就是裸空格。而 `[build] include_dirs` 确实会流进这条通道 —— `plan.cppm:883`: + +```cpp +main_cu.localIncludeDirs = local_include_dirs_for_manifest(projectRoot, manifest); +``` +(`local_include_dirs_for_manifest` 读的正是 `manifest.buildConfig.includeDirs`;另一条 `plan.cppm:878` 走 `packages[0].privateBuild.includeDirs`,同样进这个函数。) + +于是 `C:/Program Files (x86)/Windows Kits/10/Include/*/cppwinrt` → +`-IC:/Program` + `Files` + `(x86)/Windows` + `Kits/10/Include/*/cppwinrt` +—— 第一段被 `cl.exe` 当 include 目录吃掉,**剩下三段正是报告里那三行 `C1083: Cannot open source file`**。完全对上。 + +报告怀疑 `flags.cppm:238-242` 应该覆盖到 —— 那是**另一条通道**,所以对不上号。这正是"同一决策两处推导"的老毛病(见 `.agents/docs` 里 #242 / C3 两次同类事故)。 + +**并且这不是 Windows/MSVC 专属**:Linux 上 `include_dirs = ["/home/my dir/inc"]` 会以完全一样的方式裂开。 + +**修法**:`local_include_flags` 对每个 token 走和 `flags.cppm:241` 同一个 `shell_quote_arg(escape_flag_path(...))`;顺手把写死的 `-I` 换成 `d.includePrefix`(现在靠 `cl.exe` 恰好接受 `-I` 兜着)。3a 单独修:`expand_dir_glob` 支持绝对 glob,或对绝对路径按其 root 拆出 base 再展开。 + +**归类:A(真实且通用,跨平台)。** 两半都该修,3b 优先级更高(3a 只是没展开,3b 是**静默把 include 路径变成源文件**)。 + +--- + +## 4. `ldflags` 的文件输入 — 问题真实,**但报告的两个建议里只有一个能收** + +事实核对:`src/build/flags.cppm:108-124` 的 `normalize_ldflag` 只重写两种前缀: + +```cpp +if (flag.starts_with("-L") ...) → 按 root 绝对化 +if (flag.starts_with("-Wl,-rpath,") ...) → 按 root 绝对化 +return flag; // 其余原样 +``` + +ninja 的 cwd 是 `target///`(`flags.cppm:216`、`:372` 两处注释确认),所以 `ldflags = ["gen/app.res"]` 必然 `LNK1181`。属实,而且**跨平台通用**:Linux 上 `ldflags = ["libfoo.a"]` 同样解析不到。 + +报告指出的不一致也属实:`docs/07-build-mcpp.md:49` 写着 `link-search` 的相对路径按工程根解析,代码 `build_program.cppm:131-132` 里 `abs_against_root` 确实这么做了;而 manifest 侧 `[build] ldflags` 没有任何说明。 + +**两个建议分开判:** + +- ❌ **"让 `ldflags` 里长得像路径的条目按工程根解析"—— 不该做(C 类)。** 这是在 flag 字符串上做形状猜测,mcpp 已经在这条路上摔过一次:`join_flags`(`ninja_backend.cppm:134-166`)那一大段注释记的就是 "`shell_quote_arg` 假设每个 flag 元素 = 一个 argv token" 这个猜测怎么把 `-include foo.h` 和 `-Wl,-rpath,'$$ORIGIN'` 一起搞坏的。`ldflags` 是**逃生舱**,它的契约就是"原样进链接行";给它加启发式重写,等于让 `-Wl,--version-script=foo.map`、`@rsp`、`-l:libfoo.a` 这些形状各自去猜。 + +- ✅ **"给 `[build]` 补 typed 字段"—— 该做(A 类)。** `link_inputs`(文件输入,按 package root 解析,进 `implicitInputs` 让 ninja 能跟踪重建)+ `link_search`(目录,和 `mcpp:link-search` 同语义)。typed 通道不需要猜形状,还顺带解决 ninja 依赖跟踪 —— 现在 `ldflags = ["../../../gen/app.res"]` 就算路径蒙对了,**改了 `.res` 也不会触发重链**。 + + 短期无成本的止血:在 `docs/05-mcpp-toml.md` 的 `ldflags` 处写清"原样传递、相对路径相对于 `target///`",并给出 `link-search`/`-L` 的正确写法。 + +**归类:A(补 typed 字段 + 补文档)+ C(路径启发式该拒绝)。** + +--- + +## 5. 文档里的 `[build] linkage` — 真实,**且比报告说的多一处** + +代码事实:`linkage` 只在 `[target.]` 里解析(`src/manifest/toml.cppm:995-1001`),`[build]` 的白名单(`toml.cppm:907`)确实没有它,所以静默忽略 + warning。而 `buildConfig.linkage` 这个**字段本身存在**,由 `prepare.cppm:1016`(`[target]` 覆盖)、`:1026-1029`(target 默认 static / `--static`)、`:1304`(musl)写入,最后在 `flags.cppm:336` 决定 `/MT` vs `/MD`: + +```cpp +msvc_base += (plan.manifest.buildConfig.linkage == "static") ? " /MT" : " /MD"; +``` + +报告列的两处文档 bug 属实(`docs/03-toolchains.md:129` 和 `:183`),`docs/05-mcpp-toml.md:484` 的 `[target.*]` 归属是对的。 + +**报告漏了第三处**:`src/build/prepare.cppm:1303` 的注释也写着 + +```cpp +// out via [build].linkage / [target.].linkage. +``` + +—— 又一次"契约只写在注释里,且注释是错的"。 + +**报告用的绕行比必要的重**:`[target.'cfg(windows)'.build] cflags/cxxflags = ["/MT"]` 是能用,但既然 `x86_64-windows-msvc` 是精确 triple,直接写 + +```toml +[target.x86_64-windows-msvc] +linkage = "static" +``` + +就走了正规通道(也可以 `mcpp build --static`)。文档误导让人多绕了一圈,这本身就是这条 issue 的代价证据。 + +**"或者在 `[build]` 下把它实现成 `[target.*]` 的简写"—— 建议不该收。** `[build]` 是 target-agnostic 的(一份配置服务所有 target),`linkage` 是 target 属性 —— `docs/05-mcpp-toml.md:554` 已经把这条写成明文规则("`toolchain` / `linkage` are exact-triple only"),`toml.cppm:1048/1066` 也按这条规则在报错。加简写会让 `[build] linkage = "static"` 在交叉编译时含义不明。 + +**归类:A(改三处文档 + 那条注释)。建议的实现部分拒绝。** + +--- + +## 6. path 索引注册 —— 症状真实,**但报告的根因和代码对不上** + +报告的机制描述是: + +> mcpp 把 `[indices]` 物化进 `/.mcpp/.xlings.json`,但真正解析包的那个沙盒 xlings 以 `$MCPP_HOME/registry` 为 home,它读的是 `~/.mcpp/registry/.xlings.json`,不看项目那份。 + +**代码不是这么走的。** 对非 builtin 索引,mcpp 走的是项目作用域: + +```cpp +// src/build/prepare.cppm:1813 +const bool useProjectEnv = idxSpec && !idxSpec->is_builtin(); // path 索引 → true +// :2027 +auto projEnv = mcpp::config::make_project_xlings_env(**cfg, *root); +auto r = mcpp::xlings::call(projEnv, "install_packages", argsJson, &progress); +``` + +```cpp +// src/config.cppm:134-137 +make_project_xlings_env(cfg, projectDir) + → { cfg.xlingsBinary, cfg.xlingsHome(), projectDir / ".mcpp" }; + ^^^^^^^^^^^^^^^^^^^ XLINGS_PROJECT_DIR +``` + +`xlings.cppm:832-838` 在项目模式下**显式设置** `XLINGS_PROJECT_DIR`(全局模式才 `env -u`)。另外 `config.cppm:771-776` 的 `exposeLocalIndex` 还把本地索引 symlink/copy 进 `.mcpp/data/` 和 `.mcpp/.xlings/data/` 两处。 + +所以:**管道是通的、也确实被调用了**(`prepare.cppm:1457` 在依赖解析前调 `ensure_project_index_dir`)。报告观察到的 `searched repos: [xim, mcpplibs]` 说明 **xlings 侧没有把项目 repo 并进搜索集**,断点在 mcpp↔xlings 契约,不在 mcpp 的"注册落点"。 + +**最可能的实际断点(未实测,需要 Windows/CI 复现确认)**:`config.cppm:704` 对 path 索引写的是 + +```cpp +customRepos.push_back({ name, source.generic_string(), "", "" }); +// ^^^^^^^^^^^^^^^^^^^^^^ 一个文件系统路径,写进了 `url` 字段 +``` + +`seed_xlings_json`(`xlings.cppm:1198-1210`)把它当 `"url"` 发出去。xlings 若把 `index_repos[].url` 当 git remote 处理,`D:/a/SpinningMomo/SpinningMomo/mcpp` 这种值就可能被静默丢弃 —— 症状恰好就是"repo 列表里没有 `sm`"。 + +**顺带发现的一个真 bug(同族,未构成本次故障)**:`xlings.cppm:1104-1118`,`install_with_progress` 的 POSIX NDJSON 兜底分支把命令行**写死**成 + +```cpp +"cd {} && env -u XLINGS_PROJECT_DIR XLINGS_HOME={} {} interface install_packages ..." +``` + +—— 硬 unset `XLINGS_PROJECT_DIR`,无视传进来的 `env.projectDir`;而同一函数的**直接路径**用的是 `build_command_prefix(env)`(会正确设置)。同一个函数两条路径对 project scope 的处理相反。今天没炸是因为依赖安装走的是 `xlings::call` 而不是 `install_with_progress`,但只要有人把项目作用域的安装接到这个函数上就会静默降级到全局。 + +**为什么 e2e 没抓到**:`tests/e2e/52_local_path_namespaced_index.sh:41-47` **预先手工创建了已安装产物**: + +```bash +mkdir -p "$TMP/project/app/.mcpp/.xlings/data/xpkgs/compat.cfg/1.0.0/src" +``` + +于是 `findCompleteInstalled()` 直接命中,**xlings 安装这一腿从来没被执行过**。`42_custom_local_index.sh` / `169_semver_project_index.sh` 同族。这就是"本地绿、CI 红"的结构性来源 —— 和报告观察到的现象同构,只是发生在测试里。 + +**报告的建议("至少让诊断能对上")完全正确,且比它自己以为的更有价值**:mcpp 的诊断(`prepare.cppm:2088-2094`)已经会按 `useProjectEnv` 去读对应的 `.xlings.json` 并列出 repo,但 xlings 的子进程错误文本(`searched repos: [...]`)是另一套,两者拼在一起才误导。修法应该是:install 失败时把 **mcpp 认为生效的 scope + XLINGS_PROJECT_DIR + 项目 `.xlings.json` 的 repo 列表 + xlings 自报的 repo 列表**四项并排打出来,差异一眼可见。 + +**归类:D(症状真实,根因误判)。** mcpp 侧确定该做的三件: +1. e2e 补一条**真的走安装**的 path 索引用例(不预置 xpkgs 目录); +2. 诊断并排输出(上面那四项); +3. `install_with_progress` 的 POSIX 兜底改用 `build_command_prefix(env)`,消掉同函数两条路径的分歧。 + +path 索引在 xlings 侧到底怎么被接受(`url` 里放路径是否合法、要不要新的 `path` 字段),需要开一条 openxlings/xlings 的 issue 定契约 —— 这条**不是 mcpp 单方面能修的**。 + +--- + +## 7. Windows 没有 MSVC 时的默认工具链 + +### 7.1 一般电脑默认有 MSVC STL 吗?—— **没有** + +- Windows 10/11 自带的是 **UCRT 运行时 DLL**(`ucrtbase.dll`,OS 组件)。 +- **头文件和导入库**(`ucrt/*.h`、`libucrt.lib`)来自 **Windows SDK**,不预装。 +- **MSVC STL**(`` 等)只随 **Visual Studio / Build Tools** 的 "Desktop development with C++" 负载安装,不预装。 +- 结论:裸装 Windows 上,**编译期一无所有**;只有运行期 CRT。 + +### 7.2 mcpp 今天在裸 Windows 上会怎样 + +```cpp +// src/toolchain/triple.cppm:148 +inline constexpr std::string_view kFirstRunMacWin = "llvm@20.1.7"; +``` + +首次 `mcpp build`(`prepare.cppm:1207-1222`)在 Windows 上自动装 `llvm@20.1.7`。而: + +```cpp +// src/toolchain/clang.cppm:137-140 +// Clang targeting MSVC uses MSVC STL, not libc++. +bool msvTarget = is_msvc_target(tc); +tc.stdlibId = msvTarget ? "msvc-stl" : "libc++"; +``` + +README:311-313 也白纸黑字写了: + +> On Windows, llvm requires an existing **MSVC BuildTools or Visual Studio** (UCRT, Windows SDK, MSVC STL). The MinGW route (`--target x86_64-windows-gnu`) needs no [Visual Studio]. + +**所以:默认路径在裸 Windows 上必挂,而能用的路径(`x86_64-windows-gnu`,winlibs GCC,完全自包含)就在旁边,只是不是默认。** + +更糟的是**没有诊断**。`prepare.cppm:1284-1295` 只对 `CompilerId::MSVC && envOverrides.empty()`(检测到 VC tools 但缺 SDK)给了引导文案。clang-targeting-msvc 在完全没有 VS 的机器上,mcpp 不作任何检查 —— 用户拿到的是 clang 自己的 `'vector' file not found` 或 `unable to find a Visual Studio installation`,从这里推不出"该换 `--target x86_64-windows-gnu`"。 + +### 7.3 该不该加 clang + libc++ 并设为 Windows 默认?—— **不该** + +分两种 "clang + libc++ on Windows": + +**(a) libc++ 配 MSVC ABI(`x86_64-pc-windows-msvc` + libc++)** +- LLVM 官方**不发布** Windows 的 libc++ 二进制;这个组合在上游是 experimental,要自己 build,且 locale / 线程 / 异常几块长期有缺口。 +- 更硬的问题是 **ABI 隔离**:libc++ 的 `std::string`/`std::vector` 和 MSVC STL 不兼容,一旦选它,vcpkg 的 MSVC 预编译包、任何第三方 `.lib`、系统 SDK 里跨 `std::` 类型的接口全部不能链。 +- 而且它**并不解决问题**:MSVC ABI 依然需要 Windows SDK 的 `ucrt`/`um`/`shared` 头和 import lib,裸机器上照样没有。 +- **判定:不真实可行的方案。** + +**(b) llvm-mingw(clang + libc++ + lld + libunwind,`x86_64-windows-gnu`)** +- 这个是**真的能用**、真的自包含。 +- 但它和 mcpp 已有的 **winlibs GCC(`x86_64-windows-gnu`,libstdc++ + `import std`,全静态自包含)功能等价** —— 同一个 target、同一个 ABI、同一类产物。 +- 加它 = 给同一个 target 加第二套 stdlib 实现,换来的边际收益(clang 诊断、libc++)远小于成本(多一条 payload 发布线、多一组 `import std` 路径、多一个 BMI 缓存维度、CI 多一轴)。 +- **判定:真实但优先级极低,不该为了"裸 Windows 能构建"而做 —— 那个洞已经被 winlibs GCC 填上了。** + +### 7.4 真正的问题是**默认值选错了**,不是缺工具链 + +Windows 上正确的首次运行策略应该是 **detection-first**(mcpp 在 `msvc@system` 上已经用过这个先例,`lifecycle.cppm:629-651`): + +``` +mcpp build(Windows,无配置) + ├─ 探测到 VS/BuildTools + Windows SDK ? + │ 是 → msvc@system(或 llvm@20.1.7,二选一按策略定) + │ 否 → gcc@16 + --target x86_64-windows-gnu(winlibs,自包含,零 VS 依赖) + └─ 无论哪条,打印一行说明选了什么、以及怎么切换 +``` + +这样: +- 有 VS 的机器行为**不变**(今天的 `llvm@20.1.7` 或 msvc); +- 裸机器**从"必挂且看不懂"变成"直接能用"**; +- 不需要任何新工具链、新 payload、新 CI 轴 —— 全部零件(winlibs payload、target 解析、`kFirstRunMacWin` 常量、msvc 探测)都已存在。 + +**配套的最小诊断**(独立于上面,单独也有价值):在 `prepare.cppm` 已有的 MSVC-缺-SDK 检查旁边,补一条 —— clang 且 `is_msvc_target(tc)` 且探不到 MSVC STL/SDK 时,直接失败并给出 + +``` +llvm on Windows targets the MSVC ABI and needs Visual Studio / Build Tools +(UCRT + Windows SDK + MSVC STL), which was not found. + + • install: winget install Microsoft.VisualStudio.2022.BuildTools + • or switch to the self-contained MinGW route (no Visual Studio needed): + mcpp toolchain default gcc@16 --target x86_64-windows-gnu +``` + +**归类:A(真实且通用)—— 但落点是默认选择策略 + 诊断,不是新工具链。** + +--- + +## 8. 建议的落地顺序 + +按 (影响面 × 修复成本) 排: + +| 序 | 动作 | 成本 | 说明 | +|---|---|---|---| +| 1 | `local_include_flags` 补 shell 引号 + 用 `d.includePrefix`(#3b) | 极小 | 静默把 include 路径变成源文件,跨平台,含空格路径必中 | +| 2 | Windows 首跑 detection-first + clang-无-MSVC 诊断(#7) | 小 | 裸 Windows 从"必挂"变"能用",零新组件 | +| 3 | 文档三处 + `prepare.cppm:1303` 注释改掉 `[build] linkage`(#5) | 极小 | 纯文档,但直接造成了报告人的错误绕行 | +| 4 | `msvc.cppm` INCLUDE 加 `cppwinrt`(存在时)(#2a) | 极小 | 一行;C++/WinRT 项目开箱可用 | +| 5 | `command_from_argv` 的 Windows `argv[0]` 引号(#1a) | 中 | 需在 Windows 上实测 `cmd.exe /c` 的引号行为;影响面超出 MSVC | +| 6 | `build.mcpp` 编译/指令面走 `CommandDialect`(#1b) | 大 | 没有这个,#1a 修了 MSVC 上的 `build.mcpp` 依然不可用 | +| 7 | `[build] link_inputs` / `link_search` typed 字段(#4) | 中 | 顺带修好重链跟踪;同时补 `ldflags` 的 cwd 说明 | +| 8 | path 索引:真安装 e2e + 并排诊断 + `install_with_progress` 兜底修正(#6) | 中 | 契约那半要开 openxlings/xlings issue | +| 9 | 绝对路径 `include_dirs` 的 glob 展开(#3a) | 小 | #3b 修完后这个只是"没展开",不再是错误落点 | +| — | ~~`[build] linkage` 实现成简写~~ | — | **拒绝**:违反 `[build]` target-agnostic 规则 | +| — | ~~`ldflags` 里"像路径"的条目按 root 重解析~~ | — | **拒绝**:flag 字符串形状猜测,`join_flags` 注释里记着上次教训 | +| — | ~~mcpp 内置 `cppwinrt.exe` 代码生成~~ | — | **拒绝**:属 `build.mcpp`,#1 修好即可 | +| — | ~~Windows 加 clang + libc++ 并设为默认~~ | — | **拒绝**:MSVC-ABI 版不可行;mingw 版与已有 winlibs GCC 重复 | + +**其中 #1b 和 #7 是本次真正的架构性发现**: +- `build.mcpp` 整条通道从未接入 0.0.89 就建好的 `CommandDialect`,MSVC 支持从来就是**零**,只是被 #1a 的引号错误挡在前面看不见; +- Windows 的默认工具链和 Windows 上唯一零依赖可用的工具链,**是两个不同的东西**,而这个事实只写在 README 的脚注里。 + +**共同成因**:CI 的 Windows 面全跑在 `llvm@20.1.7` 上(`ci-windows-e2e.yml:70`),MSVC 只有一个不含 `build.mcpp` 的脚本。补 `MSVC × build.mcpp` 和 `含空格路径 × include_dirs` 两条 e2e,是让这批问题不复发的最小闸门。 diff --git a/.agents/docs/2026-08-02-windows-usability-design.md b/.agents/docs/2026-08-02-windows-usability-design.md new file mode 100644 index 00000000..fbc26e1e --- /dev/null +++ b/.agents/docs/2026-08-02-windows-usability-design.md @@ -0,0 +1,648 @@ +# Windows 可用性设计:裸机无感可用 + build.mcpp 全方言 + 测试面补齐 + +日期:2026-08-02 · 基线:main @ 7f1489d(mcpp 2026.8.1.1) +前置:`.agents/docs/2026-08-01-issue331-windows-msvc-triage.md`(issue #331 逐条核验) + +> 本文所有代码坐标均对着 HEAD 核验过。**未在 Windows 实跑**——凡只能由 Windows 侧行为 +> 决定的判断,文中标注「未实测」。 + +--- + +## 0. 摘要 + +mcpp 今天在 Windows 上有三个**正交**的故障面。它们互相独立触发、独立修复,但共同构成 +「mcpp 在 Windows 上门槛高」这一个用户感受。 + +| | 故障面 | 触发条件 | 现状 | +|---|---|---|---| +| **F1** | 默认工具链选错 | 机器无 VS / BuildTools | 必挂 + **零诊断** | +| **F2** | 含空格路径被拆开 | 路径含空格(`Program Files`、用户名带空格) | 两处独立漏引号 | +| **F3** | build.mcpp 在 MSVC 上零支持 | `[toolchain] = msvc@system` | 三层全断 | + +外加一个跨平台的能力缺口: + +| | 缺口 | 现状 | +|---|---|---| +| **F4** | build.mcpp 不能 `import std;` | 只能 `#include`,无 std BMI 通道 | + +对应五个组件: + +| | 组件 | 主要触及 | +|---|---|---| +| A | Windows 首跑 detection-first + 意图分档回退 | `msvc.cppm` `triple.cppm` `prepare.cppm` | +| B | 含空格路径:两条通道收敛成一处 | `ninja_backend.cppm` `platform/process.cppm` | +| C | build.mcpp 三层(引号 / 方言 / 环境) | `dialect.cppm` `build_program.cppm` | +| D | build.mcpp 支持 `import std;` | `build_program.cppm`(复用 `stdmod.cppm`) | +| E | CI 测试面:三轴 + 自证门 + 新 e2e | `ci-fresh-install.yml` `ci-windows.yml` `tests/e2e/` | + +**核心立场:不引入 clang + libc++ 的任何形态。** 裸 Windows 的洞不是「缺工具链」,是 +「默认值选错」——零依赖可用的 `x86_64-windows-gnu`(winlibs GCC)早已是 `verified` 层、 +早已有 Windows-native CI,只是不是默认。理由见 §7。 + +--- + +## 1. 问题模型 + +### 1.1 F1 — 默认工具链在裸 Windows 上必挂 + +一般 Windows 电脑**没有** MSVC STL。系统自带的只有 UCRT 运行时 DLL;头文件与导入库来自 +Windows SDK,STL 来自 Visual Studio / Build Tools 的 "Desktop development with C++" 负载, +两者都不预装。 + +而 mcpp 的首跑默认把 macOS 和 Windows 并成了一条分支: + +```cpp +// src/toolchain/triple.cppm:148 +inline constexpr std::string_view kFirstRunMacWin = "llvm@20.1.7"; +``` + +```cpp +// src/build/prepare.cppm:1209 +if constexpr (mcpp::platform::is_macos || mcpp::platform::is_windows) { + defaultSpec = std::string(pins::kFirstRunMacWin); +} +``` + +Windows 的 host triple 是 MSVC ABI(`triple.cppm:137`,`t.env = "msvc"`),而 clang 打 MSVC +ABI 时用的是 MSVC STL 而非 libc++: + +```cpp +// src/toolchain/clang.cppm:138-140 +// Clang targeting MSVC uses MSVC STL, not libc++. +bool msvTarget = is_msvc_target(tc); +tc.stdlibId = msvTarget ? "msvc-stl" : "libc++"; +``` + +⇒ **裸机器上默认路径必挂**,而且安装那一步是成功的(llvm payload 装得下来),失败发生在 +之后的编译期。 + +**零诊断。** `prepare.cppm:1284` 现有的引导只覆盖 `CompilerId::MSVC && envOverrides.empty()` +——即「检测到 VC tools 但缺 SDK」。clang-targeting-msvc 在完全没有 VS 的机器上不触发任何 +检查,用户拿到的是 clang 自己的 `'vector' file not found` 或 +`unable to find a Visual Studio installation`,从这里推不出「该换 `--target x86_64-windows-gnu`」。 + +**可用的路径就在旁边。** `triple.cppm:106` 早已把 `x86_64-windows-gnu` 登记为 `verified` +层、pin `gcc@16.1.0`、默认静态;`registry.cppm:240-242` 在 Windows host 上把它映射到 +winlibs 的 `mingw-gcc`;`tests/e2e/97_mingw_toolchain.sh` 每次 Windows CI 都在真跑,并且 +断言到「产物在剥离 PATH 的干净目录下能跑」。这条路零 VS 依赖、全静态自包含、`import std` +可用——唯一的问题是它不是默认,而这个事实只写在 `README:311-313` 的脚注里。 + +**首跑方案单独盖不住的洞。** detection-first 只在「没有任何默认值」时生效。一个用户在无 VS +的机器上跑过一次旧版 mcpp,`llvm@20.1.7` 已被写进 `config.toml` 的 `[toolchain] default`, +从此每次构建都有默认值 ⇒ 首跑分支永不再进 ⇒ **永远撞墙**。存量用户和升级用户全在这个洞里。 +所以 A 组件必须包含一个作用在**每次构建**上的修复门,而不只是首跑决策。 + +### 1.2 F2 — 含空格路径被拆开 + +同一份 manifest `[build] include_dirs` 经两条通道到达编译命令行,只有一条做了 shell 引号。 + +**通道一(全局,正确)** —— `flags.cppm:223`/`:241`: + +```cpp +includeTokens.push_back(std::string(d.includePrefix) + p.string()); +... +include_flags += shell_quote_arg(escape_path(std::filesystem::path(t))); +``` + +`:219-221` 的注释明写这么做的理由:*"ninja-$-escape and shell-quote per token (#234) so an +include dir whose name contains a space can't silently split into two shell words once ninja +hands the resolved command line to the shell."* + +**通道二(per-TU,漏了)** —— `ninja_backend.cppm:108-113`: + +```cpp +std::string local_include_flags(const CompileUnit& cu, bool msvcDialect) { + ... + for (auto const& inc : cu.localIncludeDirs) { + flags += " -I"; // ← 硬编码,未用 d.includePrefix + flags += escape_flag_path(inc); // ← 只有 ninja `$` 转义,没有 shell 引号 + } +``` + +`cu.localIncludeDirs` 的来源同样是 manifest(`plan.cppm:883` → +`local_include_dirs_for_manifest`,`scanner.cppm:999` → `local_include_dirs_for`),所以这是 +**同一个语义、两处推导**。ninja 反转义后把裸空格交给 shell,路径当场裂开。 + +这条**不是 Windows 专属**:Linux 上 `include_dirs = ["/home/my dir/inc"]` 以完全一样的方式 +中招。只是 Windows 上 `C:\Program Files\...` 让它变成日常。 + +同一函数还有第二个小问题:`msvcDialect` 形参只被 `localIncludeDirsAfter` 那一半使用,前一半 +硬编码 `-I`。cl.exe 接受 `-I`,所以今天无害,但这是同一处硬编码的另一面。 + +**第二处** —— `platform/process.cppm:234` 的 `command_from_argv`,被 `:400` 和 `:459` 两个 +执行入口使用。Windows 下把 argv 拼成 `cmd.exe /c` 的字符串时 `argv[0]` 未加引号,payload 装 +在 `C:\Program Files\...` 或用户名带空格的机器上即断(**未实测**,依据是代码里无引号逻辑)。 +这条是 build.mcpp 通道的前置——不修它,§4 的后两层白做。 + +### 1.3 F3 — build.mcpp 在 MSVC 上零支持,共三层 + +**第一层:引号。** 同 §1.2 第二处。 + +**第二层:方言。** `build_program.cppm` 整个文件 `grep -i msvc` 只有注释命中,**零代码分支**。 +编译 argv 写死 GNU 驱动语法(`:677-695`): + +```cpp +std::vector compileArgv = { hostCompiler.string(), std_flag, "-O0" }; +... +compileArgv.push_back("-x"); compileArgv.push_back("c++"); +... +if (staticHostHelper) compileArgv.push_back("-static"); +compileArgv.push_back("-o"); compileArgv.push_back(bin.string()); +``` + +`-O0` / `-x c++` / `-static` / `-o` 一个都不是 cl.exe 的语法。`host_base_flags(tc)` +(`:168-243`)也只有 Clang 和 GCC 两个分支,MSVC 走到底会拼出一串 `-B` / `-L` / `-Wl,`。 + +指令面同病:`parse_line`(`:132-134`)把 `mcpp:link-lib=foo` 硬拼成 `-lfoo`、 +`mcpp:link-search` 硬拼成 `-L`,MSVC 侧应是 `foo.lib` 和 `/LIBPATH:`。 + +**第三层:环境。** `model.cppm:46` 的 `envOverrides`(MSVC 的 `INCLUDE` / `LIB` / `VSLANG`) +目前**只有** `ninja_backend.cppm:1342`/`:1395` 在消费。`build_program.cppm` 调的是 +`capture_exec(compileArgv, {}, compileCwd)` —— 传的是空 env。所以即使方言全翻译对了, +cl.exe 依然找不到 ``。 + +> **这是 #331 报告没看到的一层。** 报告只说到第一层;修好引号后 MSVC 上的 build.mcpp +> 依然编不过,只是错误从 `'C:\Program' is not recognized` 变成 +> `D9002: ignoring unknown option '-O0'` + `LNK1181`,再修好方言又会变成 +> `cannot open include file: 'cstdio'`。三层必须一起过。 + +### 1.4 F4 — build.mcpp 不能 `import std;` + +`build_program.cppm` 只认 `import mcpp`: + +```cpp +// src/build/build_program.cppm:666 +bool usesModule = srcText.find("import mcpp") != std::string::npos; +``` + +没有任何 std BMI 通道。内置的 `mcpp` 模块(`:247` 的 `kMcppModuleSource`)特意在 global +module fragment 里用 `` / `` 而**不用** `import std;`,注释写得很直白: +*"a typed API over the stdout wire protocol so build.mcpp can `import mcpp;` +(no `#include`, no `import std;`)"* —— 这是绕开缺口的权宜,不是设计意图。 + +结果是 build.mcpp 作为「原生 C++ 构建程序」的定位与 mcpp 自身的模块化主张自相矛盾: +mcpp 让用户全项目 `import std;`,却要求构建脚本回退到 `#include`。 + +--- + +## 2. 组件 A:首跑 detection-first + 意图分档回退 + +### 2.1 新谓词:什么叫「这台机器有可用的 MSVC」 + +`msvc.cppm` 增加: + +```cpp +// 两件齐才算可用。 +bool has_usable_msvc(); // = find_std_module_source() && find_windows_sdk() +``` + +两个查找器都是**现成的**(`msvc.cppm:34` / `:102`),不新增任何探测逻辑。 + +**为什么必须两件齐**:只探 `find_vs_install_path()` 会把「装了 VS 但只勾了 .NET 负载」判为 +有 MSVC,然后在编译期才炸——恰好是现在这个 bug 的变种。要求 STL 与 SDK 同时在场,直接堆死 +「有 VS 无 C++ 负载」和「有 VC tools 缺 SDK」两种半残状态。 + +### 2.2 pin 拆分 + +```cpp +// src/toolchain/triple.cppm:148 —— 拆前 +inline constexpr std::string_view kFirstRunMacWin = "llvm@20.1.7"; + +// 拆后 +inline constexpr std::string_view kFirstRunMac = "llvm@20.1.7"; +inline constexpr std::string_view kFirstRunWinMsvc = "llvm@20.1.7"; // 探到 VS +inline constexpr std::string_view kFirstRunWinGnu = "gcc@16.1.0"; // 未探到 +``` + +`prepare.cppm:1209` 的 `if constexpr (is_macos || is_windows)` 拆成独立的 macOS / Windows +分支。Windows 分支: + +``` +has_usable_msvc() + ├─ true → defaultSpec = kFirstRunWinMsvc (现行为完全不变) + └─ false → defaultSpec = kFirstRunWinGnu + defaultTarget = "x86_64-windows-gnu" +``` + +**两个轴都写。** 只写 `default_target` 也能工作——`prepare.cppm:1024` 的词表 pin 约定会把 +`tcSpec` 自动带成 `gcc@16.1.0`——但依赖隐式推导会让 `mcpp toolchain list` 与 `config.toml` +的表述对不上。显式写两个轴,`toolchain list` 两个轴都打星,与 e2e 97 已有的断言一致。 + +`prepare.cppm:1167`(offline / no-auto-install 的硬错误分支)与 `:1224`(First run 的 info +文案)同步分档,否则会向裸 Windows 用户建议一条在他机器上不可用的命令。 + +### 2.3 意图来源分档 + +在现有优先级链的四个赋值点各记一个来源标记。**零新配置字段**: + +| 赋值点 | 来源 | 语义 | +|---|---|---| +| `prepare.cppm:947` | `ManifestToolchain` | 项目 `mcpp.toml [toolchain]` —— 用户显式 | +| `prepare.cppm:951` | `GlobalDefault` | 全局 `config.toml` —— mcpp 自选居多 | +| `prepare.cppm:1015` | `TargetSection` | `[target.X].toolchain` —— 用户显式 | +| `prepare.cppm:1024` | `TargetPin` | 词表约定 —— mcpp 自选 | +| `prepare.cppm:1274` | `FirstRun` | 本次首跑写入 —— mcpp 自选 | + +分档策略: + +``` +用户显式(ManifestToolchain | TargetSection) + → 硬失败 + 诊断。不静默推翻用户写死的选择。 + 一个真需要 MSVC ABI(要链 vcpkg 预编译 .lib)的项目, + 静默换 ABI 比报错更坏。 + +mcpp 自选(GlobalDefault | TargetPin | FirstRun) + → 自动回退到 winlibs,重写全局默认,一行 info。真·无感。 +``` + +### 2.4 存量修复门 + +位置:`detect()` 之后,即现在 `prepare.cppm:1284` 那个 MSVC-缺-SDK 检查所在处。 + +**两个检查合成一个判据**,而不是并排两条 —— 否则又是一处「同一决策两处推导」: + +``` +判据:目标是 MSVC ABI(msvc@system 或 clang→msvc target)且 !has_usable_msvc() + ├─ 来源 = 用户显式 → 硬失败 + 诊断 + └─ 来源 = mcpp 自选 → 回退 winlibs + 重写全局默认 + info +``` + +这一门作用在**每次构建**上,因此同时盖住了 §1.1 末尾那个首跑方案盖不住的洞:已经把 +`llvm@20.1.7` 写进 `config.toml` 的存量/升级用户,下次构建自动被修好,无需任何手动命令。 + +现有 `:1284` 的「有 VC tools 但缺 SDK」文案作为诊断分支的一个 case 保留,不丢信息。 + +### 2.5 offline 例外 + +`mcpp::platform::env::offline_mode()` / `no_auto_install()` 为真时**不自动安装** winlibs, +只走诊断分支。否则一个关网的 CI 会在毫无预期的情况下去拉 ~200MB payload —— 这正是 +`:1150-1183` 那段 offline 硬错误存在的理由,回退路径必须尊重它。 + +### 2.6 诊断文案 + +``` +llvm on Windows targets the MSVC ABI and needs Visual Studio / Build Tools +(UCRT + Windows SDK + MSVC STL), which was not found on this machine. + + • no Visual Studio? use the self-contained MinGW-w64 toolchain — no VS required: + mcpp toolchain default gcc@16.1.0 --target x86_64-windows-gnu + • have Visual Studio? install the "Desktop development with C++" workload + (it provides the MSVC STL and the Windows SDK), then retry. +``` + +--- + +## 3. 组件 B:含空格路径 + +### 3.1 两条通道收敛成一处 + +`ninja_backend.cppm:108` 的 `local_include_flags` 改为: + +- 用 `d.includePrefix` 取代硬编码的 `" -I"`(与 `flags.cppm:223` 一致); +- 每个 token 走与 `flags.cppm:241` **同一个** quoting helper。 + +**这是本组件的关键约束**:修法不能是在 `:112` 再抄一遍 `shell_quote_arg(escape_path(...))`, +而必须让两条通道调用同一个函数。否则下次新增第三条通道时会以完全相同的方式再漏一次 —— +本 bug 本身就是「#234 修了通道一、通道二没跟上」的产物。 + +抽出的 helper 建议落在 `flags.cppm`(通道一现居地,且 `ninja_backend` 已依赖它),签名形如: + +```cpp +std::string include_token(const CommandDialect& d, + const std::filesystem::path& dir); // prefix + escape + quote +``` + +`localIncludeDirsAfter` 那一半的 `-idirafter` / `/I` / NASM `-I` 三态降级逻辑 +(`ninja_backend.cppm:126-131` 的注释)保持不变,只在 quoting 上并轨。 + +### 3.2 `command_from_argv` 的 Windows argv[0] + +`platform/process.cppm:234`。Windows 下走 `cmd.exe /c` 时对 `argv[0]` 加引号。 + +**这条必须在 Windows 上实测**才能定稿:`cmd.exe` 的引号规则与 `CreateProcess` 不同 +(`/c "a b" c` 的整体剥壳行为、内嵌引号的处理),从 Linux 侧推断不可靠。实施时先写一个 +最小复现(payload 路径含空格 → `capture_exec`),在 CI 上跑通再改。 + +覆盖它的测试见 §6.3。 + +--- + +## 4. 组件 C:build.mcpp 三层 + +### 4.1 L1 引号 + +同 §3.2。 + +### 4.2 L2 方言:扩 `CommandDialect` + +`dialect.cppm:22` 的 `CommandDialect` 目前只有编译侧字段,**没有链接侧**。新增四个: + +| 字段 | GNU | MSVC | 用处 | +|---|---|---|---| +| `libFlag` | `-l{}` | `{}.lib` | `parse_line` 的 `mcpp:link-lib` | +| `libSearchPrefix` | `-L` | `/LIBPATH:` | `parse_line` 的 `mcpp:link-search` | +| `forceCxxLang` | `-x c++` | `/TP` | `.mcpp` 扩展名对编译器未知 | +| `staticRuntime` | `-static` | `/MT` | `staticHostHelper` | + +`libFlag` 用格式串而非前缀,因为 MSVC 是后缀形态(`foo.lib`)而非前缀形态,一个 +`std::string_view prefix` 表达不了。 + +改造两处消费点: + +- `build_program.cppm:677-695` 的 `compileArgv` —— `-O0` 走 `d.optPrefix`、`-o` 走 + `d.outputObjPrefix`(或链接输出的对应形态)、`-x c++` 走 `d.forceCxxLang`、`-static` 走 + `d.staticRuntime`; +- `build_program.cppm:132-134` 的 `parse_line` —— `link-lib` / `link-search` 走上表。 + +`parse_line` 目前不持有 dialect,需要把它传进去(或把翻译推迟到消费点)。**倾向后者**: +`parse_line` 产出中立的结构化字段(`libs` / `libSearchDirs`),翻译发生在拼 argv 的地方 —— +这样指令协议本身保持方言无关,和 `mcpp:` 协议是「声明式」的定位一致。 + +### 4.3 L3 环境 + +- `build_program.cppm` 的 `capture_exec(compileArgv, {}, compileCwd)` 改为传 + `tc.envOverrides`(`model.cppm:46`); +- `host_base_flags`(`:168`)加 MSVC 分支,返回空 —— MSVC 不用 `-B`/`-L`/`--sysroot`, + 它的搜索路径**全部**经由 `INCLUDE` / `LIB` 环境变量,这正是 L3 存在的理由。 + +### 4.4 模块路径的边界 + +MSVC × `import mcpp;`(以及 MSVC × `import std;`,见 §5)走的是 `.ifc` + `/reference`,与 +GCC 的 `gcm.cache` / Clang 的 `-fmodule-file=` 差异较大,是独立的一块工作量。 + +**本方案明确不做**,但必须**明确报错**而非静默炸: + +``` +build.mcpp: `import mcpp;` / `import std;` are not yet supported under MSVC. + Use `#include` in build.mcpp, or build with a GCC/Clang toolchain. +``` + +两者共用同一个门与同一条诊断,不是两处推导。 + +--- + +## 5. 组件 D:build.mcpp 支持 `import std;` + +### 5.1 机器全在,只差接线 + +主构建的 std 模块通道是 `mcpp::toolchain::stdmod::ensure_built` +(声明 `stdmod.cppm:63`,调用点 `prepare.cppm:3785`),返回: + +```cpp +struct StdModule { // stdmod.cppm:46 + std::filesystem::path bmiPath; // /gcm.cache/std.gcm + std::filesystem::path objectPath; // /std.o + std::filesystem::path compatBmiPath; + std::filesystem::path compatObjectPath; +}; +``` + +按 (工具链 × 标准 × 方言) 三层缓存。build.mcpp 直接复用同一个函数,四步: + +1. **检测** —— 沿用 `:666` 同款子串检测,增加 `import std;` / `import std.compat;`; +2. **构建** —— `ensure_built(hostTc, m->package.standard, stdFlagAndDialect, ...)`; +3. **喂入** —— 按方言: + - GCC → 把 BMI 软链/拷到 `bdir/gcm.cache/std.gcm`。`import mcpp;` 已经是这个模式 + (`:698` 把 `compileCwd` 设成 `bdir` 正是为此),std 只是多一个成员; + - Clang → `-fmodule-file=std=`,复用 `flags.cppm:358` 的 `stdBmiUsePrefix` 与 + `staged_std_bmi_path`; +4. **链接** —— `stdObjectPath` 加进 `compileArgv`,位置同 `:686` 的 `mcpp.o`。 + +### 5.2 唯一必须盯死的点:host ≠ target + +`ensure_built` 必须喂**宿主**工具链,不能喂 `*tc`。 + +好消息是这个坑已经被填过:`prepare.cppm:1349` 的 `host_tc_for_build_program()` 在交叉构建 +下会单独解析一份宿主工具链,注释明写 *"Deliberately NO target injection: the spec resolves +for the host."*。接线时取它返回的 `htc` 即可。 + +喂错的后果是产出一个**在宿主上跑不了的 helper**——正是 mingw-cross 那批 host≠target bug +的同款失败模式(std 源探测 / binutils `-B` ×2 / `-lstdc++exp` 门全部错在同一个轴上)。这 +一条在实施时应有单测或 e2e 直接锁住,不能只靠 review —— 现成的挂载点是 +`tests/e2e/112_build_mcpp_cross.sh`(唯一一个跑交叉 build.mcpp 的 e2e),给它补一条 +`import std;` 断言即可,不必新写。 + +### 5.3 成本 + +- 本地构建:`hostTc == *tc`,std BMI 与主构建**共享缓存,零额外开销**; +- 交叉构建:多编一份宿主 std —— 合理且不可避免。 + +### 5.4 标准档位 + +`import std;` 的可用性受 `importStdMinLevel` 门控(`clang.cppm:151` = 20,MSVC = 23)。 +build.mcpp 的 `std_flag` 来自 `m->package.standard`,与主构建同源,所以这个门自动生效, +无需额外逻辑。若项目标准低于门槛,应报与主构建一致的诊断。 + +### 5.5 顺带解锁(不在本方案内) + +内置 `mcpp` 模块(`build_program.cppm:250` 的 `kMcppModuleSource`)可以不再受「不能 +`import std;`」的约束。但它现在的 C 级原语实现工作正常且零依赖,**本方案不动它** —— +留作后续,并把 `:247` 的注释更新为陈述现状而非陈述限制。 + +### 5.6 检测方式的已知弱点 + +子串检测(`srcText.find`)会命中注释和字符串字面量里的 `import std`。 + +- **过检**:多编一份 std BMI。本地零成本(共享缓存),交叉构建浪费一次编译。 +- **漏检**:非常规写法(如 `import std;` 双空格)导致编译报错。 + +两种失败模式都不产生**错误的构建结果**,且与现有 `import mcpp` 检测的行为一致。本方案 +沿用,不引入完整扫描器 —— 若将来 build.mcpp 通道接入 P1689 扫描,两者一起升级。 + +--- + +## 6. 组件 E:CI 测试面 + +### 6.1 硬约束 + +GitHub Actions **没有** Windows 10 / 11 客户端镜像,也**没有**任何不带 VS 的 Windows runner。 +可用标签只有: + +| 标签 | 实际 | 内核近似 | +|---|---|---| +| `windows-2022` | Windows Server 2022 | ≈ Win10 21H2 | +| `windows-2025` | Windows Server 2025 | ≈ Win11 24H2 | +| `windows-11-arm` | Windows 11 ARM64 | —— | + +因此:「win10 / win11」映射为 `windows-2022` / `windows-2025` 两个镜像;「无 MSVC」必须 +自己造。 + +`windows-11-arm` **不纳入**:`aarch64-windows-gnu` 在 `triple.cppm:101` 的 `kKnownTargets` +里根本不存在,winlibs 也无 arm64 payload —— 那是一条独立的移植线,不是测试面问题。 + +### 6.2 fresh-install 三轴 + +`ci-fresh-install.yml:383` 的 `windows-fresh` 从 1 个 job 拆成 3 个: + +| job | runner | 内容 | +|---|---|---| +| `windows-2022-fresh` | `windows-2022` | 现有全套 | +| `windows-2025-fresh` | `windows-2025` | 现有全套 | +| `windows-nomsvc-fresh` | `windows-2025` + 遮蔽 | 见 §6.3 | + +三个 job 共享同一段步骤定义(composite action 或 reusable workflow),避免三处推导。 + +### 6.3 无 MSVC 面:遮蔽法 + 自证门 + +**遮蔽**(runner 是一次性的,破坏无所谓): + +- 重命名 `C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe` + (`msvc.cppm:154` 写死的路径); +- 重命名 VS 安装根; +- 清 `VSINSTALLDIR` / `VCINSTALLDIR` / `VCToolsInstallDir` / `VS*COMNTOOLS` + (`msvc.cppm:168` 的 env 策略)。 + +**遮蔽法唯一的风险**是漏遮一条探测策略(`msvc.cppm:239` 的三级 fallback:vswhere → env → +已知路径)导致「其实还探得到 MSVC,于是走了老路径而全绿」的假绿。 + +**自证门直接消掉这个风险** —— job 的第一步: + +``` +1. mcpp toolchain default msvc → 必须失败 ← 遮蔽生效的自证 +2. mcpp new / build / run → 必须全绿 ← 无感可用 +3. mcpp toolchain list → 星在 x86_64-windows-gnu +4. 产物拷到干净目录、剥离 PATH 后运行 → 必须成功 ← 自包含 +``` + +第 1 步一旦因为遮蔽不全而**通过**,job 当场红。假绿在结构上不可能。 + +第 4 步复用 `tests/e2e/97_mingw_toolchain.sh` 已验证的模式 +(`PATH="/usr/bin:/c/Windows/System32"` + `objdump -p` 导入表检查)。 + +### 6.4 新 e2e + +现有编号最高 `178_test_observability.sh`,新增从 179 起: + +| 编号 | 覆盖 | `# requires:` | +|---|---|---| +| 179 | 含空格路径工程(`.../my dir/proj`,含 `include_dirs` + build.mcpp) | —— (跨平台) | +| 180 | MSVC × build.mcpp(`#include` 形态,三层全过) | `windows msvc` | +| 181 | build.mcpp `import std;`(GCC + Clang) | —— (跨平台) | +| 112(扩) | 已有的 `build_mcpp_cross.sh` 补一条 `import std;` 断言 —— 锁 §5.2 的 host≠target | `mingw-cross` | +| 182 | 裸 Windows 回退 + 意图分档(显式配置须硬失败) | `windows` | + +179 与 181 是跨平台的 —— 空格拆分和 `import std;` 都不是 Windows 专属,在 Linux 上跑更快、 +反馈更早。 + +> **提醒**:`# requires:` 必须在脚本第 2 行,否则等同没写(见 +> `.agents/docs/2026-07-31-test-observability-*`)。 + +### 6.5 补现有 CI 的空缺 + +`ci-windows.yml:267` 的 MSVC 步骤目前跑 `99_msvc_native_build.sh`,其中 `build.mcpp` 出现 +**0 次**;整个 Windows e2e 套件(`ci-windows-e2e.yml:70`)跑在 `llvm@20.1.7` 上,十三个涉及 +build.mcpp 的 e2e(89 / 92 / 97 / 110 / 111 / 112 / 124 / 125 / 143 / 144 / 145 / 164 / 168) +全在 clang 下、payload 路径不含空格。 + +> **覆盖缺口是本批次三条 A 类问题的共同成因**: +> `MSVC × {build.mcpp, 含空格路径, cppwinrt, /MT}` 这个笛卡尔积在 CI 里是空的。 + +MSVC 步骤补 180。 + +--- + +## 7. 明确拒绝 + +### 7.1 Windows 上加 clang + libc++ 并设为默认 —— 拒绝 + +分两种形态,都不成立: + +**(a) libc++ 配 MSVC ABI(`x86_64-pc-windows-msvc` + libc++)** + +- LLVM 官方**不发布** Windows 的 libc++ 二进制,上游属 experimental,locale / 线程 / 异常 + 几块长期有缺口; +- **ABI 隔离**:libc++ 的 `std::string` / `std::vector` 与 MSVC STL 不兼容。一旦选它, + vcpkg 的 MSVC 预编译包、任何第三方 `.lib`、系统 SDK 里跨 `std::` 类型的接口全部不能链; +- **而且它并不解决问题**:MSVC ABI 依然需要 Windows SDK 的 `ucrt`/`um`/`shared` 头和 + import lib,裸机器上照样没有。 + +**(b) llvm-mingw(clang + libc++ + lld + libunwind,`x86_64-windows-gnu`)** + +- 技术上可行; +- 但与已有的 winlibs GCC **功能等价** —— 同一个 target、同一个 ABI、同一类产物; +- 加它 = 给同一个 target 加第二套 stdlib 实现,边际收益(clang 诊断、libc++)远小于成本 + (多一条 payload 发布线、多一组 `import std` 路径、多一个 BMI 缓存维度、CI 多一轴)。 + +**结论:裸 Windows 的洞已被 winlibs GCC 填上,缺的只是默认值。** + +### 7.2 其余拒绝项(承自 #331 triage) + +| 项 | 理由 | +|---|---| +| `ldflags` 路径启发式 | `join_flags` 注释里记着上次同类事故(0.0.97 的 C3 回归);正解是补 typed 字段 | +| `[build] linkage` 简写 | `docs/05-mcpp-toml.md:554` 已明文(*"`toolchain` / `linkage` are exact-triple only"*);这是文档错误不是功能缺失 | +| mcpp 内置 cppwinrt 生成 | 属 build.mcpp 的职责范围,不是引擎职责 | + +--- + +## 8. 风险与不覆盖 + +| 风险 | 缓解 | +|---|---| +| 遮蔽清单与 `msvc.cppm` 探测策略漂移 | §6.3 的自证门:漏遮当场红,不会假绿 | +| 自动回退改写全局 config,让 CI 意外切工具链 | §2.5:offline / no-auto-install 下只诊断不安装;且分档只对 mcpp 自选的默认生效 | +| `cmd.exe` 引号规则从 Linux 侧推断不可靠 | §3.2:先写最小复现在 Windows CI 上跑通再定稿 | +| `ensure_built` 喂错工具链产出跑不了的 helper | §5.2:用 `host_tc_for_build_program()`,并由 e2e 112(扩)在交叉场景下锁住 | + +**明确不覆盖**: + +- 真实客户端 SKU 独有的行为(UAC 提权、Defender 实时扫描干扰、长路径策略)—— GHA 无客户端 + 镜像,只能靠自托管 runner,不在本方案内; +- ARM64 Windows(见 §6.1); +- MSVC × 模块化 build.mcpp(见 §4.4,明确报 unsupported)。 + +--- + +## 9. 实施顺序 + +按「改动小 × 收益大」排,前三条各自独立可交付: + +| 序 | 内容 | 规模 | 收益 | +|---|---|---|---| +| 1 | 组件 A(首跑分档 + 存量修复门 + 诊断) | 小 | 裸 Windows 从「必挂」变「无感可用」,零新组件 | +| 2 | 组件 B(两条通道收敛 quoting) | 小 | 跨平台;含空格路径不再静默拆分 | +| 3 | 组件 D(build.mcpp `import std;`) | 小 | 跨平台;机器全在,只差接线 | +| 4 | 组件 E 的 CI 三轴 + 自证门 | 中 | 锁住 1 的行为;测试面从 1 个镜像变 3 轴 | +| 5 | 组件 C L1(argv[0] 引号) | 中 | 需 Windows 实测;是 6 的前置 | +| 6 | 组件 C L2+L3(方言 + 环境) | 大 | MSVC 上的 build.mcpp 从零到可用 | +| 7 | 组件 E 的 e2e:新增 179–182 + 扩 112 | 中 | 与 1/2/3/6 各自配套,随对应项落地 | + +1–3 可并行,互不触碰同一函数(A 在 `prepare.cppm` 上半段,B 在 `ninja_backend`/`flags`, +D 在 `build_program.cppm` 下半段)。5–6 串行且必须在 Windows 上实测。 + +--- + +## 附:代码坐标速查 + +| 坐标 | 内容 | +|---|---| +| `src/toolchain/triple.cppm:148` | `kFirstRunMacWin` —— 待拆分 | +| `src/toolchain/triple.cppm:106` | `x86_64-windows-gnu` 词表项(verified,pin gcc@16.1.0) | +| `src/toolchain/triple.cppm:137` | Windows host triple `env = "msvc"` | +| `src/toolchain/clang.cppm:138-140` | clang 打 MSVC ABI ⇒ `stdlibId = "msvc-stl"` | +| `src/toolchain/msvc.cppm:34/:102` | `find_std_module_source` / `find_windows_sdk` | +| `src/toolchain/msvc.cppm:154/:168/:239` | vswhere / env / 三级 fallback —— 遮蔽清单来源 | +| `src/toolchain/registry.cppm:240-242` | Windows host → `mingw-gcc`(winlibs) | +| `src/toolchain/dialect.cppm:22` | `CommandDialect` —— 待扩四字段 | +| `src/toolchain/stdmod.cppm:46/:63` | `StdModule` / `ensure_built` | +| `src/toolchain/model.cppm:46` | `envOverrides` | +| `src/build/prepare.cppm:947/951/1015/1024/1274` | tcSpec 优先级链 —— 意图来源标记点 | +| `src/build/prepare.cppm:1167/1209/1224` | offline 硬错误 / 首跑默认 / First run 文案 | +| `src/build/prepare.cppm:1284` | 现有 MSVC-缺-SDK 诊断 —— 与新门合并 | +| `src/build/prepare.cppm:1349` | `host_tc_for_build_program()` —— host≠target 已解 | +| `src/build/prepare.cppm:3785` | `ensure_built` 主构建调用点 | +| `src/build/flags.cppm:223/241` | 通道一:`d.includePrefix` + `shell_quote_arg` | +| `src/build/flags.cppm:358` | `stdBmiUsePrefix` / `staged_std_bmi_path` | +| `src/build/ninja_backend.cppm:108-113` | 通道二:硬编码 `-I`,漏 shell 引号 | +| `src/build/ninja_backend.cppm:1342/1395` | `envOverrides` 唯一消费点 | +| `src/build/build_program.cppm:132-134` | `parse_line` 的 `link-lib` / `link-search` | +| `src/build/build_program.cppm:168-243` | `host_base_flags` —— 只有 Clang/GCC | +| `src/build/build_program.cppm:666` | `usesModule` 子串检测 | +| `src/build/build_program.cppm:677-695` | `compileArgv` —— 写死 GNU 语法 | +| `src/build/build_program.cppm:698` | `compileCwd = bdir`(gcm.cache 定位) | +| `src/platform/process.cppm:234/400/459` | `command_from_argv` 及两个调用点 | +| `.github/workflows/ci-fresh-install.yml:383` | `windows-fresh` —— 待拆三轴 | +| `.github/workflows/ci-windows.yml:261/267` | mingw e2e 步骤 / MSVC 步骤 | +| `tests/e2e/97_mingw_toolchain.sh` | winlibs 自包含的既有验证模式 | +| `tests/e2e/112_build_mcpp_cross.sh` | 唯一的交叉 build.mcpp e2e —— §5.2 的挂载点 | +| `docs/05-mcpp-toml.md:554` | `toolchain` / `linkage` 仅限精确 triple(§7.2) | diff --git a/.agents/docs/2026-08-02-windows-usability-implementation-plan.md b/.agents/docs/2026-08-02-windows-usability-implementation-plan.md new file mode 100644 index 00000000..45cf5ba4 --- /dev/null +++ b/.agents/docs/2026-08-02-windows-usability-implementation-plan.md @@ -0,0 +1,1019 @@ +# Windows 可用性 — 实施计划 + +> **For agentic workers:** 本计划按任务逐条执行,每个任务自带测试循环与提交点。 +> 步骤用 `- [ ]` 复选框跟踪。 + +**Goal:** 让 mcpp 在裸 Windows(无 MSVC STL)上无感可用,补齐含空格路径与 build.mcpp 的 +方言/环境缺口,给 build.mcpp 接上 `import std;`,并把测试面从 1 个 Windows 镜像扩到 +3 轴(含无 MSVC 轴)。 + +**Architecture:** 五个正交组件 A–E,见 `.agents/docs/2026-08-02-windows-usability-design.md`。 +A(默认值分档)、B(引号收敛)、D(`import std;`)互不触碰同一函数,可并行推进; +C(build.mcpp 方言+环境)串行且依赖 B 的 argv[0] 修复;E(CI/e2e)随对应组件落地。 + +**Tech Stack:** C++23 modules,mcpp 自举构建(`mcpp build` / `mcpp test`), +gtest 单测(`tests/unit/`),bash e2e(`tests/e2e/`,`# requires:` 能力门), +GitHub Actions。 + +--- + +## Global Constraints + +- **版本号**:本批次发 `2026.8.2.1`。规范 `YYYY.M.D.N`,月日不补零,`.0` 保留给稳定版。 +- **版本号只改两处**:`mcpp.toml:3` 与 `src/toolchain/fingerprint.cppm:21`。 + **`.xlings.json:3` 是 bootstrap pin,是自举起点,不随本次发布走** —— 它只在 release + 完成、包已上架之后单独 bump。提前改会让全部 CI 去装一个不存在的版本。 +- **校验**:任何版本改动后必须 `bash .github/tools/check_version_pins.sh` 通过。 +- **不引入 clang + libc++ 的任何形态**(设计 §7.1)。 +- **不新增配置字段**:意图来源分档复用现有的两层配置(项目 `mcpp.toml` vs 全局 + `config.toml`),不落盘任何新状态。 +- **同一决策只推导一次**:新增的 MSVC 可用性判据必须与 `prepare.cppm:1284` 现有的 + 「有 VC tools 缺 SDK」检查**合并成一处**,不得并排两个 if。 +- **e2e `# requires:` 必须在脚本第 2 行**,否则等同没写。 +- **单测风格**:`#include ` + `import std;` + `import mcpp.;`。 +- **分支**:`feat/windows-usability`,单 PR,合入用 bypass squash。 + +--- + +## File Structure + +| 文件 | 责任 | 本计划中的变化 | +|---|---|---| +| `src/toolchain/msvc.cppm` | VS/SDK 发现 | **+** `has_usable_msvc()` 谓词 | +| `src/toolchain/triple.cppm` | triple 词表 + 版本 pin | `kFirstRunMacWin` 拆三 | +| `src/toolchain/dialect.cppm` | 命令方言表 | **+** 四个链接/语言字段 | +| `src/build/prepare.cppm` | 构建前置:工具链解析、依赖、计划 | 首跑分档、意图来源、修复门 | +| `src/build/flags.cppm` | 全局编译/链接 flag 组装 | **+** `include_token()` 导出 | +| `src/build/ninja_backend.cppm` | ninja 文件生成 | `local_include_flags` 改调 helper | +| `src/build/build_program.cppm` | build.mcpp 编译/执行/指令解析 | 方言化、env、`import std;` | +| `src/platform/process.cppm` | 进程执行 | Windows `argv[0]` 引号 | +| `tests/unit/test_windows_defaults.cpp` | **新** — A 组件单测 | 新建 | +| `tests/unit/test_build_flags.cpp` | flags 单测 | **+** `include_token` 用例 | +| `tests/unit/test_dialect.cpp` | **新** — 方言字段单测 | 新建 | +| `tests/e2e/179_spaced_paths.sh` | **新** — 含空格路径 | 新建 | +| `tests/e2e/180_msvc_build_mcpp.sh` | **新** — MSVC × build.mcpp | 新建 | +| `tests/e2e/181_build_mcpp_import_std.sh` | **新** — build.mcpp `import std;` | 新建 | +| `tests/e2e/182_windows_no_msvc_fallback.sh` | **新** — 回退 + 意图分档 | 新建 | +| `tests/e2e/112_build_mcpp_cross.sh` | 交叉 build.mcpp | **+** `import std;` 断言 | +| `.github/workflows/ci-fresh-install.yml` | 全新安装验证 | windows-fresh 拆三轴 | +| `.github/workflows/ci-windows.yml` | Windows CI | MSVC 步骤补 180 | +| `docs/03-toolchains.md` `docs/07-build-mcpp.md` `README*.md` | 用户文档 | 同步新行为 | + +--- + +## Task 0: 分支与版本号 + +**Files:** +- Modify: `mcpp.toml:3` +- Modify: `src/toolchain/fingerprint.cppm:21` + +- [ ] **Step 1: 建分支** + +```bash +git checkout -b feat/windows-usability +``` + +- [ ] **Step 2: bump 两处版本号(且仅两处)** + +`mcpp.toml:3`:`version = "2026.8.1.1"` → `version = "2026.8.2.1"` +`src/toolchain/fingerprint.cppm:21`:`MCPP_VERSION = "2026.8.1.1"` → `"2026.8.2.1"` + +`.xlings.json:3` **保持 `2026.8.1.1` 不动**。 + +- [ ] **Step 3: 校验 pin 一致性** + +Run: `bash .github/tools/check_version_pins.sh` +Expected: 退出码 0,输出 `mcpp version: building=2026.8.2.1 (fingerprint=2026.8.2.1) bootstrap pin=2026.8.1.1` + +- [ ] **Step 4: 提交** + +```bash +git add mcpp.toml src/toolchain/fingerprint.cppm .agents/docs/ +git commit -m "chore: bump to 2026.8.2.1 + windows usability design/plan docs" +``` + +--- + +## Task 1: `has_usable_msvc()` 谓词 + +**Files:** +- Modify: `src/toolchain/msvc.cppm`(export 区 `:25-40`,实现区 `:239` 附近) +- Test: `tests/unit/test_windows_defaults.cpp`(新建) + +**Interfaces:** +- Produces: `bool mcpp::toolchain::msvc::has_usable_msvc();` + —— 当且仅当 `find_std_module_source()` 与 `find_windows_sdk()` 都返回值时为 `true`。 + 非 Windows 平台恒为 `false`。 + +- [ ] **Step 1: 写失败测试** + +`tests/unit/test_windows_defaults.cpp`: + +```cpp +#include + +import std; +import mcpp.toolchain.msvc; +import mcpp.platform; + +// has_usable_msvc() 的契约:两件齐才为真。非 Windows 恒假。 +// 在 Windows CI 上这两条分别覆盖有 VS / 遮蔽后无 VS 的机器。 +TEST(WindowsDefaults, HasUsableMsvcIsFalseOffWindows) { + if constexpr (!mcpp::platform::is_windows) { + EXPECT_FALSE(mcpp::toolchain::msvc::has_usable_msvc()); + } else { + GTEST_SKIP() << "windows-specific path covered by e2e 182"; + } +} + +// 谓词必须与它的两个组成部分一致 —— 不允许出现 +// 「has_usable_msvc() 为真但 find_windows_sdk() 为空」这种自相矛盾。 +TEST(WindowsDefaults, HasUsableMsvcAgreesWithItsParts) { + const bool both = mcpp::toolchain::msvc::find_std_module_source().has_value() + && mcpp::toolchain::msvc::find_windows_sdk().has_value(); + EXPECT_EQ(mcpp::toolchain::msvc::has_usable_msvc(), both); +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `mcpp test -- --gtest_filter='WindowsDefaults.*'` +Expected: 编译失败 —— `has_usable_msvc` 未声明。 + +- [ ] **Step 3: 实现** + +`src/toolchain/msvc.cppm` export 区(紧跟 `find_windows_sdk` 声明之后)加: + +```cpp +// True only when BOTH halves of a usable MSVC C++ setup are present: +// the STL's std module source AND the Windows SDK. Either alone is a +// half-installed state (VS with only the .NET workload; VC tools without +// the SDK) that fails at compile time instead of at selection time — +// which is exactly the bug this predicate exists to prevent. +bool has_usable_msvc(); +``` + +实现区: + +```cpp +bool has_usable_msvc() { +#if defined(_WIN32) + return find_std_module_source().has_value() && find_windows_sdk().has_value(); +#else + return false; +#endif +} +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `mcpp test -- --gtest_filter='WindowsDefaults.*'` +Expected: PASS(Linux 上第一条通过、第二条 both=false 也通过) + +- [ ] **Step 5: 提交** + +```bash +git add src/toolchain/msvc.cppm tests/unit/test_windows_defaults.cpp +git commit -m "feat(toolchain): add msvc::has_usable_msvc() — STL and SDK both present" +``` + +--- + +## Task 2: pin 拆分 + 首跑分档 + +**Files:** +- Modify: `src/toolchain/triple.cppm:146-154`(pins 块) +- Modify: `src/build/prepare.cppm:1167-1183`(offline 硬错误)、`:1207-1232`(首跑) +- Test: `tests/unit/test_windows_defaults.cpp` + +**Interfaces:** +- Consumes: `msvc::has_usable_msvc()`(Task 1) +- Produces: + - `pins::kFirstRunMac` = `"llvm@20.1.7"` + - `pins::kFirstRunWinMsvc` = `"llvm@20.1.7"` + - `pins::kFirstRunWinGnu` = `"gcc@16.1.0"` + - `pins::kFirstRunWinGnuTarget` = `"x86_64-windows-gnu"` + - `kFirstRunMacWin` **删除**(全仓库无残留引用) + +- [ ] **Step 1: 写失败测试** + +追加到 `tests/unit/test_windows_defaults.cpp`: + +```cpp +import mcpp.toolchain.triple; + +// 拆分后的三个 pin 必须各自可解析,且 GNU 档位的 target 必须是已登记的 +// verified 目标 —— 否则回退会把用户送进一个 mcpp 拒绝构建的 target。 +TEST(WindowsDefaults, FirstRunPinsParse) { + namespace pins = mcpp::toolchain::triple::pins; + for (auto spec : { pins::kFirstRunMac, pins::kFirstRunWinMsvc, + pins::kFirstRunWinGnu }) { + auto parsed = mcpp::toolchain::parse_toolchain_spec(std::string(spec)); + ASSERT_TRUE(parsed.has_value()) << spec; + EXPECT_FALSE(parsed->version.empty()) << spec; + } +} + +TEST(WindowsDefaults, GnuFallbackTargetIsVerified) { + namespace triple = mcpp::toolchain::triple; + auto t = triple::parse(std::string(triple::pins::kFirstRunWinGnuTarget)); + ASSERT_TRUE(t.has_value()); + const auto* known = triple::find_known_target(*t); + ASSERT_NE(known, nullptr); + EXPECT_EQ(known->tier, "verified"); + // 词表 pin 必须与回退用的工具链一致,否则两处推导会漂移。 + EXPECT_EQ(known->pin, triple::pins::kFirstRunWinGnu); +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `mcpp test -- --gtest_filter='WindowsDefaults.FirstRun*:WindowsDefaults.GnuFallback*'` +Expected: 编译失败 —— `kFirstRunMac` 等未声明。 + +- [ ] **Step 3: 改 pins 块** + +`src/toolchain/triple.cppm:146-154` 替换为: + +```cpp +namespace pins { + // First-run auto-install defaults (prepare.cppm), per host platform/arch. + // + // macOS and Windows used to share ONE pin. They must not: Apple ships no + // GCC so LLVM is the only self-contained choice there, while on Windows + // clang targets the MSVC ABI and therefore needs Visual Studio's STL + + // the Windows SDK — neither of which is preinstalled. A bare Windows box + // got a default it could never build with, and no diagnostic. + inline constexpr std::string_view kFirstRunMac = "llvm@20.1.7"; + // Windows WITH a usable MSVC (STL + SDK): unchanged behavior — the MSVC + // ABI is what lets a project link vcpkg / third-party .lib artifacts. + inline constexpr std::string_view kFirstRunWinMsvc = "llvm@20.1.7"; + // Windows WITHOUT one: winlibs GCC targeting PE/GNU. Fully self-contained + // (static libstdc++/libgcc, its own UCRT), zero VS dependency, `import std` + // works. Must stay equal to kKnownTargets["x86_64-windows-gnu"].pin. + inline constexpr std::string_view kFirstRunWinGnu = "gcc@16.1.0"; + inline constexpr std::string_view kFirstRunWinGnuTarget = "x86_64-windows-gnu"; + inline constexpr std::string_view kFirstRunLinuxX86_64 = "gcc@16.1.0"; + inline constexpr std::string_view kFirstRunLinuxOther = "gcc@15.1.0-musl"; + // Suggested install spellings used by help / MCPP_NO_AUTO_INSTALL errors. + inline constexpr std::string_view kSuggestLlvm = "llvm 20.1.7"; + inline constexpr std::string_view kSuggestGccMusl = "gcc 15.1.0-musl"; + inline constexpr std::string_view kSuggestGccMingw = "gcc 16.1.0"; +} // namespace pins +``` + +- [ ] **Step 4: 改首跑分支** + +`src/build/prepare.cppm:1209-1215` 的 `if constexpr (is_macos || is_windows)` 拆开: + +```cpp + namespace pins = mcpp::toolchain::triple::pins; + std::string defaultSpec; + std::string defaultTargetSpec; // empty = host target + if constexpr (mcpp::platform::is_macos) { + defaultSpec = std::string(pins::kFirstRunMac); + } else if constexpr (mcpp::platform::is_windows) { + // detection-first: the MSVC ABI is only a viable default when the + // machine actually has the MSVC STL *and* the Windows SDK. Without + // them, fall back to the self-contained winlibs GCC — same product + // shape (PE, static), zero Visual Studio dependency. + if (mcpp::toolchain::msvc::has_usable_msvc()) { + defaultSpec = std::string(pins::kFirstRunWinMsvc); + } else { + defaultSpec = std::string(pins::kFirstRunWinGnu); + defaultTargetSpec = std::string(pins::kFirstRunWinGnuTarget); + } + } else if (mcpp::platform::host_arch == std::string_view("x86_64")) { + defaultSpec = std::string(pins::kFirstRunLinuxX86_64); + } else { + defaultSpec = std::string(pins::kFirstRunLinuxOther); + } +``` + +首跑 info 文案(`:1223-1231`)同步分档:Windows-GNU 档位说明「未检测到 Visual Studio, +使用自包含的 MinGW-w64 工具链」。 + +持久化(`:1265-1275`)在 `defaultTargetSpec` 非空时**同时写 `defaultTarget`**,并把 +`overrides.target_triple` 设为它,使本次构建立即生效。 + +- [ ] **Step 5: 改 offline 硬错误分支** + +`prepare.cppm:1167-1174` 的 macOS/Windows 合并分支拆开;Windows 且 `!has_usable_msvc()` +时建议 `mcpp toolchain install gcc 16.1.0` + `mcpp toolchain default gcc@16.1.0 --target x86_64-windows-gnu`, +而不是建议一条在该机器上不可用的 llvm 命令。 + +- [ ] **Step 6: 全仓库确认无 `kFirstRunMacWin` 残留** + +Run: `grep -rn "kFirstRunMacWin" src/ docs/ .github/` +Expected: 无输出。 + +- [ ] **Step 7: 跑测试** + +Run: `mcpp test -- --gtest_filter='WindowsDefaults.*'` +Expected: PASS + +- [ ] **Step 8: 提交** + +```bash +git add src/toolchain/triple.cppm src/build/prepare.cppm tests/unit/test_windows_defaults.cpp +git commit -m "feat(toolchain): detection-first Windows first-run default (winlibs GCC when no MSVC)" +``` + +--- + +## Task 3: 意图来源分档 + 存量修复门 + +**Files:** +- Modify: `src/build/prepare.cppm:947`/`:951`/`:1015`/`:1024`/`:1274`(来源标记) +- Modify: `src/build/prepare.cppm:1280-1292`(现有 MSVC 诊断 → 合并成统一门) + +**Interfaces:** +- Consumes: `msvc::has_usable_msvc()`(Task 1)、pins(Task 2) +- Produces: 文件内的 `enum class TcOrigin { ManifestToolchain, GlobalDefault, + TargetSection, TargetPin, FirstRun }` 与 `bool tc_origin_is_user_explicit(TcOrigin)` + +- [ ] **Step 1: 写失败测试** + +追加到 `tests/unit/test_windows_defaults.cpp` —— 分档策略是纯函数,可直接测: + +```cpp +import mcpp.build.prepare; + +// 分档判据:只有用户显式写下的两种来源算「显式」。mcpp 自选的三种可以被 +// 自动修复。写错这张表的后果是两个方向的:把显式判成自选 → 静默推翻用户的 +// ABI 选择;把自选判成显式 → 存量用户永远撞墙。 +TEST(WindowsDefaults, OriginClassification) { + using mcpp::build::TcOrigin; + EXPECT_TRUE (mcpp::build::tc_origin_is_user_explicit(TcOrigin::ManifestToolchain)); + EXPECT_TRUE (mcpp::build::tc_origin_is_user_explicit(TcOrigin::TargetSection)); + EXPECT_FALSE(mcpp::build::tc_origin_is_user_explicit(TcOrigin::GlobalDefault)); + EXPECT_FALSE(mcpp::build::tc_origin_is_user_explicit(TcOrigin::TargetPin)); + EXPECT_FALSE(mcpp::build::tc_origin_is_user_explicit(TcOrigin::FirstRun)); +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `mcpp test -- --gtest_filter='WindowsDefaults.OriginClassification'` +Expected: 编译失败。 + +- [ ] **Step 3: 加枚举与谓词并 export** + +`src/build/prepare.cppm` export 区: + +```cpp +// Where the resolved toolchain spec came from. Used to decide whether an +// unusable toolchain may be auto-repaired: mcpp may revise a default it +// picked itself, but must never silently overrule a choice the user wrote +// down (a project that needs the MSVC ABI to link vcpkg .lib artifacts is +// better served by an error than by a silent ABI swap). +enum class TcOrigin { + ManifestToolchain, // mcpp.toml [toolchain] — user explicit + TargetSection, // mcpp.toml [target.X].toolchain — user explicit + GlobalDefault, // config.toml [toolchain] default + TargetPin, // triple.cppm vocabulary convention + FirstRun, // written by this very invocation +}; + +inline bool tc_origin_is_user_explicit(TcOrigin o) { + return o == TcOrigin::ManifestToolchain || o == TcOrigin::TargetSection; +} +``` + +- [ ] **Step 4: 在五个赋值点打标记** + +| 行 | 赋值 | 标记 | +|---|---|---| +| `:947` | `tcSpec = m->toolchain.for_platform(...)` | `ManifestToolchain` | +| `:951` | `tcSpec = (*cfg)->defaultToolchain` | `GlobalDefault` | +| `:1015` | `tcSpec = it->second.toolchain` | `TargetSection` | +| `:1024` | `tcSpec = std::string(known->pin)` | `TargetPin` | +| `:1274` | `tcSpec = defaultSpec` | `FirstRun` | + +- [ ] **Step 5: 把现有诊断替换成统一门** + +`prepare.cppm:1280-1292` 现在是: + +```cpp + if (tc->compiler == mcpp::toolchain::CompilerId::MSVC + && tc->envOverrides.empty()) { + return std::unexpected(std::format("msvc {} was detected at {}, ...")); + } +``` + +替换为一个判据、两个分支: + +```cpp + // One decision, one derivation: "this build targets the MSVC ABI but the + // machine has no usable MSVC" covers BOTH the old `msvc@system without a + // Windows SDK` case and the (previously undiagnosed) `clang targeting the + // MSVC ABI on a box with no Visual Studio at all` case. Splitting them + // into two ifs is how the second one went unnoticed in the first place. + const bool targetsMsvcAbi = + tc->compiler == mcpp::toolchain::CompilerId::MSVC + || mcpp::toolchain::is_msvc_target(*tc); + if (targetsMsvcAbi && !mcpp::toolchain::msvc::has_usable_msvc()) { + if (tc_origin_is_user_explicit(tcOrigin) + || mcpp::platform::env::offline_mode() + || mcpp::platform::env::no_auto_install()) { + return std::unexpected(msvc_unavailable_guidance(*tc)); + } + // mcpp picked this default itself and it cannot work here — revise it. + // This gate runs on EVERY build, which is what repairs users who + // already have `llvm@20.1.7` persisted from an older mcpp: the + // first-run branch never fires again for them. + ... 切到 kFirstRunWinGnu + kFirstRunWinGnuTarget,重写全局 config, + info 一行,然后重新 detect() ... + } +``` + +`msvc_unavailable_guidance()` 输出设计 §2.6 的文案,并在 `envOverrides.empty()` 且 +探到了 VC tools 时保留原有的「装 Windows SDK 组件」措辞,不丢信息。 + +- [ ] **Step 6: 跑测试 + 全量单测** + +Run: `mcpp test` +Expected: 全过。 + +- [ ] **Step 7: 提交** + +```bash +git add src/build/prepare.cppm tests/unit/test_windows_defaults.cpp +git commit -m "feat(build): auto-repair an unusable MSVC default, diagnose an explicit one" +``` + +--- + +## Task 4: include token 引号收敛 + +**Files:** +- Modify: `src/build/flags.cppm:215-242`(抽出 helper 并 export) +- Modify: `src/build/ninja_backend.cppm:108-132`(改调 helper) +- Test: `tests/unit/test_build_flags.cpp` + +**Interfaces:** +- Produces: + `std::string mcpp::build::include_token(const mcpp::toolchain::CommandDialect& d, + const std::filesystem::path& dir, + std::string_view prefixOverride = {});` + 返回**已加前缀、已做 ninja `$` 转义、已加 shell 引号**的单个 token(不含前导空格)。 + `prefixOverride` 非空时取代 `d.includePrefix`(供 `-idirafter` / NASM `-I` 复用)。 + +- [ ] **Step 1: 写失败测试** + +追加到 `tests/unit/test_build_flags.cpp`: + +```cpp +import mcpp.toolchain.dialect; + +// #331: 同一份 manifest include_dirs 走两条通道到命令行,只有 flags.cppm 那条 +// 做了 shell 引号。含空格的路径在 per-TU 通道上裂成多个 shell 词。两条通道 +// 必须调同一个 helper —— 抄一遍不算修好,下一条通道还会漏。 +TEST(BuildFlags, IncludeTokenQuotesSpaces) { + const auto& gnu = mcpp::toolchain::gnu_dialect(); + auto tok = mcpp::build::include_token(gnu, std::filesystem::path("/home/my dir/inc")); + // 引号之后,交给 shell 时必须仍是单个词。 + EXPECT_NE(tok.find("my dir"), std::string::npos); + EXPECT_TRUE(tok.starts_with("'-I") || tok.starts_with("\"-I") || tok.find('\'') != std::string::npos) + << "unquoted token: " << tok; +} + +TEST(BuildFlags, IncludeTokenUsesDialectPrefix) { + const auto& msvc = mcpp::toolchain::msvc_dialect(); + auto tok = mcpp::build::include_token(msvc, std::filesystem::path("/tmp/inc")); + EXPECT_NE(tok.find("/I"), std::string::npos); + EXPECT_EQ(tok.find("-I"), std::string::npos); +} + +TEST(BuildFlags, IncludeTokenPrefixOverride) { + const auto& gnu = mcpp::toolchain::gnu_dialect(); + auto tok = mcpp::build::include_token(gnu, std::filesystem::path("/tmp/inc"), "-idirafter"); + EXPECT_NE(tok.find("-idirafter"), std::string::npos); +} +``` + +> 若 `gnu_dialect()` / `msvc_dialect()` 的实际取用函数名与此不同,改用 +> `dialect.cppm` 中真实的取用入口(实现时以 `grep -n "dialect_for\|gnu_dialect" src/toolchain/dialect.cppm` 为准)。 + +- [ ] **Step 2: 跑测试确认失败** + +Run: `mcpp test -- --gtest_filter='BuildFlags.IncludeToken*'` +Expected: 编译失败 —— `include_token` 未声明。 + +- [ ] **Step 3: 抽 helper** + +`src/build/flags.cppm` export 区加声明,实现区把 `:222-242` 现有逻辑抽成: + +```cpp +std::string include_token(const mcpp::toolchain::CommandDialect& d, + const std::filesystem::path& dir, + std::string_view prefixOverride) { + std::string prefix(prefixOverride.empty() ? d.includePrefix : prefixOverride); + return shell_quote_arg(escape_path(std::filesystem::path(prefix + dir.string()))); +} +``` + +`:222-242` 改为调用它,行为不变(回归由现有 flags 单测兜住)。 + +- [ ] **Step 4: ninja_backend 改调 helper** + +`src/build/ninja_backend.cppm:108-132` 的 `local_include_flags` 需要拿到 dialect。 +签名从 `(const CompileUnit& cu, bool msvcDialect)` 改为 +`(const CompileUnit& cu, const mcpp::toolchain::CommandDialect& d)`,调用点同步。 +函数体: + +```cpp +std::string local_include_flags(const CompileUnit& cu, + const mcpp::toolchain::CommandDialect& d) { + const bool nasmUnit = is_nasm_source(cu.source); + const bool msvcDialect = d.includePrefix == std::string_view("/I"); + std::string flags; + for (auto const& inc : cu.localIncludeDirs) { + flags += ' '; + flags += mcpp::build::include_token(d, inc); + } + // #249 的三态降级保持不变,只把 quoting 并轨。 + for (auto const& inc : cu.localIncludeDirsAfter) { + std::string_view pfx = nasmUnit ? "-I" : (msvcDialect ? "/I" : "-idirafter"); + flags += ' '; + flags += mcpp::build::include_token(d, inc, pfx); + } + return flags; +} +``` + +- [ ] **Step 5: 跑测试** + +Run: `mcpp test -- --gtest_filter='BuildFlags.*:NinjaBackend.*'` +Expected: PASS + +- [ ] **Step 6: 自建回归(归一化 diff build.ninja)** + +```bash +mcpp build -p mcpp 2>/dev/null || mcpp build +# 与 main 的 build.ninja 做归一化 diff,确认除 include token 的引号外无差异 +``` + +- [ ] **Step 7: 提交** + +```bash +git add src/build/flags.cppm src/build/ninja_backend.cppm tests/unit/test_build_flags.cpp +git commit -m "fix(build): quote per-TU include dirs — converge both channels on one helper (#331)" +``` + +--- + +## Task 5: `command_from_argv` 的 Windows argv[0] 引号 + +**Files:** +- Modify: `src/platform/process.cppm:234` +- Test: `tests/unit/test_platform_process.cpp`(若不存在则新建) + +**Interfaces:** +- 无新导出;`command_from_argv` 行为变更(Windows 分支)。 + +- [ ] **Step 1: 写失败测试** + +```cpp +#include +import std; +import mcpp.platform.process; + +// #331 #1a: Windows 走 cmd.exe /c 时 argv[0] 未加引号,payload 装在 +// "C:\Program Files\..." 或用户名含空格的机器上,命令行在第一个空格处断开。 +// 这条不是 MSVC 专属 —— 是全 Windows 的路径假设。 +TEST(PlatformProcess, ArgvZeroWithSpacesStaysOneToken) { + std::vector argv = { + "C:\\Program Files\\mcpp\\g++.exe", "-c", "main.cpp" }; + auto cmd = mcpp::platform::process::command_from_argv(argv); + if constexpr (mcpp::platform::is_windows) { + EXPECT_TRUE(cmd.starts_with('"')) << cmd; + EXPECT_NE(cmd.find("\"C:\\Program Files\\mcpp\\g++.exe\""), std::string::npos) << cmd; + } else { + // POSIX 侧已有引号,断言不回退 + EXPECT_NE(cmd.find("Program Files"), std::string::npos) << cmd; + } +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `mcpp test -- --gtest_filter='PlatformProcess.*'` +Expected: 若 `command_from_argv` 未导出则编译失败 → 先 export 再跑;Windows 上 FAIL。 + +- [ ] **Step 3: 实现** + +`process.cppm:234` 的 Windows 分支对 `argv[0]` 加双引号(其余参数沿用现有规则)。 + +> **实测门**:`cmd.exe /c` 对整条命令的引号剥壳规则与 `CreateProcess` 不同。 +> 本步骤的最终形态**必须由 Windows CI 的实跑结果确定**,不得只凭 Linux 侧推断合入。 +> e2e 179(Task 10)在 Windows 上跑通即为验收。 + +- [ ] **Step 4: 跑测试** + +Run: `mcpp test -- --gtest_filter='PlatformProcess.*'` +Expected: PASS(Linux);Windows 由 CI 验收。 + +- [ ] **Step 5: 提交** + +```bash +git add src/platform/process.cppm tests/unit/test_platform_process.cpp +git commit -m "fix(platform): quote argv[0] on Windows — payload paths with spaces (#331)" +``` + +--- + +## Task 6: build.mcpp 支持 `import std;` + +**Files:** +- Modify: `src/build/build_program.cppm:660-700` +- Modify: `src/build/prepare.cppm`(把 `host_tc_for_build_program()` 的结果与 + `m->package.standard` / `stdFlagAndDialect` 传进 build.mcpp 调用) +- Test: `tests/e2e/181_build_mcpp_import_std.sh`(新建) + +**Interfaces:** +- Consumes: `mcpp::toolchain::stdmod::ensure_built(tc, standard, dialectFlags, macosDeploymentTarget)` + → `StdModule{ bmiPath, objectPath, compatBmiPath, compatObjectPath }`(`stdmod.cppm:46/63`) +- Consumes: `prepare.cppm:1349` 的 `host_tc_for_build_program()` → `{frontend, Toolchain}` + +- [ ] **Step 1: 写失败 e2e** + +`tests/e2e/181_build_mcpp_import_std.sh`(注意 `# requires:` 必须在**第 2 行**): + +```bash +#!/usr/bin/env bash +# requires: unix-shell +# 181_build_mcpp_import_std.sh — build.mcpp can `import std;` +# mcpp 让用户全项目 import std,却要求构建脚本回退到 #include —— 这条测试 +# 锁住那个缺口被补上。跨平台:import std 不是 Windows 专属问题。 +set -e + +TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT +cd "$TMP" +"$MCPP" new imp_std >/dev/null 2>&1 +cd imp_std + +cat > build.mcpp <<'EOF' +import std; +int main() { + std::vector flags{"MCPP_FROM_IMPORT_STD"}; + for (auto const& f : flags) std::println("mcpp:cfg={}", f); + std::println("mcpp:rerun-if-changed=build.mcpp"); + return 0; +} +EOF + +cat > src/main.cpp <<'EOF' +import std; +int main() { +#ifdef MCPP_FROM_IMPORT_STD + std::println("import-std-ok"); + return 0; +#else + std::println("define missing"); + return 1; +#endif +} +EOF + +out=$("$MCPP" build 2>&1) || { echo "FAIL: build: $out"; exit 1; } +run_out=$("$MCPP" run 2>&1) || { echo "FAIL: run: $run_out"; exit 1; } +[[ "$run_out" == *"import-std-ok"* ]] || { echo "FAIL: run output: $run_out"; exit 1; } + +echo "PASS: build.mcpp import std" +``` + +- [ ] **Step 2: 跑 e2e 确认失败** + +Run: `MCPP=$(pwd)/target//release/bin/mcpp bash tests/e2e/181_build_mcpp_import_std.sh` +Expected: FAIL —— build.mcpp 编译报错(找不到 std 模块)。 + +> **坑**:`target/` 下有多个指纹目录,`ls | head -1` 会取到旧版本的二进制。 +> 用 `mcpp build` 的输出路径,或 `find target -name mcpp -newer mcpp.toml`。 + +- [ ] **Step 3: 实现** + +`build_program.cppm` 检测扩展: + +```cpp + bool usesModule = srcText.find("import mcpp") != std::string::npos; + bool usesStd = srcText.find("import std;") != std::string::npos + || srcText.find("import std.compat;") != std::string::npos; + bool usesStdCompat= srcText.find("import std.compat;") != std::string::npos; +``` + +`usesStd` 为真时,调 `stdmod::ensure_built(hostTc, standard, stdFlagAndDialect, deploymentTarget)`, +然后按方言接入: + +- GCC:把 `sm->bmiPath` 暂存到 `bdir/gcm.cache/std.gcm`(`std.compat.gcm` 同理), + 并令 `compileCwd = bdir`(与 `usesModule` 共用同一条 cwd 决策,不要写成两个 if); +- Clang:`compileArgv.push_back(std::string(traits.stdBmiUsePrefix) + sm->bmiPath.string())`, + 复用 `flags.cppm:358` 的同一前缀常量; +- 两者都把 `sm->objectPath`(及 compat 的)推进 `compileArgv`,位置同 `mcpp.o`。 + +**必须喂宿主工具链**:调用点传的是 `host_tc_for_build_program()` 返回的 `htc`, +**不是** `*tc`。喂 `*tc` 会在交叉构建下产出跑不了的 helper。 + +- [ ] **Step 4: 跑 e2e** + +Run: `bash tests/e2e/181_build_mcpp_import_std.sh` +Expected: `PASS: build.mcpp import std` + +- [ ] **Step 5: 更新 `kMcppModuleSource` 的注释** + +`build_program.cppm:247` 的 *"no `#include`, no `import std;`"* 改为陈述现状 +(内置模块用 C 级原语实现,与 build.mcpp 能否 `import std;` 无关),不改代码。 + +- [ ] **Step 6: 提交** + +```bash +git add src/build/build_program.cppm src/build/prepare.cppm tests/e2e/181_build_mcpp_import_std.sh +git commit -m "feat(build.mcpp): support import std; via the shared stdmod cache" +``` + +--- + +## Task 7: e2e 112 扩交叉 `import std;` 断言 + +**Files:** +- Modify: `tests/e2e/112_build_mcpp_cross.sh` + +- [ ] **Step 1: 追加断言** + +在现有交叉 build.mcpp 用例之后,追加一个 `import std;` 形态的 build.mcpp, +断言构建通过且 helper 在**宿主**上真的跑起来(产生了它该产生的 marker 文件)。 +这是 §5.2 host≠target 的锁:喂错工具链时 helper 会在 exec 阶段失败。 + +- [ ] **Step 2: 跑** + +Run: `bash tests/e2e/112_build_mcpp_cross.sh`(需 `mingw-cross` 能力,本机无则 CI 验收) + +- [ ] **Step 3: 提交** + +```bash +git add tests/e2e/112_build_mcpp_cross.sh +git commit -m "test(e2e): lock host!=target for build.mcpp import std (112)" +``` + +--- + +## Task 8: `CommandDialect` 扩四字段 + +**Files:** +- Modify: `src/toolchain/dialect.cppm:22-60` +- Test: `tests/unit/test_dialect.cpp`(新建) + +**Interfaces:** +- Produces(`CommandDialect` 新成员): + - `std::string_view libFlag;` GNU `"-l{}"` / MSVC `"{}.lib"` + - `std::string_view libSearchPrefix;` GNU `"-L"` / MSVC `"/LIBPATH:"` + - `std::string_view forceCxxLang;` GNU `"-x c++"` / MSVC `"/TP"` + - `std::string_view staticRuntime;` GNU `"-static"`/ MSVC `"/MT"` +- Produces: `std::string lib_flag_for(const CommandDialect&, std::string_view name);` + —— 因为 MSVC 是后缀形态(`foo.lib`),单个前缀常量表达不了。 + +- [ ] **Step 1: 写失败测试** + +```cpp +#include +import std; +import mcpp.toolchain.dialect; + +// 链接侧字段:GNU 是前缀形态、MSVC 是后缀形态,所以 libFlag 必须是格式串 +// 而非前缀 —— 用 std::string_view prefix 表达不了 `foo.lib`。 +TEST(Dialect, LibFlagBothShapes) { + EXPECT_EQ(mcpp::toolchain::lib_flag_for(mcpp::toolchain::gnu_dialect(), "z"), "-lz"); + EXPECT_EQ(mcpp::toolchain::lib_flag_for(mcpp::toolchain::msvc_dialect(), "z"), "z.lib"); +} + +TEST(Dialect, LinkAndLangFieldsArePopulated) { + for (auto const* d : { &mcpp::toolchain::gnu_dialect(), + &mcpp::toolchain::msvc_dialect() }) { + EXPECT_FALSE(d->libFlag.empty()); + EXPECT_FALSE(d->libSearchPrefix.empty()); + EXPECT_FALSE(d->forceCxxLang.empty()); + EXPECT_FALSE(d->staticRuntime.empty()); + } +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `mcpp test -- --gtest_filter='Dialect.*'` +Expected: 编译失败。 + +- [ ] **Step 3: 实现** —— 加四个字段到 `CommandDialect`、填两个方言表、加 `lib_flag_for`。 + +- [ ] **Step 4: 跑测试** + +Run: `mcpp test -- --gtest_filter='Dialect.*'` +Expected: PASS + +- [ ] **Step 5: 提交** + +```bash +git add src/toolchain/dialect.cppm tests/unit/test_dialect.cpp +git commit -m "feat(toolchain): add link/language fields to CommandDialect" +``` + +--- + +## Task 9: build.mcpp 方言化(L2)+ 环境(L3)+ MSVC 模块门 + +**Files:** +- Modify: `src/build/build_program.cppm:132-140`(指令面)、`:168-243`(host_base_flags)、 + `:677-700`(compileArgv + capture_exec) + +- [ ] **Step 1: 指令面改为方言无关** + +`parse_line` 不再拼 `-l` / `-L`,改为产出中立字段 `d.libs` / `d.libSearchDirs`; +翻译推迟到拼 argv 处,用 `lib_flag_for()` / `d.libSearchPrefix`。 +理由:`mcpp:` 协议是声明式的,方言属于消费端。 + +- [ ] **Step 2: compileArgv 方言化** + +`-O0` → `d.optPrefix`+`0`;`-x c++` → `d.forceCxxLang`;`-static` → `d.staticRuntime`; +`-o ` → 按方言(MSVC 用 `/Fe:`)。 + +- [ ] **Step 3: host_base_flags 加 MSVC 分支** + +返回空 —— MSVC 的搜索路径全部经由 `INCLUDE` / `LIB` 环境变量。 + +- [ ] **Step 4: capture_exec 传 env** + +`capture_exec(compileArgv, {}, compileCwd)` → 传 `tc.envOverrides`(`model.cppm:46`)。 +这是三层里的最后一层:没有它,方言全对 cl.exe 依然找不到 ``。 + +- [ ] **Step 5: MSVC × 模块化 build.mcpp 的 unsupported 门** + +`usesModule || usesStd` 且方言为 MSVC 时,返回: + +``` +build.mcpp: `import mcpp;` / `import std;` are not yet supported under MSVC. + Use `#include` in build.mcpp, or build with a GCC/Clang toolchain. +``` + +一个门、一条诊断,不写成两处。 + +- [ ] **Step 6: 跑现有 build.mcpp e2e 确认无回归** + +Run: `for t in 89 92 110 111 124 125 143 144 145 164 168; do bash tests/e2e/${t}_*.sh || echo "FAIL $t"; done` +Expected: 全 PASS(GNU 路径行为不变) + +- [ ] **Step 7: 提交** + +```bash +git add src/build/build_program.cppm +git commit -m "feat(build.mcpp): dialect-aware compile/directives + MSVC env plumbing (#331)" +``` + +--- + +## Task 10: e2e 179 含空格路径 + +**Files:** +- Create: `tests/e2e/179_spaced_paths.sh` + +- [ ] **Step 1: 写测试** + +第 2 行 `# requires: unix-shell`。在一个**路径含空格**的临时目录里建工程, +`[build] include_dirs` 指向一个含空格的目录,放一个只能通过该 include 找到的头文件, +再放一个 build.mcpp,断言 build + run 全绿。跨平台。 + +- [ ] **Step 2: 跑,确认在修复前失败 / 修复后通过** + +Run: `bash tests/e2e/179_spaced_paths.sh` + +- [ ] **Step 3: 提交** + +--- + +## Task 11: e2e 180 MSVC × build.mcpp + +**Files:** +- Create: `tests/e2e/180_msvc_build_mcpp.sh`(第 2 行 `# requires: windows msvc`) + +- [ ] **Step 1: 写测试** —— `#include` 形态的 build.mcpp,在 `msvc@system` 下 build + run; + 并断言 `import std;` 形态给出的是**明确的 unsupported 诊断**而非裸崩。 +- [ ] **Step 2: 由 Windows CI 验收**(本机无 MSVC) +- [ ] **Step 3: 提交** + +--- + +## Task 12: e2e 182 裸 Windows 回退 + 意图分档 + +**Files:** +- Create: `tests/e2e/182_windows_no_msvc_fallback.sh`(第 2 行 `# requires: windows`) + +- [ ] **Step 1: 写测试** + +在遮蔽了 MSVC 的环境下(由 CI job 提供,脚本自身先断言 `toolchain default msvc` 失败): +1. 全新 MCPP_HOME 下 `mcpp new` + `build` + `run` 必须全绿; +2. `toolchain list` 星在 `x86_64-windows-gnu`; +3. 写一个显式 `[toolchain] windows = "llvm@20.1.7"` 的工程 → 必须**硬失败**且诊断里含 + `x86_64-windows-gnu` 指路(验证意图分档没把显式判成自选)。 + +- [ ] **Step 2: 由 Windows CI 验收** +- [ ] **Step 3: 提交** + +--- + +## Task 13: CI —— fresh-install 三轴 + 遮蔽自证门 + +**Files:** +- Modify: `.github/workflows/ci-fresh-install.yml:375-440` +- Modify: `.github/workflows/ci-windows.yml:267`(MSVC 步骤补 180) + +- [ ] **Step 1: 把 `windows-fresh` 的步骤抽成可复用单元**,避免三处推导。 +- [ ] **Step 2: 加 `windows-2022-fresh` / `windows-2025-fresh` 两轴。** +- [ ] **Step 3: 加 `windows-nomsvc-fresh`** —— 遮蔽 vswhere / VS 根 / 四个环境变量, + **第一步就断言 `mcpp toolchain default msvc` 必须失败**(自证门),再跑 182。 +- [ ] **Step 4: `ci-windows.yml` 的 MSVC 步骤追加 `tests/e2e/180_msvc_build_mcpp.sh`。** +- [ ] **Step 5: 提交。** + +--- + +## Task 14: 文档同步 + +**Files:** +- Modify: `docs/03-toolchains.md`(Windows 默认行为、回退规则) +- Modify: `docs/07-build-mcpp.md`(`import std;` 现已支持;MSVC 限制) +- Modify: `README.md` / `README.zh.md`(把 winlibs 从脚注提到正文) +- Modify: `docs/zh/` 对应中文页 + +- [ ] **Step 1–2: 改文档、检查 `[build] linkage` 的三处错误措辞(设计 §7.2)。** +- [ ] **Step 3: 提交。** + +--- + +## Task 15: PR → CI 全绿 → 合入 + +- [ ] **Step 1: 本机全量回归** + +```bash +mcpp test # 单测 +bash tests/e2e/run_all.sh # e2e(本机能力范围内) +bash .github/tools/check_version_pins.sh +``` + +- [ ] **Step 2: 开 PR** + +```bash +git push -u origin feat/windows-usability +gh pr create --title "feat(windows): usable on a bare Windows box (2026.8.2.1)" --body "..." +``` + +- [ ] **Step 3: 盯 CI,红了就修,直到全绿。** + +关注点:Windows 三轴、MSVC 步骤、cross-build-test(112)、Linux/macOS 无回归。 + +- [ ] **Step 4: 合入** + +```bash +gh pr merge --squash --admin --delete-branch +``` + +> **坑**:叠栈 PR 时 `--delete-branch` 会 CLOSE 子 PR 且不可 reopen。本批次是单 PR,无此风险。 + +--- + +## Task 16: Release + 生态闭环 + +- [ ] **Step 1: 打 tag 触发 release.yml,产出四平台产物。** +- [ ] **Step 2: 镜像到 xlings-res 双端(GitHub + GitCode)。** + +publish-ecosystem 的 per-file 超时会杀大件 —— 失败时**本地 gtc 补传**, +token 走 `~/.config/gitcode-tool/config.json` 而非环境变量;沙箱 wrapper 的 gtc 坏, +用 repo 里的 gtc + `/usr/bin/python3`。 + +- [ ] **Step 3: 两端独立 GET + sha256 核验**(不信 workflow 的绿灯; + `obs_callback 400 EOF` 是假错,对象可能已落盘,须回探下载 URL 判定)。 +- [ ] **Step 4: 更新 xim-pkgindex(PR 须用 Sunrisepeak 账号合)。** +- [ ] **Step 5: clean-room 验证** + +用隔离的 `XLINGS_HOME` 真装 `xlings install mcpp@2026.8.2.1`,别动本地 `~/.xlings`; +索引 artifact CDN 滞后可达 ~40min,`not found` ≠ 回归,重跑即绿。 + +- [ ] **Step 6: bump bootstrap pin** + +包上架**之后**才改 `.xlings.json:3` → `2026.8.2.1`,单独提交/PR。 + +--- + +## Self-Review + +**Spec coverage** + +| 设计节 | 覆盖任务 | +|---|---| +| §2 组件 A | Task 1(谓词)、2(pin+首跑)、3(分档+修复门+诊断+offline 例外) | +| §3 组件 B | Task 4(include 收敛)、5(argv[0]) | +| §4 组件 C | Task 8(方言字段)、9(L2+L3+模块门) | +| §5 组件 D | Task 6(`import std;`)、7(交叉锁) | +| §6 组件 E | Task 10–13(e2e 179/180/182 + 112 扩 + CI 三轴) | +| §7 拒绝项 | Task 14(文档修正 `[build] linkage` 措辞);其余为「不做」,无任务 | +| §9 顺序 | Task 编号即顺序;1–6 可并行,8–9 依赖 5 | + +**已知遗留**(设计中明确不做,此处记录以免误判为漏项): +ARM64 Windows;真实客户端 SKU 行为;MSVC × 模块化 build.mcpp(Task 9 Step 5 只给诊断); +内置 `mcpp` 模块改用 `import std;`(Task 6 Step 5 只改注释)。 diff --git a/mcpp.toml b/mcpp.toml index 3533601f..46247af3 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.8.1.2" +version = "2026.8.2.1" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/src/toolchain/fingerprint.cppm b/src/toolchain/fingerprint.cppm index 987c9c7f..6ea15d3c 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 = "2026.8.1.2"; +inline constexpr std::string_view MCPP_VERSION = "2026.8.2.1"; struct FingerprintInputs { Toolchain toolchain; From 103780b5cbc3b10135ac829bd504b371e1a0ae58 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sun, 2 Aug 2026 01:34:09 +0800 Subject: [PATCH 02/14] feat(windows): detection-first default + auto-repair an unusable MSVC choice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bare Windows box has the UCRT runtime DLLs but neither the MSVC STL nor the Windows SDK — both arrive only with Visual Studio's 'Desktop development with C++' workload. mcpp's first-run default shared one pin with macOS (llvm@20.1.7), and on Windows the host triple is MSVC-ABI, so clang picked the MSVC STL and the build failed at compile time with no actionable message. The self-contained alternative (winlibs GCC targeting x86_64-windows-gnu) was already a verified target with Windows-native CI — just not the default. - msvc::has_usable_msvc(): STL *and* SDK, so a half-installed VS cannot pass - split kFirstRunMacWin into per-platform pins; the Windows no-VS case seeds only the target axis and lets the existing vocabulary pin derive the rest - TcOrigin: distinguish a default mcpp chose from one the user wrote down; only the former may be auto-repaired - fold the old 'VC tools but no SDK' check into one MSVC-ABI-usability gate that runs on every build, which is what repairs users already carrying a persisted llvm@20.1.7 — the first-run branch never fires again for them --- src/build/prepare.cppm | 246 ++++++++++++++++++++++++--- src/toolchain/msvc.cppm | 24 +++ src/toolchain/triple.cppm | 30 +++- tests/unit/test_windows_defaults.cpp | 90 ++++++++++ 4 files changed, 366 insertions(+), 24 deletions(-) create mode 100644 tests/unit/test_windows_defaults.cpp diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 0ca7bea9..163a6692 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -600,6 +600,65 @@ bool graph_or_targets_import_std(const mcpp::modgraph::Graph& graph, // `--no-cache` used to be the only switch and it meant "clear the build dir", // which says nothing about a cache (and its help text claimed all of target/); // it stays as a deprecated alias for Off. +// Where the resolved toolchain spec came from. +// +// This exists so mcpp can tell its own guesses apart from the user's +// instructions. When a resolved toolchain turns out to be unusable on this +// machine (the motivating case: a Windows default that targets the MSVC ABI +// on a box with no Visual Studio), mcpp may quietly revise a default it +// picked itself — but a spec the user wrote into mcpp.toml must produce an +// error instead. A project that needs the MSVC ABI to link vcpkg-built .lib +// files is worse off with a silent ABI swap than with a failed build. +// +// Deliberately derived from the two config layers that already exist rather +// than persisted: no new field, nothing to keep in sync on disk. +export enum class TcOrigin { + None, // nothing resolved yet + ManifestToolchain, // mcpp.toml [toolchain] — user explicit + TargetSection, // mcpp.toml [target.X].toolchain — user explicit + GlobalDefault, // config.toml [toolchain] default — mcpp's own default + TargetPin, // triple.cppm vocabulary convention + FirstRun, // chosen and persisted by this very invocation +}; + +export inline bool tc_origin_is_user_explicit(TcOrigin o) { + return o == TcOrigin::ManifestToolchain || o == TcOrigin::TargetSection; +} + +// What to tell a user whose build targets the MSVC ABI on a machine that +// cannot serve it. Two shapes, because the two states need different fixes: +// +// • cl.exe was found but the Windows SDK was not — a half-installed VS. +// Point at the missing SDK component; switching toolchains would be an +// over-correction for someone who clearly wants MSVC. +// • nothing usable at all — the bare-Windows case. Lead with the MinGW-w64 +// route, which needs no Visual Studio and is already a verified target, +// and keep the "install the C++ workload" option second. +export std::string msvc_unavailable_guidance(const mcpp::toolchain::Toolchain& tc) { + namespace pins = mcpp::toolchain::triple::pins; + const bool haveVcTools = tc.compiler == mcpp::toolchain::CompilerId::MSVC; + if (haveVcTools && mcpp::toolchain::msvc::find_msvc_tools_dir()) { + return std::format( + "msvc {} was detected at {}, but no Windows SDK was found —\n" + " cl.exe cannot compile without the UCRT/SDK headers.\n" + " Install the 'Windows 11 SDK' component via the Visual Studio\n" + " Installer (it is part of the Desktop development with C++\n" + " workload), then retry.", + tc.version, tc.binaryPath.string()); + } + return std::format( + "this build targets the MSVC ABI, which needs Visual Studio /\n" + " Build Tools (MSVC STL + Windows SDK) — neither was found.\n" + "\n" + " No Visual Studio? Use the self-contained MinGW-w64 toolchain\n" + " (no Visual Studio required, `import std` works):\n" + " mcpp toolchain default {} --target {}\n" + "\n" + " Have Visual Studio? Install the 'Desktop development with C++'\n" + " workload — it provides the MSVC STL and the Windows SDK.", + pins::kSuggestGccMingw, pins::kFirstRunWinGnuTarget); +} + export enum class CacheMode { Global, Local, Off }; export std::optional parse_cache_mode(std::string_view v) { @@ -1016,10 +1075,42 @@ prepare_build(bool print_fingerprint, } auto tcSpec = m->toolchain.for_platform(kCurrentPlatform); + // Where the spec came from decides whether mcpp may later revise it. + // See TcOrigin: mcpp can rewrite a default it chose itself, but must not + // silently overrule one the user wrote down. + auto tcOrigin = tcSpec.has_value() ? TcOrigin::ManifestToolchain + : TcOrigin::None; if (!tcSpec.has_value()) { auto cfg = get_cfg(); if (cfg && !(*cfg)->defaultToolchain.empty()) { - tcSpec = (*cfg)->defaultToolchain; + tcSpec = (*cfg)->defaultToolchain; + tcOrigin = TcOrigin::GlobalDefault; + } + } + + // ─── Windows first run without Visual Studio ──────────────────────── + // The host triple on Windows is MSVC-ABI, so the historical default + // (llvm) resolves to clang targeting MSVC — which uses the MSVC STL and + // the Windows SDK. Neither ships with Windows; both arrive only with + // Visual Studio's "Desktop development with C++" workload. On a bare box + // that default installs fine and then fails at compile time with no + // actionable message. + // + // Seed only the TARGET axis and let the block right below derive the + // rest: the vocabulary table already maps x86_64-windows-gnu to its pin + // (winlibs GCC) and to static linkage, so the toolchain answer stays a + // single derivation instead of being spelled out a second time here. + bool windowsGnuFirstRun = false; + if constexpr (mcpp::platform::is_windows) { + if (!tcSpec.has_value() && overrides.target_triple.empty() + && m->buildConfig.target.empty() + && !mcpp::toolchain::msvc::has_usable_msvc()) { + auto cfgW = get_cfg(); + if (!cfgW || (*cfgW)->defaultTarget.empty()) { + overrides.target_triple = + std::string(mcpp::toolchain::triple::pins::kFirstRunWinGnuTarget); + windowsGnuFirstRun = true; + } } } @@ -1083,7 +1174,10 @@ prepare_build(bool print_fingerprint, if (parsed) overrides.target_triple = parsed->str(); if (hasExplicitSection) { - if (!it->second.toolchain.empty()) tcSpec = it->second.toolchain; + if (!it->second.toolchain.empty()) { + tcSpec = it->second.toolchain; + tcOrigin = TcOrigin::TargetSection; + } if (!it->second.linkage.empty()) m->buildConfig.linkage = it->second.linkage; } // Convention from the vocabulary table (triple.cppm): the target's @@ -1092,8 +1186,13 @@ prepare_build(bool print_fingerprint, // mapping, not here) and its default linkage. GCC 16 pin rationale: // GCC 15 drops module template instantiations at link (remediation // doc A2; packages shipped 2026-07-08/09, GitHub+GitCode). - if (known && !hasToolchainOverride && !known->pin.empty()) + if (known && !hasToolchainOverride && !known->pin.empty()) { tcSpec = std::string(known->pin); + // A convention, not an instruction: on the Windows-GNU first-run + // path this is what turns the seeded target into `gcc@16.1.0`. + if (!tc_origin_is_user_explicit(tcOrigin)) + tcOrigin = TcOrigin::TargetPin; + } if (known && known->defaultStatic && m->buildConfig.linkage.empty()) m->buildConfig.linkage = "static"; } @@ -1235,6 +1334,21 @@ prepare_build(bool print_fingerprint, std::string_view release = mcpp::platform::env::offline_mode() ? "or drop --offline / unset MCPP_OFFLINE to let mcpp auto-install." : "or unset MCPP_NO_AUTO_INSTALL to let mcpp auto-install."; + // Windows without a usable MSVC must not be told to install llvm: + // that default resolves to clang targeting the MSVC ABI, which is + // exactly what this machine cannot build. Name the toolchain that + // will actually work there instead. + if (mcpp::platform::is_windows + && !mcpp::toolchain::msvc::has_usable_msvc()) { + return std::unexpected(std::format( + "no toolchain configured (and no Visual Studio found).\n" + " run one of:\n" + " mcpp toolchain install {} --target {}\n" + " mcpp toolchain default {} --target {}\n" + " {}", + pins::kSuggestGccMingw, pins::kFirstRunWinGnuTarget, + pins::kFirstRunWinGnu, pins::kFirstRunWinGnuTarget, release)); + } if constexpr (mcpp::platform::is_macos || mcpp::platform::is_windows) { return std::unexpected(std::format( "no toolchain configured.\n" @@ -1242,7 +1356,7 @@ prepare_build(bool print_fingerprint, " mcpp toolchain install {}\n" " mcpp toolchain default {}\n" " {}", - pins::kSuggestLlvm, pins::kFirstRunMacWin, release)); + pins::kSuggestLlvm, pins::kFirstRunMac, release)); } else { return std::unexpected(std::format( "no toolchain configured.\n" @@ -1277,8 +1391,13 @@ prepare_build(bool print_fingerprint, // toolchain, addable later for native-ABI aarch64 builds. namespace pins = mcpp::toolchain::triple::pins; std::string defaultSpec; - if constexpr (mcpp::platform::is_macos || mcpp::platform::is_windows) { - defaultSpec = std::string(pins::kFirstRunMacWin); + if constexpr (mcpp::platform::is_macos) { + defaultSpec = std::string(pins::kFirstRunMac); + } else if constexpr (mcpp::platform::is_windows) { + // Reaching here means has_usable_msvc() was true — the seed above + // diverts the no-Visual-Studio case onto the windows-gnu target + // before the target block runs, so it never gets this far. + defaultSpec = std::string(pins::kFirstRunWinMsvc); } else if (mcpp::platform::host_arch == std::string_view("x86_64")) { defaultSpec = std::string(pins::kFirstRunLinuxX86_64); } else { @@ -1342,24 +1461,113 @@ prepare_build(bool print_fingerprint, mcpp::ui::status("Default", std::format("set to {}", defaultSpec)); } // best-effort: a failed config write only loses the persistence, // not the running build. - tcSpec = defaultSpec; + tcSpec = defaultSpec; + tcOrigin = TcOrigin::FirstRun; + } + + // Windows first run that got diverted to winlibs GCC: announce it and + // persist BOTH axes, so the next invocation is silent and + // `mcpp toolchain list` shows the same pair the build actually used. + // Persisting only the target would leave the toolchain axis implicit + // (derived from the vocabulary pin) and the two views would disagree. + if (windowsGnuFirstRun && tcSpec.has_value()) { + mcpp::ui::info("First run", + std::format("no toolchain configured and no Visual Studio found — " + "installing {} for {} (MinGW-w64, self-contained)", + *tcSpec, overrides.target_triple)); + if (auto cfgW = get_cfg(); cfgW) { + if (mcpp::config::write_default_toolchain(**cfgW, *tcSpec)) + (*cfgW)->defaultToolchain = *tcSpec; + if (mcpp::config::write_default_target(**cfgW, overrides.target_triple)) + (*cfgW)->defaultTarget = overrides.target_triple; + mcpp::ui::status("Default", + std::format("set to {} → {}", *tcSpec, overrides.target_triple)); + } + tcOrigin = TcOrigin::FirstRun; } auto tc = mcpp::toolchain::detect(explicit_compiler); if (!tc) return std::unexpected(tc.error().message); - // Native MSVC builds need the synthesized INCLUDE/LIB env — absent when - // detection found VC tools but no Windows SDK. Fail here with guidance - // instead of cl.exe's later "cannot open include file: 'corecrt.h'". - if (tc->compiler == mcpp::toolchain::CompilerId::MSVC - && tc->envOverrides.empty()) { - return std::unexpected(std::format( - "msvc {} was detected at {}, but no Windows SDK was found —\n" - " cl.exe cannot compile without the UCRT/SDK headers.\n" - " Install the 'Windows 11 SDK' component via the Visual Studio\n" - " Installer (it is part of the Desktop development with C++\n" - " workload), then retry.", - tc->version, tc->binaryPath.string())); + // ── Targeting the MSVC ABI without a usable MSVC ───────────────────── + // + // One judgement, one place. This used to be two separate concerns and + // only one of them was implemented: `msvc@system` with no Windows SDK + // was caught here, while clang-targeting-MSVC on a machine with no + // Visual Studio at all — the default on every bare Windows box — fell + // straight through to clang's own "'vector' file not found", from which + // no user could infer that a working alternative was one flag away. + // Deriving the same judgement in two places is how the second case went + // unnoticed, so they are now one condition with two outcomes. + const bool targetsMsvcAbi = + tc->compiler == mcpp::toolchain::CompilerId::MSVC + || mcpp::toolchain::is_msvc_target(*tc); + if (targetsMsvcAbi && !mcpp::toolchain::msvc::has_usable_msvc()) { + const bool mayRepair = + !tc_origin_is_user_explicit(tcOrigin) + && !mcpp::platform::env::offline_mode() + && !mcpp::platform::env::no_auto_install() + && mcpp::platform::is_windows; + if (!mayRepair) { + return std::unexpected(msvc_unavailable_guidance(*tc)); + } + // mcpp chose this default itself and it cannot work on this machine. + // Revise it — including for users who already have `llvm@20.1.7` + // persisted by an older mcpp: the first-run branch never fires again + // for them, so this gate (which runs on EVERY build) is what repairs + // them without a single manual command. + namespace pins = mcpp::toolchain::triple::pins; + mcpp::ui::info("Toolchain", + std::format("{} targets the MSVC ABI but no Visual Studio " + "(MSVC STL + Windows SDK) was found — switching to {} → {}", + tcSpec.value_or("the configured default"), + pins::kFirstRunWinGnu, pins::kFirstRunWinGnuTarget)); + + overrides.target_triple = std::string(pins::kFirstRunWinGnuTarget); + // The x86_64-windows-gnu row is defaultStatic; the target block that + // normally applies that already ran, so mirror just this one field. + if (m->buildConfig.linkage.empty()) m->buildConfig.linkage = "static"; + + auto gnuSpec = mcpp::toolchain::parse_toolchain_spec( + std::string(pins::kFirstRunWinGnu)); + if (!gnuSpec) return std::unexpected(gnuSpec.error()); + if (auto t = mcpp::toolchain::triple::parse(overrides.target_triple)) + gnuSpec->target = *t; + auto gnuPkg = mcpp::toolchain::to_xim_package(*gnuSpec); + + auto cfgR = get_cfg(); + if (!cfgR) return std::unexpected(cfgR.error()); + mcpp::fetcher::Fetcher fetcherR(**cfgR); + mcpp::fetcher::InstallProgressHandler progressR; + auto payloadR = fetcherR.resolve_xpkg_path(gnuPkg.target(), + /*autoInstall=*/true, &progressR); + if (!payloadR) { + return std::unexpected(std::format( + "switching to the MinGW-w64 toolchain ({}) failed: {}\n" + " install it manually with:\n" + " mcpp toolchain install {} --target {}", + pins::kFirstRunWinGnu, payloadR.error().message, + pins::kSuggestGccMingw, pins::kFirstRunWinGnuTarget)); + } + explicit_compiler = + mcpp::toolchain::toolchain_frontend(payloadR->binDir, gnuPkg); + if (!std::filesystem::exists(explicit_compiler)) { + return std::unexpected(std::format( + "MinGW-w64 payload {} has no known C++ frontend in {}", + gnuPkg.target(), payloadR->binDir.string())); + } + mcpp::toolchain::ensure_post_install_fixup(**cfgR, payloadR->root, gnuPkg); + + // Persist both axes so the repair happens once, not on every build. + if (mcpp::config::write_default_toolchain(**cfgR, pins::kFirstRunWinGnu)) + (*cfgR)->defaultToolchain = std::string(pins::kFirstRunWinGnu); + if (mcpp::config::write_default_target(**cfgR, overrides.target_triple)) + (*cfgR)->defaultTarget = overrides.target_triple; + + tcSpec = std::string(pins::kFirstRunWinGnu); + tcOrigin = TcOrigin::FirstRun; + tc = mcpp::toolchain::detect(explicit_compiler); + if (!tc) return std::unexpected(tc.error().message); } // For musl-gcc the toolchain is fully self-contained diff --git a/src/toolchain/msvc.cppm b/src/toolchain/msvc.cppm index aadf27e8..5149a9da 100644 --- a/src/toolchain/msvc.cppm +++ b/src/toolchain/msvc.cppm @@ -101,6 +101,19 @@ struct WindowsSdk { // Locate the Windows 10/11 SDK (highest version with ucrt headers). std::optional find_windows_sdk(); +// True only when BOTH halves of a usable MSVC C++ setup are present: the +// STL's std module source AND the Windows SDK. +// +// Either half alone is a half-installed state — Visual Studio with only the +// .NET workload, or VC tools without the SDK — that a cheaper +// `find_vs_install_path()` probe would happily call "MSVC is here", only for +// the build to fail later inside the compiler. Selecting a toolchain on a +// weaker signal than the one the build actually needs is the bug this +// predicate exists to prevent, so it deliberately asks for both. +// +// Always false off Windows: the whole discovery chain is Win32-only. +bool has_usable_msvc(); + // Synthesize the environment cl.exe/link.exe need — what vcvars would set, // derived directly from the located VC tools + SDK (no vcvarsall.bat run): // INCLUDE = \include; \Include\\{ucrt,um,shared,winrt} @@ -434,6 +447,17 @@ std::optional find_windows_sdk() { return std::nullopt; } +bool has_usable_msvc() { +#if defined(_WIN32) + // Both, deliberately — see the declaration for why either half alone is + // a trap. Order matters only for cost: the STL probe short-circuits the + // SDK directory scan on machines with no Visual Studio at all. + return find_std_module_source().has_value() && find_windows_sdk().has_value(); +#else + return false; +#endif +} + std::vector build_env_for_cl(const std::filesystem::path& clPath, std::string_view arch, const WindowsSdk& sdk) { diff --git a/src/toolchain/triple.cppm b/src/toolchain/triple.cppm index 3e91d32c..a84c7d12 100644 --- a/src/toolchain/triple.cppm +++ b/src/toolchain/triple.cppm @@ -145,12 +145,32 @@ inline Triple host_triple() { // README platform table (drawn from kKnownTargets above). namespace pins { // First-run auto-install defaults (prepare.cppm), per host platform/arch. - inline constexpr std::string_view kFirstRunMacWin = "llvm@20.1.7"; - inline constexpr std::string_view kFirstRunLinuxX86_64 = "gcc@16.1.0"; - inline constexpr std::string_view kFirstRunLinuxOther = "gcc@15.1.0-musl"; + // + // macOS and Windows shared ONE pin until 2026.8.2.1. They must not: + // Apple ships no GCC, so upstream LLVM with bundled libc++ is the only + // self-contained choice there — but on Windows clang targets the MSVC + // ABI (host triple env=msvc) and therefore uses the MSVC STL, which only + // arrives with Visual Studio's "Desktop development with C++" workload. + // A bare Windows box got a default it could never build with, and no + // diagnostic. The Windows pin is now chosen by detection, not by + // sharing macOS's answer. + inline constexpr std::string_view kFirstRunMac = "llvm@20.1.7"; + // Windows WITH a usable MSVC (STL + SDK, see msvc::has_usable_msvc()): + // unchanged behavior. The MSVC ABI is what lets a project link vcpkg / + // third-party .lib artifacts, so it stays the answer when it can work. + inline constexpr std::string_view kFirstRunWinMsvc = "llvm@20.1.7"; + // Windows WITHOUT one: winlibs GCC targeting PE/GNU. Fully self-contained + // (static libstdc++/libgcc, its own UCRT), zero Visual Studio dependency, + // `import std` works. Must stay equal to the x86_64-windows-gnu row's + // `pin` in kKnownTargets above — test_windows_defaults.cpp enforces it. + inline constexpr std::string_view kFirstRunWinGnu = "gcc@16.1.0"; + inline constexpr std::string_view kFirstRunWinGnuTarget = "x86_64-windows-gnu"; + inline constexpr std::string_view kFirstRunLinuxX86_64 = "gcc@16.1.0"; + inline constexpr std::string_view kFirstRunLinuxOther = "gcc@15.1.0-musl"; // Suggested install spellings used by help / MCPP_NO_AUTO_INSTALL errors. - inline constexpr std::string_view kSuggestLlvm = "llvm 20.1.7"; - inline constexpr std::string_view kSuggestGccMusl = "gcc 15.1.0-musl"; + inline constexpr std::string_view kSuggestLlvm = "llvm 20.1.7"; + inline constexpr std::string_view kSuggestGccMusl = "gcc 15.1.0-musl"; + inline constexpr std::string_view kSuggestGccMingw = "gcc 16.1.0"; } // namespace pins } // namespace mcpp::toolchain::triple diff --git a/tests/unit/test_windows_defaults.cpp b/tests/unit/test_windows_defaults.cpp new file mode 100644 index 00000000..e5316a85 --- /dev/null +++ b/tests/unit/test_windows_defaults.cpp @@ -0,0 +1,90 @@ +#include + +import std; +import mcpp.platform; +import mcpp.toolchain.msvc; +import mcpp.toolchain.triple; +import mcpp.toolchain.registry; +import mcpp.build.prepare; + +// ── has_usable_msvc() ─────────────────────────────────────────────────────── +// +// The predicate that decides whether the MSVC ABI is a viable default on this +// machine. A bare Windows box has the UCRT runtime DLLs but neither the MSVC +// STL (Visual Studio's "Desktop development with C++" workload) nor the +// Windows SDK, so a default that targets the MSVC ABI there can never build. + +TEST(WindowsDefaults, HasUsableMsvcIsFalseOffWindows) { + if constexpr (mcpp::platform::is_windows) { + GTEST_SKIP() << "windows behavior is covered by e2e 182"; + } else { + EXPECT_FALSE(mcpp::toolchain::msvc::has_usable_msvc()); + } +} + +// The predicate must never disagree with the two probes it is defined as. +// A drift here (e.g. someone "optimizing" it down to find_vs_install_path()) +// silently reintroduces the half-installed-VS trap it exists to close. +TEST(WindowsDefaults, HasUsableMsvcAgreesWithItsParts) { + const bool both = mcpp::toolchain::msvc::find_std_module_source().has_value() + && mcpp::toolchain::msvc::find_windows_sdk().has_value(); + EXPECT_EQ(mcpp::toolchain::msvc::has_usable_msvc(), both); +} + +// ── First-run pins ────────────────────────────────────────────────────────── +// +// macOS and Windows shared one pin until 2026.8.2.1. They must not: Apple +// ships no GCC, while on Windows clang targets the MSVC ABI and therefore +// needs a Visual Studio that is not preinstalled. + +TEST(WindowsDefaults, FirstRunPinsParse) { + namespace pins = mcpp::toolchain::triple::pins; + for (auto spec : { pins::kFirstRunMac, pins::kFirstRunWinMsvc, + pins::kFirstRunWinGnu, pins::kFirstRunLinuxX86_64, + pins::kFirstRunLinuxOther }) { + auto parsed = mcpp::toolchain::parse_toolchain_spec(std::string(spec)); + ASSERT_TRUE(parsed.has_value()) << spec; + EXPECT_FALSE(parsed->version.empty()) << spec; + } +} + +// The GNU fallback must land on a target mcpp is willing to build for, and +// the toolchain it names must equal that target's vocabulary pin — otherwise +// the same decision is derived in two places and they will drift. +TEST(WindowsDefaults, GnuFallbackTargetIsVerifiedAndPinAgrees) { + namespace triple = mcpp::toolchain::triple; + auto t = triple::parse(std::string(triple::pins::kFirstRunWinGnuTarget)); + ASSERT_TRUE(t.has_value()); + const auto* known = triple::find_known_target(*t); + ASSERT_NE(known, nullptr) << triple::pins::kFirstRunWinGnuTarget; + EXPECT_EQ(known->tier, "verified"); + EXPECT_EQ(known->pin, triple::pins::kFirstRunWinGnu); +} + +// ── Intent classification ─────────────────────────────────────────────────── +// +// This table decides who may be overruled. Getting it wrong fails in both +// directions: call an explicit choice "mcpp's own" and a project that needs +// the MSVC ABI silently gets a different one; call mcpp's own default +// "explicit" and every user carrying a stale persisted default stays stuck. +TEST(WindowsDefaults, OriginClassification) { + using mcpp::build::TcOrigin; + using mcpp::build::tc_origin_is_user_explicit; + EXPECT_TRUE (tc_origin_is_user_explicit(TcOrigin::ManifestToolchain)); + EXPECT_TRUE (tc_origin_is_user_explicit(TcOrigin::TargetSection)); + EXPECT_FALSE(tc_origin_is_user_explicit(TcOrigin::GlobalDefault)); + EXPECT_FALSE(tc_origin_is_user_explicit(TcOrigin::TargetPin)); + EXPECT_FALSE(tc_origin_is_user_explicit(TcOrigin::FirstRun)); + EXPECT_FALSE(tc_origin_is_user_explicit(TcOrigin::None)); +} + +// The fallback target must be PE/GNU, not the host's MSVC-ABI triple — the +// whole point is to leave the ABI that needs Visual Studio behind. +TEST(WindowsDefaults, GnuFallbackTargetIsWindowsGnu) { + namespace triple = mcpp::toolchain::triple; + auto t = triple::parse(std::string(triple::pins::kFirstRunWinGnuTarget)); + ASSERT_TRUE(t.has_value()); + EXPECT_EQ(t->os, "windows"); + EXPECT_EQ(t->env, "gnu"); + EXPECT_TRUE(t->is_windows_gnu()); +} From c29bec20d9556a77572ff6a0fbf09af045e20022 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sun, 2 Aug 2026 01:42:11 +0800 Subject: [PATCH 03/14] fix(build,platform): paths with spaces survive both include channels and cmd.exe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent places assumed no path ever contains a space — which on Windows means assuming nobody installs under C:\Program Files and no user name has a space in it. include dirs (#331): the same manifest include_dirs reaches the compiler through the global blob in flags.cppm and through ninja_backend's per-TU $local_includes. Only the first shell-quoted; the second applied ninja's $ escaping alone, so ninja un-escaped the space and handed the shell a word that split. Both now go through mcpp::build::include_token — a shared helper rather than a second copy of the quoting, because a third channel is exactly how this happened. That also fixes the plain half of local_include_flags hardcoding -I while its after-dirs half honoured the dialect. cmd.exe: argv[0] was emitted raw to survive cmd's /c quote stripping, which traded 'the path gets mangled' for 'the path is cut at the first space'. Give cmd the outer quote pair it insists on consuming and the inner quoting arrives intact, so every token can be quoted. run_exec keeps inheriting stdio — sealing its stdin would break interactive `mcpp run`. The Windows command-line shape is now built by host-independent functions so Linux CI exercises it; that branch is not even compiled on the platforms mcpp is developed on, which is how the unquoted argv[0] survived this long. --- src/build/flags.cppm | 40 ++++++++- src/build/ninja_backend.cppm | 28 ++++-- src/platform/process.cppm | 104 ++++++++++++++++++++--- src/platform/shell.cppm | 26 +++++- tests/unit/test_ninja_backend.cpp | 37 +++++++- tests/unit/test_windows_command_line.cpp | 75 ++++++++++++++++ 6 files changed, 281 insertions(+), 29 deletions(-) create mode 100644 tests/unit/test_windows_command_line.cpp diff --git a/src/build/flags.cppm b/src/build/flags.cppm index f4625a2c..3a584194 100644 --- a/src/build/flags.cppm +++ b/src/build/flags.cppm @@ -82,6 +82,26 @@ std::string atomic_link_flag(const std::vector& linkDirs, // escaped as `\"`) — cmd.exe/CreateProcess argv convention. std::string shell_quote_arg(std::string_view arg); +// One include-directory token, fully prepared for a ninja command line: +// dialect prefix, ninja `$` escaping, and shell quoting — in that order. +// +// #331: the same manifest `[build] include_dirs` reaches the compiler through +// two channels — the global blob assembled below, and the per-translation-unit +// `$local_includes` emitted by ninja_backend. Only the first one quoted, so an +// include dir containing a space (`C:\Program Files\...`, or `/home/my dir` on +// Linux) survived one path and split into separate shell words on the other. +// Both channels call this now; adding a third one and forgetting to quote is +// how the bug happened, and a shared helper is the only fix that also covers +// the fourth. +// +// `prefixOverride` replaces `d.includePrefix` for the callers that need a +// different flag for the same kind of path (`-idirafter` for #249's +// after-dirs, plain `-I` for NASM units which would parse `-idirafter

` as +// `-i dirafter

`). +std::string include_token(const mcpp::toolchain::CommandDialect& d, + const std::filesystem::path& dir, + std::string_view prefixOverride = {}); + } // namespace mcpp::build namespace mcpp::build { @@ -139,6 +159,19 @@ std::string atomic_link_flag(const std::vector& linkDirs, return {}; } +std::string include_token(const mcpp::toolchain::CommandDialect& d, + const std::filesystem::path& dir, + std::string_view prefixOverride) { + std::string_view prefix = + prefixOverride.empty() ? d.includePrefix : prefixOverride; + // Prefix first, then escape+quote the whole token: the prefix and the + // path are ONE argv word, so quoting them separately would put the + // opening quote in the wrong place and re-split exactly what we came to + // join. + return shell_quote_arg( + escape_path(std::filesystem::path(std::string(prefix) + dir.string()))); +} + std::string shell_quote_arg(std::string_view arg) { // Characters that split/alter a word when unquoted in POSIX sh or // cmd.exe: whitespace plus the common shell metacharacters. Anything @@ -222,7 +255,7 @@ CompileFlags compute_flags(const BuildPlan& plan) { std::vector includeTokens; for (auto& inc : plan.manifest.buildConfig.includeDirs) { std::filesystem::path p = inc.has_root_path() ? inc : (plan.projectRoot / inc); - includeTokens.push_back(std::string(d.includePrefix) + p.string()); + includeTokens.push_back(include_token(d, p)); } // #249: `[build] include_dirs_after` — searched AFTER the toolchain's // system dirs via -idirafter (gcc+clang), so entries can't shadow @@ -233,12 +266,13 @@ CompileFlags compute_flags(const BuildPlan& plan) { for (auto& inc : plan.manifest.buildConfig.includeDirsAfter) { std::filesystem::path ip(inc); std::filesystem::path p = ip.has_root_path() ? ip : (plan.projectRoot / ip); - includeTokens.push_back((msvcInclude ? "/I" : "-idirafter") + p.string()); + includeTokens.push_back( + include_token(d, p, msvcInclude ? "/I" : "-idirafter")); } std::string include_flags; for (auto& t : includeTokens) { include_flags += ' '; - include_flags += shell_quote_arg(escape_path(std::filesystem::path(t))); + include_flags += t; // already prefixed, escaped and quoted } // Sysroot / payload paths — resolved ONCE by the toolchain link model diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index 169e3308..3af3f7be 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -105,12 +105,20 @@ bool is_nasm_source(const std::filesystem::path& src) { return src.extension() == ".asm"; } -std::string local_include_flags(const CompileUnit& cu, bool msvcDialect) { - const bool nasmUnit = is_nasm_source(cu.source); +std::string local_include_flags(const CompileUnit& cu, + const mcpp::toolchain::CommandDialect& d) { + const bool nasmUnit = is_nasm_source(cu.source); + const bool msvcDialect = d.includePrefix == std::string_view("/I"); std::string flags; for (auto const& inc : cu.localIncludeDirs) { - flags += " -I"; - flags += escape_flag_path(inc); + // #331: this used to hardcode `-I` and apply only ninja's `$` + // escaping — no shell quoting — while the global channel in + // flags.cppm quoted properly. Same manifest include_dirs, two + // derivations, and a directory with a space in it split into + // separate shell words on this path only. Both channels now go + // through mcpp::build::include_token. + flags += ' '; + flags += mcpp::build::include_token(d, inc); } // #249: after-dirs are searched AFTER the toolchain's system dirs // (-idirafter, gcc+clang), so a dep source root that contains a file @@ -126,8 +134,10 @@ std::string local_include_flags(const CompileUnit& cu, bool msvcDialect) { // `-idirafter

` as its `-i` option with value `dirafter

` — // a silently wrong search dir — so nasm units get plain -I. for (auto const& inc : cu.localIncludeDirsAfter) { - flags += nasmUnit ? " -I" : (msvcDialect ? " /I" : " -idirafter"); - flags += escape_flag_path(inc); + std::string_view pfx = + nasmUnit ? "-I" : (msvcDialect ? "/I" : "-idirafter"); + flags += ' '; + flags += mcpp::build::include_token(d, inc, pfx); } return flags; } @@ -994,7 +1004,7 @@ std::string emit_ninja_string(const BuildPlan& plan) { append(std::format("build {} : cxx_scan {}{}\n", escape_ninja_path(ddi), escape_ninja_path(cu.source), stagedOrderOnly)); append(std::format(" compile_target = {}\n", escape_ninja_path(cu.object))); - if (auto includes = local_include_flags(cu, msvcDeps); !includes.empty()) + if (auto includes = local_include_flags(cu, dial); !includes.empty()) append(std::format(" local_includes ={}\n", includes)); if (auto flags = join_flags(cu.packageCxxflags); !flags.empty()) append(std::format(" unit_cxxflags ={}\n", flags)); @@ -1076,7 +1086,7 @@ std::string emit_ninja_string(const BuildPlan& plan) { } else { out_line += stagedOrderOnly + "\n"; } - if (auto includes = local_include_flags(cu, msvcDeps); !includes.empty()) + if (auto includes = local_include_flags(cu, dial); !includes.empty()) out_line += " local_includes =" + includes + "\n"; if (is_gas_source(cu.source) || is_nasm_source(cu.source)) { if (auto flags = join_flags(asm_unit_flags(cu)); !flags.empty()) @@ -1128,7 +1138,7 @@ std::string emit_ninja_string(const BuildPlan& plan) { out_line += " |" + implicit; out_line += stagedOrderOnly; out_line += "\n"; - if (auto includes = local_include_flags(cu, msvcDeps); !includes.empty()) + if (auto includes = local_include_flags(cu, dial); !includes.empty()) out_line += " local_includes =" + includes + "\n"; if (is_gas_source(cu.source) || is_nasm_source(cu.source)) { if (auto flags = join_flags(asm_unit_flags(cu)); !flags.empty()) diff --git a/src/platform/process.cppm b/src/platform/process.cppm index 48fd420c..01446e8e 100644 --- a/src/platform/process.cppm +++ b/src/platform/process.cppm @@ -128,12 +128,48 @@ int run_passthrough(std::string_view command, // a wait-status word requiring WIFEXITED/WEXITSTATUS unwrapping. int extract_exit_code(int raw_status); +// ─── Windows command-line shaping (host-independent, for testing) ───────── +// +// `cmd.exe /c ` applies a quote rule that silently mangles most +// command lines (`cmd /?`, /C section): unless the whole string is exactly +// one quoted executable name, cmd removes the FIRST character and the LAST +// quote character, then runs the remainder. A correctly quoted line like +// +// "C:\Program Files\gcc\g++.exe" -c "main.cpp" +// +// therefore arrives as +// +// C:\Program Files\gcc\g++.exe" -c "main.cpp +// +// The fix is to hand cmd an outer pair to consume. These two functions build +// exactly that shape and are compiled on every platform so the rule can be +// unit-tested from Linux/macOS — the Windows branch below is otherwise +// unreachable in every environment mcpp is normally developed on, which is +// how the unquoted-argv[0] bug survived. +std::string windows_command_from_argv(const std::vector& argv); +std::string windows_wrap_for_cmd_c(std::string_view cmd); + } // namespace mcpp::platform::process // ─── Implementation ────────────────────────────────────────────────────── namespace mcpp::platform::process { +// Host-independent (see the declarations): always the Windows shape. +std::string windows_command_from_argv(const std::vector& argv) { + if (argv.empty()) return ""; + std::string cmd = mcpp::platform::shell::quote_windows(argv[0]); + for (std::size_t i = 1; i < argv.size(); ++i) { + cmd += ' '; + cmd += mcpp::platform::shell::quote_windows(argv[i]); + } + return cmd; +} + +std::string windows_wrap_for_cmd_c(std::string_view cmd) { + return "\"" + std::string(cmd) + "\""; +} + namespace { // Append a non-interactive stdin redirect to prevent child processes from @@ -151,6 +187,44 @@ std::string seal_stdin(std::string_view cmd) { #endif } +// Everything that reaches _popen / std::system on Windows is run by +// `cmd.exe /c `, and cmd applies a quote rule that mangles any +// command line carrying more than one pair of quotes (`cmd /?`, the /C +// section): unless the whole string is exactly one quoted executable name, +// cmd strips the FIRST character and the LAST quote character and runs what +// is left. So +// +// "C:\Program Files\gcc\g++.exe" -c "main.cpp" +// +// becomes +// +// C:\Program Files\gcc\g++.exe" -c "main.cpp +// +// which is why command_from_argv used to leave argv[0] unquoted — the +// program path then survived, at the cost of breaking as soon as it +// contained a space, which every default install path does +// (`C:\Program Files\...`, or any user whose account name has a space). +// +// The documented fix is to give cmd an outer pair to eat, so the inner +// quoting arrives intact. Applied at the single point where a command +// string becomes a child process, so no caller has to remember it, and the +// redirects appended by seal_stdin / silent_redirect stay inside the wrap +// where cmd still parses them after stripping. +std::string wrap_for_cmd_c(std::string_view cmd) { +#if defined(_WIN32) + return windows_wrap_for_cmd_c(cmd); +#else + return std::string(cmd); +#endif +} + +// Seal stdin AND wrap. Kept separate from wrap_for_cmd_c because run_exec +// deliberately inherits stdio — `mcpp run` hands the terminal to the program +// being run, and sealing its stdin would break every interactive one. +std::string finalize_shell_command(std::string_view cmd) { + return wrap_for_cmd_c(seal_stdin(cmd)); +} + int normalize_exit_code(int rc) { #if defined(_WIN32) return rc; @@ -227,22 +301,27 @@ std::string spawn_failure(std::string_view program, int error) { } #else // Build a shell command line from an argv vector (Windows + residual non-POSIX -// fallback only; Linux/macOS exec directly, #248). The first token (program) -// is kept RAW on Windows — quoting it would make cmd.exe's `/c "..."` strip the -// outer quotes and mangle the path (see platform.shell) — and shell-quoted -// otherwise. Remaining args are always shell-quoted. +// fallback only; Linux/macOS exec directly, #248). EVERY token is shell-quoted, +// including the program — a payload under `C:\Program Files\...` or a home +// directory with a space in the user name is otherwise cut at the first space +// and reported as `'C:\Program' is not recognized`. +// +// argv[0] used to be left raw here to survive cmd.exe's /c quote stripping. +// That traded one bug for another; finalize_shell_command now feeds cmd the +// outer quote pair it insists on eating, so the quoting below arrives intact. std::string command_from_argv(const std::vector& argv) { - if (argv.empty()) return ""; #if defined(_WIN32) - std::string cmd = argv[0]; + // One derivation: the tested, host-independent shaper above. + return windows_command_from_argv(argv); #else + if (argv.empty()) return ""; std::string cmd = mcpp::platform::shell::quote(argv[0]); -#endif for (std::size_t i = 1; i < argv.size(); ++i) { cmd += ' '; cmd += mcpp::platform::shell::quote(argv[i]); } return cmd; +#endif } #endif @@ -253,7 +332,7 @@ int extract_exit_code(int raw_status) { } RunResult capture(std::string_view command) { - auto cmd = seal_stdin(command); + auto cmd = finalize_shell_command(command); RunResult result; std::FILE* fp = ::popen(cmd.c_str(), "r"); @@ -306,14 +385,14 @@ RunResult capture_with_env( } int run_silent(std::string_view command) { - auto cmd = seal_stdin(command); + auto cmd = finalize_shell_command(command); return normalize_exit_code(std::system(cmd.c_str())); } int run_streaming(std::string_view command, std::function on_line) { - auto cmd = seal_stdin(command); + auto cmd = finalize_shell_command(command); std::FILE* fp = ::popen(cmd.c_str(), "r"); if (!fp) return -1; @@ -342,7 +421,7 @@ int run_streaming(std::string_view command, } int run_passthrough(std::string_view command, std::string* output) { - auto cmd = seal_stdin(command); + auto cmd = finalize_shell_command(command); std::FILE* fp = ::popen(cmd.c_str(), "r"); if (!fp) return -1; @@ -397,7 +476,8 @@ int run_exec(const std::vector& argv, return normalize_exit_code(status); #else std::string prefix = mcpp::platform::env::build_env_prefix(extraEnv); - std::string cmd = prefix + command_from_argv(argv); + // wrap only — run_exec inherits stdio on purpose (see finalize_shell_command). + std::string cmd = wrap_for_cmd_c(prefix + command_from_argv(argv)); return normalize_exit_code(std::system(cmd.c_str())); #endif } diff --git a/src/platform/shell.cppm b/src/platform/shell.cppm index 7c30bbf2..e37bc9e9 100644 --- a/src/platform/shell.cppm +++ b/src/platform/shell.cppm @@ -20,6 +20,13 @@ export namespace mcpp::platform::shell { // Platform-aware shell argument quoting. std::string quote(std::string_view s); +// The two halves of `quote`, callable regardless of host. Exposed so the +// Windows command-line shape can be built and unit-tested from any platform +// — the cmd.exe quoting rules are the easiest thing in mcpp to get wrong and +// the hardest to notice, since a Linux/macOS run never executes that code. +std::string quote_windows(std::string_view s); +std::string quote_posix(std::string_view s); + // Silent redirect — stdout + stderr → /dev/null (or NUL on Windows). // stdin is NOT touched here; that's the responsibility of // mcpp::platform::process::seal_stdin, which is auto-applied by capture / @@ -36,25 +43,36 @@ constexpr std::string_view silent_redirect = ">/dev/null 2>&1"; namespace mcpp::platform::shell { -std::string quote(std::string_view s) { +std::string quote_windows(std::string_view s) { std::string out; out.reserve(s.size() + 2); -#if defined(_WIN32) out.push_back('"'); for (char c : s) { if (c == '"') out += "\\\""; else out.push_back(c); } out.push_back('"'); -#else + return out; +} + +std::string quote_posix(std::string_view s) { + std::string out; + out.reserve(s.size() + 2); out.push_back('\''); for (char c : s) { if (c == '\'') out += "'\\''"; else out.push_back(c); } out.push_back('\''); -#endif return out; } +std::string quote(std::string_view s) { +#if defined(_WIN32) + return quote_windows(s); +#else + return quote_posix(s); +#endif +} + } // namespace mcpp::platform::shell diff --git a/tests/unit/test_ninja_backend.cpp b/tests/unit/test_ninja_backend.cpp index 7afea22d..2b861f7c 100644 --- a/tests/unit/test_ninja_backend.cpp +++ b/tests/unit/test_ninja_backend.cpp @@ -226,12 +226,47 @@ TEST(NinjaBackend, MsvcDialectEmitsIncludeDirsAfterAsTrailingSlashI) { auto line_end = ninja.find('\n', line_start); auto line = ninja.substr(line_start, line_end - line_start); - auto i_pos = line.find("-I/dep/include"); + // Both halves take the dialect's prefix. The plain half used to hardcode + // `-I` even here — cl.exe accepts it, so it never broke anything, but it + // meant local_include_flags derived the prefix twice and only agreed with + // the dialect on one of them. Converging both on include_token fixed it. + auto i_pos = line.find("/I/dep/include"); auto after_pos = line.find("/I/dep/tarball-root"); ASSERT_NE(i_pos, std::string::npos) << line; ASSERT_NE(after_pos, std::string::npos) << line; EXPECT_LT(i_pos, after_pos) << line; EXPECT_EQ(line.find("-idirafter"), std::string::npos) << line; + EXPECT_EQ(line.find("-I/dep"), std::string::npos) << line; +} + +// #331: the per-TU include channel applied only ninja's `$` escaping, while +// the global channel in flags.cppm shell-quoted. Same manifest include_dirs, +// two derivations — so a directory with a space in it survived one path and +// split into separate shell words on the other, which is what every Windows +// user hits the moment a dependency lands under `C:\Program Files`. +TEST(NinjaBackend, LocalIncludeDirsWithSpacesAreShellQuoted) { + auto plan = minimal_plan(); + plan.compileUnits.push_back({ + .source = "src/main.cpp", + .object = "obj/main.o", + .packageName = "spaced", + .localIncludeDirs = {"/opt/my dep/include"}, + .localIncludeDirsAfter = {"/opt/my dep/after"}, + }); + + auto ninja = emit_ninja_string(plan); + auto line_start = ninja.find("local_includes ="); + ASSERT_NE(line_start, std::string::npos) << ninja; + auto line = ninja.substr(line_start, ninja.find('\n', line_start) - line_start); + + // Two escaping layers, in order: ninja's (`$ ` for a literal space, so + // ninja does not treat it as a field separator) and then the shell's + // (quotes, so what ninja hands to sh stays one word). The old code had + // only the first, which is why the path survived ninja and then split in + // the shell. The prefix must be INSIDE the quotes — quoting the path + // alone would leave `-I` as its own word and reintroduce the split. + EXPECT_NE(line.find("'-I/opt/my$ dep/include'"), std::string::npos) << line; + EXPECT_NE(line.find("'-idirafter/opt/my$ dep/after'"), std::string::npos) << line; } // #249 NASM degradation: nasm_object edges share $local_includes, but NASM diff --git a/tests/unit/test_windows_command_line.cpp b/tests/unit/test_windows_command_line.cpp new file mode 100644 index 00000000..191acf95 --- /dev/null +++ b/tests/unit/test_windows_command_line.cpp @@ -0,0 +1,75 @@ +#include + +import std; +import mcpp.platform.process; +import mcpp.platform.shell; + +// The cmd.exe quoting rules are the easiest thing in mcpp to get wrong and +// the hardest to notice: on Linux and macOS the Windows branch is not even +// compiled, so nothing here is exercised by an ordinary local run. These +// tests drive the host-independent shapers directly, so a regression fails +// on every platform instead of only on a Windows runner. + +namespace proc = mcpp::platform::process; + +// #331: argv[0] used to be emitted RAW to survive cmd.exe's /c stripping. +// That made every payload under `C:\Program Files\...` — and every machine +// whose user name has a space — fail with `'C:\Program' is not recognized`. +TEST(WindowsCommandLine, ProgramPathWithSpacesIsQuoted) { + auto cmd = proc::windows_command_from_argv( + {"C:\\Program Files\\mcpp\\g++.exe", "-c", "main.cpp"}); + EXPECT_TRUE(cmd.starts_with("\"C:\\Program Files\\mcpp\\g++.exe\"")) << cmd; +} + +TEST(WindowsCommandLine, EveryArgumentIsQuoted) { + auto cmd = proc::windows_command_from_argv( + {"g++.exe", "-I", "C:\\my dir\\inc", "src\\main.cpp"}); + EXPECT_NE(cmd.find("\"C:\\my dir\\inc\""), std::string::npos) << cmd; + EXPECT_NE(cmd.find("\"src\\main.cpp\""), std::string::npos) << cmd; +} + +TEST(WindowsCommandLine, EmptyArgvIsEmpty) { + EXPECT_EQ(proc::windows_command_from_argv({}), ""); +} + +// The outer pair is what cmd.exe consumes under its "strip the first +// character and the last quote character" rule, so the inner quoting is +// what actually reaches the program. Without it, quoting argv[0] makes +// things worse rather than better. +TEST(WindowsCommandLine, WrapAddsTheOuterPairCmdConsumes) { + auto inner = proc::windows_command_from_argv( + {"C:\\Program Files\\mcpp\\g++.exe", "-c", "main.cpp"}); + auto wrapped = proc::windows_wrap_for_cmd_c(inner); + ASSERT_GE(wrapped.size(), inner.size() + 2); + EXPECT_EQ(wrapped.front(), '"'); + EXPECT_EQ(wrapped.back(), '"'); + EXPECT_EQ(wrapped.substr(1, wrapped.size() - 2), inner); + + // Simulate what cmd.exe does with `/c `: drop the first + // character and the last quote character. What remains must be exactly + // the command we meant to run. + auto stripped = wrapped.substr(1); + stripped.erase(stripped.rfind('"'), 1); + EXPECT_EQ(stripped, inner); +} + +// Redirects appended after the command must end up INSIDE the wrap, so cmd +// still parses them once it has stripped the outer pair. +TEST(WindowsCommandLine, RedirectStaysInsideTheWrap) { + auto wrapped = proc::windows_wrap_for_cmd_c( + proc::windows_command_from_argv({"prog.exe", "arg"}) + " Date: Sun, 2 Aug 2026 01:47:01 +0800 Subject: [PATCH 04/14] feat(build.mcpp): support import std; MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mcpp asks projects to import std everywhere and then made their build script fall back to #include — there was no std BMI channel in the build.mcpp compile at all, and the bundled mcpp module says so in its own header comment. The std module the main build already uses is reusable verbatim: stdmod::ensure_built caches on (toolchain x standard x dialect), so a native build is a cache HIT on the very artifact the project's own TUs import — zero extra work. Only a cross build pays for a second one, which is unavoidable because build.mcpp runs on the host. That host/target split is the load-bearing part: prepare.cppm already resolves a host toolchain for build.mcpp (deliberately without the --target axis) and passes it in, so ensure_built gets the right one. Feeding it the target's std would produce a helper that cannot execute, and silently so until exec time. e2e 112 now proves the helper RAN by the file it was asked to write, not by the compiler's exit code. Detection matches the whole module name up to its ';' — 'import std' is a prefix of 'import std.compat', so the naive substring test builds a BMI nobody asked for. e2e 181 covers import std alone, import std with import mcpp (they share the staged-BMI cwd, which an implementation treating them as two independent conditions gets wrong), and an #include-only program that merely mentions import std in a comment. --- src/build/build_program.cppm | 148 +++++++++++++++++++++++-- tests/e2e/112_build_mcpp_cross.sh | 30 +++++ tests/e2e/181_build_mcpp_import_std.sh | 110 ++++++++++++++++++ 3 files changed, 276 insertions(+), 12 deletions(-) create mode 100755 tests/e2e/181_build_mcpp_import_std.sh diff --git a/src/build/build_program.cppm b/src/build/build_program.cppm index 699dbbaf..a41c95d1 100644 --- a/src/build/build_program.cppm +++ b/src/build/build_program.cppm @@ -21,6 +21,7 @@ import mcpp.toolchain.fingerprint; // hash_file / hash_string (FNV-1a, 16 hex) import mcpp.toolchain.linkmodel; // shared C-library / clang-cfg-bypass model import mcpp.toolchain.model; // Toolchain, PayloadPaths, is_clang/is_musl_target/is_mingw_target import mcpp.toolchain.registry; // archive_tool +import mcpp.toolchain.stdmod; // ensure_built — the SAME std BMI the main build uses import mcpp.toolchain.triple; // host_triple (MCPP_HOST contract value) import mcpp.ui; @@ -243,10 +244,13 @@ std::vector host_base_flags(const mcpp::toolchain::Toolchain& tc) { return f; } -// The bundled `mcpp` build module — a typed API over the stdout wire protocol so -// build.mcpp can `import mcpp;` (no `#include`, no `import std;`). I/O uses -// C-level primitives in the global module fragment, so the module needs no std -// module BMI. The functions mirror the directive set 1:1; they just print the +// The bundled `mcpp` build module — a typed API over the stdout wire protocol +// so build.mcpp can `import mcpp;` instead of `#include`. Its own I/O uses +// C-level primitives in the global module fragment, so the module itself +// needs no std BMI and stays buildable before one exists. (That was once also +// a limit on build.mcpp; it no longer is — a build.mcpp may `import std;` and +// the engine stages the same std module the main build uses.) +// The functions mirror the directive set 1:1; they just print the // `mcpp:` lines the engine already parses. Embedded in the binary (not shipped as // a file) so it always matches this mcpp's protocol. // NOTE: the module declaration line uses a `@MODULE@` placeholder (substituted @@ -311,6 +315,34 @@ inline const char* dep_dir(const char* name) { // GCC : -fmodules → gcm.cache/mcpp.gcm + mcpp.o; build.mcpp compiles from // `bdir` (cwd) so GCC finds gcm.cache/mcpp.gcm. // Clang : --precompile → mcpp.pcm, then -c → mcpp.o; pass -fmodule-file=mcpp=. +// Does the source contain `import ;`? +// +// A plain substring search is not enough here: "import std" is a prefix of +// "import std.compat", so the naive test reports both for a program that +// only imports the latter, and mcpp would build a std BMI nobody asked for. +// Match the whole module name and require the terminating `;`, tolerating +// the whitespace the grammar allows. Occurrences inside comments or string +// literals still match — over-detection costs one cached BMI lookup, never +// a wrong build, and that is the same trade the `import mcpp` check has +// always made. +bool imports_module(std::string_view src, std::string_view name) { + constexpr std::string_view kImport = "import"; + std::size_t pos = 0; + while ((pos = src.find(kImport, pos)) != std::string_view::npos) { + std::size_t i = pos + kImport.size(); + // `importfoo` is not an import. + if (i >= src.size() || (src[i] != ' ' && src[i] != '\t')) { ++pos; continue; } + while (i < src.size() && (src[i] == ' ' || src[i] == '\t')) ++i; + if (src.compare(i, name.size(), name) == 0) { + std::size_t j = i + name.size(); + while (j < src.size() && (src[j] == ' ' || src[j] == '\t')) ++j; + if (j < src.size() && src[j] == ';') return true; + } + ++pos; + } + return false; +} + std::expected, std::string> build_mcpp_module(const fs::path& bdir, const fs::path& compiler, const std::vector& base, const std::string& stdFlag, @@ -663,7 +695,9 @@ std::expected run_build_program( // finds gcm.cache/mcpp.gcm. std::string srcText; { std::ifstream is(src); std::ostringstream ss; ss << is.rdbuf(); srcText = ss.str(); } - bool usesModule = srcText.find("import mcpp") != std::string::npos; + bool usesModule = srcText.find("import mcpp") != std::string::npos; + bool usesStdCompat = imports_module(srcText, "std.compat"); + bool usesStd = usesStdCompat || imports_module(srcText, "std"); std::vector moduleFlags; if (usesModule) { @@ -673,18 +707,104 @@ std::expected run_build_program( moduleFlags = std::move(*mf); } + // ── `import std;` in build.mcpp ───────────────────────────────────────── + // + // mcpp asks projects to `import std;` everywhere and then made their build + // script fall back to `#include` — the bundled `mcpp` module even says so + // in its own header comment. The std module the main build already uses is + // reusable verbatim: stdmod::ensure_built caches on + // (toolchain × standard × dialect), so for a native build this is a cache + // HIT on the very artifact the project's own TUs import. Only a cross + // build pays for a second one, which is unavoidable — see below. + // + // `tc` here is the HOST toolchain: prepare.cppm's + // host_tc_for_build_program() resolves the spec WITHOUT the --target axis + // and hands it in. That is load-bearing. build.mcpp is compiled AND run on + // the machine doing the build, so a std BMI built for the target would + // produce a helper that cannot execute — the same host≠target mistake the + // mingw-cross work had to fix in four separate places. + std::vector stdFlags; + std::vector stdObjects; + // GCC finds staged BMIs by cwd; Clang/MSVC get an explicit path flag. + bool stdStagedInBdir = false; + if (usesStd) { + if (!tc.hasImportStd) { + return std::unexpected(std::format( + "build.mcpp uses `import std;` but the host toolchain ({}) " + "ships no std module.\n" + " Use #include in build.mcpp, or switch to a toolchain " + "that provides one.", tc.label())); + } + auto sm = mcpp::toolchain::ensure_built( + tc, cppStandard.canonical, std_flag, + mcpp::platform::macos::deployment_target( + m.buildConfig.macosDeploymentTarget)); + if (!sm) { + return std::unexpected(std::format( + "build.mcpp uses `import std;` but the std module could not be " + "built for the host toolchain: {}", sm.error().message)); + } + + auto traits = mcpp::toolchain::bmi_traits(tc); + if (traits.stdBmiUsePrefix.empty()) { + // GCC: BMIs are found implicitly under /gcm.cache, so stage + // the cached ones where the compile will look. Copy rather than + // symlink — this mirrors the main build's staging edge, and a + // stale copy is caught by ensure_built's own cache key. + std::error_code ec; + fs::path gcmDir = bdir / traits.bmiDir; + fs::create_directories(gcmDir, ec); + auto stage = [&](const fs::path& from, std::string_view name) + -> std::expected { + if (from.empty() || !fs::exists(from)) return {}; + fs::path to = gcmDir / std::format("{}{}", name, traits.bmiExt); + fs::copy_file(from, to, fs::copy_options::overwrite_existing, ec); + if (ec) return std::unexpected(std::format( + "staging {} for build.mcpp failed: {}", name, ec.message())); + return {}; + }; + if (auto r = stage(sm->bmiPath, "std"); !r) + return std::unexpected(r.error()); + if (usesStdCompat) { + if (auto r = stage(sm->compatBmiPath, "std.compat"); !r) + return std::unexpected(r.error()); + } + // -fmodules may already be present from the `mcpp` module path; + // GCC tolerates the repeat, but keep the argv honest. + if (!usesModule) stdFlags.push_back("-fmodules"); + stdStagedInBdir = true; + } else { + stdFlags.push_back(std::string(traits.stdBmiUsePrefix) + + sm->bmiPath.string()); + if (usesStdCompat && !sm->compatBmiPath.empty()) + stdFlags.push_back(std::string(traits.stdCompatBmiUsePrefix) + + sm->compatBmiPath.string()); + // The prefixes carry a leading space for the ninja string channel; + // an argv element must not. + for (auto& f : stdFlags) + if (!f.empty() && f.front() == ' ') f.erase(0, 1); + } + if (!sm->objectPath.empty() && fs::exists(sm->objectPath)) + stdObjects.push_back(sm->objectPath.string()); + if (usesStdCompat && !sm->compatObjectPath.empty() + && fs::exists(sm->compatObjectPath)) + stdObjects.push_back(sm->compatObjectPath.string()); + } + // `-x c++` is required: the `.mcpp` extension is unknown to the compiler, so // without it the driver hands build.mcpp to the linker as a linker script. std::vector compileArgv = { hostCompiler.string(), std_flag, "-O0" }; for (auto& bf : base) compileArgv.push_back(bf); for (auto& mf : moduleFlags) compileArgv.push_back(mf); + for (auto& sf : stdFlags) compileArgv.push_back(sf); compileArgv.push_back("-x"); compileArgv.push_back("c++"); compileArgv.push_back(src.string()); - if (usesModule) { - // Link the module object (reset the input language first so the .o isn't - // treated as C++ source). + if (usesModule || !stdObjects.empty()) { + // Link the module objects (reset the input language first so the .o + // isn't treated as C++ source). compileArgv.push_back("-x"); compileArgv.push_back("none"); - compileArgv.push_back((bdir / "mcpp.o").string()); + if (usesModule) compileArgv.push_back((bdir / "mcpp.o").string()); + for (auto& so : stdObjects) compileArgv.push_back(so); } // Self-contained helper link — see the staticHostHelper doctrine above. // Deliberately NOT in `base`: that also feeds the bundled module's @@ -693,9 +813,13 @@ std::expected run_build_program( if (staticHostHelper) compileArgv.push_back("-static"); compileArgv.push_back("-o"); compileArgv.push_back(bin.string()); mcpp::ui::info("build.mcpp", "compiling"); - // GCC resolves `import mcpp;` via gcm.cache/ relative to the compile cwd, so - // run the module-using compile from bdir; otherwise the project root is fine. - std::string compileCwd = usesModule ? bdir.string() : root.string(); + // GCC resolves imported BMIs via gcm.cache/ relative to the compile cwd, so + // any compile that imports a module — `mcpp`, `std`, or both — has to run + // from bdir, where they were staged. One condition, not two: a build.mcpp + // that imports only std needs exactly the same cwd as one that imports + // only mcpp. Otherwise the project root is fine. + const bool needsBmiCwd = usesModule || stdStagedInBdir; + std::string compileCwd = needsBmiCwd ? bdir.string() : root.string(); auto cres = mcpp::platform::process::capture_exec(compileArgv, {}, compileCwd); if (cres.exit_code != 0) { return std::unexpected(std::format( diff --git a/tests/e2e/112_build_mcpp_cross.sh b/tests/e2e/112_build_mcpp_cross.sh index c1bf3bd8..c99ec03e 100755 --- a/tests/e2e/112_build_mcpp_cross.sh +++ b/tests/e2e/112_build_mcpp_cross.sh @@ -60,4 +60,34 @@ if command -v wine &>/dev/null; then echo "unexpected wine output: $out"; exit 1; } fi +# ── host≠target for `import std;` in build.mcpp ──────────────────────────── +# The std module staged for a build.mcpp must be the HOST one. Feeding it the +# target's would produce a helper that cannot execute here, and the failure is +# silent until exec time — the same class of mistake the mingw-cross work had +# to fix in four separate places. A cross build is the only configuration +# where host and target BMIs differ, so this is the one place it can be +# caught. +cat > build.mcpp <<'EOF' +import std; +int main() { + std::ofstream f("src/cross_gen.cpp"); + f << "extern \"C\" const char* bp_target() { return \"" + << (std::getenv("MCPP_TARGET") ? std::getenv("MCPP_TARGET") : "") + << "\"; }\n"; + if (!f) return 1; + std::println("mcpp:generated=src/cross_gen.cpp"); + return 0; +} +EOF + +rm -f src/cross_gen.cpp +"$MCPP" build --target x86_64-windows-gnu > build-std.log 2>&1 || { + cat build-std.log; echo "cross build with import std in build.mcpp failed"; exit 1; } +# The helper actually RAN on the host — proven by the file it was asked to +# write, not by the compiler's exit code. +[[ -f src/cross_gen.cpp ]] || { + cat build-std.log; echo "import-std build.mcpp did not run on the host"; exit 1; } +grep -q 'x86_64-windows-gnu' src/cross_gen.cpp || { + cat src/cross_gen.cpp; echo "MCPP_TARGET wrong under import std"; exit 1; } + echo "OK" diff --git a/tests/e2e/181_build_mcpp_import_std.sh b/tests/e2e/181_build_mcpp_import_std.sh new file mode 100755 index 00000000..ee70ed5a --- /dev/null +++ b/tests/e2e/181_build_mcpp_import_std.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# requires: unix-shell +# 181_build_mcpp_import_std.sh — build.mcpp can `import std;` +# +# mcpp asks projects to `import std;` everywhere, then made their build script +# fall back to `#include` — there was no std BMI channel in the build.mcpp +# compile at all. This locks the gap shut. Cross-platform on purpose: +# `import std;` is not a Windows-specific concern, and running it on Linux +# gives the fastest feedback. +set -e + +TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT +cd "$TMP" +"$MCPP" new imp_std >/dev/null 2>&1 +cd imp_std + +# 1) `import std;` alone — the container/algorithm/format surface a real build +# script reaches for, none of which is available without the std module. +cat > build.mcpp <<'EOF' +import std; +int main() { + std::vector defines{"MCPP_FROM_IMPORT_STD", "MCPP_STD_COUNT_2"}; + std::ranges::sort(defines); + for (auto const& d : defines) std::println("mcpp:cfg={}", d); + std::println("mcpp:rerun-if-changed=build.mcpp"); + return 0; +} +EOF + +cat > src/main.cpp <<'EOF' +import std; +int main() { +#if defined(MCPP_FROM_IMPORT_STD) && defined(MCPP_STD_COUNT_2) + std::println("import-std-ok"); + return 0; +#else + std::println("defines missing"); + return 1; +#endif +} +EOF + +out=$("$MCPP" build 2>&1) || { echo "FAIL: build with import std: $out"; exit 1; } +run_out=$("$MCPP" run 2>&1) || { echo "FAIL: run: $run_out"; exit 1; } +[[ "$run_out" == *"import-std-ok"* ]] \ + || { echo "FAIL: run output: $run_out"; exit 1; } + +# 2) `import std;` together with `import mcpp;` — both module channels active +# at once. These share the staged-BMI cwd, and an implementation that +# handles them as two independent conditions gets the cwd wrong for one. +cat > build.mcpp <<'EOF' +import std; +import mcpp; +int main() { + std::string tag = std::format("MCPP_BOTH_{}", 1 + 1); + mcpp::define(tag.c_str()); + mcpp::rerun_if_changed("build.mcpp"); + return 0; +} +EOF + +cat > src/main.cpp <<'EOF' +import std; +int main() { +#ifdef MCPP_BOTH_2 + std::println("both-modules-ok"); + return 0; +#else + std::println("define missing"); + return 1; +#endif +} +EOF + +out=$("$MCPP" build 2>&1) || { echo "FAIL: build with import std + mcpp: $out"; exit 1; } +run_out=$("$MCPP" run 2>&1) || { echo "FAIL: run (both): $run_out"; exit 1; } +[[ "$run_out" == *"both-modules-ok"* ]] \ + || { echo "FAIL: run output (both): $run_out"; exit 1; } + +# 3) An `#include`-only build.mcpp must still take the plain path — no std BMI +# staged, no -fmodules, cwd = project root. Regression guard: the naive +# detector ("does the text contain 'import std'") would fire on a comment. +cat > build.mcpp <<'EOF' +#include +// This program deliberately mentions import std; in a comment. +int main() { + std::puts("mcpp:cfg=MCPP_PLAIN_PATH"); + std::puts("mcpp:rerun-if-changed=build.mcpp"); + return 0; +} +EOF + +cat > src/main.cpp <<'EOF' +import std; +int main() { +#ifdef MCPP_PLAIN_PATH + std::println("plain-path-ok"); + return 0; +#else + return 1; +#endif +} +EOF + +out=$("$MCPP" build 2>&1) || { echo "FAIL: build plain build.mcpp: $out"; exit 1; } +run_out=$("$MCPP" run 2>&1) || { echo "FAIL: run (plain): $run_out"; exit 1; } +[[ "$run_out" == *"plain-path-ok"* ]] \ + || { echo "FAIL: run output (plain): $run_out"; exit 1; } + +echo "PASS: build.mcpp import std (alone, with import mcpp, and the plain path)" From 25a19fa41e220aeecc9f166192ab77648649bf4a Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sun, 2 Aug 2026 01:51:54 +0800 Subject: [PATCH 05/14] feat(build.mcpp): dialect-aware compile and directives, plus the toolchain env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MSVC support in the build.mcpp path was zero, not partial: `grep -i msvc` over build_program.cppm hit only comments. Three layers had to be fixed together, because each one only becomes visible after the previous is gone — first 'C:\Program' is not recognized, then D9002 on -O0, then LNK1181, then 'cannot open include file: cstdio'. - CommandDialect gains the link/language spellings it lacked: libFlag (a FORMAT, since GNU prefixes -lz and MSVC suffixes z.lib — no single prefix expresses both), libSearchPrefix, forceCxxLang, staticRuntime, outputExePrefix. - The host compile is spelled through the dialect instead of hardcoded GNU. - mcpp:link-lib / link-search / cfg are translated at the parse boundary, so the wire protocol stays declarative — a build program names WHICH library it needs, never how the local driver spells one. Storing the translated form in the cache is safe: the cache key already hashes the compiler. - host_base_flags returns nothing for MSVC — cl.exe finds headers and import libs through INCLUDE/LIB, not argv — and capture_exec now receives tc.envOverrides, which only ninja_backend consumed before. Named modules under cl.exe (.ifc + /reference) remain unimplemented; both import kinds share ONE gate and one diagnostic rather than failing obscurely. --- src/build/build_program.cppm | 121 ++++++++++++++++++++++++++++++----- src/toolchain/dialect.cppm | 48 ++++++++++++++ tests/unit/test_dialect.cpp | 55 ++++++++++++++++ 3 files changed, 208 insertions(+), 16 deletions(-) create mode 100644 tests/unit/test_dialect.cpp diff --git a/src/build/build_program.cppm b/src/build/build_program.cppm index a41c95d1..ddf31212 100644 --- a/src/build/build_program.cppm +++ b/src/build/build_program.cppm @@ -17,6 +17,7 @@ import mcpp.manifest; import mcpp.platform; import mcpp.platform.process; import mcpp.toolchain.cppfly; // std_flag (dialect- and c++fly-aware -std= spelling) +import mcpp.toolchain.dialect; // CommandDialect — gnu vs cl.exe spellings import mcpp.toolchain.fingerprint; // hash_file / hash_string (FNV-1a, 16 hex) import mcpp.toolchain.linkmodel; // shared C-library / clang-cfg-bypass model import mcpp.toolchain.model; // Toolchain, PayloadPaths, is_clang/is_musl_target/is_mingw_target @@ -78,8 +79,10 @@ namespace fs = std::filesystem; struct Directives { std::vector cxxflags; // -> buildConfig.cxxflags std::vector cflags; // -> buildConfig.cflags - std::vector ldflags; // -> buildConfig.ldflags (already -l/-L) - std::vector defines; // cfg= -> -D, into BOTH c/cxx flags + // -> buildConfig.ldflags, already spelled for the host dialect + // (-l/-L for GNU, name.lib//LIBPATH: for cl.exe) — see parse_line. + std::vector ldflags; + std::vector defines; // cfg= -> define prefix, into BOTH c/cxx flags std::vector generated; // relative source paths // source= — select a PRE-EXISTING file (tarball payload / vendored tree) // into the compile set. Downstream identical to generated=; the semantic @@ -101,6 +104,22 @@ struct Directives { std::vector rerunEnv; // declared env-var inputs }; +// Split a whitespace-separated flag string into argv tokens. The dialect +// table stores some entries as multi-token strings ("-x c++", +// "/nologo /EHsc /utf-8") because their other consumer is a ninja command +// line, where a single string is what's wanted; an argv vector is not. +std::vector split_ws(std::string_view s) { + std::vector out; + std::size_t i = 0; + while (i < s.size()) { + while (i < s.size() && (s[i] == ' ' || s[i] == '\t')) ++i; + std::size_t b = i; + while (i < s.size() && s[i] != ' ' && s[i] != '\t') ++i; + if (i > b) out.emplace_back(s.substr(b, i - b)); + } + return out; +} + std::string trim(std::string_view s) { std::size_t b = 0, e = s.size(); while (b < e && (s[b] == ' ' || s[b] == '\t' || s[b] == '\r')) ++b; @@ -119,7 +138,17 @@ std::string abs_against_root(const fs::path& root, std::string_view p) { // Parse one stdout line. Returns true if it was a recognized (or unknown-but- // `mcpp:`) directive; false for ordinary program chatter. -bool parse_line(const fs::path& root, std::string_view raw, Directives& d) { +// `dial` decides how `link-lib` / `link-search` are spelled. The `mcpp:` +// protocol itself is declarative — a build program says WHICH library it +// needs, never how the local compiler driver names one — so the translation +// belongs here at the boundary, not in the program. +// +// Storing the translated form in Directives (and therefore in the build.mcpp +// cache) is safe because the cache key already hashes the compiler: switching +// toolchains invalidates the entry before any spelling from the old dialect +// could be replayed under the new one. +bool parse_line(const fs::path& root, const mcpp::toolchain::CommandDialect& dial, + std::string_view raw, Directives& d) { std::string line = trim(raw); constexpr std::string_view kPfx = "mcpp:"; if (!line.starts_with(kPfx)) return false; @@ -130,9 +159,13 @@ bool parse_line(const fs::path& root, std::string_view raw, Directives& d) { if (key == "cxxflag") d.cxxflags.push_back(val); else if (key == "cflag") d.cflags.push_back(val); - else if (key == "link-lib") d.ldflags.push_back("-l" + val); - else if (key == "link-search") d.ldflags.push_back("-L" + abs_against_root(root, val)); - else if (key == "cfg") d.defines.push_back("-D" + val); + else if (key == "link-lib") d.ldflags.push_back( + mcpp::toolchain::lib_flag_for(dial, val)); + else if (key == "link-search") d.ldflags.push_back( + std::string(dial.libSearchPrefix) + + abs_against_root(root, val)); + else if (key == "cfg") d.defines.push_back( + std::string(dial.definePrefix) + val); else if (key == "generated") d.generated.push_back(val); else if (key == "source") d.sources.push_back(val); else if (key == "include-dir") d.includeDirs.push_back(abs_against_root(root, val)); @@ -144,12 +177,13 @@ bool parse_line(const fs::path& root, std::string_view raw, Directives& d) { return true; } -void parse_output(const fs::path& root, std::string_view out, Directives& d) { +void parse_output(const fs::path& root, const mcpp::toolchain::CommandDialect& dial, + std::string_view out, Directives& d) { std::size_t pos = 0; while (pos <= out.size()) { std::size_t nl = out.find('\n', pos); std::string_view ln = out.substr(pos, nl == std::string_view::npos ? std::string_view::npos : nl - pos); - parse_line(root, ln, d); + parse_line(root, dial, ln, d); if (nl == std::string_view::npos) break; pos = nl + 1; } @@ -168,6 +202,14 @@ std::string env_value(const std::string& name) { // only ones needed. Passed as separate argv tokens (no shell). std::vector host_base_flags(const mcpp::toolchain::Toolchain& tc) { std::vector f; + + // MSVC carries none of this on the command line: cl.exe and link.exe find + // headers and import libraries through INCLUDE / LIB, which detection + // synthesized into tc.envOverrides. Emitting the GNU shapes below would + // produce a string of unknown options and then LNK1181. The environment + // is passed to capture_exec instead — that is the whole MSVC "base". + if (tc.compiler == mcpp::toolchain::CompilerId::MSVC) return f; + const auto lm = mcpp::toolchain::resolve_link_model(tc); // Clang with a bundled cfg on LINUX: bypass it (--no-default-config) and @@ -688,6 +730,14 @@ std::expected run_build_program( cppStandard.level); auto base = host_base_flags(tc); + // The host compile has always been spelled in GNU driver syntax with no + // dialect branch at all — `grep -i msvc` over this file used to hit only + // comments. Under cl.exe every one of `-O0` / `-x c++` / `-static` / `-o` + // is wrong, so the whole build.mcpp path was unusable on a native MSVC + // toolchain regardless of what else was fixed. + const auto& dial = mcpp::toolchain::dialect_for(tc); + const bool msvcHost = dial.id == std::string_view("msvc"); + // Only wire the bundled `mcpp` module when build.mcpp actually imports it — // so the common `#include`-based program compiles exactly as before (no // -fmodules, cwd = project root). When it does `import mcpp;`, compile the @@ -699,6 +749,18 @@ std::expected run_build_program( bool usesStdCompat = imports_module(srcText, "std.compat"); bool usesStd = usesStdCompat || imports_module(srcText, "std"); + // Named modules under cl.exe go through .ifc + /reference, a different + // pipeline from GCC's gcm.cache and Clang's -fmodule-file. That work is + // not done, so say so plainly — one gate for both module kinds, because + // they fail for exactly the same reason and two conditions would drift. + if (msvcHost && (usesModule || usesStd)) { + return std::unexpected(std::string( + "build.mcpp: `import mcpp;` / `import std;` are not yet supported " + "under MSVC.\n" + " Use #include in build.mcpp, or build with a GCC/Clang " + "toolchain.")); + } + std::vector moduleFlags; if (usesModule) { auto mf = build_mcpp_module(bdir, hostCompiler, base, std_flag, @@ -793,15 +855,28 @@ std::expected run_build_program( // `-x c++` is required: the `.mcpp` extension is unknown to the compiler, so // without it the driver hands build.mcpp to the linker as a linker script. - std::vector compileArgv = { hostCompiler.string(), std_flag, "-O0" }; + std::vector compileArgv = { hostCompiler.string() }; + if (msvcHost) { + // /nologo /EHsc /utf-8 — cl.exe needs these to behave like the other + // two drivers do by default (quiet, exceptions on, UTF-8 sources). + for (auto& f : split_ws(dial.alwaysFlags)) compileArgv.push_back(f); + } + compileArgv.push_back(std_flag); + // No optimization: this program runs once per build and its compile time + // is on the critical path. MSVC spells "off" /Od, not /O0. + compileArgv.push_back(msvcHost ? std::string("/Od") + : std::string(dial.optPrefix) + "0"); for (auto& bf : base) compileArgv.push_back(bf); for (auto& mf : moduleFlags) compileArgv.push_back(mf); for (auto& sf : stdFlags) compileArgv.push_back(sf); - compileArgv.push_back("-x"); compileArgv.push_back("c++"); + // The `.mcpp` extension is unknown to every driver, so without this the + // file is handed to the linker as a linker script. + for (auto& f : split_ws(dial.forceCxxLang)) compileArgv.push_back(f); compileArgv.push_back(src.string()); if (usesModule || !stdObjects.empty()) { - // Link the module objects (reset the input language first so the .o - // isn't treated as C++ source). + // Link the module objects (GNU: reset the input language first so the + // .o isn't treated as C++ source; cl.exe infers by extension and is + // unreachable here anyway, gated above). compileArgv.push_back("-x"); compileArgv.push_back("none"); if (usesModule) compileArgv.push_back((bdir / "mcpp.o").string()); for (auto& so : stdObjects) compileArgv.push_back(so); @@ -810,8 +885,13 @@ std::expected run_build_program( // Deliberately NOT in `base`: that also feeds the bundled module's // compile/precompile commands, where a link flag has no business (and for // Clang would perturb the default PIC/PIE codegen of mcpp.o). - if (staticHostHelper) compileArgv.push_back("-static"); - compileArgv.push_back("-o"); compileArgv.push_back(bin.string()); + if (staticHostHelper) compileArgv.push_back(std::string(dial.staticRuntime)); + if (msvcHost) { + // /Fe: takes its value attached, not as a separate argv token. + compileArgv.push_back(std::string(dial.outputExePrefix) + bin.string()); + } else { + compileArgv.push_back("-o"); compileArgv.push_back(bin.string()); + } mcpp::ui::info("build.mcpp", "compiling"); // GCC resolves imported BMIs via gcm.cache/ relative to the compile cwd, so // any compile that imports a module — `mcpp`, `std`, or both — has to run @@ -820,7 +900,16 @@ std::expected run_build_program( // only mcpp. Otherwise the project root is fine. const bool needsBmiCwd = usesModule || stdStagedInBdir; std::string compileCwd = needsBmiCwd ? bdir.string() : root.string(); - auto cres = mcpp::platform::process::capture_exec(compileArgv, {}, compileCwd); + // The toolchain's own environment (MSVC's INCLUDE / LIB / VSLANG, which + // detection synthesized from the located VC tools + Windows SDK). Only + // ninja_backend consumed these before, so a build.mcpp compile under + // cl.exe could not find no matter how correct its argv was — + // the third and last layer of #331's first finding. + std::vector> compileEnv; + for (auto const& ev : tc.envOverrides) + compileEnv.emplace_back(ev.key, ev.value); + auto cres = mcpp::platform::process::capture_exec(compileArgv, compileEnv, + compileCwd); if (cres.exit_code != 0) { return std::unexpected(std::format( "build.mcpp failed to compile (exit {}):\n{}", cres.exit_code, cres.output)); @@ -838,7 +927,7 @@ std::expected run_build_program( } Directives d; - parse_output(root, rres.output, d); + parse_output(root, dial, rres.output, d); // Dependency mode (genBase set): relative `generated=` paths resolve // against OUT_DIR-style genBase, not the (possibly read-only, shared) diff --git a/src/toolchain/dialect.cppm b/src/toolchain/dialect.cppm index 29288108..1d7b80eb 100644 --- a/src/toolchain/dialect.cppm +++ b/src/toolchain/dialect.cppm @@ -33,6 +33,22 @@ struct CommandDialect { std::string_view debugFlags; // "-g" | "/Zi /FS" std::string_view alwaysFlags; // "" | "/nologo /EHsc /utf-8" + // Link and language-selection spellings. + // + // `libFlag` is a FORMAT, not a prefix: GNU names a library by prefixing + // (`-lz`) while MSVC names it by suffixing (`z.lib`), and no single + // prefix string can express both. Use lib_flag_for(). + std::string_view libFlag; // "-l{}" | "{}.lib" + std::string_view libSearchPrefix; // "-L" | "/LIBPATH:" + // The `.mcpp` extension is unknown to every compiler driver, so the + // language has to be forced or the driver hands the file to the linker. + std::string_view forceCxxLang; // "-x c++" | "/TP" + // Static CRT / runtime. On MSVC this is a compile-time CRT model, not a + // link mode — there is no /MT equivalent of `-static` for the whole image. + std::string_view staticRuntime; // "-static"| "/MT" + // Output an executable (linking driver step). + std::string_view outputExePrefix; // "-o " | "/Fe:" + // Artifact naming. std::string_view objExt; // ".o" | ".obj" @@ -56,6 +72,16 @@ struct CommandDialect { // Dialect lookup. GCC / Clang / MinGW → gnu; MSVC → msvc. const CommandDialect& dialect_for(const Toolchain& tc); +// The two dialect rows, reachable without a Toolchain. Exposed so the MSVC +// row — which no build reaches until the cl.exe backend lands — can still be +// unit-tested, and so callers that already know the shape they want (the +// build.mcpp host compile) need not synthesize a Toolchain to ask. +const CommandDialect& gnu_dialect(); +const CommandDialect& msvc_dialect(); + +// Name a library the way this dialect does: `-lz` vs `z.lib`. +std::string lib_flag_for(const CommandDialect& d, std::string_view name); + // The full -std=/-/std: flag for a normalized standard (canonical like // "c++26"/"gnu++23", numeric level). MSVC: /std:c++20 exists; everything // newer maps to /std:c++latest (required for import std); gnu dialects have @@ -79,6 +105,11 @@ constexpr CommandDialect kGnuDialect{ .optPrefix = "-O", .debugFlags = "-g", .alwaysFlags = "", + .libFlag = "-l{}", + .libSearchPrefix = "-L", + .forceCxxLang = "-x c++", + .staticRuntime = "-static", + .outputExePrefix = "-o ", .objExt = ".o", .ninjaDepsMode = "", .rspfileLink = false, @@ -99,6 +130,11 @@ constexpr CommandDialect kMsvcDialect{ .optPrefix = "/O", .debugFlags = "/Zi /FS", .alwaysFlags = "/nologo /EHsc /utf-8", + .libFlag = "{}.lib", + .libSearchPrefix = "/LIBPATH:", + .forceCxxLang = "/TP", + .staticRuntime = "/MT", + .outputExePrefix = "/Fe:", .objExt = ".obj", .ninjaDepsMode = "msvc", .rspfileLink = true, @@ -113,6 +149,18 @@ const CommandDialect& dialect_for(const Toolchain& tc) { return kGnuDialect; } +const CommandDialect& gnu_dialect() { return kGnuDialect; } +const CommandDialect& msvc_dialect() { return kMsvcDialect; } + +std::string lib_flag_for(const CommandDialect& d, std::string_view name) { + // Two shapes, one table entry: `{}` marks where the name goes, which is + // a prefix position for GNU and a suffix position for MSVC. + std::string out(d.libFlag); + if (auto p = out.find("{}"); p != std::string::npos) + out.replace(p, 2, name); + return out; +} + std::string std_flag_for(const CommandDialect& d, std::string_view canonical, int level) { if (d.id == "msvc") { diff --git a/tests/unit/test_dialect.cpp b/tests/unit/test_dialect.cpp new file mode 100644 index 00000000..cdc97a2d --- /dev/null +++ b/tests/unit/test_dialect.cpp @@ -0,0 +1,55 @@ +#include + +import std; +import mcpp.toolchain.dialect; +import mcpp.toolchain.model; + +// The MSVC row is unreachable in a real build until the cl.exe backend lands, +// so these tests are the only thing keeping it honest. + +TEST(Dialect, LibFlagHasBothShapes) { + // GNU names a library by prefixing, MSVC by suffixing. A single + // string_view prefix cannot express `z.lib`, which is why libFlag is a + // format rather than a prefix. + EXPECT_EQ(mcpp::toolchain::lib_flag_for(mcpp::toolchain::gnu_dialect(), "z"), + "-lz"); + EXPECT_EQ(mcpp::toolchain::lib_flag_for(mcpp::toolchain::msvc_dialect(), "z"), + "z.lib"); +} + +TEST(Dialect, LibFlagHandlesDottedAndHyphenatedNames) { + EXPECT_EQ(mcpp::toolchain::lib_flag_for(mcpp::toolchain::gnu_dialect(), + "avcodec-60"), "-lavcodec-60"); + EXPECT_EQ(mcpp::toolchain::lib_flag_for(mcpp::toolchain::msvc_dialect(), + "avcodec-60"), "avcodec-60.lib"); +} + +// Every dialect field must be populated in both rows. An empty one silently +// emits nothing, which for staticRuntime or forceCxxLang means the compile +// changes meaning rather than failing. +TEST(Dialect, LinkAndLanguageFieldsPopulatedInBothRows) { + for (auto const* d : { &mcpp::toolchain::gnu_dialect(), + &mcpp::toolchain::msvc_dialect() }) { + EXPECT_FALSE(d->libFlag.empty()) << d->id; + EXPECT_FALSE(d->libSearchPrefix.empty()) << d->id; + EXPECT_FALSE(d->forceCxxLang.empty()) << d->id; + EXPECT_FALSE(d->staticRuntime.empty()) << d->id; + EXPECT_FALSE(d->outputExePrefix.empty()) << d->id; + // A `{}` placeholder is what makes lib_flag_for work at all. + EXPECT_NE(d->libFlag.find("{}"), std::string_view::npos) << d->id; + } +} + +// dialect_for must keep routing clang-targeting-MSVC to the gnu spellings: +// that driver takes GNU flags even though its ABI and STL are Microsoft's. +TEST(Dialect, OnlyNativeClExeGetsTheMsvcRow) { + mcpp::toolchain::Toolchain clangMsvc; + clangMsvc.compiler = mcpp::toolchain::CompilerId::Clang; + clangMsvc.targetTriple = "x86_64-pc-windows-msvc"; + EXPECT_EQ(mcpp::toolchain::dialect_for(clangMsvc).id, "gnu"); + + mcpp::toolchain::Toolchain cl; + cl.compiler = mcpp::toolchain::CompilerId::MSVC; + cl.targetTriple = "x86_64-pc-windows-msvc"; + EXPECT_EQ(mcpp::toolchain::dialect_for(cl).id, "msvc"); +} From c03d90b6cf20239589f9c40a260dae476270b20b Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sun, 2 Aug 2026 02:09:37 +0800 Subject: [PATCH 06/14] test(e2e),ci,docs: bare-Windows axis, spaced paths, MSVC build.mcpp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI gains the Windows shapes it never had. windows-fresh becomes a matrix over windows-2022 and windows-2025 — GitHub publishes no Windows CLIENT image, so those Server builds are the closest stand-ins for the Win10 and Win11 kernel generations; genuinely client-only behaviour (UAC, Defender, long-path policy) still needs a self-hosted runner and is called out as uncovered. windows-nomsvc-fresh masks Visual Studio, because no VS-free runner exists. Masking risks a false green — miss one of msvc.cppm's three discovery strategies and mcpp still finds MSVC, takes the old path and passes while proving nothing — so e2e 182 opens by asserting that detection FAILS. It then checks the fallback, that both axes persist, that the exe runs with the toolchain off PATH, and that an explicit [toolchain] is refused rather than swapped. e2e 179 (spaced paths) asserts on the generated ninja file, not just on the build succeeding: a split include flag can still compile by luck. e2e 180 fills the empty `MSVC x build.mcpp` cell, including the link-lib translation and the module refusal. Docs: the winlibs route moves from a footnote to the stated default, and the three `[build] linkage` mentions are corrected — the parser only reads `linkage` under `[target.]`, so that key was silently ignored everywhere it was documented. --- .github/workflows/ci-fresh-install.yml | 92 +++++++++++++++- .github/workflows/ci-windows.yml | 6 ++ README.md | 16 +-- README.zh-CN.md | 13 ++- docs/03-toolchains.md | 28 ++++- docs/07-build-mcpp.md | 39 ++++++- docs/zh/03-toolchains.md | 21 +++- docs/zh/07-build-mcpp.md | 37 ++++++- src/build/prepare.cppm | 3 +- tests/e2e/179_spaced_paths.sh | 87 ++++++++++++++++ tests/e2e/180_msvc_build_mcpp.sh | 121 ++++++++++++++++++++++ tests/e2e/182_windows_no_msvc_fallback.sh | 91 ++++++++++++++++ tests/e2e/run_all.sh | 8 ++ 13 files changed, 534 insertions(+), 28 deletions(-) create mode 100755 tests/e2e/179_spaced_paths.sh create mode 100755 tests/e2e/180_msvc_build_mcpp.sh create mode 100755 tests/e2e/182_windows_no_msvc_fallback.sh diff --git a/.github/workflows/ci-fresh-install.yml b/.github/workflows/ci-fresh-install.yml index e33ab2de..60a1ef72 100644 --- a/.github/workflows/ci-fresh-install.yml +++ b/.github/workflows/ci-fresh-install.yml @@ -374,14 +374,26 @@ jobs: mcpp run # ────────────────────────────────────────────────────────────────── - # Windows: llvm@20.1.7 + MSVC STL + # Windows WITH Visual Studio: llvm@20.1.7 + MSVC STL + # + # Two images, because the OS version is a real variable for a tool that + # touches the UCRT, the Windows SDK and long paths. GitHub publishes no + # Windows 10/11 CLIENT image, so these Server builds are the closest + # available stand-ins: windows-2022 is the Win10 21H2 kernel generation, + # windows-2025 the Win11 24H2 one. What they cannot cover is genuinely + # client-only behaviour — UAC prompts, Defender real-time scanning, the + # long-path policy default — which needs a self-hosted runner. # ────────────────────────────────────────────────────────────────── windows-fresh: needs: [wait-index] - name: Windows fresh install + name: Windows fresh install (${{ matrix.image }}) if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }} - runs-on: windows-latest + runs-on: ${{ matrix.image }} timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + image: [windows-2022, windows-2025] env: # The one derived value (see the header comment): every install job names # the SAME version the index guard waited for, so the two cannot disagree. @@ -437,3 +449,77 @@ jobs: run: | mcpp clean mcpp run + + # ────────────────────────────────────────────────────────────────── + # Windows WITHOUT Visual Studio — the shape of an ordinary user's machine + # + # A stock Windows install ships the UCRT runtime DLLs and nothing else: the + # MSVC STL and the Windows SDK arrive only with Visual Studio's "Desktop + # development with C++" workload. mcpp's Windows default targeted the MSVC + # ABI, so `mcpp new && mcpp build` failed on every such box — and no CI job + # could see it, because every GitHub Windows image ships Visual Studio. + # + # There is no VS-free runner, so the image is masked instead. The risk with + # masking is a false green: miss one of msvc.cppm's three discovery + # strategies (vswhere, environment, well-known paths) and mcpp still finds + # MSVC, takes the ordinary path, and the job passes while proving nothing. + # e2e 182 opens by asserting `mcpp toolchain default msvc` FAILS, which + # turns exactly that into a hard failure. + # ────────────────────────────────────────────────────────────────── + windows-nomsvc-fresh: + needs: [wait-index] + name: Windows fresh install (no Visual Studio) + if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }} + runs-on: windows-2025 + timeout-minutes: 30 + env: + MCPP_PIN: ${{ needs.wait-index.outputs.version }} + steps: + - uses: actions/checkout@v4 + + - name: Mask Visual Studio + shell: pwsh + run: | + # All three discovery strategies at once. The runner is disposable, + # so renaming in place is fine and is closer to "absent" than any + # env-only trick would be. + $vswhere = "C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" + if (Test-Path $vswhere) { Rename-Item $vswhere "vswhere.exe.masked" } + + Get-ChildItem "C:\Program Files*\Microsoft Visual Studio" -Directory ` + -ErrorAction SilentlyContinue | ForEach-Object { + $masked = "$($_.FullName).masked" + if (-not (Test-Path $masked)) { Rename-Item $_.FullName $masked } + } + + foreach ($v in @('VSINSTALLDIR','VCINSTALLDIR','VCToolsInstallDir', + 'VS170COMNTOOLS','VS160COMNTOOLS','VS150COMNTOOLS')) { + "$v=" | Out-File -Append -FilePath $env:GITHUB_ENV -Encoding utf8 + } + + - name: Install xlings + shell: pwsh + env: + XLINGS_NON_INTERACTIVE: '1' + run: | + irm https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.ps1 | iex + + $xlingsbin = "$env:USERPROFILE\.xlings\subos\current\bin" + $env:PATH = "$xlingsbin;$env:PATH" + $xlingsbin | Out-File -Append -FilePath $env:GITHUB_PATH -Encoding utf8 + + - name: Install mcpp and config mirror + shell: pwsh + run: | + xlings update + xlings install "mcpp@$env:MCPP_PIN" -y -g --verbose + mcpp --version + mcpp self config --mirror GLOBAL + + # The self-check, the fallback, persistence, a self-contained exe, and + # the refusal to overrule an explicit [toolchain] — all in e2e 182, so + # the assertions live with the tests rather than in workflow YAML. + - name: "No Visual Studio: fallback to winlibs GCC (e2e 182)" + shell: bash + run: | + MCPP="$(command -v mcpp)" bash tests/e2e/182_windows_no_msvc_fallback.sh diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index bb57f98b..269cbfe0 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -292,6 +292,12 @@ jobs: cd "$GITHUB_WORKSPACE" MCPP="$MCPP_SELF" bash tests/e2e/99_msvc_native_build.sh + # build.mcpp under cl.exe. `MSVC x build.mcpp` was an empty cell in + # this matrix and the feature was correspondingly at zero — the ten + # build.mcpp e2e all run under clang, whose payload path has no + # spaces in it either. Both gaps closed here. + MCPP="$MCPP_SELF" bash tests/e2e/180_msvc_build_mcpp.sh + # restore the LLVM default for the remaining steps "$MCPP_SELF" toolchain default llvm@20.1.7 diff --git a/README.md b/README.md index 0968290e..9ae9e768 100644 --- a/README.md +++ b/README.md @@ -294,8 +294,8 @@ the right toolchain payload is resolved and installed automatically. | `x86_64-linux-gnu` | gcc *(Linux default)* or llvm | ✅ | | `x86_64-linux-musl` | gcc 16, fully static | ✅ | | `aarch64-linux-musl` | gcc 16, fully static — cross from x86_64 (qemu-verified) or native | ✅ | -| `x86_64-windows-gnu` | gcc 16 MinGW-w64 — native on Windows, cross from Linux (wine-verified) | ✅ | -| `x86_64-windows-msvc` | `msvc@system` (detected VS/BuildTools) or llvm ¹ *(Windows default)* | ✅ | +| `x86_64-windows-gnu` | gcc 16 MinGW-w64 — native on Windows, cross from Linux (wine-verified) *(Windows default without Visual Studio)* | ✅ | +| `x86_64-windows-msvc` | `msvc@system` (detected VS/BuildTools) or llvm ¹ *(Windows default with Visual Studio)* | ✅ | | `aarch64-macos` | llvm *(macOS default)* | ✅ | | `riscv64-linux-musl` | — | 🔄 | | `aarch64-linux-gnu` | — | 🔄 | @@ -308,10 +308,14 @@ the right toolchain payload is resolved and installed automatically. > `musl-gcc@…` — stay permanently accepted as aliases and normalize to the > canonical forms above. > -> ¹ On Windows, llvm requires an existing **MSVC BuildTools or Visual Studio** -> (UCRT, Windows SDK, MSVC STL). The MinGW route (`--target x86_64-windows-gnu`, -> or `mcpp toolchain default gcc@16 --target x86_64-windows-gnu`) needs no -> Visual Studio at all. +> ¹ On Windows, llvm targets the MSVC ABI and therefore requires an existing +> **MSVC BuildTools or Visual Studio** (UCRT, Windows SDK, MSVC STL). You do +> not have to arrange this: on first run mcpp checks for a usable MSVC and, +> finding none, defaults to `x86_64-windows-gnu` (winlibs MinGW-w64) — fully +> self-contained, no Visual Studio, `import std` included. Nothing to install +> or configure; `mcpp new && mcpp build` just works on a stock Windows box. +> An explicit `[toolchain]` in `mcpp.toml` is always respected as written — +> mcpp revises its own default, never yours. ## Documentation diff --git a/README.zh-CN.md b/README.zh-CN.md index 113ee89e..787db9ae 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -291,8 +291,8 @@ mcpp 的身份模型是两条正交轴:**工具链** = `family@version`(family | `x86_64-linux-gnu` | gcc(*Linux 默认*)或 llvm | ✅ | | `x86_64-linux-musl` | gcc 16,全静态 | ✅ | | `aarch64-linux-musl` | gcc 16,全静态——x86_64 交叉(qemu 实测)或原生 | ✅ | -| `x86_64-windows-gnu` | gcc 16 MinGW-w64——Windows 原生,Linux 交叉(wine 实测) | ✅ | -| `x86_64-windows-msvc` | `msvc@system`(探测 VS/BuildTools)或 llvm ¹(*Windows 默认*) | ✅ | +| `x86_64-windows-gnu` | gcc 16 MinGW-w64——Windows 原生,Linux 交叉(wine 实测)(*无 Visual Studio 时的 Windows 默认*) | ✅ | +| `x86_64-windows-msvc` | `msvc@system`(探测 VS/BuildTools)或 llvm ¹(*有 Visual Studio 时的 Windows 默认*) | ✅ | | `aarch64-macos` | llvm(*macOS 默认*) | ✅ | | `riscv64-linux-musl` | — | 🔄 | | `aarch64-linux-gnu` | — | 🔄 | @@ -304,9 +304,12 @@ mcpp 的身份模型是两条正交轴:**工具链** = `family@version`(family > 旧拼写——`x86_64-w64-mingw32`、`gcc@16.1.0-musl`、`mingw-cross@…`、`musl-gcc@…`—— > 作为别名**永久接受**,归一到上表的 canonical 形式。 > -> ¹ Windows 上 llvm 依赖已安装的 **MSVC BuildTools 或 Visual Studio**(UCRT、Windows -> SDK、MSVC STL)。MinGW 路线(`--target x86_64-windows-gnu`,或 -> `mcpp toolchain default gcc@16 --target x86_64-windows-gnu`)完全不需要 Visual Studio。 +> ¹ Windows 上 llvm 打的是 MSVC ABI,因此依赖已安装的 **MSVC BuildTools 或 +> Visual Studio**(UCRT、Windows SDK、MSVC STL)。这件事你不需要自己安排:mcpp 首跑会 +> 探测是否有可用的 MSVC,探不到就默认走 `x86_64-windows-gnu`(winlibs MinGW-w64)—— +> 完全自包含、不需要 Visual Studio、`import std` 可用。无需安装或配置,裸 Windows 上 +> `mcpp new && mcpp build` 直接可用。而 `mcpp.toml` 里显式写的 `[toolchain]` 永远按你 +> 写的执行——mcpp 只修正自己选的默认值,不改你的。 ## 文档 diff --git a/docs/03-toolchains.md b/docs/03-toolchains.md index 4e61148a..cd1d74b0 100644 --- a/docs/03-toolchains.md +++ b/docs/03-toolchains.md @@ -110,6 +110,21 @@ and `planned` targets that are registered but not yet shipped. ## Windows PE via MinGW-w64 (`x86_64-windows-gnu`, no Visual Studio required) +**This is the Windows default when no Visual Studio is present.** Windows ships +the UCRT runtime DLLs but not the MSVC STL or the Windows SDK — those come with +Visual Studio's "Desktop development with C++" workload. Since llvm on Windows +targets the MSVC ABI and needs both, mcpp checks for a usable MSVC (STL **and** +SDK — half of one is the trap) on first run and falls back here when it finds +none, persisting the choice so later builds are silent. Nothing to install or +configure. + +The same check also repairs an existing setup: if a `[toolchain] default` mcpp +chose earlier can no longer work on this machine, it is revised in place, with +a line saying so. An explicit `[toolchain]` in `mcpp.toml` (or a +`[target.X].toolchain`) is never overruled — a project that needs the MSVC ABI +to link vcpkg-built `.lib` files gets an error naming the alternative, not a +silent ABI swap. + "MinGW" in mcpp is a **target**, not a toolchain name: `x86_64-windows-gnu` — GCC producing Windows PE with the GNU CRT. The same identity works from both hosts; which self-contained payload serves it is resolved automatically @@ -126,7 +141,14 @@ mcpp toolchain default gcc@16 --target x86_64-windows-gnu It uses the regular GCC module pipeline (`gcm.cache`, `import std` via libstdc++'s `bits/std.cc`). The target's default linkage is **static** — the produced `.exe` is fully self-contained (no `libstdc++-6.dll` to ship, -runs directly under wine); `[build] linkage = "dynamic"` opts out. +runs directly under wine). To opt out, set it on the target section — +`linkage` is exact-triple only (§2.7 of [mcpp.toml](05-mcpp-toml.md)), and a +`[build] linkage` key does not exist and is silently ignored: + +```toml +[target.x86_64-windows-gnu] +linkage = "dynamic" +``` In a manifest: @@ -180,7 +202,9 @@ INCLUDE/LIB environment from the detected VC tools + Windows SDK (no `vcvarsall` involved), stages `std.ixx`/`std.compat.ixx` as `.ifc` BMIs, compiles `.cppm` module units via `/interface /TP /ifcOutput`, scans with `/scanDependencies`, and links with `link.exe`/`lib.exe` through response -files. `[build] linkage = "static"` selects the `/MT` CRT. A missing Windows +files. `[target.x86_64-windows-msvc] linkage = "static"` (or `mcpp build +--static`) selects the `/MT` CRT — not `[build] linkage`, which is not a key. +A missing Windows SDK fails the build with installation guidance (`mcpp self doctor` reports SDK status). diff --git a/docs/07-build-mcpp.md b/docs/07-build-mcpp.md index 7f7071a2..d7044415 100644 --- a/docs/07-build-mcpp.md +++ b/docs/07-build-mcpp.md @@ -69,7 +69,7 @@ interface and belongs in the declarative manifest/descriptor ## Typed API: `import mcpp;` (recommended) Instead of printing raw strings you can write `build.mcpp` **modules-first** — -`import mcpp;`, no `#include`, no `import std;`. The `mcpp` module is bundled in the +`import mcpp;`, no `#include` needed. The `mcpp` module is bundled in the mcpp binary (so it always matches your mcpp's protocol) and is compiled on demand; its functions just emit the directives above: @@ -98,10 +98,39 @@ int main() { | `mcpp::include_dir(d)` / `mcpp::include_dir_after(d)` | `mcpp:include-dir=` / `mcpp:include-dir-after=` | | `mcpp::rerun_if_changed(p)` / `mcpp::rerun_if_env_changed(v)` | the matching `rerun-*` directives | -If your `build.mcpp` also needs to *write* a generated file, mix in a textual -`#include ` — that's fine; only `import std;` is unnecessary. The raw -stdout protocol above remains the low-level substrate; `import mcpp;` is the typed -layer over it. +The raw stdout protocol above remains the low-level substrate; `import mcpp;` +is the typed layer over it. + +### `import std;` (mcpp 2026.8.2.1+) + +A `build.mcpp` may `import std;` (and `import std.compat;`), alone or together +with `import mcpp;`: + +```cpp +// build.mcpp +import std; +import mcpp; + +int main() { + for (auto const& f : std::vector{"FOO", "BAR"}) + mcpp::define(f.c_str()); +} +``` + +mcpp stages the **same** std module its own build uses, keyed on +(toolchain × standard × dialect) — so for an ordinary build this costs +nothing, the artifact is already there. A cross build (`--target …`) pays for +one extra std module, because `build.mcpp` compiles and runs on the *host* +while the project targets something else. + +`#include` still works and stays the right choice for a program that only +needs `std::fopen`; there is no requirement to modularize a build script. + +> **Not yet under MSVC.** Named modules with `cl.exe` go through `.ifc` + +> `/reference`, a pipeline mcpp has not wired up. A `build.mcpp` using +> `import mcpp;` or `import std;` under a native MSVC toolchain fails with an +> explicit message telling you to use `#include` or a GCC/Clang toolchain — +> `#include`-based programs are fully supported there. ## Environment contract (mcpp 0.0.95+) diff --git a/docs/zh/03-toolchains.md b/docs/zh/03-toolchains.md index 45c75b18..2f8f9c03 100644 --- a/docs/zh/03-toolchains.md +++ b/docs/zh/03-toolchains.md @@ -115,6 +115,19 @@ Available toolchains (run `mcpp toolchain install `): ## Windows PE 之 MinGW-w64(`x86_64-windows-gnu`,无需 Visual Studio) +**没装 Visual Studio 时,这就是 Windows 上的默认值。** Windows 自带的只有 +UCRT 运行时 DLL,MSVC STL 与 Windows SDK 都只随 Visual Studio 的 +"Desktop development with C++" 负载安装。而 llvm 在 Windows 上打的是 MSVC ABI, +两者都需要,所以 mcpp 首跑时会探测机器上是否有可用的 MSVC(STL **与** SDK +两件齐——只有一半才是真正的坑),探不到就落到这里,并把选择持久化,之后的 +构建不再重复提示。无需任何安装或配置。 + +同一道检查也会修复既有配置:如果 mcpp 早先自己选定的 `[toolchain] default` +在这台机器上已经不可用,它会被就地改写,并打印一行说明。但**用户在 +`mcpp.toml` 里显式写下的** `[toolchain]`(或 `[target.X].toolchain`)永远不会 +被推翻——一个需要 MSVC ABI 去链接 vcpkg 预编译 `.lib` 的工程,得到的是一条 +指明替代方案的错误,而不是被静默换掉 ABI。 + mcpp 里 "MinGW" 是一个 **target**,不是工具链名:`x86_64-windows-gnu` ——GCC 产出 Windows PE(GNU CRT)。两种宿主用同一个身份、同一条命令; 由哪个自包含 payload 来承接是自动分流的(Windows 宿主 → winlibs UCRT @@ -130,7 +143,13 @@ mcpp toolchain default gcc@16 --target x86_64-windows-gnu 它走常规的 GCC 模块管线(`gcm.cache`、经 libstdc++ `bits/std.cc` 的 `import std`)。该 target 默认 linkage 为 **static**——产出的 `.exe` 完全自包含(无需随包分发 `libstdc++-6.dll`,可直接在 wine 下运行); -`[build] linkage = "dynamic"` 可退出。 +要退出请写在 target 段上——`linkage` 只认精确 triple(见 +[mcpp.toml](05-mcpp-toml.md) §2.7),`[build] linkage` 这个键并不存在,写了会被静默忽略: + +```toml +[target.x86_64-windows-gnu] +linkage = "dynamic" +``` manifest 中: diff --git a/docs/zh/07-build-mcpp.md b/docs/zh/07-build-mcpp.md index 63a814a6..3133ce11 100644 --- a/docs/zh/07-build-mcpp.md +++ b/docs/zh/07-build-mcpp.md @@ -62,8 +62,8 @@ manifest/描述符里(`[build] include_dirs`),而不是构建期程序里。 ## 类型化 API:`import mcpp;`(推荐) -除了打印裸字符串,你还可以把 `build.mcpp` 写成**模块优先**——`import mcpp;`,无 -`#include`、无 `import std;`。`mcpp` 模块**内置在 mcpp 二进制里**(因此永远和你这版 mcpp +除了打印裸字符串,你还可以把 `build.mcpp` 写成**模块优先**——`import mcpp;`,不需要 +`#include`。`mcpp` 模块**内置在 mcpp 二进制里**(因此永远和你这版 mcpp 的协议匹配),按需编译;它的函数只是 emit 上面那些指令: ```cpp @@ -91,9 +91,36 @@ int main() { | `mcpp::include_dir(d)` / `mcpp::include_dir_after(d)` | `mcpp:include-dir=` / `mcpp:include-dir-after=` | | `mcpp::rerun_if_changed(p)` / `mcpp::rerun_if_env_changed(v)` | 对应的 `rerun-*` 指令 | -如果 `build.mcpp` 还需要*写*生成文件,混入一个文本 `#include ` 即可——这没问题, -只有 `import std;` 是不必要的。上面的裸 stdout 协议仍是底层基底;`import mcpp;` 是其上的 -类型化层。 +上面的裸 stdout 协议仍是底层基底;`import mcpp;` 是其上的类型化层。 + +### `import std;`(mcpp 2026.8.2.1+) + +`build.mcpp` 可以 `import std;`(以及 `import std.compat;`),单用或与 +`import mcpp;` 并用皆可: + +```cpp +// build.mcpp +import std; +import mcpp; + +int main() { + for (auto const& f : std::vector{"FOO", "BAR"}) + mcpp::define(f.c_str()); +} +``` + +mcpp 会把它自己构建时用的**同一份** std 模块暂存过来,缓存键是 +(工具链 × 标准 × 方言)——所以普通构建下这是零成本,产物本来就在。只有交叉构建 +(`--target …`)才会多编一份:`build.mcpp` 在**宿主**上编译并运行,而工程的目标 +是别的平台。 + +`#include` 依然有效,对只需要 `std::fopen` 的程序也依然是更合适的选择——构建脚本 +没有必须模块化的要求。 + +> **MSVC 下尚不支持。** `cl.exe` 的具名模块走 `.ifc` + `/reference`,这条管线 mcpp +> 还没接。在原生 MSVC 工具链下使用 `import mcpp;` 或 `import std;` 的 `build.mcpp` +> 会得到一条明确的报错,告诉你改用 `#include` 或换 GCC/Clang 工具链——基于 +> `#include` 的程序在那里是完全支持的。 ## 环境契约(mcpp 0.0.95+) diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 163a6692..030ed8b7 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -1579,7 +1579,8 @@ prepare_build(bool print_fingerprint, // dynamic-musl binaries depend on a system /lib/ld-musl-x86_64.so.1 // that most distros don't ship. Default linkage to "static" when // the resolved toolchain is musl, unless the user has already opted - // out via [build].linkage / [target.].linkage. + // out via `--static` or [target.].linkage. (There is no + // [build].linkage — the parser only reads it under a target section.) if (isMuslTc && m->buildConfig.linkage.empty()) { m->buildConfig.linkage = "static"; } diff --git a/tests/e2e/179_spaced_paths.sh b/tests/e2e/179_spaced_paths.sh new file mode 100755 index 00000000..92a38fc1 --- /dev/null +++ b/tests/e2e/179_spaced_paths.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# requires: unix-shell +# 179_spaced_paths.sh — a project whose paths contain spaces still builds +# +# #331: two independent places assumed no path ever contains a space. On +# Windows that means assuming nobody installs under `C:\Program Files` and no +# user account name has a space in it — both routinely false. The failures are +# not Windows-specific though (a Linux `/home/my dir/inc` splits identically), +# so this runs everywhere: same bug, much faster feedback. +set -e + +TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT + +# Every path below the project root carries a space, including the project +# directory itself. +mkdir -p "$TMP/my work dir" +cd "$TMP/my work dir" +"$MCPP" new "spaced_proj" >/dev/null 2>&1 +cd spaced_proj + +# An include directory with a space, holding a header reachable ONLY through +# it — so a split include flag fails to compile rather than silently passing. +mkdir -p "vendor inc/deep dir" +cat > "vendor inc/deep dir/spaced_header.hpp" <<'EOF' +#pragma once +inline int spaced_header_value() { return 4242; } +EOF + +cat > mcpp.toml <<'EOF' +[package] +name = "spaced_proj" +version = "0.1.0" + +[build] +include_dirs = ["vendor inc/deep dir"] +EOF + +# A build.mcpp exercises the other half: mcpp compiles and execs it, so its +# own argv carries the (spaced) payload path of the host compiler. +cat > build.mcpp <<'EOF' +#include +int main() { + std::puts("mcpp:cfg=SPACED_BUILD_PROGRAM_RAN"); + std::puts("mcpp:rerun-if-changed=build.mcpp"); + return 0; +} +EOF + +cat > src/main.cpp <<'EOF' +#include +import std; +int main() { +#ifndef SPACED_BUILD_PROGRAM_RAN + std::println("build.mcpp directive missing"); + return 1; +#endif + if (spaced_header_value() != 4242) { + std::println("wrong header value"); + return 1; + } + std::println("spaced-ok"); + return 0; +} +EOF + +out=$("$MCPP" build 2>&1) || { echo "FAIL: build in a spaced path: $out"; exit 1; } +run_out=$("$MCPP" run 2>&1) || { echo "FAIL: run: $run_out"; exit 1; } +[[ "$run_out" == *"spaced-ok"* ]] || { echo "FAIL: run output: $run_out"; exit 1; } + +# The include flag must reach the compiler as ONE shell word. Assert on the +# generated ninja file rather than on the build succeeding by luck: a split +# flag can still compile if the header happens to be findable another way. +ninja_file=$(find target -name build.ninja | head -1) +[[ -n "$ninja_file" ]] || { echo "FAIL: no build.ninja found"; exit 1; } +grep -q "vendor" "$ninja_file" || { + echo "FAIL: include dir absent from build.ninja"; exit 1; } +# TWO escaping layers, in order, and the test has to know both: ninja escapes +# the space as `$ ` (so ninja itself does not treat it as a separator), and +# the shell quoting wraps the whole token (so what ninja hands to sh stays one +# word). The old code had only the first, which is exactly why the path +# survived ninja and then split in the shell. +grep -qE "'-I[^']*vendor\\\$ inc" "$ninja_file" || { + echo "FAIL: include dir not shell-quoted with its prefix:" + grep -oE "[^ ]*vendor[^ ]*( inc[^ ]*)?" "$ninja_file" | head -3 + exit 1; } + +echo "PASS: spaced paths — include dirs, project root and build.mcpp" diff --git a/tests/e2e/180_msvc_build_mcpp.sh b/tests/e2e/180_msvc_build_mcpp.sh new file mode 100755 index 00000000..629ce79f --- /dev/null +++ b/tests/e2e/180_msvc_build_mcpp.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# requires: windows msvc +# 180_msvc_build_mcpp.sh — build.mcpp works under a native MSVC toolchain +# +# `MSVC x build.mcpp` was an empty cell in the CI matrix, and the feature was +# correspondingly at zero: `grep -i msvc` over build_program.cppm hit only +# comments. Three separate layers had to be fixed, and each only becomes +# visible once the previous one is gone — the argv[0] quoting, the GNU-only +# flag spellings, and the missing INCLUDE/LIB environment. A test that only +# checked "does it build" would have passed on any one of them being fixed, +# so this checks the produced effect instead. +set -e + +CONF="${MCPP_HOME:-$HOME/.mcpp}/config.toml" +ORIG_DEFAULT="" +if [[ -f "$CONF" ]]; then + ORIG_DEFAULT=$(sed -n '/^\[toolchain\]/,/^\[/p' "$CONF" \ + | grep -E '^default[[:space:]]*=' | head -1 | cut -d'"' -f2 || true) +fi +TMP=$(mktemp -d) +restore() { + if [[ -n "$ORIG_DEFAULT" ]]; then + "$MCPP" toolchain default "$ORIG_DEFAULT" >/dev/null 2>&1 || true + fi + rm -rf "$TMP" +} +trap restore EXIT + +cd "$TMP" +"$MCPP" toolchain default msvc >/dev/null 2>&1 \ + || { echo "FAIL: msvc@system not selectable"; exit 1; } + +"$MCPP" new msvc_bp >/dev/null 2>&1 +cd msvc_bp + +# 1) An #include-based build.mcpp — the supported shape under MSVC. It writes +# a file AND emits a define, so a pass requires the helper to have compiled, +# linked, and actually run. +cat > build.mcpp <<'EOF' +#include +int main() { + std::FILE* f = std::fopen("helper-ran", "w"); + if (!f) return 2; + std::fputs("ok\n", f); + std::fclose(f); + std::puts("mcpp:cfg=MSVC_BUILD_PROGRAM_RAN"); + std::puts("mcpp:rerun-if-changed=build.mcpp"); + return 0; +} +EOF + +cat > src/main.cpp <<'EOF' +import std; +int main() { +#ifdef MSVC_BUILD_PROGRAM_RAN + std::println("msvc-build-mcpp-ok"); + return 0; +#else + std::println("define missing"); + return 1; +#endif +} +EOF + +out=$("$MCPP" build 2>&1) || { echo "FAIL: msvc build with build.mcpp: $out"; exit 1; } +[[ -f helper-ran ]] || { echo "FAIL: build.mcpp did not run under MSVC: $out"; exit 1; } +run_out=$("$MCPP" run 2>&1) || { echo "FAIL: run: $run_out"; exit 1; } +[[ "$run_out" == *"msvc-build-mcpp-ok"* ]] \ + || { echo "FAIL: run output: $run_out"; exit 1; } + +# 2) `mcpp:link-lib` must be spelled the MSVC way. Naming a library that is +# always present in the SDK proves the translation reached the linker: +# the GNU spelling `-ladvapi32` would be an unknown option, then LNK1181. +cat > build.mcpp <<'EOF' +#include +int main() { + std::puts("mcpp:link-lib=advapi32"); + std::puts("mcpp:cfg=MSVC_LINK_LIB_OK"); + std::puts("mcpp:rerun-if-changed=build.mcpp"); + return 0; +} +EOF + +cat > src/main.cpp <<'EOF' +import std; +int main() { +#ifdef MSVC_LINK_LIB_OK + std::println("msvc-link-lib-ok"); + return 0; +#else + return 1; +#endif +} +EOF + +out=$("$MCPP" build 2>&1) || { echo "FAIL: msvc link-lib translation: $out"; exit 1; } +run_out=$("$MCPP" run 2>&1) || { echo "FAIL: run (link-lib): $run_out"; exit 1; } +[[ "$run_out" == *"msvc-link-lib-ok"* ]] \ + || { echo "FAIL: run output (link-lib): $run_out"; exit 1; } + +# 3) Named modules under cl.exe (.ifc + /reference) are not implemented. That +# must be an explicit refusal, not an obscure compiler error — the whole +# point of the gate is that the user learns what to do instead. +cat > build.mcpp <<'EOF' +import std; +int main() { + std::println("mcpp:cfg=SHOULD_NOT_GET_HERE"); + return 0; +} +EOF + +set +e +mod_out=$("$MCPP" build 2>&1) +mod_rc=$? +set -e +[[ $mod_rc -ne 0 ]] || { + echo "FAIL: import std in build.mcpp unexpectedly succeeded under MSVC"; exit 1; } +echo "$mod_out" | grep -qi "not yet supported under MSVC" || { + echo "FAIL: no explicit unsupported diagnostic:"; echo "$mod_out"; exit 1; } + +echo "PASS: MSVC build.mcpp — include path, link-lib translation, module refusal" diff --git a/tests/e2e/182_windows_no_msvc_fallback.sh b/tests/e2e/182_windows_no_msvc_fallback.sh new file mode 100755 index 00000000..5e7e1b6a --- /dev/null +++ b/tests/e2e/182_windows_no_msvc_fallback.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# requires: windows no-msvc +# 182_windows_no_msvc_fallback.sh — a bare Windows box builds with no setup +# +# A stock Windows install has the UCRT runtime DLLs but neither the MSVC STL +# nor the Windows SDK: both arrive only with Visual Studio's "Desktop +# development with C++" workload. mcpp's first-run default used to be clang +# targeting the MSVC ABI, which needs exactly those two — so `mcpp new && mcpp +# build` failed on every such machine, with a diagnostic from clang that said +# nothing about the working alternative sitting right next to it. +# +# The CI job that runs this masks the machine's Visual Studio first. Step 0 +# below is what makes that trustworthy: if the masking missed one of +# msvc.cppm's three discovery strategies, mcpp would still find MSVC, take the +# old path, and this test would pass for the wrong reason. Asserting that +# detection FAILS turns that silent false-green into a hard failure. +set -e + +TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT +export MCPP_HOME="$TMP/mcpp-home" # isolated: no inherited default + +# ── 0) Self-check: the environment really has no usable MSVC ──────────────── +if "$MCPP" toolchain default msvc >/dev/null 2>&1; then + echo "FAIL: msvc@system still resolves — the MSVC masking is incomplete," + echo " so nothing below would be testing the no-Visual-Studio path." + exit 1 +fi + +# ── 1) First run must just work ───────────────────────────────────────────── +cd "$TMP" +"$MCPP" new bare_win >/dev/null 2>&1 || { echo "FAIL: mcpp new"; exit 1; } +cd bare_win + +build_out=$("$MCPP" build 2>&1) || { + echo "FAIL: first build on a machine without Visual Studio:" + echo "$build_out"; exit 1; } +run_out=$("$MCPP" run 2>&1) || { echo "FAIL: run: $run_out"; exit 1; } +[[ "$run_out" == *"Hello"* || "$run_out" == *"hello"* ]] \ + || { echo "FAIL: unexpected run output: $run_out"; exit 1; } + +# The build must have announced the substitution rather than done it silently. +echo "$build_out" | grep -qi "x86_64-windows-gnu" \ + || { echo "FAIL: fallback not reported in build output:"; echo "$build_out"; exit 1; } + +# ── 2) Both axes persisted, so the next invocation is silent and `list` +# agrees with what the build actually used ─────────────────────────── +list_out=$("$MCPP" toolchain list 2>&1) +echo "$list_out" | grep -E '\*\s*gcc' >/dev/null \ + || { echo "FAIL: no gcc starred in Toolchains: $list_out"; exit 1; } +echo "$list_out" | grep -E '\*\s*x86_64-windows-gnu' >/dev/null \ + || { echo "FAIL: x86_64-windows-gnu not starred in Targets: $list_out"; exit 1; } + +# A second build must not re-announce the switch — the repair is persisted, +# not re-derived every time. +build2=$("$MCPP" build 2>&1) || { echo "FAIL: second build: $build2"; exit 1; } + +# ── 3) The produced exe is self-contained ────────────────────────────────── +EXE=$(find target -name "bare_win.exe" -path "*/bin/*" | head -1) +[[ -n "$EXE" ]] || { echo "FAIL: no exe produced"; exit 1; } +ISO="$TMP/iso"; mkdir -p "$ISO"; cp "$EXE" "$ISO/" +iso_rc=0 +iso_out=$(cd "$ISO" && PATH="/usr/bin:/c/Windows/System32" ./bare_win.exe 2>&1) || iso_rc=$? +[[ $iso_rc -eq 0 ]] || { + echo "FAIL: exe does not run without the toolchain on PATH (rc=$iso_rc): $iso_out" + exit 1; } + +# ── 4) An EXPLICIT choice must not be overruled ──────────────────────────── +# mcpp may revise a default it picked itself. It must not silently swap the +# ABI out from under a project that asked for MSVC in writing — a project +# linking vcpkg-built .lib files is far better served by an error. +cd "$TMP" +"$MCPP" new explicit_msvc >/dev/null 2>&1 +cd explicit_msvc +cat >> mcpp.toml <<'EOF' + +[toolchain] +windows = "llvm@20.1.7" +EOF + +set +e +exp_out=$("$MCPP" build 2>&1) +exp_rc=$? +set -e +[[ $exp_rc -ne 0 ]] || { + echo "FAIL: explicit [toolchain] windows = llvm was silently replaced" + echo "$exp_out"; exit 1; } +echo "$exp_out" | grep -q "x86_64-windows-gnu" || { + echo "FAIL: the error does not point at the working alternative:" + echo "$exp_out"; exit 1; } + +echo "PASS: bare Windows — fallback, persistence, self-contained exe, explicit choice respected" diff --git a/tests/e2e/run_all.sh b/tests/e2e/run_all.sh index f4147038..5f10700e 100755 --- a/tests/e2e/run_all.sh +++ b/tests/e2e/run_all.sh @@ -86,6 +86,14 @@ case "$OS" in -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 \ -property installationPath 2>/dev/null | grep -q .; then CAPS+=(msvc) + else + # no-msvc: the bare-Windows shape — a machine with no usable + # Visual Studio. Declared as its own capability rather than + # inferred from "not msvc" because a test needs to REQUIRE it: + # 182 verifies the fallback path, and running it on a box that + # does have MSVC would exercise the ordinary path and pass while + # proving nothing. The CI job that masks Visual Studio lands here. + CAPS+=(no-msvc) fi # NOTE: Windows runners may have g++.exe (MinGW/Strawberry) in PATH # but it's not a proper mcpp-compatible GCC. Don't add gcc capability. From 93b88b319759c6fbf2f86927c7ae259903b7a4c8 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sun, 2 Aug 2026 02:23:19 +0800 Subject: [PATCH 07/14] fix(build): never auto-repair a toolchain the user selected as msvc@system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mcpp never picks msvc@system itself — it cannot install one — so the only way it reaches config.toml is a user running `mcpp toolchain default msvc`. Under the origin rules that landed as GlobalDefault, i.e. repairable, which would have silently moved a user who evidently wants MSVC (and is most likely just missing the SDK component) onto MinGW instead of naming the component to install. Also records the implementation findings in the plan doc — the ones the plan could not have predicted, notably that the raw argv[0] was deliberate, that the Windows branch of command_from_argv is not compiled on the platforms mcpp is developed on, and that Directives is cache-serialized. --- ...02-windows-usability-implementation-plan.md | 14 ++++++++++++++ src/build/prepare.cppm | 18 +++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/.agents/docs/2026-08-02-windows-usability-implementation-plan.md b/.agents/docs/2026-08-02-windows-usability-implementation-plan.md index 45cf5ba4..0078cc96 100644 --- a/.agents/docs/2026-08-02-windows-usability-implementation-plan.md +++ b/.agents/docs/2026-08-02-windows-usability-implementation-plan.md @@ -1000,6 +1000,20 @@ token 走 `~/.config/gitcode-tool/config.json` 而非环境变量;沙箱 wrapper --- +## 实施记录(执行中发现、计划里没写的东西) + +| 发现 | 影响 | +|---|---| +| **`command_from_argv` 的裸 argv[0] 是故意的** —— 注释写明引号会被 `cmd /c` 剥掉 | 真修法不在 `command_from_argv` 一处:要按 cmd.exe `/c` 的文档规则给整条命令**再包一层外引号**,让 cmd 吃掉它,内层引号才能抵达。`run_exec` 必须只包不封 stdin(`mcpp run` 要交互) | +| **`command_from_argv` 只在非 Linux/macOS 分支编译** | Linux 上测不到。把 Windows 的字符串成形抽成宿主无关的 `windows_command_from_argv` / `windows_wrap_for_cmd_c`,Linux CI 也能守住这条规则 —— 这个分支在开发平台上根本不编译,正是裸 argv[0] 活这么久的原因 | +| **`local_include_flags` 的 `msvcDialect` 形参只用于 after-dirs** | 普通 include 硬编码 `-I`。收敛到 `include_token` 后 MSVC 方言下变成 `/I`,`test_ninja_backend.cpp` 里有一条断言编码的正是这个 bug,已更新 | +| **两层转义,顺序固定** | ninja 的 `$ ` 在内、shell 引号在外。写断言时必须知道文件里是 `'-I/opt/my$ dep/include'`,单测和 e2e 各踩了一次 | +| **`ensure_built` 的 `tc` 已经是宿主工具链** | `prepare.cppm` 调 `run_build_program(*m, *root, host->first, host->second, ...)`,host≠target 在调用点就闭合了,组件 D 无需自己解析 | +| **`Directives` 会被序列化进 build.mcpp 缓存** | 计划里的「产出中立字段」会改缓存格式。改为在 parse 时按方言翻译 —— 缓存键已含 `compiler `,换工具链自动失效,所以安全且零格式变更 | +| **`mcpp toolchain default msvc` 也写 `config.toml`** | 与 mcpp 自己持久化的默认同源,会被误判成可改写。收紧:`tc->compiler == MSVC` 一律视为用户显式 —— mcpp 从不自选 msvc@system | +| **`[build] linkage` 根本不被解析** | `toml.cppm:995` 只在 `[target.]` 下读。文档三处 + `prepare.cppm` 一处注释都在教一个静默失效的键,已改 | +| **`no-msvc` 需要是一个显式能力** | 不能由「非 msvc」推出:e2e 182 必须 REQUIRE 它,否则在有 MSVC 的机器上会走普通路径并通过,证明不了任何事 | + ## Self-Review **Spec coverage** diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 030ed8b7..4eaa2462 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -1473,7 +1473,7 @@ prepare_build(bool print_fingerprint, if (windowsGnuFirstRun && tcSpec.has_value()) { mcpp::ui::info("First run", std::format("no toolchain configured and no Visual Studio found — " - "installing {} for {} (MinGW-w64, self-contained)", + "using {} for {} (MinGW-w64, self-contained)", *tcSpec, overrides.target_triple)); if (auto cfgW = get_cfg(); cfgW) { if (mcpp::config::write_default_toolchain(**cfgW, *tcSpec)) @@ -1503,8 +1503,24 @@ prepare_build(bool print_fingerprint, tc->compiler == mcpp::toolchain::CompilerId::MSVC || mcpp::toolchain::is_msvc_target(*tc); if (targetsMsvcAbi && !mcpp::toolchain::msvc::has_usable_msvc()) { + // Native cl.exe is ALWAYS a deliberate choice: mcpp never selects + // msvc@system on its own — it cannot install one — so the only way it + // reaches config.toml is a user typing `mcpp toolchain default msvc`. + // Without this, that user (who evidently wants MSVC and is probably + // just missing the SDK component) would be silently moved to MinGW + // instead of being told which component to install. + // + // The residual imprecision is deliberate and bounded: a *global* + // default of llvm@20.1.7 is indistinguishable from the one mcpp used + // to write itself, so an explicitly-typed one gets repaired too. The + // value is identical either way and the machine cannot build with it; + // a user who wants that failure can pin it in mcpp.toml, which is + // honoured exactly. + const bool userChoseMsvcItself = + tc->compiler == mcpp::toolchain::CompilerId::MSVC; const bool mayRepair = !tc_origin_is_user_explicit(tcOrigin) + && !userChoseMsvcItself && !mcpp::platform::env::offline_mode() && !mcpp::platform::env::no_auto_install() && mcpp::platform::is_windows; From c5e2aee82347e51311d5f1d06b1061a7581b03f6 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sun, 2 Aug 2026 02:30:55 +0800 Subject: [PATCH 08/14] ci(windows): validate the bare-Windows path on every PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ci-fresh-install only runs post-release, so the fix this PR exists for — a stock Windows machine building without setup — had no pre-merge coverage. Learning that it broke after a release is far too late. mcpp is built while Visual Studio is still present (the self-host build uses llvm, which targets the MSVC ABI and needs it) and VS is masked only afterwards. e2e 182 opens by asserting MSVC detection FAILS, so an incomplete mask fails the job rather than silently exercising the ordinary path. --- .github/workflows/ci-windows.yml | 62 ++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index 269cbfe0..36961228 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -116,6 +116,68 @@ jobs: # Windows-specific behavioural regressions. Kept on one runner: each leg is # seconds-to-2-minutes, so per-leg runners would cost more setup than they # save. + # A Windows machine WITHOUT Visual Studio — the shape of an ordinary user's + # box, and the one shape no GitHub image provides. Every runner ships VS, so + # a bare-Windows regression was structurally invisible here; the fresh-install + # workflow now covers it too, but that one only runs post-release, which is + # far too late to learn that `mcpp new && mcpp build` no longer works on a + # stock machine. + # + # Order matters: mcpp is built while Visual Studio is still present (the + # self-host build uses llvm, which targets the MSVC ABI and needs it), and + # only then is VS masked. e2e 182 opens by asserting that MSVC detection + # FAILS, so an incomplete mask fails the job instead of quietly testing the + # ordinary path. + no-msvc-fallback: + name: "bare Windows: no Visual Studio (windows x64)" + runs-on: windows-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/bootstrap-mcpp + + - name: Build mcpp from source (self-host, VS still present) + shell: bash + run: | + export MCPP_VENDORED_XLINGS="$XLINGS_BIN" + "$MCPP" build + # Newest by mtime — target/ is cache-restored and keeps a directory + # per build fingerprint, so `find | head -1` can hand back the + # PREVIOUS release's binary after a version bump. + MCPP_SELF=$(find target -name "mcpp.exe" -path "*/bin/*" -printf "%T@ %p\n" \ + | sort -rn | head -1 | cut -d" " -f2-) + test -n "$MCPP_SELF" || { echo "FAIL: no mcpp.exe"; exit 1; } + MCPP_SELF=$(cd "$(dirname "$MCPP_SELF")" && pwd)/$(basename "$MCPP_SELF") + echo "MCPP_SELF=$MCPP_SELF" >> "$GITHUB_ENV" + + - name: Mask Visual Studio + shell: pwsh + run: | + # All three of msvc.cppm's discovery strategies at once: vswhere, + # the install roots, and the environment. The runner is disposable, + # so renaming in place is both safe and closer to "absent" than any + # env-only trick. + $vswhere = "C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" + if (Test-Path $vswhere) { Rename-Item $vswhere "vswhere.exe.masked" } + + Get-ChildItem "C:\Program Files*\Microsoft Visual Studio" -Directory ` + -ErrorAction SilentlyContinue | ForEach-Object { + $masked = "$($_.FullName).masked" + if (-not (Test-Path $masked)) { Rename-Item $_.FullName $masked } + } + + foreach ($v in @('VSINSTALLDIR','VCINSTALLDIR','VCToolsInstallDir', + 'VS170COMNTOOLS','VS160COMNTOOLS','VS150COMNTOOLS')) { + "$v=" | Out-File -Append -FilePath $env:GITHUB_ENV -Encoding utf8 + } + + - name: "No Visual Studio: fallback to winlibs GCC (e2e 182)" + shell: bash + env: + MCPP_VENDORED_XLINGS: ${{ env.XLINGS_BIN }} + run: | + MCPP="$MCPP_SELF" bash tests/e2e/182_windows_no_msvc_fallback.sh + toolchains: name: "toolchains + regressions (windows x64)" runs-on: windows-latest From 3345242ec700eb6139e5814b45ad0039ada1834b Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sun, 2 Aug 2026 02:43:09 +0800 Subject: [PATCH 09/14] fix(build): keep per-TU include paths forward-slashed for the response file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows CI caught a regression the Linux run could not: every dependency header went missing (`'gtest/gtest.h' file not found`), with nothing in the error pointing at the include flag. Converging both include channels on one helper also converged their path form, and they legitimately differ. On Windows ninja copies $local_includes into a RESPONSE FILE (#261), which the drivers tokenize GNU-style — a backslash there is an ESCAPE character, inside quotes as well — so `C:\src\inc` loses its separators. The comment in ninja_backend.cppm says exactly this; the shared helper quietly stopped honouring it by using the native-separator escaper. include_token now takes the path form explicitly, so the difference is a stated parameter rather than an accident of which escaper a caller reached for. escape_ninja_chars takes text instead of a path for the same reason: round-tripping through std::filesystem::path re-normalizes separators on Windows and would silently undo a deliberate generic_string(). The regression test is guarded to Windows: POSIX treats '\' as an ordinary filename character, so the assertion cannot be made from a Linux host. A test that passes for the wrong reason everywhere would be worse than one that says where it applies. Also fixes the VS masking step, which failed with 'Access to the path C:\Program Files\Microsoft Visual Studio is denied' — the root is held open on the runner. All three of msvc.cppm's discovery strategies converge on \VC\Tools\MSVC, so masking VC one level down works and is equally complete. The step now verifies its own postcondition instead of leaving an incomplete mask to surface later as a confusing pass. --- .github/workflows/ci-fresh-install.yml | 29 ++++++++++++++----- .github/workflows/ci-windows.yml | 33 ++++++++++++++++------ src/build/flags.cppm | 39 ++++++++++++++++++++------ src/build/ninja_backend.cppm | 12 ++++++-- tests/unit/test_ninja_backend.cpp | 36 ++++++++++++++++++++++++ 5 files changed, 124 insertions(+), 25 deletions(-) diff --git a/.github/workflows/ci-fresh-install.yml b/.github/workflows/ci-fresh-install.yml index 60a1ef72..de6be6f1 100644 --- a/.github/workflows/ci-fresh-install.yml +++ b/.github/workflows/ci-fresh-install.yml @@ -480,16 +480,20 @@ jobs: - name: Mask Visual Studio shell: pwsh run: | - # All three discovery strategies at once. The runner is disposable, - # so renaming in place is fine and is closer to "absent" than any - # env-only trick would be. + $ErrorActionPreference = 'Continue' + + # All three of msvc.cppm's discovery strategies converge on + # \VC\Tools\MSVC, so mask the VC directory rather than the + # Visual Studio root: the root is held open on the runner and + # renaming it is denied, while VC one level down renames fine. + # The runner is disposable, so this is both safe and closer to + # "absent" than any env-only trick would be. $vswhere = "C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" if (Test-Path $vswhere) { Rename-Item $vswhere "vswhere.exe.masked" } - Get-ChildItem "C:\Program Files*\Microsoft Visual Studio" -Directory ` - -ErrorAction SilentlyContinue | ForEach-Object { - $masked = "$($_.FullName).masked" - if (-not (Test-Path $masked)) { Rename-Item $_.FullName $masked } + Get-ChildItem "C:\Program Files*\Microsoft Visual Studio\*\*\VC" ` + -Directory -ErrorAction SilentlyContinue | ForEach-Object { + Rename-Item $_.FullName "$($_.Name).masked" -ErrorAction SilentlyContinue } foreach ($v in @('VSINSTALLDIR','VCINSTALLDIR','VCToolsInstallDir', @@ -497,6 +501,17 @@ jobs: "$v=" | Out-File -Append -FilePath $env:GITHUB_ENV -Encoding utf8 } + # Check the mask's own postcondition here, where the cause is + # obvious, rather than letting it surface later as a confusing pass. + $left = Get-ChildItem "C:\Program Files*\Microsoft Visual Studio\*\*\VC\Tools\MSVC" ` + -Directory -ErrorAction SilentlyContinue + if ($left) { + Write-Host "FAIL: VC tools still present after masking:" + $left | ForEach-Object { Write-Host " $($_.FullName)" } + exit 1 + } + Write-Host "Visual Studio masked: no VC\Tools\MSVC remains." + - name: Install xlings shell: pwsh env: diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index 36961228..4a33ab21 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -153,17 +153,22 @@ jobs: - name: Mask Visual Studio shell: pwsh run: | - # All three of msvc.cppm's discovery strategies at once: vswhere, - # the install roots, and the environment. The runner is disposable, - # so renaming in place is both safe and closer to "absent" than any - # env-only trick. + $ErrorActionPreference = 'Continue' + + # All three of msvc.cppm's discovery strategies converge on + # \VC\Tools\MSVC — vswhere returns an installationPath that + # find_latest_msvc_tools then resolves through it, the env strategy + # checks it explicitly, and the well-known-path scan tests for it. + # So mask the VC directory rather than the Visual Studio root: the + # root is held open on the runner and renaming it is denied, while + # VC one level down renames fine. The runner is disposable, so this + # is both safe and closer to "absent" than any env-only trick. $vswhere = "C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" if (Test-Path $vswhere) { Rename-Item $vswhere "vswhere.exe.masked" } - Get-ChildItem "C:\Program Files*\Microsoft Visual Studio" -Directory ` - -ErrorAction SilentlyContinue | ForEach-Object { - $masked = "$($_.FullName).masked" - if (-not (Test-Path $masked)) { Rename-Item $_.FullName $masked } + Get-ChildItem "C:\Program Files*\Microsoft Visual Studio\*\*\VC" ` + -Directory -ErrorAction SilentlyContinue | ForEach-Object { + Rename-Item $_.FullName "$($_.Name).masked" -ErrorAction SilentlyContinue } foreach ($v in @('VSINSTALLDIR','VCINSTALLDIR','VCToolsInstallDir', @@ -171,6 +176,18 @@ jobs: "$v=" | Out-File -Append -FilePath $env:GITHUB_ENV -Encoding utf8 } + # Check the mask's own postcondition here, where the cause is + # obvious, instead of letting it surface three steps later as a + # confusing pass. + $left = Get-ChildItem "C:\Program Files*\Microsoft Visual Studio\*\*\VC\Tools\MSVC" ` + -Directory -ErrorAction SilentlyContinue + if ($left) { + Write-Host "FAIL: VC tools still present after masking:" + $left | ForEach-Object { Write-Host " $($_.FullName)" } + exit 1 + } + Write-Host "Visual Studio masked: no VC\Tools\MSVC remains." + - name: "No Visual Studio: fallback to winlibs GCC (e2e 182)" shell: bash env: diff --git a/src/build/flags.cppm b/src/build/flags.cppm index 3a584194..9ad2003a 100644 --- a/src/build/flags.cppm +++ b/src/build/flags.cppm @@ -98,9 +98,22 @@ std::string shell_quote_arg(std::string_view arg); // different flag for the same kind of path (`-idirafter` for #249's // after-dirs, plain `-I` for NASM units which would parse `-idirafter

` as // `-i dirafter

`). +// +// `form` picks the separator, and the two channels genuinely need different +// ones (#261): tokens that stay on the command line keep native separators, +// while tokens ninja copies into a RESPONSE FILE must be forward-slashed, +// because the drivers tokenize response files GNU-style — there a backslash +// is an ESCAPE character and `C:\src\inc` loses its separators. Quoting +// alone does not save it; the escape happens inside quotes too. +enum class PathForm { + Native, // command line — a backslash is just a character + Generic, // response file — forward slashes, see above +}; + std::string include_token(const mcpp::toolchain::CommandDialect& d, const std::filesystem::path& dir, - std::string_view prefixOverride = {}); + std::string_view prefixOverride = {}, + PathForm form = PathForm::Native); } // namespace mcpp::build @@ -112,9 +125,11 @@ std::filesystem::path staged_std_bmi_path(const BuildPlan& plan) { return mcpp::toolchain::staged_std_bmi_path(plan.toolchain, plan.outputDir); } -// Escape a path for embedding in ninja rule strings. -std::string escape_path(const std::filesystem::path& p) { - auto s = p.string(); +// Escape a string for embedding in ninja rule strings. Takes the text, not a +// path: round-tripping through std::filesystem::path would re-normalize the +// separators on Windows, which silently undoes a caller that deliberately +// chose generic_string() for a response-file token (#261). +std::string escape_ninja_chars(std::string_view s) { std::string out; out.reserve(s.size()); for (char c : s) { @@ -125,6 +140,11 @@ std::string escape_path(const std::filesystem::path& p) { return out; } +// Escape a path for embedding in ninja rule strings (native separators). +std::string escape_path(const std::filesystem::path& p) { + return escape_ninja_chars(p.string()); +} + std::string normalize_ldflag(const std::filesystem::path& root, const std::string& flag) { auto absolute_path = [&](std::string_view raw) { std::filesystem::path p{std::string(raw)}; @@ -161,15 +181,18 @@ std::string atomic_link_flag(const std::vector& linkDirs, std::string include_token(const mcpp::toolchain::CommandDialect& d, const std::filesystem::path& dir, - std::string_view prefixOverride) { + std::string_view prefixOverride, + PathForm form) { std::string_view prefix = prefixOverride.empty() ? d.includePrefix : prefixOverride; + std::string path = form == PathForm::Generic ? dir.generic_string() + : dir.string(); // Prefix first, then escape+quote the whole token: the prefix and the // path are ONE argv word, so quoting them separately would put the // opening quote in the wrong place and re-split exactly what we came to - // join. - return shell_quote_arg( - escape_path(std::filesystem::path(std::string(prefix) + dir.string()))); + // join. `escape_path` only adds ninja's `$` escapes and never touches + // separators, so the form chosen above survives it. + return shell_quote_arg(escape_ninja_chars(std::string(prefix) + path)); } std::string shell_quote_arg(std::string_view arg) { diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index 3af3f7be..b951f85b 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -117,8 +117,15 @@ std::string local_include_flags(const CompileUnit& cu, // derivations, and a directory with a space in it split into // separate shell words on this path only. Both channels now go // through mcpp::build::include_token. + // + // Generic form is REQUIRED here and only here: on Windows ninja + // copies $local_includes into a response file (#261), which the + // drivers tokenize GNU-style — a backslash there is an escape + // character, inside quotes as well, so `C:\src\inc` would lose its + // separators and every dependency header would go missing. flags += ' '; - flags += mcpp::build::include_token(d, inc); + flags += mcpp::build::include_token(d, inc, {}, + mcpp::build::PathForm::Generic); } // #249: after-dirs are searched AFTER the toolchain's system dirs // (-idirafter, gcc+clang), so a dep source root that contains a file @@ -137,7 +144,8 @@ std::string local_include_flags(const CompileUnit& cu, std::string_view pfx = nasmUnit ? "-I" : (msvcDialect ? "/I" : "-idirafter"); flags += ' '; - flags += mcpp::build::include_token(d, inc, pfx); + flags += mcpp::build::include_token(d, inc, pfx, + mcpp::build::PathForm::Generic); } return flags; } diff --git a/tests/unit/test_ninja_backend.cpp b/tests/unit/test_ninja_backend.cpp index 2b861f7c..8fe96cb8 100644 --- a/tests/unit/test_ninja_backend.cpp +++ b/tests/unit/test_ninja_backend.cpp @@ -6,6 +6,7 @@ import mcpp.build.flags; import mcpp.build.ninja; import mcpp.build.plan; import mcpp.manifest; +import mcpp.toolchain.dialect; import mcpp.toolchain.model; import mcpp.platform; @@ -269,6 +270,41 @@ TEST(NinjaBackend, LocalIncludeDirsWithSpacesAreShellQuoted) { EXPECT_NE(line.find("'-idirafter/opt/my$ dep/after'"), std::string::npos) << line; } +// #261: on Windows $local_includes is copied into a RESPONSE FILE, which the +// drivers tokenize GNU-style — a backslash is an escape character there, and +// quoting does not exempt it. A native-separator token like C:\src\inc loses +// its separators and every dependency header goes missing, with nothing in +// the error pointing at the include flag. +// +// The separator distinction is real only on Windows: POSIX treats '\' as an +// ordinary filename character, so generic_string() leaves it alone and this +// assertion cannot be made from a Linux host. Guarded rather than weakened — +// a test that passes for the wrong reason everywhere is worse than one that +// says where it applies. Windows CI is the enforcement point. +TEST(NinjaBackend, LocalIncludeTokensUseGenericSeparators) { + const auto& gnu = mcpp::toolchain::gnu_dialect(); + auto sub = std::filesystem::path("src") / "inc"; + auto tok = mcpp::build::include_token(gnu, sub, {}, + mcpp::build::PathForm::Generic); + // True on every host: the generic form never uses the native separator. + EXPECT_NE(tok.find("src/inc"), std::string::npos) << tok; + + if constexpr (mcpp::platform::is_windows) { + auto abs = mcpp::build::include_token( + gnu, std::filesystem::path("C:\\src\\inc"), {}, + mcpp::build::PathForm::Generic); + EXPECT_EQ(abs.find('\\'), std::string::npos) << abs; + + // The command-line channel keeps native separators — a backslash is + // just a character there, and rewriting those paths would be a change + // nobody asked for. + auto native = mcpp::build::include_token( + gnu, std::filesystem::path("C:\\src\\inc"), {}, + mcpp::build::PathForm::Native); + EXPECT_NE(native.find('\\'), std::string::npos) << native; + } +} + // #249 NASM degradation: nasm_object edges share $local_includes, but NASM // would parse `-idirafter

` as its `-i` option with value `dirafter

` — // a silently wrong search dir. Only the C/C++ frontends have a system-header From 9f706cd0dd1cdcfaccd571471162a88c2f4ab480 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sun, 2 Aug 2026 03:33:27 +0800 Subject: [PATCH 10/14] fix(toolchain): store multi-token dialect flags as tokens, not a string to split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS CI segfaulted on every build.mcpp test. The crash was inside `contract_env` — a function this branch never touched — corrupting a local vector before the compile even started, and it reproduced only under clang/libc++, never under GCC. Bisecting with a clean target and dep cache each round (a first attempt was worthless: switching mcpp.toml's toolchain without clearing the dependency cache links a libstdc++-built mcpplibs.cmdline into a libc++ mcpp, which crashes for an entirely unrelated reason) narrowed it to a single addition: the `split_ws` helper. Not a call to it — its mere PRESENCE in the anonymous namespace. Deleting it fixed the crash; a trivial unused function in the same spot did not cause one. That is a clang codegen problem, not a logic error, and no amount of reading the diff would have found it. The fix is also the better design. `split_ws` only existed because the dialect table stored "-x c++" and "/nologo /EHsc /utf-8" as ninja-command strings while the build.mcpp path needs argv tokens — so the token boundary was being re-derived at the call site. The table now carries both forms, the argv one as a span over a static array, and the spelling stays in one row. Verified under BOTH toolchains from a clean target and cache: clang 22.1.8 (e2e 89 / 92 / 179 / 181 all pass, previously all segfaults) and GCC 16.1.0 (unit 49/49). --- src/build/build_program.cppm | 20 ++------------------ src/toolchain/dialect.cppm | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/src/build/build_program.cppm b/src/build/build_program.cppm index ddf31212..cc059651 100644 --- a/src/build/build_program.cppm +++ b/src/build/build_program.cppm @@ -104,22 +104,6 @@ struct Directives { std::vector rerunEnv; // declared env-var inputs }; -// Split a whitespace-separated flag string into argv tokens. The dialect -// table stores some entries as multi-token strings ("-x c++", -// "/nologo /EHsc /utf-8") because their other consumer is a ninja command -// line, where a single string is what's wanted; an argv vector is not. -std::vector split_ws(std::string_view s) { - std::vector out; - std::size_t i = 0; - while (i < s.size()) { - while (i < s.size() && (s[i] == ' ' || s[i] == '\t')) ++i; - std::size_t b = i; - while (i < s.size() && s[i] != ' ' && s[i] != '\t') ++i; - if (i > b) out.emplace_back(s.substr(b, i - b)); - } - return out; -} - std::string trim(std::string_view s) { std::size_t b = 0, e = s.size(); while (b < e && (s[b] == ' ' || s[b] == '\t' || s[b] == '\r')) ++b; @@ -859,7 +843,7 @@ std::expected run_build_program( if (msvcHost) { // /nologo /EHsc /utf-8 — cl.exe needs these to behave like the other // two drivers do by default (quiet, exceptions on, UTF-8 sources). - for (auto& f : split_ws(dial.alwaysFlags)) compileArgv.push_back(f); + for (auto f : dial.alwaysFlagsArgv) compileArgv.emplace_back(f); } compileArgv.push_back(std_flag); // No optimization: this program runs once per build and its compile time @@ -871,7 +855,7 @@ std::expected run_build_program( for (auto& sf : stdFlags) compileArgv.push_back(sf); // The `.mcpp` extension is unknown to every driver, so without this the // file is handed to the linker as a linker script. - for (auto& f : split_ws(dial.forceCxxLang)) compileArgv.push_back(f); + for (auto f : dial.forceCxxLangArgv) compileArgv.emplace_back(f); compileArgv.push_back(src.string()); if (usesModule || !stdObjects.empty()) { // Link the module objects (GNU: reset the input language first so the diff --git a/src/toolchain/dialect.cppm b/src/toolchain/dialect.cppm index 1d7b80eb..41c05345 100644 --- a/src/toolchain/dialect.cppm +++ b/src/toolchain/dialect.cppm @@ -32,6 +32,9 @@ struct CommandDialect { std::string_view optPrefix; // "-O" | "/O" std::string_view debugFlags; // "-g" | "/Zi /FS" std::string_view alwaysFlags; // "" | "/nologo /EHsc /utf-8" + // Same rationale as forceCxxLangArgv: the argv consumer gets tokens, not + // a string it has to split. Both spans point at static arrays below. + std::span alwaysFlagsArgv; // Link and language-selection spellings. // @@ -42,7 +45,15 @@ struct CommandDialect { std::string_view libSearchPrefix; // "-L" | "/LIBPATH:" // The `.mcpp` extension is unknown to every compiler driver, so the // language has to be forced or the driver hands the file to the linker. + // + // Two forms of the same thing, because the two consumers need different + // shapes and neither should re-derive the other's: a ninja command line + // wants one string, an argv vector wants tokens. Storing both keeps the + // spelling in one row — splitting the string at the call site would put + // the token boundary in a second place, and cost a helper this file is + // better off without (see the note on `alwaysFlagsArgv`). std::string_view forceCxxLang; // "-x c++" | "/TP" + std::span forceCxxLangArgv; // Static CRT / runtime. On MSVC this is a compile-time CRT model, not a // link mode — there is no /MT equivalent of `-static` for the whole image. std::string_view staticRuntime; // "-static"| "/MT" @@ -95,6 +106,12 @@ namespace mcpp::toolchain { namespace { +// Token forms of the multi-token rows. Static arrays so the spans above are +// constexpr-initializable and no consumer has to split a string at runtime. +constexpr std::string_view kGnuForceCxxArgv[] = {"-x", "c++"}; +constexpr std::string_view kMsvcForceCxxArgv[] = {"/TP"}; +constexpr std::string_view kMsvcAlwaysArgv[] = {"/nologo", "/EHsc", "/utf-8"}; + constexpr CommandDialect kGnuDialect{ .id = "gnu", .includePrefix = "-I", @@ -105,9 +122,11 @@ constexpr CommandDialect kGnuDialect{ .optPrefix = "-O", .debugFlags = "-g", .alwaysFlags = "", + .alwaysFlagsArgv = {}, .libFlag = "-l{}", .libSearchPrefix = "-L", .forceCxxLang = "-x c++", + .forceCxxLangArgv = kGnuForceCxxArgv, .staticRuntime = "-static", .outputExePrefix = "-o ", .objExt = ".o", @@ -130,9 +149,11 @@ constexpr CommandDialect kMsvcDialect{ .optPrefix = "/O", .debugFlags = "/Zi /FS", .alwaysFlags = "/nologo /EHsc /utf-8", + .alwaysFlagsArgv = kMsvcAlwaysArgv, .libFlag = "{}.lib", .libSearchPrefix = "/LIBPATH:", .forceCxxLang = "/TP", + .forceCxxLangArgv = kMsvcForceCxxArgv, .staticRuntime = "/MT", .outputExePrefix = "/Fe:", .objExt = ".obj", From 222cfe4d6695f045b455e9bb3b9b4f2dab0ff4ea Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sun, 2 Aug 2026 03:52:50 +0800 Subject: [PATCH 11/14] fix(build,ci): BMI flags quote their paths; build.mcpp matches the std BMI's deployment target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both found by macOS CI, both clang-only, both real. A BMI flag and its path are one shell word, but only the include tokens were being quoted — so a project under `/Users/me/my work dir/…` handed the shell `-fmodule-file=std=/Users/me/my`, `work`, `dir/…` and died on "no such file or directory: 'work'", with nothing in the error naming the flag that split. GCC never showed it: its BmiTraits leave these prefixes empty. The prefixes carry a leading space for this string channel and MSVC's is itself two words (`/reference std=`), so the split is at the last space and only the value gets quoted. `import std;` in build.mcpp then failed on macOS with "compiled for the target 'arm64-apple-macosx14.0.0' but the current translation unit is being compiled for …": ensure_built was told the deployment target, the compile that consumes the BMI was not, and clang rejects the mismatch. The main build makes the value explicit on every TU for exactly this reason; host_base_flags contributes nothing on macOS because it trusts the clang cfg. The bare-Windows job no longer builds mcpp itself — it takes build-test's artifact. Compiling with clang reads the MSVC STL, and the handles that leaves make the VS directories unrenamable, so the masking silently did nothing. Two more mask bugs the postcondition check surfaced: a trailing wildcard segment in -Path lists a directory's CONTENTS rather than the directory, and the rename errors were being swallowed. Errors are now reported. --- .github/workflows/ci-windows.yml | 46 ++++++++++++++++++++++---------- src/build/build_program.cppm | 20 +++++++++++--- src/build/flags.cppm | 27 ++++++++++++++----- 3 files changed, 70 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index 4a33ab21..73fb3e86 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -130,26 +130,36 @@ jobs: # ordinary path. no-msvc-fallback: name: "bare Windows: no Visual Studio (windows x64)" + # Takes the binary build-test already produced instead of building here. + # Building in this job means compiling with clang, which reads the MSVC + # STL — the open handles that leaves make the VS directories unrenamable, + # so the masking below silently did nothing. + needs: build-test runs-on: windows-latest - timeout-minutes: 45 + timeout-minutes: 30 steps: - uses: actions/checkout@v4 - - uses: ./.github/actions/bootstrap-mcpp - - name: Build mcpp from source (self-host, VS still present) + - name: Fetch the PR's mcpp.exe + uses: actions/download-artifact@v4 + with: + name: mcpp-windows-x86_64 + path: dist + + - name: Unpack it shell: bash run: | - export MCPP_VENDORED_XLINGS="$XLINGS_BIN" - "$MCPP" build - # Newest by mtime — target/ is cache-restored and keeps a directory - # per build fingerprint, so `find | head -1` can hand back the - # PREVIOUS release's binary after a version bump. - MCPP_SELF=$(find target -name "mcpp.exe" -path "*/bin/*" -printf "%T@ %p\n" \ - | sort -rn | head -1 | cut -d" " -f2-) - test -n "$MCPP_SELF" || { echo "FAIL: no mcpp.exe"; exit 1; } + ZIP=$(ls dist/*.zip | head -1) + test -n "$ZIP" || { echo "FAIL: no zip artifact"; exit 1; } + unzip -q "$ZIP" -d unpacked + MCPP_SELF=$(find unpacked -name "mcpp.exe" | head -1) + test -n "$MCPP_SELF" || { echo "FAIL: no mcpp.exe in $ZIP"; exit 1; } MCPP_SELF=$(cd "$(dirname "$MCPP_SELF")" && pwd)/$(basename "$MCPP_SELF") + "$MCPP_SELF" --version echo "MCPP_SELF=$MCPP_SELF" >> "$GITHUB_ENV" + # Masked before anything else touches Visual Studio, so no process of + # ours is holding a handle into it. - name: Mask Visual Studio shell: pwsh run: | @@ -166,9 +176,17 @@ jobs: $vswhere = "C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" if (Test-Path $vswhere) { Rename-Item $vswhere "vswhere.exe.masked" } - Get-ChildItem "C:\Program Files*\Microsoft Visual Studio\*\*\VC" ` - -Directory -ErrorAction SilentlyContinue | ForEach-Object { - Rename-Item $_.FullName "$($_.Name).masked" -ErrorAction SilentlyContinue + # -Path with a trailing wildcard segment lists the CONTENTS of the + # matches, not the matches themselves, so `…\*\*\VC` would hand back + # VC's children. Resolve-Path returns the directories themselves. + # Errors are reported, not swallowed: a silent failure here is how + # the first attempt "masked" nothing and still looked fine. + Resolve-Path "C:\Program Files*\Microsoft Visual Studio\*\*\VC" ` + -ErrorAction SilentlyContinue | ForEach-Object { + $p = $_.Path + Write-Host "masking $p" + try { Rename-Item -LiteralPath $p -NewName "VC.masked" -ErrorAction Stop } + catch { Write-Host " rename failed: $($_.Exception.Message)" } } foreach ($v in @('VSINSTALLDIR','VCINSTALLDIR','VCToolsInstallDir', diff --git a/src/build/build_program.cppm b/src/build/build_program.cppm index cc059651..01b6a912 100644 --- a/src/build/build_program.cppm +++ b/src/build/build_program.cppm @@ -781,16 +781,30 @@ std::expected run_build_program( " Use #include in build.mcpp, or switch to a toolchain " "that provides one.", tc.label())); } - auto sm = mcpp::toolchain::ensure_built( - tc, cppStandard.canonical, std_flag, + const std::string macosDeploymentTarget = mcpp::platform::macos::deployment_target( - m.buildConfig.macosDeploymentTarget)); + m.buildConfig.macosDeploymentTarget); + auto sm = mcpp::toolchain::ensure_built( + tc, cppStandard.canonical, std_flag, macosDeploymentTarget); if (!sm) { return std::unexpected(std::format( "build.mcpp uses `import std;` but the std module could not be " "built for the host toolchain: {}", sm.error().message)); } + // The std BMI was built FOR a deployment target, and clang refuses to + // load a module built for a different one ("compiled for the target + // 'arm64-apple-macosx14.0.0' but the current translation unit is + // being compiled for …"). The main build makes the value explicit on + // every TU for exactly this reason (flags.cppm); the build.mcpp + // compile has to say the same thing or the BMI it just asked for is + // rejected. host_base_flags contributes nothing here — on macOS it + // trusts the clang cfg and returns empty. + if constexpr (mcpp::platform::is_macos) { + if (!macosDeploymentTarget.empty()) + stdFlags.push_back("-mmacosx-version-min=" + macosDeploymentTarget); + } + auto traits = mcpp::toolchain::bmi_traits(tc); if (traits.stdBmiUsePrefix.empty()) { // GCC: BMIs are found implicitly under /gcm.cache, so stage diff --git a/src/build/flags.cppm b/src/build/flags.cppm index 9ad2003a..211d5247 100644 --- a/src/build/flags.cppm +++ b/src/build/flags.cppm @@ -411,17 +411,32 @@ CompileFlags compute_flags(const BuildPlan& plan) { // /reference//ifcSearchDir). auto traits = mcpp::toolchain::bmi_traits(plan.toolchain); std::string module_flag{traits.compileModulesFlag}; + // A BMI flag and its path are ONE shell word, so the quotes have to wrap + // both — a build under `/Users/me/my work dir/…` otherwise hands the + // shell `-fmodule-file=std=/Users/me/my`, `work`, `dir/…` and the compile + // dies on "no such file or directory: 'work'" with nothing naming the + // flag that split. The BmiTraits prefixes carry a leading space for this + // string channel, and MSVC's is itself two words (`/reference std=`), so + // split at the LAST space: everything before it stays outside the quotes. + auto bmi_flag = [](std::string_view prefix, const std::filesystem::path& p) { + auto sp = prefix.find_last_of(' '); + std::string_view lead = sp == std::string_view::npos + ? std::string_view{} : prefix.substr(0, sp + 1); + std::string_view body = sp == std::string_view::npos + ? prefix : prefix.substr(sp + 1); + return std::string(lead) + + shell_quote_arg(escape_ninja_chars(std::string(body) + p.string())); + }; std::string std_module_flag; if (!traits.stdBmiUsePrefix.empty() && !plan.stdBmiPath.empty()) { - std_module_flag = std::string(traits.stdBmiUsePrefix) - + escape_path(staged_std_bmi_path(plan)); + std_module_flag = bmi_flag(traits.stdBmiUsePrefix, + staged_std_bmi_path(plan)); } std::string std_compat_module_flag; if (!traits.stdCompatBmiUsePrefix.empty() && !plan.stdCompatBmiPath.empty()) { auto compatDst = mcpp::toolchain::staged_std_compat_bmi_path( plan.toolchain, plan.outputDir); - std_compat_module_flag = std::string(traits.stdCompatBmiUsePrefix) - + escape_path(compatDst); + std_compat_module_flag = bmi_flag(traits.stdCompatBmiUsePrefix, compatDst); } std::string prebuilt_module_flag; if (traits.needsPrebuiltModulePath) { @@ -434,8 +449,8 @@ CompileFlags compute_flags(const BuildPlan& plan) { // resolution fails with `module 'X' not found`. The other // `-fmodule-file=` flags in this block are already escape_path'd // (absolute) for the same reason — this one was a leftover. - prebuilt_module_flag = std::string(traits.bmiSearchPrefix) - + escape_path(plan.outputDir / traits.bmiDir); + prebuilt_module_flag = bmi_flag(traits.bmiSearchPrefix, + plan.outputDir / traits.bmiDir); } std::string cxx_std_flag = plan.cppStandardFlag.empty() From ab630fd6ecf7584bad0583ec2062388249e2adba Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sun, 2 Aug 2026 04:04:59 +0800 Subject: [PATCH 12/14] test: assert the host's quote character, not a fixed one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows runner failed `LocalIncludeDirsWithSpacesAreShellQuoted` for a difference that is correct: shell_quote_arg emits single quotes for POSIX sh and double for cmd.exe, and the assertion hardcoded `'`. Both the unit test and e2e 179 now take the quote character from the platform. e2e 179 also drops its `unix-shell` requirement. Paths with spaces are the Windows problem — `C:\Program Files`, an account name with a space — so skipping the test there left the platform it exists for uncovered. --- tests/e2e/179_spaced_paths.sh | 7 +++++-- tests/unit/test_ninja_backend.cpp | 9 +++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/e2e/179_spaced_paths.sh b/tests/e2e/179_spaced_paths.sh index 92a38fc1..39a49464 100755 --- a/tests/e2e/179_spaced_paths.sh +++ b/tests/e2e/179_spaced_paths.sh @@ -1,5 +1,4 @@ #!/usr/bin/env bash -# requires: unix-shell # 179_spaced_paths.sh — a project whose paths contain spaces still builds # # #331: two independent places assumed no path ever contains a space. On @@ -79,7 +78,11 @@ grep -q "vendor" "$ninja_file" || { # the shell quoting wraps the whole token (so what ninja hands to sh stays one # word). The old code had only the first, which is exactly why the path # survived ninja and then split in the shell. -grep -qE "'-I[^']*vendor\\\$ inc" "$ninja_file" || { +# The quote character is the host shell's — POSIX sh single, cmd.exe double — +# so accept either rather than pinning whichever platform this happens to run +# on. (An earlier version hardcoded `'` and failed on Windows for a difference +# that was correct.) +grep -qE "['\"]-I[^'\"]*vendor\\\$ inc" "$ninja_file" || { echo "FAIL: include dir not shell-quoted with its prefix:" grep -oE "[^ ]*vendor[^ ]*( inc[^ ]*)?" "$ninja_file" | head -3 exit 1; } diff --git a/tests/unit/test_ninja_backend.cpp b/tests/unit/test_ninja_backend.cpp index 8fe96cb8..7d0dac34 100644 --- a/tests/unit/test_ninja_backend.cpp +++ b/tests/unit/test_ninja_backend.cpp @@ -266,8 +266,13 @@ TEST(NinjaBackend, LocalIncludeDirsWithSpacesAreShellQuoted) { // only the first, which is why the path survived ninja and then split in // the shell. The prefix must be INSIDE the quotes — quoting the path // alone would leave `-I` as its own word and reintroduce the split. - EXPECT_NE(line.find("'-I/opt/my$ dep/include'"), std::string::npos) << line; - EXPECT_NE(line.find("'-idirafter/opt/my$ dep/after'"), std::string::npos) << line; + // + // The quote character is the host shell's, not a fixed one: POSIX sh + // wants single quotes, cmd.exe double. Hardcoding `'` passed on Linux + // and failed on the Windows runner for a difference that is correct. + const std::string q = mcpp::platform::is_windows ? "\"" : "'"; + EXPECT_NE(line.find(q + "-I/opt/my$ dep/include" + q), std::string::npos) << line; + EXPECT_NE(line.find(q + "-idirafter/opt/my$ dep/after" + q), std::string::npos) << line; } // #261: on Windows $local_includes is copied into a RESPONSE FILE, which the From 5c725c781d570a4dfa490dccfd0f673550826530 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sun, 2 Aug 2026 04:18:57 +0800 Subject: [PATCH 13/14] fix(build.mcpp,ci): one deployment target for every host compile; restore xlings in the no-VS job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS rejected `import std;` together with `import mcpp;`: mcpp.pcm had been built at the host default (15.0) while the compile consuming it now carried 14.0. Putting the flag on the std path alone just moved the mismatch to the other module — clang refuses either direction. The deployment target now lives in host_base_flags, which feeds every compile in this file: the bundled mcpp module's precompile, its object step, and the build.mcpp compile. One resolution, one place, and the same value goes to stdmod so the std BMI agrees too. The bare-Windows job also needs xlings back — masking Visual Studio worked (the postcondition and e2e 182's own self-check both confirm it), but dropping the build step took bootstrap-mcpp with it, so mcpp had no backend to install the winlibs toolchain through. It runs after masking, where nothing it does can hold Visual Studio open. --- .github/workflows/ci-windows.yml | 6 +++++ src/build/build_program.cppm | 41 ++++++++++++++++++-------------- 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index 73fb3e86..a1baeb52 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -206,6 +206,12 @@ jobs: } Write-Host "Visual Studio masked: no VC\Tools\MSVC remains." + # After masking, so nothing this action does can be holding Visual + # Studio open. It installs xlings (which mcpp resolves the winlibs + # toolchain through) and a released mcpp; neither needs a C++ compiler, + # so a masked VS is irrelevant to it. + - uses: ./.github/actions/bootstrap-mcpp + - name: "No Visual Studio: fallback to winlibs GCC (e2e 182)" shell: bash env: diff --git a/src/build/build_program.cppm b/src/build/build_program.cppm index 01b6a912..93c88efb 100644 --- a/src/build/build_program.cppm +++ b/src/build/build_program.cppm @@ -184,9 +184,24 @@ std::string env_value(const std::string& name) { // --target, prepare.cppm resolves the spec a second time without the target axis // (host_tc_for_build_program) and passes that here, so the native cases are the // only ones needed. Passed as separate argv tokens (no shell). -std::vector host_base_flags(const mcpp::toolchain::Toolchain& tc) { +std::vector host_base_flags(const mcpp::toolchain::Toolchain& tc, + std::string_view macosDeploymentTarget) { std::vector f; + // macOS deployment target, FIRST and unconditionally, because clang + // refuses to load a module built for a different one and this function's + // result feeds every compile in this file: the bundled `mcpp` module's + // precompile, its object step, and the build.mcpp compile itself. Putting + // it anywhere narrower produced the mismatch in whichever direction was + // left out — first the std BMI (built for 14.0) against a compile with no + // version-min, then mcpp.pcm (built at the host default 15.0) against a + // compile that had just been given 14.0. + if constexpr (mcpp::platform::is_macos) { + if (!macosDeploymentTarget.empty()) + f.push_back(std::string("-mmacosx-version-min=") + + std::string(macosDeploymentTarget)); + } + // MSVC carries none of this on the command line: cl.exe and link.exe find // headers and import libraries through INCLUDE / LIB, which detection // synthesized into tc.envOverrides. Emitting the GNU shapes below would @@ -712,7 +727,13 @@ std::expected run_build_program( tc, cppStandard.canonical.empty() ? std::string_view("c++23") : std::string_view(cppStandard.canonical), cppStandard.level); - auto base = host_base_flags(tc); + // One resolution of the deployment target, used by every compile below + // and by the std module it asks stdmod to build — they must agree or + // clang rejects the BMI. + const std::string macosDeploymentTarget = + mcpp::platform::macos::deployment_target( + m.buildConfig.macosDeploymentTarget); + auto base = host_base_flags(tc, macosDeploymentTarget); // The host compile has always been spelled in GNU driver syntax with no // dialect branch at all — `grep -i msvc` over this file used to hit only @@ -781,9 +802,6 @@ std::expected run_build_program( " Use #include in build.mcpp, or switch to a toolchain " "that provides one.", tc.label())); } - const std::string macosDeploymentTarget = - mcpp::platform::macos::deployment_target( - m.buildConfig.macosDeploymentTarget); auto sm = mcpp::toolchain::ensure_built( tc, cppStandard.canonical, std_flag, macosDeploymentTarget); if (!sm) { @@ -792,19 +810,6 @@ std::expected run_build_program( "built for the host toolchain: {}", sm.error().message)); } - // The std BMI was built FOR a deployment target, and clang refuses to - // load a module built for a different one ("compiled for the target - // 'arm64-apple-macosx14.0.0' but the current translation unit is - // being compiled for …"). The main build makes the value explicit on - // every TU for exactly this reason (flags.cppm); the build.mcpp - // compile has to say the same thing or the BMI it just asked for is - // rejected. host_base_flags contributes nothing here — on macOS it - // trusts the clang cfg and returns empty. - if constexpr (mcpp::platform::is_macos) { - if (!macosDeploymentTarget.empty()) - stdFlags.push_back("-mmacosx-version-min=" + macosDeploymentTarget); - } - auto traits = mcpp::toolchain::bmi_traits(tc); if (traits.stdBmiUsePrefix.empty()) { // GCC: BMIs are found implicitly under /gcm.cache, so stage From bc4ce5e750212e103a0e16fca7a37f94736e8c2b Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sun, 2 Aug 2026 04:32:23 +0800 Subject: [PATCH 14/14] fix(build): a remembered default target must not overrule an explicit [toolchain] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit e2e 182 caught the fallback breaking its own promise. Once the no-Visual-Studio path persists `default_target = x86_64-windows-gnu`, every later project inherits that target — and the vocabulary pin attached to it then replaced a project's explicit `[toolchain] windows = "llvm@20.1.7"` with gcc, silently. "mcpp revises its own defaults, never yours" has to hold on the second build too, not just the first. The pin now stands down when the target came from the global config AND the toolchain came from the user. A target the user actually asked for (--target, or `[build] target`) still wins, exactly as before — the distinction is between what mcpp remembered and what the user requested, which is the same line TcOrigin already draws on the toolchain axis. --- src/build/prepare.cppm | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 4eaa2462..fbd81cfb 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -1120,9 +1120,14 @@ prepare_build(bool print_fingerprint, // [toolchain] default_target (global config) > host. if (overrides.target_triple.empty() && !m->buildConfig.target.empty()) overrides.target_triple = m->buildConfig.target; + // Remembered, not requested: this one came out of the global config, so + // it must not outrank anything the user wrote down (see the pin below). + bool targetFromGlobalDefault = false; if (overrides.target_triple.empty()) { - if (auto cfg = get_cfg(); cfg && !(*cfg)->defaultTarget.empty()) + if (auto cfg = get_cfg(); cfg && !(*cfg)->defaultTarget.empty()) { overrides.target_triple = (*cfg)->defaultTarget; + targetFromGlobalDefault = true; + } } // Normalize the triple (alias spellings → canonical), validate against // the known-target vocabulary, then apply the manifest [target.] @@ -1186,10 +1191,22 @@ prepare_build(bool print_fingerprint, // mapping, not here) and its default linkage. GCC 16 pin rationale: // GCC 15 drops module template instantiations at link (remediation // doc A2; packages shipped 2026-07-08/09, GitHub+GitCode). - if (known && !hasToolchainOverride && !known->pin.empty()) { + // A convention, not an instruction: on the Windows-GNU first-run path + // this is what turns the seeded target into `gcc@16.1.0`. + // + // It must not fire when a REMEMBERED target would overrule a + // toolchain the user wrote down. Once the no-Visual-Studio fallback + // persists `default_target = x86_64-windows-gnu`, every later project + // inherits that target — and the pin attached to it would then + // silently replace an explicit `[toolchain] windows = "llvm@…"`, + // which is exactly the promise the fallback is built on ("mcpp + // revises its own defaults, never yours"). A target the user asked + // for (--target, or [build] target) still wins, as it always has. + const bool pinWouldOverruleUser = + targetFromGlobalDefault && tc_origin_is_user_explicit(tcOrigin); + if (known && !hasToolchainOverride && !known->pin.empty() + && !pinWouldOverruleUser) { tcSpec = std::string(known->pin); - // A convention, not an instruction: on the Windows-GNU first-run - // path this is what turns the seeded target into `gcc@16.1.0`. if (!tc_origin_is_user_explicit(tcOrigin)) tcOrigin = TcOrigin::TargetPin; }