クラウドワークス エンジニアブログ

日本最大級のクラウドソーシング「クラウドワークス」の開発の裏側をお届けするエンジニアブログ

LSPってなんだ〜?

はじめに

クラウドワークスエージェント(旧ビズアシ)事業のエンジニアをしている山田です。
先日RubyKaigi2025に参加しました。オフラインは去年の沖縄に続き、二度目の参加です。
前回、LSP / Parser関連の話が印象に残っていて、今回それ関連のセッションを多めに取ったと思います。
ただ、ふわっとした理解のままで臨んでしまったので、あまり頭に入ってこなかったです。

ということで今回改めてLSPについて調べてみました。

LSPとは

LSP(language server protocol)は「定義参照」などの言語機能を提供する言語サーバーとIDEなどのクライアントとのやりとりを標準化したものです。

IDE拡張機能IDEの内部構造に密結合する形になるため、IDEの数だけ開発が必要となりますが、LSPがあることで、言語サーバーを切り出し、クライアント側ではプロトコルに従って言語サーバーとのやりとり+αを実装するだけで良くなります。

LSPはJSON RPC v2.0形式を採用しており、サポートしている機能は、解析対象のソースコードの追加や、エディタでの編集内容を送るなどの言語サーバーが機能するために必要なものと、自動補完や定義参照などの言語サーバーが提供する機能そのものの大きく二つがあります。

例)

  • textDocument/didChange: ドキュメントの編集
  • textDocument/completion: 自動補完
  • textDocument/definition: シンボルの定義位置を取得

参照: 言語サーバー プロトコル拡張機能の追加 - Visual Studio (Windows) | Microsoft Learn

プロトコルで定義された機能が全てIDEや言語サーバーで提供されるとは限りません。
それぞれが提供している機能についてはinitialize時にcapabilitesとして互いに通知します。
参照: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initialize

基本的な仕組み

言語サーバーは別プロセスとして実行され、ユーザーによるエディタ上での操作をトリガーにIDEなどのクライアントと言語サーバーはプロトコルを使用して通信します。

例)

実際のメッセージ内容については、例えば、textDocument/definition(定義参照)の場合、リクエストとレスポンスは以下のような形になります。

リクエス

{
    "jsonrpc": "2.0",
    "id" : 1,
    "method": "textDocument/definition",
    "params": {
        "textDocument": {
            "uri": "file:///p%3A/mseng/VSCode/Playgrounds/cpp/use.cpp"
        },
        "position": {
            "line": 3,
            "character": 12
        }
    }
}

レスポンス

{
    "jsonrpc": "2.0",
    "id": "1",
    "result": {
        "uri": "file:///p%3A/mseng/VSCode/Playgrounds/cpp/provide.cpp",
        "range": {
            "start": {
                "line": 0,
                "character": 4
            },
            "end": {
                "line": 0,
                "character": 11
            }
        }
    }
}

textDocument/definitionはAPIのエンドポイントのURLのようにも見えますが、実際にはmethodの値として言語サーバーに渡すものです。また、その他必要な情報をparamsとして渡します。

LSPでやりとりするデータの属性(?)については特徴があります。
ファイルのURIや何行目の何文字目かなどエディタレベルのものを使うことで、プログラミング言語の違いの影響を受けず、プロトコルを簡素化できるという利点があります。

プロトコルの詳細

実際のメッセージはヘッダーとコンテンツで構成されています。
先ほど例示したリクエストとレスポンスはコンテンツ部分のみ抽出したものです。

ヘッダーはHTTPの仕様に準拠しており、現在サポートされているのは、Content-LengthとContent-Typeのみです。

メッセージの全体構成は以下のようになります。

例)

Content-Length: ...\r\n
\r\n
{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "textDocument/completion",
    "params": {
        ...
    }
}

メッセージはすべてのメッセージのベースとなる要約、リクエストとレスポンス、レスポンスのない通知の四種類が存在します。

ざっくり言語サーバーを動かすのに必要なmethodは「通知」、言語機能を提供するmethodは「リクエスト、レスポンス」でやりとりされる感じでしょうか。

言語サーバーとは

LSPについてもう少し深掘るか迷いましたが、仕様ドキュメントの二番煎じにしかならなさそうなので、いったん概要程度に止めることにします。

ここまでLSPについて調べた中でも、「言語サーバーってどこでどうやって動いてるの?」「メッセージの内容は分かったけど、エンドポイントは?どういう経路で通信してるの?」といったところがイマイチ分からなかったため、言語サーバーについても調べることにしました。

改めて言語サーバーとは定義参照や自動補完などの言語機能を提供するサービスです。
提供機能や実装内容、処理などについては今回は調べていません。

言語サーバーはクライアントとは別のプロセス(厳密には公式ドキュメントでは独自プロセスとあり、おそらく子プロセスと思われる)で実行されます。
言語分析は大抵CPUとメモリの使用量が多いため、別プロセスにすることでパフォーマンスコストを削減できます。

言語サーバーの起動は以下のタイミングで行われるようです。

  • エディタが起動し、該当言語の拡張機能が有効になっているとき
  • 言語に対応したソースコードファイルを開いたとき
  • プロジェクト全体の解析が必要になったとき
  • ユーザーが明示的に言語サーバーの機能を呼び出したとき

VSCodeで検証してみましたが、Rubyプロジェクトを開いたときに起動したであろうことを確認しました。
キャプチャ間に合わなかったのですが、最初にinitializeが走り、index化が走っています。

また、プロセスはこんな感じでした。 プロジェクトを開いたタイミングで言語サーバーが起動されてそうです。

% ps aux | grep ruby   
87571   0.0  0.1 411118528  43440   ??  S     4:00PM   0:00.56 ~/.rbenv/versions/3.1.6/bin/ruby bin/rails runner /Users/yuki.yamada/code/bizasstjp/vendor/bundle/ruby/3.1.0/gems/ruby-lsp-rails-0.4.2/lib/ruby_lsp/ruby_lsp_rails/server.rb start {"supports_progress":true}

クライアントと言語サーバーとの通信はプロセス間通信であり、VSCodeの言語サーバーは標準入出力を使っているものが多いです。
他にもソケット通信といった方法がありますが、標準入出力はほぼすべてのOSでサポートされており、ネットワーク設定やポートの開放が不要なのでファイアーウォールの影響を受けにくい、という利点があります。

クライアント側の言語サーバーとの接続に関するインターフェースの一部分についての一例はこのように展開されています。

public async Task<Connection> ActivateAsync(CancellationToken token)
{
    await Task.Yield();

    ProcessStartInfo info = new ProcessStartInfo();
    info.FileName = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "Server", @"MockLanguageServer.exe");
    info.Arguments = "bar";
    info.RedirectStandardInput = true;
    info.RedirectStandardOutput = true;
    info.UseShellExecute = false;
    info.CreateNoWindow = true;

    Process process = new Process();
    process.StartInfo = info;

    if (process.Start())
    {
        return new Connection(process.StandardOutput.BaseStream, process.StandardInput.BaseStream);
    }

    return null;
}

言語サーバーの標準入出力使ってるんだなということが分かりますね。

おわりに

LSPとはなにか調べるところから、その過程で生じた疑問を潰していく流れでまとめてしまったので、体系的な理解はしづらい構成になってるかもしれませんが、私としてはLSP周りの理解は進んだかなと思います。

今回は言語サーバー内部に立ち入らなかったですし、Parserについては触れることもできなかったので、次回のRubyKaigiまでに頭に入れておきたいなと思います。

参考

言語サーバー プロトコルの概要 - Visual Studio (Windows) | Microsoft Learn Official page for Language Server Protocol Language Server Extension Guide | Visual Studio Code Extension API language server protocolについて (前編) #VSCode - Qiita

We're hiring!

クラウドワークス では、働き方の変革に挑戦するエンジニアを募集しています! 個性豊かで、愉快なエンジニアがお待ちしています。まずは気軽にお話してみませんか? www.wantedly.com

© 2016 CrowdWorks, Inc., All rights reserved.