From 3d3e2dce6c13de41cc067aee398ede0507f16fbe Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Fri, 17 Jul 2026 17:26:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(build):=20=E6=B1=87=E7=BC=96=E6=BA=90?= =?UTF-8?q?=E4=B8=80=E7=AD=89=E5=85=AC=E6=B0=91=20=E2=80=94=20.S/.s/.asm?= =?UTF-8?q?=20=E8=BF=9B=20sources,NASM=20=E6=8C=89=E7=9B=AE=E6=A0=87?= =?UTF-8?q?=E6=8E=A8=E5=AF=BC=20-f?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设计: .agents/docs/2026-07-17-asm-sources-and-general-build-capabilities-design.md (PR-A) - .S/.s (GAS) 走 C 驱动的新 asm_object 规则(asm 安全旗标子集,无 -std/-O, PIC/调试/工具链定位旗标保留);.asm (NASM) 走 nasm_object 规则,-f 由 Triple::nasm_format() 从目标三元组推导(elf64/win64/macho64/...,交叉零特判), %include 经 -MD/-MQ 进 ninja deps 通道 - 默认 sources glob 扩为 src/**/*.{cppm,cpp,cc,c,S,s,asm};汇编对象名带全扩展 限定(foo.asm.o),同 stem 的 .c/.asm 兄弟不再撞对象 - 扩展名路由四处同改并单测锁住:scanner 跳模块扫描、plan 链接收集 (is_implementation_source)、ninja pick_rule、cdb(.asm 不进 compile_commands,clangd 读不懂;.S 以 cc + asm 旗标进) - nasm 惰性解析(仅当计划含 .asm 单元):PATH → mcpp 沙箱 → xlings install, 版本 floor ≥2.16,不可得即硬失败,绝不静默跳过;非 x86 目标遇 .asm、 MSVC 工具链遇 .S 均硬报错并给出门控指引 - G8a: 源 glob 跟随目录软链接(递归栈祖先链防环 + 文件级 canonical 去重); 顺带修 path_matches_glob 用 fs::relative 会解析软链致匹配失效的问题 (改词法 lexically_relative) - e2e 104(GAS 全链路+增量+build/test 双路径)/105(NASM/-f 推导/%include 依赖追踪/CDB 排除),run_all.sh 增 nasm capability;单测 35/35 过, e2e 106 过(3 失败均为既有环境性:22/54/62) --- ...s-and-general-build-capabilities-design.md | 397 ++++++++++++++++++ .../docs/2026-07-17-mcpp-feature-requests.md | 41 ++ docs/05-mcpp-toml.md | 18 +- docs/zh/05-mcpp-toml.md | 6 +- src/build/compile_commands.cppm | 15 +- src/build/flags.cppm | 26 ++ src/build/ninja_backend.cppm | 96 ++++- src/build/plan.cppm | 18 +- src/build/prepare.cppm | 46 ++ src/manifest/toml.cppm | 9 +- src/modgraph/scanner.cppm | 83 +++- src/toolchain/triple.cppm | 14 + src/xlings.cppm | 61 +++ tests/e2e/104_asm_sources_gas.sh | 85 ++++ tests/e2e/105_asm_sources_nasm.sh | 82 ++++ tests/e2e/run_all.sh | 8 + tests/unit/test_modgraph.cpp | 45 ++ tests/unit/test_ninja_backend.cpp | 93 ++++ tests/unit/test_toolchain_triple.cpp | 20 + 19 files changed, 1125 insertions(+), 38 deletions(-) create mode 100644 .agents/docs/2026-07-17-asm-sources-and-general-build-capabilities-design.md create mode 100644 .agents/docs/2026-07-17-mcpp-feature-requests.md create mode 100755 tests/e2e/104_asm_sources_gas.sh create mode 100755 tests/e2e/105_asm_sources_nasm.sh diff --git a/.agents/docs/2026-07-17-asm-sources-and-general-build-capabilities-design.md b/.agents/docs/2026-07-17-asm-sources-and-general-build-capabilities-design.md new file mode 100644 index 00000000..2281ba5a --- /dev/null +++ b/.agents/docs/2026-07-17-asm-sources-and-general-build-capabilities-design.md @@ -0,0 +1,397 @@ +# 汇编源一等公民 + 通用构建能力(G1–G9)设计方案 + +> 日期:2026-07-17 +> 需求来源:[2026-07-17-mcpp-feature-requests.md](2026-07-17-mcpp-feature-requests.md)(G1–G9 清单) +> 基线:mcpp 0.0.94(HEAD `450c24b`),xlings 已打包 nasm(G7 供给侧已完成) +> 本文所有 file:line 锚点均在 0.0.94 源码上核实。 + +--- + +## 0. 结论先行 + +G1–G9 全部与现有架构**兼容且低摩擦**,没有一条需要推翻既有抽象: + +| 项 | 合理性结论 | 实现量级 | 排入 PR | +|---|---|---|---| +| G1 `.S`/`.asm` 原生汇编源 | ✅ 三处扩展名路由 + 一条 ninja 规则,架构零改动 | 中 | **PR-A** | +| G7 nasm 供给 | ✅ xlings 已打包;mcpp 侧只差 `ensure_nasm` 按需触发 | 小 | PR-A | +| G8a glob 不跟目录软链接 | ✅ 已定位:`recursive_directory_iterator` 默认选项 | 小(scanner 已打开) | PR-A | +| G5 mcpp.toml features gate sources | ✅ 纯解析器覆盖缺口,struct 字段已存在 | 小 | **PR-B** | +| G6 generated_files 进 mcpp.toml | ✅ 同上,materialize/指纹链路格式无关已通 | 小 | PR-B | +| G1b cfg 条件化 sources(新识别) | ✅ L1 条件机制的自然延伸,`.asm` 跨架构可用的前提 | 小 | PR-B | +| G4 per-glob flags | ✅ `scan_overrides` 就是现成的 per-glob 模板,per-TU flags 管道端到端已存在 | 中 | PR-B | +| G8b 相对 `-I` 对 `.cppm` 不生效 | ⚠️ 现象待复现定位;修复点在 flags 组装,随 G4 顺手 | 小 | PR-B | +| G3 build.mcpp 环境契约 + cross | ✅ env 注入点唯一(`capture_exec`);cross 跳过是显式 TODO | 中 | **PR-C** | +| G2 依赖包 build.mcpp | ✅ leaf-only 是显式设计注释,插入点明确;共享 registry 只读是主要风险 | 大 | PR-C | +| G9 跨项目产物缓存 | ✅ 与源码构建哲学不矛盾(缓存键含工具链指纹);生态级远期 | 大 | 路线图,不排期 | + +**全部工作收敛为一个版本 0.0.95、三个 PR**:PR-A 汇编源(G1+G7+G8a)→ PR-B 声明式清单能力(G5+G6+G1b+G4+G8b)→ PR-C build.mcpp 补全(G3+G2)。版本 bump 与发布只在 PR-C 合入后做一次。G9 只留设计草图。 + +--- + +## 1. 架构合理性分析(逐条对照源码) + +### 1.1 G1:为什么"一等公民"是对的,以及架构上它意味着什么 + +mcpp 的源文件处理链是:**glob 发现 → scanner 分类 → plan 对象命名/收集 → ninja 规则选择 → 链接对象收集**。扩展名路由分散在三个独立位置,**没有中心注册表**: + +1. scanner 分类:`.c`/`.m` 跳过模块文本扫描 — `src/modgraph/scanner.cppm:264-266`; +2. plan 侧 `is_implementation_source()` 只认 `.cpp/.cc/.cxx/.c/.m` — `src/build/plan.cppm:200-203`;链接对象收集以此为门 — `plan.cppm:599-613, 745-760`; +3. ninja 规则选择 `pick_rule()` — `src/build/ninja_backend.cppm:530-537`(`is_c_source()` 同逻辑在 `compile_commands.cppm:52-54` 还有一份拷贝)。 + +这直接印证"一等公民 or 静默坏掉"的判断:一个新扩展名如果只改了 glob 而没改 `is_implementation_source()`,它的 `.o` 会**被静默丢出链接线**(`plan.cppm:602/608` 的扩展名门),不报错。所以 G1 的成本恰好就是把这三处 + cdb 一处改齐,并加单测锁住一致性——这也是为什么"让每个包用 build.mcpp 自己编汇编"是更差的路线:build.mcpp 产物走 `mcpp:generated=` 只能是**源文件**,`.o` 级产物没有进入链接线的协议,且 cross 下 build.mcpp 目前整体跳过(`build_program.cppm:388-393`)。 + +其余支撑事实: + +- **`.S`(GAS)几乎免费**:`.c` 走 `c_object` 规则(`$cc $cflags -c`,`ninja_backend.cppm:397-407`),gcc/clang 驱动器按扩展名自动对 `.S` 做 cpp 预处理+汇编。唯一要避免的是把 `-std=c17` 等 C 专属旗标喂给汇编单元(触发驱动器告警),故给汇编单独一条规则、单独一份 flag 组装(见 §2.1)。 +- **`.asm`(NASM)绕过 CommandDialect 是正确的**:`CommandDialect`(`src/toolchain/dialect.cppm:22-54`)抽象的是"同一语义、不同拼写"(gnu vs msvc);nasm 只有一种拼写,不进 dialect,自建规则即可。`-f` 格式从 `Triple`(`src/toolchain/triple.cppm:32-60`,已有 `is_pe()`/`family()`)推导,零新概念。 +- **增量/并行自动获得**:per-source 增量是 ninja 的职责(mtime + command hash),新源类型有 ninja edge 即得;nasm 自带 `-MD` 依赖输出,`%include` 追踪走 ninja deps 通道,与 `c_object` 同款。 +- **指纹**:输出目录指纹的 flags 哈希在 `canonical_compile_flags()`(`src/build/prepare.cppm:182-229`),需追加 asm 维度(见 §2.1.6)。 + +**Cargo/Zig 对照**(需求文档已给):Cargo 无原生汇编逼出 cc/nasm-rs crate 生态,构建逻辑碎在几千个 build.rs 里;Zig `addAssemblyFile` 声明式进图。mcpp 的声明式依赖图哲学("依赖图保持声明式、命令式困在叶子",见 2026-06-29-manifest-environment-and-platform-design)与 Zig 同路。 + +### 1.2 G5/G6:不是新功能,是解析器覆盖缺口 + +两个描述符语法(mcpp.toml 与 index 描述符)**合成到同一份数据模型** `mcpp.manifest.types`(`src/manifest/manifest.cppm:14-18`)。核对结果: + +- `BuildConfig::featureSources`(`types.cppm:132`)与 `BuildConfig::generatedFiles`(`types.cppm:142`)**字段已存在**; +- xpkg 解析器两者都填:feature `sources` 分发在 `xpkg.cppm:976`,`generated_files` 在 `xpkg.cppm:843-863`; +- toml 解析器两者都没有分支:`[features]` 表单只处理 implies/defines/requires/provides(`toml.cppm:149-183`),无 `sources`;全文无 `generated` 引用; +- 消费侧完全格式无关:featureSources 的 drop+add 在 `prepare.cppm:2481-2525`(0.0.94 已修双模式);generatedFiles 的落盘在 `materialize_generated_files()`(`prepare.cppm:278-330`,调用点 `:1561`),内容进包指纹(`prepare.cppm:268-273`),落盘后被正常 glob 匹配——**build 侧零改动**。 + +Schema 准入(2026-06-04-manifest-schema-ownership,"closed grammar, open vocabulary"):两个字段都是**固定形状的机制 + 开放值域**(feature 名/路径/内容是值不是键),满足 Rule A/B/C,无需新解析语义。结论:G5/G6 就是给 `toml.cppm` 各加一个分支 + 文档,是本清单里性价比最高的两项。 + +### 1.3 G1b(新识别):cfg 条件化 sources —— `.asm` 跨架构可用的前提 + +分析 G1 时发现的缺口:x86 汇编源**只能在 x86 目标上进构建**,但目前所有源集机制都不能按 target 条件化——`[target.'cfg(...)'.build]` 只合并 flags(`ConditionalConfig` `types.cppm:245-256`,合并点 `prepare.cppm:714-736` 仅 cflags/cxxflags/ldflags),featureSources 按 feature 不按 target。没有它,含 `.asm` 的包在 aarch64 上要么硬报错要么用户手工开关 feature。 + +修法是 L1 机制的自然延伸:`ConditionalConfig` 增加 `sources` 字段,`[target.'cfg(arch = "x86_64")'.build] sources = [...]` 求值后 APPEND 进 `bc.sources`(与 flags 同一合并点,同样"按解析后的 target 求值"——这正是 L1 设计当初选对的地方,交叉编译直接正确)。`!` 排除 glob 由 scanner 既有的正负 glob 集处理(`scanner.cppm:420-433`)。归入 PR-B 的"声明式源集三补齐"。 + +### 1.4 G4:per-glob flags —— 管道已存在,只差接线 + +三个关键事实说明 G4 是接线不是架构: + +1. **per-glob 机制已有模板**:`[scan_overrides.""]`(解析 `toml.cppm:194-224`,消费 `scanner.cppm:446-480`)就是"glob → 命中源文件 → 附加元数据"的完整先例; +2. **per-TU flags 管道端到端已通**:`SourceUnit.packageCflags/packageCxxflags`(scanner 附加,`scanner.cppm:449-490`)→ `CompileUnit`(`plan.cppm:431-433`)→ ninja edge 变量 `unit_cflags`/`unit_cxxflags`(`ninja_backend.cppm:640-646, 687-693`); +3. **per-TU 增量自动正确**:flags 变化改变 edge command,ninja 按 command hash 只重编命中的 TU。 + +唯一的设计工作是语义定界(见 §2.3):匹配顺序、多 glob 叠加、与 feature defines/`[target.*]` per-target flags 的组合次序。 + +### 1.5 G2/G3:build.mcpp 补全 —— 跳过是显式设计债,不是意外 + +- **G3**:env 注入点唯一且为空——`capture_exec({bin}, {}, root)`(`build_program.cppm:459`),第二参数就是 env map,契约变量从这里注入,一处改。cross 跳过是带注释的显式 TODO(`build_program.cppm:388-393`:"host-toolchain-for-cross is a follow-up")。真正的工作量在**cross 下解析宿主工具链**:当前传入的 `hostCompiler` 在 `--target` 构建里是交叉编译器前端,需要独立解析 host 三元组的默认工具链(`host_triple()` `triple.cppm:118-125` + registry 既有解析路径)。 +- **G2**:leaf-only 是设计注释明说的(`prepare.cppm:962-963`:"Leaf-only: it cannot gate the top-level dependency graph"),全树唯一调用点 `prepare.cppm:965`。依赖侧插入点在 per-dep prepare 流(`prepare.cppm:1366-1440` 一带,`materialize_generated_files` 调用点 `:1561` 之侧)。**主要风险已知**:依赖根在 `~/.mcpp/registry` 共享且可能只读(mcpp-index smoke 只读 pack 事故的同款教训),所以 dep 的 build.mcpp 产物目录必须**项目本地化**,不能写进共享包根(见 §2.4.2)。 +- **作用域照 Cargo 是对的且现成**:`apply()`(`build_program.cppm:364-371`)写的是"所在 manifest 的 bc"——对 dep manifest 调用即天然作用域正确:cxxflag/cfg 只进该包自身 TU;`link-lib`/`link-search` 写进 dep 的 `bc.ldflags`,而 per-package ldflags 传播到终链的通道已存在(openblas per-OS ldflags 先例)。 + +**信任模型必须写明**:G2 意味着构建任意依赖包会执行其 build.mcpp(Cargo build.rs 同模型)。mcpp 生态已接受同级别信任(index 描述符的 `install()` Lua 已在 xim 沙箱执行),但 build.mcpp 是全权 C++,doc 与 changelog 必须显式声明这一点。 + +### 1.6 G8:两个一致性 bug + +- **G8a 已定位**:`expand_glob` 用 `recursive_directory_iterator(root)` 默认构造(`scanner.cppm:210-221`),默认**不跟目录软链接**。修 = 加 `follow_directory_symlink` + `canonical` 去重防环。vendored 大库常用软链目录组织第三方源,POC 实测命中。 +- **G8b 待定位**:相对 `-I` 对 `.cppm` 单元不生效。可疑点:模块单元编译的 cwd 与 include 组装(`flags.cppm:310-320` includes 拼接)相对基准不一致。修法方向:include dirs 在 flags 组装时统一绝对化。**先加最小复现 e2e,再修**(systematic-debugging 原则),随 G4 的 flags PR 顺手。 + +### 1.7 G9:远期,方向正确 + +缓存键 = 工具链指纹(已有 10 字段 FNV 指纹,`fingerprint.cppm:90-115`)× 源内容哈希 × canonical flags——**指纹体系就是为此铺的路**,与"源码构建"哲学不矛盾(缓存的是"这套工具链+这套源+这套旗标"的确定性产物)。依赖 hash-as-identity(L1b 路线图项)。本文不排期,只锚定:实现时缓存放 `~/.mcpp/cache/`,以 registry 只读共享的同款血统纪律管理。 + +--- + +## 2. 设计细节 + +### 2.1 G1:`.S`/`.s`/`.asm` 原生汇编源(PR-A) + +#### 2.1.1 扩展名与路由 + +| 扩展 | 含义 | 编译器 | ninja 规则 | +|---|---|---|---| +| `.S` / `.s` | GAS 汇编(`.S` 过 cpp 预处理,`.s` 不过;区分交给驱动器) | `$cc`(gcc/clang 驱动) | 新 `asm_object` | +| `.asm` | NASM 汇编 | `nasm` | 新 `nasm_object` | + +`.S`/`.s` 同时收:Windows 大小写不敏感文件系统下二者不可区分,一起路由到 cc 驱动最稳(驱动器自己按真实扩展名决定是否预处理)。 + +**默认 glob 收窄决策**:默认 sources glob(`toml.cppm:878-887`)从 `src/**/*.{cppm,cpp,cc,c}` 扩为 `src/**/*.{cppm,cpp,cc,c,S,s,asm}`。理由:树里有汇编源的项目几乎必然想编它;此前这些文件根本不进构建,行为变化是"新文件入图"而非"旧行为改变";不想要的用 `!` 排除。MASM 语法 `.asm` 在 nasm 下会硬报错(而非静默错编),错误信息给出排除指引(见 2.1.7)。 + +**改齐五处路由**(§1.1 的三处 + cdb + P1689): + +1. `scanner.cppm:264-266`:`.S/.s/.asm` 加入跳过模块扫描分支(同 `.c`); +2. `plan.cppm:200-203`:`is_implementation_source()` 增列三扩展(链接收集 `:599-613/:745-760` 自动跟随); +3. `ninja_backend.cppm:530-537`:`pick_rule()` 路由 `.S/.s`→`asm_object`、`.asm`→`nasm_object`;`is_c_source` 的 P1689 扫描门(`:551-552, :583, :657`)同步扩为 `is_non_scannable_source`; +4. `compile_commands.cppm:52-54`:`.S/.s` 以 cc 命令进 cdb;**`.asm` 不进 cdb**(clangd 等工具不识别 nasm 命令行,进了反而害 LSP); +5. `p1689.cppm:388-389`:备用 scanner 路径同步。 + +加一个单测锁一致性:枚举全部受支持扩展,断言"scanner 分类 / is_implementation_source / pick_rule"三方对每个扩展的行为组合合法(防止未来再加扩展名漏改)。 + +#### 2.1.2 ninja 规则 + +```ninja +rule asm_object + command = $cc $asmflags -c $in -o $out # GNU; 按 dialect compileOnly/outputObjPrefix 拼 + deps = gcc + depfile = $out.d # 经 -MD -MF(.S 走 cpp 才有头依赖;.s 无依赖,depfile 为空可接受) + +rule nasm_object + command = $nasm -f $nasmfmt $nasmflags -MD $out.d -MQ $out -o $out $in + deps = gcc + depfile = $out.d +``` + +两条规则都按需 emit(仿 `need_c_rule` 门,`ninja_backend.cppm:278-284`)。`$asmflags`/`$nasmflags` 是全局变量 + `unit_asmflags` edge 变量(G4 到来后 per-glob 附加)。 + +#### 2.1.3 flags 组装(`flags.cppm` 新增 `f.as` / `f.nasm`) + +- `f.as`(喂 `$cc` 的汇编单元):includes(`-I`,`.S` 的 cpp 要用)+ defines + 目标选择/sysroot/`-B` 等 toolchain 定位旗标(**必须继承**,hermetic link model 的教训:宿主污染假绿)——但**不含** `-std=`、`-O`、方言旗标。PIC:不注入(汇编的 PIC 是源码自身的事,注 `-fPIC` 对 `.s` 无意义)。 +- `f.nasm`:`-D`(defines 直通,`-D` 拼写同 GNU)+ `-I/`(**注意 nasm 的 `-I` 需以路径分隔符结尾**,组装时补齐)+ debug(profile 带调试时 ELF 上加 `-g -F dwarf`,PE/Mach-O 上省略——nasm 各格式调试支持不齐)。 +- MSVC 工具链(family=Msvc)遇 `.S/.s`:**硬报错**"GAS assembly is not supported by the MSVC toolchain"(cl 没有 GAS 通道;MASM 支持是显式 non-goal)。遇 `.asm`:正常走 nasm(nasm 产 COFF,与 cl 对象互链没问题)。 + +#### 2.1.4 NASM `-f` 从 Triple 推导 + +新增 `triple.cppm` 方法(与 `is_pe()`/`family()` 并列): + +```cpp +// Triple::nasm_format(): x86 目标 → nasm 输出格式;非 x86 → nullopt(调用方硬报错) +// linux/musl: x86_64→elf64, x86→elf32 +// windows(gnu|msvc): x86_64→win64, x86→win32 +// macos: x86_64→macho64 +``` + +aarch64 目标遇到 `.asm` 单元 → 硬报错,错误信息指引用 `[target.'cfg(arch = "x86_64")'.build] sources` 门控(G1b,PR-B 落地;PR-A 单独在 main 上的窗口期内文案先指引 `!` 排除,同版本发布无用户可见断层)。 + +#### 2.1.5 nasm 获取(G7 mcpp 侧收口) + +仿 `ensure_ninja`(`xlings.cppm:266/:1209`)加 `ensure_nasm(env, quiet, cb)`: + +- **惰性触发**:仅当 scan 后计划里存在 ≥1 个 `.asm` 单元才调用(在 plan→backend 之间,拿到单元列表后);纯 C/C++ 项目零感知; +- 解析顺序:PATH 里已有 `nasm` 且 `nasm -v` ≥ 2.16 → 直接用;否则 `install_direct(env, "xim:nasm")` 走 xlings(已打包);装完仍不可用或版本过低 → **硬失败**,不降级不静默跳(用户既定原则); +- `nasm` 绝对路径进 ninja 变量 `$nasm`。 + +#### 2.1.6 指纹与增量 + +- per-source 增量:ninja edge + depfile 自动获得,无 mcpp 侧工作;NASM 用自带 `-MD/-MQ` 走 ninja deps 通道(`%include` 追踪); +- 输出目录指纹(**实施时修订**):PR-A **不**把 nasm 版本折入目录指纹——默认 glob 扩展后人人的 sources 配置都含 `.asm` 模式,"静态含 .asm 就解析 nasm"会迫使每个项目都跑 ensure_nasm,违背惰性原则。替代保证:nasm 绝对路径进 ninja `$nasm` 变量 → 换 nasm(路径变)即 command hash 变即重编;asm 相关全局旗标(`f.as`/`f.nasm`)全部派生自已在指纹里的输入(profile/includes/toolchain)。同路径原地升级 nasm 不触发重编,与既有 c_object 无 depfile 的容忍度同级,接受;用户侧 `asmflags` 键落地时(PR-B per-glob)再进 `canonical_compile_flags`; +- `mcpp test` 路径:0.0.94 修复已保证 featureSources 双模式一致,汇编源走同一 sources 通道,无需特判——e2e 里 build/test 双路径都锁(feature 诊断双路径对比的教训)。 + +#### 2.1.7 错误面 + +- MASM 语法喂 nasm:nasm 自身报错,mcpp 包一层提示:"if this is MASM/GAS syntax, exclude it via `!` glob or rename; mcpp routes `.asm` to NASM"; +- 非 x86 目标遇 `.asm`:见 2.1.4; +- nasm 缺失/版本过低:硬失败 + `xlings install nasm` 指引。 + +#### 2.1.8 G8a 顺手修(scanner 已打开) + +`expand_glob`/`expand_dir_glob`(`scanner.cppm:210-247`)改用 `directory_options::follow_directory_symlink`,以 `fs::canonical` 结果集去重防环;新增含软链目录的单测 + e2e 断言。 + +### 2.2 声明式源集三补齐:G5 + G6 + G1b(PR-B) + +#### 2.2.1 G5:`[features]` 的 `sources` + +```toml +[features] +# 表单形式增加 sources 键,与 xpkg 语义逐字对齐(xpkg.cppm:976) +jpeg = { sources = ["src/codecs/jpeg/**"], defines = ["HAS_JPEG"], deps = [] } +``` + +- 解析:`toml.cppm:149-183` 表单分支增 `sources` → `m.buildConfig.featureSources[fname]`(struct 已有,`types.cppm:132`); +- 消费:零改动(`prepare.cppm:2481-2525` 的 drop+add 格式无关,0.0.94 已修双模式); +- 单测:toml 解析进 featureSources;**再加一条 toml/xpkg 对称性单测**:同一逻辑内容分别用两种语法写,断言合成的 `Manifest` 相等(把"两语法一模型"从惯例升级为被测契约,防止下次再长出不对称)。 + +#### 2.2.2 G6:`[generated_files]` + +```toml +[generated_files] +"src/wrap/module.cppm" = """ +module; +#include +export module vendored; +... +""" +``` + +- 解析:`toml.cppm` 新节,镜像 `xpkg.cppm:843-863` → `m.buildConfig.generatedFiles`;路径合法性(相对、不越根)由消费端已有校验兜底(`prepare.cppm:283-296`),解析端重复校验一次给出更好的报错位置; +- 消费/指纹:零改动(`materialize_generated_files` + `genfile:` 指纹折入 `prepare.cppm:268-273`); +- 注意调用点:`materialize` 目前只对**已解析依赖**调用(`prepare.cppm:1561`);根项目自身的 generatedFiles 需要补一个根侧调用(scan 之前)——小改,单测锁住。 + +#### 2.2.3 G1b:`[target.'cfg(...)'.build] sources` + +- `ConditionalConfig`(`types.cppm:245-256`)增 `sources` 字段;解析 `toml.cppm:747-781` 增分支;合并点 `prepare.cppm:714-736` APPEND 进 `bc.sources`(在 build.mcpp 与 scan 之前,顺序天然正确); +- 求值语义沿用 L1:**按解析后的 target**(交叉编译正确性); +- xpkg 侧:index 描述符同样需要表达(ffmpeg/x264 按 arch 选汇编目录),`xpkg.cppm` 增 `target_cfg` 键解析进同一 `conditionalConfigs`。**描述符语法新增 → 走 index_contract 的 min-version 机制**(`pm/index_contract.cppm:108`):使用该键的描述符声明 `min_mcpp = "0.0.95"`,旧 mcpp 拿到新描述符时按既有 floor 逻辑处理,不静默错编。 + +#### 2.2.4 文档 + +`docs/05-mcpp-toml.md` 增补三节;schema-ownership 文档的准入三问在 PR 描述里逐条回答(三项都是"机制固定、值域开放",通过)。 + +### 2.3 G4:per-glob flags(PR-B) + +#### 2.3.1 语法与语义 + +```toml +[build.flags."src/simd/**/*.avx2.cpp"] +cxxflags = ["-mavx2"] + +[build.flags."third_party/**"] +cflags = ["-w"] +cxxflags = ["-w"] + +[build.flags."src/x86/**/*.asm"] +asmflags = ["-DPREFIX"] # 喂 nasm 或 as,按该文件的路由决定 +``` + +- **键集**:`cflags` / `cxxflags` / `asmflags` / `defines`(defines 双拼写展开,同 `mcpp:cfg=` 的处理);不含 `ldflags`(链接无 per-TU 概念); +- **匹配语义**:对每个已发现源文件,**按声明顺序**遍历所有 glob,命中即 APPEND(后声明的排后,自然覆盖 GCC 系"后旗标胜"语义);glob 匹配复用 `path_matches_glob`(`scanner.cppm:95-140`),相对包根; +- **组合次序**(最终 per-TU 命令行):全局 flags(profile/toolchain)→ 包级 flags(descriptor/manifest)→ feature defines → per-glob(最特异,排最后); +- **零命中告警**:一个 glob 若未命中任何源文件,warn(拼错 glob 的静默失效是这类机制的经典坑); +- **作用域**:mcpp.toml 与 xpkg 描述符都支持(OpenCV SIMD dispatch 在描述符里写);per-glob flags 是**私有构建旗标**,不进 usage requirements,不传播消费者。 + +#### 2.3.2 实现落点 + +- struct:`BuildConfig` 增 `std::vector> globFlags`(**vector 不是 map——顺序即语义**); +- 解析:`toml.cppm` 仿 `scan_overrides`(`:194-224`);`xpkg.cppm` 对称分支; +- 消费:scanner 附加 per-unit flags 处(`scanner.cppm:449-490`),命中即 append 到 `SourceUnit.packageCflags/packageCxxflags`(既有管道直达 ninja `unit_*flags`);汇编单元新增 `packageAsmflags` 一路(随 G1 的 `unit_asmflags`); +- 指纹:`canonical_compile_flags()` 折入 globFlags 全序列(glob 串 + 旗标),保证输出目录一致性;per-TU 增量由 ninja command hash 自动正确。 + +#### 2.3.3 G8b 顺手修 + +先写最小复现 e2e(`.cppm` 单元 + 相对 `include_dirs`),定位后大概率修在 flags 组装处统一 `fs::absolute`(以包根为基准)。若复现发现根因在别处(如 BMI 二次编译的 cwd),按实际根因单独小 PR,不硬塞。 + +### 2.4 G3 + G2:build.mcpp 补全(PR-C,单 PR 内两阶段) + +**实现顺序刻意:先契约(G3)后扩围(G2)**——依赖包的 build.mcpp 没有 TARGET/features 上下文几乎不可用,契约先行才不用发两版协议。同一 PR 内按此顺序分两组 commit 实现(squash 后对外是一个变更),G3 部分完成即先跑全量回归再叠 G2。 + +#### 2.4.1 G3:环境契约 + cross 运行(第一阶段,root-only) + +**注入变量**(`capture_exec` 第二参数,`build_program.cppm:459` 一处改): + +| 变量 | 值 | Cargo 对应 | +|---|---|---| +| `MCPP_TARGET` | 解析后的 canonical 三元组(`x86_64-windows-gnu`) | `TARGET` | +| `MCPP_HOST` | `host_triple().str()` | `HOST` | +| `MCPP_PROFILE` | 生效 profile 名(dev/release/…) | `PROFILE` | +| `MCPP_OUT_DIR` | `target/.build-mcpp/out/`(mcpp 预创建;`generated=` 推荐落这里) | `OUT_DIR` | +| `MCPP_MANIFEST_DIR` | 包根(= cwd,显式给一份) | `CARGO_MANIFEST_DIR` | +| `MCPP_FEATURE_` | `1`,每个活跃 feature 一个 | `CARGO_FEATURE_*` | +| `MCPP_FEATURES` | 活跃 feature 逗号列表(遍历友好) | — | + +- **缓存正确性**:注入的契约值整体折入 cache key(`write_cache`/`cache_fresh`,`build_program.cppm:284-360` 增一行 `ctx ` 记录)——target/profile/features 变了必须重跑,不能依赖用户自觉 `rerun-if-env-changed`; +- **时序修正**:build.mcpp 调用点(`prepare.cppm:965`)目前在 feature 激活(`:2419` 一带)**之前**——注入 features 要求把 root 的 feature 闭包计算前移或拆出(激活闭包本身无 IO,纯 `featuresMap` 图运算,可提前);这是 G3 里最需要小心的重排,单测锁"闭包提前算与原位算结果一致"; +- **cross 下运行**:删掉 `:388-393` 的跳过;host 编译器改为**按 `host_triple()` 独立解析**的宿主默认工具链(不能用 cross 前端);`host_base_flags(tc)` 喂宿主 sysroot(既有函数,热机/CI 裸沙箱两态都过的那套);typed `import mcpp;` 模块(`kMcppModuleSource`)同样用宿主链编; +- typed 库(`build_program.cppm:201-214`)同步长出读取接口:`mcpp::target()`/`host()`/`profile()`/`out_dir()`/`has_feature("x")`(C 级原语实现,免 import std,协议 1:1 原则不变); +- e2e:cross(mingw-cross capability)下 build.mcpp 真跑 + `MCPP_TARGET` 断言;features 变更触发重跑断言。 + +#### 2.4.2 G2:依赖包 build.mcpp(第二阶段) + +- **执行点**:per-dep prepare 流,`materialize_generated_files`(`prepare.cppm:1561`)之侧、该 dep glob 展开之前;dep 的 feature 集在 Stage 2a(`activateFeatures`,`prepare.cppm:1845-1860`)已可得; +- **作用域(照 Cargo,现成)**:对 dep 的 manifest 调 `apply()` 即正确——`cxxflag/cflag/cfg` 只进该 dep 自身 TU;`link-lib/link-search` 进 dep 的 `bc.ldflags`,沿既有 per-package ldflags 通道到终链;**只请求叶子边、不能加依赖**的既有纪律对 dep 同样成立(dep 图此刻已定); +- **产物目录项目本地化(关键)**:dep 根在 `~/.mcpp/registry` 共享、可能只读、**绝不可写**。dep 的 bin/cache/OUT_DIR 全部落 `/target/.build-mcpp/deps/@-/`;cwd 仍为 dep 根(读源用),`MCPP_OUT_DIR` 指项目本地;`generated=` 路径解析:相对路径**以 OUT_DIR 为基**(root 的 build.mcpp 保持相对包根不变,dep 语义文档写清),落进 `bc.sources` 的是绝对路径(既有通道支持,root 侧 `:371` 先例); +- **触发条件**:dep 根存在 `build.mcpp` 即运行(Cargo 模型,不搞 opt-in 开关);描述符可用 `min_mcpp = "0.0.95"` 声明依赖此能力; +- **信任声明**:changelog/docs 显式写明"构建依赖 = 执行其 build.mcpp"(与 build.rs 同模型); +- **缓存**:per-dep cache 文件在项目本地目录,key 含 dep 指纹 + 契约值,registry 共享包升级(指纹变)自动失效; +- e2e:双包 fixture(dep 带 build.mcpp 产 generated 源 + link-search),断言 flags 不泄漏到 root TU、link 指令达终链、registry 目录零写入(`find -newer` 断言)。 + +### 2.5 G9:路线图锚点(不排期) + +缓存键 = 工具链 10 字段指纹 × per-source 内容哈希 × canonical flags × feature 集;位置 `~/.mcpp/cache/cas/`;命中单位是对象文件与 BMI。前置:hash-as-identity(L1b)、BMI 跨项目可重定位性核实。在 G1–G2 全落地后单独立设计文档。 + +--- + +## 3. 任务拆分 / PR / 版本号 + +**一个版本 0.0.95,三个 PR,合并顺序 A → B → C**。仓库既有的"一版本一 PR"惯例这里放宽为"一版本一批 PR":PR-A/PR-B 合入 main 但**不 bump 版本、不发布**(有先例:`ce34a70`/`70967c7` 等 main 上无版本提交;0.0.90 也是 #210+#213 两 PR 成一版);**只有 PR-C 携带版本 bump**(mcpp.toml + `fingerprint.cppm:21` 同提交),其合入即触发 0.0.95 发布链。squash 标题沿用 `feat(scope): 中文标题 (#PR)` / 末 PR 加 `(0.0.95)`(参照 `6d8083a`/`116f532`)。PR 号以开 PR 时实际为准(当前序列 ~#219 起)。 + +| PR | squash 标题草案 | 内容 | 规模 | +|---|---|---|---| +| **PR-A** | `feat(build): 汇编源一等公民 — .S/.s/.asm 进 sources,NASM 按目标推导 -f (#NNN)` | G1 全部(§2.1)+ G7 收口(`ensure_nasm`)+ G8a 软链 glob | 中:5 处路由 + 2 条 ninja 规则 + flags/指纹 + ensure_nasm | +| **PR-B** | `feat(manifest): 声明式清单能力 — features.sources / generated_files / cfg 条件 sources / per-glob flags (#NNN)` | G5 + G6 + G1b(§2.2)+ G4(§2.3)+ G8b(定位后);含 xpkg 侧 target_cfg/globFlags 与 toml↔xpkg 对称性单测 | 中:双解析器 + 一处合并点 + scanner 接线 + 指纹 | +| **PR-C** | `feat(build): build.mcpp 补全 — 环境契约 + cross 下运行 + 依赖包执行 (0.0.95) (#NNN)` | G3 + G2(§2.4,PR 内先契约后扩围两阶段)+ **版本 bump 0.0.95** + changelog(汇总 A/B/C 全部能力) | 大:env 注入 + 缓存 key + feature 闭包前移 + 宿主链解析 + dep 流插入 + 目录纪律 | + +**PR 间依赖**:A 与 B 代码不相交、可并行开发(唯一接缝:A 的非 x86 `.asm` 报错文案引用 B 的 cfg-sources 语法,同版本发布无断层;B 的 `asmflags` per-glob 键消费 A 的 `unit_asmflags` 通道——B 后合即可,若 B 先行则该键留 TODO 随 A 激活)。C 独立于 A/B,但排最后合,因为它带版本 bump 且是回归面最大的一个:合 C 前 main 上已有 A/B 的全部新 e2e 作回归底座。 + +每个 PR 的 DoD(统一): + +1. 单测过(`mcpp test`,tests/unit 自动发现,当前 35 文件基线); +2. e2e 全绿(`tests/e2e/run_all.sh`,本机基线 94 过/5 环境败;新 e2e 见 §4.3); +3. `docs/05-mcpp-toml.md` 同步(PR-B;PR-A/C 补 docs 对应章节); +4. 三平台 CI 过(linux/macos/windows + e2e 工作流)。 + +PR-C 额外 DoD:`mcpp.toml version` 与 `MCPP_VERSION` 同提交 bump 到 0.0.95(release.yml 冒烟断言二者一致,`release.yml:198/:537`);changelog 汇总三个 PR 的全部用户可见变化(含 G2 信任模型声明、默认 glob 扩展声明)。 + +--- + +## 4. 开发流程 + +### 4.1 单 PR 循环(A/B/C 各一轮) + +1. `main` 拉 feature 分支(`feat/asm-sources`、`feat/declarative-manifest`、`feat/build-mcpp-completion`);A/B 可并行分支,C 待 A/B 合入后从 main 拉; +2. **TDD**:先写失败单测/e2e,再实现(superpowers:test-driven-development);G8b 这类未定位 bug 先写复现再修(systematic-debugging); +3. 本地验证:`mcpp build && mcpp test` 自托管 + `tests/e2e/run_all.sh`;**feature/源集类改动必须 build/test 双路径对比**(0.0.94 事故的直接教训);e2e 断言必须 host-aware(0.0.74 的教训:win/mac 自托管 e2e 跑 Linux 断言会挂); +4. docs 同步;仅 PR-C:版本 bump 两处同步(§3 PR-C DoD); +5. 开 PR(`gh pr create`),CI 全绿后 **squash 合入**,标题按 §3 草案; +6. PR-C 合入后走 §5 发布链(一次);发布验证闭环后 workspace bootstrap pin bump(`ci: workspace mcpp bootstrap pin -> 0.0.95 (released + indexed)` 惯例提交)。 + +### 4.2 单测清单(按 PR) + +- **PR-A**:扩展名路由一致性锁(§2.1.1 末);`Triple::nasm_format` 全目标矩阵;`asm_object`/`nasm_object` 规则 emit(test_ninja_backend);`f.as`/`f.nasm` 组装(test_build_flags,含 nasm `-I` 尾分隔符);默认 glob 扩展(test_manifest);软链 glob(test_modgraph); +- **PR-B**:toml featureSources/generatedFiles/cfg-sources 解析;**toml↔xpkg 对称性测试**;根侧 generatedFiles materialize;globFlags 顺序语义/零命中告警/指纹折入;scanner 命中附加; +- **PR-C**:契约 env 注入与 cache key;feature 闭包前移等价性;cross 宿主链解析;dep 作用域(flags 不泄漏)/OUT_DIR 定位/registry 零写入。 + +### 4.3 e2e 新增(编号接现有 ~103;104–105 随 PR-A,106–109 随 PR-B,110–111 随 PR-C) + +| 编号 | 名称 | requires | 断言要点 | +|---|---|---|---| +| 104 | `104_asm_sources_gas.sh` | gcc | `.S` 进构建、对象入链、增量(touch 后仅重编该 TU)、build/test 双路径 | +| 105 | `105_asm_sources_nasm.sh` | gcc **nasm** | `.asm` 编译、`-f` 推导(host 断言)、`%include` 增量、缺 nasm 硬失败文案 | +| 106 | `106_feature_gated_sources_toml.sh` | gcc | mcpp.toml featureSources 开/关 × build/test 四象限 | +| 107 | `107_generated_files_toml.sh` | gcc | 落盘、指纹变更触发重建、越根路径报错 | +| 108 | `108_cfg_conditional_sources.sh` | gcc | host-aware:按 host arch 断言源集差异 | +| 109 | `109_per_glob_flags.sh` | gcc | 命中 TU 得旗标(`-DXX` 探针)、未命中不受影响、顺序覆盖、零命中告警 | +| 110 | `110_build_mcpp_env_contract.sh` | gcc | 契约变量值、features 改变触发重跑、cross(`# requires: mingw-cross` 变体)下真跑 | +| 111 | `111_dep_build_mcpp.sh` | gcc | §2.4.2 的三断言(作用域/终链/registry 零写入) | + +`run_all.sh` capability 自动探测(`:41-112`)增 `nasm`(`command -v nasm`,装了 xlings nasm 的 CI runner 应可得;探不到则 105 skip 不 fail)。CI e2e runner 预装 nasm 进 workflow(linux 必装,mac/win 有 xlings 包则装)。 + +### 4.4 mcpp-index 侧联动验证 + +**发布前预验证(不等 release)**:PR-A 合入 main 后即可用 main 自托管构建的 mcpp 二进制在 mcpp-index 本地跑 nasm POC(最小 `.asm` 包或 F1+ ffmpeg 汇编档雏形)——把描述符/推导问题在发版前暴露,这是三 PR 合一版相对多版最大的红利:发布时生态用例已实跑过。 + +**0.0.95 发布后一次性解锁**(全部 `min_mcpp = "0.0.95"`): + +- nasm POC 包正式进 index,CI `mcpp test --workspace` 锁;F1+ ffmpeg 汇编加速档开工; +- 把现有靠 xpkg featureSources 的包(compat.gtest/eigen/spdlog/cjson)形态在 mcpp.toml 侧各写一个对称 fixture 进 mcpp-index tests;OpenCV imgcodecs 形态(feature=源集+define)开工; +- OpenCV SIMD dispatch(P5)解阻塞; +- 评估把复杂 install() Lua 逻辑迁移为包内 build.mcpp 的机会(长期让 index 描述符更薄)。 + +--- + +## 5. 发布与 xlings 生态打通(PR-C 合入后一次) + +既有自动化管线(release.yml + publish-ecosystem)已覆盖,流程照抄,坑位提示来自 0.0.91–0.0.94 实操: + +1. **tag 触发 release.yml**:四平台 job(linux musl 静态 / aarch64 cross / macos / windows)自托管构建 + pack + 冒烟(冒烟断言 `--version` == mcpp.toml,DoD-3 的保险);`mcpp publish --dry-run` 产源码 tarball + xpkg.lua; +2. **publish-ecosystem job**(全平台 needs): + - `mirror_res.sh mcpp ` → xlings-res 双端(github + gitcode);**已知坑**:gtc 大文件致 30min timeout 复发 → 本地 `gtc` 补传大件,`gitcode.com` 真 GET + `cmp` 核验,然后 rerun --failed(幂等续传);本机跑 gtc 需 `/usr/bin/python3`;两端**永不删除在服资产**; + - `bump_index.sh mcpp ` → 对 openxlings/xim-pkgindex 开 bump PR;**索引 sha256 必须独立核验**(下载资产本地算,不信 CI 日志);合入后注意 refresh marker 假新鲜问题(必要时手动 `xlings update`); +3. **fresh-install 验证**:`ci-fresh-install`(workflow_run 自动触发)+ aarch64 变体;0.0.95 需额外人工验证一次 `xlings install nasm` 在**双镜像**(github/gitcode)都可达(G7 供给侧收口的最后一环); +4. **bootstrap pin bump**:`ci: workspace mcpp bootstrap pin -> 0.0.95 (released + indexed)` 提交进 main; +5. mcpp-index 联动项(§4.4"发布后"清单)随后开工。 + +合并节奏建议:A → B 之间不必等待;**C 合入前**至少完成一轮 §4.4 的发布前预验证(main 二进制真跑 nasm POC),因为 C 一合就是发布——三层新机制的问题要在 main 上各自消化,不要堆到 release 冒烟才暴露。 + +--- + +## 6. 风险与非目标 + +**风险** + +| 风险 | 缓解 | +|---|---| +| 默认 glob 扩展把从未编译的 `.asm` 意外拉进构建 | changelog 显式声明;MASM 报错带排除指引;`!` glob 逃生门 | +| G3 的 feature 闭包前移引入激活语义回归 | 等价性单测 + 0.0.94 双路径 e2e 全量回归 | +| G2 打开依赖任意代码执行面 | 文档显式声明信任模型(= build.rs);registry 零写入 e2e 锁 | +| nasm 在 CI 三平台可得性不齐 | capability 探测 + skip;linux CI 必装;硬失败文案指引 xlings | +| xpkg 新键(target_cfg/globFlags)被旧 mcpp 消费 | 描述符 `min_mcpp` floor(index_contract 既有机制) | + +**非目标(本轮明确不做)** + +- MASM(`ml64`)支持——`.asm` 恒路由 NASM; +- `asmflags` 进 `[build]` 顶层(per-glob 已覆盖需求,顶层键等真实需求出现再加); +- build.mcpp 产 `.o` 直接入链的协议(G1 已消解该需求); +- G9 实现(单独设计文档)。 diff --git a/.agents/docs/2026-07-17-mcpp-feature-requests.md b/.agents/docs/2026-07-17-mcpp-feature-requests.md new file mode 100644 index 00000000..173a4248 --- /dev/null +++ b/.agents/docs/2026-07-17-mcpp-feature-requests.md @@ -0,0 +1,41 @@ +# mcpp 通用构建能力需求清单(G1–G9) + +> 日期:2026-07-17 +> 视角:每条都是对媒体/加密/数值计算类库通用的机制,不是给 ffmpeg/opencv 开特例。 +> 背景:mcpp-index 收录大型 C/C++ 库(ffmpeg、opencv、openssl、dav1d、x264、BLAS 族)的 POC 过程中沉淀的通用能力缺口。 +> 对应设计方案:[2026-07-17-asm-sources-and-general-build-capabilities-design.md](2026-07-17-asm-sources-and-general-build-capabilities-design.md) + +## 先回答:要支持 nasm 构建吗? + +**要,建议做成一等公民(`.asm` 直接进 sources)**,理由四条: + +1. 手写汇编是 FFmpeg/dav1d/x264/OpenSSL/BLAS 整个生态的常态,mcpp 定位"大型 C/C++ 库源码构建"迟早绕不开; +2. 声明式进 sources 自动获得指纹/增量/并行,不用每个包自己写 build.mcpp 管缓存重跑; +3. nasm 本身是交叉汇编器,原生支持后 cross 零特判(build.mcpp 路线在 cross 下还要先解决跳过问题); +4. Cargo 没有原生汇编(逼出了 cc crate 生态),Zig 有 `addAssemblyFile` —— mcpp 该站 Zig 这边。 + +## 通用能力清单(G1–G9) + +### 声明式构建能力(高杠杆) + +- **G1 原生汇编源**:`.S`(GAS,现有 cc 驱动就能编,覆盖 ARM 汇编,成本≈加后缀路由)+ `.asm`(NASM,`-f` 格式按目标三元组推导,覆盖 x86 汇编)★本清单最高杠杆 +- **G4 per-glob flags**:`[build.flags."**/*.avx2.cpp"] cxxflags=["-mavx2"]` —— 两大用例:SIMD 多档 dispatch TU、三方代码告警隔离(现在只能全包 `-w`,连自己的封装层都被静默) +- **G5 mcpp.toml 的 features gate sources**:index 描述符能做(compat.gtest),mcpp.toml 不能——两边不对称;而"feature = 一组源文件 + 一个 define"正是 vendored 大库最高频形态(imgcodecs 17 种编解码器、ffmpeg codec 集合、highgui 后端) + +### build.mcpp 补全(通用构建期逻辑) + +- **G2 运行依赖包的 build.mcpp**(实测 0.0.93 静默跳过;作用域照 Cargo:flags 只进该包自身 TU,link 指令传播到终链)——没有它 build.mcpp 只对根项目有意义 +- **G3 build.mcpp 环境契约**:cross 下运行 + 暴露 TARGET/HOST/活跃 features/PROFILE/OUT_DIR(对应 Cargo 的环境变量族)——不暴露 features,build 程序无法按 codec 集合筛汇编列表 + +### 供给与修缮 + +- **G7 xlings 打包 nasm**(≥2.16;缺失硬失败不降级)——**已完成:最新 xlings 已支持 nasm** +- **G8a** glob 不跟目录软链接、**G8b** 相对 `-I` 对 `.cppm` 单元不生效(两个 POC 实测的一致性问题) +- **G6 generated_files 进 mcpp.toml**(与 index 对齐,小项) +- **G9 跨项目构建产物缓存**(生态级远期;缓存键含工具链指纹,与"源码构建"哲学不矛盾) + +## 排期建议 + +G7(已完成)→ **G1**(最高杠杆)→ **G5、G4**(声明式三连,vendored 大库形态就此完整)→ **G2+G3** → G8 顺手 → G6/G9 远期。 + +映射到收录项目:F1+ 的 ffmpeg 汇编加速硬依赖 G7 + (G1 或 G2+G3),G1 路线最短;P5 的 OpenCV SIMD dispatch 硬依赖 G4 或 G2+G3;而 F0/F1(纯 C)和 P0–P4 零阻塞,随时可开工。 diff --git a/docs/05-mcpp-toml.md b/docs/05-mcpp-toml.md index 25e51df8..67e5e84e 100644 --- a/docs/05-mcpp-toml.md +++ b/docs/05-mcpp-toml.md @@ -15,7 +15,7 @@ version = "0.1.0" ``` mcpp infers automatically: -- Source files: `src/**/*.{cppm,cpp,cc,c}` +- Source files: `src/**/*.{cppm,cpp,cc,c,S,s,asm}` - Entry point: `src/main.cpp` → produces the `hello` binary - Standard: C++23 - Modules: scans `export module ...` declarations and builds the dependency graph automatically @@ -134,7 +134,7 @@ the package/feature boundary, not on an individual target. ```toml [build] -sources = ["src/**/*.cppm", "src/**/*.cpp"] # Source globs (default: src/**/*.{cppm,cpp,cc,c}) +sources = ["src/**/*.cppm", "src/**/*.cpp"] # Source globs (default: src/**/*.{cppm,cpp,cc,c,S,s,asm}) include_dirs = ["include", "third_party/include"] # Header search paths c_standard = "c11" # Standard for C source files (default c11) cflags = ["-DFOO=1"] # Extra C compile flags @@ -188,6 +188,18 @@ sources = [ ] ``` +**Assembly sources** (mcpp 0.0.95+): `.S`/`.s` (GAS — preprocessed by the C +driver, covers ARM and AT&T-syntax x86) and `.asm` (NASM — Intel-syntax x86) +are first-class sources: default-globbed, fingerprinted, built incrementally +in parallel, and linked like any other object. The NASM output format is +derived from the target triple (`elf64`/`win64`/`macho64`/... — cross builds +just work), and `nasm` itself is resolved lazily only when `.asm` units exist: +`PATH` first, then the mcpp sandbox, then `xlings install nasm`; if none +yields nasm ≥ 2.16 the build **fails hard** (assembly is never silently +skipped). Limits: `.asm` targets x86 only (hard error elsewhere — gate the +files off other targets), `.S` is unavailable on the MSVC toolchain, and +`.asm` means NASM syntax (MASM sources should be `!`-excluded). + ### 2.4 `[lib]` — Library Root Module Convention ```toml @@ -718,7 +730,7 @@ mcpp build --target x86_64-linux-musl | Item | Default | Notes | |---|---|---| -| Source files | `src/**/*.{cppm,cpp,cc,c}` | Scanned recursively and automatically | +| Source files | `src/**/*.{cppm,cpp,cc,c,S,s,asm}` | Scanned recursively and automatically | | Entry point | `src/main.cpp` | If this file exists, a `bin` target is inferred | | Library root | `src/.cppm` | Override with `[lib].path` | | C++ standard | `c++23` | Configure with `[package].standard`; supports `c++26` / `c++2c` / `c++latest` / `c++fly` (experimental playground) | diff --git a/docs/zh/05-mcpp-toml.md b/docs/zh/05-mcpp-toml.md index 56781b6c..fd72613d 100644 --- a/docs/zh/05-mcpp-toml.md +++ b/docs/zh/05-mcpp-toml.md @@ -15,7 +15,7 @@ version = "0.1.0" ``` mcpp 自动推断: -- 源文件: `src/**/*.{cppm,cpp,cc,c}` +- 源文件: `src/**/*.{cppm,cpp,cc,c,S,s,asm}` - 入口: `src/main.cpp` → 生成 `hello` 二进制 - 标准: C++23 - 模块: 扫描 `export module ...` 声明自动建立依赖图 @@ -129,7 +129,7 @@ mcpp 刻意不在一次构建里把同一个共享源编译成两份:一个源 ```toml [build] -sources = ["src/**/*.cppm", "src/**/*.cpp"] # 源文件 glob(默认: src/**/*.{cppm,cpp,cc,c}) +sources = ["src/**/*.cppm", "src/**/*.cpp"] # 源文件 glob(默认: src/**/*.{cppm,cpp,cc,c,S,s,asm}) include_dirs = ["include", "third_party/include"] # 头文件搜索路径 c_standard = "c11" # C 源文件的标准(默认 c11) cflags = ["-DFOO=1"] # 额外 C 编译参数 @@ -635,7 +635,7 @@ mcpp build --target x86_64-linux-musl | 项目 | 默认值 | 说明 | |---|---|---| -| 源文件 | `src/**/*.{cppm,cpp,cc,c}` | 自动递归扫描 | +| 源文件 | `src/**/*.{cppm,cpp,cc,c,S,s,asm}` | 自动递归扫描 | | 入口 | `src/main.cpp` | 有这个文件就推断为 `bin` 目标 | | 库根 | `src/.cppm` | 可用 `[lib].path` 覆盖 | | C++ 标准 | `c++23` | 用 `[package].standard` 配置; 支持 `c++26` / `c++2c` | diff --git a/src/build/compile_commands.cppm b/src/build/compile_commands.cppm index e2fa2188..d2c47629 100644 --- a/src/build/compile_commands.cppm +++ b/src/build/compile_commands.cppm @@ -123,10 +123,19 @@ std::string emit_compile_commands(const BuildPlan& plan, const CompileFlags& fla nlohmann::json entries = nlohmann::json::array(); for (auto& cu : plan.compileUnits) { - // Pick compiler + flags based on source type. - const bool isCSource = is_c_source(cu.source); + // NASM units carry a command line no CDB consumer (clangd, …) can + // interpret — a bogus entry actively harms LSP diagnostics, so they + // are excluded from the CDB entirely. + if (cu.source.extension() == ".asm") continue; + // Pick compiler + flags based on source type. GAS units (.S/.s) ride + // the C driver with the asm-safe flag string, mirroring build.ninja. + const auto ext = cu.source.extension(); + const bool isGasSource = ext == ".S" || ext == ".s"; + const bool isCSource = is_c_source(cu.source) || isGasSource; const auto& compiler = isCSource ? flags.ccBinary : flags.cxxBinary; - const auto& flagStr = isCSource ? flags.cc : flags.cxx; + const auto& flagStr = isGasSource ? flags.as + : isCSource ? flags.cc + : flags.cxx; auto output_path = (plan.outputDir / cu.object).string(); diff --git a/src/build/flags.cppm b/src/build/flags.cppm index dce0b410..78ac9731 100644 --- a/src/build/flags.cppm +++ b/src/build/flags.cppm @@ -26,6 +26,8 @@ export namespace mcpp::build { struct CompileFlags { std::string cxx; // full cxxflags string std::string cc; // full cflags string + std::string as; // asm-safe subset for .S/.s via the C driver + std::string nasm; // NASM global flags (.asm; own spelling) std::string ld; // ldflags string std::filesystem::path cxxBinary; // g++ / clang++ / cl.exe std::filesystem::path ccBinary; // gcc / clang (derived; cl.exe = same) @@ -319,6 +321,30 @@ CompileFlags compute_flags(const BuildPlan& plan) { : std::format("{}{}{}{}{}{}{}", d.stdPrefix, c_std, opt_flag, pic_flag, compile_toolchain_flags, b_flag, include_flags); + // GAS assembly (.S/.s via the C driver): the asm-safe subset — no -std + // (C-only) and no -O (meaningless), but PIC stays (.S sources gate on + // __PIC__), -g is fine, and the toolchain-location flags must come along + // (hermetic link model: never fall back to a host `as`). MSVC dialect has + // no GAS path — prepare hard-errors before these flags are consumed. + f.as = std::format("{}{}{}{}{}", + prof.debug ? " -g" : "", pic_flag, + compile_toolchain_flags, b_flag, include_flags); + + // NASM (.asm): fixed GNU-ish spelling of its own — include dirs are + // re-spelt with -I regardless of dialect (nasm ≥2.14 inserts a missing + // path separator itself); DWARF debug info exists on ELF only. + if (!plan.nasmPath.empty()) { + std::string nasm_includes; + for (auto& inc : plan.manifest.buildConfig.includeDirs) { + auto abs = inc.is_absolute() ? inc : (plan.projectRoot / inc); + nasm_includes += " -I" + escape_path(abs); + } + std::string nasm_debug; + if (prof.debug && plan.nasmFormat.starts_with("elf")) + nasm_debug = " -g -F dwarf"; + f.nasm = nasm_debug + nasm_includes; + } + // Link flags f.staticStdlib = plan.manifest.buildConfig.staticStdlib; f.linkage = plan.manifest.buildConfig.linkage; diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index 1efbbf90..94e20bd2 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -151,6 +151,35 @@ bool is_c_source(const std::filesystem::path& src) { return ext == ".c" || ext == ".m"; } +bool is_gas_source(const std::filesystem::path& src) { + auto ext = src.extension(); + return ext == ".S" || ext == ".s"; +} + +bool is_nasm_source(const std::filesystem::path& src) { + return src.extension() == ".asm"; +} + +// TUs the P1689 module scan must skip: C-family and assembly units cannot +// contain `import`/`module` declarations, and feeding them to the scanner +// would route them through the C++ frontend. +bool is_scan_exempt(const std::filesystem::path& src) { + return is_c_source(src) || is_gas_source(src) || is_nasm_source(src); +} + +// Per-unit flags an assembler can take: the -D/-U/-I subset of the unit's C +// flags (feature defines land there). NASM shares the GNU -D/-U/-I spelling +// (and ≥2.14 inserts a missing -I path separator itself), so one filter +// serves both asm rules. +std::vector asm_unit_flags(const CompileUnit& cu) { + std::vector out; + for (auto& f : cu.packageCflags) { + if (f.starts_with("-D") || f.starts_with("-U") || f.starts_with("-I")) + out.push_back(f); + } + return out; +} + std::string ltrim_copy(std::string_view s) { while (!s.empty() && std::isspace(static_cast(s.front()))) s.remove_prefix(1); @@ -275,20 +304,29 @@ std::string emit_ninja_string(const BuildPlan& plan) { // All compile/link flags are computed once via flags.cppm. auto flags = compute_flags(plan); - bool need_c_rule = false; + bool need_c_rule = false, need_asm_rule = false, need_nasm_rule = false; for (auto& cu : plan.compileUnits) { - if (is_c_source(cu.source)) { - need_c_rule = true; - break; - } + if (is_c_source(cu.source)) need_c_rule = true; + else if (is_gas_source(cu.source)) need_asm_rule = true; + else if (is_nasm_source(cu.source)) need_nasm_rule = true; } append(std::format("cxx = {}\n", escape_ninja_path(flags.cxxBinary))); append(std::format("cxxflags = {}\n", flags.cxx)); - if (need_c_rule) { + if (need_c_rule || need_asm_rule) { // asm_object drives the C compiler too append(std::format("cc = {}\n", escape_ninja_path(flags.ccBinary))); + } + if (need_c_rule) { append(std::format("cflags = {}\n", flags.cc)); } + if (need_asm_rule) { + append(std::format("asmflags ={}\n", flags.as)); + } + if (need_nasm_rule) { + append(std::format("nasm = {}\n", escape_ninja_path(plan.nasmPath))); + append(std::format("nasmfmt = {}\n", plan.nasmFormat)); + append(std::format("nasmflags ={}\n", flags.nasm)); + } append(std::format("ldflags ={}\n", flags.ld)); // `ar` for cxx_archive. @@ -406,6 +444,28 @@ std::string emit_ninja_string(const BuildPlan& plan) { append("\n"); } + if (need_asm_rule) { + // GAS assembly (.S/.s) through the C driver: it preprocesses .S (cpp) + // and assembles both, dispatching by extension. $asmflags is the + // asm-safe flag subset (no -std / no -O — see flags.cppm). + append("rule asm_object\n"); + append(std::format( + " command = $cc $local_includes $asmflags $unit_asmflags {}\n", + compile_tail)); + append(" description = AS $out\n\n"); + } + + if (need_nasm_rule) { + // NASM (.asm): its own fixed flag spelling — deliberately outside + // CommandDialect. -MD/-MQ feed ninja's header tracking for %include. + append("rule nasm_object\n"); + append(" command = $nasm -f $nasmfmt $local_includes $nasmflags " + "$unit_asmflags -MD $out.d -MQ $out -o $out $in\n"); + append(" deps = gcc\n"); + append(" depfile = $out.d\n"); + append(" description = NASM $out\n\n"); + } + // Link/archive/shared: driver-style (g++/clang++ are the linker) vs the // msvc dialect's separate link.exe/lib.exe. The msvc commands go through // response files — object lists exceed cmd.exe's 8191-char limit fast. @@ -533,6 +593,10 @@ std::string emit_ninja_string(const BuildPlan& plan) { return "cxx_module"; if (ext == ".c" || ext == ".m") return "c_object"; + if (ext == ".S" || ext == ".s") + return "asm_object"; + if (ext == ".asm") + return "nasm_object"; return "cxx_object"; }; @@ -549,7 +613,7 @@ std::string emit_ninja_string(const BuildPlan& plan) { std::vector ddi_paths; ddi_paths.reserve(plan.compileUnits.size()); for (auto& cu : plan.compileUnits) { - if (is_c_source(cu.source)) + if (is_scan_exempt(cu.source)) continue; auto ddi = (cu.object.parent_path() / cu.source.filename()).string() + ".ddi"; ddi_paths.push_back(ddi); @@ -580,7 +644,7 @@ std::string emit_ninja_string(const BuildPlan& plan) { }(); std::map ddi_expect; for (auto& cu : plan.compileUnits) { - if (is_c_source(cu.source)) continue; + if (is_scan_exempt(cu.source)) continue; if (!cu.scanOverridden && !verifyAll) continue; auto ddi = (cu.object.parent_path() / cu.source.filename()).string() + ".ddi"; std::string exp; @@ -618,7 +682,7 @@ std::string emit_ninja_string(const BuildPlan& plan) { out_line += " | " + bmi_path(*cu.providesModule); } out_line += std::format(" : {} {}", rule, escape_ninja_path(cu.source)); - if (rule != "c_object") { + if (!is_scan_exempt(cu.source)) { auto ddi = (cu.object.parent_path() / cu.source.filename()).string() + ".ddi"; auto it = ddi_to_dd.find(ddi); if (it != ddi_to_dd.end()) { @@ -637,7 +701,10 @@ std::string emit_ninja_string(const BuildPlan& plan) { } if (auto includes = local_include_flags(cu); !includes.empty()) out_line += " local_includes =" + includes + "\n"; - if (is_c_source(cu.source)) { + if (is_gas_source(cu.source) || is_nasm_source(cu.source)) { + if (auto flags = join_flags(asm_unit_flags(cu)); !flags.empty()) + out_line += " unit_asmflags =" + flags + "\n"; + } else if (is_c_source(cu.source)) { if (auto flags = join_flags(cu.packageCflags); !flags.empty()) out_line += " unit_cflags =" + flags + "\n"; } else { @@ -653,8 +720,8 @@ std::string emit_ninja_string(const BuildPlan& plan) { std::string rule = pick_rule(cu.source); std::string implicit; - // .c files don't `import` modules; skip BMI implicit inputs. - if (rule != "c_object") { + // C/asm files don't `import` modules; skip BMI implicit inputs. + if (!is_scan_exempt(cu.source)) { for (auto& imp : cu.imports) { if (imp == "std") { if (has_std_artifacts) @@ -684,7 +751,10 @@ std::string emit_ninja_string(const BuildPlan& plan) { out_line += "\n"; if (auto includes = local_include_flags(cu); !includes.empty()) out_line += " local_includes =" + includes + "\n"; - if (is_c_source(cu.source)) { + if (is_gas_source(cu.source) || is_nasm_source(cu.source)) { + if (auto flags = join_flags(asm_unit_flags(cu)); !flags.empty()) + out_line += " unit_asmflags =" + flags + "\n"; + } else if (is_c_source(cu.source)) { if (auto flags = join_flags(cu.packageCflags); !flags.empty()) out_line += " unit_cflags =" + flags + "\n"; } else { diff --git a/src/build/plan.cppm b/src/build/plan.cppm index 7f3febd3..9f9a08c3 100644 --- a/src/build/plan.cppm +++ b/src/build/plan.cppm @@ -61,6 +61,11 @@ struct BuildPlan { std::filesystem::path stdCompatBmiPath; // absolute path to prebuilt std.compat.pcm std::filesystem::path stdCompatObjectPath; // absolute path to prebuilt std.compat.o std::filesystem::path scanDepsPath; // clang-scan-deps binary (Clang only) + // NASM assembly (.asm sources). Both resolved in prepare AFTER the plan + // exists — only when the plan actually contains .asm units (lazy, hard + // failure when unavailable; never a silent skip). + std::filesystem::path nasmPath; // nasm binary (empty → no .asm units) + std::string nasmFormat; // -f value derived from the target triple std::vector compileUnits; // topologically sorted std::vector linkUnits; @@ -133,9 +138,17 @@ std::string sanitize_for_path(std::string_view module_name) { std::string object_filename_for(const std::filesystem::path& src, std::string_view objExt = ".o") { + auto ext = src.extension(); + // Assembly siblings of a C/C++ TU commonly share its stem (foo.c + + // foo.asm); keep the full extension in the object name so they can never + // collide — the per-package collision prefix can't help two same-stem + // files in the same directory. + if (ext == ".S" || ext == ".s" || ext == ".asm") { + return src.filename().string() + std::string(objExt); + } auto stem = src.stem().string(); // distinguish .cppm vs .cpp by extension prefix to avoid collisions - return stem + (src.extension() == ".cppm" + return stem + (ext == ".cppm" ? ".m" + std::string(objExt) : std::string(objExt)); } @@ -199,7 +212,8 @@ std::vector runtime_aliases_for_target( bool is_implementation_source(const std::filesystem::path& source) { auto ext = source.extension(); - return ext == ".cpp" || ext == ".cc" || ext == ".cxx" || ext == ".c" || ext == ".m"; + return ext == ".cpp" || ext == ".cc" || ext == ".cxx" || ext == ".c" || ext == ".m" + || ext == ".S" || ext == ".s" || ext == ".asm"; } std::vector shared_library_link_flags(const mcpp::manifest::Target& t) { diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 00538df9..50ec3447 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -2794,6 +2794,52 @@ prepare_build(bool print_fingerprint, } } + // ─── Assembly units: validate + resolve the assembler ───────────── + // .S/.s ride the C driver (GAS) — the MSVC dialect has no such path. + // .asm is NASM: x86-family only, and the binary is resolved LAZILY — + // only when the plan actually contains .asm units — as a hard failure, + // never a silent skip (a dropped .o surfaces as undefined references + // much later; fail here with the real cause instead). + { + bool hasGas = false, hasNasm = false; + for (auto& cu : ctx.plan.compileUnits) { + auto ext = cu.source.extension(); + if (ext == ".S" || ext == ".s") hasGas = true; + else if (ext == ".asm") hasNasm = true; + } + if (hasGas && mcpp::toolchain::dialect_for(*tc).id == "msvc") { + return std::unexpected(std::string( + "GAS assembly sources (.S/.s) are not supported by the MSVC " + "toolchain; use NASM syntax (.asm) or a MinGW/LLVM toolchain, " + "or `!`-exclude them in [build].sources")); + } + if (hasNasm) { + auto trip = mcpp::toolchain::triple::parse(tc->targetTriple) + .value_or(mcpp::toolchain::triple::host_triple()); + auto fmt = trip.nasm_format(); + if (!fmt) { + return std::unexpected(std::format( + "NASM sources (.asm) are x86-only, but the target is {}; " + "gate them off non-x86 targets (a feature, or a " + "`!`-exclude glob in [build].sources)", trip.str())); + } + ctx.plan.nasmFormat = *fmt; + auto cfgNasm = get_cfg(); + std::optional nasmBin; + if (cfgNasm) { + nasmBin = mcpp::xlings::ensure_nasm( + mcpp::config::make_xlings_env(**cfgNasm), /*quiet=*/false, {}); + } + if (!nasmBin) { + return std::unexpected(std::string( + "NASM sources (.asm) present but no usable nasm (>= 2.16) " + "was found or installable; install one via `xlings install " + "nasm` or your system package manager")); + } + ctx.plan.nasmPath = *nasmBin; + } + } + // ─── M3.2: BMI cache stage / populate-task collection ───────────── // For each version-based dep package (i.e. fetched from a registry, // not a path dep), check the global BMI cache. If cached → stage into diff --git a/src/manifest/toml.cppm b/src/manifest/toml.cppm index a50df685..4d546dc9 100644 --- a/src/manifest/toml.cppm +++ b/src/manifest/toml.cppm @@ -875,16 +875,21 @@ std::expected parse_string(std::string_view content, namespace { void apply_defaults_and_infer(Manifest& m, const std::filesystem::path& root) { - // Default sources glob (covers .cppm/.cpp/.cc/.c under src/). + // Default sources glob (covers .cppm/.cpp/.cc/.c plus assembly under + // src/). Assembly in the tree almost certainly wants building; a project + // that vendors foreign-syntax .asm can `!`-exclude it. if (m.buildConfig.sources.empty()) { m.buildConfig.sources = { "src/**/*.cppm", "src/**/*.cpp", "src/**/*.cc", "src/**/*.c", + "src/**/*.S", + "src/**/*.s", + "src/**/*.asm", }; m.modules.sources = m.buildConfig.sources; // legacy mirror - m.inferredNotes.push_back("sources [src/**/*.{cppm,cpp,cc,c}]"); + m.inferredNotes.push_back("sources [src/**/*.{cppm,cpp,cc,c,S,s,asm}]"); } // Default include_dirs: ["include"] iff /include/ exists. diff --git a/src/modgraph/scanner.cppm b/src/modgraph/scanner.cppm index 2032a80b..f6dc0d4a 100644 --- a/src/modgraph/scanner.cppm +++ b/src/modgraph/scanner.cppm @@ -97,8 +97,12 @@ bool path_matches_glob(const std::filesystem::path& candidate, std::string_view glob) { // Supports "**" (any number of dirs) and "*" (within one segment). - // Matches relative-path of candidate against glob. - auto rel = std::filesystem::relative(candidate, root).generic_string(); + // Matches relative-path of candidate against glob. LEXICAL relative: + // fs::relative() canonicalizes, which would resolve a path reached + // through a directory symlink back to its real location and break the + // match (globs are about where a file appears in the tree, not where + // its bits live). + auto rel = candidate.lexically_relative(root).generic_string(); auto match = [](std::string_view s, std::string_view p) -> bool { // Simple recursive matcher. @@ -210,13 +214,47 @@ bool is_module_name_char(char c) { std::vector expand_glob(const std::filesystem::path& root, std::string_view glob) { - std::vector out; - if (!std::filesystem::exists(root)) return out; - for (auto& e : std::filesystem::recursive_directory_iterator(root)) { - if (!e.is_regular_file()) continue; + namespace fs = std::filesystem; + std::vector out; + if (!fs::exists(root)) return out; + // Follow directory symlinks (vendored trees are often symlink farms). + // Cycle guard: a directory whose canonical path is already on the + // CURRENT recursion chain is a link loop — only that is pruned; the same + // real directory reached via a second lexical path (dir + link to it) + // still walks, because glob matching is lexical. Files reachable twice + // are deduped by canonical identity afterwards. + std::vector chain; // canonical dirs of the recursion stack + std::error_code ec, eec; // ec: iteration; eec: per-entry probes + { + auto c = fs::canonical(root, eec); + chain.push_back(eec ? root : c); + } + fs::recursive_directory_iterator it( + root, fs::directory_options::follow_directory_symlink, ec); + for (fs::recursive_directory_iterator end; !ec && it != end; it.increment(ec)) { + auto& e = *it; + if (e.is_directory(eec) && !eec) { + auto depth = static_cast(it.depth()); + chain.resize(std::min(chain.size(), depth + 1)); + auto c = fs::canonical(e.path(), eec); + if (!eec && std::find(chain.begin(), chain.end(), c) != chain.end()) { + it.disable_recursion_pending(); // link cycle + } else { + chain.push_back(eec ? e.path() : c); + } + continue; + } + if (!e.is_regular_file(eec) || eec) continue; if (path_matches_glob(e.path(), root, glob)) out.push_back(e.path()); } std::sort(out.begin(), out.end()); + // Dedup files reachable through more than one directory link (first + // lexical occurrence wins). + std::set seenFiles; + out.erase(std::remove_if(out.begin(), out.end(), [&](const fs::path& p) { + auto c = fs::canonical(p, eec); + return !eec && !seenFiles.insert(c).second; + }), out.end()); return out; } @@ -233,11 +271,29 @@ std::vector expand_dir_glob(const std::filesystem::path& if (std::filesystem::is_directory(p, ec)) out.push_back(p); return out; } - // Walk all directories under root, match each against the glob. + // Walk all directories under root, match each against the glob. Same + // follow-symlinks + recursion-chain cycle guard as expand_glob above. out.push_back(root); // root itself eligible if glob is "" (rare) - for (auto& e : std::filesystem::recursive_directory_iterator(root, ec)) { - if (ec) break; - if (!e.is_directory(ec) || ec) continue; + std::vector chain; + std::error_code eec; // per-entry probes; ec drives iteration + { + auto c = std::filesystem::canonical(root, eec); + chain.push_back(eec ? root : c); + } + std::filesystem::recursive_directory_iterator it( + root, std::filesystem::directory_options::follow_directory_symlink, ec); + for (std::filesystem::recursive_directory_iterator end; + !ec && it != end; it.increment(ec)) { + auto& e = *it; + if (!e.is_directory(eec) || eec) continue; + auto depth = static_cast(it.depth()); + chain.resize(std::min(chain.size(), depth + 1)); + auto c = std::filesystem::canonical(e.path(), eec); + if (!eec && std::find(chain.begin(), chain.end(), c) != chain.end()) { + it.disable_recursion_pending(); // link cycle + continue; + } + chain.push_back(eec ? e.path() : c); if (path_matches_glob(e.path(), root, glob)) out.push_back(e.path()); } out.erase(out.begin()); // drop root sentinel @@ -260,8 +316,11 @@ std::expected scan_file(const std::filesystem::path& file // declarations, and we route them to the C-language compile rule (no // P1689 scan, no BMI lookups). Skip the line-by-line module scan to // avoid any chance of a benign identifier (`import_foo`, `module_t`, ...) - // being misparsed. Objective-C .m files use the same C-like path. - if (file.extension() == ".c" || file.extension() == ".m") { + // being misparsed. Objective-C .m files use the same C-like path, and so + // does assembly (.S/.s via the C driver, .asm via NASM). + auto sext = file.extension(); + if (sext == ".c" || sext == ".m" + || sext == ".S" || sext == ".s" || sext == ".asm") { return u; } diff --git a/src/toolchain/triple.cppm b/src/toolchain/triple.cppm index d2d9edde..3e91d32c 100644 --- a/src/toolchain/triple.cppm +++ b/src/toolchain/triple.cppm @@ -56,6 +56,20 @@ struct Triple { return {}; } + // NASM `-f` output format for this target. NASM is x86-family only: + // nullopt off x86, and the caller must hard-error (suggesting cfg-gated + // sources) rather than pick a format. + std::optional nasm_format() const { + bool x64 = arch == "x86_64"; + bool x32 = arch == "x86" || arch == "i386" || arch == "i486" + || arch == "i586" || arch == "i686"; + if (!x64 && !x32) return std::nullopt; + if (os == "windows") return x64 ? "win64" : "win32"; + if (os == "macos") return x64 ? "macho64" : "macho32"; + if (os == "linux") return x64 ? "elf64" : "elf32"; + return std::nullopt; + } + bool operator==(const Triple&) const = default; }; diff --git a/src/xlings.cppm b/src/xlings.cppm index c1ea7c42..358d6383 100644 --- a/src/xlings.cppm +++ b/src/xlings.cppm @@ -266,6 +266,13 @@ void ensure_patchelf(const Env& env, bool quiet, void ensure_ninja(const Env& env, bool quiet, const BootstrapProgressCallback& cb); +// Resolve a usable nasm (≥ kNasmMinMajor.kNasmMinMinor): PATH first (CI +// images ship one), the mcpp sandbox second, `xlings install nasm` third. +// Called lazily — only when a build plan actually contains .asm units. The +// caller HARD-FAILS on nullopt; assembly sources are never silently skipped. +std::optional ensure_nasm(const Env& env, bool quiet, + const BootstrapProgressCallback& cb); + // ─── Index freshness ──────────────────────────────────────────────── // Check whether the default mcpplibs index data exists and is fresh @@ -1233,6 +1240,60 @@ void ensure_ninja(const Env& env, bool quiet, } } +namespace { + +// "NASM version 2.16.03 compiled on ..." → true iff ≥ 2.16. A binary that +// fails to run or reports something unparseable is unusable — false. +bool nasm_version_ok(const std::filesystem::path& bin) { + auto r = mcpp::platform::process::capture_exec({bin.string(), "-v"}); + if (r.exit_code != 0) return false; + auto pos = r.output.find("version "); + if (pos == std::string::npos) return false; + int major = 0, minor = 0; + const char* p = r.output.c_str() + pos + 8; + while (*p >= '0' && *p <= '9') major = major * 10 + (*p++ - '0'); + if (*p++ != '.') return false; + while (*p >= '0' && *p <= '9') minor = minor * 10 + (*p++ - '0'); + return major > 2 || (major == 2 && minor >= 16); +} + +std::optional find_sandbox_nasm(const Env& env) { + auto root = paths::xim_tool_root(env, "nasm"); + auto nasm_name = std::string("nasm") + std::string(mcpp::platform::exe_suffix); + std::error_code ec; + if (!std::filesystem::exists(root, ec)) return std::nullopt; + for (auto& v : std::filesystem::directory_iterator(root, ec)) { + for (auto cand : { v.path() / nasm_name, v.path() / "bin" / nasm_name }) { + if (std::filesystem::exists(cand, ec) && nasm_version_ok(cand)) + return cand; + } + } + return std::nullopt; +} + +} // namespace + +std::optional ensure_nasm(const Env& env, bool quiet, + const BootstrapProgressCallback& cb) +{ + auto nasm_name = std::string("nasm") + std::string(mcpp::platform::exe_suffix); + if (auto sys = mcpp::platform::fs::which(nasm_name); + sys && nasm_version_ok(*sys)) { + return sys; + } + if (auto boxed = find_sandbox_nasm(env)) return boxed; + + if (!quiet) + print_status("Bootstrap", "nasm into mcpp sandbox (one-time)"); + mcpp::log::ScopedTimer _t_nasm("init", "bootstrap nasm"); + int rc = install_with_progress(env, "xim:nasm", cb, quiet); + if (rc != 0 && !quiet) { + std::println(stderr, + "warning: failed to bootstrap nasm into mcpp sandbox (exit {})", rc); + } + return find_sandbox_nasm(env); +} + // ─── Index freshness ──────────────────────────────────────────────── bool is_index_fresh(const Env& env, std::int64_t ttlSeconds) { diff --git a/tests/e2e/104_asm_sources_gas.sh b/tests/e2e/104_asm_sources_gas.sh new file mode 100755 index 00000000..bb0bf1be --- /dev/null +++ b/tests/e2e/104_asm_sources_gas.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# requires: gcc +# GAS assembly sources (.S) as first-class citizens: globbed by the DEFAULT +# sources glob, routed to the asm_object rule (C driver, asm-safe flags), +# preprocessed (cpp macros work), linked into the binary, and covered on +# BOTH build and test paths (the 0.0.94 feature-sources lesson: always +# compare the two). +set -e + +# x86_64 assembly — host-aware skip elsewhere (aarch64 self-host e2e). +[[ "$(uname -m)" == "x86_64" ]] || { echo "SKIP: x86_64-only asm"; exit 0; } + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT + +cd "$TMP" +"$MCPP" new gasmix > /dev/null +cd gasmix + +# .S is preprocessed: ASM_BIAS proves cpp ran before the assembler. +cat > src/add3.S <<'EOF' +#define ASM_BIAS 7 + .text + .globl asm_add3 +#if defined(__ELF__) + .type asm_add3, @function +#endif +asm_add3: + leaq (%rdi,%rsi), %rax + addq %rdx, %rax + addq $ASM_BIAS, %rax + ret +#if defined(__ELF__) + .section .note.GNU-stack,"",@progbits +#endif +EOF + +cat > src/main.cpp <<'EOF' +import std; +extern "C" long asm_add3(long a, long b, long c); +int main() { + std::println("asm add3 = {}", asm_add3(1, 2, 3)); + return asm_add3(1, 2, 3) == 13 ? 0 : 1; +} +EOF + +# NOTE: no [build].sources — the default glob must pick up the .S file. +cat > mcpp.toml <<'EOF' +[package] +name = "gasmix" +version = "0.1.0" +EOF + +"$MCPP" build > build.log 2>&1 || { cat build.log; echo "build failed"; exit 1; } + +ninja_file="$(find target -name build.ninja | head -1)" +[[ -n "$ninja_file" ]] || { echo "no build.ninja generated"; exit 1; } + +grep -q '^rule asm_object' "$ninja_file" || { + cat "$ninja_file"; echo "missing asm_object rule"; exit 1; } +# Routed to asm_object with the extension-qualified object name (.S.o). +grep -qE 'build obj/add3\.S\.o : asm_object .*add3\.S' "$ninja_file" || { + cat "$ninja_file"; echo "add3.S not routed to asm_object"; exit 1; } +# The global asm flag string must not smuggle in a C/C++ standard. +grep -E '^asmflags =' "$ninja_file" | grep -q -- '-std=' && { + echo "asmflags carries a -std flag"; exit 1; } || true + +out="$("$MCPP" run 2>&1 | tail -1)" +[[ "$out" == "asm add3 = 13" ]] || { echo "unexpected output: $out"; exit 1; } + +# Incremental: touching the .S recompiles and relinks successfully. +touch src/add3.S +"$MCPP" build > rebuild.log 2>&1 || { cat rebuild.log; echo "rebuild failed"; exit 1; } +out="$("$MCPP" run 2>&1 | tail -1)" +[[ "$out" == "asm add3 = 13" ]] || { echo "post-rebuild output: $out"; exit 1; } + +# Test path: the asm object must link into test binaries too. +mkdir -p tests +cat > tests/test_asm.cpp <<'EOF' +extern "C" long asm_add3(long a, long b, long c); +int main() { return asm_add3(2, 3, 4) == 16 ? 0 : 1; } +EOF +"$MCPP" test > test.log 2>&1 || { cat test.log; echo "mcpp test failed"; exit 1; } + +echo "OK" diff --git a/tests/e2e/105_asm_sources_nasm.sh b/tests/e2e/105_asm_sources_nasm.sh new file mode 100755 index 00000000..72d1d2ee --- /dev/null +++ b/tests/e2e/105_asm_sources_nasm.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# requires: gcc nasm +# NASM assembly sources (.asm) as first-class citizens: default-globbed, +# routed to the nasm_object rule with `-f` derived from the target triple, +# include dirs + %include tracked through the depfile (-MD), excluded from +# compile_commands.json, and linked into the binary. +set -e + +[[ "$(uname -m)" == "x86_64" ]] || { echo "SKIP: x86-only NASM"; exit 0; } + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT + +cd "$TMP" +"$MCPP" new nasmix > /dev/null +cd nasmix + +mkdir -p inc +cat > inc/consts.inc <<'EOF' +%define MUL_BIAS 5 +EOF + +cat > src/mul2.asm <<'EOF' +%include "consts.inc" +section .text +global asm_mul2 +asm_mul2: + lea rax, [rdi*2 + MUL_BIAS] + ret +EOF + +cat > src/main.cpp <<'EOF' +import std; +extern "C" long asm_mul2(long a); +int main() { + std::println("asm mul2 = {}", asm_mul2(10)); + return asm_mul2(10) == 25 ? 0 : 1; +} +EOF + +# No [build].sources — the default glob must pick up the .asm file. +cat > mcpp.toml <<'EOF' +[package] +name = "nasmix" +version = "0.1.0" + +[build] +include_dirs = ["inc"] +EOF + +"$MCPP" build > build.log 2>&1 || { cat build.log; echo "build failed"; exit 1; } + +ninja_file="$(find target -name build.ninja | head -1)" +[[ -n "$ninja_file" ]] || { echo "no build.ninja generated"; exit 1; } + +grep -q '^rule nasm_object' "$ninja_file" || { + cat "$ninja_file"; echo "missing nasm_object rule"; exit 1; } +grep -q '^nasmfmt = elf64' "$ninja_file" || { + cat "$ninja_file"; echo "nasmfmt not derived as elf64"; exit 1; } +grep -qE 'build obj/mul2\.asm\.o : nasm_object .*mul2\.asm' "$ninja_file" || { + cat "$ninja_file"; echo "mul2.asm not routed to nasm_object"; exit 1; } + +# NASM units must be excluded from the CDB (clangd can't read them). +if [[ -f compile_commands.json ]]; then + grep -q 'mul2\.asm' compile_commands.json && { + echo "mul2.asm leaked into compile_commands.json"; exit 1; } || true +fi + +out="$("$MCPP" run 2>&1 | tail -1)" +[[ "$out" == "asm mul2 = 25" ]] || { echo "unexpected output: $out"; exit 1; } + +# %include dependency tracking: changing the .inc must recompile the .asm +# (via nasm -MD depfile) and change the observable result. +cat > inc/consts.inc <<'EOF' +%define MUL_BIAS 6 +EOF +"$MCPP" build > rebuild.log 2>&1 || { cat rebuild.log; echo "rebuild failed"; exit 1; } +out="$("$MCPP" run 2>&1 | tail -1)" +[[ "$out" == "asm mul2 = 26" ]] || { + echo "stale after %include change: $out (depfile tracking broken)"; exit 1; } + +echo "OK" diff --git a/tests/e2e/run_all.sh b/tests/e2e/run_all.sh index b604dee7..cb1f954a 100755 --- a/tests/e2e/run_all.sh +++ b/tests/e2e/run_all.sh @@ -99,6 +99,14 @@ case "$OS" in Linux|Darwin) CAPS+=(symlink) ;; esac +# nasm: the x86 assembler for .asm sources (PATH — including the xlings +# subos shim — or the mcpp sandbox tool dir). +if command -v nasm &>/dev/null \ + || ls "${MCPP_HOME}/registry/data/xpkgs/xim-x-nasm"/*/nasm 2>/dev/null | head -1 | grep -q . \ + || ls "${MCPP_HOME}/registry/data/xpkgs/xim-x-nasm"/*/bin/nasm 2>/dev/null | head -1 | grep -q .; then + CAPS+=(nasm) +fi + # scan-deps: clang-scan-deps available (needed for P1689 / Clang dyndep flows) if command -v clang-scan-deps &>/dev/null \ || ls "${MCPP_HOME}/registry/data/xpkgs/xim-x-llvm"/*/bin/clang-scan-deps 2>/dev/null | head -1 | grep -q . \ diff --git a/tests/unit/test_modgraph.cpp b/tests/unit/test_modgraph.cpp index 25663281..b5ac1e79 100644 --- a/tests/unit/test_modgraph.cpp +++ b/tests/unit/test_modgraph.cpp @@ -95,6 +95,51 @@ TEST(Scanner, IgnoresImportInsideSingleLineRawString) { std::filesystem::remove_all(dir); } +// Assembly units skip the module text scan entirely (like .c): an .asm/.S +// file legally contains no `import`/`module` declarations, and asm comment +// syntax would misparse. +TEST(Scanner, AssemblySourcesSkipModuleScan) { + auto dir = make_tempdir("mcpp-scanner-asm"); + write(dir / "src" / "simd.asm", + "; import std -- a comment, not a declaration\n" + "section .text\n" + "global sum2\n" + "sum2:\n lea rax, [rdi+rsi]\n ret\n"); + write(dir / "src" / "copy.S", + "#include \"defs.h\"\n" + ".text\n.globl asm_copy\nasm_copy:\n ret\n"); + + for (auto name : { "simd.asm", "copy.S" }) { + auto u = scan_file(dir / "src" / name, "pkg"); + ASSERT_TRUE(u.has_value()) << u.error().format(); + EXPECT_FALSE(u->provides.has_value()) << name; + EXPECT_TRUE(u->requires_.empty()) << name; + } + + std::filesystem::remove_all(dir); +} + +// G8a: source globs follow directory symlinks (vendored trees are often +// symlink farms) without looping on cycles. +TEST(Scanner, ExpandGlobFollowsDirectorySymlinks) { + auto dir = make_tempdir("mcpp-scanner-symlink"); + auto real = dir / "vendor-real"; + write(real / "impl.cpp", "int f() { return 1; }\n"); + std::error_code ec; + std::filesystem::create_directories(dir / "src"); + std::filesystem::create_directory_symlink(real, dir / "src" / "vendor", ec); + if (ec) GTEST_SKIP() << "symlinks unsupported here: " << ec.message(); + // A cycle: the linked tree points back at its parent. + std::filesystem::create_directory_symlink(dir, real / "loop", ec); + + auto files = expand_glob(dir, "src/**/*.cpp"); + + ASSERT_EQ(files.size(), 1u); + EXPECT_EQ(files[0].filename(), "impl.cpp"); + + std::filesystem::remove_all(dir); +} + TEST(Scanner, RecordsPackageLocalIncludeDirs) { auto dir = make_tempdir("mcpp-scanner-includes"); write(dir / "src" / "foo.cpp", diff --git a/tests/unit/test_ninja_backend.cpp b/tests/unit/test_ninja_backend.cpp index e1aad6fd..2f664f07 100644 --- a/tests/unit/test_ninja_backend.cpp +++ b/tests/unit/test_ninja_backend.cpp @@ -127,6 +127,99 @@ TEST(NinjaBackend, CxxFlagsIncludeBuildIncludeDirs) { << flags.cxx; } +// ── assembly sources (.S/.s → asm_object via $cc, .asm → nasm_object) ──────── + +TEST(NinjaBackend, GasSourceUsesAsmObjectRule) { + auto plan = minimal_plan(); + plan.compileUnits.push_back({ + .source = "src/copy.S", + .object = "obj/copy.S.o", + .packageName = "asm_rule_test", + // Only the -D/-U/-I subset may reach the assembler: -std/-O/-w on an + // asm command line are driver noise (or errors). + .packageCflags = {"-DHAVE_ASM=1", "-std=c99", "-O2", "-w"}, + .packageCxxflags = {"-DWRONG_CXX_FLAG=1"}, + }); + + auto ninja = emit_ninja_string(plan); + + EXPECT_NE(ninja.find("rule asm_object"), std::string::npos) << ninja; + EXPECT_NE(ninja.find("build obj/copy.S.o : asm_object src/copy.S"), + std::string::npos) << ninja; + EXPECT_NE(ninja.find("cc = "), std::string::npos) << ninja; + EXPECT_NE(ninja.find("unit_asmflags = -DHAVE_ASM=1\n"), std::string::npos) + << ninja; + EXPECT_EQ(ninja.find("unit_asmflags = -DHAVE_ASM=1 -std=c99"), std::string::npos) + << ninja; + // The global asm flag string must not carry a C standard or opt level. + auto asmline_pos = ninja.find("asmflags ="); + ASSERT_NE(asmline_pos, std::string::npos) << ninja; + auto asmline = ninja.substr(asmline_pos, ninja.find('\n', asmline_pos) - asmline_pos); + EXPECT_EQ(asmline.find("-std="), std::string::npos) << asmline; + EXPECT_EQ(asmline.find("-O"), std::string::npos) << asmline; +} + +TEST(NinjaBackend, NasmSourceUsesNasmRuleWithDerivedFormat) { + auto plan = minimal_plan(); + plan.nasmPath = "/opt/bin/nasm"; + plan.nasmFormat = "elf64"; + plan.compileUnits.push_back({ + .source = "src/simd.asm", + .object = "obj/simd.asm.o", + .packageName = "nasm_rule_test", + .packageCflags = {"-DHAVE_AVX2=1", "-O2"}, + }); + + auto ninja = emit_ninja_string(plan); + + EXPECT_NE(ninja.find("rule nasm_object"), std::string::npos) << ninja; + EXPECT_NE(ninja.find("nasm = /opt/bin/nasm"), std::string::npos) << ninja; + EXPECT_NE(ninja.find("nasmfmt = elf64"), std::string::npos) << ninja; + EXPECT_NE(ninja.find("build obj/simd.asm.o : nasm_object src/simd.asm"), + std::string::npos) << ninja; + EXPECT_NE(ninja.find("unit_asmflags = -DHAVE_AVX2=1\n"), std::string::npos) + << ninja; +} + +TEST(NinjaBackend, NoAsmRulesWithoutAsmSources) { + auto plan = minimal_plan(); + plan.compileUnits.push_back({ + .source = "src/main.cpp", + .object = "obj/main.o", + .packageName = "plain_test", + }); + + auto ninja = emit_ninja_string(plan); + + EXPECT_EQ(ninja.find("rule asm_object"), std::string::npos) << ninja; + EXPECT_EQ(ninja.find("rule nasm_object"), std::string::npos) << ninja; +} + +TEST(NinjaBackend, CompileCommandsSkipNasmAndCoverGas) { + auto plan = minimal_plan(); + plan.nasmPath = "/opt/bin/nasm"; + plan.nasmFormat = "elf64"; + plan.compileUnits.push_back({ + .source = "src/simd.asm", + .object = "obj/simd.asm.o", + .packageName = "cdb_test", + }); + plan.compileUnits.push_back({ + .source = "src/copy.S", + .object = "obj/copy.S.o", + .packageName = "cdb_test", + }); + + auto flags = compute_flags(plan); + auto cdb = emit_compile_commands(plan, flags); + + // NASM command lines are meaningless to CDB consumers (clangd) — excluded. + EXPECT_EQ(cdb.find("simd.asm"), std::string::npos) << cdb; + // GAS units ride the C driver and stay in the CDB. + EXPECT_NE(cdb.find("copy.S"), std::string::npos) << cdb; + EXPECT_EQ(cdb.find("\"-std=c11\""), std::string::npos) << cdb; // asm-safe flags, no C std +} + TEST(NinjaBackend, RootPackageCxxflagsAreEmittedOncePerUnit) { auto plan = minimal_plan(); plan.manifest.buildConfig.cxxflags = {"-DROOT_FLAG=1"}; diff --git a/tests/unit/test_toolchain_triple.cpp b/tests/unit/test_toolchain_triple.cpp index 326c7946..82268946 100644 --- a/tests/unit/test_toolchain_triple.cpp +++ b/tests/unit/test_toolchain_triple.cpp @@ -126,6 +126,26 @@ TEST(Triple, DidYouMeanStaysQuietOnGarbage) { EXPECT_FALSE(did_you_mean("totally-unrelated-string-xyz").has_value()); } +// ── nasm output format ─────────────────────────────────────────────────────── + +TEST(Triple, NasmFormatCoversX86Targets) { + EXPECT_EQ(parse("x86_64-linux-gnu")->nasm_format(), "elf64"); + EXPECT_EQ(parse("x86_64-linux-musl")->nasm_format(), "elf64"); + EXPECT_EQ(parse("x86_64-windows-gnu")->nasm_format(), "win64"); + EXPECT_EQ(parse("x86_64-windows-msvc")->nasm_format(), "win64"); + EXPECT_EQ(parse("x86_64-macos")->nasm_format(), "macho64"); + EXPECT_EQ(parse("i686-linux-gnu")->nasm_format(), "elf32"); + EXPECT_EQ(parse("i686-windows-gnu")->nasm_format(), "win32"); +} + +TEST(Triple, NasmFormatIsNulloptOffX86) { + // NASM is an x86-family assembler; non-x86 targets have no format and the + // caller must hard-error (with a cfg-gating hint) instead of guessing. + EXPECT_FALSE(parse("aarch64-linux-musl")->nasm_format().has_value()); + EXPECT_FALSE(parse("aarch64-macos")->nasm_format().has_value()); + EXPECT_FALSE(parse("riscv64-linux-musl")->nasm_format().has_value()); +} + // ── host ───────────────────────────────────────────────────────────────────── TEST(Triple, HostTripleIsCanonicalAndNonEmpty) {