今日からneovimでRubyの型(RBS)を書き始める方法 + 実際に書いてみた感想

しばらくRubyをあんま触ってない日々が続いてたんですが、オフラインでRubyKaigiに参加したKaigiEffectということでやる気が甦ってきたので、型を真面目に書くための準備を整えようと色々とやってました。

RubyKaigiでモダンなRubyの開発体験のデモをいくつか見たんですが、大体VSCodeだったのが生粋のVimmerである自分としては残念だったので、neovimでも色々やれるぞという環境を整えておきたかったのも一つです。

という訳で色々環境が整ったのでまとめていきます。

ちなみに、今回の題材はrbsとSteepによる型検査です。sorbetとかもありますが、自分としてはrbsの書式の方が圧倒的に好きなのでこちらでやっていきたいと思います。 (sorbetはRubyコードに直接書けるという大きなメリットはあるんだけど……)

Steepを動かす

まず対象のプロジェクトにSteepをインストールします。最新の環境に合わせたかったので、自分はGemfileでgithubのmasterが入る様にしています。 合わせて依存関係でrbsもインストールされます。

Steepとrbsの関係は過去に色々なカンファレンスで語られているので改めて自分が解説する様なことは特にありませんが、非常に簡単に書いておくとrbsRubyの型を書くための書式であるrbsフォーマットの文法定義やパーサを提供するgemで、それを使って型検査を実際に行うのがSteepです。

init

Steepをインストールしたら bundle exec steep initを実行します。これでSteepfileが生成されます。

コメントアウトされたサンプルが既に記述されているので、それを参考に書きましょう。

target :app do
  check "lib"
  signature "sig"

  library "set", "pathname"
end

SteepのREADMEに書かれているシンプルな例はこんな感じ。lib以下をsig以下にあるrbs情報を使って型検査する、という意味です。

標準の組込みライブラリに関してはrbs gemの中で型情報が定義されているものがあり、それを利用できます。libraryを使ってそれを指定することで型情報を引っ張ってこれます。

これで bundle exec steep check を実行すればとりあえずSteepは動くはずです。もし既存のプロジェクトに追加したら恐らく死ぬ程型エラーが出ますw

利用しているrubygemsの型情報

Rubyistがコードを書く時は普通は多くのrubygemsを利用します。それらの型情報はどう扱えばいいかというと、 https://github.com/ruby/gem_rbs_collection に型情報がまとめられています。 (まだ発展途上のためこれからコミュニティの力で型情報を充実させていく必要があります)

rbs gemが提供するrbsコマンドにはこのgem_rbs_collectionを利用するための仕組みが用意されています。まず以下のコマンドを実行します。

bundle exec rbs collection init

そうすると、rbs_collection.yamlというファイルが生成されます。これはBundlerでいうGemfileの様なものです。少し違う点として基本的にこのファイルは余り編集する必要がありません。次にgem_rbs_collectionの型情報をインストールします。

bundle exec rbs collection install

このコマンドでBundlerがインストールしているrubygemsの情報を自動的に検出して対応するgemのrbsを自動的にインストールしてくれます。 (存在するなら)

gem_rbs_collectionからインストールして欲しくない場合は、先程生成されたrbs_collection.yamlを編集することでignoreを指定することができます。

インストールが終わったらBundlerの様にrbs_collection.lock.yamlが生成されます。Gemfile.lockみたいなものですね。

Steepでgem_rbs_collectionを利用する

Steepはgem_rbs_collectionに対応しており、collection_config の引数にrbs_collection.yamlのファイル名を渡すことでgem_rbs_collectionからインストールしたrbsを認識します。以下の様に記述します。

target :app do
  check "lib"
  signature "sig"

  collection_config "rbs_collection.yaml"
end

これで、大体書くまでの事前準備は完了です。後はsigディレクトリ以下にゴリゴリと型を書いていくだけです。

neovimでrbsを書くために

実際型を書いていくと、逐一ターミナルでsteep checkとか実行してエラーを確認するのは面倒臭くなります。またrbsrubyコードの外側にあるためファイルの切り替えも頻繁に行うことになります。そのため、それらを支援し更に型を書くことで得られる恩恵を享受できる様にエディタを設定しておかないと、いまいち旨味がありません。

rbsファイルとの切り替え

rbsとの切り替えはファイル名のパターンでファイルを切り替えられるvim pluginが昔から色々あるので、それを設定しておくと良いでしょう。vim-altrとかother.nvimなどが利用できます。

自分は最近other.nvimを利用しています。設定内容は以下の様な感じです。(最近neovimのプラグインluaで書かれていることが多く設定もluaで行います)

      local rails_controller_patterns = {
        { target = "/spec/controllers/%1_spec.rb", context = "spec" },
        { target = "/spec/requests/%1_spec.rb", context = "spec" },
        { target = "/spec/factories/%1.rb", context = "factories", transformer = "singularize" },
        { target = "/app/models/%1.rb", context = "models", transformer = "singularize" },
        { target = "/app/views/%1/**/*.html.*", context = "view" },
      }
      require("other-nvim").setup({
        mappings = {
          {
            pattern = "/app/models/(.*).rb",
            target = {
              { target = "/spec/models/%1_spec.rb", context = "spec" },
              { target = "/spec/factories/%1.rb", context = "factories", transformer = "pluralize" },
              { target = "/app/controllers/**/%1_controller.rb", context = "controller", transformer = "pluralize" },
              { target = "/app/views/%1/**/*.html.*", context = "view", transformer = "pluralize" },
            },
          },
          {
            pattern = "/spec/models/(.*)_spec.rb",
            target = {
              { target = "/app/models/%1.rb", context = "models" },
            },
          },
          {
            pattern = "/spec/factories/(.*).rb",
            target = {
              { target = "/app/models/%1.rb", context = "models", transformer = "singularize" },
              { target = "/spec/models/%1_spec.rb", context = "spec", transformer = "singularize" },
            },
          },
          {
            pattern = "/app/services/(.*).rb",
            target = {
              { target = "/spec/services/%1_spec.rb", context = "spec" },
            },
          },
          {
            pattern = "/spec/services/(.*)_spec.rb",
            target = {
              { target = "/app/services/%1.rb", context = "services" },
            },
          },
          {
            pattern = "/app/controllers/.*/(.*)_controller.rb",
            target = rails_controller_patterns,
          },
          {
            pattern = "/app/controllers/(.*)_controller.rb",
            target = rails_controller_patterns,
          },
          {
            pattern = "/app/views/(.*)/.*.html.*",
            target = {
              { target = "/spec/factories/%1.rb", context = "factories", transformer = "singularize" },
              { target = "/app/models/%1.rb", context = "models", transformer = "singularize" },
              { target = "/app/controllers/**/%1_controller.rb", context = "controller", transformer = "pluralize" },
            },
          },
          {
            pattern = "/lib/(.*).rb",
            target = {
              { target = "/spec/%1_spec.rb", context = "spec" },
              { target = "/sig/%1.rbs", context = "sig" },
            },
          },
          {
            pattern = "/sig/(.*).rbs",
            target = {
              { target = "/lib/%1.rb", context = "lib" },
              { target = "/%1.rb" },
            },
          },
          {
            pattern = "/spec/(.*)_spec.rb",
            target = {
              { target = "/lib/%1.rb", context = "lib" },
              { target = "/sig/%1.rbs", context = "sig" },
            },
          },
        },
      })

      local wk = require "which-key"
      wk.register({
        ["<leader>o"] = {
          name = "+Other",
        },
      })
      vim.keymap.set("n", "<F3>", "<cmd>OtherClear<CR><cmd>:Other<CR>")
      vim.keymap.set("n", "<leader>os", "<cmd>OtherClear<CR><cmd>:OtherSplit<CR>")
      vim.keymap.set("n", "<leader>ov", "<cmd>OtherClear<CR><cmd>:OtherVSplit<CR>")

F3を押すとこんな感じでポップアップが出ます。すぐにrbsに移動できて便利です。

SteepのLanguage Serverをnvimで使う

RubyKaigi 2022のデモでも紹介されていましたが、SteepにはLanguage Serverが実装されていてエディタ上に直接型エラーを表示したり、メソッドの型シグネチャを補完候補に表示したりできます。VSCodeでデモが行われていましたが、neovimでも十分に実現可能です。多少設定が必要ですが、そのための流れを紹介していきます。

まず、以下のプラグインをインストールしましょう。インストール方法は各自好きなパッケージマネージャーを利用してください。私は最近はpacker.nvimを利用しています。

大体この辺りがあればOKです。Rubyに限らずその他のLanguage Serverも利用したい場合は、mason.nvimmason-lspconfig.nvimもインストールしましょう。これらはLanguage Serverのインストーラーとそれらをnvim-lspconfigを使って動かす際に設定しやすくしてくれるフック機構を提供してくれるプラグインです。

一応、Steepもmasonでインストールできるんですが、プロジェクトの外部にインストールするよりBundlerを使ってインストールした方が扱い易いので、私はRubyに関してはmasonは活用していません。

実際にSteepのLanguage Serverを利用する設定は以下の様になります。 (自分の設定から関係する箇所だけ抜き出したので、微妙に間違ってるかもしれない……)

vim.keymap.set("n", "[d", vim.diagnostic.goto_prev, {silent = true})
vim.keymap.set("n", "]d", vim.diagnostic.goto_next, {silent = true})

-- The nvim-cmp almost supports LSP's capabilities so You should advertise it to LSP servers..
local capabilities = vim.lsp.protocol.make_client_capabilities()
capabilities = require("cmp_nvim_lsp").update_capabilities(capabilities)

-- Use an on_attach function to only map the following keys
-- after the language server attaches to the current buffer
---@diagnostic disable-next-line: unused-local
local on_attach = function(client, bufnr)
  local bufopts = { noremap = true, silent = true, buffer = bufnr }
  vim.keymap.set("n", "gD", vim.lsp.buf.declaration, bufopts)
  vim.keymap.set("n", "gd", "<cmd>Lspsaga peek_definition<CR>", bufopts)
  vim.keymap.set("n", "gh", "<cmd>Lspsaga hover_doc<CR>", bufopts)
  vim.keymap.set("n", "gs", "<cmd>Lspsaga lsp_finder<CR>", bufopts)
  vim.keymap.set("n", "gi", vim.lsp.buf.implementation, bufopts)
  vim.keymap.set("n", "gr", vim.lsp.buf.references, bufopts)
  vim.keymap.set("n", "gt", vim.lsp.buf.type_definition, bufopts)
  vim.keymap.set("n", "<C-k>", vim.lsp.buf.signature_help, bufopts)
  vim.keymap.set("n", "<space>wa", vim.lsp.buf.add_workspace_folder, bufopts)
  vim.keymap.set("n", "<space>wr", vim.lsp.buf.remove_workspace_folder, bufopts)
  vim.keymap.set("n", "<space>wl", function()
    print(vim.inspect(vim.lsp.buf.list_workspace_folders()))
  end, bufopts)
  vim.keymap.set("n", "<space>rn", "<cmd>Lspsaga rename<CR>", bufopts)
  vim.keymap.set("n", "<F6>", "<cmd>Lspsaga rename<CR>", bufopts)
  vim.keymap.set("n", "<space>ca", "<cmd>Lspsaga code_action<CR>", bufopts)
  vim.keymap.set("v", "<space>ca", "<cmd><C-U>Lspsaga range_code_action<CR>", bufopts)
  vim.keymap.set("n", "<space>cd", "<cmd>Lspsaga show_line_diagnostics<CR>", bufopts)
  vim.keymap.set("n", "[e", "<cmd>Lspsaga diagnostic_jump_next<CR>", bufopts)
  vim.keymap.set("n", "]e", "<cmd>Lspsaga diagnostic_jump_prev<CR>", bufopts)
  vim.keymap.set("n", "[E", function()
    require("lspsaga.diagnostic").goto_prev({ severity = vim.diagnostic.severity.ERROR })
  end, bufopts)
  vim.keymap.set("n", "]E", function()
    require("lspsaga.diagnostic").goto_next({ severity = vim.diagnostic.severity.ERROR })
  end, bufopts)
  vim.keymap.set("n", "<space>cf", function()
    vim.lsp.buf.format({ async = true })
  end, bufopts)
end

local lspconfig = require "lspconfig"

-- SteepのLanguage Serverを起動するための設定
-- デフォルトの設定をいくつか上書きしている
lspconfig.steep.setup({
  -- 補完に対応したcapabilitiesを渡す
  capabilities = capabilities,
  on_attach = function(client, bufnr)
    -- LSP関連のキーマップの基本定義
    on_attach(client, bufnr)
    -- Steepで型チェックを再実行するためのキーマップ定義
    vim.keymap.set("n", "<space>ct", function()
      client.request("$/typecheck", { guid = "typecheck-" .. os.time() }, function()
      end, bufnr)
    end, { silent = true, buffer = bufnr })
  end,
  on_new_config = function(config, root_dir)
    config.cmd = {"bundle", "exec", "steep", "langserver"}
    return config
  end,
})

-- ここからnvim-cmpの補完設定
local cmp = require "cmp"
cmp.setup({
  sources = cmp.config.sources({
    { name = "nvim_lsp" },
    { name = "nvim_lsp_signature_help" },
  }),

  mapping = cmp.mapping.preset.insert({
    ["<C-e>"] = cmp.mapping.abort(),
    ["<CR>"] = cmp.mapping.confirm({ select = true }), -- Accept currently selected item. Set `select` to `false` to only confirm explicitly selected items.
  }),
})

基本的には他のLanguage Serverでもほとんど設定内容は変わらないのですが、Steep特有の設定としてはbundle execを経由する様にコマンド定義を上書きしていることと、$/typecheckという独自のメッセージをリクエストすることで型検査を再実行するためのキーマップを定義している所です。

動作サンプル

実際に手元のプロジェクトに型を書いてみてneovimで型検査をしているデモです。今日紹介したもの以外にも色々プラグインが入ってます。

型エラーが行の横に表示されていて、修正して再実行すれば消えているのが分かります。

また補完候補にシグネチャやドキュメントを出したり、ホバーで型定義を表示したりrbsをプレビューしたりも出来ます。VSCodeに負けてないなって感じがしますね。

型を書くためのドキュメント

基本はrbsリポジトリの方を見ましょう。細かい書式は https://github.com/ruby/rbs/blob/master/docs/syntax.md を見るのが良さそうです。

またSteepには独自の拡張があり、アノテーションという仕組みでローカル変数の型を明示することが出来ます。ドキュメントは https://github.com/soutaro/steep/blob/master/manual/annotations.md にあります。

実際に書いてみて

今回、試しに型を書いてみたのは、自分が昔作ったcrono_triggerというgemです。コードの規模はそれ程では無いですが、ActiveRecordを利用していてRailsの機能をバリバリ使っているgemです。現実のプロジェクトに近そうなので対象にしてみました。

実際にどういう風にSteepfileを設定して型を書いたのかは https://github.com/joker1007/crono_trigger/tree/steep にあります。 (まだブランチなのでその内マージして消えてるかも)

書いてみて感じたのは、Railsに型を付けるのはやはり中々容易ではないということを実感しました。

特にこのgemはActiveSupport::Concernを利用していて、ActiveRecord::Baseのサブクラスにincludeされることが前提になっているコードです。更に内部にClassMethodsモジュールなどがあり、そこではActiveRecord::Baseのシングルトンクラスで使えるメソッドの存在が前提になっています。

当然これらを自動的に判別することは現時点では出来ないため、どうにかして型定義を引っ張ってきたいのですが、現状だと良い方法がよく分かりません。特にシングルトンクラスのメソッド定義をgem_rbs_collectionから引っ張ってくる方法は現時点では多分無いんじゃないだろうか。

そのため、これらの型エラーがノイズになって非常に沢山出力されます。今回、gem_rbs_collectionを参照して型定義をコピーしてきて自分で定義することで型エラーを可能な限り削除し、SteepのDiagnosticモードを緩くするlenient presetを使ったり、どうにもならないファイルを無視することでノイズにならない程度に型エラーを削減することが出来ました。

今の実行結果はこんな感じ。

Detected 15 problems from 6 files

これなら役に立たせることは出来そうです。実際いくつかおかしいなと思う箇所を発見することができました。

しかし、現状のやり方で書いた型定義を、他のコードから参照した時にActiveRecord自体の型定義と衝突しないかとか、scopeで定義したメソッドで返す型をActiveRecord::Relationにするべきか_ActiveRecord_Relation[Model, Key]にするべきかとか、色々とよく分かっていない点があります。 またメソッドのシグネチャ自体もまだまだ発展途上です。

今回書いていて、自分でもすぐに組込みライブラリ用の型シグネチャが足りていないケースなどが見つかったので、プルリクエストを出したりしていました。こうやってコツコツとプルリクエストを出していくことでちょっとづつ充実させていくことが出来るでしょう。

しばらくRubyから離れていてあんまり活動できていなかったんですが、Kaigi Effectの結果またやる気が戻ってきてちょっとしたOSS活動に繋げることが出来たので、やはりRubyKaigiという現場は良いものだなと改めて感じました。

まだ多少ハードルはあると思いますが、型の恩恵を得るのに十分な準備は整いつつあると思います。RubyKaigiで登壇者の方々が言っていた様に、コミュニティの力は非常に重要だと思うので、とりあえず触ってみて皆でプルリク出していきましょう。