Skip to content

Latest commit

 

History

History
394 lines (301 loc) · 20.8 KB

31-authoring-a-rule-package.md

File metadata and controls

394 lines (301 loc) · 20.8 KB

31 —— 编写规则包

读者: 要把一步构建工作打包给其他工程使用的生态作者 —— 一门设备语言、 一个着色器编译器、一个生成的接口,或者一项检查。

本章回答的那一个问题: 一个包怎样供给一条规则,以及消费方要写什么才能 用上它。

不在这里: 给单个工程自己的构建加一步,那是 30 —— 构建程序 —— 同一批原语,规模更小;feature 相关 的键本身,那是 06;以及已发布规则各自 的拼法,那属于 mcpp:plugins。示例: 08-build-rules 做检查与嵌入, 12-a-new-device-language 新增 一门语言。

扩展模型

mcpp 的构建表面由包扩展,而不是由发布扩展。做扩展的有五个点,经由它们进来 的任何名字,引擎都不持有。

扩展点 效果 声明位置
mcpp::action 构建图里的一条边:一条命令,带声明的输入与输出 构建程序,或它 import 的规则模块
device_extensions 一个扩展名被引擎归类为设备源,而不是被拒绝 包的某个 feature
rule_module 消费方的构建程序 import 哪个模块来够到这条规则 同一个 feature
tools = [...] 一个从源码为构建机构建出来的生成器或编译器,用 mcpp::dep_bin 取到 一条依赖边
[xlings]、[feature-xlings] 规则要运行的预建工具,按需安装 包本身,或它的某个 feature

生态用它们建出来的东西:

扩展面 所在包 使用的扩展点
CUDA、HIP、SYCL、Ascend C mcpp:plugins,各一个 feature(42) 驱动厂商编译器的 action,加上按加速器设闸的载荷
Slang mcpp:plugins 的 rules-slang device_extensions = [".slang"] —— 第一门无需引擎点名即被支持的语言
GLSL 与 HLSL 到 SPIR-V,以及其上的模块 mcpp:plugins 的 rules-spirv 每个着色器一条 action,加上一个生成的模块
设备与 C++ 之间的岛边界 mcpp.tools.island 一个生成器,加上 mcpp::generated
作为可链接对象的资源文件 08-build-rules role = "object"
能让构建失败的检查 08-build-rules role = "check"
引擎从未听说过的语言 12-a-new-device-language device_extensions,加上经 tools = [...] 构建出来的编译器

这个模型能表达的形态

一门新语言,不论由什么编译它。 一条规则声明它认领的扩展名,为每个源 提交一条 action,并把编译器列进这条 action 的输入。这个编译器可以是厂商 工具包、一个 LLVM 前端、一个发出设备二进制的解释器,也可以是规则包自己从 源码构建出来的程序。它产出的是设备二进制、目标文件还是 C++,由 action 的 role 决定,别无其他。引擎始终不学习这门语言:它学到的是"某个扩展名是 设备源"以及"某条 action 认领它"。

预处理与代码生成。 role = "source" 的 action 产出 C++,由声明它的包 随后编译,而该包的每一条编译边都要等它。输入可以是一个模板、一份接口 定义、一张表,或另一条 action 的输出 —— 串联是常规做法,因为 action 之间 由文件定序、并计入指纹。

一个一半是 C++、一半是另一种语言的文件。 归类发生在任何规则运行之前, 所以一个带着引擎自有扩展名的文件按 C++ 编译,永远到不了规则那里。因此一个 携带外来代码块的源,要使用规则认领的扩展名,再由规则把它拆开:抽出来的 C++ 走 role = "source",外来的那一半走规则自己的编译器,两者之间的缝就是 42 —— 异构硬件构建 里的 extern "C" 边界。 本仓库今天没有任何包是这个形态。

边界

声明不能重新归类引擎已经拥有的东西。 一个依赖的 device_extensions 在内建角色之后才被查询,所以一个规则包认领不了 .cpp、.cppm、.c 或 .S。这些是引擎自己的词汇,一个包不得把文件从中挪走。认领它们不会被 诊断,也不产生任何效果:实测把 ".cpp" 加进某条规则的 device_extensions, 消费方的 main.cpp 仍按 C++ 编译,构建成功。

模块接口的扩展名是工程的轴,不是规则的轴。 把接口写成 .ixx 的工程 声明 [build] module_extensions(04 —— mcpp.toml 工程文件指南)。 没有任何规则包的键能新增一个,因为一个模块接口要被扫描 import、要产出 BMI、还要进链接 —— 这是三项引擎行为,而不是一条要跑的命令。

两张表都不包含的扩展名会被点名拒绝,所以写错的 device_extensions 会 立刻显形,而不是把一个源默默丢掉:

error: scanner errors:
  .../orphan.zzz: 'orphan.zzz' is listed in [build] sources, and mcpp has no role
  for the extension '.zzz'.
  Its object would be compiled and then linked by nothing, so this is refused rather
  than built.

规则包的定义

三部分,没有一部分是 mcpp 特有的:

部分 内容
一个包 普通的 mcpp.toml,有版本与许可证
一个模块 一个 .cppm,导出 options 与一个提交构建边的函数
一个 feature 选中它的开关,也是它自己的依赖挂靠的地方

引擎里没有规则名,也没有厂商名。消费方命名这个包、激活一个 feature,构建 程序 import 那个模块。

manifest 的三个键

[features]
default = []

[features.rules-toy]
sources           = ["src/rules-toy.cppm"]
rule_module       = "example.rules.toy"
device_extensions = [".toy"]
键 对消费方的作用
sources 只有 feature 激活时才编译该模块
rule_module(2026.9.7.1+) 消费方构建程序 import 的模块;请求该特性即请求包的库。它必须由该特性的某个单元声明(2026.10.11.1+)
device_extensions(2026.9.7.1+) 这些扩展名被归类为设备源:不做 import 扫描、不产出 BMI,且没有 action 认领时被拒绝

只做生成或检查、不编译某种语言的规则不声明 device_extensions; 08-build-rules 就是这个形状,它的消费方把这个包写在 [build-dependencies] 中。

设备扩展名不在默认的 source glob 里。消费方通过点名这些文件来选入:

[build]
sources = ["src/*.cpp", "src/kernels/*.toy"]

消费方 import 的那个模块

每一条已发布规则遵循的写法是:一个带默认值的 options 结构,加一个提交边 的 compile(或 generate)函数:

export module example.rules.toy;
import std;
import mcpp;

export namespace example::rules::toy {

struct options {
    std::string out_dir  = std::string(mcpp::out_dir());
    std::string rule_dir = std::string(mcpp::dep_dir("rules-toy"));
};

bool compile(options opt = {});

}

把"规划边"与"提交边"拆成两个函数的规则,给了消费方一条越过它最后一个 旋钮、而不必手写 action 的路;08-build-rules 的 plan / submit 就是 这个形状。

规则只取自己认领的扩展名,其余留给别人。 mcpp::device_sources() 是 一个串,每行一个包根相对路径,而且它是这个包全部的设备集合。一个有两个 后端的构建把两边的源都放进同一个列表,而构建程序里的每一条规则读到的是 同一个值。

规则包自己的依赖、构建与测试(mcpp 2026.10.11.1+)

规则的模块导入引擎接口 mcpp,这使它成为构建插件(30 —— build.mcpp,构建插件): 只存在于构建程序配置中的包。对作者而言有三个推论。

  • 规则的库所导入的东西写在它的 [dependencies] 中。 基于另一条规则(例如 mcpp:plugins 的 plugins-core)或普通库(例如 TOML 解析器)的规则在那里写出它。消费方的 构建程序使用这条规则时,这些依赖与它一起为构建程序编译。消费方的构建程序自己也导入的依赖, 在其条目上写 reexport = true 交出;不写时该导入被拒绝,诊断写出这个键。规则的 [build-dependencies] 服务规则包自己的 build.mcpp,与任何包相同。
  • 规则包可以作为构建的根构建与测试。 在包中执行 mcpp build 会为本机编译它的库并带上 引擎接口,mcpp test 以同样的方式运行 tests/ 下的测试:测试可以导入规则的模块与 mcpp, 在没有消费方工程的情况下检查规则的逻辑。
  • 它以源码发布。 mcpp pack 拒绝构建插件;消费方的构建用其构建程序的工具链编译它。

本版本之前编写、从自己的 [build-dependencies](host-module = true)导入包的规则包照常构建: 该条目同时被读作规则的依赖,并提示作者移动它(plugin-imports-from-build-dependencies, 51 —— 支持的版本)。

声明工作:mcpp::action

mcpp::action a;
a.id          = id.c_str();          // stable, unique within the package
a.role        = "source";
a.description = desc.c_str();
a.arg("sh").arg(script.c_str()).arg(input.c_str()).arg(output.c_str());
a.input(input.c_str());
a.input(script.c_str());
a.output(output.c_str());
a.submit();

这些字符串必须比 action 活得久。a.id = ("toy:" + stem).c_str() 交给它 的是一个指向临时对象的指针,而该临时对象在 submit() 之前就已经消失。

五种 role

role 决定这条边的输出去哪里,以及这条边什么时候跑。用 mcpp::roles 常量拼写它(mcpp::roles::source、::check、::object、::artifact、 ::prepare),不要用裸字符串:一个不认识某个常量的引擎会拒绝编译这个规则包 并指出它,而不是把一个拼错的字符串读成 source(SPEC-007 R3.6)。

role 输出 次序
source 可编译的那些进入编译集合 声明它的包的每条编译边都等它
check 一个 stamp 文件 与编译并行;blocking = true 让编译等它
object 进入链接集合 链接边消费它们
artifact 一个新文件 它的输入是链接产物,所以它在链接之后跑
prepare 一个 stamp 文件,加上一个声明的目录(output_dir)由命令去填充 整个构建计划里的每条编译边与每条链接边都等它

prepare 用于命令运行时文件名还不知道的施工——把一个包管理器的前缀装起来、 解开一个 SDK——也是 SPEC-007(docs/specs/build-plugins.md)要求依赖适配类 规则包使用的形态,而不是一个 blocking 的 check;完整的约定(output_dir 上的后置条件、命中它内部的重跑依据会得到的 R1.3 警告)见 docs/30 自己那一节。

声明的输入,以及其中的编译器

声明的输入变化时,action 重跑。命令调用的那个工具也是输入。 少了它, 改动规则自己的编译器,会让每条边都是干净的,产物保留上一个编译器产生的 字节 —— 一次覆盖在陈旧结果之上的绿色构建。

action 的命令跑在构建目录里,不是包根。mcpp::device_sources() 答的 是包根相对路径,所以规则要先用 mcpp::manifest_dir() 拼成绝对路径,再放 上命令行。

命令自己发现输入时,声明 depfile

一份 include 了别的文件的着色器或 kernel,有一些规则枚举不出来的输入。 涉及的每个编译器都能产出 depfile ——glslangValidator --depfile、 glslc -MD -MF、slangc -depfile,以及 clang 家族驱动的 -MD -MF:

a.depfile = dep.c_str();          // a path the command writes
a.arg("--depfile").arg(dep.c_str());

depfile 不得同时被声明为 output()。

串接 action

一个 action 可以消费另一个的产物。引擎只负责给它们定序与做指纹,而这就是 一次 device link 在引擎侧的全部内容:N 个 artifact action,其输出不进 链接;再加一个 object action 读取它们,产出真正进链接的对象。

规则自带的环境

一条规则拥有它所驱动的那份包清单,因为运行编译器、读头文件、把库目录放上 链接行的正是它的代码。

[target.'cfg(accelerator = "cuda")'.feature-xlings.rules-cuda]
"xim:cuda-nvcc"   = "12.9.86"
"xim:cuda-cudart" = "12.9.79"

两道闸,下载一个字节之前两道都要开:feature 说这条规则要不要, cfg(accelerator = ...) 选择器说这次构建要不要为设备编译。不带加速器的 构建两道都不开。

一个裸版本是项目可以覆盖的选择;>= 是项目不得低于的要求。见 23 —— 项目环境 的"一个包一个版本"一节。

岛的边界是生成的,而且不由规则生成

mcpp.tools.island 写出设备翻译单元包含的 extern "C" 头,以及 C++ 侧 import 的模块。它不是规则包,mcpp:plugins 里也没有任何东西调用它: 调用它的是工程自己的 build.mcpp,因为模块名与命名空间的形状是工程的 决定,而不是规则的。

规则欠它的只有一个字段。生成的头经由 mcpp::tools::island::force_include_flags 到达岛,而这些旗标要落在设备 编译器自己的命令行上 —— cuda、hip、sycl 与 ascendc 的 options::flags —— 因为那个驱动不继承 mcpp::cxxflag 的任何东西,也因为 把一个头强制灌进每个 C++ 翻译单元,会让声明出现在模块接口的 export module 之前,那是非良构的。

生成器本身见 42 —— 异构硬件构建 的"生成 这个边界"一节。

报告构建应当知道的事

warning —— 成功了,并且仍然被听见

mcpp::warning 是规则"干完了活、但有话要说"的通道:它不得不选择的宿主 编译器,它回落到的载荷。除此之外,构建程序的输出只在非零退出时才被打印。

fact / floor —— 探针通道

规则测量,引擎在任何东西被编译之前比较。

mcpp::fact("cuda.driver", driver_version);
mcpp::floor("cuda.driver", runtime_needs);

引擎读到的是一个名字、一个关系和一个版本;名字是穿过它的数据。一个探不到 答案的探针,不陈述任何答案。

找到规则自己的文件

对象 获取方式
规则包自己的目录树 mcpp::dep_dir("<name>") —— 按消费方在 [dependencies] 里写的那个名字
[xlings.workspace] 声明的载荷 mcpp::xpkg_dir("<name>")
由某个依赖构建出的宿主工具 mcpp::dep_bin("<pkg>", "<tool>")

dep_dir 按消费方 manifest 里的拼法作答。随模块一起发文件的规则,要把该 目录作为一个 option 暴露出来,好让用别的键声明这条边的消费方能提供它;并且 在找不到时以一条点名"找的是什么"的消息拒绝,而不是拿一个空路径去执行 命令。

编写一个分发成员(mcpp 2026.9.11.1+)

一个 dist-* 成员既不是规则也不是工具。它不编译翻译单元,也不在构建程序 运行期间把活干完:它消费链接产物,产出用户要去安装的东西 —— 一个 .msi、一个 .deb、一个 AppImage、一个签过名的 .app。机制是一条 artifact action 加上 mcpp pack --format <name>,见 30 —— 产出可分发物。

本章其余内容原样适用。有六条在这里绑得更紧,每一条都是这个类别会犯、而 规则不会犯的错。

无条件声明,有条件提交。 provides_pack_format 是引擎用来回答"这张图 提供哪些格式"的东西,而这个回答要在一次"什么格式都没要"的构建上给出。一个 只在被问到时才声明的成员,对它的作者照常工作 —— 作者永远传自己那个格式 ——而对其他所有人,这个集合变成不可知的。

同时提供 plan 与 submit。 可分发物是交到用户手上之前的最后一环,因此 它是整个构建里最可能需要项目自己改一笔的部分:换一个压缩等级、多带一个 文件、加第二个签名。generate_all(opt) 恰好等于 submit(plan_all(opt)), 正是让这样一笔改动,不至于变成把成员重新实现一遍的东西。

点名输入,不要去 harvest 一个目录。 一个解析成空的路径是无声的,而一个 缺失的具名输入是错误。实测:一条 WiX action 绑定一个目录并 harvest 它, 当那个路径在 Windows 上解析成空时,产出了一个有效的、空的、52 KB 的 安装包,并且没有任何诊断。${mcpp.target_file:<name>} 就是这个机制 —— 构建程序既不知道三元组也不知道指纹,而一个不存在的 target 名会被拒绝,而 不是展开成一个空路径。

在成功路径上,为自己的输出断言一条下界。 只要成员能判断自己的结果是 空的或不合理的,它就必须经 mcpp::warning 说出来,因为成功构建的 stderr 会被丢弃。这与上一段实测到的是同一个失败,只是被拦在了后一层。

在工具会被查找的那个位置声明它。 xpkg_dir 从 MCPP_XPKG_*_DIR 回答,而这些是 mcpp 为正在被构建的那个包设置的。一个依赖的声明会把 载荷装上,却不会让它对消费方的构建程序可见,所以运行载荷工具的成员要自己 声明它 —— 并且在查找返回空时把这件事说出来,而不是指向一份读者并不拥有的 manifest。

构建程序的库是这条规则的例外,它的声明在使用它的每一个构建程序里 都可见(tests/e2e/622),因为库自己的代码,是作为那些构建 程序各自的一部分运行的,而不是作为一个只经由依赖图才能看到的依赖。这正是 一个成员要在自己身上声明自己的载荷、而拉入这个成员的项目从不重复这条声明 的原因。dist 成员的载荷因此也归属于该成员 —— 当工具服务于某一个 target 时归到 target 轴上,与其他任何按目标区分的声明一样:

[target.'cfg(env = "android")'.feature-xlings.dist-apk]
"xim:android-build-tools" = ""

一个工具从哪里来,只有一个顺序(2026.10.1.3+)。一个规则的工具按每个成员共用的顺序 解析:构建程序自己的选项、该成员历来读取的变量、引擎的覆盖([xlings.overrides])、然后 是声明的载荷。构建程序点名了工具时,该载荷不得因此被安装,所以规则只在最后一步请求 载荷 —— 而只有部分构建需要的载荷声明为 provision = "on-request",并以 mcpp::xpkg_request 请求(docs/23、docs/30)。

mcpp.plugins.tool(mcpp:plugins,在 plugins-core 中)实现这个顺序,包括那一份列出所有 点名方式的拒绝文案,并以 mcpp::decision 记录答案,因此构建会报告来源, mcpp why tool <name> 也能回答。自己解析工具的规则,欠的是同一个顺序与同一份记录。

规则禁止在无人指定时搜索 PATH:在那里找到的工具可以经由构建程序自己的选择 (mcpp::plugins::tool::on_path())或一条覆盖到达,而自行回落到它的规则必须说出它用了 哪一个程序、以及如何陈述它(SPEC-007 R6.2)。

构建不许碰网络,而被包起来的工具可能会碰。 在 appimagetool 1.9.1 上 实测:除非用 --runtime-file 指定一份本地副本,它每次被调用都会从一个 GitHub release 下载它的 type-2 runtime 存根。包装这类工具的成员必须从 自己声明的载荷里把这个文件供上。安装期是下载合法的时候;构建期不是,而 一个会去取东西的构建既不可复现,也不能离线使用。

一个 (name, version) 只指一份载荷。 包装签名或打包工具的成员,继承 了那个工具的兼容面,所以与被包装的工具同步版本是正当的,而且陈述了一件 真事。

当前边界

  • 规则包自己 [features] default 里的规则 feature 不隐含 host-module。 mcpp:plugins 声明 default = [];一个规则默认开启的规则包会被拒绝, 消息点名该模块与需要补的键。
  • mcpp emit xpkg 把 manifest = "mcpp.toml" 写进 mcpp 段,而 mcpp xpkg parse 把该键报为未知,并以 1 退出。mcpp-index 里没有任何 描述符使用它(218 个里 0 个);自带 mcpp.toml 的包整个省略 mcpp 字段。见 09 —— 按场景选命令 的"当前 边界"一节。