RubyKaigiの復習 LSP

先日久しぶりに RubyKaigi に参加してきました。やっぱりオフラインはいいですね。noteはブースを出させてもらったので社外の方ともたくさん交流でき楽しかったです。

イベント参加レポートを書くのは苦手なので..理解できなかったものや、詳しく知らなかったものを復習して記事にしていこうと思います。

とりあえず第一弾は LSP について。
Matz のキーノート で Solargraph が、Shopify さんの syntax tree の発表で ruby-lsp が触れられていました。
(LSP については Microsoft のドキュメントをご参照ください)

私は Rubymine を使っているので あまり意識してこなかったのですが、今後ますます便利になりそうなので仕組みを理解しておきたいと思い Solargraph の中身を軽く見てました。

以下は Microsoft のドキュメントから拝借したLSP図です。
IDE と LSP サーバーが JSON-RPC で通信しています。

(VSCodeへのインストール方法は https://solargraph.org/guides/getting-started の通りです)

まずは Language サーバーの起動から見ていきます。
VSCode の拡張機能として入れた castwide/vscode-solargraph から microsoft/vscode-languageserver-node の LanguageClient クラスを経由して起動しているようです。

https://github.com/castwide/vscode-solargraph/blob/cc473c9b312f659db6e3a05cc60b5c4af90e2a98/src/language-client.ts#L117

let client = new LanguageClient('Ruby Language Server', serverOptions, clientOptions);

上記の serverOptions を辿っていくと Solargraph を起動し、コネクションを作成していることがわかります。

} else if (transport == 'socket') {
    return () => {
        return new Promise((resolve, reject) => {
            let socketProvider: solargraph.SocketProvider = new solargraph.SocketProvider(configuration);
            socketProvider.start().then(() => {
                let socket: net.Socket = net.createConnection(socketProvider.port);
                resolve({
                    reader: socket,
                    writer: socket
                });
            }).catch((err) => {
                reject(err);
            });
        });
    };


さて VSCode 側からの起動、接続がわかったので Solargraph gem を見ていきます。

さきほど VSCode から叩いた起動コマンドはこちらです。

https://github.com/castwide/solargraph/blob/fccb6e58634d1e8615a6215e93f7ee97a556dcc4/lib/solargraph/shell.rb#L19

    def socket
      require 'backport'
      port = options[:port]
      port = available_port if port.zero?
      Backport.run do
        Signal.trap("INT") do
          Backport.stop
        end
        Signal.trap("TERM") do
          Backport.stop
        end
        Backport.prepare_tcp_server host: options[:host], port: port, adapter: Solargraph::LanguageServer::Transport::Adapter
        STDERR.puts "Solargraph is listening PORT=#{port} PID=#{Process.pid}"
      end
    end

Solargraph は Backport という gem を使って TCPServer を立ち上げています。

Backport からは Adapter を介してデータを受け取ります。
Adapter には、起動時に呼ばれるメソッド(opening)、停止時に呼ばれるメソッド(closing)、データ受信時に呼ばれるメソッド(receiving) などを定義します。

ここではデータ受信時に呼ばれるメソッド(receiving)を見ていきます

https://github.com/castwide/solargraph/blob/2d57326d55b8a7deeec32d79f09162199eb2efb8/lib/solargraph/language_server/transport/adapter.rb#L26-L28

def receiving data
  @data_reader.receive data
end

receiving で受けとったデータは MessageWorker というワーカーで処理されます。

https://github.com/castwide/solargraph/blob/d3632696ece30f6ae9fe939c9e1b28675e6327d0/lib/solargraph/language_server/host/message_worker.rb#L48-L55

def tick
  message = @mutex.synchronize do
    @resource.wait(@mutex) if messages.empty?
    messages.shift
  end
  handler = @host.receive(message)
  handler && handler.send_response
end

上記の @host .receive の中でLSPのメッセージに対応したハンドラーが生成されメッセージを処理します。
以下がハンドラーの一部です。

https://github.com/castwide/solargraph/blob/d3632696ece30f6ae9fe939c9e1b28675e6327d0/lib/solargraph/language_server/message.rb#L58-L58

register 'textDocument/didOpen',                TextDocument::DidOpen
register 'textDocument/didChange',              TextDocument::DidChange
register 'textDocument/didSave',                TextDocument::DidSave
register 'textDocument/didClose',               TextDocument::DidClose
register 'textDocument/hover',                  TextDocument::Hover

例えばホバーの場合は、 TextDocument::Hover の process メソッドで処理されます

https://github.com/castwide/solargraph/blob/d3632696ece30f6ae9fe939c9e1b28675e6327d0/lib/solargraph/language_server/message/text_document/hover.rb#L8-L8

def process
  line = params['position']['line']
  col = params['position']['character']
  contents = []
  suggestions = host.definitions_at(params['textDocument']['uri'], line, col)
  last_link = nil
  suggestions.each do |pin|
    parts = []
    this_link = host.options['enablePages'] ? pin.link_documentation : pin.text_documentation
    if !this_link.nil? && this_link != last_link
      parts.push this_link
    end
    parts.push "`#{pin.detail}`" unless pin.is_a?(Pin::Namespace) || pin.detail.nil?
    parts.push pin.documentation unless pin.documentation.nil? || pin.documentation.empty?
    unless parts.empty?
      data = parts.join("\n\n")
      next if contents.last && contents.last.end_with?(data)
      contents.push data
    end
    last_link = this_link unless this_link.nil?
  end
  set_result(
    contents_or_nil(contents)
  )

何やらサジェストを用意してそうです。

最後に処理した結果を JSON-RPC で VSCode へ返します。
(ここではキューに積んで、最終的に Adapter の write メソッドでクライアントに送ります)

https://github.com/castwide/solargraph/blob/d3632696ece30f6ae9fe939c9e1b28675e6327d0/lib/solargraph/language_server/message/base.rb#L62-L85

def send_response
  return if id.nil?
  if host.cancel?(id)
    # https://microsoft.github.io/language-server-protocol/specifications/specification-current/#cancelRequest
    # cancel should send response RequestCancelled
    Solargraph::Logging.logger.info "Cancelled response to #{method}"
    set_result nil
    set_error ErrorCodes::REQUEST_CANCELLED, "cancelled by client"
  else
    Solargraph::Logging.logger.info "Sending response to #{method}"
  end
  response = {
    jsonrpc: "2.0",
    id: id,
  }
  response[:result] = result unless result.nil?
  response[:error] = error unless error.nil?
  response[:result] = nil if result.nil? and error.nil?
  json = response.to_json
  envelope = "Content-Length: #{json.bytesize}\r\n\r\n#{json}"
  Solargraph.logger.debug envelope
  host.queue envelope
  host.clear id
end

以上で一通りメッセージ処理が確認できました。
オブザーバーパターンを使っているところで少し迷いましたが、そんなに複雑ではなく読みやすかったです。


VSCodeを使用しているチームメンバーに Solargraph について聞いたところ現状はそこまで活用できてないとのことでしたが、今後ますます便利になることを期待しています。

Solargraph のプラグインも書けるようなので何か独自ルールも定義できるといいかもしれないなと思いました。
(サンプルコードのままでは動かなかったのでその旨を伝えてみました https://github.com/castwide/solargraph/issues/596

ということで第1弾の復習は以上です。

この記事が気に入ったらサポートをしてみませんか?