以前、 RubyKaigi 2025 follow up イベントで rbs_rails LSP サーバを紹介 しました。
その際に 友人 から「LSP サーバを新たに作るのではなく、Ruby LSP のアドオンとして実装すべきだ」という提案をもらいました。
この提案はとてもよい指摘です。RubyKaigi 2024 の The state of Ruby dev tooling でも、開発補助ツール(の組み合わせ)が増えすぎると、選択肢が膨大になり開発者の混乱のもととなる、と説明されています。
個人的にはツールがいくつもあり、多様性の中で切磋琢磨するという牧歌的な考え方も捨てがたいのですが、開発環境の (デファクト) スタンダードがあり、開発環境がかんたんに用意できることと便利である、というのはもっともな意見です。
情報収集と設定の微調整をし続けたものだけがよい開発環境を構築できる、というのは望ましくはないですしね。
ということで、先日 ruby-lsp-rbs_rails という Ruby LSP アドオンを公開しました 。これにより、この gem をインストールするだけで、rbs_rails による型定義ファイルの自動生成ができるようになりました。
機能そのものは以前の提案と同じものですので、詳細は 前回の記事 を御覧ください。
今回は rbs_rails アドオンを作るに当たって得た知見である Ruby LSP アドオンの作り方を紹介します。
Ruby LSP は インストールされている gem から ruby_lsp/**/addon.rb というファイルを検索 し、アドオンとして扱います。そのため、Ruby LSP アドオンを作る際は以下のことを守る必要があります。
ruby_lsp/**/addon.rb を用意するこの規約に従うため、gem の名前は ruby_lsp-[addon_name] (ruby_lsp の後はハイフン) とするのがよいでしょう。
$ bundle gem ruby_lsp-rbs_rails
Creating gem 'ruby_lsp-rbs_rails'...
MIT License enabled in config
Code of conduct enabled in config
RuboCop enabled in config
Initializing git repo in /Users/tkomiya/ruby_lsp-rbs_rails
create ruby_lsp-rbs_rails/Gemfile
create ruby_lsp-rbs_rails/lib/ruby_lsp/rbs_rails.rb
create ruby_lsp-rbs_rails/lib/ruby_lsp/rbs_rails/version.rb
create ruby_lsp-rbs_rails/sig/ruby_lsp/rbs_rails.rbs
create ruby_lsp-rbs_rails/ruby_lsp-rbs_rails.gemspec
create ruby_lsp-rbs_rails/Rakefile
create ruby_lsp-rbs_rails/README.md
create ruby_lsp-rbs_rails/bin/console
create ruby_lsp-rbs_rails/bin/setup
create ruby_lsp-rbs_rails/.gitignore
create ruby_lsp-rbs_rails/.github/workflows/main.yml
create ruby_lsp-rbs_rails/LICENSE.txt
create ruby_lsp-rbs_rails/CODE_OF_CONDUCT.md
create ruby_lsp-rbs_rails/.rubocop.yml
Gem 'ruby_lsp-rbs_rails' was successfully created. For more information on making a RubyGem visit https://bundler.io/guides/creating_gem.htmlこの gem の名称にすると、自動的に lib/ruby_lsp/[addon_name]/ というディレクトリが作成されるので、gem 作成後に addon.rb を配置するだけで済みます。
なお、残念ながら拙作の ruby-lsp-rbs_rails はこの命名規則に従っていません。
rubygems.org で公開されているアドオン群が ruby-lsp (ハイフン) という名前を使っている ので、それに倣ってこのパッケージ名を選んだのですが、アドオンの規約どおりのファイルが生成されずに手作業でディレクトリ構成を調整する羽目になりました。 lib/ruby/lsp/rbs_rails.rb というファイルができてしまうんですよね…。
この記事を書くに当たって振り返ったところ、パッケージ名を ruby-lsp (ハイフン) よりも ruby_lsp (アンダースコア) にするほうがコード生成や、パッケージ名的に自然だということに気づきました。
ですので、ruby_lsp-… という名前を使うのが良いかもしれません。
さて、プロジェクトが準備できたところで、アドオンの実装を進めていきましょう。
まずは Ruby LSP がアドオンを検知できるよう、 lib/ruby_lsp/**/addon.rb というファイルを作成します。
今回は rbs_rails 用のアドオンを作るのが目的ですから、 lib/ruby_lsp/rbs_rails/addon.rb というファイルを作成します。
このファイルにはアドオンのクラスを定義します。このクラスは RubyLsp::Addon クラスを継承するようにしてください。なお、クラス名やモジュール階層には特に指定はないようです。
ここではファイル名に合わせて RubyLsp::RbsRails::Addon というクラスを作ります。
# frozen_string_literal: true
require "ruby_lsp/addon"
module RubyLsp
module RbsRails
class Addon < ::RubyLsp::Addon
def activate(global_state, message_queue)
end
def deactivate
end
def name
"Ruby LSP RBS Rails"
end
def version
"0.1.0"
end
end
end
endアドオンクラスには #activate, #deactivate, #name, #version という 4つのメソッドを実装します。それぞれ次のような役割を持ちます。
#activate: LSP サーバが起動したときに一度だけ呼び出されるメソッド。アドオンの初期化処理を実装する。#deactivate: LSP サーバが終了するときに呼び出されるメソッド。リソースの開放などを行う。#name: アドオンの名前を返すメソッド。#version: アドオンのバージョンを返すメソッド。ここまでで、何もしない Ruby LSP アドオンが完成しました。
ここからは、エディタの操作に応じてアドオンが動作するように仕立てていきます。
先程用意した #activate メソッドと #deactivate メソッドに、アドオンの初期化処理と終了処理を実装します。
まずは #activate メソッドから見ていきます。このメソッドは global_state と message_queue という 2つの引数を受け取ります。
global_state: Ruby LSP のグローバルな状態を表すオブジェクトmessage_queue: LSP サーバと LSP クライアント間でメッセージを送受信するためのキューオブジェクト最初の引数である global_state は Ruby LSP の内部状態にアクセスできるオブジェクトです。プロジェクトの状態や設定値などが読み込めるようです。rbs_rails アドオンではプロジェクトのルートディレクトリを取得するために global_state.workspace_path を参照しています。
もうひとつの引数である message_queue は LSP の通信に必要なオブジェクトです。LSP のメッセージのやり取りに必要となるため、インスタンス変数に保存しておくのが良いでしょう。
rbs_rails では、変換の進捗状況をログ出力するために message_queue を利用しています。
def activate(global_state, message_queue)
@message_queue = message_queue
# @message_queue << RubyLsp::Notification.window_log_message("hello world") という呼び出しでログが記録できる
endrbs_rails アドオンでは、他にも初期化処理として 以下のような処理を実装しています。
もうひとつの #deactivate メソッドは、サブプロセスの終了など、アドオンがアンロードされるときに呼び出されます。そのため、リソースの解放といったクリーンアップ処理がある場合に #deactivate メソッドで処理をします。
rbs_rails アドオンでは特にクリーンアップ処理は必要なかったので、空のままにしています。
rbs_rails アドオンでは、ファイルの変更を検知して型定義ファイルの生成処理を実行しています。
Ruby LSP では、アドオンクラスに #workspace_did_change_watched_files というメソッドを定義することで、ファイルの変更を検知できます。
この名前でピンときた方もいるかも知れません。このメソッドは LSP で定義されている workspace/didChangeWatchedFiles イベント そのものです。
#workspace_did_change_watched_files メソッドは、 changes という引数をひとつ受け取ります。changes にはファイルの変更イベントが配列で指定されています。
このファイル変更イベントは :uri, :type というキーを持つハッシュデータで、 :uri には変更されたファイルの URI が、:type には変更の種類(作成: 1, 変更: 2, 削除: 4)が、それぞれ格納されています。
def workspace_did_change_watched_files(changes)
# changes == [
# { uri: "file:///path/to/changed_file.rb", type: 2 },
# { uri: "file:///path/to/deleted_file.rb", type: 4 }
# ]
end:uri には変更されたファイルの URI が、 :type には変更の種類(作成: 1, 変更: 2, 削除: 4)が、それぞれ格納されています。
rbs_rails アドオンでは、変更の内容に応じて処理を切り替えています。
config/routes.rb または config/routes/*.rb だった場合は、ルーティングの型定義を生成db/schema.rb だった場合は、すべてのモデルの型定義を生成大まかにコードで表現すると次のようなコードが実装されています (実際にはもう少し複雑です)。
def workspace_did_change_watched_files(changes)
changes.each do |change|
case change[:type]
when 1, 2 # Created or Changed
case change[:uri]
when /config\/routes(?:\/.*)?\.rb$/
generate_routes_signature
when /db\/schema\.rb$/
generate_all_model_signatures
when /app\/models\/(.*)\.rb$/
model_name = Regexp.last_match(1)
generate_model_signature(model_name)
end
when 4 # Deleted
delete_signature(change[:uri])
end
end
end詳しくは rbs_rails アドオンの 実際の実装 を
読んでみてください。rbs_rails の型生成ロジックを呼び出すための薄いグルーコードですので、読みやすいと思います。
rbs_rails アドオンでは利用していませんが、Ruby LSP アドオンは他にもさまざまなイベントに対応するメソッドを提供しています。
これらの詳細については Add-ons | Ruby LSP を読んでみてください。
ここまでのステップを元に、ひととおり機能を実装した rbs_rails アドオンがこちらです。
ソースコードは tk0miya/ruby-lsp-rbs_rails で公開しています。具体的なコードはリポジトリを確認してみてください。Ruby LSP が共通部分を提供してくれているため、ほんの 200 行強のコードで rbs_rails を LSP 化できました。
ruby-lsp-rbs_rails は、当初の提案より簡単に使えるようになったので、ぜひ試してみてください。
また、これをきっかけに便利な Ruby LSP アドオンを書いてみてください。