做一个 formatter
Wadler Pretty Printer 读后感及一点点实践
最近在给自己的玩具语言搓 LSP,等 reviewer 行动期间摸个鱼,和 AI 对谈研究了下 formatter,才知道里面大有门道。
逛了圈知乎,似乎没什么人讲这个,只看到一篇简单讲发展历史的:
vibe reading 了 Wadler 的论文之后,我大致懂了基本原理。
document algebra
Wadler Pretty Printer 的思路是,把文档用代数形式表示出来,从而我们的排版操作可以利用代数性质来做。并且它的代数性质刚好很不错。
以下的表示并不完全和原论文相同,融入了一些我自己的理解和偏好。
给定以下五个基本构造和一个操作:
其中 表示拼接, 构成一个幺半群。
从字符串构造 ,并且是字符串幺半群到 幺半群的同态:
让 缩进 格。它只在断行处生效,非断行处缩进无效果:
对 缩进无效果:
缩进 格无效果:
缩进可以叠加:
缩进对拼接分配:
任意 都可经由上述等式规约为如下标准形:
证明过程略去。并且这很符合直觉——一个文档就是很多行(不包含缩进)的中间 join 上换行符和缩进。此前我们对每个操作都刻意写出了每种构造的情况,后面我们将如此省略一些可由现有等式推出的性质,读者可自行证明。
表示将文档渲染为字符串,同时也是 幺半群到字符串幺半群的同态:
缩进会在断行处后添加对应数量的空格:
我们都知道 formatter 可以根据最大宽度选择布局,而上面的构造不具备这种能力。接下来我们引入两个新的构造和一个变换:
表示将 中的换行压成空格:
表示多个布局中选择一个,有:
此外,对于 ,还应该满足两个不变式:
- 中所有候选的第一行都不短于 的第一行
这使得在选择布局时可以从左往右依次尝试布局。
在一些改进(如 Daan Leijen 的 wl-pprint)中,引入了一个永远失败的 构造 ,并规定 ,从而使得 构成一个幺半群。但这已超出 Wadler 原始论文和本文的讨论范围,不过多赘述。
意味着,如果 能用一行表示,那就压平:
操作并不处理 构造,我们会先消解布局选择再渲染:
只检查当前布局的第一行能否放进剩余宽度 :
则消去 :
最后,显然有:
简单的例子
由于我不会任何函数式语言,所以使用 sum type 体验较为舒适的 Rust。
接下来我们实现一个带 optional tail comma 扩展的 JSON formatter,源码已上传 GitHub。
定义 Doc IR:
#[derive(Debug, Clone)]
enum Doc {
Nil,
Text(String),
// 断行点:
// - flat 模式下输出 `flat`
// - broken 模式下输出 `broken`,然后换行并缩进
Break { flat: String, broken: String },
Concat(Vec<Doc>),
Nest(usize, Box<Doc>),
Union(Box<Doc>, Box<Doc>),
}
fn nil() -> Doc {
Doc::Nil
}
fn text(s: impl Into<String>) -> Doc {
Doc::Text(s.into())
}
fn concat(docs: impl IntoIterator<Item = Doc>) -> Doc {
let mut out = Vec::new();
for doc in docs {
match doc {
Doc::Nil => {}
Doc::Concat(docs) => out.extend(docs),
doc => out.push(doc),
}
}
match out.len() {
0 => Doc::Nil,
1 => out.pop().unwrap(),
_ => Doc::Concat(out),
}
}
fn break_with(flat: impl Into<String>, broken: impl Into<String>) -> Doc {
Doc::Break {
flat: flat.into(),
broken: broken.into(),
}
}
// Wadler 的 `line`:
// - flat 时变成一个空格
// - broken 时真正换行
fn line() -> Doc {
break_with(" ", "")
}
impl Doc {
fn append(self, other: Doc) -> Doc {
concat([self, other])
}
fn nest(self, indent: usize) -> Doc {
Doc::Nest(indent, Box::new(self))
}
fn group(self) -> Doc {
Doc::Union(Box::new(self.flatten()), Box::new(self))
}
}
起初,我做了一个 TailComma 变体,但询问 AI 后,被建议与 Line 变体合并,统一成更通用的 Break { flat, broken } 变体。
flatten 操作:
impl Doc {
fn flatten(&self) -> Doc {
match self {
Doc::Nil => nil(),
Doc::Text(s) => text(s.clone()),
Doc::Break { flat, .. } => text(flat.clone()),
Doc::Concat(docs) => concat(docs.iter().map(Doc::flatten)),
Doc::Nest(_, doc) => doc.flatten(),
Doc::Union(flat, _) => flat.flatten(),
}
}
}
渲染:
fn render(out: &mut impl Write, width: usize, doc: &Doc) -> Result<(), fmt::Error> {
let mut col = 0;
let mut stack = vec![(0usize, doc)];
while let Some((indent, doc)) = stack.pop() {
match doc {
Doc::Nil => {}
Doc::Text(s) => {
write!(out, "{s}")?;
col += display_width(s);
}
Doc::Break { broken, .. } => {
writeln!(out, "{broken}")?;
for _ in 0..indent {
out.write_char(' ')?;
}
col = indent;
}
Doc::Concat(docs) => {
stack.extend(docs.iter().rev().map(|doc| (indent, doc)));
}
Doc::Nest(extra, doc) => {
stack.push((indent + extra, doc));
}
Doc::Union(flat, broken) => {
let fit = width
.checked_sub(col)
.map(|remaining| fits(remaining, flat))
.unwrap_or(false);
stack.push((indent, if fit { flat } else { broken }));
}
}
}
Ok(())
}
// Wadler 风格的 fits:
// 只检查“当前这一行”还能不能放下。
fn fits(mut remaining: usize, doc: &Doc) -> bool {
let mut stack = vec![doc];
while let Some(doc) = stack.pop() {
match doc {
Doc::Nil => {}
Doc::Text(s) => match remaining.checked_sub(display_width(s)) {
Some(new) => remaining = new,
None => return false,
},
Doc::Break { .. } => return true,
Doc::Concat(docs) => {
for doc in docs.iter().rev() {
stack.push(doc);
}
}
Doc::Nest(_, doc) => {
stack.push(doc);
}
Doc::Union(flat, _) => {
stack.push(flat);
}
}
}
true
}
fn display_width(s: &str) -> usize {
// 这里只假设输入全是 ASCII。
// 真要做严谨的显示宽度,请用 unicode-width 之类的库。
s.len()
}
impl Display for Doc {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let width = f.width().unwrap_or(80);
render(f, width, self)
}
}
JSON formatter:
fn join(docs: impl IntoIterator<Item = Doc>, sep: Doc) -> Doc {
let mut iter = docs.into_iter();
let Some(mut r) = iter.next() else {
return nil();
};
for doc in iter {
r = r.append(sep.clone()).append(doc)
}
r
}
// 这个断行点用于尾逗号:
// - flat 时负责右括号前的那个空格
// - broken 时输出 `,`,然后换行
fn tail_comma() -> Doc {
break_with(" ", ",")
}
fn kv(k: impl ToString, v: Doc) -> Doc {
concat([str(k), text(": "), v]).group()
}
fn delim(
left: impl Into<String>,
items: impl IntoIterator<Item = Doc>,
right: impl Into<String>,
) -> Doc {
let left = text(left.into());
let right = text(right.into());
let items: Vec<_> = items.into_iter().collect();
if items.is_empty() {
return concat([left, right]);
}
let body = join(items, concat([text(","), line()]));
concat([left, concat([line(), body]).nest(4), tail_comma(), right]).group()
}
fn arr(items: impl IntoIterator<Item = Doc>) -> Doc {
delim("[", items, "]")
}
fn obj(items: impl IntoIterator<Item = Doc>) -> Doc {
delim("{", items, "}")
}
fn str(x: impl ToString) -> Doc {
text(format!("{:?}", x.to_string()))
}
最终,试着打印一段 package.json 的内容(源自我的 Blog 主题):
fn main() {
let doc = obj([
kv("name", str("komorebi-workspace")),
kv("private", text("true")),
kv("type", str("module")),
kv("workspace", arr([str("packages/*"), str("examples/*")])),
kv(
"script",
obj([
kv("run", str("npm --workspace examples/basic run dev")),
kv("test", str("vitest run")),
kv("docs:dev", str("vitepress dev docs")),
]),
),
kv("engine", obj([kv("node", str(">=22.12.0"))])),
]);
println!("width 40:");
println!("{doc:40}");
println!();
println!("width 200:");
println!("{doc:200}");
println!();
println!("width 400:");
println!("{doc:400}");
}
最终效果:

看起来还是不错的!
当然,要把这套理论用来给一个编程语言做 formatter 还远远不够,还请读者自行探索学术界和工业界的实践吧。