プログラミング開発においてエディタの機能は開発速度に大きく影響してきます。
Visual Studio CodeやNeovimのようなエディタを使用している方は多いと思いますが、リアルタイムでの構文エラーの表示や定義箇所の参照等の機能は開発効率を高めてくれます。
このような機能の背後ではLSP(Language Server Protocol)による、エディタと静的解析ツールとの通信が行われています。
静的解析ツールとの通信はプラグインが行ってくれるため、どのように動いているか普段はあまり意識することはないかもしれませんが、プラグインが期待したように動作しない場合等、仕組みを知っておくことで原因の特定につながる場合があります。
本記事ではLSPについて簡単に説明し、その後に実際のエディタの動きを見ることで、エディタの裏側で動いている仕組みについて触れてみたいと思います。
LSPとは何か
LSPはLanguage Server Protocolの略で、静的解析ツールやリンター、フォーマッターのようなツールとエディタとの連携を行うためのプロトコルです。
LSPが世に出る前はエディタと静的解析ツールとの標準の通信プロトコルは存在しなかったため、エディタごとに独自に静的解析ツールを呼び出しており、静的解析ツールの再利用が難しい状況だったようです。
しかしLSPの登場により、LSPをサポートしているエディタであれば、静的解析ツールを修正することなく再利用が簡単に行えるようになりました。
例えば、以下のような静的解析ツールはVisual Studio CodeやNeovim等LSPをサポートしているエディタから使用可能です。
https://docs.basedpyright.com/latest/
https://clangd.llvm.org/
LSPとエディタ、静的解析ツールとの連携
以下はLSPの公式ドキュメント
https://learn.microsoft.com/ja-jp/visualstudio/extensibility/language-server-protocol?view=vs-2022
に記載されているLSPの通信シーケンス図です。
エディタがDevelopment Tool、静的解析ツールがLanguage Server(LS)に該当します。
ここで記載されているように、LSPでは解析機能を提供するツールが独立したサーバとして動作しており、プロセス間通信でエディタからの要求を受信、結果を返信しています。
例えばNeovimの場合は標準入出力によりLSとの通信を行います。
LSを手動で起動してみる
LSが独立したプロセスとして動作することを見るために、例としてpython向けのLS(basedpyright)を手動で起動してみます。
通常はエディタのプラグインとしてインストールすることが多いかもしれませんが、以下の方法でインストールできます。
1 2 3 4 |
$ python -m venv lsp_test $ cd lsp_test $ source bin/active $ pip install basedpyright |
basedpyrightを実行すると、以下のような出力の後プロセスが入力待ちの状態となっていることが分かります。
1 2 3 4 5 6 |
(python3.9.2) 130 test@atde9:~/work/venv/python3.9.2$ basedpyright-langserver --stdio Content-Length: 123 {"jsonrpc":"2.0","method":"window/logMessage","params":{"type":3,"message":"basedpyright language server 1.29.5 starting"}}Content-Length: 189 {"jsonrpc":"2.0","method":"window/logMessage","params":{"type":3,"message":"Server root directory: file:///home/test/work/venv/python3.9.2/lib/python3.9/site-packages/basedpyright/dist"}} |
ここではjson形式のようなメッセージが出力されていることがわかります。
データフォーマット
LSPの通信メッセージはヘッダ部分とコンテンツ部分とに分かれています。
ヘッダ部分のフィールドは末尾には‘\r\n’を付与します。また、コンテンツ部分との区切りは‘\r\n’となります。
上記ではヘッダ部分にContent-Lengthのみ格納されており、コンテンツ部分は{“jsonrpc”で始まっています。
コンテンツ部分の記述はJSON-RPCを使用しています。コンテンツサイズはヘッダ部分のContent-Lengthで定義されているため、データ末尾に改行は付与されていません。
そのため、 上記出力では
1 |
{"jsonrpc":"2.0","method":"window/logMessage","params":{"type":3,"message":"basedpyright language server 1.29.5 starting"}}Content-Length: 189 |
のように1つ目のメッセージのすぐ後に二つ目のメッセージのContent-Length: 189が続く形となっています。
上記の出力例ではmethodとしてwindow/logMessageが指定されていることが分かります。
以下LSPの仕様を見てみると、これはLS側からクライアントに対してmessageの内容をログとして送信するためのメッセージであることが分かります。
https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#window_logMessage
The log message notification is sent from the server to the client to ask the client to log a particular message.
エディタと解析ツール間のLSPメッセージの流れ
LSが独立したプロセスとして起動することを見ましたが、次に実際にエディタからLSの解析機能を使う場合のメッセージの流れを見てみたいと思います。
これ以降はNeovim(v0.11.0)を使用した場合の例をお見せします。
一般的には解析用のコマンドをショートカットキーに割り当てることが多いと思いますが、操作が分かりやすいようにここではコマンドラインモードから解析機能を実行して説明します。
Neovimではコマンドラインモードで以下のようにLSPのログレベルを設定することで、詳細なLSPの通信ログ出力することができます。
1 |
:lua vim.lsp.set_log_level("TRACE") |
ログの出力先ファイルは以下のコマンドで確認できます。
1 |
:= vim.lsp.get_log_path() |
以下はNeovimからa.pyというファイルをオープンした際のログです。大量のメッセージが流れるため、一部のみ抜粋しています。
1 2 3 |
[TRACE][2025-07-10 16:53:14] ...m/lsp/client.lua:543 "LSP[basedpyright]" "init_params" { capabilities = { general = { positionEncodings = { "utf-8", "utf-16", "utf-32" } }, textDocument = { callHierarchy = { dynamicRegistration = false }, codeAction = { codeActionLiteralSupport = { codeActionKind = { valueSet = { "", "quickfix", "refactor", "refactor.extract", "refactor.inline", "refactor.rewrite", "source", "source.organizeImports" } } }, dataSupport = true, dynamicRegistration = true, isPreferredSupport = true, resolveSupport = { properties = { "edit", "command" } } }, codeLens = { dynamicRegistration = false, resolveSupport = { properties = { "command" } } }, completion = { completionItem = { commitCharactersSupport = false, deprecatedSupport = true, documentationFormat = { "markdown", "plaintext" }, preselectSupport = false, resolveSupport = { properties = { "additionalTextEdits", "command" } }, snippetSupport = true, tagSupport = { valueSet = { 1 } } }, completionItemKind = { valueSet = { 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 } }, completionList = { itemDefaults = { "editRange", "insertTextFormat", "insertTextMode", "data" } }, contextSupport = true, dynamicRegistration = false }, declaration = { linkSupport = true }, definition = { dynamicRegistration = true, linkSupport = true }, diagnostic = { dynamicRegistration = false }, documentHighlight = { dynamicRegistration = false }, documentSymbol = { dynamicRegistration = false, hierarchicalDocumentSymbolSupport = true, symbolKind = { valueSet = { 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 } } }, foldingRange = { dynamicRegistration = false, foldingRange = { collapsedText = true }, lineFoldingOnly = true }, formatting = { dynamicRegistration = true }, hover = { contentFormat = { "markdown", "plaintext" }, dynamicRegistration = true }, implementation = { linkSupport = true }, inlayHint = { dynamicRegistration = true, resolveSupport = { properties = { "textEdits", "tooltip", "location", "command" } } }, publishDiagnostics = { dataSupport = true, relatedInformation = true, tagSupport = { valueSet = { 1, 2 } } }, rangeFormatting = { dynamicRegistration = true, rangesSupport = true }, references = { dynamicRegistration = false }, rename = { dynamicRegistration = true, prepareSupport = true }, semanticTokens = { augmentsSyntaxTokens = true, dynamicRegistration = false, formats = { "relative" }, multilineTokenSupport = false, overlappingTokenSupport = true, requests = { full = { delta = true }, range = false }, serverCancelSupport = false, tokenModifiers = { "declaration", "definition", "readonly", "static", "deprecated", "abstract", "async", "modification", "documentation", "defaultLibrary" }, tokenTypes = { "namespace", "type", "class", "enum", "interface", "struct", "typeParameter", "parameter", "variable", "property", "enumMember", "event", "function", "method", "macro", "keyword", "modifier", "comment", "string", "number", "regexp", "operator", "decorator" } }, signatureHelp = { dynamicRegistration = false, signatureInformation = { activeParameterSupport = true, documentationFormat = { "markdown", "plaintext" }, parameterInformation = { labelOffsetSupport = true } } }, synchronization = { didSave = true, dynamicRegistration = false, willSave = true, willSaveWaitUntil = true }, typeDefinition = { linkSupport = true } }, window = { showDocument = { support = true }, showMessage = { messageActionItem = { additionalPropertiesSupport = true } }, workDoneProgress = true }, workspace = { applyEdit = true, configuration = true, didChangeConfiguration = { dynamicRegistration = false }, didChangeWatchedFiles = { dynamicRegistration = false, relativePatternSupport = true }, inlayHint = { refreshSupport = true }, semanticTokens = { refreshSupport = true }, symbol = { dynamicRegistration = false, symbolKind = { valueSet = { 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 } } }, workspaceEdit = { resourceOperations = { "rename", "create", "delete" } }, workspaceFolders = true } }, clientInfo = { name = "Neovim", version = "0.11.0+v0.11.0" }, processId = 1967525, rootPath = "/home/test/work/", rootUri = "file:///home/test/work/", trace = "off", workDoneToken = "1", workspaceFolders = { { name = "/home/test/work/", uri = "file:///home/test/work/" } } } [TRACE][2025-07-10 16:53:14] ...m/lsp/client.lua:1105 "notification" "window/logMessage" { message = "basedpyright language server 1.28.5 starting", type = 3 } [DEBUG][2025-07-10 16:53:14] .../vim/lsp/rpc.lua:391 "rpc.receive" { jsonrpc = "2.0", method = "window/logMessage", params = { message = "Server root directory: file:///home/test/.local/share/nvim/mason/packages/basedpyright/venv/lib/python3.9/site-packages/basedpyright/dist", type = 3 } } |
ログを確認する準備ができたので、エディタの操作の例として、以下のようなpythonのコード上で作業を行います。
1 2 3 4 5 |
def func(): print("Function f called") def main(): func() |
ここではpythonファイルを開いた際に、Neovimからbasedpyrightを自動起動するように設定しているものとします。
NeovimのLSPの設定については以下のような公式ドキュメントに詳しく記載されているため、設定方法については割愛します。
https://neovim.io/doc/user/lsp.html
例:関数宣言へのジャンプ
ここで5行目のfunc()上にカーソルを置いた状態で以下のコマンドを実行してみます。
このコマンドはカーソル配下のシンボルの宣言箇所へジャンプするコマンドなので、カーソルは1行目の先頭から8文字目に移動します。
1 |
:lua vim.lsp.buf.declaration() |
次にLSPのログを見ると以下のように出力されています。
1 2 |
[DEBUG][2025-07-10 17:42:36] .../vim/lsp/rpc.lua:277 "rpc.send" { id = 382, jsonrpc = "2.0", method = "textDocument/declaration", params = { position = { character = 4, line = 4 }, textDocument = { uri = "file:///home/test/work//a.py" } } } [DEBUG][2025-07-10 17:42:36] .../vim/lsp/rpc.lua:391 "rpc.receive" { id = 382, jsonrpc = "2.0", result = { { range = { ["end"] = { character = 11, line = 0 }, start = { character = 7, line = 0 } }, uri = "file:///home/test/work//a.py" } } } |
エディタからの要求(rpc.send)を見るとmethod=”textDocument/declaration”となっています。
これは以下のLSPメッセージに該当し、指定した箇所にあるシンボルの定義場所の位置を取得するためのメッセージに該当します。
https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_declaration
The go to declaration request is sent from the client to the server to resolve the declaration location of a symbol at a given text document position.
paramsにはメッセージのパラメータが指定されています。
1 |
params = { position = { character = 4, line = 4 }, textDocument = { uri = "file:///home/test/work//a.py" } } |
textDocument内のuriはファイルが格納されている場所となります。
また、position中のlineはファイル先頭からの行数、characterは行先頭からの文字数を示します。
これらは0ベースのため、ここではファイル先頭から5行目の先頭から5文字目を指しており、上記の
1 |
:lua vim.lsp.buf.declaration() |
実行時のカーソル位置と一致していることが分かります。
次にLSからの応答(rpc.receive)を見てみると、
1 |
id = 382 |
となっています。
idは対応する要求のidを示すので、先ほどの要求に対する応答であることが分かります。
応答結果のresultを見ると以下のようになっています。
1 |
result = { { range = { ["end"] = { character = 11, line = 0 }, start = { character = 7, line = 0 } }, uri = "file:///home/test/work//a.py" } } } |
uriで指定されたパスは上記のa.pyを示しており、startは1行目の8文字目、endは1行目の12文字目を示していますが、この範囲がfuncの定義位置と一致していることが分かります。
LSPによる再利用性の向上
これまでの流れからNeovimは定義箇所へジャンプしたいシンボルの位置情報をLSへ送信し、それに対してLSは定義箇所の位置情報を応答として返していることが分かります。
その後にNeovimが応答結果に該当する箇所にカーソルを移動させています。
ここで重要なのはこれら要求、応答の仕様はLSPで定義されており、Neovimと今回の例で使用した静的解析ツール(basedpyright)固有のフォーマットではないということです。
例えば、
1 |
method = "textDocument/declaration" |
をサポートしているエディタであれば、同じようにLSを使用して定義箇所へジャンプすることができます。
また、今回はシンボルの定義箇所へジャンプする機能のみを見てみましたが、ホバーによる情報表示や自動補完等、その他の開発を効率化してくれる機能についても同様となります。
このように、LSPをサポートしているエディタやLSであれば、組み合わせを変えてもお互いを意識せずに動作することができるため、エディタ間で解析ツールの再利用が可能となります。
まとめ
本記事ではエディタの背後で動作しているLSPの仕組みについて簡単ですが見てみました。
LSPは一般的にはプログラミング言語用に使用されることが多いように思いますが、それ以外にも例えば、systemdのユニットファイル用のLSや
https://github.com/JFryy/systemd-lsp
nginxの設定用のLSも存在します。
https://github.com/pappasam/nginx-language-server
LSPの仕組みが分かれば色々な用途向けにLSを実装することも可能となります。
使用したいLSがまだ存在していない場合、自分で作成してみることで開発効率を上げることができるかもしれません。