LoginSignup
47
44

More than 5 years have passed since last update.

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

Last updated at Posted at 2015-08-10

最近話題の Ruby ライクなプログラミング言語 Crystal について。

「Ruby コードが変更なく動く」という衝撃的な発言や、「静的型付けの Rails 嬉しい」といった気の早すぎるように思える発言なども目にして、「じゃあ実際どのくらい Ruby コードがそのまま動くの?」ということを知りたく、「なんかの Gem を Ruby コードのコピペで実装してみよう!」と思いました。

とりあえず空いてる時間が5時間くらいあったので、その範囲という制限でやります。文章も進めながらだらだらと書いていったものそのままで整理していないので超読み辛いかもしれません。

お題の Gem

Crystal でコピペ実装する Gem は dotenv です。

これを選択したのは、

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

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

進め方

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

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

TL;DR

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

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

environment.cr

さて、それではコピペ実装をはじめます。

まずは、パッと見で短くて簡単そうな environment.rb からコピペしてみることにする。

src/dotenv/environment.cr

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 を直接指定してビルドをかけてみることにした。

$ 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

class Hash(K, V)
...
end

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

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

そのつもりで見ると確かにエラーメッセージもそのまんま指摘されてるように見える。

さて、では改めてビルドしてみよう。

$ 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 というとてもわかりやすいものが存在していたので、それを定義して進むことにする。

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

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

environment_spec.cr

それじゃあ、ビルドも通ったので、environment.cr の実装に対して、オリジナルと同じテストを試してみて動作確認をする。

テストもまずは乱暴にそのまま これenvironment_spec.cr にコピペして実行してみる。

$ 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

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

    def initialize(filename)
      @filename = filename
      load
    end

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

とりあえず何も考えず super を呼んでみる。

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

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

さて、ここで出たエラーは、

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") } にして進める。それと、ブロック内で requiredef でメソッド定義しているところがあるとエラーになったのでそれらをトップレベルに移動したのと、Tempfile#writeprint メソッドに変更。

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") }

...

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

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

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

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

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

インスタンス生成時に型指定するようにしたい。たぶんこうすればいいのかな…

テストを再実行すると、とりあえず Dotenv::Environment のインスタンス生成ではエラーにならなくなったので一歩前進した感。

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

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

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

parser.cr

オリジナルの parser.rb を見ると、ここで2つのファイルを require してるみたいだ。めんどくさいなぁ…おっと、やばい、本音が飛び出してしまいました。気を取り直して、以下の3つのファイルをコピペしていきましょう。

  • dotenv/substitutions/command.rb
  • dotenv/substitutions/variable.rb
  • dotenv/parser.rb

substitutions/command.cr

これ をまるっとコピペしておもむろに crystal build

$ 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 が使えないので $~ に変更
  • 正規表現のマッチオブジェクトで名前付きキャプチャの参照をシンボルから文字列に変更
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

お次は こちら。当然コピペからだけど、このファイルは command.rb と似ていて、確実に同様の修正が必要になりそうなので、コピペついでに必要な修正を入れておく。

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 だ。とりあえず確実に存在しないであろう RUBZY_VERSION を参照してるところと、以下の class << self の箇所だけ書き変えてビルド、っと…いや待て、attr_reader もあるな。しかも self がクラスのコンテキストだからこれはクラスのインスタンス変数 (ややこしい) のゲッターメソッドだな。

    class << self
      attr_reader :substitutions

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

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

    def self.substitutions
      @substitutions
    end

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

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

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

      @hash = {}

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

      @hash = {} of String => String
$ crystal build src/dotenv/parser.cr
Syntax error in ./src/dotenv/parser.cr:50: unexpected token: NEWLINE

    private

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

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

みたいな感じで。

$ 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 するファイルが見つかりません、と。なるほどなるほど。

require "./substitutions/variable"
require "./substitutions/command"

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

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

  class FormatError < SyntaxError; end

今度は SyntaxError がないとのこと。うーん、どうしよう。コンパイル言語である Crystal では、当然シンタックスエラーはコンパイラのフロントエンドで構文解析によって補足される。おそらく ここ だろう。これを継承するのはあまりに無理があるので、この Exception クラスを継承してみることにして、あとで困ったらそのときどうにかする…

  class FormatError < Exception; end

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

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

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

constants がない、と。

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

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

Parser クラス内で雑に定数定義をプリントしてみると、

  class Parser
    puts Substitutions::VARIABLE
    puts Substitutions::INTERPOLATED_SHELL_COMMAND
$ 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

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

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

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

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

〜長いエラー行〜

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

parser_spec.cr

うう、parser_spec.rb はだいぶボリュームあるぜ…とりあえず俺にできることはコピペしてテストを流してみることだけだ…

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

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

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

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

次のエラーは何かな。

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 だった。ということで、シングルクォート文字列をすべてダブルクォート文字列に変更。つらい。

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

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

よく見ると、どうやらこのエラーはまずは以下をどうにかしないといけないみたいだ。

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

        key, value = match.captures

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

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

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

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 に変更する。

さて、次のエラーは。

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

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

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

      if $~[1] == '"'

よし次。

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

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

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

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

次!

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

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

これは、has_key? があるので速攻で書き変える。

次は例外クラスが絡むエラーのようだ。そろそろ来るかと思ってました。できれば来ないで欲しかったけど。

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)
      ^~~~~

これを、

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

こうする。

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

これでいいのかはとりあえず不明。ひたすら勘を頼りに進める。

次は ~ がない問題のようです。

undefined method '~' for Regex

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

Regex trace:

  ./src/dotenv/parser.cr:59

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

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

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

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

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

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

expect がない。いかん、心が折れる…

(時間切れ)

47
44
7

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
47
44