询问十位开发人员,他们认为应该如何格式化某段代码,您可能会得到十种不同的意见。 更糟糕的是,这些观点几乎从来没有基于事实。 相反,当你问为什么他们更喜欢 X 风格而不是 Y 风格时,答案几乎总是相当于“我就是喜欢”。
如果我们可以回避整个争论并让计算机为我们做出决定会怎么样? 不,我不是在谈论要求 ChatGPT 为您格式化代码,我是在谈论“代码格式化程序”。
代码格式化程序是一个程序,它将源代码作为输入,使用特定样式对其进行格式化,然后将其写回磁盘或 STDOUT。 虽然此类工具已存在很长时间,但在过去 15 年左右的时间里,它们的使用变得越来越流行。 去的
戈夫姆特 特别是似乎是更多使用代码格式化程序背后的推动力,因为当今使用的许多流行格式化程序在 gofmt 发布后的几年中开始出现。 为了说明这一点,这里列出了似乎相当流行的各种格式化程序,以及它们首次引入的年份:
我怀疑 gofmt 本身并不是一个特别值得注意的格式化程序(除了不允许您以任何方式配置它,因为它应该是),而是 Go 本身非常受欢迎,因此让许多开发人员感受到了它的美丽不必担心手动格式化代码。 随着时间的推移,这种情况逐渐流行起来,导致自引入 gofmt 以来可用代码格式化程序的数量不断增加。
那么如何实际构建代码格式化程序呢? 是否需要数十年的 Haskell 工作经验并掌握 monad 的方式? 或者也许您必须阅读数百篇计算机科学论文才能理解 lambda 的深层含义? 为了更好地理解整个计算机科学,在麻省理工学院学习四年,背负沉重的学生债务怎么样?
不,编写一个像样的代码格式化程序实际上很简单,只是没有像计算机科学中的许多其他主题那样以简单的方式进行解释。 幸运的是,我最近花了几周的时间编写了一个代码格式化程序
印科,所以我现在自然是与代码格式化相关的所有方面的专家。
我们将在本文中介绍的设置基于 Inko 的格式化程序,而 Inko 的格式化程序又基于 更漂亮 和纸 “更漂亮的打印机”
(如果我没记错的话,Prettier 也是基于它的)。 这篇论文本身有点平凡,我已经忘记了 80%,但概念却很简单。
我们将使用 Inko 作为选择的语言来展示如何编写格式化程序,但将代码翻译成不同的语言应该很容易。
哦,在我忘记之前:如果您也有兴趣了解如何实现模式匹配,请看一下 这个 Git 存储库 其中包含 Rust 中的两个实现。 就像我们今天要讨论的代码一样,Rust 代码有很好的文档记录,并且应该很容易理解。 有趣的事实:
微光 基于此确切代码的模式匹配实现。 整洁的!
目录
节点和树
格式化程序的基本思想如下:我们采用某种抽象语法树(AST),特别是包含注释的树,并将其转换为格式化树。 格式化树具有各种节点,例如“仅渲染此文本”或“尝试将所有子节点放在一行上”。 构建树后,我们访问每个节点并将其呈现为字符串。 然后将生成的字符串写入文件或 STDOUT。
我们的树将使用总和类型或“枚举”创建。 在 Inko 中,您可以按如下方式定义枚举:
class enum Letter {
case A
case B
case C
}
Rust 的等价物如下:
在 Inko 中,枚举案例可以在定义时包装值,如下所示:
class enum Option[T] {
# This case stores some value of type "T", whatever that is.
case Some(T)
case None
}
在 Inko 中,您可以像这样创建一个枚举实例:
Option.Some(42)
Option.None
对于我们的树,我们将从基本定义开始:
现在让我们看看我们需要的不同节点。
文本
我们树的两个最基本的节点是 Text(value)
和 Unicode(value,
。
size)
这 Text
节点存储一个 ASCII 字符串(例如您语言中的关键字),而 Unicode
节点存储包含一个或多个多字节字符的字符串,及其表示为扩展字素簇数量的大小。 尺寸为 Unicode
节点被缓存,因为根据树的结构,我们最终可能必须多次计算此类节点的宽度。 由于计算字素簇是一个 O(n)
操作时,缓存该值会加快速度。
我们将这些节点定义如下:
class enum Node {
case Text(String)
case Unicode(String, Int)
}
这 String
参数存储要渲染的字符串,而 Int
参数用于存储扩展字素簇的数量。 为了 Unicode
我们还将添加一个辅助方法来使构建它们更容易一些:
class enum Node {
case Text(String)
case Unicode(String, Int)
fn static unicode(value: String) -> Node {
# `value.chars` returns an iterator over the extended grapheme clusters,
# and `count` simply counts them.
Node.Unicode(value, value.chars.count)
}
}
使用这种方法,我们构造 Unicode
节点如下:
Node.unicode('this is the string to render')
空格和缩进
为了处理空格和缩进,我们将定义三个节点: SpaceOrLine
,
Line
, 和 Indent
。
SpaceOrLine
是一个节点,如果它驻留在不需要换行的组中,则渲染为空间,换行时渲染为线 是 需要。
Line
是一个节点,如果它位于需要换行的组中,则呈现为新行,否则呈现为空。
Indent(nodes)
是一个渲染一个或多个节点的节点,缩进每个新行,但前提是它位于需要换行的组中。
在 Inko 中,我们像这样定义这些节点:
class enum Node {
...
case SpaceOrLine
case Line
case Indent(Array[Node])
}
为了帮助理解这些节点以及何时使用它们,请考虑我们要格式化的以下数组:
我们将构建以下树来格式化该数组:
# I'll explain what "Group" is in just a moment.
Node.Group(
0,
[
Node.Text('['),
Node.Line,
Node.Indent(
[
Node.Text('100'),
Node.Text(','),
Node.SpaceOrLine,
Node.Text('200')
]
),
Node.Line,
Node.Text(']')
]
)
当不需要包装时,数组将按原样呈现,因为 Line
化为乌有, Indent
仅在需要包装时才缩进,并且
SpaceOrLine
渲染到一个空间。 缠绕时 是 需要时,数组呈现如下:
分组节点
要将节点分组在一起,我们可以使用两个节点之一: Group
或者 Nodes
。
Group(id, nodes)
是我们尝试适应当前行的节点集合。 如果这不合适,每个子节点都会放置在自己的行上。 每个组都有一个 ID(只是范围内的数字) 0 <= id <= N
) 对于我们正在格式化的文档来说是唯一的。
筑巢时 Group
节点(例如 Group -> something else -> Group
),按组检查是否需要包装。 这意味着如果一个外部
Group
需要换行,这不会立即强制所有子组也换行。
Nodes(nodes)
是我们直接渲染的节点集合,无需任何特殊处理。 这使得在代码方面更容易使用某些辅助函数来生成我们只想连接在一起的多个节点。
我们这样定义这些节点:
class enum Node {
...
case Group(Int, Array[Node])
case Nodes(Array[Node])
}
这 Int
参数是组 ID,而 Array[Node]
参数存储子节点。
构建时 Group
我们需要跟踪下一个要使用的节点 ID。 这是通过在某处存储一个计数器,将现有值作为新值来完成的。 Group
,然后递增它:
let id = the_id_counter
the_id_counter += 1
Node.Group(id, nodes)
在 Inko 中,我们可以将其缩短为以下内容:
Node.Group(the_id_counter := the_id_counter + 1, nodes)
这 :=
运算符为变量分配一个新值,并返回之前的值。 相比之下, =
运算符丢弃旧值。
条件格式
我们要介绍的最后一个节点是 IfWrap(id, A, B)
节点。 如果组使用 ID,则这是呈现节点 A 的节点 id
需要被包装,否则渲染节点B。
使用前面显示的数组示例,当需要使用此树进行换行时,我们可以使用此节点添加尾随逗号:
Node.Group(
0,
[
Node.Text('['),
Node.Line,
Node.Indent(
[
Node.Text('100'),
Node.Text(','),
Node.SpaceOrLine,
Node.Text('200'),
Node.IfWrap(0, Node.Text(','), Node.Text(''))
]
),
Node.Line,
Node.Text(']')
]
)
当需要包装时,数组现在呈现如下:
计算宽度
在格式化树时,我们需要知道一个节点在当前行占据了多少个字符,因为这用于确定是否需要换行。 这意味着我们需要一种方法来计算 a 的宽度 Node
,我们将其定义如下:
class enum Node {
...
fn width(wrapped: ref Set[Int]) -> Int {
match self {
case Nodes(nodes) or Group(_, nodes) or Indent(nodes) -> {
Int.sum(nodes.iter.map(fn (n) { n.width(wrapped) }))
}
case IfWrap(id, node, _) if wrapped.contains?(id) -> node.width(wrapped)
case IfWrap(_, _, node) -> node.width(wrapped)
case Text(str) -> str.size
case Unicode(_, chars) -> chars
case SpaceOrLine -> 1
case _ -> 0
}
}
}
这 wrapped
参数是一个不可变的借用哈希集,其中包含我们迄今为止已处理且需要包装的所有组的 ID。 返回值是整数宽度。 在正文中,我们针对当前节点进行模式匹配(self
)。 对于包含其他节点的节点,例如 Nodes
和 Group
,宽度是所有子节点的宽度之和。
为了 IfWrap
我们必须根据是否需要包裹来不同地计算宽度。 这也是为什么我们不能计算一次宽度并缓存它:深度嵌套节点的宽度可能会根据父节点的包装需求而改变。
为了 Text
我们用 String.size
获取以字节为单位的大小(这恰好也是它的字符数,如 Text
节点仅存储 ASCII 文本),而对于
Unicode
我们使用预先计算的字素簇计数的节点。
该实现是一种递归算法而不是迭代算法,主要是为了简单起见,并且因为格式化树通常不是那么深度嵌套,所以它已经足够好了。
最终结果如下:
class enum Node {
case Group(Int, Array[Node])
case Nodes(Array[Node])
case IfWrap(Int, Node, Node)
case Text(String)
case Unicode(String, Int)
case SpaceOrLine
case Line
case Indent(Array[Node])
fn static unicode(value: String) -> Node {
Node.Unicode(value, value.chars.count)
}
fn width(wrapped: ref Set[Int]) -> Int {
match self {
case Nodes(nodes) or Group(_, nodes) or Indent(nodes) -> {
Int.sum(nodes.iter.map(fn (n) { n.width(wrapped) }))
}
case IfWrap(id, node, _) if wrapped.contains?(id) -> node.width(wrapped)
case IfWrap(_, _, node) -> node.width(wrapped)
case Text(str) -> str.size
case Unicode(_, chars) -> chars
case SpaceOrLine -> 1
case _ -> 0
}
}
}
跟踪包装需求
当遍历格式化树时,我们需要记录特定子树是否需要换行。 为此,我们将引入一个 Wrap
枚举可以处于两种状态之一: Enable
,意味着需要包装,或者 Detect
这意味着我们需要根据宽度来检测它。 Detect
是默认状态:
class enum Wrap {
case Enable
case Detect
fn enable? -> Bool {
match self {
case Enable -> true
case _ -> false
}
}
}
这 Wrap.enable?
添加方法是为了更容易检查是否需要换行,而无需手动进行模式匹配 Wrap
枚举。
将 AST 降级为格式化树
要将 AST 降级为格式化树,我们需要一种类型来访问 AST 中的节点并返回其对应的节点 Node
价值观。 我们还需要一个类型 Node
并将其转换为字符串格式的源代码,同时跟踪必要的状态,例如行长度。 为此,我们将介绍两种类型: Builder
和 Generator
。
这 Builder
type 用于定义访问 AST 节点所需的方法,返回其对应的 Node
价值观。 这 Generator
type 用于转换那些 Node
值到字符串。
为了简单起见,我们将限制本文中显示的代码来处理简单的函数调用、文本文字和字符串。
发电机类型
的基本布局 Generator
类型如下:
class Generator {
# This field is the buffer we'll write our formatted code into.
let @buffer: StringBuffer
# This field tracks the indentation levels, not the number of indentation
# characters (i.e. if you use 2 spaces for indentation, you increment this
# field by one).
let @indent: Int
# The number of characters/extended grapheme clusters on the current line.
let @size: Int
# The maximum number of characters we allow per line. If your formatter
# doesn't allow users to change this value, you probably want to turn this
# into a constant instead.
let @max: Int
# A hash set containing all the groups that need to be wrapped.
let @wrapped: Set[Int]
fn static new(max: Int) -> Generator {
Generator {
@buffer = StringBuffer.new,
@indent = 0,
@size = 0,
@max = max,
@wrapped = Set.new,
}
}
}
StringBuffer
是我们可以推送的类型 String
中的值并连接在一起,而不产生中间 String
价值观。
为了使用这种类型,我们定义一个 generate
方法需要一个 Node
,将其呈现为 String
并存储 String
在缓冲区中的 Generator
类型:
class Generator {
...
fn mut generate(node: Node) {
node(node, ref Wrap.Detect)
}
fn mut node(node: Node, wrap: ref Wrap) {
}
}
这 generate
方法只是调用 node
方法的默认值
wrap
争论。 如果您选择的语言支持默认参数,则不需要这样做,您可以将这两个方法合并为一个方法。
Inko 使用单一所有权进行内存管理。 这 generate
方法接管了所有权 Node
传递给它,因为的类型 node
论据是 Node
而不是例如 ref Node
(这是不可变的借用)。 表达方式 ref Wrap.Detect
创建一个实例 Wrap.Detect
case,然后将该值的不可变借用传递给 node
方法。 这个借用一直有效,直到我们从调用返回 node
。
在我们实施之前 node
方法,我们将添加两个辅助方法
Generator
输入并定义一个包含用于缩进行的字符的常量:
let INDENT = ' '
class Generator {
...
fn mut text(value: String, chars: Int) {
@size += chars
@buffer.push(value)
}
fn mut new_line {
@size = INDENT.size * @indent
@buffer.push('n')
@indent.times(fn (_) { @buffer.push(INDENT) })
}
}
在 Inko 中,单引号和双引号字符串文字都支持转义序列,例如 n
和 t
。 事实上,它们是完全一样的。 在其他语言(例如 Rust)中,您可能需要使用双引号,因此请记住这一点。
这 text
方法添加了一个 String
的 chars
将字素簇扩展到缓冲区。 这 new_line
方法添加一个新行,同时确保缩进新行。 这 INDENT
常量定义用于缩进行的字符。 在本例中,我们使用两个空格,但也可以是四个空格、一个制表符、一个制表符和三个空格,或者其他。
现在我们可以看一下 node
方法。 我们将从基本结构开始,然后逐步渲染每个节点:
fn mut node(node: Node, wrap: ref Wrap) {
match node {
case Nodes(nodes) -> {}
case Group(id, nodes) -> {}
case IfWrap(id, node, _) if @wrapped.contains?(id) -> {}
case IfWrap(_, _, node) -> {}
case Text(str) -> {}
case Unicode(str, width) -> {}
case Line if wrap.enable? -> {}
case SpaceOrLine if wrap.enable? -> {}
case SpaceOrLine -> {}
case Indent(nodes) if wrap.enable? -> {}
case Indent(nodes) -> {}
case _ -> {}
}
}
如果您很难理解 Inko 的模式匹配语法,您可以在以下位置了解更多信息: 文档。
渲染节点
渲染 Nodes
节点很简单:我们迭代子节点,并单独渲染它们。 类似于 Node.width
我们将使用递归算法。 虽然您可以将其转变为迭代算法,但它会有点棘手,而且我不确定它在实践中实际上会表现得更好。 渲染代码 Nodes
如下:
fn mut node(node: Node, wrap: ref Wrap) {
match node {
case Nodes(nodes) -> nodes.into_iter.each(fn (n) { node(n, wrap) })
...
}
}
英科没有 for
循环,而是使用迭代器和闭包。
nodes.into_iter
移动 nodes
Array
进入 一个迭代器 Node
价值观。 然后我们使用 each
要调用的迭代器类型的方法 node
对于每个值。
渲染组
渲染 Group
节点是事情变得有趣的地方。 首先,我们需要计算子节点的宽度,然后我们需要检查是否可以将它们放入当前行。 如果是这样,我们将这样做,否则我们将在其自己的行上渲染每个子节点:
fn mut node(node: Node, wrap: ref Wrap) {
match node {
...
case Group(id, nodes) -> {
let width = Int.sum(nodes.iter.map(fn (n) { n.width(@wrapped) }))
let wrap = if @size + width > @max {
@wrapped.insert(id)
Wrap.Enable
} else {
Wrap.Detect
}
nodes.into_iter.each(fn (n) { node(n, wrap) })
}
}
}
让我们从这一行开始分解:
let width = Int.sum(nodes.iter.map(fn (n) { n.width(@wrapped) }))
这会迭代子节点(不获取所有权,因此使用
iter
并不是 into_iter
),计算每个节点的宽度,然后使用以下方法对结果求和 Int.sum()
。 注意我们如何通过 wrapped
哈希值设置为每次调用 width
,这是必需的,因此我们可以根据任何包装需求计算正确的宽度 Group
节点。
接下来,我们查看节点是否适合当前行:
let wrap = if @size + width > @max {
@wrapped.insert(id)
Wrap.Enable
} else {
Wrap.Detect
}
我们检查当前的行大小加上计算的宽度是否不超过行限制。 如果是这样,我们会跟踪当前的 Group
身份证号在 wrapped
哈希集,并使用 Wrap.Enable
表示子节点的包装是必要的,否则我们使用 Wrap.Detect
。 我们在渲染子节点时,传递这个
Wrap
值作为每次调用的不可变借用 node
:
nodes.into_iter.each(fn (n) { node(n, wrap) })
性能方面,如果我们能够以某种方式缓存输出,那就太好了
width
加快速度,但我还没有找到这样做的方法。 幸运的是,这应该不重要,因为最终的设置足够快。 例如,Inko 的代码格式化程序使用这种精确的算法每秒可以处理大约 240,000 行,这已经足够快了。
渲染 IfWrap
渲染 IfWrap
节点很简单:我们检查目标组 ID 是否在 wrapped
设置或不设置,并渲染适当的子节点:
fn mut node(node: Node, wrap: ref Wrap) {
match node {
...
case IfWrap(id, node, _) if @wrapped.contains?(id) -> {
node(node, Wrap.Enable)
}
case IfWrap(_, _, node) -> node(node, wrap)
}
}
这 if @wrapped
bit 是模式匹配保护,因此仅当模式和保护都匹配时才会评估主体。
渲染文本和 Unicode
渲染图 Text
和 Unicode
节点很简单:
fn mut node(node: Node, wrap: ref Wrap) {
match node {
...
case Text(str) -> text(str, str.size)
case Unicode(str, width) -> text(str, width)
}
}
我们使用 text
之前定义的辅助方法。 为了 Text
我们使用的节点
String.size
传递字节大小(以及字符,如 Text
仅适用于 ASCII 文本),并且适用于 Unicode
我们传递预先计算的扩展字素簇计数的节点。
渲染空白
我们渲染各种空白节点(Line
, SpaceOrLine
和 Indent
) 如下:
fn mut node(node: Node, wrap: ref Wrap) {
match node {
...
case Line if wrap.enable? -> new_line
case SpaceOrLine if wrap.enable? -> new_line
case SpaceOrLine -> text(' ', chars: 1)
case Indent(nodes) if wrap.enable? -> {
@size += INDENT.size
@indent += 1
@buffer.push(INDENT)
nodes.into_iter.each(fn (n) { node(n, wrap) })
@indent -= 1
}
case Indent(nodes) -> nodes.into_iter.each(fn (n) { node(n, wrap) })
case _ -> {}
}
}
为了 Line
和 SpaceOrLine
我们称之为 new_line
如果需要包裹。 如果不需要包装,我们忽略 Line
节点(由通配符覆盖 _
模式在最后),而 SpaceOrLine
通过调用将其转换为单个空间 text
辅助方法。
为了 Indent
节点我们首先增加行的大小,因为我们从 当前的 行,然后我们增加缩进 等级 (不是缩进字符数)并将缩进文本添加到当前行。 然后我们渲染子节点,并将缩进级别重置为其之前的值。
将生成器转换为字符串
一旦我们完成了 Generator
类型,我们想把内部缓冲区变成 String
我们可以写入文件或 STDOUT。 为了使这变得简单,我们将实施 IntoString
特质来自于 std.string
模块:
impl IntoString for Generator {
fn pub move into_string -> String {
@buffer.into_string
}
}
给定一个实例 Generator
,然后我们可以使用 Generator.into_string
移动 Generator
进入 A String
。
最终的生成器类型
结合这一切,我们的 Generator
类型最终看起来像这样:
class Generator {
let @buffer: StringBuffer
let @indent: Int
let @size: Int
let @max: Int
let @wrapped: Set[Int]
fn static new(max: Int) -> Generator {
Generator {
@buffer = StringBuffer.new,
@indent = 0,
@size = 0,
@max = max,
@wrapped = Set.new,
}
}
fn mut generate(node: Node) {
node(node, ref Wrap.Detect)
}
fn mut node(node: Node, wrap: ref Wrap) {
match node {
case Nodes(nodes) -> nodes.into_iter.each(fn (n) { node(n, wrap) })
case Group(id, nodes) -> {
let width = Int.sum(nodes.iter.map(fn (n) { n.width(@wrapped) }))
let wrap = if @size + width > @max {
@wrapped.insert(id)
Wrap.Enable
} else {
Wrap.Detect
}
nodes.into_iter.each(fn (n) { node(n, wrap) })
}
case IfWrap(id, node, _) if @wrapped.contains?(id) -> {
node(node, Wrap.Enable)
}
case IfWrap(_, _, node) -> node(node, wrap)
case Text(str) -> text(str, str.size)
case Unicode(str, width) -> text(str, width)
case Line if wrap.enable? -> new_line
case SpaceOrLine if wrap.enable? -> new_line
case SpaceOrLine -> text(' ', chars: 1)
case Indent(nodes) if wrap.enable? -> {
@size += INDENT.size
@indent += 1
@buffer.push(INDENT)
nodes.into_iter.each(fn (n) { node(n, wrap) })
@indent -= 1
}
case Indent(nodes) -> nodes.into_iter.each(fn (n) { node(n, wrap) })
case _ -> {}
}
}
fn mut text(value: String, chars: Int) {
@size += chars
@buffer.push(value)
}
fn mut new_line {
@size = INDENT.size * @indent
@buffer.push('n')
@indent.times(fn (_) { @buffer.push(INDENT) })
}
}
impl IntoString for Generator {
fn pub move into_string -> String {
@buffer.into_string
}
}
建造者类型
这 Builder
type 用于访问 AST,并将每个 AST 节点转化为其对应的 Node
价值。 这通常是大部分复杂性所在,因为您将在此处处理构建格式规则、边缘情况等。 为了使事情易于理解,我们
Builder
type 仅支持简单函数调用、字符串文字和常规文本文字(例如简单整数)。
我们将从该类型的基本定义开始,如下所示:
class Builder {
let @id: Int
fn static new -> Builder {
Builder { @id = 0 }
}
fn mut new_id -> Int {
@id := @id + 1
}
}
这 id
字段用于跟踪下一个要使用的 ID Group
节点。 这 new_id
方法用于请求新的ID并自动更新
id
场地。
弦乐
对于字符串,我们将定义一个 string
方法如下:
class Builder {
...
fn mut string(value: String) -> Node {
Node.Group(new_id, [Node.Text('"'), Node.unicode(value), Node.Text('"')])
}
}
该方法构造了一个 Group
表示双引号字符串的节点。 虽然它需要的参数是常规的 String
在此示例中,在真正的格式化程序中,这将类似于 StringLiteral
AST 节点,包含实际字符串值以及额外数据(例如源位置)。
函数调用
对于函数调用,我们将定义一个 call
具有两个参数名称的方法:名称为 String
,参数节点为数组 Node
价值观:
class Builder {
...
fn mut call(name: String, arguments: Array[Node]) -> Node {
let id = new_id
if arguments.empty? {
return Node.Group(id, [Node.Text(name), Node.Text('()')])
}
let max = arguments.size - 1
let vals = arguments
.into_iter
.with_index
.map(fn (index_and_node) {
match index_and_node {
case (index, node) if index < max -> {
Node.Nodes([node, Node.Text(','), Node.SpaceOrLine])
}
case (_, node) -> {
Node.Nodes([node, Node.IfWrap(id, Node.Text(','), Node.Text(''))])
}
}
})
.to_array
Node.Group(
id,
[
Node.Text(name),
Node.Group(
new_id,
[
Node.Text('('),
Node.Line,
Node.Indent(vals),
Node.Line,
Node.Text(')'),
],
),
],
)
}
}
我们首先请求一个新的组 ID,然后检查是否有任何参数需要处理。 如果没有,我们返回一个简单的 Group
渲染到的节点 NAME()
在哪里 NAME
是函数名。
如果我们确实有争论,我们会翻转列表 Node
将值放入逗号分隔列表中,最后一个值后面有一个逗号,仅在需要换行时才显示:
let max = arguments.size - 1
let vals = arguments
.into_iter
.with_index
.map(fn (index_and_node) {
match index_and_node {
case (index, node) if index < max -> {
Node.Nodes([node, Node.Text(','), Node.SpaceOrLine])
}
case (_, node) -> {
Node.Nodes([node, Node.IfWrap(id, Node.Text(','), Node.Text(''))])
}
}
})
.to_array
这里我们转 arguments
进入 一个迭代器 Node
值,然后我们使用创建一个新的迭代器 with_index
产生以下形式的值 (index,
。 我们这样做是为了知道何时处理最后一个值,这样我们就可以插入一个仅在需要换行时才显示的尾随逗号。 Inko 不支持闭包参数中的模式匹配或
value)let
在这个阶段绑定,所以我们需要显式匹配 index_and_node
元组到其组件中。
其余的很简单:对于除最后一个参数之外的所有参数,我们都会生成一个 Nodes
包含值,后跟一个逗号和一个 SpaceOrLine
节点。 对于最后一个参数,我们生成一个 Node
包含最后一个参数,后跟一个 IfWrap
,仅当需要换行时才会呈现为逗号。
作为 map
创建一个新的迭代器(并且一切都是惰性完成的),我们需要使用将结果转换为数组 to_array
方法,这样我们就可以将结果数组存储在 Node
。
这 Node
返回结果如下:
Node.Group(
id,
[
Node.Text(name),
Node.Group(
new_id,
[
Node.Text('('),
Node.Line,
Node.Indent(vals),
Node.Line,
Node.Text(')'),
],
),
],
)
无论格式需要如何,该树都确保左括号始终位于名称之后,并且在需要换行时将右括号放在自己的行上。 如果需要换行,我们将每个参数放在自己的行上(因为它们位于 Group
节点),并且每行都缩进。 结果是,如果需要换行,则表达式如下:
foo(10000000000000000, 200000000000000000, 'this is a string')
将被格式化为这样:
foo(
10000000000000000,
200000000000000000,
'this is a string',
)
使用生成器和生成器类型
设置完这两种类型后,我们可以像这样使用它们:
# This creates a Generator that enforces a line length of 80 characters.
let gen = Generator.new(80)
let build = Builder.new
let node = build.call(
'foo',
[
Node.Text('1000000000000000000000000000000'),
build.call(
'bar',
[
Node.Text('2000000000000000000000000000000'),
build.string('this is a string'),
build.call('without_arguments', []),
],
),
],
)
gen.generate(node)
gen.into_string
这会产生以下输出:
foo(
1000000000000000000000000000000,
bar(2000000000000000000000000000000, "this is a string", without_arguments()),
)
如果我们将行限制更改为 120,则输出如下:
foo(1000000000000000000000000000000, bar(2000000000000000000000000000000, "this is a string", without_arguments()))
如果我们使用 40 的限制,我们会得到以下结果:
foo(
1000000000000000000000000000000,
bar(
2000000000000000000000000000000,
"this is a string",
without_arguments(),
),
)
发表这篇文章后我有一个问题: 您如何使用这种格式化算法处理(尾随)注释?
这样做并不像人们想象的那么复杂,并且涉及以下步骤:
- 给定要渲染的节点列表(可能包括注释),将此列表转换为迭代器,允许在不推进迭代器的情况下查看值(即
Peekable
在铁锈中)
- 要渲染的节点上的迭代器
- 对于每个节点,查看下一个节点。 如果这个节点是一个注释并且 开始
与当前节点在同一行 结束,推进迭代器并将结果节点放在一边 - 渲染当前节点
- 在当前行渲染一个空格,取出之前放置的注释节点并渲染它。 因为我们在第3步中推进了迭代器,所以注释节点不会再次被处理
- 对每个表达式重复此过程
你可以找到这个想法的一个例子
这里。
此实现的警告是注释节点可能会溢出行限制。 为了解决这个问题,您需要扩展格式化程序以支持注释换行。 这变得很复杂,因为您必须考虑注释中包含的任何特殊格式(例如 Markdown 代码块)并确保不会弄乱它们的格式。 对于 Inko,我决定让事情变得简单,在必要时不包含注释,而是将其留给开发人员。
将其应用于真正的格式化程序
虽然我们到目前为止讨论的是真实格式化程序的简化版本,但它与真实的生产代码格式化程序没有太大区别。 例如,Inko 自己的格式化程序使用此处讨论的相同设置,它只是有一些额外的节点来处理特定的格式化需求,并且必须处理诸如以文字形式呈现字符串转义序列之类的事情(即 n
被呈现为文字 n
而不是实际的换行符),以及根据格式规则呈现的任何格式边缘情况。
换句话说,这里讨论的设置可以帮助您完成大约 80% 的工作,而剩下的 20% 则用于根据您的格式需求处理边缘情况。 对于 Inko,我可能花了一两周的时间编写初始格式化程序,然后再花两到三周处理意外的边缘情况并仔细调整输出。
本文中显示的代码的最终版本以及大量有助于更好地理解代码的注释可以在以下位置找到:
这个 Git 存储库,您可以轻松地通过以下任一方式运行
安装 Inko
或使用 Docker/Podman。 如果您想要更高级的示例,请考虑查看 Inko 自己的格式化程序使用的代码。
代码示例和链接存储库需要使用 Inko 的 main
分支,因为代码依赖于一些尚未发布的更改。 有关更多详细信息,请参阅存储库的自述文件。
如果您想了解有关构建编程语言的各个方面的更多信息,或者您有兴趣了解有关 Inko 的更多信息,请考虑
通过 GitHub 赞助商赞助我的工作, 加入 Inko 的 Discord 服务器 或者 矩阵通道 (桥接至 Discord 服务器),或订阅 /r/inko Reddit 子版块。