CONL:用于配置文件的"Markdown"

Conrad Irwin — 2025年4月

在构建软件的日常工作中,我需要持续地与复杂的配置文件打交道。截至2025年,它们通常采用以下三种格式之一:带注释的 JSON、YAML 或 TOML。

尽管这些格式被广泛使用,但在实践中,每一种都让人痛苦不堪:

我还看到一些新的格式试图添加类型、for 循环和其他“只是一点编程”的功能;但我认为这完全是本末倒置。如果你需要生成配置文件,请使用你最喜欢的图灵完备编程语言。

所以,正如预言的那样,我创建了 CONL

目标是创建一个极简的格式,感觉像是配置文件的“markdown”。它应该是:

而且我对它非常满意!

; 一个 CONL 文档定义了键值对
; 值通常是标量
port = 8080
; 但也可以是值的列表
watch_directories
 = ~/go
 = ~/rust
; 或键到值的映射
env
 REGION = us-east1
 QUEUE_NAME = example-queue
; 多行标量的工作方式类似于 markdown
; 带有可选的语法提示
init_script = """bash
 #!/usr/bin/env bash
 echo "hello world!"

CONL 的格式现在可以被认为是“稳定的”,所以我鼓励你开始使用它!

我已经有了 RustGo 的有效实现;一个 langauge server 和一个 Zed 扩展。

CONL 极小的功能集使你能够轻松构建自己的实现(如果你喜欢,甚至可以一次性完成所有代码)。如果你想将其移植到你喜欢的语言,这里有一个更正式的 spec,以及一个可重用的 test suite,其中包含示例 CONL 文档及其等效的 JSON。

设计决策(供好奇者参考...)

鉴于我想制作一些易于阅读_和_易于解析的东西,很明显它需要一个最小且明确的语法。

我受到了 INI critique of TOML 的强烈启发,以避免语法类型的陷阱。乍一看,"false"false 是相同的值(尽管第一个在 JavaScript 中为真),因此语法不应区分两者。

另一方面,当你想在文档中获得结构时,INI 确实会崩溃(TOML 在某种程度上也是如此)。实际上,软件配置通常是一个嵌套树,JSON 和 YAML 可以很好地处理这个问题;所以我想保留这种能力。

这导致了一个数据模型,其中每个值都是 scalar|list|map 之一(与 JSON 的 null|bool|number|string|object|array 相比,感觉不错)。我也尝试过允许 Rust 风格的 enums,并提供语法来选择 enum 变体;但是,尽管它在配置文件中是一个常用的功能,但编程语言对它的支持并不好,所以它被放弃了。

简化模型的首要限制是不支持往返。你可以将任何 JSON 文档转换为 CONL,或将任何 CONL 文档转换为 JSON;但是 JSON -> CONL -> JSON 会丢失类型信息。

当时我也特别恼火带注释的 JSON。配置文件中最常见的操作是“注释掉我刚刚添加的内容,看看是否能解决问题”。带注释的 JSON 的问题是它也有逗号;你需要确保它们位于正确的位置。这很快让我意识到每个键都应该在其自己的行上定义。换行符是极好的分隔符。不再需要管理逗号了!

结构

鉴于每个键都应该有自己的行,我最初用于嵌套 map/list 的方法是使用 JSON 分隔符。这可行,但感觉有点牵强(也让我真的想添加逗号来获得像 TOML 中那样的行字面量)。

a = {
 b = 2
c = [
 1
 2

我从使用 TOML 的经验中知道,我不想要同一事物的两种不同格式(特别是如果一个版本使用换行符作为分隔符,而另一个版本使用逗号)。我也知道,如果你需要做任何超出顶层的事情,表格语法(尽管最初很酷)会让你注意到缺乏结构。

我还注意到,如果你看第一个参数,它实际上是明确的。如果 = 在开头,你有一个列表,否则你有一个 map。这确实使实现者的事情复杂化,但避免了你混淆 [{ 并收到令人困惑的错误消息的可能性。

我过去/现在也担心缩进。也就是说,如果它对 Python 来说足够好,我认为它对我来说也足够好...

最终的极端情况是没有值的键。我想将这些设置为错误:

a ; error?!
b = c

但是,后来我意识到,如果我注释掉 map 的内容,那就会变成一个错误。所以现在,如果你想表示一个没有值的键(或 JSON null),你可以这样做。

语法

CONL 的第一个版本使用 # 作为注释标记,但我很快遇到了问题。URL 包含 #,所以我的下一个版本要求 # 前面有一个空格。然后我注意到颜色,它_以_ # 开头(并且我想修复 a =# _不是_注释的解析问题)。另一个常见的注释是 //,但它在 URL 中使用,所以我必须保留我的空格规则。答案最终来自 INI。 很少在配置文件中找到的值中使用;所以它变成了注释字符。

我很早就知道我想要 markdown 的多行字符串。所以这意味着 """ 必须是一个保留的标记。也就是说,我最初非常反对带引号的标量。我想避免用多种方式来表示同一件事。所以相反,我决定使用 " 作为转义字符(有点像 Powershell 使用反引号):

"= = "; ; {"=":";"}

在使用了很长一段时间后,问题是(在已经有些罕见的情况下你需要转义)你经常需要多个转义。嵌入一个 Regex 非常痛苦,因为你通常不会寻找 = 和 ; 作为“你需要转义的东西”。

因此,为了使 CONL 更主流,我切换到带有反斜杠转义的双引号字面量。为了避免 JSON 的 UCS-2 问题,我不想支持 \u0000(并且 C 的 \U0000000 似乎不必要地冗长,因为 unicode 最多只有 10FFFF)。因此,可变长度的 \{0000} 用于代码点。

; 键和值可以包含任何内容
; 除了 ; (以及键的 =)。
welcome message = Whatcha "%n"!
; 类型被延迟到解析时;
; 应用程序知道它想要什么。
enabled = yes
country_code = no
; 鼓励对数值使用单位
timeout = 500ms
; 如果你需要一个空字符串或
; 转义序列,请使用引号。
empty_string = ""
indent = "\t"
; 以下转义序列
; 在带引号的字面量中起作用
escape_sequences
 = "\\" ; '\'
 = "\"" ; '"'
 = "\t" ; tab
 = "\n" ; newline
 = "\r" ; carriage return
 = "\{1F321}" ; 🐱 (或任何代码点)

再见,感谢阅读!

← Go 模块的工具

我是 Bluesky 上的 @ConradIrwin和 X),github:ConradIrwin 或 me@cirw.in。

此内容可在 CC-BY-3.0 许可下使用,有 RSSATOM 源。