🔻

Ruby パーサ「Prism」を触ってみる

2024/07/04に公開

こんにちは、 simomu です。今日は Ruby のパーサの一つである Prism を触ってみた話をします。

Prism について

https://github.com/ruby/prism
Prism は Ruby 3.3 から default gem として追加された Ruby のパーサです。gem として Ruby のコードをパースするのに利用できる他、Ruby 3.3 時点では実験的ではあるものの実際に Ruby のコードを実行する際のパーサに Prism をすることもできます。

$ ruby --parser=prism --dump=prism_parsetree -e 'puts "Hello World"'

ruby: warning: The compiler based on the Prism parser is currently experimental and compatibility with the compiler based on parse.y is not yet complete. Please report any issues you find on the `ruby/prism` issue tracker.
@ ProgramNode (location: (1,0)-(1,18))
├── locals: []
└── statements:
    @ StatementsNode (location: (1,0)-(1,18))
    └── body: (length: 1)
        └── @ CallNode (location: (1,0)-(1,18))
            ├── flags: ∅
            ├── receiver: ∅
            ├── call_operator_loc: ∅
            ├── name: :puts
            ├── message_loc: (1,0)-(1,4) = "puts"
            ├── opening_loc: ∅
            ├── arguments:
            │   @ ArgumentsNode (location: (1,5)-(1,18))
            │   ├── flags: ∅
            │   └── arguments: (length: 1)
            │       └── @ StringNode (location: (1,5)-(1,18))
            │           ├── flags: ∅
            │           ├── opening_loc: (1,5)-(1,6) = "\""
            │           ├── content_loc: (1,6)-(1,17) = "Hello World"
            │           ├── closing_loc: (1,17)-(1,18) = "\""
            │           └── unescaped: "Hello World"
            ├── closing_loc: ∅
            └── block: ∅

Ruby のパーサは元々パーサジェネレータの bison によって生成されたパーサを利用していましたが、 Ruby 3.3 からは bison を置き換える形で Lrama が Ruby に導入された一方で、この Prism も Ruby 本体に取り込まれたようです。

Prism はパーサジェネレータを利用していない手書きの再帰下降構文解析であり、Shopify のエンジニアの方をメインに開発が進んでいるそうです。

Prism を触ってみる

シンプルに Ruby コードを Prism に解析させる例です。

require 'prism'

code = <<~CODE
# This is comment
puts 'Hello, world!'
CODE

pp Prism.parse(code)
#<Prism::ParseResult:0x00000001029d4e70
 @comments=
  [#<Prism::InlineComment @location=#<Prism::Location @start_offset=0 @length=17 start_line=1>>],
 @data_loc=nil,
 @errors=[],
 @magic_comments=[],
 @source=
  #<Prism::ASCIISource:0x0000000102c32d48
   @offsets=[0, 18, 39],
   @source="# This is comment\n" + "puts 'Hello, world!'\n",
   @start_line=1>,
 @value=
  @ ProgramNode (location: (2,0)-(2,20))
  ├── locals: []
  └── statements:
      @ StatementsNode (location: (2,0)-(2,20))
      └── body: (length: 1)
          └── @ CallNode (location: (2,0)-(2,20))
              ├── flags: ignore_visibility
              ├── receiver: ∅
              ├── call_operator_loc: ∅
              ├── name: :puts
              ├── message_loc: (2,0)-(2,4) = "puts"
              ├── opening_loc: ∅
              ├── arguments:
              │   @ ArgumentsNode (location: (2,5)-(2,20))
              │   ├── flags: ∅
              │   └── arguments: (length: 1)
              │       └── @ StringNode (location: (2,5)-(2,20))
              │           ├── flags: ∅
              │           ├── opening_loc: (2,5)-(2,6) = "'"
              │           ├── content_loc: (2,6)-(2,19) = "Hello, world!"
              │           ├── closing_loc: (2,19)-(2,20) = "'"
              │           └── unescaped: "Hello, world!"
              ├── closing_loc: ∅
              └── block: ∅,
 @warnings=[]>

解析結果は ParseResult オブジェクトとして返ってきて、value に構文木が格納されています。また、コード内のコメントは別に comments に収められているため、ソースコード内のコメントだけを収集したいケースではこの comments を見るだけでよさそうです。

各 Node にはソースコード上の位置情報のほか、Node 固有の様々な情報が格納されています。

# 位置情報
Prism.parse('puts "Hello World!"') 
  .value.compact_child_nodes
  .first.compact_child_nodes
  .first.arguments
  .arguments.first.location
#=> (1,5)-(1,19)

# StringNode 固有の情報
Prism.parse('puts "Hello World!"') 
  .value.compact_child_nodes
  .first.compact_child_nodes
  .first.arguments
  .arguments.first.unescaped
#=> "Hello World!"

更に、Prism の特徴の1つにエラートレラントがあり、これは Ruby の文法として不完全な状態のコードをパースできるというものです。

例えばクラス名として不正な文字列が来た時には、以下のような ParseResult が返ってきます。エラー情報が errors に格納されている一方で、value には構文木そのものは構築されていて、本来クラス名が入る箇所が IntegerNode としてパースされていることが確認できます。

Prism.parse('class 1 end')
#<Prism::ParseResult:0x0000000129036090
 @comments=[],
 @data_loc=nil,
 @errors=
  [#<Prism::ParseError @type=:class_name @message="unexpected constant path after `class`; class/module name must be CONSTANT" @location=#<Prism::Location @start_offset=6 @length=1 start_line=1> @level=:syntax>,
   #<Prism::ParseError @type=:class_name @message="unexpected constant path after `class`; class/module name must be CONSTANT" @location=#<Prism::Location @start_offset=6 @length=1 start_line=1> @level=:syntax>],
 @magic_comments=[],
 @source=
  #<Prism::ASCIISource:0x000000012949ffc8
   @offsets=[0],
   @source="class 1 end",
   @start_line=1>,
 @value=
  @ ProgramNode (location: (1,0)-(1,11))
  ├── locals: []
  └── statements:
      @ StatementsNode (location: (1,0)-(1,11))
      └── body: (length: 1)
          └── @ ClassNode (location: (1,0)-(1,11))
              ├── locals: []
              ├── class_keyword_loc: (1,0)-(1,5) = "class"
              ├── constant_path:
              │   @ IntegerNode (location: (1,6)-(1,7))
              │   ├── flags: decimal
              │   └── value: 1
              ├── inheritance_operator_loc: ∅
              ├── superclass: ∅
              ├── body: ∅
              ├── end_keyword_loc: (1,8)-(1,11) = "end"
              └── name: :"1",
 @warnings=[]>

また、end) が足りないようなコードをパースした場合では、以下のような ParseResult が返却され、足りない node が MissingNode として扱われます。

Prism.parse('(1')
#<Prism::ParseResult:0x00000001285391b0
 @comments=[],
 @data_loc=nil,
 @errors=
  [#<Prism::ParseError @type=:unexpected_token_close_context @message="unexpected end-of-input, assuming it is closing the parent top level context" @location=#<Prism::Location @start_offset=2 @length=0 start_line=1> @level=:syntax>,
   #<Prism::ParseError @type=:expect_rparen @message="expected a matching `)`" @location=#<Prism::Location @start_offset=2 @length=0 start_line=1> @level=:syntax>],
 @magic_comments=[],
 @source=
  #<Prism::ASCIISource:0x000000012a0d3a60 @offsets=[0], @source="(1", @start_line=1>,
 @value=
  @ ProgramNode (location: (1,0)-(1,2))
  ├── locals: []
  └── statements:
      @ StatementsNode (location: (1,0)-(1,2))
      └── body: (length: 1)
          └── @ ParenthesesNode (location: (1,0)-(1,2))
              ├── body:
              │   @ StatementsNode (location: (1,1)-(1,2))
              │   └── body: (length: 2)
              │       ├── @ IntegerNode (location: (1,1)-(1,2))
              │       │   ├── flags: decimal
              │       │   └── value: 1
              │       └── @ MissingNode (location: (1,1)-(1,2))
              ├── opening_loc: (1,0)-(1,1) = "("
              └── closing_loc: (1,2)-(1,2) = "",
 @warnings=
  [#<Prism::ParseWarning @type=:void_statement @message="possibly useless use of a literal in void context" @location=#<Prism::Location @start_offset=1 @length=1 start_line=1> @level=:verbose>]>

これらは Prism が LSP 等でも利用されることを想定しているため、エディタで編集中のコードなどの不完全なコードでも部分的にパースできる必要があるためとのことです。

他にも、構文木は返さずに、Ruby の文法として正しいかどうかだけを判断してくれるメソッドが用意されていたり、

Prism.parse_success?('1 + 2')
#=> true
Prism.parse_success?('1 + ')
#=> false

構文木を構築せずにトーカナイズだけを行う事もできます。

Prism.lex('a if true').value
=>
[[IDENTIFIER(1,0)-(1,1)("a"), 32],
 [KEYWORD_IF_MODIFIER(1,2)-(1,4)("if"), 1025],
 [KEYWORD_TRUE(1,5)-(1,9)("true"), 2],
 [EOF(1,9)-(1,9)(""), 2]]

Prism.lex('if true then a end').value
=>
[[KEYWORD_IF(1,0)-(1,2)("if"), 1],
 [KEYWORD_TRUE(1,3)-(1,7)("true"), 2],
 [KEYWORD_THEN(1,8)-(1,12)("then"), 1],
 [IDENTIFIER(1,13)-(1,14)("a"), 32],
 [KEYWORD_END(1,15)-(1,18)("end"), 2],
 [EOF(1,18)-(1,18)(""), 2]]

上記のトーカナイズのサンプルを見るだけでも、if キーワードも使われ方によって KEYWORD_IFKEYWORD_IF_MODIFIER の別々のトークンがあることがわかるなど、Ruby のパースの難しさが垣間見えそうですね。

構文木をたどる

先のサンプルで書いたように、各 Node の compact_child_nodes に子 Node が格納されているため、 Node の情報をたどっていく場合は compact_child_nodes を再帰的にたどっていくことで実現できます。

ただ、Prism には構文木を辿っていった上で特定の Node に対する処理を行いたいときに利用できるクラス Prism::Visitor が用意されています。その名の通り Visitor パターンになっていて、 Prism::Node#accept に Visitor を渡すと構文木をたどっていき、到達した Node に対応する Visitor#visit_*_node を呼び出します。Prism::Visitor を継承し visit_*_node メソッドを定義することで一部の Node に対しての処理を記述することができます。

以下は、ソースコード内の Symbol リテラルを発見するたびに、その Symbol の名前を出力するサンプルです。

require 'prism'

class MyVisitor < Prism::Visitor
  def visit_symbol_node(node)
    puts "Find symbol: #{node.unescaped}"
    super(node)
  end
end

code = <<~CODE
puts :hello
puts :world
puts 'Hello'
puts 'World'
CODE

Prism.parse(code).value.accept(MyVisitor.new)
#=> Find symbol: hello
#=> Find symbol: world

Prism の GitHub リポジトリにも Visitor のサンプルがいくつか存在します。
https://github.com/ruby/prism/tree/main/sample

別のパーサ gem の構文木に変換する

Prism の機能として、他のパーサ gem、例えば whitequark/parser が出力する構文木に変換することも可能です。

Prism::Translation::Parser.parse('1+2')
=>
s(:send,
  s(:int, 1), :+,
  s(:int, 2))

これによって、今まで parser gem 等を利用して構文木を取得していたツールやライブラリが、
扱う構文木のオブジェクトはそのままで構文解析そのものを Prism に乗り換えるということも可能になりそうです。

既に RuboCop ではこの機能を利用して、まだ実験的なもののようですが RuboCop による Ruby コードの構文解析を Prism で行うオプションが追加されています。

https://github.com/rubocop/rubocop/pull/12724

rubocop で prism を利用する場合は、 gem 'prism' を Gemfile に追記したうえで、.rubocop.yaml に以下の設定を追加することで動くようで、従来の parser gem よりも構文解析が高速になる傾向にあるようです。

AllCops:
  ParserEngine: parser_prism
  TargetRubyVersion: 3.3

まとめ

今回は Ruby のパーサの Prism を触ってみました。
Ruby コードをパースして構文木を取得する必要のあるライブラリやツールを作りたくなったときに活用してみようと思います。

参考

RDoc Documentation | Prism Ruby parser
Prism:エラートレラントな、まったく新しいRubyパーサ | gihyo.jp

SocialPLUS Tech Blog

Discussion