【笔记】TOML 的数据树与声明边界

TOML 同时支持 a.b.c = 3、[a.b]、{ c = 3 } 和 [[servers]] 等写法,容易给人一种印象:只要最终生成同一棵配置树,各种写法就能在文档中随意切换、反复打开和追加。

实际上,TOML 只是表达方式灵活,声明语义很严格。它以对人友好的配置文件语法,无歧义地构造一棵 Table 树;在构造这棵树的同时,还要遵守键、Table 和 Array of Tables 各自的定义边界。

因此,理解 TOML 要同时看两件事:

  • 最终会得到怎样的数据树;
  • 树中的路径和节点是如何被声明出来的。

这个“数据树 + 声明状态”的双重模型,不仅能解释 dotted key 和 Table header 为什么能生成相同结构却不能随意混写,也能解释嵌套 Array of Tables 的定位和追加规则。

1. TOML 的核心取舍

TOML(Tom’s Obvious, Minimal Language)的官方目标是:成为一种容易阅读、语义直观的最小化配置文件格式,能够无歧义地映射到 hash table,也容易被各种语言解析为数据结构。

这个定位决定了它的主战场是“人工手写和维护,机器稳定解析”的配置文件。它不追求:

  • 像 JSON 一样普遍用于 API 传输和通用数据交换;
  • 像 YAML 一样提供锚点、别名、显式 tag 和多文档流等更广泛的序列化能力;
  • 通过后写覆盖或引用复用,在单个文档内实现配置合并。

如果主要需求是机器交换数据,JSON 往往更直接;如果需要很深的嵌套、大量对象列表或引用复用,YAML 可能更紧凑。TOML 的优势集中在“浅而宽、以 section 分组”的配置上,Cargo.toml 和 pyproject.toml 是典型形态。

2. 整体模型:Table 树与声明账本

TOML 的目标数据模型可以理解为一个无名的 root table,其中包含普通值、Array、Table 和 Array of Tables。

flowchart TB
    R[root table]
    V[scalar or date-time value]
    A[array value]
    T[table]
    AT[array of tables]
    TK[nested members]
    E1[table element]
    E2[table element]

    R --> V
    R --> A
    R --> T
    R --> AT
    T --> TK
    AT --> E1
    AT --> E2

TOML 文本不是一组可以重复执行的“进入 Table 并修改它”指令,也不是可覆盖的 Map 合并脚本。解析器会边读取边构造树,同时记录每个路径的声明状态。

节点状态如何出现后续边界
未出现路径尚未被使用可定义为值、Table 或 Array of Tables
普通值a = 3不能重复赋值,也不能再成为 a.b 的父 Table
隐式父 Table[a.b] 为路径自动建立 aa 已存在,但仍可通过 [a] 首次显式声明
已定义的普通 Table[a] 显式声明,或 dotted key 定义中间路径可增加尚未定义的成员,但不能再用同路径 header 重新声明自己
Inline Tablea = { b = 3 }完全封闭,花括号外不能再增加成员
Array of Tables[[servers]]重复同一 header 会追加新元素,不是重新打开旧元素

“路径已经存在”不等于“Table 已经显式声明”,隐式父 Table 就是两者之间的差异。“最终结构相同”也不等于“声明过程可以混用”,这是后续大部分边界的根源。

3. 树上的基本节点怎么写

理解 TOML 语法最快的方法,是先在脑中翻译成等价的 JSON 结构,再考虑 TOML 如何声明它。

3.1 键与值

bare key 只允许 ASCII 字母、数字、下划线和连字符。键名包含空格、点或其他字符时,需要使用 quoted key:

simple_key = "value"
"key with spaces" = "value"
"example.com" = true

TOML 的值类型包括 String、Integer、Float、Boolean、四种日期时间、Array 和 Inline Table。

name = "codex"
port = 8080
ratio = 0.85
enabled = true
created = 2026-08-17T10:00:00Z

日期时间是 TOML 相对 JSON 多出的原生值类型;四种形式是带 offset 的 date-time,以及不带 offset 的 local date-time、date 和 time。TOML 规范没有通用 null 值,“没有该键”、空字符串、空 Array 和应用约定的特殊值不能自动互换。

3.2 Table:header、dotted key 与 Inline Table

Table 对应 JSON object,也就是字典或 hash table。表头式适合键多、需要注释或分组的场景:

[mcp_servers.context7]
url = "https://mcp.context7.com/mcp"

dotted key 在一行 key/value 中构造中间 Table:

mcp_servers.context7.url = "https://mcp.context7.com/mcp"

两者都对应:

{
  "mcp_servers": {
    "context7": {
      "url": "https://mcp.context7.com/mcp"
    }
  }
}

Inline Table 适合键少、结构简单的局部数据:

"/Users/example/Eden" = { trust_level = "trusted" }

它与普通 Table 能表达相同的数据结构,但声明边界更严格:Inline Table 在花括号内完成定义后就完全封闭,不能从外部增加键或子 Table。

3.3 Array 与 Array of Tables

普通 Array 是一个值,其中可放普通值、其他 Array 或 Inline Table:

status_line = ["context-used", "model", "project-name"]
servers = [{ name = "a" }, { name = "b" }]

Array of Tables 用双方括号创建 Table 列表:

[[servers]]
name = "a"

[[servers]]
name = "b"

它们都对应:

{
  "servers": [
    { "name": "a" },
    { "name": "b" }
  ]
}

[servers] 和 [[servers]] 并不是同一类型的两种排版。前者声明一个普通 Table;后者第一次出现时定义 Array 及第一个 Table 元素,每次重复都向 Array 追加一个新 Table。

3.4 缩进只是排版

TOML 的嵌套层级由键和 header 的点分路径决定,缩进会被当作普通空白忽略。

[a]
[a.b]
[a.b.c]
key = "value"

与下面这份 TOML 语义相同:

[a]
  [a.b]
    [a.b.c]
      key = "value"

这与 YAML block 风格不同:YAML 用缩进构造层级,TOML 中的缩进只影响观感。但“缩进没有语义”不等于“类型会自动推断”:字符串引号、布尔值和日期格式仍必须遵守各自语法。

4. dotted key 是结构声明

a.b.c = 3

a.b.c 会创建并定义 Table a、Table a.b 和叶子键 a.b.c。解析结果是嵌套数据,不是扁平 Map:

{
  "a": {
    "b": {
      "c": 3
    }
  }
}

可以继续向已有 Table 增加尚未定义的成员:

a.b.c = 3
a.b.d = 4
a.x = 5

这里没有重复定义 a 或 a.b,而是在它们之下定义不同叶子键。如果点本身是键名的一部分,必须引用该键段:

site."example.com".enabled = true

这里的第二层键名是完整的 example.com,不会被拆成 example 和 com。

5. 结构等价不等于声明可以拼接

下面两份独立的 TOML 都合法:

a.b.c = 3
a.b.d = 4
[a.b]
c = 3
d = 4

两者生成同一棵数据树,但不能在同一文档中拼接:

a.b.c = 3

[a.b] # INVALID: a.b 已由 dotted key 定义
d = 4

[a.b] 不是选中或重新打开已有 Table,而是显式声明 Table a.b。前面的 dotted key 已经定义 a.b,后面的 Table header 就构成重复声明。同理,之后再写 [a] 也会重复声明已被 dotted key 定义的 a。

但可以在已有 Table 之下声明尚不存在的更深子 Table:

a.b.c = 3

[a.b.extra] # VALID: 首次声明 extra
x = 1

这里没有重新声明 a.b,而是首次声明 a.b.extra。

5.1 隐式父 Table:“已存在”不等于“已显式声明”

下面的写法合法:

[a.b]
c = 3

[a]
x = 1

[a.b] 必须先让父路径 a 存在,但它显式声明的只是 a.b。a 此时是隐式父 Table,后面的 [a] 才是对它的第一次显式声明。

[a.b]
  a   = 隐式父 Table
  a.b = 已显式声明的 Table

[a]
  a   = 第一次显式声明,合法

这与 dotted key 不同。a.b.c = 3 会创建并定义最后一段之前的各层 Table,之后再写 [a] 或 [a.b] 都是重复声明。

规范允许先声明 [a.b] 再声明 [a],但不值得在日常配置中刻意利用,因为它会迫使阅读者额外跟踪隐式与显式状态。

5.2 Table header 和 dotted key 的寻址基准不同

文件开头位于无名的 root table。第一个 Table header 出现后,后续 key/value 属于当前 Table,直到下一个 header 或文件结束。

root_key = 1

[a.b]
c = 3
d = 4

对应 root_key = 1、a.b.c = 3 和 a.b.d = 4。容易出错的是,Table header 下的 dotted key 相对于当前 Table:

[a.b]
c = 3
a.b.d = 4

最后一行定义的不是 root 下的 a.b.d,而是 a.b.a.b.d。给当前 Table 增加 d 时应直接写 d = 4;需要更深一层时,可写 extra.enabled = true,它会定义 a.b.extra.enabled。

Table header 自身则始终声明它写出的完整路径。在 [a] 之后写 [b],声明的是 root 下的 b,不是 a.b;需要后者必须显式写 [a.b]。

6. 单次定义的直接结果

6.1 叶子键不能重复赋值

name = "first"
name = "second" # INVALID

TOML 不定义“后写覆盖先写”。多文件配置合并的覆盖规则属于应用,不是单份 TOML 文档的语义。

6.2 普通值不能后续变成 Table

a.b = 1
a.b.c = 3 # INVALID

第一行已经把 a.b 定义成 Integer,第二行却要把它当作 Table 挂载 c,节点类型发生冲突。

6.3 普通 Table 可增加成员,但不能重复声明自己

a.b.c = 3
a.b.d = 4       # VALID: 新成员

[a.b.extra]     # VALID: 新子 Table
x = 1

# [a.b]         # INVALID: 重新声明 a.b

“Table 不能重复定义”不等于“Table 第一次出现后就被冻结”。准确边界是:同一 Table header 不能显式声明两次,但普通 Table 可以在不重复定义键的前提下获得新成员。

6.4 Inline Table 完全封闭

a = { b = { c = 3 } }
a.b.d = 4 # INVALID

Inline Table 的花括号内容就是它的完整定义。它不能向已有普通 Table 追加内容,也不能在自身定义结束后再被扩展。

7. 嵌套 Array of Tables 的定位机制

[[servers]] 的基本语义是每出现一次就向 servers Array 追加一个 Table。真正容易看错的是 [[hooks.PostToolUse.hooks]] 这种多层嵌套。

7.1 中间段用于寻路,最后一段决定本次声明

一行 header 的路径为 k1.k2....kn 时:

  • 中间段 k1 到 k(n-1) 用于寻路。某段已是 Table 时就进入它;已是 Array of Tables 时,就进入该 Array 最近创建的 Table 元素;
  • 最后一段 kn 才由单括号或双括号决定,本行要显式声明普通 Table,还是创建 Array of Tables 的新元素。
最后一段的写法作用
[kn]首次显式声明普通 Table
[[kn]]首次时建立 Array 并追加第一个 Table;之后每次追加一个新 Table

嵌套在 Array of Tables 下的子 Table 或子 Array,其父 Array 元素必须先被定义,否则解析器无法知道子节点应归属于哪个元素。因此,header 的顺序可能决定文档是否合法。

7.2 从根路径定位,不是压栈和出栈

不要把嵌套 header 理解成“进入一层 push,结束时 pop 回上一层”的调用栈。TOML 没有“记住来时的路”这种导航语义。

更准确的类比是 cd /a/b/c 这种从文档根开始的完整路径定位,而不是 pushd/popd。每一行 header 都按自己写出的路径重新寻址;当路径穿过已声明的 Array of Tables 时,它指向该 Array 当前最后一个 Table 元素。

7.3 用 hooks 配置逐行追踪

[[hooks.PostToolUse]]
matcher = "Edit|Write"
[[hooks.PostToolUse.hooks]]
command = "claude-stats xrecord"
type = "command"

[[hooks.PostToolUse]]
matcher = "Bash"
[[hooks.PostToolUse.hooks]]
command = "claude-stats xrecord"
type = "command"

解析过程如下:

  1. 第一个 [[hooks.PostToolUse]] 让中间路径 hooks 存在,然后建立 PostToolUse Array 并追加元素 E0。matcher 写入 E0。
  2. [[hooks.PostToolUse.hooks]] 通过 PostToolUse Array 时定位到当前最后元素 E0,然后在 E0 下建立内层 hooks Array 并追加 F0。command 和 type 写入 F0。
  3. 第二个 [[hooks.PostToolUse]] 向外层 Array 追加 E1,此时“最近元素”已从 E0 变成 E1。matcher 写入 E1。
  4. 第二个 [[hooks.PostToolUse.hooks]] 因此定位到 E1,并在它下建立另一个内层 hooks Array。

最终结构为:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [{ "command": "claude-stats xrecord", "type": "command" }]
      },
      {
        "matcher": "Bash",
        "hooks": [{ "command": "claude-stats xrecord", "type": "command" }]
      }
    ]
  }
}

两组 matcher 和内层 hooks 能正确配对,不是解析器根据字段名猜测出来的,而是“引用 Array of Tables 时指向最近定义的 Table 元素”的机械结果。

7.4 是否指向新元素,取决于哪一层被追加

如果不在中间插入新的 [[hooks.PostToolUse]],连续写多次 [[hooks.PostToolUse.hooks]],就会向同一个 matcher 的内层 hooks Array 连续追加:

[[hooks.PostToolUse]]
matcher = "Edit|Write"

[[hooks.PostToolUse.hooks]]
command = "cmd1"
type = "command"

[[hooks.PostToolUse.hooks]]
command = "cmd2"
type = "command"

因为外层 PostToolUse 没有再追加新元素,它的“最近元素”始终是同一个。因此,是否指向新对象,取决于路径中哪一层被新的 [[...]] 追加过。

7.5 顺序不能反过来

子 Table 或子 Array 的父节点如果是 Array 元素,该元素必须先存在。下面的顺序会报错:

[[hooks.PostToolUse.hooks]]
command = "x"

[[hooks.PostToolUse]] # INVALID
matcher = "y"

第一个 header 出现时,没有任何 PostToolUse Array 元素可供内层 hooks 归属;按路径继续解析,PostToolUse 只能先成为普通父 Table。后面再尝试把同一路径声明为 Array of Tables,就会产生类型冲突。

7.6 可用内联结构表达同一棵树

前一个 hooks 元素也可写成:

[[hooks.PostToolUse]]
matcher = "Edit|Write"
hooks = [
  { command = "claude-stats xrecord", type = "command" }
]

这与展开的 [[hooks.PostToolUse.hooks]] 生成同一数据结构。选择哪种写法,可根据可读性和生成方式决定。例如,某段配置由脚本按完整文本块追加时,展开的 Array of Tables 可能比解析并改写内联 Array 更简单。

8. 与其他配置和序列化格式的取舍

格式核心定位类型与歧义嵌套方式更适合的结构
JSON通用数据交换值类型语法明确,无注释和原生 date-time花括号和方括号原地嵌套机器生成和传输的数据树
YAML通用序列化plain scalar 按 schema 解析,还有 tag、锚点和别名block 风格用缩进,flow 风格用括号深层嵌套、大量列表与需要引用的文档
Properties简单 key/value 配置值主要以字符串处理无原生嵌套,通常用点分键名模拟小型、扁平配置
TOML人工维护的配置文件字面量规则明确,有原生 date-timedotted key、Table header、Inline Table 和 Array of Tables浅而宽、以 section 分组的配置

8.1 相对 JSON:保留确定的树结构,换成更适合手写的外观

JSON 的嵌套靠括号和逗号,很适合机器生成和消费,但人工维护时容易受到括号、逗号和无法加注释的影响。TOML 保留了“稳定映射成 hash table”的确定性,用 Table header 替代部分原地括号嵌套,并补上注释、原生 date-time 和多行字符串。

代价是,路径很深时,TOML 常常需要在多个 header 中重复完整祖先路径,比 JSON 的原地嵌套更啰嗦。

8.2 相对 YAML:明确路径和受限字面量换取可预期性

YAML block 风格用缩进构造层级,plain scalar 根据所用 schema 解析成布尔、数字、null 或字符串。TOML 则用点分路径明确表达层级,字符串必须使用字符串语法,布尔值只有小写 true 和 false。

这并不意味着 TOML 在所有场景都更易手写:

  • 浅层、扁平、以 section 分组的配置中,TOML 不需要跟踪缩进语义,通常更省心;
  • 深层嵌套或大量“Array 中放 Table”的场景中,TOML 要反复写完整 header 路径,[[array]] 的最近元素规则也要额外理解,YAML 的 - item 往往更紧凑。

Kubernetes manifest、GitHub Actions workflow 和 Compose 文件这类“深层嵌套 + 大量对象列表”的配置常使用 YAML,而 TOML 常见于浅层 section 分组,正是这个差异的体现。

YAML 的“挪威问题”

YAML 1.1 的布尔类型会把 y/yes/n/no/on/off 的多种大小写形式识别为布尔值。国家代码列表中的挪威代码 NO 因此可能被 YAML 1.1 解析器读成 false:

countries:
  - US
  - CA
  - NO
  - FR

问题的根源不是“YAML 一定会把所有裸词猜错”,而是 plain scalar 的类型取决于解析器使用的 schema。YAML 1.2 Core Schema 已将布尔词收窄到 true/false 的大小写形式,NO、yes、on 不再按 YAML 1.1 布尔语义解析。但实际工具采用的 YAML 版本和 schema 未必相同,不能只根据最新规范推断运行结果。

TOML 不接受 NO 这种未引用的字符串值,布尔值也只有小写 true 和 false,因此不会出现字符串碰巧命中隐式布尔词的问题。

YAML 不只是“可读的 JSON”

对于普通 mapping 和 sequence,YAML block 风格与 JSON 都是在数据所属位置直接嵌套子结构;YAML 用缩进和短横线降低了人工阅读负担,但没有 TOML header 这种用完整路径分开声明树中不同位置的外观。

但把 YAML 整体等同于“JSON 的可读版”会忽略真正的能力差异:

  • 锚点和别名(&anchor / *alias)可以让节点被再次引用,这不再是 JSON 或 TOML 的纯树模型;
  • 显式 tag、自定义 tag 和多文档流体现了 YAML 更广的序列化目标。配置文件只是 YAML 的一个常见用途,不是全部边界。

8.3 相对 Properties:原生层级和类型换来了额外复杂度

Properties 主要是扁平 key=value,没有原生 Table、Array 或类型系统。a.b.c=value 可以被应用解释为层级,但对 Properties 文件本身来说,它仍是一个带点的普通键名。布尔和数字也通常由应用从字符串转换。

TOML 提供真正的 Table、Array、Inline Table 和 Array of Tables,可以不靠应用命名约定表达结构化数据。代价是,如果配置只有十几个扁平字符串键值,Properties 的极简可能更合适,TOML 的容器和类型系统反而是额外负担。

8.4 相对 INI:相似的 section 外观,不同的规范化数据模型

TOML 的 [table] 外观延续了 INI [section] 的熟悉感,但 INI 没有一份所有实现共同遵守的完整正式规范,重复 section 和重复 key 如何处理取决于具体方言或解析器。例如,有的实现会合并重复 section,Python configparser 在默认 strict=True 时则会拒绝单一输入中的重复 section 和 option。

INI 也没有 TOML 规范中的原生 Array of Tables 数据模型。不同 INI 方言可以用应用规则补充多值语义。例如 Git config 允许同一 key 有多个值:

[remote "origin"]
    fetch = +refs/heads/*:refs/remotes/origin/*
    fetch = +refs/notes/*:refs/notes/*

这是“一个 key 对应多个值”,不是“整个 Table 作为 Array 元素追加”。TOML 的 [[servers]] 每次创建的是一个完整子 Table,两者不是同一种能力。

9. 把这套模型放回 mise 配置

mise 中常见:

[env]
_.file = ".env"
_.path = "./bin"

这里先由 [env] 建立当前 Table,然后 _.file 和 _.path 作为相对 dotted key,定义 env._.file 和 env._.path。另一份文档可以改用:

[env._]
file = ".env"
path = "./bin"

两种写法生成的数据结构相同,所以 mise 看到的含义相同。但不能在同一份 TOML 中这样拼接:

[env]
_.file = ".env"

[env._] # INVALID: env._ 已由 _.file 定义
path = "./bin"

准确结论是:

它们是两种可替代的整体写法,解析结构等价;它们不是能在同一份文档中先后使用的追加操作。

这个案例说明,理解 TOML 不能只看最终 JSON 树,还要跟踪这棵树是如何被声明出来的。

10. 实际写作约束

TOML 规范允许的写法比日常需要的更多。为了避免未来重新追踪声明状态,可以采用更严格的项目风格。

10.1 同一分支选一种主要写法

少量、稀疏的嵌套配置可用 dotted key:

server.host = "localhost"
server.port = 8080
server.tls.enabled = true

同一 Table 下字段较多时改用 header:

[server]
host = "localhost"
port = 8080

[server.tls]
enabled = true

不要在同一分支中先用 dotted key 定义 Table,再尝试用 header 打开它。

10.2 按父子关系和业务邻近性组织

即使规范允许先写 [a.b] 再写 [a],也不应当作常规编排方式。相关字段集中放置,父 Table 和子 Table 按自然顺序排列,可以避免不必要的隐式状态推理。

10.3 不把应用合并规则当成 TOML 语义

mise、Cargo 或其他工具可能按全局、项目和本机配置的优先级合并多棵 TOML 树。这是应用层策略。单份 TOML 内部仍然不允许重复 key 或重复显式声明同一普通 Table。

10.4 用真正的宿主解析器验证

本篇的 dotted key、Table 单次声明、当前作用域和 Array of Tables 归属规则,在 TOML 1.0 与 1.1 中保持一致。但版本之间仍有语法差异:

  • TOML 1.0 的 Inline Table 要求花括号内不换行,且最后一个 key/value 后不允许 trailing comma;
  • TOML 1.1 允许 Inline Table 跨多行,也允许 trailing comma。

因此,配置通过某个在线 TOML 1.1 validator,不等于它能被项目实际使用的 TOML 1.0 parser 接受。最终必须使用真正消费该文件的应用或 parser 验证。YAML 的版本和 schema 差异也应作同样处理。

11. 复习索引

11.1 一句话心智模型

TOML 用多种外观构造一棵 Table 树,但构造过程遵守单次声明:结构等价不代表写法可以拼接,普通 Table 不能重复显式声明,Array of Tables 的重复 header 则是创建新元素。

11.2 遇到边界时的判断顺序

  1. 当前 key/value 位于 root table,还是某个 Table header 下?
  2. dotted key 是从当前 Table 开始的哪条相对路径?
  3. 路径中的节点是未定义、隐式父 Table、已定义 Table、普通值,还是 Array of Tables?
  4. 当前语句是添加新成员,还是重复赋值或重复声明已有 Table?
  5. 路径穿过 Array of Tables 时,它指向哪一层最近定义的 Table 元素?
  6. 使用 Inline Table 时,是否试图在花括号外继续扩展它?
  7. 实际消费配置的工具支持哪个 TOML 版本?

11.3 最容易忘的边界

  • [a.b] 是显式声明 Table,不是重新打开已有 Table。
  • a.b.c = 3 会定义中间 Table a 和 a.b。
  • [a.b] 下的 x.y = 1 表示 a.b.x.y = 1。
  • [a.b] 创建的父 a 可能仍是隐式 Table,之后可首次显式声明 [a]。
  • Inline Table 在花括号结束时就完全封闭。
  • [[x]] 重复出现会追加新 Table 元素,[x] 重复出现则是非法的重复声明。
  • 引用 Array of Tables 的路径指向该 Array 最近定义的 Table 元素,所以子节点不能先于父元素声明。
  • TOML 的文档内单次声明与应用层多文件合并是两个不同契约。

12. 核验锚点