sml

SML { ❄ }

下载页 问题反馈、使用疑问,或随便聊聊: QQ 群 · 点击加入 sal@mail.swebase.cn 中文 EN

第 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 —— 需确认是真展开还是当普通键吞掉了。

三条给写文档的人的结论:

  1. @when / @for 目前只有 Rust 有(其余四端一律 E-PARSE-005)—— 别把它们写进"跨端通用"的示例。
  2. top-level-array 只有 Rust + C 有;namespace / multi-include 只有 Lua 缺(且它是明确报码拒绝,不是静默)。
  3. 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

启用后,本文件内(或被 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)

when / for 属于「解析期指令」家族,由 Rust 端的 cargo feature when 统一门控编译; 文档层的 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 把多个子文件装入各自命名空间:include "auth.sml" as auth 与 include "billing.sml" as billing。每个子文件用 @is Module 并含 name 字段。验证 auth 与 billing 命名空间都被正确装入。
💡 提示:include ... as auth / as billing 把不同文件装入相互隔离的命名空间,避免字段冲突。
虚拟文件(可在 include 中引用)
auth.sml
@contract Module { name: str }
@is Module
name: auth
billing.sml
@contract Module { name: str }
@is Module
name: billing
✍ 自测考题:第 10 章自测:feature 参考 得分 0 / 4
Q1. 开启 glob 通配 include 用哪个 feature?
Q2. 判断:不声明 @feature,SML 基础语法(键值/块/数组/契约)仍全部可用。
Q3. 下面哪个属于 feature 控制的扩展能力?
Q4. @feature 写错(如拼错名字)通常会?