irbの実行環境から定義されている関数のソースコードが見たいなぁ...と思いMethodクラスのドキュメントを眺めていたのですが、そのようなメソッドは提供されていませんでした。代わりではありませんがsource_location
たる関数が定義されているファイル名と定義された箇所の行数を配列で返すメソッドを発見しました。
上手くソースコードを出力できないかと試していた所、すでに実現しているgemを発見してしまいました。
実は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_source
をrequiere
するとMethodクラスにsource
とcomment
関数が追加され呼び出し可能になります。仕組みはシンプルで宣言したモジュールをファイル読み込み時に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_expression
のeach
内部から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クラスにsource
とcomment
関数が拡張されるsource
からsource_helper
を呼び出してファイル名と定義された箇所の行数を取得する- 取得したファイル名からファイルを読み込んで、先頭から定義された箇所までをスライスする
- スライスした値を順に処理していき、式が完全になるまで検査を続ける
method_source
の内部ではファイルからソースコードを読み込んで、該当箇所までを絞り込んでいました。
「便利なメソッドが組み込み関数にないんだったら、自分で作ればいいじゃない!」..という心意気を感じました。他にもProcやUnboundMethodに対しても呼び出しが可能なように処理が記載されていて抜かりないです。
少しでも「ええな〜」と思ったらはてなスター・はてなブックマーク・シェアを頂けると励みになります。