読者です 読者をやめる 読者になる 読者になる

RuboCopのASTの書き方の例

先日 RuboCop に PR を出していたコード中の AST について少し説明を書いてみる。

実例に基づいた思考過程を辿る形で記載してみるので参考になればと思う。

github.com

対象のコードは以下。

def_node_matcher :match_threequals?, <<-PATTERN
  (send (regexp (str _) {(regopt) (regopt _)}) :=== !nil)
PATTERN

/re/ === "str" といった構文を表す AST となる (send (regexp (str _) {(regopt) (regopt _)}) :=== !nil) に焦点を絞る。

前提知識と PR を出した際の3つのステップという流れで話を進めて行く。

前提知識: RuboCop のパーサー

RuboCop での AST の解析には Parser という Gem が実装として使われているので、実装の際には必ずお世話になると思う。

/re/ === "str" の AST を知るには以下のようなコードの結果を元にする。

require 'parser/current'

Parser::CurrentRuby.parse('/re/ === "str"')

実行すると S 式で AST の結果を取得できる。

s(:send,
  s(:regexp,
    s(:str, "re"),
    s(:regopt)), :===,
  s(:str, "str"))

ステップ1: 似たコード (構文) を元にする

拡張元のコードとして近いものは以下。=~=== にするのが最初。このコードはとても分かりやすくて右辺と左辺に何らかのオブジェクトがくるように !nil としている。

def_node_matcher :match_operator?, <<-PATTERN
  (send !nil :=~ !nil)
PATTERN

テストコードの方については PR を参考にしてもらうとして、初手は :=~:=== に置き換える以下のような実装で済ました (もちろん Baby steps の開発途中のコードなので master コードの candidate となる PR 上のコミットには入っていない) 。

def_node_matcher :match_threequals?, <<-PATTERN
-  (send !nil :=~ !nil)
+  (send !nil :=== !nil)
PATTERN

ステップ2: ある特定のクラスのインスタンスであることを指定する

ステップ1の内容で一見で良さそうであるものの、レシーバーが Regexpインスタンスである必要があるため、次のようにレシーバーのインスタンスに縛りを入れた。

def_node_matcher :match_threequals?, <<-PATTERN
-  (send !nil :=== !nil)
+  (send (regexp (str _) (regopt)) :=== !nil)
PATTERN

前提知識に記していた /re/ === "str" に対する S 式を再掲する。

s(:send,
  s(:regexp,
    s(:str, "re"),
    s(:regopt)), :===,
  s(:str, "str"))

Parser の出力結果を使った具体的な値が含まれた S 式を、抽象構文木として抽象化した際のポイントは3つ。

  1. s(:str, "re")"re" の部分は任意の文字列であってもらいたいため (str _) というように _ に置き換えている
  2. s(:str, "str") は何らかのオブジェクトを受け取るという緩い形にしているため !nil に置き換えている
  3. (:regopt) を忘れずに記述する。ここについては後述する

次のステップが PR 状態の AST に組み上げる仕上げ。

ステップ3: 正規表現オプションのありなしを指定する

/re/i といった正規表現オプションにもマッチするようにする。これが上述の3つめのポイント。

/re/i === "str" の AST を知るには以下のようなコードの結果を元にする。

require 'parser/current'

Parser::CurrentRuby.parse('/re/i === "str"')

ここでもう一度 AST の結果を S 式で取得してみよう。分かりやすく正規表現オプションがない版との差分にしておく。

s(:send,
  s(:regexp,
    s(:str, "re"),
-    s(:regopt)), :===,
+    s(:regopt, :i)), :===,
  s(:str, "str"))

正規表現オプションのないケースである (regopt) と、任意の正規表現オプションのある (regopt _) のいずれかにマッチするよう { ... } でそれぞれを囲って仕上がり。

def_node_matcher :match_threequals?, <<-PATTERN
-  (send (regexp (str _) (regopt)) :=== !nil)
+  (send (regexp (str _) {(regopt) (regopt _)}) :=== !nil)
PATTERN

こんな流れでテストコードをパスすることを確認して PR へと進んでいた。