第 10 章:feature 完整参考
SML 的设计原则是"从极简到丰富,功能可裁剪"——基础九件套默认开启;复杂能力默认关闭,需要时用 @feature enable 显式开启。
本章是每个 feature 的权威参考:开启方式、语法、报错信息、与其它 feature 的关系。
⚠️ 本章的适用范围(2026-09-19 实测补充):
@feature enable/disable这套门控机制本身 目前只有 Rust(参考实现)与 JS 有 —— C / C++ / Lua 会把@feature …当非法指令 (E-PARSE-005,实测;它们的合法指令只有contract/is/version),因此能力集是固定的。 另外各 feature 的实现面不齐(例如typed-block只有 Rust + JS),逐条情况见下表与 §5.2.2 的审计表;跨实现共享的文档请以@is这类五端都认的写法为准,别依赖@feature。
实现面总表(2026-09-19 真机实测)
判据是跑出来的行为(Rust smltools、C dumper、C++ 自编 runner、luajit、JS parseSafe),
不是读源码猜的。? = 本轮未实测(不代表没有)。
| 特性 | Rust | JS | C | C++ | Lua |
|---|---|---|---|---|---|
@feature 门控机制 |
✅ | ✅ | ❌ E-PARSE-005 |
❌ E-PARSE-005 |
❌ E-PARSE-005 |
bareword-string 裸词即串 |
✅ | ✅ | ✅ | ✅ | ✅ |
include(单文件) |
✅ | ✅ ※ | ✅ | ✅ | ✅ |
env($env.X) |
✅ | ✅ | ✅ | ✅ | ✅ |
contract(@contract / @is) |
✅ | ✅ | ✅ | ✅ | ✅ |
fragment(@base {} + &base) |
✅ 展开 | ✅ | ⚠️ 不展开(留下 &base 键) |
❌ E-PARSE-005 |
❌ E-PARSE-001 |
top-level-array |
✅ | ❌ E-PARSE-003 |
✅ | ❌ E-PARSE-006 |
❌ E-PARSE-006 |
namespace(include … as ns) |
✅ | ✅ | ✅ | ✅ | ❌ E-FEATURE-001 |
implicit-ns |
✅ | ✅ | ? | ? | ? |
multi-include(include "a", "b") |
✅ | ✅ | ✅ | ✅ | ❌ E-FEATURE-001 |
glob-include("inc/*.sml") |
✅ ⚑ | ✅ | ❌ E-INCLUDE-001 |
? | ❌ E-FEATURE-001 |
regex-include(re:"…") |
✅ | ✅(受限子集)⚑ | ? | ❌ | ❌ |
ext-rewrite |
✅ | ? | ? | ? | ? |
when(@when) |
✅ | ❌ E-PARSE-005 |
❌ E-PARSE-005 |
❌ E-PARSE-005 |
❌ E-PARSE-005 |
for(@for) |
✅ | ❌ E-PARSE-005 |
❌ E-PARSE-005 |
❌ E-PARSE-005 |
⚠️ 待复核 |
typed-block |
✅ | ✅ | ❌ 静默(仅 __type 元数据) |
❌ 同 C | ❌ 连元数据都丢 |
※ JS 的 include 由宿主提供文件表:parse(text, { files }) —— JS 侧自己不读盘,
这是为编辑器 / 服务端内嵌设计的(files 是 { "相对路径": "内容" })。不传 files 会得 E-INCLUDE-001。
⚑ glob-include / regex-include 的两端现状(2026-09-19 实测):
① JS 侧补上了这两项(此前 glob 报 E-INCLUDE-001、re:"…" 报 E-LEX-004);
② 用当前源码的构建实测 Rust 与 JS 对同一份输入结论一致(15 例:目录前缀 / 锚 / 错误码 / 0 命中);
③ 曾有「Rust 的 release 二进制不展开 glob(os error 123)」的记录 —— 若你手上那份仍如此,
那说明 binaries 比源码旧(源码里 glob 分支一直在),重编即可;
④ Rust 的 regex-include 在 Windows 上有过一个平台缺陷:模式里的 \. 被当成目录分隔符
(re:"widget_.*\.sml" 被切成 dir=widget_.* ⇒「目录读取失败」),已修(改按 SML 层的 / 切)。
⑤ JS 的 re: 是受限子集:分组 () / 交替 | / {n,m} / \d 这类写法在两端都按字面字符处理
(不是"JS 不支持",是这套正则本来就只有 . [...] * + ? ^ $)。
⚠️ Lua 的 for 待复核:它在无 @feature 时"接受"了 @for,但同时拒绝 @feature —— 需确认是真展开还是当普通键吞掉了。
三条给写文档的人的结论:
@when/@for目前只有 Rust 有(其余四端一律E-PARSE-005)—— 别把它们写进"跨端通用"的示例。top-level-array只有 Rust + C 有;namespace/multi-include只有 Lua 缺(且它是明确报码拒绝,不是静默)。- C / C++ / Lua 没有
@feature(E-PARSE-005),所以"用@feature裁剪能力"对它们无从谈起; 它们对namespace/multi-include等是无条件支持的(想关也关不掉)。
词法层面的实现差异(2026-09-19 补测,判据同样是真机行为)
同一轮把词法单元也扫了一遍(20 个用例 × 5 端;判据是"用例里的键 x 是否幸存",
这样能抓住"解析成功但键没了"这类静默数据损坏 —— 只看"能不能解析"是抓不到的):
| 用例族 | 结果 |
|---|---|
| 开头 BOM(U+FEFF) | ⚠️ 五端原先把 BOM 吃进第一个键名(Rust 输出 "\uFEFFx": 1、JS 输出 {"\uFEFFx":1})⇒ 本轮已五端统一跳过(见下) |
块注释 /* */、_* *_、跨行、内含 /* 提及、末尾无换行 |
✅ 五端一致 |
字符串:单行 / 多行 / 转义 / 内含 # // /* |
✅ 五端一致 |
| 三引号多行字符串 | ❌ 仅 Lua:键被丢掉(未修,已登记) |
CRLF 行尾、三种行注释、@version 行尾注释 |
✅ 五端一致 |
@feature … # 行尾注释 |
❌ Lua 吞掉下一行(未修,已登记);C 报 E-PARSE-005(C 无 @feature,见上表) |
BOM 的修法(五端各一处,全部按"文件级规范化"处理):Rust 在
sml-parse::scan::strip_version(所有文本入口的唯一漏斗)+ sml-include::expand_file_tokens(子文件);
JS 在 parse();C 在 sml_parse + resolve_includes 的子文件读取;C++ 在 Parser::parse + include 的子文件读取;
Lua 在 Sml.load + include 的子文件读取。每端都加了**断言"键名仍是 x"**的测试 ——
只断言"能解析"是抓不到它的(不去 BOM 也"解析成功")。
⚠️ 顺带一提:仓库里 smltools 的 YAML / XML 导入器早就各自去过 BOM(yaml.rs:93、xml.rs:63),
只有 native SML 这条路径漏了 —— 属"同一件事两套待遇",本轮补齐。
10.1 怎么开/关 feature
文件开头用 @feature 指令:
@version v1
@feature enable glob regex
@feature disable ext-rewrite
enable名字列表:用空格分隔。disable名字列表:同上。- 可以多次出现,按出现顺序叠加生效。
- 写在
@version之后、其它内容之前。
启用后,本文件内(或被 include 的子文件)即可使用对应能力;不同文件可独立声明。
10.2 内置 feature 清单
include(默认开)
最基础的"插文件"能力。
| 项 | 值 |
|---|---|
| 状态 | 默认开,无法 disable |
| 语法 | include "path.sml" |
| 作用 | 把目标文件内容原样插入 |
| 路径基准 | 被包含文件自身所在目录 |
| 嵌套上限 | 32 层(超限报错) |
| 循环引用 | 报错(不静默) |
错误码:include.circular、include.depth-exceeded、include.not-found。
namespace(默认开)
as 命名空间、点分路径、宏/契约隔离。
| 项 | 值 |
|---|---|
| 状态 | 默认开 |
| 语法 | include "x" as a.b.c |
| 作用 | 把内容包进 a { b { c { ... } } } 嵌套块 |
| 宏隔离 | 是——@name / @contract 也按命名空间隔离 |
| 外部引用语法 | a.b.c.MacroName(限定名) |
| 内部引用语法 | MacroName(本地名,解析器自动加前缀) |
错误码:ns.invalid-path、ns.duplicate-symbol、ns.unresolved-prefix。
implicit-ns(默认开)
不带扩展名的 include 自动作命名空间。
| 项 | 值 |
|---|---|
| 状态 | 默认开 |
| 语法 | include "ui" 等价 include "ui.sml" as ui |
| 隐式命名空间名 | 文件名(去扩展名) |
显式 as 覆盖 |
显式 as foo 优先于隐式 |
关闭它:
@feature disable implicit-ns——之后include "ui"必须带扩展名。
multi(默认关)
一次 include 多个目标 + import 别名。
| 项 | 值 |
|---|---|
| 状态 | 默认关 |
| 开启 | @feature enable multi |
| 语法 | include "a.sml", "b.sml" as y, "c" |
| 别名形式 | import ui.buttons, admin.panel |
| 同名冲突 | 报错(不静默) |
错误码:multi.dup-name、multi.empty。
glob(默认关)
* 通配匹配目录里多个文件。
| 项 | 值 |
|---|---|
| 状态 | 默认关 |
| 开启 | @feature enable glob |
| 语法 | include "widgets/*.sml" |
| 通配符 | *(一段字符,不跨 /)、?(单字符) |
| 排序 | 字典序确定,跨平台一致 |
隐式 as |
适用——include "widgets/*" ⇒ as widgets |
错误码:glob.not-found(若 0 匹配)、glob.malformed。
regex(默认关)
re: / /.../ 前缀触发正则匹配。
| 项 | 值 |
|---|---|
| 状态 | 默认关 |
| 开启 | @feature enable regex |
| 语法 | include "re:^v[0-9]+\\.sml$" as versions |
| 正则子集 | . * + ? ^ $ [a-z]、\\. \\d \\w |
| 性能 | 手写递归回溯,O(n·m) 足够文件名短串 |
| 注意 | 路径分隔符用 \\.(\ 在 md 里也要双写) |
完整正则特性(lookahead、backref)不支持——刻意保持简单,复杂匹配请用 shell 通配 + glob。
ext-rewrite(默认关)
把任意后缀的文件当 .sml 解析。
| 项 | 值 |
|---|---|
| 状态 | 默认关 |
| 开启 | @feature enable ext-rewrite |
| 语法 | include "*.json" -> .sml、include "conf" -> .sml |
| 典型用法 | 把 .json / .yaml / .conf 改写后用 SML 解析器处理 |
| 风险 | 错误地把二进制文件当 sml 解析会爆栈;建议加 glob 限制 |
contract(默认开)
@contract / @is 校验与回填。
| 项 | 值 |
|---|---|
| 状态 | 默认开 |
| 语法 | @contract Name [loose|strict] { field: type [default v] [min n] [max n] [enum(a,b)] [?] [required] } |
| 引用类型 | str int num bool array[T] enum(...) 或另一个契约名 |
| 严格度 | 默认严格;loose 允许未声明字段;strict 显式声明严格(与默认等价,仅为可读性) |
| 嵌套 | 无限;递归契约在被引用时检测环 |
| 错误位置 | 精确到行列 |
错误码:contract.required-missing、contract.type-mismatch、contract.enum-invalid、contract.out-of-range、contract.unknown-field、contract.recursive。
env(默认开)
$env.VAR 环境变量注入。
| 项 | 值 |
|---|---|
| 状态 | 默认开 |
| 语法 | $env.VAR_NAME |
| 缺失行为 | 替换为空串(不报错) |
| 类型 | 始终是字符串(数字也要引号) |
| 转义 | 名字里 _ . - 可用;首字符必须是字母或 _ |
| 嵌套 | $env 里不能再写 $env |
错误码:env.bad-name。
escape(默认开)
引号字符串内的转义。
| 项 | 值 |
|---|---|
| 状态 | 默认开 |
| 支持 | \n \t \r \\ \" \' \0 \u{XXXX} \uXXXX |
| 不支持 | 八进制 \077、十六进制裸 \x41(避免歧义) |
| 作用域 | 仅引号字符串;裸词不转义 |
fragment(默认开)
@name / &name 片段继承。
| 项 | 值 |
|---|---|
| 状态 | 默认开 |
| 语法 | 定义 @name { ... }、引用 key: &name |
| 作用域 | 跟随命名空间(@feature enable namespace 时) |
| 冲突 | 同一作用域重复 @name 报错 |
top-array(默认开)
文件顶层是数组(不是对象)的允许。
| 项 | 值 |
|---|---|
| 状态 | 默认开 |
| 语法 | [{...} {...} ...](顶层即数组) |
| 典型场景 | 配置项是有序列表(如监控规则、路由表) |
bareword-str(默认开)
裸词自动识别为字符串。
| 项 | 值 |
|---|---|
| 状态 | 默认开 |
| 关闭 | @feature disable bareword-str——之后所有字符串必须引号 |
| 触发 | 当版本 v.strict-strings() 设定时 |
when(默认关)
解析期静态条件裁剪:按 $env.* 在解析阶段决定「下一个字段/块」是否保留。
| 项 | 值 |
|---|---|
| 状态 | 默认关 |
| 开启 | @feature enable when |
| 语法 | @when $env.NAME [==|!= "value"](无运算符时为真值测试) |
| 作用域 | 紧邻的下一个字段/块(消费后清空) |
| 条件形式 | 闭集:仅 $env.NAME 真值,或 $env.NAME ==/!= 字面量 |
| 依赖 | 条件左侧 $env.* 要求 env 特性已开启 |
@version v1
@feature enable when
@when $env.ENV == "prod"
log_level: warn # 仅 prod 环境出现
@when $env.DEBUG # 真值测试:非空且非 "0"/"false"
verbose: true
设计取舍:不做通用表达式(
&&/||/()/函数调用)。一旦支持就必须引入求值器与沙箱、 超时、禁 IO 等防护,与「SML 是纯数据格式」冲突。复合条件请拆成多个@when, 或在构建期用外部工具按环境生成不同文件。
for(默认关)
解析期有界循环展开:把 in 后的有限列表逐项代入循环体,生成一个数组。
| 项 | 值 |
|---|---|
| 状态 | 默认关 |
| 开启 | @feature enable for |
| 语法 | key: @for VAR in a b c { ... } |
| 循环变量 | 只读绑定,循环体内以 ${VAR} 文本插值引用 |
| 列表 | in 后的有限枚举(裸词或引号串),无 while、无递归 |
| 插值位置 | ${VAR} 仅允许出现在值位置(如 name: "${h}");键名位置不支持,会解析报错 |
| 作用域 | 嵌套 @for 时内层可见外层绑定(同名内层覆盖) |
@version v1
@feature enable for
hosts: @for h in web api db {
name: "${h}"
port: 8080
}
# ⇒ hosts: [ {name:"web",port:8080}, {name:"api",...}, {name:"db",...} ]
组合陷阱
@when × @for:
- 外层
@when写在@for字段之前时,条件为假则整个hosts字段都不出现 (不是数组变空,而是键被删除)——「@when过滤的是字段,不是某一轮迭代」。@when写在@for块内部时,它作用于块内紧邻的下一个字段,会逐元素求值。 两者语义不同,务必分清。
为什么不做通用循环:有界循环(
for ... in枚举)属于 LOOP 语言(原始递归), 算不了 Ackermann 函数,故非图灵完备;一旦引入while/递归就必须配套沙箱与资源配额, 那与「SML 是纯数据格式」冲突。
键名位置不支持
${VAR}:${VAR}中的{/}会被词法层当作块边界,导致键被拆散、 静默生成错误结构。因此循环变量插值只接受值位置(name: "${h}");若需"动态键名", 请用数组表达:hosts: @for h in a b c { name: "${h}", ... },再用代码侧按name索引。 在键名处写${VAR}会显式报错(fail-fast),而非产出垃圾。
完整可运行示例见 examples/for_when.sml
(@when + @for + 嵌套 @for + 组合陷阱一次性演示)。
10.3 兼容矩阵
| feature ↓ \ → | 与 namespace | 与 multi | 与 glob | 与 regex |
|---|---|---|---|---|
include |
✅ 直接组合 | ✅ | ✅ | ✅ |
namespace |
— | ✅ | ✅ | ✅ |
multi |
✅ | — | ⚠️ 见注1 | ⚠️ |
glob |
✅(隐式 as) |
⚠️ | — | ❌ 互斥见注2 |
regex |
✅ | ⚠️ | ❌ 互斥 | — |
ext-rewrite |
✅ | ✅ | ✅ | ✅ |
contract |
✅(引用契约用限定名) | — | — | — |
注 1:
include "a, b" as y这种"逗号即分隔"的语法与multi一起用,列表里单个目标不能再含,(即使在引号内也不行——引号内逗号当字面量)。注 2:
include "re:^.*\\.sml$"已经覆盖所有 .sml,没必要再include "*.sml"。同时用可能被解释为"glob 优先"或"regex 优先",跨实现行为可能不同——SML 规定显式前缀优先:re:走 regex;*.sml走 glob。
typed-block(默认关;Rust ✅ + JS ✅,C / C++ / Lua ❌)
块级类型标注:把契约名写在块名前面(Metrics metrics { }),等价于在块内首行写 @is Metrics。
零新 token —— 与既有裸块写法 type [name] { } 完全同形,只有首词是已定义契约名时才生效。
详见 §5.2.2。
块级标注同样支持多契约叠加:把多个契约名连续写在块名前(HasHost HasPort api { } ——
从首词起连续命中契约表的词都是契约,第一个未命中的是块名),等价于块内 @is HasHost, HasPort。
单契约(Metrics metrics { })行为逐字不变;契约词 ≥ 2 才走叠加,同字段冲突报 E-CONTRACT-016。
写法与合并规则详见 §5.4.1 叠加(mixin)。开关:
@feature enable typed-block
| 项 | 值 |
|---|---|
| 默认 | 关(不开时该写法退化为普通裸块,契约不生效、也不报错 —— 静默差异) |
| 依赖 | contract(契约层本身也得开,默认开) |
| 实现面(2026-09-19 审计) | Rust ✅(rust/sml-feature 的 Feature::TypedBlock + rust/sml-parse/src/parser.rs 门控)· JS ✅(js/sml.mjs)· ❌ C / C++ / Lua(源码里没有该特性名) |
⚠️ 在 C / C++ / Lua 上,这个写法会退化成普通裸块(多出 __type/__name 元数据)⇒
契约不校验、不填默认值、也不报错 —— 静默差异(C 端 dumper 实测)。
⚠️⚠️ 更要注意:@feature 这套门控机制本身目前也只有 Rust + JS 有 ——
C / C++ / Lua 会把 @feature … 当非法指令报 E-PARSE-005
(“合法指令:contract / is / version”;C 实测,C++ / Lua 同代码路径)。
所以本章"用 @feature 裁剪能力"的叙述,对那三端不适用(它们的能力集是固定的)。
⇒ 跨实现共享的文档(如 showcase_contract.sml)建议写 @is 契约名,别依赖 @feature。
10.4 feature 的实现层(架构小贴士)
SML 解析器按"feature bitmask"运行:
FeatureSet = (include | namespace | implicit-ns | contract | env | escape
| fragment | top-array | bareword-str
| multi | glob | regex | ext-rewrite
| when | for | typed-block)
- 核心层(默认开)9 个 bit 默认 = 1(含
when、for之外的全部基础能力)。 - 进阶层(默认关)
multi | glob | regex | ext-rewrite | when | for6 个 bit 默认 = 0。 - 解析器对未开启的 feature 直接拒绝对应语法(解析期报错,不静默跳过)。
when/for属于「解析期指令」家族,由 Rust 端的 cargo featurewhen统一门控编译; 文档层的when/for特性开关控制运行时是否允许该语法。编译期与运行时两层解耦: 即使构建时未开启 cargo feature,其它特性不受影响。
这就是"功能可裁剪、不要步入 YAML 覆辙"的实现基础。
10.5 性能与可移植性
| feature | 对解析时间影响 | 跨平台差异 |
|---|---|---|
include |
O(总文件大小) | 路径分隔符归一化(/ ↔ \) |
namespace |
极小(路径编译一次) | 无 |
multi |
线性叠加 | 无 |
glob |
O(n) 文件枚举 | 隐藏文件(.foo)默认不包含 |
regex |
O(n·m) 短串 | 无 |
ext-rewrite |
视被改写文件大小 | 内容编码假设 UTF-8 |
contract |
与字段数线性 | 递归契约需 memoize |
10.6 未来 feature(roadmap)
以下条目均未实现,仅作路线图预告;逐条标注它们当前的替代 / 缓解手段,避免读者误用或重复造轮子:
| 构想 | 状态 | 现状 / 被何取代 |
|---|---|---|
@import once(去重 include,避免同文件被含多次) |
未实现 | 菱形包含本就合法(链栈环检测只挡真环 E-INCLUDE-002),展开次数有 E-LIMIT-003 上限兜底 —— 「去重」并非必需 |
feature from "another.sml"(从另一文件继承 feature 设置) |
未实现 | 无直接替代;各文件用行首 @feature 自行声明 |
with contract=loose(块级放宽,覆盖文件级设置) |
未实现 | 替代——在 @contract 上写 loose / strict(契约级开关,非块级覆盖) |
? 三元简写(a ? b : c 在值位置) |
未实现 | ? 现为 optional 字段标记,值位置无三元语义 |
10.7 动手试一试
为你的项目写一份 features.sml:
@version v1
@feature enable glob multi
@feature disable ext-rewrite
include "modules/*.sml" as modules
import modules.auth, modules.billing
用 sml check features.sml 之类的命令(参考 ch07 多语言)跑通解析与契约校验。
→ 附录:对照与排查
动手练习
读完本章,在下面的编辑器里直接修改 SML 并点“运行”,立刻看到解析结果或校验错误——有输出才能高效学习。
虚拟文件(可在 include 中引用)
@contract Module { name: str }
@is Module
name: auth@contract Module { name: str }
@is Module
name: billing