🌲

Ruby AST勘所

2022/11/27に公開

Rubocopのプラグイン(cop)などのためにユーザーがRuby ASTを触る機会がありますが、このRuby ASTには混乱をまねきかねない部分がいくつかあります。そこで本稿では混乱しやすい部分を簡潔にまとめました。

Ruby AST

AST (抽象構文木) は構文解析の結果を保持するためのデータ構造です。

ここではRubyにおける構文解析ライブラリのデファクトスタンダードであるparser gemのASTを扱います。

Ruby本体が提供するRipperやRubyVM::AbstractSyntaxTreeといったAPIもありますが、Ripperは字句単位の情報取得に特化したAPI設計になっておりそのままASTとして使えるようになっていません。RubyVM::AbstractSyntaxTreeはVMでの利用に向いた設計になっており、外部ツールから利用するのには必ずしも便利ではない面があります。

Ruby本体のAPIをラップしたsyntax_tree gemも有名になりつつありますが、今回はこちらのASTは扱いません。

パーサーフラグ

互換性フラグ

Parser gemのパーサーには互換性フラグが存在します。これらはできるだけ推奨設定側に倒すのがおすすめです。

Parser::Builders::Default.emit_arg_inside_procarg0 = true
Parser::Builders::Default.emit_encoding = true
Parser::Builders::Default.emit_forward_arg = true
Parser::Builders::Default.emit_index = true
Parser::Builders::Default.emit_kwargs = true
Parser::Builders::Default.emit_lambda = true
Parser::Builders::Default.emit_match_pattern = true
Parser::Builders::Default.emit_procarg0 = true

以下のコードで一括で推奨設定側に倒すこともできますが、その場合は将来新しい互換性フラグが追加されたときに挙動が意図せず変わってしまうリスクを負うことになります。

Parser::Builders::Default.modernize

変換抑制フラグ

__FILE__, __LINE__ はデフォルトでリテラルに展開されてしまいます。これを抑制するには Parser::Builders::Default のインスタンスにある emit_file_line_as_literals をオフにする必要があります。

Parser::Builders::Default のカスタムインスタンスを渡すのはちょっと大変なのですが、その方法については省略します。

ノード

ノードは以下の通りです。

class Parser::AST::Node
  # Symbol
  # ノードの種類。
  attr_reader :type
  # Array<Node | Numeric | String | Symbol | Boolean | nil>
  # ノードの引数。
  attr_reader :children
  # Parser::Source::Map | nil
  # ノードの位置情報。
  attr_reader :locaction
end

ノードをinspectで出力すると以下のような形式で出力されます。

irb(main):0:0> Parser::CurrentRuby.parse("1 + 1")
=>
s(:send,
  s(:int, 1), :+,
  s(:int, 1))

これはRubyの式になっていて、 AST::Sexp をincludeした状態でevalすれば元のASTを復元することができます。 (ロケーション情報を除く)

ノードを to_s で出力すると以下のような形式 (S式) で出力されます。

irb(main):0:0> puts Parser::CurrentRuby.parse("1 + 1")
(send
  (int 1) :+
  (int 1))
=> nil

脱糖

Parser gemは等価な式をなるべく同じtypeにまとめます。代表的なものとして以下があります。

  • send
    • メソッド呼び出しと等価なものはなるべくsendに統一される。
    • たとえば 1 + 11.+(1) というメソッド呼び出しと同じASTになる。
  • if
    • 条件式と等価なものはなるべくifに統一される。
    • たとえば if 式と後置の if と3項条件演算子 (x ? y : z) はすべて if になる。
    • また unless も順番を適宜入れ替えることで if に変換される。

ラッパーノード

逆に、見た目が同じでも特別な挙動をするものにはラップ用のノードが使われることがあります。

  • 静的な正規表現リテラルに対して /x/ =~ y のようなマッチ式を書いた場合 (名前つきキャプチャーのローカル変数化)
  • 条件が期待される位置で区間リテラル (2..3, 2...3) を書いた場合 (flip flop)
  • 条件が期待される位置で正規表現リテラルを書いた場合 (現在の行へのマッチ)
  • emit_index, emit_kwargs, emit_lambda, emit_match_pattern, emit_procarg0 も本来区別すべきだったノードが区別されない問題の修正のために導入されている。
  • 2項演算子のうち &&|| はshort-circuitの挙動を持つため、扱いが分けられている。

トリッキーな表現

ブロック

ブロックつきのメソッド呼び出しでは、ブロックは特殊な引数です。そのため、意味的にはメソッド呼び出しノードの一部としてブロックが定義されているほうが自然ですが、表記上はメソッドの外にブロックが存在しています。

Parser gemのASTでは見た目に合わせて、ブロックを外側に置いています。実際に解析をするときはメソッド呼び出しノードに到達してからブロックの情報を取得したくなるので、ちょっと不便です。

a.zip(b) do end
#^^^^^^^ send node
#^^^^^^^^^^^^^^ block node

rescue / ensure

rescueとensureは特殊な2項演算子のように扱われます。左右に見える begin-endは構文の優先順位を決めるための括弧にすぎず、rescue/ensureの一部ではないと考えます。

begin x; y; ensure; z; end
#^^^^                  ^^^ ただの括弧
#     ^^^^^ 左辺    ^^ 右辺
#     ^^^^^^^^^^^^^^^^ ensure式

begin

非常に紛らわしいのですが、以下のようになっています。

  • beginノード
    • ( - ) によって生じる文グループ
      • (1; 2) など
    • または、暗黙の文グルーピング
      • if x; y; z; endy; z の部分など
  • kwbeginノード
    • begin - end によって生じる文グループ
      • begin 1; 2; end など

暗黙の文グルーピング

複数の文が入りうる部分を1つのノードで表現したいときは、一般に以下のルールが適用されます。

  • 文が0個の場合
    • nil
  • 文が1個の場合
    • その文自身
  • 文が2個以上の場合
    • "begin" ノードでまとめる

一番わかりやすいのはトップレベルでの例です。

irb(main):1:0> Parser::CurrentRuby.parse("")
=> nil
irb(main):2:0> Parser::CurrentRUby.parse("1")
=> s(:int, 1)
irb(main):3:0> Parser::CurrentRUby.parse("1; 1")
=>
s(:begin,
  s(:int, 1),
  s(:int, 1))

左辺式

左辺式は、多値代入とそうでない場合で表現が異なります。

単値代入の場合

左辺の種類ごとに別の代入ノードになります。たとえば、

  • ローカル変数への代入は lvasgn ノード
  • インスタンス変数への代入は ivasgn ノード

多値代入の場合

「単値代入のノードから右辺式部分を削除したもの」を左辺式として使います。

たとえば x = 42(lvasgn :x (int 42)) なので、ここから右辺式部分を削除した (lvasgn :x) が左辺式になります。

位置

ノードには位置情報を含めることができます。位置情報は Parser::Source::Map のインスタンスです。

Parser::Source::Map は、式の種類に応じて複数の範囲情報を保持しています。必ず expression という名前の範囲は存在し、その式全体をあらわしています (ヒアドキュメントの場合のみ特殊)。

各ノードがどのような範囲情報を持っているかはdoc/AST_FORMAT.mdに詳しく書いてあります。

コメント

parse_with_comments を使うと、コメントの一覧も一緒に取得できます。コメントはコメントテキストと範囲情報からなります。

コメントとASTのノードとの関係は規定されていないため、コメントをノードに紐付ける必要があればそのためのヒューリスティックスを適切に実装する必要があります。

Discussion