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

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

## 忙しい人向けの紹介

定数の名前書き換えloadするimport_as gemの紹介[^57577]です。

https://github.com/hanachin/import_as

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

[^57577]: 五七五七七

```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`で渡されたブロックを取っておきます。ブロックを使うとメソッド呼び出しや存在しない定数への参照の評価を遅延できます。なので定義していないメソッド呼び出し(`LordVoldemort`や`as`)、未定義定数`YouKnowWho`の参照を`import { LordVoldemort as YouKnowWho }`のように自由に書けます。evalしない・できない・しなくていい、エバらないDSLを定義するときに使えます。

```rb
module ImportAs
  class DSL
    def initialize(&block)
      @as = block
    end
  end

  def import(&block)
    DSL.new(&block)
  end
end
```

`DSL#from`にファイルのパスをわたします。現在は絶対パスにしか対応していません。
`from`ではわたされたパスからソースコードを読み、書き換え、新しいソースコードに書き込み、新しいソースコードを`load`します。

```rb
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](https://docs.ruby-lang.org/ja/latest/class/Ripper=3a=3aFilter.html)を利用して定数を書き換えます。`new`に書き換える定数の対応表をわたします。わたしている`const_hash`の実装は後述します。

```rb
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で生やしたメソッドの中から本来生えてるメソッドをレシーバーなしで呼べてべんりです。

```rb
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](https://docs.ruby-lang.org/ja/latest/class/Ripper=3a=3aFilter.html)を使うとべんり

`RubyVM::AbstractSyntaxTree`でエバらないDSL書いていきましょう :muscle: