最近話題の Ruby ライクなプログラミング言語 Crystal について。
「Ruby コードが変更なく動く」という衝撃的な発言や、「静的型付けの Rails 嬉しい」といった気の早すぎるように思える発言なども目にして、「じゃあ実際どのくらい Ruby コードがそのまま動くの?」ということを知りたく、「なんかの Gem を Ruby コードのコピペで実装してみよう!」と思いました。
とりあえず空いてる時間が5時間くらいあったので、その範囲という制限でやります。文章も進めながらだらだらと書いていったものそのままで整理していないので超読み辛いかもしれません。
お題の Gem
Crystal でコピペ実装する Gem は dotenv です。
これを選択したのは、
- 他の Gem への依存がない
- そんなに大きくない
- コードリーディングで使われたりしてたので Ruby っぽいコードだろう
- 自分がまったく使ったこともないしコードもはじめて見る
といった理由によります。
進め方
以下を基本原則として進めます。
- 進めやすそうなところから実装していく
- 元のコードを読み解き論理的に実装するのではなく、コピペを起点に場当り的に進める
- テストで単体の動作を確認しながら進める
- 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
の継承元の Hash
を Hash(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
うーわー、やっぱりというか何というかすごいエラーになっちゃったな。まるっとコピペは乱暴すぎたか。あれ?でも何かこのエラーは、メッセージ見た感じ、テストが問題ってわけでもないっぽい…?initialize
で Hash
が初期化されてないって言われてる。
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") }
にして進める。それと、ブロック内で require
や def
でメソッド定義しているところがあるとエラーになったのでそれらをトップレベルに移動したのと、Tempfile#write
を print
メソッドに変更。
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.cr
と variable.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
を提案されたが、Regex
に last_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
がない。いかん、心が折れる…
(時間切れ)