sml

SML { ❄ }

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

SML { ❄ }

swsml on crates.io Gitee stars & forks

SML(SNOWARE Markup Language) 是一种声明式数据 / 配置格式,定位为 JSON / YAML / TOML 的轻量替代品。它强调可读性与少仪式感:引号可选、块冒号可省、逗号可选、支持片段继承与契约校验。

仓库:snoware/sml | 参考实现:Rust (swsml) | 其它实现:C (sml.c) · C++ (sml.cpp) · JavaScript (sml.mjs) · Lua (lib/sml.soup) · Python 五端同码:同一类错误在 Rust / C / C++ / JS / Lua 上报同一个错误码(码表 143 条,2026-09 起静默点全部改为显式报错)

特性一览

📖 SML 教科书

从零开始、循序渐进,也能当工具书随时查。→ 在线阅读

多语言实现对照

实现状态说明(重要)

  • Rust(rust/,crate swsml)是 SML 的参考实现:语法、契约系统、emit 后端、测试与安全扫描(OSV 依赖漏洞扫描 + 完整回归测试)均以它为准,也是目前唯一持续维护并做版本保证的实现。生产使用请选择 Rust。
  • Rust 以外的实现(C / JavaScript / Lua / C++ / Python)目前标记为「实验性(experimental)」:随仓库一并提供,可运行、可嵌入,但暂不保证与 Rust 行为完全一致、暂不保证 API 稳定、暂不纳入例行的漏洞扫描与回归测试。请自行评估后使用,发现问题欢迎提 issue。
语言 仓库 / 文件 状态
Rust rust/ (swsml) ✅ 参考实现 · 推荐(契约系统完整,serde 桥接,例行漏洞扫描)
C c/sml.c ⚠️ 实验性(暂不保证,契约系统曾与 Rust 对齐,后续变更不承诺同步)
JavaScript js/sml.mjs ⚠️ 实验性(暂不保证,零依赖 ESM,浏览器 / Node 通用,含契约与 playground)
Lua lua/lib/sml.lua ⚠️ 实验性(暂不保证,Soup 生态 lib/sml.soup 同源)
C++ cpp/ ⚠️ 实验性(暂不保证)
Python rust/ 外另见 py 绑定 ⚠️ 实验性(暂不保证)

契约系统的规范与判定以 Rust 实现为准;C / JavaScript / C++ 曾做过对齐验证,但因非 Rust 实现已进入「暂不保证」状态,跨端一致性不再作为版本承诺。

快速使用

# 基础键值
firstName: John
age: 27
address:
{
    streetAddress: "21 2nd Street"
    state: NY
}

# 数组(逗号可选)
phoneNumbers: [ { type: home } { type: office } ]

# 片段继承(&name 是值引用,写作 key: &name)
@base { region: cn-north-1 }
region: &base

Rust

use sml::parse;
let v = parse("name: John\nage: 27").unwrap();
assert_eq!(v["name"], "John");

C

#include "sml.h"
char err[256] = {0};
sml_value *v = sml_parse("name: John\nage: 27", err, sizeof(err));
/* v->type == SML_STR ("John") ... 用 sml_free(v) 释放 */

JavaScript

import { parse, stringify } from "./sml.mjs";
const v = parse('name: John\nage: 27');
console.log(stringify(v));

C++

#include "sml.hpp"
sml::Value v = sml::parse("name: John\nage: 27");
// v["name"].as_str() == "John" | v["age"].as_int() == 27
// 解析失败时抛 sml::ParseError(含行列位置)

C++ 实现 cpp/ 为头文件 + 单编译单元(sml.cpp),零第三方依赖;run_tests.py 跑 test_comments.cpp / test_contracts.cpp 两套契约与注释测试。

版本演进(@version)

SML 通过 @version 声明文档遵循的语法版本,使解析器在将来引入不兼容语法时仍能正确读取旧文档。当前实现支持 v1 / v2 / v3 / v4(@version 接受 v1/1、v2/2、v3/3、v4/4,超出 v1..v4 范围会直接报错),最新基线为 v4。

版本 语义 字符串写法
v1(默认) 初始公开版。裸词即字符串,类型自动识别 name: John ✅
v2 草案版,引入「字符串必须显式引号」的不兼容语法 name: "John" 必引号
v3 正式版,与 v2 同语义。自由文本必须写作 "..." name: "John" 必引号
v4(CURRENT) 片段定义的 type / name 改为显式关键字形式 @f type: Server name: prod,废弃 v3 的位置参数 @f Server prod;其余语法与 v3 完全兼容 name: "John" 必引号

v2 与 v3 在字符串处理上语义一致,v2 为草案代号、v3 为正式代号。数字 / bool / null / 片段引用 &x / 环境变量 $env.X 在 v2 / v3 下仍为裸词——只有自由字符串需要引号。

# 默认 v1:裸词即字符串
name: John
age: 27
tags: [ a b c ]          # 数组裸词元素合法

# 显式 v3:字符串必须引号,标量仍裸词
@version v3
name: "John"
age: 27
active: true
tags: [ "a" "b" "c" ]    # 数组元素也必须引号
ref: &frag               # 片段引用仍是裸词

未定义片段引用在 v3 下直接报错(不再静默降级为字符串)。调用方还可用 parse_allowed(docs, &[Version::V1, Version::V2, Version::V3, Version::V4]) 限制接受版本范围——超出范围的文档会被拒绝,防止 @version 成为绕过能力限制的后门。

契约系统(Contract)

契约是 SML 的「配置 Schema」:定义一组字段的类型、是否必填、默认值、取值范围,并在 @is 应用处做校验。非常适合「用 SML 做应用配置」的场景。

定义契约

@contract ResenderConfig loose {
    api_key:     str                # 必填字符串
    port:        int  default 8080 min 1 max 65535
    debug:       bool default false
    mode:        enum(active, disabled) default active
    tags:        array[str] ?      # 可选字符串数组
}

字段修饰符:

修饰符 含义
str / int / num / bool 字段类型
enum(a, b, c) 枚举,取值须在其中
array[T] 数组,元素类型 T(如 array[int]、array[str])
? 或 optional 可选字段
required 显式必填(默认即必填)
default <值> 缺失时填入该默认值(并自动视为可选)
min <数> / max <数> 数值取值范围(含端点)

应用契约

两种写法:

# 写法一:匿名块(顶层直接 @is)
@contract Cfg loose { api_key: str port: int default 8080 }
@is Cfg
api_key: re_abc
port: 8080
# 写法二:字段级 / 块级 @is
server prod {
    @is Cfg
    api_key: re_prod
    port: 9090
}

严格 vs 宽松

组合契约(递归引用)

@contract Endpoint { host: str port: int }
@contract Service {
    name:  str
    main:  Endpoint          # 引用另一个契约
    peers: array[Endpoint]   # 契约数组
}
@is Service
name: gateway
main: { host: localhost port: 8080 }
peers: [ { host: a port: 1 } { host: b port: 2 } ]

契约校验失败会返回带位置的精确错误(如 contract: Service — 字段 main.port 大于最大值 65535),方便编辑器 / CLI 直接定位。

进阶特性

include 与命名空间(可裁剪设计)

设计哲学:从极简到丰富,功能可裁剪——基础能力默认开启,复杂能力(多目标 / 通配 / 正则 / 扩展名重写)必须显式 @feature enable 才生效,避免重蹈 YAML 过度复杂的覆辙。

基础形态

# 带扩展名 ⇒ 普通内联(内容直接并入当前作用域)
include "common.sml"
app: myapp

# 不带扩展名 ⇒ 默认命名空间 = 文件名(零样板隔离)
include "ui"          # 等价于 include "ui.sml" as ui
title: ui.title       # 用前缀访问

# 显式指定命名空间(覆盖默认)
include "ui.sml" as ui.form.widgets

规则很干净:带扩展名 = 内联;不带扩展名 = 命名空间(以文件名为 ns)。显式 as 始终优先。

点分路径(嵌套命名空间)

ns 支持 a.b.c 形式,等价于 Rust 的模块路径,展开为嵌套块 a { b { c { ... } } }。

宏与契约也随命名空间隔离(2b)

命名空间不止隔离数据键值,还隔离宏与契约定义:被包含文件里定义的 @contract / @name / @base 必须以 ns. 前缀对外引用,调用方才能找到:

# widgets.sml 内部
@contract Button { label: str }
@name primary = { label: "OK" }

# 主文件引用时必须带前缀
@is ui.form.widgets.Button
button: &ui.form.widgets.primary

被包含文件内部对自身宏的自引用仍按本地名解析(无需前缀),只有对外暴露才需要 ns. 前缀——解析器按"当前命名空间栈"自动给宏注册表加前缀。

冲突即报错(不静默)

命名空间是独占作用域,绝不静默覆盖:

多目标与 import 别名

逗号分隔一次包含多个目标;import 是 include 的等价写法(语义一致):

include "a.sml", "b.sml" as y, "c"          # 多目标,各自可有 as
import ui.buttons, admin.panel              # import 别名写法

通配与正则(需 feature 开启)

@feature enable glob
include "widgets/*.sml"        # glob 通配,按文件名排序逐个包含

@feature enable regex
include /plugins/.*\.sml/      # 正则匹配(/.../ 定界或 re: 前缀)

零拷贝切片(性能)

include 不会把文件内容深拷贝拼接成一大段文本。每个被包含文件读入后只持有其字符串切片,解析器消费一段「切片流」:遇到 include "x" as a.b 时插入零拷贝的开块字面量 a { b {、接入 x 的切片、再插入闭块 } }。文件内容只被解析一次,无中间大字符串,内存占用 = 各文件切片之和。

Feature 分层(可裁剪)

层 feature 能力 默认
0 include 基础 include "x.sml" 内联(带扩展名才内联) 开
1 namespace as ns + 点分路径 + 宏/契约隔离 + 冲突报错 开
1 implicit-ns 无扩展名 include "foo" ⇒ as foo 默认命名空间 开
2 multi-include 逗号多目标 a, b, c 与 import 别名 关
2 glob-include * 通配 dir/*.sml 关
3 regex-include re: / /.../ 正则匹配 关
3 ext-rewrite -> .sml 把非 sml 文件当 sml 解析 关

默认仅开启 include + namespace + implicit-ns(极简三件套)。复杂能力需 @feature enable 显式 opt-in。

跨语言一致

Rust / C / JavaScript / Lua 四端共用同一语义:点分路径、宏/契约隔离、冲突报错、切片式零拷贝、feature 分层裁剪。

注:以上为规范层面的目标;当前仅 Rust 实现做保证,C / JavaScript / Lua 属实验性实现,暂不保证已完整跟进(feature 分层裁剪等以 Rust 为准)。

\u 转义

字符串支持 \u{XXXX} 与 \uXXXX Unicode 转义,解析期转为 UTF-8:

label: "雪花 \u{2744} snow"

JSON 双向桥接

SML 与 JSON 同构(均为树状键值 / 数组),可无损互转:

import { parse, stringify } from "./sml.mjs";
// SML -> JSON
const obj = parse(smlText);            // 普通 JS 对象
const json = JSON.stringify(obj);
// JSON -> SML
const sml = stringify(JSON.parse(json));

Rust 侧通过 serde feature 提供 sml::serde::from_str / to_string,任意 #[derive(Deserialize)] 结构体都能一键反序列化。

多目标转译(emit)

SML 不只是配置格式——解析为 Value 后,可经由内置后端编译 / 转译为其它宿主格式,把同一份数据喂给不同生态:

feature 目标 入口函数
emit-markdown Markdown / GFM to_markdown
emit-latex LaTeX 文档 to_latex
emit-xml XML / LVGL UI to_xml / to_lvgl
emit-svg SVG 图形 to_svg
emit-slint Slint DSL(Rust/Qt GUI) to_slint
emit-custom 用户自定义 SML 模板生成器 to_custom

默认全部开启;若只需解析 / 序列化回 SML,可 default-features = false 关掉所有 emit-*,本模块整体不参与编译。

通用约定:对象 / 块通常映射为宿主的「容器 / 元素 / 环境」,数组映射为「列表 / 序列」,字符串标量默认自动转义防止注入宿主保留字(如 XML 的 <、&)。裸块元数据 __type / __name 被后端用来选择语义,而非当作普通字段输出。

use sml::{parse, emit::to_markdown, emit::MarkdownOptions};

let v = parse("# 标题\nbody: 内容\nitems: [ a b c ]").unwrap();
// SML -> Markdown
let md = to_markdown(&v, &MarkdownOptions::new()).unwrap();
// SML -> Slint GUI 描述
use sml::emit::{to_slint, SlintOptions};
let slint = to_slint(&v, &SlintOptions::new()).unwrap();

转译后端对不可信输入做了递归深度上限(MAX_VALUE_DEPTH = 128)保护:超深嵌套会返回 Err 而非栈溢出崩溃,避免 DoS。

落地应用

SML 已被多个真实项目采用:

resender 中的使用

resender 是一个 Rhai 驱动 + Slint GUI 的 Resend 邮件发送工具,它的配置文件 resender.sml 直接用 SML 契约保证结构正确:

@contract ResenderConfig loose {
    api_key:    str
    from:       str
    to:         array[str]
    subject:    str default "Hello"
    port:       int default 465  min 1 max 65535
    tls:        bool default true
}

@is ResenderConfig
api_key: re_xxxxxx
from: me@example.com
to: [ alice@example.com bob@example.com ]
subject: Weekly Report
port: 465
tls: true

其 Rust 端 src/config.rs 维护 CONFIG_CONTRACT 常量(即上面的契约文本),保存时把当前配置序列化回 SML 并自动附上 @is ResenderConfig,读取时再校验——这正是「契约即 Schema」的典型用法。

完整示例

一个接近真实的部署配置范例:

@version v1

@contract Service loose {
    name:    str
    port:    int  default 8080 min 1 max 65535
    debug:   bool default false
    peers:   array[str] ?
}

@base {
    region: cn-north-1
    timeout: 30
}

# 顶层应用契约
@is Service
name: gateway
port: 9090
debug: true
peers: [ auth billing ]

# 片段继承复用
service auth { &base port: 7100 name: auth-svc }
service billing { &base port: 7200 name: billing-svc }

# 嵌套块 + 数组
database: {
    url: "postgres://localhost:5432/app"
    pool: { min: 2 max: 16 }
}
features: [ logging metrics tracing ]

本地 Playground

不想装任何东西?直接在浏览器里试:SML Playground →

左侧写 SML(含契约),右侧实时显示解析结果或精确错误位置。

下载

⚠️ 0.4.3 为重新打包:修掉了「在 VS Code 1.13x 上扩展根本无法激活」的致命问题 (模块顶层读了新版宿主已移除的 vscode.InsertTextFormat)。装过更早版本请重新下载安装。

编辑器支持

编辑器 获取方式 能力
VS Code 从 下载页 获取 VSIX 后「从 VSIX 安装」;也可从本仓库 editors/vscode/ 本地打包:npm run package。未上架市场(上架流程繁琐,刻意暂缓) 语法高亮、诊断、补全、格式化和跳转到定义(@is X → @contract X、&base → @base);悬浮给出契约声明 + 解析器填充默认值后的结构,停在块名上还给路径与应用契约;特殊颜色(右键写入工作区 HL-cfg.sml,只对语法单元生效);特别高亮(一个词点亮整个工作区);自检(右键把「为什么没反应」逐条写进输出面板)。实现方式是进程内直接调解析器,不启 LSP
Zed 在 Zed 里执行 zed: install dev extension,选择本仓库的 editors/zed/ 目录。详见 editors/zed/README.md 语法高亮、括号匹配、注释切换(Tree-sitter)

⚠️ Zed 装不了 .vsix:VSIX 是 VS Code 专用的包格式;Zed 用的是自己的扩展格式 (extension.toml + Tree-sitter grammar),两套不通用。所以上面两行是两个不同的东西, 不是同一个包换了个按钮。