NoahOrblog

某・福島にある大学のコンピュータ理工学部の大学生のお話。

Protocol BuffersのLanguage Serverを書いている話

Noahです。この記事はCA20卒アドベントカレンダーの23日目の記事です

adventar.org

かなり前の大学で行われたLT会の際に作ったprotocol buffers用のLanguage Serverについて書きます.

Language Server Protocol

Microsoftの提唱しているエディタによる補完や定義元ジャンプを提供するサーバーとの通信プロトコルです.

microsoft.github.io

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.

簡単に言ってしまえば、今まではEmacsVimVSCodeIntellij IDEAなどで補完や定義元ジャンプするには、従来各エディタの拡張として書く必要があったものを単一のサーバーを使ってプロトコル定義をしてしまえばクライアント側(各エディタで使うクライアント)を実装するだけで各言語に対応できる、というもの

protobuf language server

普段からLSPに何かしら貢献をしたいなと思っていたので作ったものがこちら

github.com

( (未だ)テストすら書いておらずmessageの定義元ジャンプしか実装していない上に雑すぎてgoto使ったりしてるのでvaporwareとしています)

現状とりあえずは私の環境(syntax="proto3", macOS, NeoVim(v0.4.2), vim-lsp)で動く状態なので、その際にひっかかったりしたことについて書きたいと思います

簡単な動作はこのような感じで、message の定義へ飛べるようになっています

実装

LSPではサーバーとの通信に標準入出力を使ってJSON-RPCを使います.

sourcegraph/go-langserverを実装の参考にしつつ書いていますので、sourcegraph製のライブラリを多用しています

github.com

github.com

github.com

メソッド群

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 ブランチを利用しています

github.com

Diffは以下のような感じで、もともとのパーサになかった機能の、トークンの開始位置と終端の位置、行頭からの文字数をカウントする機能を追加するようにしています。現状[]byteで行っているのでマルチバイト文字列が混入するとうまく行かない場合があります

Comparing master...n/master · n04ln/x · GitHub

おわりに

Language Server Protocolのサーバーを作るのは仕組み自体は意外と簡単で、難しいところはパースをする段階や、必要な情報をいかにソースから取得するかの段階だと思います.

個人的にはLSPについて実装をしつつ、なかなかの理解を得られたので満足していますが、補完や自家製パーサーなど実装して実用に耐えうるものにしていきたいのでこれからもやっていくぞ :muscle: