この記事はOkinawa.rb Advent Calendar 2018の11日目の記事です。
昨日は @kou-sy さんの「プロを目指す人のためのRuby入門」を6週間かけて写経して学んだことでした。
明日は @hanachin_ さんのTurnipで書いたfeatureにパスに応じて異なるtypeをつける方法です。
忙しい人向けの紹介
定数の名前書き換えloadするimport_as gemの紹介1です。
以下のような名前を呼んではいけないクラスが定義されたファイルがあります。
class LordVoldemort
def name
"Voldemort"
end
end
import_as gemを使うと名前を呼んではいけないクラスを"あの人"と呼ぶことが出来ます。
require "import_as/core_ext"
import { LordVoldemort as YouKnowWho }.from File.expand_path("./lord_voldemort.rb", __dir__)
puts YouKnowWho.new.name
# Voldemort
puts LordVoldemort.new.name
# Traceback (most recent call last):
# main.rb:8:in `<main>': uninitialized constant LordVoldemort (NameError)
べんりですね。
実装方法
import
で渡されたブロックを取っておきます。ブロックを使うとメソッド呼び出しや存在しない定数への参照の評価を遅延できます。なので定義していないメソッド呼び出し(LordVoldemort
やas
)、未定義定数YouKnowWho
の参照をimport { LordVoldemort as YouKnowWho }
のように自由に書けます。evalしない・できない・しなくていい、エバらないDSLを定義するときに使えます。
module ImportAs
class DSL
def initialize(&block)
@as = block
end
end
def import(&block)
DSL.new(&block)
end
end
DSL#from
にファイルのパスをわたします。現在は絶対パスにしか対応していません。
from
ではわたされたパスからソースコードを読み、書き換え、新しいソースコードに書き込み、新しいソースコードをload
します。
module ImportAs
class DSL
def from(path)
Tempfile.open(["import_as", ".rb"]) do |tf|
new_source = rewrite(File.read(path))
tf.write(new_source)
tf.flush
tf.close
load tf.path
end
end
end
end
rewrite
ではRipper::Filterを利用して定数を書き換えます。new
に書き換える定数の対応表をわたします。わたしているconst_hash
の実装は後述します。
module ImportAs
class ConstRewriter
def initialize(const_hash)
@rewriter = Class.new(Ripper::Filter) {
private
define_method(:on_const) do |t, f|
f << const_hash.fetch(t.to_sym, t).to_s
end
def on_default(_, t, f)
f << t
end
}
end
def rewrite(ruby)
@rewriter.new(ruby).parse('')
end
end
class DSL
private
def rewrite(rb)
ConstRewriter.new(const_hash).rewrite(rb)
end
end
end
const_hash
では、Ruby 2.6で導入されたRubyVM::AbstractSyntaxTree.of
を使ってimport
に渡されたブロックを解析し、定数書き換えの対応表を作成します。
このように複雑なデータ構造を扱うとき、Refinementsを使ってヘルパーメソッドを直接対象のデータ構造にメソッドを生やしてあげると、引数で引き回したりせずに済みます。また、Refinementsで生やしたメソッドの中から本来生えてるメソッドをレシーバーなしで呼べてべんりです。
module ImportAs
class DSL
private
using Module.new {
refine(Symbol) do
def const_id?
id = self
Module.new.module_eval { const_set(id, true) rescue false }
end
end
refine(RubyVM::AbstractSyntaxTree::Node) do
def array?
type == "NODE_ARRAY"
end
def const?
type == "NODE_CONST"
end
def fcall?
type == "NODE_FCALL"
end
def scope?
type == "NODE_SCOPE"
end
def const_pair
original_const_id, args = children
raise Error unless original_const_id.const_id?
raise Error unless args.array?
head, *rest = args.children
raise Error unless rest.size == 1 && rest[0].nil?
raise Error unless head.fcall?
as, args2 = head.children
raise Error unless as == :as
head2, *rest2 = args2.children
raise Error unless rest.size == 1 && rest[0].nil?
raise Error unless head2.const?
raise Error unless head2.children.size == 1
new_const_id = head2.children[0]
raise Error unless new_const_id.const_id?
[original_const_id, new_const_id]
end
end
}
def const_hash
root = RubyVM::AbstractSyntaxTree.of(@as)
raise Error unless root.scope?
_tbl, _args, body = root.children
raise Error unless body.fcall?
[body.const_pair].to_h
end
end
end
まとめ
以下のような事柄が学べました。
- ブロックを使うと評価を遅延できるのでRubyの文法として正しい文法であればなんでも書ける
- Ruby 2.6では
RubyVM::AbstractSyntaxTree.of
でブロックやProcの抽象構文木が取れる -
RubyVM::AbstractSyntaxTree
とブロックを組み合わせて使うとエバらない(evalしない・できない)DSLを実装できる - 抽象構文木の解析をするときは
RubyVM::AbstractSyntaxTree::Node
にRefinementsでメソッドを生やすとべんり - 単純にソースコードの特定の字句を置換するならRipper::Filterを使うとべんり
RubyVM::AbstractSyntaxTree
でエバらないDSL書いていきましょう
-
五七五七七 ↩