igsr5 のブログ

呟きたい時に呟きます

Shopify/ruby-lsp の内部実装を読み解く (3) v0.0.2 の変更箇所を途中まで

概要

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

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

前提

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

やること

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

追記)分量が多くなってきたので第3回からはコミット単位ではなく、気になるPR単位で読む。

今回の内容

前回https://github.com/mtsmfm/language_server-protocol-ruby のコードリーディングを実施していた。今回は Shopify/ruby-lsp に戻ってコードリーディングを進めていく。

ここまでで v0.0.1 は見終わったので次は v0.0.2。

github.com

本編

Re-organize code to VSCode standards by mutecipher · Pull Request #2 · Shopify/ruby-lsp · GitHub

Ruby::Lsp::Handler クラスの実装が追加されている。このクラスは https://github.com/mtsmfm/language_server-protocol-ruby のサンプルコードをリファクタリングしたものになっている。変わらず JSON RPC のIOには標準入出力が利用されていて一瞬「あれ?」となったがどうやらこれが一般的らしい

通信は基本的に標準入出力を介して行われます。なので、両者のプロセスは完全に独立している訳ではなく、サーバーはクライアントの子プロセスとして起動されるのが普通です[1]。

実際のところ、LSPには通信方式についての規定はありません。標準入出力の代わりにTCPを使う例もあります。従ってサーバーが子プロセスにならないこともあり得ますが、実例に乏しいためこのように記述しています。

Remove VSCode client logic by mutecipher · Pull Request #6 · Shopify/ruby-lsp · GitHub

Shopify/ruby-lsp の内部実装を読み解く (1) v0.0.1 の変更箇所、LSP の基本動作、LSIF (Language Server Index Format) - igsr5 のブログ で「この時はまだ VSCode Extension (のちの Shopify/vscode-ruby-lsp)も同じリポジトリに同封されているのかー」と言っていたが #6 の PR でリポジトリを分けることが決まっていた。意外と早かった。

Add support for folding defs by RyanBrushett · Pull Request #10 · Shopify/ruby-lsp · GitHub

この PR では以下の 4 つの method の実装が行われている。

handler.config do
  on("initialize") do
    store.clear
    respond_with_capabilities
  end

  on("textDocument/didChange") do |request|
    uri = request.dig(:params, :textDocument, :uri)
    text = request.dig(:params, :contentChanges, 0, :text)
    store[uri] = text

    nil
  end

  on("textDocument/didOpen") do |request|
    uri = request.dig(:params, :textDocument, :uri)
    text = request.dig(:params, :textDocument, :text)
    store[uri] = text

    nil
  end

  on("textDocument/didClose") do |request|
    uri = request.dig(:params, :textDocument, :uri)
    store.delete(uri)

    nil
  end

  on("textDocument/foldingRange") do |request|
    respond_with_folding_ranges(request.dig(:params, :textDocument, :uri))
  end

  on("shutdown") { shutdown }
end

textDocument/didOpen, textDocument/didChange, textDocument/didClose では対象のファイルの uri - contents 対をハッシュとしてインメモリに保存・更新・削除する処理を行なっている。これは大多数の method のリクエストを行う際に uri のみの params で良くするのが狙い。

Add support for document opened, changed and closed events. We need those for every request. We basically keep a hash as storage where the key is the file URI and the value is the content of that file. Most other requests will only send us the URI and so we just access the hash to get the file contents

textDocument/foldingRange に関してはここで Ruby::Lsp::Requests::FoldingRanges クラスという Ruby プログラムのパース、FoldingRange[] の抽出などを行う実装が追加されている。なお textDocument/foldingRange とはその名の通りエディタ内でコード折り畳みをしてくれるあれである。この method にリクエストすることで対象 textDocument 内のどこが折り畳み可能か?それぞれどう折り畳むか?をエディタに知らせることができる。

これはコードを読むのが早いので以下に一部実装を抜粋している。

def initialize(source)
  @parser = SyntaxTree.new(source)
  @queue = [@parser.parse]
  @ranges = []
end

def run
  until @queue.empty?
    node = @queue.shift

    case node
    when SyntaxTree::Def
      location = node.location

      @ranges << LanguageServer::Protocol::Interface::FoldingRange.new(
    start_line: location.start_line - 1,
    end_line: location.end_line - 1,
    kind: "region"
      )
    else
      @queue.unshift(*node.child_nodes.compact)
    end
  end

  @ranges
end

ここではまず syntaxTree (CRubyで利用されている Ripper を wrap したライブラリ) を用いて受け取った Ruby プログラムの文字列をパースしている。その後 SyntaxTree::Def Node を走査して、見つかった各メソッドを折り畳み対象としている。

Store the parsed tree to avoid unnecessary re-parsing by vinistock · Pull Request #13 · Shopify/ruby-lsp · GitHub

10番のPRで追加された Store クラス (uri - contents 対ハッシュをインメモリに保持しているクラス) が contents の代わりに syntax_tree でパース済みの AST を保持するようになった。これで大部分の method でいちいち syntax_tree でパースする必要がなくなった。

Introduce a generic visitor pattern for requests handling by Morriar · Pull Request #19 · Shopify/ruby-lsp · GitHub

AST Node の走査に Visitor パターンが導入された。 このPRではまだ LSP の実装に使われていないが、 https://shopify.engineering/improving-the-developer-experience-with-ruby-lsp を読んでいるなかでも Visitor パターンの言及はあって、例えば以下のような書き方ができるようになっている。

class FoldingRange < SyntaxTree::Visitor
  def initialize(ast)
    @ast = ast
    @ranges = []
  end
  
  def run
    visit(@ast)
    @ranges
  end
  
  def visit_class(node)
    location = node.location
    
    @ranges << {
      startLine: location.start_line - 1,
      endLine: location.end_line - 1,
      kind: "region",
    }
  
    super
  end
  
  def visit_def(node)
    location = node.location
    
    @ranges << {
      startLine: location.start_line - 1,
      endLine: location.end_line - 1,
      kind: "region",
    }
  
    super
  end
end

Enhance code folding by vinistock · Pull Request #20 · Shopify/ruby-lsp · GitHub

FoldingRange が強化された。今まではシンプルに def のみ折り畳み可能だったが、ここでは class や if、elsif など計 17 ケースが新たにサポートされた。 ここからも少しづつサポート範囲が広がっていくが FoldingRange については個人的にあまり関心がないので積極的に拾わないことにする。

Implement DocumentSymbol requests handler by Morriar · Pull Request #21 · Shopify/ruby-lsp · GitHub

textDocument/documentSymbol の実装が追加された。 textDocument/documentSymbol とは与えられた textDocument の中にある symbol の種類や階層関係を取得するためのメソッド。

https://user-images.githubusercontent.com/583144/160193413-5d55a95e-45e2-4f89-97ad-6d72e28ab621.png

例えば symbol の種類は 3.17 時点で以下の種類が存在する。

// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_documentSymbol



/**
 * A symbol kind.
 */
export namespace SymbolKind {
    export const File = 1;
    export const Module = 2;
    export const Namespace = 3;
    export const Package = 4;
    export const Class = 5;
    export const Method = 6;
    export const Property = 7;
    export const Field = 8;
    export const Constructor = 9;
    export const Enum = 10;
    export const Interface = 11;
    export const Function = 12;
    export const Variable = 13;
    export const Constant = 14;
    export const String = 15;
    export const Number = 16;
    export const Boolean = 17;
    export const Array = 18;
    export const Object = 19;
    export const Key = 20;
    export const Null = 21;
    export const EnumMember = 22;
    export const Struct = 23;
    export const Event = 24;
    export const Operator = 25;
    export const TypeParameter = 26;
}

export type SymbolKind = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26;

基本的に syntax_tree がパースした AST Node のクラスと Kind が一致するので前述の Visitor パターンがとても刺さっていた。


長くなってしまったので今回はここまで。次回は https://github.com/Shopify/ruby-lsp/pull/32 から見ていく。

参考資料