🐙

快適な環境を求めてShopify製のruby-lspに移行した話

2024/12/07に公開

この記事はSmartHR Advent Calendar 2024シリーズ2の7日目の記事です。

はじめに

普段の開発ではNeovimでLanguage Serverのサポートを受けて開発をしており、これにより作業効率の向上を図っております。
仕事ではRailsを使用しており、またコードベースは膨大になっています。

これまでLanguage Serverにsolargraphを採用していましたが、大規模なコードベースだと後述するストレスを感じるようになりました。
しばらく放置していたのですが、常にストレスがある状態で開発していては辛いので、一念発起して解決策を探しました。

この記事では、感じていた不満を解消するためにShopify製のruby-lspに移行した経緯を話していきます。

現状の環境

  • macOS
  • Neovim 0.10.1
  • solargraph 0.50.0
  • nvim-lspconfig c646154

ruby-lspとは

Improving the Developer Experience with the Ruby LSPに書いてあるとおり、Shopify製のRubyのLSPです。
2024/12月時点で、定義ジャンプやホバー、補完や診断・フォーマットなどの機能を提供しており、solargraph相当の機能を提供しています。

Two major goals for the Ruby LSP are performance and stability. Developers tend to have high expectations when it comes to how responsive their editor feels. In order to meet these expectations in Shopify’s Core monolith, the Ruby LSP has to be able to efficiently handle and analyze thousands of files, which may sometimes be thousands of lines long.

また大規模なコードベースでも適切なパフォーマンスを提供したいと考えているようで、今回感じていたストレスを解決してくれそうな期待感があります。

大規模コードベースで生じた不満

普段はsolargraphで問題ないのですが、大規模コードベースだとsolargraphの補完が遅くなることがありました。

では、ここからはどれくらい遅いかを比較していきます。

計測に使うスクリプトはこちらです。(非推奨になったNeovimのAPIを使っているのでバージョンによっては動かない可能性があります)
リクエスト送信時間とレスポンス受信時間を計測して、その差分をログに出力します。

nvim/init.lua
local log_file = vim.fn.stdpath("cache") .. "/manual_completion_time.log"

function measure_manual_completion_time()
  local start_time = vim.loop.hrtime() -- リクエスト送信の時間を記録する

  -- カーソルのある位置で補完の手続きをコールする
  vim.lsp.buf_request(0, "textDocument/completion", vim.lsp.util.make_position_params(), function(err, result)
    local end_time = vim.loop.hrtime() -- レスポンスの受信時間を記録する
    local duration = (end_time - start_time) / 1e6 -- ミリ秒に変換

    local log_entry
    if err or not result then
      log_entry = string.format("No completions found (%.2f ms)\n", duration)
    else
      local items = vim.lsp.util.extract_completion_items(result)
      local labels = {}
      for _, item in ipairs(items) do
        table.insert(labels, item.label)
      end
      -- 成功したら補完候補と所要時間を出力する
      log_entry = string.format("Completion time: %.2f ms\nLabels: %s\n", duration, table.concat(labels, ", "))
    end

    vim.fn.writefile({ log_entry }, log_file, "a")

    require("notify")(log_entry, "info")
  end)
end

vim.api.nvim_create_user_command("MeasureCompletionTime", function()
  measure_manual_completion_time()
end, {})

これ以降は、このスクリプトを使って計測していきます。

小規模なコードベースの場合

適当に手元にあった小規模なプロジェクトのpinが5000以内のプロジェクトで計測していきます。

> solargraph scan -v
Scanned hoge/project_name (4356 pins) in 1.792319000000134 seconds.

エディタを起動し直して:MeasureCompletionTimeを実行します。

> cat ~/.cache/manual_completion_time.log

Completion time: 7.10 ms Labels: 補完候補
Completion time: 3.45 ms Labels: 補完候補
Completion time: 3.07 ms Labels: 補完候補

大規模なコードベースの場合

次に、実際に筆者が開発に関わっている約20万のpinがマークされたプロジェクトで計測してみます。

> solargraph scan -v
Scanned hoge/work_project_name (204714 pins) in 668.1230309999992 seconds.

先ほどと同じように計測して、結果を確認します。

> cat ~/.cache/manual_completion_time.log

Completion time: 81814.11 ms Labels: 補完候補
Completion time: 28.85 ms Labels: 補完候補
Completion time: 25.84 ms Labels: 補完候補

小規模な場合と比較すると、初回だけ80秒以上かかっていることがわかります。
たまたまかと思い、エディタを起動し直してもう一度計測してみても、78621.07 msという結果でした。(もしかしたら他のプロセスとの兼ね合いで遅くなっている可能性は否定できません)
初回以外は許容範囲内の結果となっています。

実はsolargraph-railsも動いているので、solargraphだけにしてもう一度計測してみます。

> cat ~/.cache/manual_completion_time.log

Completion time: 27646.92 ms Labels: 補完候補
Completion time: 23.95 ms Labels: 補完候補

少し早くなりましたが、それでも遅いです。

さすがに起動直後とはいえ1分以上かかるのは許容できないので改善策を考えます。

パフォーマンス改善を試みる

まずはsolargraph公式のパフォーマンス向上のセクションを見ていきます。
ざっとアプローチをまとめると以下のようになります。

  • スキャン対象のファイルを限定する
  • 複数のワークスペースの場合分割する
  • YARDドキュメントの維持
  • 上記で解消しないならスキャンして確認する

一つずつ適応できるか見ていきます。

  • スキャン対象のファイルを限定する

すでにconfigファイルで設定しているので、もう少し設定の余地はありそうですが、劇的な変化は見込めなさそうです。

solargraph/config.yml
include:
- "**/*.rb"
exclude:
- spec/**/*
- test/**/*
- vendor/**/*
- ".bundle/**/*"
  • 複数のワークスペースの場合分割する
  • YARDドキュメントの維持

複数ワークスペースについては、強いて言うならモジュール分割している部分は除いてもよさそうですが、あまり限定しすぎるのも気が進まないので保留にします。
YARDドキュメントについては、プロジェクトに導入していないのでぱっとできないので保留にします。

  • 上記で解消しないならスキャンして確認する

すでに実行済みで約20万pinがマークされているので、不要なファイルを除外することはできそうです。
が、都度メンテナンスするのは避けたいので、他の方法を探します。

ここまで確認した結果、solargraphのままでは解決するのが難しいと判断し、冒頭のruby-lspを試すことにしました。

ruby-lspに移行する

では、ruby-lspに移行していきます。
ruby-lspをインストールして、nvim-lspconfigの設定をruby-lspに書き換えます。

gem install ruby-lsp
ruby-lsp -v # 0.22.1
local lspconfig = require('lspconfig')
lspconfig.ruby_lsp.setup({
  cmd = { "ruby-lsp" },
  filetypes = { "ruby" },
  root_dir = nvim_lsp.util.root_pattern("Gemfile", ".git"),
  init_options = {
    formatting = "auto",
  },
  single_file_support = true,
})

これで移行が完了したので、再計測します。

小規模なコードベースの場合

先程と同じ環境で、起動し直して:MeasureCompletionTimeを実行します。

> cat ~/.cache/manual_completion_time.log

Completion time: 16.41 ms Labels: 補完候補
Completion time: 11.43 ms Labels: 補完候補
Completion time: 4.00 ms Labels: 補完候補

solargraphのときよりも若干遅くなっていますが、誤差の範囲内で十分許容できます。。

大規模なコードベースの場合

こっちも起動し直して:MeasureCompletionTimeを実行します。

> cat ~/.cache/manual_completion_time.log

Completion time: 82.71 ms Labels: 補完候補
Completion time: 12.19 ms Labels: 補完候補
Completion time: 19.14 ms Labels: 補完候補

こっちは先程よりも大幅に改善していることがわかります。
これより速くタイピングをすることは難しいので、十分満足できる結果となりました。

補完候補の比較

補完候補を返す速度は改善されましたが、肝心の補完候補はどうでしょうか。

solargraphとruby-lspで補完候補を比較してみます。

solargraphの場合
"hoge".pre # ここで:MeasureCompletionTimeを実行して返ってくる補完候補を確認する
 ActiveRecord::Ba # ここで:MeasureCompletionTimeを実行して返ってくる補完候補を確認する

# 起動直後の補完候補
Completion time: 5.57 ms Labels: prepend, private_methods, protected_methods
Completion time: 13.26 ms Labels: 
ruby-lspの場合
"hoge".pre # ここで:MeasureCompletionTimeを実行して返ってくる補完候補を確認する
 ActiveRecord::Ba # ここで:MeasureCompletionTimeを実行して返ってくる補完候補を確認する

Completion time: 10.25 ms Labels: prepend, pretty_print
Completion time: 5.55 ms Labels: ActiveRecord::Base, ActiveRecord::Batches, ActiveRecord::Batches::BatchEnumerator, ActiveRecord::Batches::ORDER_IGNORE_MESSAGE, ActiveRecord::Batches::DEFAULT_ORDER

環境による差異はあるかもしれないですが、比較してみると、ruby-lspのほうはActiveRecordの補完候補が表示されていることがわかります。
個人的にはActive**Action**などの補完候補が表示されるのは嬉しいです。

定義ジャンプについて

実は以前にruby-lspを試したことがあるのですが、go-to-definitionのサポートが薄くて微妙だったのでsolargraphに戻したという経緯がありました。
が、現在は以下の記事によるとかなり改善されているようです。
https://achris.me/posts/solargraph-vs-ruby-lsp/

最後に

そもそもYARDを導入していない時点でsolargraphはマッチしていないのかもしれませんが、移行してパフォーマンスと補完候補の両方で期待するものだと確認ができました。

プロジェクトの特性やLSPに求めている機能次第では、ruby-lspも候補になるのではないでしょうか。

Discussion