Tilia—Haskell 的新格式化工具
摘要
Tilia 是 Haskell 的一个新格式化工具,它解决了诸如注释处理、运算符固定性推断和 CPP 支持等挑战。
<p><a href="https://lobste.rs/s/jbxpqz/tilia_new_formatter_for_haskell">评论</a></p>
查看缓存全文
缓存时间: 2026/09/17 05:34
# Tilia——一款新的 Haskell 格式化工具
来源:https://markkarpov.com/post/announcing-tilia.html
*发布于 2026 年 9 月 16 日*
Haskell 仍是一种格式化难度较大的编程语言。核心的解析/打印机制现在相对容易构建,因为我们有了 `ghc-lib-parser`,它暴露了 GHC 的真实解析器(打印从来不是问题),但多年来似乎有三个难以逾越的挑战:
1. **正确处理注释**。我上个月研究了这个问题,确信已掌握足够知识,可以宣布此挑战已解决。
2. **格式化运算符链,或者更确切地说,如何绝对精确地推断运算符结合性这个令人头疼的问题**。
3. **CPP(C 预处理器)支持**。
Tilia 是我从头编写的新格式化工具。它旨在以一种系统的方式解决所有这三个挑战。在本文中,我将通过具体考察问题 2(运算符结合性)和问题 3(CPP 支持)来介绍 Tilia。让我们开始吧!
## 结合性
对于 Haskell 格式化工具的作者来说,如果没有任何自定义结合性的运算符,那该有多好!这看似是语言的一个小特性,但在格式化时却非常麻烦。一般情况下,给定一个运算符链,需要知道运算符的优先级才能进行格式化。如何获取这些信息呢?据我所知,有两种方法:
1. **硬编码**。这可以或多或少地做得更精巧些。最精巧的做法是尽可能扫描整个 Hackage,组装一个小数据库,然后随格式化工具一起发布。当然,这对用户可能使用的自定义运算符毫无帮助,因此你还允许他们手动告知格式化工具结合性。重导出是另一个烦人的细节,所以你也允许指定这些信息。
2. **与 GHC 和 Cabal 交朋友**,获取真实信息,处理重导出等所有复杂情况,同时从你要格式化的源代码中提取运算符的结合性。
据我所知,方法 2 从未被尝试过。现在我在 Tilia 中端到端地实现了它,我想我明白了原因。这趟旅程很坎坷!
### Tilia 如何发现结合性
对于一个 Haskell 依赖,发现其结合性有两种可行方式:
- 如果它已经安装——比如随编译器一起分发的基础库,或像在 Nix shell 中那样已存在的任何库——那么你可以读取其接口文件。
- 如果它未安装,那么你首先需要获取其源代码包,并从中挖掘。
一般而言,要使这两种方式都可行,必须得到非常好的支持。接口方式要简单得多:它返回的信息你可以立即使用——因为 GHC 已经编译了该包,你无需担心 CPP,也不用担心其源文件是 `.hsc` 格式,或许多其他棘手的细节。而当你直接使用源代码时,则需要操心所有这些。
有一点很有帮助:结合性是定义模块的语法特性,因此无需编译——对于仅仅运行一个格式化工具来说,那可是一个相当烦人的要求!你所要做的就是遵循重导出链(或者说重导出图),直到发现所有运算符(同时处理好各处可能存在的环)。然后你可以缓存结果(另一个有趣的方面,限于篇幅,我将跳过)。但等等,还没完。考虑这个例子:
```haskell
import Data.List.NonEmpty (NonEmpty (..))
myFunc x y = x :| y
```
我们都知道 `:|` 是 `NonEmpty` 的一个数据构造器,但仅从导入语句本身并不明显:导入列表从未提及 `:|`,只有那个通过 `(..)` 引入它的类型。因此,仅知道模块导出了哪些名称是不够的。你还需要跟踪:
- **重导出**及其形成的环。
- **子元素**:每个导出的类型或类携带了什么——数据构造器、记录字段、类方法、关联类型——以便 `T(..)` 能被展开为其实际引入作用域的名称。
- **命名空间**:Haskell 有两个命名空间,同一个运算符可以在两个命名空间中声明。类型中的 `:|` 和表达式中的 `:|` 不一定具有相同的结合性,因此结合性仅针对特定的命名空间给出。
- **导入语句的写法**:是否限定导入、使用什么别名、是使用显式列表还是 `hiding` 子句。所有这些决定了给定导入是否可能引入了该运算符。
这很容易。
最后,你绝对不能忽略任何东西,因为万一你跳过的那个怪异模块正是定义了你所寻找的运算符呢——或者定义了一个拼写相同但不同的运算符?在后一种情况下,会出现歧义,你必须报告歧义而不是去猜测。如果你想坚持绝对正确/精确性™的承诺,这就是规则。
鉴于上述所有情况,Tilia 最终与 Cabal 处于一种共生(或者寄生?)关系中,这应该不会令人惊讶。它会检查其构建计划,并在需要时要求其解决一个新的构建计划。它还会要求下载源代码包。所有这些都是一次性成本,即使对于你之前从未构建过的大型项目,也只需几秒钟。
Tilia 的命令行接口基于 Cabal 组件构建:
```bash
$ tilia inplace [组件] # 就地格式化组件的所有文件
$ tilia check [组件] # 检查组件的所有文件是否已格式化
```
`组件` 参数可以省略,此时默认为 `all`。例如,对于 Tilia 自身,有效的选择包括:
- `all`
- `tilia`(包名),意味着它的每个组件
- `lib:tilia` 或 `tilia:lib:tilia`
- `exe:tilia` 或 `tilia:exe:tilia`
- `test:tests` 或 `tilia:test:tests`,或者简写为 `tests`
Nix 使用场景可以完美运行,因为在那里我们读取接口文件——无需下载任何东西。Stack 项目往往也能直接工作,尽管目前没有专门针对 Stack 的支持。最后,通过 `cabal.project` 中的 GitHub 或任意 URL 供应的私有依赖也能正常工作。
## CPP
CPP 在 Tilia 中是一种一等格式化构造。这是我非常想实现的一个功能,也是让我不得不回到白板重新思考的一个。最终我阅读了关于 SuperC(*通过驯服预处理器解析所有 C* (https://paulgazzillo.com/papers/pldi12.pdf), Gazzillo & Grimm, PLDI 2012)和 TypeChef(*在词法宏和条件编译存在下的变体感知解析* (https://pl.cs.uni-tuebingen.de/publications/kaestner11variability-aware/), Kästner 等人, OOPSLA 2011)的论文,以及 Erwig 和 Walkingshaw 的选择演算(*选择演算:软件变体的表示* (https://web.engr.oregonstate.edu/~erwig/papers/ChoiceCalculus_TOSEM11.pdf), TOSEM 2011)。
Haskell 特有的难点在于,没有解析器支持 CPP。我的意思是,你不会得到像“这里有一个 CPP 条件”这样的 AST 节点。修改 GHC 的解析器是不现实的。那么该怎么办?
我最终采用的方法是:
1. 枚举我们要格式化的模块的 CPP 配置。
2. 对于每种配置,通过屏蔽掉不在该配置中的行,派生出该模块的一个版本。为每个配置标记一个决策向量,记录它采取了哪些分支。
3. 将这些内容输入正常的解析器。其中已没有 CPP,因此可以工作。
4. 将每个结果渲染为一种中间数据类型——这实际上是 Tilia 从一开始就在做的事情。
5. 遍历所有渲染后的结果,并*智能地*(这个词在博客文章中写起来容易,天知道实现起来有多痛苦)将它们重新组合起来,在需要的地方重新引入 CPP 指令,这纯粹是基于不同配置格式化后的实际差异。
而且,你可能感到惊讶,但这是可行的,并且是实用的。让我们看一个例子:
```haskell
{-# LANGUAGE CPP #-}
module Database.Pool ( newPool, Config (..)
#if defined(METRICS)
, withMetrics
#endif
) where
data Config = Config { configHost :: HostName, configPort :: PortNumber
#if defined(METRICS)
, configSink :: MetricsSink, configHistogram :: Histogram Double
#endif
, configIdleTime :: NominalDiffTime }
newPool cfg = createPool (connect (configHost cfg) (configPort cfg)) closeConnection
#if defined(METRICS)
(configSink cfg)
#endif
(configIdleTime cfg)
```
格式化后变为:
```haskell
{-# LANGUAGE CPP #-}
module Database.Pool
( newPool,
Config (..),
#if defined(METRICS)
withMetrics,
#endif
)
where
data Config = Config
{ configHost :: HostName,
configPort :: PortNumber,
#if defined(METRICS)
configSink :: MetricsSink,
configHistogram :: Histogram Double,
#endif
configIdleTime :: NominalDiffTime
}
newPool cfg =
createPool
(connect (configHost cfg) (configPort cfg))
closeConnection
#if defined(METRICS)
(configSink cfg)
#endif
(configIdleTime cfg)
```
看看这里发生了什么。作者写记录时使用的是*前导*逗号,其中一个逗号位于条件分支内。Tilia 使用的是尾随逗号,因此每个逗号都必须移动到它所分隔的字段的另一边——这意味着它们要跨越条件边界移动。现在位于 `configPort :: PortNumber` 之后的逗号位于 `#if` *外部*,而 `configHistogram :: Histogram Double` 则在*内部*获得了一个逗号。
底部的函数应用是另一半技巧。它原本是扁平写在一行上的,中间有一个条件参数;输出后是每个参数占一行,并且条件参数保持在序列中的位置。值得注意的是,这不是一个布局决策,而是两个。Tilia 分别布局了一个四参数应用和一个三参数应用,你看到的是合并两个结果后保留下来的部分。它们恰好一致,因此只写了一份副本,而指令则围绕着它们不同的那一个参数。
所以,我们从选择演算中借鉴的是其表示形式——一个文档是一棵可能包含 n 元*选择*节点的树,代表同时存在的多个备选方案——以及支配此类树的等式法则,这告诉合并操作其目标是什么:
- 一个所有备选项都相同的选择根本不是选择(D⟨a, a⟩ = a),这就是条件未触及的部分得以合并的方式;
- 对象结构可以提取出选择之外(D⟨f a, f b⟩ = f D⟨a, b⟩),这就是条件被保留在其围绕的声明周围而非整个模块周围的方式;
- 独立维度上的选择是可交换的,这就是打印机构建的布局变体和作者编写的条件可以相互穿透而非相乘的方式。
我们没有借鉴的是演算的其余部分。没有维度,也没有绑定器:guard 只是复制到输出中的不透明文本,从不被读取,因此两个询问相同问题的条件不会被关联在一起,并且没有任何东西会去推理哪些配置是可行的。变体也不在语法树中存在,不像在选择演算和基于它构建的变体感知解析器中那样——语法树是 `ghc-lib-parser` 的,无法更改。选择仅存在于 Tilia 用于打印的文档中。
CPP 支持令人兴奋,但我也需要对你坦诚——这是项目中最脆弱的部分,因此在接下来的版本中,它将是我关注的重点。
## 结论
Tilia 的首个版本已发布在 Hackage 上。请试用一下,如果它对你有用,请告诉我。项目的主页是这个 GitHub 仓库 (https://github.com/mrkkrp/tilia),你可以在那里报告发现的所有错误!GitHub Action (https://github.com/mrkkrp/setup-tilia) 也可用。
相似文章
流类型:线性的新视角
一篇博客文章,详细介绍了论文《流类型:透过透镜重新审视线性》,该论文利用透镜理论,在不增加核心语言复杂性的前提下解决线性类型系统中的穿线问题,并介绍了作者在一门名为 Laminar 的新语言中开展的实现实验。
LuaJIT 3.0 提议的语法扩展
LuaJIT 3.0 提议的语法扩展已公布,为 Lua 的 LuaJIT 即时编译器带来潜在的新功能。
Lophius:来自 Heretic 创造者的语言模型研究工作台
Lophius 是一个新的混合代码/GUI 研究系统,用于语言模型,运行在笔记本中,旨在减少样板代码并简化模型检查、分词器分析和推理等任务。
Lattice
Lattice 是一款写作助手工具,能够跨语言重塑文本,在保留原意的同时创造新的表达方式。
JavaScript 精简
LispE 是 NAVER 开发的一个紧凑的 Lisp 方言,它结合了函数式和数组语言特性,并支持 PyTorch 和 llama.cpp 等 AI 库。