如何编写代码格式化程序

询问十位开发人员,他们认为应该如何格式化某段代码,您可能会得到十种不同的意见。 更糟糕的是,这些观点几乎从来没有基于事实。 相反,当你问为什么他们更喜欢 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 并将其转换为字符串格式的源代码,同时跟踪必要的状态,例如行长度。 为此,我们将介绍两种类型: BuilderGenerator

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 中,单引号和双引号字符串文字都支持转义序列,例如 nt。 事实上,它们是完全一样的。 在其他语言(例如 Rust)中,您可能需要使用双引号,因此请记住这一点。

text 方法添加了一个 Stringchars 将字素簇扩展到缓冲区。 这 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

渲染图 TextUnicode 节点很简单:

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, SpaceOrLineIndent) 如下:

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 _ -> {}
  }
}

为了 LineSpaceOrLine 我们称之为 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,
value)
。 我们这样做是为了知道何时处理最后一个值,这样我们就可以插入一个仅在需要换行时才显示的尾随逗号。 Inko 不支持闭包参数中的模式匹配或 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(),
  ),
)

发表这篇文章后我有一个问题: 您如何使用这种格式化算法处理(尾随)注释?
这样做并不像人们想象的那么复杂,并且涉及以下步骤:

  1. 给定要渲染的节点列表(可能包括注释),将此列表转换为迭代器,允许在不推进迭代器的情况下查看值(即 Peekable

    在铁锈中)

  2. 要渲染的节点上的迭代器
  3. 对于每个节点,查看下一个节点。 如果这个节点是一个注释并且 开始
    与当前节点在同一行 结束,推进迭代器并将结果节点放在一边
  4. 渲染当前节点
  5. 在当前行渲染一个空格,取出之前放置的注释节点并渲染它。 因为我们在第3步中推进了迭代器,所以注释节点不会再次被处理
  6. 对每个表达式重复此过程

你可以找到这个想法的一个例子
这里

此实现的警告是注释节点可能会溢出行限制。 为了解决这个问题,您需要扩展格式化程序以支持注释换行。 这变得很复杂,因为您必须考虑注释中包含的任何特殊格式(例如 Markdown 代码块)并确保不会弄乱它们的格式。 对于 Inko,我决定让事情变得简单,在必要时不包含注释,而是将其留给开发人员。

将其应用于真正的格式化程序

虽然我们到目前为止讨论的是真实格式化程序的简化版本,但它与真实的生产代码格式化程序没有太大区别。 例如,Inko 自己的格式化程序使用此处讨论的相同设置,它只是有一些额外的节点来处理特定的格式化需求,并且必须处理诸如以文字形式呈现字符串转义序列之类的事情(即 n 被呈现为文字 n 而不是实际的换行符),以及根据格式规则呈现的任何格式边缘情况。

换句话说,这里讨论的设置可以帮助您完成大约 80% 的工作,而剩下的 20% 则用于根据您的格式需求处理边缘情况。 对于 Inko,我可能花了一两周的时间编写初始格式化程序,然后再花两到三周处理意外的边缘情况并仔细调整输出。

本文中显示的代码的最终版本以及大量有助于更好地理解代码的注释可以在以下位置找到:
这个 Git 存储库,您可以轻松地通过以下任一方式运行
安装 Inko
或使用 Docker/Podman。 如果您想要更高级的示例,请考虑查看 Inko 自己的格式化程序使用的代码

代码示例和链接存储库需要使用 Inko 的 main
分支,因为代码依赖于一些尚未发布的更改。 有关更多详细信息,请参阅存储库的自述文件。

如果您想了解有关构建编程语言的各个方面的更多信息,或者您有兴趣了解有关 Inko 的更多信息,请考虑
通过 GitHub 赞助商赞助我的工作, 加入 Inko 的 Discord 服务器 或者 矩阵通道 (桥接至 Discord 服务器),或订阅 /r/inko Reddit 子版块

Leave a Reply

Your email address will not be published. Required fields are marked *

近期新闻​

编辑精选​