やわらかテック

興味のあること。業務を通して得られた発見。個人的に試してみたことをアウトプットしています🍵

【ええな〜コード】irbから定義された関数のソースコードを表示する

irbの実行環境から定義されている関数のソースコードが見たいなぁ...と思いMethodクラスのドキュメントを眺めていたのですが、そのようなメソッドは提供されていませんでした。代わりではありませんがsource_locationたる関数が定義されているファイル名と定義された箇所の行数を配列で返すメソッドを発見しました。
上手くソースコードを出力できないかと試していた所、すでに実現しているgemを発見してしまいました。

github.com

実はpryでソースコードを表示するために、このmethod_sourceが使われているそうです。
初版のリリースが13年前ですが、保守されており現行のRubyでも問題なく使用することができました。

require 'method_source'

class Foo
  def bar
    puts 'bar'
  end
end

Foo.new.method(:bar).source.display
# def bar
#   puts 'bar'
# end

Methodクラスにはソースコードを表示するメソッドは提供されていないわけですが、一体、どのようにしてソースコードを表示させているのでしょうか。気になったので処理を追いつつ、マイ・ソースコード表示関数を作ってみたいと思います。

完成したものがこちら

先にmethod_sourceのコードを読んでかなり簡易的に定義した関数を貼っておきます。

def source_display(method)
  def complete_expression?(str)
    catch(:valid) do
      eval("BEGIN{throw :valid}\n#{str}")
    end

    str !~ /[,\\]\s*\z/
  rescue Exception
    false
  end

  file, line = method.source_location
  lines = (File.readlines(file) || [])[(line -1)..-1]
  code = ""

  lines.each do |v|
    code << v
    break if complete_expression?(code)
  end

  puts code
end

こんな感じで使えます。

class Foo
  def bar
    puts 'bar'
  end

  def self.hoge
    puts 'hoge'
  end
end


ins_method = Foo.new.method(:bar)
source_display(ins_method)
# def bar                        
#   puts 'bar'                   
# end 

class_method = Foo.method(:hoge)
source_display(class_method)
# def self.hoge
#   puts 'hoge'
# end

method_sourceの仕組み

どのようにしてmethod_sourceではソースコードを取得しているのかを簡単に追っていきます。
method_sourcerequiereするとMethodクラスにsourcecomment関数が追加され呼び出し可能になります。仕組みはシンプルで宣言したモジュールをファイル読み込み時にMethodクラスにincludeしているからです。

module MethodSource
  module MethodExtensions
    def source
      MethodSource.source_helper(source_location, defined?(name) ? name : inspect)
    end

    def comment
      MethodSource.comment_helper(source_location, defined?(name) ? name : inspect)
    end
  end
end

class Method
  include MethodSource::SourceLocation::MethodExtensions
  include MethodSource::MethodExtensions
end

method_source/lib/method_source.rb at master · banister/method_source · GitHub

そしてsource関数が呼び出されるとsource_helperが呼び出されます。

source_helper

source_helperではMethodクラスに定義されたsource_locationからファイル名と定義された箇所の行数を取得しています。 そして取得したファイル名をlines_forに渡してファイルの中身を文字列の配列として読み込みます。

def self.source_helper(source_location, name=nil)
  raise SourceNotFoundError, "Could not locate source for #{name}!" unless source_location
  file, line = *source_location

  expression_at(lines_for(file), line)
rescue SyntaxError => e
  raise SourceNotFoundError, "Could not parse source for #{name}: #{e.message}"
end
def self.lines_for(file_name, name=nil)
  ...File.readlines(file_name)
end

$ (ruby) lines_for(file)
["require \"method_source\"\n",
 "\n",                 
 "class Foo\n",        
 "  def bar\n",        
 "    puts 'bar'\n",
 "  end\n",
 "\n",
 "  def self.hoge\n",
 "    puts 'hoge'\n",
 "  end\n",
 "end\n",
 "\n",
 "binding.irb\n",
 "Foo.new.method(:bar).source\n"]

method_source/lib/method_source.rb at master · banister/method_source · GitHub

expression_at

この時点でファイル中の全行の読み込みが完了しました。
後は読み込んだファイル中の全行から対象の箇所のみを抽出する処理が残っていそうです。
先ほどのsource_helperではlines_forで読み込んだファイルをexpression_atに渡しています。expression_atでは先頭から定義された行数までをスライスした値をextract_first_expressionへさらに受け渡しています。

def expression_at(file, line_number, options={})
  :
  relevant_lines = lines[(line_number - 1)..-1] || []

  extract_first_expression(relevant_lines, options[:consume])
end

def extract_first_expression(lines, consume=0, &block)
  code = consume.zero? ? "" : lines.slice!(0..(consume - 1)).join

  lines.each do |v|
    code << v
    return code if complete_expression?(block ? block.call(code) : code)
  end
  raise SyntaxError, "unexpected $end"
end

ここからが非常に面白いです。
extract_first_expressionでは式が完全になるまで(式の終わり)を検査します。例えば関数の定義であれば、def xxxxからendまでが1つの式になります。一体、どのようにして式が完全かを判定しているのでしょうか。

evalだと...

なんとevalで文字列をRubyプログラムとして評価して成功の可否をもって、式が完全であるかを判定していました。 evalを使うことで正規表現を使ったパーサーなどを自前で作らなくて済みます。非常に上手く作られているなぁと感服しました...。

def complete_expression?(str)
  :
  catch(:valid) do
    eval("BEGIN{throw :valid}\n#{str}")
  end

  str !~ /[,\\]\s*\z/
rescue IncompleteExpression
  false
:
end

method_source/lib/method_source/code_helpers.rb at master · banister/method_source · GitHub

extract_first_expressioneach内部からcomplete_expression?が呼び出されると結果は以下のように変化していきます。

  • def bar\n: 例外発生(false)
  • def bar\n puts 'bar'\n: 例外発生(false)
  • def bar\n puts 'bar'\n end\n: 例外発生せずstr !~ /[,\\]\s*\z/がtrueとなる
"def bar\n    puts 'bar'\n  end\n"

上手く関数定義の終わりの範囲までが取得できました。後は好きな形式で出力すれば良いだけです。
今回は簡単のため省略しましたがevalの例外処理には独自に定義したモジュールを使用していました。このモジュールではevalが失敗したさまざまなケースを正規表現でパターンマッチしています。
全く知らなかったのですがdef self.===(ex)を定義すればモジュール内部で発生した例外クラスを処理することができるんですね。

method_source/lib/method_source/code_helpers.rb at master · banister/method_source · GitHub

まとめ

  • method_sourceを読み込むとMethodクラスにsourcecomment関数が拡張される
  • sourceからsource_helperを呼び出してファイル名と定義された箇所の行数を取得する
  • 取得したファイル名からファイルを読み込んで、先頭から定義された箇所までをスライスする
  • スライスした値を順に処理していき、式が完全になるまで検査を続ける

method_sourceの内部ではファイルからソースコードを読み込んで、該当箇所までを絞り込んでいました。
「便利なメソッドが組み込み関数にないんだったら、自分で作ればいいじゃない!」..という心意気を感じました。他にもProcやUnboundMethodに対しても呼び出しが可能なように処理が記載されていて抜かりないです。

少しでも「ええな〜」と思ったらはてなスター・はてなブックマーク・シェアを頂けると励みになります。