igsr5 のブログ

呟きたい時に呟きます

Shopify/ruby-lsp の内部実装を読み解く (2) mtsmfm/language_server-protocol-ruby 編

概要

個人的に最近アツい https://github.com/Shopify/ruby-lsp の内部実装を読み解いて追加機能の提案・実装が出来るレベルを目指す。

なおこの回以外のブログは https://d.hatena.ne.jp/keyword/reading-shopify-ruby-lsp#related-blog で見ることができる。

前提

筆者に LSP (Language Server Protocol) の知識はほとんどない。クライアントとして利用しているのでどういったものかは知っているが、その内部実装には明るくない。なのでこの記事を読んでいる人で内容の誤りに気がついたらコメントして頂けるとありがたい。

また Ruby および TypeScript はそれなりに歴があるのでコードを読むことに不自由はない。

やること

https://github.com/Shopify/ruby-lsp のコミットを古い方から読んでいく。こういったコードリーディングはやったことないが、単に最新のソースコードを読むよりも Shopify の優秀なエンジニアの手癖を見ることが出来て学びが多そうなので実践してみる。

なお、あまり上手くいかなかったら違うアプローチに変えるつもり。

本編

前回に引き続き、今回は https://github.com/mtsmfm/language_server-protocol-ruby のコードリーディングを行う。

launguage_server-protocol-rubyRuby 向けの LSP SDK である。本筋はあくまで Shopify/ruby-lsp なので今回はサクッとコードを読んで実装・役割を把握しておく。

全体像

このライブラリで重要なモジュールは以下の3つ。

  • LanguageServer::Protocol::Transport モジュール
  • LanguageServer::Protocol::Interface モジュール
  • LanguageServer::Protocol::Constant モジュール

LanguageServer::Protocol::Transport モジュール

LanguageServer::Protocol::Transport::Io::Reader クラスおよび LanguageServer::Protocol::Transport::Io::Writer クラスの実装を持っているモジュール。

これらのクラスは非常にシンプルで Reader クラスは JSON-RPC リクエストの読み込みを、Writer クラスは JSON-RPC レスポンスの書き込みを、それぞれ任意のIOで行う責務をもつ。このことは以下のサンプルコードを見るとわかりやすい。

require "language_server-protocol"

LSP = LanguageServer::Protocol
writer = LSP::Transport::Stdio::Writer.new
reader = LSP::Transport::Stdio::Reader.new

subscribers = {
  initialize: -> {
    LSP::Interface::InitializeResult.new(
      capabilities: LSP::Interface::ServerCapabilities.new(
        text_document_sync: LSP::Interface::TextDocumentSyncOptions.new(
          change: LSP::Constant::TextDocumentSyncKind::FULL
        ),
        completion_provider: LSP::Interface::CompletionOptions.new(
          resolve_provider: true,
          trigger_characters: %w(.)
        ),
        definition_provider: true
      )
    )
  }
}

reader.read do |request|
  result = subscribers[request[:method].to_sym].call
  writer.write(id: request[:id], result: result)
  exit
end

この例では標準入出力を指定IOとしているため以下のようなコマンドでプログラムを実行することができる。

$ echo "Content-Length: 55\r\n\r\n{\"jsonrpc\":\"2.0\",\"method\":\"initialize\",\"params\":{}}" | ruby ./test/example.rb

Content-Length: 185

{"id":null,"result":{"capabilities":{"textDocumentSync":{"change":1},"completionProvider":{"triggerCharacters":["."],"resolveProvider":true},"definitionProvider":true}},"jsonrpc":"2.0"}%

LanguageServer::Protocol::Interface モジュール

このモジュールには LSPの仕様において定義されている LSP メソッドのインターフェースやデータ構造(リクエストやレスポンスのフォーマットなど)が含まれている。

例えば LSP::Interface::ServerCapabilities というクラスは microsoft/language-server-protocol の _specifications/lsp/3.18/general/initialize.md の定義が元になっている。

module LanguageServer
  module Protocol
    module Interface
      class ServerCapabilities
        def initialize # 引数は省略
          @attributes = {}

          @attributes[:positionEncoding] = position_encoding if position_encoding
          # 省略

          @attributes.freeze
        end

        #
        # The position encoding the server picked from the encodings offered
        # by the client via the client capability `general.positionEncodings`.
        #
        # If the client didn't provide any position encodings the only valid
        # value that a server can return is 'utf-16'.
        #
        # If omitted it defaults to 'utf-16'.
        #
        # @return [string]
        def position_encoding
          attributes.fetch(:positionEncoding)
        end

        # 省略
    end
  end
end
interface ServerCapabilities {

    /**
    * The position encoding the server picked from the encodings offered
    * by the client via the client capability `general.positionEncodings`.
    *
    * If the client didn't provide any position encodings the only valid
    * value that a server can return is 'utf-16'.
    *
    * If omitted it defaults to 'utf-16'.
    *
    * @since 3.17.0
    */
    positionEncoding?: PositionEncodingKind;

    /**
    * Defines how text documents are synced. Is either a detailed structure
    * defining each notification or for backwards compatibility the
    * TextDocumentSyncKind number. If omitted it defaults to
    * `TextDocumentSyncKind.None`.
    */
    textDocumentSync?: TextDocumentSyncOptions | TextDocumentSyncKind;

    // 省略
}

なおこれらの Ruby プログラムは人力で書かれているわけではなく https://github.com/microsoft/language-server-protocol から自動生成されている。自動生成の仕組みについては後述する。

LanguageServer::Protocol::Constant モジュール

このモジュールには LSPの仕様において定義されている定数(エラーコード、イベントの種類、操作の種類など)が含まれている。

例えば LSP::Constant::TextDocumentSyncKind::FULL という定数は microsoft/language-server-protocol の _specifications/lsp/3.18/specification.md の定義が元になっている。

module LanguageServer
  module Protocol
    module Constant
      #
      # Defines how the host (editor) should sync document changes to the language
      # server.
      #
      module TextDocumentSyncKind
        #
        # Documents should not be synced at all.
        #
        NONE = 0
        #
        # Documents are synced by always sending the full content
        # of the document.
        #
        FULL = 1
        #
        # Documents are synced by sending the full content on open.
        # After that only incremental updates to the document are
        # sent.
        #
        INCREMENTAL = 2
      end
    end
  end
end
/**
 * Defines how the host (editor) should sync document changes to the language
 * server.
 */
export namespace TextDocumentSyncKind {
    /**
    * Documents should not be synced at all.
    */
    export const None = 0;

    /**
    * Documents are synced by always sending the full content
    * of the document.
    */
    export const Full = 1;

    /**
    * Documents are synced by sending the full content on open.
    * After that only incremental updates to the document are
    * sent.
    */
    export const Incremental = 2;
}

export type TextDocumentSyncKind = 0 | 1 | 2;

こちらも https://github.com/microsoft/language-server-protocol から自動生成されている(後述)。

自動生成

前述の LanguageServer::Protocol::Interface モジュールと LanguageServer::Protocol::Constant モジュールは LSP の公式仕様である https://github.com/microsoft/language-server-protocol から自動生成されており、そのロジックは https://github.com/mtsmfm/language_server-protocol-ruby/blob/v3.17.0.3/scripts/generateFiles.ts で見ることができる。

見るとわかるが、なぜか LSP の仕様は markdownmarkdown 内のコードブロック (TS) で管理されており、このスクリプトはそれらの markdown を気合いでパースして Ruby プログラムを出力する内容になっている。

一方で LSP 3.17 からは markdown ではなく json で仕様を管理する動きも見られる。mtsmfm/language_server-protocol-ruby もその流れに乗っかって脱 markdown パースを目指しているが 2023/12 時点では json 側の仕様が一部欠けているため実用には至っていない様子。 参考: https://github.com/mtsmfm/language_server-protocol-ruby/pull/49#issuecomment-1247458822

参考資料