Unparser实战:将Ruby工具从Parser迁移到Prism的经验与挑战

本文深入探讨了如何将Ruby工具从旧的Parser gem迁移到Ruby 3.4内置的新解析器Prism。文章涵盖了Ruby的奇特语法、AST(抽象语法树)的概念、Prism的设计优势、迁移过程中的API差异与挑战,并以Unparser项目为例,提供了具体的迁移策略和测试方法。

Unparser: 将Ruby工具从Parser迁移到Prism的现实经验与教训

Ruby 3.4 内置了 Prism,这是一个全新的解析器,速度更快、可移植性更强,旨在成为未来几代 Ruby 工具的基础。这一转变影响着所有处理 Ruby 语法的工具:linter、格式化程序、IDE、分析器等。我们将探索一些奇特的 Ruby 语法,了解 Prism 为 Ruby 生态系统带来了什么,然后以 Unparser 为例,展示从 Parser 过渡到 Prism 的实际过程。好消息是:这种过渡是可行的。但要注意的是:需要处理 API 差异、新的 AST 结构、大量的边缘语法,并进行彻底的测试!

实战中Ruby的奇特之处

在讨论 Prism 之前,有必要先了解为什么 Ruby 解析器的工作如此困难。Ruby 的语法极具表达力且对开发者友好,但这带来了代价:维护处理它的基础设施(最显著的是解析系统)是具有挑战性的。

Ruby 的表达力以许多令人惊讶的方式呈现。例如,RubyTrik 竞赛(“Transcendental Ruby Imbroglio Contest”)挑战参与者编写最晦涩(但有效)的 Ruby 代码。

让我们看一些挑战语言语法边界的 Ruby 案例。这些是“合法”的 Ruby(并非拼写错误或“代码高尔夫”)。

在这里,lhs 表示运算符的“左侧”,rhs 表示“右侧”。

#1: 我们可以使用 lhs..rhslhs...rhs 语法构建范围,但是能用范围边界来构建范围吗?

1
1.....2

显然可以:例如,这个表达式是一个从 1 到 ..2 的范围:

1
2
3
4
5
$ ruby-parse --33 -e '1.....2'
(erange
(int 1)
(irange nil
  (int 2)))

#2: 触发器(flip-flop)运算符本身就极为罕见,但你见过没有 LHS 或 RHS 的触发器吗?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$ ruby-parse -e '!(1..)' 
(send
  (begin
    (iflipflop
      (int 1) nil)) :!)

$ ruby-parse -e '!(..1)'  
(send
  (begin
    (iflipflop nil
      (int 1))) :!

这些不完整的触发器非常罕见,以至于 RuboCop 在遇到它们时会失败。

#3: 当通过 rescue => <表达式> 捕获异常时,Ruby 内部执行 <表达式> = $!。这意味着 rescue 的目标不仅仅是像 rescue => e 这样的局部变量;它可以是常量(rescue => Arescue => ::Object)、方法调用(rescue => foo.bar),甚至是索引赋值:

1
2
3
4
5
6
7
begin
rescue => array[42]
end

begin
rescue => hash['error']
end

由于您可以用任意数量的参数重新定义 #[],Ruby 接受零元索引赋值:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
object = Object.new
def object.[]=(value)
  puts "Received `#[]=` with #{value.inspect}"
end

begin
  raise 'oops'
rescue => object[]
end

#=> Received `#[]=` with #<RuntimeError: oops>

我们必须修补 Prism 的 Parser 翻译层来支持这种语法。

这些边缘案例可能看起来很荒谬,但它们是完美的合法 Ruby。因此,解析器(以及依赖它们的工具)必须能够处理它们。

快速回顾:什么是 AST 和解析器?

我们已经看到 Ruby 的语法可能会变得很奇怪。工具如何理解像 1.....2rescue => object[] 这样的代码呢?答案是解析器和 AST。让我们快速回顾一下。

为了清晰起见,我们将使用术语 Parser 特指 Parser Ruby gem。

AST(抽象语法树) 是源代码的分层、树状结构表示。每个节点对应一个语法结构(如方法定义、关键字、运算符或字面量),并编码其类型和值。解析器将原始源代码文本转换为这种结构化的形式,使程序能够分析、转换或编译代码,而无需直接处理非结构化的字符串。

例如,看看这段 Ruby 代码:

1
2
3
4
5
def greet
  if 👽
    puts "It's cool on Mars!"
  end
end

……这段代码可能会变成如下所示的 AST:

1
2
3
4
5
6
(def :greet
  (args)
  (if
    (send nil :👽)
    (send nil :puts
      (str "It's cool on Mars!")) nil))

那么,这为什么重要呢?一旦代码以 AST 形式存在,工具就可以分析、转换或检查它,而无需处理原始文本。

不同的解析器使用不同的 AST 表示。通常,特定于语法的内容,如空格、注释和缩进,会被省略。有些解析器可能还会规范化不同的运算符(如 and&&),因为它们之间的唯一区别是优先级,而这可以通过树节点的顺序来编码。

对于像 linter 这样的下游工具来说,分析 AST 通常要容易得多,因为它排除了不必要的信息。

介绍 Prism:Ruby 的现代解析器

多年来,存在多个 Ruby 解析器实现。CRuby 本身包含一个内置解析器(parse.y)并通过 Ripper 公开它,但 Ripper 的 API 和 AST 众所周知难以使用,并且它只支持当前运行的 Ruby 版本的语法(这对于像 RuboCop 这样的工具来说是一个关键限制,它们必须分析不同 Ruby 版本的代码)。

像 JRuby 和 Natalie 这样的替代 Ruby 实现开发了自己的解析器。此外,还出现了一些 gems,提供更易用的 Ruby API 进行解析,其中最著名的是在 Evil Martians 内部开发的 Whitequark 的 Parser gem。

然而,许多这些第三方实现往往落后于最新的 Ruby 语法。最终,Shopify 认识到需要一个现代、可靠的解析器来作为标准。他们投资开发了 Prism 来填补这一空白。

这就是 Prism 的闪光点:其设计基于广泛的模糊测试和庞大的测试语料库,其中就包括了这类奇特的代码。它整合了来自 parserunparserruby_parser 等成熟 gem 的测试,以确保全面的覆盖。

Prism 被设计在几个关键领域表现优越:

  • 性能:对解析器进行基准测试相当棘手,但 Benoit Daloze 的这篇优秀博文报告说,Prism 将 Ruby 代码解析为 MRI 字节码的速度比 parse.y 快 1.46 倍。
  • 可靠性:Prism 经过了彻底的测试,包括模糊测试和针对真实世界 Ruby 代码库的大规模评估。
  • 可维护性:它的设计比 parse.y 更容易维护和演进。
  • 可移植性:Prism 实现为一个独立的 C 库,没有 Ruby 运行时依赖,使其可嵌入到各种环境中。这对于像 JRuby 这样的替代 Ruby 实现尤其重要,它们现在可以采用标准解析器而无需受架构约束。因此,Prism 提供了 JavaScript 和 Rust 的一级绑定,并且 Java 支持直接包含在仓库中。

另一个重要方面是容错解析。LSP 实现正受到越来越多的关注,对于现代解析器来说,简单地接受一个程序(并产生相应的 AST)或拒绝它已经不够了。相反,即使代码处于损坏状态,解析器也必须提供有用的诊断信息,以便开发者仍然能够受益于自动补全、跳转到定义或内联错误高亮等功能。

Prism 的维护者在错误恢复和诊断方面投入了大量精力。比较一下这个截断的方法定义的 parse.y 输出和 Prism 输出:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$ ruby -c --parser=parse.y -e 'def foo'
-e:1: syntax error, unexpected end-of-input
def foo
bin/ruby: compile error (SyntaxError)

$ ruby -c --parser=prism -e 'def foo'
bin/ruby: -e:1: syntax errors found (SyntaxError)
> 1 | def foo
    |        ^ unexpected end-of-input, assuming it is closing the parent top level context
> 2 | 
    | ^ expected an `end` to close the `def` statement

当开发者在大型脚本中忘记添加结束的 end 时,第二个输出显然提供了更好的反馈。

一个有趣的副作用是,由于 Prism 能够从某些语法错误中恢复,它也可以输出警告,而 parse.y 不能:

1
2
3
4
5
6
7
8
$ ruby -Wc --parser=parse.y -e '42; def foo'
-e:1: syntax error, unexpected end-of-input
...

ruby -Wc --parser=prism -e '42; def foo'
-e:1: warning: possibly useless use of a literal in void context
bin/ruby: -e:1: syntax errors found (SyntaxError)
...

现在是 Ruby 3.4 时代了,该使用哪个解析器?

但有一个问题:Prism 的 AST 格式与 Parser 的不兼容。这意味着你不能简单地换入 Prism 并期望你现有的 AST 处理逻辑继续工作。

为了弥合这一差距,Prism 团队引入了翻译层。顾名思义,它将 Prism 的原生 AST 格式翻译成 Parser 使用的结构(实际上有多个翻译层支持多个解析器)。这使得依赖 Parser AST 的工具能够继续运行——现在由 Prism 在底层提供支持。

Ruby 3.4 中最显著的一个语法变化是引入了 it 块。在早期的 Ruby 版本中,你必须在块中显式声明它:

1
2
3
4
5
# Ruby 3.3: 需要显式块参数
42.tap { |it| puts it }

# Ruby 3.3: 这会引发错误:`it` 未定义
42.tap { puts it }

但在 Ruby 3.4 中,块隐式地获得一个 it 参数:

1
2
# Ruby 3.4: 这工作得很好,无需声明 `it`
42.tap { puts it }

由于 Parser 不支持这种新语法,以下是它尝试使用其最新可用语法(Ruby 3.3)解析代码的结果:

1
2
3
4
5
6
7
$ ruby-parse --33 -e '42.tap { p it }'
(block
  (send
    (int 42) :tap)
  (args)
  (send nil :p
    (send nil :it)))

从 Parser 的角度来看,这在技术上是有效的 AST,但对于 Ruby 3.4 来说语义不正确。这里的 it 被视为方法调用而不是块局部变量。

现在,让我们看看 Prism 翻译层对相同代码产生了什么:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
$ bin/prism parser -e '42.tap { p it }'
Parser:
s(:block,
  s(:send,
    s(:int, 42), :tap),
  s(:args),
  s(:send, nil, :p,
    s(:send, nil, :it)))

Prism:
s(:itblock,
  s(:send,
    s(:int, 42), :tap), :it,
  s(:send, nil, :p,
    s(:lvar, :it)))

注意关键区别:Prism AST 正确地将其识别为 itblock,并将 it 视为块局部变量 lvar,而不是方法调用。

换句话说,即使 Parser 尚未更新以理解 Ruby 3.4 的功能,翻译层也能确保你正在处理的 AST 仍然有意义,并且像 RuboCop 这样的工具可以正确操作它(前提是它们也支持这种翻译格式)。

迁移 Unparser:一个案例研究

多年来,Unparser 构建在 Parser gem 之上,提供了一种将 AST 转换回有效 Ruby 代码的可靠方法。由于我们在自己的项目中(特别是对于像 ruby-next 这样的工具)依赖 Unparser,Ruby 3.4 的过渡带来了挑战:我们需要 Unparser 与 Prism 的解析器及其新的语法功能一起工作。

与其等待其他人来解决这个迁移问题,我们直接为 Unparser 项目做出了贡献,增加了 Prism 支持,同时保持与旧 Ruby 版本的向后兼容性。这项工作确保了 Unparser 可以继续服务于生态系统,同时利用 Prism 在性能和错误处理方面的改进。

从解析到反解析

既然我们知道如何解析任意的 Ruby 程序,让我们探索一下反向过程:反解析。

如前所述,不同的 Ruby 程序可以产生相同的 AST。这意味着我们不能可靠地从 AST 重建完全相同的原始源代码。然而,在大多数现实场景中,不需要那种程度的保真度。重要的是重新生成一个能产生相同 AST 的程序,因此在运行时行为相同(让我们忽略自省程序)。无论如何,在实践中,输出通常看起来与原始代码非常相似。

那么,我们为什么要反解析 AST 呢?以下是一些依赖 Unparser 的真实项目示例:

  • Ruby Next:使用 Unparser 在执行基于 AST 的转换后重新生成 Ruby 代码,以向后移植现代语法。
  • mutant,可能是最著名的突变测试引擎,使用 Unparser 生成突变的源代码。(事实上,Unparser 最初是为了支持 Mutant 而创建的。)
  • proc_to_ast 是 Unparser 的一个薄包装,用于在运行时提取 Proc 的源代码。
  • 还有许多其他工具是 Unparser 的逆向依赖项。如果一个项目执行反解析,它可能在做一些聪明或不寻常的事情;这通常不是无聊程序会做的事情。

但“反解析”到底是什么意思呢?回想一下,许多不同的 Ruby 程序可以共享相同的 AST。例如,考虑这两个功能上等效的片段:

1
2
3
4
5
def greet
  if 👽
    puts "It's cool on Mars!"
  end
end

和:

1
2
3
def greet
  puts "It's cool on Mars!" if 👽
end

两者都生成相同的 AST,尽管外观不同。

无限数量的程序可以共享相同的 AST,但对于给定的 AST 和固定的反解析算法,我们只能重建一个程序。

那么,反解析成功的标准是什么?正式的标准是往返(round-tripping):如果我们解析一些 Ruby 代码,反解析得到的 AST,然后再解析它,我们应该得到相同的 AST:

1
parse(unparse(parse(ruby))) = parse(ruby)

注意,这与以下情况不同:

1
unparse(parse(ruby)) = ruby

这个更严格的条件意味着我们可以恢复完全相同的原始程序,包括格式、注释和语法选择。但由于 AST 通常不保留那种程度的细节,这种完美的往返是不可能的——只有语义结构被保留下来。

采用翻译层

Parser 的设计将解析 Ruby 代码与其结构表示分离开来。Builder 类提供了一个 API 来配置不同节点应如何表示,使得像 rubocop-ast(它为 RuboCop 提供支持)这样的工具可以将 Ruby 代码解析成它们自己的 AST 表示。

由于翻译层旨在作为 Parser 的即插即用替代品,在大多数情况下,你只需要使用 Prism::Translation::Parser::Builder 来代替 Parser::Builders::Default

1
2
3
4
5
6
7
8
BUILDER =
  if Gem::Version.new(RUBY_VERSION) < Gem::Version.new('3.4')
    require 'parser/current'
    Parser::Builders::Default.new
  else
    require 'prism'
    Prism::Translation::Parser::Builder.new
  end

如果你想配置 Builder 的行为,可以简单地动态定义一个子类:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
if Gem::Version.new(RUBY_VERSION) < Gem::Version.new('3.4')
  require 'parser/current'
  class Builder < Parser::Builders::Default
    modernize

    def initialize
      super

      self.emit_file_line_as_literals = false
    end
  end
else
  require 'prism'
  class Builder < Prism::Translation::Parser::Builder
    modernize

    def initialize
      super

      self.emit_file_line_as_literals = false
    end
  end
end

接下来,使用 Prism::Translation::ParserCurrentParser::CurrentRuby 定义一个解析器:

1
2
3
4
5
6
7
8
PARSER_CLASS =
  if Gem::Version.new(RUBY_VERSION) < Gem::Version.new('3.4')
    Parser::CurrentRuby
  else
    Prism::Translation::ParserCurrent
  end

parser = PARSER_CLASS.new(Builder.new)

这种方法确保与早于 3.3 的 Ruby 版本兼容,并避免在 Ruby 3.4 或更高版本上使用 Parser::CurrentRuby 时出现警告。

如果你的目标只是通过 Parser 翻译层解析 Ruby 代码,这个设置就足够了。然而,一些底层 API 在 Prism 和 Parser 之间并不兼容。如果你需要它们,就必须添加条件逻辑。

解析是有状态的

让我们看看这个简单的 Ruby 程序:

1
2
a = 0
puts a

乍一看,你可能会认为解析整个程序与独立解析每个表达式然后连接结果是一样的。但事实并非如此。当解析器遇到 a = 0 时,它会将 a 注册为一个局部变量。然后 puts a 正确地引用了这个局部变量,而不是调用一个方法 a(从技术上讲,这个方法可能在某处定义)。

解析 a = 0; puts a 产生:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
$ bin/prism parse -e 'a = 0; puts a'   
@ ProgramNode
├── locals: [:a]
└── statements:
    @ StatementsNode
    └── body: (length: 2)
        ├── @ LocalVariableWriteNode
        └── @ CallNode (location: (1,7)-(1,13))
            ├── message_loc: (1,7)-(1,11) = "puts"
            ├── arguments:
            │   @ ArgumentsNode
            │   └── arguments: (length: 1)
            │       └── @ LocalVariableReadNode

而独立地仅解析 puts a 会产生:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
$ bin/prism parse -e 'puts a'
@ ProgramNode
├── locals: []
└── statements:
    @ StatementsNode
    └── body: (length: 1)
        └── @ CallNode
            ├── name: :puts
            ├── arguments:
            │   @ ArgumentsNode
            │   └── arguments: (length: 1)
            │       └── @ CallNode
            │           ├── name: :a

注意区别:在第二个 AST 中,locals 是空的,所以 a 被视为方法调用(CallNode)。而在第一个中,它被正确地视为局部变量。

结论:解析是有状态的。 许多 Ruby 解析器在解析时维护一个静态环境(符号表),以便决定一个裸名称是局部变量还是方法调用。这就是为什么解析 a = 0; puts a(解析器已将 a 记录为局部变量)会得到与独立解析 puts a(其中 a 被解析为方法调用)不同的 AST 节点。

跨解析器处理局部变量

为了支持这一点,你可以实现特定于解析器的逻辑:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
PARSER_CLASS =
  if Gem::Version.new(RUBY_VERSION) < Gem::Version.new('3.4')
    Class.new(Parser::CurrentRuby) do
      def declare_local_variable(local_variable)
        static_env.declare(local_variable)
      end
    end
  else
    Class.new(Prism::Translation::Parser34) do
      def declare_local_variable(local_variable)
        (@local_variables ||= Set.new) << local_variable
      end

      def prism_options
        super.merge(scopes: [@local_variables.to_a])
      end
    end
  end

这是因为 Parser 使用一个 static_env 对象来跟踪局部变量,而 Prism 依赖一个 prism_options 哈希。这两种方法都允许工具在部分或增量解析期间一致地维护作用域信息。

你可以在原始的汇总拉取请求中看到完整的实现。

超越单元测试的测试

当你构建处理 AST 的工具时,仅仅依靠单元测试是不够的。你还需要针对大量的真实代码库进行测试。

这通常意味着两件事:

  1. 首先,针对像 Prism 的语料库这样的解析器测试套件进行测试,其中包含成千上万个旨在压力测试解析器的程序。这会持续发现手写测试可能遗漏的错误。
  2. 其次,根据真实世界的代码库进行验证。公开的 Ruby 仓库使你的工具暴露于生产编码模式,从而可能捕获精心策划的测试套件遗漏的问题。

为什么要在意呢?因为即使大多数开发者从不写这些边缘案例,当它们出现时,你的工具也需要能够处理它们。

总结

Prism 的 GitHub 仓库维护了一个已采用它的软件的不完整列表。除了多个 Ruby 实现外,这个列表还包括生态系统的重要工具,如 RuboCop、Rails(用于诸如 Action View 的模板依赖跟踪等功能)、Sorbet 和 Ruby LSP。

迁移 Unparser 的经验表明,这种过渡是可行的,尽管你需要谨慎处理 API 差异并进行彻底的测试。如果你维护一个 Ruby 解析工具,值得开始考虑你的迁移路径,无论是暂时使用翻译层,还是直接采用 Prism 的原生 AST。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计