で、Crystal で Ruby のコードってどんだけそのまま動くの？

最近話題の Ruby ライクなプログラミング言語 [**Crystal**](http://crystal-lang.org) について。  
「Ruby コードが変更なく動く」という衝撃的な発言や、「静的型付けの Rails 嬉しい」といった気の早すぎるように思える発言なども目にして、「じゃあ実際どのくらい Ruby コードがそのまま動くの？」ということを知りたく、「なんかの Gem を Ruby コードのコピペで実装してみよう！」と思いました。  
とりあえず空いてる時間が5時間くらいあったので、その範囲という制限でやります。文章も進めながらだらだらと書いていったものそのままで整理していないので超読み辛いかもしれません。

## お題の Gem

Crystal でコピペ実装する Gem は [**dotenv**](https://github.com/bkeepers/dotenv) です。  
これを選択したのは、

- 他の Gem への依存がない
- そんなに大きくない
- コードリーディングで使われたりしてたので Ruby っぽいコードだろう
- 自分がまったく使ったこともないしコードもはじめて見る

といった理由によります。

## 進め方

以下を基本原則として進めます。

1. 進めやすそうなところから実装していく
2. 元のコードを読み解き論理的に実装するのではなく、コピペを起点に場当り的に進める
3. テストで単体の動作を確認しながら進める
4. **dotenv-rails** など Rails 絡みのところは無視

## TL;DR

- 途中で心が折れて時間切れ
- Ruby コードがそのまま動くというのは幻想

本当にこんな結果になってしまい恐縮です。もちろん、Crystal はとても素晴しく今後が超楽しみです。ただ、Ruby にとてもよく似たシンタックスとはいえ、Crystal は Crystal としてちゃんと言語仕様やライブラリの API 実装を理解しなければいけません、という。

## environment.cr

さて、それではコピペ実装をはじめます。  
まずは、パッと見で短くて簡単そうな [`environment.rb`](https://github.com/bkeepers/dotenv/blob/release-2.0.2/lib/dotenv/environment.rb) からコピペしてみることにする。  
**src/dotenv/environment.cr**

```ruby
module Dotenv
  # This class inherits from Hash and represents the environemnt into which
  # Dotenv will load key value pairs from a file.
    attr_reader :filename

    def initialize(filename)
      @filename = filename
      load
    end

    def load
      update Parser.call(read)
    end

    def read
      File.read(@filename)
    end

    def apply
      each { |k, v| ENV[k] ||= v }
    end

    def apply!
      each { |k, v| ENV[k] = v }
    end
  end
end
```
  
まんまコピー&ペーストしたら、とにかく `environment.cr` を直接指定してビルドをかけてみることにした。

```text
$ crystal build src/dotenv/environment.cr 
Error in ./src/dotenv/environment.cr:4: wrong number of type vars for Hash(K, V) (0 for 2)

  class Environment < Hash
                      ^~~~
```

畜生、こんな初っ端からコンパイルエラーかよ…これは大変な道程になりそうな気配じゃないか…  
気を取り直してエラーメッセージを見てみる。「ほうほう、ほう。」見てもよくわからなかったので、継承元の `Hash` のコードを見てみる。  
**src/hash.cr**

```ruby
class Hash(K, V)
...
end
```

あー早速何か出てきたな！ジェネリクスか！`Hash` はコレクションクラスだから型がジェネリクスで指定されているってことだろう、きっと。とりあえず `environment.cr` の継承元の `Hash` を `Hash(K, V)` に変更した。継承先のクラスにも指定する必要があるかな…？とりあえず指定しとこう。

```ruby
class Environment(K, V) < Hash(K, V)
```

そのつもりで見ると確かにエラーメッセージもそのまんま指摘されてるように見える。  
さて、では改めてビルドしてみよう。

```text
$ crystal build src/dotenv/environment.cr
Error in ./src/dotenv/environment.cr:5: undefined method 'attr_reader'

    attr_reader :filename
    ^~~~~~~~~~~
```

`attr_reader` が無いよ、と。代わりになるものは何かあるかな？と探してみると、どうやら Crystal の作者は `attr_reader` とかが好みじゃないことがまずわかった。うん、素晴しい情報だ。これ以上無駄に存在しないものを探さずに済む…

> On the other hand, I never really liked attr_accessor, attr_reader and attr_writer. In every other language they are called property, getter and setter.

https://github.com/manastech/crystal/issues/651  
ドキュメント眺めると `getter` というとてもわかりやすいものが存在していたので、それを定義して進むことにする。

```ruby
class Environment(K, V) < Hash(K, V)
  getter filename
```

そしてビルドしてみると…おお、ビルドが通った！


## environment_spec.cr

それじゃあ、ビルドも通ったので、`environment.cr` の実装に対して、オリジナルと同じテストを試してみて動作確認をする。  
テストもまずは乱暴にそのまま [これ](https://github.com/bkeepers/dotenv/blob/release-2.0.2/spec/dotenv/environment_spec.rb) を `environment_spec.cr` にコピペして実行してみる。

```text
$ crystal spec
Error in ./spec/dotenv_spec.cr:3: instantiating 'describe(Dotenv:Class)'
... 大量の行 ...
Error: instance variable '@buckets_length' of Hash(Symbol, Char) was not initialized in all of the 'initialize' methods, rendering it nilable
```

うーわー、やっぱりというか何というかすごいエラーになっちゃったな。まるっとコピペは乱暴すぎたか。あれ？でも何かこのエラーは、メッセージ見た感じ、テストが問題ってわけでもないっぽい…？`initialize` で `Hash` が初期化されてないって言われてる。

```ruby
    def initialize(filename)
      @filename = filename
      load
    end
```

`initialize` はここでオーバーライドされてる。まだコードをちゃんと見てないので勘だけど、おそらく Crystal の `Hash` クラスでは `initialize` で初期化する際にジェネリクスで指定された型にしたがって、この `@buckets_length` とかのインスタンス変数にデータが保持されるのではなかろうか。Ruby がそうであるように Crystal もスーパークラスの `initialize` が暗黙的に呼ばれる、といったことはないようなので、その初期化処理が実行されていないことがまずは問題だと思われる。  
とりあえず何も考えず `super` を呼んでみる。

```ruby
    def initialize(filename)
      @filename = filename
      load
      super
    end
```

当然このままでうまく動くわけはないけど、少なくともエラーの内容は変化したので、`initialize` に関しては予想通りという感じ。本当はもう少し `Hash` 実装を見ておきたいところだけど、時間がないので一旦飛ばす。  
さて、ここで出たエラーは、

```text
in ./spec/dotenv/environment_spec.cr:4: undefined local variable or method 'subject'

  subject { env("OPTION_A=1\nOPTION_B=2") }
  ^~~~~~~
```

なるほど、`subject` は実装されてないのかな。テストに関してはとにかく確認できればいいと思って、サクッと `subject = { env("OPTION_A=1\nOPTION_B=2") }` にして進める。それと、ブロック内で `require` や `def` でメソッド定義しているところがあるとエラーになったのでそれらをトップレベルに移動したのと、`Tempfile#write` を `print` メソッドに変更。

```ruby
require "../spec_helper"
require "tempfile"

def env(text)
  file = Tempfile.new("dotenv")
  file.print text
  file.close
  env = Dotenv::Environment.new(file.path)
  file.unlink
  env
end

describe Dotenv::Environment do

  subject = { env("OPTION_A=1\nOPTION_B=2") }

...
```

で、テストを実行すると、

```text
in ./spec/dotenv/environment_spec.cr:8: undefined constant K

  env = Dotenv::Environment.new(file.path)
```

`K` がないんですって。ジェネリクスの型引数でしょうね。まぁこれはこうなるだろうなという感じがしてましたね。

```ruby
  env = Dotenv::Environment(String, String).new(file.path)
```

インスタンス生成時に型指定するようにしたい。たぶんこうすればいいのかな…  
テストを再実行すると、とりあえず `Dotenv::Environment` のインスタンス生成ではエラーにならなくなったので一歩前進した感。

```text
in ./src/dotenv/environment.cr:14: undefined constant Parser

      update Parser.call(read)
             ^~~~~~
```

`Parser` はまだ実装してないところなのでこれは仕方ない。てことで、ここからは `Parser` を作らないと先に進めそうにない。

## parser.cr

オリジナルの [`parser.rb`](https://github.com/bkeepers/dotenv/blob/release-2.0.2/lib/dotenv/parser.rb) を見ると、ここで2つのファイルを `require` してるみたいだ。めんどくさいなぁ…おっと、やばい、本音が飛び出してしまいました。気を取り直して、以下の3つのファイルをコピペしていきましょう。

- `dotenv/substitutions/command.rb`
- `dotenv/substitutions/variable.rb`
- `dotenv/parser.rb`

## substitutions/command.cr

[これ](https://github.com/bkeepers/dotenv/blob/release-2.0.2/lib/dotenv/substitutions/command.rb) をまるっとコピペしておもむろに `crystal build` 。

```text
$ crystal build src/dotenv/substitutions/command.cr 
Syntax error in ./src/dotenv/substitutions/command.cr:10: expecting token 'CONST', not '<<'

      class << self
```

残念ながら、`class << self` という書き方は存在しないようですね。ここは `INTERPOLATED_SHELL_COMMAND` っていうモジュール定数と `call` というメソッド定義だから、とりあえずそこはベタに書くとして、最終的には以下の点で (コンパイルエラーを解消するだけでも) 修正が必要でした。

- `class << self` 箇所の置き換え
- `String#gsub` のブロック引数に `*` が使えないので適当な変数名に変更
- `English` モジュールがないので `require English` を削除
- そのため `$LAST_MATCH_INFO` が使えないので `$~` に変更
- 正規表現のマッチオブジェクトで名前付きキャプチャの参照をシンボルから文字列に変更

```ruby
module Dotenv
  module Substitutions
    # Substitute shell commands in a value.
    #
    #   SHA=$(git rev-parse HEAD)
    #

    INTERPOLATED_SHELL_COMMAND = /
          (?<backslash>\\)?   # is it escaped with a backslash?
          \$                  # literal $
          (?<cmd>             # collect command content for eval
            \(                # require opening paren
            ([^()]|\g<cmd>)+  # allow any number of non-parens, or balanced
                              # parens (by nesting the <cmd> expression
                              # recursively)
            \)                # require closing paren
          )
        /x

    module Command
      def self.call(value, _env)
        # Process interpolated shell commands
        value.gsub(INTERPOLATED_SHELL_COMMAND) do |m|
          # Eliminate opening and closing parentheses
          command = $~["cmd"][1..-2]

          if $~["backslash"]?
            # Command is escaped, don't replace it.
            $~[0][1..-1]
          else
            # Execute the command and return the value
            `#{command}`.chomp
          end
        end
      end
    end
  end
end
```

とりあえずこんな感じ…ビルドは通ったので先に進む。ああ時間がない。

## substitutions/variable.cr

お次は [こちら](https://github.com/bkeepers/dotenv/blob/release-2.0.2/lib/dotenv/substitutions/variable.rb)。当然コピペからだけど、このファイルは `command.rb` と似ていて、確実に同様の修正が必要になりそうなので、コピペついでに必要な修正を入れておく。

```ruby
module Dotenv
  module Substitutions
    # Substitute variables in a value.
    #
    #   HOST=example.com
    #   URL="https://$HOST"
    #

    VARIABLE = /
          (\\)?        # is it escaped with a backslash?
          (\$)         # literal $
          \{?          # allow brace wrapping
          ([A-Z0-9_]+) # match the variable
          \}?          # closing brace
        /xi

    module Variable
      def self.call(value, env)
        value.gsub(VARIABLE) do |variable|
          match = $~

          if match[1] == '\\'
            variable[1..-1]
          else
            env.fetch(match[3]) { ENV[match[3]] }
          end
        end
      end
    end
  end
end
```

これでビルドは通る。

## parser.cr 再び

さて、[`parser.rb`](https://github.com/bkeepers/dotenv/blob/release-2.0.2/lib/dotenv/parser.rb) だ。とりあえず確実に存在しないであろう `RUBZY_VERSION` を参照してるところと、以下の `class << self` の箇所だけ書き変えてビルド、っと…いや待て、`attr_reader` もあるな。しかも `self` がクラスのコンテキストだからこれはクラスのインスタンス変数 (ややこしい) のゲッターメソッドだな。

```ruby
    class << self
      attr_reader :substitutions

      def call(string)
        new(string).call
      end
    end
```

微妙だけど、とりあえずこうやって単純にゲッターメソッドを定義して進んでみよう…

```ruby
    def self.substitutions
      @substitutions
    end

    def self.call(string)
      new(string).call
    end
```

ここから、とにかく出会ったエラーを無心に片付けながら進む。

```text
$ crystal build src/dotenv/parser.cr
Syntax error in ./src/dotenv/parser.cr:40: for empty hashes use '{} of KeyType => ValueType'

      @hash = {}
```

`Hash` の初期化で型指定されていないようだな。よしたぶんキーもバリューも `String` でいいだろう、たぶん…

```ruby
      @hash = {} of String => String
```

```text
$ crystal build src/dotenv/parser.cr
Syntax error in ./src/dotenv/parser.cr:50: unexpected token: NEWLINE

    private
```

次は `private` でシンタックスエラーか。なるほどなるほど。

```ruby
    private def parse_line(line)
...
    private def parse_value(value)
...
```

みたいな感じで。

```text
$ crystal build src/dotenv/parser.cr
Error in ./src/dotenv/parser.cr:1: while requiring "dotenv/substitutions/variable": can't find file 'dotenv/substitutions/variable' relative to '/path/to/dotenv/src/dotenv'

require "dotenv/substitutions/variable"
```

`require` するファイルが見つかりません、と。なるほどなるほど。

```ruby
require "./substitutions/variable"
require "./substitutions/command"
```

と `require` する側から相対パス指定で。これはあとでプロジェクトをビルドする機会があればロードパスの関係とかで変えるのかも。

```text
Error in ./src/dotenv/parser.cr:5: undefined constant SyntaxError

  class FormatError < SyntaxError; end
```

今度は `SyntaxError` がないとのこと。うーん、どうしよう。コンパイル言語である Crystal では、当然シンタックスエラーはコンパイラのフロントエンドで構文解析によって補足される。おそらく [ここ](https://github.com/manastech/crystal/blob/2a33a87cd7ee498dd166ab9bba824cb2d9291321/src/compiler/crystal/exception.cr#L50) だろう。これを継承するのはあまりに無理があるので、この [`Exception`](https://github.com/manastech/crystal/blob/2a33a87cd7ee498dd166ab9bba824cb2d9291321/src/exception.cr#L74) クラスを継承してみることにして、あとで困ったらそのときどうにかする…
 
```ruby
  class FormatError < Exception; end
```

とりあえずこれでそこはエラーにはならなくなった。次は、

```text
Error in ./src/dotenv/parser.cr:12: undefined method 'constants' for Dotenv::Substitutions:Class

      Substitutions.constants.map { |const| Substitutions.const_get(const) }
```

`constants` がない、と。

```ruby
    @substitutions =
      Substitutions.constants.map { |const| Substitutions.const_get(const) }
```

ここでエラーですね。これは `const_get` もきっと存在しないんだろうな。というかそもそも、`command.cr` と `variable.cr` でオープンクラス的に追加した定数はちゃんと定義されているんだろうか。  
`Parser` クラス内で雑に定数定義をプリントしてみると、

```ruby
  class Parser
    puts Substitutions::VARIABLE
    puts Substitutions::INTERPOLATED_SHELL_COMMAND
```

```text
$ crystal run src/dotenv/parser.cr
/
          (\\)?        # is it escaped with a backslash?
          (\$)         # literal $
          \{?          # allow brace wrapping
          ([A-Z0-9_]+) # match the variable
          \}?          # closing brace
        /ix
/
          (?<backslash>\\)?   # is it escaped with a backslash?
          \$                  # literal $
          (?<cmd>             # collect command content for eval
            \(                # require opening paren
            ([^()]|\g<cmd>)+  # allow any number of non-parens, or balanced
                              # parens (by nesting the <cmd> expression
                              # recursively)
            \)                # require closing paren
          )
        /x
```

おーいいね。定義には問題なさそう。であればここも一旦ベタに書くとしてこんな感じにする。

```ruby
    @substitutions = [Substitutions::Variable, Substitutions::Command]
```

これでビルドしてみると…よし、コンパイルエラーはなくなった。  

ではさっき作った `environment_spec` を実行してみましょう！

```text
〜長いエラー行〜
```

はい、何だかダダダーっとエラーが出ました。これはあれだ、一旦 `environment.cr` のテストは置いといて、`parser.cr` の方のテストから進めるべきだな。

## parser_spec.cr

うう、[`parser_spec.rb`](https://github.com/bkeepers/dotenv/blob/release-2.0.2/spec/dotenv/parser_spec.rb) はだいぶボリュームあるぜ…とりあえず俺にできることはコピペしてテストを流してみることだけだ…

```text
Syntax error in ./spec/dotenv/parser_spec.cr:9: expecting token ')', not '=>'

    expect(env("FOO=bar")).to eql("FOO" => "bar")
```

エラーでした。んーこれはあれか。ハッシュが引数になるときにブレースを省略できない、ということかな。とりあえず全部こんな感じに修正。

```ruby
expect(env("FOO=bar")).to eql({"FOO" => "bar"})
```

次のエラーは何かな。

```text
Syntax error in ./spec/dotenv/parser_spec.cr:18: unterminated char literal, use double quotes for strings

    expect(env('FOO="bar"')).to eql({"FOO" => "bar"})
```

そうだった。Crystal ではシングルクォートは `Char` だった。ということで、シングルクォート文字列をすべてダブルクォート文字列に変更。つらい。

```ruby
expect(env("FOO=\"bar\"")).to eql({"FOO" => "bar"})
```

次に、以前にも出た、「ブロックの中で `def` は使えません」というエラーがでたのでメソッド定義をトップレベルに移動してそれを解決した。ここまでやると、さっき `environment_spec.cr` を実行したときと同じような長いエラーになった。  
よく見ると、どうやらこのエラーはまずは以下をどうにかしないといけないみたいだ。

```text
in ./src/dotenv/parser.cr:51: undefined method 'captures' for MatchData

        key, value = match.captures
```

`MatchData#caputures` は `$1`、`$2`... の配列を返すメソッドだけど、パッと見る限り Crystal の `MatchData` で同じことができるメソッドはない様子。ただ、ここはアサインするのが `key, value` のペアなので、おそらくどちらもただの文字列だろう、と適当に勘で書き変える。

```ruby
key = match[1]
value = match[2]
```

時間がないのでどんどん対応が雑になってくるが、もうこのまま進むしかない。次はこんなエラーがでた。

```text
in ./src/dotenv/parser.cr:65: undefined method 'sub' for String (did you mean 'gsub'?)

      value = value.strip.sub(/\A(['"])(.*)\1\z/, '\2')                
```

「`sub` はないよ。`gsub` の間違いじゃない？」と言われた。別物じゃないかという気がすごくするが、言われるがままに `gsub` に変更する。  
さて、次のエラーは。

```ruby
in ./src/dotenv/parser.cr:67: undefined constant Regexp (did you mean 'Regex'?)

      if Regexp.last_match(1) == '"'
```

`Regexp` はない。`Regex` を提案されたが、`Regex` に `last_match` はない。ここはこうかな。

```ruby
      if $~[1] == '"'
```

よし次。

```text
in ./src/dotenv/parser.cr:30: @instance_vars are not yet allowed in metaclasses: use @@class_vars instead

      @substitutions
      ^~~~~~~~~~~~~~
```

ここ、だめだったか…ただ親切に `@@class_vars` にしろ、と教えてくれてるのでそうする。

```ruby
    @@substitutions = [Substitutions::Variable, Substitutions::Command]
    ...
    def self.substitutions
      @@substitutions
    end
```

次！

```text
in ./src/dotenv/parser.cr:88: undefined method 'member?' for Hash(String, String)

      !line.split[1..-1].all? { |var| @hash.member?(var) }
```

これは、`has_key?` があるので速攻で書き変える。  
次は例外クラスが絡むエラーのようだ。そろそろ来るかと思ってました。できれば来ないで欲しかったけど。

```text
instantiating 'Spec::AssertionFailed#initialize(Dotenv::FormatError:Class, String, Int32)'

in /usr/local/Cellar/crystal-lang/0.7.5/src/spec/spec.cr:96: no overload matches 'Exception#initialize' with types Dotenv::FormatError:Class
Overloads are:
 - Exception#initialize(message = nil : String | ::Nil, cause = nil : Exception | ::Nil)

      super(message)
      ^~~~~
```

これを、

```ruby
          fail FormatError, "Line #{line.inspect} has an unset variable"
```

こうする。

```ruby
          raise FormatError.new("Line #{line.inspect} has an unset variable")
```

これでいいのかはとりあえず不明。ひたすら勘を頼りに進める。  
次は `~` がない問題のようです。

```text
undefined method '~' for Regex

================================================================================

Regex trace:

  ./src/dotenv/parser.cr:59

          elsif line !~ /\A\s*(?:#.*)?\z/ # not comment or blank line
```

こうしとけばいいだろうか。

```ruby
      elsif !line.match(/\A\s*(?:#.*)?\z/) # not comment or blank line
```

次は `Spec` の DSL のエラーのようですね。

```text
in ./spec/dotenv/parser_spec.cr:10: undefined method 'expect'

    expect(env("FOO=bar")).to eql({"FOO" => "bar"})
    ^~~~~~
```

`expect` がない。いかん、心が折れる…  
(時間切れ)