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

Conrad Irwin — 2025 年 4 月 在我的日常软件开发工作中,我需要不断地与复杂的配置文件打交道。截至 2025 年,它们通常采用以下三种格式之一:JSON-with-comments、YAML 或 TOML。

尽管这些格式被广泛使用,但在实践中使用它们却很痛苦:

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

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

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

我对它非常满意!

; a conl document defines key value pairs
; values are usually scalars
port = 8080
; but can also be lists of values
watch_directories
 = ~/go
 = ~/rust
; or maps of keys to values
env
 REGION = us-east1
 QUEUE_NAME = example-queue
; multiline scalars work like markdown
; with an optional syntax hint
init_script = """bash
 #!/usr/bin/env bash
 echo "hello world!"

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

我已经有了 RustGo 的工作实现;一个 language server 和一个 Zed 扩展。

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

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

考虑到我想要创建一个易于阅读 易于解析的东西,很明显它需要一个最小的、明确的语法。

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

另一方面,当你想在文档中构建结构时,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-with-comments 也让我特别恼火。配置文件中最常见的操作是“注释掉我刚刚添加的内容,看看是否能解决问题”。JSON-with-comments 的问题是它也有逗号;你需要确保它们存在于正确的位置。这很快让我产生了每个键都应该在其自己的行上定义的想法。换行符是极好的分隔符。不再需要管理逗号了!

结构

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

a = {
 b = 2
c = [
 1
 2

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

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

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

最后一个边界情况是没有值的键。我想将这些设为一个错误:

a ; error?!
b = c

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

语法

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

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

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

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

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

; keys and values can contain anything
; except ; (and = for keys).
welcome message = Whatcha "%n"!
; types are deferred until parse time;
; the app knows what it wants.
enabled = yes
country_code = no
; units are encouraged for numeric values
timeout = 500ms
; if you need an empty string or
; escape sequences, use quotes.
empty_string = ""
indent = "\t"
; the following escape sequences
; work inside quoted literals
escape_sequences
 = "\\" ; '\'
 = "\"" ; '"'
 = "\t" ; tab
 = "\n" ; newline
 = "\r" ; carriage return
 = "\{1F321}" ; 🐱 (or any codepoint)

再见,感谢阅读! ← Tools for Go modules

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

此内容可在 CC-BY-3.0 许可下获得,有 RSSATOM 提要。