お久しぶりです。NaCl松江本社のyharaです。みなさんはRubyのparser gemやast gemを使ったことがあるでしょうか?いずれも使い方次第で強力な武器になるライブラリですが、ネットにあまり情報がないかと思います。最近仕事で使う機会があったので、せっかくなので使い方を解説します。
ある日、開発中のRailsアプリに以下のような感じのコードを見つけました。
...
@data1[:attr1].zero? || @data2.header.code="123" || @data3.code == "456"
...
どこがおかしいか分かったでしょうか?
答えはここです。
@data1[:attr1].zero? || @data2.header.code="123" || @data3.code == "456"
^
||
でつなぐ式は真偽値であるべきですが、タイプミスで==
が=
になっていました。
さて、この手の間違いを修正するにはどうしたらよいでしょうか。もちろん、この箇所を直すのは=
を==
にするだけなので簡単です。でも、他にも同じミスをしている箇所がないか気になりますよね。テストで通る場所は大丈夫でしょうが、たまたまカバレッジが漏れている箇所でこのミスをしているかもしれません。
テキストファイルから何かを探すといえば、まずは正規表現でしょうか。でも今回探したいのは「||
or
&&
and
の左右に代入式があるところ」です。このような条件を正規表現で表すのは難しいですよね。
そこで登場するのがparser gemです。parserはRubyプログラムの構造を解析するためのgemで、RuboCopの内部でも使われています。
例えば puts "Hello, world!"
というプログラムをparserにかけるとこうなります。
require 'parser/current'
p Parser::CurrentRuby.parse('puts "Hello, world!"')
# 実行結果:
# s(:send, nil, :puts,
# s(:str, "Hello, world!"))
出力がちょっと独特ですが、レシーバなし(nil
)のputs
メソッド呼び出し(send
)で、引数は文字列リテラル(str
)が一つ、という風に読みます。
CurrentRubyというのは今実行しているバージョンのRuby、ということです。parser gemは過去のRubyの文法に合わせてパースすることもできるのでこのような名前になっています。
それでは、parserを使って条件式の中の代入式を探してみましょう。大きなコードに対して解析を行う場合、AST::Processor::Mixin
を使うのが便利です。これはparser gemの内部で使われている小さなライブラリast gemの機能です。parserの処理のうち、Ruby以外の言語の解析にも使える部分が独立したgemとして用意されているようです。
最初にコード全体を貼っておきます。
require 'parser/current'
class MyProcessor
include AST::Processor::Mixin
# `||`または`or`を見つけたときの処理
def on_or(node)
left, right = node.to_a
check_assignment(node, left)
check_assignment(node, right)
node
end
# `&&`または`and`を見つけたときの処理
def on_and(node)
left, right = node.to_a
check_assignment(node, left)
check_assignment(node, right)
node
end
# その他のノードの処理
def handler_missing(node)
node.children.each do |child|
process(child) if child.is_a?(AST::Node)
end
end
private
# `node`が代入式なら当該コードを表示し、終了する
def check_assignment(parent, node)
if assignment?(node)
p parent
exit
end
end
# `node`が代入式かを返す
def assignment?(node)
# ローカル変数代入(lvasgn)やインスタンス変数代入(ivasgn)ならtrue
return true if node.type.to_s.end_with?("asgn")
# `foo.bar = baz`のような形式の場合、`bar=`メソッドの呼び出しに
# なるので、これもチェックする
if node.type == :send
m = node.to_a[1]
m =~ /\w=/
else
false
end
end
end
ARGV.each do |path|
puts path
expr = Parser::CurrentRuby.parse(File.read(path))
MyProcessor.new.process(expr)
end
これを例えばcheck.rbというファイルに保存し、
$ ruby check.rb **/*.rb
のようにして使用します。
上から順に見ていきましょう。まず、AST::Processor::Mixin
をincludeしたクラスを作ります。クラス名に制約はないのでここではMyProcessor
としました。このincludeによって、MyProcessor.new.process(expr)
のようにしてRubyプログラムの処理を開始できるようになります。
on_or
は||
またはor
を見つけたときに呼ばれるメソッドです。この左辺と右辺に代入がないかを、下で定義しているcheck_assignmentメソッドでチェックしています。on_and
は&&
またはand
に対して同じことをしています。
さて、Rubyにはこれ以外にもメソッド呼び出しや配列リテラルなどさまざまな構文がありますよね。一覧はここにありますが、これら全部についてon_xxx
メソッドを書くのはさすがに大変です。
このような場合はhandler_missing
メソッドを定義しておくと、on_xxx
がなかったときに代わりに実行されるので、and/or以外のノードをまとめて処理することができます。ここでは子要素それぞれについてprocess
で中身を処理しています。
実際のチェックを行うのはcheck_assignment
およびassignment?
メソッドです。assignment?
は引数で渡されたノードが代入かどうかを判定します。Rubyの代入にはいろいろな種類があるので判定はすこし大変です。例をいくつか挙げてみましょう。
a = 1
@a = 1
a.b = 1
a[b] = 1
これらはそれぞれ、以下の条件で判定できます。
a = 1
: typeがlvasgn
@a = 1
: typeがivasgn
a.b = 1
: b=
メソッドの呼び出しa[b] = 1
: []=
メソッドの呼び出しまとめると、「typeが〜asgnである」か「=
で終わるメソッドの呼び出しである」のいずれかで判定できそうです。assignment?
メソッドはこの条件のいずれかにあてはまるとき真を返すようにしています。
check_assignment
メソッドはassignment?
メソッドを呼び、代入だったら該当する箇所をp parent
で出力してexit
で終了しています。
このままだと出力が例のs(:send, nil, :puts, s(:str, "Hello, world!"))
みたいな形式になるので、解読が少し難しいかもしれません。こんなときはunparser gemを使いましょう。
unparserはparserの逆を行うライブラリで、parserの解析結果からRubyプログラムを再構築することができます。厳密には細かい情報、例えば文字列リテラルのクオートが'
だったか"
だったか等はparserの段階で捨てられてしまうので復元できませんが、今回のように人間が見やすい形式にする用途なら十分です。
使い方は、require "unparser"
してから
p parent
としていた箇所を
puts Unparser.unparse(parent)
にするだけです。簡単ですね。
今回はparser gemの使用例を紹介しました。parser gemは出番は少ないですが、知っておくと何かの際に役に立つかもしれません。特にプログラムを読み込むだけでなく書き換える機能もあり(参考)、筆者は昔、Railsのテストの記法がバージョンアップで変わったときにparser gemで一括変換するスクリプトを書いた覚えがあります。