Protocol BuffersのLanguage Serverを書いている話
Noahです。この記事はCA20卒アドベントカレンダーの23日目の記事です
かなり前の大学で行われたLT会の際に作ったprotocol buffers用のLanguage Serverについて書きます.
Language Server Protocol
Microsoftの提唱しているエディタによる補完や定義元ジャンプを提供するサーバーとの通信プロトコルです.
The Language Server Protocol (LSP) defines the protocol used between an editor or IDE and a language server that provides language features like auto complete, go to definition, find all references etc.
簡単に言ってしまえば、今まではEmacsやVimやVSCode、Intellij IDEAなどで補完や定義元ジャンプするには、従来各エディタの拡張として書く必要があったものを単一のサーバーを使ってプロトコル定義をしてしまえばクライアント側(各エディタで使うクライアント)を実装するだけで各言語に対応できる、というもの
protobuf language server
普段からLSPに何かしら貢献をしたいなと思っていたので作ったものがこちら
( (未だ)テストすら書いておらずmessageの定義元ジャンプしか実装していない上に雑すぎてgoto使ったりしてるのでvaporwareとしています)
現状とりあえずは私の環境(syntax="proto3", macOS, NeoVim(v0.4.2), vim-lsp)で動く状態なので、その際にひっかかったりしたことについて書きたいと思います
簡単な動作はこのような感じで、message
の定義へ飛べるようになっています
実装
LSPではサーバーとの通信に標準入出力を使ってJSON-RPCを使います.
sourcegraph/go-langserverを実装の参考にしつつ書いていますので、sourcegraph製のライブラリを多用しています
メソッド群
LSPはサーバーなので、たくさんのメソッドを定義する必要があります。
JSON-RPCで通信する際にはGraphQLのように実質エンドポイントひとつのところへ、実行するメソッド名をリクエストボディに内包する形でリクエストをするので、ペイロードのなかにどのメソッドを呼ぶか示したパラメータから以下のようにswitchで条件分岐できるように実装します
switch req.Method { case "initialize": ... return ... case "initialized": log.L().Info("invoked initialized method") ... return ... case "textDocument/didOpen": ... ...
initialize メソッド
initializeメソッドは、最初に初期化する際に利用されます。この際、どの機能が提供可能か(ここでいう XXProvider
)をBoolで返します
protobuf language serverでは型(というかmessage)の定義元ジャンプしか使わないので以下のような感じになっています
// ref. https://github.com/n04ln/protobuf_langserver/blob/master/langserver/init.go#L28 kind := lsp.TDSKIncremental res := lsp.InitializeResult{ Capabilities: lsp.ServerCapabilities{ TextDocumentSync: &lsp.TextDocumentSyncOptionsOrKind{ Kind: &kind, }, CompletionProvider: nil, DefinitionProvider: true, TypeDefinitionProvider: false, DocumentFormattingProvider: false, DocumentSymbolProvider: false, HoverProvider: false, ReferencesProvider: false, RenameProvider: false, WorkspaceSymbolProvider: false, ImplementationProvider: false, XWorkspaceReferencesProvider: false, XDefinitionProvider: false, XWorkspaceSymbolByProperties: false, SignatureHelpProvider: &lsp.SignatureHelpOptions{TriggerCharacters: []string{"(", ","}}, }, }
ところでmessageなのにType Definitionではないの?と思うかもしれませんが、Type Definitionにしなかったのは、
message A { int64 a = 1; } message B { A| a = 1; // ^ cursor is here }
な場合に利用したかったので単純にDefinitionにしています.
textDocument/didChange, textDocument/didOpen メソッド
テキストファイルを変更したときと、開いたときに発火します
今回、後述するprotobufのパースを行うために、定義元ジャンプの際に毎回行っていては無駄なので、ここでフックしてパースしています
protobuf_langserver/langserver.go at master · n04ln/protobuf_langserver · GitHub
protobuf_langserver/langserver.go at master · n04ln/protobuf_langserver · GitHub
textDocument/definition メソッド
実際に定義元情報を返すメソッドがこれです. 内部では、パースされて保持しているASTに対してWalkをして探索し、見つかったものを返しています
今回、パースする際にはmyitcv氏のモノレポにあるprotobufパーサーに必要情報を付与させる形で利用しており、そのためにフォークした n/master ブランチを利用しています
Diffは以下のような感じで、もともとのパーサになかった機能の、トークンの開始位置と終端の位置、行頭からの文字数をカウントする機能を追加するようにしています。現状[]byteで行っているのでマルチバイト文字列が混入するとうまく行かない場合があります
Comparing master...n/master · n04ln/x · GitHub
おわりに
Language Server Protocolのサーバーを作るのは仕組み自体は意外と簡単で、難しいところはパースをする段階や、必要な情報をいかにソースから取得するかの段階だと思います.
個人的にはLSPについて実装をしつつ、なかなかの理解を得られたので満足していますが、補完や自家製パーサーなど実装して実用に耐えうるものにしていきたいのでこれからもやっていくぞ :muscle: