HN 日本語サマリー

← 一覧へ戻る
プログラミング

Rust LSPの構築がいかに難しいか

Why building a Rust LSP is hard (rust-glancer.github.io)

23 pointsby agluszak11 コメント

要約

この記事は、Rustの言語サーバープロトコル(LSP)を構築する際の複雑さと課題について解説しています。著者は、Rust Glancerという実験的なLSPを開発する経験から、LSPの初期化、ファイルシステムへのアクセス、非同期処理、そしてクエリの並列実行といった、一見簡単に見えるタスクがいかに困難になりうるかを、rust-analyzerの内部構造にも触れながら説明しています。LSPは完全な情報がなくても迅速に有用な応答を提供する必要があり、そのためのアーキテクチャ的なアプローチが論じられています。

全文翻訳

ずっと昔、偉大な matklad が Rust のツールがどのように機能するかについての素晴らしい投稿を書いていました。あれは素晴らしい時代でしたが、残念ながら、rust-analyzer の最後のブログ投稿は 2023 年のものです。私は matklad ではありませんが、しばらくの間、実験的な Rust LSP である Rust Glancer を構築しています。これはおそらく私が取り組んだ中で最も面白く野心的なプロジェクトであり、その作業中に学んだことをいくつか共有したいと思います。これは、rust-analyzer と Rust Glancer の両方の視点から、Rust LSP がどのように機能するかについての(うまくいけば一貫性のある)物語になるでしょう。簡単に見えることがいかに難しくなり、難しいことがさらに難しくなり、そしてまったく存在しないと思っていたことがどうにかして存在するようになるか。明らかに、1つのブログ記事ですべてを網羅することはできません。これは、途中で提起された特定のトピックを深く掘り下げるというよりも、非常に技術的でありながらもアーキテクチャ的な概要になります。それらは、私が怠惰でなければ、個別の投稿として提供されるでしょう。そうでなければ、LSP の構築は部分的な情報から有用な回答を生成する必要があるという共通点を持つ、多くの逸話的な章に備えてください。免責事項: 私は LSP の構築の専門家ではありません。この記事の目的は、読者の関心を LSP の内部構造と注意点に向けさせることであり、曖昧さのない正式な設計概要を提供することではありません。意図的にコンパイラ jargon を使用しないようにし、多くの場所で近似的な表現を使用して、精度よりも全体的な意味に焦点を当てています。記事には、より詳細で正確な情報源への多くのリンクがあり、それらをチェックすることをお勧めします!また、この記事の準備中および準備中に rust-analyzer のコードをたくさん読みましたが、私は rust-analyzer のメンテナーではありません。もし何か間違っていたら、ごめんなさい。 LSP はどこから始まるのでしょうか? LSP サーバーには 2 つの反対側の端があります。1 つは Language Server Protocol を実装するサーバー(つまり、「リクエストを送信して整形式の応答を受け取ることができるもの」)であり、もう 1 つは提供したい実際の状態(つまり、「送信されたクエリが実際に必要なことを行い、何らかのインデックス付き状態を操作する」)です。前者は解決済みの問題のように思えますよね? 特に tower-lsp-server が存在することを考えると。さて、実際にはそうではありません。そこから始め、実際に必要になったら徐々にインデックス作成に進みましょう。 LSP は、クライアントが initialize リクエストを送信することから始まります。これには LSP を初期化する必要があります(ふむ)。応答するまで、クライアントは何も行いません。応答すると、initialized 通知が送信され、すべてがうまくいき、LSP 通信が開始されます。問題は、このリクエストにいつ応答するかということです。サーバーが起動したとき、あなたは何も持っていません。プロジェクトについて何も知りません。このリクエストを受け取って初めて、私たちが話しているコードベースが何であるかを知ることができます。そして、実際にクエリに答えるためには、それを「インデックス™」する必要があります。インデックス作成が何を意味するのかはまだわかりませんが、それは確かに多くの作業です。すべてをインデックス作成するまでブロックしますか? そうすると、ユーザーはエディタがほとんど役に立たない状態で、10〜20〜50〜100 秒の待ち時間を楽しむことになります。選択肢ではありません。すぐに開始しますか? しかし、その場合、現在開いているファイルに関する最初のクエリに何と答えるのでしょうか? それは代わりにそこでブロックされる原因にはならないでしょうか? 恐ろしい「すべてをインデックス作成する必要がある」問題を回避するにはどうすればよいでしょうか? そして、これはコンパイラと LSP の間の最初の大きな違いを明らかにします。コンパイラには、完了の定義がかなり二項的です。バイナリ(すみません)はコンパイルされているか、されていないかのどちらかです。技術的には、コンパイルされた共有ライブラリやその他のビルド成果物も使用可能ですが、実際には、コンパイラがワークスペースの 715 個中 716 個のクレートをコンパイルして停止した場合、あなたは迷惑することでしょう。LSP は異なります。ほぼ即座に有用な結果を提供できます。最初のミリ秒で完全な情報が必要なわけではありません。できるだけ早くユーザーに何か有用なものを送信する必要があります。必要なのは、「何か有用なもの」と数えるものを決定することだけです。 元の質問への答えは次のとおりです。クエリの処理を可能にするために必要な最小限の有用な作業を行う必要があります。したがって、rust-analyzer と Rust Glancer は、提供された設定を検証して応答するだけです。rust-analyzer は応答後にワークスペースの検出をスケジュールしますが、Rust Glancer は最初のクエリがヒットするまで受動的です。 ハンドシェイクが完了すると、実際の取引が始まります。つまり、最初の実際のクエリを受け取ります。ほとんどの場合、これらは textDocument/didOpen、textDocument/inlayHint、textDocument/documentSymbol になるでしょう。ユーザーが熱心で、あなたが不運な場合、これらの間に textDocument/didChange が入る可能性さえあります。そして、複雑さが爆発します。 まず、面白い事実ですが、LSP プロトコルはファイルシステムについて考えることを望んでいません。ファイルシステムはなく、ドキュメントと編集があるだけです。それは理にかなっています。多くの場合、ドキュメントは保存されていないため、その内容を知ることはできません。ただし、そうではありません。ほとんどの言語では、単一のファイル(または開いているファイルのセット)を孤立して分析しても、すぐに有用ではなくなります。 地獄へようこそ:LSP は自分が真実の情報源であると想定していますが、それでも自分でファイルシステムにアクセスする必要があり、同期的に行う必要があります。さらに面白くするために、編集はエディタの外で発生する可能性があり、クライアントはこれらのイベントの通知にあまり忠実ではない場合があります。そして、だからこそ、仮想ファイルシステムと「ソース生成」、つまり現在実行中のリクエストの時点でソースコードの状態の識別子が必要なのです。ファイルシステムへのアクセスと LSP 通知を素朴に組み合わせようとすると、プロジェクト全体が永遠に続く競合状態になります。代わりに、プロジェクトソースをメモリにロードし、それを VFS と宣言し、このロードされた状態の上に任意の変更を適用しようと最善を尽くします。状態を変更するたびに、ソース生成を更新します。これにより、一貫した内部状態を維持でき(そして、無効になったインフライトクエリをキャンセルできます)。 「インフライトクエリとは何ですか?」とあなたは尋ねるでしょう。そして、それが 2 番目の面白い事実です。LSP クエリの実行には、驚くほどの作業が必要になる場合があり、すべてのクエリが同じように作られているわけではありません。シンボルの参照を探すのはかなり複雑なタスクですが、ホバーは通常安価です。したがって、一度に 1 つのクエリを実行することは選択肢ではありません。読み取りクエリを並列で実行する必要があります。そして、何かが状態を変更すると、現在実行中のクエリは、現在時代遅れの状態に対して無駄な作業を行うことになります。あなたの仕事は、変更クエリと非変更クエリを分離し、読み取り要求を並列で実行させ、状態が変更されたら作業をキャンセルするループを作成することです。 また、運が悪く async を使用している場合は、メッセージの受信をシリアル化して、didOpen と didChange が逆の順序で来ないようにする必要があります。これはデバッグするのが非常に困難な問題になる可能性があります(なぜこのコメントが必要なのか不思議です)。 3 番目で最後の面白い事実は、LSP の作成者は、あなたが準備ができていない可能性があることを実際に考慮しており、workspace/inlayHint/refresh サーバーリクエストのような便利なツールを提供してくれたことです。このような強力なツールを使用すると、「おっと、もう一度やり直してください」と言って、最初に何も送信しなかった場合でも、実際の応答を送信できます。問題は、すべてをリフレッシュできるわけではないということです。ドキュメントシンボルはできません。それらをすぐに送信しないと、クライアント自体が再度要求する時が来たと判断するまで、それらは古くなります。これは、一部のクエリでは創造的になる必要があることを意味します。 しかし、私たちは脇道にそれてしまいました。クライアントはインレイヒントとドキュメントシンボルを待っています。そして、私たちはまだ何もインデックス作成していません。どうしますか? サーバー、サービスします 幸運なことに、ドキュメントシンボルに答えるためには、実際に 1 つのものをインデックス作成する必要があります。現在開いているファイルです。そして、これは実際に LSP が非常に迅速に役立つことの完璧な例です。この要求に応答するために必要なのは、ファイルを解析することだけです。AST(あるいはむしろ CST、しかしそれについては後で説明します)は、どの構造、トレイト、関数、メソッドなどがあるかをすでに教えてくれます。リクエストの一部としてオンデマンドで実行することもできます。 fn foo(a: Bar) {} の Bar に対する textDocument/hover は少しトリッキーです。少なくとも何らかの形式のセマンティック分析が必要です。