parser gemでRubyプログラムのバグを探す

Posted by Yutaka Hara on July 02, 2021 · 4 mins read

お久しぶりです。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 gemでバグを探す

それでは、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

のようにして使用します。

MyProcessorクラス

上から順に見ていきましょう。まず、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で終了しています。

おまけ:unparserを使う

このままだと出力が例の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で一括変換するスクリプトを書いた覚えがあります。