LoginSignup
9
7

More than 5 years have passed since last update.

定数の名前書き換えloadするimport_as gemの紹介

Last updated at Posted at 2018-12-11

この記事はOkinawa.rb Advent Calendar 2018の11日目の記事です。
昨日は @kou-sy さんの「プロを目指す人のためのRuby入門」を6週間かけて写経して学んだことでした。
明日は @hanachin_ さんのTurnipで書いたfeatureにパスに応じて異なるtypeをつける方法です。

忙しい人向けの紹介

定数の名前書き換えloadするimport_as gemの紹介1です。

以下のような名前を呼んではいけないクラスが定義されたファイルがあります。

lord_voldemort.rb
class LordVoldemort
  def name
    "Voldemort"
  end
end

import_as gemを使うと名前を呼んではいけないクラスを"あの人"と呼ぶことが出来ます。

main.rb
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で渡されたブロックを取っておきます。ブロックを使うとメソッド呼び出しや存在しない定数への参照の評価を遅延できます。なので定義していないメソッド呼び出し(LordVoldemortas)、未定義定数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書いていきましょう :muscle:


  1. 五七五七七 

9
7
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
7