こんにちは、@tk0miya です。
そろそろ RubyKaigi 2023 の余韻も冷めてきたところなのですが、持って返ってきたお土産タスクはまだまだ山盛りなので、少しずつ調べていこうと思っています。
さて、前回は Ruby の型定義情報を使って irb の入力をリッチにする katakata_irb を紹介しました。
rbs_rails を使うと Rails アプリから型定義が自動抽出できることも学びましたよね。
katakata_irb と rbs_rails の組み合わせだけでも、かなり強力な補完をしてくれますが、これに加えて自分で定義したメソッドに型を定義することで、さらに入力補完をパワーアップすることができます。
今回は、その型を定義する準備として、環境づくりの方法を紹介します。
rbs-3.1.0 で導入された rbs subtract コマンドを利用するため、最新の rbs パッケージをインストールします。
katakata_irb パッケージは rbs に依存しているので本来は Gemfile への追加は必要ないのですが、
利用したいバージョンが決まっているため、依存関係を明示しておくと良いでしょう。
gem 'rbs', '>= 3.1.0'
なお、LSP サーバである solargraph を使っている場合は rbs-3.x 系はインストールできません。
solargraph は rbs-2.x 系に依存しているためコンフリクトが発生します。
手元では solargraph に手を加え、rbs-3.x 系で動作するようにしたものを利用しました。
これを利用する場合は、Gemfile を以下のように書き換えるとよいでしょう。
# TODO: rbs-3.x を利用するため、solargraph にパッチを当てたものを利用する。
# https://github.com/castwide/solargraph/pull/662 マージ後に solargraph 本体に切り替える
gem 'solargraph', require: false, github: 'tk0miya/solargraph', branch: :update_rbs
rbs-3.1.0 で導入された rbs subtract
コマンドは、複数の型定義ファイル (.rbs ファイル) 間で重複している定義を取り除いてくれるコマンドです。
複数のツールを使って .rbs ファイルを生成する場合や、ツールと手書きの型定義を組み合わせる場合など、
ツールを利用する場合は型定義ファイル間で型定義が重複しやすいため、rbs subtract
コマンドを使って重複を除去するのがよいでしょう。
詳しくは RubyKaigi 2023 の Let’s write RBS! – RubyKaigi 2023 (トーク資料) や Desgin Doc of rbs subtract を参照してください。
先ほど紹介した Let’s write RBS! のトークでは、rbs や rbs_rails を組み合わせて、型定義を “いい感じに” 管理してくれる便利 Rake タスクである rbs:setup
を提供しています。
この Rake タスクはよく練られており非常に便利な構造になっているので、拝借させていただくことにします。
curl -L -o lib/tasks/rbs.rake https://raw.githubusercontent.com/pocke/rubykaigi-2023-lets-write-rbs/master/rbs.rake
この rbs:setup
タスクは以下の処理で構成されています。
rbs collection install
の実行rbs prototype rb
の実行rbs_rails:all
の実行rbs subtract
の実行これらを連続で実行することにより、型定義ファイルを最新に保つことができます。
実際に実行してみましょう。
bundle exec rails rbs:setup
rbs:setup
タスクを実行すると、以下の型定義情報がダウンロードもしくは生成されます。
そして、sig ディレクトリ以下の型情報の重複している定義は rbs subtract
で除去された状態になっています。
※ 当初公開された rbs.rake には誤りがあります。https://github.com/pocke/rubykaigi-2023-lets-write-rbs/pull/1 がマージされるのをお待ちください。
ここまでのステップで、Rails アプリケーションから型定義を抽出できました。
しかし、これで作業は完了ではありません。
rbs prototype rb
コマンドが生成する型定義は、ソースコードを静的解析して機械的に生成したものであり、生成時点では動作が保証されていません。
そのため、生成直後の状態で型情報をロードしようとするとエラーが発生することがあります。
この状態で katakata_irb を起動すると、(特定のクラスの) 入力補完が効かなくなってしまいます。
こうした問題を解決するため、rbs validate
コマンド使って、型定義情報の状態を検証します。
先ほど導入した rbs.rake には rbs:validate
という Rake タスクが定義されているため、このタスク経由で rbs validate
コマンドを起動しましょう (オプションを覚える必要がないので楽です)。
bundle exec rails rbs:validate
型定義情報が正しく生成されている場合は、特にエラーなどなくコマンドが終了します。
rbs validate
コマンドがクラッシュした場合は、何かしら定義に問題があるため微調整を行う必要があります。
自分のケースでは以下の調整が必要でした。
それでは、それぞれのケースをひとつずつ説明していきます。
前回の記事でも紹介しましたが、3rd party gem の型定義情報は rbs collection install
コマンドを実行した際に gem_rbs_collection リポジトリからダウンロードされます。
このリポジトリには、有志の手によってよく使われている gem の型定義情報が収集・収録されているのですが、
2023年5月時点で登録されているのは約70個で、まだ収録されている型定義情報は少ないと言わざるを得ません。
そのため、型定義情報が収録されていない 3rd party gem を利用している場合、rbs validate
でエラーになることがあります。
具体的には、
と言った場合に、rbs validate
がエラーを吐きます。
以下の例は、CanCanCan gem を利用している Rails アプリのエラーです。
root@f8da7af3f06e> bundle exec rails rbs:validate
rbs -Isig validate --silent
/usr/local/bundle/gems/rbs-3.1.0/lib/rbs/errors.rb:232:in `check!': sig/prototype/app/abilities/ability.rbs:2:2...2:25: Could not find mixin: CanCan::Ability (RBS::NoMixinFoundError)
from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder/ancestor_builder.rb:347:in `block in mixin_ancestors0'
from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/ast/declarations.rb:48:in `each'
from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/ast/declarations.rb:48:in `each_mixin'
from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder/ancestor_builder.rb:338:in `mixin_ancestors0'
from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder/ancestor_builder.rb:397:in `block in mixin_ancestors'
from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder/ancestor_builder.rb:389:in `each'
from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder/ancestor_builder.rb:389:in `mixin_ancestors'
from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder/ancestor_builder.rb:253:in `one_instance_ancestors'
from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder/ancestor_builder.rb:423:in `instance_ancestors'
from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder.rb:162:in `block in build_instance'
from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder.rb:748:in `try_cache'
from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder.rb:158:in `build_instance'
from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/cli.rb:467:in `block in run_validate'
from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/cli.rb:465:in `each'
from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/cli.rb:465:in `run_validate'
from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/cli.rb:137:in `run'
from /usr/local/bundle/gems/rbs-3.1.0/exe/rbs:7:in `<top (required)>'
from /usr/local/bundle/bin/rbs:25:in `load'
from /usr/local/bundle/bin/rbs:25:in `<main>'
rails aborted!
Command failed with status (1): [rbs -Isig validate --silent...]
/app/lib/tasks/rbs.rake:36:in `block (2 levels) in <main>'
Tasks: TOP => rbs:validate
(See full trace by running task with --trace)
CanCan::Ability モジュールの型定義が存在しないため、ability.rbs が処理できないというエラーが発生しています。
理想としては、不足している型定義を作り上げた上で、gem_rbs_collection にコントリビュートするというのが望ましいのですが、ひとまず rbs validate
がパスするように、ダミーの型を定義します。
不足している 3rd party gem の型として、 sig/gems/cancan/cancan/ability.rbs
に以下の内容を保存します。
# sig/gems/cancan/cancan/ability.rbs
module CanCan
module Ability
end
end
定義する内容は空のモジュールです。普通の Ruby のソースコードと同じ書き方でモジュールを定義すればオッケーです。また、型情報を記述するファイルは拡張子 .rbs を用います。
※ sig/ ディレクトリ以下のファイル配置ルールは決まっていないようです。この記事では 3rd party gem の型情報を sig/gems/{gem_name}/
以下にファイルを作ることにしました。
先ほどの例はモジュールの include 時のエラーでしたが、エラーがクラスの継承で発生している場合は、同様に空のクラスを定義しましょう。
次の例は Draper::Decorator
クラスのダミーの型定義です。
# sig/gems/draper/draper/decorator.rbs
module Draper
class Decorator
end
end
これらのファイルを作成した後、再度 rbs:validate
を実行すると問題が解消しているはずです。
すべてのエラーが解消するまで、同様にダミーの型定義を作成しましょう。
手元の中規模プロジェクトでは 9個分のダミー型定義を作成しました (ActionCable, ActiveHash, CanCan, CarrierWave, ConnectionPool, Devise, Discard, Draper, OperatorRecordable)。
※ 今回はエラーの回避を優先して、3rd party gem の型定義は行いません。
自作クラスが Enumerable モジュールを include している場合も、rbs validate
はエラーを発生させます。
root@f8da7af3f06e> bundle exec rails rbs:validate
rbs -Isig validate --silent
/usr/local/bundle/gems/rbs-3.1.0/lib/rbs/errors.rb:90:in `check!': sig/prototype/app/models/cookie_jar/access_histories.rbs:14:2...28:5: ::Enumerable expects parameters [Elem], but given args [] (RBS::InvalidTypeApplicationError)
from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition.rb:241:in `apply'
from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder/ancestor_builder.rb:454:in `block in instance_ancestors'
from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder/ancestor_builder.rb:451:in `each'
from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder/ancestor_builder.rb:451:in `instance_ancestors'
from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder.rb:162:in `block in build_instance'
from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder.rb:748:in `try_cache'
from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder.rb:158:in `build_instance'
from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/cli.rb:467:in `block in run_validate'
from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/cli.rb:465:in `each'
from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/cli.rb:465:in `run_validate'
from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/cli.rb:137:in `run'
from /usr/local/bundle/gems/rbs-3.1.0/exe/rbs:7:in `<top (required)>'
from /usr/local/bundle/bin/rbs:25:in `load'
from /usr/local/bundle/bin/rbs:25:in `<main>'
rails aborted!
Command failed with status (1): [rbs -Isig validate --silent...]
/app/lib/tasks/rbs.rake:36:in `block (2 levels) in <main>'
Tasks: TOP => rbs:validate
(See full trace by running task with --trace)
このエラーは、rbs prototype rb
コマンドが生成した型定義ファイルが不完全であるために発生しています。
.rbs ファイルでは Enumerable モジュールを include する際に、どういう型のデータを列挙するのかを宣言する必要があります。
しかし、rbs prototype rb
コマンドはどういうデータを列挙しているのかまで判別できないため、こうしたエラーが発生します。
この問題を解決するには、該当のクラスで列挙するデータ型を宣言します。
新たに型定義ファイルをつくり、そこに正しい型定義の include 宣言を書きましょう。
先ほどの例では CookieJar::AccessHistories
クラスでエラーになっているため、sig/handwritten/app/models/cookie_jar/access_histories.rbs
ファイルを作成し、以下のように型定義を書きます。
module CookieJar
class AccessHistories
include Enumerable[AccessHistory]
end
end
注) sig/prototype/ 以下のファイルを書き換えないでください。sig/prototype/ 以下のファイルは rbs:setup
を実行するたびに再生成されるため、変更した内容は消えてしまいます。
この型定義はモジュールとクラスを定義し、その中で列挙する型を指定して Enumerable モジュールを include しています。
CookieJar::AccessHistories
は AccessHistory
オブジェクトを列挙するので、 include Enumerable[AccessHistory]
という指定をしています。
型定義を書き加えたあと、もう一度 rbs:setup
タスクを実行しましょう。
手書きした型定義を使って、型定義ファイル群がアップデートされます。
具体的には rbs prototype rb
で生成された型定義ファイルから、不完全な include 文が削除されます (rbs subtract
によって手書きの型定義が優先されるため)。
なお、このケースでは列挙する型がすぐに判明したので、具体的な型が記述できましたが、
どういうデータを返すのかが思い出せない場合などは、以下のように untyped 型を指定しても構いません。
不完全な型定義になってしまいますが、エラーを回避することができます。
module CookieJar
class AccessHistories
include Enumerable[untyped]
end
end
自分のケースでは、もうひとつエラーに遭遇しました。具体的には以下のエラーです。
root@f8da7af3f06e> bundle exec rails rbs:validate
rbs -Isig validate --silent
/usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder.rb:616:in `define_method': sig/prototype/app/models/city.rbs:18:2...18:67: ::City.find_by has duplicated definitions in /app/.gem_rbs_collection/activerecord/7.0/activerecord.rbs:362:2...362:35 (RBS::DuplicatedMethodDefinitionError)
from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder.rb:577:in `block in import_methods'
from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder/method_builder.rb:56:in `block in each'
from /usr/local/lib/ruby/3.1.0/tsort.rb:350:in `block (2 levels) in each_strongly_connected_component'
from /usr/local/lib/ruby/3.1.0/tsort.rb:431:in `each_strongly_connected_component_from'
from /usr/local/lib/ruby/3.1.0/tsort.rb:349:in `block in each_strongly_connected_component'
from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder/method_builder.rb:73:in `each_value'
from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder/method_builder.rb:73:in `tsort_each_node'
from /usr/local/lib/ruby/3.1.0/tsort.rb:347:in `call'
from /usr/local/lib/ruby/3.1.0/tsort.rb:347:in `each_strongly_connected_component'
from /usr/local/lib/ruby/3.1.0/tsort.rb:316:in `each_strongly_connected_component'
from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder/method_builder.rb:51:in `each'
from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder.rb:576:in `import_methods'
from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder.rb:257:in `block (2 levels) in build_singleton0'
from <internal:kernel>:90:in `tap'
from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder.rb:225:in `block in build_singleton0'
from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder.rb:748:in `try_cache'
from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder.rb:218:in `build_singleton0'
from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder.rb:299:in `block (2 levels) in build_singleton'
from <internal:kernel>:90:in `tap'
from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder.rb:298:in `block in build_singleton'
from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder.rb:748:in `try_cache'
from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/definition_builder.rb:291:in `build_singleton'
from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/cli.rb:470:in `block in run_validate'
from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/cli.rb:465:in `each'
from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/cli.rb:465:in `run_validate'
from /usr/local/bundle/gems/rbs-3.1.0/lib/rbs/cli.rb:137:in `run'
from /usr/local/bundle/gems/rbs-3.1.0/exe/rbs:7:in `<top (required)>'
from /usr/local/bundle/bin/rbs:25:in `load'
from /usr/local/bundle/bin/rbs:25:in `<main>'
rails aborted!
Command failed with status (1): [rbs -Isig validate --silent...]
/app/lib/tasks/rbs.rake:36:in `block (2 levels) in <main>'
Tasks: TOP => rbs:validate
(See full trace by running task with --trace)
これは ActiveRecord の City モデルで .find_by
メソッドをオーバーライドしている場合に発生しました。
rbs prototype rb
コマンドは、対象のソースコードにメソッド定義を見つけると、自動的に型定義を生成してくれるのですが、
3rd party gem 内で定義されているメソッドをオーバーライドしている場合は、3rd party gem 側の型定義と prototype の型定義が重複してしまうため、エラーが発生してしまいます。
この問題に関しては今のところ正しい回避方法を見つけられていないことから、メソッドをリネームすることで回避しています。
この書き方をしているのは 2箇所だけで、重要な場面でなかったことが救いでした。
開発補助ツールのためにメインコードを書き換えるというのは少しもやっとしますが、今後得られるメリットは大きいと期待して、えいやっと書き換えてしまいました。
このエラーの対応方法をご存知でしたら Twitter ID: @tk0miya までご一報ください。
ここまで、自分の手元で発生した rbs validate
のエラーの内容とその対応方法をご紹介しました。
rbs validate
を実行し、出てくるエラーをひとつずつ解決していってください。
手元の中規模アプリでは、手順を整理しながら半日ほどで対応できました。
対応方法が整理された今であれば一時間以内で対応できる内容です。
最終的に、これらの対応をすることによって、rbs validate
でのエラーは発生しなくなりました。
root@f8da7af3f06e> bundle exec rails rbs:validate
rbs -Isig validate --silent
root@f8da7af3f06e>
ここまで対応することで、ようやく自分で型定義を書く準備が整いました。
詳しい手順は次の記事で紹介しますが、ここからは型を書いて rbs:setup
を実行、型を書いて rbs:setup
を実行の繰り返しになります。
この記事では、Ruby の型定義を書いていく下準備として環境を整備する方法をご紹介しました。
rbs:setup
タスクのインストール
rbs prototype rb
, rbs subtract
, rbs validate
の紹介rbs:setup
タスク初回実行後のエラー解決方法の紹介次回は実際に型定義を書くことで、より katakata_irb の入力補完をリッチにする方法をご紹介します。