RubyのParser周りを俯瞰する〜2025.夏〜

この記事は何?

来たる 8/18(月) にKashiwa.rbの勉強会が開催されます。

Kashiwa.rb #14 ワイガヤグループワーク会 - connpass

自分は「Parser や AST を触ってみる」あたりに取り組むつもり。なのだけど、パッと Parser を動かして AST を確認できるものがいくつもあるみたいなので事前に整理をしておこうと思った。例えばで以下のものがある。

RubyVM::AbstractSyntaxTree#parse

$ ruby -e 'pp RubyVM::AbstractSyntaxTree.parse("x = 1 + 2")'
(SCOPE@1:0-1:9 tbl: [:x] args: nil body: (LASGN@1:0-1:9 :x (OPCALL@1:4-1:9 (INTEGER@1:4-1:5 1) :+ (LIST@1:8-1:9 (INTEGER@1:8-1:9 2) nil))))

ripper

$ ruby -rripper -e 'pp Ripper.sexp("2 + 2")'
[:program, [[:binary, [:@int, "2", [1, 0]], :+, [:@int, "2", [1, 4]]]]]

ruby_parser

$ ruby -rruby_parser -e 'pp RubyParser.new.parse "1+1"'
s(:call, s(:lit, 1), :+, s(:lit, 1))

parser

$ ruby -rparser/ruby34 -e 'pp Parser::Ruby34.parse("2 + 2")'
s(:send,
  s(:int, 2), :+,
  s(:int, 2))

rubocop-ast

$ ruby -rrubocop-ast -e 'pp RuboCop::AST::ProcessedSource.new("2 + 2", RUBY_VERSION.to_f).ast'
s(:send,
  s(:int, 2), :+,
  s(:int, 2))

Prism

$ ruby -rprism -e 'pp Prism.parse("2 + 2").value'
@ ProgramNode (location: (1,0)-(1,5))
├── flags: ∅
├── locals: []
└── statements:
    @ StatementsNode (location: (1,0)-(1,5))
    ├── flags: ∅
    └── body: (length: 1)
        └── @ CallNode (location: (1,0)-(1,5))
            ├── flags: newline
            ├── receiver:
            │   @ IntegerNode (location: (1,0)-(1,1))
            │   ├── flags: static_literal, decimal
            │   └── value: 2
            ├── call_operator_loc: ∅
            ├── name: :+
            ├── message_loc: (1,2)-(1,3) = "+"
            ├── opening_loc: ∅
            ├── arguments:
            │   @ ArgumentsNode (location: (1,4)-(1,5))
            │   ├── flags: ∅
            │   └── arguments: (length: 1)
            │       └── @ IntegerNode (location: (1,4)-(1,5))
            │           ├── flags: static_literal, decimal
            │           └── value: 2
            ├── closing_loc: ∅
            └── block: ∅

前提情報: この記事執筆時点のRuby最新バージョン

3.4.5

CRubyのParserの変遷

以下記事にまとまっていた。

techracho.bpsinc.jp

ざっくり書くと Yacc → Bison → Lrama + Prism という解釈を得た。

prismとparse.yそれぞれの出力を確認してみる

まとめ記事にコマンドのサンプルがあったので実行してみる。

$ ruby --parser=prism --dump=parsetree -e "2 + 2"
@ ProgramNode (location: (1,0)-(1,5))
+-- locals: []
+-- statements:
    @ StatementsNode (location: (1,0)-(1,5))
    +-- body: (length: 1)
        +-- @ CallNode (location: (1,0)-(1,5))
            +-- CallNodeFlags: nil
            +-- receiver:
            |   @ IntegerNode (location: (1,0)-(1,1))
            |   +-- IntegerBaseFlags: decimal
            |   +-- value: 2
            +-- call_operator_loc: nil
            +-- name: :+
            +-- message_loc: (1,2)-(1,3) = "+"
            +-- opening_loc: nil
            +-- arguments:
            |   @ ArgumentsNode (location: (1,4)-(1,5))
            |   +-- ArgumentsNodeFlags: nil
            |   +-- arguments: (length: 1)
            |       +-- @ IntegerNode (location: (1,4)-(1,5))
            |           +-- IntegerBaseFlags: decimal
            |           +-- value: 2
            +-- closing_loc: nil
            +-- block: nil
$ ruby --parser=parse.y --dump=parsetree -e "2 + 2"
###########################################################
## Do NOT use this node dump for any purpose other than  ##
## debug and research.  Compatibility is not guaranteed. ##
###########################################################

# @ NODE_SCOPE (id: 4, line: 1, location: (1,0)-(1,5))
# +- nd_tbl: (empty)
# +- nd_args:
# |   (null node)
# +- nd_body:
#     @ NODE_OPCALL (id: 3, line: 1, location: (1,0)-(1,5))*
#     +- nd_mid: :+
#     +- nd_recv:
#     |   @ NODE_INTEGER (id: 0, line: 1, location: (1,0)-(1,1))
#     |   +- val: 2
#     +- nd_args:
#         @ NODE_LIST (id: 2, line: 1, location: (1,4)-(1,5))
#         +- as.nd_alen: 1
#         +- nd_head:
#         |   @ NODE_INTEGER (id: 1, line: 1, location: (1,4)-(1,5))
#         |   +- val: 2
#         +- nd_next:
#             (null node)

Ripper

RubyVM::AbstractSyntaxTree#parse

ruby_parser, parser

Racc

Lrama

Prism

gihyo.jp

メジャーなツールが採用している Parser と現在の状況

RuboCop

Brakeman

Ruby LSP

Prism の translation layer

俯瞰してみて出てきた疑問・気になっていること

  • lramaとPrismがCRubyにおける標準的なParserという位置付けであることが分かったが実装が2つ存在する
    • 共存させている意図は何?
    • 今後それぞれはどうなっていくと予想される?
  • Prism の translation layer によって標準Parserに大統一することもできそうな気がするが現実世界はそんなにシンプルじゃなさそう
    • 既存 Parser の開発状況や新しい文法への対応はどうなる?いつまでされる?
    • その既存 Parser に依存したツール群はどうする? ripper, ruby_parser, parser の出力する AST の形式に依存したまま?
  • Ruby の Parser 初学者がここら辺を学ぶ場合の話
    • どういう順番に見ていくのが良さそう?
    • 追いかけるべきものは?逆に追いかけなくても良さそうなやつある?

あたりを勉強会で会話してみたいなってなりました。

そもそも自分がParserやAST周りに興味を持っている背景のメモ

  • Rubyの開発体験を改善・向上するようなものに興味関心がある
    • RubyLSP, RuboCop, Brakeman, Steep, RBS, etc...
  • これらツールの基盤として大体 Parser, AST があるイメージ
  • なので「Parser や AST がどうやって実現されているか」よりも「Parser や AST をどう活用できればより開発体験が向上できるか」みたいなことの方が気になっているのかもしれない
    • Parser が新しくなることでエンドユーザー目線で何が解決されるか、とかそういったこと
    • 「エラートレランス」というキーワードで説明される Prism の話は具体性と納得感があった

そういった観点でも勉強会で会話したいなってなりました。