Ruby LSP が textDocument/hover リクエストを処理する流れ

リクエストの振り分け

RubyLsp::Server#process_message でリクエストの振り分けが書かれている。

https://github.com/Shopify/ruby-lsp/blob/v0.23.11/lib/ruby_lsp/server.rb#L13

今回見るのは textDocument/hover なので以下箇所から。

https://github.com/Shopify/ruby-lsp/blob/v0.23.11/lib/ruby_lsp/server.rb#L52-L53

引数で渡ってくる message のデータ構造が気になった。Sorbet の型だと Hash 以上の情報がない。

sig { override.params(message: T::Hash[Symbol, T.untyped]).void }

気になるので print debug しておいた。 textDocument/hover で実際に飛んでくるものはこんな感じ。

{jsonrpc: "2.0",
 id: 13,
 method: "textDocument/hover",
 params:
  {textDocument:
    {uri:
      #<URI::File file:///path/to/file.rb>},
   position: {line: 5, character: 18}}}

text_document_hover メソッド

https://github.com/Shopify/ruby-lsp/blob/v0.23.11/lib/ruby_lsp/server.rb#L708

レスポンスの生成は Requests::Hover を new して、そちらに処理を委譲している。

@store.get() で取得されるオブジェクトの型が気になるところだけど、Requests::Hover の方で型定義がありそうなのでそちらを確認する。

document = @store.get(params.dig(:textDocument, :uri))

Requests::Hover.new

https://github.com/Shopify/ruby-lsp/blob/v0.23.11/lib/ruby_lsp/requests/hover.rb#L25-L34

document は RubyDocument もしくは ERBDocument だった。

document: T.any(RubyDocument, ERBDocument),

delegate_request_if_needed! は ERB ファイルにおいて埋め込み Ruby コードではない場合は Host Language (= HTML) 側の Language Server にリクエストを委譲するかどうかの判定。

delegate_request_if_needed!(global_state, document, char_position)

おそらくこのメソッドの主たる処理は Listeners::Hover の new メソッドを呼び出すこと。

node_context = RubyDocument.locate(
  document.parse_result.value,
  char_position,
  node_types: Listeners::Hover::ALLOWED_TARGETS,
  code_units_cache: document.code_units_cache,
)
uri = document.uri
@response_builder = T.let(ResponseBuilders::Hover.new, ResponseBuilders::Hover)
Listeners::Hover.new(@response_builder, global_state, uri, node_context, dispatcher, sorbet_level)

node_context が何かはそのクラスのコメントに書いてあった。通常 AST の node 単体からだとその親 node へのアクセスが困難(できない認識)だけど、そういったアクセスを可能にするためのものと解釈した。

https://github.com/Shopify/ruby-lsp/blob/v0.23.11/lib/ruby_lsp/node_context.rb#L5C1-L7

# This class allows listeners to access contextual information about a node in the AST, such as its parent,
# its namespace nesting, and the surrounding CallNode (e.g. a method call).

Listeners::Hover.new

https://github.com/Shopify/ruby-lsp/blob/v0.23.11/lib/ruby_lsp/listeners/hover.rb#L52-L62

dispather.register しているので特定の node 時にそれぞれのメソッドが呼ばれる。

dispatcher.register(
  self,
  :on_constant_read_node_enter,
  :on_constant_write_node_enter,
  :on_constant_path_node_enter,

クラス参照(定数)に hover した時

イメージはこう。

dispatcher を動く箇所は以下なので、ここの @target を print debug する。

https://github.com/Shopify/ruby-lsp/blob/v0.23.11/lib/ruby_lsp/requests/hover.rb#L77

出力された結果は以下。

@ ConstantReadNode (location: (6,16)-(6,20)

なので on_constant_read_node_enter メソッドを見る。

on_constant_read_node_enter メソッド

https://github.com/Shopify/ruby-lsp/blob/v0.23.11/lib/ruby_lsp/listeners/hover.rb#L114

やっていることは単純だった。

name = RubyIndexer::Index.constant_name(node)
return if name.nil?

generate_hover(name, node.location)

実際の name は hover したクラス名そのものが入っている(のでキャプチャの例だと "Base" となる)

generate_hover メソッド

https://github.com/Shopify/ruby-lsp/blob/v0.23.11/lib/ruby_lsp/listeners/hover.rb#L378

entries を取り出して、 @response_builder に値を詰め込んでいる。

entries = @index.resolve(name, @node_context.nesting)
categorized_markdown_from_index_entries(name, entries).each do |category, content|
  @response_builder.push(content, category: category)
end

この entries がどういったデータなのかを確認した。

[
#<RubyIndexer::Entry::Class:0x0000000157a18350
  @comments=nil,
  @location=
   #<RubyIndexer::Location:0x00000001499f52f0
    @end_column=5,
    @end_line=109,
    @start_column=2,
    @start_line=5>,
  @name="Logicuit::Base",
  @name_location=
   #<RubyIndexer::Location:0x00000001499f52a0
    @end_column=12,
    @end_line=5,
    @start_column=8,
    @start_line=5>,
  @nesting=["Logicuit", "Base"],
  @parent_class="::Object"
  @uri=
   #<URI::Generic file:///path/to/file.rb>,
  @visibility=#<RubyIndexer::Entry::Visibility::PUBLIC>>]

RubyIndexer クラスによってコードベースを解析した情報がインデックスとして保持されていると解釈。つまりは LSP Client からのリクエストに対してこのインデックスを使って適切な情報をレスポンスしてあげると言うのが LSP Server の役目と表現できそう。

ちなみに categorized_markdown_from_index_entries の each ブロックでの引数 content を出力すると以下のような結果が得られた。

"```ruby\n" + "Base\n" + "```"
"**Definitions**: [base.rb](file:///path/to/file.rb#L5,3-109,6)"
"\n" + "\n" + "base class for all gates and circuits"

hover で表示されていた情報と完全に一致。

次回

RubyIndexer クラスがどうやってインデックスを作成しているのかを確認する。