🐙

Rustのtokioを使ってLSP, DAPサーバーを書く

2024/04/29に公開

はじめに

https://twitter.com/myuon_myon/status/1784214586390167809
https://twitter.com/myuon_myon/status/1782021458124030163

最近はioliteという言語とそのコンパイラを書いています。
そこで、VSCode上の言語機能やデバッガーの機能を作りたくなったので、それをRustのtokio上で動くLSP,DAPサーバーを書いたのでそれに関する記事です。

(この記事執筆時点のリポジトリ)
https://github.com/myuon/iolite/tree/c306d4d76133fd34d8f02004b8eac096411c4111

LSPとDAPについて

この記事を読んでいる人はLSPについては知っている人が多いと思いますが、LSPはmicrosoftが定める規格で、エディター上のautocompleteや定義ジャンプ、エラーの表示やホバー時の型表示などの機能を提供することができる規格です。
一方でDAPはLSPのデバッガーバージョンのようなもので、VSCodeのデバッガーを起動した時に提供される、ブレイクポイントやステップ実行などの機能について定めた規格です。

いずれもエディターから呼ぶためのプロトコルとして定義されており、通信は必ずしもサーバーを立てて行う必要はないですが、一般的にはサーバーとして起動してソケット通信を行うのが割とよくある実装な気がします。
(VSCode上からは、例えば標準入出力を使ってLSPでやりとりすることも可能です)

LSP
https://microsoft.github.io/language-server-protocol/overviews/lsp/overview/

DAP
https://microsoft.github.io/debug-adapter-protocol/overview

LSPサーバーを立てよう

LSPサーバーは、まずtokioでTCPサーバーの形式で立てます。以下に公式チュートリアルがあるので、サーバー自体の立て方は参考になるでしょう。

https://tokio.rs/tokio/tutorial/io#echo-server

LSPの規格上、メッセージは常にヘッダー部とコンテンツ部の2つからなります。

https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#baseProtocol

メッセージ例

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

ヘッダー部にContent-Lengthがありコンテンツ部のサイズはそれによって指定されます。ということで、サーバーのハンドラーの部分の処理は、「まずヘッダー部をパースしてContent-Lengthを取得する」「指定されたContent-Lengthの長さだけコンテンツ部を取得して、JSON-RPCとしてパースする」の2つの部分からなります。

まずはサーバー起動部分のコード例を示します。
(コードはリポジトリから引いているので、self-containedではないのはご容赦ください)

        let listener = TcpListener::bind(format!("127.0.0.1:{}", port)).await?;
        println!("Listening on http://127.0.0.1:{}", port);

        loop {
            let (stream, _) = listener.accept().await?;
            // ここで受け取ったTcpStreamを処理する部分を書く。
        }

そして受け取ったTcpStreamは以下のようにhandleします。

        let (mut reader, mut writer) = tokio::io::split(stream);

        tokio::spawn(async move {
            loop {
                    let headers = read_headers(&mut reader).await.unwrap();
                    let length = headers
                        .into_iter()
                        .find(|(key, _)| key == "Content-Length")
                        .ok_or(anyhow!("Content-Length is not found in headers"))?
                        .1
                        .parse::<usize>()
                        .map_err(|err| anyhow!("Cannot parse content-length: {}", err))?;

                    let mut content = vec![0; length];
                    reader.read(&mut content).await?;

                    let content_part = String::from_utf8(content)?;

                    // ここで、content_partをパースして、bodyに変換する処理を書く

                    let rcp_event = format!("Content-Length: {}\r\n\r\n{}", body.len(), body);

                    writer.write(rcp_event.as_bytes()).await?;
            }

            Ok::<_, anyhow::Error>(())
        });

内容としては、まずは接続が確立するたびにtokioでspawnします。また受け取ったTcpStreamをreaderとwriterに tokio::io::split で分割しておきます。
その後、1つの接続でヘッダー部のパースとコンテンツ部のパースを無限に待ち受けて繰り返します。ヘッダー部をパースできたらContent-Lengthを取り出して、そのサイズだけreaderからreadし、コンテンツ部を得ます。その後レスポンスを作成したら、今度は逆にヘッダー部とコンテンツ部からなるテキストを作成してwriterに書き込みます。

内容としてはこれだけです。あとは、LSPのコンテンツ部はJSON-RPCになっているのでそれを念頭に置いてrequest-responseの処理を書いていけば良いです。

私はLSPの型定義については以下のcrateを利用しました。

https://crates.io/crates/lsp-types

LSPサーバー内のNotification Messageの処理

上記のコードではリクエストとレスポンスが1:1で、それ以外に通信が発生しない前提でコードを書いていました。しかし、LSPの仕様を読むと、Notification Messageと呼ばれる、対応するレスポンスがなく、ただ一方的に送りつける・送られる種類のメッセージが存在することがわかります。

https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#notificationMessage

これらは、クライアントあるいはサーバーから任意のタイミングで追加の情報を送ったり、返答を要さないメッセージを送るために使われます。

これを処理するためには、requestからresponseを作成する通常のhandlerとは別に、Notificationを好きなタイミングで送れるようにキューイングできると便利です。
そのような用途では tokio::sync::mpsc::channel が便利なので、以下のようなコードが書けるでしょう。

        let (mut reader, mut writer) = tokio::io::split(stream);

        tokio::spawn(async move {
            let (sender, mut receiver) = tokio::sync::mpsc::channel::<String>(100);
            tokio::spawn(async move {
                while let Some(body) = receiver.recv().await {
                    let rcp_event = format!("Content-Length: {}\r\n\r\n{}", body.len(), body);

                    writer.write(rcp_event.as_bytes()).await?;
                }

                Ok::<_, anyhow::Error>(())
            });

            loop { ...

さっきのspawnの最初にchannelを宣言しておき、さらにそのchannelからメッセージを待ち受けるためのタスクをさらに1つspawnすることにしました。
これにより、handler内でresponseを作成しつつ、好きなタイミングでNotificationを送ることができます。

Notificationの例: PublishDiagnostics

Notificationを使う例についても少し紹介しておきましょう。

LSPのDiagnosticsという機能があります。これはソースコード上のコンパイラからのエラーや警告をユーザーにフィードバックする機能です。
この機能は通常 textDocument/didSave などの、「ソースコードの保存や変更」を受けとったLSPサーバー側がPublishDiagnostics Notificationをメッセージとして発行することで実現されます。

https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_publishDiagnostics

実際に私の実装では、lsp_handler内の以下の箇所で、textDocument/didSaveを受け取ってコンパイラを起動し、コンパイルエラーが発生するかみて発生していればPublishDiagnosticsを(ResponseではなくNotificationとして)発行するという処理を実装しています。

https://github.com/myuon/iolite/blob/c306d4d76133fd34d8f02004b8eac096411c4111/src/lsp_server.rs#L198-L302

さて、LSPの細かい言語機能については書き出すとキリがないため、例えば以下の資料などを参考にしてみてください。
(あるいは、気が向けば私もまとめてみようかと思います)

https://zenn.dev/mtshiba/books/language_server_protocol

VSCodeからの呼び出し

さて、LSPサーバーが立てられても、エディター側から呼び出せないと意味がありません。VSCodeの言語拡張の作り方については公式ドキュメントを参考にしてもらうとして、LSPサーバーとの接続は端的に言えば以下の処理だけで実現できます。

// activate内に記載
  const serverOptions = (): Promise<StreamInfo> => {
    let socket = net.connect(3030, "127.0.0.1");

    return Promise.resolve({
      writer: socket,
      reader: socket,
    });
  };

  client = new LanguageClient(
    "iolite",
    "Iolite Language Server",
    serverOptions,
    {
      documentSelector: [{ scheme: "file", language: "iolite" }],
    }
  );

  await client.start();

serverOptionsには Promise<StreamInfo> を与えることができて、ここでnodeの net.connect を呼べば接続ができます。サーバーは事前に立てておきましょう。

VSCodeの拡張機能については以下の公式ドキュメントや他の解説記事を参考にしてみてください。

https://code.visualstudio.com/api/language-extensions/language-server-extension-guide

DAPサーバーを立てよう

さて、今度はLSPではなくDAPサーバーです。
こちらも規格などはLSPとよく似ており、TCPサーバーとして立ててヘッダー部とコンテンツ部をパースするところなどはほとんど一緒です。

また、Requestに対するResponseと、それとは別に任意のタイミングで送ることができるEventという概念があるところなども同じですね。大きな違いはコンテンツ部がJSON-RPCではないことくらいsでしょうか。

DAPサーバーの型定義としては、以下のdapクレートを用いることにしました。

https://crates.io/crates/dap

ステップ実行までの通信

さて、規格のOverviewには、デバッガーを起動してからブレークポイントにヒットするまでの一連の流れが図に示されています。

https://microsoft.github.io/debug-adapter-protocol/overview

その図によると、起動までの一連の流れは以下のようになっています。
ただし、 > はエディターからDAPサーバーへのリクエスト、 < はその返答、 + はイベントを表しています

> req: Initialize
< resp: Initialize: ここでCapabilitiesを返答
+ event: Initialized event
> req: setBreakpoints
< resp: setBreakpoints
> req: setExceptionBreakpoints
< resp: setExceptionBreakpoints
> req: configurationDone
< resp: configurationDone
> req: launch
< resp: launch

しかし、実際にはsetBreakpointsやsetExceptionBreakpointsについては、(LSPでもそうですが)initialize時にどんなCapabilityをサーバーから提示したかによって変化します。

最初はCapabilityは全てoffで通信してみて、やってきたrequestを一つずつ実装する、という順序で実装していました。
また、VSCodeでいろいろ試してみると、どうもこれらのrequestの順序は規格で想定されているものとは異なる場合があるようです。

実際に、手元でやってみてログを吐いたところが以下です。
これをみると、initializeの後にlaunchが即座に来ており、その後setBreakpointsやsetExceptionBreakpointsなどがやってきています。
ので、この辺りは実際に応答などを確認しつつ、臨機応変にやっていくのがよさそうです。

> req: {"command":"initialize","arguments":{"clientID":"vscode","clientName":"Visual St..
< resp: {"command":"initialize","body":{}}..
+ event: {"event":"initialized"}..
> req: {"command":"launch","arguments":{"name":"Iolite debugger","type":"iolite-debugge..
+ event: {"event":"stopped","body":{"reason":"entry","description":"Entry","threadId":1,"..
< resp: {"command":"launch"}..
> req: {"command":"setBreakpoints","arguments":{"source":{"name":"main.io","path":"/Use..
< resp: {"command":"setBreakpoints","body":{"breakpoints":[{"id":0,"verified":true,"mess..
> req: {"command":"setBreakpoints","arguments":{"source":{"name":"test1.io","path":"/Us..
< resp: {"command":"setBreakpoints","body":{"breakpoints":[{"id":0,"verified":true,"mess..
> req: {"command":"threads","type":"request","seq":5}..
< resp: {"command":"threads","body":{"threads":[{"id":1,"name":"main"}]}}..
> req: {"command":"setExceptionBreakpoints","arguments":{"filters":[]},"type":"request"..
< resp: {"command":"setExceptionBreakpoints","body":{}}..
> req: {"command":"stackTrace","arguments":{"threadId":1,"startFrame":0,"levels":20},"t..
...

VSCodeにおける一連の通信例

では、実際にデバッガーを起動し、ステップ実行を繰り返し、その後終了するまでの一連の通信の例を示します。

ここでは、VSCode上でブレークポイントを設定してからF5で起動した時のやりとりを確認します。

> req: initialize
< resp: initialize
+ event: initialized
> req: launch
+ event: stopped ← StoppedReasonをEntryにして一度停止したことをエディターに通知する(こうしないとContinueできなくなったりするので)
< resp: launch ← ここでデバッガーを起動する(ioliteの場合はVMランタイムを起動する)
> req: setBreakpoints ← エディター側で設定されたブレークポイントの情報をもらう。ランタイムに設定
< resp: setBreakpoints
> req: threads
< resp: threads ← スレッド情報を返す。今は一旦メインスレッドの情報のみを固定値で返却
> req: setExceptionBreakpoints
< resp: setExceptionBreakpoints ← これよくわかっていないので、適当に空で返却している
> req: stackTrace
< resp: stackTrace ← スタックトレースの情報。これもエディター上の表示が変わるだけなので適当でもOK
> req: source
< resp: source ← ソースに関する情報
> req: scopes
< resp: scopes ← ローカル変数やグローバル変数に関する情報
> req: next
+ event: stopped ← StoppedReasonをStepにして停止したことをエディターに通知
< resp: next
> req: continue ← ユーザーがContinueボタンを押した
< resp: continue
+ event: terminated
+ event: exited ← プログラムが正常に終了した

流れとしては上記のような感じですが、実際にはこれではbreakpointの情報を正しく使えていなかったり、規格が想定する通信に沿っていなかったりするので、この辺りはもっと調査が必要そうです。

一応、stopped eventを送りつつ、nextが来たらstepを返して1つずつ進めるということはできているし、その時点でやってくるscopesなどをうまく返せばその時点でのスタックポインタの値やプログラムカウンタの表示などはできているので動いているとは言えそうです。

VSCode拡張機能からの起動

上記のデバッガーを起動できるようにするには、VSCodeの拡張機能からDAPサーバーに繋ぐ処理と、ブレークポイントを挟むことができるようにする必要があるのでそれらを最後に解説します。

デバッガーを起動できるようにすることやbreakpointsをかけるようにするには以下の設定を拡張機能のpackage.jsonにしてやる必要があります。

    "debuggers": [
      {
        "type": "iolite-debugger",
        "label": "Iolite Debugger",
        "languages": [
          "iolite"
        ],
        "configurationAttributes": {
          "launch": {
            "properties": {
              "sourceFile": {
                "type": "string",
                "description": "The source file to debug.",
                "default": "${file}"
              }
            }
          }
        }
      }
    ],
    "breakpoints": [
      {
        "language": "iolite"
      }
    ]

また、DAPサーバーとの接続はDebugAdapterServerというクラスがあるのでそれを使えば良さそうです。ポートを指定しておくことでF5時に勝手に繋いでくれます。

class IoliteDebugConfigurationProvider
  implements vscode.DebugConfigurationProvider
{
  provideDebugConfigurations(
    folder: vscode.WorkspaceFolder | undefined,
    token?: vscode.CancellationToken | undefined
  ): vscode.ProviderResult<vscode.DebugConfiguration[]> {
    return [debuggerDefaultConfig];
  }

  resolveDebugConfiguration(
    folder: vscode.WorkspaceFolder | undefined,
    debugConfiguration: vscode.DebugConfiguration,
    token?: vscode.CancellationToken | undefined
  ): vscode.ProviderResult<vscode.DebugConfiguration> {
    return {
      ...debuggerDefaultConfig,
      ...debugConfiguration,
    };
  }
}

class IoliteDebugAdapterDescriptorFactory
  implements vscode.DebugAdapterDescriptorFactory
{
  createDebugAdapterDescriptor(
    session: vscode.DebugSession,
    executable: vscode.DebugAdapterExecutable | undefined
  ): vscode.ProviderResult<vscode.DebugAdapterDescriptor> {
    console.debug("createDebugAdapterDescriptor", session, executable);

    return new vscode.DebugAdapterServer(3031);
  }
}


// activate内に以下を記述
  context.subscriptions.push(
    vscode.debug.registerDebugConfigurationProvider(
      debugType,
      new IoliteDebugConfigurationProvider()
    )
  );
  context.subscriptions.push(
    vscode.debug.registerDebugAdapterDescriptorFactory(
      debugType,
      new IoliteDebugAdapterDescriptorFactory()
    )
  );

また、.vscode/launch.jsonの設定を書いておくことで、F5を押した時に今開発中の拡張機能が有効な状態でVSCodeが起動します。これもやっておくと、開発効率が上がるので便利です。
(これはDAPサーバー関係ないですが)

// A launch configuration that launches the extension inside a new window
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Extension",
      "type": "extensionHost",
      "request": "launch",
      "args": ["--extensionDevelopmentPath=${workspaceFolder}"]
    }
  ]
}

諸々の設定は以下のリポジトリをかなり参考にしました。

https://github.com/vain0x/debug-adapter-examples

おわりに

最近はコンパイラの開発といいつつLSPサーバーとDAPサーバーが多かったですが、tokio上でサーバーを立てる部分やLSPで通信する部分なんかは初めて書いたので、一応まとめてみました。

総じてDAPについては情報がなく、そもそもVSCodeが規格に則っているのかよくわからず(これは規格を緩めにみた方がいいのかもしれませんが、にしても想定されている動きがわからない)、情報も少ないので結構大変ではあります。
ただ、LSPにしてもDAPにしても、充実させられれば自作言語の開発者体験を上げることができて楽しいので、挑戦する人が増えて情報も増えるといいなと思っています。

具体的なLSPの各言語機能の部分やDAPのそれぞれのリクエストの中身までは解説する余裕がなかったので、またどこかでまとめられるとよいかもしれません。

Discussion